From 43999503a6c569b786f95c3a7676f8cd3f4f87c3 Mon Sep 17 00:00:00 2001 From: Edwin Hermans Date: Thu, 16 May 2024 09:27:05 -0400 Subject: [PATCH 1/5] Rename project to something more typeable, tyora! --- README.md | 21 +++++++++++++-------- moocfi_cses.py => tyora.py | 8 ++++---- 2 files changed, 17 insertions(+), 12 deletions(-) rename moocfi_cses.py => tyora.py (98%) diff --git a/README.md b/README.md index c46ceca..a43626a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ -# mooc.fi CSES exercise task CLI -[![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/madeddie/moocfi_cses/test.yml)](https://github.com/madeddie/moocfi_cses/actions) +# Tyora: mooc.fi CSES exercise task CLI +[![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/madeddie/tyora/test.yml)](https://github.com/madeddie/tyora/actions) -This script interacts with the mooc.fi instance of the CSES (https://cses.fi) website to perform various actions such as logging in, retrieving exercise lists, and submitting solutions. It provides a convenient way to view and submit tasks. +This script interacts with the mooc.fi instance of the CSES (https://cses.fi) website to perform various actions such as logging in, retrieving exercise lists, and submitting solutions. +It provides a convenient way to view and submit tasks. ## Features @@ -14,13 +15,13 @@ This script interacts with the mooc.fi instance of the CSES (https://cses.fi) we 1. Clone the repository to your local machine: ```bash - git clone https://github.com/madeddie/moocfi_cses.git + git clone https://github.com/madeddie/tyora.git ``` 2. Navigate to the project directory: ```bash - cd moocfi_cses + cd tyora ``` 3. Install the required dependencies: @@ -34,7 +35,7 @@ This script interacts with the mooc.fi instance of the CSES (https://cses.fi) we 1. Configure the script by running: ```bash - python moocfi_cses.py configure + python tyora.py configure ``` Follow the prompts to enter your mooc.fi username and password. This information will be stored for future use. @@ -42,7 +43,7 @@ This script interacts with the mooc.fi instance of the CSES (https://cses.fi) we 2. List available exercises: ```bash - python moocfi_cses.py list + python tyora.py list ``` This will retrieve and display a list of exercises available on the CSES platform. @@ -50,11 +51,15 @@ This script interacts with the mooc.fi instance of the CSES (https://cses.fi) we 3. Submit a solution: ```bash - python moocfi_cses.py submit + python tyora.py submit ``` Replace `` with the ID of the exercise you want to submit a solution for, and `` with the path to your solution file. +## Origin of name + +In Finnish, "työ" means "work", "pyörä" means "wheel". "Työrä" would be "work wheel"? Anyway, `pyora` was already taken, so I went with `tyora`... ;) + ## Contributing Contributions are welcome! If you have any suggestions, bug reports, or feature requests, please open an issue or submit a pull request. diff --git a/moocfi_cses.py b/tyora.py similarity index 98% rename from moocfi_cses.py rename to tyora.py index 2316afb..4f0aa74 100644 --- a/moocfi_cses.py +++ b/tyora.py @@ -14,7 +14,7 @@ import requests -logger = logging.getLogger(name="moocfi_cses") +logger = logging.getLogger(name="tyora") @dataclass @@ -115,7 +115,7 @@ def parse_args(args: Optional[list[str]] = None) -> argparse.Namespace: parser.add_argument( "--config", help="Location of config file (default: %(default)s)", - default="~/.config/moocfi_cses/config.json", + default="~/.config/tyora/config.json", ) parser.add_argument( "--no-state", @@ -162,7 +162,7 @@ def create_config() -> dict[str, str]: def write_config(configfile: str, config: dict[str, str]) -> None: file_path = Path(configfile).expanduser() if file_path.exists(): - # TODO: https://github.com/madeddie/moocfi_cses/issues/28 + # TODO: https://github.com/madeddie/tyora/issues/28 ... file_path.parent.mkdir(parents=True, exist_ok=True) # Ensure directory exists print("Writing config to file") @@ -371,7 +371,7 @@ def main() -> None: cookiefile = None cookies = dict() if not args.no_state: - state_dir = Path("~/.local/state/moocfi_cses").expanduser() + state_dir = Path("~/.local/state/tyora").expanduser() if not state_dir.exists(): state_dir.mkdir(parents=True, exist_ok=True) cookiefile = state_dir / "cookies.txt" From 4eb6aacadf6e99ac0f13c24940178b9b3cd7754f Mon Sep 17 00:00:00 2001 From: Edwin Hermans Date: Thu, 16 May 2024 09:32:59 -0400 Subject: [PATCH 2/5] Correctly rename tests and module in tests --- tests/{test_moocfi_cses.py => test_tyora.py} | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) rename tests/{test_moocfi_cses.py => test_tyora.py} (81%) diff --git a/tests/test_moocfi_cses.py b/tests/test_tyora.py similarity index 81% rename from tests/test_moocfi_cses.py rename to tests/test_tyora.py index 73906fc..cd5f527 100644 --- a/tests/test_moocfi_cses.py +++ b/tests/test_tyora.py @@ -1,15 +1,15 @@ -import moocfi_cses +import tyora import pytest import requests_mock def test_parse_args_missing_args() -> None: with pytest.raises(SystemExit): - moocfi_cses.parse_args() + tyora.parse_args() def test_parse_args_command() -> None: - args = moocfi_cses.parse_args(["list"]) + args = tyora.parse_args(["list"]) assert args.cmd == "list" @@ -26,15 +26,15 @@ class TestFindLink: def test_find_link_success(self) -> None: assert ( - moocfi_cses.find_link(self.valid_html, self.valid_xpath) + tyora.find_link(self.valid_html, self.valid_xpath) == self.valid_return ) def test_find_link_bad_xpath(self) -> None: - assert moocfi_cses.find_link(self.valid_html, self.invalid_xpath) == {} + assert tyora.find_link(self.valid_html, self.invalid_xpath) == {} def test_find_link_bad_html(self) -> None: - assert moocfi_cses.find_link(self.invalid_html, self.valid_xpath) == {} + assert tyora.find_link(self.invalid_html, self.valid_xpath) == {} class TestParseForm: @@ -51,15 +51,15 @@ class TestParseForm: # TODO: add tests for unreachable and failing endpoints, 4xx, 5xx, etc @pytest.fixture -def mock_session() -> moocfi_cses.Session: - return moocfi_cses.Session( +def mock_session() -> tyora.Session: + return tyora.Session( username="test_user@test.com", password="test_password", base_url="https://example.com", ) -def test_login_successful(mock_session: moocfi_cses.Session) -> None: +def test_login_successful(mock_session: tyora.Session) -> None: # Mocking the HTTP response for successful login with requests_mock.Mocker() as m: m.get( @@ -70,7 +70,7 @@ def test_login_successful(mock_session: moocfi_cses.Session) -> None: assert mock_session.is_logged_in -def test_login_failed(mock_session: moocfi_cses.Session) -> None: +def test_login_failed(mock_session: tyora.Session) -> None: # Mocking the HTTP response for failed login with requests_mock.Mocker() as m: m.get( From 9e002c46cbba5bbdf834912a2da6f4311d799561 Mon Sep 17 00:00:00 2001 From: Edwin Hermans Date: Thu, 16 May 2024 09:34:51 -0400 Subject: [PATCH 3/5] Slightly better wording of the origin of the name --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a43626a..8d7a10e 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,8 @@ It provides a convenient way to view and submit tasks. ## Origin of name -In Finnish, "työ" means "work", "pyörä" means "wheel". "Työrä" would be "work wheel"? Anyway, `pyora` was already taken, so I went with `tyora`... ;) +The name "tyora" is derived from Finnish words: "työ" meaning "work" and "pyörä" meaning "wheel". +Anyway, `pyora` was already taken, so I went with `tyora`... ;) ## Contributing From a595f44a0316fe1582201095e7e228dbd703b621 Mon Sep 17 00:00:00 2001 From: Edwin Hermans Date: Tue, 14 May 2024 09:35:16 -0400 Subject: [PATCH 4/5] First version of the submit task code --- tyora.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 72 insertions(+), 8 deletions(-) diff --git a/tyora.py b/tyora.py index 4f0aa74..451be63 100644 --- a/tyora.py +++ b/tyora.py @@ -6,6 +6,7 @@ import json from urllib.parse import urljoin from pathlib import Path +from time import sleep from typing import AnyStr, Optional from xml.etree.ElementTree import Element, tostring @@ -145,6 +146,12 @@ def parse_args(args: Optional[list[str]] = None) -> argparse.Namespace: parser_show.set_defaults(cmd="show") 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.set_defaults(cmd="submit") + parser_submit.add_argument("--filename", help="Filename of the solution to submit") + parser_submit.add_argument("task_id", help="Numerical task identifier") + return parser.parse_args(args) @@ -221,7 +228,7 @@ def find_link(html: AnyStr, xpath: str) -> dict[str, str | None]: return link_data -def parse_form(html: AnyStr, xpath: str = ".//form") -> dict[str, str | None]: +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 = htmlement.fromstring(html).find(xpath) form_data = dict() @@ -251,9 +258,10 @@ class Task: id: str name: str state: TaskState - description: str = "N/A" - code: str = "N/A" - submit_file: str = "N/A" + description: Optional[str] = None + code: Optional[str] = None + submit_file: Optional[str] = None + submit_link: Optional[str] = None def parse_task_list(html: AnyStr) -> list[Task]: @@ -313,6 +321,13 @@ def parse_task(html: AnyStr) -> Task: 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_link_element = root.find('.//a[.="Submit"]') + submit_link = ( + submit_link_element.get("href", "N/A") + if submit_link_element is not None + else "N/A" + ) + submit_file = next( iter( [ @@ -330,6 +345,7 @@ def parse_task(html: AnyStr) -> Task: description=description.strip(), code=code, submit_file=submit_file, + submit_link=submit_link, ) return task @@ -341,10 +357,13 @@ def print_task(task: Task) -> None: print(f"\nSubmission file name: {task.submit_file}") -def submit_task(task_id: str, filename: str) -> None: - """submit file to the submit form or task_id""" - # NOTE: use parse_form - ... +# def submit_task(task_id: str, filename: str) -> None: +# """submit file to the submit form or task_id""" +# html = session.http_request(urljoin(base_url, f"task/{task_id}")) +# task = parse_task(html) +# answer = input("Do you want to submit this task? (y/n): ") +# if answer in ('y', 'Y'): +# with open(filename, 'r') as f: def main() -> None: @@ -399,6 +418,51 @@ def main() -> None: task = parse_task(html) print_task(task) + if args.cmd == "submit": + html = session.http_request(urljoin(base_url, f"task/{args.task_id}")) + task = parse_task(html) + if not task.submit_file or task.submit_file == "N/A": + raise ValueError("No submission filename found") + if not task.submit_link or task.submit_link == "N/A": + raise ValueError("No submission link found") + + submit_form_html = session.http_request(urljoin(base_url, task.submit_link)) + submit_form_data = parse_form(submit_form_html) + action = submit_form_data.pop("_action") + + for key, value in submit_form_data.items(): + submit_form_data[key] = (None, value) + submit_form_data["file"] = (task.submit_file, open(task.submit_file, "rb")) + submit_form_data["lang"] = (None, "Python3") + submit_form_data["option"] = (None, "CPython3") + + res = session.http_session.post( + urljoin(base_url, action), files=submit_form_data + ) + html = res.text + result_url = res.url + print("Waiting for test results.", end="") + while "Test report" not in html: + print(".", end="") + sleep(1) + html = session.http_request(result_url) + + print() + root = htmlement.fromstring(html) + 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 "" + + print(f"Submission status: {submit_status.lower()}") + print(f"Submission result: {submit_result.lower()}") + if __name__ == "__main__": main() From e555cc0bebfccf2420c361d61a39503a694ec79f Mon Sep 17 00:00:00 2001 From: Edwin Hermans Date: Thu, 16 May 2024 12:13:14 -0400 Subject: [PATCH 5/5] Run ruff and black on code --- tests/test_tyora.py | 17 +++++------------ tyora.py | 8 +++++--- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/tests/test_tyora.py b/tests/test_tyora.py index cd5f527..8bf2982 100644 --- a/tests/test_tyora.py +++ b/tests/test_tyora.py @@ -25,10 +25,7 @@ class TestFindLink: 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 - ) + 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) == {} @@ -84,17 +81,13 @@ def test_login_failed(mock_session: tyora.Session) -> None: # TODO: functions that use user input or read or write files -def test_create_config() -> None: - ... +def test_create_config() -> None: ... -def test_write_config() -> None: - ... +def test_write_config() -> None: ... -def test_read_config() -> None: - ... +def test_read_config() -> None: ... -def test_get_cookiejar() -> None: - ... +def test_get_cookiejar() -> None: ... diff --git a/tyora.py b/tyora.py index 451be63..c82ab2d 100644 --- a/tyora.py +++ b/tyora.py @@ -287,9 +287,11 @@ def parse_task_list(html: AnyStr) -> list[Task]: task = Task( id=item_id, name=item_name, - state=TaskState.COMPLETE - if "full" in item_class - else TaskState.INCOMPLETE, + state=( + TaskState.COMPLETE + if "full" in item_class + else TaskState.INCOMPLETE + ), ) task_list.append(task)