def __init__(self, event_sink, storage_id, storage_cred_reader, storage_cred_writer, storage_cache_dir=None, polling_interval=5): """ :param event_sink: sink to report events to :param storage_id: unique sotrage id assigned to this storage :param storage_cred_reader: reader to reaad credentials or other auth data from :param storage_cred_writer: writer to write credentials or other auth data to :param storage_cache_dir: cache directory to store cached models at :param polling_interval: the intervall to poll for changes from the service """ # pylint: disable=too-many-arguments super().__init__(event_sink, storage_id, storage_cred_reader, storage_cred_writer, storage_cache_dir=storage_cache_dir, default_model=IndexingNode(None, indexes=['_id'])) self.event_poller = None self.polling_interval = polling_interval
def root(request): """ Creates a default Root node :return: Node """ if request.param == 'standard': return Node(name=None) return IndexingNode(name=None, indexes=['_id'])
def filter_tree_by_path(tree, filter_tree, children_default=True): """ Copies the tree. It is filtered by the filters argument. :param tree: Tree to filter :param filters: The bool element states if the children on the same level should be included or not. If the filter for that item does not exist `children_default` is assumed. The filter with the longest path in a branch will also include all children recursively. :return: filtered tree """ if isinstance(tree, IndexingNode): new_root = IndexingNode(name=None, indexes=tree.index.keys()) else: new_root = type(tree)(name=None) for node in tree: if is_path_synced(node.path, filter_tree, node.props.get('is_dir', False), children_default): new_root.get_node_safe(node.path).props.update(node.props) return new_root
def indexing_node(request): """ fixture to test the indexing node """ if request.param == 'standard': return IndexingNode(None, indexes=['_id']) elif request.param == 'factory': old_root = IndexingNode(None, indexes=['_id']) new_root = old_root.create_instance() old_root.name = 'hello' old_root.parent = new_root return new_root elif request.param == 'copied': old_root = IndexingNode(None, indexes=['_id']) return deepcopy(old_root) elif request.param == 'pickled': old_root = IndexingNode(None, indexes=['_id']) fobj = io.BytesIO() pickle.dump(old_root, fobj) fobj.seek(0) return pickle.load(fobj) else: assert False
class GoogleDrive(jars.OAuthBasicStorage): """ Main class to use google drive""" # Credentials you get from registering a new application client_identificator = 144880602865 client_id = '{}.apps.googleusercontent.com'.format(client_identificator) client_secret = 'CLIENT_SECRET' redirect_uri = 'http://localhost:9324' # OAuth endpoints given in the Google API documentation authorization_base_url = "https://accounts.google.com/o/oauth2/v2/auth" token_url = "https://accounts.google.com/o/oauth2/token" scope = [ "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/drive" ] base_url = 'https://www.googleapis.com/drive/v3/' storage_name = "gdrive" storage_display_name = "Google Drive" def __init__(self, event_sink, storage_id, storage_cred_reader, storage_cred_writer, storage_cache_dir=None, polling_interval=5): """ :param event_sink: sink to report events to :param storage_id: unique sotrage id assigned to this storage :param storage_cred_reader: reader to reaad credentials or other auth data from :param storage_cred_writer: writer to write credentials or other auth data to :param storage_cache_dir: cache directory to store cached models at :param polling_interval: the intervall to poll for changes from the service """ # pylint: disable=too-many-arguments super().__init__(event_sink, storage_id, storage_cred_reader, storage_cred_writer, storage_cache_dir=storage_cache_dir, default_model=IndexingNode(None, indexes=['_id'])) self.event_poller = None self.polling_interval = polling_interval def _init_poller(self): """initalize poller""" def set_offline(value): """Set it offline""" self.offline = value # creating poller getting events from the storage return jars.PollingScheduler(self.polling_interval, target=self.update_model, target_kwargs={'emit_events': True}, offline_callback=set_offline) def check_available(self): # check authentication try: url = urllib.parse.urljoin(self.base_url, 'about?fields=storageQuota,user') about = self.oauth_session.get(url) error_mapper(about.raise_for_status)() except requests.exceptions.HTTPError as exception: if exception.response.status_code == 401: raise AuthenticationError(storage_id=self.storage_id, origin_error=exception) else: raise StorageOfflineError(storage_id=self.storage_id, origin_error=exception) except BaseException as error: raise StorageOfflineError(storage_id=self.storage_id, origin_error=error) @error_mapper @gdrive_error_mapper def get_tree(self, cached=False, filtered=True): # pylint: disable=arguments-differ, too-many-locals logger.debug('requested tree with filter %s', self.filter_tree) if cached: with self.model.lock: if filtered: logger.debug('requested tree with filter %s', self.filter_tree) return jars.utils.filter_tree_by_path( tree=self.model, filter_tree=self.filter_tree) else: return copy.deepcopy(self.model) root = IndexingNode(name=None, indexes=['_id']) # fetch and store current cursor url = urllib.parse.urljoin(self.base_url, 'changes/startPageToken') token = self.oauth_session.get(url) token.raise_for_status() root.props['_cursor'] = token.json()['startPageToken'] # get root id url = urllib.parse.urljoin(self.base_url, 'files/root?fields=id') root_id = self.oauth_session.get(url) root_id.raise_for_status() root.props['_id'] = root_id.json()['id'] # fetch metrics url = urllib.parse.urljoin(self.base_url, 'about?fields=storageQuota,user') about = self.oauth_session.get(url) about.raise_for_status() metrics = about.json() # this is relevant to client#970 total_space = int(metrics['storageQuota'].get('limit', sys.maxsize)) free_space = total_space - int(metrics['storageQuota']['usageInDrive']) root.props[METRICS] = StorageMetrics(storage_id=self.storage_id, free_space=free_space, total_space=total_space) # fetch tree url = urllib.parse.urljoin(self.base_url, 'files') page_token = None nodes = [] # fetch the whole tree into the parent_map while True: params = { 'pageSize': '1000', 'q': 'trashed=false', 'orderBy': 'folder', 'fields': 'nextPageToken,files(' + FIELDS + ')' } if page_token is not None: params['pageToken'] = page_token files_resp = self.oauth_session.get(url, params=params) files_resp.raise_for_status() resp_json = files_resp.json() files = resp_json['files'] page_token = resp_json.get('nextPageToken', None) for file in files: if 'parents' in file and is_relevant_file(file): for parent_id in file['parents']: # for each parent add this element if is_relevant_file(file): nodes.append( NodeChange(action='UPSERT', parent_id=parent_id, name=file['name'], props=gdrive_to_node_props(file))) if page_token is None: break root.add_merge_set_with_id(nodes, key='_id') self._adjust_share_ids(nodes) self.offline = False if filtered: logger.debug('requested tree with filter %s', self.filter_tree) root = jars.utils.filter_tree_by_path(root, self.filter_tree) return root def _get_children(self, parent_id): body = { 'q': "'{}' in parents and trashed=false".format(parent_id), 'fields': 'files({})'.format(FIELDS) } response = self.oauth_session.get( 'https://www.googleapis.com/drive/v3/files', params=body) response.raise_for_status() result = [] for file in response.json()['files']: if not is_relevant_file(file): continue props = gdrive_to_node_props(file) result.append((file['name'], props)) return result @error_mapper @gdrive_error_mapper def get_tree_children(self, path): """ :param path: if path is a list, it is used like a path, if a string, it is used as parent_id :param ids: if the function should return the _id field in the properties :return: """ if path: parent_id = self._path_to_file_id(path) else: # [] is the root path, no need for extra resolve parent_id = 'root' return self._get_children(parent_id) def start_events(self): """ starts the event poller """ if (self.event_poller and not self.event_poller.is_alive()) \ or not self.event_poller: # recreate poller instance self.event_poller = self._init_poller() self.event_poller.start() def stop_events(self, join=False): if self.event_poller: self.event_poller.stop(join=join) def clear_model(self): self.model = IndexingNode(None, indexes=['_id']) def get_internal_model(self): return self.model def update(self): with self.model.lock: if '_cursor' not in self.model.props: # no model ever fetched, fetch model self.model = self.get_tree(filtered=False) else: self.update_model(emit_events=False) @error_mapper @gdrive_error_mapper def update_model(self, emit_events=False): """ updates a model with a given cursor and returns the new cursor if emit_events is set it also triggers events on changes """ # TODO lock less, create iterator with self.model.lock: mergeset = [] assert '_cursor' in self.model.props page_token = str(self.model.props['_cursor']) while page_token is not None: urlparams = { 'pageToken': page_token, 'includeRemoved': 'true', 'pageSize': '1000', 'fields': 'changes,newStartPageToken,nextPageToken' } url = urllib.parse.urljoin(self.base_url, 'changes') changes_resp = self.oauth_session.get(url, params=urlparams) changes_resp.raise_for_status() changes = changes_resp.json() # logger.debug(threading.current_thread().name) # logger.debug(changes) page_token = changes.get('nextPageToken') if 'newStartPageToken' in changes: self.model.props['_cursor'] = changes['newStartPageToken'] for change in changes['changes']: merges = transform_gdrive_change(change) mergeset.extend(merges) logger.info("Finished fetching updates from gdrive.") with contextlib.ExitStack() as stack: if emit_events: stack.enter_context( jars.TreeToSyncEngineEngineAdapter( node=self.model, storage_id=self.storage_id, sync_engine=self._event_sink)) left_overs = self.model.add_merge_set_with_id( mergeset, '_id', using_update=True) if left_overs: logger.info('Left overs from delta: %s', left_overs) logger.debug('After update') self._adjust_share_ids(mergeset) logger.debug('After _adjust_share_ids') def _adjust_share_ids(self, mergeset): """Helper to identify the unique share ids on gdrive, if the permissions of the parent are different then the ones on the children, we assume this is a different share""" for change in mergeset: action, parent_id, name, props = change logger.info("_adjust_share_ids, change: %s", change) # Removing files does not change any share_ids if action == 'DELETE': logger.debug("Not handling DELETEs") continue # The node isn't shared with anyone, skip it if SHARED_WITH not in props: logger.debug("Node isn't shared with anyone, skipping") continue # The node does not exist in our index(?) if not props['_id'] in self.model.index['_id']: logger.debug("Node does not exist in our model") continue # Get a reference to the props dict of our model props = self.model.index['_id'][props['_id']].props shared_with = props[SHARED_WITH] # Get the node's parent - this can be None, if the node is root parent_node = self.model.index['_id'].get(parent_id, None) logger.info( "Checking change for file '%s' (shared_with: %s, parent_node shared_with: %s)", name, shared_with, set() if parent_node is None else parent_node.props.get( SHARED_WITH, set())) if parent_node is None or shared_with != parent_node.props.get( SHARED_WITH, set()): # Either we are looking at root, or the node has been shared with different people # than it's parent -> it's a different share logger.debug( 'setting share id for: %s (Parent: %s, Node: %s) to %s', name, set() if parent_node is None else parent_node.props.get( SHARED_WITH, set()), props.get(SHARED_WITH, set()), props['_id']) props[jars.SHARE_ID] = props['_id'] else: # The node has the same list of people it is shared to as it's parent # -> they belong to the same share # Remove the share_id on the node, we only need it on the parent logger.debug( 'Removing share_id from %s (Parent: %s, Node: %s)', name, parent_node.props.get(SHARED_WITH, set()), props.get(SHARED_WITH, set())) props.pop(jars.SHARE_ID, None) @error_mapper @gdrive_error_mapper def move(self, source, target, expected_source_vid=None, expected_target_vid=None): new_fields = {} params = {'fields': 'mimeType,' + VERSION_FIELD} # rename if source[-1] != target[-1]: new_fields['name'] = target[-1] target_id = None source_id = None source_dir_id = None with self.model.lock: try: source_node = self.model.get_node(source) except KeyError: raise FileNotFoundError() source_id = source_node.props['_id'] source_dir_id = source_node.parent.props['_id'] try: target_node = self.model.get_node(target) target_id = target_node.props['_id'] except KeyError: target_id = None # move to another dir? if source[:-1] != target[:-1]: target_dir_id = self.make_dir(target[:-1], return_id=True) params['addParents'] = target_dir_id params['removeParents'] = source_dir_id # before the actual action check the version self._check_version_id(source_id, expected_source_vid, source) if target_id is not None: # replace also involves deletion of the orignal item, # version checking is done there self.delete(target, expected_target_vid) request = self.oauth_session.patch(urllib.parse.urljoin( self.base_url, 'files/{}'.format(source_id)), json=new_fields, params=params) request.raise_for_status() file = request.json() if file['mimeType'] == MIMETYPE_FOLDER: return IS_DIR else: return file[VERSION_FIELD] def _check_version_id(self, file_id, expected_version_id, path=None): url = urllib.parse.urljoin(self.base_url, 'files/{}'.format(file_id)) if expected_version_id is not None: response = self.oauth_session.get( url, params={'fields': 'mimeType,' + VERSION_FIELD}) if expected_version_id == 'is_dir' \ and response.json()['mimeType'] == MIMETYPE_FOLDER: # its fine, dir to dir return response.raise_for_status() if response.json()[VERSION_FIELD] != expected_version_id: raise jars.VersionIdNotMatchingError( self.storage_id, path=path, version_b=expected_version_id, version_a=response.json()[VERSION_FIELD]) @error_mapper @gdrive_error_mapper def open_read(self, path, expected_version_id=None): file_id = self._path_to_file_id(path) self._check_version_id(file_id, expected_version_id, path) url = urllib.parse.urljoin(self.base_url, 'files/{}'.format(file_id)) # 'Accept-Encoding' is removed from the header until # https://github.com/shazow/urllib3/issues/437 # is fixed and merged into requests response = self.oauth_session.get(url, params={'alt': 'media'}, headers={'Accept-Encoding': None}, stream=True) response.raise_for_status() response.raw.decode_content = True return response.raw @error_mapper @gdrive_error_mapper def make_dir(self, path, return_id=False): # pylint: disable=arguments-differ # which parents to create # this needs to be locket until everything is done, since it might run parallel with jars.TreeToSyncEngineEngineAdapter(node=self.model, storage_id=self.storage_id, sync_engine=self._event_sink): parent = self.model # iterate the path for elem in path: if parent.has_child(elem): parent = parent.get_node([elem]) else: url = urllib.parse.urljoin(self.base_url, 'files/') body = { 'mimeType': 'application/vnd.google-apps.folder', 'name': elem, 'parents': [parent.props['_id']] } resp = self.oauth_session.post(url, json=body, params={'fields': FIELDS}) resp.raise_for_status() # node.props['_id'] = resp.json()['id'] props = gdrive_to_node_props(resp.json()) node = parent.add_child(name=elem, props=props) parent = node if return_id: return self.model.get_node(path).props['_id'] else: return 'is_dir' @error_mapper @gdrive_error_mapper def _path_to_file_id(self, path): """ resolves the path via the model or if this does not work via http requests """ try: with self.model.lock: node = self.model.get_node(path) return node.props['_id'] except KeyError: # try to resolve the path via requests path_elm_id = 'root' for path_elm in path: children = self._get_children(path_elm_id) path_elm_id = \ next((props['_id'] for name, props in children if name == path_elm), None) if path_elm_id is not None: return path_elm_id raise FileNotFoundError("File does not exist", path) @error_mapper @gdrive_error_mapper def delete(self, path, original_version_id): """ Delete can be done in 2 ways on GoogleDrive: either with a permanent delete by using https://developers.google.com/drive/v3/reference/files/delete or set trashed to true with a patch https://developers.google.com/drive/v3/reference/files/update. """ file_id = self._path_to_file_id(path) file_url = urllib.parse.urljoin(self.base_url, 'files/' + str(file_id)) # fetch current version id since Google Drive has node If-Match header if original_version_id is not None: version_id_resp = self.oauth_session.get( file_url, params={'fields': 'mimeType,' + VERSION_FIELD}) version_id_resp.raise_for_status() response = version_id_resp.json() if response['mimeType'] == 'application/vnd.google-apps.folder': version_id = 'is_dir' else: version_id = response[VERSION_FIELD] if version_id != original_version_id: raise jars.VersionIdNotMatchingError( self.storage_id, version_a=version_id, version_b=original_version_id) resp = self.oauth_session.patch(file_url, json={'trashed': True}) resp.raise_for_status() @error_mapper @gdrive_error_mapper def write(self, path, file_obj, original_version_id=None, size=0): """ https://developers.google.com/drive/v3/web/manage-uploads """ # pylint: disable=too-many-locals,too-many-statements parent_id = None node_id = None http_verb = '' with self.model.lock: with contextlib.suppress(KeyError): parent = self.model.get_node(path[:-1]) parent_id = parent.props['_id'] with contextlib.suppress(KeyError): node = parent.get_node([path[-1]]) node_id = node.props['_id'] # create parent and fetch id if parent_id is None: self.make_dir(path[:-1]) with self.model.lock: parent = self.model.get_node(path[:-1]) parent_id = parent.props['_id'] url = 'https://www.googleapis.com/upload/drive/v3/files' # check version id, if there is one to check if original_version_id is not None: res = self.oauth_session.get( 'https://www.googleapis.com/drive/v3/files/{}'.format(node_id), params={'fields': VERSION_FIELD}) res.raise_for_status() if res.json()[VERSION_FIELD] != original_version_id: raise jars.VersionIdNotMatchingError( self.storage_id, version_a=res.json()[VERSION_FIELD], version_b=original_version_id) body = {} # 'parents': [parent_id], 'name': path[-1]} if node_id is not None: http_verb = 'PATCH' body['fileId'] = node_id url += '/' + node_id else: http_verb = 'POST' body['parents'] = [parent_id] body['name'] = path[-1] # read the first kb to see if it is an empty file # that is a workaround for https://github.com/kennethreitz/requests/issues/3066 first_chunk = file_obj.read(1024 * 16) response = self.oauth_session.request( http_verb, url, json=body, params={ 'uploadType': 'resumable', 'fields': FIELDS }, # headers={'X-Upload-Content-Length': str(size)} ) response.raise_for_status() upload_url = response.headers['Location'] if not first_chunk: response = self.oauth_session.put(upload_url) else: chunker = FragmentingChunker(file_obj, first_chunk=first_chunk, chunk_size=1024) headers = {} if size: headers['Content-Range'] = 'bytes 0-{}/{}'.format( size - 1, size) response = self.oauth_session.put(upload_url, data=iter(chunker), headers=headers) response.raise_for_status() new_file = response.json() with jars.TreeToSyncEngineEngineAdapter(node=self.model, storage_id=self.storage_id, sync_engine=self._event_sink): props = gdrive_to_node_props(new_file) if parent.has_child(new_file['name']): parent.get_node([new_file['name']]).props.update(props) else: parent.add_child(new_file['name'], props) return new_file[VERSION_FIELD] supports_open_in_web_link = True @error_mapper @gdrive_error_mapper def create_open_in_web_link(self, path): try: file_id = self._path_to_file_id(path) response = self.oauth_session.get( 'https://www.googleapis.com/drive/v3/files/{}'.format(file_id), params={'fields': 'webViewLink'}) response.raise_for_status() return response.json()['webViewLink'] except (TypeError, FileNotFoundError) as ex: raise InvalidOperationError(self.storage_id, ex) supports_sharing_link = False supports_serialization = True def create_public_sharing_link(self, path): raise NotImplementedError def serialize(self): """ See docs from super. """ # letting super serialize the model super(GoogleDrive, self).serialize() @classmethod def _oauth_get_unique_id(cls, oauth_session): """ return unique id for this account """ user_info = get_user_metadata(cls.base_url, oauth_session) return user_info['user']['permissionId'] def get_shared_folders(self): """ Returns a list of :class:`jars.SharedFolder`""" shared_folders = [] shared_folders_nodes = [ node for node in self.model if node.props.get('shared') is True and node.parent.props.get('shared', False) is False ] for node in shared_folders_nodes: shared_folders.append( SharedFolder(path=node.path, share_id=node.props['_id'], sp_user_ids=node.props['_shared_with'])) return shared_folders def create_user_name(self): """Return a username for Google Drive. In this case the username will be 'displayName', obtained using https://developers.google.com/drive/v3/reference/about#resource """ return get_user_metadata(self.base_url, self.oauth_session)['user']['displayName']
def clear_model(self): self.model = IndexingNode(None, indexes=['_id'])
def get_tree(self, cached=False, filtered=True): # pylint: disable=arguments-differ, too-many-locals logger.debug('requested tree with filter %s', self.filter_tree) if cached: with self.model.lock: if filtered: logger.debug('requested tree with filter %s', self.filter_tree) return jars.utils.filter_tree_by_path( tree=self.model, filter_tree=self.filter_tree) else: return copy.deepcopy(self.model) root = IndexingNode(name=None, indexes=['_id']) # fetch and store current cursor url = urllib.parse.urljoin(self.base_url, 'changes/startPageToken') token = self.oauth_session.get(url) token.raise_for_status() root.props['_cursor'] = token.json()['startPageToken'] # get root id url = urllib.parse.urljoin(self.base_url, 'files/root?fields=id') root_id = self.oauth_session.get(url) root_id.raise_for_status() root.props['_id'] = root_id.json()['id'] # fetch metrics url = urllib.parse.urljoin(self.base_url, 'about?fields=storageQuota,user') about = self.oauth_session.get(url) about.raise_for_status() metrics = about.json() # this is relevant to client#970 total_space = int(metrics['storageQuota'].get('limit', sys.maxsize)) free_space = total_space - int(metrics['storageQuota']['usageInDrive']) root.props[METRICS] = StorageMetrics(storage_id=self.storage_id, free_space=free_space, total_space=total_space) # fetch tree url = urllib.parse.urljoin(self.base_url, 'files') page_token = None nodes = [] # fetch the whole tree into the parent_map while True: params = { 'pageSize': '1000', 'q': 'trashed=false', 'orderBy': 'folder', 'fields': 'nextPageToken,files(' + FIELDS + ')' } if page_token is not None: params['pageToken'] = page_token files_resp = self.oauth_session.get(url, params=params) files_resp.raise_for_status() resp_json = files_resp.json() files = resp_json['files'] page_token = resp_json.get('nextPageToken', None) for file in files: if 'parents' in file and is_relevant_file(file): for parent_id in file['parents']: # for each parent add this element if is_relevant_file(file): nodes.append( NodeChange(action='UPSERT', parent_id=parent_id, name=file['name'], props=gdrive_to_node_props(file))) if page_token is None: break root.add_merge_set_with_id(nodes, key='_id') self._adjust_share_ids(nodes) self.offline = False if filtered: logger.debug('requested tree with filter %s', self.filter_tree) root = jars.utils.filter_tree_by_path(root, self.filter_tree) return root
def test_adjust_share_ids(): """Test adjust_share_ids given different trees.""" import logging logging.basicConfig(level=logging.DEBUG) gdrive = Mock(GoogleDrive) gdrive.model = IndexingNode(None, indexes=['_id']) tree = gdrive.model # Empty mergesets should mean no changes mergeset = [] GoogleDrive._adjust_share_ids(gdrive, mergeset) assert tree.props == {} # if the node is located in the root is not necessary to compare properties with the parent child_1 = tree.add_child(name='child_1', props={'_id': 'id_1'}) mergeset = [ NodeChange(action='UPSERT', parent_id=None, name=child_1.name, props=child_1.props) ] GoogleDrive._adjust_share_ids(gdrive, mergeset) assert len(child_1.props) is 1 # if the file is not located on the root, it should be compared with the parent child_1_1 = child_1.add_child(name='child_1_1', props={'_id': 'id_1_1'}) mergeset = [ NodeChange(action='UPSERT', parent_id=child_1.props['_id'], name=child_1_1.name, props=child_1_1.props) ] # shared_with property is the same and no share_id -> nothing happens GoogleDrive._adjust_share_ids(gdrive, mergeset) assert child_1_1.props == {'_id': 'id_1_1'} # child_1_1 shared_with atribute is different than his parent child_1_1.props['_shared_with'] = ('random_123', ) # Now that the mergeset fields are not hardcoded there should not be need for this step # mergeset = [ # ('UPSERT', 'id_1', 'child_1_1', {'_id': 'id_1_1', '_shared_with': ('random_123',)}) # ] # child_1_1 share_id should be set to child_1_1 _id GoogleDrive._adjust_share_ids(gdrive, mergeset) assert child_1_1.props['_id'] == child_1_1.props['share_id'] # same case as above, but child_1 is shared too child_1.props['_shared_with'] = ('random_321', ) mergeset = [(NodeChange(action='UPSERT', parent_id=None, name='child_1', props={ '_id': 'id_1', '_shared_with': ('random_321', ) }))] GoogleDrive._adjust_share_ids(gdrive, mergeset) assert child_1_1.props['_id'] == child_1_1.props['share_id'] assert child_1.props['_id'] == child_1.props['share_id'] # child_1 and child_1_1 are shared with the same users -> same share -> remove child_1_1 # share_id child_1_1.props['_shared_with'] = ('random_321', ) mergeset = [ NodeChange(action='UPSERT', parent_id='id_1', name='child_1_1', props={ '_id': 'id_1_1', '_shared_with': ('random_321', ) }) ] GoogleDrive._adjust_share_ids(gdrive, mergeset) assert child_1.props['share_id'] == child_1.props['_id'] assert child_1_1.props.get('share_id', None) is None # DELETEs should be skipped mergeset = [ NodeChange(action='DELETE', parent_id='id_1', name='child_1_1', props={'_id': 'id_1_1'}) ] GoogleDrive._adjust_share_ids(gdrive, mergeset) assert child_1.props['share_id'] == child_1.props['_id'] assert child_1_1.props.get('share_id', None) is None # Merges of nodes that don't exist in our index should also be skipped mergeset = [ NodeChange(action='UPSERT', parent_id='id_1', name='child_1_2', props={ '_id': 'id_1_2', '_shared_with': ('random_123', ) }) ] GoogleDrive._adjust_share_ids(gdrive, mergeset) assert child_1.props['share_id'] == child_1.props['_id'] assert child_1_1.props.get('share_id', None) is None
def test_indexing_node_no_parent_or_index(): with pytest.raises(ValueError): node = IndexingNode(None)
def indexing_node_cust_props(): """ fixture to test the indexing node """ return IndexingNode(None, indexes=['_id'], props_cls=MyFancyProperties)