' => 'Alias for \', - '\' => ' Alias for \ ', - ], '*')); + cli( + CliRender::list([ + '\' => 'Alias for \', + '\ ' => ' Alias for \ ', + '\' => 'Alias for \', + '\' => ' Alias for \ ', + ], '*'), + ); // Default success exist code is "0". Max value is 255. return self::SUCCESS; diff --git a/demo/Commands/ExamplesProgressBar.php b/demo/Commands/ExamplesProgressBar.php deleted file mode 100644 index bb678ab..0000000 --- a/demo/Commands/ExamplesProgressBar.php +++ /dev/null @@ -1,96 +0,0 @@ -setName('examples:progress-bar') - ->setDescription('Examples of progress bar') - ->addOption('exception', null, InputOption::VALUE_NONE, 'Throw exception') - ->addOption('exception-list', null, InputOption::VALUE_NONE, 'Throw list of exceptions'); - - parent::configure(); - } - - protected function executeAction(): int - { - // ////////////////////////////////////////////////////////////////////// Just 3 steps - $this->progressBar(2, static function ($stepValue, $stepIndex, $currentStep) { - \sleep(1); - - return "Step info: \$stepValue={$stepValue}, \$stepIndex={$stepIndex}, \$currentStep={$currentStep}"; - }, 'Number of steps'); - - // ////////////////////////////////////////////////////////////////////// Assoc array. Step-by-step - $list = [ - 'key_1' => 'value_1', - 'key_2' => 'value_2', - 'key_3' => 'value_3', - ]; - - $this->progressBar( - $list, - static fn ( - $stepValue, - $stepIndex, - $currentStep, - ) => "Step info: \$stepValue={$stepValue}, \$stepIndex={$stepIndex}, \$currentStep={$currentStep}", - 'Assoc array', - ); - - // ////////////////////////////////////////////////////////////////////// Exit from the cycle - $this->progressBar(3, static function ($stepValue, $stepIndex, $currentStep) { - if ($stepValue === 1) { - return ProgressBarSymfony::BREAK; - } - - return "Step info: \$stepValue={$stepValue}, \$stepIndex={$stepIndex}, \$currentStep={$currentStep}"; - }, 'Exit from the cycle'); - - // ////////////////////////////////////////////////////////////////////// Exception - if ($this->getOptBool('exception')) { - $this->progressBar(3, static function ($stepValue) { - if ($stepValue === 1) { - throw new Exception("Exception #{$stepValue}"); - } - - return "\$stepValue={$stepValue}"; - }, 'Exception handling', false); - } - - // ////////////////////////////////////////////////////////////////////// List of Exceptions - if ($this->getOptBool('exception-list')) { - $this->progressBar(10, static function ($stepValue) { - if ($stepValue % 3 === 0) { - throw new Exception("Exception #{$stepValue}"); - } - - return "\$stepValue={$stepValue}"; - }, 'Handling list of exceptions at once', true); - } - - // Default success exist code is "0". Max value is 255. - return self::SUCCESS; - } -} diff --git a/demo/movies/ExamplesOutput.sh b/demo/movies/Output.sh similarity index 79% rename from demo/movies/ExamplesOutput.sh rename to demo/movies/Output.sh index 516feb0..d0767b3 100755 --- a/demo/movies/ExamplesOutput.sh +++ b/demo/movies/Output.sh @@ -30,74 +30,74 @@ wait pei "# At first, let me show you the output of the command by default." -pei "./my-app examples:output" +pei "./my-app output" wait pei "clear" pei "# There are several lines that written in Standard Error output (stderr)." -pei "./my-app examples:output > /dev/null" +pei "./my-app output > /dev/null" wait pei "clear" pei "# There is a special level to show a messge forever." -pei "./my-app examples:output --quiet" +pei "./my-app output --quiet" wait pei "clear" pei "# And pay attentin on old school style output." -pei "./my-app examples:output --stdout-only | grep 'Legacy Output'" +pei "./my-app output --stdout-only | grep 'Legacy Output'" wait pei "clear" pei "# Let's increase the output level. Just add the flag '-v'. Look at 'Info:'" -pei "./my-app examples:output -v" +pei "./my-app output -v" wait pei "clear" pei "# Next, let's look at more detailed logs (-vv). Look at 'Warning:'" -pei "./my-app examples:output -vv" +pei "./my-app output -vv" wait pei "clear" pei "# And messages that are only useful to developers during application debugging (-vvv). Look at 'Debug:'" -pei "./my-app examples:output -vvv" +pei "./my-app output -vvv" wait pei "clear" pei "# There is an easy way to find memory leaks and performance issues. Just add '--profile' flag." -pei "./my-app examples:output --profile" +pei "./my-app output --profile" wait pei "clear" pei "# Also, we can use the output as logs. It's pretty useful for cron jobs." -pei "./my-app examples:output --profile --timestamp" +pei "./my-app output --profile --timestamp" wait pei "clear" pei "# Let's simulate a fatal error." -pei "./my-app examples:output --throw-custom-exception" +pei "./my-app output --throw-custom-exception" wait pei "clear" pei "# Sometimes we have to ignore exception not to break the pipeline." -pei "./my-app examples:output --throw-custom-exception --mute-errors -vvv" +pei "./my-app output --throw-custom-exception --mute-errors -vvv" pei "# Look at the last lines." wait pei "clear" pei "# In rare cases we can use the flag '--non-zero-on-error' to return ExitCode=1 if any stderr happend." -pei "./my-app examples:output --non-zero-on-error -vvv" +pei "./my-app output --non-zero-on-error -vvv" pei "# Look at the last lines." wait pei "clear" diff --git a/demo/movies/ProgressBar.sh b/demo/movies/ProgressBar.sh new file mode 100755 index 0000000..6ad746d --- /dev/null +++ b/demo/movies/ProgressBar.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env sh + +# +# JBZoo Toolbox - Cli. +# +# This file is part of the JBZoo Toolbox project. +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +# +# @license MIT +# @copyright Copyright (C) JBZoo.com, All rights reserved. +# @see https://github.com/JBZoo/Cli +# + +. demo-magic.sh + +cd .. +clear +PROMPT_TIMEOUT=1 + +pei "# " +pei "# See it here './demo/Commands/ExamplesOutput.php'" +pei "# Let's get started!" +wait + + +pei "# At first, let me show you the output of the command by default." +pei "./my-app progress-bar --case=simple" +wait +pei "clear" + + +pei "# " +pei "./my-app progress-bar --case=simple -v" +wait +pei "clear" + +pei "# " +pei "./my-app progress-bar --case=simple -vv" +wait +pei "clear" + +pei "# " +pei "./my-app progress-bar --case=simple -vvv" +wait +pei "clear" + +pei "# " +pei "./my-app progress-bar --case=simple --no-progress" +wait +pei "clear" + +pei "# " +pei "./my-app progress-bar --case=simple --cron" +wait +pei "clear" + +pei "# " +pei "./my-app progress-bar --case=assoc" +wait +pei "clear" + +pei "# " +pei "./my-app progress-bar --case=break" +wait +pei "clear" + +pei "# " +pei "./my-app progress-bar --case=exception" +wait +pei "clear" + +pei "# " +pei "./my-app progress-bar --case=exception-list" +wait +pei "clear" + + + + +pei "##############################" +pei "# That's all for this demo. #" +pei "# Have a nice day =) #" +pei "# Thank you! #" +pei "##############################" diff --git a/demo/movies/demo-magic.sh b/demo/movies/demo-magic.sh index d7929af..9337fce 100755 --- a/demo/movies/demo-magic.sh +++ b/demo/movies/demo-magic.sh @@ -24,13 +24,13 @@ ############################################################################### # the speed to "type" the text -TYPE_SPEED=25 +TYPE_SPEED=100 # no wait after "p" or "pe" NO_WAIT=false # if > 0, will pause for this amount of seconds before automatically proceeding with any p or pe -PROMPT_TIMEOUT=3 +PROMPT_TIMEOUT=1 # don't show command number unless user specifies it SHOW_CMD_NUMS=false @@ -114,16 +114,15 @@ function p() { fi if [[ -z $TYPE_SPEED ]]; then - echo -en "$cmd" + echo "$cmd" else - echo -en "$cmd" | pv -qL $[$TYPE_SPEED+(-2 + RANDOM%5)]; + echo "$cmd" | pv -qL $[$TYPE_SPEED+(-2 + RANDOM%5)]; fi # wait for the user to press a key before moving on if [ $NO_WAIT = false ]; then wait fi - echo "" } ## diff --git a/src/CliCommand.php b/src/CliCommand.php index 0024f64..603e450 100644 --- a/src/CliCommand.php +++ b/src/CliCommand.php @@ -219,26 +219,59 @@ protected function getOptBool(string $optionName): bool return bool($value); } - protected function getOptInt(string $optionName): int + /** + * @param float[] $onlyExpectedOptions + */ + protected function getOptInt(string $optionName, array $onlyExpectedOptions = []): int { - $value = $this->getOpt($optionName) ?? 0; + $value = $this->getOpt($optionName) ?? 0; + $result = int($value); + + if (\count($onlyExpectedOptions) > 0 && !\in_array($result, $onlyExpectedOptions, true)) { + throw new Exception( + "Passed invalid value of option \"--{$optionName}={$result}\".\n" . + 'Strict expected int-values are only: ' . CliHelper::renderExpectedValues($onlyExpectedOptions), + ); + } return int($value); } - protected function getOptFloat(string $optionName): float + /** + * @param float[] $onlyExpectedOptions + */ + protected function getOptFloat(string $optionName, array $onlyExpectedOptions = []): float { - $value = $this->getOpt($optionName) ?? 0.0; + $value = $this->getOpt($optionName) ?? 0.0; + $result = float($value); - return float($value); + if (\count($onlyExpectedOptions) > 0 && !\in_array($result, $onlyExpectedOptions, true)) { + throw new Exception( + "Passed invalid value of option \"--{$optionName}={$result}\".\n" . + 'Strict expected float-values are only: ' . CliHelper::renderExpectedValues($onlyExpectedOptions), + ); + } + + return $result; } - protected function getOptString(string $optionName, string $default = ''): string + /** + * @param string[] $onlyExpectedOptions + */ + protected function getOptString(string $optionName, string $default = '', array $onlyExpectedOptions = []): string { $value = \trim((string)$this->getOpt($optionName)); $length = \strlen($value); + $result = $length > 0 ? $value : $default; + + if (\count($onlyExpectedOptions) > 0 && !\in_array($result, $onlyExpectedOptions, true)) { + throw new Exception( + "Passed invalid value of option \"--{$optionName}={$result}\".\n" . + 'Strict expected string-values are only: ' . CliHelper::renderExpectedValues($onlyExpectedOptions), + ); + } - return $length > 0 ? $value : $default; + return $result; } protected function getOptArray(string $optionName): array @@ -248,14 +281,25 @@ protected function getOptArray(string $optionName): array return (array)$list; } + /** + * @param string[] $onlyExpectedOptions + */ protected function getOptDatetime( string $optionName, string $defaultDatetime = '1970-01-01 00:00:00', + array $onlyExpectedOptions = [], ): \DateTimeImmutable { - $value = $this->getOptString($optionName); - $dateAsString = $value === '' ? $defaultDatetime : $value; + $value = $this->getOptString($optionName); + $result = $value === '' ? $defaultDatetime : $value; + + if (\count($onlyExpectedOptions) > 0 && !\in_array($result, $onlyExpectedOptions, true)) { + throw new Exception( + "Passed invalid value of option {$optionName}={$result}. " . + 'Strict expected string-values are only: ' . CliHelper::renderExpectedValues($onlyExpectedOptions), + ); + } - return new \DateTimeImmutable($dateAsString); + return new \DateTimeImmutable($result); } /** diff --git a/src/CliHelper.php b/src/CliHelper.php index 50ff241..7f3ce86 100644 --- a/src/CliHelper.php +++ b/src/CliHelper.php @@ -116,4 +116,15 @@ public static function createOrGetTraceId(): string return $traceId; } + + public static function renderExpectedValues(array $values): string + { + $result = ''; + + foreach ($values as $value) { + $result .= "\"{$value}\", "; + } + + return \rtrim($result); + } } diff --git a/src/OutputMods/Text.php b/src/OutputMods/Text.php index 426d96d..accd959 100644 --- a/src/OutputMods/Text.php +++ b/src/OutputMods/Text.php @@ -27,6 +27,8 @@ use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; +use function JBZoo\Utils\bool; + class Text extends AbstractOutputMode { public function __construct(InputInterface $input, OutputInterface $output, CliApplication $application) @@ -70,7 +72,9 @@ public function onExecAfter(int $exitCode, ?string $outputLevel = null): void public function onExecException(\Exception $exception): void { - $this->_($exception->getMessage(), OutLvl::EXCEPTION); + if (bool($this->getInput()->getOption('mute-errors'))) { + $this->_($exception->getMessage(), OutLvl::EXCEPTION); + } } public function createProgressBar(): AbstractProgressBar diff --git a/src/ProgressBars/AbstractSymfonyProgressBar.php b/src/ProgressBars/AbstractSymfonyProgressBar.php index 3c3562e..a6ac7b9 100644 --- a/src/ProgressBars/AbstractSymfonyProgressBar.php +++ b/src/ProgressBars/AbstractSymfonyProgressBar.php @@ -26,9 +26,10 @@ abstract class AbstractSymfonyProgressBar extends AbstractProgressBar { - public const BREAK = 'Progress stopped'; + /** @deprecated Use `throw new ExceptionBreak("Reason.")` */ + public const BREAK = ExceptionBreak::MESSAGE; - public const MAX_LINE_LENGTH = 80; + public const MAX_LINE_LENGTH = 120; /** @var int[] */ protected array $stepMemoryDiff = []; @@ -44,14 +45,37 @@ public static function setPlaceholder(string $name, callable $callable): void /** * @SuppressWarnings(PHPMD.NPathComplexity) */ - protected function configureProgressBar(): void + protected function configureProgressBar(bool $optimizeMode = true): void { - // Memory + self::setPlaceholder( + 'jbzoo_time_elapsed', + static fn (SymfonyProgressBar $bar): string => Dates::formatTime(\time() - $bar->getStartTime()), + ); + + self::setPlaceholder('jbzoo_time_estimated', static function (SymfonyProgressBar $bar): string { + if ($bar->getMaxSteps() === 0) { + return 'n/a'; + } + + if ($bar->getProgress() === 0) { + $estimated = 0; + } else { + $estimated = \round((\time() - $bar->getStartTime()) / $bar->getProgress() * $bar->getMaxSteps()); + } + + return Dates::formatTime($estimated); + }); + self::setPlaceholder( 'jbzoo_memory_current', static fn (): string => SymfonyHelper::formatMemory(\memory_get_usage(false)), ); + // Time optimizations + if ($optimizeMode) { + return; + } + self::setPlaceholder( 'jbzoo_memory_peak', static fn (): string => SymfonyHelper::formatMemory(\memory_get_peak_usage(false)), @@ -83,12 +107,6 @@ protected function configureProgressBar(): void return FS::format(Arr::last($this->stepMemoryDiff)); }); - // Timers - self::setPlaceholder( - 'jbzoo_time_elapsed', - static fn (SymfonyProgressBar $bar): string => Dates::formatTime(\time() - $bar->getStartTime()), - ); - self::setPlaceholder('jbzoo_time_remaining', static function (SymfonyProgressBar $bar): string { if ($bar->getMaxSteps() === 0) { return 'n/a'; @@ -107,24 +125,6 @@ protected function configureProgressBar(): void return Dates::formatTime($remaining); }); - self::setPlaceholder('jbzoo_time_estimated', static function (SymfonyProgressBar $bar): string { - if ($bar->getMaxSteps() === 0) { - return 'n/a'; - } - - if ($bar->getProgress() === 0) { - $estimated = 0; - } else { - $estimated = \round( - (\time() - $bar->getStartTime()) - / $bar->getProgress() - * $bar->getMaxSteps(), - ); - } - - return Dates::formatTime($estimated); - }); - self::setPlaceholder('jbzoo_time_step_median', function (SymfonyProgressBar $bar): string { if ( $bar->getMaxSteps() === 0 diff --git a/src/ProgressBars/ExceptionBreak.php b/src/ProgressBars/ExceptionBreak.php new file mode 100644 index 0000000..1ca69be --- /dev/null +++ b/src/ProgressBars/ExceptionBreak.php @@ -0,0 +1,22 @@ +callback)($stepValue, $stepIndex, $currentIndex); + } catch (ExceptionBreak $exception) { + $callbackResults[] = static::BREAK . ' ' . $exception->getMessage(); } catch (\Exception $exception) { if ($this->throwBatchException) { $errorMessage = 'Exception: ' . $exception->getMessage(); diff --git a/src/ProgressBars/ProgressBarSymfony.php b/src/ProgressBars/ProgressBarSymfony.php index 60fb297..9dc1f3c 100644 --- a/src/ProgressBars/ProgressBarSymfony.php +++ b/src/ProgressBars/ProgressBarSymfony.php @@ -61,13 +61,19 @@ public function execute(): bool foreach ($this->list as $stepIndex => $stepValue) { $this->setStep($stepIndex, $currentIndex); - $startTime = \microtime(true); - $startMemory = \memory_get_usage(false); + $startTime = 0; + $startMemory = 0; + if (!$this->isOptimizeMode()) { + $startTime = \microtime(true); + $startMemory = \memory_get_usage(false); + } [$stepResult, $exceptionMessage] = $this->handleOneStep($stepValue, $stepIndex, $currentIndex); - $this->stepMemoryDiff[] = \memory_get_usage(false) - $startMemory; - $this->stepTimers[] = \microtime(true) - $startTime; + if (!$this->isOptimizeMode()) { + $this->stepMemoryDiff[] = \memory_get_usage(false) - $startMemory; + $this->stepTimers[] = \microtime(true) - $startTime; + } $exceptionMessages = \array_merge($exceptionMessages, (array)$exceptionMessage); @@ -116,26 +122,24 @@ protected function buildTemplate(): string } if ($this->output->isVeryVerbose()) { - $progressBarLines[] = \implode(' ', [ - $percent, - $steps, - $bar, - $this->finishIcon, - ]); - $footerLine['Time (pass/left/est/avg/last)'] = \implode(' / ', [ + $progressBarLines[] = \implode(' ', [$percent, $steps, $bar, $this->finishIcon]); + + $footerLine['Time (pass/left/est/median/last)'] = \implode(' / ', [ '%jbzoo_time_elapsed:9s%', '%jbzoo_time_remaining:8s% ', '%jbzoo_time_estimated:8s% ', '%jbzoo_time_step_median%', '%jbzoo_time_step_last%', ]); - $footerLine['Memory (cur/peak/limit/leak/last)'] = \implode(' / ', [ + + $footerLine['Mem (cur/peak/limit/leak/last)'] = \implode(' / ', [ '%jbzoo_memory_current:8s%', '%jbzoo_memory_peak% ', '%jbzoo_memory_limit%', '%jbzoo_memory_step_median%', '%jbzoo_memory_step_last%', ]); + $footerLine['Caught exceptions'] = '%jbzoo_caught_exceptions%'; } elseif ($this->output->isVerbose()) { $progressBarLines[] = \implode(' ', [ @@ -151,6 +155,7 @@ protected function buildTemplate(): string '%jbzoo_time_remaining:8s% ', '%jbzoo_time_estimated%', ]); + $footerLine['Caught exceptions'] = '%jbzoo_caught_exceptions%'; } else { $progressBarLines[] = \implode(' ', [ @@ -162,7 +167,7 @@ protected function buildTemplate(): string ]); } - $footerLine['Last Step Message'] = '%message%'; + $footerLine['Last Message'] = '%message%'; return \implode("\n", $progressBarLines) . "\n" . CliRender::list($footerLine) . "\n"; } @@ -220,6 +225,8 @@ private function handleOneStep(mixed $stepValue, int|float|string $stepIndex, in // Executing callback try { $callbackResults = (array)($this->callback)($stepValue, $stepIndex, $currentIndex); + } catch (ExceptionBreak $exception) { + $callbackResults[] = '' . ExceptionBreak::MESSAGE . ' ' . $exception->getMessage(); } catch (\Exception $exception) { if ($this->throwBatchException) { $errorMessage = 'Exception: ' . $exception->getMessage(); @@ -229,6 +236,8 @@ private function handleOneStep(mixed $stepValue, int|float|string $stepIndex, in throw $exception; } } + + // Collect eventual output $cathedMessages = $this->outputMode->catchModeFinish(); if (\count($cathedMessages) > 0) { $callbackResults = \array_merge($callbackResults, $cathedMessages); @@ -261,7 +270,7 @@ private function createProgressBar(): ?SymfonyProgressBar return null; } - $this->configureProgressBar(); + $this->configureProgressBar($this->isOptimizeMode()); $progressBar = new SymfonyProgressBar($this->output, $this->max); @@ -276,13 +285,20 @@ private function createProgressBar(): ?SymfonyProgressBar $progressBar->setProgress(0); $progressBar->setOverwrite(true); - $progressBar->setRedrawFrequency(1); - $progressBar->minSecondsBetweenRedraws(0.5); - $progressBar->maxSecondsBetweenRedraws(1.5); + if (!$this->isOptimizeMode()) { + $progressBar->setRedrawFrequency(1); + $progressBar->minSecondsBetweenRedraws(0.5); + $progressBar->maxSecondsBetweenRedraws(1.5); + } return $progressBar; } + private function isOptimizeMode(): bool + { + return $this->outputMode->getOutput()->getVerbosity() < OutputInterface::OUTPUT_NORMAL; + } + private static function showListOfExceptions(array $exceptionMessages): void { if (\count($exceptionMessages) > 0) { diff --git a/tests/CliOutputTextTest.php b/tests/CliOutputTextTest.php index 1d55888..28e60e5 100644 --- a/tests/CliOutputTextTest.php +++ b/tests/CliOutputTextTest.php @@ -321,6 +321,22 @@ public function testMuteErrors(): void isContain($exceptionMessage, $cmdResult->err); } + public function testException(): void + { + $errMessage = 'Something went wrong'; + + $cmdResult = Helper::executeReal('test:output', ['exception' => $errMessage]); + + isSame(1, $cmdResult->code); + + isNotContain('Muted Exception: Something went wrong', $cmdResult->std); + isNotContain('Muted Exception: Something went wrong', $cmdResult->err); + isNotContain('Something went wrong', $cmdResult->std); + isContain('Something went wrong', $cmdResult->err); + + isContain('In TestOutput.php line', $cmdResult->err); + } + public function testCronMode(): void { $cmdResult = Helper::executeReal('test:output', ['cron' => null, 'exception' => 'Custom runtime error']); diff --git a/tests/CliProgressLogstashTest.php b/tests/CliProgressLogstashTest.php index 9e08a89..a1f5b90 100644 --- a/tests/CliProgressLogstashTest.php +++ b/tests/CliProgressLogstashTest.php @@ -77,13 +77,28 @@ public function testCustomMessages(): void Helper::assertLogstash(['NOTICE', '(Key=1/Step=2/Max=2): value_2; key_2; 1'], $stdOutput[2]); } - public function testBreak(): void + public function testBreakWithLegactyReturnMessage(): void { $stdOutput = $this->exec('break'); isCount(3, $stdOutput); Helper::assertLogstash(['NOTICE', 'Working on "break". Number of steps: 3.'], $stdOutput[0]); Helper::assertLogstash(['NOTICE', '(Step=1/Max=3): 0'], $stdOutput[1]); - Helper::assertLogstash(['NOTICE', '(Step=2/Max=3): Progress stopped'], $stdOutput[2]); + Helper::assertLogstash(['NOTICE', '(Step=2/Max=3): Progress aborted.'], $stdOutput[2]); + } + + public function testBreakWithException(): void + { + $stdOutput = $this->exec('break-exception'); + isCount(3, $stdOutput); + Helper::assertLogstash(['NOTICE', 'Working on "break-exception". Number of steps: 3.'], $stdOutput[0]); + Helper::assertLogstash(['NOTICE', '(Step=1/Max=3): 0'], $stdOutput[1]); + Helper::assertLogstash( + [ + 'NOTICE', + '(Step=2/Max=3): Progress aborted. Something went wrong with $listValue=1', + ], + $stdOutput[2], + ); } public function testNoItems(): void diff --git a/tests/CliProgressTest.php b/tests/CliProgressTest.php index b4a4036..7edb310 100644 --- a/tests/CliProgressTest.php +++ b/tests/CliProgressTest.php @@ -27,7 +27,7 @@ public function testMinimal(): void isContain('0% (0 / 2) [>', $cmdResult->err); isContain('50% (1 / 2) [•', $cmdResult->err); isContain('100% (2 / 2) [•', $cmdResult->err); - isContain('Last Step Message: n/a', $cmdResult->err); + isContain('Last Message: n/a', $cmdResult->err); $cmdResult = Helper::executeReal('test:progress', ['case' => 'minimal', 'stdout-only' => null, 'sleep' => 1]); isSame(0, $cmdResult->code); @@ -35,18 +35,18 @@ public function testMinimal(): void isContain('0% (0 / 2) [>', $cmdResult->std); isContain('50% (1 / 2) [•', $cmdResult->std); isContain('100% (2 / 2) [•', $cmdResult->std); - isContain('Last Step Message: n/a', $cmdResult->std); + isContain('Last Message: n/a', $cmdResult->std); } public function testMinimalVirtual(): void { $cmdResult = Helper::executeVirtaul('test:progress', ['case' => 'one-message', 'ansi' => null]); isContain('Progress of one-message', $cmdResult->std); - isContain('Last Step Message: 1, 1, 1', $cmdResult->std); + isContain('Last Message: 1, 1, 1', $cmdResult->std); $cmdResult = Helper::executeVirtaul('test:progress', ['case' => 'array-assoc']); isContain('Progress of array-assoc', $cmdResult->std); - isContain('Last Step Message: value_2, key_2, 1', $cmdResult->std); + isContain('Last Message: value_2, key_2, 1', $cmdResult->std); } public function testNoItems(): void @@ -67,7 +67,7 @@ public function testNoItems(): void isSame('no-items-data. Number of items is 0 or less.', $cmdResult->std); } - public function testProgressMessages(): void + public function testProgressMessagesLegacy(): void { $cmdResult = $this->exec('no-messages'); isSame('', $cmdResult->err); @@ -157,6 +157,21 @@ public function testProgressMessages(): void $cmdResult->std, ); + $cmdResult = $this->exec('output-as-array'); + isSame('', $cmdResult->err); + isSame(0, $cmdResult->code); + isSame( + \implode("\n", [ + 'Working on "output-as-array". Number of steps: 2.', + ' * (key_1/0): value_1; key_1; 0', + ' * (key_2/1): value_2; key_2; 1', + ]), + $cmdResult->std, + ); + } + + public function testProgressBreakLegacyReturnMessage(): void + { $cmdResult = $this->exec('break'); isSame('', $cmdResult->err); isSame(0, $cmdResult->code); @@ -164,19 +179,22 @@ public function testProgressMessages(): void \implode("\n", [ 'Working on "break". Number of steps: 3.', ' * (0): 0', - ' * (1): Progress stopped', + ' * (1): Progress aborted.', ]), $cmdResult->std, ); + } - $cmdResult = $this->exec('output-as-array'); + public function testProgressBreakWithException(): void + { + $cmdResult = $this->exec('break-exception'); isSame('', $cmdResult->err); isSame(0, $cmdResult->code); isSame( \implode("\n", [ - 'Working on "output-as-array". Number of steps: 2.', - ' * (key_1/0): value_1; key_1; 0', - ' * (key_2/1): value_2; key_2; 1', + 'Working on "break-exception". Number of steps: 3.', + ' * (0): 0', + ' * (1): Progress aborted. Something went wrong with $listValue=1', ]), $cmdResult->std, ); @@ -249,8 +267,8 @@ public function testBatchException(): void isContain('* (3): Exception #3', $cmdResult->err); isContain('* (6): Exception #6', $cmdResult->err); isContain('* (9): Exception #9', $cmdResult->err); - isContain('Caught exceptions : 4', $cmdResult->err); - isContain('Last Step Message : Exception: Exception #9', $cmdResult->err); + isContain('Caught exceptions : 4', $cmdResult->err); + isContain('Last Message : Exception: Exception #9', $cmdResult->err); isContain('Exception trace:', $cmdResult->err); isEmpty($cmdResult->std, $cmdResult->std); } diff --git a/tests/TestApp/Commands/TestProgress.php b/tests/TestApp/Commands/TestProgress.php index f43e326..308e0dc 100644 --- a/tests/TestApp/Commands/TestProgress.php +++ b/tests/TestApp/Commands/TestProgress.php @@ -18,6 +18,7 @@ use JBZoo\Cli\CliCommand; use JBZoo\Cli\Exception; +use JBZoo\Cli\ProgressBars\ExceptionBreak; use JBZoo\Cli\ProgressBars\ProgressBar; use JBZoo\Cli\ProgressBars\ProgressBarSymfony; use Symfony\Component\Console\Input\InputOption; @@ -53,9 +54,9 @@ protected function executeAction(): int } if ($testCase === 'one-message') { - $this->progressBar(3, static function ($stepValue, $stepIndex, $currentStep) { - if ($stepValue === 1) { - return "{$stepValue}, {$stepIndex}, {$currentStep}"; + $this->progressBar(3, static function ($listValue, $listKey, $stepIndex) { + if ($listValue === 1) { + return "{$listValue}, {$listKey}, {$stepIndex}"; } }, $testCase); } @@ -64,7 +65,7 @@ protected function executeAction(): int $list = ['key_1' => 'value_1', 'key_2' => 'value_2']; $this->progressBar( $list, - static fn ($stepValue, $stepIndex, $currentStep) => "{$stepValue}, {$stepIndex}, {$currentStep}", + static fn ($listValue, $listKey, $stepIndex) => "{$listValue}, {$listKey}, {$stepIndex}", $testCase, ); } @@ -84,14 +85,14 @@ protected function executeAction(): int // // old tests if ($testCase === 'no-messages') { - $this->progressBar(3, static function ($stepValue, $stepIndex, $currentStep): void { + $this->progressBar(3, static function ($listValue, $listKey, $stepIndex): void { }, $testCase); } if ($testCase === 'simple-message-all') { $this->progressBar( 3, - static fn ($stepValue, $stepIndex, $currentStep) => "{$stepValue}, {$stepIndex}, {$currentStep}", + static fn ($listValue, $listKey, $stepIndex) => "{$listValue}, {$listKey}, {$stepIndex}", $testCase, ); } @@ -100,7 +101,7 @@ protected function executeAction(): int $list = ['key_1' => 'value_1', 'key_2' => 'value_2']; $this->progressBar( $list, - static fn ($stepValue, $stepIndex, $currentStep) => [$stepValue, $stepIndex, $currentStep], + static fn ($listValue, $listKey, $stepIndex) => [$listValue, $listKey, $stepIndex], $testCase, ); } @@ -108,7 +109,7 @@ protected function executeAction(): int if ($testCase === 'array-int') { $this->progressBar( [4, 5, 6], - static fn ($stepValue, $stepIndex, $currentStep) => "{$stepValue}, {$stepIndex}, {$currentStep}", + static fn ($listValue, $listKey, $stepIndex) => "{$listValue}, {$listKey}, {$stepIndex}", $testCase, ); } @@ -116,7 +117,7 @@ protected function executeAction(): int if ($testCase === 'array-string') { $this->progressBar( ['qwerty', 'asdfgh'], - static fn ($stepValue, $stepIndex, $currentStep) => "{$stepValue}, {$stepIndex}, {$currentStep}", + static fn ($listValue, $listKey, $stepIndex) => "{$listValue}, {$listKey}, {$stepIndex}", $testCase, ); } @@ -125,39 +126,49 @@ protected function executeAction(): int $list = json(['key_1' => 'value_1', 'key_2' => 'value_2']); $this->progressBar( $list, - static fn ($stepValue, $stepIndex, $currentStep) => "{$stepValue}, {$stepIndex}, {$currentStep}", + static fn ($listValue, $listKey, $stepIndex) => "{$listValue}, {$listKey}, {$stepIndex}", $testCase, ); } if ($testCase === 'break') { - $this->progressBar(3, static function ($stepValue) { - if ($stepValue === 1) { + $this->progressBar(3, static function ($listValue) { + if ($listValue === 1) { return ProgressBarSymfony::BREAK; } - return $stepValue; + return $listValue; + }, $testCase); + } + + if ($testCase === 'break-exception') { + $this->progressBar(3, static function ($listValue) { + if ($listValue === 1) { + throw new ExceptionBreak("Something went wrong with \$listValue={$listValue}"); + } + + return $listValue; }, $testCase); } if ($testCase === 'exception') { - $this->progressBar(3, static function ($stepValue): void { - if ($stepValue === 1) { - throw new \Exception("Exception #{$stepValue}"); + $this->progressBar(3, static function ($listValue): void { + if ($listValue === 1) { + throw new \Exception("Exception #{$listValue}"); } }, $testCase, $this->getOptBool('batch-exception')); } if ($testCase === 'exception-list') { - $this->progressBar(10, static function ($stepValue): void { - if ($stepValue % 3 === 0) { - throw new \RuntimeException("Exception #{$stepValue}"); + $this->progressBar(10, static function ($listValue): void { + if ($listValue % 3 === 0) { + throw new \RuntimeException("Exception #{$listValue}"); } }, $testCase, $this->getOptBool('batch-exception')); } if ($testCase === 'million-items') { - $this->progressBar(100000, static fn ($stepValue) => $stepValue, $testCase); + $this->progressBar(100000, static fn ($listValue) => $listValue, $testCase); } if ($testCase === 'memory-leak') { From 17b82158014e4230575f2963da75e36fbd8f5b8f Mon Sep 17 00:00:00 2001 From: Denis SmetDate: Thu, 10 Aug 2023 00:44:59 +0400 Subject: [PATCH 37/53] Predefined output formats --- src/ProgressBars/AbstractSymfonyProgressBar.php | 2 +- src/ProgressBars/ProgressBarLight.php | 2 +- src/ProgressBars/ProgressBarSymfony.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ProgressBars/AbstractSymfonyProgressBar.php b/src/ProgressBars/AbstractSymfonyProgressBar.php index a6ac7b9..d3e6f07 100644 --- a/src/ProgressBars/AbstractSymfonyProgressBar.php +++ b/src/ProgressBars/AbstractSymfonyProgressBar.php @@ -71,7 +71,7 @@ protected function configureProgressBar(bool $optimizeMode = true): void static fn (): string => SymfonyHelper::formatMemory(\memory_get_usage(false)), ); - // Time optimizations + // Memory/Time optimizations if ($optimizeMode) { return; } diff --git a/src/ProgressBars/ProgressBarLight.php b/src/ProgressBars/ProgressBarLight.php index 1db2573..0ca5596 100644 --- a/src/ProgressBars/ProgressBarLight.php +++ b/src/ProgressBars/ProgressBarLight.php @@ -100,7 +100,7 @@ private function handleOneStep(mixed $stepValue, int|float|string $stepIndex, in try { $callbackResults = (array)($this->callback)($stepValue, $stepIndex, $currentIndex); } catch (ExceptionBreak $exception) { - $callbackResults[] = static::BREAK . ' ' . $exception->getMessage(); + $callbackResults[] = ExceptionBreak::MESSAGE . ' ' . $exception->getMessage(); } catch (\Exception $exception) { if ($this->throwBatchException) { $errorMessage = 'Exception: ' . $exception->getMessage(); diff --git a/src/ProgressBars/ProgressBarSymfony.php b/src/ProgressBars/ProgressBarSymfony.php index 9dc1f3c..04ed462 100644 --- a/src/ProgressBars/ProgressBarSymfony.php +++ b/src/ProgressBars/ProgressBarSymfony.php @@ -83,7 +83,7 @@ public function execute(): bool $this->progressBar->setMessage($errMessage, 'jbzoo_caught_exceptions'); } - if (\str_contains($stepResult, self::BREAK)) { + if (\str_contains($stepResult, ExceptionBreak::MESSAGE)) { $isSkipped = true; break; } From 7ee1120a4ad287e659e1ce68f1b83484afa52b01 Mon Sep 17 00:00:00 2001 From: Denis Smet Date: Thu, 10 Aug 2023 09:59:24 +0400 Subject: [PATCH 38/53] Predefined output formats --- README.md | 6 +- demo/Commands/DemoProgressBar.php | 40 +++++---- demo/movies/ProgressBar.sh | 85 ------------------- demo/movies/demo-magic.sh | 6 +- demo/movies/{Output.sh => output.sh} | 22 ++++- demo/movies/progress-bar.sh | 105 ++++++++++++++++++++++++ src/OutputMods/Logstash.php | 2 +- src/ProgressBars/ProgressBarSymfony.php | 6 +- tests/CliProgressTest.php | 10 +-- 9 files changed, 161 insertions(+), 121 deletions(-) delete mode 100755 demo/movies/ProgressBar.sh rename demo/movies/{Output.sh => output.sh} (88%) create mode 100755 demo/movies/progress-bar.sh diff --git a/README.md b/README.md index d17a5ca..0cf621e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,11 @@ The library greatly extends the functionality of [Symfony/Console](https://symfo ## Live Demo -[![asciicast](https://asciinema.org/a/486674.svg)](https://asciinema.org/a/486674) +### Output regular messages +[![asciicast](https://asciinema.org/a/601633.svg)](https://asciinema.org/a/601633?autoplay=1&startAt=4) + +### Progress Bar +[![asciicast](https://asciinema.org/a/601621.svg)](https://asciinema.org/a/601621?autoplay=1&startAt=2) diff --git a/demo/Commands/DemoProgressBar.php b/demo/Commands/DemoProgressBar.php index 612f195..95baa34 100644 --- a/demo/Commands/DemoProgressBar.php +++ b/demo/Commands/DemoProgressBar.php @@ -27,10 +27,10 @@ class DemoProgressBar extends CliCommand private const CASE_SIMPLE = 'simple'; private const CASE_MESSAGES = 'messages'; private const CASE_ARRAY = 'array'; - private const CASE_MILLION = 'million'; private const CASE_BREAK = 'break'; private const CASE_EXCEPTION = 'exception'; private const CASE_EXCEPTION_LIST = 'exception-list'; + private const CASE_MILLION = 'million'; protected function configure(): void { @@ -50,10 +50,10 @@ protected function executeAction(): int self::CASE_SIMPLE, self::CASE_MESSAGES, self::CASE_ARRAY, - self::CASE_MILLION, self::CASE_BREAK, self::CASE_EXCEPTION, self::CASE_EXCEPTION_LIST, + self::CASE_MILLION, ]); // Just 5 simple steps ///////////////////////////////////////////////////////////////////////////////////////// @@ -65,10 +65,10 @@ protected function executeAction(): int // Simple progress with custom message based on callback arguments ///////////////////////////////////////////// if ($caseName === self::CASE_MESSAGES) { - $this->progressBar(5, function ($value, $key, $step) { + $this->progressBar($listOfUsers, function ($value, $key, $step) { $this->sleep(); - return " Args \$value={$value}, \$key={$key}, \$step={$step}"; + return "Callback Args \$value={$value}, \$key={$key}, \$step={$step}"; }, 'Custom messages based on callback arguments'); } @@ -78,20 +78,10 @@ protected function executeAction(): int $this->progressBar($listOfUsers, function ($value, $key, $step) { $this->sleep(); - return "Args \$value={$value}, \$key={$key}, \$step={$step}"; + return "Callback Args \$value={$value}, \$key={$key}, \$step={$step}"; }, 'Handling associated array as a data source'); } - // 1 000 000 Items Benchmark /////////////////////////////////////////////////////////////////////////////////// - if ($caseName === self::CASE_MILLION) { - $this->progressBar( - 1_000_000, - static fn ($value, $key, $step) => 'Args ' . - "\$value={$value}, \$key={$key}, \$step={$step}", - '1 000 000 items benchmark', - ); - } - // Exit the loop programmatically ////////////////////////////////////////////////////////////////////////////// if ($caseName === self::CASE_BREAK) { dump($listOfUsers); @@ -101,7 +91,7 @@ protected function executeAction(): int throw new ExceptionBreak("Something went wrong with \$value={$value}"); } - return "Args \$value={$value}, \$key={$key}, \$step={$step}"; + return "Callback Args \$value={$value}, \$key={$key}, \$step={$step}"; }, 'Exit the loop programmatically'); } @@ -110,7 +100,7 @@ protected function executeAction(): int $this->progressBar(5, function ($value) { $this->sleep(); if ($value === 1) { - throw new Exception("Exception #{$value}"); + throw new Exception("Something went really wrong on step #{$value}"); } return "\$value={$value}"; @@ -119,16 +109,24 @@ protected function executeAction(): int // Ignoring and collecting exceptions. Throw an error only at the end. ///////////////////////////////////////// if ($caseName === self::CASE_EXCEPTION_LIST) { - $this->progressBar(10, function ($value) { + $this->progressBar(10, function ($value): void { $this->sleep(); if ($value % 3 === 0) { - throw new \RuntimeException("\$value={$value}; " . Factory::create()->realText(50)); + throw new Exception("Something went really wrong on step #{$value}"); } - - return "\$value={$value}"; }, 'Ignoring and collecting exceptions. Throw them only at the end.', true); } + // 1 000 000 Items Benchmark /////////////////////////////////////////////////////////////////////////////////// + if ($caseName === self::CASE_MILLION) { + $this->progressBar( + 1_000_000, + static fn ($value, $key, $step) => 'Callback Args ' . + "\$value={$value}, \$key={$key}, \$step={$step}", + '1 000 000 items benchmark', + ); + } + return self::SUCCESS; } diff --git a/demo/movies/ProgressBar.sh b/demo/movies/ProgressBar.sh deleted file mode 100755 index 6ad746d..0000000 --- a/demo/movies/ProgressBar.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env sh - -# -# JBZoo Toolbox - Cli. -# -# This file is part of the JBZoo Toolbox project. -# For the full copyright and license information, please view the LICENSE -# file that was distributed with this source code. -# -# @license MIT -# @copyright Copyright (C) JBZoo.com, All rights reserved. -# @see https://github.com/JBZoo/Cli -# - -. demo-magic.sh - -cd .. -clear -PROMPT_TIMEOUT=1 - -pei "# " -pei "# See it here './demo/Commands/ExamplesOutput.php'" -pei "# Let's get started!" -wait - - -pei "# At first, let me show you the output of the command by default." -pei "./my-app progress-bar --case=simple" -wait -pei "clear" - - -pei "# " -pei "./my-app progress-bar --case=simple -v" -wait -pei "clear" - -pei "# " -pei "./my-app progress-bar --case=simple -vv" -wait -pei "clear" - -pei "# " -pei "./my-app progress-bar --case=simple -vvv" -wait -pei "clear" - -pei "# " -pei "./my-app progress-bar --case=simple --no-progress" -wait -pei "clear" - -pei "# " -pei "./my-app progress-bar --case=simple --cron" -wait -pei "clear" - -pei "# " -pei "./my-app progress-bar --case=assoc" -wait -pei "clear" - -pei "# " -pei "./my-app progress-bar --case=break" -wait -pei "clear" - -pei "# " -pei "./my-app progress-bar --case=exception" -wait -pei "clear" - -pei "# " -pei "./my-app progress-bar --case=exception-list" -wait -pei "clear" - - - - -pei "##############################" -pei "# That's all for this demo. #" -pei "# Have a nice day =) #" -pei "# Thank you! #" -pei "##############################" diff --git a/demo/movies/demo-magic.sh b/demo/movies/demo-magic.sh index 9337fce..1eafd1c 100755 --- a/demo/movies/demo-magic.sh +++ b/demo/movies/demo-magic.sh @@ -24,13 +24,13 @@ ############################################################################### # the speed to "type" the text -TYPE_SPEED=100 +TYPE_SPEED=20 # no wait after "p" or "pe" NO_WAIT=false # if > 0, will pause for this amount of seconds before automatically proceeding with any p or pe -PROMPT_TIMEOUT=1 +PROMPT_TIMEOUT=7 # don't show command number unless user specifies it SHOW_CMD_NUMS=false @@ -54,7 +54,7 @@ C_NUM=0 # prompt and command color which can be overriden DEMO_PROMPT="\n$GREEN>$COLOR_RESET " DEMO_CMD_COLOR=$WHITE -DEMO_COMMENT_COLOR=$GREY +DEMO_COMMENT_COLOR=$WHITE ## # prints the script usage diff --git a/demo/movies/Output.sh b/demo/movies/output.sh similarity index 88% rename from demo/movies/Output.sh rename to demo/movies/output.sh index d0767b3..cf34d20 100755 --- a/demo/movies/Output.sh +++ b/demo/movies/output.sh @@ -16,7 +16,6 @@ cd .. -# Hide the evidence clear PROMPT_TIMEOUT=7 @@ -24,11 +23,13 @@ PROMPT_TIMEOUT=7 pei "# This is demo of different output levels of JBZoo/Cli framework." pei "# We have a lot of output levels, which can be used for different purposes." pei "# For the demo I've prepared a simple script, which will print some messages." -pei "# See it here './demo/Commands/ExamplesOutput.php'" +pei "# See it here './demo/Commands/DemoOutput.php'" pei "# Let's get started!" wait +pei "" +pei "" pei "# At first, let me show you the output of the command by default." pei "./my-app output" wait @@ -83,6 +84,19 @@ wait pei "clear" +pei "# You can quickly switch to crontab mode" +pei "./my-app output --output-mode=cron" +wait +pei "clear" + + +pei "" +pei "# It's ready for ELK Stack (Logstash)." +pei "./my-app output --output-mode=logstash | jq" +wait +pei "clear" + + pei "# Let's simulate a fatal error." pei "./my-app output --throw-custom-exception" wait @@ -91,6 +105,8 @@ pei "clear" pei "# Sometimes we have to ignore exception not to break the pipeline." pei "./my-app output --throw-custom-exception --mute-errors -vvv" +pei "" +pei "" pei "# Look at the last lines." wait pei "clear" @@ -98,6 +114,8 @@ pei "clear" pei "# In rare cases we can use the flag '--non-zero-on-error' to return ExitCode=1 if any stderr happend." pei "./my-app output --non-zero-on-error -vvv" +pei "" +pei "" pei "# Look at the last lines." wait pei "clear" diff --git a/demo/movies/progress-bar.sh b/demo/movies/progress-bar.sh new file mode 100755 index 0000000..261f2b2 --- /dev/null +++ b/demo/movies/progress-bar.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env sh + +# +# JBZoo Toolbox - Cli. +# +# This file is part of the JBZoo Toolbox project. +# For the full copyright and license information, please view the LICENSE +# file that was distributed with this source code. +# +# @license MIT +# @copyright Copyright (C) JBZoo.com, All rights reserved. +# @see https://github.com/JBZoo/Cli +# + +. demo-magic.sh + +cd .. +clear + +pei "# In this demo, you will see the basic features of Progress Bar for CLI app." +pei "# ProgressBar helps you perform looping actions and output extra info to profile your app." +pei "# Now we will take a look at its main features." +pei "# See it here './demo/Commands/DemoOutput.php'" +pei "# Let's get started!" +wait + + +pei "" +pei "" +pei "# At first, let me show you the output of the progress by default." +pei "./my-app progress-bar --case=simple" +wait +pei "clear" + + +pei "# Different levels of verbosity give different levels of detail." + +pei "" +pei "" +pei "# Verbose level (-v)" +pei "./my-app progress-bar --case=messages -v" +wait + +pei "" +pei "# Very Verbose level (-vv)" +pei "./my-app progress-bar --case=messages -vv" +wait + +pei "" +pei "# Debug level, max (-vvv)" +pei "./my-app progress-bar --case=messages -vvv" +wait +pei "clear" + + +pei "# You can use any iterated object as a data source for the widget." +pei "# Let's look at an associative array as an example." +pei "./my-app progress-bar --case=array" +pei "# As you can see, you can customize the message. This is useful for logs." +wait +pei "clear" + + +pei "# You can easily disable progress bar" +pei "./my-app progress-bar --case=messages --no-progress" +wait + +pei "" +pei "# Or quickly switch to crontab mode" +pei "./my-app progress-bar --case=messages --output-mode=cron" +wait +pei "clear" + + +pei "" +pei "# It's ready for ELK Stack (Logstash)." +pei "./my-app progress-bar --case=messages --output-mode=logstash | jq" +wait +pei "clear" + + +pei "# It is easy to interrupt the execution." +pei "./my-app progress-bar --case=break" +wait +pei "clear" + + +pei "# If an unexpected error occurs, it will stop execution and display detailed information on the screen." +pei "./my-app progress-bar --case=exception -vv" +wait +pei "clear" + + +pei "# You can catch all exceptions without interrupting execution." +pei "# And output only one single generic message at the end." +pei "./my-app progress-bar --case=exception-list -vv" +wait +pei "clear" + + +pei "##############################" +pei "# That's all for this demo. #" +pei "# Have a nice day =) #" +pei "# Thank you! #" +pei "##############################" diff --git a/src/OutputMods/Logstash.php b/src/OutputMods/Logstash.php index b64ddb6..e500404 100644 --- a/src/OutputMods/Logstash.php +++ b/src/OutputMods/Logstash.php @@ -121,7 +121,7 @@ protected function printMessage( } if ($message !== null) { - $this->logger->log($psrErrorLevel, $message, $context); + $this->logger->log($psrErrorLevel, \strip_tags($message), $context); } } diff --git a/src/ProgressBars/ProgressBarSymfony.php b/src/ProgressBars/ProgressBarSymfony.php index 04ed462..ae3a682 100644 --- a/src/ProgressBars/ProgressBarSymfony.php +++ b/src/ProgressBars/ProgressBarSymfony.php @@ -167,7 +167,7 @@ protected function buildTemplate(): string ]); } - $footerLine['Last Message'] = '%message%'; + $footerLine['Last Step Message'] = '%message%'; return \implode("\n", $progressBarLines) . "\n" . CliRender::list($footerLine) . "\n"; } @@ -226,7 +226,7 @@ private function handleOneStep(mixed $stepValue, int|float|string $stepIndex, in try { $callbackResults = (array)($this->callback)($stepValue, $stepIndex, $currentIndex); } catch (ExceptionBreak $exception) { - $callbackResults[] = '' . ExceptionBreak::MESSAGE . ' ' . $exception->getMessage(); + $callbackResults[] = '' . ExceptionBreak::MESSAGE . ' ' . $exception->getMessage(); } catch (\Exception $exception) { if ($this->throwBatchException) { $errorMessage = 'Exception: ' . $exception->getMessage(); @@ -296,7 +296,7 @@ private function createProgressBar(): ?SymfonyProgressBar private function isOptimizeMode(): bool { - return $this->outputMode->getOutput()->getVerbosity() < OutputInterface::OUTPUT_NORMAL; + return $this->outputMode->getOutput()->getVerbosity() <= OutputInterface::VERBOSITY_NORMAL; } private static function showListOfExceptions(array $exceptionMessages): void diff --git a/tests/CliProgressTest.php b/tests/CliProgressTest.php index 7edb310..1a25482 100644 --- a/tests/CliProgressTest.php +++ b/tests/CliProgressTest.php @@ -27,7 +27,7 @@ public function testMinimal(): void isContain('0% (0 / 2) [>', $cmdResult->err); isContain('50% (1 / 2) [•', $cmdResult->err); isContain('100% (2 / 2) [•', $cmdResult->err); - isContain('Last Message: n/a', $cmdResult->err); + isContain('Last Step Message: n/a', $cmdResult->err); $cmdResult = Helper::executeReal('test:progress', ['case' => 'minimal', 'stdout-only' => null, 'sleep' => 1]); isSame(0, $cmdResult->code); @@ -35,18 +35,18 @@ public function testMinimal(): void isContain('0% (0 / 2) [>', $cmdResult->std); isContain('50% (1 / 2) [•', $cmdResult->std); isContain('100% (2 / 2) [•', $cmdResult->std); - isContain('Last Message: n/a', $cmdResult->std); + isContain('Last Step Message: n/a', $cmdResult->std); } public function testMinimalVirtual(): void { $cmdResult = Helper::executeVirtaul('test:progress', ['case' => 'one-message', 'ansi' => null]); isContain('Progress of one-message', $cmdResult->std); - isContain('Last Message: 1, 1, 1', $cmdResult->std); + isContain('Last Step Message: 1, 1, 1', $cmdResult->std); $cmdResult = Helper::executeVirtaul('test:progress', ['case' => 'array-assoc']); isContain('Progress of array-assoc', $cmdResult->std); - isContain('Last Message: value_2, key_2, 1', $cmdResult->std); + isContain('Last Step Message: value_2, key_2, 1', $cmdResult->std); } public function testNoItems(): void @@ -268,7 +268,7 @@ public function testBatchException(): void isContain('* (6): Exception #6', $cmdResult->err); isContain('* (9): Exception #9', $cmdResult->err); isContain('Caught exceptions : 4', $cmdResult->err); - isContain('Last Message : Exception: Exception #9', $cmdResult->err); + isContain('Last Step Message : Exception: Exception #9', $cmdResult->err); isContain('Exception trace:', $cmdResult->err); isEmpty($cmdResult->std, $cmdResult->std); } From 23c298c40a5abb4f7312b253b0548d454a662563 Mon Sep 17 00:00:00 2001 From: Denis SmetDate: Thu, 10 Aug 2023 10:13:28 +0400 Subject: [PATCH 39/53] Predefined output formats --- README.md | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0cf621e..31c69f4 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,21 @@ The library greatly extends the functionality of [Symfony/Console](https://symfony.com/doc/current/components/console.html) and helps make creating new console utilities in PHP quicker and easier. - * Improved progress bar with a new template and additional information. See [ExamplesProgressBar.php](demo/Commands/ExamplesProgressBar.php). - * Convert option values to a strict variable type. See [ExamplesOptionsStrictTypes.php](demo/Commands/ExamplesOptionsStrictTypes.php). - * New built-in styles and colors for text output. See [ExamplesStyles.php](demo/Commands/ExamplesStyles.php). - * A powerful alias `$this->_($messages, $level)` instead of `output->wrileln()`. See [ExamplesOutput.php](demo/Commands/ExamplesOutput.php). - * Display timing and memory usage information with `--profile` option. - * Show timestamp at the beginning of each message with `--timestamp` option. - * Mute any sort of errors. So exit code will be always `0` (if it's possible) with `--mute-errors`. - * None-zero exit code on any StdErr message with `--non-zero-on-error` option. - * For any errors messages application will use StdOut instead of StdErr `--stdout-only` option (It's on your own risk!). - * Disable progress bar animation for logs with `--no-progress` option. - * Shortcut for crontab `--cron`. It's basically focused on logs output. It's combination of `--timestamp --profile --stdout-only --no-progress -vv`. + * Improved ProgressBar for loop actions and extra debug info. See [DemoProgressBar.php](demo/Commands/DemoProgressBar.php) and see [Live Demo](https://asciinema.org/a/601633?autoplay=1&startAt=4). + * Convert option values to a strict variable type. See [DemoOptionsStrictTypes.php](demo/Commands/DemoOptionsStrictTypes.php). + * New built-in styles and colors for text output. See [DemoStyles.php](demo/Commands/DemoStyles.php). + * A powerful alias `$this->_($messages, $level, $context)`, or `cli($messages, $level, $context)`. See [DemoOutput.php](demo/Commands/DemoOutput.php). + * Extra options: + * Display timing and memory usage information with `--profile` (`-X`) option. + * Show timestamp at the beginning of each message with `--timestamp` (`-T`) option. + * Mute any sort of errors. So exit code will be always `0` (if it's possible) with `--mute-errors` (`-M`). + * None-zero exit code on any StdErr message with `--non-zero-on-error` (`-Z`) option. + * For any errors messages application will use StdOut instead of StdErr `--stdout-only` (`-1`) option (It's on your own risk!). + * Disable progress bar animation for logs with `--no-progress` (`-P`) option. + * Different output modes: + * `--output-mode=text`. By default, text output format. Userfriendly and easy to read. + * `--output-mode=cron`. It's basically focused on logs output. It's combination of `--timestamp --profile --stdout-only --no-progress -vv`. + * `--output-mode=logstash`. It's basically focused on Logstash format for ELT Stack. Also, it's `--stdout-only --no-progress -vv`. ## Live Demo @@ -134,7 +138,7 @@ $application->run(); -The simplest CLI action: [./demo/Commands/Simple.php](demo/Commands/Simple.php) +The simplest CLI action: [./demo/Commands/DemoSimple.php](demo/Commands/DemoSimple.php) See Details
@@ -178,7 +182,7 @@ The simplest CLI action: [./demo/Commands/Simple.php](demo/Commands/Simple.php) ### Sanitize input variables -As live-demo take a look at demo application - [./demo/Commands/ExamplesOptionsStrictTypes.php](demo/Commands/ExamplesOptionsStrictTypes.php). +As live-demo take a look at demo application - [./demo/Commands/DemoOptionsStrictTypes.php](demo/Commands/DemoOptionsStrictTypes.php). Try to launch `./my-app examples:options-strict-types`. @@ -269,7 +273,7 @@ And predefined shortcuts for standard styles of Symfony Console Console commands have different verbosity levels, which determine the messages displayed in their output. -As live-demo take a look at demo application - [./demo/Commands/ExamplesOutput.php](demo/Commands/ExamplesOutput.php). You can see [Demo video](https://asciinema.org/a/486674). +As live-demo take a look at demo application - [./demo/Commands/ExamplesOutput.php](demo/Commands/DemoOutput.php). You can see [Demo video](https://asciinema.org/a/486674). Example of usage of verbosity levels @@ -308,7 +312,7 @@ As result, you will see ### Memory and time profiling -As live-demo take a look at demo application - [./demo/Commands/ExamplesProfile.php](demo/Commands/ExamplesProfile.php). +As live-demo take a look at demo application - [./demo/Commands/DemoProfile.php](demo/Commands/DemoProfile.php). Try to launch `./my-app examples:profile --profile`. @@ -330,7 +334,7 @@ No need to bother with the logging setup as Symfony/Console suggests. Just add t ### Helper Functions -As live-demo take a look at demo application - [./demo/Commands/ExamplesHelpers.php](demo/Commands/ExamplesHelpers.php). +As live-demo take a look at demo application - [./demo/Commands/DemoHelpers.php](demo/Commands/DemoHelpers.php). Try to launch `./my-app examples:helpers`. From c1f28c3ebf40fe2952167c55333b54cab9470490 Mon Sep 17 00:00:00 2001 From: Denis SmetDate: Thu, 10 Aug 2023 10:54:22 +0400 Subject: [PATCH 40/53] Predefined output formats --- README.md | 14 +++++--------- demo/composer.json | 6 +----- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 31c69f4..1c8cfa3 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ The library greatly extends the functionality of [Symfony/Console](https://symfo * Disable progress bar animation for logs with `--no-progress` (`-P`) option. * Different output modes: * `--output-mode=text`. By default, text output format. Userfriendly and easy to read. - * `--output-mode=cron`. It's basically focused on logs output. It's combination of `--timestamp --profile --stdout-only --no-progress -vv`. - * `--output-mode=logstash`. It's basically focused on Logstash format for ELT Stack. Also, it's `--stdout-only --no-progress -vv`. + * `--output-mode=cron` (`-Ocron`). It's basically focused on logs output. It's combination of `--timestamp --profile --stdout-only --no-progress -vv --no-ansi`. + * `--output-mode=logstash` (`-Ologstash`). It's basically focused on Logstash format for ELK Stack. Also, it's `--stdout-only --no-progress -vv`. ## Live Demo @@ -69,12 +69,8 @@ The simplest CLI application has the following file structure. See the [Demo App "keywords" : ["cli", "application", "example"], "require" : { - "php" : ">=7.4", - "jbzoo/cli" : "^2.0.0" - }, - - "require-dev" : { - "roave/security-advisories" : "dev-latest" + "php" : ">=8.1", + "jbzoo/cli" : "^7.1.0" }, "autoload" : { @@ -332,7 +328,7 @@ No need to bother with the logging setup as Symfony/Console suggests. Just add t -### Helper Functions +## Helper Functions As live-demo take a look at demo application - [./demo/Commands/DemoHelpers.php](demo/Commands/DemoHelpers.php). diff --git a/demo/composer.json b/demo/composer.json index 1952f58..187aa29 100644 --- a/demo/composer.json +++ b/demo/composer.json @@ -7,11 +7,7 @@ "require" : { "php" : "^8.1", - "jbzoo/cli" : "7.x-dev" - }, - - "require-dev" : { - "roave/security-advisories" : "dev-latest" + "jbzoo/cli" : "^7.1.0" }, "autoload" : { From e5d9c9b21a419314656ff5b99cf5f08cf886839e Mon Sep 17 00:00:00 2001 From: Denis Smet Date: Thu, 10 Aug 2023 11:28:04 +0400 Subject: [PATCH 41/53] Predefined output formats --- README.md | 93 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 66 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 1c8cfa3..3b56977 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,35 @@ [![Stable Version](https://poser.pugx.org/jbzoo/cli/version)](https://packagist.org/packages/jbzoo/cli/) [![Total Downloads](https://poser.pugx.org/jbzoo/cli/downloads)](https://packagist.org/packages/jbzoo/cli/stats) [![Dependents](https://poser.pugx.org/jbzoo/cli/dependents)](https://packagist.org/packages/jbzoo/cli/dependents?order_by=downloads) [![GitHub License](https://img.shields.io/github/license/jbzoo/cli)](https://github.com/JBZoo/Cli/blob/master/LICENSE) + + * [Live Demo](#live-demo) + * [Output regular messages](#output-regular-messages) + * [Progress Bar](#progress-bar) + * [Installing](#installing) + * [Usage Example](#usage-example) + * [Built-in Functionality](#built-in-functionality) + * [Sanitize input variables](#sanitize-input-variables) + * [Rendering text in different colors and styles](#rendering-text-in-different-colors-and-styles) + * [Verbosity Levels](#verbosity-levels) + * [Memory and time profiling](#memory-and-time-profiling) + * [Helper Functions](#helper-functions) + * [Regualar question](#regualar-question) + * [Ask user's password](#ask-users-password) + * [Ask user to select the option](#ask-user-to-select-the-option) + * [Represent a yes/no question](#represent-a-yesno-question) + * [Rendering key=>value list](#rendering-keyvalue-list) + * [Easy logging](#easy-logging) + * [Crontab](#crontab) + * [ELK Stack. Elatcisearch / Logstash](#elk-stack-elatcisearch--logstash) + * [Useful projects and links](#useful-projects-and-links) + * [License](#license) + * [See Also](#see-also) + + + + + + The library greatly extends the functionality of [Symfony/Console](https://symfony.com/doc/current/components/console.html) and helps make creating new console utilities in PHP quicker and easier. * Improved ProgressBar for loop actions and extra debug info. See [DemoProgressBar.php](demo/Commands/DemoProgressBar.php) and see [Live Demo](https://asciinema.org/a/601633?autoplay=1&startAt=4). @@ -180,7 +209,7 @@ The simplest CLI action: [./demo/Commands/DemoSimple.php](demo/Commands/DemoSimp As live-demo take a look at demo application - [./demo/Commands/DemoOptionsStrictTypes.php](demo/Commands/DemoOptionsStrictTypes.php). -Try to launch `./my-app examples:options-strict-types`. +Try to launch `./my-app options-strict-types`. ```php // If the option has `InputOption::VALUE_NONE` it returns true/false. @@ -209,7 +238,7 @@ $value = $this->getOptArray('flag-name'); // Converts an input variable to tr $value = $this->getOptDatetime('flag-name'); // Converts an input variable to \DateTimeImmutable object. // Use standard input as input variable. -// Example. `echo " Qwerty 123 " | php ./my-app examples:agruments` +// Example. `echo " Qwerty 123 " | php ./my-app agruments` $value = self::getStdIn(); // Reads StdIn as string value. `$value === " Qwerty 123 \n"` ``` @@ -284,20 +313,20 @@ cli($messages, $verboseLevel); // This is global alias function of `$this-> ```bash # Do not output any message -./my-app examples:output -q -./my-app examples:output --quiet +./my-app output -q +./my-app output --quiet # Normal behavior, no option required. Only the most useful messages. -./my-app examples:output +./my-app output # Increase verbosity of messages -./my-app examples:output -v +./my-app output -v # Display also the informative non essential messages -./my-app examples:output -vv +./my-app output -vv # Display all messages (useful to debug errors) -./my-app examples:output -vvv +./my-app output -vvv ``` As result, you will see @@ -310,33 +339,21 @@ As result, you will see As live-demo take a look at demo application - [./demo/Commands/DemoProfile.php](demo/Commands/DemoProfile.php). -Try to launch `./my-app examples:profile --profile`. +Try to launch `./my-app profile --profile`. ![ExamplesProfile](.github/assets/ExamplesProfile.png) -### Easy logging - -No need to bother with the logging setup as Symfony/Console suggests. Just add the `--timestamp` flag and save the output to a file. Especially, this is very handy for saving cron logs. - -```bash -./my-app examples:profile --timestamp >> /path/to/crontab/logs/`date +\%Y-\%m-\%d`.log -``` - -![ExamplesProfile--timestamp](.github/assets/ExamplesProfile--timestamp.png) - - - ## Helper Functions As live-demo take a look at demo application - [./demo/Commands/DemoHelpers.php](demo/Commands/DemoHelpers.php). -Try to launch `./my-app examples:helpers`. +Try to launch `./my-app helpers`. JBZoo/Cli uses [Symfony Question Helper](https://symfony.com/doc/current/components/console/helpers/questionhelper.html) as base for aliases. -#### Regualar question +### Regualar question Ask any custom question and wait for a user's input. There is an option to set a default value. @@ -345,7 +362,7 @@ $yourName = $this->ask("What's your name?", 'Default Noname'); $this->_("Your name is \"{$yourName}\""); ``` -#### Ask user's password +### Ask user's password Ask a question and hide the response. This is particularly convenient for passwords. There is an option to set a random value as default value. @@ -355,7 +372,7 @@ $yourSecret = $this->askPassword("New password?", true); $this->_("Your secret is \"{$yourSecret}\""); ``` -#### Ask user to select the option +### Ask user to select the option If you have a predefined set of answers the user can choose from, you could use a method `askOption` which makes sure that the user can only enter a valid string from a predefined list. @@ -366,7 +383,7 @@ $selectedColor = $this->askOption("What's your favorite color?", ['Red', 'Blue', $this->_("Selected color is {$selectedColor}"); ``` -#### Represent a yes/no question +### Represent a yes/no question Suppose you want to confirm an action before actually executing it. Add the following to your command. @@ -375,7 +392,7 @@ $isConfirmed = $this->confirmation('Are you ready to execute the script?'); $this->_("Is confirmed: " . ($isConfirmed ? 'Yes' : 'No')); ``` -#### Rendering key=>value list +### Rendering key=>value list If you need to show an aligned list, use the following code. @@ -398,6 +415,28 @@ $this->_(CliRender::list([ ``` +## Easy logging + + +### Crontab + +Just add the `--output-mode=cron` flag and save the output to a file. Especially, this is very handy for saving logs for Crontab. + +```bash +./my-app output --output-mode=cron >> /path/to/crontab/logs/`date +\%Y-\%m-\%d`.log 2>&1 +``` + +![ExamplesProfile--timestamp](.github/assets/ExamplesProfile--timestamp.png) + + +### ELK Stack. Elatcisearch / Logstash + +Just add the `--output-mode=logstash` flag and save the output to a file. Especially, this is very handy for saving logs for ELK Stack. + +```bash +./my-app output --output-mode=logstash >> /path/to/logstash/logs/`date +\%Y-\%m-\%d`.log 2>&1 +``` + ## Useful projects and links From 1d54c164129c24ed815ddc936ec1888cdf63e851 Mon Sep 17 00:00:00 2001 From: Denis Smet Date: Thu, 10 Aug 2023 12:03:58 +0400 Subject: [PATCH 42/53] Predefined output formats --- Makefile | 4 +++ README.md | 101 ++++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 80 insertions(+), 25 deletions(-) diff --git a/Makefile b/Makefile index d1139a2..5ad93ba 100644 --- a/Makefile +++ b/Makefile @@ -26,3 +26,7 @@ test-all: ##@Project Run all project tests at once @make test @make codestyle @make codestyle PATH_SRC=./demo + +toc: ##@Project Generate table of contents + @echo "Generate table of contents" + @gh-md-toc --insert --no-backup --skip-header ./README.md diff --git a/README.md b/README.md index 3b56977..bf3064f 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,20 @@ + * [Why?](#why) * [Live Demo](#live-demo) * [Output regular messages](#output-regular-messages) * [Progress Bar](#progress-bar) * [Installing](#installing) - * [Usage Example](#usage-example) + * [Quck Start - Build your first CLI App](#quck-start---build-your-first-cli-app) * [Built-in Functionality](#built-in-functionality) * [Sanitize input variables](#sanitize-input-variables) * [Rendering text in different colors and styles](#rendering-text-in-different-colors-and-styles) * [Verbosity Levels](#verbosity-levels) * [Memory and time profiling](#memory-and-time-profiling) + * [Progress Bar](#progress-bar-1) + * [Simple example](#simple-example) + * [Advanced usage](#advanced-usage) * [Helper Functions](#helper-functions) * [Regualar question](#regualar-question) * [Ask user's password](#ask-users-password) @@ -22,34 +26,47 @@ * [Represent a yes/no question](#represent-a-yesno-question) * [Rendering key=>value list](#rendering-keyvalue-list) * [Easy logging](#easy-logging) + * [Simple log](#simple-log) * [Crontab](#crontab) - * [ELK Stack. Elatcisearch / Logstash](#elk-stack-elatcisearch--logstash) + * [Elatcisearch / Logstash (ELK)](#elatcisearch--logstash-elk) * [Useful projects and links](#useful-projects-and-links) * [License](#license) * [See Also](#see-also) - + -The library greatly extends the functionality of [Symfony/Console](https://symfony.com/doc/current/components/console.html) and helps make creating new console utilities in PHP quicker and easier. - - * Improved ProgressBar for loop actions and extra debug info. See [DemoProgressBar.php](demo/Commands/DemoProgressBar.php) and see [Live Demo](https://asciinema.org/a/601633?autoplay=1&startAt=4). - * Convert option values to a strict variable type. See [DemoOptionsStrictTypes.php](demo/Commands/DemoOptionsStrictTypes.php). - * New built-in styles and colors for text output. See [DemoStyles.php](demo/Commands/DemoStyles.php). - * A powerful alias `$this->_($messages, $level, $context)`, or `cli($messages, $level, $context)`. See [DemoOutput.php](demo/Commands/DemoOutput.php). - * Extra options: - * Display timing and memory usage information with `--profile` (`-X`) option. - * Show timestamp at the beginning of each message with `--timestamp` (`-T`) option. - * Mute any sort of errors. So exit code will be always `0` (if it's possible) with `--mute-errors` (`-M`). - * None-zero exit code on any StdErr message with `--non-zero-on-error` (`-Z`) option. - * For any errors messages application will use StdOut instead of StdErr `--stdout-only` (`-1`) option (It's on your own risk!). - * Disable progress bar animation for logs with `--no-progress` (`-P`) option. - * Different output modes: - * `--output-mode=text`. By default, text output format. Userfriendly and easy to read. - * `--output-mode=cron` (`-Ocron`). It's basically focused on logs output. It's combination of `--timestamp --profile --stdout-only --no-progress -vv --no-ansi`. - * `--output-mode=logstash` (`-Ologstash`). It's basically focused on Logstash format for ELK Stack. Also, it's `--stdout-only --no-progress -vv`. +## Why? + +The library greatly extends the functionality of CLI App and helps make creating new console utilities in PHP quicker and easier. Here's a summary of why this library is essential: + + * **Enhanced Functionality:** The library supercharges [Symfony/Console](https://symfony.com/doc/current/components/console.html), facilitating a more streamlined development of console utilities. + + * **Progress Bar Improvements:** Developers gain a refined progress bar suited for loop actions and enhanced with debugging information. This makes tracking task progress and diagnosing issues a breeze. See [DemoProgressBar.php](demo/Commands/DemoProgressBar.php) and see [Live Demo](https://asciinema.org/a/601633?autoplay=1&startAt=4). + * `$this->_($messages, $level, $context)` as part of CliCommand instead of Symfony/Console `$output->writeln()`. + * `cli($messages, $level, $context)` as alias for different classes. + * `$this->progressBar(iterable|int $listOrMax, \Closure $callback, string $title = '')` as part of CliCommand instead of Symfony/Console ProgressBar. + + * **Strict Type Conversion:** One notable feature allows for the strict conversion of option values, ensuring data integrity and reducing runtime errors. See [DemoOptionsStrictTypes.php](demo/Commands/DemoOptionsStrictTypes.php). + + * **Styling and Output Customization:** With built-in styles and color schemes, developers can make their console outputs more readable and visually appealing. See [DemoStyles.php](demo/Commands/DemoStyles.php). + + * **Message Aliases:** The library introduces powerful aliases for message outputs, allowing for concise and consistent command calls. This is especially helpful in maintaining clean code. + + * **Advanced Options:** Features such as profiling for performance, timestamping, error muting, and specialized output modes (like cron and logstash modes) empower developers to refine their console outputs and diagnostics according to their specific needs. + * Display timing and memory usage information with `--profile` (`-X`) option. + * Show timestamp at the beginning of each message with `--timestamp` (`-T`) option. + * Mute any sort of errors. So exit code will be always `0` (if it's possible) with `--mute-errors` (`-M`). + * None-zero exit code on any StdErr message with `--non-zero-on-error` (`-Z`) option. + * For any errors messages application will use StdOut instead of StdErr `--stdout-only` (`-1`) option (It's on your own risk!). + * Disable progress bar animation for logs with `--no-progress` (`-P`) option. + + * **Versatile Output Modes:** The library provides different output formats catering to various use cases. Whether you're focusing on user-friendly text, logs, or integration with tools like ELK Stack, there's an output mode tailored for you. + * `--output-mode=text`. By default, text output format. Userfriendly and easy to read. + * `--output-mode=cron` (`-Ocron`). It's basically focused on logs output. It's combination of `--timestamp --profile --stdout-only --no-progress -vv --no-ansi`. + * `--output-mode=logstash` (`-Ologstash`). It's basically focused on Logstash format for ELK Stack. Also, it's `--stdout-only --no-progress -vv`. ## Live Demo @@ -68,8 +85,7 @@ composer require jbzoo/cli ``` - -## Usage Example +## Quck Start - Build your first CLI App The simplest CLI application has the following file structure. See the [Demo App](demo) for more details. @@ -344,6 +360,34 @@ Try to launch `./my-app profile --profile`. ![ExamplesProfile](.github/assets/ExamplesProfile.png) +## Progress Bar + +As live-demo take a look at demo application - [./demo/Commands/DemoProgressBar.php](demo/Commands/DemoProgressBar.php) and [Live Demo](https://asciinema.org/a/601633?autoplay=1&startAt=4). + +### Simple example + +```php +$this->progressBar(5, function (): void { + // Some code in loop +}); +``` + + +### Advanced usage + +```php +$this->progressBar($arrayOfSomething, function ($value, $key, $step) { + // Some code in loop + + if ($step === 3) { + throw new ExceptionBreak("Something went wrong with \$value={$value}. Stop the loop!"); + } + + return " Callback Args \$value={$value}, \$key={$key}, \$step={$step}"; +}, 'Custom messages based on callback arguments', $throwBatchException); +``` + + ## Helper Functions @@ -417,6 +461,15 @@ $this->_(CliRender::list([ ## Easy logging +### Simple log + +```bash +./my-app output --timestamp >> /path/to/crontab/logs/`date +\%Y-\%m-\%d`.log 2>&1 +``` + +![ExamplesProfile--timestamp](.github/assets/ExamplesProfile--timestamp.png) + + ### Crontab @@ -426,10 +479,8 @@ Just add the `--output-mode=cron` flag and save the output to a file. Especially ./my-app output --output-mode=cron >> /path/to/crontab/logs/`date +\%Y-\%m-\%d`.log 2>&1 ``` -![ExamplesProfile--timestamp](.github/assets/ExamplesProfile--timestamp.png) - -### ELK Stack. Elatcisearch / Logstash +### Elatcisearch / Logstash (ELK) Just add the `--output-mode=logstash` flag and save the output to a file. Especially, this is very handy for saving logs for ELK Stack. From 8f1fd115c7464562e8f06fd556af1061ee5f4fa6 Mon Sep 17 00:00:00 2001 From: Denis SmetDate: Thu, 10 Aug 2023 12:04:07 +0400 Subject: [PATCH 43/53] Predefined output formats --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 5ad93ba..146abfc 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,7 @@ test-all: ##@Project Run all project tests at once @make codestyle @make codestyle PATH_SRC=./demo + toc: ##@Project Generate table of contents @echo "Generate table of contents" @gh-md-toc --insert --no-backup --skip-header ./README.md From 78e3bba2945a3a693ee116088bd6149e96800d59 Mon Sep 17 00:00:00 2001 From: Denis Smet Date: Thu, 10 Aug 2023 12:11:45 +0400 Subject: [PATCH 44/53] Predefined output formats --- README.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bf3064f..d7bafc4 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,12 @@ * [Live Demo](#live-demo) * [Output regular messages](#output-regular-messages) * [Progress Bar](#progress-bar) - * [Installing](#installing) * [Quck Start - Build your first CLI App](#quck-start---build-your-first-cli-app) + * [Installing](#installing) + * [File Structure](#file-structure) + * [Composer file](#composer-file) + * [Binary file](#binary-file) + * [Simple CLI Action](#simple-cli-action) * [Built-in Functionality](#built-in-functionality) * [Sanitize input variables](#sanitize-input-variables) * [Rendering text in different colors and styles](#rendering-text-in-different-colors-and-styles) @@ -34,7 +38,7 @@ * [See Also](#see-also) - + @@ -66,7 +70,7 @@ The library greatly extends the functionality of CLI App and helps make creating * **Versatile Output Modes:** The library provides different output formats catering to various use cases. Whether you're focusing on user-friendly text, logs, or integration with tools like ELK Stack, there's an output mode tailored for you. * `--output-mode=text`. By default, text output format. Userfriendly and easy to read. * `--output-mode=cron` (`-Ocron`). It's basically focused on logs output. It's combination of `--timestamp --profile --stdout-only --no-progress -vv --no-ansi`. - * `--output-mode=logstash` (`-Ologstash`). It's basically focused on Logstash format for ELK Stack. Also, it's `--stdout-only --no-progress -vv`. + * `--output-mode=logstash` (`-Ologstash`). It's basically focused on Logstash format for ELK Stack. Also, it means `--stdout-only --no-progress -vv`. ## Live Demo @@ -78,17 +82,19 @@ The library greatly extends the functionality of CLI App and helps make creating -## Installing +## Quck Start - Build your first CLI App + +### Installing ```sh composer require jbzoo/cli ``` -## Quck Start - Build your first CLI App - The simplest CLI application has the following file structure. See the [Demo App](demo) for more details. + +### File Structure ``` /path/to/app/ my-app # Binrary file (See below) @@ -100,6 +106,8 @@ The simplest CLI application has the following file structure. See the [Demo App ``` +### Composer file + [./demo/composer.json](demo/composer.json) @@ -129,6 +137,8 @@ The simplest CLI application has the following file structure. See the [Demo App+### Binary file + Binary file: [demo/my-app](demo/my-app)@@ -179,6 +189,8 @@ $application->run();+### Simple CLI Action + The simplest CLI action: [./demo/Commands/DemoSimple.php](demo/Commands/DemoSimple.php)From 60a5e8a0b3763032a63ef861a952474c787c2ba4 Mon Sep 17 00:00:00 2001 From: Denis SmetDate: Thu, 10 Aug 2023 12:18:06 +0400 Subject: [PATCH 45/53] Predefined output formats --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index d7bafc4..a443ae8 100644 --- a/README.md +++ b/README.md @@ -501,6 +501,24 @@ Just add the `--output-mode=logstash` flag and save the output to a file. Especi ``` + +## Contributing + +```shell +# Fork the repo and build project +make build + +# Make your local changes + +# Run all tests and check code style +make test +make codestyle + +# Create your pull request and check all tests in CI +``` + + + ## Useful projects and links * [Symfony/Console Docs](https://symfony.com/doc/current/components/console.html) From 7a0bedad2596ca3fdfafccf0feeb9b386860e5c4 Mon Sep 17 00:00:00 2001 From: Denis Smet Date: Thu, 10 Aug 2023 12:20:41 +0400 Subject: [PATCH 46/53] Predefined output formats --- composer.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/composer.json b/composer.json index 8bf7728..2007ddd 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,12 @@ "cli", "command-line", "console-application", + "cron", + "crontab", + "elk", + "elk-stack", + "elastic", + "logstash", "symfony", "process", "symfony-console", From 6454071375f4e884972ccfa26fbd9040850b2067 Mon Sep 17 00:00:00 2001 From: Denis Smet Date: Thu, 10 Aug 2023 14:09:15 +0400 Subject: [PATCH 47/53] Predefined output formats --- .github/assets/ExamplesOutput-vvv.png | Bin 176500 -> 0 bytes .github/assets/ExamplesProfile--timestamp.png | Bin 43882 -> 0 bytes .github/assets/logs-logstash-exception.png | Bin 0 -> 994622 bytes .github/assets/logs-simple.png | Bin 0 -> 360290 bytes .github/assets/output-full-example.png | Bin 0 -> 907944 bytes .github/assets/output-styles.gif | Bin 0 -> 224619 bytes .../{ExamplesProfile.png => profiling.png} | Bin .github/assets/progress-full-example.gif | Bin 0 -> 285177 bytes README.md | 23 ++++++++++++------ demo/Commands/DemoProgressBar.php | 4 +-- src/OutputMods/Logstash.php | 2 +- 11 files changed, 18 insertions(+), 11 deletions(-) delete mode 100644 .github/assets/ExamplesOutput-vvv.png delete mode 100644 .github/assets/ExamplesProfile--timestamp.png create mode 100644 .github/assets/logs-logstash-exception.png create mode 100644 .github/assets/logs-simple.png create mode 100644 .github/assets/output-full-example.png create mode 100644 .github/assets/output-styles.gif rename .github/assets/{ExamplesProfile.png => profiling.png} (100%) create mode 100644 .github/assets/progress-full-example.gif diff --git a/.github/assets/ExamplesOutput-vvv.png b/.github/assets/ExamplesOutput-vvv.png deleted file mode 100644 index 8081c6afea1f24e543a79fb0e0f84ce91dbd4fc0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 176500 zcma&N1yCH_x;6>~Cpf`fLvVMu!QI_0gS!R@5Zv9}-QC?ixVwAsJKsKMpZniim46?q zrn+mYS<}7jeXK(i Hw6O&gd`-vsKkw6{y6-Y6A}LL6BYp10XGXrOmPPWF)O4Zj0X5hQkZR^ zj;U^^ih?R6*7F4gN?l!W(O;uF_jfhJaBg;a7!=;OmrqgEZI|tjlWm7G2Rt0EHYqH( zSv+9TBAiC{(B%+ ArR(Nf&fZOLTfh~Y?i87E8P zUQN^EsIV)T4bi`B2UMeixuFG *dr0zg0>zUF%mlZ0e6Q<|%v;)rNuVzhm1<9%vn=d}`qwY!b`I}T6;GS7DI{5; zhW?*p1<|nm#+^()E(*+{Xf)#SaoGoiH-5fBBJBCiD32#Rlgltg?c$Ydq@m(64^RvH zHugq^6R-_ BrfI_w*6}A f)f@7LLbcJ zwV?Le$g8%W3(c@paL|B+BXY_hta-5x+lAf51uT4XbA;@g-DpQo_)dAQwcLAH(I;vx zCD&5d0S;{93Cw~wc@gIv`bR8`pfZm-{Yd6~&X6zT57@86NkZsH5F!T9 Vu17`=V1E$`MXa~a! z$(K#k4%xTGhaE@^3ll@~Eg$Y!XfT?>1cr$Oc^Dbqk2KruE1XoIXf(bSQbhoKj!`lG z3c`YrfY4A5%QQtXLM!sUaKJR)5lgtgBqQRYpX}B$Bf42nj~zxkm|P%MUyB`KI~;vL z*;arZx)*dO#HOF;R?-zLe0IEoQ#}7D{DA0FcCP|lF-AOO1DfiOZ(^!BxC#};Nb__m z$X2lTa1mn4GoS0ftRNEniWGO837esr!JFAX(sDw7#(&0a#o`Np4lyQz_!<3ovPQ(!!1KPyEjv!x zbc#s=tf1X3lV-0qq&1;6%QaaYfeSJ{xT@f$zL#C}E7DgjFY0%&cZ_$+cP^j!Y+**q zX0%>dUST9d7DJ+(1iOrBd2EVOG}(}g{;x*3_J}p%zXf^ay+pmr^7Boy_i|a~C5!8| zN@R|!XT=Ki3gpSzVxdNY;{ma8>@ijVVE#*flL_ lA!hT1oISkBL~f8g9@|ETFqk2>JE(Q>JtMOc`GQi+8QRU5hwB|oYF=zG#PJ) z$6;2g8v|W_J}{qKotLeUakp}Va~ow4aGN^^IUTp^wOY1Pv=%wR+!uQgdI)!*b wAbiCF6)Ia5Veey!^;(42Y&3zkv%lw1)r|5a<1sR4D znijzVp&t?&1{2Dq&iS{F3xOx+Fqehcbc=zKor#_M)f`F~iX`Av$IWJXt-_Hg5H~nf zIIT|*o9Dar?_ImOtAXvV@xDofEOn+!&7Z*jn_rOttG(RePcfHy?Ro4HeBtb2i4sji z3PxzgCr14J&x4pl5B(F7+NiD+dNg|2o3THne@Xt%Vr1U5eQ$hv3yBPQ3US3srq^iQ zniZ}H#6gg!&!yd= 9DS16%I2)FMg8^5XE^Vl`@Kt+ewGQAM6-Rf*0V#*Ml852*C~=ILt<9BPBSgE z>*WQSx<=CWoz3Por#rx~V2YmDHzh!;;0kFTNyMybOUf&cyKSR{i`c^u1(S3ny{hgv zEf2OKQge~XTb++JqNJQ8`=kbjCH+n__e=Q2&<>iMgb|umb)3pk?S(FG1Lt$xozy$F zrNxzUI=vSg-Luw3-i-DIckfLoFZHadbcT^jz01bV8_&DP-b)^i-?qPNwza{lwj6R~ z4K25mBGw|3e5>Db&Vp-XsnfVQcz_!nP;FxM`!+G=iohBxdmH=B#TM-}DQpTTipxg2 z6_=9voO%Zy`!+96c};-k7*ZaAwu=`vR{UnkRi(vRNrz+KByyWuo6W_;%DO^{vZlt9 zdz9T&M+dlPTB%H_R9Vf!^1{@^z}e}am?z_PpNhA@m#)QMRvuPlR#OXHOA7?k{M9Xl zl}Ss;gfhHtyU~|9iO^+19T9hfy?tBNuUBDNu|BPy F~) zmbf;UtdI5mO-=;J+#4GwiymiKr}|cMM_I+3B|Z+f)H@~Z%Em>G@=^66t3i|bna9@p z^Y2&R*Opfg4>C@9iM-x-pgs`Hc{B)$7+Upt?MLosugAv6Ce+g_S#_P;J ;!h-Phpmk z#fd6?e!8n%Y3$n{qn}f$cBOu-{N9~+tX*n)JeeCVLVjuXX7G)Be<%>R#Cg&@cI-HG z{^atB3i=JkwmtbH_oLv?{*l7_Wog%?m+8aFp+Nh4GdDiR+K1MS$JxYSQ>v}?23NZ^ z5gd{G4gc-iQLukhDv`ASlYp~V sk5-VHqL9!N|J* z!=V-S&vBsx>3_WLUrhlZlL3bz|EbA;UCJr=Bg6@2>xx?Z2dV)iHu(SbKn#XLPC=n) zWRxpvJXa_i4*$Qqopdolv}58K&$}a%IM9u?^uVSQ!PZ;OrBceZubDAkcD~T7s;K l1FE6v> zOcwnlMBn*JO%~7YlQl*D{N!ZlPTLAwuJ?w3NWeYrWwTscFrP1pe#>;}hUKSLp=7aG zuIcpp#~zo>s@|2RDsKhXIO`M^zB2Ley&U|{uPt)%=%Pu+WwKco?XI02I#{mu8l8TR z*x}@5|G^9`p?F@xQhz_>FFs>_K~81>>8D&Ir)X%H(-O*JKFJtkBOc)Te;>mo)GNn$ z=b ~mUOQ}WT^%^ATmcCih_J;7f 6LZsGl*H`m&Nmgl4QXlTRgu}|m#-tW z*yma=RXXh_#1)GKBVk}-igR(b)SHY<-A!=R!DjwmWVe_W#N~Bk6zV*RlQ+}KD(vnS zY(30uqb4W++4=GAxzgg8B$jz~WrxdX0EURq8Lib|B_^9j3COs9dqJM|hq|?Vy7_|n zw9peztz2w6hI!ZZ;VtXBX5ThVXDinATARAz{m6&Q<3g+J^CWun_*nGudMK?`ZxQp& zY-7XyP$e`ZBqxPMpr8@BI!pB7MnFMDrR?M5htDv29I>S1*F)fHuQnkhZ1(4Bo7Lx) zlj%JX0~c4y-JQyEDs#Qw=~zjt$);npv< 95iPhq!iN8RjqXjKldGd0`^DR4ox>k(HBjk!0CkUncS$7g zwZzin*B?mf*w*%9HIH2~dBsT*Zu{z4UKNE2T~R!^k{*|kJDXjjk__TKj>$!;n~8*$ zmbuF)Svb*7)|nUwA%BVfmyynTJQT?knlQ_eHZCL7o +O^@8o^L>Qb~DvekPx%LX{rYT(kYI#c*`ax3UY&Fc^+CyN;(d+pf2RcmRxe zRo8o}&Y7UlI5=%IXzv%dNQ}y@>U<{ZxavhX;BMgA9g0c{W2R9nuO^v|i;dk|``zML zgcuVU7<5|MCa0aC!&)u(si#UJAMamNXu#m~n3$JWW+z+>-c$6Mz;#UsOLsn`+9HQd zKVjj)?-A!Or7ZiJZ0m3oWzk1d@MSTBjIzpuUlMJuGXCB<^9QlO2{)yhi}4)(A-T&z zDCx7?iJsPLBK6fb-VR?6WjCg WPw!bSbLoe5`Q}`(m+VF{-7$Wmn>G2- z{O!P~SFe{lL(N99A%zpC5zW{QIVkbbm6BOQW>~4{qk@70R_|x0lclOIa#JdqWPo<# z_n4n|^Chmo=fQ8D7<}DTP$n1d`qAgU%DUmu;IUeu$Y%1U$Yt_UPx2Xx1d-{Rng)I* z5Am{a?EfWQET7fs^m6VBP||f5D&wwbBEC>O2%*Z1FWOq>2d=QDJZyMgqW@Yn>^qAa zBc3szcvf>;K#s|xR(>a6JzuUXx*G4AZRP44%G?PLdU?ELkaVI&)A4;nWZpV1NQ%tq z^nQ_S7gSpc1Si`HZLxn`f1J)13K%dN5cD`mF^5B-*ML1 QWo||v+ech!9K3_b%zgNp5KpGySI-JU4ucM1CcV;74%*k;lEKINx0s>qoVsW@{7uQKA+yZ6Efqbq_SG0DrD@``;-y}bwS zs(b|`TdqTUUqL`)cl>t*-o^$sT_3ICj%5F87nB>i#l=Qn3@Avp9Ah4u;;(Hh&-FRG zY#&@gWBkK8YaIzH%D|hT!F%qTw&;Qs)7a>l)40Z&SF(lr*8rVfP6c*zSE!0Voa$!B z{QQZpKl3B_K#N{4<6)L$+u?EDb!1MSpDV-Xk71?eBBTR8GVc}f7c49U`2##yz5d0- zloZLT_M6%}Y@et7r2HgTVPOcq$2CWzHFL &y6KM !#rgBNAf8&%B(e%rsT+pSl@ZVFNYkqI6ZS%LS{ zX7~93+mNEY$;M2-gU^TtFp!M(Q-)~=$pBOH_0q5GF*B;B{(sQ&nK6+ugmhJ{xM6hd ztyf#-_bic+uPA*I`yGR()q~G FTH o?Iahk5*N zXE4nz)Bi5?(YXV*i~u;c{=RY-7Qc6Q?VaFc^1@|_EAduZ!4$KFrMBppLb=u18D@y&GS;*9PiKV&GCp*oQ~RAuP=+u1{s_UP_2H( z`Fr`iSky1%>@7z08Dtz>>^&fUN@kK*#k3d$pC*~)9YClsi&v*WJ20lU^$RyZ)IbsY z*RJVUqE<}3Y(G (R6XObK!TErN`^gK#FwhpKv7{gY6xh#6$&=%|&I%;AgHtn?nFUS<3J7kZ_D7MpvgbocxkBlDi|@9 z?K6+Bq)ziwQdPx`)qM8Zu_(jys&fx8rp!U2bH8}d3S2xjg%H2MA19Y8MQ@mY1)!Xa zWb~2uoE5*rqh_m2MHAAbmy_+B$)l5*-mf^tV-f&ot*6F#Bzasf?BZ43Du`-4L0B@J z|7j21 B-3@nU48h61%12R2pl>ktYwlpg(VW=SpW<&oKiDjGG=%fHs@n~Nv8M| zqiI?fCZ+P;Zpa&|?xA~O@sq75p9uFaC$|@mvGASGm)9^vUI43^CYua;ry1KMT@hk6 zTCMiG*)d3JbX%?4gOFNoPtNUU(j#jB4hR}^!i$ZwG#cmSQ&(Lbef4K$EO(c)-z2*z z&00;)TN`myx0dHC&cZOhKR$1~fY$YJX*>t_hG;ca&Td(4Wn{uF(_hHEV4p-czewqv zD 1b(Hnx3bp8B_H1eB|czIr8vA zHo9@Z#ndZxsChql6hRO$Y%8rpY- (LS;A9{|{RG3F^YAIRSgeRKR0bC1`9{^rt1ATZAzQ0W z3 <5nC2aKZL%DX74F*2G3@pa;+vBD-sNJ~q z^zT*0OS3}nr<%h4aA9ZP4-uH7 al>DS$bav9$`@|tYcr9itc65s7Oi?BIw5~Xaa zD2P-nRiJttF#<-9mWvh1?qfSr+3mK7)1FuAnIu~UCqbmMkNvO$J(~#0k@C|nVtU2u zAk(m1bV~JajXB{Y-svY~2R7*>dM00JWWw^eQysLEL2U@){B0;(APB+4NKRWU#VPvf zAj=@9O=j?nIZnRQd}V|}j|@cv4_^OVdopr=@0^vft%!Aqs^fY*YwEycu{RuJX6xj6 zjZ*J@rK$-ea?AQ_=Q2RhqaJVltn}ay@fVg|zO9p+>0OfTR~Er DByU!pX1^)Gi`JWrbPsZ=bj%gCAPN{!;nCopO+&N zyb^!y(tEYtm;YE%ij{e 3Ac!8F9u8XfyyVNjH;4iWAKJDZVwo>HVGOi)$ELY|F1LCD99(z$ z;eI|9lzYFp2nU(@b2~Ld2_f5BhC5CZuLdGZ1oDAM<3(NG_53~;#kRkv?cI^IsWM1q za4nJ4PjD`AOb_|%7_dSdVw1tFpLqgl8_s y@B=))cR&*qDeF*I(z0Lvn+mZa#U{P!Ovk 8U~4DDC# z ZWzXYM(dFNo!2%3lYm&>`~ zn51dbBwg`oX8{ikB`)(lH?yfspkiPVXI`u{eT%5aAKR6BNyxx;>eZ0QNX-003(0Cw z6KRPI`i3)^97sQqVJ$6ceBYlAmGbyFIe&?VV++w~H w8@jtrwb2JSW<8KAD9a z7iCRi2+=K}nHY&j;EJWO12L6IC)ut@OmFv-KEB+qiQ2Ybw|;GsY#)fG3h{J%62TnK zba(LfUN`qNE;+r^_jwY7jvT-b5}MryO-_2Y)3RE6=SY^n*6#rEZhwG;f>^YGR+uDw z{&f@w2Nf_`72v7n$xxlsV#IKM*DM1Nheduq4} f4J1r6D6KCY)f2-;NU)ip6S?H z$YXtd5XI4`e@vicaLBjCfaC;tssFm$C)!7|J7w*~!2aT#lv$obacQRPljs8`x95jL zZC%>a@sU%*=YC^?`u)xaqzRT!uXpz5`hS8!nw}D1r~w2}{*h+rAir
Q9f-VF>R;R-Z<$bPv-#^ @|(UwvqY}Zn6|@ z9wwGZw1||)9%+{53f5;gf}=RM_oY_hYP$37fycz)HwbwDjH*S%W1p^@dXJEjl2VBs zz(k+{kzQS$$D*U$F#6amkbpg7p5-mx_5PeDEz14P>*c(uB%4@&7*dzA{*k5M>x5#J znwr`fknj1g(L(?`y#~|z2*aeywXSRtZ!B#e*F6SdgVtG&$-cjtD2RA|l$Ms3=Jh|9 zB#69CW_Gm&zEjk9%A%1_xU=0&Nk77sM73U5H(LA-2>A6!F!fcu6}DCKZn@)yKq}Tv zp<_%;%Lz75fH#A*qfMaAyo(qmFwKJiH{X>s4N3_lH!rAUq4vnqlxVr_@zjmj))#(N zyhu9^KKj!s6^g#`%31lfvH+cwvoy~KkNUJ+zcN$eY8Z)t`Ded${hh+ZBtNCU=3>Sk z%AUg3a(Zg&H{Ko^%cV*gYV=(zU%n9z+YKF@aSXkit9IaAVZ@n;kP!I!@0x(@D7~cv z>dEM3aJ^_XF83R90xpN2#0APkl$qA?v9YvQTy&e13eQ)QD@X_kaXscD!opu!Sd^o8 zpe94Hxi2G5wKu)lSq #)jz|=ghJe_ z$l>|y-lPR;PY(n4yWXoP)rb85(S5V)BsIv1+4v2knQD8J{y4x{_^NLWABGeSE amG4H* zw~}Bz<^9f}wwPL*08el%1@3ic)bv}3poX5$8CA9cEf{O4e`Dwdkocs;0TJBeJyd@a zQT|Vz%ntz@ybHnYjMwS!4gSC6vYQ%0CZ8wp7t4R_uWneKVOYi(9}<0ULY}{SYyb8< ze<*{@N>_MP^Z)2R{y=SxMl~RP6YhNGb@^Z1a}nq+E-*+Z{}(=}9SIrAGzyFWGmuvd zdSE)VT+FF WWX9O(1lb@i!7&3 zcA#g9ZgbG>M|$EnX#7Xvtt^&qa&HX*_v}H#g?{rs1{eugi_?>);kvrumq?Qt;{6I1 z@dE$Mmf5(XlTv9cuMRgGlg}ZwJ=;r~RL3d{mT68BaERN>8ycN31lz-h|1)}gkY{Me z0m|aD#q}Wv%nJ<(DoVOCF9@!4Ra{*PF2|_1T(+0t){Ybu5`{C9aoWj%rcW0 r`G3_vWcYp+S*un1O&^WNP>E=CwOkBqv)q}8lFQIwUuiaC@4iG zCA5WqS+mZM>9dW?zaQ~l)_zIC=We@_D>=K<(Yf7YXH@bTcal@<_LeLqTsxKCuui-M zpL@X~Jr>#fT2kLKxN}ILWgYak8~gnrOQ&OI@BQ@AO^K0$^7T;e+J0A5QCN%dzJuKl z$%Ym zsvjeWN{ lFHdp2QE`4fvQD>lxp3eToE$FU zAlG!pxhh{0c_+p7-1@A~Qsu3Qqe?gr0#@`hEU&q!$K0KPJ5kd95^HHq+p4CgMvFG` z;Uf|R;&DrlV;|4bd{To6kUB5wy?tpdFm2OTTGs2e?&o2`lfxC#zc&m6@oxH )x8BMp+V59g|B2Wx#le4cN!Y_V*Ke<~Omp&=6TYJ@*o zJ@*m46Gx(3f=oBvRu~4za@rrepOR25#vRH2Ki)e;($F~0y2uZE7v!qJ*q6PWZ_k^% zF#B)2Vf;L)>Vd+TFrpr!UfFY%V!xW;3<8%yN(Z=v1{YK@s_F2uuB1M%TerDmEvk^| zMJbhIfIWNPl7Wkr^`ru@ M2+$34RSETVNP7>OY9O`W_Yujgy6Zi0P$Q%y!$ zadGze`1s%=u<2P_Sy{y0-Mv;uZ4q^)+VIocpWP@{#{;VQDt&?0olu&iRogCSt;4_{ z3Cbv(0rU_N?lw+%SQ0+qpfWEXsG{fO Tb*%G6l`OLmIu5^k6C1`!8~?T8TF>3(i;;FK6GnU2 z|0!2dD0c5lA#x4Vk{?f7yts4E$Tz#c0Jd05A>xIRMR& ;oab)&@I1{_UCB&%wTxPAc=pxFE^^vlz-s0N6P22k`J8#Q zYCNySv#XJ_2&wlI+0l*5l46rt2Q?@0&qxfS{AJIsSZ{)92W$d5E?QHdiq9!f#{xq3 zyJrTM`|&oEQ}OUiRS1puS$@7F;jy8i;j9Qw1QEYivS4>F#xfFvi}mg8ZQdAtTex@> zp)57^h>|)k;dGt(R89hox|VqpS5Q9y0AK{!GoDV*cQ~oM?#%b+tHGpXWYjb?VV(aV z_*#<^SuRgFCMKrdmuGf(SlDc_FrVB_pO* #0n>0` 5(^0YT?o0gPR z4qG`8HBb6!zuOGL(~xNPV~G`alRTFSAU-Y2mGFaU-RpLO)j-g bUKPl-DojPN_vE~!OU1vmLNUuXAk|>I_v~`Ml2F<15bNfc`^nJeU~ez9 z<%-4oc7iq}#j0<^WjfoB)odKwYN@j0Zg6U3BxfRxW9(*;Sq!AC=e@tZjFjf)hbw;hT}P=$9TH0+WE5HVA?wZ=A7g*=yzV0J{`FNC# y zfoPG3TzJvdAFvP!bOE%~*6aCl=j!QcK9LsBZx(M$?kc*50*tz0NQUt%eXKTYlc76W zNf#vYl~q=K9;e^+zJxp>DsLS8Mh2xI2u}*LDmklmNWNCx_kLg9Optv|RS0T!UA>FS zw_uu*L$H94RD(OeG2*TtdDsx*(Yn1OoL|gY4_=!eQ16e+N}hWWCDuBU<6$w^D7opV zl8SavOAfH77MkCm)$qq;uVl#NY7Ra*OECsJ%uEX7A~1iVg7eoB##{MvyT?DfxLWGT z*?@;41{;Y@Ox}4FJ~>jJeJ%|@qD4Q9Hx=>R9#y}~8p?74p{6^~?w`p)K8N(GC9aKy zf%40pPb58--J9%AE@e&*zdZLm3}Iqug@Ej_DyDXY->vm>vi+tW5y(Y0 j9m+Ap^U R3sgBywUNJJcobYKQx3O{H`mM-kA4KID;LylAqS$csLmg zxs)kdt!OU#T#%&qO>+J048L_Za+RV5!TWk^%iv_TAdK9I1U(Ovxc)xRHp}Y GkxeE{f92yozMoX!YR-{ql!Q0YF zta;gX$$!fRk!fmb3el!EUnFnqKghFZ!MJ%x3yM iZ~i1CQS zEML9Yqa)Z#M2KyWJsRL~->UK83d*VI9p}|aaYseQ^sj8NQK-aQOJ{S@T%J5hUr5E0 zbKj^Z&cpX^eWrk8t`!?%wBleK6`q^Vl@scI?5d=`Pqu(3gr2>OC+5sU4=COj`-&$i z&~7IRE!^<6hzkrnO29&>nI!ugfXTFm&i`wRG {m>@S*V(Gh7Eeyh?}CqW0D nUrro` GUpZRYb`RP#%4li1 ze^BzK(86h>1Z(8m52PCwoPhDeGXHvkeZ$meIWU(qdQsrwIP6RoV>9af%_2W&jdAGm zi4+c8s{vZqt4Orf?*$e}l6*eg7T51>P3qx?m3a+YoxH{s&K(h+BVU*~S1yi|H-$K_ znDvOdqI|$KX-;#*$ePNHB*0PiL;1Eb2d!(_cWU;HzR&sGDP-SK?Op=-Sj8|yF&i;X zLuRVl#k=moT*V8N)}9=$)73_P(Dgp}v*S5^WDBi7iD;evl>A?M@INjpQ%oiWZ$pA# z^M-?00C&80^cnHY7!>&XOe4o}5}9;?I>+!;nfEi4e8zD!a#mo8Pb@eLS;0gBV}1FS zAy$-Q?FSakk0CCZkw$|q=_JPSLNGbrvCU+R@I3{cw1-c9)4TVv{73;w%nuZx=zua+7+XBH!v_T z-{xvNvr*;%zC9}~G1u;X3oI4Hi9YIn14)EvnN6N>nNuKYSWZ3zgr66I;N3|exjJL$ zDpw3Xs8B3d5xdaQkM6sA7;1kW5_l&uvA~o hTsxhdR>16u>mB#P=JS)rbCYh#tVT{!NQhBU7TPSP&eChikO(mC(l7HbA z*51(2fH`#1jrG|3`h;+_9ZXaR@-xQ7Anp@GPLxGOf4Vb)hp}z2%O8q=yuVR|M4FF+ z5;fARO(0{q&j`{VS3cP;rKzn{TVF7m$e940W{QhK+sWFF4wZ21O0){a _=V|Aso_!dr z+kii#?yC#?K0Kl!ivM*<&%Ad#+$W$JnfKyifVZn1`-%-Ha;Eht5Qp_V>={cQ@;;Zg zTz{BkbmHjjPaZKxkJLm00%kawt9WMnBP2tOp9`(^WD0J2ai7mR-!K`sB4GGLvZ|}W z(L8#}BntRBekq|i;`D}X#JI1R12Zgq+uX{)ZE3n$>cxuea9 WH+cp6y z>qom$0
l#lXoqI`^juQ;ZaRZqY3T`duYN*SJ@I48a>E` zN}>%COCa(|wobI~A?^Q!%=`7abvp=eJXWL>@%wg0#V?KMdIU_`Xmak!ZMq)=VOV3` zWt@w+unyz8Uc?Qb`WjR%t;!_wUrzM8*L0ihV$RA^^>4P$eDfrt&}t<~ls1M_O_mOB zoi^uM{F*onnlVE~hnN`kKxq(-pD#2>r)%7w!(ubXhzGy(#&`VY=Jhgs`8kwMI%EGC z*#OY`O8X1Q_vtu7fYER`uBG-jZHWT8|5y4yhl)ZznT3`9UcY$pzA9D`CO{*vRAq4j z+Qj(8S{9VFD6Oq=-mZ2~69(Gv>!%LjSwYqQd;NL J6blP)dB0p2kTBakGQIIk5{TbHyak Ob@q<_ z*}7wiZu#f5iOTZ+urlVTc<4)c`D#G_^bNAZ{{6rjNYF5RXdl|a!z4^kDcik=o^1ZW zU$^z}X7eDTfGweOyXrgWa1y2e?I#*+cyX?+F6{U-si6VK-6p&8xihN%?Ge%(=srwM z2THJ1U;Plv-u0xhOi4Cpr2QW7qk$a&XSltzLuN w8Z|P1fM6lNXKe$9!$^_SVk#Li>%6 )T&uDyHTqzrFyNKJm?~ODApZ8~OIX6MC6qKVUYvA!NlDNmEw4}M5Gw& ggY)(s+Bh#cJaXf9#JXjWv;Pg#dwWWMDx}#M!}>=F|0$ zg- 621-X{0$5aGD7Be zAy*@z9k&>-oRUZH^rW+Sb95{$Cig*fIu~&}enmxYbI_Fp@gT$qlzAgCr$Vi&gE{kI zxs~759#q-HuDQ?<$R|ohS g!@$-tatcmA>!0I_?`d843c)e%runoxbX$7P z_r`B+-RifaU)FR-b|?QmO&OUvbb6w0|JzT|!G?9CJ>Q`s)p_DzdK2=LLYdH#j$%h$ zgUgvX3b5Z4>QpgSkf(y@K9$YHT~Adbc+zxyU!!kdsz8K%zpnir`U@2|C-+_E^fMtL z2cmR5#r;Zuh)&JdtxzEvAGN}fwAF-H@tbbxFQOd7H_6Esl2*XwT1}7Fhl_RT(blg~ zcOdOlixm_|jO7&vlZZZEOiXarfZRjV$`w||CESG-JQ*fF+^J*(uBgNwMQ8cB(zhp_ z_SMz_`2Y#(E$}KO=fS-vSB7t5G3B(Y?xl44>Mzag(K~*A(^b-r_s_j~_(5DPAO(je zS9R@W=Rh5S1qbK6V5p^W69=d8X0_>~Qnqr=+u7UOp2467>=ASL)#vSb?qs2CU!$h` zrzhTHU=^m-nO|gJwC6>Owngq^ypRnRC}%kg0LE?{pYYeJFKQnHA;J9x9JWb(OxijZ zJDXOx$%ijGm&jpdopM?}q+0Kk#xaGCA}_1*zirQ0Pi0>shGF=j+WP3za$B%u@+oY1 z!U^Y^PBE-}ZhU$8MYx(yN*EOE-B>xMn;y}qt!+#?CIlu`HB~oslyn3C*mRSB&px?` z{hNo~Qz)zn;jSD&byP5VSizqJ8{uUADF;fqN4a7OkBi;ITXpqfoF;mgxHTt^eu(^n z0K`|R$OW^fJn5&4_Zz^TkOC6DCN$-Z7ht~E3_~pwLVg$11Ke>kgC?)4yj%J7j4!v? z1Nz2?8_NKJBMc3&Lkxn$I$(&y-vXr8zlv3xNF1EFbwt$V@mo&ebVQ|QQh0Bx>Si5; zy$oyb&UsdDFY9={#{aV1W;jf!a}U_c`$YgTLIN<-9Ahf!#7$8bAJyaM(;Q?{e6wrJ zu)ie? Qb@4f3Q%Iq&K{uoW>1HF#AQ=w(D$M!k0_i~=`m{_sIV G}I-`?(~{PFE<(^ ze4w`p;X| TifwYUUR+6NMmA`_X!&GEhNp;#JbI zTC9SNAXlAa@VF|0%p@^`xkE9O>5|xPP?dl15euZdhQ>khENq{@y`37tvAo5^JSnoi zoge6oy%Sn@mNjVg@XpIK-cz`}kqbR-?c1)t$0VK?hKA@}OnQ3O`=S0!7R5`t7eLQ} zek`>C&3)p5pnD$}p~vHf`Twx@mQitSU)pa11h?Q4oIrw0fZ*=#E(rmGyA>1!hv312 zhu~VcI|&-x-L-HiT<+$7>&k{dSM{!@b`+qeg|nsHC>6J=a=uKF@CoT&;*dc`Sax ziSRA@+#Ya{5-f4s%!shIoL9zwA7g|jyc5lVrJ0;cO?#0oFxq}*@RR8D_a)1w;^B!y z&;VR+r%cH8+5G*v)rdKCCCs#)=diCL$;c_&y l_r(|Kwh28vInw8UjQCDL#ly$mb=*kCW>dP0LcjlMJ?S)3jxNnokli@#7Ar~D#Vsi*2 zbn5d+>}?Ofd>%YntL}`;XbZmRjyRu5{At{&zMR~^XJo&=Ut+x1JbEkB_aY&qI*q63 zp>ghJP3Uw==hYx%byi0B#auWJj&$~+{5K_h;kG!ML*Dr#CC#bcW)3xi<>{*>x5?^d z^*8Iqo|{&_WLwT>$=PLfRi0VXd84<4u2vQ9FrI+ehAbXTCDXr5sP$|w9UkQ8Fj cHI*So`=VvY-!HmaB9e zrwePbwKOr4CqcP+c`2!rh~!-KAjh6bsJ^BdHF}mfmY|EfVnT?30ysHk*{T& B$p@eT~H_Sg+ggh4kj+z`9)uFz6z&zKOzYVPFbR zYwwFP#}!>mTCb(e&lIP!rV=J<&AR?Sa*$7%whUfOTm_|`I#Y5Ll zMuk9**!41boTv>c@(+fx !9ZCb+Fqy*1Go6{giE&=`vv+TsFC|kAKuF-nDR+nd-vw zui>1Y2XmhszWwOW&q>oSa8~ fdgGTiE?a+;gC+)ATz=!neM&=M@{b_n&l>`btlJ#7D>|bYobG9S!yLJv;IsuP zCV(qRmXu%?j`sBj(*68DpyG`b18MhD;UL;AgVE{PJOx9HOnSy?as71fJqvVHujM zl$v|;# `V9(n& zk 11G;EdGELB<#b3To=fbIT$_%zk8`EIxJJ_ybQ z-u|~HAe%^SJ@zWl;3!9wp}5P|=@ZEy5&yv5Zt~3V2Ok=G(Wn60qp4C-KkVr}9r3kX zAaO6YED#n&HjucqJXgDt6;(MxAEI4YgF(z8LFX3_VH{SoO@&TF@|A_IC9HfN*C|-P zY1K-k4inxYFQLK@$vGRr;GIo>g}&S?U#hLBsBl?yo$kL_so9rF$y?%kO(GVfq6191 z63=u`9_mv8n=VNA<3xcf`LLfq+&C%1ck!~AU5r&DmFJSIZQehRx41pCeF68M-Oug$ z{dUK`Z@HDY6%{w(Kcu|A-8C1cs;qu8{)*(P0C24@l< ST6$^p 3JgE&K#zK^ZCO_hB+ zlf}DP1R3p?f@XZf2lc^jQ$06)M|&{XCnLXCNUOlt2L9m5C`Ca<2_y8#kw$Gmj-fez zn9|D{fk^Ad^bh`;r=3ci>N@Av)tt3V<&i5n1FaKHgU_(8)#AVtEzG~D(+-@)Yt~V; zikNB(sb?x`e*a6G-mtSG(zV=}R%L3uYI80m7Nbh^E(-{R((CS*4XXuZjm^=oYM?{B z2p%=HpDVEazBJZP@AuL4t^9;n?2sgU%=B?kg@XQ-nJ&FuJ*?o_O0cRr-|s|N>MgWB z+xS=H0`v0h-H9g1uH~+ECR#Z``7(>b-H4EX3mG8 zml5y2*g-FzNg~XGpdI@6{Sl;+&$z*?q8g|;jp7Ct&i9HE5(UNl3$)SGWcsr7j8Un> zh3VP3x=!w&i{(+4R{Pbm^~OiyNB2g|!-XCbtAep5U>Ap2$pKl^>p2%ZDpge-GPbBU zY|qEu9Or3nu=F~cA@h-tle6^6SnCpQ@H(y`0O|s<^mP6R*40*Vl8)YRUUr0K>W1h_VNl zDP}<(^~AH48ZaPGEfQa3Ch+;eNDkO80uXtPpIIYcb^?aU4gD876C|AG5e8R)qO0}r z$~*l%VDgufm#^7HJP!VYW^=S8$i3YX`=dFxc8Il{YH)uNi`o#R7zRmsnC7X`F%T^u zZk hD|9q#__>4tqZ_3luzm)6RmJelp2`zZ;FT#_4LhcsLVx#M$(g4!VYsV0*We zVcXl!Jd(u2nr<`m!CD6%{+v%UaLv)sl>U416eN4vE5Meb;W(I1yfqL8uj%BMx4oHY zzXCYUB~RYwnN)#b9>v2T*J-nqQ7E*p8IBk8+bfb?Hd{*ZeWakTFDe~`;ZQ7Xb%^F~ z DowBNyq| lN9mUa8oel@TO~yc)t?Io zv+L|X84 5(ZZr4J;VczdsPnB_d+f^ zKjdm>S^;qgf}#0m(tpX|&)&a(FN0H4fQztBXnfc6Q^AltjnDNfxvw_~7&hsPLf)rs z=!y@Nh|J@wI ;NJC&cJKOSnXY`xNa*J1;frnGR zaM?x>j~Re=P$Tuc9nP!6G 7&L(O)Y3nMFEF7{NDGG4hYfxwMmdF4&dWovHYr z7Uv$fcD1G(m1$i`$Q5UkDU}#eaIlZ{f(dDm-C}k^xc8O= &?u`XDV`f(CqnXW{Z&rpYaMO|6p0A(&^gR`SJ! zI6@`ar3{DJ5ku1RZ3lbuSuG|0q}iot($+p)PyMmqzM*m7UNndy@oO1O*WPDu2y`q| zS}g5UwKS6D@+e|ug-dmRuX Tp~@ zam9$au&jE5nU4w6=l+5=_ws8F-m0wErgNd17Byu-m`r$#{nsxVvyJVr?J(E-L-O@O zLXQoj`;QDYyQFU+zb}3#9*}m(!Sa8WC(;SUDtP(4jkQ0{aY2c5;%Gmvo4sa8ovv-| zob3f8FcGwPM$idlFhO1%MwXPCIjtg+PccghM2J+VRGDRfn*}`WuJ(jZyMMC`2umg@ z$`hbz(2WT+IKO`#cBJ|TYTP#6gR->KDJ|O!Q )B$hx@++u|#o3JgK+o )458km| ^TS7n+5B1Lvjk%;k?J&1Id)R^_Nz-aa6)w zKQo;#!HWE6weM-iuz*5NzSfeoI^VKnI&Pyn nHo=PQxMn$~066SfOT zVU%N{fk!hH=^_%0@Q%7FDk79dSY&)9- M z+H)C7g=&e00jGz=u)rDm{U)n#Y~#_flNO>s1#L!6M}Tgsk(%0?CN+3Cqs3)hktckm z081VN`k^aW&grr+Ut}qD!;pbNA%kWL%n3sO$i>U63(_@pgg3ec>}~jg4{$|=6~0(E zsKtP7YkZ?GPO^OTHh1e1U)$ir^A10Fq@C@|B%K^fta9XcX8m}6Fd7bz%}MP{{r7$u zSK7wl+`+P@$BTLU@as(F^&VrXgL#WkNo;k6kZ<4A45?eF7SI3@T&x5i;ybSkn?*-Y z#?jt|c9`g^0CGW4Z}c8+&d(LeM*s< so30bu2UR6BOoB} zUG|k$2VVXOfly<%0d{bYYfHbXXB6c~++2tpt3W9|`Nm5(2ty*mT_MB$gNVQR)Opt! zE;-*G*$XNNS2LooiFhG*ALFgmjSYeY%>fB 7skZ_S+UI+<5B=Q1(d4?Yo9x|h-KgP@r@Mpt5f>p4EqR$2QN+;u(*k2>&}iI zY*yPyl?{+r7Nw0ccL!Ju9na%hu0#%QP#d;-1V|>oEYTnv5bl3Hk=>Ga+-h`c$89;U zb{%O44yqcxMRvZK&&M2p=18XYt256n7NwH8?eSAXZ&pZL(=dkdqBIEX&SRVVz0Odo zN5?yfN~`zVrPC)9TWQ$ptf}%AI$9#g{69{)Bjnv|){R&EN5SS38oj@$MtITDZl}!u z{UPJ!DS?nu{H;@dr-YD_lbgHy2s&Bw5DVn-0z^-!fVtw^-bY}UfPGM=H_)pkM%WU> z)C8f7vl@Qy%gjbg8LyT-uM(4qY@^ 5q@UK z(dBdL#l~SBMZgi@zx|FZ(Y^gRX(?0cUh3_3df}&393qyTOJ+uz93H}8Q_UP>7V(kzmx6~M znEW@xz1lQny5GIy{5rY8qc_0rL(|KHARUEke`@l_V<>&bxRB9!(-u8qE#gJtN%Yq` zF$CSKMb4WjN)Q-Wo_!ES^blQp;UuPW{KehTFd1@012}BRJ ofApvg{T#!I7e*op}&ZxS7gmYNW^T~IYh}6s!Umx&UqR#Bdf{%@Q zddP}P3V$fT+jfSNQJ4WX<+m7a2|lehtFhr&sm=&1*AUo4h*vN{9Fmnz0G*N^-O1 zxOud6WF5n?n*=xoau@`Kv>9RI;;>?*OGrzLW~{4IOxYTg#EZ lYqp zboCe(V{h;ZWso#a!J2yoN{17i4lic)-#5*dF`7DZMYB2}&blecba+^`?o{Q2C3)Q; zkUF4DD3s0AjD2F+yJcHaUY-pHH#J=kAT&Oz)i+U^oeOn-gkMrAVm*U_H{K4lBiXCZ zqA}@dUlBj$!%4`L!NZL1x6o1xCDVKI^UVc^OFc(`*Zial@(T_SX~Evbu&9sn`i2*) zKuCX)pP(z2S--acO+z+7U3BSfF5zvQaD453(JVp9Rf7*e;^Mc4(xs8j3vJeZg}w7Q zHhFvIFOqB%@udm*>h=}|CC%}j)5dEjzivL3FHNxY=<6)iCusuZvBBjtVG73rC_DR* zYvgf|uJAD9G~_lY3g7?Sjf|A>xws8ntuwNRGoY*9c4!&7Z03cqxG;I%{eABUF#+R_ zbs1NVMV?Em`d;I9q8Xavm4FqUe ofnV18=j9&Bn(=wC${Oy2OhRF;wi#bwd8vl>UsyVU@C7qH#-aJCQ6G;yy^*TmKG! z!*iflqyH_x6i}nhx94p85r^Ny)!=z4CPv8!MQ*u0Y~rZ@2xuAJ2nwdh)LKn4+^hs( zg>h}N=FG0NRES9-rknsgw2gU8;m5nGONQ-~zuht{ZxBu~HDv|QQ;Hg=OD+h?V=_=! z&XXn+!s}no`b#WdnEH)W+6$N-6A-xSVyzn_?Kmy|eW`)J9H^*NSUaRxX+=Cs{{6TA ztpjPB5YA_N+QH-9|HWq`zPJPKr^O}@C)@vvPj3?gw>{H>Fh1nJHAVjW|FU8Tc(5*6 zG7L!nx#Iur5l6Ip20z2y-JX51x%z)$1p{D+&yKWuYW~f;hk@odvqSVXG&Cli;VpAk z=g*(PlO2e2sXGOqtx}if7Mhl8Z8(m2a>!a+F?OvSjK7GOX2wNsE^W^~fifcr{kk90 zY?^zWhyO0`M^FPJLlvRn?uB1FzV@9T09mknxO?%G{FGwf; 1^jt>)HDnXnAjcSlZFj-kcrYs!@|OzBxqKn)L{Uy0oazM2;J^;Li}~KjHC!% zcVuE;#%ch@iQ;KU?&()2NUfug76Bff#2`)z_f-~GJk kt|CYf9a zmc{5#$#4<{EHFF0etxbxE*XNHocylG5=uJfMO?Gvk#t~)`dQxKCz>87XM=~u*t7|; zcuO(q%ILWx%FL6ua6Ue3L0g;_JJna^>Ei9Fmn#i=P~YK2C@C4SB+8mSt 95>YK=$W4ktGhmj$Ey?sm9dR)OPYxA3kxm zMhid8YzlwYT;146d C5rK-gB!>|3ClcU7eIxhUXGg`v Q0&hucWf-i)k2OfUtCKY9439Mn*;d*)B4!90$zy8oHXh05o#*hNOWo z4f7l44s}fIsNZ4BrIB96N67%{G2m*>*V)M{rp&1AN;^k2DCR)@52UDXMswFC@o1J1 zivS)Sd9aK08m8VV(D~>7# z%Z+zJFofd7kD_$Bl9l@wl4dV#XIo37$~GFTNXG;{%CyT4h#Ok3GVD^9f<@Xxf`Z0t z$=aGG3RpAZSX?9i-8HU+Ju)d{+~y-FDJiK681Z)iC02$0Z$Q{2A_zMF@$=_Ly3vE5 zzvxLw;7av!N78t!b^)!G)n~&qD_F_EtZ9?bg<(*AJu2xdarn*P!Q?3i= zYg)i>ZDkSvO$-2j(L9xE6>O+vT|`=rR-V_%YRzvx>meK*-ws~agI?H}?s10hKX18J z1r@*J48wHk?CN}zt;oN(*pg4H3PojCE=NVd%$xa6bRCAXd~I{_k_`kd{&iH`C9N1G z>yeF6-(*F2>O7_y@&4}$u}GJ=$TSTZq_yp|pAi8il+>=?uk~inH$E=Tbo3Sg9FE6O z1>{cO6hj`y*IrQo!Q{))%XZS&K|w)VQD0=H&$Ku#9Lk@hE@cf{dP&C9E-Lt$e8bqs zE_N=vW65G-VxI?7xXwqVVO0g1nuf1?&y5Scw#(MAT679c4OCnX$$5uaB3D;82sg%0 zjkSo&@>>~>VKCUaQ30!Wefo{`525XiwN3d!`2De6YB`&!1@HaCs93LMNEk(p{kRr) zSQ~v*BjxDY(YEqiX8rzcjwKR*Y0wclp*%_%+zCJ3 !q hDasPI(O%kjPy9b{3eYR_-L9rR*gq zCvP7;^ZZ0kv$DJJr5T5b=d!-X)=RCtvPkaEudXBRC!qmxuQ3-lv!}6 1V0K`>nwgl!h{s*`&1f1`Vi$7#dnp%pxk9p!CE~2eDW0lGY0~ z2k`XHpnRA(%r8qHAIr GTa-Mw{D(9Fj$&PgLqo(Ke!EtFJBC)$;^zvFin#84wR7; z!Wlx3cW3}XHNV6P2;x8iRGJS+BM*K*@mt)>N^tv|P5SorYh6&>{&HI;0GJSG)Gn*m zx9g85Elj?mdH`Um%rZA!1lMC@V>?B~W$_!~b?{@Bq$KY?Q$`YV2G7-4D`#a &}&<{u;djf~J41z_Hb25k8k~3Z-koC7R;y3$(5A(@n lH&{nkR}#{4ks{!p^@wx|M~f4hCzo{sVuP}E1hg7Lxy?UQrmDOqK#C9g+Po`9 zE;)M$_A>jT`Kl+>!Z=)F98Stsk=bm|1B<%80F@+fPi3AgxoM(ou{85a$&$+GKq^KQ z+jK34AyDcxtXbe?;C^VCwxyn2(TFBSf@`-_PYM*1P_H;W5V3ZO WCdl>Uz~Vd21Qy zxNU o8dBiQ3n#O9wpxm`99lKh&+NPE_+1bewJx5Z1Dmd4Sb#;j) zL!XU4heHxP>vryn+s6MSb*vDa&I^_jl1>nM|M9q5(cbpZ&e1VUazw-aeAd8I@PIZ0 zgD8BfyuA`Utt6W`t%6}Y-;f@BPWr~n|N83@CR4^6&QtIPS_#`FUufHHc@P;dDE_Ow z{Di{>0h+*I;5)LC#v8l?!E)t8o~g$O17j%rx{r1xOQ`E{({~D+e*#4Nb?6syYO;*2 zOz=emtqnP7NA$_XN`EKQd?XYXd;n~vL^r8NA~L9!fgkC@%+jP=0cVidmoe3BDcOea zghn2skOJC6(Mn2>c&=DXR;d_mN*L5%rY9Q6EPML_s1>SP#Avu))116KwdGdui!k4W z_wHeeZU4|^|Couqr=z1&lafM=B;%LDwsn>?naX!@r3NH0YO)Mw7TY5Lhn3|)20iW< zvH++Nph%*a^jfXp>)Ows0jv^Pmx*x4HLR-tK#L9Z9#&bTJE1uPtdDWd@WdlLccTH4 z2)z#`6=r}D*L6Dtfcm97QoZzTrZFj)D*tSf9{igED6`U^G*&lF_Y$s8Mvaf(0|xa} z2&4!eO?>E(pXO{ xo}tc+ynqDtRM9a-Z!$Fj%nUj?KW1-bcn zst;=H7E_K@gH5s(a^h%B=fYO}`j7R%%~$Y3&PU{Oirg(ik dI7;QL^A~H^W#GL&L@LcPjKMnASt$3+o+zBoK-wiI z^okPE-d4$UHFgU>;h{Y{H) hk|e3{i<%y;rqHAa^mQ?)+&>gsm-F^XEaOFaH)xO z%ufMpS7^%&pLn~-?BmQ8sT{K{BE@Q4CAViAZbr8n)M~_+L|LDkVH!NT9h^U0OwInt zF;~kIoo~S8M2-`l#>V9I@dOuf%+qy>qyh54IcK4%47N)+Lz(*}RyGACX}hgZLG64+ zfQxS{nnQXmboxT;dsS!_+%6AM*@qJ&d1^8Q_8Y{mH3!4lP*HJ|a9lPIHwbQ}OIZrP z8IZ1qK5P);eUD2?Z-ES=H2>SyBuyImikEZ%Fsf4u#GidyQT50-^1Y`(`E)T=s(%2| z{y7AYh-GnFs;lD *EMv zf+BYn+y 7#3sN-V3v`5Y z=DGzY!A(EnZuBv5XJA-0fb`y*Dov#vI~rlO%PFx7Vxet?P3Z&X8H#Jw4c?^!6-%qz zXy4|Ia&G0aSk`W+12#*>-Ci+dgX_2J)85sP$(2Rlw6lNQI!ESJ^JJF5#1NH;(+3n< zzK0ouR_}(3_=U@ALtDv=l@jKozSewa)6)&o!OI08=2N9U0it;^E?19NX#{r1JT(y! zl;_W#jkbwi^`d^CA~mG*xE7AlrIb5XHA*ad_xxwIMbe=;4p|_AfQ2$@x@*i!HYOuC z{P5V`;+nbfnk{ZsCE^QB;|Z;F>o@w!M=xlW+geGQVueUOrxwANhJe(cG!k$FG+YUC zOFVSzwfO{G4YGG=J51;{QD%FEtwieKkX?Q(ydqb ~@U?iwqK%VfjbzA@m#90%5-SZ(+$9WkMMRzz zMz^(zxACO16gbWBF+vBS;U eod9wQ)fzL$*i&*DE6Y zaFs2|dmw7Q#R3ALzk{;SX-Lw>og&X7ZRnoQ$JNfJ4DDmLkPim)b(voGm>FMpc!WTR z(QocPpar-+RGJNJ2t9O>A^;@eA2CPQMu ^3D>9yoXRQNsE8_-U!;^dU z#-!x%I}ky&*Ig|XE>6W*#AKyRME@9CGwBdFNO@QzW$e@^VS9Uy{>)@D@fX0e0Vutv zwpa(UGzV5tM?wo*UFA!`bke;g@uoeWl2XBg3-FF*8>u|6$2$fml;J|-AVPPe2idRK z8H->qSRQ5D4?a> 5wA|b$QHf!xf^|R%#9f;{_ zhy7lz-m`z<-SZ;tbxlRRmzp;#kK5T6?Dr8q(^cUzTsv0Jf0*fmzhpAP->il-oC6*l zz@nEjQ5c9s6?H&tG~PoYoX{C?*h66p15jQW%;6UR_hUMI+^EGhy{x%a^w3(?#kL8V zF3;lX#AMj*aN)JGXuv}3?#s<0rsMP4LW*AisfFwD>e2UNiNE#hy3K9duHjEYKWxQF zBU+H-?{1=3rag}67pz1*D~R2)ssarB-P^uDxFnLOOJXLI$xUbD;~uE`ydslP_R z!I8upO)Y3^*kY6Rin^E(9OLzeZqc~8yP3OPG 0M;`M#s0NzqTHD#87;QE})D zGEmnpNK@oxfyZrs1OJN-^I|ykO~I_JuJw1^yMmBVOaPSM;#RnA$3)V}O4RkqMM^?? zj+pDCb+p;LBnZ)d-d#;O`sFQ8icP0&Rh)&Pxp^J|LseDP zkN7E4~t6YnCF++0y!k|iN+x()XyF2MUeMe)jl-0wi?Hw7S= z#%I#Wun;y9>{%LHrA%$?$7%byokMIX0ecsLPRd3j#fmKU!UFcRx;ou)g*P}y0{E7s zZD&!%qwU?;YJ)oK!DVa`Gu_P;Q_eh|409aSP|~X^k3;XUfNwBQmC^e07FrRwr`Jlf zh)A>xUUxW4bCEHyUoxDwhO`_)lw@Tb6wS4CbaeXBa9^NlFo`yMuWY^t%%W@tJOoUz z{v7jEp4&s(<7uT|NECuMLS%$wC!BgYjds0Qa)!Ny hm-Reh;veFWK%m;-ZPsR-67kUqaTK1Q&RE!a`a+ zzthfrDTDhu=SV%hi_Myz?KD1@S+~L9F{6{6WzNOqir?*xnRr91Ic-*(X)31nMuia8 zUBapm4(0QwSHWilf1EK#HLVYmjdt#xgJm9h9h|d6Z1EJh`rH~_)BwzpfK#mTWik7% zML}>@Put_Yyxu$M{R086yQ_MVDNJcsk13O%YBUA6MYn&F$Ji+cPJ3|p*yxmf r{;YbMb`=XWWPQM_r)o$#?16ZP93(V{mig-KZp zPFQP<4o{ qw aGEAm}4%bfz0X|cDY{V+k_2eEd#WF^8+)k0Ws1fLN9 zxj!NZa&0dqf1J> w8>f)+>6gQzPqbXrNcMj^tXs^VT1&IL1rD&~5Co)hsiymhULCEwd79^-Hc! z{FMk_6FwW}%2{GHn?$YUF#SS4Gqk{e>#!h#8**)(I_W%O-0E
uZ*TA3iW~QgQXe1(kJCrqmDF`^1OadGO@hLd9Lq#;LWg!vm!$^ z{kqU7Q_*w#S!z+`m_6tWJ~P>{Gv`UEA$ p%;;KB=0<;hEgvWL=wNHu5w#UhjT=va6t%llWO4V$?#*UZE+Y=?0xr zY?Conn@r}kNMx8V() ZyAk{Wk)v7|D}8<++MlLUw3SKXuNBP{jEa)BF02u ze;A()ekbNtiZV4$k85b!Z+PtXz~t4GxJnDn{IW3SuO1y*#ri$Y^Nq?7FU9E(k-j8F z<(fry!YJQyU&MuVUNAhjxg8qIPQJsttdReW($vp9ab%qTp8CWG-zqg-0E((8^Mc~Y zM==HAQYn X2^KYsblGalN#8Mb#)DBX8iwXIXyC7|`MUe>s&>t?pipQ_;!aUR`%|#F~o!52F zL9=3Bkw`w$_;jbou{0n%&g5n6^B#_oRZW;`dcB~H~g1z{~7=>B0;p+8O4TQ)Pp<%ljK5|j$JNl zI|7iPw1J@~7jE5{fl_`{`{8x2hYPd(Z$HihU1la+pO8p6`994E)}bMo;sv<;P1jqg z``=8tF{ZIPDN8yMk}RxE*@+pM1O*lKYEFgyifUd>rN_g{7#9v`qzzd8>$}aE^Lje3 zNtC` E SV%W3l9{---qJFL}uYq#$ 1z1AI&2zrC+}i8V^u+ z-{RtkgYDjfjWkQuBpY4#)_jBxdI#`vhg*w47p~BW*_Pqk=09!)w%nV4II6r@8|>OC z@26jvp4&6W4ey?=P)*x&Ou(NAy{)Ke>fg8^6Brs#Axtj0q^j+@7jo)H?ZKQZVIQH& zNez=44RiQW5Euplm2Q&{SvaUHKR<-d7h7W)1I;P1a!X(-*G;WSo0WKEHCVG&B!2Gr z;}vj99mq#{Oi6#Qid15e 3x}&fJ!- zU1+NaAF%F-5s;w2K9Q-9 476%8A=kQbP-Dn52d76k1cV{XlKL&uv8B M)X`8?ZD>jsrFTmfL0x^soi ;0z zb6SpsYaril=B)H_!-a*`b2ReZtNkp@GhU95Nrhc~7RwWUn FnftmXY_@huM;FW-^(= LN328ey1rb=e_HdmWn4(2q& zcftu-Rs9d1MRQb?I|4HI{sPr(JoRa;NkOj*R5}APK9>T58DP6(*5MyitC-Gx@ERY{ zJh-)$)53=;G&LpDm@<-tO9MBlHg2iK3rZnk4 00n=tumpbr!=$4dtKcwQPYdeZ#|-;j}nC zFt0BhGsLA{+2~5I&5I@OCQaq)*>F>-4p|;dt{m;}EB3%R9#IFXVgdrXAAqXvO*^I2 z?@8CmY~1FDuGR}%?{MLzTgt2CRcL5cBd>L_i|kR3br5n!kwlvc#*$+J+U5u3w =nE75%EDv$aJLR-&>`} zi~z%ajzz%t;%EfNx6S%vO^Iy*^to=KH&b(iCfq;qRHXHcs^OrGYmK4yVnXfd!>F za#E&t^BK9B0TvoSuathx>Cyl@>ayjmu8;}NCW*k>TEPi!{ei@7zOvo!QhgVTkX-?; z%LGd#gmc^Zs=++3U0}c)hUq!>qYlxuilsLA=j%jo=}FJ8CPaD7?s=;f&{1P_i`}Cm zs$1x8plsdzta{@mZP~+cbg^<~hfcNh`R^d>P=oV^lZwnp8B18eNVzjPCFQ5jUHsh- zqZ+FOpqI+gZw2a240nJ|$YIv6!93%-Hz9i*f)Y$=0<5+2{sg*In*z-xPTS$JdhLC0 z^#xbAhJB5w>Bu#O?Svk#Opms#qF*R9@&a0d-Px)Tfzw`ubfgS)py%OZ9EH!75M7W< z8Y3Sg vF^gZq*A>TN7WTsni)(wX{pt%v+pTbDea~wU$8LI 9;(M`#z;5Z`E Hza+R?yc$s6e0RRq6A4s3o3yf~}v zq}S@FZu(81!nbFdJ` UV8gOeD8v@8tBOjOCQ4su1k!GfduC7>++Yo_ lh^fF}3x2J(m#__>4P7lnZ7iPGnT1&k#5l7xZ2MXG$|%x|Ayx*X|p zd6C$o?guBVN&& vRt?tft@<+3-|2nD_%cw?l2~I2tTyCm zHeveGHMkl3YzhXudm6nb(8#FfRRNw(PoockDoO9OEvTZrbXuhKOlP)>MnbV?*?H-I zX-6RFRXQ~^b-gr;2pTt6Cm5E1f*pVC#LQ~a$$R8LbeiP&3XibHJFGEX0GtfBnXT&5 z&c#xH?3IvHXMZ(kn%OEo)uP_T7df= Rx)*ooYd$ Ct}hEF@)T1C%)oMcL?F0fARt-}+u|1UjySU0aJ+skiG?xP z->=qbA-n%u#EhnbA`a1QXG9c>eX$Orf{iqhNr-JkecVM-!4p1-wpnYd>!db#M-D}_ zH?1c9;Qn<2?F0ZxZR)7t@-+;h XM#NW94H;`MB?4lxThNCYL4{y zjrkEf@+8A2Yupx0AV_DDAtHucqvQy 4gY z;$K);i$4!D21bd#e?@c{0`x9JrpsZ%>Uu-(UALkebwq9q*I2_m`XE$U8w7no`M#|P zo{U`-4T=xwQ2n&o$OV!}gN!Lo0!EtwGTayT>RD0$F-j0p-CG6qoKgQPqe1C+N{d|* zW0YaLRw45RL8e{q*~eE>L+#F>Xs0q{yp-R11Sboc!X^!
Guh5d1&ArcMjM1m0|1|)x)e#ta zG^}Tc{@$$rl~SSfLzRDaGVRU)(IDz<(&>uX`StA4apkM82%Qb#dm0 XKPC| zN&*kqb3N0c9D!yWs|#!v&*+Jn!;3rkWPzjpg77iync$e|u2nhnKdHD7;1~Dpt1LK_ ze&4CSIFOtBzz6<3dEM#BGZPrd`-6@=Zx%N+I$Yi8HUaiZ(PC(B*zA+>w?bNMlIPyR z@Il|i$i0_7n8TfpM0GCwvyiPoj-UOMjgJPeFaP%{qe6M*G61#sN?%m7exTk}DI_}J;<9;Nm2>Saw z{z@eNmv>d#cA{O?z+v&fw^M&@s{YISFQMnCyA{5XGh+Ymi~QHU{ %9L-!b>UPwW4$ zp`<+z;mxk+fRF1I=X&{59~34 zFA}KIq>6#wGAR5XPVC=K($6~Ji!xOUpd}j1md47&BL~{ndcr2PHv_O9HaUT$dc~(c zHDZnpKKCl1X)k>OgQk hk{Qz)@VXcnX31nRa2Q(1vdMBjiO zeM!I5r)sRV8=z&X-x3q)Y5>W1XXibDYl=Pv8dIU{otVM)CdV_5o_e7Jz2Ezy$oMzX z-OYD60LoUv!h*pyAoY;L_g(S3&9xlQJME)?oT^wTB?h$FG*3{({bgT~CshEHr3VV^ zrD6e=`oNdx@Hs%n0C2#Xl4*mthYYTVeYbl3U!cL01KN*1wJLLrWPqA#(CDHI(9>za zH|GT9Z{EC#VQsw 9>uy~@00#Ko=mSp-s|72hoewr6z8ks56oY*Ax4Jz zPpR|_fJgf}#<-HPY@C2ukJix<_GzI$ RZD19V(4 BEtKr(LngCZBQ2mkKEA`cw;rB$6ECs@Olz@j3JpS3-QW&9VsVsXxg=b z1Iy6TF^jb!D0v<-o-32lwL+H*c!rqduqP(4QmW8B#$v3)qHC_CE&Vhh+5f}dTSjHo zwQHaXsDMa{(jg^X(%lUTlG3Sk*F#8mm$VYn-7O{Eozl%icb&-_`}5s (6g8!+A}_;OU3XDX^c*aA z7y!liFbLfZTgoQX5z%hCnseXmk}g4}U(HH&Q;Tc0&!5t|yJWk#*}EeGjN9S)v5$n@ zBg*+I--If(AuF9|Dsbf>g4ZO>$+3haL+ahT2$~O-((T{fNwufmGWVu7@xSz-sAcrt z^`O;iRHsMF{b?*)NWTT*3W5rueuQq}U@lOX=i9;KD3g3y&(#NMmzc6Fiim;?Q&R68 zt+W1C j^&!CFG&f$FQUkHnj%l^cBTHp&N7AL(nIbPWO+dGb$#_ZO%pL>JfypSpM zww@VoW|&q_bawG