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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions src/docc/plugins/html/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@
Dict,
Final,
FrozenSet,
Generic,
Iterable,
Iterator,
List,
Optional,
Sequence,
Tuple,
Type,
TypeVar,
Union,
)
from urllib.parse import urlunsplit
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 5 additions & 32 deletions src/docc/plugins/mistletoe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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,
}


Expand Down
23 changes: 21 additions & 2 deletions src/docc/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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
22 changes: 20 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Comment thread
SamWilsn marked this conversation as resolved.

@override
def exit(self, node: Node) -> None:
pass
82 changes: 82 additions & 0 deletions tests/test_build_discover.py
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

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)
Loading
Loading