diff --git a/docs/_quarto.yml b/docs/_quarto.yml index 88cb53b..5306df8 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -90,9 +90,11 @@ quartodoc: dir: api package: quartodoc render_interlinks: true + repo_url: https://github.com/machow/quartodoc renderer: style: markdown table_style: description-list + show_source_link: true sidebar: "api/_sidebar.yml" css: "api/_styles-quartodoc.css" sections: diff --git a/quartodoc/autosummary.py b/quartodoc/autosummary.py index c6bea4e..aa749be 100644 --- a/quartodoc/autosummary.py +++ b/quartodoc/autosummary.py @@ -18,6 +18,7 @@ from . import layout from .parsers import get_parser_defaults from .renderers import Renderer +from .repo_info import RepoInfo from .validation import fmt_all from ._pydantic_compat import ValidationError from .pandoc.blocks import Blocks, Header @@ -441,6 +442,9 @@ class Builder: render_interlinks: Whether to render interlinks syntax inside documented objects. Note that the interlinks filter is required to generate the links in quarto. + repo_url: + URL for the source repository. This is used to generate links from documentation + to source code. parser: Docstring parser to use. This correspond to different docstring styles, and can be one of "google", "sphinx", and "numpy". Defaults to "numpy". @@ -494,6 +498,7 @@ def __init__( dynamic: bool | None = None, parser="numpy", render_interlinks: bool = False, + repo_url: str | None = None, _fast_inventory=False, ): self.layout = self.load_layout( @@ -507,12 +512,17 @@ def __init__( self.sidebar = sidebar self.css = css self.parser = parser + self.repo_url = repo_url self.renderer = Renderer.from_config(renderer) if render_interlinks: # this is a top-level option, but lives on the renderer # so we just manually set it there for now. self.renderer.render_interlinks = render_interlinks + if repo_url: + # also a top-level option set on renderer + print("SETTING REPOINFO") + self.renderer.repo_info = RepoInfo.from_link(repo_url) if out_index is not None: self.out_index = out_index diff --git a/quartodoc/layout.py b/quartodoc/layout.py index 43cdf8e..12d8fb7 100644 --- a/quartodoc/layout.py +++ b/quartodoc/layout.py @@ -267,6 +267,8 @@ class Auto(AutoOptions): (Not implemented). A list of members to include. exclude: (Not implemented). A list of members to exclude. + show_source_link: + Whether to show a link to item source code. dynamic: Whether to dynamically load docstring. By default docstrings are loaded using static analysis. dynamic may be a string pointing to another object, diff --git a/quartodoc/renderers/md_renderer.py b/quartodoc/renderers/md_renderer.py index 583071e..2461faa 100644 --- a/quartodoc/renderers/md_renderer.py +++ b/quartodoc/renderers/md_renderer.py @@ -15,6 +15,7 @@ from quartodoc import layout from quartodoc.pandoc.blocks import DefinitionList from quartodoc.pandoc.inlines import Span, Strong, Attr, Code, Inlines +from quartodoc.repo_info import RepoInfo from .base import Renderer, escape, sanitize, convert_rst_link_to_md @@ -124,19 +125,22 @@ def __init__( header_level: int = 1, show_signature: bool = True, show_signature_annotations: bool = False, + show_source_link: bool = False, display_name: str = "relative", hook_pre=None, render_interlinks=False, - # table_style="description-list", table_style="table", + repo_info: "RepoInfo | None" = None, ): self.header_level = header_level self.show_signature = show_signature self.show_signature_annotations = show_signature_annotations + self.show_source_link = show_source_link self.display_name = display_name self.hook_pre = hook_pre self.render_interlinks = render_interlinks self.table_style = table_style + self.repo_info = repo_info self.crnt_header_level = self.header_level @@ -424,8 +428,13 @@ def render(self, el: Union[layout.DocClass, layout.DocModule]): [self.render(x) for x in raw_meths if isinstance(x, layout.Doc)] ) - str_sig = self.signature(el) - sig_part = [str_sig] if self.show_signature else [] + sig_part: list[str] = [] + + if self.show_signature: + sig_part.append(self.signature(el)) + + if self.show_source_link: + sig_part.append(self.source_link(el.obj)) with self._increment_header(): body = self.render(el.obj) @@ -439,6 +448,7 @@ def render(self, el: Union[layout.DocFunction, layout.DocAttribute]): title = self.render_header(el) str_sig = self.signature(el) + str_source = el.obj sig_part = [str_sig] if self.show_signature else [] with self._increment_header(): @@ -682,6 +692,22 @@ def render(self, el: ds.DocstringRaise) -> ParamRow: def render(self, el): raise NotImplementedError(f"{type(el)}") + # Source links ============================================================ + + def source_link(self, el: "dc.Alias | dc.Object"): + + if self.repo_info is not None: + fpath = str(el.relative_package_filepath) + url = self.repo_info.source_link(fpath) + + return f'
[source]
' + + raise ValueError( + "Unable to produce a link to source file without repo info. " + "Either set repo_info= in the renderer, or provide a repo_url in the " + "`quartodoc:` section of your _quarto.yml." + ) + # Summarize =============================================================== # this method returns a summary description, such as a table summarizing a # layout.Section, or a row in the table for layout.Page or layout.DocFunction. diff --git a/quartodoc/repo_info.py b/quartodoc/repo_info.py new file mode 100644 index 0000000..ad8d9b6 --- /dev/null +++ b/quartodoc/repo_info.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing_extensions import Self + + +@dataclass +class GithubLink: + host: str + owner: str + repo: str + + @classmethod + def parse(cls, link) -> Self: + import re + + # gitlab supports names of the form my/repo/name + supports_subgroups = re.search(r"^https?://gitlab\.", link) is not None + subgroup_token = "/" if not supports_subgroups else "" + rx = ( + r"^(?Phttps?://[^/]+)/" + r"(?P[^/]+)/" + f"(?P[^#{subgroup_token}]+)/" + ) + match = re.match(rx, re.sub(r"([^/])$", r"\1/", link)) + if match is None: + raise ValueError(f"Unable to parse link: {link}") + + return GithubLink(**match.groupdict()) + + +@dataclass +class RepoInfo: + home: str + source: str + issue: str + user: str + + @classmethod + def from_link(cls, link: str | GithubLink, branch: "str | None" = None) -> Self: + if isinstance(link, str): + gh = GithubLink.parse(link) + else: + gh = link + + if branch is None: + branch = cls.gha_current_branch() + + return cls( + home=f"{gh.host}/{gh.owner}/{gh.repo}/", + source=f"{gh.host}/{gh.owner}/{gh.repo}/blob/{branch}/", + issue=f"{gh.host}/{gh.owner}/{gh.repo}/issues/", + user=f"{gh.host}/{gh.owner}/", + ) + + def source_link(self, path) -> str: + return f"{self.source}{path}" + + @classmethod + def gha_current_branch(cls) -> str: + import os + + ref = os.environ.get("GITHUB_HEAD_REF", os.environ.get("GITHUB_REF_NAME")) + + if ref is not None: + return ref + + return "HEAD" diff --git a/quartodoc/static/styles.css b/quartodoc/static/styles.css index a029aba..367b207 100644 --- a/quartodoc/static/styles.css +++ b/quartodoc/static/styles.css @@ -1,6 +1,17 @@ /* styles for parameter tables, etc.. ---- */ +.doc-source { + width: 100%; + text-align: right; + font-size: smaller; +} + +.doc-source a { + text-decoration: none; + float: right; +} + .doc-section dt code { background: none; } diff --git a/quartodoc/tests/test_repo_info.py b/quartodoc/tests/test_repo_info.py new file mode 100644 index 0000000..deb8a85 --- /dev/null +++ b/quartodoc/tests/test_repo_info.py @@ -0,0 +1,43 @@ +import pytest + +from quartodoc.repo_info import GithubLink, RepoInfo + + +@pytest.mark.parametrize( + "src, host, owner, repo", + [ + ( + "https://github.com/machow/quartodoc", + "https://github.com", + "machow", + "quartodoc", + ), + ( + "https://gitlab.com/machow/quartodoc", + "https://gitlab.com", + "machow", + "quartodoc", + ), + ( + "https://gitlab.com/machow/some/pkgs/etc", + "https://gitlab.com", + "machow", + "some/pkgs/etc", + ), + ], +) +def test_github_link_parse(src, host, owner, repo): + gh = GithubLink.parse(src) + assert gh.host == host + assert gh.owner == owner + assert gh.repo == repo + + +def test_repo_info_from_link(): + repo = RepoInfo.from_link(GithubLink("abc", "def", "xyz"), "a_branch") + base = "abc/def/xyz" + + assert repo.home == f"{base}/" + assert repo.source == f"{base}/blob/a_branch/" + assert repo.issue == f"{base}/issues/" + assert repo.user == "abc/def/"