misc.python.materialize.ui

Utilities for interacting with humans.

  1# Copyright Materialize, Inc. and contributors. All rights reserved.
  2#
  3# Use of this software is governed by the Business Source License
  4# included in the LICENSE file at the root of this repository.
  5#
  6# As of the Change Date specified in that file, in accordance with
  7# the Business Source License, use of this software will be governed
  8# by the Apache License, Version 2.0.
  9
 10"""Utilities for interacting with humans."""
 11
 12import asyncio
 13import datetime
 14import os
 15import shlex
 16import sys
 17import time
 18from collections.abc import AsyncGenerator, Callable, Generator, Iterable
 19from contextlib import contextmanager
 20from typing import Any
 21
 22from colored import attr, fg
 23
 24
 25class Verbosity:
 26    """How noisy logs should be"""
 27
 28    quiet: bool = False
 29
 30    @classmethod
 31    def init_from_env(cls, explicit: bool | None) -> None:
 32        """Set to quiet based on MZ_QUIET being set to almost any value
 33
 34        The only values that this gets set to false for are the empty string, 0, or no
 35        """
 36        cls.quiet = env_is_truthy("MZ_QUIET")
 37        if explicit is not None:
 38            cls.quiet = explicit
 39
 40
 41def speaker(prefix: str) -> Callable[..., None]:
 42    """Create a function that will log with a prefix to stderr.
 43
 44    Obeys `Verbosity.quiet`. Note that you must include any necessary
 45    spacing after the prefix.
 46
 47    Example::
 48
 49        >>> say = speaker("mz> ")
 50        >>> say("hello")  # doctest: +SKIP
 51        mz> hello
 52    """
 53
 54    def say(msg: str) -> None:
 55        if not Verbosity.quiet:
 56            print(f"{prefix}{msg}", file=sys.stderr)
 57
 58    return say
 59
 60
 61header = speaker("==> ")
 62section = speaker("--- ")
 63say = speaker("")
 64
 65
 66def warn(message: str) -> None:
 67    """Emits a warning message to stderr."""
 68    print(f"{fg('yellow')}warning:{attr('reset')} {message}")
 69
 70
 71def confirm(question: str) -> bool:
 72    """Render a question, returning True if the user says y or yes"""
 73    response = input(f"{question} [y/N]")
 74    return response.lower() in ("y", "yes")
 75
 76
 77def progress(msg: str = "", prefix: str | None = None, *, finish: bool = False) -> None:
 78    """Print a progress message to stderr, using the same prefix format as speaker"""
 79    if prefix is not None:
 80        msg = f"{prefix}> {msg}"
 81    end = "" if not finish else "\n"
 82    print(msg, file=sys.stderr, flush=True, end=end)
 83
 84
 85def timeout_loop(timeout: int, tick: float = 1.0) -> Generator[float, None, None]:
 86    """Loop until timeout, optionally sleeping until tick
 87
 88    Always iterates at least once
 89
 90    Args:
 91        timeout: maximum. number of seconds to wait
 92        tick: how long to ensure passes between loop iterations. Default: 1
 93    """
 94    end = time.monotonic() + timeout
 95    while True:
 96        before = time.monotonic()
 97        yield end - before
 98        after = time.monotonic()
 99
