diff --git a/moocfi_cses.py b/moocfi_cses.py
index 77c0a19..d3ac115 100644
--- a/moocfi_cses.py
+++ b/moocfi_cses.py
@@ -10,10 +10,12 @@
from getpass import getpass
import logging
import json
-import urllib.parse
+from urllib.parse import urljoin
from pathlib import Path
from typing import AnyStr, Optional
+from xml.etree.ElementTree import Element, tostring
+from html2text import html2text
import htmlement
import requests
@@ -34,7 +36,7 @@ def __post_init__(self) -> None:
@property
def is_logged_in(self) -> bool:
- html = self.http_request(self.base_url)
+ html = self.http_request(urljoin(self.base_url, "list"))
login_link = find_link(html, './/a[@class="account"]')
login_text = login_link.get("text") or ""
return self.username in login_text
@@ -53,10 +55,10 @@ def login(self) -> None:
if self.is_logged_in:
return
- res = self.http_session.get(self.base_url)
+ res = self.http_session.get(urljoin(self.base_url, "list"))
login_link = find_link(res.text, './/a[@class="account"]')
if login_link:
- login_url = urllib.parse.urljoin(res.url, login_link.get("href"))
+ login_url = urljoin(res.url, login_link.get("href"))
else:
logging.debug(
f"url: {res.url}, status: {res.status_code}\nhtml:\n{res.text}"
@@ -78,7 +80,7 @@ def login(self) -> None:
login_form["session[password]"] = self.password
self.http_session.post(
- url=urllib.parse.urljoin(res.url, action),
+ url=urljoin(res.url, action),
headers={"referer": res.url},
data=login_form,
)
@@ -130,16 +132,26 @@ def parse_args(args: Optional[list[str]] = None) -> argparse.Namespace:
)
subparsers = parser.add_subparsers(required=True)
- parser_config = subparsers.add_parser("configure", help="Configure moocfi_cses")
- parser_config.set_defaults(cmd="configure")
+ # login subparser
+ parser_login = subparsers.add_parser("login", help="Login to mooc.fi CSES")
+ parser_login.set_defaults(cmd="login")
+ # list exercises subparser
parser_list = subparsers.add_parser("list", help="List exercises")
parser_list.set_defaults(cmd="list")
parser_list.add_argument(
"--filter",
- help="List complete, incomplete or all tasks (default: %(default)s)",
+ help="List only complete or incomplete tasks (default: all)",
choices=["complete", "incomplete"],
)
+ parser_list.add_argument(
+ "--limit", help="Maximum amount of items to list", type=int
+ )
+
+ # show exercise subparser
+ parser_show = subparsers.add_parser("show", help="Show details of an exercise")
+ parser_show.set_defaults(cmd="show")
+ parser_show.add_argument("task_id", help="Numerical task identifier")
return parser.parse_args(args)
@@ -252,10 +264,13 @@ class Task:
id: str
name: str
state: TaskState
+ description: str = "N/A"
+ code: str = "N/A"
+ submit_file: str = "N/A"
# TODO: this should be part of a client class or module
-def parse_task_list(html: str | bytes) -> list[Task]:
+def parse_task_list(html: AnyStr) -> list[Task]:
"""Parse html to find tasks and their status, return something useful, possibly a specific data class"""
content_element = htmlement.fromstring(html).find('.//div[@class="content"]')
task_list = list()
@@ -288,10 +303,59 @@ def parse_task_list(html: str | bytes) -> list[Task]:
# TODO: This should be part of a UI class or module
-def print_task_list(task_list: list[Task], filter: Optional[str] = None) -> None:
+def print_task_list(
+ task_list: list[Task], filter: Optional[str] = None, limit: Optional[int] = None
+) -> None:
+ count: int = 0
for task in task_list:
if not filter or filter == task.state.value:
print(f"- {task.id}: {task.name} {TASK_STATE_ICON[task.state]}")
+ count += 1
+ if limit and count >= limit:
+ return
+
+
+# TODO: Implement function that parser the specific task page into Task object
+# TODO: should we split up this function in a bunch of smaller ones? or will beautifulsoup make it simpler?
+def parse_task(html: AnyStr) -> Task:
+ root = htmlement.fromstring(html)
+ task_link_element = root.find('.//div[@class="nav sidebar"]/a')
+ task_link = task_link_element if task_link_element is not None else Element("a")
+ task_id = task_link.get("href", "").split("/")[-1]
+ task_name = task_link.text or "N/A"
+ task_span_element = task_link.find("span")
+ task_span = task_span_element if task_span_element is not None else Element("span")
+ task_span_class = task_span.get("class", "")
+ desc_div_element = root.find('.//div[@class="md"]')
+ desc_div = desc_div_element if desc_div_element is not None else Element("div")
+ description = html2text(tostring(desc_div).decode("utf8"))
+ code = root.findtext(".//pre", "N/A")
+ submit_file = next(
+ iter(
+ [
+ code_element.text
+ for code_element in root.findall(".//code")
+ if code_element.text is not None and ".py" in code_element.text
+ ]
+ ),
+ "N/A",
+ )
+ task = Task(
+ id=task_id,
+ name=task_name,
+ state=TaskState.COMPLETE if "full" in task_span_class else TaskState.INCOMPLETE,
+ description=description.strip(),
+ code=code,
+ submit_file=submit_file,
+ )
+
+ return task
+
+
+def print_task(task: Task) -> None:
+ print(f"{task.id}: {task.name} {TASK_STATE_ICON[task.state]}")
+ print(task.description)
+ print(f"\nSubmission file name: {task.submit_file}")
# TODO: Implement function that posts the submit form with the correct file
@@ -301,12 +365,6 @@ def submit_task(task_id: str, filename: str) -> None:
...
-# TODO: Implement function that parser the specific task page into Task object
-def parse_task(html: str | bytes, task: Task) -> Task:
- task = Task("a", "b", TaskState.COMPLETE)
- return task
-
-
def main() -> None:
args = parse_args()
@@ -316,7 +374,7 @@ def main() -> None:
datefmt="%Y-%m-%d %H:%M:%S",
)
- if args.cmd == "configure":
+ if args.cmd == "login":
config = create_config()
write_config(args.config, config)
return
@@ -326,7 +384,7 @@ def main() -> None:
# Merge cli args and configfile parameters in one dict
config.update((k, v) for k, v in vars(args).items() if v is not None)
- base_url = f"https://cses.fi/{config['course']}/list/"
+ base_url = f"https://cses.fi/{config['course']}/"
cookiefile = None
cookies = dict()
@@ -350,9 +408,14 @@ def main() -> None:
write_cookie_file(str(cookiefile), cookies)
if args.cmd == "list":
- html = session.http_request(base_url)
+ html = session.http_request(urljoin(base_url, "list"))
task_list = parse_task_list(html)
- print_task_list(task_list, filter=args.filter)
+ print_task_list(task_list, filter=args.filter, limit=args.limit)
+
+ if args.cmd == "show":
+ html = session.http_request(urljoin(base_url, f"task/{args.task_id}"))
+ task = parse_task(html)
+ print_task(task)
if __name__ == "__main__":
diff --git a/tests/test_moocfi_cses.py b/tests/test_moocfi_cses.py
index 2b9628c..73906fc 100644
--- a/tests/test_moocfi_cses.py
+++ b/tests/test_moocfi_cses.py
@@ -49,7 +49,6 @@ class TestParseForm:
)
-# TODO: verify these mocked tests are actually accurate
# TODO: add tests for unreachable and failing endpoints, 4xx, 5xx, etc
@pytest.fixture
def mock_session() -> moocfi_cses.Session:
@@ -64,7 +63,7 @@ def test_login_successful(mock_session: moocfi_cses.Session) -> None:
# Mocking the HTTP response for successful login
with requests_mock.Mocker() as m:
m.get(
- "https://example.com",
+ "https://example.com/list",
text='test_user@test.com (mooc.fi)',
)
mock_session.login()
@@ -75,7 +74,7 @@ def test_login_failed(mock_session: moocfi_cses.Session) -> None:
# Mocking the HTTP response for failed login
with requests_mock.Mocker() as m:
m.get(
- "https://example.com",
+ "https://example.com/list",
text='Login using mooc.fi',
)
m.get("https://example.com/account", text="Login required")