From 4fce3a06922e24ac3915ed7bb25d54bbff6f1f8c Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Thu, 26 May 2016 19:53:19 +0100 Subject: [PATCH 01/24] WebAuth: remember_login = True; update docs --- steam/client/builtins/web.py | 2 +- steam/webauth.py | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/steam/client/builtins/web.py b/steam/client/builtins/web.py index d7e74ea..a9c5d90 100644 --- a/steam/client/builtins/web.py +++ b/steam/client/builtins/web.py @@ -15,7 +15,7 @@ class Web(object): """Get web authentication cookies via WebAPI's ``AuthenticateUser`` .. note:: - only valid during the current steam session + A session is only valid during the current steam session. :return: dict with authentication cookies :rtype: :class:`dict`, :class:`None` diff --git a/steam/webauth.py b/steam/webauth.py index 8643de3..9a83706 100644 --- a/steam/webauth.py +++ b/steam/webauth.py @@ -4,6 +4,15 @@ This module simplifies the process of obtaining an authenticated session for ste After authentication is complete, a :class:`requests.Session` is created containing the auth cookies. The session can be used to access ``steamcommunity.com``, ``store.steampowered.com``, and ``help.steampowered.com``. +.. warning:: + A web session may expire randomly, or when you login from different IP address. + Some pages will return status code `401` when that happens. + Keep in mind if you are trying to write robust code. + +.. note:: + If you are using :class:`steam.client.SteamClient`, use :meth:`steam.client.builtins.web.Web.get_web_session()` + + Example usage: .. code:: python @@ -34,9 +43,6 @@ Alternatively, if Steam Guard is not enabled on the account: except wa.HTTPError: pass -.. note:: - If you are using :class:`steam.client.SteamClient`, see :meth:`steam.client.builtins.web.Web.get_web_session()` - """ from time import time import sys @@ -142,7 +148,7 @@ class WebAuth(object): "captcha_text": captcha, "loginfriendlyname": "python-steam webauth", "rsatimestamp": self.timestamp, - "remember_login": False, + "remember_login": 'true', "donotcache": int(time() * 100000), } @@ -157,12 +163,17 @@ class WebAuth(object): self.complete = True self.password = None + rememberLogin = self.session.cookies['steamRememberLogin'] if 'steamRememberLogin' in self.session.cookies else None + self.session.cookies.clear() data = resp['transfer_parameters'] self.steamid = SteamID(data['steamid']) for domain in ['store.steampowered.com', 'help.steampowered.com', 'steamcommunity.com']: + if rememberLogin: + self.session.cookies.set('steamRememberLogin', '%s||%s' % (data['steamid'], rememberLogin), + domain=domain, secure=False) self.session.cookies.set('steamLogin', '%s||%s' % (data['steamid'], data['token']), domain=domain, secure=False) self.session.cookies.set('steamLoginSecure', '%s||%s' % (data['steamid'], data['token_secure']), From 901643ca54954306884b00377a247745791eb393 Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Fri, 27 May 2016 14:16:48 +0100 Subject: [PATCH 02/24] added util for throttling code --- docs/api/steam.util.rst | 10 ++++++- steam/util/throttle.py | 63 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 steam/util/throttle.py diff --git a/docs/api/steam.util.rst b/docs/api/steam.util.rst index 3e24e1f..46e73b2 100644 --- a/docs/api/steam.util.rst +++ b/docs/api/steam.util.rst @@ -6,8 +6,16 @@ util :undoc-members: :show-inheritance: +util.throttle +------------- + +.. automodule:: steam.util.throttle + :members: + :undoc-members: + :show-inheritance: + util.web ------------ +-------- .. automodule:: steam.util.web :members: diff --git a/steam/util/throttle.py b/steam/util/throttle.py new file mode 100644 index 0000000..b11e8c5 --- /dev/null +++ b/steam/util/throttle.py @@ -0,0 +1,63 @@ +import sys +import time +import gevent + +if sys.version_info >= (3,3): + _monotonic = time.monotonic +else: + _monotonic = time.time # not really monotonic vOv + + +class ConstantRateLimit(object): + def __init__(self, times, seconds, exit_wait=False, use_gevent=False): + """Context manager for enforcing constant rate on code inside the block . + + `rate = seconds / times` + + :param times: times to execute per... + :type times: :class:`int` + :param seconds: ...seconds + :type seconds: :class:`int` + :param exit_wait: whether to automatically call :meth:`wait` before exiting the block + :type exit_wait: :class:`bool` + :param use_gevent: whether to use `gevent.sleep()` instead of `time.sleep()` + :type use_gevent: :class:`bool` + + Example: + + .. code:: python + + with RateLimiter(1, 5) as r: + # code taking 1s + r.wait() # blocks for 4s + # code taking 7s + r.wait() # doesn't block + # code taking 1s + r.wait() # blocks for 4s + """ + self.__dict__.update(locals()) + self.rate = float(seconds) / times + + def __enter__(self): + self._update_ref() + return self + + def __exit__(self, etype, evalue, traceback): + if self.exit_wait: + self.wait() + + def _update_ref(self): + self._ref = _monotonic() + self.rate + + def wait(self): + """Blocks until the rate is met""" + now = _monotonic() + if now < self._ref: + delay = max(0, self._ref - now) + if self.use_gevent: + gevent.sleep(delay) + else: + time.sleep(delay) + self._update_ref() + + From 3a2a844e24b90216cb5a6ec22d463f4e7ae3fabc Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Fri, 27 May 2016 14:18:43 +0100 Subject: [PATCH 03/24] bump to v0.8.2 --- steam/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/steam/__init__.py b/steam/__init__.py index 247c17c..82e69ed 100644 --- a/steam/__init__.py +++ b/steam/__init__.py @@ -1,7 +1,7 @@ -__version__ = "0.8.1" +__version__ = "0.8.2" __author__ = "Rossen Georgiev" -version_info = (0, 8, 1) +version_info = (0, 8, 2) from steam.steamid import SteamID from steam.globalid import GlobalID From 05102196c645733b8da5f98e32bb3ef981cfae28 Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Fri, 27 May 2016 14:20:26 +0100 Subject: [PATCH 04/24] added pythonic api for access to steam leaderboads --- steam/client/builtins/misc.py | 147 ++++++++++++++++++++++++++++++++++ steam/enums/common.py | 21 +++++ 2 files changed, 168 insertions(+) diff --git a/steam/client/builtins/misc.py b/steam/client/builtins/misc.py index c955a18..bf16148 100644 --- a/steam/client/builtins/misc.py +++ b/steam/client/builtins/misc.py @@ -2,10 +2,19 @@ Various features that don't have a category """ import logging +import sys from eventemitter import EventEmitter from steam.core.msg import MsgProto, get_um +from steam.enums import EResult, ELeaderboardDataRequest, ELeaderboardSortMethod, ELeaderboardDisplayType from steam.enums.emsg import EMsg from steam.util import WeakRefKeyDict +from steam.util.throttle import ConstantRateLimit + +if sys.version_info < (3,): + _range = xrange +else: + _range = range + class Misc(object): def __init__(self, *args, **kwargs): @@ -33,6 +42,31 @@ class Misc(object): self.send(message) + def get_leaderboard(self, app_id, name): + """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 @@ -126,3 +160,116 @@ class SteamUnifiedMessages(EventEmitter): return None else: return resp[0] + + +class SteamLeaderboard(object): + """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, + 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 __iter__(self): + def entry_generator(): + with ConstantRateLimit(1, 1, use_gevent=True) as r: + for i in _range(0, len(self), 500): + entries = self[i:i+500] + if not entries: + raise StopIteration + for entry in entries: + yield entry + r.wait() + return entry_generator() diff --git a/steam/enums/common.py b/steam/enums/common.py index 2168ce7..a5019e0 100644 --- a/steam/enums/common.py +++ b/steam/enums/common.py @@ -346,6 +346,27 @@ class EClientPersonaStateFlag(SteamIntEnum): ClanTag = 1024 Facebook = 2048 +class ELeaderboardDataRequest(SteamIntEnum): + Global = 0 + GlobalAroundUser = 1 + Friends = 2 + Users = 3 + +class ELeaderboardSortMethod(SteamIntEnum): + NONE = 0 + Ascending = 1 + Descending = 2 + +class ELeaderboardDisplayType(SteamIntEnum): + NONE = 0 + Numeric = 1 + TimeSeconds = 2 + TimeMilliSeconds = 3 + +class ELeaderboardUploadScoreMethod(SteamIntEnum): + NONE = 0 + KeepBest = 1 + ForceUpdate = 2 # Do not remove from sys import modules From 2c5177acfc23a779dda3a80825d553dfa5b4bf54 Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Fri, 27 May 2016 14:30:02 +0100 Subject: [PATCH 05/24] link stable instead of latest docs on pypi --- README.rst | 2 +- setup.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 62782fa..63f4e85 100644 --- a/README.rst +++ b/README.rst @@ -58,7 +58,7 @@ To run for ``python 2.7`` and ``3.4`` assuming you have them installed:: .. _Steam: https://store.steampowered.com/ -.. |pypi| image:: https://img.shields.io/pypi/v/steam.svg?style=flat&label=latest%20version +.. |pypi| image:: https://img.shields.io/pypi/v/steam.svg?style=flat&label=stable :target: https://pypi.python.org/pypi/steam :alt: Latest version released on PyPi diff --git a/setup.py b/setup.py index 12d0301..fe582dd 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,9 @@ import sys here = path.abspath(path.dirname(__file__)) with open(path.join(here, 'README.rst'), encoding='utf-8') as f: - long_description = f.read() + long_description = f.read()\ + .replace('io/en/latest/?badge=latest', 'io/en/stable/?badge=stable')\ + .replace('projects/steam/badge/?version=latest', 'projects/steam/badge/?version=stable') with open(path.join(here, 'steam/__init__.py'), encoding='utf-8') as f: __version__ = f.readline().split('"')[1] From c3b35da638f5c2591314ebff538e610c963c994d Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Fri, 27 May 2016 19:11:36 +0100 Subject: [PATCH 06/24] added list chunking function to utils --- steam/util/__init__.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/steam/util/__init__.py b/steam/util/__init__.py index bf294d8..8444bf2 100644 --- a/steam/util/__init__.py +++ b/steam/util/__init__.py @@ -3,6 +3,12 @@ import weakref import struct import socket +import sys + +if sys.version_info < (3,): + _range = xrange +else: + _range = range def ip_from_int(ip): """Convert IP to :py:class:`int` @@ -53,6 +59,19 @@ def clear_proto_bit(emsg): """ return int(emsg) & ~protobuf_mask +def chunks(arr, size): + """Splits a list into chunks + + :param arr: list to split + :type arr: :class:`list` + :param size: number of elements in each chunk + :type size: :class:`int` + :return: generator object + :rtype: :class:`generator` + """ + for i in _range(0, len(arr), size): + yield arr[i:i+size] + class WeakRefKeyDict(object): """Pretends to be a dictionary. From d28acd77ff708eb499973455c49bb0ad3319be99 Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Fri, 27 May 2016 19:15:11 +0100 Subject: [PATCH 07/24] SteamLeaderboards: added get_iter --- steam/client/builtins/misc.py | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/steam/client/builtins/misc.py b/steam/client/builtins/misc.py index bf16148..81296ed 100644 --- a/steam/client/builtins/misc.py +++ b/steam/client/builtins/misc.py @@ -2,19 +2,13 @@ Various features that don't have a category """ import logging -import sys from eventemitter import EventEmitter from steam.core.msg import MsgProto, get_um from steam.enums import EResult, ELeaderboardDataRequest, ELeaderboardSortMethod, ELeaderboardDisplayType from steam.enums.emsg import EMsg -from steam.util import WeakRefKeyDict +from steam.util import WeakRefKeyDict, _range, chunks from steam.util.throttle import ConstantRateLimit -if sys.version_info < (3,): - _range = xrange -else: - _range = range - class Misc(object): def __init__(self, *args, **kwargs): @@ -262,14 +256,29 @@ class SteamLeaderboard(object): return [entries[i] for i in _range(0, len(entries), step)] - def __iter__(self): + 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(1, 1, use_gevent=True) as r: - for i in _range(0, len(self), 500): - entries = self[i:i+500] + 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) From ac25105c8c97738c7fbc45ab56985af93764441f Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Fri, 27 May 2016 19:16:07 +0100 Subject: [PATCH 08/24] added OnlineUsingVR to EPersonaStateFlag --- steam/enums/common.py | 1 + 1 file changed, 1 insertion(+) diff --git a/steam/enums/common.py b/steam/enums/common.py index a5019e0..8433649 100644 --- a/steam/enums/common.py +++ b/steam/enums/common.py @@ -330,6 +330,7 @@ class EPersonaStateFlag(SteamIntEnum): OnlineUsingWeb = 256 OnlineUsingMobile = 512 OnlineUsingBigPicture = 1024 + OnlineUsingVR = 2048 class EClientPersonaStateFlag(SteamIntEnum): From 5cc86147e3dcf1ed45c2bb36a911c652fda645af Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Fri, 27 May 2016 20:42:00 +0100 Subject: [PATCH 09/24] SteamLeaderboard: use repr on name --- steam/client/builtins/misc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/steam/client/builtins/misc.py b/steam/client/builtins/misc.py index 81296ed..829de64 100644 --- a/steam/client/builtins/misc.py +++ b/steam/client/builtins/misc.py @@ -200,7 +200,7 @@ class SteamLeaderboard(object): return "<%s(%d, %s, %d, %s, %s)>" % ( self.__class__.__name__, self.app_id, - self.name, + repr(self.name), len(self), self.sort_method, self.display_type, From bbdf886e8921f69000803f88a33c28b96aa90304 Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Sat, 28 May 2016 13:33:14 +0100 Subject: [PATCH 10/24] docs: addd versionadded to steamleaderboards --- steam/client/builtins/misc.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/steam/client/builtins/misc.py b/steam/client/builtins/misc.py index 829de64..46c8503 100644 --- a/steam/client/builtins/misc.py +++ b/steam/client/builtins/misc.py @@ -37,7 +37,9 @@ class Misc(object): self.send(message) def get_leaderboard(self, app_id, name): - """Find a leaderboard + """.. versionadded:: 0.8.2 + + Find a leaderboard :param app_id: application id :type app_id: :class:`int` @@ -106,7 +108,7 @@ class SteamUnifiedMessages(EventEmitter): :param method_name: name for the method (e.g. ``Player.GetGameBadgeLevels#1``) :type method_name: :class:`str` - :return: proto message instance, or ``None`` if not found + :return: proto message instance, or :class:`None` if not found """ proto = get_um(method_name) if proto is None: @@ -118,7 +120,7 @@ class SteamUnifiedMessages(EventEmitter): def send(self, message): """Send service method request - :param message: proto message instance (use :meth:`get`) + :param message: proto message instance (use :meth:`SteamUnifiedMessages.get`) :return: ``jobid`` event identifier :rtype: :class:`str` @@ -142,7 +144,7 @@ class SteamUnifiedMessages(EventEmitter): :param message: proto message instance (use :meth:`get`) :param timeout: (optional) seconds to wait :type timeout: :class:`int` - :param raises: (optional) On timeout if ``False`` return ``None``, else raise ``gevent.Timeout`` + :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` @@ -157,7 +159,9 @@ class SteamUnifiedMessages(EventEmitter): class SteamLeaderboard(object): - """Steam leaderboard 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. @@ -259,16 +263,16 @@ class SteamLeaderboard(object): 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. + 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 + 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)` + 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: From f7833a5c4d2da184bb5e12a9bd4c331c6b9e3d6b Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Sat, 28 May 2016 13:56:28 +0100 Subject: [PATCH 11/24] docs: link against python, gevent and requests --- docs/conf.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index aff404e..8d9170e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,6 +30,7 @@ sys.path.insert(0, os.path.abspath('../')) # ones. extensions = [ 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', # 'sphinx.ext.githubpages', ] @@ -289,6 +290,14 @@ texinfo_documents = [ # If true, do not generate a @detailmenu in the "Top" node's menu. #texinfo_no_detailmenu = False +# LINK PYTHON DOCS + +intersphinx_mapping = { + 'python': ('https://docs.python.org/3.4', None), + 'gevent': ('http://www.gevent.org', None), + 'requests': ('http://docs.python-requests.org/en/master', None), + } + # AUTODOC autodoc_member_order = 'bysource' autoclass_content = 'both' From ba634f1c2486ca577345623cb3afdb4d48974abb Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Sun, 29 May 2016 16:08:46 +0100 Subject: [PATCH 12/24] bump to v0.8.3 --- steam/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/steam/__init__.py b/steam/__init__.py index 82e69ed..f1d51fd 100644 --- a/steam/__init__.py +++ b/steam/__init__.py @@ -1,7 +1,7 @@ -__version__ = "0.8.2" +__version__ = "0.8.3" __author__ = "Rossen Georgiev" -version_info = (0, 8, 2) +version_info = (0, 8, 3) from steam.steamid import SteamID from steam.globalid import GlobalID From 4ee1fef63f4577c1603159d11fee161caf548153 Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Sun, 29 May 2016 19:35:15 +0100 Subject: [PATCH 13/24] WebAPI: apihost param; get/post shortcuts * tweaks to WebAPI user guide section * can now specify hostname for API * added post/get function as shortcuts for calling endpoints --- docs/api/steam.webapi.rst | 4 - docs/user_guide.rst | 25 ++-- steam/webapi.py | 257 +++++++++++++++++++++++++------------- tests/test_webapi.py | 24 +++- 4 files changed, 208 insertions(+), 102 deletions(-) diff --git a/docs/api/steam.webapi.rst b/docs/api/steam.webapi.rst index 5a9e63a..d733441 100644 --- a/docs/api/steam.webapi.rst +++ b/docs/api/steam.webapi.rst @@ -3,7 +3,3 @@ webapi .. automodule:: steam.webapi :members: - :undoc-members: - :show-inheritance: - - diff --git a/docs/user_guide.rst b/docs/user_guide.rst index f007904..12f505b 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -10,14 +10,14 @@ overview of the functionality available in the ``steam`` module. SteamID ======= -:mod:`SteamID ` can be used to convert the universal steam id +:mod:`SteamID ` can be used to convert the universal steam id to its' various representations. .. note:: - ``SteamID`` is immutable as it inherits from ``int``. + :class:`SteamID ` is immutable as it inherits from :class:`int`. -Example usage -------------- +Converting between representations +---------------------------------- .. code:: python @@ -54,10 +54,10 @@ Example usage 'https://steamcommunity.com/gid/103582791429521412' -Resolving community urls to ``SteamID`` ------------------------------------------ +Resolving community urls to :class:`SteamID ` +-------------------------------------------------------------------- -The ``steamid`` submodule provides function to resolve community urls. +The :mod:`steam.steamid` submodule provides function to resolve community urls. Here are some examples: .. code:: python @@ -76,12 +76,19 @@ WebAPI :mod:`WebAPI ` is a thin Wrapper around `Steam Web API`_. Requires `API Key`_. Upon initialization the instance will fetch all available interfaces and populate the namespace. +Obtaining a key +--------------- + +Any steam user can get a key by visiting http://steamcommunity.com/dev/apikey. +The only requirement is that the user has verified their email. +Then the key can be used on the ``public`` WebAPI. See :class:`steam.webapi.SERVICE` + .. note:: Interface availability depends on the ``key``. Unless the schema is loaded manually. -Example usage -------------- +Calling an endpoint +------------------- .. code:: python diff --git a/steam/webapi.py b/steam/webapi.py index fa51719..3497e65 100644 --- a/steam/webapi.py +++ b/steam/webapi.py @@ -1,14 +1,23 @@ """ WebAPI provides a thin wrapper over `Steam's Web API `_ -Calling an endpoint +It is very fiendly to exploration and prototyping when using ``ipython``, ``notebooks`` or similar. +The ``key`` will determine what WebAPI interfaces and methods are available. + +.. note:: + Some endpoints don't require a key + +Currently the WebAPI can be accessed via one of two API hosts. See :class:`APIHost`. + +Example code: .. code:: python >>> api = WebAPI(key) - >>> api.ISteamUser.ResolveVanityURL(vanityurl="valve", url_type=2) >>> api.call('ISteamUser.ResolveVanityURL', vanityurl="valve", url_type=2) - {u'response': {u'steamid': u'103582791429521412', u'success': 1}} + >>> api.ISteamUser.ResolveVanityURL(vanityurl="valve", url_type=2) + >>> api.ISteamUser.ResolveVanityURL_v1(vanityurl="valve", url_type=2) + {'response': {'steamid': '103582791429521412', 'success': 1}} All globals params (``key``, ``https``, ``format``, ``raw``) can be specified on per call basis. @@ -21,11 +30,23 @@ All globals params (``key``, ``https``, ``format``, ``raw``) can be specified on "success" "1" } """ -import json -from steam.util.web import make_requests_session +import json as _json +from steam.util.web import make_requests_session as _make_session + +class APIHost(object): + """Enum of currently available API hosts.""" + Public = 'api.steampowered.com' + """ available over HTTP (port 80) and HTTPS (port 443)""" + Partner = 'partner.steam-api.com' + """available over HTTPS (port 443) only + + .. note:: + Key is required for every request. If not supplied you will get HTTP 403. + """ DEFAULT_PARAMS = { # api parameters + 'apihost': APIHost.Public, 'key': None, 'format': 'json', # internal @@ -35,80 +56,11 @@ DEFAULT_PARAMS = { } -def webapi_request(path, method='GET', caller=None, params={}, session=None): - """ - Low level function for calling Steam's WebAPI - - :param path: request url - :type path: :class:`str` - :param method: HTTP method (GET or POST) - :type method: :class:`str` - :param caller: caller reference, caller.last_response is set to the last response - :param params: dict of WebAPI and endpoint specific params - :type params: :class:`dict` - :param session: an instance requests session, or one is created per call - :type session: :class:`requests.Session` - :return: response based on paramers - :rtype: :class:`dict`, :class:`lxml.etree.Element`, :class:`str` - """ - if method not in ('GET', 'POST'): - raise NotImplemented("HTTP method: %s" % repr(self.method)) - - onetime = {} - for param in DEFAULT_PARAMS: - params[param] = onetime[param] = params.get(param, - DEFAULT_PARAMS[param], - ) - path = "%s://api.steampowered.com/%s" % ('https' if params.get('https', True) else 'http', - path) - del params['raw'] - del params['https'] - del params['http_timeout'] - - if onetime['format'] not in ('json', 'vdf', 'xml'): - raise ValueError("Expected format to be json,vdf or xml; got %s" % onetime['format']) - - # serialize some parameter types properly - for k, v in params.items(): - if isinstance(v, bool): - params[k] = 1 if v else 0 - elif isinstance(v, (list, dict)): - params[k] = json.dumps(v) - - # move params to data, if data is not specified for POST - # simplifies code calling this method - kwargs = {'params': params} if method == "GET" else {'data': params} - - f = getattr(session, method.lower()) - resp = f(path, stream=False, timeout=onetime['http_timeout'], **kwargs) - - # we keep a reference of the last response instance on the caller - if caller is not None: - caller.last_response = resp - - # 4XX and 5XX will cause this to raise - resp.raise_for_status() - - # response - if onetime['raw']: - return resp.text - - if onetime['format'] == 'json': - return resp.json() - elif onetime['format'] == 'xml': - import lxml.etree - return lxml.etree.fromstring(resp.content) - elif onetime['format'] == 'vdf': - import vdf - return vdf.loads(resp.text) - - class WebAPI(object): - """ - Steam WebAPI wrapper. See https://developer.valvesoftware.com/wiki/Steam_Web_API + """Steam WebAPI wrapper .. note:: - Interfaces and methods are populated automatically from WebAPI. + Interfaces and methods are populated automatically from Steam WebAPI. :param key: api key from https://steamcommunity.com/dev/apikey :type key: :class:`str` @@ -120,7 +72,9 @@ class WebAPI(object): :type https: :class:`bool` :param http_timeout: HTTP timeout in seconds :type http_timeout: :class:`int` - :param auto_load_interfaces: load interfaces from the WebAPI + :param apihost: api hostname, see :class:`APIHost` + :type apihost: :class:`str` + :param auto_load_interfaces: load interfaces from the Steam WebAPI :type auto_load_interfaces: :class:`bool` These can be specified per method call for one off calls @@ -130,20 +84,23 @@ class WebAPI(object): raw = DEFAULT_PARAMS['raw'] https = DEFAULT_PARAMS['https'] http_timeout = DEFAULT_PARAMS['http_timeout'] + apihost = DEFAULT_PARAMS['apihost'] interfaces = [] def __init__(self, key, format = DEFAULT_PARAMS['format'], raw = DEFAULT_PARAMS['raw'], https = DEFAULT_PARAMS['https'], http_timeout = DEFAULT_PARAMS['http_timeout'], + apihost = DEFAULT_PARAMS['apihost'], auto_load_interfaces = True): self.key = key #: api key self.format = format #: format (``json``, ``vdf``, or ``xml``) self.raw = raw #: return raw reponse or parse self.https = https #: use https or not self.http_timeout = http_timeout #: HTTP timeout in seconds + self.apihost = apihost #: ..versionadded:: 0.8.3 apihost hostname self.interfaces = [] #: list of all interfaces - self.session = make_requests_session() #: :class:`requests.Session` from :func:`steam.util.web.make_requests_session` + self.session = _make_session() #: :class:`requests.Session` from :func:`steam.util.web.make_requests_session` if auto_load_interfaces: self.load_interfaces(self.fetch_interfaces()) @@ -163,14 +120,14 @@ class WebAPI(object): The returned value can passed to :py:func:`WebAPI.load_interfaces` """ - return webapi_request( - "ISteamWebAPIUtil/GetSupportedAPIList/v1/", - method="GET", + return get('ISteamWebAPIUtil', 'GetSupportedAPIList', 1, + https=self.https, + apihost=self.apihost, caller=None, + session=self.session, params={'format': 'json', 'key': self.key, }, - session=self.session, ) def load_interfaces(self, interfaces_dict): @@ -213,7 +170,7 @@ class WebAPI(object): def doc(self): """ :return: Documentation for all interfaces and their methods - :rtype: :class:`str` + :rtype: str """ doc = "Steam Web API - List of all interfaces\n\n" for interface in self.interfaces: @@ -257,6 +214,10 @@ class WebAPIInterface(object): def key(self): return self._parent.key + @property + def apihost(self): + return self._parent.apihost + @property def https(self): return self._parent.https @@ -278,6 +239,10 @@ class WebAPIInterface(object): return self._parent.session def doc(self): + """ + :return: Documentation for all methods on this interface + :rtype: str + """ return self.__doc__ @property @@ -360,13 +325,20 @@ class WebAPIMethod(object): else: params[name] = kwargs[name] - # make the request + url = "%s://%s/%s/%s/v%s/" % ( + 'https' if self._parent.https else 'http', + self._parent.apihost, + self._parent.name, + self.name, + self.version, + ) + return webapi_request( - "%s/%s/v%s/" % (self._parent.name, self.name, self.version), + url=url, method=self.method, caller=self, - params=params, session=self._parent.session, + params=params, ) @property @@ -386,6 +358,10 @@ class WebAPIMethod(object): return self._dict['name'] def doc(self): + """ + :return: Documentation for this method + :rtype: str + """ return self.__doc__ @property @@ -411,3 +387,112 @@ class WebAPIMethod(object): ) return doc + +def webapi_request(url, method='GET', caller=None, session=None, params=None): + """Low level function for calling Steam's WebAPI + + .. versionchanged:: 0.8.3 + + :param url: request url (e.g. ``https://api.steampowered.com/A/B/v001/``) + :type url: :class:`str` + :param method: HTTP method (GET or POST) + :type method: :class:`str` + :param caller: caller reference, caller.last_response is set to the last response + :param params: dict of WebAPI and endpoint specific params + :type params: :class:`dict` + :param session: an instance requests session, or one is created per call + :type session: :class:`requests.Session` + :return: response based on paramers + :rtype: :class:`dict`, :class:`lxml.etree.Element`, :class:`str` + """ + if method not in ('GET', 'POST'): + raise NotImplemented("HTTP method: %s" % repr(self.method)) + if params is None: + params = {} + + onetime = {} + for param in DEFAULT_PARAMS: + params[param] = onetime[param] = params.get(param, DEFAULT_PARAMS[param]) + map(params.pop, ('raw', 'apihost', 'https', 'http_timeout')) + + if onetime['format'] not in ('json', 'vdf', 'xml'): + raise ValueError("Expected format to be json,vdf or xml; got %s" % onetime['format']) + + for k, v in params.items(): # serialize some types + if isinstance(v, bool): params[k] = 1 if v else 0 + elif isinstance(v, (list, dict)): params[k] = _json.dumps(v) + + kwargs = {'params': params} if method == "GET" else {'data': params} # params to data for POST + + if session is None: session = _make_session() + + f = getattr(session, method.lower()) + resp = f(url, stream=False, timeout=onetime['http_timeout'], **kwargs) + + # we keep a reference of the last response instance on the caller + if caller is not None: caller.last_response = resp + # 4XX and 5XX will cause this to raise + resp.raise_for_status() + + if onetime['raw']: + return resp.text + elif onetime['format'] == 'json': + return resp.json() + elif onetime['format'] == 'xml': + from lxml import etree as _etree + return _etree.fromstring(resp.content) + elif onetime['format'] == 'vdf': + import vdf as _vdf + return _vdf.loads(resp.text) + +def get(interface, method, version=1, + apihost=DEFAULT_PARAMS['apihost'], https=DEFAULT_PARAMS['https'], + caller=None, session=None, params=None): + """Send GET request to an API endpoint + + .. versionadded:: 0.8.3 + + :param interface: interface name + :type interface: str + :param method: method name + :type method: str + :param version: method version + :type version: int + :param apihost: API hostname + :type apihost: str + :param https: whether to use HTTPS + :type https: bool + :param params: parameters for endpoint + :type params: dict + :return: endpoint response + :rtype: :class:`dict`, :class:`lxml.etree.Element`, :class:`str` + """ + url = u"%s://%s/%s/%s/v%s/" % ( + 'https' if https else 'http', apihost, interface, method, version) + return webapi_request(url, 'GET', caller=caller, session=session, params=params) + +def post(interface, method, version=1, + apihost=DEFAULT_PARAMS['apihost'], https=DEFAULT_PARAMS['https'], + caller=None, session=None, params=None): + """Send POST request to an API endpoint + + .. versionadded:: 0.8.3 + + :param interface: interface name + :type interface: str + :param method: method name + :type method: str + :param version: method version + :type version: int + :param apihost: API hostname + :type apihost: str + :param https: whether to use HTTPS + :type https: bool + :param params: parameters for endpoint + :type params: dict + :return: endpoint response + :rtype: :class:`dict`, :class:`lxml.etree.Element`, :class:`str` + """ + url = "%s://%s/%s/%s/v%s/" % ( + 'https' if https else 'http', apihost, interface, method, version) + return webapi_request(url, 'POST', caller=caller, session=session, params=params) diff --git a/tests/test_webapi.py b/tests/test_webapi.py index 8868210..6890cbb 100644 --- a/tests/test_webapi.py +++ b/tests/test_webapi.py @@ -2,13 +2,14 @@ import unittest import mock import vcr +from steam import webapi from steam.webapi import WebAPI from steam.enums import EType, EUniverse test_api_key = 'test_api_key' test_vcr = vcr.VCR( - record_mode='new_episodes', + record_mode='none', # change to 'new_episodes' when recording serializer='yaml', filter_query_parameters=['key'], filter_post_data_parameters=['key'], @@ -26,7 +27,7 @@ class TCwebapi(unittest.TestCase): @test_vcr.use_cassette('webapi.yaml') def test_simple_api_call(self): - resp = self.api.ISteamWebAPIUtil.GetServerInfo() + resp = self.api.ISteamWebAPIUtil.GetServerInfo_v1() self.assertTrue('servertime' in resp) @test_vcr.use_cassette('webapi.yaml') @@ -44,5 +45,22 @@ class TCwebapi(unittest.TestCase): resp = self.api.ISteamRemoteStorage.GetPublishedFileDetails(itemcount=5, publishedfileids=[1,1,1,1,1]) self.assertEqual(resp['response']['resultcount'], 5) - resp = self.api.ISteamUser.ResolveVanityURL(vanityurl='valve', url_type=2) + @test_vcr.use_cassette('webapi.yaml') + def test_get(self): + resp = webapi.get('ISteamUser', 'ResolveVanityURL', 1, + session=self.api.session, params={ + 'key': test_api_key, + 'vanityurl': 'valve', + 'url_type': 2, + }) self.assertEqual(resp['response']['steamid'], '103582791429521412') + + @test_vcr.use_cassette('webapi.yaml') + def test_post(self): + resp = webapi.post('ISteamRemoteStorage', 'GetPublishedFileDetails', 1, + session=self.api.session, params={ + 'key': test_api_key, + 'itemcount': 5, + 'publishedfileids': [1,1,1,1,1], + }) + self.assertEqual(resp['response']['resultcount'], 5) From f36ddfd116d9b2d555160a043bf04ea0677ffcc4 Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Sun, 29 May 2016 19:47:55 +0100 Subject: [PATCH 14/24] use new webapi shortcuts in SteamClient --- steam/client/builtins/web.py | 5 ++--- steam/core/cm.py | 7 +++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/steam/client/builtins/web.py b/steam/client/builtins/web.py index a9c5d90..97e6692 100644 --- a/steam/client/builtins/web.py +++ b/steam/client/builtins/web.py @@ -2,7 +2,7 @@ Web related features """ from binascii import hexlify -from steam import WebAPI +from steam import webapi from steam.core.crypto import generate_session_key, symmetric_encrypt, sha1_hash, random_bytes from steam.util.web import make_requests_session @@ -32,8 +32,7 @@ class Web(object): } try: - api = WebAPI(None) - resp = api.ISteamUserAuth.AuthenticateUser(**data) + resp = webapi.post('ISteamUserAuth', 'AuthenticateUser', 1, params=data) except Exception as exp: self._logger.debug("get_web_session_cookies error: %s" % str(exp)) return None diff --git a/steam/core/cm.py b/steam/core/cm.py index 8db9ee2..5d11df8 100644 --- a/steam/core/cm.py +++ b/steam/core/cm.py @@ -347,7 +347,7 @@ class CMClient(EventEmitter): self.steam_id = SteamID(msg.header.steamid) self.session_id = msg.header.client_sessionid - self.webapi_authenticate_user_nonce = msg.body.webapi_authenticate_user_nonce + self.webapi_authenticate_user_nonce = msg.body.webapi_authenticate_user_nonce.encode('ascii') if self._heartbeat_loop: self._heartbeat_loop.kill() @@ -431,11 +431,10 @@ class CMServerList(object): :return: booststrap success :rtype: :class:`bool` """ - from steam import WebAPI + from steam import _webapi try: - api = WebAPI(None) - resp = api.ISteamDirectory.GetCMList_v1(cellid=cellid) + resp = _webapi.get('ISteamDirectory', 'GetCMList', 1, params={'cellid': cellid}) except Exception as exp: self._log.error("WebAPI boostrap failed: %s" % str(exp)) return False From aef88e45479105b34965e478c87565064fb1e296 Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Sun, 29 May 2016 20:40:31 +0100 Subject: [PATCH 15/24] WebAPI: fix list serialization --- steam/webapi.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/steam/webapi.py b/steam/webapi.py index 3497e65..285b046 100644 --- a/steam/webapi.py +++ b/steam/webapi.py @@ -305,25 +305,16 @@ class WebAPIMethod(object): islist = param['_array'] optional = param['optional'] - # raise if we are missing a required parameter if not optional and name not in kwargs and name != 'key': raise ValueError("Method requires %s to be set" % repr(name)) - # populate params that will be passed to _api_request if name in kwargs: - # some parameters can be an array, they need to be send as seperate field - # the array index is append to the name (e.g. name[0], name[1] etc) - if islist: - if not isinstance(kwargs[name], list): + if islist and not isinstance(kwargs[name], list): raise ValueError("Expected %s to be a list, got %s" % ( repr(name), repr(type(kwargs[name]))) ) - - for idx, value in enumerate(kwargs[name]): - params['%s[%d]' % (name, idx)] = value - else: - params[name] = kwargs[name] + params[name] = kwargs[name] url = "%s://%s/%s/%s/v%s/" % ( 'https' if self._parent.https else 'http', @@ -413,14 +404,19 @@ def webapi_request(url, method='GET', caller=None, session=None, params=None): onetime = {} for param in DEFAULT_PARAMS: params[param] = onetime[param] = params.get(param, DEFAULT_PARAMS[param]) - map(params.pop, ('raw', 'apihost', 'https', 'http_timeout')) + for param in ('raw', 'apihost', 'https', 'http_timeout'): + del params[param] if onetime['format'] not in ('json', 'vdf', 'xml'): raise ValueError("Expected format to be json,vdf or xml; got %s" % onetime['format']) - for k, v in params.items(): # serialize some types + for k, v in list(params.items()): # serialize some types if isinstance(v, bool): params[k] = 1 if v else 0 - elif isinstance(v, (list, dict)): params[k] = _json.dumps(v) + elif isinstance(v, dict): params[k] = _json.dumps(v) + elif isinstance(v, list): + del params[k] + for i, lvalue in enumerate(v): + params["%s[%d]" % (k, i)] = lvalue kwargs = {'params': params} if method == "GET" else {'data': params} # params to data for POST From 793a200eddf06568889ac020e25c45b54b626318 Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Sun, 29 May 2016 22:02:02 +0100 Subject: [PATCH 16/24] WebAuth: rework how cookies are set on all domains --- steam/webauth.py | 15 ++++----------- tests/generete_webauth_vcr.py | 8 ++++++-- tests/test_webauth.py | 5 +++-- vcr/webauth_user_pass_only_success.yaml | 5 ++++- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/steam/webauth.py b/steam/webauth.py index 9a83706..ce9580f 100644 --- a/steam/webauth.py +++ b/steam/webauth.py @@ -163,21 +163,14 @@ class WebAuth(object): self.complete = True self.password = None - rememberLogin = self.session.cookies['steamRememberLogin'] if 'steamRememberLogin' in self.session.cookies else None - - self.session.cookies.clear() data = resp['transfer_parameters'] - self.steamid = SteamID(data['steamid']) + for cookie in list(self.session.cookies): + for domain in ['store.steampowered.com', 'help.steampowered.com', 'steamcommunity.com']: + self.session.cookies.set(cookie.name, cookie.value, domain=domain, secure=cookie.secure) + for domain in ['store.steampowered.com', 'help.steampowered.com', 'steamcommunity.com']: - if rememberLogin: - self.session.cookies.set('steamRememberLogin', '%s||%s' % (data['steamid'], rememberLogin), - domain=domain, secure=False) - self.session.cookies.set('steamLogin', '%s||%s' % (data['steamid'], data['token']), - domain=domain, secure=False) - self.session.cookies.set('steamLoginSecure', '%s||%s' % (data['steamid'], data['token_secure']), - domain=domain, secure=True) self.session.cookies.set('Steam_Language', language, domain=domain) self.session.cookies.set('birthtime', '-3333', domain=domain) diff --git a/tests/generete_webauth_vcr.py b/tests/generete_webauth_vcr.py index 7f6caba..1d5e950 100644 --- a/tests/generete_webauth_vcr.py +++ b/tests/generete_webauth_vcr.py @@ -26,7 +26,11 @@ def request_scrubber(r): def response_scrubber(r): if 'set-cookie' in r['headers']: - del r['headers']['set-cookie'] + r['headers']['set-cookie'] = [ + 'steamLogin=0%7C%7C{}; path=/; httponly'.format('A'*16), + 'steamLoginSecure=0%7C%7C{}; path=/; httponly; secure'.format('B'*16), + 'steamMachineAuth=0%7C%7C{}; path=/; httponly'.format('C'*16), + ] if r.get('body', ''): data = json.loads(r['body']['string']) @@ -37,7 +41,7 @@ def response_scrubber(r): data['transfer_parameters']['steamid'] = '0' data['transfer_parameters']['token'] = 'A'*16 data['transfer_parameters']['token_secure'] = 'B'*16 - data['transfer_parameters']['auth'] = 'C'*16 + data['transfer_parameters']['auth'] = 'Z'*16 body = json.dumps(data) r['body']['string'] = body diff --git a/tests/test_webauth.py b/tests/test_webauth.py index 1de0144..a7f9fb6 100644 --- a/tests/test_webauth.py +++ b/tests/test_webauth.py @@ -36,8 +36,9 @@ class WACase(unittest.TestCase): self.assertIsInstance(s, requests.Session) for domain in s.cookies.list_domains(): - self.assertEqual(s.cookies.get('steamLogin', domain=domain), '0||%s' % ('A'*16)) - self.assertEqual(s.cookies.get('steamLoginSecure', domain=domain), '0||%s' % ('B'*16)) + self.assertEqual(s.cookies.get('steamLogin', domain=domain), '0%7C%7C{}'.format('A'*16)) + self.assertEqual(s.cookies.get('steamLoginSecure', domain=domain), '0%7C%7C{}'.format('B'*16)) + self.assertEqual(s.cookies.get('steamMachineAuth', domain=domain), '0%7C%7C{}'.format('C'*16)) self.assertEqual(s, user.login()) diff --git a/vcr/webauth_user_pass_only_success.yaml b/vcr/webauth_user_pass_only_success.yaml index 9005877..6ba8e3d 100644 --- a/vcr/webauth_user_pass_only_success.yaml +++ b/vcr/webauth_user_pass_only_success.yaml @@ -39,7 +39,7 @@ interactions: body: {string: !!python/unicode '{"requires_twofactor": false, "login_complete": true, "transfer_urls": ["https://steamcommunity.com/login/transfer", "https://help.steampowered.com/login/transfer"], "transfer_parameters": {"steamid": "0", "remember_login": false, "token": - "AAAAAAAAAAAAAAAA", "token_secure": "BBBBBBBBBBBBBBBB", "auth": "CCCCCCCCCCCCCCCC"}, + "AAAAAAAAAAAAAAAA", "token_secure": "BBBBBBBBBBBBBBBB", "auth": "ZZZZZZZZZZZZZZZZ"}, "success": true}'} headers: cache-control: [no-cache] @@ -49,6 +49,9 @@ interactions: date: ['Fri, 13 May 2016 03:01:25 GMT'] expires: ['Mon, 26 Jul 1997 05:00:00 GMT'] server: [Apache] + set-cookie: [steamLogin=0%7C%7CAAAAAAAAAAAAAAAA; path=/; httponly, steamLoginSecure=0%7C%7CBBBBBBBBBBBBBBBB; + path=/; httponly; secure, steamMachineAuth=0%7C%7CCCCCCCCCCCCCCCCC; path=/; + httponly] x-frame-options: [DENY] status: {code: 200, message: OK} version: 1 From a18479db896468dde205e63f9d1f94ac3d86a4aa Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Mon, 30 May 2016 00:42:14 +0100 Subject: [PATCH 17/24] docs: fix APIHost link in user_guide [ci skip] --- docs/user_guide.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 12f505b..2eadd53 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -81,7 +81,7 @@ Obtaining a key Any steam user can get a key by visiting http://steamcommunity.com/dev/apikey. The only requirement is that the user has verified their email. -Then the key can be used on the ``public`` WebAPI. See :class:`steam.webapi.SERVICE` +Then the key can be used on the ``public`` WebAPI. See :class:`steam.webapi.APIHost` .. note:: Interface availability depends on the ``key``. @@ -241,7 +241,7 @@ Alternatively, a callback can be registered to handle the response event every t Web Authentication ================== -There are currently two paths for gaining accessing to steam websites. +There are currently two paths for gaining access to steam websites. Either using :class:`WebAuth `, or via a :meth:`SteamClient.get_web_session() ` instance. .. code:: python From fd2d8fcb651585321c90c20d8eab6659800371ca Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Mon, 30 May 2016 00:47:00 +0100 Subject: [PATCH 18/24] webapi: minor identation fix [ci skip] --- steam/webapi.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/steam/webapi.py b/steam/webapi.py index 285b046..e0de065 100644 --- a/steam/webapi.py +++ b/steam/webapi.py @@ -310,10 +310,10 @@ class WebAPIMethod(object): if name in kwargs: if islist and not isinstance(kwargs[name], list): - raise ValueError("Expected %s to be a list, got %s" % ( - repr(name), - repr(type(kwargs[name]))) - ) + raise ValueError("Expected %s to be a list, got %s" % ( + repr(name), + repr(type(kwargs[name]))) + ) params[name] = kwargs[name] url = "%s://%s/%s/%s/v%s/" % ( From ba9a0ea29e167ee9ea282b6cbf8facafb520a699 Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Mon, 30 May 2016 13:14:23 +0100 Subject: [PATCH 19/24] fix error in WebAuth docs example code --- steam/webauth.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/steam/webauth.py b/steam/webauth.py index ce9580f..4e7d57b 100644 --- a/steam/webauth.py +++ b/steam/webauth.py @@ -32,7 +32,10 @@ Example usage: except wa.TwoFactorCodeRequired: user.login(twofactor_code='ZXC123') - wa.session.get('https://store.steampowered.com/account/history/') + user.session.get('https://store.steampowered.com/account/history/') + # OR + session = user.login() + session.get('https://store.steampowered.com/account/history') Alternatively, if Steam Guard is not enabled on the account: @@ -43,6 +46,8 @@ Alternatively, if Steam Guard is not enabled on the account: except wa.HTTPError: pass +The :class:`WebAuth` instance should be discarded once a session is obtained +as it is not reusable. """ from time import time import sys From 8e3957969f7e42d7be8ff482ada130a6854c92a0 Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Mon, 30 May 2016 20:37:57 +0100 Subject: [PATCH 20/24] failing to resolvw proto for EMsg.EconTrading fix #33 --- steam/core/msg.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/steam/core/msg.py b/steam/core/msg.py index eae1b25..fceead9 100644 --- a/steam/core/msg.py +++ b/steam/core/msg.py @@ -213,10 +213,16 @@ def get_cmsg(emsg): """ global cmsg_lookup, cmsg_lookup2 + if not isinstance(emsg, EMsg): + emsg = EMsg(emsg) + if emsg in cmsg_lookup_predefined: return cmsg_lookup_predefined[emsg] else: - cmsg_name = "cmsg" + str(emsg).split('.', 1)[1].lower() + enum_name = emsg.name.lower() + if enum_name.startswith("econ"): # special case for 'EconTrading_' + enum_name = enum_name[4:] + cmsg_name = "cmsg" + enum_name if not cmsg_lookup: cmsg_list = steammessages_clientserver_pb2.__dict__ From 8fd2ae28e0a6da8bc7aee82d7c77f5a531155e98 Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Mon, 30 May 2016 23:33:25 +0100 Subject: [PATCH 21/24] link to latest docs in README [ci skip] --- README.rst | 2 +- setup.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 63f4e85..4b75742 100644 --- a/README.rst +++ b/README.rst @@ -4,7 +4,7 @@ A python module for interacting with various parts of Steam_. Supports Python ``2.7+`` and ``3.4+``. -Documentation: http://steam.readthedocs.io +Documentation: http://steam.readthedocs.io/en/latest Main features ------------- diff --git a/setup.py b/setup.py index fe582dd..0377cc0 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,8 @@ import sys here = path.abspath(path.dirname(__file__)) with open(path.join(here, 'README.rst'), encoding='utf-8') as f: long_description = f.read()\ - .replace('io/en/latest/?badge=latest', 'io/en/stable/?badge=stable')\ + .replace('.io/en/latest', '.io/en/stable')\ + .replace('?badge=latest', '?badge=stable')\ .replace('projects/steam/badge/?version=latest', 'projects/steam/badge/?version=stable') with open(path.join(here, 'steam/__init__.py'), encoding='utf-8') as f: __version__ = f.readline().split('"')[1] From 1543a7f263fdf0d3282dacdafb54be33e8d11da2 Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Tue, 31 May 2016 01:47:54 +0100 Subject: [PATCH 22/24] added hmac_sha1 shortuct function --- steam/core/crypto.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/steam/core/crypto.py b/steam/core/crypto.py index ae16ec4..886735e 100644 --- a/steam/core/crypto.py +++ b/steam/core/crypto.py @@ -53,13 +53,8 @@ def symmetric_encrypt(message, key): def symmetric_encrypt_HMAC(message, key, hmac_secret): prefix = random_bytes(3) - - hmac = HMAC(hmac_secret, SHA1(), backend) - hmac.update(prefix) - hmac.update(message) - - iv = hmac.finalize()[:13] + prefix - + hmac = hmac_sha1(hmac_secret, prefix + message) + iv = hmac[:13] + prefix return symmetric_encrypt_with_iv(message, key, iv) def symmetric_encrypt_iv(iv, key): @@ -81,11 +76,9 @@ def symmetric_decrypt_HMAC(cyphertext, key, hmac_secret): iv = symmetric_decrypt_iv(cyphertext, key) message = symmetric_decrypt_with_iv(cyphertext, key, iv) - hmac = HMAC(hmac_secret, SHA1(), backend) - hmac.update(iv[-3:]) - hmac.update(message) + hmac = hmac_sha1(hmac_secret, iv[-3:] + message) - if iv[:13] != hmac.finalize()[:13]: + if iv[:13] != hmac[:13]: raise RuntimeError("Unable to decrypt message. HMAC does not match.") return message @@ -98,6 +91,11 @@ def symmetric_decrypt_with_iv(cyphertext, key, iv): decryptor = Cipher(AES(key), CBC(iv), backend).decryptor() return unpad(decryptor.update(cyphertext[BS:]) + decryptor.finalize()) +def hmac_sha1(secret, data): + hmac = HMAC(secret, SHA1(), backend) + hmac.update(data) + return hmac.finalize() + def sha1_hash(data): sha = Hash(SHA1(), backend) sha.update(data) From 1a69623feec212b9fd282343a746b0af8e3f9ff2 Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Tue, 31 May 2016 18:13:29 +0100 Subject: [PATCH 23/24] intial version of guard module; #32 * currently includes totp related functions --- docs/api/steam.guard.rst | 5 +++ docs/api/steam.rst | 1 + steam/guard.py | 88 ++++++++++++++++++++++++++++++++++++++++ tests/test_guard.py | 19 +++++++++ 4 files changed, 113 insertions(+) create mode 100644 docs/api/steam.guard.rst create mode 100644 steam/guard.py create mode 100644 tests/test_guard.py diff --git a/docs/api/steam.guard.rst b/docs/api/steam.guard.rst new file mode 100644 index 0000000..989decb --- /dev/null +++ b/docs/api/steam.guard.rst @@ -0,0 +1,5 @@ +guard +===== + +.. automodule:: steam.guard + :members: diff --git a/docs/api/steam.rst b/docs/api/steam.rst index 70479c6..42c4673 100644 --- a/docs/api/steam.rst +++ b/docs/api/steam.rst @@ -6,6 +6,7 @@ steam steam.core steam.enums steam.globalid + steam.guard steam.steamid steam.webapi steam.webauth diff --git a/steam/guard.py b/steam/guard.py new file mode 100644 index 0000000..7d184f2 --- /dev/null +++ b/steam/guard.py @@ -0,0 +1,88 @@ +import struct +from binascii import hexlify +from time import time +from steam import webapi +from steam.core.crypto import hmac_sha1, sha1_hash + +def generate_twofactor_code(shared_secret): + """Generate Steam 2FA code for login with current time + + :param shared_secret: authenticator shared shared_secret + :type shared_secret: bytes + :return: steam two factor code + :rtype: str + """ + return generate_twofactor_code_for_time(shared_secret, time() + get_time_offset()) + +def generate_twofactor_code_for_time(shared_secret, timestamp): + """Generate Steam 2FA code for timestamp + + :param shared_secret: authenticator shared secret + :type shared_secret: bytes + :param timestamp: timestamp to use, if left out uses current time + :type timestamp: int + :return: steam two factor code + :rtype: str + """ + hmac = hmac_sha1(bytes(shared_secret), + struct.pack('>Q', int(timestamp)//30)) # this will NOT stop working in 2038 + + start = ord(hmac[19:20]) & 0xF + codeint = struct.unpack('>I', hmac[start:start+4])[0] & 0x7fffffff + + charset = '23456789BCDFGHJKMNPQRTVWXY' + code = '' + + for _ in range(5): + codeint, i = divmod(codeint, len(charset)) + code += charset[i] + + return code + +def generate_confirmation_key(identity_secret, timestamp, tag=''): + """Generate confirmation key for trades. Can only be used once. + + :param identity_secret: authenticator identity secret + :type identity_secret: bytes + :param timestamp: timestamp to use for generating key + :type timestamp: int + :param tag: tag identifies what the request, see list below + :type tag: str + :return: confirmation key + :rtype: bytes + + Tag choices: + + * ``conf`` to load the confirmations page + * ``details`` to load details about a trade + * ``allow`` to confirm a trade + * ``cancel`` to cancel a trade + + """ + data = struct.pack('>Q', int(timestamp)) + tag.encode('ascii') # this will NOT stop working in 2038 + return hmac_sha1(bytes(identity_secret), data) + +def get_time_offset(): + """Get time offset from steam server time via WebAPI + + :return: time offset + :rtype: int + """ + try: + resp = webapi.post('ITwoFactorService', 'QueryTime', 1, params={'http_timeout': 5}) + except: + return 0 + + ts = int(time()) + return int(resp.get('response', {}).get('server_time', ts)) - ts + +def generate_device_id(steamid): + """Generate Android device id + + :param steamid: Steam ID + :type steamid: :class:`.SteamID`, :class:`int` + :return: android device id + :rtype: str + """ + h = hexlify(sha1(str(steamid).encode('ascii'))).decode('ascii') + return "android:%s-%s-%s-%s-%s" % (h[:8], h[8:12], h[12:16], h[16:20], h[20:32]) diff --git a/tests/test_guard.py b/tests/test_guard.py new file mode 100644 index 0000000..a219259 --- /dev/null +++ b/tests/test_guard.py @@ -0,0 +1,19 @@ +import unittest +import mock + +from steam import guard as g + +class TCguard(unittest.TestCase): + def test_generate_twofactor_code_for_time(self): + code = g.generate_twofactor_code_for_time(b'superdupersecret', timestamp=3000030) + self.assertEqual(code, 'YRGQJ') + + code = g.generate_twofactor_code_for_time(b'superdupersecret', timestamp=3000029) + self.assertEqual(code, '94R9D') + + def test_generate_confirmation_key(self): + key = g.generate_confirmation_key(b'itsmemario', 100000) + self.assertEqual(key, b'\xed\xb5\xe5\xad\x8f\xf1\x99\x01\xc8-w\xd6\xb5 p\xccz\xd7\xd1\x05') + + key = g.generate_confirmation_key(b'itsmemario', 100000, 'allow') + self.assertEqual(key, b"Q'\x06\x80\xe1g\xa8m$\xb2hV\xe6g\x8b'\x8f\xf1L\xb0") From 069418585049874ce5a219691cd9179832d57e33 Mon Sep 17 00:00:00 2001 From: Rossen Georgiev Date: Wed, 1 Jun 2016 00:32:03 +0100 Subject: [PATCH 24/24] fix rtd url in README --- README.rst | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 4b75742..65cff8c 100644 --- a/README.rst +++ b/README.rst @@ -4,7 +4,7 @@ A python module for interacting with various parts of Steam_. Supports Python ``2.7+`` and ``3.4+``. -Documentation: http://steam.readthedocs.io/en/latest +Documentation: http://steam.readthedocs.io/en/latest/ Main features ------------- diff --git a/setup.py b/setup.py index 0377cc0..0cae798 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ import sys here = path.abspath(path.dirname(__file__)) with open(path.join(here, 'README.rst'), encoding='utf-8') as f: long_description = f.read()\ - .replace('.io/en/latest', '.io/en/stable')\ + .replace('.io/en/latest/', '.io/en/stable/')\ .replace('?badge=latest', '?badge=stable')\ .replace('projects/steam/badge/?version=latest', 'projects/steam/badge/?version=stable') with open(path.join(here, 'steam/__init__.py'), encoding='utf-8') as f: