Skip to content

Commit d38aa58

Browse files
ports schedule api to the core.
1 parent 665d6c9 commit d38aa58

File tree

3 files changed

+300
-4
lines changed

3 files changed

+300
-4
lines changed

pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "pythonanywhere-core"
3-
version = "0.1.2"
3+
version = "0.1.3"
44
description = "API wrapper for programmatic management of PythonAnywhere services."
55
authors = ["PythonAnywhere <[email protected]>"]
66
license = "MIT"
@@ -20,11 +20,11 @@ keywords = ["pythonanywhere", "api", "cloud", "web hosting"]
2020
[tool.poetry.dependencies]
2121
python = "^3.7"
2222
python-dateutil = "^2.8.2"
23-
requests = "^2.25.1"
24-
snakesay = "^0.10.1"
23+
requests = "^2.30.0"
24+
snakesay = "^0.10.2"
2525

2626
[tool.poetry.dev-dependencies]
27-
pytest = "^7.2.0"
27+
pytest = "^7.3.1"
2828
pytest-cov = "^4.0.0"
2929
pytest-mock = "^3.10.0"
3030
responses = "^0.22.0"

pythonanywhere_core/schedule.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import getpass
2+
from typing import List, Optional
3+
4+
from typing_extensions import Literal
5+
6+
from pythonanywhere_core.base import call_api, get_api_endpoint
7+
from pythonanywhere_core.exceptions import PythonAnywhereApiException
8+
9+
10+
class Schedule:
11+
"""Interface for PythonAnywhere scheduled tasks API.
12+
13+
Uses `pythonanywhere.api` :method: `get_api_endpoint` to create url,
14+
which is stored in a class variable `Schedule.base_url`, then calls
15+
`call_api` with appropriate arguments to execute scheduled tasks tasks
16+
actions. Covers 'GET' and 'POST' methods for tasks list, as well as
17+
'GET', 'PATCH' and 'DELETE' methods for task with id.
18+
19+
Use :method: `Schedule.get_list` to get all tasks list.
20+
Use :method: `Schedule.create` to create new task.
21+
Use :method: `Schedule.get_specs` to get existing task specs.
22+
Use :method: `Schedule.delete` to delete existing task.
23+
Use :method: `Schedule.update` to update existing task."""
24+
25+
base_url: str = get_api_endpoint().format(username=getpass.getuser(), flavor="schedule")
26+
27+
def create(self, params: dict) -> Optional[dict]:
28+
"""Creates new scheduled task using `params`.
29+
30+
Params should be: command, enabled (True or False), interval (daily or
31+
hourly), hour (24h format) and minute.
32+
33+
:param params: dictionary with required scheduled task specs
34+
:returns: dictionary with created task specs"""
35+
36+
result = call_api(self.base_url, "POST", json=params)
37+
38+
if result.status_code == 201:
39+
return result.json()
40+
41+
if not result.ok:
42+
raise PythonAnywhereApiException(
43+
f"POST to set new task via API failed, got {result}: {result.text}"
44+
)
45+
46+
def delete(self, task_id: int) -> Literal[True]:
47+
"""Deletes scheduled task by id.
48+
49+
:param task_id: scheduled task to be deleted id number
50+
:returns: True when API response is 204"""
51+
52+
result = call_api(
53+
f"{self.base_url}{task_id}/", "DELETE"
54+
)
55+
56+
if result.status_code == 204:
57+
return True
58+
59+
if not result.ok:
60+
raise PythonAnywhereApiException(
61+
f"DELETE via API on task {task_id} failed, got {result}: {result.text}"
62+
)
63+
64+
def get_list(self) -> List[dict]:
65+
"""Gets list of existing scheduled tasks.
66+
67+
:returns: list of existing scheduled tasks specs"""
68+
69+
return call_api(self.base_url, "GET").json()
70+
71+
def get_specs(self, task_id: int) -> dict:
72+
"""Get task specs by id.
73+
74+
:param task_id: existing task id
75+
:returns: dictionary of existing task specs"""
76+
77+
result = call_api(
78+
f"{self.base_url}{task_id}/", "GET"
79+
)
80+
if result.status_code == 200:
81+
return result.json()
82+
else:
83+
raise PythonAnywhereApiException(
84+
f"Could not get task with id {task_id}. Got result {result}: {result.text}"
85+
)
86+
87+
def update(self, task_id: int, params: dict) -> dict:
88+
"""Updates existing task using id and params.
89+
90+
Params should at least one of: command, enabled, interval, hour,
91+
minute. To update hourly task don't use 'hour' param. On the other
92+
hand when changing task's interval from 'hourly' to 'daily' hour is
93+
required.
94+
95+
:param task_id: existing task id
96+
:param params: dictionary of specs to update"""
97+
98+
result = call_api(
99+
f"{self.base_url}{task_id}/",
100+
"PATCH",
101+
json=params,
102+
)
103+
if result.status_code == 200:
104+
return result.json()
105+
else:
106+
raise PythonAnywhereApiException(
107+
f"Could not update task {task_id}. Got {result}: {result.text}"
108+
)

tests/test_schedule.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import getpass
2+
import json
3+
4+
import pytest
5+
import responses
6+
7+
from pythonanywhere_core.base import get_api_endpoint
8+
from pythonanywhere_core.exceptions import PythonAnywhereApiException
9+
from pythonanywhere_core.schedule import Schedule
10+
11+
12+
@pytest.fixture
13+
def task_base_url():
14+
return get_api_endpoint().format(username=getpass.getuser(), flavor="schedule")
15+
16+
17+
@pytest.fixture
18+
def task_specs():
19+
username = getpass.getuser()
20+
return {
21+
"can_enable": False,
22+
"command": "echo foo",
23+
"enabled": True,
24+
"expiry": None,
25+
"extend_url": f"/user/{username}/schedule/task/123/extend",
26+
"hour": 16,
27+
"id": 123,
28+
"interval": "daily",
29+
"logfile": "/user/{username}/files/var/log/tasklog-126708-daily-at-1600-echo_foo.log",
30+
"minute": 0,
31+
"printable_time": "16:00",
32+
"url": f"/api/v0/user/{username}/schedule/123",
33+
"user": username,
34+
}
35+
36+
37+
@pytest.fixture
38+
def base_task_params():
39+
return {
40+
"command": "echo foo",
41+
"enabled": True,
42+
"minute": 0,
43+
}
44+
45+
46+
@pytest.fixture
47+
def hourly_task_params(base_task_params):
48+
return {
49+
**base_task_params,
50+
"interval": "hourly",
51+
"minute": 0,
52+
}
53+
54+
55+
@pytest.fixture
56+
def daily_task_params(base_task_params):
57+
return {
58+
**base_task_params,
59+
"interval": "daily",
60+
"hour": 16,
61+
}
62+
63+
64+
def test_creates_daily_task(
65+
api_token, api_responses, task_specs, daily_task_params, task_base_url
66+
):
67+
api_responses.add(
68+
responses.POST, url=task_base_url, status=201, body=json.dumps(task_specs)
69+
)
70+
71+
assert Schedule().create(daily_task_params) == task_specs
72+
73+
74+
def test_creates_hourly_task(
75+
api_token, api_responses, task_specs, hourly_task_params, task_base_url
76+
):
77+
hourly_specs = {"hour": None, "interval": "hourly", "printable_time": "00 minutes past"}
78+
task_specs.update(hourly_specs)
79+
api_responses.add(
80+
responses.POST, url=task_base_url, status=201, body=json.dumps(task_specs)
81+
)
82+
83+
assert Schedule().create(hourly_task_params) == task_specs
84+
85+
86+
def test_raises_because_missing_params(api_token, api_responses, task_base_url):
87+
body = (
88+
'{"interval":["This field is required."],"command":["This field is required."],'
89+
'"minute":["This field is required."]}'
90+
)
91+
api_responses.add(responses.POST, url=task_base_url, status=400, body=body)
92+
93+
with pytest.raises(PythonAnywhereApiException) as e:
94+
Schedule().create({})
95+
96+
expected_error_msg = (
97+
f"POST to set new task via API failed, got <Response [400]>: {body}"
98+
)
99+
assert str(e.value) == expected_error_msg
100+
101+
102+
def test_deletes_task(api_token, api_responses, task_base_url):
103+
url = f"{task_base_url}42/"
104+
api_responses.add(responses.DELETE, url=url, status=204)
105+
106+
result = Schedule().delete(42)
107+
108+
post = api_responses.calls[0]
109+
assert post.request.url == url
110+
assert post.request.body is None
111+
assert result is True
112+
113+
114+
def test_raises_because_attempt_to_delete_nonexisting_task(api_token, api_responses, task_base_url):
115+
body = '{"detail": "Not fount."}'
116+
api_responses.add(
117+
responses.DELETE, url=f"{task_base_url}42/", status=404, body=body
118+
)
119+
120+
with pytest.raises(PythonAnywhereApiException) as e:
121+
Schedule().delete(42)
122+
123+
assert (
124+
str(e.value)
125+
== f"DELETE via API on task 42 failed, got <Response [404]>: {body}"
126+
)
127+
128+
129+
def test_returns_spec_dict(api_token, api_responses, task_base_url, task_specs):
130+
api_responses.add(
131+
responses.GET,
132+
url=f"{task_base_url}123/",
133+
status=200,
134+
body=json.dumps(task_specs),
135+
)
136+
137+
assert Schedule().get_specs(123) == task_specs
138+
139+
140+
def test_raises_because_attempt_to_get_nonexisting_task(api_token, api_responses, task_base_url):
141+
body = '{"detail":"Not found."}'
142+
api_responses.add(
143+
responses.GET, url=f"{task_base_url}42/", status=404, body=body
144+
)
145+
146+
with pytest.raises(PythonAnywhereApiException) as e:
147+
Schedule().get_specs(42)
148+
149+
expected_error_msg = (
150+
f"Could not get task with id 42. Got result <Response [404]>: {body}"
151+
)
152+
assert str(e.value) == expected_error_msg
153+
154+
155+
def test_returns_tasks_list(api_token, api_responses, task_base_url):
156+
fake_specs = [{"fake": "specs"}, {"and": "more"}]
157+
api_responses.add(
158+
responses.GET, url=task_base_url, status=200, body=json.dumps(fake_specs),
159+
)
160+
161+
assert Schedule().get_list() == fake_specs
162+
163+
164+
def test_updates_daily_task(
165+
api_token, api_responses, task_specs, daily_task_params, task_base_url
166+
):
167+
api_responses.add(
168+
responses.PATCH,
169+
url=f"{task_base_url}123/",
170+
status=200,
171+
body=json.dumps(task_specs),
172+
)
173+
174+
assert Schedule().update(123, daily_task_params) == task_specs
175+
176+
177+
def test_raises_when_wrong_params(
178+
api_token, api_responses, task_specs, daily_task_params, task_base_url
179+
):
180+
body = '{"non_field_errors":["Hourly tasks must not have an hour."]}'
181+
api_responses.add(
182+
responses.PATCH, url=f"{task_base_url}1/", status=400, body=body
183+
)
184+
185+
with pytest.raises(PythonAnywhereApiException) as e:
186+
Schedule().update(1, {"hour": 23})
187+
188+
assert str(e.value) == f"Could not update task 1. Got <Response [400]>: {body}"

0 commit comments

Comments
 (0)