#----------------------------------------------------------------------------- # eveapi - EVE Online API access # # Copyright (c)2007 Jamie "Entity" van den Berge # # 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.9 - 2 September 2011 # - added workaround for row tags with attributes that were not defined # in their rowset (this should fix AssetList) # # Version: 1.1.8 - 1 September 2011 # - fix for inconsistent columns attribute in rowsets. # # Version: 1.1.7 - 1 September 2011 # - auth() method updated to work with the new authentication scheme. # # 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 (keyID, vCode, 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, **kw): if len(kw) == 2 and (("keyID" in kw and "vCode" in kw) or ("userID" in kw and "apiKey" in kw)): return _AuthContext(self._root, self._path, self.parameters, kw) raise ValueError("Must specify 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): # # 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(":")] # if name == "rowset": # for rowsets, use the given name try: columns = attributes[attributes.index('columns')+1].replace(" ", "").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) # - check for extra attributes that were not defined in the rowset, # such as rawQuantity in the assets lists. # In either case the tag is assumed to be correct and the rowset's # columns are overwritten with the tag's version. if not self.container._cols or (len(attributes)/2 > len(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 data 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 data 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 "" % 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()