diff --git a/setup.cfg b/setup.cfg index 409974a..5158a89 100644 --- a/setup.cfg +++ b/setup.cfg @@ -61,6 +61,8 @@ docc.plugins = docc.mistletoe.reference = docc.plugins.mistletoe:ReferenceTransform docc.listing.discover = docc.plugins.listing:ListingDiscover docc.listing.build = docc.plugins.listing:ListingBuilder + docc.listing.context = docc.plugins.listing:ListingContext + docc.listing.transform = docc.plugins.listing:ListingTransform docc.resources.build = docc.plugins.resources:ResourceBuilder docc.files.build = docc.plugins.files:FilesBuilder docc.files.discover = docc.plugins.files:FilesDiscover diff --git a/src/docc/__init__.py b/src/docc/__init__.py index fa8699b..d79c8eb 100644 --- a/src/docc/__init__.py +++ b/src/docc/__init__.py @@ -17,5 +17,5 @@ The documentation compiler. """ -__version__ = "0.4.1" +__version__ = "0.5.0" "Current version of docc" diff --git a/src/docc/plugins/listing/__init__.py b/src/docc/plugins/listing/__init__.py index 1aeb808..b421194 100644 --- a/src/docc/plugins/listing/__init__.py +++ b/src/docc/plugins/listing/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 Ethereum Foundation +# Copyright (C) 2023,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 @@ -18,19 +18,36 @@ """ from abc import ABC, abstractmethod +from collections import defaultdict +from collections.abc import Iterable from os.path import commonpath from pathlib import PurePath -from typing import Dict, Final, FrozenSet, Iterator, Set, Tuple +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Final, + FrozenSet, + Iterator, + Set, + Tuple, + Type, + Union, +) + +if TYPE_CHECKING: + from _typeshed import SupportsDunderGT, SupportsDunderLT from jinja2 import Environment, PackageLoader, select_autoescape from docc.build import Builder -from docc.context import Context +from docc.context import Context, Provider from docc.discover import Discover, T from docc.document import Document, Node from docc.plugins import html from docc.settings import PluginSettings from docc.source import Source +from docc.transform import Transform class Listable(ABC): @@ -38,6 +55,27 @@ class Listable(ABC): Mixin to change visibility of a Source in a directory listing. """ + @staticmethod + def _sorting_key( + thing: object, + ) -> Union["SupportsDunderGT[Any]", "SupportsDunderLT[Any]"]: + if isinstance(thing, Listable): + return thing.listing_order_key() + elif isinstance(thing, Source): + path = thing.relative_path or thing.output_path + return (True, path, None) + return (True, None, thing) + + @staticmethod + def _show_source(source: Source) -> bool: + if isinstance(source, Listable): + if not source.show_in_listing: + return False + elif not source.relative_path: + return False + + return True + @property @abstractmethod def show_in_listing(self) -> bool: @@ -46,6 +84,24 @@ def show_in_listing(self) -> bool: """ raise NotImplementedError() + @property + def is_leaf(self) -> bool: + """ + `True` if this `Source` cannot contain child sources (eg. a file). + """ + return True + + def listing_order_key( + self, + ) -> Union["SupportsDunderGT[Any]", "SupportsDunderLT[Any]"]: + """ + Key to use when sorting instances while rendering. + """ + if isinstance(self, Source): + path = self.relative_path or self.output_path + return (self.is_leaf, path, None) + return (self.is_leaf, None, self) + class ListingDiscover(Discover): """ @@ -62,13 +118,10 @@ def discover(self, known: FrozenSet[T]) -> Iterator["ListingSource"]: listings = {} for source in known: - path = source.relative_path - if isinstance(source, Listable): - if not source.show_in_listing: - continue - elif not path: + if not Listable._show_source(source): continue + path = source.relative_path if not path: path = source.output_path @@ -84,7 +137,73 @@ def discover(self, known: FrozenSet[T]) -> Iterator["ListingSource"]: source = listing -class ListingSource(Source): +class Listing: + """ + Tracks listable [`Source`]s. + + [`Source`]: ref:docc.source.Source + """ + + sources: Final[Dict[PurePath, Set[Source]]] + + def __init__(self) -> None: + self.sources = defaultdict(set) + + def add_source(self, source: Source) -> None: + """ + Register a source. + """ + path = source.relative_path or source.output_path + self.sources[path.parent].add(source) + + def descendants(self, source: Source) -> Iterable[Source]: + """ + All children of the given source. + """ + source_path = source.relative_path or source.output_path + return self.sources[source_path] + + def siblings(self, source: Source) -> Iterable[Source]: + """ + All sources with the same parent as the given source. + """ + source_path = source.relative_path or source.output_path + return self.sources[source_path.parent] + + +class ListingContext(Provider[Listing]): + """ + Injects a [`Listing`] instance into the [`Context`]. + + [`Listing`]: ref:docc.plugins.listing.Listing + [`Context`]: ref:docc.context.Context + """ + + listing: Listing + + def __init__(self, config: PluginSettings) -> None: + super().__init__(config) + self.listing = Listing() + + @classmethod + def provides(class_) -> Type[Listing]: + """ + Return the type used as a key in the [`Context`]. + + [`Context`]: ref:docc.context.Context + """ + return Listing + + def provide(self) -> Listing: + """ + Return the object to add to the [`Context`]. + + [`Context`]: ref:docc.context.Context + """ + return self.listing + + +class ListingSource(Source, Listable): """ A synthetic source that describes the contents of a directory. """ @@ -93,6 +212,9 @@ class ListingSource(Source): _output_path: Final[PurePath] sources: Final[Set[Source]] + show_in_listing: bool = True + is_leaf: bool = False + def __init__( self, relative_path: PurePath, @@ -142,7 +264,7 @@ def build( unprocessed -= to_process for source in to_process: - processed[source] = Document(ListingNode(source.sources)) + processed[source] = Document(ListingNode(leaf=False)) class ListingNode(Node): @@ -150,10 +272,10 @@ class ListingNode(Node): A node representing a directory listing. """ - sources: Final[Set[Source]] + leaf: bool - def __init__(self, sources: Set[Source]) -> None: - self.sources = sources + def __init__(self, leaf: bool) -> None: + self.leaf = leaf @property def children(self) -> Tuple[()]: @@ -169,6 +291,29 @@ def replace_child(self, old: Node, new: Node) -> None: raise TypeError() +class ListingTransform(Transform): + """ + Collect [`Source`]s and insert them into the [`Listing`] context. + + [`Source`]: ref:docc.source.Source + [`Listing`]: ref:docc.plugins.listing.Listing + """ + + def __init__(self, config: PluginSettings) -> None: + pass + + def transform(self, context: Context) -> None: + """ + Apply the transformation to the given document. + """ + source = context[Source] + if not Listable._show_source(source): + return + + listing = context[Listing] + listing.add_source(source) + + def render_html( context: object, parent: object, @@ -181,10 +326,17 @@ def render_html( assert isinstance(parent, (html.HTMLRoot, html.HTMLTag)) assert isinstance(node, ListingNode) + if node.leaf: + sources = context[Listing].siblings(context[Source]) + else: + sources = context[Listing].descendants(context[Source]) + + sources = sorted(sources, key=Listable._sorting_key) + output_path = context[Source].output_path entries = [] - for source in node.sources: + for source in sources: entry_path = source.output_path if output_path == entry_path: @@ -202,10 +354,16 @@ def render_html( + ".html" ) # TODO: Don't hardcode extension. + active = source is context[Source] path = source.relative_path or source.output_path - entries.append((path, relative_path)) - entries.sort() + if node.leaf: + path = path.name + + if isinstance(source, Listable) and not source.is_leaf: + path = str(path) + "/" + + entries.append((path, relative_path, active)) env = Environment( loader=PackageLoader("docc.plugins.listing"), diff --git a/src/docc/plugins/listing/templates/listing.html b/src/docc/plugins/listing/templates/listing.html index 47e8587..ece142c 100644 --- a/src/docc/plugins/listing/templates/listing.html +++ b/src/docc/plugins/listing/templates/listing.html @@ -1,5 +1,5 @@ {# - # Copyright (C) 2023 Ethereum Foundation + # Copyright (C) 2023,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 @@ -16,8 +16,14 @@ -#} diff --git a/src/docc/plugins/python/cst.py b/src/docc/plugins/python/cst.py index ae38786..7f7ee20 100644 --- a/src/docc/plugins/python/cst.py +++ b/src/docc/plugins/python/cst.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022-2023 Ethereum Foundation +# Copyright (C) 2022-2023,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 @@ -48,6 +48,7 @@ from docc.context import Context from docc.discover import Discover, T from docc.document import BlankNode, Document, ListNode, Node, Visit, Visitor +from docc.plugins.listing import ListingNode from docc.plugins.references import Definition, Reference from docc.plugins.verbatim import Fragment, Pos, Verbatim from docc.settings import PluginSettings @@ -497,6 +498,7 @@ def push_new(self, node: Node) -> None: def enter_module(self, node: CstNode, cst_node: cst.Module) -> Visit: assert 0 == len(self.new_stack) module = nodes.Module() + module.listing = ListingNode(leaf=True) names = sorted(node.names) diff --git a/src/docc/plugins/python/nodes.py b/src/docc/plugins/python/nodes.py index e92c923..f8faed5 100644 --- a/src/docc/plugins/python/nodes.py +++ b/src/docc/plugins/python/nodes.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022-2023 Ethereum Foundation +# Copyright (C) 2022-2023,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 @@ -71,6 +71,7 @@ class Module(PythonNode, Searchable): A Python module. """ + listing: Node = dataclasses.field(default_factory=BlankNode) name: Node = dataclasses.field(default_factory=BlankNode) docstring: Node = dataclasses.field(default_factory=BlankNode) members: Node = dataclasses.field(default_factory=ListNode) diff --git a/src/docc/plugins/python/templates/html/module.html b/src/docc/plugins/python/templates/html/module.html index f430ec0..34df4c4 100644 --- a/src/docc/plugins/python/templates/html/module.html +++ b/src/docc/plugins/python/templates/html/module.html @@ -1,5 +1,5 @@ {# - # Copyright (C) 2022-2023 Ethereum Foundation + # Copyright (C) 2022-2023,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 @@ -35,9 +35,17 @@

{{ node.name|html }}

diff --git a/src/docc/settings.py b/src/docc/settings.py index 3cc34d6..25da70c 100644 --- a/src/docc/settings.py +++ b/src/docc/settings.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022-2024 Ethereum Foundation +# Copyright (C) 2022-2024,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 @@ -180,6 +180,7 @@ def context(self) -> Sequence[str]: context = self._settings["context"] except KeyError: context = [ + "docc.listing.context", "docc.references.context", "docc.search.context", "docc.html.context", @@ -247,6 +248,7 @@ def transform(self) -> Sequence[str]: transform = self._settings["transform"] except KeyError: transform = [ + "docc.listing.transform", "docc.python.transform", "docc.mistletoe.transform", "docc.mistletoe.reference", diff --git a/whitelist.txt b/whitelist.txt index 96390b2..eb291a8 100644 --- a/whitelist.txt +++ b/whitelist.txt @@ -12,6 +12,7 @@ delitem docc commonpath dasherize +dunder endtag entityref eq @@ -69,5 +70,6 @@ tostring traverser traversable typeddict +typeshed unresolve urlunsplit