Patchwork [7,of,7] ui: add config knob to redirect status messages to stderr (API)

login
register
mail settings
Submitter Yuya Nishihara
Date Nov. 6, 2018, 2:21 p.m.
Message ID <e58976bff0817c9a61e3.1541514096@mimosa>
Download mbox | patch
Permalink /patch/36436/
State Accepted
Headers show

Comments

Yuya Nishihara - Nov. 6, 2018, 2:21 p.m.
# HG changeset patch
# User Yuya Nishihara <yuya@tcha.org>
# Date 1541241770 -32400
#      Sat Nov 03 19:42:50 2018 +0900
# Node ID e58976bff0817c9a61e3a7511b19e9bcadbf7be3
# Parent  6e2134679a54abbbc686df205d721bd69740c33c
ui: add config knob to redirect status messages to stderr (API)

This option can be used to isolate structured output from status messages.
For now, "stdio" (stdout/err pair) and "stderr" are supported. In future
patches, I'll add the "channel" option which will send status messages to
a separate command-server channel with some metadata attached, maybe in
CBOR encoding.

This is a part of the generic templating plan:
https://www.mercurial-scm.org/wiki/GenericTemplatingPlan#Sanity_check_output

.. api::

   Status messages may be sent to a dedicated stream depending on
   configuration. Don't use ``ui.status()``, etc. as a shorthand for
   conditional writes. Use ``ui.write()`` for data output.
Augie Fackler - Nov. 6, 2018, 3:04 p.m.
> On Nov 6, 2018, at 09:21, Yuya Nishihara <yuya@tcha.org> wrote:
> 
> # HG changeset patch
> # User Yuya Nishihara <yuya@tcha.org>
> # Date 1541241770 -32400
> #      Sat Nov 03 19:42:50 2018 +0900
> # Node ID e58976bff0817c9a61e3a7511b19e9bcadbf7be3
> # Parent  6e2134679a54abbbc686df205d721bd69740c33c
> ui: add config knob to redirect status messages to stderr (API)

queued these, thanks

Patch

diff --git a/mercurial/configitems.py b/mercurial/configitems.py
--- a/mercurial/configitems.py
+++ b/mercurial/configitems.py
@@ -1181,6 +1181,9 @@  coreconfigitem('ui', 'mergemarkertemplat
             '{ifeq(branch, "default", "", "{branch} ")}'
             '- {author|user}: {desc|firstline}')
 )
+coreconfigitem('ui', 'message-output',
+    default='stdio',
+)
 coreconfigitem('ui', 'nontty',
     default=False,
 )
diff --git a/mercurial/help/config.txt b/mercurial/help/config.txt
--- a/mercurial/help/config.txt
+++ b/mercurial/help/config.txt
@@ -2246,6 +2246,14 @@  User interface controls.
 
     Can be overridden per-merge-tool, see the ``[merge-tools]`` section.
 
+``message-output``
+    Where to write status and error messages. (default: ``stdio``)
+
+    ``stderr``
+      Everything to stderr.
+    ``stdio``
+      Status to stdout, and error to stderr.
+
 ``origbackuppath``
     The path to a directory used to store generated .orig files. If the path is
     not a directory, one will be created.  If set, files stored in this
diff --git a/mercurial/ui.py b/mercurial/ui.py
--- a/mercurial/ui.py
+++ b/mercurial/ui.py
@@ -234,6 +234,8 @@  class ui(object):
             self._fout = src._fout
             self._ferr = src._ferr
             self._fin = src._fin
+            self._fmsgout = src._fmsgout
+            self._fmsgerr = src._fmsgerr
             self._finoutredirected = src._finoutredirected
             self.pageractive = src.pageractive
             self._disablepager = src._disablepager
@@ -259,6 +261,8 @@  class ui(object):
             self._fout = procutil.stdout
             self._ferr = procutil.stderr
             self._fin = procutil.stdin
+            self._fmsgout = self.fout  # configurable
+            self._fmsgerr = self.ferr  # configurable
             self._finoutredirected = False
             self.pageractive = False
             self._disablepager = False
@@ -416,7 +420,7 @@  class ui(object):
 
         if self.plain():
             for k in ('debug', 'fallbackencoding', 'quiet', 'slash',
-                      'logtemplate', 'statuscopies', 'style',
+                      'logtemplate', 'message-output', 'statuscopies', 'style',
                       'traceback', 'verbose'):
                 if k in cfg['ui']:
                     del cfg['ui'][k]
