1import os.path 2import time 3import tkinter as tk 4from datetime import datetime 5 6from thonny import THONNY_USER_DIR, get_workbench 7from thonny.languages import tr 8from thonny.shell import ShellView 9from thonny.ui_utils import asksaveasfilename 10from thonny.workbench import WorkbenchEvent 11 12 13class EventLogger: 14 def __init__(self, filename): 15 self._filename = filename 16 self._events = [] 17 18 wb = get_workbench() 19 wb.bind("WorkbenchClose", self._on_worbench_close, True) 20 21 for sequence in [ 22 "<<Undo>>", 23 "<<Redo>>", 24 "<<Cut>>", 25 "<<Copy>>", 26 "<<Paste>>", 27 # "<<Selection>>", 28 # "<Key>", 29 # "<KeyRelease>", 30 "<Button-1>", 31 "<Button-2>", 32 "<Button-3>", 33 ]: 34 self._bind_all(sequence) 35 36 for sequence in [ 37 "UiCommandDispatched", 38 "MagicCommand", 39 "Open", 40 "Save", 41 "SaveAs", 42 "NewFile", 43 "EditorTextCreated", 44 "EditorTextDestroyed", 45 # "ShellTextCreated", # Too bad, this event happens before event_logging is loaded 46 "ShellCommand", 47 "ShellInput", 48 "ShowView", 49 "HideView", 50 "TextInsert", 51 "TextDelete", 52 ]: 53 self._bind_workbench(sequence) 54 55 self._bind_workbench("<FocusIn>", True) 56 self._bind_workbench("<FocusOut>", True) 57 58 ### log_user_event(KeyPressEvent(self, e.char, e.keysym, self.text.index(tk.INSERT))) 59 60 # TODO: if event data includes an Editor, then look up also text id 61 62 def _bind_workbench(self, sequence, only_workbench_widget=False): 63 def handle(event): 64 if not only_workbench_widget or event.widget == get_workbench(): 65 self._log_event(sequence, event) 66 67 get_workbench().bind(sequence, handle, True) 68 69 def _bind_all(self, sequence): 70 def handle(event): 71 self._log_event(sequence, event) 72 73 tk._default_root.bind_all(sequence, handle, True) 74 75 def _extract_interesting_data(self, event, sequence): 76 attributes = vars(event) 77 78 # generate some new attributes 79 if "text_widget" not in attributes: 80 if "editor" in attributes: 81 attributes["text_widget"] = attributes["editor"].get_text_widget() 82 83 if "widget" in attributes and isinstance(attributes["widget"], tk.Text): 84 attributes["text_widget"] = attributes["widget"] 85 86 if "text_widget" in attributes: 87 widget = attributes["text_widget"] 88 if isinstance(widget.master.master, ShellView): 89 attributes["text_widget_context"] = "shell" 90 91 # select attributes 92 data = {} 93 for name in attributes: 94 # skip some attributes 95 if ( 96 name.startswith("_") 97 or isinstance(event, WorkbenchEvent) 98 and name in ["update", "setdefault"] 99 or isinstance(event, tk.Event) 100 and name not in ["widget", "text_widget", "text_widget_context"] 101 ): 102 continue 103 104 value = attributes[name] 105 106 if isinstance(value, (tk.BaseWidget, tk.Tk)): 107 data[name + "_id"] = id(value) 108 data[name + "_class"] = value.__class__.__name__ 109 110 elif isinstance(value, (str, int, float)): 111 data[name] = value 112 113 else: 114 data[name] = repr(value) 115 116 return data 117 118 def _log_event(self, sequence, event): 119 data = self._extract_interesting_data(event, sequence) 120 data["sequence"] = sequence 121 data["time"] = datetime.now().isoformat() 122 if len(data["time"]) == 19: 123 # 0 fraction gets skipped, but reader assumes it 124 data["time"] += ".0" 125 self._events.append(data) 126 127 def _on_worbench_close(self, event=None): 128 import json 129 130 with open(self._filename, encoding="UTF-8", mode="w") as fp: 131 json.dump(self._events, fp, indent=" ") 132 133 self._check_compress_logs() 134 135 def _check_compress_logs(self): 136 import zipfile 137 138 # if uncompressed logs have grown over 10MB, 139 # compress these into new zipfile 140 141 log_dir = _get_log_dir() 142 total_size = 0 143 uncompressed_files = [] 144 for item in os.listdir(log_dir): 145 if item.endswith(".txt"): 146 full_name = os.path.join(log_dir, item) 147 total_size += os.stat(full_name).st_size 148 uncompressed_files.append((item, full_name)) 149 150 if total_size > 10 * 1024 * 1024: 151 zip_filename = _generate_timestamp_file_name("zip") 152 with zipfile.ZipFile(zip_filename, "w", compression=zipfile.ZIP_DEFLATED) as zipf: 153 for item, full_name in uncompressed_files: 154 zipf.write(full_name, arcname=item) 155 156 for _, full_name in uncompressed_files: 157 os.remove(full_name) 158 159 160def _generate_timestamp_file_name(extension): 161 # generate log filename 162 folder = _get_log_dir() 163 if not os.path.exists(folder): 164 os.makedirs(folder) 165 166 for i in range(100): 167 filename = os.path.join( 168 folder, time.strftime("%Y-%m-%d_%H-%M-%S_{}.{}".format(i, extension)) 169 ) 170 if not os.path.exists(filename): 171 return filename 172 173 raise RuntimeError() 174 175 176def _get_log_dir(): 177 return os.path.join(THONNY_USER_DIR, "user_logs") 178 179 180def export(): 181 import zipfile 182 183 filename = asksaveasfilename( 184 filetypes=[("Zip-files", ".zip"), ("all files", ".*")], 185 defaultextension=".zip", 186 initialdir=get_workbench().get_local_cwd(), 187 initialfile=time.strftime("ThonnyUsageLogs_%Y-%m-%d.zip"), 188 parent=get_workbench(), 189 ) 190 191 if not filename: 192 return 193 194 log_dir = _get_log_dir() 195 196 with zipfile.ZipFile(filename, "w", compression=zipfile.ZIP_DEFLATED) as zipf: 197 for item in os.listdir(log_dir): 198 if item.endswith(".txt") or item.endswith(".zip"): 199 zipf.write(os.path.join(log_dir, item), arcname=item) 200 201 202def load_plugin() -> None: 203 get_workbench().set_default("general.event_logging", False) 204 205 if get_workbench().get_option("general.event_logging"): 206 get_workbench().add_command( 207 "export_usage_logs", "tools", tr("Export usage logs..."), export, group=110 208 ) 209 210 filename = _generate_timestamp_file_name("txt") 211 # create logger 212 EventLogger(filename) 213