Skip to content

Commit

Permalink
Merge pull request #218 from totten/master-cmd-ref
Browse files Browse the repository at this point in the history
Simplify `Command` boilerplate for typical use-cases
  • Loading branch information
totten authored Sep 24, 2024
2 parents 5a147e9 + 149bb4a commit fa71612
Show file tree
Hide file tree
Showing 57 changed files with 591 additions and 412 deletions.
18 changes: 10 additions & 8 deletions doc/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ Cv plugins are PHP files which register event listeners.
```php
// FILE: /etc/cv/plugin/hello-command.php
use Civi\Cv\Cv;
use Civi\Cv\Command\CvCommand;
use CvDeps\Symfony\Component\Console\Input\InputInterface;
use CvDeps\Symfony\Component\Console\Input\InputArgument;
use CvDeps\Symfony\Component\Console\Output\OutputInterface;
use CvDeps\Symfony\Component\Console\Command\Command;

Expand All @@ -16,15 +18,15 @@ if (empty($CV_PLUGIN['protocol']) || $CV_PLUGIN['protocol'] > 1) {
}

Cv::dispatcher()->addListener('cv.app.commands', function($e) {
$e['commands'][] = new class extends Command {
protected function configure() {
$this->setName('hello')->setDescription('Say a greeting');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$output->writeln('Hello there!');

$e['commands'][] = (new CvCommand('hello'))
->setDescription('Say a greeting')
->addArgument('name', InputArgument::REQUIRED, 'Name of the person to greet')
->setCode(function($input, $output) {
$output->writeln('Hello, ' . $input->getArgument('name'));
return 0;
}
};
});

});
```

Expand Down
18 changes: 10 additions & 8 deletions lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,13 @@ For more info about `$options`, see the docblocks.

## Experimental API

Other classes are included, but their contracts are subject to change.

A particularly interesting one is `BootTrait`. This requires `symfony/console`, and it is used by most `cv` subcommands
to achieve common behaviors:

1. `BootTrait` defines certain CLI options (`--level`, `--user`, `--hostname`, etc).
2. `BootTrait` automatically decides between `Bootstrap.php` and `CmsBootstrap.php`.
3. `BootTrait` passes CLI options through to `Bootstrap.php` or `CmsBootstrap.php`.
Other classes are included, but their contracts are subject to change. These
include higher-level helpers for building Symfony Console apps that incorporate
Civi bootstrap behaviors.

* `BootTrait` has previously suggested as an experimentally available API
(circa v0.3.44). It changed significantly (circa v0.3.56), where
`configureBootOptions()` was replaced by `$bootOptions`, `mergeDefaultBootDefinition()`,
and `mergeBootDefinition()`.
* As an alternative, consider the classes `BaseApplication` and `CvCommand` if you aim
to build a tool using Symfony Console and Cv Lib.
12 changes: 10 additions & 2 deletions lib/src/BaseApplication.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
namespace Civi\Cv;

