From 166751dca02f954223afb98df8d261e0cfd4b275 Mon Sep 17 00:00:00 2001 From: Niklas Wagner Date: Thu, 19 Oct 2023 09:20:15 +0200 Subject: [PATCH 01/10] Switch to v1 API endpoints and use DigestAuth --- .github/workflows/test.yml | 4 +- pyprusalink/__init__.py | 144 ++++++++----------------------------- pyprusalink/client.py | 80 +++++++++++++++++++++ pyprusalink/types.py | 115 +++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 116 deletions(-) create mode 100644 pyprusalink/client.py create mode 100644 pyprusalink/types.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5860ef7..41f4307 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,8 +15,8 @@ jobs: strategy: matrix: python-version: - - "3.9" - - "3.10" + - "3.11" + - "3.12" steps: - uses: actions/checkout@v4.1.1 diff --git a/pyprusalink/__init__.py b/pyprusalink/__init__.py index 37286ae..6529f8e 100644 --- a/pyprusalink/__init__.py +++ b/pyprusalink/__init__.py @@ -1,146 +1,62 @@ """Prusalink API.""" from __future__ import annotations +from aiohttp import ClientSession -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from typing import TypedDict - -from aiohttp import ClientResponse, ClientSession - - -class PrusaLinkError(Exception): - """Base class for PrusaLink errors.""" - - -class InvalidAuth(PrusaLinkError): - """Error to indicate there is invalid auth.""" - - -class Conflict(PrusaLinkError): - """Error to indicate the command hit a conflict.""" - - -class VersionInfo(TypedDict): - """Version data.""" - - api: str - server: str - text: str - hostname: str - - -class PrinterInfo(TypedDict): - """Printer data.""" - - telemetry: dict - temperature: dict - state: dict - - -class JobInfo(TypedDict): - """Job data.""" - - state: str - job: dict | None - - -class FileInfo(TypedDict): - """File data.""" - - name: str - origin: str - size: int - refs: dict - - -class FilesInfo(TypedDict): - """Files data.""" - - files: list[FileInfo] +from pyprusalink.types import VersionInfo, PrinterInfo, PrinterStatus, JobInfo +from pyprusalink.client import ApiClient class PrusaLink: """Wrapper for the Prusalink API. Data format can be found here: - https://github.com/prusa3d/Prusa-Firmware-Buddy/blob/master/lib/WUI/link_content/basic_gets.cpp + https://github.com/prusa3d/Prusa-Link-Web/blob/master/spec/openapi.yaml """ - def __init__(self, session: ClientSession, host: str, api_key: str) -> None: + def __init__(self, session: ClientSession, host: str, username: str, password: str) -> None: """Initialize the PrusaLink class.""" - self._session = session - self.host = host - self._api_key = api_key + self.client = ApiClient(session=session, host=host, username=username, password=password) - async def cancel_job(self) -> None: + async def cancel_job(self, jobId: int) -> None: """Cancel the current job.""" - async with self.request("POST", "api/job", {"command": "cancel"}): + async with self.client.request("DELETE", f"/api/v1/job/{jobId}"): pass - async def resume_job(self) -> None: - """Resume a paused job.""" - async with self.request( - "POST", "api/job", {"command": "pause", "action": "resume"} - ): + async def pause_job(self, jobId: int) -> None: + """Pause a job.""" + async with self.client.request("PUT", f"/api/v1/job/{jobId}/pause"): pass - async def pause_job(self) -> None: - """Pause the current job.""" - async with self.request( - "POST", "api/job", {"command": "pause", "action": "pause"} - ): + async def resume_job(self, jobId: int) -> None: + """Resume a paused job.""" + async with self.client.request("PUT", f"/api/v1/job/{jobId}/resume"): pass async def get_version(self) -> VersionInfo: """Get the version.""" - async with self.request("GET", "api/version") as response: + async with self.client.request("GET", "/api/version") as response: return await response.json() - async def get_printer(self) -> PrinterInfo: + async def get_info(self) -> PrinterInfo: """Get the printer.""" - async with self.request("GET", "api/printer") as response: - return await response.json() - - async def get_job(self) -> JobInfo: - """Get current job.""" - async with self.request("GET", "api/job") as response: + async with self.client.request("GET", "/api/v1/info") as response: return await response.json() - async def get_file(self, path: str) -> FileInfo: - """Get specific file info.""" - async with self.request("GET", f"api/files{path}") as response: + async def get_status(self) -> PrinterStatus: + """Get the printer.""" + async with self.client.request("GET", "/api/v1/status") as response: return await response.json() - async def get_files(self) -> FilesInfo: - """Get all files.""" - async with self.request("GET", "api/files?recursive=true") as response: + async def get_job(self) -> JobInfo: + """Get current job.""" + async with self.client.request("GET", "/api/v1/job") as response: + # when there is no job running we'll an empty document that will fail to parse + if response.status == 204: + return {} return await response.json() - async def get_small_thumbnail(self, path: str) -> bytes: - """Get a small thumbnail.""" - async with self.request("GET", f"thumb/s{path}") as response: + # Prusa Link Web UI still uses the old endpoints and it seems that the new v1 endpoint doesn't support this yet + async def get_file(self, path: str) -> bytes: + """Get a files such as Thumbnails or Icons. Path comes from the current job['file']['refs']['thumbnail']""" + async with self.client.request("GET", path) as response: return await response.read() - - async def get_large_thumbnail(self, path: str) -> bytes: - """Get a large thumbnail.""" - async with self.request("GET", f"thumb/l{path}") as response: - return await response.read() - - @asynccontextmanager - async def request( - self, method: str, path: str, json: dict | None = None - ) -> AsyncGenerator[ClientResponse, None]: - """Make a request to the PrusaLink API.""" - url = f"{self.host}/{path}" - headers = {"X-Api-Key": self._api_key} - - async with self._session.request( - method, url, headers=headers, json=json - ) as response: - if response.status == 401: - raise InvalidAuth() - if response.status == 409: - raise Conflict() - - response.raise_for_status() - yield response diff --git a/pyprusalink/client.py b/pyprusalink/client.py new file mode 100644 index 0000000..9c2d75c --- /dev/null +++ b/pyprusalink/client.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import hashlib +import asyncio +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from aiohttp import ClientResponse, ClientSession + +from pyprusalink.types import InvalidAuth, Conflict + +class ApiClient: + def __init__(self, session: ClientSession, host: str, username: str, password: str) -> None: + self._session = session + self.host = host + self._username = username + self._password = password + self._lock = asyncio.Lock() + + self._realm = "" + self._nonce = "" + self._qop = "" + + def _generate_headers(self, method: str, path: str) -> dict: + ha1 = hashlib.md5(f"{self._username}:{self._realm}:{self._password}".encode()).hexdigest() + ha2 = hashlib.md5(f"{method}:{path}".encode()).hexdigest() + response_value = hashlib.md5(f"{ha1}:{self._nonce}:{ha2}".encode()).hexdigest() + + headers = { + 'Authorization': f'Digest username="{self._username}", realm="{self._realm}", ' + f'nonce="{self._nonce}", uri="{path}", response="{response_value}"' + } + + return headers + + def _extract_digest_params(self, headers: dict[str]) -> None: + header_value = headers.get('WWW-Authenticate', '') + if not header_value.startswith('Digest'): + return + + header_value = header_value[len('Digest '):] + + params = {} + parts = header_value.split(',') + for part in parts: + key, value = part.strip().split('=', 1) + params[key.strip()] = value.strip(' "') + + self._realm = params['realm'] + self._nonce = params['nonce'] + self._qop = params.get('qop', 'auth') + + @asynccontextmanager + async def request( + self, method: str, path: str, json_data: dict | None = None + ) -> AsyncGenerator[ClientResponse, None]: + """Make a request to the PrusaLink API.""" + async with self._lock: + url = f"{self.host}{path}" + headers = self._generate_headers(method=method, path=path) + async with self._session.request(method, url, json=json_data, headers=headers) as response: + if response.status == 401: + self._extract_digest_params(response.headers) + headers = self._generate_headers(method=method, path=path) + + async with self._session.request(method, url, json=json_data, headers=headers) as refreshed_response: + if refreshed_response.status == 401: + raise InvalidAuth() + if refreshed_response.status == 409: + raise Conflict() + + refreshed_response.raise_for_status() + + yield refreshed_response + return + + if response.status == 409: + raise Conflict() + + response.raise_for_status() + yield response diff --git a/pyprusalink/types.py b/pyprusalink/types.py new file mode 100644 index 0000000..c3be8e0 --- /dev/null +++ b/pyprusalink/types.py @@ -0,0 +1,115 @@ +from typing import TypedDict, Optional +from enum import Enum + + +class PrusaLinkError(Exception): + """Base class for PrusaLink errors.""" + + +class InvalidAuth(PrusaLinkError): + """Error to indicate there is invalid auth.""" + + +class Conflict(PrusaLinkError): + """Error to indicate the command hit a conflict.""" + + +class Capabilities(TypedDict): + """API Capabilities""" + upload_by_put: Optional[bool] + + +class VersionInfo(TypedDict): + """Version data.""" + api: str + version: str + printer: str + text: str + firmware: str + sdk: Optional[str] + capabilities: Optional[Capabilities] + + +class PrinterInfo(TypedDict): + """Printer informations.""" + mmu: Optional[bool] + name: Optional[str] + location: Optional[str] + farm_mode: Optional[bool] + nozzle_diameter: Optional[float] + min_extrusion_temp: Optional[int] + serial: Optional[str] + sd_ready: Optional[bool] + active_camera: Optional[bool] + hostname: Optional[str] + port: Optional[str] + network_error_chime: Optional[bool] + + +class StatusInfo(TypedDict): + """Status of the printer.""" + ok: Optional[bool] + message: Optional[str] + +class PrinterState(Enum): + IDLE = "IDLE" + BUSY = "BUSY" + PRINTING = "PRINTING" + PAUSED = "PAUSED" + FINISHED = "FINISHED" + STOPPED = "STOPPED" + ERROR = "ERROR" + ATTENTION = "ATTENTION" + READY = "READY" + +class PrinterStatusInfo(TypedDict): + """Printer information.""" + state: PrinterState + temp_nozzle: Optional[float] + target_nozzle: Optional[float] + temp_bed: Optional[float] + target_bed: Optional[float] + axis_x: Optional[float] + axis_y: Optional[float] + axis_z: Optional[float] + flow: Optional[int] + speed: Optional[int] + fan_hotend: Optional[int] + fan_print: Optional[int] + status_printer: Optional[StatusInfo] + status_connect: Optional[StatusInfo] + +class PrinterStatus(TypedDict): + """Printer status.""" + printer: PrinterStatusInfo + + +class PrintFileRefs(TypedDict): + """Additional Files for the current Job""" + download: Optional[str] + icon: Optional[str] + thumbnail: Optional[str] + + +class JobFilePrint(TypedDict): + """Currently printed file informations.""" + name: str + display_name: Optional[str] + path: str + display_path: Optional[str] + size: Optional[int] + m_timestamp: int + meta: Optional[dict] + refs: Optional[PrintFileRefs] + + +class JobInfo(TypedDict): + """Job information.""" + id: int + state: str + progress: int + time_remaining: Optional[int] + time_printing: int + inaccurate_estimates: Optional[bool] + serial_print: Optional[bool] + file: Optional[JobFilePrint] From 004416f672608bed31ebbf28cfc2bc530592f5fd Mon Sep 17 00:00:00 2001 From: Niklas Wagner Date: Sun, 5 Nov 2023 09:57:17 +0100 Subject: [PATCH 02/10] Format code --- pyprusalink/__init__.py | 8 ++++++-- pyprusalink/client.py | 45 ++++++++++++++++++++++++----------------- pyprusalink/types.py | 12 +++++++++++ 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/pyprusalink/__init__.py b/pyprusalink/__init__.py index 6529f8e..293e480 100644 --- a/pyprusalink/__init__.py +++ b/pyprusalink/__init__.py @@ -13,9 +13,13 @@ class PrusaLink: https://github.com/prusa3d/Prusa-Link-Web/blob/master/spec/openapi.yaml """ - def __init__(self, session: ClientSession, host: str, username: str, password: str) -> None: + def __init__( + self, session: ClientSession, host: str, username: str, password: str + ) -> None: """Initialize the PrusaLink class.""" - self.client = ApiClient(session=session, host=host, username=username, password=password) + self.client = ApiClient( + session=session, host=host, username=username, password=password + ) async def cancel_job(self, jobId: int) -> None: """Cancel the current job.""" diff --git a/pyprusalink/client.py b/pyprusalink/client.py index 9c2d75c..cecd053 100644 --- a/pyprusalink/client.py +++ b/pyprusalink/client.py @@ -8,8 +8,11 @@ from pyprusalink.types import InvalidAuth, Conflict + class ApiClient: - def __init__(self, session: ClientSession, host: str, username: str, password: str) -> None: + def __init__( + self, session: ClientSession, host: str, username: str, password: str + ) -> None: self._session = session self.host = host self._username = username @@ -21,58 +24,64 @@ def __init__(self, session: ClientSession, host: str, username: str, password: s self._qop = "" def _generate_headers(self, method: str, path: str) -> dict: - ha1 = hashlib.md5(f"{self._username}:{self._realm}:{self._password}".encode()).hexdigest() + ha1 = hashlib.md5( + f"{self._username}:{self._realm}:{self._password}".encode() + ).hexdigest() ha2 = hashlib.md5(f"{method}:{path}".encode()).hexdigest() response_value = hashlib.md5(f"{ha1}:{self._nonce}:{ha2}".encode()).hexdigest() headers = { - 'Authorization': f'Digest username="{self._username}", realm="{self._realm}", ' - f'nonce="{self._nonce}", uri="{path}", response="{response_value}"' + "Authorization": f'Digest username="{self._username}", realm="{self._realm}", ' + f'nonce="{self._nonce}", uri="{path}", response="{response_value}"' } return headers - + def _extract_digest_params(self, headers: dict[str]) -> None: - header_value = headers.get('WWW-Authenticate', '') - if not header_value.startswith('Digest'): + header_value = headers.get("WWW-Authenticate", "") + if not header_value.startswith("Digest"): return - header_value = header_value[len('Digest '):] + header_value = header_value[len("Digest ") :] params = {} - parts = header_value.split(',') + parts = header_value.split(",") for part in parts: - key, value = part.strip().split('=', 1) + key, value = part.strip().split("=", 1) params[key.strip()] = value.strip(' "') - self._realm = params['realm'] - self._nonce = params['nonce'] - self._qop = params.get('qop', 'auth') + self._realm = params["realm"] + self._nonce = params["nonce"] + self._qop = params.get("qop", "auth") @asynccontextmanager async def request( - self, method: str, path: str, json_data: dict | None = None + self, method: str, path: str, json_data: dict | None = None ) -> AsyncGenerator[ClientResponse, None]: """Make a request to the PrusaLink API.""" async with self._lock: url = f"{self.host}{path}" headers = self._generate_headers(method=method, path=path) - async with self._session.request(method, url, json=json_data, headers=headers) as response: + async with self._session.request( + method, url, json=json_data, headers=headers + ) as response: if response.status == 401: self._extract_digest_params(response.headers) headers = self._generate_headers(method=method, path=path) - async with self._session.request(method, url, json=json_data, headers=headers) as refreshed_response: + async with self._session.request( + method, url, json=json_data, headers=headers + ) as refreshed_response: if refreshed_response.status == 401: raise InvalidAuth() if refreshed_response.status == 409: raise Conflict() - + refreshed_response.raise_for_status() yield refreshed_response return - + if response.status == 409: raise Conflict() diff --git a/pyprusalink/types.py b/pyprusalink/types.py index c3be8e0..816b401 100644 --- a/pyprusalink/types.py +++ b/pyprusalink/types.py @@ -16,11 +16,13 @@ class Conflict(PrusaLinkError): class Capabilities(TypedDict): """API Capabilities""" + upload_by_put: Optional[bool] class VersionInfo(TypedDict): """Version data.""" + api: str version: str printer: str @@ -32,6 +34,7 @@ class VersionInfo(TypedDict): class PrinterInfo(TypedDict): """Printer informations.""" + mmu: Optional[bool] name: Optional[str] location: Optional[str] @@ -48,9 +51,11 @@ class PrinterInfo(TypedDict): class StatusInfo(TypedDict): """Status of the printer.""" + ok: Optional[bool] message: Optional[str] + class PrinterState(Enum): IDLE = "IDLE" BUSY = "BUSY" @@ -62,8 +67,10 @@ class PrinterState(Enum): ATTENTION = "ATTENTION" READY = "READY" + class PrinterStatusInfo(TypedDict): """Printer information.""" + state: PrinterState temp_nozzle: Optional[float] target_nozzle: Optional[float] @@ -79,13 +86,16 @@ class PrinterStatusInfo(TypedDict): status_printer: Optional[StatusInfo] status_connect: Optional[StatusInfo] + class PrinterStatus(TypedDict): """Printer status.""" + printer: PrinterStatusInfo class PrintFileRefs(TypedDict): """Additional Files for the current Job""" + download: Optional[str] icon: Optional[str] thumbnail: Optional[str] @@ -93,6 +103,7 @@ class PrintFileRefs(TypedDict): class JobFilePrint(TypedDict): """Currently printed file informations.""" + name: str display_name: Optional[str] path: str @@ -105,6 +116,7 @@ class JobFilePrint(TypedDict): class JobInfo(TypedDict): """Job information.""" + id: int state: str progress: int From b7aadc1bae52b4eecf7602c48044a200d85ce78c Mon Sep 17 00:00:00 2001 From: Niklas Wagner Date: Tue, 7 Nov 2023 18:53:15 +0100 Subject: [PATCH 03/10] Add back the legacy endpoint for material typ --- pyprusalink/__init__.py | 6 ++++++ pyprusalink/types.py | 4 ++++ pyprusalink/types_legacy.py | 13 +++++++++++++ 3 files changed, 23 insertions(+) create mode 100644 pyprusalink/types_legacy.py diff --git a/pyprusalink/__init__.py b/pyprusalink/__init__.py index 293e480..aa3271c 100644 --- a/pyprusalink/__init__.py +++ b/pyprusalink/__init__.py @@ -3,6 +3,7 @@ from aiohttp import ClientSession from pyprusalink.types import VersionInfo, PrinterInfo, PrinterStatus, JobInfo +from pyprusalink.types_legacy import LegacyPrinterStatus from pyprusalink.client import ApiClient @@ -41,6 +42,11 @@ async def get_version(self) -> VersionInfo: async with self.client.request("GET", "/api/version") as response: return await response.json() + async def get_legacy_printer(self) -> LegacyPrinterStatus: + """Get the legacy printer endpoint.""" + async with self.client.request("GET", "/api/printer") as response: + return await response.json() + async def get_info(self) -> PrinterInfo: """Get the printer.""" async with self.client.request("GET", "/api/v1/info") as response: diff --git a/pyprusalink/types.py b/pyprusalink/types.py index 816b401..b7c41e5 100644 --- a/pyprusalink/types.py +++ b/pyprusalink/types.py @@ -1,6 +1,8 @@ from typing import TypedDict, Optional from enum import Enum +"""Types of the v1 API. Source: https://github.com/prusa3d/Prusa-Link-Web/blob/master/spec/openapi.yaml""" + class PrusaLinkError(Exception): """Base class for PrusaLink errors.""" @@ -57,6 +59,8 @@ class StatusInfo(TypedDict): class PrinterState(Enum): + """Printer state as Enum.""" + IDLE = "IDLE" BUSY = "BUSY" PRINTING = "PRINTING" diff --git a/pyprusalink/types_legacy.py b/pyprusalink/types_legacy.py new file mode 100644 index 0000000..004baeb --- /dev/null +++ b/pyprusalink/types_legacy.py @@ -0,0 +1,13 @@ +from typing import TypedDict, Optional + +"""Legacy Types of the non v1 API. Source: https://github.com/prusa3d/Prusa-Link-Web/blob/master/spec/openapi-legacy.yaml""" + + +class LegacyPrinterTelemetry(TypedDict): + material: Optional[str] + + +class LegacyPrinterStatus(TypedDict): + """Printer status.""" + + telemetry: Optional[LegacyPrinterTelemetry] From 854007493a5ef92f4b31954dca878569059eebea Mon Sep 17 00:00:00 2001 From: Niklas Wagner Date: Tue, 21 Nov 2023 11:12:53 +0100 Subject: [PATCH 04/10] Switch to union type --- pyprusalink/types.py | 86 ++++++++++++++++++------------------- pyprusalink/types_legacy.py | 10 +++-- 2 files changed, 49 insertions(+), 47 deletions(-) diff --git a/pyprusalink/types.py b/pyprusalink/types.py index b7c41e5..89932f0 100644 --- a/pyprusalink/types.py +++ b/pyprusalink/types.py @@ -1,4 +1,4 @@ -from typing import TypedDict, Optional +from typing import TypedDict from enum import Enum """Types of the v1 API. Source: https://github.com/prusa3d/Prusa-Link-Web/blob/master/spec/openapi.yaml""" @@ -19,7 +19,7 @@ class Conflict(PrusaLinkError): class Capabilities(TypedDict): """API Capabilities""" - upload_by_put: Optional[bool] + upload_by_put: bool | None class VersionInfo(TypedDict): @@ -30,32 +30,32 @@ class VersionInfo(TypedDict): printer: str text: str firmware: str - sdk: Optional[str] - capabilities: Optional[Capabilities] + sdk: str | None + capabilities: Capabilities | None class PrinterInfo(TypedDict): """Printer informations.""" - mmu: Optional[bool] - name: Optional[str] - location: Optional[str] - farm_mode: Optional[bool] - nozzle_diameter: Optional[float] - min_extrusion_temp: Optional[int] - serial: Optional[str] - sd_ready: Optional[bool] - active_camera: Optional[bool] - hostname: Optional[str] - port: Optional[str] - network_error_chime: Optional[bool] + mmu: bool | None + name: str | None + location: str | None + farm_mode: bool | None + nozzle_diameter: float | None + min_extrusion_temp: int | None + serial: str | None + sd_ready: bool | None + active_camera: bool | None + hostname: str | None + port: str | None + network_error_chime: bool | None class StatusInfo(TypedDict): """Status of the printer.""" - ok: Optional[bool] - message: Optional[str] + ok: bool | None + message: str | None class PrinterState(Enum): @@ -76,19 +76,19 @@ class PrinterStatusInfo(TypedDict): """Printer information.""" state: PrinterState - temp_nozzle: Optional[float] - target_nozzle: Optional[float] - temp_bed: Optional[float] - target_bed: Optional[float] - axis_x: Optional[float] - axis_y: Optional[float] - axis_z: Optional[float] - flow: Optional[int] - speed: Optional[int] - fan_hotend: Optional[int] - fan_print: Optional[int] - status_printer: Optional[StatusInfo] - status_connect: Optional[StatusInfo] + temp_nozzle: float | None + target_nozzle: float | None + temp_bed: float | None + target_bed: float | None + axis_x: float | None + axis_y: float | None + axis_z: float | None + flow: int | None + speed: int | None + fan_hotend: int | None + fan_print: int | None + status_printer: StatusInfo | None + status_connect: StatusInfo | None class PrinterStatus(TypedDict): @@ -100,22 +100,22 @@ class PrinterStatus(TypedDict): class PrintFileRefs(TypedDict): """Additional Files for the current Job""" - download: Optional[str] - icon: Optional[str] - thumbnail: Optional[str] + download: str | None + icon: str | None + thumbnail: str | None class JobFilePrint(TypedDict): """Currently printed file informations.""" name: str - display_name: Optional[str] + display_name: str | None path: str - display_path: Optional[str] - size: Optional[int] + display_path: str | None + size: int | None m_timestamp: int - meta: Optional[dict] - refs: Optional[PrintFileRefs] + meta: dict | None + refs: PrintFileRefs | None class JobInfo(TypedDict): @@ -124,8 +124,8 @@ class JobInfo(TypedDict): id: int state: str progress: int - time_remaining: Optional[int] + time_remaining: int | None time_printing: int - inaccurate_estimates: Optional[bool] - serial_print: Optional[bool] - file: Optional[JobFilePrint] + inaccurate_estimates: bool | None + serial_print: bool | None + file: JobFilePrint | None diff --git a/pyprusalink/types_legacy.py b/pyprusalink/types_legacy.py index 004baeb..4437e77 100644 --- a/pyprusalink/types_legacy.py +++ b/pyprusalink/types_legacy.py @@ -1,13 +1,15 @@ -from typing import TypedDict, Optional +from typing import TypedDict """Legacy Types of the non v1 API. Source: https://github.com/prusa3d/Prusa-Link-Web/blob/master/spec/openapi-legacy.yaml""" class LegacyPrinterTelemetry(TypedDict): - material: Optional[str] + """Legacy Printer telemetry.""" + + material: str | None class LegacyPrinterStatus(TypedDict): - """Printer status.""" + """Legacy Printer status.""" - telemetry: Optional[LegacyPrinterTelemetry] + telemetry: LegacyPrinterTelemetry | None From aa228e246b8c3521615a4e8bc13395029042643a Mon Sep 17 00:00:00 2001 From: Niklas Wagner Date: Tue, 21 Nov 2023 11:18:15 +0100 Subject: [PATCH 05/10] isort: Fix import order --- pyprusalink/__init__.py | 6 +++--- pyprusalink/client.py | 6 +++--- pyprusalink/types.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pyprusalink/__init__.py b/pyprusalink/__init__.py index aa3271c..e80120b 100644 --- a/pyprusalink/__init__.py +++ b/pyprusalink/__init__.py @@ -1,10 +1,10 @@ """Prusalink API.""" from __future__ import annotations -from aiohttp import ClientSession -from pyprusalink.types import VersionInfo, PrinterInfo, PrinterStatus, JobInfo -from pyprusalink.types_legacy import LegacyPrinterStatus +from aiohttp import ClientSession from pyprusalink.client import ApiClient +from pyprusalink.types import JobInfo, PrinterInfo, PrinterStatus, VersionInfo +from pyprusalink.types_legacy import LegacyPrinterStatus class PrusaLink: diff --git a/pyprusalink/client.py b/pyprusalink/client.py index cecd053..530daa8 100644 --- a/pyprusalink/client.py +++ b/pyprusalink/client.py @@ -1,12 +1,12 @@ from __future__ import annotations -import hashlib import asyncio from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from aiohttp import ClientResponse, ClientSession +import hashlib -from pyprusalink.types import InvalidAuth, Conflict +from aiohttp import ClientResponse, ClientSession +from pyprusalink.types import Conflict, InvalidAuth class ApiClient: diff --git a/pyprusalink/types.py b/pyprusalink/types.py index 89932f0..9916391 100644 --- a/pyprusalink/types.py +++ b/pyprusalink/types.py @@ -1,5 +1,5 @@ -from typing import TypedDict from enum import Enum +from typing import TypedDict """Types of the v1 API. Source: https://github.com/prusa3d/Prusa-Link-Web/blob/master/spec/openapi.yaml""" From 3187f88fb29366aeee4058e8f70eb8df98604a76 Mon Sep 17 00:00:00 2001 From: Niklas Wagner Date: Fri, 24 Nov 2023 15:54:58 +0100 Subject: [PATCH 06/10] Use recrusive request code Co-authored-by: Stefan Agner --- pyprusalink/client.py | 43 ++++++++++++++++++------------------------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/pyprusalink/client.py b/pyprusalink/client.py index 530daa8..43fea41 100644 --- a/pyprusalink/client.py +++ b/pyprusalink/client.py @@ -56,34 +56,27 @@ def _extract_digest_params(self, headers: dict[str]) -> None: @asynccontextmanager async def request( - self, method: str, path: str, json_data: dict | None = None + self, method: str, path: str, json_data: dict | None = None, + try_auth: bool = True ) -> AsyncGenerator[ClientResponse, None]: """Make a request to the PrusaLink API.""" - async with self._lock: - url = f"{self.host}{path}" - headers = self._generate_headers(method=method, path=path) - async with self._session.request( - method, url, json=json_data, headers=headers - ) as response: - if response.status == 401: + url = f"{self.host}{path}" + + headers = self._generate_headers(method=method, path=path) + async with self._session.request( + method, url, json=json_data, headers=headers + ) as response: + if response.status == 401: + if try_auth: self._extract_digest_params(response.headers) - headers = self._generate_headers(method=method, path=path) - - async with self._session.request( - method, url, json=json_data, headers=headers - ) as refreshed_response: - if refreshed_response.status == 401: - raise InvalidAuth() - if refreshed_response.status == 409: - raise Conflict() - - refreshed_response.raise_for_status() - - yield refreshed_response + async with self.request(method, path, json_data, False) as response: + yield response return + else: + raise InvalidAuth() - if response.status == 409: - raise Conflict() + if response.status == 409: + raise Conflict() - response.raise_for_status() - yield response + response.raise_for_status() + yield response From 863292935f1c1d4d7f7365553f5e8101106fcf11 Mon Sep 17 00:00:00 2001 From: Niklas Wagner Date: Fri, 24 Nov 2023 15:55:54 +0100 Subject: [PATCH 07/10] Add descriptions to all functions --- pyprusalink/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyprusalink/client.py b/pyprusalink/client.py index 530daa8..c760125 100644 --- a/pyprusalink/client.py +++ b/pyprusalink/client.py @@ -24,6 +24,7 @@ def __init__( self._qop = "" def _generate_headers(self, method: str, path: str) -> dict: + """Generates new Authorization with the current _nonce, method and path.""" ha1 = hashlib.md5( f"{self._username}:{self._realm}:{self._password}".encode() ).hexdigest() @@ -38,6 +39,7 @@ def _generate_headers(self, method: str, path: str) -> dict: return headers def _extract_digest_params(self, headers: dict[str]) -> None: + """Extract realm, nonce key from Digest Auth header""" header_value = headers.get("WWW-Authenticate", "") if not header_value.startswith("Digest"): return From 23c35ec0ff0fb908f7ce79bd60ed85adbba2b5c1 Mon Sep 17 00:00:00 2001 From: Niklas Wagner Date: Fri, 24 Nov 2023 15:56:34 +0100 Subject: [PATCH 08/10] Add main.py to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c71ccfa..1a2e7bd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ venv *.egg-info *.log dist +main.py From 139bb38313d842dc7992ab9f1a80e827d37b2b5f Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Fri, 24 Nov 2023 17:55:50 +0100 Subject: [PATCH 09/10] Apply suggestions from code review --- pyprusalink/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyprusalink/client.py b/pyprusalink/client.py index 7fb47a3..99273e1 100644 --- a/pyprusalink/client.py +++ b/pyprusalink/client.py @@ -58,12 +58,11 @@ def _extract_digest_params(self, headers: dict[str]) -> None: @asynccontextmanager async def request( - self, method: str, path: str, json_data: dict | None = None, + self, method: str, path: str, json_data: dict | None = None, try_auth: bool = True ) -> AsyncGenerator[ClientResponse, None]: """Make a request to the PrusaLink API.""" url = f"{self.host}{path}" - headers = self._generate_headers(method=method, path=path) async with self._session.request( method, url, json=json_data, headers=headers From c9fba8fad261c80d45daa8e4df0b45b101d6e23f Mon Sep 17 00:00:00 2001 From: Niklas Wagner Date: Fri, 24 Nov 2023 21:01:02 +0100 Subject: [PATCH 10/10] Fix code formatting --- pyprusalink/client.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pyprusalink/client.py b/pyprusalink/client.py index 99273e1..c4e5f5f 100644 --- a/pyprusalink/client.py +++ b/pyprusalink/client.py @@ -58,8 +58,11 @@ def _extract_digest_params(self, headers: dict[str]) -> None: @asynccontextmanager async def request( - self, method: str, path: str, json_data: dict | None = None, - try_auth: bool = True + self, + method: str, + path: str, + json_data: dict | None = None, + try_auth: bool = True, ) -> AsyncGenerator[ClientResponse, None]: """Make a request to the PrusaLink API.""" url = f"{self.host}{path}"