Skip to content
Closed
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
56 changes: 39 additions & 17 deletions src/tailwind/management/commands/tailwind.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,19 +144,7 @@ def handle_start_command(self, **options):
self.npm_command("run", "start")

def handle_dev_command(self, **options):
# Check if honcho is installed
try:
subprocess.run(["honcho", "--version"], check=True, capture_output=True)
except (subprocess.CalledProcessError, FileNotFoundError):
self.stdout.write("Honcho is not installed. Installing honcho...")
try:
install_pip_package("honcho")
self.stdout.write(self.style.SUCCESS("Honcho installed successfully!"))
except Exception as err:
raise CommandError(
"Failed to install 'honcho' via pip. Please install it manually "
"(https://pypi.org/project/honcho/) and run 'python manage.py tailwind dev' again."
) from err
import time

# Check if Procfile.tailwind exists, create if not
procfile_path = os.path.join(os.getcwd(), "Procfile.tailwind")
Expand Down Expand Up @@ -184,13 +172,47 @@ def handle_dev_command(self, **options):
self.stdout.write(line)
self.stdout.write("")

# Start honcho with the Procfile
# Parse Procfile to get commands
commands = []
with open(procfile_path) as f:
for line in f:
line = line.strip()
if line and ":" in line:
_, command = line.split(":", 1)
commands.append(command.strip())

if not commands:
raise CommandError("No commands found in Procfile.tailwind")

# Start all processes
processes = []
try:
subprocess.run(["honcho", "-f", "Procfile.tailwind", "start"], check=True)
except subprocess.CalledProcessError as err:
raise CommandError(f"Failed to start honcho: {err}") from err
for cmd in commands:
proc = subprocess.Popen(cmd, shell=True)
processes.append(proc)

# Wait for any process to exit (or KeyboardInterrupt)
while True:
# Check if any process has exited
for proc in processes:
if proc.poll() is not None:
raise CommandError(f"A process exited unexpectedly with code {proc.returncode}")
# Small sleep to avoid busy waiting
time.sleep(0.1)

except KeyboardInterrupt:
self.stdout.write("\nStopping development servers...")
finally:
# Terminate all processes
import contextlib

for proc in processes:
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
with contextlib.suppress(Exception):
proc.kill()

