sapling/eden/scm/edenscm/mercurial/progress.py
Jun Wu adb24ca54d do not show Rust progress in curses interface
Summary: Rust progress bar renderer wasn't aware of the curses interface.

Reviewed By: kulshrax

Differential Revision: D27266773

fbshipit-source-id: 66294c8fef50d01f327642beeec03fc414173d5e
2021-03-23 10:52:56 -07:00

823 lines
26 KiB
Python

# Portions Copyright (c) Facebook, Inc. and its affiliates.
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2.
# progress.py progress bars related code
#
# Copyright (C) 2010 Augie Fackler <durin42@gmail.com>
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.
from __future__ import absolute_import
import contextlib
import errno
import os
import threading
import time
import bindings
from bindings import threading as rustthreading, tracing
from . import encoding, pycompat, util
from .i18n import _, _x
_tracer = util.tracer
def spacejoin(*args):
return " ".join(s for s in args if s)
def shouldprint(ui):
return not (ui.quiet or ui.plain("progress")) and (
ui._isatty(ui.ferr) or ui.configbool("progress", "assume-tty")
)
def fmtremaining(seconds):
"""format a number of remaining seconds in human readable way
This will properly display seconds, minutes, hours, days if needed"""
if seconds is None:
return ""
if seconds < 60:
# i18n: format XX seconds as "XXs"
return _("%02ds") % seconds
minutes = seconds // 60
if minutes < 60:
seconds -= minutes * 60
# i18n: format X minutes and YY seconds as "XmYYs"
return _("%dm%02ds") % (minutes, seconds)
# we're going to ignore seconds in this case
minutes += 1
hours = minutes // 60
minutes -= hours * 60
if hours < 30:
# i18n: format X hours and YY minutes as "XhYYm"
return _("%dh%02dm") % (hours, minutes)
# we're going to ignore minutes in this case
hours += 1
days = hours // 24
hours -= days * 24
if days < 15:
# i18n: format X days and YY hours as "XdYYh"
return _("%dd%02dh") % (days, hours)
# we're going to ignore hours in this case
days += 1
weeks = days // 7
days -= weeks * 7
if weeks < 55:
# i18n: format X weeks and YY days as "XwYYd"
return _("%dw%02dd") % (weeks, days)
# we're going to ignore days and treat a year as 52 weeks
weeks += 1
years = weeks // 52
weeks -= years * 52
# i18n: format X years and YY weeks as "XyYYw"
return _("%dy%02dw") % (years, weeks)
def estimateremaining(bar):
if not bar._total:
return None
bounds = bar._getestimatebounds()
if bounds is None:
return None
startpos, starttime = bounds[0]
endpos, endtime = bounds[1]
if startpos is None or endpos is None:
return None
if startpos == endpos:
return None
target = bar._total - startpos
delta = endpos - startpos
if target >= delta and delta > 0.1:
elapsed = endtime - starttime
seconds = (elapsed * (target - delta)) // delta + 1
return seconds
return None
def fmtspeed(speed, bar):
if speed is None:
return ""
elif bar._formatfunc:
return _("%s/sec") % bar._formatfunc(speed)
elif bar._unit:
return _("%d %s/sec") % (speed, bar._unit)
else:
return _("%d per sec") % speed
def estimatespeed(bar):
bounds = bar._getestimatebounds()
if bounds is None:
return None
startpos, starttime = bounds[0]
endpos, endtime = bounds[1]
if startpos is None or endpos is None:
return None
delta = endpos - startpos
elapsed = endtime - starttime
if elapsed > 0:
return delta // elapsed
return None
# file_write() and file_flush() of Python 2 do not restart on EINTR if
# the file is attached to a "slow" device (e.g. a terminal) and raise
# IOError. We cannot know how many bytes would be written by file_write(),
# but a progress text is known to be short enough to be written by a
# single write() syscall, so we can just retry file_write() with the whole
# text. (issue5532)
#
# This should be a short-term workaround. We'll need to fix every occurrence
# of write() to a terminal or pipe.
def _eintrretry(func, *args):
while True:
try:
return func(*args)
except IOError as err:
if err.errno == errno.EINTR:
continue
raise
class baserenderer(object):
"""progress bar renderer for classic-style progress bars"""
def __init__(self, bar):
self._bar = bar
self.printed = False
self.configwidth = bar._ui.config("progress", "width", default=None)
def _writeprogress(self, msg, flush=False):
msg = msg.strip("\r\n")
# The Rust set_progress handles both the stderr (no pager),
# and streampager cases.
# If there is an external pager running, then the progress
# is not expected to be rendered.
util.mainio.set_progress(msg)
def width(self):
ui = self._bar._ui
tw = ui.termwidth()
if self.configwidth is not None:
return min(int(self.configwidth), tw)
else:
return tw
def show(self):
raise NotImplementedError()
def clear(self):
if not self.printed:
return
self._writeprogress("")
def complete(self):
if not self.printed:
return
self.show(time.time())
self._writeprogress("")
class classicrenderer(baserenderer):
def __init__(self, bar):
super(classicrenderer, self).__init__(bar)
self.order = bar._ui.configlist("progress", "format")
def show(self, now):
pos, item = _progvalue(self._bar.value)
if pos is None:
pos = round(now - self._bar._enginestarttime, 1)
formatfunc = self._bar._formatfunc
if formatfunc is None:
formatfunc = str
topic = self._bar._topic
unit = self._bar._unit
total = self._bar._total
termwidth = self.width()
self.printed = True
head = ""
needprogress = False
tail = ""
for indicator in self.order:
add = ""
if indicator == "topic":
add = topic
elif indicator == "number":
fpos = formatfunc(pos)
if total:
ftotal = formatfunc(total)
maxlen = max(len(fpos), len(ftotal))
add = ("% " + str(maxlen) + "s/%s") % (fpos, ftotal)
else:
add = fpos
elif indicator.startswith("item") and item:
slice = "end"
if "-" in indicator:
wid = int(indicator.split("-")[1])
elif "+" in indicator:
slice = "beginning"
wid = int(indicator.split("+")[1])
else:
wid = 20
if slice == "end":
add = encoding.trim(item, wid, leftside=True)
else:
add = encoding.trim(item, wid)
add += (wid - encoding.colwidth(add)) * " "
elif indicator == "bar":
add = ""
needprogress = True
elif indicator == "unit" and unit:
add = unit
elif indicator == "estimate":
add = fmtremaining(estimateremaining(self._bar))
elif indicator == "speed":
add = fmtspeed(estimatespeed(self._bar), self._bar)
if not needprogress:
head = spacejoin(head, add)
else:
tail = spacejoin(tail, add)
if needprogress:
used = 0
if head:
used += encoding.colwidth(head) + 1
if tail:
used += encoding.colwidth(tail) + 1
progwidth = termwidth - used - 3
if pos is not None and total and pos <= total:
amt = pos * progwidth // total
bar = "=" * (amt - 1)
if amt > 0:
bar += ">"
bar += " " * (progwidth - amt)
else:
elapsed = now - self._bar._enginestarttime
indetpos = int(elapsed / self._bar._refresh)
progwidth -= 3
# mod the count by twice the width so we can make the
# cursor bounce between the right and left sides
amt = indetpos % (2 * progwidth)
amt -= progwidth
bar = " " * int(progwidth - abs(amt)) + "<=>" + " " * int(abs(amt))
prog = "".join(("[", bar, "]"))
out = spacejoin(head, prog, tail)
else:
out = spacejoin(head, tail)
self._writeprogress("\r" + encoding.trim(out, termwidth), flush=True)
class fancyrenderer(baserenderer):
def __init__(self, bar):
super(fancyrenderer, self).__init__(bar)
def _mergespans(self, leftspans, rightspans):
spans = []
leftspans.reverse()
rightspans.reverse()
while leftspans and rightspans:
leftwidth, leftlabel = leftspans.pop()
rightwidth, rightlabel = rightspans.pop()
if leftwidth < rightwidth:
spans.append((leftwidth, spacejoin(leftlabel, rightlabel)))
rightspans.append((rightwidth - leftwidth, rightlabel))
elif leftwidth == rightwidth:
spans.append((leftwidth, spacejoin(leftlabel, rightlabel)))
elif leftwidth > rightwidth:
spans.append((rightwidth, spacejoin(leftlabel, rightlabel)))
leftspans.append((leftwidth - rightwidth, leftlabel))
spans.extend(reversed(leftspans))
spans.extend(reversed(rightspans))
return spans
def _applyspans(self, ui, line, spans):
out = []
outpos = 0
outdebt = 0
linebyte = 0
linewidth = encoding.colwidth(line)
spans.reverse()
while outpos < linewidth:
if not spans:
out.append(line[linebyte:])
break
spanwidth, spanlabel = spans.pop()
spantext = encoding.trim(line[linebyte:], spanwidth + outdebt)
outdebt += spanwidth - encoding.colwidth(spantext)
linebyte += len(spantext)
out.append(ui.label(spantext, spanlabel))
outpos += spanwidth
return "".join(out)
def show(self, now):
topic = self._bar._topic
total = self._bar._total
pos, item = _progvalue(self._bar.value)
if total:
style = "normal"
else:
if pos is None:
style = "spinner"
pos = round(now - self._bar._enginestarttime, 1)
else:
style = "indet"
spinpos = int((now - self._bar._enginestarttime) * 20)
termwidth = self.width()
self.printed = True
formatfunc = self._bar._formatfunc or str
fpos = formatfunc(pos)
if total:
ftotal = formatfunc(total)
number = ("% " + str(len(ftotal)) + "s/%s") % (fpos, ftotal)
remaining = " " + fmtremaining(estimateremaining(self._bar))
else:
number = fpos
remaining = ""
start = " %s" % topic
if item:
start += ": "
end = " %s%s " % (number, remaining)
startwidth = encoding.colwidth(start)
endwidth = encoding.colwidth(end)
midwidth = termwidth - startwidth - endwidth
mid = encoding.trim(item + " " * midwidth, midwidth)
line = encoding.trim(start + mid + end, termwidth)
if style == "normal":
progpos = termwidth * pos // total
spans = [
(progpos, "progress.fancy.bar.normal"),
(termwidth - progpos, "progress.fancy.bar.background"),
]
elif style == "indet":
spinnerwidth = min(6, termwidth / 2)
progpos = spinpos % ((termwidth - spinnerwidth) * 2)
if progpos >= (termwidth - spinnerwidth):
progpos = 2 * (termwidth - spinnerwidth) - progpos
spans = [
(progpos, "progress.fancy.bar.background"),
(spinnerwidth, "progress.fancy.bar.indeterminate"),
(termwidth - spinnerwidth - progpos, "progress.fancy.bar.background"),
]
elif style == "spinner":
spinnerwidth = min(6, termwidth / 2)
progpos = spinpos % termwidth
spans = []
on = "progress.fancy.bar.spinner"
off = "progress.fancy.bar.background"
if progpos > termwidth - spinnerwidth:
spans = [
(spinnerwidth - (termwidth - progpos), on),
(termwidth - spinnerwidth, off),
(termwidth - progpos, on),
]
else:
spans = [
(progpos, off),
(spinnerwidth, on),
(termwidth - spinnerwidth - progpos, off),
]
spans = self._mergespans(
spans,
[
(startwidth, "progress.fancy.topic"),
(midwidth, "progress.fancy.item"),
(endwidth, "progress.fancy.count"),
],
)
line = self._applyspans(self._bar._ui, line, spans)
self._writeprogress("\r" + line + "\r", flush=True)
class nullrenderer(baserenderer):
def __init__(self, bar):
super(nullrenderer, self).__init__(bar)
def show(self, now):
pass
class rustmodelupdater(baserenderer):
def __init__(self, bar):
super(rustmodelupdater, self).__init__(bar)
model = bindings.progress.model.ProgressBar(
topic=self._bar._topic,
total=self._bar._total,
unit=self._bar._unit,
)
self._model = model
def show(self, now):
# Update data model.
model = self._model
if model is None:
return
bar = self._bar
total = bar._total or 0
pos, message = _progvalue(bar.value)
pos = pos or 0
model.set_total(total)
model.set_position(pos)
if message:
model.set_message(message)
def _writeprogress(self, msg, flush=False):
# The Rust thread will write the progress.
pass
def complete(self):
self._model = None
renderers = {
"classic": classicrenderer,
"fancy": fancyrenderer,
"none": nullrenderer,
# Does not render directly. But updates Rust progress models.
"rust:simple": rustmodelupdater,
}
def getrenderer(bar):
renderername = bar._ui.config("progress", "renderer")
return renderers.get(renderername, classicrenderer)(bar)
class engine(object):
def __init__(self):
self._cond = rustthreading.Condition()
self._active = False
self._refresh = None
self._delay = None
self._bars = []
self._currentbarindex = None
@contextlib.contextmanager
def lock(self):
# Ugly hack for buggy Python (https://bugs.python.org/issue29988)
#
# Python can skip executing "__exit__" if a signal arrives at the
# "right" time. Workaround it by using N "__exit__"s. Skipping all of
# them would require N signals to be all sent at "right" time. Unlikely
# in practise.
b = rustthreading.bug29988wrapper(self._cond)
with b, b, b, b, b, b:
yield
@contextlib.contextmanager
def _lockclear(self):
with self.lock(), _suspendrustprogressbar():
bar = self._currentbar()
if bar is not None:
bar._enginerenderer.clear()
yield
def resetstate(self):
with self.lock():
self._clear()
self._bars = []
self._currentbarindex = None
self._refresh = None
self._cond.notify_all()
def register(self, bar):
with self.lock():
self._activate(bar._ui)
now = time.time()
bar._enginestarttime = now
bar._enginerenderer = getrenderer(bar)
self._bars.append(bar)
self._recalculatedisplay(now)
global suspend
suspend = self._lockclear
# Do not redraw when registering a nested bar
if len(self._bars) <= 1:
self._cond.notify_all()
def unregister(self, bar):
with self.lock():
try:
index = self._bars.index(bar)
except ValueError:
pass
else:
if index == self._currentbarindex:
if index == 0:
self._complete()
else:
self._clear()
del self._bars[index:]
self._recalculatedisplay(time.time())
if not self._bars:
global suspend
suspend = _suspendrustprogressbar
# Do not redraw when unregistering a nested bar
if len(self._bars) < 1:
self._cond.notify_all()
bar._enginerenderer = None
def _activate(self, ui):
with self.lock():
if not self._active:
self._active = True
self._thread = threading.Thread(target=self._run, name="progress")
self._thread.daemon = True
self._thread.start()
ui.atexit(self._deactivate)
def _deactivate(self):
if self._active:
with self.lock():
self._active = False
self._cond.notify_all()
self._thread.join()
def _run(self):
with self.lock():
while self._active:
self._cond.wait(self._refresh)
if self._active:
now = time.time()
self._recalculatedisplay(now)
self._updateestimation(now)
self._show(now)
def _show(self, now):
with self.lock():
bar = self._currentbar()
if bar is not None:
bar._enginerenderer.show(now)
def _clear(self):
with self.lock():
bar = self._currentbar()
if bar is not None:
bar._enginerenderer.clear()
def _complete(self):
with self.lock():
bar = self._currentbar()
if bar is not None:
if bar._ui.configbool("progress", "clear-complete"):
bar._enginerenderer.clear()
else:
bar._enginerenderer.complete()
def _currentbar(self):
if self._currentbarindex is not None:
return self._bars[self._currentbarindex]
return None
def _recalculatedisplay(self, now):
"""determine which bar should be displayed, if any"""
with self.lock():
if not self._bars:
self._currentbarindex = None
self._refresh = None
return
# Look to see if there is a new bar to show, or how long until
# another bar should be shown.
if self._currentbarindex is None:
nextbarindex = 0
newbarindex = None
else:
newbarindex = min(self._currentbarindex, len(self._bars) - 1)
nextbarindex = self._currentbarindex + 1
changetimes = []
for b in reversed(range(nextbarindex, len(self._bars))):
bar = self._bars[b]
if self._currentbarindex is None:
startdelay = bar._delay
else:
startdelay = bar._changedelay
if bar._enginestarttime + startdelay < now:
newbarindex = b
else:
changetimes.append(bar._enginestarttime + startdelay - now)
self._currentbarindex = newbarindex
# Update the refresh time.
bar = self._currentbar()
if bar is not None:
changetimes.append(bar._refresh)
if changetimes:
self._refresh = min(changetimes)
else:
self._refresh = None
def _updateestimation(self, now):
with self.lock():
for bar in self._bars:
bar._updateestimation(now)
_engine_pid = None
_engine = None
def getengine():
global _engine
global _engine_pid
pid = os.getpid()
if pid != _engine_pid:
_engine = engine()
_engine_pid = pid
return _engine
@contextlib.contextmanager
def _suspendrustprogressbar():
util.mainio.disable_progress(True)
try:
yield
finally:
util.mainio.disable_progress(False)
suspend = _suspendrustprogressbar
def _progvalue(value):
"""split a progress bar value into a position and item"""
if isinstance(value, tuple):
return value
else:
return value, ""
class tracedbar(object):
"""base class for progress bars that generates tracing events"""
def __enter__(self):
spanid = _tracer.span(
[("name", "Progress Bar: %s" % self._topic), ("cat", "progressbar")]
)
_tracer.enter(spanid)
self._spanid = spanid
return self.enter()
def __exit__(self, exctype, excvalue, traceback):
spanid = self._spanid
total = getattr(self, "_total", None)
if total is not None:
_tracer.edit(spanid, [("total", str(total))])
_tracer.exit(spanid)
return self.exit(exctype, excvalue, traceback)
class normalbar(tracedbar):
"""context manager that adds a progress bar to slow operations
To use this, wrap a section of code that takes a long time like this:
with progress.bar(ui, "topic") as prog:
# processing code
prog.value = pos
# alternatively: prog.value = (pos, item)
"""
def __init__(self, ui, topic, unit="", total=None, start=0, formatfunc=None):
self._ui = ui
self._topic = topic
self._unit = unit
self._total = total
self._start = start
self._formatfunc = formatfunc
self._delay = ui.configwith(float, "progress", "delay")
self._refresh = ui.configwith(float, "progress", "refresh")
self._changedelay = ui.configwith(float, "progress", "changedelay")
self._estimateinterval = ui.configwith(float, "progress", "estimateinterval")
self._estimatecount = max(20, int(self._estimateinterval))
self._estimatetick = self._estimateinterval / self._estimatecount
self._estimatering = util.ring(self._estimatecount)
def reset(self, topic, unit="", total=None):
with getengine().lock():
self._topic = topic
self._unit = unit
self._total = total
self.value = self._start
self._estimatering = util.ring(self._estimatecount)
def _getestimatebounds(self):
if len(self._estimatering) < 2:
return None
else:
return self._estimatering[0], self._estimatering[-1]
def _updateestimation(self, now):
ring = self._estimatering
if len(ring) == 0 or ring[-1][1] + self._estimatetick <= now:
pos, _item = _progvalue(self.value)
ring.push((pos, now))
def enter(self):
self.value = self._start
getengine().register(self)
return self
def exit(self, type, value, traceback):
getengine().unregister(self)
class debugbar(tracedbar):
def __init__(self, ui, topic, unit="", total=None, start=0, formatfunc=None):
self._ui = ui
self._topic = topic
self._unit = unit
self._total = total
self._start = start
self._formatfunc = formatfunc
self._started = False
def reset(self, topic, unit="", total=None):
if self._started:
self._ui.write(_x("progress: %s (reset)\n") % self._topic)
self._topic = topic
self._unit = unit
self._total = total
self.value = self._start
self._started = False
def enter(self):
super(debugbar, self).__setattr__("value", self._start)
return self
def exit(self, type, value, traceback):
if self._started:
self._ui.write(_x("progress: %s (end)\n") % self._topic)
def __setattr__(self, name, value):
if name == "value":
self._started = True
pos, item = _progvalue(value)
unit = (" %s" % self._unit) if self._unit else ""
item = (" %s" % item) if item else ""
if self._total:
pct = 100.0 * pos / self._total
self._ui.write(
_x("progress: %s:%s %d/%d%s (%4.2f%%)\n")
% (self._topic, item, pos, self._total, unit, pct)
)
else:
self._ui.write(
_x("progress: %s:%s %d%s\n") % (self._topic, item, pos, unit)
)
super(debugbar, self).__setattr__(name, value)
class nullbar(tracedbar):
"""A progress bar context manager that just keeps track of state."""
def __init__(self, ui, topic, unit="", total=None, start=0, formatfunc=None):
self._topic = topic
self._unit = unit
self._total = total
self._start = start
self._formatfunc = formatfunc
def reset(self, topic, unit="", total=None):
self._topic = topic
self._unit = unit
self._total = total
self.value = self._start
def enter(self):
self.value = self._start
return self
def exit(self, type, value, traceback):
pass
def bar(ui, topic, unit="", total=None, start=0, formatfunc=None):
if ui.configbool("progress", "debug"):
return debugbar(ui, topic, unit, total, start, formatfunc)
elif (
ui.quiet
or ui.debugflag
or ui.configbool("progress", "disable")
or not shouldprint(ui)
):
return nullbar(ui, topic, unit, total, start, formatfunc)
else:
return normalbar(ui, topic, unit, total, start, formatfunc)
def spinner(ui, topic):
return bar(ui, topic, start=None)
def resetstate():
getengine().resetstate()