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
« 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
15import html5lib
16import platformdirs
17from html2text import html2text
19from .session import MoocfiCsesSession as Session
21logger = logging.getLogger(name="tyora")
22try:
23 __version__ = importlib.metadata.version("tyora")
24except importlib.metadata.PackageNotFoundError:
25 __version__ = "unknown"
27PROG_NAME = "tyora"
28CONF_FILE = platformdirs.user_config_path(PROG_NAME) / "config.json"
29STATE_DIR = platformdirs.user_state_path(f"{PROG_NAME}")
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")
59 # login subparser
60 subparsers.add_parser("login", help="Login to mooc.fi CSES")
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 )
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")
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")
85 if len(sys.argv) == 1:
86 parser.print_help()
87 sys.exit(1)
89 return parser.parse_args(args)
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 }
100 return config
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)
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
124def read_cookie_file(cookiefile: str) -> dict[str, str]:
125 """
126 Reads cookies from a JSON formatted file.
128 Args:
129 cookiefile: str path to the file containing cookies.
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 {}
142def write_cookie_file(cookiefile: str, cookies: dict[str, str]) -> None:
143 """
144 Writes cookies to a file in JSON format.
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)
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
162 return link_data
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
176 return form_data
179class TaskState(Enum):
180 COMPLETE = "complete"
181 INCOMPLETE = "incomplete"
184TASK_STATE_ICON = {
185 TaskState.COMPLETE: "✅",
186 TaskState.INCOMPLETE: "❌",
187}
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
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
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]
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", "")
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)
237 return task_list
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
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 )
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 )
296 return task
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}")
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:
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 ""
327 return {
328 "status": submit_status.lower(),
329 "result": submit_result.lower(),
330 }
333def main() -> None:
334 args = parse_args()
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 )
342 if args.cmd == "login":
343 config = create_config()
344 write_config(args.config, config)
345 return
347 config = read_config(args.config)
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)
352 base_url = f"https://cses.fi/{config['course']}/"
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))
362 session = Session(
363 username=config["username"],
364 password=config["password"],
365 base_url=base_url,
366 cookies=cookies,
367 )
368 session.login()
370 if not args.no_state and cookiefile:
371 cookies = session.cookies.get_dict()
372 write_cookie_file(str(cookiefile), cookies)
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)
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)
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 ""
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")
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")
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()
422 print()
423 results = parse_submit_result(res.text)
425 print(f"Submission status: {results['status']}")
426 print(f"Submission result: {results['result']}")
429if __name__ == "__main__":
430 main()