def handle_check_updates_command(self, **options):
self.npm_command("outdated")
Expand Down
85 changes: 44 additions & 41 deletions tests/test_dev_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@ def test_tailwind_dev_command_creates_procfile_real(settings, app_name, procfile
# Ensure Procfile.tailwind doesn't exist
assert not os.path.exists(procfile_path)

# Mock subprocess to prevent actual honcho execution
with mock.patch("subprocess.run") as mock_subprocess:
# Mock honcho is installed
mock_subprocess.side_effect = [
mock.Mock(), # honcho --version succeeds
KeyboardInterrupt(), # honcho start interrupted immediately
]
# Mock subprocess.Popen to prevent actual process execution
with mock.patch("subprocess.Popen") as mock_popen, mock.patch("time.sleep") as mock_sleep:
mock_proc = mock.Mock()
mock_proc.poll.return_value = None # Process is running
mock_proc.wait.return_value = None
mock_proc.terminate.return_value = None
mock_popen.return_value = mock_proc

# Simulate KeyboardInterrupt after first sleep
mock_sleep.side_effect = KeyboardInterrupt()

# Call dev command - should create Procfile
call_command("tailwind", "dev")
Expand Down Expand Up @@ -66,13 +69,14 @@ def test_tailwind_dev_command_uses_existing_procfile(settings, app_name, procfil
with open(procfile_path, "w") as f:
f.write(custom_content)

# Mock subprocess to prevent actual honcho execution
with mock.patch("subprocess.run") as mock_subprocess:
# Mock honcho is installed
mock_subprocess.side_effect = [
mock.Mock(), # honcho --version succeeds
KeyboardInterrupt(), # honcho start interrupted immediately
]
# Mock subprocess.Popen to prevent actual process execution
with mock.patch("subprocess.Popen") as mock_popen, mock.patch("time.sleep") as mock_sleep:
mock_proc = mock.Mock()
mock_proc.poll.return_value = None
mock_proc.wait.return_value = None
mock_proc.terminate.return_value = None
mock_popen.return_value = mock_proc
mock_sleep.side_effect = KeyboardInterrupt()

# Call dev command - should NOT overwrite existing Procfile
call_command_with_output("tailwind", "dev")
Expand All @@ -89,27 +93,24 @@ def test_tailwind_dev_command_uses_existing_procfile(settings, app_name, procfil

def test_tailwind_dev_command_subprocess_error(settings, app_name, procfile_path):
"""
GIVEN a Tailwind app is initialized and honcho is available
WHEN the dev command is run but honcho start fails
THEN a CommandError should be raised with subprocess failure message
GIVEN a Tailwind app is initialized
WHEN the dev command is run but a process exits unexpectedly
THEN a CommandError should be raised
"""
# Setup
call_command("tailwind", "init", "--app-name", app_name, "--no-input")
settings.INSTALLED_APPS += [app_name]
settings.TAILWIND_APP_NAME = app_name

# Mock subprocess to simulate honcho start failure
with mock.patch("subprocess.run") as mock_subprocess:
import subprocess

# Mock honcho version check to succeed, but start to fail
mock_subprocess.side_effect = [
mock.Mock(), # honcho --version succeeds
subprocess.CalledProcessError(1, ["honcho", "start"]), # honcho start fails
]
# Mock subprocess.Popen to simulate process failure
with mock.patch("subprocess.Popen") as mock_popen:
mock_proc = mock.Mock()
mock_proc.poll.return_value = 1 # Process exited with error code 1
mock_proc.returncode = 1
mock_popen.return_value = mock_proc

# Expect CommandError to be raised
with pytest.raises(CommandError, match="Failed to start honcho"):
with pytest.raises(CommandError, match="A process exited unexpectedly"):
call_command("tailwind", "dev")


Expand All @@ -124,13 +125,14 @@ def test_tailwind_dev_command_graceful_keyboard_interrupt(settings, app_name, pr
settings.INSTALLED_APPS += [app_name]
settings.TAILWIND_APP_NAME = app_name

# Mock subprocess to simulate KeyboardInterrupt
with mock.patch("subprocess.run") as mock_subprocess:
# Mock honcho version check to succeed, but start to be interrupted
mock_subprocess.side_effect = [
mock.Mock(), # honcho --version succeeds
KeyboardInterrupt(), # honcho start interrupted
]
# Mock subprocess.Popen to simulate KeyboardInterrupt
with mock.patch("subprocess.Popen") as mock_popen, mock.patch("time.sleep") as mock_sleep:
mock_proc = mock.Mock()
mock_proc.poll.return_value = None
mock_proc.wait.return_value = None
mock_proc.terminate.return_value = None
mock_popen.return_value = mock_proc
mock_sleep.side_effect = KeyboardInterrupt()

# Should not raise exception, should handle gracefully
call_command("tailwind", "dev")
Expand All @@ -149,13 +151,14 @@ def test_tailwind_dev_command_messages_in_the_output(settings, app_name, procfil
settings.INSTALLED_APPS += [app_name]
settings.TAILWIND_APP_NAME = app_name

# Mock subprocess to prevent actual honcho execution
with mock.patch("subprocess.run") as mock_subprocess:
# Mock honcho is installed
mock_subprocess.side_effect = [
mock.Mock(), # honcho --version succeeds
KeyboardInterrupt(), # honcho start interrupted immediately
]
# Mock subprocess.Popen to prevent actual process execution
with mock.patch("subprocess.Popen") as mock_popen, mock.patch("time.sleep") as mock_sleep:
mock_proc = mock.Mock()
mock_proc.poll.return_value = None
mock_proc.wait.return_value = None
mock_proc.terminate.return_value = None
mock_popen.return_value = mock_proc
mock_sleep.side_effect = KeyboardInterrupt()

# Call dev command - should create Procfile
out, _ = call_command_with_output("tailwind", "dev")
Expand Down