pythonanywhere-core 0.1.6__tar.gz → 0.1.8__tar.gz

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.
@@ -1,21 +1,21 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pythonanywhere-core
3
- Version: 0.1.6
3
+ Version: 0.1.8
4
4
  Summary: API wrapper for programmatic management of PythonAnywhere services.
5
5
  License: MIT
6
6
  Keywords: pythonanywhere,api,cloud,web hosting
7
7
  Author: PythonAnywhere
8
8
  Author-email: developers@pythonanywhere.com
9
- Requires-Python: >=3.7,<4.0
9
+ Requires-Python: >=3.8,<4.0
10
10
  Classifier: Development Status :: 3 - Alpha
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: License :: OSI Approved :: MIT License
13
13
  Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3.7
15
14
  Classifier: Programming Language :: Python :: 3.8
16
15
  Classifier: Programming Language :: Python :: 3.9
17
16
  Classifier: Programming Language :: Python :: 3.10
18
17
  Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Topic :: Software Development :: Libraries
20
20
  Requires-Dist: python-dateutil (>=2.8.2,<3.0.0)
21
21
  Requires-Dist: requests (>=2.30.0,<3.0.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pythonanywhere-core"
3
- version = "0.1.6"
3
+ version = "0.1.8"
4
4
  description = "API wrapper for programmatic management of PythonAnywhere services."
5
5
  authors = ["PythonAnywhere <developers@pythonanywhere.com>"]
6
6
  license = "MIT"
@@ -10,26 +10,28 @@ classifiers = [
10
10
  "Intended Audience :: Developers",
11
11
  "Topic :: Software Development :: Libraries",
12
12
  "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3.12",
13
14
  "Programming Language :: Python :: 3.11",
14
15
  "Programming Language :: Python :: 3.10",
15
16
  "Programming Language :: Python :: 3.9",
16
17
  "Programming Language :: Python :: 3.8",
17
- "Programming Language :: Python :: 3.7",
18
18
  ]
19
19
  keywords = ["pythonanywhere", "api", "cloud", "web hosting"]
20
20
 
21
21
  [tool.poetry.dependencies]
22
- python = "^3.7"
22
+ python = "^3.8"
23
23
  python-dateutil = "^2.8.2"
24
24
  requests = "^2.30.0"
25
25
  snakesay = "^0.10.2"
26
26
  typing_extensions = "^4.5.0"
27
27
 
28
28
  [tool.poetry.dev-dependencies]
29
- pytest = "^7.3.1"
30
- pytest-cov = "^4.0.0"
29
+ pytest = "^8.0.0"
30
+ pytest-cov = "^5.0.0"
31
31
  pytest-mock = "^3.10.0"
32
- responses = "^0.23.0"
32
+ responses = "^0.25.0"
33
+ sphinx = "7.1.2"
34
+ sphinx-rtd-theme = "^2.0.0"
33
35
 
34
36
  [tool.black]
35
37
  line-length = 120
@@ -0,0 +1 @@
1
+ __version__ = '0.1.8'
@@ -41,6 +41,16 @@ def helpful_token_error_message() -> str:
41
41
 
42
42
 
43
43
  def call_api(url: str, method: str, **kwargs) -> requests.Response:
44
+ """Calls PythonAnywhere API with given url and method.
45
+
46
+ :param url: url to call
47
+ :param method: HTTP method to use
48
+ :param kwargs: additional keyword arguments to pass to requests.request
49
+ :returns: requests.Response object
50
+
51
+ :raises AuthenticationError: if API returns 401
52
+ :raises NoTokenError: if API_TOKEN environment variable is not set"""
53
+
44
54
  token = os.environ.get("API_TOKEN")
45
55
  if token is None:
46
56
  raise NoTokenError(helpful_token_error_message())
@@ -0,0 +1,171 @@
1
+ import getpass
2
+ from typing import Tuple, Union
3
+ from urllib.parse import urljoin
4
+
5
+ from requests.models import Response
6
+
7
+ from pythonanywhere_core.base import call_api, get_api_endpoint
8
+ from pythonanywhere_core.exceptions import PythonAnywhereApiException
9
+
10
+
11
+ class Files:
12
+ """ Interface for PythonAnywhere files API.
13
+
14
+ Uses `pythonanywhere_core.base` :method: `get_api_endpoint` to
15
+ create url, which is stored in a class variable `Files.base_url`,
16
+ then calls `call_api` with appropriate arguments to execute files
17
+ action.
18
+
19
+ Covers:
20
+ - GET, POST and DELETE for files path endpoint
21
+ - POST, GET and DELETE for files sharing endpoint
22
+ - GET for tree endpoint
23
+
24
+ "path" methods:
25
+ - use :method: `Files.path_get` to get contents of file or
26
+ directory from `path`
27
+ - use :method: `Files.path_post` to upload or update file at given
28
+ `dest_path` using contents from `source`
29
+ - use :method: `Files.path_delete` to delete file/directory on on
30
+ given `path`
31
+
32
+ "sharing" methods:
33
+ - use :method: `Files.sharing_post` to enable sharing a file from
34
+ `path` (if not shared before) and get a link to it
35
+ - use :method: `Files.sharing_get` to get sharing url for `path`
36
+ - use :method: `Files.sharing_delete` to disable sharing for
37
+ `path`
38
+
39
+ "tree" method:
40
+ - use :method: `Files.tree_get` to get list of regular files and
41
+ subdirectories of a directory at `path` (limited to 1000 results)
42
+ """
43
+
44
+ base_url = get_api_endpoint().format(username=getpass.getuser(), flavor="files")
45
+ path_endpoint = urljoin(base_url, "path")
46
+ sharing_endpoint = urljoin(base_url, "sharing/")
47
+ tree_endpoint = urljoin(base_url, "tree/")
48
+
49
+ def _error_msg(self, result: Response) -> str:
50
+ """TODO: error responses should be unified at the API side """
51
+
52
+ if "application/json" in result.headers.get("content-type", ""):
53
+ jsn = result.json()
54
+ msg = jsn.get("detail") or jsn.get("message") or jsn.get("error", "")
55
+ return f": {msg}"
56
+ return ""
57
+
58
+ def path_get(self, path: str) -> Union[dict, bytes]:
59
+ """Returns dictionary of directory contents when `path` is an
60
+ absolute path to of an existing directory or file contents if
61
+ `path` is an absolute path to an existing file -- both
62
+ available to the PythonAnywhere user. Raises when `path` is
63
+ invalid or unavailable."""
64
+
65
+ url = f"{self.path_endpoint}{path}"
66
+
67
+ result = call_api(url, "GET")
68
+
69
+ if result.status_code == 200:
70
+ if "application/json" in result.headers.get("content-type", ""):
71
+ return result.json()
72
+ return result.content
73
+
74
+ raise PythonAnywhereApiException(
75
+ f"GET to fetch contents of {url} failed, got {result}{self._error_msg(result)}"
76
+ )
77
+
78
+ def path_post(self, dest_path: str, content: bytes) -> int:
79
+ """Uploads contents of `content` to `dest_path` which should be
80
+ a valid absolute path of a file available to a PythonAnywhere
81
+ user. If `dest_path` contains directories which don't exist
82
+ yet, they will be created.
83
+
84
+ Returns 200 if existing file on PythonAnywhere has been
85
+ updated with `source` contents, or 201 if file from
86
+ `dest_path` has been created with those contents."""
87
+
88
+ url = f"{self.path_endpoint}{dest_path}"
89
+
90
+ result = call_api(url, "POST", files={"content": content})
91
+
92
+ if result.ok:
93
+ return result.status_code
94
+
95
+ raise PythonAnywhereApiException(
96
+ f"POST to upload contents to {url} failed, got {result}{self._error_msg(result)}"
97
+ )
98
+
99
+ def path_delete(self, path: str) -> int:
100
+ """Deletes the file at specified `path` (if file is a
101
+ directory it will be deleted as well).
102
+
103
+ Returns 204 on sucess, raises otherwise."""
104
+
105
+ url = f"{self.path_endpoint}{path}"
106
+
107
+ result = call_api(url, "DELETE")
108
+
109
+ if result.status_code == 204:
110
+ return result.status_code
111
+
112
+ raise PythonAnywhereApiException(
113
+ f"DELETE on {url} failed, got {result}{self._error_msg(result)}"
114
+ )
115
+
116
+ def sharing_post(self, path: str) -> Tuple[int, str]:
117
+ """Starts sharing a file at `path`.
118
+
119
+ Returns a tuple with a status code and sharing link on
120
+ success, raises otherwise. Status code is 201 on success, 200
121
+ if file has been already shared."""
122
+
123
+ url = self.sharing_endpoint
124
+
125
+ result = call_api(url, "POST", json={"path": path})
126
+
127
+ if result.ok:
128
+ return result.status_code, result.json()["url"]
129
+
130
+ raise PythonAnywhereApiException(
131
+ f"POST to {url} to share '{path}' failed, got {result}{self._error_msg(result)}"
132
+ )
133
+
134
+ def sharing_get(self, path: str) -> str:
135
+ """Checks sharing status for a `path`.
136
+
137
+ Returns url with sharing link if file is shared or an empty
138
+ string otherwise."""
139
+
140
+ url = f"{self.sharing_endpoint}?path={path}"
141
+
142
+ result = call_api(url, "GET")
143
+
144
+ return result.json()["url"] if result.ok else ""
145
+
146
+ def sharing_delete(self, path: str) -> int:
147
+ """Stops sharing file at `path`.
148
+
149
+ Returns 204 on successful unshare."""
150
+
151
+ url = f"{self.sharing_endpoint}?path={path}"
152
+
153
+ result = call_api(url, "DELETE")
154
+
155
+ return result.status_code
156
+
157
+ def tree_get(self, path: str) -> dict:
158
+ """Returns list of absolute paths of regular files and
159
+ subdirectories of a directory at `path`. Result is limited to
160
+ 1000 items.
161
+
162
+ Raises if `path` does not point to an existing directory."""
163
+
164
+ url = f"{self.tree_endpoint}?path={path}"
165
+
166
+ result = call_api(url, "GET")
167
+
168
+ if result.ok:
169
+ return result.json()
170
+
171
+ raise PythonAnywhereApiException(f"GET to {url} failed, got {result}{self._error_msg(result)}")
@@ -0,0 +1,49 @@
1
+ import getpass
2
+ from typing import Optional
3
+
4
+ from pythonanywhere_core.base import call_api, get_api_endpoint
5
+
6
+
7
+ class StudentsAPI:
8
+ """Interface for PythonAnywhere students API.
9
+
10
+ Uses `pythonanywhere.api.base` :method: `get_api_endpoint` to
11
+ create url, which is stored in a class variable `StudentsAPI.base_url`,
12
+ then calls `call_api` with appropriate arguments to execute student
13
+ action.
14
+
15
+ Covers:
16
+ - GET
17
+ - DELETE
18
+
19
+ Methods:
20
+ - use :method: `StudentsAPI.get` to get list of students
21
+ - use :method: `StudentsAPI.delete` to remove a student
22
+ """
23
+
24
+ base_url: str = get_api_endpoint().format(username=getpass.getuser(), flavor="students")
25
+
26
+ def get(self) -> Optional[dict]:
27
+ """Returns list of PythonAnywhere students related with user's account."""
28
+
29
+ result = call_api(self.base_url, "GET")
30
+
31
+ if result.status_code == 200:
32
+ return result.json()
33
+
34
+ raise Exception(f"GET to list students failed, got {result.text}")
35
+
36
+ def delete(self, student_username: str) -> Optional[int]:
37
+ """Returns 204 if student has been successfully removed, raises otherwise."""
38
+
39
+ url = f"{self.base_url}{student_username}"
40
+
41
+ result = call_api(url, "DELETE")
42
+
43
+ if result.status_code == 204:
44
+ return result.status_code
45
+
46
+ detail = f": {result.text}" if result.text else ""
47
+ raise Exception(
48
+ f"DELETE to remove student {student_username!r} failed, got {result}{detail}"
49
+ )
@@ -13,6 +13,20 @@ from pythonanywhere_core.exceptions import SanityException, PythonAnywhereApiExc
13
13
 
14
14
 
15
15
  class Webapp:
16
+ """ Interface for PythonAnywhere webapps API.
17
+ Uses `pythonanywhere_core.base` :method: `get_api_endpoint` to
18
+ create url, which is stored in a class variable `Webapp.base_url`,
19
+ then calls `call_api` with appropriate arguments to execute webapps
20
+ action.
21
+
22
+ Use :method: `Webapp.create` to create new webapp.
23
+ Use :method: `Webapp.reload` to reload webapp.
24
+ Use :method: `Webapp.set_ssl` to set SSL certificate and private key.
25
+ Use :method: `Webapp.get_ssl_info` to get SSL certificate info.
26
+ Use :method: `Webapp.delete_log` to delete log file.
27
+ Use :method: `Webapp.get_log_info` to get log files info.
28
+
29
+ """
16
30
  def __init__(self, domain: str) -> None:
17
31
  self.endpoint = get_api_endpoint()
18
32
  self.username = getpass.getuser()
@@ -25,6 +39,7 @@ class Webapp:
25
39
  return self.domain == other.domain
26
40
 
27
41
  def sanity_checks(self, nuke: bool) -> None:
42
+ """Check that we have a token, and that we don't already have a webapp for this domain"""
28
43
  print(snakesay("Running API sanity checks"))
29
44
  token = os.environ.get("API_TOKEN")
30
45
  if not token:
@@ -48,6 +63,15 @@ class Webapp:
48
63
  )
