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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ jobs:

- name: Sync deps (frozen)
run: |
~/.cargo/bin/uv venv --python 3.13
~/.local/bin/uv venv --python 3.13
source .venv/bin/activate
~/.cargo/bin/uv sync --frozen
~/.local/bin/uv sync --frozen

- name: Lint & Test
run: |
Expand Down
1 change: 0 additions & 1 deletion .python-version

This file was deleted.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "prusa-connect"
dynamic = ["version"]
description = "Prusa Connect CLI and API client"
readme = "README.md"
requires-python = ">=3.14"
requires-python = ">=3.12"
dependencies = [
"requests>=2.32.5",
"structlog>=25.5.0",
Expand Down
43 changes: 41 additions & 2 deletions src/prusa_connect/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,42 @@
import importlib.metadata
from .client import PrusaConnectClient
from .exceptions import (
PrusaApiError,
PrusaAuthError,
PrusaConnectError,
PrusaNetworkError,
)
from .models import (
Camera,
File,
FileMeta,
Job,
JobInfo,
Owner,
Printer,
PrinterState,
SourceInfo,
SyncInfo,
Team,
Temperatures,
)
from .__version__ import __version__

__version__ = importlib.metadata.version("prusa-connect")
__all__ = [
"PrusaConnectClient",
"PrusaApiError",
"PrusaAuthError",
"PrusaConnectError",
"PrusaNetworkError",
"Camera",
"File",
"FileMeta",
"Job",
"JobInfo",
"Owner",
"Printer",
"PrinterState",
"SourceInfo",
"SyncInfo",
"Team",
"Temperatures",
]
90 changes: 83 additions & 7 deletions src/prusa_connect/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@
PrusaNetworkError,
)
from prusa_connect.models import Camera, File, Job, Printer, Team
from prusa_connect.__version__ import __version__

__all__ = ["PrusaConnectClient", "AuthStrategy"]

logger = structlog.get_logger()

DEFAULT_BASE_URL = "https://connect.prusa3d.com/app"
DEFAULT_TIMEOUT = 30.0


class AuthStrategy(Protocol):
Expand All @@ -39,22 +43,35 @@ class PrusaConnectClient:
Attributes:
token: The API Bearer token.
base_url: The API base URL.

Usage Example:
>>> from prusa_connect import PrusaConnectClient
>>> # Assume you have a credentials object
>>> client = PrusaConnectClient(credentials=my_creds)
>>> printers = client.get_printers()
"""

def __init__(self, credentials: AuthStrategy, base_url: str = DEFAULT_BASE_URL) -> None:
def __init__(
self,
credentials: AuthStrategy,
base_url: str = DEFAULT_BASE_URL,
timeout: float = DEFAULT_TIMEOUT,
) -> None:
"""Initializes the client.

Args:
credentials: An object adhering to the AuthStrategy protocol.
(e.g. PrusaConnectCredentials)
base_url: Optional override for the API endpoint.
timeout: Default timeout for API requests in seconds.
"""
self._base_url = base_url.rstrip("/")
self._credentials = credentials
self._timeout = timeout
self._session = requests.Session()
self._session.headers.update(
{
"User-Agent": "prusa-connect-python/0.1.0",
"User-Agent": f"prusa-connect-python/{__version__}",
"Accept": "application/json",
}
)
Expand All @@ -66,7 +83,7 @@ def _request(self, method: str, endpoint: str, raw: bool = False, **kwargs: Any)
method: HTTP method (GET, POST, etc.).
endpoint: API endpoint (e.g., '/printers').
raw: If True, return the raw response object instead of parsing JSON.
**kwargs: Additional arguments passed to requests.request.
**kwargs: Additional arguments passed to requests.request (e.g., timeout).

Returns:
The parsed JSON response, or the Requests Response object if raw=True.
Expand All @@ -81,11 +98,17 @@ def _request(self, method: str, endpoint: str, raw: bool = False, **kwargs: Any)
self._credentials.before_request(self._session.headers)

url = f"{self._base_url}/{endpoint.lstrip('/')}"
kwargs.setdefault("timeout", self._timeout)

try:
logger.debug("API Request", method=method, url=url)
response = self._session.request(method, url, **kwargs)
logger.debug("API Response", status_code=response.status_code, headers=response.headers, body_len=len(response.content))
logger.debug(
"API Response",
status_code=response.status_code,
headers=response.headers,
body_len=len(response.content),
)

if response.status_code in (401, 403):
raise PrusaAuthError("Invalid or expired credentials.")
Expand All @@ -109,15 +132,31 @@ def _request(self, method: str, endpoint: str, raw: bool = False, **kwargs: Any)
logger.error("Network error", error=str(e))
raise PrusaNetworkError(f"Failed to connect to Prusa Connect: {e}") from e

def api_request(self, method: str, endpoint: str, **kwargs) -> Any:
"""Public wrapper for making raw authenticated requests."""
def api_request(self, method: str, endpoint: str, **kwargs: Any) -> Any:
"""Public wrapper for making raw authenticated requests.

Args:
method: HTTP method (e.g. "GET", "POST").
endpoint: API endpoint (e.g. "/printers").
**kwargs: Arbitrary keyword arguments passed to the underlying
`requests.request` call (e.g. `json`, `data`, `timeout`).

Usage Example:
>>> response = client.api_request("GET", "/printers")
>>> print(response)
"""
return self._request(method, endpoint, **kwargs)

def get_printers(self) -> list[Printer]:
"""Fetch all printers associated with the account.

Returns:
A list of Printer objects.

