exampleΒΆ

Include setup.py without specifying the path to the git repo (uses the one in conf.py)

# -*- coding: utf-8 -*-

# Copyright (c) 2022 Oz Tiram <oz.tiram@gmail.com>
# All rights reserved.

# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

# 1. Redistributions of source code must retain the above copyright notice,
#    this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.

# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.


import os
import re
from setuptools import setup, find_packages

def read_desc():
    with open('README.rst') as stream:
        readme = stream.read()

    return readme

def read_version_number():
    VERSION_PATTERN = re.compile(r"__version__ = '([^']+)'")
    with open(os.path.join('src', 'sphinxcontrib', 'gitinclude', '__init__.py')) as stream:
        for line in stream:
            match = VERSION_PATTERN.search(line)
            if match:
                return match.group(1)

        raise ValueError('Could not extract version number')


setup(
    name='sphinxcontrib-gitinclude',
    version=read_version_number(),
    url='https://sphinxcontrib-gitinclude.readthedocs.org/',
    license='BSD',
    author='Oz Tiram',
    author_email='oz.tiram@gmail.com',
    description='Sphinx extension to include code from git repository',
    long_description=read_desc(),
    keywords="sphinx git documentation",
    zip_safe=False,
    classifiers=[
        'Development Status :: 5 - Production/Stable',
        'Intended Audience :: Developers',
        'License :: OSI Approved :: BSD License',
        'Operating System :: OS Independent',
        "Programming Language :: Python :: 3.7",
        "Programming Language :: Python :: 3.8",
        "Programming Language :: Python :: 3.9",
        "Programming Language :: Python :: Implementation :: CPython",
        "Programming Language :: Python :: Implementation :: PyPy",
        'Topic :: Documentation',
        'Topic :: Utilities',
        'Framework :: Sphinx',
        'Framework :: Sphinx :: Extension',
    ],
    platforms='any',
    packages=find_packages('src'),
    package_dir={'': 'src'},
    namespace_packages=['sphinxcontrib'],
    include_package_data=True,
    install_requires=[
        'Sphinx>=3.4.0',
    ],
    python_requires=">=3.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*, !=3.5.*, !=3.6.*",
)

Include __init__.py with specifying a path to the repository (you can use absolute or relative paths):

# -*- coding: utf-8 -*-
# Copyright (c) 2022, Oz Tiram <oz.tirm@gmail.com>
# All rights reserved.

# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

# 1. Redistributions of source code must retain the above copyright notice,
#    this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.

# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

"""
    sphinxcontrib.gitinclude
    ========================

    This extension provides a directive to include code snippets
    directly from git repositories.

    .. moduleauthor::  Oz Tiram <oz.tiramgmail.com>
"""

from typing import Any, Dict, Tuple, List


from subprocess import Popen, PIPE

from docutils.nodes import Element, Node
from docutils import nodes
from docutils.parsers.rst import directives
from sphinx.directives import optional_int
from sphinx.locale import __
from sphinx.config import Config
from sphinx.util import parselinenos
from sphinx.util import logging as sphinx_logging
from sphinx.util.typing import OptionSpec
from sphinx.directives.code import (LiteralInclude,
                                    LiteralIncludeReader,
                                    container_wrapper)

__version__ = '0.0.1'

logger = sphinx_logging.getLogger('contrib.programoutput')


