1 #-----------------------------------------------------------------------------
\r
2 # eveapi - EVE Online API access
\r
4 # Copyright (c)2007 Jamie "Entity" van den Berge <entity@vapor.com>
\r
6 # Permission is hereby granted, free of charge, to any person
\r
7 # obtaining a copy of this software and associated documentation
\r
8 # files (the "Software"), to deal in the Software without
\r
9 # restriction, including without limitation the rights to use,
\r
10 # copy, modify, merge, publish, distribute, sublicense, and/or sell
\r
11 # copies of the Software, and to permit persons to whom the
\r
12 # Software is furnished to do so, subject to the following
\r
15 # The above copyright notice and this permission notice shall be
\r
16 # included in all copies or substantial portions of the Software.
\r
18 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
\r
19 # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
\r
20 # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
\r
21 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
\r
22 # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
\r
23 # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
\r
24 # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
\r
25 # OTHER DEALINGS IN THE SOFTWARE
\r
27 #-----------------------------------------------------------------------------
\r
28 # Version: 1.1.1 - 10 Januari 2010
\r
29 # - Fixed bug that causes nested tags to not appear in rows of rowsets created
\r
30 # from normal Elements. This should fix the corp.MemberSecurity method,
\r
31 # which now returns all data for members. [jehed]
\r
33 # Version: 1.1.0 - 15 Januari 2009
\r
34 # - Added Select() method to Rowset class. Using it avoids the creation of
\r
35 # temporary row instances, speeding up iteration considerably.
\r
36 # - Added ParseXML() function, which can be passed arbitrary API XML file or
\r
38 # - Added support for proxy servers. A proxy can be specified globally or
\r
39 # per api connection instance. [suggestion by graalman]
\r
40 # - Some minor refactoring.
\r
41 # - Fixed deprecation warning when using Python 2.6.
\r
43 # Version: 1.0.7 - 14 November 2008
\r
44 # - Added workaround for rowsets that are missing the (required!) columns
\r
45 # attribute. If missing, it will use the columns found in the first row.
\r
46 # Note that this is will still break when expecting columns, if the rowset
\r
47 # is empty. [Flux/Entity]
\r
49 # Version: 1.0.6 - 18 July 2008
\r
50 # - Enabled expat text buffering to avoid content breaking up. [BigWhale]
\r
52 # Version: 1.0.5 - 03 February 2008
\r
53 # - Added workaround to make broken XML responses (like the "row:name" bug in
\r
54 # eve/CharacterID) work as intended.
\r
55 # - Bogus datestamps before the epoch in XML responses are now set to 0 to
\r
56 # avoid breaking certain date/time functions. [Anathema Matou]
\r
58 # Version: 1.0.4 - 23 December 2007
\r
59 # - Changed _autocast() to use timegm() instead of mktime(). [Invisible Hand]
\r
60 # - Fixed missing attributes of elements inside rows. [Elandra Tenari]
\r
62 # Version: 1.0.3 - 13 December 2007
\r
63 # - Fixed keyless columns bugging out the parser (in CorporationSheet for ex.)
\r
65 # Version: 1.0.2 - 12 December 2007
\r
66 # - Fixed parser not working with indented XML.
\r
69 # - Some micro optimizations
\r
77 #-----------------------------------------------------------------------------
\r
83 from xml.parsers import expat
\r
84 from time import strptime
\r
85 from calendar import timegm
\r
89 #-----------------------------------------------------------------------------
\r
91 class Error(StandardError):
\r
92 def __init__(self, code, message):
\r
94 self.args = (message.rstrip("."),)
\r
97 def EVEAPIConnection(url="api.eve-online.com", cacheHandler=None, proxy=None):
\r
98 # Creates an API object through which you can call remote functions.
\r
100 # The following optional arguments may be provided:
\r
102 # url - root location of the EVEAPI server
\r
104 # proxy - (host,port) specifying a proxy server through which to request
\r
105 # the API pages. Specifying a proxy overrides default proxy.
\r
107 # cacheHandler - an object which must support the following interface:
\r
109 # retrieve(host, path, params)
\r
111 # Called when eveapi wants to fetch a document.
\r
112 # host is the address of the server, path is the full path to
\r
113 # the requested document, and params is a dict containing the
\r
114 # parameters passed to this api call (userID, apiKey etc).
\r
115 # The method MUST return one of the following types:
\r
117 # None - if your cache did not contain this entry
\r
118 # str/unicode - eveapi will parse this as XML
\r
119 # Element - previously stored object as provided to store()
\r
120 # file-like object - eveapi will read() XML from the stream.
\r
122 # store(host, path, params, doc, obj)
\r
124 # Called when eveapi wants you to cache this item.
\r
125 # You can use obj to get the info about the object (cachedUntil
\r
126 # and currentTime, etc) doc is the XML document the object
\r
127 # was generated from. It's generally best to cache the XML, not
\r
128 # the object, unless you pickle the object. Note that this method
\r
129 # will only be called if you returned None in the retrieve() for
\r
133 if url.lower().startswith("http://"):
\r
137 url, path = url.split("/", 1)
\r
141 ctx = _RootContext(None, path, {}, {})
\r
142 ctx._handler = cacheHandler
\r
144 ctx._proxy = proxy or globals()["proxy"]
\r
148 def ParseXML(file_or_string):
\r
150 return _ParseXML(file_or_string, False, None)
\r
152 raise TypeError("XML data must be provided as string or file-like object")
\r
155 def _ParseXML(response, fromContext, storeFunc):
\r
156 # pre/post-process XML or Element data
\r
158 if fromContext and isinstance(response, Element):
\r
160 elif type(response) in (str, unicode):
\r
161 obj = _Parser().Parse(response, False)
\r
162 elif hasattr(response, "read"):
\r
163 obj = _Parser().Parse(response, True)
\r
165 raise TypeError("retrieve method must return None, string, file-like object or an Element instance")
\r
167 error = getattr(obj, "error", False)
\r
169 raise Error(error.code, error.data)
\r
171 result = getattr(obj, "result", False)
\r
173 raise RuntimeError("API object does not contain result")
\r
175 if fromContext and storeFunc:
\r
176 # call the cache handler to store this object
\r
179 # make metadata available to caller somehow
\r
188 #-----------------------------------------------------------------------------
\r
190 #-----------------------------------------------------------------------------
\r
192 _listtypes = (list, tuple, dict)
\r
195 class _Context(object):
\r
197 def __init__(self, root, path, parentDict, newKeywords=None):
\r
198 self._root = root or self
\r
202 self.parameters = parentDict.copy()
\r
204 self.parameters = {}
\r
205 self.parameters.update(newKeywords)
\r
207 self.parameters = parentDict or {}
\r
209 def context(self, *args, **kw):
\r
213 path += "/" + "/".join(args)
\r
214 return self.__class__(self._root, path, self.parameters, kw)
\r
218 def __getattr__(self, this):
\r
219 # perform arcane attribute majick trick
\r
220 return _Context(self._root, self._path + "/" + this, self.parameters)
\r
222 def __call__(self, **kw):
\r
224 # specified keywords override contextual ones
\r
225 for k, v in self.parameters.iteritems():
\r
229 # no keywords provided, just update with contextual ones.
\r
230 kw.update(self.parameters)
\r
232 # now let the root context handle it further
\r
233 return self._root(self._path, **kw)
\r
236 class _AuthContext(_Context):
\r
238 def character(self, characterID):
\r
239 # returns a copy of this connection object but for every call made
\r
240 # through it, it will add the folder "/char" to the url, and the
\r
241 # characterID to the parameters passed.
\r
242 return _Context(self._root, self._path + "/char", self.parameters, {"characterID":characterID})
\r
244 def corporation(self, characterID):
\r
245 # same as character except for the folder "/corp"
\r
246 return _Context(self._root, self._path + "/corp", self.parameters, {"characterID":characterID})
\r
249 class _RootContext(_Context):
\r
251 def auth(self, userID=None, apiKey=None):
\r
252 # returns a copy of this object but for every call made through it, the
\r
253 # userID and apiKey will be added to the API request.
\r
254 if userID and apiKey:
\r
255 return _AuthContext(self._root, self._path, self.parameters, {"userID":userID, "apiKey":apiKey})
\r
256 raise ValueError("Must specify userID and apiKey")
\r
258 def setcachehandler(self, handler):
\r
259 self._root._handler = handler
\r
261 def __call__(self, path, **kw):
\r
262 # convert list type arguments to something the API likes
\r
263 for k, v in kw.iteritems():
\r
264 if isinstance(v, _listtypes):
\r
265 kw[k] = ','.join(map(str, list(v)))
\r
267 cache = self._root._handler
\r
269 # now send the request
\r
270 path += ".xml.aspx"
\r
273 response = cache.retrieve(self._host, path, kw)
\r
277 if response is None:
\r
278 if self._proxy is None:
\r
279 http = httplib.HTTPConnection(self._host)
\r
281 http.request("POST", path, urllib.urlencode(kw), {"Content-type": "application/x-www-form-urlencoded"})
\r
283 http.request("GET", path)
\r
285 http = httplib.HTTPConnection(*self._proxy)
\r
287 http.request("POST", 'http://'+self._host+path, urllib.urlencode(kw), {"Content-type": "application/x-www-form-urlencoded"})
\r
289 http.request("GET", 'http://'+self._host+path)
\r
291 response = http.getresponse()
\r
292 if response.status != 200:
\r
293 if response.status == httplib.NOT_FOUND:
\r
294 raise AttributeError("'%s' not available on API server (404 Not Found)" % path)
\r
296 raise RuntimeError("'%s' request failed (%d %s)" % (path, response.status, response.reason))
\r
300 response = response.read()
\r
306 return _ParseXML(response, True, store and (lambda obj: cache.store(self._host, path, kw, response, obj)))
\r
309 #-----------------------------------------------------------------------------
\r
311 #-----------------------------------------------------------------------------
\r
314 # attempts to cast an XML string to the most probable type.
\r
316 if s.strip("-").isdigit():
\r
326 if len(s) == 19 and s[10] == ' ':
\r
327 # it could be a date string
\r
329 return max(0, int(timegm(strptime(s, "%Y-%m-%d %H:%M:%S"))))
\r
330 except OverflowError:
\r
335 # couldn't cast. return string unchanged.
\r
339 class _Parser(object):
\r
341 def Parse(self, data, isStream=False):
\r
342 self.container = self.root = None
\r
343 p = expat.ParserCreate()
\r
344 p.StartElementHandler = self.tag_start
\r
345 p.CharacterDataHandler = self.tag_cdata
\r
346 p.EndElementHandler = self.tag_end
\r
347 p.ordered_attributes = True
\r
348 p.buffer_text = True
\r
353 p.Parse(data, True)
\r
357 def tag_start(self, name, attributes):
\r
359 # If there's a colon in the tag name, cut off the name from the colon
\r
360 # onward. This is a workaround to make certain bugged XML responses
\r
361 # (such as eve/CharacterID.xml.aspx) work.
\r
363 name = name[:name.index(":")]
\r
366 if name == "rowset":
\r
367 # for rowsets, use the given name
\r
369 columns = attributes[attributes.index('columns')+1].split(",")
\r
371 # rowset did not have columns tag set (this is a bug in API)
\r
372 # columns will be extracted from first row instead.
\r
376 priKey = attributes[attributes.index('key')+1]
\r
377 this = IndexRowset(cols=columns, key=priKey)
\r
379 this = Rowset(cols=columns)
\r
382 this._name = attributes[attributes.index('name')+1]
\r
383 this.__catch = "row" # tag to auto-add to rowset.
\r
388 this.__parent = self.container
\r
390 if self.root is None:
\r
391 # We're at the root. The first tag has to be "eveapi" or we can't
\r
392 # really assume the rest of the xml is going to be what we expect.
\r
393 if name != "eveapi":
\r
394 raise RuntimeError("Invalid API response")
\r
397 if isinstance(self.container, Rowset) and (self.container.__catch == this._name):
\r
398 # check for missing columns attribute (see above)
\r
399 if not self.container._cols:
\r
400 self.container._cols = attributes[0::2]
\r
402 self.container.append([_autocast(attributes[i]) for i in range(1, len(attributes), 2)])
\r
404 this._attributes = this._attributes2 = None
\r
406 this._isrow = False
\r
407 this._attributes = attributes
\r
408 this._attributes2 = []
\r
410 self.container = this
\r
413 def tag_cdata(self, data):
\r
414 if data == "\r\n" or data.strip() != data:
\r
417 this = self.container
\r
418 data = _autocast(data)
\r
420 if this._attributes:
\r
421 # this tag has attributes, so we can't simply assign the cdata
\r
422 # as an attribute to the parent tag, as we'll lose the current
\r
423 # tag's attributes then. instead, we'll assign the data as
\r
424 # attribute of this tag.
\r
427 # this was a simple <tag>data</tag> without attributes.
\r
428 # we won't be doing anything with this actual tag so we can just
\r
429 # bind it to its parent (done by __tag_end)
\r
430 setattr(this.__parent, this._name, data)
\r
433 def tag_end(self, name):
\r
434 this = self.container
\r
435 if this is self.root:
\r
436 del this._attributes
\r
437 #this.__dict__.pop("_attributes", None)
\r
440 # we're done with current tag, so we can pop it off. This means that
\r
441 # self.container will now point to the container of element 'this'.
\r
442 self.container = this.__parent
\r
445 attributes = this.__dict__.pop("_attributes")
\r
446 attributes2 = this.__dict__.pop("_attributes2")
\r
447 if attributes is None:
\r
448 # already processed this tag's closure early, in tag_start()
\r
451 if self.container._isrow:
\r
452 # Special case here. tags inside a row! Such tags have to be
\r
453 # added as attributes of the row.
\r
454 parent = self.container.__parent
\r
456 # get the row line for this element from its parent rowset
\r
457 _row = parent._rows[-1]
\r
459 # add this tag's value to the end of the row
\r
460 _row.append(getattr(self.container, this._name, this))
\r
462 # fix columns if neccessary.
\r
463 if len(parent._cols) < len(_row):
\r
464 parent._cols.append(this._name)
\r
466 # see if there's already an attribute with this name (this shouldn't
\r
467 # really happen, but it doesn't hurt to handle this case!
\r
468 sibling = getattr(self.container, this._name, None)
\r
469 if sibling is None:
\r
470 self.container._attributes2.append(this._name)
\r
471 setattr(self.container, this._name, this)
\r
472 # Note: there aren't supposed to be any NON-rowset tags containing
\r
473 # multiples of some tag or attribute. Code below handles this case.
\r
474 elif isinstance(sibling, Rowset):
\r
475 # its doppelganger is a rowset, append this as a row to that.
\r
476 row = [_autocast(attributes[i]) for i in range(1, len(attributes), 2)]
\r
477 row.extend([getattr(this, col) for col in attributes2])
\r
478 sibling.append(row)
\r
479 elif isinstance(sibling, Element):
\r
480 # parent attribute is an element. This means we're dealing
\r
481 # with multiple of the same sub-tag. Change the attribute
\r
482 # into a Rowset, adding the sibling element and this one.
\r
484 rs.__catch = rs._name = this._name
\r
485 row = [_autocast(attributes[i]) for i in range(1, len(attributes), 2)]+[getattr(this, col) for col in attributes2]
\r
487 row = [getattr(sibling, attributes[i]) for i in range(0, len(attributes), 2)]+[getattr(sibling, col) for col in attributes2]
\r
489 rs._cols = [attributes[i] for i in range(0, len(attributes), 2)]+[col for col in attributes2]
\r
490 setattr(self.container, this._name, rs)
\r
492 # something else must have set this attribute already.
\r
493 # (typically the <tag>data</tag> case in tag_data())
\r
496 # Now fix up the attributes and be done with it.
\r
497 for i in range(1, len(attributes), 2):
\r
498 this.__dict__[attributes[i-1]] = _autocast(attributes[i])
\r
505 #-----------------------------------------------------------------------------
\r
506 # XML Data Containers
\r
507 #-----------------------------------------------------------------------------
\r
508 # The following classes are the various container types the XML data is
\r
511 # Note that objects returned by API calls are to be treated as read-only. This
\r
512 # is not enforced, but you have been warned.
\r
513 #-----------------------------------------------------------------------------
\r
515 class Element(object):
\r
516 # Element is a namespace for attributes and nested tags
\r
518 return "<Element '%s'>" % self._name
\r
522 # A Row is a single database record associated with a Rowset.
\r
523 # The fields in the record are accessed as attributes by their respective
\r
526 # To conserve resources, Row objects are only created on-demand. This is
\r
527 # typically done by Rowsets (e.g. when iterating over the rowset).
\r
529 def __init__(self, cols=None, row=None):
\r
530 self._cols = cols or []
\r
531 self._row = row or []
\r
533 def __nonzero__(self):
\r
536 def __ne__(self, other):
\r
537 return self.__cmp__(other)
\r
539 def __eq__(self, other):
\r
540 return self.__cmp__(other) == 0
\r
542 def __cmp__(self, other):
\r
543 if type(other) != type(self):
\r
544 raise TypeError("Incompatible comparison type")
\r
545 return cmp(self._cols, other._cols) or cmp(self._row, other._row)
\r
547 def __getattr__(self, this):
\r
549 return self._row[self._cols.index(this)]
\r
551 raise AttributeError, this
\r
553 def __getitem__(self, this):
\r
554 return self._row[self._cols.index(this)]
\r
557 return "Row(" + ','.join(map(lambda k, v: "%s:%s" % (str(k), str(v)), self._cols, self._row)) + ")"
\r
560 class Rowset(object):
\r
561 # Rowsets are collections of Row objects.
\r
563 # Rowsets support most of the list interface:
\r
564 # iteration, indexing and slicing
\r
566 # As well as the following methods:
\r
568 # IndexedBy(column)
\r
569 # Returns an IndexRowset keyed on given column. Requires the column to
\r
570 # be usable as primary key.
\r
572 # GroupedBy(column)
\r
573 # Returns a FilterRowset keyed on given column. FilterRowset objects
\r
574 # can be accessed like dicts. See FilterRowset class below.
\r
576 # SortBy(column, reverse=True)
\r
577 # Sorts rowset in-place on given column. for a descending sort,
\r
578 # specify reversed=True.
\r
580 # SortedBy(column, reverse=True)
\r
581 # Same as SortBy, except this retuens a new rowset object instead of
\r
582 # sorting in-place.
\r
584 # Select(columns, row=False)
\r
585 # Yields a column values tuple (value, ...) for each row in the rowset.
\r
586 # If only one column is requested, then just the column value is
\r
587 # provided instead of the values tuple.
\r
588 # When row=True, each result will be decorated with the entire row.
\r
591 def IndexedBy(self, column):
\r
592 return IndexRowset(self._cols, self._rows, column)
\r
594 def GroupedBy(self, column):
\r
595 return FilterRowset(self._cols, self._rows, column)
\r
597 def SortBy(self, column, reverse=False):
\r
598 ix = self._cols.index(column)
\r
599 self.sort(key=lambda e: e[ix], reverse=reverse)
\r
601 def SortedBy(self, column, reverse=False):
\r
603 rs.SortBy(column, reverse)
\r
606 def Select(self, *columns, **options):
\r
607 if len(columns) == 1:
\r
608 i = self._cols.index(columns[0])
\r
609 if options.get("row", False):
\r
610 for line in self._rows:
\r
611 yield (line, line[i])
\r
613 for line in self._rows:
\r
616 i = map(self._cols.index, columns)
\r
617 if options.get("row", False):
\r
618 for line in self._rows:
\r
619 yield line, [line[x] for x in i]
\r
621 for line in self._rows:
\r
622 yield [line[x] for x in i]
\r
627 def __init__(self, cols=None, rows=None):
\r
628 self._cols = cols or []
\r
629 self._rows = rows or []
\r
631 def append(self, row):
\r
632 if isinstance(row, list):
\r
633 self._rows.append(row)
\r
634 elif isinstance(row, Row) and len(row._cols) == len(self._cols):
\r
635 self._rows.append(row._row)
\r
637 raise TypeError("incompatible row type")
\r
639 def __add__(self, other):
\r
640 if isinstance(other, Rowset):
\r
641 if len(other._cols) == len(self._cols):
\r
642 self._rows += other._rows
\r
643 raise TypeError("rowset instance expected")
\r
645 def __nonzero__(self):
\r
646 return not not self._rows
\r
649 return len(self._rows)
\r
654 def __getitem__(self, ix):
\r
655 if type(ix) is slice:
\r
656 return Rowset(self._cols, self._rows[ix])
\r
657 return Row(self._cols, self._rows[ix])
\r
659 def sort(self, *args, **kw):
\r
660 self._rows.sort(*args, **kw)
\r
663 return ("Rowset(columns=[%s], rows=%d)" % (','.join(self._cols), len(self)))
\r
665 def __getstate__(self):
\r
666 return (self._cols, self._rows)
\r
668 def __setstate__(self, state):
\r
669 self._cols, self._rows = state
\r
673 class IndexRowset(Rowset):
\r
674 # An IndexRowset is a Rowset that keeps an index on a column.
\r
676 # The interface is the same as Rowset, but provides an additional method:
\r
678 # Get(key [, default])
\r
679 # Returns the Row mapped to provided key in the index. If there is no
\r
680 # such key in the index, KeyError is raised unless a default value was
\r
684 def Get(self, key, *default):
\r
685 row = self._items.get(key, None)
\r
689 raise KeyError, key
\r
690 return Row(self._cols, row)
\r
694 def __init__(self, cols=None, rows=None, key=None):
\r
696 self._ki = ki = cols.index(key)
\r
698 raise ValueError("Rowset has no column %s" % key)
\r
700 Rowset.__init__(self, cols, rows)
\r
702 self._items = dict((row[ki], row) for row in self._rows)
\r
704 def __getitem__(self, ix):
\r
705 if type(ix) is slice:
\r
706 return IndexRowset(self._cols, self._rows[ix], self._key)
\r
707 return Rowset.__getitem__(self, ix)
\r
709 def append(self, row):
\r
710 Rowset.append(self, row)
\r
711 self._items[row[self._ki]] = row
\r
713 def __getstate__(self):
\r
714 return (Rowset.__getstate__(self), self._items, self._ki)
\r
716 def __setstate__(self, state):
\r
717 state, self._items, self._ki = state
\r
718 Rowset.__setstate__(self, state)
\r
721 class FilterRowset(object):
\r
722 # A FilterRowset works much like an IndexRowset, with the following
\r
724 # - FilterRowsets are accessed much like dicts
\r
725 # - Each key maps to a Rowset, containing only the rows where the value
\r
726 # of the column this FilterRowset was made on matches the key.
\r
728 def __init__(self, cols=None, rows=None, key=None, key2=None, dict=None):
\r
729 if dict is not None:
\r
730 self._items = items = dict
\r
731 elif cols is not None:
\r
732 self._items = items = {}
\r
734 idfield = cols.index(key)
\r
739 items[id].append(row)
\r
743 idfield2 = cols.index(key2)
\r
747 items[id][row[idfield2]] = row
\r
749 items[id] = {row[idfield2]:row}
\r
757 items = self._items
\r
758 self.keys = items.keys
\r
759 self.iterkeys = items.iterkeys
\r
760 self.__contains__ = items.__contains__
\r
761 self.has_key = items.has_key
\r
762 self.__len__ = items.__len__
\r
763 self.__iter__ = items.__iter__
\r
766 return FilterRowset(self._cols[:], None, self.key, self.key2, dict=copy.deepcopy(self._items))
\r
768 def get(self, key, default=_unspecified):
\r
772 if default is _unspecified:
\r
776 def __getitem__(self, i):
\r
778 return IndexRowset(self._cols, None, self.key2, self._items.get(i, {}))
\r
779 return Rowset(self._cols, self._items[i])
\r
781 def __getstate__(self):
\r
782 return (self._cols, self._rows, self._items, self.key, self.key2)
\r
784 def __setstate__(self, state):
\r
785 self._cols, self._rows, self._items, self.key, self.key2 = state
\r