├── .editorconfig ├── .flake8 ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ └── build-test-lint.yml ├── .gitignore ├── .readthedocs.yml ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── Test.ipynb ├── berserk ├── __init__.py ├── clients.py ├── enums.py ├── exceptions.py ├── formats.py ├── models.py ├── session.py ├── todo.md └── utils.py ├── codecov.yml ├── docs ├── Makefile ├── _static │ └── css │ │ └── custom.css ├── announcing.rst ├── api.rst ├── authors.rst ├── conf.py ├── contributing.rst ├── history.rst ├── index.rst ├── installation.rst ├── make.bat ├── readme.rst └── usage.rst ├── pyproject.toml ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── test_formats.py ├── test_models.py ├── test_session.py └── test_utils.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | count = True 3 | max-complexity = 10 4 | show-source = True 5 | statistics = True 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * berserk version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.github/workflows/build-test-lint.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Build, test, lint 5 | 6 | on: 7 | - push 8 | - pull_request 9 | 10 | jobs: 11 | 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: ["3.7", "3.8", "3.9", "3.10"] 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v3 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | python -m pip install tox 29 | - name: Test with tox 30 | run: tox -e py 31 | - name: "Upload coverage to Codecov" 32 | uses: "codecov/codecov-action@v2" 33 | with: 34 | fail_ci_if_error: false 35 | flake8: 36 | runs-on: ubuntu-latest 37 | 38 | steps: 39 | - uses: actions/checkout@v3 40 | - name: Set up Python 3.10 41 | uses: actions/setup-python@v3 42 | with: 43 | python-version: '3.10' 44 | - name: Install dependencies 45 | run: | 46 | python -m pip install --upgrade pip 47 | python -m pip install tox 48 | - name: Test with tox 49 | run: tox -e flake8 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | pip-wheel-metadata/ 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # dotenv 85 | .env 86 | 87 | # virtualenv 88 | .venv 89 | venv/ 90 | ENV/ 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | .spyproject 95 | 96 | # Rope project settings 97 | .ropeproject 98 | 99 | # mkdocs documentation 100 | /site 101 | 102 | # mypy 103 | .mypy_cache/ 104 | 105 | # idea 106 | .idea/ 107 | .vscode/ 108 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | build: 2 | image: latest 3 | 4 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Authors 3 | ======= 4 | 5 | Development Lead 6 | ================ 7 | 8 | * Robert Grant 9 | 10 | Developers 11 | ========== 12 | 13 | * Robert Graham 14 | 15 | Contributors 16 | ============ 17 | 18 | * Harald Klein 19 | * *your name here* :) 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | Contributing 4 | ============ 5 | 6 | Contributions are welcome, and they are greatly appreciated! Every little bit 7 | helps, and credit will always be given. 8 | 9 | You can contribute in many ways: 10 | 11 | Types of Contributions 12 | ---------------------- 13 | 14 | Report Bugs 15 | ~~~~~~~~~~~ 16 | 17 | Report bugs at https://github.com/rhgrant10/berserk/issues. 18 | 19 | If you are reporting a bug, please include: 20 | 21 | * Your operating system name and version. 22 | * Any details about your local setup that might be helpful in troubleshooting. 23 | * Detailed steps to reproduce the bug. 24 | 25 | Fix Bugs 26 | ~~~~~~~~ 27 | 28 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 29 | wanted" is open to whoever wants to implement it. 30 | 31 | Implement Features 32 | ~~~~~~~~~~~~~~~~~~ 33 | 34 | Look through the GitHub issues for features. Anything tagged with "enhancement" 35 | and "help wanted" is open to whoever wants to implement it. 36 | 37 | Write Documentation 38 | ~~~~~~~~~~~~~~~~~~~ 39 | 40 | berserk could always use more documentation, whether as part of the 41 | official berserk docs, in docstrings, or even on the web in blog posts, 42 | articles, and such. 43 | 44 | Submit Feedback 45 | ~~~~~~~~~~~~~~~ 46 | 47 | The best way to send feedback is to file an issue at https://github.com/rhgrant10/berserk/issues. 48 | 49 | If you are proposing a feature: 50 | 51 | * Explain in detail how it would work. 52 | * Keep the scope as narrow as possible, to make it easier to implement. 53 | * Remember that this is a volunteer-driven project, and that contributions 54 | are welcome :) 55 | 56 | Get Started! 57 | ------------ 58 | 59 | Ready to contribute? Here's how to set up `berserk` for local development. 60 | 61 | 1. Fork the `berserk` repo on GitHub. 62 | 2. Clone your fork locally:: 63 | 64 | $ git clone git@github.com:your_name_here/berserk.git 65 | 66 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 67 | 68 | $ mkvirtualenv berserk 69 | $ cd berserk/ 70 | $ python setup.py develop 71 | 72 | 4. Create a branch for local development:: 73 | 74 | $ git checkout -b name-of-your-bugfix-or-feature 75 | 76 | Now you can make your changes locally. 77 | 78 | 5. When you're done making changes, check that your changes pass flake8 and the 79 | tests, including testing other Python versions with tox:: 80 | 81 | $ flake8 berserk tests 82 | $ python setup.py test or py.test 83 | $ tox 84 | 85 | To get flake8 and tox, just pip install them into your virtualenv. 86 | 87 | 6. Commit your changes and push your branch to GitHub:: 88 | 89 | $ git add . 90 | $ git commit -m "Your detailed description of your changes." 91 | $ git push origin name-of-your-bugfix-or-feature 92 | 93 | 7. Submit a pull request through the GitHub website. 94 | 95 | Pull Request Guidelines 96 | ----------------------- 97 | 98 | Before you submit a pull request, check that it meets these guidelines: 99 | 100 | 1. The pull request should include tests. 101 | 2. If the pull request adds functionality, the docs should be updated. Put 102 | your new functionality into a function with a docstring, and add the 103 | feature to the list in README.rst. 104 | 3. The pull request should work for Python 2.7, 3.4, 3.5 and 3.6, and for PyPy. Check 105 | https://travis-ci.org/rhgrant10/berserk/pull_requests 106 | and make sure that the tests pass for all supported Python versions. 107 | 108 | Tips 109 | ---- 110 | 111 | To run a subset of tests:: 112 | 113 | $ py.test tests.test_berserk 114 | 115 | 116 | Deploying 117 | --------- 118 | 119 | A reminder for the maintainers on how to deploy. 120 | Make sure all your changes are committed (including an entry in HISTORY.rst). 121 | Then run:: 122 | 123 | $ bumpversion patch # possible: major / minor / patch 124 | $ git push 125 | $ git push --tags 126 | 127 | Travis will then deploy to PyPI if tests pass. 128 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | History 2 | ======= 3 | 4 | 0.10.0 (2020-04-26) 5 | ------------------- 6 | 7 | * Add ``Challenge.create_ai`` for creating an AI challenge 8 | * Add ``Challenge.create_open`` for creating an open challenge 9 | * Add ``Challenge.create_with_accept`` auto-acceptance of challenges using OAuth token 10 | * Bugfix for passing initial board positions in FEN for challenges 11 | * Minor fixes for docstrings 12 | 13 | 0.9.0 (2020-04-14) 14 | ------------------ 15 | 16 | * Add remaining ``Board`` endpoints: seek, handle_draw_offer, offer_draw, accept_draw, and decline_draw 17 | * Multiple doc updates/fixes 18 | * Add codecov reporting 19 | 20 | 0.8.0 (2020-03-08) 21 | ------------------ 22 | 23 | * Add new ``Board`` client: stream_incoming_events, stream_game_state, make_move, post_message, abort_game, and resign_game 24 | 25 | 0.7.0 (2020-01-26) 26 | ------------------ 27 | 28 | * Add simuls 29 | * Add studies export and export chapter 30 | * Add tournament results, games export, and list by creator 31 | * Add user followers, users following, rating history, and puzzle activity 32 | * Add new ``Teams`` client: join, get members, kick member, and leave 33 | * Updated documentation, including new docs for some useful utils 34 | * Fixed bugs in ``Tournaments.export_games`` 35 | * Deprecated ``Users.get_by_team`` - use ``Teams.get_members`` instead 36 | 37 | 38 | 0.6.1 (2020-01-20) 39 | ------------------ 40 | 41 | * Add py37 to the travis build 42 | * Update development status classifier to 4 - Beta 43 | * Fix py36 issue preventing successful build 44 | * Make updates to the Makefile 45 | 46 | 47 | 0.6.0 (2020-01-20) 48 | ------------------ 49 | 50 | * Add logging to the ``berserk.session`` module 51 | * Fix exception message when no cause 52 | * Fix bug in ``Broadcasts.push_pgn_update`` 53 | * Update documentation and tweak the theme 54 | 55 | 56 | 0.5.0 (2020-01-20) 57 | ------------------ 58 | 59 | * Add ``ResponseError`` for 4xx and 5xx responses with status code, reason, and cause 60 | * Add ``ApiError`` for all other request errors 61 | * Fix test case broken by 0.4.0 release 62 | * Put all utils code under test 63 | 64 | 65 | 0.4.0 (2020-01-19) 66 | ------------------ 67 | 68 | * Add support for the broadcast endpoints 69 | * Add a utility for easily converting API objects into update params 70 | * Fix multiple bugs with the tournament create endpoint 71 | * Improve the reusability of some conversion utilities 72 | * Improve many docstrings in the client classes 73 | 74 | 75 | 0.3.2 (2020-01-04) 76 | ------------------ 77 | 78 | * Fix bug where options not passed for challenge creation 79 | * Convert requirements from pinned to sematically compatible 80 | * Bump all developer dependencies 81 | * Use pytest instead of the older py.test 82 | * Use py37 in tox 83 | 84 | 85 | 0.3.1 (2018-12-23) 86 | ------------------ 87 | 88 | * Convert datetime string in tournament creation response into datetime object 89 | 90 | 91 | 0.3.0 (2018-12-23) 92 | ------------------ 93 | 94 | * Convert all timestamps to datetime in all responses 95 | * Provide support for challenging other players to a game 96 | 97 | 98 | 0.2.1 (2018-12-08) 99 | ------------------ 100 | 101 | * Bump requests dependency to >-2.20.0 (CVE-2018-18074) 102 | 103 | 104 | 0.2.0 (2018-12-08) 105 | ------------------ 106 | 107 | * Add `position` and `start_date` params to `Tournament.create` 108 | * Add `Position` enum 109 | 110 | 111 | 0.1.2 (2018-07-14) 112 | ------------------ 113 | 114 | * Fix an asine bug in the docs 115 | 116 | 117 | 0.1.1 (2018-07-14) 118 | ------------------ 119 | 120 | * Added tests for session and formats modules 121 | * Fixed mispelled PgnHandler class (!) 122 | * Fixed issue with trailing whitespace when splitting multiple PGN texts 123 | * Fixed the usage overview in the README 124 | * Fixed the versions for travis-ci 125 | * Made it easier to test the `JsonHandler` class 126 | * Salted the bumpversion config to taste 127 | 128 | 129 | 0.1.0 (2018-07-10) 130 | ------------------ 131 | 132 | * First release on PyPI. 133 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Python client for the lichess API 5 | Copyright (C) 2018 Robert Grant 6 | 7 | This program is free software: you can redistribute it and/or modify 8 | it under the terms of the GNU General Public License as published by 9 | the Free Software Foundation, either version 3 of the License, or 10 | (at your option) any later version. 11 | 12 | This program is distributed in the hope that it will be useful, 13 | but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | GNU General Public License for more details. 16 | 17 | You should have received a copy of the GNU General Public License 18 | along with this program. If not, see . 19 | 20 | Also add information on how to contact you by electronic and paper mail. 21 | 22 | You should also get your employer (if you work as a programmer) or school, 23 | if any, to sign a "copyright disclaimer" for the program, if necessary. 24 | For more information on this, and how to apply and follow the GNU GPL, see 25 | . 26 | 27 | The GNU General Public License does not permit incorporating your program 28 | into proprietary programs. If your program is a subroutine library, you 29 | may consider it more useful to permit linking proprietary applications with 30 | the library. If this is what you want to do, use the GNU Lesser General 31 | Public License instead of this License. But first, please read 32 | . 33 | 34 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | 7 | recursive-include tests * 8 | recursive-exclude * __pycache__ 9 | recursive-exclude * *.py[co] 10 | 11 | include requirements*.txt 12 | 13 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | try: 8 | from urllib import pathname2url 9 | except: 10 | from urllib.request import pathname2url 11 | 12 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 13 | endef 14 | export BROWSER_PYSCRIPT 15 | 16 | define PRINT_HELP_PYSCRIPT 17 | import re, sys 18 | 19 | for line in sys.stdin: 20 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 21 | if match: 22 | target, help = match.groups() 23 | print("%-20s %s" % (target, help)) 24 | endef 25 | export PRINT_HELP_PYSCRIPT 26 | 27 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 28 | 29 | help: 30 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 31 | 32 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 33 | 34 | clean-build: ## remove build artifacts 35 | rm -fr build/ 36 | rm -fr dist/ 37 | rm -fr .eggs/ 38 | find . -name '*.egg-info' -exec rm -fr {} + 39 | find . -name '*.egg' -exec rm -f {} + 40 | 41 | clean-pyc: ## remove Python file artifacts 42 | find . -name '*.pyc' -exec rm -f {} + 43 | find . -name '*.pyo' -exec rm -f {} + 44 | find . -name '*~' -exec rm -f {} + 45 | find . -name '__pycache__' -exec rm -fr {} + 46 | 47 | clean-test: ## remove test and coverage artifacts 48 | rm -fr .tox/ 49 | rm -f .coverage 50 | rm -fr htmlcov/ 51 | rm -fr .pytest_cache 52 | 53 | lint: ## check style with flake8 54 | flake8 berserk tests 55 | 56 | test: ## run tests quickly with the default Python 57 | pytest 58 | 59 | test-all: ## run tests on every Python version with tox 60 | tox 61 | 62 | coverage: ## check code coverage quickly with the default Python 63 | coverage run --source berserk -m pytest 64 | coverage report -m 65 | coverage html 66 | $(BROWSER) htmlcov/index.html 67 | 68 | docs: ## generate Sphinx HTML documentation, including API docs 69 | # rm -f docs/berserk.rst 70 | # rm -f docs/modules.rst 71 | # sphinx-apidoc -o docs/ berserk 72 | $(MAKE) -C docs clean 73 | $(MAKE) -C docs html 74 | $(BROWSER) docs/_build/html/index.html 75 | 76 | servedocs: docs ## compile the docs watching for changes 77 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 78 | 79 | release: test-all dist ## package and upload a release 80 | twine upload dist/* 81 | 82 | dist: clean ## builds source and wheel package 83 | python setup.py sdist 84 | python setup.py bdist_wheel 85 | ls -l dist 86 | 87 | install: clean ## install the package to the active Python's site-packages 88 | pip install -r requirements_dev.txt -e . 89 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | berserk 3 | ======= 4 | 5 | 6 | .. image:: https://img.shields.io/pypi/v/berserk.svg 7 | :target: https://pypi.python.org/pypi/berserk 8 | :alt: Available on PyPI 9 | 10 | .. image:: https://img.shields.io/travis/rhgrant10/berserk.svg 11 | :target: https://travis-ci.org/rhgrant10/berserk 12 | :alt: Continuous Integration 13 | 14 | .. image:: https://codecov.io/gh/rhgrant10/berserk/branch/master/graph/badge.svg 15 | :target: https://codecov.io/gh/rhgrant10/berserk 16 | :alt: Code Coverage 17 | 18 | .. image:: https://readthedocs.org/projects/berserk/badge/?version=latest 19 | :target: https://berserk.readthedocs.io/en/latest/?badge=latest 20 | :alt: Documentation Status 21 | 22 | 23 | Python client for the `Lichess API`_ (modified). 24 | 25 | .. _Lichess API: https://lichess.org/api 26 | 27 | * Free software: GNU General Public License v3 28 | * Documentation: https://berserk.readthedocs.io. 29 | 30 | 31 | Features 32 | ======== 33 | 34 | * handles JSON and PGN formats at user's discretion 35 | * token auth session 36 | * easy integration with OAuth2 37 | * automatically converts time values to datetimes 38 | 39 | Usage 40 | ===== 41 | 42 | You can use any ``requests.Session``-like object as a session, including those 43 | from ``requests_oauth``. A simple token session is included, as shown below: 44 | 45 | .. code-block:: python 46 | 47 | import berserk 48 | 49 | session = berserk.TokenSession(API_TOKEN) 50 | client = berserk.Client(session=session) 51 | 52 | Most if not all of the API is available: 53 | 54 | .. code-block:: python 55 | 56 | client.account.get 57 | client.account.get_email 58 | client.account.get_preferences 59 | client.account.get_kid_mode 60 | client.account.set_kid_mode 61 | client.account.upgrade_to_bot 62 | 63 | client.users.get_puzzle_activity 64 | client.users.get_realtime_statuses 65 | client.users.get_all_top_10 66 | client.users.get_leaderboard 67 | client.users.get_public_data 68 | client.users.get_activity_feed 69 | client.users.get_by_id 70 | client.users.get_by_team 71 | client.users.get_live_streamers 72 | client.users.get_users_followed 73 | client.users.get_users_following 74 | client.users.get_rating_history 75 | 76 | client.teams.get_members 77 | client.teams.join 78 | client.teams.leave 79 | client.teams.kick_member 80 | 81 | client.games.export 82 | client.games.export_by_player 83 | client.games.export_multi 84 | client.games.get_among_players 85 | client.games.get_ongoing 86 | client.games.get_tv_channels 87 | 88 | client.challenges.create 89 | client.challenges.create_ai 90 | client.challenges.create_open 91 | client.challenges.create_with_accept 92 | client.challenges.accept 93 | client.challenges.decline 94 | 95 | client.board.stream_incoming_events 96 | client.board.seek 97 | client.board.stream_game_state 98 | client.board.make_move 99 | client.board.post_message 100 | client.board.abort_game 101 | client.board.resign_game 102 | client.board.handle_draw_offer 103 | client.board.offer_draw 104 | client.board.accept_draw 105 | client.board.decline_draw 106 | 107 | client.bots.stream_incoming_events 108 | client.bots.stream_game_state 109 | client.bots.make_move 110 | client.bots.post_message 111 | client.bots.abort_game 112 | client.bots.resign_game 113 | client.bots.accept_challenge 114 | client.bots.decline_challenge 115 | 116 | client.tournaments.get 117 | client.tournaments.create 118 | client.tournaments.export_games 119 | client.tournaments.stream_results 120 | client.tournaments.stream_by_creator 121 | 122 | client.broadcasts.create 123 | client.broadcasts.get 124 | client.broadcasts.update 125 | client.broadcasts.push_pgn_update 126 | 127 | client.simuls.get 128 | 129 | client.studies.export_chapter 130 | client.studies.export 131 | 132 | 133 | Details for each function can be found in the `full documentation `_. 134 | 135 | 136 | Credits 137 | ======= 138 | 139 | This package was created with Cookiecutter_ and the 140 | `audreyr/cookiecutter-pypackage`_ project template. 141 | 142 | .. _Cookiecutter: https://github.com/audreyr/cookiecutter 143 | .. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage 144 | -------------------------------------------------------------------------------- /Test.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "canadian-savage", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "import berserk" 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "execution_count": 2, 16 | "id": "worldwide-talent", 17 | "metadata": {}, 18 | "outputs": [], 19 | "source": [ 20 | "\n", 21 | "session = berserk.TokenSession(\"lip_WemfGeUdnPbJTOoKndeV\")\n", 22 | "client = berserk.Client(session=session)\n" 23 | ] 24 | }, 25 | { 26 | "cell_type": "code", 27 | "execution_count": 3, 28 | "id": "deadly-wagner", 29 | "metadata": {}, 30 | "outputs": [ 31 | { 32 | "data": { 33 | "text/plain": [ 34 | "'qLV4LUK4'" 35 | ] 36 | }, 37 | "execution_count": 3, 38 | "metadata": {}, 39 | "output_type": "execute_result" 40 | } 41 | ], 42 | "source": [ 43 | "client.games.import_game(\"\"\"[Event \"🕸️ Opening Traps 🕸️: Fishing Pole Trap\"]\n", 44 | "[Site \"https://lichess.org/study/Of3mcPk8/NHPhHyxK\"]\n", 45 | "[Date \"????.??.??\"]\n", 46 | "[Result \"*\"]\n", 47 | "[FEN \"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1\"]\n", 48 | "[UTCDate \"2016.07.21\"]\n", 49 | "[UTCTime \"14:05:49\"]\n", 50 | "[Variant \"Standard\"]\n", 51 | "[ECO \"C65\"]\n", 52 | "[Opening \"Ruy Lopez: Berlin Defense, Fishing Pole Variation\"]\n", 53 | "[Annotator \"https://lichess.org/@/Toxenory\"]\n", 54 | "\n", 55 | "1. e4 e5 2. Nf3 Nc6 3. Bb5 Nf6 4. O-O Ng4 5. h3 h5 6. hxg4 hxg4 7. Ne1 Qh4 8. f3 g3 { [%csl Gf2][%cal Gg3f2] } 9. Bxc6 Qh1# *\n", 56 | "\n", 57 | "\n", 58 | "\"\"\")" 59 | ] 60 | } 61 | ], 62 | "metadata": { 63 | "kernelspec": { 64 | "display_name": "Python 3 (ipykernel)", 65 | "language": "python", 66 | "name": "python3" 67 | }, 68 | "language_info": { 69 | "codemirror_mode": { 70 | "name": "ipython", 71 | "version": 3 72 | }, 73 | "file_extension": ".py", 74 | "mimetype": "text/x-python", 75 | "name": "python", 76 | "nbconvert_exporter": "python", 77 | "pygments_lexer": "ipython3", 78 | "version": "3.9.2" 79 | } 80 | }, 81 | "nbformat": 4, 82 | "nbformat_minor": 5 83 | } 84 | -------------------------------------------------------------------------------- /berserk/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Top-level package for berserk.""" 3 | 4 | 5 | __author__ = """Robert Grant""" 6 | __email__ = 'rhgrant10@gmail.com' 7 | __version__ = '0.10.0' 8 | 9 | 10 | from .clients import Client # noqa: F401 11 | from .enums import Color # noqa: F401 12 | from .enums import Mode # noqa: F401 13 | from .enums import PerfType # noqa: F401 14 | from .enums import Position # noqa: F401 15 | from .enums import Room # noqa: F401 16 | from .enums import Variant # noqa: F401 17 | from .formats import JSON # noqa: F401 18 | from .formats import LIJSON # noqa: F401 19 | from .formats import NDJSON # noqa: F401 20 | from .formats import PGN # noqa: F401 21 | from .session import Requestor # noqa: F401 22 | from .session import TokenSession # noqa: F401 23 | -------------------------------------------------------------------------------- /berserk/clients.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from time import time as now 3 | 4 | import requests 5 | from deprecated import deprecated 6 | 7 | from . import models 8 | from .formats import ( 9 | JSON, 10 | LIJSON, 11 | NDJSON, 12 | PGN, 13 | TEXT, 14 | ) 15 | from .session import Requestor 16 | 17 | __all__ = [ 18 | 'Client', 19 | 'Account', 20 | 'Board', 21 | 'Bots', 22 | 'Broadcasts', 23 | 'Challenges', 24 | 'Games', 25 | 'Simuls', 26 | 'Studies', 27 | 'Teams', 28 | 'Tournaments', 29 | 'Users', 30 | 'TV', 31 | 'Puzzles', 32 | 'OpeningExplorer', 33 | ] 34 | 35 | 36 | # Base URL for the API 37 | API_URL = 'https://lichess.org/' 38 | 39 | 40 | class BaseClient: 41 | def __init__(self, session, base_url=None): 42 | self._r = Requestor(session, base_url or API_URL, default_fmt=JSON) 43 | 44 | 45 | class FmtClient(BaseClient): 46 | """Client that can return PGN or not. 47 | 48 | :param session: request session, authenticated as needed 49 | :type session: :class:`requests.Session` 50 | :param str base_url: base URL for the API 51 | :param bool pgn_as_default: ``True`` if PGN should be the default format 52 | for game exports when possible. This defaults 53 | to ``False`` and is used as a fallback when 54 | ``as_pgn`` is left as ``None`` for methods that 55 | support it. 56 | """ 57 | 58 | def __init__(self, session, base_url=None, pgn_as_default=False): 59 | super().__init__(session, base_url) 60 | self.pgn_as_default = pgn_as_default 61 | 62 | def _use_pgn(self, as_pgn=None): 63 | # helper to merge default with provided arg 64 | return as_pgn if as_pgn is not None else self.pgn_as_default 65 | 66 | 67 | class Client(BaseClient): 68 | """Main touchpoint for the API. 69 | 70 | All endpoints are namespaced into the clients below: 71 | 72 | - :class:`account ` - managing account information 73 | - :class:`bots ` - performing bot operations 74 | - :class:`broadcasts ` - getting and creating 75 | broadcasts 76 | - :class:`challenges ` - using challenges 77 | - :class:`games ` - getting and exporting games 78 | - :class:`simuls ` - getting simultaneous 79 | exhibition games 80 | - :class:`studies ` - exporting studies 81 | - :class:`teams ` - getting information about teams 82 | - :class:`tournaments ` - getting and 83 | creating tournaments 84 | - :class:`users ` - getting information about users 85 | - :class:`TV ` - getting information about lichess tv 86 | 87 | :param session: request session, authenticated as needed 88 | :type session: :class:`requests.Session` 89 | :param str base_url: base API URL to use (if other than the default) 90 | :param bool pgn_as_default: ``True`` if PGN should be the default format 91 | for game exports when possible. This defaults 92 | to ``False`` and is used as a fallback when 93 | ``as_pgn`` is left as ``None`` for methods that 94 | support it. 95 | """ 96 | 97 | def __init__(self, session=None, base_url=None, pgn_as_default=False): 98 | session = session or requests.Session() 99 | super().__init__(session, base_url) 100 | self.account = Account(session, base_url) 101 | self.users = Users(session, base_url) 102 | self.relations = Relations(session, base_url) 103 | self.teams = Teams(session, base_url) 104 | self.games = Games(session, base_url, pgn_as_default=pgn_as_default) 105 | self.challenges = Challenges(session, base_url) 106 | self.board = Board(session, base_url) 107 | self.bots = Bots(session, base_url) 108 | self.tournaments = Tournaments( 109 | session, base_url, pgn_as_default=pgn_as_default 110 | ) 111 | self.broadcasts = Broadcasts(session, base_url) 112 | self.simuls = Simuls(session, base_url) 113 | self.studies = Studies(session, base_url) 114 | self.tv = TV(session, base_url) 115 | self.puzzles = Puzzles(session, base_url) 116 | self.opening_explorer = OpeningExplorer( 117 | session, 'https://explorer.lichess.ovh/' 118 | ) 119 | 120 | 121 | class Account(BaseClient): 122 | """Client for account-related endpoints.""" 123 | 124 | def get(self): 125 | """Get your public information. 126 | 127 | :return: public information about the authenticated user 128 | :rtype: dict 129 | """ 130 | path = 'api/account' 131 | return self._r.get(path, converter=models.Account.convert) 132 | 133 | def get_email(self): 134 | """Get your email address. 135 | 136 | :return: email address of the authenticated user 137 | :rtype: str 138 | """ 139 | path = 'api/account/email' 140 | return self._r.get(path)['email'] 141 | 142 | def get_preferences(self): 143 | """Get your account preferences. 144 | 145 | :return: preferences of the authenticated user 146 | :rtype: dict 147 | """ 148 | path = 'api/account/preferences' 149 | return self._r.get(path)['prefs'] 150 | 151 | def get_kid_mode(self): 152 | """Get your kid mode status. 153 | 154 | :return: current kid mode status 155 | :rtype: bool 156 | """ 157 | path = 'api/account/kid' 158 | return self._r.get(path)['kid'] 159 | 160 | def set_kid_mode(self, value): 161 | """Set your kid mode status. 162 | 163 | :param bool value: whether to enable or disable kid mode 164 | :return: success 165 | :rtype: bool 166 | """ 167 | path = 'api/account/kid' 168 | params = {'v': value} 169 | return self._r.post(path, params=params)['ok'] 170 | 171 | @deprecated(version='0.11.0', reason='use Bots.upgrade_to_bot instead') 172 | def upgrade_to_bot(self): 173 | """Upgrade your account to a bot account. 174 | 175 | Requires bot:play oauth scope. User cannot have any previously played 176 | games. 177 | 178 | :return: success 179 | :rtype: bool 180 | """ 181 | path = 'api/bot/account/upgrade' 182 | return self._r.post(path)['ok'] 183 | 184 | 185 | class Users(BaseClient): 186 | """Client for user-related endpoints.""" 187 | 188 | def get_puzzle_activity(self, max=None): 189 | """Stream puzzle activity history starting with the most recent. 190 | 191 | :param int max: maximum number of entries to stream 192 | :return: puzzle activity history 193 | :rtype: iter 194 | """ 195 | path = 'api/user/puzzle-activity' 196 | params = {'max': max} 197 | return self._r.get( 198 | path, 199 | params=params, 200 | fmt=NDJSON, 201 | stream=True, 202 | converter=models.PuzzleActivity.convert, 203 | ) 204 | 205 | def get_realtime_statuses(self, *user_ids): 206 | """Get the online, playing, and streaming statuses of players. 207 | 208 | Only id and name fields are returned for offline users. 209 | 210 | :param user_ids: one or more user IDs (names) 211 | :return: statuses of given players 212 | :rtype: list 213 | """ 214 | path = 'api/users/status' 215 | params = {'ids': ','.join(user_ids)} 216 | return self._r.get(path, params=params) 217 | 218 | def get_all_top_10(self): 219 | """Get the top 10 players for each speed and variant. 220 | 221 | :return: top 10 players in each speed and variant 222 | :rtype: dict 223 | """ 224 | path = 'player' 225 | return self._r.get(path, fmt=LIJSON) 226 | 227 | def get_leaderboard(self, perf_type, count=10): 228 | """Get the leaderboard for one speed or variant. 229 | 230 | :param perf_type: speed or variant 231 | :type perf_type: :class:`~berserk.enums.PerfType` 232 | :param int count: number of players to get 233 | :return: top players for one speed or variant 234 | :rtype: list 235 | """ 236 | path = f'player/top/{count}/{perf_type}' 237 | return self._r.get(path, fmt=LIJSON)['users'] 238 | 239 | def get_public_data(self, username): 240 | """Get the public data for a user. 241 | 242 | :param str username: username 243 | :return: public data available for the given user 244 | :rtype: dict 245 | """ 246 | path = f'api/user/{username}' 247 | return self._r.get(path, converter=models.User.convert) 248 | 249 | def get_activity_feed(self, username): 250 | """Get the activity feed of a user. 251 | 252 | :param str username: username 253 | :return: activity feed of the given user 254 | :rtype: list 255 | """ 256 | path = f'api/user/{username}/activity' 257 | return self._r.get(path, converter=models.Activity.convert) 258 | 259 | def get_by_id(self, *usernames): 260 | """Get multiple users by their IDs. 261 | 262 | :param usernames: one or more usernames 263 | :return: user data for the given usernames 264 | :rtype: list 265 | """ 266 | path = 'api/users' 267 | return self._r.post( 268 | path, data=','.join(usernames), converter=models.User.convert 269 | ) 270 | 271 | @deprecated(version='0.7.0', reason='use Teams.get_members(id) instead') 272 | def get_by_team(self, team_id): 273 | """Get members of a team. 274 | 275 | :param str team_id: ID of a team 276 | :return: users on the given team 277 | :rtype: iter 278 | """ 279 | path = f'team/{team_id}/users' 280 | return self._r.get( 281 | path, fmt=NDJSON, stream=True, converter=models.User.convert 282 | ) 283 | 284 | def get_live_streamers(self): 285 | """Get basic information about currently streaming users. 286 | 287 | :return: users currently streaming a game 288 | :rtype: list 289 | """ 290 | path = 'streamer/live' 291 | return self._r.get(path) 292 | 293 | @deprecated(version='0.11.0', reason='moved to Relations') 294 | def get_users_followed(self, username): 295 | """Stream users followed by a user. 296 | 297 | :param str username: a username 298 | :return: iterator over the users the given user follows 299 | :rtype: iter 300 | """ 301 | path = f'/api/user/{username}/following' 302 | return self._r.get( 303 | path, stream=True, fmt=NDJSON, converter=models.User.convert 304 | ) 305 | 306 | @deprecated(version='0.11.0', reason='moved to relations and removed') 307 | def get_users_following(self, username): 308 | """Stream users who follow a user. 309 | 310 | :param str username: a username 311 | :return: iterator over the users that follow the given user 312 | :rtype: iter 313 | """ 314 | path = f'/api/user/{username}/followers' 315 | return self._r.get( 316 | path, stream=True, fmt=NDJSON, converter=models.User.convert 317 | ) 318 | 319 | def get_rating_history(self, username): 320 | """Get the rating history of a user. 321 | 322 | :param str username: a username 323 | :return: rating history for all game types 324 | :rtype: list 325 | """ 326 | path = f'/api/user/{username}/rating-history' 327 | return self._r.get(path, converter=models.RatingHistory.convert) 328 | 329 | def get_performance_statistics(self, username, perf): 330 | """Get the performance statistics of a user. 331 | 332 | :param str username: a username 333 | :param str perf: a perf (https://lichess.org/api#operation/apiUserPerf) 334 | :return: performance history of username in perf 335 | :rtype: list 336 | """ 337 | path = f'/api/user/{username}/perf/{perf}' 338 | return self._r.get(path, converter=models.User.convert) 339 | 340 | def get_crosstable(self, user1, user2, matchup=False): 341 | """Get the number of games and current score of any two users. 342 | 343 | Matchup information is only returned if the two given users are 344 | playing at the moment of the request. 345 | 346 | :param str user1: user1 347 | :param str user2: user2 348 | :param bool matchup: whether to include current match data 349 | :return: total number of games and current score of the given users 350 | :rtype: dict 351 | """ 352 | path = f'/api/crosstable/{user1}/{user2}' 353 | params = {'matchup': str(bool(matchup)).lower()} 354 | return self._r.get(path, params=params) 355 | 356 | 357 | class Relations(BaseClient): 358 | def get_users_followed(self, username): 359 | """Stream users followed by a user. 360 | 361 | :param str username: a username 362 | :return: iterator over the users the given user follows 363 | :rtype: iter 364 | """ 365 | path = f'/api/user/{username}/following' 366 | return self._r.get( 367 | path, stream=True, fmt=NDJSON, converter=models.User.convert 368 | ) 369 | 370 | @deprecated(version='0.11.0', reason='Removed from Lichess API.') 371 | def get_users_following(self, username): 372 | """Stream users who follow a user. 373 | 374 | :param str username: a username 375 | :return: iterator over the users that follow the given user 376 | :rtype: iter 377 | """ 378 | path = f'/api/user/{username}/followers' 379 | return self._r.get( 380 | path, stream=True, fmt=NDJSON, converter=models.User.convert 381 | ) 382 | 383 | def follow_user(self, username): 384 | """Follow a user. 385 | 386 | :param str username: username of player to follow. 387 | :return: success 388 | :rtype: bool 389 | """ 390 | path = f'/api/rel/follow/{username}' 391 | return self._r.post(path)['ok'] 392 | 393 | def unfollow_user(self, username): 394 | """Unfollow a user. 395 | 396 | :param str username: username of player to unfollow. 397 | :return: success 398 | :rtype: bool 399 | """ 400 | path = f'/api/rel/unfollow/{username}' 401 | return self._r.post(path)['ok'] 402 | 403 | 404 | class Teams(BaseClient): 405 | def get_swiss_tournaments( 406 | self, team_id, max_tournaments=100, stream=False 407 | ): 408 | """Get swiss tournaments of a team. 409 | 410 | :param str team_id: ID of a team 411 | :param int max_tournaments: how many entries to download 412 | :param bool stream: whether to stream data or not 413 | :return: swiss tournaments of the given team 414 | :rtype: list or iter 415 | """ 416 | path = f'api/team/{team_id}/swiss' 417 | params = {'max': max_tournaments} 418 | return self._r.get( 419 | path, params=params, converter=models.Tournament.convert, 420 | stream=stream, fmt=NDJSON, 421 | ) 422 | 423 | def get_team(self, team_id): 424 | """Get a single team informations. 425 | 426 | :param str team_id: ID of a team 427 | :return: informations about the given team 428 | :rtype: dict 429 | """ 430 | path = f'api/team/{team_id}' 431 | return self._r.get(path) 432 | 433 | def get_popular(self, page=1): 434 | """Get popular teams, page by page. 435 | 436 | :param int page: page to get 437 | :return: popular teams infos 438 | :rtype: dict 439 | """ 440 | path = 'api/team/all' 441 | params = {'page': page} 442 | return self._r.get(path, params=params) 443 | 444 | def get_player_teams(self, username): 445 | """Get teams of a player. 446 | 447 | :param str username: a username 448 | :return: teams of the given user 449 | :rtype: list 450 | """ 451 | path = f'api/team/of/{username}' 452 | return self._r.get(path) 453 | 454 | def search_teams(self, text, page=1): 455 | """Search teams by keyword. 456 | 457 | :param str text: search keyword 458 | :param int page: page to get 459 | :return: search results 460 | :rtype: list 461 | """ 462 | path = 'api/team/search' 463 | params = {'text': text, 'page': page} 464 | return self._r.get(path, params=params) 465 | 466 | def get_members(self, team_id): 467 | """Get members of a team. 468 | 469 | :param str team_id: ID of a team 470 | :return: users on the given team 471 | :rtype: iter 472 | """ 473 | path = f'api/team/{team_id}/users' 474 | return self._r.get( 475 | path, fmt=NDJSON, stream=True, converter=models.User.convert 476 | ) 477 | 478 | def get_arena_tournaments( 479 | self, team_id, max_tournaments=100, stream=False 480 | ): 481 | """Get Arena tournaments of a team. 482 | 483 | :param str team_id: ID of a team 484 | :param int max_tournaments: how many entries to download 485 | :param bool stream: whether to stream data or not 486 | :return: arena tournaments of the given team 487 | :rtype: list or iter 488 | """ 489 | path = f'api/team/{team_id}/arena' 490 | params = {'max': max_tournaments} 491 | return self._r.get( 492 | path, params=params, converter=models.Tournaments.convert, 493 | stream=stream, fmt=NDJSON, 494 | ) 495 | 496 | def join(self, team_id, message=None, password=None): 497 | """Join a team. 498 | 499 | :param str team_id: ID of a team 500 | :param str message: optional request message, if the team requires one 501 | :param str password: optional password, if the team requires one 502 | :return: success 503 | :rtype: bool 504 | """ 505 | path = f'team/{team_id}/join' 506 | payload = { 507 | 'message': message, 508 | 'password': password, 509 | } 510 | return self._r.post(path, data=payload)['ok'] 511 | 512 | def leave(self, team_id): 513 | """Leave a team. 514 | 515 | :param str team_id: ID of a team 516 | :return: success 517 | :rtype: bool 518 | """ 519 | path = f'team/{team_id}/quit' 520 | return self._r.post(path)['ok'] 521 | 522 | def kick_member(self, team_id, user_id): 523 | """Kick a member out of your team. 524 | 525 | :param str team_id: ID of a team 526 | :param str user_id: ID of a team member 527 | :return: success 528 | :rtype: bool 529 | """ 530 | path = f'team/{team_id}/kick/{user_id}' 531 | return self._r.post(path)['ok'] 532 | 533 | def message_all(self, team_id, message): 534 | """Message all members of your team. 535 | 536 | :param str team_id: ID of a team 537 | :param str message: the message to send to all your team members 538 | :return: success 539 | :rtype: bool 540 | """ 541 | path = f'team/{team_id}/pm-all' 542 | payload = {'message': message} 543 | return self._r.post(path, data=payload)['ok'] 544 | 545 | 546 | class Games(FmtClient): 547 | """Client for games-related endpoints.""" 548 | 549 | def export( 550 | self, 551 | game_id, 552 | as_pgn=None, 553 | moves=None, 554 | tags=None, 555 | clocks=None, 556 | evals=None, 557 | opening=None, 558 | literate=None, 559 | ): 560 | """Get one finished game as PGN or JSON. 561 | 562 | :param str game_id: the ID of the game to export 563 | :param bool as_pgn: whether to return the game in PGN format 564 | :param bool moves: whether to include the PGN moves 565 | :param bool tags: whether to include the PGN tags 566 | :param bool clocks: whether to include clock comments in the PGN moves 567 | :param bool evals: whether to include analysis evaluation comments in 568 | the PGN moves when available 569 | :param bool opening: whether to include the opening name 570 | :param bool literate: whether to include literate the PGN 571 | :return: exported game, as JSON or PGN 572 | """ 573 | path = f'game/export/{game_id}' 574 | params = { 575 | 'moves': moves, 576 | 'tags': tags, 577 | 'clocks': clocks, 578 | 'evals': evals, 579 | 'opening': opening, 580 | 'literate': literate, 581 | } 582 | fmt = PGN if self._use_pgn(as_pgn) else JSON 583 | return self._r.get( 584 | path, params=params, fmt=fmt, converter=models.Game.convert 585 | ) 586 | 587 | def export_ongoing( 588 | self, 589 | username, 590 | moves=True, 591 | pgn_in_json=False, 592 | tags=True, 593 | clocks=True, 594 | evals=True, 595 | opening=True, 596 | literate=False, 597 | players=None, 598 | ): 599 | """Get ongoing game of a player. 600 | 601 | :param str username: which player's games to return 602 | :param bool moves: whether to include the PGN moves 603 | :param bool pgn_in_json: whether to include the full PGN within the 604 | JSON response, in a ``pgn`` field 605 | :param bool tags: whether to include the PGN tags 606 | :param bool clocks: whether to include clock comments in the PGN moves, 607 | when available 608 | :param bool evals: whether to include analysis evaluation comments in 609 | the PGN, when available 610 | :param bool opening: whether to include the opening name 611 | :param bool literate: whether to insert textual annotations in the PGN 612 | about the opening, analysis variations, mistakes, 613 | and game termination 614 | :param str players: URL of a text file containing real names and 615 | ratings, to replace Lichess usernames and ratings in the PGN 616 | :return: exported game, as JSON or PGN 617 | """ 618 | path = f'api/user/{username}/current-game' 619 | params = { 620 | 'moves': moves, 621 | 'pgnInJson': pgn_in_json, 622 | 'tags': tags, 623 | 'clocks': clocks, 624 | 'evals': evals, 625 | 'opening': opening, 626 | 'literate': literate, 627 | 'players': players, 628 | } 629 | fmt = PGN if self._use_pgn(not pgn_in_json) else JSON 630 | return self._r.get( 631 | path, params=params, fmt=fmt, converter=models.Game.convert 632 | ) 633 | 634 | def export_by_player( 635 | self, 636 | username, 637 | as_pgn=None, 638 | since=None, 639 | until=None, 640 | max=None, 641 | vs=None, 642 | rated=None, 643 | perf_type=None, 644 | color=None, 645 | analysed=None, 646 | moves=None, 647 | tags=None, 648 | evals=None, 649 | opening=None, 650 | ): 651 | """Get games by player. 652 | 653 | :param str username: which player's games to return 654 | :param bool as_pgn: whether to return the game in PGN format 655 | :param int since: lowerbound on the game timestamp 656 | :param int until: upperbound on the game timestamp 657 | :param int max: limit the number of games returned 658 | :param str vs: filter by username of the opponent 659 | :param bool rated: filter by game mode (``True`` for rated, ``False`` 660 | for casual) 661 | :param perf_type: filter by speed or variant 662 | :type perf_type: :class:`~berserk.enums.PerfType` 663 | :param color: filter by the color of the player 664 | :type color: :class:`~berserk.enums.Color` 665 | :param bool analysed: filter by analysis availability 666 | :param bool moves: whether to include the PGN moves 667 | :param bool tags: whether to include the PGN tags 668 | :param bool clocks: whether to include clock comments in the PGN moves 669 | :param bool evals: whether to include analysis evaluation comments in 670 | the PGN moves when available 671 | :param bool opening: whether to include the opening name 672 | :param bool literate: whether to include literate the PGN 673 | :return: iterator over the exported games, as JSON or PGN 674 | """ 675 | path = f'api/games/user/{username}' 676 | params = { 677 | 'since': since, 678 | 'until': until, 679 | 'max': max, 680 | 'vs': vs, 681 | 'rated': rated, 682 | 'perfType': perf_type, 683 | 'color': color, 684 | 'analysed': analysed, 685 | 'moves': moves, 686 | 'tags': tags, 687 | 'evals': evals, 688 | 'opening': opening, 689 | } 690 | fmt = PGN if self._use_pgn(as_pgn) else NDJSON 691 | return self._r.get( 692 | path, params=params, fmt=fmt, converter=models.Game.convert 693 | ) 694 | 695 | def export_multi( 696 | self, 697 | *game_ids, 698 | as_pgn=None, 699 | moves=None, 700 | tags=None, 701 | clocks=None, 702 | evals=None, 703 | opening=None, 704 | ): 705 | """Get multiple games by ID. 706 | 707 | :param game_ids: one or more game IDs to export 708 | :param bool as_pgn: whether to return the game in PGN format 709 | :param bool moves: whether to include the PGN moves 710 | :param bool tags: whether to include the PGN tags 711 | :param bool clocks: whether to include clock comments in the PGN moves 712 | :param bool evals: whether to include analysis evaluation comments in 713 | the PGN moves when available 714 | :param bool opening: whether to include the opening name 715 | :return: iterator over the exported games, as JSON or PGN 716 | """ 717 | path = 'games/export/_ids' 718 | params = { 719 | 'moves': moves, 720 | 'tags': tags, 721 | 'clocks': clocks, 722 | 'evals': evals, 723 | 'opening': opening, 724 | } 725 | payload = ','.join(game_ids) 726 | fmt = PGN if self._use_pgn(as_pgn) else NDJSON 727 | yield from self._r.post( 728 | path, 729 | params=params, 730 | data=payload, 731 | fmt=fmt, 732 | stream=True, 733 | converter=models.Game.convert, 734 | ) 735 | 736 | def get_among_players(self, *usernames): 737 | """Get the games currently being played among players. 738 | 739 | Note this will not includes games where only one player is in the given 740 | list of usernames. 741 | 742 | :param usernames: two or more usernames 743 | :return: iterator over all games played among the given players 744 | """ 745 | path = 'api/stream/games-by-users' 746 | payload = ','.join(usernames) 747 | yield from self._r.post( 748 | path, 749 | data=payload, 750 | fmt=NDJSON, 751 | stream=True, 752 | converter=models.Game.convert, 753 | ) 754 | 755 | # move this to Account? 756 | def get_ongoing(self, count=10): 757 | """Get your currently ongoing games. 758 | 759 | :param int count: number of games to get 760 | :return: some number of currently ongoing games 761 | :rtype: list 762 | """ 763 | path = 'api/account/playing' 764 | params = {'nb': count} 765 | return self._r.get(path, params=params)['nowPlaying'] 766 | 767 | @deprecated(version='0.11.0', reason='moved to tv') 768 | def get_tv_channels(self): 769 | """Get basic information about the best games being played. 770 | 771 | :return: best ongoing games in each speed and variant 772 | :rtype: dict 773 | """ 774 | path = 'tv/channels' 775 | return self._r.get(path) 776 | 777 | def import_game(self, pgn): 778 | """Import one game from PGN. 779 | 780 | :param str pgn: the PGN, it can contain only one game 781 | :return: game id 782 | :rtype: str 783 | """ 784 | path = 'api/import' 785 | payload = {'pgn': pgn} 786 | return self._r.post(path, data=payload)['id'] 787 | 788 | def stream_moves(self, game_id): 789 | """Stream moves of a game as NDJSON. 790 | 791 | :param str id: the ID of the game to stream 792 | :return: game stream 793 | :rtype: dict 794 | """ 795 | path = f'api/stream/game/{game_id}' 796 | return self._r.get(path, stream=True) 797 | 798 | 799 | class Challenges(BaseClient): 800 | def create( 801 | self, 802 | username, 803 | rated, 804 | clock_limit=None, 805 | clock_increment=None, 806 | days=None, 807 | color=None, 808 | variant=None, 809 | position=None, 810 | ): 811 | """Challenge another player to a game. 812 | 813 | :param str username: username of the player to challege 814 | :param bool rated: whether or not the game will be rated 815 | :param int clock_limit: clock initial time (in seconds) 816 | :param int clock_increment: clock increment (in seconds) 817 | :param int days: days per move (for correspondence games; omit clock) 818 | :param color: color of the accepting player 819 | :type color: :class:`~berserk.enums.Color` 820 | :param variant: game variant to use 821 | :type variant: :class:`~berserk.enums.Variant` 822 | :param position: custom intial position in FEN (variant must be 823 | standard and the game cannot be rated) 824 | :type position: str 825 | :return: challenge data 826 | :rtype: dict 827 | """ 828 | path = f'api/challenge/{username}' 829 | payload = { 830 | 'rated': rated, 831 | 'clock.limit': clock_limit, 832 | 'clock.increment': clock_increment, 833 | 'days': days, 834 | 'color': color, 835 | 'variant': variant, 836 | 'fen': position, 837 | } 838 | return self._r.post( 839 | path, json=payload, converter=models.Tournament.convert 840 | ) 841 | 842 | def create_with_accept( 843 | self, 844 | username, 845 | rated, 846 | token, 847 | clock_limit=None, 848 | clock_increment=None, 849 | days=None, 850 | color=None, 851 | variant=None, 852 | position=None, 853 | ): 854 | """Start a game with another player. 855 | 856 | This is just like the regular challenge create except it forces the 857 | opponent to accept. You must provide the OAuth token of the opponent 858 | and it must have the challenge:write scope. 859 | 860 | :param str username: username of the opponent 861 | :param bool rated: whether or not the game will be rated 862 | :param str token: opponent's OAuth token 863 | :param int clock_limit: clock initial time (in seconds) 864 | :param int clock_increment: clock increment (in seconds) 865 | :param int days: days per move (for correspondence games; omit clock) 866 | :param color: color of the accepting player 867 | :type color: :class:`~berserk.enums.Color` 868 | :param variant: game variant to use 869 | :type variant: :class:`~berserk.enums.Variant` 870 | :param position: custom intial position in FEN (variant must be 871 | standard and the game cannot be rated) 872 | :type position: :class:`~berserk.enums.Position` 873 | :return: game data 874 | :rtype: dict 875 | """ 876 | path = f'api/challenge/{username}' 877 | payload = { 878 | 'rated': rated, 879 | 'acceptByToken': token, 880 | 'clock.limit': clock_limit, 881 | 'clock.increment': clock_increment, 882 | 'days': days, 883 | 'color': color, 884 | 'variant': variant, 885 | 'fen': position, 886 | } 887 | return self._r.post( 888 | path, json=payload, converter=models.Tournament.convert 889 | ) 890 | 891 | def create_ai( 892 | self, 893 | level=8, 894 | clock_limit=None, 895 | clock_increment=None, 896 | days=None, 897 | color=None, 898 | variant=None, 899 | position=None, 900 | ): 901 | """Challenge AI to a game. 902 | 903 | :param int level: level of the AI (1 to 8) 904 | :param int clock_limit: clock initial time (in seconds) 905 | :param int clock_increment: clock increment (in seconds) 906 | :param int days: days per move (for correspondence games; omit clock) 907 | :param color: color of the accepting player 908 | :type color: :class:`~berserk.enums.Color` 909 | :param variant: game variant to use 910 | :type variant: :class:`~berserk.enums.Variant` 911 | :param position: use one of the custom initial positions (variant must 912 | be standard and cannot be rated) 913 | :type position: str 914 | :return: success indicator 915 | :rtype: bool 916 | """ 917 | path = 'api/challenge/ai' 918 | payload = { 919 | 'level': level, 920 | 'clock.limit': clock_limit, 921 | 'clock.increment': clock_increment, 922 | 'days': days, 923 | 'color': color, 924 | 'variant': variant, 925 | 'fen': position, 926 | } 927 | return self._r.post( 928 | path, json=payload, converter=models.Tournament.convert 929 | ) 930 | 931 | def create_open( 932 | self, 933 | clock_limit=None, 934 | clock_increment=None, 935 | variant=None, 936 | position=None, 937 | ): 938 | """Create a challenge that any two players can join. 939 | 940 | :param int clock_limit: clock initial time (in seconds) 941 | :param int clock_increment: clock increment (in seconds) 942 | :param variant: game variant to use 943 | :type variant: :class:`~berserk.enums.Variant` 944 | :param position: custom intial position in FEN (variant must be 945 | standard and the game cannot be rated) 946 | :type position: str 947 | :return: challenge data 948 | :rtype: dict 949 | """ 950 | path = 'api/challenge/open' 951 | payload = { 952 | 'clock.limit': clock_limit, 953 | 'clock.increment': clock_increment, 954 | 'variant': variant, 955 | 'fen': position, 956 | } 957 | return self._r.post( 958 | path, json=payload, converter=models.Tournament.convert 959 | ) 960 | 961 | def accept(self, challenge_id): 962 | """Accept an incoming challenge. 963 | 964 | :param str challenge_id: id of the challenge to accept 965 | :return: success indicator 966 | :rtype: bool 967 | """ 968 | path = f'api/challenge/{challenge_id}/accept' 969 | return self._r.post(path)['ok'] 970 | 971 | def decline(self, challenge_id): 972 | """Decline an incoming challenge. 973 | 974 | :param str challenge_id: id of the challenge to decline 975 | :return: success indicator 976 | :rtype: bool 977 | """ 978 | path = f'api/challenge/{challenge_id}/decline' 979 | return self._r.post(path)['ok'] 980 | 981 | def cancel(self, challenge_id, opponent_token=None): 982 | """Cancel a challenge you sent. 983 | 984 | A challenge that has been accepted but not yet played is aborted. 985 | If the ``opponent_token`` is set, the game can be canceled even if 986 | both players have moved. 987 | 988 | :param str challenge_id: id of the challenge to cancel 989 | :param str opponent_token: the opponent's ``challenge:write`` token 990 | :return: success indicator 991 | :rtype: bool 992 | """ 993 | path = f'api/challenge/{challenge_id}/cancel' 994 | params = {'opponentToken': opponent_token} 995 | return self._r.post(path, params=params)['ok'] 996 | 997 | 998 | class Board(BaseClient): 999 | """Client for physical board or external application endpoints.""" 1000 | 1001 | def stream_incoming_events(self): 1002 | """Get your realtime stream of incoming events. 1003 | 1004 | :return: stream of incoming events 1005 | :rtype: iterator over the stream of events 1006 | """ 1007 | path = 'api/stream/event' 1008 | yield from self._r.get(path, stream=True) 1009 | 1010 | def seek( 1011 | self, 1012 | time, 1013 | increment, 1014 | rated=False, 1015 | variant='standard', 1016 | color='random', 1017 | rating_range=None, 1018 | ): 1019 | """Create a public seek to start a game with a random opponent. 1020 | 1021 | :param int time: intial clock time in minutes 1022 | :param int increment: clock increment in minutes 1023 | :param bool rated: whether the game is rated (impacts ratings) 1024 | :param str variant: game variant to use 1025 | :param str color: color to play 1026 | :param rating_range: range of opponent ratings 1027 | :return: duration of the seek 1028 | :rtype: float 1029 | """ 1030 | if isinstance(rating_range, (list, tuple)): 1031 | low, high = rating_range 1032 | rating_range = f'{low}-{high}' 1033 | 1034 | path = '/api/board/seek' 1035 | payload = { 1036 | 'rated': str(bool(rated)).lower(), 1037 | 'time': time, 1038 | 'increment': increment, 1039 | 'variant': variant, 1040 | 'color': color, 1041 | 'ratingRange': rating_range or '', 1042 | } 1043 | 1044 | # we time the seek 1045 | start = now() 1046 | 1047 | # just keep reading to keep the search going 1048 | for line in self._r.post(path, data=payload, fmt=TEXT, stream=True): 1049 | pass 1050 | 1051 | # and return the time elapsed 1052 | return now() - start 1053 | 1054 | def stream_game_state(self, game_id): 1055 | """Get the stream of events for a board game. 1056 | 1057 | :param str game_id: ID of a game 1058 | :return: iterator over game states 1059 | """ 1060 | path = f'api/board/game/stream/{game_id}' 1061 | yield from self._r.get( 1062 | path, stream=True, converter=models.GameState.convert 1063 | ) 1064 | 1065 | def make_move(self, game_id, move): 1066 | """Make a move in a board game. 1067 | 1068 | :param str game_id: ID of a game 1069 | :param str move: move to make 1070 | :return: success 1071 | :rtype: bool 1072 | """ 1073 | path = f'api/board/game/{game_id}/move/{move}' 1074 | return self._r.post(path)['ok'] 1075 | 1076 | def post_message(self, game_id, text, spectator=False): 1077 | """Post a message in a board game. 1078 | 1079 | :param str game_id: ID of a game 1080 | :param str text: text of the message 1081 | :param bool spectator: post to spectator room (else player room) 1082 | :return: success 1083 | :rtype: bool 1084 | """ 1085 | path = f'api/board/game/{game_id}/chat' 1086 | room = 'spectator' if spectator else 'player' 1087 | payload = {'room': room, 'text': text} 1088 | return self._r.post(path, json=payload)['ok'] 1089 | 1090 | def abort_game(self, game_id): 1091 | """Abort a board game. 1092 | 1093 | :param str game_id: ID of a game 1094 | :return: success 1095 | :rtype: bool 1096 | """ 1097 | path = f'api/board/game/{game_id}/abort' 1098 | return self._r.post(path)['ok'] 1099 | 1100 | def resign_game(self, game_id): 1101 | """Resign a board game. 1102 | 1103 | :param str game_id: ID of a game 1104 | :return: success 1105 | :rtype: bool 1106 | """ 1107 | path = f'api/board/game/{game_id}/resign' 1108 | return self._r.post(path)['ok'] 1109 | 1110 | def handle_draw_offer(self, game_id, accept): 1111 | """Create, accept, or decline a draw offer. 1112 | 1113 | To offer a draw, pass ``accept=True`` and a game ID of an in-progress 1114 | game. To response to a draw offer, pass either ``accept=True`` or 1115 | ``accept=False`` and the ID of a game in which you have recieved a 1116 | draw offer. 1117 | 1118 | Often, it's easier to call :func:`offer_draw`, :func:`accept_draw`, or 1119 | :func:`decline_draw`. 1120 | 1121 | :param str game_id: ID of an in-progress game 1122 | :param bool accept: whether to accept 1123 | :return: True if successful 1124 | :rtype: bool 1125 | """ 1126 | accept = 'yes' if accept else 'no' 1127 | path = f'/api/board/game/{game_id}/draw/{accept}' 1128 | return self._r.post(path)['ok'] 1129 | 1130 | def offer_draw(self, game_id): 1131 | """Offer a draw in the given game. 1132 | 1133 | :param str game_id: ID of an in-progress game 1134 | :return: True if successful 1135 | :rtype: bool 1136 | """ 1137 | return self.handle_draw_offer(game_id, True) 1138 | 1139 | def accept_draw(self, game_id): 1140 | """Accept an already offered draw in the given game. 1141 | 1142 | :param str game_id: ID of an in-progress game 1143 | :return: True if successful 1144 | :rtype: bool 1145 | """ 1146 | return self.handle_draw_offer(game_id, True) 1147 | 1148 | def decline_draw(self, game_id): 1149 | """Decline an already offered draw in the given game. 1150 | 1151 | :param str game_id: ID of an in-progress game 1152 | :return: True if successful 1153 | :rtype: bool 1154 | """ 1155 | return self.handle_draw_offer(game_id, False) 1156 | 1157 | 1158 | class Bots(BaseClient): 1159 | """Client for bot-related endpoints.""" 1160 | 1161 | def stream_incoming_events(self): 1162 | """Get your realtime stream of incoming events. 1163 | 1164 | :return: stream of incoming events 1165 | :rtype: iterator over the stream of events 1166 | """ 1167 | path = 'api/stream/event' 1168 | yield from self._r.get(path, stream=True) 1169 | 1170 | def get_online(self, nb): 1171 | """Stream the online bot users, as ndjson. 1172 | 1173 | :param int nb: how many bot users to fetch 1174 | :return: list of online bots 1175 | :rtype: iter 1176 | """ 1177 | path = 'api/bot/online' 1178 | params = {'nb': nb} 1179 | yield from self._r.get( 1180 | path, params=params, stream=True, converter=models.User.convert 1181 | ) 1182 | 1183 | def upgrade_to_bot(self): 1184 | """Upgrade your account to a bot account. 1185 | 1186 | :return: success 1187 | :rtype: bool 1188 | """ 1189 | path = 'api/bot/account/upgrade' 1190 | return self._r.post(path)['ok'] 1191 | 1192 | def stream_game_state(self, game_id): 1193 | """Get the stream of events for a bot game. 1194 | 1195 | :param str game_id: ID of a game 1196 | :return: iterator over game states 1197 | """ 1198 | path = f'api/bot/game/stream/{game_id}' 1199 | yield from self._r.get( 1200 | path, stream=True, converter=models.GameState.convert 1201 | ) 1202 | 1203 | def make_move(self, game_id, move, offering_draw=False): 1204 | """Make a move in a bot game. 1205 | 1206 | :param str game_id: ID of a game 1207 | :param str move: move to make 1208 | :return: success 1209 | :rtype: bool 1210 | """ 1211 | path = f'api/bot/game/{game_id}/move/{move}' 1212 | params = {'offeringDraw': offering_draw} 1213 | return self._r.post(path, params=params)['ok'] 1214 | 1215 | def post_message(self, game_id, text, spectator=False): 1216 | """Post a message in a bot game. 1217 | 1218 | :param str game_id: ID of a game 1219 | :param str text: text of the message 1220 | :param bool spectator: post to spectator room (else player room) 1221 | :return: success 1222 | :rtype: bool 1223 | """ 1224 | path = f'api/bot/game/{game_id}/chat' 1225 | room = 'spectator' if spectator else 'player' 1226 | payload = {'room': room, 'text': text} 1227 | return self._r.post(path, json=payload)['ok'] 1228 | 1229 | def abort_game(self, game_id): 1230 | """Abort a bot game. 1231 | 1232 | :param str game_id: ID of a game 1233 | :return: success 1234 | :rtype: bool 1235 | """ 1236 | path = f'api/bot/game/{game_id}/abort' 1237 | return self._r.post(path)['ok'] 1238 | 1239 | def resign_game(self, game_id): 1240 | """Resign a bot game. 1241 | 1242 | :param str game_id: ID of a game 1243 | :return: success 1244 | :rtype: bool 1245 | """ 1246 | path = f'api/bot/game/{game_id}/resign' 1247 | return self._r.post(path)['ok'] 1248 | 1249 | @deprecated( 1250 | version='0.11.0', reason='use Challenges.accept_challenge instead' 1251 | ) 1252 | def accept_challenge(self, challenge_id): 1253 | """Accept an incoming challenge. 1254 | 1255 | :param str challenge_id: ID of a challenge 1256 | :return: success 1257 | :rtype: bool 1258 | """ 1259 | path = f'api/challenge/{challenge_id}/accept' 1260 | return self._r.post(path)['ok'] 1261 | 1262 | @deprecated( 1263 | version='0.11.0', reason='use Challenges.decline_challenge instead' 1264 | ) 1265 | def decline_challenge(self, challenge_id): 1266 | """Decline an incoming challenge. 1267 | 1268 | :param str challenge_id: ID of a challenge 1269 | :return: success 1270 | :rtype: bool 1271 | """ 1272 | path = f'api/challenge/{challenge_id}/decline' 1273 | return self._r.post(path)['ok'] 1274 | 1275 | 1276 | class Tournaments(FmtClient): 1277 | """Client for tournament-related endpoints.""" 1278 | 1279 | def get(self): 1280 | """Get recently finished, ongoing, and upcoming tournaments. 1281 | 1282 | :return: current tournaments 1283 | :rtype: list 1284 | """ 1285 | path = 'api/tournament' 1286 | return self._r.get(path, converter=models.Tournaments.convert_values) 1287 | 1288 | def create( 1289 | self, 1290 | clock_time, 1291 | clock_increment, 1292 | minutes, 1293 | name=None, 1294 | wait_minutes=None, 1295 | variant=None, 1296 | berserkable=None, 1297 | rated=None, 1298 | start_date=None, 1299 | position=None, 1300 | password=None, 1301 | conditions=None, 1302 | ): 1303 | """Create a new tournament. 1304 | 1305 | .. note:: 1306 | 1307 | ``wait_minutes`` is always relative to now and is overriden by 1308 | ``start_time``. 1309 | 1310 | .. note:: 1311 | 1312 | If ``name`` is left blank then one is automatically created. 1313 | 1314 | :param int clock_time: intial clock time in minutes 1315 | :param int clock_increment: clock increment in seconds 1316 | :param int minutes: length of the tournament in minutes 1317 | :param str name: tournament name 1318 | :param int wait_minutes: future start time in minutes 1319 | :param str start_date: when to start the tournament 1320 | :param str variant: variant to use if other than standard 1321 | :param bool rated: whether the game affects player ratings 1322 | :param str berserkable: whether players can use berserk 1323 | :param str position: custom initial position in FEN 1324 | :param str password: password (makes the tournament private) 1325 | :param dict conditions: conditions for participation 1326 | :return: created tournament info 1327 | :rtype: dict 1328 | """ 1329 | path = 'api/tournament' 1330 | payload = { 1331 | 'name': name, 1332 | 'clockTime': clock_time, 1333 | 'clockIncrement': clock_increment, 1334 | 'minutes': minutes, 1335 | 'waitMinutes': wait_minutes, 1336 | 'startDate': start_date, 1337 | 'variant': variant, 1338 | 'rated': rated, 1339 | 'position': position, 1340 | 'berserkable': berserkable, 1341 | 'password': password, 1342 | **{f'conditions.{c}': v for c, v in (conditions or {}).items()}, 1343 | } 1344 | return self._r.post( 1345 | path, json=payload, converter=models.Tournament.convert 1346 | ) 1347 | 1348 | def export_games( 1349 | self, 1350 | id_, 1351 | as_pgn=False, 1352 | moves=None, 1353 | tags=None, 1354 | clocks=None, 1355 | evals=None, 1356 | opening=None, 1357 | ): 1358 | """Export games from a tournament. 1359 | 1360 | :param str id_: tournament ID 1361 | :param bool as_pgn: whether to return PGN instead of JSON 1362 | :param bool moves: include moves 1363 | :param bool tags: include tags 1364 | :param bool clocks: include clock comments in the PGN moves, when 1365 | available 1366 | :param bool evals: include analysis evalulation comments in the PGN 1367 | moves, when available 1368 | :param bool opening: include the opening name 1369 | :return: games 1370 | :rtype: list 1371 | """ 1372 | path = f'api/tournament/{id_}/games' 1373 | params = { 1374 | 'moves': moves, 1375 | 'tags': tags, 1376 | 'clocks': clocks, 1377 | 'evals': evals, 1378 | 'opening': opening, 1379 | } 1380 | fmt = PGN if self._use_pgn(as_pgn) else NDJSON 1381 | return self._r.get( 1382 | path, params=params, fmt=fmt, converter=models.Game.convert 1383 | ) 1384 | 1385 | def stream_results(self, id_, limit=None): 1386 | """Stream the results of a tournament. 1387 | 1388 | Results are the players of a tournament with their scores and 1389 | performance in rank order. Note that results for ongoing 1390 | tournaments can be inconsistent due to ranking changes. 1391 | 1392 | :param str id_: tournament ID 1393 | :param int limit: maximum number of results to stream 1394 | :return: iterator over the stream of results 1395 | :rtype: iter 1396 | """ 1397 | path = f'api/tournament/{id_}/results' 1398 | params = {'nb': limit} 1399 | return self._r.get(path, params=params, stream=True) 1400 | 1401 | def stream_by_creator(self, username): 1402 | """Stream the tournaments created by a player. 1403 | 1404 | :param str username: username of the player 1405 | :return: tournaments 1406 | :rtype: iter 1407 | """ 1408 | path = f'api/user/{username}/tournament/created' 1409 | return self._r.get(path, stream=True) 1410 | 1411 | 1412 | class Broadcasts(BaseClient): 1413 | """Broadcast of one or more games.""" 1414 | 1415 | def create( 1416 | self, 1417 | name, 1418 | description, 1419 | sync_url=None, 1420 | markdown=None, 1421 | credit=None, 1422 | starts_at=None, 1423 | official=None, 1424 | throttle=None, 1425 | ): 1426 | """Create a new broadcast. 1427 | 1428 | .. note:: 1429 | 1430 | ``sync_url`` must be publicly accessible. If not provided, you 1431 | must periodically push new PGN to update the broadcast manually. 1432 | 1433 | :param str name: name of the broadcast 1434 | :param str description: short description 1435 | :param str markdown: long description 1436 | :param str sync_url: URL by which Lichess can poll for updates 1437 | :param str credit: short text to give credit to the source provider 1438 | :param int starts_at: start time as millis 1439 | :param bool official: DO NOT USE 1440 | :param int throttle: DO NOT USE 1441 | :return: created tournament info 1442 | :rtype: dict 1443 | """ 1444 | path = 'broadcast/new' 1445 | payload = { 1446 | 'name': name, 1447 | 'description': description, 1448 | 'syncUrl': sync_url, 1449 | 'markdown': markdown, 1450 | 'credit': credit, 1451 | 'startsAt': starts_at, 1452 | 'official': official, 1453 | 'throttle': throttle, 1454 | } 1455 | return self._r.post( 1456 | path, json=payload, converter=models.Broadcast.convert 1457 | ) 1458 | 1459 | def get(self, broadcast_id, slug='-'): 1460 | """Get a broadcast by ID. 1461 | 1462 | :param str broadcast_id: ID of a broadcast 1463 | :param str slug: slug for SEO 1464 | :return: broadcast information 1465 | :rtype: dict 1466 | """ 1467 | path = f'broadcast/{slug}/{broadcast_id}' 1468 | return self._r.get(path, converter=models.Broadcast.convert) 1469 | 1470 | def update( 1471 | self, 1472 | broadcast_id, 1473 | name, 1474 | description, 1475 | sync_url, 1476 | markdown=None, 1477 | credit=None, 1478 | starts_at=None, 1479 | official=None, 1480 | throttle=None, 1481 | slug='-', 1482 | ): 1483 | """Update an existing broadcast by ID. 1484 | 1485 | .. note:: 1486 | 1487 | Provide all fields. Values in missing fields will be erased. 1488 | 1489 | :param str broadcast_id: ID of a broadcast 1490 | :param str name: name of the broadcast 1491 | :param str description: short description 1492 | :param str sync_url: URL by which Lichess can poll for updates 1493 | :param str markdown: long description 1494 | :param str credit: short text to give credit to the source provider 1495 | :param int starts_at: start time as millis 1496 | :param bool official: DO NOT USE 1497 | :param int throttle: DO NOT USE 1498 | :param str slug: slug for SEO 1499 | :return: updated broadcast information 1500 | :rtype: dict 1501 | """ 1502 | path = f'broadcast/{slug}/{broadcast_id}' 1503 | payload = { 1504 | 'name': name, 1505 | 'description': description, 1506 | 'syncUrl': sync_url, 1507 | 'markdown': markdown, 1508 | 'credit': credit, 1509 | 'startsAt': starts_at, 1510 | 'official': official, 1511 | } 1512 | return self._r.post( 1513 | path, json=payload, converter=models.Broadcast.convert 1514 | ) 1515 | 1516 | def push_pgn_update(self, broadcast_id, pgn_games, slug='-'): 1517 | """Manually update an existing broadcast by ID. 1518 | 1519 | :param str broadcast_id: ID of a broadcast 1520 | :param list pgn_games: one or more games in PGN format 1521 | :return: success 1522 | :rtype: bool 1523 | """ 1524 | path = f'broadcast/{slug}/{broadcast_id}/push' 1525 | games = '\n\n'.join(g.strip() for g in pgn_games) 1526 | return self._r.post(path, data=games)['ok'] 1527 | 1528 | 1529 | class Simuls(BaseClient): 1530 | """Simultaneous exhibitions - one vs many.""" 1531 | 1532 | def get(self): 1533 | """Get recently finished, ongoing, and upcoming simuls. 1534 | 1535 | :return: current simuls 1536 | :rtype: list 1537 | """ 1538 | path = 'api/simul' 1539 | return self._r.get(path) 1540 | 1541 | 1542 | class Studies(BaseClient): 1543 | """Study chess the Lichess way.""" 1544 | 1545 | def export_chapter(self, study_id, chapter_id): 1546 | """Export one chapter of a study. 1547 | 1548 | :return: chapter 1549 | :rtype: PGN 1550 | """ 1551 | path = f'/study/{study_id}/{chapter_id}.pgn' 1552 | return self._r.get(path, fmt=PGN) 1553 | 1554 | def export(self, study_id): 1555 | """Export all chapters of a study. 1556 | 1557 | :return: all chapters as PGN 1558 | :rtype: list 1559 | """ 1560 | path = f'/study/{study_id}.pgn' 1561 | return self._r.get(path, fmt=PGN, stream=True) 1562 | 1563 | 1564 | class TV(FmtClient): 1565 | """Chess TV of Lichess.""" 1566 | 1567 | def get_tv_channels(self): 1568 | """Get basic information about the best games being played. 1569 | 1570 | :return: best ongoing games in each speed and variant 1571 | :rtype: dict 1572 | """ 1573 | path = 'api/tv/channels' 1574 | return self._r.get(path) 1575 | 1576 | def stream_current(self): 1577 | """Stream current TV game. 1578 | 1579 | :return: dict of positions and moves of the current TV game 1580 | :rtype: dict 1581 | """ 1582 | path = 'api/tv/feed' 1583 | return self._r.get(path, fmt=NDJSON, stream=True) 1584 | 1585 | def get_best_ongoing( 1586 | self, 1587 | channel, 1588 | nb=10, 1589 | moves=True, 1590 | pgn_in_json=False, 1591 | tags=True, 1592 | clocks=False, 1593 | opening=False, 1594 | ): 1595 | """Get a list of ongoing games for a given TV channel. 1596 | 1597 | :param bool moves: whether to include the PGN moves 1598 | :param int nb: number of games to fetch 1599 | :param bool pgn_in_json: whether to include the full PGN within the 1600 | JSON response, in a ``pgn`` field 1601 | :param bool tags: whether to include the PGN tags 1602 | :param bool clocks: whether to include clock comments in the PGN moves, 1603 | when available 1604 | :param bool opening: whether to include the opening name 1605 | :return: exported game, as JSON or PGN 1606 | :rtype: str or dict 1607 | """ 1608 | path = f'api/tv/{channel}' 1609 | params = { 1610 | 'nb': nb, 1611 | 'moves': moves, 1612 | 'pgnInJson': pgn_in_json, 1613 | 'tags': tags, 1614 | 'clocks': clocks, 1615 | 'opening': opening, 1616 | } 1617 | fmt = PGN if self._use_pgn(not pgn_in_json) else NDJSON 1618 | return self._r.get( 1619 | path, params=params, fmt=fmt, converter=models.Game.convert 1620 | ) 1621 | 1622 | 1623 | class Puzzles(BaseClient): 1624 | """Chess puzzles.""" 1625 | 1626 | def get_daily(self): 1627 | """Get the daily Lichess puzzle. 1628 | 1629 | :return: daily puzzle 1630 | :rtype: dict 1631 | """ 1632 | path = 'api/puzzle/daily' 1633 | return self._r.get(path, fmt=JSON) 1634 | 1635 | def get_activity(self, max_entries=None): 1636 | """Get your puzzle activity. 1637 | 1638 | :param int max_entries: how many entries to download 1639 | :return: your puzzle activity 1640 | :rtype: dict 1641 | """ 1642 | path = 'api/puzzle/activity' 1643 | params = {'max': max_entries} 1644 | return self._r.get(path, params=params, fmt=NDJSON) 1645 | 1646 | def get_dashboard(self, days=30): 1647 | """Get your puzzle dashboard. 1648 | 1649 | :param int days: how many days to look back when aggregating puzzle 1650 | results 1651 | :return: your puzzle dashboard 1652 | :rtype: dict 1653 | """ 1654 | path = f'api/puzzle/dashboard/{days}' 1655 | return self._r.get(path, fmt=JSON) 1656 | 1657 | def get_storm_dashboard(self, username, days=30): 1658 | """Get storm dashboard of player. 1659 | 1660 | :param str username: a username 1661 | :param int days: how many days of history to return 1662 | :return: a player storm dashboard 1663 | :rtype: dict 1664 | """ 1665 | path = f'api/storm/dashboard/{username}' 1666 | params = {'days': days} 1667 | return self._r.get(path, params=params, fmt=JSON) 1668 | 1669 | 1670 | class OpeningExplorer(BaseClient): 1671 | """Chess openings explorer.""" 1672 | 1673 | def masters( 1674 | self, 1675 | fen=None, 1676 | play=None, 1677 | since=1952, 1678 | until=None, 1679 | moves=12, 1680 | top_games=15, 1681 | ): 1682 | """Get openings from the masters database. 1683 | 1684 | :param str fen: FEN of the root position 1685 | :param str play: comma separated sequence of legal moves in UCI 1686 | notation, play additional moves starting from ``fen`` 1687 | :param int since: include only games from this year or later 1688 | :param int until: include only games from this year or earlier 1689 | :param int moves: number of most common moves to display 1690 | :param int top_games: number of top games to display 1691 | :return: masters database search results 1692 | :rtype: dict 1693 | """ 1694 | path = 'masters' 1695 | params = { 1696 | 'fen': fen, 1697 | 'play': play, 1698 | 'since': since, 1699 | 'until': until, 1700 | 'moves': moves, 1701 | 'topGames': top_games, 1702 | } 1703 | return self._r.get(path, params=params) 1704 | 1705 | def lichess( 1706 | self, 1707 | variant='standard', 1708 | fen=None, 1709 | play=None, 1710 | speeds=None, 1711 | ratings=None, 1712 | since='0000-01', 1713 | until=None, 1714 | moves=12, 1715 | top_games=15, 1716 | recent_games=4, 1717 | ): 1718 | """Get openings sampled from all lichess games. 1719 | 1720 | :param str variant: variant 1721 | :param str fen: FEN of the root position 1722 | :param str play: comma separated sequence of legal moves in UCI 1723 | notation, play additional moves starting from ``fen`` 1724 | :param str speeds: comma separated list of game speeds to look for 1725 | :param str ratings: comma separated list of rating groups, 1726 | ranging from their value to the next higher group 1727 | (1600, 1800, 2000, 2200, 2500) 1728 | :param int since: include only games from this month or later 1729 | :param int until: include only games from this month or earlier 1730 | :param int moves: number of most common moves to display 1731 | :param int top_games: number of top games to display 1732 | :param int recent_games: number of recent games to display 1733 | :return: lichess database search results 1734 | :rtype: dict 1735 | """ 1736 | path = 'lichess' 1737 | params = { 1738 | 'variant': variant, 1739 | 'fen': fen, 1740 | 'play': play, 1741 | 'speeds': speeds, 1742 | 'ratings': ratings, 1743 | 'since': since, 1744 | 'until': until, 1745 | 'moves': moves, 1746 | 'topGames': top_games, 1747 | 'recentGames': recent_games, 1748 | } 1749 | return self._r.get(path, params=params) 1750 | 1751 | def player( 1752 | self, 1753 | player, 1754 | color, 1755 | variant='standard', 1756 | fen=None, 1757 | play=None, 1758 | speeds=None, 1759 | modes=None, 1760 | since='0000-01', 1761 | until=None, 1762 | moves=12, 1763 | recent_games=4, 1764 | ): 1765 | """Get openings of a particular lichess player. 1766 | 1767 | :param str player: a username 1768 | :param str color: white or black 1769 | :param str variant: variant 1770 | :param str fen: FEN of the root position 1771 | :param str play: comma separated sequence of legal moves in UCI 1772 | notation, play additional moves starting from ``fen`` 1773 | :param str speeds: comma separated list of game speeds to look for 1774 | :param str modes: casual or rated 1775 | :param int since: include only games from this month or later 1776 | :param int until: include only games from this month or earlier 1777 | :param int moves: number of most common moves to display 1778 | :param int recent_games: number of recent games to display 1779 | :return: player database search results 1780 | :rtype: dict 1781 | """ 1782 | path = 'player' 1783 | params = { 1784 | 'player': player, 1785 | 'color': color, 1786 | 'variant': variant, 1787 | 'fen': fen, 1788 | 'play': play, 1789 | 'speeds': speeds, 1790 | 'modes': modes, 1791 | 'since': since, 1792 | 'until': until, 1793 | 'moves': moves, 1794 | 'recentGames': recent_games, 1795 | } 1796 | return self._r.get(path, params=params, fmt=NDJSON) 1797 | -------------------------------------------------------------------------------- /berserk/enums.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | __all__ = ['PerfType', 'Variant', 'Color', 'Room', 'Mode', 'Position'] 5 | 6 | 7 | class GameType: 8 | ANTICHESS = 'antichess' 9 | ATOMIC = 'atomic' 10 | CHESS960 = 'chess960' 11 | CRAZYHOUSE = 'crazyhouse' 12 | HORDE = 'horde' 13 | KING_OF_THE_HILL = 'kingOfTheHill' 14 | RACING_KINGS = 'racingKings' 15 | THREE_CHECK = 'threeCheck' 16 | 17 | 18 | class PerfType(GameType): 19 | BULLET = 'bullet' 20 | BLITZ = 'blitz' 21 | RAPID = 'rapid' 22 | CLASSICAL = 'classical' 23 | ULTRA_BULLET = 'ultraBullet' 24 | 25 | 26 | class Variant(GameType): 27 | STANDARD = 'standard' 28 | 29 | 30 | class Color: 31 | WHITE = 'white' 32 | BLACK = 'black' 33 | 34 | 35 | class Room: 36 | PLAYER = 'player' 37 | SPECTATOR = 'spectator' 38 | 39 | 40 | class Mode: 41 | CASUAL = 'casual' 42 | RATED = 'rated' 43 | 44 | 45 | class Position: 46 | ALEKHINES_DEFENCE = 'rnbqkb1r/pppppppp/5n2/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 2 2' # noqa: E501 47 | ALEKHINES_DEFENCE__MODERN_VARIATION = 'rnbqkb1r/ppp1pppp/3p4/3nP3/3P4/5N2/PPP2PPP/RNBQKB1R b KQkq - 1 4' # noqa: E501 48 | BENKO_GAMBIT = 'rnbqkb1r/p2ppppp/5n2/1ppP4/2P5/8/PP2PPPP/RNBQKBNR w KQkq b6 1 4' # noqa: E501 49 | BENONI_DEFENCE__CZECH_BENONI = 'rnbqkb1r/pp1p1ppp/5n2/2pPp3/2P5/8/PP2PPPP/RNBQKBNR w KQkq - 0 4' # noqa: E501 50 | BENONI_DEFENCE__MODERN_BENONI = 'rnbqkb1r/pp1p1ppp/4pn2/2pP4/2P5/8/PP2PPPP/RNBQKBNR w KQkq - 0 4' # noqa: E501 51 | BISHOPS_OPENING = 'rnbqkbnr/pppp1ppp/8/4p3/2B1P3/8/PPPP1PPP/RNBQK1NR b KQkq - 2 2' # noqa: E501 52 | BLACKMAR_DIEMER_GAMBIT = 'rnbqkbnr/ppp1pppp/8/3p4/3PP3/8/PPP2PPP/RNBQKBNR b KQkq e3 1 2' # noqa: E501 53 | BOGO_INDIAN_DEFENCE = 'rnbqk2r/pppp1ppp/4pn2/8/1bPP4/5N2/PP2PPPP/RNBQKB1R w KQkq - 3 4' # noqa: E501 54 | BONGCLOUD_ATTACK = 'rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPPKPPP/RNBQ1BNR b kq - 0 2' # noqa: E501 55 | BUDAPEST_DEFENCE = 'rnbqkb1r/pppp1ppp/5n2/4p3/2PP4/8/PP2PPPP/RNBQKBNR w KQkq - 0 3' # noqa: E501 56 | CARO_KANN_DEFENCE = 'rnbqkbnr/pp1ppppp/2p5/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 1 2' # noqa: E501 57 | CARO_KANN_DEFENCE__ADVANCE_VARIATION = 'rnbqkbnr/pp2pppp/2p5/3pP3/3P4/8/PPP2PPP/RNBQKBNR b KQkq - 1 3' # noqa: E501 58 | CARO_KANN_DEFENCE__CLASSICAL_VARIATION = 'rn1qkbnr/pp2pppp/2p5/5b2/3PN3/8/PPP2PPP/R1BQKBNR w KQkq - 2 5' # noqa: E501 59 | CARO_KANN_DEFENCE__EXCHANGE_VARIATION = 'rnbqkbnr/pp2pppp/2p5/3P4/3P4/8/PPP2PPP/RNBQKBNR b KQkq - 1 3' # noqa: E501 60 | CARO_KANN_DEFENCE__PANOV_BOTVINNIK_ATTACK = 'rnbqkb1r/pp3ppp/4pn2/3p4/2PP4/2N5/PP3PPP/R1BQKBNR w KQkq - 1 6' # noqa: E501 61 | CARO_KANN_DEFENCE__STEINITZ_VARIATION = 'rnbqkb1r/pp3ppp/4pn2/3p4/2PP4/2N5/PP3PPP/R1BQKBNR w KQkq - 1 6' # noqa: E501 62 | CATALAN_OPENING = 'rnbqkb1r/pppp1ppp/4pn2/8/2PP4/6P1/PP2PP1P/RNBQKBNR b KQkq - 1 3' # noqa: E501 63 | CATALAN_OPENING__CLOSED_VARIATION = 'rnbqk2r/ppp1bppp/4pn2/3p4/2PP4/5NP1/PP2PPBP/RNBQK2R b KQkq - 4 5' # noqa: E501 64 | CLOSED_GAME = 'rnbqkbnr/ppp1pppp/8/3p4/3P4/8/PPP1PPPP/RNBQKBNR w KQkq - 0 2' # noqa: E501 65 | DANISH_GAMBIT = 'rnbqkbnr/pppp1ppp/8/8/3pP3/2P5/PP3PPP/RNBQKBNR b KQkq - 1 3' # noqa: E501 66 | DUTCH_DEFENCE = 'rnbqkbnr/ppppp1pp/8/5p2/3P4/8/PPP1PPPP/RNBQKBNR w KQkq f6 1 2' # noqa: E501 67 | DUTCH_DEFENCE__LENINGRAD_VARIATION = 'rnbqk2r/ppppp1bp/5np1/5p2/2PP4/5NP1/PP2PPBP/RNBQK2R b KQkq - 4 5' # noqa: E501 68 | DUTCH_DEFENCE__STAUNTON_GAMBIT = 'rnbqkb1r/ppppp1pp/5n2/6B1/3Pp3/2N5/PPP2PPP/R2QKBNR b KQkq - 4 4' # noqa: E501 69 | DUTCH_DEFENCE__STONEWALL_VARIATION = 'rnbq1rk1/ppp1b1pp/4pn2/3p1p2/2PP4/5NP1/PP2PPBP/RNBQ1RK1 w - d6 1 7' # noqa: E501 70 | ENGLISH_OPENING = 'rnbqkbnr/pppppppp/8/8/2P5/8/PP1PPPPP/RNBQKBNR b KQkq c3 1 1' # noqa: E501 71 | ENGLISH_OPENING__CLOSED_SYSTEM = 'r1bqk1nr/ppp2pbp/2np2p1/4p3/2P5/2NP2P1/PP2PPBP/R1BQK1NR w KQkq - 0 6' # noqa: E501 72 | ENGLISH_OPENING__REVERSED_SICILIAN = 'rnbqkbnr/pppp1ppp/8/4p3/2P5/8/PP1PPPPP/RNBQKBNR w KQkq e6 1 2' # noqa: E501 73 | ENGLISH_OPENING__SYMMETRICAL_VARIATION = 'rnbqkbnr/pp1ppppp/8/2p5/2P5/8/PP1PPPPP/RNBQKBNR w KQkq c6 1 2' # noqa: E501 74 | FOUR_KNIGHTS_GAME = 'r1bqkb1r/pppp1ppp/2n2n2/4p3/4P3/2N2N2/PPPP1PPP/R1BQKB1R w KQkq - 5 4' # noqa: E501 75 | FOUR_KNIGHTS_GAME__SCOTCH_VARIATION = 'r1bqkb1r/pppp1ppp/2n2n2/4p3/3PP3/2N2N2/PPP2PPP/R1BQKB1R b KQkq d3 1 4' # noqa: E501 76 | FOUR_KNIGHTS_GAME__SPANISH_VARIATION = 'r1bqkb1r/pppp1ppp/2n2n2/1B2p3/4P3/2N2N2/PPPP1PPP/R1BQK2R b KQkq - 0 4' # noqa: E501 77 | FRANKENSTEIN_DRACULA_VARIATION = 'rnbqkb1r/pppp1ppp/8/4p3/2B1n3/2N5/PPPP1PPP/R1BQK1NR w KQkq - 0 4' # noqa: E501 78 | FRENCH_DEFENCE = 'rnbqkbnr/pppp1ppp/4p3/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 1 2' # noqa: E501 79 | FRENCH_DEFENCE__ADVANCE_VARIATION = 'rnbqkbnr/ppp2ppp/4p3/3pP3/3P4/8/PPP2PPP/RNBQKBNR b KQkq - 1 3' # noqa: E501 80 | FRENCH_DEFENCE__BURN_VARIATION = 'rnbqkb1r/ppp2ppp/4pn2/3p2B1/3PP3/2N5/PPP2PPP/R2QKBNR b KQkq - 1 4' # noqa: E501 81 | FRENCH_DEFENCE__CLASSICAL_VARIATION = 'rnbqkb1r/ppp2ppp/4pn2/3p4/3PP3/2N5/PPP2PPP/R1BQKBNR w KQkq - 3 4' # noqa: E501 82 | FRENCH_DEFENCE__EXCHANGE_VARIATION = 'rnbqkbnr/ppp2ppp/4p3/3P4/3P4/8/PPP2PPP/RNBQKBNR b KQkq - 1 3' # noqa: E501 83 | FRENCH_DEFENCE__RUBINSTEIN_VARIATION = 'rnbqkbnr/ppp2ppp/4p3/8/3Pp3/2N5/PPP2PPP/R1BQKBNR w KQkq - 1 4' # noqa: E501 84 | FRENCH_DEFENCE__TARRASCH_VARIATION = 'rnbqkbnr/ppp2ppp/4p3/3p4/3PP3/8/PPPN1PPP/R1BQKBNR b KQkq - 2 3' # noqa: E501 85 | FRENCH_DEFENCE__WINAWER_VARIATION = 'rnbqk1nr/ppp2ppp/4p3/3p4/1b1PP3/2N5/PPP2PPP/R1BQKBNR w KQkq - 3 4' # noqa: E501 86 | GIUOCO_PIANO = 'r1bqk1nr/pppp1ppp/2n5/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 5 4' # noqa: E501 87 | GRUNFELD_DEFENCE = 'rnbqkb1r/ppp1pp1p/5np1/3p4/2PP4/2N5/PP2PPPP/R1BQKBNR w KQkq d6 1 4' # noqa: E501 88 | GRUNFELD_DEFENCE__BRINCKMANN_ATTACK = 'rnbqkb1r/ppp1pp1p/5np1/3p4/2PP1B2/2N5/PP2PPPP/R2QKBNR b KQkq - 2 4' # noqa: E501 89 | GRUNFELD_DEFENCE__EXCHANGE_VARIATION = 'rnbqkb1r/ppp1pp1p/6p1/3n4/3P4/2N5/PP2PPPP/R1BQKBNR w KQkq - 1 5' # noqa: E501 90 | GRUNFELD_DEFENCE__RUSSIAN_VARIATION = 'rnbqkb1r/ppp1pp1p/5np1/3p4/2PP4/1QN5/PP2PPPP/R1B1KBNR b KQkq - 0 4' # noqa: E501 91 | GRUNFELD_DEFENCE__TAIMANOV_VARIATION = 'rnbqk2r/ppp1ppbp/5np1/3p2B1/2PP4/2N2N2/PP2PPPP/R2QKB1R b KQkq - 0 5' # noqa: E501 92 | HALLOWEEN_GAMBIT = 'r1bqkb1r/pppp1ppp/2n2n2/4N3/4P3/2N5/PPPP1PPP/R1BQKB1R b KQkq - 1 4' # noqa: E501 93 | HUNGARIAN_OPENING = 'rnbqkbnr/pppppppp/8/8/8/6P1/PPPPPP1P/RNBQKBNR b KQkq - 1 1' # noqa: E501 94 | ITALIAN_GAME = 'r1bqkbnr/pppp1ppp/2n5/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R b KQkq - 4 3' # noqa: E501 95 | ITALIAN_GAME__EVANS_GAMBIT = 'r1bqk1nr/pppp1ppp/2n5/2b1p3/1PB1P3/5N2/P1PP1PPP/RNBQK2R b KQkq b3 1 4' # noqa: E501 96 | ITALIAN_GAME__HUNGARIAN_DEFENCE = 'r1bqk1nr/ppppbppp/2n5/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 5 4' # noqa: E501 97 | ITALIAN_GAME__TWO_KNIGHTS_DEFENCE = 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 5 4' # noqa: E501 98 | KINGS_GAMBIT = 'rnbqkbnr/pppp1ppp/8/4p3/4PP2/8/PPPP2PP/RNBQKBNR b KQkq f3 1 2' # noqa: E501 99 | KINGS_GAMBIT_ACCEPTED = 'rnbqkbnr/pppp1ppp/8/8/4Pp2/8/PPPP2PP/RNBQKBNR w KQkq - 1 3' # noqa: E501 100 | KINGS_GAMBIT_ACCEPTED__BISHOPS_GAMBIT = 'rnbqkbnr/pppp1ppp/8/8/2B1Pp2/8/PPPP2PP/RNBQK1NR b KQkq - 2 3' # noqa: E501 101 | KINGS_GAMBIT_ACCEPTED__CLASSICAL_VARIATION = 'rnbqkbnr/pppp1p1p/8/6p1/4Pp2/5N2/PPPP2PP/RNBQKB1R w KQkq - 0 4' # noqa: E501 102 | KINGS_GAMBIT_ACCEPTED__MODERN_DEFENCE = 'rnbqkbnr/ppp2ppp/8/3p4/4Pp2/5N2/PPPP2PP/RNBQKB1R w KQkq d6 1 4' # noqa: E501 103 | KINGS_GAMBIT_DECLINED__CLASSICAL_VARIATION = 'rnbqk1nr/pppp1ppp/8/2b1p3/4PP2/8/PPPP2PP/RNBQKBNR w KQkq - 2 3' # noqa: E501 104 | KINGS_GAMBIT_DECLINED__FALKBEER_COUNTERGAMBIT = 'rnbqkbnr/ppp2ppp/8/3pp3/4PP2/8/PPPP2PP/RNBQKBNR w KQkq d6 1 3' # noqa: E501 105 | KINGS_INDIAN_ATTACK = 'rnbqkbnr/ppp1pppp/8/3p4/8/5NP1/PPPPPP1P/RNBQKB1R b KQkq - 1 2' # noqa: E501 106 | KINGS_INDIAN_DEFENCE = 'rnbqkb1r/pppppp1p/5np1/8/2PP4/8/PP2PPPP/RNBQKBNR w KQkq - 1 3' # noqa: E501 107 | KINGS_INDIAN_DEFENCE__4E4 = 'rnbqk2r/ppp1ppbp/3p1np1/8/2PPP3/2N5/PP3PPP/R1BQKBNR w KQkq - 1 5' # noqa: E501 108 | KINGS_INDIAN_DEFENCE__AVERBAKH_VARIATION = 'rnbq1rk1/ppp1ppbp/3p1np1/6B1/2PPP3/2N5/PP2BPPP/R2QK1NR b KQ - 4 6' # noqa: E501 109 | KINGS_INDIAN_DEFENCE__CLASSICAL_VARIATION = 'rnbq1rk1/ppp1ppbp/3p1np1/8/2PPP3/2N2N2/PP2BPPP/R1BQK2R b KQ - 4 6' # noqa: E501 110 | KINGS_INDIAN_DEFENCE__FIANCHETTO_VARIATION = 'rnbqk2r/ppp1ppbp/3p1np1/8/2PP4/2N2NP1/PP2PP1P/R1BQKB1R b KQkq - 1 5' # noqa: E501 111 | KINGS_INDIAN_DEFENCE__FOUR_PAWNS_ATTACK = 'rnbqk2r/ppp1ppbp/3p1np1/8/2PPPP2/2N5/PP4PP/R1BQKBNR b KQkq f3 1 5' # noqa: E501 112 | KINGS_INDIAN_DEFENCE__SAMISCH_VARIATION = 'rnbqk2r/ppp1ppbp/3p1np1/8/2PPP3/2N2P2/PP4PP/R1BQKBNR b KQkq - 1 5' # noqa: E501 113 | KINGS_PAWN = 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 1 1' 114 | LONDON_SYSTEM = 'rnbqkb1r/ppp1pppp/5n2/3p4/3P1B2/5N2/PPP1PPPP/RN1QKB1R b KQkq - 4 3' # noqa: E501 115 | MODERN_DEFENCE = 'rnbqkbnr/pppppp1p/6p1/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2' # noqa: E501 116 | MODERN_DEFENCE__ROBATSCH_DEFENCE = 'rnbqk1nr/ppppppbp/6p1/8/3PP3/2N5/PPP2PPP/R1BQKBNR b KQkq - 0 3' # noqa: E501 117 | NIMZO_INDIAN_DEFENCE = 'rnbqk2r/pppp1ppp/4pn2/8/1bPP4/2N5/PP2PPPP/R1BQKBNR w KQkq - 3 4' # noqa: E501 118 | NIMZO_INDIAN_DEFENCE__CLASSICAL_VARIATION = 'rnbqk2r/pppp1ppp/4pn2/8/1bPP4/2N5/PPQ1PPPP/R1B1KBNR b KQkq - 4 4' # noqa: E501 119 | NIMZO_INDIAN_DEFENCE__FISCHER_VARIATION = 'rnbqk2r/p1pp1ppp/1p2pn2/8/1bPP4/2N1P3/PP3PPP/R1BQKBNR w KQkq - 0 5' # noqa: E501 120 | NIMZO_INDIAN_DEFENCE__HUBNER_VARIATION = 'r1bqk2r/pp3ppp/2nppn2/2p5/2PP4/2PBPN2/P4PPP/R1BQK2R w KQkq - 0 8' # noqa: E501 121 | NIMZO_INDIAN_DEFENCE__KASPAROV_VARIATION = 'rnbqk2r/pppp1ppp/4pn2/8/1bPP4/2N2N2/PP2PPPP/R1BQKB1R b KQkq - 0 4' # noqa: E501 122 | NIMZO_INDIAN_DEFENCE__LENINGRAD_VARIATION = 'rnbqk2r/pppp1ppp/4pn2/6B1/1bPP4/2N5/PP2PPPP/R2QKBNR b KQkq - 0 4' # noqa: E501 123 | NIMZO_INDIAN_DEFENCE__SAMISCH_VARIATION = 'rnbqk2r/pppp1ppp/4pn2/8/2PP4/P1P5/4PPPP/R1BQKBNR b KQkq - 0 5' # noqa: E501 124 | NIMZO_LARSEN_ATTACK = 'rnbqkbnr/pppppppp/8/8/8/1P6/P1PPPPPP/RNBQKBNR b KQkq - 1 1' # noqa: E501 125 | OLD_INDIAN_DEFENCE = 'rnbqkb1r/ppp1pppp/3p1n2/8/2PP4/8/PP2PPPP/RNBQKBNR w KQkq - 1 3' # noqa: E501 126 | OPEN_GAME = 'rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2' 127 | PETROVS_DEFENCE = 'rnbqkb1r/pppp1ppp/5n2/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 3 3' # noqa: E501 128 | PETROVS_DEFENCE__CLASSICAL_ATTACK = 'rnbqkb1r/ppp2ppp/3p4/8/3Pn3/5N2/PPP2PPP/RNBQKB1R b KQkq d3 1 5' # noqa: E501 129 | PETROVS_DEFENCE__STEINITZ_ATTACK = 'rnbqkb1r/pppp1ppp/5n2/4p3/3PP3/5N2/PPP2PPP/RNBQKB1R b KQkq d3 1 3' # noqa: E501 130 | PETROVS_DEFENCE__THREE_KNIGHTS_GAME = 'rnbqkb1r/pppp1ppp/5n2/4p3/4P3/2N2N2/PPPP1PPP/R1BQKB1R b KQkq - 4 3' # noqa: E501 131 | PHILIDOR_DEFENCE = 'rnbqkbnr/ppp2ppp/3p4/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 1 3' # noqa: E501 132 | PIRC_DEFENCE = 'rnbqkb1r/ppp1pppp/3p1n2/8/3PP3/8/PPP2PPP/RNBQKBNR w KQkq - 2 3' # noqa: E501 133 | PIRC_DEFENCE__AUSTRIAN_ATTACK = 'rnbqkb1r/ppp1pp1p/3p1np1/8/3PPP2/2N5/PPP3PP/R1BQKBNR b KQkq f3 1 4' # noqa: E501 134 | PIRC_DEFENCE__CLASSICAL_VARIATION = 'rnbqkb1r/ppp1pp1p/3p1np1/8/3PP3/2N2N2/PPP2PPP/R1BQKB1R b KQkq - 2 4' # noqa: E501 135 | QUEENS_GAMBIT = 'rnbqkbnr/ppp1pppp/8/3p4/2PP4/8/PP2PPPP/RNBQKBNR b KQkq c3 1 2' # noqa: E501 136 | QUEENS_GAMBIT_ACCEPTED = 'rnbqkbnr/ppp1pppp/8/8/2pP4/8/PP2PPPP/RNBQKBNR w KQkq - 1 3' # noqa: E501 137 | QUEENS_GAMBIT_DECLINED__ALBIN_COUNTERGAMBIT = 'rnbqkbnr/ppp2ppp/8/3pp3/2PP4/8/PP2PPPP/RNBQKBNR w KQkq e6 1 3' # noqa: E501 138 | QUEENS_GAMBIT_DECLINED__CHIGORIN_DEFENCE = 'r1bqkbnr/ppp1pppp/2n5/3p4/2PP4/8/PP2PPPP/RNBQKBNR w KQkq - 2 3' # noqa: E501 139 | QUEENS_GAMBIT_DECLINED__SEMI_SLAV_DEFENCE = 'rnbqkb1r/pp3ppp/2p1pn2/3p4/2PP4/2N2N2/PP2PPPP/R1BQKB1R w KQkq - 1 5' # noqa: E501 140 | QUEENS_GAMBIT_DECLINED__SEMI_TARRASCH_DEFENCE = 'rnbqkb1r/pp3ppp/4pn2/2pp4/2PP4/2N2N2/PP2PPPP/R1BQKB1R w KQkq c6 1 5' # noqa: E501 141 | QUEENS_GAMBIT_DECLINED__SLAV_DEFENCE = 'rnbqkbnr/pp2pppp/2p5/3p4/2PP4/8/PP2PPPP/RNBQKBNR w KQkq - 0 3' # noqa: E501 142 | QUEENS_GAMBIT_DECLINED__TARRASCH_DEFENCE = 'rnbqkbnr/pp3ppp/4p3/2pp4/2PP4/2N5/PP2PPPP/R1BQKBNR w KQkq - 0 4' # noqa: E501 143 | QUEENS_INDIAN_DEFENCE = 'rnbqkb1r/p1pp1ppp/1p2pn2/8/2PP4/5N2/PP2PPPP/RNBQKB1R w KQkq - 1 4' # noqa: E501 144 | QUEENS_PAWN = 'rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq d3 1 1' # noqa: E501 145 | QUEENSS_PAWN_GAME__MODERN_DEFENCE = 'rnbqk1nr/ppp1ppbp/3p2p1/8/2PP4/2N5/PP2PPPP/R1BQKBNR w KQkq - 1 4' # noqa: E501 146 | RICHTER_VERESOV_ATTACK = 'rnbqkb1r/ppp1pppp/5n2/3p2B1/3P4/2N5/PPP1PPPP/R2QKBNR b KQkq - 4 3' # noqa: E501 147 | RUY_LOPEZ = 'r1bqkbnr/pppp1ppp/2n5/1B2p3/4P3/5N2/PPPP1PPP/RNBQK2R b KQkq - 4 3' # noqa: E501 148 | RUY_LOPEZ__BERLIN_DEFENCE = 'r1bqkb1r/pppp1ppp/2n2n2/1B2p3/4P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 5 4' # noqa: E501 149 | RUY_LOPEZ__CLASSICAL_VARIATION = 'r1bqk1nr/pppp1ppp/2n5/1Bb1p3/4P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 5 4' # noqa: E501 150 | RUY_LOPEZ__CLOSED_VARIATION = 'r1bqk2r/2ppbppp/p1n2n2/1p2p3/4P3/1B3N2/PPPP1PPP/RNBQR1K1 b kq - 0 7' # noqa: E501 151 | RUY_LOPEZ__EXCHANGE_VARIATION = 'r1bqkbnr/1ppp1ppp/p1B5/4p3/4P3/5N2/PPPP1PPP/RNBQK2R b KQkq - 1 4' # noqa: E501 152 | RUY_LOPEZ__MARSHALL_ATTACK = 'r1bq1rk1/2p1bppp/p1n2n2/1p1pp3/4P3/1BP2N2/PP1P1PPP/RNBQR1K1 w - - 0 9' # noqa: E501 153 | RUY_LOPEZ__SCHLIEMANN_DEFENCE = 'r1bqkbnr/pppp2pp/2n5/1B2pp2/4P3/5N2/PPPP1PPP/RNBQK2R w KQkq f6 1 4' # noqa: E501 154 | RETI_OPENING = 'rnbqkbnr/ppp1pppp/8/3p4/2P5/5N2/PP1PPPPP/RNBQKB1R b KQkq c3 1 2' # noqa: E501 155 | SCANDINAVIAN_DEFENCE = 'rnbqkbnr/ppp1pppp/8/3p4/4P3/8/PPPP1PPP/RNBQKBNR w KQkq d6 1 2' # noqa: E501 156 | SCANDINAVIAN_DEFENCE__MODERN_VARIATION = 'rnbqkb1r/ppp1pppp/5n2/3P4/3P4/8/PPP2PPP/RNBQKBNR b KQkq - 0 3' # noqa: E501 157 | SCOTCH_GAME = 'r1bqkbnr/pppp1ppp/2n5/4p3/3PP3/5N2/PPP2PPP/RNBQKB1R b KQkq d3 1 3' # noqa: E501 158 | SCOTCH_GAME__CLASSICAL_VARIATION = 'r1bqk1nr/pppp1ppp/2n5/2b5/3NP3/8/PPP2PPP/RNBQKB1R w KQkq - 2 5' # noqa: E501 159 | SCOTCH_GAME__MIESES_VARIATION = 'r1bqkb1r/p1pp1ppp/2p2n2/4P3/8/8/PPP2PPP/RNBQKB1R b KQkq - 1 6' # noqa: E501 160 | SCOTCH_GAME__STEINITZ_VARIATION = 'r1b1kbnr/pppp1ppp/2n5/8/3NP2q/8/PPP2PPP/RNBQKB1R w KQkq - 2 5' # noqa: E501 161 | SICILIAN_DEFENCE = 'rnbqkbnr/pp1ppppp/8/2p5/4P3/8/PPPP1PPP/RNBQKBNR w KQkq c6 1 2' # noqa: E501 162 | SICILIAN_DEFENCE__ACCELERATED_DRAGON = 'r1bqkbnr/pp1ppp1p/2n3p1/8/3NP3/8/PPP2PPP/RNBQKB1R w KQkq - 1 5' # noqa: E501 163 | SICILIAN_DEFENCE__ALAPIN_VARIATION = 'rnbqkbnr/pp1ppppp/8/2p5/4P3/2P5/PP1P1PPP/RNBQKBNR b KQkq - 1 2' # noqa: E501 164 | SICILIAN_DEFENCE__CLOSED_VARIATION = 'rnbqkbnr/pp1ppppp/8/2p5/4P3/2N5/PPPP1PPP/R1BQKBNR b KQkq - 2 2' # noqa: E501 165 | SICILIAN_DEFENCE__DRAGON_VARIATION = 'rnbqkb1r/pp2pp1p/3p1np1/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 1 6' # noqa: E501 166 | SICILIAN_DEFENCE__GRAND_PRIX_ATTACK = 'r1bqkbnr/pp1ppppp/2n5/2p5/4PP2/2N5/PPPP2PP/R1BQKBNR b KQkq f3 1 3' # noqa: E501 167 | SICILIAN_DEFENCE__HYPER_ACCELERATED_DRAGON = 'rnbqkbnr/pp1ppp1p/6p1/2p5/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 1 2' # noqa: E501 168 | SICILIAN_DEFENCE__KAN_VARIATION = 'rnbqkbnr/1p1p1ppp/p3p3/8/3NP3/8/PPP2PPP/RNBQKB1R w KQkq - 1 5' # noqa: E501 169 | SICILIAN_DEFENCE__NAJDORF_VARIATION = 'rnbqkb1r/1p2pppp/p2p1n2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 1 6' # noqa: E501 170 | SICILIAN_DEFENCE__RICHTER_RAUZER_VARIATION = 'r1bqkb1r/pp2pppp/2np1n2/6B1/3NP3/2N5/PPP2PPP/R2QKB1R b KQkq - 5 6' # noqa: E501 171 | SICILIAN_DEFENCE__SCHEVENINGEN_VARIATION = 'rnbqkb1r/pp3ppp/3ppn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 1 6' # noqa: E501 172 | SICILIAN_DEFENCE__SMITH_MORRA_GAMBIT = 'rnbqkbnr/pp1ppppp/8/8/3pP3/2P5/PP3PPP/RNBQKBNR b KQkq - 1 3' # noqa: E501 173 | SOKOLSKY_OPENING = 'rnbqkbnr/pppppppp/8/8/1P6/8/P1PPPPPP/RNBQKBNR b KQkq - 1 1' # noqa: E501 174 | TORRE_ATTACK = 'rnbqkb1r/ppp1pppp/5n2/3p2B1/3P4/5N2/PPP1PPPP/RN1QKB1R b KQkq - 4 3' # noqa: E501 175 | TROMPOWSKY_ATTACK = 'rnbqkb1r/pppppppp/5n2/6B1/3P4/8/PPP1PPPP/RN1QKBNR b KQkq - 3 2' # noqa: E501 176 | VIENNA_GAME = 'rnbqkbnr/pppp1ppp/8/4p3/4P3/2N5/PPPP1PPP/R1BQKBNR b KQkq - 2 2' # noqa: E501 177 | ZUKERTORT_OPENING = 'rnbqkbnr/pppppppp/8/8/8/5N2/PPPPPPPP/RNBQKB1R b KQkq - 1 1' # noqa: E501 178 | -------------------------------------------------------------------------------- /berserk/exceptions.py: -------------------------------------------------------------------------------- 1 | def get_message(e): 2 | return e.args[0] if e.args else '' 3 | 4 | 5 | def set_message(e, value): 6 | args = list(e.args) 7 | if args: 8 | args[0] = value 9 | else: 10 | args.append(value) 11 | e.args = args 12 | 13 | 14 | class BerserkError(Exception): 15 | message = property(get_message, set_message) 16 | 17 | 18 | class ApiError(BerserkError): 19 | def __init__(self, error): 20 | super().__init__(get_message(error)) 21 | self.__cause__ = self.error = error 22 | 23 | 24 | class ResponseError(ApiError): 25 | """Response that indicates an error.""" 26 | 27 | # sentinal object for when None is a valid result 28 | __UNDEFINED = object() 29 | 30 | def __init__(self, response): 31 | error = ResponseError._catch_exception(response) 32 | super().__init__(error) 33 | self._cause = ResponseError.__UNDEFINED 34 | self.response = response 35 | base_message = f'HTTP {self.status_code}: {self.reason}' 36 | if self.cause: 37 | self.message = f'{base_message}: {self.cause}' 38 | 39 | @property 40 | def status_code(self): 41 | """HTTP status code of the response.""" 42 | return self.response.status_code 43 | 44 | @property 45 | def reason(self): 46 | """HTTP status text of the response.""" 47 | return self.response.reason 48 | 49 | @property 50 | def cause(self): 51 | if self._cause is ResponseError.__UNDEFINED: 52 | try: 53 | self._cause = self.response.json() 54 | except Exception: 55 | self._cause = None 56 | return self._cause 57 | 58 | @staticmethod 59 | def _catch_exception(response): 60 | try: 61 | response.raise_for_status() 62 | except Exception as e: 63 | return e 64 | -------------------------------------------------------------------------------- /berserk/formats.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | 4 | import ndjson 5 | 6 | from . import utils 7 | 8 | 9 | class FormatHandler: 10 | """Provide request headers and parse responses for a particular format. 11 | 12 | Instances of this class should override the :meth:`parse_stream` and 13 | :meth:`parse` methods to support handling both streaming and non-streaming 14 | responses. 15 | 16 | :param str mime_type: the MIME type for the format 17 | """ 18 | 19 | def __init__(self, mime_type): 20 | self.mime_type = mime_type 21 | self.headers = {'Accept': mime_type} 22 | 23 | def handle(self, response, is_stream, converter=utils.noop): 24 | """Handle the response by returning the data. 25 | 26 | :param response: raw response 27 | :type response: :class:`requests.Response` 28 | :param bool is_stream: ``True`` if the response is a stream 29 | :param func converter: function to handle field conversions 30 | :return: either all response data or an iterator of response data 31 | """ 32 | if is_stream: 33 | return map(converter, iter(self.parse_stream(response))) 34 | else: 35 | return converter(self.parse(response)) 36 | 37 | def parse(self, response): 38 | """Parse all data from a response. 39 | 40 | :param response: raw response 41 | :type response: :class:`requests.Response` 42 | :return: response data 43 | """ 44 | return response 45 | 46 | def parse_stream(self, response): 47 | """Yield the parsed data from a stream response. 48 | 49 | :param response: raw response 50 | :type response: :class:`requests.Response` 51 | :return: iterator over the response data 52 | """ 53 | yield response 54 | 55 | 56 | class JsonHandler(FormatHandler): 57 | """Handle JSON data. 58 | 59 | :param str mime_type: the MIME type for the format 60 | :param decoder: the decoder to use for the JSON format 61 | :type decoder: :class:`json.JSONDecoder` 62 | """ 63 | 64 | def __init__(self, mime_type, decoder=json.JSONDecoder): 65 | super().__init__(mime_type=mime_type) 66 | self.decoder = decoder 67 | 68 | def parse(self, response): 69 | """Parse all JSON data from a response. 70 | 71 | :param response: raw response 72 | :type response: :class:`requests.Response` 73 | :return: response data 74 | :rtype: JSON 75 | """ 76 | return response.json(cls=self.decoder) 77 | 78 | def parse_stream(self, response): 79 | """Yield the parsed data from a stream response. 80 | 81 | :param response: raw response 82 | :type response: :class:`requests.Response` 83 | :return: iterator over multiple JSON objects 84 | """ 85 | for line in response.iter_lines(): 86 | if line: 87 | decoded_line = line.decode('utf-8') 88 | yield json.loads(decoded_line) 89 | 90 | 91 | class PgnHandler(FormatHandler): 92 | """Handle PGN data.""" 93 | 94 | def __init__(self): 95 | super().__init__(mime_type='application/x-chess-pgn') 96 | 97 | def handle(self, *args, **kwargs): 98 | kwargs['converter'] = utils.noop # disable conversions 99 | return super().handle(*args, **kwargs) 100 | 101 | def parse(self, response): 102 | """Parse all text data from a response. 103 | 104 | :param response: raw response 105 | :type response: :class:`requests.Response` 106 | :return: response text 107 | :rtype: str 108 | """ 109 | return response.text 110 | 111 | def parse_stream(self, response): 112 | """Yield the parsed PGN games from a stream response. 113 | 114 | :param response: raw response 115 | :type response: :class:`requests.Response` 116 | :return: iterator over multiple PGN texts 117 | """ 118 | lines = [] 119 | last_line = True 120 | for line in response.iter_lines(): 121 | decoded_line = line.decode('utf-8') 122 | if last_line or decoded_line: 123 | lines.append(decoded_line) 124 | else: 125 | yield '\n'.join(lines).strip() 126 | lines = [] 127 | last_line = decoded_line 128 | 129 | if lines: 130 | yield '\n'.join(lines).strip() 131 | 132 | 133 | class TextHandler(FormatHandler): 134 | def __init__(self): 135 | super().__init__(mime_type='text/plain') 136 | 137 | def parse(self, response): 138 | return response.text 139 | 140 | def parse_stream(self, response): 141 | yield from response.iter_lines() 142 | 143 | 144 | #: Basic text 145 | TEXT = TextHandler() 146 | 147 | #: Handles vanilla JSON 148 | JSON = JsonHandler(mime_type='application/json') 149 | 150 | #: Handles oddball LiChess JSON (normal JSON, crazy MIME type) 151 | LIJSON = JsonHandler(mime_type='application/vnd.lichess.v3+json') 152 | 153 | #: Handles newline-delimited JSON 154 | NDJSON = JsonHandler(mime_type='application/x-ndjson', decoder=ndjson.Decoder) 155 | 156 | #: Handles PGN 157 | PGN = PgnHandler() 158 | -------------------------------------------------------------------------------- /berserk/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from . import utils 3 | 4 | 5 | class model(type): 6 | @property 7 | def conversions(cls): 8 | return {k: v for k, v in vars(cls).items() if not k.startswith('_')} 9 | 10 | 11 | class Model(metaclass=model): 12 | @classmethod 13 | def convert(cls, data): 14 | if isinstance(data, (list, tuple)): 15 | return [cls.convert_one(v) for v in data] 16 | return cls.convert_one(data) 17 | 18 | @classmethod 19 | def convert_one(cls, data): 20 | for k in set(data) & set(cls.conversions): 21 | data[k] = cls.conversions[k](data[k]) 22 | return data 23 | 24 | @classmethod 25 | def convert_values(cls, data): 26 | for k in data: 27 | data[k] = cls.convert(data[k]) 28 | return data 29 | 30 | 31 | class Account(Model): 32 | createdAt = utils.datetime_from_millis 33 | seenAt = utils.datetime_from_millis 34 | 35 | 36 | class User(Model): 37 | createdAt = utils.datetime_from_millis 38 | seenAt = utils.datetime_from_millis 39 | 40 | 41 | class Activity(Model): 42 | interval = utils.inner(utils.datetime_from_millis, 'start', 'end') 43 | 44 | 45 | class Game(Model): 46 | createdAt = utils.datetime_from_millis 47 | lastMoveAt = utils.datetime_from_millis 48 | 49 | 50 | class GameState(Model): 51 | createdAt = utils.datetime_from_millis 52 | wtime = utils.datetime_from_millis 53 | btime = utils.datetime_from_millis 54 | winc = utils.datetime_from_millis 55 | binc = utils.datetime_from_millis 56 | 57 | 58 | class Tournament(Model): 59 | startsAt = utils.datetime_from_str 60 | 61 | 62 | class Tournaments(Model): 63 | startsAt = utils.datetime_from_millis 64 | finishesAt = utils.datetime_from_millis 65 | 66 | 67 | class Broadcast(Model): 68 | broadcast = utils.inner( 69 | utils.datetime_from_millis, 'startedAt', 'startsAt' 70 | ) 71 | 72 | 73 | class RatingHistory(Model): 74 | points = utils.listing(utils.rating_history) 75 | 76 | 77 | class PuzzleActivity(Model): 78 | date = utils.datetime_from_millis 79 | -------------------------------------------------------------------------------- /berserk/session.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | import urllib 4 | 5 | import requests 6 | 7 | from . import ( 8 | exceptions, 9 | utils, 10 | ) 11 | 12 | LOG = logging.getLogger(__name__) 13 | 14 | 15 | class Requestor: 16 | """Encapsulates the logic for making a request. 17 | 18 | :param session: the authenticated session object 19 | :type session: :class:`requests.Session` 20 | :param str base_url: the base URL for requests 21 | :param fmt: default format handler to use 22 | :type fmt: :class:`~berserk.formats.FormatHandler` 23 | """ 24 | 25 | def __init__(self, session, base_url, default_fmt): 26 | self.session = session 27 | self.base_url = base_url 28 | self.default_fmt = default_fmt 29 | 30 | def request( 31 | self, method, path, *args, fmt=None, converter=utils.noop, **kwargs 32 | ): 33 | """Make a request for a resource in a paticular format. 34 | 35 | :param str method: HTTP verb 36 | :param str path: the URL suffix 37 | :param fmt: the format handler 38 | :type fmt: :class:`~berserk.formats.FormatHandler` 39 | :param func converter: function to handle field conversions 40 | :return: response 41 | :raises berserk.exceptions.ResponseError: if the status is >=400 42 | """ 43 | fmt = fmt or self.default_fmt 44 | kwargs['headers'] = fmt.headers 45 | url = urllib.parse.urljoin(self.base_url, path) 46 | 47 | is_stream = kwargs.get('stream') 48 | LOG.debug( 49 | '%s %s %s params=%s data=%s json=%s', 50 | 'stream' if is_stream else 'request', 51 | method, 52 | url, 53 | kwargs.get('params'), 54 | kwargs.get('data'), 55 | kwargs.get('json'), 56 | ) 57 | try: 58 | response = self.session.request(method, url, *args, **kwargs) 59 | except requests.RequestException as e: 60 | raise exceptions.ApiError(e) 61 | if not response.ok: 62 | raise exceptions.ResponseError(response) 63 | 64 | return fmt.handle(response, is_stream=is_stream, converter=converter) 65 | 66 | def get(self, *args, **kwargs): 67 | """Convenience method to make a GET request.""" 68 | return self.request('GET', *args, **kwargs) 69 | 70 | def post(self, *args, **kwargs): 71 | """Convenience method to make a POST request.""" 72 | return self.request('POST', *args, **kwargs) 73 | 74 | 75 | class TokenSession(requests.Session): 76 | """Session capable of personal API token authentication. 77 | 78 | :param str token: personal API token 79 | """ 80 | 81 | def __init__(self, token): 82 | super().__init__() 83 | self.token = token 84 | self.headers = {'Authorization': f'Bearer {token}'} 85 | -------------------------------------------------------------------------------- /berserk/todo.md: -------------------------------------------------------------------------------- 1 | # berserk 2 | ## TO-DO 3 | ### Account 4 | - [x] Get my profile 5 | - [x] Get my email adress 6 | - [x] Get my preferences 7 | - [x] Get my kid mode status 8 | - [x] Set my kid mode status 9 | 10 | _:warning: Found `upgrade_to_bot` in **Account** instead of **Bot**_ 11 | 12 | ### Users 13 | - [x] Get real-time users status 14 | - [x] Get all top 10 15 | - [x] Get one leaderboard 16 | - [x] Get user public data 17 | - [x] Get rating history of a user 18 | - [x] Get performance statistics of a user 19 | - [x] Get user activity 20 | - [x] Get users by ID 21 | - [x] Get members of a team *:warning: Depreated* 22 | - [x] Get live streamers 23 | - [x] Get crosstable 24 | 25 | _:warning: Found `get_users_following` and `get_users_followed` in **Users** instead of **Relations** :information_source: `deprecated`_ 26 | 27 | ### Relations 28 | - [x] Get users followed by a user _:warning: Found in **Users** :information_source: `deprecated`_ 29 | - [x] Get users who follow a user _:warning: Found in **Users** :information_source: `deprecated`_ 30 | - [x] Follow a player 31 | - [x] Unfollow a player 32 | 33 | ### Games 34 | - [x] Export one game 35 | - [x] Export ongoing game of a user 36 | - [x] Export games of a user 37 | - [x] Export games by IDs 38 | - [x] Stream current games 39 | - [x] Get my ongoing games 40 | - [x] Stream moves of a game 41 | - [x] Import one game 42 | 43 | _:warning: Found `get_tv_channels` in **Games** instead of **TV** :information_source: `deprecated`__ 44 | 45 | ### TV 46 | - [x] Get current TV games _:warning: Found in **Games** :information_source: `deprecated`_ 47 | - [x] Stream current TV game 48 | - [x] Get best ongoing games of a TV channel 49 | 50 | ### Puzzles 51 | - [x] Get the daily puzzle 52 | - [x] Get your puzzle activity 53 | - [x] Get your puzzle dashboard 54 | - [x] Get the storm dashboard of a player 55 | 56 | ### :heavy_check_mark: Teams 57 | - [x] Get team swiss tournaments 58 | - [x] Get a single team 59 | - [x] Get popular teams 60 | - [x] Teams of a player 61 | - [x] Search teams 62 | - [x] Get members of a team 63 | - [x] Get team Arena tournaments 64 | - [x] Join a team 65 | - [x] Leave a team 66 | - [x] Kick a user from your team 67 | - [ ] Message all members 68 | 69 | ### Board 70 | - [x] Stream incoming events 71 | - [x] Create a seek 72 | - [x] Stream Board game state 73 | - [x] Make a Board move 74 | - [x] Write in the chat 75 | - [ ] Fetch the game chat 76 | - [x] Abort a game 77 | - [x] Resign a game 78 | - [x] Handle draw offers 79 | - [ ] Handle takeback offers 80 | - [ ] Claim victory of a game 81 | 82 | ### :heavy_check_mark: Bots 83 | - [x] Stream incoming events 84 | - [x] Get online bots 85 | - [x] Upgrade to Bot account 86 | - [x] Stream Bot game state 87 | - [x] Make a Bot move 88 | - [x] Write in the chat 89 | - [x] Abort a game 90 | - [x] Resign a game 91 | 92 | _:warning: Found `accept_challenge` and `decline_challenge` in **Bots** instead of **Challenges**_ 93 | 94 | ### Challenges 95 | - [ ] List your challenges 96 | - [x] Create a challenge 97 | - [x] Accept a challenge 98 | - [x] Decline a challenge 99 | - [ ] Cancel a challenge 100 | - [x] Challenge the AI 101 | - [x] Open-ended challenge 102 | - [ ] Start clocks of a game 103 | - [ ] Add time to the opponent clock 104 | - [ ] Admin challenge tokens 105 | 106 | ### Bulk pairings 107 | - [ ] View upcoming bulk pairings 108 | - [ ] Create a bulk pairing 109 | - [ ] Manually start clocks 110 | - [ ] Cancel a bulk pairing 111 | 112 | ### Arena tournaments 113 | - [x] Get current tournaments 114 | - [x] Create a new Arena tournament 115 | - [ ] Get info about an Arena tournament 116 | - [ ] Update an Arena tournament 117 | - [ ] Join an Arena tournament 118 | - [ ] Terminate an Arena tournament 119 | - [ ] Update a team battle 120 | - [x] Export games of an Arena tournament 121 | - [x] Get results of an Arena tournament 122 | - [ ] Get team standing of a team battle 123 | - [x] Get tournaments created by a user 124 | - [ ] Get team Arena tournaments 125 | 126 | ### Swiss 127 | 128 | ### Opening Explorer 129 | - [x] Masters database 130 | - [x] Lichess games 131 | - [x] Player games 132 | - [ ] OTB master game -------------------------------------------------------------------------------- /berserk/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import collections 3 | from datetime import ( 4 | datetime, 5 | timezone, 6 | ) 7 | 8 | 9 | def to_millis(dt): 10 | """Return the milliseconds between the given datetime and the epoch. 11 | 12 | :param datetime dt: a datetime 13 | :return: milliseconds since the epoch 14 | :rtype: int 15 | """ 16 | return dt.timestamp() * 1000 17 | 18 | 19 | def datetime_from_seconds(ts): 20 | """Return the datetime for the given seconds since the epoch. 21 | 22 | UTC is assumed. The returned datetime is timezone aware. 23 | 24 | :return: timezone aware datetime 25 | :rtype: :class:`datetime` 26 | """ 27 | return datetime.fromtimestamp(ts, timezone.utc) 28 | 29 | 30 | def datetime_from_millis(millis): 31 | """Return the datetime for the given millis since the epoch. 32 | 33 | UTC is assumed. The returned datetime is timezone aware. 34 | 35 | :return: timezone aware datetime 36 | :rtype: :class:`datetime` 37 | """ 38 | return datetime_from_seconds(millis / 1000) 39 | 40 | 41 | def datetime_from_str(dt_str): 42 | """Convert the time in a string to a datetime. 43 | 44 | UTC is assumed. The returned datetime is timezone aware. The format 45 | must match ``%Y-%m-%dT%H:%M:%S.%fZ``. 46 | 47 | :return: timezone aware datetime 48 | :rtype: :class:`datetime` 49 | """ 50 | dt = datetime.strptime(dt_str, '%Y-%m-%dT%H:%M:%S.%fZ') 51 | return dt.replace(tzinfo=timezone.utc) 52 | 53 | 54 | _RatingHistoryEntry = collections.namedtuple('Entry', 'year month day rating') 55 | 56 | 57 | def rating_history(data): 58 | return _RatingHistoryEntry(*data) 59 | 60 | 61 | def inner(func, *keys): 62 | def convert(data): 63 | for k in keys: 64 | try: 65 | data[k] = func(data[k]) 66 | except KeyError: 67 | pass # normal for keys to not be present sometimes 68 | return data 69 | 70 | return convert 71 | 72 | 73 | def listing(func): 74 | def convert(items): 75 | result = [] 76 | for item in items: 77 | result.append(func(item)) 78 | return result 79 | 80 | return convert 81 | 82 | 83 | def noop(arg): 84 | return arg 85 | 86 | 87 | def build_adapter(mapper, sep='.'): 88 | """Build a data adapter. 89 | 90 | Uses a map to pull values from an object and assign them to keys. 91 | For example: 92 | 93 | .. code-block:: python 94 | 95 | >>> mapping = { 96 | ... 'broadcast_id': 'broadcast.id', 97 | ... 'slug': 'broadcast.slug', 98 | ... 'name': 'broadcast.name', 99 | ... 'description': 'broadcast.description', 100 | ... 'syncUrl': 'broadcast.sync.url', 101 | ... } 102 | 103 | >>> cast = {'broadcast': {'id': 'WxOb8OUT', 104 | ... 'slug': 'test-tourney', 105 | ... 'name': 'Test Tourney', 106 | ... 'description': 'Just a test', 107 | ... 'ownerId': 'rhgrant10', 108 | ... 'sync': {'ongoing': False, 'log': [], 'url': None}}, 109 | ... 'url': 'https://lichess.org/broadcast/test-tourney/WxOb8OUT'} 110 | 111 | >>> adapt = build_adapter(mapping) 112 | >>> adapt(cast) 113 | {'broadcast_id': 'WxOb8OUT', 114 | 'slug': 'test-tourney', 115 | 'name': 'Test Tourney', 116 | 'description': 'Just a test', 117 | 'syncUrl': None} 118 | 119 | :param dict mapper: map of keys to their location in an object 120 | :param str sep: nested key delimiter 121 | :return: adapted data 122 | :rtype: dict 123 | """ 124 | 125 | def get(data, location): 126 | for key in location.split(sep): 127 | data = data[key] 128 | return data 129 | 130 | def adapter(data, default=None, fill=False): 131 | result = {} 132 | for key, loc in mapper.items(): 133 | try: 134 | result[key] = get(data, loc) 135 | except KeyError: 136 | if fill: 137 | result[key] = default 138 | return result 139 | 140 | return adapter 141 | 142 | 143 | def page( 144 | get_page, 145 | args=None, 146 | kwargs=None, 147 | results_key='currentPageResults', 148 | page_param='page', 149 | first_page=1, 150 | page_increment=1, 151 | last_page=None, 152 | ): 153 | results = True 154 | kwargs = kwargs or {} 155 | kwargs.setdefault(page_param, first_page) 156 | while results and not last_page or kwargs[page_param] <= last_page: 157 | page = get_page(*args, **kwargs) 158 | results = page[results_key] 159 | yield from results 160 | kwargs[page_param] += page_increment 161 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | patch: 4 | default: false 5 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = berserk 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | .highlight .go { 2 | color: hsla(0, 0%, 50%, .8); 3 | } -------------------------------------------------------------------------------- /docs/announcing.rst: -------------------------------------------------------------------------------- 1 | Hi everyone, 2 | 3 | I'm pleased to announce the release of berserk v0.7.0! 4 | 5 | What's New? 6 | ----------- 7 | 8 | It's been a while since the last slew of commits and lots has happened since v0.3.2: 9 | 10 | **Features** 11 | 12 | * Add ``ApiError`` for all other request errors 13 | * Add ``ResponseError`` for 4xx and 5xx responses with status code, reason, and cause 14 | * Add a utility for easily converting API objects into update params 15 | * Add logging to the ``berserk.session`` module 16 | * Add new ``Teams`` client: join, get members, kick member, and leave 17 | * Add simuls 18 | * Add studies export and export chapter 19 | * Add support for the broadcast endpoints 20 | * Add tests for all utils 21 | * Add tournament results, games export, and list by creator 22 | * Add user followers, users following, rating history, and puzzle activity 23 | 24 | **Deprecations** 25 | 26 | * Deprecated ``Users.get_by_team`` - use ``Teams.get_members`` instead 27 | 28 | **Bugfixes** 29 | 30 | * Fix bug in ``Broadcasts.push_pgn_update`` 31 | * Fix exception message when no cause 32 | * Fix multiple bugs with the tournament create endpoint 33 | * Fix py36 issue preventing successful build 34 | * Fix test case broken by 0.4.0 release 35 | * Fix multiple bugs in ``Tournaments.export_games`` 36 | 37 | **Misc** 38 | 39 | * Update development status classifier to 4 - Beta 40 | * Update documentation and tweak the theme 41 | * Update the travis build to include py37 42 | * Update the Makefile 43 | 44 | 45 | What is berserk? 46 | ---------------- 47 | 48 | berserk is the Python client for the Lichess API. It supports JSON and PGN, 49 | provides pluggable session auth, and implements most if not all of the API. 50 | 51 | License: GNU General Public License v3 52 | 53 | * Read the **docs**: https://berserk.readthedocs.io/ 54 | * Install from **PyPI**: https://pypi.org/project/berserk/ 55 | * Contribute **source**: https://github.com/rhgrant10/berserk 56 | 57 | 58 | Example 59 | ------- 60 | 61 | .. code-block:: python 62 | 63 | >>> import berserk 64 | 65 | >>> session = berserk.TokenSession('my-api-token') 66 | >>> client = berserk.Client(session) 67 | 68 | >>> my = client.account.get() 69 | >>> games = list(client.games.export_by_player(my['username'], as_pgn=True)) 70 | >>> len(games) 71 | 18 72 | 73 | 74 | Enjoy! 75 | 76 | -- Rob 77 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | Developer Interface 2 | =================== 3 | 4 | Clients 5 | ------- 6 | 7 | .. automodule:: berserk.clients 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | Session 13 | ------- 14 | 15 | .. automodule:: berserk.session 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | Enums 21 | ----- 22 | 23 | .. automodule:: berserk.enums 24 | :members: 25 | :undoc-members: 26 | :inherited-members: 27 | :show-inheritance: 28 | 29 | Formats 30 | ------- 31 | 32 | .. automodule:: berserk.formats 33 | :members: 34 | :undoc-members: 35 | :show-inheritance: 36 | 37 | 38 | Exceptions 39 | ---------- 40 | 41 | .. automodule:: berserk.exceptions 42 | :members: 43 | :undoc-members: 44 | :show-inheritance: 45 | 46 | Utils 47 | ----- 48 | 49 | .. automodule:: berserk.utils 50 | :members: 51 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # berserk documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Jun 9 13:47:02 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another 17 | # directory, add these directories to sys.path here. If the directory is 18 | # relative to the documentation root, use os.path.abspath to make it 19 | # absolute, like shown here. 20 | # 21 | import os 22 | import sys 23 | 24 | sys.path.insert(0, os.path.abspath('..')) 25 | 26 | import berserk # noqa 27 | 28 | # -- General configuration --------------------------------------------- 29 | 30 | # If your documentation needs a minimal Sphinx version, state it here. 31 | # 32 | # needs_sphinx = '1.0' 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 36 | extensions = [ 37 | 'sphinx.ext.autodoc', 38 | 'sphinx.ext.viewcode', 39 | 'sphinx.ext.intersphinx', 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # The suffix(es) of source filenames. 46 | # You can specify multiple suffix as a list of string: 47 | # 48 | # source_suffix = ['.rst', '.md'] 49 | source_suffix = '.rst' 50 | 51 | # The master toctree document. 52 | master_doc = 'index' 53 | 54 | # General information about the project. 55 | project = u'berserk' 56 | copyright = u"2018, Robert Grant" 57 | author = u"Robert Grant" 58 | 59 | # The version info for the project you're documenting, acts as replacement 60 | # for |version| and |release|, also used in various other places throughout 61 | # the built documents. 62 | # 63 | # The short X.Y version. 64 | version = berserk.__version__ 65 | # The full version, including alpha/beta/rc tags. 66 | release = berserk.__version__ 67 | 68 | # The language for content autogenerated by Sphinx. Refer to documentation 69 | # for a list of supported languages. 70 | # 71 | # This is also used if you do content translation via gettext catalogs. 72 | # Usually you set "language" from the command line for these cases. 73 | language = None 74 | 75 | # List of patterns, relative to source directory, that match files and 76 | # directories to ignore when looking for source files. 77 | # This patterns also effect to html_static_path and html_extra_path 78 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 79 | 80 | # The name of the Pygments (syntax highlighting) style to use. 81 | pygments_style = 'sphinx' 82 | 83 | # If true, `todo` and `todoList` produce output, else they produce nothing. 84 | todo_include_todos = False 85 | 86 | 87 | # -- Options for HTML output ------------------------------------------- 88 | 89 | # The theme to use for HTML and HTML Help pages. See the documentation for 90 | # a list of builtin themes. 91 | # 92 | # html_theme = 'alabaster' 93 | html_theme = 'sphinx_rtd_theme' 94 | 95 | # Theme options are theme-specific and customize the look and feel of a 96 | # theme further. For a list of options available for each theme, see the 97 | # documentation. 98 | # 99 | html_theme_options = { 100 | # 'canonical_url': '', 101 | # 'analytics_id': 'UA-XXXXXXX-1', # Provided by Google in your dashboard 102 | # 'logo_only': False, 103 | 'display_version': True, 104 | # 'prev_next_buttons_location': 'bottom', 105 | # 'style_external_links': False, 106 | # 'vcs_pageview_mode': '', 107 | # 'style_nav_header_background': 'white', 108 | # # Toc options 109 | 'collapse_navigation': False, 110 | # 'sticky_navigation': True, 111 | # 'navigation_depth': 4, 112 | # 'includehidden': True, 113 | # 'titles_only': False 114 | } 115 | 116 | # Add any paths that contain custom static files (such as style sheets) here, 117 | # relative to this directory. They are copied after the builtin static files, 118 | # so a file named "default.css" will overwrite the builtin "default.css". 119 | html_static_path = ['_static'] 120 | html_css_files = [ 121 | 'css/custom.css', 122 | ] 123 | 124 | # -- Options for HTMLHelp output --------------------------------------- 125 | 126 | # Output file base name for HTML help builder. 127 | htmlhelp_basename = 'berserkdoc' 128 | 129 | 130 | # -- Options for LaTeX output ------------------------------------------ 131 | 132 | latex_elements = { 133 | # The paper size ('letterpaper' or 'a4paper'). 134 | # 135 | # 'papersize': 'letterpaper', 136 | # The font size ('10pt', '11pt' or '12pt'). 137 | # 138 | # 'pointsize': '10pt', 139 | # Additional stuff for the LaTeX preamble. 140 | # 141 | # 'preamble': '', 142 | # Latex figure (float) alignment 143 | # 144 | # 'figure_align': 'htbp', 145 | } 146 | 147 | # Grouping the document tree into LaTeX files. List of tuples 148 | # (source start file, target name, title, author, documentclass 149 | # [howto, manual, or own class]). 150 | latex_documents = [ 151 | ( 152 | master_doc, 153 | 'berserk.tex', 154 | u'berserk Documentation', 155 | u'Robert Grant', 156 | 'manual', 157 | ), 158 | ] 159 | 160 | 161 | # -- Options for manual page output ------------------------------------ 162 | 163 | # One entry per manual page. List of tuples 164 | # (source start file, name, description, authors, manual section). 165 | man_pages = [(master_doc, 'berserk', u'berserk Documentation', [author], 1)] 166 | 167 | 168 | # -- Options for Texinfo output ---------------------------------------- 169 | 170 | # Grouping the document tree into Texinfo files. List of tuples 171 | # (source start file, target name, title, author, 172 | # dir menu entry, description, category) 173 | texinfo_documents = [ 174 | ( 175 | master_doc, 176 | 'berserk', 177 | u'berserk Documentation', 178 | author, 179 | 'berserk', 180 | 'One line description of project.', 181 | 'Miscellaneous', 182 | ), 183 | ] 184 | 185 | 186 | # Example configuration for intersphinx: refer to the Python standard library. 187 | intersphinx_mapping = { 188 | 'http://docs.python.org/3/': None, 189 | 'https://requests.readthedocs.io/en/master/': None, 190 | } 191 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to berserk's documentation! 2 | ====================================== 3 | 4 | .. image:: https://img.shields.io/pypi/v/berserk.svg 5 | :target: https://pypi.python.org/pypi/berserk 6 | :alt: Available on PyPI 7 | 8 | .. image:: https://img.shields.io/travis/rhgrant10/berserk.svg 9 | :target: https://travis-ci.org/rhgrant10/berserk 10 | :alt: Continuous Integration 11 | 12 | .. image:: https://codecov.io/gh/rhgrant10/tsplib95/branch/master/graph/badge.svg 13 | :target: https://codecov.io/gh/rhgrant10/tsplib95 14 | :alt: Code Coverage 15 | 16 | .. image:: https://readthedocs.org/projects/berserk/badge/?version=latest 17 | :target: https://berserk.readthedocs.io/en/latest/?badge=latest 18 | :alt: Documentation Status 19 | 20 | 21 | Python client for the `Lichess API`_. 22 | 23 | .. _Lichess API: https://lichess.org/api 24 | 25 | ---- 26 | 27 | .. toctree:: 28 | :maxdepth: 4 29 | 30 | readme 31 | installation 32 | usage 33 | api 34 | contributing 35 | authors 36 | history 37 | 38 | 39 | Indices and tables 40 | ================== 41 | * :ref:`genindex` 42 | * :ref:`modindex` 43 | * :ref:`search` 44 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | Installation 4 | ============ 5 | 6 | 7 | Stable release 8 | -------------- 9 | 10 | To install berserk, run this command in your terminal: 11 | 12 | .. code-block:: console 13 | 14 | $ pip install berserk 15 | 16 | This is the preferred method to install berserk, as it will always install the most recent stable release. 17 | 18 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 19 | you through the process. 20 | 21 | .. _pip: https://pip.pypa.io 22 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 23 | 24 | 25 | From sources 26 | ------------ 27 | 28 | The sources for berserk can be downloaded from the `Github repo`_. 29 | 30 | You can either clone the public repository: 31 | 32 | .. code-block:: console 33 | 34 | $ git clone git://github.com/rhgrant10/berserk 35 | 36 | Or download the `tarball`_: 37 | 38 | .. code-block:: console 39 | 40 | $ curl -OL https://github.com/rhgrant10/berserk/tarball/master 41 | 42 | Once you have a copy of the source, you can install it with: 43 | 44 | .. code-block:: console 45 | 46 | $ python setup.py install 47 | 48 | 49 | .. _Github repo: https://github.com/rhgrant10/berserk 50 | .. _tarball: https://github.com/rhgrant10/berserk/tarball/master 51 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=berserk 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | Use ``berserk`` by creating an API client: 6 | 7 | .. code-block:: python 8 | 9 | >>> import berserk 10 | >>> client = berserk.Client() 11 | 12 | Authenticating 13 | ============== 14 | 15 | By default the client does not perform any authentication. However many of the 16 | endpoints are not open. To use a form of authentication, just pass the 17 | appropriate ``requests.Session``-like object: 18 | 19 | - using an API token: ``berserk.TokenSession`` 20 | - using oauth: ``requests_oauthlib.Oauth2Session`` 21 | 22 | .. note:: 23 | 24 | Some endpoints require specific Oauth2 permissions. 25 | 26 | Using an API token 27 | ------------------ 28 | 29 | If you have a personal API token, you can simply use the ``TokenSession`` 30 | provided. For example, assuming you have written your token to 31 | ``'./lichess.token'``: 32 | 33 | .. code-block:: python 34 | 35 | >>> with open('./lichess.token') as f: 36 | ... token = f.read() 37 | ... 38 | >>> session = berserk.TokenSession(token) 39 | >>> client = berserk.Client(session) 40 | 41 | Using Oauth2 42 | ------------ 43 | 44 | Some of the endpoints require OAuth2 authentication. Although outside the 45 | scope of this documentation, you can use ``requests_oauthlib.Oauth2Session`` 46 | for this. 47 | 48 | .. code-block:: python 49 | 50 | >>> from requests_oauthlib import OAuth2Session 51 | >>> session = OAuth2Session(...) 52 | >>> client = berserk.Client(session) 53 | 54 | 55 | Accounts 56 | ======== 57 | 58 | Information and Preferences 59 | --------------------------- 60 | 61 | .. code-block:: python 62 | 63 | >>> client.account.get() 64 | {'blocking': False, 65 | 'count': {...}, 66 | 'createdAt': datetime.datetime(2018, 5, 16, 8, 9, 18, 187000), 67 | 'followable': True, 68 | 'following': False, 69 | 'followsYou': False, 70 | 'id': 'rhgrant10', 71 | 'nbFollowers': 1, 72 | 'nbFollowing': 1, 73 | 'online': True, 74 | 'perfs': {...}, 75 | 'playTime': {...}, 76 | 'seenAt': datetime.datetime(2018, 12, 9, 10, 28, 30, 221000), 77 | 'url': 'https://lichess.org/@/rhgrant10', 78 | 'username': 'rhgrant10'} 79 | 80 | >>> client.account.get_email() 81 | 'rhgrant10@gmail.com' 82 | 83 | >>> client.account.get_preferences() 84 | {'animation': 2, 85 | 'autoQueen': 1, 86 | ... 87 | 'transp': False, 88 | 'zen': 0}} 89 | 90 | Kid Mode 91 | -------- 92 | 93 | Using Oauth2, you can set the kid mode. 94 | 95 | .. code-block:: python 96 | 97 | >>> client.account.set_kid_mode(True) # enable 98 | True 99 | >>> client.account.set_kid_mode(False) # disable 100 | True 101 | 102 | Note that the ``set_kid_mode`` method returns an indicator of success and *not* 103 | the current or previous status. 104 | 105 | .. code-block:: python 106 | 107 | >>> def show_kid_mode(): 108 | ... is_enabled = client.account.get_kid_mode() 109 | ... print('enabled' if is_enabled else 'disabled') 110 | ... 111 | >>> show_kid_mode() 112 | disabled 113 | 114 | >>> # try to enable, but the request fails 115 | >>> client.account.set_kid_mode(True) 116 | False 117 | >>> show_kid_mode() 118 | disabled 119 | 120 | >>> # try again, this time it succeeds 121 | >>> client.account.set_kid_mode(True) 122 | True 123 | >>> show_kid_mode() 124 | enabled 125 | 126 | Bot Account Upgrade 127 | ------------------- 128 | 129 | If this is a new account that has not yet played a game, and if you 130 | have the required OAuth2 permission, you can upgrade the account to a bot 131 | account: 132 | 133 | .. code-block:: python 134 | 135 | >>> client.account.upgrade_to_bot() 136 | 137 | Read more below about how to use bot functionality. 138 | 139 | 140 | Users and Teams 141 | =============== 142 | 143 | Realtime Statuses 144 | ----------------- 145 | 146 | Get realtime information about one or more players: 147 | 148 | .. code-block:: python 149 | 150 | >>> players = ['Sasageyo', 'Voinikonis_Nikita', 'Zugzwangerz', 'DOES-NOT-EXIST'] 151 | >>> client.users.get_realtime_statuses(players) 152 | [{'id': 'sasageyo', 153 | 'name': 'Sasageyo', 154 | 'title': 'IM', 155 | 'online': True, 156 | 'playing': True}, 157 | {'id': 'voinikonis_nikita', 158 | 'name': 'Voinikonis_Nikita', 159 | 'title': 'FM', 160 | 'online': True, 161 | 'playing': True}, 162 | {'id': 'zugzwangerz', 'name': 'Zugzwangerz'}] 163 | 164 | Top 10 Lists 165 | ------------ 166 | 167 | .. code-block:: python 168 | 169 | >>> top10 = client.users.get_all_top_10() 170 | >>> list(top10) 171 | ['bullet', 172 | 'blitz', 173 | 'rapid', 174 | 'classical', 175 | 'ultraBullet', 176 | 'crazyhouse', 177 | 'chess960', 178 | 'kingOfTheHill', 179 | 'threeCheck', 180 | 'antichess', 181 | 'atomic', 182 | 'horde', 183 | 'racingKings'] 184 | >>> top10['horde'][0] 185 | {'id': 'ingrid-vengeance', 186 | 'perfs': {'horde': {'progress': 22, 'rating': 2443}}, 187 | 'username': 'Ingrid-Vengeance'} 188 | 189 | Leaderboards 190 | ------------ 191 | 192 | .. code-block:: python 193 | 194 | >>> client.users.get_leaderboard('horde', count=11)[-1] 195 | {'id': 'philippesaner', 196 | 'perfs': {'horde': {'progress': 10, 'rating': 2230}}, 197 | 'username': 'PhilippeSaner'} 198 | 199 | Public Data 200 | ----------- 201 | 202 | .. code-block:: python 203 | 204 | >>> client.users.get_public_data('PhilippeSaner') 205 | {'completionRate': 87, 206 | 'count': {...}, 207 | 'createdAt': datetime.datetime(2017, 1, 9, 16, 14, 31, 140000), 208 | 'id': 'philippesaner', 209 | 'nbFollowers': 40, 210 | 'nbFollowing': 13, 211 | 'online': False, 212 | 'perfs': {...}, 213 | 'playTime': {'total': 1505020, 'tv': 1038007}, 214 | 'profile': {'country': 'CA', 'location': 'Ottawa'}, 215 | 'seenAt': datetime.datetime(2018, 12, 9, 10, 26, 28, 22000), 216 | 'url': 'https://lichess.org/@/PhilippeSaner', 217 | 'username': 'PhilippeSaner'} 218 | 219 | Activity Feeds 220 | -------------- 221 | 222 | .. code-block:: python 223 | 224 | >>> feed = client.users.get_activity_feed('PhilippeSaner') 225 | >>> feed[0] 226 | {'games': {'horde': {'draw': 0, 227 | 'loss': 1, 228 | 'rp': {'after': 2230, 'before': 2198}, 229 | 'win': 12}}, 230 | 'interval': {'end': datetime.datetime(2018, 12, 9, 16, 0), 231 | 'start': datetime.datetime(2018, 12, 8, 16, 0)}, 232 | 'tournaments': {'best': [{'nbGames': 1, 233 | 'rank': 6, 234 | 'rankPercent': 33, 235 | 'score': 2, 236 | 'tournament': {'id': '9zm2uIdP', 'name': 'Daily Horde Arena'}}], 237 | 'nb': 1}} 238 | 239 | Team Members 240 | ------------ 241 | 242 | .. code-block:: python 243 | 244 | >>> client.users.get_by_team('coders') 245 | 246 | >>> members = list(_) 247 | >>> len(members) 248 | 228 249 | 250 | Live Streamers 251 | -------------- 252 | 253 | .. code-block:: python 254 | 255 | >>> client.users.get_live_streamers() 256 | [{'id': 'chesspatzerwal', 'name': 'ChesspatzerWAL', 'patron': True}, 257 | {'id': 'ayrtontwigg', 'name': 'AyrtonTwigg', 'playing': True}, 258 | {'id': 'fanatikchess', 'name': 'FanatikChess', 'patron': True}, 259 | {'id': 'jwizzy74', 'name': 'Jwizzy74', 'patron': True, 'playing': True}, 260 | {'id': 'devjamesb', 'name': 'DevJamesB', 'playing': True}, 261 | {'id': 'kafka4x', 'name': 'Kafka4x', 'playing': True}, 262 | {'id': 'sparklehorse', 'name': 'Sparklehorse', 'patron': True, 'title': 'IM'}, 263 | {'id': 'ivarcode', 'name': 'ivarcode', 'playing': True}, 264 | {'id': 'pepellou', 'name': 'pepellou', 'patron': True, 'playing': True}, 265 | {'id': 'videogamepianist', 'name': 'VideoGamePianist', 'playing': True}] 266 | 267 | 268 | Exporting Games 269 | =============== 270 | 271 | By Player 272 | --------- 273 | 274 | Finished games can be exported and current games can be listed. Let's take a 275 | look at the most recent 300 games played by "LeelaChess" on Dec. 8th, 2018: 276 | 277 | .. code-block:: python 278 | 279 | >>> start = berserk.utils.to_millis(datetime(2018, 12, 8)) 280 | >>> end = berserk.utils.to_millis(datetime(2018, 12, 9)) 281 | >>> client.games.export_by_player('LeelaChess', since=start, until=end, 282 | ... max=300) 283 | 284 | >>> games = list(_) 285 | >>> games[0]['createdAt'] 286 | datetime.datetime(2018, 12, 9, 22, 54, 24, 195000, tzinfo=datetime.timezone.utc) 287 | >>> games[-1]['createdAt'] 288 | datetime.datetime(2018, 12, 8, 9, 11, 42, 229000, tzinfo=datetime.timezone.utc) 289 | 290 | Wow, they play a lot of chess :) 291 | 292 | By ID 293 | ----- 294 | 295 | You can export games too using their IDs. Let's export the last game LeelaChess 296 | played that day: 297 | 298 | .. code-block:: python 299 | 300 | >>> game_id = games[0]['id'] 301 | >>> client.games.export(game_id) 302 | {'analysis': [...], 303 | 'clock': {'increment': 8, 'initial': 300, 'totalTime': 620}, 304 | 'createdAt': datetime.datetime(2018, 12, 9, 22, 54, 24, 195000, tzinfo=datetime.timezone.utc), 305 | 'id': 'WatQhhbJ', 306 | 'lastMoveAt': datetime.datetime(2018, 12, 9, 23, 5, 59, 396000, tzinfo=datetime.timezone.utc), 307 | 'moves': ... 308 | 'opening': {'eco': 'D38', 309 | 'name': "Queen's Gambit Declined: Ragozin Defense", 310 | 'ply': 8}, 311 | 'perf': 'rapid', 312 | 'players': {'black': {'analysis': {'acpl': 44, 313 | 'blunder': 1, 314 | 'inaccuracy': 4, 315 | 'mistake': 2}, 316 | 'rating': 1333, 317 | 'ratingDiff': 0, 318 | 'user': {'id': 'fsoto', 'name': 'fsoto'}}, 319 | 'white': {'analysis': {'acpl': 11, 320 | 'blunder': 0, 321 | 'inaccuracy': 2, 322 | 'mistake': 0}, 323 | 'provisional': True, 324 | 'rating': 2490, 325 | 'ratingDiff': 0, 326 | 'user': {'id': 'leelachess', 'name': 'LeelaChess', 'title': 'BOT'}}}, 327 | 'rated': True, 328 | 'speed': 'rapid', 329 | 'status': 'mate', 330 | 'variant': 'standard', 331 | 'winner': 'white'} 332 | 333 | PGN vs JSON 334 | ----------- 335 | 336 | Of course sometimes PGN format is desirable. Just pass ``as_pgn=True`` to 337 | any of the export methods: 338 | 339 | .. code-block:: python 340 | 341 | >>> pgn = client.games.export(game_id, as_pgn=True) 342 | >>> print(pgn) 343 | [Event "Rated Rapid game"] 344 | [Site "https://lichess.org/WatQhhbJ"] 345 | [Date "2018.12.09"] 346 | [Round "-"] 347 | [White "LeelaChess"] 348 | [Black "fsoto"] 349 | [Result "1-0"] 350 | [UTCDate "2018.12.09"] 351 | [UTCTime "22:54:24"] 352 | [WhiteElo "2490"] 353 | [BlackElo "1333"] 354 | [WhiteRatingDiff "+0"] 355 | [BlackRatingDiff "+0"] 356 | [WhiteTitle "BOT"] 357 | [Variant "Standard"] 358 | [TimeControl "300+8"] 359 | [ECO "D38"] 360 | [Opening "Queen's Gambit Declined: Ragozin Defense"] 361 | [Termination "Normal"] 362 | 363 | 1. d4 { [%eval 0.08] [%clk 0:05:00] } 1... d5 ... 364 | 365 | TV Channels 366 | ----------- 367 | 368 | .. code-block:: python 369 | 370 | >>> channels = client.games.get_tv_channels() 371 | >>> list(channels) 372 | ['Bot', 373 | 'Blitz', 374 | 'Racing Kings', 375 | 'UltraBullet', 376 | 'Bullet', 377 | 'Classical', 378 | 'Three-check', 379 | 'Antichess', 380 | 'Computer', 381 | 'Horde', 382 | 'Rapid', 383 | 'Atomic', 384 | 'Crazyhouse', 385 | 'Chess960', 386 | 'King of the Hill', 387 | 'Top Rated'] 388 | >>> channels['King of the Hill'] 389 | {'gameId': 'YPL6tP2K', 390 | 'rating': 1554, 391 | 'user': {'id': 'linischoki', 'name': 'linischoki'}} 392 | 393 | 394 | Working with tournaments 395 | ======================== 396 | 397 | You have to specify the clock time, increment, and minutes, but creating a new 398 | tournament is easy: 399 | 400 | .. code-block:: python 401 | 402 | >>> client.tournaments.create(clock_time=10, clock_increment=3, minutes=180) 403 | {'berserkable': True, 404 | 'clock': {'increment': 3, 'limit': 600}, 405 | 'createdBy': 'rhgrant10', 406 | 'duels': [], 407 | 'fullName': "O'Kelly Arena", 408 | 'greatPlayer': {'name': "O'Kelly", 409 | 'url': "https://wikipedia.org/wiki/Alb%C3%A9ric_O'Kelly_de_Galway"}, 410 | 'id': '3uwyXjiC', 411 | 'minutes': 180, 412 | 'nbPlayers': 0, 413 | 'perf': {'icon': '#', 'name': 'Rapid'}, 414 | 'quote': {'author': 'Bent Larsen', 415 | 'text': 'I often play a move I know how to refute.'}, 416 | 'secondsToStart': 300, 417 | 'standing': {'page': 1, 'players': []}, 418 | 'startsAt': '2018-12-10T00:32:12.116Z', 419 | 'system': 'arena', 420 | 'variant': 'standard', 421 | 'verdicts': {'accepted': True, 'list': []}} 422 | 423 | You can specify the starting position for new tournaments using one of the 424 | provided enum value in ``berserk.enums.Position``: 425 | 426 | .. code-block:: python 427 | 428 | >>> client.tournaments.create(clock_time=10, clock_increment=3, minutes=180, 429 | position=berserk.enums.Position.KINGS_PAWN) 430 | 431 | 432 | Additionally you can see tournaments that have recently finished, are in 433 | progress, and are about to start: 434 | 435 | .. code-block:: python 436 | 437 | >>> tournaments = client.tournaments.get() 438 | >>> list(tournaments) 439 | ['created', 'started', 'finished'] 440 | >>> len(tournaments['created']) 441 | 19 442 | >>> tournaments['created'][0] 443 | {'clock': {'increment': 0, 'limit': 300}, 444 | 'createdBy': 'bashkimneziri', 445 | 'finishesAt': datetime.datetime(2018, 12, 24, 0, 21, 2, 179000, tzinfo=datetime.timezone.utc), 446 | 'fullName': 'GM Arena', 447 | 'id': 'COnVgmKH', 448 | 'minutes': 45, 449 | 'nbPlayers': 1, 450 | 'perf': {'icon': ')', 'key': 'blitz', 'name': 'Blitz', 'position': 1}, 451 | 'rated': True, 452 | 'secondsToStart': 160, 453 | 'startsAt': datetime.datetime(2018, 12, 23, 23, 36, 2, 179000, tzinfo=datetime.timezone.utc), 454 | 'status': 10, 455 | 'system': 'arena', 456 | 'variant': {'key': 'standard', 'name': 'Standard', 'short': 'Std'}, 457 | 'winner': None} 458 | 459 | 460 | Being a bot 461 | =========== 462 | 463 | .. warning:: 464 | 465 | These commands only work using bot accounts. Make sure you have converted 466 | the account with which you authenticate into a bot account first. See 467 | above for details. 468 | 469 | Bots stream game information and react by calling various endpoints. There are 470 | two streams of information: 471 | 472 | 1. incoming events 473 | 2. state of a particular game 474 | 475 | In general, a bot will listen to the stream of incoming events, determine which 476 | challenges to accept, and once accepted, listen to the stream of game states 477 | and respond with the best moves in an attempt to win as many games as possible. 478 | You *can* create a bot that looses intentionally if that makes you happy, but 479 | regardless you will need to listen to both streams of information. 480 | 481 | The typical pattern is to have one main thread that listens to the event 482 | stream and spawns new threads when accepting challenges. Each challenge thread 483 | then listens to the stream of state for that particular game and plays it to 484 | completion. 485 | 486 | Responding to challenges 487 | ------------------------ 488 | 489 | Here the goal is to respond to challenges and spawn workers to play those 490 | accepted. Here's a bit of sample code that hits the highlights: 491 | 492 | .. code-block:: python 493 | 494 | >>> is_polite = True 495 | >>> for event in client.bots.stream_incoming_events(): 496 | ... if event['type'] == 'challenge': 497 | ... if should_accept(event): 498 | ... client.bots.accept_challenge(event['id']) 499 | ... elif is_polite: 500 | ... client.bots.decline_challenge(event['id']) 501 | ... elif event['type'] == 'gameStart': 502 | ... game = Game(event['id']) 503 | ... game.start() 504 | ... 505 | 506 | Playing a game 507 | -------------- 508 | 509 | Having accepted a challenge and recieved the gameStart event for it, the main 510 | job here is to listen and react to the stream of the game state: 511 | 512 | .. code-block:: python 513 | 514 | >>> class Game(threading.Thread): 515 | ... def __init__(self, client, game_id, **kwargs): 516 | ... super().__init__(**kwargs) 517 | ... self.game_id = game_id 518 | ... self.client = client 519 | ... self.stream = client.bots.stream_game_state(game_id) 520 | ... self.current_state = next(self.stream) 521 | ... 522 | ... def run(self): 523 | ... for event in self.stream: 524 | ... if event['type'] == 'gameState': 525 | ... self.handle_state_change(event) 526 | ... elif event['type'] == 'chatLine': 527 | ... self.handle_chat_line(event) 528 | ... 529 | ... def handle_state_change(self, game_state): 530 | ... pass 531 | ... 532 | ... def handle_chat_line(self, chat_line): 533 | ... pass 534 | ... 535 | 536 | Obviously the code above is just to communicate the gist of what is required. 537 | But once you have your framework for reacting to changes in game state, there 538 | are a variety of actions you can take: 539 | 540 | .. code-block:: python 541 | 542 | >>> client.bots.make_move(game_id, 'e2e4') 543 | True 544 | >>> client.bots.abort_game(game_id) 545 | True 546 | >>> client.bots.resign_game(game_id) 547 | True 548 | >>> client.bots.post_message(game_id, 'Prepare to loose') 549 | True 550 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = [ 4 | "setuptools >= 40.6.2", 5 | "wheel", 6 | ] 7 | 8 | [tool.isort] 9 | multi_line_output = 3 10 | use_parentheses = true 11 | include_trailing_comma = true 12 | force_grid_wrap = 2 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | blue==0.9.0 2 | bumpversion==0.6.0 3 | coverage==6.3.2 4 | Deprecated==1.2.13 5 | flake8==4.0.1 6 | isort==5.10.1 7 | ndjson==0.3.1 8 | pytest-cov==3.0.0 9 | pytest-runner==6.0.0 10 | pytest==7.1.1 11 | requests==2.27.1 12 | sphinx-rtd-theme==1.0.0 13 | Sphinx==4.3.2 14 | twine==4.0.0 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = berserk 3 | version = 0.10.0 4 | description = Python client for the lichess API 5 | keywords = berserk 6 | url = https://github.com/rhgrant10/berserk 7 | long_description = file: README, 8 | long_description_content_type = text/x-rst 9 | author = Robert Grant 10 | author_email = rhgrant10@gmail.com 11 | license = GNU General Public License v3 12 | 13 | [options] 14 | zip_safe = True 15 | include_package_data = True 16 | packages = find: 17 | install_requires = 18 | requests>=2 19 | ndjson>=0.2 20 | deprecated>=1.2.7 21 | 22 | [options.extras_require] 23 | tests = 24 | coverage 25 | flake8 26 | pytest-cov 27 | pytest-runner 28 | pytest 29 | dev = 30 | bumpversion 31 | sphinx-rtd-theme 32 | Sphinx 33 | twine 34 | watchdog 35 | all = 36 | %(tests)s 37 | %(dev)s 38 | 39 | [bumpversion] 40 | current_version = 0.10.0 41 | commit = True 42 | tag = True 43 | 44 | [bumpversion:file:setup.py] 45 | search = version='{current_version}' 46 | replace = version='{new_version}' 47 | 48 | [bumpversion:file:berserk/__init__.py] 49 | search = __version__ = '{current_version}' 50 | replace = __version__ = '{new_version}' 51 | 52 | [bdist_wheel] 53 | universal = 1 54 | 55 | [aliases] 56 | test = pytest 57 | 58 | [tool:pytest] 59 | collect_ignore = ['setup.py'] 60 | 61 | [coverage:run] 62 | source = berserk 63 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import setuptools 4 | 5 | 6 | setuptools.setup() 7 | -------------------------------------------------------------------------------- /tests/test_formats.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from unittest import mock 3 | 4 | from berserk import formats as fmts 5 | 6 | 7 | def test_base_headers(): 8 | fmt = fmts.FormatHandler('foo') 9 | assert fmt.headers == {'Accept': 'foo'} 10 | 11 | 12 | def test_base_handle(): 13 | fmt = fmts.FormatHandler('foo') 14 | fmt.parse = mock.Mock(return_value='bar') 15 | fmt.parse_stream = mock.Mock() 16 | 17 | result = fmt.handle('baz', is_stream=False) 18 | assert result == 'bar' 19 | assert fmt.parse_stream.call_count == 0 20 | 21 | 22 | def test_base_handle_stream(): 23 | fmt = fmts.FormatHandler('foo') 24 | fmt.parse = mock.Mock() 25 | fmt.parse_stream = mock.Mock(return_value='bar') 26 | 27 | result = fmt.handle('baz', is_stream=True) 28 | assert list(result) == list('bar') 29 | assert fmt.parse.call_count == 0 30 | 31 | 32 | def test_json_handler_parse(): 33 | fmt = fmts.JsonHandler('foo') 34 | m_response = mock.Mock() 35 | m_response.json.return_value = 'bar' 36 | 37 | result = fmt.parse(m_response) 38 | assert result == 'bar' 39 | 40 | 41 | def test_json_handler_parse_stream(): 42 | fmt = fmts.JsonHandler('foo') 43 | m_response = mock.Mock() 44 | m_response.iter_lines.return_value = [b'{"x": 5}', b'', b'{"y": 3}'] 45 | 46 | result = fmt.parse_stream(m_response) 47 | assert list(result) == [{'x': 5}, {'y': 3}] 48 | 49 | 50 | def test_pgn_handler_parse(): 51 | fmt = fmts.PgnHandler() 52 | m_response = mock.Mock() 53 | m_response.text = 'bar' 54 | 55 | result = fmt.parse(m_response) 56 | assert result == 'bar' 57 | 58 | 59 | def test_pgn_handler_parse_stream(): 60 | fmt = fmts.PgnHandler() 61 | m_response = mock.Mock() 62 | m_response.iter_lines.return_value = [b'one', b'two', b'', b'', b'three'] 63 | 64 | result = fmt.parse_stream(m_response) 65 | assert list(result) == ['one\ntwo', 'three'] 66 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from berserk import models 3 | 4 | 5 | def test_conversion(): 6 | class Example(models.Model): 7 | foo = int 8 | 9 | original = {'foo': '5', 'bar': 3, 'baz': '4'} 10 | modified = {'foo': 5, 'bar': 3, 'baz': '4'} 11 | assert Example.convert(original) == modified 12 | -------------------------------------------------------------------------------- /tests/test_session.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | from berserk import session 7 | from berserk import utils 8 | 9 | 10 | def test_request(): 11 | m_session = mock.Mock() 12 | m_fmt = mock.Mock() 13 | requestor = session.Requestor(m_session, 'http://foo.com/', m_fmt) 14 | 15 | result = requestor.request('bar', 'path', baz='qux') 16 | 17 | assert result == m_fmt.handle.return_value 18 | 19 | args, kwargs = m_session.request.call_args 20 | assert ('bar', 'http://foo.com/path') == args 21 | assert {'headers': m_fmt.headers, 'baz': 'qux'} == kwargs 22 | 23 | args, kwargs = m_fmt.handle.call_args 24 | assert (m_session.request.return_value,) == args 25 | assert {'is_stream': None, 'converter': utils.noop} == kwargs 26 | 27 | 28 | def test_bad_request(): 29 | m_session = mock.Mock() 30 | m_session.request.return_value.ok = False 31 | m_session.request.return_value.raise_for_status.side_effect = Exception() 32 | m_fmt = mock.Mock() 33 | requestor = session.Requestor(m_session, 'http://foo.com/', m_fmt) 34 | 35 | with pytest.raises(Exception): 36 | requestor.request('bar', 'path', baz='qux') 37 | 38 | 39 | def test_token_session(): 40 | token_session = session.TokenSession('foo') 41 | assert token_session.token == 'foo' 42 | assert token_session.headers == {'Authorization': 'Bearer foo'} 43 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import datetime 3 | import collections 4 | 5 | import pytest 6 | 7 | from berserk import utils 8 | 9 | 10 | TIME_FMT = '%Y-%m-%dT%H:%M:%S.%fZ' 11 | 12 | 13 | Case = collections.namedtuple('Case', 'dt seconds millis text') 14 | 15 | 16 | @pytest.fixture 17 | def time_case(): 18 | dt = datetime.datetime( 19 | 2017, 12, 28, 23, 52, 30, tzinfo=datetime.timezone.utc 20 | ) 21 | ts = dt.timestamp() 22 | return Case(dt, ts, ts * 1000, dt.strftime(TIME_FMT)) 23 | 24 | 25 | def test_to_millis(time_case): 26 | assert utils.to_millis(time_case.dt) == time_case.millis 27 | 28 | 29 | def test_datetime_from_seconds(time_case): 30 | assert utils.datetime_from_seconds(time_case.seconds) == time_case.dt 31 | 32 | 33 | def test_datetime_from_millis(time_case): 34 | assert utils.datetime_from_millis(time_case.millis) == time_case.dt 35 | 36 | 37 | def test_datetime_from_str(time_case): 38 | assert utils.datetime_from_str(time_case.text) == time_case.dt 39 | 40 | 41 | def test_inner(): 42 | convert = utils.inner(lambda v: 2 * v, 'x', 'y') 43 | result = convert({'x': 42}) 44 | assert result == {'x': 84} 45 | 46 | 47 | def test_noop(): 48 | assert 'foo' == utils.noop('foo') 49 | 50 | 51 | @pytest.fixture 52 | def adapter_mapping(): 53 | return { 54 | 'foo_bar': 'foo.bar', 55 | 'baz': 'baz', 56 | 'qux': 'foo.qux', 57 | 'quux': 'foo.quux', 58 | 'corgeGrault': 'foo.corge.grault', 59 | 'corgeGarply': 'foo.corge.garply', 60 | } 61 | 62 | 63 | @pytest.fixture 64 | def data_to_adapt(): 65 | return { 66 | 'foo': { 67 | 'bar': 'one', 68 | 'qux': 'three', 69 | 'corge': {'grault': 'four', 'garply': None}, 70 | }, 71 | 'baz': 'two', 72 | } 73 | 74 | 75 | def test_adapt_with_fill(adapter_mapping, data_to_adapt): 76 | adapt = utils.build_adapter(adapter_mapping) 77 | default = object() 78 | assert adapt(data_to_adapt, fill=True, default=default) == { 79 | 'foo_bar': 'one', 80 | 'baz': 'two', 81 | 'qux': 'three', 82 | 'quux': default, 83 | 'corgeGrault': 'four', 84 | 'corgeGarply': None, 85 | } 86 | 87 | 88 | def test_adapt(adapter_mapping, data_to_adapt): 89 | adapt = utils.build_adapter(adapter_mapping) 90 | assert adapt(data_to_adapt) == { 91 | 'foo_bar': 'one', 92 | 'baz': 'two', 93 | 'qux': 'three', 94 | 'corgeGrault': 'four', 95 | 'corgeGarply': None, 96 | } 97 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37, py38, py39, py310, flake8 3 | skip_missing_interpreters = true 4 | requires = 5 | pip>=21.3 6 | setuptools>=40.6.2 7 | wheel 8 | 9 | [testenv] 10 | setenv = 11 | PYTHONPATH = {toxinidir} 12 | passenv = LICHESS_TOKEN 13 | deps = 14 | -rrequirements.txt 15 | commands = 16 | pytest --basetemp={envtmpdir} --cov 17 | python -m coverage xml 18 | 19 | [testenv:flake8] 20 | commands = 21 | flake8 berserk --verbose 22 | --------------------------------------------------------------------------------