diff --git a/README.rst b/README.rst index 0c61c7e..8076a98 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ .. |.~.get_linkcode_resolve| replace:: ``get_linkcode_resolve()`` -.. _.~.get_linkcode_resolve: https://sphinx-github-style.readthedocs.io/en/latest/modules.html#sphinx_github_style.__init__.get_linkcode_resolve +.. _.~.get_linkcode_resolve: https://sphinx-github-style.readthedocs.io/en/latest/linkcode.html#sphinx_github_style.utils.linkcode.get_linkcode_resolve .. |linkcode_blob| replace:: ``linkcode_blob`` .. _linkcode_blob: https://sphinx-github-style.readthedocs.io/en/latest/index.html#confval-linkcode_blob .. |linkcode_link_text| replace:: ``linkcode_link_text`` diff --git a/docs/source/README.rst b/docs/source/README.rst index d6dc161..ab5510a 100644 --- a/docs/source/README.rst +++ b/docs/source/README.rst @@ -103,7 +103,7 @@ Using :mod:`sphinx.ext.linkcode`, a ``View on GitHub`` link is added to the doc .. only:: html - .. autofunction:: sphinx_github_style.__init__.get_repo_dir + .. autofunction:: sphinx_github_style.utils.git.get_repo_dir :noindex: .. only:: readme or pypi @@ -135,10 +135,9 @@ Syntax Highlighting .. only:: html - .. literalinclude:: ../../sphinx_github_style/__init__.py + .. literalinclude:: ../../sphinx_github_style/utils/git.py :language: python - :start-after: # EXAMPLE START - :end-before: # EXAMPLE END + :pyobject: get_repo_dir .. only:: readme or pypi diff --git a/docs/source/git.rst b/docs/source/git.rst new file mode 100644 index 0000000..b38c0e8 --- /dev/null +++ b/docs/source/git.rst @@ -0,0 +1,7 @@ +The ``sphinx_github_style.utils.git`` submodule +================================================ + +.. automodule:: sphinx_github_style.utils.git + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/linkcode.rst b/docs/source/linkcode.rst new file mode 100644 index 0000000..e031aa5 --- /dev/null +++ b/docs/source/linkcode.rst @@ -0,0 +1,7 @@ +The ``sphinx_github_style.utils.linkcode`` submodule +===================================================== + +.. automodule:: sphinx_github_style.utils.linkcode + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules.rst b/docs/source/modules.rst index c2fef6a..3fa4ccf 100644 --- a/docs/source/modules.rst +++ b/docs/source/modules.rst @@ -3,7 +3,7 @@ The ``sphinx_github_style`` Package .. automodule:: sphinx_github_style.__init__ - :members: + :members: setup :undoc-members: | @@ -15,3 +15,9 @@ The ``sphinx_github_style`` Package add_linkcode_class github_style lexer + +.. toctree:: + :caption: The Utils Subpackage + :titlesonly: + + utils diff --git a/docs/source/sphinx.rst b/docs/source/sphinx.rst new file mode 100644 index 0000000..0f7f959 --- /dev/null +++ b/docs/source/sphinx.rst @@ -0,0 +1,7 @@ +The ``sphinx_github_style.utils.sphinx`` submodule +=================================================== + +.. automodule:: sphinx_github_style.utils.sphinx + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/utils.rst b/docs/source/utils.rst new file mode 100644 index 0000000..5b66709 --- /dev/null +++ b/docs/source/utils.rst @@ -0,0 +1,17 @@ +The ``sphinx_github_style.utils`` subpackage +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. automodule:: sphinx_github_style.utils + :members: + :undoc-members: + :show-inheritance: + +The ``sphinx_github_style.utils`` subpackage contains a variety of helper functions + +.. toctree:: + :maxdepth: 3 + :titlesonly: + + git + linkcode + sphinx diff --git a/sphinx_github_style/__init__.py b/sphinx_github_style/__init__.py index c540af2..39a30fe 100644 --- a/sphinx_github_style/__init__.py +++ b/sphinx_github_style/__init__.py @@ -1,12 +1,7 @@ -import sys import sphinx -import inspect -import subprocess from pathlib import Path -from functools import cached_property +from typing import Dict, Any from sphinx.application import Sphinx -from sphinx.errors import ExtensionError -from typing import Dict, Any, Optional, Callable __version__ = "1.2.0" __author__ = 'Adam Korn ' @@ -15,6 +10,10 @@ from .github_style import GitHubStyle from .lexer import GitHubLexer +from .utils.linkcode import get_linkcode_url, get_linkcode_revision, get_linkcode_resolve +from .utils.sphinx import get_conf_val, set_conf_val +from .utils.git import get_repo_dir + def setup(app: Sphinx) -> Dict[str, Any]: app.setup_extension('sphinx.ext.linkcode') @@ -53,194 +52,3 @@ def add_static_path(app) -> None: app.config.html_static_path.append( str(Path(__file__).parent.joinpath("_static").absolute()) ) - - -def get_linkcode_revision(blob: str) -> str: - """Get the blob to link to on GitHub - - .. note:: - - The value of ``blob`` can be any of ``"head"``, ``"last_tag"``, or ``"{blob}"`` - - * ``head`` (default): links to the most recent commit hash; if this commit is tagged, uses the tag instead - * ``last_tag``: links to the most recent commit tag on the currently checked out branch - * ``blob``: links to any blob you want, for example ``"master"`` or ``"v2.0.1"`` - """ - if blob == "head": - return get_head() - if blob == 'last_tag': - return get_last_tag() - # Link to the branch/tree/blob you provided, ex. "master" - return blob - - -def get_head() -> str: - """Gets the most recent commit hash or tag - - :return: The SHA or tag name of the most recent commit, or "master" if the call to git fails. - """ - cmd = "git log -n1 --pretty=%H" - try: - # get most recent commit hash - head = subprocess.check_output(cmd.split()).strip().decode('utf-8') - - # if head is a tag, use tag as reference - cmd = "git describe --exact-match --tags " + head - try: - tag = subprocess.check_output(cmd.split(" ")).strip().decode('utf-8') - return tag - - except subprocess.CalledProcessError: - return head - - except subprocess.CalledProcessError: - print("Failed to get head") # so no head? - return "master" - - -def get_last_tag() -> str: - """Get the most recent commit tag on the currently checked out branch - - :raises ExtensionError: if no tags exist on the branch - """ - try: - cmd = "git describe --tags --abbrev=0" - return subprocess.check_output(cmd.split(" ")).strip().decode('utf-8') - - except subprocess.CalledProcessError: - raise ExtensionError("``sphinx-github-style``: no tags found on current branch") - - -def get_linkcode_url(blob: Optional[str] = None, context: Optional[Dict] = None, url: Optional[str] = None) -> str: - """Get the template URL for linking to highlighted GitHub source code with :mod:`sphinx.ext.linkcode` - - Formatted into the final link by a ``linkcode_resolve()`` function - - :param blob: The Git blob to link to - :param context: The :external+sphinx:confval:`html_context` dictionary - :param url: The base URL of the repository (ex. ``https://github.com/TDKorn/sphinx-github-style``) - """ - if url is None: - if context is None or not all(context.get(key) for key in ("github_user", "github_repo")): - raise ExtensionError( - "sphinx-github-style: config value ``linkcode_url`` is missing") - else: - print( - "sphinx-github-style: config value ``linkcode_url`` is missing. " - "Creating link from ``html_context`` values..." - ) - url = f"https://github.com/{context['github_user']}/{context['github_repo']}" - - blob = get_linkcode_revision(blob) if blob else context.get('github_version') - - if blob is not None: - url = url.strip("/") + f"/blob/{blob}/" # URL should be "https://github.com/user/repo" - else: - raise ExtensionError( - "sphinx-github-style: must provide a blob or GitHub version to link to") - - return url + "{filepath}#L{linestart}-L{linestop}" - - -def get_linkcode_resolve(linkcode_url: str, repo_dir: Optional[Path] = None) -> Callable: - """Defines and returns a ``linkcode_resolve`` function for your package - - Used by default if ``linkcode_resolve`` isn't defined in ``conf.py`` - - :param linkcode_url: The template URL to use when resolving cross-references with :mod:`sphinx.ext.linkcode` - :param repo_dir: The root directory of the Git repository. - """ - if repo_dir is None: - repo_dir = get_repo_dir() - - def linkcode_resolve(domain, info): - """Returns a link to the source code on GitHub, with appropriate lines highlighted - - :By: - Adam Korn (https://github.com/tdkorn) - :Adapted From: - nlgranger/SeqTools (https://github.com/nlgranger/seqtools/blob/master/docs/conf.py) - """ - if domain != 'py' or not info['module']: - return None - - modname = info['module'] - fullname = info['fullname'] - - submod = sys.modules.get(modname) - if submod is None: - return None - - obj = submod - for part in fullname.split('.'): - try: - obj = getattr(obj, part) - except AttributeError: - return None - - if isinstance(obj, property): - obj = obj.fget - elif isinstance(obj, cached_property): - obj = obj.func - - try: - modpath = inspect.getsourcefile(inspect.unwrap(obj)) - filepath = Path(modpath).relative_to(repo_dir) - if filepath is None: - return - except Exception: - return None - - try: - source, lineno = inspect.getsourcelines(obj) - except Exception: - return None - - linestart, linestop = lineno, lineno + len(source) - 1 - - # Example: https://github.com/TDKorn/my-magento/blob/docs/magento/models/model.py#L28-L59 - final_link = linkcode_url.format( - filepath=filepath.as_posix(), - linestart=linestart, - linestop=linestop - ) - print(f"Final Link for {fullname}: {final_link}") - return final_link - - return linkcode_resolve - - -# EXAMPLE START -def get_repo_dir() -> Path: - """Returns the root directory of the repository - - :return: A Path object representing the working directory of the repository. - """ - try: - cmd = "git rev-parse --show-toplevel" - repo_dir = Path(subprocess.check_output(cmd.split(" ")).strip().decode('utf-8')) - - except subprocess.CalledProcessError as e: - raise RuntimeError("Unable to determine the repository directory") from e - - return repo_dir -# EXAMPLE END - - -def get_conf_val(app: Sphinx, attr: str, default: Optional[Any] = None) -> Any: - """Retrieve the value of a ``conf.py`` config variable - - :param attr: the config variable to retrieve - :param default: the default value to return if the variable isn't found - """ - return app.config._raw_config.get(attr, getattr(app.config, attr, default)) - - -def set_conf_val(app: Sphinx, attr: str, value: Any) -> None: - """Set the value of a ``conf.py`` config variable - - :param attr: the config variable to set - :param value: the variable value - """ - app.config._raw_config[attr] = value - setattr(app.config, attr, value) diff --git a/sphinx_github_style/utils/__init__.py b/sphinx_github_style/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sphinx_github_style/utils/git.py b/sphinx_github_style/utils/git.py new file mode 100644 index 0000000..eba57b2 --- /dev/null +++ b/sphinx_github_style/utils/git.py @@ -0,0 +1,55 @@ +import subprocess +from pathlib import Path +from sphinx.errors import ExtensionError + + +def get_head() -> str: + """Gets the most recent commit hash or tag + + :return: The SHA or tag name of the most recent commit, or "master" if the call to git fails. + """ + cmd = "git log -n1 --pretty=%H" + try: + # get most recent commit hash + head = subprocess.check_output(cmd.split()).strip().decode('utf-8') + + # if head is a tag, use tag as reference + cmd = "git describe --exact-match --tags " + head + try: + tag = subprocess.check_output(cmd.split(" ")).strip().decode('utf-8') + return tag + + except subprocess.CalledProcessError: + return head + + except subprocess.CalledProcessError: + print("Failed to get head") # so no head? + return "master" + + +def get_last_tag() -> str: + """Get the most recent commit tag on the currently checked out branch + + :raises ExtensionError: if no tags exist on the branch + """ + try: + cmd = "git describe --tags --abbrev=0" + return subprocess.check_output(cmd.split(" ")).strip().decode('utf-8') + + except subprocess.CalledProcessError: + raise ExtensionError("``sphinx-github-style``: no tags found on current branch") + + +def get_repo_dir() -> Path: + """Returns the root directory of the repository + + :return: A Path object representing the working directory of the repository. + """ + try: + cmd = "git rev-parse --show-toplevel" + repo_dir = Path(subprocess.check_output(cmd.split(" ")).strip().decode('utf-8')) + + except subprocess.CalledProcessError as e: + raise RuntimeError("Unable to determine the repository directory") from e + + return repo_dir diff --git a/sphinx_github_style/utils/linkcode.py b/sphinx_github_style/utils/linkcode.py new file mode 100644 index 0000000..1cd6be4 --- /dev/null +++ b/sphinx_github_style/utils/linkcode.py @@ -0,0 +1,125 @@ +import sys +import inspect +from pathlib import Path +from functools import cached_property +from sphinx.errors import ExtensionError +from typing import Dict, Optional, Callable +from sphinx_github_style.utils.git import get_head, get_last_tag, get_repo_dir + + +def get_linkcode_revision(blob: str) -> str: + """Get the blob to link to on GitHub + + .. note:: + + The value of ``blob`` can be any of ``"head"``, ``"last_tag"``, or ``"{blob}"`` + + * ``head`` (default): links to the most recent commit hash; if this commit is tagged, uses the tag instead + * ``last_tag``: links to the most recent commit tag on the currently checked out branch + * ``blob``: links to any blob you want, for example ``"master"`` or ``"v2.0.1"`` + """ + if blob == "head": + return get_head() + if blob == 'last_tag': + return get_last_tag() + # Link to the branch/tree/blob you provided, ex. "master" + return blob + + +def get_linkcode_url(blob: Optional[str] = None, context: Optional[Dict] = None, url: Optional[str] = None) -> str: + """Get the template URL for linking to highlighted GitHub source code with :mod:`sphinx.ext.linkcode` + + Formatted into the final link by a ``linkcode_resolve()`` function + + :param blob: The Git blob to link to + :param context: The :external+sphinx:confval:`html_context` dictionary + :param url: The base URL of the repository (ex. ``https://github.com/TDKorn/sphinx-github-style``) + """ + if url is None: + if context is None or not all(context.get(key) for key in ("github_user", "github_repo")): + raise ExtensionError( + "sphinx-github-style: config value ``linkcode_url`` is missing") + else: + print( + "sphinx-github-style: config value ``linkcode_url`` is missing. " + "Creating link from ``html_context`` values..." + ) + url = f"https://github.com/{context['github_user']}/{context['github_repo']}" + + blob = get_linkcode_revision(blob) if blob else context.get('github_version') + + if blob is not None: + url = url.strip("/") + f"/blob/{blob}/" # URL should be "https://github.com/user/repo" + else: + raise ExtensionError( + "sphinx-github-style: must provide a blob or GitHub version to link to") + + return url + "{filepath}#L{linestart}-L{linestop}" + + +def get_linkcode_resolve(linkcode_url: str, repo_dir: Optional[Path] = None) -> Callable: + """Defines and returns a ``linkcode_resolve`` function for your package + + Used by default if ``linkcode_resolve`` isn't defined in ``conf.py`` + + :param linkcode_url: The template URL to use when resolving cross-references with :mod:`sphinx.ext.linkcode` + :param repo_dir: The root directory of the Git repository. + """ + if repo_dir is None: + repo_dir = get_repo_dir() + + def linkcode_resolve(domain, info): + """Returns a link to the source code on GitHub, with appropriate lines highlighted + + :By: + Adam Korn (https://github.com/tdkorn) + :Adapted From: + nlgranger/SeqTools (https://github.com/nlgranger/seqtools/blob/master/docs/conf.py) + """ + if domain != 'py' or not info['module']: + return None + + modname = info['module'] + fullname = info['fullname'] + + submod = sys.modules.get(modname) + if submod is None: + return None + + obj = submod + for part in fullname.split('.'): + try: + obj = getattr(obj, part) + except AttributeError: + return None + + if isinstance(obj, property): + obj = obj.fget + elif isinstance(obj, cached_property): + obj = obj.func + + try: + modpath = inspect.getsourcefile(inspect.unwrap(obj)) + filepath = Path(modpath).relative_to(repo_dir) + if filepath is None: + return + except Exception: + return None + + try: + source, lineno = inspect.getsourcelines(obj) + except Exception: + return None + + linestart, linestop = lineno, lineno + len(source) - 1 + + # Example: https://github.com/TDKorn/my-magento/blob/docs/magento/models/model.py#L28-L59 + final_link = linkcode_url.format( + filepath=filepath.as_posix(), + linestart=linestart, + linestop=linestop + ) + print(f"Final Link for {fullname}: {final_link}") + return final_link + + return linkcode_resolve diff --git a/sphinx_github_style/utils/sphinx.py b/sphinx_github_style/utils/sphinx.py new file mode 100644 index 0000000..463f1af --- /dev/null +++ b/sphinx_github_style/utils/sphinx.py @@ -0,0 +1,21 @@ +from sphinx.application import Sphinx +from typing import Optional, Any + + +def get_conf_val(app: Sphinx, attr: str, default: Optional[Any] = None) -> Any: + """Retrieve the value of a ``conf.py`` config variable + + :param attr: the config variable to retrieve + :param default: the default value to return if the variable isn't found + """ + return app.config._raw_config.get(attr, getattr(app.config, attr, default)) + + +def set_conf_val(app: Sphinx, attr: str, value: Any) -> None: + """Set the value of a ``conf.py`` config variable + + :param attr: the config variable to set + :param value: the variable value + """ + app.config._raw_config[attr] = value + setattr(app.config, attr, value)