blob: 25f14a513a30057dca30e8ecda65b99839efbdb7 [file] [log] [blame] [edit]
# Copyright 2024 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Shell code utility functions."""
import logging
import pathlib
from pathlib import Path
import sys
from typing import Any, Iterable, List, Optional, Tuple, Union
# For use by quote. Match all characters that the shell might treat specially.
# This means a number of things:
# - Reserved characters.
# - Characters used in expansions (brace, variable, path, globs, etc...).
# - Characters that an interactive shell might use (like !).
# - Whitespace so that one arg turns into multiple.
# See the bash man page as well as the POSIX shell documentation for more info:
# http://www.gnu.org/software/bash/manual/bashref.html
# http://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html
_SHELL_QUOTABLE_CHARS = frozenset("[|&;()<> \t\n!{}[]=*?~$\"'\\#^")
# The chars that, when used inside of double quotes, need escaping.
# Order here matters as we need to escape backslashes first.
_SHELL_ESCAPE_CHARS = r"\"`$"
def quote(s: Union[str, bytes, Path]) -> str:
"""Quote |s| in a way that is safe for use in a shell.
We aim to be safe, but also to produce "nice" output. That means we don't
use quotes when we don't need to, and we prefer to use less quotes (like
putting it all in single quotes) than more (using double quotes and escaping
a bunch of stuff, or mixing the quotes).
While python does provide a number of alternatives like:
- pipes.quote
- shlex.quote
They suffer from various problems like:
- Not widely available in different python versions.
- Do not produce pretty output in many cases.
- Are in modules that rarely otherwise get used.
Note: We don't handle reserved shell words like "for" or "case". This is
because those only matter when they're the first element in a command, and
there is no use case for that. When we want to run commands, we tend to
run real programs and not shell ones.
Args:
s: The string to quote.
Returns:
A safely (possibly quoted) string.
"""
# If callers pass down bad types, don't blow up.
if isinstance(s, bytes):
s = s.decode("utf-8", "backslashreplace")
elif isinstance(s, pathlib.PurePath):
return str(s)
elif not isinstance(s, str):
return repr(s)
# See if no quoting is needed so we can return the string as-is.
for c in s:
if c in _SHELL_QUOTABLE_CHARS:
break
else:
if not s:
return "''"
else:
return s # type: ignore
# See if we can use single quotes first. Output is nicer.
if "'" not in s:
return "'%s'" % s
# Have to use double quotes. Escape the few chars that still expand when
# used inside double quotes.
for c in _SHELL_ESCAPE_CHARS:
if c in s:
s = s.replace(c, rf"\{c}")
return f'"{s}"'
def unquote(s: str) -> str:
"""Do the opposite of quote.
This function assumes that the input is a valid, escaped string. The
behaviour is undefined on malformed strings.
Args:
s: An escaped string.
Returns:
The unescaped version of the string.
"""
if not s:
return ""
if s[0] == "'":
return s[1:-1]
if s[0] != '"':
return s
s = s[1:-1]
output = ""
i = 0
while i < len(s) - 1:
# Skip the backslash when it makes sense.
if s[i] == "\\" and s[i + 1] in _SHELL_ESCAPE_CHARS:
i += 1
output += s[i]
i += 1
return output + s[i] if i < len(s) else output
def cmd_to_str(cmd: Union[List[Any], Tuple[Any]]) -> str:
"""Translate a command list into a space-separated string.
The resulting string should be suitable for logging messages and for
pasting into a terminal to run. Command arguments are surrounded by
quotes to keep them grouped, even if an argument has spaces in it.
Examples:
['a', 'b'] ==> "'a' 'b'"
['a b', 'c'] ==> "'a b' 'c'"
['a', 'b\'c'] ==> '\'a\' "b\'c"'
[u'a', "/'$b"] ==> '\'a\' "/\'$b"'
[] ==> ''
See unittest for additional (tested) examples.
Args:
cmd: List of command arguments.
Returns:
String representing full command.
"""
# If callers pass down bad types, triage it a bit.
if isinstance(cmd, (list, tuple)):
return " ".join(quote(arg) for arg in cmd)
else:
raise ValueError(
f"cmd must be list or tuple, not {type(cmd)}: {repr(cmd)!r}"
)
def get_choice(title: str, options: Iterable[str], group_size: int = 0) -> int:
"""Ask user to choose an option from the list.
When |group_size| is 0, then all items in |options| will be extracted and
shown at the same time. Otherwise, the items will be extracted |group_size|
at a time, and then shown to the user. This makes it easier to support
generators that are slow, extremely large, or people usually want to pick
from the first few choices.
Args:
title: The text to display before listing options.
options: Iterable which provides options to display.
group_size: How many options to show before asking the user to choose.
Returns:
An integer of the index in |options| the user picked.
"""
def prompt_for_choice(max_choice: int, more: bool) -> Optional[int]:
prompt = f"Please choose an option [0-{max_choice:d}]"
if more:
prompt += " (Enter for more options)"
prompt += ": "
while True:
choice = input(prompt)
if more and not choice.strip():
return None
try:
choice_val = int(choice)
except ValueError:
print("Input is not an integer")
continue
if choice_val < 0 or choice_val > max_choice:
print(f"Choice {choice_val:d} out of range (0-{max_choice:d})")
continue
return choice_val
print(title)
max_choice = 0
for i, opt in enumerate(options):
if i and group_size and not i % group_size:
choice = prompt_for_choice(i - 1, True)
if choice is not None:
return choice
print(f" [{i:d}]: {opt}")
max_choice = i
return prompt_for_choice(max_choice, False)
def boolean_prompt(
prompt: str = "Do you want to continue?",
default: bool = True,
true_value: str = "yes",
false_value: str = "no",
prolog: Optional[str] = None,
) -> bool:
"""Helper function for processing boolean choice prompts.
Args:
prompt: The question to present to the user.
default: Boolean to return if the user just presses enter.
true_value: The text to display that represents a True returned.
false_value: The text to display that represents a False returned.
prolog: The text to display before prompt.
Returns:
True or False.
"""
true_value, false_value = true_value.lower(), false_value.lower()
true_text, false_text = true_value, false_value
if true_value == false_value:
raise ValueError(
f"true_value and false_value must differ: got {true_value!r}"
)
if default:
true_text = true_text[0].upper() + true_text[1:]
else:
false_text = false_text[0].upper() + false_text[1:]
prompt = f"\n{prompt} ({true_text}/{false_text})? "
if prolog:
prompt = f"\n{prolog}\n{prompt}"
while True:
try:
response = input(prompt).lower()
except EOFError:
# If the user hits CTRL+D, or stdin is disabled, use the default.
print()
response = None
except KeyboardInterrupt:
# If the user hits CTRL+C, just exit the process.
print()
logging.notice("CTRL+C detected; exiting")
sys.exit(1)
if not response:
return default
if true_value.startswith(response):
if not false_value.startswith(response):
return True
# common prefix between the two...
elif false_value.startswith(response):
return False
def boolean_value(sval: str, default: bool, msg: Optional[str] = None) -> bool:
"""See if the string value is a value users typically consider as boolean
Often times people set shell variables to different values to mean "true"
or "false". For example, they can do:
export FOO=yes
export BLAH=1
export MOO=true
Handle all that user ugliness here.
If the user picks an invalid value, you can use |msg| to display a non-fatal
warning rather than raising an exception.
Args:
sval: The string value we got from the user.
default: If we can't figure out if the value is true or false, use this.
msg: If |sval| is an unknown value, use |msg| to warn the user that we
could not decode the input. Otherwise, raise ValueError().
Returns:
The interpreted boolean value of |sval|.
Raises:
ValueError() if |sval| is an unknown value and |msg| is not set.
"""
if sval is None:
return default
if isinstance(sval, str):
s = sval.lower()
if s in ("yes", "y", "1", "true"):
return True
elif s in ("no", "n", "0", "false"):
return False
if msg is not None:
logging.warning("%s: %r", msg, sval)
return default
else:
raise ValueError(f"Could not decode as a boolean value: {sval!r}")