From 4747e7369de015ec225f2b438688be901b108aa1 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Wed, 6 Jul 2022 20:44:47 +0200 Subject: [PATCH 01/20] Support old and new PSR-3 versions New versions of PSR-3 have adjusted method signatures, making it incompatible with the old implementation. By splitting out the core functionality and implement different classes for logging with different method signatures, the library is now compatible with both versions. Alternative fix for #28 --- README.md | 22 ++-- src/Base.php | 263 ++++++++++++++++++++++++++++++++++++++++++++++ src/CLI.php | 241 +----------------------------------------- src/PSR3CLI.php | 7 +- src/PSR3CLIv3.php | 24 +++++ 5 files changed, 310 insertions(+), 247 deletions(-) create mode 100644 src/Base.php create mode 100644 src/PSR3CLIv3.php diff --git a/README.md b/README.md index 7c68cea..fcae998 100644 --- a/README.md +++ b/README.md @@ -75,12 +75,12 @@ for further info. ## Exceptions -By default the CLI class registers an exception handler and will print the exception's message to the end user and +By default, the CLI class registers an exception handler and will print the exception's message to the end user and exit the programm with a non-zero exit code. You can disable this behaviour and catch all exceptions yourself by passing false to the constructor. You can use the provided ``splitbrain\phpcli\Exception`` to signal any problems within your main code yourself. The -exceptions's code will be used as the exit code then. +exception's code will be used as the exit code then. Stacktraces will be printed on log level `debug`. @@ -129,11 +129,17 @@ The table formatter is used for the automatic help screen accessible when callin The CLI class is a fully PSR-3 compatible logger (printing colored log data to STDOUT and STDERR). This is useful when you call backend code from your CLI that expects a Logger instance to produce any sensible status output while running. - -To use this ability simply inherit from `splitbrain\phpcli\PSR3CLI` instead of `splitbrain\phpcli\CLI`, then pass `$this` -as the logger instance. Be sure you have the suggested `psr/log` composer package installed. -![Screenshot](screenshot2.png) +If you need to pass a class implementing the `Psr\Log\LoggerInterface` you can do so by inheriting from one of the two provided classes implementing this interface instead of `splitbrain\phpcli\CLI`. + + * Use `splitbrain\phpcli\PSR3CLI` if you're using version 2 of PSR3 (PHP < 8.0) + * Use `splitbrain\phpcli\PSR3CLIv3` if you're using version 3 of PSR3 (PHP >= 8.0) + +The resulting object then can be passed as the logger instance. The difference between the two is in adjusted method signatures (with appropriate type hinting) only. Be sure you have the suggested `psr/log` composer package installed when using these classes. + +Note: if your backend code calls for a PSR-3 logger but does not actually type check for the interface (AKA being LoggerAware only) you can also just pass an instance of `splitbrain\phpcli\CLI`. + +## Log Levels You can adjust the verbosity of your CLI tool using the `--loglevel` parameter. Supported loglevels are the PSR-3 loglevels and our own `success` level: @@ -141,13 +147,15 @@ loglevels and our own `success` level: * debug * info * notice -* success +* success (this is not defined in PSR-3) * warning * error * critical * alert * emergency +![Screenshot](screenshot2.png) + Convenience methods for all log levels are available. Placeholder interpolation as described in PSR-3 is available, too. Messages from `warning` level onwards are printed to `STDERR` all below are printed to `STDOUT`. diff --git a/src/Base.php b/src/Base.php new file mode 100644 index 0000000..ac181d5 --- /dev/null +++ b/src/Base.php @@ -0,0 +1,263 @@ + + * @license MIT + */ +abstract class Base +{ + /** @var string the executed script itself */ + protected $bin; + /** @var Options the option parser */ + protected $options; + /** @var Colors */ + public $colors; + + /** @var array PSR-3 compatible loglevels and their prefix, color, output channel */ + protected $loglevel = array( + 'debug' => array('', Colors::C_RESET, STDOUT), + 'info' => array('ℹ ', Colors::C_CYAN, STDOUT), + 'notice' => array('☛ ', Colors::C_CYAN, STDOUT), + 'success' => array('✓ ', Colors::C_GREEN, STDOUT), + 'warning' => array('⚠ ', Colors::C_BROWN, STDERR), + 'error' => array('✗ ', Colors::C_RED, STDERR), + 'critical' => array('☠ ', Colors::C_LIGHTRED, STDERR), + 'alert' => array('✖ ', Colors::C_LIGHTRED, STDERR), + 'emergency' => array('✘ ', Colors::C_LIGHTRED, STDERR), + ); + + protected $logdefault = 'info'; + + /** + * constructor + * + * Initialize the arguments, set up helper classes and set up the CLI environment + * + * @param bool $autocatch should exceptions be catched and handled automatically? + */ + public function __construct($autocatch = true) + { + if ($autocatch) { + set_exception_handler(array($this, 'fatal')); + } + + $this->colors = new Colors(); + $this->options = new Options($this->colors); + } + + /** + * Register options and arguments on the given $options object + * + * @param Options $options + * @return void + * + * @throws Exception + */ + abstract protected function setup(Options $options); + + /** + * Your main program + * + * Arguments and options have been parsed when this is run + * + * @param Options $options + * @return void + * + * @throws Exception + */ + abstract protected function main(Options $options); + + /** + * Execute the CLI program + * + * Executes the setup() routine, adds default options, initiate the options parsing and argument checking + * and finally executes main() - Each part is split into their own protected function below, so behaviour + * can easily be overwritten + * + * @throws Exception + */ + public function run() + { + if ('cli' != php_sapi_name()) { + throw new Exception('This has to be run from the command line'); + } + + $this->setup($this->options); + $this->registerDefaultOptions(); + $this->parseOptions(); + $this->handleDefaultOptions(); + $this->setupLogging(); + $this->checkArgments(); + $this->execute(); + + exit(0); + } + + // region run handlers - for easier overriding + + /** + * Add the default help, color and log options + */ + protected function registerDefaultOptions() + { + $this->options->registerOption( + 'help', + 'Display this help screen and exit immediately.', + 'h' + ); + $this->options->registerOption( + 'no-colors', + 'Do not use any colors in output. Useful when piping output to other tools or files.' + ); + $this->options->registerOption( + 'loglevel', + 'Minimum level of messages to display. Default is ' . $this->colors->wrap($this->logdefault, Colors::C_CYAN) . '. ' . + 'Valid levels are: debug, info, notice, success, warning, error, critical, alert, emergency.', + null, + 'level' + ); + } + + /** + * Handle the default options + */ + protected function handleDefaultOptions() + { + if ($this->options->getOpt('no-colors')) { + $this->colors->disable(); + } + if ($this->options->getOpt('help')) { + echo $this->options->help(); + exit(0); + } + } + + /** + * Handle the logging options + */ + protected function setupLogging() + { + $level = $this->options->getOpt('loglevel', $this->logdefault); + if (!isset($this->loglevel[$level])) $this->fatal('Unknown log level'); + foreach (array_keys($this->loglevel) as $l) { + if ($l == $level) break; + unset($this->loglevel[$l]); + } + } + + /** + * Wrapper around the option parsing + */ + protected function parseOptions() + { + $this->options->parseOptions(); + } + + /** + * Wrapper around the argument checking + */ + protected function checkArgments() + { + $this->options->checkArguments(); + } + + /** + * Wrapper around main + */ + protected function execute() + { + $this->main($this->options); + } + + // endregion + + // region logging + + /** + * Exits the program on a fatal error + * + * @param \Exception|string $error either an exception or an error message + * @param array $context + */ + public function fatal($error, array $context = array()) + { + $code = 0; + if (is_object($error) && is_a($error, 'Exception')) { + /** @var Exception $error */ + $this->logMessage('debug', get_class($error) . ' caught in ' . $error->getFile() . ':' . $error->getLine()); + $this->logMessage('debug', $error->getTraceAsString()); + $code = $error->getCode(); + $error = $error->getMessage(); + + } + if (!$code) { + $code = Exception::E_ANY; + } + + $this->logMessage('critical', $error, $context); + exit($code); + } + + /** + * Normal, positive outcome (This is not a PSR-3 level) + * + * @param string $string + * @param array $context + */ + public function success($string, array $context = array()) + { + $this->logMessage('success', $string, $context); + } + + /** + * @param string $level + * @param string $message + * @param array $context + */ + protected function logMessage($level, $message, array $context = array()) + { + // is this log level wanted? + if (!isset($this->loglevel[$level])) return; + + /** @var string $prefix */ + /** @var string $color */ + /** @var resource $channel */ + list($prefix, $color, $channel) = $this->loglevel[$level]; + if (!$this->colors->isEnabled()) $prefix = ''; + + $message = $this->interpolate($message, $context); + $this->colors->ptln($prefix . $message, $color, $channel); + } + + /** + * Interpolates context values into the message placeholders. + * + * @param $message + * @param array $context + * @return string + */ + protected function interpolate($message, array $context = array()) + { + // build a replacement array with braces around the context keys + $replace = array(); + foreach ($context as $key => $val) { + // check that the value can be casted to string + if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) { + $replace['{' . $key . '}'] = $val; + } + } + + // interpolate replacement values into the message and return + return strtr((string)$message, $replace); + } + + // endregion +} diff --git a/src/CLI.php b/src/CLI.php index 2ee7990..217983a 100644 --- a/src/CLI.php +++ b/src/CLI.php @@ -10,200 +10,8 @@ * @author Andreas Gohr * @license MIT */ -abstract class CLI +abstract class CLI extends Base { - /** @var string the executed script itself */ - protected $bin; - /** @var Options the option parser */ - protected $options; - /** @var Colors */ - public $colors; - - /** @var array PSR-3 compatible loglevels and their prefix, color, output channel */ - protected $loglevel = array( - 'debug' => array('', Colors::C_RESET, STDOUT), - 'info' => array('ℹ ', Colors::C_CYAN, STDOUT), - 'notice' => array('☛ ', Colors::C_CYAN, STDOUT), - 'success' => array('✓ ', Colors::C_GREEN, STDOUT), - 'warning' => array('⚠ ', Colors::C_BROWN, STDERR), - 'error' => array('✗ ', Colors::C_RED, STDERR), - 'critical' => array('☠ ', Colors::C_LIGHTRED, STDERR), - 'alert' => array('✖ ', Colors::C_LIGHTRED, STDERR), - 'emergency' => array('✘ ', Colors::C_LIGHTRED, STDERR), - ); - - protected $logdefault = 'info'; - - /** - * constructor - * - * Initialize the arguments, set up helper classes and set up the CLI environment - * - * @param bool $autocatch should exceptions be catched and handled automatically? - */ - public function __construct($autocatch = true) - { - if ($autocatch) { - set_exception_handler(array($this, 'fatal')); - } - - $this->colors = new Colors(); - $this->options = new Options($this->colors); - } - - /** - * Register options and arguments on the given $options object - * - * @param Options $options - * @return void - * - * @throws Exception - */ - abstract protected function setup(Options $options); - - /** - * Your main program - * - * Arguments and options have been parsed when this is run - * - * @param Options $options - * @return void - * - * @throws Exception - */ - abstract protected function main(Options $options); - - /** - * Execute the CLI program - * - * Executes the setup() routine, adds default options, initiate the options parsing and argument checking - * and finally executes main() - Each part is split into their own protected function below, so behaviour - * can easily be overwritten - * - * @throws Exception - */ - public function run() - { - if ('cli' != php_sapi_name()) { - throw new Exception('This has to be run from the command line'); - } - - $this->setup($this->options); - $this->registerDefaultOptions(); - $this->parseOptions(); - $this->handleDefaultOptions(); - $this->setupLogging(); - $this->checkArgments(); - $this->execute(); - - exit(0); - } - - // region run handlers - for easier overriding - - /** - * Add the default help, color and log options - */ - protected function registerDefaultOptions() - { - $this->options->registerOption( - 'help', - 'Display this help screen and exit immediately.', - 'h' - ); - $this->options->registerOption( - 'no-colors', - 'Do not use any colors in output. Useful when piping output to other tools or files.' - ); - $this->options->registerOption( - 'loglevel', - 'Minimum level of messages to display. Default is ' . $this->colors->wrap($this->logdefault, Colors::C_CYAN) . '. ' . - 'Valid levels are: debug, info, notice, success, warning, error, critical, alert, emergency.', - null, - 'level' - ); - } - - /** - * Handle the default options - */ - protected function handleDefaultOptions() - { - if ($this->options->getOpt('no-colors')) { - $this->colors->disable(); - } - if ($this->options->getOpt('help')) { - echo $this->options->help(); - exit(0); - } - } - - /** - * Handle the logging options - */ - protected function setupLogging() - { - $level = $this->options->getOpt('loglevel', $this->logdefault); - if (!isset($this->loglevel[$level])) $this->fatal('Unknown log level'); - foreach (array_keys($this->loglevel) as $l) { - if ($l == $level) break; - unset($this->loglevel[$l]); - } - } - - /** - * Wrapper around the option parsing - */ - protected function parseOptions() - { - $this->options->parseOptions(); - } - - /** - * Wrapper around the argument checking - */ - protected function checkArgments() - { - $this->options->checkArguments(); - } - - /** - * Wrapper around main - */ - protected function execute() - { - $this->main($this->options); - } - - // endregion - - // region logging - - /** - * Exits the program on a fatal error - * - * @param \Exception|string $error either an exception or an error message - * @param array $context - */ - public function fatal($error, array $context = array()) - { - $code = 0; - if (is_object($error) && is_a($error, 'Exception')) { - /** @var Exception $error */ - $this->debug(get_class($error) . ' caught in ' . $error->getFile() . ':' . $error->getLine()); - $this->debug($error->getTraceAsString()); - $code = $error->getCode(); - $error = $error->getMessage(); - - } - if (!$code) { - $code = Exception::E_ANY; - } - - $this->critical($error, $context); - exit($code); - } - /** * System is unusable. * @@ -270,16 +78,7 @@ public function warning($message, array $context = array()) $this->log('warning', $message, $context); } - /** - * Normal, positive outcome - * - * @param string $string - * @param array $context - */ - public function success($string, array $context = array()) - { - $this->log('success', $string, $context); - } + /** * Normal but significant events. @@ -323,40 +122,6 @@ public function debug($message, array $context = array()) */ public function log($level, $message, array $context = array()) { - // is this log level wanted? - if (!isset($this->loglevel[$level])) return; - - /** @var string $prefix */ - /** @var string $color */ - /** @var resource $channel */ - list($prefix, $color, $channel) = $this->loglevel[$level]; - if (!$this->colors->isEnabled()) $prefix = ''; - - $message = $this->interpolate($message, $context); - $this->colors->ptln($prefix . $message, $color, $channel); + $this->logMessage($level, $message, $context); } - - /** - * Interpolates context values into the message placeholders. - * - * @param $message - * @param array $context - * @return string - */ - function interpolate($message, array $context = array()) - { - // build a replacement array with braces around the context keys - $replace = array(); - foreach ($context as $key => $val) { - // check that the value can be casted to string - if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) { - $replace['{' . $key . '}'] = $val; - } - } - - // interpolate replacement values into the message and return - return strtr($message, $replace); - } - - // endregion } diff --git a/src/PSR3CLI.php b/src/PSR3CLI.php index ef744f3..0078cd7 100644 --- a/src/PSR3CLI.php +++ b/src/PSR3CLI.php @@ -7,7 +7,10 @@ /** * Class PSR3CLI * - * The same as CLI, but implements the PSR-3 logger interface + * This class can be used instead of the CLI class when a class implementing + * PSR3 version 2 is needed. + * + * @see PSR3CLIv3 for a version 3 compatible class */ abstract class PSR3CLI extends CLI implements LoggerInterface { -} \ No newline at end of file +} diff --git a/src/PSR3CLIv3.php b/src/PSR3CLIv3.php new file mode 100644 index 0000000..9d99a3a --- /dev/null +++ b/src/PSR3CLIv3.php @@ -0,0 +1,24 @@ +logMessage($level, $message, $context); + } +} From 41453e61daa7e1fd9dcd8e81e24a25bb807ec281 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Wed, 6 Jul 2022 21:01:04 +0200 Subject: [PATCH 02/20] fix typo in method name. fixes #22 --- src/Base.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Base.php b/src/Base.php index ac181d5..d90fd25 100644 --- a/src/Base.php +++ b/src/Base.php @@ -95,7 +95,7 @@ public function run() $this->parseOptions(); $this->handleDefaultOptions(); $this->setupLogging(); - $this->checkArgments(); + $this->checkArguments(); $this->execute(); exit(0); @@ -164,7 +164,7 @@ protected function parseOptions() /** * Wrapper around the argument checking */ - protected function checkArgments() + protected function checkArguments() { $this->options->checkArguments(); } From e7d5dad2be4641c8719b34f7e0c350f70a80fda4 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Wed, 6 Jul 2022 21:04:58 +0200 Subject: [PATCH 03/20] do not explicitly call exit(0). fixes #21 A normally exiting PHP script will exit with error code 0 by default, so we don't need to explicitly call it in run() --- src/Base.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Base.php b/src/Base.php index d90fd25..1c9ca50 100644 --- a/src/Base.php +++ b/src/Base.php @@ -97,8 +97,6 @@ public function run() $this->setupLogging(); $this->checkArguments(); $this->execute(); - - exit(0); } // region run handlers - for easier overriding From 69ca0c5bbe7327f8ba9fe4212273eb03bcac17a7 Mon Sep 17 00:00:00 2001 From: Dietrich Schultz Date: Thu, 3 Nov 2022 14:58:41 -0700 Subject: [PATCH 04/20] fixes colors on streams other than stdout --- src/Colors.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Colors.php b/src/Colors.php index ae25256..015e422 100644 --- a/src/Colors.php +++ b/src/Colors.php @@ -107,9 +107,9 @@ public function isEnabled() */ public function ptln($line, $color, $channel = STDOUT) { - $this->set($color); + $this->set($color, $channel); fwrite($channel, rtrim($line) . "\n"); - $this->reset(); + $this->reset($channel); } /** From 93c29c176f4ef1b2237a72803181fd5d40fba245 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Fri, 4 Nov 2022 14:03:55 +0100 Subject: [PATCH 05/20] update apigen action --- .github/workflows/apigen.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/apigen.yml b/.github/workflows/apigen.yml index 4db6741..cb85e4f 100644 --- a/.github/workflows/apigen.yml +++ b/.github/workflows/apigen.yml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/checkout@v2 - name: 📝 ApiGen PHP Document Generator - uses: varunsridharan/action-apigen@2.0 + uses: varunsridharan/action-apigen@2.1 with: cached_apigen: 'no' env: From 70443749a355842ed19ce6ee827008a3d8f04813 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Wed, 6 Dec 2023 16:56:29 +0100 Subject: [PATCH 06/20] Allow dynamic log level setting This was inspired by #32 but I am not sure this actually the way to go. Thinking a bit more about the intial post, an even better way would be to add config file reading to the Options class. Maybe from a .env file? --- src/Base.php | 108 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 84 insertions(+), 24 deletions(-) diff --git a/src/Base.php b/src/Base.php index 1c9ca50..d71a96e 100644 --- a/src/Base.php +++ b/src/Base.php @@ -6,7 +6,7 @@ * Class CLIBase * * All base functionality is implemented here. - * + * * Your commandline should not inherit from this class, but from one of the *CLI* classes * * @author Andreas Gohr @@ -21,19 +21,65 @@ abstract class Base /** @var Colors */ public $colors; - /** @var array PSR-3 compatible loglevels and their prefix, color, output channel */ + /** @var array PSR-3 compatible loglevels and their prefix, color, output channel, enabled status */ protected $loglevel = array( - 'debug' => array('', Colors::C_RESET, STDOUT), - 'info' => array('ℹ ', Colors::C_CYAN, STDOUT), - 'notice' => array('☛ ', Colors::C_CYAN, STDOUT), - 'success' => array('✓ ', Colors::C_GREEN, STDOUT), - 'warning' => array('⚠ ', Colors::C_BROWN, STDERR), - 'error' => array('✗ ', Colors::C_RED, STDERR), - 'critical' => array('☠ ', Colors::C_LIGHTRED, STDERR), - 'alert' => array('✖ ', Colors::C_LIGHTRED, STDERR), - 'emergency' => array('✘ ', Colors::C_LIGHTRED, STDERR), + 'debug' => array( + 'icon' => '', + 'color' => Colors::C_RESET, + 'channel' => STDOUT, + 'enabled' => true + ), + 'info' => array( + 'icon' => 'ℹ ', + 'color' => Colors::C_CYAN, + 'channel' => STDOUT, + 'enabled' => true + ), + 'notice' => array( + 'icon' => '☛ ', + 'color' => Colors::C_CYAN, + 'channel' => STDOUT, + 'enabled' => true + ), + 'success' => array( + 'icon' => '✓ ', + 'color' => Colors::C_GREEN, + 'channel' => STDOUT, + 'enabled' => true + ), + 'warning' => array( + 'icon' => '⚠ ', + 'color' => Colors::C_BROWN, + 'channel' => STDERR, + 'enabled' => true + ), + 'error' => array( + 'icon' => '✗ ', + 'color' => Colors::C_RED, + 'channel' => STDERR, + 'enabled' => true + ), + 'critical' => array( + 'icon' => '☠ ', + 'color' => Colors::C_LIGHTRED, + 'channel' => STDERR, + 'enabled' => true + ), + 'alert' => array( + 'icon' => '✖ ', + 'color' => Colors::C_LIGHTRED, + 'channel' => STDERR, + 'enabled' => true + ), + 'emergency' => array( + 'icon' => '✘ ', + 'color' => Colors::C_LIGHTRED, + 'channel' => STDERR, + 'enabled' => true + ), ); + /** @var string default log level */ protected $logdefault = 'info'; /** @@ -144,11 +190,7 @@ protected function handleDefaultOptions() protected function setupLogging() { $level = $this->options->getOpt('loglevel', $this->logdefault); - if (!isset($this->loglevel[$level])) $this->fatal('Unknown log level'); - foreach (array_keys($this->loglevel) as $l) { - if ($l == $level) break; - unset($this->loglevel[$l]); - } + $this->setLogLevel($level); } /** @@ -179,6 +221,21 @@ protected function execute() // region logging + /** + * Set the current log level + * + * @param string $level + */ + public function setLogLevel($level) + { + if (!isset($this->loglevel[$level])) $this->fatal('Unknown log level'); + $enable = true; + foreach (array_keys($this->loglevel) as $l) { + $this->loglevel[$l]['enabled'] = $enable; + if ($l == $level) $enable = false; + } + } + /** * Exits the program on a fatal error * @@ -222,17 +279,20 @@ public function success($string, array $context = array()) */ protected function logMessage($level, $message, array $context = array()) { - // is this log level wanted? - if (!isset($this->loglevel[$level])) return; + // unknown level is always an error + if (!isset($this->loglevel[$level])) $level = 'error'; - /** @var string $prefix */ - /** @var string $color */ - /** @var resource $channel */ - list($prefix, $color, $channel) = $this->loglevel[$level]; - if (!$this->colors->isEnabled()) $prefix = ''; + $info = $this->loglevel[$level]; + if (!$info['enabled']) return; // no logging for this level $message = $this->interpolate($message, $context); - $this->colors->ptln($prefix . $message, $color, $channel); + + // when colors are wanted, we also add the icon + if ($this->colors->isEnabled()) { + $message = $info['icon'] . $message; + } + + $this->colors->ptln($message, $info['color'], $info['channel']); } /** From 92a3ea29778deb18ef44e89196ed0ded33e609e2 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Wed, 17 Jan 2024 12:45:55 +0100 Subject: [PATCH 07/20] added failing test for #33 --- src/Base.php | 14 ++++++- tests/LogLevelTest.php | 91 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 tests/LogLevelTest.php diff --git a/src/Base.php b/src/Base.php index d71a96e..f92ced2 100644 --- a/src/Base.php +++ b/src/Base.php @@ -236,6 +236,18 @@ public function setLogLevel($level) } } + /** + * Check if a message with the given level should be logged + * + * @param string $level + * @return bool + */ + public function isLogLevelEnabled($level) + { + if (!isset($this->loglevel[$level])) $this->fatal('Unknown log level'); + return $this->loglevel[$level]['enabled']; + } + /** * Exits the program on a fatal error * @@ -283,7 +295,7 @@ protected function logMessage($level, $message, array $context = array()) if (!isset($this->loglevel[$level])) $level = 'error'; $info = $this->loglevel[$level]; - if (!$info['enabled']) return; // no logging for this level + if (!$this->isLogLevelEnabled($level)) return; // no logging for this level $message = $this->interpolate($message, $context); diff --git a/tests/LogLevelTest.php b/tests/LogLevelTest.php new file mode 100644 index 0000000..1e5fcc8 --- /dev/null +++ b/tests/LogLevelTest.php @@ -0,0 +1,91 @@ +setLogLevel($level); + foreach ($enabled as $e) { + $this->assertTrue($cli->isLogLevelEnabled($e), "$e is not enabled but should be"); + } + foreach ($disabled as $d) { + $this->assertFalse($cli->isLogLevelEnabled($d), "$d is enabled but should not be"); + } + } + + +} From 844609ef16b8486691b7fd892d54478918f27fe8 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Wed, 17 Jan 2024 13:03:34 +0100 Subject: [PATCH 08/20] Fix log level setting. closes #33 --- src/Base.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Base.php b/src/Base.php index f92ced2..c7a7e3e 100644 --- a/src/Base.php +++ b/src/Base.php @@ -229,10 +229,10 @@ protected function execute() public function setLogLevel($level) { if (!isset($this->loglevel[$level])) $this->fatal('Unknown log level'); - $enable = true; + $enable = false; foreach (array_keys($this->loglevel) as $l) { + if ($l == $level) $enable = true; $this->loglevel[$l]['enabled'] = $enable; - if ($l == $level) $enable = false; } } From a3414b242fb92c48d9339207e152cf897fa6ff61 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Tue, 3 Dec 2024 08:51:06 +0100 Subject: [PATCH 09/20] test on more modern php releases --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 74ee02d..ce4fb50 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,12 +10,12 @@ jobs: strategy: matrix: - php-versions: ['7.2', '7.3', '7.4', '8.0'] + php-versions: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] fail-fast: false steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 From 360ed0b3704fa8fcd1ce976ed79012aa2c67d22f Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Tue, 3 Dec 2024 08:57:18 +0100 Subject: [PATCH 10/20] honor NO_COLOR environment variable As argued for on https://no-color.org/ --- src/Colors.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Colors.php b/src/Colors.php index 015e422..d57de15 100644 --- a/src/Colors.php +++ b/src/Colors.php @@ -70,6 +70,10 @@ public function __construct() $this->enabled = false; return; } + if (getenv('NO_COLOR')) { // https://no-color.org/ + $this->enabled = false; + return; + } } /** From 8189c68cbde3fd8c3e0fc26295b776b143b6e481 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Wed, 18 Dec 2024 09:16:26 +0100 Subject: [PATCH 11/20] initialize log level early The default log level needs to be initialized before everthing else, otherwise an error during the option parsing (eg. when an unknown option is passed) will result in an unessecary stack trace. --- src/Base.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Base.php b/src/Base.php index c7a7e3e..a3b6049 100644 --- a/src/Base.php +++ b/src/Base.php @@ -94,7 +94,7 @@ public function __construct($autocatch = true) if ($autocatch) { set_exception_handler(array($this, 'fatal')); } - + $this->setLogLevel($this->logdefault); $this->colors = new Colors(); $this->options = new Options($this->colors); } From f6feaa2b97bd6847955670e16b3945136af4cb58 Mon Sep 17 00:00:00 2001 From: Damien Regad Date: Sun, 22 Dec 2024 18:55:39 +0100 Subject: [PATCH 12/20] Add test case for wrapping text with colors --- tests/TableFormatterTest.php | 47 ++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/TableFormatterTest.php b/tests/TableFormatterTest.php index 687643a..23b198b 100644 --- a/tests/TableFormatterTest.php +++ b/tests/TableFormatterTest.php @@ -138,4 +138,51 @@ public function test_onewrap() $result = $tf->format([5, '*'], [$col1, $col2]); $this->assertEquals($expect, $result); } + + /** + * Test that colors are correctly applied when text is wrapping across lines. + * + * @dataProvider colorwrapProvider + */ + public function test_colorwrap($text, $expect) + { + $tf = new TableFormatter(); + $tf->setMaxWidth(15); + + $this->assertEquals($expect, $tf->format(['*'], [$text])); + } + + /** + * Data provider for test_colorwrap. + * + * @return array[] + */ + public function colorwrapProvider() + { + $color = new Colors(); + $cyan = $color->getColorCode(Colors::C_CYAN); + $reset = $color->getColorCode(Colors::C_RESET); + $wrap = function ($str) use ($color) { + return $color->wrap($str, Colors::C_CYAN); + }; + + return [ + 'color word line 1' => [ + "This is ". $wrap("cyan") . " text wrapping", + "This is {$cyan}cyan{$reset} \ntext wrapping \n", + ], + 'color word line 2' => [ + "This is text ". $wrap("cyan") . " wrapping", + "This is text \n{$cyan}cyan{$reset} wrapping \n", + ], + 'color across lines' => [ + "This is ". $wrap("cyan text",) . " wrapping", + "This is {$cyan}cyan \ntext{$reset} wrapping \n", + ], + 'color across lines until end' => [ + "This is ". $wrap("cyan text wrapping"), + "This is {$cyan}cyan \n{$cyan}text wrapping{$reset} \n", + ], + ]; + } } From 19482c0041a5b441a0749f80e20d6eb80e8e43d6 Mon Sep 17 00:00:00 2001 From: Damien Regad Date: Sun, 22 Dec 2024 19:10:56 +0100 Subject: [PATCH 13/20] Fix display of colors when wrapping text When colored text wraps to the next line, the color was only applied until the end of the line, the text continued on the following line was reset to default color. This reapplies the currently used color at the beginning of each wrapped line, unless it has been properly reset. --- src/Colors.php | 3 +++ src/TableFormatter.php | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Colors.php b/src/Colors.php index d57de15..dd1fd04 100644 --- a/src/Colors.php +++ b/src/Colors.php @@ -31,6 +31,9 @@ class Colors const C_LIGHTGRAY = 'lightgray'; const C_WHITE = 'white'; + // Regex pattern to match color codes + const C_CODE_REGEX = "/(\33\[[0-9;]+m)/"; + /** @var array known color names */ protected $colors = array( self::C_RESET => "\33[0m", diff --git a/src/TableFormatter.php b/src/TableFormatter.php index 23bb894..20d71c6 100644 --- a/src/TableFormatter.php +++ b/src/TableFormatter.php @@ -293,6 +293,7 @@ protected function substr($string, $start = 0, $length = null) protected function wordwrap($str, $width = 75, $break = "\n", $cut = false) { $lines = explode($break, $str); + $color_reset = $this->colors->getColorCode(Colors::C_RESET); foreach ($lines as &$line) { $line = rtrim($line); if ($this->strlen($line) <= $width) { @@ -301,18 +302,30 @@ protected function wordwrap($str, $width = 75, $break = "\n", $cut = false) $words = explode(' ', $line); $line = ''; $actual = ''; + $color = ''; foreach ($words as $word) { + if (preg_match_all(Colors::C_CODE_REGEX, $word, $color_codes) ) { + # Word contains color codes + foreach ($color_codes[0] as $code) { + if ($code == $color_reset) { + $color = ''; + } else { + # Remember color so we can reapply it after a line break + $color = $code; + } + } + } if ($this->strlen($actual . $word) <= $width) { $actual .= $word . ' '; } else { if ($actual != '') { $line .= rtrim($actual) . $break; } - $actual = $word; + $actual = $color . $word; if ($cut) { while ($this->strlen($actual) > $width) { $line .= $this->substr($actual, 0, $width) . $break; - $actual = $this->substr($actual, $width); + $actual = $color . $this->substr($actual, $width); } } $actual .= ' '; @@ -322,4 +335,4 @@ protected function wordwrap($str, $width = 75, $break = "\n", $cut = false) } return implode($break, $lines); } -} \ No newline at end of file +} From 4e669f38f660b0e9f76ed14dda7f12bff390f94f Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Sat, 15 Mar 2025 10:02:01 +0100 Subject: [PATCH 14/20] make test backward compatible to old PHP versions --- tests/TableFormatterTest.php | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/TableFormatterTest.php b/tests/TableFormatterTest.php index 23b198b..3b1860a 100644 --- a/tests/TableFormatterTest.php +++ b/tests/TableFormatterTest.php @@ -104,7 +104,7 @@ public function test_length() $tf = new TableFormatter(); $tf->setBorder('|'); - $result = $tf->format([20, '*'], [$text, 'test']); + $result = $tf->format(array(20, '*'), array($text, 'test')); $this->assertEquals($expect, trim($result)); } @@ -118,7 +118,7 @@ public function test_colorlength() $tf = new TableFormatter(); $tf->setBorder('|'); - $result = $tf->format([20, '*'], [$text, 'test']); + $result = $tf->format(array(20, '*'), array($text, 'test')); $this->assertEquals($expect, trim($result)); } @@ -135,7 +135,7 @@ public function test_onewrap() $tf->setMaxWidth(11); $tf->setBorder('|'); - $result = $tf->format([5, '*'], [$col1, $col2]); + $result = $tf->format(array(5, '*'), array($col1, $col2)); $this->assertEquals($expect, $result); } @@ -149,7 +149,7 @@ public function test_colorwrap($text, $expect) $tf = new TableFormatter(); $tf->setMaxWidth(15); - $this->assertEquals($expect, $tf->format(['*'], [$text])); + $this->assertEquals($expect, $tf->format(array('*'), array($text))); } /** @@ -166,23 +166,23 @@ public function colorwrapProvider() return $color->wrap($str, Colors::C_CYAN); }; - return [ - 'color word line 1' => [ + return array( + 'color word line 1' => array( "This is ". $wrap("cyan") . " text wrapping", "This is {$cyan}cyan{$reset} \ntext wrapping \n", - ], - 'color word line 2' => [ + ), + 'color word line 2' => array( "This is text ". $wrap("cyan") . " wrapping", "This is text \n{$cyan}cyan{$reset} wrapping \n", - ], - 'color across lines' => [ - "This is ". $wrap("cyan text",) . " wrapping", + ), + 'color across lines' => array( + "This is ". $wrap("cyan text") . " wrapping", "This is {$cyan}cyan \ntext{$reset} wrapping \n", - ], - 'color across lines until end' => [ + ), + 'color across lines until end' => array( "This is ". $wrap("cyan text wrapping"), "This is {$cyan}cyan \n{$cyan}text wrapping{$reset} \n", - ], - ]; + ), + ); } } From 9251139917d276706f7a502557b5a9defc59996d Mon Sep 17 00:00:00 2001 From: Sean Molenaar Date: Tue, 28 Oct 2025 15:26:26 +0100 Subject: [PATCH 15/20] Update constructor to accept nullable Colors parameter --- src/Options.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Options.php b/src/Options.php index 5ee6b69..1c0752b 100644 --- a/src/Options.php +++ b/src/Options.php @@ -40,7 +40,7 @@ class Options * @param Colors $colors optional configured color object * @throws Exception when arguments can't be read */ - public function __construct(Colors $colors = null) + public function __construct(?Colors $colors = null) { if (!is_null($colors)) { $this->colors = $colors; From 6d4cf25e88cc5f25312201b9d59de2b4baab9022 Mon Sep 17 00:00:00 2001 From: Sean Molenaar Date: Tue, 14 Apr 2026 14:38:01 +0200 Subject: [PATCH 16/20] fix: allow constructor to take nullable colors --- src/TableFormatter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TableFormatter.php b/src/TableFormatter.php index 20d71c6..d952a6e 100644 --- a/src/TableFormatter.php +++ b/src/TableFormatter.php @@ -26,7 +26,7 @@ class TableFormatter * * @param Colors|null $colors */ - public function __construct(Colors $colors = null) + public function __construct(?Colors $colors = null) { // try to get terminal width $width = $this->getTerminalWidth(); From b5057bb1811a156808f15bc12710b8d389d79150 Mon Sep 17 00:00:00 2001 From: Sean Molenaar Date: Tue, 14 Apr 2026 14:40:25 +0200 Subject: [PATCH 17/20] fix: allow null for previous in exception --- src/Exception.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Exception.php b/src/Exception.php index 4d24d58..0dd58ca 100644 --- a/src/Exception.php +++ b/src/Exception.php @@ -25,7 +25,7 @@ class Exception extends \RuntimeException * @param int $code The Exception code * @param \Exception $previous The previous exception used for the exception chaining. */ - public function __construct($message = "", $code = 0, \Exception $previous = null) + public function __construct($message = "", $code = 0, ?\Exception $previous = null) { if (!$code) { $code = self::E_ANY; From 4a33aaf3bd1a9f07168f0fef021e707959685c8b Mon Sep 17 00:00:00 2001 From: Sean Molenaar Date: Tue, 14 Apr 2026 14:41:34 +0200 Subject: [PATCH 18/20] Add PHP version 8.5 to CI workflow --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ce4fb50..8830b94 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: - php-versions: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] + php-versions: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] fail-fast: false steps: From 6614dbdcf93bc00a4b7e379707d4ba2a7de88179 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Thu, 7 May 2026 08:31:11 +0200 Subject: [PATCH 19/20] fix: switch broken apigen workflow to phpDocumentor The varunsridharan/action-apigen action pulled in apigen 4.1.2, whose transitive dependency herrera-io/json points at a GitHub source repo that no longer exists, breaking installs. Replace with phpDocumentor via phpdocumentor/shim (no PHP version constraints), expose a "composer docs" script for local previews, and rewrite the workflow to build and publish to gh-pages on successful test runs. --- .github/workflows/apigen.yml | 29 +++++++++++++++++++++++------ .gitignore | 1 + apigen.neon | 4 ---- composer.json | 11 ++++++++++- 4 files changed, 34 insertions(+), 11 deletions(-) delete mode 100644 apigen.neon diff --git a/.github/workflows/apigen.yml b/.github/workflows/apigen.yml index cb85e4f..145a4da 100644 --- a/.github/workflows/apigen.yml +++ b/.github/workflows/apigen.yml @@ -10,11 +10,28 @@ on: jobs: Document_Generator: runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - - uses: actions/checkout@v2 - - name: 📝 ApiGen PHP Document Generator - uses: varunsridharan/action-apigen@2.1 + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + + - name: Install dependencies + run: composer install --no-interaction --no-progress + + - name: Generate API docs + run: composer docs + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 with: - cached_apigen: 'no' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs + publish_branch: gh-pages + user_name: 'github-actions[bot]' + user_email: 'github-actions[bot]@users.noreply.github.com' + commit_message: 'Docs updated by GitHub Actions' diff --git a/.gitignore b/.gitignore index 9f150ad..8f47dcd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ vendor/ composer.lock apigen.phar docs/ +.phpdoc/ .phpunit.result.cache diff --git a/apigen.neon b/apigen.neon deleted file mode 100644 index 4a7d196..0000000 --- a/apigen.neon +++ /dev/null @@ -1,4 +0,0 @@ -tree: Yes -deprecated: Yes -accessLevels: [public] -todo: Yes diff --git a/composer.json b/composer.json index 9e26290..f2e56ce 100644 --- a/composer.json +++ b/composer.json @@ -24,11 +24,20 @@ "psr/log": "Allows you to make the CLI available as PSR-3 logger" }, "require-dev": { - "phpunit/phpunit": "^8" + "phpunit/phpunit": "^8", + "phpdocumentor/shim": "^3" }, "autoload": { "psr-4": { "splitbrain\\phpcli\\": "src" } + }, + "scripts": { + "docs": "phpdoc -d src -t docs --no-interaction" + }, + "config": { + "allow-plugins": { + "phpdocumentor/shim": true + } } } From 84b7dcf8f7154250909a9f1486bbc7fc5e37f5a0 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Thu, 7 May 2026 08:36:30 +0200 Subject: [PATCH 20/20] fix: download phpDocumentor phar in workflow instead of composer dep phpdocumentor/shim cannot be installed across the test matrix's older PHP versions, so keep composer.json untouched and have the docs workflow fetch the latest phpDocumentor.phar directly. --- .github/workflows/apigen.yml | 8 +++++--- composer.json | 11 +---------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/.github/workflows/apigen.yml b/.github/workflows/apigen.yml index 145a4da..b5a4ec9 100644 --- a/.github/workflows/apigen.yml +++ b/.github/workflows/apigen.yml @@ -20,11 +20,13 @@ jobs: with: php-version: '8.4' - - name: Install dependencies - run: composer install --no-interaction --no-progress + - name: Download phpDocumentor + run: | + curl -fsSL -o phpDocumentor.phar https://phpdoc.org/phpDocumentor.phar + chmod +x phpDocumentor.phar - name: Generate API docs - run: composer docs + run: php phpDocumentor.phar -d src -t docs --no-interaction - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 diff --git a/composer.json b/composer.json index f2e56ce..9e26290 100644 --- a/composer.json +++ b/composer.json @@ -24,20 +24,11 @@ "psr/log": "Allows you to make the CLI available as PSR-3 logger" }, "require-dev": { - "phpunit/phpunit": "^8", - "phpdocumentor/shim": "^3" + "phpunit/phpunit": "^8" }, "autoload": { "psr-4": { "splitbrain\\phpcli\\": "src" } - }, - "scripts": { - "docs": "phpdoc -d src -t docs --no-interaction" - }, - "config": { - "allow-plugins": { - "phpdocumentor/shim": true - } } }