100        if after >= end:
101            return
102
103        if after - before < tick:
104            if tick > 0:
105                time.sleep(tick - (after - before))
106
107
108async def async_timeout_loop(
109    timeout: int, tick: float = 1.0
110) -> AsyncGenerator[float, None]:
111    """Loop until timeout, asynchronously sleeping until tick
112
113    Always iterates at least once
114
115    Args:
116        timeout: maximum. number of seconds to wait
117        tick: how long to ensure passes between loop iterations. Default: 1
118    """
119    end = time.monotonic() + timeout
120    while True:
121        before = time.monotonic()
122        yield end - before
123        after = time.monotonic()
124
125        if after >= end:
126            return
127
128        if after - before < tick:
129            if tick > 0:
130                await asyncio.sleep(tick - (after - before))
131
132
133def log_in_automation(msg: str) -> None:
134    """Log to a file, if we're running in automation"""
135    if env_is_truthy("MZ_IN_AUTOMATION"):
136        with open("/tmp/mzcompose.log", "a") as fh:
137            now = datetime.datetime.now().isoformat()
138            print(f"[{now}] {msg}", file=fh)
139
140
141def shell_quote(args: Iterable[Any]) -> str:
142    """Return shell-escaped string of all the parameters
143
144    ::
145
146        >>> shell_quote(["one", "two three"])
147        "one 'two three'"
148    """
149    return " ".join(shlex.quote(str(arg)) for arg in args)
150
151
152def env_is_truthy(env_var: str, default: str = "0") -> bool:
153    """Return true if `env_var` is set and is not one of: 0, '', no, false"""
154    env = os.getenv(env_var, default)
155    if env is not None:
156        return env not in ("", "0", "no", "false")
157    return False
158
159
160class UIError(Exception):
161    """An error intended for display to humans.
162
163    Use this exception type when the error is something the user can be expected
164    to handle. If the error indicates a truly unexpected condition (i.e., a
165    programming error), use a different exception type that will produce a
166    backtrace instead.
167
168    Attributes:
169        hint: An optional hint to display alongside the error message.
170    """
171
172    def __init__(self, message: str, hint: str | None = None):
173        super().__init__(message)
174        self.hint = hint
175
176    def set_hint(self, hint: str) -> None:
177        """Attaches a hint to the error.
178
179        This method will overwrite the existing hint, if any.
180        """
181        self.hint = hint
182
183
184class CommandFailureCausedUIError(UIError):
185    """
186    An UIError that is caused by executing a command.
187    """
188
189    def __init__(
190        self,
191        message: str,
192        cmd: list[str],
193        stdout: str | None = None,
194        stderr: str | None = None,
195        hint: str | None = None,
196    ):
197        super().__init__(message, hint)
198        self.cmd = cmd
199        self.stdout = stdout
200        self.stderr = stderr
201
202
203@contextmanager
204def error_handler(prog: str) -> Any:
205    """Catches and pretty-prints any raised `UIError`s.
206
207    Args:
208        prog: The name of the program with which to prefix the error message.
209    """
210    try:
211        yield
212    except UIError as e:
213        print(f"{prog}: {fg('red')}error:{attr('reset')} {e}", file=sys.stderr)
214        if e.hint:
215            print(f"{attr('bold')}hint:{attr('reset')} {e.hint}")
216        sys.exit(1)
217    except KeyboardInterrupt:
218        sys.exit(1)
class Verbosity:
26class Verbosity:
27    """How noisy logs should be"""
28
29    quiet: bool = False
30
31    @classmethod
32    def init_from_env(cls, explicit: bool | None) -> None:
33        """Set to quiet based on MZ_QUIET being set to almost any value
34
35        The only values that this gets set to false for are the empty string, 0, or no
36        """
37        cls.quiet = env_is_truthy("MZ_QUIET")
38        if explicit is not None:
39            cls.quiet = explicit

How noisy logs should be

quiet: bool = False
@classmethod
def init_from_env(cls, explicit: bool | None) -> None:
31    @classmethod
32    def init_from_env(cls, explicit: bool | None) -> None:
33        """Set to quiet based on MZ_QUIET being set to almost any value
34
35        The only values that this gets set to false for are the empty string, 0, or no
36        """
37        cls.quiet = env_is_truthy("MZ_QUIET")
38        if explicit is not None:
39            cls.quiet = explicit

Set to quiet based on MZ_QUIET being set to almost any value

The only values that this gets set to false for are the empty string, 0, or no

def speaker(prefix: str) -> Callable[..., None]:
42def speaker(prefix: str) -> Callable[..., None]:
43    """Create a function that will log with a prefix to stderr.
44
45    Obeys `Verbosity.quiet`. Note that you must include any necessary
46    spacing after the prefix.
47
48    Example::
49
50        >>> say = speaker("mz> ")
51        >>> say("hello")  # doctest: +SKIP
52        mz> hello
53    """
54
55    def say(msg: str) -> None:
56        if not Verbosity.quiet:
57            print(f"{prefix}{msg}", file=sys.stderr)
58
59    return say

