├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── requirements.txt ├── setup.py └── steamapi ├── __init__.py ├── app.py ├── consts.py ├── core.py ├── decorators.py ├── errors.py ├── store.py └── user.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | .venv 3 | 4 | # C extensions 5 | *.so 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | lib 20 | lib64 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | /.idea 38 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | python: 4 | - "2.7" 5 | - "3.5" 6 | - "3.6" 7 | - "3.7" 8 | 9 | install: pip install -r requirements.txt 10 | script: python -c "from steamapi import *" 11 | 12 | jobs: 13 | include: 14 | - stage: "Code quality" 15 | install: 16 | - pip install -r requirements.txt 17 | - pip install pycodestyle 18 | name: "PEP-8" 19 | python: "3.7" 20 | script: python -m pycodestyle --ignore=E501,E741 ./steamapi 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2019 Smiley Barry (alias) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SteamAPI [![Build Status](https://travis-ci.org/smiley/steamapi.svg?branch=master)](https://travis-ci.org/smiley/steamapi) 2 | ======== 3 | An object-oriented Python 2.7+/3.5+ library for accessing the Steam Web API. 4 | 5 | ## What's this? 6 | It's a Python library for accessing Steam's [Web API](http://steamcommunity.com/dev), which separates the JSON, HTTP requests, authentication and other web junk from your Python code. Your code will still ask the Steam Web API for bits and bobs of user profiles, games, etc., but invisibly, lazily, and in a cached manner. 7 | 8 | It's super-easy to use, straightforward and designed for continuous use. Finally, an easy way to interface with Steam! 9 | 10 | ## How? 11 | With some abstraction, Pythonic classes and ~~magic~~ tricks. Essentially, I use [*requests*](//github.com/kennethreitz/requests) for the actual communication, a few converter classes for parsing the output and making it a proper object, and some well-timed caching to make sure lazy-initialization doesn't get you down. 12 | 13 | ## How do I use this? 14 | Clone the repository & run `python setup.py develop`. (Or [download](/smiley/steamapi/archive/master.zip) it & run `python setup.py install`, which copies the code to your local Python packages folder) 15 | 16 | Then, you can use it like this: 17 | ```python 18 | >>> import steamapi 19 | >>> steamapi.core.APIConnection(api_key="ABCDEFGHIJKLMNOPQRSTUVWXYZ", validate_key=True) # <-- Insert API key here 20 | >>> steamapi.user.SteamUser(userurl="smileybarry") # For http://steamcommunity.com/id/smileybarry 21 | Or: 22 | >>> steamapi.user.SteamUser(76561197996416028) # Using the 64-bit Steam user ID 23 | 24 | >>> me = _ 25 | >>> me.level 26 | 22 27 | >>> me.friends 28 | [, , ...] 29 | ``` 30 | 31 | Or maybe even like this: 32 | ```python 33 | ... 34 | >>> me.recently_played 35 | [, , ...] 36 | >>> me.games 37 | [, , , ...] 38 | ``` 39 | 40 | ## More examples 41 | ### [Flask](http://flask.pocoo.org/)-based web service 42 | How about [a Flask web service that tells a user how many games & friends he has?](/smiley/steamapi-flask-example) 43 | 44 | ```python 45 | from flask import Flask, render_template 46 | from steamapi import core, user 47 | 48 | app = Flask("Steamer") 49 | core.APIConnection(api_key="YOURKEYHERE") 50 | 51 | @app.route('/user/') 52 | def hello(name=None): 53 | try: 54 | try: 55 | steam_user = user.SteamUser(userid=int(name)) 56 | except ValueError: # Not an ID, but a vanity URL. 57 | steam_user = user.SteamUser(userurl=name) 58 | name = steam_user.name 59 | content = "Your real name is {0}. You have {1} friends and {2} games.".format(steam_user.real_name, 60 | len(steam_user.friends), 61 | len(steam_user.games)) 62 | img = steam_user.avatar 63 | return render_template('hello.html', name=name, content=content, img=img) 64 | except Exception as ex: 65 | # We might not have permission to the user's friends list or games, so just carry on with a blank message. 66 | return render_template('hello.html', name=name) 67 | 68 | if __name__ == '__main__': 69 | app.run() 70 | ``` 71 | *(`hello.html` can be found [here](//github.com/smiley/steamapi-flask-example/blob/master/templates/hello.html))* 72 | 73 | You can [try it out for yourself](//github.com/smiley/steamapi-flask-example) by cloning/downloading a ZIP and [deploying it to Google App Engine](https://cloud.google.com/appengine/docs/python/tools/uploadinganapp?hl=en) for free. 74 | 75 | --- 76 | 77 | The library was made for both easy use *and* easy prototyping. It supports auto-completion in IPython and other standards-abiding interpreters, even with dynamic objects (APIResponse). I mean, what good is an API if you constantly have to have the documentation, a browser and a *web debugger* open to figure it out? 78 | 79 | Note that you need an API key for most commands, **but** API keys can be obtained immediately, for free, from the [Steam Web API developer page](http://steamcommunity.com/dev). 80 | 81 | The API registration page requires a domain, but it's only a formality. It's not enforced by the API server. 82 | 83 | ## FAQ 84 | *Don't see your question here? More questions were [asked](/../../issues?q=is%3Aissue+label%3Aquestion) and [answered](/../../issues?q=is%3Aissue+label%3Aquestion-answered) in the "Issues" section.* 85 | 86 | ### Does this work? 87 | Yep! You can try the examples above, or you can just jump in and browse the API using an interpreter. I recommend [IPython](http://ipython.org); it has some awesome auto-completion, search & code inspection. 88 | 89 | ### How can I get \* using the API? I can't find it here. 90 | You can open a Python interpreter and play around with the library. It's suited for experimentation and prototyping, to help prevent these exact cases. A full documentation will be available soon. 91 | 92 | If you still can't find it, I might've not implemented it yet. This is still a work in progress. Don't worry though, I plan to have the entire public API mapped & available soon! 93 | 94 | ### I have a feature/change that I think should go in. How can I participate? 95 | You can do one of two things: 96 | 1. [Fork the repository](/../../fork) and make your changes. When you're done, send me a pull request and I'll look at it. 97 | 2. [Open a ticket](/../../issues/new) and tell me about it. My aim is to create the best API library in terms of comfort, flexibility and capabilities, and I can't do that alone. I'd love to hear about your ideas. 98 | 99 | ### Is this official? 100 | No, and it's also not endorsed in any way by Valve Corporation. _(obligatory legal notice)_ I couldn't find a fitting name at this point for it, so I just skipped it for now. 101 | 102 | ### Can I use this library in my busy web app? 103 | No, but feel free to experiment with it. It's roughly stable right now, with many of the quirks fixed and most classes having a steady API. Small refactorings are rare, and I do plan to overhaul the object system to allow async/batching behaviour, but that's still a way off. 104 | 105 | ### Is this still actively-developed? The last commit is quite a while ago! 106 | Sadly, not anymore. This isn't abandoned or archived but, as you can tell, I haven't touched it in years. I've moved on and the projects I had in mind for this library (and the reason I wrote it) are dead at this point. If you'd like to add or change things, you can fork the repository and open a pull request, despite the above I can still review and accept changes. 107 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.3.0 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | 6 | def local_requirements(): 7 | req_list = [] 8 | with open('requirements.txt') as requirements_file: 9 | req_list = [line.strip() for line in requirements_file.readlines()] 10 | install_reqs = list(filter(None, req_list)) 11 | return install_reqs 12 | 13 | 14 | setup(name='steamapi', 15 | version='0.1', 16 | description='An object-oriented Python 2.7+ library for accessing the Steam Web API', 17 | url='https://github.com/smiley/steamapi', 18 | author='Smiley', 19 | author_email='', 20 | license='MIT', 21 | packages=['steamapi'], 22 | install_requires=local_requirements(), 23 | zip_safe=False) 24 | -------------------------------------------------------------------------------- /steamapi/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'SmileyBarry' 2 | 3 | from . import app, core, errors, user 4 | -------------------------------------------------------------------------------- /steamapi/app.py: -------------------------------------------------------------------------------- 1 | __author__ = 'SmileyBarry' 2 | 3 | from .core import APIConnection, SteamObject, store 4 | from .decorators import cached_property, INFINITE 5 | 6 | 7 | class SteamApp(SteamObject): 8 | def __init__(self, appid, name=None, owner=None): 9 | self._id = appid 10 | if name is not None: 11 | import time 12 | self._cache = dict() 13 | self._cache['name'] = (name, time.time()) 14 | # Normally, the associated userid is also the owner. 15 | # That would not be the case if the game is borrowed, though. In that case, the object creator 16 | # usually defines attributes accordingly. However, at this time we can't ask the API "is this 17 | # game borrowed?", unless it's the actively-played game, so this distinction isn't done in the 18 | # object's context, but in the object creator's context. 19 | self._owner = owner 20 | self._userid = self._owner 21 | 22 | # Factory methods 23 | @staticmethod 24 | def from_api_response(api_json, associated_userid=None): 25 | """ 26 | Create a new SteamApp instance from an APIResponse object. 27 | 28 | :param api_json: The raw JSON returned by the API, in "APIResponse" form. 29 | :type api_json: steamapi.core.APIResponse 30 | :param associated_userid: A user ID associated to this game, if applicable. This can be the user who played the 31 | app/game, or its owner if it is borrowed, depending on context. 32 | :type associated_userid: long 33 | :return: a new SteamApp instance 34 | :rtype: SteamApp 35 | """ 36 | if 'appid' not in api_json: 37 | # An app ID is a bare minimum. 38 | raise ValueError( 39 | "An app ID is required to create a SteamApp object.") 40 | 41 | appid = api_json.appid 42 | name = None 43 | if 'name' in api_json: 44 | name = api_json.name 45 | 46 | return SteamApp(appid, name, associated_userid) 47 | 48 | @cached_property(ttl=INFINITE) 49 | def _schema(self): 50 | return APIConnection().call("ISteamUserStats", "GetSchemaForGame", "v2", appid=self._id) 51 | 52 | @property 53 | def appid(self): 54 | return self._id 55 | 56 | @cached_property(ttl=INFINITE) 57 | def achievements(self): 58 | global_percentages = APIConnection().call("ISteamUserStats", "GetGlobalAchievementPercentagesForApp", "v0002", 59 | gameid=self._id) 60 | if self._userid is not None: 61 | # Ah-ha, this game is associated to a user! 62 | userid = self._userid 63 | unlocks = APIConnection().call("ISteamUserStats", 64 | "GetUserStatsForGame", 65 | "v2", 66 | appid=self._id, 67 | steamid=userid) 68 | if 'achievements' in unlocks.playerstats: 69 | unlocks = [associated_achievement.name 70 | for associated_achievement in unlocks.playerstats.achievements 71 | if associated_achievement.achieved != 0] 72 | else: 73 | userid = None 74 | unlocks = None 75 | achievements_list = [] 76 | if 'availableGameStats' not in self._schema.game: 77 | # No stat data -- at all. This is a hidden app. 78 | return achievements_list 79 | for achievement in self._schema.game.availableGameStats.achievements: 80 | achievement_obj = SteamAchievement( 81 | self._id, achievement.name, achievement.displayName, userid) 82 | achievement_obj._cache = {} 83 | if achievement.hidden == 0: 84 | store(achievement_obj, "is_hidden", False) 85 | else: 86 | store(achievement_obj, "is_hidden", True) 87 | for global_achievement in global_percentages.achievementpercentages.achievements: 88 | if global_achievement.name == achievement.name: 89 | achievement_obj.unlock_percentage = global_achievement.percent 90 | achievements_list += [achievement_obj] 91 | if unlocks is not None: 92 | for achievement in achievements_list: 93 | if achievement.apiname in unlocks: 94 | store(achievement, "is_achieved", True) 95 | else: 96 | store(achievement, "is_achieved", False) 97 | return achievements_list 98 | 99 | @cached_property(ttl=INFINITE) 100 | def name(self): 101 | if 'gameName' in self._schema.game: 102 | return self._schema.game.gameName 103 | else: 104 | return "" 105 | 106 | @cached_property(ttl=INFINITE) 107 | def owner(self): 108 | if self._owner is None: 109 | return self._userid 110 | else: 111 | return self._owner 112 | 113 | def __str__(self): 114 | return self.name 115 | 116 | def __hash__(self): 117 | # Don't just use the ID so ID collision between different types of 118 | # objects wouldn't cause a match. 119 | return hash(('app', self.id)) 120 | 121 | 122 | class SteamAchievement(SteamObject): 123 | def __init__(self, linked_appid, apiname, displayname, linked_userid=None): 124 | """ 125 | Initialise a new instance of SteamAchievement. You shouldn't create one yourself, but from 126 | "SteamApp.achievements" instead. 127 | 128 | :param linked_appid: The AppID associated with this achievement. 129 | :type linked_appid: int 130 | :param apiname: The API-based name of this achievement. Usually a string. 131 | :type apiname: str or unicode 132 | :param displayname: The achievement's user-facing name. 133 | :type displayname: str or unicode 134 | :param linked_userid: The user ID this achievement is linked to. 135 | :type linked_userid: int 136 | :return: A new SteamAchievement instance. 137 | """ 138 | self._appid = linked_appid 139 | self._id = apiname 140 | self._displayname = displayname 141 | self._userid = linked_userid 142 | self.unlock_percentage = 0.0 143 | 144 | def __hash__(self): 145 | # Don't just use the ID so ID collision between different types of 146 | # objects wouldn't cause a match. 147 | return hash((self.id, self._appid)) 148 | 149 | @property 150 | def appid(self): 151 | return self._appid 152 | 153 | @property 154 | def name(self): 155 | return self._displayname 156 | 157 | @property 158 | def apiname(self): 159 | return self._id 160 | 161 | @cached_property(ttl=INFINITE) 162 | def is_hidden(self): 163 | response = APIConnection().call("ISteamUserStats", 164 | "GetSchemaForGame", 165 | "v2", 166 | appid=self._appid) 167 | for achievement in response.game.availableGameStats.achievements: 168 | if achievement.name == self._id: 169 | if achievement.hidden == 0: 170 | return False 171 | else: 172 | return True 173 | 174 | @cached_property(ttl=INFINITE) 175 | def is_unlocked(self): 176 | if self._userid is None: 177 | raise ValueError("No Steam ID linked to this achievement!") 178 | response = APIConnection().call("ISteamUserStats", 179 | "GetPlayerAchievements", 180 | "v1", 181 | steamid=self._userid, 182 | appid=self._appid, 183 | l="English") 184 | for achievement in response.playerstats.achievements: 185 | if achievement.apiname == self._id: 186 | if achievement.achieved == 1: 187 | return True 188 | else: 189 | return False 190 | # Cannot be found. 191 | return False 192 | -------------------------------------------------------------------------------- /steamapi/consts.py: -------------------------------------------------------------------------------- 1 | __author__ = 'SmileyBarry' 2 | 3 | 4 | class Enum(object): 5 | def __init__(self): 6 | raise TypeError( 7 | "Enums cannot be instantiated, use their attributes instead") 8 | 9 | 10 | class CommunityVisibilityState(Enum): 11 | PRIVATE = 1 12 | FRIENDS_ONLY = 2 13 | FRIENDS_OF_FRIENDS = 3 14 | USERS_ONLY = 4 15 | PUBLIC = 5 16 | 17 | 18 | class OnlineState(Enum): 19 | OFFLINE = 0 20 | ONLINE = 1 21 | BUSY = 2 22 | AWAY = 3 23 | SNOOZE = 4 24 | LOOKING_TO_TRADE = 5 25 | LOOKING_TO_PLAY = 6 26 | 27 | 28 | try: 29 | get_ipython 30 | # We're inside IPython. Define all of IPython's custom function/method 31 | # names so we could special-case them. 32 | IPYTHON_PEEVES = ["trait_names", "getdoc"] 33 | IPYTHON_MODE = True 34 | except NameError: 35 | # IPython's not running us. Don't special-case it. (An empty list instantly makes any "if name in IPYTHON_PEEVES" 36 | # clause False, without doing unnecessary checks.) 37 | IPYTHON_PEEVES = [] 38 | IPYTHON_MODE = False 39 | 40 | # Unindented so that the docstring won't be overly indented. 41 | API_CALL_DOCSTRING_TEMPLATE = \ 42 | """ 43 | {name} 44 | 45 | Parameters: 46 | {parameter_list} 47 | """ 48 | API_CALL_PARAMETER_TEMPLATE = "{indent}{{requirement}} {{type}} {{name}}:{indent}{{desc}}" 49 | -------------------------------------------------------------------------------- /steamapi/core.py: -------------------------------------------------------------------------------- 1 | __author__ = 'SmileyBarry' 2 | 3 | import requests 4 | import sys 5 | import time 6 | 7 | from .consts import API_CALL_DOCSTRING_TEMPLATE, API_CALL_PARAMETER_TEMPLATE, IPYTHON_PEEVES, IPYTHON_MODE 8 | from .decorators import Singleton, cached_property, INFINITE 9 | from .errors import APIException, APIUnauthorized, APIKeyRequired, APIPrivate, APIConfigurationError 10 | from . import errors 11 | 12 | GET = "GET" 13 | POST = "POST" 14 | 15 | # A mapping of all types accepted/required by the API to their Python 16 | # equivalents. 17 | APITypes = {'bool': bool, 18 | 'int32': int, 19 | 'uint32': int, 20 | 'uint64': int, 21 | 'string': [str], 22 | 'rawbinary': [str, bytes]} 23 | 24 | if sys.version_info.major < 3: 25 | # Starting with Python 3, "str" means unicode and "unicode" is not defined. It is 26 | # still relevant for Python 2.x, however. 27 | APITypes['string'] += [unicode] 28 | APITypes['rawbinary'] += [buffer] 29 | 30 | 31 | class APICall(object): 32 | def __init__(self, api_id, parent, method=None): 33 | """ 34 | Create a new APICall instance. 35 | 36 | :param api_id: The API's string-based ID. Must start with a letter, as per Python's rules for attributes. 37 | :type api_id: str 38 | :param parent: The APICall parent of this object. If this is a service or interface, an APIInterface instance is 39 | given instead. 40 | :type parent: APICall or APIInterface 41 | :param method: The HTTP method used for calling the API. 42 | :type method: str 43 | :return: A new instance of APICall. 44 | :rtype: APICall 45 | """ 46 | self._api_id = api_id 47 | self._is_registered = False 48 | self._parent = parent 49 | self._method = method 50 | 51 | # Cached data. 52 | self._cached_key = None 53 | self._query = "" 54 | 55 | # Set an empty documentation for now. 56 | self._api_documentation = "" 57 | 58 | @property 59 | def _api_key(self): 60 | """ 61 | Fetch the appropriate API key, if applicable. 62 | 63 | If a key is defined in this call's APIInterface "grandparent" (since each APICall has a APICall parent), it is 64 | used and cached by this object indefinitely. (Until destruction) 65 | 66 | Otherwise, nothing (None) will be returned. 67 | 68 | :return: A Steam Web API key in the form of a string, or None if not available. 69 | :rtype: str or None 70 | """ 71 | if self._cached_key is not None: 72 | return self._cached_key 73 | 74 | if self._parent is not None: 75 | self._cached_key = self._parent._api_key 76 | return self._cached_key 77 | 78 | # No key is available. (This is OK) 79 | return None 80 | 81 | def _build_query(self): 82 | if self._query != "": 83 | return self._query 84 | 85 | # Build the query by calling "str" on ourselves, which recursively 86 | # calls "str" on each parent in the chain. 87 | self._query = str(self) 88 | return self._query 89 | 90 | def __str__(self): 91 | """ 92 | Generate the function URL. 93 | """ 94 | if isinstance(self._parent, APIInterface): 95 | return self._parent._query_template + self._api_id + '/' 96 | else: 97 | return str(self._parent) + self._api_id + '/' 98 | 99 | @cached_property(ttl=INFINITE) 100 | def _full_name(self): 101 | if self._parent is None: 102 | return self._api_id 103 | else: 104 | return self._parent._full_name + '.' + self._api_id 105 | 106 | def __repr__(self): 107 | if self._is_registered is True: 108 | # This is a registered, therefore working, API. 109 | note = "(verified)" 110 | else: 111 | note = "(unconfirmed)" 112 | return "<{cls} {full_name} {api_note}>".format(cls=self.__class__.__name__, 113 | full_name=self._full_name, 114 | api_note=note) 115 | 116 | def __getattribute__(self, item): 117 | if item.startswith('_'): 118 | # Underscore items are special. 119 | return super(APICall, self).__getattribute__(item) 120 | else: 121 | try: 122 | return super(APICall, self).__getattribute__(item) 123 | except AttributeError: 124 | if IPYTHON_MODE is True: 125 | # We're in IPython. Which means "getdoc()" is also 126 | # automatically used for docstrings! 127 | if item == "getdoc": 128 | return lambda: self._api_documentation 129 | elif item in IPYTHON_PEEVES: 130 | # IPython always looks for this, no matter what (hiding it in __dir__ doesn't work), so this is 131 | # necessary to keep it from constantly making new 132 | # APICall instances. (a significant slowdown) 133 | raise 134 | # Not an expected item, so generate a new APICall! 135 | return APICall(item, self) 136 | 137 | def __iter__(self): 138 | return self.__dict__.__iter__() 139 | 140 | def _set_documentation(self, docstring): 141 | """ 142 | Set a docstring specific to this instance of APICall, explaining the bound function. 143 | 144 | :param docstring: The relevant docstring. 145 | :return: None 146 | """ 147 | self._api_documentation = docstring 148 | 149 | def _register(self, apicall_child=None): 150 | """ 151 | Register a child APICall object under the "self._resolved_children" dictionary so it can be used 152 | normally. Used by API function wrappers after they're deemed working. 153 | 154 | :param apicall_child: A working APICall object that should be stored as resolved. 155 | :type apicall_child: APICall 156 | """ 157 | if apicall_child is not None: 158 | if apicall_child._api_id in self.__dict__ \ 159 | and apicall_child is not self.__dict__[apicall_child._api_id]: 160 | raise KeyError( 161 | "This API ID is already taken by another API function!") 162 | if not isinstance(self._parent, APIInterface): 163 | self._parent._register(self) 164 | else: 165 | self._is_registered = True 166 | if apicall_child is not None: 167 | self.__setattr__(apicall_child._api_id, apicall_child) 168 | apicall_child._is_registered = True 169 | 170 | def _convert_arguments(self, kwargs): 171 | """ 172 | Convert the types of given arguments to a call-friendly format. Modifies the given dictionary directly. 173 | 174 | :param kwargs: The keyword-arguments dictionary, passed on to the calling function. 175 | :type kwargs: dict 176 | :return: None, as the given dictionary is changed in-place. 177 | :rtype: None 178 | """ 179 | for argument in kwargs: 180 | if issubclass(type(kwargs[argument]), list): 181 | # The API takes multiple values in a "a,b,c" structure, so we 182 | # have to encode it in that way. 183 | kwargs[argument] = ','.join(kwargs[argument]) 184 | elif issubclass(type(kwargs[argument]), bool): 185 | # The API treats True/False as 1/0. Convert it. 186 | if kwargs[argument] is True: 187 | kwargs[argument] = 1 188 | else: 189 | kwargs[argument] = 0 190 | 191 | def __call__(self, method=GET, **kwargs): 192 | self._convert_arguments(kwargs) 193 | 194 | automatic_parsing = True 195 | if "format" in kwargs: 196 | automatic_parsing = False 197 | else: 198 | kwargs["format"] = "json" 199 | 200 | if self._api_key is not None: 201 | kwargs["key"] = self._api_key 202 | 203 | # Format the final query. 204 | query = str(self) 205 | 206 | if self._method is not None: 207 | method = self._method 208 | 209 | if method == POST: 210 | response = requests.request(method, query, data=kwargs) 211 | else: 212 | response = requests.request(method, query, params=kwargs) 213 | 214 | errors.check(response) 215 | 216 | # Store the object for future reference. 217 | if self._is_registered is False: 218 | self._parent._register(self) 219 | 220 | if automatic_parsing is True: 221 | response_obj = response.json() 222 | if len(response_obj.keys()) == 1 and 'response' in response_obj: 223 | return APIResponse(response_obj['response']) 224 | else: 225 | return APIResponse(response_obj) 226 | else: 227 | if kwargs["format"] == "json": 228 | return response.json() 229 | else: 230 | return response.content 231 | 232 | 233 | class APIInterface(object): 234 | def __init__(self, api_key=None, autopopulate=False, strict=False, 235 | api_domain="api.steampowered.com", api_protocol="http", settings=None, 236 | validate_key=False): 237 | """ 238 | Initialize a new APIInterface object. This object defines an API-interacting session, and is used to call 239 | any API functions from standard code. 240 | 241 | :param api_key: Your Steam Web API key. Can be left blank, but some APIs will not work. 242 | :type api_key: str 243 | :param autopopulate: Whether the interfaces, services and methods supported by the Steam Web API should be \ 244 | auto-populated during initialization. 245 | :type autopopulate: bool 246 | :param strict: Should the interface enforce access only to defined functions, and only as defined. Only \ 247 | applicable if :var autopopulate: is True. 248 | :type strict: bool 249 | :param api_domain: 250 | :param settings: A dictionary which defines advanced settings. 251 | :type settings: dict 252 | :param validate_key: Perform a test call to the API with the given key to ensure the key is valid & working. 253 | :return: 254 | """ 255 | if autopopulate is False and strict is True: 256 | raise ValueError( 257 | "\"strict\" is only applicable if \"autopopulate\" is set to True.") 258 | 259 | if api_protocol not in ("http", "https"): 260 | raise ValueError( 261 | "\"api_protocol\" must either be \"http\" or \"https\".") 262 | 263 | if '/' in api_domain: 264 | raise ValueError( 265 | "\"api_domain\" should only contain the domain name itself, without any paths or queries.") 266 | 267 | if issubclass(type(api_key), str) and len(api_key) == 0: 268 | # We were given an empty key (== no key), but the API's equivalent 269 | # of "no key" is None. 270 | api_key = None 271 | 272 | if settings is None: 273 | # Working around mutable argument defaults. 274 | settings = dict() 275 | 276 | super_self = super(type(self), self) 277 | 278 | # Initialization routines must use the original __setattr__ function, because they might collide with the 279 | # overridden "__setattr__", which expects a fully-built instance to 280 | # exist before being called. 281 | def set_attribute(name, value): 282 | return super_self.__setattr__(name, value) 283 | 284 | set_attribute('_api_key', api_key) 285 | set_attribute('_strict', strict) 286 | set_attribute('_settings', settings) 287 | 288 | query_template = "{proto}://{domain}/".format( 289 | proto=api_protocol, domain=api_domain) 290 | set_attribute('_query_template', query_template) 291 | 292 | if autopopulate is True: 293 | # TODO: Autopopulation should be long-term-cached somewhere for 294 | # future use, since it won't change much. 295 | 296 | # Regardless of "strict mode", it has to be OFF during 297 | # auto-population. 298 | original_strict_value = self._strict 299 | try: 300 | self.__dict__['_strict'] = False 301 | self._autopopulate_interfaces() 302 | finally: 303 | self.__dict__['_strict'] = original_strict_value 304 | elif validate_key is True: 305 | if api_key is None: 306 | raise ValueError( 307 | '"validate_key" is True, but no key was given.') 308 | 309 | # Call "GetSupportedAPIList", which is guaranteed to succeed with 310 | # any valid key. (Or no key) 311 | try: 312 | self.ISteamWebAPIUtil.GetSupportedAPIList.v1(key=self._api_key) 313 | except (APIUnauthorized, APIKeyRequired, APIPrivate): 314 | raise APIConfigurationError("This API key is invalid.") 315 | 316 | def _autopopulate_interfaces(self): 317 | # Call the API which returns a list of API Services and Interfaces. 318 | # API definitions describe how the Interfaces and Services are built 319 | # up, including parameter names & types. 320 | api_definition = self.ISteamWebAPIUtil.GetSupportedAPIList.v1( 321 | key=self._api_key) 322 | 323 | for interface in api_definition.apilist.interfaces: 324 | interface_object = APICall(interface.name, self) 325 | parameter_description = API_CALL_PARAMETER_TEMPLATE.format( 326 | indent='\t') 327 | 328 | for method in interface.methods: 329 | if method.name in interface_object: 330 | base_method_object = interface_object.__getattribute__( 331 | method.name) 332 | else: 333 | base_method_object = APICall( 334 | method.name, interface_object, method.httpmethod) 335 | # API calls have version-specific definitions, so backwards compatibility could be maintained. 336 | # However, the Web API returns versions as integers (1, 2, 337 | # etc.) but accepts them as "v?" (v1, v2, etc.) 338 | method_object = APICall( 339 | 'v' + str(method.version), base_method_object, method.httpmethod) 340 | 341 | parameters = [] 342 | for parameter in method.parameters: 343 | parameter_requirement = "REQUIRED" 344 | if parameter.optional is True: 345 | parameter_requirement = "OPTIONAL" 346 | if 'description' in parameter: 347 | desc = parameter.description 348 | else: 349 | desc = "(no description)" 350 | parameters += [parameter_description.format(requirement=parameter_requirement, 351 | type=parameter.type, 352 | name=parameter.name, 353 | desc=desc)] 354 | # Now build the docstring. 355 | func_docstring = API_CALL_DOCSTRING_TEMPLATE.format(name=method.name, 356 | parameter_list='\n'.join(parameters)) 357 | # Set the docstring appropriately 358 | method_object._api_documentation = func_docstring 359 | 360 | # Now call the standard registration method. 361 | method_object._register() 362 | # And now, add it to the APIInterface. 363 | setattr(self, interface.name, interface_object) 364 | 365 | def __getattr__(self, name): 366 | """ 367 | Creates a new APICall() instance if "strict" is disabled. 368 | 369 | :param name: A Service or Interface name. 370 | :return: A Pythonic object used to access the remote Service or Interface. (APICall) 371 | :rtype: APICall 372 | """ 373 | if name.startswith('_'): 374 | return super(type(self), self).__getattribute__(name) 375 | elif name in IPYTHON_PEEVES: 376 | # IPython always looks for this, no matter what (hiding it in __dir__ doesn't work), so this is 377 | # necessary to keep it from constantly making new APICall 378 | # instances. (a significant slowdown) 379 | raise AttributeError() 380 | else: 381 | if self._strict is True: 382 | raise AttributeError("Strict '{cls}' object has no attribute '{attr}'".format(cls=type(self).__name__, 383 | attr=name)) 384 | new_service = APICall(name, self) 385 | # Save this service. 386 | self.__dict__[name] = new_service 387 | return new_service 388 | 389 | def __setattr__(self, name, value): 390 | if self._strict is True: 391 | raise AttributeError("Cannot set attributes to a strict '{cls}' object.".format( 392 | cls=type(self).__name__)) 393 | else: 394 | return super(type(self), self).__setattr__(name, value) 395 | 396 | 397 | @Singleton 398 | class APIConnection(object): 399 | QUERY_DOMAIN = "http://api.steampowered.com" 400 | # Use double curly-braces to tell Python that these variables shouldn't be 401 | # expanded yet. 402 | QUERY_TEMPLATE = "{domain}/{{interface}}/{{command}}/{{version}}/".format( 403 | domain=QUERY_DOMAIN) 404 | 405 | def __init__(self, api_key=None, settings={}, validate_key=False): 406 | """ 407 | NOTE: APIConnection will soon be made deprecated by APIInterface. 408 | 409 | Initialise the main APIConnection. Since APIConnection is a singleton object, any further "initialisations" 410 | will not re-initialise the instance but just retrieve the existing instance. To reassign an API key, 411 | retrieve the Singleton instance and call "reset" with the key. 412 | 413 | :param api_key: A Steam Web API key. (Optional, but recommended) 414 | :param settings: A dictionary of advanced tweaks. Beware! (Optional) 415 | precache -- True/False. (Default: True) Decides whether attributes that retrieve 416 | a group of users, such as "friends", should precache player summaries, 417 | like nicknames. Recommended if you plan to use nicknames right away, since 418 | caching is done in groups and retrieving one-by-one takes a while. 419 | :param validate_key: Perform a test call to the API with the given key to ensure the key is valid & working. 420 | 421 | """ 422 | self.reset(api_key) 423 | 424 | self.precache = True 425 | 426 | if 'precache' in settings and issubclass( 427 | type(settings['precache']), bool): 428 | self.precache = settings['precache'] 429 | 430 | if validate_key: 431 | if api_key is None: 432 | raise ValueError( 433 | '"validate_key" is True, but no key was given.') 434 | 435 | # Call "GetSupportedAPIList", which is guaranteed to succeed with 436 | # any valid key. (Or no key) 437 | try: 438 | self.call("ISteamWebAPIUtil", "GetSupportedAPIList", "v1") 439 | except (APIUnauthorized, APIKeyRequired, APIPrivate): 440 | raise APIConfigurationError("This API key is invalid.") 441 | 442 | def reset(self, api_key): 443 | self._api_key = api_key 444 | 445 | def call(self, interface, command, version, method=GET, **kwargs): 446 | """ 447 | Call an API command. All keyword commands past method will be made into GET/POST-based commands, 448 | automatically. 449 | 450 | :param interface: Interface name that contains the requested command. (E.g.: "ISteamUser") 451 | :param command: A matching command. (E.g.: "GetPlayerSummaries") 452 | :param version: The version of this API you're using. (Usually v000X or vX, with "X" standing in for a number) 453 | :param method: Which HTTP method this call should use. GET by default, but can be overriden to use POST for 454 | POST-exclusive APIs or long parameter lists. 455 | :param kwargs: A bunch of keyword arguments for the call itself. "key" and "format" should NOT be specified. 456 | If APIConnection has an assoociated key, "key" will be overwritten by it, and overriding "format" 457 | cancels out automatic parsing. (The resulting object WILL NOT be an APIResponse but a string.) 458 | 459 | :rtype: APIResponse 460 | """ 461 | for argument in kwargs: 462 | if isinstance(kwargs[argument], list): 463 | # The API takes multiple values in a "a,b,c" structure, so we 464 | # have to encode it in that way. 465 | kwargs[argument] = ','.join(kwargs[argument]) 466 | elif isinstance(kwargs[argument], bool): 467 | # The API treats True/False as 1/0. Convert it. 468 | if kwargs[argument] is True: 469 | kwargs[argument] = 1 470 | else: 471 | kwargs[argument] = 0 472 | 473 | automatic_parsing = True 474 | if "format" in kwargs: 475 | automatic_parsing = False 476 | else: 477 | kwargs["format"] = "json" 478 | 479 | if self._api_key is not None: 480 | kwargs["key"] = self._api_key 481 | 482 | query = self.QUERY_TEMPLATE.format( 483 | interface=interface, command=command, version=version) 484 | 485 | if method == POST: 486 | response = requests.request(method, query, data=kwargs) 487 | else: 488 | response = requests.request(method, query, params=kwargs) 489 | 490 | errors.check(response) 491 | 492 | if automatic_parsing is True: 493 | response_obj = response.json() 494 | if len(response_obj.keys()) == 1 and 'response' in response_obj: 495 | return APIResponse(response_obj['response']) 496 | else: 497 | return APIResponse(response_obj) 498 | 499 | 500 | class APIResponse(object): 501 | """ 502 | A dict-proxying object which objectifies API responses for prettier code, 503 | easier prototyping and less meaningless debugging ("Oh, I forgot square brackets."). 504 | 505 | Recursively wraps every response given to it, by replacing each 'dict' object with an 506 | APIResponse instance. Other types are safe. 507 | """ 508 | 509 | def __init__(self, father_dict): 510 | # Initialize an empty dictionary. 511 | self._real_dictionary = {} 512 | # Recursively wrap the response in APIResponse instances. 513 | for item in father_dict: 514 | if isinstance(father_dict[item], dict): 515 | self._real_dictionary[item] = APIResponse(father_dict[item]) 516 | elif isinstance(father_dict[item], list): 517 | self._real_dictionary[item] = APIResponse._wrap_list( 518 | father_dict[item]) 519 | else: 520 | self._real_dictionary[item] = father_dict[item] 521 | 522 | @staticmethod 523 | def _wrap_list(original_list): 524 | """ 525 | Receives a list of items and recursively wraps any dictionaries inside it as APIResponse 526 | objects. Resolves issue #12. 527 | 528 | :param original_list: The original list that needs wrapping. 529 | :type original_list: list 530 | :return: A near-identical list, with "dict" objects replaced into APIResponse ones. 531 | :rtype: list 532 | """ 533 | new_list = [] 534 | for item in original_list: 535 | if isinstance(item, dict): 536 | new_list += [APIResponse(item)] 537 | elif isinstance(item, list): 538 | new_list += [APIResponse._wrap_list(item)] 539 | else: 540 | new_list += [item] 541 | return new_list 542 | 543 | def __repr__(self): 544 | return dict.__repr__(self._real_dictionary) 545 | 546 | @property 547 | def __dict__(self): 548 | return self._real_dictionary 549 | 550 | def __getattribute__(self, item): 551 | if item.startswith("_"): 552 | return super(APIResponse, self).__getattribute__(item) 553 | else: 554 | if item in self._real_dictionary: 555 | return self._real_dictionary[item] 556 | else: 557 | raise AttributeError("'{cls}' has no attribute '{attr}'".format(cls=type(self).__name__, 558 | attr=item)) 559 | 560 | def __getitem__(self, item): 561 | return self._real_dictionary[item] 562 | 563 | def __iter__(self): 564 | return self._real_dictionary.__iter__() 565 | 566 | 567 | class SteamObject(object): 568 | """ 569 | A base class for all rich Steam objects. (SteamUser, SteamApp, etc.) 570 | """ 571 | 572 | @property 573 | def id(self): 574 | return self._id # "_id" is set by the child class. 575 | 576 | def __repr__(self): 577 | try: 578 | return '<{clsname} "{name}" ({id})>'.format(clsname=self.__class__.__name__, 579 | name=_shims.sanitize_for_console( 580 | self.name), 581 | id=self._id) 582 | except (AttributeError, APIException): 583 | return '<{clsname} ({id})>'.format( 584 | clsname=self.__class__.__name__, id=self._id) 585 | 586 | def __eq__(self, other): 587 | """ 588 | :type other: SteamObject 589 | """ 590 | # Use a "hash" of each object to prevent cases where derivative classes sharing the 591 | # same ID, like a user and an app, would cause a match if compared 592 | # using ".id". 593 | return hash(self) == hash(other) 594 | 595 | def __ne__(self, other): 596 | """ 597 | :type other: SteamObject 598 | """ 599 | return not self == other 600 | 601 | def __hash__(self): 602 | return hash(self.id) 603 | 604 | 605 | def store(obj, property_name, data, received_time=0): 606 | """ 607 | Store data inside the cache of a cache-enabled object. Mainly used for pre-caching. 608 | 609 | :param obj: The target object. 610 | :type obj: SteamObject 611 | :param property_name: The destination property's name. 612 | :param data: The data that we need to store inside the object's cache. 613 | :type data: object 614 | :param received_time: The time this data was retrieved. Used for the property cache. 615 | Set to 0 to use the current time. 616 | :type received_time: float 617 | """ 618 | if received_time == 0: 619 | received_time = time.time() 620 | # Just making sure caching is supported for this object... 621 | if issubclass(type(obj), SteamObject) or hasattr(obj, "_cache"): 622 | obj._cache[property_name] = (data, received_time) 623 | else: 624 | raise TypeError( 625 | "This object type either doesn't visibly support caching, or has yet to initialise its cache.") 626 | 627 | 628 | def expire(obj, property_name): 629 | """ 630 | Expire a cached property 631 | 632 | :param obj: The target object. 633 | :type obj: SteamObject 634 | :param property_name: 635 | :type property_name: 636 | """ 637 | if issubclass(type(obj), SteamObject) or hasattr(obj, "_cache"): 638 | del obj._cache[property_name] 639 | else: 640 | raise TypeError( 641 | "This object type either doesn't visibly support caching, or has yet to initialise its cache.") 642 | 643 | 644 | def chunker(seq, size): 645 | """ 646 | Turn an iteratable into a iterable of iterables of size 647 | 648 | :param seq: The target iterable 649 | :type seq: iterable 650 | :param size: The max size of the resulting batches 651 | :type size: int 652 | :rtype: iterable 653 | """ 654 | return (seq[pos:pos + size] for pos in range(0, len(seq), size)) 655 | 656 | 657 | class _shims: 658 | """ 659 | A collection of functions used at junction points where a Python 3.x solution potentially degrades functionality 660 | or performance on Python 2.x. 661 | """ 662 | 663 | class Python2: 664 | @staticmethod 665 | def sanitize_for_console(string): 666 | """ 667 | Sanitize a string for console presentation. On Python 2, it decodes Unicode string back to ASCII, dropping 668 | non-ASCII characters. 669 | """ 670 | return string.encode(errors="ignore") 671 | 672 | class Python3: 673 | @staticmethod 674 | def sanitize_for_console(string): 675 | """ 676 | Sanitize a string for console presentation. Does nothing on Python 3. 677 | """ 678 | return string 679 | 680 | if sys.version_info.major >= 3: 681 | sanitize_for_console = Python3.sanitize_for_console 682 | else: 683 | sanitize_for_console = Python2.sanitize_for_console 684 | 685 | sanitize_for_console = staticmethod(sanitize_for_console) 686 | -------------------------------------------------------------------------------- /steamapi/decorators.py: -------------------------------------------------------------------------------- 1 | __author__ = 'SmileyBarry' 2 | 3 | import threading 4 | import time 5 | 6 | 7 | class debug(object): 8 | @staticmethod 9 | def no_return(originalFunction, *args, **kwargs): 10 | def callNoReturn(*args, **kwargs): 11 | originalFunction(*args, **kwargs) 12 | # This code should never return! 13 | raise AssertionError("No-return function returned.") 14 | return callNoReturn 15 | 16 | 17 | MINUTE = 60 18 | HOUR = 60 * MINUTE 19 | INFINITE = 0 20 | 21 | 22 | class cached_property(object): 23 | """(C) 2011 Christopher Arndt, MIT License 24 | 25 | Decorator for read-only properties evaluated only once within TTL period. 26 | 27 | It can be used to created a cached property like this:: 28 | 29 | import random 30 | 31 | # the class containing the property must be a new-style class 32 | class MyClass(object): 33 | # create property whose value is cached for ten minutes 34 | @cached_property(ttl=600) 35 | def randint(self): 36 | # will only be evaluated every 10 min. at maximum. 37 | return random.randint(0, 100) 38 | 39 | The value is cached in the '_cache' attribute of the object instance that 40 | has the property getter method wrapped by this decorator. The '_cache' 41 | attribute value is a dictionary which has a key for every property of the 42 | object which is wrapped by this decorator. Each entry in the cache is 43 | created only when the property is accessed for the first time and is a 44 | two-element tuple with the last computed property value and the last time 45 | it was updated in seconds since the epoch. 46 | 47 | The default time-to-live (TTL) is 300 seconds (5 minutes). Set the TTL to 48 | zero for the cached value to never expire. 49 | 50 | To expire a cached property value manually just do:: 51 | 52 | del instance._cache[] 53 | 54 | """ 55 | 56 | def __init__(self, ttl=300): 57 | self.ttl = ttl 58 | 59 | def __call__(self, fget, doc=None): 60 | self.fget = fget 61 | self.__doc__ = doc or fget.__doc__ 62 | self.__name__ = fget.__name__ 63 | self.__module__ = fget.__module__ 64 | return self 65 | 66 | def __get__(self, inst, owner): 67 | now = time.time() 68 | value, last_update = None, None 69 | if not hasattr(inst, '_cache'): 70 | inst._cache = {} 71 | 72 | entry = inst._cache.get(self.__name__, None) 73 | if entry is not None: 74 | value, last_update = entry 75 | if now - last_update > self.ttl > 0: 76 | entry = None 77 | 78 | if entry is None: 79 | value = self.fget(inst) 80 | cache = inst._cache 81 | cache[self.__name__] = (value, now) 82 | 83 | return value 84 | 85 | 86 | class Singleton: 87 | """ 88 | A non-thread-safe helper class to ease implementing singletons. 89 | This should be used as a decorator -- not a metaclass -- to the 90 | class that should be a singleton. 91 | 92 | The decorated class can define one `__init__` function that 93 | takes only the `self` argument. Other than that, there are 94 | no restrictions that apply to the decorated class. 95 | 96 | Limitations: The decorated class cannot be inherited from. 97 | 98 | :author: Paul Manta, Stack Overflow. 99 | http://stackoverflow.com/a/7346105/2081507 100 | (with slight modification) 101 | 102 | """ 103 | 104 | def __init__(self, decorated): 105 | self._lock = threading.Lock() 106 | self._decorated = decorated 107 | 108 | def __call__(self, *args, **kwargs): 109 | """ 110 | Returns the singleton instance. Upon its first call, it creates a 111 | new instance of the decorated class and calls its `__init__` method. 112 | On all subsequent calls, the already created instance is returned. 113 | 114 | """ 115 | with self._lock: 116 | try: 117 | return self._instance 118 | except AttributeError: 119 | self._instance = self._decorated(*args, **kwargs) 120 | return self._instance 121 | 122 | def __instancecheck__(self, inst): 123 | return isinstance(inst, self._decorated) 124 | -------------------------------------------------------------------------------- /steamapi/errors.py: -------------------------------------------------------------------------------- 1 | __author__ = 'SmileyBarry' 2 | 3 | from .decorators import debug 4 | 5 | 6 | class APIException(Exception): 7 | """ 8 | Base class for all API exceptions. 9 | """ 10 | pass 11 | 12 | 13 | class AccessException(APIException): 14 | """ 15 | You are attempting to query an object that you have no permission to query. (E.g.: private user, 16 | hidden screenshot, etc.) 17 | """ 18 | pass 19 | 20 | 21 | class APIUserError(APIException): 22 | """ 23 | An API error caused by a user error, like wrong data or just empty results for a query. 24 | """ 25 | pass 26 | 27 | 28 | class UserNotFoundError(APIUserError): 29 | """ 30 | The specified user was not found on the Steam Community. (Bad vanity URL? Non-existent ID?) 31 | """ 32 | pass 33 | 34 | 35 | class APIError(APIException): 36 | """ 37 | An API error signifies a problem with the server, a temporary issue or some other easily-repairable 38 | problem. 39 | """ 40 | pass 41 | 42 | 43 | class APIFailure(APIException): 44 | """ 45 | An API failure signifies a problem with your request (e.g.: invalid API), a problem with your data, 46 | or any error that resulted from improper use. 47 | """ 48 | pass 49 | 50 | 51 | class APIBadCall(APIFailure): 52 | """ 53 | Your API call doesn't match the API's specification. Check your arguments, service name, command & 54 | version. 55 | """ 56 | pass 57 | 58 | 59 | class APINotFound(APIFailure): 60 | """ 61 | The API you tried to call does not exist. (404) 62 | """ 63 | pass 64 | 65 | 66 | class APIUnauthorized(APIFailure): 67 | """ 68 | The API you've attempted to call either requires a key, or your key has insufficient permissions. 69 | If you're requesting user details, make sure their privacy level permits you to do so, or that you've 70 | properly authorised said user. (401) 71 | """ 72 | pass 73 | 74 | 75 | class APIKeyRequired(APIFailure): 76 | """ 77 | This API requires an API key to call and does not support anonymous requests. 78 | """ 79 | pass 80 | 81 | 82 | class APIPrivate(APIFailure): 83 | """ 84 | The API you're trying to call requires a privileged API key. Your existing key is not allowed to call this. 85 | """ 86 | 87 | 88 | class APIConfigurationError(APIFailure): 89 | """ 90 | There's either no APIConnection defined, or the parameters given to "APIConnection" or "APIInterface" are 91 | invalid. 92 | """ 93 | pass 94 | 95 | 96 | def check(response): 97 | """ 98 | :type response: requests.Response 99 | """ 100 | if response.status_code // 100 == 4: 101 | if response.status_code == 404: 102 | raise APINotFound( 103 | "The function or service you tried to call does not exist.") 104 | elif response.status_code == 401: 105 | raise APIUnauthorized("This API is not accessible to you.") 106 | elif response.status_code == 403: 107 | if '?key=' in response.request.url or '&key=' in response.request.url: 108 | raise APIPrivate( 109 | "You have no permission to use this API, or your key may be invalid.") 110 | else: 111 | raise APIKeyRequired("This API requires a key to call.") 112 | elif response.status_code == 400: 113 | raise APIBadCall( 114 | "The parameters you sent didn't match this API's requirements.") 115 | else: 116 | raise APIFailure( 117 | "Something is wrong with your configuration, parameters or environment.") 118 | elif response.status_code // 100 == 5: 119 | raise APIError("The API server has encountered an unknown error.") 120 | else: 121 | return 122 | -------------------------------------------------------------------------------- /steamapi/store.py: -------------------------------------------------------------------------------- 1 | __author__ = 'andrew' 2 | 3 | from .core import APIConnection 4 | 5 | import uuid 6 | 7 | 8 | class SteamIngameStore(object): 9 | def __init__(self, appid, debug=False): 10 | self.appid = appid 11 | self.interface = 'ISteamMicroTxnSandbox' if debug else 'ISteamMicroTxn' 12 | 13 | def get_user_microtxh_info(self, steamid): 14 | return APIConnection().call(self.interface, 'GetUserInfo', 15 | 'v1', steamid=steamid, appid=self.appid) 16 | 17 | def init_purchase(self, steamid, itemid, amount, itemcount=1, language='en', currency='USD', qty=1, 18 | description='Some description'): 19 | params = { 20 | 'steamid': steamid, 21 | 'itemid[0]': itemid, 22 | 'amount[0]': amount, 23 | 'appid': self.appid, 24 | 'orderid': uuid.uuid1().int >> 64, 25 | 'itemcount': itemcount, 26 | 'language': language, 27 | 'currency': currency, 28 | 'qty[0]': qty, 29 | 'description[0]': description, 30 | } 31 | return APIConnection().call(self.interface, 'InitTxn', 32 | 'v3', method='POST', **params) 33 | 34 | def query_txh(self, orderid): 35 | return APIConnection().call(self.interface, 'QueryTxn', 36 | 'v1', appid=self.appid, orderid=orderid) 37 | 38 | def refund_txh(self, orderid): 39 | return APIConnection().call(self.interface, 'RefundTxn', 'v1', 40 | method='POST', appid=self.appid, orderid=orderid) 41 | 42 | def finalize_txh(self, orderid): 43 | return APIConnection().call(self.interface, 'FinalizeTxn', 'v1', method='POST', appid=self.appid, 44 | orderid=orderid) 45 | -------------------------------------------------------------------------------- /steamapi/user.py: -------------------------------------------------------------------------------- 1 | __author__ = 'SmileyBarry' 2 | 3 | from .core import APIConnection, SteamObject, chunker 4 | 5 | from .app import SteamApp 6 | from .decorators import cached_property, INFINITE, MINUTE, HOUR 7 | from .errors import * 8 | 9 | import datetime 10 | import itertools 11 | 12 | 13 | class SteamUserBadge(SteamObject): 14 | def __init__(self, badge_id, level, completion_time, 15 | xp, scarcity, appid=None): 16 | """ 17 | Create a new instance of a Steam user badge. You usually shouldn't initialise this object, 18 | but instead receive it from properties like "SteamUser.badges". 19 | 20 | :param badge_id: The badge's ID. Not a unique instance ID, but one that helps to identify it 21 | out of a list of user badges. Appears as `badgeid` in the API specification. 22 | :type badge_id: int 23 | :param level: The badge's current level. 24 | :type level: int 25 | :param completion_time: The exact moment when this badge was unlocked. Can either be a 26 | datetime.datetime object or a Unix timestamp. 27 | :type completion_time: int or datetime.datetime 28 | :param xp: This badge's current experience value. 29 | :type xp: int 30 | :param scarcity: How rare this badge is. Expressed as a count of how many people have it. 31 | :type scarcity: int 32 | :param appid: This badge's associated app ID. 33 | :type appid: int 34 | """ 35 | self._badge_id = badge_id 36 | self._level = level 37 | if isinstance(completion_time, datetime.datetime): 38 | self._completion_time = completion_time 39 | else: 40 | self._completion_time = datetime.datetime.fromtimestamp( 41 | completion_time) 42 | self._xp = xp 43 | self._scarcity = scarcity 44 | self._appid = appid 45 | if self._appid is not None: 46 | self._id = self._appid 47 | else: 48 | self._id = self._badge_id 49 | 50 | @property 51 | def badge_id(self): 52 | return self._badge_id 53 | 54 | @property 55 | def level(self): 56 | return self._level 57 | 58 | @property 59 | def xp(self): 60 | return self._xp 61 | 62 | @property 63 | def scarcity(self): 64 | return self._scarcity 65 | 66 | @property 67 | def appid(self): 68 | return self._appid 69 | 70 | @property 71 | def completion_time(self): 72 | return self._completion_time 73 | 74 | def __repr__(self): 75 | return '<{clsname} {id} ({xp} XP)>'.format(clsname=self.__class__.__name__, 76 | id=self._id, 77 | xp=self._xp) 78 | 79 | def __hash__(self): 80 | # Don't just use the ID so ID collision between different types of 81 | # objects wouldn't cause a match. 82 | return hash((self._appid, self.id)) 83 | 84 | 85 | class SteamGroup(SteamObject): 86 | def __init__(self, guid): 87 | self._id = guid 88 | 89 | def __hash__(self): 90 | # Don't just use the ID so ID collision between different types of 91 | # objects wouldn't cause a match. 92 | return hash(('group', self.id)) 93 | 94 | @property 95 | def guid(self): 96 | return self._id 97 | 98 | 99 | class SteamUser(SteamObject): 100 | PLAYER_SUMMARIES_BATCH_SIZE = 350 101 | 102 | # OVERRIDES 103 | def __init__(self, userid=None, userurl=None, accountid=None): 104 | """ 105 | Create a new instance of a Steam user. Use this object to retrieve details about 106 | that user. 107 | 108 | :param userid: The user's 64-bit SteamID. (Optional, unless steam_userurl isn't specified) 109 | :type userid: int 110 | :param userurl: The user's vanity URL-ending name. (Required if "steam_userid" isn't specified, 111 | unused otherwise) 112 | :type userurl: str 113 | :raise: ValueError on improper usage. 114 | """ 115 | if userid is None and userurl is None and accountid is None: 116 | raise ValueError("One of the arguments must be supplied.") 117 | 118 | if userurl is not None: 119 | if '/' in userurl: 120 | # This is a full URL. It's not valid. 121 | raise ValueError( 122 | "\"userurl\" must be the *ending* of a vanity URL, not the entire URL!") 123 | response = APIConnection().call( 124 | "ISteamUser", "ResolveVanityURL", "v0001", vanityurl=userurl) 125 | if response.success != 1: 126 | raise UserNotFoundError("User not found.") 127 | userid = response.steamid 128 | 129 | if accountid is not None: 130 | userid = self._convert_accountid_to_steamid(accountid) 131 | 132 | if userid is not None: 133 | self._id = int(userid) 134 | 135 | def __eq__(self, other): 136 | if isinstance(other, SteamUser): 137 | if self.steamid == other.steamid: 138 | return True 139 | else: 140 | return False 141 | else: 142 | return super(SteamUser, self).__eq__(other) 143 | 144 | def __str__(self): 145 | return self.name 146 | 147 | def __hash__(self): 148 | # Don't just use the ID so ID collision between different types of 149 | # objects wouldn't cause a match. 150 | return hash(('user', self.id)) 151 | 152 | # PRIVATE UTILITIES 153 | @staticmethod 154 | def _convert_accountid_to_steamid(accountid): 155 | if accountid % 2 == 0: 156 | y = 0 157 | z = accountid / 2 158 | else: 159 | y = 1 160 | z = (accountid - 1) / 2 161 | return "7656119%d" % (z * 2 + 7960265728 + y) 162 | 163 | @staticmethod 164 | def _convert_games_list(raw_list, associated_userid=None): 165 | """ 166 | Convert a raw, APIResponse-formatted list of games into full SteamApp objects. 167 | :type raw_list: list of APIResponse 168 | :rtype: list of SteamApp 169 | """ 170 | games_list = [] 171 | for game in raw_list: 172 | game_obj = SteamApp.from_api_response(game, associated_userid) 173 | if 'playtime_2weeks' in game: 174 | game_obj.playtime_2weeks = game.playtime_2weeks 175 | if 'playtime_forever' in game: 176 | game_obj.playtime_forever = game.playtime_forever 177 | if 'img_logo_url' in game: 178 | game_obj.img_logo_url = game.img_logo_url 179 | if 'img_icon_url' in game: 180 | game_obj.img_icon_url = game.img_icon_url 181 | games_list += [game_obj] 182 | return games_list 183 | 184 | @cached_property(ttl=2 * HOUR) 185 | def _summary(self): 186 | """ 187 | :rtype: APIResponse 188 | """ 189 | return APIConnection().call("ISteamUser", "GetPlayerSummaries", 190 | "v0002", steamids=self.steamid).players[0] 191 | 192 | @cached_property(ttl=INFINITE) 193 | def _bans(self): 194 | """ 195 | :rtype: APIResponse 196 | """ 197 | return APIConnection().call("ISteamUser", "GetPlayerBans", 198 | "v1", steamids=self.steamid).players[0] 199 | 200 | @cached_property(ttl=30 * MINUTE) 201 | def _badges(self): 202 | """ 203 | :rtype: APIResponse 204 | """ 205 | return APIConnection().call("IPlayerService", "GetBadges", "v1", steamid=self.steamid) 206 | 207 | # PUBLIC ATTRIBUTES 208 | @property 209 | def steamid(self): 210 | """ 211 | :rtype: int 212 | """ 213 | return self._id 214 | 215 | @cached_property(ttl=INFINITE) 216 | def name(self): 217 | """ 218 | :rtype: str 219 | """ 220 | return self._summary.personaname 221 | 222 | @cached_property(ttl=INFINITE) 223 | def real_name(self): 224 | """ 225 | :rtype: str 226 | """ 227 | if "realname" in self._summary: 228 | return self._summary.realname 229 | else: 230 | return None 231 | 232 | @cached_property(ttl=INFINITE) 233 | def country_code(self): 234 | """ 235 | :rtype: str or NoneType 236 | """ 237 | return getattr(self._summary, 'loccountrycode', None) 238 | 239 | @cached_property(ttl=10 * MINUTE) 240 | def currently_playing(self): 241 | """ 242 | :rtype: SteamApp 243 | """ 244 | if "gameid" in self._summary: 245 | if 'gameextrainfo' in self._summary: 246 | game_name = self._summary.gameextrainfo 247 | else: 248 | game_name = None 249 | game = SteamApp(self._summary.gameid, game_name) 250 | owner = APIConnection().call("IPlayerService", "IsPlayingSharedGame", "v0001", 251 | steamid=self._id, 252 | appid_playing=game.appid) 253 | if owner.lender_steamid != 0: 254 | game._owner = owner.lender_steamid 255 | return game 256 | else: 257 | return None 258 | 259 | @property # Already cached by "_summary". 260 | def privacy(self): 261 | """ 262 | :rtype: int or CommunityVisibilityState 263 | """ 264 | # The Web API is a public-facing interface, so it's very unlikely that it will 265 | # ever change drastically. (More values could be added, but existing ones wouldn't 266 | # be changed.) 267 | return self._summary.communityvisibilitystate 268 | 269 | @property # Already cached by "_summary". 270 | def last_logoff(self): 271 | """ 272 | :rtype: datetime 273 | """ 274 | return datetime.datetime.fromtimestamp(self._summary.lastlogoff) 275 | 276 | @cached_property(ttl=INFINITE) # Already cached, but never changes. 277 | def time_created(self): 278 | """ 279 | :rtype: datetime 280 | """ 281 | return datetime.datetime.fromtimestamp(self._summary.timecreated) 282 | 283 | @cached_property(ttl=INFINITE) # Already cached, but unlikely to change. 284 | def profile_url(self): 285 | """ 286 | :rtype: str 287 | """ 288 | return self._summary.profileurl 289 | 290 | @property # Already cached by "_summary". 291 | def avatar(self): 292 | """ 293 | :rtype: str 294 | """ 295 | return self._summary.avatar 296 | 297 | @property # Already cached by "_summary". 298 | def avatar_medium(self): 299 | """ 300 | :rtype: str 301 | """ 302 | return self._summary.avatarmedium 303 | 304 | @property # Already cached by "_summary". 305 | def avatar_full(self): 306 | """ 307 | :rtype: str 308 | """ 309 | return self._summary.avatarfull 310 | 311 | @property # Already cached by "_summary". 312 | def state(self): 313 | """ 314 | :rtype: int or OnlineState 315 | """ 316 | return self._summary.personastate 317 | 318 | @cached_property(ttl=1 * HOUR) 319 | def groups(self): 320 | """ 321 | :rtype: list of SteamGroup 322 | """ 323 | response = APIConnection().call( 324 | "ISteamUser", "GetUserGroupList", "v1", steamid=self.steamid) 325 | group_list = [] 326 | for group in response.groups: 327 | group_obj = SteamGroup(group.gid) 328 | group_list += [group_obj] 329 | return group_list 330 | 331 | @cached_property(ttl=1 * HOUR) 332 | def group(self): 333 | """ 334 | :rtype: SteamGroup 335 | """ 336 | return SteamGroup(self._summary.primaryclanid) 337 | 338 | @cached_property(ttl=1 * HOUR) 339 | def friends(self): 340 | """ 341 | :rtype: list of SteamUser 342 | """ 343 | import time 344 | 345 | response = APIConnection().call("ISteamUser", "GetFriendList", "v0001", steamid=self.steamid, 346 | relationship="friend") 347 | friends_list = [] 348 | for friend in response.friendslist.friends: 349 | friend_obj = SteamUser(friend.steamid) 350 | friend_obj.friend_since = friend.friend_since 351 | friend_obj._cache = {} 352 | friends_list += [friend_obj] 353 | 354 | # Fetching some details, like name, could take some time. 355 | # So, do a few combined queries for all users. 356 | if APIConnection().precache is True: 357 | # APIConnection() accepts lists of strings as argument values. 358 | id_player_map = {str(friend.steamid): friend for friend in friends_list} 359 | ids = list(id_player_map.keys()) 360 | 361 | player_details = list(itertools.chain.from_iterable( 362 | APIConnection().call("ISteamUser", 363 | "GetPlayerSummaries", 364 | "v0002", 365 | steamids=id_batch).players 366 | for id_batch in chunker(ids, self.PLAYER_SUMMARIES_BATCH_SIZE) 367 | )) 368 | 369 | now = time.time() 370 | for player_summary in player_details: 371 | # Fill in the cache with this info. 372 | id_player_map[player_summary.steamid]._cache["_summary"] = ( 373 | player_summary, now) 374 | return friends_list 375 | 376 | @property # Already cached by "_badges". 377 | def level(self): 378 | """ 379 | :rtype: int 380 | """ 381 | return self._badges.player_level 382 | 383 | @property # Already cached by "_badges". 384 | def badges(self): 385 | """ 386 | :rtype: list of SteamUserBadge 387 | """ 388 | badge_list = [] 389 | for badge in self._badges.badges: 390 | badge_list += [SteamUserBadge(badge.badgeid, 391 | badge.level, 392 | badge.completion_time, 393 | badge.xp, 394 | badge.scarcity, 395 | getattr(badge, 'appid', None))] 396 | return badge_list 397 | 398 | @property # Already cached by "_badges". 399 | def xp(self): 400 | """ 401 | :rtype: int 402 | """ 403 | return self._badges.player_xp 404 | 405 | @cached_property(ttl=INFINITE) 406 | def recently_played(self): 407 | """ 408 | :rtype: list of SteamApp 409 | """ 410 | response = APIConnection().call( 411 | "IPlayerService", "GetRecentlyPlayedGames", "v1", steamid=self.steamid) 412 | if 'total_count' not in response: 413 | # Private profiles will cause a special response, where the API doesn't tell us if there are 414 | # any results *at all*. We just get a blank JSON document. 415 | raise AccessException() 416 | if response.total_count == 0: 417 | return [] 418 | return self._convert_games_list(response.games, self._id) 419 | 420 | @cached_property(ttl=INFINITE) 421 | def games(self): 422 | """ 423 | :rtype: list of SteamApp 424 | """ 425 | response = APIConnection().call("IPlayerService", 426 | "GetOwnedGames", 427 | "v1", 428 | steamid=self.steamid, 429 | include_appinfo=True, 430 | include_played_free_games=True) 431 | if 'game_count' not in response: 432 | # Private profiles will cause a special response, where the API doesn't tell us if there are 433 | # any results *at all*. We just get a blank JSON document. 434 | raise AccessException() 435 | if response.game_count == 0: 436 | return [] 437 | return self._convert_games_list(response.games, self._id) 438 | 439 | @cached_property(ttl=INFINITE) 440 | def owned_games(self): 441 | """ 442 | :rtype: list of SteamApp 443 | """ 444 | response = APIConnection().call("IPlayerService", 445 | "GetOwnedGames", 446 | "v1", 447 | steamid=self.steamid, 448 | include_appinfo=True, 449 | include_played_free_games=False) 450 | if 'game_count' not in response: 451 | # Private profiles will cause a special response, where the API doesn't tell us if there are 452 | # any results *at all*. We just get a blank JSON document. 453 | raise AccessException() 454 | if response.game_count == 0: 455 | return [] 456 | return self._convert_games_list(response.games, self._id) 457 | 458 | @cached_property(ttl=INFINITE) 459 | def is_vac_banned(self): 460 | """ 461 | :rtype: bool 462 | """ 463 | return self._bans.VACBanned 464 | 465 | @cached_property(ttl=INFINITE) 466 | def is_community_banned(self): 467 | """ 468 | :rtype: bool 469 | """ 470 | return self._bans.CommunityBanned 471 | 472 | @cached_property(ttl=INFINITE) 473 | def number_of_vac_bans(self): 474 | """ 475 | :rtype: int 476 | """ 477 | return self._bans.NumberOfVACBans 478 | 479 | @cached_property(ttl=INFINITE) 480 | def days_since_last_ban(self): 481 | """ 482 | :rtype: int 483 | """ 484 | return self._bans.DaysSinceLastBan 485 | 486 | @cached_property(ttl=INFINITE) 487 | def number_of_game_bans(self): 488 | """ 489 | :rtype: int 490 | """ 491 | return self._bans.NumberOfGameBans 492 | 493 | @cached_property(ttl=INFINITE) 494 | def economy_ban(self): 495 | """ 496 | :rtype: str 497 | """ 498 | return self._bans.EconomyBan 499 | 500 | @cached_property(ttl=INFINITE) 501 | def is_game_banned(self): 502 | """ 503 | :rtype: bool 504 | """ 505 | return self._bans.NumberOfGameBans != 0 506 | --------------------------------------------------------------------------------