Skip to content

Commit 87b226e

Browse files
committed
PA-1319 Add Webapp get, delete and patch methods and update docstrings, by: Alex, Piotr
1 parent d503315 commit 87b226e

File tree

2 files changed

+222
-10
lines changed

2 files changed

+222
-10
lines changed

pythonanywhere_core/webapp.py

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import getpass
55
from pathlib import Path
66
from textwrap import dedent
7+
from typing import Any
78

89
from dateutil.parser import parse
910
from snakesay import snakesay
@@ -22,11 +23,15 @@ class Webapp:
2223
Methods:
2324
- :meth:`Webapp.create`: Create a new webapp.
2425
- :meth:`Webapp.create_static_file_mapping`: Create a static file mapping.
26+
- :meth:`Webapp.add_default_static_files_mappings`: Add default static files mappings.
2527
- :meth:`Webapp.reload`: Reload the webapp.
2628
- :meth:`Webapp.set_ssl`: Set the SSL certificate and private key.
2729
- :meth:`Webapp.get_ssl_info`: Retrieve SSL certificate information.
2830
- :meth:`Webapp.delete_log`: Delete a log file.
2931
- :meth:`Webapp.get_log_info`: Retrieve log file information.
32+
- :meth:`Webapp.get`: Retrieve webapp information.
33+
- :meth:`Webapp.delete`: Delete webapp.
34+
- :meth:`Webapp.patch`: Patch webapp.
3035
3136
Class Methods:
3237
- :meth:`Webapp.list_webapps`: List all webapps for the current user.
@@ -43,7 +48,12 @@ def __eq__(self, other: Webapp) -> bool:
4348
return self.domain == other.domain
4449

4550
def sanity_checks(self, nuke: bool) -> None:
46-
"""Check that we have a token, and that we don't already have a webapp for this domain"""
51+
"""Check that we have a token, and that we don't already have a webapp for this domain.
52+
53+
:param nuke: if True, skip the check for existing webapp
54+
55+
:raises SanityException: if API token is missing or webapp already exists
56+
"""
4757
print(snakesay("Running API sanity checks"))
4858
token = os.environ.get("API_TOKEN")
4959
if not token:
@@ -98,14 +108,18 @@ def create_static_file_mapping(self, url_path: str, directory_path: Path) -> Non
98108
99109
:param url_path: URL path (e.g., '/static/')
100110
:param directory_path: Filesystem path to serve (as Path)
111+
112+
:raises PythonAnywhereApiException: if API call fails
101113
"""
102114
url = f"{self.domain_url}static_files/"
103115
call_api(url, "post", json=dict(url=url_path, path=str(directory_path)))
104116

105117
def add_default_static_files_mappings(self, project_path: Path) -> None:
106-
"""Add default static files mappings for /static/ and /media/
118+
"""Add default static files mappings for /static/ and /media/.
107119
108120
:param project_path: path to the project
121+
122+
:raises PythonAnywhereApiException: if API call fails
109123
"""
110124
self.create_static_file_mapping("/static/", Path(project_path) / "static")
111125
self.create_static_file_mapping("/media/", Path(project_path) / "media")
@@ -134,10 +148,12 @@ def reload(self) -> None:
134148
raise PythonAnywhereApiException(f"POST to reload webapp via API failed, got {response}:{response.text}")
135149

136150
def set_ssl(self, certificate: str, private_key: str) -> None:
137-
"""Set SSL certificate and private key for webapp
151+
"""Set SSL certificate and private key for webapp.
138152
139153
:param certificate: SSL certificate
140154
:param private_key: SSL private key
155+
156+
:raises PythonAnywhereApiException: if API call fails
141157
"""
142158
print(snakesay(f"Setting up SSL for {self.domain} via API"))
143159
url = f"{self.domain_url}ssl/"
@@ -154,7 +170,12 @@ def set_ssl(self, certificate: str, private_key: str) -> None:
154170
)
155171

156172
def get_ssl_info(self) -> dict[str, Any]:
157-
"""Get SSL certificate info"""
173+
"""Get SSL certificate info.
174+
175+
:returns: dictionary with SSL certificate information including parsed expiration date
176+
177+
:raises PythonAnywhereApiException: if API call fails
178+
"""
158179
url = f"{self.domain_url}ssl/"
159180
response = call_api(url, "get")
160181
if not response.ok:
@@ -191,10 +212,11 @@ def delete_log(self, log_type: str, index: int = 0) -> None:
191212
if not response.ok:
192213
raise PythonAnywhereApiException(f"DELETE log file via API failed, got {response}:{response.text}")
193214

194-
def get_log_info(self) -> dict:
195-
"""Get log files info
215+
def get_log_info(self) -> dict[str, list[int]]:
216+
"""Get log files info.
196217
197-
:returns: dictionary with log files info
218+
:returns: dictionary with log files info, keys are log types ('access', 'error', 'server'),
219+
values are lists of log file indices
198220
199221
:raises PythonAnywhereApiException: if API call fails"""
200222
url = f"{self.files_url}tree/?path=/var/log/"
@@ -222,12 +244,63 @@ def get_log_info(self) -> dict:
222244
return logs
223245

