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)
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
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
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
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.
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
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
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
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
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
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'"
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
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.
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.
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 UIError
s.
Args: prog: The name of the program with which to prefix the error message.