Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages/linkml/src/linkml/generators/shaclgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"""
Expand Down
229 changes: 229 additions & 0 deletions tests/linkml/test_generators/test_shaclgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Loading