Skip to content

headers

trestle.core.commands.author.headers ¤

Trestle author headers command.

Attributes¤

logger = logging.getLogger(__name__) module-attribute ¤

Classes¤

Headers ¤

Bases: AuthorCommonCommand

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

Source code in trestle/core/commands/author/headers.py
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
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 workspace."""
        all_versioned_templates: Dict[str, Any] = {}
        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
    ) -> None:
        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
Attributes¤
name = 'headers' class-attribute instance-attribute ¤
Functions¤
create_sample() ¤

Create sample object, this always defaults to markdown.

Source code in trestle/core/commands/author/headers.py
121
122
123
124
125
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(template_version) ¤

Create template directory and templates.

Source code in trestle/core/commands/author/headers.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
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() ¤

Validate the integrity of the template files.

Source code in trestle/core/commands/author/headers.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
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(recurse, readme_validate, relative_excludes, template_version, ignore) ¤

Run validation based on available templates.

Source code in trestle/core/commands/author/headers.py
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
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

Functions¤

handler: python