Usage Example:
>>> printers = client.get_printers()
>>> for printer in printers:
... print(printer.name, printer.printer_state)
"""
data = self._request("GET", "/printers")

Expand All @@ -139,6 +178,10 @@ def get_printer(self, uuid: str) -> Printer:

Returns:
A Printer object.

Usage Example:
>>> printer = client.get_printer("c0ffee-uuid")
>>> print(printer.telemetry.temp_nozzle)
"""
data = self._request("GET", f"/printers/{uuid}")
return Printer.model_validate(data)
Expand All @@ -151,6 +194,11 @@ def get_file_list(self, team_id: int) -> list[File]:

Returns:
A list of File objects.

Usage Example:
>>> files = client.get_file_list(team_id=123)
>>> for file in files:
... print(file.name)
"""
# Note: The endpoint might vary based on your reverse engineering.
# Assuming /teams/{id}/files based on typical Prusa structure or similar.
Expand All @@ -165,6 +213,11 @@ def get_cameras(self) -> list[Camera]:

Returns:
A list of Camera objects.

Usage Example:
>>> cameras = client.get_cameras()
>>> for cam in cameras:
... print(cam.name)
"""
data = self._request("GET", "/cameras")
if isinstance(data, dict) and "cameras" in data:
Expand All @@ -176,6 +229,10 @@ def get_teams(self) -> list[Team]:

Returns:
A list of Team objects.

Usage Example:
>>> teams = client.get_teams()
>>> print(teams[0].name)
"""
data = self._request("GET", "/users/teams")
if isinstance(data, dict) and "teams" in data:
Expand All @@ -190,6 +247,10 @@ def get_team_jobs(self, team_id: int) -> list[Job]:

Returns:
A list of Job objects.

Usage Example:
>>> jobs = client.get_team_jobs(team_id=123)
>>> print(f"Found {len(jobs)} jobs")
"""
data = self._request("GET", f"/teams/{team_id}/jobs")
if isinstance(data, dict) and "jobs" in data:
Expand All @@ -204,6 +265,11 @@ def get_printer_jobs(self, printer_uuid: str) -> list[Job]:

Returns:
A list of Job objects.

Usage Example:
>>> jobs = client.get_printer_jobs("printer-uuid")
>>> if jobs:
... print(jobs[0].state)
"""
data = self._request("GET", f"/printers/{printer_uuid}/jobs")
if isinstance(data, dict) and "jobs" in data:
Expand All @@ -220,6 +286,9 @@ def send_command(self, printer_uuid: str, command: str, kwargs: dict | None = No

Returns:
True if successful.

Usage Example:
>>> client.send_command("printer-uuid", "PAUSE_PRINT")
"""
payload = {"command": command}
if kwargs:
Expand All @@ -237,6 +306,11 @@ def get_snapshot(self, camera_id: str) -> bytes:

Returns:
The binary image data.

Usage Example:
>>> image_data = client.get_snapshot(camera_id="cam-1")
>>> with open("snap.jpg", "wb") as f:
... f.write(image_data)
"""
# Raw response for binary data
response = self._request("GET", f"/cameras/{camera_id}/snapshots/last", raw=True)
Expand All @@ -250,7 +324,9 @@ def trigger_snapshot(self, camera_token: str) -> bool:

Returns:
True if triggered.

Usage Example:
>>> client.trigger_snapshot("camera-token-xyz")
"""
self._request("POST", f"/cameras/{camera_token}/snapshots")
return True

15 changes: 15 additions & 0 deletions src/prusa_connect/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@

from pydantic import AliasChoices, BaseModel, ConfigDict, Field

__all__ = [
"Camera",
"File",
"FileMeta",
"Job",
"JobInfo",
"Owner",
"Printer",
"PrinterState",
"SourceInfo",
"SyncInfo",
"Team",
"Temperatures",
]


class PrinterState(StrEnum):
IDLE = "IDLE"
Expand Down
74 changes: 74 additions & 0 deletions tests/test_client_improvements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from unittest import mock

import pytest
import prusa_connect
from prusa_connect import PrusaConnectClient
from prusa_connect.client import AuthStrategy


class MockCredentials(AuthStrategy):
def before_request(self, headers: dict[str, str]) -> None:
headers["Authorization"] = "Bearer mock_token"


@pytest.fixture
def client():
return PrusaConnectClient(credentials=MockCredentials())


def test_default_timeout(client):
"""Test that requests use the default timeout."""
with mock.patch.object(client._session, "request") as mock_request:
# Mock response to avoid errors
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {}
mock_response.content = b"{}"
mock_request.return_value = mock_response

client.get_printers()

mock_request.assert_called()
# Check that timeout=30.0 was passed
args, kwargs = mock_request.call_args
assert kwargs["timeout"] == 30.0


def test_custom_timeout():
"""Test that a custom timeout in __init__ is respected."""
client = PrusaConnectClient(credentials=MockCredentials(), timeout=10.0)
with mock.patch.object(client._session, "request") as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {}
mock_response.content = b"{}"
mock_request.return_value = mock_response

client.get_printers()

args, kwargs = mock_request.call_args
assert kwargs["timeout"] == 10.0


def test_override_timeout(client):
"""Test that per-request timeout overrides the default."""
with mock.patch.object(client._session, "request") as mock_request:
mock_response = mock.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {}
mock_response.content = b"{}"
mock_request.return_value = mock_response

client.api_request("GET", "/test", timeout=5.0)

args, kwargs = mock_request.call_args
assert kwargs["timeout"] == 5.0


def test_top_level_imports():
"""Test that important classes are exposed at the top level."""
assert hasattr(prusa_connect, "PrusaConnectClient")
assert hasattr(prusa_connect, "Printer")
assert hasattr(prusa_connect, "Job")
assert hasattr(prusa_connect, "PrinterState")
assert hasattr(prusa_connect, "PrusaApiError")
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading