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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0.post0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [gui_scripts]
2
+ bdalabb = labprograms.app:main
@@ -0,0 +1 @@
1
+ labprograms
@@ -0,0 +1,3 @@
1
+ """Lab Program Library desktop package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from .app import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
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*
@@ -0,0 +1,4 @@
1
+ A,0,0,1.0
2
+ A,0,1,2.0
3
+ A,1,0,3.0
4
+ A,1,1,4.0
@@ -0,0 +1,4 @@
1
+ B,0,0,5.0
2
+ B,0,1,6.0
3
+ B,1,0,7.0
4
+ B,1,1,8.0
@@ -0,0 +1,4 @@
1
+ {
2
+ "title": "Implementing Matrix Multiplication Using One MapReduce Step",
3
+ "primary_file": "MatrixMulti.java"
4
+ }