224246
@classmethod
225-
def list_webapps(cls) -> list:
247+
def list_webapps(cls) -> list[dict[str, Any]]:
226248
"""List all webapps for the current user.
227249
228250
:returns: list of webapps info as dictionaries
251+
252+
:raises PythonAnywhereApiException: if API call fails
229253
"""
230254
response = call_api(cls.webapps_url, "get")
231255
if not response.ok:
232-
raise PythonAnywhereApiException(f"GET webapps via API failed, got {response}:{response.text}")
256+
raise PythonAnywhereApiException(
257+
f"GET webapps via API failed, "
258+
f"got {response}:{response.text}"
259+
)
260+
return response.json()
261+
262+
def get(self) -> dict[str, Any]:
263+
"""Retrieve webapp information.
264+
265+
:returns: dictionary with webapp information
266+
267+
:raises PythonAnywhereApiException: if API call fails
268+
"""
269+
response = call_api(self.domain_url, "get")
270+
271+
if not response.ok:
272+
raise PythonAnywhereApiException(
273+
f"GET webapp for {self.domain} via API failed, got {response}:{response.text}"
274+
)
275+
276+
return response.json()
277+
278+
def delete(self) -> None:
279+
"""Delete webapp.
280+
281+
:raises PythonAnywhereApiException: if API call fails
282+
"""
283+
response = call_api(self.domain_url, "delete")
284+
285+
if response.status_code != 204:
286+
raise PythonAnywhereApiException(
287+
f"DELETE webapp for {self.domain} via API failed, got {response}:{response.text}"
288+
)
289+
290+
def patch(self, data: dict) -> dict[str, Any]:
291+
"""Patch webapp with provided data.
292+
293+
:param data: dictionary with data to update
294+
:returns: dictionary with updated webapp information
295+
296+
:raises PythonAnywhereApiException: if API call fails
297+
"""
298+
response = call_api(self.domain_url, "patch", data=data)
299+
300+
if not response.ok:
301+
raise PythonAnywhereApiException(
302+
f"PATCH webapp for {self.domain} via API failed, "
303+
f"got {response}:{response.text}"
304+
)
305+
233306
return response.json()

tests/test_webapp.py

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,25 @@ def webapp(domain):
4747
return Webapp(domain)
4848

4949

50+
@pytest.fixture
51+
def webapp_info(domain):
52+
username = getpass.getuser()
53+
return {
54+
"id": 2097234,
55+
"user": username,
56+
"domain_name": domain,
57+
"python_version": "3.10",
58+
"source_directory": f"/home/{username}/mysite",
59+
"working_directory": f"/home/{username}/",
60+
"virtualenv_path": "",
61+
"expiry": "2025-10-16",
62+
"force_https": False,
63+
"password_protection_enabled": False,
64+
"password_protection_username": "foo",
65+
"password_protection_password": "bar"
66+
}
67+
68+
5069
def test_init(base_url, domain, domain_url, webapp):
5170
assert webapp.domain == domain
5271
assert webapp.webapps_url == base_url
@@ -240,7 +259,6 @@ def test_adds_default_static_files_mappings(mocker, webapp):
240259
)
241260

242261

