petlja-api 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # poetry
98
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102
+ #poetry.lock
103
+
104
+ # pdm
105
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106
+ #pdm.lock
107
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108
+ # in version control.
109
+ # https://pdm.fming.dev/#use-with-ide
110
+ .pdm.toml
111
+
112
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113
+ __pypackages__/
114
+
115
+ # Celery stuff
116
+ celerybeat-schedule
117
+ celerybeat.pid
118
+
119
+ # SageMath parsed files
120
+ *.sage.py
121
+
122
+ # Environments
123
+ .env
124
+ .venv
125
+ env/
126
+ venv/
127
+ ENV/
128
+ env.bak/
129
+ venv.bak/
130
+
131
+ # Spyder project settings
132
+ .spyderproject
133
+ .spyproject
134
+
135
+ # Rope project settings
136
+ .ropeproject
137
+
138
+ # mkdocs documentation
139
+ /site
140
+
141
+ # mypy
142
+ .mypy_cache/
143
+ .dmypy.json
144
+ dmypy.json
145
+
146
+ # Pyre type checker
147
+ .pyre/
148
+
149
+ # pytype static type analyzer
150
+ .pytype/
151
+
152
+ # Cython debug symbols
153
+ cython_debug/
154
+
155
+ # PyCharm
156
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
159
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160
+ #.idea/
@@ -0,0 +1,11 @@
1
+ {
2
+ "python.testing.pytestArgs": [
3
+ "."
4
+ ],
5
+ "python.testing.unittestEnabled": false,
6
+ "python.testing.pytestEnabled": true,
7
+ "[python]": {
8
+ "editor.defaultFormatter": "ms-python.autopep8"
9
+ },
10
+ "python.formatting.provider": "none"
11
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Pavle Sekešan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.3
2
+ Name: petlja_api
3
+ Version: 0.0.1
4
+ Summary: A python library for interacting with the petlja.org API
5
+ Project-URL: Homepage, https://github.com/PavleSekesan/petlja_api
6
+ Author-email: Pavle Sekešan <pavlesekesan@gmail.com>
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.7
9
+ Requires-Dist: beautifulsoup4==4.12.2
10
+ Requires-Dist: requests==2.31.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # petlja_api
14
+
15
+ A python library for interacting with the [petlja.org](https://petlja.org/) API.
16
+
17
+ ## Installation
18
+ ```
19
+ pip install petlja_api
20
+ ```
21
+
22
+ ## Basic usage
23
+
24
+ ```py
25
+ import petlja_api as petlja
26
+
27
+ session = petlja.login()
28
+
29
+ # Create problem
30
+ prob_id = petlja.create_problem(session, name="My Problem", alias="my-prob")
31
+ petlja.upload_testcases(session, prob_id, "my-prob/testcases.zip")
32
+ petlja.upload_statement(session, prob_id, "my-prob/statement.md")
33
+
34
+ # Create competition
35
+ comp_id = petlja.create_competition(session, name="My Competition", alias="my-comp")
36
+ petlja.add_problem(session, comp_id, prob_id)
37
+
38
+ # Upload solution
39
+ petlja.score = submit(session, prob_id, "my-prob/sol.cpp", comp_id)
40
+ ```
@@ -0,0 +1,28 @@
1
+ # petlja_api
2
+
3
+ A python library for interacting with the [petlja.org](https://petlja.org/) API.
4
+
5
+ ## Installation
6
+ ```
7
+ pip install petlja_api
8
+ ```
9
+
10
+ ## Basic usage
11
+
12
+ ```py
13
+ import petlja_api as petlja
14
+
15
+ session = petlja.login()
16
+
17
+ # Create problem
18
+ prob_id = petlja.create_problem(session, name="My Problem", alias="my-prob")
19
+ petlja.upload_testcases(session, prob_id, "my-prob/testcases.zip")
20
+ petlja.upload_statement(session, prob_id, "my-prob/statement.md")
21
+
22
+ # Create competition
23
+ comp_id = petlja.create_competition(session, name="My Competition", alias="my-comp")
24
+ petlja.add_problem(session, comp_id, prob_id)
25
+
26
+ # Upload solution
27
+ petlja.score = submit(session, prob_id, "my-prob/sol.cpp", comp_id)
28
+ ```
@@ -0,0 +1,55 @@
1
+ # petljatest
2
+
3
+ Automatically create a competition, add its problems and test all of their solutions on [arena.petlja.org](https://arena.petlja.org/)
4
+
5
+ ## Usage
6
+
7
+ Directory structure should be [petljapub](https://pypi.org/project/petljapub/) compatible
8
+
9
+ ```
10
+ 00_competition/
11
+ ├── 01_problem1
12
+ │   ├── problem1.cpp
13
+ │   ├── problem1.py
14
+ │   ├── problem1-sol.md
15
+ │   ├── problem1-st.md
16
+ │   ├── problem1-tgen.cpp
17
+ │   └── scoring.yaml
18
+ ├── 02_problem2
19
+ │   ├── problem2.cpp
20
+ │   ├── problem2.cs
21
+ │   ├── problem2.py
22
+ │   ├── problem2-scoring.yaml
23
+ │   ├── problem2-sol.md
24
+ │   ├── problem2-st.md
25
+ │   └── problem2-tgen.cpp
26
+ ├── ...
27
+ ```
28
+
29
+ Run script from top-level competition directory
30
+
31
+ ```
32
+ $ cd 00_competition/
33
+ $ python3 petljatest.py
34
+ Petlja username: testacc
35
+ Petlja password:
36
+ ```
37
+
38
+ Example output:
39
+
40
+ ```
41
+ Created competition 00_example_competition
42
+ Found problem 01_problem1
43
+ Uploaded testcases for problem 01_problem1
44
+ Uploaded statement for problem 01_problem1
45
+ Added problem 01_problem1 to competition 00_example_competition
46
+ Submitted solution problem1.py for problem 01_problem1
47
+ Score: 10
48
+ Submitted solution problem1.cs for problem 01_problem1
49
+ Score: 0
50
+ Submitted solution problem1.cpp for problem 01_problem1
51
+ Score: 0
52
+ Found problem 02_problem2
53
+ ...
54
+ ```
55
+
@@ -0,0 +1,96 @@
1
+ from pathlib import Path
2
+ import petlja_api as petlja
3
+ import re
4
+ import subprocess
5
+
6
+
7
+ def is_for_testing(source_path):
8
+ is_code = source_path.suffix in petlja.submit.LANGUAGE_IDS
9
+ is_tgen = source_path.stem.endswith("tgen")
10
+ return is_code and not is_tgen
11
+
12
+
13
+ def remove_metadata(st_path):
14
+ cleaned = Path(st_path.parent, st_path.stem + "-cleaned.md")
15
+ is_metadata = False
16
+ lines = open(st_path, encoding="utf-8").readlines()
17
+ with open(cleaned, "w", encoding="utf-8") as cleaned_file:
18
+ for line in lines:
19
+ if line.startswith("---"):
20
+ is_metadata = not is_metadata
21
+ continue
22
+ if not is_metadata:
23
+ cleaned_file.write(line)
24
+ return cleaned
25
+
26
+
27
+ def alias_from_name(name):
28
+ alias = re.sub(r"[^a-zA-Z0-9]", "", name).lower()
29
+ return alias
30
+
31
+
32
+ def open_comp(comp_name):
33
+ comp_alias = alias_from_name(comp_name)
34
+ try:
35
+ comp_id = petlja.create_competition(session, comp_name, comp_alias)
36
+ print(f"Created competition {comp_name}")
37
+ except ValueError:
38
+ comp_id = petlja.get_competition_id(session, comp_alias)
39
+ print(f"Found competition {comp_name}")
40
+ return comp_id
41
+
42
+
43
+ def open_prob(prob_name):
44
+ prob_alias = alias_from_name(prob_name)
45
+ try:
46
+ prob_id = petlja.create_problem(session, prob_name, prob_alias)
47
+ print(f"Created problem {prob_name}")
48
+ except ValueError:
49
+ prob_id = petlja.get_problem_id(session, prob_alias)
50
+ print(f"Found problem {prob_name}")
51
+ return prob_id
52
+
53
+
54
+ session = petlja.login()
55
+
56
+ comp_path = Path.cwd()
57
+ comp_name = comp_path.name
58
+ comp_id = open_comp(comp_name)
59
+
60
+ for problem_path in comp_path.iterdir():
61
+ if problem_path.is_dir():
62
+ problem_name = problem_path.name
63
+ problem_id = open_prob(problem_name)
64
+
65
+ subprocess.run(
66
+ ["petljapub", "tests-zip"], cwd=problem_path,
67
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
68
+ )
69
+ testcases_path = problem_path.joinpath("_build/testcases.zip")
70
+ petlja.upload_testcases(session, problem_id, testcases_path)
71
+ print(f"Uploaded testcases for problem {problem_name}")
72
+
73
+ statement_path = next(problem_path.glob("*-st.md"))
74
+ cleaned_statement_path = remove_metadata(statement_path)
75
+ petlja.upload_statement(session, problem_id, cleaned_statement_path)
76
+ cleaned_statement_path.unlink()
77
+ print(f"Uploaded statement for problem {problem_name}")
78
+
79
+ petlja.add_problem(session, comp_id, problem_id)
80
+ print(f"Added problem {problem_name} to competition {comp_name}")
81
+
82
+ for solution_path in problem_path.iterdir():
83
+ if not is_for_testing(solution_path):
84
+ continue
85
+
86
+ solution_name = solution_path.name
87
+ language = solution_path.suffix
88
+ petlja.add_language(session, comp_id, language)
89
+ score = petlja.submit_solution(
90
+ session,
91
+ comp_id,
92
+ problem_id,
93
+ solution_path,
94
+ )
95
+ print(f"Submitted solution {solution_name} for problem {problem_name}")
96
+ print(f"Score: {score}")
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "petlja_api"
7
+ version = "0.0.1"
8
+ authors = [
9
+ { name="Pavle Sekešan", email="pavlesekesan@gmail.com" },
10
+ ]
11
+ description = "A python library for interacting with the petlja.org API"
12
+ readme = "README.md"
13
+ requires-python = ">=3.7"
14
+ dependencies = [
15
+ "beautifulsoup4==4.12.2",
16
+ "requests==2.31.0"
17
+ ]
18
+
19
+ [project.urls]
20
+ "Homepage" = "https://github.com/PavleSekesan/petlja_api"
@@ -0,0 +1,4 @@
1
+ from .auth import login
2
+ from .competition import create_competition, get_competition_id, get_added_problem_ids, add_problem, upload_scoring, add_language
3
+ from .problem import get_problem_id, get_problem_name, create_problem, upload_testcases, upload_statement
4
+ from .submit import submit_solution
@@ -0,0 +1,48 @@
1
+ from getpass import getpass
2
+
3
+ import requests
4
+ from bs4 import BeautifulSoup
5
+
6
+ from .urls import ARENA_URL
7
+
8
+
9
+ def _get_credentials():
10
+ username = input("Petlja username: ")
11
+ password = getpass("Petlja password: ")
12
+ return username, password
13
+
14
+
15
+ def get_csrf_token(page):
16
+ soup = BeautifulSoup(page, "html.parser")
17
+ csrf_token = soup.find("input", {"name": "__RequestVerificationToken"}).get("value")
18
+ return csrf_token
19
+
20
+
21
+ def login(username=None, password=None):
22
+ if username is None or password is None:
23
+ username, password = _get_credentials()
24
+
25
+ session = requests.Session()
26
+ mainpage = session.get(ARENA_URL)
27
+ csrf_token = get_csrf_token(mainpage.text)
28
+ login_res = session.post(
29
+ f"{ARENA_URL}/sr-Latn-RS/Account/Login",
30
+ data={
31
+ "LoginViewModel.UserNameOrEmail": username,
32
+ "LoginViewModel.Password": password,
33
+ "__RequestVerificationToken": csrf_token,
34
+ "LoginViewModel.RememberMe": "false",
35
+ },
36
+ allow_redirects=False,
37
+ )
38
+
39
+ if login_res.status_code == 302:
40
+ jwt = session.cookies["PetljaCookie"]
41
+ session.headers.update({"Authorization": f"Bearer {jwt}"})
42
+ return session
43
+ elif login_res.status_code == 200:
44
+ raise PermissionError("Wrong username or password")
45
+ else:
46
+ raise RuntimeError(
47
+ f"Unknown error: login failed with status code {login_res.status_code}"
48
+ )
@@ -0,0 +1,131 @@
1
+ import json
2
+ import re
3
+ from datetime import datetime
4
+
5
+ from bs4 import BeautifulSoup
6
+
7
+ from .auth import get_csrf_token
8
+ from .problem import get_problem_name
9
+ from .urls import ARENA_URL, CPANEL_URL, PETLJA_URL
10
+ from .submit import LANGUAGE_IDS
11
+
12
+
13
+ def get_competition_id(session, alias):
14
+ page = session.get(f"{ARENA_URL}/competition/{alias}")
15
+ if (
16
+ page.status_code == 404
17
+ or 'error' in page.url
18
+ ):
19
+ raise ValueError(f"Competition with alias {alias} does not exist")
20
+
21
+ soup = BeautifulSoup(page.text, "html.parser")
22
+ competition_id = soup.find("button", attrs={"id": "ciRun"})["data-competition-id"]
23
+ return competition_id
24
+
25
+
26
+ def get_added_problem_ids(session, competition_id):
27
+ page = session.get(f"{CPANEL_URL}/CompetitionTasks/{competition_id}")
28
+ soup = BeautifulSoup(page.text, "html.parser")
29
+ # Get object viewModel from inline script in html which contains data about added problems
30
+ # FIXME regex hack, should be replaced with a proper parser
31
+ regex = re.compile(r"var viewModel=({.*?});\n")
32
+ match = regex.search(soup.prettify()).group(1)
33
+ # Can be parsed as json since it is a javascript object
34
+ data = json.loads(match)
35
+ problem_ids = [str(problem["problemId"]) for problem in data["problems"]]
36
+ return problem_ids
37
+
38
+
39
+ def create_competition(
40
+ session, name, alias=None, description=None, start_date=None, end_date=None
41
+ ):
42
+ if alias is None:
43
+ alias = ""
44
+ if description is None:
45
+ description = ""
46
+ if start_date is None:
47
+ start_date = datetime.now()
48
+ if end_date is None:
49
+ end_date = ""
50
+
51
+ regex = re.compile(r"^[a-z0-9-]+$")
52
+ if not regex.match(alias):
53
+ raise NameError(
54
+ f"Invalid alias {alias}: must contain only lowercase alphanumeric characters and dashes"
55
+ )
56
+
57
+ url = f"{CPANEL_URL}/CreateCompetition"
58
+ page = session.get(url)
59
+ csrf_token = get_csrf_token(page.text)
60
+ resp = session.post(
61
+ url,
62
+ data={
63
+ "Name": name,
64
+ "Alias": alias,
65
+ "Description": description,
66
+ "StartDate": start_date,
67
+ "EndDate": end_date,
68
+ "HasNotEndDate": [True, False], # Not sure what this field does
69
+ "__RequestVerificationToken": csrf_token,
70
+ },
71
+ allow_redirects=False,
72
+ )
73
+
74
+ if resp.status_code == 302:
75
+ header_loc = resp.headers["Location"] # /cpanel/CompetitionSettings/:comp_id
76
+ comp_id = header_loc.split("/")[-1]
77
+ return comp_id
78
+ elif resp.status_code == 200:
79
+ raise ValueError("Competition alias already exists")
80
+ else:
81
+ raise RuntimeError(f"Unknown error: {resp.status_code}")
82
+
83
+
84
+ def add_problem(session, competition_id, problem_id):
85
+ already_added = get_added_problem_ids(session, competition_id)
86
+ if problem_id in already_added:
87
+ return
88
+
89
+ url = f"{PETLJA_URL}/api/dashboard/competitions/problems/add"
90
+ problem_name = get_problem_name(session, problem_id)
91
+ resp = session.post(
92
+ url,
93
+ json={
94
+ "competitionId": competition_id,
95
+ "problemId": problem_id,
96
+ "name": problem_name,
97
+ # "sortOrder": 0, # Seems to be optional
98
+ },
99
+ )
100
+
101
+ # TODO: Check for errors
102
+
103
+
104
+ def upload_scoring(session, competition_id, problem_id, scoring_path):
105
+ url = f"{PETLJA_URL}/api/dashboard/competitions/problems/addGraderHints"
106
+ with open(scoring_path) as scoring_file:
107
+ scoring = scoring_file.read()
108
+ resp = session.post(
109
+ url,
110
+ json={
111
+ "competitionId": competition_id,
112
+ "problemId": problem_id,
113
+ "hintsYML": scoring,
114
+ "testCaseCount": 100, # FIXME Count number of testcases from scoring file
115
+ },
116
+ )
117
+
118
+ errors = resp.json()["errors"]
119
+ if errors:
120
+ raise ValueError(f"Error uploading scoring, petlja response: {errors[0]}")
121
+
122
+ def add_language(session, competition_id, extension):
123
+ url = f'{PETLJA_URL}/api/dashboard/competitions/programmingLanguages/add'
124
+ res = session.post(
125
+ url,
126
+ json={
127
+ "competitionId": competition_id,
128
+ "languageId": LANGUAGE_IDS[extension]
129
+ }
130
+ )
131
+ # TODO Check for errors
@@ -0,0 +1,98 @@
1
+ from bs4 import BeautifulSoup
2
+
3
+ from .auth import get_csrf_token
4
+ from .urls import CPANEL_URL, PROBLEMS_URL
5
+
6
+
7
+ def get_problem_id(session, alias):
8
+ page = session.get(f"{PROBLEMS_URL}/{alias}")
9
+ if page.status_code == 404:
10
+ raise ValueError(f"Problem with alias {alias} does not exist")
11
+
12
+ soup = BeautifulSoup(page.text, "html.parser")
13
+ problem_id = soup.find("button", attrs={"class": "btn-solution-submit"})[
14
+ "data-problem-id"
15
+ ]
16
+ return problem_id
17
+
18
+
19
+ def get_problem_name(session, problem_id):
20
+ page = session.get(f"{CPANEL_URL}/EditProblem/{problem_id}")
21
+ if page.status_code == 404:
22
+ raise ValueError(f"Problem with id {problem_id} does not exist")
23
+
24
+ soup = BeautifulSoup(page.text, "html.parser")
25
+ problem_name = soup.find("input", attrs={"id": "Problem_Name"})["value"]
26
+ return problem_name
27
+
28
+
29
+ def create_problem(session, name, alias):
30
+ if not alias or not alias.isalnum() or not alias.islower():
31
+ raise NameError(
32
+ f"Invalid problem alias {alias}: must be alphanumeric and lowercase"
33
+ )
34
+
35
+ create_problem_page = session.get(f"{CPANEL_URL}/CreateTask")
36
+ csrf_token = get_csrf_token(create_problem_page.text)
37
+ resp = session.post(
38
+ f"{CPANEL_URL}/CreateTask",
39
+ data={
40
+ "Name": name,
41
+ "UniqueId": alias,
42
+ "Type": "0",
43
+ "__RequestVerificationToken": csrf_token,
44
+ },
45
+ allow_redirects=False
46
+ )
47
+ if resp.status_code == 302:
48
+ return get_problem_id(session, alias)
49
+ elif resp.status_code == 200:
50
+ raise ValueError("Problem alias already exists")
51
+ else:
52
+ raise RuntimeError(
53
+ "Unknown error while creating problem (status code {resp.status_code})"
54
+ )
55
+
56
+
57
+ def check_testcase_upload(page):
58
+ soup = BeautifulSoup(page.text, "html.parser")
59
+ error = soup.find("div", attrs={"class": "validation-summary-errors"})
60
+ if error:
61
+ errmsg = f"Testcase upload failed: {error.text.strip()}"
62
+ if error.text.strip() == "UserIdNotFound":
63
+ errmsg += " (Is the zip file in the correct format?)"
64
+ raise ValueError(errmsg)
65
+
66
+
67
+ def upload_testcases(session, problem_id, testcases_path):
68
+ page = session.get(f"{CPANEL_URL}/EditProblem/{problem_id}?tab=testcases")
69
+ csrf_token = get_csrf_token(page.text)
70
+ with open(testcases_path, "rb") as zipfile:
71
+ resp = session.post(
72
+ f"{CPANEL_URL}/EditProblem/{problem_id}",
73
+ files={"TestCases": zipfile},
74
+ data={
75
+ "PostAction": "EditTestCases",
76
+ "__RequestVerificationToken": csrf_token,
77
+ },
78
+ )
79
+ # Have to scrape the response page to check for errors
80
+ # because the response is 302 even if there is an error
81
+ check_testcase_upload(resp)
82
+
83
+
84
+ def upload_statement(session, problem_id, statement_path):
85
+ page = session.get(f"{CPANEL_URL}/EditProblem/{problem_id}?tab=statement")
86
+ csrf_token = get_csrf_token(page.text)
87
+ with open(statement_path, encoding="utf-8") as statement:
88
+ resp = session.post(
89
+ f"{CPANEL_URL}/EditProblem/{problem_id}",
90
+ data={
91
+ "Problem.ProblemStatementMD": statement.read(),
92
+ "PostAction": "EditStatement",
93
+ "Problem.MDSupported": "true",
94
+ "__RequestVerificationToken": csrf_token,
95
+ },
96
+ allow_redirects=False,
97
+ )
98
+ # TODO: check for errors
@@ -0,0 +1,54 @@
1
+ from .urls import ARENA_URL
2
+ import time
3
+
4
+ # Weird Petlja API language IDs
5
+ LANGUAGE_IDS = {
6
+ ".c": 10,
7
+ ".cs": 1,
8
+ ".cpp": 2,
9
+ ".java": 3,
10
+ ".m": 7,
11
+ ".pas": 4,
12
+ ".py": 9,
13
+ }
14
+
15
+ TIMEOUT = 30
16
+
17
+
18
+ def submit_solution(session, competition_id, problem_id, source_path):
19
+ with open(source_path) as source_file:
20
+ source = source_file.read()
21
+
22
+ extension = source_path.suffix
23
+ submit_res = session.post(
24
+ f"{ARENA_URL}/api/competition/submit-competition-problem",
25
+ json={
26
+ "competitionId": competition_id,
27
+ "problemId": problem_id,
28
+ "source": source,
29
+ "languageId": LANGUAGE_IDS[extension],
30
+ },
31
+ )
32
+ success = submit_res.json()["succeeded"]
33
+ if not success:
34
+ error = submit_res.json()["errors"][0]["description"]
35
+ raise Exception(error)
36
+ submission_id = submit_res.json()["value"]
37
+ # Polling the server every x seconds
38
+ # Better solution may exist
39
+ tries = 0
40
+ while tries < TIMEOUT:
41
+ submission_data = session.post(
42
+ f"{ARENA_URL}/api/competition/submissions",
43
+ json={
44
+ "competitionId": competition_id,
45
+ "idStamp": submission_id,
46
+ "loadNew": True,
47
+ },
48
+ )
49
+ score = submission_data.json()["value"]["item1"][0]["score"]
50
+ if score != '-':
51
+ return score
52
+ time.sleep(1)
53
+
54
+ raise TimeoutError
@@ -0,0 +1,4 @@
1
+ ARENA_URL = "https://arena.petlja.org"
2
+ PETLJA_URL = "https://petlja.org"
3
+ CPANEL_URL = f"{PETLJA_URL}/sr-Latn-RS/cpanel"
4
+ PROBLEMS_URL = f"{PETLJA_URL}/sr-Latn-RS/problems"
@@ -0,0 +1,236 @@
1
+ import os
2
+ import random
3
+
4
+ import pytest
5
+ import requests
6
+ import petlja_api as petlja
7
+ from dotenv import load_dotenv
8
+
9
+ load_dotenv()
10
+
11
+
12
+ @pytest.fixture
13
+ def sess():
14
+ return petlja.login(os.environ['PETLJA_USER'], os.environ['PETLJA_PASS'])
15
+
16
+
17
+ @pytest.fixture
18
+ def created_prob(sess):
19
+ uid = random.randint(1000, 9999)
20
+ alias = f"testprob{uid}"
21
+ pid = petlja.create_problem(sess, "Test zadatak", alias)
22
+ return pid, alias
23
+
24
+
25
+ @pytest.fixture
26
+ def empty_comp(sess):
27
+ uid = random.randint(1000, 9999)
28
+ alias = f"testcomp{uid}"
29
+ cid = petlja.create_competition(sess, "Test takmicenje", alias)
30
+ return cid, alias
31
+
32
+
33
+ @pytest.fixture
34
+ def comp_with_problems(sess, empty_comp, created_prob, scoring, testcases):
35
+ cid, alias = empty_comp
36
+ pid, _ = created_prob
37
+ petlja.add_problem(sess, cid, pid)
38
+ petlja.upload_testcases(sess, pid, testcases)
39
+ petlja.upload_scoring(sess, cid, pid, scoring)
40
+ return cid, alias
41
+
42
+
43
+ @pytest.fixture
44
+ def src_ok(tmp_path):
45
+ src = """
46
+ #include <iostream>
47
+ using namespace std;
48
+
49
+ int main()
50
+ {
51
+ int a, b; cin >> a >> b;
52
+ cout << 2 * (a + b) << endl;
53
+ }
54
+ """
55
+ path = tmp_path / "trening_ok.cpp"
56
+ path.write_text(src)
57
+ return path
58
+
59
+
60
+ @pytest.fixture
61
+ def src_wa(tmp_path):
62
+ src = """
63
+ #include <iostream>
64
+ using namespace std;
65
+
66
+ int main()
67
+ {
68
+ return 0;
69
+ }
70
+ """
71
+ path = tmp_path / "trening_wa.cpp"
72
+ path.write_text(src)
73
+ return path
74
+
75
+
76
+ @pytest.fixture
77
+ def src_py(tmp_path):
78
+ src = """
79
+ a = int(input())
80
+ b = int(input())
81
+ print(2 * (a + b))
82
+ """
83
+ path = tmp_path / "trening.py"
84
+ path.write_text(src)
85
+ return path
86
+
87
+
88
+ @pytest.fixture
89
+ def statement(tmp_path):
90
+ st = """
91
+ За низ ћемо рећи да је **уравнотежен** ако је збир његових елемената једнак његовој дужини (броју елемената).
92
+
93
+ Дат је низ $a$ дужине $n$, чији су елементи једноцифрени бројеви. Одредити колико он садржи уравнотежених сегмената (поднизова са узастопним елементима).
94
+
95
+ ## Улаз
96
+
97
+ У првом реду стандардног улаза је број $n$ $(1 \leq n \leq 10^5)$, а у другом $n$ ненегативних једноцифрених бројева, раздвојених по једним размаком.
98
+
99
+ ## Излаз
100
+
101
+ На стандардни излаз исписати тражени број.
102
+
103
+ ## Пример
104
+
105
+ ### Улаз
106
+
107
+ ~~~
108
+ 5
109
+ 0 3 0 0 2
110
+ ~~~
111
+
112
+ ### Излаз
113
+
114
+ ~~~
115
+ 4
116
+ ~~~
117
+
118
+ *Објашњење*: Тражени сегменти су $[0 3 0]$, $[3 0 0]$, $[0 2]$ и $[0 3 0 0 2]$.
119
+
120
+ """
121
+ path = tmp_path / "tekst-st.md"
122
+ path.write_text(st)
123
+ return path
124
+
125
+
126
+ @pytest.fixture
127
+ def testcases():
128
+ return "tests/data/testcases.zip"
129
+
130
+
131
+ @pytest.fixture
132
+ def scoring(tmp_path):
133
+ yaml = """
134
+ type: testcase
135
+ score_total: 100
136
+ score_overrides:
137
+ - {name: 1, score: 10}
138
+ - {name: 2, score: 10}
139
+ - {name: 3, score: 10}
140
+ - {name: 4, score: 10}
141
+ - {name: 5, score: 10}
142
+ - {name: 6, score: 10}
143
+ - {name: 7, score: 10}
144
+ - {name: 8, score: 10}
145
+ - {name: 9, score: 10}
146
+ - {name: 10, score: 10}
147
+ public: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
148
+ """
149
+ scoring_path = tmp_path / "scoring.yaml"
150
+ scoring_path.write_text(yaml)
151
+ return scoring_path
152
+
153
+
154
+ def test_login_success(sess):
155
+ assert sess is not None
156
+
157
+
158
+ def test_login_failed():
159
+ with pytest.raises(PermissionError):
160
+ petlja.login("wrongusername", "wrongpass")
161
+
162
+
163
+ def test_submit_ok(sess, comp_with_problems, src_ok):
164
+ cid, _ = comp_with_problems
165
+ pid = petlja.get_added_problem_ids(sess, cid)[0]
166
+ score = petlja.submit_solution(sess, cid, pid, src_ok)
167
+ assert int(score) > 0
168
+
169
+
170
+ def test_submit_wa(sess, comp_with_problems, src_wa):
171
+ cid, _ = comp_with_problems
172
+ pid = petlja.get_added_problem_ids(sess, cid)[0]
173
+ score = petlja.submit_solution(sess, cid, pid, src_wa)
174
+ assert score == "0"
175
+
176
+
177
+ def test_create_problem(created_prob):
178
+ _, alias = created_prob
179
+ res = requests.get(f"https://petlja.org/problems/{alias}")
180
+ assert res.status_code == 200
181
+
182
+
183
+ def test_create_already_existing_prob(sess):
184
+ with pytest.raises(ValueError):
185
+ petlja.create_problem(sess, "Postojeci problem", "osdrz23odbijanje")
186
+
187
+
188
+ def test_upload_testcases(sess, created_prob, testcases):
189
+ id, _ = created_prob
190
+ petlja.upload_testcases(sess, id, testcases)
191
+
192
+
193
+ def test_upload_statement(sess, created_prob, statement):
194
+ id, _ = created_prob
195
+ petlja.upload_statement(sess, id, statement)
196
+
197
+
198
+ def test_upload_scoring(sess, comp_with_problems, scoring):
199
+ cid, _ = comp_with_problems
200
+ pid = petlja.get_added_problem_ids(sess, cid)[0]
201
+ petlja.upload_scoring(sess, cid, pid, scoring)
202
+
203
+
204
+ def test_get_competition_id(sess, empty_comp):
205
+ cid, alias = empty_comp
206
+ assert petlja.get_competition_id(sess, alias) == cid
207
+
208
+
209
+ def test_get_competition_id_nonexistent(sess):
210
+ with pytest.raises(ValueError):
211
+ petlja.get_competition_id(sess, 'qurvoqireouqh')
212
+
213
+
214
+ def test_submit_unallowed_lang(sess, comp_with_problems, created_prob, src_py):
215
+ cid, _ = comp_with_problems
216
+ pid, _ = created_prob
217
+ with pytest.raises(Exception):
218
+ petlja.submit_solution(sess, cid, pid, src_py)
219
+
220
+
221
+ # Testing TLE is slow
222
+
223
+ # def test_submit_tle(sess, cid, pid, tmp_path):
224
+ # src = """
225
+ # #include <iostream>
226
+ # using namespace std;
227
+
228
+ # int main()
229
+ # {
230
+ # int a, b; cin >> a >> b;
231
+ # while(true) {}
232
+ # }
233
+ # """
234
+ # src_path = tmp_path / "trening_tle.cpp"
235
+ # score = _submit_src_file(sess, cid, pid, src, src_path)
236
+ # assert score == "0"