diff --git a/README.md b/README.md index 6a006c0..dfee7b2 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,73 @@ Finally, to generate the documentation: docc ``` +## Development + +### Setting Up + +Clone the repository with submodules: + +```bash +git clone --recurse-submodules https://github.com/SamWilsn/docc.git +cd docc +``` + +If you already cloned without submodules: + +```bash +git submodule update --init --recursive +``` + +Install in development mode to run tests and lint: + +```bash +pip install -e ".[test,lint]" +``` + +### Code Style + +This project uses: + +- **black** for code formatting (line length: 79). +- **isort** for import sorting (black profile). +- **flake8** for linting. +- **pyre** for type checking. + +Format code before committing: + +```bash +black src tests +isort src tests +``` + +### Running Tests + +```bash +pytest +``` + +Tests require 80% code coverage to pass. For a detailed coverage report: + +```bash +pytest --cov-report=html +``` + +The HTML report will be generated in `htmlcov/`. + +### Using Tox + +Run the full test suite with linting: + +```bash +tox +``` + +Run only type checking: + +```bash +tox -e type +``` + [docs-badge]: https://github.com/SamWilsn/docc/actions/workflows/gh-pages.yaml/badge.svg?branch=master [docs]: https://samwilsn.github.io/docc/ [`pyproject.toml`]: https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#declaring-project-metadata diff --git a/pyproject.toml b/pyproject.toml index c491fcd..ab6fe7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,3 +15,19 @@ paths = [ "src" ] [tool.docc.output] path = "docs" + +[tool.pytest.ini_options] +# term-missing shows uncovered line numbers in terminal output +addopts = "--cov=docc --cov-report=term-missing" + +[tool.coverage.run] +source = ["src/docc"] +branch = true + +[tool.coverage.report] +fail_under = 80 +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", +] diff --git a/setup.cfg b/setup.cfg index 5158a89..017dac1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -112,6 +112,9 @@ lint = flake8-bugbear>=25.10.21,<26.0.0 flake8>=7.3,<8 pytest>=8.4.2,<9 +test = + pytest>=8.4.2,<9 + pytest-cov>=6.0,<7 [flake8] dictionaries=en_US,python,technical diff --git a/src/docc/plugins/html/__init__.py b/src/docc/plugins/html/__init__.py index bb12986..dd27f6e 100644 --- a/src/docc/plugins/html/__init__.py +++ b/src/docc/plugins/html/__init__.py @@ -29,6 +29,7 @@ Dict, Final, FrozenSet, + Generic, Iterable, Iterator, List, @@ -36,6 +37,7 @@ Sequence, Tuple, Type, + TypeVar, Union, ) from urllib.parse import urlunsplit @@ -667,6 +669,41 @@ def _reference_support( return markupsafe.Markup(output) +V = TypeVar("V") + + +class _CollectVisitor(Generic[V], Visitor): + _class: Type[V] + found: List[V] + + def __init__(self, class_: Type[V]) -> None: + self._class = class_ + self.found = list() + + def enter(self, node: Node) -> Visit: + if isinstance(node, self._class): + self.found.append(node) + + return Visit.TraverseChildren + + def exit(self, node: Node) -> None: + pass + + +def render_to_plain(context: Context, node: Node) -> str: + """ + Renders a node to HTML, then extracts the raw text. + """ + visitor = HTMLVisitor(context) + node.visit(visitor) + + collect = _CollectVisitor(TextNode) + visitor.root.visit(collect) + + raw_text = [n._value for n in collect.found] + return "".join(raw_text) + + def _find_filter( value: object, class_: object, diff --git a/src/docc/plugins/mistletoe.py b/src/docc/plugins/mistletoe.py index 8d33dde..f09ab47 100644 --- a/src/docc/plugins/mistletoe.py +++ b/src/docc/plugins/mistletoe.py @@ -272,9 +272,12 @@ def _render_image( ) -> html.RenderResult: token = node.token assert isinstance(token, spans.Image) + + plain = [html.render_to_plain(context, n) for n in node.children] + attributes = { "src": token.src, - "alt": token.content, + "alt": "".join(plain), } if token.title: attributes["title"] = token.title @@ -458,7 +461,7 @@ def _render_table_cell( align = "left" elif token.align == 0: align = "center" - elif token.align == 2: + elif token.align == 1: align = "right" else: raise NotImplementedError(f"table alignment {token.align}") @@ -488,34 +491,6 @@ def _render_line_break( return None -def _render_html_span( - context: Context, - parent: Union[html.HTMLRoot, html.HTMLTag], - node: MarkdownNode, -) -> html.RenderResult: - token = node.token - assert isinstance(token, spans.HTMLSpan) - parser = html.HTMLParser(context) - parser.feed(token.content) - for child in parser.root.children: - parent.append(child) - return None - - -def _render_html_block( - context: Context, - parent: Union[html.HTMLRoot, html.HTMLTag], - node: MarkdownNode, -) -> html.RenderResult: - token = node.token - assert isinstance(token, blocks.HTMLBlock) - parser = html.HTMLParser(context) - parser.feed(token.content) - for child in parser.root.children: - parent.append(child) - return None - - def _render_document( context: Context, parent: Union[html.HTMLRoot, html.HTMLTag], @@ -558,8 +533,6 @@ def _render_document( "ThematicBreak": _render_thematic_break, "LineBreak": _render_line_break, "Document": _render_document, - "HTMLBlock": _render_html_block, - "HTMLSpan": _render_html_span, } diff --git a/src/docc/source.py b/src/docc/source.py index 2060b02..1b339df 100644 --- a/src/docc/source.py +++ b/src/docc/source.py @@ -91,6 +91,9 @@ class StringSource(TextSource): A Source that reads text snippets from a `str`. """ + _output_path: Final[PurePath] + _relative_path: Final[Optional[PurePath]] + def __init__( self, text: str, @@ -99,8 +102,8 @@ def __init__( ) -> None: self._lines: Final[Sequence[str]] = text.split("\n") self._text: Final[str] = text - self.output_path: Final[PurePath] = output_path - self.relative_path: Final[Optional[PurePath]] = relative_path + self._output_path = output_path + self._relative_path = relative_path @override def open(self) -> TextIO: @@ -115,3 +118,19 @@ def line(self, number: int) -> str: Extract a line of text from the source. """ return self._lines[number - 1] + + @override + @property + def output_path(self) -> PurePath: + """ + Where to write the output from this Source relative to the output path. + """ + return self._output_path + + @override + @property + def relative_path(self) -> Optional[PurePath]: + """ + Path to the Source (if one exists) relative to the project root. + """ + return self._relative_path diff --git a/tests/conftest.py b/tests/conftest.py index 6dc80ff..8c7115f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -# Copyright (C) 2025 Ethereum Foundation +# Copyright (C) 2025-2026 Ethereum Foundation # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -21,6 +21,7 @@ from docc.context import Context from docc.document import Document, Node, Visit, Visitor +from docc.plugins.references import Reference from docc.settings import PluginSettings, Settings @@ -55,7 +56,7 @@ def _assert_in( haystack = haystack.root visitor = ContainsVisitor(matcher) haystack.visit(visitor) - assert visitor.found + assert visitor.found, f"No matching node found in tree: {haystack!r}" def _assert_not_in( @@ -105,3 +106,20 @@ def _make_context(root: Node) -> Context: @pytest.fixture def make_context() -> Callable[[Node], Context]: return _make_context + + +class ReferenceChecker(Visitor): + """Helper visitor to check for Reference nodes in a tree.""" + + def __init__(self) -> None: + self.found: int = 0 + + @override + def enter(self, node: Node) -> Visit: + if isinstance(node, Reference): + self.found += 1 + return Visit.TraverseChildren + + @override + def exit(self, node: Node) -> None: + pass diff --git a/tests/test_build_discover.py b/tests/test_build_discover.py new file mode 100644 index 0000000..3d88700 --- /dev/null +++ b/tests/test_build_discover.py @@ -0,0 +1,82 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from pathlib import Path + +from docc.build import Builder +from docc.build import load as load_builders +from docc.discover import Discover +from docc.discover import load as load_discovers +from docc.settings import Settings + + +class TestLoadDiscovers: + def test_load_empty_discovery_list(self, tmp_path: Path) -> None: + settings = Settings( + tmp_path, + {"tool": {"docc": {"discovery": []}}}, + ) + + result = list(load_discovers(settings)) + assert result == [] + + def test_load_single_discover(self, tmp_path: Path) -> None: + settings = Settings( + tmp_path, + { + "tool": { + "docc": { + "discovery": ["docc.python.discover"], + "plugins": { + "docc.python.discover": {"paths": [str(tmp_path)]} + }, + } + } + }, + ) + + result = list(load_discovers(settings)) + assert len(result) == 1 + assert result[0][0] == "docc.python.discover" + assert isinstance(result[0][1], Discover) + + +class TestLoadBuilders: + def test_load_empty_builder_list(self, tmp_path: Path) -> None: + settings = Settings( + tmp_path, + {"tool": {"docc": {"build": []}}}, + ) + + result = list(load_builders(settings)) + assert result == [] + + def test_load_single_builder(self, tmp_path: Path) -> None: + settings = Settings( + tmp_path, + { + "tool": { + "docc": { + "build": ["docc.python.build"], + "plugins": {"docc.python.build": {}}, + } + } + }, + ) + + result = list(load_builders(settings)) + assert len(result) == 1 + assert result[0][0] == "docc.python.build" + assert isinstance(result[0][1], Builder) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..705a809 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,383 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging +from io import StringIO, TextIOBase +from pathlib import Path +from typing import Tuple + +import pytest + +from docc.cli import _OutputVisitor, main +from docc.context import Context +from docc.document import ( + BlankNode, + ListNode, + Node, + OutputNode, + Visit, +) + + +class MockOutputNode(OutputNode): + def __init__( + self, content: str = "output content", ext: str = ".html" + ) -> None: + self._content = content + self._ext = ext + + @property + def children(self) -> Tuple[()]: + return () + + def replace_child(self, old: Node, new: Node) -> None: + pass + + @property + def extension(self) -> str: + return self._ext + + def output(self, context: Context, destination: TextIOBase) -> None: + destination.write(self._content) + + +class TestOutputVisitor: + def test_enter_output_node_calls_output(self) -> None: + output_node = MockOutputNode("test output") + context = Context({}) + destination = StringIO() + + visitor = _OutputVisitor(context, destination) + result = visitor.enter(output_node) + + assert result == Visit.SkipChildren + assert destination.getvalue() == "test output" + + def test_enter_non_output_node_traverses(self) -> None: + node = BlankNode() + context = Context({}) + destination = StringIO() + + visitor = _OutputVisitor(context, destination) + result = visitor.enter(node) + + assert result == Visit.TraverseChildren + + def test_enter_list_node_traverses(self) -> None: + node = ListNode([BlankNode()]) + context = Context({}) + destination = StringIO() + + visitor = _OutputVisitor(context, destination) + result = visitor.enter(node) + + assert result == Visit.TraverseChildren + + def test_exit_does_nothing(self) -> None: + node = BlankNode() + context = Context({}) + destination = StringIO() + + visitor = _OutputVisitor(context, destination) + result = visitor.exit(node) + + assert result is None, "exit() should return None" + assert ( + destination.getvalue() == "" + ), "exit() should not write to destination" + + +class TestMainFunction: + def test_main_requires_output_path( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + ) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text("[tool.docc]\n") + + monkeypatch.chdir(tmp_path) + with caplog.at_level(logging.CRITICAL): + with pytest.raises(SystemExit) as exc_info: + main([]) + assert exc_info.value.code == 1 + assert any( + "Output path is required" in r.message for r in caplog.records + ) + + def test_main_with_output_flag( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + src_dir = tmp_path / "src" + src_dir.mkdir() + py_file = src_dir / "example.py" + py_file.write_text('"""Module docstring."""\n') + + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + f""" +[tool.docc] +discovery = ["docc.python.discover"] +build = ["docc.python.build"] +transform = [ + "docc.listing.transform", + "docc.python.transform", + "docc.html.transform", +] +context = ["docc.listing.context", "docc.html.context"] + +[tool.docc.plugins."docc.python.discover"] +paths = ["{src_dir}"] + +[tool.docc.output] +path = "docs" +""" + ) + + output_dir = tmp_path / "output" + + monkeypatch.chdir(tmp_path) + main(["--output", str(output_dir)]) + + assert output_dir.exists(), "Output directory should be created" + + def test_main_uses_settings_output_path( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + src_dir = tmp_path / "src" + src_dir.mkdir() + py_file = src_dir / "example.py" + py_file.write_text('"""Module docstring."""\n') + + output_dir = tmp_path / "docs" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + f""" +[tool.docc] +discovery = ["docc.python.discover"] +build = ["docc.python.build"] +transform = [ + "docc.listing.transform", + "docc.python.transform", + "docc.html.transform", +] +context = ["docc.listing.context", "docc.html.context"] + +[tool.docc.plugins."docc.python.discover"] +paths = ["{src_dir}"] + +[tool.docc.output] +path = "{output_dir}" +""" + ) + + monkeypatch.chdir(tmp_path) + main([]) + + assert ( + output_dir.exists() + ), "Output directory should be created from settings" + + +class TestOutputVisitorWithNestedNodes: + def test_nested_output_nodes(self) -> None: + inner = MockOutputNode("inner") + outer_content = ListNode([inner]) + + class ContainerNode(OutputNode): + @property + def children(self) -> Tuple[ListNode]: + return (outer_content,) + + def replace_child(self, old: Node, new: Node) -> None: + raise NotImplementedError + + @property + def extension(self) -> str: + return ".html" + + def output( + self, context: Context, destination: TextIOBase + ) -> None: + destination.write("outer") + + container = ContainerNode() + context = Context({}) + destination = StringIO() + + visitor = _OutputVisitor(context, destination) + container.visit(visitor) + + assert destination.getvalue() == "outer" + + def test_multiple_output_nodes(self) -> None: + first_node = MockOutputNode("first") + second_node = MockOutputNode("second") + root = ListNode([first_node, second_node]) + + context = Context({}) + destination = StringIO() + + visitor = _OutputVisitor(context, destination) + root.visit(visitor) + + assert destination.getvalue() == "firstsecond" + + +def test_main_processes_python_source( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + src_dir = tmp_path / "src" + src_dir.mkdir() + + py_file = src_dir / "example.py" + py_file.write_text('"""Module docstring."""\n\ndef hello():\n pass\n') + + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + f""" +[tool.docc] +discovery = ["docc.python.discover"] +build = ["docc.python.build"] +transform = [ + "docc.listing.transform", + "docc.python.transform", + "docc.verbatim.transform", + "docc.references.index", + "docc.html.transform", +] +context = [ + "docc.listing.context", + "docc.html.context", + "docc.references.context", +] + +[tool.docc.plugins."docc.python.discover"] +paths = ["{src_dir}"] + +[tool.docc.output] +path = "docs" +""" + ) + + output_dir = tmp_path / "docs" + + monkeypatch.chdir(tmp_path) + main(["--output", str(output_dir)]) + + assert output_dir.exists(), "Output directory should be created" + html_files = list(output_dir.rglob("*.html")) + assert len(html_files) >= 1, "Should produce at least one HTML file" + content = html_files[0].read_text() + assert "Module docstring" in content + + +def test_main_empty_project( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.docc] +discovery = [] +build = [] +transform = [] +context = [] + +[tool.docc.output] +path = "docs" +""" + ) + + output_dir = tmp_path / "docs" + + monkeypatch.chdir(tmp_path) + main(["--output", str(output_dir)]) + + assert not output_dir.exists(), "No output should be created" + + +def test_main_duplicate_context_raises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """ + When two context plugins provide the same type, main() raises + an Exception about the conflict. + """ + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.docc] +discovery = [] +build = [] +transform = [] +context = ["docc.references.context", "docc.references.context"] + +[tool.docc.output] +path = "docs" +""" + ) + + monkeypatch.chdir(tmp_path) + with pytest.raises(Exception, match="conflicts with"): + main(["--output", str(tmp_path / "docs")]) + + +def test_main_document_without_extension_skipped( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """ + When a document has no extension (no OutputNode), the write + phase logs an error and skips it. + """ + pyproject = tmp_path / "pyproject.toml" + src_dir = tmp_path / "src" + src_dir.mkdir() + py_file = src_dir / "example.py" + py_file.write_text('"""Module docstring."""\n') + + pyproject.write_text( + f""" +[tool.docc] +discovery = ["docc.python.discover"] +build = ["docc.python.build"] +transform = [] +context = [] + +[tool.docc.plugins."docc.python.discover"] +paths = ["{src_dir}"] + +[tool.docc.output] +path = "docs" +""" + ) + + output_dir = tmp_path / "docs" + + monkeypatch.chdir(tmp_path) + with caplog.at_level(logging.ERROR): + main(["--output", str(output_dir)]) + + assert any( + "does not specify a file extension" in r.message + for r in caplog.records + ) diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 0000000..fef396e --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,150 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from pathlib import Path + +import pytest + +from docc.context import Context, load +from docc.settings import Settings + + +class TestContext: + def test_init_empty(self) -> None: + ctx = Context() + assert str not in ctx + + def test_init_with_none(self) -> None: + ctx = Context(None) + assert str not in ctx + + def test_getitem_missing_raises(self) -> None: + ctx = Context({}) + with pytest.raises(KeyError): + ctx[str] + + def test_contains_true(self) -> None: + ctx = Context({str: "hello"}) + assert str in ctx + + def test_contains_false(self) -> None: + ctx = Context({}) + assert str not in ctx + + def test_init_validates_types(self) -> None: + with pytest.raises(ValueError, match="is not an instance"): + Context({str: 123}) + + def test_repr(self) -> None: + ctx = Context({str: "hello"}) + result = repr(ctx) + assert "Context" in result + assert "str" in result + + def test_multiple_types(self) -> None: + class CustomA: + pass + + class CustomB: + pass + + a = CustomA() + b = CustomB() + + ctx = Context({CustomA: a, CustomB: b}) + assert ctx[CustomA] is a + assert ctx[CustomB] is b + + def test_subclass_types(self) -> None: + class Base: + pass + + class Derived(Base): + pass + + d = Derived() + ctx = Context({Derived: d}) + assert ctx[Derived] is d + + def test_base_and_derived_stored_separately(self) -> None: + class Base: + pass + + class Derived(Base): + pass + + b = Base() + d = Derived() + ctx = Context({Base: b, Derived: d}) + assert ctx[Base] is b + assert ctx[Derived] is d + + def test_derived_stored_base_lookup_not_found(self) -> None: + """ + When a Derived instance is stored under its Derived key, + looking up by Base raises KeyError because Context uses exact + type matching on the dict key, not isinstance checks. + """ + + class Base: + pass + + class Derived(Base): + pass + + d = Derived() + ctx = Context({Derived: d}) + assert Base not in ctx + with pytest.raises(KeyError): + ctx[Base] + + +class TestContextLoad: + def test_load_empty_context_list(self, tmp_path: Path) -> None: + settings = Settings( + tmp_path, + {"tool": {"docc": {"context": []}}}, + ) + + result = list(load(settings)) + assert result == [] + + def test_load_single_context(self, tmp_path: Path) -> None: + settings = Settings( + tmp_path, + {"tool": {"docc": {"context": ["docc.references.context"]}}}, + ) + + result = list(load(settings)) + assert len(result) == 1 + assert result[0][0] == "docc.references.context" + + def test_load_multiple_contexts(self, tmp_path: Path) -> None: + settings = Settings( + tmp_path, + { + "tool": { + "docc": { + "context": [ + "docc.references.context", + "docc.search.context", + ] + } + } + }, + ) + + result = list(load(settings)) + assert len(result) == 2 diff --git a/tests/test_debug.py b/tests/test_debug.py new file mode 100644 index 0000000..37b388e --- /dev/null +++ b/tests/test_debug.py @@ -0,0 +1,115 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from io import StringIO +from pathlib import Path + +import pytest + +from docc.context import Context +from docc.document import BlankNode, Document, ListNode +from docc.plugins.debug import DebugNode, DebugTransform +from docc.settings import PluginSettings, Settings + + +@pytest.fixture +def plugin_settings() -> PluginSettings: + settings = Settings(Path("."), {"tool": {"docc": {}}}) + return settings.for_plugin("docc.debug.transform") + + +class TestDebugNode: + def test_init(self) -> None: + child = BlankNode() + node = DebugNode(child) + assert node.child is child + + def test_children(self) -> None: + child = BlankNode() + node = DebugNode(child) + children = tuple(node.children) + assert children == (child,) + + def test_replace_child(self) -> None: + old_child = BlankNode() + new_child = BlankNode() + node = DebugNode(old_child) + + node.replace_child(old_child, new_child) + assert node.child is new_child + + def test_replace_child_no_match(self) -> None: + child = BlankNode() + other = BlankNode() + new_child = BlankNode() + node = DebugNode(child) + + node.replace_child(other, new_child) + assert node.child is child + + def test_extension(self) -> None: + node = DebugNode(BlankNode()) + assert node.extension == ".txt" + + def test_output(self) -> None: + child = BlankNode() + node = DebugNode(child) + context = Context({}) + destination = StringIO() + + node.output(context, destination) + + result = destination.getvalue() + assert "" in result + + def test_output_nested(self) -> None: + inner = BlankNode() + outer = ListNode([inner]) + node = DebugNode(outer) + context = Context({}) + destination = StringIO() + + node.output(context, destination) + + result = destination.getvalue() + assert "" in result + assert "" in result + + +class TestDebugTransform: + def test_transform(self, plugin_settings: PluginSettings) -> None: + root = BlankNode() + document = Document(root) + context = Context({Document: document}) + + transform = DebugTransform(plugin_settings) + transform.transform(context) + + assert isinstance(document.root, DebugNode) + assert document.root.child is root + + def test_transform_with_nested( + self, plugin_settings: PluginSettings + ) -> None: + inner = BlankNode() + root = ListNode([inner]) + document = Document(root) + context = Context({Document: document}) + + transform = DebugTransform(plugin_settings) + transform.transform(context) + + assert isinstance(document.root, DebugNode) + assert document.root.child is root diff --git a/tests/test_document.py b/tests/test_document.py new file mode 100644 index 0000000..593b028 --- /dev/null +++ b/tests/test_document.py @@ -0,0 +1,524 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from io import StringIO +from typing import ClassVar, List, Literal, Tuple + +import pytest +from typing_extensions import override + +from docc.document import ( + BlankNode, + Document, + ListNode, + Node, + OutputNode, + Visit, + Visitor, + _ExtensionVisitor, + _StrVisitor, +) + +Event = Tuple[Literal["enter", "exit"], int] + + +@pytest.mark.parametrize( + "node, expected_repr", + [ + (BlankNode(), ""), + (ListNode(), ""), + ], + ids=["BlankNode", "ListNode"], +) +def test_node_children_iterable(node: Node, expected_repr: str) -> None: + assert hasattr(node.children, "__iter__") + + +@pytest.mark.parametrize( + "node, expected_repr", + [ + (BlankNode(), ""), + (ListNode(), ""), + ], + ids=["BlankNode", "ListNode"], +) +def test_node_repr(node: Node, expected_repr: str) -> None: + assert repr(node) == expected_repr + + +class TestBlankNode: + def test_children_returns_empty_tuple(self) -> None: + node = BlankNode() + assert tuple(node.children) == () + + def test_replace_child_raises_type_error(self) -> None: + node = BlankNode() + with pytest.raises(TypeError): + node.replace_child(BlankNode(), BlankNode()) + + def test_bool_is_false(self) -> None: + node = BlankNode() + assert bool(node) is False + + +class TestListNode: + def test_children_property_returns_list(self) -> None: + first_child, second_child = BlankNode(), BlankNode() + node = ListNode([first_child, second_child]) + assert list(node.children) == [first_child, second_child] + + def test_default_children_is_empty(self) -> None: + node = ListNode() + assert list(node.children) == [] + + def test_replace_child(self) -> None: + old_child = BlankNode() + new_child = BlankNode() + other_child = BlankNode() + node = ListNode([old_child, other_child]) + + node.replace_child(old_child, new_child) + + assert list(node.children) == [new_child, other_child] + + def test_replace_child_when_not_found(self) -> None: + old_child = BlankNode() + new_child = BlankNode() + other_child = BlankNode() + node = ListNode([other_child]) + + node.replace_child(old_child, new_child) + assert list(node.children) == [other_child] + + def test_bool_true_when_has_children(self) -> None: + node = ListNode([BlankNode()]) + assert bool(node) is True + + def test_bool_false_when_empty(self) -> None: + node = ListNode() + assert bool(node) is False + + def test_len(self) -> None: + node = ListNode([BlankNode(), BlankNode(), BlankNode()]) + assert len(node) == 3 + + +class RecordingVisitor(Visitor): + returns: ClassVar[Visit] = Visit.TraverseChildren + events: List[Event] + + def __init__(self) -> None: + self.events = [] + + @override + def enter(self, node: Node) -> Visit: + self.events.append(("enter", id(node))) + return self.returns + + @override + def exit(self, node: Node) -> None: + self.events.append(("exit", id(node))) + + +class SkippingVisitor(RecordingVisitor): + returns: ClassVar[Visit] = Visit.SkipChildren + + +class TestNodeVisit: + def test_visit_single_node(self) -> None: + node = BlankNode() + visitor = RecordingVisitor() + node.visit(visitor) + + assert visitor.events == [ + ("enter", id(node)), + ("exit", id(node)), + ] + + def test_visit_with_children(self) -> None: + first_child = BlankNode() + second_child = BlankNode() + parent = ListNode([first_child, second_child]) + + visitor = RecordingVisitor() + parent.visit(visitor) + + assert visitor.events == [ + ("enter", id(parent)), + ("enter", id(first_child)), + ("exit", id(first_child)), + ("enter", id(second_child)), + ("exit", id(second_child)), + ("exit", id(parent)), + ] + + def test_visit_skip_children(self) -> None: + child = BlankNode() + parent = ListNode([child]) + + visitor = SkippingVisitor() + parent.visit(visitor) + + assert visitor.events == [ + ("enter", id(parent)), + ("exit", id(parent)), + ] + + def test_visit_nested_structure(self) -> None: + leaf = BlankNode() + inner = ListNode([leaf]) + outer = ListNode([inner]) + + visitor = RecordingVisitor() + outer.visit(visitor) + + assert visitor.events == [ + ("enter", id(outer)), + ("enter", id(inner)), + ("enter", id(leaf)), + ("exit", id(leaf)), + ("exit", id(inner)), + ("exit", id(outer)), + ] + + def test_visit_depth_first(self) -> None: + first_leaf = BlankNode() + second_leaf = BlankNode() + third_leaf = BlankNode() + first_branch = ListNode([first_leaf, second_leaf]) + second_branch = ListNode([third_leaf]) + root = ListNode([first_branch, second_branch]) + + visitor = RecordingVisitor() + root.visit(visitor) + + enter_events = [e for e in visitor.events if e[0] == "enter"] + assert enter_events[0] == ("enter", id(root)) + assert enter_events[1] == ("enter", id(first_branch)) + assert enter_events[2] == ("enter", id(first_leaf)) + + +class TestNodeDump: + def test_dump_to_stringio(self) -> None: + node = BlankNode() + output = StringIO() + node.dump(file=output) + result = output.getvalue() + assert "" in result + + def test_dumps_returns_string(self) -> None: + node = BlankNode() + result = node.dumps() + assert isinstance(result, str) + assert "" in result + + def test_dump_nested_structure(self) -> None: + child = BlankNode() + parent = ListNode([child]) + + result = parent.dumps() + assert "" in result + assert "" in result + + +class TestStrVisitor: + def test_builds_rich_tree(self) -> None: + node = BlankNode() + visitor = _StrVisitor() + node.visit(visitor) + + assert visitor.root is not None + assert "" in str(visitor.root.label) + + def test_nested_tree(self) -> None: + child = BlankNode() + parent = ListNode([child]) + + visitor = _StrVisitor() + parent.visit(visitor) + + assert visitor.root is not None + + +class TestDocument: + def test_init_with_root(self) -> None: + root = BlankNode() + doc = Document(root) + assert doc.root is root + + def test_extension_returns_none_when_no_output_nodes(self) -> None: + root = BlankNode() + doc = Document(root) + assert doc.extension() is None + + def test_extension_returns_extension_from_output_node(self) -> None: + from io import TextIOBase + + from docc.context import Context + + class TestOutputNode(OutputNode): + @property + def children(self): + return () + + def replace_child(self, old: Node, new: Node) -> None: + pass + + @property + def extension(self) -> str: + return ".test" + + def output( + self, context: Context, destination: TextIOBase + ) -> None: + pass + + root = TestOutputNode() + doc = Document(root) + assert doc.extension() == ".test" + + +class TestExtensionVisitor: + def test_finds_extension(self) -> None: + from io import TextIOBase + + from docc.context import Context + + class TestOutputNode(OutputNode): + @property + def children(self): + return () + + def replace_child(self, old: Node, new: Node) -> None: + pass + + @property + def extension(self) -> str: + return ".html" + + def output( + self, context: Context, destination: TextIOBase + ) -> None: + pass + + root = TestOutputNode() + visitor = _ExtensionVisitor() + root.visit(visitor) + + assert visitor.extension == ".html" + + def test_returns_none_when_no_output_nodes(self) -> None: + root = BlankNode() + visitor = _ExtensionVisitor() + root.visit(visitor) + + assert visitor.extension is None + + def test_conflicting_extensions_logs_warning( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """ + When two OutputNodes have different extensions, the visitor + logs a warning and keeps the first extension. + """ + import logging + from io import TextIOBase + + from docc.context import Context + + class HtmlOutputNode(OutputNode): + @property + def children(self): + return () + + def replace_child(self, old: Node, new: Node) -> None: + pass + + @property + def extension(self) -> str: + return ".html" + + def output( + self, context: Context, destination: TextIOBase + ) -> None: + pass + + class TxtOutputNode(OutputNode): + @property + def children(self): + return () + + def replace_child(self, old: Node, new: Node) -> None: + pass + + @property + def extension(self) -> str: + return ".txt" + + def output( + self, context: Context, destination: TextIOBase + ) -> None: + pass + + root = ListNode([HtmlOutputNode(), TxtOutputNode()]) + visitor = _ExtensionVisitor() + + with caplog.at_level(logging.WARNING): + root.visit(visitor) + + # The first extension is kept + assert visitor.extension == ".html" + # A warning was logged about the conflict + assert any("extension" in r.message for r in caplog.records) + + +class ConditionalSkipVisitor(RecordingVisitor): + skip_after_first: bool + + def __init__(self, skip_after_first: bool = False) -> None: + super().__init__() + self.skip_after_first = skip_after_first + self._first_seen = False + + @override + def enter(self, node: Node) -> Visit: + self.events.append(("enter", id(node))) + if self.skip_after_first and not self._first_seen: + self._first_seen = True + return Visit.TraverseChildren + elif self.skip_after_first: + return Visit.SkipChildren + return Visit.TraverseChildren + + +class TestVisitorEdgeCases: + def test_visit_empty_list_node(self) -> None: + node = ListNode([]) + visitor = RecordingVisitor() + node.visit(visitor) + + assert visitor.events == [ + ("enter", id(node)), + ("exit", id(node)), + ] + + def test_visit_deeply_nested(self) -> None: + node: Node = BlankNode() + for _ in range(10): + node = ListNode([node]) + + visitor = RecordingVisitor() + node.visit(visitor) + + enter_count = sum(1 for e in visitor.events if e[0] == "enter") + assert enter_count == 11 + + def test_visit_wide_tree(self) -> None: + children: List[Node] = [BlankNode() for _ in range(100)] + node = ListNode(children) + + visitor = RecordingVisitor() + node.visit(visitor) + + enter_count = sum( + 1 + for e in visitor.events + if e[0] == "enter" and e != ("enter", id(node)) + ) + assert enter_count == 100 + + def test_conditional_skip(self) -> None: + grandchild = BlankNode() + child = ListNode([grandchild]) + parent = ListNode([child]) + + visitor = ConditionalSkipVisitor(skip_after_first=True) + parent.visit(visitor) + + assert ("enter", id(grandchild)) not in visitor.events + + +class ModifyingVisitor(Visitor): + def __init__(self, old: Node, new: Node) -> None: + self.old = old + self.new = new + self.stack: List[Node] = [] + + @override + def enter(self, node: Node) -> Visit: + self.stack.append(node) + return Visit.TraverseChildren + + @override + def exit(self, node: Node) -> None: + self.stack.pop() + if node == self.old and self.stack: + self.stack[-1].replace_child(self.old, self.new) + + +class SkipSpecificChildVisitor(Visitor): + """Visitor that returns SkipChildren for a specific child node.""" + + enter_calls: List[Node] + exit_calls: List[Node] + skip_target: Node + + def __init__(self, skip_target: Node) -> None: + self.enter_calls = [] + self.exit_calls = [] + self.skip_target = skip_target + + @override + def enter(self, node: Node) -> Visit: + self.enter_calls.append(node) + if node is self.skip_target: + return Visit.SkipChildren + return Visit.TraverseChildren + + @override + def exit(self, node: Node) -> None: + self.exit_calls.append(node) + + +def test_visit_skip_children_calls_exit() -> None: + grandchild = BlankNode() + skipped_child = ListNode([grandchild]) + other_child = BlankNode() + parent = ListNode([skipped_child, other_child]) + + visitor = SkipSpecificChildVisitor(skip_target=skipped_child) + parent.visit(visitor) + + # exit must be called for the child that returned SkipChildren + assert skipped_child in visitor.exit_calls + + # The grandchild should NOT have been entered (children were skipped) + assert grandchild not in visitor.enter_calls + + # Both the skipped child and the other child should have exit called + assert other_child in visitor.exit_calls + assert parent in visitor.exit_calls + + +def test_visit_replace_during_exit() -> None: + old_child = BlankNode() + new_child = BlankNode() + parent = ListNode([old_child]) + + visitor = ModifyingVisitor(old_child, new_child) + parent.visit(visitor) + + assert old_child not in parent.children + assert new_child in parent.children diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 0000000..9ad7737 --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,227 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from io import StringIO +from pathlib import Path, PurePath +from typing import Dict, Optional, Set + +import pytest + +from docc.context import Context +from docc.document import BlankNode, Document +from docc.plugins.files import ( + FileNode, + FilesBuilder, + FilesDiscover, + FileSource, +) +from docc.settings import PluginSettings, Settings +from docc.source import Source + + +@pytest.fixture +def plugin_settings(tmp_path: Path) -> PluginSettings: + settings = Settings(tmp_path, {"tool": {"docc": {}}}) + return settings.for_plugin("docc.files.discover") + + +class TestFileSource: + def test_init(self) -> None: + relative = PurePath("src/file.txt") + absolute = PurePath("/path/to/src/file.txt") + source = FileSource(relative, absolute) + + assert source.relative_path == relative + assert source.absolute_path == absolute + + def test_output_path_removes_suffix(self) -> None: + relative = PurePath("docs/readme.md") + absolute = PurePath("/path/docs/readme.md") + source = FileSource(relative, absolute) + + assert source.output_path == PurePath("docs/readme") + + def test_output_path_compound_extension(self) -> None: + relative = PurePath("static/chota.min.css") + absolute = PurePath("/path/static/chota.min.css") + source = FileSource(relative, absolute) + + assert source.output_path == PurePath("static/chota.min") + + def test_output_path_no_suffix(self) -> None: + relative = PurePath("docs/readme") + absolute = PurePath("/path/docs/readme") + source = FileSource(relative, absolute) + + assert source.output_path == PurePath("docs/readme") + + +class TestFileNode: + def test_init(self, tmp_path: Path) -> None: + file_path = tmp_path / "test.txt" + file_path.write_text("content") + + node = FileNode(file_path) + assert node.path == file_path + + def test_children_empty(self, tmp_path: Path) -> None: + file_path = tmp_path / "test.txt" + file_path.write_text("content") + + node = FileNode(file_path) + assert node.children == () + + def test_replace_child_raises(self, tmp_path: Path) -> None: + file_path = tmp_path / "test.txt" + file_path.write_text("content") + + node = FileNode(file_path) + with pytest.raises(TypeError): + node.replace_child(BlankNode(), BlankNode()) + + def test_extension(self, tmp_path: Path) -> None: + file_path = tmp_path / "test.txt" + file_path.write_text("content") + + node = FileNode(file_path) + assert node.extension == ".txt" + + def test_extension_multiple_suffixes(self, tmp_path: Path) -> None: + file_path = tmp_path / "chota.min.css" + file_path.write_text("content") + + node = FileNode(file_path) + assert node.extension == ".css" + + def test_output(self, tmp_path: Path) -> None: + file_path = tmp_path / "test.txt" + file_path.write_text("file content here") + + node = FileNode(file_path) + context = Context({}) + destination = StringIO() + + node.output(context, destination) + + assert destination.getvalue() == "file content here" + + +class TestFilesBuilder: + def test_build_processes_file_sources( + self, tmp_path: Path, plugin_settings: PluginSettings + ) -> None: + file_path = tmp_path / "test.txt" + file_path.write_text("content") + + source = FileSource(PurePath("test.txt"), file_path) + unprocessed: Set[Source] = {source} + processed: Dict[Source, Document] = {} + + builder = FilesBuilder(plugin_settings) + builder.build(unprocessed, processed) + + assert source in processed + assert len(processed) == 1 + assert len(unprocessed) == 0 + assert isinstance(processed[source].root, FileNode) + + def test_build_ignores_non_file_sources( + self, plugin_settings: PluginSettings + ) -> None: + class OtherSource(Source): + @property + def relative_path(self) -> Optional[PurePath]: + return PurePath("other.py") + + @property + def output_path(self) -> PurePath: + return PurePath("other.py") + + source = OtherSource() + unprocessed: Set[Source] = {source} + processed: Dict[Source, Document] = {} + + builder = FilesBuilder(plugin_settings) + builder.build(unprocessed, processed) + + assert source in unprocessed + assert len(processed) == 0 + + +class TestFilesDiscover: + def test_init_no_files(self, tmp_path: Path) -> None: + settings = Settings(tmp_path, {"tool": {"docc": {}}}) + plugin_settings = settings.for_plugin("docc.files.discover") + + discover = FilesDiscover(plugin_settings) + assert discover.sources == [] + + def test_init_with_files(self, tmp_path: Path) -> None: + first_file = tmp_path / "file1.txt" + first_file.write_text("content1") + second_file = tmp_path / "file2.txt" + second_file.write_text("content2") + + settings = Settings( + tmp_path, + { + "tool": { + "docc": { + "plugins": { + "docc.files.discover": { + "files": ["file1.txt", "file2.txt"] + } + } + } + } + }, + ) + plugin_settings = settings.for_plugin("docc.files.discover") + + discover = FilesDiscover(plugin_settings) + assert len(discover.sources) == 2 + + def test_discover_yields_sources(self, tmp_path: Path) -> None: + first_file = tmp_path / "file1.txt" + first_file.write_text("content") + + settings = Settings( + tmp_path, + { + "tool": { + "docc": { + "plugins": { + "docc.files.discover": {"files": ["file1.txt"]} + } + } + } + }, + ) + plugin_settings = settings.for_plugin("docc.files.discover") + + discover = FilesDiscover(plugin_settings) + sources = list(discover.discover(frozenset())) + + assert len(sources) == 1 + assert isinstance(sources[0], FileSource) + + def test_discover_empty_when_no_files(self, tmp_path: Path) -> None: + settings = Settings(tmp_path, {"tool": {"docc": {}}}) + plugin_settings = settings.for_plugin("docc.files.discover") + + discover = FilesDiscover(plugin_settings) + sources = list(discover.discover(frozenset())) + + assert sources == [] diff --git a/tests/test_html_e2e.py b/tests/test_html_e2e.py new file mode 100644 index 0000000..91be245 --- /dev/null +++ b/tests/test_html_e2e.py @@ -0,0 +1,482 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +End-to-end tests for the HTML rendering pipeline. + +These tests exercise the full docc pipeline path through HTML rendering: +discover -> build -> context -> transform -> HTML render -> file output. +""" + +from io import StringIO +from pathlib import PurePath +from typing import Optional + +from docc.context import Context +from docc.document import BlankNode, Document, ListNode +from docc.plugins.html import ( + HTML, + HTMLRoot, + HTMLTag, + HTMLTransform, + TextNode, + blank_node, + html_tag, + list_node, + references_definition, + references_reference, + render_reference, + text_node, +) +from docc.plugins.references import ( + Definition, + Index, + IndexTransform, + Reference, +) +from docc.settings import PluginSettings +from docc.source import Source, StringSource + + +class _MockSource(StringSource): + """A minimal Source implementation for testing.""" + + def __init__( + self, + output_path: Optional[PurePath] = None, + relative_path: Optional[PurePath] = None, + ) -> None: + if output_path is None: + output_path = PurePath("test.py") + if relative_path is None: + relative_path = output_path + + super().__init__("", output_path, relative_path) + + +class TestHTMLTransformProducesHTMLRoot: + """ + Test that HTMLTransform replaces a document tree with an HTMLRoot. + + This exercises the pipeline step where the document tree (containing + Definition, BlankNode, ListNode, etc.) is converted into an HTML tree + via HTMLVisitor and the entry-point-based renderer lookup. + """ + + def test_definition_with_blank_becomes_html_root( + self, plugin_settings: PluginSettings + ) -> None: + """ + A document containing a Definition wrapping a BlankNode is + transformed into an HTMLRoot. + + The Definition renderer (references_definition) internally creates + its own HTMLVisitor to render children, so this exercises the + nested visitor path through the entry point system. + """ + source = _MockSource(PurePath("docs/module.py")) + index = Index() + + definition = Definition( + identifier="my_module.MyClass", child=BlankNode() + ) + + document = Document(definition) + context = Context({Document: document, Source: source, Index: index}) + + # First apply IndexTransform to assign specifiers. + index_transform = IndexTransform(plugin_settings) + index_transform.transform(context) + assert definition.specifier == 0 + + # Then apply HTMLTransform. + html_transform = HTMLTransform(plugin_settings) + html_transform.transform(context) + + assert isinstance(document.root, HTMLRoot) + + def test_transform_skips_already_output_node( + self, plugin_settings: PluginSettings + ) -> None: + """ + If the document root is already an OutputNode (e.g. HTMLRoot), + HTMLTransform leaves it unchanged. + """ + context = Context({}) + existing_root = HTMLRoot(context) + existing_root.append(HTMLTag("div")) + document = Document(existing_root) + context = Context({Document: document}) + + transform = HTMLTransform(plugin_settings) + transform.transform(context) + + assert document.root is existing_root + + +class TestHTMLRootOutputProducesValidHTML: + """ + Test that HTMLRoot.output() renders a full HTML document string + containing the expected structural elements from the Jinja2 template. + """ + + def test_output_includes_static_css_links(self) -> None: + """The rendered HTML includes links to chota and docc CSS.""" + source = _MockSource(PurePath("docs/page.py")) + context = Context({Source: source}) + root = HTMLRoot(context) + root.append(HTMLTag("p")) + + buffer = StringIO() + root.output(context, buffer) + output = buffer.getvalue() + + assert "chota.min.css" in output + assert "docc.css" in output + + def test_output_with_extra_css(self) -> None: + """Extra CSS files configured via HTML context appear in output.""" + source = _MockSource(PurePath("index.py")) + html_config = HTML(extra_css=["custom.css"], breadcrumbs=True) + context = Context({Source: source, HTML: html_config}) + root = HTMLRoot(context) + root.append(HTMLTag("p")) + + buffer = StringIO() + root.output(context, buffer) + output = buffer.getvalue() + + assert "custom.css" in output + + def test_output_breadcrumbs_for_nested_path(self) -> None: + """Breadcrumbs are rendered for documents in nested directories.""" + source = _MockSource(PurePath("a/b/c/page.py")) + context = Context({Source: source}) + root = HTMLRoot(context) + root.append(HTMLTag("section")) + + buffer = StringIO() + root.output(context, buffer) + output = buffer.getvalue() + + assert "breadcrumbs" in output + assert "page.py" in output + + def test_output_no_breadcrumbs_when_disabled(self) -> None: + """Breadcrumbs section is absent when breadcrumbs=False.""" + source = _MockSource(PurePath("a/b/page.py")) + html_config = HTML(extra_css=[], breadcrumbs=False) + context = Context({Source: source, HTML: html_config}) + root = HTMLRoot(context) + root.append(HTMLTag("div")) + + buffer = StringIO() + root.output(context, buffer) + output = buffer.getvalue() + + assert "breadcrumbs" not in output + + def test_output_nested_html_tags_produce_markup(self) -> None: + """Nested HTMLTag structures are correctly serialized.""" + source = _MockSource(PurePath("page.py")) + context = Context({Source: source}) + root = HTMLRoot(context) + + section = HTMLTag("section", {"id": "main"}) + heading = HTMLTag("h1") + heading.append(TextNode("Title")) + section.append(heading) + paragraph = HTMLTag("p") + paragraph.append(TextNode("Body text")) + section.append(paragraph) + root.append(section) + + buffer = StringIO() + root.output(context, buffer) + output = buffer.getvalue() + + assert "" in output + assert "Title" in output + assert "

" in output + assert "Body text" in output + + +class TestDefinitionRendersWithId: + """ + Test that rendering a Definition node produces HTML with an id + attribute matching the definition's identifier and specifier. + """ + + def test_definition_blank_child_gets_id(self) -> None: + """ + Calling references_definition directly with a Definition whose + child is a BlankNode produces a span with the correct id. + """ + source = _MockSource(PurePath("docs/module.py")) + index = Index() + location = index.define(source, "my_func") + + definition = Definition( + identifier="my_func", + child=BlankNode(), + specifier=location.specifier, + ) + + context = Context({Source: source, Index: index}) + parent = HTMLRoot(context) + + result = references_definition(context, parent, definition) + # references_definition appends to parent and returns None. + assert result is None + + children = list(parent.children) + assert len(children) >= 1 + + first = children[0] + assert isinstance(first, HTMLTag) + assert first.tag_name == "span" + assert first.attributes.get("id") == "my_func:0" + + def test_definition_with_list_child_gets_id(self) -> None: + """ + A Definition wrapping a ListNode containing an HTMLTag gets + the id applied to the first rendered child element. + """ + source = _MockSource(PurePath("docs/module.py")) + index = Index() + location = index.define(source, "MyClass") + + # The child is an HTMLTag so that after HTMLVisitor renders + # the ListNode's children, the first child is already an HTMLTag. + inner_tag = HTMLTag("div", {"class": "class-def"}) + inner_tag.append(TextNode("MyClass")) + child_list = ListNode([inner_tag]) + + definition = Definition( + identifier="MyClass", + child=child_list, + specifier=location.specifier, + ) + + context = Context({Source: source, Index: index}) + parent = HTMLRoot(context) + + references_definition(context, parent, definition) + + children = list(parent.children) + assert len(children) >= 1 + + first = children[0] + assert isinstance(first, HTMLTag) + assert first.attributes.get("id") == "MyClass:0" + + def test_definition_text_child_wrapped_in_span(self) -> None: + """ + When the first rendered child is a TextNode, it gets wrapped + in a and the id is set on the span. + """ + source = _MockSource(PurePath("docs/module.py")) + index = Index() + location = index.define(source, "some_var") + + text = TextNode("some_var") + inner_tag = HTMLTag("p") + inner_tag.append(text) + child_list = ListNode([inner_tag]) + + definition = Definition( + identifier="some_var", + child=child_list, + specifier=location.specifier, + ) + + context = Context({Source: source, Index: index}) + parent = HTMLRoot(context) + + references_definition(context, parent, definition) + + children = list(parent.children) + assert len(children) >= 1 + + first = children[0] + assert isinstance(first, HTMLTag) + assert first.attributes.get("id") == "some_var:0" + + +class TestReferenceRendersAsLink: + """ + Test that rendering a Reference produces an tag linking to the + definition's location. + """ + + def test_same_file_reference_has_fragment_only(self) -> None: + """ + When the reference and definition are in the same source file, + the href is just a fragment (no file path component). + """ + source = _MockSource(PurePath("docs/module.py")) + + index = Index() + index.define(source, "local_func") + + context = Context({Source: source, Index: index}) + ref = Reference(identifier="local_func") + + anchor = render_reference(context, ref) + + assert isinstance(anchor, HTMLTag) + href = anchor.attributes.get("href", "") + assert href is not None + assert "local_func:0" in href + + def test_references_reference_appends_anchor(self) -> None: + """ + The references_reference render function appends an anchor to the + parent and returns it for child traversal when a child is present. + """ + source = _MockSource(PurePath("docs/page.py")) + def_source = _MockSource(PurePath("docs/target.py")) + + index = Index() + index.define(def_source, "func") + + context = Context({Source: source, Index: index}) + parent = HTMLRoot(context) + + child_node = HTMLTag("code") + child_node.append(TextNode("func")) + ref = Reference(identifier="func", child=child_node) + + result = references_reference(context, parent, ref) + + # When the reference has a child, the anchor is returned so + # the visitor can traverse into it. + assert isinstance(result, HTMLTag) + assert result.tag_name == "a" + assert result in parent.children + + def test_references_reference_no_child_appends_text(self) -> None: + """ + When a Reference has no meaningful child (BlankNode), the anchor + gets a TextNode with the identifier and returns None. + """ + source = _MockSource(PurePath("docs/page.py")) + def_source = _MockSource(PurePath("docs/target.py")) + + index = Index() + index.define(def_source, "some_id") + + context = Context({Source: source, Index: index}) + parent = HTMLRoot(context) + + ref = Reference(identifier="some_id") + + result = references_reference(context, parent, ref) + + assert result is None + children = list(parent.children) + assert len(children) == 1 + anchor = children[0] + assert isinstance(anchor, HTMLTag) + assert anchor.tag_name == "a" + + anchor_children = list(anchor.children) + assert len(anchor_children) == 1 + text_child = anchor_children[0] + assert isinstance(text_child, TextNode) + assert text_child._value == "some_id" + + +class TestFullPipelineHTMLOutput: + """ + Integration test: build a document tree, apply transforms, render + to an HTML string, and verify the output contains all expected + structural and content elements. + """ + + def test_definition_to_html_output( + self, plugin_settings: PluginSettings + ) -> None: + """ + Full pipeline: Definition with BlankNode -> IndexTransform -> + HTMLTransform -> HTMLRoot.output() -> HTML string. + """ + source = _MockSource(PurePath("docs/example.py")) + index = Index() + + definition = Definition( + identifier="example.hello", + child=BlankNode(), + ) + tree = ListNode([definition]) + document = Document(tree) + + context = Context({Document: document, Source: source, Index: index}) + + # Step 1: IndexTransform assigns specifiers. + index_transform = IndexTransform(plugin_settings) + index_transform.transform(context) + assert definition.specifier == 0 + + # Step 2: HTMLTransform converts the tree to HTML. + html_transform = HTMLTransform(plugin_settings) + html_transform.transform(context) + root = document.root + assert isinstance(root, HTMLRoot) + + # Step 3: Output the HTML to a string. + buffer = StringIO() + root.output(context, buffer) + output = buffer.getvalue() + + # Verify full HTML document structure. + assert "" in output + assert "" in output + assert "" in output + assert "" in output + assert "" in output + assert " None: + """ + Test the individual render helper functions (blank_node, + list_node, html_tag, text_node) that form the foundation of + the HTML rendering pipeline. + """ + context = Context({}) + root = HTMLRoot(context) + + # blank_node returns None and does not modify parent. + assert blank_node(context, root, BlankNode()) is None + assert len(list(root.children)) == 0 + + # list_node returns the parent (for child traversal). + ln = ListNode() + assert list_node(context, root, ln) is root + + # html_tag appends the tag and returns None. + tag = HTMLTag("div", {"class": "test"}) + assert html_tag(context, root, tag) is None + assert tag in root.children + + # text_node appends the text and returns None. + tn = TextNode("hello") + assert text_node(context, root, tn) is None + assert tn in root.children diff --git a/tests/test_html_nodes.py b/tests/test_html_nodes.py new file mode 100644 index 0000000..e8cd8bd --- /dev/null +++ b/tests/test_html_nodes.py @@ -0,0 +1,334 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Tests for HTML node types: TextNode, HTMLTag, HTMLRoot.""" + +import pytest + +from docc.context import Context +from docc.document import BlankNode +from docc.plugins.html import HTMLRoot, HTMLTag, TextNode + +# --------------------------------------------------------------------------- +# TextNode +# --------------------------------------------------------------------------- + + +def test_text_node_init() -> None: + node = TextNode("hello") + assert node._value == "hello" + + +def test_text_node_children_empty() -> None: + node = TextNode("test") + assert tuple(node.children) == () + + +def test_text_node_replace_child_raises() -> None: + node = TextNode("test") + with pytest.raises(TypeError, match="text nodes have no children"): + node.replace_child(BlankNode(), BlankNode()) + + +def test_text_node_repr() -> None: + node = TextNode("hello world") + assert repr(node) == "'hello world'" + + +def test_text_node_repr_double_quote() -> None: + node = TextNode('say "hello"') + assert repr(node) == "'say \"hello\"'" + + +def test_text_node_repr_single_quote() -> None: + node = TextNode("it's") + assert repr(node) == '"it\'s"' + + +def test_text_node_html_entities() -> None: + text = TextNode("<script>") + assert text._value == "<script>" + + +def test_text_node_unicode() -> None: + text = TextNode("Hello \u00e9\u00e8\u00ea") + assert "\u00e9" in text._value + + +def test_text_node_special_characters() -> None: + text = TextNode("") + assert text._value == "" + + +# --------------------------------------------------------------------------- +# HTMLTag +# --------------------------------------------------------------------------- + + +def test_html_tag_init() -> None: + tag = HTMLTag("div") + assert tag.tag_name == "div" + assert tag.attributes == {} + assert list(tag.children) == [] + + +def test_html_tag_init_with_attributes() -> None: + tag = HTMLTag("a", {"href": "/test", "class": "link"}) + assert tag.attributes["href"] == "/test" + assert tag.attributes["class"] == "link" + + +def test_html_tag_append_child() -> None: + parent = HTMLTag("div") + existing = HTMLTag("p") + parent.append(existing) + child = HTMLTag("span") + parent.append(child) + children = list(parent.children) + assert children[-1] is child + + +def test_html_tag_append_text() -> None: + tag = HTMLTag("p") + existing = HTMLTag("span") + tag.append(existing) + text = TextNode("hello") + tag.append(text) + children = list(tag.children) + assert children[-1] is text + + +def test_html_tag_replace_child() -> None: + parent = HTMLTag("div") + before = HTMLTag("header") + old = HTMLTag("span") + after = HTMLTag("footer") + parent.append(before) + parent.append(old) + parent.append(after) + + parent.replace_child(old, new := HTMLTag("p")) + children = list(parent.children) + assert old not in children + assert children.index(new) == 1 + + +def test_html_tag_replace_child_duplicate() -> None: + parent = HTMLTag("div") + child = HTMLTag("a") + parent.append(child) + parent.append(child) + + new = HTMLTag("em") + parent.replace_child(child, new) + + children = list(parent.children) + assert child not in children + assert children.count(new) == 2 + + +def test_html_tag_multiple_children() -> None: + parent = HTMLTag("div") + first_child = HTMLTag("span") + second_child = HTMLTag("p") + text = TextNode("text") + + parent.append(first_child) + parent.append(second_child) + parent.append(text) + + assert len(list(parent.children)) == 3 + + +def test_html_tag_repr() -> None: + tag = HTMLTag("div") + assert repr(tag) == "

