Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,7 @@ jobs:
cache-dependency-glob: "uv.lock"
- name: Install dependencies
run: uv sync --locked
- name: Run pre-commit
run: uv run pre-commit run --all-files
- name: Run tests
run: uv run pytest
11 changes: 7 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance when working with code in this repository.

## Project Overview

dsd-pythonahwyhere is a plugin for deploying Django projects to PythonAnywhere, using django-simple-deploy.
dsd-pythonanywhere is a plugin for deploying Django projects to PythonAnywhere, using django-simple-deploy.

## Development Commands

Expand All @@ -19,9 +19,12 @@ dsd-pythonahwyhere is a plugin for deploying Django projects to PythonAnywhere,

- Run tests with pytest: `uv run pytest`
- Tests are located in the `tests/` directory and follow standard pytest and pytest-mock conventions.
- Integration tests (in `dsd-pythonanywhere/tests/integration_tests`) must be run from the `django-simple-deploy` project root. See `.github/workflows/integration_tests.yaml` for test setup details.
- Use `-k` to filter tests by name pattern, e.g., `uv run pytest -k "test_setup_script"` to run only setup script tests.
- Add or update tests for the code you change, even if nobody asked.
- New features and bug fixes should always include a concise test (not exhaustive).
- Always run full test suite and ruff pre-commit hooks as the last tasks in your todo list

### Code Quality

- Run pre-commit hooks: `uv run pre-commit run --all-files`
- Format Python code with Ruff: `uv run ruff format .`
- Lint and auto-fix Python code: `uv run ruff check --fix .`
- Run ruff pre-commit hooks: `uv run pre-commit run --all-files`.
65 changes: 59 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,53 @@ requires a few prerequisites:
- Create a PythonAnywhere [Beginner account](https://www.pythonanywhere.com/registration/register/beginner/),
which is a limited account with one web app, but requires no credit card.
- Generate an [API token](https://help.pythonanywhere.com/pages/GettingYourAPIToken)
- Ideally, stay logged in to PythonAnywhere in your default browser to make the
deployment smoother.
- Stay logged in to PythonAnywhere in your default browser.

With those prerequisites met, you can install the plugin and deploy your app:
With those prerequisites met, and if you're coming from the [Django Girls tutorial Deploy section](https://tutorial.djangogirls.org/en/deploy/),
you can deploy your project with the following steps:

1. Export your PythonAnywhere API credentials and install `dsd-pythonanywhere`:

```sh
# Export your PythonAnywhere API credentials as environment variables
export API_USER=[your_pythonanywhere_username]
export API_TOKEN=[your_pythonanywhere_api_token]
# Install dsd-pythonanywhere (which also installs django-simple-deploy)
pip install git+https://github.com/caktus/dsd-pythonanywhere.git@main
```

2. Add `django-simple-deploy` to your `INSTALLED_APPS` in `settings.py`:

```diff
diff --git a/mysite/settings.py b/mysite/settings.py
index 8bf8f39..b288aa1 100644
--- a/mysite/settings.py
+++ b/mysite/settings.py
@@ -38,6 +38,7 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"blog",
+ "django_simple_deploy",
]
```

3. Run the deployment command:

```sh
# TBD
python manage.py deploy --automate-all
```

This command can take several minutes as it creates the web app, installs
dependencies, etc. You should see progress in your browser console on
PythonAnywhere as well.

If you run into issues and need to re-run the deployment, you may need to
reset your local and remote repositories to a clean state first:

```sh
# Stash any local changes
git stash --include-untracked
# Go back to step 2 since settings.py was reverted
```

## Approach
Expand All @@ -61,16 +101,21 @@ console, changes not being committed/pushed to version control, etc.

This plugin integrates with `django-simple-deploy` to provide a more familiar
local workflow, though with some caveats due to free tier limitations (primarily
lack of SSH access).
lack of SSH access and required browser interaction).

```mermaid
sequenceDiagram
participant User as Local Machine
participant Browser
participant GitHub
participant PA as PythonAnywhere

User->>GitHub: Commit & push changes

User->>PA: Bash Console API: create console
User->>Browser: Open console URL
Note over Browser,PA: Browser connection starts bash process

User->>PA: Bash Console API: clone repo
PA->>GitHub: git clone
PA->>PA: Install dependencies & create .env
Expand All @@ -82,7 +127,15 @@ sequenceDiagram
```

**Note:** Users should stay logged into PythonAnywhere in their default browser
during deployment, as the console API may need to start a new console session.
during deployment.

Additionally:

* If a PythonAnywhere bash console isn't already running, the plugin will
programmatically open your browser to the console URL. This is required because
the PythonAnywhere API creates console objects but doesn't start the actual
process. Only connecting to the console in a browser will do that (per the [API
documentation](https://help.pythonanywhere.com/pages/API/#apiv0userusernameconsoles)).

## Plugin Development

Expand Down
150 changes: 36 additions & 114 deletions developer_resources/api-exploration.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
},
{
"cell_type": "code",
"execution_count": 1,
"execution_count": null,
"id": "79210915",
"metadata": {},
"outputs": [],
Expand All @@ -21,7 +21,7 @@
},
{
"cell_type": "code",
"execution_count": 2,
"execution_count": null,
"id": "225f3422-2872-4deb-9783-e249957aca30",
"metadata": {},
"outputs": [],
Expand All @@ -35,7 +35,7 @@
},
{
"cell_type": "code",
"execution_count": 3,
"execution_count": null,
"id": "af8c7fe0",
"metadata": {},
"outputs": [],
Expand All @@ -44,7 +44,7 @@
"import os\n",
"from pathlib import Path\n",
"\n",
"from dsd_pythonanywhere.client import APIClient\n",
"from dsd_pythonanywhere.client import PythonAnywhereClient\n",
"\n",
"logging.basicConfig(\n",
" level=logging.DEBUG, force=True, format=\"%(asctime)s - %(levelname)s - %(message)s\"\n",
Expand All @@ -53,19 +53,10 @@
},
{
"cell_type": "code",
"execution_count": 6,
"execution_count": null,
"id": "1f0cff23",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Set API_TOKEN\n",
"Set API_USER\n"
]
}
],
"outputs": [],
"source": [
"# VS Code's Jupyter extension doesn't support loading .envrc, so do it manually here\n",
"\n",
Expand All @@ -82,71 +73,38 @@
},
{
"cell_type": "code",
"execution_count": 7,
"execution_count": null,
"id": "7adbd24d",
"metadata": {},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"2025-12-29 12:32:51,046 - DEBUG - Converted retries value: 3 -> Retry(total=3, connect=None, read=None, redirect=None, status=None)\n"
]
}
],
"outputs": [],
"source": [
"username = os.getenv(\"API_USER\")\n",
"client = APIClient(username)"
"client = PythonAnywhereClient(username)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "c8fbf886",
"metadata": {},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"2025-09-12 13:46:48,086 - DEBUG - Starting new HTTPS connection (1): www.pythonanywhere.com:443\n",
"2025-09-12 13:46:48,242 - DEBUG - https://www.pythonanywhere.com:443 \"GET /api/v0/user/copelco/consoles/ HTTP/1.1\" 200 2\n",
"2025-09-12 13:46:48,243 - DEBUG - API response: 200 []\n",
"2025-09-12 13:46:48,243 - DEBUG - No active bash console found, starting a new one...\n",
"2025-09-12 13:46:48,311 - DEBUG - https://www.pythonanywhere.com:443 \"POST /api/v0/user/copelco/consoles/ HTTP/1.1\" 201 233\n",
"2025-09-12 13:46:48,311 - DEBUG - API response: 201 {\"id\":42095523,\"user\":\"copelco\",\"executable\":\"bash\",\"arguments\":\"\",\"working_directory\":null,\"name\":\"Bash console 42095523\",\"console_url\":\"/user/copelco/consoles/42095523/\",\"console_frame_url\":\"/user/copelco/consoles/42095523/frame/\"}\n",
"2025-09-12 13:46:48,312 - DEBUG - Found bash console: {'id': 42095523, 'user': 'copelco', 'executable': 'bash', 'arguments': '', 'working_directory': None, 'name': 'Bash console 42095523', 'console_url': '/user/copelco/consoles/42095523/', 'console_frame_url': '/user/copelco/consoles/42095523/frame/'}\n",
"2025-09-12 13:46:48,312 - DEBUG - Attempt 0: checking if console is active\n",
"2025-09-12 13:46:48,380 - DEBUG - https://www.pythonanywhere.com:443 \"POST /api/v0/user/copelco/consoles/42095523/send_input/ HTTP/1.1\" 412 87\n",
"2025-09-12 13:46:48,381 - DEBUG - API error status_code=412 error_data={'error': 'Console not yet started. Please load it (or its iframe) in a browser first'}\n",
"2025-09-12 13:46:48,381 - DEBUG - API response: 412 {\"error\":\"Console not yet started. Please load it (or its iframe) in a browser first\"}\n",
"2025-09-12 13:46:48,382 - DEBUG - Console not yet started, opening browser...\n",
"2025-09-12 13:46:48,523 - DEBUG - Console not yet started, waiting...\n",
"2025-09-12 13:46:49,530 - DEBUG - Attempt 1: checking if console is active\n",
"2025-09-12 13:46:49,625 - DEBUG - https://www.pythonanywhere.com:443 \"POST /api/v0/user/copelco/consoles/42095523/send_input/ HTTP/1.1\" 200 15\n",
"2025-09-12 13:46:49,627 - DEBUG - API response: 200 {\"status\":\"OK\"}\n",
"2025-09-12 13:46:49,627 - DEBUG - Console is active.\n",
"2025-09-12 13:46:49,707 - DEBUG - https://www.pythonanywhere.com:443 \"POST /api/v0/user/copelco/consoles/42095523/send_input/ HTTP/1.1\" 200 15\n",
"2025-09-12 13:46:49,708 - DEBUG - API response: 200 {\"status\":\"OK\"}\n",
"2025-09-12 13:46:49,777 - DEBUG - https://www.pythonanywhere.com:443 \"GET /api/v0/user/copelco/consoles/42095523/get_latest_output/ HTTP/1.1\" 200 51\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"\n",
"Preparing execution environment...\n"
]
}
],
"outputs": [],
"source": [
"print(client.run_command(\"ls -la\"))"
"client.request(method=\"GET\", url=client._base_url(\"cpu\"))"
]
},
{
"cell_type": "code",
"execution_count": 8,
"execution_count": null,
"id": "83e6f6f1",
"metadata": {},
"outputs": [],
"source": [
"client.webapp_exists()"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "5e5f7022",
"metadata": {},
"outputs": [],
Expand All @@ -156,81 +114,45 @@
},
{
"cell_type": "code",
"execution_count": 16,
"execution_count": null,
"id": "180bcefc",
"metadata": {},
"outputs": [],
"source": [
"webapp = Webapp(\"copelco.pythonanywhere.com\")"
"webapp = Webapp.list_webapps()"
]
},
{
"cell_type": "code",
"execution_count": 17,
"execution_count": null,
"id": "1fe24344",
"metadata": {},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"2025-12-29 13:12:08,848 - DEBUG - Starting new HTTPS connection (1): www.pythonanywhere.com:443\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"2025-12-29 13:12:09,476 - DEBUG - https://www.pythonanywhere.com:443 \"GET /api/v0/user/copelco/webapps/copelco.pythonanywhere.com/ HTTP/1.1\" 403 63\n"
]
}
],
"outputs": [],
"source": [
"webapp.sanity_checks(nuke=False)"
]
},
{
"cell_type": "code",
"execution_count": 18,
"execution_count": null,
"id": "a27334f9",
"metadata": {},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"2025-12-29 13:12:11,430 - DEBUG - Starting new HTTPS connection (1): www.pythonanywhere.com:443\n",
"2025-12-29 13:12:20,857 - DEBUG - https://www.pythonanywhere.com:443 \"POST /api/v0/user/copelco/webapps/ HTTP/1.1\" 200 94\n",
"2025-12-29 13:12:20,863 - DEBUG - Starting new HTTPS connection (1): www.pythonanywhere.com:443\n",
"2025-12-29 13:12:21,097 - DEBUG - https://www.pythonanywhere.com:443 \"PATCH /api/v0/user/copelco/webapps/copelco.pythonanywhere.com/ HTTP/1.1\" 200 None\n"
]
}
],
"outputs": [],
"source": [
"webapp.create(python_version=\"3.13\", virtualenv_path=\"/home/copelco/venv\", project_path=\"/home/copelco/dsd-testproj\", nuke=False)"
"webapp.create(\n",
" python_version=\"3.13\",\n",
" virtualenv_path=\"/home/copelco/venv\",\n",
" project_path=\"/home/copelco/dsd-testproj\",\n",
" nuke=False,\n",
")"
]
},
{
"cell_type": "code",
"execution_count": 25,
"execution_count": null,
"id": "64c54a59",
"metadata": {},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"2025-12-29 13:38:21,458 - DEBUG - Starting new HTTPS connection (1): www.pythonanywhere.com:443\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"2025-12-29 13:38:32,034 - DEBUG - https://www.pythonanywhere.com:443 \"POST /api/v0/user/copelco/webapps/copelco.pythonanywhere.com/reload/ HTTP/1.1\" 200 15\n"
]
}
],
"outputs": [],
"source": [
"webapp.reload()"
]
Expand Down
Loading