├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── actions │ └── changelog │ │ ├── Dockerfile │ │ ├── action.yml │ │ ├── main.py │ │ └── requirements.txt └── workflows │ ├── changelog.yml │ ├── lint-test.yml │ ├── publish.yml │ ├── status-embed.yml │ └── todo.yml ├── .gitignore ├── .readthedocs.yml ├── CHANGELOG.rst ├── LICENSE ├── README.rst ├── async-requirements.txt ├── codingame ├── __init__.py ├── abc.py ├── clash_of_code.py ├── client │ ├── __init__.py │ ├── async_.py │ ├── base.py │ ├── client.py │ └── sync.py ├── codingamer.py ├── exceptions.py ├── http │ ├── __init__.py │ ├── async_.py │ ├── base.py │ ├── client.py │ ├── httperror.py │ └── sync.py ├── leaderboard.py ├── notification │ ├── __init__.py │ ├── data.py │ ├── enums.py │ └── notification.py ├── state.py ├── types │ ├── __init__.py │ ├── clash_of_code.py │ ├── codingamer.py │ └── notification.py └── utils.py ├── dev-requirements.txt ├── docs ├── Makefile ├── _static │ ├── chrome_cookie.png │ ├── codingame.png │ └── firefox_cookie.png ├── api.rst ├── changelog.rst ├── conf.py ├── extensions │ ├── og_tags.py │ └── resourcelinks.py ├── index.rst ├── make.bat ├── requirements.txt └── user_guide │ ├── intro.rst │ └── quickstart.rst ├── examples ├── example_clash_of_code.py ├── example_codingamer.py └── example_login.py ├── requirements.txt ├── scripts ├── format.sh ├── full-test.sh ├── lint-code.sh ├── lint-docs.sh ├── lint.sh └── test.sh ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── async │ ├── __init__.py │ ├── conftest.py │ ├── test_client.py │ ├── test_codingamer.py │ └── test_notification.py ├── conftest.py ├── mock │ └── responses │ │ ├── get_clash_of_code_from_handle.json │ │ ├── get_codingamer_from_handle.json │ │ ├── get_codingamer_from_id.json │ │ ├── get_language_ids.json │ │ ├── get_last_read_notifications.json │ │ ├── get_pending_clash_of_code.json │ │ ├── get_unread_notifications.json │ │ ├── get_unseen_notifications.json │ │ ├── login.json │ │ └── search.json └── sync │ ├── __init__.py │ ├── conftest.py │ ├── test_client.py │ ├── test_codingamer.py │ └── test_notification.py └── todo.md /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '[BUG] ' 5 | labels: 'bug' 6 | assignees: 'takos22' 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | A clear and concise description of what the bug is. 12 | 13 | ## To Reproduce 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | ## Expected behavior 21 | A clear and concise description of what you expected to happen. 22 | 23 | ## Screenshots 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | ## Environment information: 27 | - OS: [e.g. Windows 10] 28 | - Python version: [e.g. 3.7.6] 29 | - codingame module version: [e.g. 1.0.1] 30 | 31 | ## Additional context 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Security bug report 4 | url: https://mailxto.com/hibec0 5 | about: To avoid leaking the security issues, send me a mail at takos2210@gmail.com. 6 | - name: Need help? 7 | url: https://discord.gg/PGC3eAznJ6 8 | about: Join the discord support server 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[FR] ' 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Related problem 11 | A clear and concise description of a problem this feature request is related to. Ex. I'm always frustrated when [...] 12 | 13 | ## Wanted solution 14 | A clear and concise description of what you want to happen. 15 | 16 | ## Considered alternatives 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | ## Additional context 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/actions/changelog/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | COPY requirements.txt /requirements.txt 3 | RUN pip install -r /requirements.txt 4 | 5 | COPY main.py /main.py 6 | CMD ["python", "/main.py"] 7 | -------------------------------------------------------------------------------- /.github/actions/changelog/action.yml: -------------------------------------------------------------------------------- 1 | name: "Generate CHANGELOG.rst" 2 | description: "Generate the CHANGELOG.tst file from docs/changelog.rst" 3 | author: "Takos22 " 4 | inputs: 5 | token: 6 | description: "Token for the repo. Can be passed in using {{ secrets.GITHUB_TOKEN }}" 7 | required: true 8 | runs: 9 | using: "docker" 10 | image: "Dockerfile" 11 | -------------------------------------------------------------------------------- /.github/actions/changelog/main.py: -------------------------------------------------------------------------------- 1 | import re 2 | import traceback 3 | import typing 4 | 5 | from github import Github 6 | from pydantic import BaseSettings, SecretStr 7 | from sphobjinv import Inventory 8 | 9 | DOCS_BASE_URL = "https://codingame.readthedocs.io/en/" 10 | STDLIB_DOCS_BASE_URL = "https://docs.python.org/" 11 | 12 | ref_to_doc_branch = {"dev": "latest", "master": "stable"} 13 | roles = { 14 | "attr": "attribute", 15 | "meth": "method", 16 | "exc": "exception", 17 | } 18 | refs = { 19 | "async": ( 20 | "Asynchronous client", 21 | "user_guide/quickstart.html#about-the-asynchronous-client", 22 | ), 23 | "login": ( 24 | "Login", 25 | "user_guide/quickstart.html#login", 26 | ), 27 | } 28 | 29 | 30 | class Settings(BaseSettings): 31 | input_token: SecretStr 32 | github_repository: str 33 | github_ref: str 34 | github_ref_name: str 35 | 36 | 37 | def main(): 38 | settings = Settings(_env_file=".github/actions/changelog/.env") 39 | github = Github(settings.input_token.get_secret_value()) 40 | repo = github.get_repo(settings.github_repository) 41 | docs_changelog = repo.get_contents( 42 | "docs/changelog.rst", settings.github_ref.split("/")[-1] 43 | ) 44 | log("debug", f"docs/changelog.rst at {settings.github_ref_name} downloaded") 45 | 46 | docs_url = ( 47 | DOCS_BASE_URL 48 | + ( 49 | settings.github_ref_name 50 | if settings.github_ref_name.startswith("v") 51 | else ref_to_doc_branch.get(settings.github_ref_name, "latest") 52 | ) 53 | + "/" 54 | ) 55 | log("notice", f"Using docs at {docs_url}") 56 | 57 | inventory = Inventory(url=docs_url + "objects.inv") 58 | log("debug", "Downloaded codingame's inventory") 59 | stdlib_inventory = Inventory(url=STDLIB_DOCS_BASE_URL + "objects.inv") 60 | log("debug", "Downloaded stdlib's inventory") 61 | 62 | content = docs_changelog.decoded_content.decode() 63 | new_content = content 64 | directives: typing.List[re.Match] = list( 65 | re.finditer(r":(\w+):`(.+?)`", content) 66 | ) 67 | log("debug", f"Found {len(directives)} in docs/changelog.rst") 68 | 69 | links: typing.List[str] = [] 70 | cache: typing.Dict[str, int] = {} 71 | stdlib_cache: typing.Dict[str, int] = {} 72 | 73 | log("group", "Directive search") 74 | 75 | for directive in directives: 76 | role, name = directive.groups() 77 | if role == "ref": 78 | links.append("`{} <{}>`__".format(*refs[name])) 79 | log("debug", f"Found :ref:`{name}`") 80 | continue 81 | 82 | role = roles.get(role, role) 83 | 84 | index = None 85 | stdlib = False 86 | cached = False 87 | 88 | if f"{role}:{name}" in cache: 89 | index = cache[f"{role}:{name}"] 90 | cached = True 91 | 92 | if f"{role}:{name}" in stdlib_cache: 93 | index = stdlib_cache[f"{role}:{name}"] 94 | stdlib = True 95 | cached = True 96 | 97 | if index is None: 98 | indexes = [ 99 | i 100 | for _, i in inventory.suggest( 101 | f":py:{role}:`codingame.{name}`", 102 | with_index=True, 103 | thresh=90, 104 | ) 105 | ] 106 | 107 | if not indexes: 108 | indexes = [ 109 | i 110 | for _, i in stdlib_inventory.suggest( 111 | f":py:{role}:`{name}`", 112 | with_index=True, 113 | thresh=90, 114 | ) 115 | ] 116 | stdlib = True 117 | 118 | if not indexes: 119 | links.append(f"``{name}``") 120 | log( 121 | "warning", 122 | f":py:{role}:`codingame.{name}` or " 123 | f":py:{role}:`{name}` not found", 124 | title="Directive not found", 125 | ) 126 | continue 127 | 128 | index = indexes[0] 129 | 130 | if stdlib: 131 | obj = stdlib_inventory.objects[index] 132 | links.append( 133 | f"`{obj.dispname_expanded} " 134 | f"<{STDLIB_DOCS_BASE_URL + obj.uri_expanded}>`__" 135 | ) 136 | log("debug", f"Found :{role}:`{name}`" + " (cached)" * cached) 137 | stdlib_cache[f"{role}:{name}"] = index 138 | continue 139 | 140 | obj = inventory.objects[index] 141 | links.append( 142 | f"`{obj.dispname_expanded[len('codingame.'):]} " 143 | f"<{docs_url + obj.uri_expanded}>`__" 144 | ) 145 | log("debug", f"Found :{role}:`codingame.{name}`" + " (cached)" * cached) 146 | cache[f"{role}:{name}"] = index 147 | 148 | log("endgroup") 149 | 150 | for directive, link in zip(directives[::-1], links[::-1]): 151 | new_content = ( 152 | new_content[: directive.start()] 153 | + link 154 | + new_content[directive.end() :] # noqa: E203 155 | ) 156 | 157 | new_content = new_content[ 158 | len(".. currentmodule:: codingame\n\n") : # noqa: E203 159 | ] 160 | 161 | changelog = repo.get_contents( 162 | "CHANGELOG.rst", settings.github_ref.split("/")[-1] 163 | ) 164 | log("debug", f"CHANGELOG.rst at {settings.github_ref_name} downloaded") 165 | 166 | if new_content != changelog.decoded_content.decode(): 167 | repo.update_file( 168 | changelog.path, 169 | "Update CHANGELOG.rst", 170 | new_content, 171 | changelog.sha, 172 | branch=settings.github_ref.split("/")[-1], 173 | ) 174 | log( 175 | "notice", 176 | "Changelog's content changed, updated CHANGELOG.rst", 177 | file="CHANGELOG.rst", 178 | ) 179 | else: 180 | log("notice", "Changelog's content hasn't changed") 181 | 182 | 183 | LOG_PARAMETER_NAMES = { 184 | "end_line": "endLine", 185 | "column": "col", 186 | "end_column": "endColumn", 187 | } 188 | 189 | 190 | def log( 191 | level: str, 192 | message: str = "", 193 | title: str = None, 194 | file: str = None, 195 | line: int = None, 196 | end_line: int = None, 197 | column: int = None, 198 | end_column: int = None, 199 | ): 200 | parameters = dict( 201 | filter( 202 | lambda i: i[1] is not None, 203 | { 204 | "title": title, 205 | "file": file, 206 | "line": line, 207 | "end_line": end_line, 208 | "column": column, 209 | "end_column": end_column, 210 | }.items(), 211 | ) 212 | ) 213 | 214 | print( 215 | "::" 216 | + level 217 | + ( 218 | ( 219 | " " 220 | + ",".join( 221 | f"{LOG_PARAMETER_NAMES.get(k, k)}={v}" 222 | for k, v in parameters.items() 223 | ) 224 | ) 225 | if parameters 226 | else "" 227 | ) 228 | + "::" 229 | + message, 230 | flush=True, 231 | ) 232 | 233 | 234 | if __name__ == "__main__": 235 | try: 236 | main() 237 | except Exception as e: 238 | log( 239 | "error", 240 | traceback.format_exc(), 241 | title=f"{e.__class__.__name__}: {str(e)}", 242 | file=".github/actions/changelog/main.py", 243 | ) 244 | raise 245 | -------------------------------------------------------------------------------- /.github/actions/changelog/requirements.txt: -------------------------------------------------------------------------------- 1 | pygithub~=1.55 2 | pydantic~=1.8 3 | sphobjinv~=2.1 4 | -------------------------------------------------------------------------------- /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: Generate CHANGELOG.rst from docs/changelog.rst 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | generate-changelog: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: ./.github/actions/changelog 15 | with: 16 | token: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/lint-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Lint and test 5 | 6 | on: 7 | push: 8 | pull_request: 9 | workflow_dispatch: 10 | schedule: 11 | - cron: '0 8 * * 6' # every saturday at 8:00 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | cache: 'pip' 30 | cache-dependency-path: | 31 | requirements.txt 32 | async-requirements.txt 33 | dev-requirements.txt 34 | 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip wheel 38 | pip install -r requirements.txt 39 | pip install -r async-requirements.txt 40 | pip install -r dev-requirements.txt 41 | pip install pytest-github-actions-annotate-failures 42 | 43 | - name: Lint with flake8 44 | run: | 45 | # stop the build if there are Python syntax errors or undefined names 46 | flake8 codingame tests examples --count --select=E9,F63,F7,F82 --show-source --statistics \ 47 | --format='::error file=%(path)s,line=%(row)d,col=%(col)d::[flake8] %(code)s: %(text)s' 48 | # exit-zero treats all errors as warnings 49 | flake8 codingame tests examples --count --exit-zero --statistics \ 50 | --format='::warning file=%(path)s,line=%(row)d,col=%(col)d::[flake8] %(code)s: %(text)s' 51 | 52 | - name: Check formatting with black 53 | run: | 54 | black codingame tests examples --check --line-length 80 55 | 56 | - name: Check import ordering with isort 57 | run: | 58 | isort codingame tests examples --check-only 59 | 60 | - name: Lint the docs with doc8 61 | run: | 62 | doc8 docs --quiet 63 | 64 | - name: Check package build 65 | run: | 66 | python setup.py --quiet sdist 67 | twine check dist/* 68 | 69 | - name: Test the mocked API endpoints with pytest 70 | run: | 71 | pytest --only-mocked --overwrite-environ -v 72 | 73 | - name: Test with pytest without mocking API enpoints 74 | env: 75 | TEST_LOGIN_REMEMBER_ME_COOKIE: ${{ secrets.TEST_LOGIN_REMEMBER_ME_COOKIE }} 76 | 77 | TEST_CODINGAMER_ID: ${{ secrets.TEST_CODINGAMER_ID }} 78 | TEST_CODINGAMER_PSEUDO: ${{ secrets.TEST_CODINGAMER_PSEUDO }} 79 | TEST_CODINGAMER_PUBLIC_HANDLE: ${{ secrets.TEST_CODINGAMER_PUBLIC_HANDLE }} 80 | 81 | TEST_CLASHOFCODE_PUBLIC_HANDLE: ${{ secrets.TEST_CLASHOFCODE_PUBLIC_HANDLE }} 82 | run: | 83 | pytest --no-mocking --cov-append -v 84 | 85 | - name: Upload coverage to Codecov 86 | uses: codecov/codecov-action@v1 87 | with: 88 | fail_ci_if_error: true 89 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Publish to PyPI 5 | 6 | on: 7 | release: 8 | types: [created, edited] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | deploy: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: '3.x' 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install setuptools wheel twine 26 | - name: Build and publish 27 | env: 28 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 29 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 30 | run: | 31 | python setup.py sdist bdist_wheel 32 | twine check dist/* 33 | twine upload dist/* 34 | -------------------------------------------------------------------------------- /.github/workflows/status-embed.yml: -------------------------------------------------------------------------------- 1 | name: Status Embeds 2 | 3 | on: 4 | workflow_run: 5 | workflows: 6 | - Lint and test 7 | - Publish to PyPI 8 | types: 9 | - completed 10 | 11 | jobs: 12 | embed: 13 | name: Send Status Embed 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Github Actions Embed 17 | uses: SebastiaanZ/github-status-embed-for-discord@v0.2.1 18 | with: 19 | webhook_id: '754029108513603644' 20 | webhook_token: ${{ secrets.WEBHOOK_TOKEN }} 21 | 22 | workflow_name: ${{ github.event.workflow_run.name }} 23 | run_id: ${{ github.event.workflow_run.id }} 24 | run_number: ${{ github.event.workflow_run.run_number }} 25 | status: ${{ github.event.workflow_run.conclusion }} 26 | actor: ${{ github.actor }} 27 | repository: ${{ github.repository }} 28 | ref: ${{ github.ref }} 29 | sha: ${{ github.event.workflow_run.head_sha }} 30 | -------------------------------------------------------------------------------- /.github/workflows/todo.yml: -------------------------------------------------------------------------------- 1 | name: "Convert TODOs to issues" 2 | 3 | on: ["push"] 4 | 5 | jobs: 6 | build: 7 | runs-on: "ubuntu-latest" 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: TODO to Issue 11 | uses: alstr/todo-to-issue-action@v4.2 12 | id: todo 13 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 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 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | .env.save 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | .env.local 114 | 115 | # Databases 116 | database.db 117 | 118 | # VSCode project settings 119 | .vscode/ 120 | 121 | # Pycharm project settings 122 | .idea 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the version of Python and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.9" 12 | 13 | # Build documentation in the docs/ directory with Sphinx 14 | sphinx: 15 | configuration: docs/conf.py 16 | 17 | # We recommend specifying your dependencies to enable reproducible builds: 18 | # https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 19 | python: 20 | install: 21 | - requirements: docs/requirements.txt -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-now Takos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | codingame API wrapper 2 | ===================== 3 | 4 | .. image:: https://img.shields.io/pypi/v/codingame?color=blue 5 | :target: https://pypi.python.org/pypi/codingame 6 | :alt: PyPI version info 7 | .. image:: https://img.shields.io/pypi/pyversions/codingame?color=orange 8 | :target: https://pypi.python.org/pypi/codingame 9 | :alt: Supported Python versions 10 | .. image:: https://img.shields.io/github/checks-status/takos22/codingame/dev?label=tests 11 | :target: https://github.com/takos22/codingame/actions/workflows/lint-test.yml 12 | :alt: Lint and test workflow status 13 | .. image:: https://readthedocs.org/projects/codingame/badge/?version=latest 14 | :target: https://codingame.readthedocs.io 15 | :alt: Documentation build status 16 | .. image:: https://codecov.io/gh/takos22/codingame/branch/dev/graph/badge.svg?token=HQ3J3034Y2 17 | :target: https://codecov.io/gh/takos22/codingame 18 | :alt: Code coverage 19 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 20 | :target: https://github.com/psf/black 21 | :alt: Code style: Black 22 | .. image:: https://img.shields.io/discord/754028526079836251.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2 23 | :target: https://discord.gg/8HgtN6E 24 | :alt: Discord support server 25 | 26 | Pythonic wrapper for the undocumented `CodinGame `_ API. 27 | 28 | 29 | Installation 30 | ------------ 31 | 32 | **Python 3.8 or higher is required.** 33 | 34 | Install ``codingame`` with pip: 35 | 36 | .. code:: sh 37 | 38 | pip install codingame 39 | 40 | 41 | Quickstart 42 | ---------- 43 | 44 | Create an application, in ``example.py``: 45 | 46 | .. code:: python 47 | 48 | import codingame 49 | 50 | client = codingame.Client() 51 | 52 | # get a codingamer 53 | codingamer = client.get_codingamer("username") 54 | print(codingamer.pseudo) 55 | 56 | # get the global leaderboard 57 | global_leaderboard = client.get_global_leaderboard() 58 | # print the pseudo of the top codingamer 59 | print(global_leaderboard.users[0].pseudo) 60 | 61 | See `the docs `__. 62 | 63 | Contribute 64 | ---------- 65 | 66 | - `Source Code `_ 67 | - `Issue Tracker `_ 68 | 69 | 70 | Support 71 | ------- 72 | 73 | If you are having issues, please let me know by joining the discord support server at https://discord.gg/8HgtN6E 74 | 75 | License 76 | ------- 77 | 78 | The project is licensed under the MIT license. 79 | 80 | Links 81 | ------ 82 | 83 | - `PyPi `_ 84 | - `Documentation `_ 85 | - `Discord support server `_ 86 | 87 | Disclaimer 88 | ---------- 89 | 90 | This extension was developed as a proof of concept and as an exploratory project. 91 | CodinGame is not responsible for any content or security issues that may arise 92 | due to this module, if you do find any, feel free to open an issue or a pull request. 93 | -------------------------------------------------------------------------------- /async-requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp~=3.7 2 | -------------------------------------------------------------------------------- /codingame/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | CodinGame API Wrapper 3 | ===================== 4 | 5 | Wrapper for the undocumented CodinGame API. 6 | """ 7 | 8 | from typing import NamedTuple 9 | 10 | VersionInfo = NamedTuple( 11 | "VersionInfo", major=int, minor=int, micro=int, releaselevel=str, serial=int 12 | ) 13 | 14 | version_info = VersionInfo(major=1, minor=4, micro=3, releaselevel="", serial=0) 15 | 16 | __title__ = "codingame" 17 | __author__ = "takos22" 18 | __version__ = "1.4.3" 19 | 20 | from .clash_of_code import ClashOfCode, Player 21 | from .client import Client 22 | from .codingamer import CodinGamer, PartialCodinGamer 23 | from .exceptions import ( 24 | ChallengeNotFound, 25 | ClashOfCodeNotFound, 26 | CodinGameAPIError, 27 | CodinGamerNotFound, 28 | EmailNotLinked, 29 | EmailRequired, 30 | IncorrectPassword, 31 | LoginError, 32 | LoginRequired, 33 | MalformedEmail, 34 | NotFound, 35 | PasswordRequired, 36 | PuzzleNotFound, 37 | WrongCaptchaAnswer, 38 | ) 39 | from .leaderboard import ( 40 | ChallengeLeaderboard, 41 | ChallengeRankedCodinGamer, 42 | GlobalLeaderboard, 43 | GlobalRankedCodinGamer, 44 | League, 45 | PuzzleLeaderboard, 46 | PuzzleRankedCodinGamer, 47 | ) 48 | from .notification import ( 49 | AchievementUnlockedData, 50 | CareerCandidateData, 51 | ClashInviteData, 52 | ClashOverData, 53 | CommentType, 54 | Contribution, 55 | ContributionData, 56 | ContributionModeratedActionType, 57 | ContributionModeratedData, 58 | ContributionType, 59 | CustomData, 60 | FeatureData, 61 | FriendRegisteredData, 62 | GenericData, 63 | JobAcceptedData, 64 | JobExpiredData, 65 | LanguageMapping, 66 | LeagueData, 67 | NewBlogData, 68 | NewCommentData, 69 | NewHintData, 70 | NewLevelData, 71 | NewPuzzleData, 72 | NewWorkBlogData, 73 | Notification, 74 | NotificationData, 75 | NotificationType, 76 | NotificationTypeGroup, 77 | OfferApplyData, 78 | PuzzleOfTheWeekData, 79 | PuzzleSolution, 80 | QuestCompletedData, 81 | TestFinishedData, 82 | ) 83 | 84 | __all__ = ( 85 | # Client 86 | Client, 87 | # CodinGamer 88 | CodinGamer, 89 | PartialCodinGamer, 90 | # Clash of Code 91 | ClashOfCode, 92 | Player, 93 | # Notification 94 | Notification, 95 | NotificationType, 96 | NotificationTypeGroup, 97 | ContributionType, 98 | CommentType, 99 | ContributionModeratedActionType, 100 | LanguageMapping, 101 | NotificationData, 102 | AchievementUnlockedData, 103 | LeagueData, 104 | NewBlogData, 105 | ClashInviteData, 106 | ClashOverData, 107 | Contribution, 108 | PuzzleSolution, 109 | NewCommentData, 110 | ContributionData, 111 | FeatureData, 112 | NewHintData, 113 | ContributionModeratedData, 114 | NewPuzzleData, 115 | PuzzleOfTheWeekData, 116 | QuestCompletedData, 117 | FriendRegisteredData, 118 | NewLevelData, 119 | GenericData, 120 | CustomData, 121 | CareerCandidateData, 122 | TestFinishedData, 123 | JobAcceptedData, 124 | JobExpiredData, 125 | NewWorkBlogData, 126 | OfferApplyData, 127 | # Leaderboard 128 | GlobalLeaderboard, 129 | GlobalRankedCodinGamer, 130 | League, 131 | ChallengeLeaderboard, 132 | ChallengeRankedCodinGamer, 133 | PuzzleLeaderboard, 134 | PuzzleRankedCodinGamer, 135 | # Exceptions 136 | CodinGameAPIError, 137 | LoginError, 138 | EmailRequired, 139 | MalformedEmail, 140 | PasswordRequired, 141 | EmailNotLinked, 142 | IncorrectPassword, 143 | WrongCaptchaAnswer, 144 | LoginRequired, 145 | NotFound, 146 | CodinGamerNotFound, 147 | ClashOfCodeNotFound, 148 | ChallengeNotFound, 149 | PuzzleNotFound, 150 | ) 151 | -------------------------------------------------------------------------------- /codingame/abc.py: -------------------------------------------------------------------------------- 1 | """Abstract Base Classes""" 2 | 3 | import abc 4 | import typing 5 | from collections.abc import Mapping as BaseMapping 6 | 7 | if typing.TYPE_CHECKING: 8 | from .state import ConnectionState 9 | 10 | __all__ = ( 11 | "BaseObject", 12 | "BaseUser", 13 | ) 14 | 15 | 16 | class BaseObject(abc.ABC): 17 | """Abstract base class for any object returned by the CodinGame API. 18 | 19 | This makes all the attributes read-only.""" 20 | 21 | _state: "ConnectionState" 22 | 23 | __slots__ = ("_state", "__initialised") 24 | 25 | def __init__(self, state: "ConnectionState"): 26 | self._state = state 27 | self.__initialised = True 28 | 29 | def __setattr__(self, name, value): 30 | if not name.startswith("_") and getattr( 31 | self, "_BaseObject__initialised", False 32 | ): # pragma: no cover 33 | raise AttributeError(f"{name!r} attribute is read-only.") 34 | object.__setattr__(self, name, value) 35 | 36 | def _setattr(self, name: str, value): 37 | """Set the value of a read-only attribute.""" 38 | 39 | object.__setattr__(self, name, value) 40 | 41 | 42 | class BaseUser(BaseObject): 43 | """Abstract Base Class for codingame users (CodinGamer, Player, ...).""" 44 | 45 | public_handle: str 46 | """Public handle of the CodinGamer (hexadecimal str).""" 47 | id: int 48 | """ID of the CodinGamer. Last 7 digits of the :attr:`public_handle` 49 | reversed.""" 50 | pseudo: typing.Optional[str] 51 | """Pseudo of the CodinGamer.""" 52 | avatar: typing.Optional[int] 53 | """Avatar ID of the CodinGamer. You can get the avatar url with 54 | :attr:`avatar_url`.""" 55 | cover: typing.Optional[int] 56 | """Cover ID of the CodinGamer. You can get the cover url with 57 | :attr:`cover_url`.""" 58 | 59 | __slots__ = ("public_handle", "id", "pseudo", "avatar", "cover") 60 | 61 | @property 62 | def avatar_url(self) -> typing.Optional[str]: 63 | """Optional :class:`str`: Avatar URL of the CodinGamer.""" 64 | return ( 65 | self._state.http.get_file_url(self.avatar) if self.avatar else None 66 | ) 67 | 68 | @property 69 | def cover_url(self) -> typing.Optional[str]: 70 | """Optional :class:`str`: Cover URL of the CodinGamer.""" 71 | return self._state.http.get_file_url(self.cover) if self.cover else None 72 | 73 | def __repr__(self): 74 | return ( 75 | "<{0.__class__.__name__} id={0.id!r} pseudo={0.pseudo!r}>".format( 76 | self 77 | ) 78 | ) 79 | 80 | def __eq__(self, other): 81 | return self.public_handle == other.public_handle 82 | 83 | 84 | class Mapping(BaseMapping, BaseObject): 85 | _raw: dict 86 | 87 | __slots__ = ("_raw",) 88 | 89 | def __init__(self, state: "ConnectionState", data: dict): 90 | self._raw = data 91 | 92 | super().__init__(state) 93 | 94 | def __getitem__(self, name: str): # pragma: no cover 95 | return self._raw[name] 96 | 97 | def __iter__(self): # pragma: no cover 98 | return iter(self._raw) 99 | 100 | def __len__(self): # pragma: no cover 101 | return len(self._raw) 102 | -------------------------------------------------------------------------------- /codingame/clash_of_code.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import TYPE_CHECKING, List, Optional 3 | 4 | from .abc import BaseObject, BaseUser 5 | from .types.clash_of_code import ClashOfCode as ClashOfCodeDict 6 | from .types.clash_of_code import ( 7 | LanguageId, 8 | LanguageIds, 9 | Mode, 10 | Modes, 11 | PlayerStatus, 12 | ) 13 | from .utils import to_datetime 14 | 15 | if TYPE_CHECKING: 16 | from .state import ConnectionState 17 | 18 | __all__ = ( 19 | "ClashOfCode", 20 | "Player", 21 | ) 22 | 23 | 24 | class ClashOfCode(BaseObject): 25 | """Represents a Clash of Code. 26 | 27 | Attributes 28 | ----------- 29 | public_handle: :class:`str` 30 | Public handle of the Clash of Code (hexadecimal str). 31 | 32 | join_url: :class:`str` 33 | URL to join the Clash of Code. 34 | 35 | public: :class:`bool` 36 | Whether the Clash of Code is public. 37 | 38 | min_players: :class:`int` 39 | Minimum number of players. 40 | 41 | max_players: :class:`int` 42 | Maximum number of players. 43 | 44 | modes: Optional :class:`list` of :class:`str` 45 | List of possible modes. 46 | 47 | programming_languages: Optional :class:`list` of :class:`str` 48 | List of possible programming languages. 49 | 50 | started: :class:`bool` 51 | Whether the Clash of Code is started. 52 | 53 | finished: :class:`bool` 54 | Whether the Clash of Code is finished. 55 | 56 | mode: Optional :class:`str` 57 | The mode of the Clash of Code. 58 | 59 | creation_time: Optional :class:`~datetime.datetime` 60 | Creation time of the Clash of Code. 61 | Doesn't always exist. 62 | 63 | start_time: :class:`~datetime.datetime` 64 | Start time of the Clash of Code. If the Clash of Code hasn't started 65 | yet, this is the expected start time of the Clash of Code. 66 | 67 | end_time: Optional :class:`~datetime.datetime` 68 | End time of the Clash of Code. 69 | 70 | time_before_start: :class:`~datetime.timedelta` 71 | Time before the start of the Clash of Code. 72 | 73 | time_before_end: Optional :class:`~datetime.timedelta` 74 | Time before the end of the Clash of Code. 75 | 76 | players: :class:`list` of :class:`Player` 77 | List of the players in the Clash of Code. 78 | """ 79 | 80 | public_handle: str 81 | join_url: str 82 | public: bool 83 | min_players: int 84 | max_players: int 85 | modes: Optional[Modes] 86 | programming_languages: Optional[LanguageIds] 87 | started: bool 88 | finished: bool 89 | mode: Optional[Mode] 90 | creation_time: Optional[datetime] 91 | start_time: datetime 92 | end_time: Optional[datetime] 93 | time_before_start: timedelta 94 | time_before_end: Optional[timedelta] 95 | players: List["Player"] 96 | 97 | __slots__ = ( 98 | "public_handle", 99 | "join_url", 100 | "public", 101 | "min_players", 102 | "max_players", 103 | "modes", 104 | "programming_languages", 105 | "started", 106 | "finished", 107 | "mode", 108 | "creation_time", 109 | "start_time", 110 | "end_time", 111 | "time_before_start", 112 | "time_before_end", 113 | "players", 114 | ) 115 | 116 | def __init__(self, state: "ConnectionState", data: ClashOfCodeDict): 117 | self.public_handle = data["publicHandle"] 118 | self.join_url = ( 119 | f"https://www.codingame.com/clashofcode/clash/{self.public_handle}" 120 | ) 121 | self.public = data.get("type", "PUBLIC") == "PUBLIC" 122 | self.min_players = data["nbPlayersMin"] 123 | self.max_players = data["nbPlayersMax"] 124 | self.modes = data.get("modes") 125 | self.programming_languages = data.get("programmingLanguages") 126 | 127 | self.started = data["started"] 128 | self.finished = data["finished"] 129 | self.mode = data.get("mode") 130 | 131 | self.creation_time = to_datetime(data.get("creationTime")) 132 | self.start_time = to_datetime(data["startTimestamp"]) 133 | self.end_time = to_datetime(data.get("endTime")) 134 | 135 | self.time_before_start = timedelta(milliseconds=data["msBeforeStart"]) 136 | self.time_before_end = ( 137 | timedelta(milliseconds=data["msBeforeEnd"]) 138 | if "msBeforeEnd" in data 139 | else None 140 | ) 141 | 142 | self.players = [ 143 | Player( 144 | state, 145 | self, 146 | self.started, 147 | self.finished, 148 | player, 149 | ) 150 | for player in data["players"] 151 | ] 152 | 153 | super().__init__(state) 154 | 155 | def __repr__(self) -> str: 156 | return ( 157 | "".format(self) 162 | ) 163 | 164 | 165 | class Player(BaseUser): 166 | """Represents a Clash of Code player. 167 | 168 | Attributes 169 | ----------- 170 | clash_of_code: :class:`ClashOfCode` 171 | Clash of Code the Player belongs to. 172 | 173 | public_handle: :class:`str` 174 | Public handle of the CodinGamer (hexadecimal str). 175 | 176 | id: :class:`int` 177 | ID of the CodinGamer. Last 7 digits of the :attr:`public_handle` 178 | reversed. 179 | 180 | pseudo: :class:`int` 181 | Pseudo of the CodinGamer. 182 | 183 | avatar: Optional :class:`int` 184 | Avatar ID of the CodinGamer. 185 | You can get the avatar url with :attr:`avatar_url`. 186 | 187 | cover: Optional :class:`int` 188 | Cover ID of the CodinGamer. In this case, always ``None``. 189 | 190 | started: :class:`bool` 191 | Whether the Clash of Code is started. 192 | 193 | finished: :class:`bool` 194 | Whether the Clash of Code is finished. 195 | 196 | status: :class:`str` 197 | Status of the Player. Can be ``OWNER`` or ``STANDARD``. 198 | 199 | .. note:: 200 | You can use :attr:`owner` to get a :class:`bool` that describes 201 | whether the player is the owner. 202 | 203 | owner: :class:`bool` 204 | Whether the player is the Clash of Code owner. 205 | 206 | position: Optional :class:`int` 207 | Join position of the Player. 208 | 209 | rank: Optional :class:`int` 210 | Rank of the Player. Only use this when the Clash of Code is finished 211 | because it isn't precise until then. 212 | 213 | duration: Optional :class:`~datetime.timedelta` 214 | Time taken by the player to solve the problem of the Clash of Code. 215 | 216 | language_id: Optional :class:`str` 217 | Language ID of the language the player used in the Clash of Code. 218 | 219 | score: Optional :class:`int` 220 | Score of the Player (between 0 and 100). 221 | 222 | code_length: Optional :class:`int` 223 | Length of the Player's code. 224 | Only available when the Clash of Code's mode is ``SHORTEST``. 225 | 226 | solution_shared: Optional :class:`bool` 227 | Whether the Player shared his code. 228 | 229 | submission_id: Optional :class:`int` 230 | ID of the player's submission. 231 | """ 232 | 233 | clash_of_code: ClashOfCode 234 | public_handle: str 235 | id: int 236 | pseudo: Optional[str] 237 | avatar: Optional[int] 238 | cover: Optional[int] 239 | started: bool 240 | finished: bool 241 | status: PlayerStatus 242 | owner: bool 243 | position: Optional[int] 244 | rank: Optional[int] 245 | duration: Optional[timedelta] 246 | language_id: Optional[LanguageId] 247 | score: Optional[int] 248 | code_length: Optional[int] 249 | solution_shared: Optional[bool] 250 | submission_id: Optional[int] 251 | 252 | __slots__ = ( 253 | "clash_of_code", 254 | "started", 255 | "finished", 256 | "status", 257 | "owner", 258 | "position", 259 | "rank", 260 | "duration", 261 | "language_id", 262 | "score", 263 | "code_length", 264 | "solution_shared", 265 | "submission_id", 266 | ) 267 | 268 | def __init__( 269 | self, 270 | state: "ConnectionState", 271 | coc: ClashOfCode, 272 | started: bool, 273 | finished: bool, 274 | data: dict, 275 | ): 276 | self.clash_of_code: ClashOfCode = coc 277 | 278 | self.public_handle = data["codingamerHandle"] 279 | self.id = data["codingamerId"] 280 | self.pseudo = data["codingamerNickname"] 281 | self.avatar = data.get("codingamerAvatarId") 282 | self.cover = None 283 | 284 | self.started = started 285 | self.finished = finished 286 | 287 | self.status = data["status"] 288 | self.owner = self.status == "OWNER" 289 | self.position = data.get("position") 290 | self.rank = data.get("rank") 291 | 292 | self.duration = ( 293 | timedelta(milliseconds=data["duration"]) 294 | if "duration" in data 295 | else None 296 | ) 297 | self.language_id = data.get("languageId") 298 | self.score = data.get("score") 299 | self.code_length = data.get("criterion") 300 | self.solution_shared = data.get("solutionShared") 301 | self.submission_id = data.get("submissionId") 302 | 303 | super().__init__(state) 304 | 305 | def __repr__(self) -> str: 306 | return ( 307 | "".format(self) 310 | ) 311 | -------------------------------------------------------------------------------- /codingame/client/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import Client 2 | 3 | __all__ = ("Client",) 4 | -------------------------------------------------------------------------------- /codingame/client/client.py: -------------------------------------------------------------------------------- 1 | from .base import BaseClient 2 | 3 | __all__ = ("Client",) 4 | 5 | 6 | class Client(BaseClient): 7 | """Client for the CodinGame API. 8 | 9 | Instanciates a :class:`~codingame.client.sync.SyncClient` if ``is_async`` is 10 | ``False`` or not given. 11 | Instanciates a :class:`~codingame.client.async_.AsyncClient` if ``is_async`` 12 | is ``True``. 13 | 14 | .. note:: 15 | There are docs for both :class:`~codingame.client.sync.SyncClient` and 16 | :class:`~codingame.client.async_.AsyncClient`. 17 | 18 | Parameters 19 | ---------- 20 | is_async : bool 21 | Whether the client is asynchronous. Defaults to ``False``. 22 | """ 23 | 24 | def __new__(cls, is_async: bool = False): 25 | if is_async: 26 | from .async_ import AsyncClient 27 | 28 | return AsyncClient() 29 | else: 30 | from .sync import SyncClient 31 | 32 | return SyncClient() 33 | -------------------------------------------------------------------------------- /codingame/client/sync.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from datetime import datetime 3 | 4 | from ..clash_of_code import ClashOfCode 5 | from ..codingamer import CodinGamer 6 | from ..exceptions import LoginError, LoginRequired, NotFound 7 | from ..http import HTTPError 8 | from ..leaderboard import ( 9 | ChallengeLeaderboard, 10 | GlobalLeaderboard, 11 | PuzzleLeaderboard, 12 | ) 13 | from ..notification import Notification 14 | from ..utils import ( 15 | CLASH_OF_CODE_HANDLE_REGEX, 16 | CODINGAMER_HANDLE_REGEX, 17 | to_datetime, 18 | validate_leaderboard_group, 19 | validate_leaderboard_type, 20 | ) 21 | from .base import BaseClient 22 | 23 | __all__ = ("SyncClient",) 24 | 25 | 26 | class SyncClient(BaseClient): 27 | """Synchronous client for the CodinGame client.""" 28 | 29 | def __init__(self): 30 | super().__init__(is_async=False) 31 | 32 | # -------------------------------------------------------------------------- 33 | # CodinGamer 34 | 35 | def login( 36 | self, 37 | email: typing.Optional[str] = None, 38 | password: typing.Optional[str] = None, 39 | remember_me_cookie: typing.Optional[str] = None, 40 | ) -> typing.Optional[CodinGamer]: 41 | if remember_me_cookie is not None: 42 | # see issue #5 43 | self._state.http.set_cookie("rememberMe", remember_me_cookie) 44 | self._state.logged_in = True 45 | 46 | codingamer_id = int(remember_me_cookie[:7]) 47 | self._state.codingamer = self.get_codingamer(codingamer_id) 48 | 49 | return self._state.codingamer 50 | else: 51 | raise LoginError( 52 | "Email/password login is unavailable, use cookie authentication" 53 | " instead, with the ``remember_me_cookie`` parameter." 54 | ) 55 | 56 | # try: 57 | # data = self._state.http.login(email, password) 58 | # except HTTPError as error: 59 | # raise LoginError.from_id( 60 | # error.data["id"], error.data["message"] 61 | # ) from None 62 | 63 | # self._state.logged_in = True 64 | # self._state.codingamer = CodinGamer(self._state, data["codinGamer"]) 65 | # return self.codingamer 66 | 67 | def get_codingamer(self, codingamer: typing.Union[str, int]) -> CodinGamer: 68 | handle = None 69 | 70 | if isinstance(codingamer, int): 71 | try: 72 | data = self._state.http.get_codingamer_from_id(codingamer) 73 | except HTTPError as error: 74 | if error.data["id"] == 404: 75 | raise NotFound.from_type( 76 | "codingamer", f"No CodinGamer with id {codingamer!r}" 77 | ) from None 78 | raise # pragma: no cover 79 | handle = data["publicHandle"] 80 | 81 | else: 82 | if CODINGAMER_HANDLE_REGEX.match(codingamer): 83 | handle = codingamer 84 | else: 85 | results = self._state.http.search(codingamer) 86 | users = [ 87 | result for result in results if result["type"] == "USER" 88 | ] 89 | if users: 90 | handle = users[0]["id"] 91 | else: 92 | raise NotFound.from_type( 93 | "codingamer", 94 | f"No CodinGamer with username {codingamer!r}", 95 | ) 96 | 97 | data = self._state.http.get_codingamer_from_handle(handle) 98 | if data is None: 99 | raise NotFound.from_type( 100 | "codingamer", f"No CodinGamer with handle {handle!r}" 101 | ) 102 | return CodinGamer(self._state, data["codingamer"]) 103 | 104 | # -------------------------------------------------------------------------- 105 | # Clash of Code 106 | 107 | def get_clash_of_code(self, handle: str) -> ClashOfCode: 108 | if not CLASH_OF_CODE_HANDLE_REGEX.match(handle): 109 | raise ValueError( 110 | f"Clash of Code handle {handle!r} isn't in the good format " 111 | "(regex: [0-9]{7}[0-9a-f]{32})." 112 | ) 113 | 114 | try: 115 | data = self._state.http.get_clash_of_code_from_handle(handle) 116 | except HTTPError as error: 117 | if error.data["id"] == 502: 118 | raise NotFound.from_type( 119 | "clash_of_code", f"No Clash of Code with handle {handle!r}" 120 | ) from None 121 | raise # pragma: no cover 122 | return ClashOfCode(self._state, data) 123 | 124 | def get_pending_clash_of_code(self) -> typing.Optional[ClashOfCode]: 125 | data: list = self._state.http.get_pending_clash_of_code() 126 | if not data: 127 | return None # pragma: no cover 128 | return ClashOfCode(self._state, data[0]) # pragma: no cover 129 | 130 | # -------------------------------------------------------------------------- 131 | # Language IDs 132 | 133 | def get_language_ids(self) -> typing.List[str]: 134 | return self._state.http.get_language_ids() 135 | 136 | # -------------------------------------------------------------------------- 137 | # Notifications 138 | 139 | def get_unseen_notifications(self) -> typing.Iterator[Notification]: 140 | if not self.logged_in: 141 | raise LoginRequired() 142 | 143 | try: 144 | data = self._state.http.get_unseen_notifications(self.codingamer.id) 145 | except HTTPError as error: 146 | if error.data["id"] == 492: 147 | raise LoginRequired() from None 148 | raise # pragma: no cover 149 | 150 | for notification in data: 151 | yield Notification(self._state, notification) 152 | 153 | def get_unread_notifications(self) -> typing.Iterator[Notification]: 154 | if not self.logged_in: 155 | raise LoginRequired() 156 | 157 | try: 158 | data = self._state.http.get_unread_notifications(self.codingamer.id) 159 | except HTTPError as error: 160 | if error.data["id"] == 492: 161 | raise LoginRequired() from None 162 | raise # pragma: no cover 163 | 164 | for notification in data: 165 | yield Notification(self._state, notification) 166 | 167 | def get_read_notifications(self) -> typing.Iterator[Notification]: 168 | if not self.logged_in: 169 | raise LoginRequired() 170 | 171 | try: 172 | data = self._state.http.get_last_read_notifications( 173 | self.codingamer.id 174 | ) 175 | except HTTPError as error: 176 | if error.data["id"] == 492: 177 | raise LoginRequired() from None 178 | raise # pragma: no cover 179 | 180 | for notification in data: 181 | yield Notification(self._state, notification) 182 | 183 | def mark_notifications_as_seen( 184 | self, notifications: typing.List[typing.Union["Notification", int]] 185 | ) -> datetime: 186 | if not notifications: 187 | raise ValueError("notifications argument must not be empty.") 188 | 189 | if not self.logged_in: 190 | raise LoginRequired() 191 | 192 | try: 193 | data = self._state.http.mark_notifications_as_seen( 194 | self.codingamer.id, 195 | [n if isinstance(n, int) else n.id for n in notifications], 196 | ) 197 | except HTTPError as error: 198 | if error.data["id"] == 492: 199 | raise LoginRequired() from None 200 | raise # pragma: no cover 201 | 202 | seen_date = to_datetime(data) 203 | for notification in notifications: 204 | if isinstance(notification, int): 205 | continue 206 | notification._setattr("seen", True) 207 | notification._setattr("seen_date", seen_date) 208 | 209 | return seen_date 210 | 211 | def mark_notifications_as_read( 212 | self, notifications: typing.List[typing.Union["Notification", int]] 213 | ) -> datetime: 214 | if not notifications: 215 | raise ValueError("notifications argument must not be empty.") 216 | 217 | if not self.logged_in: 218 | raise LoginRequired() 219 | 220 | try: 221 | data = self._state.http.mark_notifications_as_read( 222 | self.codingamer.id, 223 | [n if isinstance(n, int) else n.id for n in notifications], 224 | ) 225 | except HTTPError as error: 226 | if error.data["id"] == 492: 227 | raise LoginRequired() from None 228 | raise # pragma: no cover 229 | 230 | read_date = to_datetime(data) 231 | for notification in notifications: 232 | if isinstance(notification, int): 233 | continue 234 | notification._setattr("read", True) 235 | notification._setattr("read_date", read_date) 236 | 237 | return read_date 238 | 239 | # -------------------------------------------------------------------------- 240 | # Leaderboards 241 | 242 | def get_global_leaderboard( 243 | self, page: int = 1, type: str = "GENERAL", group: str = "global" 244 | ) -> GlobalLeaderboard: 245 | type = validate_leaderboard_type(type) 246 | group = validate_leaderboard_group(group, self.logged_in) 247 | 248 | data = self._state.http.get_global_leaderboard( 249 | page, 250 | type, 251 | group, 252 | self.codingamer.public_handle if self.logged_in else "", 253 | ) 254 | return GlobalLeaderboard(self._state, type, group, page, data) 255 | 256 | def get_challenge_leaderboard( 257 | self, challenge_id: str, group: str = "global" 258 | ) -> ChallengeLeaderboard: 259 | group = validate_leaderboard_group(group, self.logged_in) 260 | 261 | try: 262 | data = self._state.http.get_challenge_leaderboard( 263 | challenge_id, 264 | group, 265 | self.codingamer.public_handle if self.logged_in else "", 266 | ) 267 | except HTTPError as error: 268 | if ( 269 | error.data.get("id") == 702 270 | or error.data.get("code") == "NOT_FOUND" 271 | ): # see issue #26 272 | raise NotFound.from_type( 273 | "challenge", f"No Challenge named {challenge_id!r}" 274 | ) from None 275 | raise # pragma: no cover 276 | 277 | return ChallengeLeaderboard(self._state, challenge_id, group, data) 278 | 279 | def get_puzzle_leaderboard( 280 | self, puzzle_id: str, group: str = "global" 281 | ) -> PuzzleLeaderboard: 282 | group = validate_leaderboard_group(group, self.logged_in) 283 | 284 | try: 285 | data = self._state.http.get_puzzle_leaderboard( 286 | puzzle_id, 287 | group, 288 | self.codingamer.public_handle if self.logged_in else "", 289 | ) 290 | except HTTPError as error: 291 | if error.data["code"] == "INVALID_PARAMETERS": 292 | raise NotFound.from_type( 293 | "puzzle", f"No Puzzle named {puzzle_id!r}" 294 | ) from None 295 | raise # pragma: no cover 296 | 297 | return PuzzleLeaderboard(self._state, puzzle_id, group, data) 298 | -------------------------------------------------------------------------------- /codingame/exceptions.py: -------------------------------------------------------------------------------- 1 | __all__ = ( 2 | "CodinGameAPIError", 3 | "LoginError", 4 | "EmailRequired", 5 | "MalformedEmail", 6 | "PasswordRequired", 7 | "EmailNotLinked", 8 | "IncorrectPassword", 9 | "LoginRequired", 10 | "NotFound", 11 | "CodinGamerNotFound", 12 | "ClashOfCodeNotFound", 13 | "ChallengeNotFound", 14 | "PuzzleNotFound", 15 | ) 16 | 17 | 18 | class CodinGameAPIError(Exception): 19 | """Base exception for the CodinGame API.""" 20 | 21 | def __init__(self, message: str): 22 | self.message = message 23 | super().__init__(message) 24 | 25 | 26 | class LoginError(CodinGameAPIError): 27 | """Raised when the login data is incorrect.""" 28 | 29 | @classmethod 30 | def from_id(cls, id: int, message: str): # pragma: no cover 31 | # unused since the login method changed 32 | errors = { 33 | 332: EmailRequired, 34 | 334: MalformedEmail, 35 | 336: PasswordRequired, 36 | 393: EmailNotLinked, 37 | 396: IncorrectPassword, 38 | 701: WrongCaptchaAnswer, 39 | } 40 | return errors.get(id, cls)(message) 41 | 42 | 43 | class EmailRequired(LoginError): 44 | """Raised when the email given at login is empty.""" 45 | 46 | 47 | class MalformedEmail(LoginError): 48 | """Raised when the email given at login isn't well formed.""" 49 | 50 | 51 | class PasswordRequired(LoginError): 52 | """Raised when the password given at login is empty.""" 53 | 54 | 55 | class EmailNotLinked(LoginError): 56 | """Raised when the email given at login isn't linked to a CodinGamer 57 | account.""" 58 | 59 | 60 | class IncorrectPassword(LoginError): 61 | """Raised when the password given at login is incorrect.""" 62 | 63 | 64 | class WrongCaptchaAnswer(LoginError): 65 | """Raised when the captcha in the email/password login is incorrect. 66 | 67 | See :ref:`login` to fix this""" 68 | 69 | 70 | class LoginRequired(LoginError): 71 | """Raised when an action requires the client to log in.""" 72 | 73 | def __init__(self, message: str = None): 74 | super().__init__( 75 | message or "You must be logged in to perform this action." 76 | ) 77 | 78 | 79 | class NotFound(CodinGameAPIError): 80 | """Raised when something isn't found.""" 81 | 82 | @classmethod 83 | def from_type(cls, type: str, message: str): 84 | errors = { 85 | "codingamer": CodinGamerNotFound, 86 | "clash_of_code": ClashOfCodeNotFound, 87 | "challenge": ChallengeNotFound, 88 | "puzzle": PuzzleNotFound, 89 | } 90 | return errors.get(type, cls)(message) 91 | 92 | 93 | class CodinGamerNotFound(NotFound): 94 | """Raised when a CodinGamer isn't found.""" 95 | 96 | 97 | class ClashOfCodeNotFound(NotFound): 98 | """Raised when a Clash of Code isn't found.""" 99 | 100 | 101 | class ChallengeNotFound(NotFound): 102 | """Raised when a Challenge isn't found.""" 103 | 104 | 105 | class PuzzleNotFound(NotFound): 106 | """Raised when a Puzzle isn't found.""" 107 | -------------------------------------------------------------------------------- /codingame/http/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import HTTPClient 2 | from .httperror import HTTPError 3 | 4 | __all__ = ( 5 | "HTTPClient", 6 | "HTTPError", 7 | ) 8 | -------------------------------------------------------------------------------- /codingame/http/async_.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from http.cookies import Morsel 3 | from http.cookies import _quote as cookie_quote 4 | 5 | import aiohttp 6 | 7 | from .base import BaseHTTPClient 8 | from .httperror import HTTPError 9 | 10 | if typing.TYPE_CHECKING: 11 | from ..state import ConnectionState 12 | 13 | __all__ = ("AsyncHTTPClient",) 14 | 15 | 16 | class AsyncHTTPClient(BaseHTTPClient): 17 | def __init__(self, state: "ConnectionState"): 18 | self.state = state 19 | self.__session: aiohttp.ClientSession = aiohttp.ClientSession( 20 | headers=self.headers 21 | ) 22 | 23 | @property 24 | def is_async(self): 25 | return True 26 | 27 | async def close(self): 28 | await self.__session.close() 29 | 30 | async def request( 31 | self, service: str, func: str, parameters: typing.Optional[list] = None 32 | ): 33 | parameters = parameters or [] 34 | url = self.API_URL + service + "/" + func 35 | async with self.__session.post(url, json=parameters) as response: 36 | data = await response.json() 37 | try: 38 | response.raise_for_status() 39 | except aiohttp.ClientResponseError as error: 40 | raise HTTPError.from_aiohttp(error, data) from None 41 | return data 42 | 43 | def set_cookie( 44 | self, 45 | name: str, 46 | value: typing.Optional[str] = None, 47 | domain: str = "www.codingame.com", 48 | ): 49 | if value is not None: 50 | morsel = Morsel() 51 | morsel.set(name, value, cookie_quote(value)) 52 | morsel["domain"] = domain 53 | self.__session.cookie_jar.update_cookies({name: morsel}) 54 | else: # pragma: no cover 55 | self.__session.cookie_jar._cookies.get(domain, {}).pop(name, None) 56 | -------------------------------------------------------------------------------- /codingame/http/base.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from abc import ABC, abstractmethod 3 | 4 | from ..types import ( 5 | ClashOfCode, 6 | CodinGamerFromID, 7 | Follower, 8 | Following, 9 | Notification, 10 | PointsStatsFromHandle, 11 | ) 12 | 13 | if typing.TYPE_CHECKING: 14 | from ..state import ConnectionState 15 | 16 | __all__ = ("BaseHTTPClient",) 17 | 18 | 19 | DEFAULT_FILTER = { 20 | "active": False, 21 | "keyword": "", 22 | "column": "", 23 | "filter": "", 24 | } 25 | 26 | 27 | class BaseHTTPClient(ABC): 28 | BASE_URL = "https://www.codingame.com" 29 | API_URL = BASE_URL + "/services/" 30 | STATIC_URL = "https://static.codingame.com" 31 | 32 | headers: dict = { 33 | "User-Agent": ( 34 | "CodinGame API wrapper in Python " 35 | "(https://github.com/takos22/codingame)" 36 | ) 37 | } 38 | state: "ConnectionState" 39 | 40 | @property 41 | @abstractmethod 42 | def is_async(self) -> bool: 43 | ... # pragma: no cover 44 | 45 | @abstractmethod 46 | def close(self): 47 | ... # pragma: no cover 48 | 49 | @abstractmethod 50 | def request( 51 | self, service: str, func: str, parameters: typing.Optional[list] = None 52 | ): 53 | ... # pragma: no cover 54 | 55 | @abstractmethod 56 | def set_cookie( 57 | self, 58 | name: str, 59 | value: typing.Optional[str] = None, 60 | domain: str = "www.codingame.com", 61 | ): 62 | ... # pragma: no cover 63 | 64 | def get_file_url(self, id: int, format: str = None) -> str: 65 | url = f"{self.STATIC_URL}/servlet/fileservlet?id={id}" 66 | if format: 67 | url += f"&format={format}" 68 | return url 69 | 70 | # Search 71 | 72 | def search(self, query: str): 73 | return self.request("Search", "search", [query, "en", None]) 74 | 75 | # ProgrammingLanguage 76 | 77 | def get_language_ids(self) -> typing.List[str]: 78 | return self.request("ProgrammingLanguage", "findAllIds") 79 | 80 | # CodinGamer 81 | 82 | def login(self, email: str, password: str): # pragma: no cover 83 | return self.request( 84 | "CodinGamer", "loginSite", [email, password, True, "CODINGAME", ""] 85 | ) 86 | 87 | def get_codingamer_from_handle(self, handle: str) -> PointsStatsFromHandle: 88 | return self.request( 89 | "CodinGamer", "findCodingamePointsStatsByHandle", [handle] 90 | ) 91 | 92 | def get_codingamer_from_id(self, id: int) -> CodinGamerFromID: 93 | return self.request( 94 | "CodinGamer", "findCodinGamerPublicInformations", [id] 95 | ) 96 | 97 | def get_codingamer_followers( 98 | self, id: int, current_id: int = None 99 | ) -> typing.List[Follower]: 100 | return self.request( 101 | "CodinGamer", "findFollowers", [id, current_id or id, None] 102 | ) 103 | 104 | def get_codingamer_follower_ids(self, id: int) -> typing.List[int]: 105 | return self.request("CodinGamer", "findFollowerIds", [id]) 106 | 107 | def get_codingamer_following( 108 | self, id: int, current_id: int = None 109 | ) -> typing.List[Following]: 110 | return self.request( 111 | "CodinGamer", "findFollowing", [id, current_id or id] 112 | ) 113 | 114 | def get_codingamer_following_ids(self, id: int) -> typing.List[int]: 115 | return self.request("CodinGamer", "findFollowingIds", [id]) 116 | 117 | # ClashOfCode 118 | 119 | def get_codingamer_clash_of_code_rank(self, id: int) -> int: 120 | return self.request("ClashOfCode", "getClashRankByCodinGamerId", [id]) 121 | 122 | def get_clash_of_code_from_handle(self, handle: str) -> ClashOfCode: 123 | return self.request("ClashOfCode", "findClashByHandle", [handle]) 124 | 125 | def get_pending_clash_of_code(self) -> typing.List[ClashOfCode]: 126 | return self.request("ClashOfCode", "findPendingClashes") 127 | 128 | # Notification 129 | 130 | def get_unread_notifications(self, id: int) -> typing.List[Notification]: 131 | return self.request("Notification", "findUnreadNotifications", [id]) 132 | 133 | def get_unseen_notifications(self, id: int) -> typing.List[Notification]: 134 | return self.request("Notification", "findUnseenNotifications", [id]) 135 | 136 | def get_last_read_notifications(self, id: int) -> typing.List[Notification]: 137 | return self.request( 138 | "Notification", "findLastReadNotifications", [id, None] 139 | ) 140 | 141 | def mark_notifications_as_seen( 142 | self, id: int, notification_ids: typing.List[int] 143 | ) -> int: 144 | return self.request( 145 | "Notification", "markAsSeen", [id, notification_ids] 146 | ) 147 | 148 | def mark_notifications_as_read( 149 | self, id: int, notification_ids: typing.List[int] 150 | ) -> int: 151 | return self.request( 152 | "Notification", "markAsRead", [id, notification_ids] 153 | ) 154 | 155 | # Leaderboards 156 | 157 | def get_global_leaderboard( 158 | self, 159 | page: int, 160 | type: str, 161 | group: str, 162 | handle: str = "", 163 | filter: typing.Optional[dict] = None, 164 | ): 165 | filter = filter or DEFAULT_FILTER 166 | return self.request( 167 | "Leaderboards", 168 | "getGlobalLeaderboard", 169 | [page, type, filter, handle, True, group], 170 | ) 171 | 172 | def get_challenge_leaderboard( 173 | self, 174 | challenge_id: str, 175 | group: str, 176 | handle: str = "", 177 | filter: typing.Optional[dict] = None, 178 | ): 179 | filter = filter or DEFAULT_FILTER 180 | return self.request( 181 | "Leaderboards", 182 | "getFilteredChallengeLeaderboard", 183 | [challenge_id, handle, group, filter], 184 | ) 185 | 186 | def get_puzzle_leaderboard( 187 | self, 188 | puzzle_id: str, 189 | group: str, 190 | handle: str = "", 191 | filter: typing.Optional[dict] = None, 192 | ): 193 | filter = filter or DEFAULT_FILTER 194 | return self.request( 195 | "Leaderboards", 196 | "getFilteredPuzzleLeaderboard", 197 | [puzzle_id, handle, group, filter], 198 | ) 199 | -------------------------------------------------------------------------------- /codingame/http/client.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from .base import BaseHTTPClient 4 | 5 | if typing.TYPE_CHECKING: 6 | from ..state import ConnectionState 7 | 8 | __all__ = ("HTTPClient",) 9 | 10 | 11 | class HTTPClient(BaseHTTPClient): 12 | def __new__(cls, state: "ConnectionState", is_async: bool = False): 13 | if is_async: 14 | from .async_ import AsyncHTTPClient 15 | 16 | return AsyncHTTPClient(state) 17 | else: 18 | from .sync import SyncHTTPClient 19 | 20 | return SyncHTTPClient(state) 21 | -------------------------------------------------------------------------------- /codingame/http/httperror.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from http import HTTPStatus 3 | 4 | if typing.TYPE_CHECKING: 5 | import aiohttp 6 | import requests 7 | 8 | 9 | __all__ = ("HTTPError",) 10 | 11 | 12 | class HTTPError(Exception): 13 | def __init__(self, status_code: int, reason: str, data): 14 | self.status_code: int = status_code 15 | self.reason: str = reason or HTTPStatus(status_code).phrase 16 | self.data = data 17 | 18 | def __str__(self): 19 | return f"HTTPError: {self.status_code} {self.reason}, {self.data!r}" 20 | 21 | def __repr__(self): 22 | return f"{self.__class__.__name__}({self.status_code}, {self.reason!r})" 23 | 24 | @classmethod 25 | def from_requests( 26 | cls, http_error: "requests.HTTPError", data 27 | ) -> "HTTPError": 28 | status_code = http_error.response.status_code 29 | reason = http_error.response.reason 30 | return cls(status_code, reason, data) 31 | 32 | @classmethod 33 | def from_aiohttp( 34 | cls, http_error: "aiohttp.ClientResponseError", data 35 | ) -> "HTTPError": 36 | status_code = http_error.status 37 | reason = http_error.message 38 | return cls(status_code, reason, data) 39 | -------------------------------------------------------------------------------- /codingame/http/sync.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import requests 4 | 5 | from .base import BaseHTTPClient 6 | from .httperror import HTTPError 7 | 8 | if typing.TYPE_CHECKING: 9 | from ..state import ConnectionState 10 | 11 | 12 | __all__ = ("SyncHTTPClient",) 13 | 14 | 15 | class SyncHTTPClient(BaseHTTPClient): 16 | def __init__(self, state: "ConnectionState"): 17 | self.state = state 18 | self.__session: requests.Session = requests.Session() 19 | 20 | @property 21 | def is_async(self) -> bool: 22 | return False 23 | 24 | def close(self): 25 | self.__session.close() 26 | 27 | def request( 28 | self, service: str, func: str, parameters: typing.Optional[list] = None 29 | ): 30 | parameters = parameters or [] 31 | url = self.API_URL + service + "/" + func 32 | with self.__session.post( 33 | url, json=parameters, headers=self.headers 34 | ) as response: 35 | data = response.json() 36 | try: 37 | response.raise_for_status() 38 | except requests.HTTPError as error: 39 | raise HTTPError.from_requests(error, data) from None 40 | return data 41 | 42 | def set_cookie( 43 | self, 44 | name: str, 45 | value: typing.Optional[str] = None, 46 | domain: str = "www.codingame.com", 47 | ): 48 | return self.__session.cookies.set(name, value, domain=domain) 49 | -------------------------------------------------------------------------------- /codingame/notification/__init__.py: -------------------------------------------------------------------------------- 1 | from .data import ( 2 | AchievementUnlockedData, 3 | CareerCandidateData, 4 | ClashInviteData, 5 | ClashOverData, 6 | Contribution, 7 | ContributionData, 8 | ContributionModeratedData, 9 | CustomData, 10 | FeatureData, 11 | FriendRegisteredData, 12 | GenericData, 13 | JobAcceptedData, 14 | JobExpiredData, 15 | LanguageMapping, 16 | LeagueData, 17 | NewBlogData, 18 | NewCommentData, 19 | NewHintData, 20 | NewLevelData, 21 | NewPuzzleData, 22 | NewWorkBlogData, 23 | NotificationData, 24 | OfferApplyData, 25 | PuzzleOfTheWeekData, 26 | PuzzleSolution, 27 | QuestCompletedData, 28 | TestFinishedData, 29 | ) 30 | from .enums import ( 31 | CommentType, 32 | ContributionModeratedActionType, 33 | ContributionType, 34 | NotificationType, 35 | NotificationTypeGroup, 36 | ) 37 | from .notification import Notification 38 | 39 | __all__ = ( 40 | Notification, 41 | # enums 42 | NotificationType, 43 | NotificationTypeGroup, 44 | ContributionType, 45 | CommentType, 46 | ContributionModeratedActionType, 47 | # data classes 48 | LanguageMapping, 49 | NotificationData, 50 | AchievementUnlockedData, 51 | LeagueData, 52 | NewBlogData, 53 | ClashInviteData, 54 | ClashOverData, 55 | Contribution, 56 | PuzzleSolution, 57 | NewCommentData, 58 | ContributionData, 59 | FeatureData, 60 | NewHintData, 61 | ContributionModeratedData, 62 | NewPuzzleData, 63 | PuzzleOfTheWeekData, 64 | QuestCompletedData, 65 | FriendRegisteredData, 66 | NewLevelData, 67 | GenericData, 68 | CustomData, 69 | CareerCandidateData, 70 | TestFinishedData, 71 | JobAcceptedData, 72 | JobExpiredData, 73 | NewWorkBlogData, 74 | OfferApplyData, 75 | ) 76 | -------------------------------------------------------------------------------- /codingame/notification/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | __all__ = ( 4 | "NotificationTypeGroup", 5 | "NotificationType", 6 | "ContributionType", 7 | "CommentType", 8 | "ContributionModeratedActionType", 9 | ) 10 | 11 | 12 | class NotificationTypeGroup(str, Enum): 13 | """Enumeration for the :attr:`Notification.type_group`. 14 | 15 | .. warning:: 16 | There might be some missing type groups. 17 | """ 18 | 19 | achievement = "achievement" 20 | arena = "arena" 21 | blog = "blog" 22 | clash = "clash" 23 | comment = "comment" 24 | contest = "contest" 25 | contribution = "contribution" 26 | feature = "feature" 27 | hints = "hints" 28 | moderation = "moderation" 29 | puzzle = "puzzle" 30 | quest = "quest" 31 | social = "social" 32 | xp = "xp" 33 | generic = "generic" 34 | custom = "custom" 35 | other = "other" 36 | 37 | 38 | class NotificationType(str, Enum): 39 | """Enumeration for the :attr:`Notification.type`.""" 40 | 41 | # achievement 42 | achievement_unlocked = "achievement-unlocked" 43 | """When a new achievement is unlocked.""" 44 | 45 | # arena 46 | new_league = "new-league" 47 | """When a new league is added to an arena. 48 | 49 | If the new league is higher than your current one, you will get demoted, 50 | otherwise your league will stay the same.""" 51 | eligible_for_next_league = "eligible-for-next-league" 52 | """When you are better than the boss of your current league. 53 | 54 | This means you will be promoted soon.""" 55 | promoted_league = "promoted-league" 56 | """When you are promoted to a higher league.""" 57 | 58 | # blog 59 | new_blog = "new-blog" 60 | """When a new blog entry is created.""" 61 | 62 | # clash 63 | clash_invite = "clash-invite" 64 | """When you are invited to a Clash of Code.""" 65 | clash_over = "clash-over" 66 | """When a Clash of Code you participated in is over.""" 67 | 68 | # comment 69 | new_comment = "new-comment" 70 | """When someone comments your contribution or your solution.""" 71 | new_comment_response = "new-comment-response" 72 | """When someone replies to your commeny.""" 73 | 74 | # contest 75 | contest_scheduled = "contest-scheduled" 76 | """When a contest is scheduled.""" 77 | contest_soon = "contest-soon" 78 | """When a contest is starting soon.""" 79 | contest_started = "contest-started" 80 | """When a contest has started.""" 81 | contest_over = "contest-over" 82 | """When a contest is over.""" 83 | 84 | # contribution 85 | contribution_received = "contribution-received" 86 | """When your contribution is received.""" 87 | contribution_accepted = "contribution-accepted" 88 | """When your contribution is accepted.""" 89 | contribution_refused = "contribution-refused" 90 | """When your contribution is refused.""" 91 | contribution_clash_mode_removed = "contribution-clash-mode-removed" 92 | """When your contribution is modified.""" 93 | 94 | # feature 95 | feature = "feature" 96 | """When a new feature is available on CodinGame.""" 97 | 98 | # hints 99 | new_hint = "new-hint" 100 | """When a new hint is revealed.""" 101 | 102 | # moderation 103 | contribution_moderated = "contribution-moderated" 104 | """When your contribution is validated or denied.""" 105 | 106 | # puzzle 107 | new_puzzle = "new-puzzle" 108 | """When a new puzzle is available.""" 109 | puzzle_of_the_week = "puzzle-of-the-week" 110 | """When the puzzle of the week is available.""" 111 | new_league_opened = "new-league-opened" 112 | """When a new league is opened. 113 | 114 | I don't know why this isn't in :attr:`NotificationTypeGroup.arena` like 115 | :attr:`NotificationType.new_league`, 116 | :attr:`NotificationType.eligible_for_next_league` and 117 | :attr:`NotificationType.promoted_league`.""" 118 | 119 | # quest 120 | quest_completed = "quest-completed" 121 | """When a quest is completed.""" 122 | 123 | # social 124 | following = "following" 125 | """When a CodinGamer starts following you.""" 126 | friend_registered = "friend-registered" 127 | """When a friend registers on CodinGame.""" 128 | invitation_accepted = "invitation-accepted" 129 | """When a friend accepts your invitation and registers on CodinGame.""" 130 | 131 | # xp 132 | new_level = "new-level" 133 | """When you reach a new level.""" 134 | 135 | # generic 136 | info_generic = "info-generic" 137 | """When you get a generic information notification.""" 138 | warning_generic = "warning-generic" 139 | """When you get a generic warning notification.""" 140 | important_generic = "important-generic" 141 | """When you get a generic important notification.""" 142 | 143 | # custom 144 | custom = "custom" 145 | """When you get a custom notification.""" 146 | 147 | # other 148 | career_new_candidate = "career-new-candidate" 149 | career_update_candidate = "career-update-candidate" 150 | 151 | # didn't find a category 152 | test_finished = "test-finished" 153 | job_accepted = "job-accepted" 154 | job_expired = "job-expired" 155 | new_work_blog = "new-work-blog" 156 | offer_apply = "offer-apply" 157 | recruiter_contact = "recruiter-contact" 158 | 159 | 160 | class ContributionType(str, Enum): 161 | clash_of_code = "CLASHOFCODE" 162 | puzzle_in_out = "PUZZLE_INOUT" 163 | puzzle_solo = "PUZZLE_SOLO" 164 | puzzle_multiplayer = "PUZZLE_MULTI" 165 | puzzle_optimization = "PUZZLE_OPTI" 166 | 167 | 168 | class CommentType(str, Enum): 169 | contribution = "CONTRIBUTION" 170 | solution = "SOLUTION" 171 | 172 | 173 | class ContributionModeratedActionType(str, Enum): 174 | validate = "validate" 175 | deny = "deny" 176 | -------------------------------------------------------------------------------- /codingame/notification/notification.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from datetime import datetime 3 | 4 | from ..abc import BaseObject 5 | from ..codingamer import PartialCodinGamer 6 | from ..types import notification as types 7 | from ..utils import to_datetime 8 | from .data import NotificationData 9 | from .enums import NotificationType, NotificationTypeGroup 10 | 11 | if typing.TYPE_CHECKING: 12 | from ..state import ConnectionState 13 | 14 | __all__ = ("Notification",) 15 | 16 | 17 | class Notification(BaseObject): 18 | """Represents a Notification. 19 | 20 | Attributes 21 | ----------- 22 | id: :class:`int` 23 | ID of the notification. 24 | 25 | type_group: :class:`NotificationTypeGroup` 26 | Group type of the notification. 27 | 28 | type: :class:`NotificationType` 29 | Precise type of the notification. 30 | 31 | date: :class:`~datetime.datetime` 32 | Date of the notification. Was ``notification.creation_time``. 33 | 34 | creation_time: :class:`~datetime.datetime` 35 | Date of the notification. 36 | 37 | .. deprecated:: 1.3 38 | Use :attr:`date` instead. 39 | 40 | priority: :class:`int` 41 | Priority of the notification. 42 | 43 | urgent: :class:`bool` 44 | Whether the notification is urgent. 45 | 46 | seen: :class:`bool` 47 | Whether the notification has been seen. 48 | 49 | seen_date: Optional :class:`~datetime.datetime` 50 | Date when the notification was last marked as seen. 51 | 52 | read: :class:`bool` 53 | Whether the notification has been read. 54 | 55 | read_date: Optional :class:`~datetime.datetime` 56 | Date when the notification was last marked as read. 57 | 58 | data: Optional :class:`dict` 59 | Data of the notification. 60 | 61 | .. note:: 62 | Every notification type has different data. 63 | So there isn't the same keys and values every time. 64 | 65 | codingamer: Optional :class:`PartialCodinGamer` 66 | CodinGamer that sent the notification, only appears in some 67 | notification types. 68 | """ 69 | 70 | id: int 71 | type: NotificationType 72 | type_group: NotificationTypeGroup 73 | date: datetime 74 | priority: int 75 | urgent: bool 76 | seen: bool 77 | seen_date: typing.Optional[datetime] 78 | read: bool 79 | read_date: typing.Optional[datetime] 80 | data: typing.Optional[NotificationData] 81 | codingamer: typing.Optional[PartialCodinGamer] 82 | 83 | __slots__ = ( 84 | "id", 85 | "type", 86 | "type_group", 87 | "date", 88 | "creation_time", 89 | "priority", 90 | "urgent", 91 | "seen", 92 | "seen_date", 93 | "read", 94 | "read_date", 95 | "data", 96 | "codingamer", 97 | ) 98 | 99 | def __init__(self, state: "ConnectionState", data: types.Notification): 100 | self.id = data["id"] 101 | try: 102 | self.type = NotificationType(data["type"]) 103 | except ValueError: # pragma: no cover 104 | self.type = data["type"] 105 | print( 106 | f"Unknown notification type {self.type}, please report this at " 107 | "https://github.com/takos22/codingame/issues/new" 108 | f"\nPlease include this: {data!r}" 109 | ) 110 | 111 | try: 112 | self.type_group = NotificationTypeGroup(data["typeGroup"]) 113 | except ValueError: # pragma: no cover 114 | self.type_group = data["typeGroup"] 115 | print( 116 | f"Unknown notification type group {self.type_group}, please " 117 | "report this at https://github.com/takos22/codingame/issues/new" 118 | f"\nPlease include this: {data!r}" 119 | ) 120 | 121 | self.date = to_datetime(data["date"]) 122 | self.creation_time = self.date # deprecated 123 | self.priority = data["priority"] 124 | self.urgent = data["urgent"] 125 | 126 | self.seen = bool(data.get("seenDate")) 127 | self.seen_date = None 128 | if self.seen: 129 | self.seen_date = to_datetime(data["seenDate"]) 130 | 131 | self.read = bool(data.get("readDate")) 132 | self.read_date = None 133 | if self.read: 134 | self.read_date = to_datetime(data["readDate"]) 135 | 136 | self.data = NotificationData.from_type( 137 | self.type, state, data.get("data") 138 | ) 139 | self.codingamer = None 140 | if data.get("codingamer"): 141 | self.codingamer = PartialCodinGamer(state, data["codingamer"]) 142 | 143 | super().__init__(state) 144 | 145 | def __repr__(self): 146 | return ( 147 | "" 150 | ).format(self) 151 | 152 | def mark_as_seen( 153 | self, 154 | ) -> typing.Union[datetime, typing.Awaitable[datetime]]: 155 | """|maybe_coro| 156 | 157 | Mark this notification as seen. 158 | 159 | .. warning:: 160 | If you want to mark multiple notifications as seen at the same time, 161 | use :meth:`Client.mark_notifications_as_seen` as it only makes one 162 | API request for all the notifications instead of one API request for 163 | each notification. 164 | 165 | Returns 166 | ------- 167 | :class:`datetime` 168 | The time when this notification was marked as seen. 169 | 170 | .. versionadded:: 1.4 171 | """ 172 | 173 | if self._state.is_async: 174 | 175 | async def _mark_as_seen() -> datetime: 176 | data = await self._state.http.mark_notifications_as_seen( 177 | self._state.codingamer.id, [self.id] 178 | ) 179 | self._setattr("seen", True) 180 | self._setattr("seen_date", to_datetime(data)) 181 | return self.seen_date 182 | 183 | else: 184 | 185 | def _mark_as_seen() -> datetime: 186 | data = self._state.http.mark_notifications_as_seen( 187 | self._state.codingamer.id, [self.id] 188 | ) 189 | self._setattr("seen", True) 190 | self._setattr("seen_date", to_datetime(data)) 191 | return self.seen_date 192 | 193 | return _mark_as_seen() 194 | 195 | def mark_as_read( 196 | self, 197 | ) -> typing.Union[datetime, typing.Awaitable[datetime]]: 198 | """|maybe_coro| 199 | 200 | Mark this notification as read. 201 | 202 | .. warning:: 203 | If you want to mark multiple notifications as read at the same time, 204 | use :meth:`Client.mark_notifications_as_read` as it only makes one 205 | API request for all the notifications instead of one API request for 206 | each notification. 207 | 208 | Returns 209 | ------- 210 | :class:`datetime` 211 | The time when this notification was marked as read. 212 | 213 | .. versionadded:: 1.4 214 | """ 215 | 216 | if self._state.is_async: 217 | 218 | async def _mark_as_read() -> datetime: 219 | data = await self._state.http.mark_notifications_as_read( 220 | self._state.codingamer.id, [self.id] 221 | ) 222 | self._setattr("read", True) 223 | self._setattr("read_date", to_datetime(data)) 224 | return self.read_date 225 | 226 | else: 227 | 228 | def _mark_as_read() -> datetime: 229 | data = self._state.http.mark_notifications_as_read( 230 | self._state.codingamer.id, [self.id] 231 | ) 232 | self._setattr("read", True) 233 | self._setattr("read_date", to_datetime(data)) 234 | return self.read_date 235 | 236 | return _mark_as_read() 237 | -------------------------------------------------------------------------------- /codingame/state.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from .http import HTTPClient 4 | 5 | if typing.TYPE_CHECKING: 6 | from .codingamer import CodinGamer 7 | 8 | __all__ = ("ConnectionState",) 9 | 10 | 11 | class ConnectionState: 12 | """Saves information about the state of the connection to the API.""" 13 | 14 | http: "HTTPClient" 15 | logged_in: bool 16 | codingamer: typing.Optional["CodinGamer"] 17 | 18 | def __init__(self, is_async: bool = False): 19 | self.http = HTTPClient(self, is_async) 20 | 21 | self.logged_in = False 22 | self.codingamer = None 23 | 24 | @property 25 | def is_async(self) -> bool: 26 | "Whether the HTTP client is async." 27 | return self.http.is_async 28 | -------------------------------------------------------------------------------- /codingame/types/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | codingame.types 3 | ~~~~~~~~~~~~~~~ 4 | Typings for the CodinGame API. 5 | """ 6 | 7 | from . import clash_of_code, codingamer, notification 8 | from .clash_of_code import * # noqa: F403 9 | from .codingamer import * # noqa: F403 10 | from .notification import * # noqa: F403 11 | 12 | __all__ = clash_of_code.__all__ + codingamer.__all__ + notification.__all__ 13 | -------------------------------------------------------------------------------- /codingame/types/clash_of_code.py: -------------------------------------------------------------------------------- 1 | """ 2 | codingame.types.clash_of_code 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | Typings for the `ClashOfCode/` endpoints of the CodinGame API. 5 | """ 6 | 7 | from typing import List, Literal, Optional, TypedDict 8 | 9 | __all__ = ( 10 | "ClashOfCode", 11 | "Mode", 12 | "Modes", 13 | "LanguageId", 14 | "LanguageIds", 15 | "DurationType", 16 | "Player", 17 | "PlayerStatus", 18 | "PlayerTestSessionStatus", 19 | ) 20 | 21 | PlayerStatus = Literal["OWNER", "STANDARD"] 22 | PlayerTestSessionStatus = Literal["READY", "COMPLETED"] 23 | 24 | Mode = Literal["FASTEST", "REVERSE", "SHORTEST"] 25 | Modes = List[Mode] 26 | LanguageId = str 27 | LanguageIds = List[LanguageId] 28 | DurationType = Literal["SHORT"] # there might be other duration types 29 | 30 | 31 | class Player(TypedDict): 32 | codingamerId: int 33 | codingamerHandle: str 34 | status: PlayerStatus 35 | duration: int # time spent 36 | codingamerNickname: Optional[str] 37 | codingamerAvatarId: Optional[int] 38 | # available after start 39 | position: Optional[int] # join position 40 | rank: Optional[int] # not precise until submission 41 | testSessionHandle: Optional[str] 42 | testSessionStatus: Optional[PlayerTestSessionStatus] 43 | # available after submission 44 | score: Optional[int] # test case percentage 45 | criterion: Optional[int] # code length when mode is SHORTEST 46 | languageId: Optional[ 47 | LanguageId 48 | ] # sometimes available before, but can change 49 | solutionShared: Optional[bool] 50 | submissionId: Optional[int] 51 | 52 | 53 | class ClashOfCode(TypedDict): 54 | publicHandle: str 55 | nbPlayersMin: int 56 | nbPlayersMax: int 57 | clashDurationTypeId: DurationType 58 | creationTime: str 59 | startTime: str # estimation until started 60 | endTime: Optional[str] # available when started 61 | msBeforeStart: int # estimation until started 62 | msBeforeEnd: Optional[int] # available when started 63 | started: bool 64 | finished: bool 65 | publicClash: bool 66 | players: List[Player] 67 | modes: Optional[Modes] # available in private clashes or when started 68 | mode: Optional[Mode] # available when started 69 | programmingLanguages: Optional[ 70 | LanguageIds 71 | ] # available in private clashes or when started 72 | -------------------------------------------------------------------------------- /codingame/types/codingamer.py: -------------------------------------------------------------------------------- 1 | """ 2 | codingame.types.codingamer 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | Typings for the `CodinGamer/` endpoints of the CodinGame API. 5 | """ 6 | 7 | from typing import Dict, List, Literal, Optional, TypedDict 8 | 9 | __all__ = ( 10 | "PartialCodinGamer", 11 | "Category", 12 | "CodinGamerFromID", 13 | "CodinGamerFromHandle", 14 | "PointsStatsFromHandle", 15 | "Follower", 16 | "Following", 17 | ) 18 | 19 | 20 | class _FormValues(TypedDict): 21 | school: Optional[str] 22 | company: Optional[str] # not sure if company is in form values 23 | city: Optional[str] 24 | 25 | 26 | class _BaseCodinGamer(TypedDict, total=False): 27 | userId: int 28 | publicHandle: str 29 | countryId: str 30 | enable: bool 31 | pseudo: Optional[str] 32 | avatar: Optional[int] 33 | cover: Optional[int] 34 | 35 | 36 | class PartialCodinGamer(_BaseCodinGamer, total=True): 37 | pass 38 | 39 | 40 | class _BaseCodinGamerInfo(_BaseCodinGamer, total=False): 41 | level: int 42 | tagline: Optional[str] 43 | 44 | 45 | class _BaseCodinGamerFrom(_BaseCodinGamerInfo, total=False): 46 | formValues: _FormValues 47 | schoolId: Optional[int] 48 | company: Optional[str] 49 | biography: Optional[str] 50 | city: Optional[str] 51 | 52 | 53 | class CodinGamerFromID(_BaseCodinGamerFrom, total=True): 54 | pass 55 | 56 | 57 | Category = Literal["STUDENT", "PROFESSIONAL", "UNKNOWN"] 58 | 59 | 60 | class CodinGamerFromHandle(_BaseCodinGamerFrom, total=True): 61 | rank: int 62 | xp: int 63 | category: Category 64 | onlineSince: Optional[int] 65 | 66 | 67 | class _RankHistorics(TypedDict): 68 | ranks: List[int] 69 | totals: List[int] 70 | points: List[int] 71 | contestPoints: List[int] 72 | optimPoints: List[int] 73 | codegolfPoints: List[int] 74 | multiTrainingPoints: List[int] 75 | clashPoints: List[int] 76 | dates: List[int] 77 | 78 | 79 | class _PointsRankingDto(TypedDict): 80 | rankHistorics: _RankHistorics 81 | codingamePointsTotal: int 82 | codingamePointsRank: int 83 | codingamePointsContests: int 84 | codingamePointsAchievements: int 85 | codingamePointsXp: int 86 | codingamePointsOptim: int 87 | codingamePointsCodegolf: int 88 | codingamePointsMultiTraining: int 89 | codingamePointsClash: int 90 | numberCodingamers: int 91 | numberCodingamersGlobal: int 92 | 93 | 94 | class _XpThreshold(TypedDict): 95 | level: int 96 | xpThreshold: int 97 | cumulativeXp: int 98 | rewardLanguages: Optional[Dict[int, str]] 99 | 100 | 101 | class PointsStatsFromHandle(TypedDict): 102 | codingamerPoints: int 103 | achievementCount: int 104 | codingamer: CodinGamerFromHandle 105 | codingamePointsRankingDto: _PointsRankingDto 106 | xpThresholds: List[_XpThreshold] 107 | 108 | 109 | class _BaseFriend(_BaseCodinGamerInfo, total=False): 110 | rank: int 111 | points: int 112 | isFollowing: bool 113 | isFollower: bool 114 | schoolField: Optional[str] 115 | companyField: Optional[str] 116 | languages: Optional[str] # actually a json encoded list 117 | 118 | 119 | class Follower(_BaseFriend, total=True): 120 | pass 121 | 122 | 123 | class Following(_BaseFriend, total=True): 124 | pass 125 | -------------------------------------------------------------------------------- /codingame/types/notification.py: -------------------------------------------------------------------------------- 1 | """ 2 | codingame.types.notification 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | Typings for the `Notifications/` endpoints of the CodinGame API. 5 | """ 6 | 7 | from typing import Dict, Optional, Union 8 | 9 | try: 10 | from typing import Literal, TypedDict 11 | except ImportError: # pragma: no cover 12 | from typing_extensions import Literal, TypedDict 13 | 14 | Literal.__module__ = TypedDict.__module__ = "typing" 15 | 16 | from .codingamer import PartialCodinGamer 17 | 18 | __all__ = ( 19 | "NotificationTypeGroup", 20 | "NotificationType", 21 | "NotificationData", 22 | "Notification", 23 | "FollowingData", 24 | "FriendRegisteredData", 25 | "InvitationAcceptedData", 26 | "ContestScheduledData", 27 | "ContestSoonData", 28 | "ContestStartedData", 29 | "ContestOverData", 30 | "NewCommentData", 31 | "NewCommentResponseData", 32 | "ClashInviteData", 33 | "ClashOverData", 34 | "AchievementUnlockedData", 35 | "NewLevelData", 36 | "NewBlogData", 37 | "FeatureData", 38 | "NewLeagueData", 39 | "ElligibleForNextLeagueData", 40 | "PromotedLeague", 41 | "ContributionReceivedData", 42 | "ContributionAcceptedData", 43 | "ContributionRefusedData", 44 | "ContributionClashModeRemovedData", 45 | "NewPuzzleData", 46 | "PuzzleOfTheWeekData", 47 | "NewLeagueOpenedData", 48 | "NewHintData", 49 | "ContributionModeratedData", 50 | "QuestCompletedData", 51 | "InfoGenericData", 52 | "WarningGenericData", 53 | "ImportantGenericData", 54 | "CustomData", 55 | "CareerNewCandidateData", 56 | "CareerUpdateCandidateData", 57 | "TestFinishedData", 58 | "JobAcceptedData", 59 | "JobExpiredData", 60 | "NewWorkBlogData", 61 | "OfferApplyData", 62 | ) 63 | 64 | NotificationTypeGroup = Literal[ 65 | "social", 66 | "contest", 67 | "comment", 68 | "clash", 69 | "other", 70 | "achievement", 71 | "xp", 72 | "blog", 73 | "feature", 74 | "arena", 75 | "contribution", 76 | "puzzle", 77 | "hints", 78 | "moderation", 79 | "quest", 80 | ] 81 | NotificationType = Literal[ 82 | # social 83 | "following", 84 | "friend-registered", 85 | "invitation-accepted", 86 | # contest 87 | "contest-scheduled", 88 | "contest-soon", 89 | "contest-started", 90 | "contest-over", 91 | # comment 92 | "new-comment", 93 | "new-comment-response", 94 | # clash 95 | "clash-invite", 96 | "clash-over", 97 | # achievement 98 | "achievement-unlocked", 99 | # xp 100 | "new-level", 101 | # blog 102 | "new-blog", 103 | # feature 104 | "feature", 105 | # arena 106 | "new-league", 107 | "eligible-for-next-league", 108 | "promoted-league", 109 | # contribution 110 | "contribution-received", 111 | "contribution-accepted", 112 | "contribution-refused", 113 | "contribution-clash-mode-removed", 114 | # puzzle 115 | "new-puzzle", 116 | "puzzle-of-the-week", 117 | "new-league-opened", 118 | # hints 119 | "new-hint", 120 | # moderation 121 | "contribution-moderated", 122 | # quest 123 | "quest-completed", 124 | # generic 125 | "info-generic", 126 | "warning-generic", 127 | "important-generic", 128 | # custom 129 | "custom", 130 | # other 131 | "career-new-candidate", 132 | "career-update-candidate", 133 | # didn't find the category for these 134 | "test-finished", 135 | "job-accepted", 136 | "job-expired", 137 | "new-work-blog", 138 | "offer-apply", 139 | "recruiter-contact", 140 | ] 141 | 142 | LanguageMapping = Dict[str, str] # "language": "text" 143 | 144 | # social 145 | 146 | FollowingData = None 147 | 148 | 149 | class FriendRegisteredData(TypedDict): 150 | name: str 151 | 152 | 153 | InvitationAcceptedData = None 154 | 155 | # contest 156 | 157 | 158 | class ContestData(TypedDict, total=False): 159 | contest: str # name 160 | publicId: str 161 | imageId: int 162 | 163 | 164 | class ContestScheduledData(ContestData, total=True): 165 | date: int # UTC timestamp with ms 166 | 167 | 168 | class ContestSoonData(ContestData, total=True): 169 | hours: int # hours until start 170 | 171 | 172 | class ContestStartedData(ContestData, total=True): 173 | pass 174 | 175 | 176 | class ContestOverData(ContestData, total=True): 177 | rank: int 178 | playerCount: int 179 | 180 | 181 | # comment 182 | 183 | 184 | class ContributionData(TypedDict): 185 | handle: str 186 | title: str # maybe optional 187 | type: Literal[ 188 | "CLASHOFCODE", 189 | "PUZZLE_INOUT", 190 | "PUZZLE_MULTI", 191 | "PUZZLE_SOLO", 192 | "PUZZLE_OPTI", 193 | ] # maybe optional 194 | 195 | 196 | class PuzzleSolutionData(TypedDict): 197 | puzzleId: str 198 | puzzleDetailsPageUrl: Optional[str] 199 | testSessionSubmissionId: int 200 | 201 | 202 | class BaseNewCommentData(TypedDict, total=False): 203 | type: LanguageMapping 204 | 205 | 206 | class CompleteNewCommentData(BaseNewCommentData, total=True): 207 | commentType: Literal["CONTRIBUTION", "SOLUTION"] 208 | typeData: Union[ContributionData, PuzzleSolutionData] 209 | commentId: int 210 | 211 | 212 | class URLNewCommentData(BaseNewCommentData, total=True): 213 | url: str 214 | 215 | 216 | NewCommentData = NewCommentResponseData = Union[ 217 | CompleteNewCommentData, URLNewCommentData 218 | ] 219 | 220 | # clash 221 | 222 | 223 | class ClashInviteData(TypedDict): 224 | handle: str 225 | 226 | 227 | class ClashOverData(TypedDict): 228 | handle: str 229 | rank: int 230 | playerCount: int 231 | 232 | 233 | # achievement 234 | 235 | 236 | class AchievementUnlockedData(TypedDict): 237 | id: str 238 | imageId: int 239 | points: int 240 | level: str 241 | completionTime: int 242 | label: LanguageMapping 243 | 244 | 245 | # xp 246 | 247 | 248 | class NewLevelData(TypedDict): 249 | level: int 250 | reward: Optional[LanguageMapping] 251 | triggerCareerPopup: Optional[bool] 252 | 253 | 254 | # blog 255 | 256 | 257 | class NewBlogData(TypedDict): 258 | title: LanguageMapping 259 | url: LanguageMapping 260 | 261 | 262 | # feature 263 | 264 | 265 | class FeatureData(TypedDict): 266 | title: Optional[LanguageMapping] 267 | description: LanguageMapping 268 | "image-instant" # : str 269 | url: str 270 | 271 | 272 | FeatureData.__annotations__["image-instant"] = str 273 | 274 | 275 | # arena 276 | 277 | # for new-league, new-league-opened, elligible-for-next-league, promoted-league 278 | class LeagueData(TypedDict): 279 | titleLabel: LanguageMapping 280 | divisionIndex: int 281 | divisionCount: int 282 | divisionOffset: int 283 | thresholdIndex: int 284 | thumbnailBinaryId: int 285 | testSessionHandle: str 286 | 287 | 288 | NewLeagueData = ElligibleForNextLeagueData = PromotedLeague = LeagueData 289 | 290 | 291 | # contribution 292 | 293 | 294 | ContributionReceivedData = ContributionAcceptedData = ContributionData 295 | ContributionRefusedData = ContributionClashModeRemovedData = ContributionData 296 | 297 | 298 | # puzzle 299 | 300 | 301 | class NewPuzzleData(TypedDict): 302 | level: LanguageMapping 303 | name: LanguageMapping 304 | image: str # image url 305 | puzzleId: int 306 | 307 | 308 | class PuzzleOfTheWeekData(TypedDict): 309 | puzzleId: int 310 | puzzleLevel: str 311 | puzzlePrettyId: str 312 | puzzleName: LanguageMapping 313 | puzzleOfTheWeekImageId: int 314 | contributorNickname: str 315 | contributorAvatarId: Optional[int] 316 | 317 | 318 | NewLeagueOpenedData = LeagueData 319 | 320 | # hint 321 | 322 | 323 | class NewHintData(TypedDict): 324 | puzzleTitle: LanguageMapping 325 | thumbnailBinaryId: int 326 | testSessionHandle: str 327 | 328 | 329 | # moderation 330 | 331 | 332 | class ContributionModeratedData(TypedDict): 333 | actionType: Literal["validate", "deny"] 334 | contribution: ContributionData 335 | 336 | 337 | # quest 338 | 339 | 340 | class QuestCompletedData(TypedDict): 341 | questId: int 342 | label: LanguageMapping 343 | 344 | 345 | # generic 346 | 347 | 348 | class GenericData(TypedDict): 349 | description: LanguageMapping 350 | url: str 351 | 352 | 353 | InfoGenericData = WarningGenericData = ImportantGenericData = GenericData 354 | 355 | 356 | # custom 357 | 358 | 359 | class CustomData(TypedDict): 360 | title: LanguageMapping 361 | description: LanguageMapping 362 | image: str # url of the image 363 | url: str 364 | 365 | 366 | # other 367 | 368 | 369 | class CareerCandidateData(TypedDict): 370 | handle: str 371 | username: Optional[str] 372 | country: str 373 | region: str 374 | avatar: Optional[int] 375 | 376 | 377 | CareerNewCandidateData = CareerUpdateCandidateData = CareerCandidateData 378 | 379 | 380 | # no category 381 | 382 | 383 | class TestFinishedData(TypedDict): 384 | campaignId: int # probably 385 | candidateId: int # probably 386 | candidateName: Optional[str] 387 | candidateEmail: str 388 | 389 | 390 | class JobAcceptedData(TypedDict): 391 | jobName: Optional[str] 392 | jobOfferLocation: str 393 | challengeId: Optional[int] 394 | 395 | 396 | class JobExpiredData(TypedDict): 397 | jobName: Optional[str] 398 | 399 | 400 | NewWorkBlogData = NewBlogData 401 | 402 | 403 | class OfferApplyData(TypedDict): 404 | candidateName: str 405 | jobName: Optional[str] 406 | jobOfferLocation: str 407 | challengeId: Optional[int] 408 | jobOfferId: Optional[int] 409 | jobOfferApplicantId: Optional[int] 410 | 411 | 412 | # notification 413 | 414 | NotificationData = Union[ 415 | FollowingData, 416 | FriendRegisteredData, 417 | InvitationAcceptedData, 418 | ContestScheduledData, 419 | ContestSoonData, 420 | ContestStartedData, 421 | ContestOverData, 422 | NewCommentData, 423 | NewCommentResponseData, 424 | ClashInviteData, 425 | ClashOverData, 426 | AchievementUnlockedData, 427 | NewLevelData, 428 | NewBlogData, 429 | FeatureData, 430 | NewLeagueData, 431 | ElligibleForNextLeagueData, 432 | PromotedLeague, 433 | ContributionReceivedData, 434 | ContributionAcceptedData, 435 | ContributionRefusedData, 436 | ContributionClashModeRemovedData, 437 | NewPuzzleData, 438 | PuzzleOfTheWeekData, 439 | NewLeagueOpenedData, 440 | NewHintData, 441 | ContributionModeratedData, 442 | QuestCompletedData, 443 | InfoGenericData, 444 | WarningGenericData, 445 | ImportantGenericData, 446 | CustomData, 447 | CareerNewCandidateData, 448 | CareerUpdateCandidateData, 449 | TestFinishedData, 450 | JobAcceptedData, 451 | JobExpiredData, 452 | NewWorkBlogData, 453 | OfferApplyData, 454 | ] 455 | 456 | 457 | class Notification(TypedDict): 458 | id: int 459 | type: NotificationType 460 | typeGroup: NotificationTypeGroup 461 | priority: int 462 | urgent: bool 463 | date: int # UTC timestamp with ms 464 | seenDate: Optional[int] # UTC timestamp with ms 465 | readDate: Optional[int] # UTC timestamp with ms 466 | data: Optional[NotificationData] 467 | codingamer: Optional[PartialCodinGamer] 468 | -------------------------------------------------------------------------------- /codingame/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import typing 3 | from datetime import datetime, timezone 4 | 5 | from .exceptions import LoginRequired 6 | 7 | __all__ = ( 8 | "CODINGAMER_HANDLE_REGEX", 9 | "CLASH_OF_CODE_HANDLE_REGEX", 10 | "validate_leaderboard_type", 11 | "validate_leaderboard_group", 12 | "DT_FORMAT_1", 13 | "DT_FORMAT_2", 14 | "to_datetime", 15 | ) 16 | 17 | CODINGAMER_HANDLE_REGEX = re.compile(r"[0-9a-f]{32}[0-9]{7}") 18 | CLASH_OF_CODE_HANDLE_REGEX = re.compile(r"[0-9]{7}[0-9a-f]{32}") 19 | 20 | 21 | def validate_leaderboard_type(type: str) -> str: 22 | """Validates that the leaderboard type is one of ``"GENERAL"``, 23 | ``"CONTESTS"``, ``"BOT_PROGRAMMING"``, ``"OPTIM"`` or ``"CODEGOLF"``. 24 | 25 | Parameters 26 | ---------- 27 | type : :class:`str` 28 | The type to validate. 29 | 30 | Returns 31 | ------- 32 | :class:`str` 33 | The valid type. 34 | 35 | Raises 36 | ------ 37 | ValueError 38 | The type is invalid. 39 | """ 40 | 41 | type = type.upper() 42 | if type not in [ 43 | "GENERAL", 44 | "CONTESTS", 45 | "BOT_PROGRAMMING", 46 | "OPTIM", 47 | "CODEGOLF", 48 | ]: 49 | raise ValueError( 50 | "type argument must be one of: GENERAL, CONTESTS, " 51 | f"BOT_PROGRAMMING, OPTIM, CODEGOLF. Got: {type}" 52 | ) 53 | 54 | return type 55 | 56 | 57 | def validate_leaderboard_group(group: str, logged_in: bool) -> str: 58 | """Validates that the leaderboard group is one of ``"global"``, 59 | ``"country"``, ``"company"``, ``"school"`` or ``"following"`` and that the 60 | user is logged in except for ``"global"``. 61 | 62 | Parameters 63 | ---------- 64 | type : :class:`str` 65 | The type to validate. 66 | logged_in : :class:`bool` 67 | Whether the user is logged in. 68 | 69 | Returns 70 | ------- 71 | :class:`str` 72 | The valid group. 73 | 74 | Raises 75 | ------ 76 | ValueError 77 | The group is invalid. 78 | """ 79 | 80 | group = group.lower() 81 | if group not in [ 82 | "global", 83 | "country", 84 | "company", 85 | "school", 86 | "following", 87 | ]: 88 | raise ValueError( 89 | "group argument must be one of: global, country, company, " 90 | f"school, following. Got: {group}" 91 | ) 92 | 93 | if group in ["country", "company", "school", "following"] and not logged_in: 94 | raise LoginRequired() 95 | 96 | return group 97 | 98 | 99 | DT_FORMAT_1 = "%b %d, %Y %I:%M:%S %p" 100 | DT_FORMAT_2 = "%b %d, %Y, %I:%M:%S %p" # see issue #23 101 | 102 | 103 | def to_datetime(data: typing.Optional[typing.Union[int, str]]) -> datetime: 104 | if isinstance(data, int): 105 | return datetime.fromtimestamp(data / 1000.0, timezone.utc) 106 | elif isinstance(data, str): # pragma: no cover 107 | try: 108 | return datetime.strptime(data, DT_FORMAT_1) 109 | except ValueError: 110 | return datetime.strptime(data, DT_FORMAT_2) 111 | elif data is None: 112 | return None 113 | else: 114 | raise TypeError # pragma: no cover 115 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | autoflake~=2.3 2 | black~=22.6 3 | doc8~=1.1 4 | flake8~=7.0 5 | isort~=5.13 6 | pytest~=7.4 7 | pytest-asyncio~=0.16.0 8 | pytest-cov~=4.1 9 | pytest-mock~=3.6 10 | python-dotenv~=1.0 11 | setuptools~=69.1 12 | twine~=5.0 13 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/chrome_cookie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takos22/codingame/fb193bc62e66edad057f2e16dd5a81ea1a7e9931/docs/_static/chrome_cookie.png -------------------------------------------------------------------------------- /docs/_static/codingame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takos22/codingame/fb193bc62e66edad057f2e16dd5a81ea1a7e9931/docs/_static/codingame.png -------------------------------------------------------------------------------- /docs/_static/firefox_cookie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takos22/codingame/fb193bc62e66edad057f2e16dd5a81ea1a7e9931/docs/_static/firefox_cookie.png -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. module:: codingame 2 | :synopsis: Wrapper for the undocumented CodinGame API. 3 | 4 | codingame |version| API Reference 5 | ================================= 6 | 7 | The following section outlines the API of the ``codingame`` module. All the 8 | public classes, methods and functions are documented here. 9 | 10 | Version Related Info 11 | -------------------- 12 | 13 | There are two main ways to query version information about the library. 14 | 15 | .. data:: version_info 16 | 17 | A named tuple that is similar to :obj:`py:sys.version_info`. 18 | 19 | Just like :obj:`py:sys.version_info` the valid values for ``releaselevel`` are 20 | 'alpha', 'beta', 'candidate' and 'final'. 21 | 22 | .. data:: __version__ 23 | 24 | A string representation of the version. e.g. ``'1.0.0rc1'``. This is based 25 | off of :pep:`440`. 26 | 27 | Client 28 | ------ 29 | 30 | Hybrid client 31 | ************* 32 | 33 | .. autoclass:: Client 34 | 35 | Synchronous client 36 | ****************** 37 | 38 | .. autoclass:: codingame.client.sync.SyncClient 39 | 40 | .. currentmodule:: codingame 41 | 42 | Asynchronous client 43 | ******************* 44 | 45 | .. autoclass:: codingame.client.async_.AsyncClient 46 | 47 | .. currentmodule:: codingame 48 | 49 | .. _codingame_api_models: 50 | 51 | CodinGame Models 52 | ---------------- 53 | 54 | Models are classes that are created from the data received from CodinGame and 55 | are not meant to be created by the user of the library. 56 | 57 | .. danger:: 58 | 59 | The classes listed below are **not intended to be created by users** and are 60 | also **read-only**. 61 | 62 | For example, this means that you should not make your own 63 | :class:`CodinGamer` instances nor should you modify the :class:`CodinGamer` 64 | instance yourself. 65 | 66 | 67 | CodinGamer 68 | ********** 69 | 70 | .. autoclass:: PartialCodinGamer() 71 | 72 | .. autoclass:: CodinGamer() 73 | 74 | Clash of Code 75 | ************* 76 | 77 | .. autoclass:: ClashOfCode() 78 | 79 | .. autoclass:: Player() 80 | 81 | Notification 82 | ************ 83 | 84 | .. autoclass:: Notification() 85 | 86 | Enumerations 87 | ############ 88 | 89 | .. autoclass:: NotificationTypeGroup() 90 | :undoc-members: 91 | 92 | .. autoclass:: NotificationType() 93 | :undoc-members: 94 | 95 | .. autoclass:: ContributionType() 96 | :undoc-members: 97 | 98 | .. autoclass:: CommentType() 99 | :undoc-members: 100 | 101 | .. autoclass:: ContributionModeratedActionType() 102 | :undoc-members: 103 | 104 | Notification data 105 | ################# 106 | 107 | .. autoclass:: LanguageMapping() 108 | :undoc-members: 109 | :no-inherited-members: 110 | 111 | .. autoclass:: NotificationData() 112 | :undoc-members: 113 | :no-inherited-members: 114 | 115 | .. autoclass:: AchievementUnlockedData() 116 | :undoc-members: 117 | :no-inherited-members: 118 | 119 | .. autoclass:: LeagueData() 120 | :undoc-members: 121 | :no-inherited-members: 122 | 123 | .. autoclass:: NewBlogData() 124 | :undoc-members: 125 | :no-inherited-members: 126 | 127 | .. autoclass:: ClashInviteData() 128 | :undoc-members: 129 | :no-inherited-members: 130 | 131 | .. autoclass:: ClashOverData() 132 | :undoc-members: 133 | :no-inherited-members: 134 | 135 | .. autoclass:: Contribution() 136 | :undoc-members: 137 | :no-inherited-members: 138 | 139 | .. autoclass:: PuzzleSolution() 140 | :undoc-members: 141 | :no-inherited-members: 142 | 143 | .. autoclass:: NewCommentData() 144 | :undoc-members: 145 | :no-inherited-members: 146 | 147 | .. autoclass:: ContributionData() 148 | :undoc-members: 149 | :no-inherited-members: 150 | 151 | .. autoclass:: FeatureData() 152 | :undoc-members: 153 | :no-inherited-members: 154 | 155 | .. autoclass:: NewHintData() 156 | :undoc-members: 157 | :no-inherited-members: 158 | 159 | .. autoclass:: ContributionModeratedData() 160 | :undoc-members: 161 | :no-inherited-members: 162 | 163 | .. autoclass:: NewPuzzleData() 164 | :undoc-members: 165 | :no-inherited-members: 166 | 167 | .. autoclass:: PuzzleOfTheWeekData() 168 | :undoc-members: 169 | :no-inherited-members: 170 | 171 | .. autoclass:: QuestCompletedData() 172 | :undoc-members: 173 | :no-inherited-members: 174 | 175 | .. autoclass:: FriendRegisteredData() 176 | :undoc-members: 177 | :no-inherited-members: 178 | 179 | .. autoclass:: NewLevelData() 180 | :undoc-members: 181 | :no-inherited-members: 182 | 183 | .. autoclass:: GenericData() 184 | :undoc-members: 185 | :no-inherited-members: 186 | 187 | .. autoclass:: CustomData() 188 | :undoc-members: 189 | :no-inherited-members: 190 | 191 | .. autoclass:: CareerCandidateData() 192 | :undoc-members: 193 | :no-inherited-members: 194 | 195 | .. autoclass:: TestFinishedData() 196 | :undoc-members: 197 | :no-inherited-members: 198 | 199 | .. autoclass:: JobAcceptedData() 200 | :undoc-members: 201 | :no-inherited-members: 202 | 203 | .. autoclass:: JobExpiredData() 204 | :undoc-members: 205 | :no-inherited-members: 206 | 207 | .. autoclass:: NewWorkBlogData() 208 | :undoc-members: 209 | :no-inherited-members: 210 | 211 | .. autoclass:: OfferApplyData() 212 | :undoc-members: 213 | :no-inherited-members: 214 | 215 | Leaderboards 216 | ************ 217 | 218 | Global leaderboard 219 | ################## 220 | 221 | .. autoclass:: GlobalRankedCodinGamer() 222 | 223 | .. autoclass:: GlobalLeaderboard() 224 | 225 | Challenge leaderboard 226 | ##################### 227 | 228 | .. autoclass:: League() 229 | 230 | .. autoclass:: ChallengeRankedCodinGamer() 231 | 232 | .. autoclass:: ChallengeLeaderboard() 233 | 234 | Puzzle leaderboard 235 | ################## 236 | 237 | .. autoclass:: PuzzleRankedCodinGamer() 238 | 239 | .. autoclass:: PuzzleLeaderboard() 240 | 241 | Exceptions 242 | ---------- 243 | 244 | The following exceptions are thrown by the library. 245 | 246 | .. autoexception:: CodinGameAPIError 247 | 248 | .. autoexception:: LoginError 249 | 250 | .. autoexception:: EmailRequired 251 | 252 | .. autoexception:: MalformedEmail 253 | 254 | .. autoexception:: PasswordRequired 255 | 256 | .. autoexception:: EmailNotLinked 257 | 258 | .. autoexception:: IncorrectPassword 259 | 260 | .. autoexception:: WrongCaptchaAnswer 261 | 262 | .. autoexception:: LoginRequired 263 | 264 | .. autoexception:: NotFound 265 | 266 | .. autoexception:: CodinGamerNotFound 267 | 268 | .. autoexception:: ClashOfCodeNotFound 269 | 270 | .. autoexception:: ChallengeNotFound 271 | 272 | .. autoexception:: PuzzleNotFound 273 | 274 | Exception Hierarchy 275 | ******************* 276 | 277 | - :exc:`Exception` 278 | - :exc:`CodinGameAPIError` 279 | - :exc:`LoginError` 280 | - :exc:`EmailRequired` 281 | - :exc:`MalformedEmail` 282 | - :exc:`PasswordRequired` 283 | - :exc:`EmailNotLinked` 284 | - :exc:`IncorrectPassword` 285 | - :exc:`WrongCaptchaAnswer` 286 | - :exc:`LoginRequired` 287 | - :exc:`NotFound` 288 | - :exc:`CodinGamerNotFound` 289 | - :exc:`ClashOfCodeNotFound` 290 | - :exc:`ChallengeNotFound` 291 | - :exc:`PuzzleNotFound` 292 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: codingame 2 | 3 | Changelog 4 | ========= 5 | 6 | All notable changes to this project will be documented in this file. 7 | 8 | The format is based on 9 | `Keep a Changelog `__, and this project 10 | adheres to `Semantic Versioning `__. 11 | 12 | Version 1.4.3 (2024-02-21) 13 | -------------------------- 14 | 15 | Changed 16 | ******* 17 | 18 | - Every :class:`datetime.datetime` is now timezone aware, using the CodinGame 19 | timezone (UTC). 20 | - :attr:`ClashOfCode.creation_time` can be ``None`` because of an API change 21 | by CodinGame. 22 | 23 | Fixed 24 | ***** 25 | 26 | - :exc:`KeyError` was raised when using :meth:`Client.get_clash_of_code` because 27 | of an API change by CodinGame. 28 | 29 | Removed 30 | ******* 31 | 32 | - Removed support for python 3.7 as it has reached its end of life. For more 33 | information, see `PEP 537 `__. 34 | 35 | Version 1.4.2 (2023-04-14) 36 | -------------------------- 37 | 38 | Fixed 39 | ***** 40 | 41 | - :exc:`KeyError` was raised when using :meth:`Client.get_challenge_leaderboard` 42 | instead of :exc:`NotFound` because of an API change by CodinGame. 43 | 44 | Version 1.4.1 (2023-02-11) 45 | -------------------------- 46 | 47 | Fixed 48 | ***** 49 | 50 | - :exc:`ValueError` when using :meth:`Client.get_pending_clash_of_code` because 51 | of a change in datetime format by CodinGame. 52 | 53 | Version 1.4.0 (2022-08-19) 54 | -------------------------- 55 | 56 | Added 57 | ***** 58 | 59 | - :meth:`Client.mark_notifications_as_seen` and 60 | :meth:`Client.mark_notifications_as_read`. 61 | - :meth:`Notification.mark_as_seen` and :meth:`Notification.mark_as_read`. 62 | 63 | Removed 64 | ******* 65 | 66 | - Removed support for python 3.6 as it has reached its end of life. For more 67 | information, see `PEP 494 `__. 68 | 69 | Version 1.3.0 (2022-06-21) 70 | -------------------------- 71 | 72 | Added 73 | ***** 74 | 75 | - :meth:`Client.get_unread_notifications`. 76 | - :meth:`Client.get_read_notifications`. 77 | - :class:`PartialCodinGamer`. 78 | - :attr:`Notification.codingamer`. 79 | - :attr:`Notification.seen`, :attr:`Notification.seen_date`, 80 | :attr:`Notification.read` and :attr:`Notification.read_date`. 81 | - :class:`NotificationType` and :class:`NotificationTypeGroup` enums for 82 | :attr:`Notification.type` and :attr:`Notification.type_group`. 83 | - :class:`NotificationData` and subclasses. 84 | 85 | Changed 86 | ******* 87 | 88 | - Deprecated :attr:`Notification.creation_time` in favor of 89 | :attr:`Notification.date` 90 | 91 | Removed 92 | ******* 93 | 94 | - Removed ``Notification._raw``. 95 | 96 | Version 1.2.4 (2022-06-17) 97 | -------------------------- 98 | 99 | Fixed 100 | ***** 101 | 102 | - :meth:`CodinGamer.get_followers` and :meth:`CodinGamer.get_followed` now work 103 | while being logged in as any user, not just as the user you want to get the 104 | followers of. 105 | 106 | Version 1.2.3 (2021-11-07) 107 | -------------------------- 108 | 109 | Fixed 110 | ***** 111 | 112 | - :exc:`ImportError` of ``codingame.types`` submodule when importing 113 | ``codingame``, the ``1.2.1`` and ``1.2.2`` fixes don't work. 114 | 115 | Version 1.2.2 (2021-11-06) 116 | -------------------------- 117 | 118 | Fixed 119 | ***** 120 | 121 | - :exc:`ImportError` of ``codingame.types`` submodule when importing 122 | ``codingame``. 123 | 124 | Version 1.2.1 (2021-11-06) 125 | -------------------------- 126 | 127 | Fixed 128 | ***** 129 | 130 | - :exc:`ModuleNotFoundError` of ``codingame.types`` submodule when importing 131 | ``codingame``. 132 | 133 | Version 1.2.0 (2021-11-04) 134 | -------------------------- 135 | 136 | Added 137 | ***** 138 | 139 | - :meth:`Client.request` to make requests to CodinGame API services that aren't 140 | implemented yet in the library. 141 | 142 | Removed 143 | ******* 144 | 145 | - ``codingame.endpoints`` submodule. 146 | 147 | Version 1.1.0 (2021-11-01) 148 | -------------------------- 149 | 150 | Changed 151 | ******* 152 | 153 | - Update :meth:`Client.login` to bypass captcha on login endpoint with 154 | cookie based authentication, see :ref:`login`. 155 | 156 | Version 1.0.1 (2021-07-12) 157 | -------------------------- 158 | 159 | Added 160 | ***** 161 | 162 | - :meth:`CodinGamer.profile_url`. 163 | 164 | Version 1.0.0 (2021-07-12) 165 | -------------------------- 166 | 167 | Added 168 | ***** 169 | 170 | - Asynchronous client with ``Client(is_async=True)``, see :ref:`async`. 171 | 172 | - Context managers: 173 | 174 | .. code-block:: python 175 | 176 | # synchronous 177 | with Client() as client: 178 | client.get_global_leaderboard() 179 | 180 | #asynchronous 181 | async with Client(is_async=True) as client: 182 | await client.get_global_leaderboard() 183 | 184 | - More exceptions: :exc:`LoginError` regroups all the exceptions related 185 | to login: :exc:`LoginRequired`, :exc:`EmailRequired`, :exc:`MalformedEmail`, 186 | :exc:`PasswordRequired`, :exc:`EmailNotLinked` and :exc:`IncorrectPassword`. 187 | And :exc:`NotFound` regroups :exc:`CodinGamerNotFound`, 188 | :exc:`ClashOfCodeNotFound`, :exc:`ChallengeNotFound` and :exc:`PuzzleNotFound` 189 | 190 | - :attr:`ChallengeLeaderboard.has_leagues` and 191 | :attr:`PuzzleLeaderboard.has_leagues`. 192 | 193 | - :attr:`Notification._raw`. 194 | 195 | Changed 196 | ******* 197 | 198 | - Remove properties like ``CodinGamer.followers`` in favor of methods like 199 | :meth:`CodinGamer.get_followers` to better differentiate API calls and to make 200 | it compatible with async API calls. Here's a list of all of the changed ones: 201 | 202 | - ``Client.language_ids`` -> :meth:`Client.get_language_ids` 203 | - ``Client.notifications`` -> 204 | :meth:`Client.get_unseen_notifications` 205 | - ``CodinGamer.followers`` -> :meth:`CodinGamer.get_followers` 206 | - ``CodinGamer.followers_ids`` -> :meth:`CodinGamer.get_followers_ids` 207 | - ``CodinGamer.following`` -> :meth:`CodinGamer.get_followed` 208 | - ``CodinGamer.following_ids`` -> :meth:`CodinGamer.get_followed_ids` 209 | - ``CodinGamer.clash_of_code_rank`` -> 210 | :meth:`CodinGamer.get_clash_of_code_rank` 211 | 212 | - Make all attributes of CodinGame models read-only. 213 | 214 | - Change type of :attr:`ClashOfCode.time_before_start` and 215 | :attr:`ClashOfCode.time_before_end` from :class:`float` to 216 | :class:`datetime.timedelta`. 217 | 218 | - Rewrite the way the client works to implement a class to manage the connection 219 | state and separate the :class:`Client` that the user uses from the HTTP client 220 | that interacts with the API. 221 | 222 | Removed 223 | ******* 224 | 225 | - Remove argument type validation, not my fault if you can't read the docs. 226 | 227 | Version 0.4.0 (2021-06-19) 228 | -------------------------- 229 | 230 | Added 231 | ***** 232 | 233 | - :meth:`Client.get_global_leaderboard` with :class:`GlobalLeaderboard` and 234 | :class:`GlobalRankedCodinGamer`. 235 | 236 | - :meth:`Client.get_challenge_leaderboard` with 237 | :class:`ChallengeLeaderboard`, :class:`ChallengeRankedCodinGamer` and 238 | :class:`League`. 239 | 240 | - :meth:`Client.get_puzzle_leaderboard` with :class:`PuzzleLeaderboard`, 241 | :class:`PuzzleRankedCodinGamer` and :class:`League`. 242 | 243 | Changed 244 | ******* 245 | 246 | - Update docs style, code style and tests. 247 | 248 | Version 0.3.5 (2020-12-10) 249 | -------------------------- 250 | 251 | Added 252 | ***** 253 | 254 | - Get a user with their user ID in :meth:`Client.get_codingamer`. 255 | 256 | - ``CodinGamer.followers_ids`` and ``CodinGamer.following_ids`` properties to 257 | get information about followed users and followers without logging in. 258 | 259 | - ``CodinGamer.clash_of_code_rank``. 260 | 261 | Version 0.3.4 (2020-12-01) 262 | -------------------------- 263 | 264 | Added 265 | ***** 266 | 267 | - Support for python 3.9. 268 | 269 | Version 0.3.3 (2020-11-06) 270 | -------------------------- 271 | 272 | Added 273 | ***** 274 | 275 | - Searching for a CodinGamer with their pseudo in :meth:`Client.get_codingamer`. 276 | 277 | - :attr:`CodinGamer.xp`, thanks `@LiJu09 `__ 278 | (`#3 `__). 279 | 280 | Version 0.3.2 (2020-09-23) 281 | -------------------------- 282 | 283 | Added 284 | ***** 285 | 286 | - :meth:`Client.get_pending_clash_of_code`. 287 | 288 | Changed 289 | ******* 290 | 291 | - Renamed ``Notification.date`` to :attr:`Notification.creation_time`. 292 | 293 | Version 0.3.1 (2020-09-20) 294 | -------------------------- 295 | 296 | Added 297 | ***** 298 | 299 | - ``Client.notifications`` property. 300 | 301 | - :class:`Notification` class. 302 | 303 | - :exc:`LoginRequired` exception. 304 | 305 | Version 0.3.0 (2020-09-20) 306 | -------------------------- 307 | 308 | Added 309 | ***** 310 | 311 | - :meth:`Client.login`. 312 | 313 | - :meth:`Client.logged_in` and :meth:`Client.codingamer`. 314 | 315 | - ``Client.language_ids`` property. 316 | 317 | - ``CodinGamer.followers`` and ``CodinGamer.following`` properties. 318 | 319 | Version 0.2.1 (2020-09-16) 320 | -------------------------- 321 | 322 | Added 323 | ***** 324 | 325 | - Argument type validation. 326 | 327 | Version 0.2.0 (2020-09-13) 328 | -------------------------- 329 | 330 | Added 331 | ***** 332 | 333 | - :meth:`Client.get_clash_of_code`. 334 | 335 | - :class:`ClashOfCode` and :class:`Player` classes. 336 | 337 | - :exc:`ClashOfCodeNotFound` exception. 338 | 339 | Changed 340 | ******* 341 | 342 | - Renamed ``Client.codingamer()`` to :meth:`Client.get_codingamer`. 343 | 344 | Version 0.1.0 (2020-09-12) 345 | -------------------------- 346 | 347 | Added 348 | ***** 349 | 350 | - :class:`Client` class. 351 | 352 | - ``Client.codingamer()`` method to get a codingamer. 353 | 354 | - :class:`CodinGamer` class. 355 | 356 | - :exc:`CodinGamerNotFound` exception. 357 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | import os 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import re 16 | import sys 17 | from typing import NamedTuple 18 | 19 | VersionInfo = NamedTuple( 20 | "VersionInfo", major=int, minor=int, micro=int, releaselevel=str, serial=int 21 | ) 22 | 23 | sys.path.insert(0, os.path.abspath("..")) 24 | sys.path.append(os.path.abspath("extensions")) 25 | 26 | 27 | # -- Project information ----------------------------------------------------- 28 | 29 | project = "codingame" 30 | copyright = "2020 - now, takos22" 31 | author = "takos22" 32 | 33 | # The version info for the project you're documenting, acts as replacement for 34 | # |version| and |release|, also used in various other places throughout the 35 | # built documents. 36 | # 37 | # The short X.Y version. 38 | 39 | with open("../codingame/__init__.py") as f: 40 | # getting version info without importing the whole module 41 | version_info_code = re.search( 42 | r"^version_info\s*=\s*(VersionInfo\(\s*major=\d+,\s*minor=\d+,\s*" 43 | r'micro=\d+,\s*releaselevel=[\'"]\w*[\'"],\s*serial=\d+\s*\))', 44 | f.read(), 45 | re.MULTILINE, 46 | ).group(1) 47 | 48 | version_info: VersionInfo = eval( 49 | version_info_code, globals(), {"VersionInfo": VersionInfo} 50 | ) 51 | version = "{0.major}.{0.minor}.{0.micro}".format(version_info) 52 | 53 | 54 | # The full version, including alpha/beta/rc tags 55 | releaselevels = { 56 | "alpha": "a", 57 | "beta": "b", 58 | "releasecandidate": "rc", 59 | } 60 | release = version + ( 61 | releaselevels.get(version_info.releaselevel, version_info.releaselevel) 62 | + str(version_info.serial) 63 | if version_info.releaselevel 64 | else "" 65 | ) 66 | 67 | branch = "dev" if version_info.releaselevel else "v" + version 68 | 69 | 70 | # -- General configuration --------------------------------------------------- 71 | 72 | # If your documentation needs a minimal Sphinx version, state it here. 73 | needs_sphinx = "3.0" 74 | 75 | # Add any Sphinx extension module names here, as strings. They can be 76 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 77 | # ones. 78 | extensions = [ 79 | "sphinx_toolbox.more_autodoc", 80 | "sphinx.ext.autodoc", 81 | # "sphinx.ext.autosectionlabel", 82 | "sphinx.ext.coverage", 83 | "sphinx.ext.extlinks", 84 | "sphinx.ext.intersphinx", 85 | "sphinx.ext.napoleon", 86 | "sphinx.ext.todo", 87 | "sphinx.ext.viewcode", 88 | "sphinx_inline_tabs", 89 | "sphinx_copybutton", 90 | "notfound.extension", 91 | "hoverxref.extension", 92 | "sphinx_search.extension", 93 | "resourcelinks", 94 | "og_tags", 95 | ] 96 | 97 | # Links used for cross-referencing stuff in other documentation 98 | intersphinx_mapping = { 99 | "py": ("https://docs.python.org/3", None), 100 | "req": ("https://requests.readthedocs.io/en/latest/", None), 101 | } 102 | 103 | rst_prolog = """ 104 | .. |coro| replace:: This function is a |coroutine_link|_. 105 | .. |maybe_coro| replace:: This function can be a |coroutine_link|_. 106 | .. |coroutine_link| replace:: *coroutine* 107 | .. _coroutine_link: https://docs.python.org/3/library/asyncio-task.html#coroutine 108 | .. |nbsp| unicode:: 0xA0\n :trim: 109 | """ # noqa: E501 110 | 111 | # Add any paths that contain templates here, relative to this directory. 112 | templates_path = ["_templates"] 113 | 114 | # The master toctree document. 115 | master_doc = "index" 116 | 117 | # List of patterns, relative to source directory, that match files and 118 | # directories to ignore when looking for source files. 119 | # This pattern also affects html_static_path and html_extra_path. 120 | exclude_patterns = ["_build"] 121 | 122 | 123 | # -- Options for HTML output ------------------------------------------------- 124 | 125 | # The theme to use for HTML and HTML Help pages. See the documentation for 126 | # a list of builtin themes. 127 | # 128 | html_title = "Codingame" 129 | html_theme = "furo" 130 | html_theme_options = { 131 | "navigation_with_keys": True, 132 | # "light_logo": "codingame.png", 133 | # "dark_logo": "codingame.png", 134 | "dark_css_variables": { 135 | "color-inline-code-background": "#292d2d", 136 | }, 137 | } 138 | 139 | # Add any paths that contain custom static files (such as style sheets) here, 140 | # relative to this directory. They are copied after the builtin static files, 141 | # so a file named "default.css" will overwrite the builtin "default.css". 142 | html_static_path = ["_static"] 143 | 144 | resource_links = { 145 | "discord": "https://discord.gg/8HgtN6E", 146 | "repo": "https://github.com/takos22/codingame", 147 | "issues": "https://github.com/takos22/codingame/issues", 148 | "examples": f"https://github.com/takos22/codingame/tree/{branch}/examples", 149 | "codingame": "https://codingame.com", 150 | } 151 | 152 | # remove type hints in signatures 153 | autodoc_typehints = "none" 154 | 155 | # display TODOs in docs 156 | todo_include_todos = True 157 | 158 | # avoid confusion between section references 159 | autosectionlabel_prefix_document = True 160 | 161 | # pygments styles 162 | pygments_style = "sphinx" 163 | pygments_dark_style = "monokai" 164 | 165 | # autodoc defaults 166 | autodoc_default_options = { 167 | "members": True, 168 | "inherited-members": True, 169 | "exclude-members": "with_traceback", 170 | # "undoc-members": True, 171 | } 172 | autodoc_member_order = "bysource" 173 | autoclass_content = "both" 174 | autodoc_mock_imports = ["codingame.state", "aiohttp"] 175 | set_type_checking_flag = True 176 | typehints_document_rtype = False 177 | 178 | 179 | rtd_lang = os.environ.get("READTHEDOCS_LANGUAGE", "en") 180 | rtd_version = os.environ.get("READTHEDOCS_VERSION", "latest") 181 | 182 | if os.environ.get("READTHEDOCS", False): 183 | notfound_urls_prefix = f"/{rtd_lang}/{rtd_version}/" 184 | else: 185 | notfound_urls_prefix = "/" 186 | 187 | notfound_context = { 188 | "title": "Page not found", 189 | "body": ( 190 | "

Page not found

\n\n" 191 | "

Unfortunately we couldn't find the page you were looking for.

" 192 | "

Try using the search box or go to the " 193 | f'homepage

' 194 | ), 195 | } 196 | 197 | hoverxref_project = "codingame" 198 | hoverxref_version = rtd_version 199 | hoverxref_auto_ref = True 200 | hoverxref_domains = ["py"] 201 | hoverxref_roles = [ 202 | "ref", 203 | "doc", 204 | "numref", 205 | "mod", 206 | "func", 207 | "data", 208 | "const", 209 | "class", 210 | "meth", 211 | "attr", 212 | "exc", 213 | "obj", 214 | ] 215 | hoverxref_role_types = {role: "tooltip" for role in hoverxref_roles} 216 | 217 | 218 | github_username = "takos22" 219 | github_repository = "codingame" 220 | hide_none_rtype = True 221 | 222 | og_site_name = "CodinGame documentation" 223 | og_desc = ( 224 | "The codingame module is a wrapper for the undocumented CodinGame API, " 225 | "it enables developers to interact with CodinGame through a " 226 | "Python programming interface." 227 | ) 228 | og_image = ( 229 | "https://codingame.readthedocs.io/" 230 | f"{rtd_lang}/{rtd_version}/_static/codingame.png" 231 | ) 232 | if not os.environ.get("READTHEDOCS", False): 233 | og_site_url = f"https://codingame.readthedocs.io/{rtd_lang}/{rtd_version}/" 234 | -------------------------------------------------------------------------------- /docs/extensions/og_tags.py: -------------------------------------------------------------------------------- 1 | # credits to: Takayuki Shimizukawa @ Sphinx-users.jp 2 | # based on: 3 | # https://github.com/sphinx-contrib/ogp/blob/master/sphinxcontrib_ogp/ext.py 4 | 5 | import os 6 | from typing import Any, Dict 7 | from urllib.parse import urljoin, urlparse, urlunparse 8 | 9 | from docutils import nodes 10 | from sphinx import addnodes 11 | from sphinx.application import Config, Sphinx 12 | 13 | 14 | class Visitor: 15 | def __init__(self, document: nodes.Node, config: Config): 16 | self.document = document 17 | self.config = config 18 | self.text_list = [] 19 | self.images = [] 20 | self.n_sections = 0 21 | 22 | def dispatch_visit(self, node): 23 | # skip toctree 24 | if isinstance(node, addnodes.compact_paragraph) and node.get("toctree"): 25 | raise nodes.SkipChildren 26 | 27 | # collect images 28 | if isinstance(node, nodes.image): 29 | self.images.append(node) 30 | 31 | # collect 3 first sections 32 | if self.n_sections < 3: 33 | 34 | # collect text 35 | if isinstance(node, nodes.paragraph): 36 | self.text_list.append(node.astext()) 37 | 38 | # add depth 39 | if isinstance(node, nodes.section): 40 | self.n_sections += 1 41 | 42 | def dispatch_departure(self, node): 43 | pass 44 | 45 | def get_og_description(self): 46 | text = " ".join(self.text_list) 47 | desc_length = self.config["og_desc_length"] 48 | if len(text) > desc_length: 49 | text = text[: desc_length - 3] + "..." 50 | return text 51 | 52 | def get_og_image_url(self, page_url: str): 53 | if not self.images: 54 | return 55 | 56 | return urljoin(page_url, self.images[0]["uri"]) 57 | 58 | 59 | def get_og_tags(context: Dict[str, Any], doctree: nodes.Node, config: Config): 60 | if os.getenv("READTHEDOCS") and config["og_site_url"] is None: 61 | if config["html_baseurl"] is None: 62 | raise EnvironmentError( 63 | "ReadTheDocs did not provide a valid canonical URL!" 64 | ) 65 | 66 | # readthedocs uses html_baseurl for sphinx > 1.8 67 | parse_result = urlparse(config["html_baseurl"]) 68 | 69 | # Grab root url from canonical url 70 | config["og_site_url"] = urlunparse( 71 | ( 72 | parse_result.scheme, 73 | parse_result.netloc, 74 | parse_result.path, 75 | "", 76 | "", 77 | "", 78 | ) 79 | ) 80 | 81 | # page_url 82 | site_url = config["og_site_url"] 83 | page_url = urljoin(site_url, context["pagename"] + context["file_suffix"]) 84 | 85 | # collection 86 | visitor = Visitor(doctree, config) 87 | doctree.walkabout(visitor) 88 | 89 | # og:title 90 | og_title = config["og_title"] or context["title"] 91 | 92 | # og:site_name 93 | og_site_name = config["og_site_name"] or context["shorttitle"] 94 | 95 | # og:description 96 | og_desc = config["og_desc"] or visitor.get_og_description() 97 | 98 | # og:image 99 | og_image = config["og_image"] or visitor.get_og_image_url(page_url) 100 | 101 | # OG tags 102 | tags = """ 103 | 104 | 105 | 106 | 107 | 108 | """.format( 109 | title=og_title, 110 | desc=og_desc, 111 | page_url=page_url, 112 | site_name=og_site_name, 113 | ) 114 | if og_image: 115 | tags += ''.format( 116 | url=og_image 117 | ) 118 | return tags 119 | 120 | 121 | def html_page_context( 122 | app: Sphinx, 123 | pagename: str, 124 | templatename: str, 125 | context: Dict[str, Any], 126 | doctree, 127 | ): 128 | if not doctree: 129 | return 130 | 131 | context["metatags"] += get_og_tags(context, doctree, app.config) 132 | 133 | 134 | def setup(app: Sphinx) -> Dict[str, Any]: 135 | app.add_config_value("og_site_url", None, "html") 136 | app.add_config_value("og_title", None, "html") 137 | app.add_config_value("og_site_name", None, "html") 138 | app.add_config_value("og_desc", None, "html") 139 | app.add_config_value("og_desc_length", 200, "html") 140 | app.add_config_value("og_image", None, "html") 141 | app.connect("html-page-context", html_page_context) 142 | return { 143 | "version": "0.1.0", 144 | "parallel_read_safe": True, 145 | "parallel_write_safe": True, 146 | } 147 | -------------------------------------------------------------------------------- /docs/extensions/resourcelinks.py: -------------------------------------------------------------------------------- 1 | # https://raw.githubusercontent.com/Rapptz/discord.py/master/docs/extensions/resourcelinks.py 2 | 3 | from typing import Any, Dict, List, Tuple 4 | 5 | import sphinx 6 | from docutils import nodes, utils 7 | from docutils.nodes import Node, system_message 8 | from docutils.parsers.rst.states import Inliner 9 | from sphinx.application import Sphinx 10 | from sphinx.util.nodes import split_explicit_title 11 | from sphinx.util.typing import RoleFunction 12 | 13 | 14 | def make_link_role(resource_links: Dict[str, str]) -> RoleFunction: 15 | def role_fn( 16 | role: str, 17 | rawtext: str, 18 | text: str, 19 | lineno: int, 20 | inliner: Inliner, 21 | options: Dict = None, 22 | content: List[str] = None, 23 | ) -> Tuple[List[Node], List[system_message]]: 24 | options = options or {} 25 | content = content or [] 26 | 27 | text = utils.unescape(text) 28 | has_explicit_title, title, key = split_explicit_title(text) 29 | full_url = resource_links[key] 30 | if not has_explicit_title: 31 | title = full_url 32 | pnode = nodes.reference(title, title, internal=False, refuri=full_url) 33 | return [pnode], [] 34 | 35 | return role_fn 36 | 37 | 38 | def add_link_role(app: Sphinx) -> None: 39 | app.add_role("resource", make_link_role(app.config.resource_links)) 40 | 41 | 42 | def setup(app: Sphinx) -> Dict[str, Any]: 43 | app.add_config_value("resource_links", {}, "env") 44 | app.connect("builder-inited", add_link_role) 45 | return {"version": sphinx.__display_version__, "parallel_read_safe": True} 46 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | 2 | .. image:: _static/codingame.png 3 | :alt: CodinGame logo 4 | :align: right 5 | :scale: 50% 6 | 7 | codingame |version| -- CodinGame API wrapper documentation 8 | ========================================================== 9 | 10 | The ``codingame`` module is a wrapper for the undocumented CodinGame API, it 11 | enables developers to interact with CodinGame through a Python programming 12 | interface. 13 | 14 | .. image:: https://img.shields.io/pypi/v/codingame?color=blue 15 | :target: https://pypi.python.org/pypi/codingame 16 | :alt: PyPI version info 17 | .. image:: https://img.shields.io/pypi/pyversions/codingame?color=orange 18 | :target: https://pypi.python.org/pypi/codingame 19 | :alt: Supported Python versions 20 | .. image:: https://img.shields.io/github/checks-status/takos22/codingame/master?label=tests 21 | :target: https://github.com/takos22/codingame/actions/workflows/lint-test.yml 22 | :alt: Lint and test workflow status 23 | .. image:: https://readthedocs.org/projects/codingame/badge/?version=latest 24 | :target: https://codingame.readthedocs.io/en/latest/ 25 | :alt: Documentation build status 26 | .. image:: https://codecov.io/gh/takos22/codingame/branch/master/graph/badge.svg?token=0P3BV8D3AJ 27 | :target: https://codecov.io/gh/takos22/codingame 28 | :alt: Code coverage 29 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 30 | :target: https://github.com/psf/black 31 | :alt: Code style: Black 32 | .. image:: https://img.shields.io/discord/831992562986123376.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2 33 | :target: https://discord.gg/PGC3eAznJ6 34 | :alt: Discord support server 35 | 36 | **Features:** 37 | 38 | - Easy to develop 39 | - Wraps main functionalities of the API. 40 | - Supports both synchronous and asynchronous code 41 | 42 | Getting started 43 | --------------- 44 | 45 | Is this your first time using the module? This is the place to get started! 46 | 47 | - **First steps:** :ref:`intro` | :ref:`quickstart` 48 | - **Examples:** Many examples are available in the 49 | :resource:`repository `. 50 | 51 | Getting help 52 | ------------ 53 | 54 | If you're having trouble with something, these resources might help. 55 | 56 | - Ask us and hang out with us in our :resource:`Discord server `. 57 | - If you're looking for something specific, try the :ref:`index ` 58 | or :ref:`searching `. 59 | - Report bugs in the :resource:`issue tracker `. 60 | 61 | 62 | User Guide 63 | ---------- 64 | 65 | .. toctree:: 66 | :maxdepth: 1 67 | 68 | user_guide/intro 69 | user_guide/quickstart 70 | 71 | 72 | API Reference 73 | ------------- 74 | 75 | .. toctree:: 76 | :maxdepth: 3 77 | 78 | API reference 79 | 80 | 81 | Changelog 82 | --------- 83 | 84 | .. toctree:: 85 | :maxdepth: 2 86 | 87 | changelog 88 | 89 | 90 | Indices and tables 91 | ================== 92 | 93 | * :ref:`genindex` 94 | * :ref:`search` 95 | 96 | .. toctree:: 97 | :caption: References 98 | :hidden: 99 | 100 | GitHub Repository 101 | Examples 102 | Issue Tracker 103 | Discord support server 104 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # requirements for building the docs 2 | sphinx~=5.0 3 | furo 4 | sphinx-inline-tabs 5 | sphinx-copybutton 6 | sphinx-notfound-page 7 | sphinx-hoverxref 8 | sphinx-toolbox 9 | readthedocs-sphinx-search 10 | typing_extensions 11 | -------------------------------------------------------------------------------- /docs/user_guide/intro.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: codingame 2 | 3 | .. _intro: 4 | 5 | Introduction 6 | ============ 7 | 8 | This is the documentation for the ``codingame`` module. 9 | 10 | Prerequisites 11 | ------------- 12 | 13 | **Python 3.8 or higher is required.** 14 | 15 | 16 | .. _installing: 17 | 18 | Installing 19 | ---------- 20 | 21 | Install ``codingame`` with pip: 22 | 23 | .. tab:: Linux or MacOS 24 | 25 | .. code:: sh 26 | 27 | python3 -m pip install -U codingame 28 | 29 | .. tab:: Windows 30 | 31 | .. code:: sh 32 | 33 | py -3 -m pip install -U codingame 34 | 35 | .. _installing_async: 36 | 37 | Installing the asynchronous version 38 | *********************************** 39 | 40 | If you want to use the asynchronous client, make sure to have the correct 41 | modules installed by doing: 42 | 43 | .. tab:: Linux or MacOS 44 | 45 | .. code:: sh 46 | 47 | python3 -m pip install -U codingame[async] 48 | 49 | .. tab:: Windows 50 | 51 | .. code:: sh 52 | 53 | py -3 -m pip install -U codingame[async] 54 | 55 | .. _venv: 56 | 57 | Virtual Environments 58 | ******************** 59 | 60 | Sometimes you want to keep libraries from polluting system installs or use a 61 | different version of libraries than the ones installed on the system. You might 62 | also not have permissions to install libraries system-wide. 63 | For this purpose, the standard library as of Python comes with a concept 64 | called "Virtual Environment"s to help maintain these separate versions. 65 | 66 | A more in-depth tutorial is found on :doc:`py:tutorial/venv`. 67 | 68 | However, for the quick and dirty: 69 | 70 | 1. Go to your project's working directory: 71 | 72 | .. tab:: Linux or MacOS 73 | 74 | .. code:: sh 75 | 76 | cd your-project-dir 77 | 78 | .. tab:: Windows 79 | 80 | .. code:: sh 81 | 82 | cd your-project-dir 83 | 84 | 2. Create a virtual environment: 85 | 86 | .. tab:: Linux or MacOS 87 | 88 | .. code:: sh 89 | 90 | python3 -m venv venv 91 | 92 | .. tab:: Windows 93 | 94 | .. code:: sh 95 | 96 | py -3 -m venv venv 97 | 98 | 3. Activate the virtual environment: 99 | 100 | .. tab:: Linux or MacOS 101 | 102 | .. code:: sh 103 | 104 | source venv/bin/activate 105 | 106 | .. tab:: Windows 107 | 108 | .. code:: sh 109 | 110 | venv\Scripts\activate.bat 111 | 112 | 4. Use pip like usual: 113 | 114 | .. tab:: Linux or MacOS 115 | 116 | .. code:: sh 117 | 118 | pip install -U codingame 119 | 120 | .. tab:: Windows 121 | 122 | .. code:: sh 123 | 124 | pip install -U codingame 125 | 126 | Congratulations. You now have a virtual environment all set up. 127 | You can start to code, learn more in the :doc:`quickstart`. 128 | -------------------------------------------------------------------------------- /docs/user_guide/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. _quickstart: 2 | 3 | .. currentmodule:: codingame 4 | 5 | Quickstart 6 | ========== 7 | 8 | This page gives a brief introduction to the module. It assumes you have the 9 | module already installed, if you don't check the :ref:`installing` portion. 10 | You can find examples :resource:`here `. 11 | 12 | You can switch between the tutorial for the synchronous client and the 13 | asynchronous client with the tabs. 14 | 15 | .. _async: 16 | 17 | About the asynchronous client 18 | ----------------------------- 19 | 20 | .. note:: 21 | If you're new to Python or you don't need to use the asynchronous client, 22 | you can probably skip this section. 23 | 24 | The :class:`asynchronous client ` 25 | has the same methods as the 26 | :class:`synchronous client `, but all of 27 | them are `coroutines `_. That means that you can use methods 28 | like :meth:`Client.login`, but you need to do ``await client.login(...)`` 29 | instead of ``client.login(...)``. 30 | 31 | It works like this for methods of CodinGame models, like 32 | :meth:`CodinGamer.get_followers` needs to be awaited with the asynchronous 33 | client. 34 | 35 | Get a CodinGamer 36 | ---------------- 37 | 38 | Getting a :class:`CodinGamer` from their pseudo with the 39 | :meth:`Client.get_codingamer` method: 40 | 41 | .. tab:: Synchronous 42 | 43 | .. code-block:: python3 44 | 45 | import codingame 46 | 47 | client = codingame.Client() 48 | 49 | codingamer = client.get_codingamer("pseudo") 50 | print(codingamer) 51 | print(codingamer.pseudo) 52 | print(codingamer.public_handle) 53 | print(codingamer.avatar_url) 54 | 55 | .. tab:: Asynchronous 56 | 57 | .. code-block:: python3 58 | 59 | import codingame 60 | import asyncio 61 | 62 | async def main(): 63 | client = codingame.Client(is_async=True) 64 | 65 | codingamer = await client.get_codingamer("pseudo") 66 | print(codingamer) 67 | print(codingamer.pseudo) 68 | print(codingamer.public_handle) 69 | print(codingamer.avatar_url) 70 | 71 | asyncio.run(main()) 72 | 73 | .. note:: 74 | You can also use a public handle (39 character long hexadecimal string at 75 | the end of its profile link) or a user ID. Just replace the ``"pseudo"`` 76 | with the public handle or user ID. 77 | 78 | .. seealso:: 79 | See :meth:`Client.get_codingamer` and :class:`CodinGamer` for more info. 80 | 81 | Get a Clash of Code 82 | ------------------- 83 | 84 | Getting a :class:`Clash of Code ` from its public handle with the 85 | :meth:`Client.get_clash_of_code` method or the next public clash with 86 | :meth:`Client.get_pending_clash_of_code`: 87 | 88 | .. tab:: Synchronous 89 | 90 | .. code-block:: python3 91 | 92 | import codingame 93 | 94 | client = codingame.Client() 95 | 96 | # get a clash of code from its handle 97 | coc = client.get_clash_of_code("handle") 98 | # or get the next public clash 99 | coc = client.get_pending_clash_of_code() 100 | 101 | print(coc) 102 | print(coc.join_url) 103 | print(coc.modes) 104 | print(coc.programming_languages) 105 | print(coc.public_handle) 106 | print(coc.players) 107 | 108 | .. tab:: Asynchronous 109 | 110 | .. code-block:: python3 111 | 112 | import codingame 113 | import asyncio 114 | 115 | async def main(): 116 | client = codingame.Client(is_async=True) 117 | 118 | # get a clash of code from its handle 119 | coc = await client.get_clash_of_code("handle") 120 | # or get the next public clash 121 | coc = await client.get_pending_clash_of_code() 122 | 123 | print(coc) 124 | print(coc.join_url) 125 | print(coc.modes) 126 | print(coc.programming_languages) 127 | print(coc.public_handle) 128 | print(coc.players) 129 | 130 | asyncio.run(main()) 131 | 132 | .. note:: 133 | The public handle is the 39 character long hexadecimal string at the end of 134 | the Clash of Code invite link. 135 | 136 | 137 | .. seealso:: 138 | See :meth:`Client.get_clash_of_code`, 139 | :meth:`Client.get_pending_clash_of_code` and :class:`ClashOfCode` 140 | for more info. 141 | 142 | .. _login: 143 | 144 | Login 145 | ----- 146 | 147 | As of 2021-10-27, logging in with the email and the password no longer works 148 | because of an endpoint change, see 149 | `issue #5 `__. 150 | The only way to fix it is to log in with cookie authentication, you need to get 151 | the ``rememberMe`` cookie from :resource:`CodinGame `. This cookie 152 | should be valid for 1 year, but you might need to update it every time you log 153 | in on CodinGame. 154 | 155 | 1. Open https://codingame.com. 156 | 2. Log into your account, if you're not logged in already. 157 | 3. Access and copy the ``rememberMe`` cookie: 158 | 159 | .. tab:: Chrome 160 | 161 | Open the developer console (:kbd:`F12`), go to the **Application** tab, 162 | look for the ``rememberMe`` cookie then copy its value. 163 | 164 | .. image:: /_static/chrome_cookie.png 165 | :alt: Screenshot of where to find the cookie in Chrome 166 | 167 | .. tab:: Firefox 168 | 169 | Open the developer console (:kbd:`F12`), go to the **Storage** tab, 170 | look for the ``rememberMe`` cookie then copy its value. 171 | 172 | .. image:: /_static/firefox_cookie.png 173 | :alt: Screenshot of where to find the cookie in Firefox 174 | 175 | .. credits to https://github.com/s-vivien/CGBenchmark for the screenshots 176 | 177 | 4. Paste the ``rememberMe`` cookie in the code below: 178 | 179 | .. tab:: Synchronous 180 | 181 | .. code-block:: python3 182 | 183 | import codingame 184 | 185 | client = codingame.Client() 186 | client.login(remember_me_cookie="your cookie here") 187 | 188 | # then you can access the logged in codingamer like this 189 | print(client.logged_in) 190 | print(client.codingamer) 191 | print(client.codingamer.pseudo) 192 | print(client.codingamer.public_handle) 193 | print(client.codingamer.avatar_url) 194 | 195 | .. tab:: Asynchronous 196 | 197 | .. code-block:: python3 198 | 199 | import codingame 200 | import asyncio 201 | 202 | async def main(): 203 | client = codingame.Client(is_async=True) 204 | await client.login(remember_me_cookie="your cookie here") 205 | 206 | # then you can access the logged in codingamer like this 207 | print(client.logged_in) 208 | print(client.codingamer) 209 | print(client.codingamer.pseudo) 210 | print(client.codingamer.public_handle) 211 | print(client.codingamer.avatar_url) 212 | 213 | asyncio.run(main()) 214 | 215 | .. seealso:: 216 | See :class:`Client` and :meth:`Client.login` for more info. 217 | 218 | .. note:: 219 | Don't worry, the cookie isn't saved nor shared. 220 | 221 | Once you are logged in, you have access to many more methods of the 222 | :class:`Client`, like :meth:`Client.get_unseen_notifications`, and of the logged 223 | in :class:`CodinGamer`, like :meth:`CodinGamer.get_followers`. 224 | 225 | Get unseen notifications 226 | ------------------------ 227 | 228 | Getting a :class:`list` of the unseen :class:`notifications ` of 229 | the logged in :class:`CodinGamer` with the 230 | :meth:`Client.get_unseen_notifications` method: 231 | 232 | .. tab:: Synchronous 233 | 234 | .. code-block:: python3 235 | 236 | import codingame 237 | 238 | client = codingame.Client() 239 | client.login("email", "password") 240 | 241 | notifications = list(client.get_unseen_notifications()) 242 | 243 | print(f"{len(notifications)} unseen notifications:") 244 | for notification in notifications: 245 | print(notification.type_group, notification.type) 246 | 247 | .. tab:: Asynchronous 248 | 249 | .. code-block:: python3 250 | 251 | import codingame 252 | import asyncio 253 | 254 | async def main(): 255 | client = codingame.Client(is_async=True) 256 | await client.login("email", "password") 257 | 258 | notifications = [] 259 | async for notification in client.get_unseen_notifications(): 260 | notifications.append(notification) 261 | 262 | print(f"{len(notifications)} unseen notifications:") 263 | for notification in notifications: 264 | print(notification.type_group, notification.type) 265 | 266 | asyncio.run(main()) 267 | 268 | .. seealso:: 269 | See :meth:`Client.get_unseen_notifications` and :class:`Notification` for 270 | more info. 271 | -------------------------------------------------------------------------------- /examples/example_clash_of_code.py: -------------------------------------------------------------------------------- 1 | import codingame 2 | 3 | client = codingame.Client() 4 | 5 | # get a pending public clash of code 6 | coc = client.get_pending_clash_of_code() 7 | # or get a clash of code from its public handle 8 | coc = client.get_clash_of_code("clash of code handle here") 9 | 10 | print(coc) 11 | print(coc.join_url) 12 | print(coc.modes) 13 | print(coc.programming_languages) 14 | print(coc.public_handle) 15 | print(coc.players) 16 | -------------------------------------------------------------------------------- /examples/example_codingamer.py: -------------------------------------------------------------------------------- 1 | import codingame 2 | 3 | client = codingame.Client() 4 | 5 | # get a codingamer from their pseudo or public handle 6 | codingamer = client.get_codingamer("a pseudo or public handle here") 7 | print(codingamer) 8 | print(codingamer.pseudo) 9 | print(codingamer.public_handle) 10 | print(codingamer.avatar_url) 11 | -------------------------------------------------------------------------------- /examples/example_login.py: -------------------------------------------------------------------------------- 1 | import codingame 2 | 3 | client = codingame.Client() 4 | # see https://codingame.readthedocs.io/en/1.2.x/user_guide/quickstart.html#login 5 | client.login(remember_me_cookie="your cookie here") 6 | 7 | # then you can access the logged in codingamer like this 8 | print(client.logged_in) 9 | print(client.codingamer) 10 | print(client.codingamer.pseudo) 11 | print(client.codingamer.public_handle) 12 | print(client.codingamer.avatar_url) 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests~=2.25 2 | -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | folders="codingame tests examples docs setup.py" 5 | 6 | set -x 7 | 8 | # put every import on one line for autoflake remove unused imports 9 | python3 -m isort $folders --force-single-line-imports 10 | # remove unused imports and variables 11 | python3 -m autoflake $folders --remove-all-unused-imports --recursive --remove-unused-variables --in-place --exclude=__init__.py 12 | # resort imports 13 | python3 -m isort $folders 14 | 15 | # format code 16 | python3 -m black $folders --line-length 80 17 | -------------------------------------------------------------------------------- /scripts/full-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | set -x 4 | 5 | python3 -m pytest --only-mocked --overwrite-environ 6 | python3 -m pytest --no-mocking --cov-append 7 | -------------------------------------------------------------------------------- /scripts/lint-code.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | folders="codingame tests examples docs setup.py" 5 | 6 | set -x 7 | 8 | # stop the build if there are Python syntax errors or undefined names 9 | python3 -m flake8 $folders --count --select=E9,F63,F7,F82 --show-source --statistics 10 | # exit-zero treats all errors as warnings 11 | python3 -m flake8 $folders --count --exit-zero --statistics 12 | 13 | # check formatting with black 14 | python3 -m black $folders --check --line-length 80 15 | 16 | # check import ordering with isort 17 | python3 -m isort $folders --check-only 18 | -------------------------------------------------------------------------------- /scripts/lint-docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | folders="docs" 5 | 6 | set -x 7 | 8 | # check the docs with doc8 9 | python3 -m doc8 $folders --quiet 10 | 11 | # check package build for README.rst 12 | rm -rf dist 13 | python3 setup.py --quiet sdist 14 | python3 -m twine check dist/* 15 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # lint code 5 | ./scripts/lint-code.sh 6 | 7 | # lint docs and README 8 | ./scripts/lint-docs.sh 9 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | lint=true 5 | full=false 6 | 7 | # no linitng if -n or --no-lint flag 8 | for arg in "$@" 9 | do 10 | if [ "$arg" == "-n" ] || [ "$arg" == "--no-lint" ]; then 11 | lint=false 12 | fi 13 | if [ "$arg" == "-f" ] || [ "$arg" == "--full" ]; then 14 | full=true 15 | fi 16 | done 17 | 18 | if [ "$lint" = true ]; then 19 | # lint 20 | ./scripts/lint.sh 21 | fi 22 | 23 | 24 | if [ "$full" = true ]; then 25 | # lint 26 | ./scripts/full-test.sh 27 | else 28 | set -x 29 | python3 -m pytest 30 | fi 31 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-ignore = E402 3 | max-complexity = 10 4 | max-line-length = 80 5 | per-file-ignores = 6 | # imported but unused 7 | __init__.py: F401 8 | 9 | [isort] 10 | multi_line_output = 3 11 | include_trailing_comma = True 12 | force_grid_wrap = 0 13 | use_parentheses = True 14 | ensure_newline_before_comments = True 15 | line_length = 80 16 | 17 | [tool:pytest] 18 | testpaths = tests 19 | addopts = --cov 20 | 21 | [coverage:run] 22 | source = 23 | codingame 24 | tests 25 | omit = 26 | */conftest.py 27 | # branch = True 28 | 29 | [coverage:report] 30 | show_missing = True 31 | exclude_lines = 32 | pragma: no cover 33 | def __repr__ 34 | def __str__ 35 | raise AssertionError 36 | raise NotImplementedError 37 | if 0: 38 | if __name__ == .__main__.: 39 | if TYPE_CHECKING: 40 | if typing.TYPE_CHECKING: 41 | if sys.version_info 42 | 43 | [doc8] 44 | max-line-length = 80 45 | ignore = D002,D004 46 | ignore-path = docs/_build 47 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import typing 4 | 5 | from setuptools import setup 6 | 7 | 8 | def get_version(package) -> str: 9 | """Return package version as listed in `__version__` in `init.py`.""" 10 | 11 | path = os.path.join(package, "__init__.py") 12 | version = "" 13 | with open(path, "r", encoding="utf8") as init_py: 14 | version = re.search( 15 | r"^__version__\s*=\s*['\"]([^'\"]*)['\"]", 16 | init_py.read(), 17 | re.MULTILINE, 18 | ).group(1) 19 | 20 | if not version: 21 | raise RuntimeError(f"__version__ is not set in {path}") 22 | 23 | return version 24 | 25 | 26 | def get_packages(package) -> typing.List[str]: 27 | """Return root package and all sub-packages.""" 28 | 29 | return [ 30 | dirpath.replace("\\", ".") 31 | for dirpath, *_ in os.walk(package) 32 | if os.path.exists(os.path.join(dirpath, "__init__.py")) 33 | ] 34 | 35 | 36 | def get_long_description(filename: str = "README.rst") -> str: 37 | """Return the README.""" 38 | 39 | with open(filename, "r", encoding="utf8") as readme: 40 | long_description = readme.read() 41 | return long_description 42 | 43 | 44 | def get_requirements(filename: str = "requirements.txt") -> typing.List[str]: 45 | """Return the requirements.""" 46 | 47 | requirements = [] 48 | with open(filename, "r", encoding="utf8") as requirements_txt: 49 | requirements = requirements_txt.read().splitlines() 50 | return requirements 51 | 52 | 53 | extra_requires = { 54 | "async": get_requirements("async-requirements.txt"), 55 | } 56 | 57 | setup( 58 | name="codingame", 59 | version=get_version("codingame"), 60 | url="https://github.com/takos22/codingame", 61 | license="MIT", 62 | description="Pythonic wrapper for the undocumented CodinGame API.", 63 | long_description=get_long_description(), 64 | long_description_content_type="text/x-rst", 65 | author="takos22", 66 | author_email="takos2210@gmail.com", 67 | packages=get_packages("codingame"), 68 | python_requires=">=3.8", 69 | install_requires=get_requirements(), 70 | extras_require=extra_requires, 71 | project_urls={ 72 | "Documentation": "https://codingame.readthedocs.io/", 73 | "Issue tracker": "https://github.com/takos22/codingame/issues", 74 | }, 75 | classifiers=[ 76 | "Programming Language :: Python", 77 | "Programming Language :: Python :: 3", 78 | "Programming Language :: Python :: 3 :: Only", 79 | "Programming Language :: Python :: 3.8", 80 | "Programming Language :: Python :: 3.9", 81 | "Programming Language :: Python :: 3.10", 82 | "Programming Language :: Python :: 3.11", 83 | "Programming Language :: Python :: 3.12", 84 | ], 85 | ) 86 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takos22/codingame/fb193bc62e66edad057f2e16dd5a81ea1a7e9931/tests/__init__.py -------------------------------------------------------------------------------- /tests/async/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takos22/codingame/fb193bc62e66edad057f2e16dd5a81ea1a7e9931/tests/async/__init__.py -------------------------------------------------------------------------------- /tests/async/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from dotenv import load_dotenv 5 | 6 | from codingame import Client 7 | from codingame.client.async_ import AsyncClient 8 | 9 | load_dotenv() 10 | 11 | 12 | @pytest.fixture(name="client", scope="function") 13 | async def create_client() -> AsyncClient: 14 | async with Client(is_async=True) as client: 15 | yield client 16 | 17 | 18 | @pytest.fixture(name="auth_client") 19 | async def create_logged_in_client( 20 | request: pytest.FixtureRequest, 21 | ) -> AsyncClient: 22 | async with Client(is_async=True) as client: 23 | if "mock_http" in request.fixturenames: 24 | mock_http = request.getfixturevalue("mock_http") 25 | mock_http(client._state.http, "login") 26 | mock_http(client._state.http, "get_codingamer_from_id") 27 | mock_http(client._state.http, "get_codingamer_from_handle") 28 | 29 | await client.login( 30 | remember_me_cookie=os.environ.get("TEST_LOGIN_REMEMBER_ME_COOKIE"), 31 | ) 32 | yield client 33 | -------------------------------------------------------------------------------- /tests/async/test_codingamer.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from codingame.client.async_ import AsyncClient 6 | from codingame.codingamer import CodinGamer 7 | from codingame.exceptions import LoginRequired 8 | 9 | pytestmark = pytest.mark.asyncio 10 | 11 | 12 | @pytest.fixture(name="codingamer") 13 | async def get_codingamer(auth_client) -> CodinGamer: 14 | return await auth_client.get_codingamer( 15 | os.environ.get("TEST_CODINGAMER_PUBLIC_HANDLE") 16 | ) 17 | 18 | 19 | async def test_codingamer_avatar_and_cover_urls(client: AsyncClient): 20 | codingamer = await client.get_codingamer("Takos") 21 | assert isinstance(codingamer.avatar_url, str) 22 | assert isinstance(codingamer.cover_url, str) 23 | assert isinstance(codingamer.profile_url, str) 24 | 25 | 26 | async def test_codingamer_eq(client: AsyncClient, codingamer: CodinGamer): 27 | other_codingamer = await client.get_codingamer( 28 | os.environ.get("TEST_CODINGAMER_PUBLIC_HANDLE") 29 | ) 30 | assert codingamer == other_codingamer 31 | 32 | 33 | async def test_codingamer_get_followers(codingamer: CodinGamer): 34 | async for follower in codingamer.get_followers(): 35 | assert isinstance(follower, CodinGamer) 36 | 37 | 38 | async def test_codingamer_get_followers_error(client: AsyncClient): 39 | codingamer = await client.get_codingamer( 40 | os.environ.get("TEST_CODINGAMER_PUBLIC_HANDLE") 41 | ) 42 | with pytest.raises(LoginRequired): 43 | next(await codingamer.get_followers()) 44 | 45 | 46 | async def test_codingamer_get_followers_ids(codingamer: CodinGamer): 47 | followers_ids = await codingamer.get_followers_ids() 48 | assert isinstance(followers_ids, list) 49 | assert all(isinstance(follower_id, int) for follower_id in followers_ids) 50 | 51 | 52 | async def test_codingamer_get_followed(codingamer: CodinGamer): 53 | async for followed in codingamer.get_followed(): 54 | assert isinstance(followed, CodinGamer) 55 | 56 | 57 | async def test_codingamer_get_followed_error(client: AsyncClient): 58 | codingamer = await client.get_codingamer( 59 | os.environ.get("TEST_CODINGAMER_PUBLIC_HANDLE") 60 | ) 61 | with pytest.raises(LoginRequired): 62 | next(await codingamer.get_followed()) 63 | 64 | 65 | async def test_codingamer_get_followed_ids(codingamer: CodinGamer): 66 | followed_ids = await codingamer.get_followed_ids() 67 | assert isinstance(followed_ids, list) 68 | assert all(isinstance(followed_id, int) for followed_id in followed_ids) 69 | 70 | 71 | async def test_codingamer_get_clash_of_code_rank(codingamer: CodinGamer): 72 | rank = await codingamer.get_clash_of_code_rank() 73 | assert isinstance(rank, int) 74 | -------------------------------------------------------------------------------- /tests/async/test_notification.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from codingame.client.async_ import AsyncClient 6 | from codingame.notification import Notification 7 | 8 | pytestmark = pytest.mark.asyncio 9 | 10 | 11 | @pytest.fixture(name="notification") 12 | async def get_notification(auth_client: AsyncClient, mock_http) -> Notification: 13 | mock_http(auth_client._state.http, "get_unseen_notifications") 14 | notifications = [n async for n in auth_client.get_unseen_notifications()] 15 | 16 | # if all notifications are seen, we dont want to fail the test 17 | if not notifications: # pragma: no cover 18 | notifications = [ 19 | n async for n in auth_client.get_unread_notifications() 20 | ] 21 | if not notifications: # pragma: no cover 22 | notifications = [n async for n in auth_client.get_read_notifications()] 23 | 24 | return notifications[-1] 25 | 26 | 27 | async def test_client_notification_mark_as_seen( 28 | auth_client: AsyncClient, notification: Notification, mock_http 29 | ): 30 | mock_http( 31 | auth_client._state.http, 32 | "mark_notifications_as_seen", 33 | int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000), 34 | ) 35 | seen_date = await notification.mark_as_seen() 36 | 37 | assert notification.seen 38 | assert notification.seen_date == seen_date 39 | assert notification.seen_date.timestamp() == pytest.approx( 40 | datetime.datetime.now(datetime.timezone.utc).timestamp(), abs=10_000 41 | ) # 10 seconds should be enough 42 | 43 | 44 | async def test_client_notification_mark_as_read( 45 | auth_client: AsyncClient, notification: Notification, mock_http 46 | ): 47 | mock_http( 48 | auth_client._state.http, 49 | "mark_notifications_as_read", 50 | int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000), 51 | ) 52 | read_date = await notification.mark_as_read() 53 | 54 | assert notification.read 55 | assert notification.read_date == read_date 56 | assert notification.read_date.timestamp() == pytest.approx( 57 | datetime.datetime.now(datetime.timezone.utc).timestamp(), abs=10_000 58 | ) # 10 seconds should be enough 59 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | import typing 5 | 6 | import pytest 7 | from dotenv import load_dotenv 8 | from pytest_mock import MockerFixture 9 | 10 | from codingame.http import HTTPError 11 | 12 | if typing.TYPE_CHECKING: 13 | from _pytest.config import Config 14 | from _pytest.config.argparsing import Parser 15 | 16 | from codingame.http import HTTPClient 17 | 18 | load_dotenv() 19 | 20 | 21 | def mock_environ(overwrite=False): 22 | def set_environ(key: str, value: str) -> str: 23 | if overwrite or key not in os.environ: 24 | os.environ[key] = value 25 | return os.environ[key] 26 | 27 | set_environ("TEST_LOGIN_EMAIL", "email@example.com") 28 | set_environ("TEST_LOGIN_PASSWORD", "VerySafePassword") 29 | set_environ( 30 | "TEST_LOGIN_REMEMBER_ME_COOKIE", 31 | "1234567fedcba9876543210fedcba9876543210", 32 | ) 33 | 34 | set_environ("TEST_CODINGAMER_ID", "1234567") 35 | set_environ("TEST_CODINGAMER_PSEUDO", "Pseudo123") 36 | set_environ( 37 | "TEST_CODINGAMER_PUBLIC_HANDLE", 38 | "0123456789abcdef0123456789abcdef7654321", 39 | ) 40 | 41 | set_environ( 42 | "TEST_CLASHOFCODE_PUBLIC_HANDLE", 43 | "1234567fedcba9876543210fedcba9876543210", 44 | ) 45 | 46 | 47 | if all(opt not in sys.argv[1:] for opt in ("--nm", "--no-mocking")): 48 | overwrite = False 49 | if any(opt in sys.argv[1:] for opt in ("--oe", "--overwrite-environ")): 50 | overwrite = True 51 | mock_environ(overwrite) 52 | 53 | 54 | def pytest_addoption(parser: "Parser"): 55 | parser.addoption( 56 | "--nm", 57 | "--no-mocking", 58 | action="store_true", 59 | dest="no_mocking", 60 | help="Run all tests without mocking the API calls.", 61 | ) 62 | parser.addoption( 63 | "--oe", 64 | "--overwrite-environ", 65 | action="store_true", 66 | dest="overwrite_environ", 67 | help="Overwrite current environement variables with mock ones.", 68 | ) 69 | parser.addoption( 70 | "--om", 71 | "--only-mocked", 72 | action="store_true", 73 | dest="only_mocked", 74 | help="Only run tests that have been mocked.", 75 | ) 76 | 77 | 78 | def pytest_collection_modifyitems( 79 | config: "Config", items: typing.List[pytest.Item] 80 | ): 81 | mocking_fixture_name = "mocker" 82 | if config.getoption("only_mocked", default=False): 83 | selected_items = [] 84 | deselected_items = [] 85 | 86 | for item in items: 87 | if mocking_fixture_name in getattr(item, "fixturenames", ()): 88 | selected_items.append(item) 89 | else: 90 | deselected_items.append(item) 91 | 92 | config.hook.pytest_deselected(items=deselected_items) 93 | items[:] = selected_items 94 | 95 | 96 | @pytest.fixture(name="is_mocking", scope="session") 97 | def is_mocking_fixture(pytestconfig: "Config"): 98 | return not pytestconfig.getoption("no_mocking", default=False) 99 | 100 | 101 | @pytest.fixture(name="mock_environ", scope="session", autouse=True) 102 | def mock_environ_fixture(is_mocking: bool): 103 | if is_mocking: 104 | overwrite = False 105 | if any(opt in sys.argv[1:] for opt in ("--oe", "--overwrite-environ")): 106 | overwrite = True 107 | mock_environ(overwrite) 108 | 109 | 110 | class FakeMocker: 111 | def __init__(self, *_, **__): 112 | pass 113 | 114 | def __getattr__(self, _): 115 | return FakeMocker() # for attribute of attribute 116 | 117 | def __call__(self, *_, **__): 118 | pass 119 | 120 | 121 | @pytest.fixture(name="mocker") 122 | def mocker_fixture(is_mocking: bool, pytestconfig: "Config"): 123 | mocker = MockerFixture if is_mocking else FakeMocker 124 | 125 | # imitate https://github.com/pytest-dev/pytest-mock/blob/main/src/pytest_mock/plugin.py#L388 # noqa: E501 126 | result = mocker(pytestconfig) 127 | yield result 128 | result.stopall() 129 | 130 | 131 | not_set = object() 132 | 133 | 134 | def awaitable(obj): 135 | async def f(): 136 | return obj 137 | 138 | return f() 139 | 140 | 141 | @pytest.fixture(name="mock_http") 142 | def mock_http_fixture(mocker: MockerFixture): 143 | def mock_http( 144 | http_client: "HTTPClient", 145 | method: str, 146 | api_data=not_set, # None is an acceptable api data 147 | *args, 148 | **kwargs, 149 | ): 150 | if api_data is not_set: 151 | with open(f"tests/mock/responses/{method}.json") as f: 152 | api_data = json.load(f) 153 | 154 | mocker.patch.object( 155 | http_client, 156 | method, 157 | new=lambda *_: ( 158 | awaitable(api_data) if http_client.is_async else api_data 159 | ), 160 | *args, 161 | **kwargs, 162 | ) 163 | 164 | return mock_http 165 | 166 | 167 | @pytest.fixture(name="mock_httperror") 168 | def mock_httperror_fixture(mocker: MockerFixture): 169 | def mock_httperror( 170 | http_client: "HTTPClient", method: str, api_data, *args, **kwargs 171 | ): 172 | error = HTTPError(422, "", api_data) 173 | if http_client.is_async: 174 | 175 | async def fake_api_call(*_): 176 | raise error 177 | 178 | else: 179 | 180 | def fake_api_call(*_): 181 | raise error 182 | 183 | mocker.patch.object( 184 | http_client, method, new=fake_api_call, *args, **kwargs 185 | ) 186 | 187 | return mock_httperror 188 | -------------------------------------------------------------------------------- /tests/mock/responses/get_clash_of_code_from_handle.json: -------------------------------------------------------------------------------- 1 | { 2 | "nbPlayersMin": 2, 3 | "nbPlayersMax": 100, 4 | "publicHandle": "1234567fedcba9876543210fedcba9876543210", 5 | "clashDurationTypeId": "SHORT", 6 | "mode": "REVERSE", 7 | "startTimestamp": 1708107395331, 8 | "msBeforeStart": -26220184000, 9 | "msBeforeEnd": -26219284000, 10 | "finished": true, 11 | "started": true, 12 | "type": "PUBLIC", 13 | "players": [ 14 | { 15 | "codingamerId": 1234567, 16 | "codingamerNickname": "Player 1", 17 | "codingamerHandle": "0123456789abcdef0123456789abcdef7654321", 18 | "score": 100, 19 | "duration": 123456, 20 | "status": "STANDARD", 21 | "testSessionStatus": "COMPLETED", 22 | "languageId": "Python3", 23 | "rank": 1, 24 | "position": 2, 25 | "solutionShared": true, 26 | "testSessionHandle": "12345678fedcba9876543210fedcba9876543210", 27 | "submissionId": 12345678 28 | }, 29 | { 30 | "codingamerId": 2345678, 31 | "codingamerNickname": "Player 2", 32 | "codingamerHandle": "f0123456789abcdef0123456789abcde8765432", 33 | "score": 100, 34 | "duration": 234567, 35 | "status": "STANDARD", 36 | "testSessionStatus": "COMPLETED", 37 | "languageId": "C++", 38 | "rank": 2, 39 | "position": 3, 40 | "solutionShared": true, 41 | "testSessionHandle": "23456789edcba9876543210fedcba9876543210f", 42 | "submissionId": 23456789 43 | }, 44 | { 45 | "codingamerId": 3456789, 46 | "codingamerNickname": "Player 3", 47 | "codingamerHandle": "ef0123456789abcdef0123456789abcd9876543", 48 | "codingamerAvatarId": 12345678901234, 49 | "score": 0, 50 | "duration": 345678, 51 | "status": "OWNER", 52 | "testSessionStatus": "COMPLETED", 53 | "languageId": "Javascript", 54 | "rank": 3, 55 | "position": 1, 56 | "solutionShared": false, 57 | "testSessionHandle": "34567890dcba9876543210fedcba9876543210fe", 58 | "submissionId": 34567890 59 | } 60 | ], 61 | "programmingLanguages": [ 62 | "Python3", "C", "C++", "Java", "Javascript" 63 | ], 64 | "modes": [ 65 | "REVERSE", "SHORTEST" 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /tests/mock/responses/get_codingamer_from_handle.json: -------------------------------------------------------------------------------- 1 | { 2 | "codingamerPoints": 135, 3 | "achievementCount": 9, 4 | "codingamer": { 5 | "userId": 1234567, 6 | "pseudo": "Pseudo123", 7 | "countryId": "US", 8 | "publicHandle": "0123456789abcdef0123456789abcdef7654321", 9 | "formValues": {}, 10 | "enable": false, 11 | "rank": 12345, 12 | "level": 9, 13 | "xp": 1234, 14 | "category": "UNKNOWN" 15 | }, 16 | "codingamePointsRankingDto": { 17 | "rankHistorics": { 18 | "ranks": [23456, 12345], 19 | "totals": [1234567, 2345678], 20 | "points": [1603, 3047], 21 | "contestPoints": [369, 702], 22 | "optimPoints": [123, 234], 23 | "codegolfPoints": [123, 234], 24 | "multiTrainingPoints": [123, 234], 25 | "clashPoints": [1234, 2345], 26 | "dates": [1600000000000, 1620000000000] 27 | }, 28 | "codingamePointsTotal": 3047, 29 | "codingamePointsRank": 12345, 30 | "codingamePointsContests": 702, 31 | "codingamePointsAchievements": 123, 32 | "codingamePointsXp": 123, 33 | "codingamePointsOptim": 234, 34 | "codingamePointsCodegolf": 234, 35 | "codingamePointsMultiTraining": 234, 36 | "codingamePointsClash": 2345, 37 | "numberCodingamers": 2345678, 38 | "numberCodingamersGlobal": 4567890 39 | }, 40 | "xpThresholds": [ 41 | { 42 | "level": 9, 43 | "xpThreshold": 270, 44 | "cumulativeXp": 1097 45 | }, 46 | { 47 | "level": 10, 48 | "xpThreshold": 316, 49 | "cumulativeXp": 1413 50 | }, 51 | { 52 | "level": 11, 53 | "xpThreshold": 364, 54 | "cumulativeXp": 1777 55 | }, 56 | { 57 | "level": 12, 58 | "xpThreshold": 415, 59 | "cumulativeXp": 2192 60 | }, 61 | { 62 | "level": 13, 63 | "xpThreshold": 468, 64 | "cumulativeXp": 2660 65 | }, 66 | { 67 | "level": 14, 68 | "xpThreshold": 523, 69 | "cumulativeXp": 3183 70 | }, 71 | { 72 | "level": 15, 73 | "xpThreshold": 580, 74 | "cumulativeXp": 3763 75 | }, 76 | { 77 | "level": 16, 78 | "xpThreshold": 640, 79 | "cumulativeXp": 4403 80 | }, 81 | { 82 | "level": 17, 83 | "xpThreshold": 700, 84 | "cumulativeXp": 5103 85 | }, 86 | { 87 | "level": 18, 88 | "xpThreshold": 763, 89 | "cumulativeXp": 5866 90 | }, 91 | { 92 | "level": 19, 93 | "xpThreshold": 828, 94 | "cumulativeXp": 6694 95 | }, 96 | { 97 | "level": 20, 98 | "xpThreshold": 894, 99 | "cumulativeXp": 7588, 100 | "rewardLanguages": { 101 | "1": "Vous venez d'obtenir les droits de mod\u00e9ration des contributions communautaires. Plus d'infos sur le forum.", 102 | "2": "You have just obtained the moderation rights on community contributions. More info in the forum." 103 | } 104 | }, 105 | { 106 | "level": 21, 107 | "xpThreshold": 962, 108 | "cumulativeXp": 8550 109 | }, 110 | { 111 | "level": 22, 112 | "xpThreshold": 1031, 113 | "cumulativeXp": 9581 114 | }, 115 | { 116 | "level": 23, 117 | "xpThreshold": 1103, 118 | "cumulativeXp": 10684 119 | }, 120 | { 121 | "level": 24, 122 | "xpThreshold": 1175, 123 | "cumulativeXp": 11859 124 | }, 125 | { 126 | "level": 25, 127 | "xpThreshold": 1250, 128 | "cumulativeXp": 13109 129 | }, 130 | { 131 | "level": 26, 132 | "xpThreshold": 1325, 133 | "cumulativeXp": 14434 134 | }, 135 | { 136 | "level": 27, 137 | "xpThreshold": 1402, 138 | "cumulativeXp": 15836 139 | }, 140 | { 141 | "level": 28, 142 | "xpThreshold": 1481, 143 | "cumulativeXp": 17317 144 | }, 145 | { 146 | "level": 29, 147 | "xpThreshold": 1561, 148 | "cumulativeXp": 18878, 149 | "rewardLanguages": { 150 | "1": "Vous pouvez maintenant \u00e9diter les contributions communautaires accept\u00e9es. Plus d'infos sur le forum.", 151 | "2": "You can now edit the accepted community contributions. More info in the forum." 152 | } 153 | }, 154 | { 155 | "level": 30, 156 | "xpThreshold": 1643, 157 | "cumulativeXp": 20521 158 | }, 159 | { 160 | "level": 31, 161 | "xpThreshold": 1726, 162 | "cumulativeXp": 22247 163 | }, 164 | { 165 | "level": 32, 166 | "xpThreshold": 1810, 167 | "cumulativeXp": 24057 168 | }, 169 | { 170 | "level": 33, 171 | "xpThreshold": 1895, 172 | "cumulativeXp": 25952 173 | }, 174 | { 175 | "level": 34, 176 | "xpThreshold": 1982, 177 | "cumulativeXp": 27934 178 | }, 179 | { 180 | "level": 35, 181 | "xpThreshold": 2070, 182 | "cumulativeXp": 30004 183 | }, 184 | { 185 | "level": 36, 186 | "xpThreshold": 2160, 187 | "cumulativeXp": 32164 188 | }, 189 | { 190 | "level": 37, 191 | "xpThreshold": 2250, 192 | "cumulativeXp": 34414 193 | }, 194 | { 195 | "level": 38, 196 | "xpThreshold": 2342, 197 | "cumulativeXp": 36756 198 | }, 199 | { 200 | "level": 39, 201 | "xpThreshold": 2435, 202 | "cumulativeXp": 39191 203 | }, 204 | { 205 | "level": 40, 206 | "xpThreshold": 2529, 207 | "cumulativeXp": 41720 208 | }, 209 | { 210 | "level": 41, 211 | "xpThreshold": 2625, 212 | "cumulativeXp": 44345 213 | }, 214 | { 215 | "level": 42, 216 | "xpThreshold": 2721, 217 | "cumulativeXp": 47066, 218 | "rewardLanguages": { 219 | "1": "La r\u00e9ponse \u00e0 toutes les questions, n'est-ce pas ?", 220 | "2": "The answer to everything, right?" 221 | } 222 | }, 223 | { 224 | "level": 43, 225 | "xpThreshold": 2819, 226 | "cumulativeXp": 49885 227 | }, 228 | { 229 | "level": 44, 230 | "xpThreshold": 2918, 231 | "cumulativeXp": 52803 232 | }, 233 | { 234 | "level": 45, 235 | "xpThreshold": 3018, 236 | "cumulativeXp": 55821 237 | }, 238 | { 239 | "level": 46, 240 | "xpThreshold": 3119, 241 | "cumulativeXp": 58940 242 | }, 243 | { 244 | "level": 47, 245 | "xpThreshold": 3222, 246 | "cumulativeXp": 62162 247 | }, 248 | { 249 | "level": 48, 250 | "xpThreshold": 3325, 251 | "cumulativeXp": 65487 252 | }, 253 | { 254 | "level": 49, 255 | "xpThreshold": 3430, 256 | "cumulativeXp": 68917 257 | }, 258 | { 259 | "level": 50, 260 | "xpThreshold": 3535, 261 | "cumulativeXp": 72452 262 | }, 263 | { 264 | "level": 51, 265 | "xpThreshold": 3642, 266 | "cumulativeXp": 76094 267 | }, 268 | { 269 | "level": 52, 270 | "xpThreshold": 3749, 271 | "cumulativeXp": 79843 272 | }, 273 | { 274 | "level": 53, 275 | "xpThreshold": 3858, 276 | "cumulativeXp": 83701 277 | } 278 | ] 279 | } 280 | -------------------------------------------------------------------------------- /tests/mock/responses/get_codingamer_from_id.json: -------------------------------------------------------------------------------- 1 | { 2 | "userId": 1234567, 3 | "pseudo": "Pseudo123", 4 | "countryId": "US", 5 | "publicHandle": "0123456789abcdef0123456789abcdef7654321", 6 | "formValues": {}, 7 | "enable": false, 8 | "level": 9 9 | } 10 | -------------------------------------------------------------------------------- /tests/mock/responses/get_language_ids.json: -------------------------------------------------------------------------------- 1 | [ 2 | "Bash", 3 | "C", 4 | "C#", 5 | "C++", 6 | "Clojure", 7 | "D", 8 | "Dart", 9 | "F#", 10 | "Go", 11 | "Groovy", 12 | "Haskell", 13 | "Java", 14 | "Javascript", 15 | "Kotlin", 16 | "Lua", 17 | "ObjectiveC", 18 | "OCaml", 19 | "Pascal", 20 | "Perl", 21 | "PHP", 22 | "Python3", 23 | "Ruby", 24 | "Rust", 25 | "Scala", 26 | "Swift", 27 | "TypeScript", 28 | "VB.NET" 29 | ] 30 | -------------------------------------------------------------------------------- /tests/mock/responses/get_pending_clash_of_code.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "nbPlayersMin": 2, 4 | "nbPlayersMax": 8, 5 | "publicHandle": "1234567fedcba9876543210fedcba9876543210", 6 | "clashDurationTypeId": "SHORT", 7 | "mode": "REVERSE", 8 | "startTimestamp": 1708107395331, 9 | "msBeforeStart": -26220184000, 10 | "msBeforeEnd": -26219284000, 11 | "finished": true, 12 | "started": true, 13 | "type": "PUBLIC", 14 | "players": [ 15 | { 16 | "codingamerId": 1234567, 17 | "codingamerNickname": "Player 1", 18 | "codingamerHandle": "0123456789abcdef0123456789abcdef7654321", 19 | "score": 100, 20 | "duration": 123456, 21 | "status": "STANDARD", 22 | "testSessionStatus": "COMPLETED", 23 | "languageId": "Python3", 24 | "rank": 1, 25 | "position": 2, 26 | "solutionShared": true, 27 | "testSessionHandle": "12345678fedcba9876543210fedcba9876543210", 28 | "submissionId": 12345678 29 | }, 30 | { 31 | "codingamerId": 2345678, 32 | "codingamerNickname": "Player 2", 33 | "codingamerHandle": "f0123456789abcdef0123456789abcde8765432", 34 | "score": 100, 35 | "duration": 234567, 36 | "status": "STANDARD", 37 | "testSessionStatus": "COMPLETED", 38 | "languageId": "C++", 39 | "rank": 2, 40 | "position": 3, 41 | "solutionShared": true, 42 | "testSessionHandle": "23456789edcba9876543210fedcba9876543210f", 43 | "submissionId": 23456789 44 | }, 45 | { 46 | "codingamerId": 3456789, 47 | "codingamerNickname": "Player 3", 48 | "codingamerHandle": "ef0123456789abcdef0123456789abcd9876543", 49 | "codingamerAvatarId": 12345678901234, 50 | "score": 0, 51 | "duration": 345678, 52 | "status": "OWNER", 53 | "testSessionStatus": "COMPLETED", 54 | "languageId": "Javascript", 55 | "rank": 3, 56 | "position": 1, 57 | "solutionShared": false, 58 | "testSessionHandle": "34567890dcba9876543210fedcba9876543210fe", 59 | "submissionId": 34567890 60 | } 61 | ], 62 | "programmingLanguages": [ 63 | "Python3", 64 | "C", 65 | "C++", 66 | "Java", 67 | "Javascript" 68 | ], 69 | "modes": [ 70 | "REVERSE", 71 | "SHORTEST" 72 | ] 73 | } 74 | ] 75 | -------------------------------------------------------------------------------- /tests/mock/responses/login.json: -------------------------------------------------------------------------------- 1 | { 2 | "codinGamer": { 3 | "globalId": "01234567-89ab-cdef-fedc-ba9876543210", 4 | "userId": 1234567, 5 | "email": "email@example.com", 6 | "pseudo": "Pseudo123", 7 | "countryId": "US", 8 | "publicHandle": "0123456789abcdef0123456789abcdef7654321", 9 | "privateHandle": "fedcba9876543210fedcba9876543210fedcba9", 10 | "formValues": { 11 | "skills": "[]", 12 | "student": "unchecked", 13 | "category": "UNKNOWN", 14 | "pseudo": "Pseudo123", 15 | "countryId": "US" 16 | }, 17 | "registerOrigin": "CODINGAME", 18 | "enable": true, 19 | "rank": 1234, 20 | "formCachedValues": { 21 | "skills": "[]", 22 | "student": "unchecked", 23 | "category": "UNKNOWN", 24 | "pseudo": "Pseudo123", 25 | "countryId": "US" 26 | }, 27 | "creationTime": 1600000000000, 28 | "onlineSince": 1620000000000, 29 | "hiddenFromFriendFinder": false, 30 | "shareAllSolutions": false, 31 | "level": 9, 32 | "xp": 1234, 33 | "category": "UNKNOWN", 34 | "privateUser": false, 35 | "createdCoursesCount": 0, 36 | "alreadyAnsweredOptin": false 37 | }, 38 | "countryCode": "US", 39 | "newFeatures": [], 40 | "enabledNotifications": [ 41 | "clash-invite", 42 | "contest-scheduled", 43 | "contest-started", 44 | "contest-over", 45 | "clash-over", 46 | "following", 47 | "new-puzzle", 48 | "friend-registered", 49 | "invitation-accepted", 50 | "new-comment", 51 | "new-comment-response", 52 | "achievement-unlocked", 53 | "new-hint", 54 | "promoted-league", 55 | "contest-soon", 56 | "puzzle-of-the-week", 57 | "eligible-for-next-league", 58 | "new-league", 59 | "new-league-opened", 60 | "feature", 61 | "new-level", 62 | "career-new-candidate", 63 | "career-update-candidate", 64 | "custom", 65 | "new-blog", 66 | "contribution-received", 67 | "contribution-accepted", 68 | "contribution-refused", 69 | "contribution-clash-mode-removed", 70 | "quest-completed" 71 | ], 72 | "notificationConfig": { 73 | "soundEnabled": true, 74 | "nativeNotificationEnabled": false 75 | }, 76 | "activatedFeatures": [ 77 | "SIGNUP_CHECKBOXES", 78 | "CODINGAME_LSP", 79 | "COOKIE_BANNER_BUTTONS", 80 | "ALWAYS_ENABLE_AMPLITUDE", 81 | "TRUESKILL_SOLUTIONS_RANKING_SYSTEM", 82 | "OPTIN_CODING_TEST" 83 | ], 84 | "actionTypes": { 85 | "deleteContribution": { "authorPolicy": "ALLOWED" }, 86 | "receiveCareerNotification": { "authorPolicy": "ROLE" }, 87 | "deleteComment": { "authorPolicy": "ALLOWED" }, 88 | "submitMultiContribution": { "authorPolicy": "ROLE" }, 89 | "sendPingEmailToAll": { "authorPolicy": "ROLE" }, 90 | "accessCoursesBeta": { "authorPolicy": "ROLE" }, 91 | "editAcceptedContribution": { "authorPolicy": "ALLOWED" }, 92 | "hideStream": { "authorPolicy": "ROLE" }, 93 | "updateAvatar": { "minCodinGamerCount": 1, "authorPolicy": "ROLE" }, 94 | "updateAllAvatar": { "authorPolicy": "ROLE" }, 95 | "accessDisabledLeaderboard": { "authorPolicy": "ROLE" }, 96 | "impersonate": { "authorPolicy": "DENIED" }, 97 | "editComment": { "authorPolicy": "ALLOWED" }, 98 | "editContribution": { "authorPolicy": "ALLOWED" }, 99 | "denyPuzzleContribution": { "authorPolicy": "DENIED" }, 100 | "sendPingEmailToOptinCodingamers": { "authorPolicy": "ROLE" }, 101 | "denyClashContribution": { "authorPolicy": "DENIED" }, 102 | "validateClashContribution": { "authorPolicy": "DENIED" }, 103 | "sendPrivateMessageToAll": { "authorPolicy": "ROLE" }, 104 | "denyContribution": { "authorPolicy": "DENIED" }, 105 | "editClashContribution": { "authorPolicy": "ALLOWED" }, 106 | "updateCover": { "minCodinGamerCount": 1, "authorPolicy": "ROLE" }, 107 | "editPuzzleContribution": { "authorPolicy": "ALLOWED" }, 108 | "sendPrivateMessageToOptinCodingamers": { "authorPolicy": "ROLE" }, 109 | "editAcceptedPuzzleContribution": { "authorPolicy": "ALLOWED" }, 110 | "updateCareerSettings": { "authorPolicy": "ROLE" }, 111 | "editAcceptedClashContribution": { "authorPolicy": "ALLOWED" }, 112 | "validateContribution": { "authorPolicy": "DENIED" }, 113 | "validatePuzzleContribution": { "authorPolicy": "DENIED" }, 114 | "banStreamer": { "authorPolicy": "ROLE" }, 115 | "updateAllCover": { "authorPolicy": "ROLE" } 116 | }, 117 | "chatToken": "1234567 2021-08-11 abcdefghijklmn//opqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdef+ghijklmnopqr+stuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0+123456789abcde+fghijklmnopqrstuvwxyzABCDEFGHIJKLMNO=", 118 | "disqusSsoPayload": "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUV== a5e59b1ad8ab0327dae3012d0beee0f00bb625da 1234567890", 119 | "gaveFeedbackOnCurrentCompany": false, 120 | "xpThresholds": [ 121 | { "level": 4, "xpThreshold": 80, "cumulativeXp": 159 }, 122 | { "level": 5, "xpThreshold": 111, "cumulativeXp": 270 }, 123 | { "level": 6, "xpThreshold": 146, "cumulativeXp": 416 }, 124 | { "level": 7, "xpThreshold": 185, "cumulativeXp": 601 }, 125 | { "level": 8, "xpThreshold": 226, "cumulativeXp": 827 }, 126 | { "level": 9, "xpThreshold": 270, "cumulativeXp": 1097 }, 127 | { "level": 10, "xpThreshold": 316, "cumulativeXp": 1413 }, 128 | { "level": 11, "xpThreshold": 364, "cumulativeXp": 1777 }, 129 | { "level": 12, "xpThreshold": 415, "cumulativeXp": 2192 }, 130 | { "level": 13, "xpThreshold": 468, "cumulativeXp": 2660 }, 131 | { "level": 14, "xpThreshold": 523, "cumulativeXp": 3183 }, 132 | { "level": 15, "xpThreshold": 580, "cumulativeXp": 3763 }, 133 | { "level": 16, "xpThreshold": 640, "cumulativeXp": 4403 }, 134 | { "level": 17, "xpThreshold": 700, "cumulativeXp": 5103 }, 135 | { "level": 18, "xpThreshold": 763, "cumulativeXp": 5866 }, 136 | { "level": 19, "xpThreshold": 828, "cumulativeXp": 6694 }, 137 | { 138 | "level": 20, 139 | "xpThreshold": 894, 140 | "cumulativeXp": 7588, 141 | "rewardLanguages": { 142 | "1": "Vous venez d\"obtenir les droits de modération des contributions communautaires. Plus d\"infos sur le forum.", 143 | "2": "You have just obtained the moderation rights on community contributions. More info in the forum." 144 | } 145 | }, 146 | { "level": 21, "xpThreshold": 962, "cumulativeXp": 8550 }, 147 | { "level": 22, "xpThreshold": 1031, "cumulativeXp": 9581 }, 148 | { "level": 23, "xpThreshold": 1103, "cumulativeXp": 10684 }, 149 | { "level": 24, "xpThreshold": 1175, "cumulativeXp": 11859 }, 150 | { "level": 25, "xpThreshold": 1250, "cumulativeXp": 13109 }, 151 | { "level": 26, "xpThreshold": 1325, "cumulativeXp": 14434 }, 152 | { "level": 27, "xpThreshold": 1402, "cumulativeXp": 15836 }, 153 | { "level": 28, "xpThreshold": 1481, "cumulativeXp": 17317 }, 154 | { 155 | "level": 29, 156 | "xpThreshold": 1561, 157 | "cumulativeXp": 18878, 158 | "rewardLanguages": { 159 | "1": "Vous pouvez maintenant éditer les contributions communautaires acceptées. Plus d\"infos sur le forum.", 160 | "2": "You can now edit the accepted community contributions. More info in the forum." 161 | } 162 | }, 163 | { "level": 30, "xpThreshold": 1643, "cumulativeXp": 20521 }, 164 | { "level": 31, "xpThreshold": 1726, "cumulativeXp": 22247 }, 165 | { "level": 32, "xpThreshold": 1810, "cumulativeXp": 24057 }, 166 | { "level": 33, "xpThreshold": 1895, "cumulativeXp": 25952 }, 167 | { "level": 34, "xpThreshold": 1982, "cumulativeXp": 27934 }, 168 | { "level": 35, "xpThreshold": 2070, "cumulativeXp": 30004 }, 169 | { "level": 36, "xpThreshold": 2160, "cumulativeXp": 32164 }, 170 | { "level": 37, "xpThreshold": 2250, "cumulativeXp": 34414 }, 171 | { "level": 38, "xpThreshold": 2342, "cumulativeXp": 36756 }, 172 | { "level": 39, "xpThreshold": 2435, "cumulativeXp": 39191 }, 173 | { "level": 40, "xpThreshold": 2529, "cumulativeXp": 41720 }, 174 | { "level": 41, "xpThreshold": 2625, "cumulativeXp": 44345 }, 175 | { 176 | "level": 42, 177 | "xpThreshold": 2721, 178 | "cumulativeXp": 47066, 179 | "rewardLanguages": { 180 | "1": "La réponse à toutes les questions, n'est-ce pas ?", 181 | "2": "The answer to everything, right?" 182 | } 183 | }, 184 | { "level": 43, "xpThreshold": 2819, "cumulativeXp": 49885 }, 185 | { "level": 44, "xpThreshold": 2918, "cumulativeXp": 52803 }, 186 | { "level": 45, "xpThreshold": 3018, "cumulativeXp": 55821 }, 187 | { "level": 46, "xpThreshold": 3119, "cumulativeXp": 58940 }, 188 | { "level": 47, "xpThreshold": 3222, "cumulativeXp": 62162 }, 189 | { "level": 48, "xpThreshold": 3325, "cumulativeXp": 65487 }, 190 | { "level": 49, "xpThreshold": 3430, "cumulativeXp": 68917 }, 191 | { "level": 50, "xpThreshold": 3535, "cumulativeXp": 72452 }, 192 | { "level": 51, "xpThreshold": 3642, "cumulativeXp": 76094 }, 193 | { "level": 52, "xpThreshold": 3749, "cumulativeXp": 79843 }, 194 | { "level": 53, "xpThreshold": 3858, "cumulativeXp": 83701 } 195 | ], 196 | "user": { 197 | "id": 1234567, 198 | "email": "email@example.com", 199 | "languageId": 1, 200 | "status": "ACTIVE", 201 | "properties": { 202 | "testSessionConfig": { "predilectionLanguage": "Python3" }, 203 | "modes": [], 204 | "newFeature": { "taglineProfile": false }, 205 | "privacySettings-codingame": { 206 | "facebook": true, 207 | "analytics": true, 208 | "advertising": true 209 | }, 210 | "languages": ["Python3"], 211 | "isDicordDisclaimerMinified": true, 212 | "ideHelperStatus": { "multiPlayViews": 2, "soloSubmitViews": 1 }, 213 | "ideConfig": { "mode": "expert" }, 214 | "xpConfig": { "lastTotalXp": 1234 }, 215 | "seenChallengeIds": [123, 234], 216 | "chatSettings": { 217 | "theme": "black", 218 | "open": false, 219 | "hideMinimized": true 220 | }, 221 | "contributionsListLastVisit": 1610000000000, 222 | "abtesting": { "Quick AB 1": "variant-1A" }, 223 | "chatChannels": { "channels": [] }, 224 | "codeEditorConfig": { 225 | "fontSize": "big", 226 | "mode": "normal", 227 | "minimap": true 228 | }, 229 | "cookiesBanner-codingame": { "seen": true } 230 | } 231 | }, 232 | "visitor": false, 233 | "admin": false, 234 | "languageId": 1, 235 | "userId": 1234567, 236 | "userEmail": "email@example.com", 237 | "impersonated": false 238 | } 239 | -------------------------------------------------------------------------------- /tests/mock/responses/search.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "PUZZLE", 4 | "name": "Spring Challenge 2020", 5 | "level": "multi", 6 | "id": "spring-challenge-2020", 7 | "imageBinaryId": 44730303396746 8 | }, 9 | { 10 | "type": "PUZZLE", 11 | "name": "Spring Challenge 2021", 12 | "level": "multi", 13 | "id": "spring-challenge-2021", 14 | "imageBinaryId": 64484028993851 15 | }, 16 | { 17 | "finished": true, 18 | "type": "CHALLENGE", 19 | "name": "Spring Challenge 2020", 20 | "id": "spring-challenge-2020", 21 | "imageBinaryId": 42443007839121 22 | }, 23 | { 24 | "finished": true, 25 | "type": "CHALLENGE", 26 | "name": "Spring Challenge 2021", 27 | "id": "spring-challenge-2021", 28 | "imageBinaryId": 61169048207966 29 | }, 30 | { 31 | "type": "PLAYGROUND", 32 | "name": "Python Hello World program", 33 | "id": "56235", 34 | "imageBinaryId": 56048941552661 35 | }, 36 | { 37 | "type": "PLAYGROUND", 38 | "name": "Hello World in C++, the long way", 39 | "id": "304" 40 | }, 41 | { 42 | "type": "USER", 43 | "name": "Pseudo123", 44 | "id": "0123456789abcdef0123456789abcdef7654321" 45 | }, 46 | { 47 | "type": "USER", 48 | "name": "Pseudo1234", 49 | "id": "fedcba9876543210fedcba98765432107654321" 50 | } 51 | ] 52 | -------------------------------------------------------------------------------- /tests/sync/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/takos22/codingame/fb193bc62e66edad057f2e16dd5a81ea1a7e9931/tests/sync/__init__.py -------------------------------------------------------------------------------- /tests/sync/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from dotenv import load_dotenv 5 | 6 | from codingame import Client 7 | 8 | load_dotenv() 9 | 10 | 11 | @pytest.fixture(name="client", scope="function") 12 | def create_client() -> Client: 13 | with Client() as client: 14 | yield client 15 | 16 | 17 | @pytest.fixture(name="auth_client") 18 | def create_logged_in_client(request: pytest.FixtureRequest) -> Client: 19 | with Client() as client: 20 | if "mock_http" in request.fixturenames: 21 | mock_http = request.getfixturevalue("mock_http") 22 | mock_http(client._state.http, "login") 23 | mock_http(client._state.http, "get_codingamer_from_id") 24 | mock_http(client._state.http, "get_codingamer_from_handle") 25 | 26 | client.login( 27 | remember_me_cookie=os.environ.get("TEST_LOGIN_REMEMBER_ME_COOKIE"), 28 | ) 29 | yield client 30 | -------------------------------------------------------------------------------- /tests/sync/test_codingamer.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from codingame.client import Client 6 | from codingame.codingamer import CodinGamer 7 | from codingame.exceptions import LoginRequired 8 | 9 | 10 | @pytest.fixture(name="codingamer") 11 | def get_codingamer(auth_client) -> CodinGamer: 12 | return auth_client.get_codingamer( 13 | os.environ.get("TEST_CODINGAMER_PUBLIC_HANDLE") 14 | ) 15 | 16 | 17 | def test_codingamer_avatar_and_cover_urls(client: Client): 18 | codingamer = client.get_codingamer("Takos") 19 | assert isinstance(codingamer.avatar_url, str) 20 | assert isinstance(codingamer.cover_url, str) 21 | assert isinstance(codingamer.profile_url, str) 22 | 23 | 24 | def test_codingamer_eq(client: Client, codingamer: CodinGamer): 25 | other_codingamer = client.get_codingamer( 26 | os.environ.get("TEST_CODINGAMER_PUBLIC_HANDLE") 27 | ) 28 | assert codingamer == other_codingamer 29 | 30 | 31 | def test_codingamer_get_followers(codingamer: CodinGamer): 32 | for follower in codingamer.get_followers(): 33 | assert isinstance(follower, CodinGamer) 34 | 35 | 36 | def test_codingamer_get_followers_error(client: Client): 37 | codingamer = client.get_codingamer( 38 | os.environ.get("TEST_CODINGAMER_PUBLIC_HANDLE") 39 | ) 40 | with pytest.raises(LoginRequired): 41 | next(codingamer.get_followers()) 42 | 43 | 44 | def test_codingamer_get_followers_ids(codingamer: CodinGamer): 45 | followers_ids = codingamer.get_followers_ids() 46 | assert isinstance(followers_ids, list) 47 | assert all(isinstance(follower_id, int) for follower_id in followers_ids) 48 | 49 | 50 | def test_codingamer_get_followed(codingamer: CodinGamer): 51 | for followed in codingamer.get_followed(): 52 | assert isinstance(followed, CodinGamer) 53 | 54 | 55 | def test_codingamer_get_followed_error(client: Client): 56 | codingamer = client.get_codingamer( 57 | os.environ.get("TEST_CODINGAMER_PUBLIC_HANDLE") 58 | ) 59 | with pytest.raises(LoginRequired): 60 | next(codingamer.get_followed()) 61 | 62 | 63 | def test_codingamer_get_followed_ids(codingamer: CodinGamer): 64 | followed_ids = codingamer.get_followed_ids() 65 | assert isinstance(followed_ids, list) 66 | assert all(isinstance(followed_id, int) for followed_id in followed_ids) 67 | 68 | 69 | def test_codingamer_get_clash_of_code_rank(codingamer: CodinGamer): 70 | rank = codingamer.get_clash_of_code_rank() 71 | assert isinstance(rank, int) 72 | -------------------------------------------------------------------------------- /tests/sync/test_notification.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from codingame.client.sync import SyncClient 6 | from codingame.notification import Notification 7 | 8 | 9 | @pytest.fixture(name="notification") 10 | def get_notification(auth_client: SyncClient, mock_http) -> Notification: 11 | mock_http(auth_client._state.http, "get_unseen_notifications") 12 | notifications = list(auth_client.get_unseen_notifications()) 13 | 14 | # if all notifications are seen or read, we dont want to fail the test 15 | if not notifications: # pragma: no cover 16 | notifications = list(auth_client.get_unread_notifications()) 17 | if not notifications: # pragma: no cover 18 | notifications = list(auth_client.get_read_notifications()) 19 | 20 | return notifications[-1] 21 | 22 | 23 | def test_client_notification_mark_as_seen( 24 | auth_client: SyncClient, notification: Notification, mock_http 25 | ): 26 | mock_http( 27 | auth_client._state.http, 28 | "mark_notifications_as_seen", 29 | int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000), 30 | ) 31 | seen_date = notification.mark_as_seen() 32 | 33 | assert notification.seen 34 | assert notification.seen_date == seen_date 35 | assert notification.seen_date.timestamp() == pytest.approx( 36 | datetime.datetime.now(datetime.timezone.utc).timestamp(), abs=10_000 37 | ) # 10 seconds should be enough 38 | 39 | 40 | def test_client_notification_mark_as_read( 41 | auth_client: SyncClient, notification: Notification, mock_http 42 | ): 43 | mock_http( 44 | auth_client._state.http, 45 | "mark_notifications_as_read", 46 | int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000), 47 | ) 48 | read_date = notification.mark_as_read() 49 | 50 | assert notification.read 51 | assert notification.read_date == read_date 52 | assert notification.read_date.timestamp() == pytest.approx( 53 | datetime.datetime.now(datetime.timezone.utc).timestamp(), abs=10_000 54 | ) # 10 seconds should be enough 55 | -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | ## Endpoints found 4 | 5 | ### Get ranks in every category of a user 6 | 7 | Endpoint: `CodinGamer/findRankingPoints` 8 | JSON: `[user id]` 9 | Additional info: none 10 | [Source](https://github.com/tbali0524/codingame_api/blob/6d2bf1a8d10da552304eb1d4bee5cf75771c294b/cg_api.php#L581) 11 | 12 | ### Get basic info about a challenge 13 | 14 | Endpoint: `Challenge/findChallengeMinimalInfoByChallengePublicId` 15 | JSON: `[challenge public id]` 16 | Additional info: none 17 | [Source](https://github.com/tbali0524/codingame_api/blob/6d2bf1a8d10da552304eb1d4bee5cf75771c294b/cg_api.php#L367) 18 | 19 | ### Get info about a challenge 20 | 21 | Endpoint: `Challenge/findWorldCupByPublicId` 22 | JSON: `[challenge public id, user id/null]` 23 | Additional info: none 24 | Source: network tab while searching on codingame 25 | 26 | ### Get info about all challenges 27 | 28 | Endpoint: `Challenge/findAllChallenges` 29 | JSON: `[]` 30 | Additional info: some attributes like `finished` are always false 31 | [Source](https://github.com/tbali0524/codingame_api/blob/6d2bf1a8d10da552304eb1d4bee5cf75771c294b/cg_api.php#L354) 32 | 33 | ### Get number of done achievments of a user and number of total achievments 34 | 35 | Endpoint: `CodinGamer/findTotalAchievementProgress` 36 | JSON: `[user public handle]` 37 | Additional info: none 38 | [Source](https://github.com/tbali0524/codingame_api/blob/6d2bf1a8d10da552304eb1d4bee5cf75771c294b/cg_api.php#L597) 39 | 40 | ### Get all done challenges and puzzles of a user 41 | 42 | Endpoint: `CodinGamer/getMyConsoleInformation` 43 | JSON: `[user id]` 44 | Additional info: none 45 | [Source](https://github.com/tbali0524/codingame_api/blob/6d2bf1a8d10da552304eb1d4bee5cf75771c294b/cg_api.php#L621) 46 | 47 | ### Get n last activities (challenges, clashes, ...) of a user 48 | 49 | Endpoint: `LastActivities/getLastActivities` 50 | JSON: `[user id, number of activities]` 51 | Additional info: Login needed 52 | [Source](https://github.com/tbali0524/codingame_api/blob/6d2bf1a8d10da552304eb1d4bee5cf75771c294b/cg_api.php#L762) 53 | 54 | ### Get basic information about every puzzle 55 | 56 | Endpoint: `Leaderboards/findAllPuzzleLeaderboards` 57 | JSON: `[]` 58 | Additional info: none 59 | [Source](https://github.com/tbali0524/codingame_api/blob/6d2bf1a8d10da552304eb1d4bee5cf75771c294b/cg_api.php#L781) 60 | 61 | ### Get ranking of a user in a challenge 62 | 63 | Endpoint: `Leaderboards/getCodinGamerChallengeRanking` 64 | JSON: `[user id, challenge public id, "global"]` 65 | Additional info: none 66 | [Source](https://github.com/tbali0524/codingame_api/blob/6d2bf1a8d10da552304eb1d4bee5cf75771c294b/cg_api.php#L794) 67 | 68 | ### Get public clash of code ranking of a user 69 | 70 | Endpoint: `Leaderboards/getCodinGamerClashRanking` 71 | JSON: `[user id, "global", null]` 72 | Additional info: none 73 | [Source](https://github.com/tbali0524/codingame_api/blob/6d2bf1a8d10da552304eb1d4bee5cf75771c294b/cg_api.php#L826) 74 | 75 | ### Get global ranking of a user 76 | 77 | Endpoint: `Leaderboards/getCodinGamerGlobalRankingByHandle` 78 | JSON: `[user public handle, "GENERAL", "global", null]` 79 | Additional info: none 80 | [Source](https://github.com/tbali0524/codingame_api/blob/6d2bf1a8d10da552304eb1d4bee5cf75771c294b/cg_api.php#L851) 81 | 82 | ### Get number of solved puzzles by programming language 83 | 84 | Endpoint: `Puzzle/countSolvedPuzzlesByProgrammingLanguage` 85 | JSON: `[user id]` 86 | Additional info: Login needed 87 | [Source](https://github.com/tbali0524/codingame_api/blob/6d2bf1a8d10da552304eb1d4bee5cf75771c294b/cg_api.php#L949) 88 | 89 | ### Get puzzles info by ids 90 | 91 | Endpoint: `Puzzle/findProgressByIds` 92 | JSON: `[[puzzle ids], user id, language id]` 93 | Additional info: Language id: 1 for french, 2 for english 94 | [Source](https://github.com/tbali0524/codingame_api/blob/6d2bf1a8d10da552304eb1d4bee5cf75771c294b/cg_api.php#L984) 95 | 96 | ### Get puzzle info by pretty id 97 | 98 | Endpoint: `Puzzle/findProgressByPrettyId` 99 | JSON: `[puzzle pretty id, user id]` 100 | Additional info: Login needed 101 | [Source](https://github.com/tbali0524/codingame_api/blob/6d2bf1a8d10da552304eb1d4bee5cf75771c294b/cg_api.php#L1003) 102 | 103 | ### Register 104 | 105 | Endpoint: `CodinGamer/registerSite` 106 | JSON: `[{"email": email, "password": password, "recaptchaResponse": recaptcha}, "CODINGAME", whether to subscribe to the newsletter]` 107 | Additional info: Recaptcha needed 108 | Source: network tab while searching on codingame 109 | 110 | ### Follow a codingamer 111 | 112 | Endpoint: `CodinGamer/setFollowing` 113 | JSON: `[self id, codingamer to follow id, true]` 114 | Additional info: Login needed 115 | Source: network tab while searching on codingame 116 | 117 | ### Play clash of code 118 | 119 | Endpoint: `ClashOfCode/playClash` 120 | JSON: `[user id, {"SHORT":true}, recaptcha]` 121 | Additional info: Recaptcha and login needed 122 | Source: network tab while searching on codingame 123 | 124 | ### Mark notifications as seen 125 | 126 | Endpoint: `Notification/markAsSeen` 127 | JSON: `[user id, [notification ids...]]` 128 | Additional info: Login needed 129 | Source: network tab while searching on codingame 130 | 131 | ### Mark notifications as read 132 | 133 | Endpoint: `Notification/markAsRead` 134 | JSON: `[user id, [notification ids...]]` 135 | Additional info: Login needed 136 | Source: network tab while searching on codingame 137 | 138 | ### Create private clash of code 139 | 140 | Endpoint: `ClashOfCode/createPrivateClash` 141 | JSON: `[user id, {SHORT: true}, programming languages, modes]` 142 | Additional info: Login needed 143 | Source: network tab while searching on codingame 144 | --------------------------------------------------------------------------------