From 5eb2df56ef49840ae7f50bcc0f52773458c7adbb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Feb 2024 18:28:11 +0100 Subject: [PATCH 01/31] depend(deps): bump sphinx-autobuild from 2021.3.14 to 2024.2.4 (#551) Bumps [sphinx-autobuild](https://github.com/sphinx-doc/sphinx-autobuild) from 2021.3.14 to 2024.2.4. - [Release notes](https://github.com/sphinx-doc/sphinx-autobuild/releases) - [Changelog](https://github.com/sphinx-doc/sphinx-autobuild/blob/main/NEWS.rst) - [Commits](https://github.com/sphinx-doc/sphinx-autobuild/compare/2021.03.14...2024.02.04) --- updated-dependencies: - dependency-name: sphinx-autobuild dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/docs.txt b/requirements/docs.txt index b9dbe8fc..180479d8 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1,5 +1,5 @@ sphinx==7.1.2 -sphinx-autobuild==2021.3.14 +sphinx-autobuild==2024.2.4 sphinx-copybutton==0.5.2 furo==2024.1.29 enum-tools[sphinx]==0.11.0 From 1fb61521cdcd7fc9eb4eea15bb57b2f0b2135a68 Mon Sep 17 00:00:00 2001 From: David Hozic Date: Tue, 6 Feb 2024 09:05:42 +0100 Subject: [PATCH 02/31] Split GUI's tabs into into separate classes (#552) * Schema tab * Analytics tab * optional tab * Live tab * About tab * Output tab --- src/daf_gui/connector.py | 24 +- src/daf_gui/edit_window_manager.py | 22 + src/daf_gui/main.py | 797 ++--------------------------- src/daf_gui/tabs/__init__.py | 6 + src/daf_gui/tabs/about.py | 59 +++ src/daf_gui/tabs/analytics.py | 207 ++++++++ src/daf_gui/tabs/debug.py | 42 ++ src/daf_gui/tabs/live.py | 105 ++++ src/daf_gui/tabs/optional.py | 67 +++ src/daf_gui/tabs/schema.py | 365 +++++++++++++ 10 files changed, 934 insertions(+), 760 deletions(-) create mode 100644 src/daf_gui/edit_window_manager.py create mode 100644 src/daf_gui/tabs/__init__.py create mode 100644 src/daf_gui/tabs/about.py create mode 100644 src/daf_gui/tabs/analytics.py create mode 100644 src/daf_gui/tabs/debug.py create mode 100644 src/daf_gui/tabs/live.py create mode 100644 src/daf_gui/tabs/optional.py create mode 100644 src/daf_gui/tabs/schema.py diff --git a/src/daf_gui/connector.py b/src/daf_gui/connector.py index 19855a91..d7fbeb8c 100644 --- a/src/daf_gui/connector.py +++ b/src/daf_gui/connector.py @@ -3,6 +3,7 @@ clients. """ from typing import List, Optional, Literal, Awaitable +from abc import ABC, abstractmethod from daf.logging.tracing import TraceLEVELS, trace from daf.misc import instance_track as it @@ -29,10 +30,13 @@ class GLOBALS: connection: "AbstractConnectionCLIENT" = None -class AbstractConnectionCLIENT: +class AbstractConnectionCLIENT(ABC): """ Interface for connection clients. """ + connected: bool + + @abstractmethod async def initialize(self, *args, **kwargs): """ Method for initializing DAF. @@ -46,12 +50,14 @@ async def initialize(self, *args, **kwargs): """ raise NotImplementedError + @abstractmethod async def shutdown(self): """ Method calls DAF's shutdown core function. """ raise NotImplementedError + @abstractmethod async def add_account(self, obj: daf.client.ACCOUNT): """ Adds and initializes a new account into DAF. @@ -63,6 +69,7 @@ async def add_account(self, obj: daf.client.ACCOUNT): """ raise NotImplementedError + @abstractmethod async def remove_account(self, account_ref: it.ObjectReference): """ Logs out and removes account from DAF. @@ -74,18 +81,21 @@ async def remove_account(self, account_ref: it.ObjectReference): """ raise NotImplementedError + @abstractmethod async def get_accounts(self) -> List[daf.client.ACCOUNT]: """ Retrieves a list of all accounts in DAF. """ raise NotImplementedError + @abstractmethod async def get_logger(self) -> daf.logging.LoggerBASE: """ Returns the logger object used in DAF. """ raise NotImplementedError + @abstractmethod async def refresh(self, object_ref: it.ObjectReference) -> object: """ Returns updated state of the object. @@ -97,6 +107,7 @@ async def refresh(self, object_ref: it.ObjectReference) -> object: """ raise NotImplementedError + @abstractmethod async def execute_method(self, object_ref: it.ObjectReference, method_name: str, **kwargs): """ Executes a method inside object and returns the result. @@ -114,7 +125,6 @@ async def execute_method(self, object_ref: it.ObjectReference, method_name: str, """ raise NotImplementedError - class LocalConnectionCLIENT(AbstractConnectionCLIENT): """ Client used for starting and running DAF locally, on the same @@ -312,4 +322,12 @@ def _ping(self): def get_connection() -> AbstractConnectionCLIENT: - return GLOBALS.connection + """ + Returns the active connection client. + If the client is disconnected / does not exist, a ConnectionError is raised. + """ + conn = GLOBALS.connection + if conn is None or not conn.connected: + raise ConnectionError("DAF is not started / connected to. Click the (top-left corner) START button first!") + + return conn diff --git a/src/daf_gui/edit_window_manager.py b/src/daf_gui/edit_window_manager.py new file mode 100644 index 00000000..281785b3 --- /dev/null +++ b/src/daf_gui/edit_window_manager.py @@ -0,0 +1,22 @@ +from tkclasswiz import ObjectEditWindow + +import ttkbootstrap.dialogs as tkdiag + + +__all__ = ( + "EditWindowManager", +) + + +class EditWindowManager: + "Manager class for graphically editing objects" + def __init__(self) -> None: + self.window = None + + def open_object_edit_window(self, *args, **kwargs): + if self.window is None or self.window.closed: + self.window = ObjectEditWindow() + self.window.open_object_edit_frame(*args, **kwargs) + else: + tkdiag.Messagebox.show_error("Object edit window is already open, close it first.", "Already open") + self.window.focus() diff --git a/src/daf_gui/main.py b/src/daf_gui/main.py index 84bfb5cf..b14728db 100644 --- a/src/daf_gui/main.py +++ b/src/daf_gui/main.py @@ -2,17 +2,12 @@ Main file of the DAF GUI. """ from importlib.util import find_spec -from typing import List from pathlib import Path - -from daf.misc import instance_track as it - -import subprocess -import sys - import tk_async_execute as tae +import subprocess import json +import sys # Automatically install GUI requirements if GUI is requested to avoid making it an optional dependency @@ -51,74 +46,48 @@ import ttkbootstrap as ttk +from ttkbootstrap.toast import ToastNotification +from tkclasswiz.utilities import * +from tkclasswiz.storage import * from tkclasswiz.convert import * from tkclasswiz.dpi import * -from tkclasswiz.object_frame.window import ObjectEditWindow -from tkclasswiz.storage import * -from tkclasswiz.utilities import * +from PIL import ImageTk + +from .edit_window_manager import * from .connector import * +from .tabs import * -from PIL import Image, ImageTk -from ttkbootstrap.tooltip import ToolTip -from ttkbootstrap.toast import ToastNotification import tkinter as tk -import tkinter.filedialog as tkfile import ttkbootstrap.dialogs.dialogs as tkdiag -import ttkbootstrap.tableview as tktw import ttkbootstrap.style as tkstyle -import tkclasswiz as wiz -import webbrowser import sys import os import daf -WIN_UPDATE_DELAY = 0.005 -CREDITS_TEXT = \ -""" -Welcome to Discord Advertisement Framework - UI mode. -The UI runs on top of Discord Advertisement Framework and allows easier usage for those who -don't want to write Python code to use the software. - -This is written as part of my bachelor thesis as a degree finishing project -"Framework for advertising NFT on social network Discord". -""" - -GITHUB_URL = "https://github.com/davidhozic/discord-advertisement-framework" -DOC_URL = f"https://daf.davidhozic.com/en/v{'.'.join(daf.VERSION.split('.')[:2])}.x/" -DISCORD_URL = "https://discord.gg/DEnvahb2Sw" - -OPTIONAL_MODULES = [ - # Label, optional name, installed var - ("SQL logging", "sql", daf.logging.sql.SQL_INSTALLED), - ("Voice messages", "voice", daf.message.voice_based.GLOBAL.voice_installed), - ("Web features (Chrome)", "web", daf.web.GLOBALS.selenium_installed), -] - - class GLOBAL: app: "Application" = None -def gui_daf_assert_running(): - if not GLOBAL.app._daf_running: - raise ConnectionError("Start the framework first (START button)") - - class Application(): def __init__(self) -> None: # Window initialization win_main = ttk.Window(themename="cosmo") + self.win_main = win_main + + # Object edit + self.edit_mgr = EditWindowManager() # DPI set_dpi(win_main.winfo_fpixels('1i')) dpi_5 = dpi_scaled(5) + dpi_10 = dpi_scaled(10) + path = os.path.join(os.path.dirname(__file__), "img/logo.png") photo = ImageTk.PhotoImage(file=path) win_main.iconphoto(0, photo) - self.win_main = win_main screen_res = int(win_main.winfo_screenwidth() / 1.25), int(win_main.winfo_screenheight() / 1.375) win_main.wm_title(f"Discord Advert Framework {daf.VERSION}") win_main.wm_minsize(*screen_res) @@ -132,9 +101,16 @@ def __init__(self) -> None: self.bnt_toolbar_stop_daf = ttk.Button(self.frame_toolbar, text="Stop", state="disabled", command=self.stop_daf) self.bnt_toolbar_stop_daf.pack(side="left") + # Main Frame + self.frame_main = ttk.Frame(self.win_main) + self.frame_main.pack(expand=True, fill=tk.BOTH, side="bottom") + tabman_mf = ttk.Notebook(self.frame_main) + tabman_mf.pack(fill=tk.BOTH, expand=True) + self.tabman_mf = tabman_mf + # Connection self.combo_connection_edit = ComboEditFrame( - self.open_object_edit_window, + self.edit_mgr.open_object_edit_window, [ ObjectInfo(LocalConnectionCLIENT, {}), ObjectInfo(RemoteConnectionCLIENT, {"host": "http://"}), @@ -143,34 +119,27 @@ def __init__(self) -> None: ) self.combo_connection_edit.pack(side="left", fill=ttk.X, expand=True) - # Main Frame - self.frame_main = ttk.Frame(self.win_main) - self.frame_main.pack(expand=True, fill=tk.BOTH, side="bottom") - tabman_mf = ttk.Notebook(self.frame_main) - tabman_mf.pack(fill=tk.BOTH, expand=True) - self.tabman_mf = tabman_mf - - # Toast notifications self.init_event_listeners() # Optional dependencies tab - self.init_optional_dep_tab() + self.tabman_mf.add(OptionalTab(padding=(dpi_10, dpi_10)), text="Optional modules") # Objects tab - self.init_schema_tab() + self.tab_schema = SchemaTab(self.edit_mgr, self.combo_connection_edit, master=tabman_mf, padding=(dpi_10, dpi_10)) + tabman_mf.add(self.tab_schema, text="Schema definition") # Live inspect tab - self.init_live_inspect_tab() + self.tabman_mf.add(LiveTab(self.edit_mgr, padding=(dpi_10, dpi_10)), text="Live view") # Output tab - self.init_output_tab() + self.tabman_mf.add(DebugTab(), text="Output") # Analytics - self.init_analytics_tab() + self.tabman_mf.add(AnalyticsTab(self.edit_mgr, padding=(dpi_10, dpi_10)), text="Analytics") - # Credits tab - self.init_credits_tab() + # About tab + self.tabman_mf.add(AboutTab(), text="About") # GUI menu self.init_menu() @@ -182,10 +151,6 @@ def __init__(self) -> None: self.win_main.protocol("WM_DELETE_WINDOW", self.close_window) self.tabman_mf.select(1) - # Connection - self.connection: AbstractConnectionCLIENT = None - - if sys.version_info.minor == 12 and sys.version_info.major == 3: tkdiag.Messagebox.show_warning( "DAF's support on Python 3.12 is limited. Web browser features and" @@ -248,678 +213,17 @@ def trace_listener(level: daf.TraceLEVELS, message: str): daf.EventID._ws_disconnect, lambda: self.win_main.after_idle(self.stop_daf) ) - def init_schema_tab(self): - self.objects_edit_window = None - dpi_10 = dpi_scaled(10) - dpi_5 = dpi_scaled(5) - - tab_schema = ttk.Frame(self.tabman_mf, padding=(dpi_10, dpi_10)) - self.tabman_mf.add(tab_schema, text="Schema definition") - - # Object tab file menu - bnt_file_menu = ttk.Menubutton(tab_schema, text="Schema") - menubar_file = ttk.Menu(bnt_file_menu) - menubar_file.add_command(label="Save schema", command=self.save_schema) - menubar_file.add_command(label="Load schema", command=self.load_schema) - menubar_file.add_command(label="Generate script", command=self.generate_daf_script) - bnt_file_menu.configure(menu=menubar_file) - bnt_file_menu.pack(anchor=tk.W) - - # Object tab account tab - frame_tab_account = ttk.Labelframe( - tab_schema, - text="Accounts", padding=(dpi_10, dpi_10), bootstyle="primary") - frame_tab_account.pack(side="left", fill=tk.BOTH, expand=True, pady=dpi_10, padx=dpi_5) - - # Accounts list. Defined here since it's needed below - self.lb_accounts = ListBoxScrolled(frame_tab_account) - - @gui_except() - @gui_confirm_action() - def import_accounts(): - "Imports account from live view" - async def import_accounts_async(): - accs = await self.connection.get_accounts() - # for acc in accs: - # acc.intents = None # Intents cannot be loaded properly - - values = convert_to_object_info(accs) - if not len(values): - raise ValueError("Live view has no elements.") - - self.lb_accounts.clear() - self.lb_accounts.insert(tk.END, *values) - - gui_daf_assert_running() - tae.async_execute(import_accounts_async(), wait=False, pop_up=True, master=self.win_main) - - menu_bnt = ttk.Menubutton( - frame_tab_account, - text="Object options" - ) - menu = ttk.Menu(menu_bnt) - menu.add_command( - label="New ACCOUNT", - command=lambda: self.open_object_edit_window(daf.ACCOUNT, self.lb_accounts) - ) - - menu.add_command(label="Edit", command=self.edit_accounts) - menu.add_command(label="Remove", command=self.lb_accounts.delete_selected) - menu_bnt.configure(menu=menu) - menu_bnt.pack(anchor=tk.W) - - frame_account_bnts = ttk.Frame(frame_tab_account) - frame_account_bnts.pack(fill=tk.X, pady=dpi_5) - ttk.Button( - frame_account_bnts, text="Import from DAF (live)", command=import_accounts - ).pack(side="left") - t = ttk.Button( - frame_account_bnts, text="Load selection to DAF (live)", command=lambda: self.add_accounts_daf(True) - ).pack(side="left") - self.load_at_start_var = ttk.BooleanVar(value=True) - t = ttk.Checkbutton( - frame_account_bnts, text="Load all at start", onvalue=True, offvalue=False, - variable=self.load_at_start_var - ) - ToolTip(t, "When starting DAF, load all accounts from the list automatically.") - t.pack(side="left", padx=dpi_5) - self.save_objects_to_file_var = ttk.BooleanVar(value=False) - t = ttk.Checkbutton( - frame_account_bnts, text="Preserve state on shutdown", onvalue=True, offvalue=False, - variable=self.save_objects_to_file_var, - ) - ToolTip( - t, - "When stopping DAF, save all account state (and guild, message, ...) to a file.\n" - "When starting DAF, load everything from that file." - ) - t.pack(side="left", padx=dpi_5) - - - self.lb_accounts.pack(fill=tk.BOTH, expand=True, side="left") - - # Object tab account tab logging tab - frame_logging = ttk.Labelframe(tab_schema, padding=(dpi_10, dpi_10), text="Logging", bootstyle="primary") - label_logging_mgr = ttk.Label(frame_logging, text="Selected logger:") - label_logging_mgr.pack(anchor=tk.N) - frame_logging.pack(side="left", fill=tk.BOTH, expand=True, pady=dpi_10, padx=dpi_5) - - frame_logger_select = ttk.Frame(frame_logging) - frame_logger_select.pack(fill=tk.X) - self.combo_logging_mgr = ComboBoxObjects(frame_logger_select) - self.bnt_edit_logger = ttk.Button(frame_logger_select, text="Edit", command=self.edit_logger) - self.combo_logging_mgr.pack(fill=tk.X, side="left", expand=True) - self.bnt_edit_logger.pack(anchor=tk.N, side="right") - - self.label_tracing = ttk.Label(frame_logging, text="Selected trace level:") - self.label_tracing.pack(anchor=tk.N) - frame_tracer_select = ttk.Frame(frame_logging) - frame_tracer_select.pack(fill=tk.X) - self.combo_tracing = ComboBoxObjects(frame_tracer_select) - self.combo_tracing.pack(fill=tk.X, side="left", expand=True) - - self.combo_logging_mgr["values"] = [ - ObjectInfo(daf.LoggerJSON, {"path": str(Path.home().joinpath("daf/History"))}), - ObjectInfo(daf.LoggerSQL, {"database": str(Path.home().joinpath("daf/messages")), "dialect": "sqlite"}), - ObjectInfo(daf.LoggerCSV, {"path": str(Path.home().joinpath("daf/History")), "delimiter": ";"}), - ] - self.combo_logging_mgr.current(0) - - tracing_values = [en for en in daf.TraceLEVELS] - self.combo_tracing["values"] = tracing_values - self.combo_tracing.current(tracing_values.index(daf.TraceLEVELS.NORMAL)) - - def init_live_inspect_tab(self): - dpi_10 = dpi_scaled(10) - dpi_5 = dpi_scaled(5) - - @gui_confirm_action() - def remove_account(): - selection = list_live_objects.curselection() - if len(selection): - values = list_live_objects.get() - for i in selection: - tae.async_execute( - self.connection.remove_account(values[i].real_object), - wait=False, - pop_up=True, - callback=self.load_live_accounts, - master=self.win_main - ) - else: - tkdiag.Messagebox.show_error("Select atlest one item!", "Select errror") - - @gui_except() - def add_account(): - gui_daf_assert_running() - selection = combo_add_object_edit.combo.current() - if selection >= 0: - fnc: ObjectInfo = combo_add_object_edit.combo.get() - fnc_data = convert_to_objects(fnc.data) - tae.async_execute(self.connection.add_account(**fnc_data), wait=False, pop_up=True, master=self.win_main) - else: - tkdiag.Messagebox.show_error("Combobox does not have valid selection.", "Combo invalid selection") - - def view_live_account(): - selection = list_live_objects.curselection() - if len(selection) == 1: - object_: ObjectInfo = list_live_objects.get()[selection[0]] - self.open_object_edit_window( - daf.ACCOUNT, - list_live_objects, - old_data=object_ - ) - else: - tkdiag.Messagebox.show_error("Select one item!", "Empty list!") - - tab_live = ttk.Frame(self.tabman_mf, padding=(dpi_10, dpi_10)) - self.tabman_mf.add(tab_live, text="Live view") - frame_add_account = ttk.Frame(tab_live) - frame_add_account.pack(fill=tk.X, pady=dpi_10) - - combo_add_object_edit = ComboEditFrame( - self.open_object_edit_window, - [ObjectInfo(daf.add_object, {})], - master=frame_add_account, - ) - ttk.Button(frame_add_account, text="Execute", command=add_account).pack(side="left") - combo_add_object_edit.pack(side="left", fill=tk.X, expand=True) - - frame_account_opts = ttk.Frame(tab_live) - frame_account_opts.pack(fill=tk.X, pady=dpi_5) - ttk.Button(frame_account_opts, text="Refresh", command=self.load_live_accounts).pack(side="left") - ttk.Button(frame_account_opts, text="Edit", command=view_live_account).pack(side="left") - ttk.Button(frame_account_opts, text="Remove", command=remove_account).pack(side="left") - - list_live_objects = ListBoxScrolled(tab_live) - list_live_objects.pack(fill=tk.BOTH, expand=True) - self.list_live_objects = list_live_objects - # The default bind is removal from list and not from actual daf. - list_live_objects.listbox.unbind_all("") - list_live_objects.listbox.unbind_all("") - list_live_objects.listbox.bind("", lambda e: remove_account()) - list_live_objects.listbox.bind("", lambda e: remove_account()) - - def init_output_tab(self): - self.tab_output = ttk.Frame(self.tabman_mf) - self.tabman_mf.add(self.tab_output, text="Output") - text_output = ListBoxScrolled(self.tab_output) - text_output.unbind("") - text_output.unbind("") - text_output.unbind("") - text_output.pack(fill=tk.BOTH, expand=True) - - class STDIOOutput: - def flush(self_): - pass - - def write(self_, data: str): - if data == '\n': - return - - for r in daf.tracing.TRACE_COLOR_MAP.values(): - data = data.replace(r, "") - - text_output.insert(tk.END, data.replace("\033[0m", "")) - if len(text_output.get()) > 1000: - text_output.delete(0, 500) - - text_output.see(tk.END) - - self._oldstdout = sys.stdout - sys.stdout = STDIOOutput() - - def init_credits_tab(self): - dpi_10 = dpi_scaled(10) - dpi_30 = dpi_scaled(30) - logo_img = Image.open(f"{os.path.dirname(__file__)}/img/logo.png") - logo_img = logo_img.resize( - (dpi_scaled(400), dpi_scaled(400)), - resample=0 - ) - logo = ImageTk.PhotoImage(logo_img) - self.tab_info = ttk.Frame(self.tabman_mf) - self.tabman_mf.add(self.tab_info, text="About") - info_bnts_frame = ttk.Frame(self.tab_info) - info_bnts_frame.pack(pady=dpi_30) - ttk.Button(info_bnts_frame, text="Github", command=lambda: webbrowser.open(GITHUB_URL)).grid(row=0, column=0) - ttk.Button( - info_bnts_frame, - text="Documentation", - command=lambda: webbrowser.open(DOC_URL) - ).grid(row=0, column=1) - ttk.Button( - info_bnts_frame, - text="My Discord server", - command=lambda: webbrowser.open(DISCORD_URL) - ).grid(row=0, column=2) - ttk.Label(self.tab_info, text="Like the app? Give it a star :) on GitHub (^)").pack(pady=dpi_10) - ttk.Label(self.tab_info, text=CREDITS_TEXT).pack() - label_logo = ttk.Label(self.tab_info, image=logo) - label_logo.image = logo - label_logo.pack() - - def init_analytics_tab(self): - dpi_10 = dpi_scaled(10) - dpi_5 = dpi_scaled(5) - tab_analytics = ttk.Notebook(self.tabman_mf, padding=(dpi_5, dpi_5)) # ttk.Frame(self.tabman_mf, padding=(dpi_10, dpi_10)) - self.tabman_mf.add(tab_analytics, text="Analytics") - - def create_analytic_frame( - getter_history: str, - getter_counts: str, - counts_coldata: dict, - tab_name: str - ): - """ - Creates a logging tab. - - Parameters - ------------- - getter_history: str - The name of the LoggerBASE method that is used to retrieve actual logs. - getter_counts: str - The name of the LoggerBASE method that is used to retrieve counts. - counts_coldata: dict - Column data for TableView used for counts. - tab_name: str - The title to write inside the tab button. - """ - async def analytics_load_history(): - gui_daf_assert_running() - logger = await self.connection.get_logger() - - param_object = combo_history.combo.get() - param_object_params = convert_to_objects(param_object.data) - items = await self.connection.execute_method( - it.ObjectReference.from_object(logger), getter_history, **param_object_params - ) - items = convert_to_object_info(items) - lst_history.clear() - lst_history.insert(tk.END, *items) - - def show_log(listbox: ListBoxScrolled): - selection = listbox.curselection() - if len(selection) == 1: - object_: ObjectInfo = listbox.get()[selection[0]] - self.open_object_edit_window( - object_.class_, - listbox, - old_data=object_, - check_parameters=False, - allow_save=False - ) - else: - tkdiag.Messagebox.show_error("Select ONE item!", "Empty list!") - - async def delete_logs_async(primary_keys: List[int]): - logger = await self.connection.get_logger() - await self.connection.execute_method( - it.ObjectReference.from_object(logger), - "delete_logs", - primary_keys=primary_keys # TODO: update on server - ) - - @gui_confirm_action() - def delete_logs(listbox: ListBoxScrolled): - selection = listbox.curselection() - if len(selection): - all_ = listbox.get() - tae.async_execute( - delete_logs_async([all_[i].data["id"] for i in selection]), - wait=False, - pop_up=True, - master=self.win_main - ) - else: - tkdiag.Messagebox.show_error("Select atlest one item!", "Selection error.") - - frame_message = ttk.Frame(tab_analytics, padding=(dpi_5, dpi_5)) - tab_analytics.add(frame_message, text=tab_name) - frame_msg_history = ttk.Labelframe(frame_message, padding=(dpi_10, dpi_10), text="Logs", bootstyle="primary") - frame_msg_history.pack(fill=tk.BOTH, expand=True) - - combo_history = ComboEditFrame( - self.open_object_edit_window, - [ObjectInfo(getattr(daf.logging.LoggerBASE, getter_history), {})], - frame_msg_history, - ) - combo_history.pack(fill=tk.X) - - frame_msg_history_bnts = ttk.Frame(frame_msg_history) - frame_msg_history_bnts.pack(fill=tk.X, pady=dpi_10) - ttk.Button( - frame_msg_history_bnts, - text="Get logs", - command=lambda: tae.async_execute(analytics_load_history(), wait=False, pop_up=True, master=self.win_main) - ).pack(side="left", fill=tk.X) - ttk.Button( - frame_msg_history_bnts, - command=lambda: show_log(lst_history), - text="View log" - ).pack(side="left", fill=tk.X) - ttk.Button( - frame_msg_history_bnts, - command=lambda: delete_logs(lst_history), - text="Delete selected" - ).pack(side="left", fill=tk.X) - lst_history = ListBoxScrolled(frame_msg_history) - lst_history.listbox.unbind_all("") - lst_history.listbox.unbind_all("") - lst_history.listbox.bind("", lambda e: delete_logs(lst_history)) - lst_history.listbox.bind("", lambda e: delete_logs(lst_history)) - lst_history.pack(expand=True, fill=tk.BOTH) - - # Number of messages - async def analytics_load_num(): - gui_daf_assert_running() - logger = await self.connection.get_logger() - param_object = combo_count.combo.get() - parameters = convert_to_objects(param_object.data) - count = await self.connection.execute_method( - it.ObjectReference.from_object(logger), - getter_counts, - **parameters - ) - - tw_num.delete_rows() - tw_num.insert_rows(0, count) - tw_num.goto_first_page() - - frame_num = ttk.Labelframe(frame_message, padding=(dpi_10, dpi_10), text="Counts", bootstyle="primary") - combo_count = ComboEditFrame( - self.open_object_edit_window, - [ObjectInfo(getattr(daf.logging.LoggerBASE, getter_counts), {})], - frame_num, - ) - combo_count.pack(fill=tk.X) - tw_num = tktw.Tableview( - frame_num, - bootstyle="primary", - coldata=counts_coldata, - searchable=True, - paginated=True, - autofit=True) - - ttk.Button( - frame_num, - text="Calculate", - command=lambda: tae.async_execute(analytics_load_num(), wait=False, pop_up=True, master=self.win_main) - ).pack(anchor=tk.W, pady=dpi_10) - - frame_num.pack(fill=tk.BOTH, expand=True, pady=dpi_5) - tw_num.pack(expand=True, fill=tk.BOTH) - - # Message tab - create_analytic_frame( - "analytic_get_message_log", - "analytic_get_num_messages", - [ - {"text": "Date", "stretch": True}, - {"text": "Number of successful", "stretch": True}, - {"text": "Number of failed", "stretch": True}, - {"text": "Guild snowflake", "stretch": True}, - {"text": "Guild name", "stretch": True}, - {"text": "Author snowflake", "stretch": True}, - {"text": "Author name", "stretch": True}, - ], - "Message tracking" - ) - - # Invite tab - create_analytic_frame( - "analytic_get_invite_log", - "analytic_get_num_invites", - [ - {"text": "Date", "stretch": True}, - {"text": "Count", "stretch": True}, - {"text": "Guild snowflake", "stretch": True}, - {"text": "Guild name", "stretch": True}, - {"text": "Invite ID", "stretch": True}, - ], - "Invite tracking" - ) - - def init_optional_dep_tab(self): - dpi_10 = dpi_scaled(10) - dpi_5 = dpi_scaled(5) - frame_optionals = ttk.Frame(self.tabman_mf, padding=(dpi_10, dpi_10)) - self.tabman_mf.add(frame_optionals, text="Optional modules") - ttk.Label( - frame_optionals, - text= - "This section allows you to install optional packages available inside DAF\n" - "Be aware that loading may be slower when installing these." - ).pack(anchor=tk.NW) - frame_optionals_packages = ttk.Frame(frame_optionals) - frame_optionals_packages.pack(fill=tk.BOTH, expand=True) - - def install_deps(optional: str, gauge: ttk.Floodgauge, bnt: ttk.Button): - @gui_except() - def _installer(): - subprocess.check_call([ - sys.executable.replace("pythonw", "python"), "-m", "pip", "install", - f"discord-advert-framework[{optional}]=={daf.VERSION}" - ]) - tkdiag.Messagebox.show_info("To apply the changes, restart the program!") - - return _installer - - for row, (title, optional_name, installed_flag) in enumerate(OPTIONAL_MODULES): - ttk.Label(frame_optionals_packages, text=title).grid(row=row, column=0) - gauge = ttk.Floodgauge( - frame_optionals_packages, bootstyle=ttk.SUCCESS if installed_flag else ttk.DANGER, value=0 - ) - gauge.grid(pady=dpi_5, row=row, column=1) - if not installed_flag: - gauge.start() - bnt_install = ttk.Button(frame_optionals_packages, text="Install") - bnt_install.configure(command=install_deps(optional_name, gauge, bnt_install)) - bnt_install.grid(row=row, column=1) - - def open_object_edit_window(self, *args, **kwargs): - if self.objects_edit_window is None or self.objects_edit_window.closed: - self.objects_edit_window = ObjectEditWindow() - self.objects_edit_window.open_object_edit_frame(*args, **kwargs) - else: - tkdiag.Messagebox.show_error("Object edit window is already open, close it first.", "Already open") - self.objects_edit_window.focus() - - @gui_except() - def load_live_accounts(self): - async def load_accounts(): - gui_daf_assert_running() - object_infos = convert_to_object_info(await self.connection.get_accounts(), save_original=True) - self.list_live_objects.clear() - self.list_live_objects.insert(tk.END, *object_infos) - - tae.async_execute(load_accounts(), wait=False, pop_up=True, master=self.win_main) - - def edit_logger(self): - selection = self.combo_logging_mgr.current() - if selection >= 0: - object_: ObjectInfo = self.combo_logging_mgr.get() - self.open_object_edit_window(object_.class_, self.combo_logging_mgr, old_data=object_) - else: - tkdiag.Messagebox.show_error("Select atleast one item!", "Empty list!") - - def edit_accounts(self): - selection = self.lb_accounts.curselection() - if len(selection): - object_: ObjectInfo = self.lb_accounts.get()[selection[0]] - self.open_object_edit_window(daf.ACCOUNT, self.lb_accounts, old_data=object_) - else: - tkdiag.Messagebox.show_error("Select atleast one item!", "Empty list!") - - def generate_daf_script(self): - """ - Converts the schema into DAF script - """ - filename = tkfile.asksaveasfilename(filetypes=[("DAF Python script", "*.py")], ) - if filename == "": - return - - if not filename.endswith(".py"): - filename += ".py" - - logger = self.combo_logging_mgr.get() - tracing = self.combo_tracing.get() - connection_mgr = self.combo_connection_edit.combo.get() - logger_is_present = str(logger) != "" - tracing_is_present = str(tracing) != "" - remote_is_present = isinstance(connection_mgr, ObjectInfo) and connection_mgr.class_ is RemoteConnectionCLIENT - run_logger_str = "\n logger=logger," if logger_is_present else "" - run_tracing_str = f"\n debug={tracing}," if tracing_is_present else "" - run_remote_str = "\n remote_client=remote_client," if remote_is_present else "" - - accounts: list[ObjectInfo] = self.lb_accounts.get() - - accounts_str, imports = convert_objects_to_script(accounts) - imports = "\n".join(set(imports)) - - if logger_is_present: - logger_str, logger_imports = convert_objects_to_script(logger) - logger_imports = "\n".join(set(logger_imports)) - else: - logger_imports = "" - - if remote_is_present: - connection_mgr: RemoteConnectionCLIENT = convert_to_objects(connection_mgr) - kwargs = {"host": "0.0.0.0", "port": connection_mgr.port} - if connection_mgr.auth is not None: - kwargs["username"] = connection_mgr.auth.login - kwargs["password"] = connection_mgr.auth.password - - remote_str, remote_imports = convert_objects_to_script(ObjectInfo(daf.RemoteAccessCLIENT, kwargs)) - remote_imports = "\n".join(set(remote_imports)) - else: - remote_str, remote_imports = "", "" - - _ret = f''' -""" -Automatically generated file for Discord Advertisement Framework {daf.VERSION}. -This can be run eg. 24/7 on a server without graphical interface. - -The file has the required classes and functions imported, then the logger is defined and the -accounts list is defined. - -At the bottom of the file the framework is then started with the run function. -""" - -# Import the necessary items -{logger_imports} -{remote_imports} -{imports} -{f"from {tracing.__module__} import {tracing.__class__.__name__}" if tracing_is_present else ""} -import daf - -# Define the logger -{f"logger = {logger_str}" if logger_is_present else ""} - -# Define remote control context -{f"remote_client = {remote_str}" if remote_is_present else ""} - -# Defined accounts -accounts = {accounts_str} - -# Run the framework (blocking) -daf.run( - accounts=accounts,{run_logger_str}{run_tracing_str}{run_remote_str} - save_to_file={self.save_objects_to_file_var.get()} -) -''' - with open(filename, "w", encoding="utf-8") as file: - file.write(_ret) - - tkdiag.Messagebox.show_info(f"Saved to {filename}", "Finished", self.win_main) - - @gui_except() - def save_schema(self) -> bool: - filename = tkfile.asksaveasfilename(filetypes=[("JSON", "*.json")]) - if filename == "": - return False - - json_data = { - "loggers": { - "all": convert_to_dict(self.combo_logging_mgr["values"]), - "selected_index": self.combo_logging_mgr.current(), - }, - "tracing": self.combo_tracing.current(), - "accounts": convert_to_dict(self.lb_accounts.get()), - "connection": { - "all": convert_to_dict(self.combo_connection_edit.combo["values"]), - "selected_index": self.combo_connection_edit.combo.current() - } - } - - if not filename.endswith(".json"): - filename += ".json" - - with open(filename, "w", encoding="utf-8") as file: - json.dump(json_data, file, indent=2) - - tkdiag.Messagebox.show_info(f"Saved to {filename}", "Finished", self.win_main) - - return True - - @gui_except() - def load_schema(self): - filename = tkfile.askopenfilename(filetypes=[("JSON", "*.json")]) - if filename == "": - return - - with open(filename, "r", encoding="utf-8") as file: - json_data = json.load(file) - - # Load accounts - accounts = json_data.get("accounts") - if accounts is not None: - accounts = convert_from_dict(accounts) - self.lb_accounts.clear() - self.lb_accounts.insert(tk.END, *accounts) - - # Load loggers - logging_data = json_data.get("loggers") - if logging_data is not None: - loggers = [convert_from_dict(x) for x in logging_data["all"]] - - self.combo_logging_mgr["values"] = loggers - selected_index = logging_data["selected_index"] - if selected_index >= 0: - self.combo_logging_mgr.current(selected_index) - - # Tracing - tracing_index = json_data.get("tracing") - if tracing_index is not None and tracing_index >= 0: - self.combo_tracing.current(json_data["tracing"]) - - # Remote client - connection_data = json_data.get("connection") - if connection_data is not None: - clients = [convert_from_dict(x) for x in connection_data["all"]] - - self.combo_connection_edit.combo["values"] = clients - selected_index = connection_data["selected_index"] - if selected_index >= 0: - self.combo_connection_edit.combo.current(selected_index) - - # @gui_except() def start_daf(self): # Initialize connection - connection = convert_to_objects(self.combo_connection_edit.combo.get()) + connection: AbstractConnectionCLIENT = convert_to_objects(self.combo_connection_edit.combo.get()) self.connection = connection kwargs = {} - logger = self.combo_logging_mgr.get() - if logger is not None and not isinstance(logger, str): - kwargs["logger"] = convert_to_objects(logger) - - tracing = self.combo_tracing.get() - if not isinstance(tracing, str): - kwargs["debug"] = tracing + kwargs["logger"] = self.tab_schema.get_logger() + kwargs["debug"] = self.tab_schema.get_tracing() window = tae.async_execute( - connection.initialize(**kwargs, save_to_file=self.save_objects_to_file_var.get()), + connection.initialize(**kwargs, save_to_file=self.tab_schema.save_to_file), wait=True, pop_up=True, show_exceptions=False, @@ -930,8 +234,8 @@ def start_daf(self): raise exc self._daf_running = True - if self.load_at_start_var.get(): - self.add_accounts_daf() + if self.tab_schema.auto_load_accounts: + self.tab_schema.load_accounts() self.bnt_toolbar_start_daf.configure(state="disabled") self.bnt_toolbar_stop_daf.configure(state="enabled") @@ -940,37 +244,16 @@ def stop_daf(self): self._daf_running = False self.bnt_toolbar_start_daf.configure(state="enabled") self.bnt_toolbar_stop_daf.configure(state="disabled") - tae.async_execute(self.connection.shutdown(), wait=False, pop_up=True, master=self.win_main) - - @gui_except() - def add_accounts_daf(self, selection: bool = False): - gui_daf_assert_running() - accounts = self.lb_accounts.get() - if selection: - indexes = self.lb_accounts.curselection() - if not len(indexes): - raise ValueError("Select at least one item.") - - indexes = set(indexes) - accounts = [a for i, a in enumerate(accounts) if i in indexes] - - for account in accounts: - tae.async_execute( - self.connection.add_account(convert_to_objects(account)), - wait=False, - pop_up=True, - master=self.win_main - ) + tae.async_execute(get_connection().shutdown(), pop_up=True) def close_window(self): resp = tkdiag.Messagebox.yesnocancel("Do you wish to save?", "Save?", alert=True, parent=self.win_main) - if resp is None or resp == "Cancel" or resp == "Yes" and not self.save_schema(): + if resp is None or resp == "Cancel" or resp == "Yes" and not self.tab_schema.save_schema(): return if self._daf_running: - tae.async_execute(self.connection.shutdown(), pop_up=True) + self.stop_daf() - sys.stdout = self._oldstdout self.win_main.quit() def until_closed(self): diff --git a/src/daf_gui/tabs/__init__.py b/src/daf_gui/tabs/__init__.py new file mode 100644 index 00000000..b7baa562 --- /dev/null +++ b/src/daf_gui/tabs/__init__.py @@ -0,0 +1,6 @@ +from .analytics import * +from .optional import * +from .schema import * +from .about import * +from .debug import * +from .live import * \ No newline at end of file diff --git a/src/daf_gui/tabs/about.py b/src/daf_gui/tabs/about.py new file mode 100644 index 00000000..566a9ba0 --- /dev/null +++ b/src/daf_gui/tabs/about.py @@ -0,0 +1,59 @@ +import ttkbootstrap as ttk + +from tkclasswiz.dpi import dpi_scaled +from PIL import Image, ImageTk + +import webbrowser +import daf +import os + + +__all__ = ( + "AboutTab", +) + + +CREDITS_TEXT = \ +""" +Welcome to Discord Advertisement Framework - UI mode. +The UI runs on top of Discord Advertisement Framework and allows easier usage for those who +don't want to write Python code to use the software. + +This is written as part of my bachelor thesis as a degree finishing project +"Framework for advertising NFT on social network Discord". +""" + +GITHUB_URL = "https://github.com/davidhozic/discord-advertisement-framework" +DOC_URL = f"https://daf.davidhozic.com/en/v{'.'.join(daf.VERSION.split('.')[:2])}.x/" +DISCORD_URL = "https://discord.gg/DEnvahb2Sw" + + +class AboutTab(ttk.Frame): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + dpi_10 = dpi_scaled(10) + dpi_30 = dpi_scaled(30) + logo_img = Image.open(f"{os.path.dirname(__file__)}/../img/logo.png") + logo_img = logo_img.resize( + (dpi_scaled(400), dpi_scaled(400)), + resample=0 + ) + logo = ImageTk.PhotoImage(logo_img) + info_bnts_frame = ttk.Frame(self) + info_bnts_frame.pack(pady=dpi_30) + ttk.Button(info_bnts_frame, text="Github", command=lambda: webbrowser.open(GITHUB_URL)).grid(row=0, column=0) + ttk.Button( + info_bnts_frame, + text="Documentation", + command=lambda: webbrowser.open(DOC_URL) + ).grid(row=0, column=1) + ttk.Button( + info_bnts_frame, + text="My Discord server", + command=lambda: webbrowser.open(DISCORD_URL) + ).grid(row=0, column=2) + ttk.Label(self, text="Like the app? Give it a star :) on GitHub (^)").pack(pady=dpi_10) + ttk.Label(self, text=CREDITS_TEXT).pack() + label_logo = ttk.Label(self, image=logo) + label_logo.image = logo + label_logo.pack() diff --git a/src/daf_gui/tabs/analytics.py b/src/daf_gui/tabs/analytics.py new file mode 100644 index 00000000..c05033d7 --- /dev/null +++ b/src/daf_gui/tabs/analytics.py @@ -0,0 +1,207 @@ +from ttkbootstrap.tableview import Tableview +from tkclasswiz.utilities import gui_confirm_action +from tkclasswiz.dpi import dpi_scaled +from tkclasswiz.convert import * +from tkclasswiz.storage import * +from typing import List + +from ..edit_window_manager import * +from ..connector import * + +import ttkbootstrap.dialogs as tkdiag +import ttkbootstrap as ttk +import tkinter as tk + +import daf.misc.instance_track as it +import tk_async_execute as tae +import daf + +__all__ = ( + "AnalyticsTab", + "AnalyticFrame", +) + + +class AnalyticsTab(ttk.Notebook): + def __init__(self, edit_mgr: EditWindowManager, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + dpi_10 = dpi_scaled(10) + + self.add( + AnalyticFrame( + "analytic_get_message_log", + "analytic_get_num_messages", + [ + {"text": "Date", "stretch": True}, + {"text": "Number of successful", "stretch": True}, + {"text": "Number of failed", "stretch": True}, + {"text": "Guild snowflake", "stretch": True}, + {"text": "Guild name", "stretch": True}, + {"text": "Author snowflake", "stretch": True}, + {"text": "Author name", "stretch": True}, + ], + edit_mgr, + master=self, + padding=(dpi_10, dpi_10) + ), + text="Message tracking", + ) + + self.add( + AnalyticFrame( + "analytic_get_invite_log", + "analytic_get_num_invites", + [ + {"text": "Date", "stretch": True}, + {"text": "Count", "stretch": True}, + {"text": "Guild snowflake", "stretch": True}, + {"text": "Guild name", "stretch": True}, + {"text": "Invite ID", "stretch": True}, + ], + edit_mgr, + master=self, + padding=(dpi_10, dpi_10) + ), + text="Invite tracking", + ) + + +class AnalyticFrame(ttk.Frame): + def __init__( + self, + getter_history: str, + getter_counts: str, + counts_coldata: dict, + edit_mgr: EditWindowManager, + *args, + **kwargs + ): + super().__init__(*args, **kwargs) + dpi_10 = dpi_scaled(10) + dpi_5 = dpi_scaled(10) + + self.edit_mgr = edit_mgr + + async def analytics_load_history(): + connection = get_connection() + logger = await connection.get_logger() + + param_object = combo_history.combo.get() + param_object_params = convert_to_objects(param_object.data) + items = await connection.execute_method( + it.ObjectReference.from_object(logger), getter_history, **param_object_params + ) + items = convert_to_object_info(items) + lst_history.clear() + lst_history.insert(tk.END, *items) + + def show_log(listbox: ListBoxScrolled): + selection = listbox.curselection() + if len(selection) == 1: + object_: ObjectInfo = listbox.get()[selection[0]] + self.edit_mgr.open_object_edit_window( + object_.class_, + listbox, + old_data=object_, + check_parameters=False, + allow_save=False + ) + else: + tkdiag.Messagebox.show_error("Select ONE item!", "Empty list!") + + async def delete_logs_async(primary_keys: List[int]): + connection = get_connection() + logger = await connection.get_logger() + await connection.execute_method( + it.ObjectReference.from_object(logger), + "delete_logs", + primary_keys=primary_keys # TODO: update on server + ) + + @gui_confirm_action() + def delete_logs(listbox: ListBoxScrolled): + selection = listbox.curselection() + if len(selection): + all_ = listbox.get() + tae.async_execute( + delete_logs_async([all_[i].data["id"] for i in selection]), + wait=False, + pop_up=True, + master=self + ) + else: + tkdiag.Messagebox.show_error("Select atlest one item!", "Selection error.") + + frame_msg_history = ttk.Labelframe(self, padding=(dpi_10, dpi_10), text="Logs", bootstyle="primary") + frame_msg_history.pack(fill=tk.BOTH, expand=True) + + combo_history = ComboEditFrame( + self.edit_mgr.open_object_edit_window, + [ObjectInfo(getattr(daf.logging.LoggerBASE, getter_history), {})], + frame_msg_history, + ) + combo_history.pack(fill=tk.X) + + frame_msg_history_bnts = ttk.Frame(frame_msg_history) + frame_msg_history_bnts.pack(fill=tk.X, pady=dpi_10) + ttk.Button( + frame_msg_history_bnts, + text="Get logs", + command=lambda: tae.async_execute(analytics_load_history(), wait=False, pop_up=True, master=self) + ).pack(side="left", fill=tk.X) + ttk.Button( + frame_msg_history_bnts, + command=lambda: show_log(lst_history), + text="View log" + ).pack(side="left", fill=tk.X) + ttk.Button( + frame_msg_history_bnts, + command=lambda: delete_logs(lst_history), + text="Delete selected" + ).pack(side="left", fill=tk.X) + lst_history = ListBoxScrolled(frame_msg_history) + lst_history.listbox.unbind_all("") + lst_history.listbox.unbind_all("") + lst_history.listbox.bind("", lambda e: delete_logs(lst_history)) + lst_history.listbox.bind("", lambda e: delete_logs(lst_history)) + lst_history.pack(expand=True, fill=tk.BOTH) + + # Number of messages + async def analytics_load_num(): + connection = get_connection() + logger = await connection.get_logger() + param_object = combo_count.combo.get() + parameters = convert_to_objects(param_object.data) + count = await connection.execute_method( + it.ObjectReference.from_object(logger), + getter_counts, + **parameters + ) + + tw_num.delete_rows() + tw_num.insert_rows(0, count) + tw_num.goto_first_page() + + frame_num = ttk.Labelframe(self, padding=(dpi_10, dpi_10), text="Counts", bootstyle="primary") + combo_count = ComboEditFrame( + self.edit_mgr.open_object_edit_window, + [ObjectInfo(getattr(daf.logging.LoggerBASE, getter_counts), {})], + frame_num, + ) + combo_count.pack(fill=tk.X) + tw_num = Tableview( + frame_num, + bootstyle="primary", + coldata=counts_coldata, + searchable=True, + paginated=True, + autofit=True) + + ttk.Button( + frame_num, + text="Calculate", + command=lambda: tae.async_execute(analytics_load_num(), wait=False, pop_up=True, master=self) + ).pack(anchor=tk.W, pady=dpi_10) + + frame_num.pack(fill=tk.BOTH, expand=True, pady=dpi_5) + tw_num.pack(expand=True, fill=tk.BOTH) diff --git a/src/daf_gui/tabs/debug.py b/src/daf_gui/tabs/debug.py new file mode 100644 index 00000000..7ba386d9 --- /dev/null +++ b/src/daf_gui/tabs/debug.py @@ -0,0 +1,42 @@ +import ttkbootstrap as ttk +import tkinter as tk + +from tkclasswiz.storage import ListBoxScrolled + +import daf +import sys + + +__all__ = ( + "DebugTab", +) + + +class DebugTab(ttk.Frame): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + text_output = ListBoxScrolled(self) + text_output.unbind("") + text_output.unbind("") + text_output.unbind("") + text_output.pack(fill=tk.BOTH, expand=True) + + class STDIOOutput: + def flush(self_): + pass + + def write(self_, data: str): + if data == '\n': + return + + for r in daf.tracing.TRACE_COLOR_MAP.values(): + data = data.replace(r, "") + + text_output.insert(tk.END, data.replace("\033[0m", "")) + if len(text_output.get()) > 1000: + text_output.delete(0, 500) + + text_output.see(tk.END) + + self._oldstdout = sys.stdout + sys.stdout = STDIOOutput() diff --git a/src/daf_gui/tabs/live.py b/src/daf_gui/tabs/live.py new file mode 100644 index 00000000..883dd8f4 --- /dev/null +++ b/src/daf_gui/tabs/live.py @@ -0,0 +1,105 @@ +import ttkbootstrap as ttk + +from tkclasswiz.utilities import gui_except, gui_confirm_action +from tkclasswiz.dpi import dpi_scaled +from tkclasswiz.convert import * +from tkclasswiz.storage import * + +from ..edit_window_manager import * +from ..connector import * + +import ttkbootstrap.dialogs as tkdiag +import tk_async_execute as tae +import tkinter as tk +import daf + + +__all__ = ( + "LiveTab", +) + + +class LiveTab(ttk.Frame): + def __init__(self, edit_mgr: EditWindowManager, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + dpi_10 = dpi_scaled(10) + dpi_5 = dpi_scaled(5) + + self.edit_mgr = edit_mgr + frame_add_account = ttk.Frame(self) + frame_add_account.pack(fill=tk.X, pady=dpi_10) + + self.combo_add_object_edit = ComboEditFrame( + self.edit_mgr.open_object_edit_window, + [ObjectInfo(daf.add_object, {})], + master=frame_add_account, + ) + ttk.Button(frame_add_account, text="Execute", command=self.add_account).pack(side="left") + self.combo_add_object_edit.pack(side="left", fill=tk.X, expand=True) + + frame_account_opts = ttk.Frame(self) + frame_account_opts.pack(fill=tk.X, pady=dpi_5) + ttk.Button(frame_account_opts, text="Refresh", command=self.load_accounts).pack(side="left") # TODO + ttk.Button(frame_account_opts, text="Edit", command=self.view_live_account).pack(side="left") + ttk.Button(frame_account_opts, text="Remove", command=self.remove_account).pack(side="left") + + list_live_objects = ListBoxScrolled(self) + list_live_objects.pack(fill=tk.BOTH, expand=True) + self.list_live_objects = list_live_objects + # The default bind is removal from list and not from actual daf. + list_live_objects.listbox.unbind_all("") + list_live_objects.listbox.unbind_all("") + list_live_objects.listbox.bind("", lambda e: self.remove_account()) + list_live_objects.listbox.bind("", lambda e: self.remove_account()) + + @gui_except() + def add_account(self): + connection = get_connection() + selection = self.combo_add_object_edit.combo.current() + if selection >= 0: + fnc: ObjectInfo = self.combo_add_object_edit.combo.get() + fnc_data = convert_to_objects(fnc.data) + tae.async_execute(connection.add_account(**fnc_data), wait=False, pop_up=True, master=self) + else: + tkdiag.Messagebox.show_error("Combobox does not have valid selection.", "Combo invalid selection") + + @gui_confirm_action() + @gui_except() + def remove_account(self): + connection = get_connection() + selection = self.list_live_objects.curselection() + if len(selection): + values = self.list_live_objects.get() + for i in selection: + tae.async_execute( + connection.remove_account(values[i].real_object), + wait=False, + pop_up=True, + callback=self.load_accounts, + master=self + ) + else: + tkdiag.Messagebox.show_error("Select atlest one item!", "Select errror") + + @gui_except() + def view_live_account(self): + selection = self.list_live_objects.curselection() + if len(selection) == 1: + object_: ObjectInfo = self.list_live_objects.get()[selection[0]] + self.edit_mgr.open_object_edit_window( + daf.ACCOUNT, + self.list_live_objects, + old_data=object_ + ) + else: + tkdiag.Messagebox.show_error("Select one item!", "Empty list!") + + @gui_except() + def load_accounts(self): + connection = get_connection() + async def _load_accounts(): + object_infos = convert_to_object_info(await connection.get_accounts(), save_original=True) + self.list_live_objects.clear() + self.list_live_objects.insert(tk.END, *object_infos) + + tae.async_execute(_load_accounts(), wait=False, pop_up=True, master=self) diff --git a/src/daf_gui/tabs/optional.py b/src/daf_gui/tabs/optional.py new file mode 100644 index 00000000..de536ad1 --- /dev/null +++ b/src/daf_gui/tabs/optional.py @@ -0,0 +1,67 @@ +import ttkbootstrap as ttk + +from tkclasswiz.utilities import gui_except +from tkclasswiz.dpi import dpi_scaled +from tkclasswiz.convert import * +from tkclasswiz.storage import * + +from ..connector import * + +import ttkbootstrap.dialogs as tkdiag +import tkinter as tk +import subprocess +import sys + +import daf + + +__all__ = ( + "OptionalTab", +) + + +OPTIONAL_MODULES = [ + # Label, optional name, installed var + ("SQL logging", "sql", daf.logging.sql.SQL_INSTALLED), + ("Voice messages", "voice", daf.message.voice_based.GLOBAL.voice_installed), + ("Web features (Chrome)", "web", daf.web.GLOBALS.selenium_installed), +] + + +class OptionalTab(ttk.Frame): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + dpi_5 = dpi_scaled(5) + + ttk.Label( + self, + text= + "This section allows you to install optional packages available inside DAF\n" + "Be aware that loading may be slower when installing these." + ).pack(anchor=tk.NW) + + frame_optionals_packages = ttk.Frame(self) + frame_optionals_packages.pack(fill=tk.BOTH, expand=True) + + def install_deps(optional: str, gauge: ttk.Floodgauge, bnt: ttk.Button): + @gui_except() + def _installer(): + subprocess.check_call([ + sys.executable.replace("pythonw", "python"), "-m", "pip", "install", + f"discord-advert-framework[{optional}]=={daf.VERSION}" + ]) + tkdiag.Messagebox.show_info("To apply the changes, restart the program!") + + return _installer + + for row, (title, optional_name, installed_flag) in enumerate(OPTIONAL_MODULES): + ttk.Label(frame_optionals_packages, text=title).grid(row=row, column=0) + gauge = ttk.Floodgauge( + frame_optionals_packages, bootstyle=ttk.SUCCESS if installed_flag else ttk.DANGER, value=0 + ) + gauge.grid(pady=dpi_5, row=row, column=1) + if not installed_flag: + gauge.start() + bnt_install = ttk.Button(frame_optionals_packages, text="Install") + bnt_install.configure(command=install_deps(optional_name, gauge, bnt_install)) + bnt_install.grid(row=row, column=1) diff --git a/src/daf_gui/tabs/schema.py b/src/daf_gui/tabs/schema.py new file mode 100644 index 00000000..a38af308 --- /dev/null +++ b/src/daf_gui/tabs/schema.py @@ -0,0 +1,365 @@ +from ttkbootstrap.tooltip import ToolTip + +from tkclasswiz.utilities import gui_except, gui_confirm_action +from tkclasswiz.dpi import dpi_scaled +from tkclasswiz.convert import * +from tkclasswiz.storage import * + +from operator import getitem +from pathlib import Path + +from ..edit_window_manager import * +from ..connector import * + + +import ttkbootstrap.dialogs as tkdiag +import tkinter.filedialog as tkfile +import ttkbootstrap as ttk +import tkinter as tk + +import tk_async_execute as tae +import json +import daf + + +__all__ = ( + "SchemaTab", +) + + +class SchemaTab(ttk.Frame): + """ + The schema tab of DAF GUI application. + """ + def __init__( + self, + edit_window_manager: EditWindowManager, + combo_conn: ComboEditFrame, + *args, + **kwargs, + ): + dpi_10 = dpi_scaled(10) + dpi_5 = dpi_scaled(5) + + super().__init__(*args, **kwargs) + + self.edit_mgr = edit_window_manager + self.combo_conn = combo_conn + + # Object tab file menu + bnt_file_menu = ttk.Menubutton(self, text="Schema") + menubar_file = tk.Menu(bnt_file_menu) + menubar_file.add_command(label="Save schema", command=self.save_schema) + menubar_file.add_command(label="Load schema", command=self.load_schema) + menubar_file.add_command(label="Generate script", command=self.save_schema_as_script) + bnt_file_menu.configure(menu=menubar_file) + bnt_file_menu.pack(anchor=tk.W) + + # Object tab account tab + frame_tab_account = ttk.Labelframe( + self, + text="Accounts", padding=(dpi_10, dpi_10), bootstyle="primary") + frame_tab_account.pack(side="left", fill=tk.BOTH, expand=True, pady=dpi_10, padx=dpi_5) + + # Accounts list. Defined here since it's needed below + self.lb_accounts = ListBoxScrolled(frame_tab_account) + + menu_bnt = ttk.Menubutton( + frame_tab_account, + text="Object options" + ) + menu = ttk.Menu(menu_bnt) + menu.add_command( + label="New ACCOUNT", + command=lambda: self.edit_mgr.open_object_edit_window(daf.ACCOUNT, self.lb_accounts) + ) + + menu.add_command(label="Edit", command=self.edit_accounts) + menu.add_command(label="Remove", command=self.lb_accounts.delete_selected) + menu_bnt.configure(menu=menu) + menu_bnt.pack(anchor=tk.W) + + frame_account_bnts = ttk.Frame(frame_tab_account) + frame_account_bnts.pack(fill=tk.X, pady=dpi_5) + ttk.Button( + frame_account_bnts, text="Import from DAF (live)", command=self.import_accounts + ).pack(side="left") + t = ttk.Button( + frame_account_bnts, text="Load selection to DAF (live)", command=lambda: self.load_accounts(True) + ).pack(side="left") + self.load_at_start_var = ttk.BooleanVar(value=True) + t = ttk.Checkbutton( + frame_account_bnts, text="Load all at start", onvalue=True, offvalue=False, + variable=self.load_at_start_var + ) + ToolTip(t, "When starting DAF, load all accounts from the list automatically.") + t.pack(side="left", padx=dpi_5) + self.save_objects_to_file_var = ttk.BooleanVar(value=False) + t = ttk.Checkbutton( + frame_account_bnts, text="Preserve state on shutdown", onvalue=True, offvalue=False, + variable=self.save_objects_to_file_var, + ) + ToolTip( + t, + "When stopping DAF, save all account state (and guild, message, ...) to a file.\n" + "When starting DAF, load everything from that file." + ) + t.pack(side="left", padx=dpi_5) + + self.lb_accounts.pack(fill=tk.BOTH, expand=True, side="left") + + # Object tab account tab logging tab + frame_logging = ttk.Labelframe(self, padding=(dpi_10, dpi_10), text="Logging", bootstyle="primary") + label_logging_mgr = ttk.Label(frame_logging, text="Selected logger:") + label_logging_mgr.pack(anchor=tk.N) + frame_logging.pack(side="left", fill=tk.BOTH, expand=True, pady=dpi_10, padx=dpi_5) + + frame_logger_select = ttk.Frame(frame_logging) + frame_logger_select.pack(fill=tk.X) + self.combo_logging_mgr = ComboBoxObjects(frame_logger_select) + self.bnt_edit_logger = ttk.Button(frame_logger_select, text="Edit", command=self.edit_logger) + self.combo_logging_mgr.pack(fill=tk.X, side="left", expand=True) + self.bnt_edit_logger.pack(anchor=tk.N, side="right") + + self.label_tracing = ttk.Label(frame_logging, text="Selected trace level:") + self.label_tracing.pack(anchor=tk.N) + frame_tracer_select = ttk.Frame(frame_logging) + frame_tracer_select.pack(fill=tk.X) + self.combo_tracing = ComboBoxObjects(frame_tracer_select) + self.combo_tracing.pack(fill=tk.X, side="left", expand=True) + + self.combo_logging_mgr["values"] = [ + ObjectInfo(daf.LoggerJSON, {"path": str(Path.home().joinpath("daf/History"))}), + ObjectInfo(daf.LoggerSQL, {"database": str(Path.home().joinpath("daf/messages")), "dialect": "sqlite"}), + ObjectInfo(daf.LoggerCSV, {"path": str(Path.home().joinpath("daf/History")), "delimiter": ";"}), + ] + self.combo_logging_mgr.current(0) + + tracing_values = [en for en in daf.TraceLEVELS] + self.combo_tracing["values"] = tracing_values + self.combo_tracing.current(tracing_values.index(daf.TraceLEVELS.NORMAL)) + + @property + def save_to_file(self): + return self.save_objects_to_file_var.get() + + @property + def auto_load_accounts(self): + return self.load_at_start_var.get() + + def get_tracing(self) -> daf.TraceLEVELS: + return self._get_converted(self.combo_tracing, daf.TraceLEVELS) + + def get_logger(self) -> daf.LoggerBASE: + return self._get_converted(self.combo_logging_mgr, daf.LoggerBASE) + + @gui_except() + def _get_converted(self, combo: ComboBoxObjects, cls: type): + value = combo.get() + conv = convert_to_objects(value) + if not isinstance(conv, cls): + raise ValueError(f"Invalid tracing value {value}.") + + return conv + + @gui_except() + @gui_confirm_action() + def import_accounts(self): + "Imports account from live view" + async def import_accounts_async(): + accs = await get_connection().get_accounts() + # for acc in accs: + # acc.intents = None # Intents cannot be loaded properly + + values = convert_to_object_info(accs) + if not len(values): + raise ValueError("Live view has no elements.") + + self.lb_accounts.clear() + self.lb_accounts.insert(tk.END, *values) + + tae.async_execute(import_accounts_async(), wait=False, pop_up=True, master=self) + + @gui_except() + def load_accounts(self, selection: bool = False): + connection = get_connection() + accounts = self.lb_accounts.get() + if selection: + accounts = [accounts[i] for i in self.lb_accounts.curselection()] + + accounts = convert_to_objects(list(accounts)) + + for account in accounts: + tae.async_execute( + connection.add_account(account), + wait=False, + pop_up=True, + master=self + ) + + @gui_except() + def save_schema(self) -> bool: + filename = tkfile.asksaveasfilename(filetypes=[("JSON", "*.json")]) + if filename == "": + return False + + json_data = { + "loggers": { + "all": convert_to_dict(self.combo_logging_mgr["values"]), + "selected_index": self.combo_logging_mgr.current(), + }, + "tracing": self.combo_tracing.current(), + "accounts": convert_to_dict(self.lb_accounts.get()), + "connection": { + "all": convert_to_dict(self.combo_conn.combo["values"]), + "selected_index": self.combo_conn.combo.current() + } + } + + if not filename.endswith(".json"): + filename += ".json" + + with open(filename, "w", encoding="utf-8") as file: + json.dump(json_data, file, indent=2) + + tkdiag.Messagebox.show_info(f"Saved to {filename}", "Finished", self) + return True + + @gui_except() + def load_schema(self): + filename = tkfile.askopenfilename(filetypes=[("JSON", "*.json")]) + if filename == "": + return + + with open(filename, "r", encoding="utf-8") as file: + json_data = json.load(file) + + # Load accounts + accounts = json_data.get("accounts") + if accounts is not None: + accounts = convert_from_dict(accounts) + self.lb_accounts.clear() + self.lb_accounts.insert(tk.END, *accounts) + + # Load loggers + logging_data = json_data.get("loggers") + if logging_data is not None: + loggers = [convert_from_dict(x) for x in logging_data["all"]] + + self.combo_logging_mgr["values"] = loggers + selected_index = logging_data["selected_index"] + if selected_index >= 0: + self.combo_logging_mgr.current(selected_index) + + # Tracing + tracing_index = json_data.get("tracing") + if tracing_index is not None and tracing_index >= 0: + self.combo_tracing.current(json_data["tracing"]) + + # Remote client + connection_data = json_data.get("connection") + if connection_data is not None: + clients = [convert_from_dict(x) for x in connection_data["all"]] + + self.combo_conn.combo["values"] = clients + selected_index = connection_data["selected_index"] + if selected_index >= 0: + self.combo_conn.combo.current(selected_index) + + def save_schema_as_script(self): + """ + Converts the schema into DAF script + """ + filename = tkfile.asksaveasfilename(filetypes=[("DAF Python script", "*.py")], ) + if filename == "": + return + + if not filename.endswith(".py"): + filename += ".py" + + logger = self.combo_logging_mgr.get() + tracing = self.combo_tracing.get() + connection_mgr = self.combo_conn.combo.get() + logger_is_present = str(logger) != "" + tracing_is_present = str(tracing) != "" + remote_is_present = isinstance(connection_mgr, ObjectInfo) and connection_mgr.class_ is RemoteConnectionCLIENT + run_logger_str = "\n logger=logger," if logger_is_present else "" + run_tracing_str = f"\n debug={tracing}," if tracing_is_present else "" + run_remote_str = "\n remote_client=remote_client," if remote_is_present else "" + + accounts: list[ObjectInfo] = self.lb_accounts.get() + + accounts_str, imports = convert_objects_to_script(accounts) + imports = "\n".join(set(imports)) + + if logger_is_present: + logger_str, logger_imports = convert_objects_to_script(logger) + logger_imports = "\n".join(set(logger_imports)) + else: + logger_imports = "" + + if remote_is_present: + connection_mgr: RemoteConnectionCLIENT = convert_to_objects(connection_mgr) + kwargs = {"host": "0.0.0.0", "port": connection_mgr.port} + if connection_mgr.auth is not None: + kwargs["username"] = connection_mgr.auth.login + kwargs["password"] = connection_mgr.auth.password + + remote_str, remote_imports = convert_objects_to_script(ObjectInfo(daf.RemoteAccessCLIENT, kwargs)) + remote_imports = "\n".join(set(remote_imports)) + else: + remote_str, remote_imports = "", "" + + _ret = f''' +""" +Automatically generated file for Discord Advertisement Framework {daf.VERSION}. +This can be run eg. 24/7 on a server without graphical interface. + +The file has the required classes and functions imported, then the logger is defined and the +accounts list is defined. + +At the bottom of the file the framework is then started with the run function. +""" + +# Import the necessary items +{logger_imports} +{remote_imports} +{imports} +{f"from {tracing.__module__} import {tracing.__class__.__name__}" if tracing_is_present else ""} +import daf + +# Define the logger +{f"logger = {logger_str}" if logger_is_present else ""} + +# Define remote control context +{f"remote_client = {remote_str}" if remote_is_present else ""} + +# Defined accounts +accounts = {accounts_str} + +# Run the framework (blocking) +daf.run( + accounts=accounts,{run_logger_str}{run_tracing_str}{run_remote_str} + save_to_file={self.save_objects_to_file_var.get()} +) +''' + with open(filename, "w", encoding="utf-8") as file: + file.write(_ret) + + tkdiag.Messagebox.show_info(f"Saved to {filename}", "Finished", self) + + def edit_logger(self): + selection = self.combo_logging_mgr.current() + if selection >= 0: + object_: ObjectInfo = self.combo_logging_mgr.get() + self.edit_mgr.open_object_edit_window(object_.class_, self.combo_logging_mgr, old_data=object_) + else: + tkdiag.Messagebox.show_error("Select atleast one item!", "Empty list!") + + def edit_accounts(self): + selection = self.lb_accounts.curselection() + if len(selection): + object_: ObjectInfo = self.lb_accounts.get()[selection[0]] + self.edit_mgr.open_object_edit_window(daf.ACCOUNT, self.lb_accounts, old_data=object_) + else: + tkdiag.Messagebox.show_error("Select atleast one item!", "Empty list!") From 39f7e94f6b991f0ee4e3ff83b9a3b4506886e66a Mon Sep 17 00:00:00 2001 From: David Hozic Date: Sat, 2 Mar 2024 12:52:39 +0100 Subject: [PATCH 03/31] fix --- .github/workflows/build_exe.yml | 1 - docs/source/changelog.rst | 4 ++++ requirements/mandatory.txt | 3 ++- src/daf/__init__.py | 2 +- src/daf_gui/main.py | 35 --------------------------------- 5 files changed, 7 insertions(+), 38 deletions(-) diff --git a/.github/workflows/build_exe.yml b/.github/workflows/build_exe.yml index d77e2c7c..6ecde57b 100644 --- a/.github/workflows/build_exe.yml +++ b/.github/workflows/build_exe.yml @@ -19,7 +19,6 @@ jobs: run: | pip install .[all] pip install pyinstaller - pip install ttkbootstrap==1.10.1 - name: Build run: | echo "from daf_gui import run;run()" > discord-advert-framework.py diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 0eef9082..eb0fbf3c 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -37,6 +37,10 @@ Glossary Releases --------------------- +v4.0.5 +===================== +- Fixed exe build. + v4.0.4 ===================== - Fixed automatic responder's not being removable over a remote connection. diff --git a/requirements/mandatory.txt b/requirements/mandatory.txt index 60c11385..124ff6fc 100644 --- a/requirements/mandatory.txt +++ b/requirements/mandatory.txt @@ -4,4 +4,5 @@ typeguard>=2.13,<2.14 typing_extensions>=4,<5; python_version < "3.11" tkinter-async-execute>=1.2,<1.3 asyncio-event-hub>=1.0,<1.2 -tkclasswiz>=1.4,<1.5 \ No newline at end of file +tkclasswiz>=1.4,<1.5 +ttkbootstrap==1.10.1 diff --git a/src/daf/__init__.py b/src/daf/__init__.py index 1f160ae4..f9520ad3 100644 --- a/src/daf/__init__.py +++ b/src/daf/__init__.py @@ -22,7 +22,7 @@ import warnings -VERSION = "4.0.4" +VERSION = "4.0.5" if sys.version_info.minor == 12 and sys.version_info.major == 3: diff --git a/src/daf_gui/main.py b/src/daf_gui/main.py index b14728db..323bf9fa 100644 --- a/src/daf_gui/main.py +++ b/src/daf_gui/main.py @@ -9,41 +9,6 @@ import json import sys - -# Automatically install GUI requirements if GUI is requested to avoid making it an optional dependency -# One other way would be to create a completely different package on pypi for the core daf, but that is a lot of -# work to be done. It is better to auto install. -to_install = [ - ("ttkbootstrap", "==1.10.1"), -] - -version_path = Path.home().joinpath("./gui_versions.json") -if not version_path.exists(): - version_path.touch() - -with open(version_path, "r") as file: - try: - version_data = json.load(file) - except json.JSONDecodeError as exc: - version_data = {} - -for package, version in to_install: - installed_version = version_data.get(package, "0") - if find_spec(package) is None or installed_version != version: - print(f"Auto installing {package}{version}") - subprocess.check_call( - [ - sys.executable.replace("pythonw", "python"), - "-m", "pip", "install", "-U", - package + version - ] - ) - version_data[package] = version - -with open(version_path, "w") as file: - json.dump(version_data, file) - - import ttkbootstrap as ttk from ttkbootstrap.toast import ToastNotification From 7a13c25237777a4e5e53d50b1f55b823044ac205 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Mar 2024 08:10:57 +0100 Subject: [PATCH 04/31] depend(deps): update selenium requirement (#554) Updates the requirements on [selenium](https://github.com/SeleniumHQ/Selenium) to permit the latest version. - [Release notes](https://github.com/SeleniumHQ/Selenium/releases) - [Commits](https://github.com/SeleniumHQ/Selenium/compare/selenium-4.16.0...selenium-4.18.1) --- updated-dependencies: - dependency-name: selenium dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/web.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/web.txt b/requirements/web.txt index 81f8828e..8185bd67 100644 --- a/requirements/web.txt +++ b/requirements/web.txt @@ -1,3 +1,3 @@ -selenium>=4.16,<4.18 +selenium>=4.16,<4.19 undetected-chromedriver>=3.5,<3.6 webdriver-manager>=4.0,<4.1 From 05528b12a8cd37b71187cab672c4fa4b8ab317da Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Mar 2024 08:11:17 +0100 Subject: [PATCH 05/31] ci(deps): bump mathieudutour/github-tag-action from 6.1 to 6.2 (#560) Bumps [mathieudutour/github-tag-action](https://github.com/mathieudutour/github-tag-action) from 6.1 to 6.2. - [Release notes](https://github.com/mathieudutour/github-tag-action/releases) - [Commits](https://github.com/mathieudutour/github-tag-action/compare/v6.1...v6.2) --- updated-dependencies: - dependency-name: mathieudutour/github-tag-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/create_release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/create_release.yml b/.github/workflows/create_release.yml index 19654ffb..f9d11915 100644 --- a/.github/workflows/create_release.yml +++ b/.github/workflows/create_release.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@master - name: Create tag id: tag_version - uses: mathieudutour/github-tag-action@v6.1 + uses: mathieudutour/github-tag-action@v6.2 with: github_token: ${{ secrets.TOKEN_GH }} custom_tag : ${{github.event.inputs.version-major}}.${{github.event.inputs.version-minor}}.${{github.event.inputs.version-bugfix}} From 5473c332e8f3db9171414c1f77892e80d5e6be34 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Mar 2024 08:11:38 +0100 Subject: [PATCH 06/31] ci(deps): bump softprops/action-gh-release from 1 to 2 (#559) Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v1...v2) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build_exe.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build_exe.yml b/.github/workflows/build_exe.yml index 6ecde57b..e2ac1084 100644 --- a/.github/workflows/build_exe.yml +++ b/.github/workflows/build_exe.yml @@ -24,7 +24,7 @@ jobs: echo "from daf_gui import run;run()" > discord-advert-framework.py pyinstaller --onefile --windowed discord-advert-framework.py --add-data src/daf_gui/img/:daf_gui/img/ --icon src/daf_gui/img/logo.png --add-data src/_discord/bin/:_discord/bin/ - name: Upload - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: files: | dist/discord-advert-framework.exe From 48d05b708f409dacc55231fa7b45bff78757445a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Mar 2024 08:11:51 +0100 Subject: [PATCH 07/31] ci(deps): bump pypa/gh-action-pypi-publish from 1.8.11 to 1.8.14 (#558) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.8.11 to 1.8.14. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/2f6f737ca5f74c637829c0f5c3acd0e29ea5e8bf...81e9d935c883d0b210363ab89cf05f3894778450) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/python-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index d74ce642..e858d4fb 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -30,7 +30,7 @@ jobs: python -m build - name: Publish package - uses: pypa/gh-action-pypi-publish@2f6f737ca5f74c637829c0f5c3acd0e29ea5e8bf + uses: pypa/gh-action-pypi-publish@81e9d935c883d0b210363ab89cf05f3894778450 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 332454c98dcb8bdcb903deab3d653cb52a5bd0a1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Mar 2024 08:14:32 +0100 Subject: [PATCH 08/31] depend(deps): update pytest requirement from <8.1,>=7.4 to >=7.4,<8.2 (#557) Updates the requirements on [pytest](https://github.com/pytest-dev/pytest) to permit the latest version. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/7.4.0...8.1.0) --- updated-dependencies: - dependency-name: pytest dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/testing.txt b/requirements/testing.txt index f482ba04..ebe2326d 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -1,2 +1,2 @@ -pytest>=7.4,<8.1 +pytest>=7.4,<8.2 pytest-asyncio>=0.21,<0.22 \ No newline at end of file From f65176a77a4fbf1468d60bf3c3586e7e71e9c93d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Mar 2024 08:14:46 +0100 Subject: [PATCH 09/31] depend(deps): update aiosqlite requirement (#555) Updates the requirements on [aiosqlite](https://github.com/omnilib/aiosqlite) to permit the latest version. - [Changelog](https://github.com/omnilib/aiosqlite/blob/main/CHANGELOG.md) - [Commits](https://github.com/omnilib/aiosqlite/compare/v0.19.0...v0.20.0) --- updated-dependencies: - dependency-name: aiosqlite dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/sql.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/sql.txt b/requirements/sql.txt index 23fbf16c..37a8e5a6 100644 --- a/requirements/sql.txt +++ b/requirements/sql.txt @@ -1,5 +1,5 @@ sqlalchemy[asyncio]>=2.0,<3.0 -aiosqlite>=0.19,<0.20 +aiosqlite>=0.19,<0.21 pymssql>=2.2,<2.3 asyncpg>=0.29,<0.30 asyncmy>=0.2,<0.3 From 94b59bc35125bb00a71f5191fde8e240a1a43229 Mon Sep 17 00:00:00 2001 From: David Hozic Date: Thu, 23 May 2024 16:49:48 +0200 Subject: [PATCH 10/31] Feat/periods (#570) * TimeDayWeekMonthPeriod * DaysofX * not implemented errors --- docs/source/changelog.rst | 9 ++ src/daf/__init__.py | 2 +- src/daf/message/messageperiod.py | 176 ++++++++++++++++++++++++++++++- src/daf/message/text_based.py | 3 +- 4 files changed, 185 insertions(+), 5 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index eb0fbf3c..5de379c8 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -37,10 +37,19 @@ Glossary Releases --------------------- +v4.1.0 +===================== +- New message period types: + + + :class:`daf.message.messageperiod.NamedDayOfYearPeriod` + + :class:`daf.message.messageperiod.NamedDayOfMonthPeriod` + + v4.0.5 ===================== - Fixed exe build. + v4.0.4 ===================== - Fixed automatic responder's not being removable over a remote connection. diff --git a/src/daf/__init__.py b/src/daf/__init__.py index f9520ad3..26ad1548 100644 --- a/src/daf/__init__.py +++ b/src/daf/__init__.py @@ -22,7 +22,7 @@ import warnings -VERSION = "4.0.5" +VERSION = "4.1.0" if sys.version_info.minor == 12 and sys.version_info.major == 3: diff --git a/src/daf/message/messageperiod.py b/src/daf/message/messageperiod.py index 24b24d18..0f382070 100644 --- a/src/daf/message/messageperiod.py +++ b/src/daf/message/messageperiod.py @@ -15,6 +15,8 @@ "RandomizedDurationPeriod", "DaysOfWeekPeriod", "DailyPeriod", + "NamedDayOfYearPeriod", + "NamedDayOfMonthPeriod", ) @@ -160,7 +162,7 @@ def adjust(self, minimum: timedelta) -> None: class DaysOfWeekPeriod(EveryXPeriod): """ Represents a period that will send on ``days`` at specific ``time``. - + E. g., parameters ``days=["Mon", "Wed"]`` and ``time=time(hour=12, minute=0)`` produce a behavior that will send a message every Monday and Wednesday at 12:00. @@ -241,7 +243,7 @@ def calculate(self): def adjust(self, minimum: timedelta) -> None: # The minium between sends will always be 24 hours. # Slow-mode minimum is maximum 6 hours, thus this is not needed. - pass + raise NotImplementedError("Setting minimal period would break the definition of class.") @doc_category("Message period") @@ -267,3 +269,173 @@ def __init__( ) -> None: super().__init__(DaysOfWeekPeriod.WEEK_DAYS, time, next_send_time) + +@doc_category("Message period") +class NamedDayOfYearPeriod(EveryXPeriod): + """ + .. versionadded:: 4.1 + + This period type enables messages to be sent on specific ``week``\ th ``day`` of a ``month`` each year on + a specific ``time``. + + E.g., each year on second Monday in December at 12 noon (example below). + + Parameters + --------------- + time: time + The time at which to send. + day: 'Mon'-'Sun' + The day of week when to send. + week: int + The week number of which to send. E.g., 1 for 1st week, 2 for second week. + month: 1 - 12 + The month in which to send. + next_send_time: datetime | timedelta + Represents the time at which the message should first be sent. + Use ``datetime`` to specify the exact date and time at which the message should start being sent. + Use ``timedelta`` to specify how soon (after creation of the object) the message + should start being sent. + + Example + ---------- + .. code-block:: python + + # Every second monday of December at 12:00. + NamedDayOfYearPeriod( + time=time(hour=12), # Time + day="Mon", # Day (Monday) + week=2, # Which week (second monday) + month=12 # Month (December) + ) + """ + DAYS = Literal["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + _DAYS_ARGS = get_args(DAYS) + + def __init__( + self, + time: time, + day: NamedDayOfYearPeriod.DAYS, + week: Literal[1, 2, 3, 4, 5], + month: Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + next_send_time: Union[datetime, timedelta] = None + ) -> None: + self.time = time + self.day = day + self.week = week + self.month = month + self.isoday = NamedDayOfYearPeriod._DAYS_ARGS.index(day) + 1 + super().__init__(next_send_time) + + def calculate(self) -> datetime: + # In case of deferral, the next_send_time will be greater, + # thus next send time should be relative to that instead of now. + now = max(datetime.now().astimezone(), self.next_send_time) + self_time = self.time + next = now.replace( + day=1, + month=self.month, + hour=self_time.hour, + minute=self_time.minute, + second=self_time.second, + microsecond=self_time.microsecond + ) + + while True: + isoday = next.isoweekday() + if isoday > self.isoday: + next = next.replace(day=1 + 7 + self.isoday - isoday) + else: + next = next.replace(day=1 + self.isoday - isoday) + + next += timedelta(days=7 * (self.week - 1)) + + if next >= now: + break + + next = next.replace(year=next.year + 1) + + return next + + def adjust(self, minimum: timedelta) -> None: + raise NotImplementedError("Setting minimal period would break the definition of class.") + + +class NamedDayOfMonthPeriod(EveryXPeriod): + """ + .. versionadded:: 4.1 + + This period type enables messages to be sent on specific ``week``\ th ``day`` each month at a specific ``time``. + + E.g., each year on second Monday in December at 12 noon (example below). + + Parameters + --------------- + time: time + The time at which to send. + day: 'Mon'-'Sun' + The day of week when to send. + week: int + The week number of which to send. E.g., 1 for 1st week, 2 for second week. + month: 1 - 12 + The month in which to send. + next_send_time: datetime | timedelta + Represents the time at which the message should first be sent. + Use ``datetime`` to specify the exact date and time at which the message should start being sent. + Use ``timedelta`` to specify how soon (after creation of the object) the message + should start being sent. + + Example + ---------- + .. code-block:: python + + # Every second monday of December at 12:00. + NamedDayOfYearPeriod( + time=time(hour=12), # Time + day="Mon", # Day (Monday) + week=2, # Which week (second monday) + month=12 # Month (December) + ) + """ + DAYS = Literal["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + _DAYS_ARGS = get_args(DAYS) + + def __init__( + self, + time: time, + day: NamedDayOfMonthPeriod.DAYS, + week: int, + next_send_time: Union[datetime, timedelta] = None + ) -> None: + self.time = time + self.day = day + self.week = week + self.isoday = NamedDayOfMonthPeriod._DAYS_ARGS.index(day) + 1 + super().__init__(next_send_time) + + def calculate(self) -> datetime: + now = max(datetime.now().astimezone(), self.next_send_time) + self_time = self.time + next = now.replace( + day=1, + hour=self_time.hour, + minute=self_time.minute, + second=self_time.second, + microsecond=self_time.microsecond + ) + + while True: + isoday = next.isoweekday() + if isoday > self.isoday: + next = next.replace(day=1 + 7 + self.isoday - isoday) + else: + next = next.replace(day=1 + self.isoday - isoday) + + next += timedelta(days=7 * (self.week - 1)) + + if next >= now: + break + + next = next.replace(month=next.month + 1) + + def adjust(self, minimum: timedelta) -> None: + raise NotImplementedError("Setting minimal period would break the definition of class.") diff --git a/src/daf/message/text_based.py b/src/daf/message/text_based.py index 37831d0b..9a90bbea 100644 --- a/src/daf/message/text_based.py +++ b/src/daf/message/text_based.py @@ -468,7 +468,7 @@ class DirectMESSAGE(BaseMESSAGE): "previous_message", "dm_channel", ) - + _old_data_type = Union[list, tuple, set, str, discord.Embed, FILE, _FunctionBaseCLASS] @typechecked @@ -482,7 +482,6 @@ def __init__( remove_after: Optional[Union[int, timedelta, datetime]] = None, period: BaseMessagePeriod = None ): - if not isinstance(data, BaseTextData): trace( f"Using data types other than {[x.__name__ for x in BaseTextData.__subclasses__()]}, " From 4621a33184565468383d856e80b1ad3d74511bd0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 May 2024 16:50:02 +0200 Subject: [PATCH 11/31] depend(deps): update selenium requirement (#571) Updates the requirements on [selenium](https://github.com/SeleniumHQ/Selenium) to permit the latest version. - [Release notes](https://github.com/SeleniumHQ/Selenium/releases) - [Commits](https://github.com/SeleniumHQ/Selenium/compare/selenium-4.16.0...selenium-4.21.0) --- updated-dependencies: - dependency-name: selenium dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/web.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/web.txt b/requirements/web.txt index 8185bd67..51272ff7 100644 --- a/requirements/web.txt +++ b/requirements/web.txt @@ -1,3 +1,3 @@ -selenium>=4.16,<4.19 +selenium>=4.16,<4.22 undetected-chromedriver>=3.5,<3.6 webdriver-manager>=4.0,<4.1 From 391fb9c452b10e26d2e4c8cbfa6aad824d1222a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 May 2024 16:50:15 +0200 Subject: [PATCH 12/31] depend(deps): bump sphinx from 7.1.2 to 7.3.7 (#568) Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 7.1.2 to 7.3.7. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/master/CHANGES.rst) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v7.1.2...v7.3.7) --- updated-dependencies: - dependency-name: sphinx dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/docs.txt b/requirements/docs.txt index 180479d8..6128e5fc 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1,4 +1,4 @@ -sphinx==7.1.2 +sphinx==7.3.7 sphinx-autobuild==2024.2.4 sphinx-copybutton==0.5.2 furo==2024.1.29 From ce12e7b4c195a0e06d3ee52272f6e2fe494ea5b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 May 2024 16:50:30 +0200 Subject: [PATCH 13/31] depend(deps): bump enum-tools[sphinx] from 0.11.0 to 0.12.0 (#562) Bumps [enum-tools[sphinx]](https://github.com/domdfcoding/enum_tools) from 0.11.0 to 0.12.0. - [Release notes](https://github.com/domdfcoding/enum_tools/releases) - [Commits](https://github.com/domdfcoding/enum_tools/compare/v0.11.0...v0.12.0) --- updated-dependencies: - dependency-name: enum-tools[sphinx] dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/docs.txt b/requirements/docs.txt index 6128e5fc..8be0dd30 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -2,7 +2,7 @@ sphinx==7.3.7 sphinx-autobuild==2024.2.4 sphinx-copybutton==0.5.2 furo==2024.1.29 -enum-tools[sphinx]==0.11.0 +enum-tools[sphinx]==0.12.0 sphinx-design[furo]==0.5.0 readthedocs-sphinx-search==0.3.2 sphinxcontrib-svg2pdfconverter==1.2.2 \ No newline at end of file From c605a1d70fe9d63d7a35425685e06fe501e64d21 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 Aug 2024 13:26:47 +0200 Subject: [PATCH 14/31] depend(deps): bump furo from 2024.1.29 to 2024.8.6 (#584) Bumps [furo](https://github.com/pradyunsg/furo) from 2024.1.29 to 2024.8.6. - [Release notes](https://github.com/pradyunsg/furo/releases) - [Changelog](https://github.com/pradyunsg/furo/blob/main/docs/changelog.md) - [Commits](https://github.com/pradyunsg/furo/compare/2024.01.29...2024.08.06) --- updated-dependencies: - dependency-name: furo dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/docs.txt b/requirements/docs.txt index 8be0dd30..1dc476c9 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1,7 +1,7 @@ sphinx==7.3.7 sphinx-autobuild==2024.2.4 sphinx-copybutton==0.5.2 -furo==2024.1.29 +furo==2024.8.6 enum-tools[sphinx]==0.12.0 sphinx-design[furo]==0.5.0 readthedocs-sphinx-search==0.3.2 From 1a28f8822cd946014729d5c4712395e7bca43384 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 Aug 2024 13:29:07 +0200 Subject: [PATCH 15/31] depend(deps): update pymssql requirement from <2.3,>=2.2 to >=2.2,<2.4 (#563) Updates the requirements on [pymssql](https://github.com/pymssql/pymssql) to permit the latest version. - [Release notes](https://github.com/pymssql/pymssql/releases) - [Changelog](https://github.com/pymssql/pymssql/blob/master/ChangeLog.rst) - [Commits](https://github.com/pymssql/pymssql/compare/v2.2.0...v2.3.0) --- updated-dependencies: - dependency-name: pymssql dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/sql.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/sql.txt b/requirements/sql.txt index 37a8e5a6..a08ec90c 100644 --- a/requirements/sql.txt +++ b/requirements/sql.txt @@ -1,5 +1,5 @@ sqlalchemy[asyncio]>=2.0,<3.0 aiosqlite>=0.19,<0.21 -pymssql>=2.2,<2.3 +pymssql>=2.2,<2.4 asyncpg>=0.29,<0.30 asyncmy>=0.2,<0.3 From eb0226680814e7c7a4b7993c3ed8256d7b1d21d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 Aug 2024 13:29:22 +0200 Subject: [PATCH 16/31] ci(deps): bump pypa/gh-action-pypi-publish from 1.8.14 to 1.9.0 (#576) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.8.14 to 1.9.0. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/81e9d935c883d0b210363ab89cf05f3894778450...ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/python-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index e858d4fb..ecef7a59 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -30,7 +30,7 @@ jobs: python -m build - name: Publish package - uses: pypa/gh-action-pypi-publish@81e9d935c883d0b210363ab89cf05f3894778450 + uses: pypa/gh-action-pypi-publish@ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 95c2654f5ce0f3fcfada2ae020071e32e3bbc257 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 Aug 2024 13:29:42 +0200 Subject: [PATCH 17/31] depend(deps): update selenium requirement (#582) Updates the requirements on [selenium](https://github.com/SeleniumHQ/Selenium) to permit the latest version. - [Release notes](https://github.com/SeleniumHQ/Selenium/releases) - [Commits](https://github.com/SeleniumHQ/Selenium/compare/selenium-4.16.0...selenium-4.23.0) --- updated-dependencies: - dependency-name: selenium dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/web.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/web.txt b/requirements/web.txt index 51272ff7..e4531b52 100644 --- a/requirements/web.txt +++ b/requirements/web.txt @@ -1,3 +1,3 @@ -selenium>=4.16,<4.22 +selenium>=4.16,<4.24 undetected-chromedriver>=3.5,<3.6 webdriver-manager>=4.0,<4.1 From 6f7b2115c902f7c7a74a4896a81f55e366114edb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 Aug 2024 13:56:42 +0200 Subject: [PATCH 18/31] depend(deps): bump sphinx-design[furo] from 0.5.0 to 0.6.1 (#583) Bumps [sphinx-design[furo]](https://github.com/executablebooks/sphinx-design) from 0.5.0 to 0.6.1. - [Release notes](https://github.com/executablebooks/sphinx-design/releases) - [Changelog](https://github.com/executablebooks/sphinx-design/blob/main/CHANGELOG.md) - [Commits](https://github.com/executablebooks/sphinx-design/compare/v0.5.0...v0.6.1) --- updated-dependencies: - dependency-name: sphinx-design[furo] dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/docs.txt b/requirements/docs.txt index 1dc476c9..b581acf4 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -3,6 +3,6 @@ sphinx-autobuild==2024.2.4 sphinx-copybutton==0.5.2 furo==2024.8.6 enum-tools[sphinx]==0.12.0 -sphinx-design[furo]==0.5.0 +sphinx-design[furo]==0.6.1 readthedocs-sphinx-search==0.3.2 sphinxcontrib-svg2pdfconverter==1.2.2 \ No newline at end of file From 438d5496cc2d26d7f00f6ae1a0f9773f8afae9b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 Aug 2024 14:48:30 +0200 Subject: [PATCH 19/31] depend(deps): update pytest requirement from <8.2,>=7.4 to >=7.4,<8.4 (#581) Updates the requirements on [pytest](https://github.com/pytest-dev/pytest) to permit the latest version. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/7.4.0...8.3.1) --- updated-dependencies: - dependency-name: pytest dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/testing.txt b/requirements/testing.txt index ebe2326d..f8205ec3 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -1,2 +1,2 @@ -pytest>=7.4,<8.2 +pytest>=7.4,<8.4 pytest-asyncio>=0.21,<0.22 \ No newline at end of file From 31e27ba630d66d6e07dc302d5d9b3d78e08138e0 Mon Sep 17 00:00:00 2001 From: David Hozic Date: Wed, 11 Sep 2024 17:23:05 +0200 Subject: [PATCH 20/31] Fix docs --- src/daf/message/messageperiod.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/daf/message/messageperiod.py b/src/daf/message/messageperiod.py index 0f382070..b1447010 100644 --- a/src/daf/message/messageperiod.py +++ b/src/daf/message/messageperiod.py @@ -360,6 +360,7 @@ def adjust(self, minimum: timedelta) -> None: raise NotImplementedError("Setting minimal period would break the definition of class.") +@doc_category("Message period") class NamedDayOfMonthPeriod(EveryXPeriod): """ .. versionadded:: 4.1 From aa859a029730f549ff0a20e8a8e6830bbc5e00a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Sep 2024 18:25:56 +0200 Subject: [PATCH 21/31] ci(deps): bump pypa/gh-action-pypi-publish from 1.9.0 to 1.10.1 (#591) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.9.0 to 1.10.1. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0...0ab0b79471669eb3a4d647e625009c62f9f3b241) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/python-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index ecef7a59..add9f1b0 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -30,7 +30,7 @@ jobs: python -m build - name: Publish package - uses: pypa/gh-action-pypi-publish@ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0 + uses: pypa/gh-action-pypi-publish@0ab0b79471669eb3a4d647e625009c62f9f3b241 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 5dba8d87aa3fa0b1dc4b658ef365536ebbac937f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Sep 2024 18:26:17 +0200 Subject: [PATCH 22/31] depend(deps): update aiohttp-socks requirement (#589) Updates the requirements on [aiohttp-socks](https://github.com/romis2012/aiohttp-socks) to permit the latest version. - [Release notes](https://github.com/romis2012/aiohttp-socks/releases) - [Commits](https://github.com/romis2012/aiohttp-socks/compare/v0.8.0...v0.9.0) --- updated-dependencies: - dependency-name: aiohttp-socks dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/mandatory.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/mandatory.txt b/requirements/mandatory.txt index 124ff6fc..23dfa603 100644 --- a/requirements/mandatory.txt +++ b/requirements/mandatory.txt @@ -1,5 +1,5 @@ aiohttp>=3.9.0,<3.10.0 -aiohttp_socks>=0.8,<0.9 +aiohttp_socks>=0.8,<0.10 typeguard>=2.13,<2.14 typing_extensions>=4,<5; python_version < "3.11" tkinter-async-execute>=1.2,<1.3 From f6d4a4f0d12be6e5bad93a8676c63dc1f3c0fe5c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Sep 2024 18:29:13 +0200 Subject: [PATCH 23/31] depend(deps): update tkinter-async-execute requirement (#585) Updates the requirements on tkinter-async-execute to permit the latest version. --- updated-dependencies: - dependency-name: tkinter-async-execute dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/mandatory.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/mandatory.txt b/requirements/mandatory.txt index 23dfa603..0bd65fb8 100644 --- a/requirements/mandatory.txt +++ b/requirements/mandatory.txt @@ -2,7 +2,7 @@ aiohttp>=3.9.0,<3.10.0 aiohttp_socks>=0.8,<0.10 typeguard>=2.13,<2.14 typing_extensions>=4,<5; python_version < "3.11" -tkinter-async-execute>=1.2,<1.3 +tkinter-async-execute>=1.2,<1.4 asyncio-event-hub>=1.0,<1.2 tkclasswiz>=1.4,<1.5 ttkbootstrap==1.10.1 From e7ab388c1381c043b2953cca8ee79cf37097fe0c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Sep 2024 18:44:41 +0200 Subject: [PATCH 24/31] depend(deps): update aiohttp requirement (#588) Updates the requirements on [aiohttp](https://github.com/aio-libs/aiohttp) to permit the latest version. - [Release notes](https://github.com/aio-libs/aiohttp/releases) - [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst) - [Commits](https://github.com/aio-libs/aiohttp/compare/v3.9.0...v3.10.5) --- updated-dependencies: - dependency-name: aiohttp dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/mandatory.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/mandatory.txt b/requirements/mandatory.txt index 0bd65fb8..a83d678f 100644 --- a/requirements/mandatory.txt +++ b/requirements/mandatory.txt @@ -1,4 +1,4 @@ -aiohttp>=3.9.0,<3.10.0 +aiohttp>=3.9.0,<3.11.0 aiohttp_socks>=0.8,<0.10 typeguard>=2.13,<2.14 typing_extensions>=4,<5; python_version < "3.11" From 09edd9300aeb1c9d2eec1ea9b10f54f0b6490780 Mon Sep 17 00:00:00 2001 From: David Hozic Date: Wed, 11 Sep 2024 19:12:03 +0200 Subject: [PATCH 25/31] SQL Logging fix (#592) * Fix SQL log deletion * Fixed log deletion * Changelog --- docs/source/changelog.rst | 3 +++ src/daf/convert.py | 4 ++-- src/daf/logging/sql/mgr.py | 5 ++--- src/daf_gui/tabs/analytics.py | 13 +++++++++---- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 5de379c8..49890213 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -44,6 +44,9 @@ v4.1.0 + :class:`daf.message.messageperiod.NamedDayOfYearPeriod` + :class:`daf.message.messageperiod.NamedDayOfMonthPeriod` +- Fixed SQL log removal through the GUI. +- Fixed CSV and JSON reading through remote. + v4.0.5 ===================== diff --git a/src/daf/convert.py b/src/daf/convert.py index 834ec79e..7f1190f4 100644 --- a/src/daf/convert.py +++ b/src/daf/convert.py @@ -104,10 +104,10 @@ def import_class(path: str): "attrs": ["_daf_id"] }, logging.LoggerJSON: { - "attrs": [] + "attrs": ["_daf_id"] }, logging.LoggerCSV: { - "attrs": [] + "attrs": ["_daf_id"] }, discord.Embed: { "custom_encoder": lambda embed: embed.to_dict(), diff --git a/src/daf/logging/sql/mgr.py b/src/daf/logging/sql/mgr.py index 0bcfba87..3596d30a 100644 --- a/src/daf/logging/sql/mgr.py +++ b/src/daf/logging/sql/mgr.py @@ -1250,7 +1250,7 @@ async def __analytic_get_log( ) return list(*zip(*logs.unique().all())) - async def delete_logs(self, logs: List[Union[MessageLOG, InviteLOG]]): + async def delete_logs(self, table: Union[MessageLOG, InviteLOG], primary_keys: List[int]): """ Method used to delete log objects objects. @@ -1262,9 +1262,8 @@ async def delete_logs(self, logs: List[Union[MessageLOG, InviteLOG]]): List of Primary Key IDs that match the rows of the table to delete. """ session: Union[AsyncSession, Session] - table = type(logs[0]) async with self.session_maker() as session: - await self._run_async(session.execute, delete(table).where(table.id.in_([log.id for log in logs]))) + await self._run_async(session.execute, delete(table).where(table.id.in_(primary_keys))) await self._run_async(session.commit) @async_util.with_semaphore("_mutex") diff --git a/src/daf_gui/tabs/analytics.py b/src/daf_gui/tabs/analytics.py index c05033d7..699a9f89 100644 --- a/src/daf_gui/tabs/analytics.py +++ b/src/daf_gui/tabs/analytics.py @@ -1,5 +1,5 @@ from ttkbootstrap.tableview import Tableview -from tkclasswiz.utilities import gui_confirm_action +from tkclasswiz.utilities import gui_confirm_action, gui_except from tkclasswiz.dpi import dpi_scaled from tkclasswiz.convert import * from tkclasswiz.storage import * @@ -109,22 +109,27 @@ def show_log(listbox: ListBoxScrolled): else: tkdiag.Messagebox.show_error("Select ONE item!", "Empty list!") - async def delete_logs_async(primary_keys: List[int]): + async def delete_logs_async(table, keys: List[int]): connection = get_connection() logger = await connection.get_logger() await connection.execute_method( it.ObjectReference.from_object(logger), "delete_logs", - primary_keys=primary_keys # TODO: update on server + primary_keys=keys, + table=table ) @gui_confirm_action() + @gui_except() def delete_logs(listbox: ListBoxScrolled): selection = listbox.curselection() if len(selection): all_ = listbox.get() + if issubclass(all_[0].class_, dict): + raise ValueError("Only SQL logs can be deleted through the GUI at the moment.") + tae.async_execute( - delete_logs_async([all_[i].data["id"] for i in selection]), + delete_logs_async(all_[0].class_, [all_[i].data["id"] for i in selection]), wait=False, pop_up=True, master=self From e871f05f9fae129fec9a0eb1547b763e05aea882 Mon Sep 17 00:00:00 2001 From: David Hozic Date: Wed, 11 Sep 2024 20:05:21 +0200 Subject: [PATCH 26/31] Case-insensitive text matching (in AutoGUILD, Automatic responders, ...) (#593) * Optional case-sensitive matching * Trigger tests --- docs/source/changelog.rst | 2 ++ src/daf/guild/autoguild.py | 10 +++++----- src/daf/logic.py | 13 ++++++++++--- src/daf/responder/base.py | 2 +- testing/test_responder.py | 9 ++++++++- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 49890213..29f3b78c 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -44,6 +44,8 @@ v4.1.0 + :class:`daf.message.messageperiod.NamedDayOfYearPeriod` + :class:`daf.message.messageperiod.NamedDayOfMonthPeriod` +- Added optional case-sensitive matching to :class:`daf.logic.contains` and :class:`daf.logic.regex`. The default + is still case-insensitive. - Fixed SQL log removal through the GUI. - Fixed CSV and JSON reading through remote. diff --git a/src/daf/guild/autoguild.py b/src/daf/guild/autoguild.py index b84d6132..fe460394 100644 --- a/src/daf/guild/autoguild.py +++ b/src/daf/guild/autoguild.py @@ -198,7 +198,7 @@ def _get_guilds(self): client: discord.Client = self.parent.client guilds = [ g for g in client.guilds - if self.include_pattern.check(g.name.lower()) + if self.include_pattern.check(g.name) ] return guilds @@ -341,12 +341,12 @@ async def initialize(self, parent: Any, event_ctrl: EventController): self._event_ctrl.add_listener( EventID.discord_member_join, self._on_member_join, - predicate=lambda memb: self.include_pattern.check(memb.guild.name.lower()) + predicate=lambda memb: self.include_pattern.check(memb.guild.name) ) self._event_ctrl.add_listener( EventID.discord_invite_delete, self._on_invite_delete, - predicate=lambda inv: self.include_pattern.check(inv.guild.name.lower()) + predicate=lambda inv: self.include_pattern.check(inv.guild.name) ) except discord.HTTPException as exc: trace(f"Could not query invite links in {self}", TraceLEVELS.ERROR, exc) @@ -358,7 +358,7 @@ async def initialize(self, parent: Any, event_ctrl: EventController): self._event_ctrl.add_listener( EventID.discord_guild_join, self._on_guild_join, - predicate=lambda guild: self.include_pattern.check(guild.name.lower()) + predicate=lambda guild: self.include_pattern.check(guild.name) ) self._event_ctrl.add_listener( @@ -471,7 +471,7 @@ async def get_next_guild(): try: # Get next result from top.gg yielded: web.QueryResult = await self.guild_query_iter.__anext__() - if self.include_pattern.check(yielded.name.lower()): + if self.include_pattern.check(yielded.name): return yielded except StopAsyncIteration: trace(f"Iterated though all found guilds -> stopping guild join in {self}.", TraceLEVELS.NORMAL) diff --git a/src/daf/logic.py b/src/daf/logic.py index e857221a..921f44b2 100644 --- a/src/daf/logic.py +++ b/src/daf/logic.py @@ -130,10 +130,17 @@ class contains(BaseLogic): keyword: str The keyword needed to be inside a text message. """ - def __init__(self, keyword: str) -> None: - self.keyword = keyword.lower() + def __init__(self, keyword: str, case_sensitive: bool = False) -> None: + self.case_sensitive = case_sensitive + if not case_sensitive: + keyword = keyword.lower() + + self.keyword = keyword def check(self, input: str): + if not self.case_sensitive: + input = input.lower() + return self.keyword in re.findall(r'\w+', input) # \w+ == match all words, including **bold** @@ -157,7 +164,7 @@ class regex(BaseLogic): def __init__( self, pattern: str, - flags: re.RegexFlag = re.MULTILINE, + flags: re.RegexFlag = re.MULTILINE | re.IGNORECASE, full_match: bool = False ) -> None: self._full_match = full_match diff --git a/src/daf/responder/base.py b/src/daf/responder/base.py index 80c997c5..8845b950 100644 --- a/src/daf/responder/base.py +++ b/src/daf/responder/base.py @@ -58,7 +58,7 @@ async def handle_message(self, message: discord.Message): return # Check keywords - if not self.condition.check(message.clean_content.lower()): + if not self.condition.check(message.clean_content): return await self.action.perform(message) # All constraints satisfied diff --git a/testing/test_responder.py b/testing/test_responder.py index dd1a5692..d5da37a9 100644 --- a/testing/test_responder.py +++ b/testing/test_responder.py @@ -7,6 +7,7 @@ from daf.client import ACCOUNT import pytest +import re async def test_dm_responder(): @@ -17,6 +18,9 @@ async def test_dm_responder(): ("condition", "input", "should_match"), [ # RegEx + (regex("(buy|sell).*nft"), "I want to buy some NFT", True), + (regex("(buy|sell).*nft", flags=re.MULTILINE), "I want to buy some NFT", False), + (regex("(buy|sell).*NFT", flags=re.MULTILINE), "I want to buy some NFT", True), (regex("(buy|sell).*nft"), "I want to buy some nfts", True), (regex("(buy|sell).*nft"), "I want to sell some nfts", True), (regex("(buy|sell).*nft"), "I want to get some nfts", False), @@ -25,6 +29,9 @@ async def test_dm_responder(): # Contains (contains('car'), "I want to buy a NFT", False), (contains('car'), "I want to buy a car", True), + (contains('car'), "I want to buy a Car", True), + (contains('car', case_sensitive=True), "I want to buy a Car", False), + (contains('Car', case_sensitive=True), "I want to buy a Car", True), (contains('nfts'), "can I get some sweet NFTs my way please?", True), # Boolean mixed (and_(contains("buy"), contains("nfts"), contains("dragon")), "I want to buy some nfts.", False), @@ -112,7 +119,7 @@ def test_responder_conditions(condition: BaseLogic, input: str, should_match: bo """ Tests the text-matching condition logic. """ - assert condition.check(input.lower()) == should_match, "Condition failed" + assert condition.check(input) == should_match, "Condition failed" async def test_guild_responder(): From 72fc2f3d9d4899371200651a65f680eab6bea833 Mon Sep 17 00:00:00 2001 From: David Hozic Date: Thu, 12 Sep 2024 15:03:55 +0200 Subject: [PATCH 27/31] Message (anti-spam) constraints (#594) * Message constraints * Documentation * Fix documentation --- docs/source/changelog.rst | 2 + .../message_definition_example_output.png | Bin 26648 -> 7334 bytes docs/source/guide/core/shilldefine.rst | 23 +++++-- src/daf/logic.py | 21 ++----- src/daf/message/__init__.py | 1 + src/daf/message/base.py | 35 ----------- src/daf/message/constraints.py | 46 ++++++++++++++ src/daf/message/text_based.py | 59 +++++++++++++++++- src/daf/message/voice_based.py | 39 +++++++++++- 9 files changed, 165 insertions(+), 61 deletions(-) create mode 100644 src/daf/message/constraints.py diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 29f3b78c..4b592c3b 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -46,6 +46,8 @@ v4.1.0 - Added optional case-sensitive matching to :class:`daf.logic.contains` and :class:`daf.logic.regex`. The default is still case-insensitive. +- Added ``constraints`` parameter to :class:`daf.message.TextMESSAGE`. See :ref:`Message constraints` for more + information. - Fixed SQL log removal through the GUI. - Fixed CSV and JSON reading through remote. diff --git a/docs/source/guide/core/images/message_definition_example_output.png b/docs/source/guide/core/images/message_definition_example_output.png index 25c6946cfa57b7e430529ca12d16bdea062b8018..f4cb11a702bce93e937727b7e748f425c75a5f37 100644 GIT binary patch literal 7334 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 Z#C1JJWb?`4fB&~zs3@x@QzK;_@*l##RTlsN literal 26648 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%0DPu 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 diff --git a/docs/source/guide/core/shilldefine.rst b/docs/source/guide/core/shilldefine.rst index 630c2dc0..ff13d928 100644 --- a/docs/source/guide/core/shilldefine.rst +++ b/docs/source/guide/core/shilldefine.rst @@ -106,7 +106,7 @@ We will not cover :class:`daf.guild.USER` separately as the definition process i the same. We will also not cover :class:`daf.guild.AutoGUILD` here, as it is covered in :ref:`Automatic Generation (core)`. -Let's define our :class:`daf.guild.GUILD` object now. Its most important parameters are: +Let's define our :class:`daf.guild.GUILD` object now. Its most important (but not all) parameters are: - ``snowflake``: An integer parameter. Represents a unique identifier, which identifies every Discord resource. Snowflake can be obtained by @@ -146,6 +146,8 @@ Let's expand our example from :ref:`Definition of accounts (core)`. daf.run(accounts=accounts) + + Now let's define our messages. -------------------------------------- @@ -178,13 +180,20 @@ The most important parameters inside :class:`daf.message.TextMESSAGE` are: multiple specified days at a specific time. - :class:`~daf.message.messageperiod.DailyPeriod`: A period that sends every day at a specific time. +- ``constraints``: A list of constraints that only allow a message to be sent when they are fulfilled. This can for + example be used to only send messages to channels when the last message in that channel is not or own, thus + **preventing unnecessary spam**. Currently a single constraint is supported: + + - :class:`daf.message.constraints.AntiSpamMessageConstraint` + Now that we have an overview of the most important parameters, let's define our message. We will define a message that sends fixed data into a single channel, with a fixed time (duration) period. .. code-block:: python :linenos: - :emphasize-lines: 1-3, 19-23 + :emphasize-lines: 1-4, 19-24 + from daf.message.constraints import AntiSpamMessageConstraint from daf.message.messageperiod import FixedDurationPeriod from daf.messagedata import TextMessageData from daf.message import TextMESSAGE @@ -201,12 +210,13 @@ We will define a message that sends fixed data into a single channel, with a fix is_user=False, # Above token is user account's token and not a bot token. servers=[ GUILD( - snowflake=863071397207212052, + snowflake=2312312312312312313123, messages=[ TextMESSAGE( data=TextMessageData(content="Looking for NFT?"), - channels=[1159224699830677685], - period=FixedDurationPeriod(duration=timedelta(seconds=15)) + channels=[3215125123123123123123], + period=FixedDurationPeriod(duration=timedelta(seconds=5)), + constraints=[AntiSpamMessageConstraint(per_channel=True)] ) ] ) @@ -217,6 +227,7 @@ We will define a message that sends fixed data into a single channel, with a fix daf.run(accounts=accounts) + .. image:: ./images/message_definition_example_output.png :width: 20cm @@ -233,7 +244,7 @@ Additionally, it contains a ``volume`` parameter. Message advertisement examples -------------------------------------- -The following examples show a complete core script setup needed to advertise periodic messages. +The following examples show a complete core script (without message constraints) setup needed to advertise periodic messages. .. dropdown:: TextMESSAGE diff --git a/src/daf/logic.py b/src/daf/logic.py index 921f44b2..1af0fe1e 100644 --- a/src/daf/logic.py +++ b/src/daf/logic.py @@ -44,7 +44,7 @@ def __init__( *args, operands: List[BaseLogic] = [], ) -> None: - self.operands = [*operands, *args] + self.operands: List[BaseLogic] = [*operands, *args] @doc_category("Text matching (logic)") @@ -60,12 +60,7 @@ class and_(BooleanLogic): def check(self, input: str): for op in self.operands: - if isinstance(op, BaseLogic): - check = op.check(input) - else: - check = op in input - - if not check: + if not op.check(input): return False return True @@ -83,12 +78,7 @@ class or_(BooleanLogic): """ def check(self, input: str): for op in self.operands: - if isinstance(op, BaseLogic): - check = op.check(input) - else: - check = op in input - - if check: + if op.check(input): return True return False @@ -113,11 +103,8 @@ def operand(self): return self.operands[0] def check(self, input: str): - op = self.operands[0] - if isinstance(op, BaseLogic): - return not op.check(input) + return not self.operand.check(input) - return op not in input @doc_category("Text matching (logic)") diff --git a/src/daf/message/__init__.py b/src/daf/message/__init__.py index e26ffdf7..ac34ef7d 100644 --- a/src/daf/message/__init__.py +++ b/src/daf/message/__init__.py @@ -5,6 +5,7 @@ from .text_based import * from .messageperiod import * from .autochannel import * +from .constraints import * try: from .voice_based import * diff --git a/src/daf/message/base.py b/src/daf/message/base.py index 3a586ad2..ed6424d3 100644 --- a/src/daf/message/base.py +++ b/src/daf/message/base.py @@ -600,41 +600,6 @@ async def initialize( self.parent = parent return await super().initialize(event_ctrl) - @async_util.with_semaphore("update_semaphore") - async def _send(self): - """ - Sends the data into the channels. - """ - # Acquire mutex to prevent update method from writing while sending - data_to_send = await self._data.to_dict() - if self._verify_data(data_to_send): # There is data to be send - errored_channels = [] - succeeded_channels = [] - - # Send to channels - for channel in self.channels: - # Clear previous messages sent to channel if mode is MODE_DELETE_SEND - context = await self._send_channel(channel, **data_to_send) - if context["success"]: - succeeded_channels.append(channel) - else: - errored_channels.append({"channel": channel, "reason": context["reason"]}) - action = context["action"] - if action is ChannelErrorAction.SKIP_CHANNELS: # Don't try to send to other channels - break - - elif action is ChannelErrorAction.REMOVE_ACCOUNT: - self._event_ctrl.emit(EventID.g_account_expired, self.parent.parent) - break - - self._update_state(succeeded_channels, errored_channels) - if errored_channels or succeeded_channels: - return self.generate_log_context( - **data_to_send, succeeded_ch=succeeded_channels, failed_ch=errored_channels - ) - - return None - async def _on_update(self, _, _init_options: Optional[dict], **kwargs): await self._close() if "start_in" not in kwargs: diff --git a/src/daf/message/constraints.py b/src/daf/message/constraints.py new file mode 100644 index 00000000..97b021be --- /dev/null +++ b/src/daf/message/constraints.py @@ -0,0 +1,46 @@ +""" +Implements additional message constraints, that are required to pass +for a message to be sent. + +.. versionadded:: 4.1 +""" +from __future__ import annotations +from abc import abstractmethod, ABC +from _discord import TextChannel + +from .autochannel import AutoCHANNEL +from ..misc import doc_category + + +__all__ = ("AntiSpamMessageConstraint",) + + +class BaseMessageConstraint(ABC): + @abstractmethod + def check(self, channels: list[TextChannel]) -> list[TextChannel]: + """ + Checks if the message can be sent based on the configured check. + """ + + +@doc_category("Message constraints") +class AntiSpamMessageConstraint(BaseMessageConstraint): + """ + Prevents a new message to be sent if the last message in the same channel was + sent by us, thus preventing spam on inactivate channels. + + .. versionadded:: 4.1 + """ + def __init__(self, per_channel: bool = True) -> None: + self.per_channel = per_channel + super().__init__() + + def check(self, channels: list[TextChannel | AutoCHANNEL]) -> list[TextChannel]: + allowed = list(filter( + lambda channel: channel.last_message is None or + channel.last_message.author.id != channel._state.user.id, channels + )) + if not self.per_channel and len(allowed) != len(channels): # In global mode, only allow to all channels + return [] + + return allowed diff --git a/src/daf/message/text_based.py b/src/daf/message/text_based.py index 9a90bbea..807ec107 100644 --- a/src/daf/message/text_based.py +++ b/src/daf/message/text_based.py @@ -8,6 +8,7 @@ from ..messagedata.dynamicdata import _DeprecatedDynamic +from .constraints import BaseMessageConstraint from ..messagedata import BaseTextData, TextMessageData, FILE from ..logging.tracing import trace, TraceLEVELS from .messageperiod import * @@ -83,12 +84,21 @@ class TextMESSAGE(BaseChannelMessage): printed to the console instead of message being published to the follower channels. .. versionadded:: 2.10 + + period: BaseMessagePeriod + The sending period. See :ref:`Message period` for possible types. + constraints: Optional[List[BaseMessageConstraint]] + List of constraints that prevents a message from being sent unless all of them + are fulfilled. See :ref:`Message constraints` for possible types. + + .. versionadded:: 4.1 """ __slots__ = ( "mode", "sent_messages", "auto_publish", + "constraints", ) _old_data_type = Union[list, tuple, set, str, discord.Embed, FILE, _FunctionBaseCLASS] @@ -104,7 +114,8 @@ def __init__( start_in: Optional[Union[timedelta, datetime]] = None, remove_after: Optional[Union[int, timedelta, datetime]] = None, auto_publish: bool = False, - period: BaseMessagePeriod = None + period: BaseMessagePeriod = None, + constraints: List[BaseMessageConstraint] = None ): if not isinstance(data, BaseTextData): trace( @@ -133,6 +144,11 @@ def __init__( data = TextMessageData(content, embed, files) super().__init__(start_period, end_period, data, channels, start_in, remove_after, period) + + if constraints is None: + constraints = [] + + self.constraints = constraints self.mode = mode self.auto_publish = auto_publish # Dictionary for storing last sent message for each channel @@ -333,6 +349,45 @@ async def _handle_error( def _verify_data(self, data: dict) -> bool: return super()._verify_data(TextMessageData, data) + @async_util.with_semaphore("update_semaphore") + async def _send(self): + """ + Sends the data into the channels. + """ + # Acquire mutex to prevent update method from writing while sending + data_to_send = await self._data.to_dict() + if self._verify_data(data_to_send): # There is data to be send + errored_channels = [] + succeeded_channels = [] + + channels = self.channels + for constraint in self.constraints: + channels = constraint.check(channels) + + # Send to channels + for channel in channels: + # Clear previous messages sent to channel if mode is MODE_DELETE_SEND + context = await self._send_channel(channel, **data_to_send) + if context["success"]: + succeeded_channels.append(channel) + else: + errored_channels.append({"channel": channel, "reason": context["reason"]}) + action = context["action"] + if action is ChannelErrorAction.SKIP_CHANNELS: # Don't try to send to other channels + break + + elif action is ChannelErrorAction.REMOVE_ACCOUNT: + self._event_ctrl.emit(EventID.g_account_expired, self.parent.parent) + break + + self._update_state(succeeded_channels, errored_channels) + if errored_channels or succeeded_channels: + return self.generate_log_context( + **data_to_send, succeeded_ch=succeeded_channels, failed_ch=errored_channels + ) + + return None + async def _send_channel( self, channel: Union[discord.TextChannel, discord.Thread, None], @@ -461,6 +516,8 @@ class DirectMESSAGE(BaseMESSAGE): * int - provided amounts of successful sends * timedelta - the specified time difference * datetime - specific date & time + period: BaseMessagePeriod + The sending period. See :ref:`Message period` for possible types. """ __slots__ = ( diff --git a/src/daf/message/voice_based.py b/src/daf/message/voice_based.py index c19c2689..f5a983df 100644 --- a/src/daf/message/voice_based.py +++ b/src/daf/message/voice_based.py @@ -10,7 +10,7 @@ from ..messagedata.dynamicdata import _DeprecatedDynamic from ..messagedata import BaseVoiceData, VoiceMessageData, FILE -from ..misc import doc, instance_track +from ..misc import doc, instance_track, async_util from ..logging import sql from .. import dtypes @@ -69,7 +69,7 @@ class VoiceMESSAGE(BaseChannelMessage): * timedelta - the specified time difference * datetime - specific date & time period: BaseMessagePeriod - The sending period. + The sending period. See :ref:`Message period` for possible types. """ __slots__ = ( "volume", @@ -243,6 +243,41 @@ def initialize(self, parent: Any, event_ctrl: EventController, channel_getter: C def _verify_data(self, data: dict) -> bool: return super()._verify_data(VoiceMessageData, data) + @async_util.with_semaphore("update_semaphore") + async def _send(self): + """ + Sends the data into the channels. + """ + # Acquire mutex to prevent update method from writing while sending + data_to_send = await self._data.to_dict() + if self._verify_data(data_to_send): # There is data to be send + errored_channels = [] + succeeded_channels = [] + + # Send to channels + for channel in self.channels: + # Clear previous messages sent to channel if mode is MODE_DELETE_SEND + context = await self._send_channel(channel, **data_to_send) + if context["success"]: + succeeded_channels.append(channel) + else: + errored_channels.append({"channel": channel, "reason": context["reason"]}) + action = context["action"] + if action is ChannelErrorAction.SKIP_CHANNELS: # Don't try to send to other channels + break + + elif action is ChannelErrorAction.REMOVE_ACCOUNT: + self._event_ctrl.emit(EventID.g_account_expired, self.parent.parent) + break + + self._update_state(succeeded_channels, errored_channels) + if errored_channels or succeeded_channels: + return self.generate_log_context( + **data_to_send, succeeded_ch=succeeded_channels, failed_ch=errored_channels + ) + + return None + async def _send_channel(self, channel: discord.VoiceChannel, file: Optional[FILE]) -> dict: From f4e94d36ac052683dd981d4da1c55cec5620548b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:04:11 +0200 Subject: [PATCH 28/31] depend(deps): update selenium requirement (#596) Updates the requirements on [selenium](https://github.com/SeleniumHQ/Selenium) to permit the latest version. - [Release notes](https://github.com/SeleniumHQ/Selenium/releases) - [Commits](https://github.com/SeleniumHQ/Selenium/compare/selenium-4.16.0...selenium-4.24.0) --- updated-dependencies: - dependency-name: selenium dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/web.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/web.txt b/requirements/web.txt index e4531b52..6961cde3 100644 --- a/requirements/web.txt +++ b/requirements/web.txt @@ -1,3 +1,3 @@ -selenium>=4.16,<4.24 +selenium>=4.16,<4.25 undetected-chromedriver>=3.5,<3.6 webdriver-manager>=4.0,<4.1 From 491e3b79c7b00078e0e571cc62a946c5b3faaaf1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:49:13 +0200 Subject: [PATCH 29/31] depend(deps): bump sphinx-autobuild from 2024.2.4 to 2024.9.3 (#595) Bumps [sphinx-autobuild](https://github.com/sphinx-doc/sphinx-autobuild) from 2024.2.4 to 2024.9.3. - [Release notes](https://github.com/sphinx-doc/sphinx-autobuild/releases) - [Changelog](https://github.com/sphinx-doc/sphinx-autobuild/blob/main/NEWS.rst) - [Commits](https://github.com/sphinx-doc/sphinx-autobuild/compare/2024.02.04...2024.09.03) --- updated-dependencies: - dependency-name: sphinx-autobuild dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/docs.txt b/requirements/docs.txt index b581acf4..318693ec 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1,5 +1,5 @@ sphinx==7.3.7 -sphinx-autobuild==2024.2.4 +sphinx-autobuild==2024.9.3 sphinx-copybutton==0.5.2 furo==2024.8.6 enum-tools[sphinx]==0.12.0 From 6dcc5759709f9d44b94a284c465002f489c6fc41 Mon Sep 17 00:00:00 2001 From: David Hozic Date: Thu, 12 Sep 2024 17:18:55 +0200 Subject: [PATCH 30/31] Disable guild join (#597) --- docs/source/changelog.rst | 4 +++- docs/source/guide/core/web.rst | 6 ++++++ src/daf/guild/autoguild.py | 12 ++++++++++++ src/daf/web.py | 4 ++++ src/daf_gui/tod_extensions/loader.py | 1 + 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 4b592c3b..928e31e3 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -50,8 +50,10 @@ v4.1.0 information. - Fixed SQL log removal through the GUI. - Fixed CSV and JSON reading through remote. +- Disabled the :ref:`Automatic guild discovery and join` features due to the search provider shutting down its + services. It will be reenabled in a future version. + - v4.0.5 ===================== - Fixed exe build. diff --git a/docs/source/guide/core/web.rst b/docs/source/guide/core/web.rst index 87a8a8a8..d3b042bc 100644 --- a/docs/source/guide/core/web.rst +++ b/docs/source/guide/core/web.rst @@ -63,6 +63,12 @@ If you restart DAF, it will not re-login, but will just load the data from the s Automatic guild discovery and join ====================================== + +.. error:: + + This feature is currently **disabled** due to the search provider going down its + services. It will be reenabled in a future version. + The web layer beside login with username and password, also allows (semi) automatic guild discovery and join. To use this feature, users need to create an :class:`~daf.guild.AutoGUILD` instance, where they pass the ``auto_join`` diff --git a/src/daf/guild/autoguild.py b/src/daf/guild/autoguild.py index fe460394..6c1c9eda 100644 --- a/src/daf/guild/autoguild.py +++ b/src/daf/guild/autoguild.py @@ -88,6 +88,10 @@ class AutoGUILD: auto_join: Optional[web.GuildDISCOVERY] = None .. versionadded:: v2.5 + .. warning:: + This is temporarily disabled until a new guild provider is found. + It will be reenabled in a future version. + Optional :class:`~daf.web.GuildDISCOVERY` object which will automatically discover and join guilds though the browser. This will open a Google Chrome session. @@ -155,6 +159,14 @@ def __init__( self._remove_after = remove_after self._messages: List[MessageDuplicator] = [] self.logging = logging + + if auto_join is not None: # TODO: remove in future after feature is reenabled. + auto_join = None + trace( + "Automatic join feature is currently disabled and will not work. It will be reenabled in a future version.", + TraceLEVELS.WARNING + ) + self.auto_join = auto_join self.parent = None self.guild_query_iter = None diff --git a/src/daf/web.py b/src/daf/web.py index d8bc146f..33595bdf 100644 --- a/src/daf/web.py +++ b/src/daf/web.py @@ -728,6 +728,10 @@ def __init__(self, id: int, name: str, url: str) -> None: @doc.doc_category("Web") class GuildDISCOVERY: """ + .. warning:: + + This is temporarily disabled (since v4.1) until a new guild provider is found. + Client used for searching servers. To be used with :class:`daf.guild.AutoGUILD`. diff --git a/src/daf_gui/tod_extensions/loader.py b/src/daf_gui/tod_extensions/loader.py index 687a5762..d5a0ee7b 100644 --- a/src/daf_gui/tod_extensions/loader.py +++ b/src/daf_gui/tod_extensions/loader.py @@ -48,6 +48,7 @@ def register_extensions(): def register_deprecations(): register_deprecated(daf.AutoGUILD, "include_pattern", str) register_deprecated(daf.AutoGUILD, "exclude_pattern") + register_deprecated(daf.AutoGUILD, "auto_join") register_deprecated(daf.AutoCHANNEL, "include_pattern", str) register_deprecated(daf.AutoCHANNEL, "exclude_pattern") From 66d40fa42d68b90aff7029459bc4cb658524bf07 Mon Sep 17 00:00:00 2001 From: David Hozic Date: Thu, 12 Sep 2024 17:40:31 +0200 Subject: [PATCH 31/31] Update web.rst (#598) --- docs/source/guide/core/web.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/guide/core/web.rst b/docs/source/guide/core/web.rst index d3b042bc..6d7399c8 100644 --- a/docs/source/guide/core/web.rst +++ b/docs/source/guide/core/web.rst @@ -66,7 +66,7 @@ Automatic guild discovery and join .. error:: - This feature is currently **disabled** due to the search provider going down its + This feature is currently **disabled** due to the search provider shutting down its services. It will be reenabled in a future version. The web layer beside login with username and password, also allows (semi) automatic guild discovery and join.