Skip to content

Commit b2990ab

Browse files
committed
#33 Adds cli support for students endpoint. by Piotr
1 parent e084148 commit b2990ab

File tree

9 files changed

+487
-2
lines changed

9 files changed

+487
-2
lines changed

cli/pa

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
import typer
44

55
from cli import django
6+
from cli import path
67
from cli import schedule
8+
from cli import students
79
from cli import webapp
8-
from cli import path
910

1011
help = """This is a new experimental PythonAnywhere cli client.
1112
@@ -14,9 +15,10 @@ It was build with typer & click under the hood.
1415

1516
app = typer.Typer(help=help)
1617
app.add_typer(django.app, name="django", help="Makes Django Girls tutorial projects deployment easy")
18+
app.add_typer(path.app, name="path", help="Perform some operations on files")
1719
app.add_typer(schedule.app, name="schedule", help="Manage scheduled tasks")
20+
app.add_typer(students.app, name="students", help="Perform some operations on students")
1821
app.add_typer(webapp.app, name="webapp", help="Everything for web apps")
19-
app.add_typer(path.app, name="path", help="Perform some operations on files")
2022

2123

2224
if __name__ == "__main__":

cli/students.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import sys
2+
3+
import typer
4+
5+
from pythonanywhere.scripts_commons import get_logger
6+
from pythonanywhere.students import Students
7+
8+
app = typer.Typer()
9+
10+
11+
def setup(quiet: bool) -> Students:
12+
logger = get_logger(set_info=True)
13+
if quiet:
14+
logger.disabled = True
15+
return Students()
16+
17+
18+
@app.command()
19+
def get(
20+
numbered: bool = typer.Option(
21+
False, "-n", "--numbered", help="Add ordering numbers."
22+
),
23+
quiet: bool = typer.Option(
24+
False, "-q", "--quiet", help="Disable additional logging."
25+
),
26+
raw: bool = typer.Option(
27+
False, "-a", "--raw", help="Print list of usernames from the API response."
28+
),
29+
sort: bool = typer.Option(False, "-s", "--sort", help="Sort alphabetically"),
30+
sort_reverse: bool = typer.Option(
31+
False, "-r", "--reverse", help="Sort in reverse order"
32+
),
33+
):
34+
"""
35+
Get list of student usernames.
36+
"""
37+
38+
api = setup(quiet)
39+
students = api.get()
40+
41+
if students is None or students == []:
42+
sys.exit(1)
43+
44+
if raw:
45+
typer.echo(students)
46+
sys.exit()
47+
48+
if sort or sort_reverse:
49+
students.sort(reverse=sort_reverse)
50+
51+
for number, student in enumerate(students, start=1):
52+
line = f"{number:>3}. {student}" if numbered else student
53+
typer.echo(line)
54+
55+
56+
@app.command()
57+
def delete(
58+
student: str = typer.Argument(..., help="Username of a student to be removed."),
59+
quiet: bool = typer.Option(
60+
False, "-q", "--quiet", help="Disable additional logging."
61+
),
62+
):
63+
"""
64+
Remove a student from the students list.
65+
"""
66+
67+
api = setup(quiet)
68+
result = 0 if api.delete(student) else 1
69+
sys.exit(result)
70+
71+
72+
@app.command()
73+
def holidays(
74+
quiet: bool = typer.Option(
75+
False, "-q", "--quiet", help="Disable additional logging."
76+
),
77+
):
78+
"""
79+
School's out for summer! School's out forever! (removes all students)
80+
"""
81+
82+
api = setup(quiet)
83+
students = api.get()
84+
85+
if not students:
86+
if not quiet:
87+
typer.echo("No students found!")
88+
sys.exit(1)
89+
90+
result = 0 if all(api.delete(s) for s in students) else 1
91+
if not quiet:
92+
typer.echo(
93+
[
94+
f"Removed all {len(students)} students!",
95+
f"Something went wrong, try again",
96+
][result]
97+
)
98+
sys.exit(result)

pythonanywhere/api/students_api.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Interface speaking with PythonAnywhere API providing methods for
2+
students. *Don't use* `StudentsAPI` :class: in helper scripts, use
3+
`pythonanywhere.students.Students` class instead."""
4+
5+
import getpass
6+
7+
from pythonanywhere.api.base import call_api, get_api_endpoint
8+
9+
10+
class StudentsAPI:
11+
"""Interface for PythonAnywhere students API.
12+
13+
Uses `pythonanywhere.api.base` :method: `get_api_endpoint` to
14+
create url, which is stored in a class variable `StudentsAPI.base_url`,
15+
then calls `call_api` with appropriate arguments to execute student
16+
action.
17+
18+
Covers:
19+
- GET
20+
- DELETE
21+
22+
Methods:
23+
- use :method: `StudentsAPI.get` to get list of students
24+
- use :method: `StudentsAPI.delete` to remove a student
25+
"""
26+
27+
base_url = get_api_endpoint().format(username=getpass.getuser(), flavor="students")
28+
29+
def get(self):
30+
"""Returns list of PythonAnywhere students related with user's account."""
31+
32+
result = call_api(self.base_url, "GET")
33+
34+
if result.status_code == 200:
35+
return result.json()
36+
37+
raise Exception(f"GET to list students failed, got {result.text}")
38+
39+
def delete(self, student_username):
40+
"""Returns 204 if student has been successfully removed, raises otherwise."""
41+
42+
url = f"{self.base_url}{student_username}"
43+
44+
result = call_api(url, "DELETE")
45+
46+
if result.status_code == 204:
47+
return result.status_code
48+
49+
detail = f": {result.text}" if result.text else ""
50+
raise Exception(
51+
f"DELETE to remove student {student_username!r} failed, got {result}{detail}"
52+
)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from typing import Dict, List
2+
3+
class StudentsAPI:
4+
base_url: str = ...
5+
def get(self) -> Dict[str, List[Dict[str, str]]]: ...
6+
def delete(self, params: dict) -> int: ...

