diff --git a/.github/workflows/integration_tests.yaml b/.github/workflows/integration_tests.yaml
new file mode 100644
index 0000000..b0b8da7
--- /dev/null
+++ b/.github/workflows/integration_tests.yaml
@@ -0,0 +1,39 @@
+name: integration_tests
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+jobs:
+ tests:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout dsd-pythonanywhere
+ uses: actions/checkout@v6
+ with:
+ path: dsd-pythonanywhere
+ - name: Checkout django-simple-deploy
+ uses: actions/checkout@v6
+ with:
+ repository: django-simple-deploy/django-simple-deploy
+ path: django-simple-deploy
+ - name: Install uv and set the Python version
+ uses: astral-sh/setup-uv@v7
+ with:
+ python-version: "3.14"
+ - name: Install dependencies
+ run: |
+ cd django-simple-deploy/
+ uv venv
+ uv pip install -e ".[dev]"
+ uv pip install -e "../dsd-pythonanywhere[dev]"
+ - name: Configure Git
+ run: |
+ git config --global user.email "ci_tester@example.com"
+ git config --global user.name "Ci Tester"
+ git config --global init.defaultBranch main
+ - name: Run integration tests
+ run: |
+ cd django-simple-deploy/
+ uv run pytest
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 999fb0d..33f17a0 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -20,14 +20,3 @@ jobs:
run: uv sync --locked
- name: Run tests
run: uv run pytest
- # - name: Clone django-simple-deploy parent project and install dependencies
- # run: |
- # git clone https://github.com/django-simple-deploy/django-simple-deploy.git
- # cd django-simple-deploy/
- # uv venv
- # uv pip install -e ".[dev]"
- # uv add --editable "../[dev]"
- # - name: Run integration tests
- # run: |
- # cd django-simple-deploy/
- # uv run pytest
diff --git a/README.md b/README.md
index bd3593e..0c197e4 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,34 @@
+
# dsd-pythonanywhere
+
+
A plugin for deploying Django projects to [PythonAnywhere](https://www.pythonanywhere.com/), using django-simple-deploy.
For full documentation, see the documentation for [django-simple-deploy](https://django-simple-deploy.readthedocs.io/en/latest/).
-**Current status:** In active development. The plugin currently clones your
-repository to PythonAnywhere, but it doesn't configure the web app just yet. Not
-yet recommended for actual deployments yet.
+**Current status:** In active development. Not yet recommended for actual
+deployments yet.
+
+- [Motivation](#motivation)
+- [Quickstart](#quickstart)
+- [Approach](#approach)
+- [Plugin Development](#plugin-development)
+ - [Automated Tests](#automated-tests)
## Motivation
-This plugin is motivated by the desire to provide a deployment option for
-`django-simple-deploy` that doesn't require a credit card to get started.
-PythonAnywhere offers a free tier that allows users to deploy small Django apps
-and may be a helpful way to get small Django apps online without financial
-commitment.
+This plugin hopes to provide a deployment option for `django-simple-deploy` that
+doesn't require a credit card to get started. PythonAnywhere offers a free tier
+that allows users to deploy small Django apps and may be a helpful way to get
+small Django apps online without financial commitment.
## Quickstart
@@ -28,12 +42,60 @@ requires a few prerequisites:
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
- first deployment smoother.
+ deployment smoother.
+
+With those prerequisites met, you can install the plugin and deploy your app:
+
+```sh
+# TBD
+```
+
+## Approach
+
+PythonAnywhere provides a
+[`pa_autoconfigure_django.py`](https://github.com/pythonanywhere/helper_scripts/blob/master/scripts/pa_autoconfigure_django.py)
+helper script, currently used in the [Django Girls
+tutorial](https://tutorial.djangogirls.org/en/deploy/). However, it's designed
+to run directly on PythonAnywhere, which presents challenges: using a web-based
+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).
+
+```mermaid
+sequenceDiagram
+ participant User as Local Machine
+ participant GitHub
+ participant PA as PythonAnywhere
+
+ User->>GitHub: Commit & push changes
+
+ User->>PA: Bash Console API: clone repo
+ PA->>GitHub: git clone
+ PA->>PA: Install dependencies & create .env
+
+ User->>PA: Webapp API: create webapp
+ User->>PA: Bash Console API: copy wsgi.py
+
+ PA-->>User: 🎉 App deployed!
+```
+
+**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.
## Plugin Development
To set up a development environment for working on this plugin alongside
-`django-simple-deploy`, follow these steps.
+`django-simple-deploy`, follow these steps. This will create a directory
+structure that looks like this:
+
+```sh
+dsd-dev/
+├── django-simple-deploy # ← parent project needed to run integration tests
+├── dsd-pythonanywhere # ← our plugin development directory
+└── dsd-dev-project_[random_string] # ← sample project for testing deployments
+```
1. Create a parent directory to hold your development work:
@@ -54,7 +116,7 @@ git clone git@github.com:caktus/dsd-pythonanywhere.git
```sh
git clone git@github.com:django-simple-deploy/django-simple-deploy.git
cd django-simple-deploy/
-# Builds a copy of the sample project in parent dir for testing (../dsd-dev-project-[random_string]/)
+# Builds a copy of the sample project in parent dir for testing (../dsd-dev-project_[random_string]/)
uv run python tests/e2e_tests/utils/build_dev_env.py
```
@@ -62,12 +124,20 @@ uv run python tests/e2e_tests/utils/build_dev_env.py
```sh
cd ../
-cd dsd-dev-project-[random_string]/
+cd dsd-dev-project_[random_string]/
source .venv/bin/activate
# Install dsd-pythonanywhere plugin in editable mode
pip install -e "../dsd-pythonanywhere/[dev]"
```
+Your development environment is now configured to use your local copy of
+`django-simple-deploy` and the `dsd-pythonanywhere` plugin in editable mode.
+Verify with:
+
+```sh
+pip show django_simple_deploy dsd_pythonanywhere | grep Editable
+```
+
5. Create a [new public repository on GitHub](https://github.com/new).
6. Push the sample project to your new repository:
@@ -85,9 +155,68 @@ export API_USER=[your_pythonanywhere_username]
export API_TOKEN=[your_pythonanywhere_api_token]
```
+If desired, you can add these lines to a `.env` file in the parent directory to
+more easily load them when working on the project:
+
+```sh
+source ../.env
+```
+
8. You can now make changes to `dsd-pythonanywhere` in the cloned directory
and test them by running deployments from the sample project:
```sh
python manage.py deploy
+# To reset your sample project to a clean state between tests
+python ./reset_project.py
+# (**CAUTION**) If using --automate-all, you may need to force push to reset the remote changes
+git push origin main --force
+```
+
+9. (Optional) Forward local ports for script debugging on PythonAnywhere:
+
+To debug `scripts/setup.sh` that's run on PythonAnywhere during deployment, you
+can use a service like [ngrok](https://ngrok.com/) to expose your local
+`dsd_pythonanywhere` development code to the remote environment. First, start ngrok to
+forward a local port (e.g., `8000`):
+
+```sh
+# In a new terminal
+ngrok http 8000 --url https://.ngrok-free.app
+```
+
+Then run a local HTTP server to serve your `dsd_pythonanywhere` code:
+
+```sh
+# In another terminal in the dsd-pythonanywhere directory
+uv run python -m http.server 8000
+```
+
+Finally, set the `REMOTE_SETUP_SCRIPT_URL` environment variable
+in your sample project to point to the ngrok URL for `scripts/setup.sh`:
+
+```sh
+export REMOTE_SETUP_SCRIPT_URL="https://.ngrok-free.app/scripts/setup.sh"
+```
+
+### Automated Tests
+
+To run the unit tests for this plugin, run:
+
+```sh
+cd dsd-pythonanywhere/
+uv run pytest
+```
+
+To run the integration tests (and unit tests), which exercise the mechanics of
+`python manage.py deploy` locally without actually deploying to PythonAnywhere,
+run:
+
+```sh
+cd django-simple-deploy/
+# Install dsd-pythonanywhere plugin in editable mode
+uv add --editable "../dsd-pythonanywhere[dev]"
+uv run pytest
+# To skip platform_agnostic_tests for faster feedback during plugin development:
+uv run pytest --ignore=tests/integration_tests/platform_agnostic_tests
```
diff --git a/developer_resources/api-exploration.ipynb b/developer_resources/api-exploration.ipynb
index 103dc27..d6c9987 100644
--- a/developer_resources/api-exploration.ipynb
+++ b/developer_resources/api-exploration.ipynb
@@ -53,7 +53,7 @@
},
{
"cell_type": "code",
- "execution_count": 4,
+ "execution_count": 6,
"id": "1f0cff23",
"metadata": {},
"outputs": [
@@ -62,15 +62,14 @@
"output_type": "stream",
"text": [
"Set API_TOKEN\n",
- "Set API_USER\n",
- "Set API_HOST\n"
+ "Set API_USER\n"
]
}
],
"source": [
"# VS Code's Jupyter extension doesn't support loading .envrc, so do it manually here\n",
"\n",
- "envrc = Path(\"../.envrc\")\n",
+ "envrc = Path(\"../../.env\")\n",
"env_vars = [\n",
" line.lstrip(\"export \").split(\"=\")\n",
" for line in envrc.read_text().splitlines()\n",
@@ -83,7 +82,7 @@
},
{
"cell_type": "code",
- "execution_count": 5,
+ "execution_count": 7,
"id": "7adbd24d",
"metadata": {},
"outputs": [
@@ -91,7 +90,7 @@
"name": "stderr",
"output_type": "stream",
"text": [
- "2025-09-12 13:46:45,962 - DEBUG - Converted retries value: 3 -> Retry(total=3, connect=None, read=None, redirect=None, status=None)\n"
+ "2025-12-29 12:32:51,046 - DEBUG - Converted retries value: 3 -> Retry(total=3, connect=None, read=None, redirect=None, status=None)\n"
]
}
],
@@ -147,16 +146,116 @@
},
{
"cell_type": "code",
- "execution_count": null,
+ "execution_count": 8,
"id": "5e5f7022",
"metadata": {},
"outputs": [],
- "source": []
+ "source": [
+ "from pythonanywhere_core.webapp import Webapp"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 16,
+ "id": "180bcefc",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "webapp = Webapp(\"copelco.pythonanywhere.com\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 17,
+ "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"
+ ]
+ }
+ ],
+ "source": [
+ "webapp.sanity_checks(nuke=False)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 18,
+ "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"
+ ]
+ }
+ ],
+ "source": [
+ "webapp.create(python_version=\"3.13\", virtualenv_path=\"/home/copelco/venv\", project_path=\"/home/copelco/dsd-testproj\", nuke=False)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 25,
+ "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"
+ ]
+ }
+ ],
+ "source": [
+ "webapp.reload()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b6a776d7",
+ "metadata": {},
+ "source": [
+ "Run in PA Bash console:\n",
+ "\n",
+ "Need to push requirements.txt changes first.\n",
+ "\n",
+ "```sh\n",
+ "python3.13 -m venv venv\n",
+ "./venv/bin/pip install -r dsd-testproj/requirements.txt\n",
+ "rm /var/www/copelco_pythonanywhere_com_wsgi.py\n",
+ "ln -s ~/dsd-testproj/blog/wsgi.py /var/www/copelco_pythonanywhere_com_wsgi.py\n",
+ "```"
+ ]
}
],
"metadata": {
"kernelspec": {
- "display_name": "dsd-pythonanywhere (3.12.0)",
+ "display_name": "dsd-pythonanywhere (3.14.0)",
"language": "python",
"name": "python3"
},
@@ -170,7 +269,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.12.0"
+ "version": "3.14.0"
}
},
"nbformat": 4,
diff --git a/dsd_pythonanywhere/client.py b/dsd_pythonanywhere/client.py
index 98bd303..d7b702d 100644
--- a/dsd_pythonanywhere/client.py
+++ b/dsd_pythonanywhere/client.py
@@ -4,10 +4,12 @@
import time
import webbrowser
from dataclasses import dataclass
+from pathlib import Path
import requests
from django_simple_deploy.management.commands.utils import plugin_utils
from pythonanywhere_core.base import get_api_endpoint
+from pythonanywhere_core.webapp import Webapp
from requests.adapters import HTTPAdapter
logger = logging.getLogger(__name__)
@@ -123,7 +125,7 @@ class Console:
See https://help.pythonanywhere.com/pages/API/#consoles for more details.
"""
- def __init__(self, bash_console: dict, api_client: "APIClient"):
+ def __init__(self, bash_console: dict, api_client: "PythonAnywhereClient"):
# Full console info returned from Consoles API endpoint
self.bash_console = bash_console
self.api_client = api_client
@@ -255,8 +257,8 @@ def run_command(self, command: str) -> str:
return result.output
-class APIClient:
- """PythonAnywhere API client."""
+class PythonAnywhereClient:
+ """Client for interacting with the PythonAnywhere API, including console and webapp management."""
def __init__(self, username: str):
self.username = username
@@ -265,19 +267,19 @@ def __init__(self, username: str):
self.session.headers.update({"Authorization": f"Token {self.token}"})
self.session.mount("https://", HTTPAdapter(max_retries=3))
- @property
- def _hostname(self) -> str:
- """Get the PythonAnywhere hostname.
+ # Initialize webapp for this user's domain
+ self.domain_name = f"{username}.{self._pythonanywhere_domain}"
+ self.webapp = Webapp(self.domain_name)
- This uses the same method as pythonanywhere_core to determine the hostname.
+ @property
+ def _pythonanywhere_domain(self) -> str:
+ """Get the PythonAnywhere domain (e.g., 'pythonanywhere.com')."""
+ return os.getenv("PYTHONANYWHERE_DOMAIN", "pythonanywhere.com")
- Returns:
- The hostname (e.g., "www.pythonanywhere.com")
- """
- return os.getenv(
- "PYTHONANYWHERE_SITE",
- "www." + os.getenv("PYTHONANYWHERE_DOMAIN", "pythonanywhere.com"),
- )
+ @property
+ def _hostname(self) -> str:
+ """Get the PythonAnywhere API hostname (e.g., 'www.pythonanywhere.com')."""
+ return os.getenv("PYTHONANYWHERE_SITE", f"www.{self._pythonanywhere_domain}")
def _base_url(self, flavor: str) -> str:
"""Construct the base URL for a specific API endpoint flavor.
@@ -323,6 +325,8 @@ def request(self, method: str, url: str, **kwargs):
log_message(f"API response: {response.status_code} {response.text}")
return response
+ # --- Console management methods ---
+
def get_active_console(self) -> Console:
"""Return an active PythonAnywhere bash console."""
base_url = self._base_url("consoles")
@@ -354,3 +358,79 @@ def run_command(self, command: str) -> str:
raise RuntimeError("No active bash console found")
return console.run_command(command)
+
+ # --- Webapp management methods ---
+
+ def webapp_exists(self) -> bool:
+ """Check if a web app already exists for this domain.
+
+ Returns:
+ True if a webapp exists, False otherwise
+ """
+ webapps = self.webapp.list_webapps()
+ return any(app.get("domain_name") == self.domain_name for app in webapps)
+
+ def create_webapp(
+ self,
+ python_version: str,
+ virtualenv_path: str | Path,
+ project_path: str | Path,
+ nuke: bool = False,
+ ) -> dict:
+ """Create a new web app on PythonAnywhere.
+
+ Args:
+ python_version: Python version (e.g., "3.13")
+ virtualenv_path: Path to the virtual environment
+ project_path: Path to the Django project
+ nuke: If True, delete existing webapp before creating
+
+ Returns:
+ The webapp configuration dict
+ """
+ log_message(f"Creating webapp for {self.domain_name}...")
+ self.webapp.sanity_checks(nuke=nuke)
+ result = self.webapp.create(
+ python_version=python_version,
+ virtualenv_path=str(virtualenv_path),
+ project_path=str(project_path),
+ nuke=nuke,
+ )
+ log_message(f"Webapp created: {self.domain_name}")
+ return result
+
+ def reload_webapp(self) -> None:
+ """Reload the web app to apply changes."""
+ log_message(f"Reloading webapp {self.domain_name}...")
+ self.webapp.reload()
+ log_message("Webapp reloaded successfully")
+
+ def create_or_update_webapp(
+ self,
+ python_version: str,
+ virtualenv_path: str | Path,
+ project_path: str | Path,
+ ) -> None:
+ """Create or update a webapp.
+
+ This is a convenience method that:
+ 1. Creates the webapp if it doesn't exist
+ 2. Reloads the webapp
+
+ Args:
+ python_version: Python version (e.g., "3.13")
+ virtualenv_path: Path to the virtual environment
+ project_path: Path to the Django project
+ """
+ if not self.webapp_exists():
+ self.create_webapp(
+ python_version=python_version,
+ virtualenv_path=virtualenv_path,
+ project_path=project_path,
+ nuke=False,
+ )
+ else:
+ log_message(f"Webapp {self.domain_name} already exists, skipping creation")
+
+ # Always reload to apply changes
+ self.reload_webapp()
diff --git a/dsd_pythonanywhere/deploy_messages.py b/dsd_pythonanywhere/deploy_messages.py
index 136c523..8b6fafc 100644
--- a/dsd_pythonanywhere/deploy_messages.py
+++ b/dsd_pythonanywhere/deploy_messages.py
@@ -4,12 +4,11 @@
from textwrap import dedent
-
confirm_automate_all = """
The --automate-all flag means django-simple-deploy will:
-- ...
- Commit all changes to your project that are necessary for deployment.
-- Push these changes to PythonAnywhere.
+- Push these changes to your remote repository.
+- Deploy your project to PythonAnywhere using the PythonAnywhere bash console.
- Open your deployed project in a new browser tab.
"""
diff --git a/dsd_pythonanywhere/platform_deployer.py b/dsd_pythonanywhere/platform_deployer.py
index 43a940e..9867da5 100644
--- a/dsd_pythonanywhere/platform_deployer.py
+++ b/dsd_pythonanywhere/platform_deployer.py
@@ -49,7 +49,7 @@ def _add_requirements(self):
from django_simple_deploy.management.commands.utils import plugin_utils
from django_simple_deploy.management.commands.utils.plugin_utils import dsd_config
-from dsd_pythonanywhere.client import APIClient
+from dsd_pythonanywhere.client import PythonAnywhereClient
from . import deploy_messages as platform_msgs
@@ -57,6 +57,11 @@ def _add_requirements(self):
"REMOTE_SETUP_SCRIPT_URL",
"https://raw.githubusercontent.com/caktus/dsd-pythonanywhere/refs/heads/main/scripts/setup.sh",
)
+PLUGIN_REQUIREMENTS = (
+ "dsd-pythonanywhere @ git+https://github.com/caktus/dsd-pythonanywhere@main",
+ "python-dotenv",
+ "dj-database-url",
+)
class PlatformDeployer:
@@ -68,6 +73,8 @@ class PlatformDeployer:
def __init__(self):
self.templates_path = Path(__file__).parent / "templates"
+ self.api_user = os.getenv("API_USER")
+ self.client = PythonAnywhereClient(username=self.api_user)
# --- Public methods ---
@@ -79,8 +86,10 @@ def deploy(self, *args, **options):
self._prep_automate_all()
# Configure project for deployment to PythonAnywhere
- self._clone_and_run_setup_script()
self._add_requirements()
+ self._modify_settings()
+ self._modify_wsgi()
+ self._modify_gitignore()
self._conclude_automate_all()
self._show_success_message()
@@ -98,7 +107,7 @@ def _validate_platform(self):
pass
def _get_origin_url(self) -> str:
- """"""
+ """Get the git remote origin URL."""
origin_url = (
plugin_utils.run_quick_command("git config --get remote.origin.url", check=True)
.stdout.decode()
@@ -116,51 +125,130 @@ def _get_origin_url(self) -> str:
return https_url
def _get_deployed_project_name(self):
- return os.getenv("API_USER")
+ return self.api_user
+
+ def _get_repo_name(self) -> str:
+ """Get the repository name from the git remote URL.
+
+ Falls back to the project root directory name if no remote is configured.
+ """
+ try:
+ origin_url = self._get_origin_url()
+ return Path(origin_url).stem
+ except Exception:
+ # No remote configured, use project directory name
+ return dsd_config.project_root.name
def _prep_automate_all(self):
"""Take any further actions needed if using automate_all."""
pass
- def _clone_and_run_setup_script(self):
- client = APIClient(username=os.getenv("API_USER"))
- # Proof of concept to run script remotely on Python Anywhere
+ def _clone_and_run_setup_script(self, repo_name: str):
+ # Run the setup script to clone repo and install dependencies
cmd = [f"curl -fsSL {REMOTE_SETUP_SCRIPT_URL} | bash -s --"]
origin_url = self._get_origin_url()
- repo_name = Path(origin_url).stem
- cmd.append(f"{origin_url} {repo_name}")
+ django_project_name = dsd_config.local_project_name
+ cmd.append(f"{origin_url} {repo_name} {django_project_name}")
cmd = " ".join(cmd)
plugin_utils.write_output(f" Cloning and running setup script: {cmd}")
- client.run_command(cmd)
+ self.client.run_command(cmd)
plugin_utils.write_output("Done cloning and running setup script.")
+ def _copy_wsgi_file(self, repo_name: str):
+ """Copy wsgi.py to PythonAnywhere's wsgi location.
+
+ This must be done after webapp creation, as creating a webapp
+ overwrites the wsgi file.
+ """
+ plugin_utils.write_output(" Copying wsgi.py to PythonAnywhere...")
+
+ django_project_name = dsd_config.local_project_name
+ domain = f"{self.client.username}.pythonanywhere.com"
+ wsgi_dest = f"/var/www/{domain.replace('.', '_')}_wsgi.py"
+ wsgi_src = f"{repo_name}/{django_project_name}/wsgi.py"
+
+ cmd = f"cp {wsgi_src} {wsgi_dest}"
+ self.client.run_command(cmd)
+ plugin_utils.write_output(f" Copied {wsgi_src} to {wsgi_dest}")
+
+ def _create_webapp(self, repo_name: str):
+ """Create the webapp on PythonAnywhere."""
+ plugin_utils.write_output(" Creating webapp on PythonAnywhere...")
+ # Paths on PythonAnywhere (remote home directory)
+ remote_home = Path(f"/home/{self.client.username}")
+ project_path = remote_home / repo_name
+ virtualenv_path = remote_home / "venv"
+ self.client.create_or_update_webapp(
+ python_version="3.13",
+ virtualenv_path=virtualenv_path,
+ project_path=project_path,
+ )
+ plugin_utils.write_output("Webapp created and configured.")
+
def _add_requirements(self):
"""Add requirements for deploying to PythonAnywhere."""
plugin_utils.write_output(" Adding deploy requirements...")
- requirements = (
- "gunicorn",
- "dj-database-url",
- "dsd-pythonanywhere @ git+https://github.com/caktus/dsd-pythonanywhere@main",
- )
- plugin_utils.add_packages(requirements)
+ plugin_utils.add_packages(PLUGIN_REQUIREMENTS)
+
+ def _modify_settings(self):
+ """Add platformsh-specific settings."""
+ plugin_utils.write_output(" Modifying settings.py for PythonAnywhere...")
+ template_path = self.templates_path / "settings.py"
+ context = {"deployed_project_name": self._get_deployed_project_name()}
+ plugin_utils.modify_settings_file(template_path, context)
+
+ def _modify_wsgi(self):
+ """Modify wsgi.py for PythonAnywhere deployment."""
+ plugin_utils.write_output(" Modifying wsgi.py for PythonAnywhere...")
+ template_path = self.templates_path / "wsgi.py"
+ context = {
+ "django_project_name": dsd_config.local_project_name,
+ "repo_name": self._get_repo_name(),
+ }
+ contents = plugin_utils.get_template_string(template_path, context)
+ path = dsd_config.project_root / dsd_config.local_project_name / "wsgi.py"
+ plugin_utils.add_file(path, contents)
+
+ def _modify_gitignore(self) -> None:
+ """Ensure .gitignore ignores deployment files."""
+ patterns = [".env"]
+ gitignore_path = dsd_config.git_path / ".gitignore"
+ if not gitignore_path.exists():
+ # Make the .gitignore file with patterns.
+ gitignore_path.write_text("\n".join(patterns), encoding="utf-8")
+ plugin_utils.write_output("No .gitignore file found; created .gitignore.")
+ plugin_utils.write_output("Added .env to .gitignore.")
+ else:
+ # Append patterns to .gitignore if not already there.
+ contents = gitignore_path.read_text()
+ patterns_to_add = "".join([pattern for pattern in patterns if pattern not in contents])
+ contents += f"\n{patterns_to_add}"
+ gitignore_path.write_text(contents)
+ plugin_utils.write_output(f"Added {patterns_to_add} to .gitignore")
def _conclude_automate_all(self):
"""Finish automating the push to PythonAnywhere.
- Commit all changes.
- - ...
+ - Push to remote repo.
+ - Run setup script on PythonAnywhere.
"""
# Making this check here lets deploy() be cleaner.
if not dsd_config.automate_all:
return
plugin_utils.commit_changes()
+ # Push to remote (GitHub, etc).
+ plugin_utils.write_output(" Pushing changes to remote repository...")
+ plugin_utils.run_quick_command("git push origin HEAD", check=True)
# Push project.
plugin_utils.write_output(" Deploying to PythonAnywhere...")
-
- # Should set self.deployed_url, which will be reported in the success message.
- pass
+ repo_name = self._get_repo_name()
+ self._clone_and_run_setup_script(repo_name=repo_name)
+ self._create_webapp(repo_name=repo_name)
+ self._copy_wsgi_file(repo_name=repo_name)
+ self.deployed_url = f"https://{self._get_deployed_project_name()}.pythonanywhere.com"
def _show_success_message(self):
"""After a successful run, show a message about what to do next.
diff --git a/dsd_pythonanywhere/templates/settings.py b/dsd_pythonanywhere/templates/settings.py
index da84703..cc97245 100644
--- a/dsd_pythonanywhere/templates/settings.py
+++ b/dsd_pythonanywhere/templates/settings.py
@@ -1,3 +1,28 @@
{{current_settings}}
# PythonAnywhere settings.
+import os # noqa: E402
+
+try:
+ from dotenv import load_dotenv
+
+ load_dotenv()
+except ImportError:
+ pass
+
+if os.getenv("ON_PYTHONANYWHERE"):
+ import dj_database_url
+
+ DEBUG = os.getenv("DEBUG") == "TRUE"
+ SECRET_KEY = os.getenv("SECRET_KEY")
+
+ try:
+ ALLOWED_HOSTS.append("*")
+ except NameError:
+ ALLOWED_HOSTS = ["*"]
+
+ DATABASES = {
+ "default": dj_database_url.config(),
+ }
+
+ STATIC_ROOT = os.path.join(BASE_DIR, "static")
diff --git a/dsd_pythonanywhere/templates/wsgi.py b/dsd_pythonanywhere/templates/wsgi.py
new file mode 100644
index 0000000..6130047
--- /dev/null
+++ b/dsd_pythonanywhere/templates/wsgi.py
@@ -0,0 +1,21 @@
+import os
+from pathlib import Path
+
+from dotenv import load_dotenv
+
+# Set the project directory explicitly for PythonAnywhere
+# The WSGI file lives in /var/www/, not in the project directory
+project_dir = Path.home() / "{{ repo_name }}"
+
+# Ensure the project directory is in the PYTHONPATH
+if str(project_dir) not in os.sys.path:
+ os.sys.path.insert(0, str(project_dir))
+
+# Load deployed environment variables from the project's .env file
+load_dotenv(project_dir / ".env")
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ django_project_name }}.settings")
+
+from django.core.wsgi import get_wsgi_application # noqa E402
+
+application = get_wsgi_application()
diff --git a/pyproject.toml b/pyproject.toml
index cb65658..39e4fd5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,7 +9,7 @@ description = "A plugin for django-simple-deploy, supporting deployments to Pyth
readme = "README.md"
authors = [
- {name = "Eric Matthes", email = "ehmatthes@gmail.com" },
+ {name = "Caktus Consulting Group", email = "solutions@caktusgroup.com" },
]
classifiers = [
@@ -46,6 +46,7 @@ dependencies = [
dev = [
"build>=1.2.1",
"pytest>=8.3.0",
+ "pytest-mock>=3.15.1",
"twine>=5.1.1",
]
@@ -63,7 +64,7 @@ include-package-data = true
[tool.ruff]
line-length = 100
-exclude = ["dsd_pythonanywhere/templates/settings.py", "tests/integration_tests/test_pythonanywhere_config.py"]
+exclude = ["dsd_pythonanywhere/templates/settings.py"]
[dependency-groups]
dev = [
diff --git a/scripts/setup.sh b/scripts/setup.sh
index dda9a1c..1f1302c 100644
--- a/scripts/setup.sh
+++ b/scripts/setup.sh
@@ -1,19 +1,24 @@
#!/bin/bash
set -e
-if [ -z "$1" ] || [ -z "$2" ]; then
- echo "Usage: $0 "
+if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]; then
+ echo "Usage: $0 [python-version]"
exit 1
fi
+GIT_REPO_URL=$1
+REPO_NAME=$2
+DJANGO_PROJECT_NAME=$3
+PYTHON_VERSION=${4:-python3.13}
+
# Clone the repository from the provided Git URL
echo "Cloning repository..."
-if [ ! -d "$2" ]; then
- git clone "$1" "$2"
+if [ ! -d "$REPO_NAME" ]; then
+ git clone "$GIT_REPO_URL" "$REPO_NAME"
else
- echo "Directory $2 already exists. Skipping clone."
+ echo "Directory $REPO_NAME already exists. Skipping clone."
fi
# Create and activate a Python virtual environment, if it doesn't already exist
@@ -22,7 +27,7 @@ echo "Setting up Python virtual environment..."
if [ ! -d "venv" ]; then
echo "Creating virtual environment..."
- python3.12 -m venv venv
+ $PYTHON_VERSION -m venv venv
fi
echo "Activating virtual environment..."
@@ -31,4 +36,23 @@ source venv/bin/activate
echo "Installing dependencies..."
./venv/bin/pip install --upgrade pip
-./venv/bin/pip install -r $2/requirements.txt
+./venv/bin/pip install -r $REPO_NAME/requirements.txt
+
+# Create .env file with environment variables
+
+echo "Creating .env file..."
+
+if [ ! -f "$REPO_NAME/.env" ]; then
+ # Generate a random Django secret key
+ DJANGO_SECRET_KEY=$(./venv/bin/python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())")
+ cat > "$REPO_NAME/.env" << EOF
+DEBUG=TRUE
+ON_PYTHONANYWHERE=TRUE
+SECRET_KEY=$DJANGO_SECRET_KEY
+EOF
+ echo ".env file created."
+else
+ echo ".env file already exists. Skipping creation."
+fi
+
+echo "Setup complete!!!"
diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py
new file mode 100644
index 0000000..42534bd
--- /dev/null
+++ b/tests/integration_tests/conftest.py
@@ -0,0 +1,12 @@
+"""Conftest for dsd-pythonanywhere integration tests.
+
+Re-export fixtures from django-simple-deploy's integration tests.
+"""
+
+from tests.integration_tests.conftest import dsd_version as dsd_version # noqa: F401
+from tests.integration_tests.conftest import pkg_manager as pkg_manager # noqa: F401
+from tests.integration_tests.conftest import (
+ reset_test_project as reset_test_project,
+) # noqa: F401
+from tests.integration_tests.conftest import run_dsd as run_dsd # noqa: F401
+from tests.integration_tests.conftest import tmp_project as tmp_project # noqa: F401
diff --git a/tests/integration_tests/reference_files/.gitignore b/tests/integration_tests/reference_files/.gitignore
index d2649cd..53cb54f 100644
--- a/tests/integration_tests/reference_files/.gitignore
+++ b/tests/integration_tests/reference_files/.gitignore
@@ -8,3 +8,5 @@ __pycache__/
db.sqlite3
dsd_logs/
+
+.env
\ No newline at end of file
diff --git a/tests/integration_tests/reference_files/requirements.txt b/tests/integration_tests/reference_files/requirements.txt
index 6df36b6..0f4a9c6 100644
--- a/tests/integration_tests/reference_files/requirements.txt
+++ b/tests/integration_tests/reference_files/requirements.txt
@@ -8,4 +8,7 @@ requests==2.32.3
sqlparse==0.5.2
urllib3==2.2.3
-django-simple-deploy=={current-version}
\ No newline at end of file
+django-simple-deploy=={current-version}
+dsd-pythonanywhere @ git+https://github.com/caktus/dsd-pythonanywhere@main
+python-dotenv
+dj-database-url
\ No newline at end of file
diff --git a/tests/integration_tests/reference_files/settings.py b/tests/integration_tests/reference_files/settings.py
index d06621e..cc78a78 100644
--- a/tests/integration_tests/reference_files/settings.py
+++ b/tests/integration_tests/reference_files/settings.py
@@ -131,3 +131,31 @@
# My settings.
LOGIN_URL = "users:login"
+
+
+# PythonAnywhere settings.
+import os # noqa: E402
+
+try:
+ from dotenv import load_dotenv
+
+ load_dotenv()
+except ImportError:
+ pass
+
+if os.getenv("ON_PYTHONANYWHERE"):
+ import dj_database_url
+
+ DEBUG = os.getenv("DEBUG") == "TRUE"
+ SECRET_KEY = os.getenv("SECRET_KEY")
+
+ try:
+ ALLOWED_HOSTS.append("*")
+ except NameError:
+ ALLOWED_HOSTS = ["*"]
+
+ DATABASES = {
+ "default": dj_database_url.config(),
+ }
+
+ STATIC_ROOT = os.path.join(BASE_DIR, "static")
diff --git a/tests/integration_tests/test_pythonanywhere_config.py b/tests/integration_tests/test_pythonanywhere_config.py
index 317eee7..e53d448 100644
--- a/tests/integration_tests/test_pythonanywhere_config.py
+++ b/tests/integration_tests/test_pythonanywhere_config.py
@@ -1,10 +1,9 @@
-"""Integration tests for django-simple-deploy, targeting Fly.io."""
+"""Integration tests for django-simple-deploy, targeting PythonAnywhere."""
from pathlib import Path
-from tests.integration_tests.conftest import (dsd_version, pkg_manager,
- reset_test_project, run_dsd,
- tmp_project)
+import pytest
+
from tests.integration_tests.utils import it_helper_functions as hf
# --- Fixtures ---
@@ -50,14 +49,17 @@ def test_pyproject_toml(tmp_project, pkg_manager, tmp_path, dsd_version):
if pkg_manager in ("req_txt", "pipenv"):
assert not Path("pyproject.toml").exists()
elif pkg_manager == "poetry":
- context = {"current-version": dsd_version}
- hf.check_reference_file(
- tmp_project,
- "pyproject.toml",
- "dsd-pythonanywhere",
- context=context,
- tmp_path=tmp_path,
+ pytest.skip(
+ "Skipping unsupported dsd-pythonanywhere pyproject.toml test for poetry"
)
+ # context = {"current-version": dsd_version}
+ # hf.check_reference_file(
+ # tmp_project,
+ # "pyproject.toml",
+ # "dsd-pythonanywhere",
+ # context=context,
+ # tmp_path=tmp_path,
+ # )
def test_pipfile(tmp_project, pkg_manager, tmp_path, dsd_version):
@@ -65,14 +67,15 @@ def test_pipfile(tmp_project, pkg_manager, tmp_path, dsd_version):
if pkg_manager in ("req_txt", "poetry"):
assert not Path("Pipfile").exists()
elif pkg_manager == "pipenv":
- context = {"current-version": dsd_version}
- hf.check_reference_file(
- tmp_project,
- "Pipfile",
- "dsd-pythonanywhere",
- context=context,
- tmp_path=tmp_path,
- )
+ pytest.skip("Skipping unsupported dsd-pythonanywhere Pipfile test for pipenv")
+ # context = {"current-version": dsd_version}
+ # hf.check_reference_file(
+ # tmp_project,
+ # "Pipfile",
+ # "dsd-pythonanywhere",
+ # context=context,
+ # tmp_path=tmp_path,
+ # )
def test_gitignore(tmp_project):
@@ -130,7 +133,9 @@ def test_log_dir(tmp_project):
# DEV: Update these for more platform-specific log messages.
# Spot check for opening log messages.
assert "INFO: Logging run of `manage.py deploy`..." in log_file_text
- assert "INFO: Configuring project for deployment to PythonAnywhere..." in log_file_text
+ assert (
+ "INFO: Configuring project for deployment to PythonAnywhere..." in log_file_text
+ )
assert "INFO: CLI args:" in log_file_text
assert (
diff --git a/tests/integration_tests/test_setup_script.py b/tests/integration_tests/test_setup_script.py
new file mode 100644
index 0000000..a03f888
--- /dev/null
+++ b/tests/integration_tests/test_setup_script.py
@@ -0,0 +1,94 @@
+"""Basic happy path tests for the PythonAnywhere setup.sh script."""
+
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+
+@pytest.fixture(scope="module")
+def setup_script_result(tmp_path_factory) -> dict:
+ """Run setup.sh once and return the result along with paths for testing."""
+ tmp_path = tmp_path_factory.mktemp("setup_script")
+ script_path = Path(__file__).parent.parent.parent / "scripts" / "setup.sh"
+ dir_name = "test_project"
+ django_project_name = "mysite"
+ # Use the current Python version available on CI for testing
+ python_version = f"python{sys.version_info.major}.{sys.version_info.minor}"
+
+ # Create a minimal git repository with a requirements.txt file and Django project
+ source_repo = tmp_path / "source_repo"
+ source_repo.mkdir()
+ (source_repo / "requirements.txt").write_text("django\n")
+
+ # Create a minimal Django project structure with wsgi.py
+ django_project_dir = source_repo / django_project_name
+ django_project_dir.mkdir()
+ (django_project_dir / "wsgi.py").write_text(
+ 'import os\nos.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")\n'
+ )
+
+ subprocess.run(["git", "init"], cwd=source_repo, check=True, capture_output=True)
+ subprocess.run(["git", "add", "."], cwd=source_repo, check=True, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-m", "Initial commit"],
+ cwd=source_repo,
+ check=True,
+ capture_output=True,
+ )
+ repo_url = source_repo.as_uri()
+ result = subprocess.run(
+ [
+ "bash",
+ str(script_path),
+ repo_url,
+ dir_name,
+ django_project_name,
+ python_version,
+ ],
+ cwd=tmp_path,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+ return {
+ "result": result,
+ "tmp_path": tmp_path,
+ "venv_path": tmp_path / "venv",
+ "project_path": tmp_path / dir_name,
+ "django_project_name": django_project_name,
+ }
+
+
+def test_setup_script_creates_venv(setup_script_result):
+ """setup.sh creates a virtual environment."""
+ assert setup_script_result["result"].returncode == 0
+ assert setup_script_result["venv_path"].exists()
+ assert (setup_script_result["venv_path"] / "bin" / "activate").exists()
+
+
+def test_setup_script_clones_repo(setup_script_result):
+ """setup.sh clones the repository."""
+ assert setup_script_result["project_path"].exists()
+ assert (setup_script_result["project_path"] / "requirements.txt").exists()
+
+
+def test_setup_script_creates_env_file(setup_script_result):
+ """setup.sh creates a .env file with required environment variables."""
+ assert ".env file created." in setup_script_result["result"].stdout
+
+ env_file = setup_script_result["project_path"] / ".env"
+ assert env_file.exists()
+
+ env_content = env_file.read_text()
+ assert "DEBUG=TRUE" in env_content
+ assert "ON_PYTHONANYWHERE=TRUE" in env_content
+ assert "SECRET_KEY=" in env_content
+
+ # Verify secret key is not empty
+ for line in env_content.splitlines():
+ if line.startswith("SECRET_KEY="):
+ secret_key = line.split("=", 1)[1]
+ assert len(secret_key) == 50
diff --git a/tests/unit_tests/test_client_api_client.py b/tests/unit_tests/test_client_api_client.py
index 3b62571..690a123 100644
--- a/tests/unit_tests/test_client_api_client.py
+++ b/tests/unit_tests/test_client_api_client.py
@@ -1,20 +1,20 @@
import pytest
import requests
-from dsd_pythonanywhere.client import APIClient
+from dsd_pythonanywhere.client import PythonAnywhereClient
@pytest.fixture
def api_client(mocker):
"""APIClient instance with mocked environment."""
mocker.patch.dict("os.environ", {"API_TOKEN": "test_token_12345"})
- return APIClient(username="testuser")
+ return PythonAnywhereClient(username="testuser")
def test_api_client_init(mocker):
"""APIClient initializes with username and token from environment."""
mocker.patch.dict("os.environ", {"API_TOKEN": "my_secret_token"})
- client = APIClient(username="myuser")
+ client = PythonAnywhereClient(username="myuser")
assert client.username == "myuser"
assert client.token == "my_secret_token"
@@ -34,7 +34,7 @@ def test_hostname_custom_domain(mocker):
"os.environ",
{"API_TOKEN": "token", "PYTHONANYWHERE_DOMAIN": "pythonanywhere.eu"},
)
- client = APIClient(username="testuser")
+ client = PythonAnywhereClient(username="testuser")
assert client._hostname == "www.pythonanywhere.eu"
diff --git a/tests/unit_tests/test_client_console.py b/tests/unit_tests/test_client_console.py
index ec55202..07c7000 100644
--- a/tests/unit_tests/test_client_console.py
+++ b/tests/unit_tests/test_client_console.py
@@ -1,13 +1,13 @@
import pytest
-from dsd_pythonanywhere.client import APIClient, CommandResult, CommandRun, Console
+from dsd_pythonanywhere.client import CommandResult, CommandRun, Console, PythonAnywhereClient
@pytest.fixture
def mock_api_client(mocker):
"""APIClient instance with mocked request method."""
mocker.patch.dict("os.environ", {"API_TOKEN": "test_token_12345"})
- client = APIClient(username="testuser")
+ client = PythonAnywhereClient(username="testuser")
mocker.patch.object(client, "request")
return client
diff --git a/tests/unit_tests/test_platform_deployer.py b/tests/unit_tests/test_platform_deployer.py
new file mode 100644
index 0000000..907e64c
--- /dev/null
+++ b/tests/unit_tests/test_platform_deployer.py
@@ -0,0 +1,67 @@
+from pathlib import Path
+import sys
+
+from dsd_pythonanywhere.platform_deployer import (
+ PlatformDeployer,
+ dsd_config,
+ PLUGIN_REQUIREMENTS,
+)
+
+
+def test_modify_gitignore(tmp_path: Path, monkeypatch):
+ """Test that _modify_gitignore adds patterns correctly."""
+ deployer = PlatformDeployer()
+ monkeypatch.setattr(dsd_config, "git_path", tmp_path)
+ monkeypatch.setattr(dsd_config, "stdout", sys.stdout)
+
+ gitignore_path = tmp_path / ".gitignore"
+
+ # Test when .gitignore does not exist
+ deployer._modify_gitignore()
+ assert gitignore_path.exists()
+ contents = gitignore_path.read_text()
+ assert ".env" in contents
+
+ # Test when .gitignore exists but does not contain .env
+ gitignore_path.write_text("*.pyc\n__pycache__/")
+ deployer._modify_gitignore()
+ contents = gitignore_path.read_text()
+ assert ".env" in contents
+
+ # Test when .gitignore already contains .env
+ gitignore_path.write_text(".env\n*.pyc\n__pycache__/")
+ deployer._modify_gitignore()
+ contents = gitignore_path.read_text()
+ assert contents.count(".env") == 1
+
+
+def test_modify_settings(tmp_path: Path, monkeypatch):
+ """Look for one of expected modified lines in settings.py."""
+ settings_path = tmp_path / "settings.py"
+ settings_content = "# Existing settings"
+ settings_path.write_text(settings_content)
+
+ deployer = PlatformDeployer()
+ monkeypatch.setattr(dsd_config, "settings_path", settings_path)
+ monkeypatch.setattr(dsd_config, "stdout", sys.stdout)
+
+ deployer._modify_settings()
+ modified_content = settings_path.read_text()
+ assert 'if os.getenv("ON_PYTHONANYWHERE"):' in modified_content
+
+
+def test_add_requirements(tmp_path: Path, monkeypatch):
+ """Test that _add_requirements adds required packages."""
+ requirements_path = tmp_path / "requirements.txt"
+ requirements_content = "Django"
+ requirements_path.write_text(requirements_content)
+
+ deployer = PlatformDeployer()
+ monkeypatch.setattr(dsd_config, "req_txt_path", requirements_path)
+ monkeypatch.setattr(dsd_config, "stdout", sys.stdout)
+ monkeypatch.setattr(dsd_config, "requirements", [])
+
+ deployer._add_requirements()
+ modified_content = requirements_path.read_text()
+ for package in PLUGIN_REQUIREMENTS:
+ assert package in modified_content
diff --git a/uv.lock b/uv.lock
index 0ff2258..b5f350d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -728,6 +728,7 @@ dev = [
{ name = "build" },
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
{ name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "pytest-mock" },
{ name = "twine" },
]
@@ -749,6 +750,7 @@ requires-dist = [
{ name = "django-simple-deploy", specifier = ">=0.9.0" },
{ name = "pluggy", specifier = ">=1.5.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" },
+ { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.15.1" },
{ name = "pythonanywhere", specifier = ">=0.15.5" },
{ name = "pythonanywhere-core", specifier = ">=0.2.4" },
{ name = "requests", specifier = ">=2.32.2" },