merged branch leek/feature/progress-helper (PR #3501)

This PR was squashed before being merged into the master branch (closes #3501).

Commits
-------

4f3ded7 Actually this is worse
72a1c65 * Coding standards fixes * Changed `started` to `startTime` * Other fixes/edits
8249928 * Weeks/months/years is probably unrealistic * Set some sensible padding defaults * Use isset() instead of is_array()
37b62bf Fixing bug for elapsed time between 1 and 2 seconds
8fe4568 Special formatting for when there is no maximum set
75f532f Minor docblock updates
e436e1a Adding ProgressHelper for Console Component

Discussion
----------

[2.2][Console] Add ProgressHelper

[![Build Status](https://secure.travis-ci.org/leek/symfony.png?branch=feature/progress-helper)](http://travis-ci.org/leek/symfony)

Bug fix: no
Feature addition: yes
Backwards compatibility break: no
Symfony2 tests pass: Yes
Fixes the following tickets: -
Todo:
- Add unit tests
- Add documentation

--

I find myself needing some sort of progress indicator in most of my Console applications.
If this is something that could possibly be apart of Symfony, that would be great.

**Example:**
![Progress Example](http://i.imgur.com/a0wGQ.gif)

---------------------------------------------------------------------------

by jmikola at 2012-03-05T03:08:24Z

Do you have an example of this being used within a console command?

I'd be curious what the performance overhead is. My earliest console commands (nearly 2 years ago) would print status during each iteration (for a database migration) and I found the impact noticeable. After some time, I revised it to only print each X iterations, which often matched up with the batch size inserts/updates.

But for the last year, I've been using [declare(ticks=X)](http://php.net/manual/en/control-structures.declare.php) and have been quite happy with the results. By tuning the tick interrupt, the performance overhead is very small. It's especially helpful when dealing with processing code that is difficult to interrupt with a manual call to update the progress display, as PHP takes care of invoking the tick handler for me. I've thought about making such a console component helper for it, but I think the implementation is too invasive to abstract into a helper.

Here's an example of it being used in OrnicarMessageBundle's [MongoDBMigrateMetadataCommand](https://github.com/ornicar/OrnicarMessageBundle/blob/master/Command/MongoDBMigrateMetadataCommand.php).

---------------------------------------------------------------------------

by leek at 2012-03-05T04:05:29Z

@jmikola: Here is a simple example:

```php
<?php
// ...
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $progress = $this->getHelperSet()->get('progress');
        $progress->start($output, 50);

        $i = 0;
        while ($i++ < 50) {
            usleep(mt_rand(20000, 200000));
            $progress->advance();
        }

        $progress->finish();
    }
```

The performance overhead shouldn't be much more than a standard `$output->write()` call. When used with a loop doing 1000's of iterations, you can set the `redrawFreq` option to something more appropriate to control how often the progress indicator is redrawn to the console.

---------------------------------------------------------------------------

by leek at 2012-03-10T10:05:32Z

Added some minor updates along with an example GIF of 2 of the progress bars (see edited PR).

---------------------------------------------------------------------------

by jmikola at 2012-03-10T15:22:29Z

Why does `1 sec` flash over to `1 secs` before `2 secs` is rendered?

---------------------------------------------------------------------------

by henrikbjorn at 2012-03-10T15:26:08Z

👍

---------------------------------------------------------------------------

by leek at 2012-03-10T16:07:08Z

@jmikola: Thanks! I didn't even notice that. Fixed.

---------------------------------------------------------------------------

by drak at 2012-03-11T09:04:58Z

What an amazing PR.  I feel like I just have to write some code that uses this feature just because it's there!

---------------------------------------------------------------------------

by henrikbjorn at 2012-03-11T09:55:50Z

This is needed a lot, we have a bunch of import scripts where this is useful.

@fabpot what are your thoughts on this?

---------------------------------------------------------------------------

by francoispluchino at 2012-03-14T12:34:38Z

👍

---------------------------------------------------------------------------

by vicb at 2012-03-14T13:00:42Z

could you please order the properties & methods by visibility according to the Sf2 CS.

---------------------------------------------------------------------------

by leek at 2012-03-14T19:08:52Z

No problem - I'll make the requested changes tonight.

---------------------------------------------------------------------------

by stof at 2012-04-03T22:48:45Z

@fabpot ping

---------------------------------------------------------------------------

by stloyd at 2012-04-14T09:46:31Z

@fabpot Any hope to get this in 2.1 ?

---------------------------------------------------------------------------

by mvriel at 2012-05-15T19:28:34Z

👍

Tried it out by manually including it in my project and works like a charm

---------------------------------------------------------------------------

by blaugueux at 2012-05-23T18:46:15Z

Up ! It will be great to have this feature in the next release.

@fabpot ping

---------------------------------------------------------------------------

by guilhermeblanco at 2012-05-28T22:58:35Z

@fabpot tried on my app and everything works fine.
Any plans to merge this one into 2.1?

---------------------------------------------------------------------------

by damonjones at 2012-05-29T02:31:39Z

+1
This would be a very nice feature to have in 2.1.

---------------------------------------------------------------------------

by fabpot at 2012-05-29T06:18:57Z

This is scheduled for 2.2.

---------------------------------------------------------------------------

by Burgov at 2012-08-16T13:04:34Z

I have a service which downloads a file using wget though the console component, and reads the progress from stderr. Rather than advancing in steps, i'd like to be able to set the current progress. Something like this method might be a nice addition:

```php
    public function setCurrent($value, $redraw = false)
    {
        $this->advance($value - $this->current, $redraw);
    }
```
This commit is contained in:
Fabien Potencier 2012-09-29 21:39:42 +02:00
commit 2598323632

View File

@ -0,0 +1,354 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Console\Helper;
use Symfony\Component\Console\Output\OutputInterface;
/**
* The Progress class providers helpers to display progress output.
*
* @author Chris Jones <leeked@gmail.com>
*/
class ProgressHelper extends Helper
{
const FORMAT_QUIET = ' %percent%%';
const FORMAT_NORMAL = ' %current%/%max% [%bar%] %percent%%';
const FORMAT_VERBOSE = ' %current%/%max% [%bar%] %percent%% Elapsed: %elapsed%';
const FORMAT_QUIET_NOMAX = ' %current%';
const FORMAT_NORMAL_NOMAX = ' %current% [%bar%]';
const FORMAT_VERBOSE_NOMAX = ' %current% [%bar%] Elapsed: %elapsed%';
/**
* @var array
*/
protected $options = array(
'barWidth' => null,
'barChar' => null,
'emptyBarChar' => null,
'progressChar' => null,
'format' => null,
'redrawFreq' => null,
);
/**
* @var array
*/
private $defaultOptions = array(
'barWidth' => 28,
'barChar' => '=',
'emptyBarChar' => '-',
'progressChar' => '>',
'format' => self::FORMAT_NORMAL_NOMAX,
'redrawFreq' => 1,
);
/**
* @var OutputInterface
*/
private $output;
/**
* Current step
*
* @var integer
*/
private $current;
/**
* Maximum number of steps
*
* @var integer
*/
private $max;
/**
* Start time of the progress bar
*
* @var integer
*/
private $startTime;
/**
* List of formatting variables
*
* @var array
*/
private $defaultFormatVars = array(
'current',
'max',
'bar',
'percent',
'elapsed',
);
/**
* Available formatting variables
*
* @var array
*/
private $formatVars;
/**
* Stored format part widths (used for padding)
*
* @var array
*/
private $widths = array(
'current' => 4,
'max' => 4,
'percent' => 3,
'elapsed' => 6,
);
/**
* Various time formats
*
* @var array
*/
private $timeFormats = array(
array(0, '???'),
array(2, '1 sec'),
array(59, 'secs', 1),
array(60, '1 min'),
array(3600, 'mins', 60),
array(5400, '1 hr'),
array(86400, 'hrs', 3600),
array(129600, '1 day'),
array(604800, 'days', 86400),
);
/**
* Starts the progress output.
*
* @param OutputInterface $output An Output instance
* @param integer $max Maximum steps
* @param array $options Options for progress helper
*/
public function start(OutputInterface $output, $max = null, array $options = array())
{
$this->startTime = time();
$this->current = 0;
$this->max = (int) $max;
$this->output = $output;
switch ($output->getVerbosity()) {
case OutputInterface::VERBOSITY_QUIET:
$this->options['format'] = self::FORMAT_QUIET_NOMAX;
if ($this->max > 0) {
$this->options['format'] = self::FORMAT_QUIET;
}
break;
case OutputInterface::VERBOSITY_VERBOSE:
$this->options['format'] = self::FORMAT_VERBOSE_NOMAX;
if ($this->max > 0) {
$this->options['format'] = self::FORMAT_VERBOSE;
}
break;
default:
if ($this->max > 0) {
$this->options['format'] = self::FORMAT_NORMAL;
}
break;
}
$this->options = array_merge($this->defaultOptions, $options);
$this->inititalize();
}
/**
* Advances the progress output X steps.
*
* @param integer $step Number of steps to advance
* @param Boolean $redraw Whether to redraw or not
*/
public function advance($step = 1, $redraw = false)
{
if ($this->current === 0) {
$redraw = true;
}
$this->current += $step;
if ($redraw || $this->current % $this->options['redrawFreq'] === 0) {
$this->display();
}
}
/**
* Outputs the current progress string.
*
* @param Boolean $finish Forces the end result
*/
public function display($finish = false)
{
$message = $this->options['format'];
foreach ($this->generate($finish) as $name => $value) {
$message = str_replace("%{$name}%", $value, $message);
}
$this->overwrite($this->output, $message);
}
/**
* Finish the progress output
*/
public function finish()
{
if ($this->startTime !== null) {
if (!$this->max) {
$this->options['barChar'] = $this->options['barCharOriginal'];
$this->display(true);
}
$this->startTime = null;
$this->output->writeln('');
$this->output = null;
}
}
/**
* Initialize the progress helper.
*/
protected function inititalize()
{
$this->formatVars = array();
foreach ($this->defaultFormatVars as $var) {
if (strpos($this->options['format'], "%{$var}%") !== false) {
$this->formatVars[$var] = true;
}
}
if ($this->max > 0) {
$this->widths['max'] = strlen($this->max);
$this->widths['current'] = $this->widths['max'];
} else {
$this->options['barCharOriginal'] = $this->options['barChar'];
$this->options['barChar'] = $this->options['emptyBarChar'];
}
}
/**
* Generates the array map of format variables to values.
*
* @param Boolean $finish Forces the end result
* @return array Array of format vars and values
*/
protected function generate($finish = false)
{
$vars = array();
$percent = 0;
if ($this->max > 0) {
$percent = (double) round($this->current / $this->max, 1);
}
if (isset($this->formatVars['bar'])) {
$completeBars = 0;
$emptyBars = 0;
if ($this->max > 0) {
$completeBars = floor($percent * $this->options['barWidth']);
} else {
if (!$finish) {
$completeBars = floor($this->current % $this->options['barWidth']);
} else {
$completeBars = $this->options['barWidth'];
}
}
$emptyBars = $this->options['barWidth'] - $completeBars - strlen($this->options['progressChar']);
$bar = str_repeat($this->options['barChar'], $completeBars);
if ($completeBars < $this->options['barWidth']) {
$bar .= $this->options['progressChar'];
$bar .= str_repeat($this->options['emptyBarChar'], $emptyBars);
}
$vars['bar'] = $bar;
}
if (isset($this->formatVars['elapsed'])) {
$elapsed = time() - $this->startTime;
$vars['elapsed'] = str_pad($this->humaneTime($elapsed), $this->widths['elapsed'], ' ', STR_PAD_LEFT);
}
if (isset($this->formatVars['current'])) {
$vars['current'] = str_pad($this->current, $this->widths['current'], ' ', STR_PAD_LEFT);
}
if (isset($this->formatVars['max'])) {
$vars['max'] = $this->max;
}
if (isset($this->formatVars['percent'])) {
$vars['percent'] = str_pad($percent * 100, $this->widths['percent'], ' ', STR_PAD_LEFT);
}
return $vars;
}
/**
* Converts seconds into human-readable format.
*
* @param integer $secs Number of seconds
* @return string Time in readable format
*/
private function humaneTime($secs)
{
$text = '';
foreach ($this->timeFormats as $format) {
if ($secs < $format[0]) {
if (count($format) == 2) {
$text = $format[1];
break;
} else {
$text = ceil($secs / $format[2]) . ' ' . $format[1];
break;
}
}
}
return $text;
}
/**
* Overwrites a previous message to the output.
*
* @param OutputInterface $output An Output instance
* @param string|array $messages The message as an array of lines or a single string
* @param Boolean $newline Whether to add a newline or not
* @param integer $size The size of line
*/
private function overwrite(OutputInterface $output, $messages, $newline = true, $size = 80)
{
for ($place = $size; $place > 0; $place--) {
$output->write("\x08", false);
}
$output->write($messages, false);
for ($place = ($size - strlen($messages)); $place > 0; $place--) {
$output->write(' ', false);
}
// clean up the end line
for ($place = ($size - strlen($messages)); $place > 0; $place--) {
$output->write("\x08", false);
}
if ($newline) {
$output->write('');
}
}
/**
* Returns the canonical name of this helper.
*
* @return string The canonical name
*/
public function getName()
{
return 'progress';
}
}