1 #-----------------------------------------------------------------------------
2 # eveapi - EVE Online API access
4 # Copyright (c)2007 Jamie "Entity" van den Berge <entity@vapor.com>
6 # Permission is hereby granted, free of charge, to any person
7 # obtaining a copy of this software and associated documentation
8 # files (the "Software"), to deal in the Software without
9 # restriction, including without limitation the rights to use,
10 # copy, modify, merge, publish, distribute, sublicense, and/or sell
11 # copies of the Software, and to permit persons to whom the
12 # Software is furnished to do so, subject to the following
15 # The above copyright notice and this permission notice shall be
16 # included in all copies or substantial portions of the Software.
18 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19 # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
20 # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
22 # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
23 # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
24 # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
25 # OTHER DEALINGS IN THE SOFTWARE
27 #-----------------------------------------------------------------------------
28 # Version: 1.1.9 - 2 September 2011
29 # - added workaround for row tags with attributes that were not defined
30 # in their rowset (this should fix AssetList)
32 # Version: 1.1.8 - 1 September 2011
33 # - fix for inconsistent columns attribute in rowsets.
35 # Version: 1.1.7 - 1 September 2011
36 # - auth() method updated to work with the new authentication scheme.
38 # Version: 1.1.6 - 27 May 2011
39 # - Now supports composite keys for IndexRowsets.
40 # - Fixed calls not working if a path was specified in the root url.
42 # Version: 1.1.5 - 27 Januari 2011
43 # - Now supports (and defaults to) HTTPS. Non-SSL proxies will still work by
44 # explicitly specifying http:// in the url.
46 # Version: 1.1.4 - 1 December 2010
47 # - Empty explicit CDATA tags are now properly handled.
48 # - _autocast now receives the name of the variable it's trying to typecast,
49 # enabling custom/future casting functions to make smarter decisions.
51 # Version: 1.1.3 - 6 November 2010
52 # - Added support for anonymous CDATA inside row tags. This makes the body of
53 # mails in the rows of char/MailBodies available through the .data attribute.
55 # Version: 1.1.2 - 2 July 2010
56 # - Fixed __str__ on row objects to work properly with unicode strings.
58 # Version: 1.1.1 - 10 Januari 2010
59 # - Fixed bug that causes nested tags to not appear in rows of rowsets created
60 # from normal Elements. This should fix the corp.MemberSecurity method,
61 # which now returns all data for members. [jehed]
63 # Version: 1.1.0 - 15 Januari 2009
64 # - Added Select() method to Rowset class. Using it avoids the creation of
65 # temporary row instances, speeding up iteration considerably.
66 # - Added ParseXML() function, which can be passed arbitrary API XML file or
68 # - Added support for proxy servers. A proxy can be specified globally or
69 # per api connection instance. [suggestion by graalman]
70 # - Some minor refactoring.
71 # - Fixed deprecation warning when using Python 2.6.
73 # Version: 1.0.7 - 14 November 2008
74 # - Added workaround for rowsets that are missing the (required!) columns
75 # attribute. If missing, it will use the columns found in the first row.
76 # Note that this is will still break when expecting columns, if the rowset
77 # is empty. [Flux/Entity]
79 # Version: 1.0.6 - 18 July 2008
80 # - Enabled expat text buffering to avoid content breaking up. [BigWhale]
82 # Version: 1.0.5 - 03 February 2008
83 # - Added workaround to make broken XML responses (like the "row:name" bug in
84 # eve/CharacterID) work as intended.
85 # - Bogus datestamps before the epoch in XML responses are now set to 0 to
86 # avoid breaking certain date/time functions. [Anathema Matou]
88 # Version: 1.0.4 - 23 December 2007
89 # - Changed _autocast() to use timegm() instead of mktime(). [Invisible Hand]
90 # - Fixed missing attributes of elements inside rows. [Elandra Tenari]
92 # Version: 1.0.3 - 13 December 2007
93 # - Fixed keyless columns bugging out the parser (in CorporationSheet for ex.)
95 # Version: 1.0.2 - 12 December 2007
96 # - Fixed parser not working with indented XML.
99 # - Some micro optimizations
107 #-----------------------------------------------------------------------------
114 from xml.parsers import expat
115 from time import strptime
116 from calendar import timegm
120 #-----------------------------------------------------------------------------
122 class Error(StandardError):
123 def __init__(self, code, message):
125 self.args = (message.rstrip("."),)
128 def EVEAPIConnection(url="api.eveonline.com", cacheHandler=None, proxy=None):
129 # Creates an API object through which you can call remote functions.
131 # The following optional arguments may be provided:
133 # url - root location of the EVEAPI server
135 # proxy - (host,port) specifying a proxy server through which to request
136 # the API pages. Specifying a proxy overrides default proxy.
138 # cacheHandler - an object which must support the following interface:
140 # retrieve(host, path, params)
142 # Called when eveapi wants to fetch a document.
143 # host is the address of the server, path is the full path to
144 # the requested document, and params is a dict containing the
145 # parameters passed to this api call (keyID, vCode, etc).
146 # The method MUST return one of the following types:
148 # None - if your cache did not contain this entry
149 # str/unicode - eveapi will parse this as XML
150 # Element - previously stored object as provided to store()
151 # file-like object - eveapi will read() XML from the stream.
153 # store(host, path, params, doc, obj)
155 # Called when eveapi wants you to cache this item.
156 # You can use obj to get the info about the object (cachedUntil
157 # and currentTime, etc) doc is the XML document the object
158 # was generated from. It's generally best to cache the XML, not
159 # the object, unless you pickle the object. Note that this method
160 # will only be called if you returned None in the retrieve() for
164 if not url.startswith("http"):
165 url = "https://" + url
166 p = urlparse.urlparse(url, "https")
167 if p.path and p.path[-1] == "/":
169 ctx = _RootContext(None, p.path, {}, {})
170 ctx._handler = cacheHandler
171 ctx._scheme = p.scheme
173 ctx._proxy = proxy or globals()["proxy"]
177 def ParseXML(file_or_string):
179 return _ParseXML(file_or_string, False, None)
181 raise TypeError("XML data must be provided as string or file-like object")
184 def _ParseXML(response, fromContext, storeFunc):
185 # pre/post-process XML or Element data
187 if fromContext and isinstance(response, Element):
189 elif type(response) in (str, unicode):
190 obj = _Parser().Parse(response, False)
191 elif hasattr(response, "read"):
192 obj = _Parser().Parse(response, True)
194 raise TypeError("retrieve method must return None, string, file-like object or an Element instance")
196 error = getattr(obj, "error", False)
198 raise Error(error.code, error.data)
200 result = getattr(obj, "result", False)
202 raise RuntimeError("API object does not contain result")
204 if fromContext and storeFunc:
205 # call the cache handler to store this object
208 # make metadata available to caller somehow
217 #-----------------------------------------------------------------------------
219 #-----------------------------------------------------------------------------
221 _listtypes = (list, tuple, dict)
224 class _Context(object):
226 def __init__(self, root, path, parentDict, newKeywords=None):
227 self._root = root or self
231 self.parameters = parentDict.copy()
234 self.parameters.update(newKeywords)
236 self.parameters = parentDict or {}
238 def context(self, *args, **kw):
242 path += "/" + "/".join(args)
243 return self.__class__(self._root, path, self.parameters, kw)
247 def __getattr__(self, this):
248 # perform arcane attribute majick trick
249 return _Context(self._root, self._path + "/" + this, self.parameters)
251 def __call__(self, **kw):
253 # specified keywords override contextual ones
254 for k, v in self.parameters.iteritems():
258 # no keywords provided, just update with contextual ones.
259 kw.update(self.parameters)
261 # now let the root context handle it further
262 return self._root(self._path, **kw)
265 class _AuthContext(_Context):
267 def character(self, characterID):
268 # returns a copy of this connection object but for every call made
269 # through it, it will add the folder "/char" to the url, and the
270 # characterID to the parameters passed.
271 return _Context(self._root, self._path + "/char", self.parameters, {"characterID":characterID})
273 def corporation(self, characterID):
274 # same as character except for the folder "/corp"
275 return _Context(self._root, self._path + "/corp", self.parameters, {"characterID":characterID})
278 class _RootContext(_Context):
280 def auth(self, **kw):
281 if len(kw) == 2 and (("keyID" in kw and "vCode" in kw) or ("userID" in kw and "apiKey" in kw)):
282 return _AuthContext(self._root, self._path, self.parameters, kw)
283 raise ValueError("Must specify keyID and vCode")
285 def setcachehandler(self, handler):
286 self._root._handler = handler
288 def __call__(self, path, **kw):
289 # convert list type arguments to something the API likes
290 for k, v in kw.iteritems():
291 if isinstance(v, _listtypes):
292 kw[k] = ','.join(map(str, list(v)))
294 cache = self._root._handler
296 # now send the request
300 response = cache.retrieve(self._host, path, kw)
305 if self._scheme == "https":
306 connectionclass = httplib.HTTPSConnection
308 connectionclass = httplib.HTTPConnection
310 if self._proxy is None:
311 http = connectionclass(self._host)
313 http.request("POST", path, urllib.urlencode(kw), {"Content-type": "application/x-www-form-urlencoded"})
315 http.request("GET", path)
317 http = connectionclass(*self._proxy)
319 http.request("POST", 'https://'+self._host+path, urllib.urlencode(kw), {"Content-type": "application/x-www-form-urlencoded"})
321 http.request("GET", 'https://'+self._host+path)
323 response = http.getresponse()
324 if response.status != 200:
325 if response.status == httplib.NOT_FOUND:
326 raise AttributeError("'%s' not available on API server (404 Not Found)" % path)
328 raise RuntimeError("'%s' request failed (%d %s)" % (path, response.status, response.reason))
332 response = response.read()
338 retrieve_fallback = cache and getattr(cache, "retrieve_fallback", False)
339 if retrieve_fallback:
340 # implementor is handling fallbacks...
342 return _ParseXML(response, True, store and (lambda obj: cache.store(self._host, path, kw, response, obj)))
343 except Error, reason:
344 response = retrieve_fallback(self._host, path, kw, reason=e)
345 if response is not None:
349 # implementor is not handling fallbacks...
350 return _ParseXML(response, True, store and (lambda obj: cache.store(self._host, path, kw, response, obj)))
352 #-----------------------------------------------------------------------------
354 #-----------------------------------------------------------------------------
356 def _autocast(key, value):
357 # attempts to cast an XML string to the most probable type.
359 if value.strip("-").isdigit():
369 if len(value) == 19 and value[10] == ' ':
370 # it could be a date string
372 return max(0, int(timegm(strptime(value, "%Y-%m-%d %H:%M:%S"))))
373 except OverflowError:
378 # couldn't cast. return string unchanged.
383 class _Parser(object):
385 def Parse(self, data, isStream=False):
386 self.container = self.root = None
388 p = expat.ParserCreate()
389 p.StartElementHandler = self.tag_start
390 p.CharacterDataHandler = self.tag_cdata
391 p.StartCdataSectionHandler = self.tag_cdatasection_enter
392 p.EndCdataSectionHandler = self.tag_cdatasection_exit
393 p.EndElementHandler = self.tag_end
394 p.ordered_attributes = True
404 def tag_cdatasection_enter(self):
405 # encountered an explicit CDATA tag.
408 def tag_cdatasection_exit(self):
410 # explicit CDATA without actual data. expat doesn't seem
411 # to trigger an event for this case, so do it manually.
412 # (_cdata is set False by this call)
417 def tag_start(self, name, attributes):
419 # If there's a colon in the tag name, cut off the name from the colon
420 # onward. This is a workaround to make certain bugged XML responses
421 # (such as eve/CharacterID.xml.aspx) work.
423 name = name[:name.index(":")]
427 # for rowsets, use the given name
429 columns = attributes[attributes.index('columns')+1].replace(" ", "").split(",")
431 # rowset did not have columns tag set (this is a bug in API)
432 # columns will be extracted from first row instead.
436 priKey = attributes[attributes.index('key')+1]
437 this = IndexRowset(cols=columns, key=priKey)
439 this = Rowset(cols=columns)
442 this._name = attributes[attributes.index('name')+1]
443 this.__catch = "row" # tag to auto-add to rowset.
448 this.__parent = self.container
450 if self.root is None:
451 # We're at the root. The first tag has to be "eveapi" or we can't
452 # really assume the rest of the xml is going to be what we expect.
454 raise RuntimeError("Invalid API response")
457 if isinstance(self.container, Rowset) and (self.container.__catch == this._name):
459 # - check for missing columns attribute (see above)
460 # - check for extra attributes that were not defined in the rowset,
461 # such as rawQuantity in the assets lists.
462 # In either case the tag is assumed to be correct and the rowset's
463 # columns are overwritten with the tag's version.
464 if not self.container._cols or (len(attributes)/2 > len(self.container._cols)):
465 self.container._cols = attributes[0::2]
468 self.container.append([_autocast(attributes[i], attributes[i+1]) for i in xrange(0, len(attributes), 2)])
470 this._attributes = this._attributes2 = None
473 this._attributes = attributes
474 this._attributes2 = []
476 self.container = this
479 def tag_cdata(self, data):
481 # unset cdata flag to indicate it's been handled.
484 if data in ("\r\n", "\n") or data.strip() != data:
487 this = self.container
488 data = _autocast(this._name, data)
491 # sigh. anonymous data inside rows makes Entity cry.
492 # for the love of Jove, CCP, learn how to use rowsets.
493 parent = this.__parent
494 _row = parent._rows[-1]
496 if len(parent._cols) < len(_row):
497 parent._cols.append("data")
499 elif this._attributes:
500 # this tag has attributes, so we can't simply assign the cdata
501 # as an attribute to the parent tag, as we'll lose the current
502 # tag's attributes then. instead, we'll assign the data as
503 # attribute of this tag.
506 # this was a simple <tag>data</tag> without attributes.
507 # we won't be doing anything with this actual tag so we can just
508 # bind it to its parent (done by __tag_end)
509 setattr(this.__parent, this._name, data)
511 def tag_end(self, name):
512 this = self.container
513 if this is self.root:
515 #this.__dict__.pop("_attributes", None)
518 # we're done with current tag, so we can pop it off. This means that
519 # self.container will now point to the container of element 'this'.
520 self.container = this.__parent
523 attributes = this.__dict__.pop("_attributes")
524 attributes2 = this.__dict__.pop("_attributes2")
525 if attributes is None:
526 # already processed this tag's closure early, in tag_start()
529 if self.container._isrow:
530 # Special case here. tags inside a row! Such tags have to be
531 # added as attributes of the row.
532 parent = self.container.__parent
534 # get the row line for this element from its parent rowset
535 _row = parent._rows[-1]
537 # add this tag's value to the end of the row
538 _row.append(getattr(self.container, this._name, this))
540 # fix columns if neccessary.
541 if len(parent._cols) < len(_row):
542 parent._cols.append(this._name)
544 # see if there's already an attribute with this name (this shouldn't
545 # really happen, but it doesn't hurt to handle this case!
546 sibling = getattr(self.container, this._name, None)
548 self.container._attributes2.append(this._name)
549 setattr(self.container, this._name, this)
550 # Note: there aren't supposed to be any NON-rowset tags containing
551 # multiples of some tag or attribute. Code below handles this case.
552 elif isinstance(sibling, Rowset):
553 # its doppelganger is a rowset, append this as a row to that.
554 row = [_autocast(attributes[i], attributes[i+1]) for i in xrange(0, len(attributes), 2)]
555 row.extend([getattr(this, col) for col in attributes2])
557 elif isinstance(sibling, Element):
558 # parent attribute is an element. This means we're dealing
559 # with multiple of the same sub-tag. Change the attribute
560 # into a Rowset, adding the sibling element and this one.
562 rs.__catch = rs._name = this._name
563 row = [_autocast(attributes[i], attributes[i+1]) for i in xrange(0, len(attributes), 2)]+[getattr(this, col) for col in attributes2]
565 row = [getattr(sibling, attributes[i]) for i in xrange(0, len(attributes), 2)]+[getattr(sibling, col) for col in attributes2]
567 rs._cols = [attributes[i] for i in xrange(0, len(attributes), 2)]+[col for col in attributes2]
568 setattr(self.container, this._name, rs)
570 # something else must have set this attribute already.
571 # (typically the <tag>data</tag> case in tag_data())
574 # Now fix up the attributes and be done with it.
575 for i in xrange(0, len(attributes), 2):
576 this.__dict__[attributes[i]] = _autocast(attributes[i], attributes[i+1])
583 #-----------------------------------------------------------------------------
584 # XML Data Containers
585 #-----------------------------------------------------------------------------
586 # The following classes are the various container types the XML data is
589 # Note that objects returned by API calls are to be treated as read-only. This
590 # is not enforced, but you have been warned.
591 #-----------------------------------------------------------------------------
593 class Element(object):
594 # Element is a namespace for attributes and nested tags
596 return "<Element '%s'>" % self._name
598 _fmt = u"%s:%s".__mod__
600 # A Row is a single database record associated with a Rowset.
601 # The fields in the record are accessed as attributes by their respective
604 # To conserve resources, Row objects are only created on-demand. This is
605 # typically done by Rowsets (e.g. when iterating over the rowset).
607 def __init__(self, cols=None, row=None):
608 self._cols = cols or []
609 self._row = row or []
611 def __nonzero__(self):
614 def __ne__(self, other):
615 return self.__cmp__(other)
617 def __eq__(self, other):
618 return self.__cmp__(other) == 0
620 def __cmp__(self, other):
621 if type(other) != type(self):
622 raise TypeError("Incompatible comparison type")
623 return cmp(self._cols, other._cols) or cmp(self._row, other._row)
625 def __getattr__(self, this):
627 return self._row[self._cols.index(this)]
629 raise AttributeError, this
631 def __getitem__(self, this):
632 return self._row[self._cols.index(this)]
635 return "Row(" + ','.join(map(_fmt, zip(self._cols, self._row))) + ")"
638 class Rowset(object):
639 # Rowsets are collections of Row objects.
641 # Rowsets support most of the list interface:
642 # iteration, indexing and slicing
644 # As well as the following methods:
647 # Returns an IndexRowset keyed on given column. Requires the column to
648 # be usable as primary key.
651 # Returns a FilterRowset keyed on given column. FilterRowset objects
652 # can be accessed like dicts. See FilterRowset class below.
654 # SortBy(column, reverse=True)
655 # Sorts rowset in-place on given column. for a descending sort,
656 # specify reversed=True.
658 # SortedBy(column, reverse=True)
659 # Same as SortBy, except this returns a new rowset object instead of
662 # Select(columns, row=False)
663 # Yields a column values tuple (value, ...) for each row in the rowset.
664 # If only one column is requested, then just the column value is
665 # provided instead of the values tuple.
666 # When row=True, each result will be decorated with the entire row.
669 def IndexedBy(self, column):
670 return IndexRowset(self._cols, self._rows, column)
672 def GroupedBy(self, column):
673 return FilterRowset(self._cols, self._rows, column)
675 def SortBy(self, column, reverse=False):
676 ix = self._cols.index(column)
677 self.sort(key=lambda e: e[ix], reverse=reverse)
679 def SortedBy(self, column, reverse=False):
681 rs.SortBy(column, reverse)
684 def Select(self, *columns, **options):
685 if len(columns) == 1:
686 i = self._cols.index(columns[0])
687 if options.get("row", False):
688 for line in self._rows:
689 yield (line, line[i])
691 for line in self._rows:
694 i = map(self._cols.index, columns)
695 if options.get("row", False):
696 for line in self._rows:
697 yield line, [line[x] for x in i]
699 for line in self._rows:
700 yield [line[x] for x in i]
705 def __init__(self, cols=None, rows=None):
706 self._cols = cols or []
707 self._rows = rows or []
709 def append(self, row):
710 if isinstance(row, list):
711 self._rows.append(row)
712 elif isinstance(row, Row) and len(row._cols) == len(self._cols):
713 self._rows.append(row._row)
715 raise TypeError("incompatible row type")
717 def __add__(self, other):
718 if isinstance(other, Rowset):
719 if len(other._cols) == len(self._cols):
720 self._rows += other._rows
721 raise TypeError("rowset instance expected")
723 def __nonzero__(self):
724 return not not self._rows
727 return len(self._rows)
732 def __getitem__(self, ix):
733 if type(ix) is slice:
734 return Rowset(self._cols, self._rows[ix])
735 return Row(self._cols, self._rows[ix])
737 def sort(self, *args, **kw):
738 self._rows.sort(*args, **kw)
741 return ("Rowset(columns=[%s], rows=%d)" % (','.join(self._cols), len(self)))
743 def __getstate__(self):
744 return (self._cols, self._rows)
746 def __setstate__(self, state):
747 self._cols, self._rows = state
751 class IndexRowset(Rowset):
752 # An IndexRowset is a Rowset that keeps an index on a column.
754 # The interface is the same as Rowset, but provides an additional method:
756 # Get(key [, default])
757 # Returns the Row mapped to provided key in the index. If there is no
758 # such key in the index, KeyError is raised unless a default value was
762 def Get(self, key, *default):
763 row = self._items.get(key, None)
768 return Row(self._cols, row)
772 def __init__(self, cols=None, rows=None, key=None):
775 self._ki = ki = [cols.index(k) for k in key.split(",")]
776 self.composite = True
778 self._ki = ki = cols.index(key)
779 self.composite = False
781 raise ValueError("Rowset has no column %s" % key)
783 Rowset.__init__(self, cols, rows)
787 self._items = dict((tuple([row[k] for k in ki]), row) for row in self._rows)
789 self._items = dict((row[ki], row) for row in self._rows)
791 def __getitem__(self, ix):
792 if type(ix) is slice:
793 return IndexRowset(self._cols, self._rows[ix], self._key)
794 return Rowset.__getitem__(self, ix)
796 def append(self, row):
797 Rowset.append(self, row)
799 self._items[tuple([row[k] for k in self._ki])] = row
801 self._items[row[self._ki]] = row
803 def __getstate__(self):
804 return (Rowset.__getstate__(self), self._items, self._ki)
806 def __setstate__(self, state):
807 state, self._items, self._ki = state
808 Rowset.__setstate__(self, state)
811 class FilterRowset(object):
812 # A FilterRowset works much like an IndexRowset, with the following
814 # - FilterRowsets are accessed much like dicts
815 # - Each key maps to a Rowset, containing only the rows where the value
816 # of the column this FilterRowset was made on matches the key.
818 def __init__(self, cols=None, rows=None, key=None, key2=None, dict=None):
820 self._items = items = dict
821 elif cols is not None:
822 self._items = items = {}
824 idfield = cols.index(key)
829 items[id].append(row)
833 idfield2 = cols.index(key2)
837 items[id][row[idfield2]] = row
839 items[id] = {row[idfield2]:row}
848 self.keys = items.keys
849 self.iterkeys = items.iterkeys
850 self.__contains__ = items.__contains__
851 self.has_key = items.has_key
852 self.__len__ = items.__len__
853 self.__iter__ = items.__iter__
856 return FilterRowset(self._cols[:], None, self.key, self.key2, dict=copy.deepcopy(self._items))
858 def get(self, key, default=_unspecified):
862 if default is _unspecified:
866 def __getitem__(self, i):
868 return IndexRowset(self._cols, None, self.key2, self._items.get(i, {}))
869 return Rowset(self._cols, self._items[i])
871 def __getstate__(self):
872 return (self._cols, self._rows, self._items, self.key, self.key2)
874 def __setstate__(self, state):
875 self._cols, self._rows, self._items, self.key, self.key2 = state