use Civi\Cv\Util\AliasFilter;
use Civi\Cv\Util\BootTrait;
use Civi\Cv\Util\CvArgvInput;
use LesserEvil\ShellVerbosityIsEvil;
use Symfony\Component\Console\Input\InputInterface;
Expand All @@ -24,6 +25,8 @@ public static function main(string $name, ?string $binDir, array $argv) {

try {
$application = new static($name);
Cv::ioStack()->replace('app', $application);
$application->configure();
$argv = AliasFilter::filter($argv);
$result = $application->run(new CvArgvInput($argv), Cv::ioStack()->current('output'));
}
Expand All @@ -38,8 +41,7 @@ public static function main(string $name, ?string $binDir, array $argv) {
exit($result);
}

public function __construct($name = 'UNKNOWN', $version = 'UNKNOWN') {
parent::__construct($name, $version);
public function configure() {
$this->setCatchExceptions(TRUE);
$this->setAutoExit(FALSE);

Expand All @@ -66,6 +68,12 @@ protected function getDefaultInputDefinition() {
$definition = parent::getDefaultInputDefinition();
$definition->addOption(new InputOption('cwd', NULL, InputOption::VALUE_REQUIRED, 'If specified, use the given directory as working directory.'));
$definition->addOption(new InputOption('site-alias', NULL, InputOption::VALUE_REQUIRED, 'Load site connection data based on its alias'));

$c = new class() {
use BootTrait;
};
$c->mergeDefaultBootDefinition($definition);

return $definition;
}

Expand Down
38 changes: 38 additions & 0 deletions lib/src/Command/CvCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php
namespace Civi\Cv\Command;

use Civi\Cv\Util\BootTrait;
use Civi\Cv\Util\OptionCallbackTrait;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

/**
* `CvCommand` is a Symfony `Command` with support for bootstrapping CiviCRM/CMS.
*
* - From end-user POV, the command accepts options like --user, --level, --hostname.
* - From dev POV, the command allows you to implement `execute()` method without needing to
* explicitly boot Civi.
* - From dev POV, you may fine-tune command by changing the $bootOptions / getBootOptions().
*/
class CvCommand extends Command {

use OptionCallbackTrait;
use BootTrait;

public function mergeApplicationDefinition($mergeArgs = TRUE) {
parent::mergeApplicationDefinition($mergeArgs);
$this->mergeBootDefinition($this->getDefinition());
}

/**
* @param \Symfony\Component\Console\Input\InputInterface $input
* @param \Symfony\Component\Console\Output\OutputInterface $output
*/
protected function initialize(InputInterface $input, OutputInterface $output) {
$this->autoboot($input, $output);
parent::initialize($input, $output);
$this->runOptionCallbacks($input, $output);
}

}
7 changes: 7 additions & 0 deletions lib/src/Cv.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ public static function ioStack(): IOStack {
return static::$instances[__FUNCTION__];
}

/**
* @return \CvDeps\Symfony\Component\Console\Application|\Symfony\Component\Console\Application
*/
public static function app() {
return static::ioStack()->current('app');
}

/**
* @return \CvDeps\Symfony\Component\Console\Input\InputInterface|\Symfony\Component\Console\Input\InputInterface
*/
Expand Down
96 changes: 91 additions & 5 deletions lib/src/Util/BootTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,70 @@
*/
trait BootTrait {

public function configureBootOptions($defaultLevel = 'full|cms-full') {
$this->addOption('level', NULL, InputOption::VALUE_REQUIRED, 'Bootstrap level (none,classloader,settings,full,cms-only,cms-full)', $defaultLevel);
$this->addOption('hostname', NULL, InputOption::VALUE_REQUIRED, 'Hostname (for a multisite system)');
$this->addOption('test', 't', InputOption::VALUE_NONE, 'Bootstrap the test database (CIVICRM_UF=UnitTests)');
$this->addOption('user', 'U', InputOption::VALUE_REQUIRED, 'CMS user');
/**
* Describe the expected bootstrap behaviors for this command.
*
* - For most commands, you will want to automatically boot CiviCRM/CMS.
* The default implementation will do this.
* - For some special commands (e.g. core-installer or PHP-script-runner), you may
* want more fine-grained control over when/how the system boots.
*
* @var array
*/
protected $bootOptions = [
// Whether to automatically boot Civi during `initialize()` phase.
'auto' => TRUE,

// Default boot level.
'default' => 'full|cms-full',

// List of all boot levels that are allowed in this command.
'allow' => ['full|cms-full', 'full', 'cms-full', 'settings', 'classloader', 'cms-only', 'none'],
];

/**
* @internal
*/
public function mergeDefaultBootDefinition($definition, $defaultLevel = 'full|cms-full') {
// If we were only dealing with built-in/global commands, then these options could be defined at the command-level.
// However, we also have extension-based commands. The system will boot before we have a chance to discover them.
// By putting these options at the application level, we ensure they will be defined+used.
$definition->addOption(new InputOption('level', NULL, InputOption::VALUE_REQUIRED, 'Bootstrap level (none,classloader,settings,full,cms-only,cms-full)', $defaultLevel));
$definition->addOption(new InputOption('hostname', NULL, InputOption::VALUE_REQUIRED, 'Hostname (for a multisite system)'));
$definition->addOption(new InputOption('test', 't', InputOption::VALUE_NONE, 'Bootstrap the test database (CIVICRM_UF=UnitTests)'));
$definition->addOption(new InputOption('user', 'U', InputOption::VALUE_REQUIRED, 'CMS user'));
}

/**
* @internal
*/
public function mergeBootDefinition($definition) {
$bootOptions = $this->getBootOptions();
$definition->getOption('level')->setDefault($bootOptions['default']);
}

/**
* Evaluate the $bootOptions.
*
* - If we've already booted, do nothing.
* - If the configuration looks reasonable and if we haven't booted yet, then boot().
* - If the configuration looks unreasonable, then abort.
*/
protected function autoboot(InputInterface $input, OutputInterface $output): void {
$bootOptions = $this->getBootOptions();
if (!in_array($input->getOption('level'), $bootOptions['allow'])) {
throw new \LogicException(sprintf("Command called with with level (%s) but only accepts levels (%s)",
$input->getOption('level'), implode(', ', $bootOptions['allow'])));
}

if (!$this->isBooted() && ($bootOptions['auto'] ?? TRUE)) {
$this->boot($input, $output);
}
}

/**
* Start CiviCRM and/or CMS. Respect options like --user and --level.
*/
public function boot(InputInterface $input, OutputInterface $output) {
$logger = $this->bootLogger($output);
$logger->debug('Start');
Expand Down Expand Up @@ -290,4 +347,33 @@ private function bootLogger(OutputInterface $output): InternalLogger {
return new SymfonyConsoleLogger('BootTrait', $output);
}

/**
* @return bool
*/
protected function isBooted() {
return defined('CIVICRM_DSN');
}

protected function assertBooted() {
if (!$this->isBooted()) {
throw new \Exception("Error: This command requires bootstrapping, but the system does not appear to be bootstrapped. Perhaps you set --level=none?");
}
}

/**
* @return array{auto: bool, default: string, allow: string[]}
*/
public function getBootOptions(): array {
return $this->bootOptions;
}

/**
* @param array{auto: bool, default: string, allow: string[]} $bootOptions
* @return $this
*/
public function setBootOptions(array $bootOptions) {
$this->bootOptions = array_merge($this->bootOptions, $bootOptions);
return $this;
}

}
9 changes: 8 additions & 1 deletion lib/src/Util/IOStack.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,19 @@ class IOStack {
*
* @param \Symfony\Component\Console\Input\InputInterface $input
* @param \Symfony\Component\Console\Output\OutputInterface $output
* @param \Symfony\Component\Console\Application|null $app
* @return scalar
* Internal identifier for the stack-frame. ID formatting is not guaranteed.
*/
public function push(\Symfony\Component\Console\Input\InputInterface $input, \Symfony\Component\Console\Output\OutputInterface $output) {
public function push(\Symfony\Component\Console\Input\InputInterface $input, \Symfony\Component\Console\Output\OutputInterface $output, ?\Symfony\Component\Console\Application $app = NULL) {
++static::$id;
$app = $app ?: ($this->stack[0]['app'] ?? NULL);
array_unshift($this->stack, [
'id' => static::$id,
'input' => $input,
'output' => $output,
'io' => new SymfonyStyle($input, $output),
'app' => $app,
]);
return static::$id;
}
Expand Down Expand Up @@ -68,6 +71,10 @@ public function get($id, string $property) {
return NULL;
}

public function replace($property, $value) {
$this->stack[0][$property] = $value;
}

public function reset() {
$this->stack = [];
}
Expand Down
42 changes: 42 additions & 0 deletions lib/src/Util/OptionalOption.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace Civi\Cv\Util;

class OptionalOption {

/**
* Parse an option's data. This is for options where the default behavior
* (of total omission) differs from the activated behavior
* (of an active but unspecified option).
*
* Example, suppose we want these interpretations:
* cv en ==> Means "--refresh=auto"; see $omittedDefault
* cv en -r ==> Means "--refresh=yes"; see $activeDefault
* cv en -r=yes ==> Means "--refresh=yes"
* cv en -r=no ==> Means "--refresh=no"
*
* @param \CvDeps\Symfony\Component\Console\Input\InputInterface|\Symfony\Component\Console\Input\InputInterface $input
* @param array $rawNames
* Ex: array('-r', '--refresh').
* @param string $omittedDefault
* Value to use if option is completely omitted.
* @param string $activeDefault
* Value to use if option is activated without data.
* @return string
*/
public static function parse($input, $rawNames, $omittedDefault, $activeDefault) {
$value = NULL;
foreach ($rawNames as $rawName) {
if ($input->hasParameterOption($rawName)) {
if (NULL === $input->getParameterOption($rawName)) {
return $activeDefault;
}
else {
return $input->getParameterOption($rawName);
}
}
}
return $omittedDefault;
}

}
6 changes: 1 addition & 5 deletions src/Command/AngularHtmlListCommand.php
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
<?php
namespace Civi\Cv\Command;

use Civi\Cv\Util\BootTrait;
use Civi\Cv\Util\StructuredOutputTrait;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class AngularHtmlListCommand extends BaseCommand {
class AngularHtmlListCommand extends CvCommand {

use BootTrait;
use StructuredOutputTrait;

/**
Expand All @@ -34,11 +32,9 @@ protected function configure() {
cv ang:html:list crmUi/*
cv ang:html:list \';(tabset|wizard)\\.html;\'
');
$this->configureBootOptions();
}

protected function execute(InputInterface $input, OutputInterface $output): int {
$this->boot($input, $output);
if (!$input->getOption('user')) {
$output->getErrorOutput()->writeln("<comment>For a full list, try passing --user=[username].</comment>");
}
Expand Down
7 changes: 1 addition & 6 deletions src/Command/AngularHtmlShowCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,12 @@
namespace Civi\Cv\Command;

use Civi\Cv\Util\Process;
use Civi\Cv\Util\BootTrait;
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 AngularHtmlShowCommand extends BaseCommand {

use BootTrait;
class AngularHtmlShowCommand extends CvCommand {

/**
* @param string|null $name
Expand Down Expand Up @@ -38,11 +35,9 @@ protected function configure() {
cv ang:html:show crmMailing/BlockMailing.html --diff | colordiff
cv ang:html:show "~/crmMailing/BlockMailing.html"
');
$this->configureBootOptions();
}

protected function execute(InputInterface $input, OutputInterface $output): int {
$this->boot($input, $output);
if (!$input->getOption('user')) {
$output->getErrorOutput()->writeln("<comment>For a full list, try passing --user=[username].</comment>");
}
Expand Down
Loading

0 comments on commit fa71612

Please sign in to comment.