diff --git a/README.md b/README.md index 834ab99..6026091 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,20 @@ Use the `-o`, `--output` option to write to a file instead of standard output: ```bash griffe2md markdown -o markdown.md ``` + +`griffe2md` can be configured in either `pyproject.toml` or a `griffe2md.toml` file. The latter can be placed in a `.config` or `config` directory in the project root. + +`griffe2md.toml` file is structured as a simple key-value dictionary, e.g.: + +```toml +docstring_style = "sphinx" +``` + +If you configure it in `pyproject.toml`, the configuration should go under the `tool.griffe2md` key: + +```toml +[tool.griffe2md] +docstring_style = "sphinx" +``` + +See [the documentation](https://mkdocstrings.github.io/griffe2md/reference/griffe2md/config/#griffe2md.config.ConfigDict) for reference. diff --git a/duties.py b/duties.py index 8152356..7cee833 100644 --- a/duties.py +++ b/duties.py @@ -185,7 +185,7 @@ def coverage(ctx: Context) -> None: @duty -def test(ctx: Context, *cli_args: str, match: str = "") -> None: +def test(ctx: Context, *cli_args: str, match: str = "") -> None: # noqa: PT028 """Run the test suite. Parameters: diff --git a/pyproject.toml b/pyproject.toml index 77fbe4e..c4af0a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,8 @@ dependencies = [ "griffe>=0.49", "jinja2>=3.1.2", "mdformat>=0.7.16", + # YORE: EOL 3.10: Remove line. + "tomli>=2.0; python_version < '3.11'", ] [project.urls] diff --git a/src/griffe2md/cli.py b/src/griffe2md/cli.py index 8e15217..d34faf4 100644 --- a/src/griffe2md/cli.py +++ b/src/griffe2md/cli.py @@ -18,6 +18,7 @@ from typing import Any from griffe2md import debug +from griffe2md.config import load_config from griffe2md.main import write_package_docs @@ -57,5 +58,7 @@ def main(args: list[str] | None = None) -> int: """ parser = get_parser() opts = parser.parse_args(args=args) - write_package_docs(opts.package, output=opts.output) + config = load_config() + + write_package_docs(opts.package, config, opts.output) return 0 diff --git a/src/griffe2md/config.py b/src/griffe2md/config.py new file mode 100644 index 0000000..13a635b --- /dev/null +++ b/src/griffe2md/config.py @@ -0,0 +1,256 @@ +"""Load configuration.""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Literal, TypedDict, cast + +# YORE: EOL 3.10: Replace block with line 2. +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +if TYPE_CHECKING: + from re import Pattern + +logger = logging.getLogger(__name__) + +CONFIG_FILE_PATHS = ( + Path(".config/griffe2md.toml"), + Path("config/griffe2md.toml"), + Path("pyproject.toml"), +) + + +def _locate_config_file() -> Path | None: + for path in CONFIG_FILE_PATHS: + if path.is_file(): + return path + return None + + +def load_config() -> ConfigDict | None: + """Load the configuration if config file or config entry in pyproject.toml exists. + + If neither config file was found or pyproject.toml file doesn't have + a `[tool.griffe2md]` section, None is returned. + """ + if not (config_path := _locate_config_file()): + return None + + logger.debug("Loading config from %s", config_path) + + with config_path.open("rb") as f: + config = tomllib.load(f) + + if config_path.name == "pyproject.toml": + return config.get("tool", {}).get("griffe2md", None) + return cast("ConfigDict", config) + + +class ConfigDict(TypedDict): + """Configuration for griffe2md, griffe and mkdocstrings.""" + + allow_inspection: bool + """Allow using introspection on modules for which sources aren't available (compiled modules, etc.).""" + + annotations_path: Literal["brief", "source", "full"] + """The verbosity for annotations path: `brief` (recommended), `source` (as written in the source), or `full`.""" + + docstring_options: dict + """mkdocstring [configuration](https://mkdocstrings.github.io/python/usage/configuration/general/)""" + + docstring_section_style: Literal["list", "table"] + """The style used to render docstring sections.""" + + docstring_style: Literal["google", "numpy", "sphinx", "auto"] | None + """The style in which docstrings are written: `auto`, `google`, `numpy`, `sphinx`, or `None`.""" + + filters: list[str] | list[tuple[Pattern[str], bool]] + """A list of filters. + + A filter starting with `!` will exclude matching objects instead of including them. + The `members` option takes precedence over `filters` (filters will still be applied recursively + to lower members in the hierarchy). + """ + + group_by_category: bool + """Group the object's children by categories: attributes, classes, functions, and modules.""" + + heading_level: int + """The initial heading level to use.""" + + inherited_members: bool | list[str] + """A boolean, or an explicit list of inherited members to render. + + If true, select all inherited members, which can then be filtered with `members`. + If false or empty list, do not select any inherited member. + """ + + line_length: int + """Maximum line length when formatting code/signatures.""" + + load_external_modules: bool + """Whether to always load external modules/packages.""" + + members: list[str] | bool | None + """A boolean, or an explicit list of members to render. + + If true, select all members without further filtering. + If false or empty list, do not render members. + If none, select all members and apply further filtering with filters and docstrings. + """ + + members_order: Literal["alphabetical", "source"] + """The members ordering to use. + + - `alphabetical`: order members alphabetically; + - `source`: order members as they appear in the source file. + """ + + merge_init_into_class: bool + """Whether to merge the `__init__` method into the class' signature and docstring.""" + + preload_modules: list[str] | None + """Pre-load modules that are not specified directly in autodoc instructions (`::: identifier`). + + It is useful when you want to render documentation for a particular member of an object, + and this member is imported from another package than its parent. + + For an imported member to be rendered, you need to add it to the `__all__` attribute + of the importing module. + + The modules must be listed as an array of strings. + """ + + separate_signature: bool + """Whether to put the whole signature in a code block below the heading. + + If Black or Ruff are installed, the signature is also formatted using them. + """ + + show_bases: bool + """Show the base classes of a class.""" + + show_category_heading: bool + """When grouped by categories, show a heading for each category.""" + + show_docstring_attributes: bool + """Whether to display the 'Attributes' section in the object's docstring.""" + + show_docstring_classes: bool + """Whether to display the 'Classes' section in the object's docstring.""" + + show_docstring_description: bool + """Whether to display the textual block (including admonitions) in the object's docstring.""" + + show_docstring_examples: bool + """Whether to display the 'Examples' section in the object's docstring.""" + + show_docstring_functions: bool + """Whether to display the 'Functions' section in the object's docstring.""" + + show_docstring_modules: bool + """Whether to display the 'Modules' section in the object's docstring.""" + + show_docstring_other_parameters: bool + """Whether to display the 'Other Parameters' section in the object's docstring.""" + + show_docstring_parameters: bool + """Whether to display the 'Parameters' section in the object's docstring.""" + + show_docstring_raises: bool + """Whether to display the 'Raises' section in the object's docstring.""" + + show_docstring_receives: bool + """Whether to display the 'Receives' section in the object's docstring.""" + + show_docstring_returns: bool + """Whether to display the 'Returns' section in the object's docstring.""" + + show_docstring_warns: bool + """Whether to display the 'Warns' section in the object's docstring.""" + + show_docstring_yields: bool + """Whether to display the 'Yields' section in the object's docstring.""" + + show_if_no_docstring: bool + """Show the object heading even if it has no docstring or children with docstrings.""" + + show_object_full_path: bool + """Show the full Python path of every object.""" + + show_root_full_path: bool + """Show the full Python path for the root object heading.""" + + show_root_heading: bool + """Show the heading of the object at the root of the documentation tree. + + The root object is the object referenced by the identifier after `:::`. + """ + + show_root_members_full_path: bool + """Show the full Python path of the root members.""" + + show_signature: bool + """Show methods and functions signatures.""" + + show_signature_annotations: bool + """Show the type annotations in methods and functions signatures.""" + + show_submodules: bool + """When rendering a module, show its submodules recursively.""" + + signature_crossrefs: bool + """Whether to render cross-references for type annotations in signatures.""" + + summary: bool | dict + """Whether to render summaries of modules, classes, functions (methods) and attributes.""" + + +default_config: ConfigDict = { + "docstring_style": "google", + "docstring_options": {"ignore_init_summary": True}, + "show_root_heading": True, + "show_root_full_path": True, + "show_root_members_full_path": True, + "show_object_full_path": True, + "show_category_heading": False, + "show_if_no_docstring": True, + "show_signature": True, + "show_signature_annotations": False, + "signature_crossrefs": False, + "separate_signature": True, + "line_length": 80, + "merge_init_into_class": True, + "show_docstring_attributes": True, + "show_docstring_description": True, + "show_docstring_examples": True, + "show_docstring_other_parameters": True, + "show_docstring_parameters": True, + "show_docstring_raises": True, + "show_docstring_receives": True, + "show_docstring_returns": True, + "show_docstring_warns": True, + "show_docstring_yields": True, + "show_bases": True, + "show_submodules": True, + "group_by_category": False, + "heading_level": 2, + "members_order": "alphabetical", + "docstring_section_style": "list", + "members": None, + "inherited_members": True, + "filters": ["!^_"], + "annotations_path": "brief", + "preload_modules": None, + "load_external_modules": False, + "allow_inspection": True, + "summary": True, + "show_docstring_classes": True, + "show_docstring_functions": True, + "show_docstring_modules": True, +} diff --git a/src/griffe2md/main.py b/src/griffe2md/main.py index a503eb5..c7eca10 100644 --- a/src/griffe2md/main.py +++ b/src/griffe2md/main.py @@ -5,13 +5,14 @@ import re import sys from pathlib import Path -from typing import IO, TYPE_CHECKING +from typing import IO, TYPE_CHECKING, cast import mdformat from griffe import GriffeLoader, Parser from jinja2 import Environment, FileSystemLoader from griffe2md import rendering +from griffe2md.config import ConfigDict, default_config if TYPE_CHECKING: from griffe import Object @@ -27,7 +28,7 @@ def _output(text: str, to: IO | str | None = None) -> None: to.write(text) -def prepare_context(obj: Object, config: dict | None = None) -> dict: +def prepare_context(obj: Object, config: ConfigDict | None = None) -> dict: """Prepare Jinja context. Parameters: @@ -37,13 +38,16 @@ def prepare_context(obj: Object, config: dict | None = None) -> dict: Returns: The Jinja context. """ - config = dict(rendering.default_config, **(config or {})) + config = cast("ConfigDict", {**default_config, **(config or {})}) if config["filters"]: - config["filters"] = [(re.compile(filtr.lstrip("!")), filtr.startswith("!")) for filtr in config["filters"]] + config["filters"] = [ + (re.compile(filtr.lstrip("!")), filtr.startswith("!")) if isinstance(filtr, str) else filtr + for filtr in config["filters"] + ] heading_level = config["heading_level"] try: - config["members_order"] = rendering.Order(config["members_order"]) + config["members_order"] = rendering.Order(config["members_order"]).value except ValueError as error: choices = "', '".join(item.value for item in rendering.Order) raise ValueError( @@ -113,7 +117,7 @@ def prepare_env(env: Environment | None = None) -> Environment: return env -def render_object_docs(obj: Object, config: dict | None = None) -> str: +def render_object_docs(obj: Object, config: ConfigDict | None = None) -> str: """Render docs for a given object. Parameters: @@ -129,7 +133,7 @@ def render_object_docs(obj: Object, config: dict | None = None) -> str: return mdformat.text(rendered) -def render_package_docs(package: str, config: dict | None = None) -> str: +def render_package_docs(package: str, config: ConfigDict | None = None) -> str: """Render docs for a given package. Parameters: @@ -139,7 +143,7 @@ def render_package_docs(package: str, config: dict | None = None) -> str: Returns: Markdown. """ - config = config or dict(rendering.default_config) + config = cast("ConfigDict", {**default_config, **(config or {})}) parser = config["docstring_style"] and Parser(config["docstring_style"]) loader = GriffeLoader(docstring_parser=parser) module = loader.load(package) @@ -147,7 +151,11 @@ def render_package_docs(package: str, config: dict | None = None) -> str: return render_object_docs(module, config) # type: ignore[arg-type] -def write_package_docs(package: str, config: dict | None = None, output: IO | str | None = None) -> None: +def write_package_docs( + package: str, + config: ConfigDict | None = None, + output: IO | str | None = None, +) -> None: """Write docs for a given package to a file or stdout. Parameters: diff --git a/src/griffe2md/rendering.py b/src/griffe2md/rendering.py index 0207268..1649307 100644 --- a/src/griffe2md/rendering.py +++ b/src/griffe2md/rendering.py @@ -29,7 +29,7 @@ if TYPE_CHECKING: from collections.abc import Sequence - from griffe.dataclasses import Alias, Attribute, Class, Function, Module, Object + from griffe import Alias, Attribute, Class, Function, Module, Object from jinja2.runtime import Context from markupsafe import Markup @@ -37,7 +37,7 @@ logger = logging.getLogger(__name__) -class Order(enum.Enum): +class Order(str, enum.Enum): """Enumeration for the possible members ordering.""" alphabetical = "alphabetical" @@ -46,48 +46,6 @@ class Order(enum.Enum): """Source code order.""" -default_config: dict = { - "docstring_style": "google", - "docstring_options": {"ignore_init_summary": True}, - "show_root_heading": True, - "show_root_full_path": True, - "show_root_members_full_path": True, - "show_object_full_path": True, - "show_category_heading": False, - "show_if_no_docstring": True, - "show_signature": True, - "show_signature_annotations": False, - "signature_crossrefs": False, - "separate_signature": True, - "line_length": 80, - "merge_init_into_class": True, - "show_docstring_attributes": True, - "show_docstring_description": True, - "show_docstring_examples": True, - "show_docstring_other_parameters": True, - "show_docstring_parameters": True, - "show_docstring_raises": True, - "show_docstring_receives": True, - "show_docstring_returns": True, - "show_docstring_warns": True, - "show_docstring_yields": True, - "show_bases": True, - "show_submodules": True, - "group_by_category": False, - "heading_level": 2, - "members_order": Order.alphabetical.value, - "docstring_section_style": "list", - "members": None, - "inherited_members": True, - "filters": ["!^_"], - "annotations_path": "brief", - "preload_modules": None, - "load_external_modules": False, - "allow_inspection": True, - "summary": True, -} - - def do_any(seq: Sequence, attribute: str | None = None) -> bool: """Check if at least one of the item in the sequence evaluates to true. @@ -118,8 +76,8 @@ def _sort_key_source(item: Object | Alias) -> Any: order_map = { - Order.alphabetical: _sort_key_alphabetical, - Order.source: _sort_key_source, + Order.alphabetical.value: _sort_key_alphabetical, + Order.source.value: _sort_key_source, } @@ -284,7 +242,7 @@ def do_format_attribute( def do_order_members( members: Sequence[Object | Alias], order: Order, - members_list: bool | list[str] | None, + members_list: bool | list[str] | None, # noqa: FBT001 ) -> Sequence[Object | Alias]: """Order members given an ordering method. @@ -415,7 +373,7 @@ def do_filter_objects( @lru_cache(maxsize=1) def _get_black_formatter() -> Callable[[str, int], str]: try: - from black import InvalidInput, Mode, format_str + from black import InvalidInput, Mode, format_str # noqa: PLC0415 except ModuleNotFoundError: logger.info("Formatting signatures requires Black to be installed.") return lambda text, _: text @@ -444,7 +402,7 @@ def from_private_package(obj: Object | Alias) -> bool: if not obj.is_alias: return False try: - return obj.target.package.name == f"_{obj.parent.package.name}" + return obj.target.package.name == f"_{obj.parent.package.name}" # type: ignore[union-attr] except (AliasResolutionError, CyclicAliasError): return False @@ -469,7 +427,7 @@ def do_as_attributes_section( name=attribute.name, description=attribute.docstring.value.split("\n", 1)[0] if attribute.docstring else "", annotation=attribute.annotation, - value=attribute.value, + value=str(attribute.value) if attribute.value else None, ) for attribute in attributes if not check_public or attribute.is_public or from_private_package(attribute) diff --git a/src/griffe2md/templates/summary/modules.md.jinja b/src/griffe2md/templates/summary/modules.md.jinja index 814f0a4..c31e798 100644 --- a/src/griffe2md/templates/summary/modules.md.jinja +++ b/src/griffe2md/templates/summary/modules.md.jinja @@ -5,7 +5,7 @@ inherited_members=config.inherited_members, keep_no_docstrings=config.show_if_no_docstring, ) - |order_members(config.members_order.alphabetical, members_list) + |order_members("alphabetical", members_list) |as_modules_section(check_public=not members_list) %} {% if section %}{% include "docstring/modules.md.jinja" with context %}{% endif %} diff --git a/tests/test_cli.py b/tests/test_cli.py index 9ea0691..916a6f8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -27,7 +27,7 @@ def test_show_help(capsys: pytest.CaptureFixture) -> None: def test_render_self() -> None: """Render docs for itself.""" - cli.main(["griffe2md", "-o/dev/null"]) + cli.main(["griffe2md"]) def test_show_version(capsys: pytest.CaptureFixture) -> None: diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..4919d3f --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,28 @@ +"""Test config loading.""" + +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +import griffe2md.cli +from griffe2md.config import CONFIG_FILE_PATHS + + +@pytest.mark.parametrize("rel_path", CONFIG_FILE_PATHS) +def test_load_config(tmpdir: Path, rel_path: Path) -> None: + """Test that config is loaded.""" + expected_config = {"dummy": True} + config_text = "dummy=true" + + mock_write = Mock() + + with tmpdir.as_cwd(), patch("griffe2md.cli.write_package_docs", mock_write): # type: ignore[attr-defined] + text = f"[tool.griffe2md]\n{config_text}" if rel_path.name == "pyproject.toml" else config_text + config_path = Path(tmpdir) / rel_path + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(text, "utf-8") + + griffe2md.cli.main(["griffe2md"]) + + mock_write.assert_called_once_with("griffe2md", expected_config, None)