├── .coveragerc ├── .github ├── CONTRIBUTING.md └── ISSUE_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTORS.md ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── api.rst ├── conf.py ├── index.rst ├── make.bat ├── master_server.rst ├── rcon.rst ├── source.rst └── steamid.rst ├── pylintrc ├── setup.py ├── tests ├── conftest.py ├── pylintrc ├── test_api.py ├── test_master_server.py ├── test_message.py ├── test_rcon.py ├── test_server.py ├── test_source.py ├── test_steamid.py └── test_util.py ├── tox.ini └── valve ├── __init__.py ├── rcon.py ├── source ├── __init__.py ├── a2s.py ├── master_server.py ├── messages.py └── util.py ├── steam ├── __init__.py ├── api │ ├── __init__.py │ └── interface.py ├── client.py └── id.py ├── testing.py └── vdf.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = valve/ 4 | 5 | [report] 6 | exclude_lines = 7 | pragma: no cover 8 | if __name__ = .__main__.: 9 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Python-valve ##### 2 | 3 | Thank you for wanting to contribute to Python-valve. Whether you're 4 | reporting an issue, submitting a pull request or anything else, please 5 | be aware of this project's [Code of Conduct](../CODE_OF_CONDUCT.md). 6 | To summarise: *be polite, be respectful, be empathetic*. 7 | 8 | Additionally, it is expected that English is used in all issues, pull 9 | requests, code, commit messages, etc.. If English is not your first 10 | language, your best effort attempt will be appreciated and treated with 11 | the necessary patience. 12 | 13 | Finally, whether it's a bug report, pull request or a feature requests. 14 | Please check to make sure that it is not a duplicate of an existing 15 | discussion. If it is a duplicate, add to the existing discussion instead. 16 | 17 | 18 | ## Bug Reports #### 19 | 20 | When reporting issues with Python-valve, please follow the template that 21 | is provided to the best of your ability. Sometimes the scope of the issue 22 | means that the template isn't entirely relevant. In these cases it's cool 23 | to ignore the irrelevant parts. :+1: 24 | 25 | 26 | ## Pull Requests #### 27 | 28 | Always feel free to submit pull requests. When doing so, please include, 29 | at minimum, a short description of what the pull request is for. If the 30 | pull request addresses a specific issue, please refer back to that issue. 31 | However, entirely speculative pull requests are also appreciated. 32 | 33 | For more complex pull requests, please be more elaborate in your 34 | description. 35 | 36 | A few criteria must be met before a pull request will be accepted: 37 | 38 | - When adding **new code**, it *must* have **accompanying tests**. It 39 | *must* also **pass all linting checks**. 40 | - Ideally **all tests must pass**, on all platforms. It's okay to modify 41 | tests. It *might* be okay to skip tests or mark them as failing. If in 42 | doubt, mention it in the pull request description or comments. 43 | - If intentionally making non-backwards compatible, **breaking changes**, 44 | you **must include a justification**. Ideally, please also demonstrate 45 | that you've thought through the implications. 46 | - If modifying or adding to the public API, the corresponding 47 | **documentation must be updated**. 48 | 49 | Everyone deserves credit for their work. Therefore, in addition 50 | to the above requirements, you may wish to add yourself to the formal 51 | [list of contributors](../CONTRIBUTORS.md), with a very brief description 52 | of your contribution(s). This is entirely optional though. 53 | 54 | 55 | ## Ideas and Feature Requests #### 56 | 57 | It's completely fine to open an issue for ideas and feature requests. 58 | When creating such issues, please be as descriptive as possible and 59 | include a concrete use case. 60 | 61 | 62 | ## If in doubt ... #### 63 | 64 | [Open an issue and ask](https://github.com/serverstf/python-valve/issues/new)! 65 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Please describe your issue in detail. Please also include any relevant 2 | stack traces or other error messages. Ideally, if possible, provide a 3 | small snippet that recreates the problem. Finally, fill in the applicable 4 | fields below which describe the environment you witnessed the issue in. 5 | 6 | - **Python-valve Version(s)**: ... 7 | - **Python Version(s)**: ... 8 | - **Operating System(s)/Platform(s)**: ... 9 | - **Game Server(s) and Version(s)**: ... 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | .tox/ 3 | _build/ 4 | build/ 5 | dist/ 6 | htmlcov/ 7 | 8 | .coverage 9 | 10 | *.pyc 11 | *.egg* 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.4" 5 | - "3.5" 6 | - "3.6" 7 | - "3.7-dev" 8 | - "pypy" 9 | - "pypy3" 10 | install: 11 | - pip install -e .[development,test,docs] 12 | - pip install coveralls 13 | script: 14 | - pylint -ry valve/ || true 15 | - py.test -ra -v tests/ --cov valve/ 16 | - sphinx-build -vwT -b html docs/ _build/html 17 | after_success: 18 | - ls -la 19 | - coveralls 20 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct ##### 2 | 3 | 4 | ## Our Pledge #### 5 | 6 | In the interest of fostering an open and welcoming environment, we as 7 | contributors and maintainers pledge to making participation in our project 8 | and our community a harassment-free experience for everyone, regardless of 9 | age, body size, disability, ethnicity, gender identity and expression, level 10 | of experience, nationality, personal appearance, race, religion, sexual 11 | identity and orientation and so on. 12 | 13 | 14 | ## Our Standards #### 15 | 16 | Examples of behaviour that contributes to creating a positive 17 | environment include: 18 | 19 | * Using welcoming and inclusive language 20 | * Being respectful of differing viewpoints and experiences 21 | * Gracefully accepting constructive criticism 22 | * Focusing on what is best for the community 23 | * Showing empathy towards other community members 24 | 25 | 26 | ## Our Responsibilities #### 27 | 28 | Project maintainers are responsible for clarifying the standards of 29 | acceptable behaviour and are expected to take appropriate and fair 30 | corrective action in response to any instances of unacceptable behaviour. 31 | 32 | 33 | ## Enforcement #### 34 | 35 | Instances of abusive, harassing, or otherwise unacceptable behaviour may be 36 | reported by contacting [the maintainers][maintainers]. All complaints will 37 | be reviewed and investigated and will result in a response that is deemed 38 | necessary and appropriate to the circumstances. All reported incidents 39 | will be treated with strict confidentiality. 40 | 41 | 42 | ## Attribution #### 43 | 44 | This Code of Conduct is adapted from the 45 | [Contributor Covenant, version 1.4][original]. 46 | 47 | [maintainers]: https://github.com/Holiverh 48 | [original]: https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 49 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Python-valve Contributors #### 2 | 3 | - **[Oliver Ainsworth](https://github.com/Holiverh)** 4 | - Original author and maintainer. 5 | 6 | **[Unabridged list of contributors]( 7 | https://github.com/serverstf/python-valve/graphs/contributors)**. 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2017 Oliver Ainsworth 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | 21 | 22 | TRADEMARKS 23 | ---------- 24 | Valve, the Valve logo, Half-Life, the Half-Life logo, the Lambda logo, 25 | Steam, the Steam logo, Team Fortress, the Team Fortress logo, 26 | Opposing Force, Day of Defeat, the Day of Defeat logo, Counter-Strike, 27 | the Counter-Strike logo, Source, the Source logo, Counter-Strike: 28 | Condition Zero, Portal, the Portal logo, Dota, the Dota 2 logo, and 29 | Defense of the Ancients are trademarks and/or registered trademarks of 30 | Valve Corporation. 31 | 32 | Any reference to these are purely for the purpose of identification. 33 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | recursive-exclude tests * 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |PyPI| |PyPIPythonVersions| |Travis| |Coveralls| 2 | 3 | Python-valve 4 | ============ 5 | 6 | Python-valve is a Python library which intends to provide an all-in-one 7 | interface to various Valve products and services, including: 8 | 9 | - Source servers 10 | 11 | - A2S server queries 12 | - RCON 13 | 14 | - Source master server 15 | - Steam web API 16 | - Local Steam Clients 17 | - Valve Data Format/KeyValues (.vdf) 18 | 19 | To get started, install Python-valve with pip: 20 | ``pip install python-valve``. 21 | 22 | UNMAINTAINED 23 | ------------ 24 | 25 | This project is no longer actively maintained. The server query part 26 | has been rewritten and is available as 27 | `python-a2s `__ to not break 28 | compatibility with projects using the old API. 29 | 30 | RCON Example 31 | ------------ 32 | 33 | In this example we connect to a Source server's remote console and issue 34 | a simple ``echo`` command to it. 35 | 36 | .. code:: python 37 | 38 | import valve.rcon 39 | 40 | server_address = ("...", 27015) 41 | password = "top_secret" 42 | 43 | with valve.rcon.RCON(server_address, password) as rcon: 44 | print(rcon("echo Hello, world!")) 45 | 46 | 47 | Server Query Example 48 | -------------------- 49 | 50 | In this example we demonstrate the Source master server and A2S query 51 | implementations by listing all Team Fortress 2 servers in Europe and 52 | Asia running the map ``ctf_2fort``, along with the players on each server 53 | sorted by their score. 54 | 55 | .. code:: python 56 | 57 | import valve.source 58 | import valve.source.a2s 59 | import valve.source.master_server 60 | 61 | with valve.source.master_server.MasterServerQuerier() as msq: 62 | try: 63 | for address in msq.find(region=[u"eu", u"as"], 64 | gamedir=u"tf", 65 | map=u"ctf_2fort"): 66 | try: 67 | with valve.source.a2s.ServerQuerier(address) as server: 68 | info = server.info() 69 | players = server.players() 70 | 71 | except valve.source.NoResponseError: 72 | print("Server {}:{} timed out!".format(*address)) 73 | continue 74 | 75 | print("{player_count}/{max_players} {server_name}".format(**info)) 76 | for player in sorted(players["players"], 77 | key=lambda p: p["score"], reverse=True): 78 | print("{score} {name}".format(**player)) 79 | 80 | except valve.source.NoResponseError: 81 | print("Master server request timed out!") 82 | 83 | 84 | 85 | Versioning 86 | ---------- 87 | 88 | Python-valve uses `Semantic Versioning `__. At this 89 | time, Python-valve is yet to reach its 1.0 release. Hence, every minor 90 | version should be considered to potentially contain breaking changes. 91 | Hence, when specifying Python-valve as a requirement, either in 92 | ``setup.py`` or ``requirements.txt``, it's advised to to pin the 93 | specific minor version. E.g. ``python-valve==0.2.0``. 94 | 95 | 96 | Testing 97 | ------- 98 | 99 | Python-valve uses `Pytest `__ for running its 100 | test suite. Unit test coverage is always improving. There are also 101 | functional tests included which run against real Source servers. 102 | 103 | If working on Python-valve use the following to install the test 104 | dependencies and run the tests: 105 | 106 | .. code:: shell 107 | 108 | pip install -e .[test] 109 | py.test tests/ --cov valve/ 110 | 111 | 112 | Documentation 113 | ------------- 114 | 115 | Documentation is written using `Sphinx `__ 116 | and is hosted on `Read the Docs `__. 117 | 118 | If working on Python-valve use the following to install the documentation 119 | dependencies, build the docs and then open them in a browser. 120 | 121 | .. code:: shell 122 | 123 | pip install -e .[docs] 124 | (cd docs/ && make html) 125 | xdg-open docs/_build/html/index.html 126 | 127 | 128 | Python 2 129 | -------- 130 | 131 | Python-valve supports Python 2.7! However, it's important to bear in 132 | mind that Python 2.7 will not be maintained past 2020. Python-valve 133 | *may* drop support for Python 2.7 in a future major release before 2020 134 | in order to make use of new, non-backwards compatible Python 3 features. 135 | 136 | It's strongly encouraged that new Python-valve projects use Python 3. 137 | 138 | 139 | Trademarks 140 | ---------- 141 | 142 | Valve, the Valve logo, Half-Life, the Half-Life logo, the Lambda logo, 143 | Steam, the Steam logo, Team Fortress, the Team Fortress logo, Opposing 144 | Force, Day of Defeat, the Day of Defeat logo, Counter-Strike, the 145 | Counter-Strike logo, Source, the Source logo, Counter-Strike: Condition 146 | Zero, Portal, the Portal logo, Dota, the Dota 2 logo, and Defense of the 147 | Ancients are trademarks and/or registered trademarks of Valve 148 | Corporation. 149 | 150 | Any reference to these are purely for the purpose of identification. 151 | Valve Corporation is not affiliated with Python-valve or any 152 | Python-valve contributors in any way. 153 | 154 | .. |PyPI| image:: https://img.shields.io/pypi/v/python-valve.svg?style=flat-square 155 | :target: https://pypi.python.org/pypi/python-valve 156 | .. |PyPIPythonVersions| image:: https://img.shields.io/pypi/pyversions/python-valve.svg?style=flat-square 157 | :target: https://pypi.python.org/pypi/python-valve 158 | .. |Travis| image:: https://img.shields.io/travis/serverstf/python-valve.svg?style=flat-square 159 | :target: https://travis-ci.org/serverstf/python-valve 160 | .. |Coveralls| image:: https://img.shields.io/coveralls/serverstf/python-valve.svg?style=flat-square 161 | :target: https://coveralls.io/github/serverstf/python-valve 162 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/python-valve.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/python-valve.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/python-valve" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/python-valve" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | Steam Web API 2 | ************* 3 | 4 | The Steam Web API provides a mechanism to use Steam services over an HTTP. 5 | The API is divided up into "interfaces" with each interface having a number of 6 | methods that can be performed on it. Python-valve provides a thin wrapper on 7 | top of these interfaces as well as a higher-level implementation. 8 | 9 | Generally you'll want to use the higher-level interface to the API as it 10 | provides greater abstraction and session management. However the higher-level 11 | API only covers a few core interfaces of the Steam Web API, so it may be 12 | necessary to use the wrapper layer in some circumstances. 13 | 14 | Although an API key is not strictly necessary to use the Steam Web API, it is 15 | advisable to `get an API key `_. Using an 16 | API key allows access to greater functionality. Also, before using the Steam 17 | Web API it is good idea to read the 18 | `Steam Web API Terms of Use `_ and 19 | `Steam Web API Documentation `_. 20 | 21 | 22 | .. module:: valve.steam.api.interface 23 | 24 | 25 | Low-level Wrapper 26 | ================= 27 | 28 | The Steam Web API is self-documenting via the 29 | ``/ISteamWebAPIUtil/GetSupportedAPIList/v1/`` endpoint. This enables 30 | python-valve to build the wrapper entirely automatically, which includes 31 | validating parameters and automatic generation of documentation. 32 | 33 | The entry-point for using the API wrapper is by constructing a :class:`API` 34 | instance. During initialisation a request is issued to the 35 | ``GetSupportedAPIList`` endpoint and the interfaces are constructed. If a Steam 36 | Web API key is specified then a wider selection of interfaces will be available. 37 | Note that this can be a relatively time consuming process as the response 38 | returned by ``GetSupportedAPIList`` can be quite large. This is especially true 39 | when an API key is given as there are more interfaces to generated. 40 | 41 | An instance of each interface is created and bound to the :class:`API` 42 | instance, as it is this :class:`API` instance that will be responsible for 43 | dispatching the HTTP requests. The interfaces are made available via 44 | :meth:`API.__getitem__`. The interface objects have methods which correspond 45 | to those returned by ``GetSupportedAPIList``. 46 | 47 | .. autoclass:: API 48 | :members: 49 | :undoc-members: 50 | :special-members: __init__, __getitem__ 51 | 52 | 53 | Interface Method Version Pinning 54 | -------------------------------- 55 | 56 | It's important to be aware of the fact that API interface methods can have 57 | multiple versions. For example, ``ISteamApps/GetAppList``. This means they may 58 | take different arguments and returned different responses. The default 59 | behaviour of the API wrapper is to always expose the method with the highest 60 | version number. 61 | 62 | This is fine in most cases, however it does pose a potential problem. New 63 | versions of interface methods are likely to break backwards compatability. 64 | Therefore :class:`API` provides a mechanism to manually specify the interface 65 | method versions to use via the ``versions`` argument to :meth:`API.__init__`. 66 | 67 | The if given at all, ``versions`` is expected to be a dictionary of dictionaries 68 | keyed against interface names. The inner dictionaries map method names to 69 | versions. For example: 70 | 71 | .. code:: python 72 | 73 | {"ISteamApps": {"GetAppList": 1}} 74 | 75 | Passsing this into :meth:`API.__init__` would mean version 1 of 76 | ``ISteamApps/GetAppList`` would be used in preference to the default behaviour 77 | of using the highest version -- wich at the time of writing is version 2. 78 | 79 | It is important to pin your interface method versions when your code enters 80 | production or otherwise face the risk of it breaking in the future if and when 81 | Valve updates the Steam Web API. The :meth:`API.pin_versions()` method is 82 | provided to help in determining what versions to pin. How to integrate interface 83 | method version pinning into existing code is an excerise for the reader however. 84 | 85 | 86 | Response Formatters 87 | ------------------- 88 | 89 | .. autofunction:: json_format 90 | 91 | .. autofunction:: etree_format 92 | 93 | .. autofunction:: vdf_format 94 | 95 | 96 | Interfaces 97 | ========== 98 | 99 | These interfaces are automatically wrapped and documented. The availability of 100 | some interfaces is dependant on whether or not an API key is given. It should 101 | also be noted that as the interfaces are generated automatically they do not 102 | respect the naming conventions as detailed in PEP 8. 103 | 104 | .. automodule:: interfaces 105 | :members: 106 | :undoc-members: 107 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # python-valve documentation build configuration file, created by 4 | # sphinx-quickstart on Sat Apr 19 16:34:28 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | import pkg_resources 19 | 20 | import valve.steam.api.interface 21 | 22 | 23 | # If extensions (or modules to document with autodoc) are in another directory, 24 | # add these directories to sys.path here. If the directory is relative to the 25 | # documentation root, use os.path.abspath to make it absolute, like shown here. 26 | #sys.path.insert(0, os.path.abspath('.')) 27 | sys.path.insert(0, os.path.abspath("..")) 28 | 29 | sys.modules["interfaces"] = valve.steam.api.interface.API()._interfaces_module 30 | 31 | # -- General configuration ------------------------------------------------ 32 | 33 | # If your documentation needs a minimal Sphinx version, state it here. 34 | #needs_sphinx = '1.0' 35 | 36 | # Add any Sphinx extension module names here, as strings. They can be 37 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 38 | # ones. 39 | extensions = [ 40 | 'sphinx.ext.autodoc', 41 | 'sphinx.ext.todo', 42 | ] 43 | 44 | # Add any paths that contain templates here, relative to this directory. 45 | templates_path = ['_templates'] 46 | 47 | # The suffix of source filenames. 48 | source_suffix = '.rst' 49 | 50 | # The encoding of source files. 51 | #source_encoding = 'utf-8-sig' 52 | 53 | # The master toctree document. 54 | master_doc = 'index' 55 | 56 | # General information about the project. 57 | project = u'python-valve' 58 | copyright = u'2014-2017, Oliver Ainsworth' 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | # 64 | # The short X.Y version. 65 | version = pkg_resources.get_distribution("python-valve").version 66 | # The full version, including alpha/beta/rc tags. 67 | release = version 68 | 69 | # The language for content autogenerated by Sphinx. Refer to documentation 70 | # for a list of supported languages. 71 | #language = None 72 | 73 | # There are two options for replacing |today|: either, you set today to some 74 | # non-false value, then it is used: 75 | #today = '' 76 | # Else, today_fmt is used as the format for a strftime call. 77 | #today_fmt = '%B %d, %Y' 78 | 79 | # List of patterns, relative to source directory, that match files and 80 | # directories to ignore when looking for source files. 81 | exclude_patterns = ['_build'] 82 | 83 | # The reST default role (used for this markup: `text`) to use for all 84 | # documents. 85 | #default_role = None 86 | 87 | # If true, '()' will be appended to :func: etc. cross-reference text. 88 | #add_function_parentheses = True 89 | 90 | # If true, the current module name will be prepended to all description 91 | # unit titles (such as .. function::). 92 | #add_module_names = True 93 | 94 | # If true, sectionauthor and moduleauthor directives will be shown in the 95 | # output. They are ignored by default. 96 | #show_authors = False 97 | 98 | # The name of the Pygments (syntax highlighting) style to use. 99 | pygments_style = 'sphinx' 100 | 101 | # A list of ignored prefixes for module index sorting. 102 | #modindex_common_prefix = [] 103 | 104 | # If true, keep warnings as "system message" paragraphs in the built documents. 105 | #keep_warnings = False 106 | 107 | 108 | # -- Options for HTML output ---------------------------------------------- 109 | 110 | # The theme to use for HTML and HTML Help pages. See the documentation for 111 | # a list of builtin themes. 112 | html_theme = 'sphinx_rtd_theme' 113 | if os.environ.get('READTHEDOCS') == 'True': 114 | html_theme = 'default' 115 | 116 | # Theme options are theme-specific and customize the look and feel of a theme 117 | # further. For a list of options available for each theme, see the 118 | # documentation. 119 | #html_theme_options = {} 120 | 121 | # Add any paths that contain custom themes here, relative to this directory. 122 | #html_theme_path = [] 123 | 124 | # The name for this set of Sphinx documents. If None, it defaults to 125 | # " v documentation". 126 | #html_title = None 127 | 128 | # A shorter title for the navigation bar. Default is the same as html_title. 129 | #html_short_title = None 130 | 131 | # The name of an image file (relative to this directory) to place at the top 132 | # of the sidebar. 133 | #html_logo = None 134 | 135 | # The name of an image file (within the static path) to use as favicon of the 136 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 137 | # pixels large. 138 | #html_favicon = None 139 | 140 | # Add any paths that contain custom static files (such as style sheets) here, 141 | # relative to this directory. They are copied after the builtin static files, 142 | # so a file named "default.css" will overwrite the builtin "default.css". 143 | html_static_path = ['_static'] 144 | 145 | # Add any extra paths that contain custom files (such as robots.txt or 146 | # .htaccess) here, relative to this directory. These files are copied 147 | # directly to the root of the documentation. 148 | #html_extra_path = [] 149 | 150 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 151 | # using the given strftime format. 152 | #html_last_updated_fmt = '%b %d, %Y' 153 | 154 | # If true, SmartyPants will be used to convert quotes and dashes to 155 | # typographically correct entities. 156 | #html_use_smartypants = True 157 | 158 | # Custom sidebar templates, maps document names to template names. 159 | #html_sidebars = {} 160 | 161 | # Additional templates that should be rendered to pages, maps page names to 162 | # template names. 163 | #html_additional_pages = {} 164 | 165 | # If false, no module index is generated. 166 | #html_domain_indices = True 167 | 168 | # If false, no index is generated. 169 | #html_use_index = True 170 | 171 | # If true, the index is split into individual pages for each letter. 172 | #html_split_index = False 173 | 174 | # If true, links to the reST sources are added to the pages. 175 | #html_show_sourcelink = True 176 | 177 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 178 | #html_show_sphinx = True 179 | 180 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 181 | #html_show_copyright = True 182 | 183 | # If true, an OpenSearch description file will be output, and all pages will 184 | # contain a tag referring to it. The value of this option must be the 185 | # base URL from which the finished HTML is served. 186 | #html_use_opensearch = '' 187 | 188 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 189 | #html_file_suffix = None 190 | 191 | # Output file base name for HTML help builder. 192 | htmlhelp_basename = 'python-valvedoc' 193 | 194 | 195 | # -- Options for LaTeX output --------------------------------------------- 196 | 197 | latex_elements = { 198 | # The paper size ('letterpaper' or 'a4paper'). 199 | #'papersize': 'letterpaper', 200 | 201 | # The font size ('10pt', '11pt' or '12pt'). 202 | #'pointsize': '10pt', 203 | 204 | # Additional stuff for the LaTeX preamble. 205 | #'preamble': '', 206 | } 207 | 208 | # Grouping the document tree into LaTeX files. List of tuples 209 | # (source start file, target name, title, 210 | # author, documentclass [howto, manual, or own class]). 211 | latex_documents = [ 212 | ('index', 'python-valve.tex', u'python-valve Documentation', 213 | u'Oliver Ainsworth', 'manual'), 214 | ] 215 | 216 | # The name of an image file (relative to this directory) to place at the top of 217 | # the title page. 218 | #latex_logo = None 219 | 220 | # For "manual" documents, if this is true, then toplevel headings are parts, 221 | # not chapters. 222 | #latex_use_parts = False 223 | 224 | # If true, show page references after internal links. 225 | #latex_show_pagerefs = False 226 | 227 | # If true, show URL addresses after external links. 228 | #latex_show_urls = False 229 | 230 | # Documents to append as an appendix to all manuals. 231 | #latex_appendices = [] 232 | 233 | # If false, no module index is generated. 234 | #latex_domain_indices = True 235 | 236 | 237 | # -- Options for manual page output --------------------------------------- 238 | 239 | # One entry per manual page. List of tuples 240 | # (source start file, name, description, authors, manual section). 241 | man_pages = [ 242 | ('index', 'python-valve', u'python-valve Documentation', 243 | [u'Oliver Ainsworth'], 1) 244 | ] 245 | 246 | # If true, show URL addresses after external links. 247 | #man_show_urls = False 248 | 249 | 250 | # -- Options for Texinfo output ------------------------------------------- 251 | 252 | # Grouping the document tree into Texinfo files. List of tuples 253 | # (source start file, target name, title, author, 254 | # dir menu entry, description, category) 255 | texinfo_documents = [ 256 | ('index', 'python-valve', u'python-valve Documentation', 257 | u'Oliver Ainsworth', 'python-valve', 'One line description of project.', 258 | 'Miscellaneous'), 259 | ] 260 | 261 | # Documents to append as an appendix to all manuals. 262 | #texinfo_appendices = [] 263 | 264 | # If false, no module index is generated. 265 | #texinfo_domain_indices = True 266 | 267 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 268 | #texinfo_show_urls = 'footnote' 269 | 270 | # If true, do not generate a @detailmenu in the "Top" node's menu. 271 | #texinfo_no_detailmenu = False 272 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to python-valve's documentation! 2 | ======================================== 3 | python-valve is a Python library which aims to provide the ability to 4 | interface with various Valve services and products, including: the Steam 5 | web API, locally installed Steam clients, Source servers and the Source 6 | master server. 7 | 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | source 15 | master_server 16 | steamid 17 | rcon 18 | api 19 | 20 | 21 | Although Python libraries *do* already exist for many aspects which 22 | python-valve aims to cover, many of them are ageing and no long maintained. 23 | python-valve hopes to change that and provide an all-in-one library for 24 | interfacing with Valve products and services that is well tested, well 25 | documented and actively maintained. 26 | 27 | python-valve's functional test suite for its A2S implementation is actively 28 | ran against thousands of servers to ensure that if any subtle changes are made 29 | by Valve that break things they can be quickly picked up and fixed. 30 | 31 | 32 | License 33 | ======= 34 | Copyright (c) 2013-2017 Oliver Ainsworth 35 | 36 | Permission is hereby granted, free of charge, to any person obtaining a copy 37 | of this software and associated documentation files (the "Software"), to deal 38 | in the Software without restriction, including without limitation the rights 39 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 40 | copies of the Software, and to permit persons to whom the Software is 41 | furnished to do so, subject to the following conditions: 42 | 43 | The above copyright notice and this permission notice shall be included in 44 | all copies or substantial portions of the Software. 45 | 46 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 47 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 48 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 49 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 50 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 51 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 52 | THE SOFTWARE. 53 | 54 | 55 | Trademarks 56 | ---------- 57 | Valve, the Valve logo, Half-Life, the Half-Life logo, the Lambda logo, 58 | Steam, the Steam logo, Team Fortress, the Team Fortress logo, 59 | Opposing Force, Day of Defeat, the Day of Defeat logo, Counter-Strike, 60 | the Counter-Strike logo, Source, the Source logo, Counter-Strike: 61 | Condition Zero, Portal, the Portal logo, Dota, the Dota 2 logo, and 62 | Defense of the Ancients are trademarks and/or registered trademarks of 63 | Valve Corporation. 64 | 65 | Any reference to these are purely for the purpose of identification. Valve 66 | Corporation is not affiliated with python-valve in any way. 67 | 68 | 69 | Indices and tables 70 | ================== 71 | 72 | * :ref:`genindex` 73 | * :ref:`modindex` 74 | * :ref:`search` 75 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\python-valve.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\python-valve.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/master_server.rst: -------------------------------------------------------------------------------- 1 | Querying the Source Master Server 2 | ********************************* 3 | When a Source server starts it can optionally add it self to an index of 4 | live servers to enable players to find the server via matchmaking and the 5 | in-game server browsers. It does this by registering it self with the "master 6 | server". The master server is hosted by Valve but the protocol used to 7 | communicate with it is *reasonably* well documented. 8 | 9 | Clients can request a list of server addresses from the master server for 10 | a particular region. Optionally, they can also specify a filtration criteria 11 | to restrict what servers are returned. :mod:`valve.source.master_server` 12 | provides an interface for interacting with the master server. 13 | 14 | .. note:: 15 | Although "master server" is used in a singular context there are in fact 16 | multiple servers. By default 17 | :class:`valve.source.master_server.MasterServerQuerier` will lookup 18 | ``hl2master.steampowered.com`` which, at the time of writing, has three 19 | ``A`` entries. 20 | 21 | .. autoclass:: valve.source.master_server.MasterServerQuerier 22 | :members: 23 | :special-members: 24 | 25 | .. autoclass:: valve.source.master_server.Duplicates 26 | :show-inheritance: 27 | 28 | 29 | Example 30 | ======= 31 | In this example we will list all unique European and Asian Team Fortress 2 32 | servers running the map *ctf_2fort*. 33 | 34 | .. code:: python 35 | 36 | import valve.source.master_server 37 | 38 | with valve.source.master_server.MasterServerQuerier() as msq: 39 | servers = msq.find( 40 | region=["eu", "as"], 41 | duplicates="skip", 42 | gamedir="tf", 43 | map="ctf_2fort", 44 | ) 45 | for host, port in servers: 46 | print "{0}:{1}".format(host, port) 47 | -------------------------------------------------------------------------------- /docs/rcon.rst: -------------------------------------------------------------------------------- 1 | .. module:: valve.rcon 2 | 3 | Source Remote Console (RCON) 4 | **************************** 5 | 6 | Source remote console (or RCON) provides a way for server operators to 7 | administer and interact with their servers remotely in the same manner as 8 | the console provided by :program:`srcds`. The :mod:`valve.rcon` module 9 | provides an implementation of the RCON protocol. 10 | 11 | RCON is a simple, TCP-based request-response protocol with support for 12 | basic authentication. The RCON client initiates a connection to a server 13 | and attempts to authenticate by submitting a password. If authentication 14 | succeeds then the client is free to send further requests. These subsequent 15 | requests are interpreted the same way as if you were to type them into 16 | the :program:`srcds` console. 17 | 18 | .. warning:: 19 | Passwords and console commands are sent in plain text. Tunneling the 20 | connection through a secure channel may be advisable where possible. 21 | 22 | .. note:: 23 | Multiple RCON authentication failures in a row from a single host will 24 | result in the Source server automatically banning that IP, preventing 25 | any subsequent connection attempts. 26 | 27 | 28 | High-level API 29 | ============== 30 | 31 | The :mod:`valve.rcon` module provides a number of ways to interact with 32 | RCON servers. The simplest is the :func:`execute` function which executes 33 | a single command on the server and returns the response as a string. 34 | 35 | In many cases this may be sufficient but it's important to consider that 36 | :func:`execute` will create a new, temporary connection for every command. 37 | If order to reuse a connection the :class:`RCON` class should be used 38 | directly. 39 | 40 | Also note that :func:`execute` only returns Unicode strings which may 41 | prove problematic in some cases. See :ref:`rcon-unicode`. 42 | 43 | .. autofunction:: execute 44 | 45 | 46 | Core API 47 | ======== 48 | 49 | The core API for the RCON implementation is split encapsulated by two 50 | distinct classes: :class:`RCONMessage` and :class:`RCON`. 51 | 52 | 53 | Representing RCON Messages 54 | -------------------------- 55 | 56 | Each RCON message, whether a request or a response, is represented by an 57 | instance of the :class:`RCONMessage` class. Each message has three fields: 58 | the message ID, type and contents or body. The message ID of a request is 59 | reflected back to the client when the server returns a response but is 60 | otherwise unsued by this implementation. The type is one of four constants 61 | (represented by three distinct values) which signifies the semantics of the 62 | message's ID and body. The body it self is an opaque string; its value 63 | depends on the type of message. 64 | 65 | .. autoclass:: RCONMessage 66 | :members: 67 | :exclude-members: Type 68 | 69 | 70 | .. _rcon-unicode: 71 | 72 | Unicode and String Encoding 73 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 74 | 75 | The type of the body field of RCON messages is documented as being a 76 | double null-terminated, ASCII-encoded string. At the Python level though 77 | both Unicode strings and raw byte string interfaces are provided by 78 | :attr:`RCONMessage.text` and :attr:`RCONMessage.body` respectively. 79 | 80 | In Python you are encouraged to deal with text (a.k.a. Unicode strings) 81 | in preference to raw byte strings unless strictly neccessary. However, 82 | it has been reported that under some conditions RCON servers may return 83 | invalid ASCII sequences in the response body. Therefore it is possible 84 | that the textual representation of the body cannot be determined and 85 | attempts to access :attr:`RCONMessage.text` will fail with a 86 | :exc:`UnicodeDecodeError` being raised. 87 | 88 | It appears -- but is not conclusively determined -- that RCON servers in 89 | fact return UTF-8-encoded message bodies, hence why ASCII seems to to work 90 | in most cases. Until this can be categorically proven as the behaviour that 91 | should be expected Python-valve will continue to attempt to process ASCII 92 | strings. 93 | 94 | If you come across :exc:`UnicodeDecodeError` whilst accessing response 95 | bodies you will instead have to make-do and handle the raw byte strings 96 | manually. For example: 97 | 98 | .. code:: python 99 | 100 | response = rcon.execute("command") 101 | response_text = response.body.decode("utf-8") 102 | 103 | If this is undesirable it is also possible to globally set the encoding 104 | used by :class:`RCONMessage` but this *not* particularly encouraged: 105 | 106 | .. code:: python 107 | 108 | import valve.rcon 109 | 110 | valve.rcon.RCONMessage.ENCODING = "utf-8" 111 | 112 | 113 | Creating RCON Connections 114 | ----------------------------- 115 | 116 | .. autoclass:: RCON 117 | :members: 118 | :special-members: __call__, __enter__, __exit__ 119 | 120 | 121 | Example 122 | ^^^^^^^ 123 | 124 | .. code:: python 125 | 126 | import valve.rcon 127 | 128 | address = ("rcon.example.com", 27015) 129 | password = "top-secrect-password" 130 | with valve.rcon.RCON(address, password) as rcon: 131 | response = rcon.execute("echo Hello, world!") 132 | print(response.text) 133 | 134 | 135 | Command-line Client 136 | =================== 137 | 138 | As well as providing means to programatically interact with RCON servers, 139 | the :mod:`valve.rcon` module also provides an interactive, command-line 140 | client. A client shell can be started by calling :func:`shell` or running 141 | the :mod:`valve.rcon` module. 142 | 143 | .. autofunction:: shell 144 | 145 | 146 | Using the RCON Shell 147 | -------------------- 148 | 149 | When :func:`shell` is executed, an interactive RCON shell is created. This 150 | shell reads commands from stdin, passes them to a connected RCON server 151 | then prints the response to stdout in a conventional read-eval-print pattern. 152 | 153 | By default, commands are treated as plain RCON commmands and are passed 154 | directly to the connected server for evaluation. However, commands prefixed 155 | with an exclamation mark are interpreted by the shell it self: 156 | 157 | ``!connect`` 158 | Connect to an RCON server. This command accepts two space-separated 159 | arguments: the address of the server and the corresponding password; 160 | the latter is optional. If the password is not given the user is 161 | prompted for it. 162 | 163 | If the shell is already connected to a server then it will disconnect 164 | first before connecting to the new one. 165 | 166 | ``!disconnect`` 167 | Disconnect from the current RCON server. 168 | 169 | ``!shutdown`` 170 | Shutdown the RCON server. This actually just sends an ``exit`` command 171 | to the server. This must be used instead of ``exit`` as its behaviour 172 | could prove confusing with ``!exit`` otherwise. 173 | 174 | ``!exit`` 175 | Exit the shell. This *does not* shutdown the RCON server. 176 | 177 | Help is available via the ``help`` command. When connected, an optional 178 | argument can be provided which is the RCON command to show help for. 179 | 180 | When connected to a server, command completions are provided via the tab key. 181 | 182 | 183 | Command-line Invocation 184 | ----------------------- 185 | 186 | The :mod:`valve.rcon` module is runnable. When ran with no arguments its the 187 | same as calling :func:`shell` with defaults. As with :func:`shell`, the 188 | address and password can be provided as a part of the invoking command: 189 | 190 | .. code:: bash 191 | 192 | $ python -m valve.rcon 193 | $ python -m valve.rcon rcon.example.com:27015 194 | $ python -m valve.rcon rcon.example.com:27015 --password TOP-SECRET 195 | 196 | .. warning:: 197 | Passing sensitive information via command-line arguments, such as 198 | your RCON password, can be *dangerous*. For example, it can show 199 | up in :program:`ps` output. 200 | 201 | 202 | Executing a Single Command 203 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 204 | 205 | When ran, the module has two modes of execution: the default, which will 206 | spawn an interactive RCON shell and the single command execution mode. 207 | When passed the ``--execute`` argument, :program:`python -m valve.rcon` 208 | will run the given command and exit with a status code of zero upon 209 | completion. The command response is printed to stdout. 210 | 211 | This can be useful for simple scripting of RCON commands outside of a 212 | Python environment, such as in a shell script. 213 | 214 | .. code:: bash 215 | 216 | $ python -m valve.rcon rcon.example.com:27015 \ 217 | --password TOP-SECRET --execute "echo Hello, world!" 218 | 219 | 220 | Usage 221 | ^^^^^ 222 | 223 | .. literalinclude:: ../valve/rcon.py 224 | :pyobject: _USAGE 225 | -------------------------------------------------------------------------------- /docs/source.rst: -------------------------------------------------------------------------------- 1 | Interacting with Source Servers 2 | ******************************* 3 | 4 | .. module:: valve.source.a2s 5 | 6 | Source provides the "A2S" protocol for querying game servers. This protocol 7 | is used by the Steam and in-game server browsers to list information about 8 | servers such as their name, player count and whether or not they're password 9 | protected. :mod:`valve.source.a2s` provides a client implementation of 10 | A2S. 11 | 12 | .. autoclass:: valve.source.a2s.ServerQuerier 13 | :members: 14 | 15 | 16 | Example 17 | ======= 18 | In this example we will query a server, printing out it's name and the number 19 | of players currently conected. Then we'll print out all the players sorted 20 | score-decesending. 21 | 22 | .. code:: python 23 | 24 | import valve.source.a2s 25 | 26 | SERVER_ADDRESS = (..., ...) 27 | 28 | with valve.source.a2s.ServerQuerier(SERVER_ADDRESS) as server: 29 | info = server.info() 30 | players = server.players() 31 | 32 | print("{player_count}/{max_players} {server_name}".format(**info)) 33 | for player in sorted(players["players"], 34 | key=lambda p: p["score"], reverse=True): 35 | print("{score} {name}".format(**player)) 36 | 37 | 38 | Queriers and Exceptions 39 | ======================= 40 | 41 | .. module:: valve.source 42 | 43 | Both :class:`valve.source.a2s.ServerQuerier` and 44 | :class:`valve.source.master_server.MasterServerQuerier` are based on a 45 | common querier interface. They also raise similar exceptions. All of these 46 | live in the :mod:`valve.source` module. 47 | 48 | .. autoclass:: valve.source.BaseQuerier 49 | :members: 50 | 51 | .. autoexception:: valve.source.NoResponseError 52 | 53 | .. autoexception:: valve.source.QuerierClosedError 54 | 55 | 56 | Identifying Server Platforms 57 | ============================ 58 | 59 | .. module:: valve.source.util 60 | 61 | :mod:`valve.source.util` provides a handful of utility classes which are 62 | used when querying Source servers. 63 | 64 | .. automodule:: valve.source.util 65 | :members: 66 | :special-members: 67 | -------------------------------------------------------------------------------- /docs/steamid.rst: -------------------------------------------------------------------------------- 1 | .. module:: valve.steam.id 2 | 3 | SteamIDs 4 | ******** 5 | 6 | SteamID are used in many places within Valve services to identify entities 7 | such as users, groups and game servers. SteamIDs have many different 8 | representations which all need to be handled so the :mod:`valve.steam.id` 9 | module exists to provide an mechanism for representing these IDs in a usable 10 | fashion. 11 | 12 | The :class:`SteamID` Class 13 | ========================== 14 | 15 | Rarely will you ever want to instantiate a :class:`.SteamID` directly. Instead 16 | it is best to use the :meth:`.SteamID.from_community_url` and 17 | :meth:`.SteamID.from_text` class methods for creating new instances. 18 | 19 | .. autoclass:: SteamID 20 | :members: 21 | :special-members: 22 | 23 | 24 | Exceptions 25 | ========== 26 | 27 | .. autoexception:: SteamIDError 28 | :show-inheritance: 29 | 30 | 31 | Useful Constants 32 | ================ 33 | 34 | As well as providing the :class:`.SteamID` class, the :mod:`valve.steam.id` 35 | module also contains numerous constants which relate to the contituent parts 36 | of a SteamID. These constants map to their numeric equivalent. 37 | 38 | 39 | Account Types 40 | ------------- 41 | The following are the various account types that can be encoded into a 42 | SteamID. Many of them are seemingly no longer in use -- at least not in 43 | public facing services -- and you're only likely to come across 44 | :data:`TYPE_INDIVIDUAL`, :data:`TYPE_CLAN` and possibly 45 | :data:`TYPE_GAME_SERVER`. 46 | 47 | .. autodata:: TYPE_INVALID 48 | .. autodata:: TYPE_INDIVIDUAL 49 | .. autodata:: TYPE_MULTISEAT 50 | .. autodata:: TYPE_GAME_SERVER 51 | .. autodata:: TYPE_ANON_GAME_SERVER 52 | .. autodata:: TYPE_PENDING 53 | .. autodata:: TYPE_CONTENT_SERVER 54 | .. autodata:: TYPE_CLAN 55 | .. autodata:: TYPE_CHAT 56 | .. autodata:: TYPE_P2P_SUPER_SEEDER 57 | .. autodata:: TYPE_ANON_USER 58 | 59 | 60 | Universes 61 | --------- 62 | 63 | A SteamID "universe" provides a way of grouping IDs. Typically you'll only 64 | ever come across the :data:`UNIVERSE_INDIVIDUAL` universe. 65 | 66 | .. autodata:: UNIVERSE_INDIVIDUAL 67 | .. autodata:: UNIVERSE_PUBLIC 68 | .. autodata:: UNIVERSE_BETA 69 | .. autodata:: UNIVERSE_INTERNAL 70 | .. autodata:: UNIVERSE_DEV 71 | .. autodata:: UNIVERSE_RC 72 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable = locally-disabled,locally-enabled,fixme,too-few-public-methods,duplicate-code 3 | 4 | [BASIC] 5 | good-names = i,j,k,_,x,y,ip,id 6 | const-rgx = (([A-Z_][A-Z0-9_]*)|(__.*__)|(log))$ 7 | 8 | [FORMAT] 9 | max-line-length = 79 10 | max-module-lines = 2000 11 | 12 | [REPORTS] 13 | reports = no 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os.path 4 | import sys 5 | import textwrap 6 | 7 | import setuptools 8 | 9 | 10 | def readme(): 11 | """Load README contents.""" 12 | path = os.path.join(os.path.dirname(__file__), "README.rst") 13 | with open(path) as readme: 14 | return readme.read() 15 | 16 | 17 | def install_requires(): 18 | """Determine installation requirements.""" 19 | requirements = [ 20 | "docopt>=0.6.2", 21 | "monotonic", 22 | "requests>=2.0", 23 | "six>=1.6", 24 | ] 25 | if sys.version_info[0] == 2: 26 | requirements.append("enum34>=1.1") 27 | return requirements 28 | 29 | 30 | setuptools.setup( 31 | name="python-valve", 32 | version="0.2.1", 33 | description=("Python implementation for Source servers, RCON, A2S, " 34 | "VDF, the Steam Web API and various other Valve products " 35 | "and services."), 36 | long_description=readme(), 37 | author="Oliver Ainsworth", 38 | author_email="ottajay@googlemail.com", 39 | url="https://github.com/serverstf/python-valve", 40 | packages=setuptools.find_packages(exclude=["tests"]), 41 | install_requires=install_requires(), 42 | extras_require={ 43 | "development": [ 44 | "pylint", 45 | ], 46 | "test": [ 47 | "mock==1.0.1", 48 | "pytest>=3.6.0", 49 | "pytest-cov", 50 | "pytest-timeout", 51 | ], 52 | "docs": [ 53 | "sphinx", 54 | "sphinx_rtd_theme", 55 | ], 56 | }, 57 | license="MIT License", 58 | classifiers=[ 59 | "Development Status :: 4 - Beta", 60 | "Intended Audience :: Developers", 61 | "License :: OSI Approved :: MIT License", 62 | "Programming Language :: Python :: 2.7", 63 | "Programming Language :: Python :: 3.4", 64 | "Programming Language :: Python :: 3.5", 65 | "Programming Language :: Python :: 3.6", 66 | "Programming Language :: Python :: 3.7", 67 | "Programming Language :: Python :: Implementation :: CPython", 68 | "Programming Language :: Python :: Implementation :: PyPy", 69 | "Topic :: Games/Entertainment", 70 | ], 71 | ) 72 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import (absolute_import, 4 | unicode_literals, print_function, division) 5 | 6 | import threading 7 | 8 | try: 9 | import mock 10 | except ImportError: 11 | import unittest.mock as mock 12 | import pytest 13 | 14 | import valve.source.a2s 15 | import valve.source.master_server 16 | import valve.testing 17 | 18 | 19 | def srcds_functional(**filter_): 20 | """Enable SRCDS functional testing for a test case 21 | 22 | This decorator will cause the test case to be parametrised with addresses 23 | for Source servers as returned from the master server. The test case 24 | should request a fixture called ``address`` which is a two-item tuple 25 | of the address of the server. 26 | 27 | All keyword arguments will be converted to a filter string which will 28 | be used when querying the master server. For example: 29 | 30 | ``` 31 | @srcds_functional(gamedir="tf") 32 | def test_foo(address): 33 | pass 34 | ``` 35 | 36 | This will result in the test only being ran on TF2 servers. See the link 37 | below for other filter options: 38 | 39 | https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol#Filter 40 | """ 41 | 42 | def decorator(function): 43 | function._srcds_filter = filter_ 44 | return function 45 | 46 | return decorator 47 | 48 | 49 | def pytest_addoption(parser): 50 | parser.addoption("--srcds-functional", 51 | action="store_true", 52 | default=False, 53 | dest="srcds_functional", 54 | help="Enable A2S functional tests against 'real' servers") 55 | parser.addoption("--srcds-functional-limit", 56 | action="store", 57 | type=int, 58 | default=20, 59 | help=("Limit the number of servers srcds_functional " 60 | "tests are ran against. Set to 0 to run against " 61 | "*all* servers -- warning: really slow"), 62 | dest="srcds_functional_limit") 63 | 64 | 65 | def pytest_generate_tests(metafunc): 66 | """Generate parametrised tests from real Source server instances 67 | 68 | This will apply an 'address' parametrised fixture for all rests marked 69 | with srcds_functional which is a two-item tuple address for a public 70 | Source server. 71 | 72 | This uses the MasterServerQuerier to find public server addressess from 73 | all regions. Filters passed into ``@srcds_functional`` will be used when 74 | querying the master server. 75 | """ 76 | if hasattr(metafunc.function, "_srcds_filter"): 77 | if not metafunc.config.getoption("srcds_functional"): 78 | pytest.skip("--srcds-functional not enabled") 79 | if "address" not in metafunc.fixturenames: 80 | raise Exception("You cannot use the srcds_functional decorator " 81 | "without requesting an 'address' fixture") 82 | msq = valve.source.master_server.MasterServerQuerier() 83 | server_addresses = [] 84 | address_limit = metafunc.config.getoption("srcds_functional_limit") 85 | region = metafunc.function._srcds_filter.pop('region', 'eu') 86 | try: 87 | for address in msq.find(region=region, 88 | **metafunc.function._srcds_filter): 89 | if address_limit: 90 | if len(server_addresses) >= address_limit: 91 | break 92 | server_addresses.append(address) 93 | except valve.source.NoResponseError: 94 | pass 95 | metafunc.parametrize("address", server_addresses) 96 | 97 | 98 | def pytest_configure(): 99 | pytest.Mock = mock.Mock 100 | pytest.MagicMock = mock.MagicMock 101 | pytest.srcds_functional = srcds_functional 102 | 103 | 104 | @pytest.yield_fixture 105 | def rcon_server(): 106 | server = valve.testing.TestRCONServer() 107 | thread = threading.Thread(target=server.serve_forever) 108 | thread.start() 109 | yield server 110 | server.shutdown() 111 | thread.join() 112 | -------------------------------------------------------------------------------- /tests/pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable = locally-disabled,fixme 3 | 4 | [BASIC] 5 | good-names = i,j,k,_,x,y,ip 6 | const-rgx = (([A-Z_][A-Z0-9_]*)|(__.*__)|(log))$ 7 | 8 | [FORMAT] 9 | max-line-length = 79 10 | max-module-lines = 2000 11 | 12 | [REPORTS] 13 | reports = no 14 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2014 Oliver Ainsworth 3 | 4 | from __future__ import (absolute_import, 5 | unicode_literals, print_function, division) 6 | 7 | import re 8 | import textwrap 9 | import types 10 | 11 | try: 12 | import mock 13 | except ImportError: 14 | import unittest.mock as mock 15 | import pytest 16 | 17 | from valve.steam.api import interface 18 | 19 | 20 | class TestEnsureIdentifier(object): 21 | 22 | def test_strip_bad_chars(self): 23 | assert interface._ensure_identifier("Upsidé;Down!") == "UpsidDown" 24 | 25 | def test_strip_bad_start(self): 26 | assert interface._ensure_identifier("123testing123") == "testing123" 27 | 28 | def test_illegal(self): 29 | with pytest.raises(NameError): 30 | interface._ensure_identifier("12345!£$%^&*()678909") 31 | 32 | 33 | def test_make_interfaces(monkeypatch): 34 | mocks = [mock.Mock(), mock.Mock()] 35 | mocks_copy = mocks[:] 36 | mocks[0].__name__ = "TestInterfaceOne" 37 | mocks[1].__name__ = "TestInterfaceTwo" 38 | monkeypatch.setattr(interface, 39 | "make_interface", 40 | mock.Mock(side_effect=lambda *args: mocks.pop(0))) 41 | interfaces = interface.make_interfaces( 42 | { 43 | "apilist": { 44 | "interfaces": [ 45 | {"name": "TestInterfaceOne"}, 46 | {"name": "TestInterfaceTwo"} 47 | ] 48 | } 49 | }, 50 | {"TestInterfaceOne": {"TestMethod": 1}}, 51 | ) 52 | assert isinstance(interfaces, types.ModuleType) 53 | assert interfaces.__all__ == ["TestInterfaceOne", "TestInterfaceTwo"] 54 | assert interface.make_interface.call_count == 2 55 | assert interface.make_interface.call_args_list[0][0][0] == \ 56 | {"name": "TestInterfaceOne"} 57 | assert interface.make_interface.call_args_list[0][0][1] == {"TestMethod": 1} 58 | assert interface.make_interface.call_args_list[1][0][0] == \ 59 | {"name": "TestInterfaceTwo"} 60 | assert interface.make_interface.call_args_list[1][0][1] == {} 61 | assert interfaces.TestInterfaceOne is mocks_copy[0] 62 | assert interfaces.TestInterfaceTwo is mocks_copy[1] 63 | 64 | 65 | class TestMakeInterface(object): 66 | 67 | def test_not_pinned(self, monkeypatch): 68 | mocks = [mock.Mock(), mock.Mock()] 69 | mocks_copy = mocks[:] 70 | mocks[0].name = "TestMethod" 71 | mocks[0].version = 1 72 | mocks[1].name = "TestMethod" 73 | mocks[1].version = 2 74 | monkeypatch.setattr(interface, 75 | "make_method", 76 | mock.Mock(side_effect=lambda *args: mocks.pop(0))) 77 | iface = interface.make_interface( 78 | { 79 | "name": "TestInterfaceOne", 80 | "methods": [ 81 | { 82 | "name": "TestMethod", 83 | "version": 1, 84 | }, 85 | { 86 | "name": "TestMethod", 87 | "version": 2, 88 | } 89 | ], 90 | }, 91 | {} 92 | ) 93 | assert issubclass(iface, interface.BaseInterface) 94 | assert iface.TestMethod.name == "TestMethod" 95 | assert iface.TestMethod.version == 2 96 | assert list(iface(mock.Mock())) == [mocks_copy[1]] 97 | assert interface.make_method.call_count == 2 98 | assert interface.make_method.call_args_list[0][0][0] == { 99 | "name": "TestMethod", 100 | "version": 1, 101 | } 102 | assert interface.make_method.call_args_list[1][0][0] == { 103 | "name": "TestMethod", 104 | "version": 2, 105 | } 106 | 107 | def test_pinned(self, monkeypatch): 108 | mocks = [mock.Mock(), mock.Mock()] 109 | mocks_copy = mocks[:] 110 | mocks[0].name = "TestMethod" 111 | mocks[0].version = 1 112 | mocks[1].name = "TestMethod" 113 | mocks[1].version = 2 114 | monkeypatch.setattr(interface, 115 | "make_method", 116 | mock.Mock(side_effect=lambda *args: mocks.pop(0))) 117 | iface = interface.make_interface( 118 | { 119 | "name": "TestInterfaceOne", 120 | "methods": [ 121 | { 122 | "name": "TestMethod", 123 | "version": 1, 124 | }, 125 | { 126 | "name": "TestMethod", 127 | "version": 2, 128 | } 129 | ], 130 | }, 131 | { 132 | "TestMethod": 1, 133 | }, 134 | ) 135 | assert issubclass(iface, interface.BaseInterface) 136 | assert iface.TestMethod.name == "TestMethod" 137 | assert iface.TestMethod.version == 1 138 | assert list(iface(mock.Mock())) == [mocks_copy[0]] 139 | assert interface.make_method.call_count == 2 140 | assert interface.make_method.call_args_list[0][0][0] == { 141 | "name": "TestMethod", 142 | "version": 1, 143 | } 144 | assert interface.make_method.call_args_list[1][0][0] == { 145 | "name": "TestMethod", 146 | "version": 2, 147 | } 148 | 149 | 150 | class TestMethodParameters(object): 151 | 152 | def test_ignore_key(self): 153 | params = interface._MethodParameters([ 154 | { 155 | "name": "key", 156 | "type": "string", 157 | "optional": False, 158 | "description": "test parameter", 159 | }, 160 | ]) 161 | assert "key" not in params 162 | 163 | def test_duplicate_name(self): 164 | with pytest.raises(NameError): 165 | params = interface._MethodParameters([ 166 | { 167 | "name": "test", 168 | "type": "string", 169 | "optional": False, 170 | "description": "test parameter", 171 | }, 172 | { 173 | "name": "test", 174 | "type": "string", 175 | "optional": False, 176 | "description": "test parameter", 177 | } 178 | ]) 179 | 180 | def test_missing_description(self): 181 | params = interface._MethodParameters([ 182 | { 183 | "name": "test", 184 | "type": "string", 185 | "optional": False, 186 | }, 187 | ]) 188 | param = params["test"] 189 | assert param["name"] == "test" 190 | assert param["type"] == "string" 191 | assert param["optional"] is False 192 | assert param["description"] == "" 193 | 194 | def test_unknown_type(self): 195 | params = interface._MethodParameters([ 196 | { 197 | "name": "test", 198 | "type": "", 199 | "optional": False, 200 | "description": "test parameter", 201 | }, 202 | ]) 203 | param = params["test"] 204 | assert param["name"] == "test" 205 | assert param["type"] == "string" 206 | assert param["optional"] is False 207 | assert param["description"] == "test parameter" 208 | 209 | def test_sorted(self): 210 | params = interface._MethodParameters([ 211 | { 212 | "name": "zebra", 213 | "type": "string", 214 | "optional": False, 215 | "description": "test parameter", 216 | }, 217 | { 218 | "name": "aardvark", 219 | "type": "string", 220 | "optional": False, 221 | "description": "test parameter", 222 | }, 223 | ]) 224 | assert list(params.keys()) == ["aardvark", "zebra"] 225 | 226 | def test_signature(self): 227 | params = interface._MethodParameters([ 228 | { 229 | "name": "zebra", 230 | "type": "string", 231 | "optional": False, 232 | "description": "test parameter", 233 | }, 234 | { 235 | "name": "aardvark", 236 | "type": "string", 237 | "optional": False, 238 | "description": "test parameter", 239 | }, 240 | { 241 | "name": "xenopus", 242 | "type": "string", 243 | "optional": True, 244 | "description": "test parameter", 245 | }, 246 | { 247 | "name": "bullfrog", 248 | "type": "string", 249 | "optional": True, 250 | "description": "test parameter", 251 | }, 252 | ]) 253 | assert params.signature == \ 254 | "self, aardvark, zebra, bullfrog=None, xenopus=None" 255 | 256 | def test_signature_no_params(self): 257 | params = interface._MethodParameters([]) 258 | assert params.signature == "self" 259 | 260 | def test_validate_missing_mandatory(self): 261 | params = interface._MethodParameters([ 262 | { 263 | "name": "test", 264 | "type": "string", 265 | "optional": False, 266 | "description": "test parameter", 267 | }, 268 | ]) 269 | with pytest.raises(TypeError): 270 | params.validate() 271 | 272 | def test_validate_skip_missing_optional(self): 273 | params = interface._MethodParameters([ 274 | { 275 | "name": "test", 276 | "type": "string", 277 | "optional": True, 278 | "description": "test parameter", 279 | }, 280 | ]) 281 | assert params.validate(test=None) == {} 282 | 283 | def test_validate_type_conversion(self, monkeypatch): 284 | validator = mock.Mock() 285 | monkeypatch.setattr(interface, 286 | "PARAMETER_TYPES", {"string": validator}) 287 | params = interface._MethodParameters([ 288 | { 289 | "name": "test", 290 | "type": "string", 291 | "optional": False, 292 | "description": "test parameter", 293 | }, 294 | ]) 295 | assert params.validate(test="raw value") == { 296 | "test": validator.return_value} 297 | assert validator.called 298 | assert validator.call_args[0][0] == "raw value" 299 | 300 | 301 | def test_make_method(): 302 | method = interface.make_method({ 303 | "name": "test", 304 | "version": 1, 305 | "httpmethod": "GET", 306 | "parameters": [ 307 | { 308 | "name": "foo", 309 | "type": "string", 310 | "optional": False, 311 | "description": "foo docs", 312 | }, 313 | { 314 | "name": "bar", 315 | "type": "string", 316 | "optional": True, 317 | "description": "bar docs", 318 | }, 319 | ], 320 | }) 321 | assert method.__name__ == "test" 322 | assert method.name == "test" 323 | assert method.version == 1 324 | assert method.__doc__ == textwrap.dedent("""\ 325 | :param string bar: bar docs 326 | :param string foo: foo docs""") 327 | assert method.__defaults__ == (None,) 328 | iface = mock.Mock() 329 | method(iface, "foo") 330 | assert iface._request.called 331 | assert iface._request.call_args[0][0] == "GET" 332 | assert iface._request.call_args[0][1] == "test" 333 | assert iface._request.call_args[0][2] == 1 334 | assert iface._request.call_args[0][3] == {"foo": "foo"} 335 | 336 | 337 | class TestAPI(object): 338 | 339 | @pytest.fixture 340 | def interfaces(self): 341 | module = types.ModuleType(str("test")) 342 | module.TestInterface = type( 343 | str("TestInterface"), (interface.BaseInterface,), {}) 344 | return module 345 | 346 | @pytest.mark.parametrize(("format_name", "format_func"), [ 347 | ("json", interface.json_format), 348 | ("xml", interface.etree_format), 349 | ("vdf", interface.vdf_format), 350 | ]) 351 | def test_formats(self, monkeypatch, format_name, format_func): 352 | monkeypatch.setattr(interface, "make_interfaces", mock.Mock()) 353 | monkeypatch.setattr(interface.API, "_bind_interfaces", mock.Mock()) 354 | monkeypatch.setattr(interface.API, "request", mock.Mock()) 355 | api = interface.API(format=format_name) 356 | assert api.format is format_func 357 | assert interface.make_interfaces.called 358 | assert (interface.make_interfaces.call_args[0][0] 359 | is api.request.return_value) 360 | assert interface.make_interfaces.call_args[0][1] == {} 361 | assert api.request.called 362 | assert api.request.call_args[0][0] == "GET" 363 | assert api.request.call_args[0][1] == "ISteamWebAPIUtil" 364 | assert api.request.call_args[0][2] == "GetSupportedAPIList" 365 | assert api.request.call_args[0][3] == 1 366 | assert api.request.call_args[1]["format"] is interface.json_format 367 | assert api._bind_interfaces.called 368 | 369 | def test_inherit_interfaces(self, monkeypatch): 370 | monkeypatch.setattr(interface, "make_interfaces", mock.Mock()) 371 | monkeypatch.setattr(interface.API, "_bind_interfaces", mock.Mock()) 372 | monkeypatch.setattr(interface.API, "request", mock.Mock()) 373 | interfaces = types.ModuleType(str("test")) 374 | api = interface.API(interfaces=interfaces) 375 | assert api._interfaces_module == interfaces 376 | assert not interface.make_interfaces.called 377 | assert api._bind_interfaces.called 378 | assert not api.request.called 379 | 380 | def test_getitem(self): 381 | api = interface.API(interfaces=types.ModuleType(str("test"))) 382 | api._interfaces = mock.MagicMock() 383 | assert api["Test"] is api._interfaces.__getitem__.return_value 384 | assert api._interfaces.__getitem__.call_args[0][0] == "Test" 385 | 386 | def test_bind_interfaces(self, interfaces): 387 | interfaces.NotSubClass = type(str("NotSubClass"), (), {}) 388 | interface.not_a_class = None 389 | api = interface.API(interfaces=interfaces) 390 | assert isinstance(api["TestInterface"], interface.BaseInterface) 391 | with pytest.raises(KeyError): 392 | api["NotSubClass"] 393 | 394 | def test_request(self, interfaces): 395 | api = interface.API(interfaces=interfaces) 396 | api._session = mock.Mock() 397 | api.format = mock.Mock(format="json") 398 | request = api._session.request 399 | raw_response = request.return_value 400 | response = api.request("GET", "interface", "method", 401 | 1, params={"key": "test", "foo": "bar"}) 402 | assert api.format.called 403 | assert api.format.call_args[0][0] is raw_response.text 404 | assert request.call_args[0][0] == "GET" 405 | assert request.call_args[0][1] == api.api_root + "interface/method/v1/" 406 | assert request.call_args[0][2] == {"format": "json", "foo": "bar"} 407 | 408 | @pytest.mark.parametrize("format_", ["json", "xml", "vdf"]) 409 | def test_request_with_key(self, interfaces, format_): 410 | api = interface.API(key="key", interfaces=interfaces) 411 | api._session = mock.Mock() 412 | api.format = mock.Mock(format=format_) 413 | request = api._session.request 414 | raw_response = request.return_value 415 | response = api.request("GET", "interface", "method", 416 | 1, params={"key": "test", "foo": "bar"}) 417 | assert api.format.called 418 | assert api.format.call_args[0][0] is raw_response.text 419 | assert request.call_args[0][0] == "GET" 420 | assert request.call_args[0][1] == api.api_root + "interface/method/v1/" 421 | assert request.call_args[0][2] == { 422 | "key": "key", 423 | "format": format_, 424 | "foo": "bar", 425 | } 426 | 427 | def test_request_unknown_format(self, interfaces): 428 | api = interface.API(interfaces=interfaces) 429 | api._session = mock.Mock() 430 | api.format = mock.Mock(format="invalid") 431 | request = api._session.request 432 | raw_response = request.return_value 433 | with pytest.raises(ValueError): 434 | api.request("GET", "interface", "method", 1) 435 | 436 | def test_iter(self, interfaces): 437 | api = interface.API(interfaces=interfaces) 438 | foo = object() 439 | bar = object() 440 | api._interfaces = {"foo": foo, "bar": bar} 441 | list_ = list(api) 442 | assert len(list_) == 2 443 | assert foo in list_ 444 | assert bar in list_ 445 | 446 | def test_versions(self, interfaces): 447 | api = interface.API(interfaces=interfaces) 448 | ifoo = mock.MagicMock() 449 | ifoo.name = "ifoo" 450 | ifoo_meths = [mock.Mock(version=1), mock.Mock(version=2)] 451 | ifoo_meths[0].name = "eggs" 452 | ifoo_meths[1].name = "spam" 453 | ifoo.__iter__ = lambda i: iter(ifoo_meths) 454 | ibar = mock.MagicMock() 455 | ibar.name = "ibar" 456 | ibar_meths = [mock.Mock(version=1)] 457 | ibar_meths[0].name = "method" 458 | ibar.__iter__ = lambda i: iter(ibar_meths) 459 | api._interfaces = { 460 | "ifoo": ifoo, 461 | "ibar": ibar, 462 | } 463 | assert api.versions() == { 464 | "ifoo": {"eggs": 1, "spam": 2}, 465 | "ibar": {"method": 1}, 466 | } 467 | -------------------------------------------------------------------------------- /tests/test_master_server.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2014-2017 Oliver Ainsworth 3 | 4 | from __future__ import (absolute_import, 5 | unicode_literals, print_function, division) 6 | 7 | try: 8 | import mock 9 | except ImportError: 10 | import unittest.mock as mock 11 | import pytest 12 | 13 | import valve.source 14 | from valve.source import a2s 15 | from valve.source import master_server 16 | from valve.source import messages 17 | from valve.source import util 18 | 19 | 20 | def test_iter(monkeypatch): 21 | monkeypatch.setattr(master_server.MasterServerQuerier, 22 | "find", mock.Mock(return_value=iter([]))) 23 | msq = master_server.MasterServerQuerier() 24 | assert iter(msq) is master_server.MasterServerQuerier.find.return_value 25 | assert master_server.MasterServerQuerier.find.called 26 | assert master_server.MasterServerQuerier.find.call_args[1] == \ 27 | {"region": "all"} 28 | 29 | 30 | class TestMapRegion(object): 31 | 32 | @pytest.mark.parametrize("region", [ 33 | master_server.REGION_US_EAST_COAST, 34 | master_server.REGION_US_WEST_COAST, 35 | master_server.REGION_SOUTH_AMERICA, 36 | master_server.REGION_EUROPE, 37 | master_server.REGION_ASIA, 38 | master_server.REGION_AUSTRALIA, 39 | master_server.REGION_MIDDLE_EAST, 40 | master_server.REGION_AFRICA, 41 | master_server.REGION_REST, 42 | ]) 43 | def test_numeric(self, region): 44 | msq = master_server.MasterServerQuerier() 45 | assert msq._map_region(region) == [region] 46 | 47 | def test_numeric_invalid(self): 48 | with pytest.raises(ValueError): 49 | msq = master_server.MasterServerQuerier() 50 | msq._map_region(420) 51 | 52 | @pytest.mark.parametrize(("region", "numeric_identifiers"), { 53 | "na-east": [master_server.REGION_US_EAST_COAST], 54 | "na-west": [master_server.REGION_US_WEST_COAST], 55 | "na": [master_server.REGION_US_EAST_COAST, 56 | master_server.REGION_US_WEST_COAST], 57 | "sa": [master_server.REGION_SOUTH_AMERICA], 58 | "eu": [master_server.REGION_EUROPE], 59 | "as": [master_server.REGION_ASIA, master_server.REGION_MIDDLE_EAST], 60 | "oc": [master_server.REGION_AUSTRALIA], 61 | "af": [master_server.REGION_AFRICA], 62 | "rest": [master_server.REGION_REST], 63 | "all": [master_server.REGION_US_EAST_COAST, 64 | master_server.REGION_US_WEST_COAST, 65 | master_server.REGION_SOUTH_AMERICA, 66 | master_server.REGION_EUROPE, 67 | master_server.REGION_ASIA, 68 | master_server.REGION_AUSTRALIA, 69 | master_server.REGION_MIDDLE_EAST, 70 | master_server.REGION_AFRICA, 71 | master_server.REGION_REST], 72 | }.items()) 73 | def test_string(self, region, numeric_identifiers): 74 | msq = master_server.MasterServerQuerier() 75 | assert set(msq._map_region(region)) == set(numeric_identifiers) 76 | 77 | def test_string_valid(self): 78 | with pytest.raises(ValueError): 79 | msq = master_server.MasterServerQuerier() 80 | msq._map_region("absolutely-ridiculous") 81 | 82 | @pytest.mark.parametrize("region", ["eu", "Eu", "eU", "EU"]) 83 | def test_string_case_sensitivity(self, region): 84 | msq = master_server.MasterServerQuerier() 85 | assert msq._map_region(region) == [master_server.REGION_EUROPE] 86 | 87 | 88 | class TestFind(object): 89 | 90 | @pytest.fixture 91 | def _map_region(self, monkeypatch): 92 | monkeypatch.setattr(master_server.MasterServerQuerier, 93 | "_map_region", mock.Mock(return_value=["blah"])) 94 | return master_server.MasterServerQuerier._map_region 95 | 96 | @pytest.fixture 97 | def _query(self, monkeypatch): 98 | monkeypatch.setattr(master_server.MasterServerQuerier, 99 | "_query", mock.Mock(return_value=[])) 100 | return master_server.MasterServerQuerier._query 101 | 102 | def test_defaults(self, _map_region, _query, monkeypatch): 103 | msq = master_server.MasterServerQuerier() 104 | list(msq.find()) 105 | assert _map_region.called 106 | assert _map_region.call_args[0][0] == "all" 107 | assert _query.called 108 | assert _query.call_args[0][0] == _map_region.return_value[0] 109 | assert _query.call_args[0][1] == "" 110 | 111 | def test_iterable_region(self, _map_region, _query): 112 | msq = master_server.MasterServerQuerier() 113 | list(msq.find(region=["first", "second"])) 114 | assert _map_region.call_count == 2 115 | assert _map_region.call_args_list[0][0][0] == "first" 116 | assert _map_region.call_args_list[1][0][0] == "second" 117 | assert _query.call_count == \ 118 | len(_map_region.return_value) * _map_region.call_count 119 | 120 | def test_filter_secure(self, _map_region, _query): 121 | msq = master_server.MasterServerQuerier() 122 | list(msq.find(secure=True)) 123 | assert _query.called 124 | assert _query.call_args[0][1] == r"\secure\1" 125 | 126 | def test_filter_linux(self, _map_region, _query): 127 | msq = master_server.MasterServerQuerier() 128 | list(msq.find(linux=True)) 129 | assert _query.called 130 | assert _query.call_args[0][1] == r"\linux\1" 131 | 132 | def test_filter_empty(self, _map_region, _query): 133 | msq = master_server.MasterServerQuerier() 134 | list(msq.find(empty=True)) 135 | assert _query.called 136 | assert _query.call_args[0][1] == r"\empty\1" 137 | 138 | def test_filter_full(self, _map_region, _query): 139 | msq = master_server.MasterServerQuerier() 140 | list(msq.find(full=True)) 141 | assert _query.called 142 | assert _query.call_args[0][1] == r"\full\1" 143 | 144 | def test_filter_proxy(self, _map_region, _query): 145 | msq = master_server.MasterServerQuerier() 146 | list(msq.find(proxy=True)) 147 | assert _query.called 148 | assert _query.call_args[0][1] == r"\proxy\1" 149 | 150 | def test_filter_noplayers(self, _map_region, _query): 151 | msq = master_server.MasterServerQuerier() 152 | list(msq.find(noplayers=True)) 153 | assert _query.called 154 | assert _query.call_args[0][1] == r"\noplayers\1" 155 | 156 | def test_filter_white(self, _map_region, _query): 157 | msq = master_server.MasterServerQuerier() 158 | list(msq.find(white=True)) 159 | assert _query.called 160 | assert _query.call_args[0][1] == r"\white\1" 161 | 162 | # elif key in {"gametype", "gamedata", "gamedataor"}: 163 | 164 | @pytest.mark.parametrize(("filter_term", "filter_", "expected"), [ 165 | ("gametype", ["tag"], r"\gametype\tag"), 166 | ("gametype", ["tag", "tag2"], r"\gametype\tag,tag2"), 167 | ("gamedata", ["tag"], r"\gamedata\tag"), 168 | ("gamedata", ["tag", "tag2"], r"\gamedata\tag,tag2"), 169 | ("gamedataor", ["tag"], r"\gamedataor\tag"), 170 | ("gamedataor", ["tag", "tag2"], r"\gamedataor\tag,tag2"), 171 | ]) 172 | def test_filter_list(self, _map_region, _query, 173 | filter_term, filter_, expected): 174 | msq = master_server.MasterServerQuerier() 175 | list(msq.find(**{filter_term: filter_})) 176 | assert _query.called 177 | assert _query.call_args[0][1] == expected 178 | 179 | @pytest.mark.parametrize("filter_term", 180 | ["gametype", "gamedata", "gamedataor"]) 181 | def test_filter_list_empty(self, _map_region, _query, filter_term): 182 | msq = master_server.MasterServerQuerier() 183 | list(msq.find(**{filter_term: []})) 184 | assert _query.called 185 | assert _query.call_args[0][1] == "" 186 | 187 | @pytest.mark.parametrize("filter_term", 188 | ["gametype", "gamedata", "gamedataor"]) 189 | def test_filter_list_all_empty_elements(self, _map_region, 190 | _query, filter_term): 191 | msq = master_server.MasterServerQuerier() 192 | list(msq.find(**{filter_term: ["", ""]})) 193 | assert _query.called 194 | assert _query.call_args[0][1] == "" 195 | 196 | @pytest.mark.parametrize("filter_term", 197 | ["gametype", "gamedata", "gamedataor"]) 198 | def test_filter_list_some_empty_elements(self, _map_region, 199 | _query, filter_term): 200 | msq = master_server.MasterServerQuerier() 201 | list(msq.find(**{filter_term: ["tag", "", "tag2"]})) 202 | assert _query.called 203 | assert _query.call_args[0][1] == r"\{}\tag,tag2".format(filter_term) 204 | 205 | def test_filter_napp(self, _map_region, _query): 206 | msq = master_server.MasterServerQuerier() 207 | list(msq.find(napp=440)) 208 | assert _query.called 209 | assert _query.call_args[0][1] == r"\napp\440" 210 | 211 | def test_filter_type(self, _map_region, _query): 212 | msq = master_server.MasterServerQuerier() 213 | server_type = util.ServerType(108) 214 | list(msq.find(type=server_type)) 215 | assert _query.called 216 | assert _query.call_args[0][1] == r"\type\{}".format(server_type.char) 217 | 218 | def test_filter_type_cast(self, monkeypatch, _map_region, _query): 219 | 220 | class MockServerType(object): 221 | 222 | init_args = [] 223 | 224 | def __init__(self, *args, **kwargs): 225 | self.init_args.append((args, kwargs)) 226 | 227 | @property 228 | def char(self): 229 | return "d" 230 | 231 | monkeypatch.setattr(util, "ServerType", MockServerType) 232 | msq = master_server.MasterServerQuerier() 233 | list(msq.find(type="test")) 234 | assert util.ServerType.init_args 235 | assert util.ServerType.init_args[0][0][0] == "test" 236 | assert _query.called 237 | assert _query.call_args[0][1] == r"\type\d" 238 | 239 | def test_filter_multiple(self, _map_region, _query): 240 | msq = master_server.MasterServerQuerier() 241 | list(msq.find(napp=240, gametype=["tag", "tag2"])) 242 | assert _query.called 243 | assert _query.call_args[0][1] == r"\gametype\tag,tag2\napp\240" 244 | 245 | 246 | class TestQuery(object): 247 | 248 | @pytest.fixture 249 | def msq(self, monkeypatch): 250 | monkeypatch.setattr( 251 | master_server.MasterServerQuerier, "request", mock.Mock()) 252 | monkeypatch.setattr( 253 | master_server.MasterServerQuerier, "get_response", mock.Mock()) 254 | return master_server.MasterServerQuerier() 255 | 256 | @pytest.fixture 257 | def request_(self, monkeypatch): 258 | monkeypatch.setattr(messages, "MasterServerRequest", mock.Mock()) 259 | return messages.MasterServerRequest 260 | 261 | @pytest.fixture 262 | def response(self, monkeypatch): 263 | responses = [] 264 | 265 | @classmethod 266 | def mock_decode(cls, raw_response): 267 | return responses.pop(0) 268 | 269 | monkeypatch.setattr( 270 | messages.MasterServerResponse, 271 | "decode", 272 | mock_decode) 273 | 274 | def add_response(*addresses): 275 | for batch in addresses: 276 | fields = {"addresses": [], 277 | "start_port": b"26122", 278 | "start_host": "255.255.255.255"} 279 | for address in batch: 280 | fields["addresses"].append( 281 | messages.MSAddressEntry(host=address[0], 282 | port=address[1])) 283 | responses.append(messages.MasterServerResponse(**fields)) 284 | 285 | return add_response 286 | 287 | def test_initial_request(self, msq, request_, response): 288 | response([("0.0.0.0", 0)]) 289 | list(msq._query(master_server.REGION_REST, "")) 290 | assert request_.called 291 | assert request_.call_args[1] == { 292 | "region": master_server.REGION_REST, 293 | "address": "0.0.0.0:0", 294 | "filter": "", 295 | } 296 | 297 | def test_single_batch(self, msq, request_, response): 298 | response([("8.8.8.8", 27015), ("0.0.0.0", 0)]) 299 | addresses = list(msq._query(master_server.REGION_REST, r"\full\1")) 300 | assert request_.called 301 | assert request_.call_args[1] == { 302 | "region": master_server.REGION_REST, 303 | "address": "0.0.0.0:0", 304 | "filter": r"\full\1", 305 | } 306 | assert addresses == [("8.8.8.8", 27015)] 307 | 308 | def test_multiple_batches(self, msq, request_, response): 309 | response( 310 | [ 311 | ("8.8.8.8", 27015), 312 | ], 313 | [ 314 | ("8.8.4.4", 27015), 315 | ("0.0.0.0", 0), 316 | ], 317 | ) 318 | addresses = list(msq._query(master_server.REGION_REST, r"\empty\1")) 319 | assert request_.call_count == 2 320 | assert request_.call_args_list[0][1] == { 321 | "region": master_server.REGION_REST, 322 | "address": "0.0.0.0:0", 323 | "filter": r"\empty\1", 324 | } 325 | assert request_.call_args_list[1][1] == { 326 | "region": master_server.REGION_REST, 327 | "address": "8.8.8.8:27015", 328 | "filter": r"\empty\1", 329 | } 330 | assert addresses == [ 331 | ("8.8.8.8", 27015), 332 | ("8.8.4.4", 27015), 333 | ] 334 | 335 | def test_no_response(self, msq, request_, response): 336 | msq.get_response.side_effect = valve.source.NoResponseError 337 | assert list(msq._query(master_server.REGION_REST, "")) == [] 338 | assert request_.called 339 | assert request_.call_args[1] == { 340 | "region": master_server.REGION_REST, 341 | "address": "0.0.0.0:0", 342 | "filter": "", 343 | } 344 | 345 | @pytest.mark.parametrize(("method", "addresses"), [ 346 | ( 347 | master_server.Duplicates.KEEP, 348 | [ 349 | ("192.0.2.0", 27015), 350 | ("192.0.2.1", 27015), 351 | ("192.0.2.2", 27015), 352 | ("192.0.2.1", 27015), 353 | ("192.0.2.3", 27015), 354 | ], 355 | ), 356 | ( 357 | master_server.Duplicates.SKIP, 358 | [ 359 | ('192.0.2.0', 27015), 360 | ('192.0.2.1', 27015), 361 | ('192.0.2.2', 27015), 362 | ('192.0.2.3', 27015), 363 | ], 364 | ), 365 | ( 366 | master_server.Duplicates.STOP, 367 | [ 368 | ('192.0.2.0', 27015), 369 | ('192.0.2.1', 27015), 370 | ('192.0.2.2', 27015), 371 | ], 372 | ), 373 | ]) 374 | def test_duplicates(self, msq, response, method, addresses): 375 | response([ 376 | ("192.0.2.0", 27015), 377 | ("192.0.2.1", 27015), 378 | ("192.0.2.2", 27015), 379 | ("192.0.2.1", 27015), 380 | ("192.0.2.3", 27015), 381 | ("0.0.0.0", 0), 382 | ]) 383 | # `find` invokes `query` once for every region; so only one region 384 | assert list(msq.find(region="eu", duplicates=method)) == addresses 385 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2014-2017 Oliver Ainsworth 3 | 4 | from __future__ import (absolute_import, 5 | unicode_literals, print_function, division) 6 | 7 | import pytest 8 | import six 9 | 10 | import valve.source 11 | import valve.source.a2s 12 | 13 | 14 | @pytest.srcds_functional(gamedir="tf") 15 | def test_tf2_ping(address): 16 | try: 17 | a2s = valve.source.a2s.ServerQuerier(address) 18 | latency = a2s.ping() 19 | except valve.source.NoResponseError: 20 | pytest.skip("Timedout waiting for response") 21 | assert latency > 0 22 | 23 | 24 | @pytest.srcds_functional(gamedir="tf") 25 | def test_tf2_info(address): 26 | try: 27 | a2s = valve.source.a2s.ServerQuerier(address) 28 | info = a2s.info() 29 | except valve.source.NoResponseError: 30 | pytest.skip("Timedout waiting for response") 31 | assert info["app_id"] == 440 32 | assert info["folder"] == "tf" 33 | assert isinstance(info["folder"], six.text_type) 34 | 35 | 36 | @pytest.srcds_functional(gamedir="tf") 37 | def test_tf2_rules(address): 38 | try: 39 | a2s = valve.source.a2s.ServerQuerier(address) 40 | rules = a2s.rules() 41 | except valve.source.NoResponseError: 42 | pytest.skip("Timedout waiting for response") 43 | 44 | 45 | @pytest.srcds_functional(gamedir="cstrike") 46 | def test_css_ping(address): 47 | try: 48 | a2s = valve.source.a2s.ServerQuerier(address) 49 | latency = a2s.ping() 50 | except valve.source.NoResponseError: 51 | pytest.skip("Timedout waiting for response") 52 | assert latency > 0 53 | 54 | 55 | @pytest.srcds_functional(gamedir="cstrike") 56 | def test_css_info(address): 57 | try: 58 | a2s = valve.source.a2s.ServerQuerier(address) 59 | info = a2s.info() 60 | except valve.source.NoResponseError: 61 | return 62 | assert info["app_id"] == 240 63 | assert info["folder"] == "cstrike" 64 | assert isinstance(info["folder"], six.text_type) 65 | 66 | 67 | @pytest.srcds_functional(gamedir="csgo") 68 | def test_csgo_ping(address): 69 | try: 70 | a2s = valve.source.a2s.ServerQuerier(address) 71 | latency = a2s.ping() 72 | except valve.source.NoResponseError: 73 | pytest.skip("Timedout waiting for response") 74 | assert latency > 0 75 | 76 | 77 | @pytest.srcds_functional(gamedir="csgo") 78 | def test_csgo_info(address): 79 | try: 80 | a2s = valve.source.a2s.ServerQuerier(address) 81 | info = a2s.info() 82 | except valve.source.NoResponseError: 83 | return 84 | assert info["app_id"] == 730 85 | assert info["folder"] == "csgo" 86 | assert isinstance(info["folder"], six.text_type) 87 | 88 | 89 | @pytest.srcds_functional(gamedir="dota") 90 | def test_dota2_ping(address): 91 | try: 92 | a2s = valve.source.a2s.ServerQuerier(address) 93 | latency = a2s.ping() 94 | except valve.source.NoResponseError: 95 | pytest.skip("Timedout waiting for response") 96 | assert latency > 0 97 | 98 | 99 | @pytest.srcds_functional(gamedir="dota") 100 | def test_dota2_info(address): 101 | try: 102 | a2s = valve.source.a2s.ServerQuerier(address) 103 | info = a2s.info() 104 | except valve.source.NoResponseError: 105 | return 106 | assert info["app_id"] == 570 107 | assert info["folder"] == "dota" 108 | assert isinstance(info["folder"], six.text_type) 109 | 110 | 111 | @pytest.srcds_functional(gamedir="left4dead") 112 | def test_l4d_ping(address): 113 | try: 114 | a2s = valve.source.a2s.ServerQuerier(address) 115 | latency = a2s.ping() 116 | except valve.source.NoResponseError: 117 | pytest.skip("Timedout waiting for response") 118 | assert latency > 0 119 | 120 | 121 | @pytest.srcds_functional(gamedir="left4dead") 122 | def test_l4d_info(address): 123 | try: 124 | a2s = valve.source.a2s.ServerQuerier(address) 125 | info = a2s.info() 126 | except valve.source.NoResponseError: 127 | return 128 | assert info["app_id"] == 500 129 | assert info["folder"] == "left4dead" 130 | assert isinstance(info["folder"], six.text_type) 131 | 132 | 133 | @pytest.srcds_functional(gamedir="left4dead2") 134 | def test_l4d2_ping(address): 135 | try: 136 | a2s = valve.source.a2s.ServerQuerier(address) 137 | latency = a2s.ping() 138 | except valve.source.NoResponseError: 139 | pytest.skip("Timedout waiting for response") 140 | assert latency > 0 141 | 142 | 143 | @pytest.srcds_functional(gamedir="left4dead2") 144 | def test_l4d2_info(address): 145 | try: 146 | a2s = valve.source.a2s.ServerQuerier(address) 147 | info = a2s.info() 148 | except valve.source.NoResponseError: 149 | return 150 | assert info["app_id"] == 550 151 | assert info["folder"] == "left4dead2" 152 | assert isinstance(info["folder"], six.text_type) 153 | 154 | 155 | # quake live 156 | @pytest.srcds_functional(region='rest', appid='282440') 157 | def test_ql_rules(address): 158 | try: 159 | a2s = valve.source.a2s.ServerQuerier(address) 160 | rules = a2s.rules() 161 | except valve.source.NoResponseError: 162 | return 163 | -------------------------------------------------------------------------------- /tests/test_source.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2017 Oliver Ainsworth 3 | 4 | from __future__ import (absolute_import, 5 | unicode_literals, print_function, division) 6 | 7 | import socket 8 | 9 | import pytest 10 | 11 | import valve.source 12 | 13 | 14 | class TestBaseQuerier: 15 | 16 | def test(self): 17 | querier = valve.source.BaseQuerier(('192.0.2.0', 27015)) 18 | assert querier.host == '192.0.2.0' 19 | assert querier.port == 27015 20 | assert querier._socket.family == socket.AF_INET 21 | assert querier._socket.type == socket.SOCK_DGRAM 22 | querier.close() 23 | assert querier._socket is None 24 | 25 | def test_close(self): 26 | querier = valve.source.BaseQuerier(('192.0.2.0', 27015)) 27 | assert querier._socket.family == socket.AF_INET 28 | assert querier._socket.type == socket.SOCK_DGRAM 29 | querier.close() 30 | assert querier._socket is None 31 | with pytest.raises(valve.source.QuerierClosedError): 32 | querier.request() 33 | with pytest.raises(valve.source.QuerierClosedError): 34 | querier.get_response() 35 | 36 | def test_close_redundant(self): 37 | querier = valve.source.BaseQuerier(('192.0.2.0', 27015)) 38 | assert querier._socket.family == socket.AF_INET 39 | assert querier._socket.type == socket.SOCK_DGRAM 40 | querier.close() 41 | assert querier._socket is None 42 | with pytest.raises(valve.source.QuerierClosedError): 43 | querier.request() 44 | with pytest.raises(valve.source.QuerierClosedError): 45 | querier.get_response() 46 | querier.close() 47 | assert querier._socket is None 48 | with pytest.raises(valve.source.QuerierClosedError): 49 | querier.request() 50 | with pytest.raises(valve.source.QuerierClosedError): 51 | querier.get_response() 52 | 53 | def test_context_manager(self): 54 | with valve.source.BaseQuerier(('192.0.2.0', 27015)) as querier: 55 | assert querier._socket.family == socket.AF_INET 56 | assert querier._socket.type == socket.SOCK_DGRAM 57 | assert querier._socket is None 58 | with pytest.raises(valve.source.QuerierClosedError): 59 | querier.request() 60 | with pytest.raises(valve.source.QuerierClosedError): 61 | querier.get_response() 62 | 63 | def test_context_manager_close_before_exit(self): 64 | with valve.source.BaseQuerier(('192.0.2.0', 27015)) as querier: 65 | assert querier._socket.family == socket.AF_INET 66 | assert querier._socket.type == socket.SOCK_DGRAM 67 | with pytest.warns(UserWarning): 68 | querier.close() 69 | assert querier._socket is None 70 | with pytest.raises(valve.source.QuerierClosedError): 71 | querier.request() 72 | with pytest.raises(valve.source.QuerierClosedError): 73 | querier.get_response() 74 | assert querier._socket is None 75 | with pytest.raises(valve.source.QuerierClosedError): 76 | querier.request() 77 | with pytest.raises(valve.source.QuerierClosedError): 78 | querier.get_response() 79 | 80 | def test_context_manager_close_after_exit(self): 81 | with valve.source.BaseQuerier(('192.0.2.0', 27015)) as querier: 82 | assert querier._socket.family == socket.AF_INET 83 | assert querier._socket.type == socket.SOCK_DGRAM 84 | assert querier._socket is None 85 | with pytest.raises(valve.source.QuerierClosedError): 86 | querier.request() 87 | with pytest.raises(valve.source.QuerierClosedError): 88 | querier.get_response() 89 | querier.close() 90 | assert querier._socket is None 91 | with pytest.raises(valve.source.QuerierClosedError): 92 | querier.request() 93 | with pytest.raises(valve.source.QuerierClosedError): 94 | querier.get_response() 95 | -------------------------------------------------------------------------------- /tests/test_steamid.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2014 Oliver Ainsworth 3 | 4 | from __future__ import (absolute_import, 5 | unicode_literals, print_function, division) 6 | 7 | import pytest 8 | import six 9 | 10 | from valve.steam import id as steamid 11 | 12 | 13 | class TestSteamID(object): 14 | 15 | def test_account_number_too_small(self): 16 | with pytest.raises(steamid.SteamIDError): 17 | steamid.SteamID(-1, 0, 18 | steamid.TYPE_INDIVIDUAL, 19 | steamid.UNIVERSE_INDIVIDUAL) 20 | 21 | def test_account_number_too_big(self): 22 | with pytest.raises(steamid.SteamIDError): 23 | steamid.SteamID(4294967296, 0, 24 | steamid.TYPE_INDIVIDUAL, 25 | steamid.UNIVERSE_INDIVIDUAL) 26 | 27 | 28 | @pytest.mark.parametrize(("type_", "as_string"), [ 29 | (steamid.TYPE_ANON_GAME_SERVER, "TYPE_ANON_GAME_SERVER"), 30 | (steamid.TYPE_INVALID, "TYPE_INVALID"), 31 | (steamid.TYPE_INDIVIDUAL, "TYPE_INDIVIDUAL"), 32 | (steamid.TYPE_MULTISEAT, "TYPE_MULTISEAT"), 33 | (steamid.TYPE_GAME_SERVER, "TYPE_GAME_SERVER"), 34 | (steamid.TYPE_ANON_GAME_SERVER, "TYPE_ANON_GAME_SERVER"), 35 | (steamid.TYPE_PENDING, "TYPE_PENDING"), 36 | (steamid.TYPE_CONTENT_SERVER, "TYPE_CONTENT_SERVER"), 37 | (steamid.TYPE_CLAN, "TYPE_CLAN"), 38 | (steamid.TYPE_CHAT, "TYPE_CHAT"), 39 | (steamid.TYPE_P2P_SUPER_SEEDER, "TYPE_P2P_SUPER_SEEDER"), 40 | (steamid.TYPE_ANON_USER, "TYPE_ANON_USER"), 41 | ]) 42 | def test_type_name(type_, as_string): 43 | id_ = steamid.SteamID(1, 0, type_, steamid.UNIVERSE_INDIVIDUAL) 44 | assert id_.type_name == as_string 45 | 46 | 47 | class TestTextRepresentation: 48 | 49 | def test_pending(self): 50 | id_ = steamid.SteamID(1, 0, 51 | steamid.TYPE_PENDING, 52 | steamid.UNIVERSE_INDIVIDUAL) 53 | assert str(id_) == "STEAM_ID_PENDING" 54 | 55 | def test_invalid(self): 56 | id_ = steamid.SteamID(1, 0, 57 | steamid.TYPE_INVALID, 58 | steamid.UNIVERSE_INDIVIDUAL) 59 | assert str(id_) == "UNKNOWN" 60 | 61 | def test_other(self): 62 | id_ = steamid.SteamID(1, 0, 63 | steamid.TYPE_INDIVIDUAL, 64 | steamid.UNIVERSE_INDIVIDUAL) 65 | assert str(id_) == "STEAM_0:0:1" 66 | 67 | 68 | class Test64CommunityID: 69 | 70 | @pytest.mark.parametrize("type_", [ 71 | steamid.TYPE_INVALID, 72 | steamid.TYPE_MULTISEAT, 73 | steamid.TYPE_GAME_SERVER, 74 | steamid.TYPE_ANON_GAME_SERVER, 75 | steamid.TYPE_PENDING, 76 | steamid.TYPE_CONTENT_SERVER, 77 | steamid.TYPE_CHAT, 78 | steamid.TYPE_P2P_SUPER_SEEDER, 79 | steamid.TYPE_ANON_USER, 80 | ]) 81 | def test_bad_type(self, type_): 82 | id_ = steamid.SteamID(1, 0, type_, steamid.UNIVERSE_INDIVIDUAL) 83 | with pytest.raises(steamid.SteamIDError): 84 | int(id_) 85 | 86 | def test_individual(self): 87 | id_ = steamid.SteamID(44647673, 0, 88 | steamid.TYPE_INDIVIDUAL, 89 | steamid.UNIVERSE_INDIVIDUAL) 90 | assert int(id_) == 76561198049561074 91 | 92 | def test_group(self): 93 | id_ = steamid.SteamID(44647673, 1, 94 | steamid.TYPE_CLAN, 95 | steamid.UNIVERSE_INDIVIDUAL) 96 | assert int(id_) == 103582791518816755 97 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2014-2017 Oliver Ainsworth 3 | 4 | from __future__ import (absolute_import, 5 | unicode_literals, print_function, division) 6 | 7 | import pytest 8 | import six 9 | 10 | from valve.source import util 11 | 12 | 13 | class TestPlatform(object): 14 | 15 | @pytest.mark.parametrize("identifier", [76, 108, 109, 111, 119]) 16 | def test_valid_numeric_identifer(self, identifier): 17 | platform = util.Platform(identifier) 18 | assert platform.value == identifier 19 | 20 | def test_invalid_numeric_identifier(self): 21 | with pytest.raises(ValueError): 22 | util.Platform(50) 23 | 24 | @pytest.mark.parametrize(("identifier", "expected"), [ 25 | ("L", 76), 26 | ("l", 108), 27 | ("m", 109), 28 | ("o", 111), 29 | ("w", 119), 30 | ]) 31 | def test_valid_character_identifier(self, identifier, expected): 32 | platform = util.Platform(identifier) 33 | assert platform.value == expected 34 | 35 | def test_invalid_character_identifier(self): 36 | with pytest.raises(ValueError): 37 | util.Platform("a") 38 | 39 | @pytest.mark.parametrize(("identifier", "expected"), [ 40 | ("linux", 108), 41 | ("Linux", 108), 42 | ("LINUX", 108), 43 | ("mac os x", 111), # Note: not 109 44 | ("Mac OS X", 111), 45 | ("MAC OS X", 111), 46 | ("windows", 119), 47 | ("Windows", 119), 48 | ("WINDOWS", 119), 49 | ]) 50 | def test_valid_string_identifier(self, identifier, expected): 51 | platform = util.Platform(identifier) 52 | assert platform.value == expected 53 | 54 | def test_invalid_string_identifier(self): 55 | with pytest.raises(ValueError): 56 | util.Platform("raindeer") 57 | 58 | def test_empty_string_identifier(self): 59 | with pytest.raises(ValueError): 60 | util.Platform("") 61 | 62 | @pytest.mark.parametrize(("identifier", "string"), [ 63 | (76, "Linux"), 64 | (108, "Linux"), 65 | (109, "Mac OS X"), 66 | (111, "Mac OS X"), 67 | (119, "Windows"), 68 | ]) 69 | def test_to_unicode(self, identifier, string): 70 | platform = util.Platform(identifier) 71 | assert six.text_type(platform) == string 72 | 73 | @pytest.mark.parametrize(("identifier", "string"), [ 74 | (76, b"Linux"), 75 | (108, b"Linux"), 76 | (109, b"Mac OS X"), 77 | (111, b"Mac OS X"), 78 | (119, b"Windows"), 79 | ]) 80 | def test_to_bytestring(self, identifier, string): 81 | platform = util.Platform(identifier) 82 | assert bytes(platform) == string 83 | 84 | @pytest.mark.parametrize("identifier", [76, 108, 109, 111, 119]) 85 | def test_to_integer(self, identifier): 86 | platform = util.Platform(identifier) 87 | assert int(platform) == identifier 88 | 89 | @pytest.mark.parametrize(("identifier", "os_name"), [ 90 | (76, "posix"), 91 | (108, "posix"), 92 | (109, "posix"), 93 | (111, "posix"), 94 | (119, "nt"), 95 | ]) 96 | def test_os_name(self, identifier, os_name): 97 | platform = util.Platform(identifier) 98 | assert platform.os_name == os_name 99 | 100 | @pytest.mark.parametrize(("platform", "other"), [ 101 | (util.Platform(76), util.Platform(76)), 102 | (util.Platform(76), util.Platform(108)), # Starbound 103 | (util.Platform(108), util.Platform(76)), # Starbound 104 | (util.Platform(108), util.Platform(108)), 105 | (util.Platform(109), util.Platform(109)), 106 | (util.Platform(111), util.Platform(111)), 107 | (util.Platform(109), util.Platform(111)), # Special Mac case 108 | (util.Platform(111), util.Platform(109)), # Special Mac case 109 | (util.Platform(119), util.Platform(119)), 110 | ]) 111 | def test_equality(self, platform, other): 112 | assert platform == other 113 | 114 | @pytest.mark.parametrize(("platform", "other"), [ 115 | (util.Platform(76), 76), 116 | (util.Platform(108), 76), # Starbound 117 | (util.Platform(76), 108), # Starbound 118 | (util.Platform(108), 108), 119 | (util.Platform(109), 109), 120 | (util.Platform(111), 111), 121 | (util.Platform(109), 111), # Special Mac case 122 | (util.Platform(111), 109), # Special Mac case 123 | (util.Platform(119), 119), 124 | ]) 125 | def test_equality_integer(self, platform, other): 126 | assert platform == other 127 | 128 | @pytest.mark.parametrize(("platform", "other"), [ 129 | (util.Platform(76), "L"), 130 | (util.Platform(76), "l"), # Starbound 131 | (util.Platform(108), "l"), 132 | (util.Platform(109), "m"), 133 | (util.Platform(111), "o"), 134 | (util.Platform(109), "o"), # Special Mac case 135 | (util.Platform(111), "m"), # Special Mac case 136 | (util.Platform(119), "w"), 137 | ]) 138 | def test_equality_character(self, platform, other): 139 | assert platform == other 140 | 141 | @pytest.mark.parametrize(("platform", "other"), [ 142 | (util.Platform(76), "Linux"), 143 | (util.Platform(108), "Linux"), 144 | (util.Platform(109), "Mac OS X"), 145 | (util.Platform(111), "Mac OS X"), 146 | (util.Platform(119), "Windows"), 147 | ]) 148 | def test_equality_string(self, platform, other): 149 | assert platform == other 150 | 151 | 152 | class TestServerType(object): 153 | 154 | @pytest.mark.parametrize("identifier", [100, 108, 112]) 155 | def test_valid_numeric_identifer(self, identifier): 156 | server_type = util.ServerType(identifier) 157 | assert server_type.value == identifier 158 | 159 | def test_invalid_numeric_identifier(self): 160 | with pytest.raises(ValueError): 161 | util.ServerType(42) 162 | 163 | @pytest.mark.parametrize(("identifier", "expected"), [ 164 | ("D", 68), 165 | ("d", 100), 166 | ("l", 108), 167 | ("p", 112), 168 | ]) 169 | def test_valid_character_identifier(self, identifier, expected): 170 | server_type = util.ServerType(identifier) 171 | assert server_type.value == expected 172 | 173 | def test_invalid_character_identifier(self): 174 | with pytest.raises(ValueError): 175 | util.ServerType("a") 176 | 177 | @pytest.mark.parametrize(("identifier", "expected"), [ 178 | ("dedicated", 100), 179 | ("Dedicated", 100), 180 | ("DEDICATED", 100), 181 | ("non-dedicated", 108), 182 | ("Non-Dedicated", 108), 183 | ("NON-DEDICATED", 108), 184 | ("sourcetv", 112), 185 | ("SourceTV", 112), 186 | ("SOURCETV", 112), 187 | ]) 188 | def test_valid_string_identifier(self, identifier, expected): 189 | server_type = util.ServerType(identifier) 190 | assert server_type.value == expected 191 | 192 | def test_invalid_string_identifier(self): 193 | with pytest.raises(ValueError): 194 | util.ServerType("snowman") 195 | 196 | def test_empty_string_identifier(self): 197 | with pytest.raises(ValueError): 198 | util.Platform("") 199 | 200 | @pytest.mark.parametrize(("identifier", "string"), [ 201 | (68, "Dedicated"), 202 | (100, "Dedicated"), 203 | (108, "Non-Dedicated"), 204 | (112, "SourceTV"), 205 | ]) 206 | def test_to_unicode(self, identifier, string): 207 | server_type = util.ServerType(identifier) 208 | assert six.text_type(server_type) == string 209 | 210 | @pytest.mark.parametrize(("identifier", "string"), [ 211 | (68, b"Dedicated"), 212 | (100, b"Dedicated"), 213 | (108, b"Non-Dedicated"), 214 | (112, b"SourceTV"), 215 | ]) 216 | def test_to_bytestring(self, identifier, string): 217 | server_type = util.ServerType(identifier) 218 | assert bytes(server_type) == string 219 | 220 | @pytest.mark.parametrize("identifier", [68, 100, 108, 112]) 221 | def test_to_integer(self, identifier): 222 | server_type = util.ServerType(identifier) 223 | assert int(server_type) == identifier 224 | 225 | @pytest.mark.parametrize(("server_type", "other"), [ 226 | (util.ServerType(68), util.ServerType(68)), 227 | (util.ServerType(68), util.ServerType(100)), # Starbound 228 | (util.ServerType(100), util.ServerType(68)), # Starbound 229 | (util.ServerType(100), util.ServerType(100)), 230 | (util.ServerType(108), util.ServerType(108)), 231 | (util.ServerType(112), util.ServerType(112)), 232 | ]) 233 | def test_equality(self, server_type, other): 234 | assert server_type == other 235 | 236 | @pytest.mark.parametrize(("server_type", "other"), [ 237 | (util.ServerType(68), 68), 238 | (util.ServerType(100), 68), # Starbound 239 | (util.ServerType(68), 100), # Starbound 240 | (util.ServerType(100), 100), 241 | (util.ServerType(108), 108), 242 | (util.ServerType(112), 112), 243 | ]) 244 | def test_equality_integer(self, server_type, other): 245 | assert server_type == other 246 | 247 | @pytest.mark.parametrize(("server_type", "other"), [ 248 | (util.ServerType(68), "D"), 249 | (util.ServerType(68), "D"), # Starbound 250 | (util.ServerType(100), "d"), 251 | (util.ServerType(108), "l"), 252 | (util.ServerType(112), "p"), 253 | ]) 254 | def test_equality_character(self, server_type, other): 255 | assert server_type == other 256 | 257 | @pytest.mark.parametrize(("server_type", "other"), [ 258 | (util.ServerType(68), "Dedicated"), 259 | (util.ServerType(100), "Dedicated"), 260 | (util.ServerType(108), "Non-Dedicated"), 261 | (util.ServerType(112), "SourceTV"), 262 | ]) 263 | def test_equality_string(self, server_type, other): 264 | assert server_type == other 265 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py34, pypy 3 | 4 | [testenv] 5 | deps = 6 | docopt>=0.6.2 7 | mock==1.0.1 8 | pytest>=3.6.0 9 | pytest-timeout 10 | commands = py.test tests/ 11 | -------------------------------------------------------------------------------- /valve/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Oliver Ainsworth 3 | 4 | from __future__ import (absolute_import, 5 | unicode_literals, print_function, division) 6 | -------------------------------------------------------------------------------- /valve/source/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2017 Oliver Ainsworth 3 | 4 | from __future__ import (absolute_import, 5 | unicode_literals, print_function, division) 6 | 7 | import functools 8 | import select 9 | import socket 10 | import warnings 11 | 12 | import six 13 | 14 | 15 | class NoResponseError(Exception): 16 | """Raised when a server querier doesn't receive a response.""" 17 | 18 | 19 | class QuerierClosedError(Exception): 20 | """Raised when attempting to use a querier after it's closed.""" 21 | 22 | 23 | class BaseQuerier(object): 24 | """Base class for implementing source server queriers. 25 | 26 | When an instance of this class is initialised a socket is created. 27 | It's important that, once a querier is to be discarded, the associated 28 | socket be closed via :meth:`close`. For example: 29 | 30 | .. code-block:: python 31 | 32 | querier = valve.source.BaseQuerier(('...', 27015)) 33 | try: 34 | querier.request(...) 35 | finally: 36 | querier.close() 37 | 38 | When server queriers are used as context managers, the socket will 39 | be cleaned up automatically. Hence it's preferably to use the `with` 40 | statement over the `try`-`finally` pattern described above: 41 | 42 | .. code-block:: python 43 | 44 | with valve.source.BaseQuerier(('...', 27015)) as querier: 45 | querier.request(...) 46 | 47 | Once a querier has been closed, any attempts to make additional requests 48 | will result in a :exc:`QuerierClosedError` to be raised. 49 | 50 | :ivar host: Host requests will be sent to. 51 | :ivar port: Port number requests will be sent to. 52 | :ivar timeout: How long to wait for a response to a request. 53 | """ 54 | 55 | def __init__(self, address, timeout=5.0): 56 | self.host = address[0] 57 | self.port = address[1] 58 | self.timeout = timeout 59 | self._contextual = False 60 | self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 61 | 62 | def __enter__(self): 63 | self._contextual = True 64 | return self 65 | 66 | def __exit__(self, type_, exception, traceback): 67 | self._contextual = False 68 | self.close() 69 | 70 | def _check_open(function): 71 | # Wrap methods to raise QuerierClosedError when called 72 | # after the querier has been closed. 73 | 74 | @functools.wraps(function) 75 | def wrapper(self, *args, **kwargs): 76 | if self._socket is None: 77 | raise QuerierClosedError 78 | return function(self, *args, **kwargs) 79 | 80 | return wrapper 81 | 82 | def close(self): 83 | """Close the querier's socket. 84 | 85 | It is safe to call this multiple times. 86 | """ 87 | if self._contextual: 88 | warnings.warn("{0.__class__.__name__} used as context " 89 | "manager but close called before exit".format(self)) 90 | if self._socket is not None: 91 | self._socket.close() 92 | self._socket = None 93 | 94 | @_check_open 95 | def request(self, *request): 96 | """Issue a request. 97 | 98 | The given request segments will be encoded and combined to 99 | form the final message that is sent to the configured address. 100 | 101 | :param request: Request message segments. 102 | :type request: valve.source.messages.Message 103 | 104 | :raises QuerierClosedError: If the querier has been closed. 105 | """ 106 | request_final = b"".join(segment.encode() for segment in request) 107 | self._socket.sendto(request_final, (self.host, self.port)) 108 | 109 | @_check_open 110 | def get_response(self): 111 | """Wait for a response to a request. 112 | 113 | :raises NoResponseError: If the configured :attr:`timeout` is 114 | reached before a response is received. 115 | :raises QuerierClosedError: If the querier has been closed. 116 | 117 | :returns: The raw response as a :class:`bytes`. 118 | """ 119 | ready = select.select([self._socket], [], [], self.timeout) 120 | if not ready[0]: 121 | raise NoResponseError("Timed out waiting for response") 122 | try: 123 | data = ready[0][0].recv(65536) 124 | except socket.error as exc: 125 | six.raise_from(NoResponseError(exc)) 126 | return data 127 | 128 | del _check_open 129 | -------------------------------------------------------------------------------- /valve/source/a2s.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013-2017 Oliver Ainsworth 3 | 4 | from __future__ import (absolute_import, 5 | unicode_literals, print_function, division) 6 | 7 | import monotonic 8 | 9 | import valve.source 10 | from . import messages 11 | 12 | 13 | # NOTE: backwards compatability; remove soon(tm) 14 | NoResponseError = valve.source.NoResponseError 15 | 16 | 17 | class ServerQuerier(valve.source.BaseQuerier): 18 | """Implements the A2S Source server query protocol. 19 | 20 | https://developer.valvesoftware.com/wiki/Server_queries 21 | 22 | .. note:: 23 | Instantiating this class creates a socket. Be sure to close the 24 | querier once finished with it. See :class:`valve.source.BaseQuerier`. 25 | """ 26 | 27 | def request(self, request): 28 | super(ServerQuerier, self).request( 29 | messages.Header(split=messages.NO_SPLIT), request) 30 | 31 | def get_response(self): 32 | 33 | data = valve.source.BaseQuerier.get_response(self) 34 | 35 | # According to https://developer.valvesoftware.com/wiki/Server_queries 36 | # "TF2 currently does not split replies, expect A2S_PLAYER and 37 | # A2S_RULES to be simply cut off after 1260 bytes." 38 | # 39 | # However whilst testing info() on a TF2 server, it did 40 | # set the split header to -2. So it is unclear whether the 41 | # warning means that only one fragment of the message is sent 42 | # or that the warning is no longer valid. 43 | 44 | response = messages.Header().decode(data) 45 | if response["split"] == messages.SPLIT: 46 | fragments = {} 47 | fragment = messages.Fragment.decode(response.payload) 48 | if fragment.is_compressed: 49 | raise NotImplementedError("Fragments are compressed") 50 | fragments[fragment["fragment_id"]] = fragment 51 | while len(fragments) < fragment["fragment_count"]: 52 | data = valve.source.BaseQuerier.get_response(self) 53 | fragment = messages.Fragment.decode( 54 | messages.Header.decode(data).payload) 55 | fragments[fragment["fragment_id"]] = fragment 56 | return b"".join([frag[1].payload for frag in 57 | sorted(fragments.items(), key=lambda f: f[0])]) 58 | return response.payload 59 | 60 | def ping(self): 61 | """Ping the server, returning the round-trip latency in milliseconds 62 | 63 | The A2A_PING request is deprecated so this actually sends a A2S_INFO 64 | request and times that. The time difference between the two should 65 | be negligble. 66 | """ 67 | 68 | time_sent = monotonic.monotonic() 69 | self.request(messages.InfoRequest()) 70 | messages.InfoResponse.decode(self.get_response()) 71 | time_received = monotonic.monotonic() 72 | return (time_received - time_sent) * 1000.0 73 | 74 | def info(self): 75 | """Retreive information about the server state 76 | 77 | This returns the response from the server which implements 78 | ``__getitem__`` for accessing response fields. For example: 79 | 80 | .. code:: python 81 | 82 | with ServerQuerier(...) as server: 83 | print(server.info()["server_name"]) 84 | 85 | The following fields are available on the response: 86 | 87 | +--------------------+------------------------------------------------+ 88 | | Field | Description | 89 | +====================+================================================+ 90 | | response_type | Always ``0x49`` | 91 | +--------------------+------------------------------------------------+ 92 | | server_name | The name of the server | 93 | +--------------------+------------------------------------------------+ 94 | | map | The name of the map being ran by the server | 95 | +--------------------+------------------------------------------------+ 96 | | folder | The *gamedir* if the modification being ran by | 97 | | | the server. E.g. ``tf``, ``cstrike``, ``csgo``.| 98 | +--------------------+------------------------------------------------+ 99 | | game | A string identifying the game being ran by the | 100 | | | server | 101 | +--------------------+------------------------------------------------+ 102 | | app_id | The numeric application ID of the game ran by | 103 | | | the server. Note that this is the app ID of the| 104 | | | client, not the server. For example, for Team | 105 | | | Fortress 2 ``440`` is returned instead of | 106 | | | ``232250`` which is the ID of the server | 107 | | | software. | 108 | +--------------------+------------------------------------------------+ 109 | | player_count | Number of players currently connected. | 110 | | | See :meth:`.players` for caveats about the | 111 | | | accuracy of this field. | 112 | +--------------------+------------------------------------------------+ 113 | | max_players | The number of player slots available. Note that| 114 | | | ``player_count`` may exceed this value under | 115 | | | certain circumstances. See :meth:`.players`. | 116 | +--------------------+------------------------------------------------+ 117 | | bot_count | The number of AI players present | 118 | +--------------------+------------------------------------------------+ 119 | | server_type | A :class:`.util.ServerType` instance | 120 | | | representing the type of server. E.g. | 121 | | | Dedicated, non-dedicated or Source TV relay. | 122 | +--------------------+------------------------------------------------+ 123 | | platform | A :class:`.util.Platform` instances | 124 | | | represneting the platform the server is running| 125 | | | on. E.g. Windows, Linux or Mac OS X. | 126 | +--------------------+------------------------------------------------+ 127 | | password_protected | Whether or not a password is required to | 128 | | | connect to the server. | 129 | +--------------------+------------------------------------------------+ 130 | | vac_enabled | Whether or not Valve anti-cheat (VAC) is | 131 | | | enabled | 132 | +--------------------+------------------------------------------------+ 133 | | version | The version string of the server software | 134 | +--------------------+------------------------------------------------+ 135 | 136 | Currently the *extra data field* (EDF) is not supported. 137 | """ 138 | 139 | self.request(messages.InfoRequest()) 140 | return messages.InfoResponse.decode(self.get_response()) 141 | 142 | def players(self): 143 | """Retrive a list of all players connected to the server 144 | 145 | The following fields are available on the response: 146 | 147 | +--------------------+------------------------------------------------+ 148 | | Field | Description | 149 | +====================+================================================+ 150 | | response_type | Always ``0x44`` | 151 | +--------------------+------------------------------------------------+ 152 | | player_count | The number of players listed | 153 | +--------------------+------------------------------------------------+ 154 | | players | A list of player entries | 155 | +--------------------+------------------------------------------------+ 156 | 157 | The ``players`` field is a list that contains ``player_count`` number 158 | of :class:`..messages.PlayerEntry` instances which have the same 159 | interface as the top-level response object that is returned. 160 | 161 | The following fields are available on each player entry: 162 | 163 | +--------------------+------------------------------------------------+ 164 | | Field | Description | 165 | +====================+================================================+ 166 | | name | The name of the player | 167 | +--------------------+------------------------------------------------+ 168 | | score | Player's score at the time of the request. | 169 | | | What this relates to is dependant on the | 170 | | | gamemode of the server. | 171 | +--------------------+------------------------------------------------+ 172 | | duration | Number of seconds the player has been | 173 | | | connected as a float | 174 | +--------------------+------------------------------------------------+ 175 | 176 | .. note:: 177 | Under certain circumstances, some servers may return a player 178 | list which contains empty ``name`` fields. This can lead to 179 | ``player_count`` being misleading. 180 | 181 | Filtering out players with empty names may yield a more 182 | accurate enumeration of players: 183 | 184 | .. code-block:: python 185 | 186 | with ServerQuerier(...) as query: 187 | players = [] 188 | for player in query.players()["players"]: 189 | if player["name"]: 190 | players.append(player) 191 | player_count = len(players) 192 | """ 193 | 194 | # TF2 and L4D2's A2S_SERVERQUERY_GETCHALLENGE doesn't work so 195 | # just use A2S_PLAYER to get challenge number which should work 196 | # fine for all servers 197 | self.request(messages.PlayersRequest(challenge=-1)) 198 | challenge = messages.GetChallengeResponse.decode(self.get_response()) 199 | self.request(messages.PlayersRequest(challenge=challenge["challenge"])) 200 | return messages.PlayersResponse.decode(self.get_response()) 201 | 202 | def rules(self): 203 | """Retreive the server's game mode configuration 204 | 205 | This method allows you capture a subset of a server's console 206 | variables (often referred to as 'cvars',) specifically those which 207 | have the ``FCVAR_NOTIFY`` flag set on them. These cvars are used to 208 | indicate game mode's configuration, such as the gravity setting for 209 | the map or whether friendly fire is enabled or not. 210 | 211 | The following fields are available on the response: 212 | 213 | +--------------------+------------------------------------------------+ 214 | | Field | Description | 215 | +====================+================================================+ 216 | | response_type | Always ``0x56`` | 217 | +--------------------+------------------------------------------------+ 218 | | rule_count | The number of rules | 219 | +--------------------+------------------------------------------------+ 220 | | rules | A dictionary mapping rule names to their | 221 | | | corresponding string value | 222 | +--------------------+------------------------------------------------+ 223 | """ 224 | 225 | self.request(messages.RulesRequest(challenge=-1)) 226 | challenge = messages.GetChallengeResponse.decode(self.get_response()) 227 | self.request(messages.RulesRequest(challenge=challenge["challenge"])) 228 | return messages.RulesResponse.decode(self.get_response()) 229 | -------------------------------------------------------------------------------- /valve/source/master_server.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013-2017 Oliver Ainsworth 3 | 4 | from __future__ import (absolute_import, 5 | unicode_literals, print_function, division) 6 | 7 | import enum 8 | import itertools 9 | 10 | import six 11 | 12 | import valve.source 13 | from . import messages 14 | from . import util 15 | 16 | 17 | REGION_US_EAST_COAST = 0x00 18 | REGION_US_WEST_COAST = 0x01 19 | REGION_SOUTH_AMERICA = 0x02 20 | REGION_EUROPE = 0x03 21 | REGION_ASIA = 0x04 22 | REGION_AUSTRALIA = 0x05 23 | REGION_MIDDLE_EAST = 0x06 24 | REGION_AFRICA = 0x07 25 | REGION_REST = 0xFF 26 | 27 | MASTER_SERVER_ADDR = ("hl2master.steampowered.com", 27011) 28 | 29 | 30 | class Duplicates(enum.Enum): 31 | """Behaviour for duplicate addresses. 32 | 33 | These values are intended to be used with :meth:`MasterServerQuerier.find` 34 | to control how duplicate addresses returned by the master server are 35 | treated. 36 | 37 | :cvar KEEP: All addresses are returned, even duplicates. 38 | :cvar SKIP: Skip duplicate addresses. 39 | :cvar STOP: Stop returning addresses when a duplicate is encountered. 40 | """ 41 | 42 | KEEP = "keep" 43 | SKIP = "skip" 44 | STOP = "stop" 45 | 46 | 47 | class MasterServerQuerier(valve.source.BaseQuerier): 48 | """Implements the Source master server query protocol 49 | 50 | https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol 51 | 52 | .. note:: 53 | Instantiating this class creates a socket. Be sure to close the 54 | querier once finished with it. See :class:`valve.source.BaseQuerier`. 55 | """ 56 | 57 | def __init__(self, address=MASTER_SERVER_ADDR, timeout=10.0): 58 | super(MasterServerQuerier, self).__init__(address, timeout) 59 | 60 | def __iter__(self): 61 | """An unfitlered iterator of all Source servers 62 | 63 | This will issue a request for an unfiltered set of server addresses 64 | for each region. Addresses are received in batches but returning 65 | a completely unfiltered set will still take a long time and be 66 | prone to timeouts. 67 | 68 | .. note:: 69 | If a request times out then the iterator will terminate early. 70 | Previous versions would propagate a :exc:`NoResponseError`. 71 | 72 | See :meth:`.find` for making filtered requests. 73 | """ 74 | return self.find(region="all") 75 | 76 | def _query(self, region, filter_string): 77 | """Issue a request to the master server 78 | 79 | Returns a generator which yields ``(host, port)`` addresses as 80 | returned by the master server. 81 | 82 | Addresses are returned in batches therefore multiple requests may be 83 | dispatched. Because of this any of these requests may result in a 84 | :exc:`NotResponseError` raised. In such circumstances the iterator 85 | will exit early. Otherwise the iteration continues until the final 86 | address is reached which is indicated by the master server returning 87 | a 0.0.0.0:0 address. 88 | 89 | .. note:: 90 | The terminating 0.0.0.0:0 is not yielded by the iterator. 91 | 92 | ``region`` should be a valid numeric region identifier and 93 | ``filter_string`` should be a formatted filter string as described 94 | on the Valve develper wiki: 95 | 96 | https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol#Filter 97 | """ 98 | last_addr = "0.0.0.0:0" 99 | first_request = True 100 | while first_request or last_addr != "0.0.0.0:0": 101 | first_request = False 102 | self.request(messages.MasterServerRequest( 103 | region=region, address=last_addr, filter=filter_string)) 104 | try: 105 | raw_response = self.get_response() 106 | except valve.source.NoResponseError: 107 | return 108 | else: 109 | response = messages.MasterServerResponse.decode(raw_response) 110 | for address in response["addresses"]: 111 | last_addr = "{}:{}".format( 112 | address["host"], address["port"]) 113 | if not address.is_null: 114 | yield address["host"], address["port"] 115 | 116 | def _deduplicate(self, method, query): 117 | """Deduplicate addresses in a :meth:`._query`. 118 | 119 | The given ``method`` should be a :class:`Duplicates` object. The 120 | ``query`` is an iterator as returned by :meth:`._query`. 121 | """ 122 | seen = set() 123 | if method is Duplicates.KEEP: 124 | for address in query: 125 | yield address 126 | else: 127 | for address in query: 128 | if address in seen: 129 | if method is Duplicates.SKIP: 130 | continue 131 | elif method is Duplicates.STOP: 132 | break 133 | yield address 134 | seen.add(address) 135 | 136 | def _map_region(self, region): 137 | """Convert string to numeric region identifier 138 | 139 | If given a non-string then a check is performed to ensure it is a 140 | valid region identifier. If it's not, ValueError is raised. 141 | 142 | Returns a list of numeric region identifiers. 143 | """ 144 | if isinstance(region, six.text_type): 145 | try: 146 | regions = { 147 | "na-east": [REGION_US_EAST_COAST], 148 | "na-west": [REGION_US_WEST_COAST], 149 | "na": [REGION_US_EAST_COAST, REGION_US_WEST_COAST], 150 | "sa": [REGION_SOUTH_AMERICA], 151 | "eu": [REGION_EUROPE], 152 | "as": [REGION_ASIA, REGION_MIDDLE_EAST], 153 | "oc": [REGION_AUSTRALIA], 154 | "af": [REGION_AFRICA], 155 | "rest": [REGION_REST], 156 | "all": [REGION_US_EAST_COAST, 157 | REGION_US_WEST_COAST, 158 | REGION_SOUTH_AMERICA, 159 | REGION_EUROPE, 160 | REGION_ASIA, 161 | REGION_AUSTRALIA, 162 | REGION_MIDDLE_EAST, 163 | REGION_AFRICA, 164 | REGION_REST], 165 | }[region.lower()] 166 | except KeyError: 167 | raise ValueError( 168 | "Invalid region identifer {!r}".format(region)) 169 | else: 170 | # Just assume it's an integer identifier, we'll validate below 171 | regions = [region] 172 | for reg in regions: 173 | if reg not in {REGION_US_EAST_COAST, 174 | REGION_US_WEST_COAST, 175 | REGION_SOUTH_AMERICA, 176 | REGION_EUROPE, 177 | REGION_ASIA, 178 | REGION_AUSTRALIA, 179 | REGION_MIDDLE_EAST, 180 | REGION_AFRICA, 181 | REGION_REST}: 182 | raise ValueError("Invalid region identifier {!r}".format(reg)) 183 | return regions 184 | 185 | def find(self, region="all", duplicates=Duplicates.SKIP, **filters): 186 | """Find servers for a particular region and set of filtering rules 187 | 188 | This returns an iterator which yields ``(host, port)`` server 189 | addresses from the master server. 190 | 191 | ``region`` spcifies what regions to restrict the search to. It can 192 | either be a ``REGION_`` constant or a string identifying the region. 193 | Alternately a list of the strings or ``REGION_`` constants can be 194 | used for specifying multiple regions. 195 | 196 | The following region identification strings are supported: 197 | 198 | +---------+-----------------------------------------+ 199 | | String | Region(s) | 200 | +=========+=========================================+ 201 | | na-east | East North America | 202 | +---------+-----------------------------------------+ 203 | | na-west | West North America | 204 | +---------+-----------------------------------------+ 205 | | na | East North American, West North America | 206 | +---------+-----------------------------------------+ 207 | | sa | South America | 208 | +---------+-----------------------------------------+ 209 | | eu | Europe | 210 | +---------+-----------------------------------------+ 211 | | as | Asia, the Middle East | 212 | +---------+-----------------------------------------+ 213 | | oc | Oceania/Australia | 214 | +---------+-----------------------------------------+ 215 | | af | Africa | 216 | +---------+-----------------------------------------+ 217 | | rest | Unclassified servers | 218 | +---------+-----------------------------------------+ 219 | | all | All of the above | 220 | +---------+-----------------------------------------+ 221 | 222 | .. note:: 223 | "``rest``" corresponds to all servers that don't fit with any 224 | other region. What causes a server to be placed in this region 225 | by the master server isn't entirely clear. 226 | 227 | The region strings are not case sensitive. Specifying an invalid 228 | region identifier will raise a ValueError. 229 | 230 | As well as region-based filtering, alternative filters are supported 231 | which are documented on the Valve developer wiki. 232 | 233 | https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol#Filter 234 | 235 | This method accepts keyword arguments which are used for building the 236 | filter string that is sent along with the request to the master server. 237 | Below is a list of all the valid keyword arguments: 238 | 239 | +------------+-------------------------------------------------------+ 240 | | Filter | Description | 241 | +============+=======================================================+ 242 | | type | Server type, e.g. "dedicated". This can be a | 243 | | | ``ServerType`` instance or any value that can be | 244 | | | converted to a ``ServerType``. | 245 | +------------+-------------------------------------------------------+ 246 | | secure | Servers using Valve anti-cheat (VAC). This should be | 247 | | | a boolean. | 248 | +------------+-------------------------------------------------------+ 249 | | gamedir | A string specifying the mod being ran by the server. | 250 | | | For example: ``tf``, ``cstrike``, ``csgo``, etc.. | 251 | +------------+-------------------------------------------------------+ 252 | | map | Which map the server is running. | 253 | +------------+-------------------------------------------------------+ 254 | | linux | Servers running on Linux. Boolean. | 255 | +------------+-------------------------------------------------------+ 256 | | empty | Servers which are not empty. Boolean. | 257 | +------------+-------------------------------------------------------+ 258 | | full | Servers which are full. Boolean. | 259 | +------------+-------------------------------------------------------+ 260 | | proxy | SourceTV relays only. Boolean. | 261 | +------------+-------------------------------------------------------+ 262 | | napp | Servers not running the game specified by the given | 263 | | | application ID. E.g. ``440`` would exclude all TF2 | 264 | | | servers. | 265 | +------------+-------------------------------------------------------+ 266 | | noplayers | Servers that are empty. Boolean | 267 | +------------+-------------------------------------------------------+ 268 | | white | Whitelisted servers only. Boolean. | 269 | +------------+-------------------------------------------------------+ 270 | | gametype | Server which match *all* the tags given. This should | 271 | | | be set to a list of strings. | 272 | +------------+-------------------------------------------------------+ 273 | | gamedata | Servers which match *all* the given hidden tags. | 274 | | | Only applicable for L4D2 servers. | 275 | +------------+-------------------------------------------------------+ 276 | | gamedataor | Servers which match *any* of the given hidden tags. | 277 | | | Only applicable to L4D2 servers. | 278 | +------------+-------------------------------------------------------+ 279 | 280 | .. note:: 281 | Your mileage may vary with some of these filters. There's no 282 | real guarantee that the servers returned by the master server will 283 | actually satisfy the filter. Because of this it's advisable to 284 | explicitly check for compliance by querying each server 285 | individually. See :mod:`valve.source.a2s`. 286 | 287 | The master server may return duplicate addresses. By default, these 288 | duplicates are excldued from the iterator returned by this method. 289 | See :class:`Duplicates` for controller this behaviour. 290 | """ 291 | if isinstance(region, (int, six.text_type)): 292 | regions = self._map_region(region) 293 | else: 294 | regions = [] 295 | for reg in region: 296 | regions.extend(self._map_region(reg)) 297 | filter_ = {} 298 | for key, value in six.iteritems(filters): 299 | if key in {"secure", "linux", "empty", 300 | "full", "proxy", "noplayers", "white"}: 301 | value = int(bool(value)) 302 | elif key in {"gametype", "gamedata", "gamedataor"}: 303 | value = [six.text_type(elt) 304 | for elt in value if six.text_type(elt)] 305 | if not value: 306 | continue 307 | value = ",".join(value) 308 | elif key == "napp": 309 | value = int(value) 310 | elif key == "type": 311 | if not isinstance(value, util.ServerType): 312 | value = util.ServerType(value).char 313 | else: 314 | value = value.char 315 | filter_[key] = six.text_type(value) 316 | # Order doesn't actually matter, but it makes testing easier 317 | filter_ = sorted(filter_.items(), key=lambda pair: pair[0]) 318 | filter_string = "\\".join([part for pair in filter_ for part in pair]) 319 | if filter_string: 320 | filter_string = "\\" + filter_string 321 | queries = [] 322 | for region in regions: 323 | queries.append(self._query(region, filter_string)) 324 | query = self._deduplicate( 325 | Duplicates(duplicates), itertools.chain.from_iterable(queries)) 326 | for address in query: 327 | yield address 328 | -------------------------------------------------------------------------------- /valve/source/util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2014-2017 Oliver Ainsworth 3 | 4 | from __future__ import (absolute_import, 5 | unicode_literals, print_function, division) 6 | 7 | import sys 8 | 9 | import six 10 | 11 | 12 | class Platform(object): 13 | """A Source server platform identifier 14 | 15 | This class provides utilities for representing Source server platforms 16 | as returned from a A2S_INFO request. Each platform is ultimately 17 | represented by one of the following integers: 18 | 19 | +-----+----------+ 20 | | ID | Platform | 21 | +=====+==========+ 22 | | 76 | Linux | 23 | +-----+----------+ 24 | | 108 | Linux | 25 | +-----+----------+ 26 | | 109 | Mac OS X | 27 | +-----+----------+ 28 | | 111 | Mac OS X | 29 | +-----+----------+ 30 | | 119 | Windows | 31 | +-----+----------+ 32 | 33 | .. note:: 34 | Starbound uses 76 instead of 108 for Linux in the old GoldSource 35 | style. 36 | """ 37 | 38 | def __init__(self, value): 39 | """Initialise the platform identifier 40 | 41 | The given ``value`` will be mapped to a numeric identifier. If the 42 | value is already an integer it must then it must exist in the table 43 | above else ValueError is returned. 44 | 45 | If ``value`` is a one character long string then it's ordinal value 46 | as given by ``ord()`` is used. Alternately the string can be either 47 | of the following: 48 | 49 | * Linux 50 | * Mac OS X 51 | * Windows 52 | """ 53 | if isinstance(value, six.text_type): 54 | if len(value) == 1: 55 | value = ord(value) 56 | else: 57 | value = { 58 | "linux": 108, 59 | "mac os x": 111, 60 | "windows": 119, 61 | }.get(value.lower()) 62 | if value is None: 63 | raise ValueError("Couldn't convert string {!r} to valid " 64 | "platform identifier".format(value)) 65 | if value not in {76, 108, 109, 111, 119}: 66 | raise ValueError("Invalid platform identifier {!r}".format(value)) 67 | self.value = value 68 | 69 | def __repr__(self): 70 | return "<{self.__class__.__name__} " \ 71 | "{self.value} '{self}'>".format(self=self) 72 | 73 | def __unicode__(self): 74 | return { 75 | 76: "Linux", 76 | 108: "Linux", 77 | 109: "Mac OS X", 78 | 111: "Mac OS X", 79 | 119: "Windows", 80 | }[self.value] 81 | 82 | if six.PY3: 83 | def __str__(self): 84 | return self.__unicode__() 85 | 86 | def __bytes__(self): 87 | return self.__unicode__().encode(sys.getdefaultencoding()) 88 | else: 89 | def __str__(self): 90 | return self.__unicode__().encode(sys.getdefaultencoding()) 91 | 92 | def __int__(self): 93 | return self.value 94 | 95 | def __eq__(self, other): 96 | """Check for equality between two platforms 97 | 98 | If ``other`` is not a Platform instance then an attempt is made to 99 | convert it to one using same approach as :meth:`__init__`. This means 100 | platforms can be compared against integers and strings. For example: 101 | 102 | .. code:: pycon 103 | 104 | >>>Platform(108) == "linux" 105 | True 106 | >>>Platform(109) == 109 107 | True 108 | >>>Platform(119) == "w" 109 | True 110 | 111 | Despite the fact there are two numerical identifers for Mac (109 and 112 | 111) comparing either of them together will yield ``True``. 113 | 114 | .. code:: pycon 115 | 116 | >>>Platform(109) == Platform(111) 117 | True 118 | """ 119 | if not isinstance(other, Platform): 120 | other = Platform(other) 121 | if self.value == 76 or self.value == 108: 122 | return other.value == 76 or other.value == 108 123 | elif self.value == 109 or self.value == 111: 124 | return other.value == 109 or other.value == 111 125 | else: 126 | return self.value == other.value 127 | 128 | @property 129 | def os_name(self): 130 | """Convenience mapping to names returned by ``os.name``""" 131 | return { 132 | 76: "posix", 133 | 108: "posix", 134 | 109: "posix", 135 | 111: "posix", 136 | 119: "nt", 137 | }[self.value] 138 | 139 | 140 | Platform.LINUX = Platform(108) 141 | Platform.MAC_OS_X = Platform(111) 142 | Platform.WINDOWS = Platform(119) 143 | 144 | 145 | class ServerType(object): 146 | """A Source server platform identifier 147 | 148 | This class provides utilities for representing Source server types 149 | as returned from a A2S_INFO request. Each server type is ultimately 150 | represented by one of the following integers: 151 | 152 | +-----+---------------+ 153 | | ID | Server type | 154 | +=====+===============+ 155 | | 68 | Dedicated | 156 | +-----+---------------+ 157 | | 100 | Dedicated | 158 | +-----+---------------+ 159 | | 108 | Non-dedicated | 160 | +-----+---------------+ 161 | | 112 | SourceTV | 162 | +-----+---------------+ 163 | 164 | .. note:: 165 | Starbound uses 68 instead of 100 for a dedicated server in the old 166 | GoldSource style. 167 | """ 168 | 169 | def __init__(self, value): 170 | """Initialise the server type identifier 171 | 172 | The given ``value`` will be mapped to a numeric identifier. If the 173 | value is already an integer it must then it must exist in the table 174 | above else ValueError is returned. 175 | 176 | If ``value`` is a one character long string then it's ordinal value 177 | as given by ``ord()`` is used. Alternately the string can be either 178 | of the following: 179 | 180 | * Dedicated 181 | * Non-Dedicated 182 | * SourceTV 183 | """ 184 | if isinstance(value, six.text_type): 185 | if len(value) == 1: 186 | value = ord(value) 187 | else: 188 | value = { 189 | "dedicated": 100, 190 | "non-dedicated": 108, 191 | "sourcetv": 112, 192 | }.get(value.lower()) 193 | if value is None: 194 | raise ValueError("Couldn't convert string {!r} to valid " 195 | "server type identifier".format(value)) 196 | if value not in {68, 100, 108, 112}: 197 | raise ValueError( 198 | "Invalid server type identifier {!r}".format(value)) 199 | self.value = value 200 | 201 | def __repr__(self): 202 | return "<{self.__class__.__name__} " \ 203 | "{self.value} '{self}'>".format(self=self) 204 | 205 | def __unicode__(self): 206 | return { 207 | 68: "Dedicated", 208 | 100: "Dedicated", 209 | 108: "Non-Dedicated", 210 | 112: "SourceTV", 211 | }[self.value] 212 | 213 | if six.PY3: 214 | def __str__(self): 215 | return self.__unicode__() 216 | 217 | def __bytes__(self): 218 | return self.__unicode__().encode(sys.getdefaultencoding()) 219 | else: 220 | def __str__(self): 221 | return self.__unicode__().encode(sys.getdefaultencoding()) 222 | 223 | def __int__(self): 224 | return self.value 225 | 226 | def __eq__(self, other): 227 | """Check for equality between two server types 228 | 229 | If ``other`` is not a ServerType instance then an attempt is made to 230 | convert it to one using same approach as :meth:`.__init__`. This means 231 | server types can be compared against integers and strings. For example: 232 | 233 | .. code:: pycon 234 | 235 | >>>Server(100) == "dedicated" 236 | True 237 | >>>Platform(108) == 108 238 | True 239 | >>>Platform(112) == "p" 240 | True 241 | """ 242 | if not isinstance(other, ServerType): 243 | other = ServerType(other) 244 | if self.value == 68 or self.value == 100: 245 | return other.value == 68 or other.value == 100 246 | else: 247 | return self.value == other.value 248 | 249 | @property 250 | def char(self): 251 | return chr(self.value) 252 | 253 | 254 | ServerType.DEDICATED = ServerType(100) 255 | ServerType.NON_DEDICATED = ServerType(108) 256 | ServerType.SOURCETV = ServerType(112) 257 | -------------------------------------------------------------------------------- /valve/steam/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Oliver Ainsworth 3 | 4 | from __future__ import (absolute_import, 5 | unicode_literals, print_function, division) 6 | -------------------------------------------------------------------------------- /valve/steam/api/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2014 Oliver Ainsworth 3 | 4 | from __future__ import (absolute_import, 5 | unicode_literals, print_function, division) 6 | -------------------------------------------------------------------------------- /valve/steam/api/interface.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2014 Oliver Ainsworth 3 | 4 | from __future__ import (absolute_import, 5 | unicode_literals, print_function, division) 6 | 7 | import collections 8 | import contextlib 9 | import functools 10 | import json 11 | import string 12 | import textwrap 13 | import types 14 | import warnings 15 | import xml.etree.ElementTree as etree 16 | 17 | import requests 18 | import six 19 | 20 | from ... import vdf 21 | 22 | 23 | API_RESPONSE_FORMATS = {"json", "vdf", "xml"} 24 | 25 | 26 | def api_response_format(format): 27 | if format not in API_RESPONSE_FORMATS: 28 | raise ValueError("Bad response format {!r}".format(format)) 29 | 30 | def decorator(function): 31 | 32 | @functools.wraps(function) 33 | def wrapper(response): 34 | return function(response) 35 | 36 | wrapper.format = format 37 | return wrapper 38 | 39 | return decorator 40 | 41 | 42 | @api_response_format("json") 43 | def json_format(response): 44 | """Parse response as JSON using the standard Python JSON parser 45 | 46 | :return: the JSON object encoded in the response. 47 | """ 48 | return json.loads(response) 49 | 50 | 51 | @api_response_format("xml") 52 | def etree_format(response): 53 | """Parse response using ElementTree 54 | 55 | :return: a :class:`xml.etree.ElementTree.Element` of the root element of 56 | the response. 57 | """ 58 | return etree.fromstring(response) 59 | 60 | 61 | @api_response_format("vdf") 62 | def vdf_format(response): 63 | """Parse response using :mod:`valve.vdf` 64 | 65 | :return: a dictionary decoded from the VDF. 66 | """ 67 | return vdf.loads(response) 68 | 69 | 70 | def uint32(value): 71 | """Validate a 'unit32' method parameter type""" 72 | value = int(value) 73 | if value > 4294967295: 74 | raise ValueError("{} exceeds upper bound for uint32".format(value)) 75 | if value < 0: 76 | raise ValueError("{} below lower bound for uint32".format(value)) 77 | return value 78 | 79 | 80 | def uint64(value): 81 | """Validate a 'unit64' method parameter type""" 82 | value = int(value) 83 | if value > 18446744073709551615: 84 | raise ValueError("{} exceeds upper bound for uint64".format(value)) 85 | if value < 0: 86 | raise ValueError("{} below lower bound for uint64".format(value)) 87 | return value 88 | 89 | 90 | def int32(value): 91 | """Validate a 'int32' method parameter type""" 92 | value = int(value) 93 | if value > 2147483647: 94 | raise ValueError("{} exceeds upper bound for int32".format(value)) 95 | if value < -2147483648: 96 | raise ValueError("{} below lower bound for int32".format(value)) 97 | 98 | 99 | PARAMETER_TYPES = { 100 | "string": str, 101 | "bool": bool, 102 | "uint32": uint32, 103 | "uint64": uint64, 104 | "int32": int32, 105 | "rawbinary": bytes, 106 | } 107 | 108 | 109 | class BaseInterface(object): 110 | 111 | def __init__(self, api): 112 | self._api = api 113 | 114 | def _request(self, http_method, method, version, params): 115 | return self._api.request(http_method, 116 | self.name, method, version, params) 117 | 118 | def __iter__(self): 119 | """An iterator of all interface methods 120 | 121 | Implemented by :func:`make_interface`. 122 | """ 123 | raise NotImplementedError 124 | 125 | 126 | def _ensure_identifier(name): 127 | """Convert ``name`` to a valid Python identifier 128 | 129 | Returns a valid Python identifier in the form ``[A-Za-z_][0-9A-Za-z_]*``. 130 | Any invalid characters are stripped away. Then all numeric leading 131 | characters are also stripped away. 132 | 133 | :raises NameError: if a valid identifier cannot be formed. 134 | """ 135 | # Note: the identifiers generated by this function must be safe 136 | # for use with eval() 137 | identifier = "".join(char for char in name 138 | if char in string.ascii_letters + string.digits + "_") 139 | try: 140 | while identifier[0] not in string.ascii_letters + "_": 141 | identifier = identifier[1:] 142 | except IndexError: 143 | raise NameError( 144 | "Cannot form valid Python identifier from {!r}".format(name)) 145 | return identifier 146 | 147 | 148 | class _MethodParameters(collections.OrderedDict): 149 | """Represents the parameters accepted by a Steam API interface method 150 | 151 | Parameters are sorted alphabetically by their name. 152 | """ 153 | 154 | def __init__(self, specs): 155 | unordered = {} 156 | for spec in specs: 157 | if spec["name"] == "key": 158 | # This is applied in API.request() 159 | continue 160 | spec["name"] = _ensure_identifier(spec["name"]) 161 | if spec["name"] in unordered: 162 | # Hopefully this will never happen ... 163 | raise NameError("Parameter name {!r} " 164 | "already in use".format(spec["name"])) 165 | if "description" not in spec: 166 | spec["description"] = "" 167 | if spec["type"] not in PARAMETER_TYPES: 168 | warnings.warn( 169 | "No parameter type handler for {!r}, interpreting " 170 | "as 'string'; however this may change in " 171 | "the future".format(spec["type"]), FutureWarning) 172 | spec["type"] = "string" 173 | unordered[spec["name"]] = spec 174 | super(_MethodParameters, self).__init__( 175 | sorted(unordered.items(), key=lambda a: a[0])) 176 | 177 | @property 178 | def signature(self): 179 | """Get the method signature as a string 180 | 181 | Firstly the the parameters are split into mandatory and optional groups. 182 | The mandatory fields with no default are always first so it's valid 183 | syntaxically. These are sorted alphabetically. The optional parameters 184 | follow with their default set to None. These are also sorted 185 | alphabetically. 186 | 187 | Includes the leading 'self' argument. 188 | """ 189 | signature = ["self"] 190 | optional = [] 191 | mandatory = [] 192 | for param in self.values(): 193 | if param["optional"]: 194 | optional.append(param) 195 | else: 196 | mandatory.append(param) 197 | signature.extend(param["name"] for param in mandatory) 198 | signature.extend(param["name"] + "=None" for param in optional) 199 | return ", ".join(signature) 200 | 201 | def validate(self, **kwargs): 202 | """Validate key-word arguments 203 | 204 | Validates and coerces arguments to the correct type when making the 205 | HTTP request. Optional parameters which are not given or are set to 206 | None are not included in the returned dictionary. 207 | 208 | :raises TypeError: if any mandatory arguments are missing. 209 | :return: a dictionary of parameters to be sent with the method request. 210 | """ 211 | values = {} 212 | for arg in self.values(): 213 | value = kwargs.get(arg["name"]) 214 | if value is None: 215 | if not arg["optional"]: 216 | # Technically the method signature protects against this 217 | # ever happening 218 | raise TypeError( 219 | "Missing mandatory argument {!r}".format(arg["name"])) 220 | else: 221 | continue 222 | values[arg["name"]] = PARAMETER_TYPES[arg["type"]](value) 223 | return values 224 | 225 | 226 | def make_method(spec): 227 | """Make an interface method 228 | 229 | This takes a dictionary like that is returned by 230 | ISteamWebAPIUtil/GetSupportedAPIList (in JSON) which describes a method 231 | for an interface. 232 | 233 | The specification is expected to have the following keys: 234 | 235 | * ``name`` 236 | * ``version`` 237 | * ``httpmethod`` 238 | * ``parameters`` 239 | """ 240 | spec["name"] = _ensure_identifier(spec["name"]) 241 | args = _MethodParameters(spec["parameters"]) 242 | 243 | def method(self, **kwargs): 244 | return self._request(spec["httpmethod"], spec["name"], 245 | spec["version"], args.validate(**kwargs)) 246 | 247 | # Do some eval() voodoo so we can rewrite the method signature. Otherwise 248 | # when something like autodoc sees it, it'll just output f(**kwargs) which 249 | # is really lame. _ensure_identifiers sanitises the function and 250 | # argument names it's safe. 251 | eval_globals = {} 252 | code = compile( 253 | textwrap.dedent(""" 254 | def {}({}): 255 | return method(**locals()) 256 | """.format(spec["name"], args.signature)), 257 | "", 258 | "exec", 259 | ) 260 | eval(code, {"method": method}, eval_globals) 261 | method = eval_globals[spec["name"]] 262 | method.version = spec["version"] 263 | method.name = spec["name"] 264 | method.__name__ = spec["name"] if six.PY3 else bytes(spec["name"]) 265 | param_docs = [] 266 | for arg, param_spec in args.items(): 267 | param_docs.append( 268 | ":param {type} {arg}: {description}".format(arg=arg, **param_spec)) 269 | method.__doc__ = "\n".join(param_docs) if param_docs else None 270 | return method 271 | 272 | 273 | def make_interface(spec, versions): 274 | """Build an interface class 275 | 276 | This takes an interface specification as returned by 277 | ``ISteamWebAPIUtil/GetSupportedAPIList`` and builds a :class:`BaseInterface` 278 | subclass from it. 279 | 280 | An interface specification may define methods which have multiple versions. 281 | If an entry for the method exists in ``versions`` then that version will be 282 | used. Otherwise the version of the method with the highest version will be. 283 | 284 | :param api_list: a JSON-decoded interface specification taken from a 285 | response to a ``ISteamWebAPIUtil/GetSupportedAPIList/v1`` request. 286 | :param versions: a dictionary of method versions to use for the interface. 287 | """ 288 | methods = {} 289 | max_versions = {} 290 | attrs = {"name": spec["name"], 291 | "__iter__": lambda self: iter(methods.values())} 292 | for method_spec in spec["methods"]: 293 | method = make_method(method_spec) 294 | # Take care to use method.name as it's make_method that has ultimate 295 | # authority over the naming of a method. 296 | pinned_version = versions.get(method.name) 297 | if pinned_version is None: 298 | # Version not pinned, so just use the highest one 299 | current_method = methods.get(method.name) 300 | if (current_method is not None 301 | and method.version < current_method.version): 302 | method = current_method 303 | methods[method.name] = method 304 | else: 305 | if method.version == pinned_version: 306 | methods[method.name] = method 307 | max_versions[method.name] = max(method.version, 308 | max_versions.get(method.name, 0)) 309 | for method in methods.values(): 310 | if method.version < max_versions[method.name]: 311 | warnings.warn( 312 | "{interface}/{meth.name} is pinned to version {meth.version}" 313 | " but the most recent version is {version}".format( 314 | interface=spec["name"], 315 | meth=method, 316 | version=max_versions[method.name] 317 | ), 318 | FutureWarning, 319 | ) 320 | attrs.update(methods) 321 | return type( 322 | spec["name"] if six.PY3 else bytes(spec["name"]), 323 | (BaseInterface,), 324 | attrs, 325 | ) 326 | 327 | 328 | def make_interfaces(api_list, versions): 329 | """Build a module of interface classes 330 | 331 | Takes a JSON response of ``ISteamWebAPIUtil/GetSupportedAPIList`` and 332 | builds a module of :class:`BaseInterface` subclasses for each listed 333 | interface. 334 | 335 | :param api_list: a JSON-decoded response to a 336 | ``ISteamWebAPIUtil/GetSupportedAPIList/v1`` request. 337 | :param versions: a dictionary of interface method versions. 338 | :return: a module of :class:`BaseInterface` subclasses. 339 | """ 340 | module = types.ModuleType("interfaces" if six.PY3 else b"interfaces") 341 | module.__all__ = [] 342 | for interface_spec in api_list["apilist"]["interfaces"]: 343 | interface = make_interface(interface_spec, 344 | versions.get(interface_spec["name"], {})) 345 | module.__all__.append(interface.__name__) 346 | setattr(module, interface.__name__, interface) 347 | return module 348 | 349 | 350 | class API(object): 351 | 352 | api_root = "https://api.steampowered.com/" 353 | 354 | def __init__(self, key=None, format="json", versions=None, interfaces=None): 355 | """Initialise an API wrapper 356 | 357 | The API is usable without an API key but exposes significantly less 358 | functionality, therefore it's advisable to use a key. 359 | 360 | Response formatters are callables which take the Unicode response from 361 | the Steam Web API and turn it into a more usable Python object, such as 362 | dictionary. The Steam API it self can generate responses in either 363 | JSON, XML or VDF. The formatter callables should have an attribute 364 | ``format`` which is a string indicating which textual format they 365 | handle. For convenience the ``format`` parameter also accepts the 366 | strings ``json``, ``xml`` and ``vdf`` which are mapped to the 367 | :func:`json_format`, :func:`etree_format` and :func:`vdf_format` 368 | formatters respectively. 369 | 370 | The ``interfaces`` argument can optionally be set to a module 371 | containing :class:`BaseInterface` subclasses which will be instantiated 372 | and bound to the :class:`API` instance. If not given then the 373 | interfaces are loaded using ``ISteamWebAPIUtil/GetSupportedAPIList``. 374 | 375 | The optional ``versions`` argument allows specific versions of interface 376 | methods to be used. If given, ``versions`` should be a mapping of 377 | further mappings keyed against the interface name. The inner mapping 378 | should specify the version of interface method to use which is keyed 379 | against the method name. These mappings don't need to be complete and 380 | can omit methods or even entire interfaces. In which case the default 381 | behaviour is to use the method with the highest version number. 382 | 383 | :param str key: a Steam Web API key. 384 | :param format: response formatter. 385 | :param versions: the interface method versions to use. 386 | :param interfaces: a module containing :class:`BaseInterface` 387 | subclasses or ``None`` if they should be loaded for the first time. 388 | """ 389 | self.key = key 390 | if format == "json": 391 | format = json_format 392 | elif format == "xml": 393 | format = etree_format 394 | elif format == "vdf": 395 | format = vdf_format 396 | self.format = format 397 | self._session = requests.Session() 398 | if interfaces is None: 399 | self._interfaces_module = make_interfaces( 400 | self.request("GET", "ISteamWebAPIUtil", 401 | "GetSupportedAPIList", 1, format=json_format), 402 | versions or {}, 403 | ) 404 | else: 405 | self._interfaces_module = interfaces 406 | self._bind_interfaces() 407 | 408 | def __getitem__(self, interface_name): 409 | """Get an interface instance by name""" 410 | return self._interfaces[interface_name] 411 | 412 | def _bind_interfaces(self): 413 | """Bind all interfaces to this API instance 414 | 415 | Instantiate all :class:`BaseInterface` subclasses in the 416 | :attr:`_interfaces_module` with a reference to this :class:`API` 417 | instance. 418 | 419 | Sets :attr:`_interfaces` to a dictionary mapping interface names to 420 | corresponding instances. 421 | """ 422 | self._interfaces = {} 423 | for name, interface in self._interfaces_module.__dict__.items(): 424 | try: 425 | if issubclass(interface, BaseInterface): 426 | self._interfaces[name] = interface(self) 427 | except TypeError: 428 | # Not a class 429 | continue 430 | 431 | def request(self, http_method, interface, 432 | method, version, params=None, format=None): 433 | """Issue a HTTP request to the Steam Web API 434 | 435 | This is called indirectly by interface methods and should rarely be 436 | called directly. The response to the request is passed through the 437 | response formatter which is then returned. 438 | 439 | :param str interface: the name of the interface. 440 | :param str method: the name of the method on the interface. 441 | :param int version: the version of the method. 442 | :param params: a mapping of GET or POST data to be sent with the 443 | request. 444 | :param format: a response formatter callable to overide :attr:`format`. 445 | """ 446 | if params is None: 447 | params = {} 448 | if format is None: 449 | format = self.format 450 | path = "{interface}/{method}/v{version}/".format(**locals()) 451 | if format.format not in API_RESPONSE_FORMATS: 452 | raise ValueError("Response formatter specifies its format as " 453 | "{!r}, but only 'json', 'xml' and 'vdf' " 454 | "are permitted values".format(format.format)) 455 | params["format"] = format.format 456 | if "key" in params: 457 | del params["key"] 458 | if self.key: 459 | params["key"] = self.key 460 | return format(self._session.request(http_method, 461 | self.api_root + path, params).text) 462 | 463 | @contextlib.contextmanager 464 | def session(self): 465 | """Create an API sub-session without rebuilding the interfaces 466 | 467 | This returns a context manager which yields a new :class:`API` instance 468 | with the same interfaces as the current one. The difference between 469 | this and creating a new :class:`API` manually is that this will avoid 470 | rebuilding the all interface classes which can be slow. 471 | """ 472 | yield API(self.key, self.format, self._interfaces_module) 473 | 474 | def __iter__(self): 475 | """An iterator of all bound API interfaces""" 476 | for interface in self._interfaces.values(): 477 | yield interface 478 | 479 | def versions(self): 480 | """Get the versions of the methods for each interface 481 | 482 | This returns a dictionary of dictionaries which is keyed against 483 | interface names. The inner dictionaries map method names to method 484 | version numbers. This structure is suitable for passing in as the 485 | ``versions`` argument to :meth:`__init__`. 486 | """ 487 | versions = {} 488 | for interface in self: 489 | method_versions = {} 490 | for method in interface: 491 | method_versions[method.name] = method.version 492 | versions[interface.name] = method_versions 493 | return versions 494 | -------------------------------------------------------------------------------- /valve/steam/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Oliver Ainsworth 3 | 4 | """ 5 | Provides an interface to the Steam client if present and running. 6 | 7 | Windows only. 8 | """ 9 | 10 | import itertools 11 | import _winreg as winreg 12 | import os 13 | 14 | AWAY = "away" 15 | BUSY = "busy" 16 | OFFLINE = "offline" 17 | ONLINE = "online" 18 | 19 | DOWNLOADS = "downloads" 20 | GAMES = "games" 21 | GAMES_DETAILS = "games/details" 22 | GAMES_GRID = "games/grid" 23 | GAMES_LIST = "games/list" 24 | GAMES_LIST_LARGE = "largegameslist" 25 | GAMES_LIST_MINI = "minigameslist" 26 | MEDIA = "media" 27 | TOOLS = "tools" 28 | ACTIVATE_PRODUCT = "activateproduct" 29 | REGISTER_PRODUCT = "registerproduct" 30 | FRIENDS = "friends" 31 | MAIN = "main" 32 | USER_MEDIA = "mymedia" 33 | NEWS = "news" 34 | SCREENSHOTS = "screenshots" 35 | SERVERS = "servers" 36 | SETTINGS = "settings" 37 | 38 | 39 | class SteamClient(object): 40 | """ 41 | Provides a means to interact with the current user's Steam 42 | client. 43 | 44 | It should be noted that most functionality that depends on the 45 | 'Steam browser protocol' is completely untested, and parts 46 | seemingly broken. Broken parts methods will noted in their 47 | docstrings but left in the hopes that Valve fix/reintroduce 48 | them. 49 | 50 | https://developer.valvesoftware.com/wiki/Steam_browser_protocol 51 | """ 52 | 53 | def __init__(self, **kwargs): 54 | 55 | # This flag is intended to be KEY_WOW64_64KEY or KEY_WOW_32KEY 56 | # from _winreg. I'm not entirely sure if it'd be possible to 57 | # detect whether this flag should be set automatically or 58 | # wether it needs to be here at all ... 59 | self.registry_access_flag = kwargs.get("registry_access_flag") 60 | 61 | def _get_registry_key(self, *args): 62 | args = list(itertools.chain(*[str(arg).split("\\") for arg in args])) 63 | sub_key = "Software\\Valve\Steam\\" + "\\".join(args[:-1]) 64 | if self.registry_access_flag is not None: 65 | access_flag = self.registry_access_flag | winreg.KEY_QUERY_VALUE 66 | else: 67 | access_flag = winreg.KEY_QUERY_VALUE 68 | with winreg.OpenKey(winreg.HKEY_CURRENT_USER, 69 | sub_key, 0, access_flag) as key: 70 | return winreg.QueryValueEx(key, args[-1])[0] 71 | 72 | def _startfile(self, *args): 73 | args = list(itertools.chain(*[str(arg).split("/") for arg in args])) 74 | os.startfile("steam://" + "/".join(args)) 75 | 76 | # TODO: def restart(self): 77 | # return # HKEY_CURRENT_USER\Software\Valve\Steam\Restart = 1 78 | 79 | @property 80 | def is_offline(self): 81 | return self._get_registry_key("Offline") 82 | 83 | @property 84 | def path(self): 85 | return self._get_registry_key("SteamPath") 86 | 87 | @property 88 | def executable_path(self): 89 | return self._get_registry_key("SteamExe") 90 | 91 | @property 92 | def last_name(self): 93 | return self._get_registry_key("LastGameNameUsed") 94 | 95 | @property 96 | def language(self): 97 | return self._get_registry_key("Language") 98 | 99 | @property 100 | def pid(self): 101 | return self._get_registry_key("ActiveProcess\\pid") 102 | 103 | @property 104 | def dll(self): 105 | return self._get_registry_key("ActiveProcess\\SteamClientDll") 106 | 107 | @property 108 | def dll64(self): 109 | return self._get_registry_key("ActiveProcess\\SteamClientDll64") 110 | 111 | @property 112 | def update_available(self): 113 | return self._get_registry_key("Steam.exe\\UpdateAvailable") 114 | 115 | @property 116 | def update_progress(self): 117 | return (self._get_registry_key("Steam.exe\\UpdateBytesDownloaded"), 118 | self._get_registry_key("Steam.exe\\UpdateBytesToDownload")) 119 | 120 | def is_installed(self, appid): 121 | return self._get_registry_key("Apps", appid, "Installed") 122 | 123 | def add_non_steam_game(self): 124 | self._startfile("AddNonSteamGame") 125 | 126 | def open_store_page(self, appid): 127 | self._startfile("store", appid) 128 | 129 | def accept_gift(self, pass_): 130 | self._startfile("ackmessage/ackGuestPass", pass_) 131 | 132 | def open_news_page(self, appid, latest_only=False): 133 | 134 | if latest_only: 135 | self._startfile("updatenews", appid) 136 | else: 137 | self._startfile("appnews", appid) 138 | 139 | def backup_wizard(self, appid): 140 | self._startfile("backup", appid) 141 | 142 | def browse_media(self): 143 | self._startfile("browsemedia") 144 | 145 | def check_requirements(self, appid): 146 | self._startfile("checksysreqs", appid) 147 | 148 | def connect(self, host, port=None, password=None): 149 | args = ["connect", host] 150 | if port is not None: 151 | args[0] = args[0] + ":" + str(port) 152 | if password is not None: 153 | args.append(password) 154 | self._startfile(*args) 155 | 156 | def defragment(self, appid): 157 | self._startfile("defrag", appid) 158 | 159 | def close(self): 160 | self._startfile("ExitSteam") 161 | 162 | def opens_friends_list(self): 163 | self._startfile("friends") 164 | 165 | # TODO: def add_friend(self, steamid): 166 | # return # steam://friends/add/ 167 | 168 | # steam://friends/friends/ 169 | # steam://friends/players 170 | 171 | # TODO: def join_chat(self, steamid): 172 | # return # steam://friends/joinchat/ 173 | 174 | # TODO: def send_message(self, steamid): 175 | # return # steam://friends/message/ 176 | 177 | def toggle_offline_friends(self): 178 | self._startfile("friends/settings/hideoffline") 179 | 180 | def toggle_friends_avatars(self): 181 | self._startfile("friends/settings/showavatars") 182 | 183 | def sort_friends(self): 184 | self._startfile("friends/settings/sortbyname") 185 | 186 | def set_status(self, status): 187 | self._startfile("friends/status", status) 188 | 189 | def flush_configs(self): 190 | self._startfile("flushconfig") 191 | 192 | def show_guest_passes(self): 193 | self._startfile("guestpasses") 194 | 195 | def install(self, appid): 196 | self._startfile("install", appid) 197 | 198 | def uninstall(self, appid): 199 | self._startfile("uninstall", appid) 200 | 201 | def install_addon(self, addon): 202 | self._startfile("installaddon", addon) 203 | 204 | def uninstall_addon(self, addon): 205 | self._startfile("removeaddon", addon) 206 | 207 | def navigate(self, component, *args, **kwargs): 208 | 209 | if kwargs.get("take_focus", False): 210 | self._startfile("open", component, *args) 211 | else: 212 | self._startfile("nav", component, *args) 213 | 214 | def validate(self, appid): 215 | self._startfile("validate", appid) 216 | 217 | def open_url(self, url): 218 | """ Broken """ 219 | self._startfile("openurl", url) 220 | 221 | def preload(self, appid): 222 | self._startfile("preload", appid) 223 | 224 | def open_publisher_catalogue(self, publisher): 225 | self._startfile("publisher", publisher) 226 | 227 | def purchase(self, appid): 228 | self._startfile("purchase", appid) 229 | 230 | def subscribe(self, appid): 231 | self._startfile("purchase/subscription", appid) 232 | 233 | def run(self, appid): 234 | self._startfile("run", appid) 235 | 236 | 237 | # TODO: runsafe, rungameid, subscriptioninstall, 238 | # support, takesurvey, url 239 | -------------------------------------------------------------------------------- /valve/steam/id.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013-2017 Oliver Ainsworth 3 | 4 | """Provides utilities for representing SteamIDs 5 | 6 | See: https://developer.valvesoftware.com/wiki/SteamID 7 | """ 8 | 9 | import re 10 | import warnings 11 | 12 | import six 13 | import six.moves.urllib.parse as urlparse 14 | 15 | 16 | UNIVERSE_INDIVIDUAL = 0 #: 17 | UNIVERSE_PUBLIC = 1 #: 18 | UNIVERSE_BETA = 2 #: 19 | UNIVERSE_INTERNAL = 3 #: 20 | UNIVERSE_DEV = 4 #: 21 | UNIVERSE_RC = 5 #: 22 | 23 | _universes = [ 24 | UNIVERSE_INDIVIDUAL, 25 | UNIVERSE_PUBLIC, 26 | UNIVERSE_BETA, 27 | UNIVERSE_INTERNAL, 28 | UNIVERSE_DEV, 29 | UNIVERSE_RC, 30 | ] 31 | 32 | TYPE_INVALID = 0 #: 33 | TYPE_INDIVIDUAL = 1 #: 34 | TYPE_MULTISEAT = 2 #: 35 | TYPE_GAME_SERVER = 3 #: 36 | TYPE_ANON_GAME_SERVER = 4 #: 37 | TYPE_PENDING = 5 #: 38 | TYPE_CONTENT_SERVER = 6 #: 39 | TYPE_CLAN = 7 #: 40 | TYPE_CHAT = 8 #: 41 | TYPE_P2P_SUPER_SEEDER = 9 #: 42 | TYPE_ANON_USER = 10 #: 43 | 44 | _types = [ 45 | TYPE_INVALID, 46 | TYPE_INDIVIDUAL, 47 | TYPE_MULTISEAT, 48 | TYPE_GAME_SERVER, 49 | TYPE_ANON_GAME_SERVER, 50 | TYPE_PENDING, 51 | TYPE_CONTENT_SERVER, 52 | TYPE_CLAN, 53 | TYPE_CHAT, 54 | TYPE_P2P_SUPER_SEEDER, 55 | TYPE_ANON_USER, 56 | ] 57 | 58 | type_letter_map = { 59 | TYPE_INDIVIDUAL: "U", 60 | TYPE_CLAN: "g", 61 | TYPE_CHAT: "T", 62 | } 63 | letter_type_map = {v: k for k, v in type_letter_map.items()} 64 | 65 | type_url_path_map = { 66 | TYPE_INDIVIDUAL: ["profiles", "id"], 67 | TYPE_CLAN: ["groups", "gid"], 68 | } 69 | 70 | # These shall be left as homage to Great Line-feed Drought of 2013 71 | textual_id_regex = re.compile(r"^STEAM_(?P\d+):(?P\d+):(?P\d+)$") 72 | community32_regex = re.compile(r".*/(?P{paths})/\[(?P[{type_chars}]):1:(?P\d+)\]$".format(paths="|".join("|".join(paths) for paths in type_url_path_map.values()), type_chars="".join(c for c in type_letter_map.values()))) 73 | community64_regex = re.compile(r".*/(?P{paths})/(?P\d+)$".format(paths="|".join("|".join(paths) for paths in type_url_path_map.values()))) 74 | 75 | 76 | class SteamIDError(ValueError): 77 | """Raised when parsing or building invalid SteamIDs""" 78 | pass 79 | 80 | 81 | class SteamID(object): 82 | """Represents a SteamID 83 | 84 | A SteamID is broken up into four components: a 32 bit account number, 85 | a 20 bit "instance" identifier, a 4 bit account type and an 8 bit 86 | "universe" identifier. 87 | 88 | There are 10 known accounts types as listed below. Generally you won't 89 | encounter types other than "individual" and "group". 90 | 91 | +----------------+---------+---------------+---------------------------+ 92 | | Type | Numeric | Can be mapped | Constant | 93 | | | value | to URL | | 94 | +================+=========+===============+===========================+ 95 | | Invalid | 0 | No | ``TYPE_INVALID`` | 96 | +----------------+---------+---------------+---------------------------+ 97 | | Individual | 1 | Yes | ``TYPE_INDIVIDUAL`` | 98 | +----------------+---------+---------------+---------------------------+ 99 | | Multiseat | 2 | No | ``TYPE_MULTISEAT`` | 100 | +----------------+---------+---------------+---------------------------+ 101 | | Game server | 3 | No | ``TYPE_GAME_SERVER`` | 102 | +----------------+---------+---------------+---------------------------+ 103 | | Anonymous game | 4 | No | ``TYPE_ANON_GAME_SERVER`` | 104 | | server | | | | 105 | +----------------+---------+---------------+---------------------------+ 106 | | Pending | 5 | No | ``TYPE_PENDING`` | 107 | +----------------+---------+---------------+---------------------------+ 108 | | Content server | 6 | No | ``TYPE_CONTENT_SERVER`` | 109 | +----------------+---------+---------------+---------------------------+ 110 | | Group | 7 | Yes | ``TYPE_CLAN`` | 111 | +----------------+---------+---------------+---------------------------+ 112 | | Chat | 8 | No | ``TYPE_CHAT`` | 113 | +----------------+---------+---------------+---------------------------+ 114 | | "P2P Super | 9 | No | ``TYPE_P2P_SUPER_SEEDER`` | 115 | | Seeder" | | | | 116 | +----------------+---------+---------------+---------------------------+ 117 | | Anonymous user | 10 | No | ``TYPE_ANON_USER`` | 118 | +----------------+---------+---------------+---------------------------+ 119 | 120 | 121 | ``TYPE_``-prefixed constants are provided by the :mod:`valve.steam.id` 122 | module for the numerical values of each type. 123 | 124 | All SteamIDs can be represented textually as well as by their numerical 125 | components. This is typically in the STEAM_X:Y:Z form where X, Y, Z are 126 | the "universe", "instance" and the account number respectively. There are 127 | two special cases however. If the account type if invalid then "UNKNOWN" 128 | is the textual representation. Similarly "STEAM_ID_PENDING" is used when 129 | the type is pending. 130 | 131 | As well as the the textual representation of SteamIDs there are also the 132 | 64 and 32 bit versions which contain the SteamID components encoded into 133 | integers of corresponding width. However the 32-bit representation also 134 | includes a letter to indicate account type. 135 | """ 136 | 137 | #: Used for building community URLs 138 | base_community_url = "http://steamcommunity.com/" 139 | 140 | @classmethod 141 | def from_community_url(cls, id, universe=UNIVERSE_INDIVIDUAL): 142 | """Parse a Steam community URL into a :class:`.SteamID` instance 143 | 144 | This takes a Steam community URL for a profile or group and converts 145 | it to a SteamID. The type of the ID is infered from the type character 146 | in 32-bit community urls (``[U:1:1]`` for example) or from the URL path 147 | (``/profile`` or ``/groups``) for 64-bit URLs. 148 | 149 | As there is no way to determine the universe directly from 150 | URL it must be expliticly set, defaulting to 151 | :data:`UNIVERSE_INDIVIDUAL`. 152 | 153 | Raises :class:`.SteamIDError` if the URL cannot be parsed. 154 | """ 155 | 156 | url = urlparse.urlparse(id) 157 | match = community32_regex.match(url.path) 158 | if match: 159 | if (match.group("path") not in 160 | type_url_path_map[letter_type_map[match.group("type")]]): 161 | warnings.warn("Community URL ({}) path doesn't " 162 | "match type character".format(url.path)) 163 | w = int(match.group("W")) 164 | y = w & 1 165 | z = (w - y) / 2 166 | return cls(z, y, letter_type_map[match.group("type")], universe) 167 | match = community64_regex.match(url.path) 168 | if match: 169 | w = int(match.group("W")) 170 | y = w & 1 171 | if match.group("path") in type_url_path_map[TYPE_INDIVIDUAL]: 172 | z = (w - y - 0x0110000100000000) / 2 173 | type = TYPE_INDIVIDUAL 174 | elif match.group("path") in type_url_path_map[TYPE_CLAN]: 175 | z = (w - y - 0x0170000000000000) / 2 176 | type = TYPE_CLAN 177 | return cls(z, y, type, universe) 178 | raise SteamIDError("Invalid Steam community URL ({})".format(url)) 179 | 180 | @classmethod 181 | def from_text(cls, id, type=TYPE_INDIVIDUAL): 182 | """Parse a SteamID in the STEAM_X:Y:Z form 183 | 184 | Takes a teaxtual SteamID in the form STEAM_X:Y:Z and returns 185 | a corresponding :class:`.SteamID` instance. The X represents the 186 | account's 'universe,' Z is the account number and Y is either 1 or 0. 187 | 188 | As the account type cannot be directly inferred from the SteamID 189 | it must be explicitly specified, defaulting to :data:`TYPE_INDIVIDUAL`. 190 | 191 | The two special IDs ``STEAM_ID_PENDING`` and ``UNKNOWN`` are also 192 | handled returning SteamID instances with the appropriate 193 | types set (:data:`TYPE_PENDING` and :data:`TYPE_INVALID` respectively) 194 | and with all other components of the ID set to zero. 195 | """ 196 | 197 | if id == "STEAM_ID_PENDING": 198 | return cls(0, 0, TYPE_PENDING, 0) 199 | if id == "UNKNOWN": 200 | return cls(0, 0, TYPE_INVALID, 0) 201 | match = textual_id_regex.match(id) 202 | if not match: 203 | raise SteamIDError("ID '{}' doesn't match format {}".format( 204 | id, textual_id_regex.pattern)) 205 | return cls( 206 | int(match.group("Z")), 207 | int(match.group("Y")), 208 | type, 209 | int(match.group("X")) 210 | ) 211 | 212 | def __init__(self, account_number, instance, type, universe): 213 | if universe not in _universes: 214 | raise SteamIDError("Invalid universe {}".format(universe)) 215 | if type not in _types: 216 | raise SteamIDError("Invalid type {}".format(type)) 217 | if account_number < 0 or account_number > (2**32) - 1: 218 | raise SteamIDError( 219 | "Account number ({}) out of range".format(account_number)) 220 | if instance not in [1, 0]: 221 | raise SteamIDError( 222 | "Expected instance to be 1 or 0, got {}".format(instance)) 223 | self.account_number = int(account_number) # Z 224 | self.instance = instance # Y 225 | self.type = type 226 | self.universe = universe # X 227 | 228 | @property 229 | def type_name(self): 230 | """The account type as a string""" 231 | 232 | return {v: k for k, v in six.iteritems(globals()) 233 | if k.startswith("TYPE_")}.get(self.type, self.type) 234 | 235 | def __str__(self): 236 | """The textual representation of the SteamID 237 | 238 | This is in the STEAM_X:Y:Z form and can be parsed by :meth:`.from_text` 239 | to produce an equivalent :class:`.` instance. Alternately 240 | ``STEAM_ID_PENDING`` or ``UNKNOWN`` may be returned if the account 241 | type is :data:`TYPE_PENDING` or :data:`TYPE_INVALID` respectively. 242 | 243 | .. note:: 244 | :meth:`.from_text` will still handle the ``STEAM_ID_PENDING`` and 245 | ``UNKNOWN`` cases. 246 | """ 247 | 248 | if self.type == TYPE_PENDING: 249 | return "STEAM_ID_PENDING" 250 | elif self.type == TYPE_INVALID: 251 | return "UNKNOWN" 252 | return "STEAM_{}:{}:{}".format(self.universe, 253 | self.instance, self.account_number) 254 | 255 | def __unicode__(self): 256 | return unicode(str(self)) 257 | 258 | def __int__(self): 259 | """The 64 bit representation of the SteamID 260 | 261 | 64 bit SteamIDs are only valid for those with the type 262 | :data:`TYPE_INDIVIDUAL` or :data:`TYPE_CLAN`. For all other types 263 | :class:`.SteamIDError` will be raised. 264 | 265 | The 64 bit representation is calculated by multiplying the account 266 | number by two then adding the "instance" and then adding another 267 | constant which varies based on the account type. 268 | 269 | For :data:`TYPE_INDIVIDUAL` the constant is ``0x0110000100000000``, 270 | whereas for :data:`TYPE_CLAN` it's ``0x0170000000000000``. 271 | """ 272 | 273 | if self.type == TYPE_INDIVIDUAL: 274 | return ((self.account_number * 2) + 275 | 0x0110000100000000 + self.instance) 276 | elif self.type == TYPE_CLAN: 277 | return ((self.account_number * 2) + 278 | 0x0170000000000000 + self.instance) 279 | raise SteamIDError("Cannot create 64-bit identifier for " 280 | "SteamID with type {}".format(self.type_name)) 281 | 282 | def __eq__(self, other): 283 | try: 284 | return (self.account_number == other.account_number and 285 | self.instance == other.instance and 286 | self.type == other.type and 287 | self.universe == other.universe) 288 | except AttributeError: 289 | return False # Should probably raise TypeError 290 | 291 | def __ne__(self, other): 292 | return not self == other 293 | 294 | def as_32(self): 295 | """Returns the 32 bit community ID as a string 296 | 297 | This is only applicable for :data:`TYPE_INDIVIDUAL`, 298 | :data:`TYPE_CLAN` and :data:`TYPE_CHAT` types. For any other types, 299 | attempting to generate the 32-bit representation will result in 300 | a :class:`.SteamIDError` being raised. 301 | """ 302 | 303 | try: 304 | return "[{}:1:{}]".format( 305 | type_letter_map[self.type], 306 | (self.account_number * 2) + self.instance 307 | ) 308 | except KeyError: 309 | raise SteamIDError("Cannot create 32-bit indentifier for " 310 | "SteamID with type {}".format(self.type_name)) 311 | 312 | def as_64(self): 313 | """Returns the 64 bit representation as a string 314 | 315 | This is only possible if the ID type is :data:`TYPE_INDIVIDUAL` or 316 | :data:`TYPE_CLAN`, otherwise :class:`.SteamIDError` is raised. 317 | """ 318 | 319 | return str(int(self)) 320 | 321 | def community_url(self, id64=True): 322 | """Returns the full URL to the Steam Community page for the SteamID 323 | 324 | This can either be generate a URL from the 64 bit representation 325 | (the default) or the 32 bit one. Generating community URLs is only 326 | supported for IDs of type :data:`TYPE_INDIVIDUAL` and 327 | :data:`TYPE_CLAN`. Attempting to generate a URL for any other type 328 | will result in a :class:`.SteamIDError` being raised. 329 | """ 330 | 331 | path_func = self.as_64 if id64 else self.as_32 332 | try: 333 | return urlparse.urljoin( 334 | self.__class__.base_community_url, 335 | "/".join((type_url_path_map[self.type][0], path_func())) 336 | ) 337 | except KeyError: 338 | raise SteamIDError( 339 | "Cannot generate community URL for type {}".format( 340 | self.type_name)) 341 | -------------------------------------------------------------------------------- /valve/testing.py: -------------------------------------------------------------------------------- 1 | """Utilities for testing.""" 2 | 3 | import copy 4 | import functools 5 | import select 6 | 7 | import six.moves.socketserver as socketserver 8 | 9 | import valve.rcon 10 | 11 | 12 | class UnexpectedRCONMessage(Exception): 13 | """Raised when an RCON request wasn't expected.""" 14 | 15 | 16 | class ExpectedRCONMessage(valve.rcon.RCONMessage): 17 | """Request expected by :class:`TestRCONServer`. 18 | 19 | This class should not be instantiated directly. Instead use the 20 | :meth:`TestRCONServer.expect` factory to create them. 21 | 22 | Instances of this class can be configured to respond to the request 23 | using :meth:`respond`, :meth:`response_close`, etc.. 24 | """ 25 | 26 | def __init__(self, id_, type_, body): 27 | valve.rcon.RCONMessage.__init__(self, id_, type_, body) 28 | self.responses = [] 29 | 30 | def respond(self, id_, type_, body): 31 | """Respond to the request with a message. 32 | 33 | The parameters for this method are the same as those given to 34 | the initialise of :class:`valve.rcon.RCONMessage`. The created 35 | message will be encoded and sent to the client. 36 | """ 37 | response = functools.partial( 38 | _TestRCONHandler.send_message, 39 | message=valve.rcon.RCONMessage(id_, type_, body), 40 | ) 41 | self.responses.append(response) 42 | 43 | def respond_close(self): 44 | """Respond by closing the connection.""" 45 | self.responses.append(_TestRCONHandler.close) 46 | 47 | def respond_terminate_multi_part(self, id_): 48 | """Respond by sending a multi-part message terminator. 49 | 50 | :class:`valve.rcon.RCON` always expects multi-part responses so you 51 | must configure one of these responses whenver you :meth:`respond` 52 | with a :class:`valve.rcon.RCONMessage.Type.RESPONSE_VALVE`-type 53 | message. 54 | """ 55 | self.respond( 56 | id_, valve.rcon.RCONMessage.Type.RESPONSE_VALUE, b"") 57 | self.respond( 58 | id_, 59 | valve.rcon.RCONMessage.Type.RESPONSE_VALUE, 60 | b"\x00\x01\x00\x00", 61 | ) 62 | 63 | 64 | class _TestRCONHandler(socketserver.BaseRequestHandler): 65 | """Request handler for :class:`TestRCONServer`.""" 66 | 67 | def _decode_messages(self): 68 | """Decode buffer into discrete RCON messages. 69 | 70 | This may consume the buffer, either in whole or part. 71 | 72 | :returns: an iterator of :class:`valve.rcon.RCONMessage`s. 73 | """ 74 | while self._buffer: 75 | try: 76 | message, self._buffer = \ 77 | valve.rcon.RCONMessage.decode(self._buffer) 78 | except valve.rcon.RCONMessageError: 79 | return 80 | else: 81 | yield message 82 | 83 | def _handle_request(self, message): 84 | """Handle individual RCON requests. 85 | 86 | Given a RCON request this will check that it matches the next 87 | expected request by comparing the request's ID, type and body 88 | attributes. If they all match, then each of the responses 89 | configured for the request is called. 90 | 91 | :param valve.rcon.RCONMessage: the request to handle. 92 | 93 | :raises UnexpectedRCONMessage: if given message does not match 94 | the expected request. 95 | """ 96 | if not self._expectations: 97 | raise UnexpectedRCONMessage( 98 | "Unexpected message {}".format(message)) 99 | expected = self._expectations.pop(0) 100 | for attribute in ['id', 'type', 'body']: 101 | a_message = getattr(message, attribute) 102 | a_expected = getattr(expected, attribute) 103 | if a_message != a_expected: 104 | raise UnexpectedRCONMessage( 105 | "Expected {} == {!r}, got {!r}".format( 106 | attribute, a_expected, a_message)) 107 | for response in expected.responses: 108 | response(self) 109 | 110 | def send_message(self, message): 111 | self.request.sendall(message.encode()) 112 | 113 | def close(self): 114 | self.request.close() 115 | 116 | def setup(self): 117 | self._buffer = b"" 118 | self._expectations = self.server.expectations() 119 | 120 | def handle(self): 121 | """Handle incoming requests. 122 | 123 | This will continually read incoming requests from the connected 124 | socket assigned to this handler. If the connected client closes 125 | the connection this method will exit. 126 | """ 127 | while True: 128 | ready, _, _ = select.select([self.request], [], [], 0) 129 | if ready: 130 | received = self.request.recv(4096) 131 | if not received: 132 | return 133 | self._buffer += received 134 | try: 135 | for message in self._decode_messages(): 136 | self._handle_request(message) 137 | except UnexpectedRCONMessage: 138 | return 139 | 140 | 141 | class TestRCONServer(socketserver.TCPServer): 142 | """Stub RCON server for testing. 143 | 144 | This class provides a simple RCON server which can be configured to 145 | respond to requests in certain ways. The idea is that this can be used 146 | in testing to fake the responses from a real RCON server. 147 | 148 | Specifically, each instance of this server can be configured to 149 | :meth:`expect` requests in a certain order. For each expected request 150 | there can be any number of responses for it. Each connection to the 151 | server will expect the exact same requests. 152 | 153 | All expected requests should be configured *before* connecting the 154 | client to the server. 155 | 156 | :param address: the address the server should bind to. By default it 157 | will use a random port on all interfaces. In such cases the actual 158 | address in use can be retrieved via the :attr:`server_address` 159 | attribute. 160 | """ 161 | 162 | def __init__(self, address=('', 0)): 163 | socketserver.TCPServer.__init__(self, ('', 0), _TestRCONHandler) 164 | self._expectations = [] 165 | 166 | def expect(self, id_, type_, body): 167 | """Expect a RCON request. 168 | 169 | The parameters for this method are the same as those passed to the 170 | initialiser of :class:`ExpectedRCONMessage`. 171 | 172 | :returns: the corresponding :class:`ExpectedRCONMessage`. 173 | """ 174 | self._expectations.append(ExpectedRCONMessage(id_, type_, body)) 175 | return self._expectations[-1] 176 | 177 | def expectations(self): 178 | """Get a copy of all the expectations. 179 | 180 | :returns: a deep copy of all the :class:`ExpectedRCONMessage` 181 | configured for the server. 182 | """ 183 | return copy.deepcopy(self._expectations) 184 | -------------------------------------------------------------------------------- /valve/vdf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (C) 2013 Oliver Ainsworth 3 | 4 | """ 5 | Implements a parser for the Valve Data Format (VDF,) or as often 6 | refered KeyValues. 7 | 8 | Currently only provides parsing functionality without the ability 9 | to serialise. API designed to mirror that of the built-in JSON 10 | module. 11 | 12 | https://developer.valvesoftware.com/wiki/KeyValues 13 | """ 14 | 15 | import string 16 | import re 17 | 18 | _KV_KEY = 0 19 | _KV_BLOCK = 1 20 | _KV_BLOCKEND = 2 21 | _KV_PAIR = 3 22 | 23 | ALWAYS = 0 24 | UNQUOTED = 1 25 | NEVER = 2 26 | 27 | 28 | def coerce_type(token): 29 | """ 30 | Attempts to convert a token to a native Python object by 31 | matching it against various regexes. 32 | 33 | Will silently fall back to string if no conversion can be made. 34 | 35 | Currently only capable of converting integers and floating point 36 | numbers. 37 | """ 38 | 39 | regexes = [ 40 | # regex, converter 41 | (r"^-?[0-9]+$", int), 42 | (r"^[-+]?[0-9]*\.?[0-9]+$", float), 43 | # TODO: ("rgb", pass), 44 | # TODO: ("hex triplet", pass), 45 | ] 46 | for regex, converter in regexes: 47 | print(regex, converter, token, re.match(regex, token, re.UNICODE)) 48 | if re.match(regex, token, re.UNICODE): 49 | return converter(token) 50 | # Fallback to string 51 | return token 52 | 53 | 54 | # Largely based on necavi's https://github.com/necavi/py-keyvalues 55 | def loads(src, encoding=None, coerce_=UNQUOTED): 56 | """ 57 | Loades a VDF string into a series of nested dictionaries. 58 | 59 | encoding -- The encoding of the given source string if not 60 | Unicode. If this is not set and a bytestring is 61 | given, ASCII will be the assumed encoding. 62 | 63 | corece_ -- can be set to determine whether an attempt should 64 | be made to convert values to native Python type 65 | equivalents. 66 | 67 | If set to UNQUOTED (default,) only values that 68 | are not enclosed in double quotes will be 69 | converted. 70 | 71 | If set to ALWAYS, will attempt to convert 72 | regardless of whether the value is quoted or not. 73 | not recommended. 74 | 75 | If set to NEVER, no attempt will be made to 76 | convert. Should produce most reliable behaviour. 77 | """ 78 | 79 | if isinstance(src, str) and encoding is None: 80 | encoding = "ascii" 81 | if encoding is not None: 82 | src = src.decode(encoding) 83 | # else: 84 | # assume unicode 85 | # pair type, pair key, pair value, coerce 86 | pairs = [[_KV_BLOCK, "", None, False]] 87 | # _KV_KEY -- all tokens begin as this 88 | # _KV_BLOCK -- is for when a _KV_KEY is followed by a { 89 | # _KV_PAIR -- is for when a _KV_KEY is followed by another token 90 | extended_alphanumeric = set( 91 | string.ascii_letters.decode("ascii") + 92 | unicode(string.digits) + 93 | u".-_") 94 | i = 0 95 | line = 1 96 | col = 0 97 | token = None 98 | try: 99 | while i < len(src): 100 | char = src[i] 101 | # Whitespace 102 | if char in {u" ", u"\t"}: 103 | pass 104 | # End-of-line 105 | elif char == u"\n": 106 | try: 107 | if src[i+1] == u"\r": # Will IndexError at EOF 108 | i += 1 109 | col += 1 110 | line += 1 111 | col = 0 112 | except IndexError: 113 | pass 114 | # End-of-line 115 | elif char == u"\r": 116 | try: 117 | if src[i+1] == u"\n": # Will IndexError at EOF 118 | i += 1 119 | col += 1 120 | line += 1 121 | col = 0 122 | except IndexError: 123 | pass 124 | # Double-quotes enclosed token 125 | elif char == u"\"": 126 | 127 | token = u"" 128 | while True: 129 | i += 1 130 | col += 1 131 | char = src[i] 132 | # I don't agree with the assertion in py-keyvalues 133 | # that \n or \r should also terminate a token if 134 | # its quoted. 135 | if char == u"\"": 136 | break 137 | elif char in {"\r", "\n"}: 138 | raise SyntaxError("End-of-line quoted token") 139 | elif char == u"\\": 140 | i += 1 141 | try: 142 | escaped_char = src[i] 143 | except IndexError: 144 | raise SyntaxError("EOF in escaped character") 145 | try: 146 | char = { 147 | u"n": u"\n", 148 | u"r": u"\r", 149 | u"t": u"\t", 150 | u"\"": u"\"", 151 | u"\\": u"\\", 152 | }[escaped_char] 153 | except KeyError: 154 | raise SyntaxError("Invalid escape character") 155 | token += char 156 | if pairs[-1][0] == _KV_KEY: 157 | pairs[-1][0] = _KV_PAIR 158 | pairs[-1][2] = token 159 | pairs[-1][3] = coerce_ in [ALWAYS] 160 | else: 161 | pairs.append([_KV_KEY, token, None, False]) 162 | # Unquoted token 163 | elif char in extended_alphanumeric: 164 | token = u"" 165 | while True: 166 | token += char 167 | i += 1 168 | col += 1 169 | char = src[i] 170 | if char not in extended_alphanumeric: 171 | # Assume end of token; in most cases this will 172 | # white space or a new line 173 | 174 | # If newline, rewind 1 char so it can be 175 | # properly handled by the end-of-line processors 176 | if char in {u"\n", u"\r"}: 177 | i -= 1 178 | col -= 1 179 | char = src[i] 180 | break 181 | if pairs[-1][0] == _KV_KEY: 182 | pairs[-1][0] = _KV_PAIR 183 | pairs[-1][2] = token 184 | pairs[-1][3] = coerce_ in [ALWAYS, UNQUOTED] 185 | else: 186 | pairs.append([_KV_KEY, token, None, False]) 187 | # I don't know if there are any cases where an unquoted 188 | # key may be illegal, e.g. if it contains only digits. 189 | # I assume it is, but I won't handle it for now. 190 | # Block start 191 | elif char == u"{": 192 | if pairs[-1][0] != _KV_KEY: 193 | raise SyntaxError("Block doesn't follow block name") 194 | pairs[-1][0] = _KV_BLOCK 195 | elif char == u"}": 196 | pairs.append([_KV_BLOCKEND, None, None, False]) 197 | else: 198 | raise SyntaxError("Unexpected character") 199 | i += 1 200 | col += 1 201 | except SyntaxError as exc: 202 | raise ValueError("{} '{}'; line {} column {}".format( 203 | exc.message, src[i], line, col)) 204 | dict_ = {} 205 | dict_stack = [dict_] 206 | CURRENT = -1 207 | PREVIOUS = -2 208 | for type, key, value, should_coerce in pairs[1:]: 209 | if type == _KV_BLOCK: 210 | dict_stack.append({}) 211 | dict_stack[PREVIOUS][key] = dict_stack[CURRENT] 212 | elif type == _KV_BLOCKEND: 213 | dict_stack = dict_stack[:CURRENT] 214 | elif type == _KV_PAIR: 215 | dict_stack[CURRENT][key] = (coerce_type(value) if 216 | should_coerce else value) 217 | # else: 218 | # should never occur, but would be caused by a token not being 219 | # followed by a block or value 220 | return dict_ 221 | 222 | 223 | def load(fp, encoding=None, coerce_=UNQUOTED): 224 | """ 225 | Same as loads but takes a file-like object as the source. 226 | """ 227 | return loads(fp.read(), encoding, coerce_) 228 | 229 | 230 | def dumps(obj, encoding=None, indent=u" ", object_encoders={}): 231 | """ 232 | Serialises a series of nested dictionaries to the VDF/KeyValues 233 | format and returns it as a string. 234 | 235 | If 'encoding' isn't specified a Unicode string will be returned, 236 | else an ecoded bytestring will be. 237 | 238 | 'indent' is the string to be used to indent nested blocks. The 239 | string given should be Unicode and represent one level of 240 | indentation. Four spaces by default. 241 | 242 | 'object_encoders' maps a series of types onto serialisers, which 243 | convert objects to their VDF equivalent. If no encoder is 244 | specified for a type it'll fall back to using __unicode__. 245 | Note that currently this likely causes None to be encoded 246 | incorrectly. Also, floats which include the exponent in their 247 | textual representaiton may also be 'wrong.' 248 | """ 249 | 250 | object_codecs = { 251 | float: lambda v: unicode(repr(v / 1.0)), 252 | } 253 | object_codecs.update(object_encoders) 254 | # I don't know how TYPE_NONE (None) are meant to be encoded so we 255 | # just use unicode() until it's known. 256 | lines = [] 257 | 258 | def recurse_obj(obj, indent_level=0): 259 | ind = indent * indent_level 260 | for key, value in obj.iteritems(): 261 | if isinstance(value, dict): 262 | lines.append(u"{}\"{}\"".format(ind, key)) 263 | lines.append(u"{}{{".format(ind)) 264 | recurse_obj(value, indent_level + 1) 265 | lines.append(u"{}}}".format(ind)) 266 | else: 267 | lines.append(u"{}\"{}\"{}\"{}\"".format( 268 | ind, 269 | key, 270 | indent, 271 | object_codecs.get(type(value), unicode)(value), 272 | )) 273 | 274 | recurse_obj(obj) 275 | if encoding is not None: 276 | return u"\n".join(lines).encode(encoding) 277 | else: 278 | return u"\n".join(lines) 279 | 280 | 281 | def dump(obj, fp, encoding, indent=u" ", object_encoders={}): 282 | """ 283 | Same as dumps but takes a file-like object 'fp' which will be 284 | written to. 285 | """ 286 | 287 | return fp.write(dumps(obj, encoding, indent, object_encoders)) 288 | --------------------------------------------------------------------------------