diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 9c4fab932..1b1d79e19 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -84,6 +84,25 @@ class ShaclGenerator(Generator): Conforms to :rfc:`5646` (BCP 47). """ + message_template: str | None = None + """Template for ``sh:message`` on property shapes. + + When set, each property shape receives an ``sh:message`` literal built from + this template. The following placeholders are expanded: + + * ``{name}`` — the slot name (underscore-separated LinkML name) + * ``{title}`` — the slot title (human-readable), falls back to *name* + * ``{description}`` — the slot description, falls back to empty string + * ``{comments}`` — the slot comments joined with ``; ``, falls back to empty string + * ``{class}`` — the enclosing class name + * ``{path}`` — the property IRI (compact or full) + + Example: ``"Validation of {name} failed!"`` → + ``sh:message "Validation of has_speed failed!"`` + + If ``default_language`` is also set the literal is language-tagged. + """ + generatorname = os.path.basename(__file__) generatorversion = "0.0.1" valid_formats = ["ttl"] @@ -136,6 +155,7 @@ def _resolve_language(self, element=None) -> str | None: def __post_init__(self) -> None: super().__post_init__() + self.message_template = (self.message_template or "").strip() or None self.generate_header() def generate_header(self) -> str: @@ -235,6 +255,25 @@ def prop_pv_text(p, v): order += 1 prop_pv_text(SH.name, s.title) prop_pv_text(SH.description, s.description) + + # sh:message from template + if self.message_template is not None: + try: + msg_text = self.message_template.format( + name=s.name, + title=s.title or s.name, + description=s.description or "", + comments="; ".join(s.comments) if s.comments else "", + **{"class": c.name}, + path=str(slot_uri), + ).strip() + except (KeyError, IndexError, ValueError) as exc: + raise ValueError( + f"Invalid placeholder {exc} in --message-template. " + f"Allowed: {{name}}, {{title}}, {{description}}, {{comments}}, {{class}}, {{path}}" + ) from None + if msg_text: + prop_pv_text(SH.message, msg_text) # minCount if s.minimum_cardinality: prop_pv_literal(SH.minCount, s.minimum_cardinality) @@ -612,6 +651,18 @@ def add_simple_data_type(func: Callable, r: ElementName) -> None: "language tag." ), ) +@click.option( + "--message-template", + default=None, + show_default=True, + help=( + "Template string for sh:message on each property shape. " + "Placeholders: {name} (slot name), {title} (slot title or name), " + "{description} (slot description), {comments} (slot comments joined with '; '), " + "{class} (class name), {path} (property IRI). " + 'Example: "{name} ({class}): {description} [{comments}]"' + ), +) @click.version_option(__version__, "-V", "--version") def cli(yamlfile, **args): """Generate SHACL turtle from a LinkML model""" diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py index a7c21620f..d1311c762 100644 --- a/tests/linkml/test_generators/test_shaclgen.py +++ b/tests/linkml/test_generators/test_shaclgen.py @@ -1192,6 +1192,34 @@ def _build_shacl_lang_schema(): return sb.schema +def _build_message_test_schema(): + """Build a schema for sh:message testing (includes a second slot without title).""" + sb = SchemaBuilder() + sb.add_slot( + SlotDefinition( + "vehicle_name", + range="string", + description="The vehicle name.", + title="Name", + required=True, + ) + ) + sb.add_slot( + SlotDefinition( + "speed", + range="integer", + description="Speed in km/h.", + ) + ) + sb.add_class( + "Vehicle", + slots=["vehicle_name", "speed"], + description="A road vehicle.", + ) + sb.add_defaults() + return sb.schema + + def _parse_shacl(schema, **kwargs): shacl = ShaclGenerator(schema, mergeimports=False, **kwargs).serialize() g = rdflib.Graph() @@ -1380,3 +1408,204 @@ def test_shacl_default_language_in_language_bcp47_warning(caplog): labels = list(g.objects(EX.Vehicle, RDFS.label)) assert any(lit.language == "toolongtag" for lit in labels) assert any("in_language" in rec.message and "toolongtag" in rec.message for rec in caplog.records) + + +# --------------------------------------------------------------------------- +# --message-template tests +# --------------------------------------------------------------------------- + + +def test_message_template_basic(): + """--message-template emits sh:message on every property shape.""" + schema = _build_message_test_schema() + g = _parse_shacl(schema, message_template="Validation of {name} failed!") + + vehicle_shape = EX.Vehicle + + msgs = _get_prop_objects(g, vehicle_shape, EX.vehicle_name, SH.message) + assert Literal("Validation of vehicle_name failed!") in msgs + + msgs = _get_prop_objects(g, vehicle_shape, EX.speed, SH.message) + assert Literal("Validation of speed failed!") in msgs + + +def test_message_template_title_placeholder(): + """{title} expands to slot title, falling back to slot name.""" + schema = _build_message_test_schema() + g = _parse_shacl(schema, message_template="{title} is invalid") + + vehicle_shape = EX.Vehicle + + # vehicle_name has title="Name" + msgs = _get_prop_objects(g, vehicle_shape, EX.vehicle_name, SH.message) + assert Literal("Name is invalid") in msgs + + # speed has no title → falls back to slot name + msgs = _get_prop_objects(g, vehicle_shape, EX.speed, SH.message) + assert Literal("speed is invalid") in msgs + + +def test_message_template_class_placeholder(): + """{class} expands to the enclosing class name.""" + schema = _build_message_test_schema() + g = _parse_shacl(schema, message_template="{class}.{name} constraint violated") + + vehicle_shape = EX.Vehicle + + msgs = _get_prop_objects(g, vehicle_shape, EX.vehicle_name, SH.message) + assert Literal("Vehicle.vehicle_name constraint violated") in msgs + + +def test_message_template_description_placeholder(): + """{description} expands to the slot description, empty string when absent.""" + schema = _build_message_test_schema() + g = _parse_shacl(schema, message_template="{name} ({class}): {description}") + + vehicle_shape = EX.Vehicle + + # vehicle_name has description="The vehicle name." + msgs = _get_prop_objects(g, vehicle_shape, EX.vehicle_name, SH.message) + assert Literal("vehicle_name (Vehicle): The vehicle name.") in msgs + + # speed has description="Speed in km/h." + msgs = _get_prop_objects(g, vehicle_shape, EX.speed, SH.message) + assert Literal("speed (Vehicle): Speed in km/h.") in msgs + + +def test_message_template_description_fallback_empty(): + """{description} falls back to empty string when slot has no description.""" + sb = SchemaBuilder() + sb.add_slot(SlotDefinition("bare_slot", range="string")) + sb.add_class("Thing", slots=["bare_slot"]) + sb.add_defaults() + g = _parse_shacl(sb.schema, message_template="{name}: {description}") + + msgs = _get_prop_objects(g, EX.Thing, EX.bare_slot, SH.message) + assert Literal("bare_slot: ") in msgs + + +def test_message_template_comments_placeholder(): + """{comments} expands to slot comments joined with '; '.""" + sb = SchemaBuilder() + sb.add_slot( + SlotDefinition( + "wind_speed", + range="float", + description="Wind speed in metres per second.", + comments=["ISO 34503:2023, Section 10.2.3"], + ) + ) + sb.add_class("Weather", slots=["wind_speed"]) + sb.add_defaults() + g = _parse_shacl(sb.schema, message_template="{name} ({class}): {description} [{comments}]") + + msgs = _get_prop_objects(g, EX.Weather, EX.wind_speed, SH.message) + assert Literal("wind_speed (Weather): Wind speed in metres per second. [ISO 34503:2023, Section 10.2.3]") in msgs + + +def test_message_template_comments_multiple(): + """{comments} joins multiple comments with '; '.""" + sb = SchemaBuilder() + sb.add_slot( + SlotDefinition( + "temperature", + range="float", + comments=["ISO 34503:2023, Section 10.2", "Unit: Celsius"], + ) + ) + sb.add_class("Weather", slots=["temperature"]) + sb.add_defaults() + g = _parse_shacl(sb.schema, message_template="{comments}") + + msgs = _get_prop_objects(g, EX.Weather, EX.temperature, SH.message) + assert Literal("ISO 34503:2023, Section 10.2; Unit: Celsius") in msgs + + +def test_message_template_comments_fallback_empty(): + """{comments} falls back to empty string when slot has no comments.""" + sb = SchemaBuilder() + sb.add_slot(SlotDefinition("bare_slot", range="string")) + sb.add_class("Thing", slots=["bare_slot"]) + sb.add_defaults() + g = _parse_shacl(sb.schema, message_template="{name}: {comments}") + + msgs = _get_prop_objects(g, EX.Thing, EX.bare_slot, SH.message) + assert Literal("bare_slot: ") in msgs + + +def test_no_message_template_no_sh_message(): + """Without --message-template, no sh:message is emitted (backward-compat).""" + schema = _build_message_test_schema() + g = _parse_shacl(schema) + + vehicle_shape = EX.Vehicle + + msgs = _get_prop_objects(g, vehicle_shape, EX.vehicle_name, SH.message) + assert msgs == [] + + msgs = _get_prop_objects(g, vehicle_shape, EX.speed, SH.message) + assert msgs == [] + + +def test_message_template_invalid_placeholder_raises(): + """An invalid placeholder in --message-template raises ValueError.""" + import pytest + + schema = _build_message_test_schema() + with pytest.raises(ValueError, match="Invalid placeholder"): + _parse_shacl(schema, message_template="Error: {invalid}") + + +def test_message_template_positional_placeholder_raises(): + """Positional placeholders like {0} raise ValueError.""" + import pytest + + schema = _build_message_test_schema() + with pytest.raises(ValueError, match="Invalid placeholder"): + _parse_shacl(schema, message_template="Error: {0}") + + +def test_message_template_format_spec_raises(): + """Format specs like {name:d} raise ValueError.""" + import pytest + + schema = _build_message_test_schema() + with pytest.raises(ValueError, match="Invalid placeholder"): + _parse_shacl(schema, message_template="Error: {name:d}") + + +def test_message_template_empty_string_treated_as_none(): + """An empty message_template is normalised to None (no sh:message).""" + schema = _build_message_test_schema() + g = _parse_shacl(schema, message_template="") + + vehicle_shape = EX.Vehicle + msgs = _get_prop_objects(g, vehicle_shape, EX.vehicle_name, SH.message) + assert msgs == [] + + +def test_message_template_whitespace_only_treated_as_none(): + """A whitespace-only message_template is normalised to None (no sh:message).""" + schema = _build_message_test_schema() + g = _parse_shacl(schema, message_template=" ") + + vehicle_shape = EX.Vehicle + msgs = _get_prop_objects(g, vehicle_shape, EX.vehicle_name, SH.message) + assert msgs == [] + + +def test_message_template_with_default_language(): + """sh:message is language-tagged when both --message-template and --default-language are set.""" + schema = _build_message_test_schema() + g = _parse_shacl( + schema, + message_template="Validation of {name} failed!", + default_language="en", + ) + + vehicle_shape = EX.Vehicle + msgs = _get_prop_objects(g, vehicle_shape, EX.vehicle_name, SH.message) + assert Literal("Validation of vehicle_name failed!", lang="en") in msgs + + # Verify the message is NOT a plain literal + assert Literal("Validation of vehicle_name failed!") not in msgs