rbx.cp 0.13.8__py3-none-any.whl → 0.14.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.
Files changed (71) hide show
  1. rbx/box/cli.py +74 -70
  2. rbx/box/code.py +3 -0
  3. rbx/box/contest/build_contest_statements.py +65 -23
  4. rbx/box/contest/contest_package.py +8 -1
  5. rbx/box/contest/main.py +9 -3
  6. rbx/box/contest/schema.py +17 -13
  7. rbx/box/contest/statements.py +12 -8
  8. rbx/box/dump_schemas.py +2 -1
  9. rbx/box/environment.py +1 -1
  10. rbx/box/fields.py +22 -4
  11. rbx/box/generators.py +32 -13
  12. rbx/box/limits_info.py +161 -0
  13. rbx/box/package.py +18 -1
  14. rbx/box/packaging/boca/boca_language_utils.py +26 -0
  15. rbx/box/packaging/boca/boca_outcome_utils.py +10 -0
  16. rbx/box/packaging/boca/packager.py +7 -5
  17. rbx/box/packaging/contest_main.py +20 -12
  18. rbx/box/packaging/packager.py +24 -14
  19. rbx/box/packaging/polygon/packager.py +7 -3
  20. rbx/box/packaging/polygon/upload.py +2 -1
  21. rbx/box/presets/__init__.py +64 -64
  22. rbx/box/remote.py +3 -3
  23. rbx/box/sanitizers/issue_stack.py +124 -0
  24. rbx/box/schema.py +87 -27
  25. rbx/box/solutions.py +74 -117
  26. rbx/box/statements/build_statements.py +12 -1
  27. rbx/box/statements/builders.py +5 -3
  28. rbx/box/statements/latex_jinja.py +73 -23
  29. rbx/box/statements/schema.py +7 -9
  30. rbx/box/stressing/generator_parser.py +3 -1
  31. rbx/box/tasks.py +10 -10
  32. rbx/box/testcase_extractors.py +8 -0
  33. rbx/box/testing/testing_preset.py +129 -2
  34. rbx/box/testing/testing_shared.py +3 -1
  35. rbx/box/timing.py +305 -0
  36. rbx/box/tooling/boca/debug_utils.py +88 -0
  37. rbx/box/tooling/boca/manual_scrape.py +20 -0
  38. rbx/box/tooling/boca/scraper.py +660 -57
  39. rbx/box/unit.py +0 -2
  40. rbx/box/validators.py +0 -4
  41. rbx/grading/judge/cacher.py +36 -0
  42. rbx/grading/judge/program.py +12 -2
  43. rbx/grading/judge/sandbox.py +1 -1
  44. rbx/grading/judge/sandboxes/stupid_sandbox.py +2 -1
  45. rbx/grading/judge/storage.py +36 -3
  46. rbx/grading/limits.py +4 -0
  47. rbx/grading/steps.py +3 -2
  48. rbx/resources/presets/default/contest/contest.rbx.yml +7 -1
  49. rbx/resources/presets/default/contest/statement/info.rbx.tex +54 -0
  50. rbx/resources/presets/default/problem/.gitignore +1 -0
  51. rbx/resources/presets/default/problem/problem.rbx.yml +19 -3
  52. rbx/resources/presets/default/problem/rbx.h +52 -5
  53. rbx/resources/presets/default/problem/statement/statement.rbx.tex +6 -2
  54. rbx/resources/presets/default/problem/testlib.h +6299 -0
  55. rbx/resources/presets/default/problem/validator.cpp +4 -3
  56. rbx/resources/presets/default/shared/contest_template.rbx.tex +8 -4
  57. rbx/resources/presets/default/shared/icpc.sty +16 -1
  58. rbx/resources/presets/default/shared/problem_template.rbx.tex +4 -1
  59. rbx/testing_utils.py +17 -1
  60. {rbx_cp-0.13.8.dist-info → rbx_cp-0.14.0.dist-info}/METADATA +4 -2
  61. {rbx_cp-0.13.8.dist-info → rbx_cp-0.14.0.dist-info}/RECORD +65 -62
  62. {rbx_cp-0.13.8.dist-info → rbx_cp-0.14.0.dist-info}/WHEEL +1 -1
  63. {rbx_cp-0.13.8.dist-info → rbx_cp-0.14.0.dist-info}/entry_points.txt +0 -1
  64. rbx/providers/__init__.py +0 -43
  65. rbx/providers/codeforces.py +0 -73
  66. rbx/providers/provider.py +0 -26
  67. rbx/submitors/__init__.py +0 -18
  68. rbx/submitors/codeforces.py +0 -121
  69. rbx/submitors/submitor.py +0 -25
  70. /rbx/resources/presets/default/problem/sols/{wa.cpp → wa-overflow.cpp} +0 -0
  71. {rbx_cp-0.13.8.dist-info → rbx_cp-0.14.0.dist-info}/LICENSE +0 -0
@@ -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 log_response_alert(self, response: Any, message: str) -> Tuple[Any, str]:
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 = ALERT_REGEX.search(html)
176
+ alert = self.get_alert(html)
99
177
  if alert:
100
- self.raw_error(
101
- f'{message} ([item]{self.base_url}[/item]):\n{alert.group(1)}'
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(date)
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 = ALERT_REGEX.search(html)
235
+ alert = self.get_alert(html)
156
236
  if alert:
157
- msg = alert.group(1)
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
- f'Error while submitting problem to BOCA website:\n{alert.group(1)}'
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(self, url: str, error_msg: Optional[str] = None):
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(url)
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
- needle = "js_myhash(document.form1.password.value)+'"
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 = hashlib.sha256(self.password.encode()).hexdigest()
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(self, file: pathlib.Path) -> bool:
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
- form = self.br.select_form(name='form1')
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
- problem_index = naming.get_problem_index()
210
- if problem_index is None:
211
- console.console.print(
212
- 'It seems this problem is not part of a contest. Cannot upload it to BOCA.'
213
- )
214
- raise typer.Exit(1)
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
- problem_shortname = naming.get_problem_shortname()
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 list_runs(self) -> List[BocaRun]:
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 = cells[2].text.strip()
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
- continue
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 download_run(
321
- self,
322
- run_number: int,
323
- site_number: int,
324
- into_dir: pathlib.Path,
325
- name: Optional[str] = None,
326
- ):
327
- url = f'{self.base_url}/admin/runedit.php?runnumber={run_number}&runsitenumber={site_number}'
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 != "team's code:":
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
- filename = filename.with_stem(name or f'{run_number}-{site_number}')
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(tmp_file, final_path)
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)