From c56f6b0130b5ab3d8b127b3dd48d18a2763fe0c2 Mon Sep 17 00:00:00 2001 From: will Date: Wed, 3 Jun 2015 08:54:25 -0700 Subject: [PATCH 1/5] Added new FormElements to represent floats and string input text boxes --- glue/qt/custom_viewer.py | 131 ++++++++++++++++++++++++---- glue/qt/tests/test_custom_viewer.py | 50 +++++++++-- 2 files changed, 156 insertions(+), 25 deletions(-) diff --git a/glue/qt/custom_viewer.py b/glue/qt/custom_viewer.py index 7ba8a274d..82669b384 100644 --- a/glue/qt/custom_viewer.py +++ b/glue/qt/custom_viewer.py @@ -246,15 +246,15 @@ def a(x, y): try: # get the current values of each input to the UDF a = [settings(item) for item in a] - except AttributeError as exc: + except MissingSettingError as exc: # the UDF expects an argument that we don't know how to provide # try to give a helpful error message missing = exc.args[0] setting_list = "\n -".join(settings.setting_names()) - raise AttributeError("This custom viewer is trying to use an " - "unrecognized variable named %s\n. Valid " - "variable names are\n -%s" % - (missing, setting_list)) + raise MissingSettingError("This custom viewer is trying to use an " + "unrecognized variable named %s\n. Valid " + "variable names are\n -%s" % + (missing, setting_list)) k = k or {} return func(*a, **k) @@ -268,6 +268,8 @@ def __call__(self, key): def setting_names(self): return NotImplementedError() +class MissingSettingError(KeyError): + pass class SettingsOracle(SettingsOracleInterface): @@ -280,18 +282,18 @@ def __init__(self, settings, **override): self.view = override.pop('view', None) def __call__(self, key): - try: - if key == 'self': - return self.override['_self'] - if key in self.override: - return self.override[key] - if key == 'style': - return self.layer.style - if key == 'layer': - return self.layer - return self.settings[key].value(self.layer, self.view) - except (KeyError, AttributeError): - raise AttributeError(key) + if key == 'self': + return self.override['_self'] + if key in self.override: + return self.override[key] + if key == 'style': + return self.layer.style + if key == 'layer': + return self.layer + if key not in self.settings: + raise MissingSettingError(key) + + return self.settings[key].value(self.layer, self.view) def setting_names(self): return list(set(list(self.settings.keys()) + ['style', 'layer'])) @@ -422,6 +424,9 @@ def value(layer=None, view=None): return o + def __contains__(self, item): + return item in self.kwargs + def keys(self): return self.kwargs.keys() @@ -1082,7 +1087,6 @@ def recognizes(cls, params): return False def _build_ui(self): - w = QtGui.QSlider() w = LabeledSlider(*self.params[:3]) w.valueChanged.connect(nonpartial(self.changed)) return w @@ -1091,6 +1095,97 @@ def value(self, layer=None, view=None): return self.ui.value() +class TextBoxElement(FormElement): + """ + A form element representing a generic textbox + + The shorthand is any string starting with an _.:: + + e = FormElement.auto("_default") + + Everything after the underscore is taken as the default value. + """ + state = wp.ValueProperty('ui') + + def _build_ui(self): + self._widget = GenericTextBox() + self._widget.textChanged.connect(nonpartial(self.changed)) + self.set_value(self.params[1:]) + return self._widget + + def value(self, layer=None, view=None): + return self._widget.text() + + def set_value(self, val): + self._widget.setText(str(val)) + + @classmethod + def recognizes(cls, params): + try: + if isinstance(params, str) & params.startswith('_'): + return True + except AttributeError: + return None + + +class FloatElement(FormElement): + """ + A form element representing a generic number box. + + The shorthand is any number:: + + e = FormElement.auto(2) + + The number itself is taken as the default value. + """ + state = wp.ValueProperty('ui') + + def _build_ui(self): + self._widget = GenericTextBox() + self._widget.textChanged.connect(nonpartial(self.changed)) + self.set_value(self.params) + return self._widget + + def value(self, layer=None, view=None): + try: + return float(self._widget.text()) + except ValueError: + return None + + def set_value(self, val): + self._widget.setText(str(val)) + + @classmethod + def recognizes(cls, params): + return isinstance(params, (int, float)) and not isinstance(params, bool) + +class GenericTextBox(QtGui.QWidget): + + def __init__(self): + super(GenericTextBox, self).__init__() + self._textbox = QtGui.QLineEdit() + + @property + def valueChanged(self): + return self._textbox.textChanged + + @property + def textChanged(self): + return self._textbox.textChanged + + def value(self, layer=None, view=None): + return self._textbox.text() + + def text(self): + return self._textbox.text() + + def set_value(self, text): + self._textbox.setText(text) + + setText = set_value + setValue = set_value + + class LabeledSlider(QtGui.QWidget): """ diff --git a/glue/qt/tests/test_custom_viewer.py b/glue/qt/tests/test_custom_viewer.py index 649ba5276..34b3ec9df 100644 --- a/glue/qt/tests/test_custom_viewer.py +++ b/glue/qt/tests/test_custom_viewer.py @@ -9,7 +9,10 @@ from ...core import Data from ...core.subset import SubsetState from ...core.tests.util import simple_session -from ..custom_viewer import FormElement, NumberElement, ChoiceElement, CustomViewer, CustomSubsetState, AttributeInfo +from ..custom_viewer import FormElement, NumberElement, \ + ChoiceElement, CustomViewer, \ + CustomSubsetState, AttributeInfo, \ + FloatElement, TextBoxElement, SettingsOracle, MissingSettingError from ..glue_application import GlueApplication from ...core.tests.test_state import check_clone_app, clone @@ -26,6 +29,7 @@ def _make_widget(viewer): e=False, f=['a', 'b', 'c'], g=OrderedDict(a=1, b=2, c=3), + h=64 ) @@ -45,8 +49,8 @@ def _setup(axes): @viewer.plot_data -def _plot_data(axes, a, b, g): - plot_data(axes=axes, a=a, b=b, g=g) +def _plot_data(axes, a, b, g, h): + plot_data(axes=axes, a=a, b=b, g=g, h=h) return [] @@ -82,12 +86,13 @@ class ViewerSubclass(CustomViewer): e = False f = ['a', 'b', 'c'] g = OrderedDict(a=1, b=2, c=3) + h = 64 def setup(self, axes): return setup(axes) - def plot_data(self, axes, a, b, g): - return plot_data(axes=axes, a=a, b=b, g=g) + def plot_data(self, axes, a, b, g, h): + return plot_data(axes=axes, a=a, b=b, g=g, h=h) def plot_subset(self, b, c, d, e, f, style): return plot_subset(b=b, c=c, d=d, e=e, f=f, style=style) @@ -144,9 +149,10 @@ def test_plot_data(self): a, k = plot_data.call_args assert isinstance(k['axes'], Axes) - assert set(k.keys()) == set(('axes', 'a', 'b', 'g')) + assert set(k.keys()) == set(('axes', 'a', 'b', 'g', 'h')) assert k['a'] == 50 assert k['g'] == 1 + assert k['h'] == 64 def test_plot_subset(self): w = self.build() @@ -254,7 +260,6 @@ def test_state(self): v = w._coordinator roi = MagicMock() s = CustomSubsetState(type(v), roi, v.settings()) - assert_array_equal(s.to_mask(self.data), [False, True, True]) def test_state_view(self): @@ -313,6 +318,19 @@ def test_choice_tuple(self): e = FormElement.auto(('a', 'b')) assert isinstance(e, ChoiceElement) + def test_float(self): + e = FormElement.auto(1.2) + assert isinstance(e, FloatElement) + + e = FormElement.auto(2) + assert isinstance(e, FloatElement) + assert e.value() == 2 + + def test_textbox(self): + e = FormElement.auto('_str') + assert isinstance(e, TextBoxElement) + assert e.value() == 'str' + def test_unrecognized(self): with pytest.raises(ValueError): e = FormElement.auto(None) @@ -344,3 +362,21 @@ def test_subset(self): assert_array_equal(v, [3, 4, 5]) assert v.id == self.d.id['x'] assert v.categories is None + +def test_oracle_raises_original_error(): + class BadFormElement(TextBoxElement): + + def value(self, layer=None, view=None): + raise AttributeError('Inner Error') + + oracle = SettingsOracle({'bad_form': BadFormElement('str("text")')}) + + try: + oracle('bad_form') + except AttributeError as err: + assert 'Inner Error' in err.args + + with pytest.raises(MissingSettingError): + oracle('missing') + + From 023573c2f3ff944deaa779e2eb98f7f06dbda82d Mon Sep 17 00:00:00 2001 From: will Date: Wed, 3 Jun 2015 16:29:42 -0700 Subject: [PATCH 2/5] Improved docs and test coverage --- glue/qt/custom_viewer.py | 36 +++++++++---- glue/qt/tests/test_custom_viewer.py | 81 ++++++++++++++++++++++++----- 2 files changed, 95 insertions(+), 22 deletions(-) diff --git a/glue/qt/custom_viewer.py b/glue/qt/custom_viewer.py index 82669b384..4ba820fb4 100644 --- a/glue/qt/custom_viewer.py +++ b/glue/qt/custom_viewer.py @@ -115,12 +115,13 @@ class AttributeInfo(np.ndarray): """ @classmethod - def make(cls, id, values, categories=None): + def make(cls, id, values, comp, categories=None): values = np.asarray(values) result = values.view(AttributeInfo) result.id = id result.values = values result.categories = categories + result._component = comp return result @classmethod @@ -142,14 +143,14 @@ def from_layer(cls, layer, cid, view=None): categories = None if isinstance(comp, core.data.CategoricalComponent): categories = comp._categories - return cls.make(cid, values, categories) + return cls.make(cid, values, comp, categories) def __gluestate__(self, context): return dict(cid=context.id(self.id)) @classmethod def __setgluestate__(cls, rec, context): - return cls.make(context.object(rec['cid']), []) + return cls.make(context.object(rec['cid']), [], None) class ViewerState(object): @@ -274,6 +275,13 @@ class MissingSettingError(KeyError): class SettingsOracle(SettingsOracleInterface): def __init__(self, settings, **override): + reserved_words = {'axes', 'layer', 'self'} + for key in settings.keys(): + if key in reserved_words: + raise AssertionError('You tried to create a custom setting %s' % key + + ' but you cannot use a reserved word: ' + + ','.join(sorted(reserved_words))) + self.settings = settings # dict-like, items have a value() method self.override = override # look for settings here first @@ -406,7 +414,7 @@ def value(self, key, layer=None, view=None): try: result = self.kwargs[key] except KeyError: - raise AttributeError(key) + raise MissingSettingError(key) if isinstance(result, AttributeInfo) and layer is not None: cid = result.id @@ -1034,7 +1042,11 @@ def auto(params): given a shorthand object. For examle, FormElement.auto((0., 1.)) returns a NumberElement """ - for cls in FormElement.__subclasses__(): + + def subclasses(cls): + return cls.__subclasses__() + [g for s in cls.__subclasses__() for g in subclasses(s)] + + for cls in subclasses(FormElement): if cls.recognizes(params): return cls(params) raise ValueError("Unrecognzied UI Component: %s" % (params,)) @@ -1310,7 +1322,7 @@ def value(self, layer=None, view=None): if layer is not None: cid = layer.data.id[cid] return AttributeInfo.from_layer(layer, cid, view) - return AttributeInfo.make(cid, []) + return AttributeInfo.make(cid, [], None) @property def state(self): @@ -1355,9 +1367,15 @@ def _build_ui(self): def value(self, layer=None, view=None): cid = self._component if layer is None or cid is None: - return AttributeInfo.make(cid, []) + return AttributeInfo.make(cid, [], None) return AttributeInfo.from_layer(layer, cid, view) + def _list_components(self): + comps = list(set([c for l in self.container.layers + for c in l.data.components if not c._hidden])) + comps = sorted(comps, key=lambda x: x.label) + return comps + def _update_components(self): combo = self.ui old = self._component @@ -1365,9 +1383,7 @@ def _update_components(self): combo.blockSignals(True) combo.clear() - comps = list(set([c for l in self.container.layers - for c in l.data.components if not c._hidden])) - comps = sorted(comps, key=lambda x: x.label) + comps = self._list_components() for c in comps: combo.addItem(c.label, userData=c) diff --git a/glue/qt/tests/test_custom_viewer.py b/glue/qt/tests/test_custom_viewer.py index 34b3ec9df..cf7008ec4 100644 --- a/glue/qt/tests/test_custom_viewer.py +++ b/glue/qt/tests/test_custom_viewer.py @@ -12,7 +12,8 @@ from ..custom_viewer import FormElement, NumberElement, \ ChoiceElement, CustomViewer, \ CustomSubsetState, AttributeInfo, \ - FloatElement, TextBoxElement, SettingsOracle, MissingSettingError + FloatElement, TextBoxElement, SettingsOracle, \ + MissingSettingError, FrozenSettings from ..glue_application import GlueApplication from ...core.tests.test_state import check_clone_app, clone @@ -331,6 +332,16 @@ def test_textbox(self): assert isinstance(e, TextBoxElement) assert e.value() == 'str' + def test_recognizes_subsubclasses(self): + + class SubClassFormElement(TextBoxElement): + @classmethod + def recognizes(cls, params): + return params == 'specific_class' + + e = FormElement.auto('specific_class') + assert isinstance(e, SubClassFormElement) + def test_unrecognized(self): with pytest.raises(ValueError): e = FormElement.auto(None) @@ -363,20 +374,66 @@ def test_subset(self): assert v.id == self.d.id['x'] assert v.categories is None -def test_oracle_raises_original_error(): - class BadFormElement(TextBoxElement): + def test_has_component(self): + + v = AttributeInfo.from_layer(self.s, self.d.id['x']) + comp = self.s.data.get_component(self.d.id['x']) + assert v._component == comp + + + +class TestSettingsOracle(object): + + + + def test_oracle_raises_original_error(self): + class BadFormElement(TextBoxElement): + + def value(self, layer=None, view=None): + raise AttributeError('Inner Error') + + oracle = SettingsOracle({'bad_form': BadFormElement('str("text")')}) + + try: + oracle('bad_form') + assert False + except AttributeError as err: + assert 'Inner Error' in err.args + + def test_oracle_raises_missing(self): + oracle = SettingsOracle({'Form': TextBoxElement('_text')}) + with pytest.raises(MissingSettingError): + oracle('missing') + + def test_frozen_oracle_raises_missing(self): + + oracle = FrozenSettings() + with pytest.raises(MissingSettingError): + oracle.value('missing') + + + def test_load_reserved_words(self): + + _self = MagicMock() + layer = MagicMock() + style = layer.style + extra = MagicMock() + oracle = SettingsOracle({}, _self=_self, + layer=layer, + extra=extra) + assert oracle('self') == _self + assert oracle('layer') == layer + assert oracle('style') == style + assert oracle('extra') == extra - def value(self, layer=None, view=None): - raise AttributeError('Inner Error') - oracle = SettingsOracle({'bad_form': BadFormElement('str("text")')}) + def test_setting_names(self): - try: - oracle('bad_form') - except AttributeError as err: - assert 'Inner Error' in err.args + oracle = SettingsOracle({'Form': TextBoxElement('_text')}) + assert sorted(oracle.setting_names()) == sorted(['style', 'layer', 'Form']) - with pytest.raises(MissingSettingError): - oracle('missing') + def test_raises_if_overlapping_reserved_words(self): + with pytest.raises(AssertionError): + oracle = SettingsOracle({'self': TextBoxElement('_text')}) \ No newline at end of file From b891fae2307c415ed4e5a2e8ddbcda92980c9437 Mon Sep 17 00:00:00 2001 From: will Date: Sat, 6 Jun 2015 09:06:07 -0700 Subject: [PATCH 3/5] cleanup --- glue/qt/custom_viewer.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/glue/qt/custom_viewer.py b/glue/qt/custom_viewer.py index 4ba820fb4..c9f5f86f2 100644 --- a/glue/qt/custom_viewer.py +++ b/glue/qt/custom_viewer.py @@ -1173,9 +1173,13 @@ def recognizes(cls, params): class GenericTextBox(QtGui.QWidget): - def __init__(self): - super(GenericTextBox, self).__init__() + def __init__(self, parent=None): + super(GenericTextBox, self).__init__(parent) + self._l = QtGui.QHBoxLayout() self._textbox = QtGui.QLineEdit() + self._l.setContentsMargins(2, 2, 2, 2) + self._l.addWidget(self._textbox) + self.setLayout(self._l) @property def valueChanged(self): @@ -1371,6 +1375,14 @@ def value(self, layer=None, view=None): return AttributeInfo.from_layer(layer, cid, view) def _list_components(self): + """ + Determine which components to list. + + + This can be overridden by subclassing to limit which components are + visible to the user. + + """ comps = list(set([c for l in self.container.layers for c in l.data.components if not c._hidden])) comps = sorted(comps, key=lambda x: x.label) From 190a531ebd62255b517118724effb9204f766e7a Mon Sep 17 00:00:00 2001 From: Will Date: Mon, 22 Jun 2015 12:22:42 -0400 Subject: [PATCH 4/5] fixed py26 issue. --- glue/qt/custom_viewer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/glue/qt/custom_viewer.py b/glue/qt/custom_viewer.py index c9f5f86f2..28828d540 100644 --- a/glue/qt/custom_viewer.py +++ b/glue/qt/custom_viewer.py @@ -275,7 +275,7 @@ class MissingSettingError(KeyError): class SettingsOracle(SettingsOracleInterface): def __init__(self, settings, **override): - reserved_words = {'axes', 'layer', 'self'} + reserved_words = set(['axes', 'layer', 'self']) for key in settings.keys(): if key in reserved_words: raise AssertionError('You tried to create a custom setting %s' % key + From b3c5f3f9e046f7dce88ef1d9d1bb02e0c612c271 Mon Sep 17 00:00:00 2001 From: Will Date: Mon, 22 Jun 2015 12:57:28 -0400 Subject: [PATCH 5/5] fixed py26 issue --- glue/qt/custom_viewer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/glue/qt/custom_viewer.py b/glue/qt/custom_viewer.py index 28828d540..bf939e741 100644 --- a/glue/qt/custom_viewer.py +++ b/glue/qt/custom_viewer.py @@ -275,6 +275,7 @@ class MissingSettingError(KeyError): class SettingsOracle(SettingsOracleInterface): def __init__(self, settings, **override): + reserved_words = set(['axes', 'layer', 'self']) for key in settings.keys(): if key in reserved_words: