diff --git a/mytk/app.py b/mytk/app.py index 88d2e1b..3c8f815 100644 --- a/mytk/app.py +++ b/mytk/app.py @@ -81,7 +81,8 @@ def __init__( self.create_menu() App.app = self - self.after(self.run_loop_delay, self.run_main_queue) + if self.is_running: + self.after(self.run_loop_delay, self.run_main_queue) @property def widget(self): @@ -152,7 +153,8 @@ def run_main_queue(self): e, ) - self.after(self.run_loop_delay, self.run_main_queue) + if self.is_running: + self.after(self.run_loop_delay, self.run_main_queue) def create_menu(self): """ diff --git a/mytk/configurable.py b/mytk/configurable.py new file mode 100644 index 0000000..ba0ec79 --- /dev/null +++ b/mytk/configurable.py @@ -0,0 +1,196 @@ +from typing import Optional, Any, Callable +from dataclasses import dataclass +import numbers +import re +from mytk import Dialog, Label, Entry + +def is_numeric(value) -> bool: + return isinstance(value, numbers.Real) + +@dataclass +class ConfigurableProperty: + name: str + default_value : Optional[Any] = None + displayed_name: str = None + validate_fct : Optional[Callable[[Any], bool]] = None + valid_set : set | list = None + value_type: Optional[type] = None + + def __post_init__(self): + if self.value_type is None and self.default_value is not None: + self.value_type = type(self.default_value) + + if self.default_value is not None and not self.is_valid(self.default_value): + raise ValueError(f"Default value {self.default_value} is not valid for this type of property {type(self)}") + + for value in self.valid_set or []: + if value is not None and not self.is_valid(value): + raise ValueError(f"Value {value} is not valid for this type of property {type(self)}") + + def is_in_valid_set(self, value: Any) -> bool: + if self.valid_set is None: + return True + + return value in self.valid_set + + def is_valid_type(self, value: Any) -> bool: + expected_type = self.value_type + + if expected_type is None or expected_type is Any: + return True + + return isinstance(value, expected_type) + + def is_valid(self, value: Any) -> bool: + if not self.is_valid_type(value): + return False + if self.validate_fct and not self.validate_fct(value): + return False + return True + + def sanitize(self, value) -> Any: + if value is None: + value = self.default_value + + if self.value_type not in (None, Any): + if isinstance(value, self.value_type): + return value + + if not self.is_valid_type(value): + try: + value = self.value_type(value) + except (ValueError, TypeError): + value = self.default_value + + return value + + +@dataclass +class ConfigurableStringProperty(ConfigurableProperty): + valid_regex:Optional[Any] = None + + def __post_init__(self): + self.value_type = str + super().__post_init__() + + def is_valid(self, value: str) -> bool: + if not super().is_valid(value): + return False + + if re.search(self.valid_regex or ".*", value) is None: + return False + + return True + +@dataclass +class ConfigurableNumericProperty(ConfigurableProperty): + min_value: Optional[Any] = float("-inf") + max_value: Optional[Any] = float("+inf") + multiplier: int = 1 + format_string : Optional[str] = None + + def is_in_valid_range(self, value: Any) -> bool: + try: + return self.min_value <= value <= self.max_value + except TypeError: + return False + + + def is_valid(self, value: Any) -> bool: + if not super().is_valid(value): + return False + if not self.is_in_valid_range(value): + return False + return True + + def sanitize(self, value) -> Any: + value = super().sanitize(value) + + if is_numeric(value): + if not self.is_in_valid_range(value): + if value < self.min_value: + value = self.min_value + elif value > self.max_value: + value = self.max_value + + return value + + @staticmethod + def int_property_list(keys:list[str]): + properties = [] + for key in keys: + properties.append(ConfigurableProperty(name=key, value_type=int)) + + return properties + +class ConfigModel: + def __init__(self, properties:list[ConfigurableProperty] = None): + self.properties = { pd.name:pd for pd in properties or []} + self._values = { pd.name:pd.default_value for pd in properties or []} + + @property + def values(self): + return self._values + + @values.setter + def values(self, new_values): + if all(self.is_valid(new_values).values()): + self._values = new_values + else: + raise ValueError("Some values are invalid") + def update_values(self, new_values): + if all(self.is_valid(new_values).values()): + self._values.update(new_values) + else: + raise ValueError("Some values are invalid") + + def is_valid(self, values): + is_valid = {} + for key, value in values.items(): + property = self.properties[key] + is_valid[key] = property.is_valid(value) + return is_valid + + def sanitize(self, values): + sanitized_values = {} + for key, value in values.items(): + property = self.properties[key] + sanitized_values[key] = property.sanitize(value) + + return sanitized_values + +class ConfigurationDialog(Dialog, ConfigModel): + def __init__(self, populate_body_fct=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.populate_body_fct = populate_body_fct + self.configuration_widgets = {} + + def populate_widget_body(self): + if self.populate_body_fct is None: + for i, (key, value) in enumerate(self.values.items()): + if key in self.properties: + text_label = self.properties[key].displayed_name or key + if self.properties[key].displayed_name is not None: + text_label = self.properties[key].displayed_name + + Label(text_label).grid_into(self, row=i, column=0, padx=10, pady=5, sticky="e") + entry = Entry(character_width=6) + entry.value_variable.set(value) + entry.grid_into(self, row=i, column=1, padx=10, pady=5, sticky="w") + self.configuration_widgets[key] = entry + else: + self.populate_body_fct() + + def widget_values(self) -> dict: + values = {} + for key, entry_widget in self.configuration_widgets.items(): + values[key] = entry_widget.value_variable.get() + + return self.sanitize(values) + + def run(self): + reply = super().run() + + self.values.update(self.widget_values()) + + return reply diff --git a/mytk/tests/testConfigurable.py b/mytk/tests/testConfigurable.py new file mode 100644 index 0000000..5f8d7e7 --- /dev/null +++ b/mytk/tests/testConfigurable.py @@ -0,0 +1,467 @@ +import envtest +from typing import Optional, Tuple, Any + +from mytk.configurable import ConfigModel, ConfigurableStringProperty, ConfigurableNumericProperty, ConfigurationDialog +from mytk import Dialog, Label, Entry +import threading, atexit, sys + + +class TestObject(ConfigModel): + pass + +class ConfigurableTestCase(envtest.MyTkTestCase): + def test000_configurable_property(self) -> None: + """ + Verify that the abstract ImageProvider cannot be instantiated directly. + """ + + prop = ConfigurableNumericProperty( + name="Exposure Time", + default_value=100, + min_value=0, + max_value=1000, + validate_fct=lambda x: x >= 0, + format_string="{:.1f} ms", + multiplier=1000 + ) + + self.assertIsNotNone(prop) + + def test010_configurable_property_with_defaults(self) -> None: + + prop = ConfigurableNumericProperty( + name="Exposure Time", + default_value=100, + ) + + self.assertIsNotNone(prop) + self.assertTrue(prop.is_in_valid_range(100)) + self.assertTrue(prop.is_in_valid_range(10000)) + self.assertTrue(prop.is_in_valid_range(-10000)) + + def test020_configurable_property_validated(self) -> None: + + prop = ConfigurableNumericProperty( + name="Exposure Time", + default_value=100, + min_value=0, + max_value=1000, + ) + + self.assertIsNotNone(prop) + self.assertTrue(prop.is_in_valid_range(100)) + self.assertFalse(prop.is_in_valid_range(10000)) + + def test022_configurable_property_is_valid_set(self) -> None: + + prop = ConfigurableNumericProperty( + name="Exposure Time", + valid_set = set([1,2,3]) + ) + + self.assertTrue(prop.is_in_valid_set(1)) + self.assertTrue(prop.is_in_valid_set(2)) + self.assertFalse(prop.is_in_valid_set(0)) + + def test022_configurable_property_is_valid_set_as_list(self) -> None: + + prop = ConfigurableNumericProperty( + name="Exposure Time", + valid_set = [1,2,3] + ) + + self.assertTrue(prop.is_in_valid_set(1)) + self.assertTrue(prop.is_in_valid_set(2)) + self.assertFalse(prop.is_in_valid_set(0)) + + def test025_configurable_property_is_valid(self) -> None: + + prop = ConfigurableNumericProperty( + name="Exposure Time", + default_value=100, + min_value=0, + max_value=1000, + value_type=int + ) + + self.assertTrue(prop.is_valid(100)) + self.assertTrue(prop.is_valid(0)) + self.assertTrue(prop.is_valid(1000)) + self.assertFalse(prop.is_valid(-100)) + self.assertFalse(prop.is_valid(5.5)) + self.assertFalse(prop.is_valid(5000.12)) + + def test025_configurable_property_sanitize(self) -> None: + + prop = ConfigurableNumericProperty( + name="Exposure Time", + default_value=100, + min_value=0, + max_value=1000, + value_type=int + ) + + self.assertEqual(prop.sanitize(-100), 0) + self.assertEqual(prop.sanitize(2000), 1000) + self.assertEqual(prop.sanitize(100.5), 100) + + def test026_configurable_property_sanitize_no_type(self) -> None: + + prop = ConfigurableNumericProperty( + name="Exposure Time", + default_value=100, + min_value=0, + max_value=1000, + ) + + self.assertEqual(prop.sanitize(-100.1), 0) + self.assertEqual(prop.sanitize(2000), 1000) + self.assertEqual(prop.sanitize(100.5), 100) + + def test026_configurable_property_sanitize_type_inferred_from_default_no_range(self) -> None: + + prop = ConfigurableNumericProperty( + name="Exposure Time", + default_value=100, + ) + + self.assertEqual(prop.sanitize(-100.1), -100) + self.assertEqual(prop.sanitize(2000), 2000) + self.assertEqual(prop.sanitize(100.5), 100) + + def test027_configurable_property_sanitize_no_type_no_range(self) -> None: + + prop = ConfigurableNumericProperty( + name="Exposure Time", + default_value=100, + value_type=Any + ) + + self.assertEqual(prop.sanitize(-100.1), -100.1) + self.assertEqual(prop.sanitize(2000), 2000) + self.assertEqual(prop.sanitize(100.5), 100.5) + + def test028_configurable_property_wrong_type(self) -> None: + + with self.assertRaises(ValueError): + prop = ConfigurableNumericProperty( + name="Exposure Time", + default_value=100.1, + min_value=0, + max_value=1000, + value_type=int + ) + + with self.assertRaises(ValueError): + prop = ConfigurableNumericProperty( + name="Exposure Time", + default_value=100.0, + min_value=0, + max_value=1000, + value_type=int + ) + + def test028_configurable_property_wrong_str_type(self) -> None: + with self.assertRaises(ValueError): + prop = ConfigurableStringProperty( + name="String", + default_value=100.0, + ) + + def test029_configurable_property_is_invalid(self) -> None: + prop = ConfigurableStringProperty(name="String") + self.assertFalse(prop.is_valid(10)) + + def test031_configurable_property_is_in_valid_set_but_set_is_empty(self) -> None: + prop = ConfigurableStringProperty(name="String") + self.assertTrue(prop.is_in_valid_set(10)) + + def test031_configurable_property_has_invalid_values(self) -> None: + with self.assertRaises(ValueError): + prop = ConfigurableStringProperty(name="String", valid_set=['a', 1]) + + def test031_configurable_property_sanitize_none_to_default(self) -> None: + prop = ConfigurableStringProperty(name="String", default_value="Something") + self.assertEqual(prop.sanitize(None), "Something") + + def test031_configurable_property_sanitize_unabnle_to_cast_defaults_to_default_value(self) -> None: + prop = ConfigurableNumericProperty(name="Numeric value", default_value=100) + self.assertEqual(prop.sanitize("adsklahjs"), 100) + + def test050_configurable_str_property(self) -> None: + + prop = ConfigurableStringProperty( + name="Name", + valid_regex="[ABC]def" + ) + + self.assertEqual(prop.value_type, str) + + self.assertTrue(prop.is_valid("Adef")) + self.assertTrue(prop.is_valid("Bdef")) + self.assertTrue(prop.is_valid("Cdef")) + self.assertFalse(prop.is_valid("Test")) + self.assertFalse(prop.is_valid(100)) + self.assertEqual(prop.sanitize("Cdef"), 'Cdef') + + def test051_configurable_str_property_is_valid_set_as_list(self) -> None: + + prop = ConfigurableStringProperty( + name="Name", + valid_set = ["Daniel", "Mireille"], + valid_regex = ".*" + ) + + self.assertTrue(prop.is_in_valid_set("Daniel")) + self.assertTrue(prop.is_in_valid_set("Mireille")) + self.assertFalse(prop.is_in_valid_set("Bob the builder")) + + def test052_configurable_property_fct_validate(self) -> None: + + def is_positive(value): + return value > 0 + + prop = ConfigurableNumericProperty( + name="Name", + validate_fct = is_positive + ) + + self.assertTrue(prop.is_valid(1)) + self.assertFalse(prop.is_valid(-1)) + + + def test053_configurable_property_invalid_set(self) -> None: + with self.assertRaises(ValueError) as err: + prop = ConfigurableNumericProperty( + name="Name", + valid_set=['String'] + ) + + + def test060_quick_propertyy_lists(self): + props = ConfigurableNumericProperty.int_property_list(['a','b']) + self.assertIsNotNone(props) + self.assertEqual(len(props), 2) + + def test030_configurable_object_init(self) -> None: + + prop1 = ConfigurableNumericProperty( + name="exposure_time", + default_value=100, + min_value=0, + max_value=1000, + ) + + prop2 = ConfigurableNumericProperty( + name="gain", + default_value=100, + min_value=0, + max_value=1000, + ) + + prop3 = ConfigurableStringProperty( + name="name", + default_value="Test", + ) + + obj = TestObject(properties=[prop1, prop2, prop3]) + self.assertIsNotNone(obj) + + def test030_configurable_object_valid_props(self): + prop1 = ConfigurableNumericProperty( + name="exposure_time", + default_value=100, + min_value=0, + max_value=1000, + ) + + prop2 = ConfigurableNumericProperty( + name="gain", + default_value=100, + min_value=0, + max_value=1000, + ) + + prop3 = ConfigurableStringProperty( + name="name", + default_value="Test", + ) + + obj = TestObject(properties=[prop1, prop2, prop3]) + + self.assertTrue(obj.is_valid({"gain":1,"exposure_time":1})) + + def test030_configurable_object_invalid_props(self): + prop1 = ConfigurableNumericProperty( + name="exposure_time", + default_value=100, + min_value=0, + max_value=1000, + ) + + prop2 = ConfigurableNumericProperty( + name="gain", + default_value=100, + min_value=0, + max_value=1000, + ) + + prop3 = ConfigurableStringProperty( + name="name", + default_value="Test", + ) + + obj = TestObject(properties=[prop1, prop2, prop3]) + + is_valid = obj.is_valid({"gain":-1,"exposure_time":1}) + self.assertFalse(is_valid['gain']) + self.assertTrue(is_valid['exposure_time']) + + def test030_configurable_object_invalid_key(self): + prop1 = ConfigurableNumericProperty( + name="exposure_time", + default_value=100, + min_value=0, + max_value=1000, + ) + + prop2 = ConfigurableNumericProperty( + name="gain", + default_value=100, + min_value=0, + max_value=1000, + ) + + prop3 = ConfigurableStringProperty( + name="name", + default_value="Test", + ) + + obj = TestObject(properties=[prop1, prop2, prop3]) + + with self.assertRaises(KeyError): + self.assertTrue(obj.is_valid({"gain":1,"exposure_time":1,"bla":0})) + + + def test030_configurable_object_sanitize(self): + prop1 = ConfigurableNumericProperty( + name="exposure_time", + default_value=100, + min_value=10, + max_value=1000, + ) + + prop2 = ConfigurableNumericProperty( + name="gain", + default_value=100, + min_value=1, + max_value=1000, + ) + + prop3 = ConfigurableStringProperty( + name="name", + default_value="Test", + ) + + obj = TestObject(properties=[prop1, prop2, prop3]) + + sanitized = obj.sanitize({"gain":0,"exposure_time":1}) + self.assertEqual(sanitized['gain'],1) + + sanitized = obj.sanitize({"gain":0,"exposure_time":10_000}) + self.assertEqual(sanitized['gain'],1) # too low + self.assertEqual(sanitized['exposure_time'],1_000) # too high + + sanitized = obj.sanitize({"gain":0,"exposure_time":None}) + self.assertEqual(sanitized['exposure_time'], 100) # None -> default_value + + sanitized = obj.sanitize({"gain":0,"exposure_time":"10"}) + self.assertEqual(sanitized['exposure_time'], 10) # Wrong type -> casting + + sanitized = obj.sanitize({"gain":0,"exposure_time":"Wrong"}) + self.assertEqual(sanitized['exposure_time'], 100) # Wrong type -> default_value + + def test099_configurable_object_get_set(self): + prop1 = ConfigurableNumericProperty( + name="exposure_time", + default_value=100, + min_value=10, + max_value=1000, + ) + + prop2 = ConfigurableNumericProperty( + name="gain", + default_value=100, + min_value=1, + max_value=1000, + ) + + prop3 = ConfigurableStringProperty( + name="name", + default_value="Test", + ) + + obj = TestObject(properties=[prop1, prop2, prop3]) + + self.assertIsNotNone(obj.values) + obj.values = {"gain":1} + + with self.assertRaises(ValueError): + obj.values = {"gain":-1} + + def test100_configurable_object_dialog(self) -> None: + + prop1 = ConfigurableNumericProperty( + name="exposure_time", + displayed_name="Exposure time", + default_value=100, + min_value=0, + max_value=1000, + ) + + prop2 = ConfigurableNumericProperty( + name="gain", + displayed_name="Gain", + default_value=3, + min_value=0, + max_value=1000, + ) + + diag = ConfigurationDialog(title="Configuration", properties=[prop1, prop2], + buttons_labels=["Ok"], auto_click=("Ok", 200)) + reply = diag.run() + + self.assertEqual(diag.values, {"gain":3, 'exposure_time':100}) + + def test110_configurable_object_dialog_with_values(self) -> None: + + prop1 = ConfigurableNumericProperty( + name="exposure_time", + displayed_name="Exposure time", + default_value=100, + min_value=0, + max_value=1000, + ) + + prop2 = ConfigurableNumericProperty( + name="gain", + displayed_name="Gain", + default_value=3, + min_value=0, + max_value=1000, + ) + + diag = ConfigurationDialog(title="Configuration", properties=[prop1, prop2], + buttons_labels=["Ok"], auto_click=("Ok", 200)) + + diag.values = {"gain":10, 'exposure_time':30} + reply = diag.run() + + self.assertEqual(diag.values, {"gain":10, 'exposure_time':30}) + + # # def test050_ConfiguModel(self) -> None: + # # ConfigModel() + + +if __name__ == "__main__": + envtest.unittest.main()