class GitIncludeReader(LiteralIncludeReader):

    def __init__(self,
                 filename: str,
                 hash_or_tag: str,
                 git_repo: str,
                 options: Dict,
                 config: Config) -> None:

        self.filename = filename
        self.git_repo = git_repo
        self.hash_or_tag = hash_or_tag
        self.options = options
        self.encoding = options.get('encoding', config.source_encoding)
        self.lineno_start = self.options.get('lineno-start', 1)

        self.parse_options()

    def _read_file(self, cmd: str) -> List[str]:
        with Popen(cmd, cwd=self.git_repo,
                   shell=True,
                   stdout=PIPE,
                   stderr=PIPE) as p:
            out, err = p.communicate()
            if err:
                logger.warning(__(f'failed to run: {cmd}'))
                return []

            text = out.decode()
            if 'tab-width' in self.options:
                text = text.expandtabs(self.options['tab-width'])

            return text.splitlines(True)

    def show_diff(self):
        diff = self.options.get('diff').split("/")[-1]
        cmd = f"git --no-pager  diff --no-color {self.hash_or_tag}..{diff} -- {self.filename}"  # noqa: E501
        return self._read_file(cmd)

    def read_file(self,
                  filename: str,
                  location: Tuple[str, int] = None) -> List[str]:
        cmd = f"git --no-pager show {self.hash_or_tag}:{filename}"
        return self._read_file(cmd)

    def read(self,
             location: Tuple[str, int] = None,
             ) -> Tuple[str, int]:

        if 'diff' in self.options:
            lines = self.show_diff()
        else:
            filters = [self.pyobject_filter,
                       self.start_filter,
                       self.end_filter,
                       self.lines_filter,
                       self.prepend_filter,
                       self.append_filter,
                       self.dedent_filter]

            lines = self.read_file(self.filename,
                                   location=location)
            for func in filters:
                lines = func(lines, location=location)

        return ''.join(lines), len(lines)


class GitInclude(LiteralInclude):

    has_content = False
    required_arguments = 2
    optional_arguments = 1
    final_argument_whitespace = True
    option_spec: OptionSpec = {
        'dedent': optional_int,
        'linenos': directives.flag,
        'lineno-start': int,
        'lineno-match': directives.flag,
        'tab-width': int,
        'language': directives.unchanged_required,
        'force': directives.flag,
        'encoding': directives.encoding,
        'pyobject': directives.unchanged_required,
        'lines': directives.unchanged_required,
        'start-after': directives.unchanged_required,
        'end-before': directives.unchanged_required,
        'start-at': directives.unchanged_required,
        'end-at': directives.unchanged_required,
        'prepend': directives.unchanged_required,
        'append': directives.unchanged_required,
        'emphasize-lines': directives.unchanged_required,
        'caption': directives.unchanged,
        'class': directives.class_option,
        'name': directives.unchanged,
        'diff': directives.unchanged_required,
    }

    def run(self) -> List[Node]:
        document = self.state.document

        if not document.settings.file_insertion_enabled:
            return [document.reporter.warning('File insertion disabled',
                                              line=self.lineno)]
        # convert options['diff'] to absolute path
        if 'diff' in self.options:
            _, path = self.env.relfn2path(self.options['diff'])
            self.options['diff'] = path

        try:
            location = self.state_machine.get_source_and_line(self.lineno)
            rel_filename, filename = self.env.relfn2path(self.arguments[0])
            self.env.note_dependency(rel_filename)
            try:
                git_repo = self.arguments[2]
            except IndexError:
                git_repo = self.env.app.config.git_repo

                if not git_repo:
                    return [document.reporter.warning(
                        "default git_repo isn't configured", line=self.lineno)]

            reader = GitIncludeReader(self.arguments[0],
                                      self.arguments[1],
                                      git_repo,
                                      self.options,
                                      self.config)

            text, lines = reader.read(location=location)

            retnode: Element = nodes.literal_block(text, text, source=filename)

            retnode['force'] = 'force' in self.options
            self.set_source_info(retnode)
            if self.options.get('diff'):  # if diff is set, set udiff
                retnode['language'] = 'udiff'
            elif 'language' in self.options:
                retnode['language'] = self.options['language']
            if ('linenos' in self.options or 'lineno-start' in self.options or
                    'lineno-match' in self.options):
                retnode['linenos'] = True
            retnode['classes'] += self.options.get('class', [])
            extra_args = retnode['highlight_args'] = {}
            if 'emphasize-lines' in self.options:
                hl_lines = parselinenos(self.options['emphasize-lines'], lines)
                if any(i >= lines for i in hl_lines):
                    logger.warning(__('line number spec is out of range(1-%d): %r') %  # noqa: E501
                                   (lines, self.options['emphasize-lines']),
                                   location=location)
                extra_args['hl_lines'] = [x + 1 for x in hl_lines if x < lines]
            extra_args['linenostart'] = reader.lineno_start

            if 'caption' in self.options:
                caption = self.options['caption'] or self.arguments[0]
                retnode = container_wrapper(self, retnode, caption)

            # retnode will be note_implicit_target that is linked from caption
            # and numref.
            # when options['name'] is provided, it should be primary ID.
            self.add_name(retnode)

            return [retnode]
        except Exception as exc:
            return [document.reporter.warning(exc, line=self.lineno)]


