Skip to content

Commit bc19fad

Browse files
committed
create webapp and copy wsgi file
1 parent 2375856 commit bc19fad

File tree

6 files changed

+190
-38
lines changed

6 files changed

+190
-38
lines changed

dsd_pythonanywhere/client.py

Lines changed: 94 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
import time
55
import webbrowser
66
from dataclasses import dataclass
7+
from pathlib import Path
78

89
import requests
910
from django_simple_deploy.management.commands.utils import plugin_utils
1011
from pythonanywhere_core.base import get_api_endpoint
12+
from pythonanywhere_core.webapp import Webapp
1113
from requests.adapters import HTTPAdapter
1214

1315
logger = logging.getLogger(__name__)
@@ -123,7 +125,7 @@ class Console:
123125
See https://help.pythonanywhere.com/pages/API/#consoles for more details.
124126
"""
125127

126-
def __init__(self, bash_console: dict, api_client: "APIClient"):
128+
def __init__(self, bash_console: dict, api_client: "PythonAnywhereClient"):
127129
# Full console info returned from Consoles API endpoint
128130
self.bash_console = bash_console
129131
self.api_client = api_client
@@ -255,8 +257,8 @@ def run_command(self, command: str) -> str:
255257
return result.output
256258

257259

258-
class APIClient:
259-
"""PythonAnywhere API client."""
260+
class PythonAnywhereClient:
261+
"""Client for interacting with the PythonAnywhere API, including console and webapp management."""
260262

261263
def __init__(self, username: str):
262264
self.username = username
@@ -265,19 +267,19 @@ def __init__(self, username: str):
265267
self.session.headers.update({"Authorization": f"Token {self.token}"})
266268
self.session.mount("https://", HTTPAdapter(max_retries=3))
267269

268-
@property
269-
def _hostname(self) -> str:
270-
"""Get the PythonAnywhere hostname.
270+
# Initialize webapp for this user's domain
271+
self.domain_name = f"{username}.{self._pythonanywhere_domain}"
272+
self.webapp = Webapp(self.domain_name)
271273

272-
This uses the same method as pythonanywhere_core to determine the hostname.
274+
@property
275+
def _pythonanywhere_domain(self) -> str:
276+
"""Get the PythonAnywhere domain (e.g., 'pythonanywhere.com')."""
277+
return os.getenv("PYTHONANYWHERE_DOMAIN", "pythonanywhere.com")
273278

274-
Returns:
275-
The hostname (e.g., "www.pythonanywhere.com")
276-
"""
277-
return os.getenv(
278-
"PYTHONANYWHERE_SITE",
279-
"www." + os.getenv("PYTHONANYWHERE_DOMAIN", "pythonanywhere.com"),
280-
)
279+
@property
280+
def _hostname(self) -> str:
281+
"""Get the PythonAnywhere API hostname (e.g., 'www.pythonanywhere.com')."""
282+
return os.getenv("PYTHONANYWHERE_SITE", f"www.{self._pythonanywhere_domain}")
281283

282284
def _base_url(self, flavor: str) -> str:
283285
"""Construct the base URL for a specific API endpoint flavor.
@@ -323,6 +325,8 @@ def request(self, method: str, url: str, **kwargs):
323325
log_message(f"API response: {response.status_code} {response.text}")
324326
return response
325327

328+
# --- Console management methods ---
329+
326330
def get_active_console(self) -> Console:
327331
"""Return an active PythonAnywhere bash console."""
328332
base_url = self._base_url("consoles")
@@ -354,3 +358,79 @@ def run_command(self, command: str) -> str:
354358
raise RuntimeError("No active bash console found")
355359

