diff --git a/tests/test_umapi_engine.py b/tests/test_umapi_engine.py index e35705c4..f3c86b40 100644 --- a/tests/test_umapi_engine.py +++ b/tests/test_umapi_engine.py @@ -9,7 +9,7 @@ from tests.util import compare_iter from user_sync.connector.connector_umapi import Commands from user_sync.engine.common import AdobeGroup -from user_sync.engine.umapi import UmapiTargetInfo, UmapiConnectors, RuleProcessor +from user_sync.engine.umapi import UmapiTargetInfo, UmapiConnectors, RuleProcessor, MultiIndex @pytest.fixture @@ -90,7 +90,7 @@ def progress_func(*_): rp.logger.progress = progress_func key = rp.get_user_key(user['identity_type'], user['username'], user['domain']) - rp.directory_user_by_user_key[key] = user + rp.directory_user_index = MultiIndex(data=[user], key_names=['email', 'username']) rp.options['process_groups'] = True rp.push_umapi = True @@ -132,7 +132,7 @@ def update(up_user, up_attrs): conn = MockUmapiConnector() info = UmapiTargetInfo(None) user_key = rp.get_user_key(up_user['identity_type'], up_user['username'], up_user['domain']) - rp.directory_user_by_user_key[user_key] = up_user + rp.directory_user_index = MultiIndex(data=[up_user], key_names=['email', 'username']) commands = [rp.update_umapi_user(info, user_key, up_attrs, group_add, group_rem, mock_umapi_user)] rp.execute_commands(commands, conn) assert user_key in rp.updated_user_keys @@ -326,9 +326,8 @@ def test_read_desired_user_groups_basic(rule_processor, mock_dir_user): assert "Console Group" in rp.after_mapping_hook_scope['target_groups'] # Assert the user group updated in umapi info - user_key = rp.get_directory_user_key(mock_dir_user) - assert ('console group' in rp.umapi_info_by_name[None].desired_groups_by_user_key[user_key]) - assert user_key in rp.filtered_directory_user_by_user_key + assert 'console group' in rp.umapi_info_by_name[None].get_desired_groups(email=mock_dir_user['email'], username=mock_dir_user['username'])['desired_groups'] + assert mock_dir_user['email'] == rp.filtered_directory_user_index.data[0]['email'] @mock.patch('user_sync.helper.CSVAdapter.read_csv_rows') def test_read_stray_key_map(csv_reader, rule_processor): @@ -477,22 +476,123 @@ def compare_attr(text, target): compare_attr(x[5], state['target_attributes']) -class TestUmapiTargetInfo(): - def test_add_mapped_group(self): - umapi_target_info = UmapiTargetInfo("") - umapi_target_info.add_mapped_group("All Students") - assert "all students" in umapi_target_info.mapped_groups - assert "All Students" in umapi_target_info.non_normalize_mapped_groups - - def test_add_additional_group(self): - umapi_target_info = UmapiTargetInfo("") - umapi_target_info.add_additional_group('old_name', 'new_name') - assert umapi_target_info.additional_group_map['old_name'][0] == 'new_name' - - def test_add_desired_group_for(self): - umapi_target_info = UmapiTargetInfo("") - with mock.patch("user_sync.engine.umapi.UmapiTargetInfo.get_desired_groups") as mock_desired_groups: - mock_desired_groups.return_value = None - umapi_target_info.add_desired_group_for('user_key', 'group_name') - assert umapi_target_info.desired_groups_by_user_key['user_key'] == {'group_name'} +def test_targetinfo_add_mapped_group(): + umapi_target_info = UmapiTargetInfo("") + umapi_target_info.add_mapped_group("All Students") + assert "all students" in umapi_target_info.mapped_groups + assert "All Students" in umapi_target_info.non_normalize_mapped_groups + +def test_targetinfo_add_additional_group(): + umapi_target_info = UmapiTargetInfo("") + umapi_target_info.add_additional_group('old_name', 'new_name') + assert umapi_target_info.additional_group_map['old_name'][0] == 'new_name' + +def test_targetinfo_add_desired_group_for(): + umapi_target_info = UmapiTargetInfo(None) + umapi_target_info.add_desired_group_for('federatedID', 'example.com', 'user@example.com', 'user@example.com', 'group_name') + assert 'group_name' in umapi_target_info.get_desired_groups('user@example.com', 'user@example.com')['desired_groups'] + + +@pytest.fixture +def test_data(): + return [{ + "email": "user1@example.com", + "username": "user1.un@example.com", + "firstname": "Test", + "lastname": "User 001", + },{ + "email": "user2@example.com", + "username": "user2.un@example.com", + "firstname": "Test", + "lastname": "User 002", + },{ + "email": "user3@example.com", + "username": "user3.un@example.com", + "firstname": "Test", + "lastname": "User 003", + }] + +def test_new_multi_index(test_data): + """Construct valid MultiIndex with no errors""" + user_index = MultiIndex(data=test_data, key_names=['email', 'username']) + assert user_index.data == test_data + assert user_index.key_names == ['email', 'username'] + assert user_index.index['email'] == {'user1@example.com': 0, 'user2@example.com': 1, 'user3@example.com': 2} + assert user_index.index['username'] == {'user1.un@example.com': 0, 'user2.un@example.com': 1, 'user3.un@example.com': 2} + + +def test_new_multi_index_keyerr(test_data): + """MultiIndex data must be complete""" + del test_data[1]['email'] + + with pytest.raises(KeyError): + MultiIndex(data=test_data, key_names=['email', 'username']) + + +def test_multi_index_retrieve(test_data): + """Ensure we get one record from MultiIndex""" + user_index = MultiIndex(data=test_data, key_names=['email', 'username']) + # get a single user with exact email/username match + user = user_index.get(email='user3@example.com', username='user3.un@example.com') + assert user['firstname'] == 'Test' + assert user['lastname'] == 'User 003' + + # get a single user where email matches but not username + user = user_index.get(email='user2@example.com', username='user2@example.com') + assert user['firstname'] == 'Test' + assert user['lastname'] == 'User 002' + + # get a single record with a single key + user = user_index.get(email='user1@example.com') + assert user['firstname'] == 'Test' + assert user['lastname'] == 'User 001' + + +def test_multi_index_retrieve_none(test_data): + user_index = MultiIndex(data=test_data, key_names=['email', 'username']) + result = user_index.get(email='user4@example.com', username='user4.un@example.com') + assert result is None + + +def test_multi_index_nonexistent_key(test_data): + user_index = MultiIndex(data=test_data, key_names=['email', 'username']) + with pytest.raises(KeyError): + user_index.get(foobar='user1@example.com') + + +def test_multi_index_add_record(test_data): + user_index = MultiIndex(data=test_data, key_names=['email', 'username']) + user_index.add({ + 'email': 'user4@example.com', + 'username': 'user4.un@example.com', + 'firstname': 'Test', + 'lastname': 'User 004', + }) + + user = user_index.get(email='user4@example.com', username='user4.un@example.com') + assert user['firstname'] == 'Test' + assert user['lastname'] == 'User 004' + + +def test_multi_index_add_record_err(test_data): + user_index = MultiIndex(data=test_data, key_names=['email', 'username']) + with pytest.raises(KeyError): + user_index.add({ + 'email': 'user4@example.com', + 'firstname': 'Test', + 'lastname': 'User 004', + }) + + +def test_multi_index_update(test_data): + user_index = MultiIndex(data=test_data, key_names=['email', 'username']) + user = test_data[0].copy() + user['firstname'] = 'Test Updated' + user['lastname'] = 'User 001 Updated' + + user_index.update(user, email=user['email'], username=user['username']) + + user = user_index.get(email=user['email'], username=user['username']) + assert user['firstname'] == 'Test Updated' + assert user['lastname'] == 'User 001 Updated' diff --git a/user_sync/engine/umapi.py b/user_sync/engine/umapi.py index bfdc390d..ea2b7402 100644 --- a/user_sync/engine/umapi.py +++ b/user_sync/engine/umapi.py @@ -68,8 +68,8 @@ def __init__(self, caller_options): options = dict(self.default_options) options.update(caller_options) self.options = options - self.directory_user_by_user_key = {} - self.filtered_directory_user_by_user_key = {} + self.directory_user_index = MultiIndex([], ['email', 'username']) + self.filtered_directory_user_index = MultiIndex([], ['email', 'username']) self.umapi_info_by_name = {} self.adobeid_user_by_email = {} # counters for action summary log @@ -228,8 +228,8 @@ def log_action_summary(self, umapi_connectors): """ logger = self.logger # find the total number of directory users and selected/filtered users - self.action_summary['directory_users_read'] = len(self.directory_user_by_user_key) - self.action_summary['directory_users_selected'] = len(self.filtered_directory_user_by_user_key) + self.action_summary['directory_users_read'] = len(self.directory_user_index.data) + self.action_summary['directory_users_selected'] = len(self.filtered_directory_user_index.data) # find the total number of adobe users and excluded users self.action_summary['primary_users_read'] = self.primary_user_count self.action_summary['excluded_user_count'] = self.excluded_user_count @@ -363,8 +363,6 @@ def read_desired_user_groups(self, mappings, directory_connector): directory_group_filter = set(directory_group_filter) extended_attributes = options.get('extended_attributes') - directory_user_by_user_key = self.directory_user_by_user_key - directory_groups = set(mappings.keys()) if self.will_process_groups() else set() if directory_group_filter is not None: directory_groups.update(directory_group_filter) @@ -377,15 +375,15 @@ def read_desired_user_groups(self, mappings, directory_connector): if not user_key: self.logger.warning("Ignoring directory user with empty user key: %s", directory_user) continue - directory_user_by_user_key[user_key] = directory_user + + self.directory_user_index.add(directory_user) if not self.is_directory_user_in_groups(directory_user, directory_group_filter): continue if not self.is_selected_user_key(user_key): continue - self.filtered_directory_user_by_user_key[user_key] = directory_user - self.get_umapi_info(PRIMARY_TARGET_NAME).add_desired_group_for(user_key, None) + self.filtered_directory_user_index.add(directory_user) # set up groups in hook scope; the target groups will be used whether or not there's customer hook code self.after_mapping_hook_scope['source_groups'] = set() @@ -422,7 +420,8 @@ def read_desired_user_groups(self, mappings, directory_connector): target_group = AdobeGroup.lookup(target_group_qualified_name) if target_group is not None: umapi_info = self.get_umapi_info(target_group.get_umapi_name()) - umapi_info.add_desired_group_for(user_key, target_group.get_group_name()) + umapi_info.add_desired_group_for(directory_user['identity_type'], directory_user['domain'], + directory_user['email'], directory_user['username'], target_group.get_group_name()) else: self.logger.error('Target adobe group %s is not known; ignored', target_group_qualified_name) @@ -442,11 +441,12 @@ def read_desired_user_groups(self, mappings, directory_connector): raise user_sync.error.AssertionException("Additional group resolution error: {}".format(str(e))) umapi_info.add_mapped_group(rename_group) umapi_info.add_additional_group(rename_group, member_group) - umapi_info.add_desired_group_for(user_key, rename_group) + umapi_info.add_desired_group_for(directory_user['identity_type'], directory_user['domain'], + directory_user['email'], directory_user['username'], rename_group) - self.logger.debug('Total directory users after filtering: %d', len(self.filtered_directory_user_by_user_key)) + self.logger.debug('Total directory users after filtering: %d', len(self.filtered_directory_user_index.data)) if self.logger.isEnabledFor(logging.DEBUG): - self.logger.debug('Group work list: %s', dict([(umapi_name, umapi_info.get_desired_groups_by_user_key()) + self.logger.debug('Group work list: %s', dict([(umapi_name, umapi_info.get_desired_groups_by_user_key().data) for umapi_name, umapi_info in self.umapi_info_by_name.items()])) @@ -485,21 +485,23 @@ def sync_umapi_users(self, umapi_connectors): self.logger.debug('%sing users to umapi...', verb) umapi_info, umapi_connector = self.get_umapi_info(PRIMARY_TARGET_NAME), umapi_connectors.get_primary_connector() if self.push_umapi: - primary_adds_by_user_key = umapi_info.get_desired_groups_by_user_key() + primary_adds = umapi_info.get_desired_groups_by_user_key().data else: - primary_adds_by_user_key, update_commands = self.update_umapi_users_for_connector(umapi_info, umapi_connector) + primary_adds, update_commands = self.update_umapi_users_for_connector(umapi_info, umapi_connector) primary_commands.extend(update_commands) # save groups for new users - total_users = len(primary_adds_by_user_key) + total_users = len(primary_adds.data) user_count = 0 - for user_key, groups_to_add in primary_adds_by_user_key.items(): + for primary_add in primary_adds.data: user_count += 1 - if exclude_unmapped_users and not groups_to_add: + if exclude_unmapped_users and not primary_add['desired_groups']: # If user is not part of any group and ignore outcast is enabled. Do not create user. continue - primary_commands.append(self.create_umapi_user(user_key, groups_to_add, umapi_info, umapi_connector.trusted)) + user_key = self.get_user_key(primary_add['id_type'], primary_add['username'], + primary_add['domain'], primary_add['email']) + primary_commands.append(self.create_umapi_user(user_key, primary_add['desired_groups'], umapi_info, umapi_connector.trusted)) # then sync the secondary connectors for umapi_name, umapi_connector in umapi_connectors.get_secondary_connectors().items(): @@ -512,15 +514,18 @@ def sync_umapi_users(self, umapi_connectors): else: secondary_adds_by_user_key, update_commands = self.update_umapi_users_for_connector(umapi_info, umapi_connector) secondary_command_lists[umapi_name].extend(update_commands) - total_users = len(secondary_adds_by_user_key) - for user_key, groups_to_add in secondary_adds_by_user_key.items(): + total_users = len(secondary_adds_by_user_key.data) + for secondary_add in secondary_adds_by_user_key.data: # We only create users who have group mappings in the secondary umapi - if groups_to_add: + if secondary_add['desired_groups']: + user_key = self.get_user_key(secondary_add['id_type'], secondary_add['username'], + secondary_add['domain'], secondary_add['email']) self.secondary_users_created.add(user_key) if user_key not in self.primary_users_created: # We pushed an existing user to a secondary in order to update his groups self.updated_user_keys.add(user_key) - secondary_command_lists[umapi_name].append(self.create_umapi_user(user_key, groups_to_add, umapi_info, umapi_connector.trusted)) + secondary_command_lists[umapi_name].append(self.create_umapi_user(user_key, secondary_add['desired_groups'], + umapi_info, umapi_connector.trusted)) return primary_commands, secondary_command_lists def execute_commands(self, command_list, connector): @@ -800,7 +805,8 @@ def create_umapi_user(self, user_key, groups_to_add, umapi_info, trusted): :type umapi_info: UmapiTargetInfo :type trusted: bool """ - directory_user = self.directory_user_by_user_key[user_key] + directory_user = self.get_from_index(self.directory_user_index, user_key) + commands = self.create_umapi_commands_for_directory_user(directory_user, self.will_update_user_info(umapi_info), trusted) if not commands: return @@ -817,6 +823,12 @@ def create_umapi_user(self, user_key, groups_to_add, umapi_info, trusted): self.primary_users_created.add(user_key) return commands + def get_from_index(self, index, user_key): + """Parse user key and try to retrieve user from provided index""" + + _, username, _, email = self.parse_user_key(user_key) + return index.get(email=email, username=username) + def update_umapi_user(self, umapi_info, user_key, attributes_to_update=None, groups_to_add=None, groups_to_remove=None, umapi_user=None): # Note that the user may exist only in the directory, only in the umapi, or both at this point. @@ -843,8 +855,8 @@ def update_umapi_user(self, umapi_info, user_key, attributes_to_update=None, gro self.logger.info('Managing groups in %s for user key: %s added: %s removed: %s', umapi_info.get_name(), user_key, groups_to_add, groups_to_remove) - if user_key in self.directory_user_by_user_key: - directory_user = self.directory_user_by_user_key[user_key] + directory_user = self.get_from_index(self.directory_user_index, user_key) + if directory_user is not None: identity_type = self.get_identity_type_from_directory_user(directory_user) else: directory_user = umapi_user @@ -888,13 +900,11 @@ def update_umapi_users_for_connector(self, umapi_info, umapi_connector: UmapiCon """ command_list = [] - filtered_directory_user_by_user_key = self.filtered_directory_user_by_user_key - # the way we construct the return value is to start with a map from all directory users # to their groups in this umapi, make a copy, and pop off any adobe users we find. # That way, any key/value pairs left in the map are the unmatched adobe users and their groups. - user_to_group_map = umapi_info.get_desired_groups_by_user_key() - user_to_group_map = {} if user_to_group_map is None else user_to_group_map.copy() + dir_user_groups_all = umapi_info.get_desired_groups_by_user_key() + dir_user_groups_update = MultiIndex([], ['email', 'username']) # compute all static options before looping over users in_primary_org = self.is_primary_org(umapi_info) @@ -909,7 +919,7 @@ def update_umapi_users_for_connector(self, umapi_info, umapi_connector: UmapiCon umapi_users = self.get_umapi_user_in_groups(umapi_info, umapi_connector, self.options['adobe_group_filter']) else: umapi_users = umapi_connector.iter_users() - # Walk all the adobe users, getting their group data, matching them with directory users, + # Walk all the adobe us # and adjusting their attribute and group data accordingly. for umapi_user in umapi_users: # if target is ESM, then override identity type @@ -922,10 +932,10 @@ def update_umapi_users_for_connector(self, umapi_info, umapi_connector: UmapiCon if not user_key: self.logger.warning("Ignoring umapi user with empty user key: %s", umapi_user) continue - if umapi_info.get_umapi_user(user_key) is not None: + if umapi_info.get_umapi_user(email=umapi_user['email'], username=umapi_user['username']) is not None: self.logger.debug("Ignoring umapi user. This user has already been processed: %s", umapi_user) continue - umapi_info.add_umapi_user(user_key, umapi_user) + umapi_info.add_umapi_user(umapi_user) attribute_differences = {} current_groups = self.normalize_groups(umapi_user.get('groups')) groups_to_add = set() @@ -935,7 +945,11 @@ def update_umapi_users_for_connector(self, umapi_info, umapi_connector: UmapiCon # map because we know they don't need to be created. # Also, keep track of the mapped groups for the directory user # so we can update the adobe user's groups as needed. - desired_groups = user_to_group_map.pop(user_key, None) or set() + desired_groups_rec = self.get_from_index(dir_user_groups_all, user_key) + desired_groups = set() + if desired_groups_rec is not None: + dir_user_groups_update.add(desired_groups_rec) + desired_groups = desired_groups_rec['desired_groups'] # check for excluded users if self.is_umapi_user_excluded(in_primary_org, user_key, current_groups): @@ -943,7 +957,7 @@ def update_umapi_users_for_connector(self, umapi_info, umapi_connector: UmapiCon self.map_email_override(umapi_user) - directory_user = filtered_directory_user_by_user_key.get(user_key) + directory_user = self.get_from_index(self.filtered_directory_user_index, user_key) if directory_user is None: # There's no selected directory user matching this adobe user # so we mark this adobe user as a stray, and we mark him @@ -975,7 +989,12 @@ def update_umapi_users_for_connector(self, umapi_info, umapi_connector: UmapiCon groups_to_add, groups_to_remove, umapi_user)) # mark the umapi's adobe users as processed and return the remaining ones in the map umapi_info.set_umapi_users_loaded() - return (user_to_group_map, command_list) + new_user_groups = MultiIndex([], ['email', 'username']) + for user in dir_user_groups_all.data: + r = dir_user_groups_update.get(email=user['email'], username=user['username']) + if r is None: + new_user_groups.add(user) + return (new_user_groups, command_list) def map_email_override(self, umapi_user): """ @@ -1243,7 +1262,7 @@ def execute_actions(self): break -class UmapiTargetInfo(object): +class UmapiTargetInfo: def __init__(self, name): """ :type name: str @@ -1251,12 +1270,9 @@ def __init__(self, name): self.name = name self.mapped_groups = set() self.non_normalize_mapped_groups = set() - self.desired_groups_by_user_key = {} - self.umapi_user_by_user_key = {} + self.desired_groups_by_user_key = MultiIndex(data=[], key_names=['email', 'username']) + self.umapi_user_by_user_key = MultiIndex(data=[], key_names=['email', 'username']) self.umapi_users_loaded = False - self.stray_by_user_key = {} - self.groups_added_by_user_key = {} - self.groups_removed_by_user_key = {} # keep track of auto-mapped additional groups for conflict tracking. # if feature is disabled, this dict will be empty @@ -1290,40 +1306,49 @@ def get_non_normalize_mapped_groups(self): def get_desired_groups_by_user_key(self): return self.desired_groups_by_user_key - def get_desired_groups(self, user_key): + def get_desired_groups(self, email, username): """ :type user_key: str """ - desired_groups = self.desired_groups_by_user_key.get(user_key) - return desired_groups + return self.desired_groups_by_user_key.get(email=email, username=username) - def add_desired_group_for(self, user_key, group): + def add_desired_group_for(self, id_type, domain, email, username, group): """ :type user_key: str :type group: Optional(str) """ - desired_groups = self.get_desired_groups(user_key) - if desired_groups is None: - self.desired_groups_by_user_key[user_key] = desired_groups = set() - if group is not None: - normalized_group_name = normalize_string(group) - desired_groups.add(normalized_group_name) - - def add_umapi_user(self, user_key, user): + if group is None: + return + + normalized_group_name = normalize_string(group) + desired_groups_rec = self.get_desired_groups(email, username) + if desired_groups_rec is None: + groups = set() + groups.add(normalized_group_name) + desired_groups_rec = { + 'id_type': id_type, + 'domain': domain, + 'email': email, + 'username': username, + 'desired_groups': groups, + } + self.desired_groups_by_user_key.add(desired_groups_rec) + else: + desired_groups_rec['desired_groups'].add(normalized_group_name) + self.desired_groups_by_user_key.update(desired_groups_rec, email=email, username=username) + + def add_umapi_user(self, user): """ :type user_key: str :type user: dict """ - self.umapi_user_by_user_key[user_key] = user - - def iter_umapi_users(self): - return self.umapi_user_by_user_key.items() + self.umapi_user_by_user_key.add(user) - def get_umapi_user(self, user_key): + def get_umapi_user(self, email, username): """ :type user_key: str """ - return self.umapi_user_by_user_key.get(user_key) + return self.umapi_user_by_user_key.get(email=email, username=username) def set_umapi_users_loaded(self): self.umapi_users_loaded = True @@ -1333,3 +1358,94 @@ def is_umapi_users_loaded(self): def __repr__(self): return "UmapiTargetInfo('name': %s)" % self.name + + +class MultiIndex: + """ + This data structure replaces the old convention of caching users in a + dictionary indexed by a static composite key. The MultiIndex structure + consists of a simple list (self.data) consisting of one or more dictionaries + that follow a regular structure (e.g. a list of directory users or UMAPI + users). + + This list is indexed by one or more keys. Each key should point to one + record in self.data. + + When a record is fetched from the index, a record is returned if at least + one key matches a record. This allows partial matches - i.e. when retrieving + information for a user where the email address matches a record but the + username does not (or vice versa). + + Example: + + >>> data = [{"key1": "foo", "key2": "bar", "other": "data"}] + >>> mi = MultiIndex(data=data, key_names=["key1", "key2"]) + >>> mi.get(key1="foo", key2="bar") + {"key1": "foo", "key2": "bar", "other": "data"} + >>> mi.get(key1="foo", key2="invalid") + {"key1": "foo", "key2": "bar", "other": "data"} + >>> mi.get(key1="invalid", key2="invalid") + None + + MultiIndex supports the addition of new individual records and + the ability to update existing records. It does not support + deletion because that would require a full reindex for each + deletion. + """ + def __init__(self, data, key_names): + self.data = data + self.key_names = key_names + self.index = {} + for kn in key_names: + self.index[kn] = {} + self.build_index() + + def build_index(self): + for i, obj in enumerate(self.data): + self.index_obj(i, obj) + + def get_index(self, **kwargs): + for kn, k in kwargs.items(): + keys = self.index.get(kn) + if keys is None: + raise KeyError(f"Key '{kn}' not found in index") + i = keys.get(k.lower()) + if i is None: + continue + return i + return None + + def get(self, **kwargs): + i = self.get_index(**kwargs) + return self.data[i] if i is not None else None + + def index_obj(self, i, obj): + for kn in self.key_names: + k = obj.get(kn) + if k is None: + raise KeyError(f"Can't find key '{kn}' on object {obj=}") + self.index[kn][k.lower()] = i + + def add(self, obj): + i = len(self.data) + self.data.append(obj) + self.index_obj(i, obj) + + def update(self, obj, **kwargs): + i = self.get_index(**kwargs) + if i is None: + raise ValueError(f"Can't find object for any key {kwargs=}") + + curr_obj = self.data[i] + reindex = {} + for kn in self.key_names: + if kn not in obj: + raise KeyError(f"Can't find key '{kn}' on object {obj=}") + if curr_obj[kn].lower() != obj[kn].lower(): + reindex[kn] = (curr_obj[kn].lower(), obj[kn].lower()) + + self.data[i] = obj + for kn, keys in reindex.items(): + old, new = keys + del self.index[kn][old] + self.index[kn][new] = i