239 lines
8.0 KiB
Python
239 lines
8.0 KiB
Python
|
"""
|
||
|
This module contains a collection of utility function for dealing with property
|
||
|
groupings.
|
||
|
|
||
|
Terminology:
|
||
|
|
||
|
For the purpose of grouping and ungrouping, tuples/lists and dictionaries are considered
|
||
|
"composite values" and all other values are considered "scalar values".
|
||
|
|
||
|
A "grouping value" is either composite or scalar.
|
||
|
|
||
|
A "schema" is a grouping value that can be used to encode an expected grouping
|
||
|
structure
|
||
|
|
||
|
"""
|
||
|
from dash.exceptions import InvalidCallbackReturnValue
|
||
|
from ._utils import AttributeDict, stringify_id
|
||
|
|
||
|
|
||
|
def flatten_grouping(grouping, schema=None):
|
||
|
"""
|
||
|
Convert a grouping value to a list of scalar values
|
||
|
|
||
|
:param grouping: grouping value to flatten
|
||
|
:param schema: If provided, a grouping value representing the expected structure of
|
||
|
the input grouping value. If not provided, the grouping value is its own schema.
|
||
|
A schema is required in order to be able treat tuples and dicts in the input
|
||
|
grouping as scalar values.
|
||
|
|
||
|
:return: list of the scalar values in the input grouping
|
||
|
"""
|
||
|
if schema is None:
|
||
|
schema = grouping
|
||
|
else:
|
||
|
validate_grouping(grouping, schema)
|
||
|
|
||
|
if isinstance(schema, (tuple, list)):
|
||
|
return [
|
||
|
g
|
||
|
for group_el, schema_el in zip(grouping, schema)
|
||
|
for g in flatten_grouping(group_el, schema_el)
|
||
|
]
|
||
|
|
||
|
if isinstance(schema, dict):
|
||
|
return [g for k in schema for g in flatten_grouping(grouping[k], schema[k])]
|
||
|
|
||
|
return [grouping]
|
||
|
|
||
|
|
||
|
def grouping_len(grouping):
|
||
|
"""
|
||
|
Get the length of a grouping. The length equal to the number of scalar values
|
||
|
contained in the grouping, which is equivalent to the length of the list that would
|
||
|
result from calling flatten_grouping on the grouping value.
|
||
|
|
||
|
:param grouping: The grouping value to calculate the length of
|
||
|
:return: non-negative integer
|
||
|
"""
|
||
|
if isinstance(grouping, (tuple, list)):
|
||
|
return sum([grouping_len(group_el) for group_el in grouping])
|
||
|
|
||
|
if isinstance(grouping, dict):
|
||
|
return sum([grouping_len(group_el) for group_el in grouping.values()])
|
||
|
|
||
|
return 1
|
||
|
|
||
|
|
||
|
def make_grouping_by_index(schema, flat_values):
|
||
|
"""
|
||
|
Make a grouping like the provided grouping schema, with scalar values drawn from a
|
||
|
flat list by index.
|
||
|
|
||
|
Note: Scalar values in schema are not used
|
||
|
|
||
|
:param schema: Grouping value encoding the structure of the grouping to return
|
||
|
:param flat_values: List of values with length matching the grouping_len of schema.
|
||
|
Elements of flat_values will become the scalar values in the resulting grouping
|
||
|
"""
|
||
|
|
||
|
def _perform_make_grouping_like(value, next_values):
|
||
|
if isinstance(value, (tuple, list)):
|
||
|
return list(
|
||
|
_perform_make_grouping_like(el, next_values)
|
||
|
for i, el in enumerate(value)
|
||
|
)
|
||
|
|
||
|
if isinstance(value, dict):
|
||
|
return {
|
||
|
k: _perform_make_grouping_like(v, next_values)
|
||
|
for i, (k, v) in enumerate(value.items())
|
||
|
}
|
||
|
|
||
|
return next_values.pop(0)
|
||
|
|
||
|
if not isinstance(flat_values, list):
|
||
|
raise ValueError(
|
||
|
"The flat_values argument must be a list. "
|
||
|
f"Received value of type {type(flat_values)}"
|
||
|
)
|
||
|
|
||
|
expected_length = len(flatten_grouping(schema))
|
||
|
if len(flat_values) != expected_length:
|
||
|
raise ValueError(
|
||
|
f"The specified grouping pattern requires {expected_length} "
|
||
|
f"elements but received {len(flat_values)}\n"
|
||
|
f" Grouping pattern: {repr(schema)}\n"
|
||
|
f" Values: {flat_values}"
|
||
|
)
|
||
|
|
||
|
return _perform_make_grouping_like(schema, list(flat_values))
|
||
|
|
||
|
|
||
|
def map_grouping(fn, grouping):
|
||
|
"""
|
||
|
Map a function over all of the scalar values of a grouping, maintaining the
|
||
|
grouping structure
|
||
|
|
||
|
:param fn: Single-argument function that accepts and returns scalar grouping values
|
||
|
:param grouping: The grouping to map the function over
|
||
|
:return: A new grouping with the same structure as input grouping with scalar
|
||
|
values updated by the input function.
|
||
|
"""
|
||
|
if isinstance(grouping, (tuple, list)):
|
||
|
return [map_grouping(fn, g) for g in grouping]
|
||
|
|
||
|
if isinstance(grouping, dict):
|
||
|
return AttributeDict({k: map_grouping(fn, g) for k, g in grouping.items()})
|
||
|
|
||
|
return fn(grouping)
|
||
|
|
||
|
|
||
|
def make_grouping_by_key(schema, source, default=None):
|
||
|
"""
|
||
|
Create a grouping from a schema by using the schema's scalar values to look up
|
||
|
items in the provided source object.
|
||
|
|
||
|
:param schema: A grouping of potential keys in source
|
||
|
:param source: Dict-like object to use to look up scalar grouping value using
|
||
|
scalar grouping values as keys
|
||
|
:param default: Default scalar value to use if grouping scalar key is not present
|
||
|
in source
|
||
|
:return: grouping
|
||
|
"""
|
||
|
return map_grouping(lambda s: source.get(s, default), schema)
|
||
|
|
||
|
|
||
|
class SchemaTypeValidationError(InvalidCallbackReturnValue):
|
||
|
def __init__(self, value, full_schema, path, expected_type):
|
||
|
super().__init__(
|
||
|
msg=f"""
|
||
|
Schema: {full_schema}
|
||
|
Path: {repr(path)}
|
||
|
Expected type: {expected_type}
|
||
|
Received value of type {type(value)}:
|
||
|
{repr(value)}
|
||
|
"""
|
||
|
)
|
||
|
|
||
|
@classmethod
|
||
|
def check(cls, value, full_schema, path, expected_type):
|
||
|
if not isinstance(value, expected_type):
|
||
|
raise SchemaTypeValidationError(value, full_schema, path, expected_type)
|
||
|
|
||
|
|
||
|
class SchemaLengthValidationError(InvalidCallbackReturnValue):
|
||
|
def __init__(self, value, full_schema, path, expected_len):
|
||
|
super().__init__(
|
||
|
msg=f"""
|
||
|
Schema: {full_schema}
|
||
|
Path: {repr(path)}
|
||
|
Expected length: {expected_len}
|
||
|
Received value of length {len(value)}:
|
||
|
{repr(value)}
|
||
|
"""
|
||
|
)
|
||
|
|
||
|
@classmethod
|
||
|
def check(cls, value, full_schema, path, expected_len):
|
||
|
if len(value) != expected_len:
|
||
|
raise SchemaLengthValidationError(value, full_schema, path, expected_len)
|
||
|
|
||
|
|
||
|
class SchemaKeysValidationError(InvalidCallbackReturnValue):
|
||
|
def __init__(self, value, full_schema, path, expected_keys):
|
||
|
super().__init__(
|
||
|
msg=f"""
|
||
|
Schema: {full_schema}
|
||
|
Path: {repr(path)}
|
||
|
Expected keys: {expected_keys}
|
||
|
Received value with keys {set(value.keys())}:
|
||
|
{repr(value)}
|
||
|
"""
|
||
|
)
|
||
|
|
||
|
@classmethod
|
||
|
def check(cls, value, full_schema, path, expected_keys):
|
||
|
if set(value.keys()) != set(expected_keys):
|
||
|
raise SchemaKeysValidationError(value, full_schema, path, expected_keys)
|
||
|
|
||
|
|
||
|
def validate_grouping(grouping, schema, full_schema=None, path=()):
|
||
|
"""
|
||
|
Validate that the provided grouping conforms to the provided schema.
|
||
|
If not, raise a SchemaValidationError
|
||
|
"""
|
||
|
if full_schema is None:
|
||
|
full_schema = schema
|
||
|
|
||
|
if isinstance(schema, (tuple, list)):
|
||
|
SchemaTypeValidationError.check(grouping, full_schema, path, (tuple, list))
|
||
|
SchemaLengthValidationError.check(grouping, full_schema, path, len(schema))
|
||
|
|
||
|
for i, (g, s) in enumerate(zip(grouping, schema)):
|
||
|
validate_grouping(g, s, full_schema=full_schema, path=path + (i,))
|
||
|
elif isinstance(schema, dict):
|
||
|
SchemaTypeValidationError.check(grouping, full_schema, path, dict)
|
||
|
SchemaKeysValidationError.check(grouping, full_schema, path, set(schema))
|
||
|
|
||
|
for k in schema:
|
||
|
validate_grouping(
|
||
|
grouping[k], schema[k], full_schema=full_schema, path=path + (k,)
|
||
|
)
|
||
|
else:
|
||
|
pass
|
||
|
|
||
|
|
||
|
def update_args_group(g, triggered):
|
||
|
if isinstance(g, dict):
|
||
|
str_id = stringify_id(g["id"])
|
||
|
prop_id = f"{str_id}.{g['property']}"
|
||
|
|
||
|
new_values = {
|
||
|
"value": g.get("value"),
|
||
|
"str_id": str_id,
|
||
|
"triggered": prop_id in triggered,
|
||
|
"id": AttributeDict(g["id"]) if isinstance(g["id"], dict) else g["id"],
|
||
|
}
|
||
|
g.update(new_values)
|