diff --git a/lib/cfgapp.py b/lib/cfgapp.py index 55859baa..f65ef5e7 100644 --- a/lib/cfgapp.py +++ b/lib/cfgapp.py @@ -6,7 +6,7 @@ # entry point for the configuration application, which is also reachable # using the system tray menu when using the tray resident application -def main(root) -> None: +def main(root): # not setting the root of form_Config() informs that this is the # configuration app, thus no `Reload` button should be displayed form = form_Config() diff --git a/lib/configurator/writer.py b/lib/configurator/writer.py index 151557ff..42cd02c8 100644 --- a/lib/configurator/writer.py +++ b/lib/configurator/writer.py @@ -4,31 +4,61 @@ from tomlkit import document, comment, item, aot from time import strftime +from ..utility import is_private_item_name from ..i18n.strings import UI_APP +# the comment text constants for the configuration file are defined here +COMMENT_PUBLIC_SECTION = "global parameters and user defined items" +COMMENT_PRIVATE_ITEMS = "private items: please do not modify below this line" + + # all items have an `as_table()` utility that converts them to TOML tables -def write_whenever_config(filename, tasks, conditions, events, globals) -> None: +# this writer separates private items from user created ones +def write_whenever_config(filename, tasks, conditions, events, globals): + head = document() doc = document() + priv = document() mod_time = strftime("%Y-%m-%d @%H:%M:%S") - doc.add(comment(f"{UI_APP}: {mod_time}")) + head.add(comment(f"{UI_APP}: {mod_time}")) + head.add(comment(COMMENT_PUBLIC_SECTION)) for k in globals: if globals[k] is not None: doc.add(k, item(globals[k])) + priv.add(comment(COMMENT_PRIVATE_ITEMS)) + p = aot() t = aot() for elem in tasks: - t.append(elem.as_table()) + if is_private_item_name(elem.name): + p.append(elem.as_table()) + else: + t.append(elem.as_table()) doc.append("task", t) + priv.append("task", p) + p = aot() t = aot() for elem in conditions: - t.append(elem.as_table()) + if is_private_item_name(elem.name): + p.append(elem.as_table()) + else: + t.append(elem.as_table()) doc.append("condition", t) + priv.append("condition", p) + p = aot() t = aot() for elem in events: - t.append(elem.as_table()) + if is_private_item_name(elem.name): + p.append(elem.as_table()) + else: + t.append(elem.as_table()) doc.append("event", t) + priv.append("event", p) with open(filename, "w") as f: + f.write(head.as_string()) + f.write("\n") f.write(doc.as_string()) + f.write("\n") + f.write(priv.as_string()) # end. diff --git a/lib/extra/_template.py b/lib/extra/_template.py index d0d7a15a..95813925 100644 --- a/lib/extra/_template.py +++ b/lib/extra/_template.py @@ -97,7 +97,7 @@ def __init__(self, t: items.Table | None = None): self.updateitem() - def updateitem(self) -> None: + def updateitem(self): # set base item properties according to specific parameters in `tags` self.command = "ls" self.command_arguments = [ @@ -165,12 +165,12 @@ def __init__(self, tasks_available, item=None): self._updateform() # update the form with the specific parameters (usually in the `tags`) - def _updateform(self) -> None: + def _updateform(self): self.data_set("parameter1", self._item.tags.get("parameter1")) # type: ignore return super()._updateform() # update the item from the form elements (usually update `tags`) - def _updatedata(self) -> None: + def _updatedata(self): self._item.tags["parameter1"] = self.data_get("parameter1") # type: ignore self._item.updateitem() # type: ignore return super()._updatedata() diff --git a/lib/extra/c_battery_charging_linux.py b/lib/extra/c_battery_charging_linux.py index 476d8771..5b6c919f 100644 --- a/lib/extra/c_battery_charging_linux.py +++ b/lib/extra/c_battery_charging_linux.py @@ -82,7 +82,7 @@ def _get_batteries(): batteries.append(str(x)) batteries.sort() return batteries - except: + except Exception: return [] diff --git a/lib/extra/c_battery_charging_win32.py b/lib/extra/c_battery_charging_win32.py index 2b0e3514..68b32cc9 100644 --- a/lib/extra/c_battery_charging_win32.py +++ b/lib/extra/c_battery_charging_win32.py @@ -66,7 +66,7 @@ def _has_battery(): conn = wmi.WMI() batteries = conn.query(query) return bool(len(batteries) > 0) - except: + except Exception: return False diff --git a/lib/extra/c_battery_low_linux.py b/lib/extra/c_battery_low_linux.py index dce942e9..e44a7f5b 100644 --- a/lib/extra/c_battery_low_linux.py +++ b/lib/extra/c_battery_low_linux.py @@ -84,7 +84,7 @@ def _get_batteries(): batteries.append(str(x)) batteries.sort() return batteries - except: + except Exception: return [] diff --git a/lib/extra/c_battery_low_win32.py b/lib/extra/c_battery_low_win32.py index dfc5355c..5fc4a5ed 100644 --- a/lib/extra/c_battery_low_win32.py +++ b/lib/extra/c_battery_low_win32.py @@ -65,7 +65,7 @@ def _has_battery(): conn = wmi.WMI() batteries = conn.query(query) return bool(len(batteries) > 0) - except: + except Exception: return False diff --git a/lib/extra/c_removabledrive_win32.py b/lib/extra/c_removabledrive_win32.py index 8aa90070..6f0df546 100644 --- a/lib/extra/c_removabledrive_win32.py +++ b/lib/extra/c_removabledrive_win32.py @@ -137,7 +137,7 @@ def check_tags(cls, tags): if drive_letter is not None: if ( not isinstance(drive_letter, str) - and not drive_letter[0] in AVAILABLE_DRIVE_LETTERS + and drive_letter[0] not in AVAILABLE_DRIVE_LETTERS ): errors.append("drive_letter") drive_label = tags.get("drive_label") diff --git a/lib/extra/c_session_locked_win32.py b/lib/extra/c_session_locked_win32.py index 38ed2112..f15cc2aa 100644 --- a/lib/extra/c_session_locked_win32.py +++ b/lib/extra/c_session_locked_win32.py @@ -130,7 +130,7 @@ def check_tags(cls, tags): missing.append("check_frequency") elif ( not isinstance(check_frequency, str) - and not check_frequency in CHECK_EXTRA_DELAY.keys() + and check_frequency not in CHECK_EXTRA_DELAY.keys() ): errors.append("check_frequency") if errors or missing: diff --git a/lib/extra/i18n/extra_locale.py b/lib/extra/i18n/extra_locale.py index a549d31c..01634c67 100644 --- a/lib/extra/i18n/extra_locale.py +++ b/lib/extra/i18n/extra_locale.py @@ -1,7 +1,6 @@ # get localized versions of strings for extra items UIs -import os from importlib import import_module from ...i18n.localizer import get_locale diff --git a/lib/forms/about.py b/lib/forms/about.py index a852f7ac..e84eaaae 100644 --- a/lib/forms/about.py +++ b/lib/forms/about.py @@ -73,7 +73,7 @@ def __init__(self, main=False): # display a simple about box -def show_about_box(main=False) -> None: +def show_about_box(main=False): box = AboutBox(main) box.run() del box diff --git a/lib/forms/cfgform.py b/lib/forms/cfgform.py index 03756ac5..a2a7140c 100644 --- a/lib/forms/cfgform.py +++ b/lib/forms/cfgform.py @@ -9,13 +9,13 @@ from ttkbootstrap_icons_mat import MatIcon as Icons from ..i18n.strings import * -from ..icons import APP_ICON32 as APP_ICON from .ui import * from .colors import * from ..repocfg import AppConfig from ..utility import get_configfile, is_private_item_name from ..items.item import ALL_AVAILABLE_ITEMS_D +from ..internal import multi_conds_run_task as mcrt from ..configurator.reader import read_whenever_config from ..configurator.writer import write_whenever_config @@ -209,7 +209,7 @@ def tab_change(): self._updateform() # update the associated data according to what is in the form - def _updatedata(self) -> None: + def _updatedata(self): self._itemlistentries = [] # encode the invisible list field with the same signature that is used in # the _item_ module, so that the corresponding form and item factories can @@ -244,7 +244,7 @@ def _updatedata(self) -> None: ) # update the form fields according to the associated actual data - def _updateform(self) -> None: + def _updateform(self): self._tv_items.delete(*self._tv_items.get_children()) for entry in self._itemlistentries: t = entry[2].split(":", 1)[0] @@ -293,7 +293,7 @@ def _resetdata(self): self._changed = False # load the configuration from a TOML file, only display non-private items - def _load_config(self, fn) -> None: + def _load_config(self, fn): self._resetdata() default_globals = self._globals.copy() tasks, conditions, events, self._globals = read_whenever_config(fn) @@ -333,7 +333,7 @@ def _load_config(self, fn) -> None: self._changed = False # save the configuration to a TOML file: add private items when required - def _save_config(self, fn) -> None: + def _save_config(self, fn): # 0. set configuration globals as retrieved from the form AppConfig.delete("RESET_CONDS_ON_RESUME") AppConfig.set( @@ -367,6 +367,36 @@ def _save_config(self, fn) -> None: del self._conditions[name] if name in self._events.keys(): del self._events[name] + + # check whether there are MCRT conditions (both confluence and + # confluent) and, if so, create the support items, that is, the initial + # task and condition, and the updater task, otherwise remove them if + # present so that no time and resources are wasted for no reason + n_mcrt_initializer = mcrt.initializer_name() + n_mcrt_initial_cond = mcrt.initial_condition_name() + n_mcrt_updater = mcrt.updater_name() + mcrt_active = False + for cond in self._conditions.values(): + if isinstance(cond, mcrt.ConfluenceCondition) or mcrt.is_confluent_cond( + cond + ): + mcrt_active = True + break + if mcrt_active: + if n_mcrt_updater not in self._tasks.keys(): + self._tasks[n_mcrt_updater] = mcrt.updater() + if n_mcrt_initializer not in self._tasks.keys(): + self._tasks[n_mcrt_initializer] = mcrt.initializer() + if n_mcrt_initial_cond not in self._conditions.keys(): + self._conditions[n_mcrt_initial_cond] = mcrt.initial_condition() + else: + if n_mcrt_updater in self._tasks.keys(): + del self._tasks[n_mcrt_updater] + if n_mcrt_initializer in self._tasks.keys(): + del self._tasks[n_mcrt_initializer] + if n_mcrt_initial_cond in self._conditions.keys(): + del self._conditions[n_mcrt_initial_cond] + # ... # finally write the configuration fle @@ -379,7 +409,7 @@ def _save_config(self, fn) -> None: ) # to be called when one of the global parameters has been changed - def _set_changed(self, changed=True) -> None: + def _set_changed(self, changed=True): self._changed = changed # check currently loaded items for signature coherence @@ -397,27 +427,37 @@ def _check_items(self) -> bool: return True # command button reactions - def delete(self) -> None: + def delete(self): # TODO: do not ignore type error item_name, _, item_signature = self.data_get("item_selection") # type: ignore item_type = item_signature.split(":", 1)[0] if self.messagebox.askyesno(UI_POPUP_T_CONFIRM, UI_POPUP_DELETEITEM_Q): if item_type == "task": - del self._tasks[item_name] + e = self._tasks[item_name] + if e.can_be_removed(self._conditions): + del self._tasks[item_name] + else: + self.messagebox.showerror(UI_POPUP_T_ERR, UI_POPUP_REFERENCEDTASK) elif item_type == "cond": - del self._conditions[item_name] + e = self._conditions[item_name] + if e.can_be_removed(self._events): + del self._conditions[item_name] + else: + self.messagebox.showerror(UI_POPUP_T_ERR, UI_POPUP_REFERENCEDCOND) elif item_type == "event": del self._events[item_name] self._updatedata() self._updateform() self._changed = True - def save(self) -> None: + def save(self): self._updatedata() fn = self.data_get("config_file") if fn and self._changed: if os.path.exists(fn): - if self.messagebox.askyesno(UI_POPUP_T_CONFIRM, UI_POPUP_OVERWRITEFILE_Q): + if self.messagebox.askyesno( + UI_POPUP_T_CONFIRM, UI_POPUP_OVERWRITEFILE_Q + ): self._save_config(fn) self._changed = False else: @@ -426,12 +466,12 @@ def save(self) -> None: # edit a specific item: to be complete, this is also bound # to the double click event for an element of the list - def edit(self) -> None: + def edit(self): selection = self.data_get("item_selection") if selection is None: return - item_name, _, item_signature = selection # type: ignore + item_name, _, item_signature = selection # type: ignore item_type = item_signature.split(":", 1)[0] # task items @@ -473,6 +513,15 @@ def edit(self) -> None: x for x in self._tasks.keys() if not is_private_item_name(x) ) e = fform(available_tasks, self._conditions[item_name]) + # this is a special case, which has an extra parameter + if item_signature == "cond:lua:mcrt_confluence": + confluent_conds = list( + x + for x in self._conditions.keys() + if not is_private_item_name(x) + and mcrt.is_confluent_cond(self._conditions[x]) + ) + e.set_available_conditions(confluent_conds) if e is not None: new_item = e.run() if new_item: @@ -495,8 +544,7 @@ def edit(self) -> None: event_conds = list( x for x in self._conditions - if self._conditions[x].type == "event" - and not is_private_item_name(x) + if self._conditions[x].type == "event" and not is_private_item_name(x) ) available_item = ALL_AVAILABLE_ITEMS_D.get(item_signature) assert available_item is not None @@ -518,7 +566,7 @@ def edit(self) -> None: # create a new item: open the item type selection dialog and, if an # item type is chosen, open the appropriate form with default values - def new(self) -> None: + def new(self): e = form_NewItem() if e is not None: r = e.run() @@ -531,6 +579,15 @@ def new(self) -> None: x for x in self._tasks.keys() if not is_private_item_name(x) ] form = form_class(list(available_tasks)) + # this is a special case, which has an extra parameter + if form_class is mcrt.form_ConfluenceCondition: + confluent_conds = list( + x + for x in self._conditions.keys() + if not is_private_item_name(x) + and mcrt.is_confluent_cond(self._conditions[x]) + ) + form.set_available_conditions(confluent_conds) # note that, since providing a suitable event based # condition is mandatory for an event, the form will # refuse to create a new event @@ -573,7 +630,7 @@ def new(self) -> None: # reload the configuration by sending the appropriate message to the # main (hidden) application form - def reload(self) -> None: + def reload(self): if self.messagebox.askyesno(UI_POPUP_T_CONFIRM, UI_POPUP_RELOADCONFIG_Q): if self._app: self._app.send_event("<>") @@ -581,9 +638,11 @@ def reload(self) -> None: # modify the reaction to the quit button so that if the configuration # has changed the user is asked whether or not he wants to discard it - def exit_close(self) -> None: + def exit_close(self): if self._changed: - if self.messagebox.askokcancel(UI_POPUP_T_CONFIRM, UI_POPUP_DISCARDCONFIG_Q): + if self.messagebox.askokcancel( + UI_POPUP_T_CONFIRM, UI_POPUP_DISCARDCONFIG_Q + ): return super().exit_close() else: return super().exit_close() diff --git a/lib/forms/cond.py b/lib/forms/cond.py index e0eeb1fd..c802a647 100644 --- a/lib/forms/cond.py +++ b/lib/forms/cond.py @@ -11,7 +11,17 @@ from ..items.cond import Condition -from ..utility import is_valid_item_name, clean_caption +from ..utility import ( + is_valid_item_name, + clean_caption, + is_private_item_name, + whenever_has_lua_sync, +) +from ..internal import multi_conds_run_task as mcrt + + +# add a negated disabled to tk dictionary (please forgive me) +tk.NOT_DISABLED = f"!{tk.DISABLED}" # type: ignore # condition box base class: since this is the class that will be used in @@ -61,10 +71,15 @@ def __init__(self, title, tasks_available, item=None): ck_itemSuspended = ttk.Checkbutton( area_common, text=UI_FORM_SUSPENDCONDATSTARTUP ) - l_maxTasksRetries = ttk.Label(area_common, text=UI_FORM_MAXTASKRETRIES_SC) - e_maxTasksRetries = ttk.Entry(area_common) + ck_mcrtActivateFurther = ttk.Checkbutton( + area_common, text=UI_FORM_ACTIVATEFURTHERCONDS + ) sep1 = ttk.Separator(area_common) + # disable confluence if confluence condition is not available + if not mcrt.ConfluenceCondition.available: + ck_mcrtActivateFurther.config(state=tk.DISABLED) + l_tasks = ttk.Label(area_common, text=UI_FORM_ACTIVETASKS_SC) # build a scrolled frame for the treeview sftv_tasks = ttk.Frame(area_common) @@ -109,32 +124,39 @@ def __init__(self, title, tasks_available, item=None): b_addTask.grid(row=0, column=2, sticky=tk.E, padx=PAD, pady=PAD) b_delTask.grid(row=0, column=3, sticky=tk.E, padx=PAD, pady=PAD) - # self._tv_tasks.bind('', lambda _: self.recall_task()) - # control flow section area_ctlflow = ttk.Frame(area_common) + area_ctlflowL = ttk.Frame(area_ctlflow) + area_ctlflowR = ttk.Frame(area_ctlflow) + area_ctlflowRsub = ttk.Frame(area_ctlflowR) + area_ctlflowL.grid(row=0, column=0, sticky=tk.W) + area_ctlflowR.grid(row=0, column=1, sticky=tk.SE) + area_ctlflowRsub.grid(row=0, column=0, sticky=tk.S) rb_noCheck = ttk.Radiobutton( - area_ctlflow, text=UI_FORM_BREAKNEVER, value="break_none" + area_ctlflowL, text=UI_FORM_BREAKNEVER, value="break_none" ) rb_breakFailure = ttk.Radiobutton( - area_ctlflow, text=UI_FORM_BREAKONFAILURE, value="break_failure" + area_ctlflowL, text=UI_FORM_BREAKONFAILURE, value="break_failure" ) rb_breakSuccess = ttk.Radiobutton( - area_ctlflow, text=UI_FORM_BREAKONSUCCESS, value="break_success" + area_ctlflowL, text=UI_FORM_BREAKONSUCCESS, value="break_success" ) + l_maxTasksRetries = ttk.Label(area_ctlflowRsub, text=UI_FORM_MAXTASKRETRIES_SC) + e_maxTasksRetries = ttk.Entry(area_ctlflowRsub, width=10) # control flow section: arrange widgets rb_noCheck.grid(row=0, column=0, sticky=tk.W, padx=PAD, pady=PAD) rb_breakFailure.grid(row=1, column=0, sticky=tk.W, padx=PAD, pady=PAD) rb_breakSuccess.grid(row=2, column=0, sticky=tk.W, padx=PAD, pady=PAD) + l_maxTasksRetries.grid(row=0, column=0, sticky=tk.E, padx=PAD, pady=PAD) + e_maxTasksRetries.grid(row=0, column=1, sticky=tk.E, padx=PAD, pady=PAD) # arrange top items items in the appropriate notebook l_itemName.grid(row=0, column=0, sticky=tk.W, padx=PAD, pady=PAD) e_itemName.grid(row=0, column=1, sticky=tk.EW, padx=PAD, pady=PAD) ck_itemRecurring.grid(row=1, column=1, sticky=tk.W, padx=PAD, pady=PAD) ck_itemSuspended.grid(row=2, column=1, sticky=tk.W, padx=PAD, pady=PAD) - l_maxTasksRetries.grid(row=4, column=0, sticky=tk.W, padx=PAD, pady=PAD) - e_maxTasksRetries.grid(row=4, column=1, sticky=tk.EW, padx=PAD, pady=PAD) + ck_mcrtActivateFurther.grid(row=3, column=1, sticky=tk.W, padx=PAD, pady=PAD) sep1.grid(row=5, column=0, columnspan=2, sticky=tk.EW, pady=PAD) l_tasks.grid(row=10, column=0, columnspan=2, sticky=tk.W, padx=PAD, pady=PAD) sftv_tasks.grid( @@ -148,17 +170,23 @@ def __init__(self, title, tasks_available, item=None): ck_itemRecurring.bind("", lambda _: self._check_recurring()) ck_itemRecurring.bind("", lambda _: self._check_recurring()) + ck_mcrtActivateFurther.bind("", lambda _: self._mcrt_confluent()) + ck_mcrtActivateFurther.bind( + "", lambda _: self._mcrt_confluent() + ) # expand appropriate sections area_common.rowconfigure(index=11, weight=1) area_common.columnconfigure(1, weight=1) area_taskchoose.columnconfigure(1, weight=1) + area_ctlflow.columnconfigure(0, weight=1) self._area_specific.rowconfigure(index=0, weight=1) self._area_specific.columnconfigure(index=0, weight=1) # bind data to widgets self.data_bind("@name", e_itemName, TYPE_STRING, is_valid_item_name) self.data_bind("@recurring", ck_itemRecurring) + self.data_bind("@confluent", ck_mcrtActivateFurther) self.data_bind( "@max_tasks_retries", e_maxTasksRetries, TYPE_INT, lambda x: x >= -1 ) @@ -176,35 +204,59 @@ def __init__(self, title, tasks_available, item=None): "@max_tasks_retries": clean_caption(UI_FORM_MAXTASKRETRIES_SC), } + # keep a list of task related widgets to disable them when needed + self._task_elems = [ + # current list + l_tasks, + tv_tasks, + sb_tasks, + # chooser + l_chooseTask, + cb_chooseTask, + b_addTask, + b_delTask, + # control flow + ck_execSequence, + rb_noCheck, + rb_breakSuccess, + rb_breakFailure, + # retry control + l_maxTasksRetries, + e_maxTasksRetries, + ] + # propagate widgets that need to be accessed self._tv_tasks = tv_tasks self._max_retries = e_maxTasksRetries # finally set the item if item: + # disable the possibility to activate other condition if the + # associated condition is already a confluence + if isinstance(item, mcrt.ConfluenceCondition): + ck_mcrtActivateFurther.config(state=tk.DISABLED) self.set_item(item) else: self.reset_item() self._check_recurring() - # self._max_retries.config(state=tk.NORMAL) self.changed = False - def add_task(self) -> None: - task = self.data_get("@choose_task") - if task: - self._tasks.append(task) + def add_task(self): + elem = self.data_get("@choose_task") self._updatedata() + if elem: + self._tasks.append(elem) self._updateform() - def del_task(self) -> None: + def del_task(self): elem = self.data_get("@tasks_selection") + self._updatedata() if elem: idx = int(elem[0]) del self._tasks[idx] - self._updatedata() self._updateform() - def add_check_caption(self, dataname, caption) -> None: + def add_check_caption(self, dataname, caption): assert self.data_exists(dataname) self._captions[dataname] = clean_caption(caption) @@ -218,28 +270,75 @@ def _invalid_data_captions(self): else: return res - def _popup_invalid_data(self, captions) -> None: + def _popup_invalid_data(self, captions): captions.sort() capts = "- " + "\n- ".join(captions) msg = UI_POPUP_INVALIDPARAMETERS_T % capts self.messagebox.showerror(UI_POPUP_T_ERR, msg) - def _check_recurring(self) -> None: + def _check_recurring(self): # we use the opposite of the value because of : anyway # the counterpart does not work so well (same thing # for , while does better) not_rec = not self.data_get("@recurring") or False if not_rec: + self.data_set("@max_tasks_retries", 0) self._max_retries.config(state=tk.DISABLED) else: - self._max_retries.config(state=tk.NORMAL) + # before enabling, check that this is not a confluent condition + if not self.data_get("@confluent"): + self._max_retries.config(state=tk.NORMAL) + + def _mcrt_confluent(self, force=False): + # since this reacts to click, bail out if item is a confluence + if isinstance(self._item, mcrt.ConfluenceCondition): + return + # same consideration as above; this function also disables all task + # related form widgets when the condition is set to be confluent + if force: + confluent = True + else: + confluent = not self.data_get("@confluent") or False + # retrieve the name of the MCRT updater task + mcrt_updater = mcrt.updater_name() + if confluent: + self.data_set("@name", "") + self.data_set("@control_flow", "break_none") + self.data_set("@execute_sequence", True) + self.data_set("@choose_task", "") + self.data_set("@max_tasks_retries", 0) + self._tv_tasks.delete(*self._tv_tasks.get_children()) + self._tasks = [mcrt_updater] + for elem in self._task_elems: + spec = list(elem.state()) + if tk.DISABLED not in spec: + spec.append(tk.DISABLED) + if tk.NOT_DISABLED in spec: # type:ignore + spec.remove(tk.NOT_DISABLED) # type:ignore + elem.state(spec) + # TODO: there must be a better way to achieve this + if "config" in elem.__dict__: + elem.config(state=tk.DISABLED) + else: + for elem in self._task_elems: + spec = list(elem.state()) + if tk.DISABLED in spec: + spec.remove(tk.DISABLED) + if tk.NOT_DISABLED not in spec: # type:ignore + spec.append(tk.NOT_DISABLED) # type:ignore + elem.state(spec) + # TODO: there must be a better way to do this (same as above) + if "config" in elem.__dict__: + elem.config(state=tk.NORMAL) + if mcrt_updater in self._tasks: + self._tasks.remove(mcrt_updater) # contents is the root for slave widgets @property def contents(self) -> ttk.Frame: return self._area_specific - def _updateform(self) -> None: + def _updateform(self): self._tv_tasks.delete(*self._tv_tasks.get_children()) if self._item: assert isinstance(self._item, Condition) @@ -249,28 +348,38 @@ def _updateform(self) -> None: self._max_retries.config(state=tk.DISABLED) self.data_set("@name", self._item.name) self.data_set("@recurring", self._item.recurring or False) - self.data_set("@max_tasks_retries", self._item.max_tasks_retries or 0) self.data_set("@suspended", self._item.suspended or False) - self.data_set( - "@execute_sequence", - ( - self._item.execute_sequence - if self._item.execute_sequence is False - else True - ), - ) - idx = 0 - for task in self._tasks: - self._tv_tasks.insert( - "", iid="%s-%s" % (idx, task), values=(idx, task), index=tk.END - ) - idx += 1 - if self._item.break_on_failure: - self.data_set("@control_flow", "break_failure") - elif self._item.break_on_success: - self.data_set("@control_flow", "break_success") + if mcrt.is_confluent_cond(self._item): + self.data_set("@confluent", True) + self._mcrt_confluent(True) else: - self.data_set("@control_flow", "break_none") + self.data_set("@confluent", False) + self.data_set("@max_tasks_retries", self._item.max_tasks_retries or 0) + self.data_set( + "@execute_sequence", + ( + self._item.execute_sequence + if self._item.execute_sequence is False + else True + ), + ) + idx = 0 + for task in self._tasks: + # this is correct and avoid listing possible confluent tasks + if not is_private_item_name(task): + self._tv_tasks.insert( + "", + iid="%s-%s" % (idx, task), + values=(idx, task), + index=tk.END, + ) + idx += 1 + if self._item.break_on_failure: + self.data_set("@control_flow", "break_failure") + elif self._item.break_on_success: + self.data_set("@control_flow", "break_success") + else: + self.data_set("@control_flow", "break_none") else: self._max_retries.config(state=tk.DISABLED) self.data_set("@name", "") @@ -278,10 +387,12 @@ def _updateform(self) -> None: self.data_set("@recurring", True) self.data_set("@suspended", False) self.data_set("@execute_sequence", True) + self.data_set("@max_tasks_retries", 0) + self.data_set("@confluent", False) self.data_set("@choose_task", "") # the data update utility loads data into the item - def _updatedata(self) -> None: + def _updatedata(self): assert isinstance(self._item, Condition) name = self.data_get("@name") if name is not None: @@ -308,7 +419,7 @@ def _updatedata(self) -> None: self._item.tasks = self._tasks.copy() # set and remove the associated item - def set_item(self, item: Condition) -> None: + def set_item(self, item: Condition): assert isinstance(item, Condition) try: self._item = item.__class__( @@ -317,20 +428,27 @@ def set_item(self, item: Condition) -> None: except ValueError: self._item = item # item was newly created: use it assert isinstance(self._item.tasks, list) - self._tasks = self._item.tasks.copy() + # check if this is a confluent condition and set widgets accordingly + if mcrt.is_confluent_cond(item): + self._tasks = list() + self.data_set("@confluent", True) + self._mcrt_confluent(True) + else: + self._tasks = self._item.tasks.copy() + self.data_set("@confluent", False) - def reset_item(self) -> None: + def reset_item(self): self._item = None self._tasks = [] # command button reactions: cancel deletes the current item so that None # is returned upon dialog close, while ok finalizes item initialization # and lets the run() function return a configured item - def exit_cancel(self) -> None: + def exit_cancel(self): self._item = None return super().exit_cancel() - def exit_ok(self) -> None: + def exit_ok(self): errs = self._invalid_data_captions() if errs is None: self._updatedata() diff --git a/lib/forms/cond_command.py b/lib/forms/cond_command.py index 0585fabc..97525cc9 100644 --- a/lib/forms/cond_command.py +++ b/lib/forms/cond_command.py @@ -1,9 +1,6 @@ # command condition form -import sys -import os import re -import shutil from shlex import split as arg_split, quote from os.path import normpath @@ -266,8 +263,8 @@ def _updatedata(self): v = bool(self.data_get("set_environment_variables")) self._item.set_environment_variables = False if not v else None e = {} - for l in self._envvars: - e[l[0]] = str(l[1]) + for c in self._envvars: + e[c[0]] = str(c[1]) self._item.environment_variables = e or None self._item.success_status = None self._item.success_stdout = None @@ -334,7 +331,7 @@ def _updatedata(self): self._item.case_sensitive = False if not case_sensitive else None return super()._updatedata() - def _updateform(self) -> None: + def _updateform(self): assert isinstance(self._item, CommandCondition) self.data_set("varname", "") self.data_set("newvalue", "") @@ -409,7 +406,7 @@ def _updateform(self) -> None: ) return super()._updateform() - def add_var(self) -> None: + def add_var(self): assert isinstance(self._item, CommandCondition) name = self.data_get("varname") value = self.data_get("newvalue") @@ -424,14 +421,14 @@ def add_var(self) -> None: ) self._envvars.append([name, value]) e = {} - for l in self._envvars: - e[l[0]] = str(l[1]) + for c in self._envvars: + e[c[0]] = str(c[1]) self._item.environment_variables = e or None self._envvars.sort(key=lambda x: x[0]) self._updatedata() self._updateform() - def del_var(self) -> None: + def del_var(self): assert isinstance(self._item, CommandCondition) entry = self.data_get("envvar_selection") assert isinstance(entry, list) @@ -439,8 +436,8 @@ def del_var(self) -> None: value = entry[1] self._envvars = list(entry for entry in self._envvars if entry[0] != name) e = {} - for l in self._envvars: - e[l[0]] = str(l[1]) + for c in self._envvars: + e[c[0]] = str(c[1]) self._item.environment_variables = e or None # first update the form data, then recall the variable in the input # fields, so that the user can re-add the variable again if needed @@ -449,7 +446,7 @@ def del_var(self) -> None: self.data_set("varname", name) self.data_set("newvalue", value) - def recall_var(self) -> None: + def recall_var(self): entry = self.data_get("envvar_selection") assert isinstance(entry, list) name = entry[0] @@ -457,7 +454,7 @@ def recall_var(self) -> None: self.data_set("varname", name) self.data_set("newvalue", value) - def browse_command(self) -> None: + def browse_command(self): filetypes = [(UI_FILETYPE_ALL, ".*")] if is_windows(): exts = get_executable_extensions() @@ -468,7 +465,7 @@ def browse_command(self) -> None: if entry: self.data_set("command", entry) - def browse_startup_path(self) -> None: + def browse_startup_path(self): entry = filedialog.askdirectory(parent=self.dialog) if entry: self.data_set("startup_path", entry) diff --git a/lib/forms/cond_dbus.py b/lib/forms/cond_dbus.py index 4a8476e7..1cd97546 100644 --- a/lib/forms/cond_dbus.py +++ b/lib/forms/cond_dbus.py @@ -168,7 +168,7 @@ def __init__(self, tasks_available, item=None): # update the form self._updateform() - def _updateform(self) -> None: + def _updateform(self): assert isinstance(self._item, DBusCondition) self.data_set("bus", self._item.bus) self.data_set("service", self._item.service) @@ -185,7 +185,7 @@ def _updateform(self) -> None: ) return super()._updateform() - def _updatedata(self) -> None: + def _updatedata(self): assert isinstance(self._item, DBusCondition) parameter_call = self.data_get("parameter_call") parameter_check = self.data_get("parameter_check") diff --git a/lib/forms/cond_lua.py b/lib/forms/cond_lua.py index 6671b9db..b4b751f4 100644 --- a/lib/forms/cond_lua.py +++ b/lib/forms/cond_lua.py @@ -1,7 +1,6 @@ # Lua condition form import re -import os import tkinter as tk import ttkbootstrap as ttk import ttkbootstrap.constants as ttkc @@ -15,7 +14,7 @@ from ..utility import ( guess_typed_value, get_editor_theme, - get_luadir, + get_lua_path, get_lua_initscript, ) @@ -37,30 +36,7 @@ def __init__(self, tasks_available, item=None): super().__init__(UI_TITLE_LUACOND, tasks_available, item) assert isinstance(self._item, LuaScriptCondition) - # update the LUA_PATH for user scripts: note that it seems that - # LUA_PATH as a global variable to search modulesseems to have been - # replaced by the `package.path` table element,thus the need to use - # the initialization script; also, the default Lua search path is - # not added to the embedded Lua path, in order to avoid to search - # for unsupported or possibly binary modules - luabase = get_luadir() - ps = os.path.sep - lua_path = ( - ";".join( - [ - "?", - "?.lua", - f"{luabase}{ps}?", - f"{luabase}{ps}?.lua", - f"{luabase}{ps}?{ps}?", - f"{luabase}{ps}?{ps}?.lua", - ] - ) - + ";;" - ) - self._item.variables_to_set = { - "LUA_PATH": lua_path, - } + self._item.variables_to_set = {"LUA_PATH": get_lua_path()} # legacy self._item.init_script_path = get_lua_initscript() # form data @@ -201,8 +177,8 @@ def add_var(self): ) self._results.append([name, value]) e = {} - for l in self._results: - e[l[0]] = str(l[1]) + for c in self._results: + e[c[0]] = str(c[1]) self._item.expected_results = e or None self._results.sort(key=lambda x: x[0]) self._updatedata() @@ -210,7 +186,7 @@ def add_var(self): else: self.messagebox.showerror(UI_POPUP_T_ERR, UI_POPUP_INVALIDVARNAME) - def del_var(self) -> None: + def del_var(self): assert isinstance(self._item, LuaScriptCondition) entry = self.data_get("luavar_selection") assert isinstance(entry, list) @@ -218,8 +194,8 @@ def del_var(self) -> None: value = entry[1] self._results = list(entry for entry in self._results if entry[0] != name) e = {} - for l in self._results: - e[l[0]] = str(l[1]) + for c in self._results: + e[c[0]] = str(c[1]) self._item.expected_results = e or None # first update the form data, then recall the variable in the input # fields, so that the user can re-add the variable again if needed @@ -228,7 +204,7 @@ def del_var(self) -> None: self.data_set("varname", name) self.data_set("newvalue", value) - def recall_var(self) -> None: + def recall_var(self): entry = self.data_get("luavar_selection") assert isinstance(entry, list) name = entry[0] @@ -236,7 +212,7 @@ def recall_var(self) -> None: self.data_set("varname", name) self.data_set("newvalue", value) - def _updatedata(self) -> None: + def _updatedata(self): assert isinstance(self._item, LuaScriptCondition) script = self.data_get("script") assert isinstance(script, str) @@ -247,12 +223,12 @@ def _updatedata(self) -> None: self.data_get("ignore_persistent_success") or None ) e = {} - for l in self._results: - e[l[0]] = guess_typed_value(str(l[1])) + for c in self._results: + e[c[0]] = guess_typed_value(str(c[1])) self._item.expected_results = e or None return super()._updatedata() - def _updateform(self) -> None: + def _updateform(self): assert isinstance(self._item, LuaScriptCondition) self.data_set("script", self._item.script) self.data_set("expect_all", self._item.expect_all or False) diff --git a/lib/forms/cond_time.py b/lib/forms/cond_time.py index 17be7ce6..8836c4c9 100644 --- a/lib/forms/cond_time.py +++ b/lib/forms/cond_time.py @@ -224,7 +224,7 @@ def __init__(self, tasks_available, item=None): # update the form self._updateform() - def clear_timespec(self) -> None: + def clear_timespec(self): self.data_set("ts_year") self.data_set("ts_month") self.data_set("ts_day") @@ -233,7 +233,7 @@ def clear_timespec(self) -> None: self.data_set("ts_min") self.data_set("ts_sec") - def add_timespec(self) -> None: + def add_timespec(self): if m := self.data_get("ts_month"): try: month = _MONTHS_MAP[m] @@ -272,7 +272,7 @@ def add_timespec(self) -> None: self._updatedata() self._updateform() - def del_timespec(self) -> None: + def del_timespec(self): sel = self.data_get("timespec_selection") if sel: assert isinstance(sel, list) @@ -281,7 +281,7 @@ def del_timespec(self) -> None: self._updatedata() self._updateform() - def recall_timespec(self) -> None: + def recall_timespec(self): sel = self.data_get("timespec_selection") if sel: assert isinstance(sel, list) @@ -298,14 +298,14 @@ def recall_timespec(self) -> None: self.data_set("ts_min", spec.minute) self.data_set("ts_sec", spec.second) - def clear_alltimespecs(self) -> None: + def clear_alltimespecs(self): # this functionality is never implemented if self.messagebox.askyesno(UI_POPUP_T_CONFIRM, UI_POPUP_DELETEALLENTRIES_Q): self._timespecs = [] self._updatedata() self._updateform() - def _updatedata(self) -> None: + def _updatedata(self): assert isinstance(self._item, TimeCondition) e = [] for timespec in self._timespecs: @@ -313,7 +313,7 @@ def _updatedata(self) -> None: self._item.time_specifications = e or None return super()._updatedata() - def _updateform(self) -> None: + def _updateform(self): self._tv_timeSpecs.delete(*self._tv_timeSpecs.get_children()) idx = 0 for ts in self._timespecs: diff --git a/lib/forms/cond_wmi.py b/lib/forms/cond_wmi.py index 498f082c..31656b3e 100644 --- a/lib/forms/cond_wmi.py +++ b/lib/forms/cond_wmi.py @@ -206,7 +206,7 @@ def __init__(self, tasks_available, item=None): # update the form self._updateform() - def add_check(self) -> None: + def add_check(self): assert isinstance(self._item, WMICondition) if self.data_get("index") == "": index = None @@ -257,7 +257,7 @@ def add_check(self) -> None: self._updatedata() self._updateform() - def del_check(self) -> None: + def del_check(self): assert isinstance(self._item, WMICondition) entry = self.data_get("result_selection") assert isinstance(entry, list) @@ -290,7 +290,7 @@ def del_check(self) -> None: self._updatedata() self._updateform() - def recall_check(self) -> None: + def recall_check(self): entry = self.data_get("result_selection") assert isinstance(entry, list) index = entry[0] @@ -302,7 +302,7 @@ def recall_check(self) -> None: self.data_set("operator", operator) self.data_set("value", value) - def _updatedata(self) -> None: + def _updatedata(self): assert isinstance(self._item, WMICondition) query = self.data_get("query") assert isinstance(query, str) @@ -313,15 +313,15 @@ def _updatedata(self) -> None: self.data_get("ignore_persistent_success") or None ) e = [] - for l in self._results: + for c in self._results: d = { - "field": l[1], - "operator": l[2], - "value": l[3], + "field": c[1], + "operator": c[2], + "value": c[3], } - if l[0] != "" and l[0] is not None: + if c[0] != "" and c[0] is not None: try: - index = int(l[0]) + index = int(c[0]) d["index"] = index except ValueError: self.messagebox.showerror(UI_POPUP_T_ERR, UI_POPUP_INVALIDINDEX) @@ -329,7 +329,7 @@ def _updatedata(self) -> None: self._item.result_check = e or None return super()._updatedata() - def _updateform(self) -> None: + def _updateform(self): assert isinstance(self._item, WMICondition) self.data_set("query", self._item.query) self.data_set("check_all", self._item.result_check_all or False) diff --git a/lib/forms/event.py b/lib/forms/event.py index b6d0c70a..c9b11a16 100644 --- a/lib/forms/event.py +++ b/lib/forms/event.py @@ -83,7 +83,7 @@ def __init__(self, title, conditions_available, item=None): self.reset_item() self.changed = False - def add_check_caption(self, dataname, caption) -> None: + def add_check_caption(self, dataname, caption): assert self.data_exists(dataname) self._captions[dataname] = clean_caption(caption) @@ -97,7 +97,7 @@ def _invalid_data_captions(self): else: return res - def _popup_invalid_data(self, captions) -> None: + def _popup_invalid_data(self, captions): captions.sort() capts = "- " + "\n- ".join(captions) msg = UI_POPUP_INVALIDPARAMETERS_T % capts @@ -108,7 +108,7 @@ def _popup_invalid_data(self, captions) -> None: def contents(self) -> ttk.Frame: return self._sub_contents - def _updateform(self) -> None: + def _updateform(self): if self._item: assert isinstance(self._item, Event) self.data_set("@name", self._item.name) @@ -118,7 +118,7 @@ def _updateform(self) -> None: self.data_set("@condition", "") # the data update utility loads data into the item - def _updatedata(self) -> None: + def _updatedata(self): assert isinstance(self._item, Event) name = self.data_get("@name") if name is not None: @@ -126,7 +126,7 @@ def _updatedata(self) -> None: self._item.condition = self.data_get("@condition") # set and remove the associated item - def set_item(self, item) -> None: + def set_item(self, item): assert isinstance(item, Event) try: self._item = item.__class__( @@ -135,17 +135,17 @@ def set_item(self, item) -> None: except ValueError: self._item = item # item was newly created: use it - def reset_item(self) -> None: + def reset_item(self): self._item = None # command button reactions: cancel deletes the current item so that None # is returned upon dialog close, while ok finalizes item initialization # and lets the run() function return a configured item - def exit_cancel(self) -> None: + def exit_cancel(self): self._item = None return super().exit_cancel() - def exit_ok(self) -> None: + def exit_ok(self): errs = self._invalid_data_captions() if errs is None: self._updatedata() diff --git a/lib/forms/event_dbus.py b/lib/forms/event_dbus.py index 78c9cdac..a7a3f0be 100644 --- a/lib/forms/event_dbus.py +++ b/lib/forms/event_dbus.py @@ -107,7 +107,7 @@ def __init__(self, conditions_available, item=None): # update the form self._updateform() - def _updateform(self) -> None: + def _updateform(self): assert isinstance(self._item, DBusEvent) self.data_set("bus", self._item.bus) self.data_set("rule", self._item.rule) @@ -115,7 +115,7 @@ def _updateform(self) -> None: self.data_set("parameter_check_all", self._item.parameter_check_all or False) return super()._updateform() - def _updatedata(self) -> None: + def _updatedata(self): assert isinstance(self._item, DBusEvent) parameter_check = self.data_get("parameter_check") assert isinstance(parameter_check, str) diff --git a/lib/forms/event_fschange.py b/lib/forms/event_fschange.py index b7e15f18..fd5fa21d 100644 --- a/lib/forms/event_fschange.py +++ b/lib/forms/event_fschange.py @@ -37,7 +37,7 @@ def __init__(self, conditions_available, item=None): PAD = WIDGET_PADDING_PIXELS # parameters section - ck_recursive = ttk.Checkbutton(area, text=UI_FORM_RECURSIVE_DIRSCAN) + ck_recursive = ttk.Checkbutton(area, text=UI_FORM_RECURSIVEDIRSCAN) l_monitored = ttk.Label(area, text=UI_FORM_MONITOREDFSITEMS_SC) # build a scrolled frame for the treeview sftv_monitored = ttk.Frame(area) @@ -65,7 +65,7 @@ def __init__(self, conditions_available, item=None): b_delEntry = ttk.Button( area, text=UI_DEL, width=BUTTON_STANDARD_WIDTH, command=self.del_fsitem ) - ck_selectDir = ttk.Checkbutton(area, text=UI_FORM_SELECT_DIRECTORY) + ck_selectDir = ttk.Checkbutton(area, text=UI_FORM_SELECTDIRECTORY) # arrange top items in the grid ck_recursive.grid( @@ -114,7 +114,7 @@ def _updateform(self): idx += 1 return super()._updateform() - def _updatedata(self) -> None: + def _updatedata(self): assert isinstance(self._item, FilesystemChangeEvent) self._item.recursive = self.data_get("recursive") or None e = [] @@ -123,7 +123,7 @@ def _updatedata(self) -> None: self._item.watch = e or None return super()._updatedata() - def recall_fsitem(self) -> None: + def recall_fsitem(self): e = self.data_get("item_selection") if e: entry = e[1] @@ -133,7 +133,7 @@ def recall_fsitem(self) -> None: # in this case a non-existing item is selected self.data_set("item_monitor") - def browse_fsitem(self) -> None: + def browse_fsitem(self): if self.data_get("select_dir"): entry = filedialog.askdirectory(parent=self.dialog) else: @@ -141,7 +141,7 @@ def browse_fsitem(self) -> None: if entry: self.data_set("item_monitor", entry) - def add_fsitem(self) -> None: + def add_fsitem(self): self._updatedata() i = self.data_get("item_monitor") assert isinstance(i, str) @@ -150,7 +150,7 @@ def add_fsitem(self) -> None: self._watch.append(item) self._updateform() - def del_fsitem(self) -> None: + def del_fsitem(self): self._updatedata() e = self.data_get("item_monitor") if e: diff --git a/lib/forms/history.py b/lib/forms/history.py index 7383ae52..32900643 100644 --- a/lib/forms/history.py +++ b/lib/forms/history.py @@ -53,17 +53,17 @@ def __init__(self, wrapper, main=False): }, { "text": UI_FORM_HS_TIME, - "width": 130, + "width": 160, "anchor": ttkc.CENTER, }, { "text": UI_FORM_HS_TASK, - "width": 240, + "width": 225, "anchor": ttkc.W, }, { "text": UI_FORM_HS_TRIGGER, - "width": 240, + "width": 225, "anchor": ttkc.W, }, { @@ -107,7 +107,7 @@ def __init__(self, wrapper, main=False): self._updateform() - def set_history(self, history) -> None: + def set_history(self, history): h = list( ( [ @@ -124,7 +124,7 @@ def set_history(self, history) -> None: h.reverse() self._history = h - def _updateform(self) -> None: + def _updateform(self): self._tv_history.delete_rows() for entry, outcome in self._history: icon = ( @@ -136,7 +136,7 @@ def _updateform(self) -> None: self._tv_history.insert_row("end", values=entry) # reload history data when the `reload` button is clicked - def reload(self) -> None: + def reload(self): self.set_history(self._wrapper.get_history()) self._updateform() return super().reload() diff --git a/lib/forms/menubox.py b/lib/forms/menubox.py index cb803214..6aa21c07 100644 --- a/lib/forms/menubox.py +++ b/lib/forms/menubox.py @@ -5,7 +5,6 @@ import tkinter as tk import ttkbootstrap as ttk -from ttkbootstrap.icons import Icon from ttkbootstrap_icons_mat import MatIcon as Icons from PIL import ImageTk @@ -17,7 +16,7 @@ from ..repocfg import AppConfig -from ..utility import get_image, get_icon +from ..utility import get_image # default UI values diff --git a/lib/forms/task.py b/lib/forms/task.py index 7999e840..7311584a 100644 --- a/lib/forms/task.py +++ b/lib/forms/task.py @@ -71,7 +71,7 @@ def __init__(self, title, item=None): self.reset_item() self.changed = False - def add_check_caption(self, dataname, caption) -> None: + def add_check_caption(self, dataname, caption): assert self.data_exists(dataname) self._captions[dataname] = clean_caption(caption) @@ -85,7 +85,7 @@ def _invalid_data_captions(self): else: return res - def _popup_invalid_data(self, captions) -> None: + def _popup_invalid_data(self, captions): captions.sort() capts = "- " + "\n- ".join(captions) msg = UI_POPUP_INVALIDPARAMETERS_T % capts @@ -96,7 +96,7 @@ def _popup_invalid_data(self, captions) -> None: def contents(self) -> ttk.Frame: return self._sub_contents - def _updateform(self) -> None: + def _updateform(self): if self._item: assert isinstance(self._item, Task) self.data_set("@name", self._item.name) @@ -104,14 +104,14 @@ def _updateform(self) -> None: self.data_set("@name", "") # the data update utility loads data into the item - def _updatedata(self) -> None: + def _updatedata(self): assert isinstance(self._item, Task) name = self.data_get("@name") if name is not None: self._item.name = name # set and remove the associated item - def set_item(self, item) -> None: + def set_item(self, item): assert isinstance(item, Task) try: self._item = item.__class__( @@ -120,17 +120,17 @@ def set_item(self, item) -> None: except ValueError: self._item = item # item was newly created: use it - def reset_item(self) -> None: + def reset_item(self): self._item = None # command button reactions: cancel deletes the current item so that None # is returned upon dialog close, while ok finalizes item initialization # and lets the run() function return a configured item - def exit_cancel(self) -> None: + def exit_cancel(self): self._item = None return super().exit_cancel() - def exit_ok(self) -> None: + def exit_ok(self): errs = self._invalid_data_captions() if errs is None: self._updatedata() diff --git a/lib/forms/task_command.py b/lib/forms/task_command.py index 2d2d1503..e7e2655a 100644 --- a/lib/forms/task_command.py +++ b/lib/forms/task_command.py @@ -1,6 +1,5 @@ # command task form -import sys from os.path import normpath from ..i18n.strings import * @@ -224,7 +223,7 @@ def __init__(self, item=None): self._updateform() # the data update utility loads data into the item - def _updatedata(self) -> None: + def _updatedata(self): assert isinstance(self._item, CommandTask) command = self.data_get("command") args = self.data_get("command_arguments") @@ -240,8 +239,8 @@ def _updatedata(self) -> None: v = bool(self.data_get("set_environment_variables")) self._item.set_environment_variables = False if not v else None e = {} - for l in self._envvars: - e[l[0]] = str(l[1]) + for c in self._envvars: + e[c[0]] = str(c[1]) self._item.environment_variables = e or None self._item.success_status = None self._item.success_stdout = None @@ -305,7 +304,7 @@ def _updatedata(self) -> None: self._item.case_sensitive = False if not case_sensitive else None return super()._updatedata() - def _updateform(self) -> None: + def _updateform(self): assert isinstance(self._item, CommandTask) self.data_set("varname", "") self.data_set("newvalue", "") @@ -375,7 +374,7 @@ def _updateform(self) -> None: ) return super()._updateform() - def add_var(self) -> None: + def add_var(self): assert isinstance(self._item, CommandTask) name = self.data_get("varname") value = self.data_get("newvalue") @@ -390,14 +389,14 @@ def add_var(self) -> None: ) self._envvars.append([name, value]) e = {} - for l in self._envvars: - e[l[0]] = str(l[1]) + for c in self._envvars: + e[c[0]] = str(c[1]) self._item.environment_variables = e or None self._envvars.sort(key=lambda x: x[0]) self._updatedata() self._updateform() - def del_var(self) -> None: + def del_var(self): assert isinstance(self._item, CommandTask) entry = self.data_get("envvar_selection") assert isinstance(entry, list) @@ -405,8 +404,8 @@ def del_var(self) -> None: value = entry[1] self._envvars = list(entry for entry in self._envvars if entry[0] != name) e = {} - for l in self._envvars: - e[l[0]] = str(l[1]) + for c in self._envvars: + e[c[0]] = str(c[1]) self._item.environment_variables = e or None # first update the form data, then recall the variable in the input # fields, so that the user can re-add the variable again if needed @@ -423,7 +422,7 @@ def recall_var(self): self.data_set("varname", name) self.data_set("newvalue", value) - def browse_command(self) -> None: + def browse_command(self): filetypes = [(UI_FILETYPE_ALL, ".*")] if is_windows(): exts = get_executable_extensions() @@ -434,7 +433,7 @@ def browse_command(self) -> None: if entry: self.data_set("command", entry) - def browse_startup_path(self) -> None: + def browse_startup_path(self): entry = filedialog.askdirectory(parent=self.dialog) if entry: self.data_set("startup_path", entry) diff --git a/lib/forms/task_internal.py b/lib/forms/task_internal.py index 38a8767c..f8f84a5a 100644 --- a/lib/forms/task_internal.py +++ b/lib/forms/task_internal.py @@ -32,13 +32,13 @@ def _check_command(s: str): - l = s.split(None, 1) - if len(l) == 0: + c = s.split(None, 1) + if len(c) == 0: return False - elif len(l) == 1: - command, args = l[0], "" - elif len(l) == 2: - command, args = l[0], l[1] + elif len(c) == 1: + command, args = c[0], "" + elif len(c) == 2: + command, args = c[0], c[1] else: # unreachable return False @@ -86,14 +86,14 @@ def __init__(self, item=None): # update the form self._updateform() - def _updatedata(self) -> None: + def _updatedata(self): assert isinstance(self._item, InternalCommandTask) command = self.data_get("command") assert isinstance(command, str) self._item.command = command.strip() or "" return super()._updatedata() - def _updateform(self) -> None: + def _updateform(self): assert isinstance(self._item, InternalCommandTask) self.data_set("command", self._item.command) return super()._updateform() diff --git a/lib/forms/task_lua.py b/lib/forms/task_lua.py index d4396062..297ddc0a 100644 --- a/lib/forms/task_lua.py +++ b/lib/forms/task_lua.py @@ -1,7 +1,6 @@ # Lua task form import re -import os import tkinter as tk import ttkbootstrap as ttk import ttkbootstrap.constants as ttkc @@ -15,7 +14,7 @@ from ..utility import ( guess_typed_value, get_editor_theme, - get_luadir, + get_lua_path, get_lua_initscript, ) @@ -41,27 +40,7 @@ def __init__(self, item=None): # form data self._results = [] - # update the LUA_PATH for user scripts: note that it seems that - # LUA_PATH as a global variable to search modulesseems to have been - # replaced by the `package.path` table element,thus the need to use - # the initialization script; also, the default Lua search path is - # not added to the embedded Lua path, in order to avoid to search - # for unsupported or possibly binary modules - luabase = get_luadir() - ps = os.path.sep - lua_path = ";".join( - [ - "?", - "?.lua", - f"{luabase}{ps}?", - f"{luabase}{ps}?.lua", - f"{luabase}{ps}?{ps}?", - f"{luabase}{ps}?{ps}?.lua", - ] - ) - self._item.variables_to_set = { - "LUA_PATH": lua_path, - } + self._item.variables_to_set = {"LUA_PATH": get_lua_path()} # legacy self._item.init_script_path = get_lua_initscript() # build the UI: build widgets, arrange them in the box, bind data @@ -161,7 +140,7 @@ def __init__(self, item=None): # update the form self._updateform() - def add_var(self) -> None: + def add_var(self): assert isinstance(self._item, LuaScriptTask) name = self.data_get("varname") value = self.data_get("newvalue") @@ -176,8 +155,8 @@ def add_var(self) -> None: ) self._results.append([name, value]) e = {} - for l in self._results: - e[l[0]] = str(l[1]) + for c in self._results: + e[c[0]] = str(c[1]) self._item.expected_results = e or None self._results.sort(key=lambda x: x[0]) self._updatedata() @@ -185,7 +164,7 @@ def add_var(self) -> None: else: self.messagebox.showerror(UI_POPUP_T_ERR, UI_POPUP_INVALIDVARNAME) - def del_var(self) -> None: + def del_var(self): assert isinstance(self._item, LuaScriptTask) entry = self.data_get("luavar_selection") assert isinstance(entry, list) @@ -193,8 +172,8 @@ def del_var(self) -> None: value = entry[1] self._results = list(entry for entry in self._results if entry[0] != name) e = {} - for l in self._results: - e[l[0]] = str(l[1]) + for c in self._results: + e[c[0]] = str(c[1]) self._item.expected_results = e or None # first update the form data, then recall the variable in the input # fields, so that the user can re-add the variable again if needed @@ -203,7 +182,7 @@ def del_var(self) -> None: self.data_set("varname", name) self.data_set("newvalue", value) - def recall_var(self) -> None: + def recall_var(self): entry = self.data_get("luavar_selection") assert isinstance(entry, list) name = entry[0] @@ -211,19 +190,19 @@ def recall_var(self) -> None: self.data_set("varname", name) self.data_set("newvalue", value) - def _updatedata(self) -> None: + def _updatedata(self): assert isinstance(self._item, LuaScriptTask) script = self.data_get("script") assert isinstance(script, str) self._item.script = script.strip() or "" self._item.expect_all = bool(self.data_get("expect_all")) or None e = {} - for l in self._results: - e[l[0]] = guess_typed_value(str(l[1])) + for c in self._results: + e[c[0]] = guess_typed_value(str(c[1])) self._item.expected_results = e or None return super()._updatedata() - def _updateform(self) -> None: + def _updateform(self): assert isinstance(self._item, LuaScriptTask) self.data_set("script", self._item.script) self.data_set("expect_all", self._item.expect_all or False) diff --git a/lib/forms/ui.py b/lib/forms/ui.py index 59ce7cbd..2a87f133 100644 --- a/lib/forms/ui.py +++ b/lib/forms/ui.py @@ -5,7 +5,6 @@ # from tkinter import ttk import ttkbootstrap as ttk from ttkbootstrap import dialogs -import ttkbootstrap.constants as ttkc from ttkbootstrap.icons import Icon from ttkbootstrap_icons_mat import MatIcon as Icons @@ -473,7 +472,7 @@ def __init__( def __enter__(self): return self - def __exit__(self, _exc_type, _exc_value, _traceback) -> None: + def __exit__(self, _exc_type, _exc_value, _traceback): del self._dialog # contents is the root for slave widgets @@ -492,7 +491,7 @@ def messagebox(self) -> MessageBox: # internals # force a variable value (and mimic ttk *Var retrieval method used below) - def _force_set_data(self, name: str, value: Any) -> None: + def _force_set_data(self, name: str, value: Any): class _Elem(object): def __init__(self, v): self._v = v @@ -505,7 +504,7 @@ def get(self): # bind a ttk.Treeview to a variable: it involves defining a new # function and reacting to a (virtual) event - def _bind_ttk_treeview(self, name: str, treeview: ttk.Treeview) -> None: + def _bind_ttk_treeview(self, name: str, treeview: ttk.Treeview): def _store_data(event): iid = treeview.focus() data = treeview.item(iid)["values"] @@ -515,19 +514,19 @@ def _store_data(event): treeview.bind("<>", _store_data) # bind an event to this form - def event_bind(self, event, reaction) -> None: + def event_bind(self, event, reaction): self._dialog.bind(event, reaction) # bind and when gaining focus and unbind when n is lost - def _key_exit_close(self, event) -> None: + def _key_exit_close(self, event): if event.widget == self._dialog: self.exit_close() - def _key_exit_ok(self, event) -> None: + def _key_exit_ok(self, event): if event.widget == self._dialog: self.exit_ok() - def focus_in(self) -> None: + def focus_in(self): self.event_bind("", self._key_exit_close) self.event_bind("", self._key_exit_ok) @@ -548,7 +547,7 @@ def data_bind( widget: tk.Widget | tuple[ttk.Radiobutton, ...], dtype: str | None = None, check: Callable[..., bool] | None = None, - ) -> None: + ): # as per documentation, the configure() method is not the preferred # method to configure a widget in tkinter, and direct access to the # widget keys (as if it were a dict) is used in the examples @@ -663,7 +662,7 @@ def data_get(self, dataname: str, default: Any | None = None): return default # set the value of a widget: None is used to clear the widget - def data_set(self, dataname: str, value: Any | None = None) -> None: + def data_set(self, dataname: str, value: Any | None = None): if dataname in self._data: try: if value is None: @@ -701,7 +700,7 @@ def data_valid(self, dataname: str) -> bool: return False # set autocheck feature on or off - def set_autocheck(self, c: bool) -> None: + def set_autocheck(self, c: bool): self._autocheck = bool(c) # return a list of the widget-bound variables @@ -712,65 +711,65 @@ def data_vars(self): # exit functions are predefined and may be overridden or not: the # default implementation destroys the window and: if OK leave form # data accessible, otherwise clear form data - def exit_ok(self) -> None: + def exit_ok(self): self._dialog.destroy() - def exit_cancel(self) -> None: + def exit_cancel(self): self._data = {} self._dialog.destroy() - def exit_close(self) -> None: + def exit_close(self): self._dialog.destroy() - def exit_quit(self) -> None: + def exit_quit(self): self._dialog.destroy() # other button reactions have to be overridden because the default # implementation just does nothing - def load(self) -> None: + def load(self): pass - def save(self) -> None: + def save(self): pass - def new(self) -> None: + def new(self): pass - def add(self) -> None: + def add(self): pass - def delete(self) -> None: + def delete(self): pass - def edit(self) -> None: + def edit(self): pass - def modify(self) -> None: + def modify(self): pass - def reset(self) -> None: + def reset(self): pass - def reload(self) -> None: + def reload(self): pass - def remove(self) -> None: + def remove(self): pass # the following utilities allow to enable or disable the default buttons - def enable_buttons(self, *names: str) -> None: + def enable_buttons(self, *names: str): for name in map(str.lower, names): if name in self._std_buttons: self._std_buttons[name].enable(True) - def disable_buttons(self, *names: str) -> None: + def disable_buttons(self, *names: str): for name in map(str.lower, names): if name in self._std_buttons: self._std_buttons[name].enable(False) # main dialog loop: the initial dialog will actually have a main loop # while the following ones will just be spawned and then waited for - def run(self) -> None: + def run(self): if self._main: self._dialog.mainloop() else: diff --git a/lib/i18n/localizer.py b/lib/i18n/localizer.py index 10d985ae..a632b5a0 100644 --- a/lib/i18n/localizer.py +++ b/lib/i18n/localizer.py @@ -381,7 +381,7 @@ "dutch_netherlands": "nl", "nl_nl": "nl", "nl-nl": "nl", # Norwegian - "norwegian_bokmål": "no", "norwegian_nynorsk": "no", "no_no": "no", "no-no": "no", + "norwegian_bokmål": "no", "norwegian_nynorsk": "no", "norwegian_norway": "no", "no_no": "no", "no-no": "no", # Polish @@ -464,7 +464,7 @@ def get_locale(force_locale=None): if cur_locale in locale_map.keys(): _CURRENT_SHORT_LOCALE = locale_map[cur_locale] return locale_map[cur_locale] - except: + except Exception: pass return _CURRENT_SHORT_LOCALE diff --git a/lib/i18n/strings.py b/lib/i18n/strings.py index cb36a680..d0f28819 100644 --- a/lib/i18n/strings.py +++ b/lib/i18n/strings.py @@ -15,7 +15,7 @@ CLI_APP = "when" UI_APP_LABEL = "When Automation Tool" UI_APP_COPYRIGHT = "© 2023-2026 Francesco Garosi" -UI_APP_VERSION = "2.0.3" +UI_APP_VERSION = "2.1.1" # other strings that should not be translated UI_WHENEVER = "whenever" @@ -61,7 +61,7 @@ def which_locale() -> str | None: if _short_locale is not None: try: exec(f"from .strings_{_short_locale} import *") - except: + except Exception: pass diff --git a/lib/i18n/strings_base.py b/lib/i18n/strings_base.py index 0c033280..7c7d9465 100644 --- a/lib/i18n/strings_base.py +++ b/lib/i18n/strings_base.py @@ -17,6 +17,8 @@ ITEM_COND_DBUS = "DBus Inspection Based Condition" ITEM_COND_WMI = "WMI Query Based Condition" ITEM_COND_EVENT = "Event Based Condition" +ITEM_COND_MCRT = "Condition Activated by Other Conditions" +ITEM_COND_STARTUP = "Startup Condition" ITEM_EVENT = "Event" ITEM_EVENT_FSCHANGE = "Filesystem Monitoring Based Event" @@ -203,6 +205,7 @@ UI_FORM_WMI_QUERY_SC = "WMI Query (WQL):" UI_FORM_WMI_RESULT_CHECKS_SC = "Result checks:" UI_FORM_WMI_NAMESPACE_SC = "WMI Namespace:" +UI_FORM_MCRT_ACTIVATINGCONDS_SC = "Activating conditions:" UI_FORM_ITEMTYPE_SC = "Choose item type:" UI_FORM_ITEMSUBTYPES_SC = "Available items:" @@ -228,9 +231,10 @@ UI_FORM_TIMESPECS = "Time specifications" UI_FORM_EXPECTRESULTS = "Expected results" UI_FORM_MATCHALLRESULTS = "Match ALL results" -UI_FORM_RECURSIVE_DIRSCAN = "Recursively scan directories" -UI_FORM_SELECT_DIRECTORY = "Use button to select directories" +UI_FORM_RECURSIVEDIRSCAN = "Recursively scan directories" +UI_FORM_SELECTDIRECTORY = "Use button to select directories" UI_FORM_IGNOREPERSISTSUCCESS = "Ignore persistently successful checks" +UI_FORM_ACTIVATEFURTHERCONDS = "Activate further conditions" UI_FORM_OR = "or" UI_FORM_FILELOCATION_SC = "File:" @@ -257,6 +261,7 @@ UI_TITLE_TIMECOND = f"{UI_APP}: Time Condition Editor" UI_TITLE_DBUSCOND = f"{UI_APP}: DBus Method Condition Editor" UI_TITLE_WMICOND = f"{UI_APP}: WMI Query Condition Editor" +UI_TITLE_MCRTCOND = f"{UI_APP}: Condition Activated by Other Conditions Editor" UI_TITLE_DBUSEVENT = f"{UI_APP}: DBus Signal Event Editor" UI_TITLE_FSCHANGEEVENT = f"{UI_APP}: Filesystem Monitoring Event Editor" @@ -294,16 +299,16 @@ UI_POPUP_EMPTYCHECKVALUE = "No value provided for check" UI_POPUP_INVALIDFILEORDIR = "Invalid file or directory name" UI_POPUP_INVALIDTIMESPEC = "Invalid or missing time specification" -UI_POPUP_REFERENCEDTASK = "The Task is still referenced in at least\none Condition: remove any references\nbefore attempting to delete it" -UI_POPUP_REFERENCEDCOND = "The Condition is still referenced in at least\none Event: remove any references before\nattempting to delete it" +UI_POPUP_REFERENCEDTASK = "The task is still referenced in at least one condition: remove any references before attempting to delete it" +UI_POPUP_REFERENCEDCOND = "The condition is still referenced in at least one event: remove any references before attempting to delete it" UI_POPUP_MISSINGEVENTCOND = "No condition specified for event" UI_POPUP_INVALIDOPERATOR = "The specified operator is not valid" UI_POPUP_INVALIDINDEX = "The specified index is not valid" UI_POPUP_INVALIDFIELD = "The specified field name is not valid" UI_POPUP_WARNFIXCONFIG = f"""\ -One or more items are not recognized: if the configuration -has been generated with a previous version of {UI_APP}, you may -need to run `{CLI_APP} tool --fix-config` from the command line. +One or more items are not recognized: if the configuration \ +has been generated with a previous version of {UI_APP}, you may \ +need to run `{CLI_APP} tool --fix-config` from the command line.\ """ diff --git a/lib/i18n/strings_de.py b/lib/i18n/strings_de.py index 23d35d41..92070391 100644 --- a/lib/i18n/strings_de.py +++ b/lib/i18n/strings_de.py @@ -17,6 +17,8 @@ ITEM_COND_DBUS = "DBUS-Inspektion basierte Kondition" ITEM_COND_WMI = "WMI-Query basierte Kondition" ITEM_COND_EVENT = "Ereignis basierte Kondition" +ITEM_COND_MCRT = "Kondition durch andere Konditionen aktivierte" +ITEM_COND_STARTUP = "Anfangkondition" ITEM_EVENT = "Ereignis" ITEM_EVENT_FSCHANGE = "Dateisystem Überwachung basiertes Ereignis" @@ -203,6 +205,7 @@ UI_FORM_WMI_QUERY_SC = "WMI-Query (WQL):" UI_FORM_WMI_RESULT_CHECKS_SC = "Ergebnisprüfungen:" UI_FORM_WMI_NAMESPACE_SC = "WMI-Namespace:" +UI_FORM_MCRT_ACTIVATINGCONDS_SC = "Aktivierende Konditionen:" UI_FORM_ITEMTYPE_SC = "Elementtyp wählen:" UI_FORM_ITEMSUBTYPES_SC = "Verfügbare Elemente:" @@ -228,9 +231,10 @@ UI_FORM_TIMESPECS = "Zeitspezifikationen" UI_FORM_EXPECTRESULTS = "Erwartete Ergebnisse" UI_FORM_MATCHALLRESULTS = "Alle Ergebnisse übereinstimmen" -UI_FORM_RECURSIVE_DIRSCAN = "Verzeichnisse rekursiv scannen" -UI_FORM_SELECT_DIRECTORY = "Schaltfläche verwenden, um Verzeichnisse auszuwählen" +UI_FORM_RECURSIVEDIRSCAN = "Verzeichnisse rekursiv scannen" +UI_FORM_SELECTDIRECTORY = "Schaltfläche verwenden, um Verzeichnisse auszuwählen" UI_FORM_IGNOREPERSISTSUCCESS = "Anhaltend erfolgreiche Ergebnisse übergehen" +UI_FORM_ACTIVATEFURTHERCONDS = "Weitere Konditionen aktivieren" UI_FORM_OR = "oder" UI_FORM_FILELOCATION_SC = "Datei:" @@ -257,6 +261,7 @@ UI_TITLE_TIMECOND = f"{UI_APP}: Zeitkondition Bearbeitung" UI_TITLE_DBUSCOND = f"{UI_APP}: DBUS-Methode Kondition Bearbeitung" UI_TITLE_WMICOND = f"{UI_APP}: WMI-Query Kondition Bearbeitung" +UI_TITLE_MCRTCOND = f"{UI_APP}: Kondition durch andere Konditionen aktivierte Bearbeitung" UI_TITLE_DBUSEVENT = f"{UI_APP}: DBUS-Signal Ereignis Bearbeitung" UI_TITLE_FSCHANGEEVENT = f"{UI_APP}: Dateisystemüberwachungsereignis Bearbeitung" @@ -294,13 +299,13 @@ UI_POPUP_EMPTYCHECKVALUE = "Kein Wert für die Prüfung bereitgestellt" UI_POPUP_INVALIDFILEORDIR = "Ungültiger Datei- oder Verzeichnisname" UI_POPUP_INVALIDTIMESPEC = "Ungültige oder fehlende Zeitspezifikation" -UI_POPUP_REFERENCEDTASK = "Der Task wird zumindest immer noch verwiesen\neine Kondition: entfernen Sie alle Referenzen\nbevor Sie versuchen, ihn zu löschen" -UI_POPUP_REFERENCEDCOND = "Die Kondition wird zumindest immer noch verwiesen\nein Ereignis: entfernen Sie vor Beweisvorgängen vorher\nversuch, ihn zu löschen" +UI_POPUP_REFERENCEDTASK = "Der Task wird zumindest immer noch in einer Kondition verwendet: entfernen Sie alle Referenzen bevor Sie versuchen, ihn zu löschen" +UI_POPUP_REFERENCEDCOND = "Die Kondition wird zumindest immer noch in einem Ereignis verwendet: entfernen Sie alle Referenzen bevor Sie versuchen, ihn zu löschen" UI_POPUP_MISSINGEVENTCOND = "Keine Kondition für das Ereignis angegeben" UI_POPUP_INVALIDOPERATOR = "Der angegebene Operator ist nicht gültig" UI_POPUP_INVALIDINDEX = "Der angegebene Index ist nicht gültig" UI_POPUP_INVALIDFIELD = "Der angegebene Feldname ist nicht gültig" -UI_POPUP_WARNFIXCONFIG = f"Ein oder mehrere Elemente werden nicht erkannt: wenn die Konfiguration\nwurde mit einer früheren Version von {UI_APP} generiert, sollten Sie vielleicht\n`{CLI_APP} tool --fix-config` durch die Befehlszeile anwenden." +UI_POPUP_WARNFIXCONFIG = f"Ein oder mehrere Elemente werden nicht erkannt: wenn die Konfiguration wurde mit einer früheren Version von {UI_APP} generiert, sollten Sie vielleicht `{CLI_APP} tool --fix-config` durch die Befehlszeile anwenden." # about box diff --git a/lib/i18n/strings_en.py b/lib/i18n/strings_en.py index 0c033280..7c7d9465 100644 --- a/lib/i18n/strings_en.py +++ b/lib/i18n/strings_en.py @@ -17,6 +17,8 @@ ITEM_COND_DBUS = "DBus Inspection Based Condition" ITEM_COND_WMI = "WMI Query Based Condition" ITEM_COND_EVENT = "Event Based Condition" +ITEM_COND_MCRT = "Condition Activated by Other Conditions" +ITEM_COND_STARTUP = "Startup Condition" ITEM_EVENT = "Event" ITEM_EVENT_FSCHANGE = "Filesystem Monitoring Based Event" @@ -203,6 +205,7 @@ UI_FORM_WMI_QUERY_SC = "WMI Query (WQL):" UI_FORM_WMI_RESULT_CHECKS_SC = "Result checks:" UI_FORM_WMI_NAMESPACE_SC = "WMI Namespace:" +UI_FORM_MCRT_ACTIVATINGCONDS_SC = "Activating conditions:" UI_FORM_ITEMTYPE_SC = "Choose item type:" UI_FORM_ITEMSUBTYPES_SC = "Available items:" @@ -228,9 +231,10 @@ UI_FORM_TIMESPECS = "Time specifications" UI_FORM_EXPECTRESULTS = "Expected results" UI_FORM_MATCHALLRESULTS = "Match ALL results" -UI_FORM_RECURSIVE_DIRSCAN = "Recursively scan directories" -UI_FORM_SELECT_DIRECTORY = "Use button to select directories" +UI_FORM_RECURSIVEDIRSCAN = "Recursively scan directories" +UI_FORM_SELECTDIRECTORY = "Use button to select directories" UI_FORM_IGNOREPERSISTSUCCESS = "Ignore persistently successful checks" +UI_FORM_ACTIVATEFURTHERCONDS = "Activate further conditions" UI_FORM_OR = "or" UI_FORM_FILELOCATION_SC = "File:" @@ -257,6 +261,7 @@ UI_TITLE_TIMECOND = f"{UI_APP}: Time Condition Editor" UI_TITLE_DBUSCOND = f"{UI_APP}: DBus Method Condition Editor" UI_TITLE_WMICOND = f"{UI_APP}: WMI Query Condition Editor" +UI_TITLE_MCRTCOND = f"{UI_APP}: Condition Activated by Other Conditions Editor" UI_TITLE_DBUSEVENT = f"{UI_APP}: DBus Signal Event Editor" UI_TITLE_FSCHANGEEVENT = f"{UI_APP}: Filesystem Monitoring Event Editor" @@ -294,16 +299,16 @@ UI_POPUP_EMPTYCHECKVALUE = "No value provided for check" UI_POPUP_INVALIDFILEORDIR = "Invalid file or directory name" UI_POPUP_INVALIDTIMESPEC = "Invalid or missing time specification" -UI_POPUP_REFERENCEDTASK = "The Task is still referenced in at least\none Condition: remove any references\nbefore attempting to delete it" -UI_POPUP_REFERENCEDCOND = "The Condition is still referenced in at least\none Event: remove any references before\nattempting to delete it" +UI_POPUP_REFERENCEDTASK = "The task is still referenced in at least one condition: remove any references before attempting to delete it" +UI_POPUP_REFERENCEDCOND = "The condition is still referenced in at least one event: remove any references before attempting to delete it" UI_POPUP_MISSINGEVENTCOND = "No condition specified for event" UI_POPUP_INVALIDOPERATOR = "The specified operator is not valid" UI_POPUP_INVALIDINDEX = "The specified index is not valid" UI_POPUP_INVALIDFIELD = "The specified field name is not valid" UI_POPUP_WARNFIXCONFIG = f"""\ -One or more items are not recognized: if the configuration -has been generated with a previous version of {UI_APP}, you may -need to run `{CLI_APP} tool --fix-config` from the command line. +One or more items are not recognized: if the configuration \ +has been generated with a previous version of {UI_APP}, you may \ +need to run `{CLI_APP} tool --fix-config` from the command line.\ """ diff --git a/lib/i18n/strings_fr.py b/lib/i18n/strings_fr.py index d4f80b56..17076a28 100644 --- a/lib/i18n/strings_fr.py +++ b/lib/i18n/strings_fr.py @@ -17,6 +17,8 @@ ITEM_COND_DBUS = "Condition basée sur l'inspection du DBUS" ITEM_COND_WMI = "Condition basée sur une requête WMI" ITEM_COND_EVENT = "Condition basée sur un événement" +ITEM_COND_MCRT = "Condition activée par autres conditions" +ITEM_COND_STARTUP = "Condition initiale" ITEM_EVENT = "Événement" ITEM_EVENT_FSCHANGE = "Événement basé sur la surveillance du système de fichiers" @@ -203,6 +205,7 @@ UI_FORM_WMI_QUERY_SC = "WMI Query (WQL):" UI_FORM_WMI_RESULT_CHECKS_SC = "Vérification des résultats:" UI_FORM_WMI_NAMESPACE_SC = "WMI Namespace:" +UI_FORM_MCRT_ACTIVATINGCONDS_SC = "Conditions qui activent:" UI_FORM_ITEMTYPE_SC = "Choisisser le type d'élément:" UI_FORM_ITEMSUBTYPES_SC = "Éléments disponibles:" @@ -228,9 +231,10 @@ UI_FORM_TIMESPECS = "Spécifications de temps" UI_FORM_EXPECTRESULTS = "Résultats attendus" UI_FORM_MATCHALLRESULTS = "Faites correspondre tous les résultats" -UI_FORM_RECURSIVE_DIRSCAN = "Contrôler les répertoires récursivement" -UI_FORM_SELECT_DIRECTORY = "Utiliser le bouton pour sélectionner un répertoire" +UI_FORM_RECURSIVEDIRSCAN = "Contrôler les répertoires récursivement" +UI_FORM_SELECTDIRECTORY = "Utiliser le bouton pour sélectionner un répertoire" UI_FORM_IGNOREPERSISTSUCCESS = "Ignorer les cas de succès persistent" +UI_FORM_ACTIVATEFURTHERCONDS = "Activer des autres conditions" UI_FORM_OR = "ou" UI_FORM_FILELOCATION_SC = "Fichier:" @@ -257,6 +261,7 @@ UI_TITLE_TIMECOND = f"{UI_APP}: éditeur de condition de temps" UI_TITLE_DBUSCOND = f"{UI_APP}: éditeur de condition de méthode DBUS" UI_TITLE_WMICOND = f"{UI_APP}: éditeur de condition de requête WMI" +UI_TITLE_MCRTCOND = f"{UI_APP}: éditeur de condition activée par autres conditions" UI_TITLE_DBUSEVENT = f"{UI_APP}: éditeur d'événements Signal DBUS" UI_TITLE_FSCHANGEEVENT = f"{UI_APP}: éditeur d'événements de surveillance du système de fichiers" @@ -294,13 +299,13 @@ UI_POPUP_EMPTYCHECKVALUE = "Aucune valeur prévue pour le test" UI_POPUP_INVALIDFILEORDIR = "Nom de fichier ou de répertoire non valide" UI_POPUP_INVALIDTIMESPEC = "Spécification de temps non valide ou manquante" -UI_POPUP_REFERENCEDTASK = "La task est toujours référencée au moins\nUne condition: supprimez toutes les références\navant d'essayer de le supprimer" -UI_POPUP_REFERENCEDCOND = "La condition est toujours référencée au moins\nUn événement: supprimez toutes les références avant\nd'essayer de le supprimer" +UI_POPUP_REFERENCEDTASK = "Le task est toujours référencée au moins dans une condition: supprimez toutes les références avant d'essayer de l'éliminer'" +UI_POPUP_REFERENCEDCOND = "La condition est toujours référencée au moins dans un événement: supprimez toutes les références avant d'essayer de l'éliminer" UI_POPUP_MISSINGEVENTCOND = "Aucune condition spécifiée pour l'événement" UI_POPUP_INVALIDOPERATOR = "L'opérateur spécifié n'est pas valide" UI_POPUP_INVALIDINDEX = "L'indice spécifié n'est pas valide" UI_POPUP_INVALIDFIELD = "Le nom de champ spécifié n'est pas valide" -UI_POPUP_WARNFIXCONFIG = f"Un ou plusieurs éléments ne sont pas reconnus: si la configuration\na été généré avec une version précédente de {UI_APP}, vous pouvez\navoir besoin d'exécuter l'outil `{CLI_APP} --fix-config` à partir de la ligne de commande." +UI_POPUP_WARNFIXCONFIG = f"Un ou plusieurs éléments ne sont pas reconnus: si la configuration a été généré avec une version précédente de {UI_APP}, vous pouvez avoir besoin d'exécuter l'outil `{CLI_APP} --fix-config` à partir de la ligne de commande." # about box diff --git a/lib/i18n/strings_it.py b/lib/i18n/strings_it.py index 118d4de1..d6027361 100644 --- a/lib/i18n/strings_it.py +++ b/lib/i18n/strings_it.py @@ -17,6 +17,8 @@ ITEM_COND_DBUS = "Condizione basata sull'ispezione di DBUS" ITEM_COND_WMI = "Condizione basata su query WMI" ITEM_COND_EVENT = "Condizione basata su evento" +ITEM_COND_MCRT = "Condizione attivata da altre condizioni" +ITEM_COND_STARTUP = "Condizione iniziale" ITEM_EVENT = "Evento" ITEM_EVENT_FSCHANGE = "Evento basato su monitoraggio del filesystem" @@ -203,6 +205,7 @@ UI_FORM_WMI_QUERY_SC = "Query WMI (WQL):" UI_FORM_WMI_RESULT_CHECKS_SC = "Verifiche risultati:" UI_FORM_WMI_NAMESPACE_SC = "Namespace WMI:" +UI_FORM_MCRT_ACTIVATINGCONDS_SC = "Attivato dalle condizioni:" UI_FORM_ITEMTYPE_SC = "Scegli il tipo di elemento:" UI_FORM_ITEMSUBTYPES_SC = "Elementi disponibili:" @@ -228,9 +231,10 @@ UI_FORM_TIMESPECS = "Specifiche temporali" UI_FORM_EXPECTRESULTS = "Risultati attesi" UI_FORM_MATCHALLRESULTS = "Verifica tutti i risultati" -UI_FORM_RECURSIVE_DIRSCAN = "Scansione ricorsiva directory" -UI_FORM_SELECT_DIRECTORY = "Usa il bottone per selezionare la directory" +UI_FORM_RECURSIVEDIRSCAN = "Scansione ricorsiva directory" +UI_FORM_SELECTDIRECTORY = "Usa il bottone per selezionare la directory" UI_FORM_IGNOREPERSISTSUCCESS = "Ignora successi persistenti" +UI_FORM_ACTIVATEFURTHERCONDS = "Attiva ulteriori condizioni" UI_FORM_OR = "o" UI_FORM_FILELOCATION_SC = "File:" @@ -257,6 +261,7 @@ UI_TITLE_TIMECOND = f"{UI_APP}: Editor di condizioni basate sul tempo" UI_TITLE_DBUSCOND = f"{UI_APP}: Editor di condizioni basate su chiamata DBUS" UI_TITLE_WMICOND = f"{UI_APP}: Editor di condizioni basate su query WMI" +UI_TITLE_MCRTCOND = f"{UI_APP}: Editor di condizioni attivate da altre condizioni" UI_TITLE_DBUSEVENT = f"{UI_APP}: Editor degli eventi di tipo DBUS" UI_TITLE_FSCHANGEEVENT = f"{UI_APP}: Editor degli eventi di monitoraggio del filesystem" @@ -294,13 +299,13 @@ UI_POPUP_EMPTYCHECKVALUE = "Nessun valore fornito per la verifica" UI_POPUP_INVALIDFILEORDIR = "File o nome della directory non valido" UI_POPUP_INVALIDTIMESPEC = "Specifiche di tempo non valide o mancanti" -UI_POPUP_REFERENCEDTASK = "Il task è ancora citato in almeno\nUna condizione: rimuovere qualsiasi riferimento\nPrima di tentare di eliminarlo" -UI_POPUP_REFERENCEDCOND = "La condizione è ancora citata in almeno\nUn evento: rimuovere qualsiasi riferimento prima\ndi tentare di eliminarla" +UI_POPUP_REFERENCEDTASK = "Il task è ancora citato in almeno una condizione: rimuovere qualsiasi riferimento prima di tentare di eliminarlo" +UI_POPUP_REFERENCEDCOND = "La condizione è ancora citata in almeno un evento: rimuovere qualsiasi riferimento prima di tentare di eliminarla" UI_POPUP_MISSINGEVENTCOND = "Nessuna condizione specificata per l'evento" UI_POPUP_INVALIDOPERATOR = "L'operatore specificato non è valido" UI_POPUP_INVALIDINDEX = "L'indice specificato non è valido" UI_POPUP_INVALIDFIELD = "Il nome del campo specificato non è valido" -UI_POPUP_WARNFIXCONFIG = f"Uno o più elementi non sono stati riconosciuti: se la configurazione\nè stata generata con una versione precedente di {UI_APP}, può\nessere necessario eseguire `{CLI_APP} tool --fix-config` dalla riga di comando." +UI_POPUP_WARNFIXCONFIG = f"Uno o più elementi non sono stati riconosciuti: se la configurazione è stata generata con una versione precedente di {UI_APP}, può essere necessario eseguire `{CLI_APP} tool --fix-config` dalla riga di comando." # about box diff --git a/lib/internal/cond_startup.py b/lib/internal/cond_startup.py new file mode 100644 index 00000000..9a4763b7 --- /dev/null +++ b/lib/internal/cond_startup.py @@ -0,0 +1,105 @@ +# interval condition item + +from lib.i18n.strings import * + +from tomlkit import items, table + +import tkinter as tk +import ttkbootstrap as ttk + +from ..forms.ui import * + +# since a condition is defined, the base form is the one for conditions +from ..forms.cond import form_Condition + +from ..items.cond_interval import IntervalCondition +from ..items.itemhelp import CheckedTable, ConfigurationError + + +# an interval based condition +class StartupCondition(IntervalCondition): + + # availability at class level + item_type = "interval" + item_subtype = "startup" + item_hrtype = ITEM_COND_STARTUP + available = False + + def __init__(self, t: items.Table | None = None): + # first initialize the base class + IntervalCondition.__init__(self, t) + + # then set type (same as base), subtype and human readable name + self.type = self.item_type + self.subtype = self.item_subtype + self.hrtype = self.item_hrtype + if t: + assert t.get("type") == self.type + self.tags = t.get("tags", table()) + assert isinstance(self.tags, items.Table) + assert self.tags.get("subtype") == self.subtype + else: + self.tags = table() + self.tags.append("subtype", self.subtype) + self.interval_seconds = 0 + + def load_checking( + self, item: items.Table, item_line: int, tasks: list[str] | None = None + ): + try: + super().load_checking(item, item_line, tasks) + # ignore the erro on `interval_seconds` + except ConfigurationError as e: + if e.entry_name != "interval_seconds": + raise e + self.type = self.item_type + self.subtype = self.item_subtype + self.hrtype = self.item_hrtype + tab = CheckedTable(item, item_line) + assert tab.get_str("type") == self.type + # since this is used for checks only, check that this is still zero + self.interval_seconds = tab.get_int_between( + "interval_seconds", 0, 0, mandatory=True + ) + + @classmethod + def check_tags(cls, tags): + missing = [] + errors = [] + if errors or missing: + return (errors, missing) + return None + + def as_table(self): + return IntervalCondition.as_table(self) + + +# specialized subform: it will never be shown because the item is not available +class form_StartupCondition(form_Condition): + + def __init__(self, tasks_available, item=None): + if item: + assert isinstance(item, StartupCondition) + else: + item = StartupCondition() + super().__init__(UI_TITLE_EVENTCOND, tasks_available, item) + assert isinstance(self._item, StartupCondition) + + # build the UI: build widgets, arrange them in the box, bind data + + # client area + area = ttk.Frame(super().contents) + area.grid(row=0, column=0, sticky=tk.NSEW) + PAD = WIDGET_PADDING_PIXELS + + # widgets section + l_noParams = ttk.Label(area, text=UI_CAPTION_NOSPECIFICPARAMS) + + # arrange items in the grid + l_noParams.grid(row=0, column=0, sticky=tk.W, padx=PAD, pady=PAD) + + # update the form + self._updateform() + + +# end. diff --git a/lib/internal/multi_conds_run_task.py b/lib/internal/multi_conds_run_task.py new file mode 100644 index 00000000..6f17add8 --- /dev/null +++ b/lib/internal/multi_conds_run_task.py @@ -0,0 +1,363 @@ +# multiple conditions to run tasks (aka confluence) +# +# private items that enable the condition "confluence" feature (issue #187), +# that is the possibility for a task to depend on more than one condition. +# +# The idea is to have a persistence store where *all* the conditions that are +# defined as concurring to the triggering of tasks are recorded: this store is +# periodically read, and when all the conditions that concur to a specific +# task (or list/set of tasks) are found in the store, the following happens: +# +# 1. the conditions are _removed_ from the store +# 2. the corresponding task or list/set of tasks is executed +# +# For this purpose, the conditions that concur to tasks will all be bound to +# a single Lua-based task, which writes their name in the store, while multiple +# conditions will be built for each task group to be activated by multiple +# conditions. +# +# The persistence store does not need to be structured, it only has to be read, +# operated on, and written quickly. No concurrent reads or writes should be +# allowed, assuming that one instance of a verified condition cannot concur to +# multiple task groups (this is arbitrary, but cleaner than the opposite), and +# that once a certain set of conditions that concur to a task group is verified +# it has to be atomically removed from the list. +# +# The condition names are "mangled" before being written to the persistence +# file, but the mangling is simple and effective: it only consists of a pair +# of colons around the name. This ensures that no name given to other items +# can conflict with the mangled name, since brackets are not allowed chars +# for item names. This also allow for a quick and precise search without the +# need for special considerations: if the name of a condition is "Cond1", for +# instance, searching for the string ":Cond1:" will match only the presence +# of "Cond1" and not, for instance, "ThatCond1" - which is mangled, instead, +# as ":ThatCond1:". This also eliminates the need for multiple lines, regex +# search, and other complications. The colon character is used because it has +# no special meaning in Lua pattern syntax, so the `string.gsub` function can +# be used. + +from ..i18n.strings import * + +from tomlkit import items, table + +import tkinter as tk +import ttkbootstrap as ttk +import ttkbootstrap.constants as ttkc + +from ..forms.ui import * + +# since a condition is defined, the base form is the one for conditions +from ..forms.cond import form_Condition + +from ..utility import ( + get_lua_initscript, + get_lua_path, + get_private_item_name_prefix, + clean_caption, +) + +from ..items import cond, task_lua, cond_lua, cond_interval + +from .cond_startup import StartupCondition + + +# constants +_MCRT_LIBRARY = "_mcrt_lib" + +_MCRT_EXTRA_DELAY = 15 + + +# this is the prefix for all of our item names +_ITEM_PREFIX = get_private_item_name_prefix() + "MCRT_" + +# specific names, local to this module +_TASK_INITIALIZER = _ITEM_PREFIX + "Initializer" +_TASK_UPDATER = _ITEM_PREFIX + "Updater" +_COND_INITIALIZER = _ITEM_PREFIX + "Initializer" + +# the template for the confluence condition Lua script +_MCRT_COND_CONFLUENCE_SCRIPT_TEMPLATE = f""" + local mcrt = require("{_MCRT_LIBRARY}") + res = mcrt.check_conditions_verified([[COND_LIST]]) +""" + + +# the following items are the specific ones that implement the confluence + + +# 1. initialization task: resets the persistence file; the reason why we want +# this to be performed by the Lua interpreter instead of the GUI application, +# is that in this way the MCRT system is initialized even when used with +# another frontend +def initializer() -> task_lua.LuaScriptTask: + task = task_lua.LuaScriptTask() + task.name = _TASK_INITIALIZER + task.variables_to_set = {"LUA_PATH": get_lua_path()} + task.init_script_path = get_lua_initscript() + task.script = f""" + local mcrt = require("{_MCRT_LIBRARY}") + mcrt.initialize() + """ + return task + + +def initializer_name() -> str: + return _TASK_INITIALIZER + + +# 2. updater: just adds the verified condition to the persistence file +def updater() -> task_lua.LuaScriptTask: + task = task_lua.LuaScriptTask() + task.name = _TASK_UPDATER + task.variables_to_set = {"LUA_PATH": get_lua_path()} + task.init_script_path = get_lua_initscript() + task.script = f""" + local mcrt = require("{_MCRT_LIBRARY}") + mcrt.set_condition_verified(whenever_condition) + """ + return task + + +def updater_name() -> str: + return _TASK_UPDATER + + +# 3. initialization condition: it is a once-only condition that only is +# verified at the first tick, and runs the initialization task +def initial_condition() -> StartupCondition: + cond = StartupCondition() + cond.name = _COND_INITIALIZER + cond.tasks = [_TASK_INITIALIZER] + return cond + + +def initial_condition_name() -> str: + return _COND_INITIALIZER + + +# 4. confluence condition: uses the script template defined above; we +# can implement this type of condition as an "extra" condition anyway, so +# we define an item and a form for it exactly in the same way: the only +# difference is that the item is forced into the available ones and not +# dynamically loaded from the `extra` module folder +class ConfluenceCondition(cond_lua.LuaScriptCondition): + + # availability at class level: these variables *MUST* be set for all items + item_type = "lua" + item_subtype = "mcrt_confluence" + item_hrtype = ITEM_COND_MCRT + available = True + + def __init__(self, t: items.Table | None = None): + # first initialize the base class + cond_lua.LuaScriptCondition.__init__(self, t) + + # then set type (same as base), subtype and human readable name + self.type = self.item_type + self.subtype = self.item_subtype + self.hrtype = self.item_hrtype + if t: + assert t.get("type") == self.type + self.tags = t.get("tags", table()) + assert isinstance(self.tags, items.Table) + assert self.tags.get("subtype") == self.subtype + else: + self.tags = table() + self.tags.append("subtype", self.subtype) + self.tags.append("mcrt_confluent_conditions", list()) + self.updateitem() + + def updateitem(self): + confluent_conditions = self.tags.get("mcrt_confluent_conditions", list()) + self.variables_to_set = {"LUA_PATH": get_lua_path()} + self.init_script_path = get_lua_initscript() + self.script = _MCRT_COND_CONFLUENCE_SCRIPT_TEMPLATE.replace( + "[[COND_LIST]]", + '{"%s"}' % '", "'.join(confluent_conditions), + ) + self.expected_results = {"res": True} + self.check_after = _MCRT_EXTRA_DELAY # for now keep it fixed to 15 seconds + self.recur_after_failed_check = True + + @classmethod + def check_tags(cls, tags): + missing = [] + errors = [] + confluent_conditions = tags.get("mcrt_confluent_conditions") + if confluent_conditions is None: + missing.append("mcrt_confluent_conditions") + elif ( + not isinstance(confluent_conditions, list) + or not len(confluent_conditions) > 1 + or any(not isinstance(x, str) for x in confluent_conditions) + ): + errors.append("mcrt_confluent_conditions") + if errors or missing: + return (errors, missing) + return None + + +class form_ConfluenceCondition(form_Condition): + + # note that the available conditions should be filtered, the provided + # names must correspond to conditions that activate confluence: this + # module provides a helper to distinguish them from others + def __init__(self, tasks_available, item=None): + # check that item is the expected one for safety, build one by default + if item: + assert isinstance(item, ConfluenceCondition) + else: + item = ConfluenceCondition() + + self._conds_available = list() + self._conds_activating = item.tags.get("mcrt_confluent_conditions") or list() + super().__init__(UI_TITLE_MCRTCOND, tasks_available, item) + + # create a specific frame for the contents + area = ttk.Frame(super().contents) + area.grid(row=0, column=0, sticky=tk.NSEW) + PAD = WIDGET_PADDING_PIXELS + + l_activatingConds = ttk.Label(area, text=UI_FORM_MCRT_ACTIVATINGCONDS_SC) + sftv_activatingConds = ttk.Frame(area) + tv_activatingConds = ttk.Treeview( + sftv_activatingConds, + columns=("seq", "conditions"), + show="", + displaycolumns=(1,), + height=5, + bootstyle=ttkc.SECONDARY, + ) + sb_activatingConds = ttk.Scrollbar( + sftv_activatingConds, orient=tk.VERTICAL, command=tv_activatingConds.yview + ) + tv_activatingConds.configure(yscrollcommand=sb_activatingConds.set) + tv_activatingConds.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + sb_activatingConds.pack(side=tk.RIGHT, fill=tk.Y) + + sf_condChoose = ttk.Frame(area) + l_chooseCond = ttk.Label(sf_condChoose, text=UI_FORM_COND_SC) + cb_chooseCond = ttk.Combobox( + sf_condChoose, values=self._conds_available, state="readonly" + ) + b_addCond = ttk.Button( + sf_condChoose, + text=UI_ADD, + width=BUTTON_STANDARD_WIDTH, + command=self.add_cond, + ) + b_delCond = ttk.Button( + sf_condChoose, + text=UI_DEL, + width=BUTTON_STANDARD_WIDTH, + command=self.del_cond, + ) + + # choose condition section: arrange items + l_chooseCond.grid(row=0, column=0, sticky=tk.W, padx=PAD, pady=PAD) + cb_chooseCond.grid(row=0, column=1, sticky=tk.EW, padx=PAD, pady=PAD) + b_addCond.grid(row=0, column=2, sticky=tk.E, padx=PAD, pady=PAD) + b_delCond.grid(row=0, column=3, sticky=tk.E, padx=PAD, pady=PAD) + + # notebook area: arrange items + l_activatingConds.grid(row=0, column=0, sticky=tk.EW, padx=PAD, pady=PAD) + sftv_activatingConds.grid(row=1, column=0, sticky=tk.NSEW, padx=PAD, pady=PAD) + sf_condChoose.grid(row=2, column=0, sticky=tk.EW, padx=PAD, pady=PAD) + + # expand appropriate sections + sf_condChoose.columnconfigure(1, weight=1) + area.rowconfigure(1, weight=1) + area.columnconfigure(0, weight=1) + + # bind data to widgets + self.data_bind( + "cond_selection", + tv_activatingConds, + check=lambda _: len(self._conds_activating) > 1, + ) + self.data_bind("choose_cond", cb_chooseCond, TYPE_STRING) + + # add a check that the chosen conditions should be more than one + self.add_check_caption( + "cond_selection", clean_caption(UI_FORM_MCRT_ACTIVATINGCONDS_SC) + ) + + # propagate widgets that need to be accessed + self._tv_activatingConds = tv_activatingConds + self._cb_chooseCond = cb_chooseCond + + # always update the form at the end of initialization + self._updateform() + + def add_cond(self): + # only add a condition if not present, ignore otherwise + elem = self.data_get("choose_cond") + if elem and elem not in self._conds_activating: + self._conds_activating.append(elem) + self._updatedata() + self._updateform() + + def del_cond(self): + elem = self.data_get("cond_selection") + if elem: + idx = int(elem[0]) + del self._conds_activating[idx] + self._updatedata() + self._updateform() + + # update the form with the specific parameters (usually in the `tags`) + def _updateform(self): + self._tv_activatingConds.delete(*self._tv_activatingConds.get_children()) + idx = 0 + for cnd in self._conds_activating: + self._tv_activatingConds.insert( + "", iid="%s-%s" % (idx, cnd), values=(idx, cnd), index=tk.END + ) + idx += 1 + return super()._updateform() + + # update the item from the form elements (usually update `tags`) + def _updatedata(self): + assert isinstance(self._item, ConfluenceCondition) + self._item.tags["mcrt_confluent_conditions"] = self._conds_activating # type: ignore + return super()._updatedata() + + # set the list of available conditions, that implement confluence + def set_available_conditions(self, conds: list[str]): + self._conds_available = conds.copy() + self._conds_available.sort() + self._cb_chooseCond["values"] = self._conds_available + for cnd in self._conds_activating.copy(): + if cnd not in self._conds_available: + self._conds_activating.remove(cnd) + self._updateform() + + +# check whether a condition is confluent +def is_confluent_cond(c: cond.Condition) -> bool: + if c.tasks is not None and len(c.tasks) == 1: + return c.tasks[0] == _TASK_UPDATER + return False + + +# check whether a condition implements confluence +def is_confluence_cond(c: cond.Condition) -> bool: + return isinstance(c, ConfluenceCondition) + + +# only return interesting symbols +__all__ = [ + "initial_condition", + "initial_condition_name", + "initializer", + "initializer_name", + "updater", + "updater_name", + "is_confluent_cond", + "is_confluence_cond", + "ConfluenceCondition", + "form_ConfluenceCondition", +] + + +# end. diff --git a/lib/internal/reset_conds_on_resume.py b/lib/internal/reset_conds_on_resume.py index 85ff26a7..3b8452e0 100644 --- a/lib/internal/reset_conds_on_resume.py +++ b/lib/internal/reset_conds_on_resume.py @@ -7,9 +7,6 @@ # - a private condition that the above event triggers # - a private task that runs the whenever internal `reset_conditions` command -import sys -import os - from ..utility import get_private_item_name_prefix from ..platform import is_windows, is_linux, is_mac diff --git a/lib/items/cond.py b/lib/items/cond.py index 4575f761..3837f7f4 100644 --- a/lib/items/cond.py +++ b/lib/items/cond.py @@ -27,6 +27,7 @@ ) from .itemhelp import CheckedTable, ConfigurationError +from .event import Event # base class for conditions: all condition items will have the same interface @@ -74,7 +75,7 @@ def __str__(self): # to check the correctness of the configuration file as a side effect def load_checking( self, item: items.Table, item_line: int, tasks: list[str] | None = None - ) -> None: + ): self.type = None self.hrtype = None name_check = lambda x: is_valid_item_name(x) or is_private_item_name(x) @@ -131,7 +132,7 @@ def check_in_document(cls, name: str, doc: TOMLDocument, tasks=None) -> bool: except Exception: raise ConfigurationError( name, - message=f"condition not found in the configuration", + message="condition not found in the configuration", ) # check that elem and elemd are not None assert elem is not None and elemd is not None @@ -226,6 +227,12 @@ def signature(self): def private(self): assert isinstance(self.name, str) return is_private_item_name(self.name) + + def can_be_removed(self, all_events: dict[str, Event]) -> bool: + for k in all_events: + if self.name == all_events[k].condition: + return False + return True def as_table(self): if not check_not_none( diff --git a/lib/items/cond_command.py b/lib/items/cond_command.py index 9800a6be..6a4a4e4d 100644 --- a/lib/items/cond_command.py +++ b/lib/items/cond_command.py @@ -86,7 +86,7 @@ def __init__(self, t: items.Table | None = None): def load_checking( self, item: items.Table, item_line: int, tasks: list[str] | None = None - ) -> None: + ): super().load_checking(item, item_line, tasks) self.type = "command" self.hrtype = ITEM_COND_COMMAND diff --git a/lib/items/cond_dbus.py b/lib/items/cond_dbus.py index b8f4237e..3d71a0ae 100644 --- a/lib/items/cond_dbus.py +++ b/lib/items/cond_dbus.py @@ -75,7 +75,7 @@ def __init__(self, t: items.Table | None = None): def load_checking( self, item: items.Table, item_line: int, tasks: list[str] | None = None - ) -> None: + ): super().load_checking(item, item_line, tasks) self.type = "dbus" self.hrtype = ITEM_COND_DBUS diff --git a/lib/items/cond_event.py b/lib/items/cond_event.py index 89a6f985..66988998 100644 --- a/lib/items/cond_event.py +++ b/lib/items/cond_event.py @@ -28,7 +28,7 @@ def __init__(self, t: items.Table | None = None): def load_checking( self, item: items.Table, item_line: int, tasks: list[str] | None = None - ) -> None: + ): super().load_checking(item, item_line, tasks) self.type = "event" self.hrtype = ITEM_COND_EVENT diff --git a/lib/items/cond_idle.py b/lib/items/cond_idle.py index 0afedf15..3eec61c0 100644 --- a/lib/items/cond_idle.py +++ b/lib/items/cond_idle.py @@ -31,7 +31,7 @@ def __init__(self, t: items.Table | None = None): def load_checking( self, item: items.Table, item_line: int, tasks: list[str] | None = None - ) -> None: + ): super().load_checking(item, item_line, tasks) self.type = "idle" self.hrtype = ITEM_COND_IDLE diff --git a/lib/items/cond_interval.py b/lib/items/cond_interval.py index 5ea0d70b..de4de7e4 100644 --- a/lib/items/cond_interval.py +++ b/lib/items/cond_interval.py @@ -30,7 +30,7 @@ def __init__(self, t: items.Table | None = None): def load_checking( self, item: items.Table, item_line: int, tasks: list[str] | None = None - ) -> None: + ): super().load_checking(item, item_line, tasks) self.type = "interval" self.hrtype = ITEM_COND_INTERVAL diff --git a/lib/items/cond_lua.py b/lib/items/cond_lua.py index 38f5ab68..b74fb0e4 100644 --- a/lib/items/cond_lua.py +++ b/lib/items/cond_lua.py @@ -6,7 +6,12 @@ import os from tomlkit import items -from ..utility import check_not_none, append_not_none, toml_script_string +from ..utility import ( + check_not_none, + append_not_none, + toml_script_string, + toml_literal, + ) from .cond import Condition from .itemhelp import CheckedTable @@ -57,7 +62,7 @@ def __init__(self, t: items.Table | None = None): def load_checking( self, item: items.Table, item_line: int, tasks: list[str] | None = None - ) -> None: + ): super().load_checking(item, item_line, tasks) self.type = "lua" self.hrtype = ITEM_COND_LUA @@ -72,13 +77,13 @@ def load_checking( "init_script_path", check=os.path.isfile ) self.variables_to_set = tab.get_dict_check_and_keys_re( - "variables_to_set", - LUA_VAR_PATTERN, + "variables_to_set", + LUA_VAR_PATTERN, lambda x: isinstance(x, (bool, int, float, str)), ) self.expected_results = tab.get_dict_check_and_keys_re( - "expected_results", - LUA_VAR_PATTERN, + "expected_results", + LUA_VAR_PATTERN, lambda x: isinstance(x, (bool, int, float, str)), ) @@ -96,7 +101,7 @@ def as_table(self): t = append_not_none(t, "expect_all", self.expect_all) t = append_not_none(t, "variables_to_set", self.variables_to_set) t = append_not_none(t, "expected_results", self.expected_results) - t = append_not_none(t, "init_script_path", self.init_script_path) + t = append_not_none(t, "init_script_path", toml_literal(self.init_script_path)) return t diff --git a/lib/items/cond_time.py b/lib/items/cond_time.py index b1bdbfbb..403b8272 100644 --- a/lib/items/cond_time.py +++ b/lib/items/cond_time.py @@ -258,7 +258,7 @@ def __init__(self, t: items.Table | None = None): def load_checking( self, item: items.Table, item_line: int, tasks: list[str] | None = None - ) -> None: + ): super().load_checking(item, item_line, tasks) self.type = "time" self.hrtype = ITEM_COND_TIME diff --git a/lib/items/cond_wmi.py b/lib/items/cond_wmi.py index ea16e673..2a99992f 100644 --- a/lib/items/cond_wmi.py +++ b/lib/items/cond_wmi.py @@ -61,7 +61,7 @@ def __init__(self, t: items.Table | None = None): def load_checking( self, item: items.Table, item_line: int, tasks: list[str] | None = None - ) -> None: + ): super().load_checking(item, item_line, tasks) self.type = "wmi" self.hrtype = ITEM_COND_WMI diff --git a/lib/items/event.py b/lib/items/event.py index b2934ea0..47faaf42 100644 --- a/lib/items/event.py +++ b/lib/items/event.py @@ -56,7 +56,7 @@ def __str__(self): # to check the correctness of the configuration file as a side effect def load_checking( self, item: items.Table, item_line: int, event_conds: list[str] | None = None - ) -> None: + ): self.type = None self.hrtype = None name_check = lambda x: is_valid_item_name(x) or is_private_item_name(x) @@ -109,7 +109,7 @@ def check_in_document( except Exception: raise ConfigurationError( name, - message=f"event not found in the configuration", + message="event not found in the configuration", ) # check that elem and elemd are not None assert elem is not None and elemd is not None @@ -205,6 +205,9 @@ def private(self) -> bool: assert isinstance(self.name, str) return is_private_item_name(self.name) + def can_be_removed(self) -> bool: + return True + def as_table(self) -> items.Table: if not check_not_none( self.name, diff --git a/lib/items/event_cli.py b/lib/items/event_cli.py index ea30cc83..49f8a805 100644 --- a/lib/items/event_cli.py +++ b/lib/items/event_cli.py @@ -22,7 +22,7 @@ def __init__(self, t: items.Table | None = None): if t: assert t.get("type") == self.type - def load_checking(self, item: items.Table, item_line: int, event_conds: list[str] | None = None) -> None: + def load_checking(self, item: items.Table, item_line: int, event_conds: list[str] | None = None): super().load_checking(item, item_line, event_conds) self.type = "cli" self.hrtype = ITEM_EVENT_CLI diff --git a/lib/items/event_dbus.py b/lib/items/event_dbus.py index 06434ae5..c5172fa5 100644 --- a/lib/items/event_dbus.py +++ b/lib/items/event_dbus.py @@ -46,7 +46,7 @@ def __init__(self, t: items.Table | None = None): self.parameter_check_all = None self.parameter_check = None - def load_checking(self, item: items.Table, item_line: int, event_conds: list[str] | None = None) -> None: + def load_checking(self, item: items.Table, item_line: int, event_conds: list[str] | None = None): super().load_checking(item, item_line, event_conds) self.type = "dbus" self.hrtype = ITEM_EVENT_DBUS diff --git a/lib/items/event_fschange.py b/lib/items/event_fschange.py index 5b6e4837..a2265bd2 100644 --- a/lib/items/event_fschange.py +++ b/lib/items/event_fschange.py @@ -35,7 +35,7 @@ def __init__(self, t: items.Table | None = None): self.recursive = None # self.poll_seconds = None - def load_checking(self, item: items.Table, item_line: int, event_conds: list[str] | None = None) -> None: + def load_checking(self, item: items.Table, item_line: int, event_conds: list[str] | None = None): super().load_checking(item, item_line, event_conds) self.type = "fschange" self.hrtype = ITEM_EVENT_FSCHANGE diff --git a/lib/items/event_wmi.py b/lib/items/event_wmi.py index c163da44..d8edc8b3 100644 --- a/lib/items/event_wmi.py +++ b/lib/items/event_wmi.py @@ -48,7 +48,7 @@ def __init__(self, t: items.Table | None = None): def load_checking( self, item: items.Table, item_line: int, event_conds: list[str] | None = None - ) -> None: + ): super().load_checking(item, item_line, event_conds) self.type = "wmi" self.hrtype = ITEM_EVENT_WMI diff --git a/lib/items/item.py b/lib/items/item.py index ca7b110b..89ab2dc9 100644 --- a/lib/items/item.py +++ b/lib/items/item.py @@ -39,6 +39,10 @@ from lib.items.event_fschange import FilesystemChangeEvent from lib.items.event_wmi import WMIEvent +# this is a special case because it implies auxiliary item when used +from ..internal.multi_conds_run_task import ConfluenceCondition, form_ConfluenceCondition +from ..internal.cond_startup import StartupCondition, form_StartupCondition + # to dynamically determine nature of extra items from lib.items.task import Task from lib.items.cond import Condition @@ -90,6 +94,10 @@ ('event:dbus', ITEM_EVENT_DBUS, form_DBusEvent, DBusEvent), ('event:fschange', ITEM_EVENT_FSCHANGE, form_FilesystemChangeEvent, FilesystemChangeEvent), ('event:wmi', ITEM_EVENT_WMI, form_WMIEvent, WMIEvent), + + # the following item are native to When (and not to whenever), thus not extras + ('cond:lua:mcrt_confluence', ITEM_COND_MCRT, form_ConfluenceCondition, ConfluenceCondition), + ('cond:interval:startup', ITEM_COND_STARTUP, form_StartupCondition, StartupCondition), ] diff --git a/lib/items/itemhelp.py b/lib/items/itemhelp.py index b15c13cd..328dffb0 100644 --- a/lib/items/itemhelp.py +++ b/lib/items/itemhelp.py @@ -59,7 +59,7 @@ def get(self, entry: str, mandatory=False, default=None): name, entry_name=entry, item_line=self._table_line, - message=f"entry must be provided", + message="entry must be provided", ) return v @@ -73,7 +73,7 @@ def get_check( name, entry_name=entry, item_line=self._table_line, - message=f"item name invalid or not provided", + message="item name invalid or not provided", ) if not check(v): raise ConfigurationError( diff --git a/lib/items/task.py b/lib/items/task.py index edfd4949..33e93bc0 100644 --- a/lib/items/task.py +++ b/lib/items/task.py @@ -16,6 +16,7 @@ is_private_item_name, ) +from .cond import Condition from .itemhelp import CheckedTable, ConfigurationError @@ -48,7 +49,7 @@ def __str__(self): # the following too is a constructor, that may generate errors: it can be # used in a configuration checking function, or by the constructor itself # to check the correctness of the configuration file as a side effect - def load_checking(self, item: items.Table, item_line: int) -> None: + def load_checking(self, item: items.Table, item_line: int): self.type = None self.hrtype = None check = lambda x: is_valid_item_name(x) or is_private_item_name(x) @@ -94,7 +95,7 @@ def check_in_document(cls, name: str, doc: TOMLDocument) -> bool: except Exception: raise ConfigurationError( name, - message=f"condition not found in the configuration", + message="task not found in the configuration", ) # check that elem and elemd are not None assert elem is not None and elemd is not None @@ -190,6 +191,13 @@ def private(self): assert isinstance(self.name, str) return is_private_item_name(self.name) + def can_be_removed(self, all_conds: dict[str, Condition]) -> bool: + for k in all_conds: + tasks = all_conds[k].tasks + if tasks is not None and self.name in tasks: + return False + return True + def as_table(self): if not check_not_none( self.name, diff --git a/lib/items/task_command.py b/lib/items/task_command.py index 4ea19647..2396df96 100644 --- a/lib/items/task_command.py +++ b/lib/items/task_command.py @@ -80,7 +80,7 @@ def __init__(self, t: items.Table | None = None): self.set_environment_variables = None self.environment_variables = None - def load_checking(self, item: items.Table, item_line: int) -> None: + def load_checking(self, item: items.Table, item_line: int): super().load_checking(item, item_line) self.type = "command" self.hrtype = ITEM_TASK_COMMAND diff --git a/lib/items/task_internal.py b/lib/items/task_internal.py index eb622a46..bff50380 100644 --- a/lib/items/task_internal.py +++ b/lib/items/task_internal.py @@ -27,7 +27,7 @@ def __init__(self, t: items.Table | None = None): else: self.command = DEFAULT_COMMAND - def load_checking(self, item: items.Table, item_line: int) -> None: + def load_checking(self, item: items.Table, item_line: int): super().load_checking(item, item_line) self.type = "internal" self.hrtype = ITEM_TASK_INTERNAL diff --git a/lib/items/task_lua.py b/lib/items/task_lua.py index 9440d1b7..1a14a2ab 100644 --- a/lib/items/task_lua.py +++ b/lib/items/task_lua.py @@ -6,7 +6,12 @@ import os from tomlkit import items -from ..utility import check_not_none, append_not_none, toml_script_string +from ..utility import ( + check_not_none, + append_not_none, + toml_script_string, + toml_literal, + ) from .itemhelp import CheckedTable from .task import Task @@ -51,7 +56,7 @@ def __init__(self, t: items.Table | None = None): self.variables_to_set = None self.init_script_path = None - def load_checking(self, item: items.Table, item_line: int) -> None: + def load_checking(self, item: items.Table, item_line: int): super().load_checking(item, item_line) self.type = "lua" self.hrtype = ITEM_TASK_LUA @@ -64,13 +69,13 @@ def load_checking(self, item: items.Table, item_line: int) -> None: "init_script_path", check=os.path.isfile ) self.variables_to_set = tab.get_dict_check_and_keys_re( - "variables_to_set", - LUA_VAR_PATTERN, + "variables_to_set", + LUA_VAR_PATTERN, lambda x: isinstance(x, (bool, int, float, str)), ) self.expected_results = tab.get_dict_check_and_keys_re( - "expected_results", - LUA_VAR_PATTERN, + "expected_results", + LUA_VAR_PATTERN, lambda x: isinstance(x, (bool, int, float, str)), ) @@ -84,7 +89,7 @@ def as_table(self) -> items.Table: t = append_not_none(t, "expect_all", self.expect_all) t = append_not_none(t, "variables_to_set", self.variables_to_set) t = append_not_none(t, "expected_results", self.expected_results) - t = append_not_none(t, "init_script_path", self.init_script_path) + t = append_not_none(t, "init_script_path", toml_literal(self.init_script_path)) return t diff --git a/lib/lua/LIBRARY.md b/lib/lua/LIBRARY.md new file mode 100644 index 00000000..2dd0723a --- /dev/null +++ b/lib/lua/LIBRARY.md @@ -0,0 +1 @@ +# This directory contains Lua library files diff --git a/lib/lua/__init__.py b/lib/lua/__init__.py new file mode 100644 index 00000000..f2bb99ba --- /dev/null +++ b/lib/lua/__init__.py @@ -0,0 +1,11 @@ +# Lua library static source files + +import os +import sys + +def lua_library_path() -> str: + f = sys.modules[__name__].__file__ + assert(isinstance(f, str)) + return os.path.dirname(os.path.abspath(f)) + +# end. diff --git a/lib/lua/_mcrt_lib.lua b/lib/lua/_mcrt_lib.lua new file mode 100644 index 00000000..6df86fbb --- /dev/null +++ b/lib/lua/_mcrt_lib.lua @@ -0,0 +1,127 @@ +-- mcrt: multiple conditions to run a task +-- NOTE: internals have a double underscore and will not be directly +-- used in the scripts that require the library + + +local __MCRT_LOCK = "__When__private__MCRT_LockState" +local __MCRT_PERSIST = "__When__private__MCRT_SharedState" + + +-- the library itself +local mcrt = {} + + +-- mangle names +local function __mangle_name(name) + return ":" .. name .. ":" +end + +-- find whether or not a (mangled) name is present in a string +local function __has_name(name, s) + return string.find(s, __mangle_name(name)) ~= nil +end + +-- remove a name from a string +local function __rm_name(name, s) + return string.gsub(s, __mangle_name(name), "") +end + +-- add a name to a string +local function __add_name(name, s) + return s .. __mangle_name(name) +end + + +-- actual library functions + +-- initialization just resets the shared state +function mcrt.initialize() + if sync.lock(__MCRT_LOCK, 1.0) then + local ok, msg = pcall(function() + local sst = {} + sst.persistent = "" + sharedstate.save(__MCRT_PERSIST, sst) + end) + if not ok then + log.debug("the following error occurred: " .. (msg or "")) + res = false + end + sync.release(__MCRT_LOCK) + return res + else + log.debug("could not acquire shared state for condition confluence") + return false + end +end + +-- set the condition bearing the provided name to verified +function mcrt.set_condition_verified(cond_name) + if sync.lock(__MCRT_LOCK, 1.0) then + local ok, msg = pcall(function() + local sst = sharedstate.load(__MCRT_PERSIST) + local persistent = sst.persistent + if persistent ~= nil then + if not __has_name(cond_name, persistent) then + persistent = __add_name(cond_name, persistent) + end + else + persistent = __add_name(cond_name, "") + end + sst.persistent = persistent + sharedstate.save(__MCRT_PERSIST, sst) + end) + if not ok then + log.debug("the following error occurred: " .. (msg or "")) + res = false + end + sync.release(__MCRT_LOCK) + return res + else + log.debug("could not acquire shared state for condition confluence") + return false + end +end + +-- check whether the provided conditions are all verified, and if so remove +-- their names prior to returning true; otherwise return false +function mcrt.check_conditions_verified(cond_names) + if sync.lock(__MCRT_LOCK, 1.0) then + res = true + local ok, msg = pcall(function() + local sst = sharedstate.load(__MCRT_PERSIST) + local persistent = sst.persistent + if persistent ~= nil then + for _, name in ipairs(cond_names) do + if not __has_name(name, persistent) then + res = false + break + end + end + if res then + for _, name in ipairs(cond_names) do + persistent = __rm_name(name, persistent) + end + sst.persistent = persistent + sharedstate.save(__MCRT_PERSIST, sst) + end + else + res = false + end + end) + if not ok then + log.debug("the following error occurred: " .. (msg or "")) + res = false + end + sync.release(__MCRT_LOCK) + return res + else + log.debug("could not acquire shared state for condition confluence") + return false + end +end + +-- return the library table +return mcrt + + +-- end. diff --git a/lib/repocfg.py b/lib/repocfg.py index 8edc4262..613417a2 100644 --- a/lib/repocfg.py +++ b/lib/repocfg.py @@ -23,12 +23,12 @@ def __init__(self, initial_table=None): for k in initial_table: self._table[k] = initial_table[k] - def set(self, key: str, value) -> None: + def set(self, key: str, value): if key in self._table: raise ValueError("value already set for '%s': delete it first" % key) self._table[key] = value - def delete(self, key: str) -> None: + def delete(self, key: str): if key in self._table: del self._table[key] @@ -41,10 +41,10 @@ def get(self, key: str, default=None): def __getitem__(self, key: str): return self.get(key) - def __setitem__(self, key: str, value) -> None: + def __setitem__(self, key: str, value): self.set(key, value) - def __delitem__(self, key: str) -> None: + def __delitem__(self, key: str): self.delete(key) def __str__(self) -> str: diff --git a/lib/runner/history.py b/lib/runner/history.py index 6944ba80..4f4e6786 100644 --- a/lib/runner/history.py +++ b/lib/runner/history.py @@ -15,7 +15,7 @@ def __init__(self, maxlen): self._open_records_timing = {} self._private_prefix = get_private_item_name_prefix() - def append(self, record) -> None: + def append(self, record): time = record["header"]["time"] # application = record['header']['application'] # level = record['header']['level'] diff --git a/lib/runner/logger.py b/lib/runner/logger.py index cda14f91..e6f5fde5 100644 --- a/lib/runner/logger.py +++ b/lib/runner/logger.py @@ -180,7 +180,7 @@ def __init__( status=None, message=None, logger=None, - ) -> None: + ): self._level = level or self.LEVEL_INFO self._emitter = emitter self._action = action @@ -201,7 +201,7 @@ def update( when=None, status=None, message=None, - ) -> None: + ): if level is not None: self._level = level if emitter is not None: @@ -268,7 +268,7 @@ def as_record(self): }, } - def log(self, message=None) -> None: + def log(self, message=None): assert self._logger is not None if message is not None: self._message = str(message) diff --git a/lib/runner/process.py b/lib/runner/process.py index 9a2a3c2d..c9672823 100644 --- a/lib/runner/process.py +++ b/lib/runner/process.py @@ -27,7 +27,7 @@ # the following function will be used to start a thread that actually # reads subprocess output and possibly provides input to the subprocess # itself: it has to be aware of the wrapper instance that calls it -def _logreader(wrapper) -> None: +def _logreader(wrapper): pipe = wrapper.pipe() sleep_seconds = _MSECS_BETWEEN_READS / 1000.0 while wrapper.running(): @@ -63,7 +63,7 @@ def get_history(self): return self._history.get_copy() # use the logger to determine whether a line is pertinent to history - def process_output(self, line: str | None) -> None: + def process_output(self, line: str | None): if line: log_record = json.loads(line) if not self._logger.log(log_record): diff --git a/lib/toolbox/check_config.py b/lib/toolbox/check_config.py index f654cb20..dcdd8fd2 100644 --- a/lib/toolbox/check_config.py +++ b/lib/toolbox/check_config.py @@ -37,7 +37,7 @@ def check_globals(doc: TOMLDocument) -> list[ConfigurationError]: if not isinstance(tags, items.Table): errors.append( ConfigurationError( - "(globals)", key, message=f"the tags entry must be a dictionary" + "(globals)", key, message="the tags entry must be a dictionary" ) ) else: @@ -73,7 +73,7 @@ def check_items(doc: TOMLDocument) -> list[ConfigurationError]: ConfigurationError( "task", name, - message=f"the tags entry must be a dictionary", + message="the tags entry must be a dictionary", ) ) else: @@ -96,7 +96,7 @@ def check_items(doc: TOMLDocument) -> list[ConfigurationError]: err = ConfigurationError(name, message=f"malformed task {name}") else: # TODO: report item line in TOML document - err = ConfigurationError("", message=f"unnamed task found") + err = ConfigurationError("", message="unnamed task found") if err is not None: errors.append(err) # retrieve conditions and store names of event based ones @@ -114,7 +114,7 @@ def check_items(doc: TOMLDocument) -> list[ConfigurationError]: ConfigurationError( "condition", name, - message=f"the tags entry must be a dictionary", + message="the tags entry must be a dictionary", ) ) else: @@ -141,7 +141,7 @@ def check_items(doc: TOMLDocument) -> list[ConfigurationError]: else: # TODO: report item line in TOML document err = ConfigurationError( - "", message=f"unnamed condition found" + "", message="unnamed condition found" ) if err is not None: errors.append(err) @@ -159,7 +159,7 @@ def check_items(doc: TOMLDocument) -> list[ConfigurationError]: ConfigurationError( "event", name, - message=f"the tags entry must be a dictionary", + message="the tags entry must be a dictionary", ) ) else: @@ -182,7 +182,7 @@ def check_items(doc: TOMLDocument) -> list[ConfigurationError]: err = ConfigurationError(name, message=f"malformed event {name}") else: # TODO: report item line in TOML document - err = ConfigurationError("", message=f"unnamed event found") + err = ConfigurationError("", message="unnamed event found") if err is not None: errors.append(err) return errors @@ -217,12 +217,12 @@ def check_config_file(filename, verbose=True) -> bool: except ParseError as err: if verbose: write_error(CLI_ERR_CONFIG_INVALID % (filename, str(err))) - except Exception as err: + except Exception as _err: if verbose: # uncomment the following to have the exception reported # if AppConfig.get("DEBUG"): # import traceback - # traceback.print_exception(err) + # traceback.print_exception(_err) write_error(CLI_ERR_ERROR_GENERIC) # if we are here the check was not positive return False diff --git a/lib/toolbox/create_shortcuts.py b/lib/toolbox/create_shortcuts.py index c5b0dfd3..2ddf3744 100644 --- a/lib/toolbox/create_shortcuts.py +++ b/lib/toolbox/create_shortcuts.py @@ -52,7 +52,7 @@ def create_icon(verbose=False) -> None | str: try: with open(target, "wb") as f: f.write(base64.b64decode(icon)) - except Exception as e: + except Exception: if verbose: write_error(CLI_ERR_CANNOT_CREATE_FILE % icon_filename) return None @@ -176,10 +176,8 @@ def create_shortcuts(main_script, desktop=True, autostart=True, verbose=False) - # first: check whether a `pipx`-style install is available if is_windows(): exename = "when-bg.exe" - interpreter = os.path.join(sys.exec_prefix, "pythonw.exe") else: exename = "when" - interpreter = sys.executable p = os.path.expanduser(os.path.join("~", ".local", "bin")) target = os.path.join(p, exename) if not (os.path.isfile(target) and os.access(target, os.X_OK)): @@ -211,9 +209,8 @@ def create_shortcuts(main_script, desktop=True, autostart=True, verbose=False) - True, autostart, ) - except Exception as e: + except Exception: if verbose: - print(e) write_warning(CLI_ERR_CANNOT_CREATE_SHORTCUT) return False return True diff --git a/lib/toolbox/fix_config.py b/lib/toolbox/fix_config.py index 7bcce394..326fd3ae 100644 --- a/lib/toolbox/fix_config.py +++ b/lib/toolbox/fix_config.py @@ -222,7 +222,7 @@ def update_legacy_items(): } -def update_conversions() -> None: +def update_conversions(): if whenever_has_dbus(): for k in CONVERSIONS_DBUS: CONVERSIONS[k] = CONVERSIONS_DBUS[k] @@ -299,7 +299,7 @@ def fix_config_file(filename, verbose=True, backup=True) -> bool: console.print(CLI_MSG_WRITE_NEW_CONFIG % filename) # type: ignore write_whenever_config(filename, tasks, conditions, events, globals) return True - except Exception as e: + except Exception: write_warning(CLI_ERR_CANNOT_FIX_CONFIG % filename) return False diff --git a/lib/toolbox/install_lua.py b/lib/toolbox/install_lua.py index 487b123f..c12a4198 100644 --- a/lib/toolbox/install_lua.py +++ b/lib/toolbox/install_lua.py @@ -170,9 +170,18 @@ def upgrade_lua(fname: str, verbose: bool = True) -> bool: return install_lua(fname, verbose) +# add a name to the list of reserved files and directories: this is used +# internally upon initialization of services by When itself, so we assume +# that the name is correct +def reserve_lua(fname: str): + if fname not in RESERVED: + RESERVED.append(fname) + + __all__ = [ "install_lua", "upgrade_lua", + "reserve_lua", ] diff --git a/lib/toolbox/install_whenever.py b/lib/toolbox/install_whenever.py index 757d6e5c..ec1ab5da 100644 --- a/lib/toolbox/install_whenever.py +++ b/lib/toolbox/install_whenever.py @@ -96,7 +96,7 @@ def get_whenever_download_metadata(verbose=False): if verbose: write_error(CLI_ERR_NO_SUITABLE_BINARY) return None - except ValueError as e: # too many values to unpack + except ValueError: # too many values to unpack if verbose: write_error(f"malformed `{CHECKSUM_FILE}` file") return None diff --git a/lib/trayapp.py b/lib/trayapp.py index 9a2c7c24..ef8f264f 100644 --- a/lib/trayapp.py +++ b/lib/trayapp.py @@ -3,7 +3,6 @@ from lib.i18n.strings import * import pystray -import sys import threading from .utility import get_image @@ -24,56 +23,56 @@ # menu reactions: all events are managed by the main application, and # therefore they are implemented as messages passed to the root window -def on_configure(root) -> None: +def on_configure(root): root.send_event("<>") -def on_about(root) -> None: +def on_about(root): root.send_event("<>") -def on_menu_box(root) -> None: +def on_menu_box(root): root.send_event("<>") -def on_pause_scheduler(root) -> None: +def on_pause_scheduler(root): root.send_event("<>") -def on_resume_scheduler(root) -> None: +def on_resume_scheduler(root): root.send_event("<>") -def on_reset_conditions(root) -> None: +def on_reset_conditions(root): root.send_event("<>") -def on_history(root) -> None: +def on_history(root): root.send_event("<>") # this sends an EXIT event to the main loop, so that the invisible # main window is destroyed and all the cleanup is performed -def on_exit(root) -> None: +def on_exit(root): root.send_exit() # set icon color to gray/color: these functions are called by the main # application to change the icon status when pausing/resuming the scheduler -def set_tray_icon_gray(icon) -> None: +def set_tray_icon_gray(icon): icon.icon = _tray_icon_gray -def set_tray_icon_normal(icon) -> None: +def set_tray_icon_normal(icon): icon.icon = _tray_icon -def set_tray_icon_busy(icon) -> None: +def set_tray_icon_busy(icon): icon.icon = _tray_icon_busy # entry point for the tray resident application -def main(root) -> None: +def main(root): # create the menu: this menu is OK for Windows and for Linux environments # that implement the `AppIndicator` protocol; unfortunately this protocol # seems to be deprecated by teh folks at Gnome, together with almost diff --git a/lib/utility.py b/lib/utility.py index 2433999e..c676fb92 100644 --- a/lib/utility.py +++ b/lib/utility.py @@ -32,10 +32,11 @@ from .repocfg import AppConfig from .runner.logger import Logger from .platform import is_windows, is_linux, is_mac +from .lua import lua_library_path # lowest whenever version supported -_MIN_WHENEVER_SUPPORTED = Version.parse("1.0.0") +_MIN_WHENEVER_SUPPORTED = Version.parse("1.2.0") # a regular expression to check whether an user-given name is valid @@ -127,8 +128,8 @@ def is_valid_item_name(s: str) -> bool: # check that all passed arguments are not None -def check_not_none(*l) -> bool: - for x in l: +def check_not_none(*args) -> bool: + for x in args: if x is None: return False return True @@ -156,15 +157,20 @@ def guess_typed_value(s: str) -> bool | int | float | str: return s +# find an unique-ish suffix for an item name +def generate_item_name_suffix() -> str: + d = blake2s(digest_size=5) + d.update(str(time()).encode("utf-8")) + return d.hexdigest().upper() + + # find an unique-ish name for an item def generate_item_name(o=None) -> str: if o is None: base = "Item" else: base = o.__class__.__name__ - d = blake2s(digest_size=5) - d.update(str(time()).encode("utf-8")) - return "%s_%s" % (base, d.hexdigest().upper()) + return "%s_%s" % (base, generate_item_name_suffix()) # get an image from a stored icon @@ -257,9 +263,32 @@ def get_scriptsdir() -> str: return scriptdir -# determine lua library directory and ensure that it exists -def get_luadir() -> str: +# determine temp directory and ensure that it exists +def get_tempdir(cleanup: bool=False) -> str: configdir: str = AppConfig.get("APPDATA") # type: ignore + if is_windows(): + subdir = "Temp" + else: + subdir = "temp" + tempdir = os.path.join(configdir, subdir) + if os.path.isdir(tempdir): + if cleanup: + shutil.rmtree(tempdir) + try: + os.makedirs(tempdir) + except Exception: + raise OSError(CLI_ERR_SPECIFICDIR_UNACCESSIBLE % tempdir) + else: + try: + os.makedirs(tempdir) + except Exception: + raise OSError(CLI_ERR_SPECIFICDIR_UNACCESSIBLE % tempdir) + return tempdir + + +# determine Lua library directory and ensure that it exists +def get_luadir() -> str: + configdir: str = AppConfig.get("APPDATA") # type: ignore if is_windows(): subdir = "Lua" else: @@ -273,6 +302,35 @@ def get_luadir() -> str: return luadir +# get directory where the application is installed +def get_app_basedir(): + return AppConfig.get("WHEN_BASEDIR") + + +# construct Lua path +def get_lua_path() -> str: + luabase = get_luadir() + lualib = lua_library_path() + ps = os.path.sep + return ( + ";".join( + [ + "?", + "?.lua", + f"{lualib}{ps}?", + f"{lualib}{ps}?.lua", + f"{lualib}{ps}?{ps}?", + f"{lualib}{ps}?{ps}?.lua", + f"{luabase}{ps}?", + f"{luabase}{ps}?.lua", + f"{luabase}{ps}?{ps}?", + f"{luabase}{ps}?{ps}?.lua", + ] + ) + + ";;" + ) + + # the following cannot be used, because the embedded Lua interpreter cannot # load Lua binary modules due to its safe setup, which disallows it # # determine binary lib extension depending on operating system @@ -292,10 +350,15 @@ def get_lua_initscript() -> str: return init +# return the path to Lua static library shipped with When +def get_lua_staticlib_path() -> str: + return lua_library_path() + + # save a script to the scripts directory and make it executable: possible # existing files are overwritten without confirmation as the scripts folder # should be completely managed by When -def save_script(fname, text) -> None: +def save_script(fname, text): dest = os.path.join(get_scriptsdir(), fname) with open(dest, "w") as f: f.write(text) @@ -337,7 +400,15 @@ def check_whenever_version() -> bool: # return the output of `whenever --options` -def retrieve_whenever_options() -> None: +def retrieve_whenever_options(): + # first assume no feature is available + AppConfig.set("WHENEVER_HAS_DBUS", False) + AppConfig.set("WHENEVER_HAS_WMI", False) + AppConfig.set("WHENEVER_HAS_LUASYNC", False) + AppConfig.set("WHENEVER_HAS_LUAHTTPREQ", False) + # ...other options might appear above here + + # then ask the executable for options whenever_path: str = AppConfig.get("WHENEVER") # type: ignore try: result = subprocess.run( @@ -350,37 +421,40 @@ def retrieve_whenever_options() -> None: subprocess.CREATE_NO_WINDOW if sys.platform.startswith("win") else 0 ), ) - except Exception: - # maybe no executable has been found? - AppConfig.set("WHENEVER_HAS_DBUS", False) - AppConfig.set("WHENEVER_HAS_WMI", False) - # ...other options might appear - return None - if result: - # output has the form: `options: [wmi] [dbus]`, each one might or - # might not be present in the output, so we check whether the list - # below contains or not one of them - if result.returncode == 0: - opts = result.stdout.strip().split() - if "dbus" in opts: - AppConfig.set("WHENEVER_HAS_DBUS", True) - else: - AppConfig.set("WHENEVER_HAS_DBUS", False) - if sys.platform.startswith("win") and "wmi" in opts: - AppConfig.set("WHENEVER_HAS_WMI", True) + if result: + # output has the form: `options: ..`, each one might + # or might not be present in the output, so we check whether the + # list below contains or not one of them + if result.returncode == 0: + opts = result.stdout.strip().split() + if opts[0] != "options:": + # maybe it is not the `whenever` we are looking for? + return + opts = opts[1:] + if "dbus" in opts: + AppConfig.delete("WHENEVER_HAS_DBUS") + AppConfig.set("WHENEVER_HAS_DBUS", True) + if sys.platform.startswith("win") and "wmi" in opts: + AppConfig.delete("WHENEVER_HAS_WMI") + AppConfig.set("WHENEVER_HAS_WMI", True) + if "lua_sync" in opts: + AppConfig.delete("WHENEVER_HAS_LUASYNC") + AppConfig.set("WHENEVER_HAS_LUASYNC", True) + if "lua_httpreq" in opts: + AppConfig.delete("WHENEVER_HAS_LUAHTTPREQ") + AppConfig.set("WHENEVER_HAS_LUAHTTPREQ", True) + # ...other options might appear else: - AppConfig.set("WHENEVER_HAS_WMI", False) - # ...other options might appear + # this might be an older version, assume DBus is available + AppConfig.delete("WHENEVER_HAS_DBUS") + AppConfig.set("WHENEVER_HAS_DBUS", True) else: - # this might be an older version, assume DBus is available + # this too might be an older version, assume DBus is available + AppConfig.delete("WHENEVER_HAS_DBUS") AppConfig.set("WHENEVER_HAS_DBUS", True) - AppConfig.set("WHENEVER_HAS_WMI", False) - # ...other options might appear - else: - # this might be an older version, assume DBus is available - AppConfig.set("WHENEVER_HAS_DBUS", True) - AppConfig.set("WHENEVER_HAS_WMI", False) - # ...other options might appear + except Exception: + # maybe no executable has been found? Still, no available option. + return # check whether the scheduler is running @@ -406,15 +480,18 @@ def is_whenever_running() -> None | bool: return False -# a couple of shortcuts for whenever options +# some shortcuts for whenever options def whenever_has_dbus() -> bool: - res = bool(AppConfig.get("WHENEVER_HAS_DBUS")) - return res - + return bool(AppConfig.get("WHENEVER_HAS_DBUS")) def whenever_has_wmi() -> bool: - res = bool(AppConfig.get("WHENEVER_HAS_WMI")) - return res + return bool(AppConfig.get("WHENEVER_HAS_WMI")) + +def whenever_has_lua_sync() -> bool: + return bool(AppConfig.get("WHENEVER_HAS_LUASYNC")) + +def whenever_has_lua_httpreq() -> bool: + return bool(AppConfig.get("WHENEVER_HAS_LUAHTTPREQ")) # return the configuration file path @@ -456,12 +533,12 @@ def get_editor_theme(): # write a warning to stderr -def write_warning(s) -> None: +def write_warning(s): _err_console.print(f"[bold yellow]{UI_APP} - warning:[/] {s}", highlight=False) # write an error to stderr -def write_error(s) -> None: +def write_error(s): _err_console.print(f"[bold red]{UI_APP} - ERROR:[/] {s}", highlight=False) @@ -583,7 +660,7 @@ def clean_caption(s) -> str: # initialize the logger -def init_logger(filename, level, app=None) -> None: +def init_logger(filename, level, app=None): global _logger _logger = Logger(filename, level, app) diff --git a/pyproject.toml b/pyproject.toml index e5826f70..aeeeb035 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,22 @@ -[tool.poetry] +[project] name = "when" -version = "2.0.3" +version = "2.1.1" description = "Interface for the **whenever** automation tool" -authors = ["Francesco Garosi "] +authors = [ + { name = "Francesco Garosi", email = "francesco.garosi@gmail.com" }, +] license = 'BSD 3-Clause "New" or "Revised" License' readme = "README.md" +requires-python = ">=3.10" + +[project.scripts] +when = "when.when:main" + +[project.gui-scripts] +when-bg = "when.when_bg:run_bg" + + +[tool.poetry] packages = [ { include = "lib" }, { include = "when" }, @@ -26,7 +38,7 @@ rich = "^13.9.4" semver = "^3.0.4" tkhtmlview = "^0.3.2" tklinenums = "^1.7.1" -tomlkit = "^0.13.3" +tomlkit = "^0.14.0" tomlkit-extras = "^0.2.0" ttkbootstrap = "^1.20.0" ttkbootstrap-icons = "^3.3.0" @@ -34,13 +46,15 @@ ttkbootstrap-icons-mat = "^1.0.0" winshell = { version = "^0.6", markers = "sys_platform == 'win32'" } wmi = { version = "^1.5.1", markers = "sys_platform == 'win32'" } -[tool.poetry.scripts] -when = "when.when:main" -when-bg = "when.when_bg:run_bg" - [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.ruff.lint] -ignore = ["E402", "F403", "F405"] +ignore = [ + "E402", # module-import-not-at-top-of-file / Module level import not at top of cell + "F401", # unused-import / {name} imported but unused + "F403", # undefined-local-with-import-star / from {name} import * used; unable to detect undefined names + "F405", # undefined-local-with-import-star-usage / {name} may be undefined, or defined from star imports + "E731", # lambda-assignment / Do not assign a lambda expression, use a def +] diff --git a/support/docs/cond_confluence.md b/support/docs/cond_confluence.md new file mode 100644 index 00000000..49a050e6 --- /dev/null +++ b/support/docs/cond_confluence.md @@ -0,0 +1,28 @@ +# Conditions Activated by Other Conditions + +These conditions are specifically built to be the consequence of the verification of other conditions. The specific parameter tab will display of list of _activating_ conditions that, after their verification, will cause the current condition to be verified. + +![ConditionConfluence](graphics/when-cond-confluence.png) + +To add a condition to the list, it is sufficient to select it from the box below the list, and click the _Add_ button. There are some constraints: + +* the conditions that appear in the box are only the ones that have been configured to _activate further conditions_, +* it would be useless, and there fore it is not possible, to add a condition twice, +* it is not possible to use a condition of this type to _activate further conditions_: the specific check box in the common parameters tab is disabled, +* at least two _activating_ conditions must be in the list for the configuration to be valid. + +These constraints are enforced by the form, but they should be considered just obvious: condition duplication and the use of this type of conditions to create complicated chains is discouraged. + +Although it might seem complex, the workflow is quite simple: _after_ **all** the conditions listed in the list of _activating conditions_ are verified, the tasks associated to the current condition are run, as with every other type of condition. In this sentence, the word _after_ is intentionally used: there is no concept of simultaneos validity of the conditions that activate the current one. This should give a hint on what conditions are more suitable to be used to concur in the activation of another, or at least which ones should _not_ anyway. In the category of conditions that are not ideal for this use, the following generically described kinds should be counted: + +* conditions that depend on the status of the system in a particular instant, for example on the current load, +* conditions that take into account interaction with the user, such as idle time or presence of removable drives, +* conditions that are verified at certain instants in time, + +as well as, possibily, other types. Other conditions, such as time intervals, availability of certain files or resources that do not strictly depend on user interaction, and under certain circumstances automatic session locks[^1] can be appropriate candidates for _condition confluence_. However, **When** does not limit what conditions can be used for this purpose, and even by exploiting the less encouraged types, there might be the opportunity to use this feature productively. + + +[`◀ Conditions`](conditions.md) + + +[^1]: However, **when** and **whenever** do not consider _automatic_ and _manual_ session locks as different conditions or events. diff --git a/support/docs/conditions.md b/support/docs/conditions.md index bf4fd041..9b36500b 100644 --- a/support/docs/conditions.md +++ b/support/docs/conditions.md @@ -7,13 +7,18 @@ All condition editors share a common part, which encompasses all parameters that It allows to set the mandatory item _Name_ (an alphanumeric string beginning either with a letter or an underscore[^1]), the tasks associated with it, and to decide other behaviors specific to conditions: * _Check Condition Recurrently_: when set, the condition is continuously re-checked even after its verification, becoming _recurrent_. By default a verified condition stops being checked after the first occurrence, unless a [_reset conditions_](tray.md) command is sent via the system tray menu. -* _Max Task Retries_: when greater or equal to `0`, the number of times that the underlying scheduler will _retry_ to run the associated task or list of tasks in case one of them fails (of course `0` means _only one check and no retries_); a value of `-1` means that the scheduler will try to run the associated task(s) forever until they all succeed. Only available if the _Check Condition Recurrently_ flag is set. * _Suspend Condition at Startup_: to start the condition in suspended mode, which means that it will not be checked during the session. +* _Activate further conditions_: conditions for which this option is checked will concur in activation of other specifically built [conditions](cond_confluence.md). +* _Max Task Retries_: when greater or equal to `0`, the number of times that the underlying scheduler will _retry_ to run the associated task or list of tasks in case one of them fails (of course `0` means _only one check and no retries_); a value of `-1` means that the scheduler will try to run the associated task(s) forever until they all succeed. Only available if the _Check Condition Recurrently_ flag is set. The central list displays the list of tasks associated with the condition, in the order in which they would be run in case the _Execute Tasks Sequentially_ box is checked (otherwise, all tasks are spawned simultaneously). To add a task to the list, it must be selected from the drop down list below the list and the _Add_ button must be clicked. To remove a task, it must be double clicked on the list (or selected in the drop down list, with the same effect) and the _Remove_ button has to be clicked. Note that all occurrences of the task displayed in the text box are removed from the list. When the tasks are set to be run sequentially, the behavior upon success or failure of one of them (that is: stop the sequence on either success or failure) can be specified, by clicking the appropriate option, respectively the _Stop Running Sequence when a Task Succeeds/Fails_ options. Leave the _Do Not Check for Task Outcome_ selected to ignore the outcome of the associated tasks. +:::{caution} +When a condition is set to _activate further conditions_ by checking the appropriate box, it can only be used for the specific purpose: this means that no other _tasks_ can be triggered by this condition, and in fact possible _tasks_ that were listed as its consequence prior to checking the box are deleted, and the entire _tasks_ section in the form is disabled. +::: + These are the parameters that appear on the _Common Parameters_ tab: the _Specific Parameters_ tab, instead, varies according to the type of condition that is being edited. The conditions available in **When** that are natively supported by **whenever** are the following: @@ -27,9 +32,10 @@ The conditions available in **When** that are natively supported by **whenever** Other conditions are supported, that are implemented as reactions to particular commands, DBus messages or method invocations, WMI events or queries, _Lua_ scripts. These conditions appear along with the native ones, and the related documentation can be found at the following locations: -* [_System Load_ below Threshold](cond_extra01.md#system-load) conditions -* [_Battery Charge_ below Threshold](cond_extra01.md#low-battery) conditions -* [_Battery Charge_ above Threshold](cond_extra01.md#charging-battery) conditions +* Conditions [_activated by other conditions_](cond_confluence.md) +* [_System Load_ below threshold](cond_extra01.md#system-load) conditions +* [_Battery Charge_ below threshold](cond_extra01.md#low-battery) conditions +* [_Battery Charge_ above threshold](cond_extra01.md#charging-battery) conditions * [_Removable Drive_ available](cond_extra01.md#removable-drives) conditions * [_Session Locked_](cond_extra01.md#session-locked-windows) (Windows only) conditions diff --git a/support/docs/graphics/when-cond-common.png b/support/docs/graphics/when-cond-common.png index 0049da57..9274f969 100644 Binary files a/support/docs/graphics/when-cond-common.png and b/support/docs/graphics/when-cond-common.png differ diff --git a/support/docs/graphics/when-cond-confluence.png b/support/docs/graphics/when-cond-confluence.png new file mode 100644 index 00000000..5668e326 Binary files /dev/null and b/support/docs/graphics/when-cond-confluence.png differ diff --git a/support/docs/index.rst b/support/docs/index.rst index fc847b7e..44d29218 100644 --- a/support/docs/index.rst +++ b/support/docs/index.rst @@ -32,6 +32,7 @@ access to the features of **whenever** that are supported on the host environmen cond_timerelated cond_actionrelated cond_eventrelated + cond_confluence cond_extra01 events events_extra01 diff --git a/support/docs/main.md b/support/docs/main.md index 514fa717..68a29803 100644 --- a/support/docs/main.md +++ b/support/docs/main.md @@ -31,7 +31,11 @@ This document references: * [**whenever**](https://github.com/almostearthling/whenever): the main scheduler and automation tool used by **When**; * [**whenever_tray**](https://github.com/almostearthling/whenever_tray): a minimal, lightweight, cross platform wrapper and frontend for **whenever**. -The first is the main core that **When** uses to accomplish its mission: unlike the previous version, **When** totally relies on **whenever** as its internal engine instead of implementing a scheduler on its own. The second can be considered as a complement to **When**, in the sense that **When** is designed to share its configuration location with **whenever_tray** in an interoperable way that would allow the latter to be launched as an alternative frontend for running **whenever** after having configured it with the help of **When**. +The first is the main core that **When** uses to accomplish its mission: unlike the previous version, **When** totally relies on **whenever** as its internal engine instead of implementing a scheduler on its own. The second can be considered as a complement to **When**, in the sense that **When** is designed to share its configuration location with **whenever_tray** in an interoperable way that would allow the latter to be launched as an alternate frontend for running **whenever** after having configured it with the help of **When**. + +:::{tip} +A configuration file generated with **When** will still work in case **whenever** is started by an alternate frontend, or even on its own. This includes advanced features such as the ability to [_reset conditions on system resume_](cfgform.md#modify-scheduler-parameters), or the [_conditions activated by other conditions_](cond_confluence.md), as well as the specific items. Some of these features, however, require **When** to be _installed_ (although not _running_), because they depend on resources (for instance, _Lua_ scripts), that are located in the **When** installation tree. Apart from that, the configuration is read by **whenever** as a definitely regular configuration file. +::: ## Covered Topics @@ -57,16 +61,16 @@ For more information about the companion tools, **whenever** and **whenever_tray For the sake of readability, a glossary follows for some of the terms used throughout this documentation. -| **Term** | **Meaning** | -|--------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------| -| _APPDATA_ | the _application data_ directory is where **When** keeps configuration files, data files, and logs: see [this page](appdata.md) for details | -| _condition_ | circumstance or set of circumstances that may or may not occur in a certain moment and whose occurrence determines the execution of tasks | -| _event_ | signal, message, or external coincidence anyway that **When** (or **whenever**) can be instructed to listen to | -| _item_ | used throughout the document to specify one of a _task_, a _condition_ or an _event_ | -| _system tray area_ | area of the desktop where background-running applications show an icon and notifications: goes by several other names | -| _task_ | an action that **When** will perform upon verification of a certain _condition_ | -| _tick_ | the instant in which the tests for condition verification are started and possibly consequential tasks are launched | -| ... | ... | +| **Term** | **Meaning** | +|--------------------|---------------------------------------------------------------------------------------------------------------------------------------------| +| _APPDATA_ | the _application data_ directory is where **When** keeps configuration files, data files, and logs: see [this page](appdata.md) for details | +| _condition_ | circumstance or set of circumstances that may or may not occur in a certain moment and whose occurrence determines the execution of tasks | +| _event_ | signal, message, or external coincidence anyway that **When** (or **whenever**) can be instructed to listen to | +| _item_ | used throughout the document to specify one of a _task_, a _condition_ or an _event_ | +| _system tray area_ | area of the desktop where background-running applications show an icon and notifications: goes by several other names | +| _task_ | an action that **When** will perform upon verification of a certain _condition_ | +| _tick_ | the instant in which the tests for condition verification are started and possibly consequential tasks are launched | +| ... | ... | [^1]: this actually fulfills what was requested in Issue #85. diff --git a/when/when.py b/when/when.py index c8c3bd0c..4bab9ab2 100644 --- a/when/when.py +++ b/when/when.py @@ -25,11 +25,17 @@ get_whenever_version, check_whenever_version, get_luadir, + get_lua_initscript, + get_tempdir, get_scriptsdir, get_appdata, get_logfile, get_configfile, is_whenever_running, + whenever_has_wmi, + whenever_has_dbus, + whenever_has_lua_sync, + whenever_has_lua_httpreq, get_image, get_UI_theme, get_tkroot, @@ -44,6 +50,8 @@ from lib.runner.process import Wrapper +from lib.internal import multi_conds_run_task as mcrt + # main root window, to be withdrawn _root = None @@ -52,6 +60,9 @@ # this is used to enable exception handling too DEBUG = AppConfig.get("DEBUG") +# add current base directory to the configuration store +AppConfig.set("WHEN_BASEDIR", BASEDIR) + # the following class is used to create an invisible window that actually # implements the tkinter main loop, and that reads virtual events to display @@ -118,32 +129,32 @@ def __init__(self): self._busy = False # the main loop is mandatory to react to events - def run(self) -> None: + def run(self): if self._window is not None: self._window.mainloop() # the scheduler wrapper is needed as it provides access to task history - def set_wrapper(self, wrapper) -> None: + def set_wrapper(self, wrapper): self._wrapper = wrapper # the tray icon if any - def set_trayicon(self, icon) -> None: + def set_trayicon(self, icon): self._trayicon = icon # send an event to the main loop asynchronously - def send_event(self, event: str) -> None: + def send_event(self, event: str): if self._window: self._window.update() self._window.event_generate(event) # shortcut to send an EXIT event - def send_exit(self) -> None: + def send_exit(self): if self._window: self._window.update() self._window.event_generate("<>") # destroy the window, stop whenever, and cleanup internals - def destroy(self) -> None: + def destroy(self): if self._wrapper: self._wrapper.whenever_exit() self._wrapper = None @@ -161,14 +172,14 @@ def destroy(self) -> None: # event reactions: these are called by the systray app that runs in a # separate, detached thread so that it does not slow down the main loop - def open_history(self, _) -> None: + def open_history(self, _): if self._window and self._wrapper: form = self.form_History(self._wrapper) form.run() del form gc.collect() - def sched_pause(self, _) -> None: + def sched_pause(self, _): if not self._paused and self._window and self._wrapper: log = get_logger().context().use(emitter="FRONTEND") log.use( @@ -179,7 +190,7 @@ def sched_pause(self, _) -> None: ).log("pausing the scheduler") self._wrapper.whenever_pause() - def sched_resume(self, _) -> None: + def sched_resume(self, _): if self._paused and self._window and self._wrapper: log = get_logger().context().use(emitter="FRONTEND") log.use( @@ -190,7 +201,7 @@ def sched_resume(self, _) -> None: ).log("resuming scheduler activity") self._wrapper.whenever_resume() - def sched_reset_conditions(self, _) -> None: + def sched_reset_conditions(self, _): if self._window and self._wrapper: log = get_logger().context().use(emitter="FRONTEND") log.use( @@ -201,7 +212,7 @@ def sched_reset_conditions(self, _) -> None: ).log("resetting all conditions") self._wrapper.whenever_reset_conditions() - def sched_reload_configuration(self, _) -> None: + def sched_reload_configuration(self, _): if self._window and self._wrapper: log = get_logger().context().use(emitter="FRONTEND") log.use( @@ -212,7 +223,7 @@ def sched_reload_configuration(self, _) -> None: ).log("reloading configuration") self._wrapper.whenever_reload_configuration() - def sched_icon_busy(self, _) -> None: + def sched_icon_busy(self, _): if self._icon: # check current status to avoid useless icon swaps if not self._busy: @@ -220,7 +231,7 @@ def sched_icon_busy(self, _) -> None: if not self._paused: self.set_tray_icon_busy(self._trayicon) - def sched_icon_not_busy(self, _) -> None: + def sched_icon_not_busy(self, _): if self._icon: # check current status to avoid useless icon swaps if self._busy: @@ -230,14 +241,14 @@ def sched_icon_not_busy(self, _) -> None: else: self.set_tray_icon_normal(self._trayicon) - def sched_icon_paused(self, _) -> None: + def sched_icon_paused(self, _): if self._icon: # check current status to avoid useless icon swaps if not self._paused: self._paused = True self.set_tray_icon_gray(self._trayicon) - def sched_icon_not_paused(self, _) -> None: + def sched_icon_not_paused(self, _): if self._icon: # check current status to avoid useless icon swaps if self._paused: @@ -247,26 +258,26 @@ def sched_icon_not_paused(self, _) -> None: else: self.set_tray_icon_normal(self._trayicon) - def open_cfgapp(self, _) -> None: + def open_cfgapp(self, _): if self._window: form = self.form_Config(self) form.run() del form gc.collect() - def open_menubox(self, _) -> None: + def open_menubox(self, _): if self._window and self._wrapper: form = self.form_MenuBox(self) form.run() del form gc.collect() - def open_aboutbox(self, _) -> None: + def open_aboutbox(self, _): if self._window: self.show_about_box(False) gc.collect() - def exit_app(self, _) -> None: + def exit_app(self, _): self.destroy() @@ -275,44 +286,80 @@ def exit_app(self, _) -> None: # displays a window, because all other forms are just non-root toplevels; # NOTE: every windowed subprogram *must* receive a reference to the root # window and take care of sending a `send_exit()` event when finishing -def setup_windows() -> None: +def setup_windows(): global _root _root = App() # prepare expected environment, such as configuration directory -def prepare_environment() -> None: +def prepare_environment(): # create the application data directory if it does not exist try: _ = get_appdata() except Exception as e: exit_error(e) + # create the scripts directory if it does not exist try: _ = get_scriptsdir() except Exception as e: exit_error(e) + # create temp directory if it does not exist, ensuring that it is clean + try: + _ = get_tempdir(True) + except Exception as e: + exit_error(e) + # create the Lua library directory if it does not exist try: _ = get_luadir() except Exception as e: exit_error(e) + # create the Lua initialization script if it does not exist + try: + _ = get_lua_initscript() + except Exception as e: + exit_error(e) + # ... + + +# activate or deactivate non-extra features according to availability +def check_prepare_features(): + # deactivate main capabilities depending on WMI + if not whenever_has_wmi(): + pass + # ... + # deactivate main capabilities depending on DBus + if not whenever_has_dbus(): + pass + # ... + # deactivate main capabilities depending on Lua sync facilities + if not whenever_has_lua_sync(): + mcrt.ConfluenceCondition.available = False + pass + # ... + # deactivate main capabilities depending on Lua HTTP access + if not whenever_has_lua_httpreq(): + pass + # ... # subcommand main functions # version: display the application version and exit -def main_version(_) -> None: +def main_version(_): # print plain text: could be programatically used to determine version print("%s: v%s" % (UI_APP, UI_APP_VERSION)) # config: enter the configuration utility and exit (no scheduler launched) -def main_config(args) -> None: +def main_config(args): # set some global configuration values according to CLI options AppConfig.delete("APPDATA") AppConfig.set("APPDATA", args.dir_appdata) + # prepare application retrieve_whenever_options() prepare_environment() + check_prepare_features() if DEBUG: configfile = get_configfile() if not os.path.exists(configfile): @@ -343,7 +390,7 @@ def main_config(args) -> None: # start: start the scheduler in the background and display the tray icon -def main_start(args) -> None: +def main_start(args): # set some global configuration values according to CLI options AppConfig.delete("APPDATA") AppConfig.set("APPDATA", args.dir_appdata) @@ -351,13 +398,14 @@ def main_start(args) -> None: AppConfig.set("LOGLEVEL", args.log_level.upper()) AppConfig.delete("WHENEVER") AppConfig.set("WHENEVER", args.whenever) + # exit with an error if whenever is already running + if is_whenever_running(): + exit_error(CLI_ERR_ALREADY_RUNNING) + # prepare application and initialize the logger retrieve_whenever_options() prepare_environment() - # prepare application so that the logger can be initialized + check_prepare_features() setup_windows() - if is_whenever_running(): - exit_error(CLI_ERR_ALREADY_RUNNING) - # get configuration options log_level = AppConfig.get("LOGLEVEL") log_file = get_logfile() config_file = get_configfile() @@ -388,6 +436,7 @@ def main_start(args) -> None: status=log.STATUS_MSG, ).log(f"found `whenever` version {v}: please upgrade") exit_error(CLI_ERR_WHENEVER_WRONG_VERSION) + # different exception handling for DEBUG/RELEASE runs if DEBUG: # setup the scheduler and associate it to the application wrapper = Wrapper(config_file, whenever, _root) @@ -456,7 +505,7 @@ def main_start(args) -> None: # toolbox: various utilities that can help build a proper setup -def main_toolbox(args) -> None: +def main_toolbox(args): AppConfig.delete("APPDATA") AppConfig.set("APPDATA", args.dir_appdata) prepare_environment() @@ -611,7 +660,7 @@ def main_toolbox(args) -> None: # main program: perform CLI parsing and run the appropriate subcommand -def main() -> None: +def main(): global _root default_appdata = get_default_configdir() diff --git a/when/when_bg.pyw b/when/when_bg.pyw index 9d222da0..a2304335 100644 --- a/when/when_bg.pyw +++ b/when/when_bg.pyw @@ -5,7 +5,7 @@ from .when import main from lib.platform import is_windows -def run_bg() -> None: +def run_bg(): if is_windows(): if os.path.basename(sys.argv[0]) != os.path.basename(sys.executable): pythonw = os.path.join(os.path.dirname(sys.executable), "pythonw.exe")