diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml index ee554f9f59..98e9935904 100644 --- a/.github/workflows/validation.yml +++ b/.github/workflows/validation.yml @@ -27,10 +27,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - - name: Install dependencies - run: npm install `cat npm-requirements.txt` - name: Run style checks - run: npx remark src/**/*.md --frail --rc-path .remarkrc + run: make remark # YAML yamllint: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d233723fb8..21bcfd6341 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -82,6 +82,7 @@ repos: - click - markdown-it-py - importlib_resources + - jinja2 - pandas-stubs - pyparsing - pytest diff --git a/Makefile b/Makefile index a56b672d82..76de5d4056 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,3 @@ -.PHONY: tools/contributors.tsv - validate_citation_cff: CITATION.cff cffconvert --validate @@ -8,12 +6,13 @@ update_contributors: python tools/print_contributors.py yarn all-contributors generate +.PHONY: runprettier runprettier: prettier --write "src/schema/**/*.yaml" python3 -m yamllint -f standard src/schema/ -c .yamllint.yml +.PHONY: commitschema SCHEMA_CHANGES := $(shell git diff --name-only | grep src/schema/*.yaml) - commitschema: @echo SCHEMA_CHANGES $(SCHEMA_CHANGES) git add src/schema/*.yaml && \ @@ -23,6 +22,9 @@ commitschema: formatschema: runprettier commitschema -all: +# check style of all markdown files +node_modules: npm-requirements.txt + npm install `cat npm-requirements.txt` -.PHONY: runprettier commitschema +remark: node_modules + npx remark src/**/*.md --frail --rc-path .remarkrc diff --git a/tools/schemacode/pyproject.toml b/tools/schemacode/pyproject.toml index 7e7b10fc52..8801d3357c 100644 --- a/tools/schemacode/pyproject.toml +++ b/tools/schemacode/pyproject.toml @@ -33,7 +33,8 @@ expressions = ["pyparsing"] render = [ "tabulate", "pandas", - "markdown-it-py" + "markdown-it-py", + "jinja2" ] tests = [ "bidsschematools[expressions,render]", @@ -68,7 +69,8 @@ bidsschematools = [ "data/schema/BIDS_VERSION", "data/schema/SCHEMA_VERSION", "data/schema/**/*.yaml", - "tests/data/*" + "tests/data/*", + "render/templates/*" ] [tool.black] diff --git a/tools/schemacode/src/bidsschematools/render/templates/common_principle.jinja b/tools/schemacode/src/bidsschematools/render/templates/common_principle.jinja new file mode 100644 index 0000000000..bad215ae02 --- /dev/null +++ b/tools/schemacode/src/bidsschematools/render/templates/common_principle.jinja @@ -0,0 +1,4 @@ +{% for principle in principles %} +1. **{{ principle.display_name }}** - {{ principle.description }} + +{% endfor %} diff --git a/tools/schemacode/src/bidsschematools/render/templates/entity_definition.jinja b/tools/schemacode/src/bidsschematools/render/templates/entity_definition.jinja new file mode 100644 index 0000000000..39aac1a76c --- /dev/null +++ b/tools/schemacode/src/bidsschematools/render/templates/entity_definition.jinja @@ -0,0 +1,10 @@ + +## {{ entity.name }} + +**Full name**: {{ entity.display_name }} + +**Format**: `{{ entity.name }}-<{{ entity.format }}>` +{% if entity.allowed_values %} +**Allowed values**: `{% for value in allowed_values %}{{ value }}{% if not loop.last %}, {% endif %}{% endfor %}` +{% endif %} +**Definition**: {{ entity.description }} diff --git a/tools/schemacode/src/bidsschematools/render/templates/glossary.jinja b/tools/schemacode/src/bidsschematools/render/templates/glossary.jinja new file mode 100644 index 0000000000..814dffaa41 --- /dev/null +++ b/tools/schemacode/src/bidsschematools/render/templates/glossary.jinja @@ -0,0 +1,28 @@ + + +## {{ obj_key }} + +**Name**: {{ obj_def['display_name'] }} + +**Type**: {{ obj['type'].title() }} +{% if obj["type"] == "suffix" %} +**Format**: `_{{ obj_def['value'] }}.` +{% elif obj["type"] == "extension" %} +**Format**: `_{{ obj_def['value'] }}` +{% elif obj["type"] == "format" %} +**Regular expression**: `{{ obj_def['pattern'] }}` +{% endif %} + +{% if levels %} +**Allowed values**: `{% for level in levels %}{{ level }}{% if not loop.last %}`, `{% endif %}{% endfor %}` +{% endif %} + +**Description**: +{{ obj_desc }} + +{% if reduced_obj_def %} +**Schema information**: +```yml +{{ reduced_obj_def }} +``` +{% endif %} diff --git a/tools/schemacode/src/bidsschematools/render/text.py b/tools/schemacode/src/bidsschematools/render/text.py index d6945526ae..90e8d01481 100644 --- a/tools/schemacode/src/bidsschematools/render/text.py +++ b/tools/schemacode/src/bidsschematools/render/text.py @@ -1,6 +1,9 @@ """Functions for rendering portions of the schema as text.""" +from pathlib import Path + import yaml +from jinja2 import Template from markdown_it import MarkdownIt from bidsschematools.render import utils @@ -26,6 +29,8 @@ "suffixes": "suffix", } +TEMPLATE_DIR = Path(__file__).parent / "templates" + def make_entity_definitions(schema, src_path=None): """Generate definitions and other relevant information for entities in the specification. @@ -50,37 +55,32 @@ def make_entity_definitions(schema, src_path=None): text = "" for entity in entity_order: entity_info = entity_definitions[entity] - entity_text = _make_entity_definition(entity, entity_info) - text += "\n" + entity_text + text += _make_entity_definition(entity_info) - text = text.replace("SPEC_ROOT", utils.get_relpath(src_path)) - return text + return text.replace("SPEC_ROOT", utils.get_relpath(src_path)) -def _make_entity_definition(entity, entity_info): - """Describe an entity.""" - entity_shorthand = entity_info["name"] - text = "" - text += f"## {entity_shorthand}" - text += "\n\n" - text += f"**Full name**: {entity_info['display_name']}" - text += "\n\n" - text += f"**Format**: `{entity_info['name']}-<{entity_info.get('format', 'label')}>`" - text += "\n\n" - if "enum" in entity_info.keys(): - allowed_values = [] +def _make_entity_definition(entity_info): + """Generate markdown description for an entity.""" + # Prepare data for template rendering + entity_info.format = entity_info.get("format", "label") + + # Prepare enum values if present + allowed_values = [] + if "enum" in entity_info: for value in entity_info["enum"]: if isinstance(value, str): allowed_values.append(value) - else: + elif isinstance(value, dict) and "name" in value: allowed_values.append(value["name"]) + else: + allowed_values.append(str(value)) # Fallback to string + entity_info.allowed_values = allowed_values - text += f"**Allowed values**: `{'`, `'.join(allowed_values)}`" - text += "\n\n" - - description = entity_info["description"] - text += f"**Definition**: {description}" - return text + with (TEMPLATE_DIR / "entity_definition.jinja").open("r") as f: + template_str = f.read() + template = Template(template_str) + return template.render(entity=entity_info) def make_glossary(schema, src_path=None): @@ -104,6 +104,9 @@ def make_glossary(schema, src_path=None): all_objects = {} schema = schema.to_dict() + with (TEMPLATE_DIR / "glossary.jinja").open("r") as f: + template_str = f.read() + for group, group_objects in schema["objects"].items(): group_obj_keys = list(group_objects.keys()) @@ -142,27 +145,20 @@ def make_glossary(schema, src_path=None): text = "" for obj_key in sorted(all_objects.keys()): obj = all_objects[obj_key] - obj_marker = obj["key"] + obj_def = obj.get("definition", None) if obj_def is None: - raise ValueError(f"{obj_marker} has no definition.") + raise ValueError(f"{obj['key']} has no definition.") # Clean up the text description obj_desc = obj_def.get("description", None) if obj_desc is None: - raise ValueError(f"{obj_marker} has no description.") - - text += f'\n' - text += f"\n## {obj_key}\n\n" - text += f"**Name**: {obj_def['display_name']}\n\n" - text += f"**Type**: {obj['type'].title()}\n\n" + raise ValueError(f"{obj['key']} has no description.") + obj_desc = MarkdownIt().render(f"{obj_desc}") - if obj["type"] == "suffix": - text += f"**Format**: `_{obj_def['value']}.`\n\n" - elif obj["type"] == "extension": - text += f"**Format**: `_{obj_def['value']}`\n\n" - elif obj["type"] == "format": - text += f"**Regular expression**: `{obj_def['pattern']}`\n\n" + levels = list(obj_def.get("enum", []) or obj_def.get("definition", {}).get("Levels", {})) + if levels: + levels = [level["name"] if isinstance(level, dict) else level for level in levels] keys_to_drop = [ "description", @@ -173,20 +169,19 @@ def make_glossary(schema, src_path=None): "enum", "definition", ] - levels = list(obj_def.get("enum", []) or obj_def.get("definition", {}).get("Levels", {})) - if levels: - levels = [level["name"] if isinstance(level, dict) else level for level in levels] - text += f"**Allowed values**: `{'`, `'.join(levels)}`\n\n" - - # Convert description into markdown and append to text - obj_desc = MarkdownIt().render(f"**Description**:\n{obj_desc}") - text += f"{obj_desc}\n\n" - reduced_obj_def = {k: v for k, v in obj_def.items() if k not in keys_to_drop} - if reduced_obj_def: reduced_obj_def = yaml.dump(reduced_obj_def) - text += f"**Schema information**:\n```yaml\n{reduced_obj_def}\n```" + + template = Template(template_str) + text += template.render( + obj_key=obj_key, + obj=obj, + obj_def=obj_def, + obj_desc=obj_desc, + levels=levels, + reduced_obj_def=reduced_obj_def, + ) # Spec internal links need to be replaced text = text.replace("SPEC_ROOT", utils.get_relpath(src_path)) @@ -549,21 +544,19 @@ def define_common_principles(schema, src_path=None): string : str The definitions of the common principles in a multiline string. """ - string = "" + # reorder the principles according to the order common_principles = schema["objects"]["common_principles"] order = schema["rules"]["common_principles"] - for i_prin, principle in enumerate(order): - principle_name = common_principles[principle]["display_name"] - substring = ( - f"{i_prin + 1}. **{principle_name}** - {common_principles[principle]['description']}" - ) - string += substring - if i_prin < len(order) - 1: - string += "\n\n" + principles = [] + for principle in order: + principles.append(common_principles.get(principle)) - string = string.replace("SPEC_ROOT", utils.get_relpath(src_path)) + with (TEMPLATE_DIR / "common_principle.jinja").open("r") as f: + template_str = f.read() + template = Template(template_str) + text = template.render(principles=principles) - return string + return text.replace("SPEC_ROOT", utils.get_relpath(src_path)) def define_allowed_top_directories(schema, src_path=None) -> str: diff --git a/tools/schemacode/src/bidsschematools/tests/test_render_text.py b/tools/schemacode/src/bidsschematools/tests/test_render_text.py index 6f67567f1f..ba174c6199 100644 --- a/tools/schemacode/src/bidsschematools/tests/test_render_text.py +++ b/tools/schemacode/src/bidsschematools/tests/test_render_text.py @@ -34,6 +34,8 @@ def test_make_entity_definitions(schema_obj): for expected_format in expected_formats: assert expected_format in schema_text + assert "**Definition**: A person or animal participating in the study." in schema_text + def test_make_glossary(schema_obj, schema_dir): """ @@ -43,13 +45,13 @@ def test_make_glossary(schema_obj, schema_dir): """ # Test consistency object_files = [] - for root, dirs, files in os.walk(schema_dir, topdown=False): + for root, _, files in os.walk(schema_dir, topdown=False): if "objects" in root: for object_file in files: object_base, _ = os.path.splitext(object_file) object_files.append(object_base) rule_files = [] - for root, dirs, files in os.walk(schema_dir, topdown=False): + for root, _, files in os.walk(schema_dir, topdown=False): if "rules" in root: for rule_file in files: rule_base, _ = os.path.splitext(rule_file) @@ -57,6 +59,7 @@ def test_make_glossary(schema_obj, schema_dir): rules_only = list(filter(lambda a: a not in object_files, rule_files)) glossary = text.make_glossary(schema_obj) + for line in glossary.split("\n"): if line.startswith('