Skip to content

folders

trestle.core.commands.author.folders ¤

Trestle author docs sub-command.

logger ¤

Classes¤

Folders (AuthorCommonCommand) ¤

Markdown governed folders - enforcing consistent files and templates across directories.

Source code in trestle/core/commands/author/folders.py
class Folders(AuthorCommonCommand):
    """Markdown governed folders - enforcing consistent files and templates across directories."""

    name = 'folders'

    def _init_arguments(self) -> None:
        self.add_argument(
            author_const.GH_SHORT, author_const.GH_LONG, help=author_const.GH_HELP, default=None, type=str
        )
        self.add_argument(
            author_const.SHORT_HEADER_VALIDATE,
            author_const.LONG_HEADER_VALIDATE,
            help=author_const.HEADER_VALIDATE_HELP,
            action='store_true'
        )
        self.add_argument(
            author_const.HOV_SHORT, author_const.HOV_LONG, help=author_const.HOV_HELP, action='store_true'
        )
        self.add_argument(
            author_const.SHORT_TEMPLATE_VERSION,
            author_const.LONG_TEMPLATE_VERSION,
            help=author_const.TEMPLATE_VERSION_HELP,
            action='store'
        )
        self.add_argument(
            author_const.SHORT_IGNORE, author_const.LONG_IGNORE, help=author_const.IGNORE_HELP, default=None, type=str
        )
        self.add_argument(author_const.MODE_ARG_NAME, choices=author_const.MODE_CHOICES)
        tn_help_str = '\n'.join(
            [
                'The name of the the task to be governed.',
                '',
                'The template files are at .trestle/author/[task-name],',
                'where the directory tree established and the markdown files within that directory'
                + 'tree are enforced.'
            ]
        )

        self.add_argument(
            author_const.TASK_NAME_SHORT, author_const.TASK_NAME_LONG, help=tn_help_str, required=True, type=str
        )
        self.add_argument(
            author_const.SHORT_README_VALIDATE,
            author_const.LONG_README_VALIDATE,
            help=author_const.README_VALIDATE_FOLDERS_HELP,
            action='store_true'
        )

    def _run(self, args: argparse.Namespace) -> int:
        try:
            if self._initialize(args):
                raise TrestleError(f'Error when initializing trestle folders command with args: {args}')
            if args.mode == 'create-sample':
                status = self.create_sample()

            elif args.mode == 'template-validate':
                status = self.template_validate(
                    args.header_validate, args.header_only_validate, args.governed_heading, args.readme_validate
                )
            elif args.mode == 'setup':
                status = self.setup_template(args.template_version)
            elif args.mode == 'validate':
                # mode is validate
                status = self.validate(
                    args.header_validate,
                    args.header_only_validate,
                    args.governed_heading,
                    args.readme_validate,
                    args.template_version,
                    args.ignore
                )
            else:
                raise TrestleIncorrectArgsError(f'Unsupported mode: {args.mode} for folders command.')

            return status

        except Exception as e:  # pragma: no cover
            return handle_generic_command_exception(e, logger, 'Error occurred when running trestle author folders')

    def setup_template(self, template_version: str) -> int:
        """Create structure to allow markdown template enforcement."""
        if not self.task_path.exists():
            self.task_path.mkdir(exist_ok=True, parents=True)
        elif self.task_path.is_file():
            raise TrestleError(f'Task path: {self.rel_dir(self.task_path)} is a file not a directory.')
        if not self.template_dir.exists():
            self.template_dir.mkdir(exist_ok=True, parents=True)
        elif self.template_dir.is_file():
            raise TrestleError(f'Template path: {self.rel_dir(self.template_dir)} is a file not a directory.')

        template_file_a_md = self.template_dir / 'a_template.md'
        template_file_another_md = self.template_dir / 'another_template.md'
        template_file_drawio = self.template_dir / 'architecture.drawio'
        TemplateVersioning.write_versioned_template(
            'template.md', self.template_dir, template_file_a_md, template_version
        )
        TemplateVersioning.write_versioned_template(
            'template.md', self.template_dir, template_file_another_md, template_version
        )
        TemplateVersioning.write_versioned_template(
            'template.drawio', self.template_dir, template_file_drawio, template_version
        )

        return CmdReturnCodes.SUCCESS.value

    def template_validate(
        self, validate_header: bool, validate_only_header: bool, heading: str, readme_validate: bool
    ) -> int:
        """Validate that the template is acceptable markdown."""
        if not self.template_dir.is_dir():
            raise TrestleError(
                f'Template directory {self.rel_dir(self.template_dir)} for task {self.task_name} does not exist.'
            )
        # get list of files:
        template_files = self.template_dir.rglob('*')

        for template_file in template_files:
            try:
                if not file_utils.is_local_and_visible(template_file):
                    continue
                elif template_file.is_dir():
                    continue
                elif template_file.suffix.lower() == const.MARKDOWN_FILE_EXT:
                    if not readme_validate and template_file.name == 'readme.md':
                        raise TrestleError('Template directory contains a readme.md file and readme validation is off.')

                    md_api = MarkdownAPI()
                    md_api.load_validator_with_template(
                        template_file, validate_header, not validate_only_header, heading
                    )
                elif template_file.suffix.lower().lstrip('.') == 'drawio':
                    _ = draw_io.DrawIOMetadataValidator(template_file)
                else:
                    logger.info(
                        f'File: {self.rel_dir(template_file)} within the template directory was ignored'
                        + ' as it is not markdown.'
                    )
            except Exception as ex:
                raise TrestleError(
                    f'Template file {self.rel_dir(template_file)} for task {self.task_name}'
                    + f' failed to validate due to {ex}'
                )
        logger.info(f'TEMPLATES VALID: {self.task_name}.')
        return CmdReturnCodes.SUCCESS.value

    def _measure_template_folder(
        self,
        instance_dir: pathlib.Path,
        validate_header: bool,
        validate_only_header: bool,
        governed_heading: str,
        readme_validate: bool,
        template_version: str,
        ignore: str
    ) -> bool:
        """
        Validate instances against templates.

        Validation will succeed iff:
            1. All template files from the specified version are present in the task
            2. All of the instances are valid
        """
        all_versioned_templates = {}
        instance_version = template_version
        instance_file_names: List[pathlib.Path] = []
        # Fetch all instances versions and build dictionary of required template files
        for instance_file in instance_dir.iterdir():
            if not file_utils.is_local_and_visible(instance_file):
                continue
            if not instance_file.is_file():
                continue
            if instance_file.name.lower() == 'readme.md' and not readme_validate:
                continue
            if ignore:
                p = re.compile(ignore)
                matched = p.match(instance_file.parts[-1])
                if matched is not None:
                    logger.info(f'Ignoring file {instance_file} from validation.')
                    continue
            instance_file_name = instance_file.relative_to(instance_dir)
            instance_file_names.append(instance_file_name)
            if instance_file.suffix == const.MARKDOWN_FILE_EXT:
                md_api = MarkdownAPI()
                versioned_template_dir = None
                if template_version != '':
                    template_file = self.template_dir / instance_file_name
                    versioned_template_dir = self.template_dir
                else:
                    instance_version = md_api.processor.fetch_value_from_header(
                        instance_file, author_const.TEMPLATE_VERSION_HEADER
                    )
                    if instance_version is None:
                        instance_version = '0.0.1'  # backward compatibility
                    versioned_template_dir = TemplateVersioning.get_versioned_template_dir(
                        self.template_dir, instance_version
                    )
                    template_file = versioned_template_dir / instance_file_name

                # Check if instance is in the available templates,
                # additional files are allowed but should not be validated.
                templates = self._get_templates(versioned_template_dir, readme_validate)
                is_template_present = False
                for template in templates:
                    if template.name == str(instance_file_name):
                        is_template_present = True
                        break

                if not is_template_present:
                    logger.info(
                        f'INFO: File{instance_file} will not be validated '
                        f'as its name does not match any template file.'
                    )
                    continue

                if instance_version not in all_versioned_templates.keys():
                    all_versioned_templates[instance_version] = dict.fromkeys(
                        [t.relative_to(versioned_template_dir) for t in templates], False
                    )

                if instance_file_name in all_versioned_templates[instance_version]:
                    # validate
                    md_api.load_validator_with_template(
                        template_file, validate_header, not validate_only_header, governed_heading
                    )
                    status = md_api.validate_instance(instance_file)
                    if not status:
                        logger.warning(
                            f'INVALID: Markdown file {instance_file} failed validation against' + f' {template_file}'
                        )
                        return False
                    else:
                        logger.info(f'VALID: {instance_file}')
                    # mark template as present
                    all_versioned_templates[instance_version][instance_file_name] = True

            elif instance_file.suffix == const.DRAWIO_FILE_EXT:
                drawio = draw_io.DrawIO(instance_file)
                metadata = drawio.get_metadata()[0]
                versioned_template_dir = None
                if template_version != '':
                    template_file = self.template_dir / instance_file_name
                    versioned_template_dir = self.template_dir
                else:
                    if author_const.TEMPLATE_VERSION_HEADER in metadata.keys():
                        instance_version = metadata[author_const.TEMPLATE_VERSION_HEADER]
                    else:
                        instance_version = '0.0.1'  # backward compatibility

                    versioned_template_dir = TemplateVersioning.get_versioned_template_dir(
                        self.template_dir, instance_version
                    )
                    template_file = versioned_template_dir / instance_file_name

                if instance_version not in all_versioned_templates.keys():
                    templates = self._get_templates(versioned_template_dir, readme_validate)

                    all_versioned_templates[instance_version] = dict.fromkeys(
                        [t.relative_to(versioned_template_dir) for t in templates], False
                    )

                if instance_file_name in all_versioned_templates[instance_version]:
                    # validate
                    drawio_validator = draw_io.DrawIOMetadataValidator(template_file)
                    status = drawio_validator.validate(instance_file)
                    if not status:
                        logger.warning(
                            f'INVALID: Drawio file {instance_file} failed validation against' + f' {template_file}'
                        )
                        return False
                    else:
                        logger.info(f'VALID: {instance_file}')
                    # mark template as present
                    all_versioned_templates[instance_version][instance_file_name] = True

            else:
                logger.debug(f'Unsupported extension of the instance file: {instance_file}, will not be validated.')

        # Check that all template files are present
        for version in all_versioned_templates.keys():
            for template in all_versioned_templates[version]:
                if not all_versioned_templates[version][template]:
                    logger.warning(
                        f'Required template file {template} does not exist in measured instance' + f'{instance_dir}'
                    )
                    return False

        return True

    def _get_templates(self, versioned_template_dir: pathlib.Path, readme_validate: bool) -> List[pathlib.Path]:
        """Get templates for the given version."""
        templates = list(
            filter(
                lambda p: file_utils.is_local_and_visible(p) and p.is_file()
                and  # noqa: W504 - conflicting lint and formatting
                (p.suffix == const.MARKDOWN_FILE_EXT or p.suffix == const.DRAWIO_FILE_EXT),
                versioned_template_dir.iterdir()
            )
        )
        if not readme_validate:
            templates = list(filter(lambda p: p.name.lower() != 'readme.md', templates))

        return templates

    def create_sample(self) -> int:
        """
        Create a sample folder within the task and populate with template content.

        Returns:
            Unix return code for running sample as a command.
        """
        ii = 0
        while True:
            sample_path = self.task_path / f'sample_folder_{ii}'
            if sample_path.exists():
                ii = ii + 1
                continue
            shutil.copytree(str(self.template_dir), str(sample_path))
            return CmdReturnCodes.SUCCESS.value

    def validate(
        self,
        validate_header: bool,
        validate_only_header: bool,
        governed_heading: str,
        readme_validate: bool,
        template_version: str,
        ignore: str
    ) -> int:
        """Validate task."""
        if not self.task_path.is_dir():
            raise TrestleError(f'Task directory {self.task_path} does not exist. Exiting validate.')

        for task_instance in self.task_path.iterdir():
            if task_instance.is_dir():
                if file_utils.is_symlink(task_instance):
                    continue
                result = self._measure_template_folder(
                    task_instance,
                    validate_header,
                    validate_only_header,
                    governed_heading,
                    readme_validate,
                    template_version,
                    ignore
                )
                if not result:
                    raise TrestleError(
                        'Governed-folder validation failed for task'
                        + f'{self.task_name} on directory {self.rel_dir(task_instance)}'
                    )
            else:
                logger.info(
                    f'Unexpected file {self.rel_dir(task_instance)} identified in {self.task_name}'
                    + ' directory, ignoring.'
                )
        return CmdReturnCodes.SUCCESS.value
