From c02a4c9857dde87421f054b02295be0111003105 Mon Sep 17 00:00:00 2001 From: WouterJ Date: Mon, 18 Jan 2016 17:12:22 +0100 Subject: [PATCH] Added a console.ERROR event --- UPGRADE-3.3.md | 7 + UPGRADE-4.0.md | 3 + src/Symfony/Component/Console/Application.php | 37 ++++- src/Symfony/Component/Console/CHANGELOG.md | 2 + .../Component/Console/ConsoleEvents.php | 18 ++- .../Console/Event/ConsoleErrorEvent.php | 112 +++++++++++++++ .../Component/Console/Event/ConsoleEvent.php | 4 +- .../Console/Event/ConsoleExceptionEvent.php | 11 +- .../Console/Event/ConsoleTerminateEvent.php | 2 +- .../EventListener/ExceptionListener.php | 10 +- .../Console/Tests/ApplicationTest.php | 133 ++++++++++++++---- .../EventListener/ExceptionListenerTest.php | 14 +- 12 files changed, 298 insertions(+), 55 deletions(-) create mode 100644 src/Symfony/Component/Console/Event/ConsoleErrorEvent.php diff --git a/UPGRADE-3.3.md b/UPGRADE-3.3.md index 1269051076e0..7e4503c3d06d 100644 --- a/UPGRADE-3.3.md +++ b/UPGRADE-3.3.md @@ -11,6 +11,13 @@ Debug * The `ContextErrorException` class is deprecated. `\ErrorException` will be used instead in 4.0. +Console +------- + + * The `console.exception` event and the related `ConsoleExceptionEvent` class + have been deprecated in favor of the `console.error` event and the `ConsoleErrorEvent` + class. The deprecated event and class will be removed in 4.0. + DependencyInjection ------------------- diff --git a/UPGRADE-4.0.md b/UPGRADE-4.0.md index 1ff703c8e948..3ab39affe321 100644 --- a/UPGRADE-4.0.md +++ b/UPGRADE-4.0.md @@ -12,6 +12,9 @@ Console * Setting unknown style options is not supported anymore and throws an exception. + * The `console.exception` event and the related `ConsoleExceptionEvent` class have + been removed in favor of the `console.error` event and the `ConsoleErrorEvent` class. + Debug ----- diff --git a/src/Symfony/Component/Console/Application.php b/src/Symfony/Component/Console/Application.php index 210cd15ea4fb..6b1972e3c489 100644 --- a/src/Symfony/Component/Console/Application.php +++ b/src/Symfony/Component/Console/Application.php @@ -33,6 +33,7 @@ use Symfony\Component\Console\Helper\Helper; use Symfony\Component\Console\Helper\FormatterHelper; use Symfony\Component\Console\Event\ConsoleCommandEvent; +use Symfony\Component\Console\Event\ConsoleErrorEvent; use Symfony\Component\Console\Event\ConsoleExceptionEvent; use Symfony\Component\Console\Event\ConsoleTerminateEvent; use Symfony\Component\Console\Exception\CommandNotFoundException; @@ -118,16 +119,40 @@ public function run(InputInterface $input = null, OutputInterface $output = null $this->configureIO($input, $output); try { + $e = null; $exitCode = $this->doRun($input, $output); } catch (\Exception $e) { + $exception = $e; + } catch (\Throwable $e) { + $exception = new FatalThrowableError($e); + } + + if (null !== $e && null !== $this->dispatcher) { + $event = new ConsoleErrorEvent($this->runningCommand, $input, $output, $e, $e->getCode()); + $this->dispatcher->dispatch(ConsoleEvents::ERROR, $event); + + $e = $event->getError(); + + if ($event->isErrorHandled()) { + $e = null; + $exitCode = 0; + } else { + $exitCode = $e->getCode(); + } + + $event = new ConsoleTerminateEvent($this->runningCommand, $input, $output, $exitCode); + $this->dispatcher->dispatch(ConsoleEvents::TERMINATE, $event); + } + + if (null !== $e) { if (!$this->catchExceptions) { throw $e; } if ($output instanceof ConsoleOutputInterface) { - $this->renderException($e, $output->getErrorOutput()); + $this->renderException($exception, $output->getErrorOutput()); } else { - $this->renderException($e, $output); + $this->renderException($exception, $output); } $exitCode = $e->getCode(); @@ -863,17 +888,17 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI } catch (\Throwable $x) { $e = new FatalThrowableError($x); } + if (null !== $e) { - $event = new ConsoleExceptionEvent($command, $input, $output, $e, $e->getCode()); + $event = new ConsoleExceptionEvent($command, $input, $output, $e, $e->getCode(), false); $this->dispatcher->dispatch(ConsoleEvents::EXCEPTION, $event); if ($e !== $event->getException()) { + @trigger_error('The "console.exception" event is deprecated since version 3.3 and will be removed in 4.0. Use the "console.error" event instead.', E_USER_DEPRECATED); + $x = $e = $event->getException(); } - $event = new ConsoleTerminateEvent($command, $input, $output, $e->getCode()); - $this->dispatcher->dispatch(ConsoleEvents::TERMINATE, $event); - throw $x; } } else { diff --git a/src/Symfony/Component/Console/CHANGELOG.md b/src/Symfony/Component/Console/CHANGELOG.md index c24c24c5d1cb..bf77d6f0c470 100644 --- a/src/Symfony/Component/Console/CHANGELOG.md +++ b/src/Symfony/Component/Console/CHANGELOG.md @@ -6,6 +6,8 @@ CHANGELOG * added `ExceptionListener` * added `AddConsoleCommandPass` (originally in FrameworkBundle) +* added console.error event to catch exceptions thrown by other listeners +* deprecated console.exception event in favor of console.error 3.2.0 ------ diff --git a/src/Symfony/Component/Console/ConsoleEvents.php b/src/Symfony/Component/Console/ConsoleEvents.php index b3571e9afb3b..57036733a2d5 100644 --- a/src/Symfony/Component/Console/ConsoleEvents.php +++ b/src/Symfony/Component/Console/ConsoleEvents.php @@ -40,7 +40,8 @@ final class ConsoleEvents const TERMINATE = 'console.terminate'; /** - * The EXCEPTION event occurs when an uncaught exception appears. + * The EXCEPTION event occurs when an uncaught exception appears + * while executing Command#run(). * * This event allows you to deal with the exception or * to modify the thrown exception. @@ -48,6 +49,21 @@ final class ConsoleEvents * @Event("Symfony\Component\Console\Event\ConsoleExceptionEvent") * * @var string + * + * @deprecated The console.exception event is deprecated since version 3.3 and will be removed in 4.0. Use the console.error event instead. */ const EXCEPTION = 'console.exception'; + + /** + * The ERROR event occurs when an uncaught exception appears or + * a throwable error. + * + * This event allows you to deal with the exception/error or + * to modify the thrown exception. + * + * @Event("Symfony\Component\Console\Event\ConsoleErrorEvent") + * + * @var string + */ + const ERROR = 'console.error'; } diff --git a/src/Symfony/Component/Console/Event/ConsoleErrorEvent.php b/src/Symfony/Component/Console/Event/ConsoleErrorEvent.php new file mode 100644 index 000000000000..d48c577d4e73 --- /dev/null +++ b/src/Symfony/Component/Console/Event/ConsoleErrorEvent.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Event; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Debug\Exception\FatalThrowableError; + +/** + * Allows to handle throwables thrown while running a command. + * + * @author Wouter de Jong + */ +class ConsoleErrorEvent extends ConsoleExceptionEvent +{ + private $error; + private $handled = false; + + public function __construct(Command $command, InputInterface $input, OutputInterface $output, $error, $exitCode) + { + if (!$error instanceof \Throwable && !$error instanceof \Exception) { + throw new InvalidArgumentException(sprintf('The error passed to ConsoleErrorEvent must be an instance of \Throwable or \Exception, "%s" was passed instead.', is_object($error) ? get_class($error) : gettype($error))); + } + + $exception = $error; + if (!$error instanceof \Exception) { + $exception = new FatalThrowableError($error); + } + parent::__construct($command, $input, $output, $exception, $exitCode, false); + + $this->error = $error; + } + + /** + * Returns the thrown error/exception. + * + * @return \Throwable + */ + public function getError() + { + return $this->error; + } + + /** + * Replaces the thrown error/exception. + * + * @param \Throwable $error + */ + public function setError($error) + { + if (!$error instanceof \Throwable && !$error instanceof \Exception) { + throw new InvalidArgumentException(sprintf('The error passed to ConsoleErrorEvent must be an instance of \Throwable or \Exception, "%s" was passed instead.', is_object($error) ? get_class($error) : gettype($error))); + } + + $this->error = $error; + } + + /** + * Marks the error/exception as handled. + * + * If it is not marked as handled, the error/exception will be displayed in + * the command output. + */ + public function markErrorAsHandled() + { + $this->handled = true; + } + + /** + * Whether the error/exception is handled by a listener or not. + * + * If it is not yet handled, the error/exception will be displayed in the + * command output. + * + * @return bool + */ + public function isErrorHandled() + { + return $this->handled; + } + + /** + * @deprecated Since version 3.3, to be removed in 4.0. Use getError() instead + */ + public function getException() + { + @trigger_error(sprintf('The %s() method is deprecated since version 3.3 and will be removed in 4.0. Use ConsoleErrorEvent::getError() instead.', __METHOD__), E_USER_DEPRECATED); + + return parent::getException(); + } + + /** + * @deprecated Since version 3.3, to be removed in 4.0. Use setError() instead + */ + public function setException(\Exception $exception) + { + @trigger_error(sprintf('The %s() method is deprecated since version 3.3 and will be removed in 4.0. Use ConsoleErrorEvent::setError() instead.', __METHOD__), E_USER_DEPRECATED); + + parent::setException($exception); + } +} diff --git a/src/Symfony/Component/Console/Event/ConsoleEvent.php b/src/Symfony/Component/Console/Event/ConsoleEvent.php index ab620c4609a2..5440da216c96 100644 --- a/src/Symfony/Component/Console/Event/ConsoleEvent.php +++ b/src/Symfony/Component/Console/Event/ConsoleEvent.php @@ -28,7 +28,7 @@ class ConsoleEvent extends Event private $input; private $output; - public function __construct(Command $command, InputInterface $input, OutputInterface $output) + public function __construct(Command $command = null, InputInterface $input, OutputInterface $output) { $this->command = $command; $this->input = $input; @@ -38,7 +38,7 @@ public function __construct(Command $command, InputInterface $input, OutputInter /** * Gets the command that is executed. * - * @return Command A Command instance + * @return Command|null A Command instance */ public function getCommand() { diff --git a/src/Symfony/Component/Console/Event/ConsoleExceptionEvent.php b/src/Symfony/Component/Console/Event/ConsoleExceptionEvent.php index 603b7eed78cd..56e7d4d429ad 100644 --- a/src/Symfony/Component/Console/Event/ConsoleExceptionEvent.php +++ b/src/Symfony/Component/Console/Event/ConsoleExceptionEvent.php @@ -19,17 +19,24 @@ * Allows to handle exception thrown in a command. * * @author Fabien Potencier + * + * @deprecated ConsoleExceptionEvent is deprecated since version 3.3 and will be removed in 4.0. Use ConsoleErrorEvent instead. */ class ConsoleExceptionEvent extends ConsoleEvent { private $exception; private $exitCode; + private $handled = false; - public function __construct(Command $command, InputInterface $input, OutputInterface $output, \Exception $exception, $exitCode) + public function __construct(Command $command, InputInterface $input, OutputInterface $output, \Exception $exception, $exitCode, $deprecation = true) { + if ($deprecation) { + @trigger_error(sprintf('The %s class is deprecated since version 3.3 and will be removed in 4.0. Use the ConsoleErrorEvent instead.', __CLASS__), E_USER_DEPRECATED); + } + parent::__construct($command, $input, $output); - $this->setException($exception); + $this->exception = $exception; $this->exitCode = (int) $exitCode; } diff --git a/src/Symfony/Component/Console/Event/ConsoleTerminateEvent.php b/src/Symfony/Component/Console/Event/ConsoleTerminateEvent.php index b6a5d7c0dc48..80bc2fa9eb98 100644 --- a/src/Symfony/Component/Console/Event/ConsoleTerminateEvent.php +++ b/src/Symfony/Component/Console/Event/ConsoleTerminateEvent.php @@ -29,7 +29,7 @@ class ConsoleTerminateEvent extends ConsoleEvent */ private $exitCode; - public function __construct(Command $command, InputInterface $input, OutputInterface $output, $exitCode) + public function __construct(Command $command = null, InputInterface $input, OutputInterface $output, $exitCode) { parent::__construct($command, $input, $output); diff --git a/src/Symfony/Component/Console/EventListener/ExceptionListener.php b/src/Symfony/Component/Console/EventListener/ExceptionListener.php index 58c57065834a..f7b23058fb55 100644 --- a/src/Symfony/Component/Console/EventListener/ExceptionListener.php +++ b/src/Symfony/Component/Console/EventListener/ExceptionListener.php @@ -14,7 +14,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Console\Event\ConsoleEvent; use Symfony\Component\Console\ConsoleEvents; -use Symfony\Component\Console\Event\ConsoleExceptionEvent; +use Symfony\Component\Console\Event\ConsoleErrorEvent; use Symfony\Component\Console\Event\ConsoleTerminateEvent; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -31,15 +31,15 @@ public function __construct(LoggerInterface $logger = null) $this->logger = $logger; } - public function onConsoleException(ConsoleExceptionEvent $event) + public function onConsoleError(ConsoleErrorEvent $event) { if (null === $this->logger) { return; } - $exception = $event->getException(); + $error = $event->getError(); - $this->logger->error('Exception thrown while running command "{command}". Message: "{message}"', array('exception' => $exception, 'command' => $this->getInputString($event), 'message' => $exception->getMessage())); + $this->logger->error('Error thrown while running command "{command}". Message: "{message}"', array('error' => $error, 'command' => $this->getInputString($event), 'message' => $error->getMessage())); } public function onConsoleTerminate(ConsoleTerminateEvent $event) @@ -60,7 +60,7 @@ public function onConsoleTerminate(ConsoleTerminateEvent $event) public static function getSubscribedEvents() { return array( - ConsoleEvents::EXCEPTION => array('onConsoleException', -128), + ConsoleEvents::ERROR => array('onConsoleError', -128), ConsoleEvents::TERMINATE => array('onConsoleTerminate', -128), ); } diff --git a/src/Symfony/Component/Console/Tests/ApplicationTest.php b/src/Symfony/Component/Console/Tests/ApplicationTest.php index 86b058b1456a..af4d3b600761 100644 --- a/src/Symfony/Component/Console/Tests/ApplicationTest.php +++ b/src/Symfony/Component/Console/Tests/ApplicationTest.php @@ -26,6 +26,7 @@ use Symfony\Component\Console\Output\StreamOutput; use Symfony\Component\Console\Tester\ApplicationTester; use Symfony\Component\Console\Event\ConsoleCommandEvent; +use Symfony\Component\Console\Event\ConsoleErrorEvent; use Symfony\Component\Console\Event\ConsoleExceptionEvent; use Symfony\Component\Console\Event\ConsoleTerminateEvent; use Symfony\Component\Console\Exception\CommandNotFoundException; @@ -922,7 +923,7 @@ public function testRunWithDispatcher() /** * @expectedException \LogicException - * @expectedExceptionMessage caught + * @expectedExceptionMessage error */ public function testRunWithExceptionAndDispatcher() { @@ -953,7 +954,77 @@ public function testRunDispatchesAllEventsWithException() $tester = new ApplicationTester($application); $tester->run(array('command' => 'foo')); - $this->assertContains('before.foo.caught.after.', $tester->getDisplay()); + $this->assertContains('before.foo.error.after.', $tester->getDisplay()); + } + + public function testRunDispatchesAllEventsWithExceptionInListener() + { + $dispatcher = $this->getDispatcher(); + $dispatcher->addListener('console.command', function () { + throw new \RuntimeException('foo'); + }); + + $application = new Application(); + $application->setDispatcher($dispatcher); + $application->setAutoExit(false); + + $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output) { + $output->write('foo.'); + }); + + $tester = new ApplicationTester($application); + $tester->run(array('command' => 'foo')); + $this->assertContains('before.error.after.', $tester->getDisplay()); + } + + public function testRunAllowsErrorListenersToSilenceTheException() + { + $dispatcher = $this->getDispatcher(); + $dispatcher->addListener('console.error', function (ConsoleErrorEvent $event) { + $event->getOutput()->write('silenced.'); + + $event->markErrorAsHandled(); + }); + + $dispatcher->addListener('console.command', function () { + throw new \RuntimeException('foo'); + }); + + $application = new Application(); + $application->setDispatcher($dispatcher); + $application->setAutoExit(false); + + $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output) { + $output->write('foo.'); + }); + + $tester = new ApplicationTester($application); + $tester->run(array('command' => 'foo')); + $this->assertContains('before.error.silenced.after.', $tester->getDisplay()); + $this->assertEquals(0, $tester->getStatusCode()); + } + + public function testLegacyExceptionListenersAreStillTriggered() + { + $dispatcher = $this->getDispatcher(); + $dispatcher->addListener('console.exception', function (ConsoleExceptionEvent $event) { + $event->getOutput()->write('caught.'); + + $event->setException(new \RuntimeException('replaced in caught.')); + }); + + $application = new Application(); + $application->setDispatcher($dispatcher); + $application->setAutoExit(false); + + $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output) { + throw new \RuntimeException('foo'); + }); + + $tester = new ApplicationTester($application); + $tester->run(array('command' => 'foo')); + $this->assertContains('before.caught.error.after.', $tester->getDisplay()); + $this->assertContains('replaced in caught.', $tester->getDisplay()); } public function testRunWithError() @@ -976,7 +1047,7 @@ public function testRunWithError() /** * @expectedException \LogicException - * @expectedExceptionMessage caught + * @expectedExceptionMessage error */ public function testRunWithErrorAndDispatcher() { @@ -993,7 +1064,7 @@ public function testRunWithErrorAndDispatcher() $tester = new ApplicationTester($application); $tester->run(array('command' => 'dym')); - $this->assertContains('before.dym.caught.after.', $tester->getDisplay(), 'The PHP Error did not dispached events'); + $this->assertContains('before.dym.error.after.', $tester->getDisplay(), 'The PHP Error did not dispached events'); } public function testRunDispatchesAllEventsWithError() @@ -1010,7 +1081,7 @@ public function testRunDispatchesAllEventsWithError() $tester = new ApplicationTester($application); $tester->run(array('command' => 'dym')); - $this->assertContains('before.dym.caught.after.', $tester->getDisplay(), 'The PHP Error did not dispached events'); + $this->assertContains('before.dym.error.after.', $tester->getDisplay(), 'The PHP Error did not dispached events'); } public function testRunWithErrorFailingStatusCode() @@ -1121,32 +1192,6 @@ public function testTerminalDimensions() $this->assertSame(array($width, 80), $application->getTerminalDimensions()); } - protected function getDispatcher($skipCommand = false) - { - $dispatcher = new EventDispatcher(); - $dispatcher->addListener('console.command', function (ConsoleCommandEvent $event) use ($skipCommand) { - $event->getOutput()->write('before.'); - - if ($skipCommand) { - $event->disableCommand(); - } - }); - $dispatcher->addListener('console.terminate', function (ConsoleTerminateEvent $event) use ($skipCommand) { - $event->getOutput()->writeln('after.'); - - if (!$skipCommand) { - $event->setExitCode(113); - } - }); - $dispatcher->addListener('console.exception', function (ConsoleExceptionEvent $event) { - $event->getOutput()->write('caught.'); - - $event->setException(new \LogicException('caught.', $event->getExitCode(), $event->getException())); - }); - - return $dispatcher; - } - public function testSetRunCustomDefaultCommand() { $command = new \FooCommand(); @@ -1203,6 +1248,32 @@ public function testCanCheckIfTerminalIsInteractive() $inputStream = $tester->getInput()->getStream(); $this->assertEquals($tester->getInput()->isInteractive(), @posix_isatty($inputStream)); } + + protected function getDispatcher($skipCommand = false) + { + $dispatcher = new EventDispatcher(); + $dispatcher->addListener('console.command', function (ConsoleCommandEvent $event) use ($skipCommand) { + $event->getOutput()->write('before.'); + + if ($skipCommand) { + $event->disableCommand(); + } + }); + $dispatcher->addListener('console.terminate', function (ConsoleTerminateEvent $event) use ($skipCommand) { + $event->getOutput()->writeln('after.'); + + if (!$skipCommand) { + $event->setExitCode(113); + } + }); + $dispatcher->addListener('console.error', function (ConsoleErrorEvent $event) { + $event->getOutput()->write('error.'); + + $event->setError(new \LogicException('error.', $event->getExitCode(), $event->getError())); + }); + + return $dispatcher; + } } class CustomApplication extends Application diff --git a/src/Symfony/Component/Console/Tests/EventListener/ExceptionListenerTest.php b/src/Symfony/Component/Console/Tests/EventListener/ExceptionListenerTest.php index c7e6890b45f4..7f432cdd7602 100644 --- a/src/Symfony/Component/Console/Tests/EventListener/ExceptionListenerTest.php +++ b/src/Symfony/Component/Console/Tests/EventListener/ExceptionListenerTest.php @@ -13,7 +13,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Event\ConsoleExceptionEvent; +use Symfony\Component\Console\Event\ConsoleErrorEvent; use Symfony\Component\Console\Event\ConsoleTerminateEvent; use Symfony\Component\Console\EventListener\ExceptionListener; use Symfony\Component\Console\Input\ArgvInput; @@ -24,7 +24,7 @@ class ExceptionListenerTest extends \PHPUnit_Framework_TestCase { - public function testOnConsoleException() + public function testOnConsoleError() { $exception = new \RuntimeException('An error occurred'); @@ -32,11 +32,11 @@ public function testOnConsoleException() $logger ->expects($this->once()) ->method('error') - ->with('Exception thrown while running command "{command}". Message: "{message}"', array('exception' => $exception, 'command' => 'test:run --foo=baz buzz', 'message' => 'An error occurred')) + ->with('Error thrown while running command "{command}". Message: "{message}"', array('error' => $exception, 'command' => 'test:run --foo=baz buzz', 'message' => 'An error occurred')) ; $listener = new ExceptionListener($logger); - $listener->onConsoleException($this->getConsoleExceptionEvent($exception, new ArgvInput(array('console.php', 'test:run', '--foo=baz', 'buzz')), 1)); + $listener->onConsoleError($this->getConsoleErrorEvent($exception, new ArgvInput(array('console.php', 'test:run', '--foo=baz', 'buzz')), 1)); } public function testOnConsoleTerminateForNonZeroExitCodeWritesToLog() @@ -68,7 +68,7 @@ public function testGetSubscribedEvents() { $this->assertEquals( array( - 'console.exception' => array('onConsoleException', -128), + 'console.error' => array('onConsoleError', -128), 'console.terminate' => array('onConsoleTerminate', -128), ), ExceptionListener::getSubscribedEvents() @@ -108,9 +108,9 @@ private function getLogger() return $this->getMockForAbstractClass(LoggerInterface::class); } - private function getConsoleExceptionEvent(\Exception $exception, InputInterface $input, $exitCode) + private function getConsoleErrorEvent(\Exception $exception, InputInterface $input, $exitCode) { - return new ConsoleExceptionEvent(new Command('test:run'), $input, $this->getOutput(), $exception, $exitCode); + return new ConsoleErrorEvent(new Command('test:run'), $input, $this->getOutput(), $exception, $exitCode); } private function getConsoleTerminateEvent(InputInterface $input, $exitCode)