Skip to content

[Console] Command simplification and deprecations #59564

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions UPGRADE-7.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ Console
});
```

* Static methods `Command::getDefaultName()` and `Command::getDefaultDescription()` are deprecated.
Extract the command name and description through class reflection instead

FrameworkBundle
---------------

Expand Down
2 changes: 2 additions & 0 deletions src/Symfony/Component/Console/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ CHANGELOG
* Add support for invokable commands and add `#[Argument]` and `#[Option]` attributes to define input arguments and options
* Deprecate not declaring the parameter type in callable commands defined through `setCode` method
* Add support for help definition via `AsCommand` attribute
* Delay command initialization and configuration
* Deprecate static methods `Command::getDefaultName()` and `Command::getDefaultDescription()`

7.2
---
Expand Down
106 changes: 76 additions & 30 deletions src/Symfony/Component/Console/Command/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,29 @@
private array $synopsis = [];
private array $usages = [];
private ?HelperSet $helperSet = null;
private bool $initialized = false;

/**
* @deprecated since Symfony 7.3
*/
public static function getDefaultName(): ?string
{
trigger_deprecation('symfony/console', '7.3', 'The static method "%s()" is deprecated and will be removed in Symfony 8.0, extract the command name from the "%s" attribute instead.', __METHOD__, AsCommand::class);

if ($attribute = (new \ReflectionClass(static::class))->getAttributes(AsCommand::class)) {
return $attribute[0]->newInstance()->name;
}

return null;
}

