From 35302154ae188fa3a5943ebd17cfa93839eba503 Mon Sep 17 00:00:00 2001 From: forbes Date: Sun, 8 Feb 2026 13:11:08 -0600 Subject: [PATCH] feat: expose version to Python and add update checker (#28, #29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #28: Add version.py.in CMake template that injects KINDRED_CREATE_VERSION at build time, making the Kindred Create version available to Python code via 'from version import VERSION'. Issue #29: Add update_checker.py that queries the Gitea releases API on startup (10s deferred) to check for newer versions. Uses stdlib urllib only, 5s timeout, never blocks the UI. Respects user preferences for check interval, enable/disable, and skipped versions. Logs results to Console for now — UI notification will follow in issue #30. Closes #28 Closes #29 --- src/Mod/Create/CMakeLists.txt | 9 ++ src/Mod/Create/InitGui.py | 11 +++ src/Mod/Create/update_checker.py | 165 +++++++++++++++++++++++++++++++ src/Mod/Create/version.py.in | 1 + 4 files changed, 186 insertions(+) create mode 100644 src/Mod/Create/update_checker.py create mode 100644 src/Mod/Create/version.py.in diff --git a/src/Mod/Create/CMakeLists.txt b/src/Mod/Create/CMakeLists.txt index 40aab5df9c..de033db073 100644 --- a/src/Mod/Create/CMakeLists.txt +++ b/src/Mod/Create/CMakeLists.txt @@ -1,11 +1,20 @@ # Kindred Create core module # Handles auto-loading of ztools and Silo addons +# Generate version.py from template with Kindred Create version +configure_file( + ${CMAKE_CURRENT_SOURCE_DIR}/version.py.in + ${CMAKE_CURRENT_BINARY_DIR}/version.py + @ONLY +) + # Install Python init files install( FILES Init.py InitGui.py + update_checker.py + ${CMAKE_CURRENT_BINARY_DIR}/version.py DESTINATION Mod/Create ) diff --git a/src/Mod/Create/InitGui.py b/src/Mod/Create/InitGui.py index c3da4592be..bdf0eed086 100644 --- a/src/Mod/Create/InitGui.py +++ b/src/Mod/Create/InitGui.py @@ -148,6 +148,16 @@ def _setup_silo_activity_panel(): FreeCAD.Console.PrintLog(f"Create: Silo activity panel skipped: {e}\n") +def _check_for_updates(): + """Check for application updates in the background.""" + try: + from update_checker import _run_update_check + + _run_update_check() + except Exception as e: + FreeCAD.Console.PrintLog(f"Create: Update check skipped: {e}\n") + + # Defer enhancements until the GUI event loop is running try: from PySide.QtCore import QTimer @@ -156,5 +166,6 @@ try: QTimer.singleShot(2000, _setup_silo_auth_panel) QTimer.singleShot(3000, _check_silo_first_start) QTimer.singleShot(4000, _setup_silo_activity_panel) + QTimer.singleShot(10000, _check_for_updates) except Exception: pass diff --git a/src/Mod/Create/update_checker.py b/src/Mod/Create/update_checker.py new file mode 100644 index 0000000000..eb402ab90a --- /dev/null +++ b/src/Mod/Create/update_checker.py @@ -0,0 +1,165 @@ +"""Kindred Create update checker. + +Queries the Gitea releases API to determine if a newer version is +available. Designed to run in the background on startup without +blocking the UI. +""" + +import json +import re +import urllib.request +from datetime import datetime, timezone + +import FreeCAD + +_RELEASES_URL = "https://git.kindred-systems.com/api/v1/repos/kindred/create/releases" +_PREF_PATH = "User parameter:BaseApp/Preferences/Mod/KindredCreate/Update" +_TIMEOUT = 5 + + +def _parse_version(tag): + """Parse a version tag like 'v0.1.3' into a comparable tuple. + + Returns None if the tag doesn't match the expected pattern. + """ + m = re.match(r"^v?(\d+)\.(\d+)\.(\d+)$", tag) + if not m: + return None + return (int(m.group(1)), int(m.group(2)), int(m.group(3))) + + +def check_for_update(current_version): + """Check if a newer release is available on Gitea. + + Args: + current_version: Version string like "0.1.3". + + Returns: + Dict with update info if a newer version exists, None otherwise. + Dict keys: version, tag, release_url, assets, body. + """ + current = _parse_version(current_version) + if current is None: + return None + + req = urllib.request.Request( + f"{_RELEASES_URL}?limit=10", + headers={"Accept": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: + releases = json.loads(resp.read()) + + best = None + best_version = current + + for release in releases: + if release.get("draft"): + continue + if release.get("prerelease"): + continue + + tag = release.get("tag_name", "") + # Skip the rolling 'latest' tag + if tag == "latest": + continue + + ver = _parse_version(tag) + if ver is None: + continue + + if ver > best_version: + best_version = ver + best = release + + if best is None: + return None + + assets = [] + for asset in best.get("assets", []): + assets.append( + { + "name": asset.get("name", ""), + "url": asset.get("browser_download_url", ""), + "size": asset.get("size", 0), + } + ) + + return { + "version": ".".join(str(x) for x in best_version), + "tag": best["tag_name"], + "release_url": best.get("html_url", ""), + "assets": assets, + "body": best.get("body", ""), + } + + +def _should_check(param): + """Determine whether an update check should run now. + + Args: + param: FreeCAD parameter group for update preferences. + + Returns: + True if a check should be performed. + """ + if not param.GetBool("CheckEnabled", True): + return False + + last_check = param.GetString("LastCheckTimestamp", "") + if not last_check: + return True + + interval_days = param.GetInt("CheckIntervalDays", 1) + if interval_days <= 0: + return True + + try: + last_dt = datetime.fromisoformat(last_check) + now = datetime.now(timezone.utc) + elapsed = (now - last_dt).total_seconds() + return elapsed >= interval_days * 86400 + except (ValueError, TypeError): + return True + + +def _run_update_check(): + """Entry point called from the deferred startup timer.""" + param = FreeCAD.ParamGet(_PREF_PATH) + + if not _should_check(param): + return + + try: + from version import VERSION + except ImportError: + FreeCAD.Console.PrintLog( + "Create: update check skipped — version module not available\n" + ) + return + + try: + result = check_for_update(VERSION) + except Exception as e: + FreeCAD.Console.PrintLog(f"Create: update check failed: {e}\n") + return + + # Record that we checked + param.SetString( + "LastCheckTimestamp", + datetime.now(timezone.utc).isoformat(), + ) + + if result is None: + FreeCAD.Console.PrintLog("Create: application is up to date\n") + return + + skipped = param.GetString("SkippedVersion", "") + if result["version"] == skipped: + FreeCAD.Console.PrintLog( + f"Create: update {result['version']} available but skipped by user\n" + ) + return + + FreeCAD.Console.PrintMessage( + f"Kindred Create {result['version']} is available (current: {VERSION})\n" + ) diff --git a/src/Mod/Create/version.py.in b/src/Mod/Create/version.py.in new file mode 100644 index 0000000000..05a09531c1 --- /dev/null +++ b/src/Mod/Create/version.py.in @@ -0,0 +1 @@ +VERSION = "@KINDRED_CREATE_VERSION@"