Skip to content

Convert Overdrive API responses to pydantic models (PP-3175)#3169

Draft
dbernstein wants to merge 1 commit intomainfrom
feature/overdrive-availability-pydantic-model
Draft

Convert Overdrive API responses to pydantic models (PP-3175)#3169
dbernstein wants to merge 1 commit intomainfrom
feature/overdrive-availability-pydantic-model

Conversation

@dbernstein
Copy link
Copy Markdown
Contributor

@dbernstein dbernstein commented Mar 23, 2026

Description

This PR converts Overdrive API dict-based response objects to typed, validated Pydantic models (following the existing BaseOverdriveModel / OPDS2 conventions).
This PR covers the availability document — the response from the Overdrive availability endpoint used by update_licensepool and the async importer.

New models (model.py)

  • AvailabilityTypeStrEnum for Normal, AlwaysAvailable, LimitedAvailability
  • AvailabilityAccount — per-library copy counts (id, copiesOwned, copiesAvailable, shared)
  • Availability — full availability document (reserveId, accounts, availabilityType, available, copiesOwned/Available, numberOfHolds, isOwnedByCollections, errorCode, links)
    reserveId is optional to accommodate error responses (e.g. errorCode: "NotFound") that omit it.

Refactors

  • representation.py: book_info_to_circulation(book: dict)book_info_to_circulation(availability: Availability, book_id: str | None = None). book_id takes precedence over reserve_id so callers with a known ID (e.g. after a circulation lookup) remain authoritative. _get_applicable_accounts now works with list[AvailabilityAccount].
  • api.py: update_licensepool calls Availability.model_validate_json(content) instead of book.update(json.loads(content)). update_licensepool_with_book_info signature updated to accept Availability + explicit book_id.
  • importer.py: availabilityV2 dict is validated through Availability.model_validate(...) before being passed to book_info_to_circulation.

Tests

  • TestAvailability added to test_model.py covering all fixture files (overdrive_availability_information*.json, overdrive_availability_advantage.json, overdrive_availability_not_found.json) and enum/default validation.
  • test_representation.py updated to construct Availability objects rather than raw dicts.
  • test_api.py updated to use new update_licensepool_with_book_info signature.

Checklist

  • New/changed code has accompanying tests
  • mypy passes with no new errors
  • All 171 Overdrive integration tests pass
  • Follows existing BaseOverdriveModel conventions (frozen, aliases, specific types)

@dbernstein dbernstein changed the title Feature/overdrive availability pydantic model First in a series of PRs converting Overdrive API responses to pydantic (PP-3175) Mar 23, 2026
@dbernstein dbernstein force-pushed the feature/overdrive-availability-pydantic-model branch from c446363 to 4922d7a Compare March 23, 2026 20:22
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 23, 2026

Codecov Report

❌ Patch coverage is 97.61905% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 93.31%. Comparing base (f9c99b5) to head (d4dee98).

Files with missing lines Patch % Lines
.../manager/integration/license/overdrive/importer.py 75.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #3169   +/-   ##
=======================================
  Coverage   93.30%   93.31%           
=======================================
  Files         502      502           
  Lines       46155    46172   +17     
  Branches     6318     6315    -3     
=======================================
+ Hits        43067    43086   +19     
  Misses       2003     2003           
+ Partials     1085     1083    -2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dbernstein dbernstein force-pushed the feature/overdrive-availability-pydantic-model branch from 4922d7a to f2da1fd Compare April 15, 2026 00:45
@dbernstein dbernstein force-pushed the feature/overdrive-availability-pydantic-model branch from f2da1fd to d4dee98 Compare April 16, 2026 18:50
@claude
Copy link
Copy Markdown

claude bot commented Apr 16, 2026

Claude finished @dbernstein's task in 3m 22s —— View job


Review complete

  • Gather context and understand the PR
  • Review model.py — new Pydantic models
  • Review representation.py — refactored book_info_to_circulation
  • Review api.pyupdate_licensepool changes
  • Review importer.py — availability validation
  • Review test files
  • Post inline comments and summary

Clean, well-scoped refactor that eliminates raw dict access in favour of typed Pydantic models throughout the availability flow. The new models follow existing BaseOverdriveModel conventions, the book_id override mechanism cleanly replaces the previous "mutate the dict before calling" workaround, and test coverage is thorough across all fixture files. Two minor notes left as inline comments: a subtle patrons_in_hold_queue default behaviour change in representation.py and a cast vs assertion choice in api.py — neither blocks the PR.

if patrons_in_hold_queue is None:
patrons_in_hold_queue = 0
patrons_in_hold_queue += book["numberOfHolds"]
patrons_in_hold_queue = availability.number_of_holds
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: This is a subtle behavioral change from the old code. Previously, patrons_in_hold_queue was only set when "numberOfHolds" was present in the dict (leaving it as None otherwise); CirculationData with patrons_in_hold_queue=None means "don't overwrite the stored value," while 0 means "set to zero." Since number_of_holds defaults to 0, any availability response that omits numberOfHolds will now explicitly zero out the hold queue instead of leaving it unchanged. The Overdrive API almost certainly always includes this field for owned titles, so practical impact is likely nil — but worth confirming the default is intentional.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I didn't see anything in the OverDrive docs to suggest that numberOfHolds would be missing or suggests anything other than 0 holds.

# Use the caller-provided ID for LicensePool lookup. This is always
# set because circulation_lookup creates dict(id=book_id) when called
# with a string, and book-list dicts always include "id".
resolved_book_id = cast(str, book.get("id"))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: cast(str, book.get("id")) suppresses the None case at the type level without a runtime check. The comment explains why "id" is always present, but an assert (or assert book.get("id") is not None) would surface a violation immediately rather than letting None propagate to LicensePool.for_foreign_id with a harder-to-diagnose error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Okay: we should fix that.

@dbernstein dbernstein changed the title First in a series of PRs converting Overdrive API responses to pydantic (PP-3175) Convert Overdrive API responses to pydantic models (PP-3175) Apr 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant