Explorar o código

chore: Python 3.11 is the minimum, and the installer finds or installs it

maziggy hai 1 semana
pai
achega
a735f8c43a

+ 1 - 0
CHANGELOG.md

@@ -161,6 +161,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Native installs need Python 3.11 or newer** — Since v0.2.4.8 Bambuddy hasn't started on Python 3.10, while the installer, README and wiki still said 3.10 was enough. The requirement now says 3.11. The installer uses the system's `python3` when it is new enough and otherwise looks for a newer Python next to it: on Ubuntu 22.04, whose `python3` is 3.10, it installs and uses the `python3.11` package. An environment left from an older Python is rebuilt. The update scripts check the install's Python first and, if it is too old, stop before changing anything and link to the steps for moving it to 3.11. Docker and the Windows installer ship Python 3.13 and are not affected. Wiki updated.
 - **The webhook endpoints listed in Settings didn't exist** — **Settings → API Keys → Webhook Endpoints** listed endpoints for the status of all printers, pausing and resuming that Bambuddy doesn't have, and two others under the wrong path. It now lists the real ones: add to the queue, start the next queued job, stop, cancel, printer status and queue status. Wiki updated.
 - **A valid API key could be refused as invalid during a brief database problem** — When checking an API key hit a database error (a busy database, or saving the key's last-used time while it was locked), the request was answered "Invalid API key", so integrations such as Home Assistant or scripts were told their key was wrong. It now answers "Authentication service temporarily unavailable" (503), which a client can retry, and the error is logged. A wrong or expired key is refused as before.
 - **Errors were reported as "Authentication service temporarily unavailable" with login switched off** — Without authentication, any unexpected error in Bambuddy answered 503 "Authentication service temporarily unavailable", and the log blamed the authentication check, which sent troubleshooting the wrong way. Such errors are now reported as the internal error they are (500), and the error with its details now lands in `bambuddy.log` (and so in support packages), whether login is on or off. The protection that refuses requests when Bambuddy can't tell whether login is on stays as it was.

+ 1 - 1
README.md

@@ -599,7 +599,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 ## 🚀 Quick Start
 
 ### Requirements
-- Python 3.10+ (3.11/3.12 recommended)
+- Python 3.11+ (3.12 or 3.13 recommended)
 - Bambu Lab printer with **Developer Mode** enabled (see below)
 - **"Store sent files on external storage"** enabled in Bambu Studio/OrcaSlicer
 - Same local network as printer

+ 6 - 8
backend/app/core/compat.py

@@ -1,11 +1,9 @@
-"""Compatibility shims for older Python versions."""
+"""Kept for the modules that import StrEnum from here.
 
-import sys
+Python 3.11 is the minimum, so this is enum.StrEnum itself; new code can
+import it from enum directly.
+"""
 
-if sys.version_info >= (3, 11):
-    from enum import StrEnum
-else:
-    from enum import Enum
+from enum import StrEnum
 
-    class StrEnum(str, Enum):
-        """Drop-in replacement for enum.StrEnum on Python < 3.11."""
+__all__ = ["StrEnum"]

+ 1 - 2
backend/app/core/local_config.py

@@ -26,11 +26,10 @@ exists). Here we just expose state so the frontend can render accordingly.
 from __future__ import annotations
 
 import logging
+import tomllib
 from pathlib import Path
 from typing import Literal, TypedDict
 
-import tomllib
-
 log = logging.getLogger(__name__)
 
 DEFAULT_PATH = Path("/etc/bambuddy/local.toml")

+ 1 - 1
backend/app/main.py

@@ -8372,7 +8372,7 @@ async def on_print_complete(printer_id: int, data: dict):
                 if in_flight is not None:
                     try:
                         await asyncio.wait_for(in_flight.wait(), timeout=_FINISH_PHOTO_PRODUCER_WAIT_SECONDS)
-                    except asyncio.TimeoutError:
+                    except TimeoutError:
                         logger.warning(
                             "[PHOTO-BG] timed out waiting for stage-22 producer for printer %s — proceeding to fallback",
                             printer_id,

+ 2 - 2
backend/app/services/camera.py

@@ -382,7 +382,7 @@ async def close_tls_proxy(server: "asyncio.Server") -> None:
     way to guarantee no handler outlives the server that owns it.
 
     ``Server.close_clients()`` would do this natively, but it landed in Python
-    3.13 and Bambuddy supports 3.10, so the handler set is tracked by hand in
+    3.13 and Bambuddy supports 3.11, so the handler set is tracked by hand in
     ``_proxy_handlers``.
 
     Safe to call on any ``asyncio.Server`` from anywhere else: a server that
@@ -901,7 +901,7 @@ async def extract_video_last_frame(video_path: Path, output_path: Path) -> bool:
             logger.warning("ffmpeg produced no output for %s", video_path)
             return False
         return True
-    except asyncio.TimeoutError:
+    except TimeoutError:
         logger.warning("ffmpeg timed out extracting last frame from %s", video_path)
         if process is not None:
             try:

+ 1 - 1
backend/app/services/camera_diagnose.py

@@ -146,7 +146,7 @@ async def _check_tcp_reachable(ip_address: str, port: int, timeout: float) -> Ca
             status="ok",
             duration_ms=int((time.monotonic() - started) * 1000),
         )
-    except asyncio.TimeoutError:
+    except TimeoutError:
         return CameraDiagnoseStage(
             name="tcp_reachable",
             status="failed",

+ 2 - 2
backend/app/services/camera_fanout.py

@@ -224,7 +224,7 @@ class MjpegBroadcaster:
             if predecessor is not None:
                 try:
                     await asyncio.wait_for(predecessor.wait_until_torn_down(), timeout=_TEARDOWN_WAIT_SECONDS)
-                except asyncio.TimeoutError:
+                except TimeoutError:
                     logger.warning("Prior broadcaster %r didn't tear down in time; dialing anyway", self._key)
             async for chunk in self._factory(self._upstream_disconnect):
                 # Snapshot subscribers under lock so we don't iterate a list
@@ -365,7 +365,7 @@ async def iter_subscriber(
         while True:
             try:
                 chunk = await asyncio.wait_for(queue.get(), timeout=_DISCONNECT_POLL_SECONDS)
-            except asyncio.TimeoutError:
+            except TimeoutError:
                 # No frame this tick — is the client still there? This used to
                 # wait 30 s before asking, and the disconnect check after a yield
                 # only fires when frames are actually flowing. So a viewer that

+ 3 - 3
backend/app/services/diagnostic_snapshot.py

@@ -72,7 +72,7 @@ async def _run_connection_for(printer) -> dict:
             timeout=_PER_DIAGNOSTIC_TIMEOUT_SECONDS,
         )
         return {**base, "result": _serialize(result)}
-    except asyncio.TimeoutError:
+    except TimeoutError:
         # Name the step that hung and keep the checks that finished before it.
         # Without them a bundle from a farm whose every printer overran said
         # only "timed_out" fourteen times, and nothing about why (#3164).
@@ -102,7 +102,7 @@ async def _run_vp_for(vp) -> dict:
             timeout=_PER_DIAGNOSTIC_TIMEOUT_SECONDS,
         )
         return {**base, "result": _serialize(result)}
-    except asyncio.TimeoutError:
+    except TimeoutError:
         return {**base, "error": "timed_out"}
     except Exception as e:
         logger.warning("VP diagnostic failed for VP %s: %s", vp.id, e, exc_info=True)
@@ -119,7 +119,7 @@ async def _run_log_health() -> Any:
             timeout=_PER_DIAGNOSTIC_TIMEOUT_SECONDS,
         )
         return _serialize(result)
-    except asyncio.TimeoutError:
+    except TimeoutError:
         return {"error": "timed_out"}
     except Exception as e:
         logger.warning("Log-health scan failed: %s", e, exc_info=True)

