blob: f0a9d13dd58593b78324945245a2ddc9152e09bf [file] [log] [blame]
# Copyright 2019 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Common python commands used by various build scripts."""
import contextlib
import errno
import io
import logging
import os
from pathlib import Path
import re
from typing import cast, Dict, Generator, Iterable, List, Optional, Tuple, Union
from chromite.lib import osutils
# Simple quote chars we'll use often, to avoid confusion like "'" vs. '"'.
SINGLE_QUOTE = "'"
DOUBLE_QUOTE = '"'
QUOTE_CHARS = [SINGLE_QUOTE, DOUBLE_QUOTE]
@contextlib.contextmanager
def _Open(
obj: Union[str, "os.PathLike[str]", io.TextIOWrapper],
mode: str = "r",
encoding: str = "utf-8",
) -> Generator[io.TextIOWrapper, None, None]:
"""Convenience ctx that accepts a file path or an open file object."""
if isinstance(obj, (str, os.PathLike)):
with open(obj, mode=mode, encoding=encoding) as f:
yield cast(io.TextIOWrapper, f)
else:
yield obj
def LoadData(
data: str, multiline: bool = False, source: str = "<data>"
) -> Dict[str, str]:
"""Turn key=value content into a dict.
Note: If you're designing a new data store, please use json rather than
this format. This func is designed to work with legacy/external files
where json isn't an option.
Only UTF-8 content is supported currently.
Args:
data: The data to parse.
multiline: Allow a value enclosed by quotes to span multiple lines.
source: Helpful string for users to diagnose source of errors.
Returns:
a dict of all the key=value pairs found in the file.
"""
d = {}
key = None
in_quotes = None
for raw_line in data.splitlines(True):
line = raw_line.split("#")[0]
if not line.strip():
continue
# Continue processing a multiline value.
if multiline and in_quotes and key:
if line.rstrip()[-1] == in_quotes:
# Wrap up the multiline value if the line ends with a quote.
d[key] += line.rstrip()[:-1]
in_quotes = None
else:
d[key] += line
continue
chunks = line.split("=", 1)
if len(chunks) != 2:
raise ValueError(
"Malformed key=value file %r; line %r" % (source, raw_line)
)
key = chunks[0].strip()
val = chunks[1].strip()
if len(val) >= 2 and val[0] in QUOTE_CHARS and val[0] == val[-1]:
# Strip matching quotes on the same line.
val = val[1:-1]
elif val and multiline and val[0] in QUOTE_CHARS:
# Unmatched quote here indicates a multiline value. Do not
# strip the '\n' at the end of the line.
in_quotes = val[0]
val = chunks[1].lstrip()[1:]
d[key] = val
return d
def LoadFile(
obj: Union[str, "os.PathLike[str]", io.TextIOWrapper],
ignore_missing: bool = False,
multiline: bool = False,
) -> Dict[str, str]:
"""Turn a key=value file into a dict.
Note: If you're designing a new data store, please use json rather than
this format. This func is designed to work with legacy/external files
where json isn't an option.
Only UTF-8 content is supported currently.
Args:
obj: The file to read. Can be a path or an open file object.
ignore_missing: If the file does not exist, return an empty dict.
multiline: Allow a value enclosed by quotes to span multiple lines.
Returns:
a dict of all the key=value pairs found in the file.
"""
try:
with _Open(obj) as f:
if isinstance(obj, (str, os.PathLike)):
source = str(obj)
else:
source = "<already-open file>"
return LoadData(f.read(), multiline=multiline, source=source)
except EnvironmentError as e:
if not (ignore_missing and e.errno == errno.ENOENT):
raise
return {}
def UpdateKeyInContents(
old_lines: Iterable[str], key: str, value: str
) -> List[str]:
"""Update a key in the contents of a key-value store.
Key-value pairs are represented as:
key="value"
If the key-value store does not already contain |key|, it will be appended.
Args:
old_lines: The existing contents of the key-value store.
key: The variable key to update.
value: The value to write for that key. Quotes will be added
automatically.
Returns:
A new list of lines for an updated key-value store.
Raises:
ValueError: If the key already exists in the file with a multiline
value. This is valid in some key-value stores, but so far it hasn't
been necessary to make this function compatible with that.
If you hit this error in production, consider adding that feature!
ValueError: If the new value is multiline. Again, if you hit this error
in production, consider adding this feature!
ValueError: If the new value has a single quote on one side and a double
quote on the other side, and thus cannot be wrapped.
"""
if "\n" in value:
raise ValueError(
f"Cannot update multi-line value in key-value store: {value}"
)
# Pre-construct the new key=value string.
# Start by figuring out whether to wrap it in single or double quotes.
quote_char: str
if not value: # Avoid IndexError with value[0]
quote_char = DOUBLE_QUOTE
elif (
value[0] in QUOTE_CHARS
and value[-1] in QUOTE_CHARS
and value[0] != value[-1]
):
raise ValueError(
f"Cannot wrap string with mismatched quotes on the ends: {value}"
)
elif DOUBLE_QUOTE in (value[0], value[-1]):
quote_char = SINGLE_QUOTE
else:
quote_char = DOUBLE_QUOTE
new_keyval_line = f"{key}={quote_char}{value}{quote_char}"
# re_any_key_value should match any key="value" line.
# The value can be wrapped in either single-quotes or double-quotes.
# Either the key or the quoted value can be padded by whitespace.
re_any_key_value = re.compile(
r"^\s*(?P<key>[A-Za-z-_.]+)\s*="
r"\s*(?P<quote>['\"])(?P<value>.*)(?P=quote)\s*$",
)
def _extract_key_value(line: str) -> Optional[Tuple[str, str]]:
"""If the line looks like key="value", return the key and value.
Returns None if the line does not have the expected format.
"""
m = re_any_key_value.match(line)
if not m:
return None
return (m.group("key"), m.group("value"))
# new_lines is the content to be used to overwrite/create the config file
# at the end of this function.
new_lines = []
# Scan current lines, copy all vars to new_lines, change the line with
# |key|.
found = False
for line in old_lines:
# Strip newlines from end of line. We already add newlines below.
line = line.rstrip("\n")
file_keyval = _extract_key_value(line)
# Skip any line that doesn't look like a key=value line.
if file_keyval is None:
new_lines.append(line)
continue
# Skip any keyval line that has a different key.
file_key, file_value = file_keyval
if file_key != key:
new_lines.append(line)
continue
# Replace the line with our new line.
found = True
logging.info(
"Updating %s=%s to %s=%s", file_key, file_value, key, value
)
new_lines.append(new_keyval_line)
if not found:
logging.info("Adding new variable %s=%s", key, value)
new_lines.append(new_keyval_line)
# End the file with a single newline, but don't add one if one exists.
if new_lines[-1]:
new_lines.append("")
return new_lines
def UpdateKeyInLocalFile(
filepath: Union[Path, str], key: str, value: str
) -> bool:
"""Update a key in a local key-value store file with the value passed.
If `filepath` does not already exist, it will be created.
Args:
filepath: Name of file to modify.
key: The variable key to update.
value: The value to write for that key. Quotes will be added
automatically.
Returns:
True if changes were made to the file.
"""
return UpdateKeysInLocalFile(filepath, {key: value})
def UpdateKeysInLocalFile(
filepath: Union[Path, str],
keys_values: Dict[str, str],
) -> bool:
"""Update any number of key-value pairs in a local key-value store file.
Args:
filepath: The local path to the key-value store file.
keys_values: Dict of {key: value} for all new values.
Returns:
True if any key-value pairs were changed in the file.
"""
original_lines: List[str] = []
try:
original_lines = osutils.ReadText(filepath).splitlines()
except FileNotFoundError:
logging.info("Creating new file %s", filepath)
# Make sure original_lines ends in a blank line. That way, if
# UpdateKeyInContents will do nothing but append a blank line, we won't
# report that a change was made.
if original_lines and original_lines[-1]:
original_lines.append("")
# Copy `original_lines`. We'll modify `lines`, and later compare it against
# the original.
lines = list(original_lines)
for key, value in keys_values.items():
lines = UpdateKeyInContents(lines, key, value)
if changed := lines != original_lines:
osutils.WriteFile(filepath, "\n".join(lines))
return changed