├── .editorconfig ├── .git-blame-ignore-revs ├── .github ├── ISSUE_TEMPLATE │ └── bug.yml ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── codeql.yml │ ├── docs.yml │ ├── format.yml │ ├── integration_test.yml │ ├── test.yml │ └── typing.yml ├── .gitignore ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE ├── Makefile ├── README.rst ├── berserk ├── __init__.py ├── clients │ ├── __init__.py │ ├── account.py │ ├── analysis.py │ ├── base.py │ ├── board.py │ ├── bots.py │ ├── broadcasts.py │ ├── bulk_pairings.py │ ├── challenges.py │ ├── external_engine.py │ ├── fide.py │ ├── games.py │ ├── messaging.py │ ├── oauth.py │ ├── opening_explorer.py │ ├── puzzles.py │ ├── relations.py │ ├── simuls.py │ ├── studies.py │ ├── tablebase.py │ ├── teams.py │ ├── tournaments.py │ ├── tv.py │ └── users.py ├── exceptions.py ├── formats.py ├── models.py ├── py.typed ├── session.py ├── types │ ├── __init__.py │ ├── account.py │ ├── analysis.py │ ├── bots.py │ ├── broadcast.py │ ├── bulk_pairings.py │ ├── challenges.py │ ├── common.py │ ├── fide.py │ ├── opening_explorer.py │ ├── puzzles.py │ ├── studies.py │ ├── team.py │ ├── tournaments.py │ └── tv.py └── utils.py ├── check-endpoints.py ├── docs ├── api.rst ├── changelog.rst ├── conf.py ├── contributing.rst ├── index.rst └── usage.rst ├── hooks └── pre-commit ├── integration ├── local.sh ├── run-tests.sh └── test_lila_account.py ├── poetry.lock ├── pyproject.toml └── tests ├── clients ├── cassettes │ ├── test_analysis │ │ └── TestAnalysis.test_get_cloud_evaluation.yaml │ ├── test_fide │ │ ├── TestFide.test_get_player.yaml │ │ └── TestFide.test_search_players.yaml │ ├── test_opening_explorer │ │ ├── TestLichessGames.test_result.yaml │ │ ├── TestMasterGames.test_export.yaml │ │ ├── TestMasterGames.test_result.yaml │ │ └── TestPlayerGames.results.yaml │ ├── test_teams │ │ ├── TestLichessGames.test_get_popular.yaml │ │ ├── TestLichessGames.test_get_team.yaml │ │ ├── TestLichessGames.test_search.yaml │ │ └── TestLichessGames.test_teams_of_player.yaml │ ├── test_tournaments │ │ ├── TestLichessGames.test_arenas_result.yaml │ │ ├── TestLichessGames.test_arenas_result_with_sheet.yaml │ │ ├── TestLichessGames.test_swiss_result.yaml │ │ └── TestLichessGames.test_team_standings.yaml │ └── test_users │ │ ├── TestLichessGames.test_get_by_autocomplete.yaml │ │ ├── TestLichessGames.test_get_by_autocomplete_as_object.yaml │ │ ├── TestLichessGames.test_get_by_autocomplete_as_object_not_found.yaml │ │ └── TestLichessGames.test_get_by_autocomplete_not_found.yaml ├── test_analysis.py ├── test_fide.py ├── test_opening_explorer.py ├── test_teams.py ├── test_tournaments.py ├── test_users.py └── utils.py ├── conftest.py ├── test_config.py ├── test_e2e.py ├── test_formats.py ├── test_models.py ├── test_session.py └── test_utils.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | charset = utf-8 10 | end_of_line = lf 11 | 12 | [*.py] 13 | indent_size = 4 14 | 15 | [*.bat] 16 | indent_style = tab 17 | end_of_line = crlf 18 | 19 | [LICENSE] 20 | insert_final_newline = false 21 | 22 | [Makefile] 23 | indent_style = tab 24 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Initial formatting with black 2 | bc9752f718bf42d2d8dcce0fab93e5f5c660c772 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Report a bug in berserk 3 | labels: ['bug'] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for reporting an issue. Before submitting, please [search existing issues](https://github.com/lichess-org/berserk/issues) to confirm this is not a duplicate issue. 9 | - type: textarea 10 | attributes: 11 | label: Describe the issue 12 | description: Provide what happened, what went wrong, and what you expected to happen 13 | validations: 14 | required: true 15 | - type: textarea 16 | attributes: 17 | label: Code to reproduce the bug 18 | description: Provide the code to reproduce the bug 19 | validations: 20 | required: true 21 | - type: input 22 | attributes: 23 | label: Berserk version 24 | description: Paste the result of `python3 -c "import berserk; print(berserk.__version__)"` 25 | validations: 26 | required: true 27 | validations: 28 | required: true 29 | - type: input 30 | attributes: 31 | label: Python version 32 | description: Paste the result of `python3 --version` 33 | validations: 34 | required: true 35 | - type: input 36 | attributes: 37 | label: Operating system 38 | description: Specify the OS you are using 39 | validations: 40 | required: true 41 | - type: textarea 42 | attributes: 43 | label: Additional information 44 | description: | 45 | Provide any additional information that will give more context for the issue you are encountering. 46 | Screenshots can be added by clicking this area and then pasting or dragging them in. 47 | validations: 48 | required: false 49 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Set update schedule for GitHub Actions 2 | 3 | version: 2 4 | updates: 5 | 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | # Check for updates to GitHub Actions every week 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | Checklist when adding a new endpoint 6 | 7 | 8 | 10 | 11 | - [ ] Added new endpoint to the `README.md` 12 | - [ ] Ensured that my endpoint name does not repeat the name of the client. Wrong: `client.users.get_user()`, Correct: `client.users.get()` 13 | - [ ] Typed the returned JSON using TypedDicts in `berserk/types/`, [example](https://github.com/lichess-org/berserk/blob/master/berserk/types/team.py#L32) 14 | - [ ] Written tests for GET endpoints not requiring authentification. [Documentation](https://github.com/lichess-org/berserk/blob/master/CONTRIBUTING.rst#using-pytest-recording--vcrpy), [example](https://github.com/lichess-org/berserk/blob/master/tests/clients/test_teams.py#L11) 15 | - [ ] Added the endpoint and your name to `CHANGELOG.md` in the `To be released` section (to be created if necessary) 16 |
17 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | paths: 7 | - ".github/workflows/codeql.yml" 8 | - "**.py" 9 | - "poetry.lock" 10 | - "pyproject.toml" 11 | pull_request: 12 | paths: 13 | - ".github/workflows/codeql.yml" 14 | - "**.py" 15 | - "poetry.lock" 16 | - "pyproject.toml" 17 | 18 | jobs: 19 | analyze: 20 | name: Analyze 21 | runs-on: ubuntu-latest 22 | permissions: 23 | actions: read 24 | contents: read 25 | security-events: write 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: github/codeql-action/init@v3 29 | with: 30 | languages: python 31 | - uses: github/codeql-action/analyze@v3 32 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | paths: 7 | - ".github/workflows/deploy-docs.yml" 8 | - "**.py" 9 | - "**.rst" 10 | - "poetry.lock" 11 | - "pyproject.toml" 12 | pull_request: 13 | paths: 14 | - ".github/workflows/check-docs.yml" 15 | - "**.py" 16 | - "**.rst" 17 | - "poetry.lock" 18 | - "pyproject.toml" 19 | 20 | # Allows running this workflow manually from the Actions tab 21 | workflow_dispatch: 22 | 23 | concurrency: 24 | group: "docs-${{ github.ref }}" 25 | cancel-in-progress: true 26 | 27 | jobs: 28 | docs: 29 | strategy: 30 | fail-fast: true 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - name: Set up latest python 35 | uses: actions/setup-python@v5 36 | with: 37 | python-version: "3.12" 38 | - name: Set up poetry 39 | uses: abatilo/actions-poetry@v4 40 | - name: Install dependencies 41 | run: poetry install --with dev 42 | - name: Build doc 43 | run: poetry run sphinx-build -b html docs _build -EW --keep-going 44 | - name: Upload artifact 45 | if: github.ref == 'refs/heads/master' 46 | uses: actions/upload-pages-artifact@v3 47 | with: 48 | path: "_build" 49 | deploy: 50 | needs: docs 51 | if: github.ref == 'refs/heads/master' 52 | environment: 53 | name: github-pages 54 | url: ${{ steps.deployment.outputs.page_url }} 55 | permissions: 56 | contents: read 57 | pages: write 58 | id-token: write 59 | runs-on: ubuntu-latest 60 | steps: 61 | - name: Setup Pages 62 | uses: actions/configure-pages@v5 63 | - name: Deploy to GitHub Pages 64 | id: deployment 65 | uses: actions/deploy-pages@v4 66 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Format 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | paths: 7 | - ".github/workflows/format.yml" 8 | - "**.py" 9 | - ".editorconfig" 10 | pull_request: 11 | paths: 12 | - ".github/workflows/format.yml" 13 | - "**.py" 14 | - ".editorconfig" 15 | 16 | jobs: 17 | format: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up latest python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: "3.12" 25 | - name: Set up poetry 26 | uses: abatilo/actions-poetry@v4 27 | - name: Install dependencies 28 | run: poetry install --with dev 29 | - name: Check formatting 30 | run: poetry run black berserk tests check-endpoints.py --check -------------------------------------------------------------------------------- /.github/workflows/integration_test.yml: -------------------------------------------------------------------------------- 1 | name: Lila integration test 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | lila: 9 | runs-on: ubuntu-24.04 10 | strategy: 11 | matrix: 12 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 13 | container: ubuntu:latest 14 | services: 15 | bdit_lila: 16 | image: ghcr.io/lichess-org/lila-docker:main 17 | options: --restart=always 18 | steps: 19 | - name: Setup Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | python -m pip install pytest 27 | - name: Install curl 28 | run: apt-get update && apt-get install -y curl 29 | - name: Checkout berserk 30 | uses: actions/checkout@v4 31 | - name: Run tests 32 | run: | 33 | ./integration/run-tests.sh 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | paths: 7 | - ".github/workflows/test.yml" 8 | - "**.py" 9 | - "poetry.lock" 10 | - "pyproject.toml" 11 | pull_request: 12 | paths: 13 | - ".github/workflows/test.yml" 14 | - "**.py" 15 | - "poetry.lock" 16 | - "pyproject.toml" 17 | 18 | jobs: 19 | test: 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 24 | os: [ubuntu-latest, macos-latest, windows-latest] 25 | runs-on: ${{ matrix.os }} 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Set up Python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | - name: Set up poetry 33 | uses: abatilo/actions-poetry@v4 34 | - name: Install dependencies 35 | run: poetry install --with dev 36 | - name: Test 37 | run: poetry run pytest tests 38 | -------------------------------------------------------------------------------- /.github/workflows/typing.yml: -------------------------------------------------------------------------------- 1 | name: Typing 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | paths: 7 | - ".github/workflows/typing.yml" 8 | - "**.py" 9 | - "poetry.lock" 10 | - "pyproject.toml" 11 | pull_request: 12 | paths: 13 | - ".github/workflows/typing.yml" 14 | - "**.py" 15 | - "poetry.lock" 16 | - "pyproject.toml" 17 | 18 | jobs: 19 | test: 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | - name: Set up poetry 32 | uses: abatilo/actions-poetry@v4 33 | - name: Install dependencies 34 | run: poetry install --with dev 35 | - name: Typecheck 36 | run: poetry run pyright berserk 37 | -------------------------------------------------------------------------------- /.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 | # documentation artefacts 28 | _build/ 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # dotenv 86 | .env 87 | 88 | # virtualenv 89 | .venv 90 | venv/ 91 | ENV/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # idea 107 | .idea/ 108 | .vscode/ 109 | config.py 110 | pyvenv.cfg 111 | Scripts/activate 112 | *.bat 113 | *.ps1 114 | *.exe 115 | berserk/test_clients.py 116 | Scripts/rst2html.py 117 | Scripts/rst2html4.py 118 | Scripts/rst2html5.py 119 | Scripts/rst2latex.py 120 | Scripts/rst2man.py 121 | Scripts/rst2odt.py 122 | Scripts/rst2odt_prepstyles.py 123 | Scripts/rst2pseudoxml.py 124 | Scripts/rst2s5.py 125 | Scripts/rst2xetex.py 126 | Scripts/rst2xml.py 127 | Scripts/rstpep2html.py 128 | -------------------------------------------------------------------------------- /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/lichess-org/berserk/issues. 18 | 19 | Fix Bugs 20 | ~~~~~~~~ 21 | 22 | Look through the GitHub issues for bugs. 23 | 24 | Implement Missing Endpoints 25 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 26 | 27 | You can run the ``check-endpoints.py`` script (requires yaml to be installed, ``pip3 install pyyaml``) in the root of the project to get a list of endpoints that are still missing. 28 | 29 | Write Documentation 30 | ~~~~~~~~~~~~~~~~~~~ 31 | 32 | berserk could always use more documentation, whether as part of the 33 | official berserk docs, in docstrings, or even on the web in blog posts, 34 | articles, and such. 35 | 36 | Submit Feedback 37 | ~~~~~~~~~~~~~~~ 38 | 39 | The best way to send feedback is to file an issue at https://github.com/lichess-org/berserk/issues. 40 | 41 | If you are proposing a feature: 42 | 43 | * Explain in detail how it would work. 44 | * Keep the scope as narrow as possible, to make it easier to implement. 45 | * Remember that this is a volunteer-driven project, and that contributions 46 | are welcome :) 47 | 48 | Get Started! 49 | ------------ 50 | 51 | - Install ``poetry`` (``pip3 install poetry``) 52 | - Setup dependencies by running ``poetry install --with dev`` or ``make setup`` 53 | - Optional: Add pre-commit hook to test and format your change before commiting: ``cp hooks/pre-commit .git/hooks/pre-commit`` 54 | - Start editing the code 55 | - To test your changes, run ``poetry shell`` to activate the poetry environment, open a python interpreter (``python3``), and import the library to test your changes:: 56 | 57 | >>> import berserk 58 | >>> client = berserk.Client() 59 | >>> client.users.my_new_method() 60 | 61 | For a PR to be merged, it needs to pass the CI, you can reproduce most of them locally (commands assume being in the root directory of this repo): 62 | 63 | - To run tests, use ``poetry run pytest`` or ``make test`` 64 | - To run type checking, use ``poetry run pyright berserk`` or ``make typecheck`` 65 | - To format the code, use ``poetry run black berserk tests`` or ``make format`` 66 | - To check doc generation use ``poetry run sphinx-build -b html docs _build -EW`` or ``make docs`` 67 | 68 | - You can then open ``_build/index.html`` in your browser or use ``python3 -m http.server --directory _build`` to serve it locally 69 | - Alternatively, run ``make servedocs`` to automatically build and serve them on http://localhost:8000 70 | 71 | Writing Tests 72 | ------------- 73 | 74 | We use ``requests-mock`` and ``pytest-recording`` / ``vcrpy`` to test http requests. 75 | 76 | Using ``requests-mock`` 77 | ~~~~~~~~~~~~~~~~~~~~~~~ 78 | 79 | ``requests-mock`` can be used to manually mock and test simple http requests: 80 | 81 | .. code-block:: python 82 | 83 | import requests_mock 84 | from berserk import Client 85 | 86 | def test_correct_speed_params(self): 87 | """The test verify that speeds parameter are passed correctly in query params""" 88 | with requests_mock.Mocker() as m: 89 | m.get( 90 | "https://explorer.lichess.ovh/lichess?variant=standard&speeds=rapid%2Cclassical", 91 | json={"white":1212,"draws":160,"black":1406}, 92 | ) 93 | res = Client().opening_explorer.get_lichess_games(speeds=["rapid", "classical"]) 94 | 95 | Mocking should only be used to test **client-side** logic. 96 | 97 | Using ``pytest-recording`` / ``vcrpy`` 98 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 99 | 100 | ``pytest-recording`` (which internally uses ``vcrpy``) can be used to record and replay real http requests: 101 | 102 | .. code-block:: python 103 | 104 | import pytest 105 | 106 | from berserk import Client, OpeningStatistic 107 | 108 | from utils import validate, skip_if_older_3_dot_10 109 | 110 | @skip_if_older_3_dot_10 111 | @pytest.mark.vcr # <---- this tells pytest-recording to record/mock requests made in this test 112 | def test_result(self): 113 | """Verify that the response matches the typed-dict""" 114 | res = Client().opening_explorer.get_lichess_games( 115 | variant="standard", 116 | speeds=["blitz", "rapid", "classical"], 117 | ratings=["2200", "2500"], 118 | position="rnbqkbnr/ppp2ppp/8/3pp3/4P3/2NP4/PPP2PPP/R1BQKBNR b KQkq - 0 1", 119 | ) 120 | validate(OpeningStatistic, res) 121 | 122 | This should be used to test **server-side** behavior. 123 | 124 | To record new requests, run ``make test_record``. This will run all tests and record new requests made in annotated methods in a ``cassettes`` directory next to the test. 125 | Note that this will not overwrite existing captures, so you need to delete them manually if you want to re-record them. 126 | 127 | When running tests regularly (e.g. with ``make test``), the recorded requests will be replayed instead of making real http requests. 128 | 129 | ⚠️ Do not record sensitive information (tokens). See the `Filtering information documentation `_. 130 | 131 | .. code-block:: python 132 | 133 | Deploying 134 | --------- 135 | 136 | A reminder for the maintainers on how to deploy. 137 | 138 | You need a PyPI account with access to the ``berserk`` package and have an API token with the corresponding access configured for poetry (see https://python-poetry.org/docs/repositories/#configuring-credentials): 139 | 140 | - Create a token: https://pypi.org/manage/account/token/ (you can see your existing tokens at https://pypi.org/manage/account/) 141 | - Configure poetry: ``poetry config pypi-token.pypi ``. Add a space before the command to avoid it being saved in your shell history. 142 | 143 | Make sure all your changes are committed (including an entry in CHANGELOG.rst) and you set the version in ``pyproject.toml`` correctly. 144 | 145 | Then run ``make publish`` and tag the release on git: ``git tag v1.2.3 && git push --tags`` 146 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help clean clean-docs clean-test setup test format docs servedocs publish 2 | .DEFAULT_GOAL := help 3 | 4 | define PRINT_HELP_PYSCRIPT 5 | import re, sys 6 | 7 | for line in sys.stdin: 8 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 9 | if match: 10 | target, help = match.groups() 11 | print("%-20s %s" % (target, help)) 12 | endef 13 | export PRINT_HELP_PYSCRIPT 14 | 15 | help: 16 | @python3 -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 17 | 18 | clean: clean-docs clean-test ## remove test cache and built docs 19 | 20 | clean-docs: ## remove build artifacts 21 | rm -fr _build 22 | 23 | clean-test: ## remove test and coverage artifacts 24 | rm -fr .pytest_cache 25 | 26 | setup: ## setup poetry env and install dependencies 27 | poetry install --with dev 28 | 29 | test: ## run tests with pytest 30 | poetry run pytest tests 31 | 32 | test_record: ## run tests with pytest and record http requests 33 | poetry run pytest --record-mode=once 34 | 35 | typecheck: ## run type checking with pyright 36 | poetry run pyright berserk 37 | 38 | format: ## format python files with black and docformatter 39 | poetry run black berserk tests check-endpoints.py 40 | poetry run docformatter --in-place --black berserk/*.py 41 | 42 | docs: ## generate Sphinx HTML documentation, including API docs 43 | poetry run sphinx-build -b html docs _build -EW --keep-going 44 | 45 | servedocs: docs ## compile the docs and serve them locally 46 | python3 -m http.server --directory _build --bind 127.0.0.1 47 | 48 | publish: ## publish to pypi 49 | @echo 50 | @echo "Release checklist:" 51 | @echo " - Did you update the documentation? (including adding new endpoints to the README?)" 52 | @echo " - Did you update the changelog? (remember to thank contributors)" 53 | @echo " - Did you check that tests, docs, and type checking pass?" 54 | @echo " - Did you bump the version?" 55 | @echo " - Did you tag the commit? (can also be done afterwards)" 56 | @echo 57 | @read -p "Are you sure you want to create a release? [y/N] " ans && [ $${ans:-N} = y ] 58 | sleep 5 59 | poetry publish --build 60 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | berserk 2 | ======= 3 | 4 | .. image:: https://github.com/lichess-org/berserk/actions/workflows/test.yml/badge.svg 5 | :target: https://github.com/lichess-org/berserk/actions 6 | :alt: Test status 7 | 8 | .. image:: https://badge.fury.io/py/berserk.svg 9 | :target: https://pypi.org/project/berserk/ 10 | :alt: PyPI package 11 | 12 | .. image:: https://github.com/lichess-org/berserk/actions/workflows/docs.yml/badge.svg 13 | :target: https://lichess-org.github.io/berserk/ 14 | :alt: Docs 15 | 16 | .. image:: https://img.shields.io/discord/280713822073913354.svg?label=discord&color=green&logo=discord 17 | :target: https://discord.gg/lichess 18 | :alt: Discord 19 | 20 | Python client for the `Lichess API `_. 21 | 22 | This is based on the `original berserk version created by rhgrant10 `_ and the `berserk-downstream fork created by ZackClements `_. Big thanks to them and all other contributors! 23 | 24 | `Documentation `_ 25 | 26 | Installation 27 | ------------ 28 | 29 | Requires Python 3.8+. Download and install the latest release: 30 | :: 31 | 32 | pip3 install berserk 33 | 34 | If you have `berserk-downstream` installed, make sure to uninstall it first! 35 | 36 | Features 37 | -------- 38 | 39 | * handles (ND)JSON and PGN formats at user's discretion 40 | * token auth session 41 | * easy integration with OAuth2 42 | * automatically converts time values to datetimes 43 | 44 | Usage 45 | ----- 46 | 47 | You can use any ``requests.Session``-like object as a session, including those 48 | from ``requests_oauth``. A simple token session is included, as shown below: 49 | 50 | .. code:: python 51 | 52 | import berserk 53 | 54 | session = berserk.TokenSession(API_TOKEN) 55 | client = berserk.Client(session=session) 56 | 57 | 58 | Most of the API is available: 59 | 60 | .. code:: python 61 | 62 | client.account.get 63 | client.account.get_email 64 | client.account.get_preferences 65 | client.account.get_kid_mode 66 | client.account.set_kid_mode 67 | client.account.upgrade_to_bot 68 | 69 | client.analysis.get_cloud_evaluation 70 | 71 | client.board.stream_incoming_events 72 | client.board.seek 73 | client.board.stream_game_state 74 | client.board.make_move 75 | client.board.post_message 76 | client.board.get_game_chat 77 | client.board.abort_game 78 | client.board.resign_game 79 | client.board.handle_draw_offer 80 | client.board.offer_draw 81 | client.board.accept_draw 82 | client.board.decline_draw 83 | client.board.handle_takeback_offer 84 | client.board.offer_takeback 85 | client.board.accept_takeback 86 | client.board.decline_takeback 87 | client.board.claim_victory 88 | client.board.go_berserk 89 | 90 | client.bots.get_online_bots 91 | client.bots.stream_incoming_events 92 | client.bots.stream_game_state 93 | client.bots.make_move 94 | client.bots.post_message 95 | client.bots.abort_game 96 | client.bots.resign_game 97 | client.bots.accept_challenge 98 | client.bots.decline_challenge 99 | 100 | client.broadcasts.get_official 101 | client.broadcasts.create 102 | client.broadcasts.get 103 | client.broadcasts.update 104 | client.broadcasts.push_pgn_update 105 | client.broadcasts.create_round 106 | client.broadcasts.get_round 107 | client.broadcasts.update_round 108 | client.broadcasts.get_round_pgns 109 | client.broadcasts.get_pgns 110 | client.broadcasts.stream_round 111 | 112 | client.bulk_pairings.get_upcoming 113 | client.bulk_pairings.create 114 | client.bulk_pairings.start_clocks 115 | client.bulk_pairings.cancel 116 | 117 | client.challenges.get_mine 118 | client.challenges.create 119 | client.challenges.create_ai 120 | client.challenges.create_open 121 | client.challenges.create_with_accept 122 | client.challenges.accept 123 | client.challenges.decline 124 | client.challenges.cancel 125 | client.challenges.start_clocks 126 | client.challenges.add_time_to_opponent_clock 127 | client.challenges.create_tokens_for_multiple_users 128 | 129 | client.explorer.get_lichess_games 130 | client.explorer.get_masters_games 131 | client.explorer.get_player_games 132 | client.explorer.stream_player_games 133 | client.explorer.get_otb_master_game 134 | 135 | client.external_engine.get 136 | client.external_engine.get_by_id 137 | client.external_engine.create 138 | client.external_engine.update 139 | client.external_engine.delete 140 | 141 | client.fide.get_player 142 | client.fide.search_players 143 | 144 | client.games.export 145 | client.games.export_ongoing_by_player 146 | client.games.export_by_player 147 | client.games.export_multi 148 | client.games.get_among_players 149 | client.games.stream_games_by_ids 150 | client.games.add_game_ids_to_stream 151 | client.games.get_ongoing 152 | client.games.stream_game_moves 153 | client.games.get_tv_channels 154 | client.games.import_game 155 | 156 | client.messaging.send 157 | 158 | client.oauth.test_tokens 159 | 160 | client.puzzles.get_daily 161 | client.puzzles.get 162 | client.puzzles.get_puzzle_activity 163 | client.puzzles.get_puzzle_dashboard 164 | client.puzzles.get_storm_dashboard 165 | client.puzzles.create_race 166 | 167 | client.relations.get_users_followed 168 | client.relations.follow 169 | client.relations.unfollow 170 | 171 | client.simuls.get 172 | 173 | client.studies.export_chapter 174 | client.studies.export 175 | client.studies.export_by_username 176 | client.studies.import_pgn 177 | 178 | client.tablebase.look_up 179 | client.tablebase.standard 180 | client.tablebase.atomic 181 | client.tablebase.antichess 182 | 183 | client.teams.get_members 184 | client.teams.join 185 | client.teams.leave 186 | client.teams.kick_member 187 | client.teams.get_join_requests 188 | client.teams.accept_join_request 189 | client.teams.decline_join_request 190 | client.teams.get_team 191 | client.teams.teams_of_player 192 | client.teams.get_popular 193 | client.teams.search 194 | client.teams.message_all_members 195 | 196 | client.tournaments.edit_swiss 197 | client.tournaments.get 198 | client.tournaments.get_tournament 199 | client.tournaments.get_swiss 200 | client.tournaments.get_team_standings 201 | client.tournaments.update_team_battle 202 | client.tournaments.create_arena 203 | client.tournaments.create_swiss 204 | client.tournaments.export_arena_games 205 | client.tournaments.export_swiss_games 206 | client.tournaments.export_swiss_trf 207 | client.tournaments.arena_by_team 208 | client.tournaments.swiss_by_team 209 | client.tournaments.join_arena 210 | client.tournaments.join_swiss 211 | client.tournaments.terminate_arena 212 | client.tournaments.terminate_swiss 213 | client.tournaments.tournaments_by_user 214 | client.tournaments.stream_results 215 | client.tournaments.stream_swiss_results 216 | client.tournaments.stream_by_creator 217 | client.tournaments.withdraw_arena 218 | client.tournaments.withdraw_swiss 219 | client.tournaments.schedule_swiss_next_round 220 | 221 | client.tv.get_current_games 222 | client.tv.stream_current_game 223 | client.tv.stream_current_game_of_channel 224 | client.tv.get_best_ongoing 225 | 226 | client.users.get_realtime_statuses 227 | client.users.get_all_top_10 228 | client.users.get_leaderboard 229 | client.users.get_public_data 230 | client.users.get_activity_feed 231 | client.users.get_by_id 232 | client.users.get_by_team 233 | client.users.get_live_streamers 234 | client.users.get_rating_history 235 | client.users.get_crosstable 236 | client.users.get_user_performance 237 | client.users.get_by_autocomplete 238 | 239 | Details for each function can be found in the `documentation `_. 240 | -------------------------------------------------------------------------------- /berserk/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for berserk.""" 2 | 3 | from importlib import metadata 4 | 5 | berserk_metadata = metadata.metadata(__package__) 6 | 7 | 8 | __author__ = berserk_metadata["Author"] 9 | __email__ = berserk_metadata["Author-email"] 10 | __version__ = berserk_metadata["Version"] 11 | 12 | 13 | from .clients import Client 14 | from .types import ( 15 | ArenaResult, 16 | BroadcastPlayer, 17 | Team, 18 | LightUser, 19 | ChapterIdName, 20 | OnlineLightUser, 21 | OpeningStatistic, 22 | PaginatedTeams, 23 | PuzzleRace, 24 | SwissInfo, 25 | SwissResult, 26 | TVFeed, 27 | ) 28 | from .session import TokenSession 29 | from .session import Requestor 30 | from .formats import JSON 31 | from .formats import JSON_LIST 32 | from .formats import LIJSON 33 | from .formats import NDJSON 34 | from .formats import NDJSON_LIST 35 | from .formats import PGN 36 | 37 | __all__ = [ 38 | "ArenaResult", 39 | "BroadcastPlayer", 40 | "ChapterIdName", 41 | "Client", 42 | "JSON", 43 | "JSON_LIST", 44 | "LightUser", 45 | "LIJSON", 46 | "NDJSON", 47 | "NDJSON_LIST", 48 | "OnlineLightUser", 49 | "OpeningStatistic", 50 | "PaginatedTeams", 51 | "PGN", 52 | "PuzzleRace", 53 | "Requestor", 54 | "SwissInfo", 55 | "SwissResult", 56 | "Team", 57 | "TokenSession", 58 | "TVFeed", 59 | ] 60 | -------------------------------------------------------------------------------- /berserk/clients/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import requests 4 | 5 | from .analysis import Analysis 6 | from .base import BaseClient 7 | from .account import Account 8 | from .users import Users 9 | from .relations import Relations 10 | from .teams import Teams 11 | from .games import Games 12 | from .challenges import Challenges 13 | from .board import Board 14 | from .bots import Bots 15 | from .tournaments import Tournaments 16 | from .broadcasts import Broadcasts 17 | from .simuls import Simuls 18 | from .studies import Studies 19 | from .messaging import Messaging 20 | from .puzzles import Puzzles 21 | from .oauth import OAuth 22 | from .tv import TV 23 | from .tablebase import Tablebase 24 | from .opening_explorer import OpeningExplorer 25 | from .bulk_pairings import BulkPairings 26 | from .external_engine import ExternalEngine 27 | from .fide import Fide 28 | 29 | __all__ = [ 30 | "Account", 31 | "Analysis", 32 | "Board", 33 | "Bots", 34 | "Broadcasts", 35 | "BulkPairings", 36 | "Challenges", 37 | "Client", 38 | "ExternalEngine", 39 | "Fide", 40 | "Games", 41 | "Messaging", 42 | "OAuth", 43 | "Puzzles", 44 | "Relations", 45 | "Simuls", 46 | "Studies", 47 | "Tablebase", 48 | "Teams", 49 | "Tournaments", 50 | "TV", 51 | "Users", 52 | ] 53 | 54 | 55 | class Client(BaseClient): 56 | """Main touchpoint for the API. 57 | 58 | All endpoints are namespaced into the clients below: 59 | 60 | - :class:`account ` - managing account information 61 | - :class:`account ` - getting information about position analysis 62 | - :class:`bots ` - performing bot operations 63 | - :class:`broadcasts ` - getting and creating broadcasts 64 | - :class:`challenges ` - using challenges 65 | - :class:`games ` - getting and exporting games 66 | - :class:`simuls ` - getting simultaneous exhibition games 67 | - :class:`studies ` - exporting studies 68 | - :class:`teams ` - getting information about teams 69 | - :class:`tournaments ` - getting and creating 70 | tournaments 71 | - :class:`users ` - getting information about users 72 | - :class:`board ` - play games using a normal account 73 | - :class:`messaging ` - private message other players 74 | - :class:`tv ` - get information on tv channels and games 75 | - :class:`tablebase ` - lookup endgame tablebase 76 | - :class:`bulk_pairings ` - manage bulk pairings 77 | - :class: `external_engine ` - manage external engines 78 | 79 | :param session: request session, authenticated as needed 80 | :param base_url: base API URL to use (if other than the default) 81 | :param pgn_as_default: ``True`` if PGN should be the default format for game exports 82 | when possible. This defaults to ``False`` and is used as a fallback when 83 | ``as_pgn`` is left as ``None`` for methods that support it. 84 | :param tablebase_url: URL for tablebase lookups 85 | """ 86 | 87 | def __init__( 88 | self, 89 | session: requests.Session | None = None, 90 | base_url: str | None = None, 91 | pgn_as_default: bool = False, 92 | *, 93 | tablebase_url: str | None = None, 94 | explorer_url: str | None = None, 95 | ): 96 | session = session or requests.Session() 97 | super().__init__(session, base_url) 98 | self.account = Account(session, base_url) 99 | self.analysis = Analysis(session, base_url) 100 | self.users = Users(session, base_url) 101 | self.relations = Relations(session, base_url) 102 | self.teams = Teams(session, base_url) 103 | self.games = Games(session, base_url, pgn_as_default=pgn_as_default) 104 | self.challenges = Challenges(session, base_url) 105 | self.board = Board(session, base_url) 106 | self.bots = Bots(session, base_url) 107 | self.tournaments = Tournaments(session, base_url, pgn_as_default=pgn_as_default) 108 | self.broadcasts = Broadcasts(session, base_url) 109 | self.simuls = Simuls(session, base_url) 110 | self.studies = Studies(session, base_url) 111 | self.messaging = Messaging(session, base_url) 112 | self.puzzles = Puzzles(session, base_url) 113 | self.oauth = OAuth(session, base_url) 114 | self.tv = TV(session, base_url) 115 | self.tablebase = Tablebase(session, tablebase_url) 116 | self.opening_explorer = OpeningExplorer(session, explorer_url) 117 | self.bulk_pairings = BulkPairings(session, base_url) 118 | self.external_engine = ExternalEngine(session, base_url) 119 | self.fide = Fide(session) 120 | -------------------------------------------------------------------------------- /berserk/clients/account.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import cast 4 | 5 | from .. import models 6 | from ..types.account import AccountInformation, Preferences 7 | from .base import BaseClient 8 | 9 | 10 | class Account(BaseClient): 11 | """Client for account-related endpoints.""" 12 | 13 | def get(self) -> AccountInformation: 14 | """Get your public information. 15 | 16 | :return: public information about the authenticated user 17 | """ 18 | path = "/api/account" 19 | return cast( 20 | AccountInformation, self._r.get(path, converter=models.Account.convert) 21 | ) 22 | 23 | def get_email(self) -> str: 24 | """Get your email address. 25 | 26 | :return: email address of the authenticated user 27 | """ 28 | path = "/api/account/email" 29 | return self._r.get(path)["email"] 30 | 31 | def get_preferences(self) -> Preferences: 32 | """Get your account preferences. 33 | 34 | :return: preferences of the authenticated user 35 | """ 36 | path = "/api/account/preferences" 37 | return cast(Preferences, self._r.get(path)) 38 | 39 | def get_kid_mode(self) -> bool: 40 | """Get your kid mode status. 41 | 42 | :return: current kid mode status 43 | """ 44 | path = "/api/account/kid" 45 | return self._r.get(path)["kid"] 46 | 47 | def set_kid_mode(self, value: bool) -> None: 48 | """Set your kid mode status. 49 | 50 | :param bool value: whether to enable or disable kid mode 51 | """ 52 | path = "/api/account/kid" 53 | params = {"v": value} 54 | self._r.post(path, params=params) 55 | 56 | def upgrade_to_bot(self): 57 | """Upgrade your account to a bot account. 58 | 59 | Requires bot:play oauth scope. User cannot have any previously played games. 60 | """ 61 | path = "/api/bot/account/upgrade" 62 | self._r.post(path) 63 | -------------------------------------------------------------------------------- /berserk/clients/analysis.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import cast 4 | 5 | from ..types import VariantKey 6 | from .base import BaseClient 7 | from ..types.analysis import PositionEvaluation 8 | 9 | 10 | class Analysis(BaseClient): 11 | """Client for analysis-related endpoints.""" 12 | 13 | def get_cloud_evaluation( 14 | self, 15 | fen: str, 16 | num_variations: int = 1, 17 | variant: VariantKey = "standard", 18 | ) -> PositionEvaluation: 19 | """Get the cached evaluation of a position, if available. 20 | 21 | Opening positions have more chances of being available. There are about 15 million positions in the database. 22 | Up to 5 variations may be available. Variants are supported. 23 | 24 | :param fen: FEN of a position 25 | :param num_variations: number of variations 26 | :param variant: game variant to use 27 | :return: cloud evaluation of a position 28 | """ 29 | path = "/api/cloud-eval" 30 | params = {"fen": fen, "multiPv": num_variations, "variant": variant} 31 | return cast(PositionEvaluation, self._r.get(path=path, params=params)) 32 | -------------------------------------------------------------------------------- /berserk/clients/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import requests 4 | 5 | from ..formats import JSON 6 | from ..session import Requestor 7 | 8 | # Base URL for the API 9 | API_URL = "https://lichess.org" 10 | 11 | 12 | class BaseClient: 13 | def __init__(self, session: requests.Session, base_url: str | None = None): 14 | self._r = Requestor(session, base_url or API_URL, default_fmt=JSON) 15 | 16 | 17 | class FmtClient(BaseClient): 18 | """Client that can return PGN or not. 19 | 20 | :param session: request session, authenticated as needed 21 | :param base_url: base URL for the API 22 | :param pgn_as_default: ``True`` if PGN should be the default format for game exports 23 | when possible. This defaults to ``False`` and is used as a fallback when 24 | ``as_pgn`` is left as ``None`` for methods that support it. 25 | """ 26 | 27 | def __init__( 28 | self, 29 | session: requests.Session, 30 | base_url: str | None = None, 31 | pgn_as_default: bool = False, 32 | ): 33 | super().__init__(session, base_url) 34 | self.pgn_as_default = pgn_as_default 35 | 36 | def _use_pgn(self, as_pgn: bool | None = None): 37 | # helper to merge default with provided arg 38 | return as_pgn if as_pgn is not None else self.pgn_as_default 39 | -------------------------------------------------------------------------------- /berserk/clients/board.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from time import time as now 4 | from typing import Iterator, Any, Dict, Tuple, List, Literal 5 | 6 | from .. import models 7 | from ..types.common import Color, VariantKey 8 | from ..formats import TEXT, JSON_LIST 9 | from .base import BaseClient 10 | from ..session import Data 11 | 12 | 13 | class Board(BaseClient): 14 | """Client for physical board or external application endpoints.""" 15 | 16 | def stream_incoming_events(self) -> Iterator[Dict[str, Any]]: 17 | """Get your realtime stream of incoming events. 18 | 19 | :return: stream of incoming events 20 | """ 21 | path = "/api/stream/event" 22 | yield from self._r.get(path, stream=True) 23 | 24 | def seek( 25 | self, 26 | time: int, 27 | increment: int, 28 | rated: bool = False, 29 | variant: VariantKey = "standard", 30 | color: Color | Literal["random"] = "random", 31 | rating_range: str | Tuple[int, int] | List[int] | None = None, 32 | ) -> float: 33 | """Create a public seek to start a game with a random opponent. 34 | 35 | :param time: initial clock time in minutes 36 | :param increment: clock increment in minutes 37 | :param rated: whether the game is rated (impacts ratings) 38 | :param variant: game variant to use 39 | :param color: color to play 40 | :param rating_range: range of opponent ratings 41 | :return: duration of the seek 42 | """ 43 | if isinstance(rating_range, (list, tuple)): 44 | low, high = rating_range 45 | rating_range = f"{low}-{high}" 46 | 47 | path = "/api/board/seek" 48 | payload: Data = { 49 | "rated": str(bool(rated)).lower(), 50 | "time": time, 51 | "increment": increment, 52 | "variant": variant, 53 | "color": color, 54 | "ratingRange": rating_range or "", 55 | } 56 | 57 | # we time the seek 58 | start = now() 59 | 60 | # just keep reading to keep the search going 61 | for _ in self._r.post(path, data=payload, fmt=TEXT, stream=True): 62 | pass 63 | 64 | # and return the time elapsed 65 | return now() - start 66 | 67 | def stream_game_state(self, game_id: str) -> Iterator[Dict[str, Any]]: 68 | """Get the stream of events for a board game. 69 | 70 | :param game_id: ID of a game 71 | :return: iterator over game states 72 | """ 73 | path = f"/api/board/game/stream/{game_id}" 74 | yield from self._r.get(path, stream=True, converter=models.GameState.convert) 75 | 76 | def make_move(self, game_id: str, move: str) -> None: 77 | """Make a move in a board game. 78 | 79 | :param game_id: ID of a game 80 | :param move: move to make 81 | """ 82 | path = f"/api/board/game/{game_id}/move/{move}" 83 | self._r.post(path) 84 | 85 | def post_message(self, game_id: str, text: str, spectator: bool = False) -> None: 86 | """Post a message in a board game. 87 | 88 | :param game_id: ID of a game 89 | :param text: text of the message 90 | :param spectator: post to spectator room (else player room) 91 | """ 92 | path = f"/api/board/game/{game_id}/chat" 93 | room = "spectator" if spectator else "player" 94 | payload = {"room": room, "text": text} 95 | self._r.post(path, json=payload) 96 | 97 | def get_game_chat(self, game_id: str) -> List[Dict[str, str]]: 98 | """Get the messages posted in the game chat. 99 | 100 | :param str game_id: ID of a game 101 | :return: list of game chat events 102 | """ 103 | path = f"/api/board/game/{game_id}/chat" 104 | return self._r.get(path, fmt=JSON_LIST) 105 | 106 | def abort_game(self, game_id: str) -> None: 107 | """Abort a board game. 108 | 109 | :param game_id: ID of a game 110 | """ 111 | path = f"/api/board/game/{game_id}/abort" 112 | self._r.post(path) 113 | 114 | def resign_game(self, game_id: str) -> None: 115 | """Resign a board game. 116 | 117 | :param game_id: ID of a game 118 | """ 119 | path = f"/api/board/game/{game_id}/resign" 120 | self._r.post(path) 121 | 122 | def handle_draw_offer(self, game_id: str, accept: bool) -> None: 123 | """Create, accept, or decline a draw offer. 124 | 125 | To offer a draw, pass ``accept=True`` and a game ID of an in-progress 126 | game. To response to a draw offer, pass either ``accept=True`` or 127 | ``accept=False`` and the ID of a game in which you have received a 128 | draw offer. 129 | 130 | Often, it's easier to call :func:`offer_draw`, :func:`accept_draw`, or 131 | :func:`decline_draw`. 132 | 133 | :param game_id: ID of an in-progress game 134 | :param accept: whether to accept 135 | """ 136 | accept_str = "yes" if accept else "no" 137 | path = f"/api/board/game/{game_id}/draw/{accept_str}" 138 | self._r.post(path) 139 | 140 | def offer_draw(self, game_id: str) -> None: 141 | """Offer a draw in the given game. 142 | 143 | :param game_id: ID of an in-progress game 144 | """ 145 | self.handle_draw_offer(game_id, True) 146 | 147 | def accept_draw(self, game_id: str) -> None: 148 | """Accept an already offered draw in the given game. 149 | 150 | :param game_id: ID of an in-progress game 151 | """ 152 | self.handle_draw_offer(game_id, True) 153 | 154 | def decline_draw(self, game_id: str) -> None: 155 | """Decline an already offered draw in the given game. 156 | 157 | :param game_id: ID of an in-progress game 158 | """ 159 | self.handle_draw_offer(game_id, False) 160 | 161 | def handle_takeback_offer(self, game_id: str, accept: bool) -> None: 162 | """Create, accept, or decline a takeback offer. 163 | 164 | To offer a takeback, pass ``accept=True`` and a game ID of an in-progress 165 | game. To response to a takeback offer, pass either ``accept=True`` or 166 | ``accept=False`` and the ID of a game in which you have received a 167 | takeback offer. 168 | 169 | Often, it's easier to call :func:`offer_takeback`, :func:`accept_takeback`, or 170 | :func:`decline_takeback`. 171 | 172 | :param game_id: ID of an in-progress game 173 | :param accept: whether to accept 174 | """ 175 | accept_str = "yes" if accept else "no" 176 | path = f"/api/board/game/{game_id}/takeback/{accept_str}" 177 | self._r.post(path) 178 | 179 | def offer_takeback(self, game_id: str) -> None: 180 | """Offer a takeback in the given game. 181 | 182 | :param game_id: ID of an in-progress game 183 | """ 184 | self.handle_takeback_offer(game_id, True) 185 | 186 | def accept_takeback(self, game_id: str) -> None: 187 | """Accept an already offered takeback in the given game. 188 | 189 | :param game_id: ID of an in-progress game 190 | """ 191 | self.handle_takeback_offer(game_id, True) 192 | 193 | def decline_takeback(self, game_id: str) -> None: 194 | """Decline an already offered takeback in the given game. 195 | 196 | :param game_id: ID of an in-progress game 197 | """ 198 | self.handle_takeback_offer(game_id, False) 199 | 200 | def claim_victory(self, game_id: str) -> None: 201 | """Claim victory when the opponent has left the game for a while. 202 | 203 | Generally, this should only be called once the `opponentGone` event 204 | is received in the board game state stream and the `claimWinInSeconds` 205 | time has elapsed. 206 | 207 | :param str game_id: ID of an in-progress game 208 | """ 209 | path = f"/api/board/game/{game_id}/claim-victory/" 210 | self._r.post(path) 211 | 212 | def go_berserk(self, game_id: str) -> None: 213 | """Go berserk on an arena tournament game. 214 | 215 | :param str game_id: ID of an in-progress game 216 | """ 217 | path = f"/api/board/game/{game_id}/berserk" 218 | self._r.post(path) 219 | -------------------------------------------------------------------------------- /berserk/clients/bots.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Iterator, Any, Dict, cast 4 | 5 | from .. import models 6 | from ..formats import NDJSON 7 | from .base import BaseClient 8 | from ..types.challenges import ChallengeDeclineReason 9 | from ..types.bots import IncomingEvent 10 | 11 | 12 | class Bots(BaseClient): 13 | """Client for bot-related endpoints.""" 14 | 15 | def stream_incoming_events(self) -> Iterator[IncomingEvent]: 16 | """Get your realtime stream of incoming events. 17 | 18 | :return: stream of incoming events 19 | """ 20 | path = "/api/stream/event" 21 | yield from cast("Iterator[IncomingEvent]", self._r.get(path, stream=True)) 22 | 23 | def stream_game_state(self, game_id: str) -> Iterator[Dict[str, Any]]: 24 | """Get the stream of events for a bot game. 25 | 26 | :param game_id: ID of a game 27 | :return: iterator over game states 28 | """ 29 | path = f"/api/bot/game/stream/{game_id}" 30 | yield from self._r.get(path, stream=True, converter=models.GameState.convert) 31 | 32 | def get_online_bots(self, limit: int | None = None) -> Iterator[Dict[str, Any]]: 33 | """Stream the online bot users. 34 | 35 | :param limit: Maximum number of bot users to fetch 36 | :return: iterator over online bots 37 | """ 38 | path = "/api/bot/online" 39 | params = {"nb": limit} 40 | yield from self._r.get( 41 | path, params=params, stream=True, fmt=NDJSON, converter=models.User.convert 42 | ) 43 | 44 | def make_move(self, game_id: str, move: str) -> None: 45 | """Make a move in a bot game. 46 | 47 | :param game_id: ID of a game 48 | :param move: move to make 49 | """ 50 | path = f"/api/bot/game/{game_id}/move/{move}" 51 | self._r.post(path) 52 | 53 | def post_message(self, game_id: str, text: str, spectator: bool = False): 54 | """Post a message in a bot game. 55 | 56 | :param game_id: ID of a game 57 | :param text: text of the message 58 | :param spectator: post to spectator room (else player room) 59 | """ 60 | path = f"/api/bot/game/{game_id}/chat" 61 | room = "spectator" if spectator else "player" 62 | payload = {"room": room, "text": text} 63 | self._r.post(path, json=payload) 64 | 65 | def abort_game(self, game_id: str) -> None: 66 | """Abort a bot game. 67 | 68 | :param game_id: ID of a game 69 | """ 70 | path = f"/api/bot/game/{game_id}/abort" 71 | self._r.post(path) 72 | 73 | def resign_game(self, game_id: str) -> None: 74 | """Resign a bot game. 75 | 76 | :param game_id: ID of a game 77 | """ 78 | path = f"/api/bot/game/{game_id}/resign" 79 | self._r.post(path) 80 | 81 | def accept_challenge(self, challenge_id: str) -> None: 82 | """Accept an incoming challenge. 83 | 84 | :param challenge_id: ID of a challenge 85 | """ 86 | path = f"/api/challenge/{challenge_id}/accept" 87 | self._r.post(path) 88 | 89 | def decline_challenge( 90 | self, challenge_id: str, reason: ChallengeDeclineReason = "generic" 91 | ) -> None: 92 | """Decline an incoming challenge. 93 | 94 | :param challenge_id: ID of a challenge 95 | :param reason: reason for declining challenge 96 | """ 97 | path = f"/api/challenge/{challenge_id}/decline" 98 | payload = {"reason": reason} 99 | self._r.post(path, json=payload) 100 | -------------------------------------------------------------------------------- /berserk/clients/bulk_pairings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import cast 4 | 5 | from ..formats import JSON, JSON_LIST 6 | from ..types.bulk_pairings import BulkPairing 7 | from ..types.common import VariantKey 8 | from .base import BaseClient 9 | 10 | 11 | class BulkPairings(BaseClient): 12 | """Client for bulk pairing related endpoints.""" 13 | 14 | def get_upcoming(self) -> list[BulkPairing]: 15 | """Get a list of upcoming bulk pairings you created. 16 | 17 | Only bulk pairings that are scheduled in the future, or that have a clock start scheduled in the future, are listed. 18 | 19 | Bulk pairings are deleted from the server after the pairings are done and the clocks have started. 20 | 21 | :return: list of your upcoming bulk pairings. 22 | """ 23 | path = "/api/bulk-pairing" 24 | return cast("list[BulkPairing]", self._r.get(path, fmt=JSON_LIST)) 25 | 26 | def create( 27 | self, 28 | token_pairings: list[tuple[str, str]], 29 | clock_limit: int | None = None, 30 | clock_increment: int | None = None, 31 | days: int | None = None, 32 | pair_at: int | None = None, 33 | start_clocks_at: int | None = None, 34 | rated: bool = False, 35 | variant: VariantKey | None = None, 36 | fen: str | None = None, 37 | message: str | None = None, 38 | rules: list[str] | None = None, 39 | ) -> BulkPairing: 40 | """Create a bulk pairing. 41 | 42 | :param players_tokens: players OAuth tokens 43 | :param clock_limit: clock initial time 44 | :param clock_increment: clock increment 45 | :param days: days per turn (for correspondence) 46 | :param pair_at: date at which the games will be created as a milliseconds unix timestamp. Up to 7 days in the future. Defaults to now. 47 | :param start_clocks_at: date at which the clocks will be automatically started as a Unix timestamp in milliseconds. 48 | Up to 7 days in the future. Note that the clocks can start earlier than specified, if players start making moves in the game. 49 | If omitted, the clocks will not start automatically. 50 | :param rated: set to true to make the games rated. defaults to false. 51 | :param variant: variant of the games 52 | :param fen: custom initial position (in FEN). Only allowed if variant is standard, fromPosition, or chess960 (if a valid 960 starting position), and the games cannot be rated. 53 | :param message: message sent to players when their game is created 54 | :param rules: extra game rules 55 | :return: the newly created bulk pairing 56 | """ 57 | path = "/api/bulk-pairing" 58 | payload = { 59 | "players": ",".join(":".join(pair) for pair in token_pairings), 60 | "clock.limit": clock_limit, 61 | "clock.increment": clock_increment, 62 | "days": days, 63 | "pairAt": pair_at, 64 | "startClocksAt": start_clocks_at, 65 | "rated": rated, 66 | "variant": variant, 67 | "fen": fen, 68 | "message": message, 69 | "rules": ",".join(rules) if rules else None, 70 | } 71 | return cast( 72 | BulkPairing, 73 | self._r.post( 74 | path, 75 | payload=payload, 76 | fmt=JSON, 77 | ), 78 | ) 79 | 80 | def start_clocks(self, bulk_pairing_id: str) -> None: 81 | """Immediately start all clocks of the games of the given bulk pairing. 82 | 83 | This overrides the startClocksAt value of an existing bulk pairing. 84 | 85 | If the games have not yet been created (pairAt is in the future) or the clocks 86 | have already started (startClocksAt is in the past), then this does nothing. 87 | 88 | :param bulk_pairing_id: id of the bulk pairing to start clocks of 89 | """ 90 | path = f"/api/bulk-pairing/{bulk_pairing_id}/start-clocks" 91 | self._r.post(path) 92 | 93 | def cancel(self, bulk_pairing_id: str) -> None: 94 | """Cancel and delete a bulk pairing that is scheduled in the future. 95 | 96 | If the games have already been created, then this does nothing. 97 | 98 | :param bulk_pairing_id: id of the bulk pairing to cancel 99 | """ 100 | path = f"/api/bulk-pairing/{bulk_pairing_id}" 101 | self._r.request("DELETE", path) 102 | -------------------------------------------------------------------------------- /berserk/clients/challenges.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Dict, List 4 | from deprecated import deprecated 5 | 6 | from ..types.challenges import ChallengeJson, ChallengeDeclineReason 7 | from ..types.common import Color, VariantKey 8 | from .base import BaseClient 9 | 10 | 11 | class Challenges(BaseClient): 12 | def get_mine(self) -> Dict[str, List[ChallengeJson]]: 13 | """Get all outgoing challenges (created by me) and incoming challenges (targeted at me). 14 | 15 | Requires OAuth2 authorization with challenge:read scope. 16 | 17 | :return: all my outgoing and incoming challenges 18 | """ 19 | path = "/api/challenge" 20 | return self._r.get(path) 21 | 22 | def create( 23 | self, 24 | username: str, 25 | rated: bool, 26 | clock_limit: int | None = None, 27 | clock_increment: int | None = None, 28 | days: int | None = None, 29 | color: Color | None = None, 30 | variant: VariantKey | None = None, 31 | position: str | None = None, 32 | ) -> Dict[str, Any]: 33 | """Challenge another player to a game. 34 | 35 | :param username: username of the player to challenge 36 | :param rated: whether or not the game will be rated 37 | :param clock_limit: clock initial time (in seconds) 38 | :param clock_increment: clock increment (in seconds) 39 | :param days: days per move (for correspondence games; omit clock) 40 | :param color: color of the accepting player 41 | :param variant: game variant to use 42 | :param position: custom initial position in FEN (variant must be standard and 43 | the game cannot be rated) 44 | :return: challenge data 45 | """ 46 | path = f"/api/challenge/{username}" 47 | payload = { 48 | "rated": rated, 49 | "clock.limit": clock_limit, 50 | "clock.increment": clock_increment, 51 | "days": days, 52 | "color": color, 53 | "variant": variant, 54 | "fen": position, 55 | } 56 | return self._r.post(path, json=payload) 57 | 58 | @deprecated(version="0.12.7") 59 | def create_with_accept( 60 | self, 61 | username: str, 62 | rated: bool, 63 | token: str, 64 | clock_limit: int | None = None, 65 | clock_increment: int | None = None, 66 | days: int | None = None, 67 | color: Color | None = None, 68 | variant: VariantKey | None = None, 69 | position: str | None = None, 70 | ) -> Dict[str, Any]: 71 | """Start a game with another player. 72 | 73 | This is just like the regular challenge create except it forces the 74 | opponent to accept. You must provide the OAuth token of the opponent 75 | and it must have the challenge:write scope. 76 | 77 | :param username: username of the opponent 78 | :param rated: whether or not the game will be rated 79 | :param token: opponent's OAuth token 80 | :param clock_limit: clock initial time (in seconds) 81 | :param clock_increment: clock increment (in seconds) 82 | :param days: days per move (for correspondence games; omit clock) 83 | :param color: color of the accepting player 84 | :param variant: game variant to use 85 | :param position: custom initial position in FEN (variant must be standard and 86 | the game cannot be rated) 87 | :return: game data 88 | """ 89 | path = f"/api/challenge/{username}" 90 | payload = { 91 | "rated": rated, 92 | "acceptByToken": token, 93 | "clock.limit": clock_limit, 94 | "clock.increment": clock_increment, 95 | "days": days, 96 | "color": color, 97 | "variant": variant, 98 | "fen": position, 99 | } 100 | return self._r.post(path, json=payload) 101 | 102 | def create_ai( 103 | self, 104 | level: int = 8, 105 | clock_limit: int | None = None, 106 | clock_increment: int | None = None, 107 | days: int | None = None, 108 | color: Color | None = None, 109 | variant: VariantKey | None = None, 110 | position: str | None = None, 111 | ) -> Dict[str, Any]: 112 | """Challenge AI to a game. 113 | 114 | :param level: level of the AI (1 to 8) 115 | :param clock_limit: clock initial time (in seconds) 116 | :param clock_increment: clock increment (in seconds) 117 | :param days: days per move (for correspondence games; omit clock) 118 | :param color: color of the accepting player 119 | :param variant: game variant to use 120 | :param position: use one of the custom initial positions (variant must be 121 | standard and cannot be rated) 122 | :return: information about the created game 123 | """ 124 | path = "/api/challenge/ai" 125 | payload = { 126 | "level": level, 127 | "clock.limit": clock_limit, 128 | "clock.increment": clock_increment, 129 | "days": days, 130 | "color": color, 131 | "variant": variant, 132 | "fen": position, 133 | } 134 | return self._r.post(path, json=payload) 135 | 136 | def create_open( 137 | self, 138 | clock_limit: int | None = None, 139 | clock_increment: int | None = None, 140 | variant: VariantKey | None = None, 141 | position: str | None = None, 142 | rated: bool | None = None, 143 | name: str | None = None, 144 | ) -> Dict[str, Any]: 145 | """Create a challenge that any two players can join. 146 | 147 | :param clock_limit: clock initial time (in seconds) 148 | :param clock_increment: clock increment (in seconds) 149 | :param variant: game variant to use 150 | :param position: custom initial position in FEN (variant must be standard and 151 | the game cannot be rated) 152 | :param rated: Game is rated and impacts players ratings 153 | :param name: Optional name for the challenge, that players will see on 154 | the challenge page. 155 | :return: challenge data 156 | """ 157 | path = "/api/challenge/open" 158 | payload = { 159 | "clock.limit": clock_limit, 160 | "clock.increment": clock_increment, 161 | "variant": variant, 162 | "fen": position, 163 | "rated": rated, 164 | "name": name, 165 | } 166 | return self._r.post(path, json=payload) 167 | 168 | def accept(self, challenge_id: str) -> None: 169 | """Accept an incoming challenge. 170 | 171 | :param challenge_id: id of the challenge to accept 172 | """ 173 | path = f"/api/challenge/{challenge_id}/accept" 174 | self._r.post(path) 175 | 176 | def decline( 177 | self, challenge_id: str, reason: ChallengeDeclineReason = "generic" 178 | ) -> None: 179 | """Decline an incoming challenge. 180 | 181 | :param challenge_id: ID of a challenge 182 | :param reason: reason for declining challenge 183 | """ 184 | path = f"/api/challenge/{challenge_id}/decline" 185 | payload = {"reason": reason} 186 | self._r.post(path, json=payload) 187 | 188 | def cancel(self, challenge_id: str, opponent_token: str | None = None) -> None: 189 | """Cancel an outgoing challenge, or abort the game if challenge was accepted but the game was not yet played. 190 | 191 | Requires OAuth2 authorization with challenge:write, bot:play and board:play scopes. 192 | 193 | :param challenge_id: ID of a challenge 194 | :param opponent_token: if set to the challenge:write token of the opponent, allows game to be canceled 195 | even if both players have moved 196 | """ 197 | path = f"/api/challenge/{challenge_id}/cancel" 198 | params = {"opponentToken": opponent_token} 199 | self._r.post(path=path, params=params) 200 | 201 | def start_clocks( 202 | self, game_id: str, token_player_1: str, token_player_2: str 203 | ) -> None: 204 | """Starts the clocks of a game immediately, even if a player has not yet made a move. 205 | 206 | Requires the OAuth tokens of both players with challenge:write scope. The tokens can be in any order. 207 | 208 | If the clocks have already started, the call will have no effect. 209 | 210 | :param game_id: game ID 211 | :param token_player_1: OAuth token of player 1 with challenge:write scope 212 | :param token_player_2: OAuth token of player 2 with challenge:write scope 213 | """ 214 | path = f"/api/challenge/{game_id}/start-clocks" 215 | params = {"token1": token_player_1, "token2": token_player_2} 216 | self._r.post(path=path, params=params) 217 | 218 | def add_time_to_opponent_clock(self, game_id: str, seconds: int) -> None: 219 | """Add seconds to the opponent's clock. Can be used to create games with time odds. 220 | 221 | Requires OAuth2 authorization with challenge:write scope. 222 | 223 | :param game_id: game ID 224 | :param seconds: number of seconds to add to opponent's clock 225 | """ 226 | path = f"/api/round/{game_id}/add-time/{seconds}" 227 | self._r.post(path) 228 | 229 | def create_tokens_for_multiple_users( 230 | self, usernames: List[str], description: str 231 | ) -> Dict[str, str]: 232 | """This endpoint can only be used by Lichess admins. 233 | 234 | Create and obtain challenge:write tokens for multiple users. 235 | 236 | If a similar token already exists for a user, it is reused. This endpoint is idempotent. 237 | 238 | :param usernames: List of usernames 239 | :param description: user-visible token description 240 | :return: challenge:write tokens of each user 241 | """ 242 | path = "/api/token/admin-challenge" 243 | payload = {"users": ",".join(usernames), "description": description} 244 | return self._r.post(path=path, payload=payload) 245 | -------------------------------------------------------------------------------- /berserk/clients/external_engine.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import List, cast 4 | 5 | from .base import BaseClient 6 | 7 | 8 | class ExternalEngine(BaseClient): 9 | """Client for external engine related endpoints.""" 10 | 11 | def get(self) -> List[ExternalEngine]: 12 | """Lists all external engines that have been registered for the user, and the credentials required to use them. 13 | 14 | Requires OAuth2 authorization. 15 | 16 | :return: info about the external engines 17 | """ 18 | path = "/api/external-engine" 19 | return cast(List[ExternalEngine], self._r.get(path)) 20 | 21 | def get_by_id(self, engine_id: str) -> ExternalEngine: 22 | """Get properties and credentials of an external engine. 23 | 24 | Requires OAuth2 authorization. 25 | 26 | :param engine_id: external engine ID 27 | :return: info about the external engine 28 | """ 29 | path = f"/api/external-engine/{engine_id}" 30 | return cast(ExternalEngine, self._r.get(path)) 31 | 32 | def create( 33 | self, 34 | name: str, 35 | max_threads: int, 36 | max_hash_table_size: int, 37 | default_depth: int, 38 | provider_secret: str, 39 | variants: List[str] | None = None, 40 | provider_data: str | None = None, 41 | ) -> ExternalEngine: 42 | """Registers a new external engine for the user. 43 | 44 | Requires OAuth2 authorization. 45 | 46 | :param name: engine display name 47 | :param max_threads: maximum number of available threads 48 | :param max_hash_table_size: maximum available hash table size, in MiB 49 | :param default_depth: estimated depth of normal search 50 | :param provider_secret: random token that used to wait for analysis requests and provide analysis 51 | :param variants: list of supported chess variants 52 | :param provider_data: arbitrary data that engine provider can use for identification or bookkeeping 53 | :return: info about the external engine 54 | """ 55 | path = "/api/external-engine" 56 | payload = { 57 | "name": name, 58 | "maxThreads": max_threads, 59 | "maxHash": max_hash_table_size, 60 | "defaultDepth": default_depth, 61 | "variants": variants, 62 | "providerSecret": provider_secret, 63 | "providerData": provider_data, 64 | } 65 | return cast(ExternalEngine, self._r.post(path=path, payload=payload)) 66 | 67 | def update( 68 | self, 69 | engine_id: str, 70 | name: str, 71 | max_threads: int, 72 | max_hash_table_size: int, 73 | default_depth: int, 74 | provider_secret: str, 75 | variants: List[str] | None = None, 76 | provider_data: str | None = None, 77 | ) -> ExternalEngine: 78 | """Updates the properties of an external engine. 79 | 80 | Requires OAuth2 authorization. 81 | 82 | :param engine_id: engine ID 83 | :param name: engine display name 84 | :param max_threads: maximum number of available threads 85 | :param max_hash_table_size: maximum available hash table size, in MiB 86 | :param default_depth: estimated depth of normal search 87 | :param provider_secret: random token that used to wait for analysis requests and provide analysis 88 | :param variants: list of supported chess variants 89 | :param provider_data: arbitrary data that engine provider can use for identification or bookkeeping 90 | :return: info about the external engine 91 | """ 92 | path = f"/api/external-engine/{engine_id}" 93 | payload = { 94 | "name": name, 95 | "maxThreads": max_threads, 96 | "maxHash": max_hash_table_size, 97 | "defaultDepth": default_depth, 98 | "variants": variants, 99 | "providerSecret": provider_secret, 100 | "providerData": provider_data, 101 | } 102 | return cast( 103 | ExternalEngine, self._r.request(method="PUT", path=path, payload=payload) 104 | ) 105 | 106 | def delete(self, engine_id: str) -> None: 107 | """Unregisters an external engine. 108 | 109 | Requires OAuth2 authorization. 110 | 111 | :param engine_id: engine ID 112 | """ 113 | path = f"/api/external-engine/{engine_id}" 114 | self._r.request("DELETE", path) 115 | -------------------------------------------------------------------------------- /berserk/clients/fide.py: -------------------------------------------------------------------------------- 1 | from typing import List, cast 2 | from ..types.fide import FidePlayer 3 | from .base import BaseClient 4 | 5 | 6 | class Fide(BaseClient): 7 | def search_players(self, name: str) -> List[FidePlayer]: 8 | """Search for FIDE players by name. 9 | 10 | :param name: name (or partial name) of the player 11 | :return: a list of matching FIDE players 12 | """ 13 | path = "/api/fide/player" 14 | params = {"q": name} 15 | data = self._r.get(path, params=params) 16 | return cast(List[FidePlayer], data) 17 | 18 | def get_player(self, player_id: int) -> FidePlayer: 19 | """Get detailed FIDE player data by ID. 20 | 21 | :param player_id: FIDE player ID 22 | :return: FIDE player data 23 | """ 24 | path = f"/api/fide/player/{player_id}" 25 | data = self._r.get(path) 26 | return cast(FidePlayer, data) 27 | -------------------------------------------------------------------------------- /berserk/clients/messaging.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .base import BaseClient 4 | 5 | 6 | class Messaging(BaseClient): 7 | def send(self, username: str, text: str) -> None: 8 | """Send a private message to another player. 9 | 10 | :param username: the user to send the message to 11 | :param text: the text to send 12 | """ 13 | path = f"/inbox/{username}" 14 | payload = {"text": text} 15 | self._r.post(path, data=payload) 16 | -------------------------------------------------------------------------------- /berserk/clients/oauth.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Dict 4 | 5 | from .. import models 6 | from .base import BaseClient 7 | 8 | 9 | class OAuth(BaseClient): 10 | def test_tokens(self, *tokens: str) -> Dict[str, Any]: 11 | """Test the validity of up to 1000 OAuth tokens. 12 | 13 | Valid OAuth tokens will be returned with their associated user ID and scopes. 14 | Invalid tokens will be returned as null. 15 | 16 | :param tokens: one or more OAuth tokens 17 | :return: info about the tokens 18 | """ 19 | path = "/api/token/test" 20 | payload = ",".join(tokens) 21 | return self._r.post(path, data=payload, converter=models.OAuth.convert) 22 | -------------------------------------------------------------------------------- /berserk/clients/opening_explorer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Iterator, cast 4 | import requests 5 | import logging 6 | 7 | from .base import BaseClient 8 | from ..types import ( 9 | OpeningStatistic, 10 | VariantKey, 11 | Speed, 12 | OpeningExplorerRating, 13 | ) 14 | from ..formats import PGN 15 | from ..types.common import Color 16 | 17 | logger = logging.getLogger("berserk.client.opening_explorer") 18 | 19 | EXPLORER_URL = "https://explorer.lichess.ovh" 20 | 21 | 22 | class OpeningExplorer(BaseClient): 23 | """Openings explorer endpoints.""" 24 | 25 | def __init__(self, session: requests.Session, explorer_url: str | None = None): 26 | super().__init__(session, explorer_url or EXPLORER_URL) 27 | 28 | def get_lichess_games( 29 | self, 30 | variant: VariantKey = "standard", 31 | position: str | None = None, 32 | play: list[str] | None = None, 33 | speeds: list[Speed] | None = None, 34 | ratings: list[OpeningExplorerRating] | None = None, 35 | since: str | None = None, 36 | until: str | None = None, 37 | moves: int | None = None, 38 | top_games: int | None = None, 39 | recent_games: int | None = None, 40 | history: bool | None = None, 41 | ) -> OpeningStatistic: 42 | """Get most played move from a position based on lichess games.""" 43 | 44 | path = "/lichess" 45 | 46 | if top_games and top_games >= 4: 47 | logger.warn( 48 | "The Lichess API caps the top games parameter to 4 (you requested %d)", 49 | top_games, 50 | ) 51 | 52 | if recent_games and recent_games >= 4: 53 | logger.warn( 54 | "The Lichess API caps the recent games parameter to 4 (you requested %d)", 55 | recent_games, 56 | ) 57 | 58 | params = { 59 | "variant": variant, 60 | "fen": position, 61 | "play": ",".join(play) if play else None, 62 | "speeds": ",".join(speeds) if speeds else None, 63 | "ratings": ",".join(ratings) if ratings else None, 64 | "since": since, 65 | "until": until, 66 | "moves": moves, 67 | "topGames": top_games, 68 | "recentGames": recent_games, 69 | "history": history, 70 | } 71 | return cast(OpeningStatistic, self._r.get(path, params=params)) 72 | 73 | def get_masters_games( 74 | self, 75 | position: str | None = None, 76 | play: list[str] | None = None, 77 | since: int | None = None, 78 | until: int | None = None, 79 | moves: int | None = None, 80 | top_games: int | None = None, 81 | ) -> OpeningStatistic: 82 | """Get most played move from a position based on masters games.""" 83 | 84 | path = "/masters" 85 | 86 | params = { 87 | "fen": position, 88 | "play": ",".join(play) if play else None, 89 | "since": since, 90 | "until": until, 91 | "moves": moves, 92 | "topGames": top_games, 93 | } 94 | return cast(OpeningStatistic, self._r.get(path, params=params)) 95 | 96 | def get_player_games( 97 | self, 98 | player: str, 99 | color: Color, 100 | variant: VariantKey | None = None, 101 | position: str | None = None, 102 | play: list[str] | None = None, 103 | speeds: list[Speed] | None = None, 104 | ratings: list[OpeningExplorerRating] | None = None, 105 | since: int | None = None, 106 | until: int | None = None, 107 | moves: int | None = None, 108 | top_games: int | None = None, 109 | recent_games: int | None = None, 110 | history: bool | None = None, 111 | wait_for_indexing: bool = True, 112 | ) -> OpeningStatistic: 113 | """Get most played move from a position based on player games. 114 | 115 | The complete statistics for a player may not immediately be available at the 116 | time of the request. If ``wait_for_indexing`` is true, berserk will wait for 117 | Lichess to complete indexing the games of the player and return the final 118 | result. Otherwise, it will return the first result available, which may be empty, 119 | outdated, or incomplete. 120 | 121 | If you want to get intermediate results during indexing, use ``stream_player_games``. 122 | """ 123 | iterator = self.stream_player_games( 124 | player, 125 | color, 126 | variant, 127 | position, 128 | play, 129 | speeds, 130 | ratings, 131 | since, 132 | until, 133 | moves, 134 | top_games, 135 | recent_games, 136 | history, 137 | ) 138 | result = next(iterator) 139 | if wait_for_indexing: 140 | for result in iterator: 141 | continue 142 | return result 143 | 144 | def stream_player_games( 145 | self, 146 | player: str, 147 | color: Color, 148 | variant: VariantKey | None = None, 149 | position: str | None = None, 150 | play: list[str] | None = None, 151 | speeds: list[Speed] | None = None, 152 | ratings: list[OpeningExplorerRating] | None = None, 153 | since: int | None = None, 154 | until: int | None = None, 155 | moves: int | None = None, 156 | top_games: int | None = None, 157 | recent_games: int | None = None, 158 | history: bool | None = None, 159 | ) -> Iterator[OpeningStatistic]: 160 | """Get most played move from a position based on player games. 161 | 162 | The complete statistics for a player may not immediately be available at the 163 | time of the request. If it is already available, the returned iterator will 164 | only have one element with the result, otherwise it will return the last known 165 | state first and provide updated statistics as games of the player are indexed. 166 | """ 167 | 168 | path = "/player" 169 | 170 | if top_games and top_games >= 4: 171 | logger.warn( 172 | "The Lichess API caps the top games parameter to 4 (you requested %d)", 173 | top_games, 174 | ) 175 | 176 | if recent_games and recent_games >= 4: 177 | logger.warn( 178 | "The Lichess API caps the recent games parameter to 4 (you requested %d)", 179 | recent_games, 180 | ) 181 | 182 | params = { 183 | "player": player, 184 | "color": color, 185 | "variant": variant, 186 | "fen": position, 187 | "play": ",".join(play) if play else None, 188 | "speeds": ",".join(speeds) if speeds else None, 189 | "ratings": ",".join(ratings) if ratings else None, 190 | "since": since, 191 | "until": until, 192 | "moves": moves, 193 | "topGames": top_games, 194 | "recentGames": recent_games, 195 | "history": history, 196 | } 197 | 198 | for response in self._r.get(path, params=params, stream=True): 199 | yield cast(OpeningStatistic, response) 200 | 201 | def get_otb_master_game(self, game_id: str) -> str: 202 | """Get PGN representation of an over-the-board master game. 203 | 204 | :param game_id: game ID 205 | :return: PGN of the game 206 | """ 207 | path = f"/master/pgn/{game_id}" 208 | return self._r.get(path, fmt=PGN) 209 | -------------------------------------------------------------------------------- /berserk/clients/puzzles.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Iterator, Any, Dict, cast 4 | 5 | from .. import models 6 | from ..formats import NDJSON 7 | from .base import BaseClient 8 | from ..types import PuzzleRace 9 | 10 | 11 | class Puzzles(BaseClient): 12 | """Client for puzzle-related endpoints.""" 13 | 14 | def get_daily(self) -> Dict[str, Any]: 15 | """Get the current daily Lichess puzzle. 16 | 17 | :return: current daily puzzle 18 | """ 19 | path = "/api/puzzle/daily" 20 | return self._r.get(path) 21 | 22 | def get(self, id: str) -> Dict[str, Any]: 23 | """Get a puzzle by its id. 24 | 25 | :param id: the id of the puzzle to retrieve 26 | :return: the puzzle 27 | """ 28 | path = f"/api/puzzle/{id}" 29 | return self._r.get(path) 30 | 31 | def get_puzzle_activity( 32 | self, max: int | None = None, before: int | None = None 33 | ) -> Iterator[Dict[str, Any]]: 34 | """Stream puzzle activity history of the authenticated user, starting with the 35 | most recent activity. 36 | 37 | :param max: maximum number of entries to stream. defaults to all activity 38 | :param before: timestamp in milliseconds. only stream activity before this time. 39 | defaults to now. use together with max for pagination 40 | :return: iterator over puzzle activity history 41 | """ 42 | path = "/api/puzzle/activity" 43 | params = {"max": max, "before": before} 44 | yield from self._r.get( 45 | path, 46 | params=params, 47 | fmt=NDJSON, 48 | stream=True, 49 | converter=models.PuzzleActivity.convert, 50 | ) 51 | 52 | def get_puzzle_dashboard(self, days: int = 30) -> Dict[str, Any]: 53 | """Get the puzzle dashboard of the authenticated user. 54 | 55 | :param days: how many days to look back when aggregating puzzle results 56 | :return: the puzzle dashboard 57 | """ 58 | path = f"/api/puzzle/dashboard/{days}" 59 | return self._r.get(path) 60 | 61 | def get_storm_dashboard(self, username: str, days: int = 30) -> Dict[str, Any]: 62 | """Get storm dashboard of a player. Set days to 0 if you're only interested in 63 | the high score. 64 | 65 | :param username: the username of the player to download the dashboard for 66 | :param days: how many days of history to return 67 | :return: the storm dashboard 68 | """ 69 | path = f"/api/storm/dashboard/{username}" 70 | params = {"days": days} 71 | return self._r.get(path, params=params) 72 | 73 | def create_race(self) -> PuzzleRace: 74 | """Create a new private puzzle race. The Lichess user who creates the race must join the race page, 75 | and manually start the race when enough players have joined. 76 | 77 | :return: puzzle race ID and URL 78 | """ 79 | path = "/api/racer" 80 | return cast(PuzzleRace, self._r.post(path)) 81 | -------------------------------------------------------------------------------- /berserk/clients/relations.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Iterator, Any, Dict 4 | 5 | from .. import models 6 | from ..formats import NDJSON 7 | from .base import BaseClient 8 | 9 | 10 | class Relations(BaseClient): 11 | def get_users_followed(self) -> Iterator[Dict[str, Any]]: 12 | """Stream users you are following. 13 | 14 | :return: iterator over the users the given user follows 15 | """ 16 | path = "/api/rel/following" 17 | yield from self._r.get( 18 | path, stream=True, fmt=NDJSON, converter=models.User.convert 19 | ) 20 | 21 | def follow(self, username: str): 22 | """Follow a player. 23 | 24 | :param username: user to follow 25 | """ 26 | path = f"/api/rel/follow/{username}" 27 | self._r.post(path) 28 | 29 | def unfollow(self, username: str): 30 | """Unfollow a player. 31 | 32 | :param username: user to unfollow 33 | """ 34 | path = f"/api/rel/unfollow/{username}" 35 | self._r.post(path) 36 | -------------------------------------------------------------------------------- /berserk/clients/simuls.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Dict 4 | 5 | from .base import BaseClient 6 | 7 | 8 | class Simuls(BaseClient): 9 | """Simultaneous exhibitions - one vs many.""" 10 | 11 | def get(self) -> Dict[str, Any]: 12 | """Get recently finished, ongoing, and upcoming simuls. 13 | 14 | :return: current simuls 15 | """ 16 | path = "/api/simul" 17 | return self._r.get(path) 18 | -------------------------------------------------------------------------------- /berserk/clients/studies.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import cast, List, Iterator 4 | 5 | from ..formats import PGN 6 | from ..types.common import Color, VariantKey 7 | from ..types import ChapterIdName 8 | from .base import BaseClient 9 | 10 | 11 | class Studies(BaseClient): 12 | """Study chess the Lichess way.""" 13 | 14 | def export_chapter(self, study_id: str, chapter_id: str) -> str: 15 | """Export one chapter of a study. 16 | 17 | :return: chapter PGN 18 | """ 19 | path = f"/api/study/{study_id}/{chapter_id}.pgn" 20 | return self._r.get(path, fmt=PGN) 21 | 22 | def export(self, study_id: str) -> Iterator[str]: 23 | """Export all chapters of a study. 24 | 25 | :return: iterator over all chapters as PGN 26 | """ 27 | path = f"/api/study/{study_id}.pgn" 28 | yield from self._r.get(path, fmt=PGN, stream=True) 29 | 30 | def export_by_username(self, username: str) -> Iterator[str]: 31 | """Export all chapters of all studies of a user in PGN format. 32 | 33 | If authenticated, then all public, unlisted, and private studies are included. 34 | 35 | If not, only public (non-unlisted) studies are included. 36 | 37 | return:iterator over all chapters as PGN""" 38 | path = f"/study/by/{username}/export.pgn" 39 | yield from self._r.get(path, fmt=PGN, stream=True) 40 | 41 | def import_pgn( 42 | self, 43 | study_id: str, 44 | chapter_name: str, 45 | pgn: str, 46 | orientation: Color = "white", 47 | variant: VariantKey = "standard", 48 | ) -> List[ChapterIdName]: 49 | """Imports arbitrary PGN into an existing study. 50 | Creates a new chapter in the study. 51 | 52 | If the PGN contains multiple games (separated by 2 or more newlines) then multiple chapters will be created within the study. 53 | 54 | Note that a study can contain at most 64 chapters. 55 | 56 | return: List of the chapters {id, name}""" 57 | # https://lichess.org/api/study/{studyId}/import-pgn 58 | path = f"/api/study/{study_id}/import-pgn" 59 | payload = { 60 | "name": chapter_name, 61 | "pgn": pgn, 62 | "orientation": orientation, 63 | "variant": variant, 64 | } 65 | # The return is of the form: 66 | return cast( 67 | List[ChapterIdName], self._r.post(path, data=payload).get("chapters", []) 68 | ) 69 | -------------------------------------------------------------------------------- /berserk/clients/tablebase.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Dict, Literal 4 | import requests 5 | 6 | from .base import BaseClient 7 | 8 | TABLEBASE_URL = "https://tablebase.lichess.ovh" 9 | 10 | 11 | class Tablebase(BaseClient): 12 | """Client for tablebase related endpoints.""" 13 | 14 | def __init__(self, session: requests.Session, tablebase_url: str | None = None): 15 | super().__init__(session, tablebase_url or TABLEBASE_URL) 16 | 17 | def look_up( 18 | self, 19 | position: str, 20 | variant: Literal["standard", "atomic", "antichess"] = "standard", 21 | ) -> Dict[str, Any]: 22 | """Look up the tablebase result for a position. 23 | 24 | :param position: FEN of the position to look up 25 | :param variant: the variant of the position to look up (supported are standard, 26 | atomic, and antichess) 27 | :return: tablebase information about this position 28 | """ 29 | path = f"/{variant}" 30 | params = {"fen": position} 31 | return self._r.get(path, params=params) 32 | 33 | def standard(self, position: str) -> Dict[str, Any]: 34 | """Look up the tablebase result for a standard chess position. 35 | 36 | :param position: FEN of the position to lookup 37 | :return: tablebase information about this position 38 | """ 39 | return self.look_up(position, "standard") 40 | 41 | def atomic(self, position: str) -> Dict[str, Any]: 42 | """Look up the tablebase result for an atomic chess position. 43 | 44 | :param position: FEN of the position to lookup 45 | :return: tablebase information about this position 46 | """ 47 | return self.look_up(position, "atomic") 48 | 49 | def antichess(self, position: str) -> Dict[str, Any]: 50 | """Look up the tablebase result for an antichess position. 51 | 52 | :param position: FEN of the position to lookup 53 | :return: tablebase information about this position 54 | """ 55 | return self.look_up(position, "antichess") 56 | -------------------------------------------------------------------------------- /berserk/clients/teams.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Iterator, Any, cast, List, Dict 4 | 5 | from .. import models 6 | from ..types import Team, PaginatedTeams 7 | from ..formats import NDJSON, JSON_LIST 8 | from .base import BaseClient 9 | from ..session import Params 10 | 11 | 12 | class Teams(BaseClient): 13 | def get_members(self, team_id: str) -> Iterator[Dict[str, Any]]: 14 | """Get members of a team. 15 | 16 | :param team_id: ID of the team to get members from 17 | :return: users on the given team 18 | """ 19 | path = f"/api/team/{team_id}/users" 20 | yield from self._r.get( 21 | path, fmt=NDJSON, stream=True, converter=models.User.convert 22 | ) 23 | 24 | def join( 25 | self, team_id: str, message: str | None = None, password: str | None = None 26 | ) -> None: 27 | """Join a team. 28 | 29 | :param team_id: ID of the team to join 30 | :param message: optional request message, if the team requires one 31 | :param password: optional password, if the team requires one 32 | """ 33 | path = f"/team/{team_id}/join" 34 | payload = { 35 | "message": message, 36 | "password": password, 37 | } 38 | self._r.post(path, json=payload) 39 | 40 | def leave(self, team_id: str) -> None: 41 | """Leave a team. 42 | 43 | :param team_id: ID of the team to leave 44 | """ 45 | path = f"/team/{team_id}/quit" 46 | self._r.post(path) 47 | 48 | def kick_member(self, team_id: str, user_id: str) -> None: 49 | """Kick a member out of your team. 50 | 51 | :param team_id: ID of the team to kick from 52 | :param user_id: ID of the user to kick from the team 53 | """ 54 | path = f"/api/team/{team_id}/kick/{user_id}" 55 | self._r.post(path) 56 | 57 | def get_join_requests( 58 | self, team_id: str, declined: bool = False 59 | ) -> List[Dict[str, Any]]: 60 | """Get pending join requests of your team 61 | 62 | :param team_id: ID of the team to request the join requests from 63 | :param declined: whether to show declined requests instead of pending ones 64 | :return: list of join requests 65 | """ 66 | path = f"/api/team/{team_id}/requests" 67 | params = {"declined": declined} 68 | return self._r.get(path, params=params, fmt=JSON_LIST) 69 | 70 | def accept_join_request(self, team_id: str, user_id: str) -> None: 71 | """Accept someone's request to join one of your teams 72 | 73 | :param team_id: ID of the team to accept the request for 74 | :param user_id: ID of the user requesting to join 75 | """ 76 | path = f"/api/team/{team_id}/request/{user_id}/accept" 77 | self._r.post(path) 78 | 79 | def decline_join_request(self, team_id: str, user_id: str) -> None: 80 | """Decline someone's request to join one of your teams 81 | 82 | :param team_id: ID of the team to decline the request for 83 | :param user_id: ID of the user requesting to join 84 | """ 85 | path = f"/api/team/{team_id}/request/{user_id}/decline" 86 | self._r.post(path) 87 | 88 | def get_team(self, team_id: str) -> Team: 89 | """Get the information about the team 90 | 91 | :return: the information about the team 92 | """ 93 | path = f"/api/team/{team_id}" 94 | return cast(Team, self._r.get(path)) 95 | 96 | def teams_of_player(self, username: str) -> List[Team]: 97 | """Get all the teams a player is a member of 98 | 99 | :return: list of teams the user is a member of 100 | """ 101 | path = f"/api/team/of/{username}" 102 | return cast(List[Team], self._r.get(path)) 103 | 104 | def get_popular(self, page: int = 1) -> PaginatedTeams: 105 | """Get the most popular teams 106 | 107 | :param page: the page number that needs to be returned (Optional) 108 | :return: A paginated list of the most popular teams. 109 | """ 110 | path = "/api/team/all" 111 | params = {"page": page} 112 | return cast(PaginatedTeams, self._r.get(path, params=params)) 113 | 114 | def search(self, text: str, page: int = 1) -> PaginatedTeams: 115 | """Search for teams 116 | 117 | :param text: the query text to search for 118 | :param page: the page number that needs to be returned (Optional) 119 | :return: The paginated list of teams. 120 | """ 121 | path = "/api/team/search" 122 | params: Params = {"text": text, "page": page} 123 | return cast(PaginatedTeams, self._r.get(path, params=params)) 124 | 125 | def message_all_members(self, team_id: str, message: str) -> None: 126 | """Send a private message to all members of a team. You must be the team leader with the "Messages" permission. 127 | 128 | :param team_id: team ID 129 | :param message: message to send all team members 130 | """ 131 | path = f"/team/{team_id}/pm-all" 132 | payload = {"message": message} 133 | self._r.post(path=path, payload=payload) 134 | -------------------------------------------------------------------------------- /berserk/clients/tv.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Iterator, Dict, List, cast 4 | 5 | from .. import models 6 | from ..formats import NDJSON_LIST, PGN 7 | from ..types import TVFeed 8 | from .base import FmtClient 9 | 10 | 11 | class TV(FmtClient): 12 | """Client for TV related endpoints.""" 13 | 14 | def get_current_games(self) -> Dict[str, Any]: 15 | """Get basic information about the current TV games being played. 16 | 17 | :return: best ongoing games in each speed and variant 18 | """ 19 | path = "/api/tv/channels" 20 | return self._r.get(path) 21 | 22 | def stream_current_game(self) -> Iterator[TVFeed]: 23 | """Streams the current TV game. 24 | 25 | :return: positions and moves of the current TV game 26 | """ 27 | path = "/api/tv/feed" 28 | for response in self._r.get(path, stream=True): 29 | yield cast(TVFeed, response) 30 | 31 | def stream_current_game_of_channel(self, channel: str) -> Iterator[TVFeed]: 32 | """Streams the current TV game of a channel. 33 | 34 | :param channel: the TV channel to stream. 35 | :return: positions and moves of the channels current TV game 36 | """ 37 | path = f"/api/tv/{channel}/feed" 38 | for response in self._r.get(path, stream=True): 39 | yield cast(TVFeed, response) 40 | 41 | def get_best_ongoing( 42 | self, 43 | channel: str, 44 | as_pgn: bool | None = None, 45 | count: int | None = None, 46 | moves: bool = True, 47 | pgnInJson: bool = False, 48 | tags: bool = True, 49 | clocks: bool = False, 50 | opening: bool = False, 51 | ) -> str | List[Dict[str, Any]]: 52 | """Get a list of ongoing games for a given TV channel in PGN or NDJSON. 53 | 54 | :param channel: the name of the TV channel in camel case 55 | :param as_pgn: whether to return the game in PGN format 56 | :param count: the number of games to fetch [1..30] 57 | :param moves: whether to include the PGN moves 58 | :param pgnInJson: include the full PGN within JSON response 59 | :param tags: whether to include the PGN tags 60 | :param clocks: whether to include clock comments in the PGN moves 61 | :param opening: whether to include the opening name 62 | :return: the ongoing games of the given TV channel in PGN or NDJSON 63 | """ 64 | path = f"/api/tv/{channel}" 65 | params = { 66 | "nb": count, 67 | "moves": moves, 68 | "pgnInJson": pgnInJson, 69 | "tags": tags, 70 | "clocks": clocks, 71 | "opening": opening, 72 | } 73 | if self._use_pgn(as_pgn): 74 | return self._r.get(path, params=params, fmt=PGN) 75 | else: 76 | return self._r.get( 77 | path, params=params, fmt=NDJSON_LIST, converter=models.TV.convert 78 | ) 79 | -------------------------------------------------------------------------------- /berserk/clients/users.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Iterator, Dict, List, Any, cast 4 | from deprecated import deprecated 5 | 6 | from .. import models 7 | from .base import BaseClient 8 | from ..formats import JSON_LIST, LIJSON, NDJSON 9 | from ..types.common import OnlineLightUser, PerfType 10 | from ..session import Params 11 | 12 | 13 | class Users(BaseClient): 14 | """Client for user-related endpoints.""" 15 | 16 | @deprecated(reason="Use Puzzles.get_puzzle_activity instead", version="0.12.6") 17 | def get_puzzle_activity(self, max: int | None = None) -> Iterator[Dict[str, Any]]: 18 | """Stream puzzle activity history of the authenticated user, starting with the 19 | most recent activity. 20 | 21 | :param max: maximum number of entries to stream. defaults to all activity 22 | :return: stream of puzzle activity history 23 | """ 24 | path = "/api/puzzle/activity" 25 | params = {"max": max} 26 | yield from self._r.get( 27 | path, 28 | params=params, 29 | fmt=NDJSON, 30 | stream=True, 31 | converter=models.PuzzleActivity.convert, 32 | ) 33 | 34 | def get_realtime_statuses( 35 | self, *user_ids: str, with_game_ids: bool = False 36 | ) -> List[Dict[str, Any]]: 37 | """Get the online, playing, and streaming statuses of players. 38 | 39 | Only id and name fields are returned for offline users. 40 | 41 | :param user_ids: one or more user IDs (names) 42 | :param with_game_ids: whether or not to return IDs of the games being played 43 | :return: statuses of given players 44 | """ 45 | path = "/api/users/status" 46 | params: Params = {"ids": ",".join(user_ids), "withGameIds": with_game_ids} 47 | 48 | return self._r.get(path, fmt=JSON_LIST, params=params) 49 | 50 | def get_all_top_10(self) -> Dict[str, Any]: 51 | """Get the top 10 players for each speed and variant. 52 | 53 | :return: top 10 players in each speed and variant 54 | """ 55 | path = "/api/player" 56 | return self._r.get(path, fmt=LIJSON) 57 | 58 | def get_by_autocomplete( 59 | self, 60 | partial_username: str, 61 | only_followed_players: bool = False, 62 | as_object: bool = False, 63 | ) -> List[str] | List[OnlineLightUser]: 64 | """Provides autocompletion options for an incomplete username. 65 | 66 | :param partial_username: the beginning of a username, must provide >= 3 characters 67 | :param only_followed_players: whether to return matching followed players only, if any exist 68 | :param as_object: if false, returns an array of usernames else, returns an object with matching users 69 | :return: followed players matching term if any, else returns other players. Requires OAuth. 70 | """ 71 | path = "/api/player/autocomplete" 72 | params: Params = { 73 | "term": partial_username, 74 | "object": as_object, 75 | "friend": only_followed_players, 76 | } 77 | response = self._r.get(path, fmt=LIJSON, params=params) 78 | if as_object: 79 | return cast(List[OnlineLightUser], response.get("result", [])) 80 | return cast(List[str], response) 81 | 82 | def get_leaderboard(self, perf_type: PerfType, count: int = 10): 83 | """Get the leaderboard for one speed or variant. 84 | 85 | :param perf_type: speed or variant 86 | :param count: number of players to get 87 | :return: top players for one speed or variant 88 | """ 89 | path = f"/api/player/top/{count}/{perf_type}" 90 | return self._r.get(path, fmt=LIJSON)["users"] 91 | 92 | def get_public_data(self, username: str) -> Dict[str, Any]: 93 | """Get the public data for a user. 94 | 95 | :return: public data available for the given user 96 | """ 97 | path = f"/api/user/{username}" 98 | return self._r.get(path, converter=models.User.convert) 99 | 100 | def get_activity_feed(self, username: str) -> List[Dict[str, Any]]: 101 | """Get the activity feed of a user. 102 | 103 | :return: activity feed of the given user 104 | """ 105 | path = f"/api/user/{username}/activity" 106 | return self._r.get(path, fmt=JSON_LIST, converter=models.Activity.convert) 107 | 108 | def get_by_id(self, *usernames: str) -> List[Dict[str, Any]]: 109 | """Get multiple users by their IDs. 110 | 111 | :param usernames: one or more usernames 112 | :return: user data for the given usernames 113 | """ 114 | path = "/api/users" 115 | return self._r.post( 116 | path, data=",".join(usernames), fmt=JSON_LIST, converter=models.User.convert 117 | ) 118 | 119 | def get_live_streamers(self) -> List[Dict[str, Any]]: 120 | """Get basic information about currently streaming users. 121 | 122 | :return: users currently streaming a game 123 | """ 124 | path = "/api/streamer/live" 125 | return self._r.get(path, fmt=JSON_LIST) 126 | 127 | def get_rating_history(self, username: str) -> List[Dict[str, Any]]: 128 | """Get the rating history of a user. 129 | 130 | :return: rating history for all game types 131 | """ 132 | path = f"/api/user/{username}/rating-history" 133 | return self._r.get(path, fmt=JSON_LIST, converter=models.RatingHistory.convert) 134 | 135 | def get_crosstable( 136 | self, user1: str, user2: str, matchup: bool = False 137 | ) -> List[Dict[str, Any]]: 138 | """Get total number of games, and current score, of any two users. 139 | 140 | :param user1: first user to compare 141 | :param user2: second user to compare 142 | :param matchup: Whether to get the current match data, if any 143 | """ 144 | params = {"matchup": matchup} 145 | path = f"/api/crosstable/{user1}/{user2}" 146 | return self._r.get( 147 | path, params=params, fmt=JSON_LIST, converter=models.User.convert 148 | ) 149 | 150 | def get_user_performance(self, username: str, perf: str) -> List[Dict[str, Any]]: 151 | """Read performance statistics of a user, for a single performance. 152 | 153 | Similar to the performance pages on the website 154 | """ 155 | path = f"/api/user/{username}/perf/{perf}" 156 | return self._r.get(path, fmt=JSON_LIST, converter=models.User.convert) 157 | -------------------------------------------------------------------------------- /berserk/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | from requests import Response 3 | 4 | 5 | def get_message(e: Exception) -> str: 6 | return e.args[0] if e.args else "" 7 | 8 | 9 | def set_message(e: Exception, value: str) -> None: 10 | args = list(e.args) 11 | if args: 12 | args[0] = value 13 | else: 14 | args.append(value) 15 | e.args = tuple(args) 16 | 17 | 18 | class BerserkError(Exception): 19 | message = property(get_message, set_message) 20 | 21 | 22 | class ApiError(BerserkError): 23 | def __init__(self, error: Exception): 24 | super().__init__(get_message(error)) 25 | self.__cause__ = self.error = error 26 | 27 | 28 | class ResponseError(ApiError): 29 | """Response that indicates an error.""" 30 | 31 | # sentinal object for when None is a valid result 32 | __UNDEFINED = object() 33 | 34 | def __init__(self, response: Response): 35 | error = ResponseError._catch_exception(response) 36 | super().__init__(cast(Exception, error)) 37 | self._cause = ResponseError.__UNDEFINED 38 | self.response = response 39 | base_message = f"HTTP {self.status_code}: {self.reason}" 40 | if self.cause: 41 | self.message = f"{base_message}: {self.cause}" 42 | 43 | @property 44 | def status_code(self): 45 | """HTTP status code of the response.""" 46 | return self.response.status_code 47 | 48 | @property 49 | def reason(self): 50 | """HTTP status text of the response.""" 51 | return self.response.reason 52 | 53 | @property 54 | def cause(self): 55 | if self._cause is ResponseError.__UNDEFINED: 56 | try: 57 | self._cause = self.response.json() 58 | except Exception: 59 | self._cause = None 60 | return self._cause 61 | 62 | @staticmethod 63 | def _catch_exception(response: Response): 64 | try: 65 | response.raise_for_status() 66 | except Exception as e: 67 | return e 68 | -------------------------------------------------------------------------------- /berserk/formats.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import json 3 | from typing import Any, Callable, Dict, Generic, Iterator, List, Type, TypeVar, cast 4 | 5 | import ndjson # type: ignore 6 | from requests import Response 7 | 8 | from . import utils 9 | 10 | T = TypeVar("T") 11 | 12 | 13 | class FormatHandler(Generic[T]): 14 | """Provide request headers and parse responses for a particular format. 15 | 16 | Instances of this class should override the :meth:`parse_stream` and 17 | :meth:`parse` methods to support handling both streaming and non-streaming 18 | responses. 19 | 20 | :param mime_type: the MIME type for the format 21 | """ 22 | 23 | def __init__(self, mime_type: str): 24 | self.mime_type = mime_type 25 | self.headers = {"Accept": mime_type} 26 | 27 | def handle( 28 | self, 29 | response: Response, 30 | is_stream: bool, 31 | converter: Callable[[T], T] = utils.noop, 32 | ) -> T | Iterator[T]: 33 | """Handle the response by returning the data. 34 | 35 | :param response: raw response 36 | :type response: :class:`requests.Response` 37 | :param bool is_stream: ``True`` if the response is a stream 38 | :param func converter: function to handle field conversions 39 | :return: either all response data or an iterator of response data 40 | """ 41 | if is_stream: 42 | return map(converter, iter(self.parse_stream(response))) 43 | else: 44 | return converter(self.parse(response)) 45 | 46 | def parse(self, response: Response) -> T: 47 | """Parse all data from a response. 48 | 49 | :param response: raw response 50 | :type response: :class:`requests.Response` 51 | :return: response data 52 | """ 53 | raise NotImplementedError 54 | 55 | def parse_stream(self, response: Response) -> Iterator[T]: 56 | """Yield the parsed data from a stream response. 57 | 58 | :param response: raw response 59 | :type response: :class:`requests.Response` 60 | :return: iterator over the response data 61 | """ 62 | raise NotImplementedError 63 | 64 | 65 | class JsonHandler(FormatHandler[Dict[str, Any]]): 66 | """Handle JSON data. 67 | 68 | :param str mime_type: the MIME type for the format 69 | :param decoder: the decoder to use for the JSON format 70 | :type decoder: :class:`json.JSONDecoder` 71 | """ 72 | 73 | def __init__( 74 | self, mime_type: str, decoder: Type[json.JSONDecoder] = json.JSONDecoder 75 | ): 76 | super().__init__(mime_type=mime_type) 77 | self.decoder = decoder 78 | 79 | def parse(self, response: Response) -> Dict[str, Any]: 80 | """Parse all JSON data from a response. 81 | 82 | :param response: raw response 83 | :type response: :class:`requests.Response` 84 | :return: response data 85 | :rtype: JSON 86 | """ 87 | return response.json(cls=self.decoder) 88 | 89 | def parse_stream(self, response: Response) -> Iterator[Dict[str, Any]]: 90 | """Yield the parsed data from a stream response. 91 | 92 | :param response: raw response 93 | :type response: :class:`requests.Response` 94 | :return: iterator over multiple JSON objects 95 | """ 96 | for line in response.iter_lines(): 97 | if line: 98 | decoded_line = line.decode("utf-8") 99 | yield json.loads(decoded_line) 100 | 101 | 102 | class PgnHandler(FormatHandler[str]): 103 | """Handle PGN data.""" 104 | 105 | def __init__(self): 106 | super().__init__(mime_type="application/x-chess-pgn") 107 | 108 | def parse(self, response: Response) -> str: 109 | """Parse all text data from a response. 110 | 111 | :param response: raw response 112 | :type response: :class:`requests.Response` 113 | :return: response text 114 | :rtype: str 115 | """ 116 | return response.text 117 | 118 | def parse_stream(self, response: Response) -> Iterator[str]: 119 | """Yield the parsed PGN games from a stream response. 120 | 121 | :param response: raw response 122 | :type response: :class:`requests.Response` 123 | :return: iterator over multiple PGN texts 124 | """ 125 | lines: List[str] = [] 126 | last_line = True 127 | for line in response.iter_lines(): 128 | decoded_line = line.decode("utf-8") 129 | if last_line or decoded_line: 130 | lines.append(decoded_line) 131 | else: 132 | yield "\n".join(lines).strip() 133 | lines = [] 134 | last_line = decoded_line 135 | 136 | if lines: 137 | yield "\n".join(lines).strip() 138 | 139 | 140 | class TextHandler(FormatHandler[str]): 141 | def __init__(self): 142 | super().__init__(mime_type="text/plain") 143 | 144 | def parse(self, response: Response) -> str: 145 | return response.text 146 | 147 | def parse_stream(self, response: Response) -> Iterator[str]: 148 | yield from response.iter_lines() 149 | 150 | 151 | #: Basic text 152 | TEXT = TextHandler() 153 | 154 | #: Handles vanilla JSON 155 | JSON = JsonHandler(mime_type="application/json") 156 | 157 | #: Handle vanilla JSON where the response is a top-level list (this is only needed bc of type checking) 158 | JSON_LIST = cast(FormatHandler[List[Dict[str, Any]]], JSON) 159 | 160 | #: Handles oddball LiChess JSON (normal JSON, crazy MIME type) 161 | LIJSON = JsonHandler(mime_type="application/vnd.lichess.v3+json") 162 | 163 | #: Handles newline-delimited JSON 164 | NDJSON = JsonHandler(mime_type="application/x-ndjson", decoder=ndjson.Decoder) # type: ignore 165 | 166 | #: Handles newline-delimited JSON where the response is a top-level list (this is only needed bc of type checking, if not streaming NJDSON, the result is always a list) 167 | NDJSON_LIST = cast(FormatHandler[List[Dict[str, Any]]], NDJSON) 168 | 169 | #: Handles PGN 170 | PGN = PgnHandler() 171 | -------------------------------------------------------------------------------- /berserk/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Dict, List, Tuple, TypeVar, overload 4 | 5 | from . import utils 6 | 7 | T = TypeVar("T") 8 | 9 | 10 | class model(type): 11 | @property 12 | def conversions(cls): 13 | return {k: v for k, v in vars(cls).items() if not k.startswith("_")} 14 | 15 | 16 | class Model(metaclass=model): 17 | @overload 18 | @classmethod 19 | def convert(cls, data: Dict[str, T]) -> Dict[str, T]: 20 | ... 21 | 22 | @overload 23 | @classmethod 24 | def convert( 25 | cls, data: List[Dict[str, T]] | Tuple[Dict[str, T], ...] 26 | ) -> List[Dict[str, T]]: 27 | ... 28 | 29 | @classmethod 30 | def convert( 31 | cls, data: Dict[str, T] | List[Dict[str, T]] | Tuple[Dict[str, T], ...] 32 | ) -> Dict[str, T] | List[Dict[str, T]]: 33 | if isinstance(data, (list, tuple)): 34 | return [cls.convert_one(v) for v in data] 35 | return cls.convert_one(data) 36 | 37 | @classmethod 38 | def convert_one(cls, data: Dict[str, T]) -> Dict[str, T]: 39 | for k in set(data) & set(cls.conversions): 40 | data[k] = cls.conversions[k](data[k]) 41 | return data 42 | 43 | @classmethod 44 | def convert_values(cls, data: Dict[str, Dict[str, T]]) -> Dict[str, Dict[str, T]]: 45 | for k in data: 46 | data[k] = cls.convert(data[k]) 47 | return data 48 | 49 | 50 | class Account(Model): 51 | createdAt = utils.datetime_from_millis 52 | seenAt = utils.datetime_from_millis 53 | 54 | 55 | class User(Model): 56 | createdAt = utils.datetime_from_millis 57 | seenAt = utils.datetime_from_millis 58 | 59 | 60 | class Activity(Model): 61 | interval = utils.inner(utils.datetime_from_millis, "start", "end") 62 | 63 | 64 | class Game(Model): 65 | createdAt = utils.datetime_from_millis 66 | lastMoveAt = utils.datetime_from_millis 67 | 68 | 69 | class GameState(Model): 70 | createdAt = utils.datetime_from_millis 71 | wtime = utils.timedelta_from_millis 72 | btime = utils.timedelta_from_millis 73 | winc = utils.timedelta_from_millis 74 | binc = utils.timedelta_from_millis 75 | 76 | 77 | class Tournament(Model): 78 | startsAt = utils.datetime_from_str_or_millis 79 | 80 | 81 | class Broadcast(Model): 82 | broadcast = utils.inner(utils.datetime_from_millis, "startedAt", "startsAt") 83 | 84 | 85 | class RatingHistory(Model): 86 | points = utils.listing(utils.rating_history) 87 | 88 | 89 | class PuzzleActivity(Model): 90 | date = utils.datetime_from_millis 91 | 92 | 93 | class OAuth(Model): 94 | expires = utils.datetime_from_millis 95 | 96 | 97 | class TV(Model): 98 | createdAt = utils.datetime_from_millis 99 | lastMoveAt = utils.datetime_from_millis 100 | 101 | 102 | class FidePlayer(Model): 103 | pass 104 | -------------------------------------------------------------------------------- /berserk/py.typed: -------------------------------------------------------------------------------- 1 | Marker 2 | -------------------------------------------------------------------------------- /berserk/session.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import logging 3 | from typing import ( 4 | Any, 5 | Callable, 6 | Dict, 7 | Generic, 8 | Iterator, 9 | Literal, 10 | Mapping, 11 | TypeVar, 12 | Union, 13 | overload, 14 | ) 15 | from urllib.parse import urljoin 16 | 17 | import requests 18 | 19 | from berserk.formats import FormatHandler 20 | 21 | from . import exceptions, utils 22 | 23 | LOG = logging.getLogger(__name__) 24 | 25 | T = TypeVar("T") 26 | U = TypeVar("U") 27 | 28 | Params = Mapping[str, Union[int, bool, str, None]] 29 | Data = Union[str, Params] 30 | Converter = Callable[[T], T] 31 | 32 | 33 | class Requestor(Generic[T]): 34 | """Encapsulates the logic for making a request. 35 | 36 | :param session: the authenticated session object 37 | :param str base_url: the base URL for requests 38 | :param default_fmt: default format handler to use 39 | """ 40 | 41 | def __init__( 42 | self, session: requests.Session, base_url: str, default_fmt: FormatHandler[T] 43 | ): 44 | self.session = session 45 | self.base_url = base_url 46 | self.default_fmt = default_fmt 47 | 48 | def request( 49 | self, 50 | method: str, 51 | path: str, 52 | *, 53 | stream: bool = False, 54 | params: Params | None = None, 55 | data: Data | None = None, 56 | json: Dict[str, Any] | None = None, 57 | fmt: FormatHandler[Any] | None = None, 58 | converter: Converter[Any] = utils.noop, 59 | **kwargs: Any, 60 | ) -> Any | Iterator[Any]: 61 | """Make a request for a resource in a paticular format. 62 | 63 | :param method: HTTP verb 64 | :param path: the URL suffix 65 | :param stream: whether to stream the response 66 | :param params: request query parametrs 67 | :param data: request body data (url-encoded) 68 | :param json: request body json 69 | :param fmt: the format handler 70 | :param converter: function to handle field conversions 71 | :return: response 72 | :raises berserk.exceptions.ResponseError: if the status is >=400 73 | """ 74 | fmt = fmt or self.default_fmt 75 | url = urljoin(self.base_url, path) 76 | 77 | LOG.debug( 78 | "%s %s %s params=%s data=%s json=%s", 79 | "stream" if stream else "request", 80 | method, 81 | url, 82 | params, 83 | data, 84 | json, 85 | ) 86 | try: 87 | response = self.session.request( 88 | method, 89 | url, 90 | stream=stream, 91 | params=params, 92 | headers=fmt.headers, 93 | data=data, 94 | json=json, 95 | **kwargs, 96 | ) 97 | except requests.RequestException as e: 98 | raise exceptions.ApiError(e) 99 | if not response.ok: 100 | raise exceptions.ResponseError(response) 101 | 102 | return fmt.handle(response, is_stream=stream, converter=converter) 103 | 104 | @overload 105 | def get( 106 | self, 107 | path: str, 108 | *, 109 | stream: Literal[False] = False, 110 | params: Params | None = None, 111 | data: Data | None = None, 112 | json: Dict[str, Any] | None = None, 113 | fmt: FormatHandler[U], 114 | converter: Converter[U] = utils.noop, 115 | **kwargs: Any, 116 | ) -> U: 117 | ... 118 | 119 | @overload 120 | def get( 121 | self, 122 | path: str, 123 | *, 124 | stream: Literal[True], 125 | params: Params | None = None, 126 | data: Data | None = None, 127 | json: Dict[str, Any] | None = None, 128 | fmt: FormatHandler[U], 129 | converter: Converter[U] = utils.noop, 130 | **kwargs: Any, 131 | ) -> Iterator[U]: 132 | ... 133 | 134 | @overload 135 | def get( 136 | self, 137 | path: str, 138 | *, 139 | stream: Literal[False] = False, 140 | params: Params | None = None, 141 | data: Data | None = None, 142 | json: Dict[str, Any] | None = None, 143 | fmt: None = None, 144 | converter: Converter[T] = utils.noop, 145 | **kwargs: Any, 146 | ) -> T: 147 | ... 148 | 149 | @overload 150 | def get( 151 | self, 152 | path: str, 153 | *, 154 | stream: Literal[True], 155 | params: Params | None = None, 156 | data: Data | None = None, 157 | json: Dict[str, Any] | None = None, 158 | fmt: None = None, 159 | converter: Converter[T] = utils.noop, 160 | **kwargs: Any, 161 | ) -> Iterator[T]: 162 | ... 163 | 164 | def get( 165 | self, 166 | path: str, 167 | *, 168 | stream: Literal[True] | Literal[False] = False, 169 | params: Params | None = None, 170 | data: Data | None = None, 171 | json: Dict[str, Any] | None = None, 172 | fmt: FormatHandler[Any] | None = None, 173 | converter: Any = utils.noop, 174 | **kwargs: Any, 175 | ) -> Any | Iterator[Any]: 176 | """Convenience method to make a GET request.""" 177 | return self.request( 178 | "GET", 179 | path, 180 | params=params, 181 | stream=stream, 182 | fmt=fmt, 183 | converter=converter, 184 | data=data, 185 | json=json, 186 | **kwargs, 187 | ) 188 | 189 | @overload 190 | def post( 191 | self, 192 | path: str, 193 | *, 194 | stream: Literal[False] = False, 195 | params: Params | None = None, 196 | data: Data | None = None, 197 | json: Dict[str, Any] | None = None, 198 | fmt: FormatHandler[U], 199 | converter: Converter[U] = utils.noop, 200 | **kwargs: Any, 201 | ) -> U: 202 | ... 203 | 204 | @overload 205 | def post( 206 | self, 207 | path: str, 208 | *, 209 | stream: Literal[True], 210 | params: Params | None = None, 211 | data: Data | None = None, 212 | json: Dict[str, Any] | None = None, 213 | fmt: FormatHandler[U], 214 | converter: Converter[U] = utils.noop, 215 | **kwargs: Any, 216 | ) -> Iterator[U]: 217 | ... 218 | 219 | @overload 220 | def post( 221 | self, 222 | path: str, 223 | *, 224 | stream: Literal[False] = False, 225 | params: Params | None = None, 226 | data: Data | None = None, 227 | json: Dict[str, Any] | None = None, 228 | fmt: None = None, 229 | converter: Converter[T] = utils.noop, 230 | **kwargs: Any, 231 | ) -> T: 232 | ... 233 | 234 | @overload 235 | def post( 236 | self, 237 | path: str, 238 | *, 239 | stream: Literal[True], 240 | params: Params | None = None, 241 | data: Data | None = None, 242 | json: Dict[str, Any] | None = None, 243 | fmt: None = None, 244 | converter: Converter[T] = utils.noop, 245 | **kwargs: Any, 246 | ) -> Iterator[T]: 247 | ... 248 | 249 | def post( 250 | self, 251 | path: str, 252 | *, 253 | stream: Literal[True] | Literal[False] = False, 254 | params: Params | None = None, 255 | data: Data | None = None, 256 | json: Dict[str, Any] | None = None, 257 | fmt: FormatHandler[Any] | None = None, 258 | converter: Any = utils.noop, 259 | **kwargs: Any, 260 | ) -> Any | Iterator[Any]: 261 | """Convenience method to make a POST request.""" 262 | return self.request( 263 | "POST", 264 | path, 265 | params=params, 266 | stream=stream, 267 | fmt=fmt, 268 | converter=converter, 269 | data=data, 270 | json=json, 271 | **kwargs, 272 | ) 273 | 274 | 275 | class TokenSession(requests.Session): 276 | """Session capable of personal API token authentication. 277 | 278 | :param token: personal API token 279 | """ 280 | 281 | def __init__(self, token: str): 282 | super().__init__() 283 | self.token = token 284 | self.headers = {"Authorization": f"Bearer {token}"} 285 | -------------------------------------------------------------------------------- /berserk/types/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .account import AccountInformation, Perf, Preferences, Profile, StreamerInfo 4 | from .broadcast import BroadcastPlayer 5 | from .bulk_pairings import BulkPairing, BulkPairingGame 6 | from .challenges import ChallengeJson 7 | from .common import ClockConfig, ExternalEngine, LightUser, OnlineLightUser, VariantKey 8 | from .fide import FidePlayer 9 | from .puzzles import PuzzleRace 10 | from .opening_explorer import ( 11 | OpeningExplorerRating, 12 | OpeningStatistic, 13 | Speed, 14 | ) 15 | from .studies import ChapterIdName 16 | from .team import PaginatedTeams, Team 17 | from .tournaments import ArenaResult, CurrentTournaments, SwissResult, SwissInfo 18 | from .tv import TVFeed 19 | 20 | __all__ = [ 21 | "AccountInformation", 22 | "ArenaResult", 23 | "BroadcastPlayer", 24 | "BulkPairing", 25 | "BulkPairingGame", 26 | "ChallengeJson", 27 | "ChapterIdName", 28 | "ClockConfig", 29 | "CurrentTournaments", 30 | "ExternalEngine", 31 | "FidePlayer", 32 | "LightUser", 33 | "OnlineLightUser", 34 | "OpeningExplorerRating", 35 | "OpeningStatistic", 36 | "PaginatedTeams", 37 | "Perf", 38 | "Preferences", 39 | "Profile", 40 | "PuzzleRace", 41 | "Speed", 42 | "StreamerInfo", 43 | "SwissInfo", 44 | "SwissResult", 45 | "Team", 46 | "VariantKey", 47 | "TVFeed", 48 | ] 49 | -------------------------------------------------------------------------------- /berserk/types/account.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | 5 | from typing_extensions import TypedDict, TypeAlias, Literal 6 | 7 | 8 | class Perf(TypedDict): 9 | games: int 10 | rating: int 11 | rd: int 12 | prog: int 13 | prov: bool 14 | 15 | 16 | class Profile(TypedDict): 17 | """Public profile of an account.""" 18 | 19 | flag: str 20 | location: str 21 | bio: str 22 | realName: str 23 | fideRating: int 24 | uscfRating: int 25 | ecfRating: int 26 | cfcRating: int 27 | rcfRating: int 28 | dsbRating: int 29 | links: str 30 | 31 | 32 | class PlayTime(TypedDict): 33 | total: int 34 | tv: int 35 | 36 | 37 | class StreamerInfo(TypedDict): 38 | """Information about the streamer on a specific platform.""" 39 | 40 | channel: str 41 | 42 | 43 | class AccountInformation(TypedDict): 44 | """Information about an account.""" 45 | 46 | id: str 47 | username: str 48 | perfs: dict[str, Perf] 49 | flair: str 50 | createdAt: datetime 51 | disabled: bool 52 | tosViolation: bool 53 | profile: Profile 54 | seenAt: datetime 55 | patron: bool 56 | verified: bool 57 | playTime: PlayTime 58 | title: str 59 | url: str 60 | playing: str 61 | count: dict[str, int] 62 | streaming: bool 63 | streamer: dict[str, StreamerInfo] 64 | followable: bool 65 | following: bool 66 | blocking: bool 67 | 68 | 69 | SoundSet: TypeAlias = Literal[ 70 | "silent", 71 | "standard", 72 | "piano", 73 | "nes", 74 | "sfx", 75 | "futuristic", 76 | "robot", 77 | "music", 78 | "speech", 79 | ] 80 | 81 | 82 | class UserPreferences(TypedDict, total=False): 83 | dark: bool 84 | transp: bool 85 | bgImg: str 86 | is3d: bool 87 | theme: str 88 | pieceSet: str 89 | theme3d: str 90 | pieceSet3d: str 91 | soundSet: SoundSet 92 | blindfold: int 93 | autoQueen: int 94 | autoThreefold: int 95 | takeback: int 96 | moretime: int 97 | clockTenths: int 98 | clockBar: bool 99 | clockSound: bool 100 | premove: bool 101 | animation: int 102 | captured: bool 103 | follow: bool 104 | highlight: bool 105 | destination: bool 106 | coords: int 107 | replay: int 108 | challenge: int 109 | message: int 110 | coordColor: int 111 | submitMove: int 112 | confirmResign: int 113 | insightShare: int 114 | keyboardMove: int 115 | zen: int 116 | moveEvent: int 117 | rookCastle: int 118 | 119 | 120 | class Preferences(TypedDict): 121 | prefs: UserPreferences 122 | language: str 123 | -------------------------------------------------------------------------------- /berserk/types/analysis.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from typing_extensions import TypedDict 4 | 5 | 6 | class PrincipleVariation(TypedDict): 7 | moves: str 8 | """Centipawn (cp) is the unit of measure used in chess as representation of the advantage. A centipawn is 1/100th 9 | of a pawn. This value can be used as an indicator of the quality of play. The fewer centipawns one loses per move, 10 | the stronger the play. 11 | """ 12 | cp: int 13 | 14 | 15 | class PositionEvaluation(TypedDict): 16 | fen: str 17 | knodes: int 18 | depth: int 19 | """ Principle Variation (pv) is a variation composed of the "best" moves (in the opinion of the engine).""" 20 | pvs: List[PrincipleVariation] 21 | -------------------------------------------------------------------------------- /berserk/types/bots.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Union 4 | from typing_extensions import TypedDict, Literal, TypeAlias 5 | 6 | from .account import Perf 7 | from .challenges import ChallengeStatus, ChallengeJson 8 | from .common import Color, Speed 9 | 10 | 11 | GameSource: TypeAlias = Literal[ 12 | "lobby", 13 | "friend", 14 | "ai", 15 | "api", 16 | "tournament", 17 | "position", 18 | "import", 19 | "importlive", 20 | "simul", 21 | "relay", 22 | "pool", 23 | "swiss", 24 | ] 25 | 26 | 27 | class Opponent(TypedDict): 28 | id: str 29 | username: str 30 | rating: int 31 | 32 | 33 | class GameEventInfo(TypedDict): 34 | fullId: str 35 | gameId: str 36 | fen: str 37 | color: Color 38 | lastMove: str 39 | source: GameSource 40 | status: ChallengeStatus 41 | variant: object 42 | speed: Speed 43 | perf: Perf 44 | rated: bool 45 | hasMoved: bool 46 | opponent: Opponent 47 | isMyTurn: bool 48 | secondsLeft: int 49 | compat: object 50 | id: str 51 | 52 | 53 | class GameStartEvent(TypedDict): 54 | type: Literal["gameStart"] 55 | game: GameEventInfo 56 | 57 | 58 | class GameFinishEvent(TypedDict): 59 | type: Literal["gameFinish"] 60 | game: GameEventInfo 61 | 62 | 63 | class ChallengeEvent(TypedDict): 64 | type: Literal["challenge"] 65 | challenge: ChallengeJson 66 | 67 | 68 | class ChallengeCanceledEvent(TypedDict): 69 | type: Literal["challengeCanceled"] 70 | challenge: ChallengeJson 71 | 72 | 73 | class ChallengeDeclinedEvent(TypedDict): 74 | type: Literal["challengeDeclined"] 75 | challenge: ChallengeJson 76 | 77 | 78 | IncomingEvent: TypeAlias = Union[ 79 | GameStartEvent, 80 | GameFinishEvent, 81 | ChallengeEvent, 82 | ChallengeCanceledEvent, 83 | ChallengeDeclinedEvent, 84 | ] 85 | -------------------------------------------------------------------------------- /berserk/types/broadcast.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing_extensions import NotRequired, TypedDict 4 | 5 | from .common import Title 6 | 7 | 8 | class BroadcastPlayer(TypedDict): 9 | # The name of the player as it appears on the source PGN 10 | source_name: str 11 | # The name of the player as it will be displayed on Lichess 12 | display_name: str 13 | # Rating, optional 14 | rating: NotRequired[int] 15 | # Title, optional 16 | title: NotRequired[Title] 17 | -------------------------------------------------------------------------------- /berserk/types/bulk_pairings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing_extensions import TypedDict 4 | 5 | from .common import ClockConfig, VariantKey 6 | 7 | 8 | class BulkPairingGame(TypedDict): 9 | id: str 10 | black: str 11 | white: str 12 | 13 | 14 | class BulkPairing(TypedDict): 15 | id: str 16 | games: list[BulkPairingGame] 17 | variant: VariantKey 18 | clock: ClockConfig 19 | pairAt: int 20 | pairedAt: int | None 21 | rated: bool 22 | startClocksAt: int 23 | scheduledAt: int 24 | -------------------------------------------------------------------------------- /berserk/types/challenges.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Union 4 | from typing_extensions import TypeAlias, TypedDict, Required, NotRequired, Literal 5 | 6 | from .common import VariantKey, Color, OnlineLightUser, Speed 7 | 8 | ChallengeStatus: TypeAlias = Literal[ 9 | "created", 10 | "offline", 11 | "canceled", 12 | "declined", 13 | "accepted", 14 | ] 15 | 16 | ChallengeDeclineReason: TypeAlias = Literal[ 17 | "generic", 18 | "later", 19 | "tooFast", 20 | "tooSlow", 21 | "timeControl", 22 | "rated", 23 | "casual", 24 | "standard", 25 | "variant", 26 | "noBot", 27 | "onlyBot", 28 | ] 29 | 30 | ColorOrRandom: TypeAlias = Union[Color, Literal["random"]] 31 | 32 | ChallengeDirection: TypeAlias = Literal["in", "out"] 33 | 34 | 35 | class Variant(TypedDict): 36 | key: VariantKey 37 | name: str 38 | short: str 39 | 40 | 41 | class User(OnlineLightUser): 42 | """Challenge User""" 43 | 44 | rating: NotRequired[float] 45 | provisional: NotRequired[bool] 46 | 47 | 48 | class Perf(TypedDict): 49 | icon: str 50 | name: str 51 | 52 | 53 | class ChallengeJson(TypedDict): 54 | """Information about a challenge.""" 55 | 56 | id: Required[str] 57 | url: Required[str] 58 | status: Required[ChallengeStatus] 59 | challenger: Required[User] 60 | destUser: Required[User | None] 61 | variant: Required[Variant] 62 | rated: Required[bool] 63 | speed: Required[Speed] 64 | timeControl: object 65 | color: Required[ColorOrRandom] 66 | finalColor: Color 67 | perf: Required[Perf] 68 | direction: NotRequired[ChallengeDirection] 69 | initialFen: NotRequired[str] 70 | declineReason: str 71 | declineReasonKey: str 72 | -------------------------------------------------------------------------------- /berserk/types/common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Union 4 | 5 | from typing_extensions import Literal, TypedDict, TypeAlias, NotRequired 6 | 7 | 8 | class ClockConfig(TypedDict): 9 | # starting time in seconds 10 | limit: int 11 | # increment in seconds 12 | increment: int 13 | 14 | 15 | class ExternalEngine(TypedDict): 16 | # Engine ID 17 | id: str 18 | # Engine display name 19 | name: str 20 | # Secret token that can be used to request analysis 21 | clientSecret: str 22 | # User this engine has been registered for 23 | userId: str 24 | # Max number of available threads 25 | maxThreads: int 26 | # Max available hash table size, in MiB 27 | maxHash: int 28 | # Estimated depth of normal search 29 | defaultDepth: int 30 | # List of supported chess variants 31 | variants: str 32 | # Arbitrary data that engine provider can use for identification or bookkeeping 33 | providerData: NotRequired[str] 34 | 35 | 36 | Color: TypeAlias = Literal["white", "black"] 37 | 38 | GameType: TypeAlias = Literal[ 39 | "chess960", 40 | "kingOfTheHill", 41 | "threeCheck", 42 | "antichess", 43 | "atomic", 44 | "horde", 45 | "racingKings", 46 | "crazyhouse", 47 | "fromPosition", 48 | ] 49 | 50 | Speed = Literal[ 51 | "ultraBullet", "bullet", "blitz", "rapid", "classical", "correspondence" 52 | ] 53 | 54 | 55 | Title = Literal[ 56 | "GM", "WGM", "IM", "WIM", "FM", "WFM", "NM", "CM", "WCM", "WNM", "LM", "BOT" 57 | ] 58 | 59 | 60 | class LightUser(TypedDict): 61 | # The id of the user 62 | id: str 63 | # The name of the user 64 | name: str 65 | # The title of the user 66 | title: NotRequired[Title] 67 | # The flair of the user 68 | flair: NotRequired[str] 69 | # The patron of the user 70 | patron: NotRequired[bool] 71 | 72 | 73 | class OnlineLightUser(LightUser): 74 | # Whether the user is online 75 | online: NotRequired[bool] 76 | 77 | 78 | VariantKey: TypeAlias = Union[GameType, Literal["standard"]] 79 | 80 | PerfType: TypeAlias = Union[ 81 | GameType, Literal["bullet", "blitz", "rapid", "classical", "ultraBullet"] 82 | ] 83 | 84 | GameRule: TypeAlias = Literal[ 85 | "noAbort", "noRematch", "noGiveTime", "noClaimWin", "noEarlyDraw" 86 | ] 87 | -------------------------------------------------------------------------------- /berserk/types/fide.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing_extensions import NotRequired, TypedDict 3 | 4 | 5 | class FidePlayer(TypedDict): 6 | id: int 7 | name: str 8 | federation: str 9 | year: int 10 | title: NotRequired[str] 11 | standard: NotRequired[int] 12 | rapid: NotRequired[int] 13 | blitz: NotRequired[int] 14 | -------------------------------------------------------------------------------- /berserk/types/opening_explorer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Literal, List 4 | from typing_extensions import TypedDict 5 | from .common import Speed 6 | 7 | OpeningExplorerRating = Literal[ 8 | "0", "1000", "1200", "1400", "1600", "1800", "2000", "2200", "2500" 9 | ] 10 | 11 | 12 | class Opening(TypedDict): 13 | # The eco code of this opening 14 | eco: str 15 | # The name of this opening 16 | name: str 17 | 18 | 19 | class Player(TypedDict): 20 | # The name of the player 21 | name: str 22 | # The rating of the player during the game 23 | rating: int 24 | 25 | 26 | class GameWithoutUci(TypedDict): 27 | # The id of the game 28 | id: str 29 | # The winner of the game. Draw if None 30 | winner: Literal["white"] | Literal["black"] | None 31 | # The speed of the game 32 | speed: Speed 33 | # The type of game 34 | mode: Literal["rated"] | Literal["casual"] 35 | # The black player 36 | black: Player 37 | # The white player 38 | white: Player 39 | # The year of the game 40 | year: int 41 | # The month and year of the game. For example "2023-06" 42 | month: str 43 | 44 | 45 | class Game(GameWithoutUci): 46 | # The move in Universal Chess Interface notation 47 | uci: str 48 | 49 | 50 | class Move(TypedDict): 51 | # The move in Universal Chess Interface notation 52 | uci: str 53 | # The move in algebraic notation 54 | san: str 55 | # The average rating of games in the position after this move 56 | averageRating: int 57 | # The number of white winners after this move 58 | white: int 59 | # The number of black winners after this move 60 | black: int 61 | # The number of draws after this move 62 | draws: int 63 | # The game where the move was played 64 | game: GameWithoutUci | None 65 | 66 | 67 | class OpeningStatistic(TypedDict): 68 | # Number of game won by white from this position 69 | white: int 70 | # Number of game won by black from this position 71 | draws: int 72 | # Number draws from this position 73 | black: int 74 | # Opening info of this position 75 | opening: Opening | None 76 | # The list of moves played by players from this position 77 | moves: List[Move] 78 | # recent games with this opening 79 | recentGames: List[Game] 80 | # top rating games with this opening 81 | topGames: List[Game] 82 | -------------------------------------------------------------------------------- /berserk/types/puzzles.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing_extensions import TypedDict 4 | 5 | 6 | class PuzzleRace(TypedDict): 7 | # Puzzle race ID 8 | id: str 9 | # Puzzle race URL 10 | url: str 11 | -------------------------------------------------------------------------------- /berserk/types/studies.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing_extensions import TypedDict 4 | 5 | 6 | class ChapterIdName(TypedDict): 7 | id: str 8 | name: str 9 | -------------------------------------------------------------------------------- /berserk/types/team.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import List 4 | from typing_extensions import TypedDict, NotRequired 5 | 6 | from .common import LightUser 7 | 8 | 9 | class Team(TypedDict): 10 | # The id of the team 11 | id: str 12 | # The name of the team 13 | name: str 14 | # The description of the team 15 | description: str 16 | # Whether the team is open 17 | open: bool 18 | # The leader of the team 19 | leader: LightUser 20 | # The leaders of the team 21 | leaders: List[LightUser] 22 | # The number of members of the team 23 | nbMembers: int 24 | # Has the user asssociated with the token (if any) joined the team 25 | joined: NotRequired[bool] 26 | # Has the user asssociated with the token (if any) requested to join the team 27 | requested: NotRequired[bool] 28 | 29 | 30 | class PaginatedTeams(TypedDict): 31 | # The current page 32 | currentPage: int 33 | # The maximum number of teams per page 34 | maxPerPage: int 35 | # The teams on the current page 36 | currentPageResults: List[Team] 37 | # The total number of teams 38 | nbResults: int 39 | # The previous page 40 | previousPage: int | None 41 | # The next page 42 | nextPage: int | None 43 | # The total number of pages 44 | nbPages: int 45 | -------------------------------------------------------------------------------- /berserk/types/tournaments.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Dict, Optional 2 | 3 | from .common import Title, LightUser 4 | from typing_extensions import TypedDict, NotRequired 5 | 6 | 7 | class CurrentTournaments(TypedDict): 8 | created: List[Dict[str, Any]] 9 | started: List[Dict[str, Any]] 10 | finished: List[Dict[str, Any]] 11 | 12 | 13 | class Clock(TypedDict): 14 | limit: int 15 | increment: int 16 | 17 | 18 | class Stats(TypedDict): 19 | absences: int 20 | averageRating: int 21 | blackWins: int 22 | byes: int 23 | draws: int 24 | games: int 25 | whiteWins: int 26 | 27 | 28 | class SwissInfo(TypedDict): 29 | id: str 30 | createdBy: str 31 | startsAt: str 32 | name: str 33 | clock: Clock 34 | variant: str 35 | round: int 36 | nbRounds: int 37 | nbPlayers: int 38 | nbOngoing: int 39 | status: str 40 | rated: bool 41 | stats: Optional[Stats] 42 | 43 | 44 | # private, abstract class 45 | class TournamentResult(TypedDict): 46 | rank: int 47 | rating: int 48 | username: str 49 | performance: int 50 | title: NotRequired[Title] 51 | flair: NotRequired[str] 52 | 53 | 54 | class ArenaSheet(TypedDict): 55 | scores: str 56 | 57 | 58 | class ArenaResult(TournamentResult): 59 | score: int 60 | # only when requested, expensive and slowing down the stream 61 | sheet: NotRequired[ArenaSheet] 62 | 63 | 64 | class SwissResult(TournamentResult): 65 | points: float # can be .5 in case of draw 66 | tieBreak: float 67 | 68 | 69 | class PlayerTeamResult(TypedDict): 70 | user: LightUser 71 | score: int 72 | 73 | 74 | class TeamResult(TypedDict): 75 | rank: int 76 | id: str 77 | score: int 78 | players: List[PlayerTeamResult] 79 | 80 | 81 | class TeamBattleResult(TypedDict): 82 | id: str 83 | teams: List[TeamResult] 84 | -------------------------------------------------------------------------------- /berserk/types/tv.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import List, Literal 4 | from typing_extensions import TypedDict, NotRequired 5 | 6 | from .common import LightUser, Color 7 | 8 | 9 | class Player(TypedDict): 10 | color: Color 11 | user: NotRequired[LightUser] 12 | ai: NotRequired[int] 13 | rating: NotRequired[int] 14 | seconds: int 15 | 16 | 17 | class FeaturedData(TypedDict): 18 | id: str 19 | orientation: Color 20 | players: List[Player] 21 | fen: str 22 | 23 | 24 | class MoveData(TypedDict): 25 | fen: str 26 | lm: str 27 | wc: int 28 | bc: int 29 | 30 | 31 | class TVFeed(TypedDict): 32 | t: Literal["featured", "fen"] 33 | d: FeaturedData | MoveData 34 | -------------------------------------------------------------------------------- /berserk/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dateutil.parser 4 | 5 | from datetime import datetime, timezone, timedelta 6 | from typing import Any, Callable, Dict, List, NamedTuple, Tuple, TypeVar, Union, cast 7 | from .types.broadcast import BroadcastPlayer 8 | 9 | T = TypeVar("T") 10 | U = TypeVar("U") 11 | 12 | 13 | def to_millis(dt: datetime) -> int: 14 | """Return the milliseconds between the given datetime and the epoch.""" 15 | return int(dt.timestamp() * 1000) 16 | 17 | 18 | def timedelta_from_millis(millis: float) -> timedelta: 19 | """Return a timedelta (A duration expressing the difference between two datetime or 20 | date instances to microsecond resolution.) for a given milliseconds.""" 21 | return timedelta(milliseconds=millis) 22 | 23 | 24 | def datetime_from_seconds(ts: float) -> datetime: 25 | """Return the datetime for the given seconds since the epoch. 26 | 27 | UTC is assumed. The returned datetime is timezone aware. 28 | """ 29 | return datetime.fromtimestamp(ts, timezone.utc) 30 | 31 | 32 | def datetime_from_millis(millis: float) -> datetime: 33 | """Return the datetime for the given millis since the epoch. 34 | 35 | UTC is assumed. The returned datetime is timezone aware. 36 | """ 37 | return datetime_from_seconds(millis / 1000) 38 | 39 | 40 | def datetime_from_str(dt_str: str) -> datetime: 41 | """Convert the time in a string to a datetime. 42 | 43 | UTC is assumed. The returned datetime is timezone aware. The format must match ISO 44 | 8601. 45 | """ 46 | dt = dateutil.parser.isoparse(dt_str) 47 | return dt.replace(tzinfo=timezone.utc) 48 | 49 | 50 | def datetime_from_str_or_millis(millis_or_str: str | int) -> datetime: 51 | """Convert a string or int to a datetime. 52 | 53 | UTC is assumed. The returned datetime is timezone aware. If the input is a string, 54 | the format must match ISO 8601. 55 | """ 56 | if isinstance(millis_or_str, int): 57 | return datetime_from_millis(millis_or_str) 58 | return datetime_from_str(millis_or_str) 59 | 60 | 61 | class RatingHistoryEntry(NamedTuple): 62 | year: int 63 | month: int 64 | day: int 65 | rating: int 66 | 67 | 68 | def rating_history(data: Tuple[int, int, int, int]): 69 | return RatingHistoryEntry(*data) 70 | 71 | 72 | def inner( 73 | func: Callable[[T], U], *keys: str 74 | ) -> Callable[[Dict[str, T]], Dict[str, T | U]]: 75 | def convert(data: Dict[str, T]) -> Dict[str, T | U]: 76 | result = cast(Dict[str, Union[T, U]], data) 77 | for k in keys: 78 | try: 79 | result[k] = func(data[k]) 80 | except KeyError: 81 | pass # normal for keys to not be present sometimes 82 | return result 83 | 84 | return convert 85 | 86 | 87 | def listing(func: Callable[[T], U]) -> Callable[[List[T]], List[U]]: 88 | def convert(items: List[T]): 89 | return [func(item) for item in items] 90 | 91 | return convert 92 | 93 | 94 | def noop(arg: T) -> T: 95 | return arg 96 | 97 | 98 | def build_adapter(mapper: Dict[str, str], sep: str = "."): 99 | """Build a data adapter. 100 | 101 | Uses a map to pull values from an object and assign them to keys. 102 | For example: 103 | 104 | .. code-block:: python 105 | 106 | >>> mapping = { 107 | ... 'broadcast_id': 'broadcast.id', 108 | ... 'slug': 'broadcast.slug', 109 | ... 'name': 'broadcast.name', 110 | ... 'description': 'broadcast.description', 111 | ... 'syncUrl': 'broadcast.sync.url', 112 | ... } 113 | 114 | >>> cast = {'broadcast': {'id': 'WxOb8OUT', 115 | ... 'slug': 'test-tourney', 116 | ... 'name': 'Test Tourney', 117 | ... 'description': 'Just a test', 118 | ... 'ownerId': 'rhgrant10', 119 | ... 'sync': {'ongoing': False, 'log': [], 'url': None}}, 120 | ... 'url': 'https://lichess.org/broadcast/test-tourney/WxOb8OUT'} 121 | 122 | >>> adapt = build_adapter(mapping) 123 | >>> adapt(cast) 124 | {'broadcast_id': 'WxOb8OUT', 125 | 'slug': 'test-tourney', 126 | 'name': 'Test Tourney', 127 | 'description': 'Just a test', 128 | 'syncUrl': None} 129 | 130 | :param dict mapper: map of keys to their location in an object 131 | :param str sep: nested key delimiter 132 | :return: adapted data 133 | :rtype: dict 134 | """ 135 | 136 | def get(data: Dict[str, Any], location: str) -> Dict[str, Any]: 137 | for key in location.split(sep): 138 | data = data[key] 139 | return data 140 | 141 | def adapter( 142 | data: Dict[str, Any], default: Any = None, fill: bool = False 143 | ) -> Dict[str, Any]: 144 | result: Dict[str, Any] = {} 145 | for key, loc in mapper.items(): 146 | try: 147 | result[key] = get(data, loc) 148 | except KeyError: 149 | if fill: 150 | result[key] = default 151 | return result 152 | 153 | return adapter 154 | 155 | 156 | def to_str(players: List[BroadcastPlayer] | None) -> str: 157 | if players is None: 158 | return "" 159 | 160 | def individual_str(p: BroadcastPlayer) -> str: 161 | return ";".join([str(v) for v in p.values()]) 162 | 163 | return "\n".join([individual_str(p) for p in players]) 164 | -------------------------------------------------------------------------------- /check-endpoints.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import annotations 4 | 5 | import yaml 6 | import re 7 | import sys 8 | import pathlib 9 | 10 | # tablebase endpoints, defined dynamically with "/{variant}" in the code 11 | FALSE_POSITIVES = ["/standard", "/atomic", "/antichess"] 12 | 13 | if len(sys.argv) != 2 or not pathlib.Path(sys.argv[1]).is_file(): 14 | path = "../api/doc/specs/lichess-api.yaml" 15 | if not pathlib.Path(path).is_file(): 16 | print( 17 | "Usage: check-endpoints.py", 18 | "", 19 | ) 20 | exit(1) 21 | else: 22 | path = sys.argv[1] 23 | 24 | 25 | with open(path) as f: 26 | spec = yaml.load(f, Loader=yaml.SafeLoader) 27 | 28 | clients_content = "\n".join( 29 | p.read_text() for p in pathlib.Path("berserk/clients/").glob("*.py") 30 | ) 31 | 32 | missing_endpoints: list[str] = [] 33 | 34 | for endpoint, data in spec["paths"].items(): 35 | # Remove leading slash 36 | endpoint_without_slash = endpoint[1:] 37 | 38 | # Replace parameter placeholders with regular expression 39 | # Encode/decode methods allow to treat it as raw string: https://stackoverflow.com/questions/2428117/casting-raw-strings-python/2428132#2428132 40 | endpoint_regex = ( 41 | f'/{re.sub(r"{[^/]+?}", r"[^/]+?", endpoint_without_slash)}'.encode( 42 | "unicode_escape" 43 | ).decode() 44 | ) 45 | 46 | # Check if endpoint or a variation of it is present in file 47 | if not re.search(endpoint_regex, clients_content): 48 | if servers := data.get("servers"): 49 | if host := servers[0].get("url"): 50 | endpoint = host + endpoint 51 | missing_endpoints.append(endpoint) 52 | 53 | if missing_endpoints: 54 | print("\nMissing endpoints:\n") 55 | for endp in sorted(missing_endpoints): 56 | if endp not in FALSE_POSITIVES: 57 | print(endp) 58 | else: 59 | print("No missing endpoints") 60 | -------------------------------------------------------------------------------- /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 | Formats 21 | ------- 22 | 23 | .. automodule:: berserk.formats 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | 28 | 29 | Exceptions 30 | ---------- 31 | 32 | .. automodule:: berserk.exceptions 33 | :members: 34 | :undoc-members: 35 | :show-inheritance: 36 | 37 | Utils 38 | ----- 39 | 40 | .. automodule:: berserk.utils 41 | :members: 42 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # berserk documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jun 9 13:47:02 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another 16 | # directory, add these directories to sys.path here. If the directory is 17 | # relative to the documentation root, use os.path.abspath to make it 18 | # absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | 23 | sys.path.insert(0, os.path.abspath("..")) 24 | 25 | import berserk # noqa 26 | 27 | # -- General configuration --------------------------------------------- 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | # 31 | # needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 35 | extensions = [ 36 | "sphinx.ext.autodoc", 37 | "sphinx.ext.viewcode", 38 | "sphinx.ext.intersphinx", 39 | ] 40 | autodoc_typehints = "both" 41 | 42 | source_suffix = ".rst" 43 | 44 | # The master toctree document. 45 | master_doc = "index" 46 | 47 | # General information about the project. 48 | project = "berserk" 49 | copyright = "2018-2023, Lichess and contributors" 50 | author = "Lichess" 51 | 52 | # version 53 | version = berserk.__version__ 54 | release = berserk.__version__ 55 | 56 | # List of patterns, relative to source directory, that match files and 57 | # directories to ignore when looking for source files. 58 | # This patterns also effect to html_static_path and html_extra_path 59 | exclude_patterns = ["_build"] 60 | 61 | # The name of the Pygments (syntax highlighting) style to use. 62 | pygments_style = "sphinx" 63 | html_theme = "sphinx_rtd_theme" 64 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | ---- 4 | 5 | .. toctree:: 6 | :maxdepth: 2 7 | 8 | usage 9 | api 10 | contributing 11 | changelog 12 | 13 | 14 | Indices and tables 15 | ================== 16 | * :ref:`genindex` 17 | * :ref:`modindex` 18 | * :ref:`search` 19 | -------------------------------------------------------------------------------- /hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Format everything but only re-add files that were previously on stage. 4 | make format && (git diff --name-only --cached | xargs git add -f) 5 | -------------------------------------------------------------------------------- /integration/local.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | integration_test() { 4 | # BDIT = Berserk Docker Image Test, trying to reduce collision 5 | local BDIT_IMAGE=ghcr.io/lichess-org/lila-docker:main 6 | local BDIT_LILA=bdit_lila 7 | local BDIT_NETWORK=bdit_lila-network 8 | local BDIT_APP=bdit_app 9 | 10 | cleanup_containers() { 11 | docker rm --force $BDIT_LILA > /dev/null 2>&1 || true 12 | docker rm --force $BDIT_APP > /dev/null 2>&1 || true 13 | docker network rm $BDIT_NETWORK > /dev/null 2>&1 || true 14 | } 15 | 16 | echo "Running integration tests" 17 | cleanup_containers 18 | 19 | docker network create $BDIT_NETWORK 20 | docker run --name $BDIT_LILA --network $BDIT_NETWORK -d $BDIT_IMAGE 21 | docker run --name $BDIT_APP --network $BDIT_NETWORK -v "$(pwd)":/app -w /app $BDIT_IMAGE ./integration/run-tests.sh 22 | 23 | cleanup_containers 24 | echo "✅ Done" 25 | } 26 | integration_test -------------------------------------------------------------------------------- /integration/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | python3 -m pip install -e . --no-cache-dir 4 | 5 | attempts=0 6 | while [ $attempts -lt 30 ]; do 7 | if curl -s http://bdit_lila:8080 >/dev/null; then 8 | break 9 | fi 10 | echo "⌛ Waiting for lila to start..." 11 | sleep 1 12 | attempts=$((attempts + 1)) 13 | done 14 | 15 | pytest integration 16 | -------------------------------------------------------------------------------- /integration/test_lila_account.py: -------------------------------------------------------------------------------- 1 | import berserk 2 | import pytest 3 | 4 | BASE_URL = "http://bdit_lila:8080" 5 | 6 | 7 | @pytest.fixture(scope="module") 8 | def client(): 9 | session = berserk.TokenSession("lip_bobby") 10 | client = berserk.Client(session, base_url=BASE_URL) 11 | yield client 12 | 13 | 14 | def test_account_get(client): 15 | me = client.account.get() 16 | assert me["id"] == "bobby" 17 | 18 | 19 | def test_account_get_email(client): 20 | assert client.account.get_email() == "bobby@localhost" 21 | 22 | 23 | def test_account_get_preferences(client): 24 | preferences = client.account.get_preferences() 25 | assert preferences["language"] == "en-US" 26 | assert preferences["prefs"]["animation"] == 2 27 | 28 | 29 | def test_account_kid_mode(client): 30 | assert client.account.get_kid_mode() == False 31 | client.account.set_kid_mode(True) 32 | assert client.account.get_kid_mode() == True 33 | 34 | 35 | def test_account_upgrade_to_bot(): 36 | session = berserk.TokenSession("lip_zerogames") 37 | client = berserk.Client(session, base_url=BASE_URL) 38 | assert "title" not in client.account.get() 39 | client.account.upgrade_to_bot() 40 | assert client.account.get()["title"] == "BOT" 41 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "berserk" 3 | version = "0.13.2" 4 | description = "Python client for the lichess API" 5 | authors = ["Lichess "] 6 | license = "GPL-3.0-or-later" 7 | readme = "README.rst" 8 | repository = "https://github.com/lichess-org/berserk" 9 | documentation = "https://lichess-org.github.io/berserk/" 10 | keywords = ["lichess", "chess", "api", "client"] 11 | classifiers = [ 12 | 'Development Status :: 4 - Beta', 13 | 'Intended Audience :: Developers', 14 | 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', 15 | 'Natural Language :: English', 16 | 'Programming Language :: Python :: 3', 17 | 'Programming Language :: Python :: 3.8', 18 | 'Programming Language :: Python :: 3.9', 19 | 'Programming Language :: Python :: 3.10', 20 | 'Programming Language :: Python :: 3.11', 21 | ] 22 | # by default files declared in .gitignore are excluded 23 | include = ["CHANGELOG.rst", "LICENSE", "docs", "berserk/py.typed"] 24 | 25 | [tool.poetry.urls] 26 | Changelog = "https://lichess-org.github.io/berserk/changelog.html" 27 | "Issue Tracker" = "https://github.com/lichess-org/berserk/issues" 28 | "Discord Server" = "https://discord.gg/lichess" 29 | 30 | [tool.poetry.dependencies] 31 | python = "^3.8" 32 | requests = "^2.28.2" 33 | ndjson = "^0.3.1" 34 | python-dateutil = "^2.8.2" 35 | deprecated = "^1.2.14" 36 | typing-extensions = "^4.7.1" 37 | 38 | [tool.poetry.group.dev] 39 | optional = true 40 | 41 | [tool.poetry.group.dev.dependencies] 42 | pytest = "^7" 43 | black = "^23" 44 | sphinx = "^6.1.3" 45 | sphinx-rtd-theme = "^1.2.0" 46 | docformatter = "^1" 47 | pyright = "^1" 48 | requests-mock = "^1.11.0" 49 | pytest-recording = "^0.12.2" 50 | vcrpy = "^4.4.0" 51 | pydantic = "^2.1.1" 52 | 53 | 54 | [tool.pyright] 55 | typeCheckingMode = "strict" 56 | 57 | 58 | [build-system] 59 | requires = ["poetry-core"] 60 | build-backend = "poetry.core.masonry.api" 61 | -------------------------------------------------------------------------------- /tests/clients/cassettes/test_analysis/TestAnalysis.test_get_cloud_evaluation.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.31.0 13 | method: GET 14 | uri: https://lichess.org/api/cloud-eval?fen=rnbqkbnr%2Fppp1pppp%2F8%2F3pP3%2F8%2F8%2FPPPP1PPP%2FRNBQKBNR+b+KQkq+-+0+2&multiPv=1&variant=standard 15 | response: 16 | body: 17 | string: '{"fen": "rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR b KQkq - 0 2","knodes":13683,"depth":22,"pvs":[{"moves":"c8f5 d2d4 e7e6 g1f3 g8e7 c1e3 c7c5 d4c5 e7c6 b1c3","cp":-13}]}' 18 | headers: 19 | Access-Control-Allow-Headers: 20 | - Origin, Authorization, If-Modified-Since, Cache-Control, Content-Type 21 | Access-Control-Allow-Methods: 22 | - OPTIONS, GET, POST, PUT, DELETE 23 | Access-Control-Allow-Origin: 24 | - '*' 25 | Connection: 26 | - keep-alive 27 | Content-Type: 28 | - application/json 29 | Date: 30 | - Thu, 02 Nov 2023 21:25:59 GMT 31 | Permissions-Policy: 32 | - interest-cohort=() 33 | Server: 34 | - nginx 35 | Strict-Transport-Security: 36 | - max-age=63072000; includeSubDomains; preload 37 | Transfer-Encoding: 38 | - chunked 39 | Vary: 40 | - Origin 41 | X-Frame-Options: 42 | - DENY 43 | content-length: 44 | - '6450' 45 | status: 46 | code: 200 47 | message: OK 48 | version: 1 49 | -------------------------------------------------------------------------------- /tests/clients/cassettes/test_fide/TestFide.test_get_player.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | method: GET 4 | uri: https://lichess.org/api/fide/player/35009192 5 | headers: 6 | Accept: 7 | - application/json 8 | body: null 9 | response: 10 | status: 11 | code: 200 12 | message: OK 13 | headers: 14 | Content-Type: 15 | - application/json 16 | body: 17 | string: '{"id": 35009192, "name": "Erigaisi Arjun", "federation": "IND", "year": 2003, "title": "GM", "standard": 2782, "rapid": 2708, "blitz": 2738}' 18 | version: 1 19 | 20 | -------------------------------------------------------------------------------- /tests/clients/cassettes/test_fide/TestFide.test_search_players.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | method: GET 4 | uri: https://lichess.org/api/fide/player?q=Erigaisi 5 | headers: 6 | Accept: 7 | - application/json 8 | body: null 9 | response: 10 | status: 11 | code: 200 12 | message: OK 13 | headers: 14 | Content-Type: 15 | - application/json 16 | body: 17 | string: | 18 | [ 19 | { 20 | "id": 35009192, 21 | "name": "Erigaisi Arjun", 22 | "federation": "IND", 23 | "year": 2003, 24 | "title": "GM", 25 | "standard": 2782, 26 | "rapid": 2708, 27 | "blitz": 2738 28 | }, 29 | { 30 | "id": 35009060, 31 | "name": "Erigaisi Keerthana", 32 | "federation": "IND", 33 | "year": 2002 34 | } 35 | ] 36 | version: 1 37 | 38 | 39 | -------------------------------------------------------------------------------- /tests/clients/cassettes/test_opening_explorer/TestLichessGames.test_result.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.31.0 13 | method: GET 14 | uri: https://explorer.lichess.ovh/lichess?variant=standard&fen=rnbqkbnr%2Fppp2ppp%2F8%2F3pp3%2F4P3%2F2NP4%2FPPP2PPP%2FR1BQKBNR+b+KQkq+-+0+1&speeds=blitz%2Crapid%2Cclassical&ratings=2200%2C2500 15 | response: 16 | body: 17 | string: '{"white":1212,"draws":160,"black":1406,"moves":[{"uci":"d5d4","san":"d4","averageRating":2290,"white":625,"draws":66,"black":633,"game":null},{"uci":"g8f6","san":"Nf6","averageRating":2330,"white":246,"draws":43,"black":342,"game":null},{"uci":"d5e4","san":"dxe4","averageRating":2301,"white":205,"draws":34,"black":258,"game":null},{"uci":"c7c6","san":"c6","averageRating":2315,"white":59,"draws":7,"black":78,"game":null},{"uci":"f8b4","san":"Bb4","averageRating":2304,"white":59,"draws":6,"black":72,"game":null},{"uci":"g8e7","san":"Ne7","averageRating":2338,"white":13,"draws":3,"black":16,"game":null},{"uci":"c8e6","san":"Be6","averageRating":2312,"white":3,"draws":0,"black":1,"game":null},{"uci":"f7f5","san":"f5","averageRating":2337,"white":0,"draws":0,"black":3,"game":null},{"uci":"f8d6","san":"Bd6","averageRating":2248,"white":0,"draws":0,"black":2,"game":null},{"uci":"f7f6","san":"f6","averageRating":2270,"white":1,"draws":1,"black":0,"game":null},{"uci":"b8c6","san":"Nc6","averageRating":2444,"white":0,"draws":0,"black":1,"game":{"id":"3WS9Bp51","winner":"black","speed":"blitz","mode":"rated","black":{"name":"islam_elhlwagy","rating":2444},"white":{"name":"Elhlwagy11","rating":2275},"year":2020,"month":"2020-04"}},{"uci":"g7g5","san":"g5","averageRating":2280,"white":1,"draws":0,"black":0,"game":{"id":"lp7spxN7","winner":"white","speed":"blitz","mode":"rated","black":{"name":"Adonai_June","rating":2280},"white":{"name":"tacat1","rating":2404},"year":2021,"month":"2021-11"}}],"recentGames":[{"uci":"g8f6","id":"IWbmhvRx","winner":"white","speed":"blitz","mode":"rated","black":{"name":"mohammadallan94","rating":2282},"white":{"name":"pulemetAK29","rating":2286},"year":2023,"month":"2023-06"},{"uci":"d5d4","id":"QHLLNIM7","winner":"black","speed":"blitz","mode":"rated","black":{"name":"Danielmcloud","rating":2202},"white":{"name":"pakravan","rating":2233},"year":2023,"month":"2023-06"},{"uci":"d5e4","id":"e6Gdnlyg","winner":"black","speed":"blitz","mode":"rated","black":{"name":"choco_chips_001","rating":2210},"white":{"name":"Rouztaj","rating":2261},"year":2023,"month":"2023-06"},{"uci":"d5d4","id":"pBNMiQZq","winner":"white","speed":"blitz","mode":"rated","black":{"name":"BADromantic","rating":2261},"white":{"name":"schipasha","rating":2291},"year":2023,"month":"2023-06"}],"topGames":[{"uci":"g8f6","id":"MMsLh0fD","winner":"white","speed":"blitz","mode":"rated","black":{"name":"HomayooonT","rating":2650},"white":{"name":"Rustin_Cohle6","rating":2626},"year":2023,"month":"2023-06"},{"uci":"g8f6","id":"1bhM8jhR","winner":null,"speed":"blitz","mode":"rated","black":{"name":"The_king66","rating":2536},"white":{"name":"dubic","rating":2514},"year":2023,"month":"2023-06"},{"uci":"g8f6","id":"oNhpZ35B","winner":"black","speed":"blitz","mode":"rated","black":{"name":"frelsara","rating":2631},"white":{"name":"EmperorBundaloy","rating":2645},"year":2023,"month":"2023-05"},{"uci":"c7c6","id":"S5Qt13vF","winner":"black","speed":"blitz","mode":"rated","black":{"name":"enriann10","rating":2549},"white":{"name":"Mavi_Alpha","rating":2591},"year":2023,"month":"2023-05"}],"opening":null}' 18 | headers: 19 | Access-Control-Allow-Headers: 20 | - Accept,If-Modified-Since,Cache-Control,X-Requested-With 21 | Access-Control-Allow-Methods: 22 | - GET,OPTIONS 23 | Access-Control-Allow-Origin: 24 | - '*' 25 | Cache-Control: 26 | - max-age=10800 27 | - public 28 | Connection: 29 | - keep-alive 30 | Content-Type: 31 | - application/json 32 | Date: 33 | - Tue, 25 Jul 2023 19:24:42 GMT 34 | Expires: 35 | - Tue, 25 Jul 2023 22:24:42 GMT 36 | Server: 37 | - nginx 38 | Transfer-Encoding: 39 | - chunked 40 | content-length: 41 | - '3130' 42 | status: 43 | code: 200 44 | message: OK 45 | version: 1 46 | -------------------------------------------------------------------------------- /tests/clients/cassettes/test_opening_explorer/TestMasterGames.test_export.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.31.0 13 | method: GET 14 | uri: https://explorer.lichess.ovh/master/pgn/LSVO85Cp 15 | response: 16 | body: 17 | string: '[Event "3rd Norway Chess 2015"] 18 | 19 | [Site "Stavanger NOR"] 20 | 21 | [Date "2015.06.17"] 22 | 23 | [Round "2.4"] 24 | 25 | [White "Caruana, F."] 26 | 27 | [Black "Carlsen, M."] 28 | 29 | [Result "1-0"] 30 | 31 | [WhiteElo "2805"] 32 | 33 | [BlackElo "2876"] 34 | 35 | 36 | 1. e4 e5 2. Nf3 Nc6 3. Bb5 Nf6 4. O-O Nxe4 5. d4 Nd6 6. Bxc6 dxc6 7. dxe5 37 | Nf5 8. Qxd8+ Kxd8 9. h3 h6 10. Rd1+ Ke8 11. Nc3 Ne7 12. b3 Bf5 13. Nd4 Bh7 38 | 14. Bb2 Rd8 15. Nce2 Nd5 16. c4 Nb4 17. Nf4 Rg8 18. g4 Na6 19. Nf5 Nc5 20. 39 | Rxd8+ Kxd8 21. Rd1+ Kc8 22. Ba3 Ne6 23. Nxe6 Bxa3 24. Nexg7 Bf8 25. e6 Bxf5 40 | 26. Nxf5 fxe6 27. Ng3 Be7 28. Kg2 Rf8 29. Rd3 Rf7 30. Nh5 Bd6 31. Rf3 Rh7 41 | 32. Re3 Re7 33. f4 Ba3 34. Kf3 Bb2 35. Re2 Bc3 36. g5 Kd7 37. Kg4 Re8 38. 42 | Ng3 Rh8 39. h4 b6 40. h5 c5 41. g6 Re8 42. f5 exf5+ 43. Kf4 Rh8 44. Nxf5 Bf6 43 | 45. Rg2 1-0 44 | 45 | ' 46 | headers: 47 | Access-Control-Allow-Headers: 48 | - Accept,If-Modified-Since,Cache-Control,X-Requested-With 49 | Access-Control-Allow-Methods: 50 | - GET,OPTIONS 51 | Access-Control-Allow-Origin: 52 | - '*' 53 | Cache-Control: 54 | - max-age=10800 55 | - public 56 | Connection: 57 | - keep-alive 58 | Content-Length: 59 | - '722' 60 | Content-Type: 61 | - application/x-chess-pgn 62 | Date: 63 | - Wed, 01 Nov 2023 07:49:11 GMT 64 | Expires: 65 | - Wed, 01 Nov 2023 10:49:11 GMT 66 | Server: 67 | - nginx 68 | status: 69 | code: 200 70 | message: OK 71 | version: 1 72 | -------------------------------------------------------------------------------- /tests/clients/cassettes/test_opening_explorer/TestMasterGames.test_result.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.31.0 13 | method: GET 14 | uri: https://explorer.lichess.ovh/masters?play=d2d4%2Cd7d5%2Cc2c4%2Cc7c6%2Cc4d5 15 | response: 16 | body: 17 | string: '{"white":1667,"draws":4428,"black":1300,"moves":[{"uci":"c6d5","san":"cxd5","averageRating":2417,"white":1667,"draws":4428,"black":1299,"game":null},{"uci":"g8f6","san":"Nf6","averageRating":2515,"white":0,"draws":0,"black":1,"game":{"id":"1EErB5jc","winner":"black","black":{"name":"Dobrov, 18 | Vladimir","rating":2515},"white":{"name":"Drozdovskij, Yuri","rating":2509},"year":2006,"month":"2006-01"}}],"topGames":[{"uci":"c6d5","id":"kN6d9l2i","winner":"black","black":{"name":"Anand, 19 | V.","rating":2785},"white":{"name":"Carlsen, M.","rating":2881},"year":2014,"month":"2014-06"},{"uci":"c6d5","id":"qeYPJL2y","winner":"white","black":{"name":"Carlsen, 20 | M.","rating":2843},"white":{"name":"So, W.","rating":2778},"year":2018,"month":"2018-06"},{"uci":"c6d5","id":"VpWYyv3g","winner":null,"black":{"name":"Carlsen, 21 | M..","rating":2847},"white":{"name":"So, W..","rating":2770},"year":2021,"month":"2021-03"},{"uci":"c6d5","id":"nlh6QPSg","winner":"white","black":{"name":"Vachier 22 | Lagrave, Maxime","rating":2778},"white":{"name":"Caruana, Fabiano","rating":2835},"year":2020,"month":"2020-06"},{"uci":"c6d5","id":"IYy7abvG","winner":"black","black":{"name":"Caruana, 23 | Fabiano","rating":2835},"white":{"name":"So, Wesley","rating":2770},"year":2020,"month":"2020-06"},{"uci":"c6d5","id":"sKEBcLbM","winner":null,"black":{"name":"Carlsen, 24 | M.","rating":2832},"white":{"name":"Grischuk, A.","rating":2761},"year":2017,"month":"2017-06"},{"uci":"c6d5","id":"ambcPQzJ","winner":"white","black":{"name":"Nakamura, 25 | Hikaru","rating":2775},"white":{"name":"Aronian, Levon","rating":2813},"year":2013,"month":"2013-05"},{"uci":"c6d5","id":"7x23lu2J","winner":"black","black":{"name":"Karjakin, 26 | Sergey","rating":2771},"white":{"name":"Aronian, L.","rating":2815},"year":2014,"month":"2014-06"},{"uci":"c6d5","id":"FLar3fv2","winner":"white","black":{"name":"Duda, 27 | Jan Krzysztof","rating":2757},"white":{"name":"Caruana, Fabiano","rating":2828},"year":2020,"month":"2020-10"},{"uci":"c6d5","id":"2O6imBL4","winner":"black","black":{"name":"Aronian, 28 | L.","rating":2764},"white":{"name":"Mamedyarov, S.","rating":2808},"year":2018,"month":"2018-06"},{"uci":"c6d5","id":"4DG6brd6","winner":null,"black":{"name":"Caruana, 29 | Fabiano","rating":2835},"white":{"name":"Nakamura, Hikaru","rating":2736},"year":2020,"month":"2020-05"},{"uci":"c6d5","id":"LNwpnUi7","winner":null,"black":{"name":"Vachier 30 | Lagrave, M.","rating":2779},"white":{"name":"Nakamura, Hi","rating":2777},"year":2018,"month":"2018-10"},{"uci":"c6d5","id":"f7QMeZlL","winner":"black","black":{"name":"Carlsen, 31 | M.","rating":2840},"white":{"name":"Vallejo Pons, F.","rating":2711},"year":2016,"month":"2016-12"},{"uci":"c6d5","id":"vZzIG6Vo","winner":"white","black":{"name":"Nakamura, 32 | Hikaru","rating":2774},"white":{"name":"Ivanchuk, Vassily","rating":2776},"year":2011,"month":"2011-06"},{"uci":"c6d5","id":"HWWXcBw1","winner":null,"black":{"name":"Caruana, 33 | Fabiano","rating":2772},"white":{"name":"Nakamura, Hikaru","rating":2767},"year":2013,"month":"2013-04"}],"opening":{"eco":"D10","name":"Slav 34 | Defense: Exchange Variation"}}' 35 | headers: 36 | Access-Control-Allow-Headers: 37 | - Accept,If-Modified-Since,Cache-Control,X-Requested-With 38 | Access-Control-Allow-Methods: 39 | - GET,OPTIONS 40 | Access-Control-Allow-Origin: 41 | - '*' 42 | Cache-Control: 43 | - max-age=10800 44 | - public 45 | Connection: 46 | - keep-alive 47 | Content-Type: 48 | - application/json 49 | Date: 50 | - Wed, 26 Jul 2023 11:27:22 GMT 51 | Expires: 52 | - Wed, 26 Jul 2023 14:27:22 GMT 53 | Server: 54 | - nginx 55 | Transfer-Encoding: 56 | - chunked 57 | content-length: 58 | - '3071' 59 | status: 60 | code: 200 61 | message: OK 62 | version: 1 63 | -------------------------------------------------------------------------------- /tests/clients/cassettes/test_teams/TestLichessGames.test_get_team.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.31.0 13 | method: GET 14 | uri: https://lichess.org/api/team/lichess-swiss 15 | response: 16 | body: 17 | string: '{"id":"lichess-swiss","name":"Lichess Swiss","description":"The official 18 | Lichess Swiss team. We organize regular swiss tournaments for all to join.","open":true,"leader":{"name":"Lichess","patron":true,"id":"lichess"},"leaders":[{"name":"NoJoke","patron":true,"id":"nojoke"},{"name":"thibault","patron":true,"id":"thibault"}],"nbMembers":376628,"joined":false,"requested":false}' 19 | headers: 20 | Access-Control-Allow-Headers: 21 | - Origin, Authorization, If-Modified-Since, Cache-Control, Content-Type 22 | Access-Control-Allow-Methods: 23 | - OPTIONS, GET, POST, PUT, DELETE 24 | Access-Control-Allow-Origin: 25 | - '*' 26 | Connection: 27 | - keep-alive 28 | Content-Type: 29 | - application/json 30 | Date: 31 | - Fri, 04 Aug 2023 18:31:57 GMT 32 | Permissions-Policy: 33 | - interest-cohort=() 34 | Server: 35 | - nginx 36 | Strict-Transport-Security: 37 | - max-age=63072000; includeSubDomains; preload 38 | Transfer-Encoding: 39 | - chunked 40 | Vary: 41 | - Origin 42 | X-Frame-Options: 43 | - DENY 44 | content-length: 45 | - '378' 46 | status: 47 | code: 200 48 | message: OK 49 | version: 1 50 | -------------------------------------------------------------------------------- /tests/clients/cassettes/test_tournaments/TestLichessGames.test_arenas_result.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.31.0 13 | method: GET 14 | uri: https://lichess.org/api/tournament/hallow23/results?nb=3&sheet=False 15 | response: 16 | body: 17 | string: '{"rank":1,"score":149,"rating":2394,"username":"GGbers","flair":"activity.lichess-variant-king-of-the-hill","performance":2449} 18 | 19 | {"rank":2,"score":124,"rating":2346,"username":"Kondor75","title":"IM","performance":2447} 20 | 21 | {"rank":3,"score":123,"rating":2241,"username":"sadisticTushi","flair":"food-drink.french-fries","performance":2314} 22 | 23 | ' 24 | headers: 25 | Access-Control-Allow-Headers: 26 | - Origin, Authorization, If-Modified-Since, Cache-Control, Content-Type 27 | Access-Control-Allow-Methods: 28 | - OPTIONS, GET, POST, PUT, DELETE 29 | Access-Control-Allow-Origin: 30 | - '*' 31 | Connection: 32 | - keep-alive 33 | Content-Disposition: 34 | - attachment; filename=lichess_tournament_2023.10.31_hallow23_halloween-arena-2023.ndjson 35 | Content-Type: 36 | - application/x-ndjson 37 | Date: 38 | - Mon, 01 Apr 2024 12:26:53 GMT 39 | Permissions-Policy: 40 | - interest-cohort=() 41 | Server: 42 | - nginx 43 | Strict-Transport-Security: 44 | - max-age=63072000; includeSubDomains; preload 45 | Transfer-Encoding: 46 | - chunked 47 | Vary: 48 | - Origin 49 | X-Frame-Options: 50 | - DENY 51 | status: 52 | code: 200 53 | message: OK 54 | version: 1 55 | -------------------------------------------------------------------------------- /tests/clients/cassettes/test_tournaments/TestLichessGames.test_arenas_result_with_sheet.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.31.0 13 | method: GET 14 | uri: https://lichess.org/api/tournament/hallow23/results?nb=3&sheet=True 15 | response: 16 | body: 17 | string: '{"rank":1,"score":149,"rating":2394,"username":"GGbers","flair":"activity.lichess-variant-king-of-the-hill","performance":2449,"sheet":{"scores":"55330000533054533003305555555445555555330330330"}} 18 | 19 | {"rank":2,"score":124,"rating":2346,"username":"Kondor75","title":"IM","performance":2447,"sheet":{"scores":"44332544522100445230554555445555330202"}} 20 | 21 | {"rank":3,"score":123,"rating":2241,"username":"sadisticTushi","flair":"food-drink.french-fries","performance":2314,"sheet":{"scores":"22042202022044444220200444444442204422044220422020042210"}} 22 | 23 | ' 24 | headers: 25 | Access-Control-Allow-Headers: 26 | - Origin, Authorization, If-Modified-Since, Cache-Control, Content-Type 27 | Access-Control-Allow-Methods: 28 | - OPTIONS, GET, POST, PUT, DELETE 29 | Access-Control-Allow-Origin: 30 | - '*' 31 | Connection: 32 | - keep-alive 33 | Content-Disposition: 34 | - attachment; filename=lichess_tournament_2023.10.31_hallow23_halloween-arena-2023.ndjson 35 | Content-Type: 36 | - application/x-ndjson 37 | Date: 38 | - Mon, 01 Apr 2024 12:30:31 GMT 39 | Permissions-Policy: 40 | - interest-cohort=() 41 | Server: 42 | - nginx 43 | Strict-Transport-Security: 44 | - max-age=63072000; includeSubDomains; preload 45 | Transfer-Encoding: 46 | - chunked 47 | Vary: 48 | - Origin 49 | X-Frame-Options: 50 | - DENY 51 | status: 52 | code: 200 53 | message: OK 54 | version: 1 55 | -------------------------------------------------------------------------------- /tests/clients/cassettes/test_tournaments/TestLichessGames.test_swiss_result.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.31.0 13 | method: GET 14 | uri: https://lichess.org/api/swiss/ADAHHiMX/results?nb=3 15 | response: 16 | body: 17 | string: '{"rank":1,"points":6,"tieBreak":20.5,"rating":2150,"username":"MorphyTal2018","performance":2390} 18 | 19 | {"rank":2,"points":6,"tieBreak":19.75,"rating":2286,"username":"I_Eat_Chess","performance":2459} 20 | 21 | {"rank":3,"points":5.5,"tieBreak":16.5,"rating":2309,"username":"Prajurit_CSI","performance":2352} 22 | 23 | ' 24 | headers: 25 | Access-Control-Allow-Headers: 26 | - Origin, Authorization, If-Modified-Since, Cache-Control, Content-Type 27 | Access-Control-Allow-Methods: 28 | - OPTIONS, GET, POST, PUT, DELETE 29 | Access-Control-Allow-Origin: 30 | - '*' 31 | Connection: 32 | - keep-alive 33 | Content-Disposition: 34 | - attachment; filename=lichess_swiss_2023.10.31_ADAHHiMX_rapid.ndjson 35 | Content-Type: 36 | - application/x-ndjson 37 | Date: 38 | - Tue, 31 Oct 2023 22:09:05 GMT 39 | Permissions-Policy: 40 | - interest-cohort=() 41 | Server: 42 | - nginx 43 | Strict-Transport-Security: 44 | - max-age=63072000; includeSubDomains; preload 45 | Transfer-Encoding: 46 | - chunked 47 | Vary: 48 | - Origin 49 | X-Frame-Options: 50 | - DENY 51 | status: 52 | code: 200 53 | message: OK 54 | version: 1 55 | -------------------------------------------------------------------------------- /tests/clients/cassettes/test_tournaments/TestLichessGames.test_team_standings.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.31.0 13 | method: GET 14 | uri: https://lichess.org/api/tournament/Qv0dRqml/teams 15 | response: 16 | body: 17 | string: '{"id":"Qv0dRqml","teams":[{"rank":1,"id":"EAtFBeZ8","score":230,"players":[{"user":{"name":"vladtok","id":"vladtok"},"score":38},{"user":{"name":"DVlad","id":"dvlad"},"score":31},{"user":{"name":"DmitryK1","title":"FM","id":"dmitryk1"},"score":30},{"user":{"name":"Jhonsmit","id":"jhonsmit"},"score":27},{"user":{"name":"Yacek1111","id":"yacek1111"},"score":21},{"user":{"name":"Hard_Utilizator","title":"FM","id":"hard_utilizator"},"score":19},{"user":{"name":"Andrey-1-razryd","id":"andrey-1-razryd"},"score":18},{"user":{"name":"GuruDomino","id":"gurudomino"},"score":17},{"user":{"name":"D_L88","id":"d_l88"},"score":17},{"user":{"name":"kvmikhed","id":"kvmikhed"},"score":12}]},{"rank":2,"id":"guillon-chess","score":210,"players":[{"user":{"name":"Alexr58","title":"IM","id":"alexr58"},"score":33},{"user":{"name":"BaleevK","id":"baleevk"},"score":26},{"user":{"name":"offspring1476","id":"offspring1476"},"score":25},{"user":{"name":"denis-rucoba-tuanama","id":"denis-rucoba-tuanama"},"score":23},{"user":{"name":"jmcapoi","id":"jmcapoi"},"score":21},{"user":{"name":"borisv007","id":"borisv007"},"score":21},{"user":{"name":"Eribertoperez","id":"eribertoperez"},"score":17},{"user":{"name":"Robbertus","id":"robbertus"},"score":15},{"user":{"name":"FX-DANGER","id":"fx-danger"},"score":15},{"user":{"name":"mjuanchini","id":"mjuanchini"},"score":14}]},{"rank":3,"id":"bZ9dPpbL","score":201,"players":[{"user":{"name":"Wollukav","id":"wollukav"},"score":42},{"user":{"name":"Konek_Gorbunok","title":"FM","id":"konek_gorbunok"},"score":34},{"user":{"name":"RichardRapportGOAT","id":"richardrapportgoat"},"score":29},{"user":{"name":"zadvinski","id":"zadvinski"},"score":24},{"user":{"name":"stasOR","title":"FM","id":"stasor"},"score":22},{"user":{"name":"Tatschess","id":"tatschess"},"score":15},{"user":{"name":"IZhuk","id":"izhuk"},"score":12},{"user":{"name":"cot3","id":"cot3"},"score":10},{"user":{"name":"Rassvet","id":"rassvet"},"score":7},{"user":{"name":"Denisik_Sergei","id":"denisik_sergei"},"score":6}]},{"rank":4,"id":"kingkondor--friends","score":195,"players":[{"user":{"name":"Lozhnonozhka","id":"lozhnonozhka"},"score":38},{"user":{"name":"Igrok_V_Proshlom","title":"FM","id":"igrok_v_proshlom"},"score":35},{"user":{"name":"CblBOPOTKA","id":"cblbopotka"},"score":21},{"user":{"name":"Advokat343","id":"advokat343"},"score":20},{"user":{"name":"Chelcity","id":"chelcity"},"score":19},{"user":{"name":"KingKondor","id":"kingkondor"},"score":16},{"user":{"name":"Rapid2021","id":"rapid2021"},"score":15},{"user":{"name":"RomanIlyin","id":"romanilyin"},"score":14},{"user":{"name":"Andrew_Cc","id":"andrew_cc"},"score":11},{"user":{"name":"Devastator999","id":"devastator999"},"score":6}]},{"rank":5,"id":"fm-andro-team","score":192,"players":[{"user":{"name":"VLAJKO18","id":"vlajko18"},"score":27},{"user":{"name":"Mesatr","id":"mesatr"},"score":27},{"user":{"name":"MK_Juic_R","id":"mk_juic_r"},"score":26},{"user":{"name":"Divos","id":"divos"},"score":24},{"user":{"name":"BlixLT","id":"blixlt"},"score":24},{"user":{"name":"Crni_Konj","id":"crni_konj"},"score":16},{"user":{"name":"Nezh2","id":"nezh2"},"score":16},{"user":{"name":"bujka","id":"bujka"},"score":12},{"user":{"name":"erroras","id":"erroras"},"score":12},{"user":{"name":"Merzo19","id":"merzo19"},"score":8}]},{"rank":6,"id":"rochade-europa-schachzeitung","score":187,"players":[{"user":{"name":"Karlo0300","id":"karlo0300"},"score":28},{"user":{"name":"RapidHector","id":"rapidhector"},"score":28},{"user":{"name":"jeffforever","title":"FM","patron":true,"id":"jeffforever"},"score":24},{"user":{"name":"Apo-Wuff","id":"apo-wuff"},"score":20},{"user":{"name":"GORA-70","id":"gora-70"},"score":19},{"user":{"name":"Coolplay","id":"coolplay"},"score":17},{"user":{"name":"Birsch","id":"birsch"},"score":16},{"user":{"name":"Springteufel","id":"springteufel"},"score":13},{"user":{"name":"a4crest","id":"a4crest"},"score":11},{"user":{"name":"A-HF","id":"a-hf"},"score":11}]},{"rank":7,"id":"ulugbek-company","score":179,"players":[{"user":{"name":"Shihaliev_O","id":"shihaliev_o"},"score":29},{"user":{"name":"turkmenchess2023","id":"turkmenchess2023"},"score":28},{"user":{"name":"umatyakubow1977","id":"umatyakubow1977"},"score":19},{"user":{"name":"Bayramogly1975","id":"bayramogly1975"},"score":18},{"user":{"name":"HG811137AH","id":"hg811137ah"},"score":17},{"user":{"name":"Aymakowa-Mahri","id":"aymakowa-mahri"},"score":16},{"user":{"name":"BayramowBayram","id":"bayramowbayram"},"score":16},{"user":{"name":"chesslbpgm","id":"chesslbpgm"},"score":15},{"user":{"name":"suleymantmdz","id":"suleymantmdz"},"score":11},{"user":{"name":"Ezizovjumamuhammet","id":"ezizovjumamuhammet"},"score":10}]},{"rank":8,"id":"euskal-herria-combinado-vasco-navarro-on-line","score":143,"players":[{"user":{"name":"Edusanzna","id":"edusanzna"},"score":21},{"user":{"name":"ZB_G","id":"zb_g"},"score":19},{"user":{"name":"mikelbenaito","id":"mikelbenaito"},"score":18},{"user":{"name":"GrosXakeTaldea","id":"grosxaketaldea"},"score":16},{"user":{"name":"Kepaketon","id":"kepaketon"},"score":16},{"user":{"name":"DiablucoChess","patron":true,"id":"diablucochess"},"score":14},{"user":{"name":"Athletic_club","id":"athletic_club"},"score":14},{"user":{"name":"Serantes","id":"serantes"},"score":10},{"user":{"name":"ZB_IC","id":"zb_ic"},"score":9},{"user":{"name":"glchakal","id":"glchakal"},"score":6}]},{"rank":9,"id":"schach-club-kreuzberg-e-v","score":119,"players":[{"user":{"name":"bert6209","id":"bert6209"},"score":35},{"user":{"name":"Drunkenstyle36","id":"drunkenstyle36"},"score":23},{"user":{"name":"zonk123","id":"zonk123"},"score":16},{"user":{"name":"Minfrad","id":"minfrad"},"score":15},{"user":{"name":"libby1","id":"libby1"},"score":8},{"user":{"name":"schoasch","patron":true,"id":"schoasch"},"score":8},{"user":{"name":"hopfrog64","id":"hopfrog64"},"score":8},{"user":{"name":"DavidMerck","id":"davidmerck"},"score":2},{"user":{"name":"LTH1","id":"lth1"},"score":2},{"user":{"name":"hiroki6","id":"hiroki6"},"score":2}]},{"rank":10,"id":"moscow-karpov-school","score":102,"players":[{"user":{"name":"SuperLoop","id":"superloop"},"score":30},{"user":{"name":"Fotty_1338","id":"fotty_1338"},"score":27},{"user":{"name":"Pes_V_Sapogah","id":"pes_v_sapogah"},"score":19},{"user":{"name":"clash-of-clans-01","id":"clash-of-clans-01"},"score":13},{"user":{"name":"Timon89","id":"timon89"},"score":13},{"user":{"name":"Grizzly51","id":"grizzly51"},"score":0}]}]}' 18 | headers: 19 | Access-Control-Allow-Headers: 20 | - Origin, Authorization, If-Modified-Since, Cache-Control, Content-Type 21 | Access-Control-Allow-Methods: 22 | - OPTIONS, GET, POST, PUT, DELETE 23 | Access-Control-Allow-Origin: 24 | - '*' 25 | Connection: 26 | - keep-alive 27 | Content-Type: 28 | - application/json 29 | Date: 30 | - Thu, 02 Nov 2023 21:25:59 GMT 31 | Permissions-Policy: 32 | - interest-cohort=() 33 | Server: 34 | - nginx 35 | Strict-Transport-Security: 36 | - max-age=63072000; includeSubDomains; preload 37 | Transfer-Encoding: 38 | - chunked 39 | Vary: 40 | - Origin 41 | X-Frame-Options: 42 | - DENY 43 | content-length: 44 | - '6450' 45 | status: 46 | code: 200 47 | message: OK 48 | version: 1 49 | -------------------------------------------------------------------------------- /tests/clients/cassettes/test_users/TestLichessGames.test_get_by_autocomplete.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/vnd.lichess.v3+json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.31.0 13 | method: GET 14 | uri: https://lichess.org/api/player/autocomplete?term=thisisatest&object=False&friend=False 15 | response: 16 | body: 17 | string: '["thisisatest","thisisatesty","thisisatest24","thisisatesttt","thisisatest333","thisisatest666","thisisatest1234","thisisatest8083","thisisatest12345","thisisatestlololol","thisisatestaccount13"]' 18 | headers: 19 | Access-Control-Allow-Headers: 20 | - Origin, Authorization, If-Modified-Since, Cache-Control, Content-Type 21 | Access-Control-Allow-Methods: 22 | - OPTIONS, GET, POST, PUT, DELETE 23 | Access-Control-Allow-Origin: 24 | - '*' 25 | Connection: 26 | - keep-alive 27 | Content-Type: 28 | - application/json 29 | Date: 30 | - Sat, 28 Oct 2023 17:17:24 GMT 31 | Permissions-Policy: 32 | - interest-cohort=() 33 | Server: 34 | - nginx 35 | Strict-Transport-Security: 36 | - max-age=63072000; includeSubDomains; preload 37 | Transfer-Encoding: 38 | - chunked 39 | Vary: 40 | - Origin 41 | X-Frame-Options: 42 | - DENY 43 | content-length: 44 | - '195' 45 | status: 46 | code: 200 47 | message: OK 48 | version: 1 49 | -------------------------------------------------------------------------------- /tests/clients/cassettes/test_users/TestLichessGames.test_get_by_autocomplete_as_object.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/vnd.lichess.v3+json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.31.0 13 | method: GET 14 | uri: https://lichess.org/api/player/autocomplete?term=thisisatest&object=True&friend=False 15 | response: 16 | body: 17 | string: '{"result":[{"name":"Thisisatest","id":"thisisatest"},{"name":"thisisatesty","id":"thisisatesty"},{"name":"thisisatest24","id":"thisisatest24"},{"name":"Thisisatesttt","id":"thisisatesttt"},{"name":"thisisatest333","id":"thisisatest333"},{"name":"thisisatest666","id":"thisisatest666"},{"name":"Thisisatest1234","id":"thisisatest1234"},{"name":"thisisatest8083","id":"thisisatest8083"},{"name":"ThisIsATest12345","id":"thisisatest12345"},{"name":"thisisatestlololol","id":"thisisatestlololol"},{"name":"thisisatestaccount13","id":"thisisatestaccount13"}]}' 18 | headers: 19 | Access-Control-Allow-Headers: 20 | - Origin, Authorization, If-Modified-Since, Cache-Control, Content-Type 21 | Access-Control-Allow-Methods: 22 | - OPTIONS, GET, POST, PUT, DELETE 23 | Access-Control-Allow-Origin: 24 | - '*' 25 | Connection: 26 | - keep-alive 27 | Content-Type: 28 | - application/json 29 | Date: 30 | - Sat, 28 Oct 2023 17:17:23 GMT 31 | Permissions-Policy: 32 | - interest-cohort=() 33 | Server: 34 | - nginx 35 | Strict-Transport-Security: 36 | - max-age=63072000; includeSubDomains; preload 37 | Transfer-Encoding: 38 | - chunked 39 | Vary: 40 | - Origin 41 | X-Frame-Options: 42 | - DENY 43 | content-length: 44 | - '554' 45 | status: 46 | code: 200 47 | message: OK 48 | version: 1 49 | -------------------------------------------------------------------------------- /tests/clients/cassettes/test_users/TestLichessGames.test_get_by_autocomplete_as_object_not_found.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/vnd.lichess.v3+json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.31.0 13 | method: GET 14 | uri: https://lichess.org/api/player/autocomplete?term=username_not_found__&object=True&friend=False 15 | response: 16 | body: 17 | string: '{"result":[]}' 18 | headers: 19 | Access-Control-Allow-Headers: 20 | - Origin, Authorization, If-Modified-Since, Cache-Control, Content-Type 21 | Access-Control-Allow-Methods: 22 | - OPTIONS, GET, POST, PUT, DELETE 23 | Access-Control-Allow-Origin: 24 | - '*' 25 | Connection: 26 | - keep-alive 27 | Content-Length: 28 | - '13' 29 | Content-Type: 30 | - application/json 31 | Date: 32 | - Sat, 28 Oct 2023 19:26:35 GMT 33 | Permissions-Policy: 34 | - interest-cohort=() 35 | Server: 36 | - nginx 37 | Strict-Transport-Security: 38 | - max-age=63072000; includeSubDomains; preload 39 | Vary: 40 | - Origin 41 | X-Frame-Options: 42 | - DENY 43 | status: 44 | code: 200 45 | message: OK 46 | version: 1 47 | -------------------------------------------------------------------------------- /tests/clients/cassettes/test_users/TestLichessGames.test_get_by_autocomplete_not_found.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - application/vnd.lichess.v3+json 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - python-requests/2.31.0 13 | method: GET 14 | uri: https://lichess.org/api/player/autocomplete?term=username_not_found__&object=False&friend=False 15 | response: 16 | body: 17 | string: '[]' 18 | headers: 19 | Access-Control-Allow-Headers: 20 | - Origin, Authorization, If-Modified-Since, Cache-Control, Content-Type 21 | Access-Control-Allow-Methods: 22 | - OPTIONS, GET, POST, PUT, DELETE 23 | Access-Control-Allow-Origin: 24 | - '*' 25 | Connection: 26 | - keep-alive 27 | Content-Length: 28 | - '2' 29 | Content-Type: 30 | - application/json 31 | Date: 32 | - Sat, 28 Oct 2023 19:26:34 GMT 33 | Permissions-Policy: 34 | - interest-cohort=() 35 | Server: 36 | - nginx 37 | Strict-Transport-Security: 38 | - max-age=63072000; includeSubDomains; preload 39 | Vary: 40 | - Origin 41 | X-Frame-Options: 42 | - DENY 43 | status: 44 | code: 200 45 | message: OK 46 | version: 1 47 | -------------------------------------------------------------------------------- /tests/clients/test_analysis.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from berserk import Client 4 | 5 | from berserk.types.analysis import PositionEvaluation 6 | from utils import validate, skip_if_older_3_dot_10 7 | 8 | 9 | class TestAnalysis: 10 | @skip_if_older_3_dot_10 11 | @pytest.mark.vcr 12 | def test_get_cloud_evaluation(self): 13 | res = Client().analysis.get_cloud_evaluation( 14 | fen="rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR b KQkq - 0 2", 15 | ) 16 | validate(PositionEvaluation, res) 17 | -------------------------------------------------------------------------------- /tests/clients/test_fide.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from typing import List 3 | from berserk import Client 4 | from berserk.types.fide import FidePlayer 5 | from utils import validate, skip_if_older_3_dot_10 6 | 7 | 8 | class TestFide: 9 | @skip_if_older_3_dot_10 10 | @pytest.mark.vcr 11 | def test_search_players(self): 12 | res = Client().fide.search_players("Erigaisi") 13 | validate(List[FidePlayer], res) 14 | 15 | @skip_if_older_3_dot_10 16 | @pytest.mark.vcr 17 | def test_get_player(self): 18 | res = Client().fide.get_player(35009192) 19 | validate(FidePlayer, res) 20 | assert res["name"] == "Erigaisi Arjun" 21 | -------------------------------------------------------------------------------- /tests/clients/test_opening_explorer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests_mock 3 | 4 | from berserk import Client, OpeningStatistic 5 | 6 | from utils import validate, skip_if_older_3_dot_10 7 | 8 | 9 | class TestLichessGames: 10 | @skip_if_older_3_dot_10 11 | @pytest.mark.vcr 12 | def test_result(self): 13 | """Verify that the response matches the typed-dict""" 14 | res = Client().opening_explorer.get_lichess_games( 15 | variant="standard", 16 | speeds=["blitz", "rapid", "classical"], 17 | ratings=["2200", "2500"], 18 | position="rnbqkbnr/ppp2ppp/8/3pp3/4P3/2NP4/PPP2PPP/R1BQKBNR b KQkq - 0 1", 19 | ) 20 | validate(OpeningStatistic, res) 21 | 22 | def test_correct_speed_params(self): 23 | """The test verify that speeds parameter are passed correctly in query params""" 24 | with requests_mock.Mocker() as m: 25 | m.get( 26 | "https://explorer.lichess.ovh/lichess?variant=standard&speeds=rapid%2Cclassical", 27 | json={}, 28 | ) 29 | Client().opening_explorer.get_lichess_games(speeds=["rapid", "classical"]) 30 | 31 | def test_correct_rating_params(self): 32 | """The test verify that ratings parameter are passed correctly in query params""" 33 | with requests_mock.Mocker() as m: 34 | m.get( 35 | "https://explorer.lichess.ovh/lichess?variant=standard&ratings=1200%2C1400", 36 | json={}, 37 | ) 38 | Client().opening_explorer.get_lichess_games(ratings=["1200", "1400"]) 39 | 40 | 41 | class TestMasterGames: 42 | @pytest.mark.vcr 43 | def test_result(self): 44 | res = Client().opening_explorer.get_masters_games( 45 | play=["d2d4", "d7d5", "c2c4", "c7c6", "c4d5"] 46 | ) 47 | assert res["white"] == 1667 48 | assert res["black"] == 1300 49 | assert res["draws"] == 4428 50 | 51 | @pytest.mark.vcr 52 | def test_export(self): 53 | res = Client().opening_explorer.get_otb_master_game("LSVO85Cp") 54 | assert ( 55 | res 56 | == """[Event "3rd Norway Chess 2015"] 57 | [Site "Stavanger NOR"] 58 | [Date "2015.06.17"] 59 | [Round "2.4"] 60 | [White "Caruana, F."] 61 | [Black "Carlsen, M."] 62 | [Result "1-0"] 63 | [WhiteElo "2805"] 64 | [BlackElo "2876"] 65 | 66 | 1. e4 e5 2. Nf3 Nc6 3. Bb5 Nf6 4. O-O Nxe4 5. d4 Nd6 6. Bxc6 dxc6 7. dxe5 Nf5 8. Qxd8+ Kxd8 9. h3 h6 10. Rd1+ Ke8 11. Nc3 Ne7 12. b3 Bf5 13. Nd4 Bh7 14. Bb2 Rd8 15. Nce2 Nd5 16. c4 Nb4 17. Nf4 Rg8 18. g4 Na6 19. Nf5 Nc5 20. Rxd8+ Kxd8 21. Rd1+ Kc8 22. Ba3 Ne6 23. Nxe6 Bxa3 24. Nexg7 Bf8 25. e6 Bxf5 26. Nxf5 fxe6 27. Ng3 Be7 28. Kg2 Rf8 29. Rd3 Rf7 30. Nh5 Bd6 31. Rf3 Rh7 32. Re3 Re7 33. f4 Ba3 34. Kf3 Bb2 35. Re2 Bc3 36. g5 Kd7 37. Kg4 Re8 38. Ng3 Rh8 39. h4 b6 40. h5 c5 41. g6 Re8 42. f5 exf5+ 43. Kf4 Rh8 44. Nxf5 Bf6 45. Rg2 1-0 67 | """ 68 | ) 69 | 70 | 71 | class TestPlayerGames: 72 | @pytest.mark.vcr 73 | @pytest.mark.default_cassette("TestPlayerGames.results.yaml") 74 | def test_wait_for_last_results(self): 75 | result = Client().opening_explorer.get_player_games( 76 | player="evachesss", color="white", wait_for_indexing=True 77 | ) 78 | assert result["white"] == 125 79 | assert result["draws"] == 18 80 | assert result["black"] == 133 81 | 82 | @pytest.mark.vcr 83 | @pytest.mark.default_cassette("TestPlayerGames.results.yaml") 84 | def test_get_first_result_available(self): 85 | result = Client().opening_explorer.get_player_games( 86 | player="evachesss", 87 | color="white", 88 | wait_for_indexing=False, 89 | ) 90 | assert result == { 91 | "white": 0, 92 | "draws": 0, 93 | "black": 0, 94 | "moves": [], 95 | "recentGames": [], 96 | "opening": None, 97 | "queuePosition": 0, 98 | } 99 | 100 | @pytest.mark.vcr 101 | @pytest.mark.default_cassette("TestPlayerGames.results.yaml") 102 | def test_stream(self): 103 | result = list( 104 | Client().opening_explorer.stream_player_games( 105 | player="evachesss", 106 | color="white", 107 | ) 108 | ) 109 | assert result[0] == { 110 | "white": 0, 111 | "draws": 0, 112 | "black": 0, 113 | "moves": [], 114 | "recentGames": [], 115 | "opening": None, 116 | "queuePosition": 0, 117 | } 118 | assert result[-1]["white"] == 125 119 | assert result[-1]["draws"] == 18 120 | assert result[-1]["black"] == 133 121 | -------------------------------------------------------------------------------- /tests/clients/test_teams.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from berserk import Client, Team, PaginatedTeams 4 | from typing import List 5 | from utils import validate, skip_if_older_3_dot_10 6 | 7 | 8 | class TestLichessGames: 9 | @skip_if_older_3_dot_10 10 | @pytest.mark.vcr 11 | def test_get_team(self): 12 | res = Client().teams.get_team("lichess-swiss") 13 | validate(Team, res) 14 | 15 | @skip_if_older_3_dot_10 16 | @pytest.mark.vcr 17 | def test_teams_of_player(self): 18 | res = Client().teams.teams_of_player("Lichess") 19 | validate(List[Team], res) 20 | 21 | @skip_if_older_3_dot_10 22 | @pytest.mark.vcr 23 | def test_get_popular(self): 24 | res = Client().teams.get_popular() 25 | validate(PaginatedTeams, res) 26 | 27 | @skip_if_older_3_dot_10 28 | @pytest.mark.vcr 29 | def test_search(self): 30 | res = Client().teams.search("lichess") 31 | validate(PaginatedTeams, res) 32 | -------------------------------------------------------------------------------- /tests/clients/test_tournaments.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from berserk import ArenaResult, Client, SwissResult 4 | from typing import List 5 | 6 | from berserk.types.tournaments import TeamBattleResult 7 | from utils import validate, skip_if_older_3_dot_10 8 | 9 | 10 | class TestLichessGames: 11 | @skip_if_older_3_dot_10 12 | @pytest.mark.vcr 13 | def test_swiss_result(self): 14 | res = list(Client().tournaments.stream_swiss_results("ADAHHiMX", limit=3)) 15 | validate(List[SwissResult], res) 16 | 17 | @skip_if_older_3_dot_10 18 | @pytest.mark.vcr 19 | def test_arenas_result(self): 20 | res = list(Client().tournaments.stream_results("hallow23", limit=3)) 21 | validate(List[ArenaResult], res) 22 | 23 | @skip_if_older_3_dot_10 24 | @pytest.mark.vcr 25 | def test_arenas_result_with_sheet(self): 26 | res = list(Client().tournaments.stream_results("hallow23", sheet=True, limit=3)) 27 | validate(List[ArenaResult], res) 28 | 29 | @skip_if_older_3_dot_10 30 | @pytest.mark.vcr 31 | def test_team_standings(self): 32 | res = Client().tournaments.get_team_standings("Qv0dRqml") 33 | validate(TeamBattleResult, res) 34 | -------------------------------------------------------------------------------- /tests/clients/test_users.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from berserk import Client, OnlineLightUser 4 | from typing import List, Dict 5 | from utils import validate, skip_if_older_3_dot_10 6 | 7 | 8 | class TestLichessGames: 9 | @skip_if_older_3_dot_10 10 | @pytest.mark.vcr 11 | def test_get_by_autocomplete_as_object(self): 12 | res = Client().users.get_by_autocomplete("thisisatest", as_object=True) 13 | validate(List[OnlineLightUser], res) 14 | 15 | @skip_if_older_3_dot_10 16 | @pytest.mark.vcr 17 | def test_get_by_autocomplete(self): 18 | res = Client().users.get_by_autocomplete("thisisatest") 19 | validate(List[str], res) 20 | 21 | @skip_if_older_3_dot_10 22 | @pytest.mark.vcr 23 | def test_get_by_autocomplete_not_found(self): 24 | res = Client().users.get_by_autocomplete("username_not_found__") 25 | validate(List[str], res) 26 | 27 | @skip_if_older_3_dot_10 28 | @pytest.mark.vcr 29 | def test_get_by_autocomplete_as_object_not_found(self): 30 | res = Client().users.get_by_autocomplete("username_not_found__", as_object=True) 31 | validate(List[OnlineLightUser], res) 32 | -------------------------------------------------------------------------------- /tests/clients/utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pprint 3 | import sys 4 | 5 | from pydantic import TypeAdapter, ConfigDict, PydanticUserError 6 | 7 | 8 | def skip_if_older_3_dot_10(fn): 9 | return pytest.mark.skipif( 10 | sys.version_info < (3, 10), 11 | reason="Too many incompatibilities with type hint", 12 | )(fn) 13 | 14 | 15 | def validate(t: type, value: any): 16 | config = ConfigDict(strict=True, extra="forbid") 17 | 18 | class TWithConfig(t): 19 | __pydantic_config__ = config 20 | 21 | print("value") 22 | pprint.PrettyPrinter(indent=2).pprint(value) 23 | try: 24 | # In case `t` is a `TypedDict` 25 | return TypeAdapter(TWithConfig).validate_python(value) 26 | except PydanticUserError as exc_info: 27 | # In case `t` is a composition of `TypedDict`, like `list[TypedDict]` 28 | if exc_info.code == "schema-for-unknown-type": 29 | return TypeAdapter(t, config=config).validate_python(value) 30 | else: 31 | raise exc_info 32 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture(scope="module") 5 | def vcr_config(): 6 | return { 7 | "filter_headers": ["authorization"], 8 | "match_on": ["method", "scheme", "host", "port", "path", "query", "body"], 9 | "decode_compressed_response": True, 10 | } 11 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import requests_mock 2 | 3 | import berserk 4 | 5 | 6 | class TestTableBaseUrl: 7 | def test_default(self): 8 | client = berserk.Client() 9 | with requests_mock.Mocker() as m: 10 | m.get("https://tablebase.lichess.ovh/standard", json={}) 11 | client.tablebase.look_up("4k3/6KP/8/8/8/8/7p/8_w_-_-_0_1") 12 | 13 | def test_overight_url(self): 14 | client = berserk.Client(tablebase_url="https://my-tablebase.com") 15 | with requests_mock.Mocker() as m: 16 | m.get("https://my-tablebase.com/standard", json={}) 17 | client.tablebase.look_up("4k3/6KP/8/8/8/8/7p/8_w_-_-_0_1") 18 | -------------------------------------------------------------------------------- /tests/test_e2e.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lichess-org/berserk/4148d3fe0ad4f7f331012640c187e3a3b1ff2eeb/tests/test_e2e.py -------------------------------------------------------------------------------- /tests/test_formats.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from berserk import formats as fmts 4 | 5 | 6 | def test_base_headers(): 7 | fmt = fmts.FormatHandler("foo") 8 | assert fmt.headers == {"Accept": "foo"} 9 | 10 | 11 | def test_base_handle(): 12 | fmt = fmts.FormatHandler("foo") 13 | fmt.parse = mock.Mock(return_value="bar") 14 | fmt.parse_stream = mock.Mock() 15 | 16 | result = fmt.handle("baz", is_stream=False) 17 | assert result == "bar" 18 | assert fmt.parse_stream.call_count == 0 19 | 20 | 21 | def test_base_handle_stream(): 22 | fmt = fmts.FormatHandler("foo") 23 | fmt.parse = mock.Mock() 24 | fmt.parse_stream = mock.Mock(return_value="bar") 25 | 26 | result = fmt.handle("baz", is_stream=True) 27 | assert list(result) == list("bar") 28 | assert fmt.parse.call_count == 0 29 | 30 | 31 | def test_json_handler_parse(): 32 | fmt = fmts.JsonHandler("foo") 33 | m_response = mock.Mock() 34 | m_response.json.return_value = "bar" 35 | 36 | result = fmt.parse(m_response) 37 | assert result == "bar" 38 | 39 | 40 | def test_json_handler_parse_stream(): 41 | fmt = fmts.JsonHandler("foo") 42 | m_response = mock.Mock() 43 | m_response.iter_lines.return_value = [b'{"x": 5}', b"", b'{"y": 3}'] 44 | 45 | result = fmt.parse_stream(m_response) 46 | assert list(result) == [{"x": 5}, {"y": 3}] 47 | 48 | 49 | def test_pgn_handler_parse(): 50 | fmt = fmts.PgnHandler() 51 | m_response = mock.Mock() 52 | m_response.text = "bar" 53 | 54 | result = fmt.parse(m_response) 55 | assert result == "bar" 56 | 57 | 58 | def test_pgn_handler_parse_stream(): 59 | fmt = fmts.PgnHandler() 60 | m_response = mock.Mock() 61 | m_response.iter_lines.return_value = [b"one", b"two", b"", b"", b"three"] 62 | 63 | result = fmt.parse_stream(m_response) 64 | assert list(result) == ["one\ntwo", "three"] 65 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from berserk import models, utils 2 | 3 | 4 | def test_conversion(): 5 | class Example(models.Model): 6 | foo = int 7 | 8 | original = {"foo": "5", "bar": 3, "baz": "4"} 9 | modified = {"foo": 5, "bar": 3, "baz": "4"} 10 | assert Example.convert(original) == modified 11 | 12 | 13 | def test_time_delta(): 14 | """test timedelta_from millis""" 15 | test_data = 1000.0 16 | dt1 = utils.datetime_from_millis(test_data) 17 | dt2 = utils.datetime_from_millis(2 * test_data) 18 | 19 | delta_1 = utils.timedelta_from_millis(test_data) 20 | 21 | # time delta dt1 dt2 22 | delta_2 = dt2 - dt1 23 | 24 | assert delta_1 == delta_2 25 | -------------------------------------------------------------------------------- /tests/test_session.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from berserk import session 6 | from berserk import utils 7 | 8 | 9 | def test_request(): 10 | m_session = mock.Mock() 11 | m_fmt = mock.Mock() 12 | requestor = session.Requestor(m_session, "http://foo.com/", m_fmt) 13 | 14 | result = requestor.request("bar", "path", baz="qux") 15 | 16 | assert result == m_fmt.handle.return_value 17 | 18 | args, kwargs = m_session.request.call_args 19 | assert ("bar", "http://foo.com/path") == args 20 | assert { 21 | "headers": m_fmt.headers, 22 | "baz": "qux", 23 | "data": None, 24 | "json": None, 25 | "params": None, 26 | "stream": False, 27 | } == kwargs 28 | 29 | args, kwargs = m_fmt.handle.call_args 30 | assert (m_session.request.return_value,) == args 31 | assert {"is_stream": False, "converter": utils.noop} == kwargs 32 | 33 | 34 | def test_bad_request(): 35 | m_session = mock.Mock() 36 | m_session.request.return_value.ok = False 37 | m_session.request.return_value.raise_for_status.side_effect = Exception() 38 | m_fmt = mock.Mock() 39 | requestor = session.Requestor(m_session, "http://foo.com/", m_fmt) 40 | 41 | with pytest.raises(Exception): 42 | requestor.request("bar", "path", baz="qux") 43 | 44 | 45 | def test_token_session(): 46 | token_session = session.TokenSession("foo") 47 | assert token_session.token == "foo" 48 | assert token_session.headers == {"Authorization": "Bearer foo"} 49 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import collections 3 | import pytest 4 | 5 | from berserk import utils, BroadcastPlayer 6 | 7 | Case = collections.namedtuple("Case", "dt seconds millis text") 8 | 9 | 10 | @pytest.fixture 11 | def time_case(): 12 | dt = datetime.datetime(2017, 12, 28, 23, 52, 30, tzinfo=datetime.timezone.utc) 13 | ts = dt.timestamp() 14 | return Case(dt, ts, ts * 1000, dt.isoformat()) 15 | 16 | 17 | def test_to_millis(time_case): 18 | assert utils.to_millis(time_case.dt) == time_case.millis 19 | 20 | 21 | def test_datetime_from_seconds(time_case): 22 | assert utils.datetime_from_seconds(time_case.seconds) == time_case.dt 23 | 24 | 25 | def test_datetime_from_millis(time_case): 26 | assert utils.datetime_from_millis(time_case.millis) == time_case.dt 27 | 28 | 29 | def test_datetime_from_str(time_case): 30 | assert utils.datetime_from_str(time_case.text) == time_case.dt 31 | 32 | 33 | def test_datetime_from_str2(): 34 | assert utils.datetime_from_str("2023-05-16T05:46:54.327313Z") == datetime.datetime( 35 | 2023, 5, 16, 5, 46, 54, 327313, tzinfo=datetime.timezone.utc 36 | ) 37 | 38 | 39 | def test_inner(): 40 | convert = utils.inner(lambda v: 2 * v, "x", "y") 41 | result = convert({"x": 42}) 42 | assert result == {"x": 84} 43 | 44 | 45 | def test_noop(): 46 | assert "foo" == utils.noop("foo") 47 | 48 | 49 | def test_broadcast_to_str(): 50 | mc: BroadcastPlayer = { 51 | "source_name": "DrNykterstein", 52 | "display_name": "Magnus Carlsen", 53 | "rating": 2863, 54 | } 55 | giri: BroadcastPlayer = { 56 | "source_name": "AnishGiri", 57 | "display_name": "Anish Giri", 58 | "rating": 2764, 59 | "title": "GM", 60 | } 61 | 62 | assert utils.to_str([mc]) == "DrNykterstein;Magnus Carlsen;2863" 63 | assert utils.to_str([giri]) == "AnishGiri;Anish Giri;2764;GM" 64 | assert ( 65 | utils.to_str([mc, giri]) 66 | == "DrNykterstein;Magnus Carlsen;2863\nAnishGiri;Anish Giri;2764;GM" 67 | ) 68 | 69 | 70 | @pytest.fixture 71 | def adapter_mapping(): 72 | return { 73 | "foo_bar": "foo.bar", 74 | "baz": "baz", 75 | "qux": "foo.qux", 76 | "quux": "foo.quux", 77 | "corgeGrault": "foo.corge.grault", 78 | "corgeGarply": "foo.corge.garply", 79 | } 80 | 81 | 82 | @pytest.fixture 83 | def data_to_adapt(): 84 | return { 85 | "foo": { 86 | "bar": "one", 87 | "qux": "three", 88 | "corge": {"grault": "four", "garply": None}, 89 | }, 90 | "baz": "two", 91 | } 92 | 93 | 94 | def test_adapt_with_fill(adapter_mapping, data_to_adapt): 95 | adapt = utils.build_adapter(adapter_mapping) 96 | default = object() 97 | assert adapt(data_to_adapt, fill=True, default=default) == { 98 | "foo_bar": "one", 99 | "baz": "two", 100 | "qux": "three", 101 | "quux": default, 102 | "corgeGrault": "four", 103 | "corgeGarply": None, 104 | } 105 | 106 | 107 | def test_adapt(adapter_mapping, data_to_adapt): 108 | adapt = utils.build_adapter(adapter_mapping) 109 | assert adapt(data_to_adapt) == { 110 | "foo_bar": "one", 111 | "baz": "two", 112 | "qux": "three", 113 | "corgeGrault": "four", 114 | "corgeGarply": None, 115 | } 116 | --------------------------------------------------------------------------------