# coding=utf-8
# Copyright 2022 The Fiddle-Config Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Functions to output representations of `fdl.Buildable`s."""
import copy
import dataclasses
import inspect
import types
from typing import Any, Dict, Iterator, List, Optional, Type
from fiddle._src import config
from fiddle._src import daglish
from fiddle._src import history
from fiddle._src import tagging
from fiddle._src.codegen import formatting_utilities
@dataclasses.dataclass(frozen=True)
class _UnsetValue:
"""A wrapper class indicating an unset value."""
__slots__ = ('parameter',)
parameter: inspect.Parameter
def __repr__(self) -> str:
if self.parameter.default is self.parameter.empty:
return '<[unset]>'
else:
default_value = _format_value(
self.parameter.default, raw_value_repr=False)
return f'<[unset; default: {default_value}]>'
def _has_nested_builder(value: Any, state=None) -> bool:
state = state or daglish.MemoizedTraversal.begin(_has_nested_builder, value)
return isinstance(value, config.Buildable) or (
state.is_traversable(value) and any(state.yield_map_child_values(value))
)
def _path_str(path: daglish.Path) -> str:
"""Formats path in a way customized to this file.
In the future, we may wish to consider a format that is readable for printing
more arbitrary collections.
Args:
path: A path, which typically starts with an attribute.
Returns:
String representation of the path.
"""
path_str = daglish.path_str(path)
return (path_str[1:]
if path and isinstance(path[0], daglish.Attr) else path_str)
def _format_value(value: Any, *, raw_value_repr: bool) -> str:
if raw_value_repr:
return repr(value)
else:
if isinstance(value, str):
return repr(value)
else:
return formatting_utilities.pretty_print(value)
@dataclasses.dataclass(frozen=True)
class _TaggedValueWrapper:
"""Customizes representation for TaggedValues in flattened output."""
__slots__ = ('wrapped',)
wrapped: tagging.TaggedValueCls
def __repr__(self):
if self.wrapped.value is tagging.NO_VALUE:
value_repr = '<[unset]>'
else:
value_repr = repr(self.wrapped.value)
tag_str = ' '.join(sorted(str(t) for t in self.wrapped.tags))
return f'{value_repr} {tag_str}'
@dataclasses.dataclass(frozen=True)
class _LeafSetting:
"""Represents a leaf configuration setting."""
path: daglish.Path
annotation: Optional[Type[Any]]
value: Any
def _get_annotation(cfg: config.Buildable,
path: daglish.Path) -> Optional[Type[Any]]:
"""Gets the type annotation associated with a Daglish path."""
if not path or not isinstance(path[-1], daglish.Attr):
return None
value = cfg
for path_element in path[:-1]:
value = path_element.follow(value)
if isinstance(value, config.Buildable):
try:
param = value.__signature_info__.parameters[path[-1].name]
except KeyError:
# Try to use the kwarg annotation
for param in value.__signature_info__.parameters.values():
if param.kind == param.VAR_KEYWORD:
return None if param.annotation is param.empty else param.annotation
return None # probably a kwarg
return None if param.annotation is param.empty else param.annotation
def _get_tags(cfg, path):
"""Returns the tags for a given Daglish path, or None if there are none."""
if path:
child = path[-1]
if isinstance(child, daglish.Attr):
parent = daglish.follow_path(cfg, path[:-1])
if isinstance(parent, config.Buildable):
return parent.__argument_tags__.get(child.name)
return None
def _rearrange_buildable_args(
value: config.Buildable, insert_unset_sentinels: bool = True
) -> config.Buildable:
"""Returns a copy of a Buildable with normalized arguments.
This normalizes arguments by re-creating the __arguments__ dictionary in the
order of the configured function or class' signature. It also inserts "unset"
sentinels for values in the signature that don't have a value set.
Args:
value: Buildable to copy and normalize.
insert_unset_sentinels: If true, insert unset sentinels to arguments as the
default values.
Returns:
Copy of `value` with arguments normalized.
"""
# TODO(b/243576914): Consider pulling part of this function into a shared
# module, or achieving the same effect by modifying traversal order.
value = copy.copy(value)
old_arguments = dict(value.__arguments__)
new_arguments = {}
for param_name, param in value.__signature_info__.parameters.items():
if param.kind in {param.VAR_KEYWORD, param.VAR_POSITIONAL}:
continue
elif param_name in old_arguments:
new_arguments[param_name] = old_arguments.pop(param_name)
elif insert_unset_sentinels:
new_arguments[param_name] = _UnsetValue(param)
new_arguments.update(old_arguments) # Add in kwargs, in current order.
object.__setattr__(value, '__arguments__', new_arguments)
return value
[docs]
def as_str_flattened(cfg: config.Buildable,
*,
include_types: bool = True,
raw_value_repr: bool = False) -> str:
"""Returns a string of cfg's paths and values, one pair per line.
Some automated tools (e.g. grep, sort, diff, ...) handle data line-by-line.
This output format contains the full path to a value and a string
representation of said value on a single line.
Args:
cfg: A buildable to generate a string representation for.
include_types: If true, include type annotations (where available) in the
string representation of each leaf value.
raw_value_repr: If true, use `repr` on values, otherwise, a custom
pretty-printed format is used.
Returns: a string representation of `cfg`.
"""
def generate(value, state=None) -> Iterator[_LeafSetting]:
state = state or daglish.BasicTraversal.begin(generate, value)
tags = _get_tags(cfg, state.current_path)
if tags:
value = tagging.TaggedValue(tags=tags, default=value)
# Rearrange parameters in signature order, and add "unset" sentinels.
if isinstance(value, config.Buildable):
value = _rearrange_buildable_args(value)
if isinstance(value, tagging.TaggedValueCls):
value = _TaggedValueWrapper(value)
annotation = _get_annotation(cfg, state.current_path)
yield _LeafSetting(state.current_path, annotation, value)
elif not _has_nested_builder(value):
annotation = _get_annotation(cfg, state.current_path)
yield _LeafSetting(state.current_path, annotation, value)
else:
# value must be a Buildable or a traversable containing a Buidable.
assert state.is_traversable(value)
for sub_result in state.yield_map_child_values(value):
yield from sub_result
# Used in format_line below. The use of getattr and the dummy type default
# is to maintain Python 3.8 compatibility.
dummy_type = type('', (), {})
generic_alias = getattr(types, 'GenericAlias', dummy_type)
def format_line(line: _LeafSetting):
type_annotation = ''
if include_types and line.annotation is not None:
# If the annotation is a type, use the __qualname__, unless the annotation
# is a parameterized generic (e.g. list[int]), in which case the
# __qualname__ would lose information about the parameters.
is_parameterized_generic = isinstance(line.annotation, generic_alias)
if isinstance(line.annotation, type) and not is_parameterized_generic:
type_annotation = f': {line.annotation.__qualname__}'
else:
type_annotation = f': {line.annotation}'
value = _format_value(line.value, raw_value_repr=raw_value_repr)
return f'{_path_str(line.path)}{type_annotation} = {value}'
return '\n'.join(map(format_line, generate(cfg)))
[docs]
def history_per_leaf_parameter(cfg: Any,
*,
raw_value_repr: bool = False) -> str:
"""Returns a string representing the history of cfg's leaf params.
Because Buildable's are designed to be mutated, tracking down when and where
a particular parameter's value is changed can be difficult. This function
returns a string representation of each parameter and (in
reverse-chronological order) its current and past values.
This representation elides the "DAG"-construction aspects of constructing the
`cfg`, and instead only prints the history of the "outer-most" parameters
(ones that don't contain a reference to another Buildable).
Args:
cfg: A buildable, or collection containing buildables, to generate a history
for. For non-Buildable collections, either (a) the entire collection will
be printed as a history element or (b) only nested Buildable elements will
be printed, since we can't store assignment history for normal Python
collections like lists/dicts.
raw_value_repr: If true, use `repr` when string-ifying values, otherwise use
a customized pretty-printing routine.
Returns:
A string representation of `cfg`'s history, organized by param name.
"""
return '\n'.join(
_make_per_leaf_histories_recursive(cfg, raw_value_repr=raw_value_repr))
def _make_per_leaf_histories_recursive(
cfg: Any,
raw_value_repr: bool,
) -> Iterator[str]:
"""Recursively traverses `cfg` and generates per-param history summaries."""
def traverse(value, state=None) -> Iterator[str]:
state = state or daglish.BasicTraversal.begin(traverse, value)
if isinstance(value, config.Buildable):
for name, param_history in value.__argument_history__.items():
sub_value = getattr(value, name)
if _has_nested_builder(sub_value):
yield from state.call(sub_value, daglish.Attr(name))
else:
path = (*state.current_path, daglish.Attr(name))
yield _make_per_leaf_history_text(path, param_history, raw_value_repr)
# Add in unset fields.
unset_fields = sorted(
set(value.__signature_info__.parameters.keys())
- set(value.__argument_history__.keys())
)
for name in unset_fields:
path = (*state.current_path, daglish.Attr(name))
yield f'{_path_str(path)} = <[unset]>'
elif state.is_traversable(value):
for sub_result in state.yield_map_child_values(value):
yield from sub_result
else:
yield f'{_path_str(state.current_path)} = {value}'
return traverse(cfg)
def _make_per_leaf_history_text(path: daglish.Path,
param_history: List[history.HistoryEntry],
raw_value_repr: bool) -> str:
"""Returns a string representing a parameter's history.
Args:
path: The path to the parameter.
param_history: The parameter's history.
raw_value_repr: If True, use `repr` unmodified, otherwise, use a custom
pretty-printing routine.
Returns:
A string representation of the parameter's history that can be displayed to
the user.
"""
assert param_history, 'param_history should never be empty.'
def make_previous_text(entry: history.HistoryEntry) -> str:
value = _format_value(entry.new_value, raw_value_repr=raw_value_repr)
return f' - previously: {value} @ {entry.location}'
value_history = [
entry
for entry in param_history
if entry.kind == history.ChangeKind.NEW_VALUE
or entry.kind == history.ChangeKind.UPDATE_TAGS
]
if len(value_history) > 1:
value_updates = [
make_previous_text(entry) for entry in reversed(value_history[:-1])
]
past = '\n'.join(value_updates)
past = '\n' + past # prefix with a newline.
else:
past = ''
current_value = _format_value(
value_history[-1].new_value, raw_value_repr=raw_value_repr)
current = f'{current_value} @ {value_history[-1].location}'
return f'{_path_str(path)} = {current}{past}'
def as_dict_flattened(
cfg: config.Buildable, output_fn_or_cls_name: bool = False
) -> Dict[str, Any]:
"""Returns a flattened dict of cfg's paths (dot syntax) and values.
Default values and tags won't be included in the flattened dict.
Args:
cfg: A buildable to generate a flattened dict representation for.
output_fn_or_cls_name: If true, output the function or class name for
buildables.
Returns: A flattened Dict representation of `cfg`.
"""
def dict_generate(value, state=None) -> Iterator[_LeafSetting]:
state = state or daglish.BasicTraversal.begin(dict_generate, value)
if isinstance(value, config.Buildable):
# Rearrange parameters in signature order.
value = _rearrange_buildable_args(value, insert_unset_sentinels=False)
# Add the function or class name if it exists.
if (
output_fn_or_cls_name
and hasattr(value, '__fn_or_cls__')
and hasattr(value.__fn_or_cls__, '__name__')
):
current_path_with_fn_or_cls_name = state.current_path + (
daglish.Attr('__fn_or_cls__'),
daglish.Attr('__name__'),
)
yield _LeafSetting(
current_path_with_fn_or_cls_name, None, value.__fn_or_cls__.__name__
)
if not _has_nested_builder(value):
yield _LeafSetting(state.current_path, None, value)
else:
# value must be a Buildable or a traversable containing a Buildable.
assert state.is_traversable(value)
for sub_result in state.yield_map_child_values(value):
yield from sub_result
args_dict = {}
for leaf in dict_generate(cfg):
args_dict[_path_str(leaf.path)] = leaf.value
return args_dict