bdalabb 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bdalabb-0.1.0.dist-info/METADATA +46 -0
- bdalabb-0.1.0.dist-info/RECORD +16 -0
- bdalabb-0.1.0.dist-info/WHEEL +5 -0
- bdalabb-0.1.0.dist-info/entry_points.txt +2 -0
- bdalabb-0.1.0.dist-info/top_level.txt +1 -0
- labprograms/__init__.py +3 -0
- labprograms/__main__.py +5 -0
- labprograms/app.py +517 -0
- labprograms/catalog.py +295 -0
- labprograms/hotkeys.py +126 -0
- labprograms/preview.py +40 -0
- labprograms/programs/program1-matrix-multiplication/MatrixMulti.java +113 -0
- labprograms/programs/program1-matrix-multiplication/RUN_COMMANDS.txt +12 -0
- labprograms/programs/program1-matrix-multiplication/input1.txt +4 -0
- labprograms/programs/program1-matrix-multiplication/input2.txt +4 -0
- labprograms/programs/program1-matrix-multiplication/program.json +4 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bdalabb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: QuickPad-style hotkey overlay for Hadoop lab programs
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# BDA Lab
|
|
9
|
+
|
|
10
|
+
A Windows desktop overlay for keeping Hadoop lab programs together. Hold the
|
|
11
|
+
dedicated Numpad Minus key to show the window until you release it. A quick
|
|
12
|
+
second press also shows it only while that second press is held. Tap the key
|
|
13
|
+
three times quickly to pin or unpin the window. The top-row hyphen key is not
|
|
14
|
+
used. Press **Ctrl+Shift+Q** to exit the background app.
|
|
15
|
+
|
|
16
|
+
Each browser-style program tab contains inner tabs for its source code,
|
|
17
|
+
execution commands, and input files. You can preview files, download the
|
|
18
|
+
selected file, or download the complete program as a ZIP.
|
|
19
|
+
|
|
20
|
+
## Install and run
|
|
21
|
+
|
|
22
|
+
Use Windows with Python 3.10 or newer and **Tcl/Tk** included:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
pip install bdalabb
|
|
26
|
+
bdalabb
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The app starts in the background and waits for the Numpad Minus key.
|
|
30
|
+
|
|
31
|
+
## Program 1
|
|
32
|
+
|
|
33
|
+
The package includes **Implementing Matrix Multiplication Using One MapReduce
|
|
34
|
+
Step** with `MatrixMulti.java`, `RUN_COMMANDS.txt`, `input1.txt`, and
|
|
35
|
+
`input2.txt`.
|
|
36
|
+
|
|
37
|
+
## Add more programs
|
|
38
|
+
|
|
39
|
+
Pin the window with three quick taps, then choose **Add program folder** to keep
|
|
40
|
+
a complete lab program together. It is copied into the app's per-user data
|
|
41
|
+
folder. The app picks a `.java` file as the main preview when available. You can
|
|
42
|
+
also add individual files; each becomes its own program tab.
|
|
43
|
+
|
|
44
|
+
To ship fixed files inside a later package build, add a folder under
|
|
45
|
+
`src/labprograms/programs/` with a `program.json` file containing `title` and
|
|
46
|
+
`primary_file` fields. No cloud account or network connection is required.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
labprograms/__init__.py,sha256=9dKswjjlp1qEYEEGVwBkTAOjaX3dSD_HVCvntmfw9eg,66
|
|
2
|
+
labprograms/__main__.py,sha256=gSsgGE9s4B5-SzM92_WkltpSoCCaJSE8nERjbUdGY8I,80
|
|
3
|
+
labprograms/app.py,sha256=s-u3RZ4y80Nyxae2s1vV9fMOUmNiErgWAQEiV1ga_rA,18791
|
|
4
|
+
labprograms/catalog.py,sha256=V01wWWIp7ffZLxlPGxHekpKCppR9gYFFRqMruY-1ZSM,11161
|
|
5
|
+
labprograms/hotkeys.py,sha256=rmuNYYEI1TDlRZ-rgXniMUSvUa8o5AP_DgCJyAbpyG4,4092
|
|
6
|
+
labprograms/preview.py,sha256=nnQQMPtsdXrBaMYyWgma0m8G3M8Y7kjbQw6rhcRw1qY,1092
|
|
7
|
+
labprograms/programs/program1-matrix-multiplication/MatrixMulti.java,sha256=Mu4YZv67Li79twatvXZug_YVONi89yCy8QQtP4eEvnM,3179
|
|
8
|
+
labprograms/programs/program1-matrix-multiplication/RUN_COMMANDS.txt,sha256=7M3zS2I2FXyXvJZeh5WTVoewFKUw0zcHZmqrK0i8mF0,367
|
|
9
|
+
labprograms/programs/program1-matrix-multiplication/input1.txt,sha256=QgBfa-McHw442ExLHXh6_8nLJ2MvzhaY3V20tEWCKAQ,40
|
|
10
|
+
labprograms/programs/program1-matrix-multiplication/input2.txt,sha256=CvrnFWrSRhL3l0AvsYOsbd5dv5Y44Zrv2-2qZyXf_WA,40
|
|
11
|
+
labprograms/programs/program1-matrix-multiplication/program.json,sha256=rNHttTT2-5X03L54OPZZBFV92zZcT5P1re0nsF5jzBs,115
|
|
12
|
+
bdalabb-0.1.0.dist-info/METADATA,sha256=ByVsYBxx5M-KM8bxYtcGFYJIGhxG-Ng3eDHHGPyxDFs,1671
|
|
13
|
+
bdalabb-0.1.0.dist-info/WHEEL,sha256=PovZm1ExVWmrRefZoXCfejlbKLnQI5SVIf1SWRV4QQI,97
|
|
14
|
+
bdalabb-0.1.0.dist-info/entry_points.txt,sha256=9Y7tcGHb_OrvS0FXXYnP7DHa66NnJBLGRLaiP5OnERw,45
|
|
15
|
+
bdalabb-0.1.0.dist-info/top_level.txt,sha256=Jj_ZtEv3BN_J23iaP3Se6v7Iewy0yW4PuPJbIg5NaYw,12
|
|
16
|
+
bdalabb-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
labprograms
|
labprograms/__init__.py
ADDED
labprograms/__main__.py
ADDED
labprograms/app.py
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""Tk desktop interface for the Lab Program Library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
import tkinter as tk
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from tkinter import filedialog, messagebox, ttk
|
|
11
|
+
|
|
12
|
+
from .catalog import ProgramFile, ProgramLibrary, create_program_archive
|
|
13
|
+
from .hotkeys import ActivationStateMachine, WindowsKeyPoller
|
|
14
|
+
from .preview import read_preview
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
INK = "#17263d"
|
|
18
|
+
MUTED = "#607086"
|
|
19
|
+
ACCENT = "#167c72"
|
|
20
|
+
PANEL = "#f4f7fa"
|
|
21
|
+
PREVIEW_BG = "#101d2d"
|
|
22
|
+
PREVIEW_FG = "#d7e4ee"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _format_size(size: int) -> str:
|
|
26
|
+
if size < 1024:
|
|
27
|
+
return f"{size} B"
|
|
28
|
+
if size < 1024 * 1024:
|
|
29
|
+
return f"{size / 1024:.1f} KB"
|
|
30
|
+
return f"{size / (1024 * 1024):.1f} MB"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LabProgramApp:
|
|
34
|
+
def __init__(self, library: ProgramLibrary | None = None) -> None:
|
|
35
|
+
self.library = library or ProgramLibrary()
|
|
36
|
+
self.programs: list[ProgramFile] = []
|
|
37
|
+
self.file_notebooks: list[ttk.Notebook] = []
|
|
38
|
+
self.activation = ActivationStateMachine()
|
|
39
|
+
self.root = tk.Tk()
|
|
40
|
+
self.root.title("BDA Lab — Hadoop Program Library")
|
|
41
|
+
self.root.geometry("1100x740")
|
|
42
|
+
self.root.minsize(780, 520)
|
|
43
|
+
self.root.configure(bg=PANEL)
|
|
44
|
+
self._configure_styles()
|
|
45
|
+
self._build_ui()
|
|
46
|
+
self.key_poller = WindowsKeyPoller(
|
|
47
|
+
on_minus_down=lambda: self._dispatch_hotkey(self._handle_minus_down),
|
|
48
|
+
on_minus_up=lambda: self._dispatch_hotkey(self._handle_minus_up),
|
|
49
|
+
on_quit=lambda: self._dispatch_hotkey(self._quit),
|
|
50
|
+
on_error=lambda error: self._dispatch_hotkey(
|
|
51
|
+
lambda: self._handle_hotkey_error(error)
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
self.refresh()
|
|
55
|
+
|
|
56
|
+
def _configure_styles(self) -> None:
|
|
57
|
+
style = ttk.Style(self.root)
|
|
58
|
+
preferred_theme = "vista" if sys.platform == "win32" else "clam"
|
|
59
|
+
if preferred_theme in style.theme_names():
|
|
60
|
+
style.theme_use(preferred_theme)
|
|
61
|
+
style.configure("TFrame", background=PANEL)
|
|
62
|
+
style.configure("TLabel", background=PANEL, foreground=INK)
|
|
63
|
+
style.configure("Muted.TLabel", foreground=MUTED)
|
|
64
|
+
style.configure("TNotebook", background=PANEL, borderwidth=0)
|
|
65
|
+
style.configure(
|
|
66
|
+
"TNotebook.Tab",
|
|
67
|
+
padding=(18, 10),
|
|
68
|
+
background="#e6edf2",
|
|
69
|
+
foreground=INK,
|
|
70
|
+
)
|
|
71
|
+
style.map(
|
|
72
|
+
"TNotebook.Tab",
|
|
73
|
+
background=[("selected", "#ffffff")],
|
|
74
|
+
foreground=[("selected", ACCENT)],
|
|
75
|
+
)
|
|
76
|
+
style.configure("Accent.TButton", padding=(14, 9))
|
|
77
|
+
style.map(
|
|
78
|
+
"Accent.TButton",
|
|
79
|
+
foreground=[("!disabled", "#ffffff")],
|
|
80
|
+
background=[("!disabled", ACCENT), ("active", "#0f635b")],
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def _build_ui(self) -> None:
|
|
84
|
+
shell = ttk.Frame(self.root, padding=(26, 22))
|
|
85
|
+
shell.pack(fill="both", expand=True)
|
|
86
|
+
shell.columnconfigure(0, weight=1)
|
|
87
|
+
shell.rowconfigure(2, weight=1)
|
|
88
|
+
|
|
89
|
+
header = tk.Frame(shell, bg=INK, padx=22, pady=18)
|
|
90
|
+
header.grid(row=0, column=0, sticky="ew")
|
|
91
|
+
header.columnconfigure(0, weight=1)
|
|
92
|
+
tk.Label(
|
|
93
|
+
header,
|
|
94
|
+
text="BDA LAB",
|
|
95
|
+
bg=INK,
|
|
96
|
+
fg="#ffffff",
|
|
97
|
+
font=("Segoe UI", 16, "bold"),
|
|
98
|
+
).grid(row=0, column=0, sticky="w")
|
|
99
|
+
tk.Label(
|
|
100
|
+
header,
|
|
101
|
+
text="Keep each lab program and its related files together.",
|
|
102
|
+
bg=INK,
|
|
103
|
+
fg="#c8d5e2",
|
|
104
|
+
font=("Segoe UI", 10),
|
|
105
|
+
).grid(row=1, column=0, sticky="w", pady=(5, 0))
|
|
106
|
+
|
|
107
|
+
toolbar = ttk.Frame(shell, padding=(0, 16, 0, 12))
|
|
108
|
+
toolbar.grid(row=1, column=0, sticky="ew")
|
|
109
|
+
toolbar.columnconfigure(4, weight=1)
|
|
110
|
+
ttk.Button(
|
|
111
|
+
toolbar,
|
|
112
|
+
text="Add program files",
|
|
113
|
+
style="Accent.TButton",
|
|
114
|
+
command=self.add_programs,
|
|
115
|
+
).grid(row=0, column=0, sticky="w")
|
|
116
|
+
ttk.Button(
|
|
117
|
+
toolbar,
|
|
118
|
+
text="Add program folder",
|
|
119
|
+
command=self.add_program_folder,
|
|
120
|
+
).grid(row=0, column=1, padx=(9, 0), sticky="w")
|
|
121
|
+
self.download_button = ttk.Button(
|
|
122
|
+
toolbar,
|
|
123
|
+
text="Download selected file",
|
|
124
|
+
command=self.download_active,
|
|
125
|
+
state="disabled",
|
|
126
|
+
)
|
|
127
|
+
self.download_button.grid(row=0, column=2, padx=(9, 0), sticky="w")
|
|
128
|
+
ttk.Button(
|
|
129
|
+
toolbar,
|
|
130
|
+
text="Download program bundle (.zip)",
|
|
131
|
+
command=self.download_program_bundle,
|
|
132
|
+
).grid(row=0, column=3, padx=(9, 0), sticky="w")
|
|
133
|
+
self.count_label = ttk.Label(toolbar, text="0 programs", style="Muted.TLabel")
|
|
134
|
+
self.count_label.grid(row=0, column=5, sticky="e")
|
|
135
|
+
|
|
136
|
+
body = ttk.Frame(shell)
|
|
137
|
+
body.grid(row=2, column=0, sticky="nsew")
|
|
138
|
+
body.columnconfigure(0, weight=1)
|
|
139
|
+
body.rowconfigure(0, weight=1)
|
|
140
|
+
|
|
141
|
+
self.notebook = ttk.Notebook(body)
|
|
142
|
+
self.empty_state = ttk.Frame(body, padding=34)
|
|
143
|
+
self._build_empty_state()
|
|
144
|
+
|
|
145
|
+
footer = ttk.Frame(shell, padding=(0, 12, 0, 0))
|
|
146
|
+
footer.grid(row=3, column=0, sticky="ew")
|
|
147
|
+
footer.columnconfigure(0, weight=1)
|
|
148
|
+
self.status_label = ttk.Label(
|
|
149
|
+
footer,
|
|
150
|
+
text=(
|
|
151
|
+
"Hold Numpad Minus to peek · tap it 3 times to pin · "
|
|
152
|
+
"Ctrl+Shift+Q to exit"
|
|
153
|
+
),
|
|
154
|
+
style="Muted.TLabel",
|
|
155
|
+
)
|
|
156
|
+
self.status_label.grid(row=0, column=0, sticky="w")
|
|
157
|
+
|
|
158
|
+
self.root.bind("<Control-o>", lambda _event: self.add_programs())
|
|
159
|
+
self.root.bind("<Control-Tab>", self._next_tab)
|
|
160
|
+
self.root.bind("<Control-Shift-Tab>", self._previous_tab)
|
|
161
|
+
self.notebook.bind("<<NotebookTabChanged>>", self._update_download_button)
|
|
162
|
+
self.root.protocol(
|
|
163
|
+
"WM_DELETE_WINDOW",
|
|
164
|
+
self._dismiss_overlay if sys.platform == "win32" else self._quit,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _build_empty_state(self) -> None:
|
|
168
|
+
self.empty_state.columnconfigure(0, weight=1)
|
|
169
|
+
tk.Label(
|
|
170
|
+
self.empty_state,
|
|
171
|
+
text="Your lab programs will appear here",
|
|
172
|
+
bg=PANEL,
|
|
173
|
+
fg=INK,
|
|
174
|
+
font=("Segoe UI", 19, "bold"),
|
|
175
|
+
).grid(row=0, column=0, pady=(70, 8))
|
|
176
|
+
tk.Label(
|
|
177
|
+
self.empty_state,
|
|
178
|
+
text="Each program tab can contain its source, commands, and input files.",
|
|
179
|
+
bg=PANEL,
|
|
180
|
+
fg=MUTED,
|
|
181
|
+
font=("Segoe UI", 11),
|
|
182
|
+
).grid(row=1, column=0, pady=(0, 20))
|
|
183
|
+
ttk.Button(
|
|
184
|
+
self.empty_state,
|
|
185
|
+
text="Choose lab files",
|
|
186
|
+
style="Accent.TButton",
|
|
187
|
+
command=self.add_programs,
|
|
188
|
+
).grid(row=2, column=0)
|
|
189
|
+
|
|
190
|
+
def refresh(self, select_paths: set[Path] | None = None) -> None:
|
|
191
|
+
self.programs = self.library.list_programs()
|
|
192
|
+
self.file_notebooks = []
|
|
193
|
+
for tab_id in self.notebook.tabs():
|
|
194
|
+
self.notebook.forget(tab_id)
|
|
195
|
+
|
|
196
|
+
if self.programs:
|
|
197
|
+
if self.empty_state.winfo_manager() == "grid":
|
|
198
|
+
self.empty_state.grid_remove()
|
|
199
|
+
self.notebook.grid(row=0, column=0, sticky="nsew")
|
|
200
|
+
for index, program in enumerate(self.programs, start=1):
|
|
201
|
+
page, file_notebook = self._create_program_page(index, program)
|
|
202
|
+
self.notebook.add(page, text=f"Program {index}")
|
|
203
|
+
self.file_notebooks.append(file_notebook)
|
|
204
|
+
self.count_label.configure(
|
|
205
|
+
text=f"{len(self.programs)} program"
|
|
206
|
+
f"{'' if len(self.programs) == 1 else 's'}"
|
|
207
|
+
)
|
|
208
|
+
if select_paths:
|
|
209
|
+
for index, program in enumerate(self.programs):
|
|
210
|
+
if program.path.resolve() in select_paths:
|
|
211
|
+
self.notebook.select(index)
|
|
212
|
+
break
|
|
213
|
+
else:
|
|
214
|
+
if self.notebook.winfo_manager() == "grid":
|
|
215
|
+
self.notebook.grid_remove()
|
|
216
|
+
self.empty_state.grid(row=0, column=0, sticky="nsew")
|
|
217
|
+
self.count_label.configure(text="0 programs")
|
|
218
|
+
self.status_label.configure(
|
|
219
|
+
text="Add a program folder or choose individual program files."
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
self._update_download_button()
|
|
223
|
+
|
|
224
|
+
def _create_program_page(
|
|
225
|
+
self, index: int, program: ProgramFile
|
|
226
|
+
) -> tuple[ttk.Frame, ttk.Notebook]:
|
|
227
|
+
page = ttk.Frame(self.notebook, padding=(22, 18))
|
|
228
|
+
page.columnconfigure(0, weight=1)
|
|
229
|
+
page.rowconfigure(2, weight=1)
|
|
230
|
+
|
|
231
|
+
ttk.Label(
|
|
232
|
+
page,
|
|
233
|
+
text=f"Program {index}",
|
|
234
|
+
font=("Segoe UI", 17, "bold"),
|
|
235
|
+
).grid(row=0, column=0, sticky="w")
|
|
236
|
+
ttk.Label(
|
|
237
|
+
page,
|
|
238
|
+
text=(
|
|
239
|
+
f"{program.display_name} · {len(program.files)} files · "
|
|
240
|
+
f"{_format_size(program.size)} · {program.source}"
|
|
241
|
+
),
|
|
242
|
+
style="Muted.TLabel",
|
|
243
|
+
).grid(row=1, column=0, sticky="w", pady=(5, 14))
|
|
244
|
+
|
|
245
|
+
file_notebook = ttk.Notebook(page)
|
|
246
|
+
file_notebook.grid(row=2, column=0, sticky="nsew")
|
|
247
|
+
root = program.folder or program.path.parent
|
|
248
|
+
for file_path in program.files:
|
|
249
|
+
try:
|
|
250
|
+
tab_label = file_path.relative_to(root).as_posix()
|
|
251
|
+
except ValueError:
|
|
252
|
+
tab_label = file_path.name
|
|
253
|
+
file_notebook.add(
|
|
254
|
+
self._create_file_page(file_notebook, file_path),
|
|
255
|
+
text=tab_label,
|
|
256
|
+
)
|
|
257
|
+
file_notebook.bind(
|
|
258
|
+
"<<NotebookTabChanged>>", self._update_download_button
|
|
259
|
+
)
|
|
260
|
+
return page, file_notebook
|
|
261
|
+
|
|
262
|
+
def _create_file_page(
|
|
263
|
+
self, parent: ttk.Notebook, file_path: Path
|
|
264
|
+
) -> ttk.Frame:
|
|
265
|
+
page = ttk.Frame(parent, padding=(8, 8))
|
|
266
|
+
page.columnconfigure(0, weight=1)
|
|
267
|
+
page.rowconfigure(0, weight=1)
|
|
268
|
+
try:
|
|
269
|
+
preview = read_preview(file_path)
|
|
270
|
+
except OSError as exc:
|
|
271
|
+
ttk.Label(
|
|
272
|
+
page,
|
|
273
|
+
text=f"This file could not be opened for preview: {exc}",
|
|
274
|
+
wraplength=700,
|
|
275
|
+
).grid(row=0, column=0, sticky="nw", padx=16, pady=16)
|
|
276
|
+
return page
|
|
277
|
+
|
|
278
|
+
if not preview.is_text:
|
|
279
|
+
ttk.Label(
|
|
280
|
+
page,
|
|
281
|
+
text="Preview isn’t available for this file type.\n"
|
|
282
|
+
"Use the download button to save the original file.",
|
|
283
|
+
justify="center",
|
|
284
|
+
style="Muted.TLabel",
|
|
285
|
+
).grid(row=0, column=0, sticky="nsew", padx=24, pady=24)
|
|
286
|
+
return page
|
|
287
|
+
|
|
288
|
+
text_frame = tk.Frame(page, bg=PREVIEW_BG)
|
|
289
|
+
text_frame.grid(row=0, column=0, sticky="nsew")
|
|
290
|
+
text_frame.rowconfigure(0, weight=1)
|
|
291
|
+
text_frame.columnconfigure(0, weight=1)
|
|
292
|
+
code = tk.Text(
|
|
293
|
+
text_frame,
|
|
294
|
+
wrap="none",
|
|
295
|
+
padx=18,
|
|
296
|
+
pady=16,
|
|
297
|
+
bg=PREVIEW_BG,
|
|
298
|
+
fg=PREVIEW_FG,
|
|
299
|
+
insertbackground="#ffffff",
|
|
300
|
+
selectbackground="#2b5367",
|
|
301
|
+
font=("Consolas", 10),
|
|
302
|
+
relief="flat",
|
|
303
|
+
borderwidth=0,
|
|
304
|
+
state="normal",
|
|
305
|
+
)
|
|
306
|
+
code.grid(row=0, column=0, sticky="nsew")
|
|
307
|
+
y_scroll = ttk.Scrollbar(text_frame, orient="vertical", command=code.yview)
|
|
308
|
+
y_scroll.grid(row=0, column=1, sticky="ns")
|
|
309
|
+
x_scroll = ttk.Scrollbar(text_frame, orient="horizontal", command=code.xview)
|
|
310
|
+
x_scroll.grid(row=1, column=0, sticky="ew")
|
|
311
|
+
code.configure(yscrollcommand=y_scroll.set, xscrollcommand=x_scroll.set)
|
|
312
|
+
code.insert("1.0", preview.text)
|
|
313
|
+
if preview.truncated:
|
|
314
|
+
code.insert(
|
|
315
|
+
"end",
|
|
316
|
+
"\n\n[Preview shortened. Download the file to see the full contents.]",
|
|
317
|
+
)
|
|
318
|
+
code.configure(state="disabled")
|
|
319
|
+
return page
|
|
320
|
+
|
|
321
|
+
def add_programs(self) -> None:
|
|
322
|
+
selected = filedialog.askopenfilenames(
|
|
323
|
+
parent=self.root,
|
|
324
|
+
title="Choose lab program files",
|
|
325
|
+
filetypes=[("All files", "*.*")],
|
|
326
|
+
)
|
|
327
|
+
if not selected:
|
|
328
|
+
return
|
|
329
|
+
try:
|
|
330
|
+
imported = self.library.import_files(selected)
|
|
331
|
+
except (OSError, ValueError) as exc:
|
|
332
|
+
messagebox.showerror("Could not add files", str(exc), parent=self.root)
|
|
333
|
+
return
|
|
334
|
+
self.refresh(select_paths={program.path.resolve() for program in imported})
|
|
335
|
+
self.status_label.configure(
|
|
336
|
+
text=f"Added {len(imported)} file"
|
|
337
|
+
f"{'' if len(imported) == 1 else 's'}. Originals were not changed."
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def add_program_folder(self) -> None:
|
|
341
|
+
selected = filedialog.askdirectory(
|
|
342
|
+
parent=self.root,
|
|
343
|
+
title="Choose a folder containing one complete lab program",
|
|
344
|
+
mustexist=True,
|
|
345
|
+
)
|
|
346
|
+
if not selected:
|
|
347
|
+
return
|
|
348
|
+
try:
|
|
349
|
+
program = self.library.import_directory(selected)
|
|
350
|
+
except (OSError, ValueError) as exc:
|
|
351
|
+
messagebox.showerror("Could not add program folder", str(exc), parent=self.root)
|
|
352
|
+
return
|
|
353
|
+
self.refresh(select_paths={program.path.resolve()})
|
|
354
|
+
self.status_label.configure(
|
|
355
|
+
text=(
|
|
356
|
+
f"Added {program.display_name} with {len(program.files)} files. "
|
|
357
|
+
"Original files were not changed."
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def _selected_file(self) -> Path | None:
|
|
362
|
+
if not self.programs or not self.notebook.tabs():
|
|
363
|
+
return None
|
|
364
|
+
program_index = self.notebook.index(self.notebook.select())
|
|
365
|
+
if program_index >= len(self.programs):
|
|
366
|
+
return None
|
|
367
|
+
file_notebook = self.file_notebooks[program_index]
|
|
368
|
+
if not file_notebook.tabs():
|
|
369
|
+
return None
|
|
370
|
+
file_index = file_notebook.index(file_notebook.select())
|
|
371
|
+
program = self.programs[program_index]
|
|
372
|
+
return program.files[file_index] if file_index < len(program.files) else None
|
|
373
|
+
|
|
374
|
+
def download_active(self) -> None:
|
|
375
|
+
selected_file = self._selected_file()
|
|
376
|
+
if selected_file is None:
|
|
377
|
+
return
|
|
378
|
+
downloads = Path.home() / "Downloads"
|
|
379
|
+
initial_dir = downloads if downloads.is_dir() else Path.home()
|
|
380
|
+
destination = filedialog.asksaveasfilename(
|
|
381
|
+
parent=self.root,
|
|
382
|
+
title="Download program file",
|
|
383
|
+
initialdir=initial_dir,
|
|
384
|
+
initialfile=selected_file.name,
|
|
385
|
+
filetypes=[("All files", "*.*")],
|
|
386
|
+
)
|
|
387
|
+
if not destination:
|
|
388
|
+
return
|
|
389
|
+
try:
|
|
390
|
+
shutil.copy2(selected_file, destination)
|
|
391
|
+
except (OSError, shutil.SameFileError) as exc:
|
|
392
|
+
messagebox.showerror("Download failed", str(exc), parent=self.root)
|
|
393
|
+
return
|
|
394
|
+
self.status_label.configure(
|
|
395
|
+
text=f"Saved a copy of {selected_file.name} to {destination}"
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def download_program_bundle(self) -> None:
|
|
399
|
+
if not self.programs or not self.notebook.tabs():
|
|
400
|
+
return
|
|
401
|
+
program_index = self.notebook.index(self.notebook.select())
|
|
402
|
+
if program_index >= len(self.programs):
|
|
403
|
+
return
|
|
404
|
+
program = self.programs[program_index]
|
|
405
|
+
initial_name = (
|
|
406
|
+
f"{program.folder.name}.zip"
|
|
407
|
+
if program.folder
|
|
408
|
+
else f"{program.display_name}.zip"
|
|
409
|
+
)
|
|
410
|
+
downloads = Path.home() / "Downloads"
|
|
411
|
+
initial_dir = downloads if downloads.is_dir() else Path.home()
|
|
412
|
+
destination = filedialog.asksaveasfilename(
|
|
413
|
+
parent=self.root,
|
|
414
|
+
title="Download complete program",
|
|
415
|
+
initialdir=initial_dir,
|
|
416
|
+
initialfile=initial_name,
|
|
417
|
+
defaultextension=".zip",
|
|
418
|
+
filetypes=[("ZIP archive", "*.zip")],
|
|
419
|
+
)
|
|
420
|
+
if not destination:
|
|
421
|
+
return
|
|
422
|
+
try:
|
|
423
|
+
archive_path = create_program_archive(program, destination)
|
|
424
|
+
except (OSError, ValueError) as exc:
|
|
425
|
+
messagebox.showerror("Download failed", str(exc), parent=self.root)
|
|
426
|
+
return
|
|
427
|
+
self.status_label.configure(
|
|
428
|
+
text=f"Saved all {len(program.files)} files for {program.display_name} "
|
|
429
|
+
f"to {archive_path}"
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
def _update_download_button(self, _event: tk.Event | None = None) -> None:
|
|
433
|
+
selected_file = self._selected_file()
|
|
434
|
+
if selected_file is None:
|
|
435
|
+
self.download_button.configure(
|
|
436
|
+
text="Download selected file",
|
|
437
|
+
state="disabled",
|
|
438
|
+
)
|
|
439
|
+
else:
|
|
440
|
+
self.download_button.configure(
|
|
441
|
+
text=f"Download {selected_file.name}",
|
|
442
|
+
state="normal",
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
def _dispatch_hotkey(self, callback) -> None:
|
|
446
|
+
try:
|
|
447
|
+
self.root.after(0, callback)
|
|
448
|
+
except tk.TclError:
|
|
449
|
+
pass
|
|
450
|
+
|
|
451
|
+
def _handle_minus_down(self) -> None:
|
|
452
|
+
action = self.activation.press(time.monotonic())
|
|
453
|
+
if action == "show":
|
|
454
|
+
self._show_overlay()
|
|
455
|
+
|
|
456
|
+
def _handle_minus_up(self) -> None:
|
|
457
|
+
action = self.activation.release(time.monotonic())
|
|
458
|
+
if action == "hide":
|
|
459
|
+
self.root.withdraw()
|
|
460
|
+
|
|
461
|
+
def _show_overlay(self) -> None:
|
|
462
|
+
self.root.deiconify()
|
|
463
|
+
self.root.lift()
|
|
464
|
+
self.root.attributes("-topmost", True)
|
|
465
|
+
self.root.focus_force()
|
|
466
|
+
|
|
467
|
+
def _dismiss_overlay(self) -> None:
|
|
468
|
+
self.activation.dismiss()
|
|
469
|
+
self.root.withdraw()
|
|
470
|
+
|
|
471
|
+
def _handle_hotkey_error(self, error: Exception) -> None:
|
|
472
|
+
self.root.deiconify()
|
|
473
|
+
self.status_label.configure(
|
|
474
|
+
text=f"Numpad Minus activation could not start: {error}"
|
|
475
|
+
)
|
|
476
|
+
messagebox.showerror(
|
|
477
|
+
"Hotkey unavailable",
|
|
478
|
+
f"Could not start the Numpad Minus listener:\n{error}",
|
|
479
|
+
parent=self.root,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
def _quit(self) -> None:
|
|
483
|
+
self.key_poller.stop()
|
|
484
|
+
self.root.destroy()
|
|
485
|
+
|
|
486
|
+
def _next_tab(self, _event: tk.Event) -> str:
|
|
487
|
+
tabs = self.notebook.tabs()
|
|
488
|
+
if tabs:
|
|
489
|
+
current = self.notebook.index(self.notebook.select())
|
|
490
|
+
self.notebook.select((current + 1) % len(tabs))
|
|
491
|
+
return "break"
|
|
492
|
+
|
|
493
|
+
def _previous_tab(self, _event: tk.Event) -> str:
|
|
494
|
+
tabs = self.notebook.tabs()
|
|
495
|
+
if tabs:
|
|
496
|
+
current = self.notebook.index(self.notebook.select())
|
|
497
|
+
self.notebook.select((current - 1) % len(tabs))
|
|
498
|
+
return "break"
|
|
499
|
+
|
|
500
|
+
def run(self) -> None:
|
|
501
|
+
if sys.platform == "win32":
|
|
502
|
+
self.root.withdraw()
|
|
503
|
+
self.root.after(0, self.key_poller.start)
|
|
504
|
+
else:
|
|
505
|
+
self.root.deiconify()
|
|
506
|
+
self.status_label.configure(
|
|
507
|
+
text="Global Numpad Minus activation is available on Windows."
|
|
508
|
+
)
|
|
509
|
+
try:
|
|
510
|
+
self.root.mainloop()
|
|
511
|
+
finally:
|
|
512
|
+
self.key_poller.stop()
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def main() -> int:
|
|
516
|
+
LabProgramApp().run()
|
|
517
|
+
return 0
|
labprograms/catalog.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Find, import, and safely store lab program files and file bundles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import filecmp
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import zipfile
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
from typing import Iterable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
MANIFEST_NAME = "program.json"
|
|
17
|
+
IGNORED_DIRECTORY_NAMES = {".git", ".venv", "__pycache__"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ProgramFile:
|
|
22
|
+
"""One program, represented by a primary file and its associated assets."""
|
|
23
|
+
|
|
24
|
+
path: Path
|
|
25
|
+
source: str
|
|
26
|
+
title: str | None = None
|
|
27
|
+
associated_files: tuple[Path, ...] = ()
|
|
28
|
+
folder: Path | None = None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def name(self) -> str:
|
|
32
|
+
return self.path.name
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def display_name(self) -> str:
|
|
36
|
+
return self.title or self.path.stem
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def files(self) -> tuple[Path, ...]:
|
|
40
|
+
return (self.path, *self.associated_files)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def size(self) -> int:
|
|
44
|
+
try:
|
|
45
|
+
return sum(path.stat().st_size for path in self.files)
|
|
46
|
+
except OSError:
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def default_data_dir() -> Path:
|
|
51
|
+
"""Return a per-user data directory without touching the package install."""
|
|
52
|
+
if os.name == "nt":
|
|
53
|
+
base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
|
|
54
|
+
else:
|
|
55
|
+
base = Path(
|
|
56
|
+
os.environ.get("XDG_DATA_HOME")
|
|
57
|
+
or Path.home() / ".local" / "share"
|
|
58
|
+
)
|
|
59
|
+
return base / "LabProgramLibrary"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _natural_key(value: str) -> tuple[tuple[int, int | str], ...]:
|
|
63
|
+
parts = re.split(r"(\d+)", value.casefold())
|
|
64
|
+
return tuple((0, int(part)) if part.isdigit() else (1, part) for part in parts)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def create_program_archive(program: ProgramFile, destination: str | Path) -> Path:
|
|
68
|
+
"""Write all files in a program to a ZIP with a single top-level folder."""
|
|
69
|
+
target = Path(destination)
|
|
70
|
+
resolved_target = target.resolve()
|
|
71
|
+
program_paths = {path.resolve() for path in program.files}
|
|
72
|
+
if resolved_target in program_paths or (
|
|
73
|
+
program.folder
|
|
74
|
+
and resolved_target.is_relative_to(program.folder.resolve())
|
|
75
|
+
):
|
|
76
|
+
raise ValueError("Save the ZIP outside the program's source folder.")
|
|
77
|
+
root_name = program.folder.name if program.folder else "Program"
|
|
78
|
+
root_name = root_name.replace("/", "_").replace("\\", "_").strip()
|
|
79
|
+
if root_name in {"", ".", ".."}:
|
|
80
|
+
root_name = "Program"
|
|
81
|
+
|
|
82
|
+
with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
83
|
+
for file_path in program.files:
|
|
84
|
+
relative = (
|
|
85
|
+
file_path.relative_to(program.folder)
|
|
86
|
+
if program.folder
|
|
87
|
+
else Path(file_path.name)
|
|
88
|
+
)
|
|
89
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
90
|
+
raise ValueError(f"Unsafe path in program bundle: {relative}")
|
|
91
|
+
archive_path = PurePosixPath(root_name, *relative.parts).as_posix()
|
|
92
|
+
archive.write(file_path, archive_path)
|
|
93
|
+
return target
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _read_program_directory(directory: Path, source: str) -> ProgramFile | None:
|
|
97
|
+
"""Read a bundle folder, using its manifest when one is present."""
|
|
98
|
+
manifest_path = directory / MANIFEST_NAME
|
|
99
|
+
manifest: dict[str, object] = {}
|
|
100
|
+
if manifest_path.is_file():
|
|
101
|
+
try:
|
|
102
|
+
content = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
103
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
104
|
+
raise ValueError(f"Could not read {manifest_path}: {exc}") from exc
|
|
105
|
+
if not isinstance(content, dict):
|
|
106
|
+
raise ValueError(f"{manifest_path} must contain a JSON object.")
|
|
107
|
+
manifest = content
|
|
108
|
+
|
|
109
|
+
files = sorted(
|
|
110
|
+
(
|
|
111
|
+
path
|
|
112
|
+
for path in directory.rglob("*")
|
|
113
|
+
if path.is_file()
|
|
114
|
+
and not path.is_symlink()
|
|
115
|
+
and not any(part.startswith(".") for part in path.relative_to(directory).parts)
|
|
116
|
+
and path.name != MANIFEST_NAME
|
|
117
|
+
),
|
|
118
|
+
key=lambda path: _natural_key(path.relative_to(directory).as_posix()),
|
|
119
|
+
)
|
|
120
|
+
if not files:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
requested_primary = manifest.get("primary_file")
|
|
124
|
+
if requested_primary is not None:
|
|
125
|
+
if not isinstance(requested_primary, str):
|
|
126
|
+
raise ValueError(f"{manifest_path} has an invalid primary_file value.")
|
|
127
|
+
primary = (directory / requested_primary).resolve()
|
|
128
|
+
if (
|
|
129
|
+
not primary.is_relative_to(directory.resolve())
|
|
130
|
+
or not primary.is_file()
|
|
131
|
+
or primary not in [path.resolve() for path in files]
|
|
132
|
+
):
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"{manifest_path} points to a missing or unsafe primary file."
|
|
135
|
+
)
|
|
136
|
+
primary = next(path for path in files if path.resolve() == primary)
|
|
137
|
+
else:
|
|
138
|
+
primary = next(
|
|
139
|
+
(path for path in files if path.suffix.casefold() == ".java"),
|
|
140
|
+
files[0],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
title = manifest.get("title", directory.name)
|
|
144
|
+
if not isinstance(title, str) or not title.strip():
|
|
145
|
+
raise ValueError(f"{manifest_path} must have a non-empty title.")
|
|
146
|
+
associated = tuple(path for path in files if path != primary)
|
|
147
|
+
return ProgramFile(
|
|
148
|
+
path=primary,
|
|
149
|
+
source=source,
|
|
150
|
+
title=title.strip(),
|
|
151
|
+
associated_files=associated,
|
|
152
|
+
folder=directory,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class ProgramLibrary:
|
|
157
|
+
"""Combines bundled programs with files and folders imported by the user."""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
data_dir: Path | None = None,
|
|
162
|
+
bundled_dir: Path | None = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
package_dir = Path(__file__).resolve().parent
|
|
165
|
+
self.data_dir = Path(data_dir) if data_dir else default_data_dir()
|
|
166
|
+
self.user_program_dir = self.data_dir / "programs"
|
|
167
|
+
self.bundled_dir = (
|
|
168
|
+
Path(bundled_dir) if bundled_dir else package_dir / "programs"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def list_programs(self) -> list[ProgramFile]:
|
|
172
|
+
programs: list[ProgramFile] = []
|
|
173
|
+
for directory, source in (
|
|
174
|
+
(self.bundled_dir, "Included"),
|
|
175
|
+
(self.user_program_dir, "Added"),
|
|
176
|
+
):
|
|
177
|
+
if not directory.is_dir():
|
|
178
|
+
continue
|
|
179
|
+
for path in directory.iterdir():
|
|
180
|
+
if path.is_dir() and not path.name.startswith("."):
|
|
181
|
+
program = _read_program_directory(path, source)
|
|
182
|
+
if program:
|
|
183
|
+
programs.append(program)
|
|
184
|
+
elif path.is_file() and not path.name.startswith("."):
|
|
185
|
+
programs.append(ProgramFile(path=path, source=source))
|
|
186
|
+
return sorted(
|
|
187
|
+
programs,
|
|
188
|
+
key=lambda item: (
|
|
189
|
+
_natural_key(item.folder.name if item.folder else item.name),
|
|
190
|
+
item.source,
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def import_files(self, paths: Iterable[str | Path]) -> list[ProgramFile]:
|
|
195
|
+
"""Copy selected files into app storage; never overwrite an existing file."""
|
|
196
|
+
self.user_program_dir.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
imported: list[ProgramFile] = []
|
|
198
|
+
|
|
199
|
+
for selected in paths:
|
|
200
|
+
source_path = Path(selected).expanduser()
|
|
201
|
+
if not source_path.is_file():
|
|
202
|
+
raise FileNotFoundError(f"Program file not found: {source_path}")
|
|
203
|
+
|
|
204
|
+
destination = self._destination_for(source_path)
|
|
205
|
+
if source_path.resolve() != destination.resolve():
|
|
206
|
+
if not destination.exists():
|
|
207
|
+
shutil.copy2(source_path, destination)
|
|
208
|
+
imported.append(ProgramFile(path=destination, source="Added"))
|
|
209
|
+
|
|
210
|
+
return imported
|
|
211
|
+
|
|
212
|
+
def import_directory(self, selected: str | Path) -> ProgramFile:
|
|
213
|
+
"""Import a directory as one program bundle, preserving its relative paths."""
|
|
214
|
+
source = Path(selected).expanduser().resolve()
|
|
215
|
+
if not source.is_dir():
|
|
216
|
+
raise NotADirectoryError(f"Program folder not found: {source}")
|
|
217
|
+
library_dir = self.user_program_dir.resolve()
|
|
218
|
+
if source.is_relative_to(library_dir) or library_dir.is_relative_to(source):
|
|
219
|
+
raise ValueError(
|
|
220
|
+
"Choose a program folder outside the app's own library folder."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
self.user_program_dir.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
destination = self._directory_destination(source.name)
|
|
225
|
+
destination.mkdir(parents=True)
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
self._copy_program_tree(source, destination)
|
|
229
|
+
program = _read_program_directory(destination, "Added")
|
|
230
|
+
if program is None:
|
|
231
|
+
raise ValueError("The selected folder does not contain any files.")
|
|
232
|
+
manifest_path = destination / MANIFEST_NAME
|
|
233
|
+
if not manifest_path.exists():
|
|
234
|
+
manifest_path.write_text(
|
|
235
|
+
json.dumps(
|
|
236
|
+
{
|
|
237
|
+
"title": source.name,
|
|
238
|
+
"primary_file": program.path.relative_to(destination).as_posix(),
|
|
239
|
+
},
|
|
240
|
+
indent=2,
|
|
241
|
+
)
|
|
242
|
+
+ "\n",
|
|
243
|
+
encoding="utf-8",
|
|
244
|
+
)
|
|
245
|
+
program = _read_program_directory(destination, "Added")
|
|
246
|
+
assert program is not None
|
|
247
|
+
return program
|
|
248
|
+
except Exception:
|
|
249
|
+
shutil.rmtree(destination, ignore_errors=True)
|
|
250
|
+
raise
|
|
251
|
+
|
|
252
|
+
def _copy_program_tree(self, source: Path, destination: Path) -> None:
|
|
253
|
+
for current, directories, filenames in os.walk(source, followlinks=False):
|
|
254
|
+
current_path = Path(current)
|
|
255
|
+
directories[:] = [
|
|
256
|
+
name
|
|
257
|
+
for name in directories
|
|
258
|
+
if name not in IGNORED_DIRECTORY_NAMES
|
|
259
|
+
and not (current_path / name).is_symlink()
|
|
260
|
+
]
|
|
261
|
+
relative = current_path.relative_to(source)
|
|
262
|
+
target_dir = destination / relative
|
|
263
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
for filename in filenames:
|
|
265
|
+
source_file = current_path / filename
|
|
266
|
+
if source_file.is_symlink() or not source_file.is_file():
|
|
267
|
+
continue
|
|
268
|
+
shutil.copy2(source_file, target_dir / filename)
|
|
269
|
+
|
|
270
|
+
def _directory_destination(self, name: str) -> Path:
|
|
271
|
+
candidate = self.user_program_dir / name
|
|
272
|
+
number = 2
|
|
273
|
+
while candidate.exists():
|
|
274
|
+
candidate = self.user_program_dir / f"{name} ({number})"
|
|
275
|
+
number += 1
|
|
276
|
+
return candidate
|
|
277
|
+
|
|
278
|
+
def _destination_for(self, source_path: Path) -> Path:
|
|
279
|
+
candidate = self.user_program_dir / source_path.name
|
|
280
|
+
if not candidate.exists():
|
|
281
|
+
return candidate
|
|
282
|
+
if source_path.resolve() == candidate.resolve():
|
|
283
|
+
return candidate
|
|
284
|
+
if filecmp.cmp(source_path, candidate, shallow=False):
|
|
285
|
+
return candidate
|
|
286
|
+
|
|
287
|
+
stem, suffix = source_path.stem, source_path.suffix
|
|
288
|
+
number = 2
|
|
289
|
+
while True:
|
|
290
|
+
candidate = self.user_program_dir / f"{stem} ({number}){suffix}"
|
|
291
|
+
if not candidate.exists():
|
|
292
|
+
return candidate
|
|
293
|
+
if filecmp.cmp(source_path, candidate, shallow=False):
|
|
294
|
+
return candidate
|
|
295
|
+
number += 1
|
labprograms/hotkeys.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Windows Numpad Minus activation logic for the desktop overlay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
VK_SUBTRACT = 0x6D
|
|
13
|
+
VK_CONTROL = 0x11
|
|
14
|
+
VK_SHIFT = 0x10
|
|
15
|
+
VK_Q = 0x51
|
|
16
|
+
DEFAULT_TAP_WINDOW = 0.45
|
|
17
|
+
POLL_INTERVAL = 0.01
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ActivationStateMachine:
|
|
21
|
+
"""Single hold peeks, while a triple tap toggles the pinned state."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, tap_window: float = DEFAULT_TAP_WINDOW) -> None:
|
|
24
|
+
self.tap_window = tap_window
|
|
25
|
+
self.key_is_down = False
|
|
26
|
+
self.last_release_time: float | None = None
|
|
27
|
+
self.press_count = 0
|
|
28
|
+
self.pinned = False
|
|
29
|
+
|
|
30
|
+
def press(self, now: float) -> str | None:
|
|
31
|
+
"""Handle a fresh key-down edge and return the requested window action."""
|
|
32
|
+
if self.key_is_down:
|
|
33
|
+
return None
|
|
34
|
+
self.key_is_down = True
|
|
35
|
+
|
|
36
|
+
if (
|
|
37
|
+
self.last_release_time is not None
|
|
38
|
+
and 0 <= now - self.last_release_time <= self.tap_window
|
|
39
|
+
):
|
|
40
|
+
self.press_count += 1
|
|
41
|
+
else:
|
|
42
|
+
self.press_count = 1
|
|
43
|
+
|
|
44
|
+
if self.press_count >= 3:
|
|
45
|
+
self.pinned = not self.pinned
|
|
46
|
+
self.press_count = 0
|
|
47
|
+
return "show"
|
|
48
|
+
|
|
49
|
+
def release(self, now: float) -> str | None:
|
|
50
|
+
"""Hide after a peek release, but leave a pinned window visible."""
|
|
51
|
+
if not self.key_is_down:
|
|
52
|
+
return None
|
|
53
|
+
self.key_is_down = False
|
|
54
|
+
self.last_release_time = now
|
|
55
|
+
return "show" if self.pinned else "hide"
|
|
56
|
+
|
|
57
|
+
def dismiss(self) -> None:
|
|
58
|
+
"""Clear the pinned state when the window is explicitly dismissed."""
|
|
59
|
+
self.pinned = False
|
|
60
|
+
self.press_count = 0
|
|
61
|
+
self.last_release_time = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class WindowsKeyPoller:
|
|
65
|
+
"""Poll the dedicated Windows Numpad Minus key without extra dependencies."""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
on_minus_down: Callable[[], None],
|
|
70
|
+
on_minus_up: Callable[[], None],
|
|
71
|
+
on_quit: Callable[[], None],
|
|
72
|
+
on_error: Callable[[Exception], None] | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
self.on_minus_down = on_minus_down
|
|
75
|
+
self.on_minus_up = on_minus_up
|
|
76
|
+
self.on_quit = on_quit
|
|
77
|
+
self.on_error = on_error or (lambda _error: None)
|
|
78
|
+
self._stop = threading.Event()
|
|
79
|
+
self._thread: threading.Thread | None = None
|
|
80
|
+
|
|
81
|
+
def start(self) -> None:
|
|
82
|
+
if sys.platform != "win32":
|
|
83
|
+
raise OSError("Global Numpad Minus activation is available on Windows.")
|
|
84
|
+
if self._thread and self._thread.is_alive():
|
|
85
|
+
return
|
|
86
|
+
self._stop.clear()
|
|
87
|
+
self._thread = threading.Thread(
|
|
88
|
+
target=self._poll,
|
|
89
|
+
name="bdalabb-numpad-minus",
|
|
90
|
+
daemon=True,
|
|
91
|
+
)
|
|
92
|
+
self._thread.start()
|
|
93
|
+
|
|
94
|
+
def stop(self) -> None:
|
|
95
|
+
self._stop.set()
|
|
96
|
+
if self._thread and self._thread.is_alive():
|
|
97
|
+
self._thread.join(timeout=1)
|
|
98
|
+
self._thread = None
|
|
99
|
+
|
|
100
|
+
def _poll(self) -> None:
|
|
101
|
+
try:
|
|
102
|
+
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
103
|
+
get_async_key_state = user32.GetAsyncKeyState
|
|
104
|
+
get_async_key_state.argtypes = (ctypes.c_int,)
|
|
105
|
+
get_async_key_state.restype = ctypes.c_short
|
|
106
|
+
|
|
107
|
+
minus_down = False
|
|
108
|
+
quit_chord_down = False
|
|
109
|
+
while not self._stop.is_set():
|
|
110
|
+
current_minus = bool(get_async_key_state(VK_SUBTRACT) & 0x8000)
|
|
111
|
+
if current_minus and not minus_down:
|
|
112
|
+
self.on_minus_down()
|
|
113
|
+
elif minus_down and not current_minus:
|
|
114
|
+
self.on_minus_up()
|
|
115
|
+
minus_down = current_minus
|
|
116
|
+
|
|
117
|
+
current_quit_chord = all(
|
|
118
|
+
get_async_key_state(key) & 0x8000
|
|
119
|
+
for key in (VK_CONTROL, VK_SHIFT, VK_Q)
|
|
120
|
+
)
|
|
121
|
+
if current_quit_chord and not quit_chord_down:
|
|
122
|
+
self.on_quit()
|
|
123
|
+
quit_chord_down = current_quit_chord
|
|
124
|
+
self._stop.wait(POLL_INTERVAL)
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
self.on_error(exc)
|
labprograms/preview.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Bounded, read-only previews for common text-based program files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
MAX_PREVIEW_BYTES = 512 * 1024
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class FilePreview:
|
|
14
|
+
text: str
|
|
15
|
+
is_text: bool
|
|
16
|
+
truncated: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_preview(path: Path, max_bytes: int = MAX_PREVIEW_BYTES) -> FilePreview:
|
|
20
|
+
with Path(path).open("rb") as file:
|
|
21
|
+
data = file.read(max_bytes + 1)
|
|
22
|
+
|
|
23
|
+
truncated = len(data) > max_bytes
|
|
24
|
+
data = data[:max_bytes]
|
|
25
|
+
if b"\x00" in data[:4096]:
|
|
26
|
+
return FilePreview("", is_text=False, truncated=truncated)
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
text = data.decode("utf-8-sig")
|
|
30
|
+
except UnicodeDecodeError:
|
|
31
|
+
return FilePreview("", is_text=False, truncated=truncated)
|
|
32
|
+
|
|
33
|
+
if text:
|
|
34
|
+
printable = sum(
|
|
35
|
+
character.isprintable() or character in "\t\r\n\f" for character in text
|
|
36
|
+
)
|
|
37
|
+
if printable / len(text) < 0.88:
|
|
38
|
+
return FilePreview("", is_text=False, truncated=truncated)
|
|
39
|
+
|
|
40
|
+
return FilePreview(text, is_text=True, truncated=truncated)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import java.io.IOException;
|
|
2
|
+
import java.util.*;
|
|
3
|
+
|
|
4
|
+
import org.apache.hadoop.fs.Path;
|
|
5
|
+
import org.apache.hadoop.conf.*;
|
|
6
|
+
import org.apache.hadoop.io.*;
|
|
7
|
+
import org.apache.hadoop.mapreduce.*;
|
|
8
|
+
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
|
|
9
|
+
import org.apache.hadoop.mapreduce.lib.input.TextInputFormat;
|
|
10
|
+
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
|
|
11
|
+
import org.apache.hadoop.mapreduce.lib.output.TextOutputFormat;
|
|
12
|
+
|
|
13
|
+
public class MatrixMulti {
|
|
14
|
+
|
|
15
|
+
public static class Map extends Mapper<LongWritable, Text, Text, Text> {
|
|
16
|
+
public void map(LongWritable key, Text value, Context context)
|
|
17
|
+
throws IOException, InterruptedException {
|
|
18
|
+
Configuration conf = context.getConfiguration();
|
|
19
|
+
int m = Integer.parseInt(conf.get("m"));
|
|
20
|
+
int p = Integer.parseInt(conf.get("p"));
|
|
21
|
+
String line = value.toString();
|
|
22
|
+
String[] indicesAndValue = line.split(",");
|
|
23
|
+
Text outputKey = new Text();
|
|
24
|
+
Text outputValue = new Text();
|
|
25
|
+
|
|
26
|
+
// implementation
|
|
27
|
+
|
|
28
|
+
if (indicesAndValue[0].equals("A")) {
|
|
29
|
+
for (int k = 0; k < p; k++) {
|
|
30
|
+
outputKey.set(indicesAndValue[1] + "," + k);
|
|
31
|
+
outputValue.set("A," + indicesAndValue[2] + ","
|
|
32
|
+
+ indicesAndValue[3]);
|
|
33
|
+
context.write(outputKey, outputValue);
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
for (int i = 0; i < m; i++) {
|
|
37
|
+
outputKey.set(i + "," + indicesAndValue[2]);
|
|
38
|
+
outputValue.set("B," + indicesAndValue[1] + ","
|
|
39
|
+
+ indicesAndValue[3]);
|
|
40
|
+
context.write(outputKey, outputValue);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public static class Reduce extends Reducer<Text, Text, Text, Text> {
|
|
47
|
+
public void reduce(Text key, Iterable<Text> values, Context context)
|
|
48
|
+
throws IOException, InterruptedException {
|
|
49
|
+
String[] value;
|
|
50
|
+
HashMap<Integer, Float> hashA = new HashMap<Integer, Float>();
|
|
51
|
+
HashMap<Integer, Float> hashB = new HashMap<Integer, Float>();
|
|
52
|
+
int n = Integer.parseInt(context.getConfiguration().get("n"));
|
|
53
|
+
float result = 0.0f;
|
|
54
|
+
float a_ij;
|
|
55
|
+
float b_jk;
|
|
56
|
+
|
|
57
|
+
//implementation
|
|
58
|
+
|
|
59
|
+
for (Text val : values) {
|
|
60
|
+
value = val.toString().split(",");
|
|
61
|
+
if (value[0].equals("A")) {
|
|
62
|
+
hashA.put(Integer.parseInt(value[1]),
|
|
63
|
+
Float.parseFloat(value[2]));
|
|
64
|
+
} else {
|
|
65
|
+
hashB.put(Integer.parseInt(value[1]),
|
|
66
|
+
Float.parseFloat(value[2]));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (int j = 0; j < n; j++) {
|
|
70
|
+
a_ij = hashA.containsKey(j) ? hashA.get(j) : 0.0f;
|
|
71
|
+
b_jk = hashB.containsKey(j) ? hashB.get(j) : 0.0f;
|
|
72
|
+
result += a_ij * b_jk;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (result != 0.0f) {
|
|
76
|
+
context.write(null,
|
|
77
|
+
new Text(key.toString() + "," + Float.toString(result)));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public static void main(String[] args) throws Exception {
|
|
83
|
+
if (args.length != 5) {
|
|
84
|
+
System.err.println("Use: MatrixMulti <input> <output> <m> <n> <p>");
|
|
85
|
+
System.exit(1);
|
|
86
|
+
}
|
|
87
|
+
Configuration conf = new Configuration();
|
|
88
|
+
|
|
89
|
+
// A is an m-by-n matrix; B is an n-by-p matrix.
|
|
90
|
+
conf.set("m", args[2]);
|
|
91
|
+
conf.set("n", args[3]);
|
|
92
|
+
conf.set("p", args[4]);
|
|
93
|
+
|
|
94
|
+
Job job = Job.getInstance(conf);
|
|
95
|
+
job.setJobName("MatrixMatrixMultiplicationOneStep");
|
|
96
|
+
job.setJarByClass(MatrixMulti.class);
|
|
97
|
+
job.setOutputKeyClass(Text.class);
|
|
98
|
+
job.setOutputValueClass(Text.class);
|
|
99
|
+
|
|
100
|
+
job.setMapperClass(Map.class);
|
|
101
|
+
job.setReducerClass(Reduce.class);
|
|
102
|
+
|
|
103
|
+
job.setInputFormatClass(TextInputFormat.class);
|
|
104
|
+
job.setOutputFormatClass(TextOutputFormat.class);
|
|
105
|
+
|
|
106
|
+
job.setNumReduceTasks(1);
|
|
107
|
+
|
|
108
|
+
FileInputFormat.addInputPath(job, new Path(args[0]));
|
|
109
|
+
FileOutputFormat.setOutputPath(job, new Path(args[1]));
|
|
110
|
+
|
|
111
|
+
job.waitForCompletion(true);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Run these commands from the folder containing this program's downloaded files:
|
|
2
|
+
|
|
3
|
+
hadoop fs -mkdir /matrix
|
|
4
|
+
hadoop fs -put input1.txt /matrix
|
|
5
|
+
hadoop fs -put input2.txt /matrix
|
|
6
|
+
|
|
7
|
+
hadoop com.sun.tools.javac.Main MatrixMulti.java
|
|
8
|
+
jar -cf mm.jar MatrixMulti*.class
|
|
9
|
+
hadoop jar mm.jar MatrixMulti /matrix /mmoutput 2 2 2
|
|
10
|
+
|
|
11
|
+
hadoop fs -ls /mmoutput
|
|
12
|
+
hadoop fs -cat /mmoutput/part*
|