@@ -469,6 +473,7 @@  class ui(object):
 
         if section in (None, 'ui'):
             # update ui options
+            self._fmsgout, self._fmsgerr = _selectmsgdests(self)
             self.debugflag = self.configbool('ui', 'debug')
             self.verbose = self.debugflag or self.configbool('ui', 'verbose')
             self.quiet = not self.debugflag and self.configbool('ui', 'quiet')
@@ -891,6 +896,7 @@  class ui(object):
     @fout.setter
     def fout(self, f):
         self._fout = f
+        self._fmsgout, self._fmsgerr = _selectmsgdests(self)
 
     @property
     def ferr(self):
@@ -899,6 +905,7 @@  class ui(object):
     @ferr.setter
     def ferr(self, f):
         self._ferr = f
+        self._fmsgout, self._fmsgerr = _selectmsgdests(self)
 
     @property
     def fin(self):
@@ -1364,17 +1371,18 @@  class ui(object):
         If ui is not interactive, the default is returned.
         """
         if not self.interactive():
-            self.write(msg, ' ', label='ui.prompt')
-            self.write(default or '', "\n", label='ui.promptecho')
+            self._write(self._fmsgout, msg, ' ', label='ui.prompt')
+            self._write(self._fmsgout, default or '', "\n",
+                        label='ui.promptecho')
             return default
-        self._writenobuf(self._fout, msg, label='ui.prompt')
+        self._writenobuf(self._fmsgout, msg, label='ui.prompt')
         self.flush()
         try:
             r = self._readline()
             if not r:
                 r = default
             if self.configbool('ui', 'promptecho'):
-                self.write(r, "\n", label='ui.promptecho')
+                self._write(self._fmsgout, r, "\n", label='ui.promptecho')
             return r
         except EOFError:
             raise error.ResponseExpected()
@@ -1424,13 +1432,15 @@  class ui(object):
             r = self.prompt(msg, resps[default])
             if r.lower() in resps:
                 return resps.index(r.lower())
-            self.write(_("unrecognized response\n"))
+            # TODO: shouldn't it be a warning?
+            self._write(self._fmsgout, _("unrecognized response\n"))
 
     def getpass(self, prompt=None, default=None):
         if not self.interactive():
             return default
         try:
-            self.write_err(self.label(prompt or _('password: '), 'ui.prompt'))
+            self._write(self._fmsgerr, prompt or _('password: '),
+                        label='ui.prompt')
             # disable getpass() only if explicitly specified. it's still valid
             # to interact with tty even if fin is not a tty.
             with self.timeblockedsection('stdio'):
@@ -1451,7 +1461,7 @@  class ui(object):
         '''
         if not self.quiet:
             opts[r'label'] = opts.get(r'label', '') + ' ui.status'
-            self.write(*msg, **opts)
+            self._write(self._fmsgout, *msg, **opts)
 
     def warn(self, *msg, **opts):
         '''write warning message to output (stderr)
@@ -1459,7 +1469,7 @@  class ui(object):
         This adds an output label of "ui.warning".
         '''
         opts[r'label'] = opts.get(r'label', '') + ' ui.warning'
-        self.write_err(*msg, **opts)
+        self._write(self._fmsgerr, *msg, **opts)
 
     def error(self, *msg, **opts):
         '''write error message to output (stderr)
@@ -1467,7 +1477,7 @@  class ui(object):
         This adds an output label of "ui.error".
         '''
         opts[r'label'] = opts.get(r'label', '') + ' ui.error'
-        self.write_err(*msg, **opts)
+        self._write(self._fmsgerr, *msg, **opts)
 
     def note(self, *msg, **opts):
         '''write note to output (if ui.verbose is True)
@@ -1476,7 +1486,7 @@  class ui(object):
         '''
         if self.verbose:
             opts[r'label'] = opts.get(r'label', '') + ' ui.note'
-            self.write(*msg, **opts)
+            self._write(self._fmsgout, *msg, **opts)
 
     def debug(self, *msg, **opts):
         '''write debug message to output (if ui.debugflag is True)
@@ -1485,7 +1495,7 @@  class ui(object):
         '''
         if self.debugflag:
             opts[r'label'] = opts.get(r'label', '') + ' ui.debug'
-            self.write(*msg, **opts)
+            self._write(self._fmsgout, *msg, **opts)
 
     def edit(self, text, user, extra=None, editform=None, pending=None,
              repopath=None, action=None):
@@ -1939,3 +1949,11 @@  def getprogbar(ui):
 
 def haveprogbar():
     return _progresssingleton is not None
+
+def _selectmsgdests(ui):
+    name = ui.config(b'ui', b'message-output')
+    if name == b'stdio':
+        return ui.fout, ui.ferr
+    if name == b'stderr':
+        return ui.ferr, ui.ferr
+    raise error.Abort(b'invalid ui.message-output destination: %s' % name)
diff --git a/tests/test-basic.t b/tests/test-basic.t
--- a/tests/test-basic.t
+++ b/tests/test-basic.t
@@ -102,3 +102,118 @@  Repository root:
 At the end...
 
   $ cd ..
+
+Status message redirection:
+
+  $ hg init empty
+
+ status messages are sent to stdout by default:
+
+  $ hg outgoing -R t empty -Tjson 2>/dev/null
+  comparing with empty
+  searching for changes
+  [
+   {
+    "bookmarks": [],
+    "branch": "default",
+    "date": [0, 0],
+    "desc": "test",
+    "node": "acb14030fe0a21b60322c440ad2d20cf7685a376",
+    "parents": ["0000000000000000000000000000000000000000"],
+    "phase": "draft",
+    "rev": 0,
+    "tags": ["tip"],
+    "user": "test"
+   }
+  ]
+
+ which can be configured to send to stderr, so the output wouldn't be
+ interleaved:
+
+  $ cat <<'EOF' >> "$HGRCPATH"
+  > [ui]
+  > message-output = stderr
+  > EOF
+  $ hg outgoing -R t empty -Tjson 2>/dev/null
+  [
+   {
+    "bookmarks": [],
+    "branch": "default",
+    "date": [0, 0],
+    "desc": "test",
+    "node": "acb14030fe0a21b60322c440ad2d20cf7685a376",
+    "parents": ["0000000000000000000000000000000000000000"],
+    "phase": "draft",
+    "rev": 0,
+    "tags": ["tip"],
+    "user": "test"
+   }
+  ]
+  $ hg outgoing -R t empty -Tjson >/dev/null
+  comparing with empty
+  searching for changes
+
+ this option should be turned off by HGPLAIN= since it may break scripting use:
+
+  $ HGPLAIN= hg outgoing -R t empty -Tjson 2>/dev/null
+  comparing with empty
+  searching for changes
+  [
+   {
+    "bookmarks": [],
+    "branch": "default",
+    "date": [0, 0],
+    "desc": "test",
+    "node": "acb14030fe0a21b60322c440ad2d20cf7685a376",
+    "parents": ["0000000000000000000000000000000000000000"],
+    "phase": "draft",
+    "rev": 0,
+    "tags": ["tip"],
+    "user": "test"
+   }
+  ]
+
+ but still overridden by --config:
+
+  $ HGPLAIN= hg outgoing -R t empty -Tjson --config ui.message-output=stderr \
+  > 2>/dev/null
+  [
+   {
+    "bookmarks": [],
+    "branch": "default",
+    "date": [0, 0],
+    "desc": "test",
+    "node": "acb14030fe0a21b60322c440ad2d20cf7685a376",
+    "parents": ["0000000000000000000000000000000000000000"],
+    "phase": "draft",
+    "rev": 0,
+    "tags": ["tip"],
+    "user": "test"
+   }
+  ]
+
+Invalid ui.message-output option:
+
+  $ hg log -R t --config ui.message-output=bad
+  abort: invalid ui.message-output destination: bad
+  [255]
+
+Underlying message streams should be updated when ui.fout/ferr are set:
+
+  $ cat <<'EOF' > capui.py
+  > from mercurial import pycompat, registrar
+  > cmdtable = {}
+  > command = registrar.command(cmdtable)
+  > @command(b'capui', norepo=True)
+  > def capui(ui):
+  >     out = ui.fout
+  >     ui.fout = pycompat.bytesio()
+  >     ui.status(b'status\n')
+  >     ui.ferr = pycompat.bytesio()
+  >     ui.warn(b'warn\n')
+  >     out.write(b'stdout: %s' % ui.fout.getvalue())
+  >     out.write(b'stderr: %s' % ui.ferr.getvalue())
+  > EOF
+  $ hg --config extensions.capui=capui.py --config ui.message-output=stdio capui
+  stdout: status
+  stderr: warn