diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57a967e..c14e6f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: | diff --git a/.python-version b/.python-version deleted file mode 100644 index 9c9b206..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ ->=3.14 diff --git a/pyproject.toml b/pyproject.toml index f8f9153..d2cf056 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/prusa_connect/__init__.py b/src/prusa_connect/__init__.py index ec855d5..9871a8d 100644 --- a/src/prusa_connect/__init__.py +++ b/src/prusa_connect/__init__.py @@ -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", +] diff --git a/src/prusa_connect/client.py b/src/prusa_connect/client.py index 660cbc7..fb37347 100644 --- a/src/prusa_connect/client.py +++ b/src/prusa_connect/client.py @@ -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): @@ -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", } ) @@ -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. @@ -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.") @@ -109,8 +132,19 @@ 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]: @@ -118,6 +152,11 @@ def get_printers(self) -> list[Printer]: 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") @@ -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) @@ -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. @@ -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: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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) @@ -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 - diff --git a/src/prusa_connect/models.py b/src/prusa_connect/models.py index 45dc9ce..2d646d0 100644 --- a/src/prusa_connect/models.py +++ b/src/prusa_connect/models.py @@ -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" diff --git a/tests/test_client_improvements.py b/tests/test_client_improvements.py new file mode 100644 index 0000000..8932399 --- /dev/null +++ b/tests/test_client_improvements.py @@ -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") diff --git a/uv.lock b/uv.lock index a24ccc3..104e05b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.14" +requires-python = ">=3.12" [[package]] name = "annotated-types"