name ¤
Methods¤
create_sample(self) ¤

Create a sample folder within the task and populate with template content.

Returns:

Type Description
int

Unix return code for running sample as a command.

Source code in trestle/core/commands/author/folders.py
def create_sample(self) -> int:
    """
    Create a sample folder within the task and populate with template content.

    Returns:
        Unix return code for running sample as a command.
    """
    ii = 0
    while True:
        sample_path = self.task_path / f'sample_folder_{ii}'
        if sample_path.exists():
            ii = ii + 1
            continue
        shutil.copytree(str(self.template_dir), str(sample_path))
        return CmdReturnCodes.SUCCESS.value
setup_template(self, template_version) ¤

Create structure to allow markdown template enforcement.

Source code in trestle/core/commands/author/folders.py
def setup_template(self, template_version: str) -> int:
    """Create structure to allow markdown template enforcement."""
    if not self.task_path.exists():
        self.task_path.mkdir(exist_ok=True, parents=True)
    elif self.task_path.is_file():
        raise TrestleError(f'Task path: {self.rel_dir(self.task_path)} is a file not a directory.')
    if not self.template_dir.exists():
        self.template_dir.mkdir(exist_ok=True, parents=True)
    elif self.template_dir.is_file():
        raise TrestleError(f'Template path: {self.rel_dir(self.template_dir)} is a file not a directory.')

    template_file_a_md = self.template_dir / 'a_template.md'
    template_file_another_md = self.template_dir / 'another_template.md'
    template_file_drawio = self.template_dir / 'architecture.drawio'
    TemplateVersioning.write_versioned_template(
        'template.md', self.template_dir, template_file_a_md, template_version
    )
    TemplateVersioning.write_versioned_template(
        'template.md', self.template_dir, template_file_another_md, template_version
    )
    TemplateVersioning.write_versioned_template(
        'template.drawio', self.template_dir, template_file_drawio, template_version
    )

    return CmdReturnCodes.SUCCESS.value
