petlja-api 0.0.1__py3-none-any.whl
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.
- petlja_api/__init__.py +4 -0
- petlja_api/auth.py +48 -0
- petlja_api/competition.py +131 -0
- petlja_api/problem.py +98 -0
- petlja_api/submit.py +54 -0
- petlja_api/urls.py +4 -0
- petlja_api-0.0.1.dist-info/METADATA +40 -0
- petlja_api-0.0.1.dist-info/RECORD +10 -0
- petlja_api-0.0.1.dist-info/WHEEL +4 -0
- petlja_api-0.0.1.dist-info/licenses/LICENSE +21 -0
petlja_api/__init__.py
ADDED
|
@@ -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
|
petlja_api/auth.py
ADDED
|
@@ -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
|
petlja_api/problem.py
ADDED
|
@@ -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
|
petlja_api/submit.py
ADDED
|
@@ -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
|
petlja_api/urls.py
ADDED
|
@@ -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,10 @@
|
|
|
1
|
+
petlja_api/__init__.py,sha256=FoR5XbbCMOReJx9SpUv6Meut21WesaeaDSou9Dt0oNk,295
|
|
2
|
+
petlja_api/auth.py,sha256=m6hvN_hq73bpaawokdugI4TChWxWNh9E3Een1m8o0sE,1442
|
|
3
|
+
petlja_api/competition.py,sha256=Obzh90yvaP2AKcj0UDr_WwQ1nwiG60rUEyn3gjdnnEY,4209
|
|
4
|
+
petlja_api/problem.py,sha256=RhIgkCCWG33JKD9mCnYGGsBHEXCUNQKJvvyg2xcU3OQ,3457
|
|
5
|
+
petlja_api/submit.py,sha256=yMY_VdS1pktL47yzUPvp66RA01ezEZOFUatF0Kt_Sqs,1436
|
|
6
|
+
petlja_api/urls.py,sha256=ol2xDoGg8pZS3I_kXrAMfsHX_NI54XtDlCI9qfI3u84,170
|
|
7
|
+
petlja_api-0.0.1.dist-info/METADATA,sha256=V4zxVbggFapS5n7wguNaqOoat-poeg66k630462tb68,1088
|
|
8
|
+
petlja_api-0.0.1.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
|
|
9
|
+
petlja_api-0.0.1.dist-info/licenses/LICENSE,sha256=vjjmZ_TFg_A8t-6PHeFUaEo1pSrRtnO59PBzc527jS0,1071
|
|
10
|
+
petlja_api-0.0.1.dist-info/RECORD,,
|
|
@@ -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.
|