feature #18140 [Console] Add console.ERROR event and deprecate console.EXCEPTION (wouterj)

This PR was merged into the 3.3-dev branch.

Discussion
----------

[Console] Add console.ERROR event and deprecate console.EXCEPTION

| Q | A |
| --- | --- |
| Branch | master |
| Bug fix? | yes |
| New feature? | yes |
| BC breaks? | no |
| Deprecations? | yes |
| Tests pass? | yes |
| Fixed tickets | - |
| License | MIT |
| Doc PR | todo |
## The Problem

The current `console.EXCEPTION` event is only dispatched for exceptions during the execution of `Command#execute()`. All other exceptions (e.g. the ones thrown by listeners to events) are catched by the `try ... catch` loop in `Application#doRunCommand()`. This means that there is _no way to override exception handling_.
## The Solution

This PR adds a `console.ERROR` event which has the same scope as the default `try ... catch` loop. This allows to customize all exception handling.

In order to keep BC, a new event was created and `console.EXCEPTION` was deprecated.

Commits
-------

c02a4c9857 Added a console.ERROR event
This commit is contained in:
Fabien Potencier 2017-03-22 16:10:45 -07:00
commit ba4d6bce29
12 changed files with 298 additions and 55 deletions

View File

@ -73,6 +73,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
-------------------

View File

@ -58,6 +58,9 @@ Console
$commandTester->execute();
```
* The `console.exception` event and the related `ConsoleExceptionEvent` class have
been removed in favor of the `console.error` event and the `ConsoleErrorEvent` class.
Debug
-----

View File

@ -33,6 +33,7 @@ use Symfony\Component\Console\Helper\HelperSet;
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 @@ class Application
$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 @@ class Application
} 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 {

View File

@ -8,6 +8,8 @@ CHANGELOG
* added `AddConsoleCommandPass` (originally in FrameworkBundle)
* [BC BREAK] `Input::getOption()` no longer returns the default value for options
with value optional explicitly passed empty
* added console.error event to catch exceptions thrown by other listeners
* deprecated console.exception event in favor of console.error
3.2.0
------

View File

@ -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';
}

View File

@ -0,0 +1,112 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <wouter@wouterj.nl>
*/
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);
}
}

View File

@ -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 @@ class ConsoleEvent extends Event
/**
* Gets the command that is executed.
*
* @return Command A Command instance
* @return Command|null A Command instance
*/
public function getCommand()
{

View File

@ -19,17 +19,24 @@ use Symfony\Component\Console\Output\OutputInterface;
* Allows to handle exception thrown in a command.
*
* @author Fabien Potencier <fabien@symfony.com>
*
* @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;
}

View File

@ -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);

View File

@ -14,7 +14,7 @@ namespace Symfony\Component\Console\EventListener;
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 @@ class ExceptionListener implements EventSubscriberInterface
$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 @@ class ExceptionListener implements EventSubscriberInterface
public static function getSubscribedEvents()
{
return array(
ConsoleEvents::EXCEPTION => array('onConsoleException', -128),
ConsoleEvents::ERROR => array('onConsoleError', -128),
ConsoleEvents::TERMINATE => array('onConsoleTerminate', -128),
);
}

View File

@ -27,6 +27,7 @@ use Symfony\Component\Console\Output\OutputInterface;
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;
@ -969,7 +970,7 @@ class ApplicationTest extends TestCase
/**
* @expectedException \LogicException
* @expectedExceptionMessage caught
* @expectedExceptionMessage error
*/
public function testRunWithExceptionAndDispatcher()
{
@ -1000,7 +1001,77 @@ class ApplicationTest extends TestCase
$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()
@ -1028,7 +1099,7 @@ class ApplicationTest extends TestCase
/**
* @expectedException \LogicException
* @expectedExceptionMessage caught
* @expectedExceptionMessage error
*/
public function testRunWithErrorAndDispatcher()
{
@ -1045,7 +1116,7 @@ class ApplicationTest extends TestCase
$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()
@ -1062,7 +1133,7 @@ class ApplicationTest extends TestCase
$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()
@ -1173,32 +1244,6 @@ class ApplicationTest extends TestCase
$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();
@ -1255,6 +1300,32 @@ class ApplicationTest extends TestCase
$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

View File

@ -14,7 +14,7 @@ namespace Symfony\Component\Console\Tests\EventListener;
use PHPUnit\Framework\TestCase;
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;
@ -25,7 +25,7 @@ use Symfony\Component\Console\Output\OutputInterface;
class ExceptionListenerTest extends TestCase
{
public function testOnConsoleException()
public function testOnConsoleError()
{
$exception = new \RuntimeException('An error occurred');
@ -33,11 +33,11 @@ class ExceptionListenerTest extends TestCase
$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()
@ -69,7 +69,7 @@ class ExceptionListenerTest extends TestCase
{
$this->assertEquals(
array(
'console.exception' => array('onConsoleException', -128),
'console.error' => array('onConsoleError', -128),
'console.terminate' => array('onConsoleTerminate', -128),
),
ExceptionListener::getSubscribedEvents()
@ -109,9 +109,9 @@ class ExceptionListenerTest extends TestCase
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)