From be90496952d8d110315f674ce6212d3b3c7f616a Mon Sep 17 00:00:00 2001 From: John Kary Date: Sat, 29 Sep 2012 14:24:59 -0700 Subject: [PATCH 1/7] Add ability to call CLI-based commands from an event --- src/Composer/Script/EventDispatcher.php | 65 +++++++++++++------ .../Test/Script/EventDispatcherTest.php | 26 ++++++++ 2 files changed, 72 insertions(+), 19 deletions(-) diff --git a/src/Composer/Script/EventDispatcher.php b/src/Composer/Script/EventDispatcher.php index b4537ed62..6c6d26b87 100644 --- a/src/Composer/Script/EventDispatcher.php +++ b/src/Composer/Script/EventDispatcher.php @@ -16,6 +16,7 @@ use Composer\Autoload\AutoloadGenerator; use Composer\IO\IOInterface; use Composer\Composer; use Composer\DependencyResolver\Operation\OperationInterface; +use Composer\Util\ProcessExecutor; /** * The Event Dispatcher. @@ -34,6 +35,7 @@ class EventDispatcher protected $composer; protected $io; protected $loader; + protected $process; /** * Constructor. @@ -41,10 +43,11 @@ class EventDispatcher * @param Composer $composer The composer instance * @param IOInterface $io The IOInterface instance */ - public function __construct(Composer $composer, IOInterface $io) + public function __construct(Composer $composer, IOInterface $io, ProcessExecutor $process = null) { $this->composer = $composer; $this->io = $io; + $this->process = $process ?: new ProcessExecutor(); } /** @@ -78,24 +81,37 @@ class EventDispatcher $listeners = $this->getListeners($event); foreach ($listeners as $callable) { - $className = substr($callable, 0, strpos($callable, '::')); - $methodName = substr($callable, strpos($callable, '::') + 2); - - if (!class_exists($className)) { - $this->io->write('Class '.$className.' is not autoloadable, can not call '.$event->getName().' script'); - continue; - } - if (!is_callable($callable)) { - $this->io->write('Method '.$callable.' is not callable, can not call '.$event->getName().' script'); - continue; - } - - try { - $className::$methodName($event); - } catch (\Exception $e) { - $message = "Script %s handling the %s event terminated with an exception"; - $this->io->write(''.sprintf($message, $callable, $event->getName()).''); - throw $e; + if ($this->isPhpScript($callable)) { + $className = substr($callable, 0, strpos($callable, '::')); + $methodName = substr($callable, strpos($callable, '::') + 2); + + if (!class_exists($className)) { + $this->io->write('Class '.$className.' is not autoloadable, can not call '.$event->getName().' script'); + continue; + } + if (!is_callable($callable)) { + $this->io->write('Method '.$callable.' is not callable, can not call '.$event->getName().' script'); + continue; + } + + try { + $className::$methodName($event); + } catch (\Exception $e) { + $message = "Script %s handling the %s event terminated with an exception"; + $this->io->write(''.sprintf($message, $callable, $event->getName()).''); + throw $e; + } + } else { + $callback = function ($type, $buffer) use ($event, $callable) { + $io = $event->getIO(); + if ('err' === $type) { + $message = 'Script %s handling the %s event returned an error: %s'; + $io->write(sprintf(''.$message.'', $callable, $event->getName(), $buffer)); + } else { + $io->write($buffer, false); + } + }; + $this->process->execute($callable, $callback); } } } @@ -126,4 +142,15 @@ class EventDispatcher return $scripts[$event->getName()]; } + + /** + * Checks if string given references a class path and method + * + * @param string $callable + * @return boolean + */ + protected function isPhpScript($callable) + { + return false !== strpos($callable, '::'); + } } diff --git a/tests/Composer/Test/Script/EventDispatcherTest.php b/tests/Composer/Test/Script/EventDispatcherTest.php index e23dccf8a..682624d9f 100644 --- a/tests/Composer/Test/Script/EventDispatcherTest.php +++ b/tests/Composer/Test/Script/EventDispatcherTest.php @@ -35,6 +35,32 @@ class EventDispatcherTest extends TestCase $dispatcher->dispatchCommandEvent("post-install-cmd"); } + public function testDispatcherCanExecuteCommandLineScripts() + { + $eventCliCommand = 'phpunit'; + + $process = $this->getMock('Composer\Util\ProcessExecutor'); + $dispatcher = $this->getMockBuilder('Composer\Script\EventDispatcher') + ->setConstructorArgs(array( + $this->getMock('Composer\Composer'), + $this->getMock('Composer\IO\IOInterface'), + $process, + )) + ->setMethods(array('getListeners')) + ->getMock(); + + $listeners = array($eventCliCommand); + $dispatcher->expects($this->atLeastOnce()) + ->method('getListeners') + ->will($this->returnValue($listeners)); + + $process->expects($this->once()) + ->method('execute') + ->with($eventCliCommand); + + $dispatcher->dispatchCommandEvent("post-install-cmd"); + } + private function getDispatcherStubForListenersTest($listeners, $io) { $dispatcher = $this->getMockBuilder('Composer\Script\EventDispatcher') From b213e585df51c077f8256c5806bc71a09ca6cff0 Mon Sep 17 00:00:00 2001 From: John Kary Date: Sun, 30 Sep 2012 21:53:26 -0500 Subject: [PATCH 2/7] Update and refine Scripts docs with CLI-based commands --- doc/articles/scripts.md | 62 +++++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/doc/articles/scripts.md b/doc/articles/scripts.md index 3efaccc32..fc61dc624 100644 --- a/doc/articles/scripts.md +++ b/doc/articles/scripts.md @@ -6,19 +6,24 @@ ## What is a script? -A script is a callback (defined as a static method) that will be called -when the event it listens on is triggered. +A script, in Composer's terms, can either be a PHP callback (defined as a +static method) or any command-line executable command. Scripts are useful +for executing a package's custom code or package-specific commands during +the Composer execution process. -**Scripts are only executed on the root package, not on the dependencies -that are installed.** +**NOTE: Only scripts defined in the root package's `composer.json` are +executed. If a dependency of the root package specifies its own scripts, +Composer does not execute those additional scripts.** -## Event types +## Event names -- **pre-install-cmd**: occurs before the install command is executed. -- **post-install-cmd**: occurs after the install command is executed. -- **pre-update-cmd**: occurs before the update command is executed. -- **post-update-cmd**: occurs after the update command is executed. +Composer fires the following named events during its execution process: + +- **pre-install-cmd*: occurs before the `install` command is executed. +- **post-install-cmd**: occurs after the `install` command is executed. +- **pre-update-cmd**: occurs before the `update` command is executed. +- **post-update-cmd**: occurs after the `update` command is executed. - **pre-package-install**: occurs before a package is installed. - **post-package-install**: occurs after a package is installed. - **pre-package-update**: occurs before a package is updated. @@ -29,12 +34,18 @@ that are installed.** ## Defining scripts -Scripts are defined by adding the `scripts` key to a project's `composer.json`. +The root JSON object in `composer.json` should have a member called `"scripts"`, +which contains pairs of named events and each event's corresponding +scripts. An event's scripts can be defined as either as a string (only for +a single script) or an array (for single or multiple scripts.) -They are specified as an array of classes and static method names. +For any given event: -The classes used as scripts must be autoloadable via Composer's autoload -functionality. +- Scripts execute in the order defined when their corresponding event is fired. +- An array of scripts wired to a single event can contain both PHP callbacks +and command-line executables commands. +- PHP classes containing defined callbacks must be autoloadable via Composer's +autoload functionality. Script definition example: @@ -44,14 +55,15 @@ Script definition example: "post-package-install": [ "MyVendor\\MyClass::postPackageInstall" ] + "post-install-cmd": [ + "MyVendor\\MyClass::warmCache", + "phpunit -c app/" + ] } } -The event handler receives a `Composer\Script\Event` object as an argument, -which gives you access to the `Composer\Composer` instance through the -`getComposer` method. - -Using the previous example, here's an event listener example : +Using the previous definition example, here's the class `MyVendor\MyClass` +that might be used to execute the PHP callbacks: getOperation()->getPackage(); // do stuff } + + public static function warmCache(Event $event) + { + // make cache toasty + } } + +When an event is fired, Composer's internal event handler receives a +`Composer\Script\Event` object, which is passed as the first argument to your +PHP callback. This `Event` object has getters for other contextual objects: + +- `getComposer()`: returns the current instance of `Composer\Composer` +- `getName()`: returns the name of the event being fired as a string +- `getIO()`: returns the current input/output stream which implements +`Composer\IO\IOInterface` for writing to the console From 5aa3762c0998c6a2c4da6fff10b549fc69ae8318 Mon Sep 17 00:00:00 2001 From: John Kary Date: Sat, 6 Oct 2012 21:54:52 -0500 Subject: [PATCH 3/7] Expand tests for valid CLI command from script --- .../Test/Script/EventDispatcherTest.php | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/Composer/Test/Script/EventDispatcherTest.php b/tests/Composer/Test/Script/EventDispatcherTest.php index 682624d9f..c6bfc710c 100644 --- a/tests/Composer/Test/Script/EventDispatcherTest.php +++ b/tests/Composer/Test/Script/EventDispatcherTest.php @@ -35,10 +35,12 @@ class EventDispatcherTest extends TestCase $dispatcher->dispatchCommandEvent("post-install-cmd"); } - public function testDispatcherCanExecuteCommandLineScripts() + /** + * @dataProvider getValidCommands + * @param string $command + */ + public function testDispatcherCanExecuteSingleCommandLineScript($command) { - $eventCliCommand = 'phpunit'; - $process = $this->getMock('Composer\Util\ProcessExecutor'); $dispatcher = $this->getMockBuilder('Composer\Script\EventDispatcher') ->setConstructorArgs(array( @@ -49,14 +51,14 @@ class EventDispatcherTest extends TestCase ->setMethods(array('getListeners')) ->getMock(); - $listeners = array($eventCliCommand); + $listener = array($command); $dispatcher->expects($this->atLeastOnce()) ->method('getListeners') - ->will($this->returnValue($listeners)); + ->will($this->returnValue($listener)); $process->expects($this->once()) ->method('execute') - ->with($eventCliCommand); + ->with($command); $dispatcher->dispatchCommandEvent("post-install-cmd"); } @@ -78,6 +80,15 @@ class EventDispatcherTest extends TestCase return $dispatcher; } + public function getValidCommands() + { + return array( + array('phpunit'), + array('echo foo'), + array('echo -n foo'), + ); + } + public static function call() { throw new \RuntimeException(); From 22cab83bb1dde8642a5960512901ea491b55956c Mon Sep 17 00:00:00 2001 From: John Kary Date: Sat, 6 Oct 2012 22:36:17 -0500 Subject: [PATCH 4/7] PHP callables cannot containing spaces --- src/Composer/Script/EventDispatcher.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Composer/Script/EventDispatcher.php b/src/Composer/Script/EventDispatcher.php index 6c6d26b87..7dd1e1f1c 100644 --- a/src/Composer/Script/EventDispatcher.php +++ b/src/Composer/Script/EventDispatcher.php @@ -151,6 +151,6 @@ class EventDispatcher */ protected function isPhpScript($callable) { - return false !== strpos($callable, '::'); + return false === strpos($callable, ' ') && false !== strpos($callable, '::'); } } From 88650f9333444a6651ef5495525ab26937213898 Mon Sep 17 00:00:00 2001 From: John Kary Date: Sat, 6 Oct 2012 22:37:52 -0500 Subject: [PATCH 5/7] Add test for intermixing PHP callables and CLI commands in a single event's script stack Wrapped execution of the PHP callable in its own method in order to mock/test it --- src/Composer/Script/EventDispatcher.php | 12 +++++- .../Test/Script/EventDispatcherTest.php | 43 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/Composer/Script/EventDispatcher.php b/src/Composer/Script/EventDispatcher.php index 7dd1e1f1c..31589bf7b 100644 --- a/src/Composer/Script/EventDispatcher.php +++ b/src/Composer/Script/EventDispatcher.php @@ -95,7 +95,7 @@ class EventDispatcher } try { - $className::$methodName($event); + $this->executeEventPhpScript($className, $methodName, $event); } catch (\Exception $e) { $message = "Script %s handling the %s event terminated with an exception"; $this->io->write(''.sprintf($message, $callable, $event->getName()).''); @@ -116,6 +116,16 @@ class EventDispatcher } } + /** + * @param string $className + * @param string $methodName + * @param Event $event Event invoking the PHP callable + */ + protected function executeEventPhpScript($className, $methodName, Event $event) + { + $className::$methodName($event); + } + /** * @param Event $event Event object * @return array Listeners diff --git a/tests/Composer/Test/Script/EventDispatcherTest.php b/tests/Composer/Test/Script/EventDispatcherTest.php index c6bfc710c..6a06a2e46 100644 --- a/tests/Composer/Test/Script/EventDispatcherTest.php +++ b/tests/Composer/Test/Script/EventDispatcherTest.php @@ -63,6 +63,44 @@ class EventDispatcherTest extends TestCase $dispatcher->dispatchCommandEvent("post-install-cmd"); } + public function testDispatcherCanExecuteCliAndPhpInSameEventScriptStack() + { + $io = $this->getMock('Composer\IO\IOInterface'); + $process = $this->getMock('Composer\Util\ProcessExecutor'); + $dispatcher = $this->getMockBuilder('Composer\Script\EventDispatcher') + ->setConstructorArgs(array( + $this->getMock('Composer\Composer'), + $io, + $process, + )) + ->setMethods(array( + 'getListeners', + 'executeEventPhpScript', + )) + ->getMock(); + + $io->expects($this->never()) + ->method('write'); + $process->expects($this->exactly(2)) + ->method('execute'); + + $listeners = array( + 'echo -n foo', + 'Composer\\Test\\Script\\EventDispatcherTest::someMethod', + 'echo -n bar', + ); + $dispatcher->expects($this->atLeastOnce()) + ->method('getListeners') + ->will($this->returnValue($listeners)); + + $dispatcher->expects($this->once()) + ->method('executeEventPhpScript') + ->with('Composer\Test\Script\EventDispatcherTest', 'someMethod') + ->will($this->returnValue(true)); + + $dispatcher->dispatchCommandEvent("post-install-cmd"); + } + private function getDispatcherStubForListenersTest($listeners, $io) { $dispatcher = $this->getMockBuilder('Composer\Script\EventDispatcher') @@ -93,4 +131,9 @@ class EventDispatcherTest extends TestCase { throw new \RuntimeException(); } + + public static function someMethod() + { + return true; + } } From 06eb4027a734e2de4d3d2dde5f527301a0113917 Mon Sep 17 00:00:00 2001 From: John Kary Date: Sun, 7 Oct 2012 09:46:43 -0500 Subject: [PATCH 6/7] Make test less brittle Shouldn't really care about whether the IO is touched. That's the test knowing too much about the implementation. --- tests/Composer/Test/Script/EventDispatcherTest.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/Composer/Test/Script/EventDispatcherTest.php b/tests/Composer/Test/Script/EventDispatcherTest.php index 6a06a2e46..15a7ff172 100644 --- a/tests/Composer/Test/Script/EventDispatcherTest.php +++ b/tests/Composer/Test/Script/EventDispatcherTest.php @@ -65,12 +65,11 @@ class EventDispatcherTest extends TestCase public function testDispatcherCanExecuteCliAndPhpInSameEventScriptStack() { - $io = $this->getMock('Composer\IO\IOInterface'); $process = $this->getMock('Composer\Util\ProcessExecutor'); $dispatcher = $this->getMockBuilder('Composer\Script\EventDispatcher') ->setConstructorArgs(array( $this->getMock('Composer\Composer'), - $io, + $this->getMock('Composer\IO\IOInterface'), $process, )) ->setMethods(array( @@ -79,8 +78,6 @@ class EventDispatcherTest extends TestCase )) ->getMock(); - $io->expects($this->never()) - ->method('write'); $process->expects($this->exactly(2)) ->method('execute'); From a069f7a906b0c36ce6f0dd14d49f4b7bcd4f8417 Mon Sep 17 00:00:00 2001 From: John Kary Date: Sun, 21 Oct 2012 14:05:32 -0500 Subject: [PATCH 7/7] Fix styling typo --- doc/articles/scripts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/articles/scripts.md b/doc/articles/scripts.md index fc61dc624..359020cd5 100644 --- a/doc/articles/scripts.md +++ b/doc/articles/scripts.md @@ -20,7 +20,7 @@ Composer does not execute those additional scripts.** Composer fires the following named events during its execution process: -- **pre-install-cmd*: occurs before the `install` command is executed. +- **pre-install-cmd**: occurs before the `install` command is executed. - **post-install-cmd**: occurs after the `install` command is executed. - **pre-update-cmd**: occurs before the `update` command is executed. - **post-update-cmd**: occurs after the `update` command is executed.