546 lines
23 KiB
Python
546 lines
23 KiB
Python
import ipywidgets as widgets
|
|
import run as run_helpers
|
|
from bunch import Bunch
|
|
import functools
|
|
import operator
|
|
import stringcase
|
|
import subprocess
|
|
import os
|
|
import toml
|
|
import json
|
|
import time
|
|
from jupyter_ui_poll import ui_events
|
|
from IPython.display import display
|
|
import analyze
|
|
import shutil
|
|
|
|
# TODO: move constants
|
|
TEMPLATE = 'templates/baseline'
|
|
|
|
# set to path of testground bin if not in PATH
|
|
TESTGROUND = 'testground'
|
|
|
|
|
|
class RunButton(object):
|
|
def __init__(self, config):
|
|
self.config = config
|
|
self.pressed = False
|
|
self.button = widgets.Button(description='Run Test', button_style='primary')
|
|
self.button.on_click(self._clicked)
|
|
|
|
def _clicked(self, evt):
|
|
self.pressed = True
|
|
self.button.description = 'Running'
|
|
self.button.button_style = 'info'
|
|
self.button.disabled = True
|
|
|
|
def wait(self):
|
|
display(self.button)
|
|
with ui_events() as poll:
|
|
while self.pressed is False:
|
|
poll(10) # React to UI events (upto 10 at a time)
|
|
time.sleep(0.1)
|
|
self._run()
|
|
|
|
def _run(self):
|
|
endpoint = self.config.widgets.testground.daemon_endpoint.value
|
|
workdir = self.config.widgets.test_execution.output_dir.value
|
|
failed_dir = self.config.widgets.test_execution.failed_dir.value
|
|
run_helpers.mkdirp(workdir)
|
|
run_helpers.mkdirp(failed_dir)
|
|
|
|
params = self.config.template_params()
|
|
comp = self.config.composition()
|
|
comp_filename = os.path.join(workdir, 'composition.toml')
|
|
params_filename = os.path.join(workdir, 'template-params.toml')
|
|
config_snapshot_filename = os.path.join(workdir, 'config-snapshot.json')
|
|
|
|
if 'k8s' in params['TEST_RUNNER']:
|
|
archive_filename = os.path.join(workdir, 'test-output.tgz')
|
|
else:
|
|
archive_filename = os.path.join(workdir, 'test-output.zip')
|
|
|
|
with open(comp_filename, 'wt') as f:
|
|
f.write(comp)
|
|
|
|
with open(params_filename, 'w') as f:
|
|
toml.dump(params, f)
|
|
|
|
with open(config_snapshot_filename, 'w') as f:
|
|
json.dump(self.config.snapshot(), f)
|
|
|
|
cmd = [TESTGROUND, '--vv',
|
|
'--endpoint', endpoint,
|
|
'run', 'composition',
|
|
'-f', comp_filename,
|
|
'--collect', '-o', archive_filename]
|
|
|
|
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True)
|
|
for line in iter(p.stdout.readline, ''):
|
|
print(line, end='')
|
|
if p.poll():
|
|
break
|
|
self.button.description = 'Done'
|
|
self.button.button_style = 'danger'
|
|
return_code = p.wait()
|
|
if return_code:
|
|
try:
|
|
shutil.move(workdir, failed_dir)
|
|
except BaseException as err:
|
|
print('tried to move output from failed test to {}, but failed with error: {}'.format(failed_dir, err))
|
|
raise ValueError('test execution failed, skipping analysis. moved outputs to {}'.format(failed_dir))
|
|
|
|
print('test outputs saved to {}'.format(workdir))
|
|
print('extracting test data for analysis...')
|
|
analysis_dir = os.path.join(workdir, 'analysis')
|
|
analyze.extract_test_outputs(archive_filename, analysis_dir, convert_to_pandas=False, prep_notebook=True)
|
|
print('saved analysis outputs to {}'.format(analysis_dir))
|
|
|
|
|
|
|
|
|
|
# a collapsible panel for a single topic's params
|
|
class TopicConfigPanel(object):
|
|
def __init__(self):
|
|
self.topic_widgets = Bunch(
|
|
name=widgets.Text(description="Topic Name", value="blocks"),
|
|
message_rate=widgets.Text(description="Message Rate (msg/sec)", value='120/s'),
|
|
message_size=widgets.Text(description="Message Size", value="2 KiB"),
|
|
)
|
|
|
|
self.topic_weight = widgets.FloatText(description="Topic Weight", value=0.25)
|
|
|
|
# NOTE: don't change the description values! they're used to derive the JSON keys when
|
|
# collecting the param values later
|
|
self.score_widgets = Bunch(
|
|
time_in_mesh=Bunch(
|
|
weight=widgets.FloatText(description="Time in Mesh Weight", value=0.0027),
|
|
quantum=widgets.Text(description="Time in Mesh Quantum", value='1s'),
|
|
cap=widgets.FloatText(description="Time in Mesh Cap", value=3600)
|
|
),
|
|
first_message_deliveries=Bunch(
|
|
weight=widgets.FloatText(description="First Message Deliveries Weight", value=0.664),
|
|
decay=widgets.FloatText(description="First Message Deliveries Decay", value=0.9916),
|
|
cap=widgets.FloatText(description="First Message Deliveries Cap", value=1500),
|
|
),
|
|
mesh_message_deliveries=Bunch(
|
|
weight=widgets.FloatText(description="Mesh Message Deliveries Weight", value=-0.25),
|
|
decay=widgets.FloatText(description="Mesh Message Deliveries Decay", value=0.97),
|
|
cap=widgets.FloatText(description="Mesh Message Deliveries Cap", value=400),
|
|
threshold=widgets.FloatText(description="Mesh Message Deliveries Threshold", value=100),
|
|
activation=widgets.Text(description="Mesh Message Deliveries Activation", value="30s"),
|
|
window=widgets.Text(description="Mesh Message Delivery Window", value="5ms"),
|
|
),
|
|
mesh_failure_penalty=Bunch(
|
|
weight=widgets.FloatText(description="Mesh Failure Penalty Weight", value=-0.25),
|
|
decay=widgets.FloatText(description="Mesh Failure Penalty Decay", value=0.997),
|
|
),
|
|
invalid_message_deliveries=Bunch(
|
|
weight=widgets.FloatText(description="Invalid Message Deliveries Weight", value=-99),
|
|
decay=widgets.FloatText(description="Invalid Message Deliveries Decay", value=0.9994),
|
|
)
|
|
)
|
|
|
|
topic_panel = widgets.VBox([
|
|
labeled(self.topic_widgets.name),
|
|
labeled(self.topic_widgets.message_rate),
|
|
labeled(self.topic_widgets.message_size),
|
|
])
|
|
|
|
score_panel = widgets.VBox([
|
|
widgets.HTML('<h3>Peer Score Params</h3>'),
|
|
labeled(self.topic_weight),
|
|
to_collapsible_sections(self.score_widgets)],
|
|
layout={'width': '900px'})
|
|
|
|
self.panel = widgets.VBox([topic_panel, score_panel], layout={'width': '900px'})
|
|
|
|
def ui(self):
|
|
return self.panel
|
|
|
|
def snapshot(self):
|
|
return {
|
|
'topic_weight': {'value': self.topic_weight.value},
|
|
'topic': widget_snapshot(self.topic_widgets),
|
|
'score': widget_snapshot(self.score_widgets),
|
|
}
|
|
|
|
def apply_snapshot(self, snapshot):
|
|
if 'topic_weight' in snapshot and 'value' in snapshot['topic_weight']:
|
|
self.topic_weight.value = snapshot['topic_weight']['value']
|
|
if 'topic' in snapshot:
|
|
apply_snapshot(self.topic_widgets, snapshot['topic'])
|
|
if 'score' in snapshot:
|
|
apply_snapshot(self.score_widgets, snapshot['score'])
|
|
|
|
def topic_id(self):
|
|
return self.topic_widgets.name.value
|
|
|
|
def topic_params(self):
|
|
return {
|
|
'id': self.topic_widgets.name.value,
|
|
'message_rate': self.topic_widgets.message_rate.value,
|
|
'message_size': self.topic_widgets.message_size.value,
|
|
}
|
|
|
|
def score_params(self):
|
|
p = {
|
|
'TopicWeight': self.topic_weight.value,
|
|
}
|
|
for group in self.score_widgets.values():
|
|
for param in group.values():
|
|
key = param.description.replace(' ', '')
|
|
p[key] = param.value
|
|
return p
|
|
|
|
|
|
# ConfigPanel is a collection of widgets to set the test parameters.
|
|
class ConfigPanel(object):
|
|
|
|
def __init__(self):
|
|
# all the widgets used to configure the test
|
|
|
|
default_out_dir = os.path.join('.', 'output', 'pubsub-test-{}'.format(time.strftime("%Y%m%d-%H%M%S")))
|
|
default_failed_dir = os.path.join('.', 'output', 'failed')
|
|
w = Bunch(
|
|
test_execution=Bunch(
|
|
output_dir=widgets.Text(description="Local directory to collect test outputs", value=default_out_dir),
|
|
failed_dir=widgets.Text(description="Local dir to store output from failed runs", value=default_failed_dir)
|
|
),
|
|
|
|
testground=Bunch(
|
|
daemon_endpoint=widgets.Text(description="Daemon Endpoint", value='localhost:8080'),
|
|
builder=widgets.Dropdown(description="Builder", options=['docker:go', 'exec:go']),
|
|
runner=widgets.Dropdown(description="Runner", options=['cluster:k8s', 'local:docker', 'local:exec']),
|
|
plan_dir=widgets.Text(description="Subdir of $TESTGROUND_HOME/plans containing pubsub plan", value="test-plans/pubsub/test"),
|
|
keep_service=widgets.Checkbox(description="Keep pods after execution? (k8s only)", value=False),
|
|
log_level=widgets.Dropdown(description="Log level to set on test instances", options=["info", "debug", "warn", "error"]),
|
|
),
|
|
|
|
time=Bunch(
|
|
setup=widgets.Text(description="Test Setup time", value='1m'),
|
|
run=widgets.Text(description="Test Runtime", value='2m'),
|
|
warm=widgets.Text(description="Warmup time", value='5s'),
|
|
cool=widgets.Text(description="Cooldown time", value='10s'),
|
|
),
|
|
|
|
node_counts=Bunch(
|
|
total=widgets.IntText(description="Total number of test instances", disabled=True),
|
|
total_peers=widgets.IntText(description="Total number of peers in all containers", disabled=True),
|
|
publisher=widgets.IntText(description="Number of publisher nodes", value=100),
|
|
lurker=widgets.IntText(description="Number of lurker nodes", value=50),
|
|
honest_per_container=widgets.IntText(description="# of honest peers per container", value=1),
|
|
),
|
|
|
|
pubsub=Bunch(
|
|
branch=widgets.Text(description="go-libp2p-pubsub branch/tag/commit to target", value="master"),
|
|
use_hardened_api=widgets.Checkbox(description="target hardening branch API", value=True),
|
|
heartbeat=widgets.Text(description='Heartbeat interval', value='1s'),
|
|
hearbeat_delay=widgets.Text(description='Initial heartbeat delay', value='100ms'),
|
|
validate_queue_size=widgets.IntText(description='Size of validation queue', value=32),
|
|
outbound_queue_size=widgets.IntText(description='Size of outbound RPC queue', value=32),
|
|
score_inspect_period=widgets.Text(description='Interval to dump peer scores', value='5s'),
|
|
full_traces=widgets.Checkbox(description='Capture full event traces)', value=False),
|
|
degree=widgets.IntText(description='D: target mesh degree', value=10),
|
|
degree_lo=widgets.IntText(description='D_lo: mesh degree low bound', value=8),
|
|
degree_hi=widgets.IntText(description='D_hi: mesh degree upper bound', value=16),
|
|
degree_score=widgets.IntText(description='D_score: peers to select by score', value=5),
|
|
degree_lazy=widgets.IntText(description='D_lazy: lazy propagation degree', value=12),
|
|
gossip_factor=widgets.FloatText(description='Gossip Factor', value=0.25),
|
|
opportunistic_graft_ticks=widgets.IntText(description='Opportunistic Graft heartbeat ticks', value=60),
|
|
),
|
|
|
|
network=Bunch(
|
|
latency = widgets.Text(description="Min latency", value='5ms'),
|
|
max_latency = widgets.Text(description="Max latency. If zero, latency will = min latency.", value='50ms'),
|
|
jitter_pct = widgets.IntSlider(description="Latency jitter %", value=10, min=1, max=100),
|
|
bandwidth_mb = widgets.IntText(description="Bandwidth (mb)", value=10240),
|
|
degree=widgets.IntText(description="Degree (# of initial connections) for honest peers", value=20),
|
|
|
|
# TODO: support upload of topology file
|
|
# topology_file = widgets.FileUpload(description="Upload fixed topology file", accept='.json', multiple=False),
|
|
),
|
|
|
|
honest_behavior=Bunch(
|
|
flood_publishing=widgets.Checkbox(value=True, description='Flood Publishing', indent=False),
|
|
connect_delay = widgets.Text(description='Honest peer connection delay. e.g. "30s" or "50@30s,30@1m"', value='0s'),
|
|
connect_jitter_pct = widgets.BoundedIntText(description='Jitter % for honest connect delay', value=5, min=0, max=100),
|
|
),
|
|
|
|
peer_score=Bunch(
|
|
gossip_threshold=widgets.FloatText(description='Gossip Threshold', value=-4000),
|
|
publish_threshold=widgets.FloatText(description='Publish Threshold', value=-5000),
|
|
graylist_threshold=widgets.FloatText(description='Graylist Threshold', value=-10000),
|
|
acceptpx_threshold=widgets.FloatText(description='Accept PX Threshold', value=0),
|
|
opportunistic_graft_threshold=widgets.FloatText(description='Opportunistic Graft Threshold', value=0),
|
|
ip_colocation_weight=widgets.FloatText(description='IP Colocation Factor Weight', value=0),
|
|
ip_colocation_threshold=widgets.IntText(description='IP Colocation Factor Threshold', value=1),
|
|
decay_interval=widgets.Text(description='Score Decay Interval', value='1s'),
|
|
decay_to_zero=widgets.FloatText(description='Decay Zero Threshold', value=0.01),
|
|
retain_score=widgets.Text(description="Time to Retain Score", value='30s'),
|
|
)
|
|
)
|
|
|
|
# wire up node count widgets to calculate and show the total number of containers and peers
|
|
# and update when the params they're derived from change
|
|
sum_values(w.node_counts.total, w.node_counts.publisher, w.node_counts.lurker)
|
|
mul_values(w.node_counts.total_peers, w.node_counts.total, w.node_counts.honest_per_container)
|
|
|
|
|
|
self.topic_config = TopicConfigPanel()
|
|
|
|
self.save_widgets = Bunch(
|
|
save_button = widgets.Button(description='Save Config', button_style='primary'),
|
|
load_button = widgets.Button(description='Load Saved Config', button_style='warning'),
|
|
snapshot_filename = widgets.Text(description='Path:', value='configs/snapshot.json')
|
|
)
|
|
|
|
self.save_widgets.save_button.on_click(self.save_clicked)
|
|
self.save_widgets.load_button.on_click(self.load_clicked)
|
|
save_panel = widgets.HBox(list(self.save_widgets.values()))
|
|
|
|
self.panel = widgets.VBox([
|
|
to_collapsible_sections(w),
|
|
collapsible("Topic Config", [self.topic_config.ui()]),
|
|
save_panel,
|
|
])
|
|
|
|
self.widgets = w
|
|
|
|
def ui(self):
|
|
return self.panel
|
|
|
|
def save_clicked(self, evt):
|
|
filename = self.save_widgets.snapshot_filename.value
|
|
with open(filename, 'wt') as f:
|
|
json.dump(self.snapshot(), f)
|
|
print('saved config snapshot to {}'.format(filename))
|
|
|
|
def load_clicked(self, evt):
|
|
filename = self.save_widgets.snapshot_filename.value
|
|
with open(filename, 'rt') as f:
|
|
snap = json.load(f)
|
|
|
|
# HACK: ignore the test_execution.output_dir param from the snapshot, to
|
|
# avoid overwriting the output of a prior run
|
|
if 'test_execution' in snap.get('main', {}):
|
|
del(snap['main']['test_execution']['output_dir'])
|
|
|
|
self.apply_snapshot(snap)
|
|
print('loaded config snapshot from {}'.format(filename))
|
|
|
|
def snapshot(self):
|
|
return {
|
|
'main': widget_snapshot(self.widgets),
|
|
'topic': self.topic_config.snapshot(),
|
|
}
|
|
|
|
def apply_snapshot(self, snapshot):
|
|
if 'main' in snapshot:
|
|
apply_snapshot(self.widgets, snapshot['main'])
|
|
if 'topic' in snapshot:
|
|
self.topic_config.apply_snapshot(snapshot['topic'])
|
|
|
|
def template_params(self):
|
|
w = self.widgets
|
|
|
|
n_nodes = w.node_counts.total.value
|
|
n_publisher = w.node_counts.publisher.value
|
|
n_nodes_cont_honest = w.node_counts.honest_per_container.value
|
|
n_honest_nodes = n_nodes
|
|
n_honest_peers_total = n_honest_nodes * n_nodes_cont_honest
|
|
n_container_nodes_total = n_honest_peers_total
|
|
|
|
p = {
|
|
# testground
|
|
'TEST_BUILDER': w.testground.builder.value,
|
|
'TEST_RUNNER': w.testground.runner.value,
|
|
'TEST_PLAN': w.testground.plan_dir.value,
|
|
|
|
# time
|
|
'T_SETUP': w.time.setup.value,
|
|
'T_RUN': w.time.run.value,
|
|
'T_WARM': w.time.warm.value,
|
|
'T_COOL': w.time.cool.value,
|
|
|
|
# node counts
|
|
'N_NODES': n_nodes,
|
|
'N_CONTAINER_NODES_TOTAL': n_container_nodes_total,
|
|
'N_PUBLISHER': n_publisher,
|
|
'N_HONEST_PEERS_PER_NODE': n_nodes_cont_honest,
|
|
|
|
# pubsub
|
|
'T_HEARTBEAT': w.pubsub.heartbeat.value,
|
|
'T_HEARTBEAT_INITIAL_DELAY': w.pubsub.hearbeat_delay.value,
|
|
'T_SCORE_INSPECT_PERIOD': w.pubsub.score_inspect_period.value,
|
|
'VALIDATE_QUEUE_SIZE': w.pubsub.validate_queue_size.value,
|
|
'OUTBOUND_QUEUE_SIZE': w.pubsub.outbound_queue_size.value,
|
|
'FULL_TRACES': w.pubsub.full_traces.value,
|
|
'OVERLAY_D': w.pubsub.degree.value,
|
|
'OVERLAY_DLO': w.pubsub.degree_lo.value,
|
|
'OVERLAY_DHI': w.pubsub.degree_hi.value,
|
|
'OVERLAY_DSCORE': w.pubsub.degree_score.value,
|
|
'OVERLAY_DLAZY': w.pubsub.degree_lazy.value,
|
|
'GOSSIP_FACTOR': w.pubsub.gossip_factor.value,
|
|
'OPPORTUNISTIC_GRAFT_TICKS': w.pubsub.opportunistic_graft_ticks.value,
|
|
|
|
# network
|
|
'T_LATENCY': w.network.latency.value,
|
|
'T_LATENCY_MAX': w.network.max_latency.value,
|
|
'JITTER_PCT': w.network.jitter_pct.value,
|
|
'BANDWIDTH_MB': w.network.bandwidth_mb.value,
|
|
'N_DEGREE': w.network.degree.value,
|
|
# TODO: load topology file
|
|
'TOPOLOGY': {},
|
|
|
|
# honest behavior
|
|
'FLOOD_PUBLISHING': w.honest_behavior.flood_publishing.value,
|
|
'HONEST_CONNECT_DELAY_JITTER_PCT': w.honest_behavior.connect_jitter_pct.value,
|
|
|
|
# topic & peer score configs
|
|
'TOPIC_CONFIG': self._topic_config(),
|
|
'PEER_SCORE_PARAMS': self._peer_score_params(),
|
|
}
|
|
|
|
if w.pubsub.use_hardened_api.value:
|
|
p['BUILD_SELECTORS'] = [run_helpers.HARDENED_API_BUILD_TAG]
|
|
else:
|
|
p['BUILD_SELECTORS'] = []
|
|
|
|
p['GS_VERSION'] = run_helpers.pubsub_commit(w.pubsub.branch.value)
|
|
|
|
run_config = ['log_level="{}"'.format(w.testground.log_level.value)]
|
|
|
|
if w.testground.runner.value == 'cluster:k8s':
|
|
buildopts = ['push_registry=true', 'registry_type="aws"']
|
|
p['BUILD_CONFIG'] = '\n'.join(buildopts)
|
|
if w.testground.keep_service.value:
|
|
run_config.append('keep_service=true')
|
|
|
|
p['RUN_CONFIG'] = '\n'.join(run_config)
|
|
|
|
# if the connect_delay param doesn't specify a count,
|
|
# make it apply to all honest nodes
|
|
delay = w.honest_behavior.connect_delay.value
|
|
if '@' not in delay:
|
|
delay = '{}@{}'.format(n_honest_peers_total, delay)
|
|
p['HONEST_CONNECT_DELAYS'] = delay
|
|
return p
|
|
|
|
def composition(self):
|
|
return run_helpers.render_template(TEMPLATE, self.template_params())
|
|
|
|
def _topic_config(self):
|
|
# TODO: support multiple topics
|
|
topics = [self.topic_config.topic_params()]
|
|
return topics
|
|
|
|
def _peer_score_params(self):
|
|
p = {
|
|
'Thresholds': {
|
|
'GossipThreshold': self.widgets.peer_score.gossip_threshold.value,
|
|
'PublishThreshold': self.widgets.peer_score.publish_threshold.value,
|
|
'GraylistThreshold': self.widgets.peer_score.graylist_threshold.value,
|
|
'AcceptPXThreshold': self.widgets.peer_score.acceptpx_threshold.value,
|
|
'OpportunisticGraftThreshold': self.widgets.peer_score.opportunistic_graft_threshold.value,
|
|
},
|
|
'IPColocationFactorWeight': self.widgets.peer_score.ip_colocation_weight.value,
|
|
'IPColocationFactorThreshold': self.widgets.peer_score.ip_colocation_threshold.value,
|
|
'DecayInterval': self.widgets.peer_score.decay_interval.value,
|
|
'DecayToZero': self.widgets.peer_score.decay_to_zero.value,
|
|
'RetainScore': self.widgets.peer_score.retain_score.value,
|
|
|
|
# TODO: support multiple topics
|
|
'Topics': {self.topic_config.topic_id(): self.topic_config.score_params()}
|
|
}
|
|
return p
|
|
|
|
|
|
#### widget helpers ####
|
|
|
|
def labeled(widget):
|
|
if widget.description is None or widget.description == '':
|
|
return widget
|
|
label = widget.description
|
|
widget.style.description_width = '0'
|
|
return widgets.VBox([widgets.Label(value=label), widget])
|
|
|
|
|
|
def collapsible(title, params, expanded=False):
|
|
grid = widgets.Layout(width='900px', grid_template_columns="repeat(2, 400px)")
|
|
inner = widgets.GridBox(params, layout=grid)
|
|
a = widgets.Accordion(children=[inner])
|
|
a.set_title(0, title)
|
|
a.selected_index = 0 if expanded else None
|
|
return a
|
|
|
|
|
|
def to_collapsible_sections(w, expanded=False):
|
|
# build up vbox of collapsible sections
|
|
sections = []
|
|
for name, params in w.items():
|
|
title = stringcase.sentencecase(name)
|
|
children = []
|
|
for p in params.values():
|
|
children.append(labeled(p))
|
|
|
|
sections.append(collapsible(title, children, expanded=expanded))
|
|
return widgets.VBox(sections, layout={'width': '900px'})
|
|
|
|
|
|
|
|
# sets the value of target widget to the sum of all arg widgets and updates when values change
|
|
def sum_values(target, *args):
|
|
def callback(change):
|
|
if change['name'] != 'value':
|
|
return
|
|
target.value = functools.reduce(operator.add, [a.value for a in args])
|
|
|
|
for widget in args:
|
|
widget.observe(callback)
|
|
|
|
# trigger callback to set initial value
|
|
callback({'name': 'value'})
|
|
|
|
|
|
# sets the value of target widget to the product of all arg widgets and updates when values change
|
|
def mul_values(target, *args):
|
|
def callback(change):
|
|
if change['name'] != 'value':
|
|
return
|
|
target.value = functools.reduce(operator.mul, [a.value for a in args])
|
|
|
|
for widget in args:
|
|
widget.observe(callback)
|
|
|
|
# trigger callback to set initial value
|
|
callback({'name': 'value'})
|
|
|
|
|
|
# takes a nested dict (or Bunch) whose leaves are widgets,
|
|
# and returns a dict with the same structure, but with widgets replaced with
|
|
# a snapshot of their current values
|
|
def widget_snapshot(widgets):
|
|
out = dict()
|
|
for name, val in widgets.items():
|
|
if isinstance(val, Bunch) or isinstance(val, dict):
|
|
out[name] = widget_snapshot(val)
|
|
else:
|
|
w = {'value': val.value}
|
|
out[name] = w
|
|
return out
|
|
|
|
|
|
# takes a nested dict or Bunch of widgets and the output of widget_snapshot,
|
|
# and sets the current widget values to the values from the snapshot
|
|
def apply_snapshot(widgets, snapshot):
|
|
for name, val in widgets.items():
|
|
if name not in snapshot:
|
|
continue
|
|
if isinstance(val, Bunch) or isinstance(val, dict):
|
|
apply_snapshot(val, snapshot[name])
|
|
else:
|
|
s = snapshot[name]
|
|
if 'value' in s:
|
|
val.value = s['value']
|