diff --git a/tests/test_data/task_3055_complete.html b/tests/test_data/task_3055_complete.html
new file mode 100644
index 0000000..183dbf7
--- /dev/null
+++ b/tests/test_data/task_3055_complete.html
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
CSES - Candies
+
+
+
+
+
+
+
A gummy candy costs a euros and a chocolate candy costs b euros. What is the maximum number of candies you can buy if you have c euros?
+
You may assume that a , b and c are integers in the range 1 \dots 100 .
+
In a file candies.py
, implement the function count
that returns the maximum number of candies.
+
def count(a, b, c):
+ # TODO
+
+if __name__ == "__main__":
+ print(count(3, 4, 11)) # 3
+ print(count(5, 1, 100)) # 100
+ print(count(2, 3, 1)) # 0
+ print(count(2, 3, 9)) # 4
+
+
Explanation : In the first test, a gummy candy costs 3 euros and a chocolate candy costs 4 euros. You can buy at most 3 candies with 11 euros. For example, two gummy candies and one chocolate candy cost a total of 10 euros leaving you with 1 euro.
+
+
+
diff --git a/tests/test_session.py b/tests/test_session.py
index 772ea1c..aee6c0a 100644
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -1,5 +1,6 @@
import pytest
import requests_mock
+
from tyora.session import MoocfiCsesSession as Session
test_cookies = {"cookie_a": "value_a", "cookie_b": "value_b"}
@@ -8,8 +9,6 @@
@pytest.fixture
def mock_session() -> Session:
return Session(
- username="test_user@test.com",
- password="test_password",
base_url="https://example.com",
cookies=test_cookies,
)
@@ -22,7 +21,7 @@ def test_login_successful(mock_session: Session) -> None:
"https://example.com/list",
text=open("tests/test_data/session_logged_in.html").read(),
)
- mock_session.login()
+ mock_session.login(username="test_user@test.com", password="test_password")
print(mock_session.get("https://example.com/list").text)
assert mock_session.is_logged_in
@@ -43,7 +42,7 @@ def test_login_failed(mock_session: Session) -> None:
text=open("tests/test_data/tmcmoocfi-sessions.html").read(),
)
with pytest.raises(ValueError):
- mock_session.login()
+ mock_session.login(username="test_user@test.com", password="test_password")
def test_loading_cookies(mock_session: Session) -> None:
diff --git a/tests/test_tyora.py b/tests/test_tyora.py
index 09c3148..5233540 100644
--- a/tests/test_tyora.py
+++ b/tests/test_tyora.py
@@ -1,10 +1,11 @@
import pytest
+
from tyora import tyora
def test_parse_args_missing_args() -> None:
with pytest.raises(SystemExit):
- tyora.parse_args()
+ _ = tyora.parse_args()
def test_parse_args_command() -> None:
@@ -12,39 +13,6 @@ def test_parse_args_command() -> None:
assert args.cmd == "list"
-class TestFindLink:
- valid_html = (
- ""
- '
sometext '
- ""
- )
- invalid_html = "No links here"
- valid_xpath = './/a[@class="someclass"]'
- invalid_xpath = './/a[@class="somethingelse"]'
- valid_return = {"href": "somelink", "text": "sometext"}
-
- def test_find_link_success(self) -> None:
- assert tyora.find_link(self.valid_html, self.valid_xpath) == self.valid_return
-
- def test_find_link_bad_xpath(self) -> None:
- assert tyora.find_link(self.valid_html, self.invalid_xpath) == {}
-
- def test_find_link_bad_html(self) -> None:
- assert tyora.find_link(self.invalid_html, self.valid_xpath) == {}
-
-
-class TestParseForm:
- valid_html = (
- '
"
- )
- noform_html = "No form here"
- noinput_html = (
- '
'
- )
-
-
# TODO: functions that use user input or read or write files
def test_create_config() -> None: ...
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..4d94cac
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,46 @@
+from tyora import utils
+
+
+class TestFindLink:
+ valid_html = (
+ ""
+ '
sometext '
+ ""
+ )
+ invalid_html = "No links here"
+ valid_xpath = './/a[@class="someclass"]'
+ invalid_xpath = './/a[@class="somethingelse"]'
+ valid_return = {"href": "somelink", "text": "sometext"}
+
+ def test_find_link_success(self) -> None:
+ assert utils.find_link(self.valid_html, self.valid_xpath) == self.valid_return
+
+ def test_find_link_bad_xpath(self) -> None:
+ assert utils.find_link(self.valid_html, self.invalid_xpath) == {}
+
+ def test_find_link_bad_html(self) -> None:
+ assert utils.find_link(self.invalid_html, self.valid_xpath) == {}
+
+
+class TestParseForm:
+ valid_html = (
+ '
"
+ )
+ noform_html = "No form here"
+ noinput_html = (
+ '
'
+ )
+
+ def test_parse_form_success(self) -> None:
+ assert utils.parse_form(self.valid_html) == {
+ "_action": "someaction",
+ "somename": "somevalue",
+ }
+
+ def test_parse_form_no_form(self) -> None:
+ assert utils.parse_form(self.noform_html) == {}
+
+ def test_parse_form_no_input(self) -> None:
+ assert utils.parse_form(self.noinput_html) == {"_action": "someaction"}
diff --git a/tyora/__init__.py b/tyora/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/tyora/__main__.py b/tyora/__main__.py
similarity index 100%
rename from src/tyora/__main__.py
rename to tyora/__main__.py
diff --git a/tyora/client.py b/tyora/client.py
new file mode 100644
index 0000000..8fed3df
--- /dev/null
+++ b/tyora/client.py
@@ -0,0 +1,192 @@
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from enum import Enum
+from typing import AnyStr, Optional
+from urllib.parse import urljoin
+from xml.etree.ElementTree import Element, tostring
+
+import html5lib
+from html2text import html2text
+
+from .session import MoocfiCsesSession as Session
+from .utils import parse_form
+
+logger = logging.getLogger(__name__)
+
+
+class TaskState(Enum):
+ COMPLETE = "complete"
+ INCOMPLETE = "incomplete"
+
+
+@dataclass
+class Task:
+ id: str
+ name: str
+ state: TaskState
+ description: Optional[str] = None
+ code: Optional[str] = None
+ submit_file: Optional[str] = None
+ submit_link: Optional[str] = None
+
+
+class Client:
+ def __init__(self, session: Session) -> None:
+ self.session = session
+
+ def get_task_list(self) -> list[Task]:
+ res = self.session.get(urljoin(self.session.base_url, "list"))
+ res.raise_for_status()
+ return parse_task_list(res.text)
+
+ def get_task(self, task_id: str) -> Task:
+ res = self.session.get(urljoin(self.session.base_url, f"task/{task_id}"))
+ res.raise_for_status()
+ try:
+ task = parse_task(res.text)
+ except ValueError as e:
+ logger.debug(f"Error parsing task: {e}")
+ raise
+ return task
+
+ def submit_task(
+ self, task_id: str, submission: str, filename: Optional[str]
+ ) -> str:
+ task = self.get_task(task_id)
+ if not task.submit_file and not filename:
+ raise ValueError("No submission filename found for task ID: " + task_id)
+ if not task.submit_link:
+ raise ValueError("No submit link found for task ID: " + task_id)
+ submit_file = task.submit_file or filename
+
+ res = self.session.get(urljoin(self.session.base_url, task.submit_link))
+ res.raise_for_status()
+ parsed_form_data = parse_form(res.text)
+ action = parsed_form_data.pop("_action")
+
+ submit_form_data: dict[str, tuple[Optional[str], Optional[str]]] = dict()
+ for key, value in parsed_form_data.items():
+ submit_form_data[key] = (None, value)
+ submit_form_data["file"] = (submit_file, submission)
+ submit_form_data["lang"] = (None, "Python3")
+ submit_form_data["option"] = (None, "CPython3")
+ res = self.session.post(
+ urljoin(self.session.base_url, action),
+ files=submit_form_data, # type: ignore[arg-type]
+ )
+ res.raise_for_status()
+ return res.url
+
+
+def parse_task_list(html: AnyStr) -> list[Task]:
+ """Parse html to find tasks and their status, returns list of Task objects"""
+ root = html5lib.parse(html, namespaceHTMLElements=False) # type: ignore[reportUnknownMemberType]
+ task_element_list = root.findall('.//li[@class="task"]')
+
+ task_list: list[Task] = list()
+ for task_element in task_element_list:
+ task_id = None
+ task_name = None
+ task_state = None
+
+ task_link = task_element.find("a")
+ if task_link is None:
+ continue
+
+ task_name = task_link.text
+ task_id = task_link.get("href", "/").split("/")[-1]
+ if not task_name or not task_id:
+ continue
+
+ task_element_spans = task_element.findall("span")
+ if not task_element_spans:
+ continue
+
+ task_element_span = next(
+ (span for span in task_element_spans if span.get("class", "") != "detail"),
+ Element("span"),
+ )
+ task_element_class = task_element_span.get("class") or ""
+
+ task_state = (
+ TaskState.COMPLETE if "full" in task_element_class else TaskState.INCOMPLETE
+ )
+
+ task = Task(
+ id=task_id,
+ name=task_name,
+ state=task_state,
+ )
+ task_list.append(task)
+
+ return task_list
+
+
+def parse_task(html: AnyStr) -> Task:
+ root = html5lib.parse(html, namespaceHTMLElements=False) # type: ignore[reportUnknownMemberType]
+ task_link_element = root.find('.//div[@class="nav sidebar"]/a[@class="current"]')
+ task_link = task_link_element if task_link_element is not None else Element("a")
+ task_id = task_link.get("href", "").split("/")[-1]
+ if not task_id:
+ raise ValueError("Failed to find task id")
+ task_name = task_link.text or None
+ if not task_name:
+ raise ValueError("Failed to find task name")
+ 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", None)
+ submit_link_element = root.find('.//a[.="Submit"]')
+ submit_link = (
+ submit_link_element.get("href", None)
+ if submit_link_element is not None
+ else None
+ )
+
+ 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
+ ]
+ ),
+ None,
+ )
+ 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,
+ submit_link=submit_link,
+ )
+
+ return task
+
+
+# TODO test with failing results
+# Seems to be broken since the switch to html5lib, needs tests!
+def parse_submit_result(html: AnyStr) -> dict[str, str]:
+ root = html5lib.parse(html, namespaceHTMLElements=False) # type: ignore[reportUnknownMemberType]
+ submit_status_element = root.find('.//td[.="Status:"]/..') or Element("td")
+ submit_status_span_element = submit_status_element.find("td/span") or Element(
+ "span"
+ )
+ submit_status = submit_status_span_element.text or ""
+ submit_result_element = root.find('.//td[.="Result:"]/..') or Element("td")
+ submit_result_span_element = submit_result_element.find("td/span") or Element(
+ "span"
+ )
+ submit_result = submit_result_span_element.text or ""
+
+ return {
+ "status": submit_status.lower(),
+ "result": submit_result.lower(),
+ }
diff --git a/src/tyora/session.py b/tyora/session.py
similarity index 56%
rename from src/tyora/session.py
rename to tyora/session.py
index 376cc45..29ac205 100644
--- a/src/tyora/session.py
+++ b/tyora/session.py
@@ -2,13 +2,14 @@
import logging
import os
import sys
-from typing import AnyStr, Optional
+from typing import Optional
from urllib.parse import urljoin
-import html5lib
import requests
from requests_toolbelt import user_agent
+from .utils import find_link, parse_form
+
logger = logging.getLogger(__name__)
try:
@@ -19,18 +20,10 @@
class MoocfiCsesSession(requests.Session):
def __init__(
- self,
- username: str,
- password: str,
- base_url: str,
- cookies: Optional[dict] = None,
- *args,
- **kwargs,
+ self, base_url: str, cookies: Optional[dict[str, str]] = None, *args, **kwargs
):
super().__init__(*args, **kwargs)
- self.username = username
- self.password = password
self.base_url = base_url
if cookies:
@@ -44,11 +37,10 @@ def __init__(
def is_logged_in(self) -> bool:
res = self.get(urljoin(self.base_url, "list"))
res.raise_for_status()
- login_link = find_link(res.text, './/a[@class="account"]')
- login_text = login_link.get("text") or ""
- return self.username in login_text
+ logout_link = find_link(res.text, './/a[@title="Log out"]')
+ return bool(logout_link)
- def login(self) -> None:
+ def login(self, username: str, password: str) -> None:
"""Log into the site using webscraping
Steps:
@@ -75,18 +67,17 @@ def login(self) -> None:
res = self.get(login_url, headers={"referer": res.url})
login_form = parse_form(res.text, ".//form")
if login_form:
- action = login_form.get("_action")
- login_form.pop("_action")
+ action = login_form.pop("_action")
else:
logger.debug(
f"url: {res.url}, status: {res.status_code}\nhtml:\n{res.text}"
)
raise ValueError("Failed to find login form")
- login_form["session[login]"] = self.username
- login_form["session[password]"] = self.password
+ login_form["session[login]"] = username
+ login_form["session[password]"] = password
- self.post(
+ _ = self.post(
url=urljoin(res.url, action),
headers={"referer": res.url},
data=login_form,
@@ -97,30 +88,3 @@ def login(self) -> None:
f"url: {res.url}, status: {res.status_code}\nhtml:\n{res.text}"
)
raise ValueError("Login failed")
-
-
-def find_link(html: AnyStr, xpath: str) -> dict[str, Optional[str]]:
- """Search for html link by xpath and return dict with href and text"""
- anchor_element = html5lib.parse(html, namespaceHTMLElements=False).find(xpath)
- if anchor_element is None:
- return dict()
-
- link_data = dict()
- link_data["href"] = anchor_element.get("href")
- link_data["text"] = anchor_element.text
-
- return link_data
-
-
-def parse_form(html: AnyStr, xpath: str = ".//form") -> dict:
- """Search for the first form in html and return dict with action and all other found inputs"""
- form_element = html5lib.parse(html, namespaceHTMLElements=False).find(xpath)
- form_data = dict()
- if form_element is not None:
- form_data["_action"] = form_element.get("action")
- for form_input in form_element.iter("input"):
- form_key = form_input.get("name") or ""
- form_value = form_input.get("value") or ""
- form_data[form_key] = form_value
-
- return form_data
diff --git a/tyora/tyora.py b/tyora/tyora.py
new file mode 100644
index 0000000..d6e2403
--- /dev/null
+++ b/tyora/tyora.py
@@ -0,0 +1,252 @@
+import argparse
+import importlib.metadata
+import json
+import logging
+import sys
+from getpass import getpass
+from pathlib import Path
+from time import sleep
+from typing import Optional, no_type_check
+
+import platformdirs
+
+from .client import Client, Task, TaskState, parse_submit_result
+from .session import MoocfiCsesSession as Session
+
+logger = logging.getLogger(name="tyora")
+try:
+ __version__ = importlib.metadata.version("tyora")
+except importlib.metadata.PackageNotFoundError:
+ __version__ = "unknown"
+
+PROG_NAME = "tyora"
+CONF_FILE = platformdirs.user_config_path(PROG_NAME) / "config.json"
+STATE_DIR = platformdirs.user_state_path(f"{PROG_NAME}")
+
+
+# Disable typechecking for the argparse function calls since we ignore most returned values
+@no_type_check
+def parse_args(args: Optional[list[str]] = None) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Interact with mooc.fi CSES instance")
+ parser.add_argument(
+ "-V", "--version", action="version", version=f"%(prog)s {__version__}"
+ )
+ parser.add_argument("-u", "--username", help="tmc.mooc.fi username")
+ parser.add_argument("-p", "--password", help="tmc.mooc.fi password")
+ parser.add_argument(
+ "--debug", help="set logging level to debug", action="store_true"
+ )
+ parser.add_argument(
+ "--course",
+ help="SLUG of the course (default: %(default)s)",
+ default="dsa24k",
+ )
+ parser.add_argument(
+ "--config",
+ help="Location of config file (default: %(default)s)",
+ default=CONF_FILE,
+ )
+ parser.add_argument(
+ "--no-state",
+ help="Don't store cookies or cache (they're used for faster access on the future runs)",
+ action="store_true",
+ )
+ subparsers = parser.add_subparsers(required=True, title="commands", dest="cmd")
+
+ # login subparser
+ subparsers.add_parser("login", help="Login to mooc.fi CSES")
+
+ # list exercises subparser
+ parser_list = subparsers.add_parser("list", help="List exercises")
+ parser_list.add_argument(
+ "--filter",
+ 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.add_argument("task_id", help="Numerical task identifier")
+
+ # submit exercise solution subparser
+ parser_submit = subparsers.add_parser("submit", help="Submit an exercise solution")
+ parser_submit.add_argument(
+ "--filename",
+ help="Filename of the solution to submit (if not given will be guessed from task description)",
+ )
+ parser_submit.add_argument("task_id", help="Numerical task identifier")
+
+ if len(sys.argv) == 1:
+ parser.print_help()
+ sys.exit(1)
+
+ return parser.parse_args(args)
+
+
+def create_config() -> dict[str, str]:
+ username = input("Your tmc.mooc.fi username: ")
+ password = getpass("Your tmc.mooc.fi password: ")
+ config = {
+ "username": username,
+ "password": password,
+ }
+
+ return config
+
+
+def write_config(configfile: str, config: dict[str, str]) -> None:
+ file_path = Path(configfile).expanduser()
+ if file_path.exists():
+ # TODO: https://github.com/madeddie/tyora/issues/28
+ ...
+ # Ensure directory exists
+ file_path.parent.mkdir(parents=True, exist_ok=True)
+ print("Writing config to file")
+ with open(file_path, "w") as f:
+ json.dump(config, f)
+
+
+def read_config(configfile: str) -> dict[str, str]:
+ config: dict[str, str] = dict()
+ file_path = Path(configfile).expanduser()
+ with open(file_path, "r") as f:
+ config = json.load(f)
+ for setting in ("username", "password"):
+ assert setting in config
+ return config
+
+
+def read_cookie_file(cookiefile: str) -> dict[str, str]:
+ """
+ Reads cookies from a JSON formatted file.
+
+ Args:
+ cookiefile: str path to the file containing cookies.
+
+ Returns:
+ A dictionary of cookies.
+ """
+ try:
+ with open(cookiefile, "r") as f:
+ cookies: dict[str, str] = json.load(f)
+ return cookies
+ except (FileNotFoundError, json.decoder.JSONDecodeError) as e:
+ logger.debug(f"Error reading cookies from {cookiefile}: {e}")
+ return {}
+
+
+def write_cookie_file(cookiefile: str, cookies: dict[str, str]) -> None:
+ """
+ Writes cookies to a file in JSON format.
+
+ Args:
+ cookiefile: Path to the file for storing cookies.
+ cookies: A dictionary of cookies to write.
+ """
+ with open(cookiefile, "w") as f:
+ json.dump(cookies, f)
+
+
+TASK_STATE_ICON = {
+ TaskState.COMPLETE: "✅",
+ TaskState.INCOMPLETE: "❌",
+}
+
+
+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
+
+
+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}")
+
+
+def main() -> None:
+ args = parse_args()
+
+ logging.basicConfig(
+ level=logging.DEBUG if args.debug else logging.WARNING,
+ format="%(asctime)s %(levelname)s %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ )
+
+ if args.cmd == "login":
+ config = create_config()
+ write_config(args.config, config)
+ return
+
+ config = read_config(args.config)
+
+ # 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']}/"
+
+ cookiefile = None
+ cookies: dict[str, str] = dict()
+ if not args.no_state:
+ if not STATE_DIR.exists():
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
+ cookiefile = STATE_DIR / "cookies.json"
+ cookies = read_cookie_file(str(cookiefile))
+
+ session = Session(
+ base_url=base_url,
+ cookies=cookies,
+ )
+ # TODO: make logging in optional for list and show commands
+ session.login(username=config["username"], password=config["password"])
+ client = Client(session)
+
+ if not args.no_state and cookiefile:
+ cookies = session.cookies.get_dict()
+ write_cookie_file(str(cookiefile), cookies)
+
+ if args.cmd == "list":
+ print_task_list(client.get_task_list(), filter=args.filter, limit=args.limit)
+
+ if args.cmd == "show":
+ print_task(client.get_task(args.task_id))
+
+ if args.cmd == "submit":
+ # TODO allow user to paste the code in or even pipe it in
+ with open(args.filename) as f:
+ submission_code = f.read()
+
+ result_url = client.submit_task(
+ task_id=args.task_id,
+ filename=args.filename,
+ submission=submission_code,
+ )
+ print("Waiting for test results.", end="")
+ while True:
+ print(".", end="")
+ res = session.get(result_url)
+ print(res.text)
+ res.raise_for_status()
+ if "Test report" in res.text:
+ break
+ sleep(1)
+
+ print()
+ results = parse_submit_result(res.text)
+
+ print(f"Submission status: {results['status']}")
+ print(f"Submission result: {results['result']}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tyora/utils.py b/tyora/utils.py
new file mode 100644
index 0000000..b4500fe
--- /dev/null
+++ b/tyora/utils.py
@@ -0,0 +1,32 @@
+from typing import AnyStr, Optional
+
+import html5lib
+
+
+def find_link(html: AnyStr, xpath: str) -> dict[str, Optional[str]]:
+ """Search for html link by xpath and return dict with href and text"""
+ anchor_element = html5lib.parse(html, namespaceHTMLElements=False).find(xpath)
+ if anchor_element is None:
+ return dict()
+
+ link_data: dict[str, Optional[str]] = dict()
+ link_data["href"] = anchor_element.get("href")
+ link_data["text"] = anchor_element.text
+
+ return link_data
+
+
+def parse_form(html: AnyStr, xpath: str = ".//form") -> dict[str, Optional[str]]:
+ """Search for the first form in html and return dict with action and all other found inputs"""
+ form_element = html5lib.parse(html, namespaceHTMLElements=False).find(xpath)
+ if form_element is None:
+ return dict()
+
+ form_data: dict[str, Optional[str]] = dict()
+ form_data["_action"] = form_element.get("action")
+ for form_input in form_element.iter("input"):
+ form_key = form_input.get("name") or ""
+ form_value = form_input.get("value") or ""
+ form_data[form_key] = form_value
+
+ return form_data