|
| 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