From 814c7262ed1c9863ea6106840c217a69182eced3 Mon Sep 17 00:00:00 2001 From: schneefux Date: Sat, 14 Jan 2017 21:19:48 +0100 Subject: rewrite API to parse with xamoom-janus --- gamelocker/__init__.py | 1 + gamelocker/api.py | 376 +++++++++-------------------------------------- gamelocker/datatypes.py | 177 ++++++++++++++++++++++ tests/gamelocker_test.py | 54 +++---- 4 files changed, 266 insertions(+), 342 deletions(-) create mode 100644 gamelocker/datatypes.py diff --git a/gamelocker/__init__.py b/gamelocker/__init__.py index c6798dd..acd0bd8 100644 --- a/gamelocker/__init__.py +++ b/gamelocker/__init__.py @@ -8,3 +8,4 @@ Dead-simple Python wrapper for the Gamelocker API. """ from .api import * +from .janus import * diff --git a/gamelocker/api.py b/gamelocker/api.py index 4595ce5..dee307d 100644 --- a/gamelocker/api.py +++ b/gamelocker/api.py @@ -1,6 +1,5 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -# pylint: disable=too-few-public-methods,fixme # TODO: generate documentation """ @@ -9,310 +8,10 @@ requests.api This module implements the Gamelocker API. """ +import inspect import requests - - -class Utils(object): - """Utility functions.""" - - def search_dict(self, dic, attr, val): - """Find a key-value pair recursively in a dictionary. - - :param dic: Dictionary to search. - :type dic: dict - :param attr: Key to look for. - :type attr: str - :param val: Value the key needs to have. - :param val: object - :returns: (bool) Whether the dictionary contains the pair. - :rtype: bool - """ - for key in dic: - if isinstance(dic[key], dict): - if self.search_dict(dic[key], attr, val): - return True - else: - if key == attr and dic[key] == val: - return True - - return False - - -class Response(object): - """A generic API response object. - - :param errors: `errors` as returned by the API. - :param data: `errors` as returned by the API. - :param links: `links` as returned by the API. - :param included: `included` as returned by the API. - :param raw: Raw response. - :type raw: dict - """ - - def __init__(self, data): - """Constructs a :class:`Response `. - - :param data: Dictionary from API response JSON. - :type data: dict - :return: :class:`Response ` object - :rtype: gamelocker.Response - """ - if "errors" in data: - self.errors = data["errors"] - else: - self.errors = None - if "data" in data: - self.data = data["data"] - else: - self.data = None - if "links" in data: - self.links = data["links"] - else: - self.links = None - if "included" in data: - self.included = data["included"] - else: - self.included = None - self.raw = data - if self.errors: - raise AttributeError( - "API returned errors: {errors}".format( - errors=repr(self.errors))) - - def filter(self, fid, ftype): - """Returns an element from `data` matching the criteria. - - :param fid: ID to look for. - :type fid: str - :param ftype: Obect type to look for. - :type ftype: str - :return: Element matching the filter criteria - :rtype: dict - """ - for include in self.included: - if include["id"] == fid and include["type"] == ftype: - return include - for datum in self.data: - if datum["id"] == fid and datum["type"] == ftype: - return datum - - return None - - -# TODO: implement a Matches.players() -class Matches(object): - """A collection of :class:`Match ` objects. - - :param matches: A list of matches. - :type matches: list - :param length: The number of matches. - :type length: int - """ - - def __init__(self, data, matches=None): - """Constructs a :class:`Matches `. - - :param data: API data. - :type data: gamelocker.Response - :param matches: (optional) List of matches to construct - collection from. Overrides `data` matches. - :type matches: list of :class:`Matches` - :return: :class:`Matches ` object - :rtype: gamelocker.Matches - """ - self._data = data - self.matches = [] - if matches is not None: - self.matches = matches - else: - for match in self._data.data: - self.matches.append(Match(self._data, match["id"])) - self.length = len(self.matches) - - def __getitem__(self, key): - return self.matches[key] - - def where(self, attribute, value): - """Searches for matches where the condition is met. - - :param attribute: Attribute to look for. - :type attribute: str - :param value: Requested value for that attribute. - :type value: str, int, object - :return: Collection of :class:`Match` matching the criteria. - :rtype: gamelocker.Matches - """ - matches = [] - for match in self.matches: - if match.has(attribute, value): - matches.append(match) - return Matches(self._data, matches) - - -class Match(object): - """A Match record. - - :param attributes: `attributes` as returned by the API. - :param rosters: List of :class:`Roster ` objects - related to the match. - :type rosters: list - """ - - def __init__(self, data, mid): - """Constructs a :class:`Match ` from a :class:`Response`. - - :param data: API data. - :type data: gamelocker.Response - :param mid: Match ID. - :type mid: str - :return: :class:`Match ` object - :rtype: gamelocker.Match - """ - self._data = data - self._id = mid - match = self._data.filter(mid, "match") - self.attributes = match["attributes"] - self.rosters = [Roster(self._data, r["id"]) - for r in match["relationships"]["rosters"]["data"]] - - # TODO refactor duplicated code - def has(self, attribute, value, recurse=True): - """Checks whether Match or (optionally) a child owns an `attribute` - with `value`. - - :param attribute: Attribute to look for. - :type attribute: str - :param value: Requested value for that attribute. - :type value: str, int, object - :param recurse: Whether to search relationships or not. - :type recurse: bool - :return: bool - :rtype: bool - """ - if Utils().search_dict(self.attributes, attribute, value): - return True - if recurse: - for roster in self.rosters: - if roster.has(attribute, value): - return True - - return False - - -class Roster(object): - """A Roster object. - - :param attributes: `attributes` as returned by the API. - :param participants: List of :class:`Participant ` objects - related to the roster. - :type rosters: list - """ - - def __init__(self, data, rid): - """Constructs a specified :class:`Roster ` from a :class:`Response`. - - :param data: API data. - :type data: gamelocker.Response - :param rid: Roster ID. - :type rid: str - :return: :class:`Roster ` object - :rtype: gamelocker.Roster - """ - self._data = data - self._id = rid - roster = self._data.filter(rid, "roster") - self.attributes = roster["attributes"] - self.participants = [ - Participant(self._data, p["id"]) - for p in roster["relationships"]["participants"]["data"] - ] - - def has(self, attribute, value, recurse=True): - """Checks whether Roster or (optionally) a child owns an `attribute` - with `value`. - - :param attribute: Attribute to look for. - :type attribute: str - :param value: Requested value for that attribute. - :type value: str, int, object - :param recurse: Whether to search relationships or not. - :type recurse: bool - :return: bool - :rtype: bool - """ - if Utils().search_dict(self.attributes, attribute, value): - return True - if recurse: - for participant in self.participants: - if participant.has(attribute, value): - return True - - return False - - -class Participant(object): - """A Participant object. - - :param attributes: `attributes` as returned by the API. - :param player: :class:`Player ` object related to the participant. - :type player: gamelocker.Player - """ - - def __init__(self, data, pid): - """Constructs a specified :class:`Participant ` - from a :class:`Response`. - - :param data: API data. - :type data: gamelocker.Response - :param pid: Participant ID. - :type pid: str - :return: :class:`Participant ` object - :rtype: gamelocker.Participant - """ - self._data = data - self._id = pid - participant = self._data.filter(pid, "participant") - self.attributes = participant["attributes"] - self.player = Player(self._data, - participant["relationships"]["player"] - ["data"]["id"]) - - def has(self, attribute, value): - """Checks whether Participant or (optionally) owns an `attribute` - with `value`. - - :param attribute: Attribute to look for. - :type attribute: str - :param value: Requested value for that attribute. - :type value: str, int, object - :return: bool - :rtype: bool - """ - if Utils().search_dict(self.attributes, attribute, value): - return True - return False - - -class Player(object): - """A Player object. - - :param attributes: `attributes` as returned by the API. - """ - - def __init__(self, data, pid): - """Constructs a specified :class:`Player ` from a :class:`Response`. - - :param data: API data. - :type data: gamelocker.Response - :param pid: Player ID. - :type pid: str - :return: :class:`Player ` object - :rtype: gamelocker.Player - """ - self._data = data - self._id = pid - player = self._data.filter(pid, "player") - self.attributes = player["attributes"] +import requests_jwt +import gamelocker.datatypes class Gamelocker(object): @@ -349,7 +48,9 @@ class Gamelocker(object): """Sends a GET request to the API endpoint. :param method: Method to query. + :type method: str :param params: (optional) Parameters to send. + :type params: dict :return: Parsed JSON object. :rtype: dict """ @@ -362,7 +63,44 @@ class Gamelocker(object): headers=headers, params=params) http.raise_for_status() - return Response(http.json()) + return http.json() + + def _get(self, endpoint, elid="", params=None): + """Returns an object or a list of objects from the API. + + :param endpoint: API slug to use. + :type endpoint: str + :param elid: (optional) ID of the object to query for. + :type elid: str + :param params: (optional) Parameters to pass with the http request. + :type params: dict + :return: Data object. + :rtype: :class:`janus.DataMessage` + """ + data = self._req(endpoint + "/" + elid, params=params) + + # collect related data + includes = [] + if "included" in data: + for incl in data["included"]: + element = gamelocker.datatypes.data_to_object(incl) + includes.append(element) + + # main data object + if isinstance(data["data"], (list, tuple)): + elements = [] + for dat in data["data"]: + element = gamelocker.datatypes.data_to_object(dat) + # link related data + element = gamelocker.datatypes.link_to_object( + element, includes) + elements.append(element) + return elements + else: + element = gamelocker.datatypes.data_to_object(data["data"]) + # link related data + element = gamelocker.datatypes.link_to_object(element, includes) + return element def vainglory(self): """Sets title to Vainglory. @@ -374,12 +112,32 @@ class Gamelocker(object): return self def status(self): - """Returns the API version. + """Returns the API status JSON string. - :return: API version. + :return: API status JSON. :rtype: str """ - return self._req("status").raw["version"] + return self._req("status") + + def match(self, elid): + """Returns a match. + + :param elid: ID of the match. + :type elid: str + :return: A match with the given ID. + :rtype: :class:`Match` + """ + return self._get("matches", elid) + + def player(self, elid): + """Returns a player. + + :param elid: ID of the player. + :type elid: str + :return: A player with the given ID. + :rtype: :class:`Player` + """ + return self._get("players", elid) def matches(self, limit=None, offset=None, sort=None): """Returns a list of recent matches. @@ -401,4 +159,4 @@ class Gamelocker(object): params["page[offset]"] = offset if sort: # TODO make this nice and usable params["sort"] = sort - return Matches(self._req("matches", params)) + return self._get("matches", params=params) diff --git a/gamelocker/datatypes.py b/gamelocker/datatypes.py new file mode 100644 index 0000000..92848ad --- /dev/null +++ b/gamelocker/datatypes.py @@ -0,0 +1,177 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# pylint: disable=missing-docstring + +import inspect +import sys +from gamelocker.janus import DataMessage, Attribute + + +def attr(typ, key, relation=False): + """Convenience function to specify an attribute or a relation. + + :param typ: Allowed data type or class. + :type typ: object + :param key: Object name, mapping name and key mapping name. + :type key: str + :param link: (optional) Whether this is a relation. + :return: `DataMessage` `Attribute` class. + :rtype: :class:`Attribute` + """ + if relation: + return Attribute(value_type=typ, name=key, + mapping=key, key_mapping=key) + else: + return Attribute(value_type=typ, name=key, + mapping=key) + + +def rel(typ, key): + """Convenience function to specify a relation. + See `attr()`. + """ + return attr(typ, key, True) + + +class Player(DataMessage): + type_name = "player" + key_id = attr(str, "id") + + name = attr(str, "name") + stats = rel(dict, "stats") + + +class Participant(DataMessage): + type_name = "participant" + key_id = attr(str, "id") + + actor = attr(str, "actor") + stats = attr(str, "stats") + + player = rel(Player, "player") + + +class Team(DataMessage): + type_name = "team" + key_id = attr(str, "id") + + name = rel(str, "name") + + +class Roster(DataMessage): + type_name = "roster" + key_id = attr(str, "id") + + stats = attr(str, "stats") + + participants = rel(Participant, "participants") + team = rel(Team, "team") + + +class Match(DataMessage): + type_name = "match" + key_id = attr(str, "id") + + createdAt = attr(str, "createdAt") + duration = attr(int, "duration") + gameMode = attr(str, "gameMode") + patchVersion = attr(str, "patchVersion") + region = attr(str, "region") + stats = attr(dict, "stats") + + rosters = rel(Roster, "rosters") + + +def modulemap(): + """Returns a dictionary where `type_name`s are mapped + to corresponding classes from `gamelocker.datatypes`. + + :return: A dictionary with keys of `type_name` + and values of :class:`DataMessage` subclasses. + :rtype: :class:`gamelocker.janus.DataMessage` + """ + typemap = dict() + classes = inspect.getmembers(sys.modules[__name__], inspect.isclass) + for name, value in classes: + if name == "Attribute" or name == "DataMessage": + continue + typemap[value.type_name] = value + + return typemap + + +def data_to_object(data): + """Given a data dictionary from the API, returns mapped objects or None. + + :param data: Dictionary from API request. + :type data: dict + :return: An object. + :rtype: :class:`gamelocker.janus.DataMessage` + """ + # find the appropriate class from /datatypes.py + typemap = modulemap() + datatype = None + for key, dataclass in typemap.items(): + if key == data["type"]: + datatype = dataclass + if datatype is None: + raise NotImplementedError( + "Data type " + data["type"] + " is not implemented yet.") + + # load data into class + element = datatype() + element.map_message(data) + + return element + + +def link_to_object(obj, relations): + """Replaces references in `obj` + by their corresponding objects from `relations`. + + :param obj: Master object to populate. + :type obj: :class:`gamelocker.janus.DataMessage` + :param relations: Object pool to populate from. + :type obj: list of :class:`gamelocker.janus.DataMessage` + :return: The populated master object. + :rtype: :class:`gamelocker.janus.DataMessage` + """ + for att in dir(obj): + # loop through all relations that object could possibly have + if not(isinstance(object.__getattribute__(obj, att), Attribute) and + issubclass( + object.__getattribute__(obj, att).value_type, DataMessage) + and not att.startswith("__")): + continue + + # try to get the objects that are being linked to + children = object.__getattribute__(obj, att).value + if children is None: + # there is no reference -> master class has object + # but it was not populated with data + # so it is (hopefully) optional. + continue + + # can either be a reference or a list of references + if isinstance(children, (list, tuple)): + newlist = [] + for child in children: + # replaces every element + for relation in relations: + # see below + if relation.id == child.id: + relation = link_to_object(relation, relations) + newlist.append(relation) + break + object.__setattr__(obj, att, newlist) + else: + for relation in relations: + if relation.id == children.id: + # replace unpopulated relation + # with object from `relations` that has data + # also, replace all references in that object (recursively) + relation = link_to_object(relation, relations) + object.__setattr__(obj, att, relation) + break + + return obj diff --git a/tests/gamelocker_test.py b/tests/gamelocker_test.py index 9220d43..0b61d8f 100644 --- a/tests/gamelocker_test.py +++ b/tests/gamelocker_test.py @@ -11,43 +11,31 @@ class TestGamelocker: def api(self): return gamelocker.Gamelocker(self.apikey).vainglory() - def test_utils(self): - assert gamelocker.Utils().search_dict({"a": "b", "c": "d", "e": 2}, "e", 2) == True - assert gamelocker.Utils().search_dict({"a": "b", "c": {"d":1, "e": 2}}, "e", 2) == True - assert gamelocker.Utils().search_dict({"a": "b", "c": {"d":1, "e": 2}}, "f", 1) == False - def test_req(self, api): with pytest.raises(requests.exceptions.HTTPError): assert api._req("foobar") -# with pytest.raises(AttributeError): # TODO write this test -# pass - assert api._req("status").raw["status"] == 200 - assert len(api._req("matches", {"page[limit]": 10}).raw["data"]) == 10 + assert type(api._req("status")) is dict + + assert "status" in api.status() - def test_status(self, api): - assert type(api.status()) is str + def test_map(self): + assert gamelocker.datatypes.modulemap()["match"] is gamelocker.datatypes.Match - def test_matches(self, api): - assert gamelocker.Matches(None, []).length == 0 - assert gamelocker.Matches(None, [None, None]).length == 2 + def test_match(self, api): + match = api.match("91cf2ee4-d7d0-11e6-ad79-062445d3d668") + assert match.gameMode == "casual" + assert isinstance(match.rosters[0], gamelocker.datatypes.Roster) + assert isinstance(match.rosters[0].participants[0], gamelocker.datatypes.Participant) + assert isinstance(match.rosters[0].participants[0].player, gamelocker.datatypes.Player) + assert isinstance(match.rosters[0].participants[0].player.name, str) matches = api.matches() - assert type(matches) is gamelocker.Matches - assert type(matches[0]) is gamelocker.Match - assert type(matches[0].rosters[0]) is gamelocker.Roster - assert type(matches[0].rosters[0].participants[0]) is gamelocker.Participant - assert type(matches[0].rosters[0].participants[0].attributes["actor"]) is str - assert type(matches[0].rosters[0].participants[0].player) is gamelocker.Player - assert type(matches[0].rosters[0].participants[0].player.attributes["name"]) is str - - def test_filter(self, api): - assert api.matches().where("gameMode", "ranked").length > 0 - assert api.matches().where("gameMode", "foobar").length == 0 - assert api.matches(3).where("side", "left/blue").length == 3 - # in 3 games, there were 3 teams who were on the left side ;) - - def test_pagination(self, api): - assert api.matches(50).length <= 50 - assert api.matches(10).length <= 10 - assert api.matches(3).length == 3 - api.matches(10, offset=30) + assert len(matches) > 0 + assert isinstance(matches[0], gamelocker.datatypes.Match) + assert matches[0].duration > 0 + + assert len(api.matches(limit=5)) == 5 + + def test_player(self, api): + assert api.player("6abb30de-7cb8-11e4-8bd3-06eb725f8a76").name == "boombastic04" + assert "lossStreak" in api.player("6abb30de-7cb8-11e4-8bd3-06eb725f8a76").stats -- cgit v1.3.1