1
0
Fork 0

Add support for adding Command classes as scripts, (#11151)

* Add support for adding Command classes as scripts, fixes #11134

* Allow all options to be forwarded and allow using references to other scripts with args

* Fix build

* Add more checks

* Ensure exceptions are not swallowed, and remove naming restriction by using a single-command app

* Update docs

* Add tests, fix issue merging params when combining nested scripts and CLI params
pull/11161/head
Jordi Boggiano 2022-10-28 14:24:55 +02:00 committed by GitHub
parent 0430722e66
commit 6e55cb36d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 201 additions and 4 deletions

View File

@ -11,6 +11,10 @@ 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.
As of Composer 2.5 scripts can also be Symfony Console Command classes,
which allows you to easily run them including passing options. This is
however not recommended for handling events.
> **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.
@ -94,8 +98,8 @@ For any given event:
- 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 executable commands.
- PHP classes containing defined callbacks must be autoloadable via Composer's
autoload functionality.
- PHP classes and commands containing defined callbacks must be autoloadable
via Composer's autoload functionality.
- Callbacks can only autoload classes from psr-0, psr-4 and classmap
definitions. If a defined callback relies on functions defined outside of a
class, the callback itself is responsible for loading the file containing these
@ -217,7 +221,9 @@ running `composer test`:
```json
{
"scripts": {
"test": "phpunit"
"test": "phpunit",
"do-something": "MyVendor\\MyClass::doSomething"
"my-cmd": "MyVendor\\MyCommand"
}
}
```
@ -226,11 +232,63 @@ Similar to the `run-script` command you can give additional arguments to scripts
e.g. `composer test -- --filter <pattern>` will pass `--filter <pattern>` along
to the `phpunit` script.
Using a PHP method via `composer do-something arg` lets you execute a
`static function doSomething(\Composer\Script\Event $event)` and `arg` becomes
available in `$event->getArguments()`. This however does not let you easily pass
custom options in the form of `--flags`.
Using a [symfony/console](https://packagist.org/packages/symfony/console) `Command`
class you can define and access arguments and options more easily.
For example with the command below you can then simply call `composer my-cmd
--arbitrary-flag` without even the need for a `--` separator. To be detected
as symfony/console commands the class name must end with `Command` and extend
symfony's `Command` class. Also note that this will run using Composer's built-in
symfony/console version which may not match the one you have required in your
project, and may change between Composer minor releases. If you need more
safety guarantees you should rather use your own binary file that runs your own
symfony/console version in isolation in its own process then.
```php
<?php
namespace MyVendor;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class MyCommand extends Command
{
protected function configure(): void
{
$this->setDefinition([
new InputOption('arbitrary-flag', null, InputOption::VALUE_NONE, 'Example flag'),
new InputArgument('foo', InputArgument::OPTIONAL, 'Optional arg'),
]);
}
public function execute(InputInterface $input, OutputInterface $output): int
{
if ($input->getOption('arbitrary-flag')) {
$output->writeln('The flag was used')
}
return 0;
}
}
```
> **Note:** Before executing scripts, Composer's bin-dir is temporarily pushed
> on top of the PATH environment variable so that binaries of dependencies
> are directly accessible. In this example no matter if the `phpunit` binary is
> actually in `vendor/bin/phpunit` or `bin/phpunit` it will be found and executed.
## Managing the process timeout
Although Composer is not intended to manage long-running processes and other
such aspects of PHP projects, it can sometimes be handy to disable the process
timeout on custom commands. This timeout defaults to 300 seconds and can be

View File

@ -12,6 +12,7 @@
namespace Composer\Command;
use Composer\Pcre\Preg;
use Symfony\Component\Console\Input\InputInterface;
use Composer\Console\Input\InputOption;
use Composer\Console\Input\InputArgument;
@ -32,6 +33,8 @@ class ScriptAliasCommand extends BaseCommand
$this->script = $script;
$this->description = $description ?? 'Runs the '.$script.' script as defined in composer.json';
$this->ignoreValidationErrors();
parent::__construct();
}
@ -63,6 +66,11 @@ EOT
$args = $input->getArguments();
return $composer->getEventDispatcher()->dispatchScript($this->script, $input->getOption('dev') || !$input->getOption('no-dev'), $args['args']);
// TODO remove for Symfony 6+ as it is then in the interface
if (!method_exists($input, '__toString')) { // @phpstan-ignore-line
throw new \LogicException('Expected an Input instance that is stringable, got '.get_class($input));
}
return $composer->getEventDispatcher()->dispatchScript($this->script, $input->getOption('dev') || !$input->getOption('no-dev'), $args['args'], ['script-alias-input' => Preg::replace('{^\S+ ?}', '', $input->__toString(), 1)]);
}
}

View File

