diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38daddb8..abd347c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,8 @@ jobs: run: "uv run mypy --show-error-codes ." - name: "Pylint Tests" run: "uv run pylint --ignore .venv ." + - name: "Unit Tests" + run: "uv run pytest" yaml-lint: name: Run yaml lint diff --git a/pyproject.toml b/pyproject.toml index 48f53550..d7547c18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,8 +18,17 @@ dev = [ "pylint>=4.0.4", "vale>=3.13.0.0", "types-pyyaml>=6.0.12.20250915", + # Covers the docs-generation helpers in tasks/, whose escaping rules are + # subtle enough to regress without being noticed until a docs build fails. + "pytest>=8.3.0", ] +[tool.pytest.ini_options] +testpaths = ["tests"] +# `tasks` is a top-level package in the repo root rather than an installed +# distribution, so the root has to be on the import path for the tests. +pythonpath = ["."] + [tool.pylint.messages_control] # Line length is enforced by Black, so pylint doesn't need to check it. # Pylint and Black disagree about how to format multi-line arrays; Black wins. diff --git a/tasks/docs.py b/tasks/docs.py index e5a00c8e..a5c987bc 100644 --- a/tasks/docs.py +++ b/tasks/docs.py @@ -1,6 +1,8 @@ +import re import sys from collections import defaultdict from pathlib import Path +from typing import Any import jinja2 import yaml # type: ignore @@ -12,6 +14,10 @@ TEMPLATE_DIRECTORY = DOCUMENTATION_DIRECTORY / "_templates" REFERENCE_DIRECTORY = DOCUMENTATION_DIRECTORY / "docs" / "reference" +# Matches an inline code span: a run of backticks, the shortest content that +# reaches a matching run, and the closing run. DOTALL so a span may wrap lines. +INLINE_CODE_PATTERN = re.compile(r"(?P`+).*?(?P=fence)", re.DOTALL) + def _sanitize_description(desc): if not isinstance(desc, str): @@ -19,6 +25,59 @@ def _sanitize_description(desc): return desc.replace("\n", " ").replace(" ", " ").strip() +def _escape_mdx_braces(text: str) -> str: + """Escape MDX expression braces that sit outside inline code spans. + + Docusaurus parses the reference pages as MDX, where a bare ``{...}`` is a + JSX expression rather than literal text. A schema description carrying + braces -- NetBox's ``Ethernet{module}/1`` port-name token, say -- therefore + breaks the docs build with "Objects are not valid as a React child", because + ``module`` resolves to the MDX module object. + + Braces inside an inline code span are already inert, and a backslash there + would render literally rather than escaping, so those spans are left alone. + + Args: + text: Text destined for MDX prose or a Markdown table cell. + + Returns: + The text with braces outside inline code spans backslash-escaped. + """ + if not isinstance(text, str) or ("{" not in text and "}" not in text): + return text + + def escape(segment: str) -> str: + return segment.replace("{", "\\{").replace("}", "\\}") + + parts: list[str] = [] + position = 0 + for match in INLINE_CODE_PATTERN.finditer(text): + parts.append(escape(text[position : match.start()])) + parts.append(match.group(0)) + position = match.end() + parts.append(escape(text[position:])) + + return "".join(parts) + + +def _escape_mdx_structure(value: Any) -> Any: + """Recursively apply :func:`_escape_mdx_braces` to every string in a value. + + Args: + value: Any nested combination of dicts, lists and scalars. + + Returns: + The same shape, with every string escaped for MDX. + """ + if isinstance(value, str): + return _escape_mdx_braces(value) + if isinstance(value, list): + return [_escape_mdx_structure(item) for item in value] + if isinstance(value, dict): + return {key: _escape_mdx_structure(item) for key, item in value.items()} + return value + + def _generate_toc_content(metadata) -> defaultdict[str, list]: """Based on the metadata, generate the Table of Content for the reference page.""" @@ -30,9 +89,11 @@ def _generate_toc_content(metadata) -> defaultdict[str, list]: for key in metadata: # TODO: Handle the case where we can't split the key current_item = { - "name": metadata[key].get("name"), + "name": _escape_mdx_braces(metadata[key].get("name")), "link": "./reference/" + key.split("/")[1] + ".mdx", - "description": _sanitize_description(metadata[key].get("description", "")), + "description": _escape_mdx_braces( + _sanitize_description(metadata[key].get("description", "")) + ), } result[key.split("/")[0]].append(current_item) @@ -92,6 +153,12 @@ def _generate_schema_reference_content(schema_key: str, schema_metadata: dict) - for section in ["nodes", "generics", "extensions"]: schema_data[section] = schema_definition.get(section, []) + # Everything above is rendered as MDX prose or into a Markdown table, + # so braces in it have to be escaped. The code section below is not: + # it lands inside a fenced block, where MDX leaves braces alone and an + # escape would show up verbatim in the rendered YAML. + schema_data = _escape_mdx_structure(schema_data) + # Add the code section schema_data["code"] = yaml.dump(schema_definition, sort_keys=False) diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 00000000..07196b3b --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,80 @@ +"""Tests for the documentation generation helpers in ``tasks.docs``.""" + +import pytest + +from tasks.docs import _escape_mdx_braces, _escape_mdx_structure + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + pytest.param("no braces here", "no braces here", id="untouched"), + pytest.param( + "e.g. 'Ethernet{module}/1'.", + "e.g. 'Ethernet\\{module\\}/1'.", + id="bare-braces-escaped", + ), + pytest.param( + "e.g. `Ethernet{module}/1`.", + "e.g. `Ethernet{module}/1`.", + id="inline-code-left-alone", + ), + pytest.param( + "{a} then `{b}` then {c}", + "\\{a\\} then `{b}` then \\{c\\}", + id="mixed-inside-and-outside", + ), + pytest.param( + "``a ` b {c}``", + "``a ` b {c}``", + id="double-backtick-span-with-embedded-backtick", + ), + pytest.param( + "{{ device__name__value }}", + "\\{\\{ device__name__value \\}\\}", + id="jinja-style-double-braces", + ), + pytest.param( + "unterminated `code {x}", + "unterminated `code \\{x\\}", + id="unclosed-span-still-escaped", + ), + ], +) +def test_escape_mdx_braces(text: str, expected: str) -> None: + assert _escape_mdx_braces(text) == expected + + +@pytest.mark.parametrize("value", [None, 42, True]) +def test_escape_mdx_braces_passes_through_non_strings(value: object) -> None: + assert _escape_mdx_braces(value) is value # type: ignore[arg-type] + + +def test_escape_mdx_structure_walks_nested_containers() -> None: + payload = { + "description": "a {token}", + "nodes": [ + {"name": "Node", "attributes": [{"description": "b {token}"}]}, + {"name": "Safe", "attributes": [{"description": "c `{token}`"}]}, + ], + "optional": True, + "order_weight": 1000, + } + + result = _escape_mdx_structure(payload) + + assert result["description"] == "a \\{token\\}" + assert result["nodes"][0]["attributes"][0]["description"] == "b \\{token\\}" + # Inline code spans survive the walk unchanged. + assert result["nodes"][1]["attributes"][0]["description"] == "c `{token}`" + # Non-string scalars are preserved, not stringified. + assert result["optional"] is True + assert result["order_weight"] == 1000 + + +def test_escape_mdx_structure_does_not_mutate_input() -> None: + payload = {"description": "a {token}"} + + _escape_mdx_structure(payload) + + assert payload["description"] == "a {token}" diff --git a/uv.lock b/uv.lock index 81618191..b6ba9cde 100644 --- a/uv.lock +++ b/uv.lock @@ -194,7 +194,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -856,6 +856,7 @@ dependencies = [ dev = [ { name = "mypy" }, { name = "pylint" }, + { name = "pytest" }, { name = "ruff" }, { name = "types-pyyaml" }, { name = "vale" }, @@ -873,6 +874,7 @@ requires-dist = [ dev = [ { name = "mypy", specifier = ">=1.19.1" }, { name = "pylint", specifier = ">=4.0.4" }, + { name = "pytest", specifier = ">=8.3.0" }, { name = "ruff", specifier = ">=0.14.10" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, { name = "vale", specifier = ">=3.13.0.0" },