diff --git a/docs/_ext/autodoc_analysis.py b/docs/_ext/autodoc_analysis.py new file mode 100644 index 0000000000..73c0cac5a9 --- /dev/null +++ b/docs/_ext/autodoc_analysis.py @@ -0,0 +1,67 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +""" +Documentation extension for analysis class. +""" + +from typing import Any + +from docs._ext.custom_styles.styles import AnalysisDocstring +from qiskit_experiments.framework.base_analysis import BaseAnalysis +from sphinx.application import Sphinx +from sphinx.ext.autodoc import ClassDocumenter + + +class AnalysisDocumenter(ClassDocumenter): + """Sphinx extension for the custom documentation of the standard analysis class.""" + + objtype = "analysis" + directivetype = 'class' + priority = 10 + ClassDocumenter.priority + option_spec = dict(ClassDocumenter.option_spec) + + @classmethod + def can_document_member( + cls, member: Any, membername: str, isattr: bool, parent: Any + ) -> bool: + return isinstance(member, BaseAnalysis) + + def add_content(self, more_content: Any, no_docstring: bool = False) -> None: + sourcename = self.get_sourcename() + + # analysis class doesn't have explicit init method. + class_doc = self.get_doc()[0] + + # format experiment documentation into the analysis style + class_doc_parser = AnalysisDocstring( + target_cls=self.object, + docstring_lines=class_doc, + config=self.env.app.config, + indent=self.content_indent, + ) + + # write introduction + for i, line in enumerate(self.process_doc(class_doc_parser.generate_class_docs())): + self.add_line(line, sourcename, i) + self.add_line("", sourcename) + + # method and attributes + if more_content: + for line, src in zip(more_content.data, more_content.items): + self.add_line(line, src[0], src[1]) + + +def setup(app: Sphinx): + existing_documenter = app.registry.documenters.get(AnalysisDocumenter.objtype) + if existing_documenter is None or not issubclass(existing_documenter, AnalysisDocumenter): + app.add_autodocumenter(AnalysisDocumenter, override=True) diff --git a/docs/_ext/autodoc_experiment.py b/docs/_ext/autodoc_experiment.py new file mode 100644 index 0000000000..389854d881 --- /dev/null +++ b/docs/_ext/autodoc_experiment.py @@ -0,0 +1,80 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +""" +Documentation extension for experiment class. +""" + +from typing import Any + +from docs._ext.custom_styles.styles import ExperimentDocstring +from qiskit.exceptions import QiskitError +from qiskit_experiments.framework.base_experiment import BaseExperiment +from sphinx.application import Sphinx +from sphinx.ext.autodoc import ClassDocumenter + + +class ExperimentDocumenter(ClassDocumenter): + """Sphinx extension for the custom documentation of the standard experiment class.""" + + objtype = "experiment" + directivetype = 'class' + priority = 10 + ClassDocumenter.priority + option_spec = dict(ClassDocumenter.option_spec) + + @classmethod + def can_document_member( + cls, member: Any, membername: str, isattr: bool, parent: Any + ) -> bool: + return isinstance(member, BaseExperiment) + + def add_content(self, more_content: Any, no_docstring: bool = False) -> None: + sourcename = self.get_sourcename() + + try: + class_doc, init_doc = self.get_doc() + except ValueError: + raise QiskitError( + f"Documentation of {self.name} doesn't match with the expected format." + "Please run sphinx build without using the experiment template." + ) + + # format experiment documentation into the experiment style + class_doc_parser = ExperimentDocstring( + target_cls=self.object, + docstring_lines=class_doc, + config=self.env.app.config, + indent=self.content_indent, + ) + + # write introduction + for i, line in enumerate(self.process_doc(class_doc_parser.generate_class_docs())): + self.add_line(line, sourcename, i) + self.add_line("", sourcename) + + # write init method documentation + self.add_line(".. rubric:: Initialization", sourcename) + self.add_line("", sourcename) + for i, line in enumerate(self.process_doc([init_doc])): + self.add_line(line, sourcename, i) + self.add_line("", sourcename) + + # method and attributes + if more_content: + for line, src in zip(more_content.data, more_content.items): + self.add_line(line, src[0], src[1]) + + +def setup(app: Sphinx): + existing_documenter = app.registry.documenters.get(ExperimentDocumenter.objtype) + if existing_documenter is None or not issubclass(existing_documenter, ExperimentDocumenter): + app.add_autodocumenter(ExperimentDocumenter, override=True) diff --git a/docs/_ext/autoref.py b/docs/_ext/autoref.py new file mode 100644 index 0000000000..6a4c42a4ae --- /dev/null +++ b/docs/_ext/autoref.py @@ -0,0 +1,105 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +""" +Helper directive to generate reference in convenient form. +""" +import arxiv + +from docutils import nodes +from docutils.parsers.rst import Directive +from sphinx.application import Sphinx + + +class WebSite(Directive): + """A custom helper directive for showing website link. + + This can be used, for example, + + .. code-block:: + + .. ref_website:: qiskit-experiments, https://github.com/Qiskit/qiskit-experiments + + """ + required_arguments = 1 + optional_arguments = 0 + final_argument_whitespace = True + + def run(self): + try: + name, url = self.arguments[0].split(",") + except ValueError: + raise ValueError( + f"{self.arguments[0]} is invalid website directive format. " + "Name and URL should be separated by a single comma." + ) + + link_name = nodes.paragraph(text=f"{name} ") + link_name += nodes.reference(text="(open)", refuri=url) + + return [link_name] + + +class Arxiv(Directive): + """A custom helper directive for generating journal information from arXiv id. + + This directive takes two arguments + + - Arbitrary reference name (no white space should be included) + + - arXiv ID + + This can be used, for example, + + .. code-block:: + + .. ref_arxiv:: qasm3-paper 2104.14722 + + If an article is not found, no journal information will be shown. + + """ + required_arguments = 2 + optional_arguments = 0 + final_argument_whitespace = False + + def run(self): + + # search arXiv database + try: + search = arxiv.Search(id_list=[self.arguments[1]]) + paper = next(search.results()) + except Exception: + return [] + + # generate journal link nodes + ret_node = nodes.paragraph() + + journal = "" + if paper.journal_ref: + journal += f", {paper.journal_ref}, " + if paper.doi: + journal += f"doi: {paper.doi}" + + ret_node += nodes.Text(f"[{self.arguments[0]}] ") + ret_node += nodes.Text(", ".join([author.name for author in paper.authors]) + ", ") + ret_node += nodes.emphasis(text=f"{paper.title}") + if journal: + ret_node += nodes.Text(journal) + ret_node += nodes.Text(" ") + ret_node += nodes.reference(text="(open)", refuri=paper.pdf_url) + + return [ret_node] + + +def setup(app: Sphinx): + app.add_directive("ref_arxiv", Arxiv) + app.add_directive("ref_website", WebSite) diff --git a/docs/_ext/custom_styles/example/__init__.py b/docs/_ext/custom_styles/example/__init__.py new file mode 100644 index 0000000000..fc54e4b2c8 --- /dev/null +++ b/docs/_ext/custom_styles/example/__init__.py @@ -0,0 +1,48 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +""" +======================================================================= +Example Documentation (:mod:`qiskit_experiments.documentation.example`) +======================================================================= + +.. currentmodule:: qiskit_experiments.documentation.example + + +.. warning:: + + This module is just an example for documentation. Do not import. + +.. note:: + + Under the autosummary directive you need to set template to trigger custom documentation. + + +Experiments +=========== +.. autosummary:: + :toctree: ../stubs/ + :template: autosummary/experiment.rst + + DocumentedExperiment + +Analysis +======== +.. autosummary:: + :toctree: ../stubs/ + :template: autosummary/analysis.rst + + DocumentedCurveAnalysis + +""" + +from .example_experiment import DocumentedExperiment, DocumentedCurveAnalysis diff --git a/docs/_ext/custom_styles/example/example_experiment.py b/docs/_ext/custom_styles/example/example_experiment.py new file mode 100644 index 0000000000..1fa3a0ac4a --- /dev/null +++ b/docs/_ext/custom_styles/example/example_experiment.py @@ -0,0 +1,218 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +""" +Class documentation examples. + +.. warning:: + + This module is just an example for documentation. Do not import. + +""" + +from qiskit.providers import Options + +from qiskit_experiments.curve_analysis.curve_analysis import CurveAnalysis +from qiskit_experiments.framework.base_experiment import BaseExperiment + + +class DocumentedCurveAnalysis(CurveAnalysis): + r"""One line summary of this class. This is shown in the top level contains list. + + # section: overview + Overview of this analysis. It is recommended to write this section. + Here you can explain technical aspect of fit algorithm or fit model. + Standard reStructuredText directives can be used. + + You can use following sections + + - ``warning`` + - ``note`` + - ``example`` + - ``reference`` + - ``tutorial`` + + See :class:`DocumentedExperiment` for description of these sections. + In addition to above sections, analysis template provides following extra sections. + + # section: fit_model + Here you can describe your fitting model. + Standard reStructuredText directives can be used. For example: + + .. math:: + + F(x) = a \exp(-(x-f)^2/(2\sigma^2)) + b + + enables you to use the Latex syntax to write your equation. + + # section: fit_parameters + Here you can explain fit parameter details. + This section provides a special syntax to describe details of each parameter. + Documentation except for this syntax will be just ignored. + + defpar a: + desc: Description of parameter :math:`a`. + init_guess: Here you can describe how this analysis estimate initial guess of + parameter :math:`a`. + bounds: Here you can describe how this analysis bounds parameter :math:`a` value + during the fit. + + defpar b: + desc: Description of parameter :math:`b`. + init_guess: Here you can describe how this analysis estimate initial guess of + parameter :math:`b`. + bounds: Here you can describe how this analysis bounds parameter :math:`b` value + during the fit. + + Note that you cannot write text block (i.e. bullet lines, math mode, parsed literal, ...) + in the ``defpar`` syntax items. These are a single line description of parameters. + You can write multiple ``defpar`` block for each fitting parameter. + + It would be nice if parameter names conform to the parameter key values appearing in the + analysis result. For example, if fit model defines the parameter :math:`\sigma` and + this appears as ``eta`` in the result, user cannot find correspondence of these parameters. + + """ + + @classmethod + def _default_options(cls) -> Options: + """Default analysis options. + + .. note:: + + This method documentation should conforms to the below documentation syntax. + Namely, the title should be "Analysis Options" followed by a single colon + and description should be written in the Google docstring style. + Numpy style is not accepted. + + Google style docstring guideline: + https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html + + Documentation except for the analysis options will be just ignored, e.g. this note. + If analysis options contains some values from the parent class, + the custom Sphinx parser searches for the parent class method documentation + and automatically generate documentation for all available options. + If there is any missing documentation the Sphinx build will fail. + + Analysis Options: + opt1 (int): Description for the option1. + opt2 (bool): Description for the option2. + opt3 (str): Description for the option3. + + """ + opts = super()._default_options() + opts.opt1 = 1.0 + opts.opt2 = True + opts.opt3 = "opt3" + + return opts + + +class DocumentedExperiment(BaseExperiment): + """One line summary of this class. This is shown in the top level contains list. + + # section: overview + Overview of this experiment. It is recommended to write this section. + Here you can explain technical aspect of experiment, protocol, etc... + Standard reStructuredText directives can be used. + + # section: warning + Warning about this experiment if exist. + Some functionality is not available or under development, + you should write these details here. + + # section: note + Notification about this experiment if exist. + + # section: example + Example code of this experiment. + If this experiment requires user to manage complicated options, + it might be convenient for users to have some code example here. + + You can write code example, for example, as follows + + .. code-block:: python + + import qiskit_experiments + my_experiment = qiskit_experiments.MyExperiment(**options) + + # section: reference + Currently this supports article reference in arXiv database. + You can use following helper directive. + + .. ref_arxiv:: Auth2020a 21xx.01xxx + + This directive takes two arguments separated by a whitespace. + The first argument is arbitrary label for this article, which may be used to + refer to this paper from other sections. + Second argument is the arXiv ID of the paper referring to. + Once this directive is inserted, Sphinx searches the arXiv database and + automatically generates a formatted bibliography with the hyperlink to the online PDF. + + # section: tutorial + You can refer to the arbitrary web page here. + Following helper directive can be used. + + .. ref_website:: Qiskit Experiment Github, https://github.com/Qiskit/qiskit-experiments + + This directive takes two arguments separated by a comma. + The first argument is arbitrary label shown before the link. Whitespace can be included. + The second argument is the URL of the website to hyperlink. + + """ + + @classmethod + def _default_experiment_options(cls) -> Options: + """Default experiment options. + + .. note:: + + This method documentation should conforms to the below documentation syntax. + Namely, the title should be "Experiment Options" followed by a single colon + and description should be written in the Google docstring style. + Numpy style is not accepted. + + Google style docstring guideline: + https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html + + Documentation except for the experiment options will be just ignored, e.g. this note. + If experiment options contains some values from the parent class, + the custom Sphinx parser searches for the parent class method documentation + and automatically generate documentation for all available options. + If there is any missing documentation the Sphinx build will fail. + + Experiment Options: + opt1 (int): Description for the option1. + opt2 (bool): Description for the option2. + opt3 (str): Description for the option3. + + """ + opts = super()._default_experiment_options() + opts.opt1 = 1.0 + opts.opt2 = True + opts.opt3 = "opt3" + + return opts + + def __init__(self, qubit: int): + """Create new experiment. + + .. note:: + + This documentation is shown as-is. + + Args: + qubit: The qubit to run experiment. + """ + super().__init__(qubits=[qubit]) + + def circuits(self, backend=None): + pass diff --git a/docs/_ext/custom_styles/formatter.py b/docs/_ext/custom_styles/formatter.py new file mode 100644 index 0000000000..40c61892ac --- /dev/null +++ b/docs/_ext/custom_styles/formatter.py @@ -0,0 +1,205 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +""" +A class that formats documentation sections. +""" +from typing import List +from .utils import _check_no_indent + + +class DocstringSectionFormatter: + """A class that formats parsed docstring lines. + + This formatter formats sections with Google Style Python Docstrings with + several reStructuredText directives. + """ + + def __init__(self, indent: str): + self.indent = indent + + def format_header(self, lines: List[str]) -> List[str]: + """Format header section.""" + format_lines = lines + format_lines.append("") + + return format_lines + + @_check_no_indent + def format_overview(self, lines: List[str]) -> List[str]: + """Format overview section.""" + format_lines = [".. rubric:: Overview", ""] + format_lines.extend(lines) + format_lines.append("") + + return format_lines + + @_check_no_indent + def format_reference(self, lines: List[str]) -> List[str]: + """Format reference section.""" + format_lines = [".. rubric:: References", ""] + format_lines.extend(lines) + format_lines.append("") + + return format_lines + + def format_warning(self, lines: List[str]) -> List[str]: + """Format warning section.""" + format_lines = [".. warning::", ""] + for line in lines: + format_lines.append(self.indent + line) + format_lines.append("") + + return format_lines + + @_check_no_indent + def format_example(self, lines: List[str]) -> List[str]: + """Format example section.""" + format_lines = [".. rubric:: Example", ""] + format_lines.extend(lines) + format_lines.append("") + + return format_lines + + def format_note(self, lines: List[str]) -> List[str]: + """Format notification section.""" + format_lines = [".. note::", ""] + for line in lines: + format_lines.append(self.indent + line) + format_lines.append("") + + return format_lines + + @_check_no_indent + def format_tutorial(self, lines: List[str]) -> List[str]: + """Format tutorial section.""" + format_lines = [".. rubric:: Tutorials", ""] + format_lines.extend(lines) + format_lines.append("") + + return format_lines + + +class ExperimentSectionFormatter(DocstringSectionFormatter): + """Formatter for experiment class.""" + + @_check_no_indent + def format_analysis_ref(self, lines: List[str]) -> List[str]: + """Format analysis class reference section.""" + format_lines = [".. rubric:: Analysis Class Reference", ""] + format_lines.extend(lines) + format_lines.append("") + + return format_lines + + @_check_no_indent + def format_experiment_opts(self, lines: List[str]) -> List[str]: + """Format experiment options section.""" + format_lines = [ + ".. rubric:: Experiment Options", + "", + "These options can be set by :py:meth:`set_experiment_options` method.", + "", + ] + format_lines.extend(lines) + format_lines.append("") + + return format_lines + + @_check_no_indent + def format_analysis_opts(self, lines: List[str]) -> List[str]: + """Format analysis options section.""" + format_lines = [ + ".. rubric:: Analysis Options", + "", + "These options can be set by :py:meth:`set_analysis_options` method.", + "", + ] + format_lines.extend(lines) + format_lines.append("") + + return format_lines + + @_check_no_indent + def format_transpiler_opts(self, lines: List[str]) -> List[str]: + """Format transpiler options section.""" + format_lines = [ + ".. rubric:: Transpiler Options", + "", + "This option can be set by :py:meth:`set_transpile_options` method.", + "", + ] + format_lines.extend(lines) + format_lines.append("") + + return format_lines + + @_check_no_indent + def format_run_opts(self, lines: List[str]) -> List[str]: + """Format run options section.""" + format_lines = [ + ".. rubric:: Backend Run Options", + "", + "This option can be set by :py:meth:`set_run_options` method.", + "", + ] + format_lines.extend(lines) + format_lines.append("") + + return format_lines + + +class AnalysisSectionFormatter(DocstringSectionFormatter): + """Formatter for analysis class.""" + + @_check_no_indent + def format_analysis_opts(self, lines: List[str]) -> List[str]: + """Format analysis options section.""" + format_lines = [ + ".. rubric:: Run Options", + "", + "These are the keyword arguments of :py:meth:`run` method.", + "", + ] + format_lines.extend(lines) + format_lines.append("") + + return format_lines + + @_check_no_indent + def format_fit_model(self, lines: List[str]) -> List[str]: + """Format fit model section.""" + format_lines = [ + ".. rubric:: Fit Model", + "", + "This is the curve fitting analysis. ", + "Following equation(s) are used to represent curve(s).", + "", + ] + format_lines.extend(lines) + format_lines.append("") + + return format_lines + + @_check_no_indent + def format_fit_parameters(self, lines: List[str]) -> List[str]: + """Format fit parameter section.""" + format_lines = [ + ".. rubric:: Fit Parameters", + "", + "Following fit parameters are estimated during the analysis.", + "", + ] + format_lines.extend(lines) + format_lines.append("") + + return format_lines diff --git a/docs/_ext/custom_styles/section_parsers.py b/docs/_ext/custom_styles/section_parsers.py new file mode 100644 index 0000000000..870d2011ae --- /dev/null +++ b/docs/_ext/custom_styles/section_parsers.py @@ -0,0 +1,90 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +""" +Documentation section parsers. +""" + +import re +from typing import List + +from .utils import _trim_empty_lines + + +def load_standard_section(docstring_lines: List[str]) -> List[str]: + """Load standard docstring section.""" + return _trim_empty_lines(docstring_lines) + + +def load_fit_parameters(docstring_lines: List[str]) -> List[str]: + """Load fit parameter section.""" + regex_paramdef = re.compile(r"defpar (?P.+):") + + # item finder + description_kind = { + "desc": re.compile(r"desc: (?P.+)"), + "init_guess": re.compile(r"init_guess: (?P.+)"), + "bounds": re.compile(r"bounds: (?P.+)"), + } + + # parse lines + parameter_desc = dict() + current_param = None + current_item = None + for line in docstring_lines: + if not list: + # remove line feed + continue + + # check if line is new parameter definition + match = re.match(regex_paramdef, line) + if match: + current_param = match["param"] + parameter_desc[current_param] = { + "desc": "", + "init_guess": "", + "bounds": "", + } + continue + + # check description + for kind, regex in description_kind.items(): + match = re.search(regex, line) + if match: + current_item = kind + line = match["s"].rstrip() + + # add line if parameter and item are already set + if current_param and current_item: + if parameter_desc[current_param][current_item]: + parameter_desc[current_param][current_item] += " " + line.lstrip() + else: + parameter_desc[current_param][current_item] = line.lstrip() + + section_lines = list() + + def write_description(header: str, kind: str): + section_lines.append(header) + for param, desc in parameter_desc.items(): + if not desc: + section_lines.append( + f" - :math:`{param}`: No description is provided. See source for details." + ) + else: + section_lines.append(f" - :math:`{param}`: {desc[kind]}") + section_lines.append("") + + write_description("Descriptions", "desc") + write_description("Initial Guess", "init_guess") + write_description("Boundaries", "bounds") + + return _trim_empty_lines(section_lines) diff --git a/docs/_ext/custom_styles/styles.py b/docs/_ext/custom_styles/styles.py new file mode 100644 index 0000000000..91d2dfaa3c --- /dev/null +++ b/docs/_ext/custom_styles/styles.py @@ -0,0 +1,323 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +""" +Documentation extension for experiment class. +""" +import copy +import re +import sys +from abc import ABC +from typing import Union, List, Dict + +from qiskit_experiments.framework.base_analysis import BaseAnalysis +from qiskit_experiments.framework.base_experiment import BaseExperiment +from sphinx.config import Config as SphinxConfig + +from .formatter import ( + ExperimentSectionFormatter, + AnalysisSectionFormatter, + DocstringSectionFormatter, +) +from .section_parsers import load_standard_section, load_fit_parameters +from .utils import _generate_options_documentation, _format_default_options + +section_regex = re.compile(r"# section: (?P\S+)") + + +class QiskitExperimentDocstring(ABC): + """Qiskit Experiment style docstring parser base class.""" + + # mapping of sections supported by this style to parsing method or function + __sections__ = {} + + # section formatter + __formatter__ = DocstringSectionFormatter + + def __init__( + self, + target_cls: object, + docstring_lines: Union[str, List[str]], + config: SphinxConfig, + indent: str = "", + ): + """Create new parser and parse formatted docstring.""" + + if isinstance(docstring_lines, str): + lines = docstring_lines.splitlines() + else: + lines = docstring_lines + + self._target_cls = target_cls + self._indent = indent + self._config = config + + self._parsed_lines = self._classify(lines) + + def _classify(self, docstrings: List[str]) -> Dict[str, List[str]]: + """Classify formatted docstring into sections.""" + sectioned_docstrings = dict() + + def add_new_section(section: str, lines: List[str]): + if lines: + parser = self.__sections__[section] + if not parser: + raise KeyError( + f"Section {section} is automatically generated section. " + "This section cannot be overridden by class docstring." + ) + sectioned_docstrings[section] = parser(temp_lines) + + current_section = list(self.__sections__.keys())[0] + temp_lines = list() + margin = sys.maxsize + for docstring_line in docstrings: + match = re.match(section_regex, docstring_line.strip()) + if match: + section_name = match["section_name"] + if section_name in self.__sections__: + # parse previous section + if margin < sys.maxsize: + temp_lines = [l[margin:] for l in temp_lines] + add_new_section(current_section, temp_lines) + # set new section + current_section = section_name + temp_lines.clear() + margin = sys.maxsize + else: + raise KeyError(f"Section name {section_name} is invalid.") + continue + + # calculate section indent + if len(docstring_line) > 0 and not docstring_line.isspace(): + # ignore empty line + indent = len(docstring_line) - len(docstring_line.lstrip()) + margin = min(indent, margin) + + temp_lines.append(docstring_line) + + # parse final section + if margin < sys.maxsize: + temp_lines = [l[margin:] for l in temp_lines] + add_new_section(current_section, temp_lines) + + for section, lines in self._extra_sections().items(): + sectioned_docstrings[section] = lines + + return sectioned_docstrings + + def _extra_sections(self) -> Dict[str, List[str]]: + """Generate extra sections.""" + pass + + def _format(self) -> Dict[str, List[str]]: + """Format each section with predefined formatter.""" + formatter = self.__formatter__(self._indent) + + formatted_sections = {section: None for section in self.__sections__} + for section, lines in self._parsed_lines.items(): + if not lines: + continue + section_formatter = getattr(formatter, f"format_{section}", None) + if section_formatter: + formatted_sections[section] = section_formatter(lines) + else: + formatted_sections[section] = lines + [""] + + return formatted_sections + + def generate_class_docs(self) -> List[List[str]]: + """Output formatted experiment class documentation.""" + formatted_sections = self._format() + + classdoc_lines = [] + for section_lines in formatted_sections.values(): + if section_lines: + classdoc_lines.extend(section_lines) + + return [classdoc_lines] + + +class ExperimentDocstring(QiskitExperimentDocstring): + """Documentation parser for the experiment class introduction.""" + + __sections__ = { + "header": load_standard_section, + "warning": load_standard_section, + "overview": load_standard_section, + "reference": load_standard_section, + "tutorial": load_standard_section, + "analysis_ref": None, + "experiment_opts": None, + "analysis_opts": None, + "transpiler_opts": None, + "run_opts": None, + "example": load_standard_section, + "note": load_standard_section, + } + + __formatter__ = ExperimentSectionFormatter + + def __init__( + self, + target_cls: BaseExperiment, + docstring_lines: Union[str, List[str]], + config: SphinxConfig, + indent: str = "", + ): + """Create new parser and parse formatted docstring.""" + super().__init__(target_cls, docstring_lines, config, indent) + + def _extra_sections(self) -> Dict[str, List[str]]: + """Generate extra sections.""" + parsed_sections = {} + + # add analysis class reference + analysis_class = getattr(self._target_cls, "__analysis_class__", None) + if analysis_class: + analysis_ref = f":py:class:`~{analysis_class.__module__}.{analysis_class.__name__}`" + parsed_sections["analysis_ref"] = [analysis_ref] + + # add experiment option + exp_option_desc = [] + + exp_docs_config = copy.copy(self._config) + exp_docs_config.napoleon_custom_sections = [("experiment options", "args")] + exp_option = _generate_options_documentation( + current_class=self._target_cls, + method_name="_default_experiment_options", + config=exp_docs_config, + indent=self._indent, + ) + if exp_option: + exp_option_desc.extend(exp_option) + exp_option_desc.append("") + exp_option_desc.extend( + _format_default_options( + defaults=self._target_cls._default_experiment_options().__dict__, + indent=self._indent, + ) + ) + else: + exp_option_desc.append("No experiment option available for this experiment.") + + parsed_sections["experiment_opts"] = exp_option_desc + + # add analysis option + analysis_option_desc = [] + + if analysis_class: + analysis_docs_config = copy.copy(self._config) + analysis_docs_config.napoleon_custom_sections = [("analysis options", "args")] + analysis_option = _generate_options_documentation( + current_class=analysis_class, + method_name="_default_options", + config=analysis_docs_config, + indent=self._indent, + ) + + if analysis_option: + analysis_option_desc.extend(analysis_option) + analysis_option_desc.append("") + analysis_option_desc.extend( + _format_default_options( + defaults=analysis_class._default_options().__dict__, + indent=self._indent, + ) + ) + else: + analysis_option_desc.append("No analysis option available for this experiment.") + + parsed_sections["analysis_opts"] = analysis_option_desc + + # add transpiler option + transpiler_option_desc = [ + "This option is used for circuit optimization. ", + "See `Qiskit Transpiler `_ documentation for available options.", + "", + ] + transpiler_option_desc.extend( + _format_default_options( + defaults=self._target_cls._default_transpile_options().__dict__, + indent=self._indent, + ) + ) + + parsed_sections["transpiler_opts"] = transpiler_option_desc + + # add run option + run_option_desc = [ + "This option is used for controlling job execution condition. " + "Note that this option is provider dependent. " + "See provider's backend runner API for available options. " + "See `here `_ for IBM Quantum Service.", + "", + ] + run_option_desc.extend( + _format_default_options( + defaults=self._target_cls._default_run_options().__dict__, + indent=self._indent, + ) + ) + + parsed_sections["run_opts"] = run_option_desc + + return parsed_sections + + +class AnalysisDocstring(QiskitExperimentDocstring): + """Documentation parser for the analysis class introduction.""" + + __sections__ = { + "header": load_standard_section, + "warning": load_standard_section, + "overview": load_standard_section, + "fit_model": load_standard_section, + "fit_parameters": load_fit_parameters, + "reference": load_standard_section, + "tutorial": load_standard_section, + "analysis_opts": None, + "example": load_standard_section, + "note": load_standard_section, + } + + __formatter__ = AnalysisSectionFormatter + + def __init__( + self, + target_cls: BaseAnalysis, + docstring_lines: Union[str, List[str]], + config: SphinxConfig, + indent: str = "", + ): + """Create new parser and parse formatted docstring.""" + super().__init__(target_cls, docstring_lines, config, indent) + + def _extra_sections(self) -> Dict[str, List[str]]: + """Generate extra sections.""" + parsed_sections = {} + + # add analysis option + analysis_docs_config = copy.copy(self._config) + analysis_docs_config.napoleon_custom_sections = [("analysis options", "args")] + analysis_option = _generate_options_documentation( + current_class=self._target_cls, + method_name="_default_options", + config=analysis_docs_config, + indent=self._indent, + ) + if analysis_option: + parsed_sections["analysis_opts"] = analysis_option + + return parsed_sections diff --git a/docs/_ext/custom_styles/utils.py b/docs/_ext/custom_styles/utils.py new file mode 100644 index 0000000000..46e7e63381 --- /dev/null +++ b/docs/_ext/custom_styles/utils.py @@ -0,0 +1,162 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. +""" +A collection of utilities to generate documentation. +""" + +import inspect +import re +from typing import List, Tuple, Dict, Any, Callable + +from sphinx.config import Config as SphinxConfig +from sphinx.ext.napoleon.docstring import GoogleDocstring +from sphinx.util.docstrings import prepare_docstring + + +def _trim_empty_lines(docstring_lines: List[str]) -> List[str]: + """A helper function to remove redundant line feeds.""" + i_start = 0 + lines_iter = iter(docstring_lines) + while not next(lines_iter): + i_start += 1 + + i_end = len(docstring_lines) + lines_iter = iter(docstring_lines[::-1]) + while not next(lines_iter): + i_end -= 1 + + return docstring_lines[i_start:i_end] + + +def _parse_option_field( + docstring: str, + config: SphinxConfig, + target_args: List[str], + indent: str = "", +) -> Tuple[List[str], List[str]]: + """A helper function to extract descriptions of target arguments.""" + + # use GoogleDocstring parameter parser + experiment_option_parser = GoogleDocstring( + docstring=prepare_docstring(docstring, tabsize=len(indent)), config=config + ) + parsed_lines = experiment_option_parser.lines() + + # remove redundant descriptions + param_regex = re.compile(r":(param|type) (?P\S+):") + target_params_description = [] + described_params = set() + valid_line = False + for line in parsed_lines: + is_item = re.match(param_regex, line) + if is_item: + if is_item["pname"] in target_args: + valid_line = True + described_params.add(is_item["pname"]) + else: + valid_line = False + if valid_line: + target_params_description.append(line) + + # find missing parameters + missing = set(target_args) - described_params + + return target_params_description, list(missing) + + +def _generate_options_documentation( + current_class: object, + method_name: str, + target_args: List[str] = None, + config: SphinxConfig = None, + indent: str = "", +) -> List[str]: + """Automatically generate documentation from the default options method.""" + + if current_class == object: + # check if no more base class + raise Exception(f"Option docstring for {', '.join(target_args)} is missing.") + + options_docstring_lines = [] + + default_opts = getattr(current_class, method_name, None) + if not default_opts: + # getter option is not defined + return [] + + if not target_args: + target_args = list(default_opts().__dict__.keys()) + + # parse default options method + parsed_lines, target_args = _parse_option_field( + docstring=default_opts.__doc__ or "", + config=config, + target_args=target_args, + indent=indent, + ) + + if target_args: + # parse parent class method docstring if some arg documentation is missing + parent_parsed_lines = _generate_options_documentation( + current_class=inspect.getmro(current_class)[1], + method_name=method_name, + target_args=target_args, + config=config, + indent=indent, + ) + options_docstring_lines.extend(parent_parsed_lines) + + options_docstring_lines.extend(parsed_lines) + + if options_docstring_lines: + return _trim_empty_lines(options_docstring_lines) + + return options_docstring_lines + + +def _format_default_options(defaults: Dict[str, Any], indent: str = "") -> List[str]: + """Format default options to docstring lines.""" + docstring_lines = [ + ".. dropdown:: Default values", + indent + ":animate: fade-in-slide-down", + "", + ] + + if not defaults: + docstring_lines.append(indent + "No default options are set.") + else: + docstring_lines.append(indent + "Following values are set by default.") + docstring_lines.append("") + docstring_lines.append(indent + ".. parsed-literal::") + docstring_lines.append("") + for par, value in defaults.items(): + if callable(value): + value_repr = f"Callable {value.__name__}" + else: + value_repr = repr(value) + docstring_lines.append(indent * 2 + f"{par:<25} := {value_repr}") + + return docstring_lines + + +def _check_no_indent(method: Callable) -> Callable: + """Check indent of lines and return if this block is correctly indented.""" + def wraps(self, lines: List[str], *args, **kwargs): + if all(l.startswith(" ") for l in lines): + text_block = "\n".join(lines) + raise ValueError( + "Following documentation may have invalid indentation. " + f"Please carefully check all indent levels are aligned. \n\n{text_block}" + ) + return method(self, lines, *args, **kwargs) + + return wraps diff --git a/docs/_templates/autosummary/analysis.rst b/docs/_templates/autosummary/analysis.rst new file mode 100644 index 0000000000..222df215af --- /dev/null +++ b/docs/_templates/autosummary/analysis.rst @@ -0,0 +1,49 @@ +{% if referencefile %} +.. include:: {{ referencefile }} +{% endif %} + +{{ objname }} +{{ underline }} + +.. currentmodule:: {{ module }} + +.. autoanalysis:: {{ objname }} + :no-members: + :no-inherited-members: + :no-special-members: + + {% block attributes_summary %} + {% if attributes %} + + .. rubric:: Attributes + + .. autosummary:: + :toctree: ../stubs/ + {% for item in all_attributes %} + {%- if not item.startswith('_') %} + {{ name }}.{{ item }} + {%- endif -%} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block methods_summary %} + {% if methods %} + + .. rubric:: Methods + + .. autosummary:: + :toctree: ../stubs/ + {% for item in all_methods %} + {%- if not item.startswith('_') or item in ['__call__', '__mul__', '__getitem__', '__len__'] %} + {{ name }}.{{ item }} + {%- endif -%} + {%- endfor %} + {% for item in inherited_members %} + {%- if item in ['__call__', '__mul__', '__getitem__', '__len__'] %} + {{ name }}.{{ item }} + {%- endif -%} + {%- endfor %} + + {% endif %} + {% endblock %} diff --git a/docs/_templates/autosummary/experiment.rst b/docs/_templates/autosummary/experiment.rst new file mode 100644 index 0000000000..01800ea10b --- /dev/null +++ b/docs/_templates/autosummary/experiment.rst @@ -0,0 +1,49 @@ +{% if referencefile %} +.. include:: {{ referencefile }} +{% endif %} + +{{ objname }} +{{ underline }} + +.. currentmodule:: {{ module }} + +.. autoexperiment:: {{ objname }} + :no-members: + :no-inherited-members: + :no-special-members: + + {% block attributes_summary %} + {% if attributes %} + + .. rubric:: Attributes + + .. autosummary:: + :toctree: ../stubs/ + {% for item in all_attributes %} + {%- if not item.startswith('_') %} + {{ name }}.{{ item }} + {%- endif -%} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block methods_summary %} + {% if methods %} + + .. rubric:: Methods + + .. autosummary:: + :toctree: ../stubs/ + {% for item in all_methods %} + {%- if not item.startswith('_') or item in ['__call__', '__mul__', '__getitem__', '__len__'] %} + {{ name }}.{{ item }} + {%- endif -%} + {%- endfor %} + {% for item in inherited_members %} + {%- if item in ['__call__', '__mul__', '__getitem__', '__len__'] %} + {{ name }}.{{ item }} + {%- endif -%} + {%- endfor %} + + {% endif %} + {% endblock %} diff --git a/docs/conf.py b/docs/conf.py index 79c501e4fd..9fed00b22b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,9 +23,10 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) +import os +import sys +sys.path.insert(0, os.path.abspath('.')) +sys.path.append(os.path.abspath("./_ext")) """ Sphinx documentation builder @@ -37,7 +38,7 @@ os.environ['QISKIT_DOCS'] = 'TRUE' # -- Project information ----------------------------------------------------- -project = 'Qiskit ODE Solvers' +project = 'Qiskit Experiments' copyright = '2021, Qiskit Development Team' # pylint: disable=redefined-builtin author = 'Qiskit Development Team' @@ -92,6 +93,9 @@ 'sphinx_panels', 'sphinx.ext.intersphinx', 'nbsphinx', + 'autoref', + 'autodoc_experiment', + 'autodoc_analysis', ] html_static_path = ['_static'] templates_path = ['_templates'] diff --git a/qiskit_experiments/curve_analysis/curve_analysis.py b/qiskit_experiments/curve_analysis/curve_analysis.py index a31a4d5a43..d43029201d 100644 --- a/qiskit_experiments/curve_analysis/curve_analysis.py +++ b/qiskit_experiments/curve_analysis/curve_analysis.py @@ -17,6 +17,7 @@ import dataclasses import inspect +from abc import ABC import functools from typing import Any, Dict, List, Tuple, Callable, Union, Optional @@ -41,7 +42,7 @@ from qiskit_experiments.curve_analysis.utils import get_opt_value, get_opt_error -class CurveAnalysis(BaseAnalysis): +class CurveAnalysis(BaseAnalysis, ABC): """A base class for curve fit type analysis. The subclasses can override class attributes to define the behavior of @@ -270,48 +271,29 @@ def __init__(self): setattr(self, f"__{key}", None) @classmethod - def _default_options(cls): + def _default_options(cls) -> Options: """Return default analysis options. - Options: - curve_fitter: A callback function to perform fitting with formatted data. - This function should have signature: - - .. code-block:: - - def curve_fitter( - funcs: List[Callable], - series: ndarray, - xdata: ndarray, - ydata: ndarray, - p0: ndarray, - sigma: Optional[ndarray], - weights: Optional[ndarray], - bounds: Optional[ - Union[Dict[str, Tuple[float, float]], Tuple[ndarray, ndarray]] - ], - ) -> CurveAnalysisResultData: - - See :func:`~qiskit_experiment.curve_analysis.multi_curve_fit` for example. - data_processor: A callback function to format experiment data. - This function should have signature: - - .. code-block:: - - def data_processor(data: Dict[str, Any]) -> Tuple[float, float] - - This can be a :class:`~qiskit_experiment.data_processing.DataProcessor` + Analysis Options: + curve_fitter (Callable): A callback function to perform fitting with formatted data. + See :func:`~qiskit_experiments.analysis.multi_curve_fit` for example. + data_processor (Callable): A callback function to format experiment data. + This can be a :class:`~qiskit_experiments.data_processing.DataProcessor` instance that defines the `self.__call__` method. - normalization: Set ``True`` to normalize y values within range [-1, 1]. - p0: Array-like or dictionary of initial parameters. - bounds: Array-like or dictionary of (min, max) tuple of fit parameter boundaries. - x_key: Circuit metadata key representing a scanned value. - plot: Set ``True`` to create figure for fit result. - axis: Optional. A matplotlib axis object to draw. - xlabel: X label of fit result figure. - ylabel: Y label of fit result figure. - fit_reports: Mapping of fit parameters and representation in the fit report. - return_data_points: Set ``True`` to return formatted XY data. + normalization (bool) : Set ``True`` to normalize y values within range [-1, 1]. + p0 (Dict[str, float]): Array-like or dictionary + of initial parameters. + bounds (Dict[str, Tuple[float, float]]): Array-like or dictionary + of (min, max) tuple of fit parameter boundaries. + x_key (str): Circuit metadata key representing a scanned value. + plot (bool): Set ``True`` to create figure for fit result. + axis (AxesSubplot): Optional. A matplotlib axis object to draw. + xlabel (str): X label of fit result figure. + ylabel (str): Y label of fit result figure. + ylim (Tuple[float, float]): Min and max height limit of fit plot. + fit_reports (Dict[str, str]): Mapping of fit parameters and representation + in the fit report. + return_data_points (bool): Set ``True`` to return formatted XY data. """ return Options( curve_fitter=multi_curve_fit, diff --git a/requirements-dev.txt b/requirements-dev.txt index cd0c8eb3a5..10ab15adf9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,6 +10,7 @@ pygments>=2.4 reno>=3.2.0 sphinx-panels nbsphinx +arxiv ddt~=1.4.2 qiskit-aer>=0.8.0