Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
tree: 5359c1e921
Fetching contributors…

Cannot retrieve contributors at this time

866 lines (723 sloc) 27.111 kb
#-----------------------------------------------------------------------------
# eveapi - EVE Online API access
#
# Copyright (c)2007 Jamie "Entity" van den Berge <entity@vapor.com>
#
# 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
#
#-----------------------------------------------------------------------------
# Version: 1.1.6 - 27 May 2011
# - Now supports composite keys for IndexRowsets.
# - Fixed calls not working if a path was specified in the root url.
#
# Version: 1.1.5 - 27 Januari 2011
# - Now supports (and defaults to) HTTPS. Non-SSL proxies will still work by
# explicitly specifying http:// in the url.
#
# Version: 1.1.4 - 1 December 2010
# - Empty explicit CDATA tags are now properly handled.
# - _autocast now receives the name of the variable it's trying to typecast,
# enabling custom/future casting functions to make smarter decisions.
#
# Version: 1.1.3 - 6 November 2010
# - Added support for anonymous CDATA inside row tags. This makes the body of
# mails in the rows of char/MailBodies available through the .data attribute.
#
# Version: 1.1.2 - 2 July 2010
# - Fixed __str__ on row objects to work properly with unicode strings.
#
# Version: 1.1.1 - 10 Januari 2010
# - Fixed bug that causes nested tags to not appear in rows of rowsets created
# from normal Elements. This should fix the corp.MemberSecurity method,
# which now returns all data for members. [jehed]
#
# Version: 1.1.0 - 15 Januari 2009
# - Added Select() method to Rowset class. Using it avoids the creation of
# temporary row instances, speeding up iteration considerably.
# - Added ParseXML() function, which can be passed arbitrary API XML file or
# string objects.
# - Added support for proxy servers. A proxy can be specified globally or
# per api connection instance. [suggestion by graalman]
# - Some minor refactoring.
# - Fixed deprecation warning when using Python 2.6.
#
# Version: 1.0.7 - 14 November 2008
# - Added workaround for rowsets that are missing the (required!) columns
# attribute. If missing, it will use the columns found in the first row.
# Note that this is will still break when expecting columns, if the rowset
# is empty. [Flux/Entity]
#
# Version: 1.0.6 - 18 July 2008
# - Enabled expat text buffering to avoid content breaking up. [BigWhale]
#
# Version: 1.0.5 - 03 February 2008
# - Added workaround to make broken XML responses (like the "row:name" bug in
# eve/CharacterID) work as intended.
# - Bogus datestamps before the epoch in XML responses are now set to 0 to
# avoid breaking certain date/time functions. [Anathema Matou]
#
# Version: 1.0.4 - 23 December 2007
# - Changed _autocast() to use timegm() instead of mktime(). [Invisible Hand]
# - Fixed missing attributes of elements inside rows. [Elandra Tenari]
#
# Version: 1.0.3 - 13 December 2007
# - Fixed keyless columns bugging out the parser (in CorporationSheet for ex.)
#
# Version: 1.0.2 - 12 December 2007
# - Fixed parser not working with indented XML.
#
# Version: 1.0.1
# - Some micro optimizations
#
# Version: 1.0
# - Initial release
#
# Requirements:
# Python 2.4+
#
#-----------------------------------------------------------------------------
import httplib
import urlparse
import urllib
import copy
from xml.parsers import expat
from time import strptime
from calendar import timegm
proxy = None
#-----------------------------------------------------------------------------
class Error(StandardError):
def __init__(self, code, message):
self.code = code
self.args = (message.rstrip("."),)
def EVEAPIConnection(url="api.eveonline.com", cacheHandler=None, proxy=None):
# Creates an API object through which you can call remote functions.
#
# The following optional arguments may be provided:
#
# url - root location of the EVEAPI server
#
# proxy - (host,port) specifying a proxy server through which to request
# the API pages. Specifying a proxy overrides default proxy.
#
# cacheHandler - an object which must support the following interface:
#
# retrieve(host, path, params)
#
# Called when eveapi wants to fetch a document.
# host is the address of the server, path is the full path to
# the requested document, and params is a dict containing the
# parameters passed to this api call (userID, apiKey etc).
# The method MUST return one of the following types:
#
# None - if your cache did not contain this entry
# str/unicode - eveapi will parse this as XML
# Element - previously stored object as provided to store()
# file-like object - eveapi will read() XML from the stream.
#
# store(host, path, params, doc, obj)
#
# Called when eveapi wants you to cache this item.
# You can use obj to get the info about the object (cachedUntil
# and currentTime, etc) doc is the XML document the object
# was generated from. It's generally best to cache the XML, not
# the object, unless you pickle the object. Note that this method
# will only be called if you returned None in the retrieve() for
# this object.
#
if not url.startswith("http"):
url = "https://" + url
p = urlparse.urlparse(url, "https")
if p.path and p.path[-1] == "/":
p.path = p.path[:-1]
ctx = _RootContext(None, p.path, {}, {})
ctx._handler = cacheHandler
ctx._scheme = p.scheme
ctx._host = p.netloc
ctx._proxy = proxy or globals()["proxy"]
return ctx
def ParseXML(file_or_string):
try:
return _ParseXML(file_or_string, False, None)
except TypeError:
raise TypeError("XML data must be provided as string or file-like object")
def _ParseXML(response, fromContext, storeFunc):
# pre/post-process XML or Element data
if fromContext and isinstance(response, Element):
obj = response
elif type(response) in (str, unicode):
obj = _Parser().Parse(response, False)
elif hasattr(response, "read"):
obj = _Parser().Parse(response, True)
else:
raise TypeError("retrieve method must return None, string, file-like object or an Element instance")
error = getattr(obj, "error", False)
if error:
raise Error(error.code, error.data)
result = getattr(obj, "result", False)
if not result:
raise RuntimeError("API object does not contain result")
if fromContext and storeFunc:
# call the cache handler to store this object
storeFunc(obj)
# make metadata available to caller somehow
result._meta = obj
return result
#-----------------------------------------------------------------------------
# API Classes
#-----------------------------------------------------------------------------
_listtypes = (list, tuple, dict)
_unspecified = []
class _Context(object):
def __init__(self, root, path, parentDict, newKeywords=None):
self._root = root or self
self._path = path
if newKeywords:
if parentDict:
self.parameters = parentDict.copy()
else:
self.parameters = {}
self.parameters.update(newKeywords)
else:
self.parameters = parentDict or {}
def context(self, *args, **kw):
if kw or args:
path = self._path
if args:
path += "/" + "/".join(args)
return self.__class__(self._root, path, self.parameters, kw)
else:
return self
def __getattr__(self, this):
# perform arcane attribute majick trick
return _Context(self._root, self._path + "/" + this, self.parameters)
def __call__(self, **kw):
if kw:
# specified keywords override contextual ones
for k, v in self.parameters.iteritems():
if k not in kw:
kw[k] = v
else:
# no keywords provided, just update with contextual ones.
kw.update(self.parameters)
# now let the root context handle it further
return self._root(self._path, **kw)
class _AuthContext(_Context):
def character(self, characterID):
# returns a copy of this connection object but for every call made
# through it, it will add the folder "/char" to the url, and the
# characterID to the parameters passed.
return _Context(self._root, self._path + "/char", self.parameters, {"characterID":characterID})
def corporation(self, characterID):
# same as character except for the folder "/corp"
return _Context(self._root, self._path + "/corp", self.parameters, {"characterID":characterID})
class _RootContext(_Context):
def auth(self, userID=None, apiKey=None, keyID=None, vCode=None):
# returns a copy of this object but for every call made through it, the
# userID and apiKey or keyID and vCode will be added to the API request.
if userID and apiKey:
return _AuthContext(self._root, self._path, self.parameters, {"userID":userID, "apiKey":apiKey})
if keyID and vCode:
return _AuthContext(self._root, self._path, self.parameters, {"keyID":keyID, "vCode":vCode})
raise ValueError("Must specify userID and apiKey or keyID and vCode")
def setcachehandler(self, handler):
self._root._handler = handler
def __call__(self, path, **kw):
# convert list type arguments to something the API likes
for k, v in kw.iteritems():
if isinstance(v, _listtypes):
kw[k] = ','.join(map(str, list(v)))
cache = self._root._handler
# now send the request
path += ".xml.aspx"
if cache:
response = cache.retrieve(self._host, path, kw)
else:
response = None
if response is None:
if self._scheme == "https":
connectionclass = httplib.HTTPSConnection
else:
connectionclass = httplib.HTTPConnection
if self._proxy is None:
http = connectionclass(self._host)
if kw:
http.request("POST", path, urllib.urlencode(kw), {"Content-type": "application/x-www-form-urlencoded"})
else:
http.request("GET", path)
else:
http = connectionclass(*self._proxy)
if kw:
http.request("POST", 'https://'+self._host+path, urllib.urlencode(kw), {"Content-type": "application/x-www-form-urlencoded"})
else:
http.request("GET", 'https://'+self._host+path)
response = http.getresponse()
if response.status != 200:
if response.status == httplib.NOT_FOUND:
raise AttributeError("'%s' not available on API server (404 Not Found)" % path)
else:
raise RuntimeError("'%s' request failed (%d %s)" % (path, response.status, response.reason))
if cache:
store = True
response = response.read()
else:
store = False
else:
store = False
retrieve_fallback = cache and getattr(cache, "retrieve_fallback", False)
if retrieve_fallback:
# implementor is handling fallbacks...
try:
return _ParseXML(response, True, store and (lambda obj: cache.store(self._host, path, kw, response, obj)))
except Error, reason:
response = retrieve_fallback(self._host, path, kw, reason=e)
if response is not None:
return response
raise
else:
# implementor is not handling fallbacks...
return _ParseXML(response, True, store and (lambda obj: cache.store(self._host, path, kw, response, obj)))
#-----------------------------------------------------------------------------
# XML Parser
#-----------------------------------------------------------------------------
def _autocast(key, value):
# attempts to cast an XML string to the most probable type.
try:
if value.strip("-").isdigit():
return int(value)
except ValueError:
pass
try:
return float(value)
except ValueError:
pass
if len(value) == 19 and value[10] == ' ':
# it could be a date string
try:
return max(0, int(timegm(strptime(value, "%Y-%m-%d %H:%M:%S"))))
except OverflowError:
pass
except ValueError:
pass
# couldn't cast. return string unchanged.
return value
class _Parser(object):
def Parse(self, data, isStream=False):
self.container = self.root = None
self._cdata = False
p = expat.ParserCreate()
p.StartElementHandler = self.tag_start
p.CharacterDataHandler = self.tag_cdata
p.StartCdataSectionHandler = self.tag_cdatasection_enter
p.EndCdataSectionHandler = self.tag_cdatasection_exit
p.EndElementHandler = self.tag_end
p.ordered_attributes = True
p.buffer_text = True
if isStream:
p.ParseFile(data)
else:
p.Parse(data, True)
return self.root
def tag_cdatasection_enter(self):
# encountered an explicit CDATA tag.
self._cdata = True
def tag_cdatasection_exit(self):
if self._cdata:
# explicit CDATA without actual data. expat doesn't seem
# to trigger an event for this case, so do it manually.
# (_cdata is set False by this call)
self.tag_cdata("")
else:
self._cdata = False
def tag_start(self, name, attributes):
# <hack>
# If there's a colon in the tag name, cut off the name from the colon
# onward. This is a workaround to make certain bugged XML responses
# (such as eve/CharacterID.xml.aspx) work.
if ":" in name:
name = name[:name.index(":")]
# </hack>
if name == "rowset":
# for rowsets, use the given name
try:
columns = attributes[attributes.index('columns')+1].split(",")
except ValueError:
# rowset did not have columns tag set (this is a bug in API)
# columns will be extracted from first row instead.
columns = []
try:
priKey = attributes[attributes.index('key')+1]
this = IndexRowset(cols=columns, key=priKey)
except ValueError:
this = Rowset(cols=columns)
this._name = attributes[attributes.index('name')+1]
this.__catch = "row" # tag to auto-add to rowset.
else:
this = Element()
this._name = name
this.__parent = self.container
if self.root is None:
# We're at the root. The first tag has to be "eveapi" or we can't
# really assume the rest of the xml is going to be what we expect.
if name != "eveapi":
raise RuntimeError("Invalid API response")
self.root = this
if isinstance(self.container, Rowset) and (self.container.__catch == this._name):
# check for missing columns attribute (see above)
if not self.container._cols:
self.container._cols = attributes[0::2]
self.container.append([_autocast(attributes[i], attributes[i+1]) for i in xrange(0, len(attributes), 2)])
this._isrow = True
this._attributes = this._attributes2 = None
else:
this._isrow = False
this._attributes = attributes
this._attributes2 = []
self.container = this
def tag_cdata(self, data):
if self._cdata:
# unset cdata flag to indicate it's been handled.
self._cdata = False
else:
if data in ("\r\n", "\n") or data.strip() != data:
return
this = self.container
data = _autocast(this._name, data)
if this._isrow:
# sigh. anonymous data inside rows makes Entity cry.
# for the love of Jove, CCP, learn how to use rowsets.
parent = this.__parent
_row = parent._rows[-1]
_row.append(data)
if len(parent._cols) < len(_row):
parent._cols.append("data")
elif this._attributes:
# this tag has attributes, so we can't simply assign the cdata
# as an attribute to the parent tag, as we'll lose the current
# tag's attributes then. instead, we'll assign the data as
# attribute of this tag.
this.data = data
else:
# this was a simple <tag>data</tag> without attributes.
# we won't be doing anything with this actual tag so we can just
# bind it to its parent (done by __tag_end)
setattr(this.__parent, this._name, data)
def tag_end(self, name):
this = self.container
if this is self.root:
del this._attributes
#this.__dict__.pop("_attributes", None)
return
# we're done with current tag, so we can pop it off. This means that
# self.container will now point to the container of element 'this'.
self.container = this.__parent
del this.__parent
attributes = this.__dict__.pop("_attributes")
attributes2 = this.__dict__.pop("_attributes2")
if attributes is None:
# already processed this tag's closure early, in tag_start()
return
if self.container._isrow:
# Special case here. tags inside a row! Such tags have to be
# added as attributes of the row.
parent = self.container.__parent
# get the row line for this element from its parent rowset
_row = parent._rows[-1]
# add this tag's value to the end of the row
_row.append(getattr(self.container, this._name, this))
# fix columns if neccessary.
if len(parent._cols) < len(_row):
parent._cols.append(this._name)
else:
# see if there's already an attribute with this name (this shouldn't
# really happen, but it doesn't hurt to handle this case!
sibling = getattr(self.container, this._name, None)
if sibling is None:
self.container._attributes2.append(this._name)
setattr(self.container, this._name, this)
# Note: there aren't supposed to be any NON-rowset tags containing
# multiples of some tag or attribute. Code below handles this case.
elif isinstance(sibling, Rowset):
# its doppelganger is a rowset, append this as a row to that.
row = [_autocast(attributes[i], attributes[i+1]) for i in xrange(0, len(attributes), 2)]
row.extend([getattr(this, col) for col in attributes2])
sibling.append(row)
elif isinstance(sibling, Element):
# parent attribute is an element. This means we're dealing
# with multiple of the same sub-tag. Change the attribute
# into a Rowset, adding the sibling element and this one.
rs = Rowset()
rs.__catch = rs._name = this._name
row = [_autocast(attributes[i], attributes[i+1]) for i in xrange(0, len(attributes), 2)]+[getattr(this, col) for col in attributes2]
rs.append(row)
row = [getattr(sibling, attributes[i]) for i in xrange(0, len(attributes), 2)]+[getattr(sibling, col) for col in attributes2]
rs.append(row)
rs._cols = [attributes[i] for i in xrange(0, len(attributes), 2)]+[col for col in attributes2]
setattr(self.container, this._name, rs)
else:
# something else must have set this attribute already.
# (typically the <tag>data</tag> case in tag_data())
pass
# Now fix up the attributes and be done with it.
for i in xrange(0, len(attributes), 2):
this.__dict__[attributes[i]] = _autocast(attributes[i], attributes[i+1])
return
#-----------------------------------------------------------------------------
# XML Data Containers
#-----------------------------------------------------------------------------
# The following classes are the various container types the XML data is
# unpacked into.
#
# Note that objects returned by API calls are to be treated as read-only. This
# is not enforced, but you have been warned.
#-----------------------------------------------------------------------------
class Element(object):
# Element is a namespace for attributes and nested tags
def __str__(self):
return "<Element '%s'>" % self._name
_fmt = u"%s:%s".__mod__
class Row(object):
# A Row is a single database record associated with a Rowset.
# The fields in the record are accessed as attributes by their respective
# column name.
#
# To conserve resources, Row objects are only created on-demand. This is
# typically done by Rowsets (e.g. when iterating over the rowset).
def __init__(self, cols=None, row=None):
self._cols = cols or []
self._row = row or []
def __nonzero__(self):
return True
def __ne__(self, other):
return self.__cmp__(other)
def __eq__(self, other):
return self.__cmp__(other) == 0
def __cmp__(self, other):
if type(other) != type(self):
raise TypeError("Incompatible comparison type")
return cmp(self._cols, other._cols) or cmp(self._row, other._row)
def __getattr__(self, this):
try:
return self._row[self._cols.index(this)]
except:
raise AttributeError, this
def __getitem__(self, this):
return self._row[self._cols.index(this)]
def __str__(self):
return "Row(" + ','.join(map(_fmt, zip(self._cols, self._row))) + ")"
class Rowset(object):
# Rowsets are collections of Row objects.
#
# Rowsets support most of the list interface:
# iteration, indexing and slicing
#
# As well as the following methods:
#
# IndexedBy(column)
# Returns an IndexRowset keyed on given column. Requires the column to
# be usable as primary key.
#
# GroupedBy(column)
# Returns a FilterRowset keyed on given column. FilterRowset objects
# can be accessed like dicts. See FilterRowset class below.
#
# SortBy(column, reverse=True)
# Sorts rowset in-place on given column. for a descending sort,
# specify reversed=True.
#
# SortedBy(column, reverse=True)
# Same as SortBy, except this returns a new rowset object instead of
# sorting in-place.
#
# Select(columns, row=False)
# Yields a column values tuple (value, ...) for each row in the rowset.
# If only one column is requested, then just the column value is
# provided instead of the values tuple.
# When row=True, each result will be decorated with the entire row.
#
def IndexedBy(self, column):
return IndexRowset(self._cols, self._rows, column)
def GroupedBy(self, column):
return FilterRowset(self._cols, self._rows, column)
def SortBy(self, column, reverse=False):
ix = self._cols.index(column)
self.sort(key=lambda e: e[ix], reverse=reverse)
def SortedBy(self, column, reverse=False):
rs = self[:]
rs.SortBy(column, reverse)
return rs
def Select(self, *columns, **options):
if len(columns) == 1:
i = self._cols.index(columns[0])
if options.get("row", False):
for line in self._rows:
yield (line, line[i])
else:
for line in self._rows:
yield line[i]
else:
i = map(self._cols.index, columns)
if options.get("row", False):
for line in self._rows:
yield line, [line[x] for x in i]
else:
for line in self._rows:
yield [line[x] for x in i]
# -------------
def __init__(self, cols=None, rows=None):
self._cols = cols or []
self._rows = rows or []
def append(self, row):
if isinstance(row, list):
self._rows.append(row)
elif isinstance(row, Row) and len(row._cols) == len(self._cols):
self._rows.append(row._row)
else:
raise TypeError("incompatible row type")
def __add__(self, other):
if isinstance(other, Rowset):
if len(other._cols) == len(self._cols):
self._rows += other._rows
raise TypeError("rowset instance expected")
def __nonzero__(self):
return not not self._rows
def __len__(self):
return len(self._rows)
def copy(self):
return self[:]
def __getitem__(self, ix):
if type(ix) is slice:
return Rowset(self._cols, self._rows[ix])
return Row(self._cols, self._rows[ix])
def sort(self, *args, **kw):
self._rows.sort(*args, **kw)
def __str__(self):
return ("Rowset(columns=[%s], rows=%d)" % (','.join(self._cols), len(self)))
def __getstate__(self):
return (self._cols, self._rows)
def __setstate__(self, state):
self._cols, self._rows = state
class IndexRowset(Rowset):
# An IndexRowset is a Rowset that keeps an index on a column.
#
# The interface is the same as Rowset, but provides an additional method:
#
# Get(key [, default])
# Returns the Row mapped to provided key in the index. If there is no
# such key in the index, KeyError is raised unless a default value was
# specified.
#
def Get(self, key, *default):
row = self._items.get(key, None)
if row is None:
if default:
return default[0]
raise KeyError, key
return Row(self._cols, row)
# -------------
def __init__(self, cols=None, rows=None, key=None):
try:
if "," in key:
self._ki = ki = [cols.index(k) for k in key.split(",")]
self.composite = True
else:
self._ki = ki = cols.index(key)
self.composite = False
except IndexError:
raise ValueError("Rowset has no column %s" % key)
Rowset.__init__(self, cols, rows)
self._key = key
if self.composite:
self._items = dict((tuple([row[k] for k in ki]), row) for row in self._rows)
else:
self._items = dict((row[ki], row) for row in self._rows)
def __getitem__(self, ix):
if type(ix) is slice:
return IndexRowset(self._cols, self._rows[ix], self._key)
return Rowset.__getitem__(self, ix)
def append(self, row):
Rowset.append(self, row)
if self.composite:
self._items[tuple([row[k] for k in self._ki])] = row
else:
self._items[row[self._ki]] = row
def __getstate__(self):
return (Rowset.__getstate__(self), self._items, self._ki)
def __setstate__(self, state):
state, self._items, self._ki = state
Rowset.__setstate__(self, state)
class FilterRowset(object):
# A FilterRowset works much like an IndexRowset, with the following
# differences:
# - FilterRowsets are accessed much like dicts
# - Each key maps to a Rowset, containing only the rows where the value
# of the column this FilterRowset was made on matches the key.
def __init__(self, cols=None, rows=None, key=None, key2=None, dict=None):
if dict is not None:
self._items = items = dict
elif cols is not None:
self._items = items = {}
idfield = cols.index(key)
if not key2:
for row in rows:
id = row[idfield]
if id in items:
items[id].append(row)
else:
items[id] = [row]
else:
idfield2 = cols.index(key2)
for row in rows:
id = row[idfield]
if id in items:
items[id][row[idfield2]] = row
else:
items[id] = {row[idfield2]:row}
self._cols = cols
self.key = key
self.key2 = key2
self._bind()
def _bind(self):
items = self._items
self.keys = items.keys
self.iterkeys = items.iterkeys
self.__contains__ = items.__contains__
self.has_key = items.has_key
self.__len__ = items.__len__
self.__iter__ = items.__iter__
def copy(self):
return FilterRowset(self._cols[:], None, self.key, self.key2, dict=copy.deepcopy(self._items))
def get(self, key, default=_unspecified):
try:
return self[key]
except KeyError:
if default is _unspecified:
raise
return default
def __getitem__(self, i):
if self.key2:
return IndexRowset(self._cols, None, self.key2, self._items.get(i, {}))
return Rowset(self._cols, self._items[i])
def __getstate__(self):
return (self._cols, self._rows, self._items, self.key, self.key2)
def __setstate__(self, state):
self._cols, self._rows, self._items, self.key, self.key2 = state
self._bind()
Jump to Line
Something went wrong with that request. Please try again.