pythonanywhere/students.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""User interface for Pythonanywhere students API.
2+
3+
Provides a class `Students` which should be used by helper scripts
4+
providing features for programmatic listing and removing of the user's
5+
students.
6+
"""
7+
8+
import logging
9+
10+
from pythonanywhere.api.students_api import StudentsAPI
11+
from pythonanywhere.snakesay import snakesay
12+
13+
logger = logging.getLogger("pythonanywhere")
14+
15+
16+
class Students:
17+
"""Class providing interface for PythonAnywhere students API.
18+
19+
To perform actions on students related with user's account, use
20+
following methods:
21+
- :method:`Students.get` to get a list of students
22+
- :method:`Students.delete` to remove a student with a given username
23+
"""
24+
25+
def __init__(self):
26+
self.api = StudentsAPI()
27+
28+
def get(self):
29+
"""
30+
Returns list of usernames when user has students, otherwise an
31+
empty list.
32+
"""
33+
34+
try:
35+
result = self.api.get()
36+
student_usernames = [student["username"] for student in result["students"]]
37+
logger.info(snakesay(f"{len(student_usernames)} students found!"))
38+
return student_usernames
39+
except Exception as e:
40+
logger.warning(snakesay(str(e)))
41+
42+
def delete(self, username):
43+
"""
44+
Returns `True` when user with `username` successfully removed from
45+
user's students list, `False` otherwise.
46+
"""
47+
48+
try:
49+
self.api.delete(username)
50+
logger.info(snakesay(f"{username!r} removed from the students list!"))
51+
return True
52+
except Exception as e:
53+
logger.warning(snakesay(str(e)))
54+
return False

pythonanywhere/students.pyi

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from typing import Optional
2+
from pythonanywhere.api.students_api import Students_Api
3+
4+
class Students:
5+
api: StudentsAPI = ...
6+
def __init__(self) -> None: ...
7+
def get(self) -> Optional[list]: ...
8+
def delete(self, username: str) -> bool: ...

tests/test_api_students.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import getpass
2+
import json
3+
4+
import pytest
5+
import responses
6+
7+
from pythonanywhere.api.base import get_api_endpoint
8+
from pythonanywhere.api.students_api import StudentsAPI
9+
10+
11+
@pytest.fixture
12+
def students_base_url():
13+
return get_api_endpoint().format(username=getpass.getuser(), flavor="students")
14+
15+
16+
@pytest.mark.students
17+
class TestStudentsAPIGet:
18+
def test_gets_list_of_students_when_there_are_some(
19+
self, api_token, api_responses, students_base_url
20+
):
21+
students = {
22+
"students": [{"username": "student1"}, {"username": "student2"}]
23+
}
24+
api_responses.add(
25+
responses.GET, url=students_base_url, status=200, body=json.dumps(students)
26+
)
27+
28+
assert StudentsAPI().get() == students
29+
30+
def test_gets_empty_list_of_students_when_there_none(
31+
self, api_token, api_responses, students_base_url
32+
):
33+
students = {"students": []}
34+
api_responses.add(
35+
responses.GET, url=students_base_url, status=200, body=json.dumps(students)
36+
)
37+
38+
assert StudentsAPI().get() == students
39+
40+
41+
@pytest.mark.students
42+
class TestStudentsAPIDelete:
43+
def test_returns_204_when_student_deleted(
44+
self, api_token, api_responses, students_base_url
45+
):
46+
username = "byebye"
47+
url = f"{students_base_url}{username}"
48+
api_responses.add(responses.DELETE, url=url, status=204)
49+
50+
assert StudentsAPI().delete("byebye") == 204
51+
52+
def test_raises_with_404_when_no_student_to_delete_found(
53+
self, api_token, api_responses, students_base_url
54+
):
55+
username = "notyourstudent"
56+
url = f"{students_base_url}{username}"
57+
api_responses.add(responses.DELETE, url=url, status=404)
58+
59+
with pytest.raises(Exception) as e:
60+
StudentsAPI().delete("notyourstudent")
61+
62+
expected_error_msg = (
63+
f"DELETE to remove student {username!r} failed, got <Response [404]>"
64+
)
65+
assert str(e.value) == expected_error_msg

0 commit comments

Comments
 (0)