Прикреплённый файл «ejst.py»
Загрузка 1 #!/usr/bin/env python3
2 '''
3 '''
4 from collections import Counter
5 from dataclasses import dataclass, field
6 from difflib import unified_diff, HtmlDiff
7 from editdistance import eval as distance
8 from itertools import chain
9 from multiprocessing import Pool
10 from pathlib import Path
11 from pygments.formatters import Terminal256Formatter
12 from pygments import highlight
13 from pygments.lexers import PythonLexer, DiffLexer
14 from pygments.styles.native import NativeStyle
15 import ast
16 import atexit
17 import cmd
18 import csv
19 import getpass
20 import gzip
21 import os
22 import pickle
23 import re
24 import readline
25 import requests
26 import shlex
27 import shutil
28 import sshtoken
29 import subprocess
30 import sys
31 import time
32 import tomli_w
33 import tomllib
34 import traceback
35
36 CONTEST = "Python2022"
37 reSPACEQ = re.compile(r'([\s"]+)')
38
39
40 @dataclass
41 class files:
42 pwd: Path = Path().resolve()
43 config: Path = pwd / ".ejst.conf"
44 archive: Path = pwd / "var" / "archive" / "uuid"
45 cache: Path = Path("~").expanduser() / ".cache" / CONTEST
46 history: Path = cache / "history"
47 token: Path = cache / "token"
48 runs: Path = cache / "runs.csv"
49 sourcefiles: Path = cache / "sources.toml"
50 standings: Path = cache / "standings.html"
51 distances: Path = cache / "distances.pickle"
52 report: Path = cache / "report"
53 diffdir: Path = cache / "diffs"
54
55
56 @dataclass
57 class _glob:
58 allruns: list[dict] = field(default_factory=list)
59 runs: list[dict] = field(default_factory=list)
60 sourcefiles: dict[str] = field(default_factory=dict)
61 standings: list[dict] = field(default_factory=list)
62 users: dict[str] = field(default_factory=dict)
63 scores: dict[str] = field(default_factory=dict)
64 tasks: list[str] = field(default_factory=list)
65 areas: dict[str] = field(default_factory=dict)
66 logins: dict[str] = field(default_factory=dict)
67 maxscore: int = 0
68
69
70 glob = _glob()
71
72 config = {
73 "contest": f"{CONTEST}",
74 "prompt": f"{CONTEST}> ",
75 "moinroot": "/var/www/wiki/uneex",
76 "moinpage": "data/pages/LecturesCMC(2f)PythonIntro2022(2f)HomeworkGradePaste(2f)report",
77 "moinstatic": f"static/{CONTEST}",
78 "moinlogin": "moin",
79 "moinurl": "uneex.org",
80 "login": "scoreviewer",
81 "sshkey": "ED25519",
82 "ejudge": "ejudge.cs.msu.ru",
83 "ejuser": "frbrgeorge",
84 "contest_id": "230",
85 "min_score": 2 / 3,
86 "max_distance": 0.01,
87 "max_sizediff": 0.2,
88 "common_tasks": ["HelloWorld", ],
89 "agrade_tasks": ["InitParam", "TarFile", "DeStruct", "SpiralString"],
90 "scores": [66, 77, 88],
91 "common_downscore": 51,
92 "same_downscore": 26,
93 "paste_downscore": 0,
94 "copypaste": {},
95 "independent": {},
96 "manual": {},
97 }
98
99 ejudge = {
100 "interface": "new-master",
101 "role": "1",
102 "locale_id": "0",
103 "stat": "94",
104 "get": "152",
105 "getxml": "155",
106 "default": ("User_Name", "Run_Id", "Prob", "Status", "Score"),
107 "cookies": None,
108 "adm_url": None,
109 }
110
111
112 def ejget(action):
113 if type(action) is str and action.isalpha():
114 action = ejudge[action]
115 url = re.sub(r"[&]action=\d+|$", f"&action={action}", ejudge["adm_url"])
116 req = requests.get(url, cookies=ejudge["cookies"])
117 return req.text
118
119
120 def getruns():
121 request = requests.post(f"https://{config['ejudge']}/{ejudge['interface']}", data={
122 "login": config["login"],
123 "password": sshtoken.get_token(files.token.as_posix()).decode(),
124 "contest_id": config["contest_id"],
125 "role": ejudge["role"],
126 "locale_id": ejudge["locale_id"],
127 })
128 ejudge["cookies"], ejudge["adm_url"] = request.cookies, request.url
129 return ejget("get"), ejget("stat")
130
131
132 def auditpick(text, label):
133 fmt = f"\n{label}: "
134 return text[(st := text.index(fmt) + len(fmt)): text.index("\n", st + 1)]
135
136
137 def getsourcefiles():
138 sources = {}
139 for path in files.archive.glob("??/??/*"):
140 txt = (path / "audit").read_text()
141 runid = auditpick(txt, "Run-id")
142 if (path / "source").is_file():
143 sources[runid] = (path / "source").as_posix()
144 elif (path / "source.gz").is_file():
145 sources[runid] = (path / "source.gz").as_posix()
146 return sources
147
148
149 AstK = ['None '] + [s for s in dir(ast) if s[0].isalpha()]
150 AstD = {key: chr(char + 65) for key, char in zip(AstK, range(len(AstK)))}
151
152
153 def astformat(node):
154 if isinstance(node, ast.AST):
155 args = []
156 for fld in node._fields:
157 value = getattr(node, fld)
158 args.append(astformat(value))
159 return '%s%s' % (AstD[node.__class__.__name__], ''.join(args))
160 elif isinstance(node, list):
161 return '%s' % ''.join(astformat(x) for x in node)
162 return '@'
163
164
165 def readsource(args):
166 fname, user, task, runid = args
167 if fname.endswith(".gz"):
168 source = gzip.open(fname, "rb").read()
169 else:
170 source = open(fname, "rb").read()
171 astree = ast.parse(source, "p")
172 prep = astformat(astree)
173 return user, task, ast.unparse(astree), prep, runid
174
175
176 def readsources():
177 minscore = glob.maxscore * config["min_score"]
178 glob.users = {st["User"]: st for st in glob.standings if st["Score"] and int(st["Score"]) >= minscore}
179 vals = []
180 for run in glob.allruns:
181 if run["Run_Id"] in glob.sourcefiles:
182 if run["Stat_Short"] == "OK" and run["User_Name"] in glob.users:
183 vals.append((glob.sourcefiles[run["Run_Id"]], run["User_Name"], run["Prob"], run["Run_Id"]))
184 else:
185 print(f'ERROR: No {run["Run_Id"]} source, update is needed!')
186 glob.scores, glob.areas = {}, {}
187 with Pool() as pool:
188 for user, task, source, prep, runid in pool.map(readsource, vals):
189 if user:
190 glob.areas.setdefault(task, {})[user] = \
191 glob.scores.setdefault(user, {})[task] = \
192 [int(glob.users[user][task]), source, prep, set(), runid]
193 for user in glob.scores:
194 for task in glob.tasks:
195 if task not in glob.scores[user]:
196 glob.scores[user][task] = [int(glob.users[user][task] or 0), "", "", set(), 0]
197
198
199 def calcdist(args):
200 return *args, distance(*args[-2:]) / (len(args[-1]) + len(args[-2]))
201
202
203 def calcdistances():
204 poolargs = []
205
206 for task, area in glob.areas.items():
207 if task in config["common_tasks"]:
208 continue
209 users = list(area.items())
210 for i, (user, (score, source, prep, flags, runid)) in enumerate(users):
211 if task in config["independent"].get(user, []):
212 continue
213 for j in range(i + 1, len(users)):
214 user2, (score2, source2, prep2, flags2, runid2) = users[j]
215 if task in config["independent"].get(user2, []):
216 continue
217 w = abs(1 - (len(prep) + len(prep2)) / (2 * max(len(prep), len(prep2))))
218 if all((score, source, score2, source2, w < config["max_sizediff"])):
219 poolargs.append((task, user, user2, prep, prep2))
220 print(f"Calculating {len(poolargs)} distances…", end=" ")
221 with Pool() as pool:
222 for task, user, user2, prep, prep2, dist in pool.map(calcdist, poolargs):
223 if dist < config["max_distance"]:
224 glob.areas[task][user][3].add((dist, user2))
225 glob.areas[task][user2][3].add((dist, user))
226 print("done!")
227
228
229 def listtask(task):
230 ranged = sorted((len(attrs[3]), min(attrs[3], default=(100, "")), user, attrs[0])
231 for user, attrs in glob.areas[task].items())
232 weight = sum(r[0] for r in ranged)
233 return weight, ranged
234
235
236 def downscore():
237 # TODO copypaste / independent
238 counter = equal = 0
239 for user, tasks in glob.scores.items():
240 for task, attrs in tasks.items():
241 if task in config["common_tasks"]:
242 attrs[0] = min(attrs[0], config["common_downscore"])
243 if attrs[3]:
244 attrs[0] = min(attrs[0], config["same_downscore"])
245 counter += 1
246 for weight, buddy in attrs[3]:
247 if attrs[1] == glob.scores[buddy][task][1]:
248 equal += 1
249 attrs[0] = config["paste_downscore"]
250 break
251
252 for user, tasks in config["copypaste"].items():
253 for task in tasks:
254 glob.scores[user][task][0] = config["paste_downscore"]
255 for weight, buddy in glob.scores[user][task][3]:
256 glob.scores[buddy][task][0] = config["paste_downscore"]
257 for user, tasks in config["manual"].items():
258 for task, score in tasks.items():
259 glob.scores[user][task][0] = score
260 return counter, equal
261
262
263 def select(where, *fields, **pattern):
264 for run in where.values() if isinstance(where, dict) else where:
265 if run | pattern == run:
266 if fields:
267 yield {key: run[key] for key in fields}
268 else:
269 yield run
270
271
272 def showselect(where, *fields, **pattern):
273 for run in select(where, *fields, **pattern):
274 print(*run.values())
275
276
277 def selectset(where, field, **pattern):
278 return set(tuple(run.values())[0] for run in select(where, field, **pattern))
279
280
281 def selectitem(where, *fields, error=True, **pattern):
282 res = list(select(where, *fields, **pattern))
283 if len(res) != 1:
284 msg = f"Got {len(res)} selection of {pattern} instead of one"
285 if error:
286 raise ValueError(msg)
287 print(msg, file=sys.stderr)
288 if len(fields) == 1:
289 return list(res[0].values())[0] if res else None
290 else:
291 return list(res[0].values()) if res else [None] * len(fields)
292
293
294 def score(task, person):
295 for run in select(glob.runs, "default", {"Prob": task, "User_Name": person}):
296 print(*run.values())
297
298
299 def readruns():
300 glob.allruns = list(csv.DictReader(files.runs.open(newline=""), delimiter=";"))
301 for run in glob.allruns:
302 if not run["User_Name"]:
303 run["User_Name"] = run["User_Login"]
304 glob.logins[run["User_Name"]] = run["User_Login"]
305 glob.tasks = list(selectset(glob.allruns, "Prob"))
306 glob.maxscore = (len(glob.tasks) - len(config["common_tasks"])) * 100 + \
307 len(config["common_tasks"]) * config["common_downscore"]
308
309
310 def readstandings():
311 standings = files.standings.read_text()
312 htmltable = re.search(r'<table[^\n]*class="standings">(.*)</table>', standings, re.M | re.S).groups()[0]
313 table = []
314 for record in re.finditer(r'<tr[^>]+>(.*?)</tr>', htmltable, re.M | re.S):
315 table.append([])
316 for res in re.finditer(r'<t[dh][^>]+>(?:<[^>]+>)*(.*?)(?:<[^>]+>)*</t[dh]>', record.groups()[0]):
317 val = res.groups()[0].replace(' ', '')
318 table[-1].append(val)
319 glob.standings = [dict(zip(table[0], table[i])) for i in range(1, len(table))]
320
321
322 def gettoken():
323 return sshtoken.get_token(files.token.as_posix(), config["sshkey"]).decode()
324
325
326 def createtoken():
327 sshtoken.store_token(getpass.getpass("Password: "), files.token.as_posix(), config["sshkey"])
328
329
330 def dumpdistances():
331 dists = {(user, task): attrs[3] for user, tasks in glob.scores.items() for task, attrs in tasks.items()}
332 with files.distances.open("wb") as f:
333 pickle.dump(dists, f)
334
335
336 def loaddistances():
337 with files.distances.open("rb") as f:
338 dists = pickle.load(f)
339 for (user, task), pasters in dists.items():
340 glob.scores[user][task][3] |= pasters
341
342
343 def grade(user):
344 score = int(100 * sum(attrs[0] for attrs in glob.scores[user].values()) / glob.maxscore)
345 solved = len([attrs[0] for attrs in glob.scores[user].values() if attrs[0] > config["paste_downscore"]])
346 tasks = set(task for task, attrs in glob.scores[user].items() if attrs[0] > config["paste_downscore"])
347 for gr, grname in zip(config["scores"] + [glob.maxscore + 1], ("НЕУД", "'''УДОВЛ'''", "'''ХОР'''", "'''ОТЛ'''")):
348 if score < gr:
349 break
350
351 if grname == "'''ОТЛ'''" and not tasks.issuperset(set(config["agrade_tasks"])):
352 grname = "'''ХОР+'''"
353 passed = "'''ЗАЧЁТ'''" if solved == len(glob.tasks) or score >= config["scores"][1] else "незач"
354 return grname, passed, int(score), int(solved)
355
356
357 def makereport(commonmark="*"):
358 def rep(*args):
359 match args:
360 case [nick, name]:
361 report.append(f"<<Anchor({nicks[nick]})>>[[#_{nicks[nick]}|back]]\n== {name} ==")
362 case [nick, name, grad, passgr, score, solved]:
363 report.append(f"|| [[#{nicks[nick]}|{nick}]] || <<Anchor(_{nicks[nick]})>>{name}"
364 f" || {grad} || {passgr} || {score} || {solved} ||")
365 case [user, task, score, weight, buddy]:
366 common = commonmark if task in config["common_tasks"] else ""
367 if buddy:
368 source, source2 = glob.scores[user][task][1], glob.scores[buddy][task][1]
369 runid, runid2 = glob.scores[user][task][4], glob.scores[buddy][task][4]
370 diffname = f"{task}_{runid}_{runid2}.diff.html"
371 attachments[diffname] = \
372 HtmlDiff().make_file(source.split('\n'), source2.split('\n'),
373 f"{user} {task} {runid}", f"{buddy} {task} {runid2}")
374 diffurl = f"{config['moinstatic']}/report/{diffname}"
375 blink = f"[[#{nicks[glob.logins[buddy]]}|{buddy}]] '''([[{diffurl}|distance {weight}]])'''"
376 else:
377 blink = ""
378 report.append(f"|| `{task}`{common} || '''{score}''' || {blink} ||")
379 case [str(text)]:
380 report.append(text)
381 case _:
382 print("Unable to report", *args)
383
384 nicks = {nick: nick.replace(" ", "_").replace("@", "-") for nick in glob.logins.values()}
385 attachments = {}
386 report = []
387 agrade_tasks = " ".join(f"[[../../Homework_{t}|{t}]]" for t in config["agrade_tasks"])
388 rep(f"""== Текущие лимиты ==
389 Один балл таблицы — это {glob.maxscore/100} баллов EJudge.
390
391 ||<|3> ⩾ {config['scores'][2]} || '''ОТЛ''' / '''ЗАЧЁТ''', если все обязательные задачи решены ||
392 || '''ХОР+''' / '''ЗАЧЁТ''', если не решена хотя бы одна из обязательных задач: ||
393 ||<)> {agrade_tasks} ||
394 || ⩾ {config['scores'][1]} || '''ХОР''' / '''ЗАЧЁТ''' ||
395 || ⩾ {config['scores'][0]} || '''УДОВЛ''' / незачёт ||
396 || < {config['scores'][0]} || НЕУД / незачёт ||
397
398 Про копипасту:
399 * «Почти похожие» решения — не более s,,alike,,={config['same_downscore']}
400 * Полностью совпадающие решения и подтверждённая копипаста — s,,equal,,={config['paste_downscore']}
401 * «Слишком простые задачи» — не более s,,raw,,={config['common_downscore']} (отмечены «'''{commonmark}'''»))
402 == {time.strftime("%F")} ==
403 || '''User''' || '''Name ''' || '''Grade''' || '''Pass''' || '''Score''' || '''Solved''' ||""")
404
405 for user in sorted(glob.users, key=lambda x: glob.logins[x]):
406 rep(glob.logins[user], user, *grade(user))
407 for user, tasks in glob.scores.items():
408 rep(glob.logins[user], user)
409 for task, attrs in tasks.items():
410 rep(user, task, attrs[0], *min(attrs[3], default=(100500, "")))
411 grname, passed, score, solved = grade(user)
412 rep(f"|| {solved} / {len(glob.tasks)} || {score} || {grname}/{passed} ||")
413
414 if files.report.is_dir():
415 shutil.rmtree(files.report.as_posix())
416 if files.diffdir.is_dir():
417 shutil.rmtree(files.diffdir.as_posix())
418 (files.diffdir).mkdir(parents=True)
419 (files.report / "revisions").mkdir(parents=True)
420 (files.report / "revisions" / "00000001").write_text("\n".join(report) + "\n")
421 (files.report / "current").write_text("00000001\n")
422 for name, html in attachments.items():
423 (files.diffdir / name).write_text(html)
424 return (files.report / "current").as_posix()
425
426
427 def publish():
428 remotmoin = f"{config['moinlogin']}@{config['moinurl']}:{config['moinroot']}/{config['moinpage']}"
429 subprocess.check_output(["rsync", "-avP", "--delete", files.report.as_posix() + "/", remotmoin])
430 remotestatic = f"{config['moinlogin']}@{config['moinurl']}:{config['moinroot']}/{config['moinstatic']}/report/"
431 subprocess.check_output(["rsync", "-avP", "--delete", files.diffdir.as_posix() + "/", remotestatic])
432
433
434 def init(sync=False):
435 if not files.cache.is_dir():
436 files.cache.mkdir()
437 configread()
438 if not files.token.is_file():
439 print("WARNING: no password token is stored, creating one", file=sys.stderr)
440 createtoken()
441 if sync or not files.runs.is_file() or not files.standings.is_file():
442 if not sync:
443 print(f"WARNING: no {files.runs.name} is stored, downloading", file=sys.stderr)
444 runs, standings = getruns()
445 files.runs.write_text(runs)
446 files.standings.write_text(standings)
447 readruns()
448 readstandings()
449 if sync or not files.sourcefiles.is_file():
450 tomli_w.dump(getsourcefiles(), files.sourcefiles.open("wb"))
451 with files.sourcefiles.open("br") as f:
452 glob.sourcefiles = tomllib.load(f)
453 readsources()
454 if sync or not files.distances.is_file():
455 calcdistances()
456 dumpdistances()
457 loaddistances()
458 print(*downscore(), "rewrites / copypastes in question")
459
460
461 def configread():
462 if not files.config.is_file():
463 print(f"WARNING: no {files.config.as_posix()} file, creating default one")
464 configwrite()
465 with files.config.open("br") as f:
466 config.update(tomllib.load(f))
467
468
469 def configwrite():
470 with files.config.open("bw") as f:
471 tomli_w.dump(config, f)
472
473
474 def taskdiff(task, user, user2):
475 try:
476 source, source2 = glob.areas[task][user][1], glob.areas[task][user2][1]
477 except KeyError as E:
478 print("Not found:", E)
479 return
480 source, source2 = glob.areas[task][user][1], glob.areas[task][user2][1]
481 res = "\n".join(unified_diff(source.split("\n"), source2.split("\n"), user, user2, lineterm=''))
482 print(highlight(res, DiffLexer(), Terminal256Formatter(style=NativeStyle)))
483
484
485 def hyst(counter, title="", blocks=" ▁▂▃▄▅▆▇█", height=None):
486 mn, mx = min(counter), max(counter)
487 Mn, Mx = min(counter.values()), max(counter.values())
488 if title:
489 print(" ", title.center(mx - mn))
490 if height is None:
491 height = Mx - Mn + 1
492 for i in range(height, -1, -1):
493 print(f"{round(Mn + i * (Mx - Mn) / height):>3}: ", end="")
494 for j in range(mn, mx + 1):
495 h = counter[j] * height / (Mx - Mn)
496 c = blocks[0] if h < i else (blocks[-1] if h >= i + 1 else blocks[round((h - i) * (len(blocks)))])
497 print(c, end="")
498 print()
499 print(" ", mn, " " * (mx - mn - len(f"{mx} {mn}")), mx)
500
501
502 def isnumbers(*strings):
503 for string in strings:
504 try:
505 res = float(string)
506 except ValueError:
507 res = None
508 yield res
509 else:
510 yield int(res) if res.is_integer() else res
511
512
513 class Shell(cmd.Cmd):
514 prompt = config["prompt"]
515
516 @staticmethod
517 def shplit(string):
518 try:
519 return shlex.split(string)
520 except ValueError as E:
521 print(E)
522 return string.split()
523
524 @staticmethod
525 def match(text, select, *add, shift=0, prefix=""):
526 vals = tuple(filter(None, select))
527 return [prefix + key[shift:] for key in vals + add if key[shift:] and key.startswith(text.strip('"'))]
528
529 @staticmethod
530 def locateword(text, line, begidx, endidx):
531 s = line[:endidx]
532 if s.count('"') % 2:
533 s += '"'
534 return len(shlex.split(s)) - (s[-1] != " ")
535
536 @staticmethod
537 def getword(text, line, begidx, endidx):
538 s = line[:endidx]
539 if s.count('"') % 2:
540 s += '"'
541 return shlex.split(s)[-1]
542 return text
543
544 def do_config(self, arg):
545 '''Configuration
546 \t\tShow all config parameters
547 <param>\t\tShow config parameter
548 <param>\t<value>\tSet config parameter
549 token\t\tSet password silently from stdin
550 '''
551 match self.shplit(arg):
552 case []:
553 for cname in config:
554 print(f"\t{cname}:\t{config[cname]}")
555 case ["token"]:
556 createtoken()
557 case [param]:
558 if param.startswith("-"):
559 if param[1:] in config:
560 del config[param[1:]]
561 elif param in config:
562 print(f"{config[param]}")
563 else:
564 print(f"ERROR: unknown '{param}' parameter")
565 case [param, *values]:
566 if param.startswith("!"):
567 param = param[1:]
568 elif param not in config:
569 print(f"ERROR: unknown '{param}' parameter")
570 numbers = tuple(isnumbers(*values))
571 if None in numbers:
572 if isinstance(config[param], list):
573 config[param] = values
574 else:
575 config[param] = shlex.join(values)
576 else:
577 config[param] = numbers if len(values) > 1 else numbers[0]
578
579 def complete_config(self, text, line, begidx, endidx):
580 match self.locateword(text, line, begidx, endidx):
581 case 1:
582 return self.match(text, config, "token")
583 case 2:
584 res = config.get(line.split()[1], "UNKNOWN")
585 return [" ".join(res) if isinstance(res, list) else res]
586
587 def do_write(self, arg):
588 '''Write some changed data'''
589 match arg:
590 case "config":
591 configwrite()
592
593 def complete_write(self, text, line, begidx, endidx):
594 fnames = ["config"]
595 return self.match(text, fnames)
596
597 def do_task(self, arg):
598 '''List task statistics
599
600 \t\tList all tasks
601 task\t\tShow task statistic
602 task user\tShow user's task details
603 task user user2\tCompare task solutions
604 '''
605 if not arg:
606 res = sorted((listtask(task), task) for task in glob.tasks)
607 for (weight, ranged), task in res:
608 print(f"{task}: {len(ranged)} total, copypaste weight {weight}")
609 match self.shplit(arg):
610 case [task]:
611 if task in glob.tasks:
612 weight, ranged = listtask(task)
613 for total, dist, user, score in ranged:
614 print(f"{user}: total {total}, min {dist} / score {score}")
615 print(f"Copypaste weight {weight}")
616 case [task, user]:
617 if task in glob.tasks and user in glob.users:
618 for dist, paster in glob.areas[task][user][3]:
619 print(f"{paster}: {dist}")
620 print(highlight(glob.areas[task][user][1], PythonLexer(), Terminal256Formatter(style=NativeStyle)))
621 case [task, user, user2]:
622 taskdiff(task, user, user2)
623
624 def complete_task(self, text, line, begidx, endidx):
625 match self.locateword(text, line, begidx, endidx):
626 case 1:
627 return self.match(text, glob.tasks)
628 case _:
629 w = self.getword(text, line, begidx, endidx)
630 return self.match(w, glob.users, shift=len(w) - len(text))
631
632 def do_manual(self, arg):
633 '''Alias for 'score manual'''
634 self.do_score("manual")
635
636 def do_score(self, arg):
637 '''Show user scores
638
639 \t\tShow all users' scores
640 'manual'\t\tShow manual defined scores
641 user\t\tShow all user scores
642 user task\tShow detailed user task score
643 user task score\tSet manual user task score
644 user task -\tClear manual user task score
645 user task user2\tCompare solutions
646 '''
647 if not arg: # TODO calculate
648 for user, score in glob.users.items():
649 print(f"{user}: {score['Score']}")
650 return
651 user, *tasks = self.shplit(arg)
652 if user == "manual":
653 for u in config["manual"]:
654 for task, score in config["manual"][u].items():
655 print(f"{u} / {task} = {score}")
656 return
657 match tasks:
658 case []:
659 print(f"\t{user}")
660 res = [f"{task}: {score[0]}" for task, score in glob.scores[user].items()]
661 self.columnize(sorted(res))
662 s = sum(int(score[0]) for score in glob.scores[user].values())
663 print(f"\t{s}/{glob.users[user]['Score']}")
664 if user in glob.scores:
665 print("Result:", *grade(user))
666 case ["manual"]:
667 for task, score in config["manual"][user].items():
668 print(f"{user} / {task} = {score}")
669 case [task]:
670 showselect(glob.allruns, *ejudge["default"], Prob=task, User_Name=user)
671 print(f"Run: {glob.users[user][task]}")
672 for dist, paster in glob.scores[user][task][3]:
673 print(f"{paster}: {dist}")
674 print("Score:", glob.scores[user][task][0])
675 case [task, score] if score.isdigit():
676 config["manual"].setdefault(user, {})[task] = int(score)
677 if int(score) > config["same_downscore"]:
678 config["independent"].setdefault(user, []).append(task)
679 case [task, "-"]:
680 if task in config["manual"].get(user, {}):
681 del config["manual"][user][task]
682 downscore()
683 case [task, user2]:
684 taskdiff(task, user, user2)
685
686 def complete_score(self, text, line, begidx, endidx):
687 match self.locateword(text, line, begidx, endidx):
688 case 1:
689 w = self.getword(text, line, begidx, endidx)
690 return self.match(w, glob.users, "manual", shift=len(w) - len(text))
691 case 2:
692 return self.match(text, glob.tasks, "manual")
693 case 3:
694 w = self.getword(text, line, begidx, endidx)
695 return self.match(w, glob.users, shift=len(w) - len(text))
696
697 def do_stat(self, arg):
698 """Show statistices"""
699 print("Tasks by number of solutions:")
700 ts = [f"{t}: {s}" for t, s in sorted((len(users), task) for task, users in glob.areas.items())]
701 self.columnize(ts)
702 print(f"Users: {len(glob.users)} / accounted {len(glob.scores)}")
703 grades = Counter(grade(user)[0].strip("'") for user in glob.scores)
704 passes = Counter(grade(user)[1].strip("'") for user in glob.scores)
705 scores = Counter(grade(user)[2] for user in glob.scores)
706 solved = Counter(grade(user)[3] for user in glob.scores)
707 print(", ".join(f"{k}:{v}" for k, v in grades.items()))
708 print(", ".join(f"{k}:{v}" for k, v in passes.items()))
709 hyst(solved, title="Solved", height=8)
710 hyst(scores, title="Scores", height=8)
711 grsl = [(grade(user)[0].strip("'"), grade(user)[3]) for user in glob.scores]
712 for gr in 'ОТЛ', 'ХОР+', 'ХОР', 'УДОВЛ':
713 m = min((g, sl) for g, sl in grsl if g == gr)
714 M = max((g, sl) for g, sl in grsl if g == gr)
715 print(f"{gr}: min {m} / max {M}")
716
717 def do_select(self, arg):
718 '''[internal] Browse list of dicts'''
719 obj, *selectors = self.shplit(arg)
720 names = [par for par in selectors if "=" not in par]
721 if names == ["default"]:
722 names = ejudge["default"]
723 filters = dict(par.split("=") for par in selectors if "=" in par)
724 if len(names) == 1:
725 print(*sorted(selectset(getattr(glob, obj), *names, **filters)))
726 else:
727 showselect(getattr(glob, obj), *names, **filters)
728
729 def complete_select(self, text, line, begidx, endidx):
730 if self.locateword(text, line, begidx, endidx) == 1:
731 return self.match(text, glob.__dataclass_fields__.keys())
732 if "=" not in line.split()[-1] or text == "" and line[begidx - 1] != "=":
733 suffix = "=" in line and "=" or ""
734 where = getattr(glob, line.split()[1])
735 words = next(iter(where.values())) if isinstance(where, dict) else where[0]
736 return [s + suffix for s in self.match(text, words, "default")]
737 root, (field, val) = line.split()[1], line.split()[-1].split("=")
738 variants = sorted(selectset(getattr(glob, root), field))
739 return self.match(val, variants)
740
741 def do_copypaste(self, arg):
742 '''Display or register user+task as proved copypaste
743
744 ?\t\tDisplay all useras with close enough solutions
745 \t\tDisplay all proved copypaste
746 user\t\tDisplay user's proved copypaste
747 user ?\t\tDisplay user's close enough solutions
748 user task\tRegister task as copypasted by user
749 user -task\tUnrgister task as copypasted by user
750 '''
751 self._do_user_tasks(arg, "copypaste")
752
753 def _do_user_tasks(self, arg, table):
754 if not arg:
755 for user, tasks in config[table].items():
756 print(f"{user}:", *tasks)
757 return
758 match self.shplit(arg):
759 case ["?"]:
760 plist = sorted((sum(len(a[3]) for a in tasks.values()), user) for user, tasks in glob.scores.items())
761 for pastes, user in plist:
762 print(f"{user}: {pastes} / {grade(user)[0]}")
763 case [user]:
764 if user in glob.users:
765 print(*config[table].get(user, []))
766 case [user, "?"]:
767 for task, attrs in glob.scores[user].items():
768 for weight, buddy in attrs[3]:
769 print(f"{task}: {weight} = {buddy}")
770 case [user, str(task)] if task.startswith("-"):
771 if user in glob.users and task[1:] in config[table].get(user, []):
772 config[table][user].remove(task[1:])
773 if config[table][user] == []:
774 del config[table][user]
775 case [user, str(task)]:
776 if user in glob.users and task not in config[table].setdefault(user, []):
777 config[table][user].append(task)
778 downscore()
779
780 def complete_copypaste(self, text, line, begidx, endidx):
781 match self.locateword(text, line, begidx, endidx):
782 case 1:
783 w = self.getword(text, line, begidx, endidx)
784 return self.match(w, glob.users, shift=len(w) - len(text))
785 case 2:
786 return self.match(text, glob.tasks)
787
788 def do_independent(self, arg):
789 '''Display or register user+task as proved independent
790
791 \t\tDisplay all tasks proved independent
792 user\t\tDisplay user's tasks proved independent
793 user task\tRegister task as independent by user
794 user -task\tUnrgister task as independent by user
795 '''
796 self._do_user_tasks(arg, "independent")
797
798 def complete_independent(self, text, line, begidx, endidx):
799 return self.complete_copypaste(text, line, begidx, endidx)
800
801 def do_report(self, arg):
802 '''Create a Moin report with HTML attachments. Use report publish for pablish also'''
803 print(makereport())
804 if arg.endswith("publish"):
805 publish()
806
807 def complete_report(self, text, line, begidx, endidx):
808 return ["publish"]
809
810 def do_sync(self, arg):
811 '''Synchronize statistics and git'''
812 remote = f"cd ~/{config['contest']} && git pull &&"\
813 "git add . && git commit -a -m ejst && git push && git pull || :"
814 subprocess.check_output(["ssh", "-q", f"{config['ejuser']}@{config['ejudge']}", f"{remote}"], stdin=None)
815 subprocess.check_output(["git", "pull"])
816 init(sync=True)
817
818 def do_search(self, arg):
819 '''Search for something
820
821 string\tSearch for user name or user login
822 number\tSearch for run number
823 '''
824 match self.shplit(arg):
825 case [str(substr)] if substr.isdigit():
826 prob, status, name = selectitem(glob.allruns, "User_Name", "Prob", "Status", error=False, Run_Id=substr)
827 if name:
828 print(f"{substr}: {prob} / {status} / {name}")
829 case [str(substr)]:
830 for user, login in glob.logins.items():
831 if substr.lower() in user.lower() or substr.lower() in login.lower():
832 print(f"{user}: {login}")
833
834 def complete_search(self, text, line, begidx, endidx):
835 if text.isdigit():
836 runids = (str(glob.scores[user][task][4]) for user in glob.users for task in glob.tasks)
837 return self.match(text, runids)
838 elif text:
839 return [w for w in chain(*glob.logins.items()) if text.lower() in w.lower()]
840
841 def do_eval(self, arg):
842 print(eval(arg))
843
844 def complete_eval(self, text, line, begidx, endidx):
845 if (dot := text.rfind(".")) > 0:
846 return self.match(text[dot + 1:], dir(eval(text[:dot])), prefix=text[:dot + 1])
847 else:
848 return self.match(text, globals())
849
850 def do_shell(self, arg):
851 """Alias of "! [command [arguments]]". Run shell or execute command"""
852 if arg:
853 res = subprocess.run(self.shplit(arg), stdin=sys.stdin, capture_output=True)
854 print(res.stdout.decode())
855 if res.stderr:
856 print(res.stderr.decode())
857 else:
858 subprocess.call([os.environ["SHELL"], "-l"])
859
860 def do_EOF(self, arg):
861 """Press Ctrl+D to exit"""
862 return True
863
864 def emptyline(self):
865 pass
866
867 def preloop(self):
868 if files.history.is_file():
869 readline.read_history_file(files.history.as_posix())
870 readline.set_history_length(1000)
871 atexit.register(readline.write_history_file, files.history.as_posix())
872
873
874 def main():
875 init()
876 for errors in range(100):
877 try:
878 Shell().cmdloop()
879 except Exception as E:
880 traceback.print_tb(E.__traceback__)
881 print(f"[{errors}] {type(E).__name__}: {E}", file=sys.stderr)
882 except KeyboardInterrupt:
883 print("^C", file=sys.stderr)
884 else:
885 break
886 else:
887 print("Too many exceptions, exiting", file=sys.stderr)
888
889
890 if __name__ == "__main__":
891 main()
Прикреплённые файлы
Для ссылки на прикреплённый файл в тексте страницы напишите attachment:имяфайла, как показано ниже в списке файлов. Не используйте URL из ссылки «[получить]», так как он чисто внутренний и может измениться.- [получить | показать] (2018-01-17 22:56:09, 6.5 KB) [[attachment:CtrlC-CtrlV.jpg]]
- [получить | показать] (2019-12-25 17:39:02, 12.7 KB) [[attachment:contest.py]]
- [получить | показать] (2018-01-15 22:49:23, 12.2 KB) [[attachment:contest_86.n.py]]
- [получить | показать] (2023-03-07 13:14:41, 33.7 KB) [[attachment:ejst.py]]
- [получить | показать] (2020-12-28 17:58:46, 28.5 KB) [[attachment:ejstat.py]]
Вам нельзя прикреплять файлы к этой странице.