diff --git a/src/tailwind/management/commands/tailwind.py b/src/tailwind/management/commands/tailwind.py index e3b4a03..4141728 100644 --- a/src/tailwind/management/commands/tailwind.py +++ b/src/tailwind/management/commands/tailwind.py @@ -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") @@ -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") diff --git a/tests/test_dev_command.py b/tests/test_dev_command.py index c099b9b..d2ecdd9 100644 --- a/tests/test_dev_command.py +++ b/tests/test_dev_command.py @@ -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") @@ -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") @@ -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") @@ -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") @@ -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")