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
17 changes: 17 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,20 @@ 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"
testpaths = ["tests"]

[tool.coverage.run]
source = ["src/docc"]
branch = true

[tool.coverage.report]
fail_under = 80
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
9 changes: 6 additions & 3 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,17 @@ docc =
py.typed

docc.plugins.html =
templates/**
templates/*.html
static/docc.css
static/search.js
static/chota/dist/chota.min.css
static/fuse/dist/fuse.min.js

docc.plugins.listing =
templates/**
templates/*.html

docc.plugins.python =
templates/**
templates/html/*.html

[options.entry_points]
console_scripts =
Expand Down Expand Up @@ -110,6 +110,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
57 changes: 42 additions & 15 deletions src/docc/plugins/html/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,43 @@
else:
from importlib.metadata import EntryPoint, entry_points

_LOADED_RENDERERS: Dict[Type[Node], Callable[..., object]] = {}


# Module-level cache for HTML renderer entry points
_HTML_ENTRY_POINTS: Optional[Dict[str, EntryPoint]] = None


def _get_html_entry_points() -> Dict[str, EntryPoint]:
"""Get cached HTML renderer entry points."""
global _HTML_ENTRY_POINTS
if _HTML_ENTRY_POINTS is None:
found = entry_points(group="docc.plugins.html")
_HTML_ENTRY_POINTS = {entry.name: entry for entry in found}
return _HTML_ENTRY_POINTS


# Module-level cache for Jinja2 environments
_JINJA_ENVS: Dict[str, Environment] = {}


def _get_jinja_env(
package: str, with_reference_extension: bool = False
) -> Environment:
"""Get cached Jinja2 environment for a package."""
cache_key = f"{package}:{with_reference_extension}"
if cache_key not in _JINJA_ENVS:
extensions = [_ReferenceExtension] if with_reference_extension else []
env = Environment(
extensions=extensions,
loader=PackageLoader(package),
autoescape=select_autoescape(),
)
env.filters["html"] = _html_filter
env.filters["find"] = _find_filter
_JINJA_ENVS[cache_key] = env
return _JINJA_ENVS[cache_key]


RenderResult = Optional[Union["HTMLTag", "HTMLRoot"]]
"""
Expand Down Expand Up @@ -315,10 +352,8 @@ def output(self, context: Context, destination: TextIOBase) -> None:
markup = ET.tostring(element, encoding="unicode", method="html")
rendered.write(markup)

env = Environment(
extensions=[_ReferenceExtension],
loader=PackageLoader("docc.plugins.html"),
autoescape=select_autoescape(),
env = _get_jinja_env(
"docc.plugins.html", with_reference_extension=True
)
template = env.get_template("base.html")
body = rendered.getvalue()
Expand Down Expand Up @@ -424,12 +459,10 @@ class HTMLVisitor(Visitor):
context: Context

def __init__(self, context: Context) -> None:
# Discover render functions.
found = entry_points(group="docc.plugins.html")
self.entry_points = {entry.name: entry for entry in found}
self.entry_points = _get_html_entry_points()
self.root = HTMLRoot(context)
self.stack = [self.root]
self.renderers = {}
self.renderers = _LOADED_RENDERERS
self.context = context

def _renderer(self, node: Node) -> Callable[..., object]:
Expand Down Expand Up @@ -740,13 +773,7 @@ def render_template(
Render a template as a child of the given parent.
"""
static_path = _static_path_from(context)
env = Environment(
extensions=[_ReferenceExtension],
loader=PackageLoader(package),
autoescape=select_autoescape(),
)
env.filters["html"] = _html_filter
env.filters["find"] = _find_filter
env = _get_jinja_env(package, with_reference_extension=True)
template = env.get_template(template_name)
parser = HTMLParser(context)
parser.feed(
Expand Down
21 changes: 16 additions & 5 deletions src/docc/plugins/listing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from abc import ABC, abstractmethod
from os.path import commonpath
from pathlib import PurePath
from typing import Dict, Final, FrozenSet, Iterator, Set, Tuple
from typing import Dict, Final, FrozenSet, Iterator, Optional, Set, Tuple

from jinja2 import Environment, PackageLoader, select_autoescape

Expand All @@ -32,6 +32,20 @@
from docc.settings import PluginSettings
from docc.source import Source

# Module-level cache for Jinja2 environment
_LISTING_ENV: Optional[Environment] = None


def _get_listing_env() -> Environment:
"""Get cached Jinja2 environment for listing templates."""
global _LISTING_ENV
if _LISTING_ENV is None:
_LISTING_ENV = Environment(
loader=PackageLoader("docc.plugins.listing"),
autoescape=select_autoescape(),
)
return _LISTING_ENV


class Listable(ABC):
"""
Expand Down Expand Up @@ -207,10 +221,7 @@ def render_html(

entries.sort()

env = Environment(
loader=PackageLoader("docc.plugins.listing"),
autoescape=select_autoescape(),
)
env = _get_listing_env()
template = env.get_template("listing.html")
parser = html.HTMLParser(context)
parser.feed(template.render(context=context, entries=entries))
Expand Down
18 changes: 15 additions & 3 deletions src/docc/plugins/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,27 @@

import sys
from inspect import isabstract
from typing import Callable, Dict, Type, TypeVar
from typing import Callable, Dict, Optional, Type, TypeVar

if sys.version_info < (3, 10):
from importlib_metadata import EntryPoint, entry_points
else:
from importlib.metadata import EntryPoint, entry_points


# Module-level cache for entry_points() to avoid repeated look-ups
_PLUGIN_ENTRY_POINTS: Optional[Dict[str, EntryPoint]] = None


def _get_plugin_entry_points() -> Dict[str, EntryPoint]:
"""Get cached plugin entry points, loading on first access."""
global _PLUGIN_ENTRY_POINTS
if _PLUGIN_ENTRY_POINTS is None:
found = set(entry_points(group="docc.plugins"))
_PLUGIN_ENTRY_POINTS = {entry.name: entry for entry in found}
return _PLUGIN_ENTRY_POINTS


class PluginError(Exception):
"""
An error encountered while loading a plugin.
Expand All @@ -47,8 +60,7 @@ def __init__(self) -> None:
"""
Create an instance and populate it with the discovered plugins.
"""
found = set(entry_points(group="docc.plugins"))
self.entry_points = {entry.name: entry for entry in found}
self.entry_points = _get_plugin_entry_points()

def load(self, base: Type[L], name: str) -> Callable[..., L]:
"""
Expand Down
27 changes: 24 additions & 3 deletions src/docc/plugins/python/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,16 @@
import dataclasses
import typing
from dataclasses import dataclass, fields
from typing import Iterable, Literal, Optional, Sequence, Union
from typing import (
Any,
ClassVar,
Dict,
Iterable,
Literal,
Optional,
Sequence,
Union,
)

from docc.document import BlankNode, ListNode, Node, Visit, Visitor
from docc.plugins.search import Content, Searchable
Expand All @@ -31,12 +40,24 @@ class PythonNode(Node):
Base implementation of Node operations for Python nodes.
"""

# Class-level cache for dataclass fields (populated lazily)
_fields_cache: ClassVar[
Dict[type[Any], tuple[dataclasses.Field[Any], ...]]
] = {}

@classmethod
def _get_fields(cls) -> tuple[dataclasses.Field[Any], ...]:
"""Get cached dataclass fields for this class."""
if cls not in cls._fields_cache:
cls._fields_cache[cls] = tuple(fields(cls))
return cls._fields_cache[cls]

@property
def children(self) -> Iterable[Node]:
"""
Child nodes belonging to this node.
"""
for field in fields(self):
for field in self._get_fields():
value = getattr(self, field.name)

if field.type == Node:
Expand All @@ -51,7 +72,7 @@ def replace_child(self, old: Node, new: Node) -> None:
"""
Replace the old node with the given new node.
"""
for field in fields(self):
for field in self._get_fields():
value = getattr(self, field.name)
if value == old:
assert isinstance(new, field.type)
Expand Down
Loading