def setup(app) -> Dict[str, Any]:
    app.add_config_value('git_repo', '', "")
    app.add_directive('gitinclude', GitInclude)
    return {
        'parallel_read_safe': False,
    }

Include __init__.py with specifying a path to the repository (relative):

# -*- coding: utf-8 -*-
# Copyright (c) 2022, Oz Tiram <oz.tirm@gmail.com>
# All rights reserved.

# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

# 1. Redistributions of source code must retain the above copyright notice,
#    this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.

# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

"""
    sphinxcontrib.gitinclude
    ========================

    This extension provides a directive to include code snippets
    directly from git repositories.

    .. moduleauthor::  Oz Tiram <oz.tiramgmail.com>
"""

from typing import Any, Dict, Tuple, List


from subprocess import Popen, PIPE

from docutils.nodes import Element, Node
from docutils import nodes
from docutils.parsers.rst import directives
from sphinx.directives import optional_int
from sphinx.locale import __
from sphinx.config import Config
from sphinx.util import parselinenos
from sphinx.util import logging as sphinx_logging
from sphinx.util.typing import OptionSpec
from sphinx.directives.code import (LiteralInclude,
                                    LiteralIncludeReader,
                                    container_wrapper)

__version__ = '0.0.1'

logger = sphinx_logging.getLogger('contrib.programoutput')


class GitIncludeReader(LiteralIncludeReader):

    def __init__(self,
                 filename: str,
                 hash_or_tag: str,
                 git_repo: str,
                 options: Dict,
                 config: Config) -> None:

        self.filename = filename
        self.git_repo = git_repo
        self.hash_or_tag = hash_or_tag
        self.options = options
        self.encoding = options.get('encoding', config.source_encoding)
        self.lineno_start = self.options.get('lineno-start', 1)

        self.parse_options()

    def _read_file(self, cmd: str) -> List[str]:
        with Popen(cmd, cwd=self.git_repo,
                   shell=True,
                   stdout=PIPE,
                   stderr=PIPE) as p:
            out, err = p.communicate()
            if err:
                logger.warning(__(f'failed to run: {cmd}'))
                return []

            text = out.decode()
            if 'tab-width' in self.options:
                text = text.expandtabs(self.options['tab-width'])

            return text.splitlines(True)

    def show_diff(self):
        diff = self.options.get('diff').split("/")[-1]
        cmd = f"git --no-pager  diff --no-color {self.hash_or_tag}..{diff} -- {self.filename}"  # noqa: E501
        return self._read_file(cmd)

    def read_file(self,
                  filename: str,
                  location: Tuple[str, int] = None) -> List[str]:
        cmd = f"git --no-pager show {self.hash_or_tag}:{filename}"
        return self._read_file(cmd)

    def read(self,
             location: Tuple[str, int] = None,
             ) -> Tuple[str, int]:

        if 'diff' in self.options:
            lines = self.show_diff()
        else:
            filters = [self.pyobject_filter,
                       self.start_filter,
                       self.end_filter,
                       self.lines_filter,
                       self.prepend_filter,
                       self.append_filter,
                       self.dedent_filter]

            lines = self.read_file(self.filename,
                                   location=location)
            for func in filters:
                lines = func(lines, location=location)

        return ''.join(lines), len(lines)