+ 1 - 1
backend/app/services/virtual_printer/manager.py

@@ -852,7 +852,7 @@ class VirtualPrinterInstance:
             try:
                 await asyncio.wait_for(event.wait(), timeout=_SLICER_OPTIONS_WAIT_TIMEOUT)
                 slicer_opts = self._slicer_print_options.pop(file_path.name, None)
-            except asyncio.TimeoutError:
+            except TimeoutError:
                 slicer_opts = None
             finally:
                 self._slicer_print_options_events.pop(file_path.name, None)

+ 3 - 3
backend/app/services/virtual_printer/tailscale.py

@@ -110,7 +110,7 @@ class TailscaleService:
         )
         try:
             stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
-        except asyncio.TimeoutError:
+        except TimeoutError:
             process.kill()
             await process.wait()
             raise
@@ -134,8 +134,8 @@ class TailscaleService:
 
         try:
             returncode, stdout, stderr = await self._run_tailscale("status", "--json", timeout=5.0)
-        except (OSError, asyncio.TimeoutError) as e:
-            # asyncio.TimeoutError covers the case where ``_run_tailscale``
+        except (TimeoutError, OSError) as e:
+            # TimeoutError covers the case where ``_run_tailscale``
             # killed a stuck subprocess and re-raised. Without this branch
             # the timeout escaped into the FastAPI route handler and could
             # crash the VP management UI for users with a lagging

+ 1 - 1
backend/tests/unit/services/test_camera_fanout.py

@@ -59,7 +59,7 @@ def _make_factory(
                 try:
                     await asyncio.wait_for(disconnect.wait(), timeout=delay)
                     return
-                except asyncio.TimeoutError:
+                except TimeoutError:
                     pass
             yield chunk
 

+ 1 - 1
backend/tests/unit/services/test_stock_alert_producer_2955.py

@@ -962,7 +962,7 @@ async def test_subtype_and_colour_are_optional_so_the_old_call_shape_still_works
 
 @pytest.mark.asyncio
 async def test_a_spoolman_registered_date_with_a_trailing_z_still_gives_the_spool_an_age(db_session, scheduler, notify):
-    """Python 3.10's fromisoformat rejects a trailing "Z" (pyproject still allows 3.10), which
+    """Python 3.10's fromisoformat rejects a trailing "Z" (Bambuddy ran on 3.10 when this was written), which
     silently dropped the spool's age and with it the delta rate. Simulated here so the test
     means the same on every interpreter."""
 

+ 1 - 1
backend/tests/unit/test_tls_proxy_teardown_2968.py

@@ -125,7 +125,7 @@ async def _shutdown(server: asyncio.Server) -> None:
     server.close()
     try:
         await asyncio.wait_for(server.wait_closed(), timeout=5.0)
-    except asyncio.TimeoutError:
+    except TimeoutError:
         pass
 
 

+ 1 - 1
install/README.md

@@ -355,7 +355,7 @@ Get-Content "C:\Bambuddy\bambuddy-runtime-error.log" -Tail 100
 ## Requirements
 
 ### Native Installation
-- Python 3.10+ (automatically installed if missing)
+- Python 3.11+ (automatically installed if missing; on Ubuntu 22.04, whose own `python3` is 3.10, the installer adds the `python3.11` package)
 - Node.js 18+ (automatically installed if missing)
 - Git (automatically installed if missing)
 - ~500MB disk space

+ 47 - 29
install/install.sh

@@ -222,35 +222,33 @@ detect_os() {
 }
 
 detect_python() {
-    # Try python3 first, then python
-    if command -v python3 &>/dev/null; then
-        PYTHON_CMD="python3"
-    elif command -v python &>/dev/null; then
-        local version
-        version=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1)
-        if [[ "$version" -ge 3 ]]; then
-            PYTHON_CMD="python"
+    # The distribution's python3 first, then a newer one next to it (Ubuntu
+    # 22.04 ships 3.10 but has a python3.11 package). A candidate needs 3.11+
+    # and a working venv module: Debian splits ensurepip into a -venv package,
+    # and an interpreter without it cannot create the virtual environment.
+    PYTHON_CMD=""
+    local candidate version major minor found=""
+    for candidate in python3 python3.13 python3.12 python3.11 python; do
+        command -v "$candidate" &>/dev/null || continue
+        version=$("$candidate" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null) || continue
+        major=$(echo "$version" | cut -d'.' -f1)
+        minor=$(echo "$version" | cut -d'.' -f2)
+        if [[ "$major" -eq 3 ]] && [[ "$minor" -ge 11 ]]; then
+            if "$candidate" -c 'import ensurepip, venv' &>/dev/null; then
+                PYTHON_CMD="$candidate"
+                log_success "Found Python $version ($candidate)"
+                return 0
+            fi
+            found="$version (without the venv module)"
+            continue
         fi
-    fi
-
-    if [[ -z "$PYTHON_CMD" ]]; then
-        return 1
-    fi
-
-    # Check version >= 3.10
-    local version
-    version=$($PYTHON_CMD -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
-    local major minor
-    major=$(echo "$version" | cut -d'.' -f1)
-    minor=$(echo "$version" | cut -d'.' -f2)
+        [[ -n "$found" ]] || found="$version"
+    done
 
-    if [[ "$major" -lt 3 ]] || { [[ "$major" -eq 3 ]] && [[ "$minor" -lt 10 ]]; }; then
-        log_warn "Python $version found, but 3.10 or newer is required"
-        return 1
+    if [[ -n "$found" ]]; then
+        log_warn "Python $found found, but 3.11 or newer with venv support is required"
     fi
-
-    log_success "Found Python $version"
-    return 0
+    return 1
 }
 
 detect_timezone() {
@@ -290,15 +288,27 @@ install_dependencies() {
         apt)
             sudo apt-get update
             sudo apt-get install -y python3 python3-pip python3-venv git curl ffmpeg
+            # Ubuntu 22.04's python3 is 3.10; its python3.11 package is enough.
+            if ! detect_python >/dev/null 2>&1; then
+                sudo apt-get install -y python3.11 python3.11-venv || true
+            fi
             ;;
         dnf|yum)
             sudo $PKG_MANAGER install -y python3 python3-pip git curl ffmpeg
+            # RHEL/Rocky 9's python3 is 3.9; their python3.11 package is enough.
+            if ! detect_python >/dev/null 2>&1; then
+                sudo $PKG_MANAGER install -y python3.11 python3.11-pip || true
+            fi
             ;;
         pacman)
             sudo pacman -Sy --noconfirm python python-pip git curl ffmpeg
             ;;
         zypper)
             sudo zypper install -y python3 python3-pip git curl ffmpeg
+            # openSUSE Leap's python3 is 3.6; its python311 package is enough.
+            if ! detect_python >/dev/null 2>&1; then
+                sudo zypper install -y python311 || true
+            fi
             ;;
         brew)
             # Check if Homebrew is installed
@@ -407,15 +417,23 @@ setup_virtualenv() {
 
     cd "$INSTALL_PATH"
 
+    # A venv from an older Python (an install from before 3.11 was required)
+    # must be rebuilt: creating a venv over it keeps its old bin/python.
+    local venv_args=""
+    if [[ -x venv/bin/python ]] && ! venv/bin/python -c 'import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then
+        log_info "Rebuilding the virtual environment with Python 3.11+..."
+        venv_args="--clear"
+    fi
+
     if [[ "$OS_TYPE" == "macos" ]]; then
-        $PYTHON_CMD -m venv venv
+        $PYTHON_CMD -m venv $venv_args venv
         "$INSTALL_PATH/venv/bin/pip" install --upgrade pip
         "$INSTALL_PATH/venv/bin/pip" install -r requirements.txt
     else
         # Venv is owned by the service user, so pip must also run as that user —
         # otherwise `pip install --upgrade pip` fails trying to rewrite its own
         # binary inside the venv it doesn't own.
-        sudo -u "$SERVICE_USER" $PYTHON_CMD -m venv venv 2>/dev/null || $PYTHON_CMD -m venv venv
+        sudo -u "$SERVICE_USER" $PYTHON_CMD -m venv $venv_args venv 2>/dev/null || $PYTHON_CMD -m venv $venv_args venv
         sudo -u "$SERVICE_USER" "$INSTALL_PATH/venv/bin/pip" install --upgrade pip
         sudo -u "$SERVICE_USER" "$INSTALL_PATH/venv/bin/pip" install -r requirements.txt
     fi
@@ -1032,7 +1050,7 @@ main() {
 
     # Check/install Python
     if ! detect_python; then
-        log_info "Python 3.10+ not found, will install..."
+        log_info "Python 3.11+ not found, will install..."
     fi
 
     # Gather configuration

+ 7 - 0
install/update.sh

@@ -278,6 +278,13 @@ fi
 
 create_backup
 
+# The code needs Python 3.11+. An install whose venv is older has not started
+# since then anyway; say so before stopping anything rather than half-update it.
+VENV_PY="$(dirname "$VENV_PIP")/python"
+if [ -x "$VENV_PY" ] && ! "$VENV_PY" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)'; then
+  die "Bambuddy needs Python 3.11 or newer, but this install's environment uses $("$VENV_PY" -c 'import platform; print(platform.python_version())'). Nothing has been changed. How to move it to Python 3.11: https://wiki.bambuddy.cool/reference/troubleshooting/#bambuddy-needs-python-311-or-newer"
+fi
+
 log "Stopping service: $SERVICE_NAME"
 systemctl stop "$SERVICE_NAME"
 SERVICE_STOPPED=1

+ 7 - 0
install/update_macos.sh

@@ -287,6 +287,13 @@ fi
 
 create_backup
 
+# The code needs Python 3.11+. An install whose venv is older has not started
+# since then anyway; say so before stopping anything rather than half-update it.
+VENV_PY="$(dirname "$VENV_PIP")/python"
+if [ -x "$VENV_PY" ] && ! "$VENV_PY" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)'; then
+  die "Bambuddy needs Python 3.11 or newer, but this install's environment uses $("$VENV_PY" -c 'import platform; print(platform.python_version())'). Nothing has been changed. How to move it to Python 3.11: https://wiki.bambuddy.cool/reference/troubleshooting/#bambuddy-needs-python-311-or-newer"
+fi
+
 log "Stopping service: $SERVICE_NAME"
 launchctl unload "$PLIST_PATH"
 SERVICE_STOPPED=1

+ 4 - 5
pyproject.toml

@@ -2,10 +2,10 @@
 name = "bambuddy"
 version = "0.1.5"
 description = "Archive and manage Bambu Lab 3MF files"
-requires-python = ">=3.10"
+requires-python = ">=3.11"
 
 [tool.ruff]
-target-version = "py310"
+target-version = "py311"
 line-length = 120
 exclude = [
     ".git",
@@ -38,7 +38,8 @@ ignore = [
     "SIM108", # ternary operator (readability preference)
     "SIM102", # nested if (readability preference)
     "SIM105", # contextlib.suppress (readability preference)
-    "UP017",  # datetime.UTC alias (Python 3.11+ only, we support 3.10)
+    "UP017",  # datetime.UTC alias (style preference: the codebase uses timezone.utc)
+    "UP042",  # (str, Enum) -> StrEnum changes str() of the members; not a mechanical swap
 ]
 
 # Allow autofix for all enabled rules
@@ -54,8 +55,6 @@ unfixable = []
 "backend/app/main.py" = ["E402"]
 # MQTT client has some unused variables for debugging
 "backend/app/services/bambu_mqtt.py" = ["F841"]
-# compat module intentionally uses version checks for Python 3.10 support
-"backend/app/core/compat.py" = ["UP036"]
 
 [tool.ruff.lint.isort]
 known-first-party = ["backend"]