From 8143ba8a4c0995ff0d6363a32ace64e95585bbde Mon Sep 17 00:00:00 2001 From: Eduard Fugarolas Date: Tue, 14 Jul 2026 09:56:59 +0200 Subject: [PATCH 01/10] Make related-plugin references clickable links in the generated reference docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related plugin IDs showed up as bold, unlinked text — a page had no way to know where any other plugin's page would land. Now a plugin-id-to-path index gets built once up front, and each reference resolves to a relative link before rendering; an unresolvable one fails the build instead of quietly shipping a dead link. Also picked up real test coverage for the new resolution logic and split the suite into unit and integration runs, since one existing test needs a live CO instance — Taskfile and dependencies updated to match. --- Taskfile.yml | 21 ++++- poetry.lock | 49 +++++++++++- pyproject.toml | 6 ++ tests/test_update_di_reference.py | 122 +++++++++++++++++++++++++++++- tools/templates/plugin.md | 6 +- tools/update_di_reference.py | 70 ++++++++++++++--- 6 files changed, 258 insertions(+), 16 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 0c34d034f..a9007f198 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -48,7 +48,7 @@ tasks: - poetry install check: - desc: Run complete test suite + desc: Check documentation links and Markdown style deps: - check:links - check:rumdl @@ -60,6 +60,25 @@ tasks: cmds: - poetry run linkcheckMarkdown -r docs 2>&1 | grep -v "ResourceWarning" + test: + desc: Run the Python unit test suite + deps: + - test:unit + + test:unit: + desc: Run tests that need no external service + deps: + - install + cmds: + - poetry run pytest -m "not integration" + + test:integration: + desc: Run tests that need a live CO instance + deps: + - install + cmds: + - poetry run pytest -m integration + build: desc: Build the page deps: diff --git a/poetry.lock b/poetry.lock index 4f4bfaf3c..01f088d26 100644 --- a/poetry.lock +++ b/poetry.lock @@ -762,6 +762,17 @@ enabler = ["pytest-enabler (>=2.2)"] test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"] type = ["pytest-mypy"] +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1394,6 +1405,21 @@ docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-a test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] type = ["mypy (>=1.14.1)"] +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + [[package]] name = "propcache" version = "0.3.2" @@ -1690,6 +1716,27 @@ files = [ [package.extras] diagrams = ["jinja2", "railroad-diagrams"] +[[package]] +name = "pytest" +version = "9.1.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"}, + {file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -2209,4 +2256,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "8dca9021c750c8ab63ab240ae05ccde92fb1998bcd1659ce19074d31610aefc9" +content-hash = "b29564f7e517705f8e3acab382a355450ede6affa5d79e085c0200fe8284f134" diff --git a/pyproject.toml b/pyproject.toml index ff86d0d68..654e2fc25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,12 @@ jinja2 = "^3.1.6" [tool.poetry.group.dev.dependencies] linkcheckmd = "^1.4.0" rumdl = "^0.0.194" +pytest = "^9.1.1" + +[tool.pytest.ini_options] +markers = [ + "integration: needs a live DI/CMEM instance to run", +] [build-system] requires = ["poetry-core"] diff --git a/tests/test_update_di_reference.py b/tests/test_update_di_reference.py index 330e4421a..f1e731ac0 100644 --- a/tests/test_update_di_reference.py +++ b/tests/test_update_di_reference.py @@ -1,8 +1,126 @@ """Test update DI references""" -from tools.update_di_reference import get_plugin_descriptions +import pytest +from tools.update_di_reference import ( + PluginDescription, + PluginReference, + get_plugin_descriptions, + resolve_related_plugin_links, + _relative_link, + _shared_prefix_length, + create_plugin_markdown, +) + +@pytest.mark.integration def test_get_plugin_descriptions(): """Test get DI plugin descriptions""" descriptions = get_plugin_descriptions() - pass + assert set(descriptions.keys()) == {"customtask", "dataset", "distancemeasure", "transformer", "aggregator"} + assert len(descriptions["transformer"]) > 0 + assert len(descriptions["dataset"]) > 0 + for plugin_type, plugins in descriptions.items(): + for plugin in plugins: + assert isinstance(plugin, PluginDescription) + assert plugin.pluginType == plugin_type + titles = [p.title.lower() for p in plugins] + assert titles == sorted(titles) + + +def _make_plugin(plugin_id, plugin_type="transformer", main_category="Extract", related=None): + """Build a minimal valid PluginDescription for a test, filling in only what varies.""" + return PluginDescription( + pluginId=plugin_id, + title=plugin_id, + categories=[main_category], + description="test plugin", + properties={}, + actions={}, + required=[], + backendType="scala", + pluginType=plugin_type, + relatedPlugins=related or [], + ) + + +@pytest.mark.parametrize( + "a, b, expected", + [ + (("customtask",), ("dataset",), 0), + (("transformer", "Extract"), ("transformer", "Extract"), 2), + (("transformer", "Extract"), ("transformer", "Replace"), 1), + ((), ("dataset",), 0), + (("transformer", "Extract"), ("transformer",), 1), + ], +) +def test_shared_prefix_length(a, b, expected): + assert _shared_prefix_length(a, b) == expected + + +@pytest.mark.parametrize( + "current_dir_parts, target_path, expected", + [ + # same type, different category + (("transformer", "Extract"), "transformer/Replace/regexReplace.md", "../Replace/regexReplace.md"), + # different type entirely + (("transformer", "Extract"), "aggregator/average.md", "../../aggregator/average.md"), + # reverse: shallower page linking to a deeper one + (("aggregator",), "transformer/Extract/regexExtract.md", "../transformer/Extract/regexExtract.md"), + # same directory + (("customtask",), "customtask/sparqlUpdateOperator.md", "sparqlUpdateOperator.md"), + ], +) +def test_relative_link(current_dir_parts, target_path, expected): + assert _relative_link(current_dir_parts, target_path) == expected + + +def test_resolve_related_plugin_links_empty(): + plugin = _make_plugin("regexExtract", related=[]) + assert resolve_related_plugin_links(plugin, "transformer/Extract/regexExtract.md", {}) == [] + + +def test_resolve_related_plugin_links_single(): + ref = PluginReference(id="regexReplace", description="replaces text") + plugin = _make_plugin("regexExtract", related=[ref]) + plugin_paths = {"regexExtract": "transformer/Extract/regexExtract.md", "regexReplace": "transformer/Replace/regexReplace.md"} + resolved = resolve_related_plugin_links(plugin, "transformer/Extract/regexExtract.md", plugin_paths) + assert resolved == [(ref, "../Replace/regexReplace.md")] + + +def test_resolve_related_plugin_links_multiple_preserves_order(): + ref_a = PluginReference(id="regexReplace") + ref_b = PluginReference(id="regexSelect") + plugin = _make_plugin("regexExtract", related=[ref_a, ref_b]) + plugin_paths = { + "regexExtract": "transformer/Extract/regexExtract.md", + "regexReplace": "transformer/Replace/regexReplace.md", + "regexSelect": "transformer/Selection/regexSelect.md", + } + resolved = resolve_related_plugin_links(plugin, "transformer/Extract/regexExtract.md", plugin_paths) + assert [ref for ref, _ in resolved] == [ref_a, ref_b] + + +def test_resolve_related_plugin_links_unresolvable_raises(): + ref = PluginReference(id="deprecatedPlugin") + plugin = _make_plugin("regexExtract", related=[ref]) + with pytest.raises(Exception, match="deprecatedPlugin"): + resolve_related_plugin_links(plugin, "transformer/Extract/regexExtract.md", {"regexExtract": "transformer/Extract/regexExtract.md"}) + + +def test_create_plugin_markdown_writes_resolved_links(tmp_path): + target = _make_plugin("sparqlEndpoint", plugin_type="dataset") + plugin = _make_plugin( + "sparqlSelectOperator", + plugin_type="customtask", + related=[PluginReference(id="sparqlEndpoint", description="a SPARQL endpoint dataset")], + ) + plugin_paths = { + "sparqlSelectOperator": "customtask/sparqlSelectOperator.md", + "sparqlEndpoint": "dataset/sparqlEndpoint.md", + } + + create_plugin_markdown(plugin, "customtask", tmp_path, plugin_paths) + + written = (tmp_path / "customtask" / "sparqlSelectOperator.md").read_text() + assert "[sparqlEndpoint](../dataset/sparqlEndpoint.md)" in written + assert "**sparqlEndpoint**" not in written diff --git a/tools/templates/plugin.md b/tools/templates/plugin.md index 3f9bb736f..e22400748 100644 --- a/tools/templates/plugin.md +++ b/tools/templates/plugin.md @@ -26,11 +26,11 @@ tags: {% for tag in plugin.tags %} {{parameters_advanced if plugin.properties_advanced else "`None`"}} -{%- if plugin.relatedPlugins %} +{%- if related_plugins_resolved %} ## Related Plugins -{% for ref in plugin.relatedPlugins -%} -- **{{ ref.id }}**{% if ref.description %} — {{ ref.description }}{% endif %} +{% for ref, link in related_plugins_resolved -%} +- [{{ ref.id }}]({{ link }}){% if ref.description %} — {{ ref.description }}{% endif %} {% endfor %} {%- endif %} diff --git a/tools/update_di_reference.py b/tools/update_di_reference.py index 1f0d2c07c..82a6555fb 100644 --- a/tools/update_di_reference.py +++ b/tools/update_di_reference.py @@ -161,13 +161,60 @@ def get_plugin_descriptions() -> dict[str, list[PluginDescription]]: plugins[type_id] = plugins_of_type return plugins -def create_plugin_markdown(plugin: PluginDescription, plugin_type: str, base_dir: Path) -> None: +def resolve_related_plugin_links(plugin: PluginDescription, plugin_path: str, plugin_paths: dict[str, str]) -> list[tuple[PluginReference, str]]: + """Resolve each related plugin reference to a path relative to the current plugin's own page. + + Raises if a reference points at a plugin with no resolvable page — a + stale cross-reference (deprecated target, or a target outside the five + walked plugin types), not a typo (§4.1). + """ + current_dir_parts = Path(plugin_path).parts[:-1] + resolved = [] + for ref in plugin.relatedPlugins: + if ref.id not in plugin_paths: + raise Exception(f"Related plugin '{ref.id}' referenced by '{plugin.pluginId}' has no resolvable page") + resolved.append((ref, _relative_link(current_dir_parts, plugin_paths[ref.id]))) + return resolved + +def _relative_link(current_dir_parts: tuple[str, ...], target_path: str) -> str: + """Build a relative link from a page's directory to a target path. + + Climbs '../' out of the current directory to the point where the two paths + diverge, then descends into whatever remains of the target's path. + """ + target_parts = Path(target_path).parts + shared = _shared_prefix_length(current_dir_parts, target_parts[:-1]) + steps_up = ("..",) * (len(current_dir_parts) - shared) + return "/".join(steps_up + target_parts[shared:]) + +def _shared_prefix_length(a: tuple[str, ...], b: tuple[str, ...]) -> int: + """Count the leading path segments two directories have in common.""" + shared = 0 + for a_part, b_part in zip(a, b): + if a_part != b_part: + break + shared += 1 + return shared + +def create_plugin_markdown(plugin: PluginDescription, plugin_type: str, base_dir: Path, plugin_paths: dict[str, str]) -> None: """Create markdown document from plugin description.""" if plugin.is_deprecated: click.echo(f"Ignore deprecated plugin {plugin.pluginId}") return click.echo(f"Create reference documentation for {plugin.pluginId}") + # create the file (incl. directory) + if plugin.pluginType == "transformer": + directory = base_dir / plugin_type / plugin.main_category + else: + directory = base_dir / plugin_type + directory.mkdir(parents=True, exist_ok=True) + file = directory / f"{plugin.pluginId}.md" + + # resolve related-plugin links relative to this plugin's own page + plugin_path = str(directory.relative_to(base_dir) / f"{plugin.pluginId}.md") + related_plugins_resolved = resolve_related_plugin_links(plugin, plugin_path, plugin_paths) + # create content plugin_template = jinja_environment.get_template(f"plugin.md") parameter_template = jinja_environment.get_template(f"parameter.md") @@ -183,15 +230,9 @@ def create_plugin_markdown(plugin: PluginDescription, plugin_type: str, base_dir plugin=plugin, parameters=parameter_content.rstrip("\n"), parameters_advanced=parameter_advanced_content.rstrip("\n"), + related_plugins_resolved=related_plugins_resolved, ) - # create the file (incl. directory) - if plugin.pluginType == "transformer": - directory = base_dir / plugin_type / plugin.main_category - else: - directory = base_dir / plugin_type - directory.mkdir(parents=True, exist_ok=True) - file = directory / f"{plugin.pluginId}.md" with file.open("w", encoding="utf-8") as f: f.write(content) @@ -279,6 +320,17 @@ def update_di_reference(output_dir): if plugin.pluginId in plugins_dump: raise Exception(f"Duplicate plugin ID: {plugin.pluginId}") plugins_dump[plugin.pluginId] = plugin.model_dump() + + plugin_paths: dict[str, str] = {} + for type_id, plugins_list in plugins.items(): + for plugin in plugins_list: + if plugin.is_deprecated: + continue + if plugin.pluginType == "transformer": + plugin_paths[plugin.pluginId] = f"{type_id}/{plugin.main_category}/{plugin.pluginId}.md" + else: + plugin_paths[plugin.pluginId] = f"{type_id}/{plugin.pluginId}.md" + plugins_json.write_text(json.dumps(plugins_dump, indent=2)) click.echo(f"Creating DI reference documentation in {basedir}") @@ -288,5 +340,5 @@ def update_di_reference(output_dir): for type_id in plugins: Path(basedir / type_id).mkdir(parents=True, exist_ok=True) for plugin in plugins[type_id]: - create_plugin_markdown(plugin, type_id, basedir) + create_plugin_markdown(plugin, type_id, basedir, plugin_paths) create_umbrella_pages(plugins=plugins, base_dir=basedir) From ae79c18460e1747f67c5bd6896ce51515ee475c6 Mon Sep 17 00:00:00 2001 From: Eduard Fugarolas Date: Wed, 15 Jul 2026 16:00:22 +0200 Subject: [PATCH 02/10] Run the unit test suite in CI --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 60ef72ae6..80e30550e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,6 +61,9 @@ jobs: - name: reconfigure private repository access run: ACCESS_TOKEN=${{ secrets.ACCESS_TOKEN }} task use:insider-https-token + - name: test + run: task test:unit + - name: check run: task check From 435161cace7790d16894bec4146f963e4fa7ff61 Mon Sep 17 00:00:00 2001 From: Eduard Fugarolas Date: Wed, 15 Jul 2026 16:29:55 +0200 Subject: [PATCH 03/10] Extract plugin_paths construction into its own function, unit-tested. --- tests/test_update_di_reference.py | 15 +++++++++++++++ tools/update_di_reference.py | 25 ++++++++++++++++--------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/tests/test_update_di_reference.py b/tests/test_update_di_reference.py index f1e731ac0..d6cfce60c 100644 --- a/tests/test_update_di_reference.py +++ b/tests/test_update_di_reference.py @@ -9,6 +9,7 @@ _relative_link, _shared_prefix_length, create_plugin_markdown, + build_plugin_paths, ) @@ -107,6 +108,20 @@ def test_resolve_related_plugin_links_unresolvable_raises(): resolve_related_plugin_links(plugin, "transformer/Extract/regexExtract.md", {"regexExtract": "transformer/Extract/regexExtract.md"}) +def test_build_plugin_paths(): + plugins = { + "transformer": [_make_plugin("regexExtract", plugin_type="transformer", main_category="Extract")], + "dataset": [_make_plugin("sparqlEndpoint", plugin_type="dataset")], + "customtask": [_make_plugin("deprecatedPlugin", plugin_type="customtask")], + } + paths = build_plugin_paths(plugins) + assert paths == { + "regexExtract": "transformer/Extract/regexExtract.md", + "sparqlEndpoint": "dataset/sparqlEndpoint.md", + } + assert "deprecatedPlugin" not in paths + + def test_create_plugin_markdown_writes_resolved_links(tmp_path): target = _make_plugin("sparqlEndpoint", plugin_type="dataset") plugin = _make_plugin( diff --git a/tools/update_di_reference.py b/tools/update_di_reference.py index 82a6555fb..2df946acd 100644 --- a/tools/update_di_reference.py +++ b/tools/update_di_reference.py @@ -299,6 +299,21 @@ def create_umbrella_pages(plugins: dict[str, list[PluginDescription]], base_dir: click.echo(f"Create .pages file {pages_file}") f.write(pages_content) +def build_plugin_paths(plugins: dict[str, list[PluginDescription]]) -> dict[str, str]: + """Map every non-deprecated plugin's ID to the path its own page will have. + + Deprecated plugins are excluded, since no page is ever generated for them. + """ + plugin_paths: dict[str, str] = {} + for type_id, plugins_list in plugins.items(): + for plugin in plugins_list: + if plugin.is_deprecated: + continue + if plugin.pluginType == "transformer": + plugin_paths[plugin.pluginId] = f"{type_id}/{plugin.main_category}/{plugin.pluginId}.md" + else: + plugin_paths[plugin.pluginId] = f"{type_id}/{plugin.pluginId}.md" + return plugin_paths @click.command() @click.option( @@ -321,15 +336,7 @@ def update_di_reference(output_dir): raise Exception(f"Duplicate plugin ID: {plugin.pluginId}") plugins_dump[plugin.pluginId] = plugin.model_dump() - plugin_paths: dict[str, str] = {} - for type_id, plugins_list in plugins.items(): - for plugin in plugins_list: - if plugin.is_deprecated: - continue - if plugin.pluginType == "transformer": - plugin_paths[plugin.pluginId] = f"{type_id}/{plugin.main_category}/{plugin.pluginId}.md" - else: - plugin_paths[plugin.pluginId] = f"{type_id}/{plugin.pluginId}.md" + plugin_paths = build_plugin_paths(plugins) plugins_json.write_text(json.dumps(plugins_dump, indent=2)) From 76debfd34327c72bcffe239dbbd86f721ae7c11b Mon Sep 17 00:00:00 2001 From: Eduard Fugarolas Date: Wed, 15 Jul 2026 16:44:42 +0200 Subject: [PATCH 04/10] Small cleanup --- tools/update_di_reference.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/update_di_reference.py b/tools/update_di_reference.py index 2df946acd..82e27db16 100644 --- a/tools/update_di_reference.py +++ b/tools/update_di_reference.py @@ -166,7 +166,7 @@ def resolve_related_plugin_links(plugin: PluginDescription, plugin_path: str, pl Raises if a reference points at a plugin with no resolvable page — a stale cross-reference (deprecated target, or a target outside the five - walked plugin types), not a typo (§4.1). + walked plugin types), not a typo. """ current_dir_parts = Path(plugin_path).parts[:-1] resolved = [] From dc81d9520a245f34ea6456506756c7deb1ad74e2 Mon Sep 17 00:00:00 2001 From: Eduard Fugarolas Date: Thu, 16 Jul 2026 09:44:09 +0200 Subject: [PATCH 05/10] Validate related-plugin references before wiping the output directory The build used to wipe the previous docs before it had any idea whether a related-plugin reference would even resolve. A broken one only showed up mid-rebuild, once the old docs were already gone. Now it checks everything up front, reusing the existing resolution check instead of duplicating it, so the failure and its message stay in one place. Also dropped a stale docstring detail that named a count owned by a different function. --- tests/test_update_di_reference.py | 28 ++++++++++++++++++++++++++++ tools/update_di_reference.py | 16 ++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/tests/test_update_di_reference.py b/tests/test_update_di_reference.py index d6cfce60c..0280c3929 100644 --- a/tests/test_update_di_reference.py +++ b/tests/test_update_di_reference.py @@ -10,6 +10,7 @@ _shared_prefix_length, create_plugin_markdown, build_plugin_paths, + validate_related_plugin_references, ) @@ -122,6 +123,33 @@ def test_build_plugin_paths(): assert "deprecatedPlugin" not in paths +def test_validate_related_plugin_references_passes_when_all_resolve(): + plugins = { + "transformer": [_make_plugin("regexExtract", related=[PluginReference(id="regexReplace")])], + } + plugin_paths = { + "regexExtract": "transformer/Extract/regexExtract.md", + "regexReplace": "transformer/Replace/regexReplace.md", + } + validate_related_plugin_references(plugins, plugin_paths) + + +def test_validate_related_plugin_references_raises_on_unresolvable(): + plugins = { + "transformer": [_make_plugin("regexExtract", related=[PluginReference(id="removedPlugin")])], + } + plugin_paths = {"regexExtract": "transformer/Extract/regexExtract.md"} + with pytest.raises(Exception, match="removedPlugin"): + validate_related_plugin_references(plugins, plugin_paths) + + +def test_validate_related_plugin_references_skips_deprecated_plugins(): + plugins = { + "customtask": [_make_plugin("deprecatedPlugin", plugin_type="customtask", related=[PluginReference(id="removedPlugin")])], + } + validate_related_plugin_references(plugins, plugin_paths={}) + + def test_create_plugin_markdown_writes_resolved_links(tmp_path): target = _make_plugin("sparqlEndpoint", plugin_type="dataset") plugin = _make_plugin( diff --git a/tools/update_di_reference.py b/tools/update_di_reference.py index 82e27db16..14aa938a7 100644 --- a/tools/update_di_reference.py +++ b/tools/update_di_reference.py @@ -165,8 +165,7 @@ def resolve_related_plugin_links(plugin: PluginDescription, plugin_path: str, pl """Resolve each related plugin reference to a path relative to the current plugin's own page. Raises if a reference points at a plugin with no resolvable page — a - stale cross-reference (deprecated target, or a target outside the five - walked plugin types), not a typo. + stale cross-reference, not a typo. """ current_dir_parts = Path(plugin_path).parts[:-1] resolved = [] @@ -315,6 +314,18 @@ def build_plugin_paths(plugins: dict[str, list[PluginDescription]]) -> dict[str, plugin_paths[plugin.pluginId] = f"{type_id}/{plugin.pluginId}.md" return plugin_paths +def validate_related_plugin_references(plugins: dict[str, list[PluginDescription]], plugin_paths: dict[str, str]) -> None: + """Raise on the first relatedPlugins reference with no resolvable page. + + Deprecated plugins are skipped, since no page is ever generated for + them and their relatedPlugins is otherwise never inspected. + """ + for plugins_list in plugins.values(): + for plugin in plugins_list: + if plugin.is_deprecated: + continue + resolve_related_plugin_links(plugin, plugin_paths[plugin.pluginId], plugin_paths) + @click.command() @click.option( "--output-dir", "-o", @@ -337,6 +348,7 @@ def update_di_reference(output_dir): plugins_dump[plugin.pluginId] = plugin.model_dump() plugin_paths = build_plugin_paths(plugins) + validate_related_plugin_references(plugins, plugin_paths) plugins_json.write_text(json.dumps(plugins_dump, indent=2)) From c1e78b553625ba728a2ddb52d6ee373a657b6df4 Mon Sep 17 00:00:00 2001 From: Eduard Fugarolas Date: Fri, 17 Jul 2026 14:48:27 +0200 Subject: [PATCH 06/10] Read the related-plugin page path from the existing index instead of recomputing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_plugin_markdown recomputed its own plugin's page path instead of reading it from plugin_paths, which already holds it — that recomputation duplicated the transformer/non-transformer branching and forced a pointless Path-to-string-and-back trip. It now reads the path from the index, and mkdir runs right before the write instead of before path resolution. resolve_related_plugin_links raises a dedicated RelatedPluginReferenceError instead of a bare Exception, build_plugin_paths uses plugin.pluginType instead of a redundant type_id key, and validate_related_plugin_references collects every unresolvable reference before raising once instead of stopping at the first. Tests updated to match. --- tests/test_update_di_reference.py | 7 ++--- tools/update_di_reference.py | 45 ++++++++++++++++--------------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/tests/test_update_di_reference.py b/tests/test_update_di_reference.py index 0280c3929..b04eee506 100644 --- a/tests/test_update_di_reference.py +++ b/tests/test_update_di_reference.py @@ -4,6 +4,7 @@ from tools.update_di_reference import ( PluginDescription, PluginReference, + RelatedPluginReferenceError, get_plugin_descriptions, resolve_related_plugin_links, _relative_link, @@ -105,7 +106,7 @@ def test_resolve_related_plugin_links_multiple_preserves_order(): def test_resolve_related_plugin_links_unresolvable_raises(): ref = PluginReference(id="deprecatedPlugin") plugin = _make_plugin("regexExtract", related=[ref]) - with pytest.raises(Exception, match="deprecatedPlugin"): + with pytest.raises(RelatedPluginReferenceError, match="deprecatedPlugin"): resolve_related_plugin_links(plugin, "transformer/Extract/regexExtract.md", {"regexExtract": "transformer/Extract/regexExtract.md"}) @@ -139,7 +140,7 @@ def test_validate_related_plugin_references_raises_on_unresolvable(): "transformer": [_make_plugin("regexExtract", related=[PluginReference(id="removedPlugin")])], } plugin_paths = {"regexExtract": "transformer/Extract/regexExtract.md"} - with pytest.raises(Exception, match="removedPlugin"): + with pytest.raises(RelatedPluginReferenceError, match="removedPlugin"): validate_related_plugin_references(plugins, plugin_paths) @@ -162,7 +163,7 @@ def test_create_plugin_markdown_writes_resolved_links(tmp_path): "sparqlEndpoint": "dataset/sparqlEndpoint.md", } - create_plugin_markdown(plugin, "customtask", tmp_path, plugin_paths) + create_plugin_markdown(plugin, tmp_path, plugin_paths) written = (tmp_path / "customtask" / "sparqlSelectOperator.md").read_text() assert "[sparqlEndpoint](../dataset/sparqlEndpoint.md)" in written diff --git a/tools/update_di_reference.py b/tools/update_di_reference.py index 14aa938a7..9cfbc4a9b 100644 --- a/tools/update_di_reference.py +++ b/tools/update_di_reference.py @@ -161,17 +161,21 @@ def get_plugin_descriptions() -> dict[str, list[PluginDescription]]: plugins[type_id] = plugins_of_type return plugins -def resolve_related_plugin_links(plugin: PluginDescription, plugin_path: str, plugin_paths: dict[str, str]) -> list[tuple[PluginReference, str]]: +class RelatedPluginReferenceError(Exception): + """A relatedPlugins reference points at a plugin with no resolvable page.""" + + +def resolve_related_plugin_links(plugin: PluginDescription, current_plugin_path: str, plugin_paths: dict[str, str]) -> list[tuple[PluginReference, str]]: """Resolve each related plugin reference to a path relative to the current plugin's own page. Raises if a reference points at a plugin with no resolvable page — a stale cross-reference, not a typo. """ - current_dir_parts = Path(plugin_path).parts[:-1] + current_dir_parts = Path(current_plugin_path).parts[:-1] resolved = [] for ref in plugin.relatedPlugins: if ref.id not in plugin_paths: - raise Exception(f"Related plugin '{ref.id}' referenced by '{plugin.pluginId}' has no resolvable page") + raise RelatedPluginReferenceError(f"Related plugin '{ref.id}' referenced by '{plugin.pluginId}' has no resolvable page") resolved.append((ref, _relative_link(current_dir_parts, plugin_paths[ref.id]))) return resolved @@ -195,24 +199,15 @@ def _shared_prefix_length(a: tuple[str, ...], b: tuple[str, ...]) -> int: shared += 1 return shared -def create_plugin_markdown(plugin: PluginDescription, plugin_type: str, base_dir: Path, plugin_paths: dict[str, str]) -> None: +def create_plugin_markdown(plugin: PluginDescription, base_dir: Path, plugin_paths: dict[str, str]) -> None: """Create markdown document from plugin description.""" if plugin.is_deprecated: click.echo(f"Ignore deprecated plugin {plugin.pluginId}") return click.echo(f"Create reference documentation for {plugin.pluginId}") - # create the file (incl. directory) - if plugin.pluginType == "transformer": - directory = base_dir / plugin_type / plugin.main_category - else: - directory = base_dir / plugin_type - directory.mkdir(parents=True, exist_ok=True) - file = directory / f"{plugin.pluginId}.md" - - # resolve related-plugin links relative to this plugin's own page - plugin_path = str(directory.relative_to(base_dir) / f"{plugin.pluginId}.md") - related_plugins_resolved = resolve_related_plugin_links(plugin, plugin_path, plugin_paths) + current_plugin_path = plugin_paths[plugin.pluginId] + related_plugins_resolved = resolve_related_plugin_links(plugin, current_plugin_path, plugin_paths) # create content plugin_template = jinja_environment.get_template(f"plugin.md") @@ -232,6 +227,8 @@ def create_plugin_markdown(plugin: PluginDescription, plugin_type: str, base_dir related_plugins_resolved=related_plugins_resolved, ) + file = base_dir / current_plugin_path + file.parent.mkdir(parents=True, exist_ok=True) with file.open("w", encoding="utf-8") as f: f.write(content) @@ -304,27 +301,33 @@ def build_plugin_paths(plugins: dict[str, list[PluginDescription]]) -> dict[str, Deprecated plugins are excluded, since no page is ever generated for them. """ plugin_paths: dict[str, str] = {} - for type_id, plugins_list in plugins.items(): + for plugins_list in plugins.values(): for plugin in plugins_list: if plugin.is_deprecated: continue if plugin.pluginType == "transformer": - plugin_paths[plugin.pluginId] = f"{type_id}/{plugin.main_category}/{plugin.pluginId}.md" + plugin_paths[plugin.pluginId] = f"{plugin.pluginType}/{plugin.main_category}/{plugin.pluginId}.md" else: - plugin_paths[plugin.pluginId] = f"{type_id}/{plugin.pluginId}.md" + plugin_paths[plugin.pluginId] = f"{plugin.pluginType}/{plugin.pluginId}.md" return plugin_paths def validate_related_plugin_references(plugins: dict[str, list[PluginDescription]], plugin_paths: dict[str, str]) -> None: - """Raise on the first relatedPlugins reference with no resolvable page. + """Raise on every relatedPlugins reference with no resolvable page. Deprecated plugins are skipped, since no page is ever generated for them and their relatedPlugins is otherwise never inspected. """ + errors = [] for plugins_list in plugins.values(): for plugin in plugins_list: if plugin.is_deprecated: continue - resolve_related_plugin_links(plugin, plugin_paths[plugin.pluginId], plugin_paths) + try: + resolve_related_plugin_links(plugin, plugin_paths[plugin.pluginId], plugin_paths) + except RelatedPluginReferenceError as error: + errors.append(str(error)) + if errors: + raise RelatedPluginReferenceError("\n".join(errors)) @click.command() @click.option( @@ -359,5 +362,5 @@ def update_di_reference(output_dir): for type_id in plugins: Path(basedir / type_id).mkdir(parents=True, exist_ok=True) for plugin in plugins[type_id]: - create_plugin_markdown(plugin, type_id, basedir, plugin_paths) + create_plugin_markdown(plugin, basedir, plugin_paths) create_umbrella_pages(plugins=plugins, base_dir=basedir) From 5f49bc95622af015a8d219b614f74fa9abe455e2 Mon Sep 17 00:00:00 2001 From: Eduard Fugarolas Date: Fri, 17 Jul 2026 15:42:57 +0200 Subject: [PATCH 07/10] Collect all unresolvable related-plugin references instead of bailing on the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_related_plugin_links stopped at the first broken reference, so a plugin with two stale ones only ever surfaced one — now it collects all of them before raising. Duplicate plugin IDs get their own exception instead of a bare Exception. _relative_link's climbing arithmetic gets named variables instead of single letters. The plugin-type key settles on type_id everywhere instead of switching between type_id/plugin_type/category per function. --- tools/update_di_reference.py | 53 +++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/tools/update_di_reference.py b/tools/update_di_reference.py index 9cfbc4a9b..b58a03b26 100644 --- a/tools/update_di_reference.py +++ b/tools/update_di_reference.py @@ -165,30 +165,40 @@ class RelatedPluginReferenceError(Exception): """A relatedPlugins reference points at a plugin with no resolvable page.""" +class DuplicatePluginIdError(Exception): + """A plugin ID is not unique across the walked plugin types.""" + + def resolve_related_plugin_links(plugin: PluginDescription, current_plugin_path: str, plugin_paths: dict[str, str]) -> list[tuple[PluginReference, str]]: """Resolve each related plugin reference to a path relative to the current plugin's own page. - Raises if a reference points at a plugin with no resolvable page — a - stale cross-reference, not a typo. + Raises if any reference points at a plugin with no resolvable page. """ current_dir_parts = Path(current_plugin_path).parts[:-1] resolved = [] + unresolvable = [] for ref in plugin.relatedPlugins: if ref.id not in plugin_paths: - raise RelatedPluginReferenceError(f"Related plugin '{ref.id}' referenced by '{plugin.pluginId}' has no resolvable page") + unresolvable.append(ref.id) + continue resolved.append((ref, _relative_link(current_dir_parts, plugin_paths[ref.id]))) + if unresolvable: + raise RelatedPluginReferenceError( + f"Related plugin(s) {', '.join(unresolvable)} referenced by '{plugin.pluginId}' have no resolvable page" + ) return resolved def _relative_link(current_dir_parts: tuple[str, ...], target_path: str) -> str: """Build a relative link from a page's directory to a target path. Climbs '../' out of the current directory to the point where the two paths - diverge, then descends into whatever remains of the target's path. + diverge, then descends into the remaining segments of the target's path. """ target_parts = Path(target_path).parts - shared = _shared_prefix_length(current_dir_parts, target_parts[:-1]) - steps_up = ("..",) * (len(current_dir_parts) - shared) - return "/".join(steps_up + target_parts[shared:]) + shared_segment_count = _shared_prefix_length(current_dir_parts, target_parts[:-1]) + levels_to_climb = len(current_dir_parts) - shared_segment_count + steps_up = ("..",) * levels_to_climb + return "/".join(steps_up + target_parts[shared_segment_count:]) def _shared_prefix_length(a: tuple[str, ...], b: tuple[str, ...]) -> int: """Count the leading path segments two directories have in common.""" @@ -253,34 +263,33 @@ def create_umbrella_pages(plugins: dict[str, list[PluginDescription]], base_dir: - "Transformers": transformer""" f.write(content) - for plugin_type in plugins: - plugins_of_type = plugins[plugin_type] - if plugin_type == "transformer": + for type_id, plugins_of_type in plugins.items(): + if type_id == "transformer": table_template = jinja_environment.get_template(f"operator_table_with_category.md") else: table_template = jinja_environment.get_template(f"operator_table.md") # Create type-specific index.md file - index_file = base_dir / f"{plugin_type}/index.md" - index_template = jinja_environment.get_template(f"{plugin_type}_base.md") + index_file = base_dir / f"{type_id}/index.md" + index_template = jinja_environment.get_template(f"{type_id}_base.md") items = table_template.render(plugins=plugins_of_type) with index_file.open("w", encoding="utf-8") as f: - click.echo(f"Create {plugin_type} index file in {index_file}") + click.echo(f"Create {type_id} index file in {index_file}") f.write(index_template.render(items=items)) # Create the .pages files - pages_file = base_dir / plugin_type / ".pages" + pages_file = base_dir / type_id / ".pages" pages_content = "nav:\n - index.md" - if plugin_type == "transformer": + if type_id == "transformer": # transformer get separated per main_category - categories = list(set([plugin.main_category for plugin in plugins[plugin_type]])) + categories = list(set([plugin.main_category for plugin in plugins[type_id]])) categories.sort() for category in categories: pages_content += f'\n - "{category}": {category}' - sub_pages_file = base_dir / plugin_type / category / ".pages" + sub_pages_file = base_dir / type_id / category / ".pages" sub_pages_content = "nav:" - category_plugins = [plugin for plugin in plugins[plugin_type] if plugin.main_category == category] + category_plugins = [plugin for plugin in plugins[type_id] if plugin.main_category == category] for plugin in category_plugins: sub_pages_content += f"\n - \"{plugin.title}\": {plugin.pluginId}.md" with sub_pages_file.open("w", encoding="utf-8") as f: @@ -344,10 +353,10 @@ def update_di_reference(output_dir): click.echo(f"Dump plugins descriptions to {(plugins_json := Path('data/plugins.json'))}") plugins_dump: dict[str, dict] = {} - for category, plugins_list in plugins.items(): + for type_id, plugins_list in plugins.items(): for plugin in plugins_list: if plugin.pluginId in plugins_dump: - raise Exception(f"Duplicate plugin ID: {plugin.pluginId}") + raise DuplicatePluginIdError(f"Duplicate plugin ID: {plugin.pluginId}") plugins_dump[plugin.pluginId] = plugin.model_dump() plugin_paths = build_plugin_paths(plugins) @@ -359,8 +368,8 @@ def update_di_reference(output_dir): # create directory structure rmtree(basedir, ignore_errors=True) basedir.mkdir(parents=True, exist_ok=True) - for type_id in plugins: + for type_id, plugins_of_type in plugins.items(): Path(basedir / type_id).mkdir(parents=True, exist_ok=True) - for plugin in plugins[type_id]: + for plugin in plugins_of_type: create_plugin_markdown(plugin, basedir, plugin_paths) create_umbrella_pages(plugins=plugins, base_dir=basedir) From d3e98c6582f5551ba5468f10ef42004eb7435372 Mon Sep 17 00:00:00 2001 From: Eduard Fugarolas Date: Fri, 17 Jul 2026 15:46:34 +0200 Subject: [PATCH 08/10] Delete unused variable in test code --- tests/test_update_di_reference.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_update_di_reference.py b/tests/test_update_di_reference.py index b04eee506..5b66315ef 100644 --- a/tests/test_update_di_reference.py +++ b/tests/test_update_di_reference.py @@ -152,7 +152,6 @@ def test_validate_related_plugin_references_skips_deprecated_plugins(): def test_create_plugin_markdown_writes_resolved_links(tmp_path): - target = _make_plugin("sparqlEndpoint", plugin_type="dataset") plugin = _make_plugin( "sparqlSelectOperator", plugin_type="customtask", From 5c6e0821bf556a09311806a5963739ef5d4f0537 Mon Sep 17 00:00:00 2001 From: Eduard Fugarolas Date: Fri, 17 Jul 2026 16:00:10 +0200 Subject: [PATCH 09/10] Test related-plugin link rendering beyond the one happy path it had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing render test only proved a single cross-type link with a description works — nothing checked same-category links, same-directory links, more than one related plugin on a page, a reference with no description, or a plugin with none at all. Added tests for all of those against the real template output, plus a missing assertion on the existing test that had description data it never actually checked. --- tests/test_update_di_reference.py | 70 +++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/test_update_di_reference.py b/tests/test_update_di_reference.py index 5b66315ef..ef995f4e6 100644 --- a/tests/test_update_di_reference.py +++ b/tests/test_update_di_reference.py @@ -167,3 +167,73 @@ def test_create_plugin_markdown_writes_resolved_links(tmp_path): written = (tmp_path / "customtask" / "sparqlSelectOperator.md").read_text() assert "[sparqlEndpoint](../dataset/sparqlEndpoint.md)" in written assert "**sparqlEndpoint**" not in written + assert "[sparqlEndpoint](../dataset/sparqlEndpoint.md) — a SPARQL endpoint dataset" in written + + +@pytest.mark.parametrize( + "plugin_id, plugin_path, related_id, related_path, expected_link", + [ + # same type, different category + ("regexExtract", "transformer/Extract/regexExtract.md", "regexReplace", "transformer/Replace/regexReplace.md", "../Replace/regexReplace.md"), + # reverse: shallower page linking to a deeper one + ("average", "aggregator/average.md", "regexExtract", "transformer/Extract/regexExtract.md", "../transformer/Extract/regexExtract.md"), + # same directory + ("sparqlSelectOperator", "customtask/sparqlSelectOperator.md", "sparqlUpdateOperator", "customtask/sparqlUpdateOperator.md", "sparqlUpdateOperator.md"), + ], +) +def test_create_plugin_markdown_link_path_shapes(tmp_path, plugin_id, plugin_path, related_id, related_path, expected_link): + plugin = _make_plugin(plugin_id, related=[PluginReference(id=related_id)]) + plugin_paths = {plugin_id: plugin_path, related_id: related_path} + + create_plugin_markdown(plugin, tmp_path, plugin_paths) + + written = (tmp_path / plugin_path).read_text() + assert f"[{related_id}]({expected_link})" in written + + +def test_create_plugin_markdown_writes_multiple_resolved_links(tmp_path): + plugin = _make_plugin( + "regexExtract", + plugin_type="transformer", + main_category="Extract", + related=[PluginReference(id="regexReplace"), PluginReference(id="regexSelect")], + ) + plugin_paths = { + "regexExtract": "transformer/Extract/regexExtract.md", + "regexReplace": "transformer/Replace/regexReplace.md", + "regexSelect": "transformer/Selection/regexSelect.md", + } + + create_plugin_markdown(plugin, tmp_path, plugin_paths) + + written = (tmp_path / "transformer" / "Extract" / "regexExtract.md").read_text() + assert "[regexReplace](../Replace/regexReplace.md)" in written + assert "[regexSelect](../Selection/regexSelect.md)" in written + + +def test_create_plugin_markdown_omits_description_when_absent(tmp_path): + plugin = _make_plugin( + "sparqlSelectOperator", + plugin_type="customtask", + related=[PluginReference(id="sparqlEndpoint")], + ) + plugin_paths = { + "sparqlSelectOperator": "customtask/sparqlSelectOperator.md", + "sparqlEndpoint": "dataset/sparqlEndpoint.md", + } + + create_plugin_markdown(plugin, tmp_path, plugin_paths) + + written = (tmp_path / "customtask" / "sparqlSelectOperator.md").read_text() + assert "[sparqlEndpoint](../dataset/sparqlEndpoint.md)" in written + assert "[sparqlEndpoint](../dataset/sparqlEndpoint.md) —" not in written + + +def test_create_plugin_markdown_omits_section_when_no_related_plugins(tmp_path): + plugin = _make_plugin("regexExtract", plugin_type="transformer", main_category="Extract", related=[]) + plugin_paths = {"regexExtract": "transformer/Extract/regexExtract.md"} + + create_plugin_markdown(plugin, tmp_path, plugin_paths) + + written = (tmp_path / "transformer" / "Extract" / "regexExtract.md").read_text() + assert "Related Plugins" not in written From 4cee713354bf0e327ecff27503e1465997282a79 Mon Sep 17 00:00:00 2001 From: Robert Isele Date: Tue, 21 Jul 2026 14:43:44 +0200 Subject: [PATCH 10/10] Use posixpath functions instead of handwritten ones. --- tests/test_update_di_reference.py | 33 ++++++++++--------------------- tools/update_di_reference.py | 26 +++--------------------- 2 files changed, 13 insertions(+), 46 deletions(-) diff --git a/tests/test_update_di_reference.py b/tests/test_update_di_reference.py index ef995f4e6..d63f0c432 100644 --- a/tests/test_update_di_reference.py +++ b/tests/test_update_di_reference.py @@ -7,8 +7,6 @@ RelatedPluginReferenceError, get_plugin_descriptions, resolve_related_plugin_links, - _relative_link, - _shared_prefix_length, create_plugin_markdown, build_plugin_paths, validate_related_plugin_references, @@ -47,34 +45,23 @@ def _make_plugin(plugin_id, plugin_type="transformer", main_category="Extract", @pytest.mark.parametrize( - "a, b, expected", - [ - (("customtask",), ("dataset",), 0), - (("transformer", "Extract"), ("transformer", "Extract"), 2), - (("transformer", "Extract"), ("transformer", "Replace"), 1), - ((), ("dataset",), 0), - (("transformer", "Extract"), ("transformer",), 1), - ], -) -def test_shared_prefix_length(a, b, expected): - assert _shared_prefix_length(a, b) == expected - - -@pytest.mark.parametrize( - "current_dir_parts, target_path, expected", + "current_path, target_path, expected", [ # same type, different category - (("transformer", "Extract"), "transformer/Replace/regexReplace.md", "../Replace/regexReplace.md"), + ("transformer/Extract/regexExtract.md", "transformer/Replace/regexReplace.md", "../Replace/regexReplace.md"), # different type entirely - (("transformer", "Extract"), "aggregator/average.md", "../../aggregator/average.md"), + ("transformer/Extract/regexExtract.md", "aggregator/average.md", "../../aggregator/average.md"), # reverse: shallower page linking to a deeper one - (("aggregator",), "transformer/Extract/regexExtract.md", "../transformer/Extract/regexExtract.md"), + ("aggregator/average.md", "transformer/Extract/regexExtract.md", "../transformer/Extract/regexExtract.md"), # same directory - (("customtask",), "customtask/sparqlUpdateOperator.md", "sparqlUpdateOperator.md"), + ("customtask/sparqlSelectOperator.md", "customtask/sparqlUpdateOperator.md", "sparqlUpdateOperator.md"), ], ) -def test_relative_link(current_dir_parts, target_path, expected): - assert _relative_link(current_dir_parts, target_path) == expected +def test_resolve_related_plugin_links_path_shapes(current_path, target_path, expected): + ref = PluginReference(id="target") + plugin = _make_plugin("current", related=[ref]) + plugin_paths = {"current": current_path, "target": target_path} + assert resolve_related_plugin_links(plugin, current_path, plugin_paths) == [(ref, expected)] def test_resolve_related_plugin_links_empty(): diff --git a/tools/update_di_reference.py b/tools/update_di_reference.py index b58a03b26..6894509e0 100644 --- a/tools/update_di_reference.py +++ b/tools/update_di_reference.py @@ -1,6 +1,7 @@ """Update DI Reference documentation""" import json +import posixpath import re from contextlib import suppress from pathlib import Path @@ -174,41 +175,20 @@ def resolve_related_plugin_links(plugin: PluginDescription, current_plugin_path: Raises if any reference points at a plugin with no resolvable page. """ - current_dir_parts = Path(current_plugin_path).parts[:-1] + current_dir = posixpath.dirname(current_plugin_path) resolved = [] unresolvable = [] for ref in plugin.relatedPlugins: if ref.id not in plugin_paths: unresolvable.append(ref.id) continue - resolved.append((ref, _relative_link(current_dir_parts, plugin_paths[ref.id]))) + resolved.append((ref, posixpath.relpath(plugin_paths[ref.id], current_dir))) if unresolvable: raise RelatedPluginReferenceError( f"Related plugin(s) {', '.join(unresolvable)} referenced by '{plugin.pluginId}' have no resolvable page" ) return resolved -def _relative_link(current_dir_parts: tuple[str, ...], target_path: str) -> str: - """Build a relative link from a page's directory to a target path. - - Climbs '../' out of the current directory to the point where the two paths - diverge, then descends into the remaining segments of the target's path. - """ - target_parts = Path(target_path).parts - shared_segment_count = _shared_prefix_length(current_dir_parts, target_parts[:-1]) - levels_to_climb = len(current_dir_parts) - shared_segment_count - steps_up = ("..",) * levels_to_climb - return "/".join(steps_up + target_parts[shared_segment_count:]) - -def _shared_prefix_length(a: tuple[str, ...], b: tuple[str, ...]) -> int: - """Count the leading path segments two directories have in common.""" - shared = 0 - for a_part, b_part in zip(a, b): - if a_part != b_part: - break - shared += 1 - return shared - def create_plugin_markdown(plugin: PluginDescription, base_dir: Path, plugin_paths: dict[str, str]) -> None: """Create markdown document from plugin description.""" if plugin.is_deprecated: