diff --git a/CHANGELOG.md b/CHANGELOG.md index 143624c..bfa7a46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 8.4.5 - 2026-09-09 + +### Fixed + +- Breadcrumb SHACL validation no longer requires `item` on every `ListItem`, + matching Google's documented rule that the final entry in a + `BreadcrumbList` may omit it. The shape now permits at most one entry + without `item` and still reports a violation when two or more are missing. + ## 8.4.4 - 2026-09-08 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index b96de4d..54903fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wordlift-sdk" -version = "8.4.4" +version = "8.4.5" description = "Python toolkit for orchestrating WordLift imports and structured data workflows." authors = ["David Riccitelli "] readme = "README.md" diff --git a/tests/fixtures/search_gallery/baseline_conformance.json b/tests/fixtures/search_gallery/baseline_conformance.json index 8534288..b3d1e91 100644 --- a/tests/fixtures/search_gallery/baseline_conformance.json +++ b/tests/fixtures/search_gallery/baseline_conformance.json @@ -8,7 +8,7 @@ "total_with_context": 6 }, "breadcrumb": { - "conforming": 2, + "conforming": 4, "total_with_context": 4 }, "carousel": { diff --git a/tests/fixtures/search_gallery/expectations.json b/tests/fixtures/search_gallery/expectations.json index e838c9f..0016cec 100644 --- a/tests/fixtures/search_gallery/expectations.json +++ b/tests/fixtures/search_gallery/expectations.json @@ -8,12 +8,6 @@ "017-library-system-feed-01" ] }, - "breadcrumb": { - "known_nonconforming_sample_ids": [ - "001-json-ld-bredacrumb-01", - "005-json-ld-multiple-breadcrumbs-01" - ] - }, "carousel": { "known_nonconforming_sample_ids": [ "001-summary-page-01", diff --git a/tests/test_breadcrumb_listitem_item_validation.py b/tests/test_breadcrumb_listitem_item_validation.py new file mode 100644 index 0000000..13fcf2e --- /dev/null +++ b/tests/test_breadcrumb_listitem_item_validation.py @@ -0,0 +1,96 @@ +from wordlift_sdk.validation.shacl import ( + PreparedShaclValidator, + _load_graph_from_jsonld, + prepare_shapes, +) + +import pytest + + +@pytest.fixture(scope="module") +def validator() -> PreparedShaclValidator: + return PreparedShaclValidator(prepare_shapes(["google-breadcrumb"])) + + +def _list_item(position: object, item: str | None = None) -> dict: + node: dict = {"@type": "ListItem", "position": position, "name": "X"} + if item is not None: + node["item"] = item + return node + + +def _breadcrumb(elements: list[dict]) -> dict: + return { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + "itemListElement": elements, + } + + +def _conforms(validator: PreparedShaclValidator, elements: list[dict]) -> bool: + graph = _load_graph_from_jsonld(_breadcrumb(elements)) + return validator.validate_graph(graph).conforms + + +@pytest.mark.parametrize( + "elements", + [ + pytest.param( + [ + _list_item(1, "https://example.com/a"), + _list_item(2, "https://example.com/b"), + ], + id="every-entry-has-item", + ), + pytest.param( + [_list_item(1, "https://example.com/a"), _list_item(2)], + id="final-entry-omits-item", + ), + pytest.param( + [_list_item(1), _list_item(2, "https://example.com/b")], + id="first-entry-omits-item", + ), + pytest.param([_list_item(1)], id="single-entry-omits-item"), + ], +) +def test_one_entry_may_omit_item( + validator: PreparedShaclValidator, elements: list[dict] +) -> None: + assert _conforms(validator, elements) + + +@pytest.mark.parametrize( + "elements", + [ + pytest.param( + [_list_item(1, "https://example.com/a"), _list_item(2), _list_item(3)], + id="two-entries-omit-item", + ), + pytest.param( + [_list_item(1), _list_item(2), _list_item(3)], + id="no-entry-has-item", + ), + pytest.param( + [_list_item(1.0), _list_item(2.0), _list_item(3.0)], + id="float-positions", + ), + pytest.param( + [_list_item("1"), _list_item("2"), _list_item("3")], + id="string-positions", + ), + pytest.param([_list_item(1), _list_item(1)], id="tied-positions"), + ], +) +def test_two_or_more_entries_omitting_item_fail( + validator: PreparedShaclValidator, elements: list[dict] +) -> None: + assert not _conforms(validator, elements) + + +def test_missing_position_is_still_reported( + validator: PreparedShaclValidator, +) -> None: + graph = _load_graph_from_jsonld(_breadcrumb([{"@type": "ListItem", "name": "X"}])) + result = validator.validate_graph(graph) + assert not result.conforms + assert "position" in result.report_text diff --git a/tests/test_shacl_generator.py b/tests/test_shacl_generator.py index b3c8405..97212c2 100644 --- a/tests/test_shacl_generator.py +++ b/tests/test_shacl_generator.py @@ -354,3 +354,38 @@ def test_keeps_listitem_shape_without_itemlist(tmp_path: Path) -> None: content = _read_output(tmp_path, feature) assert "sh:targetClass schema:ListItem" in content + + +def test_breadcrumb_listitem_item_is_exempted_for_one_entry(tmp_path: Path) -> None: + feature = FeatureData( + url="https://example.com", + types={ + "BreadcrumbList": {"required": {"itemListElement"}, "recommended": set()}, + "ListItem": { + "required": {"position", "name", "item"}, + "recommended": set(), + }, + }, + ) + + content = _read_output(tmp_path, feature) + + assert "sh:qualifiedMaxCount 1 ;" in content + assert "sh:not [" in content + node_shape = content.split("sh:qualifiedValueShape")[0] + assert "sh:path schema:item ;\n sh:minCount 1 ;" not in node_shape + + +def test_itemlist_listitem_item_stays_unconditional(tmp_path: Path) -> None: + feature = FeatureData( + url="https://example.com", + types={ + "ItemList": {"required": {"itemListElement"}, "recommended": set()}, + "ListItem": {"required": {"position", "item"}, "recommended": set()}, + }, + ) + + content = _read_output(tmp_path, feature) + + assert "sh:qualifiedMaxCount" not in content + assert "sh:path schema:item ;\n sh:minCount 1 ;" in content diff --git a/wordlift_sdk/validation/generator.py b/wordlift_sdk/validation/generator.py index b71034f..ed4aa5a 100644 --- a/wordlift_sdk/validation/generator.py +++ b/wordlift_sdk/validation/generator.py @@ -533,6 +533,7 @@ def _emit_property( visited: set[str], one_of_map: dict[str, list[set[str]]], one_of_option_map: dict[str, list[list[set[str]]]], + parent_type: str | None = None, ) -> None: sp = " " * indent path = _prop_path(prop) @@ -565,6 +566,7 @@ def _emit_property( one_of_map, one_of_option_map.get(child_type, []), one_of_option_map, + parent_type, ) lines.append(f"{node_sp}] ;") elif len(valid_children) > 1: @@ -585,6 +587,7 @@ def _emit_property( one_of_map, one_of_option_map.get(child_type, []), one_of_option_map, + parent_type, ) lines.append(f"{or_sp} ]") lines.append(f"{or_sp}) ;") @@ -637,6 +640,7 @@ def _emit_one_of_groups( one_of_map, one_of_option_map.get(child_type, []), one_of_option_map, + parent_type, ) lines.append(f"{node_sp}] ;") elif len(valid_children) > 1: @@ -657,6 +661,7 @@ def _emit_one_of_groups( one_of_map, one_of_option_map.get(child_type, []), one_of_option_map, + parent_type, ) lines.append(f"{or_sp} ]") lines.append(f"{or_sp}) ;") @@ -699,6 +704,7 @@ def _emit_one_of_option_groups( visited=visited, one_of_map=one_of_map, one_of_option_map=one_of_option_map, + parent_type=parent_type, ) lines.append(f"{sp} ]") lines.append(f"{sp}) ;") @@ -727,6 +733,26 @@ def _emit_listitem_name_or_item_name(lines: list[str], indent: int) -> None: lines.append(f"{sp}) ;") +def _emit_breadcrumb_item_exemption(lines: list[str], indent: int) -> None: + sp = " " * indent + lines.append(f"{sp}sh:property [") + lines.append(f"{sp} sh:path schema:itemListElement ;") + lines.append(f"{sp} sh:qualifiedValueShape [") + lines.append(f"{sp} sh:not [") + lines.append(f"{sp} sh:property [") + lines.append(f"{sp} sh:path schema:item ;") + lines.append(f"{sp} sh:minCount 1 ;") + lines.append(f"{sp} ] ;") + lines.append(f"{sp} ] ;") + lines.append(f"{sp} ] ;") + lines.append(f"{sp} sh:qualifiedMaxCount 1 ;") + lines.append( + f'{sp} sh:message "Required by Google: item on every ListItem ' + 'except the final one." ;' + ) + lines.append(f"{sp}] ;") + + def _emit_node( lines: list[str], type_name: str, @@ -738,6 +764,7 @@ def _emit_node( one_of_map: dict[str, list[set[str]]], one_of_option_groups: list[list[set[str]]] | None, one_of_option_map: dict[str, list[list[set[str]]]], + parent_type: str | None = None, ) -> None: sp = " " * indent lines.append(f"{sp}a sh:NodeShape ;") @@ -745,6 +772,13 @@ def _emit_node( child_rules = _SCOPED_CHILD_RULES.get(type_name, {}) for prop in sorted(bucket["required"]): + if ( + parent_type == "BreadcrumbList" + and type_name == "ListItem" + and prop == "item" + ): + # Google: the final breadcrumb entry may omit `item`. + continue if type_name == "ListItem" and prop == "name": # Google: ListItem.name is only required when `item` is a bare URL; # if `item` is a Thing that itself carries a name, name may be omitted. @@ -761,9 +795,14 @@ def _emit_node( visited=visited, one_of_map=one_of_map, one_of_option_map=one_of_option_map, + parent_type=type_name, ) if type_name == "ListItem" and "name" in bucket["required"]: _emit_listitem_name_or_item_name(lines, indent) + if type_name == "BreadcrumbList" and "item" in buckets.get("ListItem", {}).get( + "required", set() + ): + _emit_breadcrumb_item_exemption(lines, indent) for prop in sorted(bucket["recommended"]): child_types = child_rules.get(prop) @@ -777,6 +816,7 @@ def _emit_node( visited=visited, one_of_map=one_of_map, one_of_option_map=one_of_option_map, + parent_type=type_name, ) _emit_one_of_groups( @@ -858,9 +898,14 @@ def _write_feature(feature: FeatureData, output_path: Path, overwrite: bool) -> visited={type_name}, one_of_map=feature.one_of, one_of_option_map=feature.one_of_option_groups, + parent_type=type_name, ) if type_name == "ListItem" and "name" in bucket["required"]: _emit_listitem_name_or_item_name(lines, 2) + if type_name == "BreadcrumbList" and "item" in feature.types.get( + "ListItem", {} + ).get("required", set()): + _emit_breadcrumb_item_exemption(lines, 2) for prop in sorted(bucket["recommended"]): child_types = _SCOPED_CHILD_RULES.get(type_name, {}).get(prop) @@ -874,6 +919,7 @@ def _write_feature(feature: FeatureData, output_path: Path, overwrite: bool) -> visited={type_name}, one_of_map=feature.one_of, one_of_option_map=feature.one_of_option_groups, + parent_type=type_name, ) _emit_one_of_groups( diff --git a/wordlift_sdk/validation/shacls/google-breadcrumb.ttl b/wordlift_sdk/validation/shacls/google-breadcrumb.ttl index a6ef397..b195d76 100644 --- a/wordlift_sdk/validation/shacls/google-breadcrumb.ttl +++ b/wordlift_sdk/validation/shacls/google-breadcrumb.ttl @@ -3,7 +3,7 @@ @prefix schema: . # Source: https://developers.google.com/search/docs/appearance/structured-data/breadcrumb -# Generated: 2026-08-19T07:09:28Z +# Generated: 2026-09-09T08:57:24Z # Notes: required properties => errors; recommended properties => warnings. :google_BreadcrumbListShape @@ -15,10 +15,6 @@ sh:node [ a sh:NodeShape ; sh:class schema:ListItem ; - sh:property [ - sh:path schema:item ; - sh:minCount 1 ; - ] ; sh:property [ sh:path schema:position ; sh:minCount 1 ; @@ -44,4 +40,17 @@ ) ; ] ; ] ; + sh:property [ + sh:path schema:itemListElement ; + sh:qualifiedValueShape [ + sh:not [ + sh:property [ + sh:path schema:item ; + sh:minCount 1 ; + ] ; + ] ; + ] ; + sh:qualifiedMaxCount 1 ; + sh:message "Required by Google: item on every ListItem except the final one." ; + ] ; .