Skip to content

Commit 0791c71

Browse files
tobixenclaude
andcommitted
refactor: consolidate duplicated request-handling logic into BaseDAVClient
Extract four shared helpers from the identical/near-identical code that existed in both _sync_request and _async_request: _prepare_request(url, method, body, headers) → (url_obj, combined_headers) Combines client headers with per-request headers, strips Content-Type for empty bodies, objectifies the URL, and emits the debug log line. Removes 7 identical lines from each client. _should_negotiate_auth(status_code, headers) -> bool The 401 + WWW-Authenticate + no-existing-auth + credentials-present condition, previously copy-pasted into both clients. _build_auth_from_401(www_authenticate) Calls extract_auth_types / build_auth_object and raises NotImplementedError if the server offers no supported method. Removes the identical 6-line block from each client. _raise_authorization_error(url_str, reason_source) -> NoReturn Extracts reason from reason_source.reason (AttributeError → "None given") and raises AuthorizationError. Removes the identical try/except block from each client. Side-effects: - to_normal_str import removed from both davclient.py and async_davclient.py (now only imported in base_client.py where it is used) - No behaviour changes; all 123 unit tests pass Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 572d07b commit 0791c71

3 files changed

Lines changed: 79 additions & 76 deletions

File tree

caldav/async_davclient.py

Lines changed: 6 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
from caldav.base_client import get_davclient as _base_get_davclient
6060
from caldav.compatibility_hints import FeatureSet
6161
from caldav.lib import error
62-
from caldav.lib.python_utilities import to_normal_str, to_wire
62+
from caldav.lib.python_utilities import to_wire
6363
from caldav.lib.url import URL
6464
from caldav.protocol.types import (
6565
CalendarQueryResult,
@@ -375,19 +375,7 @@ async def _async_request(
375375
Handles connection-abort workaround, 429/503 rate-limit detection,
376376
and 401 auth negotiation (including HTTP/2 fallback).
377377
"""
378-
headers = headers or {}
379-
380-
combined_headers = self.headers.copy()
381-
combined_headers.update(headers)
382-
if (body is None or body == "") and "Content-Type" in combined_headers:
383-
del combined_headers["Content-Type"]
384-
385-
# Objectify the URL
386-
url_obj = URL.objectify(url)
387-
388-
log.debug(
389-
f"sending request - method={method}, url={str(url_obj)}, headers={combined_headers}\nbody:\n{to_normal_str(body)}"
390-
)
378+
url_obj, combined_headers = self._prepare_request(url, method, body, headers)
391379

392380
# Build request kwargs - different for httpx vs niquests
393381
if _USE_HTTPX:
@@ -479,25 +467,9 @@ async def _async_request(
479467
# Handle 429/503 rate-limit responses
480468
error.raise_if_rate_limited(r.status_code, str(url_obj), r.headers.get("Retry-After"))
481469

482-
# Handle 401 responses for auth negotiation
483-
# httpx headers are already case-insensitive
484-
if (
485-
r.status_code == 401
486-
and "WWW-Authenticate" in r.headers
487-
and not self.auth
488-
and self.username is not None
489-
and self.password is not None # Empty password OK, but None means not configured
490-
):
491-
auth_types = self.extract_auth_types(r.headers["WWW-Authenticate"])
492-
self.build_auth_object(auth_types)
493-
494-
if not self.auth:
495-
raise NotImplementedError(
496-
"The server does not provide any of the currently "
497-
"supported authentication methods: basic, digest, bearer"
498-
)
499-
500-
# Retry request with authentication
470+
# Handle 401: negotiate auth then retry
471+
if self._should_negotiate_auth(r.status_code, r.headers):
472+
self._build_auth_from_401(r.headers["WWW-Authenticate"])
501473
return await self._async_request(url, method, body, headers)
502474

503475
elif (
@@ -530,11 +502,7 @@ async def _async_request(
530502

531503
# Raise AuthorizationError for 401/403 responses
532504
if response.status in (401, 403):
533-
try:
534-
reason = response.reason
535-
except AttributeError:
536-
reason = "None given"
537-
raise error.AuthorizationError(url=str(url_obj), reason=reason)
505+
self._raise_authorization_error(str(url_obj), response)
538506

539507
return response
540508

caldav/base_client.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,21 @@
77

88
from __future__ import annotations
99

10+
import logging
1011
from abc import ABC, abstractmethod
11-
from typing import TYPE_CHECKING, Any
12+
from collections.abc import Mapping
13+
from typing import TYPE_CHECKING, Any, NoReturn
1214

1315
from caldav.lib import error
1416
from caldav.lib.auth import extract_auth_types, select_auth_type
17+
from caldav.lib.python_utilities import to_normal_str
18+
from caldav.lib.url import URL
1519

1620
if TYPE_CHECKING:
1721
from caldav.compatibility_hints import FeatureSet
1822

23+
log = logging.getLogger("caldav")
24+
1925

2026
class BaseDAVClient(ABC):
2127
"""
@@ -127,6 +133,66 @@ def _select_auth_type(self, auth_types: list[str] | None = None) -> str | None:
127133

128134
return auth_type
129135

136+
def _prepare_request(
137+
self,
138+
url: str,
139+
method: str,
140+
body: str,
141+
headers: Mapping[str, str] | None,
142+
) -> tuple[URL, dict]:
143+
"""Combine headers, strip Content-Type for empty bodies, objectify URL, and log.
144+
145+
Returns:
146+
(url_obj, combined_headers) ready to pass to the HTTP library.
147+
"""
148+
headers = headers or {}
149+
combined_headers = self.headers.copy()
150+
combined_headers.update(headers)
151+
if (body is None or body == "") and "Content-Type" in combined_headers:
152+
del combined_headers["Content-Type"]
153+
url_obj = URL.objectify(url)
154+
log.debug(
155+
f"sending request - method={method}, url={str(url_obj)}, "
156+
f"headers={combined_headers}\nbody:\n{to_normal_str(body)}"
157+
)
158+
return url_obj, combined_headers
159+
160+
def _should_negotiate_auth(self, status_code: int, headers: Any) -> bool:
161+
"""Return True when a 401 response warrants auth negotiation.
162+
163+
True when: status is 401, WWW-Authenticate header present, no auth
164+
object yet, and credentials are configured.
165+
"""
166+
return (
167+
status_code == 401
168+
and "WWW-Authenticate" in headers
169+
and not self.auth
170+
and self.username is not None
171+
and self.password is not None
172+
)
173+
174+
def _build_auth_from_401(self, www_authenticate: str) -> None:
175+
"""Build auth object from a WWW-Authenticate header value.
176+
177+
Raises:
178+
NotImplementedError: If the server offers no supported auth method.
179+
"""
180+
auth_types = self.extract_auth_types(www_authenticate)
181+
self.build_auth_object(auth_types)
182+
if not self.auth:
183+
raise NotImplementedError(
184+
"The server does not provide any of the currently "
185+
"supported authentication methods: basic, digest, bearer"
186+
)
187+
188+
def _raise_authorization_error(self, url_str: str, reason_source: Any) -> NoReturn:
189+
"""Raise AuthorizationError, extracting reason from reason_source.reason."""
190+
try:
191+
reason = reason_source.reason
192+
except AttributeError:
193+
reason = "None given"
194+
raise error.AuthorizationError(url=url_str, reason=reason)
195+
130196
@abstractmethod
131197
def build_auth_object(self, auth_types: list[str] | None = None) -> None:
132198
"""

caldav/davclient.py

Lines changed: 6 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
from caldav.config import CONNKEYS # noqa: F401
5353
from caldav.elements import cdav, dav
5454
from caldav.lib import error
55-
from caldav.lib.python_utilities import to_normal_str, to_wire
55+
from caldav.lib.python_utilities import to_wire
5656
from caldav.lib.url import URL
5757
from caldav.requests import HTTPBearerAuth
5858
from caldav.response import BaseDAVResponse
@@ -979,25 +979,13 @@ def _sync_request(
979979
"""
980980
Sync HTTP request implementation with auth negotiation.
981981
"""
982-
headers = headers or {}
983-
984-
combined_headers = self.headers.copy()
985-
combined_headers.update(headers or {})
986-
if (body is None or body == "") and "Content-Type" in combined_headers:
987-
del combined_headers["Content-Type"]
988-
989-
# objectify the url
990-
url_obj = URL.objectify(url)
982+
url_obj, combined_headers = self._prepare_request(url, method, body, headers)
991983

992984
proxies = None
993985
if self.proxy is not None:
994986
proxies = {url_obj.scheme: self.proxy}
995987
log.debug("using proxy - %s" % (proxies))
996988

997-
log.debug(
998-
f"sending request - method={method}, url={str(url_obj)}, headers={combined_headers}\nbody:\n{to_normal_str(body)}"
999-
)
1000-
1001989
r = self.session.request(
1002990
method,
1003991
str(url_obj),
@@ -1015,33 +1003,14 @@ def _sync_request(
10151003
# Handle 429/503 responses: raise RateLimitError so the caller can decide whether to retry
10161004
error.raise_if_rate_limited(r.status_code, str(url_obj), r_headers.get("Retry-After"))
10171005

1018-
# Handle 401 responses for auth negotiation
1019-
if (
1020-
r.status_code == 401
1021-
and "WWW-Authenticate" in r_headers
1022-
and not self.auth
1023-
and self.username is not None
1024-
and self.password is not None # Empty password OK, but None means not configured
1025-
):
1026-
auth_types = self.extract_auth_types(r_headers["WWW-Authenticate"])
1027-
self.build_auth_object(auth_types)
1028-
1029-
if not self.auth:
1030-
raise NotImplementedError(
1031-
"The server does not provide any of the currently "
1032-
"supported authentication methods: basic, digest, bearer"
1033-
)
1034-
1035-
# Retry request with authentication
1006+
# Handle 401: negotiate auth then retry
1007+
if self._should_negotiate_auth(r.status_code, r_headers):
1008+
self._build_auth_from_401(r_headers["WWW-Authenticate"])
10361009
return self._sync_request(url, method, body, headers)
10371010

10381011
# Raise AuthorizationError for 401/403 after auth attempt
10391012
if r.status_code in (401, 403):
1040-
try:
1041-
reason = r.reason
1042-
except AttributeError:
1043-
reason = "None given"
1044-
raise error.AuthorizationError(url=str(url_obj), reason=reason)
1013+
self._raise_authorization_error(str(url_obj), r)
10451014

10461015
response = DAVResponse(r, self)
10471016
return response

0 commit comments

Comments
 (0)