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.
@@ -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
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.