Coverage for src/tyora/tyora.py: 32%

241 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-24 15:04 -0400

1import argparse 

2import importlib.metadata 

3import json 

4import logging 

5import sys 

6from dataclasses import dataclass 

7from enum import Enum 

8from getpass import getpass 

9from pathlib import Path 

10from time import sleep 

11from typing import AnyStr, Optional 

12from urllib.parse import urljoin 

13from xml.etree.ElementTree import Element, tostring 

14 

15import html5lib 

16import platformdirs 

17from html2text import html2text 

18 

19from .session import MoocfiCsesSession as Session 

20 

21logger = logging.getLogger(name="tyora") 

22try: 

23 __version__ = importlib.metadata.version("tyora") 

24except importlib.metadata.PackageNotFoundError: 

25 __version__ = "unknown" 

26 

27PROG_NAME = "tyora" 

28CONF_FILE = platformdirs.user_config_path(PROG_NAME) / "config.json" 

29STATE_DIR = platformdirs.user_state_path(f"{PROG_NAME}") 

30 

31 

32def parse_args(args: Optional[list[str]] = None) -> argparse.Namespace: 

33 parser = argparse.ArgumentParser(description="Interact with mooc.fi CSES instance") 

34 parser.add_argument( 

35 "-V", "--version", action="version", version=f"%(prog)s {__version__}" 

36 ) 

37 parser.add_argument("-u", "--username", help="tmc.mooc.fi username") 

38 parser.add_argument("-p", "--password", help="tmc.mooc.fi password") 

39 parser.add_argument( 

40 "--debug", help="set logging level to debug", action="store_true" 

41 ) 

42 parser.add_argument( 

43 "--course", 

44 help="SLUG of the course (default: %(default)s)", 

45 default="dsa24k", 

46 ) 

47 parser.add_argument( 

48 "--config", 

49 help="Location of config file (default: %(default)s)", 

50 default=CONF_FILE, 

51 ) 

52 parser.add_argument( 

53 "--no-state", 

54 help="Don't store cookies or cache (they're used for faster access on the future runs)", 

55 action="store_true", 

56 ) 

57 subparsers = parser.add_subparsers(required=True, title="commands", dest="cmd") 

58 

59 # login subparser 

60 subparsers.add_parser("login", help="Login to mooc.fi CSES") 

61 

62 # list exercises subparser 

63 parser_list = subparsers.add_parser("list", help="List exercises") 

64 parser_list.add_argument( 

65 "--filter", 

66 help="List only complete or incomplete tasks (default: all)", 

67 choices=["complete", "incomplete"], 

68 ) 

69 parser_list.add_argument( 

70 "--limit", help="Maximum amount of items to list", type=int 

71 ) 

72 

73 # show exercise subparser 

74 parser_show = subparsers.add_parser("show", help="Show details of an exercise") 

75 parser_show.add_argument("task_id", help="Numerical task identifier") 

76 

77 # submit exercise solution subparser 

78 parser_submit = subparsers.add_parser("submit", help="Submit an exercise solution") 

79 parser_submit.add_argument( 

80 "--filename", 

81 help="Filename of the solution to submit (if not given will be guessed from task description)", 

82 ) 

83 parser_submit.add_argument("task_id", help="Numerical task identifier") 

84 

85 if len(sys.argv) == 1: 

86 parser.print_help() 

87 sys.exit(1) 

88 

89 return parser.parse_args(args) 

90 

91 

92def create_config() -> dict[str, str]: 

93 username = input("Your tmc.mooc.fi username: ") 

94 password = getpass("Your tmc.mooc.fi password: ") 

95 config = { 

96 "username": username, 

97 "password": password, 

98 } 

99 

100 return config 

101 

102 

103def write_config(configfile: str, config: dict[str, str]) -> None: 

104 file_path = Path(configfile).expanduser() 

105 if file_path.exists(): 

106 # TODO: https://github.com/madeddie/tyora/issues/28 

107 ... 

108 file_path.parent.mkdir(parents=True, exist_ok=True) # Ensure directory exists 

109 print("Writing config to file") 

110 with open(file_path, "w") as f: 

111 json.dump(config, f) 

112 

113 

114def read_config(configfile: str) -> dict[str, str]: 

115 config = dict() 

116 file_path = Path(configfile).expanduser() 

117 with open(file_path, "r") as f: 

118 config = json.load(f) 

119 for setting in ("username", "password"): 

120 assert setting in config 

121 return config 

122 

123 

124def read_cookie_file(cookiefile: str) -> dict[str, str]: 

125 """ 

126 Reads cookies from a JSON formatted file. 

127 

128 Args: 

129 cookiefile: str path to the file containing cookies. 

130 

131 Returns: 

132 A dictionary of cookies. 

133 """ 

134 try: 

135 with open(cookiefile, "r") as f: 

136 return json.load(f) 

137 except (FileNotFoundError, json.decoder.JSONDecodeError) as e: 

138 logger.debug(f"Error reading cookies from {cookiefile}: {e}") 

139 return {} 

140 

141 

142def write_cookie_file(cookiefile: str, cookies: dict[str, str]) -> None: 