class GitInclude(LiteralInclude):

    has_content = False
    required_arguments = 2
    optional_arguments = 1
    final_argument_whitespace = True
    option_spec: OptionSpec = {
        'dedent': optional_int,
        'linenos': directives.flag,
        'lineno-start': int,
        'lineno-match': directives.flag,
        'tab-width': int,
        'language': directives.unchanged_required,
        'force': directives.flag,
        'encoding': directives.encoding,
        'pyobject': directives.unchanged_required,
        'lines': directives.unchanged_required,
        'start-after': directives.unchanged_required,
        'end-before': directives.unchanged_required,
        'start-at': directives.unchanged_required,
        'end-at': directives.unchanged_required,
        'prepend': directives.unchanged_required,
        'append': directives.unchanged_required,
        'emphasize-lines': directives.unchanged_required,
        'caption': directives.unchanged,
        'class': directives.class_option,
        'name': directives.unchanged,
        'diff': directives.unchanged_required,
    }

    def run(self) -> List[Node]:
        document = self.state.document

        if not document.settings.file_insertion_enabled:
            return [document.reporter.warning('File insertion disabled',
                                              line=self.lineno)]
        # convert options['diff'] to absolute path
        if 'diff' in self.options:
            _, path = self.env.relfn2path(self.options['diff'])
            self.options['diff'] = path

        try:
            location = self.state_machine.get_source_and_line(self.lineno)
            rel_filename, filename = self.env.relfn2path(self.arguments[0])
            self.env.note_dependency(rel_filename)
            try:
                git_repo = self.arguments[2]
            except IndexError:
                git_repo = self.env.app.config.git_repo

                if not git_repo:
                    return [document.reporter.warning(
                        "default git_repo isn't configured", line=self.lineno)]

            reader = GitIncludeReader(self.arguments[0],
                                      self.arguments[1],
                                      git_repo,
                                      self.options,
                                      self.config)

            text, lines = reader.read(location=location)

            retnode: Element = nodes.literal_block(text, text, source=filename)

            retnode['force'] = 'force' in self.options
            self.set_source_info(retnode)
            if self.options.get('diff'):  # if diff is set, set udiff
                retnode['language'] = 'udiff'
            elif 'language' in self.options:
                retnode['language'] = self.options['language']
            if ('linenos' in self.options or 'lineno-start' in self.options or
                    'lineno-match' in self.options):
                retnode['linenos'] = True
            retnode['classes'] += self.options.get('class', [])
            extra_args = retnode['highlight_args'] = {}
            if 'emphasize-lines' in self.options:
                hl_lines = parselinenos(self.options['emphasize-lines'], lines)
                if any(i >= lines for i in hl_lines):
                    logger.warning(__('line number spec is out of range(1-%d): %r') %  # noqa: E501
                                   (lines, self.options['emphasize-lines']),
                                   location=location)
                extra_args['hl_lines'] = [x + 1 for x in hl_lines if x < lines]
            extra_args['linenostart'] = reader.lineno_start

            if 'caption' in self.options:
                caption = self.options['caption'] or self.arguments[0]
                retnode = container_wrapper(self, retnode, caption)

            # retnode will be note_implicit_target that is linked from caption
            # and numref.
            # when options['name'] is provided, it should be primary ID.
            self.add_name(retnode)

            return [retnode]
        except Exception as exc:
            return [document.reporter.warning(exc, line=self.lineno)]


def setup(app) -> Dict[str, Any]:
    app.add_config_value('git_repo', '', "")
    app.add_directive('gitinclude', GitInclude)
    return {
        'parallel_read_safe': False,
    }

Show a diff using hashes, feel free to use any git tag or ref:

diff --git a/example/source/conf.py b/example/source/conf.py
index b3ec6f9..bd1fdf6 100644
--- a/example/source/conf.py
+++ b/example/source/conf.py
@@ -34,7 +34,7 @@ extensions = [
 # Add any paths that contain templates here, relative to this directory.
 templates_path = ['_templates']
 
-git_repo = ".."
+git_repo = "/home/oznt/Software/pwman3"
 
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.

Show some inline code:

for item in container:
     if search_something(item):
        # Found it!
        process(item)
        break
 else:
     # Didn't find anything..
     not_found_in_container()