Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
# Full Python coverage on cheap Linux runners; one smoke job each on
# macOS (10x minute multiplier) and Windows (2x) at the newest Python.
os: [ubuntu-latest]
python-version: ["3.9", "3.12", "3.14"]
python-version: ["3.10", "3.12", "3.14"]
include:
- os: macos-latest
python-version: "3.14"
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version

## [Unreleased]

### Removed

- Python 3.9 support. The floor is 3.10 so every install resolves patched
`zeroconf` (no 3.9-only advisory residual).

### Changed

- The `[tui]` extra now requires Textual 8.x (`textual>=8.0,<9.0`). The
previous `<2.0` cap was hiding an 8.x `Select` API change (`Select.NULL`
replaced `Select.BLANK`/`False` for no selection). Pilot tests read
`Static.content` instead of the removed `renderable`.

- The TUI dashboard and monitor keep one MQTT TLS session for the process
instead of opening a new connection on every refresh. One-shot CLI commands
still connect and tear down.
Expand Down
16 changes: 6 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,16 @@ uv run python -W error::ResourceWarning -m pytest tests/ -m "not live" \
python scripts/syntax_smoke.py
python scripts/cli_help_smoke.py
uv run python tests/ci_workflow_smoke.py
uv run python tests/python_compat_smoke.py # 3.9 floor: syntax + PEP 604 unions
uv run python tests/python_compat_smoke.py # 3.10 floor: syntax parse
uv run python tests/dependency_resolution_smoke.py
uv run python tests/release_readiness_smoke.py
uv run python tests/privacy_smoke.py
uv run python tests/agent_cli_smoke.py
```

`python_compat_smoke.py` is worth running by hand before any push. The project
supports Python 3.9, most contributors do not develop on it, and the failure mode
is invisible locally: `X | None` (PEP 604) is accepted by ruff and mypy and runs
fine on 3.10+, but raises `TypeError` on 3.9 the moment it is evaluated in a
**runtime** position — a class base, a `cast()` argument, a `TypeVar` bound, a
module-level alias. `from __future__ import annotations` defers *annotations*
only and does not help there. Use `typing.Optional` / `typing.Union` instead.
`python_compat_smoke.py` parses the package and tests as Python 3.10 so a
3.11-only construct cannot sneak in on a newer laptop. The advertised floor
is 3.10.
`release_readiness_smoke.py` flags local `__pycache__` / `build` / `dist` noise
that CI's clean checkout does not have — expected locally, not a failure to chase.

Expand Down Expand Up @@ -103,8 +99,8 @@ uv run --python 3.12 --with pydantic python scripts/gen_schemas.py --check

The lint job additionally runs three greps (no test-awareness in production code,
`sys.exit` only in `cli.py`, no `@mockable`) and a `-m "security or contract"`
pytest pass. `gen_schemas.py` is pinned to 3.12 because it needs 3.10+ to evaluate
the contracts' `X | None` annotations — the package itself still runs on 3.9.
pytest pass. `gen_schemas.py` needs 3.10+ to evaluate the contracts' `X | None`
annotations and is pinned to 3.12 in CI.

CI pins these tool versions (see `.github/workflows/ci.yml`); running them unpinned locally is fine.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ model URL or file → download → slice (OrcaSlicer) → upload → pri

## Install

**Requirements:** Python 3.9+, and [OrcaSlicer](https://github.com/OrcaSlicer/OrcaSlicer/releases) installed locally if you want to slice. `plate slice` and `plate job` shell out to the OrcaSlicer binary; `download`, `status`, `upload`, and `print` do not need it. `plate setup` auto-detects the usual install locations (macOS app bundle, Windows Program Files, and on Linux a `$PATH` binary, Flatpak export, or AppImage), and `plate preflight` (or `plate config validate`) tells you if it can't find one.
**Requirements:** Python 3.10+, and [OrcaSlicer](https://github.com/OrcaSlicer/OrcaSlicer/releases) installed locally if you want to slice. `plate slice` and `plate job` shell out to the OrcaSlicer binary; `download`, `status`, `upload`, and `print` do not need it. `plate setup` auto-detects the usual install locations (macOS app bundle, Windows Program Files, and on Linux a `$PATH` binary, Flatpak export, or AppImage), and `plate preflight` (or `plate config validate`) tells you if it can't find one.

Fastest way to get OrcaSlicer, if you don't have it:

Expand Down
1 change: 0 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,6 @@ Tracked for hardening; not all are “bugs” in the sense of broken claims.
| **TOFU pin capture** | First-time fingerprint probe intentionally disables cert verification to *read* the pin. A MITM during setup can poison the pin if the LAN is already hostile. | Acknowledged |
| **Agent auto-`--confirm`** | Process/policy issue; code cannot stop intentional confirmation. | Out of process scope |
| **Third-party tools** | OrcaSlicer, gmsh, and the optional camera Docker image are outside this package’s SBOM boundary. | Out of scope |
| **zeroconf / pytest on Python 3.9** | The newest `zeroconf` and `pytest` releases that still support Python 3.9 (`0.148.0`, `8.4.2`) carry open moderate advisories, and **every patched release requires Python >= 3.10**, so they cannot be upgraded on the 3.9 resolution branch. On 3.10+ the dependency floor requires patched `zeroconf` (see `pyproject.toml`). Exposure on 3.9 is limited: the advisories are LAN-local DoS / cache-corruption reachable only from a hostile device on the same network, `zeroconf` is imported lazily and used solely for optional mDNS discovery during `plate setup` (which degrades gracefully and can be skipped by passing printer details manually), and `pytest` is a test-only extra that is never installed for end users or shipped in the wheel. Revisit when Python 3.9 support is dropped. | Accepted (3.9 only) |

## Scope

Expand Down
10 changes: 4 additions & 6 deletions bambu_cli/contracts/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,10 @@
used by the generator to derive JSON Schema from these dataclasses; it is never
imported at runtime and never ships to users.

**Annotations are never evaluated at runtime.** They use ``X | None`` (PEP 604),
which only *evaluates* on Python 3.10+. ``from __future__ import annotations``
keeps them as strings, and everything here reads fields via
``dataclasses.fields()`` rather than ``typing.get_type_hints()``, so the package
imports and works fine on the 3.9 floor. Only the generator resolves them, and
it requires 3.10+.
**Annotations are never evaluated at runtime.** They use ``X | None`` (PEP 604).
``from __future__ import annotations`` keeps them as strings, and everything
here reads fields via ``dataclasses.fields()`` rather than
``typing.get_type_hints()``. Only the schema generator resolves them.
"""

from __future__ import annotations
Expand Down
3 changes: 2 additions & 1 deletion bambu_cli/interactive/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
import os
import shutil
import tempfile
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any, Callable
from typing import Any

from bambu_cli import utils
from bambu_cli.constants import (
Expand Down
2 changes: 1 addition & 1 deletion bambu_cli/job/steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from typing import Callable

from bambu_cli.errors import BambuError

Expand Down
26 changes: 13 additions & 13 deletions bambu_cli/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import ssl
import threading
import time
from typing import Any, Optional
from typing import Any

from bambu_cli.protocols import ftps as ftps_protocol
from bambu_cli.protocols import mqtt as mqtt_protocol
Expand Down Expand Up @@ -34,7 +34,7 @@ def __init__(
serial: str,
access_code: str,
insecure_tls: bool = False,
cert_fingerprint: Optional[str] = None,
cert_fingerprint: str | None = None,
simulation_mode: bool = False,
):
self.ip = ip
Expand Down Expand Up @@ -80,13 +80,13 @@ def release_mqtt(self) -> None:
def mqtt_held(self) -> bool:
return self._mqtt_session is not None

def send_command(self, payload: str, timeout: Optional[float] = None, retries: int = 2) -> bool:
def send_command(self, payload: str, timeout: float | None = None, retries: int = 2) -> bool:
"""Send a JSON command payload via MQTT."""
return mqtt_protocol.send_command(self, payload, timeout=timeout, retries=retries)

def status(
self, timeout: Optional[float] = None, retries: int = 2, *, require_complete: bool = True
) -> Optional[dict[str, Any]]:
self, timeout: float | None = None, retries: int = 2, *, require_complete: bool = True
) -> dict[str, Any] | None:
"""Get the printer status via MQTT.

Returns a merged, complete state snapshot. Pass ``require_complete=False``
Expand All @@ -96,7 +96,7 @@ def status(
return mqtt_protocol.get_status(self, timeout=timeout, retries=retries, require_complete=require_complete)

@contextlib.contextmanager
def get_ftp_client(self, timeout: Optional[float] = None):
def get_ftp_client(self, timeout: float | None = None):
"""Context manager to get a connected FTP client."""
if timeout is None:
timeout = self.ftps_timeout
Expand All @@ -111,7 +111,7 @@ def get_ftp_client(self, timeout: Optional[float] = None):
except _FTP_SSL_ERRORS:
pass

def _probe_remote_size(self, ftp, remote_path: str) -> Optional[int]:
def _probe_remote_size(self, ftp, remote_path: str) -> int | None:
"""Best-effort remote file size lookup. Returns None if unavailable."""
try:
size = ftp.size(remote_path)
Expand All @@ -134,7 +134,7 @@ def upload_file(
self,
local_path: str,
remote_path: str,
timeout: Optional[float] = None,
timeout: float | None = None,
progress_callback=None,
on_resume=None,
sleep=time.sleep,
Expand Down Expand Up @@ -240,7 +240,7 @@ def upload_file(
return False

def download_file(
self, remote_path: str, local_path: str, timeout: Optional[float] = None, progress_callback=None
self, remote_path: str, local_path: str, timeout: float | None = None, progress_callback=None
) -> bool:
"""Download a file via FTPS into a temp sibling, then atomically replace
``local_path``. A dropped/failed transfer therefore never truncates or
Expand All @@ -254,7 +254,7 @@ def download_file(
import tempfile

directory = os.path.dirname(os.path.abspath(local_path)) or "."
partial_fd: Optional[int]
partial_fd: int | None
partial_fd, partial_path = tempfile.mkstemp(
prefix=f".{os.path.basename(local_path) or 'download'}.", suffix=".part", dir=directory
)
Expand Down Expand Up @@ -287,7 +287,7 @@ def download_file(
os.remove(partial_path)
return False

def delete_file(self, remote_path: str, timeout: Optional[float] = None) -> bool:
def delete_file(self, remote_path: str, timeout: float | None = None) -> bool:
"""Delete a file from the printer via FTPS."""
try:
with self.get_ftp_client(timeout=timeout or self.ftps_timeout) as ftp:
Expand All @@ -297,7 +297,7 @@ def delete_file(self, remote_path: str, timeout: Optional[float] = None) -> bool
logger.error(f"Delete failed: {e}")
return False

def list_files(self, remote_dir: str = "/model/", timeout: Optional[float] = None) -> Optional[list]:
def list_files(self, remote_dir: str = "/model/", timeout: float | None = None) -> list | None:
"""List files in a remote directory via FTPS."""
try:
with self.get_ftp_client(timeout=timeout or self.ftps_timeout) as ftp:
Expand All @@ -306,7 +306,7 @@ def list_files(self, remote_dir: str = "/model/", timeout: Optional[float] = Non
logger.error(f"List files failed: {e}")
return None

def get_version(self, timeout: Optional[float] = 5.0, retries: int = 1) -> Optional[list]:
def get_version(self, timeout: float | None = 5.0, retries: int = 1) -> list | None:
"""Get version info via MQTT."""
return mqtt_protocol.get_version(self, timeout=timeout, retries=retries)

Expand Down
3 changes: 2 additions & 1 deletion bambu_cli/protocols/mqtt_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
import json
import ssl
import threading
from typing import Any, Callable
from collections.abc import Callable
from typing import Any

from bambu_cli.errors import PrinterStatusIncomplete
from bambu_cli.logging_utils import logger
Expand Down
2 changes: 1 addition & 1 deletion bambu_cli/tlspin.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
import hmac
import re
import ssl
from typing import Callable
from collections.abc import Callable

# A normalized SHA-256 fingerprint: exactly 64 lowercase hex chars, no separators.
_HEX64_RE = re.compile(r"\A[0-9a-f]{64}\Z")
Expand Down
12 changes: 4 additions & 8 deletions bambu_cli/tui/screens/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

from __future__ import annotations

from typing import Any, Optional
from typing import Any

from rich.text import Text
from textual.app import ComposeResult
Expand Down Expand Up @@ -61,11 +61,7 @@
_NO_OVERRIDE_PROMPT = "(profile default)"


# NOTE: ``Optional[...]`` and not ``SliceOverrides | None``. A class base is a
# runtime expression -- ``from __future__ import annotations`` does not defer it
# -- and ``type | None`` is a TypeError before Python 3.10. tests/python_compat_smoke.py
# guards this; see the class-base rule there.
class SettingsScreen(Screen[Optional[SliceOverrides]]):
class SettingsScreen(Screen[SliceOverrides | None]):
"""Collect advanced slice overrides; dismisses with them, or None on cancel."""

# Header watches screen.sub_title; without this every screen claimed to be
Expand Down Expand Up @@ -172,7 +168,7 @@ def _initial_choice(self, dest: str, choices: tuple[str, ...]) -> Any:
"""Pre-selected dropdown value, or BLANK when there is no override."""
current = self._fields.get(dest)
text = "" if current is None else str(current)
return text if text in choices else Select.BLANK
return text if text in choices else Select.NULL

def on_mount(self) -> None:
self._refresh_override_list()
Expand Down Expand Up @@ -285,7 +281,7 @@ def _field_text(self, field: SettingField) -> str:
"""The form's current text for one field, whichever control renders it."""
if field.kind == "choice" and field.choices:
select = self.query_one(f"#{field.widget_id}", Select)
return "" if select.value is Select.BLANK else str(select.value)
return "" if select.is_blank() else str(select.value)
return self.query_one(f"#{field.widget_id}", Input).value

def action_apply(self) -> None:
Expand Down
31 changes: 9 additions & 22 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,14 @@ name = "platecli"
version = "0.5.0"
description = "platecli — local CLI for Bambu Lab printers (not affiliated with Bambu Lab)"
readme = "README.md"
requires-python = ">=3.9"
requires-python = ">=3.10"
license = "MIT"
authors = [{ name = "platecli contributors" }]
keywords = ["bambu", "3d-printing", "agent", "cli", "orcaslicer"]
dependencies = [
"rich>=13.0.0",
"paho-mqtt>=2.0,<3.0",
# Split by Python version: zeroconf >= 0.149.16 carries fixes for a set of
# LAN-local advisories (unbounded record cache, compression-pointer recursion,
# TC-deferred queue, exception-dedup retention, unvalidated rdlength) but
# requires Python >= 3.10. Users install from these constraints rather than
# uv.lock, so without the marker a 3.10+ user could still resolve a vulnerable
# build. 3.9 keeps the old floor because no patched release supports it — see
# SECURITY.md.
"zeroconf>=0.131,<1.0; python_version < '3.10'",
"zeroconf>=0.149.16,<1.0; python_version >= '3.10'",
"zeroconf>=0.149.16,<1.0",
]
classifiers = [
"Development Status :: 4 - Beta",
Expand All @@ -35,7 +27,6 @@ classifiers = [
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
Expand All @@ -47,28 +38,24 @@ classifiers = [

[project.optional-dependencies]
# Optional TUI extra: `pip install 'platecli[tui]'`. Textual is never a hard
# runtime dependency (interactive-mode-plan precedent) — `cmd_tui` import-guards
# it. Tested green against textual 1.0.0 (floor). Also tried 8.2.8 (current
# latest, whose requires-python still fits this project's >=3.9 floor): our
# dashboard tests fail there (`test_tui_dashboard.py`, 3 failures — rendered
# text comes back empty, an app.run_test()/pilot timing change between 1.x and
# 8.x). Staying capped below 2.0 until that TUI-test breakage is diagnosed and
# fixed on a newer textual.
tui = ["textual>=0.86,<2.0"]
# runtime dependency — `cmd_tui` import-guards it. Pinned to the current 8.x
# major (tested 8.2.8). 8.x dropped Static.renderable (tests read .content)
# and Select.BLANK-as-False (no-selection is Select.NULL).
tui = ["textual>=8.0,<9.0"]
test = [
"pytest",
"pytest-cov",
"pytest-mock",
"pytest-asyncio",
"mutmut>=3.0",
"hypothesis>=6.0",
"textual>=0.86,<2.0",
"textual>=8.0,<9.0",
# Build-time only: scripts/gen_schemas.py derives docs/schemas/*.json from
# the dataclasses in bambu_cli.contracts. Deliberately NOT a runtime
# dependency — bambu_cli never imports pydantic, because emit_json owns
# serialization (and its credential redaction). Keeping it here means users
# install 3 runtime deps, not 8, and no compiled wheel ships to them.
"pydantic>=2.0,<3.0; python_version >= '3.10'",
"pydantic>=2.0,<3.0",
]

[project.urls]
Expand Down Expand Up @@ -128,7 +115,7 @@ exclude_lines = [
]

[tool.ruff]
target-version = "py39"
target-version = "py310"
line-length = 120
extend-exclude = ["docs", "scripts"]

Expand Down
7 changes: 2 additions & 5 deletions scripts/gen_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@
serialization, because that pass applies credential redaction that a
``model_dump_json()`` would bypass.

Requires Python 3.10+: the contracts annotate optionals as ``X | None``, which
only *evaluates* on 3.10+. The package itself never evaluates them (it reads
``dataclasses.fields()``), so runtime support for the 3.9 floor is unaffected.
Requires Python 3.10+: the contracts annotate optionals as ``X | None``.
"""

from __future__ import annotations
Expand All @@ -34,8 +32,7 @@

if sys.version_info < (3, 10): # pragma: no cover -- guarded in CI by job config
raise SystemExit(
"gen_schemas.py needs Python 3.10+ to evaluate `X | None` annotations.\n"
"This is a dev/build tool only — the package still supports 3.9."
"gen_schemas.py needs Python 3.10+ to evaluate `X | None` annotations."
)

sys.path.insert(0, str(ROOT))
Expand Down
2 changes: 1 addition & 1 deletion tests/ci_workflow_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"linux runner": "ubuntu-latest",
"macos runner": "macos-latest",
"windows runner": "windows-latest",
"oldest supported python": '"3.9"',
"oldest supported python": '"3.10"',
"current smoke python": '"3.14"',
"unit tests": 'python -W error::ResourceWarning -m pytest tests/ -m "not live" --cov=bambu_cli --cov-report=term-missing --cov-fail-under=83',
"syntax smoke auto-discovery": "python scripts/syntax_smoke.py",
Expand Down
Loading
Loading