| 
          """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 | 
        
        
           | 
          
 | 
        
        
           | 
            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 | 
        
        
           | 
          
 | 
        
        
           | 
            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 sys | 
        
        
           | 
          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 | 
        
        
           | 
          
 | 
        
        
           | 
          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}" | 
        
        
           | 
          
 | 
        
        
           | 
          
 | 
        
        
           | 
          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, | 
        
        
           | 
          ) -> "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.warning(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.warning("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.warning( | 
        
        
           | 
                          "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, | 
        
        
           | 
              } | 
        
  
via unihan-etl#261 / v0.17.2 (source, view, but page may be outdated)