Create a function that will log with a prefix to stderr.

Obeys Verbosity.quiet. Note that you must include any necessary spacing after the prefix.

Example::

>>> say = speaker("mz> ")
>>> say("hello")  # doctest: +SKIP
mz> hello
def section(msg: str) -> None:
55    def say(msg: str) -> None:
56        if not Verbosity.quiet:
57            print(f"{prefix}{msg}", file=sys.stderr)
def say(msg: str) -> None:
55    def say(msg: str) -> None:
56        if not Verbosity.quiet:
57            print(f"{prefix}{msg}", file=sys.stderr)
def warn(message: str) -> None:
67def warn(message: str) -> None:
68    """Emits a warning message to stderr."""
69    print(f"{fg('yellow')}warning:{attr('reset')} {message}")

Emits a warning message to stderr.

def confirm(question: str) -> bool:
72def confirm(question: str) -> bool:
73    """Render a question, returning True if the user says y or yes"""
74    response = input(f"{question} [y/N]")
75    return response.lower() in ("y", "yes")

Render a question, returning True if the user says y or yes

def progress( msg: str = '', prefix: str | None = None, *, finish: bool = False) -> None:
78def progress(msg: str = "", prefix: str | None = None, *, finish: bool = False) -> None:
79    """Print a progress message to stderr, using the same prefix format as speaker"""
80    if prefix is not None:
81        msg = f"{prefix}> {msg}"
82    end = "" if not finish else "\n"
83    print(msg, file=sys.stderr, flush=True, end=end)

Print a progress message to stderr, using the same prefix format as speaker

def timeout_loop(timeout: int, tick: float = 1.0) -> Generator[float, None, None]:
 86def timeout_loop(timeout: int, tick: float = 1.0) -> Generator[float, None, None]:
 87    """Loop until timeout, optionally sleeping until tick
 88
 89    Always iterates at least once
 90
 91    Args:
 92        timeout: maximum. number of seconds to wait
 93        tick: how long to ensure passes between loop iterations. Default: 1
 94    """
 95    end = time.monotonic() + timeout
 96    while True:
 97        before = time.monotonic()
 98        yield end - before
 99        after = time.monotonic()
100
101        if after >= end:
102            return
103
104        if after - before < tick:
105            if tick > 0:
106                time.sleep(tick - (after - before))

Loop until timeout, optionally sleeping until tick

Always iterates at least once

Args: timeout: maximum. number of seconds to wait tick: how long to ensure passes between loop iterations. Default: 1

async def async_timeout_loop(timeout: int, tick: float = 1.0) -> AsyncGenerator[float, None]:
109async def async_timeout_loop(
110    timeout: int, tick: float = 1.0
111) -> AsyncGenerator[float, None]:
112    """Loop until timeout, asynchronously sleeping until tick
113
114    Always iterates at least once
115
116    Args:
117        timeout: maximum. number of seconds to wait
118        tick: how long to ensure passes between loop iterations. Default: 1
119    """
120    end = time.monotonic() + timeout
121    while True:
122        before = time.monotonic()
123        yield end - before
124        after = time.monotonic()
125
126        if after >= end:
127            return
128
129        if after - before < tick:
130            if tick > 0:
131                await asyncio.sleep(tick - (after - before))

Loop until timeout, asynchronously sleeping until tick

Always iterates at least once

Args: timeout: maximum. number of seconds to wait tick: how long to ensure passes between loop iterations. Default: 1

def log_in_automation(msg: str) -> None:
134def log_in_automation(msg: str) -> None:
135    """Log to a file, if we're running in automation"""
136    if env_is_truthy("MZ_IN_AUTOMATION"):
137        with open("/tmp/mzcompose.log", "a") as fh:
138            now = datetime.datetime.now().isoformat()
139            print(f"[{now}] {msg}", file=fh)

Log to a file, if we're running in automation