243-
244262
def test_does_post_to_reload_url(api_responses, api_token, domain_url, webapp):
245263
reload_url = f"{domain_url}reload/"
246264
api_responses.add(responses.POST, reload_url, status=200)
@@ -478,3 +496,124 @@ def test_list_webapps_raises_on_error(api_responses, api_token, base_url):
478496
assert "GET webapps via API failed" in str(e.value)
479497
assert "server error" in str(e.value)
480498

499+
500+
# /api/v0/user/{username}/webapps/{domain_name}/
501+
## GET
502+
503+
504+
def test_get_to_domain_name_endpoint_returns_200_with_webapp_info_when_domain_name_exists(
505+
api_responses, api_token, domain_url, webapp, webapp_info
506+
):
507+
api_responses.add(
508+
responses.GET,
509+
domain_url,
510+
status=200,
511+
body=json.dumps(webapp_info)
512+
)
513+
514+
response = webapp.get()
515+
516+
for key, value in webapp_info.items():
517+
assert response[key] == value
518+
519+
520+
def test_get_to_domain_name_endpoint_returns_403_for_not_authorized_user(
521+
api_responses, api_token, domain, domain_url, webapp
522+
):
523+
api_responses.add(
524+
responses.GET,
525+
domain_url,
526+
status=403,
527+
body='{"detail":"You do not have permission to perform this action."}',
528+
)
529+
530+
with pytest.raises(PythonAnywhereApiException) as e:
531+
webapp.get()
532+
533+
assert f"GET webapp for {domain} via API failed" in str(e.value)
534+
assert '{"detail":"You do not have permission to perform this action."}' in str(e.value)
535+
536+
537+
# /api/v0/user/{username}/webapps/{domain_name}/
538+
## DELETE
539+
540+
def test_delete_to_domain_name_endpoint_returns_204_for_authorized_user_and_existing_webapp(
541+
api_responses, api_token, domain_url, webapp
542+
):
543+
api_responses.add(
544+
responses.DELETE,
545+
domain_url,
546+
status=204,
547+
)
548+
549+
webapp.delete()
550+
551+
request, response = api_responses.calls[0]
552+
assert request.url == domain_url
553+
assert request.method == "DELETE"
554+
assert response.status_code == 204
555+
556+
557+
def test_delete_to_domain_name_endpoint_returns_403_for_authorized_user_and_non_existing_webapp(
558+
api_responses, api_token, domain, domain_url, webapp
559+
):
560+
message = '{"detail":"You do not have permission to perform this action."}'
561+
api_responses.add(
562+
responses.DELETE,
563+
domain_url,
564+
status=403,
565+
body=message
566+
)
567+
568+
with pytest.raises(PythonAnywhereApiException) as e:
569+
webapp.delete()
570+
571+
assert f"DELETE webapp for {domain} via API failed" in str(e.value)
572+
assert message in str(e.value)
573+
574+
575+
# /api/v0/user/{username}/webapps/{domain_name}/
576+
## PATCH
577+
578+
579+
def test_patch_to_domain_name_endpoint_returns_200_for_authorized_user_and_existing_webapp(
580+
api_responses, api_token, domain_url, webapp, webapp_info
581+
):
582+
new_force_https = not webapp_info["force_https"]
583+
webapp_info["force_https"] = new_force_https
584+
585+
api_responses.add(
586+
responses.PATCH,
587+
domain_url,
588+
status=200,
589+
body=json.dumps(webapp_info)
590+
)
591+
592+
response = webapp.patch({"force_https": new_force_https, "non-supported-field": "foo"})
593+
594+
for key, value in webapp_info.items():
595+
if key == "force_https":
596+
assert response[key] == new_force_https
597+
else:
598+
assert response[key] == value
599+
600+
assert "non-supported-field" not in response
601+
602+
603+
def test_patch_to_domain_name_endpoint_returns_403_for_authorized_user_and_non_existing_webapp(
604+
api_responses, api_token, domain, domain_url, webapp
605+
):
606+
message = '{"detail":"You do not have permission to perform this action."}'
607+
data = {"force_https": True}
608+
api_responses.add(
609+
responses.PATCH,
610+
domain_url,
611+
status=403,
612+
body=message
613+
)
614+
615+
with pytest.raises(PythonAnywhereApiException) as e:
616+
webapp.patch(data)
617+
618+
assert f"PATCH webapp for {domain} via API failed" in str(e.value)
619+
assert message in str(e.value)

0 commit comments

Comments
 (0)