def test_get_docker_run_command(self): targets = [('key', BundleTarget('uuid1', '')), ('key2', BundleTarget('uuid2', ''))] bundle_locations = {'uuid1': 'local/path1', 'uuid2': 'local/path2'} session = InteractiveSession('some-docker-image', dependencies=targets, bundle_locations=bundle_locations) expected_regex = ( 'docker run -it --name interactive-session-0x[a-z0-9]{32} -w \/0x[a-z0-9]{32} -v ' '[\s\S]{0,100}local\/path1:\/0x[a-z0-9]{32}\/key:ro -v [\s\S]{0,100}local\/path2:\/0x[a-z0-9]{32}\/key2:ro ' 'some-docker-image bash') self.assertTrue( re.match(expected_regex, session.get_docker_run_command()))
def test_get_docker_run_command(self): targets = [('key', BundleTarget('uuid1', '')), ('key2', BundleTarget('uuid2', ''))] bundle_locations = {'uuid1': 'local/path1', 'uuid2': 'local/path2'} session = InteractiveSession( 'some-docker-image', dependencies=targets, bundle_locations=bundle_locations ) session._host_bash_history_path = ".bash_history" expected_regex = ( 'docker run -it --name interactive-session-0x[a-z0-9]{32} -w \/0x[a-z0-9]{32} ' '-e HOME=\/0x[a-z0-9]{32} -e HISTFILE=\/usr\/sbin\/\.bash_history -e PROMPT_COMMAND="history -a" -u \$\(id -u\):\$\(id -g\) ' '-v [\s\S]{0,100}local\/path1:\/0x[a-z0-9]{32}\/key:ro -v [\s\S]{0,100}local\/path2:\/0x[a-z0-9]{32}\/key2:ro ' '-v \.bash_history:\/usr\/sbin\/\.bash_history:rw -v [\s\S]{0,100}\/0x[a-z0-9]{32}:\/0x[a-z0-9]{32}:rw some-docker-image' ) self.assertTrue(re.match(expected_regex, session.get_docker_run_command()))
def test_missing_bundle_location(self): try: targets = [ ('key', BundleTarget('uuid1', 'sub/path1')), ('key2', BundleTarget('uuid2', 'sub/path2')), ] # Missing a location of uuid2 bundle bundle_locations = {'uuid1': 'local/path1'} session = InteractiveSession( 'some-docker-image', dependencies=targets, bundle_locations=bundle_locations ) session.start() except Exception as e: self.assertEqual(str(e), 'Missing bundle location for bundle uuid: uuid2') return self.fail('Should have thrown an error for the missing bundle location')
def _fetch_chunk(self, path, chunk_id): ''' Fetch and return a chunk from the cache, or with the client as necessary. Refreshes chunks that are older than self.timeout Only save full chunks to the cache. Partial chunks (i.e. at the end of a file) are not cached because they could grow. ''' now = int(time.time()) key = (path, chunk_id) if key in self.cache: t, arr = self.cache[key] if now - t < self.timeout: return arr # return chunk from cache else: self.cache.pop(key) # pop out expired entry # grab from client byte_range = ( chunk_id * self.chunk_size, chunk_id * self.chunk_size + self.chunk_size - 1, ) with closing( self.client.fetch_contents_blob(BundleTarget(self.bundle_uuid, path), byte_range) ) as contents: arr = contents.read() if len(arr) == self.chunk_size: # only cache if fetched full chunk if len(self.cache) >= self.max_num_chunks: # if full, remove the oldest item self.cache.popitem(last=False) self.cache[key] = (now, arr) return arr
def get_target_info(self, run_state, path, args, reply_fn): """ Return target_info of path in bundle as a message on the reply_fn """ target_info = None dep_paths = set([dep.child_path for dep in run_state.bundle.dependencies]) # if path is a dependency raise an error if path and os.path.normpath(path) in dep_paths: err = ( http.client.NOT_FOUND, '{} not found in bundle {}'.format(path, run_state.bundle.uuid), ) reply_fn(err, None, None) return else: try: target_info = download_util.get_target_info( run_state.bundle_path, BundleTarget(run_state.bundle.uuid, path), args['depth'] ) except PathException as e: err = (http.client.NOT_FOUND, str(e)) reply_fn(err, None, None) return if not path and args['depth'] > 0: target_info['contents'] = [ child for child in target_info['contents'] if child['name'] not in dep_paths ] # Object is not JSON serializable so submit its dict in API response # The client is responsible for deserializing it target_info['resolved_target'] = target_info['resolved_target'].__dict__ reply_fn(None, {'target_info': target_info}, None)
def _get_info(self, path): ''' Set a request through the json api client to get info about the bundle ''' try: info = self.client.fetch_contents_info(BundleTarget(self.bundle_uuid, path), 1) except NotFoundError: raise FuseOSError(errno.ENOENT) return info
def test_get_docker_run_command_with_subpaths(self): targets = [ ('key', BundleTarget('uuid1', 'sub/path1')), ('key2', BundleTarget('uuid2', 'sub/path2')), ] bundle_locations = {'uuid1': 'local/path1', 'uuid2': 'local/path2'} session = InteractiveSession('some-docker-image', dependencies=targets, bundle_locations=bundle_locations) session._host_bash_history_path = ".bash_history" expected_regex = ( 'docker run -it --name interactive-session-0x[a-z0-9]{32} -w \/0x[a-z0-9]{32} -u 1 -v ' '[\s\S]{0,100}local\/path1/sub/path1:\/0x[a-z0-9]{32}\/key:ro -v [\s\S]{0,100}local\/path2/sub/path2' ':\/0x[a-z0-9]{32}\/key2:ro -v \.bash_history:\/usr\/sbin\/\.bash_history:rw some-docker-image bash' ) self.assertTrue( re.match(expected_regex, session.get_docker_run_command()))
def test_single_file(self): """Test getting target info of a single file (compressed as .gz) on Azure Blob Storage.""" bundle_uuid, bundle_path = self.create_file(b"a") target_info = get_target_info(bundle_path, BundleTarget(bundle_uuid, None), 0) target_info.pop("resolved_target") self.assertEqual( target_info, {'name': bundle_uuid, 'type': 'file', 'size': 1, 'perm': 0o755} )
def test_bundle_single_file(self): """Running get_target_info for a bundle with a single file.""" bundle = self.create_run_bundle() self.save_bundle(bundle) self.upload_file(bundle, b"hello world") target = BundleTarget(bundle.uuid, "") info = self.download_manager.get_target_info(target, 0) self.assertEqual(info["name"], bundle.uuid) self.assertEqual(info["size"], 11) self.assertEqual(info["perm"], self.DEFAULT_PERM) self.assertEqual(info["type"], "file") self.assertEqual(str(info["resolved_target"]), f"{bundle.uuid}:") self.check_file_target_contents(target)
def fetch_contents_info(self, target, depth=0): """ Calls download_manager.get_target_info server-side and returns the target_info. For details on return value look at worker.download_util.get_target_info :param target: a worker.download_util.BundleTarget """ request_path = '/bundles/%s/contents/info/%s' % ( target.bundle_uuid, urllib.parse.quote(target.subpath), ) response = self._make_request('GET', request_path, query_params={'depth': depth}) # Deserialize the target. See /rest/bundles/_fetch_contents_info for serialization side response['data']['resolved_target'] = BundleTarget.from_dict( response['data']['resolved_target'] ) return response['data']
def _threaded_read(self, run_state, path, stream_fn, reply_fn): """ Given a run state, a path, a stream function and a reply function, - Computes the real filesystem path to the path in the bundle - In case of error, invokes reply_fn with an http error - Otherwise starts a thread calling stream_fn on the computed final path """ try: final_path = get_target_path( run_state.bundle_path, BundleTarget(run_state.bundle.uuid, path)) except PathException as e: reply_fn((http.client.NOT_FOUND, str(e)), None, None) read_thread = threading.Thread(target=stream_fn, args=[final_path]) read_thread.start() self.read_threads.append(read_thread)
def __call__(self, prefix, action=None, parser=None, parsed_args=None): key, target = cli_util.parse_key_target(prefix) if target is None: return () instance, worksheet_spec, bundle_spec, subpath = cli_util.parse_target_spec( target) if worksheet_spec is None: worksheet_spec = getattr(parsed_args, 'worksheet_spec', None) client, worksheet_uuid = self.cli.parse_client_worksheet_uuid( worksheet_spec) # Build parameterizable format string for suggestions suggestion_format = ''.join([ (key + ':') if key is not None else '', (worksheet_spec + '//') if worksheet_spec is not None else '', (bundle_spec + '/') if subpath is not None else '', '{}', ]) if subpath is None: # then suggest completions for bundle_spec return (suggestion_format.format(b) for b in BundlesCompleter( self.cli)(bundle_spec, action, parsed_args, worksheet_uuid)) else: # then suggest completions for subpath client, worksheet_uuid, resolved_target = self.cli.resolve_target( client, worksheet_uuid, target) dir_target = BundleTarget(resolved_target.bundle_uuid, os.path.dirname(subpath)) try: info = client.fetch_contents_info(dir_target, depth=1) except NotFoundError: return () if info['type'] == 'directory': matching_child_names = [] basename = os.path.basename(subpath) for child in info['contents']: if child['name'].startswith(basename): matching_child_names.append(child['name']) return (suggestion_format.format( os.path.join(dir_target.subpath, child_name)) for child_name in matching_child_names) else: return ()
def _fetch_bundle_contents_info(uuid, path=''): """ Fetch metadata of the bundle contents or a subpath within the bundle. Query parameters: - `depth`: recursively fetch subdirectory info up to this depth. Default is 0. Response format: ``` { "data": { "name": "<name of file or directory>", "link": "<string representing target if file is a symbolic link>", "type": "<file|directory|link>", "size": <size of file in bytes>, "perm": <unix permission integer>, "contents": [ { "name": ..., <each file of directory represented recursively with the same schema> }, ... ] } } ``` """ depth = query_get_type(int, 'depth', default=0) target = BundleTarget(uuid, path) if depth < 0: abort(http.client.BAD_REQUEST, "Depth must be at least 0") check_bundles_have_read_permission(local.model, request.user, [uuid]) try: info = local.download_manager.get_target_info(target, depth) # Object is not JSON serializable so submit its dict in API response # The client is responsible for deserializing it info['resolved_target'] = info['resolved_target'].__dict__ except NotFoundError as e: abort(http.client.NOT_FOUND, str(e)) except Exception as e: abort(http.client.BAD_REQUEST, str(e)) return {'data': info}
def _fetch_bundle_contents_blob(uuid, path=''): """ API to download the contents of a bundle or a subpath within a bundle. For directories, this method always returns a tarred and gzipped archive of the directory. For files, if the request has an Accept-Encoding header containing gzip, then the returned file is gzipped. Otherwise, the file is returned as-is. HTTP Request headers: - `Range: bytes=<start>-<end>`: fetch bytes from the range `[<start>, <end>)`. - `Accept-Encoding: <encoding>`: indicate that the client can accept encoding `<encoding>`. Currently only `gzip` encoding is supported. Query parameters: - `head`: number of lines to fetch from the beginning of the file. Default is 0, meaning to fetch the entire file. - `tail`: number of lines to fetch from the end of the file. Default is 0, meaning to fetch the entire file. - `max_line_length`: maximum number of characters to fetch from each line, if either `head` or `tail` is specified. Default is 128. HTTP Response headers (for single-file targets): - `Content-Disposition: inline; filename=<bundle name or target filename>` - `Content-Type: <guess of mimetype based on file extension>` - `Content-Encoding: [gzip|identity]` - `Target-Type: file` HTTP Response headers (for directories): - `Content-Disposition: attachment; filename=<bundle or directory name>.tar.gz` - `Content-Type: application/gzip` - `Content-Encoding: identity` - `Target-Type: directory` """ byte_range = get_request_range() head_lines = query_get_type(int, 'head', default=0) tail_lines = query_get_type(int, 'tail', default=0) truncation_text = query_get_type(str, 'truncation_text', default='') max_line_length = query_get_type(int, 'max_line_length', default=128) check_bundles_have_read_permission(local.model, request.user, [uuid]) target = BundleTarget(uuid, path) try: target_info = local.download_manager.get_target_info(target, 0) if target_info['resolved_target'] != target: check_bundles_have_read_permission( local.model, request.user, [target_info['resolved_target'].bundle_uuid]) target = target_info['resolved_target'] except NotFoundError as e: abort(http.client.NOT_FOUND, str(e)) except Exception as e: abort(http.client.BAD_REQUEST, str(e)) # Figure out the file name. bundle_name = local.model.get_bundle(target.bundle_uuid).metadata.name if not path and bundle_name: filename = bundle_name else: filename = target_info['name'] if target_info['type'] == 'directory': if byte_range: abort(http.client.BAD_REQUEST, 'Range not supported for directory blobs.') if head_lines or tail_lines: abort(http.client.BAD_REQUEST, 'Head and tail not supported for directory blobs.') # Always tar and gzip directories gzipped_stream = False # but don't set the encoding to 'gzip' mimetype = 'application/gzip' filename += '.tar.gz' fileobj = local.download_manager.stream_tarred_gzipped_directory( target) elif target_info['type'] == 'file': # Let's gzip to save bandwidth. # For simplicity, we do this even if the file is already a packed # archive (which should be relatively rare). # The browser will transparently decode the file. gzipped_stream = request_accepts_gzip_encoding() # Since guess_type() will interpret '.tar.gz' as an 'application/x-tar' file # with 'gzip' encoding, which would usually go into the Content-Encoding # header. But if the bundle contents is actually a packed archive, we don't # want the client to automatically decompress the file, so we don't want to # set the Content-Encoding header. Instead, if guess_type() detects an # archive, we just set mimetype to indicate an arbitrary binary file. mimetype, encoding = mimetypes.guess_type(filename, strict=False) if encoding is not None: mimetype = 'application/octet-stream' if byte_range and (head_lines or tail_lines): abort(http.client.BAD_REQUEST, 'Head and range not supported on the same request.') elif byte_range: start, end = byte_range fileobj = local.download_manager.read_file_section( target, start, end - start + 1, gzipped_stream) elif head_lines or tail_lines: fileobj = local.download_manager.summarize_file( target, head_lines, tail_lines, max_line_length, truncation_text, gzipped_stream) else: fileobj = local.download_manager.stream_file( target, gzipped_stream) else: # Symlinks. abort(http.client.FORBIDDEN, 'Cannot download files of this type (%s).' % target_info['type']) # Set headers. response.set_header('Content-Type', mimetype or 'text/plain') response.set_header('Content-Encoding', 'gzip' if gzipped_stream else 'identity') if target_info['type'] == 'file': response.set_header('Content-Disposition', 'inline; filename="%s"' % filename) else: response.set_header('Content-Disposition', 'attachment; filename="%s"' % filename) response.set_header('Target-Type', target_info['type']) return fileobj
def interpret_file_genpath(target_cache, bundle_uuid, genpath, post): """ |cache| is a mapping from target (bundle_uuid, subpath) to the info map, which is to be read/written to avoid reading/parsing the same file many times. |genpath| specifies the subpath and various fields (e.g., for /stats:train/errorRate, subpath = 'stats', key = 'train/errorRate'). |post| function to apply to the resulting value. Return the string value. """ MAX_LINES = 10000 # Maximum number of lines we need to read from a file. # Load the file if not is_file_genpath(genpath): raise UsageError('Not file genpath: %s' % genpath) genpath = genpath[1:] if ':' in genpath: # Looking for a particular key in the file subpath, key = genpath.split(':') else: subpath, key = genpath, None target = BundleTarget(bundle_uuid, subpath) if target not in target_cache: info = None try: target_info = rest_util.get_target_info(target, 0) if target_info['type'] == 'file': contents = head_target(target_info['resolved_target'], MAX_LINES) if len(contents) == 0: info = '' elif all('\t' in x for x in contents): # Tab-separated file (key\tvalue\nkey\tvalue...) info = {} for x in contents: kv = x.strip().split("\t", 1) if len(kv) == 2: info[kv[0]] = kv[1] else: try: # JSON file info = json.loads(''.join(contents)) except (TypeError, ValueError): try: # YAML file # Use safe_load because yaml.load() could execute # arbitrary Python code info = yaml.safe_load(''.join(contents)) except yaml.YAMLError: # Plain text file info = ''.join(contents) except NotFoundError: pass # Try to interpret the structure of the file by looking inside it. target_cache[target] = info # Traverse the info object. info = target_cache.get(target, None) if key is not None and info is not None: for k in key.split('/'): if isinstance(info, dict): info = info.get(k, None) elif isinstance(info, list): try: info = info[int(k)] except (KeyError, ValueError): info = None else: info = None if info is None: break return apply_func(post, info)
def resolve_interpreted_blocks(interpreted_blocks, brief): """ Called by the web interface. Takes a list of interpreted worksheet items (returned by worksheet_util.interpret_items) and fetches the appropriate information, replacing the 'interpreted' field in each item. The result can be serialized via JSON. """ def set_error_data(block_index, message): interpreted_blocks[block_index] = (MarkupBlockSchema().load({ 'id': block_index, 'text': 'ERROR: ' + message }).data) for block_index, block in enumerate(interpreted_blocks): if block is None: continue mode = block['mode'] try: # Replace data with a resolved version. if mode in (BlockModes.markup_block, BlockModes.placeholder_block): # no need to do anything pass elif mode == BlockModes.record_block or mode == BlockModes.table_block: # header_name_posts is a list of (name, post-processing) pairs. # Request information if brief: # In brief mode, only calculate whether we should interpret genpaths, and if so, set status to briefly_loaded. should_interpret_genpaths = (len( get_genpaths_table_contents_requests(block['rows'])) > 0) block['status'] = ( FetchStatusSchema.get_briefly_loaded_status() if should_interpret_genpaths else FetchStatusSchema.get_ready_status()) else: block['rows'] = interpret_genpath_table_contents( block['rows']) block['status'] = FetchStatusSchema.get_ready_status() elif mode == BlockModes.contents_block or mode == BlockModes.image_block: bundle_uuid = block['bundles_spec']['bundle_infos'][0]['uuid'] target_path = block['target_genpath'] target = BundleTarget(bundle_uuid, target_path) try: target_info = rest_util.get_target_info(target, 0) if target_info[ 'type'] == 'directory' and mode == BlockModes.contents_block: block['status']['code'] = FetchStatusCodes.ready block['lines'] = ['<directory>'] elif target_info['type'] == 'file': block['status']['code'] = FetchStatusCodes.ready if mode == BlockModes.contents_block: block['lines'] = head_target( target_info['resolved_target'], block['max_lines']) elif mode == BlockModes.image_block: block['status']['code'] = FetchStatusCodes.ready block['image_data'] = base64.b64encode( bytes( cat_target(target_info['resolved_target'])) ).decode('utf-8') else: block['status']['code'] = FetchStatusCodes.not_found if mode == BlockModes.contents_block: block['lines'] = None elif mode == BlockModes.image_block: block['image_data'] = None except NotFoundError: block['status']['code'] = FetchStatusCodes.not_found if mode == BlockModes.contents_block: block['lines'] = None elif mode == BlockModes.image_block: block['image_data'] = None elif mode == BlockModes.graph_block: # data = list of {'target': ...} # Add a 'points' field that contains the contents of the target. for info in block['trajectories']: target = BundleTarget(info['bundle_uuid'], info['target_genpath']) try: target_info = rest_util.get_target_info(target, 0) except NotFoundError: continue if target_info['type'] == 'file': contents = head_target(target_info['resolved_target'], block['max_lines']) # Assume TSV file without header for now, just return each line as a row info['points'] = points = [] for line in contents: row = line.split('\t') points.append(row) elif mode == BlockModes.subworksheets_block: # do nothing pass elif mode == BlockModes.schema_block: pass else: raise UsageError('Invalid display mode: %s' % mode) except UsageError as e: set_error_data(block_index, str(e)) except Exception: import traceback traceback.print_exc() set_error_data(block_index, "Unexpected error interpreting item") block['is_refined'] = True return interpreted_blocks
def test_not_found(self): """Running get_target_info for a nonexistent bundle should raise an error.""" with self.assertRaises(NotFoundError): target = BundleTarget(generate_uuid(), "") self.download_manager.get_target_info(target, 0)
def test_bundle_folder(self): """Running get_target_info for a bundle with a folder, and with subpaths.""" bundle = self.create_run_bundle() self.save_bundle(bundle) self.upload_folder(bundle, [("item.txt", b"hello world"), ("src/item2.txt", b"hello world")]) self.assertEqual(bundle.is_dir, True) self.assertEqual(bundle.storage_type, self.storage_type) target = BundleTarget(bundle.uuid, "") info = self.download_manager.get_target_info(target, 2) self.assertEqual(info["name"], bundle.uuid) self.assertEqual(info["type"], "directory") self.assertEqual(str(info["resolved_target"]), f"{bundle.uuid}:") # Directory size can vary based on platform, so removing it before checking equality. info["contents"][0].pop("size") info["contents"][1].pop("size") self.assertEqual( sorted(info["contents"], key=lambda x: x["name"]), sorted( [ { 'name': 'item.txt', 'perm': self.DEFAULT_PERM_FILE, 'type': 'file' }, { 'name': 'src', 'perm': self.DEFAULT_PERM_DIR, 'type': 'directory', 'contents': [{ 'name': 'item2.txt', 'size': 11, 'perm': self.DEFAULT_PERM_FILE, 'type': 'file', }], }, ], key=lambda x: x["name"], ), ) self.check_folder_target_contents( target, expected_members=['.', './item.txt', './src', './src/item2.txt']) target = BundleTarget(bundle.uuid, "item.txt") info = self.download_manager.get_target_info(target, 0) self.assertEqual(info["name"], "item.txt") self.assertEqual(info["type"], "file") self.assertEqual(str(info["resolved_target"]), f"{bundle.uuid}:item.txt") self.check_file_target_contents(target) target = BundleTarget(bundle.uuid, "src") info = self.download_manager.get_target_info(target, 1) self.assertEqual(info["name"], "src") self.assertEqual(info["type"], "directory") self.assertEqual(str(info["resolved_target"]), f"{bundle.uuid}:src") self.assertEqual( info["contents"], [{ 'name': 'item2.txt', 'size': 11, 'perm': self.DEFAULT_PERM_FILE, 'type': 'file' }], ) self.check_folder_target_contents( target, expected_members=['.', './item2.txt']) target = BundleTarget(bundle.uuid, "src/item2.txt") info = self.download_manager.get_target_info(target, 0) self.assertEqual(info["name"], "item2.txt") self.assertEqual(info["type"], "file") self.assertEqual(str(info["resolved_target"]), f"{bundle.uuid}:src/item2.txt") self.check_file_target_contents(target)
def test_single_txt_file(self): """Test getting target info of a single txt file on Azure Blob Storage. As this isn't supported (paths should be specified within existing .gz / .tar.gz files), this should throw an exception.""" bundle_uuid, bundle_path = self.create_txt_file(b"a") with self.assertRaises(PathException): get_target_info(bundle_path, BundleTarget(bundle_uuid, None), 0)
def test_nested_directories(self): """Test getting target info of different files within a bundle that consists of nested directories, on Azure Blob Storage.""" bundle_uuid, bundle_path = self.create_directory() target_info = get_target_info(bundle_path, BundleTarget(bundle_uuid, None), 0) target_info.pop("resolved_target") self.assertEqual(target_info, { 'name': bundle_uuid, 'type': 'directory', 'size': 249, 'perm': 0o755 }) target_info = get_target_info(bundle_path, BundleTarget(bundle_uuid, None), 1) target_info.pop("resolved_target") self.assertEqual( target_info, { 'name': bundle_uuid, 'type': 'directory', 'size': 249, 'perm': 0o755, 'contents': [ { 'name': 'README.md', 'type': 'file', 'size': 11, 'perm': 0o644 }, { 'name': 'dist', 'type': 'directory', 'size': 0, 'perm': 0o644 }, { 'name': 'src', 'type': 'directory', 'size': 0, 'perm': 0o644 }, ], }, ) target_info = get_target_info(bundle_path, BundleTarget(bundle_uuid, "README.md"), 1) target_info.pop("resolved_target") self.assertEqual(target_info, { 'name': 'README.md', 'type': 'file', 'size': 11, 'perm': 0o644 }) target_info = get_target_info(bundle_path, BundleTarget(bundle_uuid, "src/test.sh"), 1) target_info.pop("resolved_target") self.assertEqual(target_info, { 'name': 'test.sh', 'type': 'file', 'size': 7, 'perm': 0o644 }) target_info = get_target_info( bundle_path, BundleTarget(bundle_uuid, "dist/a/b/test2.sh"), 1) target_info.pop("resolved_target") self.assertEqual(target_info, { 'name': 'test2.sh', 'type': 'file', 'size': 8, 'perm': 0o644 }) target_info = get_target_info(bundle_path, BundleTarget(bundle_uuid, "src"), 1) target_info.pop("resolved_target") self.assertEqual( target_info, { 'name': 'src', 'type': 'directory', 'size': 0, 'perm': 0o644, 'contents': [{ 'name': 'test.sh', 'type': 'file', 'size': 7, 'perm': 0o644 }], }, ) # Return all depths target_info = get_target_info(bundle_path, BundleTarget(bundle_uuid, "dist/a"), 999) target_info.pop("resolved_target") self.assertEqual( target_info, { 'name': 'a', 'size': 0, 'perm': 0o644, 'type': 'directory', 'contents': [{ 'name': 'b', 'size': 0, 'perm': 0o644, 'type': 'directory', 'contents': [{ 'name': 'test2.sh', 'size': 8, 'perm': 0o644, 'type': 'file' }], }], }, )