1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
|
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, # You can obtain one at http://mozilla.org/MPL/2.0/.
import os
import tempfile
from pathlib import PurePath
import frontmatter
import sphinx
import sphinx.ext.apidoc
import yaml
from mozbuild.base import MozbuildObject
from mozbuild.frontend.reader import BuildReader
from mozbuild.util import memoize
from mozpack.copier import FileCopier
from mozpack.files import FileFinder
from mozpack.manifests import InstallManifest
here = os.path.abspath(os.path.dirname(__file__))
build = MozbuildObject.from_environment(cwd=here)
MAIN_DOC_PATH = os.path.normpath(os.path.join(build.topsrcdir, "docs"))
logger = sphinx.util.logging.getLogger(__name__)
@memoize
def read_build_config(docdir):
"""Read the active build config and return the relevant doc paths.
The return value is cached so re-generating with the same docdir won't
invoke the build system a second time."""
trees = {}
python_package_dirs = set()
is_main = docdir == MAIN_DOC_PATH
relevant_mozbuild_path = None if is_main else docdir
# Reading the Sphinx variables doesn't require a full build context.
# Only define the parts we need.
class fakeconfig(object):
topsrcdir = build.topsrcdir
variables = ("SPHINX_TREES", "SPHINX_PYTHON_PACKAGE_DIRS")
reader = BuildReader(fakeconfig())
result = reader.find_variables_from_ast(variables, path=relevant_mozbuild_path)
for path, name, key, value in result:
reldir = os.path.dirname(path)
if name == "SPHINX_TREES":
# If we're building a subtree, only process that specific subtree.
# topsrcdir always uses POSIX-style path, normalize it for proper comparison.
absdir = os.path.normpath(os.path.join(build.topsrcdir, reldir, value))
if not is_main and absdir not in (docdir, MAIN_DOC_PATH):
# allow subpaths of absdir (i.e. docdir = <absdir>/sub/path/)
if docdir.startswith(absdir):
key = os.path.join(key, docdir.split(f"{key}/")[-1])
else:
continue
assert key
if key.startswith("/"):
key = key[1:]
else:
key = os.path.normpath(os.path.join(reldir, key))
if key in trees:
raise Exception(
"%s has already been registered as a destination." % key
)
trees[key] = os.path.join(reldir, value)
if name == "SPHINX_PYTHON_PACKAGE_DIRS":
python_package_dirs.add(os.path.join(reldir, value))
return trees, python_package_dirs
class _SphinxManager(object):
"""Manages the generation of Sphinx documentation for the tree."""
NO_AUTODOC = False
def __init__(self, topsrcdir, main_path):
self.topsrcdir = topsrcdir
self.conf_py_path = os.path.join(main_path, "conf.py")
self.index_path = os.path.join(main_path, "index.rst")
# Instance variables that get set in self.generate_docs()
self.staging_dir = None
self.trees = None
self.python_package_dirs = None
def generate_docs(self, app):
"""Generate/stage documentation."""
if self.NO_AUTODOC:
logger.info("Python/JS API documentation generation will be skipped")
app.config["extensions"].remove("sphinx.ext.autodoc")
app.config["extensions"].remove("sphinx_js")
self.staging_dir = os.path.join(app.outdir, "_staging")
logger.info("Reading Sphinx metadata from build configuration")
self.trees, self.python_package_dirs = read_build_config(app.srcdir)
logger.info("Staging static documentation")
self._synchronize_docs(app)
if not self.NO_AUTODOC:
self._generate_python_api_docs()
def _generate_python_api_docs(self):
"""Generate Python API doc files."""
out_dir = os.path.join(self.staging_dir, "python")
base_args = ["--no-toc", "-o", out_dir]
for p in sorted(self.python_package_dirs):
full = os.path.join(self.topsrcdir, p)
finder = FileFinder(full)
dirs = {os.path.dirname(f[0]) for f in finder.find("**")}
test_dirs = {"test", "tests"}
excludes = {d for d in dirs if set(PurePath(d).parts) & test_dirs}
args = list(base_args)
args.append(full)
args.extend(excludes)
sphinx.ext.apidoc.main(argv=args)
def _process_markdown(self, m, markdown_file, dest):
"""
When dealing with a markdown file, we check if we have a front matter.
If this is the case, we read the information, create a temporary file,
reuse the front matter info into the md file
"""
with open(markdown_file, "r", encoding="utf_8") as f:
# Load the front matter header
post = frontmatter.load(f)
if len(post.keys()) > 0:
# Has a front matter, use it
with tempfile.NamedTemporaryFile("w", delete=False) as fh:
# Use the frontmatter title
fh.write(post["title"] + "\n")
# Add the md syntax for the title
fh.write("=" * len(post["title"]) + "\n")
# If there is a summary, add it
if "summary" in post:
fh.write(post["summary"] + "\n")
# Write the content
fh.write(post.__str__())
fh.close()
# Instead of a symlink, we copy the file
m.add_copy(fh.name, dest)
else:
# No front matter, create the symlink like for rst
# as it will be the the same file
m.add_link(markdown_file, dest)
def _synchronize_docs(self, app):
m = InstallManifest()
with open(os.path.join(MAIN_DOC_PATH, "config.yml"), "r") as fh:
tree_config = yaml.safe_load(fh)["categories"]
m.add_link(self.conf_py_path, "conf.py")
for dest, source in sorted(self.trees.items()):
source_dir = os.path.join(self.topsrcdir, source)
for root, _, files in os.walk(source_dir):
for f in files:
source_path = os.path.normpath(os.path.join(root, f))
rel_source = source_path[len(source_dir) + 1 :]
target = os.path.normpath(os.path.join(dest, rel_source))
if source_path.endswith(".md"):
self._process_markdown(
m, source_path, os.path.join(".", target)
)
else:
m.add_link(source_path, target)
copier = FileCopier()
m.populate_registry(copier)
# In the case of livereload, we don't want to delete unmodified (unaccounted) files.
copier.copy(
self.staging_dir, remove_empty_directories=False, remove_unaccounted=False
)
with open(self.index_path, "r") as fh:
data = fh.read()
def is_toplevel(key):
"""Whether the tree is nested under the toplevel index, or is
nested under another tree's index.
"""
for k in self.trees:
if k == key:
continue
if key.startswith(k):
return False
return True
def format_paths(paths):
source_doc = ["%s/index" % p for p in paths]
return "\n ".join(source_doc)
toplevel_trees = {k: v for k, v in self.trees.items() if is_toplevel(k)}
CATEGORIES = {}
# generate the datastructure to deal with the tree
for t in tree_config:
CATEGORIES[t] = format_paths(tree_config[t])
# During livereload, we don't correctly rebuild the full document
# tree (Bug 1557020). The page is no longer referenced within the index
# tree, thus we shall check categorisation only if complete tree is being rebuilt.
if app.srcdir == self.topsrcdir:
indexes = set(
[
os.path.normpath(os.path.join(p, "index"))
for p in toplevel_trees.keys()
]
)
# Format categories like indexes
cats = "\n".join(CATEGORIES.values()).split("\n")
# Remove heading spaces
cats = [os.path.normpath(x.strip()) for x in cats]
indexes = tuple(set(indexes) - set(cats))
if indexes:
# In case a new doc isn't categorized
print(indexes)
raise Exception(
"Uncategorized documentation. Please add it in docs/config.yml"
)
data = data.format(**CATEGORIES)
with open(os.path.join(self.staging_dir, "index.rst"), "w") as fh:
fh.write(data)
manager = _SphinxManager(build.topsrcdir, MAIN_DOC_PATH)
|