diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4b2aa46..11d9d8e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -17,10 +17,10 @@ jobs: persist-credentials: false fetch-depth: 0 # Fetch the full history ref: ${{ github.ref }} # Check out the current branch or tag - + - name: Fetch tags only run: git fetch --tags --no-recurse-submodules - + - name: Set up Python uses: actions/setup-python@v4 with: @@ -29,19 +29,29 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r docs/requirements.txt + pip install -e .[docs] - name: Build documentation run: sphinx-multiversion docs/source docs/build/html --keep-going --no-color + - name: Get the latest tag + run: | + # Fetch all tags + git fetch --tags + # Get the latest tag + latest_tag=$(git tag --sort=-creatordate | head -n 1) + echo "LATEST_RELEASE=$latest_tag" >> $GITHUB_ENV + - name: Generate index.html for judge0.github.io/judge0-python. run: | echo ' - + ' > docs/build/html/index.html + env: + latest_release: ${{ env.LATEST_RELEASE }} - name: Upload artifacts uses: actions/upload-artifact@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 35cd8dd..0a4e3e3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -46,4 +46,3 @@ jobs: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} run: twine upload dist/* - diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3c33777..2beadbd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,7 @@ name: Test judge0-python on: + workflow_dispatch: push: branches: ["master"] paths: ["src/**", "tests/**"] @@ -32,10 +33,10 @@ jobs: JUDGE0_ATD_API_KEY: ${{ secrets.JUDGE0_ATD_API_KEY }} JUDGE0_RAPID_API_KEY: ${{ secrets.JUDGE0_RAPID_API_KEY }} JUDGE0_SULU_API_KEY: ${{ secrets.JUDGE0_SULU_API_KEY }} - JUDGE0_TEST_API_KEY: ${{ secrets.JUDGE0_TEST_API_KEY }} - JUDGE0_TEST_API_KEY_HEADER: ${{ secrets.JUDGE0_TEST_API_KEY_HEADER }} - JUDGE0_TEST_CE_ENDPOINT: ${{ secrets.JUDGE0_TEST_CE_ENDPOINT }} - JUDGE0_TEST_EXTRA_CE_ENDPOINT: ${{ secrets.JUDGE0_TEST_EXTRA_CE_ENDPOINT }} + JUDGE0_CE_AUTH_HEADERS: ${{ secrets.JUDGE0_CE_AUTH_HEADERS }} + JUDGE0_EXTRA_CE_AUTH_HEADERS: ${{ secrets.JUDGE0_EXTRA_CE_AUTH_HEADERS }} + JUDGE0_CE_ENDPOINT: ${{ secrets.JUDGE0_CE_ENDPOINT }} + JUDGE0_EXTRA_CE_ENDPOINT: ${{ secrets.JUDGE0_EXTRA_CE_ENDPOINT }} run: | source venv/bin/activate - pytest -vv tests/ + pytest tests diff --git a/.gitignore b/.gitignore index 82f9275..1348066 100644 --- a/.gitignore +++ b/.gitignore @@ -85,7 +85,7 @@ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: -# .python-version +.python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 0000000..9661bbb Binary files /dev/null and b/docs/assets/logo.png differ diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index cd3bc0f..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -sphinx==7.4.7 -sphinxawesome-theme==5.3.2 -sphinx-autodoc-typehints==2.3.0 -sphinx-multiversion==0.2.4 \ No newline at end of file diff --git a/docs/source/_templates/versioning.html b/docs/source/_templates/versioning.html index ef74ed4..1b8de30 100644 --- a/docs/source/_templates/versioning.html +++ b/docs/source/_templates/versioning.html @@ -1,7 +1,10 @@ {% if versions %} +{% set master_version = versions | selectattr('name', 'equalto', 'master') | list %} +{% set other_versions = versions | rejectattr('name', 'equalto', 'master') | sort(attribute='name', reverse=true) %} +{% set sorted_versions = master_version + other_versions %}

{{ _('Versions') }}

diff --git a/docs/source/api/errors.rst b/docs/source/api/errors.rst new file mode 100644 index 0000000..b976cd1 --- /dev/null +++ b/docs/source/api/errors.rst @@ -0,0 +1,5 @@ +Errors Module +============= + +.. automodule:: judge0.errors + :members: diff --git a/docs/source/api/filesystem.rst b/docs/source/api/filesystem.rst new file mode 100644 index 0000000..73eafb6 --- /dev/null +++ b/docs/source/api/filesystem.rst @@ -0,0 +1,6 @@ +Filesystem Module +================= + +.. automodule:: judge0.filesystem + :members: + :member-order: bysource diff --git a/docs/source/api/retry.rst b/docs/source/api/retry.rst new file mode 100644 index 0000000..22977dc --- /dev/null +++ b/docs/source/api/retry.rst @@ -0,0 +1,6 @@ +Retry Module +============ + +.. automodule:: judge0.retry + :members: + :member-order: bysource \ No newline at end of file diff --git a/docs/source/api/types.rst b/docs/source/api/types.rst index 219d7ed..8cb94cc 100644 --- a/docs/source/api/types.rst +++ b/docs/source/api/types.rst @@ -1,6 +1,47 @@ Types Module ============ -.. automodule:: judge0.base_types +Types +----- + +.. autoclass:: judge0.base_types.Config + :members: + :member-order: bysource + +.. autoclass:: judge0.base_types.Encodable + :members: + +.. autoclass:: judge0.base_types.Flavor :members: + :member-order: bysource +.. autoclass:: judge0.base_types.Language + :members: + :member-order: bysource + +.. autoclass:: judge0.base_types.LanguageAlias + :members: + :member-order: bysource + +.. autoclass:: judge0.base_types.Status + :members: + :member-order: bysource + +.. autoclass:: judge0.base_types.TestCase + :members: + :member-order: bysource + +Type aliases +------------ + +.. autoclass:: judge0.base_types.Iterable + :members: + :member-order: bysource + +.. autoclass:: judge0.base_types.TestCaseType + :members: + :member-order: bysource + +.. autoclass:: judge0.base_types.TestCases + :members: + :member-order: bysource \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index a9ae07c..ea15353 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -10,6 +10,8 @@ import os import sys +from sphinxawesome_theme.postprocess import Icons + project = "Judge0 Python SDK" copyright = "2024, Judge0" author = "Judge0" @@ -32,7 +34,45 @@ # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output +html_title = project html_theme = "sphinxawesome_theme" +html_theme_options = { + "show_scrolltop": True, + "extra_header_link_icons": { + "repository on GitHub": { + "link": "https://github.com/judge0/judge0-python", + "icon": ( + '' + '' + ), + }, + }, + "awesome_external_links": True, + "main_nav_links": { + "Home": "https://judge0.github.io/judge0-python/", + "Judge0": "https://judge0.com/", + }, +} html_show_sphinx = False html_sidebars = { "**": [ @@ -41,9 +81,14 @@ "versioning.html", ], } +html_logo = "../assets/logo.png" +html_favicon = html_logo +pygments_style = "sphinx" sys.path.insert(0, os.path.abspath("../../src/")) # Adjust as needed +# -- Awesome theme config -- +html_permalinks_icon = Icons.permalinks_icon autodoc_default_options = { "members": True, diff --git a/docs/source/contributors_guide/contributing.rst b/docs/source/contributors_guide/contributing.rst index 2a19fb5..867f3f1 100644 --- a/docs/source/contributors_guide/contributing.rst +++ b/docs/source/contributors_guide/contributing.rst @@ -24,5 +24,73 @@ Preparing the development setup .. code-block:: console - $ pip install -e .[test] + $ pip install -e .[dev] $ pre-commit install + +Building documentation +---------------------- + +Documentation is built using Sphinx. To build the documentation, run the + +.. code-block:: console + + $ cd docs + $ make html + +You should inspect the changes in the documentation by opening the +``docs/build/html/index.html`` file in your browser. + +.. note:: + If you are having trouble with the documentation and are seeing unexpected + output, delete the ``docs/build`` directory and rerun the ``make html`` command. + +You'll see a different output since the documentation is build with +`sphinx-multiversion `_ extension. + +Testing +------- + +.. warning:: + If you are implementing features or fixing bugs, you are expected to have + all of the three API keys (ATD, Sulu, and RapidAPI) setup and set in you + environment variables - ``JUDGE0_SULU_API_KEY``, ``JUDGE0_RAPID_API_KEY``, + and ``JUDGE0_ATD_API_KEY``. + +Every bug fix or new feature should have tests for it. The tests are located in +the ``tests`` directory and are written using `pytest `_. + +While working with the tests, you should use the following fixtures: + +* ``ce_client`` - a client, chosen based on the environment variables set, that uses the CE flavor of the client. +* ``extra_ce_client`` - a client, chosen based on the environment variables set, that uses the Extra CE flavor of the client. + +The ``ce_client`` and ``extra_ce_client`` are fixtures that +return a client based on the environment variables set. This enables you to +run the full test suite locally, but also to run the tests on the CI pipeline +without changing the tests. + +You can use the fixtures in your tests like this: + +.. code-block:: python + + def test_my_test(request): + client = request.getfixturevalue("ce_client") # or extra_ce_client + +To run the tests locally, you can use the following command: + +.. code-block:: console + + $ pytest tests -k '' + +This will enable you to run a single test, without incurring the cost of +running the full test suite. If you want to run the full test suite, you can +use the following command: + +.. code-block:: console + + $ pytest tests + +or you can create a draft PR and let the CI pipeline run the tests for you. +The CI pipeline will run the tests on every PR, using a private instance +of Judge0, so you can be sure that your changes are not breaking the existing +functionality. diff --git a/docs/source/contributors_guide/release_notes.rst b/docs/source/contributors_guide/release_notes.rst index ec66b1c..da50b25 100644 --- a/docs/source/contributors_guide/release_notes.rst +++ b/docs/source/contributors_guide/release_notes.rst @@ -1,4 +1,32 @@ How to create a release ======================= -TODO \ No newline at end of file +Creating a release is a simple process that involves a few steps: + +#. **Prepare the release**: + #. Create a separate branch for the release. Name the branch ``release-x.y.z`` + where ``x.y.z`` is the version number. + #. Update the version number in ``judge0/__init__.py``. + #. Update the version number in ``judge0/pyproject.toml``. + #. Sync the branch with any changes from the master branch. + #. Create a pull request for the release branch. Make sure that all tests pass. + #. Merge the pull request. + #. Pull the changes to your local repository and tag the commit (``git tag vX.Y.Z``) with the version number. + #. Push the tags to the remote repository (``git push origin master --tags``). +#. **Create release (notes) on GitHub**. + #. Go to the `releases page `_ on GitHub. + #. Release title should be ``Judge0 Python SDK vX.Y.Z``. + #. Release notes should include a changes from the previous release to the newest release. + #. Use the `template `_ from the repo to organize the changes. + #. Create the release. ("Set as a pre-release" should NOT be checked.) +#. **Release on PyPI**: + #. Use the `GitHub Actions workflow `_ to create a release on PyPI. + #. Select `Run workflow` and as `Target repository` select `pypi`. + #. Click the `Run workflow` button. + +After the release is successfully published on PyPI, create a new pull request +that updates the working version in ``judge0/__init__.py`` and ``judge0/pyproject.toml`` +to the minor version. Merge the pull request and you're done! For example, if the +new release was ``1.2.2``, the working version should be updated to ``1.3.0.dev0``. + +You've successfully created a release! Congratulations! 🎉 \ No newline at end of file diff --git a/docs/source/in_depth/client_resolution.rst b/docs/source/in_depth/client_resolution.rst new file mode 100644 index 0000000..dfdd532 --- /dev/null +++ b/docs/source/in_depth/client_resolution.rst @@ -0,0 +1,4 @@ +Client Resolution +================= + +TODO: Describe the approach to client resolution. See `_get_implicit_client`. \ No newline at end of file diff --git a/docs/source/in_depth/overview.rst b/docs/source/in_depth/overview.rst new file mode 100644 index 0000000..d7dd136 --- /dev/null +++ b/docs/source/in_depth/overview.rst @@ -0,0 +1,8 @@ +Overview +======== + +TODO: + +* add a brief overview of the most important classes (Client, Submission, etc.) +* add a brief overview of the most important functions (create_submission, get_submission, etc.) +* write about the difference between high-level api and low-level api \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst index 3510578..4f98325 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,6 +1,6 @@ -=============================== -Judge0 Python SDK documentation -=============================== +================= +Judge0 Python SDK +================= Getting Started =============== @@ -29,12 +29,25 @@ Want to learn more To learn what is happening behind the scenes and how to best use Judge0 Python SDK to facilitate the development of your own product see In Depth guide and -Examples. +`examples `_. Getting Involved ================ -TODO +Getting involved in any open-source project is simple and rewarding, with +multiple ways to contribute to its growth and success. You can help by: + +1. `reporting bugs `_ by + creating a detailed issue describing the problem, along with any relevant code or + steps to reproduce it, so it can be addressed effectively, +2. creating a `pull request `_ for + an existing issue; we welcome improvements, fixes, and new features that align + with the project's goals, and +3. you can show support by starring the `repository `_, + letting us know that we’re doing a good job and helping us gain visibility within + the open-source community. + +Every contribution, big or small, is valuable! .. toctree:: :caption: API @@ -43,10 +56,24 @@ TODO :hidden: api/api - api/submission api/clients + api/errors + api/filesystem + api/retry + api/submission api/types + +.. toctree:: + :caption: In Depth + :glob: + :titlesonly: + :hidden: + + in_depth/overview + in_depth/client_resolution + + .. toctree:: :caption: Getting Involved :glob: @@ -54,4 +81,4 @@ TODO :hidden: contributors_guide/contributing - contributors_guide/release_notes \ No newline at end of file + contributors_guide/release_notes diff --git a/pyproject.toml b/pyproject.toml index 212569c..e8a4b3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "judge0" -version = "0.0.3" -description = "The official Python library for Judge0." +version = "0.1.0.dev0" +description = "The official Python SDK for Judge0." readme = "README.md" requires-python = ">=3.9" authors = [{ name = "Judge0", email = "contact@judge0.com" }] @@ -45,10 +45,18 @@ test = [ "pytest-cov==6.0.0", "flake8-docstrings==1.7.0", ] -docs = ["sphinx==7.4.7"] +docs = [ + "sphinx==7.4.7", + "sphinxawesome-theme==5.3.2", + "sphinx-autodoc-typehints==2.3.0", + "sphinx-multiversion==0.2.4", +] +dev = [ + "judge0[test]", + "judge0[docs]", +] [tool.flake8] -docstring-convention = "numpy" extend-ignore = [ 'D100', 'D101', @@ -56,8 +64,14 @@ extend-ignore = [ 'D103', 'D104', 'D105', + 'D107', 'D205', + "D209", 'D400', 'F821', ] +docstring-convention = "numpy" max-line-length = 88 + +[tool.pytest.ini_options] +addopts = "-vv" diff --git a/src/judge0/__init__.py b/src/judge0/__init__.py index df0f78a..f5f33a5 100644 --- a/src/judge0/__init__.py +++ b/src/judge0/__init__.py @@ -1,5 +1,7 @@ import os +from typing import Union + from .api import ( async_execute, async_run, @@ -27,6 +29,8 @@ from .retry import MaxRetries, MaxWaitTime, RegularPeriodRetry from .submission import Submission +__version__ = "0.1.0.dev0" + __all__ = [ "ATD", "ATDJudge0CE", @@ -71,8 +75,6 @@ def _get_implicit_client(flavor: Flavor) -> Client: if flavor == Flavor.EXTRA_CE and JUDGE0_IMPLICIT_EXTRA_CE_CLIENT is not None: return JUDGE0_IMPLICIT_EXTRA_CE_CLIENT - from .clients import CE, EXTRA_CE - try: from dotenv import load_dotenv @@ -80,32 +82,76 @@ def _get_implicit_client(flavor: Flavor) -> Client: except: # noqa: E722 pass + # Let's check if we can find a self-hosted client. + client = _get_custom_client(flavor) + + # Try to find one of the API keys JUDGE0_{SULU,RAPID,ATD}_API_KEY + # for hub clients. + if client is None: + client = _get_hub_client(flavor) + + # If we didn't find any of the possible keys, initialize + # the preview Sulu client based on the flavor. + if client is None: + client = _get_preview_client(flavor) + + if flavor == Flavor.CE: + JUDGE0_IMPLICIT_CE_CLIENT = client + else: + JUDGE0_IMPLICIT_EXTRA_CE_CLIENT = client + + return client + + +def _get_preview_client(flavor: Flavor) -> Union[SuluJudge0CE, SuluJudge0ExtraCE]: + if flavor == Flavor.CE: + return SuluJudge0CE(retry_strategy=RegularPeriodRetry(0.5)) + else: + return SuluJudge0ExtraCE(retry_strategy=RegularPeriodRetry(0.5)) + + +def _get_custom_client(flavor: Flavor) -> Union[Client, None]: + from json import loads + + ce_endpoint = os.getenv("JUDGE0_CE_ENDPOINT") + ce_auth_header = os.getenv("JUDGE0_CE_AUTH_HEADERS") + extra_ce_endpoint = os.getenv("JUDGE0_EXTRA_CE_ENDPOINT") + extra_ce_auth_header = os.getenv("JUDGE0_EXTRA_CE_AUTH_HEADERS") + + if flavor == Flavor.CE and ce_endpoint is not None and ce_auth_header is not None: + return Client( + endpoint=ce_endpoint, + auth_headers=loads(ce_auth_header), + ) + + if ( + flavor == Flavor.EXTRA_CE + and extra_ce_endpoint is not None + and extra_ce_auth_header is not None + ): + return Client( + endpoint=extra_ce_endpoint, + auth_headers=loads(extra_ce_auth_header), + ) + + return None + + +def _get_hub_client(flavor: Flavor) -> Union[Client, None]: + from .clients import CE, EXTRA_CE + if flavor == Flavor.CE: client_classes = CE else: client_classes = EXTRA_CE - # Try to find one of the predefined keys JUDGE0_{SULU,RAPID,ATD}_API_KEY - # in environment variables. - client = None for client_class in client_classes: api_key = os.getenv(client_class.API_KEY_ENV) if api_key is not None: client = client_class(api_key) break - - # If we didn't find any of the possible predefined keys, initialize - # the preview Sulu client based on the flavor. - if client is None: - if flavor == Flavor.CE: - client = SuluJudge0CE(retry_strategy=RegularPeriodRetry(0.5)) - else: - client = SuluJudge0ExtraCE(retry_strategy=RegularPeriodRetry(0.5)) - - if flavor == Flavor.CE: - JUDGE0_IMPLICIT_CE_CLIENT = client else: - JUDGE0_IMPLICIT_EXTRA_CE_CLIENT = client + client = None return client diff --git a/src/judge0/api.py b/src/judge0/api.py index 92b91b1..e83ecf7 100644 --- a/src/judge0/api.py +++ b/src/judge0/api.py @@ -67,8 +67,11 @@ def _resolve_client( if isinstance(client, Flavor): return get_client(client) - if client is None and isinstance(submissions, Iterable) and len(submissions) == 0: - raise ValueError("Client cannot be determined from empty submissions.") + if client is None: + if ( + isinstance(submissions, Iterable) and len(submissions) == 0 + ) or submissions is None: + raise ValueError("Client cannot be determined from empty submissions.") # client is None and we have to determine a flavor of the client from the # the submission's languages. @@ -353,10 +356,12 @@ def async_execute( resolved. submissions : Submission or Submissions, optional Submission or submissions for execution. - source_code: str, optional + source_code : str, optional A source code of a program. - test_cases: TestCaseType or TestCases, optional + test_cases : TestCaseType or TestCases, optional A single test or a list of test cases + **kwargs : dict + Additional keyword arguments to pass to the Submission constructor. Returns ------- @@ -405,6 +410,8 @@ def sync_execute( A source code of a program. test_cases: TestCaseType or TestCases, optional A single test or a list of test cases + **kwargs : dict + Additional keyword arguments to pass to the Submission constructor. Returns ------- diff --git a/src/judge0/base_types.py b/src/judge0/base_types.py index 8b892ba..94cedf8 100644 --- a/src/judge0/base_types.py +++ b/src/judge0/base_types.py @@ -1,7 +1,7 @@ import copy from dataclasses import dataclass -from enum import IntEnum, auto +from enum import auto, IntEnum from typing import Optional, Protocol, runtime_checkable, Sequence, Union from pydantic import BaseModel @@ -14,6 +14,10 @@ @dataclass(frozen=True) class TestCase: + """Test case data model.""" + + __test__ = False # Needed to avoid pytest warning + input: Optional[str] = None expected_output: Optional[str] = None @@ -21,7 +25,18 @@ class TestCase: def from_record( cls, test_case: Union[TestCaseType, None] ) -> Union["TestCase", None]: - """Create a TestCase from built-in types.""" + """Create a TestCase from built-in types. + + Parameters + ---------- + test_case: :obj:`TestCaseType` or None + Test case data. + + Returns + ------- + TestCase or None + Created TestCase object or None if test_case is None. + """ if isinstance(test_case, (tuple, list)): test_case = { field: value @@ -42,13 +57,18 @@ def from_record( @runtime_checkable -class Encodeable(Protocol): +class Encodable(Protocol): def encode(self) -> bytes: """Serialize the object to bytes.""" ... class Language(BaseModel): + """Language data model. + + Stores information about a language supported by Judge0. + """ + id: int name: str is_archived: Optional[bool] = None @@ -58,7 +78,13 @@ class Language(BaseModel): class LanguageAlias(IntEnum): - """Language enumeration.""" + """Language alias enumeration. + + Enumerates the programming languages supported by Judge0 client. Language + alias is resolved to the latest version of the language supported by the + selected client. + """ + ASSEMBLY = auto() BASH = auto() BASIC = auto() @@ -129,14 +155,20 @@ class LanguageAlias(IntEnum): class Flavor(IntEnum): - """Judge0 flavor enumeration.""" + """Flavor enumeration. + + Enumerates the flavors supported by Judge0 client. + """ CE = 0 EXTRA_CE = 1 class Status(IntEnum): - """Status enumeration.""" + """Status enumeration. + + Enumerates possible status codes of a submission. + """ IN_QUEUE = 1 PROCESSING = 2 @@ -158,7 +190,10 @@ def __str__(self): class Config(BaseModel): - """Client config data.""" + """Client config data model. + + Stores configuration data for the Judge0 client. + """ allow_enable_network: bool allow_enable_per_process_and_thread_memory_limit: bool diff --git a/src/judge0/clients.py b/src/judge0/clients.py index 311d26b..2d8366a 100644 --- a/src/judge0/clients.py +++ b/src/judge0/clients.py @@ -46,8 +46,9 @@ def __init__( self.languages = self.get_languages() self.config = self.get_config_info() except Exception as e: + home_url = getattr(self, "HOME_URL", None) raise RuntimeError( - f"Authentication failed. Visit {self.HOME_URL} to get or " + f"Authentication failed. Visit {home_url} to get or " "review your authentication credentials." ) from e @@ -355,7 +356,19 @@ def get_submissions( class ATD(Client): - """Base class for all AllThingsDev clients.""" + """Base class for all AllThingsDev clients. + + Parameters + ---------- + endpoint : str + Default request endpoint. + host_header_value : str + Value for the x-apihub-host header. + api_key : str + AllThingsDev API key. + **kwargs : dict + Additional keyword arguments for the base Client. + """ API_KEY_ENV: ClassVar[str] = "JUDGE0_ATD_API_KEY" @@ -375,7 +388,15 @@ def _update_endpoint_header(self, header_value): class ATDJudge0CE(ATD): - """AllThingsDev client for CE flavor.""" + """AllThingsDev client for CE flavor. + + Parameters + ---------- + api_key : str + AllThingsDev API key. + **kwargs : dict + Additional keyword arguments for the base Client. + """ DEFAULT_ENDPOINT: ClassVar[str] = ( "https://judge0-ce.proxy-production.allthingsdev.co" @@ -459,7 +480,15 @@ def get_submissions( class ATDJudge0ExtraCE(ATD): - """AllThingsDev client for Extra CE flavor.""" + """AllThingsDev client for Extra CE flavor. + + Parameters + ---------- + api_key : str + AllThingsDev API key. + **kwargs : dict + Additional keyword arguments for the base Client. + """ DEFAULT_ENDPOINT: ClassVar[str] = ( "https://judge0-extra-ce.proxy-production.allthingsdev.co" @@ -544,7 +573,19 @@ def get_submissions( class Rapid(Client): - """Base class for all RapidAPI clients.""" + """Base class for all RapidAPI clients. + + Parameters + ---------- + endpoint : str + Default request endpoint. + host_header_value : str + Value for the x-rapidapi-host header. + api_key : str + RapidAPI API key. + **kwargs : dict + Additional keyword arguments for the base Client. + """ API_KEY_ENV: ClassVar[str] = "JUDGE0_RAPID_API_KEY" @@ -561,7 +602,15 @@ def __init__(self, endpoint, host_header_value, api_key, **kwargs): class RapidJudge0CE(Rapid): - """RapidAPI client for CE flavor.""" + """RapidAPI client for CE flavor. + + Parameters + ---------- + api_key : str + RapidAPI API key. + **kwargs : dict + Additional keyword arguments for the base Client. + """ DEFAULT_ENDPOINT: ClassVar[str] = "https://judge0-ce.p.rapidapi.com" DEFAULT_HOST: ClassVar[str] = "judge0-ce.p.rapidapi.com" @@ -577,7 +626,15 @@ def __init__(self, api_key, **kwargs): class RapidJudge0ExtraCE(Rapid): - """RapidAPI client for Extra CE flavor.""" + """RapidAPI client for Extra CE flavor. + + Parameters + ---------- + api_key : str + RapidAPI API key. + **kwargs : dict + Additional keyword arguments for the base Client. + """ DEFAULT_ENDPOINT: ClassVar[str] = "https://judge0-extra-ce.p.rapidapi.com" DEFAULT_HOST: ClassVar[str] = "judge0-extra-ce.p.rapidapi.com" @@ -601,6 +658,8 @@ class Sulu(Client): Default request endpoint. api_key : str, optional Sulu API key. + **kwargs : dict + Additional keyword arguments for the base Client. """ API_KEY_ENV: ClassVar[str] = "JUDGE0_SULU_API_KEY" @@ -621,6 +680,8 @@ class SuluJudge0CE(Sulu): ---------- api_key : str, optional Sulu API key. + **kwargs : dict + Additional keyword arguments for the base Client. """ DEFAULT_ENDPOINT: ClassVar[str] = "https://judge0-ce.p.sulu.sh" @@ -641,6 +702,8 @@ class SuluJudge0ExtraCE(Sulu): ---------- api_key : str Sulu API key. + **kwargs : dict + Additional keyword arguments for the base Client. """ DEFAULT_ENDPOINT: ClassVar[str] = "https://judge0-extra-ce.p.sulu.sh" @@ -652,5 +715,5 @@ def __init__(self, api_key=None, **kwargs): super().__init__(self.DEFAULT_ENDPOINT, api_key, **kwargs) -CE = (RapidJudge0CE, SuluJudge0CE, ATDJudge0CE) -EXTRA_CE = (RapidJudge0ExtraCE, SuluJudge0ExtraCE, ATDJudge0ExtraCE) +CE = (SuluJudge0CE, RapidJudge0CE, ATDJudge0CE) +EXTRA_CE = (SuluJudge0ExtraCE, RapidJudge0ExtraCE, ATDJudge0ExtraCE) diff --git a/src/judge0/common.py b/src/judge0/common.py index 57ad838..e8ab58e 100644 --- a/src/judge0/common.py +++ b/src/judge0/common.py @@ -2,20 +2,22 @@ from itertools import islice from typing import Union -from judge0.base_types import Encodeable +from judge0.base_types import Encodable -def encode(content: Union[bytes, str, Encodeable]) -> str: +def encode(content: Union[bytes, str, Encodable]) -> str: + """Encode content to base64 string.""" if isinstance(content, bytes): return b64encode(content).decode() if isinstance(content, str): return b64encode(content.encode()).decode() - if isinstance(content, Encodeable): + if isinstance(content, Encodable): return b64encode(content.encode()).decode() raise ValueError(f"Unsupported type. Expected bytes or str, got {type(content)}!") def decode(content: Union[bytes, str]) -> str: + """Decode base64 encoded content.""" if isinstance(content, bytes): return b64decode( content.decode(errors="backslashreplace"), validate=True diff --git a/src/judge0/filesystem.py b/src/judge0/filesystem.py index 6773680..27fae22 100644 --- a/src/judge0/filesystem.py +++ b/src/judge0/filesystem.py @@ -11,6 +11,16 @@ class File(BaseModel): + """File object for storing file content. + + Parameters + ---------- + name : str + File name. + content : str or bytes, optional + File content. If str is provided, it will be encoded to bytes. + """ + name: str content: Optional[Union[str, bytes]] = None @@ -29,6 +39,15 @@ def __str__(self): class Filesystem(BaseModel): + """Filesystem object for storing multiple files. + + Parameters + ---------- + content : str or bytes or File or Iterable[File] or Filesystem, optional + Filesystem content. If str or bytes is provided, it will be decoded to + files. + """ + files: list[File] = [] def __init__(self, **data): @@ -66,6 +85,7 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}(content={content_encoded!r})" def encode(self) -> bytes: + """Encode Filesystem object to bytes.""" zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, "w") as zip_file: for file in self.files: diff --git a/src/judge0/retry.py b/src/judge0/retry.py index 20b42ef..f4bff5b 100644 --- a/src/judge0/retry.py +++ b/src/judge0/retry.py @@ -3,62 +3,106 @@ class RetryStrategy(ABC): + """Abstract base class that defines the interface for any retry strategy. + + See :obj:`MaxRetries`, :obj:`MaxWaitTime`, and :obj:`RegularPeriodRetry` for + example implementations. + """ + @abstractmethod def is_done(self) -> bool: + """Check if the retry strategy has exhausted its retries.""" pass @abstractmethod def wait(self) -> None: + """Delay implementation before the next retry attempt.""" pass + @abstractmethod def step(self) -> None: + """Update internal attributes of the retry strategy.""" pass class MaxRetries(RetryStrategy): """Check for submissions status every 100 ms and retry a maximum of - `max_retries` times.""" + `max_retries` times. + + Parameters + ---------- + max_retries : int + Max number of retries. + """ def __init__(self, max_retries: int = 20): + if max_retries < 1: + raise ValueError("max_retries must be at least 1.") self.n_retries = 0 self.max_retries = max_retries def step(self): + """Increment the number of retries by one.""" self.n_retries += 1 def wait(self): + """Wait for 0.1 seconds between retries.""" time.sleep(0.1) def is_done(self) -> bool: + """Check if the number of retries is bigger or equal to specified + maximum number of retries.""" return self.n_retries >= self.max_retries class MaxWaitTime(RetryStrategy): """Check for submissions status every 100 ms and wait for all submissions - a maximum of `max_wait_time` (seconds).""" + a maximum of `max_wait_time` (seconds). + + Parameters + ---------- + max_wait_time_sec : float + Maximum waiting time (in seconds). + """ def __init__(self, max_wait_time_sec: float = 5 * 60): self.max_wait_time_sec = max_wait_time_sec self.total_wait_time = 0 def step(self): + """Add 0.1 seconds to total waiting time.""" self.total_wait_time += 0.1 def wait(self): + """Wait (sleep) for 0.1 seconds.""" time.sleep(0.1) def is_done(self): + """Check if the total waiting time is bigger or equal to the specified + maximum waiting time.""" return self.total_wait_time >= self.max_wait_time_sec class RegularPeriodRetry(RetryStrategy): - """Check for submissions status periodically for indefinite amount of time.""" + """Check for submissions status periodically for indefinite amount of time. + + Parameters + ---------- + wait_time_sec : float + Wait time between retries (in seconds). + """ def __init__(self, wait_time_sec: float = 0.1): self.wait_time_sec = wait_time_sec def wait(self): + """Wait for `wait_time_sec` seconds.""" time.sleep(self.wait_time_sec) def is_done(self) -> bool: + """Return False, as this retry strategy is indefinite.""" return False + + def step(self) -> None: + """Satisfy the interface with a dummy implementation.""" + pass diff --git a/src/judge0/submission.py b/src/judge0/submission.py index 55b7660..78d1470 100644 --- a/src/judge0/submission.py +++ b/src/judge0/submission.py @@ -128,7 +128,7 @@ class Submission(BaseModel): source_code: Optional[Union[str, bytes]] = Field(default=None, repr=True) language: Union[LanguageAlias, int] = Field( - default=LanguageAlias.PYTHON, + default=LanguageAlias.PYTHON_FOR_ML, repr=True, ) additional_files: Optional[Union[str, Filesystem]] = Field(default=None, repr=True) diff --git a/tests/conftest.py b/tests/conftest.py index b1bd612..4e1547c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,74 +1,147 @@ +import json import os import pytest from dotenv import load_dotenv -from judge0 import clients +from judge0 import clients, RegularPeriodRetry load_dotenv() @pytest.fixture(scope="session") -def judge0_ce_client(): - api_key = os.getenv("JUDGE0_TEST_API_KEY") - api_key_header = os.getenv("JUDGE0_TEST_API_KEY_HEADER") - endpoint = os.getenv("JUDGE0_TEST_CE_ENDPOINT") - client = clients.Client( - endpoint=endpoint, - auth_headers={api_key_header: api_key}, - ) - return client +def custom_ce_client(): + endpoint = os.getenv("JUDGE0_CE_ENDPOINT") + auth_headers = os.getenv("JUDGE0_CE_AUTH_HEADERS") + + if endpoint is None or auth_headers is None: + return None + else: + return clients.Client(endpoint=endpoint, auth_headers=json.loads(auth_headers)) @pytest.fixture(scope="session") -def judge0_extra_ce_client(): - api_key = os.getenv("JUDGE0_TEST_API_KEY") - api_key_header = os.getenv("JUDGE0_TEST_API_KEY_HEADER") - endpoint = os.getenv("JUDGE0_TEST_EXTRA_CE_ENDPOINT") - client = clients.Client( - endpoint=endpoint, - auth_headers={api_key_header: api_key}, - ) - return client +def custom_extra_ce_client(): + endpoint = os.getenv("JUDGE0_EXTRA_CE_ENDPOINT") + auth_headers = os.getenv("JUDGE0_EXTRA_CE_AUTH_HEADERS") + + if endpoint is None or auth_headers is None: + return None + else: + return clients.Client(endpoint=endpoint, auth_headers=json.loads(auth_headers)) @pytest.fixture(scope="session") def atd_ce_client(): api_key = os.getenv("JUDGE0_ATD_API_KEY") - client = clients.ATDJudge0CE(api_key) - return client + + if api_key is None: + return None + else: + return clients.ATDJudge0CE(api_key) @pytest.fixture(scope="session") def atd_extra_ce_client(): api_key = os.getenv("JUDGE0_ATD_API_KEY") - client = clients.ATDJudge0ExtraCE(api_key) - return client + + if api_key is None: + return None + else: + return clients.ATDJudge0ExtraCE(api_key) @pytest.fixture(scope="session") def rapid_ce_client(): api_key = os.getenv("JUDGE0_RAPID_API_KEY") - client = clients.RapidJudge0CE(api_key) - return client + + if api_key is None: + return None + else: + return clients.RapidJudge0CE(api_key) @pytest.fixture(scope="session") def rapid_extra_ce_client(): api_key = os.getenv("JUDGE0_RAPID_API_KEY") - client = clients.RapidJudge0ExtraCE(api_key) - return client + + if api_key is None: + return None + else: + return clients.RapidJudge0ExtraCE(api_key) @pytest.fixture(scope="session") def sulu_ce_client(): api_key = os.getenv("JUDGE0_SULU_API_KEY") - client = clients.SuluJudge0CE(api_key) - return client + + if api_key is None: + return None + else: + return clients.SuluJudge0CE(api_key) @pytest.fixture(scope="session") def sulu_extra_ce_client(): api_key = os.getenv("JUDGE0_SULU_API_KEY") - client = clients.SuluJudge0ExtraCE(api_key) - return client + + if api_key is None: + return None + else: + return clients.SuluJudge0ExtraCE(api_key) + + +@pytest.fixture(scope="session") +def preview_ce_client() -> clients.SuluJudge0CE: + return clients.SuluJudge0CE(retry_strategy=RegularPeriodRetry(0.5)) + + +@pytest.fixture(scope="session") +def preview_extra_ce_client() -> clients.SuluJudge0ExtraCE: + return clients.SuluJudge0ExtraCE(retry_strategy=RegularPeriodRetry(0.5)) + + +@pytest.fixture(scope="session") +def ce_client( + custom_ce_client, + sulu_ce_client, + rapid_ce_client, + atd_ce_client, + preview_ce_client, +): + if custom_ce_client is not None: + return custom_ce_client + if sulu_ce_client is not None: + return sulu_ce_client + if rapid_ce_client is not None: + return rapid_ce_client + if atd_ce_client is not None: + return atd_ce_client + if preview_ce_client is not None: + return preview_ce_client + + pytest.fail("No CE client available for testing. This error should not happen!") + + +@pytest.fixture(scope="session") +def extra_ce_client( + custom_extra_ce_client, + sulu_extra_ce_client, + rapid_extra_ce_client, + atd_extra_ce_client, + preview_extra_ce_client, +): + if custom_extra_ce_client is not None: + return custom_extra_ce_client + if sulu_extra_ce_client is not None: + return sulu_extra_ce_client + if rapid_extra_ce_client is not None: + return rapid_extra_ce_client + if atd_extra_ce_client is not None: + return atd_extra_ce_client + if preview_extra_ce_client is not None: + return preview_extra_ce_client + + pytest.fail( + "No Extra CE client available for testing. This error should not happen!" + ) diff --git a/tests/test_api.py b/tests/test_api.py index 50a1464..3ba9526 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -48,7 +48,6 @@ def test_resolve_client_with_flavor( None, ], ) -@pytest.mark.skip def test_resolve_client_empty_submissions_argument(submissions): with pytest.raises(ValueError): _resolve_client(submissions=submissions) diff --git a/tests/test_api_test_cases.py b/tests/test_api_test_cases.py index 0d08f5f..8dea1e6 100644 --- a/tests/test_api_test_cases.py +++ b/tests/test_api_test_cases.py @@ -2,8 +2,9 @@ import judge0 import pytest -from judge0 import Status, Submission, TestCase from judge0.api import create_submissions_from_test_cases +from judge0.base_types import LanguageAlias, Status, TestCase +from judge0.submission import Submission @pytest.mark.parametrize( @@ -172,14 +173,20 @@ def test_single_test_case_in_iterable(self, test_cases, stdin, expected_output): [Status.ACCEPTED, Status.ACCEPTED], ], [ - Submission(source_code="print(f'Hello, {input()}')"), + Submission( + source_code="print(f'Hello, {input()}')", + language=LanguageAlias.PYTHON, + ), [ TestCase("Judge0", "Hello, Judge0"), ], [Status.ACCEPTED], ], [ - Submission(source_code="print(f'Hello, {input()}')"), + Submission( + source_code="print(f'Hello, {input()}')", + language=LanguageAlias.PYTHON, + ), [ TestCase("Judge0", "Hello, Judge0"), TestCase("pytest", "Hi, pytest"), @@ -188,8 +195,14 @@ def test_single_test_case_in_iterable(self, test_cases, stdin, expected_output): ], [ [ - Submission(source_code="print(f'Hello, {input()}')"), - Submission(source_code="print(f'Hello, {input()}')"), + Submission( + source_code="print(f'Hello, {input()}')", + language=LanguageAlias.PYTHON, + ), + Submission( + source_code="print(f'Hello, {input()}')", + language=LanguageAlias.PYTHON, + ), ], [ TestCase("Judge0", "Hello, Judge0"), @@ -207,13 +220,14 @@ def test_single_test_case_in_iterable(self, test_cases, stdin, expected_output): def test_test_cases_from_run( source_code_or_submissions, test_cases, expected_status, request ): - client = request.getfixturevalue("judge0_ce_client") + client = request.getfixturevalue("ce_client") if isinstance(source_code_or_submissions, str): submissions = judge0.run( client=client, source_code=source_code_or_submissions, test_cases=test_cases, + language=LanguageAlias.PYTHON, ) else: submissions = judge0.run( @@ -231,6 +245,7 @@ def test_test_cases_from_run( [ Submission( source_code="print(f'Hello, {input()}')", + language=LanguageAlias.PYTHON, stdin="Judge0", expected_output="Hello, Judge0", ), @@ -240,11 +255,13 @@ def test_test_cases_from_run( [ Submission( source_code="print(f'Hello, {input()}')", + language=LanguageAlias.PYTHON, stdin="Judge0", expected_output="Hello, Judge0", ), Submission( source_code="print(f'Hello, {input()}')", + language=LanguageAlias.PYTHON, stdin="pytest", expected_output="Hello, pytest", ), @@ -254,7 +271,7 @@ def test_test_cases_from_run( ], ) def test_no_test_cases(submissions, expected_status, request): - client = request.getfixturevalue("judge0_ce_client") + client = request.getfixturevalue("ce_client") submissions = judge0.run( client=client, @@ -269,9 +286,13 @@ def test_no_test_cases(submissions, expected_status, request): @pytest.mark.parametrize("n_submissions", [42, 84]) def test_batched_test_cases(n_submissions, request): - client = request.getfixturevalue("judge0_ce_client") + client = request.getfixturevalue("ce_client") submissions = [ - Submission(source_code=f"print({i})", expected_output=f"{i}") + Submission( + source_code=f"print({i})", + language=LanguageAlias.PYTHON, + expected_output=f"{i}", + ) for i in range(n_submissions) ] diff --git a/tests/test_submission.py b/tests/test_submission.py index ddae140..8675034 100644 --- a/tests/test_submission.py +++ b/tests/test_submission.py @@ -52,8 +52,10 @@ def test_from_json(): def test_status_before_and_after_submission(request): - client = request.getfixturevalue("judge0_ce_client") - submission = Submission(source_code='print("Hello World!")') + client = request.getfixturevalue("ce_client") + submission = Submission( + source_code='print("Hello World!")', language=LanguageAlias.PYTHON + ) assert submission.status is None @@ -65,8 +67,10 @@ def test_status_before_and_after_submission(request): def test_is_done(request): - client = request.getfixturevalue("judge0_ce_client") - submission = Submission(source_code='print("Hello World!")') + client = request.getfixturevalue("ce_client") + submission = Submission( + source_code='print("Hello World!")', language=LanguageAlias.PYTHON + ) assert submission.status is None @@ -77,7 +81,7 @@ def test_is_done(request): def test_language_before_and_after_execution(request): - client = request.getfixturevalue("judge0_ce_client") + client = request.getfixturevalue("ce_client") code = """\ public class Main { public static void main(String[] args) { @@ -97,7 +101,7 @@ def test_language_before_and_after_execution(request): def test_language_executable(request): - client = request.getfixturevalue("judge0_ce_client") + client = request.getfixturevalue("ce_client") code = b64decode( "f0VMRgIBAQAAAAAAAAAAAAIAPgABAAAAAABAAAAAAABAAAAAAAAAAEAQAAAAAAAAAAAAAEAAOAABAEAABAADAAEAAAAFAAAAABAAAAAAAAAAAEAAAAAAAAAAQAAAAAAAJQAAAAAAAAAlAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADHAjVANsAG+GABAAInHDwUx/41HPA8FAGhlbGxvLCB3b3JsZAoALnNoc3RydGFiAC50ZXh0AC5yb2RhdGEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAEAAAAGAAAAAAAAAAAAQAAAAAAAABAAAAAAAAAXAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABEAAAABAAAAAgAAAAAAAAAYAEAAAAAAABgQAAAAAAAADQAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAABAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAlEAAAAAAAABkAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAA" # noqa: E501 )