Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Console] Add support for invokable commands and input attributes #59340

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

Merged
merged 1 commit into from
Jan 10, 2025
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
use Symfony\Component\Config\Resource\FileResource;
use Symfony\Component\Config\ResourceCheckerInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\DataCollector\CommandDataCollector;
use Symfony\Component\Console\Debug\CliRequest;
Expand Down Expand Up @@ -608,6 +609,9 @@ public function load(array $configs, ContainerBuilder $container): void
->addTag('assets.package');
$container->registerForAutoconfiguration(AssetCompilerInterface::class)
->addTag('asset_mapper.compiler');
$container->registerAttributeForAutoconfiguration(AsCommand::class, static function (ChildDefinition $definition, AsCommand $attribute, \ReflectionClass $reflector): void {
$definition->addTag('console.command', ['command' => $attribute->name, 'description' => $attribute->description ?? $reflector->getName()]);
});
$container->registerForAutoconfiguration(Command::class)
->addTag('console.command');
$container->registerForAutoconfiguration(ResourceCheckerInterface::class)
Expand Down
104 changes: 104 additions & 0 deletions src/Symfony/Component/Console/Attribute/Argument.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Console\Attribute;

use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\Suggestion;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;

#[\Attribute(\Attribute::TARGET_PARAMETER)]
class Argument
{
private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array'];

private ?int $mode = null;

/**
* Represents a console command <argument> definition.
*
* If unset, the `name` and `default` values will be inferred from the parameter definition.
*
* @param string|bool|int|float|array|null $default The default value (for InputArgument::OPTIONAL mode only)
* @param array|callable-string(CompletionInput):list<string|Suggestion> $suggestedValues The values used for input completion
*/
public function __construct(
public string $name = '',
public string $description = '',
public string|bool|int|float|array|null $default = null,
public array|string $suggestedValues = [],
) {
if (\is_string($suggestedValues) && !\is_callable($suggestedValues)) {
throw new \TypeError(\sprintf('Argument 4 passed to "%s()" must be either an array or a callable-string.', __METHOD__));
}
}

/**
* @internal
*/
public static function tryFrom(\ReflectionParameter $parameter): ?self
{
/** @var self $self */
if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) {
return null;
}

$type = $parameter->getType();
$name = $parameter->getName();

if (!$type instanceof \ReflectionNamedType) {
throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped, Union or Intersection types are not supported for command arguments.', $name));
}

$parameterTypeName = $type->getName();

if (!\in_array($parameterTypeName, self::ALLOWED_TYPES, true)) {
throw new LogicException(\sprintf('The type "%s" of parameter "$%s" is not supported as a command argument. Only "%s" types are allowed.', $parameterTypeName, $name, implode('", "', self::ALLOWED_TYPES)));
}

if (!$self->name) {
$self->name = $name;
}

$self->mode = null !== $self->default || $parameter->isDefaultValueAvailable() ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
if ('array' === $parameterTypeName) {
$self->mode |= InputArgument::IS_ARRAY;
}

$self->default ??= $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null;

if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $parameter->getDeclaringFunction()->getClosureThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) {
$self->suggestedValues = [$instance, $self->suggestedValues[1]];
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[note for reviewer] this falls back from the "static class method call" syntax to the "object method call" syntax due to the impossibility of passing a \Closure or callable in the attribute constructor. Allowing this suggestion methods to access the instance's dependencies.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For instance, this will allow us to configure #[Argument(suggestedValues: [self::class, 'getPermissions'])] where getPermissions is not defined as static method, enabling it to access any instance dependencies and dynamically retrieve all available permissions from another service (e.g. entity manager)


return $self;
}

/**
* @internal
*/
public function toInputArgument(): InputArgument
{
$suggestedValues = \is_callable($this->suggestedValues) ? ($this->suggestedValues)(...) : $this->suggestedValues;

return new InputArgument($this->name, $this->mode, $this->description, $this->default, $suggestedValues);
}

/**
* @internal
*/
public function resolveValue(InputInterface $input): mixed
{
return $input->hasArgument($this->name) ? $input->getArgument($this->name) : null;
}
}
119 changes: 119 additions & 0 deletions src/Symfony/Component/Console/Attribute/Option.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Console\Attribute;

use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\Suggestion;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;

