Skip to content

headers

trestle.core.commands.author.headers ¤

Trestle author headers command.

logger ¤

Classes¤

Headers (AuthorCommonCommand) ¤

Enforce header / metadata across file types supported by author (markdown and drawio).

Source code in trestle/core/commands/author/headers.py
class Headers(AuthorCommonCommand):
    """Enforce header / metadata across file types supported by author (markdown and drawio)."""

    name = 'headers'

    def _init_arguments(self) -> None:
        self.add_argument(
            author_const.RECURSE_SHORT, author_const.RECURSE_LONG, help=author_const.RECURSE_HELP, action='store_true'
        )
        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 for header metadata governance are located at .trestle/author/[task name]',
                'Currently supported types are:',
                'Markdown: .trestle/author/[task name]/template.md',
                'Drawio: .trestle/author/[task name]/template.drawio',
                '',
                'Note that by default this will automatically enforce the task.'
            ]
        )
        self.add_argument(
            author_const.TASK_NAME_SHORT, author_const.TASK_NAME_LONG, help=tn_help_str, type=str, default=None
        )
        self.add_argument(
            author_const.SHORT_README_VALIDATE,
            author_const.LONG_README_VALIDATE,
            help=author_const.README_VALIDATE_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.GLOBAL_SHORT, author_const.GLOBAL_LONG, help=author_const.GLOBAL_HELP, action='store_true'
        )
        self.add_argument(
            author_const.EXCLUDE_SHORT,
            author_const.EXCLUDE_LONG,
            help=author_const.EXCLUDE_HELP,
            type=pathlib.Path,
            nargs='*',
            default=None
        )

    def _run(self, args: argparse.Namespace) -> int:
        try:
            status = 1
            if self._initialize(args):
                return status
            # Handle conditional requirement of args.task_name
            # global is special so we need to use get attribute.
            if not self.global_ and not self.task_name:
                logger.warning('Task name (-tn) argument is required when global is not specified')
                return status

            if args.exclude:
                logger.warning('--exclude or -e is deprecated, use --ignore instead.')

            if args.mode == 'create-sample':
                status = self.create_sample()

            elif args.mode == 'template-validate':
                status = self.template_validate()
            elif args.mode == 'setup':
                status = self.setup(args.template_version)
            elif args.mode == 'validate':
                exclusions = []
                if args.exclude:
                    exclusions = args.exclude
                # mode is validate
                status = self.validate(
                    args.recurse, args.readme_validate, exclusions, args.template_version, args.ignore
                )
            return status

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

    def create_sample(self) -> int:
        """Create sample object, this always defaults to markdown."""
        logger.info('Header only validation does not support sample creation.')
        logger.info('Exiting')
        return CmdReturnCodes.SUCCESS.value

    def setup(self, template_version: str) -> int:
        """Create template directory and templates."""
        # Step 1 - validation

        if self.task_name and not self.task_path.exists():
            self.task_path.mkdir(exist_ok=True, parents=True)
        elif self.task_name and 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)
        logger.info(f'Populating template files to {self.rel_dir(self.template_dir)}')
        for template in author_const.REFERENCE_TEMPLATES.values():
            destination_path = self.template_dir / template
            TemplateVersioning.write_versioned_template(template, self.template_dir, destination_path, template_version)

            logger.info(f'Template directory populated {self.rel_dir(destination_path)}')
        return CmdReturnCodes.SUCCESS.value

    def template_validate(self) -> int:
        """Validate the integrity of the template files."""
        logger.info('Checking template file integrity')
        for template_file in self.template_dir.iterdir():
            if (template_file.name not in author_const.REFERENCE_TEMPLATES.values()
                    and template_file.name.lower() != 'readme.md'):
                raise TrestleError(f'Unexpected template file {self.rel_dir(template_file)}')

            if template_file.suffix == const.MARKDOWN_FILE_EXT:
                try:
                    md_api = MarkdownAPI()
                    md_api.load_validator_with_template(template_file, True, False)
                except Exception as ex:
                    raise TrestleError(f'Template for task {self.task_name} failed to validate due to {ex}')

            elif template_file.suffix == const.DRAWIO_FILE_EXT:
                try:
                    _ = DrawIOMetadataValidator(template_file)
                except Exception as ex:
                    raise TrestleError(f'Template for task {self.task_name} failed to validate due to {ex}')

        logger.info('Templates validated')
        return CmdReturnCodes.SUCCESS.value

    def _validate_dir(
        self,
        candidate_dir: pathlib.Path,
        recurse: bool,
        readme_validate: bool,
        relative_exclusions: List[pathlib.Path],
        template_version: str,
        ignore: str
    ) -> bool:
        """Validate a directory within the trestle project."""
        all_versioned_templates = {}
        instance_version = template_version
        instance_file_names: List[pathlib.Path] = []
        # Fetch all instances versions and build dictionary of required template files
        instances = list(candidate_dir.iterdir())
        if recurse:
            instances = candidate_dir.rglob('*')
            if ignore:
                p = re.compile(ignore)
                instances = list(
                    filter(
                        lambda f: len(list(filter(p.match, str(f.relative_to(candidate_dir)).split('/')))) == 0,
                        instances
                    )
                )
        for instance_file in instances:
            if not file_utils.is_local_and_visible(instance_file):
                continue
            if instance_file.name.lower() == 'readme.md' and not readme_validate:
                continue
            if instance_file.is_dir() and not recurse:
                continue
            if any(str(ex) in str(instance_file) for ex in relative_exclusions):
                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(candidate_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 != '':
                    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
                    )

                if instance_version not in all_versioned_templates.keys():
                    templates = list(
                        filter(lambda p: file_utils.is_local_and_visible(p), versioned_template_dir.iterdir())
                    )
                    if not readme_validate:
                        templates = list(filter(lambda p: p.name.lower() != 'readme.md', templates))
                    self._update_templates(all_versioned_templates, templates, instance_version)

                # validate
                md_api.load_validator_with_template(all_versioned_templates[instance_version]['md'], True, False)
                status = md_api.validate_instance(instance_file)
                if not status:
                    logger.info(f'INVALID: {self.rel_dir(instance_file)}')
                    return False
                else:
                    logger.info(f'VALID: {self.rel_dir(instance_file)}')

            elif instance_file.suffix == const.DRAWIO_FILE_EXT:
                drawio = DrawIO(instance_file)
                metadata = drawio.get_metadata()[0]

                versioned_template_dir = None
                if template_version != '':
                    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
                    )

                if instance_version not in all_versioned_templates.keys():
                    templates = list(
                        filter(lambda p: file_utils.is_local_and_visible(p), versioned_template_dir.iterdir())
                    )
                    if not readme_validate:
                        templates = list(filter(lambda p: p.name.lower() != 'readme.md', templates))
                    self._update_templates(all_versioned_templates, templates, instance_version)

                # validate
                drawio_validator = DrawIOMetadataValidator(all_versioned_templates[instance_version]['drawio'])
                status = drawio_validator.validate(instance_file)
                if not status:
                    logger.info(f'INVALID: {self.rel_dir(instance_file)}')
                    return False
                else:
                    logger.info(f'VALID: {self.rel_dir(instance_file)}')

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

        return True

    def _update_templates(
        self, all_versioned_templates: Dict[str, Dict[str, str]], templates: List[str], instance_version: str
    ):
        all_versioned_templates[instance_version] = {}
        all_drawio_templates = list(filter(lambda p: p.suffix == const.DRAWIO_FILE_EXT, templates))
        all_md_templates = list(filter(lambda p: p.suffix == const.MARKDOWN_FILE_EXT, templates))
        if all_drawio_templates:
            all_versioned_templates[instance_version]['drawio'] = all_drawio_templates[0]
        if all_md_templates:
            all_versioned_templates[instance_version]['md'] = all_md_templates[0]

    def validate(
        self,
        recurse: bool,
        readme_validate: bool,
        relative_excludes: List[pathlib.Path],
        template_version: str,
        ignore: str
    ) -> int:
        """Run validation based on available templates."""
        paths = []
        if self.task_name:
            if not self.task_path.is_dir():
                raise TrestleError(f'Task directory {self.rel_dir(self.task_path)} does not exist. Exiting validate.')

            paths = [self.task_path]
        else:
            for path in self.trestle_root.iterdir():
                relative_path = path.relative_to(self.trestle_root)
                # Files in the root directory must be exclused
                if path.is_file():
                    continue
                if not file_utils.is_directory_name_allowed(path):
                    continue
                if str(relative_path).rstrip('/') in const.MODEL_DIR_LIST:
                    continue
                if (relative_path in relative_excludes):
                    continue
                if not file_utils.is_hidden(path):
                    paths.append(path)

        for path in paths:
            try:
                valid = self._validate_dir(path, recurse, readme_validate, relative_excludes, template_version, ignore)
                if not valid:
                    logger.info(f'validation failed on {path}')
                    return CmdReturnCodes.DOCUMENTS_VALIDATION_ERROR.value
            except Exception as e:
                raise TrestleError(f'Error during header validation on {path} {e}')

        return CmdReturnCodes.SUCCESS.value
