Skip to content

Commit 62b41d4

Browse files
heikkihclaude
andcommitted
Remove DigestAuthWorkaround, require firmware 5.2.0+
Both upstream bugs that motivated the workaround are now fixed: - encode/httpx#3045 (httpx 0.27.0, 2024-02-21) - prusa3d/Prusa-Firmware-Buddy#3665 (firmware 5.2.0, 2024-02-06) Drops the DigestAuthWorkaround subclass and uses httpx.DigestAuth directly. Bumps the minimum httpx dependency to 0.27.0 and documents the firmware requirement in README.md. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3ef8cf0 commit 62b41d4

4 files changed

Lines changed: 17 additions & 87 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
# PyPrusaLink
22

33
Python library to interact with PrusaLink API v2.
4+
5+
## Requirements
6+
7+
- Python 3.11+
8+
- Prusa firmware **5.2.0 or newer** is required. Earlier firmware versions contain a bug
9+
in HTTP Digest authentication that was fixed in 5.2.0 (released February 2024).
10+
If you haven't upgraded in over a year, now is a good time.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ classifiers = [
2020
"Topic :: Home Automation",
2121
]
2222
dependencies = [
23-
"httpx",
23+
"httpx>=0.27.0",
2424
]
2525

2626
[tool.setuptools]

pyprusalink/client.py

Lines changed: 2 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,58 +2,18 @@
22

33
from collections.abc import AsyncGenerator
44
from contextlib import asynccontextmanager
5-
import hashlib
65

7-
from httpx import AsyncClient, DigestAuth, Request, Response
8-
from httpx._auth import _DigestAuthChallenge
6+
from httpx import AsyncClient, DigestAuth, Response
97
from pyprusalink.types import Conflict, InvalidAuth, NotFound
108

119

12-
# TODO remove after the following issues are fixed (in all supported firmwares for the latter one):
13-
# https://github.com/encode/httpx/pull/3045
14-
# https://github.com/prusa3d/Prusa-Firmware-Buddy/pull/3665
15-
class DigestAuthWorkaround(DigestAuth):
16-
"""Wrapper for httpx.DigestAuth to work around a firmware issue."""
17-
18-
# Taken from httpx.DigestAuth and modified
19-
# https://github.com/encode/httpx/blob/c6907c22034e2739c4c1af89908e3c9f90602788/httpx/_auth.py#L258
20-
def _build_auth_header(
21-
self, request: Request, challenge: "_DigestAuthChallenge"
22-
) -> str:
23-
if challenge.qop is not None:
24-
return super()._build_auth_header(request, challenge)
25-
26-
def digest(data: bytes) -> bytes:
27-
return hashlib.md5(data).hexdigest().encode()
28-
29-
A1 = b":".join((self._username, challenge.realm, self._password))
30-
HA1 = digest(A1)
31-
32-
path = request.url.raw_path
33-
A2 = b":".join((request.method.encode(), path))
34-
HA2 = digest(A2)
35-
36-
digest_data = [HA1, challenge.nonce, HA2]
37-
38-
format_args = {
39-
"username": self._username,
40-
"realm": challenge.realm,
41-
"nonce": challenge.nonce,
42-
"uri": path,
43-
"response": digest(b":".join(digest_data)),
44-
# Omitting algorithm as a work around for https://github.com/prusa3d/Prusa-Firmware-Buddy/pull/3665
45-
}
46-
47-
return "Digest " + self._get_header_value(format_args)
48-
49-
5010
class ApiClient:
5111
def __init__(
5212
self, async_client: AsyncClient, host: str, username: str, password: str
5313
) -> None:
5414
self._async_client = async_client
5515
self.host = host
56-
self._auth = DigestAuthWorkaround(username=username, password=password)
16+
self._auth = DigestAuth(username=username, password=password)
5717

5818
@asynccontextmanager
5919
async def request(

tests/test_client.py

Lines changed: 7 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1-
"""Tests for ApiClient error handling and DigestAuthWorkaround."""
2-
3-
from unittest.mock import MagicMock, patch
1+
"""Tests for ApiClient error handling."""
42

53
import httpx
6-
from httpx import DigestAuth
7-
from pyprusalink.client import DigestAuthWorkaround
4+
from pyprusalink.client import ApiClient
85
from pyprusalink.types import Conflict, InvalidAuth, NotFound
96
import pytest
107

@@ -32,42 +29,8 @@ async def test_not_found_raises(pl, respx_mock):
3229
await pl.cancel_job(1)
3330

3431

35-
def test_digest_workaround_omits_algorithm_without_qop():
36-
"""When the server sends no qop, the Authorization header must not include algorithm.
37-
38-
Old Prusa firmware (< 5.2.0) rejects Digest headers that contain an unquoted
39-
algorithm parameter. The workaround builds the header manually in that case.
40-
"""
41-
auth = DigestAuthWorkaround(username="maker", password="password")
42-
43-
challenge = MagicMock()
44-
challenge.qop = None
45-
challenge.realm = b"Printer API"
46-
challenge.nonce = b"testnonce123"
47-
48-
request = MagicMock()
49-
request.url.raw_path = b"/api/version"
50-
request.method = "GET"
51-
52-
header = auth._build_auth_header(request, challenge)
53-
54-
assert header.startswith("Digest ")
55-
assert "algorithm" not in header.lower()
56-
57-
58-
def test_digest_workaround_delegates_to_parent_with_qop():
59-
"""When qop is present the workaround delegates to the standard httpx implementation."""
60-
auth = DigestAuthWorkaround(username="maker", password="password")
61-
62-
challenge = MagicMock()
63-
challenge.qop = b"auth"
64-
65-
request = MagicMock()
66-
67-
with patch.object(
68-
DigestAuth, "_build_auth_header", return_value="Digest mocked=value"
69-
) as mock:
70-
result = auth._build_auth_header(request, challenge)
71-
mock.assert_called_once_with(request, challenge)
72-
73-
assert result == "Digest mocked=value"
32+
def test_uses_standard_digest_auth():
33+
"""ApiClient uses httpx.DigestAuth directly, without any subclassing."""
34+
async_client = httpx.AsyncClient()
35+
api = ApiClient(async_client, HOST, "maker", "password")
36+
assert type(api._auth) is httpx.DigestAuth

0 commit comments

Comments
 (0)