Skip to content

Commit 836ed4c

Browse files
committed
Adds scripts for scheduled tasks providing interface for Schedule API
1 parent 30534ab commit 836ed4c

27 files changed

+1939
-36
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,8 @@ ENV/
9696
.ropeproject
9797
.venv
9898

99+
# Emacs local variables
100+
.dir-locals.el
101+
102+
# pytest
103+
.pytest_cache/

pythonanywhere/schedule_api.py

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

pythonanywhere/schedule_api.pyi

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from typing import List, Optional
2+
3+
from typing_extensions import Literal
4+
5+
class Schedule:
6+
base_url: str = ...
7+
def get_list(self) -> List[dict]: ...
8+
def create(self, params: dict) -> Optional[dict]: ...
9+
def get_specs(self, task_id: int) -> dict: ...
10+
def delete(self, task_id: int) -> Literal[True]: ...
11+
def update(self, task_id: int, params: dict) -> dict: ...

pythonanywhere/scripts_commons.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Helpers used by pythonanywhere helper scripts."""
2+
3+
import logging
4+
import sys
5+
6+
from schema import And, Or, Schema, SchemaError, Use
7+
8+
from pythonanywhere.snakesay import snakesay
9+
from pythonanywhere.task import Task
10+
11+
logger = logging.getLogger(__name__)
12+
13+
# fmt: off
14+
tabulate_formats = [
15+
"plain", "simple", "github", "grid", "fancy_grid", "pipe", "orgtbl", "jira",
16+
"presto", "psql", "rst", "mediawiki", "moinmoin", "youtrack", "html", "latex",
17+
"latex_raw", "latex_booktabs", "textile",
18+
]
19+
# fmt: on
20+
21+
22+
class ScriptSchema(Schema):
23+
"""Extends `Schema` adapting it to PA scripts validation strategies.
24+
25+
Adds predefined schemata as class variables to be used in scripts'
26+
validation schemas as well as `validate_user_input` method which acts
27+
as `Schema.validate` but returns a dictionary with converted keys
28+
ready to be used as function keyword arguments, e.g. validated
29+
arguments {"--foo": bar, "<baz>": qux} will be be converted to
30+
{"foo": bar, "baz": qux}. Additional conversion rules may be added as
31+
dictionary passed to `validate_user_input` :method: as `conversions`
32+
:param:.
33+
34+
Use :method:`ScriptSchema.validate_user_input` to obtain kwarg
35+
dictionary."""
36+
37+
# class variables are used in task scripts schemata:
38+
boolean = Or(None, bool)
39+
hour = Or(None, And(Use(int), lambda h: 0 <= h <= 23), error="--hour has to be in 0..23")
40+
id_multi = Or([], And(lambda y: [x.isdigit() for x in y], error="<id> has to be integer"))
41+
id_required = And(Use(int), error="<id> has to be an integer")
42+
minute_required = And(Use(int), lambda m: 0 <= m <= 59, error="--minute has to be in 0..59")
43+
minute = Or(None, minute_required)
44+
string = Or(None, str)
45+
tabulate_format = Or(
46+
None,
47+
And(str, lambda f: f in tabulate_formats),
48+
error="--format should match one of: {}".format(", ".join(tabulate_formats)),
49+
)
50+
51+
replacements = {"--": "", "<": "", ">": ""}
52+
53+
def convert(self, string):
54+
"""Removes cli argument notation characters ('--', '<', '>' etc.).
55+
56+
:param string: cli argument key to be converted to fit Python
57+
argument syntax."""
58+
59+
for key, value in self.replacements.items():
60+
string = string.replace(key, value)
61+
return string
62+
63+
def validate_user_input(self, arguments, *, conversions=None):
64+
"""Calls `Schema.validate` on provided `arguments`.
65+
66+
Returns dictionary with keys converted by
67+
`ScriptSchema.convert` :method: to be later used as kwarg
68+
arguments. Universal rules for conversion are stored in
69+
`replacements` class variable and may be updated using
70+
`conversions` kwarg. Use optional `conversions` :param: to add
71+
custom replacement rules.
72+
73+
:param arguments: dictionary of cli arguments provided be
74+
(e.g.) `docopt`
75+
:param conversions: dictionary of additional rules to
76+
`self.replacements`"""
77+
78+
if conversions:
79+
self.replacements.update(conversions)
80+
81+
try:
82+
self.validate(arguments)
83+
return {self.convert(key): val for key, val in arguments.items()}
84+
except SchemaError as e:
85+
logger.warning(snakesay(str(e)))
86+
sys.exit(1)
87+
88+
89+
def get_logger(set_info=False):
90+
"""Sets logger for 'pythonanywhere' package.
91+
92+
Returns `logging.Logger` instance with no message formatting which
93+
will stream to stdout. With `set_info` :param: set to `True`
94+
logger defines `logging.INFO` level otherwise it leaves default
95+
`logging.WARNING`.
96+
97+
To toggle message visibility in scripts use `logger.info` calls
98+
and switch `set_info` value accordingly.
99+
100+
:param set_info: boolean (defaults to False)"""
101+
102+
logging.basicConfig(format="%(message)s", stream=sys.stdout)
103+
logger = logging.getLogger("pythonanywhere")
104+
if set_info:
105+
logger.setLevel(logging.INFO)
106+
else:
107+
logger.setLevel(logging.WARNING)
108+
return logger
109+
110+
111+
def get_task_from_id(task_id, no_exit=False):
112+
"""Get `Task.from_id` instance representing existing task.
113+
114+
:param task_id: integer (should be a valid task id)
115+
:param no_exit: if (default) False sys.exit will be called when
116+
exception is caught"""
117+
118+
try:
119+
return Task.from_id(task_id)
120+
except Exception as e:
121+
logger.warning(snakesay(str(e)))
122+
if not no_exit:
123+
sys.exit(1)

pythonanywhere/scripts_commons.pyi

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import logging
2+
from typing import Dict, List, Optional
3+
4+
import schema
5+
6+
from pythonanywhere.task import Task
7+
8+
logger: logging.Logger = ...
9+
tabulate_formats: List[str] = ...
10+
11+
class ScriptSchema(schema.Schema):
12+
boolean: schema.Or = ...
13+
hour: schema.Or = ...
14+
id_multi: schema.Or = ...
15+
id_required: schema.And = ...
16+
minute: schema.Or = ...
17+
minute_required: schema.And = ...
18+
string: schema.Or = ...
19+
tabulate_format: schema.Or = ...
20+
replacements: Dict[str] = ...
21+
def convert(self, string: str) -> str: ...
22+
def validate_user_input(self, arguments: dict, *, conversions: Optional[dict]) -> dict: ...
23+
24+
def get_logger(set_info: bool) -> logging.Logger: ...
25+
def get_task_from_id(task_id: int, no_exit: bool) -> Task: ...

0 commit comments

Comments
 (0)