diff --git a/docs/api/steam.client.builtins.rst b/docs/api/steam.client.builtins.rst index 00c7025..0aa6fe9 100644 --- a/docs/api/steam.client.builtins.rst +++ b/docs/api/steam.client.builtins.rst @@ -27,3 +27,18 @@ Misc :undoc-members: :show-inheritance: +Unified Messages +---------------- + +.. automodule:: steam.client.builtins.unified_messages + :members: + :undoc-members: + :show-inheritance: + +Leaderboards +------------ + +.. automodule:: steam.client.builtins.leaderboards + :members: + :undoc-members: + :show-inheritance: diff --git a/steam/client/builtins/__init__.py b/steam/client/builtins/__init__.py index cec8662..f2289f3 100644 --- a/steam/client/builtins/__init__.py +++ b/steam/client/builtins/__init__.py @@ -4,8 +4,10 @@ All high level features of :class:`steam.client.SteamClient` are implemented her from steam.client.builtins.misc import Misc from steam.client.builtins.user import User from steam.client.builtins.web import Web +from steam.client.builtins.unified_messages import UnifiedMessages +from steam.client.builtins.leaderboards import Leaderboards -class BuiltinBase(Misc, User, Web): +class BuiltinBase(Misc, User, Web, UnifiedMessages, Leaderboards): """ This object is used as base to implement all high level functionality. The features are separated into submodules. diff --git a/steam/client/builtins/leaderboards.py b/steam/client/builtins/leaderboards.py new file mode 100644 index 0000000..13d9832 --- /dev/null +++ b/steam/client/builtins/leaderboards.py @@ -0,0 +1,170 @@ +""" +Reading the leaderboards with :class:`SteamLeaderboard` is as easy as iterating over a list. +""" +import logging +from steam.core.msg import MsgProto +from steam.enums import EResult, ELeaderboardDataRequest, ELeaderboardSortMethod, ELeaderboardDisplayType +from steam.enums.emsg import EMsg +from steam.util import _range, chunks +from steam.util.throttle import ConstantRateLimit + + +class Leaderboards(object): + def __init__(self, *args, **kwargs): + super(Leaderboards, self).__init__(*args, **kwargs) + + def get_leaderboard(self, app_id, name): + """.. versionadded:: 0.8.2 + + Find a leaderboard + + :param app_id: application id + :type app_id: :class:`int` + :param name: leaderboard name + :type name: :class:`str` + :return: leaderboard instance + :rtype: :class:`SteamLeaderboard` + :raises: :class:`LookupError` on message timeout or error + """ + message = MsgProto(EMsg.ClientLBSFindOrCreateLB) + message.header.routing_appid = app_id + message.body.app_id = app_id + message.body.leaderboard_name = name + message.body.create_if_not_found = False + + resp = self.send_job_and_wait(message, timeout=15) + + if not resp: + raise LookupError("Didn't receive response within 15seconds :(") + if resp.eresult != EResult.OK: + raise LookupError(EResult(resp.eresult)) + + return SteamLeaderboard(self, app_id, name, resp) + + +class SteamLeaderboard(object): + """.. versionadded:: 0.8.2 + + Steam leaderboard object. + Generated via :meth:`Misc.get_leaderboard()` + Works more or less like a :class:`list` to access entries. + + .. note:: + Each slice will produce a message to steam. + Steam and protobufs might not like large slices. + Avoid accessing individual entries by index and instead use iteration or well sized slices. + + Example usage: + + .. code:: python + + lb = client.get_leaderboard(...) + + for entry in lb[:100]: # top 100 + print entry + """ + app_id = 0 + name = '' #: leaderboard name + id = 0 #: leaderboard id + entry_count = 0 + sort_method = ELeaderboardSortMethod.NONE #: :class:`steam.enums.common.ELeaderboardSortMethod` + display_type = ELeaderboardDisplayType.NONE #: :class:`steam.enums.common.ELeaderboardDisplayType` + data_request = ELeaderboardDataRequest.Global #: :class:`steam.enums.common.ELeaderboardDataRequest` + + def __init__(self, steam, app_id, name, data): + self._steam = steam + self.app_id = app_id + + for field in data.DESCRIPTOR.fields: + if field.name.startswith('leaderboard_'): + self.__dict__[field.name.replace('leaderboard_', '')] = getattr(data, field.name) + + self.sort_method = ELeaderboardSortMethod(self.sort_method) + self.display_type = ELeaderboardDisplayType(self.display_type) + + def __repr__(self): + return "<%s(%d, %s, %d, %s, %s)>" % ( + self.__class__.__name__, + self.app_id, + repr(self.name), + len(self), + self.sort_method, + self.display_type, + ) + + def __len__(self): + return self.entry_count + + def get_entries(self, start=0, end=0, data_request=ELeaderboardDataRequest.Global): + """Get leaderboard entries. + + :param start: start entry, not index (e.g. rank 1 is `start=1`) + :type start: :class:`int` + :param end: end entry, not index (e.g. only one entry then `start=1,end=1`) + :type end: :class:`int` + :param data_request: data being requested + :type data_request: :class:`steam.enums.common.ELeaderboardDataRequest` + :return: a list of entries, see `CMsgClientLBSGetLBEntriesResponse` + :rtype: :class:`list` + :raises: :class:`LookupError` on message timeout or error + """ + message = MsgProto(EMsg.ClientLBSGetLBEntries) + message.body.app_id = self.app_id + message.body.leaderboard_id = self.id + message.body.range_start = start + message.body.range_end = end + message.body.leaderboard_data_request = data_request + + resp = self._steam.send_job_and_wait(message, timeout=15) + + if not resp: + raise LookupError("Didn't receive response within 15seconds :(") + if resp.eresult != EResult.OK: + raise LookupError(EResult(resp.eresult)) + + return resp.entries + + def __getitem__(self, x): + if isinstance(x, slice): + stop_max = len(self) + start = 0 if x.start is None else x.start if x.start >= 0 else max(0, x.start + stop_max) + stop = stop_max if x.stop is None else x.stop if x.stop >= 0 else max(0, x.stop + stop_max) + step = x.step or 1 + if step < 0: + start, stop = stop, start + step = abs(step) + else: + start, stop, step = x, x + 1, 1 + + if start >= stop: return [] + + entries = self.get_entries(start+1, stop, self.data_request) + + return [entries[i] for i in _range(0, len(entries), step)] + + def get_iter(self, times, seconds, chunk_size=2000): + """Make a iterator over the entries + + See :class:`steam.util.throttle.ConstantRateLimit` for ``times`` and ``seconds`` parameters. + + :param chunk_size: number of entries per request + :type chunk_size: :class:`int` + :returns: generator object + :rtype: :class:`generator` + + The iterator essentially buffers ``chuck_size`` number of entries, and ensures + we are not sending messages too fast. + For example, the ``__iter__`` method on this class uses ``get_iter(1, 1, 2000)`` + """ + def entry_generator(): + with ConstantRateLimit(times, seconds, use_gevent=True) as r: + for entries in chunks(self, chunk_size): + if not entries: + raise StopIteration + for entry in entries: + yield entry + r.wait() + return entry_generator() + + def __iter__(self): + return self.get_iter(1, 1, 2000) diff --git a/steam/client/builtins/misc.py b/steam/client/builtins/misc.py index c67709d..d0dea3d 100644 --- a/steam/client/builtins/misc.py +++ b/steam/client/builtins/misc.py @@ -1,22 +1,14 @@ """ Various features that don't have a category """ -import logging -from eventemitter import EventEmitter -from steam.core.msg import MsgProto, get_um -from steam.enums import EResult, ELeaderboardDataRequest, ELeaderboardSortMethod, ELeaderboardDisplayType +from steam.core.msg import MsgProto from steam.enums.emsg import EMsg -from steam.util import WeakRefKeyDict, _range, chunks, proto_fill_from_dict -from steam.util.throttle import ConstantRateLimit class Misc(object): def __init__(self, *args, **kwargs): super(Misc, self).__init__(*args, **kwargs) - name = "%s.unified_messages" % self.__class__.__name__ - self.unified_messages = SteamUnifiedMessages(self, name) #: instance of :class:`SteamUnifiedMessages` - def games_played(self, app_ids): """ Set the application being played by the user @@ -35,289 +27,3 @@ class Misc(object): message.body.games_played.extend(map(lambda x: GamePlayed(game_id=x), app_ids)) self.send(message) - - def get_leaderboard(self, app_id, name): - """.. versionadded:: 0.8.2 - - Find a leaderboard - - :param app_id: application id - :type app_id: :class:`int` - :param name: leaderboard name - :type name: :class:`str` - :return: leaderboard instance - :rtype: :class:`SteamLeaderboard` - :raises: :class:`LookupError` on message timeout or error - """ - message = MsgProto(EMsg.ClientLBSFindOrCreateLB) - message.header.routing_appid = app_id - message.body.app_id = app_id - message.body.leaderboard_name = name - message.body.create_if_not_found = False - - resp = self.send_job_and_wait(message, timeout=15) - - if not resp: - raise LookupError("Didn't receive response within 15seconds :(") - if resp.eresult != EResult.OK: - raise LookupError(EResult(resp.eresult)) - - return SteamLeaderboard(self, app_id, name, resp) - -class SteamUnifiedMessages(EventEmitter): - """Simple API for send/recv of unified messages - - Incoming messages are emitted as events once with their ``jobid`` - and once with their method name (e.g. ``Player.GetGameBadgeLevels#1``) - - Example code: - - .. code:: python - - response = client.unified_messages.send_and_wait('Player.GetGameBadgeLevels#1', { - 'property': 1, - 'something': 'value', - }) - - # or alternatively - - jobid = client.unified_message.send('Player.GetGameBadgeLevels#1', {'something': 1}) - response, = client.unified_message.wait_event(jobid) - - # or - - message = client.unified_message.get('Player.GetGameBadgeLevels#1') - message.something = 1 - response = client.unified_message.send_and_wait(message) - """ - def __init__(self, steam, logger_name=None): - self._LOG = logging.getLogger(logger_name if logger_name else self.__class__.__name__) - self._steam = steam - self._data = WeakRefKeyDict() - - steam.on(EMsg.ServiceMethod, self._handle_service_method) - steam.on(EMsg.ClientServiceMethodResponse, self._handle_client_service_method) - - def emit(self, event, *args): - if event is not None: - self._LOG.debug("Emit event: %s" % repr(event)) - EventEmitter.emit(self, event, *args) - - def _handle_service_method(self, message): - self.emit(message.header.target_job_name, message.body) - - def _handle_client_service_method(self, message): - method_name = message.body.method_name - proto = get_um(method_name, response=True) - - if proto is None: - self._LOG("Unable to find proto for %s" % repr(method_name)) - return - - resp = proto() - resp.ParseFromString(message.body.serialized_method_response) - - self.emit(method_name, resp) - - jobid = message.header.jobid_target - if jobid not in (-1, 18446744073709551615): - self.emit("job_%d" % jobid, resp) - - def get(self, method_name): - """Get request proto instance for given methed name - - :param method_name: name for the method (e.g. ``Player.GetGameBadgeLevels#1``) - :type method_name: :class:`str` - :return: proto message instance, or :class:`None` if not found - """ - proto = get_um(method_name) - if proto is None: - return None - message = proto() - self._data[message] = method_name - return message - - def send(self, message, params=None): - """Send service method request - - :param message: - proto message instance (use :meth:`SteamUnifiedMessages.get`) - or method name (e.g. ``Player.GetGameBadgeLevels#1``) - :type message: :class:`str`, proto message instance - :param params: message parameters - :type params: :class:`dict` - :return: ``jobid`` event identifier - :rtype: :class:`str` - - Listen for ``jobid`` on this object to catch the response. - - .. note:: - If you listen for ``jobid`` on the client instance you will get the encapsulated message - """ - if isinstance(message, str): - message = self.get(message) - if message not in self._data: - raise ValueError("Supplied message seems to be invalid. Did you use 'get' method?") - - if params: - proto_fill_from_dict(message, params) - - capsule = MsgProto(EMsg.ClientServiceMethod) - capsule.body.method_name = self._data[message] - capsule.body.serialized_method = message.SerializeToString() - - return self._steam.send_job(capsule) - - def send_and_wait(self, message, params=None, timeout=None, raises=False): - """Send service method request and wait for response - - :param message: - proto message instance (use :meth:`SteamUnifiedMessages.get`) - or method name (e.g. ``Player.GetGameBadgeLevels#1``) - :type message: :class:`str`, proto message instance - :param params: message parameters - :type params: :class:`dict` - :param timeout: (optional) seconds to wait - :type timeout: :class:`int` - :param raises: (optional) On timeout if :class:`False` return :class:`None`, else raise :class:`gevent.Timeout` - :type raises: :class:`bool` - :return: response proto message instance - :rtype: proto message, :class:`None` - :raises: ``gevent.Timeout`` - """ - job_id = self.send(message, params) - resp = self.wait_event(job_id, timeout, raises=raises) - if resp is None and not raises: - return None - else: - return resp[0] - - -class SteamLeaderboard(object): - """.. versionadded:: 0.8.2 - - Steam leaderboard object. - Generated via :meth:`Misc.get_leaderboard()` - Works more or less like a :class:`list` to access entries. - - .. note:: - Each slice will produce a message to steam. - Steam and protobufs might not like large slices. - Avoid accessing individual entries by index and instead use iteration or well sized slices. - - Example usage: - - .. code:: python - - lb = client.get_leaderboard(...) - - print len(lb) - - for entry in lb[:100]: # top 100 - pass - """ - app_id = 0 - name = '' #: leaderboard name - id = 0 #: leaderboard id - entry_count = 0 - sort_method = ELeaderboardSortMethod.NONE #: :class:`steam.enums.common.ELeaderboardSortMethod` - display_type = ELeaderboardDisplayType.NONE #: :class:`steam.enums.common.ELeaderboardDisplayType` - data_request = ELeaderboardDataRequest.Global #: :class:`steam.enums.common.ELeaderboardDataRequest` - - def __init__(self, steam, app_id, name, data): - self._steam = steam - self.app_id = app_id - - for field in data.DESCRIPTOR.fields: - if field.name.startswith('leaderboard_'): - self.__dict__[field.name.replace('leaderboard_', '')] = getattr(data, field.name) - - self.sort_method = ELeaderboardSortMethod(self.sort_method) - self.display_type = ELeaderboardDisplayType(self.display_type) - - def __repr__(self): - return "<%s(%d, %s, %d, %s, %s)>" % ( - self.__class__.__name__, - self.app_id, - repr(self.name), - len(self), - self.sort_method, - self.display_type, - ) - - def __len__(self): - return self.entry_count - - def get_entries(self, start=0, end=0, data_request=ELeaderboardDataRequest.Global): - """Get leaderboard entries. - - :param start: start entry, not index (e.g. rank 1 is `start=1`) - :type start: :class:`int` - :param end: end entry, not index (e.g. only one entry then `start=1,end=1`) - :type end: :class:`int` - :param data_request: data being requested - :type data_request: :class:`steam.enums.common.ELeaderboardDataRequest` - :return: a list of entries, see `CMsgClientLBSGetLBEntriesResponse` - :rtype: :class:`list` - :raises: :class:`LookupError` on message timeout or error - """ - message = MsgProto(EMsg.ClientLBSGetLBEntries) - message.body.app_id = self.app_id - message.body.leaderboard_id = self.id - message.body.range_start = start - message.body.range_end = end - message.body.leaderboard_data_request = data_request - - resp = self._steam.send_job_and_wait(message, timeout=15) - - if not resp: - raise LookupError("Didn't receive response within 15seconds :(") - if resp.eresult != EResult.OK: - raise LookupError(EResult(resp.eresult)) - - return resp.entries - - def __getitem__(self, x): - if isinstance(x, slice): - stop_max = len(self) - start = 0 if x.start is None else x.start if x.start >= 0 else max(0, x.start + stop_max) - stop = stop_max if x.stop is None else x.stop if x.stop >= 0 else max(0, x.stop + stop_max) - step = x.step or 1 - if step < 0: - start, stop = stop, start - step = abs(step) - else: - start, stop, step = x, x + 1, 1 - - if start >= stop: return [] - - entries = self.get_entries(start+1, stop, self.data_request) - - return [entries[i] for i in _range(0, len(entries), step)] - - def get_iter(self, times, seconds, chunk_size=2000): - """Make a iterator over the entries - - See :class:`steam.util.throttle.ConstantRateLimit` for ``times`` and ``seconds`` parameters. - - :param chunk_size: number of entries per request - :type chunk_size: :class:`int` - :returns: generator object - :rtype: :class:`generator` - - The iterator essentially buffers ``chuck_size`` number of entries, and ensures - we are not sending messages too fast. - For example, the ``__iter__`` method on this class uses ``get_iter(1, 1, 2000)`` - """ - def entry_generator(): - with ConstantRateLimit(times, seconds, use_gevent=True) as r: - for entries in chunks(self, chunk_size): - if not entries: - raise StopIteration - for entry in entries: - yield entry - r.wait() - return entry_generator() - - def __iter__(self): - return self.get_iter(1, 1, 2000) diff --git a/steam/client/builtins/unified_messages.py b/steam/client/builtins/unified_messages.py new file mode 100644 index 0000000..2af8196 --- /dev/null +++ b/steam/client/builtins/unified_messages.py @@ -0,0 +1,145 @@ +""" +:class:`SteamUnifiedMessages` provides a simply API to send and receive unified messages. + +Example code: + +.. code:: python + + # the easy way + response = client.unified_messages.send_and_wait('Player.GetGameBadgeLevels#1', { + 'property': 1, + 'something': 'value', + }) + + # the other way + jobid = client.unified_message.send('Player.GetGameBadgeLevels#1', {'something': 1}) + response, = client.unified_message.wait_event(jobid) + + # i know what im doing, alright? + message = client.unified_message.get('Player.GetGameBadgeLevels#1') + message.something = 1 + response = client.unified_message.send_and_wait(message) +""" +import logging +from eventemitter import EventEmitter +from steam.core.msg import MsgProto, get_um +from steam.enums import EResult +from steam.enums.emsg import EMsg +from steam.util import WeakRefKeyDict, proto_fill_from_dict + + +class UnifiedMessages(object): + def __init__(self, *args, **kwargs): + super(UnifiedMessages, self).__init__(*args, **kwargs) + + name = "%s.unified_messages" % self.__class__.__name__ + self.unified_messages = SteamUnifiedMessages(self, name) #: instance of :class:`SteamUnifiedMessages` + + +class SteamUnifiedMessages(EventEmitter): + """Simple API for send/recv of unified messages + + Incoming messages are emitted as events once with their ``jobid`` + and once with their method name (e.g. ``Player.GetGameBadgeLevels#1``) + """ + def __init__(self, steam, logger_name=None): + self._LOG = logging.getLogger(logger_name if logger_name else self.__class__.__name__) + self._steam = steam + self._data = WeakRefKeyDict() + + steam.on(EMsg.ServiceMethod, self._handle_service_method) + steam.on(EMsg.ClientServiceMethodResponse, self._handle_client_service_method) + + def emit(self, event, *args): + if event is not None: + self._LOG.debug("Emit event: %s" % repr(event)) + EventEmitter.emit(self, event, *args) + + def _handle_service_method(self, message): + self.emit(message.header.target_job_name, message.body) + + def _handle_client_service_method(self, message): + method_name = message.body.method_name + proto = get_um(method_name, response=True) + + if proto is None: + self._LOG("Unable to find proto for %s" % repr(method_name)) + return + + resp = proto() + resp.ParseFromString(message.body.serialized_method_response) + + self.emit(method_name, resp) + + jobid = message.header.jobid_target + if jobid not in (-1, 18446744073709551615): + self.emit("job_%d" % jobid, resp) + + def get(self, method_name): + """Get request proto instance for given methed name + + :param method_name: name for the method (e.g. ``Player.GetGameBadgeLevels#1``) + :type method_name: :class:`str` + :return: proto message instance, or :class:`None` if not found + """ + proto = get_um(method_name) + if proto is None: + return None + message = proto() + self._data[message] = method_name + return message + + def send(self, message, params=None): + """Send service method request + + :param message: + proto message instance (use :meth:`SteamUnifiedMessages.get`) + or method name (e.g. ``Player.GetGameBadgeLevels#1``) + :type message: :class:`str`, proto message instance + :param params: message parameters + :type params: :class:`dict` + :return: ``jobid`` event identifier + :rtype: :class:`str` + + Listen for ``jobid`` on this object to catch the response. + + .. note:: + If you listen for ``jobid`` on the client instance you will get the encapsulated message + """ + if isinstance(message, str): + message = self.get(message) + if message not in self._data: + raise ValueError("Supplied message seems to be invalid. Did you use 'get' method?") + + if params: + proto_fill_from_dict(message, params) + + capsule = MsgProto(EMsg.ClientServiceMethod) + capsule.body.method_name = self._data[message] + capsule.body.serialized_method = message.SerializeToString() + + return self._steam.send_job(capsule) + + def send_and_wait(self, message, params=None, timeout=None, raises=False): + """Send service method request and wait for response + + :param message: + proto message instance (use :meth:`SteamUnifiedMessages.get`) + or method name (e.g. ``Player.GetGameBadgeLevels#1``) + :type message: :class:`str`, proto message instance + :param params: message parameters + :type params: :class:`dict` + :param timeout: (optional) seconds to wait + :type timeout: :class:`int` + :param raises: (optional) On timeout if :class:`False` return :class:`None`, else raise :class:`gevent.Timeout` + :type raises: :class:`bool` + :return: response proto message instance + :rtype: proto message, :class:`None` + :raises: :class:`gevent.Timeout` + """ + job_id = self.send(message, params) + resp = self.wait_event(job_id, timeout, raises=raises) + if resp is None and not raises: + return None + else: + return resp[0]