Skip to content

Add CloudKit-backed Photos service and sync support#217

Open
MrJarnould wants to merge 10 commits intotimlaing:mainfrom
MrJarnould:codex/photos-cloudkit-next
Open

Add CloudKit-backed Photos service and sync support#217
MrJarnould wants to merge 10 commits intotimlaing:mainfrom
MrJarnould:codex/photos-cloudkit-next

Conversation

@MrJarnould
Copy link
Copy Markdown

Breaking change

No breaking change is intended for supported public usage.

This PR adds a CloudKit-backed Photos implementation for private and Shared Library content while preserving the legacy Shared Albums stream behavior behind the shared library key. CloudKit-only commands now explicitly reject the legacy shared key and require root or a Shared Library key like shared:<zoneName>, which makes the service split explicit and avoids ambiguous or incorrect behavior.

Proposed change

This PR modernizes the Photos service by introducing a CloudKit-backed implementation for private and Shared Library photo access while keeping the legacy Shared Albums path available separately.

It adds support for library discovery and library-scoped access to root and Shared Library zones, and expands the Photos CLI with CloudKit-backed libraries, list, get, download, changes, sync-cursor, sync, and watch flows.

It also adds sync features and follow-up fixes validated during smoke testing, including:

  • Shared Library album filters for Library and Favorites
  • stateful sync and watch mode
  • live photo handling
  • RAW alignment
  • folder structure output
  • XMP sidecars
  • EXIF datetime writes
  • dry-run support
  • local auto-delete
  • remote keep-icloud-recent-days
  • root photo lookup fallback fixes
  • private album create/readback and album membership fixes
  • typed Photos CloudKit payload models
  • recently-added descending paging fixes for --recent

Type of change

  • Dependency upgrade
  • Bugfix (non-breaking change which fixes an issue)
  • New service (thank you!)
  • New feature (which adds functionality to an existing service)
  • Breaking change (fix/feature causing existing functionality to break)
  • Code quality improvements to existing code or addition of tests
  • Documentation or code sample

Example of code:

from pyicloud import PyiCloudService

api = PyiCloudService("user@example.com")
photos = api.photos

for key in photos.libraries:
    print(key)

shared_key = next(key for key in photos.libraries if key.startswith("shared:"))
shared = photos.libraries[shared_key]

photo = next(iter(shared.all))
print(photo.id, photo.filename)

Additional information

  • This PR fixes or closes issue: fixes #
  • This PR is related to issue: N/A
  • Local focused test coverage passed with:
    • UV_CACHE_DIR=/tmp/uv-cache UV_NO_EDITABLE=1 uv run --no-sync pytest tests/services/test_photos.py tests/services/test_photos_cloudkit_client.py tests/services/test_photos_sync.py tests/test_cmdline.py -q
    • 222 passed
  • Full live smoke coverage also passed, including root, private album-scoped, Shared Library, destructive cleanup, and RAW+JPEG --align-raw coverage.

Checklist

  • The code change is tested and works locally.
  • Local tests pass. Your PR cannot be merged unless tests pass
  • There is no commented out code in this PR.
  • Tests have been added to verify that the new code works.

If user exposed functionality or configuration variables are added/changed:

  • Documentation added/updated to README

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 444debdb-e159-4692-9ce0-013c567e5f03

📥 Commits

Reviewing files that changed from the base of the PR and between 4891999 and 08151d1.

📒 Files selected for processing (2)
  • pyicloud/services/photos_legacy.py
  • tests/services/test_photos.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • pyicloud/services/photos_legacy.py

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • New Photos CLI commands: libraries, get, changes, sync-cursor, sync, watch; incremental sync with JSON streaming.
    • Full CloudKit-backed Photos service, sync engine, state backends and upload/count helpers.
  • Improvements

    • New top-level upload helper: api.photos.upload(...).
    • CLI/library handling for Shared Library vs legacy streams; clearer browse vs sync docs and updated README examples.
  • Tests

    • Extensive Photos CloudKit fixtures and new unit/integration tests for client, sync and CLI flows.

Walkthrough

Splits Photos into CloudKit-backed and legacy modules, adds a typed CloudKit client and Pydantic models, implements a state-backed photo sync engine with SQLite/Memory and materialisation (XMP/EXIF), extends the CLI with photos libraries/get/changes/sync/sync-cursor/watch, and adds many fixtures and tests.

Changes

Cohort / File(s) Summary
Documentation
README.md
Updated CLI examples to use icloud photos ...; added "Photos CLI" section covering browse vs sync, shared:<zoneName> scoping and legacy shared adapter; updated Python upload example to api.photos.upload(...).
Examples
examples.py
Album example uses UTC timestamped names, renames via album.rename(...), and uploads via api.photos.upload(str(path), album=...).
CLI & Normalization
pyicloud/cli/commands/photos.py, pyicloud/cli/normalize.py
Centralised photos service resolution; added libraries, get, changes, sync-cursor, sync, watch commands and --library option; new photo normalizers and JSON/streaming watch output.
CloudKit core
pyicloud/common/cloudkit/client.py, pyicloud/common/cloudkit/models.py, pyicloud/common/cloudkit/__init__.py
New CloudKit transport and CloudKitContainerClient with typed ops, transport error mapping, and added zones-list/database-changes Pydantic models and exports.
Photos CloudKit package
pyicloud/services/photos_cloudkit/...
New package providing constants/enums, queries, mappers, materialize (XMP/EXIF/RAW alignment), models, Photos-specific CloudKit client, full CloudKit-backed PhotosService, sync state backends, and sync engine.
Photos facade & legacy
pyicloud/services/photos.py, pyicloud/services/photos_legacy.py, pyicloud/services/photos_cloudkit/__init__.py
Replaced monolithic photos.py with a facade re-exporting CloudKit and legacy modules; added photos_legacy for HTTP/shared-stream support and curated photos_cloudkit public surface.
Sync, state & materialize
pyicloud/services/photos_cloudkit/sync.py, .../state.py, .../materialize.py
New sync pipeline and PhotoSyncOptions, deterministic state paths, atomic downloads, .xmp sidecars, EXIF injection, RAW alignment policy, SQLite and in-memory state backends, and structured run results.
Photos CloudKit client (Photos-specific)
pyicloud/services/photos_cloudkit/client.py
Adapter over CloudKitContainerClient adding Photos endpoints: batch_count and upload_file with Photos-specific response validation and error handling.
Mappers, models & queries
pyicloud/services/photos_cloudkit/mappers.py, .../models.py, .../queries.py, .../constants.py
Record-to-domain mapping, resource builders, typed domain models (PhotoResource, PhotoChangeEvent, SmartAlbumSpec), query builders, enums/constants, and shared-library gating helpers.
Tests & fixtures
tests/fixtures/*, tests/services/test_photos_cloudkit_client.py, tests/services/test_photos_sync.py, tests/test_cmdline.py
Added 60+ CloudKit JSON fixtures and README; new unit tests for PhotosCloudKitClient and sync engine; expanded CLI tests and fakes to cover libraries/get/changes/sync/watch and materialisation behaviours.

Sequence Diagram(s)

sequenceDiagram
    participant CLI as "CLI (Typer)"
    participant Service as "PhotosService"
    participant CK as "CloudKitContainerClient"
    participant State as "PhotoSyncState (SQLite/Memory)"
    participant FS as "Filesystem"

    CLI->>Service: photos sync/watch(options)
    Service->>CK: zones_list / database_changes / query / iter_changes
    CK-->>Service: typed zone & record responses
    Service->>State: open state (create_photo_sync_state)
    State-->>Service: previous cursor & resources
    Service->>Service: select resources, apply RAW/live-photo rules
    Service->>FS: write files atomically, write .xmp, set EXIF
    FS-->>Service: write success/failure
    Service->>State: upsert_resource / delete_resource / set_sync_cursor
    Service-->>CLI: PhotoSyncResult (items, counters, cursor)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • timlaing

Poem

"🐇 I hopped through records, bytes and streams,
I stitched sync cursors into dreams.
CloudKit hummed while fixtures stacked in rows,
I wrote your XMP and fixed EXIF woes.
A carrot-clap — the tests all glowed!"

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.20% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title is directly relevant to the main changes in the changeset—it introduces CloudKit-backed Photos service and sync support, which aligns with the substantial additions across the photos_cloudkit module.
Description check ✅ Passed The pull request description is comprehensive and directly related to the changeset, detailing the CloudKit implementation, new CLI features, sync capabilities, test coverage, and documentation updates.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch codex/photos-cloudkit-next

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Pylint (4.0.5)
pyicloud/services/photos_legacy.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai bot added the enhancement New feature or request label Apr 10, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (21)
tests/fixtures/photos_zone_changes_response.json (1)

15-26: Consider the dual deletion representation.

The first record contains both an isDeleted field (lines 15-19, INT64 value 0) and a deleted property (line 26, false). Whilst this dual representation may be intentional for testing field-vs-property precedence or CloudKit's actual response format, ensure it accurately reflects the real API behaviour rather than introducing redundancy.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/fixtures/photos_zone_changes_response.json` around lines 15 - 26, The
fixture shows a record with both an "isDeleted" field and a top-level "deleted"
property which may be redundant; update the test fixture so it mirrors real API
behavior by removing one representation or aligning their values—either remove
the INT64 "isDeleted" field under "fields" or remove the top-level "deleted"
property, and ensure any tests that reference "isDeleted" or "deleted" (search
for "isDeleted" and "deleted" in test code) are updated to use the single
canonical indicator consistently.
pyicloud/services/photos_cloudkit/constants.py (1)

107-110: Make PRIMARY_ZONE immutable to prevent accidental mutation.

Using a mutable dict for a module-level constant can lead to subtle runtime side effects.

Suggested refactor
+from types import MappingProxyType
+
-PRIMARY_ZONE: dict[str, str] = {
-    "zoneName": "PrimarySync",
-    "zoneType": "REGULAR_CUSTOM_ZONE",
-}
+PRIMARY_ZONE = MappingProxyType(
+    {
+        "zoneName": "PrimarySync",
+        "zoneType": "REGULAR_CUSTOM_ZONE",
+    }
+)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/services/photos_cloudkit/constants.py` around lines 107 - 110,
PRIMARY_ZONE is currently a mutable module-level dict; replace it with an
immutable mapping by importing Mapping and MappingProxyType and assigning
PRIMARY_ZONE: Mapping[str, str] = MappingProxyType({"zoneName": "PrimarySync",
"zoneType": "REGULAR_CUSTOM_ZONE"}) so callers still get a mapping but attempts
to mutate will raise; update the type annotation and add the necessary imports
(from typing import Mapping and from types import MappingProxyType) and remove
direct dict usage.
README.md (1)

940-940: Add missing comma in the upload sentence.

Line 940 should read more cleanly as “To upload a photo, use the upload method.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` at line 940, Update the sentence in README where it currently
reads "To upload a photo use the `upload` method" to include the missing comma
so it reads "To upload a photo, use the `upload` method." Locate and edit the
exact line containing that phrase in the README.md.
tests/fixtures/photos_browser_mutations/README.md (1)

8-10: Polish sentence punctuation for readability.

Line 9 reads more cleanly with a comma after “instead”.

✏️ Suggested wording tweak
-identifiers. Stable placeholder values are used instead so request and response
+identifiers. Stable placeholder values are used instead, so request and response
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/fixtures/photos_browser_mutations/README.md` around lines 8 - 10, Edit
the README sentence that currently reads "Stable placeholder values are used
instead so request and response relationships remain testable without exposing
personal data." and insert a comma after "instead" so it reads "Stable
placeholder values are used instead, so request and response relationships
remain testable without exposing personal data." This is the only change needed
to improve punctuation and readability in the
tests/fixtures/photos_browser_mutations README sentence.
pyicloud/cli/normalize.py (1)

160-164: Make zone_name extraction resilient to typed zone_id objects.

Currently, Line 161-Line 164 only handles dict input; typed model instances will normalise to zone_name=None.

💡 Suggested fix
-    if isinstance(zone_id, dict):
-        zone_name = zone_id.get("zoneName")
-    else:
-        zone_name = None
+    if isinstance(zone_id, dict):
+        zone_name = zone_id.get("zoneName")
+    else:
+        zone_name = (
+            getattr(zone_id, "zoneName", None)
+            or getattr(zone_id, "zone_name", None)
+        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/cli/normalize.py` around lines 160 - 164, The current extraction of
zone_name from library.zone_id only handles dicts and falls back to None for
typed objects; update the logic around zone_id (from library) to handle dicts
and typed model instances by: if zone_id is a dict use zone_id.get("zoneName"),
otherwise if zone_id has attributes "zoneName" or "zone_name" use
getattr(zone_id, "zoneName", ...) or getattr(zone_id, "zone_name", ...) to pull
the value, otherwise set zone_name = None; apply this in the block referencing
zone_id and zone_name so typed objects normalize correctly.
tests/services/test_photos_sync.py (1)

169-176: Consider extracting repeated cleanup logic into a context manager or fixture.

The manual cleanup pattern in finally blocks is repeated across all tests. This could be simplified using a context manager, pytest fixture, or tempfile.TemporaryDirectory which handles cleanup automatically.

♻️ Example using TemporaryDirectory
import tempfile
from contextlib import contextmanager

`@contextmanager`
def sync_test_dir(prefix: str):
    with tempfile.TemporaryDirectory(prefix=prefix, dir=TEST_BASE) as temp_dir:
        yield Path(temp_dir)

# Usage in test:
def test_example():
    with sync_test_dir("photos-sync-") as temp_dir:
        output_dir = temp_dir / "output"
        state_dir = temp_dir / "state"
        # ... test logic ...

Also applies to: 212-218, 253-259, 282-288, 338-344, 374-380, 430-436, 473-479, 510-516, 541-547

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/services/test_photos_sync.py` around lines 169 - 176, Replace the
repeated manual cleanup in tests/services/test_photos_sync.py (the finally block
that walks temp_dir.rglob("*") and unlinks/rmdirs and then temp_dir.rmdir())
with a reusable context manager or pytest fixture that creates and auto-cleans
the temp directory (e.g., a sync_test_dir context manager using
tempfile.TemporaryDirectory or a tmp_path fixture); update each test that
currently uses the manual finally (references: temp_dir, temp_dir.rglob, and the
finally block) to use "with sync_test_dir(... ) as temp_dir:" or the fixture so
teardown is automatic and remove the duplicated cleanup code from those tests.
pyicloud/services/photos_legacy.py (2)

1734-1744: Response body fully read into memory without size limit.

The download() method reads the entire response body into memory with response.raw.read(). For very large photos or videos, this could cause memory pressure. The stream=True parameter is used correctly, but the full body is then loaded at once.

This is acceptable for typical photo sizes, but consider documenting or adding optional chunked download support for large video files in future iterations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/services/photos_legacy.py` around lines 1734 - 1744, The download()
method in photos_legacy.py currently calls response.raw.read() which loads the
entire response into memory; change it to stream the response in chunks instead:
use Response.iter_content(chunk_size=...) (or iterate over response.raw.read
into a provided file-like object) and either return a generator/iterator of
bytes or accept an optional destination/file handle or max_size parameter to
write chunks to disk, preventing unbounded memory use; locate the method named
download and the use of self.versions and response.raw.read to implement chunked
reads and optional streaming download behavior.

822-852: Pagination termination condition may cause premature exit or missed photos.

The pagination loop terminates when fewer than half the page size is returned (num_results < self.page_size // 2). This heuristic could prematurely exit if the API happens to return a batch that's slightly smaller than expected due to filtering or deduplication, potentially missing legitimate photos. Conversely, the comment suggests this is intentional to handle partial responses.

Consider documenting this behaviour more explicitly or using a more robust termination signal (e.g., absence of continuation marker or consistent empty response).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/services/photos_legacy.py` around lines 822 - 852, The current
photos property uses a fragile termination check (num_results < self.page_size
// 2) which can skip real photos; update the loop in photos to stop only when
the API explicitly signals no more results (use any continuation/has_more marker
returned by _get_photos_at if available) or, if the API provides no marker,
replace the half-page heuristic with a safer fallback such as breaking only on
num_results == 0 or after two consecutive batches smaller than page_size; ensure
you update the handling around _get_photos_at, num_results, page_size,
photos_ids and offset (used with DirectionEnum) accordingly so offset arithmetic
remains correct.
pyicloud/services/photos_cloudkit/client.py (2)

143-148: Bare except Exception masks specific failure modes.

The broad exception handler makes debugging difficult if unexpected errors occur. Consider catching more specific exceptions or at least logging the original exception type for diagnostics.

🔧 Proposed enhancement
         try:
             return data.batch[0].records[0].fields.itemCount.value
-        except Exception as exc:
+        except (IndexError, KeyError, AttributeError) as exc:
             raise CloudKitApiError(
                 "Photos count query failed", payload=data.model_dump(mode="json")
             ) from exc
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/services/photos_cloudkit/client.py` around lines 143 - 148, The
broad "except Exception as exc" around accessing
data.batch[0].records[0].fields.itemCount.value should be tightened: catch the
likely concrete failures (AttributeError, IndexError, KeyError) instead of
Exception, and when raising CloudKitApiError include the original exception
type/message in the raised error (e.g., add exc.__class__.__name__ and str(exc)
into the error message or payload) so diagnostics show the underlying cause
while preserving the existing data.model_dump(mode="json") payload; keep the
"from exc" chaining to retain the traceback.

108-148: Accessing private _http attribute couples implementation to internal client details.

The batch_count method accesses self._client._http.build_url(...), which relies on a private attribute. This creates tight coupling to the internal structure of CloudKitContainerClient. Consider exposing a build_url method on the public client interface or delegating the entire batch-count operation to the underlying client.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/services/photos_cloudkit/client.py` around lines 108 - 148, The
batch_count method currently reaches into a private attribute
(self._client._http.build_url) which couples it to CloudKitContainerClient
internals; change batch_count to use a public API instead (e.g., call a new or
existing self._client.build_url(...) or delegate the whole batch-count call to a
new public method on CloudKitContainerClient such as request_batch_count or
build_url that encapsulates URL construction and HTTP details). Update
batch_count to call that public method (or delegate the request) and remove
direct use of _http; ensure you reference the
PhotosBatchCountRequest/PhotosBatchCountResponse modeling and preserve error
handling (CloudKitApiError) while passing the payload and response through the
public client method.
pyicloud/cli/commands/photos.py (1)

955-961: Simplify redundant condition.

The condition iterations and iterations > 1 is redundant. Since iterations has min=1 validation (line 869), when iterations is truthy it's always >= 1. The check could be simplified.

Suggested fix
             if only_print_filenames:
-                if iterations is None or (iterations and iterations > 1):
+                if iterations is None or iterations > 1:
                     state.console.print(f"run {payload['iteration']}")
                 for item in payload["items"]:
                     state.console.print(item["path"])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/cli/commands/photos.py` around lines 955 - 961, The conditional "if
iterations is None or (iterations and iterations > 1)" is overly verbose;
simplify it in the photos command where only_print_filenames is handled by
replacing the redundant "(iterations and iterations > 1)" with "iterations > 1"
so the check becomes "if iterations is None or iterations > 1" (use the existing
variables iterations, only_print_filenames, payload and state.console.print to
locate the block and preserve the logic that prints "run {payload['iteration']}"
when iterations is unset or greater than 1).
pyicloud/services/photos_cloudkit/models.py (1)

160-163: Prefer TYPE_CHECKING over if False for type-only imports.

Using if False works but is unconventional. The standard pattern uses typing.TYPE_CHECKING which is more explicit and self-documenting.

Suggested fix
+from typing import TYPE_CHECKING
+
 # Import-only type hints to avoid circular imports at runtime.
-if False:  # pragma: no cover
+if TYPE_CHECKING:
     from .constants import DirectionEnum, ListTypeEnum, ObjectTypeEnum
     from .service import BasePhotoAlbum, PhotoAsset
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/services/photos_cloudkit/models.py` around lines 160 - 163, Replace
the nonstandard "if False:  # pragma: no cover" block used for import-only type
hints with the standard typing.TYPE_CHECKING guard: import TYPE_CHECKING from
typing and change the conditional to "if TYPE_CHECKING:" and keep the same names
(DirectionEnum, ListTypeEnum, ObjectTypeEnum, BasePhotoAlbum, PhotoAsset) inside
that block so type-only imports are clear and avoid runtime circular imports.
pyicloud/services/photos_cloudkit/sync.py (1)

622-632: Unbounded loop in _unique_relative_path could theoretically run indefinitely.

While extremely unlikely in practice, the while True loop has no upper bound. If the path collision logic has a bug, this could hang. Consider adding a reasonable iteration limit.

Suggested defensive limit
     discriminator = asset_id[:8]
     index = 1
-    while True:
+    max_attempts = 10000
+    while index <= max_attempts:
         suffix_bits = f"_{discriminator}" if index == 1 else f"_{discriminator}_{index}"
         next_path = (directory / f"{stem}{suffix_bits}{suffix}").as_posix()
         owner = tracked_paths.get(next_path)
         if next_path not in reserved_paths and (
             owner is None or owner == (asset_id, resource_key)
         ):
             return next_path
         index += 1
+    raise PhotosServiceException(
+        f"Could not find unique path for {candidate} after {max_attempts} attempts"
+    )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/services/photos_cloudkit/sync.py` around lines 622 - 632, The
_unique_relative_path function uses an unbounded while True loop with variables
discriminator and index to find a non-colliding next_path against reserved_paths
and tracked_paths; add a defensive maximum iteration limit (e.g., MAX_ATTEMPTS
constant) and increment index each loop, and if the limit is reached raise a
clear exception (e.g., RuntimeError or custom) including asset_id and
resource_key so callers can fail fast and debugging information is present;
update any callers or tests as needed to handle the new exception.
tests/services/test_photos_cloudkit_client.py (1)

180-192: Consider using a consistent mocking approach across tests.

Lines 185, 203, and 226 directly patch client._client._http.post, reaching two levels into private implementation details. This coupling makes tests brittle if the internal structure changes. The earlier tests (lines 36-56, 62-76) mock session.post at the session level which is more resilient.

If the typed client path requires different mocking, consider extracting a helper or documenting why the different approach is necessary.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/services/test_photos_cloudkit_client.py` around lines 180 - 192, The
test is directly mocking deep internals (client._client._http.post) which
couples tests to implementation; instead construct PhotosCloudKitClient with a
MagicMock session and mock session.post (the same pattern used earlier), e.g.,
pass a MagicMock as the session argument and set session.post.return_value to
ZONES_LIST_PAYLOAD before calling client.zones_list(), or extract a test helper
(e.g., make_mocked_photos_client(session_response)) that builds a
PhotosCloudKitClient and configures the session.post mock; update the assertions
to use that session-level mock and remove references to _client._http.post in
the test.
pyicloud/common/cloudkit/client.py (2)

211-237: In-place mutation of request for pagination could be made more explicit.

The iter_changes method mutates req.zones[0].syncToken in place (line 237) to advance pagination. While functional, this pattern can be surprising. A brief comment would improve clarity.

Suggested documentation
             if not zone.moreComing:
                 return
+            # Advance the sync token for the next page
             req.zones[0].syncToken = zone.syncToken
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/common/cloudkit/client.py` around lines 211 - 237, The iter_changes
function mutates req.zones[0].syncToken in-place to advance pagination which is
surprising; update the code to make this explicit by either (A) adding a short
clarifying comment immediately above the line that sets req.zones[0].syncToken
explaining that the request object is intentionally reused to carry the next
syncToken for subsequent pages, or (B) avoid in-place mutation by creating a new
CKZoneChangesRequest (copying existing req, updating the zones[0].syncToken) and
assigning it back to req before the next loop iteration; reference the
iter_changes function and the req.zones[0].syncToken update when making the
change.

89-91: Clarify the intent of defaulting non-integer status codes to 200.

The fallback code = 200 when status_code is not an integer appears designed to handle mocked responses, but it could inadvertently mask issues where a mock returns an unexpected type. Consider either:

  1. Raising an error for invalid response types
  2. Adding a comment explaining this is for mock compatibility

Also applies to: 122-124

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/common/cloudkit/client.py` around lines 89 - 91, The current
fallback of setting code = 200 when resp.status_code is not an int masks
unexpected/mock errors; change this to explicitly validate and raise a TypeError
(or ValueError) instead of silently defaulting: check getattr(resp,
"status_code", None), if it's not an int raise TypeError("Invalid
response.status_code: ...") referencing resp and its status_code value, and
apply the same change to the other identical block (the code/status_code
handling around lines 122-124) so tests that intentionally mock must provide an
int while real failures surface immediately.
pyicloud/services/photos_cloudkit/state.py (2)

173-197: Consider batching commits for bulk sync operations.

Each upsert_resource call commits immediately (line 197). During large sync runs with many resources, this could become a performance bottleneck. Consider either:

  1. Accepting an optional commit=True parameter
  2. Providing a batch upsert method
  3. Using a context manager pattern for transaction batching

This is a minor concern for typical usage but may matter for large libraries.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/services/photos_cloudkit/state.py` around lines 173 - 197, The
upsert_resource method commits every call which hurts performance for bulk
syncs; modify upsert_resource (and its use sites) to accept an optional commit:
bool = True parameter and only call self.conn.commit() when commit is True
(leaving callers able to defer commits), and add either a new
batch_upsert_resources(resources: Iterable[SyncedPhotoResource]) that executes
many INSERT ... ON CONFLICT statements inside a single transaction (begin/commit
around the loop) or provide a context manager on the state class that begins a
transaction on __enter__ and commits on __exit__ so callers can group multiple
upserts without committing each time; update callers to use the new parameter or
batch/context API.

85-86: PRAGMA foreign_keys=ON has no effect without foreign key constraints.

The schema does not define any foreign key constraints, so this pragma is a no-op. Either remove it or add the intended foreign key relationships.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/services/photos_cloudkit/state.py` around lines 85 - 86, The PRAGMA
enabling foreign keys is a no-op because no tables define FOREIGN KEY
constraints; either remove the line self._conn.execute("PRAGMA foreign_keys=ON")
from the connection setup or actually define the intended foreign key
relationships in the schema/table creation code so the pragma has effect (update
the table CREATE statements to include FOREIGN KEY clauses referencing the
related tables). Locate the connection initialization where
self._conn.execute("PRAGMA journal_mode=WAL") and self._conn.execute("PRAGMA
foreign_keys=ON") are called and choose one of the two fixes: remove the
foreign_keys pragma if no FK constraints are planned, or add explicit FOREIGN
KEY definitions to the relevant CREATE TABLE statements so the pragma is
meaningful.
pyicloud/services/photos_cloudkit/service.py (3)

320-325: Mock-specific branching in production constructor.

The if _is_mock_like(service) check hardcodes test behaviour into the production __init__. This special-casing makes the class harder to understand and maintain.

Consider either:

  1. Using a factory function that returns different implementations for testing
  2. Passing an explicit skip_indexing_check parameter
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/services/photos_cloudkit/service.py` around lines 320 - 325, The
constructor contains test-specific branching via _is_mock_like(service) and
type(self).__name__ != "PhotoLibrary"; remove this implicit mock detection and
instead either (a) add an explicit constructor parameter like
skip_indexing_check: bool = False (or skip_indexing: bool = False) to the class
__init__ and use that to set self._indexing_state = "FINISHED" and return when
True, or (b) create a factory function (e.g., make_photo_library_for_test /
PhotoLibraryTestStub factory) that returns a test-specific implementation that
sets _indexing_state without changing production __init__; update callers/tests
to pass the new flag or use the factory and delete the _is_mock_like(...)
branching and the type(self).__name__ check from the production constructor.

1118-1125: Early exit condition may under-fetch with small page sizes.

The condition num_results < self.page_size // 2 could cause premature exit when page_size is small (e.g., if page_size is 1, then page_size // 2 is 0, and any result would exit). Given page_size is capped at 100 (line 926), this is unlikely to be an issue, but the logic is subtle.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/services/photos_cloudkit/service.py` around lines 1118 - 1125, The
loop's early-exit check using `num_results < self.page_size // 2` can
prematurely stop fetching when `page_size` is small; change the condition in the
loop that follows `seen.add(photo.id)`/`yield photo` to require either no
results or apply a minimum threshold, e.g. `if num_results == 0: break` or `if
num_results < max(1, self.page_size // 2): break`, so that `_direction`/`offset`
adjustments and pagination continue correctly for small `page_size` values;
update the conditional referencing `num_results`, `self.page_size`, and keep
`self._direction`/`DirectionEnum` logic unchanged.

11-11: Production code depends on unittest.mock.Mock for runtime behaviour.

Importing Mock from unittest.mock in production code and using _is_mock_like() checks throughout creates tight coupling between production and test code. This pattern:

  1. Adds test framework as a production dependency
  2. Makes the code harder to reason about
  3. Could mask bugs if mock detection fails

Consider using dependency injection or a dedicated test double interface instead.

Also applies to: 215-224

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/services/photos_cloudkit/service.py` at line 11, The code imports
unittest.mock.Mock and uses _is_mock_like() runtime checks, coupling production
to test framework; replace direct dependence on Mock by removing the import and
change _is_mock_like to use an injectable predicate or duck-typed check instead:
add an optional parameter (e.g., is_mock_like: Callable[..., bool]) to the
PhotosCloudKitService constructor or to the functions that call _is_mock_like,
defaulting to a conservative runtime check (e.g., check for specific
attributes/methods used by the code) so tests can pass a mock-detection helper
without importing unittest.mock in production; update all usages of
_is_mock_like (including the occurrences around the 215-224 region) to call the
injected predicate and remove any direct isinstance(..., Mock) checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@examples.py`:
- Around line 450-451: The disposable album naming uses
datetime.utcnow().strftime with second-level precision (album_name) which can
collide on rapid retries; update album_name (and consequently renamed_name) to
include higher precision or a random suffix—e.g., append microseconds via "%f"
in the strftime pattern or add a short uuid (uuid.uuid4().hex[:8])—so
functions/variables album_name and renamed_name become collision-safe in
parallel or fast re-run scenarios.

In `@pyicloud/services/photos_cloudkit/models.py`:
- Around line 145-150: Fix the docstring typo in the PhotosUploadError model:
update the class PhotosUploadError's docstring to replace "uploadimagews" with a
clear phrase such as "upload images" (e.g., "One upload images error item.") so
the docstring reads correctly and describes the error item for upload images.

In `@pyicloud/services/photos_cloudkit/sync.py`:
- Around line 704-707: The code assumes asset.asset_date exists and is not None;
change to safely fetch and validate it: use asset_date = getattr(asset,
"asset_date", None) and if asset_date is None (or not a datetime-like object)
return False before calling .astimezone(); then compute age_days = (now_local -
asset_date.astimezone()).days and compare to options.keep_icloud_recent_days.
This prevents AttributeError and handles None asset_date gracefully (refer to
asset, asset_date, now_local, and options.keep_icloud_recent_days).
- Around line 260-262: The comparison can raise TypeError when asset.added_date
is None; in the loop over assets from _iter_sync_assets, guard before comparing:
retrieve added_date = getattr(asset, "added_date", None) and skip the asset
(continue) or treat missing as older/newer according to intent if added_date is
None, otherwise compare added_date < cutoff; update the loop in sync.py to use
this explicit None-check around the comparison to avoid None < cutoff errors.

In `@pyicloud/services/photos_legacy.py`:
- Around line 341-358: The code reads
response["records"][0]["fields"]["state"]["value"] without verifying the
CloudKit response shape, which can raise IndexError/KeyError; update the block
after request.json() to defensively validate that response is a dict with a
non-empty "records" list and that the first record contains
"fields"->"state"->"value" (or use chained .get checks or a try/except around
the extraction), and if validation fails log a debug/error and raise
PyiCloudServiceNotActivatedException (or the same error path used when
indexing_state != "FINISHED") so unexpected shapes are handled gracefully;
reference the variables response, indexing_state, self.service.session.post,
zone_id and the PyiCloudServiceNotActivatedException when making the change.
- Around line 1746-1774: The delete() method currently returns success based
only on resp.status_code == 200; update it to parse and validate the JSON
payload returned by self._service.session.post (call resp.json()) and ensure the
CloudKit response contains no error entries (e.g., check top-level "results" and
each result's "status"/"error" fields or absence of "error") before returning
True; if the JSON indicates failure, return False (or surface/log the error) so
delete() only returns True when status code is 200 and the response body
confirms a successful operation.

In `@README.md`:
- Around line 123-131: Remove the leading shell prompt markers ("$ ") from the
example command lines so they are plain code examples (e.g., the lines starting
with "$ icloud photos libraries", "$ icloud photos list", "$ icloud photos
sync", etc.), which will satisfy markdownlint MD014; update each example in the
README command block to be the command only (no "$ " prefix) or alternatively
wrap the entire set in a fenced code block without prompt markers.
- Around line 813-818: Update the example CLI invocations so the shared library
placeholder is quoted to prevent shell interpreting <zoneName> as redirection;
specifically modify the example commands for "icloud photos list", "icloud
photos get", and "icloud photos sync" that use the shared:<zoneName> placeholder
(e.g., the lines showing icloud photos list --library shared:<zoneName>, icloud
photos get ... --library shared:<zoneName>, and icloud photos sync --library
shared:<zoneName>) to wrap the entire shared:<zoneName> value in single or
double quotes (e.g., 'shared:<zoneName>') in all occurrences mentioned.

In `@tests/fixtures/photos_browser_mutations/README.md`:
- Around line 4-6: The README contains an absolute filesystem path
"/Users/jacob/Documents/GitHub/pyicloud/tests/fixtures/README.md" which won't
work elsewhere; update the markdown link in
tests/fixtures/photos_browser_mutations/README.md to use a repository-relative
path (e.g., change the link target to tests/fixtures/README.md or
./../README.md) so the reference resolves correctly for other developers and CI.

In `@tests/fixtures/README.md`:
- Around line 22-24: Replace the machine-specific absolute link in the README
entry that references photos_browser_mutations with a repository-relative link;
locate the line that mentions `photos_browser_mutations` in
tests/fixtures/README.md and change the URL from the local absolute path to a
relative path like `tests/fixtures/photos_browser_mutations/README.md` so the
link works in-repo and on GitHub.

---

Nitpick comments:
In `@pyicloud/cli/commands/photos.py`:
- Around line 955-961: The conditional "if iterations is None or (iterations and
iterations > 1)" is overly verbose; simplify it in the photos command where
only_print_filenames is handled by replacing the redundant "(iterations and
iterations > 1)" with "iterations > 1" so the check becomes "if iterations is
None or iterations > 1" (use the existing variables iterations,
only_print_filenames, payload and state.console.print to locate the block and
preserve the logic that prints "run {payload['iteration']}" when iterations is
unset or greater than 1).

In `@pyicloud/cli/normalize.py`:
- Around line 160-164: The current extraction of zone_name from library.zone_id
only handles dicts and falls back to None for typed objects; update the logic
around zone_id (from library) to handle dicts and typed model instances by: if
zone_id is a dict use zone_id.get("zoneName"), otherwise if zone_id has
attributes "zoneName" or "zone_name" use getattr(zone_id, "zoneName", ...) or
getattr(zone_id, "zone_name", ...) to pull the value, otherwise set zone_name =
None; apply this in the block referencing zone_id and zone_name so typed objects
normalize correctly.

In `@pyicloud/common/cloudkit/client.py`:
- Around line 211-237: The iter_changes function mutates req.zones[0].syncToken
in-place to advance pagination which is surprising; update the code to make this
explicit by either (A) adding a short clarifying comment immediately above the
line that sets req.zones[0].syncToken explaining that the request object is
intentionally reused to carry the next syncToken for subsequent pages, or (B)
avoid in-place mutation by creating a new CKZoneChangesRequest (copying existing
req, updating the zones[0].syncToken) and assigning it back to req before the
next loop iteration; reference the iter_changes function and the
req.zones[0].syncToken update when making the change.
- Around line 89-91: The current fallback of setting code = 200 when
resp.status_code is not an int masks unexpected/mock errors; change this to
explicitly validate and raise a TypeError (or ValueError) instead of silently
defaulting: check getattr(resp, "status_code", None), if it's not an int raise
TypeError("Invalid response.status_code: ...") referencing resp and its
status_code value, and apply the same change to the other identical block (the
code/status_code handling around lines 122-124) so tests that intentionally mock
must provide an int while real failures surface immediately.

In `@pyicloud/services/photos_cloudkit/client.py`:
- Around line 143-148: The broad "except Exception as exc" around accessing
data.batch[0].records[0].fields.itemCount.value should be tightened: catch the
likely concrete failures (AttributeError, IndexError, KeyError) instead of
Exception, and when raising CloudKitApiError include the original exception
type/message in the raised error (e.g., add exc.__class__.__name__ and str(exc)
into the error message or payload) so diagnostics show the underlying cause
while preserving the existing data.model_dump(mode="json") payload; keep the
"from exc" chaining to retain the traceback.
- Around line 108-148: The batch_count method currently reaches into a private
attribute (self._client._http.build_url) which couples it to
CloudKitContainerClient internals; change batch_count to use a public API
instead (e.g., call a new or existing self._client.build_url(...) or delegate
the whole batch-count call to a new public method on CloudKitContainerClient
such as request_batch_count or build_url that encapsulates URL construction and
HTTP details). Update batch_count to call that public method (or delegate the
request) and remove direct use of _http; ensure you reference the
PhotosBatchCountRequest/PhotosBatchCountResponse modeling and preserve error
handling (CloudKitApiError) while passing the payload and response through the
public client method.

In `@pyicloud/services/photos_cloudkit/constants.py`:
- Around line 107-110: PRIMARY_ZONE is currently a mutable module-level dict;
replace it with an immutable mapping by importing Mapping and MappingProxyType
and assigning PRIMARY_ZONE: Mapping[str, str] = MappingProxyType({"zoneName":
"PrimarySync", "zoneType": "REGULAR_CUSTOM_ZONE"}) so callers still get a
mapping but attempts to mutate will raise; update the type annotation and add
the necessary imports (from typing import Mapping and from types import
MappingProxyType) and remove direct dict usage.

In `@pyicloud/services/photos_cloudkit/models.py`:
- Around line 160-163: Replace the nonstandard "if False:  # pragma: no cover"
block used for import-only type hints with the standard typing.TYPE_CHECKING
guard: import TYPE_CHECKING from typing and change the conditional to "if
TYPE_CHECKING:" and keep the same names (DirectionEnum, ListTypeEnum,
ObjectTypeEnum, BasePhotoAlbum, PhotoAsset) inside that block so type-only
imports are clear and avoid runtime circular imports.

In `@pyicloud/services/photos_cloudkit/service.py`:
- Around line 320-325: The constructor contains test-specific branching via
_is_mock_like(service) and type(self).__name__ != "PhotoLibrary"; remove this
implicit mock detection and instead either (a) add an explicit constructor
parameter like skip_indexing_check: bool = False (or skip_indexing: bool =
False) to the class __init__ and use that to set self._indexing_state =
"FINISHED" and return when True, or (b) create a factory function (e.g.,
make_photo_library_for_test / PhotoLibraryTestStub factory) that returns a
test-specific implementation that sets _indexing_state without changing
production __init__; update callers/tests to pass the new flag or use the
factory and delete the _is_mock_like(...) branching and the type(self).__name__
check from the production constructor.
- Around line 1118-1125: The loop's early-exit check using `num_results <
self.page_size // 2` can prematurely stop fetching when `page_size` is small;
change the condition in the loop that follows `seen.add(photo.id)`/`yield photo`
to require either no results or apply a minimum threshold, e.g. `if num_results
== 0: break` or `if num_results < max(1, self.page_size // 2): break`, so that
`_direction`/`offset` adjustments and pagination continue correctly for small
`page_size` values; update the conditional referencing `num_results`,
`self.page_size`, and keep `self._direction`/`DirectionEnum` logic unchanged.
- Line 11: The code imports unittest.mock.Mock and uses _is_mock_like() runtime
checks, coupling production to test framework; replace direct dependence on Mock
by removing the import and change _is_mock_like to use an injectable predicate
or duck-typed check instead: add an optional parameter (e.g., is_mock_like:
Callable[..., bool]) to the PhotosCloudKitService constructor or to the
functions that call _is_mock_like, defaulting to a conservative runtime check
(e.g., check for specific attributes/methods used by the code) so tests can pass
a mock-detection helper without importing unittest.mock in production; update
all usages of _is_mock_like (including the occurrences around the 215-224
region) to call the injected predicate and remove any direct isinstance(...,
Mock) checks.

In `@pyicloud/services/photos_cloudkit/state.py`:
- Around line 173-197: The upsert_resource method commits every call which hurts
performance for bulk syncs; modify upsert_resource (and its use sites) to accept
an optional commit: bool = True parameter and only call self.conn.commit() when
commit is True (leaving callers able to defer commits), and add either a new
batch_upsert_resources(resources: Iterable[SyncedPhotoResource]) that executes
many INSERT ... ON CONFLICT statements inside a single transaction (begin/commit
around the loop) or provide a context manager on the state class that begins a
transaction on __enter__ and commits on __exit__ so callers can group multiple
upserts without committing each time; update callers to use the new parameter or
batch/context API.
- Around line 85-86: The PRAGMA enabling foreign keys is a no-op because no
tables define FOREIGN KEY constraints; either remove the line
self._conn.execute("PRAGMA foreign_keys=ON") from the connection setup or
actually define the intended foreign key relationships in the schema/table
creation code so the pragma has effect (update the table CREATE statements to
include FOREIGN KEY clauses referencing the related tables). Locate the
connection initialization where self._conn.execute("PRAGMA journal_mode=WAL")
and self._conn.execute("PRAGMA foreign_keys=ON") are called and choose one of
the two fixes: remove the foreign_keys pragma if no FK constraints are planned,
or add explicit FOREIGN KEY definitions to the relevant CREATE TABLE statements
so the pragma is meaningful.

In `@pyicloud/services/photos_cloudkit/sync.py`:
- Around line 622-632: The _unique_relative_path function uses an unbounded
while True loop with variables discriminator and index to find a non-colliding
next_path against reserved_paths and tracked_paths; add a defensive maximum
iteration limit (e.g., MAX_ATTEMPTS constant) and increment index each loop, and
if the limit is reached raise a clear exception (e.g., RuntimeError or custom)
including asset_id and resource_key so callers can fail fast and debugging
information is present; update any callers or tests as needed to handle the new
exception.

In `@pyicloud/services/photos_legacy.py`:
- Around line 1734-1744: The download() method in photos_legacy.py currently
calls response.raw.read() which loads the entire response into memory; change it
to stream the response in chunks instead: use
Response.iter_content(chunk_size=...) (or iterate over response.raw.read into a
provided file-like object) and either return a generator/iterator of bytes or
accept an optional destination/file handle or max_size parameter to write chunks
to disk, preventing unbounded memory use; locate the method named download and
the use of self.versions and response.raw.read to implement chunked reads and
optional streaming download behavior.
- Around line 822-852: The current photos property uses a fragile termination
check (num_results < self.page_size // 2) which can skip real photos; update the
loop in photos to stop only when the API explicitly signals no more results (use
any continuation/has_more marker returned by _get_photos_at if available) or, if
the API provides no marker, replace the half-page heuristic with a safer
fallback such as breaking only on num_results == 0 or after two consecutive
batches smaller than page_size; ensure you update the handling around
_get_photos_at, num_results, page_size, photos_ids and offset (used with
DirectionEnum) accordingly so offset arithmetic remains correct.

In `@README.md`:
- Line 940: Update the sentence in README where it currently reads "To upload a
photo use the `upload` method" to include the missing comma so it reads "To
upload a photo, use the `upload` method." Locate and edit the exact line
containing that phrase in the README.md.

In `@tests/fixtures/photos_browser_mutations/README.md`:
- Around line 8-10: Edit the README sentence that currently reads "Stable
placeholder values are used instead so request and response relationships remain
testable without exposing personal data." and insert a comma after "instead" so
it reads "Stable placeholder values are used instead, so request and response
relationships remain testable without exposing personal data." This is the only
change needed to improve punctuation and readability in the
tests/fixtures/photos_browser_mutations README sentence.

In `@tests/fixtures/photos_zone_changes_response.json`:
- Around line 15-26: The fixture shows a record with both an "isDeleted" field
and a top-level "deleted" property which may be redundant; update the test
fixture so it mirrors real API behavior by removing one representation or
aligning their values—either remove the INT64 "isDeleted" field under "fields"
or remove the top-level "deleted" property, and ensure any tests that reference
"isDeleted" or "deleted" (search for "isDeleted" and "deleted" in test code) are
updated to use the single canonical indicator consistently.

In `@tests/services/test_photos_cloudkit_client.py`:
- Around line 180-192: The test is directly mocking deep internals
(client._client._http.post) which couples tests to implementation; instead
construct PhotosCloudKitClient with a MagicMock session and mock session.post
(the same pattern used earlier), e.g., pass a MagicMock as the session argument
and set session.post.return_value to ZONES_LIST_PAYLOAD before calling
client.zones_list(), or extract a test helper (e.g.,
make_mocked_photos_client(session_response)) that builds a PhotosCloudKitClient
and configures the session.post mock; update the assertions to use that
session-level mock and remove references to _client._http.post in the test.

In `@tests/services/test_photos_sync.py`:
- Around line 169-176: Replace the repeated manual cleanup in
tests/services/test_photos_sync.py (the finally block that walks
temp_dir.rglob("*") and unlinks/rmdirs and then temp_dir.rmdir()) with a
reusable context manager or pytest fixture that creates and auto-cleans the temp
directory (e.g., a sync_test_dir context manager using
tempfile.TemporaryDirectory or a tmp_path fixture); update each test that
currently uses the manual finally (references: temp_dir, temp_dir.rglob, and the
finally block) to use "with sync_test_dir(... ) as temp_dir:" or the fixture so
teardown is automatic and remove the duplicated cleanup code from those tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9de70243-1d50-4d20-a924-5cdf99572f25

📥 Commits

Reviewing files that changed from the base of the PR and between 386070c and 0ac2fcf.

📒 Files selected for processing (66)
  • README.md
  • examples.py
  • pyicloud/cli/commands/photos.py
  • pyicloud/cli/normalize.py
  • pyicloud/common/cloudkit/__init__.py
  • pyicloud/common/cloudkit/client.py
  • pyicloud/common/cloudkit/models.py
  • pyicloud/services/photos.py
  • pyicloud/services/photos_cloudkit/__init__.py
  • pyicloud/services/photos_cloudkit/client.py
  • pyicloud/services/photos_cloudkit/constants.py
  • pyicloud/services/photos_cloudkit/mappers.py
  • pyicloud/services/photos_cloudkit/materialize.py
  • pyicloud/services/photos_cloudkit/models.py
  • pyicloud/services/photos_cloudkit/queries.py
  • pyicloud/services/photos_cloudkit/service.py
  • pyicloud/services/photos_cloudkit/state.py
  • pyicloud/services/photos_cloudkit/sync.py
  • pyicloud/services/photos_legacy.py
  • tests/fixtures/README.md
  • tests/fixtures/photos_album_create_response.json
  • tests/fixtures/photos_album_membership_query_core.json
  • tests/fixtures/photos_album_membership_response.json
  • tests/fixtures/photos_album_rename_response.json
  • tests/fixtures/photos_all_photos_query_core.json
  • tests/fixtures/photos_all_photos_response.json
  • tests/fixtures/photos_browser_mutations/README.md
  • tests/fixtures/photos_browser_mutations/album_add_photo_request.json
  • tests/fixtures/photos_browser_mutations/album_add_photo_response.json
  • tests/fixtures/photos_browser_mutations/album_create_request.json
  • tests/fixtures/photos_browser_mutations/album_create_response.json
  • tests/fixtures/photos_browser_mutations/album_delete_request.json
  • tests/fixtures/photos_browser_mutations/album_delete_response.json
  • tests/fixtures/photos_browser_mutations/album_remove_photo_request.json
  • tests/fixtures/photos_browser_mutations/album_remove_photo_response.json
  • tests/fixtures/photos_browser_mutations/album_rename_request.json
  • tests/fixtures/photos_browser_mutations/album_rename_response.json
  • tests/fixtures/photos_browser_mutations/photo_delete_request.json
  • tests/fixtures/photos_browser_mutations/photo_delete_response.json
  • tests/fixtures/photos_database_changes_response.json
  • tests/fixtures/photos_favorites_query_core.json
  • tests/fixtures/photos_favorites_response.json
  • tests/fixtures/photos_indexing_not_finished_response.json
  • tests/fixtures/photos_live_photo_response.json
  • tests/fixtures/photos_missing_counterparts_response.json
  • tests/fixtures/photos_recently_added_query_core.json
  • tests/fixtures/photos_recently_added_response.json
  • tests/fixtures/photos_shared_library_all_photos_query_core.json
  • tests/fixtures/photos_shared_library_all_photos_response.json
  • tests/fixtures/photos_shared_library_favorites_query_core.json
  • tests/fixtures/photos_shared_library_favorites_response.json
  • tests/fixtures/photos_shared_library_private_zones_response.json
  • tests/fixtures/photos_shared_library_shared_zones_response.json
  • tests/fixtures/photos_shared_library_unfavorite_request.json
  • tests/fixtures/photos_shared_library_unfavorite_response.json
  • tests/fixtures/photos_shared_library_zone_changes_request.json
  • tests/fixtures/photos_shared_library_zone_changes_response.json
  • tests/fixtures/photos_upload_duplicate_response.json
  • tests/fixtures/photos_upload_skeletal_response.json
  • tests/fixtures/photos_video_only_response.json
  • tests/fixtures/photos_zone_changes_response.json
  • tests/fixtures/photos_zones_list_response.json
  • tests/services/test_photos.py
  • tests/services/test_photos_cloudkit_client.py
  • tests/services/test_photos_sync.py
  • tests/test_cmdline.py

Comment thread examples.py Outdated
Comment thread pyicloud/services/photos_cloudkit/models.py
Comment thread pyicloud/services/photos_cloudkit/sync.py
Comment thread pyicloud/services/photos_cloudkit/sync.py
Comment thread pyicloud/services/photos_legacy.py
Comment thread pyicloud/services/photos_legacy.py Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread tests/fixtures/photos_browser_mutations/README.md
Comment thread tests/fixtures/README.md
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (4)
README.md (1)

772-775: Prefer fully-qualified command names for consistency.

Line 772 and Line 774 mix full commands (e.g., icloud photos libraries) with shorthand entries (e.g., albums, list). Using the full form for each item would reduce ambiguity for quick copy/paste reading.

Suggested wording tweak
-- `icloud photos libraries`, `albums`, `list`, `get`, `changes`, and `sync-cursor`
+- `icloud photos libraries`, `icloud photos albums`, `icloud photos list`,
+  `icloud photos get`, `icloud photos changes`, and `icloud photos sync-cursor`
   are read-focused inspection commands.
-- `icloud photos sync` and `icloud photos watch` are the modern replacement path
+- `icloud photos sync` and `icloud photos watch` are the modern replacement path
   for `icloud_photos_downloader`.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 772 - 775, Update the README to use fully-qualified
command names for consistency: replace shorthand entries `albums`, `list`,
`get`, `changes`, and `sync-cursor` with their full forms prefixed by the
command namespace (e.g., `icloud photos albums`, `icloud photos list`, `icloud
photos get`, `icloud photos changes`, `icloud photos sync-cursor`), and ensure
`icloud photos sync` and `icloud photos watch` remain as-is while referencing
`icloud_photos_downloader` only where appropriate.
pyicloud/services/photos_cloudkit/sync.py (3)

627-635: Unbounded loop with no explicit limit.

The while True loop relies on eventually finding a unique path. Whilst practically safe (the 8-character discriminator plus incrementing index provides ample namespace), consider adding an upper bound as a defensive measure against pathological cases:

Optional defensive limit
     index = 1
-    while True:
+    while index < 10000:
         suffix_bits = f"_{discriminator}" if index == 1 else f"_{discriminator}_{index}"
         next_path = (directory / f"{stem}{suffix_bits}{suffix}").as_posix()
         owner = tracked_paths.get(next_path)
         if next_path not in reserved_paths and (
             owner is None or owner == (asset_id, resource_key)
         ):
             return next_path
         index += 1
+    raise PhotosServiceException(f"Could not generate unique path for {candidate}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/services/photos_cloudkit/sync.py` around lines 627 - 635, The
infinite retry loop that builds candidate paths (using discriminator, index,
reserved_paths, tracked_paths, asset_id, resource_key and next_path) needs a
defensive upper bound to avoid pathological hangs; replace the while True with a
bounded loop (e.g. max_attempts constant) and increment index each attempt,
returning next_path if valid and after max_attempts raise a clear exception (or
propagate a specific error) indicating no unique path could be found so callers
can handle the failure.

375-394: Consider wrapping remote delete in error handling.

If asset.delete() raises an exception (e.g., network error, API failure), the sync will abort without completing the remaining assets or persisting partial progress. This could leave the sync in an inconsistent state where some assets were processed but the cursor isn't updated.

Consider catching and logging delete failures to allow the sync to continue:

Suggested approach
             if _should_delete_remote_asset(
                 asset=asset,
                 options=options,
                 asset_ready_for_delete=asset_ready_for_delete,
                 asset_confirmed_local=asset_confirmed_local,
                 now_local=now_local,
             ):
-                deleted = asset.delete()
-                sync_complete = False
-                if deleted:
+                try:
+                    deleted = asset.delete()
+                except Exception:
+                    deleted = False
+                if deleted:
+                    sync_complete = False
                     result.items.append(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/services/photos_cloudkit/sync.py` around lines 375 - 394, The remote
deletion call asset.delete() inside the block guarded by
_should_delete_remote_asset(...) should be wrapped in try/except so a thrown
exception doesn't abort the whole sync; catch exceptions around the
asset.delete() call in the same scope, log the exception (including asset.id and
exception text) and append a PhotoSyncItem indicating the delete failure (e.g.,
resource_key="remote", action="delete-failed", reason containing the error or
"delete-error") to result.items and increment an appropriate counter (or leave
result.deleted_count unchanged) while ensuring sync_complete remains False so
the loop continues processing other assets; keep the existing successful-delete
handling for when delete() returns truthy.

511-516: Defensive guard for photos attribute.

getattr(album, "photos") without a default will raise AttributeError if the attribute is missing, or TypeError if it returns None. Consider adding a guard or default:

Suggested fix
-            for asset in getattr(album, "photos"):
+            photos = getattr(album, "photos", None)
+            if photos is None:
+                continue
+            for asset in photos:

The same pattern appears at line 528 for source.photos.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/services/photos_cloudkit/sync.py` around lines 511 - 516, The loop
over getattr(album, "photos") can raise if "photos" is missing or None; change
the guard to use a safe default or check before iterating (e.g., use
getattr(album, "photos", []) or if getattr(album, "photos") is not None and
iterable) so the for asset in ... loop in this function will skip when photos is
absent, and apply the same defensive change for the similar loop using
source.photos at the other occurrence; keep the existing seen set and yield
asset behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pyicloud/services/photos_cloudkit/sync.py`:
- Around line 406-422: The loop over tracked_resources currently calls
stale_path.unlink() without handling exceptions which can abort processing and
leave state inconsistent; wrap the unlink call in a try/except that catches
OSError/PermissionError, log the failure (using the module or existing logger),
but regardless of unlink success proceed to call
state.delete_resource(stale.asset_id, stale.resource_key), append the
PhotoSyncItem (marking action "deleted" and optionally include a note or flag if
the local delete failed), and increment result.deleted_count so remaining stale
entries are processed; reference the loop handling tracked_resources, the
stale_path.unlink() call, state.delete_resource(...), result.items append, and
result.deleted_count to locate where to add the try/except and logging.

In `@pyicloud/services/photos_legacy.py`:
- Around line 560-568: The upload code assumes self._upload_url always exists,
causing requests like "None/upload?..." for discovered non-root libraries; fix
by either propagating the upload URL when creating zone libraries (update the
libraries() codepath to pass the parent's upload URL into
PhotoLibrary/PhotoAlbum constructors or PhotoLibrary.__init__) or by failing
fast in upload paths (add a guard in PhotoAlbum.upload() and upload_file() to
check self._upload_url and raise a clear exception if missing). Update all
upload callsites (upload_file, PhotoAlbum.upload and any library construction in
libraries()) to use the chosen approach so no upload proceeds when _upload_url
is None.
- Around line 661-665: Currently _root_library is eagerly constructed with
PhotoLibrary(self, PRIMARY_ZONE, upload_url=upload_url) which triggers indexing
in PhotoLibrary.__init__ and can raise PyiCloudServiceNotActivatedException for
shared-only callers; change _root_library to be created lazily: store None
initially and only instantiate PhotoLibrary(self, PRIMARY_ZONE,
upload_url=upload_url) inside the root-specific accessor methods (the properties
or functions that access _root_library), leaving _shared_library untouched so
shared-only paths never hit PhotoLibrary constructor until root access is
requested. Ensure any code that reads _root_library checks for None and
constructs it atomically (or via a helper like _get_root_library()) to avoid
races.
- Around line 1441-1446: The try/except in photos_legacy.py that sets
self.creation_date currently only catches ValueError; expand the exception
handler to also catch TypeError and OverflowError so non-numeric/None or extreme
creation_date values don't abort shared-stream listing. Update the except on the
block that calls int(creation_date) / datetime.fromtimestamp in the
PhotosLegacy/album record handling (the self.creation_date assignment) to catch
(ValueError, TypeError, OverflowError) and keep the existing fallback to epoch.
- Around line 1116-1125: The code currently unconditionally copies
payload["records"][0]["recordChangeTag"] and modification date into
self._record_change_tag/_record_modification_date even when the response is a
CPLContainerRelation; change it to only refresh the cached album metadata when
the returned record is actually the album (e.g., check
payload["records"][0].get("recordType") == "CPLAlbum" or
payload["records"][0].get("recordName") indicates an album) and otherwise leave
self._record_change_tag and self._record_modification_date untouched so
subsequent rename() or delete() use the correct album change tag.
- Around line 917-922: After converting a negative index (key) to its positive
form, validate that the adjusted key is within bounds (0 <= key < len(self)) and
raise IndexError if it's out of range (also handle len(self) == 0); do this
check before calling _get_photos_at so that values below -len(self) no longer
get clamped to 0 and return the first photo. In other words, in the block that
adjusts negative key and then calls _get_photos_at, add a bounds check on the
adjusted key and raise IndexError when invalid instead of relying on
_get_photos_at to handle it.

---

Nitpick comments:
In `@pyicloud/services/photos_cloudkit/sync.py`:
- Around line 627-635: The infinite retry loop that builds candidate paths
(using discriminator, index, reserved_paths, tracked_paths, asset_id,
resource_key and next_path) needs a defensive upper bound to avoid pathological
hangs; replace the while True with a bounded loop (e.g. max_attempts constant)
and increment index each attempt, returning next_path if valid and after
max_attempts raise a clear exception (or propagate a specific error) indicating
no unique path could be found so callers can handle the failure.
- Around line 375-394: The remote deletion call asset.delete() inside the block
guarded by _should_delete_remote_asset(...) should be wrapped in try/except so a
thrown exception doesn't abort the whole sync; catch exceptions around the
asset.delete() call in the same scope, log the exception (including asset.id and
exception text) and append a PhotoSyncItem indicating the delete failure (e.g.,
resource_key="remote", action="delete-failed", reason containing the error or
"delete-error") to result.items and increment an appropriate counter (or leave
result.deleted_count unchanged) while ensuring sync_complete remains False so
the loop continues processing other assets; keep the existing successful-delete
handling for when delete() returns truthy.
- Around line 511-516: The loop over getattr(album, "photos") can raise if
"photos" is missing or None; change the guard to use a safe default or check
before iterating (e.g., use getattr(album, "photos", []) or if getattr(album,
"photos") is not None and iterable) so the for asset in ... loop in this
function will skip when photos is absent, and apply the same defensive change
for the similar loop using source.photos at the other occurrence; keep the
existing seen set and yield asset behavior unchanged.

In `@README.md`:
- Around line 772-775: Update the README to use fully-qualified command names
for consistency: replace shorthand entries `albums`, `list`, `get`, `changes`,
and `sync-cursor` with their full forms prefixed by the command namespace (e.g.,
`icloud photos albums`, `icloud photos list`, `icloud photos get`, `icloud
photos changes`, `icloud photos sync-cursor`), and ensure `icloud photos sync`
and `icloud photos watch` remain as-is while referencing
`icloud_photos_downloader` only where appropriate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5f674115-7506-4311-9d45-ccc28f947be0

📥 Commits

Reviewing files that changed from the base of the PR and between 0ac2fcf and cd461ec.

📒 Files selected for processing (9)
  • README.md
  • examples.py
  • pyicloud/services/photos_cloudkit/models.py
  • pyicloud/services/photos_cloudkit/sync.py
  • pyicloud/services/photos_legacy.py
  • tests/fixtures/README.md
  • tests/fixtures/photos_browser_mutations/README.md
  • tests/services/test_photos.py
  • tests/services/test_photos_sync.py
✅ Files skipped from review due to trivial changes (3)
  • tests/fixtures/README.md
  • examples.py
  • pyicloud/services/photos_cloudkit/models.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/services/test_photos_sync.py

Comment thread pyicloud/services/photos_cloudkit/sync.py
Comment thread pyicloud/services/photos_legacy.py
Comment thread pyicloud/services/photos_legacy.py Outdated
Comment thread pyicloud/services/photos_legacy.py
Comment thread pyicloud/services/photos_legacy.py Outdated
Comment thread pyicloud/services/photos_legacy.py
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
pyicloud/services/photos_legacy.py (1)

694-715: Consider validating the zones key before iteration.

Line 700 directly accesses response["zones"] without checking if the key exists. If the CloudKit API returns an unexpected response shape, this will raise an unhandled KeyError.

🛡️ Proposed defensive access
         response: dict[str, Any] = request.json()
-        zones: list[dict[str, Any]] = response["zones"]
+        zones: list[dict[str, Any]] = response.get("zones", [])
 
         libraries: dict[str, BasePhotoLibrary] = {
             "root": self._get_root_library(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/services/photos_legacy.py` around lines 694 - 715, The code assumes
response["zones"] exists and will KeyError on unexpected shapes; change the
access in the method that builds libraries so you defensively obtain zones
(e.g., zones = response.get("zones") and if it's None or not a list handle it by
using an empty list or logging/raising a clear error) before iterating to create
PhotoLibrary instances; ensure you still populate libraries with
self._get_root_library() and self._shared_library and assign self._libraries
accordingly when zones are missing or invalid.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pyicloud/services/photos_legacy.py`:
- Around line 1657-1663: The added_date property (in photos_legacy.py) directly
indexes self._asset_record["fields"]["addedDate"]["value"] which can raise
KeyError if the field is missing; update the added_date getter to mirror the
defensive pattern used by asset_date: wrap access to
self._asset_record["fields"]["addedDate"]["value"] in a try/except (catch
KeyError/TypeError/ValueError as needed) and return datetime.fromtimestamp(0,
timezone.utc) as a fallback (epoch) when the value is absent or invalid,
ensuring the property always returns a datetime instead of raising.
- Around line 1079-1101: In delete() validate the response payload before using
payload["records"][0]: ensure payload is a dict, payload.get("records") is a
list and len(...) > 0; only then update self._record_change_tag and
self._record_modification_date from records[0]; otherwise log a warning or error
and leave the existing self._record_change_tag / self._record_modification_date
unchanged (same defensive pattern used in rename() and add_photo()), and raise
or return a PhotosServiceException if you want to treat a missing/empty records
array as an error.

---

Nitpick comments:
In `@pyicloud/services/photos_legacy.py`:
- Around line 694-715: The code assumes response["zones"] exists and will
KeyError on unexpected shapes; change the access in the method that builds
libraries so you defensively obtain zones (e.g., zones = response.get("zones")
and if it's None or not a list handle it by using an empty list or
logging/raising a clear error) before iterating to create PhotoLibrary
instances; ensure you still populate libraries with self._get_root_library() and
self._shared_library and assign self._libraries accordingly when zones are
missing or invalid.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 024a5f37-a104-44fd-90cd-fee5abff4734

📥 Commits

Reviewing files that changed from the base of the PR and between cd461ec and 056bee7.

📒 Files selected for processing (4)
  • pyicloud/services/photos_cloudkit/sync.py
  • pyicloud/services/photos_legacy.py
  • tests/services/test_photos.py
  • tests/services/test_photos_sync.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/services/test_photos_sync.py
  • pyicloud/services/photos_cloudkit/sync.py

Comment thread pyicloud/services/photos_legacy.py
Comment thread pyicloud/services/photos_legacy.py
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
pyicloud/services/photos_legacy.py (1)

1515-1523: Avoid forcing album-size lookup during shared-stream paging.

Line 1521 calls len(self) to cap limit, which triggers webgetassetcount before paging. That conflicts with the _get_photo() intent to avoid an upfront size lookup and adds latency.

Refactor to remove the eager size dependency
     def _get_payload(
         self, offset: int, page_size: int, direction: DirectionEnum
     ) -> dict[str, Any]:
         return {
             "albumguid": self._album_guid,
             "albumctag": self._album_ctag,
-            "limit": str(min(offset + page_size, len(self))),
+            "limit": str(offset + page_size),
             "offset": str(offset),
         }

Also applies to: 1533-1549

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyicloud/services/photos_legacy.py` around lines 1515 - 1523, The payload
builder _get_payload currently calls len(self) (triggering webgetassetcount) to
cap "limit", which forces an upfront album-size lookup; change it to avoid any
size-dependent call and set "limit" to the requested page_size (e.g., "limit":
str(page_size)) so paging uses the page size only and does not call len(self);
apply the same change to the other payload/construction site referenced in the
review (the similar block around lines 1533-1549) and ensure consumers relying
on the old capped value still work with page_size-only limits.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pyicloud/services/photos_legacy.py`:
- Around line 1037-1054: The rename/delete/add_photo handlers currently assume a
successful POST on no exception; instead inspect the parsed response payload for
error indications before mutating state or returning success: in the methods
(e.g., rename, delete, add_photo) check payload for keys like "errors" or
absence of a successful "records" entry (and validate that records[0] contains
expected fields) and only then update _record_change_tag,
_record_modification_date, and _name or return True; if the payload indicates
failure, do not change those instance attributes and return False (or raise an
appropriate exception) so callers don't receive a false success.
- Around line 199-211: parse_asset_response currently indexes nested keys
directly and can raise KeyError on malformed records; update the function
(parse_asset_response) to defensively validate each rec from response["records"]
before using nested fields: ensure rec is a dict, check rec.get("recordType")
and only access rec.get("fields") when present and is a dict, and verify
masterRef/value/recordName exist and are strings before assigning into
asset_records; similarly validate CPLMaster entries before appending to
master_records; skip (and optionally log) any malformed recs so one bad record
doesn't abort the entire parse.

---

Nitpick comments:
In `@pyicloud/services/photos_legacy.py`:
- Around line 1515-1523: The payload builder _get_payload currently calls
len(self) (triggering webgetassetcount) to cap "limit", which forces an upfront
album-size lookup; change it to avoid any size-dependent call and set "limit" to
the requested page_size (e.g., "limit": str(page_size)) so paging uses the page
size only and does not call len(self); apply the same change to the other
payload/construction site referenced in the review (the similar block around
lines 1533-1549) and ensure consumers relying on the old capped value still work
with page_size-only limits.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1139b362-b18e-49ac-b3f3-5c73c7fbb401

📥 Commits

Reviewing files that changed from the base of the PR and between 056bee7 and 4891999.

📒 Files selected for processing (2)
  • pyicloud/services/photos_legacy.py
  • tests/services/test_photos.py

Comment thread pyicloud/services/photos_legacy.py
Comment thread pyicloud/services/photos_legacy.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant