• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

JBZoo / Cli / 7714487233

28 Jan 2024 01:57PM UTC coverage: 80.86%. Remained the same
7714487233

push

github

web-flow
Add timeout and error handling to getStdIn method (#25)

* Add timeout and error handling to getStdIn method

The getStdIn method in CliCommand.php now accepts a timeout parameter and includes error handling mechanisms for reading from STDIN. It implements a timeout during read operations and throws exceptions if the read operation fails or is timed out, enhancing robustness and failure mode handling.

* Improve STDIN reading in getStdIn method

The getStdIn function in CliCommand.php has been updated to better handle STDIN input. It now incorporates timeout and error handling features. This should enhance its robustness, particularly in case of read operation hurdles or timeout issues.

10 of 10 new or added lines in 1 file covered. (100.0%)

8 existing lines in 1 file now uncovered.

959 of 1186 relevant lines covered (80.86%)

233.32 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

66.95
/src/CliCommand.php
1
<?php
2

3
/**
4
 * JBZoo Toolbox - Cli.
5
 *
6
 * This file is part of the JBZoo Toolbox project.
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 *
10
 * @license    MIT
11
 * @copyright  Copyright (C) JBZoo.com, All rights reserved.
12
 * @see        https://github.com/JBZoo/Cli
13
 */
14

15
declare(strict_types=1);
16

17
namespace JBZoo\Cli;
18

19
use JBZoo\Cli\OutputMods\AbstractOutputMode;
20
use JBZoo\Cli\OutputMods\Cron;
21
use JBZoo\Cli\OutputMods\Logstash;
22
use JBZoo\Cli\OutputMods\Text;
23
use JBZoo\Cli\ProgressBars\AbstractProgressBar;
24
use JBZoo\Utils\Arr;
25
use JBZoo\Utils\Str;
26
use JBZoo\Utils\Vars;
27
use Symfony\Component\Console\Command\Command;
28
use Symfony\Component\Console\Helper\QuestionHelper;
29
use Symfony\Component\Console\Input\InputInterface;
30
use Symfony\Component\Console\Input\InputOption;
31
use Symfony\Component\Console\Output\OutputInterface;
32
use Symfony\Component\Console\Question\ChoiceQuestion;
33
use Symfony\Component\Console\Question\ConfirmationQuestion;
34
use Symfony\Component\Console\Question\Question;
35

36
use function JBZoo\Utils\bool;
37
use function JBZoo\Utils\float;
38
use function JBZoo\Utils\int;
39

40
abstract class CliCommand extends Command
41
{
42
    protected AbstractOutputMode $outputMode;
43

44
    abstract protected function executeAction(): int;
45

46
    public function progressBar(
47
        int|iterable $listOrMax,
48
        \Closure $callback,
49
        string $title = '',
50
        bool $throwBatchException = true,
51
        ?AbstractOutputMode $outputMode = null,
52
    ): AbstractProgressBar {
53
        static $nestedLevel = 0;
252✔
54

55
        $outputMode ??= $this->outputMode;
252✔
56

57
        $progressBar = $outputMode->createProgressBar()
252✔
58
            ->setTitle($title)
252✔
59
            ->setCallback($callback)
252✔
60
            ->setThrowBatchException($throwBatchException);
252✔
61

62
        if (\is_iterable($listOrMax)) {
252✔
63
            $progressBar->setList($listOrMax);
90✔
64
        } else {
65
            $progressBar->setMax($listOrMax);
168✔
66
        }
67

68
        $nestedLevel++;
252✔
69
        $progressBar->setNestedLevel($nestedLevel);
252✔
70

71
        $progressBar->execute();
252✔
72

73
        $nestedLevel--;
198✔
74
        $progressBar->setNestedLevel($nestedLevel);
198✔
75

76
        return $progressBar;
198✔
77
    }
78

79
    /**
80
     * {@inheritDoc}
81
     */
82
    protected function configure(): void
83
    {
84
        $this
876✔
85
            ->addOption(
876✔
86
                'no-progress',
876✔
87
                null,
876✔
88
                InputOption::VALUE_NONE,
876✔
89
                'Disable progress bar animation for logs. ' .
876✔
90
                'It will be used only for <info>' . Text::getName() . '</info> output format.',
876✔
91
            )
876✔
92
            ->addOption(
876✔
93
                'mute-errors',
876✔
94
                null,
876✔
95
                InputOption::VALUE_NONE,
876✔
96
                "Mute any sort of errors. So exit code will be always \"0\" (if it's possible).\n" .
876✔
97
                "It has major priority then <info>--non-zero-on-error</info>. It's on your own risk!",
876✔
98
            )
876✔
99
            ->addOption(
876✔
100
                'stdout-only',
876✔
101
                null,
876✔
102
                InputOption::VALUE_NONE,
876✔
103
                "For any errors messages application will use StdOut instead of StdErr. It's on your own risk!",
876✔
104
            )
876✔
105
            ->addOption(
876✔
106
                'non-zero-on-error',
876✔
107
                null,
876✔
108
                InputOption::VALUE_NONE,
876✔
109
                'None-zero exit code on any StdErr message.',
876✔
110
            )
876✔
111
            ->addOption(
876✔
112
                'timestamp',
876✔
113
                null,
876✔
114
                InputOption::VALUE_NONE,
876✔
115
                'Show timestamp at the beginning of each message.' .
876✔
116
                'It will be used only for <info>' . Text::getName() . '</info> output format.',
876✔
117
            )
876✔
118
            ->addOption(
876✔
119
                'profile',
876✔
120
                null,
876✔
121
                InputOption::VALUE_NONE,
876✔
122
                'Display timing and memory usage information.',
876✔
123
            )
876✔
124
            ->addOption(
876✔
125
                'output-mode',
876✔
126
                null,
876✔
127
                InputOption::VALUE_REQUIRED,
876✔
128
                "Output format. Available options:\n" . CliHelper::renderListForHelpDescription([
876✔
129
                    Text::getName()     => Text::getDescription(),
876✔
130
                    Cron::getName()     => Cron::getDescription(),
876✔
131
                    Logstash::getName() => Logstash::getDescription(),
876✔
132
                ]),
876✔
133
                Text::getName(),
876✔
134
            )
876✔
135
            ->addOption(
876✔
136
                Cron::getName(),
876✔
137
                null,
876✔
138
                InputOption::VALUE_NONE,
876✔
139
                'Alias for <info>--output-mode=' . Cron::getName() . '</info>. <comment>Deprecated!</comment>',
876✔
140
            );
876✔
141

142
        parent::configure();
876✔
143
    }
144

145
    /**
146
     * {@inheritDoc}
147
     */
148
    protected function execute(InputInterface $input, OutputInterface $output): int
149
    {
150
        $this->outputMode = $this->createOutputMode($input, $output, self::getOutputFormat($input));
876✔
151
        $this->getCliApplication()->setOutputMode($this->outputMode);
876✔
152

153
        $exitCode = Codes::OK;
876✔
154

155
        try {
156
            $this->outputMode->onExecBefore();
876✔
157
            $this->trigger('exec.before', [$this, $this->outputMode]);
876✔
158
            \ob_start();
876✔
159
            $exitCode    = $this->executeAction();
876✔
160
            $echoContent = \ob_get_clean();
720✔
161
            if ($echoContent !== false && $echoContent !== '') {
720✔
162
                $this->showLegacyOutput($echoContent);
720✔
163
            }
164
        } catch (\Exception $exception) {
156✔
165
            $echoContent = \ob_get_clean();
156✔
166
            if ($echoContent !== false && $echoContent !== '') {
156✔
167
                $this->showLegacyOutput($echoContent);
90✔
168
            }
169

170
            $this->outputMode->onExecException($exception);
156✔
171
            $this->trigger('exception', [$this, $this->outputMode, $exception]);
156✔
172

173
            $this->outputMode->onExecAfter($exitCode);
156✔
174

175
            if (!$this->getOptBool('mute-errors')) {
156✔
176
                throw $exception;
132✔
177
            }
178
        }
179

180
        $exitCode = Vars::range($exitCode, 0, 255);
744✔
181

182
        if ($this->outputMode->isOutputHasErrors() && $this->getOptBool('non-zero-on-error')) {
744✔
183
            $exitCode = Codes::GENERAL_ERROR;
24✔
184
        }
185

186
        $this->outputMode->onExecAfter($exitCode);
744✔
187
        $this->trigger('exec.after', [$this, $this->outputMode, &$exitCode]);
744✔
188

189
        if ($this->getOptBool('mute-errors')) {
744✔
190
            $exitCode = 0;
24✔
191
        }
192

193
        return $exitCode;
744✔
194
    }
195

196
    /**
197
     * @return null|mixed
198
     */
199
    protected function getOpt(string $optionName, bool $canBeArray = true): mixed
200
    {
201
        $value = $this->outputMode->getInput()->getOption($optionName);
876✔
202

203
        if ($canBeArray && \is_array($value)) {
876✔
204
            return Arr::last($value);
174✔
205
        }
206

207
        return $value;
876✔
208
    }
209

210
    /**
211
     * @SuppressWarnings(PHPMD.BooleanGetMethodName)
212
     */
213
    protected function getOptBool(string $optionName): bool
214
    {
215
        $value = $this->getOpt($optionName);
876✔
216

217
        return bool($value);
876✔
218
    }
219

220
    /**
221
     * @param int[] $onlyExpectedOptions
222
     */
223
    protected function getOptInt(string $optionName, array $onlyExpectedOptions = []): int
224
    {
225
        $value  = $this->getOpt($optionName) ?? 0;
360✔
226
        $result = int($value);
360✔
227

228
        if (\count($onlyExpectedOptions) > 0 && !\in_array($result, $onlyExpectedOptions, true)) {
360✔
229
            throw new Exception(
×
230
                "Passed invalid value of option \"--{$optionName}={$result}\".\n" .
×
231
                'Strict expected int-values are only: ' . CliHelper::renderExpectedValues($onlyExpectedOptions),
×
232
            );
×
233
        }
234

235
        return int($value);
360✔
236
    }
237

238
    /**
239
     * @param float[] $onlyExpectedOptions
240
     */
241
    protected function getOptFloat(string $optionName, array $onlyExpectedOptions = []): float
242
    {
243
        $value  = $this->getOpt($optionName) ?? 0.0;
174✔
244
        $result = float($value);
174✔
245

246
        if (\count($onlyExpectedOptions) > 0 && !\in_array($result, $onlyExpectedOptions, true)) {
174✔
247
            throw new Exception(
×
248
                "Passed invalid value of option \"--{$optionName}={$result}\".\n" .
×
249
                'Strict expected float-values are only: ' . CliHelper::renderExpectedValues($onlyExpectedOptions),
×
250
            );
×
251
        }
252

253
        return $result;
174✔
254
    }
255

256
    /**
257
     * @param string[] $onlyExpectedOptions
258
     */
259
    protected function getOptString(string $optionName, string $default = '', array $onlyExpectedOptions = []): string
260
    {
261
        $value  = \trim((string)$this->getOpt($optionName));
822✔
262
        $length = \strlen($value);
822✔
263
        $result = $length > 0 ? $value : $default;
822✔
264

265
        if (\count($onlyExpectedOptions) > 0 && !\in_array($result, $onlyExpectedOptions, true)) {
822✔
266
            throw new Exception(
×
267
                "Passed invalid value of option \"--{$optionName}={$result}\".\n" .
×
268
                'Strict expected string-values are only: ' . CliHelper::renderExpectedValues($onlyExpectedOptions),
×
269
            );
×
270
        }
271

272
        return $result;
822✔
273
    }
274

275
    protected function getOptArray(string $optionName): array
276
    {
277
        $list = $this->getOpt($optionName, false) ?? [];
174✔
278

279
        return (array)$list;
174✔
280
    }
281

282
    /**
283
     * @param string[] $onlyExpectedOptions
284
     */
285
    protected function getOptDatetime(
286
        string $optionName,
287
        string $defaultDatetime = '1970-01-01 00:00:00',
288
        array $onlyExpectedOptions = [],
289
    ): \DateTimeImmutable {
290
        $value  = $this->getOptString($optionName);
×
291
        $result = $value === '' ? $defaultDatetime : $value;
×
292

293
        if (\count($onlyExpectedOptions) > 0 && !\in_array($result, $onlyExpectedOptions, true)) {
×
294
            throw new Exception(
×
295
                "Passed invalid value of option {$optionName}={$result}. " .
×
296
                'Strict expected string-values are only: ' . CliHelper::renderExpectedValues($onlyExpectedOptions),
×
297
            );
×
298
        }
299

300
        return new \DateTimeImmutable($result);
×
301
    }
302

303
    /**
304
     * Alias to write new line in std output.
305
     * @SuppressWarnings(PHPMD.CamelCaseMethodName)
306
     */
307
    protected function _(
308
        null|bool|float|int|iterable|string $messages = '',
309
        string $verboseLevel = '',
310
        array $context = [],
311
    ): void {
312
        $this->outputMode->_($messages, $verboseLevel, $context);
630✔
313
    }
314

315
    protected function isInfoLevel(): bool
316
    {
317
        return $this->outputMode->isInfoLevel();
×
318
    }
319

320
    protected function isWarningLevel(): bool
321
    {
322
        return $this->outputMode->isWarningLevel();
×
323
    }
324

325
    protected function isDebugLevel(): bool
326
    {
327
        return $this->outputMode->isDebugLevel();
×
328
    }
329

330
    protected function isProfile(): bool
331
    {
332
        return $this->outputMode->isDisplayProfiling();
×
333
    }
334

335
    protected function trigger(string $eventName, array $arguments = [], ?callable $continueCallback = null): int
336
    {
337
        $application = $this->getApplication();
876✔
338
        if ($application === null) {
876✔
339
            return 0;
×
340
        }
341

342
        if ($application instanceof CliApplication) {
876✔
343
            $eManager = $application->getEventManager();
876✔
344
            if ($eManager === null) {
876✔
345
                return 0;
876✔
346
            }
347

348
            return $eManager->trigger("jbzoo.cli.{$eventName}", $arguments, $continueCallback);
×
349
        }
350

351
        return 0;
×
352
    }
353

354
    protected function ask(string $question, string $default = '', bool $isHidden = false): string
355
    {
356
        $question     = \rtrim($question, ':');
×
357
        $questionText = "<yellow-r>Question:</yellow-r> {$question}";
×
358
        if (!$isHidden) {
×
359
            $questionText .= ($default !== '' ? " (Default: <i>{$default}</i>)" : '');
×
360
        }
361

362
        $questionObj = new Question($questionText . ': ', $default);
×
363
        if ($isHidden) {
×
364
            $questionObj->setHidden(true);
×
365
            $questionObj->setHiddenFallback(false);
×
366
        }
367

368
        return (string)$this->getQuestionHelper()->ask(
×
369
            $this->outputMode->getInput(),
×
370
            $this->outputMode->getOutput(),
×
371
            $questionObj,
×
372
        );
×
373
    }
374

375
    protected function askPassword(string $question, bool $randomDefault = true): string
376
    {
377
        $default = '';
×
378
        if ($randomDefault) {
×
379
            $question .= ' (Default: <i>Random</i>)';
×
380
            $default = Str::random(10, false);
×
381
        }
382

383
        return $this->ask($question, $default, true);
×
384
    }
385

386
    protected function confirmation(string $question = 'Are you sure?', bool $default = false): bool
387
    {
388
        $question     = '<yellow-r>Question:</yellow-r> ' . \trim($question);
×
389
        $defaultValue = $default ? 'Y' : 'n';
×
390
        $questionObj  = new ConfirmationQuestion(
×
391
            "{$question} (<c>Y/n</c>; Default: <i>{$defaultValue}</i>): ",
×
392
            $default,
×
393
        );
×
394

395
        return (bool)$this->getQuestionHelper()->ask(
×
396
            $this->outputMode->getInput(),
×
397
            $this->outputMode->getOutput(),
×
398
            $questionObj,
×
399
        );
×
400
    }
401

402
    /**
403
     * @param string[] $options
404
     */
405
    protected function askOption(string $question, array $options, null|float|int|string $default = null): string
406
    {
407
        $question = '<yellow-r>Question:</yellow-r> ' . \trim($question);
×
408

409
        $defaultValue = '';
×
410
        if ($default !== null) {
×
411
            /** @phpstan-ignore-next-line */
412
            $defaultValue = $options[$default] ?? '';
×
413
            if (!bool($default)) {
×
414
                $default = '';
×
415
            }
416

417
            if ($defaultValue !== '') {
×
418
                $defaultValue = " (Default: <i>{$defaultValue}</i>)";
×
419
            }
420
        }
421

422
        $questionObj = new ChoiceQuestion($question . $defaultValue . ': ', $options, $default);
×
423
        $questionObj->setErrorMessage('The option "%s" is undefined. See the avaialable options');
×
424

425
        return (string)$this->getQuestionHelper()->ask(
×
426
            $this->outputMode->getInput(),
×
427
            $this->outputMode->getOutput(),
×
428
            $questionObj,
×
429
        );
×
430
    }
431

432
    /**
433
     * Reads input from STDIN with an optional timeout.
434
     *
435
     * @param  int         $timeout the timeout value in seconds (default: 5)
436
     * @return null|string the string read from STDIN, or null if an error occurred
437
     * @throws Exception   if there was an error reading from STDIN or if the read operation timed out
438
     */
439
    protected static function getStdIn(int $timeout = 5): ?string
440
    {
441
        static $result; // It can be read only once, so we save result as internal variable
24✔
442

443
        if ($result === null) {
24✔
444
            $result = '';
24✔
445

446
            $read        = [\STDIN];
24✔
447
            $write       = [];
24✔
448
            $except      = [];
24✔
449
            $streamCount = \stream_select($read, $write, $except, $timeout);
24✔
450

451
            if ($streamCount > 0) {
24✔
452
                while ($line = \fgets(\STDIN, 1024)) {
24✔
453
                    $result .= $line;
18✔
454
                }
455
            }
456

457
            if ($result === '') {
24✔
458
                cli("Reading from STDIN timed out ({$timeout} seconds)", OutLvl::WARNING);
6✔
459
            }
460
        }
461

462
        return $result;
24✔
463
    }
464

465
    private function showLegacyOutput(string $echoContent): void
466
    {
467
        $lines = \explode("\n", $echoContent);
252✔
468
        $lines = \array_map(static fn ($line) => \rtrim($line), $lines);
252✔
469
        $lines = \array_filter($lines, static fn ($line): bool => $line !== '');
252✔
470

471
        if (\count($lines) > 1) {
252✔
472
            $this->_($lines, OutLvl::LEGACY);
252✔
473
        } else {
UNCOV
474
            $this->_($echoContent, OutLvl::LEGACY);
×
475
        }
476
    }
477

478
    private function getQuestionHelper(): QuestionHelper
479
    {
UNCOV
480
        $helper = $this->getHelper('question');
×
UNCOV
481
        if ($helper instanceof QuestionHelper) {
×
UNCOV
482
            return $helper;
×
483
        }
484

UNCOV
485
        throw new Exception('Symfony QuestionHelper not found');
×
486
    }
487

488
    private function createOutputMode(
489
        InputInterface $input,
490
        OutputInterface $output,
491
        string $outputFormat,
492
    ): AbstractOutputMode {
493
        $application = $this->getCliApplication();
876✔
494

495
        if ($outputFormat === Text::getName()) {
876✔
496
            return new Text($input, $output, $application);
612✔
497
        }
498

499
        if ($outputFormat === Cron::getName()) {
264✔
500
            return new Cron($input, $output, $application);
30✔
501
        }
502

503
        if ($outputFormat === Logstash::getName()) {
234✔
504
            return new Logstash($input, $output, $application);
234✔
505
        }
506

UNCOV
507
        throw new Exception("Unknown output format: {$outputFormat}");
×
508
    }
509

510
    private function getCliApplication(): CliApplication
511
    {
512
        $application = $this->getApplication();
876✔
513
        if ($application === null) {
876✔
UNCOV
514
            throw new Exception('Application not defined. Please, use "setApplication()" method.');
×
515
        }
516

517
        if ($application instanceof CliApplication) {
876✔
518
            return $application;
876✔
519
        }
520

UNCOV
521
        throw new Exception('Application must be instance of "\JBZoo\Cli\CliApplication"');
×
522
    }
523

524
    private static function getOutputFormat(InputInterface $input): string
525
    {
526
        if (bool($input->getOption('cron'))) { // TODO: Must be deprecated in the future
876✔
527
            return Cron::getName();
18✔
528
        }
529

530
        return $input->getOption('output-mode') ?? Text::getName();
858✔
531
    }
532
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc