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

1 import argparse

2 import importlib.metadata

3 import json

4 import logging

5 import sys

6 from dataclasses import dataclass

7 from enum import Enum

8 from getpass import getpass

9 from pathlib import Path

10 from time import sleep

11 from typing import AnyStr, Optional

12 from urllib.parse import urljoin

13 from xml.etree.ElementTree import Element, tostring

14

15 import html5lib

16 import platformdirs

17 from html2text import html2text

18

19 from .session import MoocfiCsesSession as Session

20

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

22 try:

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

24 except importlib.metadata.PackageNotFoundError:

25 __version__ = "unknown"

26

27 PROG_NAME = "tyora"

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

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

30

31

32 def 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

92 def 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

103 def 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

114 def 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

124 def 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

142 def 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

154 def 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

165 def 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

179 class TaskState(Enum):

180 COMPLETE = "complete"

181 INCOMPLETE = "incomplete"

182

183

184 TASK_STATE_ICON = {

185 TaskState.COMPLETE: "✅",

186 TaskState.INCOMPLETE: "❌",

187}

188

189

190@dataclass

191 class 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

201 def 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

240 def 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

252 def 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

299 def 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

314 def 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

333 def 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

429 if __name__ == "__main__":

430 main()