From 618f3a5e2a090b100b07a48c74a6caa40336a4dd Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:37:36 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Improve=20Developer=20Experience=20with=20Z?= =?UTF-8?q?ero-Config=20Auth=20and=20Di=C3=A1taxis=20Docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Made `credentials` optional in `PrusaConnectClient`. - Added `PrusaConnectCredentials.load_default()` and `from_env()`. - Rewrote `README.md` using Diátaxis framework. - Added tests for new auth logic. Co-authored-by: dcode <171574+dcode@users.noreply.github.com> --- README.md | 181 ++++++++++++++++++++++++++++++++++++ src/prusa_connect/auth.py | 47 +++++++++- src/prusa_connect/client.py | 15 ++- tests/test_dx.py | 122 ++++++++++++++++++++++++ 4 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 tests/test_dx.py diff --git a/README.md b/README.md index e69de29..b10c2a3 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,181 @@ +# Prusa Connect Python Client + +[![PyPI version](https://badge.fury.io/py/prusa-connect.svg)](https://badge.fury.io/py/prusa-connect) +[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) +[![Python Versions](https://img.shields.io/pypi/pyversions/prusa-connect.svg)](https://pypi.org/project/prusa-connect/) + +Control your Prusa 3D printers programmatically with Python. This library provides a frictionless, strongly-typed interface for the Prusa Connect API. + +**Features:** +* **Zero-Config Authentication:** Log in once via CLI, use everywhere in Python. +* **Strong Typing:** Full Pydantic models for printers, jobs, cameras, and files. +* **Batteries Included:** Retries, timeouts, and error handling out of the box. +* **CLI Tool:** Managing printers from the terminal. + +--- + +## 1. Installation + +Install the package with the CLI tools (recommended for easiest setup): + +```bash +pip install "prusa-connect[cli]" +``` + +Or install the lightweight library only: + +```bash +pip install prusa-connect +``` + +## 2. Quickstart + +### Step 1: Authenticate + +Run the following command in your terminal to log in to your Prusa Account. This will save a secure token locally. + +```bash +prusactl list-printers +``` +*Follow the interactive prompts to enter your credentials and 2FA code.* + +### Step 2: Hello World + +Create a Python script (`hello_prusa.py`) to list your printers. The client automatically loads the credentials you just saved. + +```python +from prusa_connect import PrusaConnectClient + +# Credentials are automatically loaded from your environment or local file +client = PrusaConnectClient() + +print("My Printers:") +for printer in client.get_printers(): + status = printer.printer_state or "UNKNOWN" + print(f"- {printer.name} ({status})") + + if printer.telemetry: + print(f" Temp: {printer.telemetry.temp_nozzle}°C") +``` + +Run it: + +```bash +python hello_prusa.py +``` + +--- + +## 3. How-to Guides + +### Headless Authentication (CI/CD) + +For environments where interactive login isn't possible (like CI/CD pipelines or servers), you can use environment variables. + +1. **Option A: Raw Token** + Set the `PRUSA_TOKEN` environment variable with your raw JWT access token. + + ```bash + export PRUSA_TOKEN="ey..." + ``` + +2. **Option B: Token JSON** + Set the `PRUSA_TOKENS_JSON` environment variable with the full JSON object containing access and refresh tokens. + + ```bash + export PRUSA_TOKENS_JSON='{"access_token": "...", "refresh_token": "..."}' + ``` + +The `PrusaConnectClient` will automatically detect these variables. + +### Controlling a Printer + +You can send commands like PAUSE, RESUME, or STOP. + +```python +client = PrusaConnectClient() + +# Get your printer's UUID (e.g., from client.get_printers()) +printer_uuid = "c0ffee-uuid-1234" + +# Pause the print +client.send_command(printer_uuid, "PAUSE_PRINT") +print("Printer paused.") +``` + +### Accessing Cameras + +Fetch the latest snapshot from your printer's camera. + +```python +cameras = client.get_cameras() + +if cameras: + cam = cameras[0] + print(f"Taking snapshot from {cam.name}...") + + # Get binary image data + image_data = client.get_snapshot(cam.id) + + with open("snapshot.jpg", "wb") as f: + f.write(image_data) + print("Saved to snapshot.jpg") +``` + +### Managing Files + +List files on your team's storage. + +```python +teams = client.get_teams() +if teams: + my_team_id = teams[0].id + files = client.get_file_list(my_team_id) + + for file in files: + print(f"{file.name} ({file.size} bytes)") +``` + +--- + +## 4. Reference + +### `PrusaConnectClient` + +The main entry point for the API. + +**Initialization:** +```python +client = PrusaConnectClient( + credentials=None, # Auto-loads if None + base_url="https://connect.prusa3d.com/app", + timeout=30.0 +) +``` + +**Key Methods:** +* `get_printers() -> list[Printer]` +* `get_printer(uuid) -> Printer` +* `send_command(uuid, command)` +* `get_cameras() -> list[Camera]` +* `get_snapshot(camera_id) -> bytes` +* `get_team_jobs(team_id) -> list[Job]` + +### Data Models + +All responses are validated Pydantic models. + +* `Printer`: `uuid`, `name`, `printer_state`, `telemetry` (temps), `job` (current status). +* `Job`: `state`, `progress`, `time_remaining`, `file`. +* `Camera`: `id`, `name`, `resolution`. + +--- + +## 5. Explanation + +### Authentication Flow + +Prusa Connect uses a secure OAuth2-like flow with PKCE. +* **Interactive:** The CLI (`prusactl`) handles the complex exchange of username, password, and 2FA to obtain a **Refresh Token** and **Access Token**. +* **Refresh:** The `PrusaConnectClient` automatically checks if the Access Token is expired and uses the Refresh Token to get a new one, ensuring your long-running scripts don't break. +* **Storage:** Tokens are stored in `prusa_tokens.json` by default. Treat this file like a password. diff --git a/src/prusa_connect/auth.py b/src/prusa_connect/auth.py index ad5146b..28140c5 100644 --- a/src/prusa_connect/auth.py +++ b/src/prusa_connect/auth.py @@ -261,8 +261,11 @@ def before_request(self, headers: MutableMapping[str, str | bytes]) -> None: headers["Authorization"] = f"Bearer {self.tokens.access_token.raw_token}" @classmethod - def from_file(cls, path: Path) -> "PrusaConnectCredentials | None": + def from_file(cls, path: Path | str) -> "PrusaConnectCredentials | None": """Factory: Load credentials from a JSON file.""" + if isinstance(path, str): + path = Path(path) + try: logger.debug(f"Loading credentials from {path}") with path.open() as f: @@ -279,6 +282,48 @@ def save_to_disk(new_data): logger.info(f"No credentials found at {path.absolute()}") return None + @classmethod + def from_env(cls) -> "PrusaConnectCredentials | None": + """Factory: Load credentials from environment variables. + + Checks: + 1. PRUSA_TOKENS_JSON: A JSON string containing the full token set. + 2. PRUSA_TOKEN: A raw Access Token (JWT). + """ + if json_str := os.environ.get("PRUSA_TOKENS_JSON"): + try: + return cls(json.loads(json_str)) + except json.JSONDecodeError: + logger.warning("Invalid JSON in PRUSA_TOKENS_JSON") + + if token := os.environ.get("PRUSA_TOKEN"): + try: + # Pydantic will attempt to parse the string into the AccessToken model + return cls({"access_token": token}) + except Exception as e: + logger.debug(f"Could not create credentials from PRUSA_TOKEN: {e}") + + return None + + @classmethod + def load_default(cls) -> "PrusaConnectCredentials | None": + """Factory: Attempt to load credentials from default locations. + + Priority: + 1. Environment Variables (PRUSA_TOKENS_JSON, PRUSA_TOKEN) + 2. Local file 'prusa_tokens.json' + """ + # 1. Environment + if creds := cls.from_env(): + return creds + + # 2. Default file + default_path = Path("prusa_tokens.json") + if default_path.exists(): + return cls.from_file(default_path) + + return None + # --- PKCE & Login Flow Helpers --- diff --git a/src/prusa_connect/client.py b/src/prusa_connect/client.py index 3682c8b..771b90f 100644 --- a/src/prusa_connect/client.py +++ b/src/prusa_connect/client.py @@ -13,6 +13,7 @@ from urllib3.util import Retry from prusa_connect.__version__ import __version__ +from prusa_connect.auth import PrusaConnectCredentials from prusa_connect.exceptions import ( PrusaApiError, PrusaAuthError, @@ -62,7 +63,7 @@ class PrusaConnectClient: def __init__( self, - credentials: AuthStrategy, + credentials: AuthStrategy | None = None, base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT, ) -> None: @@ -70,11 +71,21 @@ def __init__( Args: credentials: An object adhering to the `AuthStrategy` protocol. - (e.g. `PrusaConnectCredentials`) + If None, attempts to load from environment or 'prusa_tokens.json'. base_url: Optional override for the API endpoint. timeout: Default timeout for API requests in seconds. """ self._base_url = base_url.rstrip("/") + + if credentials is None: + credentials = PrusaConnectCredentials.load_default() + + if credentials is None: + raise PrusaAuthError( + "No credentials provided and none found in default locations. " + "Please login via CLI (`prusactl list-printers`) or provide credentials explicitly." + ) + self._credentials = credentials self._timeout = timeout self._session = requests.Session() diff --git a/tests/test_dx.py b/tests/test_dx.py new file mode 100644 index 0000000..eb055a1 --- /dev/null +++ b/tests/test_dx.py @@ -0,0 +1,122 @@ +import json +from unittest.mock import MagicMock, patch + +import pytest + +from prusa_connect.auth import PrusaConnectCredentials +from prusa_connect.client import PrusaConnectClient +from prusa_connect.exceptions import PrusaAuthError +import base64 + +# Helper to encode base64url without padding +def b64url(data): + return base64.urlsafe_b64encode(json.dumps(data).encode()).decode().rstrip("=") + +def make_dummy_jwt(payload): + return f"{b64url({})}.{b64url(payload)}.sig" + +def test_credentials_load_default_env_json(monkeypatch): + """Test loading credentials from PRUSA_TOKENS_JSON.""" + + payload = { + "jti": "1", + "sub": 1, + "exp": 9999999999, + "sid": "s", + "app": "a", + "type": "access", + "connect_id": "c", + } + jwt_token = make_dummy_jwt(payload) + + data = { + "access_token": jwt_token, + "jti": "1", + "sub": 123, + "exp": 9999999999, + "sid": "session", + "app": "app", + "type": "access", + "connect_id": "cid", + } + monkeypatch.setenv("PRUSA_TOKENS_JSON", json.dumps(data)) + + # Ensure we don't pick up the file + with patch("pathlib.Path.exists", return_value=False): + creds = PrusaConnectCredentials.load_default() + assert creds is not None + assert creds.tokens.access_token.raw_token == jwt_token + + +def test_credentials_load_default_env_token(monkeypatch): + """Test loading credentials from PRUSA_TOKEN (raw JWT).""" + # Create a dummy payload that matches PrusaAccessToken fields + payload = { + "jti": "1", + "sub": 1, + "exp": 9999999999, + "sid": "s", + "app": "a", + "type": "access", + "connect_id": "c", + } + + dummy_jwt = make_dummy_jwt(payload) + + monkeypatch.setenv("PRUSA_TOKEN", dummy_jwt) + + with patch("pathlib.Path.exists", return_value=False): + creds = PrusaConnectCredentials.load_default() + assert creds is not None + assert creds.tokens.access_token.raw_token == dummy_jwt + + +def test_credentials_load_default_file(): + """Test loading credentials from prusa_tokens.json.""" + payload = { + "jti": "1", + "sub": 1, + "exp": 9999999999, + "sid": "s", + "app": "a", + "type": "access", + "connect_id": "c", + } + jwt_token = make_dummy_jwt(payload) + + data = { + "access_token": jwt_token, + "jti": "1", + "sub": 123, + "exp": 9999999999, + "sid": "session", + "app": "app", + "type": "access", + "connect_id": "cid", + } + + with patch("pathlib.Path.exists", return_value=True): + # Must patch pathlib.Path.open because load_default uses Path objects + with patch("pathlib.Path.open", new_callable=MagicMock) as mock_open: + with patch("json.load", return_value=data): + creds = PrusaConnectCredentials.load_default() + assert creds is not None + assert creds.tokens.access_token.raw_token == jwt_token + + +def test_client_init_no_creds_raises(): + """Test that Client raises PrusaAuthError if no creds found.""" + with patch("prusa_connect.auth.PrusaConnectCredentials.load_default", return_value=None): + with pytest.raises(PrusaAuthError) as exc: + PrusaConnectClient() + assert "No credentials provided" in str(exc.value) + + +def test_client_init_auto_load(): + """Test that Client automatically loads default credentials.""" + mock_creds = MagicMock() + with patch( + "prusa_connect.auth.PrusaConnectCredentials.load_default", return_value=mock_creds + ): + client = PrusaConnectClient() + assert client._credentials == mock_creds From b52ece43887d08ee324d90f092aff459a74f46cf Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:41:25 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Improve=20Developer=20Experience=20with=20Z?= =?UTF-8?q?ero-Config=20Auth=20and=20Di=C3=A1taxis=20Docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Made `credentials` optional in `PrusaConnectClient`. - Added `PrusaConnectCredentials.load_default()` and `from_env()`. - Rewrote `README.md` using Diátaxis framework. - Added tests for new auth logic. - Fixed linting errors in tests. Co-authored-by: dcode <171574+dcode@users.noreply.github.com> --- tests/test_dx.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/test_dx.py b/tests/test_dx.py index eb055a1..e051ef8 100644 --- a/tests/test_dx.py +++ b/tests/test_dx.py @@ -1,3 +1,4 @@ +import base64 import json from unittest.mock import MagicMock, patch @@ -6,18 +7,19 @@ from prusa_connect.auth import PrusaConnectCredentials from prusa_connect.client import PrusaConnectClient from prusa_connect.exceptions import PrusaAuthError -import base64 + # Helper to encode base64url without padding def b64url(data): return base64.urlsafe_b64encode(json.dumps(data).encode()).decode().rstrip("=") + def make_dummy_jwt(payload): return f"{b64url({})}.{b64url(payload)}.sig" + def test_credentials_load_default_env_json(monkeypatch): """Test loading credentials from PRUSA_TOKENS_JSON.""" - payload = { "jti": "1", "sub": 1, @@ -95,13 +97,14 @@ def test_credentials_load_default_file(): "connect_id": "cid", } - with patch("pathlib.Path.exists", return_value=True): - # Must patch pathlib.Path.open because load_default uses Path objects - with patch("pathlib.Path.open", new_callable=MagicMock) as mock_open: - with patch("json.load", return_value=data): - creds = PrusaConnectCredentials.load_default() - assert creds is not None - assert creds.tokens.access_token.raw_token == jwt_token + with ( + patch("pathlib.Path.exists", return_value=True), + patch("pathlib.Path.open", new_callable=MagicMock), + patch("json.load", return_value=data), + ): + creds = PrusaConnectCredentials.load_default() + assert creds is not None + assert creds.tokens.access_token.raw_token == jwt_token def test_client_init_no_creds_raises():