143 """ 

144 Writes cookies to a file in JSON format. 

145 

146 Args: 

147 cookiefile: Path to the file for storing cookies. 

148 cookies: A dictionary of cookies to write. 

149 """ 

150 with open(cookiefile, "w") as f: 

151 json.dump(cookies, f) 

152 

153 

154def find_link(html: AnyStr, xpath: str) -> dict[str, Optional[str]]: 

155 """Search for html link by xpath and return dict with href and text""" 

156 anchor_element = html5lib.parse(html, namespaceHTMLElements=False).find(xpath) 

157 link_data = dict() 

158 if anchor_element is not None: 

159 link_data["href"] = anchor_element.get("href") 

160 link_data["text"] = anchor_element.text 

161 

162 return link_data 

163 

164 

165def parse_form(html: AnyStr, xpath: str = ".//form") -> dict: 

166 """Search for the first form in html and return dict with action and all other found inputs""" 

167 form_element = html5lib.parse(html, namespaceHTMLElements=False).find(xpath) 

168 form_data = dict() 

169 if form_element is not None: 

170 form_data["_action"] = form_element.get("action") 

171 for form_input in form_element.iter("input"): 

172 form_key = form_input.get("name") or "" 

173 form_value = form_input.get("value") or "" 

174 form_data[form_key] = form_value 

175 

176 return form_data 

177 

178 

179class TaskState(Enum): 

180 COMPLETE = "complete" 

181 INCOMPLETE = "incomplete" 

182 

183 

184TASK_STATE_ICON = { 

185 TaskState.COMPLETE: "✅", 

186 TaskState.INCOMPLETE: "❌", 

187} 

188 

189 

190@dataclass 

191class Task: 

192 id: str 

193 name: str 

194 state: TaskState 

195 description: Optional[str] = None 

196 code: Optional[str] = None 

197 submit_file: Optional[str] = None 

198 submit_link: Optional[str] = None 

199 

200 

201def parse_task_list(html: AnyStr) -> list[Task]: 

202 """Parse html to find tasks and their status, return something useful, possibly a specific data class""" 

203 content_element = html5lib.parse(html, namespaceHTMLElements=False).find( 

204 './/div[@class="content"]' 

205 ) 

206 task_list = list() 

207 if content_element is not None: 

208 for item in content_element.findall('.//li[@class="task"]'): 

209 item_id = None 

210 item_name = None 

211 item_class = None 

212 

213 item_link = item.find("a") 

214 if item_link is not None: 

215 item_name = item_link.text or "" 

216 item_id = item_link.get("href", "").split("/")[-1] 

217 

218 item_spans = item.findall("span") or [] 

219 item_span = next( 

220 (span for span in item_spans if span.get("class", "") != "detail"), None 

221 ) 

222 if item_span is not None: 

223 item_class = item_span.get("class", "") 

224 

225 if item_id and item_name and item_class: 

226 task = Task( 

227 id=item_id, 

228 name=item_name, 

229 state=( 

230 TaskState.COMPLETE 

231 if "full" in item_class 

232 else TaskState.INCOMPLETE 

233 ), 

234 ) 

235 task_list.append(task) 

236 

237 return task_list 

238 

239 

240def print_task_list( 

241 task_list: list[Task], filter: Optional[str] = None, limit: Optional[int] = None 

242) -> None: 

243 count: int = 0 

244 for task in task_list: 

245 if not filter or filter == task.state.value: 

246 print(f"- {task.id}: {task.name} {TASK_STATE_ICON[task.state]}") 

247 count += 1 

248 if limit and count >= limit: 

249 return 

250 

251 

252def parse_task(html: AnyStr) -> Task: 

253 root = html5lib.parse(html, namespaceHTMLElements=False) 

254 task_link_element = root.find('.//div[@class="nav sidebar"]/a') 

255 task_link = task_link_element if task_link_element is not None else Element("a") 

256 task_id = task_link.get("href", "").split("/")[-1] 

257 if not task_id: 

258 raise ValueError("Failed to find task id") 

259 task_name = task_link.text or None 

260 if not task_name: 

261 raise ValueError("Failed to find task name") 

262 task_span_element = task_link.find("span") 

263 task_span = task_span_element if task_span_element is not None else Element("span") 

264 task_span_class = task_span.get("class", "") 

265 desc_div_element = root.find('.//div[@class="md"]') 

266 desc_div = desc_div_element if desc_div_element is not None else Element("div") 

267 description = html2text(tostring(desc_div).decode("utf8")) 

268 code = root.findtext(".//pre", None) 

269 submit_link_element = root.find('.//a[.="Submit"]') 

270 submit_link = ( 

271 submit_link_element.get("href", None) 

272 if submit_link_element is not None 

273 else None 

274 ) 

275 

276 submit_file = next( 

277 iter( 

278 [ 

279 code_element.text 

280 for code_element in root.findall(".//code") 

281 if code_element.text is not None and ".py" in code_element.text 

282 ] 

283 ), 

284 None, 

285 ) 

