onlinecompiler 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- onlinecompiler/__init__.py +67 -0
- onlinecompiler/_client.py +622 -0
- onlinecompiler/_verify.py +206 -0
- onlinecompiler/py.typed +0 -0
- onlinecompiler-0.1.0.dist-info/METADATA +285 -0
- onlinecompiler-0.1.0.dist-info/RECORD +8 -0
- onlinecompiler-0.1.0.dist-info/WHEEL +4 -0
- onlinecompiler-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Python client for the OnlineCompiler.io code execution API: run and judge code in 46 languages."""
|
|
2
|
+
|
|
3
|
+
from ._client import (
|
|
4
|
+
AuthenticationError,
|
|
5
|
+
Case,
|
|
6
|
+
CaseResult,
|
|
7
|
+
Checker,
|
|
8
|
+
Compiler,
|
|
9
|
+
InputFile,
|
|
10
|
+
JudgeResult,
|
|
11
|
+
Mode,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
OnlineCompiler,
|
|
14
|
+
OnlineCompilerError,
|
|
15
|
+
OutputFile,
|
|
16
|
+
ProblemSession,
|
|
17
|
+
ProblemSessionCreated,
|
|
18
|
+
PublishResult,
|
|
19
|
+
Queued,
|
|
20
|
+
RateLimitError,
|
|
21
|
+
Receipt,
|
|
22
|
+
ReceiptKey,
|
|
23
|
+
RunResult,
|
|
24
|
+
RunTestsResult,
|
|
25
|
+
Scoring,
|
|
26
|
+
Submission,
|
|
27
|
+
SubmissionDeleted,
|
|
28
|
+
SubmissionProblem,
|
|
29
|
+
SubmissionQueued,
|
|
30
|
+
SubmissionResult,
|
|
31
|
+
__version__,
|
|
32
|
+
)
|
|
33
|
+
from ._verify import canonical_json, verify_receipt, verify_webhook
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AuthenticationError",
|
|
37
|
+
"Case",
|
|
38
|
+
"CaseResult",
|
|
39
|
+
"Checker",
|
|
40
|
+
"Compiler",
|
|
41
|
+
"InputFile",
|
|
42
|
+
"JudgeResult",
|
|
43
|
+
"Mode",
|
|
44
|
+
"NotFoundError",
|
|
45
|
+
"OnlineCompiler",
|
|
46
|
+
"OnlineCompilerError",
|
|
47
|
+
"OutputFile",
|
|
48
|
+
"ProblemSession",
|
|
49
|
+
"ProblemSessionCreated",
|
|
50
|
+
"PublishResult",
|
|
51
|
+
"Queued",
|
|
52
|
+
"RateLimitError",
|
|
53
|
+
"Receipt",
|
|
54
|
+
"ReceiptKey",
|
|
55
|
+
"RunResult",
|
|
56
|
+
"RunTestsResult",
|
|
57
|
+
"Scoring",
|
|
58
|
+
"Submission",
|
|
59
|
+
"SubmissionDeleted",
|
|
60
|
+
"SubmissionProblem",
|
|
61
|
+
"SubmissionQueued",
|
|
62
|
+
"SubmissionResult",
|
|
63
|
+
"__version__",
|
|
64
|
+
"canonical_json",
|
|
65
|
+
"verify_receipt",
|
|
66
|
+
"verify_webhook",
|
|
67
|
+
]
|
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
"""The OnlineCompiler.io API client. Standard library only, so installing it adds nothing else."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.client
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from typing import Any, Literal, TypedDict, overload
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
from ._verify import _ed25519
|
|
17
|
+
from ._verify import verify_receipt as _verify_receipt
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.onlinecompiler.io"
|
|
22
|
+
# Long enough for a 30-second run or a 60-second run-tests request, plus queueing and network time.
|
|
23
|
+
DEFAULT_TIMEOUT = 90.0
|
|
24
|
+
SIGNUP_URL = "https://api.onlinecompiler.io/compilers/api-keys/"
|
|
25
|
+
RECEIPT_KEYS_PATH = "/.well-known/receipt-keys.json"
|
|
26
|
+
KEYS_MAX_AGE = 3600.0 # seconds the receipt key list is kept, as the API allows it to be cached
|
|
27
|
+
KEYS_REFETCH_AFTER = 60.0 # seconds before an unknown key_id fetches the list again
|
|
28
|
+
# The API answers 404 both for an unknown API key and for an id that is not the caller's.
|
|
29
|
+
KEY_REFUSED = "Invalid or inactive API key"
|
|
30
|
+
# What the API takes as an Idempotency-Key: 1 to 100 printable ASCII characters, space included.
|
|
31
|
+
IDEMPOTENCY_KEY = re.compile(r"[ -~]{1,100}")
|
|
32
|
+
REFUSED_ORIGINAL = ("The original request with this idempotency_key was refused, so it was not judged. Send it "
|
|
33
|
+
"again.")
|
|
34
|
+
|
|
35
|
+
Checker = Literal["trim", "exact", "tokens", "float", "rows"]
|
|
36
|
+
Scoring = Literal["all_or_nothing", "percent", "weighted"]
|
|
37
|
+
Mode = Literal["all", "samples"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _InputFile(TypedDict):
|
|
41
|
+
name: str
|
|
42
|
+
content: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class InputFile(_InputFile, total=False):
|
|
46
|
+
"""A file the program can read from its working directory. encoding is "utf8" (default) or "base64"."""
|
|
47
|
+
|
|
48
|
+
encoding: Literal["utf8", "base64"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class OutputFile(TypedDict):
|
|
52
|
+
"""A file the program wrote to $OUTPUT_DIR. url is null when the file was not stored."""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
mime: str
|
|
56
|
+
size: int
|
|
57
|
+
content_base64: str
|
|
58
|
+
url: str | None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RunResult(TypedDict, total=False):
|
|
62
|
+
"""time, total (seconds) and memory (KB) are decimal strings, as the API sends them."""
|
|
63
|
+
|
|
64
|
+
output: str
|
|
65
|
+
error: str
|
|
66
|
+
status: str
|
|
67
|
+
exit_code: int | None
|
|
68
|
+
signal: int | None
|
|
69
|
+
time: str
|
|
70
|
+
total: str
|
|
71
|
+
memory: str
|
|
72
|
+
truncated: bool
|
|
73
|
+
time_limit: float
|
|
74
|
+
files: list[OutputFile]
|
|
75
|
+
files_truncated: bool
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Case(TypedDict):
|
|
79
|
+
expected_output: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Case(_Case, total=False):
|
|
83
|
+
"""One test case: stdin, the output it should print, and optionally hidden and weight.
|
|
84
|
+
Named Case rather than Test so pytest never tries to collect it from your test files."""
|
|
85
|
+
|
|
86
|
+
input: str
|
|
87
|
+
hidden: bool
|
|
88
|
+
weight: float
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class CaseResult(TypedDict, total=False):
|
|
92
|
+
"""One test case's result. Hidden and skipped tests carry only verdict and hidden."""
|
|
93
|
+
|
|
94
|
+
verdict: str
|
|
95
|
+
hidden: bool
|
|
96
|
+
time: str
|
|
97
|
+
memory: str
|
|
98
|
+
exit_code: int | None
|
|
99
|
+
signal: int | None
|
|
100
|
+
output: str
|
|
101
|
+
error: str
|
|
102
|
+
truncated: bool
|
|
103
|
+
files: list[OutputFile]
|
|
104
|
+
files_truncated: bool
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ReceiptKey(TypedDict):
|
|
108
|
+
"""A published receipt signing key: the base64 of its raw 32-byte Ed25519 public key."""
|
|
109
|
+
|
|
110
|
+
key_id: str
|
|
111
|
+
algorithm: str
|
|
112
|
+
public_key: str
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Receipt(TypedDict, total=False):
|
|
116
|
+
"""A signed receipt (see https://onlinecompiler.io/docs/receipts). A stored problem's receipt has problem in
|
|
117
|
+
place of tests_sha256 and files_sha256. Check it with verify_receipt."""
|
|
118
|
+
|
|
119
|
+
receipt: str
|
|
120
|
+
format: int
|
|
121
|
+
submission: str
|
|
122
|
+
account: str
|
|
123
|
+
external_ref: str | None
|
|
124
|
+
issued: str
|
|
125
|
+
compiler: dict[str, Any]
|
|
126
|
+
code_sha256: str
|
|
127
|
+
tests_sha256: str
|
|
128
|
+
files_sha256: str | None
|
|
129
|
+
problem: dict[str, Any]
|
|
130
|
+
settings: dict[str, Any]
|
|
131
|
+
verdict: str
|
|
132
|
+
score: float
|
|
133
|
+
passed: int
|
|
134
|
+
total: int
|
|
135
|
+
tests: list[dict[str, Any]]
|
|
136
|
+
key_id: str
|
|
137
|
+
signature: str
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class _Judged(TypedDict):
|
|
141
|
+
verdict: str
|
|
142
|
+
score: float
|
|
143
|
+
passed: int
|
|
144
|
+
total: int
|
|
145
|
+
time_limit: float
|
|
146
|
+
compile_error: str | None
|
|
147
|
+
tests: list[CaseResult]
|
|
148
|
+
summary: str
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class RunTestsResult(_Judged):
|
|
152
|
+
"""A judged submission: summary is the result in plain words; submission is its id; receipt is None when
|
|
153
|
+
none could be signed."""
|
|
154
|
+
|
|
155
|
+
submission: str
|
|
156
|
+
receipt: Receipt | None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class JudgeResult(RunTestsResult, total=False):
|
|
160
|
+
"""A stored problem's result: the run-tests result with the problem's id and the version judged."""
|
|
161
|
+
|
|
162
|
+
problem: str
|
|
163
|
+
version: int
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class SubmissionResult(_Judged, total=False):
|
|
167
|
+
"""A finished submission's result, as GET /api/submissions/{id}/ and webhooks give it: the run-tests result
|
|
168
|
+
without submission and receipt (problem and version for a stored problem)."""
|
|
169
|
+
|
|
170
|
+
problem: str
|
|
171
|
+
version: int
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class SubmissionQueued(TypedDict):
|
|
175
|
+
"""An async submission's answer: its id and status ("queued", or the original's status for a replayed
|
|
176
|
+
Idempotency-Key)."""
|
|
177
|
+
|
|
178
|
+
submission: str
|
|
179
|
+
status: str
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class SubmissionProblem(TypedDict):
|
|
183
|
+
id: str
|
|
184
|
+
version: int
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class Submission(TypedDict):
|
|
188
|
+
"""A submission as its account sees it. status is queued, running, finished or failed; failed means it was not
|
|
189
|
+
judged, and error says why. hash_salt salts the receipt's tests and files hashes."""
|
|
190
|
+
|
|
191
|
+
submission: str
|
|
192
|
+
status: str
|
|
193
|
+
created: str
|
|
194
|
+
finished: str | None
|
|
195
|
+
external_ref: str | None
|
|
196
|
+
metadata: dict[str, Any] | None
|
|
197
|
+
problem: SubmissionProblem | None
|
|
198
|
+
result: SubmissionResult | None
|
|
199
|
+
receipt: Receipt | None
|
|
200
|
+
error: str | None
|
|
201
|
+
checker_log: str | None
|
|
202
|
+
hash_salt: str
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class SubmissionDeleted(TypedDict):
|
|
206
|
+
submission: str
|
|
207
|
+
deleted: bool
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class PublishResult(TypedDict, total=False):
|
|
211
|
+
"""state is "published" (with warnings) or "checking" (with check, the reference check's submission id)."""
|
|
212
|
+
|
|
213
|
+
problem: str
|
|
214
|
+
version: int
|
|
215
|
+
state: str
|
|
216
|
+
warnings: list[str]
|
|
217
|
+
check: str
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class ProblemSession(TypedDict):
|
|
221
|
+
"""A widget session as its owner sees it, never with its token."""
|
|
222
|
+
|
|
223
|
+
session: str
|
|
224
|
+
problem: str
|
|
225
|
+
version: int
|
|
226
|
+
external_ref: str
|
|
227
|
+
metadata: dict[str, Any] | None
|
|
228
|
+
callback_url: str
|
|
229
|
+
max_submits: int
|
|
230
|
+
submits_used: int
|
|
231
|
+
expires: str
|
|
232
|
+
revoked: str | None
|
|
233
|
+
created: str
|
|
234
|
+
submissions: list[str]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class ProblemSessionCreated(ProblemSession):
|
|
238
|
+
"""A new session with its token, which the API shows only this once: render it into the candidate's page as
|
|
239
|
+
the widget's data-session, and keep it out of logs."""
|
|
240
|
+
|
|
241
|
+
token: str
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class Queued(TypedDict):
|
|
245
|
+
status: str
|
|
246
|
+
id: int
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class Compiler(TypedDict, total=False):
|
|
250
|
+
id: str
|
|
251
|
+
name: str
|
|
252
|
+
packages: list[dict[str, str]]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class OnlineCompilerError(Exception):
|
|
256
|
+
"""The API refused or failed a request. status is the HTTP status, or 0 when no response arrived
|
|
257
|
+
(network error or timeout); body is the parsed error response, if any."""
|
|
258
|
+
|
|
259
|
+
def __init__(self, message: str, status: int = 0, body: Any = None):
|
|
260
|
+
super().__init__(message)
|
|
261
|
+
self.message = message
|
|
262
|
+
self.status = status
|
|
263
|
+
self.body = body
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class AuthenticationError(OnlineCompilerError):
|
|
267
|
+
"""HTTP 401 or 403, or 404 for the key itself: the API key is missing, unknown, inactive, of the wrong kind, or
|
|
268
|
+
not allowed from this address, or the account may not make this request (a 403 such as a plan's problem limit;
|
|
269
|
+
the message says which)."""
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class NotFoundError(OnlineCompilerError):
|
|
273
|
+
"""HTTP 404 for an id: the submission, problem or session does not exist or is not this account's."""
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class RateLimitError(OnlineCompilerError):
|
|
277
|
+
"""HTTP 429: the monthly allowance, the per-minute rate, the pending runs or the account's
|
|
278
|
+
sandboxes are used up. The message says which, and when it is the plan, how to raise it."""
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _error(status: int, raw: bytes) -> OnlineCompilerError:
|
|
282
|
+
try:
|
|
283
|
+
body = json.loads(raw)
|
|
284
|
+
message = body["error"] if isinstance(body, dict) and "error" in body else raw.decode("utf-8", "replace")
|
|
285
|
+
except ValueError:
|
|
286
|
+
body, message = None, raw.decode("utf-8", "replace")
|
|
287
|
+
message = message or f"HTTP {status}"
|
|
288
|
+
if status == 404 and message != KEY_REFUSED:
|
|
289
|
+
return NotFoundError(message, status, body)
|
|
290
|
+
if status in (401, 403, 404):
|
|
291
|
+
return AuthenticationError(message, status, body)
|
|
292
|
+
if status == 429:
|
|
293
|
+
return RateLimitError(message, status, body)
|
|
294
|
+
return OnlineCompilerError(message, status, body)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _without_none(**fields: Any) -> dict[str, Any]:
|
|
298
|
+
"""Options left as None are left out, so the API applies its own defaults."""
|
|
299
|
+
return {key: value for key, value in fields.items() if value is not None}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _segment(value: str, what: str) -> str:
|
|
303
|
+
"""value as one quoted path segment; an id or slug that could not be one is refused before any request."""
|
|
304
|
+
if not isinstance(value, str):
|
|
305
|
+
raise TypeError(f"{what} must be a string, not {type(value).__name__}")
|
|
306
|
+
if value in ("", ".", ".."):
|
|
307
|
+
raise ValueError(f"{what} must be a non-empty id")
|
|
308
|
+
return quote(value, safe="")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _judging_options(async_: bool | None, callback_url: str | None, external_ref: str | None,
|
|
312
|
+
metadata: dict[str, Any] | None) -> dict[str, Any]:
|
|
313
|
+
if callback_url is not None and async_ is not True:
|
|
314
|
+
raise ValueError("callback_url needs async_=True: the result goes to the URL, not into the response")
|
|
315
|
+
return _without_none(callback_url=callback_url, external_ref=external_ref, metadata=metadata,
|
|
316
|
+
**{"async": async_})
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class OnlineCompiler:
|
|
320
|
+
"""A client for the OnlineCompiler.io code execution API.
|
|
321
|
+
|
|
322
|
+
api_key defaults to the ONLINECOMPILER_API_KEY environment variable. Get a free key at
|
|
323
|
+
https://api.onlinecompiler.io/compilers/api-keys/. Requests are never retried, since every
|
|
324
|
+
accepted run counts toward your allowance.
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
def __init__(self, api_key: str | None = None, *, base_url: str = DEFAULT_BASE_URL,
|
|
328
|
+
timeout: float = DEFAULT_TIMEOUT):
|
|
329
|
+
self.api_key = api_key or os.environ.get("ONLINECOMPILER_API_KEY")
|
|
330
|
+
self.base_url = base_url.rstrip("/")
|
|
331
|
+
self.timeout = timeout
|
|
332
|
+
self._keys: list[ReceiptKey] | None = None
|
|
333
|
+
self._keys_fetched = 0.0
|
|
334
|
+
|
|
335
|
+
def run(self, compiler: str, code: str, input: str = "", *, time_limit: float | None = None,
|
|
336
|
+
files: list[InputFile] | None = None) -> RunResult:
|
|
337
|
+
"""Run a program and wait for its result: output, error, exit_code, time, memory and any
|
|
338
|
+
files it wrote. compiler is an id such as "python-3.14" (see compilers())."""
|
|
339
|
+
body = _without_none(compiler=compiler, code=code, input=input, time_limit=time_limit, files=files)
|
|
340
|
+
return self._request("POST", "/api/run-code-sync/", body)
|
|
341
|
+
|
|
342
|
+
@overload
|
|
343
|
+
def run_tests(self, compiler: str, code: str, tests: list[Case], *, time_limit: float | None = None,
|
|
344
|
+
checker: Checker | None = None, tolerance: float | None = None, scoring: Scoring | None = None,
|
|
345
|
+
stop_on_failure: bool | None = None, files: list[InputFile] | None = None,
|
|
346
|
+
async_: Literal[False] | None = None, external_ref: str | None = None,
|
|
347
|
+
metadata: dict[str, Any] | None = None, idempotency_key: str | None = None) -> RunTestsResult: ...
|
|
348
|
+
|
|
349
|
+
@overload
|
|
350
|
+
def run_tests(self, compiler: str, code: str, tests: list[Case], *, time_limit: float | None = None,
|
|
351
|
+
checker: Checker | None = None, tolerance: float | None = None, scoring: Scoring | None = None,
|
|
352
|
+
stop_on_failure: bool | None = None, files: list[InputFile] | None = None,
|
|
353
|
+
async_: Literal[True], callback_url: str | None = None, external_ref: str | None = None,
|
|
354
|
+
metadata: dict[str, Any] | None = None, idempotency_key: str | None = None) -> SubmissionQueued: ...
|
|
355
|
+
|
|
356
|
+
@overload
|
|
357
|
+
def run_tests(self, compiler: str, code: str, tests: list[Case], *, time_limit: float | None = None,
|
|
358
|
+
checker: Checker | None = None, tolerance: float | None = None, scoring: Scoring | None = None,
|
|
359
|
+
stop_on_failure: bool | None = None, files: list[InputFile] | None = None,
|
|
360
|
+
async_: bool | None = None, callback_url: str | None = None, external_ref: str | None = None,
|
|
361
|
+
metadata: dict[str, Any] | None = None,
|
|
362
|
+
idempotency_key: str | None = None) -> RunTestsResult | SubmissionQueued: ...
|
|
363
|
+
|
|
364
|
+
def run_tests(self, compiler: str, code: str, tests: list[Case], *, time_limit: float | None = None,
|
|
365
|
+
checker: Checker | None = None, tolerance: float | None = None, scoring: Scoring | None = None,
|
|
366
|
+
stop_on_failure: bool | None = None, files: list[InputFile] | None = None,
|
|
367
|
+
async_: bool | None = None, callback_url: str | None = None, external_ref: str | None = None,
|
|
368
|
+
metadata: dict[str, Any] | None = None,
|
|
369
|
+
idempotency_key: str | None = None) -> RunTestsResult | SubmissionQueued:
|
|
370
|
+
"""Judge a program against test cases: an overall verdict, a score, a verdict per test, the submission id
|
|
371
|
+
and a signed receipt. Each test counts as one run. time_limit is seconds per test (default 2).
|
|
372
|
+
|
|
373
|
+
async_=True answers at once with the submission id (async is a Python keyword); get the result with
|
|
374
|
+
wait_for_submission(), submission(), or a signed webhook to callback_url (which needs async_=True).
|
|
375
|
+
external_ref (your own id, signed into the receipt) and metadata (a JSON object, never in the receipt)
|
|
376
|
+
come back in lookups and webhooks. idempotency_key is sent as the Idempotency-Key header: repeating a
|
|
377
|
+
request with it answers with the original result instead of running again."""
|
|
378
|
+
headers = self._idempotency(idempotency_key)
|
|
379
|
+
body = _without_none(compiler=compiler, code=code, tests=tests, time_limit=time_limit, checker=checker,
|
|
380
|
+
tolerance=tolerance, scoring=scoring, stop_on_failure=stop_on_failure, files=files)
|
|
381
|
+
body.update(_judging_options(async_, callback_url, external_ref, metadata))
|
|
382
|
+
answer = self._request("POST", "/api/run-tests/", body, headers=headers)
|
|
383
|
+
return answer if async_ else self._settled(answer)
|
|
384
|
+
|
|
385
|
+
def submit(self, compiler: str, code: str, input: str = "", *, time_limit: float | None = None,
|
|
386
|
+
files: list[InputFile] | None = None, extra_params: dict[str, Any] | None = None) -> Queued:
|
|
387
|
+
"""Queue a run and return at once with its id. The result is POSTed to the callback URL
|
|
388
|
+
set on the API key in your dashboard, with extra_params echoed back."""
|
|
389
|
+
body = _without_none(compiler=compiler, code=code, input=input, time_limit=time_limit, files=files,
|
|
390
|
+
extra_params=extra_params)
|
|
391
|
+
return self._request("POST", "/api/run-code/", body)
|
|
392
|
+
|
|
393
|
+
# Submissions
|
|
394
|
+
|
|
395
|
+
def submission(self, submission_id: str) -> Submission:
|
|
396
|
+
"""A submission of this account: its status, and once it has ended its result and receipt (or error)."""
|
|
397
|
+
return self._request("GET", f"/api/submissions/{_segment(submission_id, 'submission_id')}/")
|
|
398
|
+
|
|
399
|
+
def delete_submission(self, submission_id: str) -> SubmissionDeleted:
|
|
400
|
+
"""Delete a queued, finished or failed submission and what it kept; a running one is refused with 409.
|
|
401
|
+
Runs already counted stay counted. Frees its Idempotency-Key."""
|
|
402
|
+
return self._request("DELETE", f"/api/submissions/{_segment(submission_id, 'submission_id')}/")
|
|
403
|
+
|
|
404
|
+
def wait_for_submission(self, submission_id: str, *, interval: float = 2.0, timeout: float = 600.0) -> Submission:
|
|
405
|
+
"""Look the submission up every interval seconds until it is finished or failed, and return it. A failed
|
|
406
|
+
one has error and no result. Raises OnlineCompilerError (status 0) if it is still queued or running after
|
|
407
|
+
timeout seconds; it is still judged, so look it up again later."""
|
|
408
|
+
deadline = time.monotonic() + timeout
|
|
409
|
+
while True:
|
|
410
|
+
current = self.submission(submission_id)
|
|
411
|
+
if current["status"] in ("finished", "failed"):
|
|
412
|
+
return current
|
|
413
|
+
if time.monotonic() + interval > deadline:
|
|
414
|
+
raise OnlineCompilerError(
|
|
415
|
+
f"Submission {submission_id} is still {current['status']} after {timeout:g} seconds", 0, current)
|
|
416
|
+
time.sleep(interval)
|
|
417
|
+
|
|
418
|
+
# Stored problems: judging
|
|
419
|
+
|
|
420
|
+
@overload
|
|
421
|
+
def judge(self, problem: str, compiler: str, code: str, *, mode: Mode | None = None,
|
|
422
|
+
async_: Literal[False] | None = None, external_ref: str | None = None,
|
|
423
|
+
metadata: dict[str, Any] | None = None, idempotency_key: str | None = None) -> JudgeResult: ...
|
|
424
|
+
|
|
425
|
+
@overload
|
|
426
|
+
def judge(self, problem: str, compiler: str, code: str, *, mode: Mode | None = None, async_: Literal[True],
|
|
427
|
+
callback_url: str | None = None, external_ref: str | None = None,
|
|
428
|
+
metadata: dict[str, Any] | None = None, idempotency_key: str | None = None) -> SubmissionQueued: ...
|
|
429
|
+
|
|
430
|
+
@overload
|
|
431
|
+
def judge(self, problem: str, compiler: str, code: str, *, mode: Mode | None = None, async_: bool | None = None,
|
|
432
|
+
callback_url: str | None = None, external_ref: str | None = None,
|
|
433
|
+
metadata: dict[str, Any] | None = None,
|
|
434
|
+
idempotency_key: str | None = None) -> JudgeResult | SubmissionQueued: ...
|
|
435
|
+
|
|
436
|
+
def judge(self, problem: str, compiler: str, code: str, *, mode: Mode | None = None, async_: bool | None = None,
|
|
437
|
+
callback_url: str | None = None, external_ref: str | None = None,
|
|
438
|
+
metadata: dict[str, Any] | None = None,
|
|
439
|
+
idempotency_key: str | None = None) -> JudgeResult | SubmissionQueued:
|
|
440
|
+
"""Judge code against a stored problem's current version. problem is its id (prob_...) or your slug for
|
|
441
|
+
it. mode "samples" judges only the visible tests. The result is the run-tests result with problem and
|
|
442
|
+
version; async_, callback_url, external_ref, metadata and idempotency_key work as in run_tests(). Send
|
|
443
|
+
async_=True for a problem with a custom checker."""
|
|
444
|
+
path = f"/api/problems/{_segment(problem, 'problem')}/submissions/"
|
|
445
|
+
headers = self._idempotency(idempotency_key)
|
|
446
|
+
body = _without_none(compiler=compiler, code=code, mode=mode)
|
|
447
|
+
body.update(_judging_options(async_, callback_url, external_ref, metadata))
|
|
448
|
+
answer = self._request("POST", path, body, headers=headers)
|
|
449
|
+
return answer if async_ else self._settled(answer)
|
|
450
|
+
|
|
451
|
+
# Stored problems: authoring. Specs and answers are plain dicts, as the API docs describe them.
|
|
452
|
+
|
|
453
|
+
def problems(self) -> list[dict[str, Any]]:
|
|
454
|
+
"""This account's problems, newest first, without their tests."""
|
|
455
|
+
return self._request("GET", "/api/problems/")["problems"]
|
|
456
|
+
|
|
457
|
+
def problem(self, problem: str) -> dict[str, Any]:
|
|
458
|
+
"""A problem by id or slug, with its draft and current version in full, hidden tests included."""
|
|
459
|
+
return self._request("GET", f"/api/problems/{_segment(problem, 'problem')}/")
|
|
460
|
+
|
|
461
|
+
def create_problem(self, spec: dict[str, Any]) -> dict[str, Any]:
|
|
462
|
+
"""Create a problem from a title, an optional slug and public flag, and its first draft's fields (tests,
|
|
463
|
+
statement, checker, drivers, reference_solution...), sent as given."""
|
|
464
|
+
return self._request("POST", "/api/problems/", spec)
|
|
465
|
+
|
|
466
|
+
def update_problem(self, problem: str, fields: dict[str, Any]) -> dict[str, Any]:
|
|
467
|
+
"""Change a problem's title, slug or public flag."""
|
|
468
|
+
return self._request("PATCH", f"/api/problems/{_segment(problem, 'problem')}/", fields)
|
|
469
|
+
|
|
470
|
+
def replace_draft(self, problem: str, draft: dict[str, Any]) -> dict[str, Any]:
|
|
471
|
+
"""Replace the problem's draft with these fields (as create_problem takes, without title)."""
|
|
472
|
+
return self._request("PUT", f"/api/problems/{_segment(problem, 'problem')}/draft/", draft)
|
|
473
|
+
|
|
474
|
+
def publish_problem(self, problem: str) -> PublishResult:
|
|
475
|
+
"""Publish the draft as the next version: at once without a reference solution (state "published"), else
|
|
476
|
+
once its reference check passes (state "checking"; problem() shows how the check went)."""
|
|
477
|
+
return self._request("POST", f"/api/problems/{_segment(problem, 'problem')}/publish/")
|
|
478
|
+
|
|
479
|
+
def archive_problem(self, problem: str) -> dict[str, Any]:
|
|
480
|
+
"""Stop new submissions and free a problem slot."""
|
|
481
|
+
return self._request("POST", f"/api/problems/{_segment(problem, 'problem')}/archive/")
|
|
482
|
+
|
|
483
|
+
def restore_problem(self, problem: str) -> dict[str, Any]:
|
|
484
|
+
"""Undo archive_problem, if the plan has a free problem slot."""
|
|
485
|
+
return self._request("POST", f"/api/problems/{_segment(problem, 'problem')}/restore/")
|
|
486
|
+
|
|
487
|
+
def public_problem(self, problem: str) -> dict[str, Any]:
|
|
488
|
+
"""The current version as a candidate sees it: statement, languages, starter code and the visible tests.
|
|
489
|
+
A public problem answers by id with no API key; the key, when the client has one, also shows your own
|
|
490
|
+
private problems and works with a slug."""
|
|
491
|
+
return self._request("GET", f"/api/problems/{_segment(problem, 'problem')}/public/", auth=bool(self.api_key))
|
|
492
|
+
|
|
493
|
+
# Stored problems: sessions for the widget
|
|
494
|
+
|
|
495
|
+
def create_session(self, problem: str, external_ref: str, *, metadata: dict[str, Any] | None = None,
|
|
496
|
+
callback_url: str | None = None, expires_in: int | None = None,
|
|
497
|
+
max_submits: int | None = None) -> ProblemSessionCreated:
|
|
498
|
+
"""Let one candidate's widget Submit to a problem's current version. Returns the session with its token,
|
|
499
|
+
shown only this once: render it into the candidate's page as data-session and keep it out of logs.
|
|
500
|
+
external_ref is your opaque reference for the candidate or attempt (the page sees it; never a name or
|
|
501
|
+
email). expires_in is seconds (60 to 86,400; default 3,600), max_submits 1 to 20 (default 1)."""
|
|
502
|
+
body = _without_none(external_ref=external_ref, metadata=metadata, callback_url=callback_url,
|
|
503
|
+
expires_in=expires_in, max_submits=max_submits)
|
|
504
|
+
return self._request("POST", f"/api/problems/{_segment(problem, 'problem')}/sessions/", body, secret=True)
|
|
505
|
+
|
|
506
|
+
def session(self, problem: str, session_id: str) -> ProblemSession:
|
|
507
|
+
"""A session: its Submits used, expiry and the ids of its submissions. Never the token."""
|
|
508
|
+
return self._request("GET", self._session_path(problem, session_id))
|
|
509
|
+
|
|
510
|
+
def revoke_session(self, problem: str, session_id: str) -> ProblemSession:
|
|
511
|
+
"""End a session now: its next Submit is refused. Submissions already made are judged as usual."""
|
|
512
|
+
return self._request("DELETE", self._session_path(problem, session_id))
|
|
513
|
+
|
|
514
|
+
# Receipts
|
|
515
|
+
|
|
516
|
+
def receipt_keys(self, *, refresh: bool = False) -> list[ReceiptKey]:
|
|
517
|
+
"""The published receipt signing keys. Needs no API key. Kept on the client for an hour; refresh=True
|
|
518
|
+
fetches them again."""
|
|
519
|
+
if refresh or self._keys is None or time.monotonic() - self._keys_fetched > KEYS_MAX_AGE:
|
|
520
|
+
self._keys = self._request("GET", RECEIPT_KEYS_PATH, auth=False)["keys"]
|
|
521
|
+
self._keys_fetched = time.monotonic()
|
|
522
|
+
return [key.copy() for key in self._keys] # copies, so that changing them leaves the cache alone
|
|
523
|
+
|
|
524
|
+
def verify_receipt(self, receipt: Receipt | Mapping[str, Any] | None) -> bool:
|
|
525
|
+
"""Whether a receipt's signature is valid, against the published keys (fetched once and kept; fetched
|
|
526
|
+
again, at most once a minute, for a key_id they lack). None (a submission without a receipt) is False.
|
|
527
|
+
Needs onlinecompiler[receipts], checked before any request; see onlinecompiler.verify_receipt to verify
|
|
528
|
+
with keys you already hold."""
|
|
529
|
+
_ed25519()
|
|
530
|
+
if not isinstance(receipt, Mapping):
|
|
531
|
+
return False
|
|
532
|
+
keys = self.receipt_keys()
|
|
533
|
+
key_id = receipt.get("key_id")
|
|
534
|
+
if (not any(key.get("key_id") == key_id for key in keys)
|
|
535
|
+
and time.monotonic() - self._keys_fetched > KEYS_REFETCH_AFTER):
|
|
536
|
+
keys = self.receipt_keys(refresh=True)
|
|
537
|
+
return _verify_receipt(receipt, keys)
|
|
538
|
+
|
|
539
|
+
# Account
|
|
540
|
+
|
|
541
|
+
def compilers(self) -> list[Compiler]:
|
|
542
|
+
"""Every compiler id and its language name. Needs no API key."""
|
|
543
|
+
return self._request("GET", "/api/compilers/", auth=False)["compilers"]
|
|
544
|
+
|
|
545
|
+
def usage(self) -> dict[str, Any]:
|
|
546
|
+
"""This month's runs and remaining allowance for the account, and for the key itself."""
|
|
547
|
+
return self._request("GET", "/api/usage/")
|
|
548
|
+
|
|
549
|
+
def limits(self) -> dict[str, Any]:
|
|
550
|
+
"""Per-run limits (memory, time, output) and default account limits. Needs no API key."""
|
|
551
|
+
return self._request("GET", "/api/limits/", auth=False)
|
|
552
|
+
|
|
553
|
+
@staticmethod
|
|
554
|
+
def _idempotency(key: str | None) -> dict[str, str] | None:
|
|
555
|
+
if key is None:
|
|
556
|
+
return None
|
|
557
|
+
if not isinstance(key, str) or not IDEMPOTENCY_KEY.fullmatch(key):
|
|
558
|
+
raise ValueError("idempotency_key must be 1 to 100 printable ASCII characters")
|
|
559
|
+
return {"Idempotency-Key": key}
|
|
560
|
+
|
|
561
|
+
@staticmethod
|
|
562
|
+
def _session_path(problem: str, session_id: str) -> str:
|
|
563
|
+
return f"/api/problems/{_segment(problem, 'problem')}/sessions/{_segment(session_id, 'session_id')}/"
|
|
564
|
+
|
|
565
|
+
def _settled(self, answer: Any) -> Any:
|
|
566
|
+
"""A sync judging answer. A retry whose Idempotency-Key finds the original still running answers 202 with
|
|
567
|
+
its id; wait for it and answer as the original would have."""
|
|
568
|
+
if not isinstance(answer, dict) or "verdict" in answer or "submission" not in answer:
|
|
569
|
+
return answer
|
|
570
|
+
try:
|
|
571
|
+
# Bounded by the client's own timeout: the original was a sync request, which ends well within it.
|
|
572
|
+
done = self.wait_for_submission(answer["submission"], interval=1.0, timeout=self.timeout)
|
|
573
|
+
except NotFoundError as exc:
|
|
574
|
+
# The 202 named a submission that was then refused (a 400 or 429 leaves none behind).
|
|
575
|
+
raise OnlineCompilerError(REFUSED_ORIGINAL, exc.status, exc.body) from None
|
|
576
|
+
if done["status"] != "finished" or done["result"] is None:
|
|
577
|
+
raise OnlineCompilerError(done["error"] or f"Submission {done['submission']} was not judged", 202, done)
|
|
578
|
+
return {**done["result"], "submission": done["submission"], "receipt": done["receipt"]}
|
|
579
|
+
|
|
580
|
+
def _request(self, method: str, path: str, body: dict[str, Any] | None = None, *, auth: bool = True,
|
|
581
|
+
headers: dict[str, str] | None = None, secret: bool = False) -> Any:
|
|
582
|
+
"""secret: the answer holds a secret (a session token), so no error quotes it."""
|
|
583
|
+
sent = {"Accept": "application/json", "User-Agent": f"onlinecompiler-python/{__version__}", **(headers or {})}
|
|
584
|
+
if auth:
|
|
585
|
+
if not self.api_key:
|
|
586
|
+
raise AuthenticationError(
|
|
587
|
+
f"No API key: pass api_key or set ONLINECOMPILER_API_KEY. Get a free key at {SIGNUP_URL}")
|
|
588
|
+
sent["Authorization"] = self.api_key
|
|
589
|
+
data = None
|
|
590
|
+
if body is not None:
|
|
591
|
+
data = json.dumps(body).encode("utf-8")
|
|
592
|
+
sent["Content-Type"] = "application/json"
|
|
593
|
+
request = urllib.request.Request(self.base_url + path, data=data, headers=sent, method=method)
|
|
594
|
+
failed = ""
|
|
595
|
+
try:
|
|
596
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
597
|
+
raw = response.read()
|
|
598
|
+
except urllib.error.HTTPError as exc: # before URLError, which it subclasses
|
|
599
|
+
with exc: # closes the error response
|
|
600
|
+
try:
|
|
601
|
+
error_body = exc.read()
|
|
602
|
+
except (http.client.HTTPException, OSError):
|
|
603
|
+
error_body = b""
|
|
604
|
+
raise _error(exc.code, error_body) from None
|
|
605
|
+
# OSError covers timeouts; HTTPException a reply cut off (IncompleteRead) or malformed.
|
|
606
|
+
except (urllib.error.URLError, http.client.HTTPException, OSError) as exc:
|
|
607
|
+
if not secret:
|
|
608
|
+
raise OnlineCompilerError(f"Could not reach {self.base_url}: {exc}") from exc
|
|
609
|
+
# An IncompleteRead holds the partial body, which may hold the secret: name only its kind, and raise
|
|
610
|
+
# below, outside this block, so that it is not attached as context either.
|
|
611
|
+
failed = f"Could not reach {self.base_url}: {type(exc).__name__}"
|
|
612
|
+
if failed:
|
|
613
|
+
raise OnlineCompilerError(failed)
|
|
614
|
+
try:
|
|
615
|
+
return json.loads(raw)
|
|
616
|
+
except ValueError:
|
|
617
|
+
pass
|
|
618
|
+
# Raised outside the except block, so that no exception context holds the text either.
|
|
619
|
+
if secret:
|
|
620
|
+
del raw # not left in a frame that an error report could capture either
|
|
621
|
+
raise OnlineCompilerError(f"Expected JSON from {path}")
|
|
622
|
+
raise OnlineCompilerError(f"Expected JSON from {path}, got: {raw[:200]!r}")
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Checks that need no client and no network: webhook signatures, receipt signatures and the canonical JSON
|
|
2
|
+
receipts are signed over. Webhooks use only the standard library; receipts need the optional cryptography
|
|
3
|
+
package (pip install "onlinecompiler[receipts]")."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from hashlib import sha256
|
|
15
|
+
from typing import Any, Protocol, Union
|
|
16
|
+
|
|
17
|
+
RECEIPTS_EXTRA = 'pip install "onlinecompiler[receipts]"'
|
|
18
|
+
_TIMESTAMP = re.compile(r"[0-9]{1,15}")
|
|
19
|
+
_SIGNATURE = re.compile(r"[0-9a-f]{64}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _SupportsItems(Protocol):
|
|
23
|
+
def items(self) -> Iterable[tuple[Any, Any]]: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Request headers: anything with items(), as dicts and the request headers of Django, Flask (werkzeug), FastAPI and
|
|
27
|
+
# Starlette have, or (name, value) pairs as an ASGI scope holds them.
|
|
28
|
+
HeadersLike = Union[_SupportsItems, Iterable[tuple[Any, Any]]]
|
|
29
|
+
MAX_SIGNATURES = 8 # X-OC-Signature entries looked at; the API sends one, or two during a secret rotation
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Canonical JSON (RFC 8785 style), written to match the API's own (backend/judge/canonical.py) byte for byte:
|
|
33
|
+
# object keys sorted by their UTF-16 code units, no whitespace, non-ASCII text left as it is, and numbers as
|
|
34
|
+
# ECMAScript writes them.
|
|
35
|
+
|
|
36
|
+
def _number(x: float) -> str:
|
|
37
|
+
if x != x or x in (float("inf"), float("-inf")):
|
|
38
|
+
raise ValueError("NaN and Infinity cannot be canonicalized")
|
|
39
|
+
if x == 0:
|
|
40
|
+
return "0"
|
|
41
|
+
sign = "-" if x < 0 else ""
|
|
42
|
+
_sign, digits_t, exp = Decimal(repr(abs(x))).as_tuple()
|
|
43
|
+
digits = "".join(map(str, digits_t)).rstrip("0") or "0"
|
|
44
|
+
n = len(digits_t) + int(exp) # where the decimal point falls, counted from the first digit
|
|
45
|
+
k = len(digits)
|
|
46
|
+
if k <= n <= 21:
|
|
47
|
+
out = digits + "0" * (n - k)
|
|
48
|
+
elif 0 < n <= 21:
|
|
49
|
+
out = digits[:n] + "." + digits[n:]
|
|
50
|
+
elif -6 < n <= 0:
|
|
51
|
+
out = "0." + "0" * -n + digits
|
|
52
|
+
else:
|
|
53
|
+
e = n - 1
|
|
54
|
+
out = digits[0] + ("." + digits[1:] if k > 1 else "") + "e" + ("-" if e < 0 else "+") + str(abs(e))
|
|
55
|
+
return sign + out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _write(value: object, out: list[str]) -> None:
|
|
59
|
+
if value is None:
|
|
60
|
+
out.append("null")
|
|
61
|
+
elif value is True:
|
|
62
|
+
out.append("true")
|
|
63
|
+
elif value is False:
|
|
64
|
+
out.append("false")
|
|
65
|
+
elif isinstance(value, int):
|
|
66
|
+
out.append(str(value))
|
|
67
|
+
elif isinstance(value, float):
|
|
68
|
+
out.append(_number(value))
|
|
69
|
+
elif isinstance(value, str):
|
|
70
|
+
out.append(json.dumps(value, ensure_ascii=False))
|
|
71
|
+
elif isinstance(value, (list, tuple)):
|
|
72
|
+
out.append("[")
|
|
73
|
+
for i, item in enumerate(value):
|
|
74
|
+
if i:
|
|
75
|
+
out.append(",")
|
|
76
|
+
_write(item, out)
|
|
77
|
+
out.append("]")
|
|
78
|
+
elif isinstance(value, Mapping):
|
|
79
|
+
if not all(isinstance(key, str) for key in value):
|
|
80
|
+
raise TypeError("canonical JSON object keys must be strings")
|
|
81
|
+
out.append("{")
|
|
82
|
+
for i, key in enumerate(sorted(value, key=lambda k: k.encode("utf-16-be"))):
|
|
83
|
+
if i:
|
|
84
|
+
out.append(",")
|
|
85
|
+
out.append(json.dumps(key, ensure_ascii=False))
|
|
86
|
+
out.append(":")
|
|
87
|
+
_write(value[key], out)
|
|
88
|
+
out.append("}")
|
|
89
|
+
else:
|
|
90
|
+
raise TypeError(f"cannot canonicalize {type(value).__name__}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def canonical_json(value: Any) -> bytes:
|
|
94
|
+
"""The canonical JSON of value as UTF-8 bytes: what receipts are signed over, and what their tests and
|
|
95
|
+
files hashes are taken of. The same bytes as the API's own canonical form for any value the API signs.
|
|
96
|
+
Raises ValueError for NaN, Infinity or a string holding a lone surrogate (which the API never signs) and
|
|
97
|
+
TypeError for anything JSON cannot hold. Python ints of any size are written exactly; JavaScript cannot hold
|
|
98
|
+
integers beyond 2**53, so the Node client's canonical form differs from this one there."""
|
|
99
|
+
out: list[str] = []
|
|
100
|
+
_write(value, out)
|
|
101
|
+
return "".join(out).encode("utf-8")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Webhooks
|
|
105
|
+
|
|
106
|
+
def _text(value: Any) -> str | None:
|
|
107
|
+
if isinstance(value, (bytes, bytearray)):
|
|
108
|
+
return bytes(value).decode("latin-1")
|
|
109
|
+
if isinstance(value, (list, tuple)) and all(isinstance(item, str) for item in value):
|
|
110
|
+
return ",".join(value)
|
|
111
|
+
return value if isinstance(value, str) else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _header(headers: Any, name: str) -> str | None:
|
|
115
|
+
"""The first value of the header called name (lower case) in a mapping, or in pairs as an ASGI scope holds them."""
|
|
116
|
+
pairs: Iterable = headers.items() if hasattr(headers, "items") else headers
|
|
117
|
+
for key, value in pairs:
|
|
118
|
+
key = _text(key)
|
|
119
|
+
if key is not None and key.lower() == name:
|
|
120
|
+
return _text(value)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def verify_webhook(secret: str | Sequence[str], raw_body: bytes | str, headers: HeadersLike,
|
|
125
|
+
tolerance: float = 300) -> bool:
|
|
126
|
+
"""Whether a webhook delivery is genuine: True when X-OC-Signature holds a v1= HMAC-SHA256 of
|
|
127
|
+
"{X-OC-Timestamp}.{raw_body}" made with secret (the whole whsec_ string), and the timestamp is within
|
|
128
|
+
tolerance seconds (default 300) of now. Only the first 8 entries of X-OC-Signature are looked at.
|
|
129
|
+
|
|
130
|
+
raw_body is the request body exactly as received, before any JSON parsing (bytes; a str is encoded as
|
|
131
|
+
UTF-8). headers is the request's headers (Django's, Flask's or FastAPI's request.headers, a dict, or
|
|
132
|
+
(name, value) pairs), matched case-insensitively. secret may be a list, for while you rotate it on your side; during a rotation on
|
|
133
|
+
ours the header holds two signatures, and one match is enough. Compared in constant time. Any missing,
|
|
134
|
+
malformed or wrong input returns False; it never raises."""
|
|
135
|
+
try:
|
|
136
|
+
given: list[object] = [secret] if isinstance(secret, str) else list(secret)
|
|
137
|
+
secrets = [item for item in given if isinstance(item, str) and item]
|
|
138
|
+
if not secrets or len(secrets) != len(given):
|
|
139
|
+
return False
|
|
140
|
+
if isinstance(raw_body, str):
|
|
141
|
+
raw_body = raw_body.encode("utf-8")
|
|
142
|
+
if not isinstance(raw_body, (bytes, bytearray, memoryview)):
|
|
143
|
+
return False
|
|
144
|
+
timestamp = _header(headers, "x-oc-timestamp")
|
|
145
|
+
signature = _header(headers, "x-oc-signature")
|
|
146
|
+
if timestamp is None or signature is None or not _TIMESTAMP.fullmatch(timestamp):
|
|
147
|
+
return False
|
|
148
|
+
if not abs(time.time() - int(timestamp)) <= tolerance: # written so that a NaN tolerance fails
|
|
149
|
+
return False
|
|
150
|
+
entries = [value.strip() for value in signature.split(",", MAX_SIGNATURES)[:MAX_SIGNATURES]]
|
|
151
|
+
values = [value[3:] for value in entries if value.startswith("v1=")]
|
|
152
|
+
sent = [value.encode("ascii") for value in values if _SIGNATURE.fullmatch(value)]
|
|
153
|
+
message = timestamp.encode("ascii") + b"." + bytes(raw_body)
|
|
154
|
+
matched = False
|
|
155
|
+
for item in secrets:
|
|
156
|
+
expected = hmac.new(item.encode("utf-8"), message, sha256).hexdigest().encode("ascii")
|
|
157
|
+
for value in sent:
|
|
158
|
+
matched |= hmac.compare_digest(expected, value)
|
|
159
|
+
return matched
|
|
160
|
+
except Exception: # bad input of any kind is a failed check, never an error
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# Receipts
|
|
165
|
+
|
|
166
|
+
def _ed25519():
|
|
167
|
+
try:
|
|
168
|
+
from cryptography.exceptions import InvalidSignature
|
|
169
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
170
|
+
except ImportError:
|
|
171
|
+
raise ModuleNotFoundError(f"Verifying receipts needs the cryptography package: {RECEIPTS_EXTRA}",
|
|
172
|
+
name="cryptography") from None
|
|
173
|
+
return Ed25519PublicKey, InvalidSignature
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _b64(value: Any) -> bytes:
|
|
177
|
+
if not isinstance(value, str):
|
|
178
|
+
raise TypeError("expected base64 text")
|
|
179
|
+
return base64.b64decode(value, validate=True)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def verify_receipt(receipt: Mapping[str, Any] | None,
|
|
183
|
+
keys: Sequence[Mapping[str, Any]] | Mapping[str, Any] | None) -> bool:
|
|
184
|
+
"""Whether a receipt's Ed25519 signature is valid: over the canonical JSON of every field but signature, with
|
|
185
|
+
the key the receipt names in key_id. keys is the published key list, as client.receipt_keys() returns it or the
|
|
186
|
+
whole {"keys": [...]} document from https://api.onlinecompiler.io/.well-known/receipt-keys.json. No network.
|
|
187
|
+
|
|
188
|
+
Returns False for a changed field, an unknown key_id, a malformed signature, None (a submission without a
|
|
189
|
+
receipt) or any other bad input. Needs the cryptography package: without it this raises ModuleNotFoundError
|
|
190
|
+
saying what to install."""
|
|
191
|
+
public_key_class, _invalid_signature = _ed25519() # outside the try, so a missing extra is never a False
|
|
192
|
+
try:
|
|
193
|
+
if isinstance(keys, Mapping):
|
|
194
|
+
keys = keys["keys"]
|
|
195
|
+
if not isinstance(receipt, Mapping) or keys is None or isinstance(keys, (str, bytes)):
|
|
196
|
+
return False
|
|
197
|
+
key_id = receipt["key_id"]
|
|
198
|
+
signature = _b64(receipt["signature"])
|
|
199
|
+
entry = next((key for key in keys if isinstance(key, Mapping) and key.get("key_id") == key_id), None)
|
|
200
|
+
if entry is None or entry.get("algorithm", "Ed25519") != "Ed25519":
|
|
201
|
+
return False
|
|
202
|
+
message = canonical_json({name: value for name, value in receipt.items() if name != "signature"})
|
|
203
|
+
public_key_class.from_public_bytes(_b64(entry["public_key"])).verify(signature, message)
|
|
204
|
+
except Exception: # InvalidSignature, and any bad input (hostile nesting deep enough for RecursionError too)
|
|
205
|
+
return False
|
|
206
|
+
return True # only once verify() has passed
|
onlinecompiler/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: onlinecompiler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the OnlineCompiler.io code execution API: run and judge code in 46 languages in a sandbox
|
|
5
|
+
Project-URL: Homepage, https://onlinecompiler.io
|
|
6
|
+
Project-URL: Documentation, https://onlinecompiler.io/docs
|
|
7
|
+
Project-URL: Pricing, https://onlinecompiler.io/pricing
|
|
8
|
+
Project-URL: Changelog, https://onlinecompiler.io/changelog
|
|
9
|
+
Author: RunCode Inc.
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: code execution,code judge,compiler api,judge0,online compiler,online judge,onlinecompiler,sandbox,signed receipts,webhooks
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Software Development :: Compilers
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Provides-Extra: receipts
|
|
21
|
+
Requires-Dist: cryptography>=42; extra == 'receipts'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# OnlineCompiler.io Python client
|
|
25
|
+
|
|
26
|
+
Run code in 46 languages from Python, or judge it against test cases, through the
|
|
27
|
+
[OnlineCompiler.io](https://onlinecompiler.io/?utm_source=pypi) code execution API. Every run gets a
|
|
28
|
+
fresh gVisor sandbox with no network, 512 MB of memory and a 30-second limit. Free up to 100,000 runs
|
|
29
|
+
a month, commercial use included.
|
|
30
|
+
|
|
31
|
+
No dependencies beyond the standard library. Python 3.10 or newer. Verifying signed receipts needs
|
|
32
|
+
one optional extra, `pip install "onlinecompiler[receipts]"` (it adds `cryptography`).
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install onlinecompiler
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Get a free API key at https://api.onlinecompiler.io/compilers/api-keys/ and set it as
|
|
39
|
+
`ONLINECOMPILER_API_KEY`, or pass it to the client.
|
|
40
|
+
|
|
41
|
+
## Run a program
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from onlinecompiler import OnlineCompiler
|
|
45
|
+
|
|
46
|
+
client = OnlineCompiler() # reads ONLINECOMPILER_API_KEY
|
|
47
|
+
|
|
48
|
+
result = client.run("python-3.14", "name = input()\nprint(f'Hello, {name}!')", input="Ada")
|
|
49
|
+
print(result["output"]) # Hello, Ada!
|
|
50
|
+
print(result["exit_code"], result["time"], result["memory"])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The result has `output` (stdout), `error` (stderr or compiler errors), `exit_code`, `signal`, `time`
|
|
54
|
+
and `total` (seconds), `memory` (KB), `truncated`, `time_limit` and `files`. `time`, `total` and
|
|
55
|
+
`memory` are decimal strings, as the API sends them. Compiler ids such as `gcc-15`, `openjdk-25` and
|
|
56
|
+
`nodejs-24` come from `client.compilers()`. Pass `time_limit` (seconds, up to 30) to stop a run sooner.
|
|
57
|
+
|
|
58
|
+
## Judge a solution against test cases
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
result = client.run_tests(
|
|
62
|
+
"python-3.14",
|
|
63
|
+
"a, b = map(int, input().split())\nprint(a + b)",
|
|
64
|
+
tests=[
|
|
65
|
+
{"input": "1 2", "expected_output": "3"},
|
|
66
|
+
{"input": "10 20", "expected_output": "30", "hidden": True},
|
|
67
|
+
],
|
|
68
|
+
)
|
|
69
|
+
print(result["verdict"], result["score"], f"{result['passed']}/{result['total']}")
|
|
70
|
+
for case in result["tests"]:
|
|
71
|
+
print(case["verdict"])
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Each test counts as one run. Hidden tests return only their verdict. `checker` picks how output is
|
|
75
|
+
compared (`trim` by default, `exact`, `tokens`, `float` or `rows`), `scoring` is `all_or_nothing`
|
|
76
|
+
(default), `percent` or `weighted` (with a `weight` per test), and `time_limit` is seconds per test
|
|
77
|
+
(default 2). See the [run-tests docs](https://onlinecompiler.io/docs/run-tests?utm_source=pypi).
|
|
78
|
+
|
|
79
|
+
Every run-tests result also has `summary` (the result in plain words), `submission` (its id) and a
|
|
80
|
+
signed `receipt`.
|
|
81
|
+
|
|
82
|
+
## Judge in the background
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
queued = client.run_tests(
|
|
86
|
+
"python-3.14",
|
|
87
|
+
"a, b = map(int, input().split())\nprint(a + b)",
|
|
88
|
+
tests=[{"input": "1 2", "expected_output": "3"}, {"input": "10 20", "expected_output": "30", "hidden": True}],
|
|
89
|
+
async_=True,
|
|
90
|
+
external_ref="attempt-4821",
|
|
91
|
+
idempotency_key="attempt-4821",
|
|
92
|
+
)
|
|
93
|
+
done = client.wait_for_submission(queued["submission"]) # looks it up every 2 seconds
|
|
94
|
+
result = done["result"] # None when it failed, which means it was not judged
|
|
95
|
+
if result is not None:
|
|
96
|
+
print(result["verdict"], result["score"])
|
|
97
|
+
else:
|
|
98
|
+
print("Not judged:", done["error"]) # send it again
|
|
99
|
+
client.delete_submission(done["submission"]) # optional: drop the stored code and output
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`async_=True` (`async` is a Python keyword) answers at once with the submission id, and the submission
|
|
103
|
+
waits in your account's queue instead of getting 429 in a burst. Add `callback_url` to have the result
|
|
104
|
+
POSTed to your server as a signed webhook (see below); it needs `async_=True`, and the client refuses it
|
|
105
|
+
without. `external_ref` is your own id, signed into the receipt; `metadata` is any JSON object, returned in
|
|
106
|
+
lookups and webhooks. `idempotency_key` (1 to 100 printable ASCII characters, checked before sending) is
|
|
107
|
+
sent as the `Idempotency-Key` header, so a retry after a timeout answers with the original result instead
|
|
108
|
+
of running again. `client.submission(id)` looks a submission up once.
|
|
109
|
+
|
|
110
|
+
## Judge against a stored problem
|
|
111
|
+
|
|
112
|
+
A stored problem keeps its tests, checker and limits on the server, so you send only the candidate's
|
|
113
|
+
code and the hidden tests never leave it.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
client.create_problem({
|
|
117
|
+
"title": "Sum of two numbers",
|
|
118
|
+
"slug": "sum",
|
|
119
|
+
"languages": ["python-3.14"],
|
|
120
|
+
"tests": [
|
|
121
|
+
{"name": "sample", "input": "1 2\n", "expected_output": "3\n", "hidden": False},
|
|
122
|
+
{"input": "5 7\n", "expected_output": "12\n"}, # tests are hidden unless you say otherwise
|
|
123
|
+
],
|
|
124
|
+
})
|
|
125
|
+
client.publish_problem("sum") # no reference solution, so it is published at once
|
|
126
|
+
|
|
127
|
+
result = client.judge("sum", "python-3.14", "a, b = map(int, input().split())\nprint(a + b)")
|
|
128
|
+
print(result["verdict"], result["score"], result["problem"], result["version"])
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`judge` takes the problem's id (`prob_...`) or your slug for it. `mode="samples"` judges only the
|
|
132
|
+
visible tests, and `async_`, `callback_url`, `external_ref`, `metadata` and `idempotency_key` work as
|
|
133
|
+
above. Problems are plain dicts, as the
|
|
134
|
+
[stored problems docs](https://onlinecompiler.io/docs/problems?utm_source=pypi) describe them:
|
|
135
|
+
`problems()`, `problem()`, `create_problem()`, `update_problem()`, `replace_draft()`,
|
|
136
|
+
`publish_problem()`, `archive_problem()`, `restore_problem()` and `public_problem()`.
|
|
137
|
+
|
|
138
|
+
## Sessions for the widget
|
|
139
|
+
|
|
140
|
+
A session lets one candidate's [embedded editor](https://onlinecompiler.io/docs/widget?utm_source=pypi)
|
|
141
|
+
Submit to a stored problem without your API key ever reaching the browser. Create it on your server and
|
|
142
|
+
put only its token in the page:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
problem_id = client.problem("sum")["problem"] # the widget takes the id, never the slug
|
|
146
|
+
session = client.create_session(problem_id, "attempt-4821", max_submits=2, expires_in=3600)
|
|
147
|
+
|
|
148
|
+
html = (f'<div class="runcode" data-key="YOUR_WIDGET_KEY" data-problem="{problem_id}" '
|
|
149
|
+
f'data-session="{session["token"]}"></div>\n'
|
|
150
|
+
'<script src="https://onlinecompiler.io/widget.js"></script>')
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The token is shown only this once and the client never logs it or puts it in an error, so keep it out
|
|
154
|
+
of your own logs too. `external_ref` should be an opaque id: the candidate's page sees it. Record the
|
|
155
|
+
score from the webhook or `client.submission(id)`, never from the browser.
|
|
156
|
+
`client.session(problem_id, session["session"])` shows the Submits used and the submission ids, and
|
|
157
|
+
`client.revoke_session(problem_id, session["session"])` ends it at once.
|
|
158
|
+
|
|
159
|
+
## Verify webhooks
|
|
160
|
+
|
|
161
|
+
An async submission with a `callback_url` (or a callback URL on the API key) gets one signed
|
|
162
|
+
`submission.finished` event. Check it before you trust it.
|
|
163
|
+
|
|
164
|
+
Verify the raw request body exactly as received: a body that was parsed and serialized again never
|
|
165
|
+
matches. In Flask that is `request.get_data()`, in Django `request.body`, in FastAPI
|
|
166
|
+
`await request.body()`.
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
import os
|
|
170
|
+
|
|
171
|
+
from flask import Flask, abort, request
|
|
172
|
+
from onlinecompiler import verify_webhook
|
|
173
|
+
|
|
174
|
+
app = Flask(__name__)
|
|
175
|
+
SECRET = os.environ["ONLINECOMPILER_WEBHOOK_SECRET"] # whsec_..., shown with the API key in your dashboard
|
|
176
|
+
|
|
177
|
+
@app.post("/hooks/oc")
|
|
178
|
+
def webhook():
|
|
179
|
+
if not verify_webhook(SECRET, request.get_data(), request.headers):
|
|
180
|
+
abort(400)
|
|
181
|
+
event = request.get_json()
|
|
182
|
+
print(event["submission"], event["status"], event["result"] and event["result"]["verdict"])
|
|
183
|
+
return "", 204 # answer quickly; deduplicate on event["delivery"]
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`verify_webhook(secret, raw_body, headers, tolerance=300)` needs no client and no network. It takes any
|
|
187
|
+
mapping of headers (Django's, Flask's, FastAPI's or a dict), compares in constant time, accepts either
|
|
188
|
+
signature while a secret is being rotated, takes a list of secrets while you rotate yours, and rejects a
|
|
189
|
+
timestamp more than `tolerance` seconds (default 300) from now. Anything missing or malformed returns
|
|
190
|
+
`False`. See the [webhooks docs](https://onlinecompiler.io/docs/webhooks?utm_source=pypi).
|
|
191
|
+
|
|
192
|
+
## Verify receipts
|
|
193
|
+
|
|
194
|
+
Every judged submission comes with a receipt signed with Ed25519: proof of what ran, against which
|
|
195
|
+
tests, and what came out. Anyone can check it with the published keys.
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
pip install "onlinecompiler[receipts]"
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
from onlinecompiler import verify_receipt
|
|
203
|
+
|
|
204
|
+
receipt = client.submission("sub_8f2Kq7LmT3xR9vWb1cYd4hNe")["receipt"]
|
|
205
|
+
print(client.verify_receipt(receipt)) # fetches the published keys once, no API key needed
|
|
206
|
+
|
|
207
|
+
keys = client.receipt_keys() # or save them and verify offline later
|
|
208
|
+
print(verify_receipt(receipt, keys)) # False once any field has been changed
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Both return `False` for `None`, which a submission that was not judged has as its receipt. Without the
|
|
212
|
+
extra, verifying raises an error that says what to install. Store and deduplicate receipts by their
|
|
213
|
+
`submission` id (or the whole receipt), not by the signature string. `canonical_json(value)` returns
|
|
214
|
+
the canonical bytes receipts are signed over, for reproducing their hashes as the
|
|
215
|
+
[receipts docs](https://onlinecompiler.io/docs/receipts?utm_source=pypi) describe.
|
|
216
|
+
|
|
217
|
+
## Files in and out
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
import base64
|
|
221
|
+
|
|
222
|
+
code = """
|
|
223
|
+
import pandas as pd
|
|
224
|
+
import matplotlib.pyplot as plt
|
|
225
|
+
df = pd.read_csv("sales.csv")
|
|
226
|
+
df.plot(x="month", y="units")
|
|
227
|
+
plt.show() # saved as figure-1.png in $OUTPUT_DIR
|
|
228
|
+
"""
|
|
229
|
+
result = client.run("python-3.14", code, files=[{"name": "sales.csv", "content": open("sales.csv").read()}])
|
|
230
|
+
for f in result["files"]:
|
|
231
|
+
with open(f["name"], "wb") as out:
|
|
232
|
+
out.write(base64.b64decode(f["content_base64"]))
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Input files sit in the program's working directory; send binary ones with `"encoding": "base64"`.
|
|
236
|
+
Files the program writes to `$OUTPUT_DIR` come back in `files`.
|
|
237
|
+
|
|
238
|
+
## Queue runs and get results by callback
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
queued = client.submit("g++-15", source, input="3\n", extra_params={"submission_id": 981})
|
|
242
|
+
print(queued["id"])
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`submit` returns at once. When the run finishes, its result is POSTed to the callback URL set on your
|
|
246
|
+
API key in the dashboard, with your `extra_params` echoed back.
|
|
247
|
+
|
|
248
|
+
## Other calls
|
|
249
|
+
|
|
250
|
+
- `client.compilers()`: every compiler id and language name (no key needed).
|
|
251
|
+
- `client.usage()`: this month's runs and what's left, for the account and for the key.
|
|
252
|
+
- `client.limits()`: per-run limits and default account limits (no key needed).
|
|
253
|
+
|
|
254
|
+
## Errors
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
from onlinecompiler import OnlineCompilerError, RateLimitError
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
client.run("python-3.14", "print(1)")
|
|
261
|
+
except RateLimitError as e: # HTTP 429
|
|
262
|
+
print(e.message) # which limit was hit, and how to raise it
|
|
263
|
+
except OnlineCompilerError as e: # anything else, including AuthenticationError
|
|
264
|
+
print(e.status, e.message) # status is 0 when the API could not be reached
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`AuthenticationError` covers a missing, unknown or unauthorized key; `NotFoundError` a submission,
|
|
268
|
+
problem or session that does not exist or is not yours. A conflict, such as an archived problem or an
|
|
269
|
+
`Idempotency-Key` reused with another body, is an `OnlineCompilerError` with status 409 and the API's
|
|
270
|
+
message.
|
|
271
|
+
|
|
272
|
+
The client never retries a request by itself, because every run the API accepts counts toward your
|
|
273
|
+
allowance. `OnlineCompiler(api_key=None, *, base_url="https://api.onlinecompiler.io", timeout=90.0)`
|
|
274
|
+
takes the key, the API address and a timeout in seconds.
|
|
275
|
+
|
|
276
|
+
## Links
|
|
277
|
+
|
|
278
|
+
[Docs](https://onlinecompiler.io/docs?utm_source=pypi) ·
|
|
279
|
+
[Languages](https://onlinecompiler.io/languages?utm_source=pypi) ·
|
|
280
|
+
[Limits](https://onlinecompiler.io/docs/limits?utm_source=pypi) ·
|
|
281
|
+
[Pricing](https://onlinecompiler.io/pricing?utm_source=pypi) ·
|
|
282
|
+
[Security](https://onlinecompiler.io/security?utm_source=pypi) ·
|
|
283
|
+
[Changelog](https://onlinecompiler.io/changelog?utm_source=pypi)
|
|
284
|
+
|
|
285
|
+
MIT licensed. OnlineCompiler.io is a product of RunCode Inc.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
onlinecompiler/__init__.py,sha256=MLAxBiuLqizMyRK_qewa_pN7azVU0Eg4w1vOgmE4MpM,1329
|
|
2
|
+
onlinecompiler/_client.py,sha256=o6xLIS2EFXVFFJjymRdGlf2BfWCVcM02BdSMJQ98UMo,28897
|
|
3
|
+
onlinecompiler/_verify.py,sha256=tEVxpKtFRsAHzD85pFyQ6YQOOX96_maqEc4FXvVMA70,9522
|
|
4
|
+
onlinecompiler/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
onlinecompiler-0.1.0.dist-info/METADATA,sha256=RJoUm1uPP_KIyvG40crStoFpOmZZ-gzyNVrzy9Kjz4s,12526
|
|
6
|
+
onlinecompiler-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
7
|
+
onlinecompiler-0.1.0.dist-info/licenses/LICENSE,sha256=ewJ8Ho-t36T048PVcYWDREgjhelKO_i5q2cH8pafvU0,1069
|
|
8
|
+
onlinecompiler-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RunCode Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|