/**
* @deprecated since Symfony 7.3
*/
public static function getDefaultDescription(): ?string
{
trigger_deprecation('symfony/console', '7.3', 'The static method "%s()" is deprecated and will be removed in Symfony 8.0, extract the command description from the "%s" attribute instead.', __METHOD__, AsCommand::class);

if ($attribute = (new \ReflectionClass(static::class))->getAttributes(AsCommand::class)) {
return $attribute[0]->newInstance()->description;
}
Expand All @@ -79,36 +90,7 @@
*/
public function __construct(?string $name = null)
{
$this->definition = new InputDefinition();

if (null === $name && null !== $name = static::getDefaultName()) {
$aliases = explode('|', $name);

if ('' === $name = array_shift($aliases)) {
$this->setHidden(true);
$name = array_shift($aliases);
}

$this->setAliases($aliases);
}

if (null !== $name) {
$this->setName($name);
}

if ('' === $this->description) {
$this->setDescription(static::getDefaultDescription() ?? '');
}

if ('' === $this->help && $attributes = (new \ReflectionClass(static::class))->getAttributes(AsCommand::class)) {
$this->setHelp($attributes[0]->newInstance()->help ?? '');
}

if (\is_callable($this)) {
$this->code = new InvokableCommand($this, $this(...));
}

$this->configure();
$this->init($name);
}

/**
Expand Down Expand Up @@ -333,6 +315,8 @@
*/
public function mergeApplicationDefinition(bool $mergeArgs = true): void
{
$this->init();

if (null === $this->application) {
return;
}
Expand All @@ -356,6 +340,8 @@
*/
public function setDefinition(array|InputDefinition $definition): static
{
$this->init();

if ($definition instanceof InputDefinition) {
$this->definition = $definition;
} else {
Expand Down Expand Up @@ -385,6 +371,8 @@
*/
public function getNativeDefinition(): InputDefinition
{
$this->init();

$definition = $this->definition ?? throw new LogicException(\sprintf('Command class "%s" is not correctly initialized. You probably forgot to call the parent constructor.', static::class));

if ($this->code && !$definition->getArguments() && !$definition->getOptions()) {
Expand All @@ -407,6 +395,8 @@
*/
public function addArgument(string $name, ?int $mode = null, string $description = '', mixed $default = null, array|\Closure $suggestedValues = []): static
{
$this->init();

$this->definition->addArgument(new InputArgument($name, $mode, $description, $default, $suggestedValues));
$this->fullDefinition?->addArgument(new InputArgument($name, $mode, $description, $default, $suggestedValues));

Expand All @@ -427,6 +417,8 @@
*/
public function addOption(string $name, string|array|null $shortcut = null, ?int $mode = null, string $description = '', mixed $default = null, array|\Closure $suggestedValues = []): static
{
$this->init();

$this->definition->addOption(new InputOption($name, $shortcut, $mode, $description, $default, $suggestedValues));
$this->fullDefinition?->addOption(new InputOption($name, $shortcut, $mode, $description, $default, $suggestedValues));

Expand Down Expand Up @@ -474,6 +466,8 @@
*/
public function getName(): ?string
{
$this->init();

return $this->name;
}

Expand All @@ -494,6 +488,8 @@
*/
public function isHidden(): bool
{
$this->init();

return $this->hidden;
}

Expand All @@ -514,6 +510,8 @@
*/
public function getDescription(): string
{
$this->init();

return $this->description;
}

Expand All @@ -534,6 +532,8 @@
*/
public function getHelp(): string
{
$this->init();

return $this->help;
}

Expand Down Expand Up @@ -586,6 +586,8 @@
*/
public function getAliases(): array
{
$this->init();

return $this->aliases;
}

Expand All @@ -596,6 +598,8 @@
*/
public function getSynopsis(bool $short = false): string
{
$this->init();

$key = $short ? 'short' : 'long';

if (!isset($this->synopsis[$key])) {
Expand Down Expand Up @@ -644,6 +648,48 @@
return $this->helperSet->get($name);
}

private function init(?string $name = null): void
{
if ($this->initialized) {
return;
}

$this->definition = new InputDefinition();

$attribute = ((new \ReflectionClass(static::class))->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();

if (null === $name && null !== $name = $attribute?->name) {
$aliases = explode('|', $name);

if ('' === $name = array_shift($aliases)) {
$this->setHidden(true);
$name = array_shift($aliases);
}

$this->setAliases($aliases);
}

if (null !== $name) {
$this->setName($name);
}

if ('' === $this->description && $attribute?->description) {
$this->setDescription($attribute->description);
}

if ('' === $this->help && $attribute?->help) {
$this->setHelp($attribute->help);
}

if (\is_callable($this)) {
$this->code = new InvokableCommand($this, $this(...));

Check failure on line 685 in src/Symfony/Component/Console/Command/Command.php

View workflow job for this annotation

GitHub Actions / Psalm

InvalidPropertyAssignment

src/Symfony/Component/Console/Command/Command.php:685:13: InvalidPropertyAssignment: $this with non-object type 'never' cannot treated as an object (see https://psalm.dev/010)

Check failure on line 685 in src/Symfony/Component/Console/Command/Command.php

View workflow job for this annotation

GitHub Actions / Psalm

NoValue

src/Symfony/Component/Console/Command/Command.php:685:48: NoValue: All possible types for this argument were invalidated - This may be dead code (see https://psalm.dev/179)

Check failure on line 685 in src/Symfony/Component/Console/Command/Command.php

View workflow job for this annotation

GitHub Actions / Psalm

InvalidFunctionCall

src/Symfony/Component/Console/Command/Command.php:685:55: InvalidFunctionCall: Cannot treat type never as callable (see https://psalm.dev/064)

Check failure on line 685 in src/Symfony/Component/Console/Command/Command.php

View workflow job for this annotation

GitHub Actions / Psalm

InvalidPropertyAssignment

src/Symfony/Component/Console/Command/Command.php:685:13: InvalidPropertyAssignment: $this with non-object type 'never' cannot treated as an object (see https://psalm.dev/010)

Check failure on line 685 in src/Symfony/Component/Console/Command/Command.php

View workflow job for this annotation

GitHub Actions / Psalm

NoValue

src/Symfony/Component/Console/Command/Command.php:685:48: NoValue: All possible types for this argument were invalidated - This may be dead code (see https://psalm.dev/179)

Check failure on line 685 in src/Symfony/Component/Console/Command/Command.php

View workflow job for this annotation

GitHub Actions / Psalm

InvalidFunctionCall

src/Symfony/Component/Console/Command/Command.php:685:55: InvalidFunctionCall: Cannot treat type never as callable (see https://psalm.dev/064)
}

$this->initialized = true;

$this->configure();
}

/**
* Validates a command name.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Symfony\Component\Console\DependencyInjection;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\LazyCommand;
use Symfony\Component\Console\CommandLoader\ContainerCommandLoader;
Expand Down Expand Up @@ -57,7 +58,10 @@ public function process(ContainerBuilder $container): void
$invokableRef = null;
}

$aliases = $tags[0]['command'] ?? str_replace('%', '%%', $class::getDefaultName() ?? '');
/** @var AsCommand|null $attribute */
$attribute = ($r->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();

$aliases = str_replace('%', '%%', $tags[0]['command'] ?? $attribute?->name ?? '');
$aliases = explode('|', $aliases);
$commandName = array_shift($aliases);

Expand Down Expand Up @@ -111,10 +115,10 @@ public function process(ContainerBuilder $container): void
$definition->addMethodCall('setHelp', [str_replace('%', '%%', $help)]);
}

$description ??= str_replace('%', '%%', $class::getDefaultDescription() ?? '');
$description ??= $attribute?->description ?? '';

if ($description) {
$definition->addMethodCall('setDescription', [$description]);
$definition->addMethodCall('setDescription', [str_replace('%', '%%', $description)]);

$container->register('.'.$id.'.lazy', LazyCommand::class)
->setArguments([$commandName, $aliases, $description, $isHidden, new ServiceClosureArgument($lazyCommandRefs[$id])]);
Expand Down
6 changes: 4 additions & 2 deletions src/Symfony/Component/Console/Tests/ApplicationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ public function testAdd()
public function testAddCommandWithEmptyConstructor()
{
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('Command class "Foo5Command" is not correctly initialized. You probably forgot to call the parent constructor.');
$this->expectExceptionMessage('The command defined in "Foo5Command" cannot have an empty name.');

(new Application())->add(new \Foo5Command());
}
Expand Down Expand Up @@ -2404,7 +2404,9 @@ private function createSignalableApplication(Command $command, ?EventDispatcherI
if ($dispatcher) {
$application->setDispatcher($dispatcher);
}
$application->add(new LazyCommand($command::getDefaultName(), [], '', false, fn () => $command, true));
/** @var AsCommand $attribute */
$attribute = ((new \ReflectionClass($command))->getAttributes(AsCommand::class)[0] ?? null)?->newInstance();
$application->add(new LazyCommand($attribute->name, [], '', false, fn () => $command, true));

return $application;
}
Expand Down
28 changes: 20 additions & 8 deletions src/Symfony/Component/Console/Tests/Command/CommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -427,9 +427,6 @@ public function testSetCodeWithStaticAnonymousFunction()

public function testCommandAttribute()
{
$this->assertSame('|foo|f', Php8Command::getDefaultName());
$this->assertSame('desc', Php8Command::getDefaultDescription());

$command = new Php8Command();

$this->assertSame('foo', $command->getName());
Expand All @@ -439,26 +436,41 @@ public function testCommandAttribute()
$this->assertSame(['f'], $command->getAliases());
}

public function testAttributeOverridesProperty()
/**
* @group legacy
*/
public function testCommandAttributeWithDeprecatedMethods()
{
$this->assertSame('my:command', MyAnnotatedCommand::getDefaultName());
$this->assertSame('This is a command I wrote all by myself', MyAnnotatedCommand::getDefaultDescription());
$this->assertSame('|foo|f', Php8Command::getDefaultName());
$this->assertSame('desc', Php8Command::getDefaultDescription());
}

public function testAttributeOverridesProperty()
{
$command = new MyAnnotatedCommand();

$this->assertSame('my:command', $command->getName());
$this->assertSame('This is a command I wrote all by myself', $command->getDescription());
}

/**
* @group legacy
*/
public function testAttributeOverridesPropertyWithDeprecatedMethods()
{
$this->assertSame('my:command', MyAnnotatedCommand::getDefaultName());
$this->assertSame('This is a command I wrote all by myself', MyAnnotatedCommand::getDefaultDescription());
}

public function testDefaultCommand()
{
$apl = new Application();
$apl->setDefaultCommand(Php8Command::getDefaultName());
$apl->setDefaultCommand('foo');
$property = new \ReflectionProperty($apl, 'defaultCommand');

$this->assertEquals('foo', $property->getValue($apl));

$apl->setDefaultCommand(Php8Command2::getDefaultName());
$apl->setDefaultCommand('foo2');
$property = new \ReflectionProperty($apl, 'defaultCommand');

$this->assertEquals('foo2', $property->getValue($apl));
Expand Down
Loading