template_validate(self, validate_header, validate_only_header, heading, readme_validate) ¤

Validate that the template is acceptable markdown.

Source code in trestle/core/commands/author/folders.py
def template_validate(
    self, validate_header: bool, validate_only_header: bool, heading: str, readme_validate: bool
) -> int:
    """Validate that the template is acceptable markdown."""
    if not self.template_dir.is_dir():
        raise TrestleError(
            f'Template directory {self.rel_dir(self.template_dir)} for task {self.task_name} does not exist.'
        )
    # get list of files:
    template_files = self.template_dir.rglob('*')

    for template_file in template_files:
        try:
            if not file_utils.is_local_and_visible(template_file):
                continue
            elif template_file.is_dir():
                continue
            elif template_file.suffix.lower() == const.MARKDOWN_FILE_EXT:
                if not readme_validate and template_file.name == 'readme.md':
                    raise TrestleError('Template directory contains a readme.md file and readme validation is off.')

                md_api = MarkdownAPI()
                md_api.load_validator_with_template(
                    template_file, validate_header, not validate_only_header, heading
                )
            elif template_file.suffix.lower().lstrip('.') == 'drawio':
                _ = draw_io.DrawIOMetadataValidator(template_file)
            else:
                logger.info(
                    f'File: {self.rel_dir(template_file)} within the template directory was ignored'
                    + ' as it is not markdown.'
                )
        except Exception as ex:
            raise TrestleError(
                f'Template file {self.rel_dir(template_file)} for task {self.task_name}'
                + f' failed to validate due to {ex}'
            )
    logger.info(f'TEMPLATES VALID: {self.task_name}.')
    return CmdReturnCodes.SUCCESS.value
validate(self, validate_header, validate_only_header, governed_heading, readme_validate, template_version, ignore) ¤

Validate task.

Source code in trestle/core/commands/author/folders.py
def validate(
    self,
    validate_header: bool,
    validate_only_header: bool,
    governed_heading: str,
    readme_validate: bool,
    template_version: str,
    ignore: str
) -> int:
    """Validate task."""
    if not self.task_path.is_dir():
        raise TrestleError(f'Task directory {self.task_path} does not exist. Exiting validate.')

    for task_instance in self.task_path.iterdir():
        if task_instance.is_dir():
            if file_utils.is_symlink(task_instance):
                continue
            result = self._measure_template_folder(
                task_instance,
                validate_header,
                validate_only_header,
                governed_heading,
                readme_validate,
                template_version,
                ignore
            )
            if not result:
                raise TrestleError(
                    'Governed-folder validation failed for task'
                    + f'{self.task_name} on directory {self.rel_dir(task_instance)}'
                )
        else:
            logger.info(
                f'Unexpected file {self.rel_dir(task_instance)} identified in {self.task_name}'
                + ' directory, ignoring.'
            )
    return CmdReturnCodes.SUCCESS.value

handler: python