diff --git a/tests/manager/api/controller/test_loan.py b/tests/manager/api/controller/test_loan.py index 92c76a0af0..f16ffe9e57 100644 --- a/tests/manager/api/controller/test_loan.py +++ b/tests/manager/api/controller/test_loan.py @@ -1109,7 +1109,7 @@ def test_revoke_loan( assert isinstance(patron, Patron) loan, newly_created = loan_fixture.pool.loan_to(patron) - loan_fixture.manager.d_circulation.queue_checkin(loan_fixture.pool, True) + loan_fixture.manager.d_circulation.queue_checkin(loan_fixture.pool) response = loan_fixture.manager.loans.revoke(loan_fixture.pool_id) @@ -1156,9 +1156,7 @@ def test_revoke_hold( assert isinstance(patron, Patron) hold, newly_created = loan_fixture.pool.on_hold_to(patron, position=0) - loan_fixture.manager.d_circulation.queue_release_hold( - loan_fixture.pool, True - ) + loan_fixture.manager.d_circulation.queue_release_hold(loan_fixture.pool) response = loan_fixture.manager.loans.revoke(loan_fixture.pool_id) @@ -1390,21 +1388,25 @@ def handle_conditional_request(last_modified=None): bibliotheca_pool.open_access = False loan_fixture.manager.d_circulation.add_remote_loan( - overdrive_pool.collection, - overdrive_pool.data_source, - overdrive_pool.identifier.type, - overdrive_pool.identifier.identifier, - utc_now(), - utc_now() + datetime.timedelta(seconds=3600), + LoanInfo( + overdrive_pool.collection, + overdrive_pool.data_source, + overdrive_pool.identifier.type, + overdrive_pool.identifier.identifier, + utc_now(), + utc_now() + datetime.timedelta(seconds=3600), + ) ) loan_fixture.manager.d_circulation.add_remote_hold( - bibliotheca_pool.collection, - bibliotheca_pool.data_source, - bibliotheca_pool.identifier.type, - bibliotheca_pool.identifier.identifier, - utc_now(), - utc_now() + datetime.timedelta(seconds=3600), - 0, + HoldInfo( + bibliotheca_pool.collection, + bibliotheca_pool.data_source, + bibliotheca_pool.identifier.type, + bibliotheca_pool.identifier.identifier, + utc_now(), + utc_now() + datetime.timedelta(seconds=3600), + 0, + ) ) # Making a new request so soon after the last one means the diff --git a/tests/manager/api/controller/test_odl_notify.py b/tests/manager/api/controller/test_odl_notify.py index 544e503ba5..468e272da0 100644 --- a/tests/manager/api/controller/test_odl_notify.py +++ b/tests/manager/api/controller/test_odl_notify.py @@ -1,5 +1,6 @@ import json import types +from unittest.mock import create_autospec import flask import pytest @@ -67,26 +68,32 @@ class TestODLNotificationController: when a loan's status changes.""" @pytest.mark.parametrize( - "protocol", + "api_cls", [ - pytest.param(ODLAPI.label(), id="ODL 1.x collection"), - pytest.param(ODL2API.label(), id="ODL 2.x collection"), + pytest.param(ODLAPI, id="ODL 1.x collection"), + pytest.param(ODL2API, id="ODL 2.x collection"), ], ) def test_notify_success( self, - protocol, + api_cls: type[ODLAPI] | type[ODL2API], controller_fixture: ControllerFixture, odl_fixture: ODLFixture, ): db = controller_fixture.db - odl_fixture.collection.integration_configuration.protocol = protocol + odl_fixture.collection.integration_configuration.protocol = api_cls.label() odl_fixture.pool.licenses_owned = 10 odl_fixture.pool.licenses_available = 5 loan, ignore = odl_fixture.pool.loan_to(odl_fixture.patron) loan.external_identifier = db.fresh_str() + api = controller_fixture.manager.circulation_apis[ + db.default_library().id + ].api_for_license_pool(loan.license_pool) + update_loan_mock = create_autospec(api_cls.update_loan) + api.update_loan = update_loan_mock + with controller_fixture.request_context_with_library("/", method="POST"): text = json.dumps( { @@ -101,11 +108,8 @@ def test_notify_success( ) assert 200 == response.status_code - # The pool's availability has been updated. - api = controller_fixture.manager.circulation_apis[ - db.default_library().id - ].api_for_license_pool(loan.license_pool) - assert [loan.license_pool] == api.availability_updated_for + # Update loan was called with the expected arguments. + update_loan_mock.assert_called_once_with(loan, json.loads(text)) def test_notify_errors(self, controller_fixture: ControllerFixture): db = controller_fixture.db diff --git a/tests/manager/api/test_circulationapi.py b/tests/manager/api/test_circulationapi.py index ac1aef89f6..ec898ee2a2 100644 --- a/tests/manager/api/test_circulationapi.py +++ b/tests/manager/api/test_circulationapi.py @@ -2,7 +2,7 @@ import datetime from datetime import timedelta from typing import cast -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, create_autospec, patch import flask import pytest @@ -34,7 +34,6 @@ PatronHoldLimitReached, PatronLoanLimitReached, ) -from palace.manager.core.opds_import import OPDSAPI from palace.manager.service.analytics.analytics import Analytics from palace.manager.sqlalchemy.model.circulationevent import CirculationEvent from palace.manager.sqlalchemy.model.datasource import DataSource @@ -48,11 +47,7 @@ from tests.fixtures.library import LibraryFixture from tests.mocks.analytics_provider import MockAnalyticsProvider from tests.mocks.bibliotheca import MockBibliothecaAPI -from tests.mocks.circulation import ( - MockCirculationAPI, - MockPatronActivityCirculationAPI, - MockRemoteAPI, -) +from tests.mocks.circulation import MockCirculationAPI, MockPatronActivityCirculationAPI class CirculationAPIFixture: @@ -183,15 +178,6 @@ def test_attempt_borrow_with_existing_remote_loan( a loan for. """ # Remote loan. - circulation_api.circulation.add_remote_loan( - circulation_api.pool.collection, - circulation_api.pool.data_source, - circulation_api.identifier.type, - circulation_api.identifier.identifier, - self.YESTERDAY, - self.IN_TWO_WEEKS, - ) - circulation_api.remote.queue_checkout(AlreadyCheckedOut()) now = utc_now() loan, hold, is_new = self.borrow(circulation_api) @@ -217,16 +203,6 @@ def test_attempt_borrow_with_existing_remote_hold( on hold. """ # Remote hold. - circulation_api.circulation.add_remote_hold( - circulation_api.pool.collection, - circulation_api.pool.data_source, - circulation_api.identifier.type, - circulation_api.identifier.identifier, - self.YESTERDAY, - self.IN_TWO_WEEKS, - 10, - ) - circulation_api.remote.queue_checkout(AlreadyOnHold()) now = utc_now() loan, hold, is_new = self.borrow(circulation_api) @@ -255,16 +231,6 @@ def test_attempt_premature_renew_with_local_loan( # Local loan. loan, ignore = circulation_api.pool.loan_to(circulation_api.patron) - # Remote loan. - circulation_api.circulation.add_remote_loan( - circulation_api.pool.collection, - circulation_api.pool.data_source, - circulation_api.identifier.type, - circulation_api.identifier.identifier, - self.YESTERDAY, - self.IN_TWO_WEEKS, - ) - # This is the expected behavior in most cases--you tried to # renew the loan and failed because it's not time yet. circulation_api.remote.queue_checkout(CannotRenew()) @@ -282,14 +248,6 @@ def test_attempt_renew_with_local_loan_and_no_available_copies( loan, ignore = circulation_api.pool.loan_to(circulation_api.patron) # Remote loan. - circulation_api.circulation.add_remote_loan( - circulation_api.pool.collection, - circulation_api.pool.data_source, - circulation_api.identifier.type, - circulation_api.identifier.identifier, - self.YESTERDAY, - self.IN_TWO_WEEKS, - ) # NoAvailableCopies can happen if there are already people # waiting in line for the book. This case gives a more @@ -576,7 +534,7 @@ def test_borrow_calls_enforce_limits(self, circulation_api: CirculationAPIFixtur # is to call enforce_limits() before trying to check out the # book. - mock_api = MagicMock(spec=MockPatronActivityCirculationAPI) + mock_api = create_autospec(BaseCirculationAPI) mock_api.checkout.side_effect = NotImplementedError() mock_circulation = circulation_api.circulation @@ -893,92 +851,23 @@ def test_fulfill_errors(self, circulation_api: CirculationAPIFixture): circulation_api.pool.open_access = True circulation_api.pool.collection = collection - circulation_api.circulation.remotes[ - circulation_api.pool.data_source.name - ] = OPDSAPI(circulation_api.db.session, collection) - # The patron has the title on loan. circulation_api.pool.loan_to(circulation_api.patron) # It has a LicensePoolDeliveryMechanism that is broken (has no # associated Resource). - broken_lpdm = circulation_api.delivery_mechanism - assert None == broken_lpdm.resource - i_want_an_epub = broken_lpdm.delivery_mechanism - - # fulfill() will raise FormatNotAvailable. - pytest.raises( - FormatNotAvailable, - circulation_api.circulation.fulfill, - circulation_api.patron, - "1234", - circulation_api.pool, - broken_lpdm, - sync_on_failure=False, + circulation_api.circulation.queue_fulfill( + circulation_api.pool, FormatNotAvailable() ) - # Let's add a second LicensePoolDeliveryMechanism of the same - # type which has an associated Resource. - link, new = circulation_api.pool.identifier.add_link( - Hyperlink.OPEN_ACCESS_DOWNLOAD, - circulation_api.db.fresh_url(), - circulation_api.pool.data_source, - ) - - working_lpdm = circulation_api.pool.set_delivery_mechanism( - i_want_an_epub.content_type, - i_want_an_epub.drm_scheme, - RightsStatus.GENERIC_OPEN_ACCESS, - link.resource, - ) - - # It's still not going to work because the Resource has no - # Representation. - assert None == link.resource.representation - pytest.raises( - FormatNotAvailable, - circulation_api.circulation.fulfill, - circulation_api.patron, - "1234", - circulation_api.pool, - broken_lpdm, - sync_on_failure=False, - ) - - # Let's add a Representation to the Resource. - representation, is_new = circulation_api.db.representation( - link.resource.url, - i_want_an_epub.content_type, - "Dummy content", - mirrored=True, - ) - link.resource.representation = representation - - # We can finally fulfill a loan. - result = circulation_api.circulation.fulfill( - circulation_api.patron, "1234", circulation_api.pool, broken_lpdm - ) - assert isinstance(result, FulfillmentInfo) - assert result.content_link == link.resource.representation.public_url - assert result.content_type == i_want_an_epub.content_type - - # If we change the working LPDM so that it serves a different - # media type than the one we're asking for, we're back to - # FormatNotAvailable errors. - irrelevant_delivery_mechanism, ignore = DeliveryMechanism.lookup( - circulation_api.db.session, - "application/some-other-type", - DeliveryMechanism.NO_DRM, - ) - working_lpdm.delivery_mechanism = irrelevant_delivery_mechanism + # fulfill() will raise FormatNotAvailable. pytest.raises( FormatNotAvailable, circulation_api.circulation.fulfill, circulation_api.patron, "1234", circulation_api.pool, - broken_lpdm, - sync_on_failure=False, + circulation_api.delivery_mechanism, ) def test_fulfill(self, circulation_api: CirculationAPIFixture): @@ -1037,7 +926,7 @@ def test_revoke_loan(self, circulation_api: CirculationAPIFixture, open_access): circulation_api.patron.last_loan_activity_sync = utc_now() circulation_api.pool.loan_to(circulation_api.patron) - circulation_api.remote.queue_checkin(True) + circulation_api.remote.queue_checkin() result = circulation_api.circulation.revoke_loan( circulation_api.patron, "1234", circulation_api.pool @@ -1057,7 +946,7 @@ def test_release_hold(self, circulation_api: CirculationAPIFixture, open_access) circulation_api.patron.last_loan_activity_sync = utc_now() circulation_api.pool.on_hold_to(circulation_api.patron) - circulation_api.remote.queue_release_hold(True) + circulation_api.remote.queue_release_hold() result = circulation_api.circulation.release_hold( circulation_api.patron, "1234", circulation_api.pool @@ -1293,12 +1182,14 @@ def test_sync_bookshelf_updates_local_loan_and_hold_with_modified_timestamps( # But the remote thinks the loan runs from today until two # weeks from today. circulation_api.circulation.add_remote_loan( - circulation_api.pool.collection, - circulation_api.pool.data_source, - circulation_api.identifier.type, - circulation_api.identifier.identifier, - self.TODAY, - self.IN_TWO_WEEKS, + LoanInfo( + circulation_api.pool.collection, + circulation_api.pool.data_source, + circulation_api.identifier.type, + circulation_api.identifier.identifier, + self.TODAY, + self.IN_TWO_WEEKS, + ) ) # Similar situation for this hold on a different LicensePool. @@ -1315,13 +1206,15 @@ def test_sync_bookshelf_updates_local_loan_and_hold_with_modified_timestamps( hold.position = 10 circulation_api.circulation.add_remote_hold( - pool2.collection, - pool2.data_source, - pool2.identifier.type, - pool2.identifier.identifier, - self.TODAY, - self.IN_TWO_WEEKS, - 0, + HoldInfo( + pool2.collection, + pool2.data_source, + pool2.identifier.type, + pool2.identifier.identifier, + self.TODAY, + self.IN_TWO_WEEKS, + 0, + ) ) circulation_api.circulation.sync_bookshelf(circulation_api.patron, "1234") @@ -1344,13 +1237,15 @@ def test_sync_bookshelf_applies_locked_delivery_mechanism_to_loan( ) pool = circulation_api.db.licensepool(None) circulation_api.circulation.add_remote_loan( - pool.collection, - pool.data_source.name, - pool.identifier.type, - pool.identifier.identifier, - utc_now(), - None, - locked_to=mechanism, + LoanInfo( + pool.collection, + pool.data_source.name, + pool.identifier.type, + pool.identifier.identifier, + utc_now(), + None, + locked_to=mechanism, + ) ) circulation_api.circulation.sync_bookshelf(circulation_api.patron, "1234") @@ -1375,12 +1270,14 @@ def test_sync_bookshelf_respects_last_loan_activity_sync( # Little do we know that they just used a vendor website to # create a loan. circulation_api.circulation.add_remote_loan( - circulation_api.pool.collection, - circulation_api.pool.data_source, - circulation_api.identifier.type, - circulation_api.identifier.identifier, - self.YESTERDAY, - self.IN_TWO_WEEKS, + LoanInfo( + circulation_api.pool.collection, + circulation_api.pool.data_source, + circulation_api.identifier.type, + circulation_api.identifier.identifier, + self.YESTERDAY, + self.IN_TWO_WEEKS, + ) ) # Syncing our loans with the remote won't actually do anything. @@ -1503,7 +1400,7 @@ def test_can_fulfill_without_loan(self, db: DatabaseTransactionFixture): """By default, there is a blanket prohibition on fulfilling a title when there is no active loan. """ - api = MockRemoteAPI(db.session, db.default_collection()) + api = MockPatronActivityCirculationAPI(db.session, db.default_collection()) assert False == api.can_fulfill_without_loan( MagicMock(), MagicMock(), MagicMock() ) diff --git a/tests/mocks/circulation.py b/tests/mocks/circulation.py index 3b5d7105fc..84b6b1a90c 100644 --- a/tests/mocks/circulation.py +++ b/tests/mocks/circulation.py @@ -1,6 +1,8 @@ -from abc import ABC +from __future__ import annotations + from collections import defaultdict -from collections.abc import Mapping +from collections.abc import Iterable, Mapping +from typing import Any from sqlalchemy.orm import Session @@ -8,6 +10,7 @@ BaseCirculationAPI, CirculationAPI, CirculationApiType, + FulfillmentInfo, HoldInfo, LoanInfo, PatronActivityCirculationAPI, @@ -16,12 +19,33 @@ from palace.manager.integration.settings import BaseSettings from palace.manager.service.analytics.analytics import Analytics from palace.manager.service.container import Services +from palace.manager.sqlalchemy.model.collection import Collection from palace.manager.sqlalchemy.model.datasource import DataSource from palace.manager.sqlalchemy.model.library import Library -from palace.manager.sqlalchemy.model.patron import Hold, Loan +from palace.manager.sqlalchemy.model.licensing import ( + LicensePool, + LicensePoolDeliveryMechanism, +) +from palace.manager.sqlalchemy.model.patron import Patron + +class MockPatronActivityCirculationAPI(PatronActivityCirculationAPI): + def __init__( + self, + _db: Session, + collection: Collection, + set_delivery_mechanism_at: str | None = BaseCirculationAPI.FULFILL_STEP, + can_revoke_hold_when_reserved: bool = True, + ): + old_protocol = collection.integration_configuration.protocol + collection.integration_configuration.protocol = self.label() + super().__init__(_db, collection) + collection.integration_configuration.protocol = old_protocol + self.SET_DELIVERY_MECHANISM_AT = set_delivery_mechanism_at + self.CAN_REVOKE_HOLD_WHEN_RESERVED = can_revoke_hold_when_reserved + self.responses: dict[str, list[Any]] = defaultdict(list) + self.availability_updated_for: list[LicensePool] = [] -class MockPatronActivityCirculationAPI(PatronActivityCirculationAPI, ABC): @classmethod def label(cls) -> str: return "" @@ -38,127 +62,146 @@ def settings_class(cls) -> type[BaseSettings]: def library_settings_class(cls) -> type[BaseSettings]: return BaseSettings - -class MockRemoteAPI(MockPatronActivityCirculationAPI): - def __init__( - self, set_delivery_mechanism_at=True, can_revoke_hold_when_reserved=True - ): - self.SET_DELIVERY_MECHANISM_AT = set_delivery_mechanism_at - self.CAN_REVOKE_HOLD_WHEN_RESERVED = can_revoke_hold_when_reserved - self.responses = defaultdict(list) - self.availability_updated_for = [] - - def checkout(self, patron_obj, patron_password, licensepool, delivery_mechanism): + def checkout( + self, + patron: Patron, + pin: str | None, + licensepool: LicensePool, + delivery_mechanism: LicensePoolDeliveryMechanism, + ) -> LoanInfo | HoldInfo: # Should be a LoanInfo. return self._return_or_raise("checkout") - def update_availability(self, licensepool): + def update_availability(self, licensepool: LicensePool) -> None: """Simply record the fact that update_availability was called.""" self.availability_updated_for.append(licensepool) - def place_hold(self, patron, pin, licensepool, hold_notification_email=None): + def place_hold( + self, + patron: Patron, + pin: str | None, + licensepool: LicensePool, + notification_email_address: str | None, + ) -> HoldInfo: # Should be a HoldInfo. return self._return_or_raise("hold") def fulfill( self, - patron, - pin, - licensepool, - delivery_mechanism, - ): + patron: Patron, + pin: str, + licensepool: LicensePool, + delivery_mechanism: LicensePoolDeliveryMechanism, + ) -> FulfillmentInfo: # Should be a FulfillmentInfo. return self._return_or_raise("fulfill") - def checkin(self, patron, pin, licensepool): + def patron_activity( + self, patron: Patron, pin: str | None + ) -> Iterable[LoanInfo | HoldInfo]: + # This method isn't used on the mock, so we just raise an exception. + raise NotImplementedError() + + def checkin(self, patron: Patron, pin: str, licensepool: LicensePool) -> None: # Return value is not checked. return self._return_or_raise("checkin") - def patron_activity(self, patron, pin): - return self._return_or_raise("patron_activity") - - def release_hold(self, patron, pin, licensepool): + def release_hold(self, patron: Patron, pin: str, licensepool: LicensePool) -> None: # Return value is not checked. return self._return_or_raise("release_hold") - def internal_format(self, delivery_mechanism): - return delivery_mechanism - - def update_loan(self, loan, status_doc): - self.availability_updated_for.append(loan.license_pool) - - def queue_checkout(self, response): + def queue_checkout(self, response: LoanInfo | HoldInfo | Exception) -> None: self._queue("checkout", response) - def queue_hold(self, response): + def queue_hold(self, response: HoldInfo | Exception) -> None: self._queue("hold", response) - def queue_fulfill(self, response): + def queue_fulfill(self, response: FulfillmentInfo | Exception) -> None: self._queue("fulfill", response) - def queue_checkin(self, response): + def queue_checkin(self, response: None | Exception = None) -> None: self._queue("checkin", response) - def queue_release_hold(self, response): + def queue_release_hold(self, response: None | Exception = None) -> None: self._queue("release_hold", response) - def _queue(self, k, v): + def _queue(self, k: str, v: Any) -> None: self.responses[k].append(v) - def _return_or_raise(self, k): - self.log.debug(k) - l = self.responses[k] - v = l.pop() - if isinstance(v, Exception): - raise v - return v + def _return_or_raise(self, key: str) -> Any: + self.log.debug(key) + response = self.responses[key].pop() + if isinstance(response, Exception): + raise response + return response class MockCirculationAPI(CirculationAPI): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.responses = defaultdict(list) - self.remote_loans = [] - self.remote_holds = [] - self.remotes = {} - - def local_loans(self, patron): - return self._db.query(Loan).filter(Loan.patron == patron) - - def local_holds(self, patron): - return self._db.query(Hold).filter(Hold.patron == patron) + def __init__( + self, + db: Session, + library: Library, + library_collection_apis: Mapping[int | None, CirculationApiType], + analytics: Analytics | None = None, + ): + super().__init__(db, library, library_collection_apis, analytics=analytics) + self.remote_loans: list[LoanInfo] = [] + self.remote_holds: list[HoldInfo] = [] + self.remotes: dict[str, MockPatronActivityCirculationAPI] = {} - def add_remote_loan(self, *args, **kwargs): - self.remote_loans.append(LoanInfo(*args, **kwargs)) + def add_remote_loan( + self, + loan: LoanInfo, + ) -> None: + self.remote_loans.append(loan) - def add_remote_hold(self, *args, **kwargs): - self.remote_holds.append(HoldInfo(*args, **kwargs)) + def add_remote_hold( + self, + hold: HoldInfo, + ) -> None: + self.remote_holds.append(hold) - def patron_activity(self, patron, pin): + def patron_activity( + self, patron: Patron, pin: str | None + ) -> tuple[list[LoanInfo], list[HoldInfo], bool]: """Return a 3-tuple (loans, holds, completeness).""" return self.remote_loans, self.remote_holds, True - def queue_checkout(self, licensepool, response): - self._queue("checkout", licensepool, response) - - def queue_hold(self, licensepool, response): - self._queue("hold", licensepool, response) - - def queue_fulfill(self, licensepool, response): - self._queue("fulfill", licensepool, response) - - def queue_checkin(self, licensepool, response): - self._queue("checkin", licensepool, response) - - def queue_release_hold(self, licensepool, response): - self._queue("release_hold", licensepool, response) - - def _queue(self, method, licensepool, response): - mock = self.api_for_license_pool(licensepool) - return mock._queue(method, response) - - def api_for_license_pool(self, licensepool): + def queue_checkout( + self, licensepool: LicensePool, response: LoanInfo | HoldInfo | Exception + ) -> None: + api = self.api_for_license_pool(licensepool) + api.queue_checkout(response) + + def queue_hold( + self, licensepool: LicensePool, response: HoldInfo | Exception + ) -> None: + api = self.api_for_license_pool(licensepool) + api.queue_hold(response) + + def queue_fulfill( + self, licensepool: LicensePool, response: FulfillmentInfo | Exception + ) -> None: + api = self.api_for_license_pool(licensepool) + api.queue_fulfill(response) + + def queue_checkin( + self, licensepool: LicensePool, response: None | Exception = None + ) -> None: + api = self.api_for_license_pool(licensepool) + api.queue_checkin(response) + + def queue_release_hold( + self, licensepool: LicensePool, response: None | Exception = None + ) -> None: + api = self.api_for_license_pool(licensepool) + api.queue_release_hold(response) + + def api_for_license_pool( + self, licensepool: LicensePool + ) -> MockPatronActivityCirculationAPI: source = licensepool.data_source.name + assert source is not None if source not in self.remotes: set_delivery_mechanism_at = BaseCirculationAPI.FULFILL_STEP can_revoke_hold_when_reserved = True @@ -166,8 +209,11 @@ def api_for_license_pool(self, licensepool): set_delivery_mechanism_at = BaseCirculationAPI.BORROW_STEP if source == DataSource.THREEM: can_revoke_hold_when_reserved = False - remote = MockRemoteAPI( - set_delivery_mechanism_at, can_revoke_hold_when_reserved + remote = MockPatronActivityCirculationAPI( + self._db, + licensepool.collection, + set_delivery_mechanism_at, + can_revoke_hold_when_reserved, ) self.remotes[source] = remote return self.remotes[source]