diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dd84ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..1d5909d --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,33 @@ +name: "Sphinx: Render docs" + +on: + workflow_dispatch: + push: + branches: ["master"] + + +jobs: + build: + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Build HTML + uses: ammaraskar/sphinx-action@7.0.0 + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: html-docs + path: docs/build/html/ + + - name: Deploy + uses: peaceiris/actions-gh-pages@v3 + if: github.ref == 'refs/heads/master' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: docs/build/html \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 62f57c9..38c14e8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,13 +3,14 @@ name: Test judge0-python on: push: branches: ["master"] + paths: ["src/**", "tests/**"] permissions: contents: read jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -19,10 +20,10 @@ jobs: python-version: "3.9" - name: Install dependencies run: | + python -m venv venv + source venv/bin/activate python -m pip install --upgrade pip - pip install pipenv - pipenv install --dev - pipenv install -e . + pip install -e .[test] - name: Test with pytest env: # Add necessary api keys as env variables. JUDGE0_ATD_API_KEY: ${{ secrets.JUDGE0_ATD_API_KEY }} @@ -33,4 +34,5 @@ jobs: JUDGE0_TEST_CE_ENDPOINT: ${{ secrets.JUDGE0_TEST_CE_ENDPOINT }} JUDGE0_TEST_EXTRA_CE_ENDPOINT: ${{ secrets.JUDGE0_TEST_EXTRA_CE_ENDPOINT }} run: | - pipenv run pytest -vv tests/ + source venv/bin/activate + pytest -vv tests/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e2a0428..8adce63 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,3 +6,11 @@ repos: additional_dependencies: - black == 24.8.0 - usort == 1.0.8.post1 + - repo: https://github.com/pycqa/flake8 + rev: 7.1.1 + hooks: + - id: flake8 + additional_dependencies: + - "flake8-pyproject" + - flake8-docstrings + - pydocstyle diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0a27e2e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +contact@judge0.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..346d24d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# How to contribute + +See [docs](https://judge0.github.io/judge0-python/contributing.html). diff --git a/Pipfile b/Pipfile deleted file mode 100644 index f7f341b..0000000 --- a/Pipfile +++ /dev/null @@ -1,18 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -requests = "==2.32.3" - -[dev-packages] -ufmt = "==2.7.3" -pre-commit = "==3.8.0" -pytest = "==8.3.3" -python-dotenv = "==1.0.1" -pytest-cov = "6.0.0" - -[requires] -python_version = "3.9" -python_full_version = "3.9.20" diff --git a/README.md b/README.md index 24baa57..de2703e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # Judge0 Python SDK -The official Python library for Judge0. +The official Python SDK for Judge0. diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md new file mode 100644 index 0000000..1a46e3d --- /dev/null +++ b/RELEASE_NOTES_TEMPLATE.md @@ -0,0 +1,15 @@ +# vX.Y.Z (YYYY-MM-DD) + +## API Changes + +## New Features + +## Improvements + +## Security Improvements + +## Bug Fixes + +## Security Fixes + +## Other Changes diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..8e76ca0 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +sphinxawesome-theme==5.3.2 +sphinx-autodoc-typehints==2.3.0 diff --git a/docs/source/api/api.rst b/docs/source/api/api.rst new file mode 100644 index 0000000..08b5d0e --- /dev/null +++ b/docs/source/api/api.rst @@ -0,0 +1,6 @@ +API Module +========== + +.. automodule:: judge0.api + :members: + :undoc-members: diff --git a/docs/source/api/clients.rst b/docs/source/api/clients.rst new file mode 100644 index 0000000..52e7e4e --- /dev/null +++ b/docs/source/api/clients.rst @@ -0,0 +1,6 @@ +Clients Module +============== + +.. automodule:: judge0.clients + :members: + :member-order: groupwise diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst new file mode 100644 index 0000000..eb4ed67 --- /dev/null +++ b/docs/source/api/index.rst @@ -0,0 +1,7 @@ +.. toctree:: + :maxdepth: 2 + + api + submission + clients + types \ No newline at end of file diff --git a/docs/source/api/submission.rst b/docs/source/api/submission.rst new file mode 100644 index 0000000..e42a6aa --- /dev/null +++ b/docs/source/api/submission.rst @@ -0,0 +1,6 @@ +Submission Module +================= + +.. automodule:: judge0.submission + :members: + :member-order: groupwise diff --git a/docs/source/api/types.rst b/docs/source/api/types.rst new file mode 100644 index 0000000..2b415b3 --- /dev/null +++ b/docs/source/api/types.rst @@ -0,0 +1,6 @@ +Types Module +============ + +.. automodule:: judge0.base_types + :members: + :undoc-members: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..77420a5 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,52 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + + +import os +import sys + +project = "Judge0 Python SDK" +copyright = "2024, Judge0" +author = "Judge0" +release = "0.1" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.autosummary", + "sphinx_autodoc_typehints", +] + +templates_path = ["_templates"] +exclude_patterns = [] + + +# add_module_names = False + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "sphinxawesome_theme" +html_show_sphinx = False + +sys.path.insert(0, os.path.abspath("../../src/")) # Adjust as needed + + +autodoc_default_options = { + "members": True, + "undoc-members": True, + "private-members": False, + "special-members": False, + "inherited-members": False, +} +autodoc_mock_imports = ["requests", "pydantic"] + +napoleon_google_docstring = False diff --git a/docs/source/contributors_guide/contributing.rst b/docs/source/contributors_guide/contributing.rst new file mode 100644 index 0000000..2a19fb5 --- /dev/null +++ b/docs/source/contributors_guide/contributing.rst @@ -0,0 +1,28 @@ +Contributing +============ + +Preparing the development setup +------------------------------- + +1. Install Python 3.9 + +.. code-block:: console + + $ sudo add-apt-repository ppa:deadsnakes/ppa + $ sudo apt update + $ sudo apt install python3.9 python3.9-venv + +2. Clone the repo, create and activate a new virtual environment + +.. code-block:: console + + $ cd judge0-python + $ python3.9 -m venv venv + $ . venv/bin/activate + +3. Install the library and development dependencies + +.. code-block:: console + + $ pip install -e .[test] + $ pre-commit install diff --git a/docs/source/contributors_guide/index.rst b/docs/source/contributors_guide/index.rst new file mode 100644 index 0000000..312258b --- /dev/null +++ b/docs/source/contributors_guide/index.rst @@ -0,0 +1,5 @@ +.. toctree:: + :maxdepth: 2 + + contributing + release_notes diff --git a/docs/source/contributors_guide/release_notes.rst b/docs/source/contributors_guide/release_notes.rst new file mode 100644 index 0000000..0b6251f --- /dev/null +++ b/docs/source/contributors_guide/release_notes.rst @@ -0,0 +1,4 @@ +How to create a release candidate +================================= + +TODO \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..6c202aa --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,54 @@ +=============================== +Judge0 Python SDK documentation +=============================== + +Getting Started +=============== + +You can run minimal Hello World example in three easy steps: + +1. Install Judge0 Python SDK: + +.. code-block:: bash + + pip install judge0 + +2. Create a minimal script: + +.. code-block:: Python + + import judge0 + + submission = judge.run(source_code="print('Hello Judge0!')") + print(submission.stdout) + +3. Run the script. + +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. + +Getting Involved +---------------- + +TODO + +.. toctree:: + :caption: API + :glob: + :titlesonly: + :hidden: + + api/index + +.. toctree:: + :caption: Getting Involved + :glob: + :titlesonly: + :hidden: + + contributors_guide/index \ No newline at end of file diff --git a/examples/0005_filesystem.py b/examples/0005_filesystem.py index c75a1b4..dc79eb6 100644 --- a/examples/0005_filesystem.py +++ b/examples/0005_filesystem.py @@ -3,7 +3,7 @@ print("Subexample 1") result = judge0.run(source_code="print('hello, world')") -fs = Filesystem(result.post_execution_filesystem) +fs = Filesystem(content=result.post_execution_filesystem) for f in fs: print(f.name) print(f) @@ -11,19 +11,19 @@ print("Subexample 2") -fs = Filesystem(File("my_file.txt", "hello, world")) +fs = Filesystem(content=File(name="my_file.txt", content="hello, world")) result = judge0.run( source_code="print(open('my_file.txt').read())", additional_files=fs ) print(result.stdout) -for f in Filesystem(result.post_execution_filesystem): +for f in Filesystem(content=result.post_execution_filesystem): print(f.name) print(f) print() print("Subexample 3") -fs = Filesystem(File("my_file.txt", "hello, world")) +fs = Filesystem(content=File(name="my_file.txt", content="hello, world")) result = judge0.run( source_code="print(open('my_file.txt').read())", additional_files=fs ) @@ -35,14 +35,14 @@ print("Subexample 4") fs = Filesystem( - [ - File("my_file.txt", "hello, world"), - File("./dir1/dir2/dir3/my_file2.txt", "hello, world2"), + content=[ + File(name="my_file.txt", content="hello, world"), + File(name="./dir1/dir2/dir3/my_file2.txt", content="hello, world2"), ] ) result = judge0.run(source_code="find .", additional_files=fs, language=46) print(result.stdout) -for f in Filesystem(result.post_execution_filesystem): +for f in Filesystem(content=result.post_execution_filesystem): print(f.name) print(f) print() diff --git a/examples/1000_http_callback_aka_webhook/main.py b/examples/1000_http_callback_aka_webhook/main.py index dab2b77..a65411d 100755 --- a/examples/1000_http_callback_aka_webhook/main.py +++ b/examples/1000_http_callback_aka_webhook/main.py @@ -1,18 +1,10 @@ #!/usr/bin/env python3 -from fastapi import FastAPI, Depends -from pydantic import BaseModel - -import uvicorn import asyncio -import judge0 +import judge0 -class CallbackResponse(BaseModel): - created_at: str - finished_at: str - language: dict - status: dict - stdout: str +import uvicorn +from fastapi import Depends, FastAPI class AppContext: @@ -47,13 +39,14 @@ async def root(app_context=Depends(get_app_context)): @app.put("/callback") -async def callback(response: CallbackResponse): +async def callback(response: judge0.Submission): print(f"Received: {response}") -# We are using free service from https://localhost.run to get a public URL for our local server. -# This approach is not recommended for production use. It is only for demonstration purposes -# since domain names change regularly and there is a speed limit for the free service. +# We are using free service from https://localhost.run to get a public URL for +# our local server. This approach is not recommended for production use. It is +# only for demonstration purposes since domain names change regularly and there +# is a speed limit for the free service. async def run_ssh_tunnel(): app_context = get_app_context() @@ -69,7 +62,9 @@ async def run_ssh_tunnel(): ] process = await asyncio.create_subprocess_exec( - *command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, ) while True: @@ -86,7 +81,11 @@ async def run_ssh_tunnel(): async def run_server(): config = uvicorn.Config( - app, host="127.0.0.1", port=LOCAL_SERVER_PORT, workers=5, loop="asyncio" + app, + host="127.0.0.1", + port=LOCAL_SERVER_PORT, + workers=5, + loop="asyncio", ) server = uvicorn.Server(config) await server.serve() diff --git a/pyproject.toml b/pyproject.toml index aeef351..3568054 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [project] name = "judge0" -version = "0.0.1" +version = "0.0.2" description = "The official Python library for Judge0." readme = "README.md" requires-python = ">=3.9" -authors = [{ name = "Judge0", email = "support@judge0.com" }] +authors = [{ name = "Judge0", email = "contact@judge0.com" }] classifiers = [ "Intended Audience :: Developers", @@ -25,7 +25,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", ] -dependencies = ["requests>=2.32.3"] +dependencies = ["requests>=2.28.0,<3.0.0", "pydantic>=2.0.0,<3.0.0"] [build-system] requires = ["setuptools>=70.0"] @@ -37,4 +37,17 @@ Repository = "https://github.com/judge0/judge0-python.git" Issues = "https://github.com/judge0/judge0-python/issues" [project.optional-dependencies] -test = ["pytest", "mkdocs"] +test = [ + "ufmt==2.7.3", + "pre-commit==3.8.0", + "pytest==8.3.3", + "python-dotenv==1.0.1", + "pytest-cov==6.0.0", + "flake8-docstrings==1.7.0", +] +docs = ["sphinx==7.4.7"] + +[tool.flake8] +docstring-convention = "numpy" +extend-ignore = ["D205", "D400", "D105", "D100", "D101", "D102", "D103", "F821"] +max-line-length = 88 diff --git a/src/judge0/__init__.py b/src/judge0/__init__.py index 8f41ec0..5ccf40b 100644 --- a/src/judge0/__init__.py +++ b/src/judge0/__init__.py @@ -98,9 +98,9 @@ def _get_implicit_client(flavor: Flavor) -> Client: # the preview Sulu client based on the flavor. if client is None: if flavor == Flavor.CE: - client = SuluJudge0CE() + client = SuluJudge0CE(retry_strategy=RegularPeriodRetry(0.5)) else: - client = SuluJudge0ExtraCE() + client = SuluJudge0ExtraCE(retry_strategy=RegularPeriodRetry(0.5)) if flavor == Flavor.CE: JUDGE0_IMPLICIT_CE_CLIENT = client @@ -113,6 +113,8 @@ def _get_implicit_client(flavor: Flavor) -> Client: CE = Flavor.CE EXTRA_CE = Flavor.EXTRA_CE +# TODO: Let's use getattr and setattr for this language ALIASES and raise an +# exception if a value already exists. PYTHON = LanguageAlias.PYTHON CPP = LanguageAlias.CPP JAVA = LanguageAlias.JAVA diff --git a/src/judge0/api.py b/src/judge0/api.py index b5fd64d..e254dd9 100644 --- a/src/judge0/api.py +++ b/src/judge0/api.py @@ -1,14 +1,26 @@ -from typing import Iterable, Optional, Union +from typing import Optional, Union -from .base_types import Flavor, TestCase, TestCases +from .base_types import Flavor, Iterable, TestCase, TestCases, TestCaseType from .clients import Client from .common import batched - -from .retry import RegularPeriodRetry, RetryMechanism +from .errors import ClientResolutionError +from .retry import RegularPeriodRetry, RetryStrategy from .submission import Submission, Submissions def get_client(flavor: Flavor = Flavor.CE) -> Client: + """Resolve client from API keys from environment or default to preview client. + + Parameters + ---------- + flavor : Flavor + Flavor of Judge0 Client. + + Returns + ------- + Client + An object of base type Client and the specified flavor. + """ from . import _get_implicit_client if isinstance(flavor, Flavor): @@ -24,18 +36,42 @@ def _resolve_client( client: Optional[Union[Client, Flavor]] = None, submissions: Optional[Union[Submission, Submissions]] = None, ) -> Client: + """Resolve a client from flavor or submission(s) arguments. + + Parameters + ---------- + client : Client or Flavor, optional + A Client object or flavor of client. Returns the client if not None. + submissions: Submission or Submissions, optional + Submission(s) used to determine the suitable client. + + Returns + ------- + Client + An object of base type Client. + + Raises + ------ + ClientResolutionError + If there is no implemented client that supports all the languages specified + in the submissions. + """ # User explicitly passed a client. if isinstance(client, Client): return client + # NOTE: At the moment, we do not support the option to check if explicit + # flavor of a client supports the submissions, i.e. submissions argument is + # ignored if flavor argument is provided. + if isinstance(client, Flavor): return get_client(client) - if client is None and isinstance(submissions, list) and len(submissions) == 0: + if client is None and isinstance(submissions, Iterable) and len(submissions) == 0: raise ValueError("Client cannot be determined from empty submissions.") # client is None and we have to determine a flavor of the client from the - # submissions and the languages. + # the submission's languages. if isinstance(submissions, Submission): submissions = [submissions] @@ -49,7 +85,7 @@ def _resolve_client( ): return client - raise RuntimeError( + raise ClientResolutionError( "Failed to resolve the client from submissions argument. " "None of the implicit clients supports all languages from the submissions. " "Please explicitly provide the client argument." @@ -57,9 +93,24 @@ def _resolve_client( def create_submissions( - client: Optional[Client] = None, + *, + client: Optional[Union[Client, Flavor]] = None, submissions: Optional[Union[Submission, Submissions]] = None, ) -> Union[Submission, Submissions]: + """Universal function for creating submissions to the client. + + Parameters + ---------- + client : Client or Flavor, optional + A client or client flavor where submissions should be created. + submissions: Submission or Submissions, optional + Submission(s) to create. + + Raises + ------ + ClientResolutionError + Raised if client resolution fails. + """ client = _resolve_client(client=client, submissions=submissions) if isinstance(submissions, Submission): @@ -79,10 +130,26 @@ def create_submissions( def get_submissions( *, - client: Optional[Client] = None, + client: Optional[Union[Client, Flavor]] = None, submissions: Optional[Union[Submission, Submissions]] = None, - fields: Union[str, Iterable[str], None] = None, + fields: Optional[Union[str, Iterable[str]]] = None, ) -> Union[Submission, Submissions]: + """Get submission (status) from a client. + + Parameters + ---------- + client : Client or Flavor, optional + A client or client flavor where submissions should be checked. + submissions : Submission or Submissions, optional + Submission(s) to update. + fields : str or sequence of str, optional + Submission attributes that need to be updated. Defaults to all attributes. + + Raises + ------ + ClientResolutionError + Raised if client resolution fails. + """ client = _resolve_client(client=client, submissions=submissions) if isinstance(submissions, Submission): @@ -106,57 +173,85 @@ def get_submissions( def wait( *, - client: Optional[Client] = None, + client: Optional[Union[Client, Flavor]] = None, submissions: Optional[Union[Submission, Submissions]] = None, - retry_mechanism: Optional[RetryMechanism] = None, + retry_strategy: Optional[RetryStrategy] = None, ) -> Union[Submission, Submissions]: + """Wait for all the submissions to finish. + + Parameters + ---------- + client : Client or Flavor, optional + A client or client flavor where submissions should be checked. + submissions : Submission or Submissions + Submission(s) to wait for. + retry_strategy : RetryStrategy, optional + A retry strategy. + + Raises + ------ + ClientResolutionError + Raised if client resolution fails. + """ client = _resolve_client(client, submissions) - if retry_mechanism is None: - retry_mechanism = RegularPeriodRetry() + if retry_strategy is None: + if client.retry_strategy is None: + retry_strategy = RegularPeriodRetry() + else: + retry_strategy = client.retry_strategy if isinstance(submissions, Submission): - submissions_to_check = { - submission.token: submission for submission in [submissions] - } + submissions_list = [submissions] else: - submissions_to_check = { - submission.token: submission for submission in submissions - } + submissions_list = submissions - while len(submissions_to_check) > 0 and not retry_mechanism.is_done(): + submissions_to_check = { + submission.token: submission for submission in submissions_list + } + + while len(submissions_to_check) > 0 and not retry_strategy.is_done(): get_submissions(client=client, submissions=list(submissions_to_check.values())) - for token in list(submissions_to_check): - submission = submissions_to_check[token] - if submission.is_done(): - submissions_to_check.pop(token) + finished_submissions = [ + token + for token, submission in submissions_to_check.items() + if submission.is_done() + ] + for token in finished_submissions: + submissions_to_check.pop(token) # Don't wait if there is no submissions to check for anymore. if len(submissions_to_check) == 0: break - retry_mechanism.wait() - retry_mechanism.step() + retry_strategy.wait() + retry_strategy.step() return submissions def create_submissions_from_test_cases( submissions: Union[Submission, Submissions], - test_cases: Optional[Union[TestCase, TestCases]] = None, -): - """Utility function for creating submissions from the (submission, test_case) pairs. - - The following table contains the return type based on the types of `submissions` - and `test_cases` arguments: - - | submissions | test_cases | returns | - |:------------|:-----------|:------------| - | Submission | TestCase | Submission | - | Submission | TestCases | Submissions | - | Submissions | TestCase | Submissions | - | Submissions | TestCases | Submissions | - + test_cases: Optional[Union[TestCaseType, TestCases]] = None, +) -> Union[Submission, list[Submission]]: + """Create submissions from the submission and test case pairs. + + Function always returns a deep copy so make sure you are using the + returned submission(s). + + Parameters + ---------- + submissions : Submission or Submissions + Base submission(s) that need to be expanded with test cases. + test_cases: TestCaseType or TestCases + Test cases. + + Returns + ------- + Submissions or Submissions + A single submission if submissions arguments is of type Submission or + source_code argument is provided, and test_cases argument is of type + TestCase. Otherwise returns a list of submissions. """ if isinstance(submissions, Submission): submissions_list = [submissions] @@ -165,10 +260,27 @@ def create_submissions_from_test_cases( if isinstance(test_cases, TestCase) or test_cases is None: test_cases_list = [test_cases] + multiple_test_cases = False else: - test_cases_list = test_cases - - test_cases_list = [TestCase.from_record(tc) for tc in test_cases_list] + try: + # Let's assume that we are dealing with multiple test_cases that + # can be created from test_cases argument. If this fails, i.e. + # raises a ValueError, we know we are dealing with a test_cases=dict, + # or test_cases=["in", "out"], or test_cases=tuple("in", "out"). + test_cases_list = [TestCase.from_record(tc) for tc in test_cases] + + # It is possible to send test_cases={}, or test_cases=[], or + # test_cases=tuple([]). In this case, we are treating that as None. + if len(test_cases) > 0: + multiple_test_cases = True + else: + multiple_test_cases = False + test_cases_list = [None] + except ValueError: + test_cases_list = [test_cases] + multiple_test_cases = False + + test_cases_list = [TestCase.from_record(test_case=tc) for tc in test_cases_list] all_submissions = [] for submission in submissions_list: @@ -179,9 +291,7 @@ def create_submissions_from_test_cases( submission_copy.expected_output = test_case.expected_output all_submissions.append(submission_copy) - if isinstance(submissions, Submission) and ( - isinstance(test_cases, TestCase) or test_cases is None - ): + if isinstance(submissions, Submission) and (not multiple_test_cases): return all_submissions[0] else: return all_submissions @@ -192,10 +302,11 @@ def _execute( client: Optional[Union[Client, Flavor]] = None, submissions: Optional[Union[Submission, Submissions]] = None, source_code: Optional[str] = None, - test_cases: Optional[Union[TestCase, TestCases]] = None, + test_cases: Optional[Union[TestCaseType, TestCases]] = None, wait_for_result: bool = False, **kwargs, ) -> Union[Submission, Submissions]: + if submissions is not None and source_code is not None: raise ValueError( "Both submissions and source_code arguments are provided. " @@ -204,6 +315,7 @@ def _execute( if submissions is None and source_code is None: raise ValueError("Neither source_code nor submissions argument are provided.") + # Internally, let's rely on Submission's dataclass. if source_code is not None: submissions = Submission(source_code=source_code, **kwargs) @@ -222,9 +334,39 @@ def async_execute( client: Optional[Union[Client, Flavor]] = None, submissions: Optional[Union[Submission, Submissions]] = None, source_code: Optional[str] = None, - test_cases: Optional[Union[TestCase, TestCases]] = None, + test_cases: Optional[Union[TestCaseType, TestCases]] = None, **kwargs, ) -> Union[Submission, Submissions]: + """Create submission(s). + + Aliases: `async_run`. + + Parameters + ---------- + client : Client or Flavor, optional + A client where submissions should be created. If None, will try to be + resolved. + submissions : Submission or Submissions, optional + Submission or submissions for execution. + source_code: str, optional + A source code of a program. + test_cases: TestCaseType or TestCases, optional + A single test or a list of test cases + + Returns + ------- + Submission or Submissions + A single submission if submissions arguments is of type Submission or + source_code argument is provided, and test_cases argument is of type + TestCase. Otherwise returns a list of submissions. + + Raises + ------ + ClientResolutionError + If client cannot be resolved from the submissions or the flavor. + ValueError + If both or neither submissions and source_code arguments are provided. + """ return _execute( client=client, submissions=submissions, @@ -240,9 +382,39 @@ def sync_execute( client: Optional[Union[Client, Flavor]] = None, submissions: Optional[Union[Submission, Submissions]] = None, source_code: Optional[str] = None, - test_cases: Optional[Union[TestCase, TestCases]] = None, + test_cases: Optional[Union[TestCaseType, TestCases]] = None, **kwargs, ) -> Union[Submission, Submissions]: + """Create submission(s) and wait for their finish. + + Aliases: `execute`, `run`, `sync_run`. + + Parameters + ---------- + client : Client or Flavor, optional + A client where submissions should be created. If None, will try to be + resolved. + submissions : Submission or Submissions, optional + Submission(s) for execution. + source_code: str, optional + A source code of a program. + test_cases: TestCaseType or TestCases, optional + A single test or a list of test cases + + Returns + ------- + Submission or Submissions + A single submission if submissions arguments is of type Submission or + source_code argument is provided, and test_cases argument is of type + TestCase. Otherwise returns a list of submissions. + + Raises + ------ + ClientResolutionError + If client cannot be resolved from the submissions or the flavor. + ValueError + If both or neither submissions and source_code arguments are provided. + """ return _execute( client=client, submissions=submissions, diff --git a/src/judge0/base_types.py b/src/judge0/base_types.py index b1d4210..05a7a64 100644 --- a/src/judge0/base_types.py +++ b/src/judge0/base_types.py @@ -1,63 +1,65 @@ -from abc import ABC, abstractmethod +import copy + from dataclasses import dataclass from enum import IntEnum -from typing import Optional, Union +from typing import Optional, Protocol, runtime_checkable, Sequence, Union + +from pydantic import BaseModel +Iterable = Sequence -TestCases = Union[ - list["TestCase"], - tuple["TestCase"], - list[dict], - tuple[dict], - list[list], - list[tuple], - tuple[list], - tuple[tuple], -] +TestCaseType = Union["TestCase", list, tuple, dict] +TestCases = Iterable[TestCaseType] @dataclass(frozen=True) class TestCase: - # Needed to disable pytest from recognizing it as a class containing different test cases. - __test__ = False - input: Optional[str] = None expected_output: Optional[str] = None - @staticmethod + @classmethod def from_record( - test_case: Optional[Union[tuple, list, dict, "TestCase"]] = None - ) -> "TestCase": + cls, test_case: Union[TestCaseType, None] + ) -> Union["TestCase", None]: + """Create a TestCase from built-in types.""" if isinstance(test_case, (tuple, list)): test_case = { field: value for field, value in zip(("input", "expected_output"), test_case) } if isinstance(test_case, dict): - return TestCase( + return cls( input=test_case.get("input", None), expected_output=test_case.get("expected_output", None), ) - if isinstance(test_case, TestCase) or test_case is None: - return test_case + if isinstance(test_case, cls): + return copy.deepcopy(test_case) + if test_case is None: + return None raise ValueError( f"Cannot create TestCase object from object of type {type(test_case)}." ) -class Encodeable(ABC): - @abstractmethod +@runtime_checkable +class Encodeable(Protocol): def encode(self) -> bytes: - pass + """Serialize the object to bytes.""" + ... -@dataclass(frozen=True) -class Language: +class Language(BaseModel): id: int name: str + is_archived: Optional[bool] = None + source_file: Optional[str] = None + compile_cmd: Optional[str] = None + run_cmd: Optional[str] = None class LanguageAlias(IntEnum): + """Language enumeration.""" + PYTHON = 0 CPP = 1 JAVA = 2 @@ -67,11 +69,15 @@ class LanguageAlias(IntEnum): class Flavor(IntEnum): + """Judge0 flavor enumeration.""" + CE = 0 EXTRA_CE = 1 class Status(IntEnum): + """Status enumeration.""" + IN_QUEUE = 1 PROCESSING = 2 ACCEPTED = 3 @@ -91,8 +97,9 @@ def __str__(self): return self.name.lower().replace("_", " ").title() -@dataclass(frozen=True) -class Config: +class Config(BaseModel): + """Client config data.""" + allow_enable_network: bool allow_enable_per_process_and_thread_memory_limit: bool allow_enable_per_process_and_thread_time_limit: bool diff --git a/src/judge0/clients.py b/src/judge0/clients.py index 0797e9a..ff8e989 100644 --- a/src/judge0/clients.py +++ b/src/judge0/clients.py @@ -1,67 +1,82 @@ -from typing import Iterable, Union +from typing import ClassVar, Optional, Union import requests -from .base_types import Config, Language, LanguageAlias +from .base_types import Config, Iterable, Language, LanguageAlias from .data import LANGUAGE_TO_LANGUAGE_ID +from .retry import RetryStrategy from .submission import Submission, Submissions +from .utils import handle_too_many_requests_error_for_preview_client class Client: - API_KEY_ENV = "JUDGE0_API_KEY" - DEFAULT_MAX_SUBMISSION_BATCH_SIZE = 20 - ENABLED_BATCHED_SUBMISSIONS = True - EFFECTIVE_SUBMISSION_BATCH_SIZE = ( - DEFAULT_MAX_SUBMISSION_BATCH_SIZE if ENABLED_BATCHED_SUBMISSIONS else 1 - ) + API_KEY_ENV: ClassVar[str] = None - def __init__(self, endpoint, auth_headers) -> None: + def __init__( + self, + endpoint, + auth_headers, + *, + retry_strategy: Optional[RetryStrategy] = None, + ) -> None: self.endpoint = endpoint self.auth_headers = auth_headers + self.retry_strategy = retry_strategy + self.session = requests.Session() + # TODO: Should be handled differently. try: - self.languages = [Language(**lang) for lang in self.get_languages()] - self.config = Config(**self.get_config_info()) + self.languages = self.get_languages() + self.config = self.get_config_info() except Exception as e: raise RuntimeError( - f"Authentication failed. Visit {self.HOME_URL} to get or review your authentication credentials." + f"Authentication failed. Visit {self.HOME_URL} to get or " + "review your authentication credentials." ) from e + def __del__(self): + self.session.close() + + @handle_too_many_requests_error_for_preview_client def get_about(self) -> dict: - r = requests.get( + response = self.session.get( f"{self.endpoint}/about", headers=self.auth_headers, ) - r.raise_for_status() - return r.json() + response.raise_for_status() + return response.json() - def get_config_info(self) -> dict: - r = requests.get( + @handle_too_many_requests_error_for_preview_client + def get_config_info(self) -> Config: + response = self.session.get( f"{self.endpoint}/config_info", headers=self.auth_headers, ) - r.raise_for_status() - return r.json() + response.raise_for_status() + return Config(**response.json()) - def get_language(self, language_id) -> dict: + @handle_too_many_requests_error_for_preview_client + def get_language(self, language_id: int) -> Language: request_url = f"{self.endpoint}/languages/{language_id}" - r = requests.get(request_url, headers=self.auth_headers) - r.raise_for_status() - return r.json() + response = self.session.get(request_url, headers=self.auth_headers) + response.raise_for_status() + return Language(**response.json()) - def get_languages(self) -> list[dict]: + @handle_too_many_requests_error_for_preview_client + def get_languages(self) -> list[Language]: request_url = f"{self.endpoint}/languages" - r = requests.get(request_url, headers=self.auth_headers) - r.raise_for_status() - return r.json() + response = self.session.get(request_url, headers=self.auth_headers) + response.raise_for_status() + return [Language(**lang_dict) for lang_dict in response.json()] + @handle_too_many_requests_error_for_preview_client def get_statuses(self) -> list[dict]: - r = requests.get( + response = self.session.get( f"{self.endpoint}/statuses", headers=self.auth_headers, ) - r.raise_for_status() - return r.json() + response.raise_for_status() + return response.json() @property def version(self): @@ -71,7 +86,7 @@ def version(self): return self._version def get_language_id(self, language: Union[LanguageAlias, int]) -> int: - """Get language id for the corresponding language alias for the client.""" + """Get language id corresponding to the language alias for the client.""" if isinstance(language, LanguageAlias): supported_language_ids = LANGUAGE_TO_LANGUAGE_ID[self.version] language = supported_language_ids.get(language, -1) @@ -82,7 +97,22 @@ def is_language_supported(self, language: Union[LanguageAlias, int]) -> bool: language_id = self.get_language_id(language) return any(language_id == lang.id for lang in self.languages) + @handle_too_many_requests_error_for_preview_client def create_submission(self, submission: Submission) -> Submission: + """Send submission for execution to a client. + + Directly send a submission to create_submission route for execution. + + Parameters + ---------- + submission : Submission + A submission to create. + + Returns + ------- + Submission + A submission with updated token attribute. + """ # Check if the client supports the language specified in the submission. if not self.is_language_supported(language=submission.language): raise RuntimeError( @@ -97,26 +127,40 @@ def create_submission(self, submission: Submission) -> Submission: body = submission.as_body(self) - resp = requests.post( + response = self.session.post( f"{self.endpoint}/submissions", json=body, params=params, headers=self.auth_headers, ) - resp.raise_for_status() + response.raise_for_status() - submission.set_attributes(resp.json()) + submission.set_attributes(response.json()) return submission + @handle_too_many_requests_error_for_preview_client def get_submission( self, submission: Submission, *, - fields: Union[str, Iterable[str], None] = None, + fields: Optional[Union[str, Iterable[str]]] = None, ) -> Submission: - """Check the submission status.""" + """Get submissions status. + + Directly send submission's token to get_submission route for status + check. By default, all submissions attributes (fields) are requested. + Parameters + ---------- + submission : Submission + Submission to update. + + Returns + ------- + Submission + A Submission with updated attributes. + """ params = { "base64_encoded": "true", } @@ -129,19 +173,34 @@ def get_submission( else: params["fields"] = "*" - resp = requests.get( + response = self.session.get( f"{self.endpoint}/submissions/{submission.token}", params=params, headers=self.auth_headers, ) - resp.raise_for_status() + response.raise_for_status() - submission.set_attributes(resp.json()) + submission.set_attributes(response.json()) return submission + @handle_too_many_requests_error_for_preview_client def create_submissions(self, submissions: Submissions) -> Submissions: - # Check if all submissions contain supported language. + """Send submissions for execution to a client. + + Directly send submissions to create_submissions route for execution. + Cannot handle more submissions than the client supports. + + Parameters + ---------- + submissions : Submissions + A sequence of submissions to create. + + Returns + ------- + Submissions + A sequence of submissions with updated token attribute. + """ for submission in submissions: if not self.is_language_supported(language=submission.language): raise RuntimeError( @@ -149,27 +208,47 @@ def create_submissions(self, submissions: Submissions) -> Submissions: f"{submission.language}!" ) + # TODO: Maybe raise an exception if the number of submissions is bigger + # than the batch size a client supports? + submissions_body = [submission.as_body(self) for submission in submissions] - resp = requests.post( + response = self.session.post( f"{self.endpoint}/submissions/batch", headers=self.auth_headers, params={"base64_encoded": "true"}, json={"submissions": submissions_body}, ) - resp.raise_for_status() + response.raise_for_status() - for submission, attrs in zip(submissions, resp.json()): + for submission, attrs in zip(submissions, response.json()): submission.set_attributes(attrs) return submissions + @handle_too_many_requests_error_for_preview_client def get_submissions( self, submissions: Submissions, *, - fields: Union[str, Iterable[str], None] = None, + fields: Optional[Union[str, Iterable[str]]] = None, ) -> Submissions: + """Get submissions status. + + Directly send submissions' tokens to get_submissions route for status + check. By default, all submissions attributes (fields) are requested. + Cannot handle more submissions than the client supports. + + Parameters + ---------- + submissions : Submissions + Submissions to update. + + Returns + ------- + Submissions + A sequence of submissions with updated attributes. + """ params = { "base64_encoded": "true", } @@ -182,26 +261,28 @@ def get_submissions( else: params["fields"] = "*" - tokens = ",".join(submission.token for submission in submissions) + tokens = ",".join([submission.token for submission in submissions]) params["tokens"] = tokens - resp = requests.get( + response = self.session.get( f"{self.endpoint}/submissions/batch", params=params, headers=self.auth_headers, ) - resp.raise_for_status() + response.raise_for_status() - for submission, attrs in zip(submissions, resp.json()["submissions"]): + for submission, attrs in zip(submissions, response.json()["submissions"]): submission.set_attributes(attrs) return submissions class ATD(Client): - API_KEY_ENV = "JUDGE0_ATD_API_KEY" + """Base class for all AllThingsDev clients.""" - def __init__(self, endpoint, host_header_value, api_key): + API_KEY_ENV: ClassVar[str] = "JUDGE0_ATD_API_KEY" + + def __init__(self, endpoint, host_header_value, api_key, **kwargs): self.api_key = api_key super().__init__( endpoint, @@ -209,6 +290,7 @@ def __init__(self, endpoint, host_header_value, api_key): "x-apihub-host": host_header_value, "x-apihub-key": api_key, }, + **kwargs, ) def _update_endpoint_header(self, header_value): @@ -216,42 +298,55 @@ def _update_endpoint_header(self, header_value): class ATDJudge0CE(ATD): - DEFAULT_ENDPOINT: str = "https://judge0-ce.proxy-production.allthingsdev.co" - DEFAULT_HOST: str = "Judge0-CE.allthingsdev.co" - HOME_URL: str = ( + """AllThingsDev client for CE flavor.""" + + DEFAULT_ENDPOINT: ClassVar[str] = ( + "https://judge0-ce.proxy-production.allthingsdev.co" + ) + DEFAULT_HOST: ClassVar[str] = "Judge0-CE.allthingsdev.co" + HOME_URL: ClassVar[str] = ( "https://www.allthingsdev.co/apimarketplace/judge0-ce/66b683c8b7b7ad054eb6ff8f" ) - DEFAULT_ABOUT_ENDPOINT: str = "01fc1c98-ceee-4f49-8614-f2214703e25f" - DEFAULT_CONFIG_INFO_ENDPOINT: str = "b7aab45d-5eb0-4519-b092-89e5af4fc4f3" - DEFAULT_LANGUAGE_ENDPOINT: str = "a50ae6b1-23c1-40eb-b34c-88bc8cf2c764" - DEFAULT_LANGUAGES_ENDPOINT: str = "03824deb-bd18-4456-8849-69d78e1383cc" - DEFAULT_STATUSES_ENDPOINT: str = "c37b603f-6f99-4e31-a361-7154c734f19b" - DEFAULT_CREATE_SUBMISSION_ENDPOINT: str = "6e65686d-40b0-4bf7-a12f-1f6d033c4473" - DEFAULT_GET_SUBMISSION_ENDPOINT: str = "b7032b8b-86da-40b4-b9d3-b1f5e2b4ee1e" - DEFAULT_CREATE_SUBMISSIONS_ENDPOINT: str = "402b857c-1126-4450-bfd8-22e1f2cbff2f" - DEFAULT_GET_SUBMISSIONS_ENDPOINT: str = "e42f2a26-5b02-472a-80c9-61c4bdae32ec" - - def __init__(self, api_key): + DEFAULT_ABOUT_ENDPOINT: ClassVar[str] = "01fc1c98-ceee-4f49-8614-f2214703e25f" + DEFAULT_CONFIG_INFO_ENDPOINT: ClassVar[str] = "b7aab45d-5eb0-4519-b092-89e5af4fc4f3" + DEFAULT_LANGUAGE_ENDPOINT: ClassVar[str] = "a50ae6b1-23c1-40eb-b34c-88bc8cf2c764" + DEFAULT_LANGUAGES_ENDPOINT: ClassVar[str] = "03824deb-bd18-4456-8849-69d78e1383cc" + DEFAULT_STATUSES_ENDPOINT: ClassVar[str] = "c37b603f-6f99-4e31-a361-7154c734f19b" + DEFAULT_CREATE_SUBMISSION_ENDPOINT: ClassVar[str] = ( + "6e65686d-40b0-4bf7-a12f-1f6d033c4473" + ) + DEFAULT_GET_SUBMISSION_ENDPOINT: ClassVar[str] = ( + "b7032b8b-86da-40b4-b9d3-b1f5e2b4ee1e" + ) + DEFAULT_CREATE_SUBMISSIONS_ENDPOINT: ClassVar[str] = ( + "402b857c-1126-4450-bfd8-22e1f2cbff2f" + ) + DEFAULT_GET_SUBMISSIONS_ENDPOINT: ClassVar[str] = ( + "e42f2a26-5b02-472a-80c9-61c4bdae32ec" + ) + + def __init__(self, api_key, **kwargs): super().__init__( self.DEFAULT_ENDPOINT, self.DEFAULT_HOST, api_key, + **kwargs, ) def get_about(self) -> dict: self._update_endpoint_header(self.DEFAULT_ABOUT_ENDPOINT) return super().get_about() - def get_config_info(self) -> dict: + def get_config_info(self) -> Config: self._update_endpoint_header(self.DEFAULT_CONFIG_INFO_ENDPOINT) return super().get_config_info() - def get_language(self, language_id) -> dict: + def get_language(self, language_id) -> Language: self._update_endpoint_header(self.DEFAULT_LANGUAGE_ENDPOINT) return super().get_language(language_id) - def get_languages(self) -> list[dict]: + def get_languages(self) -> list[Language]: self._update_endpoint_header(self.DEFAULT_LANGUAGES_ENDPOINT) return super().get_languages() @@ -267,7 +362,7 @@ def get_submission( self, submission: Submission, *, - fields: Union[str, Iterable[str], None] = None, + fields: Optional[Union[str, Iterable[str]]] = None, ) -> Submission: self._update_endpoint_header(self.DEFAULT_GET_SUBMISSION_ENDPOINT) return super().get_submission(submission, fields=fields) @@ -280,49 +375,63 @@ def get_submissions( self, submissions: Submissions, *, - fields: Union[str, Iterable[str], None] = None, + fields: Optional[Union[str, Iterable[str]]] = None, ) -> Submissions: self._update_endpoint_header(self.DEFAULT_GET_SUBMISSIONS_ENDPOINT) return super().get_submissions(submissions, fields=fields) class ATDJudge0ExtraCE(ATD): - DEFAULT_ENDPOINT: str = "https://judge0-extra-ce.proxy-production.allthingsdev.co" - DEFAULT_HOST: str = "Judge0-Extra-CE.allthingsdev.co" - HOME_URL: str = ( - "https://www.allthingsdev.co/apimarketplace/judge0-extra-ce/66b68838b7b7ad054eb70690" + """AllThingsDev client for Extra CE flavor.""" + + DEFAULT_ENDPOINT: ClassVar[str] = ( + "https://judge0-extra-ce.proxy-production.allthingsdev.co" + ) + DEFAULT_HOST: ClassVar[str] = "Judge0-Extra-CE.allthingsdev.co" + HOME_URL: ClassVar[str] = ( + "https://www.allthingsdev.co/apimarketplace/judge0-extra-ce/" + "66b68838b7b7ad054eb70690" + ) + + DEFAULT_ABOUT_ENDPOINT: ClassVar[str] = "1fd631a1-be6a-47d6-bf4c-987e357e3096" + DEFAULT_CONFIG_INFO_ENDPOINT: ClassVar[str] = "46e05354-2a43-436a-9458-5d111456f0ff" + DEFAULT_LANGUAGE_ENDPOINT: ClassVar[str] = "10465a84-2a2c-4213-845f-45e3c04a5867" + DEFAULT_LANGUAGES_ENDPOINT: ClassVar[str] = "774ecece-1200-41f7-a992-38f186c90803" + DEFAULT_STATUSES_ENDPOINT: ClassVar[str] = "a2843b3c-673d-4966-9a14-2e7d76dcd0cb" + DEFAULT_CREATE_SUBMISSION_ENDPOINT: ClassVar[str] = ( + "be2d195e-dd58-4770-9f3c-d6c0fbc2b6e5" + ) + DEFAULT_GET_SUBMISSION_ENDPOINT: ClassVar[str] = ( + "c3a457cd-37a6-4106-97a8-9e60a223abbc" + ) + DEFAULT_CREATE_SUBMISSIONS_ENDPOINT: ClassVar[str] = ( + "c64df5d3-edfd-4b08-8687-561af2f80d2f" + ) + DEFAULT_GET_SUBMISSIONS_ENDPOINT: ClassVar[str] = ( + "5d173718-8e6a-4cf5-9d8c-db5e6386d037" ) - DEFAULT_ABOUT_ENDPOINT: str = "1fd631a1-be6a-47d6-bf4c-987e357e3096" - DEFAULT_CONFIG_INFO_ENDPOINT: str = "46e05354-2a43-436a-9458-5d111456f0ff" - DEFAULT_LANGUAGE_ENDPOINT: str = "10465a84-2a2c-4213-845f-45e3c04a5867" - DEFAULT_LANGUAGES_ENDPOINT: str = "774ecece-1200-41f7-a992-38f186c90803" - DEFAULT_STATUSES_ENDPOINT: str = "a2843b3c-673d-4966-9a14-2e7d76dcd0cb" - DEFAULT_CREATE_SUBMISSION_ENDPOINT: str = "be2d195e-dd58-4770-9f3c-d6c0fbc2b6e5" - DEFAULT_GET_SUBMISSION_ENDPOINT: str = "c3a457cd-37a6-4106-97a8-9e60a223abbc" - DEFAULT_CREATE_SUBMISSIONS_ENDPOINT: str = "c64df5d3-edfd-4b08-8687-561af2f80d2f" - DEFAULT_GET_SUBMISSIONS_ENDPOINT: str = "5d173718-8e6a-4cf5-9d8c-db5e6386d037" - - def __init__(self, api_key): + def __init__(self, api_key, **kwargs): super().__init__( self.DEFAULT_ENDPOINT, self.DEFAULT_HOST, api_key, + **kwargs, ) def get_about(self) -> dict: self._update_endpoint_header(self.DEFAULT_ABOUT_ENDPOINT) return super().get_about() - def get_config_info(self) -> dict: + def get_config_info(self) -> Config: self._update_endpoint_header(self.DEFAULT_CONFIG_INFO_ENDPOINT) return super().get_config_info() - def get_language(self, language_id) -> dict: + def get_language(self, language_id) -> Language: self._update_endpoint_header(self.DEFAULT_LANGUAGE_ENDPOINT) return super().get_language(language_id) - def get_languages(self) -> list[dict]: + def get_languages(self) -> list[Language]: self._update_endpoint_header(self.DEFAULT_LANGUAGES_ENDPOINT) return super().get_languages() @@ -338,7 +447,7 @@ def get_submission( self, submission: Submission, *, - fields: Union[str, Iterable[str], None] = None, + fields: Optional[Union[str, Iterable[str]]] = None, ) -> Submission: self._update_endpoint_header(self.DEFAULT_GET_SUBMISSION_ENDPOINT) return super().get_submission(submission, fields=fields) @@ -351,16 +460,18 @@ def get_submissions( self, submissions: Submissions, *, - fields: Union[str, Iterable[str], None] = None, + fields: Optional[Union[str, Iterable[str]]] = None, ) -> Submissions: self._update_endpoint_header(self.DEFAULT_GET_SUBMISSIONS_ENDPOINT) return super().get_submissions(submissions, fields=fields) class Rapid(Client): - API_KEY_ENV = "JUDGE0_RAPID_API_KEY" + """Base class for all RapidAPI clients.""" - def __init__(self, endpoint, host_header_value, api_key): + API_KEY_ENV: ClassVar[str] = "JUDGE0_RAPID_API_KEY" + + def __init__(self, endpoint, host_header_value, api_key, **kwargs): self.api_key = api_key super().__init__( endpoint, @@ -368,61 +479,81 @@ def __init__(self, endpoint, host_header_value, api_key): "x-rapidapi-host": host_header_value, "x-rapidapi-key": api_key, }, + **kwargs, ) class RapidJudge0CE(Rapid): - DEFAULT_ENDPOINT: str = "https://judge0-ce.p.rapidapi.com" - DEFAULT_HOST: str = "judge0-ce.p.rapidapi.com" - HOME_URL: str = "https://rapidapi.com/judge0-official/api/judge0-ce" + """RapidAPI client for CE flavor.""" + + DEFAULT_ENDPOINT: ClassVar[str] = "https://judge0-ce.p.rapidapi.com" + DEFAULT_HOST: ClassVar[str] = "judge0-ce.p.rapidapi.com" + HOME_URL: ClassVar[str] = "https://rapidapi.com/judge0-official/api/judge0-ce" - def __init__(self, api_key): + def __init__(self, api_key, **kwargs): super().__init__( self.DEFAULT_ENDPOINT, self.DEFAULT_HOST, api_key, + **kwargs, ) class RapidJudge0ExtraCE(Rapid): - DEFAULT_ENDPOINT: str = "https://judge0-extra-ce.p.rapidapi.com" - DEFAULT_HOST: str = "judge0-extra-ce.p.rapidapi.com" - HOME_URL: str = "https://rapidapi.com/judge0-official/api/judge0-extra-ce" + """RapidAPI client for Extra CE flavor.""" - def __init__(self, api_key): + DEFAULT_ENDPOINT: ClassVar[str] = "https://judge0-extra-ce.p.rapidapi.com" + DEFAULT_HOST: ClassVar[str] = "judge0-extra-ce.p.rapidapi.com" + HOME_URL: ClassVar[str] = "https://rapidapi.com/judge0-official/api/judge0-extra-ce" + + def __init__(self, api_key, **kwargs): super().__init__( self.DEFAULT_ENDPOINT, self.DEFAULT_HOST, api_key, + **kwargs, ) class Sulu(Client): - API_KEY_ENV = "JUDGE0_SULU_API_KEY" + """Base class for all Sulu clients.""" + + API_KEY_ENV: ClassVar[str] = "JUDGE0_SULU_API_KEY" - def __init__(self, endpoint, api_key=None): + def __init__(self, endpoint, api_key=None, **kwargs): self.api_key = api_key super().__init__( endpoint, {"Authorization": f"Bearer {api_key}"} if api_key else None, + **kwargs, ) class SuluJudge0CE(Sulu): - DEFAULT_ENDPOINT: str = "https://judge0-ce.p.sulu.sh" - HOME_URL: str = "https://sparkhub.sulu.sh/apis/judge0/judge0-ce/readme" + """Sulu client for CE flavor.""" - def __init__(self, api_key=None): - super().__init__(self.DEFAULT_ENDPOINT, api_key) + DEFAULT_ENDPOINT: ClassVar[str] = "https://judge0-ce.p.sulu.sh" + HOME_URL: ClassVar[str] = "https://sparkhub.sulu.sh/apis/judge0/judge0-ce/readme" + + def __init__(self, api_key=None, **kwargs): + super().__init__( + self.DEFAULT_ENDPOINT, + api_key, + **kwargs, + ) class SuluJudge0ExtraCE(Sulu): - DEFAULT_ENDPOINT: str = "https://judge0-extra-ce.p.sulu.sh" - HOME_URL: str = "https://sparkhub.sulu.sh/apis/judge0/judge0-extra-ce/readme" + """Sulu client for Extra CE flavor.""" + + DEFAULT_ENDPOINT: ClassVar[str] = "https://judge0-extra-ce.p.sulu.sh" + HOME_URL: ClassVar[str] = ( + "https://sparkhub.sulu.sh/apis/judge0/judge0-extra-ce/readme" + ) - def __init__(self, api_key=None): - super().__init__(self.DEFAULT_ENDPOINT, api_key) + 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 = (RapidJudge0CE, SuluJudge0CE, ATDJudge0CE) +EXTRA_CE = (RapidJudge0ExtraCE, SuluJudge0ExtraCE, ATDJudge0ExtraCE) diff --git a/src/judge0/common.py b/src/judge0/common.py index 736895e..57ad838 100644 --- a/src/judge0/common.py +++ b/src/judge0/common.py @@ -2,7 +2,7 @@ from itertools import islice from typing import Union -from .base_types import Encodeable +from judge0.base_types import Encodeable def encode(content: Union[bytes, str, Encodeable]) -> str: @@ -17,16 +17,18 @@ def encode(content: Union[bytes, str, Encodeable]) -> str: def decode(content: Union[bytes, str]) -> str: if isinstance(content, bytes): - return b64decode(content.decode(errors="backslashreplace")).decode( + return b64decode( + content.decode(errors="backslashreplace"), validate=True + ).decode(errors="backslashreplace") + if isinstance(content, str): + return b64decode(content.encode(), validate=True).decode( errors="backslashreplace" ) - if isinstance(content, str): - return b64decode(content.encode()).decode(errors="backslashreplace") raise ValueError(f"Unsupported type. Expected bytes or str, got {type(content)}!") def batched(iterable, n): - """Utility function for batching submissions. + """Iterate over an iterable in batches of a specified size. Adapted from https://docs.python.org/3/library/itertools.html#itertools.batched. """ diff --git a/src/judge0/errors.py b/src/judge0/errors.py new file mode 100644 index 0000000..a1835a5 --- /dev/null +++ b/src/judge0/errors.py @@ -0,0 +1,9 @@ +"""Library specific errors.""" + + +class PreviewClientLimitError(RuntimeError): + """Limited usage of a preview client exceeded.""" + + +class ClientResolutionError(RuntimeError): + """Failed resolution of an unspecified client.""" diff --git a/src/judge0/filesystem.py b/src/judge0/filesystem.py index 590795c..6773680 100644 --- a/src/judge0/filesystem.py +++ b/src/judge0/filesystem.py @@ -3,21 +3,24 @@ import zipfile from base64 import b64decode, b64encode -from collections import abc -from typing import Iterable, Optional, Union +from typing import Optional, Union -from .base_types import Encodeable +from pydantic import BaseModel +from .base_types import Iterable -class File: - def __init__(self, name: str, content: Optional[Union[str, bytes]] = None): - self.name = name +class File(BaseModel): + name: str + content: Optional[Union[str, bytes]] = None + + def __init__(self, **data): + super().__init__(**data) # Let's keep content attribute internally encoded as bytes. - if isinstance(content, str): - self.content = content.encode() - elif isinstance(content, bytes): - self.content = content + if isinstance(self.content, str): + self.content = self.content.encode() + elif isinstance(self.content, bytes): + self.content = self.content else: self.content = b"" @@ -25,12 +28,13 @@ def __str__(self): return self.content.decode(errors="backslashreplace") -class Filesystem(Encodeable): - def __init__( - self, - content: Optional[Union[str, bytes, File, Iterable[File], "Filesystem"]] = None, - ): - self.files: list[File] = [] +class Filesystem(BaseModel): + files: list[File] = [] + + def __init__(self, **data): + content = data.pop("content", None) + super().__init__(**data) + self.files = [] if isinstance(content, (bytes, str)): if isinstance(content, bytes): @@ -41,13 +45,21 @@ def __init__( with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as zip_file: for file_name in zip_file.namelist(): with zip_file.open(file_name) as fp: - self.files.append(File(file_name, fp.read())) - elif isinstance(content, abc.Iterable): + self.files.append(File(name=file_name, content=fp.read())) + elif isinstance(content, Iterable): self.files = list(content) elif isinstance(content, File): self.files = [content] elif isinstance(content, Filesystem): self.files = copy.deepcopy(content.files) + elif content is None: + self.files = [] + else: + raise ValueError( + "Unsupported type for content argument. Expected " + "one of str, bytes, File, Iterable[File], or Filesystem, " + f"got {type(content)}." + ) def __repr__(self) -> str: content_encoded = b64encode(self.encode()).decode() @@ -61,6 +73,7 @@ def encode(self) -> bytes: return zip_buffer.getvalue() def __str__(self) -> str: + """Create string representation of Filesystem object.""" return b64encode(self.encode()).decode() def __iter__(self): diff --git a/src/judge0/retry.py b/src/judge0/retry.py index 33acc52..20b42ef 100644 --- a/src/judge0/retry.py +++ b/src/judge0/retry.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod -class RetryMechanism(ABC): +class RetryStrategy(ABC): @abstractmethod def is_done(self) -> bool: pass @@ -11,12 +11,11 @@ def is_done(self) -> bool: def wait(self) -> None: pass - @abstractmethod def step(self) -> None: pass -class MaxRetries(RetryMechanism): +class MaxRetries(RetryStrategy): """Check for submissions status every 100 ms and retry a maximum of `max_retries` times.""" @@ -34,7 +33,7 @@ def is_done(self) -> bool: return self.n_retries >= self.max_retries -class MaxWaitTime(RetryMechanism): +class MaxWaitTime(RetryStrategy): """Check for submissions status every 100 ms and wait for all submissions a maximum of `max_wait_time` (seconds).""" @@ -52,15 +51,12 @@ def is_done(self): return self.total_wait_time >= self.max_wait_time_sec -class RegularPeriodRetry(RetryMechanism): +class RegularPeriodRetry(RetryStrategy): """Check for submissions status periodically for indefinite amount of time.""" def __init__(self, wait_time_sec: float = 0.1): self.wait_time_sec = wait_time_sec - def step(self): - pass - def wait(self): time.sleep(self.wait_time_sec) diff --git a/src/judge0/submission.py b/src/judge0/submission.py index 2ffa178..b9d474c 100644 --- a/src/judge0/submission.py +++ b/src/judge0/submission.py @@ -1,11 +1,12 @@ import copy from datetime import datetime -from typing import Optional, Union +from typing import Any, Optional, Union -from judge0.filesystem import Filesystem +from pydantic import BaseModel, ConfigDict, Field, field_validator, UUID4 -from .base_types import LanguageAlias, Status +from .base_types import Iterable, LanguageAlias, Status from .common import decode, encode +from .filesystem import Filesystem ENCODED_REQUEST_FIELDS = { "source_code", @@ -48,7 +49,6 @@ "time", "wall_time", "memory", - "post_execution_filesystem", } REQUEST_FIELDS = ENCODED_REQUEST_FIELDS | EXTRA_REQUEST_FIELDS RESPONSE_FIELDS = ENCODED_RESPONSE_FIELDS | EXTRA_RESPONSE_FIELDS @@ -63,82 +63,160 @@ "wall_time_limit", } -Submissions = Union[list["Submission"], tuple["Submission"]] +Submissions = Iterable["Submission"] -class Submission: +class Submission(BaseModel): """ Stores a representation of a Submission to/from Judge0. + + Parameters + ---------- + source_code : str, optional + The source code to be executed. + language : LanguageAlias or int, optional + The programming language of the source code. Defaults to `LanguageAlias.PYTHON`. + additional_files : base64 encoded string, optional + Additional files that should be available alongside the source code. + Value of this string should represent the content of a .zip that + contains additional files. This attribute is required for multi-file + programs. + compiler_options : str, optional + Options for the compiler (i.e. compiler flags). + command_line_arguments : str, optional + Command line arguments for the program. + stdin : str, optional + Input to be fed via standard input during execution. + expected_output : str, optional + The expected output of the program. + cpu_time_limit : float, optional + Maximum CPU time allowed for execution, in seconds. Time in which the + OS assigns the processor to different tasks is not counted. Depends on + configuration. + cpu_extra_time : float, optional + Additional CPU time allowance in case of time extension. Depends on + configuration. + wall_time_limit : float, optional + Maximum wall clock time allowed for execution, in seconds. Depends on + configuration. + memory_limit : float, optional + Maximum memory allocation allowed for the process, in kilobytes. + Depends on configuration. + stack_limit : int, optional + Maximum stack size allowed, in kilobytes. Depends on configuration. + max_processes_and_or_threads : int, optional + Maximum number of processes and/or threads program can create. Depends + on configuration. + enable_per_process_and_thread_time_limit : bool, optional + If True, enforces time limits per process/thread. Depends on + configuration. + enable_per_process_and_thread_memory_limit : bool, optional + If True, enforces memory limits per process/thread. Depends on + configuration. + max_file_size : int, optional + Maximum file size allowed for output files, in kilobytes. Depends on + configuration. + redirect_stderr_to_stdout : bool, optional + If True, redirects standard error output to standard output. + enable_network : bool, optional + If True, enables network access during execution. + number_of_runs : int, optional + Number of times the code should be executed. + callback_url : str, optional + URL for a callback to report execution results or status. """ - def __init__( - self, - *, - source_code: Optional[str] = None, - language: Union[LanguageAlias, int] = LanguageAlias.PYTHON, - additional_files=None, - compiler_options=None, - command_line_arguments=None, - stdin=None, - expected_output=None, - cpu_time_limit=None, - cpu_extra_time=None, - wall_time_limit=None, - memory_limit=None, - stack_limit=None, - max_processes_and_or_threads=None, - enable_per_process_and_thread_time_limit=None, - enable_per_process_and_thread_memory_limit=None, - max_file_size=None, - redirect_stderr_to_stdout=None, - enable_network=None, - number_of_runs=None, - callback_url=None, - ): - self.source_code = source_code - self.language = language - self.additional_files = additional_files - - # Extra pre-execution submission attributes. - self.compiler_options = compiler_options - self.command_line_arguments = command_line_arguments - self.stdin = stdin - self.expected_output = expected_output - self.cpu_time_limit = cpu_time_limit - self.cpu_extra_time = cpu_extra_time - self.wall_time_limit = wall_time_limit - self.memory_limit = memory_limit - self.stack_limit = stack_limit - self.max_processes_and_or_threads = max_processes_and_or_threads - self.enable_per_process_and_thread_time_limit = ( - enable_per_process_and_thread_time_limit - ) - self.enable_per_process_and_thread_memory_limit = ( - enable_per_process_and_thread_memory_limit - ) - self.max_file_size = max_file_size - self.redirect_stderr_to_stdout = redirect_stderr_to_stdout - self.enable_network = enable_network - self.number_of_runs = number_of_runs - self.callback_url = callback_url - - # Post-execution submission attributes. - self.stdout = None - self.stderr = None - self.compile_output = None - self.message = None - self.exit_code = None - self.exit_signal = None - self.status = None - self.created_at = None - self.finished_at = None - self.token = "" - self.time = None - self.wall_time = None - self.memory = None - self.post_execution_filesystem = None - - def set_attributes(self, attributes): + source_code: Optional[str] = Field(default=None, repr=True) + language: Union[LanguageAlias, int] = Field( + default=LanguageAlias.PYTHON, + repr=True, + ) + additional_files: Optional[Union[str, Filesystem]] = Field(default=None, repr=True) + compiler_options: Optional[str] = Field(default=None, repr=True) + command_line_arguments: Optional[str] = Field(default=None, repr=True) + stdin: Optional[str] = Field(default=None, repr=True) + expected_output: Optional[str] = Field(default=None, repr=True) + cpu_time_limit: Optional[float] = Field(default=None, repr=True) + cpu_extra_time: Optional[float] = Field(default=None, repr=True) + wall_time_limit: Optional[float] = Field(default=None, repr=True) + memory_limit: Optional[float] = Field(default=None, repr=True) + stack_limit: Optional[int] = Field(default=None, repr=True) + max_processes_and_or_threads: Optional[int] = Field(default=None, repr=True) + enable_per_process_and_thread_time_limit: Optional[bool] = Field( + default=None, repr=True + ) + enable_per_process_and_thread_memory_limit: Optional[bool] = Field( + default=None, repr=True + ) + max_file_size: Optional[int] = Field(default=None, repr=True) + redirect_stderr_to_stdout: Optional[bool] = Field(default=None, repr=True) + enable_network: Optional[bool] = Field(default=None, repr=True) + number_of_runs: Optional[int] = Field(default=None, repr=True) + callback_url: Optional[str] = Field(default=None, repr=True) + + # Post-execution submission attributes. + stdout: Optional[str] = Field(default=None, repr=True) + stderr: Optional[str] = Field(default=None, repr=True) + compile_output: Optional[str] = Field(default=None, repr=True) + message: Optional[str] = Field(default=None, repr=True) + exit_code: Optional[int] = Field(default=None, repr=True) + exit_signal: Optional[int] = Field(default=None, repr=True) + status: Optional[Status] = Field(default=None, repr=True) + created_at: Optional[datetime] = Field(default=None, repr=True) + finished_at: Optional[datetime] = Field(default=None, repr=True) + token: Optional[UUID4] = Field(default=None, repr=True) + time: Optional[float] = Field(default=None, repr=True) + wall_time: Optional[float] = Field(default=None, repr=True) + memory: Optional[float] = Field(default=None, repr=True) + post_execution_filesystem: Optional[Filesystem] = Field(default=None, repr=True) + + model_config = ConfigDict(extra="ignore") + + @field_validator(*ENCODED_FIELDS, mode="before") + @classmethod + def process_encoded_fields(cls, value: str) -> Optional[str]: + """Validate all encoded attributes.""" + if value is None: + return None + else: + try: + return decode(value) + except Exception: + return value + + @field_validator("post_execution_filesystem", mode="before") + @classmethod + def process_post_execution_filesystem(cls, content: str) -> Filesystem: + """Validate post_execution_filesystem attribute.""" + return Filesystem(content=content) + + @field_validator("status", mode="before") + @classmethod + def process_status(cls, value: dict) -> Status: + """Validate status attribute.""" + return Status(value["id"]) + + @field_validator("language", mode="before") + @classmethod + def process_language( + cls, value: Union[LanguageAlias, dict] + ) -> Union[LanguageAlias, int]: + """Validate status attribute.""" + if isinstance(value, dict): + return value["id"] + else: + return value + + def set_attributes(self, attributes: dict[str, Any]) -> None: + """Set Submissions attributes while taking into account different + attribute's types. + + Parameters + ---------- + attributes : dict + Key-value pairs of Submission attributes and the corresponding + value. + """ for attr, value in attributes.items(): if attr in SKIP_FIELDS: continue @@ -152,11 +230,14 @@ def set_attributes(self, attributes): elif attr in FLOATING_POINT_FIELDS and value is not None: value = float(value) elif attr == "post_execution_filesystem": - value = Filesystem(value) + value = Filesystem(content=value) setattr(self, attr, value) def as_body(self, client: "Client") -> dict: + """Prepare Submission as a dictionary while taking into account + the `client`'s restrictions. + """ body = { "source_code": encode(self.source_code), "language_id": client.get_language_id(self.language), @@ -175,21 +256,24 @@ def as_body(self, client: "Client") -> dict: return body def is_done(self) -> bool: + """Check if submission is finished processing. + + Submission is considered finished if the submission status is not + IN_QUEUE and not PROCESSING. + """ if self.status is None: return False else: return self.status not in (Status.IN_QUEUE, Status.PROCESSING) def pre_execution_copy(self) -> "Submission": + """Create a deep copy of a submission.""" new_submission = Submission() for attr in REQUEST_FIELDS: setattr(new_submission, attr, copy.deepcopy(getattr(self, attr))) + new_submission.language = self.language return new_submission - def __repr__(self) -> str: - arguments = ", ".join(f"{field}={getattr(self, field)!r}" for field in FIELDS) - return f"{self.__class__.__name__}({arguments})" - def __iter__(self): if self.post_execution_filesystem is None: return iter([]) diff --git a/src/judge0/utils.py b/src/judge0/utils.py new file mode 100644 index 0000000..e38b41f --- /dev/null +++ b/src/judge0/utils.py @@ -0,0 +1,48 @@ +"""Module containing different utility functions for Judge0 Python SDK.""" + +from functools import wraps +from http import HTTPStatus + +from requests import HTTPError + +from .errors import PreviewClientLimitError + + +def is_http_too_many_requests_error(exception: Exception) -> bool: + return ( + isinstance(exception, HTTPError) + and exception.response is not None + and exception.response.status_code == HTTPStatus.TOO_MANY_REQUESTS + ) + + +def handle_too_many_requests_error_for_preview_client(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except HTTPError as err: + if is_http_too_many_requests_error(exception=err): + # If the raised exception is inside the one of the Sulu clients + # let's check if we are dealing with the implicit client. + if args: + instance = args[0] + class_name = instance.__class__.__name__ + # Check if we are using a preview version of the client. + if ( + class_name in ("SuluJudge0CE", "SuluJudge0ExtraCE") + and instance.api_key is None + ): + raise PreviewClientLimitError( + "You are using a preview version of a client and " + f"you've hit a rate limit on it. Visit {instance.HOME_URL} " + "to get your authentication credentials." + ) from err + else: + raise err from None + else: + raise err from None + except Exception as err: + raise err from None + + return wrapper diff --git a/tests/test_api_test_cases.py b/tests/test_api_test_cases.py index f395279..0d08f5f 100644 --- a/tests/test_api_test_cases.py +++ b/tests/test_api_test_cases.py @@ -1,4 +1,4 @@ -"""Separate file containg tests related to test case functionality.""" +"""Separate file containing tests related to test case functionality.""" import judge0 import pytest @@ -6,6 +6,51 @@ from judge0.api import create_submissions_from_test_cases +@pytest.mark.parametrize( + "test_case,expected_output", + [ + [ + TestCase(input="input_1", expected_output="output_1"), + TestCase(input="input_1", expected_output="output_1"), + ], + [ + tuple([]), + TestCase(input=None, expected_output=None), + ], + [ + ("input_tuple",), + TestCase(input="input_tuple", expected_output=None), + ], + [ + ("input_tuple", "output_tuple"), + TestCase(input="input_tuple", expected_output="output_tuple"), + ], + [ + [], + TestCase(input=None, expected_output=None), + ], + [ + ["input_list"], + TestCase(input="input_list", expected_output=None), + ], + [ + ["input_list", "output_list"], + TestCase(input="input_list", expected_output="output_list"), + ], + [ + {"input": "input_dict", "expected_output": "output_dict"}, + TestCase(input="input_dict", expected_output="output_dict"), + ], + [ + None, + None, + ], + ], +) +def test_test_case_from_record(test_case, expected_output): + assert TestCase.from_record(test_case) == expected_output + + @pytest.mark.parametrize( "submissions,test_cases,expected_type", [ @@ -19,7 +64,95 @@ def test_create_submissions_from_test_cases_return_type( submissions, test_cases, expected_type ): output = create_submissions_from_test_cases(submissions, test_cases) - assert type(output) == expected_type + assert type(output) is expected_type + + +class TestCreateSubmissionsFromTestCases: + @pytest.mark.parametrize( + "test_case,stdin,expected_output", + [ + [TestCase(), None, None], + [[], None, None], + [{}, None, None], + [tuple([]), None, None], + ], + ) + def test_empty_test_case(self, test_case, stdin, expected_output): + submission = create_submissions_from_test_cases( + Submission(), test_cases=test_case + ) + + assert ( + submission.stdin == stdin and submission.expected_output == expected_output + ) + + @pytest.mark.parametrize( + "test_case,stdin,expected_output", + [ + [TestCase(), None, None], + [TestCase(input="input"), "input", None], + [TestCase(expected_output="output"), None, "output"], + [["input_list"], "input_list", None], + [["input_list", "output_list"], "input_list", "output_list"], + [{"input": "input_dict"}, "input_dict", None], + [ + {"input": "input_dict", "expected_output": "output_dict"}, + "input_dict", + "output_dict", + ], + [("input_tuple",), "input_tuple", None], + [("input_tuple", "output_tuple"), "input_tuple", "output_tuple"], + ], + ) + def test_single_test_case(self, test_case, stdin, expected_output): + submission = create_submissions_from_test_cases( + Submission(), test_cases=test_case + ) + + assert ( + submission.stdin == stdin and submission.expected_output == expected_output + ) + + @pytest.mark.parametrize( + "test_cases,stdin,expected_output", + [ + [[TestCase()], None, None], + [[TestCase(input="input")], "input", None], + [[TestCase(expected_output="output")], None, "output"], + [(["input_list"],), "input_list", None], + [(["input_list", "output_list"],), "input_list", "output_list"], + [({"input": "input_dict"},), "input_dict", None], + [ + ({"input": "input_dict", "expected_output": "output_dict"},), + "input_dict", + "output_dict", + ], + [ + [ + ("input_tuple",), + ], + "input_tuple", + None, + ], + [ + [ + ("input_tuple", "output_tuple"), + ], + "input_tuple", + "output_tuple", + ], + ], + ) + def test_single_test_case_in_iterable(self, test_cases, stdin, expected_output): + submissions = create_submissions_from_test_cases( + Submission(), test_cases=test_cases + ) + + for submission in submissions: + assert ( + submission.stdin == stdin + and submission.expected_output == expected_output + ) @pytest.mark.parametrize( diff --git a/tests/test_submission.py b/tests/test_submission.py index c204bcb..fb1bf73 100644 --- a/tests/test_submission.py +++ b/tests/test_submission.py @@ -1,4 +1,52 @@ -from judge0 import Status, Submission, wait +from judge0 import run, Status, Submission, wait +from judge0.base_types import LanguageAlias + + +def test_from_json(): + submission_dict = { + "source_code": "cHJpbnQoJ0hlbGxvLCBXb3JsZCEnKQ==", + "language_id": 100, + "stdin": None, + "expected_output": None, + "stdout": "SGVsbG8sIFdvcmxkIQo=", + "status_id": 3, + "created_at": "2024-12-09T17:22:55.662Z", + "finished_at": "2024-12-09T17:22:56.045Z", + "time": "0.152", + "memory": 13740, + "stderr": None, + "token": "5513d8ca-975b-4499-b54b-342f1952d00e", + "number_of_runs": 1, + "cpu_time_limit": "5.0", + "cpu_extra_time": "1.0", + "wall_time_limit": "10.0", + "memory_limit": 128000, + "stack_limit": 64000, + "max_processes_and_or_threads": 60, + "enable_per_process_and_thread_time_limit": False, + "enable_per_process_and_thread_memory_limit": False, + "max_file_size": 1024, + "compile_output": None, + "exit_code": 0, + "exit_signal": None, + "message": None, + "wall_time": "0.17", + "compiler_options": None, + "command_line_arguments": None, + "redirect_stderr_to_stdout": False, + "callback_url": None, + "additional_files": None, + "enable_network": False, + "post_execution_filesystem": "UEsDBBQACAAIANyKiVkAAAAAAAAAABYAAAAJABwAc" + "2NyaXB0LnB5VVQJAANvJ1dncCdXZ3V4CwABBOgDAAAE6AMAACsoyswr0VD3SM3JyddRCM8v" + "yklRVNcEAFBLBwgynNLKGAAAABYAAABQSwECHgMUAAgACADciolZMpzSyhgAAAAWAAAACQA" + "YAAAAAAABAAAApIEAAAAAc2NyaXB0LnB5VVQFAANvJ1dndXgLAAEE6AMAAAToAwAAUEsFBg" + "AAAAABAAEATwAAAGsAAAAAAA==", + "status": {"id": 3, "description": "Accepted"}, + "language": {"id": 100, "name": "Python (3.12.5)"}, + } + + _ = Submission(**submission_dict) def test_status_before_and_after_submission(request): @@ -24,3 +72,23 @@ def test_is_done(request): wait(client=client, submissions=submission) assert submission.is_done() + + +def test_language_before_and_after_execution(request): + client = request.getfixturevalue("judge0_ce_client") + code = """\ + public class Main { + public static void main(String[] args) { + System.out.println("Hello World"); + } + } + """ + + submission = Submission( + source_code=code, + language=LanguageAlias.JAVA, + ) + + assert submission.language == LanguageAlias.JAVA + submission = run(client=client, submissions=submission) + assert submission.language == LanguageAlias.JAVA