From 6f447d21e5e5387b9f0625866220ae9a1c05026d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Jela=C4=8Di=C4=87?= <320345096+ijelacicbot@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:03:08 +0000 Subject: [PATCH] feat: add sync and async client APIs Expose AsyncClient and flavor-specific async clients with the same Judge0 operations as the existing sync Client. Allow injecting HTTPX clients for tests, proxies, and custom transports. Closes #50 --- CHANGELOG.md | 2 + docs/source/api/clients.rst | 38 ++ src/judge0/__init__.py | 20 + src/judge0/clients.py | 809 +++++++++++++++++++++++++++++++++++- src/judge0/submission.py | 10 +- src/judge0/utils.py | 56 ++- tests/test_async_clients.py | 264 ++++++++++++ 7 files changed, 1158 insertions(+), 41 deletions(-) create mode 100644 tests/test_async_clients.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ff4e52f8..f9e78d13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Bump the uv version used in GitHub Actions from 0.9.8 to 0.11.33 so that the CI can parse the relative `exclude-newer` value in `pyproject.toml`. +- Add paired synchronous and asynchronous client APIs, including flavor-specific + async clients and optional HTTPX client injection. - Fix Sphinx autodoc imports for Pydantic-backed submission types. - Add complete static typing across the SDK and tests, with typed single and batch submission return values. diff --git a/docs/source/api/clients.rst b/docs/source/api/clients.rst index 29e7e45a..8f00a7b4 100644 --- a/docs/source/api/clients.rst +++ b/docs/source/api/clients.rst @@ -35,3 +35,41 @@ Clients Module .. autoclass:: judge0.clients.RapidJudge0ExtraCE :show-inheritance: + +.. autoclass:: judge0.clients.AsyncClient + :exclude-members: API_KEY_ENV + +.. autoclass:: judge0.clients.AsyncATD + :show-inheritance: + +.. autoclass:: judge0.clients.AsyncATDJudge0CE + :show-inheritance: + :exclude-members: DEFAULT_ENDPOINT, DEFAULT_HOST, HOME_URL, DEFAULT_ABOUT_ENDPOINT, + DEFAULT_CONFIG_INFO_ENDPOINT, DEFAULT_LANGUAGE_ENDPOINT, DEFAULT_LANGUAGES_ENDPOINT, + DEFAULT_STATUSES_ENDPOINT, DEFAULT_CREATE_SUBMISSION_ENDPOINT, DEFAULT_GET_SUBMISSION_ENDPOINT, + DEFAULT_CREATE_SUBMISSIONS_ENDPOINT, DEFAULT_GET_SUBMISSIONS_ENDPOINT + +.. autoclass:: judge0.clients.AsyncATDJudge0ExtraCE + :show-inheritance: + :exclude-members: DEFAULT_ENDPOINT, DEFAULT_HOST, HOME_URL, DEFAULT_ABOUT_ENDPOINT, + DEFAULT_CONFIG_INFO_ENDPOINT, DEFAULT_LANGUAGE_ENDPOINT, DEFAULT_LANGUAGES_ENDPOINT, + DEFAULT_STATUSES_ENDPOINT, DEFAULT_CREATE_SUBMISSION_ENDPOINT, DEFAULT_GET_SUBMISSION_ENDPOINT, + DEFAULT_CREATE_SUBMISSIONS_ENDPOINT, DEFAULT_GET_SUBMISSIONS_ENDPOINT + +.. autoclass:: judge0.clients.AsyncRapid + :show-inheritance: + +.. autoclass:: judge0.clients.AsyncRapidJudge0CE + :show-inheritance: + +.. autoclass:: judge0.clients.AsyncRapidJudge0ExtraCE + :show-inheritance: + +.. autoclass:: judge0.clients.AsyncJudge0Cloud + :show-inheritance: + +.. autoclass:: judge0.clients.AsyncJudge0CloudCE + :show-inheritance: + +.. autoclass:: judge0.clients.AsyncJudge0CloudExtraCE + :show-inheritance: diff --git a/src/judge0/__init__.py b/src/judge0/__init__.py index d20fedb8..9af28c09 100644 --- a/src/judge0/__init__.py +++ b/src/judge0/__init__.py @@ -17,6 +17,16 @@ ATD, ATDJudge0CE, ATDJudge0ExtraCE, + AsyncATD, + AsyncATDJudge0CE, + AsyncATDJudge0ExtraCE, + AsyncClient, + AsyncJudge0Cloud, + AsyncJudge0CloudCE, + AsyncJudge0CloudExtraCE, + AsyncRapid, + AsyncRapidJudge0CE, + AsyncRapidJudge0ExtraCE, Client, Judge0Cloud, Judge0CloudCE, @@ -36,6 +46,16 @@ "ATD", "ATDJudge0CE", "ATDJudge0ExtraCE", + "AsyncATD", + "AsyncATDJudge0CE", + "AsyncATDJudge0ExtraCE", + "AsyncClient", + "AsyncJudge0Cloud", + "AsyncJudge0CloudCE", + "AsyncJudge0CloudExtraCE", + "AsyncRapid", + "AsyncRapidJudge0CE", + "AsyncRapidJudge0ExtraCE", "Client", "File", "Filesystem", diff --git a/src/judge0/clients.py b/src/judge0/clients.py index 762fd7c4..49e6c7be 100644 --- a/src/judge0/clients.py +++ b/src/judge0/clients.py @@ -26,6 +26,10 @@ class Client: Client's default endpoint. headers : dict Request authentication headers. + retry_strategy : RetryStrategy, optional + Polling strategy used by high-level helpers. + http_client : httpx.Client, optional + Injected HTTPX client. The SDK does not close an injected client. Attributes ---------- @@ -44,6 +48,7 @@ def __init__( headers: Headers | None = None, *, retry_strategy: RetryStrategy | None = None, + http_client: httpx.Client | None = None, ) -> None: self.endpoint: str = endpoint self.headers: Headers = headers if headers is not None else {} @@ -54,7 +59,8 @@ def __init__( } ) self.retry_strategy = retry_strategy - self.client = httpx.Client(base_url=self.endpoint) + self._owns_http_client = http_client is None + self.client = http_client or httpx.Client(base_url=self.endpoint) self._version: str | None = None try: @@ -67,8 +73,22 @@ def __init__( "review your authentication credentials." ) from e + def close(self) -> None: + """Close the underlying HTTP client if this instance owns it.""" + if self._owns_http_client: + self.client.close() + + def __enter__(self) -> "Client": + return self + + def __exit__(self, *args: object) -> None: + self.close() + def __del__(self) -> None: - self.client.close() + try: + self.close() + except Exception: + return None @handle_too_many_requests_error_for_preview_client def get_about(self) -> JsonObject: @@ -87,7 +107,417 @@ def get_about(self) -> JsonObject: return cast(JsonObject, response.json()) @handle_too_many_requests_error_for_preview_client - def get_config_info(self) -> Config: + def get_config_info(self) -> Config: + """Get information about client's configuration. + + Returns + ------- + Config + Client's configuration. + """ + response = self.client.get( + "/config_info", + headers=self.headers, + ) + response.raise_for_status() + return Config.model_validate(response.json()) + + @handle_too_many_requests_error_for_preview_client + def get_language(self, language_id: int) -> Language: + """Get language corresponding to the id. + + Parameters + ---------- + language_id : int + Language id. + + Returns + ------- + Language + Language corresponding to the passed id. + """ + request_url = f"/languages/{language_id}" + response = self.client.get(request_url, headers=self.headers) + response.raise_for_status() + return Language.model_validate(response.json()) + + @handle_too_many_requests_error_for_preview_client + def get_languages(self) -> list[Language]: + """Get a list of supported languages. + + Returns + ------- + list of language + A list of supported languages. + """ + response = self.client.get("/languages", headers=self.headers) + response.raise_for_status() + languages = cast(list[JsonObject], response.json()) + return [Language.model_validate(language) for language in languages] + + @handle_too_many_requests_error_for_preview_client + def get_statuses(self) -> list[JsonObject]: + """Get a list of possible submission statuses. + + Returns + ------- + list of dict + A list of possible submission statues. + """ + response = self.client.get( + "/statuses", + headers=self.headers, + ) + response.raise_for_status() + return cast(list[JsonObject], response.json()) + + @property + def version(self) -> str: + """Property corresponding to the current client's version.""" + if self._version is None: + self._version = cast(str, self.get_about()["version"]) + return self._version + + def get_language_id(self, language: LanguageAlias | int) -> int: + """Get language id corresponding to the language alias for the client. + + Parameters + ---------- + language : LanguageAlias or int + Language alias or language id. + + Returns + ------- + Language id corresponding to the language alias. + """ + if isinstance(language, LanguageAlias): + supported_language_ids = LANGUAGE_TO_LANGUAGE_ID[self.version] + language = supported_language_ids.get(language, -1) + return language + + def is_language_supported(self, language: LanguageAlias | int) -> bool: + """Check if language is supported by the client. + + Parameters + ---------- + language : LanguageAlias or int + Language alias or language id. + + Returns + ------- + bool + Return True if language is supported by the client, otherwise returns + False. + """ + 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( + f"Client {type(self).__name__} does not support language with " + f"id {submission.language}!" + ) + + params = { + "base64_encoded": "true", + "wait": "false", + } + + body = submission.as_body(self) + + response = self.client.post( + "/submissions", + json=body, + params=params, + headers=self.headers, + ) + response.raise_for_status() + + submission.set_attributes(response.json()) + + return submission + + @handle_too_many_requests_error_for_preview_client + def get_submission( + self, + submission: Submission, + *, + fields: str | Iterable[str] | None = None, + ) -> Submission: + """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", + } + + if isinstance(fields, str): + fields = [fields] + + if fields is not None: + params["fields"] = ",".join(fields) + else: + params["fields"] = "*" + + response = self.client.get( + f"/submissions/{submission.token}", + params=params, + headers=self.headers, + ) + response.raise_for_status() + + submission.set_attributes(response.json()) + + return submission + + @handle_too_many_requests_error_for_preview_client + def create_submissions(self, submissions: Submissions) -> Submissions: + """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( + f"Client {type(self).__name__} does not support language " + f"{submission.language}!" + ) + + submissions_body = [submission.as_body(self) for submission in submissions] + + response = self.client.post( + "/submissions/batch", + headers=self.headers, + params={"base64_encoded": "true"}, + json={"submissions": submissions_body}, + ) + response.raise_for_status() + + attributes = cast(list[dict[str, Any]], response.json()) + for submission, attrs in zip(submissions, attributes): + submission.set_attributes(attrs) + + return submissions + + @handle_too_many_requests_error_for_preview_client + def get_submissions( + self, + submissions: Submissions, + *, + fields: str | Iterable[str] | None = 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. + + Raises + ------ + ValueError + If any submission does not have a token. + """ + params = { + "base64_encoded": "true", + } + + if isinstance(fields, str): + fields = [fields] + + if fields is not None: + params["fields"] = ",".join(fields) + else: + params["fields"] = "*" + + tokens: list[str] = [] + for submission in submissions: + if submission.token is None: + raise ValueError("Every submission must have a token before retrieval.") + tokens.append(str(submission.token)) + params["tokens"] = ",".join(tokens) + + response = self.client.get( + "/submissions/batch", + params=params, + headers=self.headers, + ) + response.raise_for_status() + + response_body = cast(dict[str, list[dict[str, Any]]], response.json()) + for submission, attrs in zip(submissions, response_body["submissions"]): + submission.set_attributes(attrs) + + return submissions + + def _prepare_request(self, operation: str) -> None: + """Hook for flavor-specific request headers. + + Parameters + ---------- + operation : str + Logical operation name, such as ``about`` or ``create_submission``. + """ + return None + + +class AsyncClient: + """Asynchronous base class for Judge0 clients. + + Parameters + ---------- + endpoint : str + Client's default endpoint. + headers : dict + Request authentication headers. + retry_strategy : RetryStrategy, optional + Polling strategy used by high-level helpers. + http_client : httpx.AsyncClient, optional + Injected HTTPX async client. The SDK does not close an injected client. + + Attributes + ---------- + API_KEY_ENV : str + Environment variable where judge0-python should look for API key for + the client. Set to default values for RapidAPI and ATD clients. + """ + + API_KEY_ENV: ClassVar[str | None] = None + + def __init__( + self, + endpoint: str, + headers: Headers | None = None, + *, + retry_strategy: RetryStrategy | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + self.endpoint: str = endpoint + self.headers: Headers = headers if headers is not None else {} + self.headers.update( + { + "X-Judge0-App": "Judge0 Python SDK", + "X-Judge0-App-Version": __version__, + } + ) + self.retry_strategy = retry_strategy + self._owns_http_client = http_client is None + self.client = http_client or httpx.AsyncClient(base_url=self.endpoint) + self._version: str | None = None + self._ready = False + self.languages: list[Language] = [] + self.config: Config | None = None + + async def aclose(self) -> None: + """Close the underlying HTTP client if this instance owns it.""" + if self._owns_http_client: + await self.client.aclose() + + async def __aenter__(self) -> "AsyncClient": + await self._ensure_ready() + return self + + async def __aexit__(self, *args: object) -> None: + await self.aclose() + + def _prepare_request(self, operation: str) -> None: + """Hook for flavor-specific request headers. + + Parameters + ---------- + operation : str + Logical operation name, such as ``about`` or ``create_submission``. + """ + return None + + async def _ensure_ready(self) -> None: + if self._ready: + return + try: + self.languages = await self.get_languages() + self.config = await self.get_config_info() + about = await self._request_about() + self._version = cast(str, about["version"]) + self._ready = True + except Exception as e: + home_url = getattr(self, "HOME_URL", None) + raise RuntimeError( + f"Authentication failed. Visit {home_url} to get or " + "review your authentication credentials." + ) from e + + async def _request_about(self) -> JsonObject: + self._prepare_request("about") + response = await self.client.get( + "/about", + headers=self.headers, + ) + response.raise_for_status() + return cast(JsonObject, response.json()) + + @handle_too_many_requests_error_for_preview_client + async def get_about(self) -> JsonObject: + """Get general information about judge0. + + Returns + ------- + dict + General information about judge0. + """ + await self._ensure_ready() + return await self._request_about() + + @handle_too_many_requests_error_for_preview_client + async def get_config_info(self) -> Config: """Get information about client's configuration. Returns @@ -95,7 +525,8 @@ def get_config_info(self) -> Config: Config Client's configuration. """ - response = self.client.get( + self._prepare_request("config_info") + response = await self.client.get( "/config_info", headers=self.headers, ) @@ -103,7 +534,7 @@ def get_config_info(self) -> Config: return Config.model_validate(response.json()) @handle_too_many_requests_error_for_preview_client - def get_language(self, language_id: int) -> Language: + async def get_language(self, language_id: int) -> Language: """Get language corresponding to the id. Parameters @@ -116,13 +547,15 @@ def get_language(self, language_id: int) -> Language: Language Language corresponding to the passed id. """ + await self._ensure_ready() + self._prepare_request("language") request_url = f"/languages/{language_id}" - response = self.client.get(request_url, headers=self.headers) + response = await self.client.get(request_url, headers=self.headers) response.raise_for_status() return Language.model_validate(response.json()) @handle_too_many_requests_error_for_preview_client - def get_languages(self) -> list[Language]: + async def get_languages(self) -> list[Language]: """Get a list of supported languages. Returns @@ -130,13 +563,14 @@ def get_languages(self) -> list[Language]: list of language A list of supported languages. """ - response = self.client.get("/languages", headers=self.headers) + self._prepare_request("languages") + response = await self.client.get("/languages", headers=self.headers) response.raise_for_status() languages = cast(list[JsonObject], response.json()) return [Language.model_validate(language) for language in languages] @handle_too_many_requests_error_for_preview_client - def get_statuses(self) -> list[JsonObject]: + async def get_statuses(self) -> list[JsonObject]: """Get a list of possible submission statuses. Returns @@ -144,7 +578,9 @@ def get_statuses(self) -> list[JsonObject]: list of dict A list of possible submission statues. """ - response = self.client.get( + await self._ensure_ready() + self._prepare_request("statuses") + response = await self.client.get( "/statuses", headers=self.headers, ) @@ -155,7 +591,9 @@ def get_statuses(self) -> list[JsonObject]: def version(self) -> str: """Property corresponding to the current client's version.""" if self._version is None: - self._version = cast(str, self.get_about()["version"]) + raise RuntimeError( + "Async client version is available after the first awaited request." + ) return self._version def get_language_id(self, language: LanguageAlias | int) -> int: @@ -193,7 +631,7 @@ def is_language_supported(self, language: LanguageAlias | int) -> bool: 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: + async def create_submission(self, submission: Submission) -> Submission: """Send submission for execution to a client. Directly send a submission to create_submission route for execution. @@ -207,8 +645,13 @@ def create_submission(self, submission: Submission) -> Submission: ------- Submission A submission with updated token attribute. + + Raises + ------ + RuntimeError + If the client does not support the submission language. """ - # Check if the client supports the language specified in the submission. + await self._ensure_ready() if not self.is_language_supported(language=submission.language): raise RuntimeError( f"Client {type(self).__name__} does not support language with " @@ -222,7 +665,8 @@ def create_submission(self, submission: Submission) -> Submission: body = submission.as_body(self) - response = self.client.post( + self._prepare_request("create_submission") + response = await self.client.post( "/submissions", json=body, params=params, @@ -235,7 +679,7 @@ def create_submission(self, submission: Submission) -> Submission: return submission @handle_too_many_requests_error_for_preview_client - def get_submission( + async def get_submission( self, submission: Submission, *, @@ -256,6 +700,7 @@ def get_submission( Submission A Submission with updated attributes. """ + await self._ensure_ready() params = { "base64_encoded": "true", } @@ -268,7 +713,8 @@ def get_submission( else: params["fields"] = "*" - response = self.client.get( + self._prepare_request("get_submission") + response = await self.client.get( f"/submissions/{submission.token}", params=params, headers=self.headers, @@ -280,7 +726,7 @@ def get_submission( return submission @handle_too_many_requests_error_for_preview_client - def create_submissions(self, submissions: Submissions) -> Submissions: + async def create_submissions(self, submissions: Submissions) -> Submissions: """Send submissions for execution to a client. Directly send submissions to create_submissions route for execution. @@ -295,7 +741,13 @@ def create_submissions(self, submissions: Submissions) -> Submissions: ------- Submissions A sequence of submissions with updated token attribute. + + Raises + ------ + RuntimeError + If the client does not support a submission language. """ + await self._ensure_ready() for submission in submissions: if not self.is_language_supported(language=submission.language): raise RuntimeError( @@ -305,7 +757,8 @@ def create_submissions(self, submissions: Submissions) -> Submissions: submissions_body = [submission.as_body(self) for submission in submissions] - response = self.client.post( + self._prepare_request("create_submissions") + response = await self.client.post( "/submissions/batch", headers=self.headers, params={"base64_encoded": "true"}, @@ -320,7 +773,7 @@ def create_submissions(self, submissions: Submissions) -> Submissions: return submissions @handle_too_many_requests_error_for_preview_client - def get_submissions( + async def get_submissions( self, submissions: Submissions, *, @@ -347,6 +800,7 @@ def get_submissions( ValueError If any submission does not have a token. """ + await self._ensure_ready() params = { "base64_encoded": "true", } @@ -366,7 +820,8 @@ def get_submissions( tokens.append(str(submission.token)) params["tokens"] = ",".join(tokens) - response = self.client.get( + self._prepare_request("get_submissions") + response = await self.client.get( "/submissions/batch", params=params, headers=self.headers, @@ -766,3 +1221,317 @@ def __init__(self, headers: str | Headers | None = None, **kwargs: Any) -> None: CE = (Judge0CloudCE, RapidJudge0CE, ATDJudge0CE) EXTRA_CE = (Judge0CloudExtraCE, RapidJudge0ExtraCE, ATDJudge0ExtraCE) + +_ATD_OPERATION_ATTRS: dict[str, str] = { + "about": "DEFAULT_ABOUT_ENDPOINT", + "config_info": "DEFAULT_CONFIG_INFO_ENDPOINT", + "language": "DEFAULT_LANGUAGE_ENDPOINT", + "languages": "DEFAULT_LANGUAGES_ENDPOINT", + "statuses": "DEFAULT_STATUSES_ENDPOINT", + "create_submission": "DEFAULT_CREATE_SUBMISSION_ENDPOINT", + "get_submission": "DEFAULT_GET_SUBMISSION_ENDPOINT", + "create_submissions": "DEFAULT_CREATE_SUBMISSIONS_ENDPOINT", + "get_submissions": "DEFAULT_GET_SUBMISSIONS_ENDPOINT", +} + + +class AsyncATD(AsyncClient): + """Asynchronous 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 AsyncClient. + """ + + API_KEY_ENV: ClassVar[str | None] = "JUDGE0_ATD_API_KEY" + + def __init__( + self, + endpoint: str, + host_header_value: str, + api_key: str, + **kwargs: Any, + ) -> None: + self.api_key = api_key + super().__init__( + endpoint, + { + "x-apihub-host": host_header_value, + "x-apihub-key": api_key, + }, + **kwargs, + ) + + def _update_endpoint_header(self, header_value: str) -> None: + self.headers["x-apihub-endpoint"] = header_value + + def _prepare_request(self, operation: str) -> None: + self._update_endpoint_header(getattr(self, _ATD_OPERATION_ATTRS[operation])) + + +class AsyncATDJudge0CE(AsyncATD): + """Asynchronous AllThingsDev client for CE flavor. + + Parameters + ---------- + api_key : str + AllThingsDev API key. + **kwargs : dict + Additional keyword arguments for the base AsyncClient. + """ + + DEFAULT_ENDPOINT: ClassVar[str] = ATDJudge0CE.DEFAULT_ENDPOINT + DEFAULT_HOST: ClassVar[str] = ATDJudge0CE.DEFAULT_HOST + HOME_URL: ClassVar[str] = ATDJudge0CE.HOME_URL + DEFAULT_ABOUT_ENDPOINT: ClassVar[str] = ATDJudge0CE.DEFAULT_ABOUT_ENDPOINT + DEFAULT_CONFIG_INFO_ENDPOINT: ClassVar[str] = ( + ATDJudge0CE.DEFAULT_CONFIG_INFO_ENDPOINT + ) + DEFAULT_LANGUAGE_ENDPOINT: ClassVar[str] = ATDJudge0CE.DEFAULT_LANGUAGE_ENDPOINT + DEFAULT_LANGUAGES_ENDPOINT: ClassVar[str] = ATDJudge0CE.DEFAULT_LANGUAGES_ENDPOINT + DEFAULT_STATUSES_ENDPOINT: ClassVar[str] = ATDJudge0CE.DEFAULT_STATUSES_ENDPOINT + DEFAULT_CREATE_SUBMISSION_ENDPOINT: ClassVar[str] = ( + ATDJudge0CE.DEFAULT_CREATE_SUBMISSION_ENDPOINT + ) + DEFAULT_GET_SUBMISSION_ENDPOINT: ClassVar[str] = ( + ATDJudge0CE.DEFAULT_GET_SUBMISSION_ENDPOINT + ) + DEFAULT_CREATE_SUBMISSIONS_ENDPOINT: ClassVar[str] = ( + ATDJudge0CE.DEFAULT_CREATE_SUBMISSIONS_ENDPOINT + ) + DEFAULT_GET_SUBMISSIONS_ENDPOINT: ClassVar[str] = ( + ATDJudge0CE.DEFAULT_GET_SUBMISSIONS_ENDPOINT + ) + + def __init__(self, api_key: str, **kwargs: Any) -> None: + super().__init__( + self.DEFAULT_ENDPOINT, + self.DEFAULT_HOST, + api_key, + **kwargs, + ) + + +class AsyncATDJudge0ExtraCE(AsyncATD): + """Asynchronous AllThingsDev client for Extra CE flavor. + + Parameters + ---------- + api_key : str + AllThingsDev API key. + **kwargs : dict + Additional keyword arguments for the base AsyncClient. + """ + + DEFAULT_ENDPOINT: ClassVar[str] = ATDJudge0ExtraCE.DEFAULT_ENDPOINT + DEFAULT_HOST: ClassVar[str] = ATDJudge0ExtraCE.DEFAULT_HOST + HOME_URL: ClassVar[str] = ATDJudge0ExtraCE.HOME_URL + DEFAULT_ABOUT_ENDPOINT: ClassVar[str] = ATDJudge0ExtraCE.DEFAULT_ABOUT_ENDPOINT + DEFAULT_CONFIG_INFO_ENDPOINT: ClassVar[str] = ( + ATDJudge0ExtraCE.DEFAULT_CONFIG_INFO_ENDPOINT + ) + DEFAULT_LANGUAGE_ENDPOINT: ClassVar[str] = ( + ATDJudge0ExtraCE.DEFAULT_LANGUAGE_ENDPOINT + ) + DEFAULT_LANGUAGES_ENDPOINT: ClassVar[str] = ( + ATDJudge0ExtraCE.DEFAULT_LANGUAGES_ENDPOINT + ) + DEFAULT_STATUSES_ENDPOINT: ClassVar[str] = ( + ATDJudge0ExtraCE.DEFAULT_STATUSES_ENDPOINT + ) + DEFAULT_CREATE_SUBMISSION_ENDPOINT: ClassVar[str] = ( + ATDJudge0ExtraCE.DEFAULT_CREATE_SUBMISSION_ENDPOINT + ) + DEFAULT_GET_SUBMISSION_ENDPOINT: ClassVar[str] = ( + ATDJudge0ExtraCE.DEFAULT_GET_SUBMISSION_ENDPOINT + ) + DEFAULT_CREATE_SUBMISSIONS_ENDPOINT: ClassVar[str] = ( + ATDJudge0ExtraCE.DEFAULT_CREATE_SUBMISSIONS_ENDPOINT + ) + DEFAULT_GET_SUBMISSIONS_ENDPOINT: ClassVar[str] = ( + ATDJudge0ExtraCE.DEFAULT_GET_SUBMISSIONS_ENDPOINT + ) + + def __init__(self, api_key: str, **kwargs: Any) -> None: + super().__init__( + self.DEFAULT_ENDPOINT, + self.DEFAULT_HOST, + api_key, + **kwargs, + ) + + +class AsyncRapid(AsyncClient): + """Asynchronous 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 AsyncClient. + """ + + API_KEY_ENV: ClassVar[str | None] = "JUDGE0_RAPID_API_KEY" + + def __init__( + self, + endpoint: str, + host_header_value: str, + api_key: str, + **kwargs: Any, + ) -> None: + self.api_key = api_key + super().__init__( + endpoint, + { + "x-rapidapi-host": host_header_value, + "x-rapidapi-key": api_key, + }, + **kwargs, + ) + + +class AsyncRapidJudge0CE(AsyncRapid): + """Asynchronous RapidAPI client for CE flavor. + + Parameters + ---------- + api_key : str + RapidAPI API key. + **kwargs : dict + Additional keyword arguments for the base AsyncClient. + """ + + DEFAULT_ENDPOINT: ClassVar[str] = RapidJudge0CE.DEFAULT_ENDPOINT + DEFAULT_HOST: ClassVar[str] = RapidJudge0CE.DEFAULT_HOST + HOME_URL: ClassVar[str] = RapidJudge0CE.HOME_URL + + def __init__(self, api_key: str, **kwargs: Any) -> None: + super().__init__( + self.DEFAULT_ENDPOINT, + self.DEFAULT_HOST, + api_key, + **kwargs, + ) + + +class AsyncRapidJudge0ExtraCE(AsyncRapid): + """Asynchronous RapidAPI client for Extra CE flavor. + + Parameters + ---------- + api_key : str + RapidAPI API key. + **kwargs : dict + Additional keyword arguments for the base AsyncClient. + """ + + DEFAULT_ENDPOINT: ClassVar[str] = RapidJudge0ExtraCE.DEFAULT_ENDPOINT + DEFAULT_HOST: ClassVar[str] = RapidJudge0ExtraCE.DEFAULT_HOST + HOME_URL: ClassVar[str] = RapidJudge0ExtraCE.HOME_URL + + def __init__(self, api_key: str, **kwargs: Any) -> None: + super().__init__( + self.DEFAULT_ENDPOINT, + self.DEFAULT_HOST, + api_key, + **kwargs, + ) + + +class AsyncJudge0Cloud(AsyncClient): + """Asynchronous base class for all Judge0 Cloud clients. + + Parameters + ---------- + endpoint : str + Default request endpoint. + headers : str or dict + Judge0 Cloud authentication headers, either as a JSON string or a dictionary. + **kwargs : dict + Additional keyword arguments for the base AsyncClient. + """ + + def __init__( + self, + endpoint: str, + headers: str | Headers | None = None, + **kwargs: Any, + ) -> None: + self.api_key = headers + if isinstance(headers, str): + from json import loads + + headers = cast(Headers, loads(headers)) + + super().__init__( + endpoint, + headers, + **kwargs, + ) + + +class AsyncJudge0CloudCE(AsyncJudge0Cloud): + """Asynchronous Judge0 Cloud client for CE flavor. + + Parameters + ---------- + endpoint : str + Default request endpoint. + headers : str or dict + Judge0 Cloud authentication headers, either as a JSON string or a dictionary. + **kwargs : dict + Additional keyword arguments for the base AsyncClient. + """ + + DEFAULT_ENDPOINT: ClassVar[str] = Judge0CloudCE.DEFAULT_ENDPOINT + HOME_URL: ClassVar[str] = Judge0CloudCE.HOME_URL + API_KEY_ENV: ClassVar[str | None] = Judge0CloudCE.API_KEY_ENV + + def __init__(self, headers: str | Headers | None = None, **kwargs: Any) -> None: + super().__init__( + self.DEFAULT_ENDPOINT, + headers, + **kwargs, + ) + + +class AsyncJudge0CloudExtraCE(AsyncJudge0Cloud): + """Asynchronous Judge0 Cloud client for Extra CE flavor. + + Parameters + ---------- + endpoint : str + Default request endpoint. + headers : str or dict + Judge0 Cloud authentication headers, either as a JSON string or a dictionary. + **kwargs : dict + Additional keyword arguments for the base AsyncClient. + """ + + DEFAULT_ENDPOINT: ClassVar[str] = Judge0CloudExtraCE.DEFAULT_ENDPOINT + HOME_URL: ClassVar[str] = Judge0CloudExtraCE.HOME_URL + API_KEY_ENV: ClassVar[str | None] = Judge0CloudExtraCE.API_KEY_ENV + + def __init__(self, headers: str | Headers | None = None, **kwargs: Any) -> None: + super().__init__(self.DEFAULT_ENDPOINT, headers, **kwargs) + + +ASYNC_CE = (AsyncJudge0CloudCE, AsyncRapidJudge0CE, AsyncATDJudge0CE) +ASYNC_EXTRA_CE = ( + AsyncJudge0CloudExtraCE, + AsyncRapidJudge0ExtraCE, + AsyncATDJudge0ExtraCE, +) diff --git a/src/judge0/submission.py b/src/judge0/submission.py index 5716eede..94742cd8 100644 --- a/src/judge0/submission.py +++ b/src/judge0/submission.py @@ -2,7 +2,7 @@ from binascii import Error as BinasciiError from collections.abc import Iterator from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, cast +from typing import Any, Protocol, cast from pydantic import UUID4, BaseModel, ConfigDict, Field, field_validator @@ -10,8 +10,10 @@ from .common import decode, encode from .filesystem import File, Filesystem -if TYPE_CHECKING: - from .clients import Client + +class SupportsLanguageId(Protocol): + def get_language_id(self, language: LanguageAlias | int) -> int: ... + ENCODED_REQUEST_FIELDS = { "source_code", @@ -242,7 +244,7 @@ def set_attributes(self, attributes: dict[str, Any]) -> None: setattr(self, attr, value) - def as_body(self, client: "Client") -> dict[str, Any]: + def as_body(self, client: SupportsLanguageId) -> dict[str, Any]: """Prepare Submission as a dictionary while taking into account the client's restrictions. diff --git a/src/judge0/utils.py b/src/judge0/utils.py index 4ad24962..34b5083c 100644 --- a/src/judge0/utils.py +++ b/src/judge0/utils.py @@ -1,5 +1,6 @@ """Module containing different utility functions for Judge0 Python SDK.""" +import inspect from collections.abc import Callable from functools import wraps from http import HTTPStatus @@ -12,6 +13,13 @@ P = ParamSpec("P") R = TypeVar("R") +_PREVIEW_CLIENT_NAMES = ( + "Judge0CloudCE", + "Judge0CloudExtraCE", + "AsyncJudge0CloudCE", + "AsyncJudge0CloudExtraCE", +) + def is_http_too_many_requests_error(exception: Exception) -> bool: return ( @@ -20,30 +28,44 @@ def is_http_too_many_requests_error(exception: Exception) -> bool: ) +def _reraise_preview_limit_error(err: HTTPError, args: tuple[object, ...]) -> None: + if is_http_too_many_requests_error(exception=err) and args: + instance = args[0] + class_name = instance.__class__.__name__ + if ( + class_name in _PREVIEW_CLIENT_NAMES + and getattr(instance, "api_key", None) is None + ): + raise PreviewClientLimitError( + "You are using a preview version of a client and " + "you've hit a rate limit on it. Visit " + f"{getattr(instance, 'HOME_URL', None)} " + "to get your authentication credentials." + ) from err + raise err from None + + def handle_too_many_requests_error_for_preview_client( func: Callable[P, R], ) -> Callable[P, R]: + if inspect.iscoroutinefunction(func): + + @wraps(func) + async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + try: + return await func(*args, **kwargs) # type: ignore[misc] + except HTTPError as err: + _reraise_preview_limit_error(err, args) + raise + + return async_wrapper # type: ignore[return-value] + @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: try: return func(*args, **kwargs) except HTTPError as err: - if is_http_too_many_requests_error(exception=err) and args: - # If the raised exception is inside the one of the Judge0 Cloud clients - # let's check if we are dealing with the implicit client. - instance = args[0] - class_name = instance.__class__.__name__ - # Check if we are using a preview version of the client. - if ( - class_name in ("Judge0CloudCE", "Judge0CloudExtraCE") - and getattr(instance, "api_key", None) is None - ): - raise PreviewClientLimitError( - "You are using a preview version of a client and " - "you've hit a rate limit on it. Visit " - f"{getattr(instance, 'HOME_URL', None)} " - "to get your authentication credentials." - ) from err - raise err from None + _reraise_preview_limit_error(err, args) + raise return wrapper diff --git a/tests/test_async_clients.py b/tests/test_async_clients.py new file mode 100644 index 00000000..bbd79b9c --- /dev/null +++ b/tests/test_async_clients.py @@ -0,0 +1,264 @@ +import asyncio +from typing import Any + +import httpx + +from judge0 import Client +from judge0.clients import AsyncClient +from judge0.submission import Submission + +CONFIG_PAYLOAD: dict[str, Any] = { + "allow_enable_network": False, + "allow_enable_per_process_and_thread_memory_limit": False, + "allow_enable_per_process_and_thread_time_limit": False, + "allowed_languages_for_compile_options": [], + "callbacks_max_tries": 1, + "callbacks_timeout": 1.0, + "cpu_extra_time": 0.0, + "cpu_time_limit": 1.0, + "enable_additional_files": False, + "enable_batched_submissions": True, + "enable_callbacks": False, + "enable_command_line_arguments": False, + "enable_compiler_options": False, + "enable_network": False, + "enable_per_process_and_thread_memory_limit": False, + "enable_per_process_and_thread_time_limit": False, + "enable_submission_delete": False, + "enable_wait_result": False, + "maintenance_mode": False, + "max_cpu_extra_time": 1.0, + "max_cpu_time_limit": 1.0, + "max_extract_size": 1, + "max_file_size": 1, + "max_max_file_size": 1, + "max_max_processes_and_or_threads": 1, + "max_memory_limit": 1, + "max_number_of_runs": 1, + "max_processes_and_or_threads": 1, + "max_queue_size": 1, + "max_stack_limit": 1, + "max_submission_batch_size": 2, + "max_wall_time_limit": 1.0, + "memory_limit": 1, + "wall_time_limit": 1.0, + "number_of_runs": 1, + "redirect_stderr_to_stdout": False, + "stack_limit": 1, + "submission_cache_duration": 0.0, + "use_docs_as_homepage": False, +} + +LANGUAGES_PAYLOAD = [ + {"id": 89, "name": "Multi File Program"}, + {"id": 92, "name": "Python (3.11.2)"}, +] + + +def _mock_transport() -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if request.method == "GET" and path.endswith("/about"): + return httpx.Response(200, json={"version": "1.13.1"}) + if request.method == "GET" and path.endswith("/config_info"): + return httpx.Response(200, json=CONFIG_PAYLOAD) + if request.method == "GET" and path.endswith("/languages"): + return httpx.Response(200, json=LANGUAGES_PAYLOAD) + if request.method == "GET" and path.endswith("/statuses"): + return httpx.Response(200, json=[{"id": 3, "description": "Accepted"}]) + if request.method == "GET" and "/languages/" in path: + return httpx.Response(200, json={"id": 92, "name": "Python (3.11.2)"}) + if request.method == "POST" and path.endswith("/submissions/batch"): + return httpx.Response(201, json=[{"token": "t1"}, {"token": "t2"}]) + if request.method == "POST" and path.endswith("/submissions"): + return httpx.Response(201, json={"token": "tok-1"}) + if request.method == "GET" and path.endswith("/submissions/batch"): + return httpx.Response( + 200, + json={ + "submissions": [ + {"token": "t1", "status": {"id": 3, "description": "Accepted"}}, + {"token": "t2", "status": {"id": 3, "description": "Accepted"}}, + ] + }, + ) + if request.method == "GET" and "/submissions/" in path: + return httpx.Response( + 200, + json={ + "token": "tok-1", + "status": {"id": 3, "description": "Accepted"}, + "stdout": "aGVsbG8K", + }, + ) + return httpx.Response(404, json={"error": path}) + + return httpx.MockTransport(handler) + + +def _sync_http_client() -> httpx.Client: + return httpx.Client( + base_url="https://example.invalid", + transport=_mock_transport(), + ) + + +def _async_http_client() -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url="https://example.invalid", + transport=_mock_transport(), + ) + + +def test_client_uses_injected_http_client_for_get_about() -> None: + http_client = _sync_http_client() + client = Client( + endpoint="https://example.invalid", + http_client=http_client, + ) + about = client.get_about() + assert about["version"] == "1.13.1" + client.close() + + +def test_async_client_get_about() -> None: + async def _run() -> None: + http_client = _async_http_client() + client = AsyncClient( + endpoint="https://example.invalid", + http_client=http_client, + ) + about = await client.get_about() + assert about["version"] == "1.13.1" + await client.aclose() + + asyncio.run(_run()) + + +def test_async_client_reads_languages_statuses_and_config() -> None: + async def _run() -> None: + client = AsyncClient( + endpoint="https://example.invalid", + http_client=_async_http_client(), + ) + languages = await client.get_languages() + statuses = await client.get_statuses() + config = await client.get_config_info() + language = await client.get_language(92) + assert languages[0].id == 89 + assert statuses[0]["id"] == 3 + assert config.cpu_time_limit == 1.0 + assert language.id == 92 + await client.aclose() + + asyncio.run(_run()) + + +def test_async_client_create_and_get_submission() -> None: + async def _run() -> None: + client = AsyncClient( + endpoint="https://example.invalid", + http_client=_async_http_client(), + ) + submission = Submission(source_code="print(1)", language=92) + created = await client.create_submission(submission) + assert created.token == "tok-1" + fetched = await client.get_submission(created) + assert fetched.status is not None + await client.aclose() + + asyncio.run(_run()) + + +def test_async_client_create_and_get_submissions_batch() -> None: + async def _run() -> None: + client = AsyncClient( + endpoint="https://example.invalid", + http_client=_async_http_client(), + ) + submissions = [ + Submission(source_code="print(1)", language=92), + Submission(source_code="print(2)", language=92), + ] + created = await client.create_submissions(submissions) + assert [item.token for item in created] == ["t1", "t2"] + fetched = await client.get_submissions(created) + assert all(item.status is not None for item in fetched) + await client.aclose() + + asyncio.run(_run()) + + +def test_async_rapid_client_sets_auth_headers() -> None: + async def _run() -> None: + from judge0.clients import AsyncRapidJudge0CE + + client = AsyncRapidJudge0CE( + api_key="rapid-key", + http_client=_async_http_client(), + ) + await client.get_about() + assert client.headers["x-rapidapi-key"] == "rapid-key" + assert client.headers["x-rapidapi-host"] == "judge0-ce.p.rapidapi.com" + await client.aclose() + + asyncio.run(_run()) + + +def test_async_atd_client_sets_operation_endpoint_header() -> None: + captured: list[str | None] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request.headers.get("x-apihub-endpoint")) + path = request.url.path + if path.endswith("/about"): + return httpx.Response(200, json={"version": "1.13.1"}) + if path.endswith("/config_info"): + return httpx.Response(200, json=CONFIG_PAYLOAD) + if path.endswith("/languages"): + return httpx.Response(200, json=LANGUAGES_PAYLOAD) + return httpx.Response(404) + + async def _run() -> None: + from judge0.clients import AsyncATDJudge0CE + + http_client = httpx.AsyncClient( + base_url="https://example.invalid", + transport=httpx.MockTransport(handler), + ) + client = AsyncATDJudge0CE(api_key="atd-key", http_client=http_client) + await client.get_about() + assert AsyncATDJudge0CE.DEFAULT_LANGUAGES_ENDPOINT in captured + assert AsyncATDJudge0CE.DEFAULT_CONFIG_INFO_ENDPOINT in captured + assert AsyncATDJudge0CE.DEFAULT_ABOUT_ENDPOINT in captured + await client.aclose() + + asyncio.run(_run()) + + +def test_async_cloud_client_uses_cloud_endpoint() -> None: + async def _run() -> None: + from judge0.clients import AsyncJudge0CloudCE + + client = AsyncJudge0CloudCE( + headers={"Authorization": "Bearer x"}, + http_client=_async_http_client(), + ) + await client.get_about() + assert client.endpoint == "https://ce.judge0.com" + await client.aclose() + + asyncio.run(_run()) + + +def test_async_client_context_manager() -> None: + async def _run() -> None: + http_client = _async_http_client() + async with AsyncClient( + endpoint="https://example.invalid", + http_client=http_client, + ) as client: + about = await client.get_about() + assert about["version"] == "1.13.1" + + asyncio.run(_run())