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