blob: 08e3b77766789200cf32190fdb8d013f555b2ce0 [file] [log] [blame]
# Copyright lowRISC contributors.
# Licensed under the Apache License, Version 2.0, see LICENSE for details.
# SPDX-License-Identifier: Apache-2.0
r"""
Utility functions common across dvsim.
"""
import logging as log
import os
import re
import shlex
import shutil
import subprocess
import sys
import time
from collections import OrderedDict
from datetime import datetime
from pathlib import Path
import hjson
import mistletoe
from premailer import transform
# For verbose logging
VERBOSE = 15
# Timestamp format when creating directory backups.
TS_FORMAT = "%y.%m.%d_%H.%M.%S"
# Timestamp format when generating reports.
TS_FORMAT_LONG = "%A %B %d %Y %H:%M:%S UTC"
# Run a command and get the result. Exit with error if the command did not
# succeed. This is a simpler version of the run_cmd function below.
def run_cmd(cmd):
(status, output) = subprocess.getstatusoutput(cmd)
if status:
print(f'cmd {cmd} returned with status {status}', file=sys.stderr)
sys.exit(status)
return output
# Run a command with a specified timeout. If the command does not finish before
# the timeout, then it returns -1. Else it returns the command output. If the
# command fails, it throws an exception and returns the stderr.
def run_cmd_with_timeout(cmd, timeout=-1, exit_on_failure=1):
args = shlex.split(cmd)
p = subprocess.Popen(args,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
# If timeout is set, poll for the process to finish until timeout
result = ""
status = -1
if timeout == -1:
p.wait()
else:
start = time.time()
while time.time() - start < timeout:
if p.poll() is not None:
break
time.sleep(.01)
# Capture output and status if cmd exited, else kill it
if p.poll() is not None:
result = p.communicate()[0]
status = p.returncode
else:
log.error("cmd \"%s\" timed out!", cmd)
p.kill()
if status != 0:
log.error("cmd \"%s\" exited with status %d", cmd, status)
if exit_on_failure == 1:
sys.exit(status)
return (result, status)
# Parse hjson and return a dict
def parse_hjson(hjson_file):
hjson_cfg_dict = None
try:
log.debug("Parsing %s", hjson_file)
f = open(hjson_file, 'rU')
text = f.read()
hjson_cfg_dict = hjson.loads(text, use_decimal=True)
f.close()
except Exception as e:
log.fatal(
"Failed to parse \"%s\" possibly due to bad path or syntax error.\n%s",
hjson_file, e)
sys.exit(1)
return hjson_cfg_dict
def _stringify_wildcard_value(value):
'''Make sense of a wildcard value as a string (see subst_wildcards)
Strings are passed through unchanged. Integer or boolean values are printed
as numerical strings. Lists or other sequences have their items printed
separated by spaces.
'''
if type(value) is str:
return value
if type(value) in [bool, int]:
return str(int(value))
try:
return ' '.join(_stringify_wildcard_value(x) for x in value)
except TypeError:
raise ValueError('Wildcard had value {!r} which is not of a supported '
'type.'.format(value))
def _subst_wildcards(var, mdict, ignored, ignore_error, seen):
'''Worker function for subst_wildcards
seen is a list of wildcards that have been expanded on the way to this call
(used for spotting circular recursion).
Returns (expanded, seen_err) where expanded is the new value of the string
and seen_err is true if we stopped early because of an ignored error.
'''
wildcard_re = re.compile(r"{([A-Za-z0-9\_]+)}")
# Work from left to right, expanding each wildcard we find. idx is where we
# should start searching (so that we don't keep finding a wildcard that
# we've decided to ignore).
idx = 0
any_err = False
while True:
right_str = var[idx:]
match = wildcard_re.search(right_str)
# If no match, we're done.
if match is None:
return (var, any_err)
name = match.group(1)
# If the name should be ignored, skip over it.
if name in ignored:
idx += match.end()
continue
# If the name has been seen already, we've spotted circular recursion.
# That's not allowed!
if name in seen:
raise ValueError('String contains circular expansion of '
'wildcard {!r}.'.format(match.group(0)))
# Treat eval_cmd specially
if name == 'eval_cmd':
cmd = _subst_wildcards(right_str[match.end():], mdict, ignored,
ignore_error, seen)[0]
# Are there any wildcards left in cmd? If not, we can run the
# command and we're done.
cmd_matches = list(wildcard_re.finditer(cmd))
if not cmd_matches:
var = var[:match.start()] + run_cmd(cmd)
continue
# Otherwise, check that each of them is ignored, or that
# ignore_error is True.
bad_names = False
if not ignore_error:
for cmd_match in cmd_matches:
if cmd_match.group(1) not in ignored:
bad_names = True
if bad_names:
raise ValueError('Cannot run eval_cmd because the command '
'expands to {!r}, which still contains a '
'wildcard.'.format(cmd))
# We can't run the command (because it still has wildcards), but we
# don't want to report an error either because ignore_error is true
# or because each wildcard that's left is ignored. Return the
# partially evaluated version.
return (var[:idx] + right_str[:match.end()] + cmd, True)
# Otherwise, look up name in mdict.
value = mdict.get(name)
# If the value isn't set, check the environment
if value is None:
value = os.environ.get(name)
if value is None:
# Ignore missing values if ignore_error is True.
if ignore_error:
idx += match.end()
continue
raise ValueError('String to be expanded contains '
'unknown wildcard, {!r}.'.format(match.group(0)))
value = _stringify_wildcard_value(value)
# Do any recursive expansion of value, adding name to seen (to avoid
# circular recursion).
value, saw_err = _subst_wildcards(value, mdict, ignored, ignore_error,
seen + [name])
# Replace the original match with the result and go around again. If
# saw_err, increment idx past what we just inserted.
var = (var[:idx] + right_str[:match.start()] + value +
right_str[match.end():])
if saw_err:
any_err = True
idx += match.start() + len(value)
def subst_wildcards(var, mdict, ignored_wildcards=[], ignore_error=False):
'''Substitute any "wildcard" variables in the string var.
var is the string to be substituted. mdict is a dictionary mapping
variables to strings. ignored_wildcards is a list of wildcards that
shouldn't be substituted. ignore_error means to partially evaluate rather
than exit on an error.
A wildcard is written as a name (alphanumeric, allowing backslash and
underscores) surrounded by braces. For example,
subst_wildcards('foo {x} baz', {'x': 'bar'})
returns "foo bar baz". Dictionary values can be strings, booleans, integers
or lists. For example:
subst_wildcards('{a}, {b}, {c}, {d}',
{'a': 'a', 'b': True, 'c': 42, 'd': ['a', 10]})
returns 'a, 1, 42, a 10'.
If a wildcard is in ignored_wildcards, it is ignored. For example,
subst_wildcards('{a} {b}', {'b': 'bee'}, ignored_wildcards=['a'])
returns "{a} bee".
If a wildcard appears in var but is not in mdict, the environment is
checked for the variable. If the name still isn't found, the default
behaviour is to log an error and exit. If ignore_error is True, the
wildcard is ignored (as if it appeared in ignore_wildcards).
If {eval_cmd} appears in the string and 'eval_cmd' is not in
ignored_wildcards then the following text is recursively expanded. The
result of this expansion is treated as a command to run and the text is
replaced by the output of the command.
If a wildcard has been ignored (either because of ignored_wildcards or
ignore_error), the command to run in eval_cmd might contain a match for
wildcard_re. If ignore_error is True, the command is not run. So
subst_wildcards('{eval_cmd}{foo}', {}, ignore_error=True)
will return '{eval_cmd}{foo}' unchanged. If ignore_error is False, the
function logs an error and exits.
Recursion is possible in subst_wildcards. For example,
subst_wildcards('{a}', {'a': '{b}', 'b': 'c'})
returns 'c'. Circular recursion is detected, however. So
subst_wildcards('{a}', {'a': '{b}', 'b': '{a}'})
will log an error and exit. This error is raised whether or not
ignore_error is set.
Since subst_wildcards works from left to right, it's possible to compute
wildcard names with code like this:
subst_wildcards('{a}b}', {'a': 'a {', 'b': 'bee'})
which returns 'a bee'. This is pretty hard to read though, so is probably
not a good idea to use.
'''
try:
return _subst_wildcards(var, mdict, ignored_wildcards, ignore_error,
[])[0]
except ValueError as err:
log.error(str(err))
sys.exit(1)
def find_and_substitute_wildcards(sub_dict,
full_dict,
ignored_wildcards=[],
ignore_error=False):
'''
Recursively find key values containing wildcards in sub_dict in full_dict
and return resolved sub_dict.
'''
for key in sub_dict.keys():
if type(sub_dict[key]) in [dict, OrderedDict]:
# Recursively call this funciton in sub-dicts
sub_dict[key] = find_and_substitute_wildcards(
sub_dict[key], full_dict, ignored_wildcards, ignore_error)
elif type(sub_dict[key]) is list:
sub_dict_key_values = list(sub_dict[key])
# Loop through the list of key's values and substitute each var
# in case it contains a wildcard
for i in range(len(sub_dict_key_values)):
if type(sub_dict_key_values[i]) in [dict, OrderedDict]:
# Recursively call this funciton in sub-dicts
sub_dict_key_values[i] = \
find_and_substitute_wildcards(sub_dict_key_values[i],
full_dict, ignored_wildcards, ignore_error)
elif type(sub_dict_key_values[i]) is str:
sub_dict_key_values[i] = subst_wildcards(
sub_dict_key_values[i], full_dict, ignored_wildcards,
ignore_error)
# Set the substituted key values back
sub_dict[key] = sub_dict_key_values
elif type(sub_dict[key]) is str:
sub_dict[key] = subst_wildcards(sub_dict[key], full_dict,
ignored_wildcards, ignore_error)
return sub_dict
def md_results_to_html(title, css_file, md_text):
'''Convert results in md format to html. Add a little bit of styling.
'''
html_text = "<!DOCTYPE html>\n"
html_text += "<html lang=\"en\">\n"
html_text += "<head>\n"
if title != "":
html_text += " <title>{}</title>\n".format(title)
html_text += "</head>\n"
html_text += "<body>\n"
html_text += "<div class=\"results\">\n"
html_text += mistletoe.markdown(md_text)
html_text += "</div>\n"
html_text += "</body>\n"
html_text += "</html>\n"
html_text = htmc_color_pc_cells(html_text)
# this function converts css style to inline html style
html_text = transform(html_text,
external_styles=css_file,
cssutils_logging_level=log.ERROR)
return html_text
def htmc_color_pc_cells(text):
'''This function finds cells in a html table that contain numerical values
(and a few known strings) followed by a single space and an identifier.
Depending on the identifier, it shades the cell in a specific way. A set of
12 color palettes for setting those shades are encoded in ./style.css.
These are 'cna' (grey), 'c0' (red), 'c1' ... 'c10' (green). The shade 'cna'
is used for items that are maked as 'not applicable'. The shades 'c1' to
'c9' form a gradient from red to lime-green to indicate 'levels of
completeness'. 'cna' is used for greying out a box for 'not applicable'
items, 'c0' is for items that are considered risky (or not yet started) and
'c10' for items that have completed successfully, or that are
'in good standing'.
These are the supported identifiers: %, %u, G, B, E, W, EN, WN.
The shading behavior for these is described below.
%: Coloured percentage, where the number in front of the '%' sign is mapped
to a color for the cell ranging from red ('c0') to green ('c10').
%u: Uncoloured percentage, where no markup is applied and '%u' is replaced
with '%' in the output.
G: This stands for 'Good' and results in a green cell.
B: This stands for 'Bad' and results in a red cell.
E: This stands for 'Errors' and the cell is colored with red if the number
in front of the indicator is larger than 0. Otherwise the cell is
colored with green.
W: This stands for 'Warnings' and the cell is colored with yellow ('c6')
if the number in front of the indicator is larger than 0. Otherwise
the cell is colored with green.
EN: This stands for 'Errors Negative', which behaves the same as 'E' except
that the cell is colored red if the number in front of the indicator is
negative.
WN: This stands for 'Warnings Negative', which behaves the same as 'W'
except that the cell is colored yellow if the number in front of the
indicator is negative.
N/A items can have any of the following indicators and need not be
preceeded with a numerical value:
'--', 'NA', 'N.A.', 'N.A', 'N/A', 'na', 'n.a.', 'n.a', 'n/a'
'''
# Replace <td> with <td class="color-class"> based on the fp
# value. "color-classes" are listed in ./style.css as follows: "cna"
# for NA value, "c0" to "c10" for fp value falling between 0.00-9.99,
# 10.00-19.99 ... 90.00-99.99, 100.0 respetively.
def color_cell(cell, cclass, indicator="%"):
op = cell.replace("<td", "<td class=\"" + cclass + "\"")
# Remove the indicator.
op = re.sub(r"\s*" + indicator + r"\s*", "", op)
return op
# List of 'not applicable' identifiers.
na_list = ['--', 'NA', 'N.A.', 'N.A', 'N/A', 'na', 'n.a.', 'n.a', 'n/a']
na_list_patterns = '|'.join(na_list)
# List of floating point patterns: '0', '0.0' & '.0'
fp_patterns = r"[\+\-]?\d+\.?\d*"
patterns = fp_patterns + '|' + na_list_patterns
indicators = "%|%u|G|B|E|W|I|EN|WN"
match = re.findall(
r"(<td.*>\s*(" + patterns + r")\s+(" + indicators + r")\s*</td>)",
text)
if len(match) > 0:
subst_list = {}
fp_nums = []
for item in match:
# item is a tuple - first is the full string indicating the table
# cell which we want to replace, second is the floating point value.
cell = item[0]
fp_num = item[1]
indicator = item[2]
# Skip if fp_num is already processed.
if (fp_num, indicator) in fp_nums:
continue
fp_nums.append((fp_num, indicator))
if fp_num in na_list:
subst = color_cell(cell, "cna", indicator)
else:
# Item is a fp num.
try:
fp = float(fp_num)
except ValueError:
log.error(
"Percentage item \"%s\" in cell \"%s\" is not an "
"integer or a floating point number", fp_num, cell)
continue
# Percentage, colored.
if indicator == "%":
if fp >= 0.0 and fp < 10.0:
subst = color_cell(cell, "c0")
elif fp >= 10.0 and fp < 20.0:
subst = color_cell(cell, "c1")
elif fp >= 20.0 and fp < 30.0:
subst = color_cell(cell, "c2")
elif fp >= 30.0 and fp < 40.0:
subst = color_cell(cell, "c3")
elif fp >= 40.0 and fp < 50.0:
subst = color_cell(cell, "c4")
elif fp >= 50.0 and fp < 60.0:
subst = color_cell(cell, "c5")
elif fp >= 60.0 and fp < 70.0:
subst = color_cell(cell, "c6")
elif fp >= 70.0 and fp < 80.0:
subst = color_cell(cell, "c7")
elif fp >= 80.0 and fp < 90.0:
subst = color_cell(cell, "c8")
elif fp >= 90.0 and fp < 100.0:
subst = color_cell(cell, "c9")
elif fp >= 100.0:
subst = color_cell(cell, "c10")
# Percentage, uncolored.
elif indicator == "%u":
subst = cell.replace("%u", "%")
# Good: green
elif indicator == "G":
subst = color_cell(cell, "c10", indicator)
# Bad: red
elif indicator == "B":
subst = color_cell(cell, "c0", indicator)
# Info, uncolored.
elif indicator == "I":
subst = cell.replace("I", "")
# Bad if positive: red for errors, yellow for warnings,
# otherwise green.
elif indicator in ["E", "W"]:
if fp <= 0:
subst = color_cell(cell, "c10", indicator)
elif indicator == "W":
subst = color_cell(cell, "c6", indicator)
elif indicator == "E":
subst = color_cell(cell, "c0", indicator)
# Bad if negative: red for errors, yellow for warnings,
# otherwise green.
elif indicator in ["EN", "WN"]:
if fp >= 0:
subst = color_cell(cell, "c10", indicator)
elif indicator == "WN":
subst = color_cell(cell, "c6", indicator)
elif indicator == "EN":
subst = color_cell(cell, "c0", indicator)
subst_list[cell] = subst
for item in subst_list:
text = text.replace(item, subst_list[item])
return text
def print_msg_list(msg_list_title, msg_list, max_msg_count=-1):
'''This function prints a list of messages to Markdown.
The argument msg_list_title contains a string for the list title, whereas
the msg_list argument contains the actual list of message strings.
max_msg_count limits the number of messages to be printed (set to negative
number to print all messages).
Example:
print_msg_list("### Tool Warnings", ["Message A", "Message B"], 10)
'''
md_results = ""
if msg_list:
md_results += msg_list_title + "\n"
md_results += "```\n"
for k, msg in enumerate(msg_list):
if k <= max_msg_count or max_msg_count < 0:
md_results += msg + "\n\n"
else:
md_results += "Note: %d more messages have been suppressed " % (
len(msg_list) - max_msg_count)
md_results += "(max_msg_count = %d) \n\n" % (max_msg_count)
break
md_results += "```\n"
return md_results
def rm_path(path, ignore_error=False):
'''Removes the specified path if it exists.
'path' is a Path-like object. If it does not exist, the function simply
returns. If 'ignore_error' is set, then exception caught by the remove
operation is raised, else it is ignored.
'''
exc = None
try:
os.remove(path)
except FileNotFoundError:
pass
except IsADirectoryError:
try:
shutil.rmtree(path)
except OSError as e:
exc = e
except OSError as e:
exc = e
if exc:
log.error("Failed to remove {}:\n{}.".format(path, exc))
if not ignore_error:
raise exc
def mk_path(path):
'''Create the specified path if it does not exist.
'path' is a Path-like object. If it does exist, the function simply
returns. If it does not exist, the function creates the path and its
parent dictories if necessary.
'''
try:
Path(path).mkdir(parents=True, exist_ok=True)
except PermissionError as e:
log.fatal("Failed to create directory {}:\n{}.".format(path, e))
sys.exit(1)
def mk_symlink(path, link):
'''Create a symlink from the given path.
'link' is a Path-like object. If it does exist, remove the existing link and
create a new symlink with this given path.
If it does not exist, the function creates the symlink with the given path.
'''
while True:
try:
os.symlink(path, link)
break
except FileExistsError:
rm_path(link)
def clean_odirs(odir, max_odirs, ts_format=TS_FORMAT):
"""Clean previous output directories.
When running jobs, we may want to maintain a limited history of
previous invocations. This method finds and deletes the output
directories at the base of input arg 'odir' with the oldest timestamps,
if that limit is reached. It returns a list of directories that
remain after deletion.
"""
odir = Path(odir)
if os.path.exists(odir):
# If output directory exists, back it up.
ts = datetime.fromtimestamp(os.stat(odir).st_ctime).strftime(ts_format)
# Prior to Python 3.9, shutil may run into an error when passing in
# Path objects (see https://bugs.python.org/issue32689). While this
# has been fixed in Python 3.9, string casts are added so that this
# also works with older versions.
shutil.move(str(odir), str(odir.with_name(ts)))
# Get list of past output directories sorted by creation time.
pdir = odir.resolve().parent
if not pdir.exists():
return []
dirs = sorted([old for old in pdir.iterdir() if (old.is_dir() and
old != 'summary')],
key=os.path.getctime,
reverse=True)
for old in dirs[max(0, max_odirs - 1):]:
shutil.rmtree(old, ignore_errors=True)
return [] if max_odirs == 0 else dirs[:max_odirs - 1]
def check_bool(x):
"""check_bool checks if input 'x' either a bool or
one of the following strings: ["true", "false"]
It returns value as Bool type.
"""
if isinstance(x, bool):
return x
if not x.lower() in ["true", "false"]:
raise RuntimeError("{} is not a boolean value.".format(x))
else:
return (x.lower() == "true")
def check_int(x):
"""check_int checks if input 'x' is decimal integer.
It returns value as an int type.
"""
if isinstance(x, int):
return x
if not x.isdecimal():
raise RuntimeError("{} is not a decimal number".format(x))
return int(x)