├── .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 |
--------------------------------------------------------------------------------