Source code for rez.utils.formatting

# SPDX-License-Identifier: Apache-2.0
# Copyright Contributors to the Rez Project


"""
Utilities related to formatting output or translating input.
"""
from __future__ import absolute_import

from string import Formatter
from rez.vendor.enum import Enum
from rez.vendor.version.requirement import Requirement
from rez.exceptions import PackageRequestError
from rez.vendor.six import six
from pprint import pformat
import os
import os.path
import re
import time


PACKAGE_NAME_REGSTR = r"[a-zA-Z_0-9](\.?[a-zA-Z0-9_]+)*"
PACKAGE_NAME_REGEX = re.compile(r"^%s\Z" % PACKAGE_NAME_REGSTR)

ENV_VAR_REGSTR = r'\$(\w+|\{[^}]*\})'
ENV_VAR_REGEX = re.compile(ENV_VAR_REGSTR)

FORMAT_VAR_REGSTR = "{(?P<var>.+?)}"
FORMAT_VAR_REGEX = re.compile(FORMAT_VAR_REGSTR)

# package names that are invalid because they may clash with reserved dir
# names in some package repos (eg filesystem)
#
invalid_package_names = (
    "__pycache__",
)


[docs]def is_valid_package_name(name, raise_error=False): """Test the validity of a package name string. Args: name (str): Name to test. raise_error (bool): If True, raise an exception on failure Returns: bool. """ is_valid = ( PACKAGE_NAME_REGEX.match(name) and name not in invalid_package_names ) if raise_error and not is_valid: raise PackageRequestError("Not a valid package name: %r" % name) return is_valid
[docs]class PackageRequest(Requirement): """A package request parser. Valid requests include: * Any standard request, eg 'foo-1.2.3', '!foo-1', etc * "Ephemeral" request, eg '.foo-1.2.3' Example: >>> pr = PackageRequest("foo-1.3+") >>> print(pr.name, pr.range) foo 1.3+ """ def __init__(self, s): super(PackageRequest, self).__init__(s) # detect ephemeral package if s.startswith('.'): self.ephemeral = True is_valid_package_name(self.name[1:], True) else: self.ephemeral = False is_valid_package_name(self.name, True)
[docs]class StringFormatType(Enum): """Behaviour of key expansion when using `ObjectStringFormatter`.""" error = 1 # raise exception on unknown key empty = 2 # expand to empty on unknown key unchanged = 3 # leave string unchanged on unknown key
[docs]class ObjectStringFormatter(Formatter): """String formatter for objects. This formatter will expand any reference to an object's attributes. """ error = StringFormatType.error empty = StringFormatType.empty unchanged = StringFormatType.unchanged def __init__(self, instance, pretty=False, expand=StringFormatType.error): """Create a formatter. Args: instance: The object to format with. pretty: If True, references to non-string attributes such as lists are converted to basic form, with characters such as brackets and parentheses removed. expand: `StringFormatType`. """ self.instance = instance self.pretty = pretty self.expand = expand
[docs] def convert_field(self, value, conversion): if self.pretty: if value is None: return '' elif isinstance(value, list): def _str(x): if six.PY2: if isinstance(x, unicode): return x else: return str(x) else: return str(x) return ' '.join(map(_str, value)) return Formatter.convert_field(self, value, conversion)
[docs] def get_field(self, field_name, args, kwargs): if self.expand == StringFormatType.error: return Formatter.get_field(self, field_name, args, kwargs) try: return Formatter.get_field(self, field_name, args, kwargs) except (AttributeError, KeyError, TypeError): reg = re.compile(r"[^\.\[]+") try: key = reg.match(field_name).group() except: key = field_name if self.expand == StringFormatType.empty: return ('', key) else: # StringFormatType.unchanged return ("{%s}" % field_name, key)
[docs] def get_value(self, key, args, kwds): if isinstance(key, str): if key: try: # Check explicitly passed arguments first return kwds[key] except KeyError: pass try: # we deliberately do not call hasattr() first - hasattr() # silently catches exceptions from properties. return getattr(self.instance, key) except AttributeError: pass return self.instance[key] else: raise ValueError("zero length field name in format") else: return Formatter.get_value(self, key, args, kwds)
[docs]class StringFormatMixin(object): """Turn any object into a string formatter. An object inheriting this mixin will have a `format` function added, that is able to format using attributes of the object. """ format_expand = StringFormatType.error format_pretty = True
[docs] def format(self, s, pretty=None, expand=None): """Format a string. Args: s (str): String to format, eg "hello {name}" pretty (bool): If True, references to non-string attributes such as lists are converted to basic form, with characters such as brackets and parenthesis removed. If None, defaults to the object's 'format_pretty' attribute. expand (`StringFormatType`): Expansion mode. If None, will default to the object's 'format_expand' attribute. Returns: The formatting string. """ if pretty is None: pretty = self.format_pretty if expand is None: expand = self.format_expand formatter = ObjectStringFormatter(self, pretty=pretty, expand=expand) return formatter.format(s)
[docs]def expand_abbreviations(txt, fields): """Expand abbreviations in a format string. If an abbreviation does not match a field, or matches multiple fields, it is left unchanged. Example: >>> fields = ("hey", "there", "dude") >>> expand_abbreviations("hello {d}", fields) 'hello dude' Args: txt (str): Format string. fields (list of str): Fields to expand to. Returns: Expanded string. """ def _expand(matchobj): s = matchobj.group("var") if s not in fields: matches = [x for x in fields if x.startswith(s)] if len(matches) == 1: s = matches[0] return "{%s}" % s return re.sub(FORMAT_VAR_REGEX, _expand, txt)
[docs]def expandvars(text, environ=None): """Expand shell variables of form $var and ${var}. Unknown variables are left unchanged. Args: text (str): String to expand. environ (dict): Environ dict to use for expansions, defaults to os.environ. Returns: The expanded string. """ if '$' not in text: return text i = 0 if environ is None: environ = os.environ while True: m = ENV_VAR_REGEX.search(text, i) if not m: break i, j = m.span(0) name = m.group(1) if name.startswith('{') and name.endswith('}'): name = name[1:-1] if name in environ: tail = text[j:] text = text[:i] + environ[name] i = len(text) text += tail else: i = j return text
[docs]def indent(txt): """Indent the given text by 4 spaces.""" lines = ((" " + x) for x in txt.split('\n')) return '\n'.join(lines)
[docs]def dict_to_attributes_code(dict_): """Given a nested dict, generate a python code equivalent. Example: >>> d = {'foo': 'bah', 'colors': {'red': 1, 'blue': 2}} >>> print(dict_to_attributes_code(d)) foo = 'bah' colors.red = 1 colors.blue = 2 Returns: str. """ lines = [] for key, value in dict_.items(): if isinstance(value, dict): txt = dict_to_attributes_code(value) lines_ = txt.split('\n') for line in lines_: if not line.startswith(' '): line = "%s.%s" % (key, line) lines.append(line) else: value_txt = pformat(value) if '\n' in value_txt: lines.append("%s = \\" % key) value_txt = indent(value_txt) lines.extend(value_txt.split('\n')) else: line = "%s = %s" % (key, value_txt) lines.append(line) return '\n'.join(lines)
[docs]def columnise(rows, padding=2): """Print rows of entries in aligned columns.""" strs = [] maxwidths = {} for row in rows: for i, e in enumerate(row): se = str(e) nse = len(se) w = maxwidths.get(i, -1) if nse > w: maxwidths[i] = nse for row in rows: s = '' for i, e in enumerate(row): se = str(e) if i < len(row) - 1: n = maxwidths[i] + padding - len(se) se += ' ' * n s += se strs.append(s) return strs
time_divs = ( (365 * 24 * 3600, "years", 10), (30 * 24 * 3600, "months", 12), (7 * 24 * 3600, "weeks", 5), (24 * 3600, "days", 7), (3600, "hours", 10), (60, "minutes", 10), (1, "seconds", 60))
[docs]def readable_time_duration(secs): """Convert number of seconds into human readable form, eg '3.2 hours'. """ return _readable_units(secs, time_divs, True)
memory_divs = ( (1024 * 1024 * 1024 * 1024, "Tb", 128), (1024 * 1024 * 1024, "Gb", 64), (1024 * 1024, "Mb", 32), (1024, "Kb", 16), (1, "bytes", 1024))
[docs]def readable_memory_size(bytes_): """Convert number of bytes into human readable form, eg '1.2 Kb'. """ return _readable_units(bytes_, memory_divs)
def _readable_units(value, divs, plural_aware=False): if value == 0: unit = divs[-1][1] return "0 %s" % unit neg = (value < 0) if neg: value = -value for quantity, unit, threshold in divs: if value >= quantity: f = value / float(quantity) rounding = 0 if f > threshold else 1 f = round(f, rounding) f = int(f * 10) / 10.0 if plural_aware and f == 1.0: unit = unit[:-1] txt = "%g %s" % (f, unit) break if neg: txt = '-' + txt return txt
[docs]def get_epoch_time_from_str(s): """Convert a string into epoch time. Examples of valid strings: 1418350671 # already epoch time -12s # 12 seconds ago -5.4m # 5.4 minutes ago """ try: return int(s) except: pass try: if s.startswith('-'): chars = {'d': 24 * 60 * 60, 'h': 60 * 60, 'm': 60, 's': 1} m = chars.get(s[-1]) if m: n = float(s[1:-1]) secs = int(n * m) now = int(time.time()) return max((now - secs), 0) except: pass raise ValueError("'%s' is an unrecognised time format." % s)
positional_suffix = ("th", "st", "nd", "rd", "th", "th", "th", "th", "th", "th")
[docs]def positional_number_string(n): """Print the position string equivalent of a positive integer. Examples: 0: zeroeth 1: first 2: second 14: 14th 21: 21st """ if n > 20: suffix = positional_suffix[n % 10] return "%d%s" % (n, suffix) elif n > 3: return "%dth" % n elif n == 3: return "third" elif n == 2: return "second" elif n == 1: return "first" return "zeroeth"
# regex used to expand user; set here to avoid recompile on every call EXPANDUSER_RE = re.compile( r'(\A|\s|[{pathseps}])~([{seps}]|[{pathseps}]|\s|\Z)'.format( seps=re.escape(''.join(set([os.sep + (getattr(os, 'altsep') or os.sep)]))), pathseps=re.escape(''.join(set([os.pathsep + ';']))) ) )
[docs]def expanduser(path): """Expand '~' to home directory in the given string. Note that this function deliberately differs from the builtin os.path.expanduser() on Linux systems, which expands strings such as '~sclaus' to that user's homedir. This is problematic in rez because the string '~packagename' may inadvertently convert to a homedir, if a package happens to match a username. """ if '~' not in path: return path if os.name == "nt": if 'HOME' in os.environ: userhome = os.environ['HOME'] elif 'USERPROFILE' in os.environ: userhome = os.environ['USERPROFILE'] elif 'HOMEPATH' in os.environ: drive = os.environ.get('HOMEDRIVE', '') userhome = os.path.join(drive, os.environ['HOMEPATH']) else: return path else: userhome = os.path.expanduser('~') def _expanduser(path): return EXPANDUSER_RE.sub( lambda m: m.groups()[0] + userhome + m.groups()[1], path) # only replace '~' if it's at start of string or is preceeded by pathsep or # ';' or whitespace; AND, is followed either by sep, pathsep, ';', ' ' or # end-of-string. # return os.path.normpath(_expanduser(path))
[docs]def as_block_string(txt): """Return a string formatted as a python block comment string, like the one you're currently reading. Special characters are escaped if necessary. """ import json lines = [] for line in txt.split('\n'): line_ = json.dumps(line, ensure_ascii=False) line_ = line_[1:-1].rstrip() # drop double quotes lines.append(line_) return '"""\n%s\n"""' % '\n'.join(lines)
_header_br = '#' * 80 _header_br_minor = '-' * 80
[docs]def header_comment(executor, txt): """Convenience for creating header-like comment in a rex executor. Args: executor (`RexExecutor`): Executor. txt (str): Comment text. """ executor.comment("") executor.comment("") executor.comment(_header_br) executor.comment(txt) executor.comment(_header_br)
[docs]def minor_header_comment(executor, txt): executor.comment("") executor.comment(txt) executor.comment(_header_br_minor)