" + + +def test_html_tag_repr_with_attributes() -> None: + tag = HTMLTag("a", {"href": "/test"}) + result = repr(tag) + assert " None: + tag = HTMLTag("div", {"data-value": 'test"quote'}) + result = repr(tag) + assert """ in result + + +def test_html_tag_repr_none_attribute() -> None: + tag = HTMLTag("input", {"disabled": None}) + result = repr(tag) + assert "disabled" in result + + +def test_html_tag_repr_none_and_string_attributes() -> None: + tag = HTMLTag("input", {"disabled": None, "type": "text"}) + result = repr(tag) + assert "disabled" in result + assert 'type="text"' in result + + +def test_html_tag_repr_empty_attribute() -> None: + tag = HTMLTag("div", {"data-empty": ""}) + result = repr(tag) + assert 'data-empty=""' in result + + +def test_html_tag_repr_multiple_attributes() -> None: + tag = HTMLTag( + "div", + {"id": "main", "class": "container", "data-value": "test"}, + ) + result = repr(tag) + assert "id=" in result + assert "class=" in result + assert "data-value=" in result + + +# --------------------------------------------------------------------------- +# HTMLRoot +# --------------------------------------------------------------------------- + + +def test_html_root_init() -> None: + context = Context({}) + root = HTMLRoot(context) + assert list(root.children) == [] + + +def test_html_root_append() -> None: + context = Context({}) + root = HTMLRoot(context) + tag = HTMLTag("div") + root.append(tag) + assert tag in root.children + + +def test_html_root_replace_child() -> None: + context = Context({}) + root = HTMLRoot(context) + old = HTMLTag("div") + new = HTMLTag("span") + root.append(old) + + root.replace_child(old, new) + assert old not in root.children + assert new in root.children + + +def test_html_root_extension() -> None: + context = Context({}) + root = HTMLRoot(context) + assert root.extension == ".html" + + +def test_html_root_multiple_children() -> None: + context = Context({}) + root = HTMLRoot(context) + + first_tag = HTMLTag("div") + second_tag = HTMLTag("span") + text = TextNode("hello") + + root.append(first_tag) + root.append(second_tag) + root.append(text) + + children = list(root.children) + assert len(children) == 3 + assert children[0] is first_tag + assert children[1] is second_tag + assert children[2] is text + + +# --------------------------------------------------------------------------- +# HTMLTag._to_element +# --------------------------------------------------------------------------- + + +def test_html_tag_to_element_simple() -> None: + tag = HTMLTag("div") + element = tag._to_element() + assert element.tag == "div" + + +def test_html_tag_to_element_children() -> None: + parent = HTMLTag("div") + child = HTMLTag("span") + parent.append(child) + + element = parent._to_element() + assert element.tag == "div" + children = list(element) + assert len(children) == 1 + assert children[0].tag == "span" + + +def test_html_tag_to_element_text() -> None: + tag = HTMLTag("p") + tag.append(TextNode("hello")) + + element = tag._to_element() + assert element.tag == "p" + assert element.text == "hello" + + +def test_html_tag_to_element_nested_text() -> None: + parent = HTMLTag("p") + parent.append(TextNode("start ")) + child = HTMLTag("strong") + child.append(TextNode("bold")) + parent.append(child) + parent.append(TextNode(" end")) + + element = parent._to_element() + assert element.tag == "p" + assert element.text == "start " + children = list(element) + assert len(children) == 1 + assert children[0].tag == "strong" + assert children[0].text == "bold" + + +def test_html_tag_to_element_deep() -> None: + outer = HTMLTag("div") + middle = HTMLTag("section") + inner = HTMLTag("article") + inner.append(TextNode("content")) + middle.append(inner) + outer.append(middle) + + element = outer._to_element() + assert element.tag == "div" + section = list(element)[0] + assert section.tag == "section" + article = list(section)[0] + assert article.tag == "article" + assert article.text == "content" + + +def test_html_tag_to_element_with_attributes() -> None: + tag = HTMLTag("a", {"href": "/link", "class": "nav", "id": "main"}) + tag.append(TextNode("click")) + + element = tag._to_element() + assert element.tag == "a" + assert element.attrib["href"] == "/link" + assert element.attrib["class"] == "nav" + assert element.attrib["id"] == "main" + assert element.text == "click" diff --git a/tests/test_html_parser.py b/tests/test_html_parser.py new file mode 100644 index 0000000..e5dac4e --- /dev/null +++ b/tests/test_html_parser.py @@ -0,0 +1,330 @@ +# Copyright (C) 2026 Ethereum Foundation +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Tests for HTMLParser, _ElementTreeVisitor, and _make_relative.""" + +from pathlib import PurePath + +import pytest + +from docc.context import Context +from docc.document import BlankNode +from docc.plugins.html import ( + HTMLParser, + HTMLTag, + TextNode, + _ElementTreeVisitor, + _make_relative, +) + +# --------------------------------------------------------------------------- +# HTMLParser +# --------------------------------------------------------------------------- + + +def test_parser_simple_tag() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("
hello
") + + children = list(parser.root.children) + assert len(children) == 1 + child = children[0] + assert isinstance(child, HTMLTag) + assert child.tag_name == "div" + + +def test_parser_nested_tags() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("
text
") + + children = list(parser.root.children) + assert len(children) == 1 + div = children[0] + assert isinstance(div, HTMLTag) + + span = list(div.children)[0] + assert isinstance(span, HTMLTag) + assert span.tag_name == "span" + + +def test_parser_with_attributes() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed('
click') + + children = list(parser.root.children) + anchor = children[0] + assert isinstance(anchor, HTMLTag) + assert anchor.attributes["href"] == "/test" + assert anchor.attributes["class"] == "link" + + +def test_parser_text_content() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("

hello world

") + + children = list(parser.root.children) + p = children[0] + assert isinstance(p, HTMLTag) + text_children = list(p.children) + assert len(text_children) == 1 + text_child = text_children[0] + assert isinstance(text_child, TextNode) + assert text_child._value == "hello world" + + +def test_parser_multiple_elements() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("

one

two

") + + children = list(parser.root.children) + assert len(children) == 2 + + +def test_parser_empty_string() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("") + + assert list(parser.root.children) == [] + + +def test_parser_self_closing_tag() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("
") + + children = list(parser.root.children) + assert len(children) == 1 + assert isinstance(children[0], HTMLTag) + + +def test_parser_self_closing_tag_with_slash() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("
") + + children = list(parser.root.children) + assert len(children) == 1 + child = children[0] + assert isinstance(child, HTMLTag) + assert child.tag_name == "br" + + +def test_parser_boolean_attribute() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed('') + + children = list(parser.root.children) + input_elem = children[0] + assert isinstance(input_elem, HTMLTag) + assert "disabled" in input_elem.attributes + + +def test_parser_mixed_content() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("

Text bold more text

") + + children = list(parser.root.children) + p = children[0] + assert isinstance(p, HTMLTag) + p_children = list(p.children) + assert len(p_children) == 3 + + +def test_parser_deeply_nested() -> None: + context = Context({}) + parser = HTMLParser(context) + html = "
deep
" + parser.feed(html) + + children = list(parser.root.children) + assert len(children) == 1 + + +def test_parser_special_characters() -> None: + context = Context({}) + parser = HTMLParser(context) + parser.feed("

<script>

") + + children = list(parser.root.children) + p = children[0] + assert isinstance(p, HTMLTag) + text_children = list(p.children) + text_child = text_children[0] + assert isinstance(text_child, TextNode) + assert "