Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
a25463b
Revert "move response processing out of session"
AndreyNikiforov Sep 12, 2025
31a2d72
Replace session error test mocks with cassette-based approach
AndreyNikiforov Sep 11, 2025
e9ce0e4
refactor: replace mock-based session error tests with cassette-based …
AndreyNikiforov Sep 11, 2025
70ff117
refactor: remove unnecessary time.sleep mocks from session error tests
AndreyNikiforov Sep 11, 2025
3a90853
refactor: replace mock with VCR cassette for send_verification_code test
AndreyNikiforov Sep 11, 2025
25181e9
fix: update cassette to match Apple's actual API response format
AndreyNikiforov Sep 11, 2025
8069ec3
refactor: convert test_handle_albums_error to use cassette instead of…
AndreyNikiforov Sep 12, 2025
3da5f04
fix: correct albums error cassette to use 200 status with error payload
AndreyNikiforov Sep 12, 2025
3edee1e
refactor: convert more error tests to use cassettes instead of mocks
AndreyNikiforov Sep 12, 2025
ec7dc49
fix: clarify download error handling with cassettes
AndreyNikiforov Sep 12, 2025
f73f92b
fix: remove is_streaming_response check for proper error handling
AndreyNikiforov Sep 12, 2025
81f8a13
refactor: convert error simulation tests from mocks to cassettes
AndreyNikiforov Sep 12, 2025
cd53955
fix: update session error tests to expect successful retry
AndreyNikiforov Sep 12, 2025
b448c62
style: clean up test formatting for session error tests
AndreyNikiforov Sep 12, 2025
ecf7b70
skip 2sa failed auth test
AndreyNikiforov Sep 12, 2025
e261728
fix: remove incorrect delete operations from session error iteration …
AndreyNikiforov Sep 12, 2025
2d7017f
refactor: simplify test paths by replacing datetime calculations with…
AndreyNikiforov Sep 12, 2025
0fd9449
refactor: remove mocks from test_delete_after_download_session_error
AndreyNikiforov Sep 12, 2025
116babd
refactor: remove mocks from test_retry_delete_after_download_internal…
AndreyNikiforov Sep 12, 2025
a7b2620
remove unused use case test
AndreyNikiforov Sep 12, 2025
fcdc1da
test: simplify EXIF tests to download only one photo
AndreyNikiforov Sep 12, 2025
04dd7dd
refactor: update test_until_found* to use actual cassette downloads
AndreyNikiforov Sep 13, 2025
21ffa24
refactor: update size fallback tests to use cassette downloads
AndreyNikiforov Sep 13, 2025
869bec9
refactor: update dedup test for name-id7 to match standard pattern
AndreyNikiforov Sep 13, 2025
3afcd5c
refactor: update adjusted size fallback test
AndreyNikiforov Sep 13, 2025
183b512
fix asset size in cassettes
AndreyNikiforov Sep 13, 2025
1603865
refactor: remove mocks from test_download_two_sizes_with_force_size
AndreyNikiforov Sep 13, 2025
56c83af
refactor: remove mocks from live photo tests
AndreyNikiforov Sep 14, 2025
8e19d15
refactor: remove mocks from Chinese live photo tests
AndreyNikiforov Sep 14, 2025
86dafaf
refactor: remove mocks from delete-after-download dry-run tests
AndreyNikiforov Sep 14, 2025
9bedeb8
refactor: replace mocks with cassette in internal error test
AndreyNikiforov Sep 14, 2025
f64c7b2
refactor: share internal error download cassette between tests
AndreyNikiforov Sep 15, 2025
b9c6fa0
refactor: share error handling cassettes between standard and name-id…
AndreyNikiforov Sep 15, 2025
0fe970a
feat: add strict VCR cassette playback configuration
AndreyNikiforov Sep 15, 2025
07f8d74
remove error code overriding in tests
AndreyNikiforov Sep 15, 2025
9fd9f3d
fix: disable request reuse in cassettes
AndreyNikiforov Sep 15, 2025
63da2e1
refactor: remove mocks from test_force_size
AndreyNikiforov Sep 15, 2025
589705a
refactor: remove mocks from test_force_size_name_id7
AndreyNikiforov Sep 15, 2025
b32172d
remove unused imports
AndreyNikiforov Sep 15, 2025
2064c93
chore: update cassette files with modified content
AndreyNikiforov Sep 15, 2025
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
345 changes: 147 additions & 198 deletions src/pyicloud_ipd/base.py

Large diffs are not rendered by default.

118 changes: 60 additions & 58 deletions src/pyicloud_ipd/session.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import inspect
import json
import logging
from typing import Any, Callable, Mapping, NoReturn
import typing
from typing import Any, Callable, Dict, Mapping, NoReturn, Sequence

from requests import Response, Session
from typing_extensions import override
Expand Down Expand Up @@ -73,11 +74,13 @@ def request(self, method: str, url, **kwargs): # type: ignore

if "timeout" not in kwargs and self.service.http_timeout is not None:
kwargs["timeout"] = self.service.http_timeout

response = throw_on_503(
self.observe(handle_connection_error(super().request)(method, url, **kwargs))
)

content_type = response.headers.get("Content-Type", "").split(";")[0]
json_mimetypes = ["application/json", "text/json"]

request_logger.debug(response.headers)

for header, value in HEADER_DATA.items():
Expand All @@ -94,62 +97,61 @@ def request(self, method: str, url, **kwargs): # type: ignore
self.cookies.save(ignore_discard=True, ignore_expires=True) # type: ignore[attr-defined]
LOGGER.debug("Cookies saved to %s", self.service.cookiejar_path)

# content_type = response.headers.get("Content-Type", "").split(";")[0]
# json_mimetypes = ["application/json", "text/json"]

# if (
# not response.ok
# and content_type not in json_mimetypes
# and not is_streaming_response(response)
# ):
# self._raise_error(str(response.status_code), response.reason)

# if content_type not in json_mimetypes:
# if self.service.session_data.get("apple_rscd") == "401":
# code: str | None = "401"
# reason: str | None = "Invalid username/password combination."
# self._raise_error(code or "Unknown", reason or "Unknown")

# return response

# try:
# data = response.json() if response.status_code != 204 else {}
# except ValueError:
# request_logger.warning("Failed to parse response with JSON mimetype")
# return response

# request_logger.debug(data)

# if isinstance(data, dict):
# if data.get("hasError"):
# errors: Sequence[Dict[str, Any]] | None = typing.cast(
# Sequence[Dict[str, Any]] | None, data.get("service_errors")
# )
# # service_errors returns a list of dict
# # dict includes the keys: code, title, message, supressDismissal
# # Assuming a single error for now
# # May need to revisit to capture and handle multiple errors
# if errors:
# code = errors[0].get("code")
# reason = errors[0].get("message")
# self._raise_error(code or "Unknown", reason or "Unknown")
# elif not data.get("success"):
# reason = data.get("errorMessage")
# reason = reason or data.get("reason")
# reason = reason or data.get("errorReason")
# if not reason and isinstance(data.get("error"), str):
# reason = data.get("error")
# if not reason and data.get("error"):
# reason = "Unknown reason"

# code = data.get("errorCode")
# if not code and data.get("serverErrorCode"):
# code = data.get("serverErrorCode")
# if not code and data.get("error"):
# code = data.get("error")

# if reason:
# self._raise_error(code or "Unknown", reason)
# For error responses, raise exceptions for authentication/server errors
# but not for 404s which should be handled gracefully by the caller
if (
not response.ok
and response.status_code != 404
and (content_type not in json_mimetypes or response.status_code in [421, 450, 500])
):
self._raise_error(str(response.status_code), response.reason)

if content_type not in json_mimetypes:
if self.service.session_data.get("apple_rscd") == "401":
code: str | None = "401"
reason: str | None = "Invalid username/password combination."
self._raise_error(code or "Unknown", reason or "Unknown")

return response

try:
data = response.json() if response.status_code != 204 else {}
except ValueError:
request_logger.warning("Failed to parse response with JSON mimetype")
return response

request_logger.debug(data)

if isinstance(data, dict):
if data.get("hasError"):
errors: Sequence[Dict[str, Any]] | None = typing.cast(
Sequence[Dict[str, Any]] | None, data.get("service_errors")
)
# service_errors returns a list of dict
# dict includes the keys: code, title, message, supressDismissal
# Assuming a single error for now
# May need to revisit to capture and handle multiple errors
if errors:
code = errors[0].get("code")
reason = errors[0].get("message")
self._raise_error(code or "Unknown", reason or "Unknown")
elif not data.get("success"):
reason = data.get("errorMessage")
reason = reason or data.get("reason")
reason = reason or data.get("errorReason")
if not reason and isinstance(data.get("error"), str):
reason = data.get("error")
if not reason and data.get("error"):
reason = "Unknown reason"

code = data.get("errorCode")
if not code and data.get("serverErrorCode"):
code = data.get("serverErrorCode")
if not code and data.get("error"):
code = data.get("error")

if reason:
self._raise_error(code or "Unknown", reason)

return response

Expand Down
22 changes: 11 additions & 11 deletions tests/helpers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,16 +278,10 @@ def readline(self, size: int = -1) -> str: # type: ignore[override]
# Create both original and cleaned output
cleaned_output = "\n".join(cleaned_lines)

# For compatibility with old tests, adjust exit codes for specific error conditions
adjusted_exit_code = exit_code
if exit_code == 1 and "Invalid email/password combination" in raw_output:
# Authentication failure - old tests expect exit code 2
adjusted_exit_code = 2

# For compatibility, provide the cleaned output as the primary output
# but keep raw_output available if needed
result = TestResult(
exit_code=adjusted_exit_code,
exit_code=exit_code,
output=cleaned_output,
exception=exception,
stderr=stderr_capture.getvalue(),
Expand All @@ -303,15 +297,21 @@ def readline(self, size: int = -1) -> str: # type: ignore[override]


def run_with_cassette(cassette_path: str, f: Callable[[_T_contra], _T_co], inp: _T_contra) -> _T_co:
with vcr.use_cassette(cassette_path):
return f(inp)
with vcr.use_cassette(cassette_path, allow_playback_repeats=False) as _cassette:
result = f(inp)
# Check that all interactions were played (optional - can be enabled per test)
# assert cassette.all_played, f"Not all cassette interactions were used in {cassette_path}"
return result


def run_cassette(
cassette_path: str, params: Sequence[str], input: str | bytes | IO[Any] | None = None
) -> TestResult:
with vcr.use_cassette(cassette_path):
return print_result_exception(run_main_env(DEFAULT_ENV, params, input))
with vcr.use_cassette(cassette_path, allow_playback_repeats=False) as _cassette:
result = print_result_exception(run_main_env(DEFAULT_ENV, params, input))
# Check that all interactions were played (optional - can be enabled per test)
# assert _cassette.all_played, f"Not all cassette interactions were used in {cassette_path}"
return result


def add_cloned_master_cookie_dir(
Expand Down
Loading
Loading