diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 33f17a0..33212f6 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -18,5 +18,7 @@ jobs: cache-dependency-glob: "uv.lock" - name: Install dependencies run: uv sync --locked + - name: Run pre-commit + run: uv run pre-commit run --all-files - name: Run tests run: uv run pytest diff --git a/AGENTS.md b/AGENTS.md index d7cc7df..434d9fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This file provides guidance when working with code in this repository. ## Project Overview -dsd-pythonahwyhere is a plugin for deploying Django projects to PythonAnywhere, using django-simple-deploy. +dsd-pythonanywhere is a plugin for deploying Django projects to PythonAnywhere, using django-simple-deploy. ## Development Commands @@ -19,9 +19,12 @@ dsd-pythonahwyhere is a plugin for deploying Django projects to PythonAnywhere, - Run tests with pytest: `uv run pytest` - Tests are located in the `tests/` directory and follow standard pytest and pytest-mock conventions. +- Integration tests (in `dsd-pythonanywhere/tests/integration_tests`) must be run from the `django-simple-deploy` project root. See `.github/workflows/integration_tests.yaml` for test setup details. +- Use `-k` to filter tests by name pattern, e.g., `uv run pytest -k "test_setup_script"` to run only setup script tests. +- Add or update tests for the code you change, even if nobody asked. +- New features and bug fixes should always include a concise test (not exhaustive). +- Always run full test suite and ruff pre-commit hooks as the last tasks in your todo list ### Code Quality -- Run pre-commit hooks: `uv run pre-commit run --all-files` -- Format Python code with Ruff: `uv run ruff format .` -- Lint and auto-fix Python code: `uv run ruff check --fix .` +- Run ruff pre-commit hooks: `uv run pre-commit run --all-files`. diff --git a/README.md b/README.md index 0c197e4..e53d9fb 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,53 @@ requires a few prerequisites: - Create a PythonAnywhere [Beginner account](https://www.pythonanywhere.com/registration/register/beginner/), which is a limited account with one web app, but requires no credit card. - Generate an [API token](https://help.pythonanywhere.com/pages/GettingYourAPIToken) -- Ideally, stay logged in to PythonAnywhere in your default browser to make the - deployment smoother. +- Stay logged in to PythonAnywhere in your default browser. -With those prerequisites met, you can install the plugin and deploy your app: +With those prerequisites met, and if you're coming from the [Django Girls tutorial Deploy section](https://tutorial.djangogirls.org/en/deploy/), +you can deploy your project with the following steps: + +1. Export your PythonAnywhere API credentials and install `dsd-pythonanywhere`: + +```sh +# Export your PythonAnywhere API credentials as environment variables +export API_USER=[your_pythonanywhere_username] +export API_TOKEN=[your_pythonanywhere_api_token] +# Install dsd-pythonanywhere (which also installs django-simple-deploy) +pip install git+https://github.com/caktus/dsd-pythonanywhere.git@main +``` + +2. Add `django-simple-deploy` to your `INSTALLED_APPS` in `settings.py`: + +```diff +diff --git a/mysite/settings.py b/mysite/settings.py +index 8bf8f39..b288aa1 100644 +--- a/mysite/settings.py ++++ b/mysite/settings.py +@@ -38,6 +38,7 @@ INSTALLED_APPS = [ + "django.contrib.messages", + "django.contrib.staticfiles", + "blog", ++ "django_simple_deploy", + ] +``` + +3. Run the deployment command: ```sh -# TBD +python manage.py deploy --automate-all +``` + +This command can take several minutes as it creates the web app, installs +dependencies, etc. You should see progress in your browser console on +PythonAnywhere as well. + +If you run into issues and need to re-run the deployment, you may need to +reset your local and remote repositories to a clean state first: + +```sh +# Stash any local changes +git stash --include-untracked +# Go back to step 2 since settings.py was reverted ``` ## Approach @@ -61,16 +101,21 @@ console, changes not being committed/pushed to version control, etc. This plugin integrates with `django-simple-deploy` to provide a more familiar local workflow, though with some caveats due to free tier limitations (primarily -lack of SSH access). +lack of SSH access and required browser interaction). ```mermaid sequenceDiagram participant User as Local Machine + participant Browser participant GitHub participant PA as PythonAnywhere User->>GitHub: Commit & push changes + User->>PA: Bash Console API: create console + User->>Browser: Open console URL + Note over Browser,PA: Browser connection starts bash process + User->>PA: Bash Console API: clone repo PA->>GitHub: git clone PA->>PA: Install dependencies & create .env @@ -82,7 +127,15 @@ sequenceDiagram ``` **Note:** Users should stay logged into PythonAnywhere in their default browser -during deployment, as the console API may need to start a new console session. +during deployment. + +Additionally: + +* If a PythonAnywhere bash console isn't already running, the plugin will +programmatically open your browser to the console URL. This is required because +the PythonAnywhere API creates console objects but doesn't start the actual +process. Only connecting to the console in a browser will do that (per the [API +documentation](https://help.pythonanywhere.com/pages/API/#apiv0userusernameconsoles)). ## Plugin Development diff --git a/developer_resources/api-exploration.ipynb b/developer_resources/api-exploration.ipynb index d6c9987..5290114 100644 --- a/developer_resources/api-exploration.ipynb +++ b/developer_resources/api-exploration.ipynb @@ -10,7 +10,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "79210915", "metadata": {}, "outputs": [], @@ -21,7 +21,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "225f3422-2872-4deb-9783-e249957aca30", "metadata": {}, "outputs": [], @@ -35,7 +35,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "af8c7fe0", "metadata": {}, "outputs": [], @@ -44,7 +44,7 @@ "import os\n", "from pathlib import Path\n", "\n", - "from dsd_pythonanywhere.client import APIClient\n", + "from dsd_pythonanywhere.client import PythonAnywhereClient\n", "\n", "logging.basicConfig(\n", " level=logging.DEBUG, force=True, format=\"%(asctime)s - %(levelname)s - %(message)s\"\n", @@ -53,19 +53,10 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "1f0cff23", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Set API_TOKEN\n", - "Set API_USER\n" - ] - } - ], + "outputs": [], "source": [ "# VS Code's Jupyter extension doesn't support loading .envrc, so do it manually here\n", "\n", @@ -82,21 +73,13 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "7adbd24d", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2025-12-29 12:32:51,046 - DEBUG - Converted retries value: 3 -> Retry(total=3, connect=None, read=None, redirect=None, status=None)\n" - ] - } - ], + "outputs": [], "source": [ "username = os.getenv(\"API_USER\")\n", - "client = APIClient(username)" + "client = PythonAnywhereClient(username)" ] }, { @@ -104,49 +87,24 @@ "execution_count": null, "id": "c8fbf886", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2025-09-12 13:46:48,086 - DEBUG - Starting new HTTPS connection (1): www.pythonanywhere.com:443\n", - "2025-09-12 13:46:48,242 - DEBUG - https://www.pythonanywhere.com:443 \"GET /api/v0/user/copelco/consoles/ HTTP/1.1\" 200 2\n", - "2025-09-12 13:46:48,243 - DEBUG - API response: 200 []\n", - "2025-09-12 13:46:48,243 - DEBUG - No active bash console found, starting a new one...\n", - "2025-09-12 13:46:48,311 - DEBUG - https://www.pythonanywhere.com:443 \"POST /api/v0/user/copelco/consoles/ HTTP/1.1\" 201 233\n", - "2025-09-12 13:46:48,311 - DEBUG - API response: 201 {\"id\":42095523,\"user\":\"copelco\",\"executable\":\"bash\",\"arguments\":\"\",\"working_directory\":null,\"name\":\"Bash console 42095523\",\"console_url\":\"/user/copelco/consoles/42095523/\",\"console_frame_url\":\"/user/copelco/consoles/42095523/frame/\"}\n", - "2025-09-12 13:46:48,312 - DEBUG - Found bash console: {'id': 42095523, 'user': 'copelco', 'executable': 'bash', 'arguments': '', 'working_directory': None, 'name': 'Bash console 42095523', 'console_url': '/user/copelco/consoles/42095523/', 'console_frame_url': '/user/copelco/consoles/42095523/frame/'}\n", - "2025-09-12 13:46:48,312 - DEBUG - Attempt 0: checking if console is active\n", - "2025-09-12 13:46:48,380 - DEBUG - https://www.pythonanywhere.com:443 \"POST /api/v0/user/copelco/consoles/42095523/send_input/ HTTP/1.1\" 412 87\n", - "2025-09-12 13:46:48,381 - DEBUG - API error status_code=412 error_data={'error': 'Console not yet started. Please load it (or its iframe) in a browser first'}\n", - "2025-09-12 13:46:48,381 - DEBUG - API response: 412 {\"error\":\"Console not yet started. Please load it (or its iframe) in a browser first\"}\n", - "2025-09-12 13:46:48,382 - DEBUG - Console not yet started, opening browser...\n", - "2025-09-12 13:46:48,523 - DEBUG - Console not yet started, waiting...\n", - "2025-09-12 13:46:49,530 - DEBUG - Attempt 1: checking if console is active\n", - "2025-09-12 13:46:49,625 - DEBUG - https://www.pythonanywhere.com:443 \"POST /api/v0/user/copelco/consoles/42095523/send_input/ HTTP/1.1\" 200 15\n", - "2025-09-12 13:46:49,627 - DEBUG - API response: 200 {\"status\":\"OK\"}\n", - "2025-09-12 13:46:49,627 - DEBUG - Console is active.\n", - "2025-09-12 13:46:49,707 - DEBUG - https://www.pythonanywhere.com:443 \"POST /api/v0/user/copelco/consoles/42095523/send_input/ HTTP/1.1\" 200 15\n", - "2025-09-12 13:46:49,708 - DEBUG - API response: 200 {\"status\":\"OK\"}\n", - "2025-09-12 13:46:49,777 - DEBUG - https://www.pythonanywhere.com:443 \"GET /api/v0/user/copelco/consoles/42095523/get_latest_output/ HTTP/1.1\" 200 51\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "Preparing execution environment...\n" - ] - } - ], + "outputs": [], "source": [ - "print(client.run_command(\"ls -la\"))" + "client.request(method=\"GET\", url=client._base_url(\"cpu\"))" ] }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, + "id": "83e6f6f1", + "metadata": {}, + "outputs": [], + "source": [ + "client.webapp_exists()" + ] + }, + { + "cell_type": "code", + "execution_count": null, "id": "5e5f7022", "metadata": {}, "outputs": [], @@ -156,81 +114,45 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "id": "180bcefc", "metadata": {}, "outputs": [], "source": [ - "webapp = Webapp(\"copelco.pythonanywhere.com\")" + "webapp = Webapp.list_webapps()" ] }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "id": "1fe24344", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2025-12-29 13:12:08,848 - DEBUG - Starting new HTTPS connection (1): www.pythonanywhere.com:443\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2025-12-29 13:12:09,476 - DEBUG - https://www.pythonanywhere.com:443 \"GET /api/v0/user/copelco/webapps/copelco.pythonanywhere.com/ HTTP/1.1\" 403 63\n" - ] - } - ], + "outputs": [], "source": [ "webapp.sanity_checks(nuke=False)" ] }, { "cell_type": "code", - "execution_count": 18, + "execution_count": null, "id": "a27334f9", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2025-12-29 13:12:11,430 - DEBUG - Starting new HTTPS connection (1): www.pythonanywhere.com:443\n", - "2025-12-29 13:12:20,857 - DEBUG - https://www.pythonanywhere.com:443 \"POST /api/v0/user/copelco/webapps/ HTTP/1.1\" 200 94\n", - "2025-12-29 13:12:20,863 - DEBUG - Starting new HTTPS connection (1): www.pythonanywhere.com:443\n", - "2025-12-29 13:12:21,097 - DEBUG - https://www.pythonanywhere.com:443 \"PATCH /api/v0/user/copelco/webapps/copelco.pythonanywhere.com/ HTTP/1.1\" 200 None\n" - ] - } - ], + "outputs": [], "source": [ - "webapp.create(python_version=\"3.13\", virtualenv_path=\"/home/copelco/venv\", project_path=\"/home/copelco/dsd-testproj\", nuke=False)" + "webapp.create(\n", + " python_version=\"3.13\",\n", + " virtualenv_path=\"/home/copelco/venv\",\n", + " project_path=\"/home/copelco/dsd-testproj\",\n", + " nuke=False,\n", + ")" ] }, { "cell_type": "code", - "execution_count": 25, + "execution_count": null, "id": "64c54a59", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2025-12-29 13:38:21,458 - DEBUG - Starting new HTTPS connection (1): www.pythonanywhere.com:443\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2025-12-29 13:38:32,034 - DEBUG - https://www.pythonanywhere.com:443 \"POST /api/v0/user/copelco/webapps/copelco.pythonanywhere.com/reload/ HTTP/1.1\" 200 15\n" - ] - } - ], + "outputs": [], "source": [ "webapp.reload()" ] diff --git a/dsd_pythonanywhere/client.py b/dsd_pythonanywhere/client.py index d7b702d..76874c8 100644 --- a/dsd_pythonanywhere/client.py +++ b/dsd_pythonanywhere/client.py @@ -9,13 +9,12 @@ 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__) -def log_message(message: str, level: int = logging.DEBUG, **kwargs) -> None: +def log_message(message: str, level: int = logging.INFO, **kwargs) -> None: """Helper function to log messages to both logger and plugin_utils output. Args: @@ -24,8 +23,8 @@ def log_message(message: str, level: int = logging.DEBUG, **kwargs) -> None: **kwargs: Additional keyword arguments for the logger """ logger.log(level, message, **kwargs) - if plugin_utils.dsd_config.stdout is not None: - plugin_utils.write_output(message) + if plugin_utils.dsd_config.stdout is not None and level >= logging.INFO: + plugin_utils.write_output(f" {message}") @dataclass @@ -44,13 +43,14 @@ class CommandRun: # Regex pattern to match empty prompts (command finished): "HH:MM ~ $ " (with optional whitespace) EMPTY_PROMPT_PATTERN = re.compile(r"\d{2}:\d{2} ~[^$]*\$\s*$") # Regex pattern to match ANSI escape codes for cleaning - ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-9;]*m") + # Matches SGR sequences (\x1b[...m), bracketed paste mode (\x1b[?2004h/l), and other CSI sequences + ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]") def __init__(self, raw_output: str): self.raw_output = raw_output self.lines = raw_output.splitlines() - def find_most_recent_prompt_line(self, expected_command: str = None) -> int | None: + def find_most_recent_prompt_line(self, expected_command: str | None = None) -> int | None: """Find the most recent prompt line in console output. Walks backwards through lines to find the most recent line containing a @@ -172,17 +172,27 @@ def wait_for_command_completion( """ for attempt in range(max_retries): log_message( - f"Polling attempt {attempt + 1}: waiting for command '{expected_command}' to complete" + f" Polling attempt {attempt + 1}: waiting for command '{expected_command}' to complete" ) try: command_run = self.get_latest_output() - if command_run and command_run.is_command_finished(): - # Command finished, extract output - command_output = command_run.extract_command_output(expected_command) - if command_output is not None: - log_message(f"Command '{expected_command}' completed") - return CommandResult(expected_command, command_output) + if command_run: + # First check if our command appears in the output (was echoed back) + command_visible = command_run.find_most_recent_prompt_line( + expected_command=expected_command + ) + if command_visible is None: + log_message("Command not yet visible in output, waiting...") + time.sleep(6) + continue + + # Command is visible, now check if it finished (empty prompt after) + if command_run.is_command_finished(): + command_output = command_run.extract_command_output(expected_command) + if command_output is not None: + log_message(f"Command '{expected_command}' completed") + return CommandResult(expected_command, command_output) except Exception as e: log_message(f"Error polling console output: {e}") @@ -207,7 +217,7 @@ def wait_for_ready(self) -> None: test_command = "echo hello" for attempt in range(max_retries): - log_message(f"Attempt {attempt}: checking if console is ready") + log_message(f" Attempt {attempt}: checking if console is ready") # Send the test command input (with newline before to clear any partial input) response = self.send_input(f"\n{test_command}\n") @@ -216,7 +226,7 @@ def wait_for_ready(self) -> None: if response.status_code == 412: # Console not started, open in browser if we haven't already if not browser_opened: - log_message("Console not started, opening browser...") + log_message(" Console not started, opening browser...") webbrowser.open(self.browser_url) browser_opened = True # Wait for browser opening to trigger startup @@ -230,7 +240,7 @@ def wait_for_ready(self) -> None: try: result = self.wait_for_command_completion(test_command, max_retries=5) if result.output.strip() == "hello": - log_message("Console is ready") + log_message(" Console is ready") return except RuntimeError: # Command didn't complete, continue trying @@ -266,10 +276,23 @@ def __init__(self, username: str): self.session = requests.Session() self.session.headers.update({"Authorization": f"Token {self.token}"}) self.session.mount("https://", HTTPAdapter(max_retries=3)) - # Initialize webapp for this user's domain self.domain_name = f"{username}.{self._pythonanywhere_domain}" - self.webapp = Webapp(self.domain_name) + + # CRITICAL: Set LOGNAME before importing Webapp, which has class + # variables (username, files_url, webapps_url) that are computed at + # import time using getpass.getuser(), which reads LOGNAME. If we import + # Webapp at the module level, those class variables will be set with the + # wrong username. By setting LOGNAME first, then doing a lazy import + # here, we ensure Webapp uses the correct username for all API calls. + if self.username and self.username != os.getenv("LOGNAME"): + os.environ["LOGNAME"] = self.username + + # Lazy import after LOGNAME is set so class variables use correct username + from pythonanywhere_core.webapp import Webapp + + self.webapp = Webapp(domain=self.domain_name) + self.webapp.username = self.username @property def _pythonanywhere_domain(self) -> str: @@ -322,7 +345,7 @@ def request(self, method: str, url: str, **kwargs): log_message(f"API error {status_code=} {error_data=}", extra={"response": e.response}) if raise_for_status: raise - log_message(f"API response: {response.status_code} {response.text}") + log_message(f"API response: {response.status_code} {response.text}", level=logging.DEBUG) return response # --- Console management methods --- @@ -373,10 +396,10 @@ def webapp_exists(self) -> bool: def create_webapp( self, python_version: str, - virtualenv_path: str | Path, - project_path: str | Path, + virtualenv_path: Path, + project_path: Path, nuke: bool = False, - ) -> dict: + ) -> None: """Create a new web app on PythonAnywhere. Args: @@ -384,38 +407,19 @@ def create_webapp( 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( + self.webapp.create( python_version=python_version, - virtualenv_path=str(virtualenv_path), - project_path=str(project_path), + virtualenv_path=virtualenv_path, + project_path=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, + def create_webapp_if_not_exists( + self, python_version: str, virtualenv_path: Path, project_path: 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 + """Create a webapp if it doesn't exist. Args: python_version: Python version (e.g., "3.13") @@ -429,8 +433,14 @@ def create_or_update_webapp( project_path=project_path, nuke=False, ) + log_message("Configuring static file mappings...") + self.webapp.add_default_static_files_mappings(project_path=project_path) + log_message("Static file mappings configured") else: log_message(f"Webapp {self.domain_name} already exists, skipping creation") - # Always reload to apply changes - self.reload_webapp() + 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") diff --git a/dsd_pythonanywhere/deploy_messages.py b/dsd_pythonanywhere/deploy_messages.py index 8b6fafc..9216b4d 100644 --- a/dsd_pythonanywhere/deploy_messages.py +++ b/dsd_pythonanywhere/deploy_messages.py @@ -88,7 +88,14 @@ def success_msg_automate_all(deployed_url): - You can also visit your project at {deployed_url} If you make further changes and want to push them to PythonAnywhere, - commit your changes and then run `...`. + you need to: + - Commit your changes + - Push to your remote repository + - On PythonAnywhere, open a bash console and: + - Pull the latest changes + - Run migrations if necessary + - Collect static files if necessary + - Reload your webapp """ ) return msg diff --git a/dsd_pythonanywhere/platform_deployer.py b/dsd_pythonanywhere/platform_deployer.py index 9867da5..f694e8b 100644 --- a/dsd_pythonanywhere/platform_deployer.py +++ b/dsd_pythonanywhere/platform_deployer.py @@ -1,52 +1,11 @@ -"""Manages all PythonAnywhere-specific aspects of the deployment process. - -Notes: -- - -Add a new file to the user's project, without using a template: - - def _add_dockerignore(self): - # Add a dockerignore file, based on user's local project environmnet. - path = dsd_config.project_root / ".dockerignore" - dockerignore_str = self._build_dockerignore() - plugin_utils.add_file(path, dockerignore_str) - -Add a new file to the user's project, using a template: - - def _add_dockerfile(self): - # Add a minimal dockerfile. - template_path = self.templates_path / "dockerfile_example" - context = { - "django_project_name": dsd_config.local_project_name, - } - contents = plugin_utils.get_template_string(template_path, context) - - # Write file to project. - path = dsd_config.project_root / "Dockerfile" - plugin_utils.add_file(path, contents) - -Modify user's settings file: - - def _modify_settings(self): - # Add platformsh-specific settings. - template_path = self.templates_path / "settings.py" - context = { - "deployed_project_name": self._get_deployed_project_name(), - } - plugin_utils.modify_settings_file(template_path, context) - -Add a set of requirements: - - def _add_requirements(self): - # Add requirements for deploying to Fly.io. - requirements = ["gunicorn", "psycopg2-binary", "dj-database-url", "whitenoise"] - plugin_utils.add_packages(requirements) -""" +"""Manages all PythonAnywhere-specific aspects of the deployment process.""" import os +import webbrowser from pathlib import Path from django_simple_deploy.management.commands.utils import plugin_utils +from django_simple_deploy.management.commands.utils.command_errors import DSDCommandError from django_simple_deploy.management.commands.utils.plugin_utils import dsd_config from dsd_pythonanywhere.client import PythonAnywhereClient @@ -73,7 +32,7 @@ class PlatformDeployer: def __init__(self): self.templates_path = Path(__file__).parent / "templates" - self.api_user = os.getenv("API_USER") + self.api_user = os.getenv("API_USER", "") self.client = PythonAnywhereClient(username=self.api_user) # --- Public methods --- @@ -83,7 +42,9 @@ def deploy(self, *args, **options): plugin_utils.write_output("\nConfiguring project for deployment to PythonAnywhere...") self._validate_platform() - self._prep_automate_all() + + if dsd_config.automate_all: + self._prep_automate_all() # Configure project for deployment to PythonAnywhere self._add_requirements() @@ -96,15 +57,40 @@ def deploy(self, *args, **options): # --- Helper methods for deploy() --- - def _validate_platform(self): + def _get_deployed_project_name(self) -> str: + return self.api_user + + def _validate_platform(self) -> None: """Make sure the local environment and project supports deployment to PythonAnywhere. - Returns: - None Raises: DSDCommandError: If we find any reason deployment won't work. """ - pass + # Only validate API credentials when actually deploying + if not dsd_config.automate_all: + return + + # Check for required environment variables + if not os.getenv("API_USER"): + raise DSDCommandError( + "API_USER environment variable is not set. " + "Please set it to your PythonAnywhere username." + ) + + if not os.getenv("API_TOKEN"): + raise DSDCommandError( + "API_TOKEN environment variable is not set. " + "Please set it to your PythonAnywhere API token." + ) + + # Test API connection + try: + self.client.request(method="GET", url=self.client._base_url("cpu")) + except Exception as e: + raise DSDCommandError( + f"Failed to connect to PythonAnywhere API: {e}. " + "Please verify your API_USER and API_TOKEN are correct." + ) def _get_origin_url(self) -> str: """Get the git remote origin URL.""" @@ -124,9 +110,6 @@ def _get_origin_url(self) -> str: return https_url - def _get_deployed_project_name(self): - return self.api_user - def _get_repo_name(self) -> str: """Get the repository name from the git remote URL. @@ -140,21 +123,28 @@ def _get_repo_name(self) -> str: return dsd_config.project_root.name def _prep_automate_all(self): - """Take any further actions needed if using automate_all.""" - pass + """Configure paths and repo info for automate_all deployment. + + Caveats: Git commands will fail in test environments without a remote + configured. + """ + self.repo_origin_url = self._get_origin_url() + self.repo_name = self._get_repo_name() + self.pa_home = Path(f"/home/{self.client.username}") + self.pa_project_root_path = self.pa_home / self.repo_name - def _clone_and_run_setup_script(self, repo_name: str): + def _clone_and_run_setup_script(self): # 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() django_project_name = dsd_config.local_project_name - cmd.append(f"{origin_url} {repo_name} {django_project_name}") + cmd.append(f"{origin_url} {self.repo_name} {django_project_name}") cmd = " ".join(cmd) plugin_utils.write_output(f" Cloning and running setup script: {cmd}") self.client.run_command(cmd) plugin_utils.write_output("Done cloning and running setup script.") - def _copy_wsgi_file(self, repo_name: str): + def _copy_wsgi_file(self): """Copy wsgi.py to PythonAnywhere's wsgi location. This must be done after webapp creation, as creating a webapp @@ -165,23 +155,19 @@ def _copy_wsgi_file(self, repo_name: str): 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" + wsgi_src = f"{self.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): + def _create_webapp(self): """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( + self.client.create_webapp_if_not_exists( python_version="3.13", - virtualenv_path=virtualenv_path, - project_path=project_path, + virtualenv_path=self.pa_home / "venv", + project_path=self.pa_project_root_path, ) plugin_utils.write_output("Webapp created and configured.") @@ -232,6 +218,10 @@ def _conclude_automate_all(self): - Commit all changes. - Push to remote repo. - Run setup script on PythonAnywhere. + - Create webapp. + - Copy wsgi file. + - Configure static files. + - Reload webapp. """ # Making this check here lets deploy() be cleaner. if not dsd_config.automate_all: @@ -239,15 +229,15 @@ def _conclude_automate_all(self): plugin_utils.commit_changes() # Push to remote (GitHub, etc). - plugin_utils.write_output(" Pushing changes to remote repository...") + 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...") - 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) + # Deploy project. + plugin_utils.write_output("Deploying to PythonAnywhere...") + self._clone_and_run_setup_script() + self._create_webapp() + self._copy_wsgi_file() + self.client.reload_webapp() self.deployed_url = f"https://{self._get_deployed_project_name()}.pythonanywhere.com" def _show_success_message(self): @@ -257,6 +247,7 @@ def _show_success_message(self): """ if dsd_config.automate_all: msg = platform_msgs.success_msg_automate_all(self.deployed_url) + webbrowser.open(self.deployed_url) else: msg = platform_msgs.success_msg(log_output=dsd_config.log_output) plugin_utils.write_output(msg) diff --git a/scripts/setup.sh b/scripts/setup.sh index 1f1302c..4fb21b6 100644 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -49,10 +49,17 @@ if [ ! -f "$REPO_NAME/.env" ]; then DEBUG=TRUE ON_PYTHONANYWHERE=TRUE SECRET_KEY=$DJANGO_SECRET_KEY +DATABASE_URL=sqlite:///$HOME/$REPO_NAME/db.sqlite3 EOF echo ".env file created." else echo ".env file already exists. Skipping creation." fi +# Run migrations and collectstatic +echo "Running migrations and collectstatic..." +cd "$REPO_NAME" +../venv/bin/python manage.py migrate +../venv/bin/python manage.py collectstatic --noinput + echo "Setup complete!!!" diff --git a/tests/integration_tests/test_pythonanywhere_config.py b/tests/integration_tests/test_pythonanywhere_config.py index e53d448..71092a4 100644 --- a/tests/integration_tests/test_pythonanywhere_config.py +++ b/tests/integration_tests/test_pythonanywhere_config.py @@ -3,7 +3,6 @@ from pathlib import Path import pytest - from tests.integration_tests.utils import it_helper_functions as hf # --- Fixtures --- @@ -49,9 +48,7 @@ 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": - pytest.skip( - "Skipping unsupported dsd-pythonanywhere pyproject.toml test for poetry" - ) + pytest.skip("Skipping unsupported dsd-pythonanywhere pyproject.toml test for poetry") # context = {"current-version": dsd_version} # hf.check_reference_file( # tmp_project, @@ -133,9 +130,7 @@ 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 index a03f888..585ddea 100644 --- a/tests/integration_tests/test_setup_script.py +++ b/tests/integration_tests/test_setup_script.py @@ -22,12 +22,34 @@ def setup_script_result(tmp_path_factory) -> dict: source_repo.mkdir() (source_repo / "requirements.txt").write_text("django\n") - # Create a minimal Django project structure with wsgi.py + # Create a minimal Django project structure with wsgi.py and settings.py django_project_dir = source_repo / django_project_name django_project_dir.mkdir() + (django_project_dir / "__init__.py").write_text("") (django_project_dir / "wsgi.py").write_text( 'import os\nos.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")\n' ) + (django_project_dir / "settings.py").write_text( + """ +SECRET_KEY = 'test-secret-key' +DEBUG = True +INSTALLED_APPS = ['django.contrib.contenttypes', 'django.contrib.staticfiles'] +DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': 'db.sqlite3'}} +STATIC_URL = '/static/' +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +""" + ) + # Create minimal manage.py + (source_repo / "manage.py").write_text( + """#!/usr/bin/env python +import os +import sys +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings") + from django.core.management import execute_from_command_line + execute_from_command_line(sys.argv) +""" + ) subprocess.run(["git", "init"], cwd=source_repo, check=True, capture_output=True) subprocess.run(["git", "add", "."], cwd=source_repo, check=True, capture_output=True) @@ -38,20 +60,26 @@ def setup_script_result(tmp_path_factory) -> dict: 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, - ) + try: + 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, + ) + except subprocess.CalledProcessError as e: + print("Setup script failed with the following output:") + print(e.stdout) + print(e.stderr) + raise return { "result": result, @@ -92,3 +120,11 @@ def test_setup_script_creates_env_file(setup_script_result): if line.startswith("SECRET_KEY="): secret_key = line.split("=", 1)[1] assert len(secret_key) == 50 + + +def test_setup_script_runs_migrate(setup_script_result): + """setup.sh runs Django migrations.""" + stdout = setup_script_result["result"].stdout + assert "Running migrations and collectstatic..." in stdout + # Check that migrations ran (either applied or no migrations to apply) + assert "Operations to perform:" in stdout or "No migrations to apply" in stdout diff --git a/tests/unit_tests/test_client_api_client.py b/tests/unit_tests/test_client_api_client.py index 690a123..17190a1 100644 --- a/tests/unit_tests/test_client_api_client.py +++ b/tests/unit_tests/test_client_api_client.py @@ -1,3 +1,5 @@ +import os + import pytest import requests @@ -22,6 +24,32 @@ def test_api_client_init(mocker): assert client.session.headers["Authorization"] == "Token my_secret_token" +def test_logname_set_when_different(mocker): + """__init__ sets LOGNAME environment variable when different from username.""" + mocker.patch.dict("os.environ", {"API_TOKEN": "test_token", "LOGNAME": "original_user"}) + client = PythonAnywhereClient(username="myuser") + + assert client.username == "myuser" + assert os.getenv("LOGNAME") == "myuser" + + +def test_logname_set_before_webapp_import(mocker): + """LOGNAME is set before Webapp import so class variables use correct username.""" + # Mock getpass.getuser to return the current LOGNAME value + # This simulates what pythonanywhere_core.webapp.Webapp does at import time + mocker.patch.dict("os.environ", {"API_TOKEN": "test_token", "LOGNAME": "original_user"}) + mocker.patch( + "pythonanywhere_core.webapp.getpass.getuser", + side_effect=lambda: os.getenv("LOGNAME", "original_user"), + ) + + # Create client with different username - this should update LOGNAME before import + client = PythonAnywhereClient(username="deployment_user") + assert os.getenv("LOGNAME") == "deployment_user" + assert client.webapp.username == "deployment_user" + assert client.webapp.domain == "deployment_user.pythonanywhere.com" + + def test_hostname_default(api_client, mocker): """_hostname returns default PythonAnywhere domain.""" mocker.patch.dict("os.environ", {}, clear=True) @@ -78,3 +106,36 @@ def test_request_handles_errors(api_client, mocker): with pytest.raises(requests.exceptions.HTTPError): api_client.request(method="GET", url="https://example.com/api/test") + + +def test_create_webapp_if_not_exists_creates_new(api_client, mocker): + """create_webapp_if_not_exists creates webapp and configures static files when it doesn't exist.""" + mocker.patch.object(api_client, "webapp_exists", return_value=False) + mock_create = mocker.patch.object(api_client, "create_webapp") + mock_add_mappings = mocker.patch.object(api_client.webapp, "add_default_static_files_mappings") + project_path = "/home/testuser/project" + + api_client.create_webapp_if_not_exists( + python_version="3.13", + virtualenv_path="/home/testuser/venv", + project_path=project_path, + ) + + mock_create.assert_called_once() + mock_add_mappings.assert_called_once() + + +def test_create_webapp_if_not_exists_skips_existing(api_client, mocker): + """create_webapp_if_not_exists skips creation when webapp exists.""" + mocker.patch.object(api_client, "webapp_exists", return_value=True) + mock_create = mocker.patch.object(api_client, "create_webapp") + mock_add_mappings = mocker.patch.object(api_client.webapp, "add_default_static_files_mappings") + + api_client.create_webapp_if_not_exists( + python_version="3.13", + virtualenv_path="/home/testuser/venv", + project_path="/home/testuser/project", + ) + + mock_create.assert_not_called() + mock_add_mappings.assert_not_called() diff --git a/tests/unit_tests/test_client_command_run.py b/tests/unit_tests/test_client_command_run.py index f7ec808..258ff9d 100644 --- a/tests/unit_tests/test_client_command_run.py +++ b/tests/unit_tests/test_client_command_run.py @@ -91,3 +91,15 @@ def test_is_command_finished_returns_false_when_no_prompt(): """is_command_finished returns False when no prompt found.""" cmd_run = CommandRun("some output with no prompt") assert cmd_run.is_command_finished() is False + + +def test_is_command_finished_handles_bracketed_paste_mode(): + """is_command_finished correctly strips bracketed paste mode escape sequences.""" + # Real output from PythonAnywhere with bracketed paste mode (\x1b[?2004h) + output_with_bracketed_paste = ( + "Successfully installed Django-5.1.15\r\n" + "Setup complete!!!\r\n" + "\x1b[?2004h\x1b[0;0m16:08 ~\x1b[0;33m \x1b[1;32m$ \x1b[0;0m" + ) + cmd_run = CommandRun(output_with_bracketed_paste) + assert cmd_run.is_command_finished() is True diff --git a/tests/unit_tests/test_client_log_message.py b/tests/unit_tests/test_client_log_message.py index cda4991..3aba133 100644 --- a/tests/unit_tests/test_client_log_message.py +++ b/tests/unit_tests/test_client_log_message.py @@ -15,4 +15,4 @@ def test_writes_to_plugin_utils_when_stdout_exists(mocker): mock_plugin_utils = mocker.patch("dsd_pythonanywhere.client.plugin_utils") mock_plugin_utils.dsd_config.stdout = mocker.MagicMock() log_message("Test output") - mock_plugin_utils.write_output.assert_called_once_with("Test output") + mock_plugin_utils.write_output.assert_called_once_with(" Test output") diff --git a/tests/unit_tests/test_platform_deployer.py b/tests/unit_tests/test_platform_deployer.py index 907e64c..bf9c175 100644 --- a/tests/unit_tests/test_platform_deployer.py +++ b/tests/unit_tests/test_platform_deployer.py @@ -1,15 +1,18 @@ -from pathlib import Path import sys +from pathlib import Path + +import pytest +from django_simple_deploy.management.commands.utils.command_errors import DSDCommandError from dsd_pythonanywhere.platform_deployer import ( + PLUGIN_REQUIREMENTS, PlatformDeployer, dsd_config, - PLUGIN_REQUIREMENTS, ) def test_modify_gitignore(tmp_path: Path, monkeypatch): - """Test that _modify_gitignore adds patterns correctly.""" + """_modify_gitignore adds patterns correctly.""" deployer = PlatformDeployer() monkeypatch.setattr(dsd_config, "git_path", tmp_path) monkeypatch.setattr(dsd_config, "stdout", sys.stdout) @@ -36,7 +39,7 @@ def test_modify_gitignore(tmp_path: Path, monkeypatch): def test_modify_settings(tmp_path: Path, monkeypatch): - """Look for one of expected modified lines in settings.py.""" + """_modify_settings modifies settings.py as expected.""" settings_path = tmp_path / "settings.py" settings_content = "# Existing settings" settings_path.write_text(settings_content) @@ -51,7 +54,7 @@ def test_modify_settings(tmp_path: Path, monkeypatch): def test_add_requirements(tmp_path: Path, monkeypatch): - """Test that _add_requirements adds required packages.""" + """_add_requirements adds required packages.""" requirements_path = tmp_path / "requirements.txt" requirements_content = "Django" requirements_path.write_text(requirements_content) @@ -65,3 +68,66 @@ def test_add_requirements(tmp_path: Path, monkeypatch): modified_content = requirements_path.read_text() for package in PLUGIN_REQUIREMENTS: assert package in modified_content + + +def test_validate_platform_missing_api_user(monkeypatch): + """_validate_platform raises error when API_USER is missing.""" + monkeypatch.delenv("API_USER", raising=False) + monkeypatch.setenv("API_TOKEN", "test_token") + monkeypatch.setattr(dsd_config, "automate_all", True) + + with pytest.raises(DSDCommandError, match="API_USER environment variable is not set"): + deployer = PlatformDeployer() + deployer._validate_platform() + + +def test_validate_platform_missing_api_token(monkeypatch): + """_validate_platform raises error when API_TOKEN is missing.""" + monkeypatch.setenv("API_USER", "test_user") + monkeypatch.delenv("API_TOKEN", raising=False) + monkeypatch.setattr(dsd_config, "automate_all", True) + + deployer = PlatformDeployer() + with pytest.raises(DSDCommandError, match="API_TOKEN environment variable is not set"): + deployer._validate_platform() + + +def test_validate_platform_api_connection_fails(monkeypatch, mocker): + """_validate_platform raises error when API connection fails.""" + monkeypatch.setenv("API_USER", "test_user") + monkeypatch.setenv("API_TOKEN", "test_token") + monkeypatch.setattr(dsd_config, "automate_all", True) + + deployer = PlatformDeployer() + mock_request = mocker.patch.object(deployer.client, "request") + mock_request.side_effect = Exception("Connection failed") + + with pytest.raises(DSDCommandError, match="Failed to connect to PythonAnywhere API"): + deployer._validate_platform() + + +def test_validate_platform_success(monkeypatch, mocker): + """_validate_platform succeeds with valid credentials.""" + monkeypatch.setenv("API_USER", "test_user") + monkeypatch.setenv("API_TOKEN", "test_token") + monkeypatch.setattr(dsd_config, "automate_all", True) + + deployer = PlatformDeployer() + mock_request = mocker.patch.object(deployer.client, "request") + mock_response = mocker.Mock() + mock_response.ok = True + mock_request.return_value = mock_response + + # Should not raise any exception + deployer._validate_platform() + + +def test_validate_platform_skipped_without_automate_all(monkeypatch): + """_validate_platform is skipped when automate_all is False.""" + monkeypatch.delenv("API_USER", raising=False) + monkeypatch.delenv("API_TOKEN", raising=False) + monkeypatch.setattr(dsd_config, "automate_all", False) + + deployer = PlatformDeployer() + # Should not raise any exception even without credentials + deployer._validate_platform()