rbx.cp 0.13.8__py3-none-any.whl → 0.16.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.
- rbx/__version__.py +1 -0
- rbx/box/cli.py +74 -70
- rbx/box/code.py +3 -0
- rbx/box/contest/build_contest_statements.py +65 -23
- rbx/box/contest/contest_package.py +8 -1
- rbx/box/contest/main.py +9 -3
- rbx/box/contest/schema.py +17 -13
- rbx/box/contest/statements.py +12 -8
- rbx/box/dump_schemas.py +2 -1
- rbx/box/environment.py +1 -1
- rbx/box/fields.py +22 -4
- rbx/box/generators.py +32 -13
- rbx/box/git_utils.py +29 -1
- rbx/box/limits_info.py +161 -0
- rbx/box/package.py +18 -1
- rbx/box/packaging/boca/boca_language_utils.py +26 -0
- rbx/box/packaging/boca/boca_outcome_utils.py +10 -0
- rbx/box/packaging/boca/packager.py +7 -5
- rbx/box/packaging/contest_main.py +20 -12
- rbx/box/packaging/packager.py +24 -14
- rbx/box/packaging/polygon/packager.py +7 -3
- rbx/box/packaging/polygon/upload.py +2 -1
- rbx/box/presets/__init__.py +143 -78
- rbx/box/presets/fetch.py +10 -2
- rbx/box/presets/schema.py +16 -1
- rbx/box/remote.py +3 -3
- rbx/box/sanitizers/issue_stack.py +124 -0
- rbx/box/schema.py +87 -27
- rbx/box/solutions.py +74 -117
- rbx/box/statements/build_statements.py +12 -1
- rbx/box/statements/builders.py +5 -3
- rbx/box/statements/latex_jinja.py +73 -23
- rbx/box/statements/schema.py +7 -9
- rbx/box/stressing/generator_parser.py +3 -1
- rbx/box/tasks.py +10 -10
- rbx/box/testcase_extractors.py +8 -0
- rbx/box/testing/testing_preset.py +129 -2
- rbx/box/testing/testing_shared.py +3 -1
- rbx/box/timing.py +305 -0
- rbx/box/tooling/boca/debug_utils.py +88 -0
- rbx/box/tooling/boca/manual_scrape.py +20 -0
- rbx/box/tooling/boca/scraper.py +660 -57
- rbx/box/unit.py +0 -2
- rbx/box/validators.py +0 -4
- rbx/grading/judge/cacher.py +36 -0
- rbx/grading/judge/program.py +12 -2
- rbx/grading/judge/sandbox.py +1 -1
- rbx/grading/judge/sandboxes/stupid_sandbox.py +2 -1
- rbx/grading/judge/storage.py +36 -3
- rbx/grading/limits.py +4 -0
- rbx/grading/steps.py +3 -2
- rbx/resources/presets/default/contest/contest.rbx.yml +7 -1
- rbx/resources/presets/default/contest/statement/info.rbx.tex +46 -0
- rbx/resources/presets/default/preset.rbx.yml +1 -0
- rbx/resources/presets/default/problem/.gitignore +1 -0
- rbx/resources/presets/default/problem/problem.rbx.yml +19 -3
- rbx/resources/presets/default/problem/rbx.h +52 -5
- rbx/resources/presets/default/problem/statement/statement.rbx.tex +6 -2
- rbx/resources/presets/default/problem/testlib.h +6299 -0
- rbx/resources/presets/default/problem/validator.cpp +4 -3
- rbx/resources/presets/default/shared/contest_template.rbx.tex +8 -4
- rbx/resources/presets/default/shared/icpc.sty +18 -3
- rbx/resources/presets/default/shared/problem_template.rbx.tex +4 -1
- rbx/testing_utils.py +17 -1
- rbx/utils.py +45 -0
- {rbx_cp-0.13.8.dist-info → rbx_cp-0.16.0.dist-info}/METADATA +5 -2
- {rbx_cp-0.13.8.dist-info → rbx_cp-0.16.0.dist-info}/RECORD +71 -67
- {rbx_cp-0.13.8.dist-info → rbx_cp-0.16.0.dist-info}/entry_points.txt +0 -1
- rbx/providers/__init__.py +0 -43
- rbx/providers/codeforces.py +0 -73
- rbx/providers/provider.py +0 -26
- rbx/submitors/__init__.py +0 -18
- rbx/submitors/codeforces.py +0 -121
- rbx/submitors/submitor.py +0 -25
- /rbx/resources/presets/default/problem/sols/{wa.cpp → wa-overflow.cpp} +0 -0
- {rbx_cp-0.13.8.dist-info → rbx_cp-0.16.0.dist-info}/LICENSE +0 -0
- {rbx_cp-0.13.8.dist-info → rbx_cp-0.16.0.dist-info}/WHEEL +0 -0
rbx/box/tooling/boca/scraper.py
CHANGED
@@ -5,20 +5,27 @@ import os
|
|
5
5
|
import pathlib
|
6
6
|
import re
|
7
7
|
import shutil
|
8
|
+
import time
|
8
9
|
import typing
|
9
|
-
from typing import Any, List, NoReturn, Optional, Tuple
|
10
|
+
from typing import Any, Callable, List, NoReturn, Optional, Tuple, Union
|
10
11
|
|
11
12
|
import dateparser
|
12
13
|
import mechanize
|
13
14
|
import typer
|
14
|
-
from bs4 import BeautifulSoup
|
15
|
+
from bs4 import BeautifulSoup, Tag
|
15
16
|
from pydantic import BaseModel
|
17
|
+
from throttlex import Throttler
|
16
18
|
|
17
19
|
from rbx import console
|
18
20
|
from rbx.box import naming
|
21
|
+
from rbx.box.tooling.boca.debug_utils import pretty_print_request_data
|
19
22
|
from rbx.grading.steps import Outcome
|
20
23
|
|
21
24
|
ALERT_REGEX = re.compile(r'\<script[^\>]*\>\s*alert\(\'([^\']+)\'\);?\s*\<\/script\>')
|
25
|
+
REDIRECT_REGEX = re.compile(
|
26
|
+
r'\<script[^\>]*\>\s*document\.location\s*=\s*\'([^\']+)\'\;\s*\<\/script\>'
|
27
|
+
)
|
28
|
+
START_DATE_REGEX = re.compile(r'Start date\s*\(contest\=([^\)]+)\)')
|
22
29
|
UPLOAD_LOG_REGEX = re.compile(r'Problem (\d+) \([^\)]+\) updated')
|
23
30
|
|
24
31
|
|
@@ -47,15 +54,57 @@ def _parse_answer_as_outcome(answer: str) -> Optional[Outcome]:
|
|
47
54
|
return None
|
48
55
|
|
49
56
|
|
57
|
+
def _big_hex_sum(hex1: str, hex2: str) -> str:
|
58
|
+
return f'{int(hex1, 16) + int(hex2, 16):x}'
|
59
|
+
|
60
|
+
|
61
|
+
class BocaProblem(BaseModel):
|
62
|
+
index: int
|
63
|
+
shortname: str
|
64
|
+
fullname: str
|
65
|
+
basename: str
|
66
|
+
color: str
|
67
|
+
color_name: str
|
68
|
+
descfile_url: str
|
69
|
+
package_url: str
|
70
|
+
package_hash: str
|
71
|
+
|
72
|
+
|
73
|
+
class BocaLanguage(BaseModel):
|
74
|
+
index: int
|
75
|
+
name: str
|
76
|
+
extension: str
|
77
|
+
|
78
|
+
|
50
79
|
class BocaRun(BaseModel):
|
51
80
|
run_number: int
|
52
81
|
site_number: int
|
53
82
|
problem_shortname: str
|
54
|
-
outcome: Outcome
|
83
|
+
outcome: Optional[Outcome]
|
55
84
|
time: int
|
85
|
+
status: str
|
56
86
|
|
57
87
|
user: Optional[str] = None
|
58
88
|
|
89
|
+
@classmethod
|
90
|
+
def from_run_number(cls, run_number: int, site_number: int):
|
91
|
+
return cls(
|
92
|
+
run_number=run_number,
|
93
|
+
site_number=site_number,
|
94
|
+
problem_shortname='',
|
95
|
+
outcome=None,
|
96
|
+
time=0,
|
97
|
+
status='',
|
98
|
+
)
|
99
|
+
|
100
|
+
|
101
|
+
class BocaDetailedRun(BocaRun):
|
102
|
+
language_repr: str
|
103
|
+
code: str
|
104
|
+
filename: pathlib.Path
|
105
|
+
|
106
|
+
autojudge_answer: str
|
107
|
+
|
59
108
|
|
60
109
|
class BocaScraper:
|
61
110
|
def __init__(
|
@@ -63,13 +112,17 @@ class BocaScraper:
|
|
63
112
|
base_url: Optional[str] = None,
|
64
113
|
username: Optional[str] = None,
|
65
114
|
password: Optional[str] = None,
|
115
|
+
throttler: Optional[Throttler] = None,
|
116
|
+
verbose: bool = False,
|
117
|
+
is_judge: bool = False,
|
66
118
|
):
|
67
119
|
self.base_url = _parse_env_var('BOCA_BASE_URL', base_url)
|
68
120
|
self.username = _parse_env_var('BOCA_USERNAME', username)
|
69
121
|
self.password = _parse_env_var('BOCA_PASSWORD', password)
|
70
|
-
|
122
|
+
self.verbose = verbose
|
71
123
|
self.loggedIn = False
|
72
|
-
|
124
|
+
self.is_judge = is_judge
|
125
|
+
self.throttler = throttler or Throttler(max_req=1, period=1)
|
73
126
|
self.br = mechanize.Browser()
|
74
127
|
self.br.set_handle_robots(False)
|
75
128
|
self.br.addheaders = [ # type: ignore
|
@@ -79,6 +132,10 @@ class BocaScraper:
|
|
79
132
|
)
|
80
133
|
]
|
81
134
|
|
135
|
+
def log(self, message: str):
|
136
|
+
if self.verbose:
|
137
|
+
console.console.print(message)
|
138
|
+
|
82
139
|
def error(self, message: str) -> NoReturn:
|
83
140
|
console.console.print(
|
84
141
|
f'[error]{message} (at [item]{self.base_url}[/item])[/error]',
|
@@ -89,17 +146,38 @@ class BocaScraper:
|
|
89
146
|
console.console.print(f'[error]{message}[/error]')
|
90
147
|
raise typer.Exit(1)
|
91
148
|
|
92
|
-
def
|
149
|
+
def pretty_print(self, html: str):
|
150
|
+
soup = BeautifulSoup(html, 'html.parser')
|
151
|
+
console.console.print(soup.prettify())
|
152
|
+
|
153
|
+
def get_redirect(self, html: str) -> Optional[str]:
|
154
|
+
redirect = REDIRECT_REGEX.search(html)
|
155
|
+
if redirect is None:
|
156
|
+
return None
|
157
|
+
return redirect.group(1)
|
158
|
+
|
159
|
+
def get_alert(self, html: str) -> Optional[str]:
|
160
|
+
alert = ALERT_REGEX.search(html)
|
161
|
+
if alert is None:
|
162
|
+
return None
|
163
|
+
return alert.group(1)
|
164
|
+
|
165
|
+
def log_response_alert(
|
166
|
+
self,
|
167
|
+
response: Any,
|
168
|
+
message: str,
|
169
|
+
alert_ok_fn: Optional[Callable[[str], bool]] = None,
|
170
|
+
) -> Tuple[Any, str]:
|
93
171
|
if response is None:
|
94
172
|
self.raw_error(
|
95
173
|
f'{message} ([item]{self.base_url}[/item]):\nNo response received.'
|
96
174
|
)
|
97
175
|
html = response.read().decode()
|
98
|
-
alert =
|
176
|
+
alert = self.get_alert(html)
|
99
177
|
if alert:
|
100
|
-
|
101
|
-
|
102
|
-
)
|
178
|
+
if alert_ok_fn is not None and alert_ok_fn(alert):
|
179
|
+
return response, html
|
180
|
+
self.raw_error(f'{message} ([item]{self.base_url}[/item]):\n{alert}')
|
103
181
|
return response, html
|
104
182
|
|
105
183
|
def check_logs_for_update(self, problem_id: int) -> bool:
|
@@ -122,7 +200,9 @@ class BocaScraper:
|
|
122
200
|
continue
|
123
201
|
date = date_cell[0].text.strip()
|
124
202
|
|
125
|
-
parsed_date = dateparser.parse(
|
203
|
+
parsed_date = dateparser.parse(
|
204
|
+
date, settings={'TO_TIMEZONE': 'UTC', 'RETURN_AS_TIMEZONE_AWARE': True}
|
205
|
+
)
|
126
206
|
if parsed_date is None:
|
127
207
|
console.console.print(
|
128
208
|
f'Error while checking whether package upload was successful:\nCould not parse date [item]{date}[/item].'
|
@@ -152,22 +232,59 @@ class BocaScraper:
|
|
152
232
|
'Error while submitting problem to BOCA website:\nNo response received.'
|
153
233
|
)
|
154
234
|
html = response.read().decode()
|
155
|
-
alert =
|
235
|
+
alert = self.get_alert(html)
|
156
236
|
if alert:
|
157
|
-
|
158
|
-
if 'Violation' in msg:
|
237
|
+
if 'Violation' in alert:
|
159
238
|
return False
|
239
|
+
self.raw_error(f'Error while submitting problem to BOCA website:\n{alert}')
|
240
|
+
|
241
|
+
redirect = self.get_redirect(html)
|
242
|
+
if redirect is None:
|
160
243
|
self.raw_error(
|
161
|
-
|
244
|
+
'Error while submitting problem to BOCA website:\nNo redirect found after upload.'
|
162
245
|
)
|
246
|
+
_, html = self.open(
|
247
|
+
redirect, error_msg='Error while freeing BOCA problems after upload'
|
248
|
+
)
|
163
249
|
return self.check_logs_for_update(problem_id)
|
164
250
|
|
165
|
-
def open(
|
251
|
+
def open(
|
252
|
+
self,
|
253
|
+
url_or_request: Union[str, mechanize.Request],
|
254
|
+
*args,
|
255
|
+
error_msg: Optional[str] = None,
|
256
|
+
**kwargs,
|
257
|
+
):
|
258
|
+
url_or_request = self.throttler.throttle(url_or_request)
|
259
|
+
if isinstance(url_or_request, mechanize.Request):
|
260
|
+
url = url_or_request.get_full_url()
|
261
|
+
else:
|
262
|
+
url = url_or_request
|
166
263
|
if error_msg is None:
|
167
264
|
error_msg = f'Error while opening [item]{url}[/item]'
|
168
|
-
response = self.br.open(
|
265
|
+
response = self.br.open(url_or_request, *args, **kwargs)
|
169
266
|
return self.log_response_alert(response, error_msg)
|
170
267
|
|
268
|
+
def hash_single(self, password: str) -> str:
|
269
|
+
pwd_hash = hashlib.sha256(password.encode()).hexdigest()
|
270
|
+
return pwd_hash
|
271
|
+
|
272
|
+
def hash(self, password: str, salt: str) -> str:
|
273
|
+
pwd_hash = self.hash_single(password)
|
274
|
+
pwd_hash = self.hash_single(pwd_hash + salt)
|
275
|
+
return pwd_hash
|
276
|
+
|
277
|
+
def hash_two(self, password1: str, password2: str) -> str:
|
278
|
+
return _big_hex_sum(self.hash_single(password1), self.hash_single(password2))
|
279
|
+
|
280
|
+
def find_salt(self, html: str, field: str) -> str:
|
281
|
+
needle = f"js_myhash(document.{field}.value)+'"
|
282
|
+
start = html.index(needle)
|
283
|
+
start_salt = start + len(needle)
|
284
|
+
end_salt = html.index("'", start_salt)
|
285
|
+
salt = html[start_salt:end_salt]
|
286
|
+
return salt
|
287
|
+
|
171
288
|
def login(self):
|
172
289
|
if self.loggedIn:
|
173
290
|
return
|
@@ -176,28 +293,27 @@ class BocaScraper:
|
|
176
293
|
f'{self.base_url}', error_msg='Error while opening BOCA login page'
|
177
294
|
)
|
178
295
|
|
179
|
-
|
180
|
-
start = html.index(needle)
|
181
|
-
start_salt = start + len(needle)
|
182
|
-
end_salt = html.index("'", start_salt)
|
183
|
-
salt = html[start_salt:end_salt]
|
296
|
+
salt = self.find_salt(html, 'form1.password')
|
184
297
|
console.console.print(f'Using salt [item]{salt}[/item]')
|
185
298
|
|
186
|
-
pwd_hash =
|
187
|
-
pwd_hash = hashlib.sha256((pwd_hash + salt).encode()).hexdigest()
|
299
|
+
pwd_hash = self.hash(self.password, salt)
|
188
300
|
|
189
301
|
login_url = f'{self.base_url}?name={self.username}&password={pwd_hash}'
|
190
302
|
self.open(login_url, error_msg='Error while logging in to BOCA')
|
191
303
|
|
192
304
|
self.loggedIn = True
|
193
305
|
|
194
|
-
def upload(
|
306
|
+
def upload(
|
307
|
+
self,
|
308
|
+
file: pathlib.Path,
|
309
|
+
testing: bool = False,
|
310
|
+
) -> bool:
|
195
311
|
self.open(
|
196
312
|
f'{self.base_url}/admin/problem.php',
|
197
313
|
error_msg='Error while opening BOCA problem upload page',
|
198
314
|
)
|
199
315
|
try:
|
200
|
-
|
316
|
+
self.br.select_form(name='form1')
|
201
317
|
except mechanize.FormNotFoundError:
|
202
318
|
self.error(
|
203
319
|
'Problem upload form not found in BOCA website. This might happen when the login failed.'
|
@@ -206,20 +322,32 @@ class BocaScraper:
|
|
206
322
|
form = typing.cast(mechanize.HTMLForm, self.br.form)
|
207
323
|
form.set_all_readonly(False)
|
208
324
|
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
325
|
+
if testing:
|
326
|
+
problem_index = 0
|
327
|
+
problem_shortname = 'A'
|
328
|
+
hex_color = None
|
329
|
+
else:
|
330
|
+
problem_index = naming.get_problem_index()
|
331
|
+
if problem_index is None:
|
332
|
+
console.console.print(
|
333
|
+
'It seems this problem is not part of a contest. Cannot upload it to BOCA.'
|
334
|
+
)
|
335
|
+
raise typer.Exit(1)
|
336
|
+
|
337
|
+
problem_shortname = naming.get_problem_shortname()
|
338
|
+
assert problem_shortname is not None
|
339
|
+
if problem_shortname is None:
|
340
|
+
console.console.print(
|
341
|
+
'It seems this problem is not part of a contest. Cannot upload it to BOCA.'
|
342
|
+
)
|
343
|
+
raise typer.Exit(1)
|
344
|
+
|
345
|
+
entry = naming.get_problem_entry_in_contest()
|
346
|
+
assert entry is not None
|
347
|
+
_, problem_entry = entry
|
215
348
|
|
216
|
-
|
217
|
-
assert problem_shortname is not None
|
218
|
-
entry = naming.get_problem_entry_in_contest()
|
219
|
-
assert entry is not None
|
220
|
-
_, problem_entry = entry
|
349
|
+
hex_color = problem_entry.hex_color
|
221
350
|
|
222
|
-
hex_color = problem_entry.hex_color
|
223
351
|
if hex_color is None:
|
224
352
|
form['colorname'] = 'black'
|
225
353
|
form['color'] = '000000'
|
@@ -281,14 +409,120 @@ class BocaScraper:
|
|
281
409
|
)
|
282
410
|
raise typer.Exit(1)
|
283
411
|
|
284
|
-
def
|
412
|
+
def get_link_from_a(self, a: Tag) -> Optional[str]:
|
413
|
+
href = a.attrs.get('href')
|
414
|
+
if href is None:
|
415
|
+
return None
|
416
|
+
link = self.br.find_link(url=href)
|
417
|
+
if link is None:
|
418
|
+
return None
|
419
|
+
return link.absolute_url
|
420
|
+
|
421
|
+
def get_first_link(self, tag: Tag) -> Optional[str]:
|
422
|
+
a = tag.find('a')
|
423
|
+
if a is None:
|
424
|
+
return None
|
425
|
+
return self.get_link_from_a(typing.cast(Tag, a))
|
426
|
+
|
427
|
+
def list_problems(self) -> List[BocaProblem]:
|
428
|
+
_, html = self.open(
|
429
|
+
f'{self.base_url}/admin/problem.php',
|
430
|
+
error_msg='Error while listing problems in BOCA',
|
431
|
+
)
|
432
|
+
|
433
|
+
soup = BeautifulSoup(html, 'html.parser')
|
434
|
+
rows = soup.select('form[name="form0"] > table > tr')
|
435
|
+
problems: List[BocaProblem] = []
|
436
|
+
for row in rows[2:]:
|
437
|
+
cells = row.select('& > td')
|
438
|
+
index = int(cells[0].text.strip())
|
439
|
+
shortname = cells[1].text.strip()
|
440
|
+
fullname = cells[2].text.strip()
|
441
|
+
basename = cells[3].text.strip()
|
442
|
+
descfile_url = self.get_first_link(cells[4])
|
443
|
+
package_url = self.get_first_link(cells[5])
|
444
|
+
|
445
|
+
hash_balloon = cells[5].find('img')
|
446
|
+
if descfile_url is None or package_url is None or hash_balloon is None:
|
447
|
+
self.log(f'Skipping problem {shortname} because of missing data')
|
448
|
+
continue
|
449
|
+
hash_balloon = typing.cast(Tag, hash_balloon)
|
450
|
+
hash = hash_balloon.attrs.get('alt')
|
451
|
+
if hash is None:
|
452
|
+
self.log(f'Skipping problem {shortname} because hash is None')
|
453
|
+
continue
|
454
|
+
hash = str(hash).strip()
|
455
|
+
|
456
|
+
color_balloon = cells[6].find('img')
|
457
|
+
if color_balloon is None:
|
458
|
+
self.log(f'Skipping problem {shortname} because color balloon is None')
|
459
|
+
continue
|
460
|
+
color_balloon = typing.cast(Tag, color_balloon)
|
461
|
+
color = color_balloon.attrs.get('alt')
|
462
|
+
if color is None:
|
463
|
+
self.log(f'Skipping problem {shortname} because color is None')
|
464
|
+
continue
|
465
|
+
color = str(color).strip()
|
466
|
+
|
467
|
+
color_name = color_balloon.attrs.get('title')
|
468
|
+
if color_name is None:
|
469
|
+
self.log(f'Skipping problem {shortname} because color name is None')
|
470
|
+
continue
|
471
|
+
color_name = str(color_name).strip()
|
472
|
+
|
473
|
+
problems.append(
|
474
|
+
BocaProblem(
|
475
|
+
index=index,
|
476
|
+
shortname=shortname,
|
477
|
+
fullname=fullname,
|
478
|
+
basename=basename,
|
479
|
+
color=color,
|
480
|
+
color_name=color_name,
|
481
|
+
descfile_url=descfile_url,
|
482
|
+
package_url=package_url,
|
483
|
+
package_hash=hash,
|
484
|
+
)
|
485
|
+
)
|
486
|
+
|
487
|
+
return problems
|
488
|
+
|
489
|
+
def list_languages(self) -> List[BocaLanguage]:
|
490
|
+
_, html = self.open(
|
491
|
+
f'{self.base_url}/admin/language.php',
|
492
|
+
error_msg='Error while listing languages in BOCA',
|
493
|
+
)
|
494
|
+
|
495
|
+
soup = BeautifulSoup(html, 'html.parser')
|
496
|
+
tables = soup.select('table')
|
497
|
+
for table in tables:
|
498
|
+
if 'Language #' in table.text:
|
499
|
+
break
|
500
|
+
else:
|
501
|
+
self.raw_error(
|
502
|
+
'Error while listing languages in BOCA:\nNo language table found.'
|
503
|
+
)
|
504
|
+
table = typing.cast(Tag, table)
|
505
|
+
rows = table.select('tr')
|
506
|
+
languages: List[BocaLanguage] = []
|
507
|
+
for row in rows[1:]:
|
508
|
+
cells = row.select('td')
|
509
|
+
index = int(cells[0].text.strip())
|
510
|
+
name = cells[1].text.strip()
|
511
|
+
extension = cells[2].text.strip()
|
512
|
+
languages.append(BocaLanguage(index=index, name=name, extension=extension))
|
513
|
+
return languages
|
514
|
+
|
515
|
+
def list_runs(self, only_judged: bool = True) -> List[BocaRun]:
|
285
516
|
_, html = self.open(
|
286
|
-
f'{self.base_url}/admin/run.php'
|
517
|
+
f'{self.base_url}/admin/run.php'
|
518
|
+
if not self.is_judge
|
519
|
+
else f'{self.base_url}/judge/runchief.php',
|
287
520
|
error_msg='Error while listing runs in BOCA',
|
288
521
|
)
|
289
522
|
|
290
523
|
soup = BeautifulSoup(html, 'html.parser')
|
291
524
|
rows = soup.select('form[name="form1"] table tr')
|
525
|
+
off = 1 if self.is_judge else 0
|
292
526
|
|
293
527
|
runs: List[BocaRun] = []
|
294
528
|
for row in rows[1:]:
|
@@ -296,14 +530,20 @@ class BocaScraper:
|
|
296
530
|
|
297
531
|
run_number = cells[0].text.strip()
|
298
532
|
site_number = cells[1].text.strip()
|
299
|
-
shortname = cells[4].text.strip()
|
300
|
-
answer = cells[-1].text.strip()
|
301
|
-
time = int(cells[3].text.strip())
|
302
|
-
user =
|
533
|
+
shortname = cells[4 - off].text.strip()
|
534
|
+
answer = cells[-1 - off].text.strip()
|
535
|
+
time = int(cells[3 - off].text.strip())
|
536
|
+
user = (
|
537
|
+
cells[2].text.strip() if not self.is_judge else cells[-1].text.strip()
|
538
|
+
)
|
539
|
+
status = cells[-4 - off].text.strip().lower()
|
303
540
|
|
541
|
+
if only_judged and status != 'judged':
|
542
|
+
continue
|
304
543
|
outcome = _parse_answer_as_outcome(answer)
|
305
544
|
if outcome is None:
|
306
|
-
|
545
|
+
if status == 'judged':
|
546
|
+
continue
|
307
547
|
runs.append(
|
308
548
|
BocaRun(
|
309
549
|
run_number=run_number,
|
@@ -311,23 +551,35 @@ class BocaScraper:
|
|
311
551
|
problem_shortname=shortname,
|
312
552
|
outcome=outcome,
|
313
553
|
time=time,
|
554
|
+
status=status,
|
314
555
|
user=user,
|
315
556
|
)
|
316
557
|
)
|
317
558
|
|
318
559
|
return runs
|
319
560
|
|
320
|
-
def
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
561
|
+
def wait_for_all_judged(self, step: int = 5):
|
562
|
+
while True:
|
563
|
+
runs = self.list_runs(only_judged=False)
|
564
|
+
if all(run.outcome is not None for run in runs):
|
565
|
+
return
|
566
|
+
time.sleep(step)
|
567
|
+
|
568
|
+
def retrieve_run(self, run: BocaRun) -> BocaDetailedRun:
|
569
|
+
self.log(
|
570
|
+
f'Retrieving run [item]{run.run_number}-{run.site_number}[/item] from BOCA...'
|
571
|
+
)
|
572
|
+
runedit_url = (
|
573
|
+
f'{self.base_url}/admin/runedit.php'
|
574
|
+
if not self.is_judge
|
575
|
+
else f'{self.base_url}/judge/runeditchief.php'
|
576
|
+
)
|
577
|
+
url = (
|
578
|
+
f'{runedit_url}?runnumber={run.run_number}&runsitenumber={run.site_number}'
|
579
|
+
)
|
328
580
|
_, html = self.open(
|
329
581
|
url,
|
330
|
-
error_msg=f'Error while downloading BOCA run [item]{run_number}-{site_number}[/item]',
|
582
|
+
error_msg=f'Error while downloading BOCA run [item]{run.run_number}-{run.site_number}[/item]',
|
331
583
|
)
|
332
584
|
|
333
585
|
soup = BeautifulSoup(html, 'html.parser')
|
@@ -338,7 +590,7 @@ class BocaScraper:
|
|
338
590
|
|
339
591
|
for row in rows:
|
340
592
|
row_text = row.select('td')[0].text.strip().lower()
|
341
|
-
if row_text
|
593
|
+
if not row_text.endswith('code:'):
|
342
594
|
continue
|
343
595
|
link_col = row.select_one('td:nth-of-type(2) a:nth-of-type(1)')
|
344
596
|
if link_col is None:
|
@@ -357,12 +609,278 @@ class BocaScraper:
|
|
357
609
|
tmp_file, _ = self.br.retrieve(link.absolute_url)
|
358
610
|
if tmp_file is None:
|
359
611
|
self.raw_error('Error while downloading run:\nDownloaded file is None.')
|
360
|
-
|
612
|
+
|
613
|
+
return BocaDetailedRun(
|
614
|
+
**run.model_dump(),
|
615
|
+
language_repr='',
|
616
|
+
code=pathlib.Path(tmp_file).read_text(),
|
617
|
+
filename=filename,
|
618
|
+
autojudge_answer='',
|
619
|
+
)
|
620
|
+
|
621
|
+
def retrieve_runs(self, only_judged: bool = True) -> List[BocaDetailedRun]:
|
622
|
+
runs = self.list_runs(only_judged)
|
623
|
+
return [self.retrieve_run(run) for run in runs]
|
624
|
+
|
625
|
+
def download_run(
|
626
|
+
self,
|
627
|
+
run: BocaRun,
|
628
|
+
into_dir: pathlib.Path,
|
629
|
+
name: Optional[str] = None,
|
630
|
+
) -> pathlib.Path:
|
631
|
+
detailed_run = self.retrieve_run(run)
|
632
|
+
filename = detailed_run.filename.with_stem(
|
633
|
+
name or f'{run.run_number}-{run.site_number}'
|
634
|
+
)
|
361
635
|
final_path = into_dir / filename
|
362
636
|
final_path.parent.mkdir(parents=True, exist_ok=True)
|
363
|
-
shutil.move(
|
637
|
+
shutil.move(detailed_run.code, final_path)
|
364
638
|
return final_path
|
365
639
|
|
640
|
+
def _set_starttime(
|
641
|
+
self,
|
642
|
+
form: mechanize.HTMLForm,
|
643
|
+
start_time: datetime.datetime,
|
644
|
+
tzinfo: datetime.tzinfo,
|
645
|
+
):
|
646
|
+
start_time = start_time.astimezone(tzinfo)
|
647
|
+
|
648
|
+
form['startdateh'] = f'{start_time.hour:02d}'
|
649
|
+
form['startdatemin'] = f'{start_time.minute:02d}'
|
650
|
+
form['startdated'] = f'{start_time.day:02d}'
|
651
|
+
form['startdatem'] = f'{start_time.month:02d}'
|
652
|
+
form['startdatey'] = f'{start_time.year}'
|
653
|
+
|
654
|
+
self.log(
|
655
|
+
f'Setting start time to {start_time.hour}:{start_time.minute} {start_time.day}/{start_time.month}/{start_time.year}'
|
656
|
+
)
|
657
|
+
|
658
|
+
def create_and_activate_contest(self):
|
659
|
+
_, html = self.open(
|
660
|
+
f'{self.base_url}/system/contest.php?new=1',
|
661
|
+
error_msg='Error while creating contest in BOCA',
|
662
|
+
)
|
663
|
+
|
664
|
+
contest_page = self.get_redirect(html)
|
665
|
+
self.log(f'Redirected to contest page: {contest_page}')
|
666
|
+
if contest_page is None:
|
667
|
+
self.pretty_print(html)
|
668
|
+
self.raw_error(
|
669
|
+
'Error while creating contest:\nNo redirect to contest page found.'
|
670
|
+
)
|
671
|
+
|
672
|
+
_, html = self.open(
|
673
|
+
contest_page, error_msg='Error while opening the contest page'
|
674
|
+
)
|
675
|
+
|
676
|
+
try:
|
677
|
+
self.br.select_form(name='form1')
|
678
|
+
except mechanize.FormNotFoundError:
|
679
|
+
self.error(
|
680
|
+
'Contest activation form not found in BOCA website. This might happen when the login failed.'
|
681
|
+
)
|
682
|
+
|
683
|
+
form = typing.cast(mechanize.HTMLForm, self.br.form)
|
684
|
+
form.set_all_readonly(False)
|
685
|
+
form['confirmation'] = 'confirm'
|
686
|
+
|
687
|
+
response = self.br.submit(name='Submit3', type='submit', nr=1)
|
688
|
+
|
689
|
+
def alert_ok_fn(alert: str) -> bool:
|
690
|
+
return 'You must log in the new contest' in alert
|
691
|
+
|
692
|
+
self.log_response_alert(
|
693
|
+
response,
|
694
|
+
'Error while activating contest',
|
695
|
+
alert_ok_fn=alert_ok_fn,
|
696
|
+
)
|
697
|
+
self.log('Contest activated successfully')
|
698
|
+
|
699
|
+
def infer_timezone(self) -> datetime.tzinfo:
|
700
|
+
_, html = self.open(
|
701
|
+
f'{self.base_url}/admin/site.php',
|
702
|
+
error_msg='Error while inferring timezone in BOCA',
|
703
|
+
)
|
704
|
+
|
705
|
+
match = START_DATE_REGEX.search(html)
|
706
|
+
if match is None:
|
707
|
+
self.raw_error(
|
708
|
+
'Error while inferring timezone in BOCA:\nNo start date found.'
|
709
|
+
)
|
710
|
+
start_date = match.group(1)
|
711
|
+
parsed_date = dateparser.parse(
|
712
|
+
start_date, settings={'RETURN_AS_TIMEZONE_AWARE': True}
|
713
|
+
)
|
714
|
+
if parsed_date is None:
|
715
|
+
self.raw_error(
|
716
|
+
'Error while inferring timezone in BOCA:\nCould not parse start date.'
|
717
|
+
)
|
718
|
+
return parsed_date.tzinfo or datetime.timezone.utc
|
719
|
+
|
720
|
+
def configure_contest(
|
721
|
+
self,
|
722
|
+
start_time: Optional[datetime.datetime] = None,
|
723
|
+
):
|
724
|
+
tzinfo = self.infer_timezone()
|
725
|
+
|
726
|
+
_, html = self.open(
|
727
|
+
f'{self.base_url}/admin/contest.php',
|
728
|
+
error_msg='Error while configuring contest in BOCA',
|
729
|
+
)
|
730
|
+
|
731
|
+
try:
|
732
|
+
self.br.select_form(name='form1')
|
733
|
+
except mechanize.FormNotFoundError:
|
734
|
+
self.error(
|
735
|
+
'Contest activation form not found in BOCA website. This might happen when the login failed.'
|
736
|
+
)
|
737
|
+
|
738
|
+
form = typing.cast(mechanize.HTMLForm, self.br.form)
|
739
|
+
form.set_all_readonly(False)
|
740
|
+
form['confirmation'] = 'confirm'
|
741
|
+
|
742
|
+
if start_time is not None:
|
743
|
+
self._set_starttime(form, start_time, tzinfo)
|
744
|
+
|
745
|
+
req = self.br.click(name='Submit3', type='submit', nr=1)
|
746
|
+
pretty_print_request_data(req)
|
747
|
+
self.open(req)
|
748
|
+
self.log('Contest configured successfully')
|
749
|
+
|
750
|
+
def configure_main_site(
|
751
|
+
self, autojudge: Optional[bool] = None, chief: Optional[str] = None
|
752
|
+
):
|
753
|
+
_, html = self.open(
|
754
|
+
f'{self.base_url}/admin/site.php',
|
755
|
+
error_msg='Error while configuring main site in BOCA',
|
756
|
+
)
|
757
|
+
|
758
|
+
try:
|
759
|
+
form = self.br.select_form(name='form1')
|
760
|
+
except mechanize.FormNotFoundError:
|
761
|
+
self.error(
|
762
|
+
'Main site configuration form not found in BOCA website. This might happen when the login failed.'
|
763
|
+
)
|
764
|
+
|
765
|
+
form = typing.cast(mechanize.HTMLForm, self.br.form)
|
766
|
+
form.set_all_readonly(False)
|
767
|
+
form['confirmation'] = 'confirm'
|
768
|
+
|
769
|
+
if autojudge is not None:
|
770
|
+
self.br.find_control(name='autojudge', type='checkbox').items[
|
771
|
+
0
|
772
|
+
].selected = autojudge
|
773
|
+
|
774
|
+
if chief is not None:
|
775
|
+
form['chiefname'] = chief
|
776
|
+
|
777
|
+
req = self.br.click(name='Submit1', type='submit', nr=0)
|
778
|
+
pretty_print_request_data(req)
|
779
|
+
self.open(req)
|
780
|
+
self.log('Main site configured successfully')
|
781
|
+
|
782
|
+
def create_judge_account(self, password: str = 'boca'):
|
783
|
+
_, html = self.open(
|
784
|
+
f'{self.base_url}/admin/user.php',
|
785
|
+
error_msg='Error while creating judge account in BOCA',
|
786
|
+
)
|
787
|
+
|
788
|
+
try:
|
789
|
+
self.br.select_form(name='form3')
|
790
|
+
except mechanize.FormNotFoundError:
|
791
|
+
self.error(
|
792
|
+
'Judge account creation form not found in BOCA website. This might happen when the login failed.'
|
793
|
+
)
|
794
|
+
|
795
|
+
salt = self.find_salt(html, 'form3.passwordo')
|
796
|
+
console.console.print(f'Using salt [item]{salt}[/item]')
|
797
|
+
admin_pwd_hash = self.hash(self.password, salt)
|
798
|
+
|
799
|
+
self.br.form = self.br.global_form()
|
800
|
+
form = typing.cast(mechanize.HTMLForm, self.br.form)
|
801
|
+
|
802
|
+
form.method = 'POST'
|
803
|
+
form.set_all_readonly(False)
|
804
|
+
form['confirmation'] = 'confirm'
|
805
|
+
form['usernumber'] = '42565759'
|
806
|
+
form['username'] = 'judge'
|
807
|
+
form['usertype'] = ['judge']
|
808
|
+
form['usermultilogin'] = ['t']
|
809
|
+
form['userfullname'] = 'Judge RBX'
|
810
|
+
form['passwordn1'] = self.hash_two(password, self.password)
|
811
|
+
form['passwordn2'] = self.hash_two(password, self.password)
|
812
|
+
form['passwordo'] = admin_pwd_hash
|
813
|
+
|
814
|
+
req = self.br.click(name='Submit', type='submit')
|
815
|
+
pretty_print_request_data(req)
|
816
|
+
self.open(req)
|
817
|
+
self.log('Judge account created successfully')
|
818
|
+
|
819
|
+
def wait_for_problem(self, problem_index_in_contest: int, timeout: int, step: int):
|
820
|
+
_, html = self.open(
|
821
|
+
f'{self.base_url}/judge/team.php',
|
822
|
+
error_msg='Error while waiting for problem in BOCA',
|
823
|
+
)
|
824
|
+
|
825
|
+
self.log(
|
826
|
+
f'Waiting for problem [item]{problem_index_in_contest}[/item] in BOCA (timeout: {timeout}s, step: {step}s)...'
|
827
|
+
)
|
828
|
+
soup = BeautifulSoup(html, 'html.parser')
|
829
|
+
options = soup.select('select[name="problem"] option')
|
830
|
+
available_indices = set(
|
831
|
+
int(option.attrs['value'])
|
832
|
+
for option in options
|
833
|
+
if option.attrs.get('value') is not None
|
834
|
+
)
|
835
|
+
if problem_index_in_contest not in available_indices:
|
836
|
+
if timeout <= 0:
|
837
|
+
self.raw_error(
|
838
|
+
f'Problem index [item]{problem_index_in_contest}[/item] not found in BOCA.'
|
839
|
+
)
|
840
|
+
time.sleep(step)
|
841
|
+
return self.wait_for_problem(problem_index_in_contest, timeout - step, step)
|
842
|
+
return
|
843
|
+
|
844
|
+
def submit_as_judge(
|
845
|
+
self,
|
846
|
+
problem_index_in_contest: int,
|
847
|
+
language_index_in_contest: int,
|
848
|
+
file: pathlib.Path,
|
849
|
+
wait: int = 0,
|
850
|
+
):
|
851
|
+
if wait > 0:
|
852
|
+
self.wait_for_problem(problem_index_in_contest, wait, 5)
|
853
|
+
_, html = self.open(
|
854
|
+
f'{self.base_url}/judge/team.php',
|
855
|
+
error_msg='Error while submitting problem to BOCA',
|
856
|
+
)
|
857
|
+
|
858
|
+
try:
|
859
|
+
self.br.select_form(name='form1')
|
860
|
+
except mechanize.FormNotFoundError:
|
861
|
+
self.error(
|
862
|
+
'Judge submission form not found in BOCA website. This might happen when the login failed.'
|
863
|
+
)
|
864
|
+
|
865
|
+
form = typing.cast(mechanize.HTMLForm, self.br.form)
|
866
|
+
form.set_all_readonly(False)
|
867
|
+
form['confirmation'] = 'confirm'
|
868
|
+
|
869
|
+
form['problem'] = [str(problem_index_in_contest)]
|
870
|
+
form['language'] = [str(language_index_in_contest)]
|
871
|
+
|
872
|
+
with file.open('rb') as f:
|
873
|
+
form.add_file(
|
874
|
+
f,
|
875
|
+
filename=file.name,
|
876
|
+
name='sourcefile',
|
877
|
+
)
|
878
|
+
|
879
|
+
req = self.br.click(name='Submit', type='submit')
|
880
|
+
pretty_print_request_data(req)
|
881
|
+
self.open(req)
|
882
|
+
self.log('Judge submission sent successfully')
|
883
|
+
|
366
884
|
|
367
885
|
@functools.cache
|
368
886
|
def get_boca_scraper(
|
@@ -371,3 +889,88 @@ def get_boca_scraper(
|
|
371
889
|
password: Optional[str] = None,
|
372
890
|
) -> BocaScraper:
|
373
891
|
return BocaScraper(base_url, username, password)
|
892
|
+
|
893
|
+
|
894
|
+
class ContestSnapshot:
|
895
|
+
def __init__(
|
896
|
+
self,
|
897
|
+
problems: Optional[List[BocaProblem]] = None,
|
898
|
+
languages: Optional[List[BocaLanguage]] = None,
|
899
|
+
runs: Optional[List[BocaRun]] = None,
|
900
|
+
detailed_runs: Optional[List[BocaDetailedRun]] = None,
|
901
|
+
):
|
902
|
+
self.problems = problems or []
|
903
|
+
self.languages = languages or []
|
904
|
+
self.runs = runs or []
|
905
|
+
self.detailed_runs = detailed_runs or []
|
906
|
+
|
907
|
+
def __str__(self) -> str:
|
908
|
+
return f'ContestSnapshot(problems={self.problems}, languages={self.languages})'
|
909
|
+
|
910
|
+
def __repr__(self) -> str:
|
911
|
+
return self.__str__()
|
912
|
+
|
913
|
+
def get_problem_by_shortname(self, shortname: str) -> BocaProblem:
|
914
|
+
for problem in self.problems:
|
915
|
+
if problem.shortname == shortname:
|
916
|
+
return problem
|
917
|
+
raise ValueError(f'Problem with shortname {shortname} not found')
|
918
|
+
|
919
|
+
def get_problem_by_basename(self, basename: str) -> BocaProblem:
|
920
|
+
for problem in self.problems:
|
921
|
+
if problem.basename == basename:
|
922
|
+
return problem
|
923
|
+
raise ValueError(f'Problem with basename {basename} not found')
|
924
|
+
|
925
|
+
def get_problem_by_index(self, index: int) -> BocaProblem:
|
926
|
+
for problem in self.problems:
|
927
|
+
if problem.index == index:
|
928
|
+
return problem
|
929
|
+
raise ValueError(f'Problem with index {index} not found')
|
930
|
+
|
931
|
+
def get_language_by_name(self, name: str) -> BocaLanguage:
|
932
|
+
for language in self.languages:
|
933
|
+
if language.name == name:
|
934
|
+
return language
|
935
|
+
raise ValueError(f'Language with name {name} not found')
|
936
|
+
|
937
|
+
def get_language_by_extension(self, extension: str) -> BocaLanguage:
|
938
|
+
for language in self.languages:
|
939
|
+
if language.extension == extension:
|
940
|
+
return language
|
941
|
+
raise ValueError(f'Language with extension {extension} not found')
|
942
|
+
|
943
|
+
def get_language_by_index(self, index: int) -> BocaLanguage:
|
944
|
+
for language in self.languages:
|
945
|
+
if language.index == index:
|
946
|
+
return language
|
947
|
+
raise ValueError(f'Language with index {index} not found')
|
948
|
+
|
949
|
+
def get_run_by_number(self, number: int) -> BocaRun:
|
950
|
+
for run in self.runs:
|
951
|
+
if run.run_number == number:
|
952
|
+
return run
|
953
|
+
raise ValueError(f'Run with number {number} not found')
|
954
|
+
|
955
|
+
def get_detailed_run_by_number(self, number: int) -> BocaDetailedRun:
|
956
|
+
for run in self.detailed_runs:
|
957
|
+
if run.run_number == number:
|
958
|
+
return run
|
959
|
+
raise ValueError(f'Run with number {number} not found')
|
960
|
+
|
961
|
+
def get_detailed_run_by_path(self, path: pathlib.Path) -> BocaDetailedRun:
|
962
|
+
for run in self.detailed_runs:
|
963
|
+
if run.filename.name == path.name:
|
964
|
+
return run
|
965
|
+
raise ValueError(f'Run with path {path} not found')
|
966
|
+
|
967
|
+
|
968
|
+
def create_snapshot(
|
969
|
+
scraper: BocaScraper, detailed_runs: bool = False
|
970
|
+
) -> ContestSnapshot:
|
971
|
+
scraper.login()
|
972
|
+
problems = scraper.list_problems()
|
973
|
+
languages = scraper.list_languages()
|
974
|
+
runs = scraper.list_runs()
|
975
|
+
detailed = scraper.retrieve_runs() if detailed_runs else []
|
976
|
+
return ContestSnapshot(problems, languages, runs, detailed)
|