From 5d475d2e3d6a7d461ff1809c751be0c562332d2d Mon Sep 17 00:00:00 2001 From: nagisml Date: Tue, 29 Sep 2026 13:59:55 +0200 Subject: [PATCH 1/6] POC of lua macros for filters --- pyproject.toml | 2 + src/opensak/gui/dialogs/macro_dialog.py | 119 +++++++++ src/opensak/gui/mainwindow.py | 43 ++++ src/opensak/lang/cs.py | 8 + src/opensak/lang/da.py | 8 + src/opensak/lang/de.py | 8 + src/opensak/lang/de_CH.py | 8 + src/opensak/lang/en.py | 8 + src/opensak/lang/es.py | 8 + src/opensak/lang/fr.py | 8 + src/opensak/lang/nl.py | 8 + src/opensak/lang/pl.py | 8 + src/opensak/lang/pt.py | 8 + src/opensak/lang/se.py | 8 + src/opensak/macro/__init__.py | 11 + src/opensak/macro/runtime.py | 305 ++++++++++++++++++++++++ tests/e2e-tests/test_e2e_macro.py | 32 +++ tests/unit-tests/test_macro_runtime.py | 164 +++++++++++++ 18 files changed, 764 insertions(+) create mode 100644 src/opensak/gui/dialogs/macro_dialog.py create mode 100644 src/opensak/macro/__init__.py create mode 100644 src/opensak/macro/runtime.py create mode 100644 tests/e2e-tests/test_e2e_macro.py create mode 100644 tests/unit-tests/test_macro_runtime.py diff --git a/pyproject.toml b/pyproject.toml index 8c261a43..6c03c729 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,8 @@ dependencies = [ "shapely>=2.0,<3", "pywin32>=306; platform_system == 'Windows'", "keyring>=25.0,<26", + # Lua macros (proof of concept, roadmap item 2) + "lupa>=2.0,<3", ] [project.optional-dependencies] diff --git a/src/opensak/gui/dialogs/macro_dialog.py b/src/opensak/gui/dialogs/macro_dialog.py new file mode 100644 index 00000000..4498f817 --- /dev/null +++ b/src/opensak/gui/dialogs/macro_dialog.py @@ -0,0 +1,119 @@ +""" +src/opensak/gui/dialogs/macro_dialog.py — Lua macro editor/runner (proof of concept). + +Non-modal, so the cache list behind it can be watched while a macro changes +the filter. The script runs synchronously on the GUI thread; the runtime's +instruction limit guards against endless loops. +""" + +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtGui import QFontDatabase +from PySide6.QtWidgets import ( + QDialog, QFileDialog, QHBoxLayout, QPlainTextEdit, QPushButton, + QSplitter, QVBoxLayout, +) + +from opensak.gui.dialogs.widgets import clamp_dialog_height_to_screen +from opensak.lang import tr +from opensak.macro import MacroError, MacroHost, MacroRuntime + +EXAMPLE_MACRO = """\ +-- OpenSAK macro (Lua) — proof of concept +-- opensak.filter{...}, opensak.filter_profile(name), opensak.clear_filter(), +-- opensak.count(), opensak.profiles(), print(...) + +local n = opensak.filter{ + type = {"Traditional", "Multi-cache"}, + difficulty = {1, 2.5}, + found = false, + label = "Easy unfound", +} + +if n == 0 then + print("No caches match — filter not applied") +else + print("Selected " .. n .. " caches") +end +""" + + +class MacroDialog(QDialog): + """Small editor with Open… / Run and an output pane.""" + + def __init__(self, host: MacroHost, parent=None): + super().__init__(parent) + self.setWindowTitle(tr("macro_title")) + self.resize(640, 520) + clamp_dialog_height_to_screen(self, parent) + # Non-modal editor window: give it an explicit close (and min/max) + # button — the inherited dialog flags left Windows without a working ✕. + self.setWindowFlags( + Qt.WindowType.Window + | Qt.WindowType.WindowTitleHint + | Qt.WindowType.WindowSystemMenuHint + | Qt.WindowType.WindowMinMaxButtonsHint + | Qt.WindowType.WindowCloseButtonHint + ) + self._runtime = MacroRuntime(host, output=self._append_output) + self._chunk_name = "macro" + self._setup_ui() + + def _setup_ui(self) -> None: + layout = QVBoxLayout(self) + + mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) + + self._editor = QPlainTextEdit() + self._editor.setFont(mono) + self._editor.setPlainText(EXAMPLE_MACRO) + + self._output = QPlainTextEdit() + self._output.setFont(mono) + self._output.setReadOnly(True) + + splitter = QSplitter(Qt.Orientation.Vertical) + splitter.addWidget(self._editor) + splitter.addWidget(self._output) + splitter.setSizes([380, 140]) + layout.addWidget(splitter) + + buttons = QHBoxLayout() + btn_open = QPushButton(tr("macro_btn_open")) + btn_open.clicked.connect(self._open_file) + buttons.addWidget(btn_open) + buttons.addStretch() + self._btn_run = QPushButton(tr("macro_btn_run")) + self._btn_run.setDefault(True) + self._btn_run.clicked.connect(self._run) + buttons.addWidget(self._btn_run) + btn_close = QPushButton(tr("close")) + btn_close.clicked.connect(self.close) + buttons.addWidget(btn_close) + layout.addLayout(buttons) + + def _open_file(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, tr("macro_open_title"), "", "Lua (*.lua);;* (*)" + ) + if not path: + return + self._editor.setPlainText(Path(path).read_text(encoding="utf-8")) + self._chunk_name = Path(path).name + + def _append_output(self, text: str) -> None: + self._output.appendPlainText(text) + + def _run(self) -> None: + self._output.clear() + self._btn_run.setEnabled(False) + try: + self._runtime.run(self._editor.toPlainText(), chunk_name=self._chunk_name) + self._append_output(tr("macro_done")) + except MacroError as exc: + self._append_output(tr("macro_error", msg=str(exc))) + finally: + self._btn_run.setEnabled(True) diff --git a/src/opensak/gui/mainwindow.py b/src/opensak/gui/mainwindow.py index fbd784af..fec7c924 100644 --- a/src/opensak/gui/mainwindow.py +++ b/src/opensak/gui/mainwindow.py @@ -507,6 +507,12 @@ def _setup_menu(self) -> None: act_found_update.triggered.connect(self._open_found_updater) tools_menu.addAction(act_found_update) + tools_menu.addSeparator() + + act_run_macro = QAction(tr("action_run_macro"), self) + act_run_macro.triggered.connect(self._open_macro_dialog) + tools_menu.addAction(act_run_macro) + # ── GPS ─────────────────────────────────────────────────────────────── gps_menu = menubar.addMenu("&GPS") @@ -2600,6 +2606,13 @@ def _on_filter_applied(self, filterset, sort, profile_name: str) -> None: QTimer.singleShot(0, lambda: self._show_filter_dialog(filterset, profile_name)) return + self._show_filter_result(filterset, profile_name, caches) + + def _show_filter_result(self, filterset, profile_name: str, caches: list) -> None: + """Make *filterset* the active filter and show its (non-empty) *caches*. + + Shared by the "Set filter" dialog and Lua macros (opensak.filter). + """ self._current_filterset = filterset self._active_filter_name = profile_name self._save_sort_for_active_db() @@ -3023,6 +3036,36 @@ def _open_trip_planner(self) -> None: def _on_trip_planner_destroyed(self) -> None: self._trip_planner_win = None + # ── Lua macros (proof of concept) — MacroHost for opensak.macro ───────── + + def _open_macro_dialog(self) -> None: + from opensak.gui.dialogs.macro_dialog import MacroDialog + if getattr(self, "_macro_dialog", None) is None: + self._macro_dialog = MacroDialog(host=self, parent=self) + self._macro_dialog.show() + self._macro_dialog.raise_() + self._macro_dialog.activateWindow() + + def apply_filter(self, filterset, label: str) -> int: + """MacroHost: apply *filterset*; like GSAK's MFILTER, an empty result + leaves the current view untouched and returns 0.""" + with get_session() as session: + caches = apply_filters_auto( + session, filterset, self._current_sort, + columns=self._visible_table_columns(), + ) + if caches: + self._show_filter_result(filterset, label, caches) + return len(caches) + + def clear_filter(self) -> None: + """MacroHost: remove the active filter (the list reloads asynchronously).""" + self._clear_filter() + + def cache_count(self) -> int: + """MacroHost: number of caches currently in the list.""" + return self._cache_table.row_count() + def _open_found_updater(self) -> None: if self._trip_planner_active(): self._warn_trip_planner_active() diff --git a/src/opensak/lang/cs.py b/src/opensak/lang/cs.py index 293ae777..e4441181 100644 --- a/src/opensak/lang/cs.py +++ b/src/opensak/lang/cs.py @@ -86,6 +86,14 @@ # Tools menu "action_settings": "&Nastavení…", "action_found_update": "⟳ Aktualizovat nálezy z referenční databáze…", + # Lua macros (proof of concept) + "action_run_macro": "Spustit makro (Lua)…", + "macro_title": "Makro (Lua) — ověření konceptu", + "macro_btn_open": "Otevřít…", + "macro_btn_run": "▶ Spustit", + "macro_open_title": "Otevřít makro Lua", + "macro_done": "Makro dokončeno.", + "macro_error": "Chyba makra: {msg}", "action_update_location": "Update waypoint locations…", "action_download_boundaries": "Download boundary packs…", "action_check_boundaries": "Check for boundary data updates…", diff --git a/src/opensak/lang/da.py b/src/opensak/lang/da.py index 8f46d787..a234e736 100644 --- a/src/opensak/lang/da.py +++ b/src/opensak/lang/da.py @@ -86,6 +86,14 @@ # Funktioner-menu "action_settings": "&Indstillinger…", "action_found_update": "⟳ Opdater fund fra reference database…", + # Lua macros (proof of concept) + "action_run_macro": "Kør makro (Lua)…", + "macro_title": "Makro (Lua) — proof of concept", + "macro_btn_open": "Åbn…", + "macro_btn_run": "▶ Kør", + "macro_open_title": "Åbn Lua-makro", + "macro_done": "Makro færdig.", + "macro_error": "Makrofejl: {msg}", "action_update_location": "Update waypoint locations…", "action_download_boundaries": "Download grænsefladedata…", "action_check_boundaries": "Søg efter opdateringer til grænsefladedata…", diff --git a/src/opensak/lang/de.py b/src/opensak/lang/de.py index 275fca6c..1c551974 100644 --- a/src/opensak/lang/de.py +++ b/src/opensak/lang/de.py @@ -86,6 +86,14 @@ # Tools menu "action_settings": "&Einstellungen…", "action_found_update": "⟳ Funde aus Referenz-Datenbank aktualisieren", + # Lua macros (proof of concept) + "action_run_macro": "Makro ausführen (Lua)…", + "macro_title": "Makro (Lua) — Proof of Concept", + "macro_btn_open": "Öffnen…", + "macro_btn_run": "▶ Ausführen", + "macro_open_title": "Lua-Makro öffnen", + "macro_done": "Makro beendet.", + "macro_error": "Makrofehler: {msg}", "action_update_location": "Standortdaten der Wegpunkte aktualisieren…", "action_download_boundaries": "Grenzpolygon-Pakete herunterladen…", "action_check_boundaries": "Nach Grenzpolygon-Aktualisierungen suchen…", diff --git a/src/opensak/lang/de_CH.py b/src/opensak/lang/de_CH.py index 206bd854..9507afa8 100644 --- a/src/opensak/lang/de_CH.py +++ b/src/opensak/lang/de_CH.py @@ -87,6 +87,14 @@ # Tools menu "action_settings": "&Einstellungen…", "action_found_update": "⟳ Funde aus Referenz-Datenbank aktualisieren", + # Lua macros (proof of concept) + "action_run_macro": "Makro ausführen (Lua)…", + "macro_title": "Makro (Lua) — Proof of Concept", + "macro_btn_open": "Öffnen…", + "macro_btn_run": "▶ Ausführen", + "macro_open_title": "Lua-Makro öffnen", + "macro_done": "Makro beendet.", + "macro_error": "Makrofehler: {msg}", "action_update_location": "Standortdaten der Wegpunkte aktualisieren…", "action_download_boundaries": "Grenzpolygon-Pakete herunterladen…", "action_check_boundaries": "Nach Grenzpolygon-Aktualisierungen suchen…", diff --git a/src/opensak/lang/en.py b/src/opensak/lang/en.py index 0c49d687..7b49cac5 100644 --- a/src/opensak/lang/en.py +++ b/src/opensak/lang/en.py @@ -86,6 +86,14 @@ # Tools menu "action_settings": "&Settings…", "action_found_update": "⟳ Update finds from reference database…", + # Lua macros (proof of concept) + "action_run_macro": "Run macro (Lua)…", + "macro_title": "Macro (Lua) — proof of concept", + "macro_btn_open": "Open…", + "macro_btn_run": "▶ Run", + "macro_open_title": "Open Lua macro", + "macro_done": "Macro finished.", + "macro_error": "Macro error: {msg}", "action_update_location": "🌍 Update waypoint locations…", "action_download_boundaries": "Download boundary packs…", "action_check_boundaries": "Check for boundary data updates…", diff --git a/src/opensak/lang/es.py b/src/opensak/lang/es.py index c4064cef..3a24024c 100644 --- a/src/opensak/lang/es.py +++ b/src/opensak/lang/es.py @@ -88,6 +88,14 @@ # Tools menu "action_settings": "&Ajustes…", "action_found_update": "⟳ Actualizar encontradas desde base de referencia…", + # Lua macros (proof of concept) + "action_run_macro": "Ejecutar macro (Lua)…", + "macro_title": "Macro (Lua) — prueba de concepto", + "macro_btn_open": "Abrir…", + "macro_btn_run": "▶ Ejecutar", + "macro_open_title": "Abrir macro Lua", + "macro_done": "Macro finalizada.", + "macro_error": "Error de macro: {msg}", "action_update_location": "🌍 Actualizar ubicaciones de waypoints…", "action_download_boundaries": "Descargar paquetes de límites…", "action_check_boundaries": "Buscar actualizaciones de datos de límites…", diff --git a/src/opensak/lang/fr.py b/src/opensak/lang/fr.py index 1a0f3da3..010810b3 100644 --- a/src/opensak/lang/fr.py +++ b/src/opensak/lang/fr.py @@ -86,6 +86,14 @@ # Tools menu "action_settings": "&Paramètres…", "action_found_update": "⟳ Mettre à jour les caches trouvées depuis la base de données de référence…", + # Lua macros (proof of concept) + "action_run_macro": "Exécuter une macro (Lua)…", + "macro_title": "Macro (Lua) — preuve de concept", + "macro_btn_open": "Ouvrir…", + "macro_btn_run": "▶ Exécuter", + "macro_open_title": "Ouvrir une macro Lua", + "macro_done": "Macro terminée.", + "macro_error": "Erreur de macro : {msg}", "action_update_location": "🌍 Mettre à jour les données de localisation des waypoints…", "action_download_boundaries": "Télécharger les packs de frontières…", "action_check_boundaries": "Rechercher des mises à jour des données de frontières…", diff --git a/src/opensak/lang/nl.py b/src/opensak/lang/nl.py index de7d3ae2..ab972d9c 100644 --- a/src/opensak/lang/nl.py +++ b/src/opensak/lang/nl.py @@ -89,6 +89,14 @@ # Tools menu "action_settings": "&Instellingen…", "action_found_update": "⟳ Vondsten bijwerken vanuit referentiedatabase…", + # Lua macros (proof of concept) + "action_run_macro": "Macro uitvoeren (Lua)…", + "macro_title": "Macro (Lua) — proof of concept", + "macro_btn_open": "Openen…", + "macro_btn_run": "▶ Uitvoeren", + "macro_open_title": "Lua-macro openen", + "macro_done": "Macro voltooid.", + "macro_error": "Macrofout: {msg}", "action_update_location": "🌍 Waypointlocaties bijwerken…", "action_download_boundaries": "Grenspakketten downloaden…", "action_check_boundaries": "Zoeken naar updates voor grensgegevens…", diff --git a/src/opensak/lang/pl.py b/src/opensak/lang/pl.py index 1d09834b..232ce24f 100644 --- a/src/opensak/lang/pl.py +++ b/src/opensak/lang/pl.py @@ -88,6 +88,14 @@ # Tools menu "action_settings": "&Ustawienia…", "action_found_update": "⟳ Zaktualizuj znalezione z bazy referencyjnej…", + # Lua macros (proof of concept) + "action_run_macro": "Uruchom makro (Lua)…", + "macro_title": "Makro (Lua) — weryfikacja koncepcji", + "macro_btn_open": "Otwórz…", + "macro_btn_run": "▶ Uruchom", + "macro_open_title": "Otwórz makro Lua", + "macro_done": "Makro zakończone.", + "macro_error": "Błąd makra: {msg}", "action_update_location": "🌍 Zaktualizuj lokalizacje waypointów…", "action_download_boundaries": "Pobierz pakiety granic…", "action_check_boundaries": "Sprawdź aktualizacje danych granicznych…", diff --git a/src/opensak/lang/pt.py b/src/opensak/lang/pt.py index 8c274916..fa97cfec 100644 --- a/src/opensak/lang/pt.py +++ b/src/opensak/lang/pt.py @@ -86,6 +86,14 @@ # Tools menu "action_settings": "&Definições…", "action_found_update": "⟳ Atualizar caches encontradas da base de dados de referência…", + # Lua macros (proof of concept) + "action_run_macro": "Executar macro (Lua)…", + "macro_title": "Macro (Lua) — prova de conceito", + "macro_btn_open": "Abrir…", + "macro_btn_run": "▶ Executar", + "macro_open_title": "Abrir macro Lua", + "macro_done": "Macro concluída.", + "macro_error": "Erro na macro: {msg}", "action_update_location": "Update waypoint locations…", "action_download_boundaries": "Transferir pacotes de fronteiras…", "action_check_boundaries": "Verificar atualizações de dados de fronteiras…", diff --git a/src/opensak/lang/se.py b/src/opensak/lang/se.py index 6111e1c6..fc64faa0 100644 --- a/src/opensak/lang/se.py +++ b/src/opensak/lang/se.py @@ -86,6 +86,14 @@ # Tools menu "action_settings": "&Inställningar…", "action_found_update": "⟳ Uppdatera hittade från referensdatabasen…", + # Lua macros (proof of concept) + "action_run_macro": "Kör makro (Lua)…", + "macro_title": "Makro (Lua) — konceptbevis", + "macro_btn_open": "Öppna…", + "macro_btn_run": "▶ Kör", + "macro_open_title": "Öppna Lua-makro", + "macro_done": "Makrot är klart.", + "macro_error": "Makrofel: {msg}", "action_update_location": "Update waypoint locations…", "action_download_boundaries": "Ladda ner gränspaket…", "action_check_boundaries": "Sök efter uppdateringar av gränsdata…", diff --git a/src/opensak/macro/__init__.py b/src/opensak/macro/__init__.py new file mode 100644 index 00000000..4aed9d79 --- /dev/null +++ b/src/opensak/macro/__init__.py @@ -0,0 +1,11 @@ +""" +src/opensak/macro — Proof of concept: Lua macros for OpenSAK (roadmap item 2). + +A macro is a small Lua script run in a sandboxed Lua interpreter (via lupa). +It talks to OpenSAK through the global `opensak` table; see runtime.py for +the available functions. +""" + +from opensak.macro.runtime import MacroError, MacroHost, MacroRuntime, build_filterset + +__all__ = ["MacroError", "MacroHost", "MacroRuntime", "build_filterset"] diff --git a/src/opensak/macro/runtime.py b/src/opensak/macro/runtime.py new file mode 100644 index 00000000..bc8004f6 --- /dev/null +++ b/src/opensak/macro/runtime.py @@ -0,0 +1,305 @@ +""" +src/opensak/macro/runtime.py — Lua macro runtime (proof of concept). + +Runs a user's Lua script in a sandboxed interpreter and exposes a small +`opensak` API table to it. The runtime is kept free of Qt: everything that +touches the main window goes through a MacroHost, so the runtime can be +unit-tested with a fake host. + +Lua API (POC): + + opensak.filter{ ... } -- build a filter and apply it; returns + -- the number of matching caches + -- (0 = nothing matched, view unchanged) + opensak.filter_profile("Name") -- apply a saved filter profile; returns count + opensak.clear_filter() -- show all caches again + opensak.count() -- caches currently shown in the list + opensak.profiles() -- list of saved filter profile names + print(...) -- write to the macro output pane + +Keys understood by opensak.filter{} (all combined with AND): + + type = "Traditional" | {"Traditional", "Multi-cache", ...} + container = "Small" | {"Micro", "Small", ...} + difficulty = 2 | {1, 2.5} -- exact value or {min, max} + terrain = 2 | {1, 2.5} + found = true | false + available = true -- only available (not disabled/archived) + name, code, owner, country, state, county = "text" -- "contains" match + where = "SQL WHERE clause" -- raw clause against the caches table + label = "Shown in the toolbar" (optional) + +Example: + + local n = opensak.filter{ type = "Traditional", difficulty = {1, 2}, found = false } + print("Easy unfound traditionals: " .. n) +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Callable, Optional, Protocol + +from opensak.filters.engine import ( + AvailableFilter, + CacheTypeFilter, + ContainerFilter, + CountryFilter, + CountyFilter, + DifficultyFilter, + FilterProfile, + FilterSet, + FoundFilter, + GcCodeFilter, + NameFilter, + NotFoundFilter, + OwnerFilter, + StateFilter, + TerrainFilter, + WhereClauseFilter, +) +from opensak.utils.constants import CACHE_TYPES + +# A runaway `while true do end` would freeze the GUI thread, so the script is +# aborted after this many Lua VM instructions. +DEFAULT_INSTRUCTION_LIMIT = 50_000_000 + +_TEXT_FILTERS = { + "name": NameFilter, + "code": GcCodeFilter, + "owner": OwnerFilter, + "country": CountryFilter, + "state": StateFilter, + "county": CountyFilter, +} +FILTER_KEYS = sorted( + {"type", "container", "difficulty", "terrain", "found", "available", "where", "label"} + | set(_TEXT_FILTERS) +) + +# Globals removed from the sandbox: file/process access, loading other code, +# and the lupa bridge back into Python. +_SANDBOX_SETUP = """ +local limit = ... +debug.sethook(function() error("macro aborted: instruction limit reached", 2) end, "", limit) +local os_time, os_date, os_clock = os.time, os.date, os.clock +os = { time = os_time, date = os_date, clock = os_clock } +io, debug, package, require, dofile, loadfile, load, collectgarbage, python = nil +""" + + +class MacroError(Exception): + """A macro failed — Lua syntax/runtime error or a bad API call.""" + + +class MacroHost(Protocol): + """What a macro may do to the running application.""" + + def apply_filter(self, filterset: FilterSet, label: str) -> int: + """Apply *filterset* to the cache list; return the match count. + + When nothing matches, the current view must be left unchanged and 0 + returned (same as GSAK's MFILTER / $_FilterCount behaviour). + """ + + def clear_filter(self) -> None: + """Remove the active filter.""" + + def cache_count(self) -> int: + """Number of caches currently shown.""" + + +# ── Lua table → FilterSet ──────────────────────────────────────────────────── + +def _as_list(value: Any) -> list: + """A Lua value that may be a scalar or an array table → Python list.""" + if isinstance(value, (list, tuple)): + return list(value) + if hasattr(value, "values"): # lupa LuaTable + return list(value.values()) + return [value] + + +def _resolve_cache_type(name: str) -> str: + """Accept both "Traditional Cache" and the short "Traditional".""" + wanted = str(name).strip().lower() + for full in CACHE_TYPES: + if full.lower() in (wanted, f"{wanted} cache"): + return full + raise MacroError(f"unknown cache type {name!r}") + + +def _range(key: str, value: Any) -> tuple[float, float]: + items = _as_list(value) + try: + if len(items) == 1: + return float(items[0]), float(items[0]) + if len(items) == 2: + return float(items[0]), float(items[1]) + except (TypeError, ValueError): + pass + raise MacroError(f"{key} must be a number or {{min, max}}, got {value!r}") + + +def build_filterset(spec: dict) -> tuple[FilterSet, str]: + """Translate the table passed to opensak.filter{} into a FilterSet. + + Returns (filterset, label). Raises MacroError for unknown keys or values. + """ + unknown = set(spec) - set(FILTER_KEYS) + if unknown: + raise MacroError( + f"unknown filter key(s) {sorted(unknown)}; valid keys: {', '.join(FILTER_KEYS)}" + ) + + fs = FilterSet(mode="AND") + if "type" in spec: + fs.add(CacheTypeFilter([_resolve_cache_type(t) for t in _as_list(spec["type"])])) + if "container" in spec: + fs.add(ContainerFilter([str(c) for c in _as_list(spec["container"])])) + if "difficulty" in spec: + fs.add(DifficultyFilter(*_range("difficulty", spec["difficulty"]))) + if "terrain" in spec: + fs.add(TerrainFilter(*_range("terrain", spec["terrain"]))) + if "found" in spec: + fs.add(FoundFilter() if spec["found"] else NotFoundFilter()) + if spec.get("available"): + fs.add(AvailableFilter()) + for key, cls in _TEXT_FILTERS.items(): + if key in spec: + fs.add(cls(str(spec[key]))) + if "where" in spec: + fs.add(WhereClauseFilter(str(spec["where"]))) + + if len(fs) == 0: + raise MacroError("opensak.filter{} needs at least one criterion") + return fs, str(spec.get("label") or "Macro") + + +# ── Runtime ────────────────────────────────────────────────────────────────── + +class MacroRuntime: + """Run Lua macros against a MacroHost. + + A fresh Lua state is created for every run(), so macros cannot leak + state into each other. + """ + + def __init__( + self, + host: MacroHost, + output: Optional[Callable[[str], None]] = None, + profiles_dir: Optional[Path] = None, + instruction_limit: int = DEFAULT_INSTRUCTION_LIMIT, + ): + self._host = host + self._output = output or print + self._profiles_dir = profiles_dir + self._instruction_limit = instruction_limit + + # -- API functions exposed to Lua ----------------------------------------- + + def _filter(self, spec=None) -> int: + if spec is None or not hasattr(spec, "items"): + raise MacroError("opensak.filter expects a table, e.g. opensak.filter{ found = false }") + fs, label = build_filterset(dict(spec.items())) + return self._host.apply_filter(fs, label) + + def _load_profile(self, name: str) -> FilterProfile: + for path in FilterProfile.list_profiles(self._profiles_dir): + try: + profile = FilterProfile.load(path) + except Exception: + continue + if profile.name == name: + return profile + raise MacroError(f"no saved filter profile named {name!r}") + + def _filter_profile(self, name=None) -> int: + if not isinstance(name, str): + raise MacroError("opensak.filter_profile expects the profile name") + profile = self._load_profile(name) + return self._host.apply_filter(profile.filterset, profile.name) + + def _profile_names(self) -> list[str]: + names = [] + for path in FilterProfile.list_profiles(self._profiles_dir): + try: + names.append(FilterProfile.load(path).name) + except Exception: + continue + return names + + # -- Running --------------------------------------------------------------- + + def run(self, source: str, chunk_name: str = "macro") -> None: + """Execute *source*. Raises MacroError on any failure.""" + try: + from lupa.lua54 import LuaError, LuaRuntime + except ImportError as exc: + raise MacroError( + "Lua support is not installed — run: pip install \"lupa>=2.0,<3\"" + ) from exc + + lua = LuaRuntime( + register_eval=False, + register_builtins=False, + unpack_returned_tuples=True, + # No attribute access on Python objects from Lua at all: the + # script only gets the plain functions in the opensak table. + attribute_filter=self._deny_attribute, + ) + lua.execute(_SANDBOX_SETUP, self._instruction_limit) + + g = lua.globals() + g.print = self._lua_print + g.opensak = lua.table_from({ + "filter": self._wrap(self._filter), + "filter_profile": self._wrap(self._filter_profile), + "clear_filter": self._wrap(self._host.clear_filter), + "count": self._wrap(self._host.cache_count), + "profiles": self._wrap(lambda: lua.table_from(self._profile_names())), + }) + + try: + fn = lua.compile(source, name=f"={chunk_name}") + fn() + except MacroError: + raise + except LuaError as exc: + raise MacroError(str(exc)) from exc + except Exception as exc: + # A Python exception raised inside a callback (e.g. the + # attribute_filter) propagates as itself, not as a LuaError. + raise MacroError(f"{type(exc).__name__}: {exc}") from exc + + @staticmethod + def _deny_attribute(obj, attr_name, is_setting): + raise AttributeError("access to Python objects is not allowed in macros") + + @staticmethod + def _wrap(func: Callable) -> Callable: + """Turn MacroError into a clean Lua error message (no Python traceback).""" + from lupa.lua54 import LuaError + + def call(*args): + try: + return func(*args) + except MacroError as exc: + raise LuaError(str(exc)) from None + return call + + def _lua_print(self, *args) -> None: + self._output("\t".join(_lua_tostring(a) for a in args)) + + +def _lua_tostring(value: Any) -> str: + if value is None: + return "nil" + if value is True: + return "true" + if value is False: + return "false" + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value) diff --git a/tests/e2e-tests/test_e2e_macro.py b/tests/e2e-tests/test_e2e_macro.py new file mode 100644 index 00000000..ab108923 --- /dev/null +++ b/tests/e2e-tests/test_e2e_macro.py @@ -0,0 +1,32 @@ +"""tests/e2e-tests/test_e2e_macro.py — Lua macro dialog drives the real main window.""" + + +def _run_macro(window, source: str) -> str: + window._open_macro_dialog() + dlg = window._macro_dialog + dlg._editor.setPlainText(source) + dlg._btn_run.click() + return dlg._output.toPlainText() + + +def test_macro_filter_selects_caches(seeded_window): + out = _run_macro(seeded_window, """ + local n = opensak.filter{ code = "GCAAA0", label = "From macro" } + print("n=" .. n .. " shown=" .. opensak.count()) + """) + assert "n=2 shown=2" in out + assert seeded_window._cache_table.row_count() == 2 + assert seeded_window._active_filter_name == "From macro" + assert "From macro" in seeded_window._filter_lbl.text() + + +def test_macro_filter_without_match_keeps_view(seeded_window): + before = seeded_window._cache_table.row_count() + out = _run_macro(seeded_window, 'print(opensak.filter{ name = "no such cache" })') + assert out.splitlines()[0] == "0" + assert seeded_window._cache_table.row_count() == before + + +def test_macro_error_is_shown_in_output(seeded_window): + out = _run_macro(seeded_window, "opensak.filter{ bogus = 1 }") + assert "Macro error" in out and "unknown filter key" in out diff --git a/tests/unit-tests/test_macro_runtime.py b/tests/unit-tests/test_macro_runtime.py new file mode 100644 index 00000000..9846e86a --- /dev/null +++ b/tests/unit-tests/test_macro_runtime.py @@ -0,0 +1,164 @@ +"""tests/unit-tests/test_macro_runtime.py — Lua macro runtime (proof of concept). + +The runtime is Qt-free: a fake MacroHost stands in for the main window. The +DB-backed host applies the FilterSet the macro built against a real test +database, so "a Lua script selects caches" is covered end to end. +""" + +import pytest + +from opensak.db.database import get_session +from opensak.db.models import Cache +from opensak.filters.engine import ( + CacheTypeFilter, DifficultyFilter, FilterProfile, FilterSet, NotFoundFilter, + apply_filters_auto, +) +from opensak.macro import MacroError, MacroRuntime, build_filterset + + +class FakeHost: + def __init__(self, count: int = 3): + self.applied: list[tuple[FilterSet, str]] = [] + self.cleared = 0 + self._count = count + + def apply_filter(self, filterset, label): + self.applied.append((filterset, label)) + return self._count + + def clear_filter(self): + self.cleared += 1 + + def cache_count(self): + return 99 + + +class DbHost(FakeHost): + """Applies the filter against the test DB and remembers the selection.""" + + def __init__(self): + super().__init__() + self.selected: set[str] = set() + + def apply_filter(self, filterset, label): + with get_session() as s: + codes = {c.gc_code for c in apply_filters_auto(s, filterset)} + if codes: + self.selected = codes + return len(codes) + + +def _run(source, host=None, **kwargs): + out: list[str] = [] + host = host or FakeHost() + MacroRuntime(host, output=out.append, **kwargs).run(source) + return host, out + + +# ── Lua table → FilterSet ──────────────────────────────────────────────────── + +def test_build_filterset_maps_keys(): + fs, label = build_filterset({ + "type": "Traditional", "difficulty": 2, "found": False, "label": "X", + }) + assert label == "X" + kinds = [type(f) for f in fs._filters] + assert kinds == [CacheTypeFilter, DifficultyFilter, NotFoundFilter] + assert fs._filters[0].types == ["Traditional Cache"] + assert (fs._filters[1].min_difficulty, fs._filters[1].max_difficulty) == (2.0, 2.0) + + +@pytest.mark.parametrize("spec,msg", [ + ({"foo": 1}, "unknown filter key"), + ({"type": "Bogus"}, "unknown cache type"), + ({"difficulty": "hard"}, "difficulty must be"), + ({"label": "only a label"}, "at least one criterion"), +]) +def test_build_filterset_rejects_bad_input(spec, msg): + with pytest.raises(MacroError, match=msg): + build_filterset(spec) + + +# ── API ────────────────────────────────────────────────────────────────────── + +def test_filter_call_reaches_host_and_returns_count(): + host, out = _run(""" + local n = opensak.filter{ type = {"Traditional", "Multi-cache"}, terrain = {1, 3} } + print("n", n, opensak.count()) + opensak.clear_filter() + """) + assert out == ["n\t3\t99"] + assert host.cleared == 1 + fs, label = host.applied[0] + assert label == "Macro" + assert fs._filters[0].types == ["Traditional Cache", "Multi-cache"] + + +def test_filter_profile(tmp_path): + FilterProfile("Easy", FilterSet().add(DifficultyFilter(1, 2))).save(tmp_path) + host, out = _run( + 'print(#opensak.profiles()) opensak.filter_profile("Easy")', + profiles_dir=tmp_path, + ) + assert out == ["1"] + assert host.applied[0][1] == "Easy" + with pytest.raises(MacroError, match="no saved filter profile"): + _run('opensak.filter_profile("Missing")', profiles_dir=tmp_path) + + +# ── Sandbox ────────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("source", [ + 'io.open("x")', + 'os.execute("echo hi")', + 'require("os")', + 'load("return 1")()', + 'python.eval("1")', + 'local f = opensak.filter; print(f.__globals__)', +]) +def test_sandbox_blocks_escape_hatches(source): + with pytest.raises(MacroError): + _run(source) + + +def test_endless_loop_is_aborted(): + with pytest.raises(MacroError, match="instruction limit"): + _run("while true do end", instruction_limit=100_000) + + +def test_syntax_error_is_reported(): + with pytest.raises(MacroError, match="macro:1"): + _run("this is not lua") + + +# ── End to end against a real database ─────────────────────────────────────── + +@pytest.fixture(scope="module", autouse=True) +def seed(tmp_db): + with get_session() as s: + for code, ctype, diff, found in [ + ("GCMAC1", "Traditional Cache", 1.5, False), + ("GCMAC2", "Traditional Cache", 4.0, False), + ("GCMAC3", "Multi-cache", 1.0, False), + ("GCMAC4", "Traditional Cache", 1.0, True), + ]: + s.add(Cache(gc_code=code, name=code, cache_type=ctype, difficulty=diff, + terrain=1.0, found=found, latitude=47.0, longitude=8.0)) + + +def test_macro_selects_caches_in_db(): + host, out = _run(""" + local n = opensak.filter{ type = "Traditional", difficulty = {1, 2}, found = false } + print(n) + """, host=DbHost()) + assert out == ["1"] + assert host.selected == {"GCMAC1"} + + +def test_empty_result_keeps_previous_selection(): + host = DbHost() + _run(""" + opensak.filter{ type = "Multi-cache" } + assert(opensak.filter{ name = "does-not-exist" } == 0) + """, host=host) + assert host.selected == {"GCMAC3"} From 046530988ceabe03c37ff762773bb92a332940ca Mon Sep 17 00:00:00 2001 From: nagisml Date: Tue, 29 Sep 2026 16:08:57 +0200 Subject: [PATCH 2/6] secure lua env by pcall, coroutines limites and mem_limit --- opensak.spec | 1 + src/opensak/macro/runtime.py | 69 ++++++++++++++++++++++++-- tests/unit-tests/test_macro_runtime.py | 45 +++++++++++++++++ 3 files changed, 111 insertions(+), 4 deletions(-) diff --git a/opensak.spec b/opensak.spec index fc6d1990..df9c77a7 100644 --- a/opensak.spec +++ b/opensak.spec @@ -91,6 +91,7 @@ a = Analysis( "certifi", "shapely", "shapely.geometry", + "lupa.lua54", ] + (["win32com.client", "pythoncom"] if sys.platform == "win32" else []), hookspath=[], hooksconfig={}, diff --git a/src/opensak/macro/runtime.py b/src/opensak/macro/runtime.py index bc8004f6..75475b27 100644 --- a/src/opensak/macro/runtime.py +++ b/src/opensak/macro/runtime.py @@ -63,6 +63,8 @@ # A runaway `while true do end` would freeze the GUI thread, so the script is # aborted after this many Lua VM instructions. DEFAULT_INSTRUCTION_LIMIT = 50_000_000 +# Upper bound for the Lua heap, so e.g. string.rep("x", 1e10) cannot exhaust RAM. +DEFAULT_MEMORY_LIMIT = 256 * 1024 * 1024 _TEXT_FILTERS = { "name": NameFilter, @@ -77,11 +79,63 @@ | set(_TEXT_FILTERS) ) -# Globals removed from the sandbox: file/process access, loading other code, -# and the lupa bridge back into Python. +# Instruction budget + removal of globals that give file/process access, load +# other code, or bridge back into Python. +# +# The instruction limit is enforced by a count hook, which needs care: +# * The abort is an ordinary Lua error, so pcall/xpcall could catch it. +# Once the budget is spent the hook therefore becomes "sticky" (fires on +# every instruction), so the script cannot execute anything after it. +# * Hooks are per thread (coroutine) in Lua, so coroutine.create/wrap are +# replaced to install the hook in every new coroutine as well. +# * The budget is shared by all threads, so spreading the work over many +# coroutines does not multiply it. _SANDBOX_SETUP = """ local limit = ... -debug.sethook(function() error("macro aborted: instruction limit reached", 2) end, "", limit) +local sethook, co_create, co_resume = debug.sethook, coroutine.create, coroutine.resume +local pack, unpack = table.pack, table.unpack +local main = coroutine.running() +local step = math.min(limit, 1000) +local used, tripped = 0, false +local function hook() + if not tripped then + used = used + step + if used < limit then return end + tripped = true + end + sethook(hook, "", 1) + sethook(main, hook, "", 1) + error("macro aborted: instruction limit reached", 2) +end +sethook(hook, "", step) +coroutine.create = function(f) + local co = co_create(f) + sethook(co, hook, "", tripped and 1 or step) + return co +end +coroutine.wrap = function(f) + local co = coroutine.create(f) + return function(...) + local r = pack(co_resume(co, ...)) + if not r[1] then error(r[2], 0) end + return unpack(r, 2, r.n) + end +end +-- Lua runs xpcall message handlers and __gc finalizers with hooks disabled, +-- so an endless loop there could not be stopped. The handler is therefore +-- called after the stack has unwound, and finalizers are not allowed. +local pcall, raw_setmetatable, rawget = pcall, setmetatable, rawget +xpcall = function(f, handler, ...) + local r = pack(pcall(f, ...)) + if r[1] then return unpack(r, 1, r.n) end + return false, handler(r[2]) +end +setmetatable = function(t, mt) + if type(mt) == "table" and rawget(mt, "__gc") ~= nil then + error("__gc metamethods are not allowed in macros", 2) + end + return raw_setmetatable(t, mt) +end local os_time, os_date, os_clock = os.time, os.date, os.clock os = { time = os_time, date = os_date, clock = os_clock } io, debug, package, require, dofile, loadfile, load, collectgarbage, python = nil @@ -191,11 +245,13 @@ def __init__( output: Optional[Callable[[str], None]] = None, profiles_dir: Optional[Path] = None, instruction_limit: int = DEFAULT_INSTRUCTION_LIMIT, + memory_limit: int = DEFAULT_MEMORY_LIMIT, ): self._host = host self._output = output or print self._profiles_dir = profiles_dir self._instruction_limit = instruction_limit + self._memory_limit = memory_limit # -- API functions exposed to Lua ----------------------------------------- @@ -235,7 +291,7 @@ def _profile_names(self) -> list[str]: def run(self, source: str, chunk_name: str = "macro") -> None: """Execute *source*. Raises MacroError on any failure.""" try: - from lupa.lua54 import LuaError, LuaRuntime + from lupa.lua54 import LuaError, LuaMemoryError, LuaRuntime except ImportError as exc: raise MacroError( "Lua support is not installed — run: pip install \"lupa>=2.0,<3\"" @@ -245,6 +301,7 @@ def run(self, source: str, chunk_name: str = "macro") -> None: register_eval=False, register_builtins=False, unpack_returned_tuples=True, + max_memory=self._memory_limit, # No attribute access on Python objects from Lua at all: the # script only gets the plain functions in the opensak table. attribute_filter=self._deny_attribute, @@ -266,6 +323,10 @@ def run(self, source: str, chunk_name: str = "macro") -> None: fn() except MacroError: raise + except LuaMemoryError as exc: + raise MacroError( + f"macro aborted: memory limit reached ({self._memory_limit // (1024 * 1024)} MB)" + ) from exc except LuaError as exc: raise MacroError(str(exc)) from exc except Exception as exc: diff --git a/tests/unit-tests/test_macro_runtime.py b/tests/unit-tests/test_macro_runtime.py index 9846e86a..972a53e5 100644 --- a/tests/unit-tests/test_macro_runtime.py +++ b/tests/unit-tests/test_macro_runtime.py @@ -126,6 +126,51 @@ def test_endless_loop_is_aborted(): _run("while true do end", instruction_limit=100_000) +@pytest.mark.parametrize("source", [ + # pcall/xpcall must not be able to swallow the abort + "while true do pcall(function() while true do end end) end", + "while true do xpcall(function() while true do end end, function() while true do end end) end", + "xpcall(function() error('x') end, function() while true do end end)", + # hooks are per thread, so coroutines need their own + "coroutine.wrap(function() while true do end end)()", + "coroutine.resume(coroutine.create(function() while true do end end))", + "while true do pcall(coroutine.wrap(function() while true do end end)) end", + # the budget is shared, so many short coroutines don't multiply it + "for i = 1, 1e9 do coroutine.wrap(function() for j = 1, 50000 do end end)() end", +]) +def test_instruction_limit_cannot_be_bypassed(source): + with pytest.raises(MacroError, match="instruction limit"): + _run(source, instruction_limit=100_000) + + +def test_coroutines_still_work(): + _, out = _run(""" + local gen = coroutine.wrap(function(a) local b = coroutine.yield(a + 1) coroutine.yield(b * 2) end) + local co = coroutine.create(function() coroutine.yield("y") return "r" end) + print(gen(1), gen(5), select(2, coroutine.resume(co)), select(2, coroutine.resume(co))) + print(pcall(error, "caught")) + print(xpcall(error, function(e) return "handled " .. e end, "x", 0)) + print(xpcall(function(a, b) return a + b end, print, 1, 2)) + """) + assert out == ["2\t10\ty\tr", "false\tcaught", "false\thandled x", "true\t3"] + + +def test_gc_metamethods_are_rejected(): + # finalizers run with hooks disabled, so a loop in one could not be stopped + with pytest.raises(MacroError, match="__gc"): + _run("setmetatable({}, { __gc = true })", instruction_limit=100_000) + _, out = _run('print(getmetatable(setmetatable({}, { __index = {a = 1} })).__index.a)') + assert out == ["1"] + + +def test_memory_limit(): + with pytest.raises(MacroError, match="memory limit"): + _run('local s = string.rep("x", 1e9)', memory_limit=32 * 1024 * 1024) + # caught inside Lua: the allocation simply fails, nothing is exhausted + _, out = _run('print(pcall(string.rep, "x", 1e9))', memory_limit=32 * 1024 * 1024) + assert out == ["false\tnot enough memory"] + + def test_syntax_error_is_reported(): with pytest.raises(MacroError, match="macro:1"): _run("this is not lua") From 175ace9cbcb3f4c628fd9a7132a75ba2bbcfd944 Mon Sep 17 00:00:00 2001 From: nagisml Date: Tue, 29 Sep 2026 16:16:41 +0200 Subject: [PATCH 3/6] count() directly uses the DB --- src/opensak/gui/mainwindow.py | 9 +++++++-- src/opensak/macro/runtime.py | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/opensak/gui/mainwindow.py b/src/opensak/gui/mainwindow.py index fec7c924..d8276733 100644 --- a/src/opensak/gui/mainwindow.py +++ b/src/opensak/gui/mainwindow.py @@ -3063,8 +3063,13 @@ def clear_filter(self) -> None: self._clear_filter() def cache_count(self) -> int: - """MacroHost: number of caches currently in the list.""" - return self._cache_table.row_count() + """MacroHost: number of caches matching the active filter. + + Asks the database rather than the table: clear_filter() (and any + other _refresh_cache_list()) reloads the table asynchronously, so its + row count would still be stale right after the call.""" + with get_session() as session: + return len(apply_filters_auto(session, self._build_active_filterset())) def _open_found_updater(self) -> None: if self._trip_planner_active(): diff --git a/src/opensak/macro/runtime.py b/src/opensak/macro/runtime.py index 75475b27..8ed4d6a3 100644 --- a/src/opensak/macro/runtime.py +++ b/src/opensak/macro/runtime.py @@ -13,7 +13,7 @@ -- (0 = nothing matched, view unchanged) opensak.filter_profile("Name") -- apply a saved filter profile; returns count opensak.clear_filter() -- show all caches again - opensak.count() -- caches currently shown in the list + opensak.count() -- caches matching the active filter opensak.profiles() -- list of saved filter profile names print(...) -- write to the macro output pane @@ -160,7 +160,12 @@ def clear_filter(self) -> None: """Remove the active filter.""" def cache_count(self) -> int: - """Number of caches currently shown.""" + """Number of caches matching the active filter. + + Must be up to date right after apply_filter()/clear_filter(), even if + the host refreshes its view asynchronously — i.e. ask the database, + not the UI. + """ # ── Lua table → FilterSet ──────────────────────────────────────────────────── From 239bd26c41d2ee8dc7568cfb976165c00e6c4ee5 Mon Sep 17 00:00:00 2001 From: nagisml Date: Tue, 29 Sep 2026 17:12:58 +0200 Subject: [PATCH 4/6] moved into own macro menu --- src/opensak/gui/mainwindow.py | 13 +++++++------ src/opensak/lang/cs.py | 1 + src/opensak/lang/da.py | 1 + src/opensak/lang/de.py | 1 + src/opensak/lang/de_CH.py | 1 + src/opensak/lang/en.py | 1 + src/opensak/lang/es.py | 1 + src/opensak/lang/fr.py | 1 + src/opensak/lang/nl.py | 1 + src/opensak/lang/pl.py | 1 + src/opensak/lang/pt.py | 1 + src/opensak/lang/se.py | 1 + tests/e2e-tests/test_e2e_macro.py | 19 +++++++++++++++++++ 13 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/opensak/gui/mainwindow.py b/src/opensak/gui/mainwindow.py index d8276733..804def88 100644 --- a/src/opensak/gui/mainwindow.py +++ b/src/opensak/gui/mainwindow.py @@ -507,12 +507,6 @@ def _setup_menu(self) -> None: act_found_update.triggered.connect(self._open_found_updater) tools_menu.addAction(act_found_update) - tools_menu.addSeparator() - - act_run_macro = QAction(tr("action_run_macro"), self) - act_run_macro.triggered.connect(self._open_macro_dialog) - tools_menu.addAction(act_run_macro) - # ── GPS ─────────────────────────────────────────────────────────────── gps_menu = menubar.addMenu("&GPS") @@ -553,6 +547,13 @@ def _setup_menu(self) -> None: act_dist_bearing.triggered.connect(self._open_dist_bearing) gc_tools_menu.addAction(act_dist_bearing) + # ── Macros (Lua, proof of concept) ──────────────────────────────────── + macros_menu = menubar.addMenu(tr("menu_macros")) + + act_run_macro = QAction(tr("action_run_macro"), self) + act_run_macro.triggered.connect(self._open_macro_dialog) + macros_menu.addAction(act_run_macro) + # ── Hjælp ───────────────────────────────────────────────────────────── help_menu = menubar.addMenu(tr("menu_help")) diff --git a/src/opensak/lang/cs.py b/src/opensak/lang/cs.py index e4441181..d1447e4c 100644 --- a/src/opensak/lang/cs.py +++ b/src/opensak/lang/cs.py @@ -134,6 +134,7 @@ # ── Geocaching Tools menu ───────────────────────────────────────────────── "menu_gc_tools": "&Nástroje", + "menu_macros": "&Makra", "action_coord_converter": "⇄ &Převodník souřadnic…", "action_projection": "📐 &Projekce souřadnic…", "action_checksum": "🔢 &Ciferný součet…", diff --git a/src/opensak/lang/da.py b/src/opensak/lang/da.py index a234e736..a5aac6fe 100644 --- a/src/opensak/lang/da.py +++ b/src/opensak/lang/da.py @@ -134,6 +134,7 @@ # ── Geocaching Værktøjer-menu ───────────────────────────────────────────── "menu_gc_tools": "&Værktøjer", + "menu_macros": "&Makroer", "action_coord_converter": "⇄ &Koordinatkonverter…", "action_projection": "📐 &Koordinatprojektion…", "action_checksum": "🔢 &Tjeksum-beregner…", diff --git a/src/opensak/lang/de.py b/src/opensak/lang/de.py index 1c551974..2117132f 100644 --- a/src/opensak/lang/de.py +++ b/src/opensak/lang/de.py @@ -134,6 +134,7 @@ # ── Geocaching Tools menu ───────────────────────────────────────────────── "menu_gc_tools": "&Tools", + "menu_macros": "&Makros", "action_coord_converter": "⇄ &Koordinaten-Konverter…", "action_projection": "📐 &Koordinaten-Projektion…", "action_checksum": "🔢 &Koordinaten-Checksumme…", diff --git a/src/opensak/lang/de_CH.py b/src/opensak/lang/de_CH.py index 9507afa8..0b881003 100644 --- a/src/opensak/lang/de_CH.py +++ b/src/opensak/lang/de_CH.py @@ -135,6 +135,7 @@ # ── Geocaching Tools menu ───────────────────────────────────────────────── "menu_gc_tools": "&Tools", + "menu_macros": "&Makros", "action_coord_converter": "⇄ &Koordinaten-Konverter…", "action_projection": "📐 &Koordinaten-Projektion…", "action_checksum": "🔢 &Koordinaten-Checksumme…", diff --git a/src/opensak/lang/en.py b/src/opensak/lang/en.py index 7b49cac5..e0434a8b 100644 --- a/src/opensak/lang/en.py +++ b/src/opensak/lang/en.py @@ -134,6 +134,7 @@ # ── Geocaching Tools menu ───────────────────────────────────────────────── "menu_gc_tools": "&Tools", + "menu_macros": "&Macros", "action_coord_converter": "⇄ &Coordinate Converter…", "action_projection": "📐 &Coordinate Projection…", "action_checksum": "🔢 &Digit Checksum…", diff --git a/src/opensak/lang/es.py b/src/opensak/lang/es.py index 3a24024c..109f6b5c 100644 --- a/src/opensak/lang/es.py +++ b/src/opensak/lang/es.py @@ -136,6 +136,7 @@ # ── Geocaching Tools menu ───────────────────────────────────────────────── "menu_gc_tools": "&Herramientas", + "menu_macros": "&Macros", "action_coord_converter": "⇄ &Convertidor de coordenadas…", "action_projection": "📐 &Proyección de coordenadas…", "action_checksum": "🔢 Suma de &dígitos…", diff --git a/src/opensak/lang/fr.py b/src/opensak/lang/fr.py index 010810b3..4a84b88c 100644 --- a/src/opensak/lang/fr.py +++ b/src/opensak/lang/fr.py @@ -134,6 +134,7 @@ # ── Menu Outils de géocaching ───────────────────────────────────────────── "menu_gc_tools": "&Outils géo", + "menu_macros": "&Macros", "action_coord_converter": "⇄ &Convertisseur de coordonnées…", "action_projection": "📐 &Projection de coordonnées…", "action_checksum": "🔢 &Somme des chiffres…", diff --git a/src/opensak/lang/nl.py b/src/opensak/lang/nl.py index ab972d9c..d6b5d45e 100644 --- a/src/opensak/lang/nl.py +++ b/src/opensak/lang/nl.py @@ -137,6 +137,7 @@ # ── Geocaching Tools menu ───────────────────────────────────────────────── "menu_gc_tools": "&Extra's", + "menu_macros": "&Macro's", "action_coord_converter": "⇄ &Coördinaatconverter…", "action_projection": "📐 &Coördinaatprojectie…", "action_checksum": "🔢 &Cijfersom…", diff --git a/src/opensak/lang/pl.py b/src/opensak/lang/pl.py index 232ce24f..79c7b8e2 100644 --- a/src/opensak/lang/pl.py +++ b/src/opensak/lang/pl.py @@ -136,6 +136,7 @@ # ── Geocaching Tools menu ───────────────────────────────────────────────── "menu_gc_tools": "&Narzędzia", + "menu_macros": "&Makra", "action_coord_converter": "⇄ &Konwerter współrzędnych…", "action_projection": "📐 &Projekcja współrzędnych…", "action_checksum": "🔢 &Suma kontrolna cyfr…", diff --git a/src/opensak/lang/pt.py b/src/opensak/lang/pt.py index fa97cfec..26ca39b0 100644 --- a/src/opensak/lang/pt.py +++ b/src/opensak/lang/pt.py @@ -134,6 +134,7 @@ # ── Geocaching Tools menu ───────────────────────────────────────────────── "menu_gc_tools": "&Ferramentas de geocaching", + "menu_macros": "&Macros", "action_coord_converter": "⇄ &Conversor de coordenadas…", "action_projection": "📐 &Projeção de coordenadas…", "action_checksum": "🔢 &Soma dos dígitos…", diff --git a/src/opensak/lang/se.py b/src/opensak/lang/se.py index fc64faa0..13fdfd6f 100644 --- a/src/opensak/lang/se.py +++ b/src/opensak/lang/se.py @@ -134,6 +134,7 @@ # ── Geocaching Tools menu ───────────────────────────────────────────────── "menu_gc_tools": "&Verktyg", + "menu_macros": "&Makron", "action_coord_converter": "⇄ &Konvertera koordinater…", "action_projection": "📐 &Koordinat Projection…", "action_checksum": "🔢 &Checksumma av tal…", diff --git a/tests/e2e-tests/test_e2e_macro.py b/tests/e2e-tests/test_e2e_macro.py index ab108923..24b4d612 100644 --- a/tests/e2e-tests/test_e2e_macro.py +++ b/tests/e2e-tests/test_e2e_macro.py @@ -27,6 +27,25 @@ def test_macro_filter_without_match_keeps_view(seeded_window): assert seeded_window._cache_table.row_count() == before +def test_macro_count_is_current_right_after_clear_filter(seeded_window): + # clear_filter() reloads the table asynchronously, so count() must not + # read the table's (still filtered) row count. + out = _run_macro(seeded_window, """ + local all = opensak.count() + opensak.filter{ code = "GCAAA0" } + local filtered = opensak.count() + opensak.clear_filter() + print(filtered, opensak.count() == all, all > filtered) + """) + assert out.splitlines()[0] == "2\ttrue\ttrue" + + +def test_macro_menu_follows_tools_menu(seeded_window): + from opensak.lang import tr + titles = [a.text() for a in seeded_window.menuBar().actions()] + assert titles.index(tr("menu_macros")) == titles.index(tr("menu_gc_tools")) + 1 + + def test_macro_error_is_shown_in_output(seeded_window): out = _run_macro(seeded_window, "opensak.filter{ bogus = 1 }") assert "Macro error" in out and "unknown filter key" in out From 1db7efd1cab253edd5a22183c0b5819a95d44d9a Mon Sep 17 00:00:00 2001 From: nagisml Date: Tue, 29 Sep 2026 20:14:29 +0200 Subject: [PATCH 5/6] introduction lua-macros flag --- features.json | 3 ++- src/opensak/gui/mainwindow.py | 2 ++ src/opensak/utils/flags.py | 12 ++++++++++++ tests/e2e-tests/test_e2e_macro.py | 23 ++++++++++++++++++++--- tests/unit-tests/test_flags.py | 20 ++++++++++++++++++++ 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/features.json b/features.json index f43aa092..2d504bb7 100644 --- a/features.json +++ b/features.json @@ -1,4 +1,5 @@ { "reverse-geocoding": true, - "map-popout": true + "map-popout": true, + "lua-macros": true } diff --git a/src/opensak/gui/mainwindow.py b/src/opensak/gui/mainwindow.py index 804def88..1b5df946 100644 --- a/src/opensak/gui/mainwindow.py +++ b/src/opensak/gui/mainwindow.py @@ -549,6 +549,8 @@ def _setup_menu(self) -> None: # ── Macros (Lua, proof of concept) ──────────────────────────────────── macros_menu = menubar.addMenu(tr("menu_macros")) + # Beta-only until the macro API is settled (#938 step 4). + macros_menu.menuAction().setVisible(flags.lua_macros) act_run_macro = QAction(tr("action_run_macro"), self) act_run_macro.triggered.connect(self._open_macro_dialog) diff --git a/src/opensak/utils/flags.py b/src/opensak/utils/flags.py index ef49826f..8c98b0e6 100644 --- a/src/opensak/utils/flags.py +++ b/src/opensak/utils/flags.py @@ -24,12 +24,23 @@ import sys from pathlib import Path +from opensak import __version__ + # src/opensak/utils/ → src/opensak/ → src/ → project root _FEATURES_FILE: Path = Path(__file__).parent.parent.parent.parent / "features.json" + +def _is_prerelease(version: str) -> bool: + """True for semver pre-release versions such as "1.21.0-beta.1".""" + return "-" in version + + _RELEASE_DEFAULTS: dict[str, bool] = { "reverse-geocoding": True, "map-popout": True, + # Lua macros (#938): on in beta builds only, so the POC cannot reach a + # stable release before the API (#938 step 4) is settled. + "lua-macros": _is_prerelease(__version__), } @@ -72,3 +83,4 @@ def _load() -> dict[str, bool]: reverse_geocoding: bool = _flags["reverse-geocoding"] map_popout: bool = _flags["map-popout"] +lua_macros: bool = _flags["lua-macros"] diff --git a/tests/e2e-tests/test_e2e_macro.py b/tests/e2e-tests/test_e2e_macro.py index 24b4d612..efa8448b 100644 --- a/tests/e2e-tests/test_e2e_macro.py +++ b/tests/e2e-tests/test_e2e_macro.py @@ -1,5 +1,9 @@ """tests/e2e-tests/test_e2e_macro.py — Lua macro dialog drives the real main window.""" +import pytest + +from opensak.utils import flags + def _run_macro(window, source: str) -> str: window._open_macro_dialog() @@ -40,10 +44,23 @@ def test_macro_count_is_current_right_after_clear_filter(seeded_window): assert out.splitlines()[0] == "2\ttrue\ttrue" -def test_macro_menu_follows_tools_menu(seeded_window): + + +@pytest.fixture +def macros_flag(monkeypatch, request): + # Must be listed before seeded_window: the menu is built in MainWindow.__init__. + monkeypatch.setattr(flags, "lua_macros", request.param) + return request.param + + +@pytest.mark.parametrize("macros_flag", [True, False], indirect=True) +def test_macro_menu_follows_tools_menu_and_is_gated(macros_flag, seeded_window): from opensak.lang import tr - titles = [a.text() for a in seeded_window.menuBar().actions()] - assert titles.index(tr("menu_macros")) == titles.index(tr("menu_gc_tools")) + 1 + actions = seeded_window.menuBar().actions() + titles = [a.text() for a in actions] + macros = titles.index(tr("menu_macros")) + assert macros == titles.index(tr("menu_gc_tools")) + 1 + assert actions[macros].isVisible() is macros_flag def test_macro_error_is_shown_in_output(seeded_window): diff --git a/tests/unit-tests/test_flags.py b/tests/unit-tests/test_flags.py index 14c9a06a..4aa3baf7 100644 --- a/tests/unit-tests/test_flags.py +++ b/tests/unit-tests/test_flags.py @@ -1,5 +1,6 @@ # tests/unit-tests/test_flags.py — feature flag resolution tests. +import opensak import opensak.utils.flags as flags_module @@ -11,6 +12,7 @@ def test_absent_file_returns_release_defaults(self, no_features_file): assert flags_module._flags == { "reverse-geocoding": True, "map-popout": True, + "lua-macros": flags_module._is_prerelease(opensak.__version__), } def test_present_file_overrides_defaults(self, patch_features_file): @@ -25,6 +27,7 @@ def test_malformed_json_falls_back_to_defaults(self, tmp_path, monkeypatch): assert result == { "reverse-geocoding": True, "map-popout": True, + "lua-macros": flags_module._is_prerelease(opensak.__version__), } def test_unknown_keys_in_file_are_ignored(self, patch_features_file): @@ -52,6 +55,23 @@ def test_false_when_explicitly_disabled_in_file(self, patch_features_file): assert flags_module.reverse_geocoding is False +class TestLuaMacros: + def test_prerelease_detection(self): + assert flags_module._is_prerelease("1.21.0-beta.1") is True + assert flags_module._is_prerelease("1.21.0-rc.2") is True + assert flags_module._is_prerelease("1.21.0") is False + + def test_default_follows_version(self, no_features_file): + # on in beta builds, off in stable ones (#938) + assert flags_module.lua_macros is ("-" in opensak.__version__) + + def test_file_overrides_default(self, patch_features_file): + patch_features_file({"lua-macros": True}) + assert flags_module.lua_macros is True + patch_features_file({"lua-macros": False}) + assert flags_module.lua_macros is False + + # ── _parse_argv() ───────────────────────────────────────────────────────────── From b74a78de87b9bec27404ab4aec37d20381a33b7e Mon Sep 17 00:00:00 2001 From: nagisml Date: Tue, 29 Sep 2026 20:22:06 +0200 Subject: [PATCH 6/6] added docstring comments --- src/opensak/macro/runtime.py | 53 ++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/src/opensak/macro/runtime.py b/src/opensak/macro/runtime.py index 8ed4d6a3..4592644e 100644 --- a/src/opensak/macro/runtime.py +++ b/src/opensak/macro/runtime.py @@ -33,6 +33,19 @@ local n = opensak.filter{ type = "Traditional", difficulty = {1, 2}, found = false } print("Easy unfound traditionals: " .. n) + + +Known limitation (#938 step 4): the instruction limit only counts Lua VM +instructions, not work inside C functions. Lua pattern matching backtracks +in C, so e.g. string.rep("a", 100):find(".-.-.-.-.-b") runs ~12 s despite +instruction_limit=100_000, and longer subjects take minutes. The memory +limit does not help either (matching allocates nothing). Consequences: + * Planned regex functions should use a linear-time engine (google-re2) or + the `regex` module with its timeout= argument, not Python's `re`. + * A worker thread keeps the GUI responsive and lets a Cancel button + abandon the run, but cannot stop a call that is already running; only a + subprocess can be terminated hard. This belongs to the threading decision. + """ from __future__ import annotations @@ -75,7 +88,16 @@ "county": CountyFilter, } FILTER_KEYS = sorted( - {"type", "container", "difficulty", "terrain", "found", "available", "where", "label"} + { + "type", + "container", + "difficulty", + "terrain", + "found", + "available", + "where", + "label", + } | set(_TEXT_FILTERS) ) @@ -170,6 +192,7 @@ def cache_count(self) -> int: # ── Lua table → FilterSet ──────────────────────────────────────────────────── + def _as_list(value: Any) -> list: """A Lua value that may be a scalar or an array table → Python list.""" if isinstance(value, (list, tuple)): @@ -213,7 +236,9 @@ def build_filterset(spec: dict) -> tuple[FilterSet, str]: fs = FilterSet(mode="AND") if "type" in spec: - fs.add(CacheTypeFilter([_resolve_cache_type(t) for t in _as_list(spec["type"])])) + fs.add( + CacheTypeFilter([_resolve_cache_type(t) for t in _as_list(spec["type"])]) + ) if "container" in spec: fs.add(ContainerFilter([str(c) for c in _as_list(spec["container"])])) if "difficulty" in spec: @@ -237,6 +262,7 @@ def build_filterset(spec: dict) -> tuple[FilterSet, str]: # ── Runtime ────────────────────────────────────────────────────────────────── + class MacroRuntime: """Run Lua macros against a MacroHost. @@ -262,7 +288,9 @@ def __init__( def _filter(self, spec=None) -> int: if spec is None or not hasattr(spec, "items"): - raise MacroError("opensak.filter expects a table, e.g. opensak.filter{ found = false }") + raise MacroError( + "opensak.filter expects a table, e.g. opensak.filter{ found = false }" + ) fs, label = build_filterset(dict(spec.items())) return self._host.apply_filter(fs, label) @@ -299,7 +327,7 @@ def run(self, source: str, chunk_name: str = "macro") -> None: from lupa.lua54 import LuaError, LuaMemoryError, LuaRuntime except ImportError as exc: raise MacroError( - "Lua support is not installed — run: pip install \"lupa>=2.0,<3\"" + 'Lua support is not installed — run: pip install "lupa>=2.0,<3"' ) from exc lua = LuaRuntime( @@ -315,13 +343,15 @@ def run(self, source: str, chunk_name: str = "macro") -> None: g = lua.globals() g.print = self._lua_print - g.opensak = lua.table_from({ - "filter": self._wrap(self._filter), - "filter_profile": self._wrap(self._filter_profile), - "clear_filter": self._wrap(self._host.clear_filter), - "count": self._wrap(self._host.cache_count), - "profiles": self._wrap(lambda: lua.table_from(self._profile_names())), - }) + g.opensak = lua.table_from( + { + "filter": self._wrap(self._filter), + "filter_profile": self._wrap(self._filter_profile), + "clear_filter": self._wrap(self._host.clear_filter), + "count": self._wrap(self._host.cache_count), + "profiles": self._wrap(lambda: lua.table_from(self._profile_names())), + } + ) try: fn = lua.compile(source, name=f"={chunk_name}") @@ -353,6 +383,7 @@ def call(*args): return func(*args) except MacroError as exc: raise LuaError(str(exc)) from None + return call def _lua_print(self, *args) -> None: