diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8ffd12e3..e915ee06 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,9 +13,9 @@ jobs: fail-fast: false matrix: include: - - {os: ubuntu-20.04, cc: gcc-10, cxx: g++-10} - - {os: ubuntu-20.04, cc: clang-13, cxx: clang++-13} - - {os: macos-12, cc: gcc-11, cxx: g++-11} + - {os: ubuntu-22.04, cc: gcc-12, cxx: g++-12} + - {os: ubuntu-22.04, cc: clang-15, cxx: clang++-15} + - {os: macos-12, cc: gcc-12, cxx: g++-12} - {os: macos-12, cc: /usr/local/opt/llvm/bin/clang, cxx: /usr/local/opt/llvm/bin/clang++} runs-on: ${{ matrix.os }} @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v2 - name: Install ubuntu dependencies - if: matrix.os == 'ubuntu-20.04' + if: matrix.os == 'ubuntu-22.04' run: > sudo apt-get update && sudo apt-get install lsb-release wget software-properties-common && @@ -36,10 +36,10 @@ jobs: hdf5-tools libblas-dev libboost-dev - libclang-13-dev - libc++-13-dev - libc++abi-13-dev - libomp-13-dev + libclang-15-dev + libc++-15-dev + libc++abi-15-dev + libomp-15-dev libfftw3-dev libgfortran5 libgmp-dev @@ -49,7 +49,7 @@ jobs: openmpi-bin openmpi-common openmpi-doc - python3-clang-13 + python3-clang-15 python3-dev python3-mako python3-matplotlib @@ -63,7 +63,7 @@ jobs: - name: Install homebrew dependencies if: matrix.os == 'macos-12' run: | - brew install gcc@11 llvm boost fftw hdf5 open-mpi openblas + brew install gcc@12 llvm boost fftw hdf5 open-mpi openblas pip3 install mako numpy scipy mpi4py pip3 install -r requirements.txt diff --git a/CMakeLists.txt b/CMakeLists.txt index 5becfb10..3292cdf8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -108,6 +108,7 @@ target_compile_options(${PROJECT_NAME}_warnings INTERFACE -Wall -Wextra + -Wfloat-conversion -Wpedantic -Wno-sign-compare $<$:-Wno-comma-subscript> diff --git a/Jenkinsfile b/Jenkinsfile index c8ec5c28..6b9abc9b 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -58,8 +58,8 @@ for (int i = 0; i < dockerPlatforms.size(); i++) { /****************** osx builds (on host) */ def osxPlatforms = [ - ["gcc", ['CC=gcc-11', 'CXX=g++-11', 'FC=gfortran-11']], - ["clang", ['CC=$BREW/opt/llvm/bin/clang', 'CXX=$BREW/opt/llvm/bin/clang++', 'FC=gfortran-11', 'CXXFLAGS=-I$BREW/opt/llvm/include', 'LDFLAGS=-L$BREW/opt/llvm/lib']] + ["gcc", ['CC=gcc-13', 'CXX=g++-13', 'FC=gfortran-13']], + ["clang", ['CC=$BREW/opt/llvm/bin/clang', 'CXX=$BREW/opt/llvm/bin/clang++', 'FC=gfortran-13', 'CXXFLAGS=-I$BREW/opt/llvm/include', 'LDFLAGS=-L$BREW/opt/llvm/lib']] ] for (int i = 0; i < osxPlatforms.size(); i++) { def platformEnv = osxPlatforms[i] diff --git a/doc/CMakeLists.txt b/doc/CMakeLists.txt index 6578595a..b3b0458e 100644 --- a/doc/CMakeLists.txt +++ b/doc/CMakeLists.txt @@ -76,6 +76,6 @@ endif() # --------------------------------- install(DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/html/ COMPONENT documentation DESTINATION share/doc/${PROJECT_NAME} FILES_MATCHING - REGEX "\\.(html|pdf|png|gif|jpg|svg|js|xsl|css|py|txt|inv|bib|ttf|woff2|eot)$" + REGEX "\\.(html|pdf|png|gif|jpg|svg|ico|js|xsl|css|py|txt|inv|bib|ttf|woff2|eot)$" PATTERN "_*" ) diff --git a/doc/_static/triqs_logo/Icon/JPG/Triqs_Icon_RGB_Black.jpg b/doc/_static/triqs_logo/Icon/JPG/Triqs_Icon_RGB_Black.jpg new file mode 100755 index 00000000..a25819aa Binary files /dev/null and b/doc/_static/triqs_logo/Icon/JPG/Triqs_Icon_RGB_Black.jpg differ diff --git a/doc/_static/triqs_logo/Icon/JPG/Triqs_Icon_RGB_Full.jpg b/doc/_static/triqs_logo/Icon/JPG/Triqs_Icon_RGB_Full.jpg new file mode 100755 index 00000000..8fac42ac Binary files /dev/null and b/doc/_static/triqs_logo/Icon/JPG/Triqs_Icon_RGB_Full.jpg differ diff --git a/doc/_static/triqs_logo/Icon/SVG/Triqs_Icon_RGB_Black.svg b/doc/_static/triqs_logo/Icon/SVG/Triqs_Icon_RGB_Black.svg new file mode 100755 index 00000000..ff598488 --- /dev/null +++ b/doc/_static/triqs_logo/Icon/SVG/Triqs_Icon_RGB_Black.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/doc/_static/triqs_logo/Icon/SVG/Triqs_Icon_RGB_Full.svg b/doc/_static/triqs_logo/Icon/SVG/Triqs_Icon_RGB_Full.svg new file mode 100755 index 00000000..76d6707d --- /dev/null +++ b/doc/_static/triqs_logo/Icon/SVG/Triqs_Icon_RGB_Full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/doc/_static/triqs_logo/Icon/SVG/Triqs_Icon_RGB_White.svg b/doc/_static/triqs_logo/Icon/SVG/Triqs_Icon_RGB_White.svg new file mode 100755 index 00000000..4fbd963d --- /dev/null +++ b/doc/_static/triqs_logo/Icon/SVG/Triqs_Icon_RGB_White.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/doc/_static/triqs_logo/Logo/JPG/Triqs_Logo_RGB_Black.jpg b/doc/_static/triqs_logo/Logo/JPG/Triqs_Logo_RGB_Black.jpg new file mode 100755 index 00000000..a9206e03 Binary files /dev/null and b/doc/_static/triqs_logo/Logo/JPG/Triqs_Logo_RGB_Black.jpg differ diff --git a/doc/_static/triqs_logo/Logo/JPG/Triqs_Logo_RGB_Full.jpg b/doc/_static/triqs_logo/Logo/JPG/Triqs_Logo_RGB_Full.jpg new file mode 100755 index 00000000..58e6a815 Binary files /dev/null and b/doc/_static/triqs_logo/Logo/JPG/Triqs_Logo_RGB_Full.jpg differ diff --git a/doc/_static/triqs_logo/Logo/SVG/Triqs_Logo_RGB_Black.svg b/doc/_static/triqs_logo/Logo/SVG/Triqs_Logo_RGB_Black.svg new file mode 100755 index 00000000..27ff0f46 --- /dev/null +++ b/doc/_static/triqs_logo/Logo/SVG/Triqs_Logo_RGB_Black.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/doc/_static/triqs_logo/Logo/SVG/Triqs_Logo_RGB_Full.svg b/doc/_static/triqs_logo/Logo/SVG/Triqs_Logo_RGB_Full.svg new file mode 100755 index 00000000..149bdb6c --- /dev/null +++ b/doc/_static/triqs_logo/Logo/SVG/Triqs_Logo_RGB_Full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/doc/_static/triqs_logo/Logo/SVG/Triqs_Logo_RGB_White.svg b/doc/_static/triqs_logo/Logo/SVG/Triqs_Logo_RGB_White.svg new file mode 100755 index 00000000..eb273f97 --- /dev/null +++ b/doc/_static/triqs_logo/Logo/SVG/Triqs_Logo_RGB_White.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/doc/_static/triqs_logo/triqs_favicon.ico b/doc/_static/triqs_logo/triqs_favicon.ico new file mode 100644 index 00000000..9e20d735 Binary files /dev/null and b/doc/_static/triqs_logo/triqs_favicon.ico differ diff --git a/doc/conf.py.in b/doc/conf.py.in index cfdd0340..62af85ca 100644 --- a/doc/conf.py.in +++ b/doc/conf.py.in @@ -69,7 +69,7 @@ html_theme = 'sphinx_rtd_theme' # this loads the custom css file to change the page width html_style = 'css/custom.css' -#html_favicon = '@CMAKE_CURRENT_SOURCE_DIR@/logos/favicon.ico' +html_favicon = '@CMAKE_CURRENT_SOURCE_DIR@/_static/triqs_logo/triqs_favicon.ico' #html_logo = '@CMAKE_CURRENT_SOURCE_DIR@/logos/logo.png' # options for the the rtd theme @@ -97,7 +97,12 @@ html_sidebars = {'index': ['sideb.html', 'searchbox.html']} htmlhelp_basename = '@PROJECT_NAME@doc' -intersphinx_mapping = {'python': ('https://docs.python.org/3.8', None), 'triqslibs': ('https://triqs.github.io/triqs/latest', None)} +# Plot options +plot_include_source = True +plot_html_show_source_link = False +plot_html_show_formats = False + +intersphinx_mapping = {'python': ('https://docs.python.org/3.11', None), 'triqslibs': ('https://triqs.github.io/triqs/latest', None)} # open links in new tab instead of same window from sphinx.writers.html import HTMLTranslator diff --git a/doc/sphinxext/numpydoc/plot_directive.py b/doc/sphinxext/numpydoc/plot_directive.py index be03c357..45ca91f8 100644 --- a/doc/sphinxext/numpydoc/plot_directive.py +++ b/doc/sphinxext/numpydoc/plot_directive.py @@ -1,78 +1,87 @@ """ -A directive for including a matplotlib plot in a Sphinx document. +A directive for including a Matplotlib plot in a Sphinx document +================================================================ -By default, in HTML output, `plot` will include a .png file with a -link to a high-res .png and .pdf. In LaTeX output, it will include a -.pdf. +This is a Sphinx extension providing a reStructuredText directive +``.. plot::`` for including a plot in a Sphinx document. -The source code for the plot may be included in one of three ways: +In HTML output, ``.. plot::`` will include a .png file with a link +to a high-res .png and .pdf. In LaTeX output, it will include a .pdf. - 1. **A path to a source file** as the argument to the directive:: +The plot content may be defined in one of three ways: - .. plot:: path/to/plot.py +1. **A path to a source file** as the argument to the directive:: - When a path to a source file is given, the content of the - directive may optionally contain a caption for the plot:: + .. plot:: path/to/plot.py - .. plot:: path/to/plot.py + When a path to a source file is given, the content of the + directive may optionally contain a caption for the plot:: - This is the caption for the plot + .. plot:: path/to/plot.py - Additionally, one my specify the name of a function to call (with - no arguments) immediately after importing the module:: + The plot caption. - .. plot:: path/to/plot.py plot_function1 + Additionally, one may specify the name of a function to call (with + no arguments) immediately after importing the module:: - 2. Included as **inline content** to the directive:: + .. plot:: path/to/plot.py plot_function1 - .. plot:: +2. Included as **inline content** to the directive:: - import matplotlib.pyplot as plt - import matplotlib.image as mpimg - import numpy as np - img = mpimg.imread('_static/stinkbug.png') - imgplot = plt.imshow(img) + .. plot:: - 3. Using **doctest** syntax:: + import matplotlib.pyplot as plt + plt.plot([1, 2, 3], [4, 5, 6]) + plt.title("A plotting exammple") - .. plot:: - A plotting example: - >>> import matplotlib.pyplot as plt - >>> plt.plot([1,2,3], [4,5,6]) +3. Using **doctest** syntax:: + + .. plot:: + + A plotting example: + >>> import matplotlib.pyplot as plt + >>> plt.plot([1, 2, 3], [4, 5, 6]) Options ------- -The ``plot`` directive supports the following options: +The ``.. plot::`` directive supports the following options: - format : {'python', 'doctest'} - Specify the format of the input + ``:format:`` : {'python', 'doctest'} + The format of the input. If unset, the format is auto-detected. - include-source : bool - Whether to display the source code. The default can be changed - using the `plot_include_source` variable in conf.py + ``:include-source:`` : bool + Whether to display the source code. The default can be changed using + the ``plot_include_source`` variable in :file:`conf.py` (which itself + defaults to False). - encoding : str - If this source file is in a non-UTF8 or non-ASCII encoding, - the encoding must be specified using the `:encoding:` option. - The encoding will not be inferred using the ``-*- coding -*-`` - metacomment. + ``:show-source-link:`` : bool + Whether to show a link to the source in HTML. The default can be + changed using the ``plot_html_show_source_link`` variable in + :file:`conf.py` (which itself defaults to True). - context : bool - If provided, the code will be run in the context of all - previous plot directives for which the `:context:` option was - specified. This only applies to inline code plot directives, - not those run from files. + ``:context:`` : bool or str + If provided, the code will be run in the context of all previous plot + directives for which the ``:context:`` option was specified. This only + applies to inline code plot directives, not those run from files. If + the ``:context: reset`` option is specified, the context is reset + for this and future plots, and previous figures are closed prior to + running the code. ``:context: close-figs`` keeps the context but closes + previous figures before running the code. - nofigs : bool - If specified, the code block will be run, but no figures will - be inserted. This is usually useful with the ``:context:`` - option. + ``:nofigs:`` : bool + If specified, the code block will be run, but no figures will be + inserted. This is usually useful with the ``:context:`` option. -Additionally, this directive supports all of the options of the -`image` directive, except for `target` (since plot will add its own -target). These include `alt`, `height`, `width`, `scale`, `align` and -`class`. + ``:caption:`` : str + If specified, the option's argument will be used as a caption for the + figure. This overwrites the caption given in the content, when the plot + is generated from a file. + +Additionally, this directive supports all the options of the `image directive +`_, +except for ``:target:`` (since plot will add its own target). These include +``:alt:``, ``:height:``, ``:width:``, ``:scale:``, ``:align:`` and ``:class:``. Configuration options --------------------- @@ -80,135 +89,115 @@ Configuration options The plot directive has the following configuration options: plot_include_source - Default value for the include-source option + Default value for the include-source option (default: False). + + plot_html_show_source_link + Whether to show a link to the source in HTML (default: True). plot_pre_code - Code that should be executed before each plot. + Code that should be executed before each plot. If None (the default), + it will default to a string containing:: + + import numpy as np + from matplotlib import pyplot as plt plot_basedir - Base directory, to which ``plot::`` file names are relative - to. (If None or empty, file names are relative to the - directoly where the file containing the directive is.) + Base directory, to which ``plot::`` file names are relative to. + If None or empty (the default), file names are relative to the + directory where the file containing the directive is. plot_formats - File formats to generate. List of tuples or strings:: + File formats to generate (default: ['png', 'hires.png', 'pdf']). + List of tuples or strings:: [(suffix, dpi), suffix, ...] that determine the file format and the DPI. For entries whose - DPI was omitted, sensible defaults are chosen. + DPI was omitted, sensible defaults are chosen. When passing from + the command line through sphinx_build the list should be passed as + suffix:dpi,suffix:dpi, ... plot_html_show_formats - Whether to show links to the files in HTML. + Whether to show links to the files in HTML (default: True). plot_rcparams A dictionary containing any non-standard rcParams that should - be applied before each plot. + be applied before each plot (default: {}). + plot_apply_rcparams + By default, rcParams are applied when ``:context:`` option is not used + in a plot directive. If set, this configuration option overrides this + behavior and applies rcParams before each plot. + + plot_working_directory + By default, the working directory will be changed to the directory of + the example, so the code can get at its data files, if any. Also its + path will be added to `sys.path` so it can import any helper modules + sitting beside it. This configuration option can be used to specify + a central directory (also added to `sys.path`) where data files and + helper modules for all code are located. + + plot_template + Provide a customized template for preparing restructured text. + + plot_srcset + Allow the srcset image option for responsive image resolutions. List of + strings with the multiplicative factors followed by an "x". + e.g. ["2.0x", "1.5x"]. "2.0x" will create a png with the default "png" + resolution from plot_formats, multiplied by 2. If plot_srcset is + specified, the plot directive uses the + :doc:`/api/sphinxext_figmpl_directive_api` (instead of the usual figure + directive) in the intermediary rst file that is generated. + The plot_srcset option is incompatible with *singlehtml* builds, and an + error will be raised. + +Notes on how it works +--------------------- + +The plot directive runs the code it is given, either in the source file or the +code under the directive. The figure created (if any) is saved in the sphinx +build directory under a subdirectory named ``plot_directive``. It then creates +an intermediate rst file that calls a ``.. figure:`` directive (or +``.. figmpl::`` directive if ``plot_srcset`` is being used) and has links to +the ``*.png`` files in the ``plot_directive`` directory. These translations can +be customized by changing the *plot_template*. See the source of +:doc:`/api/sphinxext_plot_directive_api` for the templates defined in *TEMPLATE* +and *TEMPLATE_SRCSET*. """ -import sys, os, glob, shutil, imp, warnings, io, re, textwrap, \ - traceback, exceptions +import contextlib +import doctest +from io import StringIO +import itertools +import os +from os.path import relpath +from pathlib import Path +import re +import shutil +import sys +import textwrap +import traceback -from docutils.parsers.rst import directives -from docutils import nodes +from docutils.parsers.rst import directives, Directive from docutils.parsers.rst.directives.images import Image -align = Image.align -import sphinx +import jinja2 # Sphinx dependency. -sphinx_version = sphinx.__version__.split(".") -# The split is necessary for sphinx beta versions where the string is -# '6b1' -sphinx_version = tuple([int(re.split('[a-z]', x)[0]) - for x in sphinx_version[:2]]) - -try: - # Sphinx depends on either Jinja or Jinja2 - import jinja2 - def format_template(template, **kw): - return jinja2.Template(template).render(**kw) -except ImportError: - import jinja - def format_template(template, **kw): - return jinja.from_string(template, **kw) +from sphinx.errors import ExtensionError import matplotlib -import matplotlib.cbook as cbook -matplotlib.use('Agg') +from matplotlib.backend_bases import FigureManagerBase import matplotlib.pyplot as plt -from matplotlib import _pylab_helpers +from matplotlib import _pylab_helpers, cbook + +matplotlib.use("agg") __version__ = 2 -#------------------------------------------------------------------------------ -# Relative pathnames -#------------------------------------------------------------------------------ -# os.path.relpath is new in Python 2.6 -try: - from os.path import relpath -except ImportError: - # Copied from Python 2.7 - if 'posix' in sys.builtin_module_names: - def relpath(path, start=os.path.curdir): - """Return a relative version of a path""" - from os.path import sep, curdir, join, abspath, commonprefix, \ - pardir - - if not path: - raise ValueError("no path specified") - - start_list = abspath(start).split(sep) - path_list = abspath(path).split(sep) - - # Work out how much of the filepath is shared by start and path. - i = len(commonprefix([start_list, path_list])) - - rel_list = [pardir] * (len(start_list)-i) + path_list[i:] - if not rel_list: - return curdir - return join(*rel_list) - elif 'nt' in sys.builtin_module_names: - def relpath(path, start=os.path.curdir): - """Return a relative version of a path""" - from os.path import sep, curdir, join, abspath, commonprefix, \ - pardir, splitunc - - if not path: - raise ValueError("no path specified") - start_list = abspath(start).split(sep) - path_list = abspath(path).split(sep) - if start_list[0].lower() != path_list[0].lower(): - unc_path, rest = splitunc(path) - unc_start, rest = splitunc(start) - if bool(unc_path) ^ bool(unc_start): - raise ValueError("Cannot mix UNC and non-UNC paths (%s and %s)" - % (path, start)) - else: - raise ValueError("path is on drive %s, start on drive %s" - % (path_list[0], start_list[0])) - # Work out how much of the filepath is shared by start and path. - for i in range(min(len(start_list), len(path_list))): - if start_list[i].lower() != path_list[i].lower(): - break - else: - i += 1 - - rel_list = [pardir] * (len(start_list)-i) + path_list[i:] - if not rel_list: - return curdir - return join(*rel_list) - else: - raise RuntimeError("Unsupported platform (no relpath available!)") - -#------------------------------------------------------------------------------ +# ----------------------------------------------------------------------------- # Registration hook -#------------------------------------------------------------------------------ +# ----------------------------------------------------------------------------- -def plot_directive(name, arguments, options, content, lineno, - content_offset, block_text, state, state_machine): - return run(arguments, content, options, state_machine, state, lineno) -plot_directive.__doc__ = __doc__ def _option_boolean(arg): if not arg or not arg.strip(): @@ -219,20 +208,23 @@ def _option_boolean(arg): elif arg.strip().lower() in ('yes', '1', 'true'): return True else: - raise ValueError('"%s" unknown boolean' % arg) + raise ValueError(f'{arg!r} unknown boolean') + + +def _option_context(arg): + if arg in [None, 'reset', 'close-figs']: + return arg + raise ValueError("Argument should be None or 'reset' or 'close-figs'") + def _option_format(arg): return directives.choice(arg, ('python', 'doctest')) -def _option_align(arg): - return directives.choice(arg, ("top", "middle", "bottom", "left", "center", - "right")) def mark_plot_labels(app, document): """ - To make plots referenceable, we need to move the reference from - the "htmlonly" (or "latexonly") node to the actual figure node - itself. + To make plots referenceable, we need to move the reference from the + "htmlonly" (or "latexonly") node to the actual figure node itself. """ for name, explicit in document.nametypes.items(): if not explicit: @@ -258,37 +250,75 @@ def mark_plot_labels(app, document): document.settings.env.docname, labelid, sectname break + +class PlotDirective(Directive): + """The ``.. plot::`` directive, as documented in the module's docstring.""" + + has_content = True + required_arguments = 0 + optional_arguments = 2 + final_argument_whitespace = False + option_spec = { + 'alt': directives.unchanged, + 'height': directives.length_or_unitless, + 'width': directives.length_or_percentage_or_unitless, + 'scale': directives.nonnegative_int, + 'align': Image.align, + 'class': directives.class_option, + 'include-source': _option_boolean, + 'show-source-link': _option_boolean, + 'format': _option_format, + 'context': _option_context, + 'nofigs': directives.flag, + 'caption': directives.unchanged, + } + + def run(self): + """Run the plot directive.""" + try: + return run(self.arguments, self.content, self.options, + self.state_machine, self.state, self.lineno) + except Exception as e: + raise self.error(str(e)) + + +def _copy_css_file(app, exc): + if exc is None and app.builder.format == 'html': + src = cbook._get_data_path('plot_directive/plot_directive.css') + dst = app.outdir / Path('_static') + dst.mkdir(exist_ok=True) + # Use copyfile because we do not want to copy src's permissions. + shutil.copyfile(src, dst / Path('plot_directive.css')) + + def setup(app): setup.app = app setup.config = app.config setup.confdir = app.confdir - - options = {'alt': directives.unchanged, - 'height': directives.length_or_unitless, - 'width': directives.length_or_percentage_or_unitless, - 'scale': directives.nonnegative_int, - 'align': _option_align, - 'class': directives.class_option, - 'include-source': _option_boolean, - 'format': _option_format, - 'context': directives.flag, - 'nofigs': directives.flag, - 'encoding': directives.encoding - } - - app.add_directive('plot', plot_directive, True, (0, 2, False), **options) + app.add_directive('plot', PlotDirective) app.add_config_value('plot_pre_code', None, True) app.add_config_value('plot_include_source', False, True) + app.add_config_value('plot_html_show_source_link', True, True) app.add_config_value('plot_formats', ['png', 'hires.png', 'pdf'], True) app.add_config_value('plot_basedir', None, True) app.add_config_value('plot_html_show_formats', True, True) app.add_config_value('plot_rcparams', {}, True) - + app.add_config_value('plot_apply_rcparams', False, True) + app.add_config_value('plot_working_directory', None, True) + app.add_config_value('plot_template', None, True) + app.add_config_value('plot_srcset', [], True) app.connect('doctree-read', mark_plot_labels) + app.add_css_file('plot_directive.css') + app.connect('build-finished', _copy_css_file) + metadata = {'parallel_read_safe': True, 'parallel_write_safe': True, + 'version': matplotlib.__version__} + return metadata -#------------------------------------------------------------------------------ + +# ----------------------------------------------------------------------------- # Doctest handling -#------------------------------------------------------------------------------ +# ----------------------------------------------------------------------------- + def contains_doctest(text): try: @@ -301,77 +331,101 @@ def contains_doctest(text): m = r.search(text) return bool(m) -def unescape_doctest(text): - """ - Extract code from a piece of text, which contains either Python code - or doctests. - """ - if not contains_doctest(text): - return text +def _split_code_at_show(text, function_name): + """Split code at plt.show().""" - code = "" - for line in text.split("\n"): - m = re.match(r'^\s*(>>>|\.\.\.) (.*)$', line) - if m: - code += m.group(2) + "\n" - elif line.strip(): - code += "# " + line.strip() + "\n" - else: - code += "\n" - return code - -def split_code_at_show(text): - """ - Split code at plt.show() - - """ - - parts = [] is_doctest = contains_doctest(text) - - part = [] - for line in text.split("\n"): - if (not is_doctest and line.strip() == 'plt.show()') or \ - (is_doctest and line.strip() == '>>> plt.show()'): - part.append(line) + if function_name is None: + parts = [] + part = [] + for line in text.split("\n"): + if ((not is_doctest and line.startswith('plt.show(')) or + (is_doctest and line.strip() == '>>> plt.show()')): + part.append(line) + parts.append("\n".join(part)) + part = [] + else: + part.append(line) + if "\n".join(part).strip(): parts.append("\n".join(part)) - part = [] - else: - part.append(line) - if "\n".join(part).strip(): - parts.append("\n".join(part)) - return parts + else: + parts = [text] + return is_doctest, parts -#------------------------------------------------------------------------------ + +# ----------------------------------------------------------------------------- # Template -#------------------------------------------------------------------------------ +# ----------------------------------------------------------------------------- - -TEMPLATE = """ +_SOURCECODE = """ {{ source_code }} -{{ only_html }} +.. only:: html - {% if source_link or (html_show_formats and not multi_image) %} + {% if src_name or (html_show_formats and not multi_image) %} ( - {%- if source_link -%} - `Source code <{{ source_link }}>`__ + {%- if src_name -%} + :download:`Source code <{{ build_dir }}/{{ src_name }}>` {%- endif -%} {%- if html_show_formats and not multi_image -%} {%- for img in images -%} {%- for fmt in img.formats -%} - {%- if source_link or not loop.first -%}, {% endif -%} - `{{ fmt }} <{{ dest_dir }}/{{ img.basename }}.{{ fmt }}>`__ + {%- if src_name or not loop.first -%}, {% endif -%} + :download:`{{ fmt }} <{{ build_dir }}/{{ img.basename }}.{{ fmt }}>` {%- endfor -%} {%- endfor -%} {%- endif -%} ) {% endif %} +""" + +TEMPLATE_SRCSET = _SOURCECODE + """ + {% for img in images %} + .. figure-mpl:: {{ build_dir }}/{{ img.basename }}.{{ default_fmt }} + {% for option in options -%} + {{ option }} + {% endfor %} + {%- if caption -%} + {{ caption }} {# appropriate leading whitespace added beforehand #} + {% endif -%} + {%- if srcset -%} + :srcset: {{ build_dir }}/{{ img.basename }}.{{ default_fmt }} + {%- for sr in srcset -%} + , {{ build_dir }}/{{ img.basename }}.{{ sr }}.{{ default_fmt }} {{sr}} + {%- endfor -%} + {% endif %} + + {% if html_show_formats and multi_image %} + ( + {%- for fmt in img.formats -%} + {%- if not loop.first -%}, {% endif -%} + :download:`{{ fmt }} <{{ build_dir }}/{{ img.basename }}.{{ fmt }}>` + {%- endfor -%} + ) + {% endif %} + + + {% endfor %} + +.. only:: not html {% for img in images %} - .. figure:: {{ build_dir }}/{{ img.basename }}.png - {%- for option in options %} + .. figure-mpl:: {{ build_dir }}/{{ img.basename }}.* + {% for option in options -%} + {{ option }} + {% endfor -%} + + {{ caption }} {# appropriate leading whitespace added beforehand #} + {% endfor %} + +""" + +TEMPLATE = _SOURCECODE + """ + + {% for img in images %} + .. figure:: {{ build_dir }}/{{ img.basename }}.{{ default_fmt }} + {% for option in options -%} {{ option }} {% endfor %} @@ -379,24 +433,29 @@ TEMPLATE = """ ( {%- for fmt in img.formats -%} {%- if not loop.first -%}, {% endif -%} - `{{ fmt }} <{{ dest_dir }}/{{ img.basename }}.{{ fmt }}>`__ + :download:`{{ fmt }} <{{ build_dir }}/{{ img.basename }}.{{ fmt }}>` {%- endfor -%} ) {%- endif -%} - {{ caption }} + {{ caption }} {# appropriate leading whitespace added beforehand #} {% endfor %} -{{ only_latex }} +.. only:: not html {% for img in images %} - .. image:: {{ build_dir }}/{{ img.basename }}.pdf + .. figure:: {{ build_dir }}/{{ img.basename }}.* + {% for option in options -%} + {{ option }} + {% endfor -%} + + {{ caption }} {# appropriate leading whitespace added beforehand #} {% endfor %} """ exception_template = """ -.. htmlonly:: +.. only:: html [`source code <%(linkdir)s/%(basename)s.py>`__] @@ -408,6 +467,7 @@ Exception occurred rendering plot. # :context: option plot_context = dict() + class ImageFile: def __init__(self, basename, dirname): self.basename = basename @@ -415,24 +475,39 @@ class ImageFile: self.formats = [] def filename(self, format): - return os.path.join(self.dirname, "%s.%s" % (self.basename, format)) + return os.path.join(self.dirname, f"{self.basename}.{format}") def filenames(self): return [self.filename(fmt) for fmt in self.formats] -def out_of_date(original, derived): + +def out_of_date(original, derived, includes=None): """ - Returns True if derivative is out-of-date wrt original, - both of which are full file paths. + Return whether *derived* is out-of-date relative to *original* or any of + the RST files included in it using the RST include directive (*includes*). + *derived* and *original* are full paths, and *includes* is optionally a + list of full paths which may have been included in the *original*. """ - return (not os.path.exists(derived) or - (os.path.exists(original) and - os.stat(derived).st_mtime < os.stat(original).st_mtime)) + if not os.path.exists(derived): + return True + + if includes is None: + includes = [] + files_to_check = [original, *includes] + + def out_of_date_one(original, derived_mtime): + return (os.path.exists(original) and + derived_mtime < os.stat(original).st_mtime) + + derived_mtime = os.stat(derived).st_mtime + return any(out_of_date_one(f, derived_mtime) for f in files_to_check) + class PlotError(RuntimeError): pass -def run_code(code, code_path, ns=None, function_name=None): + +def _run_code(code, code_path, ns=None, function_name=None): """ Import a Python module from a path, and run the function given by name, if function_name is not None. @@ -441,106 +516,138 @@ def run_code(code, code_path, ns=None, function_name=None): # Change the working directory to the directory of the example, so # it can get at its data files, if any. Add its path to sys.path # so it can import any helper modules sitting beside it. - pwd = os.getcwd() - old_sys_path = list(sys.path) - if code_path is not None: + if setup.config.plot_working_directory is not None: + try: + os.chdir(setup.config.plot_working_directory) + except OSError as err: + raise OSError(f'{err}\n`plot_working_directory` option in ' + f'Sphinx configuration file must be a valid ' + f'directory path') from err + except TypeError as err: + raise TypeError(f'{err}\n`plot_working_directory` option in ' + f'Sphinx configuration file must be a string or ' + f'None') from err + elif code_path is not None: dirname = os.path.abspath(os.path.dirname(code_path)) os.chdir(dirname) - sys.path.insert(0, dirname) - # Redirect stdout - stdout = sys.stdout - sys.stdout = io.StringIO() - - # Reset sys.argv - old_sys_argv = sys.argv - sys.argv = [code_path] - - try: + with cbook._setattr_cm( + sys, argv=[code_path], path=[os.getcwd(), *sys.path]), \ + contextlib.redirect_stdout(StringIO()): try: - code = unescape_doctest(code) if ns is None: ns = {} if not ns: if setup.config.plot_pre_code is None: - exec("import numpy as np\nfrom matplotlib import pyplot as plt\n", ns) + exec('import numpy as np\n' + 'from matplotlib import pyplot as plt\n', ns) else: - exec(setup.config.plot_pre_code, ns) + exec(str(setup.config.plot_pre_code), ns) if "__main__" in code: - exec("__name__ = '__main__'", ns) - exec(code, ns) - if function_name is not None: - exec(function_name + "()", ns) + ns['__name__'] = '__main__' + + # Patch out non-interactive show() to avoid triggering a warning. + with cbook._setattr_cm(FigureManagerBase, show=lambda self: None): + exec(code, ns) + if function_name is not None: + exec(function_name + "()", ns) + except (Exception, SystemExit) as err: - raise PlotError(traceback.format_exc()) - finally: - os.chdir(pwd) - sys.argv = old_sys_argv - sys.path[:] = old_sys_path - sys.stdout = stdout + raise PlotError(traceback.format_exc()) from err + finally: + os.chdir(pwd) return ns -def clear_state(plot_rcparams): - plt.close('all') + +def clear_state(plot_rcparams, close=True): + if close: + plt.close('all') matplotlib.rc_file_defaults() matplotlib.rcParams.update(plot_rcparams) -def render_figures(code, code_path, output_dir, output_base, context, - function_name, config): + +def get_plot_formats(config): + default_dpi = {'png': 80, 'hires.png': 200, 'pdf': 200} + formats = [] + plot_formats = config.plot_formats + for fmt in plot_formats: + if isinstance(fmt, str): + if ':' in fmt: + suffix, dpi = fmt.split(':') + formats.append((str(suffix), int(dpi))) + else: + formats.append((fmt, default_dpi.get(fmt, 80))) + elif isinstance(fmt, (tuple, list)) and len(fmt) == 2: + formats.append((str(fmt[0]), int(fmt[1]))) + else: + raise PlotError('invalid image format "%r" in plot_formats' % fmt) + return formats + + +def _parse_srcset(entries): """ - Run a pyplot script and save the low and high res PNGs and a PDF - in outdir. + Parse srcset for multiples... + """ + srcset = {} + for entry in entries: + entry = entry.strip() + if len(entry) >= 2: + mult = entry[:-1] + srcset[float(mult)] = entry + else: + raise ExtensionError(f'srcset argument {entry!r} is invalid.') + return srcset + + +def render_figures(code, code_path, output_dir, output_base, context, + function_name, config, context_reset=False, + close_figs=False, + code_includes=None): + """ + Run a pyplot script and save the images in *output_dir*. Save the images under *output_dir* with file names derived from *output_base* """ - # -- Parse format list - default_dpi = {'png': 80, 'hires.png': 200, 'pdf': 200} - formats = [] - plot_formats = config.plot_formats - if isinstance(plot_formats, str): - plot_formats = eval(plot_formats) - for fmt in plot_formats: - if isinstance(fmt, str): - formats.append((fmt, default_dpi.get(fmt, 80))) - elif type(fmt) in (tuple, list) and len(fmt)==2: - formats.append((str(fmt[0]), int(fmt[1]))) - else: - raise PlotError('invalid image format "%r" in plot_formats' % fmt) - # -- Try to determine if all images already exist + if function_name is not None: + output_base = f'{output_base}_{function_name}' + formats = get_plot_formats(config) - code_pieces = split_code_at_show(code) + # Try to determine if all images already exist + is_doctest, code_pieces = _split_code_at_show(code, function_name) # Look for single-figure output files first - # Look for single-figure output files first - all_exists = True img = ImageFile(output_base, output_dir) for format, dpi in formats: - if out_of_date(code_path, img.filename(format)): + if context or out_of_date(code_path, img.filename(format), + includes=code_includes): all_exists = False break img.formats.append(format) + else: + all_exists = True if all_exists: return [(code, [img])] # Then look for multi-figure output files results = [] - all_exists = True for i, code_piece in enumerate(code_pieces): images = [] - for j in range(1000): + for j in itertools.count(): if len(code_pieces) > 1: - img = ImageFile('%s_%02d_%02d' % (output_base, i, j), output_dir) + img = ImageFile('%s_%02d_%02d' % (output_base, i, j), + output_dir) else: img = ImageFile('%s_%02d' % (output_base, j), output_dir) - for format, dpi in formats: - if out_of_date(code_path, img.filename(format)): + for fmt, dpi in formats: + if context or out_of_date(code_path, img.filename(fmt), + includes=code_includes): all_exists = False break - img.formats.append(format) + img.formats.append(fmt) # assume that if we have one, we have them all if not all_exists: @@ -550,6 +657,8 @@ def render_figures(code, code_path, output_dir, output_base, context, if not all_exists: break results.append((code_piece, images)) + else: + all_exists = True if all_exists: return results @@ -557,15 +666,24 @@ def render_figures(code, code_path, output_dir, output_base, context, # We didn't find the files, so build them results = [] - if context: - ns = plot_context - else: - ns = {} + ns = plot_context if context else {} + + if context_reset: + clear_state(config.plot_rcparams) + plot_context.clear() + + close_figs = not context or close_figs for i, code_piece in enumerate(code_pieces): - if not context: - clear_state(config.plot_rcparams) - run_code(code_piece, code_path, ns, function_name) + + if not context or config.plot_apply_rcparams: + clear_state(config.plot_rcparams, close_figs) + elif close_figs: + plt.close('all') + + _run_code(doctest.script_from_examples(code_piece) if is_doctest + else code_piece, + code_path, ns, function_name) images = [] fig_managers = _pylab_helpers.Gcf.get_all_fig_managers() @@ -578,31 +696,54 @@ def render_figures(code, code_path, output_dir, output_base, context, img = ImageFile("%s_%02d_%02d" % (output_base, i, j), output_dir) images.append(img) - for format, dpi in formats: + + for fmt, dpi in formats: try: - figman.canvas.figure.savefig(img.filename(format), dpi=dpi) + figman.canvas.figure.savefig(img.filename(fmt), dpi=dpi) + if fmt == formats[0][0] and config.plot_srcset: + # save a 2x, 3x etc version of the default... + srcset = _parse_srcset(config.plot_srcset) + for mult, suffix in srcset.items(): + fm = f'{suffix}.{fmt}' + img.formats.append(fm) + figman.canvas.figure.savefig(img.filename(fm), + dpi=int(dpi * mult)) except Exception as err: - raise PlotError(traceback.format_exc()) - img.formats.append(format) + raise PlotError(traceback.format_exc()) from err + img.formats.append(fmt) results.append((code_piece, images)) - if not context: - clear_state(config.plot_rcparams) + if not context or config.plot_apply_rcparams: + clear_state(config.plot_rcparams, close=not context) return results -def run(arguments, content, options, state_machine, state, lineno): - # The user may provide a filename *or* Python code content, but not both - if arguments and content: - raise RuntimeError("plot:: directive can't have both args and content") +def run(arguments, content, options, state_machine, state, lineno): document = state_machine.document config = document.settings.env.config nofigs = 'nofigs' in options + if config.plot_srcset and setup.app.builder.name == 'singlehtml': + raise ExtensionError( + 'plot_srcset option not compatible with single HTML writer') + + formats = get_plot_formats(config) + default_fmt = formats[0][0] + options.setdefault('include-source', config.plot_include_source) - context = 'context' in options + options.setdefault('show-source-link', config.plot_html_show_source_link) + + if 'class' in options: + # classes are parsed into a list of string, and output by simply + # printing the list, abusing the fact that RST guarantees to strip + # non-conforming characters + options['class'] = ['plot-directive'] + options['class'] + else: + options.setdefault('class', ['plot-directive']) + keep_context = 'context' in options + context_opt = None if not keep_context else options['context'] rst_file = document.attributes['source'] rst_dir = os.path.dirname(rst_file) @@ -612,21 +753,28 @@ def run(arguments, content, options, state_machine, state, lineno): source_file_name = os.path.join(setup.app.builder.srcdir, directives.uri(arguments[0])) else: - source_file_name = os.path.join(setup.app.builder.srcdir, config.plot_basedir, + source_file_name = os.path.join(setup.confdir, config.plot_basedir, directives.uri(arguments[0])) - # If there is content, it will be passed as a caption. caption = '\n'.join(content) + # Enforce unambiguous use of captions. + if "caption" in options: + if caption: + raise ValueError( + 'Caption specified in both content and options.' + ' Please remove ambiguity.' + ) + # Use caption option + caption = options["caption"] + # If the optional function name is provided, use it if len(arguments) == 2: function_name = arguments[1] else: function_name = None - fd = open(source_file_name, 'r') - code = fd.read() - fd.close() + code = Path(source_file_name).read_text(encoding='utf-8') output_base = os.path.basename(source_file_name) else: source_file_name = rst_file @@ -636,7 +784,7 @@ def run(arguments, content, options, state_machine, state, lineno): base, ext = os.path.splitext(os.path.basename(source_file_name)) output_base = '%s-%d.py' % (base, counter) function_name = None - caption = '' + caption = options.get('caption', '') base, source_ext = os.path.splitext(output_base) if source_ext in ('.py', '.rst', '.txt'): @@ -656,10 +804,8 @@ def run(arguments, content, options, state_machine, state, lineno): is_doctest = True # determine output directory name fragment - source_rel_name = relpath(source_file_name, setup.app.srcdir) - source_rel_dir = os.path.dirname(source_rel_name) - while source_rel_dir.startswith(os.path.sep): - source_rel_dir = source_rel_dir[1:] + source_rel_name = relpath(source_file_name, setup.confdir) + source_rel_dir = os.path.dirname(source_rel_name).lstrip(os.path.sep) # build_dir: where to place output files (temporarily) build_dir = os.path.join(os.path.dirname(setup.app.doctreedir), @@ -669,51 +815,80 @@ def run(arguments, content, options, state_machine, state, lineno): # see note in Python docs for warning about symbolic links on Windows. # need to compare source and dest paths at end build_dir = os.path.normpath(build_dir) - - if not os.path.exists(build_dir): - os.makedirs(build_dir) - - # output_dir: final location in the builder's directory - dest_dir = os.path.abspath(os.path.join(setup.app.builder.outdir, - source_rel_dir)) - if not os.path.exists(dest_dir): - os.makedirs(dest_dir) # no problem here for me, but just use built-ins + os.makedirs(build_dir, exist_ok=True) # how to link to files from the RST file - dest_dir_link = os.path.join(relpath(setup.app.srcdir, rst_dir), - source_rel_dir).replace(os.path.sep, '/') - build_dir_link = relpath(build_dir, rst_dir).replace(os.path.sep, '/') - source_link = dest_dir_link + '/' + output_base + source_ext + try: + build_dir_link = relpath(build_dir, rst_dir).replace(os.path.sep, '/') + except ValueError: + # on Windows, relpath raises ValueError when path and start are on + # different mounts/drives + build_dir_link = build_dir + + # get list of included rst files so that the output is updated when any + # plots in the included files change. These attributes are modified by the + # include directive (see the docutils.parsers.rst.directives.misc module). + try: + source_file_includes = [os.path.join(os.getcwd(), t[0]) + for t in state.document.include_log] + except AttributeError: + # the document.include_log attribute only exists in docutils >=0.17, + # before that we need to inspect the state machine + possible_sources = {os.path.join(setup.confdir, t[0]) + for t in state_machine.input_lines.items} + source_file_includes = [f for f in possible_sources + if os.path.isfile(f)] + # remove the source file itself from the includes + try: + source_file_includes.remove(source_file_name) + except ValueError: + pass + + # save script (if necessary) + if options['show-source-link']: + Path(build_dir, output_base + source_ext).write_text( + doctest.script_from_examples(code) + if source_file_name == rst_file and is_doctest + else code, + encoding='utf-8') # make figures try: - results = render_figures(code, source_file_name, build_dir, output_base, - context, function_name, config) + results = render_figures(code=code, + code_path=source_file_name, + output_dir=build_dir, + output_base=output_base, + context=keep_context, + function_name=function_name, + config=config, + context_reset=context_opt == 'reset', + close_figs=context_opt == 'close-figs', + code_includes=source_file_includes) errors = [] except PlotError as err: reporter = state.memo.reporter sm = reporter.system_message( - 2, "Exception occurred in plotting %s\n from %s:\n%s" % (output_base, - source_file_name, err), + 2, "Exception occurred in plotting {}\n from {}:\n{}".format( + output_base, source_file_name, err), line=lineno) results = [(code, [])] errors = [sm] # Properly indent the caption - caption = '\n'.join(' ' + line.strip() - for line in caption.split('\n')) - + if caption and config.plot_srcset: + caption = f':caption: {caption}' + elif caption: + caption = '\n' + '\n'.join(' ' + line.strip() + for line in caption.split('\n')) # generate output restructuredtext total_lines = [] for j, (code_piece, images) in enumerate(results): if options['include-source']: if is_doctest: - lines = [''] - lines += [row.rstrip() for row in code_piece.split('\n')] + lines = ['', *code_piece.splitlines()] else: - lines = ['.. code-block:: python', ''] - lines += [' %s' % row.rstrip() - for row in code_piece.split('\n')] + lines = ['.. code-block:: python', '', + *textwrap.indent(code_piece, ' ').splitlines()] source_code = "\n".join(lines) else: source_code = "" @@ -721,53 +896,38 @@ def run(arguments, content, options, state_machine, state, lineno): if nofigs: images = [] - opts = [':%s: %s' % (key, val) for key, val in list(options.items()) - if key in ('alt', 'height', 'width', 'scale', 'align', 'class')] + opts = [ + f':{key}: {val}' for key, val in options.items() + if key in ('alt', 'height', 'width', 'scale', 'align', 'class')] - only_html = ".. only:: html" - only_latex = ".. only:: latex" - - if j == 0: - src_link = source_link + # Not-None src_name signals the need for a source download in the + # generated html + if j == 0 and options['show-source-link']: + src_name = output_base + source_ext else: - src_link = None + src_name = None + if config.plot_srcset: + srcset = [*_parse_srcset(config.plot_srcset).values()] + template = TEMPLATE_SRCSET + else: + srcset = None + template = TEMPLATE - result = format_template( - TEMPLATE, - dest_dir=dest_dir_link, + result = jinja2.Template(config.plot_template or template).render( + default_fmt=default_fmt, build_dir=build_dir_link, - source_link=src_link, + src_name=src_name, multi_image=len(images) > 1, - only_html=only_html, - only_latex=only_latex, options=opts, + srcset=srcset, images=images, source_code=source_code, - html_show_formats=config.plot_html_show_formats, + html_show_formats=config.plot_html_show_formats and len(images), caption=caption) - total_lines.extend(result.split("\n")) total_lines.extend("\n") if total_lines: state_machine.insert_input(total_lines, source=source_file_name) - # copy image files to builder's output directory, if necessary - if not os.path.exists(dest_dir): - cbook.mkdirs(dest_dir) - - for code_piece, images in results: - for img in images: - for fn in img.filenames(): - destimg = os.path.join(dest_dir, os.path.basename(fn)) - if fn != destimg: - shutil.copyfile(fn, destimg) - - # copy script (if necessary) - #if source_file_name == rst_file: - target_name = os.path.join(dest_dir, output_base + source_ext) - f = open(target_name, 'w') - f.write(unescape_doctest(code)) - f.close() - return errors