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" },