49
64
 
50
65
  def create(self, python_version: str, virtualenv_path: Path, project_path: Path, nuke: bool) -> None:
66
+ """Create a webapp for the given domain, using the given python version and virtualenv path
67
+
68
+ :param python_version: python version to use
69
+ :param virtualenv_path: path to the virtualenv
70
+ :param project_path: path to the project
71
+ :param nuke: if True, delete any existing webapp for this domain
72
+
73
+ :raises PythonAnywhereApiException: if API call fails
74
+ """
51
75
  print(snakesay("Creating web app via API"))
52
76
  if nuke:
53
77
  call_api(self.domain_url, "delete")
@@ -67,12 +91,19 @@ class Webapp:
67
91
  )
68
92
 
69
93
  def add_default_static_files_mappings(self, project_path: Path) -> None:
94
+ """Add default static files mappings for /static/ and /media/
95
+
96
+ :param project_path: path to the project
97
+ """
70
98
  print(snakesay("Adding static files mappings for /static/ and /media/"))
71
99
  url = f"{self.domain_url}static_files/"
72
100
  call_api(url, "post", json=dict(url="/static/", path=str(Path(project_path) / "static")))
73
101
  call_api(url, "post", json=dict(url="/media/", path=str(Path(project_path) / "media")))
74
102
 
75
103
  def reload(self) -> None:
104
+ """Reload webapp
105
+
106
+ :raises PythonAnywhereApiException: if API call fails"""
76
107
  print(snakesay(f"Reloading {self.domain} via API"))
77
108
  url = f"{self.domain_url}reload/"
78
109
  response = call_api(url, "post")
@@ -94,6 +125,11 @@ class Webapp:
94
125
  raise PythonAnywhereApiException(f"POST to reload webapp via API failed, got {response}:{response.text}")
95
126
 
96
127
  def set_ssl(self, certificate: str, private_key: str) -> None:
128
+ """Set SSL certificate and private key for webapp
129
+
130
+ :param certificate: SSL certificate
131
+ :param private_key: SSL private key
132
+ """
97
133
  print(snakesay(f"Setting up SSL for {self.domain} via API"))
98
134
  url = f"{self.domain_url}ssl/"
99
135
  response = call_api(url, "post", json={"cert": certificate, "private_key": private_key})
@@ -109,6 +145,7 @@ class Webapp:
109
145
  )
110
146
 
111
147
  def get_ssl_info(self) -> dict[str, Any]:
148
+ """Get SSL certificate info"""
112
149
  url = f"{self.domain_url}ssl/"
113
150
  response = call_api(url, "get")
114
151
  if not response.ok:
@@ -119,6 +156,13 @@ class Webapp:
119
156
  return result
120
157
 
121
158
  def delete_log(self, log_type: str, index: int = 0) -> None:
159
+ """Delete log file
160
+
161
+ :param log_type: log type (access, error, server)
162
+ :param index: log file index (0 for current, 1 for previous, etc.)
163
+
164
+ :raises PythonAnywhereApiException: if API call fails
165
+ """
122
166
  if index:
123
167
  message = f"Deleting old (archive number {index}) {log_type} log file for {self.domain} via API"
124
168
  else:
@@ -138,7 +182,12 @@ class Webapp:
138
182
  if not response.ok:
139
183
  raise PythonAnywhereApiException(f"DELETE log file via API failed, got {response}:{response.text}")
140
184
 
141
- def get_log_info(self):
185
+ def get_log_info(self) -> dict:
186
+ """Get log files info
187
+
188
+ :returns: dictionary with log files info
189
+
190
+ :raises PythonAnywhereApiException: if API call fails"""
142
191
  url = f"{self.files_url}tree/?path=/var/log/"
143
192
  response = call_api(url, "get")
144
193
  if not response.ok:
@@ -1 +0,0 @@
1
- __version__ = '0.1.0'