diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0480d106f..b40552cd9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -78,6 +78,27 @@ jobs: run: | python -m pytest -v --ignore=tests/benchmarks -m "jstest" tests + docs-no-mpl: + # the docs should build without matplotlib (just issuing warnings) + name: Docs no matplotlib + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set Up Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + - name: Update pip + run: python -m pip install --upgrade pip + - name: Install Dependencies + run: | + python -m pip install -e .[docs] + python -m pip uninstall -y matplotlib numpy + python -m pip freeze + - name: Run HTML build + run: sphinx-build -b html . _build + working-directory: docs + check: # This job does nothing and is only used for the branch protection @@ -89,6 +110,7 @@ jobs: - lint - tests-core - tests-js + - docs-no-mpl runs-on: ubuntu-latest diff --git a/README.rst b/README.rst index 7546bde52..7cc660b6b 100644 --- a/README.rst +++ b/README.rst @@ -61,6 +61,12 @@ Using pip pip install sphinx-needs +If you wish to also use the plotting features of sphinx-needs (see :ref:`needbar` and :ref:`needpie`), you need to also install ``matplotlib``, which is available *via* the ``plotting`` extra: + +.. code-block:: bash + + pip install sphinx-needs[plotting] + .. note:: Prior version **1.0.1** the package was named ``sphinxcontrib-needs``. diff --git a/docs/conf.py b/docs/conf.py index 08a5646b6..a9f339670 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -49,7 +49,6 @@ "sphinxcontrib.plantuml", "sphinx_needs", "sphinx.ext.autodoc", - "matplotlib.sphinxext.plot_directive", "sphinx_copybutton", "sphinxcontrib.programoutput", "sphinx_design", diff --git a/docs/installation.rst b/docs/installation.rst index 09accb1dd..bccf43faa 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -15,6 +15,12 @@ Using pip pip install sphinx-needs +If you wish to also use the plotting features of sphinx-needs (see :ref:`needbar` and :ref:`needpie`), you need to also install ``matplotlib``, which is available *via* the ``plotting`` extra: + +.. code-block:: bash + + pip install sphinx-needs[plotting] + .. note:: Prior version **1.0.1** the package was named ``sphinxcontrib-needs``. diff --git a/poetry.lock b/poetry.lock index cf53f9e5a..0370be62b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -218,7 +218,7 @@ files = [ name = "contourpy" version = "1.1.0" description = "Python library for calculating contours of 2D quadrilateral grids" -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "contourpy-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:89f06eff3ce2f4b3eb24c1055a26981bffe4e7264acd86f15b97e40530b794bc"}, @@ -276,7 +276,7 @@ test-no-images = ["pytest", "pytest-cov", "wurlitzer"] name = "contourpy" version = "1.1.1" description = "Python library for calculating contours of 2D quadrilateral grids" -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "contourpy-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:46e24f5412c948d81736509377e255f6040e94216bf1a9b5ea1eaa9d29f6ec1b"}, @@ -347,7 +347,7 @@ test-no-images = ["pytest", "pytest-cov", "wurlitzer"] name = "cycler" version = "0.12.1" description = "Composable style cycles" -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"}, @@ -428,7 +428,7 @@ typing = ["typing-extensions (>=4.8)"] name = "fonttools" version = "4.44.0" description = "Tools to manipulate font files" -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "fonttools-4.44.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e1cd1c6bb097e774d68402499ff66185190baaa2629ae2f18515a2c50b93db0c"}, @@ -632,7 +632,7 @@ referencing = ">=0.28.0" name = "kiwisolver" version = "1.4.5" description = "A fast implementation of the Cassowary constraint solver" -optional = false +optional = true python-versions = ">=3.7" files = [ {file = "kiwisolver-1.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:05703cf211d585109fcd72207a31bb170a0f22144d68298dc5e61b3c946518af"}, @@ -935,7 +935,7 @@ files = [ name = "matplotlib" version = "3.7.3" description = "Python plotting package" -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "matplotlib-3.7.3-cp310-cp310-macosx_10_12_universal2.whl", hash = "sha256:085c33b27561d9c04386789d5aa5eb4a932ddef43cfcdd0e01735f9a6e85ce0c"}, @@ -1084,7 +1084,7 @@ setuptools = "*" name = "numpy" version = "1.24.4" description = "Fundamental package for array computing in Python" -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "numpy-1.24.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0bfb52d2169d58c1cdb8cc1f16989101639b34c7d3ce60ed70b19c63eba0b64"}, @@ -1132,7 +1132,7 @@ files = [ name = "pillow" version = "10.1.0" description = "Python Imaging Library (Fork)" -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "Pillow-10.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1ab05f3db77e98f93964697c8efc49c7954b08dd61cff526b7f2531a22410106"}, @@ -1476,7 +1476,7 @@ plugins = ["importlib-metadata"] name = "pyparsing" version = "3.1.1" description = "pyparsing module - Classes and methods to define and execute parsing grammars" -optional = false +optional = true python-versions = ">=3.6.8" files = [ {file = "pyparsing-3.1.1-py3-none-any.whl", hash = "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb"}, @@ -1568,7 +1568,7 @@ pytest = ">=2.8" name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" -optional = false +optional = true python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, @@ -1875,7 +1875,7 @@ testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jar name = "setuptools-scm" version = "8.0.4" description = "the blessed package to manage your versions by scm tags" -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "setuptools-scm-8.0.4.tar.gz", hash = "sha256:b5f43ff6800669595193fd09891564ee9d1d7dcb196cab4b2506d53a2e1c95c7"}, @@ -2194,7 +2194,7 @@ files = [ name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -optional = false +optional = true python-versions = ">=3.7" files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, @@ -2216,7 +2216,7 @@ files = [ name = "typing-extensions" version = "4.8.0" description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false +optional = true python-versions = ">=3.8" files = [ {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, @@ -2277,11 +2277,12 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [extras] benchmark = ["memray", "pytest-benchmark"] -docs = ["sphinx-copybutton", "sphinx-design", "sphinx-immaterial", "sphinxcontrib-plantuml", "sphinxcontrib-programoutput"] -test = ["lxml", "pytest", "pytest-xprocess", "requests-mock", "responses", "sphinxcontrib-plantuml", "syrupy"] +docs = ["matplotlib", "sphinx-copybutton", "sphinx-design", "sphinx-immaterial", "sphinxcontrib-plantuml", "sphinxcontrib-programoutput"] +plotting = ["matplotlib"] +test = ["lxml", "matplotlib", "pytest", "pytest-xprocess", "requests-mock", "responses", "sphinxcontrib-plantuml", "syrupy"] test-parallel = ["pytest-xdist"] [metadata] lock-version = "2.0" python-versions = ">=3.8,<4" -content-hash = "2f246358b9845b557c46285d283af6507dcafec6035170b97b2447e062821218" +content-hash = "845318ae7fcc4a246470671ea7ca0f05ed79189ea68152f609a0184044980487" diff --git a/pyproject.toml b/pyproject.toml index 6d6889ed7..f50ca3cc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,13 +34,16 @@ packages = [ [tool.poetry.dependencies] python = ">=3.8,<4" sphinx = ">=5.0,<8" -matplotlib = ">=3.3.0" # needpie requests-file = "^1.5.1" # external links requests = "^2.25.1" # external_links jsonschema = ">=3.2.0" # needsimport schema validation sphinx-data-viewer = "^0.1.1" # needservice debug output sphinxcontrib-jquery = "^4" # needed for datatables in sphinx>=6 +# [project.optional-dependencies.plotting] +# for needpie / needbar +matplotlib = { version = ">=3.3.0", optional = true } + # [project.optional-dependencies.test] pytest = { version = "^7", optional = true } lxml = { version = "^4.6.5", optional = true } @@ -64,10 +67,12 @@ sphinx-design = { version="^0.5", optional = true } sphinx-immaterial = { version="0.11.7", optional = true } [tool.poetry.extras] -test = ["pytest", "syrupy", "sphinxcontrib-plantuml", "requests-mock", "lxml", "responses", "pytest-xprocess"] +plotting = ["matplotlib"] +test = ["matplotlib", "pytest", "syrupy", "sphinxcontrib-plantuml", "requests-mock", "lxml", "responses", "pytest-xprocess"] test-parallel = ["pytest-xdist"] benchmark = ["pytest-benchmark", "memray"] docs = [ + "matplotlib", "sphinxcontrib-plantuml", "sphinx-copybutton", "sphinxcontrib-programoutput", diff --git a/sphinx_needs/directives/needbar.py b/sphinx_needs/directives/needbar.py index 25cf0e536..f4df2696d 100644 --- a/sphinx_needs/directives/needbar.py +++ b/sphinx_needs/directives/needbar.py @@ -1,24 +1,21 @@ +import hashlib import math -import os from typing import List, Sequence -import matplotlib -import numpy from docutils import nodes +from docutils.parsers.rst import directives from sphinx.application import Sphinx from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.data import SphinxNeedsData from sphinx_needs.filter_common import FilterBase, filter_needs, prepare_need_list -from sphinx_needs.utils import add_doc, remove_node_from_tree, save_matplotlib_figure - -if not os.environ.get("DISPLAY"): - matplotlib.use("Agg") -import hashlib - -from docutils.parsers.rst import directives - from sphinx_needs.logging import get_logger +from sphinx_needs.utils import ( + add_doc, + import_matplotlib, + remove_node_from_tree, + save_matplotlib_figure, +) logger = get_logger(__name__) @@ -82,7 +79,8 @@ def run(self) -> Sequence[nodes.Node]: text_color = text_color.strip() style = self.options.get("style") - style = style.strip() if style else matplotlib.style.use("default") + matplotlib = import_matplotlib() + style = style.strip() if style else (matplotlib.style.use("default") if matplotlib else "default") legend = "legend" in self.options @@ -169,17 +167,29 @@ def run(self) -> Sequence[nodes.Node]: # 10. cleanup matplotlib def process_needbar(app: Sphinx, doctree: nodes.document, fromdocname: str, found_nodes: List[nodes.Element]) -> None: env = app.env + needs_data = SphinxNeedsData(env) needs_config = NeedsSphinxConfig(env.config) + matplotlib = import_matplotlib() + + if matplotlib is None and found_nodes and needs_config.include_needs: + logger.warning( + "Matplotlib is not installed and required by needbar. " + "Install with `sphinx-needs[plotting]` to use. [needs.mpl]", + once=True, + type="needs", + subtype="mpl", + ) + # NEEDFLOW # for node in doctree.findall(Needbar): for node in found_nodes: - if not needs_config.include_needs: + if matplotlib is None or not needs_config.include_needs: remove_node_from_tree(node) continue id = node.attributes["ids"][0] - current_needbar = SphinxNeedsData(env).get_or_create_bars()[id] + current_needbar = needs_data.get_or_create_bars()[id] # 1. define constants error_id = current_needbar["error_id"] @@ -253,9 +263,7 @@ def process_needbar(app: Sphinx, doctree: nodes.document, fromdocname: str, foun # 5. process content local_data_number = [] - need_list = list( - prepare_need_list(SphinxNeedsData(env).get_or_create_needs().values()) - ) # adds parts to need_list + need_list = list(prepare_need_list(needs_data.get_or_create_needs().values())) # adds parts to need_list for line in local_data: line_number = [] @@ -322,7 +330,7 @@ def process_needbar(app: Sphinx, doctree: nodes.document, fromdocname: str, foun colors = colors * multi colors = colors[: len(local_data)] - y_offset = numpy.zeros(len(local_data_number[0])) + y_offset = [0.0 for _ in range(len(local_data_number[0]))] # 8. create figure bar_labels = [] @@ -347,7 +355,7 @@ def process_needbar(app: Sphinx, doctree: nodes.document, fromdocname: str, foun if current_needbar["stacked"]: # handle stacked bar - y_offset = y_offset + numpy.array(local_data_number[x]) + y_offset = [i + j for i, j in zip(y_offset, local_data_number[x])] if current_needbar["show_sum"]: try: @@ -375,15 +383,14 @@ def process_needbar(app: Sphinx, doctree: nodes.document, fromdocname: str, foun if sum_rotation.isdigit(): matplotlib.pyplot.setp(bar_labels, rotation=int(sum_rotation)) + centers = [(i + j) / 2.0 for i, j in zip(index[0], index[len(local_data_number) - 1])] if not current_needbar["horizontal"]: # We want to support even older version of matplotlib, which do not support axes.set_xticks(labels) - x_pos = (numpy.array(index[0]) + numpy.array(index[len(local_data_number) - 1])) / 2 - axes.set_xticks(x_pos) + axes.set_xticks(centers) axes.set_xticklabels(labels=xlabels) else: # We want to support even older version of matplotlib, which do not support axes.set_yticks(labels) - y_pos = (numpy.array(index[0]) + numpy.array(index[len(local_data_number) - 1])) / 2 - axes.set_yticks(y_pos) + axes.set_yticks(centers) axes.set_yticklabels(labels=xlabels) axes.invert_yaxis() # labels read top-to-bottom diff --git a/sphinx_needs/directives/needpie.py b/sphinx_needs/directives/needpie.py index 2040b88d2..5af415a1b 100644 --- a/sphinx_needs/directives/needpie.py +++ b/sphinx_needs/directives/needpie.py @@ -1,27 +1,19 @@ -import os +import hashlib from typing import Iterable, List, Sequence -import matplotlib -import numpy as np from docutils import nodes +from docutils.parsers.rst import directives from sphinx.application import Sphinx from sphinx_needs.config import NeedsSphinxConfig from sphinx_needs.data import SphinxNeedsData from sphinx_needs.debug import measure_time from sphinx_needs.filter_common import FilterBase, filter_needs, prepare_need_list - -if not os.environ.get("DISPLAY"): - matplotlib.use("Agg") -import hashlib - -import matplotlib.pyplot -from docutils.parsers.rst import directives - from sphinx_needs.logging import get_logger from sphinx_needs.utils import ( add_doc, check_and_get_external_filter_func, + import_matplotlib, remove_node_from_tree, save_matplotlib_figure, ) @@ -114,11 +106,21 @@ def process_needpie(app: Sphinx, doctree: nodes.document, fromdocname: str, foun needs_data = SphinxNeedsData(env) needs_config = NeedsSphinxConfig(env.config) + matplotlib = import_matplotlib() + + if matplotlib is None and found_nodes and needs_config.include_needs: + logger.warning( + "Matplotlib is not installed and required by needpie. " + "Install with `sphinx-needs[plotting]` to use. [needs.mpl]", + once=True, + type="needs", + subtype="mpl", + ) + # NEEDFLOW - include_needs = needs_config.include_needs # for node in doctree.findall(Needpie): for node in found_nodes: - if not include_needs: + if matplotlib is None or not needs_config.include_needs: remove_node_from_tree(node) continue @@ -215,7 +217,7 @@ def process_needpie(app: Sphinx, doctree: nodes.document, fromdocname: str, foun if text_color: pie_kwargs["textprops"] = {"color": text_color} - wedges, _texts, autotexts = axes.pie(sizes, normalize=np.asarray(sizes, np.float32).sum() >= 1, **pie_kwargs) + wedges, _texts, autotexts = axes.pie(sizes, normalize=sum(float(s) for s in sizes) >= 1, **pie_kwargs) ratio = 20 # we will remove all labels with size smaller 5% legend_enforced = False diff --git a/sphinx_needs/utils.py b/sphinx_needs/utils.py index 271b898f9..d90ddcebb 100644 --- a/sphinx_needs/utils.py +++ b/sphinx_needs/utils.py @@ -3,7 +3,7 @@ import operator import os import re -from functools import reduce, wraps +from functools import lru_cache, reduce, wraps from re import Pattern from typing import ( TYPE_CHECKING, @@ -21,7 +21,6 @@ from docutils import nodes from jinja2 import Environment, Template -from matplotlib.figure import FigureBase from sphinx.application import BuildEnvironment, Sphinx from sphinx_needs.config import NeedsSphinxConfig @@ -35,6 +34,9 @@ from typing_extensions import TypedDict if TYPE_CHECKING: + import matplotlib + from matplotlib.figure import FigureBase + from sphinx_needs.functions.functions import DynamicFunction logger = get_logger(__name__) @@ -382,7 +384,23 @@ def jinja_parse(context: Dict[str, Any], jinja_string: str) -> str: return content -def save_matplotlib_figure(app: Sphinx, figure: FigureBase, basename: str, fromdocname: str) -> nodes.image: +@lru_cache() +def import_matplotlib() -> Optional["matplotlib"]: + """Import and return matplotlib, or return None if it cannot be imported. + + Also sets the interactive backend to ``Agg``, if ``DISPLAY`` is not set. + """ + try: + import matplotlib + import matplotlib.pyplot + except ImportError: + return None + if not os.environ.get("DISPLAY"): + matplotlib.use("Agg") + return matplotlib + + +def save_matplotlib_figure(app: Sphinx, figure: "FigureBase", basename: str, fromdocname: str) -> nodes.image: builder = app.builder env = app.env