def shell_quote(args: Iterable[typing.Any]) -> str:
142def shell_quote(args: Iterable[Any]) -> str:
143    """Return shell-escaped string of all the parameters
144
145    ::
146
147        >>> shell_quote(["one", "two three"])
148        "one 'two three'"
149    """
150    return " ".join(shlex.quote(str(arg)) for arg in args)

Return shell-escaped string of all the parameters

::

>>> shell_quote(["one", "two three"])
"one 'two three'"
def env_is_truthy(env_var: str, default: str = '0') -> bool:
153def env_is_truthy(env_var: str, default: str = "0") -> bool:
154    """Return true if `env_var` is set and is not one of: 0, '', no, false"""
155    env = os.getenv(env_var, default)
156    if env is not None:
157        return env not in ("", "0", "no", "false")
158    return False

Return true if env_var is set and is not one of: 0, '', no, false

class UIError(builtins.Exception):
161class UIError(Exception):
162    """An error intended for display to humans.
163
164    Use this exception type when the error is something the user can be expected
165    to handle. If the error indicates a truly unexpected condition (i.e., a
166    programming error), use a different exception type that will produce a
167    backtrace instead.
168
169    Attributes:
170        hint: An optional hint to display alongside the error message.
171    """
172
173    def __init__(self, message: str, hint: str | None = None):
174        super().__init__(message)
175        self.hint = hint
176
177    def set_hint(self, hint: str) -> None:
178        """Attaches a hint to the error.
179
180        This method will overwrite the existing hint, if any.
181        """
182        self.hint = hint

An error intended for display to humans.

Use this exception type when the error is something the user can be expected to handle. If the error indicates a truly unexpected condition (i.e., a programming error), use a different exception type that will produce a backtrace instead.

Attributes: hint: An optional hint to display alongside the error message.

UIError(message: str, hint: str | None = None)
173    def __init__(self, message: str, hint: str | None = None):
174        super().__init__(message)
175        self.hint = hint
hint
def set_hint(self, hint: str) -> None:
177    def set_hint(self, hint: str) -> None:
178        """Attaches a hint to the error.
179
180        This method will overwrite the existing hint, if any.
181        """
182        self.hint = hint

Attaches a hint to the error.

This method will overwrite the existing hint, if any.

class CommandFailureCausedUIError(UIError):
185class CommandFailureCausedUIError(UIError):
186    """
187    An UIError that is caused by executing a command.
188    """
189
190    def __init__(
191        self,
192        message: str,
193        cmd: list[str],
194        stdout: str | None = None,
195        stderr: str | None = None,
196        hint: str | None = None,
197    ):
198        super().__init__(message, hint)
199        self.cmd = cmd
200        self.stdout = stdout
201        self.stderr = stderr

An UIError that is caused by executing a command.

CommandFailureCausedUIError( message: str, cmd: list[str], stdout: str | None = None, stderr: str | None = None, hint: str | None = None)
190    def __init__(
191        self,
192        message: str,
193        cmd: list[str],
194        stdout: str | None = None,
195        stderr: str | None = None,
196        hint: str | None = None,
197    ):
198        super().__init__(message, hint)
199        self.cmd = cmd
200        self.stdout = stdout
201        self.stderr = stderr
cmd
stdout
stderr
Inherited Members
UIError
hint
set_hint
@contextmanager
def error_handler(prog: str) -> Any:
204@contextmanager
205def error_handler(prog: str) -> Any:
206    """Catches and pretty-prints any raised `UIError`s.
207
208    Args:
209        prog: The name of the program with which to prefix the error message.
210    """
211    try:
212        yield
213    except UIError as e:
214        print(f"{prog}: {fg('red')}error:{attr('reset')} {e}", file=sys.stderr)
215        if e.hint:
216            print(f"{attr('bold')}hint:{attr('reset')} {e.hint}")
217        sys.exit(1)
218    except KeyboardInterrupt:
219        sys.exit(1)

Catches and pretty-prints any raised UIErrors.

Args: prog: The name of the program with which to prefix the error message.