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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions dsd_pythonanywhere/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,21 +263,51 @@ def wait_for_ready(self) -> None:

raise RuntimeError("Console did not become ready after waiting.")

# Marker appended to every command so we can recover its exit status.
# Console output otherwise gives no reliable signal of success/failure.
EXIT_STATUS_MARKER = "DSD_EXIT_STATUS"
EXIT_STATUS_PATTERN = re.compile(rf"{EXIT_STATUS_MARKER}:(-?\d+)\s*$")

def run_command(self, command: str) -> str:
"""Run a command and return its output.

Appends a marker that echoes the command's exit status, since the
console API otherwise gives no way to tell whether a command actually
succeeded.

Args:
command: The command string to run in the console

Returns:
The command output as a string, or empty string if command failed
The command output as a string, with the exit-status marker
stripped out.

Raises:
RuntimeError: if the command could not be sent, or exited with a
non-zero status.
"""
response = self.send_input(f"{command}\n")
full_command = f'{command}; echo "{self.EXIT_STATUS_MARKER}:$?"'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool!

15:06 ~ $ curl -fsSL https://raw.githubusercontent.com/caktus/dsd-pythonanywhere/refs/heads/main/scripts/setup.sh | bash -s -- https://github.com/copelco/dsd-testproj.git dsd-testproj blo
g; echo "DSD_EXIT_STATUS:$?"
Cloning repository...
Cloning into 'dsd-testproj'...
--snip--
127 static files copied to '/home/copelcobeginner/dsd-testproj/static'.
Setup complete!!!
DSD_EXIT_STATUS:0
15:09 ~ $

response = self.send_input(f"{full_command}\n")
if not response.ok:
return ""
raise RuntimeError(f"Failed to send command to console: {command}")

result = self.wait_for_command_completion(full_command)
output = result.output

match = self.EXIT_STATUS_PATTERN.search(output)
if match is None:
raise RuntimeError(
f"Could not determine exit status for command: {command}\nOutput:\n{output}"
)

output = self.EXIT_STATUS_PATTERN.sub("", output).strip()
exit_status = int(match.group(1))
if exit_status != 0:
raise RuntimeError(
f"Command exited with status {exit_status}: {command}\nOutput:\n{output}"
)

result = self.wait_for_command_completion(command)
return result.output
return output


class PythonAnywhereClient:
Expand Down
26 changes: 22 additions & 4 deletions dsd_pythonanywhere/platform_deployer.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,30 @@ def _copy_wsgi_file(self):
plugin_utils.write_output(" Copying wsgi.py to PythonAnywhere...")

django_project_name = dsd_config.local_project_name
domain = f"{self.client.username}.pythonanywhere.com"
# PythonAnywhere only looks for the lowercased filename here, regardless
# of the casing of the account's actual username.
domain = f"{self.client.username.lower()}.pythonanywhere.com"
wsgi_dest = f"/var/www/{domain.replace('.', '_')}_wsgi.py"
wsgi_src = f"{self.repo_name}/{django_project_name}/wsgi.py"
# Use an absolute source path: consoles can be reused across API calls
# (get_active_console() may pick up any existing bash console), so a
# `cd` left over from an earlier step can't cause this to copy from
# the wrong place.
wsgi_src = str(self.pa_project_root_path / django_project_name / "wsgi.py")

self.client.run_command(f"cp {wsgi_src} {wsgi_dest}")

# cp can "succeed" while copying the wrong file (e.g. an unexpected
# cwd), so confirm the destination actually matches the source before
# declaring the deploy successful.
verify_cmd = f"cmp -s {wsgi_src} {wsgi_dest} && echo COPY_VERIFIED || echo COPY_FAILED"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice-- worked for me!

15:32 ~ $ cmp -s /home/copelcobeginner/dsd-testproj/blog/wsgi.py /var/www/copelcobeginner_pythonanywhere_com_wsgi.py && echo COPY_VERIFIED || echo COPY_FAILED; echo "DSD_EXIT_STATUS:$?"
COPY_VERIFIED
DSD_EXIT_STATUS:0

verify_output = self.client.run_command(verify_cmd)
if "COPY_VERIFIED" not in verify_output:
raise DSDCommandError(
f"Failed to verify that {wsgi_src} was copied to {wsgi_dest} on "
"PythonAnywhere. The deployed app may still be serving PythonAnywhere's "
"placeholder wsgi app."
)

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):
Expand Down
29 changes: 29 additions & 0 deletions tests/unit_tests/test_client_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,35 @@ def test_wait_for_command_completion_debug_polling(console, mocker):
assert "raw console output" in str(debug_calls[0])


def test_run_command_returns_output_on_success(console, mocker):
"""run_command strips the exit-status marker and returns the real output."""
mocker.patch.object(console, "send_input", return_value=mocker.Mock(ok=True))
mock_result = CommandResult(command="ls", output="foo\nbar\nDSD_EXIT_STATUS:0")
mocker.patch.object(console, "wait_for_command_completion", return_value=mock_result)

output = console.run_command("ls")

assert output == "foo\nbar"


def test_run_command_raises_when_send_fails(console, mocker):
"""run_command raises rather than silently returning an empty string."""
mocker.patch.object(console, "send_input", return_value=mocker.Mock(ok=False))

with pytest.raises(RuntimeError, match="Failed to send command"):
console.run_command("ls")


def test_run_command_raises_on_nonzero_exit_status(console, mocker):
"""run_command raises when the command's own exit status is non-zero."""
mocker.patch.object(console, "send_input", return_value=mocker.Mock(ok=True))
mock_result = CommandResult(command="false", output="some error text\nDSD_EXIT_STATUS:1")
mocker.patch.object(console, "wait_for_command_completion", return_value=mock_result)

with pytest.raises(RuntimeError, match="exited with status 1"):
console.run_command("false")


def test_wait_for_command_completion_handles_exceptions(console, mocker):
"""wait_for_command_completion continues on exceptions."""
# First call raises exception, second call succeeds
Expand Down
34 changes: 34 additions & 0 deletions tests/unit_tests/test_platform_deployer.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,40 @@ def test_add_requirements(tmp_path: Path, monkeypatch):
assert "django-simple-deploy" not in modified_content


def test_copy_wsgi_file(monkeypatch, mocker):
"""_copy_wsgi_file copies the project's wsgi.py to PythonAnywhere."""
deployer = PlatformDeployer()
monkeypatch.setattr(dsd_config, "local_project_name", "mysite")
monkeypatch.setattr(dsd_config, "stdout", sys.stdout)
monkeypatch.setattr(deployer, "repo_name", "myrepo", raising=False)
monkeypatch.setattr(deployer, "pa_home", Path("/home/TestUser"), raising=False)
monkeypatch.setattr(
deployer, "pa_project_root_path", deployer.pa_home / deployer.repo_name, raising=False
)
monkeypatch.setattr(deployer.client, "username", "TestUser")

mock_run_command = mocker.patch.object(deployer.client, "run_command")

# Successful, verified copy: an absolute source path (consoles can be
# reused across API calls, so a relative path is vulnerable to a `cd`
# left over from an earlier step) and a lowercased destination filename
# (PythonAnywhere only looks for the lowercased filename, regardless of
# the account's actual username casing).
mock_run_command.side_effect = ["", "COPY_VERIFIED"]
deployer._copy_wsgi_file()
cp_cmd = mock_run_command.call_args_list[0].args[0]
assert "/home/TestUser/myrepo/mysite/wsgi.py" in cp_cmd
assert cp_cmd.split()[1].startswith("/")
assert "/var/www/testuser_pythonanywhere_com_wsgi.py" in cp_cmd

# cp "succeeds" (no error text returned), but cmp shows a content
# mismatch (e.g. a preceding command left the console's cwd somewhere
# unexpected). Must raise rather than proceed to reload_webapp().
mock_run_command.side_effect = ["", "COPY_FAILED"]
with pytest.raises(DSDCommandError, match="Failed to verify"):
deployer._copy_wsgi_file()


def test_validate_platform_missing_api_user(monkeypatch):
"""_validate_platform raises error when API_USER is missing."""
monkeypatch.delenv("API_USER", raising=False)
Expand Down