diff --git a/README.rst b/README.rst index d9a53302..8839798b 100644 --- a/README.rst +++ b/README.rst @@ -14,15 +14,16 @@ sphinx-js also works with TypeScript, using the TypeDoc tool in place of JSDoc a Setup ===== -1. Install JSDoc (or TypeDoc if you're writing TypeScript). The tool must be on your ``$PATH``, so you might want to install it globally:: +1. Install JSDoc (or TypeDoc if you're writing TypeScript). - npm install -g jsdoc + npm install jsdoc ...or... :: - npm install -g typedoc + npm install typedoc + + JSDoc 3.6.3 and 4.0.0 and TypeDoc 0.22 are known to work. - JSDoc 3.6.3 and 4.0.0 and TypeDoc 0.15.0 are known to work. 2. Install sphinx-js, which will pull in Sphinx itself as a dependency:: @@ -313,6 +314,21 @@ Configuration Reference ``jsdoc_cache`` Path to a file where JSDoc output will be cached. If omitted, JSDoc will be run every time Sphinx is. If you have a large number of source files, it may help to configure this value. But be careful: the cache is not automatically flushed if your source code changes; you must delete it manually. +How sphinx-js finds typedoc / jsdoc +----------------------------------- + +1. If the environment variable ``SPHINX_JS_NODE_MODULES`` is defined, it is + expected to point to a ``node_modules`` folder in which typedoc / jsdoc is installed. + +2. If ``SPHINX_JS_NODE_MODULES`` is not defined, we look in the directory of + ``conf.py`` for a ``node_modules`` folder in which typedoc / jsdoc. If this is + not found, we look for a ``node_modules`` folder in the parent directories + until we make it to the root of the file system. + +3. We check if ``typedoc`` / ``jsdoc`` are on the PATH, if so we use that. + +4. If none of the previous approaches located ``typedoc`` / ``jsdoc`` we raise an error. + Example ======= diff --git a/sphinx_js/analyzer_utils.py b/sphinx_js/analyzer_utils.py index 031d1c74..4b46f2fc 100644 --- a/sphinx_js/analyzer_utils.py +++ b/sphinx_js/analyzer_utils.py @@ -1,11 +1,15 @@ """Conveniences shared among analyzers""" import os +import shutil from collections.abc import Callable -from functools import wraps +from functools import cache, wraps from json import dump, load +from pathlib import Path from typing import Any, ParamSpec, TypeVar +from sphinx.errors import SphinxError + def program_name_on_this_platform(program: str) -> str: """Return the name of the executable file on the current platform, given a @@ -13,6 +17,32 @@ def program_name_on_this_platform(program: str) -> str: return program + ".cmd" if os.name == "nt" else program +@cache +def search_node_modules(cmdname: str, cmdpath: str, dir: str | Path) -> str: + if "SPHINX_JS_NODE_MODULES" in os.environ: + return str(Path(os.environ["SPHINX_JS_NODE_MODULES"]) / cmdpath) + + # We want to include "curdir" in parent_dirs, so add a garbage suffix + parent_dirs = (Path(dir) / "garbage").parents + + # search for local install + for base in parent_dirs: + typedoc = base / "node_modules" / cmdpath + print(base, typedoc) + + if typedoc.is_file(): + return str(typedoc.resolve()) + + # perhaps it's globally installed + result = shutil.which(cmdname) + if result: + return result + + raise SphinxError( + f'{cmdname} was not found. Install it using "npm install {cmdname}".' + ) + + class Command: def __init__(self, program: str): self.program = program_name_on_this_platform(program) diff --git a/sphinx_js/jsdoc.py b/sphinx_js/jsdoc.py index 1d618d8e..b2621a95 100644 --- a/sphinx_js/jsdoc.py +++ b/sphinx_js/jsdoc.py @@ -16,7 +16,12 @@ from sphinx.application import Sphinx from sphinx.errors import SphinxError -from .analyzer_utils import Command, cache_to_file, is_explicitly_rooted +from .analyzer_utils import ( + Command, + cache_to_file, + is_explicitly_rooted, + search_node_modules, +) from .ir import ( NO_DEFAULT, Attribute, @@ -265,7 +270,9 @@ def jsdoc_output( sphinx_conf_dir: str, config_path: str | None = None, ) -> list[Doclet]: - command = Command("jsdoc") + jsdoc = search_node_modules("jsdoc", "jsdoc/jsdoc.js", sphinx_conf_dir) + command = Command("node") + command.add(jsdoc) command.add("-X", *abs_source_paths) if config_path: command.add("-c", normpath(join(sphinx_conf_dir, config_path))) diff --git a/sphinx_js/typedoc.py b/sphinx_js/typedoc.py index 4ef2dd81..766b4164 100644 --- a/sphinx_js/typedoc.py +++ b/sphinx_js/typedoc.py @@ -2,7 +2,6 @@ import subprocess from collections.abc import Sequence -from errno import ENOENT from inspect import isclass from json import load from os.path import basename, join, normpath, relpath, sep, splitext @@ -11,10 +10,9 @@ from pydantic import BaseModel, Field, ValidationError from sphinx.application import Sphinx -from sphinx.errors import SphinxError from . import ir -from .analyzer_utils import Command, is_explicitly_rooted +from .analyzer_utils import Command, is_explicitly_rooted, search_node_modules from .suffix_tree import SuffixTree __all__ = ["Analyzer"] @@ -25,22 +23,15 @@ def typedoc_output( ) -> "Project": """Return the loaded JSON output of the TypeDoc command run over the given paths.""" - command = Command("typedoc") + typedoc = search_node_modules("typedoc", "typedoc/bin/typedoc", sphinx_conf_dir) + command = Command("node") + command.add(typedoc) if config_path: command.add("--tsconfig", normpath(join(sphinx_conf_dir, config_path))) with NamedTemporaryFile(mode="w+b") as temp: command.add("--json", temp.name, *abs_source_paths) - try: - subprocess.call(command.make()) - except OSError as exc: - if exc.errno == ENOENT: - raise SphinxError( - '%s was not found. Install it using "npm install -g typedoc".' - % command.program - ) - else: - raise + subprocess.call(command.make()) # typedoc emits a valid JSON file even if it finds no TS files in the dir: return parse(load(temp)) diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 00000000..92fcf76b --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,83 @@ +from pathlib import Path + +import pytest +from sphinx.errors import SphinxError + +from sphinx_js.analyzer_utils import search_node_modules + + +@pytest.fixture +def global_install(tmp_path_factory, monkeypatch): + tmpdir = tmp_path_factory.mktemp("my_program_global") + my_program = tmpdir / "my_program" + my_program.write_text("") + my_program.chmod(0o777) + monkeypatch.setenv("PATH", tmpdir, prepend=":") + return tmpdir + + +@pytest.fixture +def no_local_install(tmp_path_factory): + my_program = tmp_path_factory.mktemp("my_program_local") + working_dir = my_program / "a" / "b" / "c" + return working_dir + + +my_prog_path = Path("my_program/sub/bin.js") + + +@pytest.fixture +def local_install(no_local_install): + working_dir = no_local_install + bin_path = working_dir.parents[1] / "node_modules" / my_prog_path + bin_path.parent.mkdir(parents=True) + bin_path.write_text("") + return (working_dir, bin_path) + + +@pytest.fixture +def env_install(monkeypatch): + env_path = Path("/a/b/c") + monkeypatch.setenv("SPHINX_JS_NODE_MODULES", env_path) + return env_path / my_prog_path + + +def test_global(global_install, no_local_install): + # If no env or local, use global + working_dir = no_local_install + assert search_node_modules("my_program", my_prog_path, working_dir) == str( + global_install / "my_program" + ) + + +def test_node_modules1(global_install, local_install): + # If local and global, use local + [working_dir, bin_path] = local_install + assert search_node_modules("my_program", my_prog_path, working_dir) == str(bin_path) + + +def test_node_modules2(local_install): + # If local only, use local + [working_dir, bin_path] = local_install + assert search_node_modules("my_program", my_prog_path, working_dir) == str(bin_path) + + +def test_env1(env_install): + # If env only, use env + assert search_node_modules("my_program", my_prog_path, "/x/y/z") == str(env_install) + + +def test_env2(env_install, local_install, global_install): + # If env, local, and global, use env + [working_dir, _] = local_install + assert search_node_modules("my_program", my_prog_path, working_dir) == str( + env_install + ) + + +def test_err(): + with pytest.raises( + SphinxError, + match='my_program was not found. Install it using "npm install my_program"', + ): + search_node_modules("my_program", my_prog_path, "/a/b/c")