You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
343 lines
11 KiB
Python
343 lines
11 KiB
Python
from collections import OrderedDict, namedtuple
|
|
import fnmatch
|
|
import os
|
|
import pathlib
|
|
import re
|
|
|
|
from jinja2 import Environment, FileSystemLoader, TemplateNotFound
|
|
import sphinx
|
|
import sphinx.util
|
|
from sphinx.util.console import colorize
|
|
from sphinx.util.display import status_iterator
|
|
from sphinx.util.osutil import ensuredir
|
|
import sphinx.util.logging
|
|
|
|
from ..settings import TEMPLATE_DIR
|
|
|
|
LOGGER = sphinx.util.logging.getLogger(__name__)
|
|
_OWN_PAGE_LEVELS = [
|
|
"package",
|
|
"module",
|
|
"exception",
|
|
"class",
|
|
"function",
|
|
"method",
|
|
"property",
|
|
"data",
|
|
"attribute",
|
|
]
|
|
|
|
Path = namedtuple("Path", ["absolute", "relative"])
|
|
|
|
|
|
class PythonMapperBase:
|
|
"""Base object for JSON -> Python object mapping.
|
|
|
|
Subclasses of this object will handle their language specific JSON input,
|
|
and map that onto this standard Python object.
|
|
Subclasses may also include language-specific attributes on this object.
|
|
|
|
Args:
|
|
obj: JSON object representing this object
|
|
jinja_env: A template environment for rendering this object
|
|
|
|
Attributes:
|
|
id (str): A globally unique identifier for this object.
|
|
Generally a fully qualified name, including namespace.
|
|
name (str): A short "display friendly" name for this object.
|
|
docstring (str): The documentation for this object
|
|
imports (list): Imports in this object
|
|
children (list): Children of this object
|
|
parameters (list): Parameters to this object
|
|
methods (list): Methods on this object
|
|
"""
|
|
|
|
language = "base"
|
|
type = "base"
|
|
_RENDER_LOG_LEVEL = "VERBOSE"
|
|
|
|
def __init__(self, obj, jinja_env, app, url_root, options=None):
|
|
self.app = app
|
|
self.obj = obj
|
|
self.options = options
|
|
self.jinja_env = jinja_env
|
|
self.url_root = url_root
|
|
|
|
self.name = None
|
|
self.qual_name = None
|
|
self.id = None
|
|
|
|
def __getstate__(self):
|
|
"""Obtains serialisable data for pickling."""
|
|
__dict__ = self.__dict__.copy()
|
|
__dict__.update(app=None, jinja_env=None) # clear unpickable attributes
|
|
return __dict__
|
|
|
|
def render(self, **kwargs):
|
|
LOGGER.log(self._RENDER_LOG_LEVEL, "Rendering %s", self.id)
|
|
|
|
ctx = {}
|
|
try:
|
|
template = self.jinja_env.get_template(f"{self.language}/{self.type}.rst")
|
|
except TemplateNotFound:
|
|
template = self.jinja_env.get_template(f"base/{self.type}.rst")
|
|
|
|
ctx.update(**self.get_context_data())
|
|
ctx.update(**kwargs)
|
|
return template.render(**ctx)
|
|
|
|
@property
|
|
def rendered(self):
|
|
"""Shortcut to render an object in templates."""
|
|
return self.render()
|
|
|
|
def get_context_data(self):
|
|
own_page_level = self.app.config.autoapi_own_page_level
|
|
desired_page_level = _OWN_PAGE_LEVELS.index(own_page_level)
|
|
own_page_types = set(_OWN_PAGE_LEVELS[: desired_page_level + 1])
|
|
|
|
return {
|
|
"autoapi_options": self.app.config.autoapi_options,
|
|
"include_summaries": self.app.config.autoapi_include_summaries,
|
|
"obj": self,
|
|
"own_page_types": own_page_types,
|
|
"sphinx_version": sphinx.version_info,
|
|
}
|
|
|
|
def __lt__(self, other):
|
|
"""Object sorting comparison"""
|
|
if isinstance(other, PythonMapperBase):
|
|
return self.id < other.id
|
|
return super().__lt__(other)
|
|
|
|
def __str__(self):
|
|
return f"<{self.__class__.__name__} {self.id}>"
|
|
|
|
@property
|
|
def short_name(self):
|
|
"""Shorten name property"""
|
|
return self.name.split(".")[-1]
|
|
|
|
def output_dir(self, root):
|
|
"""The directory to render this object."""
|
|
module = self.id[: -(len("." + self.qual_name))]
|
|
parts = [root] + module.split(".")
|
|
return pathlib.PurePosixPath(*parts)
|
|
|
|
def output_filename(self):
|
|
"""The name of the file to render into, without a file suffix."""
|
|
filename = self.qual_name
|
|
if filename == "index":
|
|
filename = ".index"
|
|
|
|
return filename
|
|
|
|
@property
|
|
def include_path(self):
|
|
"""Return 'absolute' path without regarding OS path separator
|
|
|
|
This is used in ``toctree`` directives, as Sphinx always expects Unix
|
|
path separators
|
|
"""
|
|
return str(self.output_dir(self.url_root) / self.output_filename())
|
|
|
|
@property
|
|
def display(self):
|
|
"""Whether to display this object or not.
|
|
|
|
:type: bool
|
|
"""
|
|
return True
|
|
|
|
@property
|
|
def ref_type(self):
|
|
return self.type
|
|
|
|
@property
|
|
def ref_directive(self):
|
|
return self.type
|
|
|
|
|
|
class SphinxMapperBase:
|
|
"""Base class for mapping `PythonMapperBase` objects to Sphinx.
|
|
|
|
Args:
|
|
app: Sphinx application instance
|
|
"""
|
|
|
|
def __init__(self, app, template_dir=None, dir_root=None, url_root=None):
|
|
self.app = app
|
|
|
|
template_paths = [TEMPLATE_DIR]
|
|
|
|
if template_dir:
|
|
# Put at the front so it's loaded first
|
|
template_paths.insert(0, template_dir)
|
|
|
|
self.jinja_env = Environment(
|
|
loader=FileSystemLoader(template_paths),
|
|
trim_blocks=True,
|
|
lstrip_blocks=True,
|
|
)
|
|
|
|
def _wrapped_prepare(value):
|
|
return value
|
|
|
|
self.jinja_env.filters["prepare_docstring"] = _wrapped_prepare
|
|
if self.app.config.autoapi_prepare_jinja_env:
|
|
self.app.config.autoapi_prepare_jinja_env(self.jinja_env)
|
|
|
|
own_page_level = self.app.config.autoapi_own_page_level
|
|
desired_page_level = _OWN_PAGE_LEVELS.index(own_page_level)
|
|
self.own_page_types = set(_OWN_PAGE_LEVELS[: desired_page_level + 1])
|
|
|
|
self.dir_root = dir_root
|
|
self.url_root = url_root
|
|
|
|
# Mapping of {filepath -> raw data}
|
|
self.paths = OrderedDict()
|
|
# Mapping of {object id -> Python Object}
|
|
self.objects_to_render = OrderedDict()
|
|
# Mapping of {object id -> Python Object}
|
|
self.all_objects = OrderedDict()
|
|
# Mapping of {namespace id -> Python Object}
|
|
self.namespaces = OrderedDict()
|
|
|
|
def load(self, patterns, dirs, ignore=None):
|
|
"""Load objects from the filesystem into the ``paths`` dictionary."""
|
|
paths = list(self.find_files(patterns=patterns, dirs=dirs, ignore=ignore))
|
|
for path in status_iterator(
|
|
paths,
|
|
colorize("bold", "[AutoAPI] Reading files... "),
|
|
"darkgreen",
|
|
len(paths),
|
|
):
|
|
data = self.read_file(path=path)
|
|
if data:
|
|
self.paths[path] = data
|
|
|
|
return True
|
|
|
|
@staticmethod
|
|
def find_files(patterns, dirs, ignore):
|
|
if not ignore:
|
|
ignore = []
|
|
|
|
pattern_regexes = []
|
|
for pattern in patterns:
|
|
regex = re.compile(fnmatch.translate(pattern).replace(".*", "(.*)"))
|
|
pattern_regexes.append((pattern, regex))
|
|
|
|
for _dir in dirs:
|
|
for root, _, filenames in os.walk(_dir):
|
|
seen = set()
|
|
for pattern, pattern_re in pattern_regexes:
|
|
for filename in fnmatch.filter(filenames, pattern):
|
|
skip = False
|
|
|
|
match = re.match(pattern_re, filename)
|
|
norm_name = match.groups()
|
|
if norm_name in seen:
|
|
continue
|
|
|
|
# Skip ignored files
|
|
for ignore_pattern in ignore:
|
|
if fnmatch.fnmatch(
|
|
os.path.join(root, filename), ignore_pattern
|
|
):
|
|
LOGGER.info(
|
|
colorize("bold", "[AutoAPI] ")
|
|
+ colorize(
|
|
"darkgreen", f"Ignoring {root}/{filename}"
|
|
)
|
|
)
|
|
skip = True
|
|
|
|
if skip:
|
|
continue
|
|
|
|
# Make sure the path is full
|
|
if not os.path.isabs(filename):
|
|
filename = os.path.join(root, filename)
|
|
|
|
yield filename
|
|
seen.add(norm_name)
|
|
|
|
def read_file(self, path, **kwargs):
|
|
"""Read file input into memory
|
|
|
|
Args:
|
|
path: Path of file to read
|
|
"""
|
|
# TODO support JSON here
|
|
# TODO sphinx way of reporting errors in logs?
|
|
raise NotImplementedError
|
|
|
|
def add_object(self, obj):
|
|
"""Add object to local and app environment storage
|
|
|
|
Args:
|
|
obj: Instance of a AutoAPI object
|
|
"""
|
|
display = obj.display
|
|
if display and obj.type in self.own_page_types:
|
|
self.objects_to_render[obj.id] = obj
|
|
|
|
self.all_objects[obj.id] = obj
|
|
child_stack = list(obj.children)
|
|
while child_stack:
|
|
child = child_stack.pop()
|
|
self.all_objects[child.id] = child
|
|
if display and child.type in self.own_page_types:
|
|
self.objects_to_render[child.id] = child
|
|
child_stack.extend(getattr(child, "children", ()))
|
|
|
|
def map(self, options=None):
|
|
"""Trigger find of serialized sources and build objects"""
|
|
for _, data in status_iterator(
|
|
self.paths.items(),
|
|
colorize("bold", "[AutoAPI] ") + "Mapping Data... ",
|
|
length=len(self.paths),
|
|
stringify_func=(lambda x: x[0]),
|
|
):
|
|
for obj in self.create_class(data, options=options):
|
|
self.add_object(obj)
|
|
|
|
def create_class(self, data, options=None, **kwargs):
|
|
"""Create class object.
|
|
|
|
Args:
|
|
data: Instance of a AutoAPI object
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
def output_rst(self, source_suffix):
|
|
for _, obj in status_iterator(
|
|
self.objects_to_render.items(),
|
|
colorize("bold", "[AutoAPI] ") + "Rendering Data... ",
|
|
length=len(self.objects_to_render),
|
|
verbosity=1,
|
|
stringify_func=(lambda x: x[0]),
|
|
):
|
|
rst = obj.render(is_own_page=True)
|
|
if not rst:
|
|
continue
|
|
|
|
output_dir = obj.output_dir(self.dir_root)
|
|
ensuredir(output_dir)
|
|
output_path = output_dir / obj.output_filename()
|
|
path = f"{output_path}{source_suffix}"
|
|
with open(path, "wb+") as detail_file:
|
|
detail_file.write(rst.encode("utf-8"))
|
|
|
|
if self.app.config.autoapi_add_toctree_entry:
|
|
self._output_top_rst()
|
|
|
|
def _output_top_rst(self):
|
|
# Render Top Index
|
|
top_level_index = os.path.join(self.dir_root, "index.rst")
|
|
pages = [obj for obj in self.objects_to_render.values() if obj.display]
|
|
with open(top_level_index, "wb") as top_level_file:
|
|
content = self.jinja_env.get_template("index.rst")
|
|
top_level_file.write(content.render(pages=pages).encode("utf-8"))
|