#[\Attribute(\Attribute::TARGET_PARAMETER)]
class Option
{
private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array'];

private ?int $mode = null;
private string $typeName = '';

/**
* Represents a console command --option definition.
*
* If unset, the `name` and `default` values will be inferred from the parameter definition.
*
* @param array|string|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts
* @param scalar|array|null $default The default value (must be null for self::VALUE_NONE)
* @param array|callable-string(CompletionInput):list<string|Suggestion> $suggestedValues The values used for input completion
*/
public function __construct(
public string $name = '',
public array|string|null $shortcut = null,
public string $description = '',
public string|bool|int|float|array|null $default = null,
public array|string $suggestedValues = [],
) {
if (\is_string($suggestedValues) && !\is_callable($suggestedValues)) {
throw new \TypeError(\sprintf('Argument 5 passed to "%s()" must be either an array or a callable-string.', __METHOD__));
}
}

/**
* @internal
*/
public static function tryFrom(\ReflectionParameter $parameter): ?self
{
/** @var self $self */
if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) {
return null;
}

$type = $parameter->getType();
$name = $parameter->getName();

if (!$type instanceof \ReflectionNamedType) {
throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped, Union or Intersection types are not supported for command options.', $name));
}

$self->typeName = $type->getName();

if (!\in_array($self->typeName, self::ALLOWED_TYPES, true)) {
throw new LogicException(\sprintf('The type "%s" of parameter "$%s" is not supported as a command option. Only "%s" types are allowed.', $self->typeName, $name, implode('", "', self::ALLOWED_TYPES)));
}

if (!$self->name) {
$self->name = $name;
}

if ('bool' === $self->typeName) {
$self->mode = InputOption::VALUE_NONE | InputOption::VALUE_NEGATABLE;
} else {
$self->mode = null !== $self->default || $parameter->isDefaultValueAvailable() ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED;
if ('array' === $self->typeName) {
$self->mode |= InputOption::VALUE_IS_ARRAY;
}
}

if (InputOption::VALUE_NONE === (InputOption::VALUE_NONE & $self->mode)) {
$self->default = null;
} else {
$self->default ??= $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null;
}

if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $parameter->getDeclaringFunction()->getClosureThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) {
$self->suggestedValues = [$instance, $self->suggestedValues[1]];
}

return $self;
}

/**
* @internal
*/
public function toInputOption(): InputOption
{
$suggestedValues = \is_callable($this->suggestedValues) ? ($this->suggestedValues)(...) : $this->suggestedValues;

return new InputOption($this->name, $this->shortcut, $this->mode, $this->description, $this->default, $suggestedValues);
}

/**
* @internal
*/
public function resolveValue(InputInterface $input): mixed
{
if ('bool' === $this->typeName) {
return $input->hasOption($this->name) && null !== $input->getOption($this->name) ? $input->getOption($this->name) : ($this->default ?? false);
}

return $input->hasOption($this->name) ? $input->getOption($this->name) : null;
}
}
6 changes: 6 additions & 0 deletions src/Symfony/Component/Console/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
CHANGELOG
=========

7.3
---

* Add support for invokable commands
* Add `#[Argument]` and `#[Option]` attributes to define input arguments and options for invokable commands

7.2
---

Expand Down
21 changes: 14 additions & 7 deletions src/Symfony/Component/Console/Command/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
private string $description = '';
private ?InputDefinition $fullDefinition = null;
private bool $ignoreValidationErrors = false;
private ?\Closure $code = null;
private ?InvokableCommand $code = null;
private array $synopsis = [];
private array $usages = [];
private ?HelperSet $helperSet = null;
Expand Down Expand Up @@ -164,6 +164,9 @@
*/
protected function configure()
{
if (!$this->code && \is_callable($this)) {
$this->code = new InvokableCommand($this, $this(...));

Check failure on line 168 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:168:13: InvalidPropertyAssignment: $this with non-object type 'never' cannot treated as an object (see https://psalm.dev/010)

Check failure on line 168 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:168:48: NoValue: All possible types for this argument were invalidated - This may be dead code (see https://psalm.dev/179)

Check failure on line 168 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:168:55: InvalidFunctionCall: Cannot treat type never as callable (see https://psalm.dev/064)

Check failure on line 168 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:168:13: InvalidPropertyAssignment: $this with non-object type 'never' cannot treated as an object (see https://psalm.dev/010)

Check failure on line 168 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:168:48: NoValue: All possible types for this argument were invalidated - This may be dead code (see https://psalm.dev/179)

Check failure on line 168 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:168:55: InvalidFunctionCall: Cannot treat type never as callable (see https://psalm.dev/064)
Copy link
Member

@GromNaN GromNaN Jan 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The psalm errors seems incorrect. It can be added to the baseline.

Error: src/Symfony/Component/Console/Command/Command.php:168:13: InvalidPropertyAssignment: $this with non-object type 'never' cannot treated as an object (see https://psalm.dev/010)
Error: src/Symfony/Component/Console/Command/Command.php:168:48: NoValue: All possible types for this argument were invalidated - This may be dead code (see https://psalm.dev/179)
Error: src/Symfony/Component/Console/Command/Command.php:168:55: InvalidFunctionCall: Cannot treat type never as callable (see https://psalm.dev/064)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This downside of this is we're now creating a self-referencing class. Might not be an issue in practice since we won't create several instances of the command object, but still worth to have in mind and prevent writing such code in the generic case.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Completely agree here! I'm wondering if there is an alternative solution for this case…

}
}

/**
Expand Down Expand Up @@ -274,12 +277,10 @@
$input->validate();

if ($this->code) {
$statusCode = ($this->code)($input, $output);
} else {
$statusCode = $this->execute($input, $output);
return ($this->code)($input, $output);
}

return is_numeric($statusCode) ? (int) $statusCode : 0;
return $this->execute($input, $output);
}

/**
Expand Down Expand Up @@ -327,7 +328,7 @@
$code = $code(...);
}

$this->code = $code;
$this->code = new InvokableCommand($this, $code);

return $this;
}
Expand Down Expand Up @@ -395,7 +396,13 @@
*/
public function getNativeDefinition(): InputDefinition
{
return $this->definition ?? throw new LogicException(\sprintf('Command class "%s" is not correctly initialized. You probably forgot to call the parent constructor.', static::class));
$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()) {
$this->code->configure($definition);
}

return $definition;
}

/**
Expand Down
Loading
Loading