Skip to content

Commit 195c3ab

Browse files
authored
Rewrite Sphinx Check Without Tox (#42815)
* start sphinx check base * add apidoc function * fix apidoc typing * build sphinx * initial sphinx script * progress on editing output directories * progress on editing output directories * outputting to right directories? * directory updates, source as inputs and all generated docs to staging * makes site but some discrepancy in built source files * typing * resolve conftest and missing samples issue * use package_dir as cwd for build * check python ver * minor fix and update gitignore * wording fix * log message fix * another log message fix * run black
1 parent 44c9ef6 commit 195c3ab

File tree

4 files changed

+356
-2
lines changed

4 files changed

+356
-2
lines changed

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,4 +174,7 @@ component-detection-pip-report.json
174174
.github/prompts/copilot-instructions.md
175175

176176
# No uv lock for now
177-
uv.lock
177+
uv.lock
178+
179+
# Sphinx generated documentation
180+
website/

eng/tools/azure-sdk-tools/azpysdk/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from .import_all import import_all
1717
from .mypy import mypy
1818
from .pylint import pylint
19+
from .sphinx import sphinx
1920

2021
from ci_tools.logging import configure_logging, logger
2122

@@ -74,6 +75,7 @@ def build_parser() -> argparse.ArgumentParser:
7475
import_all().register(subparsers, [common])
7576
mypy().register(subparsers, [common])
7677
pylint().register(subparsers, [common])
78+
sphinx().register(subparsers, [common])
7779

7880
return parser
7981

eng/tools/azure-sdk-tools/azpysdk/mypy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def register(self, subparsers: "argparse._SubParsersAction", parent_parsers: Opt
3939

4040
def run(self, args: argparse.Namespace) -> int:
4141
"""Run the mypy check command."""
42-
logger.info("Running mypy check in isolated venv...")
42+
logger.info("Running mypy check...")
4343

4444
set_envvar_defaults()
4545

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
import argparse
2+
import os
3+
import sys
4+
import shutil
5+
import glob
6+
7+
from typing import Optional, List
8+
from subprocess import CalledProcessError, check_call
9+
from pathlib import Path
10+
11+
from .Check import Check
12+
from ci_tools.functions import pip_install
13+
from ci_tools.scenario.generation import create_package_and_install
14+
from ci_tools.variables import in_ci, set_envvar_defaults
15+
from ci_tools.variables import discover_repo_root
16+
from ci_tools.variables import in_analyze_weekly
17+
18+
from ci_tools.logging import logger
19+
20+
# dependencies
21+
SPHINX_VERSION = "8.2.0"
22+
SPHINX_RTD_THEME_VERSION = "3.0.2"
23+
MYST_PARSER_VERSION = "4.0.1"
24+
SPHINX_CONTRIB_JQUERY_VERSION = "4.1"
25+
26+
RST_EXTENSION_FOR_INDEX = """
27+
28+
## Indices and tables
29+
30+
- {{ref}}`genindex`
31+
- {{ref}}`modindex`
32+
- {{ref}}`search`
33+
34+
```{{toctree}}
35+
:caption: Developer Documentation
36+
:glob: true
37+
:maxdepth: 5
38+
39+
{}
40+
41+
```
42+
43+
"""
44+
REPO_ROOT = discover_repo_root()
45+
ci_doc_dir = os.path.join(REPO_ROOT, "_docs")
46+
sphinx_conf_dir = os.path.join(REPO_ROOT, "doc/sphinx")
47+
generate_mgmt_script = os.path.join(REPO_ROOT, "doc/sphinx/generate_doc.py")
48+
49+
50+
# env prep helper functions
51+
def create_index_file(readme_location: str, package_rst: str) -> str:
52+
readme_ext = os.path.splitext(readme_location)[1]
53+
54+
output = ""
55+
if readme_ext == ".md":
56+
with open(readme_location, "r") as file:
57+
output = file.read()
58+
else:
59+
logger.error("{} is not a valid readme type. Expecting RST or MD.".format(readme_location))
60+
61+
output += RST_EXTENSION_FOR_INDEX.format(package_rst)
62+
63+
return output
64+
65+
66+
def create_index(doc_folder: str, source_location: str, namespace: str) -> None:
67+
index_content = ""
68+
69+
package_rst = "{}.rst".format(namespace)
70+
content_destination = os.path.join(doc_folder, "index.md")
71+
72+
if not os.path.exists(doc_folder):
73+
os.mkdir(doc_folder)
74+
75+
# grep all content
76+
markdown_readmes = glob.glob(os.path.join(source_location, "README.md"))
77+
78+
# if markdown, take that, otherwise rst
79+
if markdown_readmes:
80+
index_content = create_index_file(markdown_readmes[0], package_rst)
81+
else:
82+
logger.warning("No readmes detected for this namespace {}".format(namespace))
83+
index_content = RST_EXTENSION_FOR_INDEX.format(package_rst)
84+
85+
# write index
86+
with open(content_destination, "w+", encoding="utf-8") as f:
87+
f.write(index_content)
88+
89+
90+
def write_version(site_folder: str, version: str) -> None:
91+
if not os.path.isdir(site_folder):
92+
os.mkdir(site_folder)
93+
94+
with open(os.path.join(site_folder, "version.txt"), "w") as f:
95+
f.write(version)
96+
97+
98+
# apidoc helper functions
99+
def is_mgmt_package(pkg_name: str) -> bool:
100+
return pkg_name != "azure-mgmt-core" and ("mgmt" in pkg_name or "cognitiveservices" in pkg_name)
101+
102+
103+
def copy_existing_docs(source: str, target: str) -> None:
104+
for file in os.listdir(source):
105+
logger.info("Copying {}".format(file))
106+
shutil.copy(os.path.join(source, file), target)
107+
108+
109+
def mgmt_apidoc(output_dir: str, target_folder: str, executable: str) -> int:
110+
command_array = [
111+
executable,
112+
generate_mgmt_script,
113+
"-p",
114+
target_folder,
115+
"-o",
116+
output_dir,
117+
"--verbose",
118+
]
119+
120+
try:
121+
logger.info("Command to generate management sphinx sources: {}".format(command_array))
122+
123+
check_call(command_array)
124+
except CalledProcessError as e:
125+
logger.error("script failed for path {} exited with error {}".format(output_dir, e.returncode))
126+
return 1
127+
return 0
128+
129+
130+
def sphinx_apidoc(output_dir: str, target_dir: str, namespace: str) -> int:
131+
working_doc_folder = os.path.join(output_dir, "doc")
132+
command_array = [
133+
"sphinx-apidoc",
134+
"--no-toc",
135+
"--module-first",
136+
"-o",
137+
os.path.join(output_dir, "docgen"), # This is the output folder
138+
os.path.join(target_dir, ""), # This is the input folder
139+
os.path.join(target_dir, "test*"), # This argument and below are "exclude" directory arguments
140+
os.path.join(target_dir, "example*"),
141+
os.path.join(target_dir, "sample*"),
142+
os.path.join(target_dir, "setup.py"),
143+
os.path.join(target_dir, "conftest.py"),
144+
]
145+
146+
try:
147+
# if a `doc` folder exists, just leverage the sphinx sources found therein.
148+
if os.path.exists(working_doc_folder):
149+
logger.info("Copying files into sphinx source folder.")
150+
copy_existing_docs(working_doc_folder, os.path.join(output_dir, "docgen"))
151+
152+
# otherwise, we will run sphinx-apidoc to generate the sources
153+
else:
154+
logger.info("Sphinx api-doc command: {}".format(command_array))
155+
check_call(command_array)
156+
# We need to clean "azure.rst", and other RST before the main namespaces, as they are never
157+
# used and will log as a warning later by sphinx-build, which is blocking strict_sphinx
158+
base_path = Path(os.path.join(output_dir, "docgen/"))
159+
namespace = namespace.rpartition(".")[0]
160+
while namespace:
161+
rst_file_to_delete = base_path / f"{namespace}.rst"
162+
logger.info(f"Removing {rst_file_to_delete}")
163+
rst_file_to_delete.unlink(missing_ok=True)
164+
namespace = namespace.rpartition(".")[0]
165+
except CalledProcessError as e:
166+
logger.error("sphinx-apidoc failed for path {} exited with error {}".format(output_dir, e.returncode))
167+
return 1
168+
return 0
169+
170+
171+
# build helper functions
172+
def move_output_and_compress(target_dir: str, package_dir: str, package_name: str) -> None:
173+
if not os.path.exists(ci_doc_dir):
174+
os.mkdir(ci_doc_dir)
175+
176+
individual_zip_location = os.path.join(ci_doc_dir, package_dir, package_name)
177+
shutil.make_archive(individual_zip_location, "gztar", target_dir)
178+
179+
180+
def should_build_docs(package_name: str) -> bool:
181+
return not (
182+
"nspkg" in package_name
183+
or package_name
184+
in [
185+
"azure",
186+
"azure-mgmt",
187+
"azure-keyvault",
188+
"azure-documentdb",
189+
"azure-mgmt-documentdb",
190+
"azure-servicemanagement-legacy",
191+
"azure-core-tracing-opencensus",
192+
]
193+
)
194+
195+
196+
def sphinx_build(package_dir: str, target_dir: str, output_dir: str, fail_on_warning: bool) -> int:
197+
command_array = [
198+
"sphinx-build",
199+
"-b",
200+
"html",
201+
"-A",
202+
"include_index_link=True",
203+
"-c",
204+
sphinx_conf_dir,
205+
target_dir,
206+
output_dir,
207+
]
208+
if fail_on_warning:
209+
command_array.append("-W")
210+
command_array.append("--keep-going")
211+
212+
try:
213+
logger.info("Sphinx build command: {}".format(command_array))
214+
check_call(command_array, cwd=package_dir)
215+
except CalledProcessError as e:
216+
logger.error("sphinx-build failed for path {} exited with error {}".format(target_dir, e.returncode))
217+
if in_analyze_weekly():
218+
from gh_tools.vnext_issue_creator import create_vnext_issue
219+
220+
create_vnext_issue(package_dir, "sphinx")
221+
return 1
222+
return 0
223+
224+
225+
class sphinx(Check):
226+
def __init__(self) -> None:
227+
super().__init__()
228+
229+
def register(
230+
self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None
231+
) -> None:
232+
"""Register the `sphinx` check. The sphinx check installs sphinx and and builds sphinx documentation for the target package."""
233+
parents = parent_parsers or []
234+
p = subparsers.add_parser(
235+
"sphinx",
236+
parents=parents,
237+
help="Prepares a doc folder for consumption by sphinx, runs sphinx-apidoc against target folder and handles management generation, and run sphinx-build against target folder. Zips and moves resulting files to a root location as well.",
238+
)
239+
p.set_defaults(func=self.run)
240+
241+
p.add_argument("--next", default=False, help="Next version of sphinx is being tested", required=False)
242+
243+
p.add_argument("--inci", dest="in_ci", action="store_true", default=False)
244+
245+
def run(self, args: argparse.Namespace) -> int:
246+
"""Run the sphinx check command."""
247+
logger.info("Running sphinx check...")
248+
249+
set_envvar_defaults()
250+
251+
targeted = self.get_targeted_directories(args)
252+
253+
results: List[int] = []
254+
255+
for parsed in targeted:
256+
package_dir = parsed.folder
257+
package_name = parsed.name
258+
259+
executable, staging_directory = self.get_executable(args.isolate, args.command, sys.executable, package_dir)
260+
logger.info(f"Processing {package_name} for sphinx check")
261+
262+
# check Python version
263+
if sys.version_info < (3, 11):
264+
logger.error("This tool requires Python 3.11 or newer. Please upgrade your Python interpreter.")
265+
return 1
266+
267+
create_package_and_install(
268+
distribution_directory=staging_directory,
269+
target_setup=package_dir,
270+
skip_install=False,
271+
cache_dir=None,
272+
work_dir=staging_directory,
273+
force_create=False,
274+
package_type="sdist",
275+
pre_download_disabled=False,
276+
python_executable=executable,
277+
)
278+
279+
# install sphinx
280+
try:
281+
if args.next:
282+
pip_install(
283+
["sphinx", "sphinx_rtd_theme", "myst_parser", "sphinxcontrib-jquery"],
284+
True,
285+
executable,
286+
package_dir,
287+
)
288+
else:
289+
pip_install(
290+
[
291+
f"sphinx=={SPHINX_VERSION}",
292+
f"sphinx_rtd_theme=={SPHINX_RTD_THEME_VERSION}",
293+
f"myst_parser=={MYST_PARSER_VERSION}",
294+
f"sphinxcontrib-jquery=={SPHINX_CONTRIB_JQUERY_VERSION}",
295+
],
296+
True,
297+
executable,
298+
package_dir,
299+
)
300+
except CalledProcessError as e:
301+
logger.error("Failed to install sphinx:", e)
302+
return e.returncode
303+
304+
logger.info(f"Running sphinx against {package_name}")
305+
306+
# prep env for sphinx
307+
doc_folder = os.path.join(staging_directory, "docgen")
308+
site_folder = os.path.join(package_dir, "website")
309+
310+
if should_build_docs(package_name):
311+
create_index(doc_folder, package_dir, parsed.namespace)
312+
313+
write_version(site_folder, parsed.version)
314+
else:
315+
logger.info("Skipping sphinx prep for {}".format(package_name))
316+
317+
# run apidoc
318+
if should_build_docs(parsed.name):
319+
if is_mgmt_package(parsed.name):
320+
results.append(mgmt_apidoc(doc_folder, package_dir, executable))
321+
else:
322+
results.append(sphinx_apidoc(staging_directory, package_dir, parsed.namespace))
323+
else:
324+
logger.info("Skipping sphinx source generation for {}".format(parsed.name))
325+
326+
# build
327+
if should_build_docs(package_name):
328+
# Only data-plane libraries run strict sphinx at the moment
329+
fail_on_warning = not is_mgmt_package(package_name)
330+
results.append(
331+
sphinx_build(
332+
package_dir,
333+
doc_folder, # source
334+
site_folder, # output
335+
fail_on_warning=fail_on_warning,
336+
)
337+
)
338+
339+
if in_ci() or args.in_ci:
340+
move_output_and_compress(site_folder, package_dir, package_name)
341+
if in_analyze_weekly():
342+
from gh_tools.vnext_issue_creator import close_vnext_issue
343+
344+
close_vnext_issue(package_name, "sphinx")
345+
346+
else:
347+
logger.info("Skipping sphinx build for {}".format(package_name))
348+
349+
return max(results) if results else 0

0 commit comments

Comments
 (0)