1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372
|
"""Style elements and schema validation.
"""
from collections.abc import Mapping
import functools
import jsonschema
import hashlib
schema = {
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
# Plain style elements
"align": {
"description": "Alignment of text",
"type": "string",
"enum": ["left", "right", "center"],
"default": "left",
"scope": "column"},
"bold": {
"description": "Whether text is bold",
"oneOf": [{"type": "boolean"},
{"$ref": "#/definitions/lookup"},
{"$ref": "#/definitions/re_lookup"},
{"$ref": "#/definitions/interval"}],
"default": False,
"scope": "field"},
"color": {
"description": "Foreground color of text",
"oneOf": [{"type": "string",
"enum": ["black", "red", "green", "yellow",
"blue", "magenta", "cyan", "white"]},
{"$ref": "#/definitions/lookup"},
{"$ref": "#/definitions/re_lookup"},
{"$ref": "#/definitions/interval"}],
"default": "black",
"scope": "field"},
"hide": {
"description": """Whether to hide column. A value of True
unconditionally hides the column. 'if_missing' hides the column
until the first non-missing value is encountered.""",
"oneOf": [{"type": "boolean"},
{"type": "string", "enum": ["if_missing"]}],
"default": False,
"scope": "column"},
"underline": {
"description": "Whether text is underlined",
"oneOf": [{"type": "boolean"},
{"$ref": "#/definitions/lookup"},
{"$ref": "#/definitions/re_lookup"},
{"$ref": "#/definitions/interval"}],
"default": False,
"scope": "field"},
"width_type": {
"description": "Type for numeric values in 'width'",
"oneOf": [{"type": "integer", "minimum": 1},
{"type": "number",
"exclusiveMinimum": 0,
"exclusiveMaximum": 1}]},
"width": {
"description": """Width of field. With the default value, 'auto',
the column width is automatically adjusted to fit the content and
may be truncated to ensure that the entire row fits within the
available output width. An integer value forces all fields in a
column to have a width of the specified value.
In addition, an object can be specified. Its 'min' and 'max' keys
specify the minimum and maximum widths allowed, whereas the 'width'
key specifies a fixed width. The values can be given as an integer
(representing the number of characters) or as a fraction, which
indicates the proportion of the total table width (typically the
width of your terminal).
The 'marker' key specifies the marker used for truncation ('...' by
default). Where the field is truncated can be configured with
'truncate': 'right' (default), 'left', or 'center'.
The object can also include a 'weight' key. Conceptually,
assigning widths to each column can be (roughly) viewed as each
column claiming _one_ character of available width at a time until
a column is at its maximum width or there is no available width
left. Setting a column's weight to an integer N makes it claim N
characters each iteration.""",
"oneOf": [{"$ref": "#/definitions/width_type"},
{"type": "string",
"enum": ["auto"]},
{"type": "object",
"properties": {
"max": {"$ref": "#/definitions/width_type"},
"min": {"$ref": "#/definitions/width_type"},
"width": {"$ref": "#/definitions/width_type"},
"weight": {"type": "integer", "minimum": 1},
"marker": {"type": ["string", "boolean"]},
"truncate": {"type": "string",
"enum": ["left",
"right",
"center"]}},
"additionalProperties": False}],
"default": "auto",
"scope": "column"},
# Other style elements
"aggregate": {
"description": """A function that produces a summary value. This
function will be called with all of the column's (unprocessed)
field values and should return a single value to be displayed.""",
"scope": "column"},
"delayed": {
"description": """Don't wait for this column's value.
The accessor will be wrapped in a function and called
asynchronously. This can be set to a string to mark columns as
part of a "group". All columns within a group will be accessed
within the same callable. True means to access the column's value
in its own callable (i.e. independently of other columns).""",
"type": ["boolean", "string"],
"scope": "field"},
"missing": {
"description": "Text to display for missing values",
"type": "string",
"default": "",
"scope": "column"
},
"re_flags": {
"description": """Flags passed to re.search when using re_lookup.
See the documentation of the re module for a description of
possible values. 'I' (ignore case) is the most likely value of
interest.""",
"type": "array",
"items": [{"type": "string",
"enum": ["A", "I", "L", "M", "S", "U", "X"]}],
"scope": "field"},
"transform": {
"description": """An arbitrary function.
This function will be called with the (unprocessed) field value as
the single argument and should return a transformed value. Note:
This function should not have side-effects because it may be called
multiple times.""",
"scope": "field"},
# Complete list of column style elements
"styles": {
"type": "object",
"properties": {"aggregate": {"$ref": "#/definitions/aggregate"},
"align": {"$ref": "#/definitions/align"},
"bold": {"$ref": "#/definitions/bold"},
"color": {"$ref": "#/definitions/color"},
"delayed": {"$ref": "#/definitions/delayed"},
"hide": {"$ref": "#/definitions/hide"},
"missing": {"$ref": "#/definitions/missing"},
"re_flags": {"$ref": "#/definitions/re_flags"},
"transform": {"$ref": "#/definitions/transform"},
"underline": {"$ref": "#/definitions/underline"},
"width": {"$ref": "#/definitions/width"}},
"additionalProperties": False},
# Mapping elements
"interval": {
"description": "Map a value within an interval to a style",
"type": "object",
"properties": {"interval":
{"type": "array",
"items": [
{"type": "array",
"items": [{"type": ["number", "null"]},
{"type": ["number", "null"]},
{"type": ["string", "boolean"]}],
"additionalItems": False}]}},
"additionalProperties": False},
"lookup": {
"description": "Map a value to a style",
"type": "object",
"properties": {"lookup": {"type": "object"}},
"additionalProperties": False},
"re_lookup": {
"description": """Apply a style to values that match a regular
expression. The regular expressions are matched with re.search and
tried in order, stopping after the first match. Flags for
re.search can be specified via the re_flags style attribute.""",
"type": "object",
"properties": {"re_lookup":
{"type": "array",
"items": [
{"type": "array",
"items": [{"type": "string"},
{"type": ["string", "boolean"]}],
"additionalItems": False}]}},
"additionalProperties": False},
},
"type": "object",
"properties": {
"aggregate_": {
"description": "Shared attributes for the summary rows",
"oneOf": [{"type": "object",
"properties":
{"color": {"$ref": "#/definitions/color"},
"bold": {"$ref": "#/definitions/bold"},
"underline": {"$ref": "#/definitions/underline"}}},
{"type": "null"}],
"default": {},
"scope": "table"},
"default_": {
"description": "Default style of columns",
"oneOf": [{"$ref": "#/definitions/styles"},
{"type": "null"}],
"default": {"align": "left",
"hide": False,
"width": "auto"},
"scope": "table"},
"header_": {
"description": "Attributes for the header row",
"oneOf": [{"type": "object",
"properties":
{"color": {"$ref": "#/definitions/color"},
"bold": {"$ref": "#/definitions/bold"},
"underline": {"$ref": "#/definitions/underline"}}},
{"type": "null"}],
"default": None,
"scope": "table"},
"separator_": {
"description": "Separator used between fields",
"type": "string",
"default": " ",
"scope": "table"},
"width_": {
"description": """Total width of table.
This is typically not set directly by the user. With the default
null value, the width is set to the stream's width for interactive
streams and as wide as needed to fit the content for
non-interactive streams.""",
"default": None,
"oneOf": [{"type": "integer"},
{"type": "null"}],
"scope": "table"}
},
# All other keys are column names.
"additionalProperties": {"$ref": "#/definitions/styles"}
}
def default(prop):
"""Return the default value schema property.
Parameters
----------
prop : str
A key for schema["properties"]
"""
return schema["properties"][prop]["default"]
def adopt(style, new_style):
if new_style is None:
return style
combined = {}
for key, value in style.items():
if isinstance(value, Mapping):
combined[key] = dict(value, **new_style.get(key, {}))
else:
combined[key] = new_style.get(key, value)
return combined
class StyleError(Exception):
"""Style is invalid or mispecified in some way.
"""
pass
class StyleValidationError(StyleError):
"""Exception raised if the style schema does not validate.
"""
def __init__(self, original_exception):
msg = ("Invalid style\n\n{}\n\n\n"
"See pyout.schema for style definition."
.format(original_exception))
super(StyleValidationError, self).__init__(msg)
class StrHasher():
"""A wrapper for objects that hashes based on the string representation.
Primarily its purpose to provide a customized @StrHasher.lru_cache
decorator which allows to cache based on the string representation of the
argument of unhashable objects.
"""
def __init__(self, obj):
self.value = obj
self.hash = hashlib.md5(str(obj).encode()).hexdigest()
def __hash__(self):
return hash(self.hash)
def __eq__(self, other):
return self.hash == other.hash
@staticmethod
def _to(func):
def cached_func(*args, **kwargs):
wrapper = StrHasher((args, kwargs))
return func(wrapper)
return cached_func
@staticmethod
def _from(func):
def cached_func(wrapper):
return func(*wrapper.value[0], **wrapper.value[1])
return cached_func
@classmethod
def lru_cache(cls, maxsize=128):
"""
Custom LRU cache decorator that allows for a custom hasher function.
"""
def decorator(func):
@functools.lru_cache(maxsize=maxsize)
@cls._from
def lru_cached_func(*args, **kwargs):
return func(*args, **kwargs)
# Interface cache operations
cached_func = functools.wraps(func)(
cls._to(
lru_cached_func
)
)
for op in dir(lru_cached_func):
if op.startswith('cache_'):
setattr(cached_func, op,
getattr(lru_cached_func, op))
return cached_func
return decorator
@StrHasher.lru_cache() # the same styles could be checked over again
def validate(style):
"""Check `style` against pyout.styling.schema.
Parameters
----------
style : dict
Style object to validate.
Raises
------
StyleValidationError if `style` is not valid.
"""
try:
jsonschema.validate(style, schema)
except jsonschema.ValidationError as exc:
new_exc = StyleValidationError(exc)
# Don't dump the original jsonschema exception because it is already
# included in the StyleValidationError's message.
new_exc.__cause__ = None
raise new_exc
def value_type(value):
"""Classify `value` of bold, color, and underline keys.
Parameters
----------
value : style value
Returns
-------
str, {"simple", "lookup", "re_lookup", "interval"}
"""
try:
keys = list(value.keys())
except AttributeError:
return "simple"
if keys in [["lookup"], ["re_lookup"], ["interval"]]:
return keys[0]
raise ValueError("Type of `value` could not be determined")
|