[CalendarServer-changes] [10135] CalendarServer/trunk
source_changes at macosforge.org
source_changes at macosforge.org
Fri Dec 7 10:26:55 PST 2012
Revision: 10135
http://trac.calendarserver.org//changeset/10135
Author: cdaboo at apple.com
Date: 2012-12-07 10:26:55 -0800 (Fri, 07 Dec 2012)
Log Message:
-----------
Update standard timezone service to latest spec.
Modified Paths:
--------------
CalendarServer/trunk/twext/web2/http.py
CalendarServer/trunk/twistedcaldav/ical.py
CalendarServer/trunk/twistedcaldav/test/test_timezonestdservice.py
CalendarServer/trunk/twistedcaldav/timezonestdservice.py
Modified: CalendarServer/trunk/twext/web2/http.py
===================================================================
--- CalendarServer/trunk/twext/web2/http.py 2012-12-07 17:50:39 UTC (rev 10134)
+++ CalendarServer/trunk/twext/web2/http.py 2012-12-07 18:26:55 UTC (rev 10135)
@@ -32,6 +32,7 @@
"""
# import traceback; log.msg(''.join(traceback.format_stack()))
+import json
import time
from twisted.internet import interfaces, error
@@ -50,13 +51,13 @@
log = Logger()
-defaultPortForScheme = {'http': 80, 'https':443, 'ftp':21}
+defaultPortForScheme = {'http': 80, 'https': 443, 'ftp': 21}
def splitHostPort(scheme, hostport):
- """Split the host in "host:port" format into host and port fields.
+ """Split the host in "host:port" format into host and port fields.
If port was not specified, use the default for the given scheme, if
known. Returns a tuple of (hostname, portnumber)."""
-
+
# Split hostport into host and port
hostport = hostport.split(':', 1)
try:
@@ -67,6 +68,7 @@
return hostport[0], defaultPortForScheme.get(scheme, 0)
+
def parseVersion(strversion):
"""Parse version strings of the form Protocol '/' Major '.' Minor. E.g. 'HTTP/1.1'.
Returns (protocol, major, minor).
@@ -80,7 +82,9 @@
return (proto.lower(), major, minor)
+
class HTTPError(Exception):
+
def __init__(self, codeOrResponse):
"""An Exception for propagating HTTP Error Responses.
@@ -91,28 +95,30 @@
self.response = iweb.IResponse(codeOrResponse)
Exception.__init__(self, str(self.response))
+
def __repr__(self):
return "<%s %s>" % (self.__class__.__name__, self.response)
+
class Response(object):
"""An object representing an HTTP Response to be sent to the client.
"""
implements(iweb.IResponse)
-
+
code = responsecode.OK
headers = None
stream = None
-
+
def __init__(self, code=None, headers=None, stream=None):
"""
@param code: The HTTP status code for this Response
@type code: C{int}
-
+
@param headers: Headers to be sent to the client.
- @type headers: C{dict}, L{twext.web2.http_headers.Headers}, or
+ @type headers: C{dict}, L{twext.web2.http_headers.Headers}, or
C{None}
-
+
@param stream: Content body to send to the HTTP client
@type stream: L{twext.web2.stream.IByteStream}
"""
@@ -123,13 +129,14 @@
if headers is not None:
if isinstance(headers, dict):
headers = http_headers.Headers(headers)
- self.headers=headers
+ self.headers = headers
else:
self.headers = http_headers.Headers()
if stream is not None:
self.stream = IByteStream(stream)
+
def __repr__(self):
if self.stream is None:
streamlen = None
@@ -139,6 +146,7 @@
return "<%s.%s code=%d, streamlen=%s>" % (self.__module__, self.__class__.__name__, self.code, streamlen)
+
class StatusResponseElement(Element):
"""
Render the HTML for a L{StatusResponse}
@@ -216,10 +224,11 @@
self.headers.setHeader("location", location)
-
+
+
def NotModifiedResponse(oldResponse=None):
if oldResponse is not None:
- headers=http_headers.Headers()
+ headers = http_headers.Headers()
for header in (
# Required from sec 10.3.5:
'date', 'etag', 'content-location', 'expires',
@@ -232,8 +241,9 @@
else:
headers = None
return Response(code=responsecode.NOT_MODIFIED, headers=headers)
-
+
+
def checkPreconditions(request, response=None, entityExists=True, etag=None, lastModified=None):
"""Check to see if this request passes the conditional checks specified
by the client. May raise an HTTPError with result codes L{NOT_MODIFIED}
@@ -246,7 +256,7 @@
However, if you are implementing other request methods, like PUT
for your resource, you will need to call this after determining
the etag and last-modified time of the existing resource but
- before actually doing the requested action. In that case,
+ before actually doing the requested action. In that case,
This examines the appropriate request headers for conditionals,
(If-Modified-Since, If-Unmodified-Since, If-Match, If-None-Match,
@@ -259,15 +269,15 @@
shouldn't be separately specified. Not providing the
response with a GET request may cause the emitted
"Not Modified" responses to be non-conformant.
-
+
@param entityExists: Set to False if the entity in question doesn't
yet exist. Necessary for PUT support with 'If-None-Match: *'.
-
+
@param etag: The etag of the resource to check against, or None.
-
+
@param lastModified: The last modified date of the resource to check
against, or None.
-
+
@raise: HTTPError: Raised when the preconditions fail, in order to
abort processing and emit an error page.
@@ -280,7 +290,8 @@
return False
etag = response.headers.getHeader("etag")
lastModified = response.headers.getHeader("last-modified")
-
+
+
def matchETag(tags, allowWeak):
if entityExists and '*' in tags:
return True
@@ -322,7 +333,7 @@
if inm:
if request.method in ("HEAD", "GET"):
# If it's a range request, don't allow a weak ETag, as that
- # would break.
+ # would break.
canBeWeak = not request.headers.hasHeader('Range')
if notModified != False and matchETag(inm, canBeWeak):
raise HTTPError(NotModifiedResponse(response))
@@ -333,6 +344,8 @@
if notModified == True:
raise HTTPError(NotModifiedResponse(response))
+
+
def checkIfRange(request, response):
"""Checks for the If-Range header, and if it exists, checks if the
test passes. Returns true if the server should return partial data."""
@@ -347,25 +360,29 @@
return ifrange == response.headers.getHeader("last-modified")
+
class _NotifyingProducerStream(stream.ProducerStream):
doStartReading = None
def __init__(self, length=None, doStartReading=None):
stream.ProducerStream.__init__(self, length=length)
self.doStartReading = doStartReading
-
+
+
def read(self):
if self.doStartReading is not None:
doStartReading = self.doStartReading
self.doStartReading = None
doStartReading()
-
+
return stream.ProducerStream.read(self)
+
def write(self, data):
self.doStartReading = None
stream.ProducerStream.write(self, data)
+
def finish(self):
self.doStartReading = None
stream.ProducerStream.finish(self)
@@ -379,18 +396,18 @@
Subclasses should override the process() method to determine how
the request will be processed.
-
+
@ivar method: The HTTP method that was used.
@ivar uri: The full URI that was requested (includes arguments).
@ivar headers: All received headers
@ivar clientproto: client HTTP version
@ivar stream: incoming data stream.
"""
-
+
implements(iweb.IRequest, interfaces.IConsumer)
-
+
known_expects = ('100-continue',)
-
+
def __init__(self, chanRequest, command, path, version, contentLength, headers):
"""
@param chanRequest: the channel request we're associated with.
@@ -399,16 +416,17 @@
self.method = command
self.uri = path
self.clientproto = version
-
+
self.headers = headers
-
+
if '100-continue' in self.headers.getHeader('expect', ()):
doStartReading = self._sendContinue
else:
doStartReading = None
self.stream = _NotifyingProducerStream(contentLength, doStartReading)
self.stream.registerProducer(self.chanRequest, True)
-
+
+
def checkExpect(self):
"""Ensure there are no expectations that cannot be met.
Checks Expect header against self.known_expects."""
@@ -416,50 +434,59 @@
for expect in expects:
if expect not in self.known_expects:
raise HTTPError(responsecode.EXPECTATION_FAILED)
-
+
+
def process(self):
"""Called by channel to let you process the request.
-
+
Can be overridden by a subclass to do something useful."""
pass
-
+
+
def handleContentChunk(self, data):
"""Callback from channel when a piece of data has been received.
Puts the data in .stream"""
self.stream.write(data)
-
+
+
def handleContentComplete(self):
"""Callback from channel when all data has been received. """
self.stream.unregisterProducer()
self.stream.finish()
-
+
+
def connectionLost(self, reason):
"""connection was lost"""
pass
+
def __repr__(self):
- return '<%s %s %s>'% (self.method, self.uri, self.clientproto)
+ return '<%s %s %s>' % (self.method, self.uri, self.clientproto)
+
def _sendContinue(self):
self.chanRequest.writeIntermediateResponse(responsecode.CONTINUE)
+
def _reallyFinished(self, x):
"""We are finished writing data."""
self.chanRequest.finish()
-
+
+
def _finished(self, x):
"""
We are finished writing data.
But we need to check that we have also finished reading all data as we
might have sent a, for example, 401 response before we read any data.
To make sure that the stream/producer sequencing works properly we need
- to discard the remaining data in the request.
+ to discard the remaining data in the request.
"""
if self.stream.length != 0:
return readAndDiscard(self.stream).addCallback(self._reallyFinished).addErrback(self._error)
else:
self._reallyFinished(x)
+
def _error(self, reason):
if reason.check(error.ConnectionLost):
log.msg("Request error: " + reason.getErrorMessage())
@@ -467,7 +494,8 @@
log.err(reason)
# Only bother with cleanup on errors other than lost connection.
self.chanRequest.abortConnection()
-
+
+
def writeResponse(self, response):
"""
Write a response.
@@ -476,10 +504,10 @@
# Expect: 100-continue was requested, but 100 response has not been
# sent, and there's a possibility that data is still waiting to be
# sent.
- #
+ #
# Ideally this means the remote side will not send any data.
# However, because of compatibility requirements, it might timeout,
- # and decide to do so anyways at the same time we're sending back
+ # and decide to do so anyways at the same time we're sending back
# this response. Thus, the read state is unknown after this.
# We must close the connection.
self.chanRequest.channel.setReadPersistent(False)
@@ -493,7 +521,7 @@
elif response.stream.length is not None:
response.headers.setHeader('content-length', response.stream.length)
self.chanRequest.writeHeaders(response.code, response.headers)
-
+
# if this is a "HEAD" request, or a special response code,
# don't return any data.
if self.method == "HEAD" or response.code in NO_BODY_CODES:
@@ -501,10 +529,12 @@
response.stream.close()
self._finished(None)
return
-
+
d = stream.StreamProducer(response.stream).beginProducing(self.chanRequest)
d.addCallback(self._finished).addErrback(self._error)
+
+
class XMLResponse (Response):
"""
XML L{Response} object.
@@ -517,8 +547,21 @@
Response.__init__(self, code, stream=element.toxml())
self.headers.setHeader("content-type", http_headers.MimeType("text", "xml"))
-
+
+
+class JSONResponse (Response):
+ """
+ JSON L{Response} object.
+ Renders itself as an JSON document.
+ """
+ def __init__(self, code, jobj):
+ """
+ @param xml_responses: an iterable of davxml.Response objects.
+ """
+ Response.__init__(self, code, stream=json.dumps(jobj))
+ self.headers.setHeader("content-type", http_headers.MimeType("application", "json"))
+
+
components.registerAdapter(Response, int, iweb.IResponse)
-__all__ = ['HTTPError', 'NotModifiedResponse', 'Request', 'Response', 'StatusResponse', 'RedirectResponse', 'checkIfRange', 'checkPreconditions', 'defaultPortForScheme', 'parseVersion', 'splitHostPort', "XMLResponse"]
-
+__all__ = ['HTTPError', 'NotModifiedResponse', 'Request', 'Response', 'StatusResponse', 'RedirectResponse', 'checkIfRange', 'checkPreconditions', 'defaultPortForScheme', 'parseVersion', 'splitHostPort', "XMLResponse", "JSONResponse"]
Modified: CalendarServer/trunk/twistedcaldav/ical.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/ical.py 2012-12-07 17:50:39 UTC (rev 10134)
+++ CalendarServer/trunk/twistedcaldav/ical.py 2012-12-07 18:26:55 UTC (rev 10135)
@@ -3136,23 +3136,23 @@
if tzexpanded:
if start != tzexpanded[0][0]:
results.append((
- str(start),
- PyCalendarUTCOffsetValue(tzexpanded[0][1]).getText(),
- PyCalendarUTCOffsetValue(tzexpanded[0][1]).getText(),
+ start,
+ tzexpanded[0][1],
+ tzexpanded[0][1],
tzexpanded[0][3],
))
else:
results.append((
- str(start),
- PyCalendarUTCOffsetValue(tzcomp._pycalendar.getTimezoneOffsetSeconds(start)).getText(),
- PyCalendarUTCOffsetValue(tzcomp._pycalendar.getTimezoneOffsetSeconds(start)).getText(),
- tzcomp.getTZName(),
+ start,
+ tzcomp._pycalendar.getTimezoneOffsetSeconds(start),
+ tzcomp._pycalendar.getTimezoneOffsetSeconds(start),
+ tzcomp._pycalendar.getTimezoneDescriptor(start),
))
for tzstart, tzoffsetfrom, tzoffsetto, name in tzexpanded:
results.append((
- tzstart.getText(),
- PyCalendarUTCOffsetValue(tzoffsetfrom).getText(),
- PyCalendarUTCOffsetValue(tzoffsetto).getText(),
+ tzstart,
+ tzoffsetfrom,
+ tzoffsetto,
name,
))
Modified: CalendarServer/trunk/twistedcaldav/test/test_timezonestdservice.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/test/test_timezonestdservice.py 2012-12-07 17:50:39 UTC (rev 10134)
+++ CalendarServer/trunk/twistedcaldav/test/test_timezonestdservice.py 2012-12-07 18:26:55 UTC (rev 10135)
@@ -15,7 +15,7 @@
##
from twistedcaldav.timezones import TimezoneCache
-from twistedcaldav.timezonestdservice import TimezoneInfo,\
+from twistedcaldav.timezonestdservice import TimezoneInfo, \
PrimaryTimezoneDatabase
from xml.etree.ElementTree import Element
import hashlib
@@ -28,13 +28,13 @@
"""
def test_generateXML(self):
-
+
hashed = hashlib.md5("test").hexdigest()
info = TimezoneInfo("America/New_York", ("US/Eastern",), "20110517T120000Z", hashed)
-
+
node = Element("root")
info.generateXML(node)
-
+
timezone = node.find("timezone")
self.assertTrue(timezone is not None)
self.assertEqual(timezone.findtext("tzid"), "America/New_York")
@@ -42,15 +42,16 @@
self.assertEqual(timezone.findtext("alias"), "US/Eastern")
self.assertEqual(timezone.findtext("md5"), hashed)
+
def test_parseXML(self):
-
+
hashed = hashlib.md5("test").hexdigest()
info1 = TimezoneInfo("America/New_York", ("US/Eastern",), "20110517T120000Z", hashed)
-
+
node = Element("root")
info1.generateXML(node)
timezone = node.find("timezone")
-
+
info2 = TimezoneInfo.readXML(timezone)
self.assertEqual(info2.tzid, "America/New_York")
@@ -58,6 +59,8 @@
self.assertEqual(info2.dtstamp, "20110517T120000Z")
self.assertEqual(info2.md5, hashed)
+
+
class TestPrimaryTimezoneDatabase (twistedcaldav.test.util.TestCase):
"""
Timezone support tests
@@ -66,21 +69,23 @@
def setUp(self):
TimezoneCache.create()
+
def testCreate(self):
-
+
xmlfile = self.mktemp()
db = PrimaryTimezoneDatabase(TimezoneCache.getDBPath(), xmlfile)
-
+
db.createNewDatabase()
self.assertTrue(os.path.exists(xmlfile))
self.assertTrue(db.dtstamp is not None)
self.assertTrue(len(db.timezones) > 0)
+
def testUpdate(self):
-
+
xmlfile = self.mktemp()
db = PrimaryTimezoneDatabase(TimezoneCache.getDBPath(), xmlfile)
-
+
db.createNewDatabase()
self.assertTrue(os.path.exists(xmlfile))
@@ -88,8 +93,9 @@
self.assertTrue(db.changeCount == 0)
self.assertTrue(len(db.changed) == 0)
+
def testRead(self):
-
+
xmlfile = self.mktemp()
db1 = PrimaryTimezoneDatabase(TimezoneCache.getDBPath(), xmlfile)
db1.createNewDatabase()
@@ -99,50 +105,54 @@
db2.readDatabase()
self.assertEqual(db1.dtstamp, db2.dtstamp)
self.assertEqual(len(db1.timezones), len(db2.timezones))
-
+
+
def testList(self):
-
+
xmlfile = self.mktemp()
db = PrimaryTimezoneDatabase(TimezoneCache.getDBPath(), xmlfile)
db.createNewDatabase()
self.assertTrue(os.path.exists(xmlfile))
-
+
tzids = set([tz.tzid for tz in db.listTimezones(None)])
self.assertTrue("America/New_York" in tzids)
self.assertTrue("US/Eastern" not in tzids)
-
+
+
def testListChangedSince(self):
-
+
xmlfile = self.mktemp()
db = PrimaryTimezoneDatabase(TimezoneCache.getDBPath(), xmlfile)
db.createNewDatabase()
self.assertTrue(os.path.exists(xmlfile))
-
+
tzids = set([tz.tzid for tz in db.listTimezones(db.dtstamp)])
self.assertTrue(len(tzids) == 0)
-
+
+
def testGetNone(self):
-
+
xmlfile = self.mktemp()
db = PrimaryTimezoneDatabase(TimezoneCache.getDBPath(), xmlfile)
db.createNewDatabase()
self.assertTrue(os.path.exists(xmlfile))
-
+
tz = db.getTimezone("Bogus")
self.assertEqual(tz, None)
-
+
+
def testGetOne(self):
-
+
xmlfile = self.mktemp()
db = PrimaryTimezoneDatabase(TimezoneCache.getDBPath(), xmlfile)
db.createNewDatabase()
self.assertTrue(os.path.exists(xmlfile))
-
+
# Original
tz1 = db.getTimezone("America/New_York")
self.assertTrue(str(tz1).find("VTIMEZONE") != -1)
self.assertTrue(str(tz1).find("TZID:America/New_York") != -1)
-
+
# Alias
tz1 = db.getTimezone("US/Eastern")
self.assertTrue(str(tz1).find("VTIMEZONE") != -1)
Modified: CalendarServer/trunk/twistedcaldav/timezonestdservice.py
===================================================================
--- CalendarServer/trunk/twistedcaldav/timezonestdservice.py 2012-12-07 17:50:39 UTC (rev 10134)
+++ CalendarServer/trunk/twistedcaldav/timezonestdservice.py 2012-12-07 18:26:55 UTC (rev 10135)
@@ -27,30 +27,27 @@
from twext.python.log import Logger
from twext.web2 import responsecode
-from txdav.xml import element as davxml
-from twext.web2.dav.http import ErrorResponse
from twext.web2.dav.method.propfind import http_PROPFIND
from twext.web2.dav.noneprops import NonePropertyStore
-from twext.web2.http import HTTPError, StatusResponse
+from twext.web2.http import HTTPError, JSONResponse
from twext.web2.http import Response
-from twext.web2.http import XMLResponse
from twext.web2.http_headers import MimeType
from twext.web2.stream import MemoryStream
+from txdav.xml import element as davxml
-from twisted.internet.defer import succeed, inlineCallbacks, returnValue,\
+from twisted.internet.defer import succeed, inlineCallbacks, returnValue, \
DeferredList
-from twistedcaldav import timezonexml, xmlutil
+from twistedcaldav import xmlutil
from twistedcaldav.client.geturl import getURL
from twistedcaldav.config import config
-from twistedcaldav.customxml import calendarserver_namespace
-from twistedcaldav.extensions import DAVResource,\
+from twistedcaldav.extensions import DAVResource, \
DAVResourceWithoutChildrenMixin
from twistedcaldav.ical import tzexpandlocal
from twistedcaldav.resource import ReadOnlyNoCopyResourceMixIn
-from twistedcaldav.timezones import TimezoneException, TimezoneCache, readVTZ,\
+from twistedcaldav.timezones import TimezoneException, TimezoneCache, readVTZ, \
addVTZ
-from twistedcaldav.xmlutil import addSubElement, readXMLString
+from twistedcaldav.xmlutil import addSubElement
from pycalendar.calendar import PyCalendar
from pycalendar.datetime import PyCalendarDateTime
@@ -58,6 +55,7 @@
import hashlib
import itertools
+import json
import os
log = Logger()
@@ -69,7 +67,6 @@
Extends L{DAVResource} to provide timezone service functionality.
"""
-
def __init__(self, parent):
"""
@param parent: the parent resource of this one.
@@ -80,6 +77,8 @@
self.parent = parent
self.expandcache = {}
+ self.primary = True
+ self.info_source = None
if config.TimezoneService.Mode == "primary":
log.info("Using primary Timezone Service")
@@ -89,7 +88,8 @@
self._initSecondaryService()
else:
raise ValueError("Invalid TimezoneService mode: %s" % (config.TimezoneService.Mode,))
-
+
+
def _initPrimaryService(self):
tzpath = TimezoneCache.getDBPath()
xmlfile = os.path.join(tzpath, "timezones.xml")
@@ -98,9 +98,11 @@
self.timezones.createNewDatabase()
else:
self.timezones.readDatabase()
+ self.info_source = "Primary"
+
def _initSecondaryService(self):
-
+
# Must have writeable paths
tzpath = TimezoneCache.getDBPath()
xmlfile = config.TimezoneService.XMLInfoPath
@@ -111,24 +113,32 @@
self.timezones.readDatabase()
except:
pass
+ self.info_source = "Secondary"
+ self.primary = False
+
def onStartup(self):
return self.timezones.onStartup()
+
def deadProperties(self):
if not hasattr(self, "_dead_properties"):
self._dead_properties = NonePropertyStore(self)
return self._dead_properties
+
def etag(self):
return succeed(None)
+
def checkPreconditions(self, request):
return None
+
def checkPrivileges(self, request, privileges, recurse=False, principal=None, inherited_aces=None):
return succeed(None)
+
def defaultAccessControlList(self):
return davxml.ACL(
# DAV:Read for all principals (includes anonymous)
@@ -141,21 +151,27 @@
),
)
+
def contentType(self):
- return MimeType.fromString("text/html; charset=utf-8");
+ return MimeType.fromString("text/html; charset=utf-8")
+
def resourceType(self):
return None
+
def isCollection(self):
return False
+
def isCalendarCollection(self):
return False
+
def isPseudoCalendarCollection(self):
return False
+
def render(self, request):
output = """<html>
<head>
@@ -176,10 +192,11 @@
"""
The timezone service POST method.
"""
-
+
# GET and POST do the same thing
return self.http_POST(request)
+
def http_POST(self, request):
"""
The timezone service POST method.
@@ -187,109 +204,99 @@
# Check authentication and access controls
def _gotResult(_):
-
+
if not request.args:
# Do normal GET behavior
return self.render(request)
-
+
action = request.args.get("action", ("",))
if len(action) != 1:
- raise HTTPError(StatusResponse(
+ raise HTTPError(JSONResponse(
responsecode.BAD_REQUEST,
- "Invalid action query parameter",
+ {
+ "error": "invalid-action",
+ "description": "Invalid action query parameter",
+ },
))
action = action[0]
-
+
action = {
"capabilities" : self.doCapabilities,
"list" : self.doList,
"get" : self.doGet,
"expand" : self.doExpand,
}.get(action, None)
-
+
if action is None:
- raise HTTPError(StatusResponse(
+ raise HTTPError(JSONResponse(
responsecode.BAD_REQUEST,
- "Unknown action query parameter",
+ {
+ "error": "invalid-action",
+ "description": "Unknown action query parameter",
+ },
))
-
+
return action(request)
-
+
d = self.authorize(request, (davxml.Read(),))
d.addCallback(_gotResult)
return d
+
def doCapabilities(self, request):
"""
Return a list of all timezones known to the server.
"""
-
- result = timezonexml.Capabilities(
-
- timezonexml.Operation(
- timezonexml.Action.fromString("capabilities"),
- timezonexml.Description.fromString("Get capabilities of the server"),
- ),
-
- timezonexml.Operation(
- timezonexml.Action.fromString("list"),
- timezonexml.Description.fromString("List timezones on the server"),
- timezonexml.AcceptParameter(
- timezonexml.Name.fromString("changedsince"),
- timezonexml.Required.fromString("false"),
- timezonexml.Multi.fromString("false"),
- ),
- ),
-
- timezonexml.Operation(
- timezonexml.Action.fromString("get"),
- timezonexml.Description.fromString("Get timezones from the server"),
- timezonexml.AcceptParameter(
- timezonexml.Name.fromString("format"),
- timezonexml.Required.fromString("false"),
- timezonexml.Multi.fromString("false"),
- timezonexml.Value.fromString("text/calendar"),
- timezonexml.Value.fromString("text/plain"),
- ),
- timezonexml.AcceptParameter(
- timezonexml.Name.fromString("tzid"),
- timezonexml.Required.fromString("true"),
- timezonexml.Multi.fromString("false"),
- ),
- ),
-
- timezonexml.Operation(
- timezonexml.Action.fromString("expand"),
- timezonexml.Description.fromString("Expand timezones from the server"),
- timezonexml.AcceptParameter(
- timezonexml.Name.fromString("tzid"),
- timezonexml.Required.fromString("true"),
- timezonexml.Multi.fromString("false"),
- ),
- timezonexml.AcceptParameter(
- timezonexml.Name.fromString("start"),
- timezonexml.Required.fromString("false"),
- timezonexml.Multi.fromString("false"),
- ),
- timezonexml.AcceptParameter(
- timezonexml.Name.fromString("end"),
- timezonexml.Required.fromString("false"),
- timezonexml.Multi.fromString("false"),
- ),
- ),
- )
- return XMLResponse(responsecode.OK, result)
+ result = {
+ "info" : {
+ "primary-source" if self.primary else "secondary_source": self.info_source,
+ "contacts" : [],
+ },
+ "actions" : [
+ {
+ "name": "capabilities",
+ "parameters": [],
+ },
+ {
+ "name": "list",
+ "parameters": [
+ {"name": "changedsince", "required": False, "multi": False, },
+ ],
+ },
+ {
+ "name": "get",
+ "parameters": [
+ {"name": "format", "required": False, "multi": False, "values": ["text/calendar", "text/plain", ], },
+ {"name": "tzid", "required": True, "multi": False, },
+ ],
+ },
+ {
+ "name": "expand",
+ "parameters": [
+ {"name": "tzid", "required": True, "multi": False, },
+ {"name": "start", "required": False, "multi": False, },
+ {"name": "end", "required": False, "multi": False, },
+ ],
+ },
+ ]
+ }
+ return JSONResponse(responsecode.OK, result)
+
+
def doList(self, request):
"""
Return a list of all timezones known to the server.
"""
-
+
changedsince = request.args.get("changedsince", ())
if len(changedsince) > 1:
- raise HTTPError(StatusResponse(
+ raise HTTPError(JSONResponse(
responsecode.BAD_REQUEST,
- "Invalid changedsince query parameter",
+ {
+ "error": "invalid-changedsince",
+ "description": "Invalid changedsince query parameter",
+ },
))
if len(changedsince) == 1:
# Validate a date-time stamp
@@ -297,58 +304,67 @@
try:
dt = PyCalendarDateTime.parseText(changedsince)
except ValueError:
- raise HTTPError(StatusResponse(
+ raise HTTPError(JSONResponse(
responsecode.BAD_REQUEST,
- "Invalid changedsince query parameter value",
+ {
+ "error": "invalid-changedsince",
+ "description": "Invalid changedsince query parameter",
+ },
))
if not dt.utc():
- raise HTTPError(StatusResponse(
+ raise HTTPError(JSONResponse(
responsecode.BAD_REQUEST,
"Invalid changedsince query parameter value",
))
-
timezones = []
for tz in self.timezones.listTimezones(changedsince):
- timezones.append(
- timezonexml.Summary(
- timezonexml.Tzid.fromString(tz.tzid),
- timezonexml.LastModified.fromString(tz.dtstamp),
- *tuple([timezonexml.Alias.fromString(alias) for alias in tz.aliases])
- )
- )
- result = timezonexml.TimezoneList(
- timezonexml.Dtstamp.fromString(self.timezones.dtstamp),
- *timezones
- )
- return XMLResponse(responsecode.OK, result)
+ timezones.append({
+ "tzid": tz.tzid,
+ "last-modified": tz.dtstamp,
+ "aliases": tz.aliases,
+ })
+ result = {
+ "dtstamp": self.timezones.dtstamp,
+ "timezones": timezones,
+ }
+ return JSONResponse(responsecode.OK, result)
+
def doGet(self, request):
"""
Return the specified timezone data.
"""
-
+
tzids = request.args.get("tzid", ())
if len(tzids) != 1:
- raise HTTPError(StatusResponse(
+ raise HTTPError(JSONResponse(
responsecode.BAD_REQUEST,
- "Invalid tzid query parameter",
+ {
+ "error": "invalid-tzid",
+ "description": "Invalid tzid query parameter",
+ },
))
format = request.args.get("format", ("text/calendar",))
if len(format) != 1 or format[0] not in ("text/calendar", "text/plain",):
- raise HTTPError(StatusResponse(
+ raise HTTPError(JSONResponse(
responsecode.BAD_REQUEST,
- "Invalid format query parameter",
+ {
+ "error": "invalid-format",
+ "description": "Invalid format query parameter",
+ },
))
format = format[0]
calendar = self.timezones.getTimezone(tzids[0])
if calendar is None:
- raise HTTPError(ErrorResponse(
+ raise HTTPError(JSONResponse(
responsecode.NOT_FOUND,
- (calendarserver_namespace, "invalid-tzid"),
- "Tzid could not be found",
+ {
+ "error": "missing-tzid",
+ "description": "Tzid could not be found",
+ }
))
tzdata = calendar.getText()
@@ -358,6 +374,7 @@
response.headers.setHeader("content-type", MimeType.fromString("%s; charset=utf-8" % (format,)))
return response
+
def doExpand(self, request):
"""
Expand a timezone within specified start/end dates.
@@ -365,9 +382,12 @@
tzids = request.args.get("tzid", ())
if len(tzids) != 1:
- raise HTTPError(StatusResponse(
+ raise HTTPError(JSONResponse(
responsecode.BAD_REQUEST,
- "Invalid tzid query parameter",
+ {
+ "error": "invalid-tzid",
+ "description": "Invalid tzid query parameter",
+ },
))
try:
@@ -381,10 +401,12 @@
start.setDay(1)
start.setMonth(1)
except ValueError:
- raise HTTPError(ErrorResponse(
+ raise HTTPError(JSONResponse(
responsecode.BAD_REQUEST,
- (calendarserver_namespace, "valid-start-date"),
- "Invalid start query parameter",
+ {
+ "error": "invalid-start",
+ "description": "Invalid start query parameter",
+ }
))
try:
@@ -401,59 +423,59 @@
if end <= start:
raise ValueError()
except ValueError:
- raise HTTPError(ErrorResponse(
+ raise HTTPError(JSONResponse(
responsecode.BAD_REQUEST,
- (calendarserver_namespace, "valid-end-date"),
- "Invalid end query parameter",
+ {
+ "error": "invalid-end",
+ "description": "Invalid end query parameter",
+ }
))
- results = []
-
tzid = tzids[0]
tzdata = self.timezones.getTimezone(tzid)
if tzdata is None:
- raise HTTPError(ErrorResponse(
+ raise HTTPError(JSONResponse(
responsecode.NOT_FOUND,
- (calendarserver_namespace, "invalid-tzid"),
- "Tzid could not be found",
+ {
+ "error": "missing-tzid",
+ "description": "Tzid could not be found",
+ }
))
-
+
# Now do the expansion (but use a cache to avoid re-calculating TZs)
observances = self.expandcache.get((tzid, start, end), None)
if observances is None:
observances = tzexpandlocal(tzdata, start, end)
self.expandcache[(tzid, start, end)] = observances
- # Turn into XML
- results.append(timezonexml.Tzdata(
- timezonexml.Tzid.fromString(tzid),
- *[
- timezonexml.Observance(
- timezonexml.Name(name),
- timezonexml.Onset(onset),
- timezonexml.UTCOffsetFrom(utc_offset_from),
- timezonexml.UTCOffsetTo(utc_offset_to),
- ) for onset, utc_offset_from, utc_offset_to, name in observances
- ]
- ))
-
- result = timezonexml.Timezones(
- timezonexml.Dtstamp.fromString(self.timezones.dtstamp),
- *results
- )
- return XMLResponse(responsecode.OK, result)
+ # Turn into JSON
+ result = {
+ "dtstamp": self.timezones.dtstamp,
+ "observances": [
+ {
+ "name": name,
+ "onset": onset.getXMLText(),
+ "utc-offset-from": utc_offset_from,
+ "utc-offset-to": utc_offset_to,
+ } for onset, utc_offset_from, utc_offset_to, name in observances
+ ],
+ }
+ return JSONResponse(responsecode.OK, result)
+
+
class TimezoneInfo(object):
"""
Maintains information from an on-disk store of timezone files.
"""
-
+
def __init__(self, tzid, aliases, dtstamp, md5):
self.tzid = tzid
self.aliases = aliases
self.dtstamp = dtstamp
self.md5 = md5
-
+
+
@classmethod
def readXML(cls, node):
"""
@@ -466,7 +488,8 @@
aliases = tuple([alias_node.text for alias_node in node.findall("alias")])
md5 = node.findtext("md5")
return cls(tzid, aliases, dtstamp, md5)
-
+
+
def generateXML(self, parent):
"""
Generate the XML element for this timezone info.
@@ -478,11 +501,13 @@
xmlutil.addSubElement(node, "alias", alias)
xmlutil.addSubElement(node, "md5", self.md5)
+
+
class CommonTimezoneDatabase(object):
"""
Maintains the database of timezones read from an XML file.
"""
-
+
def __init__(self, basepath, xmlfile):
self.basepath = basepath
self.xmlfile = xmlfile
@@ -490,9 +515,11 @@
self.timezones = {}
self.aliases = {}
+
def onStartup(self):
return succeed(None)
+
def readDatabase(self):
"""
Read in XML data.
@@ -507,22 +534,24 @@
for alias in tz.aliases:
self.aliases[alias] = tz.tzid
+
def listTimezones(self, changedsince):
"""
List timezones (not aliases) possibly changed since a particular dtstamp.
"""
-
- for tzid, tzinfo in sorted(self.timezones.items(), key=lambda x:x[0]):
+
+ for tzid, tzinfo in sorted(self.timezones.items(), key=lambda x: x[0]):
# Ignore those that are aliases
if tzid in self.aliases:
continue
-
+
# Detect timestamp changes
if changedsince and tzinfo.dtstamp <= changedsince:
continue
-
+
yield tzinfo
+
def getTimezone(self, tzid):
"""
Generate a PyCalendar containing the requested timezone.
@@ -533,7 +562,7 @@
vtz = readVTZ(tzid)
calendar.addComponent(vtz.getComponents()[0].duplicate())
except TimezoneException:
-
+
# Check if an alias exists and create data for that
if tzid in self.aliases:
try:
@@ -552,32 +581,37 @@
return calendar
+
def _dumpTZs(self):
-
+
_ignore, root = xmlutil.newElementTreeWithRoot("timezones")
addSubElement(root, "dtstamp", self.dtstamp)
- for _ignore,v in sorted(self.timezones.items(), key=lambda x:x[0]):
+ for _ignore, v in sorted(self.timezones.items(), key=lambda x: x[0]):
v.generateXML(root)
xmlutil.writeXML(self.xmlfile, root)
-
+
+
def _buildAliases(self):
"""
Rebuild aliases mappings from current tzinfo.
"""
-
+
self.aliases = {}
for tzinfo in self.timezones.values():
for alias in tzinfo.aliases:
self.aliases[alias] = tzinfo.tzid
+
+
class PrimaryTimezoneDatabase(CommonTimezoneDatabase):
"""
Maintains the database of timezones read from an XML file.
"""
-
+
def __init__(self, basepath, xmlfile):
super(PrimaryTimezoneDatabase, self).__init__(basepath, xmlfile)
+
def createNewDatabase(self):
"""
Create a new DB xml file from scratch by scanning zoneinfo.
@@ -587,6 +621,7 @@
self._scanTZs("")
self._dumpTZs()
+
def _scanTZs(self, path, checkIfChanged=False):
# Read in all timezone files first
for item in os.listdir(os.path.join(self.basepath, path)):
@@ -601,7 +636,7 @@
except IOError:
log.error("Unable to read timezone file: %s" % (fullPath,))
continue
-
+
if checkIfChanged:
oldtz = self.timezones.get(tzid)
if oldtz != None and oldtz.md5 == md5:
@@ -609,14 +644,14 @@
self.changeCount += 1
self.changed.add(tzid)
self.timezones[tzid] = TimezoneInfo(tzid, (), self.dtstamp, md5)
-
+
# Try links (aliases) file
try:
aliases = open(os.path.join(self.basepath, "links.txt")).read()
except IOError, e:
log.error("Unable to read links.txt file: %s" % (str(e),))
aliases = ""
-
+
try:
for alias in aliases.splitlines():
alias_from, alias_to = alias.split()
@@ -630,8 +665,8 @@
log.error("Missing alias from '%s' to '%s'" % (alias_from, alias_to,))
except ValueError:
log.error("Unable to parse links.txt file: %s" % (str(e),))
-
-
+
+
def updateDatabase(self):
"""
Update existing DB info by comparing md5's.
@@ -643,11 +678,13 @@
if self.changeCount:
self._dumpTZs()
+
+
class SecondaryTimezoneDatabase(CommonTimezoneDatabase):
"""
Caches a database of timezones from another timezone service.
"""
-
+
def __init__(self, basepath, xmlfile, uri):
super(SecondaryTimezoneDatabase, self).__init__(basepath, xmlfile)
self.uri = uri
@@ -658,22 +695,24 @@
if not os.path.exists(self.basepath):
os.makedirs(self.basepath)
-
+
# Paths need to be writeable
if not os.access(basepath, os.W_OK):
raise ValueError("Secondary Timezone Service needs writeable zoneinfo path at: %s" % (basepath,))
if os.path.exists(xmlfile) and not os.access(xmlfile, os.W_OK):
raise ValueError("Secondary Timezone Service needs writeable xmlfile path at: %s" % (xmlfile,))
+
def onStartup(self):
return self.syncWithServer()
+
@inlineCallbacks
def syncWithServer(self):
"""
Sync local data with that from the server we are replicating.
"""
-
+
log.debug("Sync'ing with secondary server")
result = (yield self._getTimezoneListFromServer())
if result is None:
@@ -681,18 +720,18 @@
log.debug("No changes on secondary server")
returnValue(None)
newdtstamp, newtimezones = result
-
+
# Compare timezone infos
-
+
# New ones on the server
newtzids = set(newtimezones.keys()) - set(self.timezones.keys())
-
+
# Check for changes
changedtzids = set()
for tzid in set(newtimezones.keys()) & set(self.timezones.keys()):
if self.timezones[tzid].dtstamp < newtimezones[tzid].dtstamp:
changedtzids.add(tzid)
-
+
log.debug("Fetching %d new, %d changed timezones on secondary server" % (len(newtzids), len(changedtzids),))
# Now apply changes - do requests in parallel for speedier fetching
@@ -708,15 +747,16 @@
self._buildAliases()
log.debug("Sync with secondary server complete")
-
+
returnValue((len(newtzids), len(changedtzids),))
-
+
+
@inlineCallbacks
def _discoverServer(self):
"""
Make sure we know the timezone service path
"""
-
+
if self.uri is None:
if config.TimezoneService.SecondaryService.Host:
self.uri = "https://%s/.well-known/timezone" % (config.TimezoneService.SecondaryService.Host,)
@@ -724,7 +764,7 @@
self.uri = config.TimezoneService.SecondaryService.URI
elif not self.uri.startswith("https:") and not self.uri.startswith("http:"):
self.uri = "https://%s/.well-known/timezone" % (self.uri,)
-
+
testURI = "%s?action=capabilities" % (self.uri,)
log.debug("Discovering secondary server: %s" % (testURI,))
response = (yield getURL(testURI))
@@ -732,29 +772,30 @@
log.error("Unable to discover secondary server: %s" % (testURI,))
self.discovered = False
returnValue(False)
-
+
# Cache the redirect target
if hasattr(response, "location"):
- self.uri = response.location
+ self.uri = response.location
log.debug("Redirected secondary server to: %s" % (self.uri,))
# TODO: Ignoring the data from capabilities for now
self.discovered = True
returnValue(True)
-
+
+
@inlineCallbacks
def _getTimezoneListFromServer(self):
"""
Retrieve the timezone list from the specified server
"""
-
+
# Make sure we have the server
if not self.discovered:
result = (yield self._discoverServer())
if not result:
returnValue(None)
-
+
# List all from the server
url = "%s?action=list" % (self.uri,)
if self.dtstamp:
@@ -763,26 +804,30 @@
response = (yield getURL(url))
if response is None or response.code / 100 != 2:
returnValue(None)
-
+
ct = response.headers.getRawHeaders("content-type", ("bogus/type",))[0]
ct = ct.split(";", 1)
ct = ct[0]
- if ct not in ("application/xml", "text/xml",):
+ if ct not in ("application/json",):
returnValue(None)
-
- etroot, _ignore = readXMLString(response.data, timezonexml.TimezoneList.sname())
- dtstamp = etroot.findtext(timezonexml.Dtstamp.sname())
- timezones = {}
- for summary in etroot.findall(timezonexml.Summary.sname()):
- tzid = summary.findtext(timezonexml.Tzid.sname())
- lastmod = summary.findtext(timezonexml.LastModified.sname())
- aliases = tuple([alias_node.text for alias_node in summary.findall(timezonexml.Alias.sname())])
- timezones[tzid] = TimezoneInfo(tzid, aliases, lastmod, None)
+ try:
+ jroot = json.loads(response.data)
+ dtstamp = jroot["dtstamp"]
+ timezones = {}
+ for timezone in jroot["timezones"]:
+ tzid = timezone["tzid"]
+ lastmod = timezone["last-modified"]
+ aliases = timezone.get("aliases", ())
+ timezones[tzid] = TimezoneInfo(tzid, aliases, lastmod, None)
+ except ValueError, KeyError:
+ log.debug("Failed to parse JSON timezone list response: %s" % (response.data,))
+ returnValue(None)
log.debug("Got %s timezones from secondary server" % (len(timezones),))
-
+
returnValue((dtstamp, timezones,))
+
@inlineCallbacks
def _getTimezoneFromServer(self, tzinfo):
# List all from the server
@@ -791,14 +836,14 @@
response = (yield getURL(url))
if response is None or response.code / 100 != 2:
returnValue(None)
-
+
ct = response.headers.getRawHeaders("content-type", ("bogus/type",))[0]
ct = ct.split(";", 1)
ct = ct[0]
if ct not in ("text/calendar",):
log.error("Invalid content-type '%s' for tzid : %s" % (ct, tzinfo.tzid,))
returnValue(None)
-
+
ical = response.data
try:
calendar = PyCalendar.parseText(ical)
@@ -808,7 +853,7 @@
ical = calendar.getText()
tzinfo.md5 = hashlib.md5(ical).hexdigest()
-
+
try:
tzpath = os.path.join(self.basepath, tzinfo.tzid) + ".ics"
if not os.path.exists(os.path.dirname(tzpath)):
@@ -821,6 +866,7 @@
else:
self.timezones[tzinfo.tzid] = tzinfo
+
def _removeTimezone(self, tzid):
tzpath = os.path.join(self.basepath, tzid) + ".ics"
try:
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20121207/398478ec/attachment-0001.html>
More information about the calendarserver-changes
mailing list