pythonanywhere-core 0.2.8__py3-none-any.whl → 0.3.0__py3-none-any.whl

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 +1 @@
1
- __version__ = "0.2.8"
1
+ __version__ = "0.3.0"
@@ -1,8 +1,11 @@
1
+ import getpass
1
2
  import os
3
+ import platform
2
4
  from typing import Dict
3
5
 
4
6
  import requests
5
7
 
8
+ from pythonanywhere_core import __version__
6
9
  from pythonanywhere_core.exceptions import AuthenticationError, NoTokenError
7
10
 
8
11
  PYTHON_VERSIONS: Dict[str, str] = {
@@ -18,6 +21,13 @@ PYTHON_VERSIONS: Dict[str, str] = {
18
21
  }
19
22
 
20
23
 
24
+ def get_username() -> str:
25
+ """Returns PythonAnywhere username from ``PYTHONANYWHERE_USERNAME``
26
+ environment variable, falling back to :func:`getpass.getuser`."""
27
+
28
+ return os.environ.get("PYTHONANYWHERE_USERNAME", getpass.getuser())
29
+
30
+
21
31
  def get_api_endpoint(username: str, flavor: str) -> str:
22
32
  hostname = os.environ.get(
23
33
  "PYTHONANYWHERE_SITE",
@@ -54,15 +64,35 @@ def call_api(url: str, method: str, **kwargs) -> requests.Response:
54
64
  :returns: requests.Response object
55
65
 
56
66
  :raises AuthenticationError: if API returns 401
57
- :raises NoTokenError: if API_TOKEN environment variable is not set"""
67
+ :raises NoTokenError: if API_TOKEN environment variable is not set
68
+
69
+ Client identification can be provided via PYTHONANYWHERE_CLIENT environment
70
+ variable (e.g., "pa/1.0.0" or "mcp-server/0.5.0") to help with usage analytics.
71
+ """
58
72
 
59
73
  token = os.environ.get("API_TOKEN")
60
74
  if token is None:
61
75
  raise NoTokenError(helpful_token_error_message())
76
+
77
+ base_user_agent = f"pythonanywhere-core/{__version__}"
78
+ client_info = os.environ.get("PYTHONANYWHERE_CLIENT")
79
+
80
+ if client_info:
81
+ user_agent = f"{base_user_agent} ({client_info}; Python/{platform.python_version()})"
82
+ else:
83
+ user_agent = f"{base_user_agent} (Python/{platform.python_version()})"
84
+
85
+ headers = {
86
+ "Authorization": f"Token {token}",
87
+ "User-Agent": user_agent
88
+ }
89
+ if "headers" in kwargs:
90
+ headers.update(kwargs.pop("headers"))
91
+
62
92
  response = requests.request(
63
93
  method=method,
64
94
  url=url,
65
- headers={"Authorization": f"Token {token}"},
95
+ headers=headers,
66
96
  **kwargs,
67
97
  )
68
98
  if response.status_code == 401:
@@ -15,4 +15,14 @@ class NoTokenError(PythonAnywhereApiException):
15
15
 
16
16
 
17
17
  class DomainAlreadyExistsException(PythonAnywhereApiException):
18
- pass
18
+ pass
19
+
20
+
21
+ class MissingCNAMEException(PythonAnywhereApiException):
22
+ def __init__(self):
23
+ super().__init__(
24
+ "Could not find a CNAME for your website. If you're using an A record, "
25
+ "CloudFlare, or some other way of pointing your domain at PythonAnywhere "
26
+ "then that should not be a problem. If you're not, you should double-check "
27
+ "your DNS setup."
28
+ )
@@ -1,10 +1,10 @@
1
- import getpass
1
+ from pathlib import Path
2
2
  from typing import Tuple, Union
3
3
  from urllib.parse import urljoin
4
4
 
5
5
  from requests.models import Response
6
6
 
7
- from pythonanywhere_core.base import call_api, get_api_endpoint
7
+ from pythonanywhere_core.base import call_api, get_api_endpoint, get_username
8
8
  from pythonanywhere_core.exceptions import PythonAnywhereApiException
9
9
 
10
10
 
@@ -38,7 +38,7 @@ class Files:
38
38
  """
39
39
 
40
40
 
41
- base_url = get_api_endpoint(username=getpass.getuser(), flavor="files")
41
+ base_url = get_api_endpoint(username=get_username(), flavor="files")
42
42
  path_endpoint = urljoin(base_url, "path")
43
43
  sharing_endpoint = urljoin(base_url, "sharing/")
44
44
  tree_endpoint = urljoin(base_url, "tree/")
@@ -174,3 +174,23 @@ class Files:
174
174
  return result.json()
175
175
 
176
176
  raise PythonAnywhereApiException(f"GET to {url} failed, got {result}{self._error_msg(result)}")
177
+
178
+ def tree_post(self, local_dir_path: str, remote_dir_path: str) -> None:
179
+ """Uploads contents of a local directory to remote path on
180
+ PythonAnywhere. Walks `local_dir_path` recursively and uploads
181
+ each file using :meth:`path_post`, preserving directory structure.
182
+
183
+ Raises :exc:`PythonAnywhereApiException` on first upload failure."""
184
+
185
+ local_dir = Path(local_dir_path)
186
+ if not local_dir.is_dir():
187
+ raise ValueError(f"{local_dir_path} is not a directory")
188
+ for path in sorted(local_dir.rglob("*")):
189
+ if path.is_file():
190
+ relative = path.relative_to(local_dir)
191
+ remote_path = f"{remote_dir_path}/{relative}"
192
+ self.path_post(remote_path, path.read_bytes())
193
+ elif path.is_dir() and not any(path.iterdir()):
194
+ placeholder = f"{remote_dir_path}/{path.relative_to(local_dir)}/.empty"
195
+ self.path_post(placeholder, b"")
196
+ self.path_delete(placeholder)
@@ -1,6 +1,4 @@
1
- import getpass
2
-
3
- from pythonanywhere_core.base import call_api, get_api_endpoint
1
+ from pythonanywhere_core.base import call_api, get_api_endpoint, get_username
4
2
  from pythonanywhere_core.exceptions import PythonAnywhereApiException
5
3
 
6
4
 
@@ -17,7 +15,7 @@ class CPU:
17
15
  """
18
16
 
19
17
  def __init__(self):
20
- self.base_url = get_api_endpoint(username=getpass.getuser(), flavor="cpu")
18
+ self.base_url = get_api_endpoint(username=get_username(), flavor="cpu")
21
19
 
22
20
  def get_cpu_usage(self):
23
21
  """Get current CPU usage information.
@@ -1,9 +1,8 @@
1
- import getpass
2
1
  from typing import List, Optional
3
2
 
4
3
  from typing_extensions import Literal
5
4
 
6
- from pythonanywhere_core.base import call_api, get_api_endpoint
5
+ from pythonanywhere_core.base import call_api, get_api_endpoint, get_username
7
6
  from pythonanywhere_core.exceptions import PythonAnywhereApiException
8
7
 
9
8
 
@@ -28,7 +27,7 @@ class Schedule:
28
27
  - :meth:`Schedule.update`: Update an existing task.
29
28
  """
30
29
 
31
- base_url: str = get_api_endpoint(username=getpass.getuser(), flavor="schedule")
30
+ base_url: str = get_api_endpoint(username=get_username(), flavor="schedule")
32
31
 
33
32
  def create(self, params: dict) -> Optional[dict]:
34
33
  """Creates new scheduled task using `params`.
@@ -1,7 +1,6 @@
1
- import getpass
2
1
  from typing import Optional
3
2
 
4
- from pythonanywhere_core.base import call_api, get_api_endpoint
3
+ from pythonanywhere_core.base import call_api, get_api_endpoint, get_username
5
4
 
6
5
 
7
6
  class StudentsAPI:
@@ -22,7 +21,7 @@ class StudentsAPI:
22
21
  - :meth:`StudentsAPI.delete`: Remove a student.
23
22
  """
24
23
 
25
- base_url: str = get_api_endpoint(username=getpass.getuser(), flavor="students")
24
+ base_url: str = get_api_endpoint(username=get_username(), flavor="students")
26
25
 
27
26
  def get(self) -> Optional[dict]:
28
27
  """Returns list of PythonAnywhere students related with user's account.
@@ -1,16 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
- import getpass
5
4
  from pathlib import Path
6
5
  from textwrap import dedent
7
6
  from typing import Any
8
7
 
9
8
  from dateutil.parser import parse
10
- from snakesay import snakesay
11
9
 
12
- from pythonanywhere_core.base import call_api, get_api_endpoint, PYTHON_VERSIONS
13
- from pythonanywhere_core.exceptions import SanityException, PythonAnywhereApiException
10
+ from pythonanywhere_core.base import call_api, get_api_endpoint, get_username, PYTHON_VERSIONS
11
+ from pythonanywhere_core.exceptions import SanityException, PythonAnywhereApiException, MissingCNAMEException
14
12
 
15
13
 
16
14
  class Webapp:
@@ -36,7 +34,7 @@ class Webapp:
36
34
  Class Methods:
37
35
  - :meth:`Webapp.list_webapps`: List all webapps for the current user.
38
36
  """
39
- username = getpass.getuser()
37
+ username = get_username()
40
38
  files_url = get_api_endpoint(username=username, flavor="files")
41
39
  webapps_url = get_api_endpoint(username=username, flavor="webapps")
42
40
 
@@ -54,7 +52,6 @@ class Webapp:
54
52
 
55
53
  :raises SanityException: if API token is missing or webapp already exists
56
54
  """
57
- print(snakesay("Running API sanity checks"))
58
55
  token = os.environ.get("API_TOKEN")
59
56
  if not token:
60
57
  raise SanityException(
@@ -76,11 +73,11 @@ class Webapp:
76
73
  f"You already have a webapp for {self.domain}.\n\nUse the --nuke option if you want to replace it."
77
74
  )
78
75
 
79
- def create(self, python_version: str, virtualenv_path: Path, project_path: Path, nuke: bool) -> None:
76
+ def create(self, python_version: str, virtualenv_path: Path | None, project_path: Path, nuke: bool) -> None:
80
77
  """Create a webapp for the given domain, using the given python version and virtualenv path
81
78
 
82
79
  :param python_version: python version to use
83
- :param virtualenv_path: path to the virtualenv
80
+ :param virtualenv_path: path to the virtualenv, or None to skip setting it
84
81
  :param project_path: path to the project
85
82
  :param nuke: if True, delete any existing webapp for this domain
86
83
 
@@ -127,24 +124,13 @@ class Webapp:
127
124
  def reload(self) -> None:
128
125
  """Reload webapp
129
126
 
127
+ :raises MissingCNAMEException: if CNAME not found (reload succeeded)
130
128
  :raises PythonAnywhereApiException: if API call fails"""
131
129
  url = f"{self.domain_url}reload/"
132
130
  response = call_api(url, "post")
133
131
  if not response.ok:
134
132
  if response.status_code == 409 and response.json()["error"] == "cname_error":
135
- print(
136
- snakesay(
137
- dedent(
138
- """
139
- Could not find a CNAME for your website. If you're using an A record,
140
- CloudFlare, or some other way of pointing your domain at PythonAnywhere
141
- then that should not be a problem. If you're not, you should double-check
142
- your DNS setup.
143
- """
144
- )
145
- )
146
- )
147
- return
133
+ raise MissingCNAMEException()
148
134
  raise PythonAnywhereApiException(f"POST to reload webapp via API failed, got {response}:{response.text}")
149
135
 
150
136
  def set_ssl(self, certificate: str, private_key: str) -> None:
@@ -155,7 +141,6 @@ class Webapp:
155
141
 
156
142
  :raises PythonAnywhereApiException: if API call fails
157
143
  """
158
- print(snakesay(f"Setting up SSL for {self.domain} via API"))
159
144
  url = f"{self.domain_url}ssl/"
160
145
  response = call_api(url, "post", json={"cert": certificate, "private_key": private_key})
161
146
  if not response.ok:
@@ -193,12 +178,6 @@ class Webapp:
193
178
 
194
179
  :raises PythonAnywhereApiException: if API call fails
195
180
  """
196
- if index:
197
- message = f"Deleting old (archive number {index}) {log_type} log file for {self.domain} via API"
198
- else:
199
- message = f"Deleting current {log_type} log file for {self.domain} via API"
200
- print(snakesay(message))
201
-
202
181
  if index == 1:
203
182
  suffix = ".1"
204
183
  elif index > 1:
@@ -1,7 +1,4 @@
1
- import getpass
2
-
3
-
4
- from pythonanywhere_core.base import call_api, get_api_endpoint
1
+ from pythonanywhere_core.base import call_api, get_api_endpoint, get_username
5
2
  from pythonanywhere_core.exceptions import DomainAlreadyExistsException, PythonAnywhereApiException
6
3
 
7
4
 
@@ -24,8 +21,8 @@ class Website:
24
21
  """
25
22
 
26
23
  def __init__(self) -> None:
27
- self.websites_base_url = get_api_endpoint(username=getpass.getuser(), flavor="websites")
28
- self.domains_base_url = get_api_endpoint(username=getpass.getuser(), flavor="domains")
24
+ self.websites_base_url = get_api_endpoint(username=get_username(), flavor="websites")
25
+ self.domains_base_url = get_api_endpoint(username=get_username(), flavor="domains")
29
26
 
30
27
 
31
28
  def create(self, domain_name: str, command: str) -> dict:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythonanywhere-core
3
- Version: 0.2.8
3
+ Version: 0.3.0
4
4
  Summary: API wrapper for programmatic management of PythonAnywhere services.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -20,16 +20,19 @@ Classifier: Programming Language :: Python :: 3.14
20
20
  Classifier: Topic :: Software Development :: Libraries
21
21
  Requires-Dist: python-dateutil (>=2.8.2,<3.0.0)
22
22
  Requires-Dist: requests (>=2.30.0,<3.0.0)
23
- Requires-Dist: snakesay (>=0.10.3,<0.11.0)
24
23
  Requires-Dist: typing_extensions (>=4.5.0,<5.0.0)
25
24
  Project-URL: Documentation, https://core.pythonanywhere.com/
26
25
  Project-URL: Homepage, https://core.pythonanywhere.com/
27
26
  Project-URL: Repository, https://github.com/pythonanywhere/pythonanywhere-core
28
27
  Description-Content-Type: text/x-rst
29
28
 
30
- API wrapper for programmatic management of PythonAnywhere services.
29
+ pythonanywhere-core
30
+ ===================
31
31
 
32
- It's a core code behind `PythonAnywhere cli tool`_.
32
+ Python SDK for PythonAnywhere API - programmatic management of PythonAnywhere
33
+ services including webapps, files, scheduled tasks, students, and websites.
34
+
35
+ Core library behind the `PythonAnywhere cli tool`_.
33
36
 
34
37
  .. _PythonAnywhere cli tool: https://pypi.org/project/pythonanywhere/
35
38
 
@@ -38,7 +41,6 @@ Documentation
38
41
 
39
42
  Full documentation is available at https://core.pythonanywhere.com/
40
43
 
41
-
42
44
  Development
43
45
  ===========
44
46
 
@@ -0,0 +1,13 @@
1
+ pythonanywhere_core/__init__.py,sha256=VrXpHDu3erkzwl_WXrqINBm9xWkcyUy53IQOj042dOs,22
2
+ pythonanywhere_core/base.py,sha256=l4GiEa-oe-EyAc_knX9kqmzQsPEIAH3ec1CivkWeK3Q,3454
3
+ pythonanywhere_core/exceptions.py,sha256=37aZy6txVFuiXfUdRJA5d7NQ1N5vbkdOveqv8rwnDbk,691
4
+ pythonanywhere_core/files.py,sha256=XjOsxei_tDgrubUVMmH4iYgc4sTmoFNk5EtZMJR3-0Q,7531
5
+ pythonanywhere_core/resources.py,sha256=VBXspr1sGhfAVSFoTQtwpeN5CDG9-wHZwXJ3-5vy7dY,1167
6
+ pythonanywhere_core/schedule.py,sha256=payqSYJf92gQzsZwVBtccXH4m_C--AAbdbf5DOtImhs,3900
7
+ pythonanywhere_core/students.py,sha256=VfdftRrwPFEb-m2LW7pbiS9LiffwFNTfHpVeLXMQLLc,1774
8
+ pythonanywhere_core/webapp.py,sha256=inwCDOHY_7IVQXJTeAxucn5vYPpJNdn-JLM3ji5gHwQ,11308
9
+ pythonanywhere_core/website.py,sha256=t2CIASC2WPQ051z2kbUgWNh_f6TfpWL4bKXqZHlqHSw,4339
10
+ pythonanywhere_core-0.3.0.dist-info/METADATA,sha256=bBzG7pSRywkSwgbg0HHGyvmiVLNKJ1EX7ba1-aU2jV8,1880
11
+ pythonanywhere_core-0.3.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
12
+ pythonanywhere_core-0.3.0.dist-info/licenses/LICENSE,sha256=zOJT_E5Fv8crFEKS5ulv0btbjnUiIkdsEBPEBmLHRlQ,1080
13
+ pythonanywhere_core-0.3.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.2.1
2
+ Generator: poetry-core 2.3.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,13 +0,0 @@
1
- pythonanywhere_core/__init__.py,sha256=G6Dbxq2ws-1ZAXwDD8q0KWueYtso_Y6Uyvtj8sRWsPI,22
2
- pythonanywhere_core/base.py,sha256=GtsIWqF-OFmMd6Dup_UPaXKjwUoeqKWOU4ayxjR5MAk,2497
3
- pythonanywhere_core/exceptions.py,sha256=NT6JBtd0VK3PbFc69qx4bnAygA94-pOTghp70xxz1Xw,281
4
- pythonanywhere_core/files.py,sha256=qIfbcg472TPeOejpif_i64PtWP_qROlxoz5JbePzX8Q,6491
5
- pythonanywhere_core/resources.py,sha256=NzzL-eNyo26Lr5FVJSAaxTq0Ia0vP3tIew_N7yFiFlk,1172
6
- pythonanywhere_core/schedule.py,sha256=giSO-Gssshkto9de2hSA3VeNKotXYu-lBG-iyqDolIw,3904
7
- pythonanywhere_core/students.py,sha256=PeBLUP7AOCRwCDiaH71wURwFx3H3PLQW91f5AjOPQmU,1778
8
- pythonanywhere_core/webapp.py,sha256=x_66jfGldtQrRhPPQgGFydJnvPreFyCBEYZGqeP0Ql4,12126
9
- pythonanywhere_core/website.py,sha256=6Q_37QDKkuKE3771V056aiPmrQ0v8ot6U59CIaZwQzk,4348
10
- pythonanywhere_core-0.2.8.dist-info/METADATA,sha256=jhltQyjSkV1Qq00hDkfYRUHAZSA4O-L_kMSDbmc6jic,1797
11
- pythonanywhere_core-0.2.8.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
12
- pythonanywhere_core-0.2.8.dist-info/licenses/LICENSE,sha256=zOJT_E5Fv8crFEKS5ulv0btbjnUiIkdsEBPEBmLHRlQ,1080
13
- pythonanywhere_core-0.2.8.dist-info/RECORD,,