286 task = Task( 

287 id=task_id, 

288 name=task_name, 

289 state=TaskState.COMPLETE if "full" in task_span_class else TaskState.INCOMPLETE, 

290 description=description.strip(), 

291 code=code, 

292 submit_file=submit_file, 

293 submit_link=submit_link, 

294 ) 

295 

296 return task 

297 

298 

299def print_task(task: Task) -> None: 

300 print(f"{task.id}: {task.name} {TASK_STATE_ICON[task.state]}") 

301 print(task.description) 

302 print(f"\nSubmission file name: {task.submit_file}") 

303 

304 

305# def submit_task(task_id: str, filename: str) -> None: 

306# """submit file to the submit form or task_id""" 

307# html = session.http_request(urljoin(base_url, f"task/{task_id}")) 

308# task = parse_task(html) 

309# answer = input("Do you want to submit this task? (y/n): ") 

310# if answer in ('y', 'Y'): 

311# with open(filename, 'r') as f: 

312 

313 

314def parse_submit_result(html: AnyStr) -> dict[str, str]: 

315 root = html5lib.parse(html, namespaceHTMLElements=False) 

316 submit_status_element = root.find('.//td[.="Status:"]/..') or Element("td") 

317 submit_status_span_element = submit_status_element.find("td/span") or Element( 

318 "span" 

319 ) 

320 submit_status = submit_status_span_element.text or "" 

321 submit_result_element = root.find('.//td[.="Result:"]/..') or Element("td") 

322 submit_result_span_element = submit_result_element.find("td/span") or Element( 

323 "span" 

324 ) 

325 submit_result = submit_result_span_element.text or "" 

326 

327 return { 

328 "status": submit_status.lower(), 

329 "result": submit_result.lower(), 

330 } 

331 

332 

333def main() -> None: 

334 args = parse_args() 

335 

336 logging.basicConfig( 

337 level=logging.DEBUG if args.debug else logging.WARNING, 

338 format="%(asctime)s %(levelname)s %(message)s", 

339 datefmt="%Y-%m-%d %H:%M:%S", 

340 ) 

341 

342 if args.cmd == "login": 

343 config = create_config() 

344 write_config(args.config, config) 

345 return 

346 

347 config = read_config(args.config) 

348 

349 # Merge cli args and configfile parameters in one dict 

350 config.update((k, v) for k, v in vars(args).items() if v is not None) 

351 

352 base_url = f"https://cses.fi/{config['course']}/" 

353 

354 cookiefile = None 

355 cookies = dict() 

356 if not args.no_state: 

357 if not STATE_DIR.exists(): 

358 STATE_DIR.mkdir(parents=True, exist_ok=True) 

359 cookiefile = STATE_DIR / "cookies.txt" 

360 cookies = read_cookie_file(str(cookiefile)) 

361 

362 session = Session( 

363 username=config["username"], 

364 password=config["password"], 

365 base_url=base_url, 

366 cookies=cookies, 

367 ) 

368 session.login() 

369 

370 if not args.no_state and cookiefile: 

371 cookies = session.cookies.get_dict() 

372 write_cookie_file(str(cookiefile), cookies) 

373 

374 if args.cmd == "list": 

375 res = session.get(urljoin(base_url, "list")) 

376 res.raise_for_status() 

377 task_list = parse_task_list(res.text) 

378 print_task_list(task_list, filter=args.filter, limit=args.limit) 

379 

380 if args.cmd == "show": 

381 res = session.get(urljoin(base_url, f"task/{args.task_id}")) 

382 res.raise_for_status() 

383 try: 

384 task = parse_task(res.text) 

385 except ValueError as e: 

386 logger.debug(f"Error parsing task: {e}") 

387 raise 

388 print_task(task) 

389 

390 if args.cmd == "submit": 

391 res = session.get(urljoin(base_url, f"task/{args.task_id}")) 

392 res.raise_for_status() 

393 task = parse_task(res.text) 

394 if not task.submit_file and not args.filename: 

395 raise ValueError("No submission filename found") 

396 if not task.submit_link: 

397 raise ValueError("No submission link found") 

398 submit_file = args.filename or task.submit_file or "" 

399 

400 res = session.get(urljoin(base_url, task.submit_link)) 

401 res.raise_for_status() 

402 submit_form_data = parse_form(res.text) 

403 action = submit_form_data.pop("_action") 

404 

405 for key, value in submit_form_data.items(): 

406 submit_form_data[key] = (None, value) 

407 submit_form_data["file"] = (submit_file, open(submit_file, "rb")) 

408 submit_form_data["lang"] = (None, "Python3") 

409 submit_form_data["option"] = (None, "CPython3") 

410 

411 res = session.post(urljoin(base_url, action), files=submit_form_data) 

412 res.raise_for_status() 

413 html = res.text 

414 result_url = res.url 

415 print("Waiting for test results.", end="") 

416 while "Test report" not in html: 

417 print(".", end="") 

418 sleep(1) 

419 res = session.get(result_url) 

420 res.raise_for_status() 

421 

422 print() 

423 results = parse_submit_result(res.text) 

424 

425 print(f"Submission status: {results['status']}") 

426 print(f"Submission result: {results['result']}") 

427 

428 

429if __name__ == "__main__": 

430 main()