356360
return console.run_command(command)
361+
362+
# --- Webapp management methods ---
363+
364+
def webapp_exists(self) -> bool:
365+
"""Check if a web app already exists for this domain.
366+
367+
Returns:
368+
True if a webapp exists, False otherwise
369+
"""
370+
webapps = self.webapp.list_webapps()
371+
return any(app.get("domain_name") == self.domain_name for app in webapps)
372+
373+
def create_webapp(
374+
self,
375+
python_version: str,
376+
virtualenv_path: str | Path,
377+
project_path: str | Path,
378+
nuke: bool = False,
379+
) -> dict:
380+
"""Create a new web app on PythonAnywhere.
381+
382+
Args:
383+
python_version: Python version (e.g., "3.13")
384+
virtualenv_path: Path to the virtual environment
385+
project_path: Path to the Django project
386+
nuke: If True, delete existing webapp before creating
387+
388+
Returns:
389+
The webapp configuration dict
390+
"""
391+
log_message(f"Creating webapp for {self.domain_name}...")
392+
self.webapp.sanity_checks(nuke=nuke)
393+
result = self.webapp.create(
394+
python_version=python_version,
395+
virtualenv_path=str(virtualenv_path),
396+
project_path=str(project_path),
397+
nuke=nuke,
398+
)
399+
log_message(f"Webapp created: {self.domain_name}")
400+
return result
401+
402+
def reload_webapp(self) -> None:
403+
"""Reload the web app to apply changes."""
404+
log_message(f"Reloading webapp {self.domain_name}...")
405+
self.webapp.reload()
406+
log_message("Webapp reloaded successfully")
407+
408+
def create_or_update_webapp(
409+
self,
410+
python_version: str,
411+
virtualenv_path: str | Path,
412+
project_path: str | Path,
413+
) -> None:
414+
"""Create or update a webapp.
415+
416+
This is a convenience method that:
417+
1. Creates the webapp if it doesn't exist
418+
2. Reloads the webapp
419+
420+
Args:
421+
python_version: Python version (e.g., "3.13")
422+
virtualenv_path: Path to the virtual environment
423+
project_path: Path to the Django project
424+
"""
425+
if not self.webapp_exists():
426+
self.create_webapp(
427+
python_version=python_version,
428+
virtualenv_path=virtualenv_path,
429+
project_path=project_path,
430+
nuke=False,
431+
)
432+
else:
433+
log_message(f"Webapp {self.domain_name} already exists, skipping creation")
434+
435+
# Always reload to apply changes
436+
self.reload_webapp()

dsd_pythonanywhere/platform_deployer.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def _add_requirements(self):
4949
from django_simple_deploy.management.commands.utils import plugin_utils
5050
from django_simple_deploy.management.commands.utils.plugin_utils import dsd_config
5151

52-
from dsd_pythonanywhere.client import APIClient
52+
from dsd_pythonanywhere.client import PythonAnywhereClient
5353

5454
from . import deploy_messages as platform_msgs
5555

@@ -73,6 +73,8 @@ class PlatformDeployer:
7373

7474
def __init__(self):
7575
self.templates_path = Path(__file__).parent / "templates"
76+
self.api_user = os.getenv("API_USER")
77+
self.client = PythonAnywhereClient(username=self.api_user)
7678

7779
# --- Public methods ---
7880

@@ -105,7 +107,7 @@ def _validate_platform(self):
105107
pass
106108

