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('