name ¤
Methods¤
create_sample(self) ¤

Create sample object, this always defaults to markdown.

Source code in trestle/core/commands/author/headers.py
def create_sample(self) -> int:
    """Create sample object, this always defaults to markdown."""
    logger.info('Header only validation does not support sample creation.')
    logger.info('Exiting')
    return CmdReturnCodes.SUCCESS.value
setup(self, template_version) ¤

Create template directory and templates.

Source code in trestle/core/commands/author/headers.py
def setup(self, template_version: str) -> int:
    """Create template directory and templates."""
    # Step 1 - validation

    if self.task_name and not self.task_path.exists():
        self.task_path.mkdir(exist_ok=True, parents=True)
    elif self.task_name and 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)
    logger.info(f'Populating template files to {self.rel_dir(self.template_dir)}')
    for template in author_const.REFERENCE_TEMPLATES.values():
        destination_path = self.template_dir / template
        TemplateVersioning.write_versioned_template(template, self.template_dir, destination_path, template_version)

        logger.info(f'Template directory populated {self.rel_dir(destination_path)}')
    return CmdReturnCodes.SUCCESS.value
template_validate(self) ¤

Validate the integrity of the template files.

Source code in trestle/core/commands/author/headers.py
def template_validate(self) -> int:
    """Validate the integrity of the template files."""
    logger.info('Checking template file integrity')
    for template_file in self.template_dir.iterdir():
        if (template_file.name not in author_const.REFERENCE_TEMPLATES.values()
                and template_file.name.lower() != 'readme.md'):
            raise TrestleError(f'Unexpected template file {self.rel_dir(template_file)}')

        if template_file.suffix == const.MARKDOWN_FILE_EXT:
            try:
                md_api = MarkdownAPI()
                md_api.load_validator_with_template(template_file, True, False)
            except Exception as ex:
                raise TrestleError(f'Template for task {self.task_name} failed to validate due to {ex}')

        elif template_file.suffix == const.DRAWIO_FILE_EXT:
            try:
                _ = DrawIOMetadataValidator(template_file)
            except Exception as ex:
                raise TrestleError(f'Template for task {self.task_name} failed to validate due to {ex}')

    logger.info('Templates validated')
    return CmdReturnCodes.SUCCESS.value
