def oscal_write(self, path: pathlib.Path, minimize_json=False) -> None: """ Write oscal objects. OSCAL schema mandates that top level elements are wrapped in a singular json/yaml field. This function handles both json and yaml output as well as """ class_name = self.__class__.__name__ # It would be nice to pass through the description but I can't seem to and # it does not affect the output dynamic_parser = {} dynamic_parser[classname_to_alias(class_name, 'field')] = ( self.__class__, Field(self, title=classname_to_alias(class_name, 'field'), alias=classname_to_alias(class_name, 'json')) ) wrapper_model = create_model(class_name, __base__=OscalBaseModel, **dynamic_parser) # type: ignore # Default behaviour is strange here. wrapped_model = wrapper_model(**{classname_to_alias(class_name, 'json'): self}) yaml_suffix = ['.yaml', '.yml'] json_suffix = ['.json'] encoding = 'utf8' write_file = pathlib.Path(path).open('w', encoding=encoding) if path.suffix in yaml_suffix: yaml.dump(yaml.safe_load(wrapped_model.json(exclude_none=True, by_alias=True)), write_file) pass elif path.suffix in json_suffix: write_file.write(wrapped_model.json(exclude_none=True, by_alias=True, indent=2)) else: raise err.TrestleError('Unknown file type')
def test_classname_to_alias() -> None: """Test conversion of class name to alias.""" module_name = catalog.Catalog.__module__ with pytest.raises(err.TrestleError): mutils.classname_to_alias('any', 'invalid_mode') short_classname = catalog.Catalog.__name__ full_classname = f'{module_name}.{short_classname}' json_alias = mutils.classname_to_alias(short_classname, 'json') assert json_alias == 'catalog' json_alias = mutils.classname_to_alias(full_classname, 'field') assert json_alias == 'catalog' short_classname = catalog.ResponsibleParty.__name__ full_classname = f'{module_name}.{short_classname}' json_alias = mutils.classname_to_alias(short_classname, 'json') assert json_alias == 'responsible-party' json_alias = mutils.classname_to_alias(full_classname, 'field') assert json_alias == 'responsible_party' short_classname = catalog.Property.__name__ full_classname = f'{module_name}.{short_classname}' json_alias = mutils.classname_to_alias(short_classname, 'json') assert json_alias == 'property' json_alias = mutils.classname_to_alias(full_classname, 'field') assert json_alias == 'property' short_classname = catalog.MemberOfOrganization.__name__ full_classname = f'{module_name}.{short_classname}' json_alias = mutils.classname_to_alias(short_classname, 'json') assert json_alias == 'member-of-organization' json_alias = mutils.classname_to_alias(full_classname, 'field') assert json_alias == 'member_of_organization'
def to_model_file_name(model_obj: OscalBaseModel, file_prefix: str, content_type: FileContentType) -> str: """Return the file name for the item.""" file_ext = FileContentType.to_file_extension(content_type) model_type = utils.classname_to_alias(type(model_obj).__name__, 'json') file_name = f'{file_prefix}{const.IDX_SEP}{model_type}{file_ext}' return file_name
def test_target_dups(tmp_dir): """Test model validation.""" content_type = FileContentType.YAML models_dir_name = test_utils.TARGET_DEFS_DIR model_ref = ostarget.TargetDefinition test_utils.ensure_trestle_config_dir(tmp_dir) file_ext = FileContentType.to_file_extension(content_type) models_full_path = tmp_dir / models_dir_name / 'my_test_model' model_alias = utils.classname_to_alias(model_ref.__name__, 'json') model_def_file = models_full_path / f'{model_alias}{file_ext}' fs.ensure_directory(models_full_path) shutil.copyfile('tests/data/yaml/good_target.yaml', model_def_file) testcmd = f'trestle validate -f {model_def_file} -m duplicates -i uuid' with patch.object(sys, 'argv', testcmd.split()): with pytest.raises(SystemExit) as pytest_wrapped_e: cli.run() assert pytest_wrapped_e.type == SystemExit assert pytest_wrapped_e.value.code is None shutil.copyfile('tests/data/yaml/bad_target_dup_uuid.yaml', model_def_file) testcmd = f'trestle validate -f {model_def_file} -m duplicates -i uuid' with patch.object(sys, 'argv', testcmd.split()): with pytest.raises(TrestleValidationError) as pytest_wrapped_e: cli.run() assert pytest_wrapped_e.type == TrestleValidationError
def get_singular_alias(alias_path: str, contextual_mode: bool = False) -> str: """ Get the alias in the singular form from a jsonpath. If contextual_mode is True and contextual_path is None, it assumes alias_path is relative to the directory the user is running trestle from. """ if len(alias_path.strip()) == 0: raise err.TrestleError('Invalid jsonpath.') singular_alias: str = '' full_alias_path = alias_path if contextual_mode: _, full_model_alias = get_contextual_model_type() first_alias_a = full_model_alias.split('.')[-1] first_alias_b = alias_path.split('.')[0] if first_alias_a == first_alias_b: full_model_alias = '.'.join(full_model_alias.split('.')[:-1]) full_alias_path = '.'.join([full_model_alias, alias_path]).strip('.') path_parts = full_alias_path.split(const.ALIAS_PATH_SEPARATOR) if len(path_parts) < 2: raise err.TrestleError('Invalid jsonpath.') model_types = [] root_model_alias = path_parts[0] found = False for module_name in const.MODELTYPE_TO_MODELMODULE.values(): model_type, model_alias = utils.get_root_model(module_name) if root_model_alias == model_alias: found = True model_types.append(model_type) break if not found: raise err.TrestleError( f'{root_model_alias} is an invalid root model alias.') model_type = model_types[0] for i in range(1, len(path_parts)): if utils.is_collection_field_type(model_type): model_type = utils.get_inner_type(model_type) i = i + 1 else: model_type = model_type.alias_to_field_map()[ path_parts[i]].outer_type_ model_types.append(model_type) if not utils.is_collection_field_type(model_type): raise err.TrestleError('Not a valid generic collection model.') last_alias = path_parts[-1] parent_model_type = model_types[-2] singular_alias = utils.classname_to_alias( utils.get_inner_type(parent_model_type.alias_to_field_map() [last_alias].outer_type_).__name__, 'json') return singular_alias
def _run(self, args): """Add an OSCAL component/subcomponent to the specified component. This method takes input a filename and a list of comma-seperated element path. Element paths are field aliases. The method first finds the parent model from the file and loads the file into the model. Then the method executes 'add' for each of the element paths specified. """ args = args.__dict__ if args[const.ARG_FILE] is None: raise err.TrestleError( f'Argument "-{const.ARG_FILE_SHORT}" is required') if args[const.ARG_ELEMENT] is None: raise err.TrestleError( f'Argument "-{const.ARG_ELEMENT}" is required') file_path = pathlib.Path(args[const.ARG_FILE]) # Get parent model and then load json into parent model parent_model, parent_alias = fs.get_contextual_model_type( file_path.absolute()) parent_object = parent_model.oscal_read(file_path.absolute()) parent_element = Element( parent_object, utils.classname_to_alias(parent_model.__name__, 'json')) # Do _add for each element_path specified in args element_paths: list[str] = args[const.ARG_ELEMENT].split(',') for elm_path_str in element_paths: element_path = ElementPath(elm_path_str) self.add(file_path, element_path, parent_model, parent_element)
def split_model(cls, model_obj: OscalBaseModel, element_paths: List[ElementPath], base_dir: pathlib.Path, content_type: FileContentType, root_file_name: str = '') -> Plan: """Split the model at the provided element paths. It returns a plan for the operation """ # assume we ran the command below: # trestle split -f target.yaml # -e 'target-definition.metadata, # target-definition.targets.*.target-control-implementations.*' # initialize plan split_plan = Plan() # loop through the element path list and update the split_plan stripped_field_alias = [] cur_path_index = 0 while cur_path_index < len(element_paths): # extract the sub element name for each of the root path of the path chain element_path = element_paths[cur_path_index] if element_path.get_parent() is None and len( element_path.get()) > 1: stripped_part = element_path.get()[1] if stripped_part == ElementPath.WILDCARD: stripped_field_alias.append('__root__') else: stripped_field_alias.append(stripped_part) # split model at the path chain cur_path_index = cls.split_model_at_path_chain( model_obj, element_paths, base_dir, content_type, cur_path_index, split_plan, False, root_file_name) cur_path_index += 1 # strip the root model object and add a WriteAction stripped_root = model_obj.stripped_instance( stripped_fields_aliases=stripped_field_alias) # If it's an empty model after stripping the fields, don't create path and don't write if set(model_obj.__fields__.keys()) == set(stripped_field_alias): return split_plan if root_file_name != '': root_file = base_dir / root_file_name else: root_file = base_dir / element_paths[0].to_root_path(content_type) split_plan.add_action(CreatePathAction(root_file, True)) wrapper_alias = utils.classname_to_alias( stripped_root.__class__.__name__, 'json') split_plan.add_action( WriteFileAction(root_file, Element(stripped_root, wrapper_alias), content_type)) return split_plan
def get_sub_model_dir(cls, base_dir: pathlib.Path, sub_model: OscalBaseModel, dir_prefix: str) -> pathlib.Path: """Get the directory path for the given model.""" model_type = utils.classname_to_alias(type(sub_model).__name__, 'json') dir_name = f'{dir_prefix}{const.IDX_SEP}{model_type}' sub_model_dir = base_dir / dir_name return sub_model_dir
def oscal_write(self, path: pathlib.Path) -> None: """ Write out a pydantic data model in an oscal friendly way. OSCAL schema mandates that top level elements are wrapped in a singular json/yaml field. This function handles both json and yaml output as well as formatting of the json. Args: path: The output file location for the oscal object. Raises: err.TrestleError: If a unknown file extension is provided. """ class_name = self.__class__.__name__ # It would be nice to pass through the description but I can't seem to and # it does not affect the output dynamic_parser = {} dynamic_parser[classname_to_alias( class_name, 'field')] = (self.__class__, Field(self, title=classname_to_alias(class_name, 'field'), alias=classname_to_alias(class_name, 'json'))) wrapper_model = create_model(class_name, __base__=OscalBaseModel, **dynamic_parser) # type: ignore # Default behaviour is strange here. wrapped_model = wrapper_model( **{classname_to_alias(class_name, 'json'): self}) # content_type = FileContentType.to_content_type(path.suffix) write_file = pathlib.Path(path).open('w', encoding=const.FILE_ENCODING) if content_type == FileContentType.YAML: yaml.dump( yaml.safe_load( wrapped_model.json(exclude_none=True, by_alias=True)), write_file) elif content_type == FileContentType.JSON: write_file.write( wrapped_model.json(exclude_none=True, by_alias=True, indent=2))
def prepare_sub_model_split_actions( cls, sub_model_item: OscalBaseModel, sub_model_dir: pathlib.Path, file_prefix: str, content_type: FileContentType) -> List[Action]: """Create split actions of sub model.""" actions: List[Action] = [] file_name = cmd_utils.to_model_file_name(sub_model_item, file_prefix, content_type) model_type = utils.classname_to_alias( type(sub_model_item).__name__, 'json') sub_model_file = sub_model_dir / file_name actions.append(CreatePathAction(sub_model_file)) actions.append( WriteFileAction(sub_model_file, Element(sub_model_item, model_type), content_type)) return actions
def prepare_trestle_project_dir( tmp_path, content_type: FileContentType, model_obj: OscalBaseModel, models_dir_name: str ): """Prepare a temp directory with an example OSCAL model.""" ensure_trestle_config_dir(tmp_path) model_alias = utils.classname_to_alias(model_obj.__class__.__name__, 'json') file_ext = FileContentType.to_file_extension(content_type) models_full_path = tmp_path / models_dir_name / 'my_test_model' model_def_file = models_full_path / f'{model_alias}{file_ext}' models_full_path.mkdir(exist_ok=True, parents=True) model_obj.oscal_write(model_def_file) return models_full_path, model_def_file
def _run(self, args: argparse.Namespace) -> int: """Add an OSCAL component/subcomponent to the specified component. This method takes input a filename and a list of comma-seperated element path. Element paths are field aliases. The method first finds the parent model from the file and loads the file into the model. Then the method executes 'add' for each of the element paths specified. """ log.set_log_level_from_args(args) try: args_dict = args.__dict__ file_path = pathlib.Path(args_dict[const.ARG_FILE]) # Get parent model and then load json into parent model parent_model, parent_alias = fs.get_stripped_contextual_model( file_path.absolute()) parent_object = parent_model.oscal_read(file_path.absolute()) # FIXME : handle YAML files after detecting file type parent_element = Element( parent_object, utils.classname_to_alias(parent_model.__name__, 'json')) add_plan = Plan() # Do _add for each element_path specified in args element_paths: List[str] = args_dict[const.ARG_ELEMENT].split(',') for elm_path_str in element_paths: element_path = ElementPath(elm_path_str) update_action, parent_element = self.add( element_path, parent_model, parent_element) add_plan.add_action(update_action) create_action = CreatePathAction(file_path.absolute(), True) write_action = WriteFileAction( file_path.absolute(), parent_element, FileContentType.to_content_type(file_path.suffix)) add_plan.add_action(create_action) add_plan.add_action(write_action) add_plan.simulate() add_plan.execute() except BaseException as err: logger.error(f'Add failed: {err}') return 1 return 0
def oscal_read(cls, path: pathlib.Path) -> 'OscalBaseModel': """ Read OSCAL objects. Handles the fact OSCAL wrap's top level elements and also deals with both yaml and json. """ # Create the wrapper model. alias = classname_to_alias(cls.__name__, 'json') content_type = FileContentType.to_content_type(path.suffix) if content_type == FileContentType.YAML: return cls.parse_obj(yaml.safe_load(path.open())[alias]) elif content_type == FileContentType.JSON: obj = load_file( path, json_loads=cls.__config__.json_loads, ) return cls.parse_obj(obj[alias]) else: raise err.TrestleError('Unknown file type')
def __init__(self, elem: OscalBaseModel, wrapper_alias: str = ''): """Initialize an element wrapper. wrapper_alias is the OSCAL alias for the given elem object and used for seriazation in to_json() method. For example, - List[Catalog.Group] element should have wrapper alias 'groups' - Catalog element should have wrapper alias 'catalog' wrapper_alias is mandatory for collection type object if wrapper_alias = IGNORE_WRAPPER_ALIAS, then it is ignored and assumed to be json-serializable during to_json() """ self._elem: OscalBaseModel = elem if wrapper_alias == '' and wrapper_alias != self.IGNORE_WRAPPER_ALIAS: if utils.is_collection_field_type(elem): raise TrestleError('wrapper_alias is required for a collection type object') else: wrapper_alias = utils.classname_to_alias(elem.__class__.__name__, 'json') self._wrapper_alias: str = wrapper_alias
def oscal_read(cls, path: pathlib.Path) -> 'OscalBaseModel': """ Read OSCAL objects. Handles the fact OSCAL wrap's top level elements and also deals with both yaml and json. """ # Define valid extensions yaml_suffix = ['.yaml', '.yml'] json_suffix = ['.json'] # Create the wrapper model. alias = classname_to_alias(cls.__name__, 'json') if path.suffix in yaml_suffix: return cls.parse_obj(yaml.safe_load(path.open())[alias]) elif path.suffix in json_suffix: obj = load_file( path, json_loads=cls.__config__.json_loads, ) return cls.parse_obj(obj[alias]) else: raise err.TrestleError('Unknown file type')
def _run(self, args: argparse.Namespace) -> int: """Remove an OSCAL component/subcomponent to the specified component. This method takes input a filename and a list of comma-seperated element path. Element paths are field aliases. The method first finds the parent model from the file and loads the file into the model. Then the method executes 'remove' for each of the element paths specified. """ log.set_log_level_from_args(args) args_dict = args.__dict__ file_path = pathlib.Path(args_dict[const.ARG_FILE]) # Get parent model and then load json into parent model try: parent_model, parent_alias = fs.get_contextual_model_type(file_path.absolute()) except Exception as err: logger.debug(f'fs.get_contextual_model_type() failed: {err}') logger.error(f'Remove failed (fs.get_contextual_model_type()): {err}') return 1 try: parent_object = parent_model.oscal_read(file_path.absolute()) except Exception as err: logger.debug(f'parent_model.oscal_read() failed: {err}') logger.error(f'Remove failed (parent_model.oscal_read()): {err}') return 1 parent_element = Element(parent_object, utils.classname_to_alias(parent_model.__name__, 'json')) add_plan = Plan() # Do _remove for each element_path specified in args element_paths: List[str] = str(args_dict[const.ARG_ELEMENT]).split(',') for elm_path_str in element_paths: element_path = ElementPath(elm_path_str) try: remove_action, parent_element = self.remove(element_path, parent_model, parent_element) except TrestleError as err: logger.debug(f'self.remove() failed: {err}') logger.error(f'Remove failed (self.remove()): {err}') return 1 add_plan.add_action(remove_action) create_action = CreatePathAction(file_path.absolute(), True) write_action = WriteFileAction( file_path.absolute(), parent_element, FileContentType.to_content_type(file_path.suffix) ) add_plan.add_action(remove_action) add_plan.add_action(create_action) add_plan.add_action(write_action) try: add_plan.simulate() except TrestleError as err: logger.debug(f'Remove failed at simulate(): {err}') logger.error(f'Remove failed (simulate()): {err}') return 1 try: add_plan.execute() except TrestleError as err: logger.debug(f'Remove failed at execute(): {err}') logger.error(f'Remove failed (execute()): {err}') return 1 return 0
def test_split_multi_level_dict( tmp_path: pathlib.Path, sample_target_def: ostarget.TargetDefinition) -> None: """Test for split_model method.""" # Assume we are running a command like below # trestle split -f target.yaml -e target-definition.targets.*.target-control-implementations.* content_type = FileContentType.YAML # prepare trestle project dir with the file target_def_dir, target_def_file = test_utils.prepare_trestle_project_dir( tmp_path, content_type, sample_target_def, test_utils.TARGET_DEFS_DIR) file_ext = FileContentType.to_file_extension(content_type) # read the model from file target_def: ostarget.TargetDefinition = ostarget.TargetDefinition.oscal_read( target_def_file) element = Element(target_def) element_args = [ 'target-definition.targets.*.target-control-implementations.*' ] element_paths = test_utils.prepare_element_paths(target_def_dir, element_args) expected_plan = Plan() # extract values targets: dict = element.get_at(element_paths[0]) targets_dir = target_def_dir / element_paths[0].to_file_path() # split every targets for key in targets: # individual target dir target: ostarget.Target = targets[key] target_element = Element(targets[key]) model_type = utils.classname_to_alias(type(target).__name__, 'json') dir_prefix = key target_dir_name = f'{dir_prefix}{const.IDX_SEP}{model_type}' target_file = targets_dir / f'{target_dir_name}{file_ext}' # target control impl dir for the target target_ctrl_impls: dict = target_element.get_at(element_paths[1]) targets_ctrl_dir = targets_dir / element_paths[1].to_file_path( root_dir=target_dir_name) for i, target_ctrl_impl in enumerate(target_ctrl_impls): model_type = utils.classname_to_alias( type(target_ctrl_impl).__name__, 'json') file_prefix = str(i).zfill(const.FILE_DIGIT_PREFIX_LENGTH) file_name = f'{file_prefix}{const.IDX_SEP}{model_type}{file_ext}' file_path = targets_ctrl_dir / file_name expected_plan.add_action(CreatePathAction(file_path)) expected_plan.add_action( WriteFileAction(file_path, Element(target_ctrl_impl), content_type)) # write stripped target model stripped_target = target.stripped_instance( stripped_fields_aliases=[element_paths[1].get_element_name()]) expected_plan.add_action(CreatePathAction(target_file)) expected_plan.add_action( WriteFileAction(target_file, Element(stripped_target), content_type)) root_file = target_def_dir / f'target-definition{file_ext}' remaining_root = element.get().stripped_instance( stripped_fields_aliases=[element_paths[0].get_element_name()]) expected_plan.add_action(CreatePathAction(root_file, True)) expected_plan.add_action( WriteFileAction(root_file, Element(remaining_root), content_type)) split_plan = SplitCmd.split_model(target_def, element_paths, target_def_dir, content_type) assert expected_plan == split_plan
def test_subsequent_split_model( tmp_path: pathlib.Path, sample_target_def: ostarget.TargetDefinition) -> None: """Test subsequent split of sub models.""" # Assume we are running a command like below # trestle split -f target-definition.yaml -e target-definition.metadata content_type = FileContentType.YAML # prepare trestle project dir with the file target_def_dir, target_def_file = test_utils.prepare_trestle_project_dir( tmp_path, content_type, sample_target_def, test_utils.TARGET_DEFS_DIR) # first split the target-def into metadata target_def = ostarget.TargetDefinition.oscal_read(target_def_file) element = Element(target_def, 'target-definition') element_args = ['target-definition.metadata'] element_paths = test_utils.prepare_element_paths(target_def_dir, element_args) metadata_file = target_def_dir / element_paths[0].to_file_path( content_type) metadata: ostarget.Metadata = element.get_at(element_paths[0]) root_file = target_def_dir / element_paths[0].to_root_path(content_type) metadata_field_alias = element_paths[0].get_element_name() stripped_root = element.get().stripped_instance( stripped_fields_aliases=[metadata_field_alias]) root_wrapper_alias = utils.classname_to_alias( stripped_root.__class__.__name__, 'json') first_plan = Plan() first_plan.add_action(CreatePathAction(metadata_file)) first_plan.add_action( WriteFileAction(metadata_file, Element(metadata, metadata_field_alias), content_type)) first_plan.add_action(CreatePathAction(root_file, True)) first_plan.add_action( WriteFileAction(root_file, Element(stripped_root, root_wrapper_alias), content_type)) first_plan.execute() # this will split the files in the temp directory # now, prepare the expected plan to split metadta at parties second_plan = Plan() metadata_file_dir = target_def_dir / element_paths[0].to_root_path() metadata2 = ostarget.Metadata.oscal_read(metadata_file) element = Element(metadata2, metadata_field_alias) element_args = ['metadata.parties.*'] element_paths = test_utils.prepare_element_paths(target_def_dir, element_args) parties_dir = metadata_file_dir / element_paths[0].to_file_path() for i, party in enumerate(element.get_at(element_paths[0])): prefix = str(i).zfill(const.FILE_DIGIT_PREFIX_LENGTH) sub_model_actions = SplitCmd.prepare_sub_model_split_actions( party, parties_dir, prefix, content_type) second_plan.add_actions(sub_model_actions) # stripped metadata stripped_metadata = metadata2.stripped_instance( stripped_fields_aliases=['parties']) second_plan.add_action(CreatePathAction(metadata_file, True)) second_plan.add_action( WriteFileAction(metadata_file, Element(stripped_metadata, metadata_field_alias), content_type)) # call the split command and compare the plans split_plan = SplitCmd.split_model(metadata, element_paths, metadata_file_dir, content_type) assert second_plan == split_plan
def split_model_at_path_chain(cls, model_obj: OscalBaseModel, element_paths: List[ElementPath], base_dir: pathlib.Path, content_type: FileContentType, cur_path_index: int, split_plan: Plan, strip_root: bool, root_file_name: str = '') -> int: """Recursively split the model at the provided chain of element paths. It assumes that a chain of element paths starts at the cur_path_index with the first path ending with a wildcard (*) It returns the index where the chain of path ends. For example, element paths could have a list of paths as below for a `TargetDefinition` model where the first path is the start of the chain. For each of the sub model described by the first element path (e.g target-defintion.targets.*) in the chain, the subsequent paths (e.g. target.target-control-implementations.*) will be applied recursively to retrieve the sub-sub models: [ 'target-definition.targets.*', 'target.target-control-implementations.*' ] for a command like below: trestle split -f target.yaml -e target-definition.targets.*.target-control-implementations.* """ # assume we ran the command below: # trestle split -f target.yaml -e target-definition.targets.*.target-control-implementations.* if split_plan is None: raise TrestleError('Split plan must have been initialized') if cur_path_index < 0: raise TrestleError( 'Current index of the chain of paths cannot be less than 0') # if there are no more element_paths, return the current plan if cur_path_index >= len(element_paths): return cur_path_index # initialize local variables element = Element(model_obj) stripped_field_alias = [] # get the sub_model specified by the element_path of this round element_path = element_paths[cur_path_index] is_parent = cur_path_index + 1 < len(element_paths) and element_paths[ cur_path_index + 1].get_parent() == element_path # root dir name for sub models dir # 00000__group.json will have the root_dir name as 00000__group for sub models of group # catalog.json will have the root_dir name as catalog sub models root_dir = '' if root_file_name != '': root_dir = pathlib.Path(root_file_name).stem # check that the path is not multiple level deep path_parts = element_path.get() if path_parts[-1] == ElementPath.WILDCARD: path_parts = path_parts[:-1] if len(path_parts) > 2: msg = 'Trestle supports split of first level children only, ' msg += f'found path "{element_path}" with level = {len(path_parts)}' raise TrestleError(msg) sub_models = element.get_at( element_path, False) # we call sub_models as in plural, but it can be just one if sub_models is None: return cur_path_index # assume cur_path_index is the end of the chain # value of this variable may change during recursive split of the sub-models below path_chain_end = cur_path_index # if wildcard is present in the element_path and the next path in the chain has current path as the parent, # we need to split recursively and create separate file for each sub item # for example, in the first round we get the `targets` using the path `target-definition.targets.*` # so, now we need to split each of the target recursively. Note that target is an instance of dict # However, there can be other sub_model, which is of type list if is_parent and element_path.get_last() is not ElementPath.WILDCARD: # create dir for all sub model items sub_models_dir = base_dir / element_path.to_root_path() sub_model_plan = Plan() path_chain_end = cls.split_model_at_path_chain( sub_models, element_paths, sub_models_dir, content_type, cur_path_index + 1, sub_model_plan, True) sub_model_actions = sub_model_plan.get_actions() split_plan.add_actions(sub_model_actions) elif element_path.get_last() == ElementPath.WILDCARD: # extract sub-models into a dict with appropriate prefix sub_model_items: Dict[str, OscalBaseModel] = {} sub_models_dir = base_dir / element_path.to_file_path( root_dir=root_dir) if isinstance(sub_models, list): for i, sub_model_item in enumerate(sub_models): # e.g. `groups/00000_groups/` prefix = str(i).zfill(const.FILE_DIGIT_PREFIX_LENGTH) sub_model_items[prefix] = sub_model_item elif isinstance(sub_models, dict): # prefix is the key of the dict sub_model_items = sub_models else: # unexpected sub model type for multi-level split with wildcard raise TrestleError( f'Sub element at {element_path} is not of type list or dict for further split' ) # process list sub model items for key in sub_model_items: prefix = key sub_model_item = sub_model_items[key] # recursively split the sub-model if there are more element paths to traverse # e.g. split target.target-control-implementations.* require_recursive_split = cur_path_index + 1 < len( element_paths) and element_paths[ cur_path_index + 1].get_parent() == element_path if require_recursive_split: # prepare individual directory for each sub-model # e.g. `targets/<UUID>__target/` sub_root_file_name = cmd_utils.to_model_file_name( sub_model_item, prefix, content_type) sub_model_plan = Plan() path_chain_end = cls.split_model_at_path_chain( sub_model_item, element_paths, sub_models_dir, content_type, cur_path_index + 1, sub_model_plan, True, sub_root_file_name) sub_model_actions = sub_model_plan.get_actions() else: sub_model_actions = cls.prepare_sub_model_split_actions( sub_model_item, sub_models_dir, prefix, content_type) split_plan.add_actions(sub_model_actions) else: # the chain of path ends at the current index. # so no recursive call. Let's just write the sub model to the file and get out sub_model_file = base_dir / element_path.to_file_path( content_type, root_dir=root_dir) split_plan.add_action(CreatePathAction(sub_model_file)) split_plan.add_action( WriteFileAction( sub_model_file, Element(sub_models, element_path.get_element_name()), content_type)) # Strip the root model and add a WriteAction for the updated model object in the plan if strip_root: stripped_field_alias.append(element_path.get_element_name()) stripped_root = model_obj.stripped_instance( stripped_fields_aliases=stripped_field_alias) if root_file_name != '': root_file = base_dir / root_file_name else: root_file = base_dir / element_path.to_root_path(content_type) split_plan.add_action(CreatePathAction(root_file)) wrapper_alias = utils.classname_to_alias( stripped_root.__class__.__name__, 'json') split_plan.add_action( WriteFileAction(root_file, Element(stripped_root, wrapper_alias), content_type)) # return the end of the current path chain return path_chain_end