Skip to content

Commit d932d3e

Browse files
Adds client identification.
Adds custom User-Agent header to API calls to enable analytics on how the library is being used by different clients (CLI, MCP server, direct usage, etc.).
1 parent 507144c commit d932d3e

File tree

4 files changed

+112
-10
lines changed

4 files changed

+112
-10
lines changed

README.rst

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
API wrapper for programmatic management of PythonAnywhere services.
1+
pythonanywhere-core
2+
===================
23

3-
It's a core code behind `PythonAnywhere cli tool`_.
4+
Python SDK for PythonAnywhere API - programmatic management of PythonAnywhere
5+
services including webapps, files, scheduled tasks, students, and websites.
6+
7+
Core library behind the `PythonAnywhere cli tool`_.
48

59
.. _PythonAnywhere cli tool: https://pypi.org/project/pythonanywhere/
610

@@ -9,7 +13,6 @@ Documentation
913

1014
Full documentation is available at https://core.pythonanywhere.com/
1115

12-
1316
Development
1417
===========
1518

docs/reference/environment-variables.rst

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,38 @@ PYTHONANYWHERE_DOMAIN
7070
7171
export PYTHONANYWHERE_DOMAIN="example.com"
7272
73+
PYTHONANYWHERE_CLIENT
74+
~~~~~~~~~~~~~~~~~~~~~~
75+
76+
**Required:** No
77+
78+
**Default:** Not set (library identifies itself without client information)
79+
80+
**Description:** Identifies the client application using ``pythonanywhere-core`` in API requests. This information is included in the User-Agent header and helps PythonAnywhere understand API usage patterns and improve service analytics.
81+
82+
**Format:** ``client-name/version`` (e.g., ``pa/1.0.0``, ``mcp-server/0.5.0``)
83+
84+
**When to use:**
85+
- Building a CLI tool that uses this library
86+
- Creating an MCP server
87+
- Developing automation scripts or custom applications
88+
- Any downstream tool that wraps ``pythonanywhere-core``
89+
90+
**User-Agent format:**
91+
- Without ``PYTHONANYWHERE_CLIENT``: ``pythonanywhere-core/0.2.8 (Python/3.13.7)``
92+
- With ``PYTHONANYWHERE_CLIENT``: ``pythonanywhere-core/0.2.8 (pa/1.0.0; Python/3.13.7)``
93+
94+
**Usage:**
95+
96+
.. code-block:: python
97+
98+
import os
99+
from importlib.metadata import version
100+
101+
# Set at application startup
102+
CLI_VERSION = version("my-cli-package")
103+
os.environ["PYTHONANYWHERE_CLIENT"] = f"my-cli/{CLI_VERSION}"
104+
73105
See Also
74106
--------
75107

pythonanywhere_core/base.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import os
2+
import platform
23
from typing import Dict
34

45
import requests
56

7+
from pythonanywhere_core import __version__
68
from pythonanywhere_core.exceptions import AuthenticationError, NoTokenError
79

810
PYTHON_VERSIONS: Dict[str, str] = {
@@ -54,15 +56,35 @@ def call_api(url: str, method: str, **kwargs) -> requests.Response:
5456
:returns: requests.Response object
5557
5658
:raises AuthenticationError: if API returns 401
57-
:raises NoTokenError: if API_TOKEN environment variable is not set"""
59+
:raises NoTokenError: if API_TOKEN environment variable is not set
60+
61+
Client identification can be provided via PYTHONANYWHERE_CLIENT environment
62+
variable (e.g., "pa/1.0.0" or "mcp-server/0.5.0") to help with usage analytics.
63+
"""
5864

5965
token = os.environ.get("API_TOKEN")
6066
if token is None:
6167
raise NoTokenError(helpful_token_error_message())
68+
69+
base_user_agent = f"pythonanywhere-core/{__version__}"
70+
client_info = os.environ.get("PYTHONANYWHERE_CLIENT")
71+
72+
if client_info:
73+
user_agent = f"{base_user_agent} ({client_info}; Python/{platform.python_version()})"
74+
else:
75+
user_agent = f"{base_user_agent} (Python/{platform.python_version()})"
76+
77+
headers = {
78+
"Authorization": f"Token {token}",
79+
"User-Agent": user_agent
80+
}
81+
if "headers" in kwargs:
82+
headers.update(kwargs.pop("headers"))
83+
6284
response = requests.request(
6385
method=method,
6486
url=url,
65-
headers={"Authorization": f"Token {token}"},
87+
headers=headers,
6688
**kwargs,
6789
)
6890
if response.status_code == 401:

tests/test_base.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import platform
2+
from pythonanywhere_core import __version__
3+
14
import pytest
25
import responses
36

@@ -9,11 +12,6 @@
912
from pythonanywhere_core.exceptions import AuthenticationError, NoTokenError
1013

1114

12-
@pytest.fixture
13-
def mock_requests(mocker):
14-
return mocker.patch("pythonanywhere_core.base.requests")
15-
16-
1715
def test_get_api_endpoint_defaults_to_pythonanywhere_dot_com_if_no_environment_variables():
1816
result = get_api_endpoint(username="bill", flavor="webapp")
1917

@@ -90,3 +88,50 @@ def test_helpful_message_outside_pythonanywhere(monkeypatch):
9088
monkeypatch.delenv("PYTHONANYWHERE_SITE", raising=False)
9189

9290
assert "Oops, you don't seem to have an API_TOKEN environment variable set." in helpful_token_error_message()
91+
92+
93+
def test_call_api_includes_user_agent_without_client_info(api_token, api_responses, monkeypatch):
94+
monkeypatch.delenv("PYTHONANYWHERE_CLIENT", raising=False)
95+
96+
url = "https://www.pythonanywhere.com/api/v0/test"
97+
api_responses.add(responses.GET, url, json={"status": "ok"}, status=200)
98+
99+
response = call_api(url, "GET")
100+
101+
assert response.status_code == 200
102+
expected_ua = f"pythonanywhere-core/{__version__} (Python/{platform.python_version()})"
103+
assert api_responses.calls[0].request.headers["User-Agent"] == expected_ua
104+
105+
106+
def test_call_api_includes_user_agent_with_client_info(api_token, api_responses, monkeypatch):
107+
monkeypatch.setenv("PYTHONANYWHERE_CLIENT", "pa/1.0.0")
108+
109+
url = "https://www.pythonanywhere.com/api/v0/test"
110+
api_responses.add(responses.GET, url, json={"status": "ok"}, status=200)
111+
112+
response = call_api(url, "GET")
113+
114+
assert response.status_code == 200
115+
expected_ua = f"pythonanywhere-core/{__version__} (pa/1.0.0; Python/{platform.python_version()})"
116+
assert api_responses.calls[0].request.headers["User-Agent"] == expected_ua
117+
118+
119+
def test_call_api_custom_headers_can_override_user_agent(api_token, api_responses):
120+
url = "https://www.pythonanywhere.com/api/v0/test"
121+
api_responses.add(responses.GET, url, json={"status": "ok"}, status=200)
122+
123+
response = call_api(url, "GET", headers={"User-Agent": "custom/1.0.0"})
124+
125+
assert response.status_code == 200
126+
assert api_responses.calls[0].request.headers["User-Agent"] == "custom/1.0.0"
127+
128+
129+
def test_call_api_preserves_authorization_with_custom_headers(api_token, api_responses):
130+
url = "https://www.pythonanywhere.com/api/v0/test"
131+
api_responses.add(responses.POST, url, json={"status": "ok"}, status=200)
132+
133+
response = call_api(url, "POST", headers={"X-Custom": "value"})
134+
135+
assert response.status_code == 200
136+
assert api_responses.calls[0].request.headers["Authorization"] == f"Token {api_token}"
137+
assert api_responses.calls[0].request.headers["X-Custom"] == "value"

0 commit comments

Comments
 (0)