Skip to content

Instantly share code, notes, and snippets.

@tony
Last active August 28, 2022 16:43

Revisions

  1. tony revised this gist Aug 28, 2022. 1 changed file with 3 additions and 3 deletions.
    6 changes: 3 additions & 3 deletions _ext slash link_issues.py
    Original file line number Diff line number Diff line change
    @@ -290,7 +290,7 @@ def get(app: Sphinx, url: str) -> t.Optional[requests.Response]:
    return response
    elif response.status_code != requests.codes.not_found:
    msg = "GET {0.url} failed with code {0.status_code}"
    logger.warn(msg.format(response))
    logger.warning(msg.format(response))

    return None

    @@ -316,7 +316,7 @@ def lookup_github_issue(
    rate_remaining = response.headers.get("X-RateLimit-Remaining")
    assert rate_remaining is not None
    if rate_remaining.isdigit() and int(rate_remaining) == 0:
    logger.warn("Github rate limit hit")
    logger.warning("Github rate limit hit")
    env.github_rate_limit = (time.time(), True)
    issue = response.json()
    closed = issue["state"] == "closed"
    @@ -327,7 +327,7 @@ def lookup_github_issue(
    url=issue["html_url"],
    )
    else:
    logger.warn(
    logger.warning(
    "Github rate limit exceeded, not resolving issue {0}".format(issue_id)
    )
    return None
  2. tony revised this gist Aug 21, 2022. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion _ext slash link_issues.py
    Original file line number Diff line number Diff line change
    @@ -4,7 +4,7 @@
    License: BSD
    Changes by Tony Narlock (2022-08-21):
    - Type annotations
    - Type annotations
    mypy --strict, requires types-requests, types-docutils
  3. tony revised this gist Aug 21, 2022. 2 changed files with 14 additions and 2 deletions.
    1 change: 1 addition & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -32,5 +32,6 @@ Install:

    - https://pypi.org/project/types-docutils/
    - https://pypi.org/project/types-requests/
    - https://pypi.org/project/typing-extensions/ (Python <3.10)

    You can use `mypy --strict` (tested as of [mypy .971](https://pypi.org/project/mypy/0.971/), released July 19, 2022)
    15 changes: 13 additions & 2 deletions _ext slash link_issues.py
    Original file line number Diff line number Diff line change
    @@ -4,7 +4,11 @@
    License: BSD
    Changes by Tony Narlock (2022-08-21):
    - Type annotations (mypy --strict, requires types-requests, types-docutils)
    - Type annotations
    mypy --strict, requires types-requests, types-docutils
    Python < 3.10 require typing-extensions
    - TrackerConfig: Use dataclasses instead of typing.NamedTuple and hacking __new__
    - app.warn (removed in 5.0) -> Use Sphinx Logging API
    @@ -19,6 +23,7 @@
    """
    import dataclasses
    import re
    import sys
    import time
    import typing as t

    @@ -31,6 +36,12 @@
    from sphinx.transforms import SphinxTransform
    from sphinx.util import logging

    if t.TYPE_CHECKING:
    if sys.version_info >= (3, 10):
    from typing import TypeGuard
    else:
    from typing_extensions import TypeGuard

    logger = logging.getLogger(__name__)

    GITHUB_API_URL = "https://api.github.com/repos/{0.project}/issues/{1}"
    @@ -149,7 +160,7 @@ def apply(self) -> None:

    def is_issuetracker_env(
    env: t.Any,
    ) -> t.TypeGuard["IssueTrackerBuildEnvironment"]:
    ) -> "TypeGuard['IssueTrackerBuildEnvironment']":
    return hasattr(env, "issuetracker_cache") and env.issuetracker_cache is not None


  4. tony created this gist Aug 21, 2022.
    36 changes: 36 additions & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,36 @@
    # Autolink plain text issues in Sphinx

    Smart linking of text issues w/ sphinx-doc, "GitHub" / "GitLab" / etc. style. No roles / without roles being required.

    ## Original package / credit

    https://github.com/ignatenkobrain/sphinxcontrib-issuetracker

    ## StackOverflow post

    https://stackoverflow.com/q/72424748/1396928

    ## Problem

    Projects like https://github.com/sloria/sphinx-issues require using docutils roles, e.g.

    plain-old docutils / reStructuredText:

    :issue:`1`

    myst-parser:

    {issue}`1`

    So it won't render issues properly when viewing in GitHub / Gitlab / etc previews.

    We need something that'll work in standard GitHub / GitLab / etc viewers

    ## mypy users

    Install:

    - https://pypi.org/project/types-docutils/
    - https://pypi.org/project/types-requests/

    You can use `mypy --strict` (tested as of [mypy .971](https://pypi.org/project/mypy/0.971/), released July 19, 2022)
    366 changes: 366 additions & 0 deletions _ext slash link_issues.py
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,366 @@
    """Issue linking w/ plain-text autolinking, e.g. #42
    Credit: https://github.com/ignatenkobrain/sphinxcontrib-issuetracker
    License: BSD
    Changes by Tony Narlock (2022-08-21):
    - Type annotations (mypy --strict, requires types-requests, types-docutils)
    - TrackerConfig: Use dataclasses instead of typing.NamedTuple and hacking __new__
    - app.warn (removed in 5.0) -> Use Sphinx Logging API
    https://www.sphinx-doc.org/en/master/extdev/logging.html#logging-api
    - Add PendingIssueXRef
    Typing for tracker_config and precision
    - Add IssueTrackerBuildEnvironment
    Subclassed / typed BuildEnvironment with .tracker_config
    - Just GitHub (for demonstration)
    """
    import dataclasses
    import re
    import time
    import typing as t

    import requests
    from docutils import nodes
    from sphinx.addnodes import pending_xref
    from sphinx.application import Sphinx
    from sphinx.config import Config
    from sphinx.environment import BuildEnvironment
    from sphinx.transforms import SphinxTransform
    from sphinx.util import logging

    logger = logging.getLogger(__name__)

    GITHUB_API_URL = "https://api.github.com/repos/{0.project}/issues/{1}"


    class IssueTrackerBuildEnvironment(BuildEnvironment):
    tracker_config: "TrackerConfig"
    issuetracker_cache: "IssueTrackerCache"
    github_rate_limit: t.Tuple[float, bool]


    class Issue(t.NamedTuple):
    id: str
    title: str
    url: str
    closed: bool


    IssueTrackerCache = t.Dict[str, Issue]


    @dataclasses.dataclass
    class TrackerConfig:
    project: str
    url: str

    """
    Issue tracker configuration.
    This class provides configuration for trackers, and is passed as
    ``tracker_config`` arguments to callbacks of
    :event:`issuetracker-lookup-issue`.
    """

    def __post_init__(self) -> None:
    if self.url is not None:
    self.url = self.url.rstrip("/")

    @classmethod
    def from_sphinx_config(cls, config: Config) -> "TrackerConfig":
    """
    Get tracker configuration from ``config``.
    """
    project = config.issuetracker_project or config.project
    url = config.issuetracker_url
    return cls(project=project, url=url)


    class PendingIssueXRef(pending_xref):
    tracker_config: TrackerConfig


    class IssueReferences(SphinxTransform):

    default_priority = 999

    def apply(self) -> None:
    config = self.document.settings.env.config
    tracker_config = TrackerConfig.from_sphinx_config(config)
    issue_pattern = config.issuetracker_issue_pattern
    title_template = None
    if isinstance(issue_pattern, str):
    issue_pattern = re.compile(issue_pattern)
    for node in self.document.traverse(nodes.Text):
    parent = node.parent
    if isinstance(parent, (nodes.literal, nodes.FixedTextElement)):
    # ignore inline and block literal text
    continue
    if isinstance(parent, nodes.reference):
    continue
    text = str(node)
    new_nodes = []
    last_issue_ref_end = 0
    for match in issue_pattern.finditer(text):
    # catch invalid pattern with too many groups
    if len(match.groups()) != 1:
    raise ValueError(
    "issuetracker_issue_pattern must have "
    "exactly one group: {0!r}".format(match.groups())
    )
    # extract the text between the last issue reference and the
    # current issue reference and put it into a new text node
    head = text[last_issue_ref_end : match.start()]
    if head:
    new_nodes.append(nodes.Text(head))
    # adjust the position of the last issue reference in the
    # text
    last_issue_ref_end = match.end()
    # extract the issue text (including the leading dash)
    issuetext = match.group(0)
    # extract the issue number (excluding the leading dash)
    issue_id = match.group(1)
    # turn the issue reference into a reference node
    refnode = PendingIssueXRef()

    refnode["refdomain"] = None
    refnode["reftarget"] = issue_id
    refnode["reftype"] = "issue"
    refnode["trackerconfig"] = tracker_config
    reftitle = title_template or issuetext
    refnode.append(
    nodes.inline(issuetext, reftitle, classes=["xref", "issue"])
    )
    new_nodes.append(refnode)
    if not new_nodes:
    # no issue references were found, move on to the next node
    continue
    # extract the remaining text after the last issue reference, and
    # put it into a text node
    tail = text[last_issue_ref_end:]
    if tail:
    new_nodes.append(nodes.Text(tail))
    # find and remove the original node, and insert all new nodes
    # instead
    parent.replace(node, new_nodes)


    def is_issuetracker_env(
    env: t.Any,
    ) -> t.TypeGuard["IssueTrackerBuildEnvironment"]:
    return hasattr(env, "issuetracker_cache") and env.issuetracker_cache is not None


    def lookup_issue(
    app: Sphinx, tracker_config: TrackerConfig, issue_id: str
    ) -> t.Optional[Issue]:
    """
    Lookup the given issue.
    The issue is first looked up in an internal cache. If it is not found, the
    event ``issuetracker-lookup-issue`` is emitted. The result of this
    invocation is then cached and returned.
    ``app`` is the sphinx application object. ``tracker_config`` is the
    :class:`TrackerConfig` object representing the issue tracker configuration.
    ``issue_id`` is a string containing the issue id.
    Return a :class:`Issue` object for the issue with the given ``issue_id``,
    or ``None`` if the issue wasn't found.
    """
    env = app.env
    if is_issuetracker_env(env):
    cache: IssueTrackerCache = env.issuetracker_cache
    if issue_id not in cache:
    issue = app.emit_firstresult(
    "issuetracker-lookup-issue", tracker_config, issue_id
    )
    cache[issue_id] = issue
    return cache[issue_id]
    return None


    def lookup_issues(app: Sphinx, doctree: nodes.document) -> None:
    """
    Lookup issues found in the given ``doctree``.
    Each issue reference in the given ``doctree`` is looked up. Each lookup
    result is cached by mapping the referenced issue id to the looked up
    :class:`Issue` object (an existing issue) or ``None`` (a missing issue).
    The cache is available at ``app.env.issuetracker_cache`` and is pickled
    along with the environment.
    """
    for node in doctree.traverse(PendingIssueXRef):
    if node["reftype"] == "issue":
    lookup_issue(app, node["trackerconfig"], node["reftarget"])


    def make_issue_reference(issue: Issue, content_node: nodes.inline) -> nodes.reference:
    """
    Create a reference node for the given issue.
    ``content_node`` is a docutils node which is supposed to be added as
    content of the created reference. ``issue`` is the :class:`Issue` which
    the reference shall point to.
    Return a :class:`docutils.nodes.reference` for the issue.
    """
    reference = nodes.reference()
    reference["refuri"] = issue.url
    if issue.title:
    reference["reftitle"] = issue.title
    if issue.closed:
    content_node["classes"].append("closed")
    reference.append(content_node)
    return reference


    def resolve_issue_reference(
    app: Sphinx, env: BuildEnvironment, node: PendingIssueXRef, contnode: nodes.inline
    ) -> t.Optional[nodes.reference]:
    """
    Resolve an issue reference and turn it into a real reference to the
    corresponding issue.
    ``app`` and ``env`` are the Sphinx application and environment
    respectively. ``node`` is a ``pending_xref`` node representing the missing
    reference. It is expected to have the following attributes:
    - ``reftype``: The reference type
    - ``trackerconfig``: The :class:`TrackerConfig`` to use for this node
    - ``reftarget``: The issue id
    - ``classes``: The node classes
    References with a ``reftype`` other than ``'issue'`` are skipped by
    returning ``None``. Otherwise the new node is returned.
    If the referenced issue was found, a real reference to this issue is
    returned. The text of this reference is formatted with the :class:`Issue`
    object available in the ``issue`` key. The reference title is set to the
    issue title. If the issue is closed, the class ``closed`` is added to the
    new content node.
    Otherwise, if the issue was not found, the content node is returned.
    """
    if node["reftype"] != "issue":
    return None

    issue = lookup_issue(app, node["trackerconfig"], node["reftarget"])
    if issue is None:
    return contnode
    else:
    classes = contnode["classes"]
    conttext = str(contnode[0])
    formatted_conttext = nodes.Text(conttext.format(issue=issue))
    formatted_contnode = nodes.inline(conttext, formatted_conttext, classes=classes)
    assert issue is not None
    return make_issue_reference(issue, formatted_contnode)
    return None


    def init_cache(app: Sphinx) -> None:
    if not hasattr(app.env, "issuetracker_cache"):
    app.env.issuetracker_cache: "IssueTrackerCache" = {} # type: ignore
    return None


    def check_project_with_username(tracker_config: TrackerConfig) -> None:
    if "/" not in tracker_config.project:
    raise ValueError(
    "username missing in project name: {0.project}".format(tracker_config)
    )


    HEADERS = {"User-Agent": "sphinxcontrib-issuetracker v{0}".format("1.0")}


    def get(app: Sphinx, url: str) -> t.Optional[requests.Response]:
    """
    Get a response from the given ``url``.
    ``url`` is a string containing the URL to request via GET. ``app`` is the
    Sphinx application object.
    Return the :class:`~requests.Response` object on status code 200, or
    ``None`` otherwise. If the status code is not 200 or 404, a warning is
    emitted via ``app``.
    """
    response = requests.get(url, headers=HEADERS)
    if response.status_code == requests.codes.ok:
    return response
    elif response.status_code != requests.codes.not_found:
    msg = "GET {0.url} failed with code {0.status_code}"
    logger.warn(msg.format(response))

    return None


    def lookup_github_issue(
    app: Sphinx, tracker_config: TrackerConfig, issue_id: str
    ) -> t.Optional[Issue]:
    check_project_with_username(tracker_config)

    env = app.env
    if is_issuetracker_env(env):
    # Get rate limit information from the environment
    timestamp, limit_hit = getattr(env, "github_rate_limit", (0, False))

    if limit_hit and time.time() - timestamp > 3600:
    # Github limits applications hourly
    limit_hit = False

    if not limit_hit:
    url = GITHUB_API_URL.format(tracker_config, issue_id)
    response = get(app, url)
    if response:
    rate_remaining = response.headers.get("X-RateLimit-Remaining")
    assert rate_remaining is not None
    if rate_remaining.isdigit() and int(rate_remaining) == 0:
    logger.warn("Github rate limit hit")
    env.github_rate_limit = (time.time(), True)
    issue = response.json()
    closed = issue["state"] == "closed"
    return Issue(
    id=issue_id,
    title=issue["title"],
    closed=closed,
    url=issue["html_url"],
    )
    else:
    logger.warn(
    "Github rate limit exceeded, not resolving issue {0}".format(issue_id)
    )
    return None


    BUILTIN_ISSUE_TRACKERS: t.Dict[str, t.Any] = {
    "github": lookup_github_issue,
    }


    def init_transformer(app: Sphinx) -> None:
    if app.config.issuetracker_plaintext_issues:
    app.add_transform(IssueReferences)


    def connect_builtin_tracker(app: Sphinx) -> None:
    if app.config.issuetracker:
    tracker = BUILTIN_ISSUE_TRACKERS[app.config.issuetracker.lower()]
    app.connect(str("issuetracker-lookup-issue"), tracker)


    def setup(app: Sphinx) -> t.Dict[str, t.Any]:
    app.add_config_value("mybase", "https://github.com/cihai/unihan-etl", "env")
    app.add_event(str("issuetracker-lookup-issue"))
    app.connect(str("builder-inited"), connect_builtin_tracker)
    app.add_config_value("issuetracker", None, "env")
    app.add_config_value("issuetracker_project", None, "env")
    app.add_config_value("issuetracker_url", None, "env")
    # configuration specific to plaintext issue references
    app.add_config_value("issuetracker_plaintext_issues", True, "env")
    app.add_config_value(
    "issuetracker_issue_pattern",
    re.compile(
    r"#(\d+)",
    ),
    "env",
    )
    app.add_config_value("issuetracker_title_template", None, "env")
    app.connect(str("builder-inited"), init_cache)
    app.connect(str("builder-inited"), init_transformer)
    app.connect(str("doctree-read"), lookup_issues)
    app.connect(str("missing-reference"), resolve_issue_reference)
    return {
    "version": "1.0",
    "parallel_read_safe": True,
    "parallel_write_safe": True,
    }
    16 changes: 16 additions & 0 deletions conf.py
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,16 @@
    import sys
    from pathlib import Path

    cwd = Path(__file__).parent
    project_root = cwd.parent

    sys.path.insert(0, str(project_root))
    sys.path.insert(0, str(cwd / "_ext"))

    extensions = [
    "link_issues",
    ]

    # issuetracker
    issuetracker = "github"
    issuetracker_project = "cihai/unihan-etl" # e.g. for https://github.com/cihai/unihan-etl