validate(self, recurse, readme_validate, relative_excludes, template_version, ignore) ¤

Run validation based on available templates.

Source code in trestle/core/commands/author/headers.py
def validate(
    self,
    recurse: bool,
    readme_validate: bool,
    relative_excludes: List[pathlib.Path],
    template_version: str,
    ignore: str
) -> int:
    """Run validation based on available templates."""
    paths = []
    if self.task_name:
        if not self.task_path.is_dir():
            raise TrestleError(f'Task directory {self.rel_dir(self.task_path)} does not exist. Exiting validate.')

        paths = [self.task_path]
    else:
        for path in self.trestle_root.iterdir():
            relative_path = path.relative_to(self.trestle_root)
            # Files in the root directory must be exclused
            if path.is_file():
                continue
            if not file_utils.is_directory_name_allowed(path):
                continue
            if str(relative_path).rstrip('/') in const.MODEL_DIR_LIST:
                continue
            if (relative_path in relative_excludes):
                continue
            if not file_utils.is_hidden(path):
                paths.append(path)

    for path in paths:
        try:
            valid = self._validate_dir(path, recurse, readme_validate, relative_excludes, template_version, ignore)
            if not valid:
                logger.info(f'validation failed on {path}')
                return CmdReturnCodes.DOCUMENTS_VALIDATION_ERROR.value
        except Exception as e:
            raise TrestleError(f'Error during header validation on {path} {e}')

    return CmdReturnCodes.SUCCESS.value

handler: python