107109
def _get_origin_url(self) -> str:
108-
""""""
110+
"""Get the git remote origin URL."""
109111
origin_url = (
110112
plugin_utils.run_quick_command("git config --get remote.origin.url", check=True)
111113
.stdout.decode()
@@ -123,7 +125,7 @@ def _get_origin_url(self) -> str:
123125
return https_url
124126

125127
def _get_deployed_project_name(self):
126-
return os.getenv("API_USER")
128+
return self.api_user
127129

128130
def _get_repo_name(self) -> str:
129131
"""Get the repository name from the git remote URL.
@@ -142,16 +144,34 @@ def _prep_automate_all(self):
142144
pass
143145

144146
def _clone_and_run_setup_script(self):
145-
client = APIClient(username=os.getenv("API_USER"))
146-
# Proof of concept to run script remotely on Python Anywhere
147+
# Run the setup script to clone repo and install dependencies
147148
cmd = [f"curl -fsSL {REMOTE_SETUP_SCRIPT_URL} | bash -s --"]
148149
origin_url = self._get_origin_url()
149150
repo_name = self._get_repo_name()
150-
cmd.append(f"{origin_url} {repo_name}")
151+
django_project_name = dsd_config.local_project_name
152+
cmd.append(f"{origin_url} {repo_name} {django_project_name}")
151153
cmd = " ".join(cmd)
152154
plugin_utils.write_output(f" Cloning and running setup script: {cmd}")
153-
client.run_command(cmd)
155+
self.client.run_command(cmd)
154156
plugin_utils.write_output("Done cloning and running setup script.")
157+
# Finally, create the webapp
158+
self._create_webapp(client=self.client, repo_name=repo_name)
159+
160+
def _create_webapp(self, client: PythonAnywhereClient, repo_name: str):
161+
"""Create the webapp on PythonAnywhere."""
162+
plugin_utils.write_output(" Creating webapp on PythonAnywhere...")
163+
164+
# Paths on PythonAnywhere (remote home directory)
165+
remote_home = Path(f"/home/{client.username}")
166+
project_path = remote_home / repo_name
167+
virtualenv_path = remote_home / "venv"
168+
169+
client.create_or_update_webapp(
170+
python_version="3.13",
171+
virtualenv_path=virtualenv_path,
172+
project_path=project_path,
173+
)
174+
plugin_utils.write_output("Webapp created and configured.")
155175

156176
def _add_requirements(self):
157177
"""Add requirements for deploying to PythonAnywhere."""
@@ -215,6 +235,7 @@ def _conclude_automate_all(self):
215235

216236
# Should set self.deployed_url, which will be reported in the success message.
217237
self._clone_and_run_setup_script()
238+
218239
self.deployed_url = f"https://{self._get_deployed_project_name()}.pythonanywhere.com"
219240

220241
def _show_success_message(self):

scripts/setup.sh

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
#!/bin/bash
22
set -e
33

4-
if [ -z "$1" ] || [ -z "$2" ]; then
5-
echo "Usage: $0 <git-repo-url> <directory-name> <python-version>"
4+
if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]; then
5+
echo "Usage: $0 <git-repo-url> <directory-name> <django-project-name> [python-version] [wsgi-dest-prefix]"
66
exit 1
77
fi
88

9+
GIT_REPO_URL=$1
10+
REPO_NAME=$2
11+
DJANGO_PROJECT_NAME=$3
12+
PYTHON_VERSION=${4:-python3.13}
13+
WSGI_DEST_PREFIX=${5:-/var/www}
14+
915
# Clone the repository from the provided Git URL
1016

1117
echo "Cloning repository..."
1218

13-
if [ ! -d "$2" ]; then
14-
git clone "$1" "$2"
19+
if [ ! -d "$REPO_NAME" ]; then
20+
git clone "$GIT_REPO_URL" "$REPO_NAME"
1521
else
16-
echo "Directory $2 already exists. Skipping clone."
22+
echo "Directory $REPO_NAME already exists. Skipping clone."
1723
fi
1824

1925
# Create and activate a Python virtual environment, if it doesn't already exist
@@ -22,7 +28,7 @@ echo "Setting up Python virtual environment..."
2228

2329
if [ ! -d "venv" ]; then
2430
echo "Creating virtual environment..."
25-
${3:-python3.13} -m venv venv
31+
$PYTHON_VERSION -m venv venv
2632
fi
2733

2834
echo "Activating virtual environment..."
@@ -31,16 +37,16 @@ source venv/bin/activate
3137
echo "Installing dependencies..."
3238

3339
./venv/bin/pip install --upgrade pip
34-
./venv/bin/pip install -r $2/requirements.txt
40+
./venv/bin/pip install -r $REPO_NAME/requirements.txt
3541

3642
# Create .env file with environment variables
3743

3844
echo "Creating .env file..."
3945

40-
if [ ! -f "$2/.env" ]; then
46+
if [ ! -f "$REPO_NAME/.env" ]; then
4147
# Generate a random Django secret key
4248
DJANGO_SECRET_KEY=$(./venv/bin/python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())")
43-
cat > "$2/.env" << EOF
49+
cat > "$REPO_NAME/.env" << EOF
4450
DEBUG=TRUE
4551
ON_PYTHONANYWHERE=TRUE
4652
SECRET_KEY=$DJANGO_SECRET_KEY
@@ -50,4 +56,15 @@ else
5056
echo ".env file already exists. Skipping creation."
5157
fi
5258

59+
# Copy wsgi.py to PythonAnywhere's wsgi location
60+
USERNAME=$(whoami)
61+
DOMAIN="${USERNAME}.pythonanywhere.com"
62+
WSGI_DEST="${WSGI_DEST_PREFIX}/${DOMAIN//./_}_wsgi.py"
63+
WSGI_SRC="$REPO_NAME/$DJANGO_PROJECT_NAME/wsgi.py"
64+
65+
echo "Copying WSGI file from $WSGI_SRC to $WSGI_DEST..."
66+
mkdir -p "$WSGI_DEST_PREFIX"
67+
cp "$WSGI_SRC" "$WSGI_DEST"
68+
echo "WSGI file copied."
69+
5370
echo "Setup complete!!!"

tests/integration_tests/test_setup_script.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Integration tests for the PythonAnywhere setup.sh script."""
22

3+
import getpass
34
import subprocess
45
import sys
56
from pathlib import Path
@@ -15,13 +16,24 @@ def setup_script_result(tmp_path_factory) -> dict:
1516
tmp_path = tmp_path_factory.mktemp("setup_script")
1617
script_path = Path(__file__).parent.parent.parent / "scripts" / "setup.sh"
1718
dir_name = "test_project"
19+
django_project_name = "mysite"
1820
# Use the current Python version available on CI for testing
1921
python_version = f"python{sys.version_info.major}.{sys.version_info.minor}"
22+
# Use a temp directory for WSGI destination prefix
23+
wsgi_dest_prefix = tmp_path / "var_www"
2024

21-
# Create a minimal git repository with a requirements.txt file
25+
# Create a minimal git repository with a requirements.txt file and Django project
2226
source_repo = tmp_path / "source_repo"
2327
source_repo.mkdir()
2428
(source_repo / "requirements.txt").write_text("django\n")
29+
30+
# Create a minimal Django project structure with wsgi.py
31+
django_project_dir = source_repo / django_project_name
32+
django_project_dir.mkdir()
33+
(django_project_dir / "wsgi.py").write_text(
34+
'import os\nos.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")\n'
35+
)
36+
2537
subprocess.run(["git", "init"], cwd=source_repo, check=True, capture_output=True)
2638
subprocess.run(["git", "add", "."], cwd=source_repo, check=True, capture_output=True)
2739
subprocess.run(
@@ -34,7 +46,15 @@ def setup_script_result(tmp_path_factory) -> dict:
3446
repo_url = source_repo.as_uri()
3547

3648
result = subprocess.run(
37-
["bash", str(script_path), repo_url, dir_name, python_version],
49+
[
50+
"bash",
51+
str(script_path),
52+
repo_url,
53+
dir_name,
54+
django_project_name,
55+
python_version,
56+
str(wsgi_dest_prefix),
57+
],
3858
cwd=tmp_path,
3959
check=True,
4060
capture_output=True,
@@ -46,6 +66,8 @@ def setup_script_result(tmp_path_factory) -> dict:
4666
"tmp_path": tmp_path,
4767
"venv_path": tmp_path / "venv",
4868
"project_path": tmp_path / dir_name,
69+
"django_project_name": django_project_name,
70+
"wsgi_dest_prefix": wsgi_dest_prefix,
4971
}
5072

5173

@@ -82,3 +104,15 @@ def test_setup_script_creates_env_file(setup_script_result):
82104
if line.startswith("SECRET_KEY="):
83105
secret_key = line.split("=", 1)[1]
84106
assert len(secret_key) == 50
107+
108+
109+
def test_setup_script_copies_wsgi_file(setup_script_result):
110+
"""setup.sh copies WSGI file to the destination prefix."""
111+
username = getpass.getuser()
112+
domain = f"{username}.pythonanywhere.com"
113+
wsgi_filename = f"{domain.replace('.', '_')}_wsgi.py"
114+
wsgi_dest = setup_script_result["wsgi_dest_prefix"] / wsgi_filename
115+
116+
assert "WSGI file copied." in setup_script_result["result"].stdout
117+
assert wsgi_dest.exists()
118+
assert "DJANGO_SETTINGS_MODULE" in wsgi_dest.read_text()

0 commit comments

Comments
 (0)