@ -14,6 +14,8 @@ namespace Composer\EventDispatcher;
use Composer\DependencyResolver\Transaction;
use Composer\Installer\InstallerEvent;
use Composer\IO\BufferIO;
use Composer\IO\ConsoleIO;
use Composer\IO\IOInterface;
use Composer\Composer;
use Composer\PartialComposer;
@ -27,6 +29,10 @@ use Composer\Installer\BinaryInstaller;
use Composer\Util\ProcessExecutor;
use Composer\Script\Event as ScriptEvent;
use Composer\Autoload\ClassLoader;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\StringInput;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\ExecutableFinder;
@ -207,6 +213,11 @@ class EventDispatcher
$args = array_merge($script, $event->getArguments());
$flags = $event->getFlags();
if (isset($flags['script-alias-input'])) {
$argsString = implode(' ', array_map(static function ($arg) { return ProcessExecutor::escape($arg); }, $script));
$flags['script-alias-input'] = $argsString . ' ' . $flags['script-alias-input'];
unset($argsString);
}
if (strpos($callable, '@composer ') === 0) {
$exec = $this->getPhpExecCommand() . ' ' . ProcessExecutor::escape(Platform::getEnv('COMPOSER_BINARY')) . ' ' . implode(' ', $args);
if (0 !== ($exitCode = $this->executeTty($exec))) {
@ -249,6 +260,46 @@ class EventDispatcher
$this->io->writeError('<error>'.sprintf($message, $callable, $event->getName()).'</error>', true, IOInterface::QUIET);
throw $e;
}
} elseif ($this->isCommandClass($callable)) {
$className = $callable;
if (!class_exists($className)) {
$this->io->writeError('<warning>Class '.$className.' is not autoloadable, can not call '.$event->getName().' script</warning>', true, IOInterface::QUIET);
continue;
}
if (!is_a($className, Command::class, true)) {
$this->io->writeError('<warning>Class '.$className.' does not extend '.Command::class.', can not call '.$event->getName().' script</warning>', true, IOInterface::QUIET);
continue;
}
if (defined('Composer\Script\ScriptEvents::'.str_replace('-', '_', strtoupper($event->getName())))) {
$this->io->writeError('<warning>You cannot bind '.$event->getName().' to a Command class, use a non-reserved name</warning>', true, IOInterface::QUIET);
continue;
}
$app = new Application();
$app->setCatchExceptions(false);
$app->setAutoExit(false);
$cmd = new $className($event->getName());
$app->add($cmd);
$app->setDefaultCommand((string) $cmd->getName(), true);
try {
$args = implode(' ', array_map(static function ($arg) { return ProcessExecutor::escape($arg); }, $event->getArguments()));
// reusing the output from $this->io is mostly needed for tests, but generally speaking
// it does not hurt to keep the same stream as the current Application
if ($this->io instanceof ConsoleIO) {
$reflProp = new \ReflectionProperty($this->io, 'output');
if (PHP_VERSION_ID < 80100) {
$reflProp->setAccessible(true);
}
$output = $reflProp->getValue($this->io);
} else {
$output = new ConsoleOutput();
}
$return = $app->run(new StringInput($event->getFlags()['script-alias-input'] ?? $args), $output);
} catch (\Exception $e) {
$message = "Script %s handling the %s event terminated with an exception";
$this->io->writeError('<error>'.sprintf($message, $callable, $event->getName()).'</error>', true, IOInterface::QUIET);
throw $e;
}
} else {
$args = implode(' ', array_map(['Composer\Util\ProcessExecutor', 'escape'], $event->getArguments()));
@ -507,6 +558,14 @@ class EventDispatcher
return false === strpos($callable, ' ') && false !== strpos($callable, '::');
}
/**
* Checks if string given references a command class
*/
protected function isCommandClass(string $callable): bool
{
return str_contains($callable, '\\') && !str_contains($callable, ' ') && str_ends_with($callable, 'Command');
}
/**
* Checks if string given references a composer run-script
*/

View File

@ -109,6 +109,78 @@ class RunScriptCommandTest extends TestCase
$this->assertStringContainsString('Run the codestyle fixer', $output, 'The custom description for the fix-cs script should be printed');
}
public function testExecutionOfCustomSymfonyCommand(): void
{
$this->initTempComposer([
'scripts' => [
'test-direct' => 'Test\\MyCommand',
'test-ref' => ['@test-direct --inneropt innerarg'],
],
'autoload' => [
'psr-4' => [
'Test\\' => '',
],
],
]);
file_put_contents('MyCommand.php', <<<'TEST'
<?php
namespace Test;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Command\Command;
class MyCommand extends Command
{
protected function configure(): void
{
$this->setDefinition([
new InputArgument('req-arg', InputArgument::REQUIRED, 'Required arg.'),
new InputArgument('opt-arg', InputArgument::OPTIONAL, 'Optional arg.'),
new InputOption('inneropt', null, InputOption::VALUE_NONE, 'Option.'),
new InputOption('outeropt', null, InputOption::VALUE_OPTIONAL, 'Optional option.'),
]);
}
public function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln($input->getArgument('req-arg'));
$output->writeln((string) $input->getArgument('opt-arg'));
$output->writeln('inneropt: '.($input->getOption('inneropt') ? 'set' : 'unset'));
$output->writeln('outeropt: '.($input->getOption('outeropt') ? 'set' : 'unset'));
return 2;
}
}
TEST
);
$appTester = $this->getApplicationTester();
$appTester->run(['command' => 'test-direct', '--outeropt' => true, 'req-arg' => 'lala']);
self::assertSame('lala
inneropt: unset
outeropt: set
', $appTester->getDisplay(true));
self::assertSame(2, $appTester->getStatusCode());
$appTester = $this->getApplicationTester();
$appTester->run(['command' => 'test-ref', '--outeropt' => true, 'req-arg' => 'lala']);
self::assertSame('innerarg
lala
inneropt: set
outeropt: set
', $appTester->getDisplay(true));
self::assertSame(2, $appTester->getStatusCode());
}
/** @return bool[][] **/
public function getDevOptions(): array
{