From 120fd6a894a0ab43ed2c15e7355fd07b9cd3faf7 Mon Sep 17 00:00:00 2001 From: Praharsh Suryadevara Date: Sun, 11 Oct 2026 14:55:18 -0500 Subject: [PATCH 01/14] Restore Python and C++ documentation builds and automate website publishing --- .github/workflows/docs.yml | 111 ++++++++++++++++++ .gitignore | 5 +- README.rst | 5 + doc/Makefile | 2 +- doc/_static/scipy.css | 2 +- doc/_templates/autosummary/class.rst | 18 +-- doc/conf.py | 32 ++--- doc/cpp_reference.rst | 11 ++ doc/doxygen/Doxyfile | 19 +-- doc/doxygen/DoxygenLayout.xml | 3 +- doc/global_optimization.rst | 10 +- doc/index.rst | 9 +- doc/local_connect.rst | 2 +- doc/monte_carlo.rst | 2 + doc/system_class.rst | 4 + doc/transition_state_refinement.rst | 4 + .../getting_started/example1_minimization.py | 33 +++--- .../example2_global_optimization.py | 10 +- pele/accept_tests/__init__.py | 1 + pele/angleaxis/__init__.py | 1 + pele/angleaxis/aamindist.py | 6 +- pele/angleaxis/rigidbody.py | 4 +- pele/basinhopping.py | 2 +- pele/gui/__init__.py | 2 +- pele/landscape/__init__.py | 1 + pele/mc.py | 2 +- pele/mindist/minpermdist_stochastic.py | 22 ++-- pele/mindist/permutational_alignment.py | 2 +- pele/mindist/rmsfit.py | 2 +- pele/optimize/__init__.py | 2 +- pele/optimize/_fire.py | 7 -- pele/optimize/_quench.py | 2 +- pele/potentials/__init__.py | 4 +- pele/potentials/potential.py | 4 +- pele/rates/__init__.py | 3 +- pele/storage/database.py | 2 +- pele/systems/basesystem.py | 11 +- pele/thermodynamics/__init__.py | 1 + pele/transition_states/__init__.py | 3 +- pele/transition_states/_find_lowest_eig.py | 3 +- pele/transition_states/_nebdriver.py | 4 +- .../_transition_state_refinement.py | 2 +- pele/utils/__init__.py | 8 +- pele/utils/disconnectivity_graph.py | 4 +- pele/utils/neighbor_list.py | 4 +- pele/utils/rbtools.py | 3 +- pyproject.toml | 1 + source/pele/cvode.hpp | 4 +- source/pele/hs_wca.hpp | 20 ++-- source/pele/rosenbrock.hpp | 7 +- 50 files changed, 286 insertions(+), 140 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 doc/cpp_reference.rst diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..765c5b866 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,111 @@ +name: Documentation + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build documentation + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + pages: read + defaults: + run: + shell: bash -el {0} + env: + OMP_NUM_THREADS: "1" + OPENBLAS_NUM_THREADS: "1" + MPLBACKEND: Agg + QT_QPA_PLATFORM: offscreen + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: conda-incubator/setup-miniconda@v3 + with: + miniforge-version: latest + activate-environment: "" + + - name: Install native dependencies + run: | + conda create -y -n pele-docs --override-channels -c conda-forge \ + python=3.12 pip sundials eigen blas-devel pkg-config + sudo apt-get update + sudo apt-get install -y gcc g++ gfortran doxygen rsync libglu1-mesa freeglut3-dev + + - name: Install current package and documentation tools + run: | + conda activate pele-docs + python -m pip install '.[docs,gui]' \ + -Csetup-args=-Dnative=false -Csetup-args=-Dgui=enabled \ + -Csetup-args=-Dlammps=disabled -Ccompile-args=-j2 + + - name: Build Python and C++ documentation + run: | + conda activate pele-docs + make -C doc html SPHINXOPTS='-W --keep-going -E -a' + + - name: Check generated documentation + run: | + python - <<'PYTHON' + from pathlib import Path + + output = Path("doc/_build/html") + required = [ + "index.html", + "cpp_reference.html", + "generated/pele.basinhopping.BasinHopping.html", + "generated/pele.potentials.LJ.html", + "generated/pele.storage.Database.html", + "doxygen/namespacepele.html", + "doxygen/annotated.html", + "doxygen/classpele_1_1CVODEBDFOptimizer.html", + "doxygen/classpele_1_1GenericMixedDescent.html", + ] + missing = [path for path in required if not (output / path).is_file()] + assert not missing, f"Missing generated documentation: {missing}" + PYTHON + + - uses: actions/upload-artifact@v4 + with: + name: pele-documentation + path: doc/_build/html + if-no-files-found: error + + - uses: actions/configure-pages@v5 + if: github.repository == 'pele-python/pele' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + + - uses: actions/upload-pages-artifact@v3 + if: github.repository == 'pele-python/pele' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + with: + path: doc/_build/html + + deploy: + name: Publish documentation + if: github.repository == 'pele-python/pele' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-24.04 + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/deploy-pages@v4 + id: deployment diff --git a/.gitignore b/.gitignore index 31a55436d..2630fa868 100644 --- a/.gitignore +++ b/.gitignore @@ -109,4 +109,7 @@ bin/act **/*.sqlite !pele/**/tests/*.sqlite -**/cython_debug/ \ No newline at end of file +**/cython_debug/ +/doc/_build/ +/doc/generated/ +/doc/doxygen/_build/ diff --git a/README.rst b/README.rst index aaf5f875c..05b0935aa 100644 --- a/README.rst +++ b/README.rst @@ -132,6 +132,11 @@ installed package. If a build fails, remove the build directory before trying ag $ rm -rf build +Documentation +============= + +docs are at https://pele-python.github.io/pele/. + Tests ===== diff --git a/doc/Makefile b/doc/Makefile index a83154a21..807497539 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -50,7 +50,7 @@ doxygen_html: cd doxygen && doxygen combine_sphinx_doxygen: sphinx_html doxygen_html - rsync -av doxygen/_build/html/ _build/html/doxygen/ + rsync -av doxygen/_build/html/ $(BUILDDIR)/html/doxygen/ html: combine_sphinx_doxygen diff --git a/doc/_static/scipy.css b/doc/_static/scipy.css index 70b0e6b53..f2f53d97e 100644 --- a/doc/_static/scipy.css +++ b/doc/_static/scipy.css @@ -1,4 +1,4 @@ -@import "default.css"; +@import "classic.css"; /** * Spacing fixes diff --git a/doc/_templates/autosummary/class.rst b/doc/_templates/autosummary/class.rst index 5ffea3bd7..822b737e6 100644 --- a/doc/_templates/autosummary/class.rst +++ b/doc/_templates/autosummary/class.rst @@ -2,12 +2,13 @@ {% block methods %} {% if methods %} - .. HACK - .. autosummary:: - :toctree: + .. rubric:: Methods + + .. autosummary:: + :toctree: {% for item in all_methods %} {%- if not item.startswith('_') or item in ['__call__'] %} - ~{{ name }}.{{ item }} + ~{{ objname }}.{{ item }} {%- endif -%} {%- endfor %} {% endif %} @@ -15,12 +16,13 @@ {% block attributes %} {% if attributes %} - .. HACK - .. autosummary:: - :toctree: + .. rubric:: Attributes + + .. autosummary:: + :toctree: {% for item in all_attributes %} {%- if not item.startswith('_') %} - ~{{ name }}.{{ item }} + ~{{ objname }}.{{ item }} {%- endif -%} {%- endfor %} {% endif %} diff --git a/doc/conf.py b/doc/conf.py index 979927c57..82775ca69 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -11,6 +11,8 @@ # serve to show the default. import sys, os +from pathlib import Path +import tomllib import pele # If extensions (or modules to document with autodoc) are in another directory, @@ -31,7 +33,7 @@ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.coverage", - "sphinx.ext.pngmath", + "sphinx.ext.mathjax", #'sphinx.ext.viewcode-new', #'viewcode-new', "sphinx.ext.autosummary", @@ -54,14 +56,11 @@ project = "pele" copyright = "2017, Johannes Klicpera, Victor Rühle, Jacob Stevenson" -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = "0.2" -# The full version, including alpha/beta/rc tags. -release = "0.2" +# Read the documentation version from the package metadata. +release = tomllib.loads( + (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text() +)["project"]["version"] +version = release # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -103,7 +102,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_style = "scipy.css" -html_theme = "default" +html_theme = "classic" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -240,6 +239,8 @@ ] autosummary_generate = True +autodoc_mock_imports = ["pymol"] +numpydoc_show_class_members = False # ----------------------------------------------------------------------------- # Source code links # ----------------------------------------------------------------------------- @@ -298,16 +299,7 @@ def linkcode_resolve(domain, info): fn = relpath(fn, start=dirname(pele.__file__)) - # pele doesn't really have versions, so just return the master branch - return "http://github.com/martiniani-lab/pele/blob/master/pele/{}{}".format( + return "https://github.com/pele-python/pele/blob/main/pele/{}{}".format( fn, linespec, ) - - -# if False:# or 'dev' in pele.version: -# return "http://github.com/martiniani-lab/pele/blob/master/numpy/%s%s" % ( -# fn, linespec) -# else: -# return "http://github.com/martiniani-lab/pele/blob/v%s/numpy/%s%s" % ( -# pele.version, fn, linespec) diff --git a/doc/cpp_reference.rst b/doc/cpp_reference.rst new file mode 100644 index 000000000..4afce95a7 --- /dev/null +++ b/doc/cpp_reference.rst @@ -0,0 +1,11 @@ +C++ reference +============= + +The C++ API reference is generated from pele's source files with Doxygen. + +* `Class index `_ +* `pele namespace and functions `_ + +Run ``make -C doc html`` to build the Python and C++ documentation together. +The combined site is written to ``doc/_build/html/``, with the C++ reference +under ``doxygen/``. Its navigation links back to the Python documentation. diff --git a/doc/doxygen/Doxyfile b/doc/doxygen/Doxyfile index 646ffe49f..cd1bb58ed 100644 --- a/doc/doxygen/Doxyfile +++ b/doc/doxygen/Doxyfile @@ -200,7 +200,6 @@ ALIASES = # "class=itcl::class" will allow you to use the command class in the # itcl::class meaning. -TCL_SUBST = # Set the OPTIMIZE_OUTPUT_FOR_C tag to YES if your project consists of C # sources only. Doxygen will then generate output that is more tailored for C. @@ -324,7 +323,6 @@ TYPEDEF_HIDES_STRUCT = NO # 2^(16+SYMBOL_CACHE_SIZE). The valid range is 0..9, the default is 0, # corresponding to a cache size of 2^16 = 65536 symbols. -SYMBOL_CACHE_SIZE = 0 # Similar to the SYMBOL_CACHE_SIZE the size of the symbol lookup cache can be # set using LOOKUP_CACHE_SIZE. This cache is used to resolve symbols given @@ -544,7 +542,6 @@ SHOW_USED_FILES = YES # then setting the SHOW_DIRECTORIES tag to YES will show the directory hierarchy # in the documentation. The default is NO. -SHOW_DIRECTORIES = NO # Set the SHOW_FILES tag to NO to disable the generation of the Files page. # This will remove the Files entry from the Quick Index and from the @@ -836,7 +833,6 @@ ALPHABETICAL_INDEX = YES # the COLS_IN_ALPHA_INDEX tag can be used to specify the number of columns # in which this list will be split (can be a number in the range [1..20]) -COLS_IN_ALPHA_INDEX = 5 # In case all classes in a project start with a common prefix, all # classes will be put under the same header in the alphabetical index. @@ -932,13 +928,11 @@ HTML_COLORSTYLE_GAMMA = 80 # page will contain the date and time when the page was generated. Setting # this to NO can help when comparing the output of multiple runs. -HTML_TIMESTAMP = YES # If the HTML_ALIGN_MEMBERS tag is set to YES, the members of classes, # files or namespaces will be aligned in HTML using tables. If set to # NO a bullet list will be used. -HTML_ALIGN_MEMBERS = YES # If the HTML_DYNAMIC_SECTIONS tag is set to YES then the generated HTML # documentation will contain sections that can be hidden and shown after the @@ -1129,7 +1123,6 @@ ENUM_VALUES_PER_LINE = 4 # By enabling USE_INLINE_TREES, doxygen will generate the Groups, Directories, # and Class Hierarchy pages using a tree view instead of an ordered list. -USE_INLINE_TREES = NO # If the treeview is enabled (see GENERATE_TREEVIEW) then this tag can be # used to set the initial width (in pixels) of the frame in which the tree @@ -1156,7 +1149,6 @@ FORMULA_FONTSIZE = 10 # Note that when changing this option you need to delete any form_*.png files # in the HTML output before the changes have effect. -FORMULA_TRANSPARENT = YES # Enable the USE_MATHJAX option to render LaTeX formulas using MathJax # (see http://www.mathjax.org) which uses client side Javascript for the @@ -1176,7 +1168,7 @@ USE_MATHJAX = YES # MathJax, but it is strongly recommended to install a local copy of MathJax # before deployment. -MATHJAX_RELPATH = http://www.mathjax.org/mathjax +MATHJAX_RELPATH = https://cdn.jsdelivr.net/npm/mathjax@2 # The MATHJAX_EXTENSIONS tag can be used to specify one or MathJax extension # names that should be enabled during MathJax rendering. @@ -1294,7 +1286,6 @@ LATEX_HIDE_INDICES = NO # Note that which sources are shown also depends on other settings # such as SOURCE_BROWSER. -LATEX_SOURCE_CODE = NO # The LATEX_BIB_STYLE tag can be used to specify the style to use for the # bibliography, e.g. plainnat, or ieeetr. The default style is "plain". See @@ -1392,13 +1383,11 @@ XML_OUTPUT = xml # which can be used by a validating XML parser to check the # syntax of the XML files. -XML_SCHEMA = # The XML_DTD tag can be used to specify an XML DTD, # which can be used by a validating XML parser to check the # syntax of the XML files. -XML_DTD = # If the XML_PROGRAMLISTING tag is set to YES Doxygen will # dump the program listings (including syntax highlighting @@ -1563,7 +1552,6 @@ EXTERNAL_GROUPS = YES # The PERL_PATH should be the absolute path and name of the perl script # interpreter (i.e. the result of `which perl'). -PERL_PATH = /usr/bin/perl #--------------------------------------------------------------------------- # Configuration options related to the dot tool @@ -1575,7 +1563,6 @@ PERL_PATH = /usr/bin/perl # this option also works with HAVE_DOT disabled, but it is recommended to # install and use dot, since it yields more powerful graphs. -CLASS_DIAGRAMS = YES # You can define message sequence charts within doxygen comments using the \msc # command. Doxygen will then run the mscgen tool (see @@ -1584,7 +1571,6 @@ CLASS_DIAGRAMS = YES # the mscgen tool resides. If left empty the tool is assumed to be found in the # default search path. -MSCGEN_PATH = # If set to YES, the inheritance and collaboration graphs will hide # inheritance and usage relations if the target is undocumented @@ -1614,12 +1600,10 @@ DOT_NUM_THREADS = 0 # the DOTFONTPATH environment variable or by setting DOT_FONTPATH to the # directory containing the font. -DOT_FONTNAME = Helvetica # The DOT_FONTSIZE tag can be used to set the size of the font of dot graphs. # The default size is 10pt. -DOT_FONTSIZE = 10 # By default doxygen will tell dot to use the Helvetica font. # If you specify a different font using DOT_FONTNAME you can use DOT_FONTPATH to @@ -1759,7 +1743,6 @@ MAX_DOT_GRAPH_DEPTH = 0 # enabling this option may lead to badly anti-aliased labels on the edges of # a graph (i.e. they become hard to read). -DOT_TRANSPARENT = NO # Set the DOT_MULTI_TARGETS tag to YES allow dot to generate multiple output # files in one run (i.e. multiple -o and -T options on the command line). This diff --git a/doc/doxygen/DoxygenLayout.xml b/doc/doxygen/DoxygenLayout.xml index 96b548b31..391dba7a6 100644 --- a/doc/doxygen/DoxygenLayout.xml +++ b/doc/doxygen/DoxygenLayout.xml @@ -2,7 +2,7 @@ - + @@ -19,7 +19,6 @@ - diff --git a/doc/global_optimization.rst b/doc/global_optimization.rst index 2f4dd6469..4158fde81 100644 --- a/doc/global_optimization.rst +++ b/doc/global_optimization.rst @@ -15,7 +15,9 @@ the behaviour of the basin hopping procedure. BasinHopping -:: +For example: + +.. code-block:: python import numpy as np import pele.potentials.lj as lj @@ -34,9 +36,11 @@ the behaviour of the basin hopping procedure. opt.run(100) -.. automodule:: pele.takestep - +See :mod:`pele.takestep` for the available step-taking classes. +.. toctree:: + :maxdepth: 1 + monte_carlo diff --git a/doc/index.rst b/doc/index.rst index 32a6e62d0..3fec84d6e 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -10,7 +10,7 @@ Tools for global optimization and energy landscape exploration. Source code: https://github.com/pele-python/pele -Documentation: http://pele-python.github.io/pele/ +Documentation: https://pele-python.github.io/pele/ pele is a package of tools for calculations @@ -94,7 +94,11 @@ Modules C++ documentation +++++++++++++++++ -`C++ Class Index <./doxygen/namespacepele.html>`_ + +.. toctree:: + :maxdepth: 1 + + cpp_reference Indices and tables ================== @@ -102,4 +106,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` * :ref:`search` - diff --git a/doc/local_connect.rst b/doc/local_connect.rst index 9d672aed3..fc3ab8914 100644 --- a/doc/local_connect.rst +++ b/doc/local_connect.rst @@ -1,4 +1,5 @@ .. _local_connect_description: + Local Connect Algorithm ======================= @@ -7,7 +8,6 @@ Local Connect Algorithm NEB transition_state_refinement - transition_state_refinenement Local connect has three steps. diff --git a/doc/monte_carlo.rst b/doc/monte_carlo.rst index e5f73e89f..378814d2e 100644 --- a/doc/monte_carlo.rst +++ b/doc/monte_carlo.rst @@ -1,5 +1,7 @@ .. _monte_carlo: + .. currentmodule:: pele.mc + Monte Carlo (`pele.mc`) =========================== diff --git a/doc/system_class.rst b/doc/system_class.rst index dd2c44245..38d96fc9c 100644 --- a/doc/system_class.rst +++ b/doc/system_class.rst @@ -2,3 +2,7 @@ .. automodule:: pele.systems +.. toctree:: + :maxdepth: 1 + + optim2params diff --git a/doc/transition_state_refinement.rst b/doc/transition_state_refinement.rst index 8bf7c6b86..ccd547425 100644 --- a/doc/transition_state_refinement.rst +++ b/doc/transition_state_refinement.rst @@ -30,4 +30,8 @@ Hessian is known this can be very slow. A :ref:`better method ` for more complete documentation +.. toctree:: + :maxdepth: 1 + + find_lowest_eigenvector diff --git a/examples/getting_started/example1_minimization.py b/examples/getting_started/example1_minimization.py index d7f4555ce..56710dc6b 100644 --- a/examples/getting_started/example1_minimization.py +++ b/examples/getting_started/example1_minimization.py @@ -8,7 +8,9 @@ As a very simple example, let's generate a random Lennard-Jones configuration and minimize it. + :: + import numpy as np from pele.potentials import LJ from pele.optimize import lbfgs_py @@ -17,7 +19,7 @@ x = np.random.uniform(-2, 2, natoms*3) pot = LJ() result = lbfgs_py(x, pot) - print result + print(result) In the above we used three imports. We used the `numpy` library to construct a random one dimensional array. We used the Lennard-Jones potential :class:`.LJ`, and we used the minimization @@ -27,26 +29,27 @@ the final energy, final coordinates, the number of function calls, etc. If we want to then save the minimized coordinates in an xyz file we can use the function :func:`.write_xyz` + :: + from pele.utils.xyz import write_xyz with open("out.xyz", "w") as fout: title = "energy = " + str(result.energy) write_xyz(fout, result.coords, title=title) """ -import numpy as np -from pele.potentials import LJ -from pele.optimize import lbfgs_py - -natoms = 5 -x = np.random.uniform(-2, 2, natoms * 3) -pot = LJ() -result = lbfgs_py(x, pot) -print(result) - +if __name__ == "__main__": + import numpy as np + from pele.potentials import LJ + from pele.optimize import lbfgs_py + from pele.utils.xyz import write_xyz -from pele.utils.xyz import write_xyz + natoms = 5 + x = np.random.uniform(-2, 2, natoms * 3) + pot = LJ() + result = lbfgs_py(x, pot) + print(result) -with open("out.xyz", "w") as fout: - title = "energy = " + str(result.energy) - write_xyz(fout, result.coords, title=title) + with open("out.xyz", "w") as fout: + title = "energy = " + str(result.energy) + write_xyz(fout, result.coords, title=title) diff --git a/examples/getting_started/example2_global_optimization.py b/examples/getting_started/example2_global_optimization.py index 8ef816e6f..72595df5d 100644 --- a/examples/getting_started/example2_global_optimization.py +++ b/examples/getting_started/example2_global_optimization.py @@ -22,17 +22,21 @@ This example involves quite a number of steps. Normally these are done automatically in functions defined in the system class (:mod:`.systems`). With the system class the following can be done in just a few steps:: + from pele.systems import LJCluster natoms = 17 system = LJCluster(natoms) database = system.create_database('lj17.sqlite') bh = system.get_basinhopping(database) bh.run(10) + The following example shows how to do this manually We start by defining the potential and choosing a random set of starting coordinates + :: + import numpy as np from pele.potentials import LJ natoms = 17 @@ -43,14 +47,18 @@ displacements of the coordinates (:class:`.RandomDisplacement`) and wrap it with the class :class:`.AdaptiveStepsizeTemperature` that adjusts both the stepsize and temperature to achieve optimum results. + :: + from pele.takestep import RandomDisplacement, AdaptiveStepsizeTemperature displace = RandomDisplacement() adaptive_displacement = AdaptiveStepsizeTemperature(displace) The last step is to set up an sqlite database (:mod:`.storage`) which we use to store the minima that we find during the basinhopping run. + :: + from pele.storage import Database database = Database("lj17.sqlite") @@ -59,7 +67,7 @@ The minima in the database can be accessed simply by:: for m in database.minima(): - print m.energy + print(m.energy) You can access other attributes of the :class:`.Minimum` as `minimum.coords` in the same way. diff --git a/pele/accept_tests/__init__.py b/pele/accept_tests/__init__.py index c525ad011..989176ff2 100644 --- a/pele/accept_tests/__init__.py +++ b/pele/accept_tests/__init__.py @@ -1,5 +1,6 @@ """ .. currentmodule:: pele.accept_tests + Acceptance Tests (`pele.accept_tests`) ----------------------------------------- This module contains the acceptance tests for MonteCarlo and BasinHopping diff --git a/pele/angleaxis/__init__.py b/pele/angleaxis/__init__.py index 68189f858..f3ec0fe7a 100644 --- a/pele/angleaxis/__init__.py +++ b/pele/angleaxis/__init__.py @@ -1,5 +1,6 @@ """ .. currentmodule:: pele.angleaxis + Angle Axis Systems (`pele.angleaxis`) ============================================ diff --git a/pele/angleaxis/aamindist.py b/pele/angleaxis/aamindist.py index 2f18dede9..23e4a0807 100644 --- a/pele/angleaxis/aamindist.py +++ b/pele/angleaxis/aamindist.py @@ -76,13 +76,13 @@ def invert(self, X): def permute(self, X, perm): """apply a permutation - Paramters - --------- + Parameters + ---------- X : np array The configuration perm : list of integers a list of integers giving the new order of the molecules. The length - of perm must be the number of rigid bodies. + of perm must be the number of rigid bodies. """ Xnew = X.copy() ca = self.topology.coords_adapter(X) diff --git a/pele/angleaxis/rigidbody.py b/pele/angleaxis/rigidbody.py index cd6226941..5086352dd 100644 --- a/pele/angleaxis/rigidbody.py +++ b/pele/angleaxis/rigidbody.py @@ -46,8 +46,8 @@ class RigidFragment(aatopology.AASiteType): We have tried to logically separate the concept of a rigid body (a rigid collection atoms) from an angle axis site, which might describe a single atom with a dipolar interaction. - Usage - ----- + **Usage** + Add the atoms to the rigid fragment one at a time. When they are all added call self.finalize_setup(). The relevant properties of the rigid fragment, including center of geometry, tensor of gyration, and internal symmetries will be computed diff --git a/pele/basinhopping.py b/pele/basinhopping.py index a1d2c7f33..1384091fc 100644 --- a/pele/basinhopping.py +++ b/pele/basinhopping.py @@ -31,7 +31,7 @@ class BasinHopping(MonteCarlo): value The acceptance test used here is the Metropolis criterion of standard Monte - Carlo algorithms, although there are many other possibilities [3]_. + Carlo algorithms, although there are many other possibilities. This global minimization method has been shown to be extremely efficient for a wide variety of problems in physics and chemistry. It is diff --git a/pele/gui/__init__.py b/pele/gui/__init__.py index ef2602a04..ba7856d0a 100644 --- a/pele/gui/__init__.py +++ b/pele/gui/__init__.py @@ -1,5 +1,5 @@ """ -.. currentmodu:: pele.gui +.. currentmodule:: pele.gui pele GUI (`pele.gui`) -------------------------- diff --git a/pele/landscape/__init__.py b/pele/landscape/__init__.py index 5875e4a89..b4313d5b8 100644 --- a/pele/landscape/__init__.py +++ b/pele/landscape/__init__.py @@ -1,5 +1,6 @@ """ .. currentmodule:: pele.landscape + Landscape Exploration (`pele.landscape`) ============================================ diff --git a/pele/mc.py b/pele/mc.py index 2dfbaff15..8507fe8f9 100644 --- a/pele/mc.py +++ b/pele/mc.py @@ -120,7 +120,7 @@ def setPrinting(self, ostream="default", frq=None): """change how the printing is done Parameters - ----------- + ---------- ostream : open file or None where to print data frq : int or None diff --git a/pele/mindist/minpermdist_stochastic.py b/pele/mindist/minpermdist_stochastic.py index 204247ad6..b7ca8b067 100644 --- a/pele/mindist/minpermdist_stochastic.py +++ b/pele/mindist/minpermdist_stochastic.py @@ -31,7 +31,7 @@ class MinPermDistCluster: Notes ----- - The following symmetries will be accounted for:: + The following symmetries will be accounted for: 1. Translational symmetry #. Global rotational symmetry @@ -39,18 +39,18 @@ class MinPermDistCluster: #. Point inversion symmetry - The algorithm here to find the best distance is + The algorithm here to find the best distance is:: - for rotation in standardalignments: - optimize permutation - optimize rotation - check_match + for rotation in standardalignments: + optimize permutation + optimize rotation + check_match - for i in range(niter): - random_rotation - optimize permutations - align rotation - check_match + for i in range(niter): + random_rotation + optimize permutations + align rotation + check_match The minpermdist algorithm is generic and can act on various types of coordinates, e.g. carthesian, angle axis, .... The transform and measure diff --git a/pele/mindist/permutational_alignment.py b/pele/mindist/permutational_alignment.py index e39ef31d3..4bb7d7739 100644 --- a/pele/mindist/permutational_alignment.py +++ b/pele/mindist/permutational_alignment.py @@ -157,7 +157,7 @@ def find_best_permutation( **kwargs ): """ - find the permutation of the atoms which minimizes the distance |X1-X2| + find the permutation of the atoms which minimizes the distance ``|X1-X2|`` With all the default parameters, findBestPermutation assumes that X1, X2 are arrays of atoms in 3d space and performs reshaping on the coordinates. However, diff --git a/pele/mindist/rmsfit.py b/pele/mindist/rmsfit.py index 9a40154ee..d9449e092 100644 --- a/pele/mindist/rmsfit.py +++ b/pele/mindist/rmsfit.py @@ -47,7 +47,7 @@ def findrotation_kearsley(x1, x2, align_com=True): Return the matrix which aligns structure XB to be as similar as possible to structure XA. - To be precise, rotate XB, so as to minimize the distance |XA - XB|. + To be precise, rotate XB, so as to minimize the distance ``|XA - XB|``. Rotations will be done around the origin, not the center of mass diff --git a/pele/optimize/__init__.py b/pele/optimize/__init__.py index a57a102d3..6193d3352 100644 --- a/pele/optimize/__init__.py +++ b/pele/optimize/__init__.py @@ -37,7 +37,7 @@ Result We have tried to make the minimizers as consistent as possible, but it is not always possible. -Some of the common parameters most of them accept are:: +Some of the common parameters most of them accept are: 1. nsteps : number of iterations #. tol : tolerance criterion for the rms gradient diff --git a/pele/optimize/_fire.py b/pele/optimize/_fire.py index 1d138204d..d3edd1517 100644 --- a/pele/optimize/_fire.py +++ b/pele/optimize/_fire.py @@ -18,13 +18,6 @@ class Fire: """ The FIRE optimization algorithm - Parameters - ---------- - coords : array - the starting configuration - potential : - the potential - Parameters ---------- coords : array diff --git a/pele/optimize/_quench.py b/pele/optimize/_quench.py index e11241829..bc040ad4d 100644 --- a/pele/optimize/_quench.py +++ b/pele/optimize/_quench.py @@ -42,7 +42,7 @@ def lbfgs_scipy(coords, pot, iprint=-1, tol=1e-3, nsteps=15000): """ a wrapper function for lbfgs routine in scipy - .. warn:: + .. warning:: the scipy version of lbfgs uses linesearch based only on energy which can make the minimization stop early. When the step size is so small that the energy doesn't change to within machine precision (times the diff --git a/pele/potentials/__init__.py b/pele/potentials/__init__.py index 7b6a4b009..4ef4e4b3a 100644 --- a/pele/potentials/__init__.py +++ b/pele/potentials/__init__.py @@ -34,12 +34,10 @@ LJ LJCut LJpshift - ATLJ + ~ATLJ.ATLJ XYModel HeisenbergModel HeisenbergModelRA - MaxNeibsLJ - MaxNeibsBLJ GMIN potentials --------------- diff --git a/pele/potentials/potential.py b/pele/potentials/potential.py index 2d5e70589..366130125 100644 --- a/pele/potentials/potential.py +++ b/pele/potentials/potential.py @@ -127,7 +127,7 @@ def getEnergyList(self, coords, ilist): Parameters ---------- coords : array - ilist : array, shape (*,2) + ilist : array, shape ``(*,2)`` a numpy array containing pairs of atoms (ilist[i,:]). """ return NotImplementedError @@ -138,7 +138,7 @@ def getEnergyGradientList(self, coords, ilist): Parameters ---------- coords : array - ilist : array, shape (*,2) + ilist : array, shape ``(*,2)`` a numpy array containing pairs of atoms (ilist[i,:]). """ return NotImplementedError diff --git a/pele/rates/__init__.py b/pele/rates/__init__.py index d4f6d57a4..c83807162 100644 --- a/pele/rates/__init__.py +++ b/pele/rates/__init__.py @@ -1,5 +1,6 @@ """ .. currentmodule:: pele.rates + Rates (`pele.rates`) ==================== @@ -106,7 +107,7 @@ .. autosummary:: :toctree: generated/ - GraphReduction + _rate_calculations.GraphReduction """ from ._rates import * diff --git a/pele/storage/database.py b/pele/storage/database.py index 4c72b683c..5ebdb140a 100644 --- a/pele/storage/database.py +++ b/pele/storage/database.py @@ -725,7 +725,7 @@ def getTransitionStatesMinimum(self, min1): return candidates.all() def getTransitionStateFromID(self, id_): - """return the transition state with id id_""" + """return the transition state with id ``id_``""" return self.session.query(TransitionState).get(id_) def minima(self, order_energy=True): diff --git a/pele/systems/basesystem.py b/pele/systems/basesystem.py index 828af7ef1..4bc63f79d 100644 --- a/pele/systems/basesystem.py +++ b/pele/systems/basesystem.py @@ -75,29 +75,30 @@ class BaseSystem: The base class for a System object Notes - ------------- + ----- The following functions need to be overloaded for running the given routines - Global Optimization:: + Global Optimization: 1. get_potential : required #. get_takestep : optional #. get_random_configuration : optional #. get_compare_exact : optional - Connecting Minima and Transition State Searches:: + Connecting Minima and Transition State Searches: 1. get_potential : required #. get_mindist : required #. get_orthogonalize_to_zero_eigenvectors : required #. get_compare_exact : optional, recommended - thermodynamics:: + thermodynamics: + 1. get_metric_tensor - GUI:: + GUI: 1. all of the above functions are required, plus #. draw : required diff --git a/pele/thermodynamics/__init__.py b/pele/thermodynamics/__init__.py index 038c4a8b5..f94a7eb25 100644 --- a/pele/thermodynamics/__init__.py +++ b/pele/thermodynamics/__init__.py @@ -1,5 +1,6 @@ """ .. currentmodule:: pele.thermodynamics + Thermodynamics (`pele.thermodynamics`) ========================================= diff --git a/pele/transition_states/__init__.py b/pele/transition_states/__init__.py index b5662f50c..72a0097ab 100644 --- a/pele/transition_states/__init__.py +++ b/pele/transition_states/__init__.py @@ -1,5 +1,6 @@ """ .. currentmodule:: pele.transition_states + Transition States (`pele.transition_states`) ================================================ This module contains functions and classes related to local transition state searches. @@ -26,7 +27,7 @@ findLowestEigenVector (Doubly-) Nudged Elastic Band -+++++++++++++++++++ ++++++++++++++++++++++++++++++ The following functions implement the Nudged Elastic Band and Doubly-Nudged Elastic Band method. The user should interact via the driver class: diff --git a/pele/transition_states/_find_lowest_eig.py b/pele/transition_states/_find_lowest_eig.py index 392afec0e..72c73586b 100644 --- a/pele/transition_states/_find_lowest_eig.py +++ b/pele/transition_states/_find_lowest_eig.py @@ -313,11 +313,12 @@ def findLowestEigenVector( the initial guess for the diagonal component of the inverse Hessian orthogZeroEigs : callable this function makes a vector orthogonal to the known zero - eigenvectors + eigenvectors:: orthogZeroEigs=0 : default behavior, assume translational and rotational symmetry orthogZeroEigs=None : the vector is unchanged + first_order : bool use the first order forward finite differences approximation for the curvature rather than the second order central differences diff --git a/pele/transition_states/_nebdriver.py b/pele/transition_states/_nebdriver.py index 4d4ffca23..e61196827 100644 --- a/pele/transition_states/_nebdriver.py +++ b/pele/transition_states/_nebdriver.py @@ -18,7 +18,7 @@ class NEBDriver: up the initial interpolation and optimizing the band. Parameters - ----------- + ---------- potential : the potential object coords1, coords2 : array @@ -61,7 +61,7 @@ class NEBDriver: additional options are passed to the NEB class See Also - --------- + -------- NEB InterpolatedPath diff --git a/pele/transition_states/_transition_state_refinement.py b/pele/transition_states/_transition_state_refinement.py index 3d19f1783..55b0ca663 100644 --- a/pele/transition_states/_transition_state_refinement.py +++ b/pele/transition_states/_transition_state_refinement.py @@ -42,7 +42,7 @@ class FindTransitionState: how much debugging information to print (only partially implemented) orthogZeroEigs : callable this function makes a vector orthogonal to the known zero - eigenvectors + eigenvectors:: orthogZeroEigs=0 : default behavior, assume translational and rotational symmetry diff --git a/pele/utils/__init__.py b/pele/utils/__init__.py index 8461296f0..0762bdb0d 100644 --- a/pele/utils/__init__.py +++ b/pele/utils/__init__.py @@ -7,12 +7,14 @@ rotations --------- .. automodule:: pele.utils.rotations - + +.. currentmodule:: pele.utils + .. autosummary:: :toctree: generated/ - pele.utils.vec3.invert3x3 + vec3.invert3x3 benchmarking tool @@ -20,7 +22,7 @@ .. autosummary:: :toctree: generated/ - pele.utils.benchmark.QuenchBenchmark + benchmark.QuenchBenchmark Coords Adapter -------------- diff --git a/pele/utils/disconnectivity_graph.py b/pele/utils/disconnectivity_graph.py index b4e7fa011..89ca0a869 100644 --- a/pele/utils/disconnectivity_graph.py +++ b/pele/utils/disconnectivity_graph.py @@ -605,7 +605,7 @@ class DisconnectivityGraph: smallest value in the group. See Also - --------- + -------- make_disconnectivity_graph.py : a script (in pele/scripts) to make the disconnectivity graph from the command line pele.storage.Database : @@ -1184,6 +1184,8 @@ def color_by_value( def draw_minima(self, minima, axes=None, **kwargs): """draw a specified list of minima as points + Parameters + ---------- minima : list of minima axes : matlplotlib axes Draw them on these axes. If None, use self.axes. diff --git a/pele/utils/neighbor_list.py b/pele/utils/neighbor_list.py index 1f68de8f6..19886116a 100644 --- a/pele/utils/neighbor_list.py +++ b/pele/utils/neighbor_list.py @@ -449,8 +449,8 @@ class NeighborListPotentialMulti(basepot): This will wrap multiple instances of NeighborListPotentialBuild. This class will the check the coords and control when the neighbor lists are rebuilt. - Parameters: - ----------- + Parameters + ---------- potentials : a list of neighbor list potential objects. Each potential must have attribute diff --git a/pele/utils/rbtools.py b/pele/utils/rbtools.py index b3acd8b06..872bd945b 100644 --- a/pele/utils/rbtools.py +++ b/pele/utils/rbtools.py @@ -1,9 +1,10 @@ """ .. currentmodule:: pele.utils.rbtools + .. autosummary:: :toctree: generated/ - pele.utils.rbtools.CoordsAdapter + CoordsAdapter """ diff --git a/pyproject.toml b/pyproject.toml index d2adb13c6..a099caa55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ [project.optional-dependencies] test = ["pytest"] +docs = ["sphinx>=8,<9", "numpydoc>=1.8,<2"] gui = ["pyqt5", "PyOpenGL"] lammps = ["lammps[mpi]"] diff --git a/source/pele/cvode.hpp b/source/pele/cvode.hpp index e19628927..3564f26a7 100644 --- a/source/pele/cvode.hpp +++ b/source/pele/cvode.hpp @@ -85,8 +85,8 @@ inline void reset_user_data(UserData udata) { } /** - * Not exactly an optimizer but solves for the differential equation $ dx/dt = - - * \grad{V(x)} $ to arrive at the trajectory to the corresponding minimum + * Not exactly an optimizer but solves for the differential equation \f$ dx/dt = - + * \nabla{V(x)} \f$ to arrive at the trajectory to the corresponding minimum */ class CVODEBDFOptimizer : public ODEBasedOptimizer { private: diff --git a/source/pele/hs_wca.hpp b/source/pele/hs_wca.hpp index 7b9171199..56e645800 100644 --- a/source/pele/hs_wca.hpp +++ b/source/pele/hs_wca.hpp @@ -19,15 +19,21 @@ namespace pele { * extrapolates linearly "into the hard core". * That should be useful to minimize HS-WCA-like systems. * The pair potential is: - * V_\text{sfHS-WCA}(r^2) = 0 \text{ if } r \geq r_S V_\text{sfHS-WCA}(r^2) = - * V_\text{fHS-WCA}(r^2) \text{ if } r_\times < r < - * r_S V_\text{sfHS-WCA}(r^2) = E_\times - (\sqrt{r^2} - r_\times)G_\times - * \text{ if } r \leq r_\times Here: E_\times = V_\text{fHS-WCA}(r_\times) - * G_\times = \text{grad}[V_\text{fHS-WCA}](r_\times) + * \f[ + * \begin{aligned} + * V_\text{sfHS-WCA}(r^2) &= 0 \text{ if } r \geq r_S \\ + * V_\text{sfHS-WCA}(r^2) &= V_\text{fHS-WCA}(r^2) + * \text{ if } r_\times < r < r_S \\ + * V_\text{sfHS-WCA}(r^2) &= E_\times - (\sqrt{r^2} - r_\times)G_\times + * \text{ if } r \leq r_\times + * \end{aligned} + * \f] + * Here: \f$E_\times = V_\text{fHS-WCA}(r_\times)\f$ + * \f$G_\times = \text{grad}[V_\text{fHS-WCA}](r_\times)\f$ * And: * dij : sum of hard radii - * r_S = (1 + \alpha) * dij - * r_\times = dij + \delta + * \f$r_S = (1 + \alpha) * dij\f$ + * \f$r_\times = dij + \delta\f$ * The choice of the delta parameter below is somewhat arbitrary and * could probably be optimised. * Computing the gradient GX at the point where we go from fWCA to diff --git a/source/pele/rosenbrock.hpp b/source/pele/rosenbrock.hpp index 6fdd7c6ee..c02dbe696 100644 --- a/source/pele/rosenbrock.hpp +++ b/source/pele/rosenbrock.hpp @@ -128,9 +128,10 @@ class FlatHarmonic : public BasePotential { * Yields a hypercubic basin with volume period^dim. * We also add a power to the energy to ensure that the hessian isn't diagonal. * The full potential is given by - * $$ - * V(x) = \left(offset + sum_i 1-cos(2\pi x_i/period)\right)^power - * $$ + * \f[ + * V(x) = \left(\mathrm{offset} + \sum_i + * \left[1-\cos(2\pi x_i/\mathrm{period})\right]\right)^{\mathrm{power}} + * \f] * The offset ensures that the hessian is smooth if power goes below 0. */ class PoweredCosineSum : public BasePotential { From 3f9f483c5fa3097d582e0cb8fa4c8451b574b6f7 Mon Sep 17 00:00:00 2001 From: Praharsh Suryadevara Date: Sun, 11 Oct 2026 16:11:11 -0500 Subject: [PATCH 02/14] Finish strict Sphinx and Breathe documentation checks Integrate core C++ interfaces with the complete Doxygen reference, verify combined HTML links and API targets, and add installation navigation. Fix the getting-started storage callback and keep publication disabled until the owner approves the hosting and protection setup. --- .github/workflows/docs.yml | 37 +++------ doc/Makefile | 11 ++- doc/README.md | 75 ++++++++++++++++++ doc/check_html.py | 76 +++++++++++++++++++ doc/conf.py | 58 ++++++++++++++ doc/cpp_reference.rst | 31 ++++++++ doc/getting_started.rst | 8 +- doc/index.rst | 3 +- doc/installation.rst | 72 ++++++++++++++++++ doc/tests/test_check_html.py | 43 +++++++++++ doc/tests/test_doxygen_references.py | 45 +++++++++++ doc/tests/test_getting_started.py | 22 ++++++ doc/tutorial_potential.rst | 6 +- .../example2_global_optimization.py | 16 +++- pele/angleaxis/rigidbody.py | 4 +- pele/systems/basesystem.py | 4 +- pele/utils/disconnectivity_graph.py | 5 +- pele/utils/rbtools.py | 4 +- pyproject.toml | 2 +- 19 files changed, 475 insertions(+), 47 deletions(-) create mode 100644 doc/README.md create mode 100644 doc/check_html.py create mode 100644 doc/installation.rst create mode 100644 doc/tests/test_check_html.py create mode 100644 doc/tests/test_doxygen_references.py create mode 100644 doc/tests/test_getting_started.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 765c5b866..d988425e9 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,7 +2,7 @@ name: Documentation on: push: - branches: [main] + branches: [main, docs-autogeneration] pull_request: branches: [main] workflow_dispatch: @@ -52,51 +52,38 @@ jobs: conda activate pele-docs python -m pip install '.[docs,gui]' \ -Csetup-args=-Dnative=false -Csetup-args=-Dgui=enabled \ - -Csetup-args=-Dlammps=disabled -Ccompile-args=-j2 + -Csetup-args=-Dlammps=disabled -Ccompile-args=-j1 - name: Build Python and C++ documentation run: | conda activate pele-docs - make -C doc html SPHINXOPTS='-W --keep-going -E -a' + make -C doc check SPHINXOPTS='-n -W --keep-going -E -a' - - name: Check generated documentation + - name: Test documentation checker and getting-started example run: | - python - <<'PYTHON' - from pathlib import Path - - output = Path("doc/_build/html") - required = [ - "index.html", - "cpp_reference.html", - "generated/pele.basinhopping.BasinHopping.html", - "generated/pele.potentials.LJ.html", - "generated/pele.storage.Database.html", - "doxygen/namespacepele.html", - "doxygen/annotated.html", - "doxygen/classpele_1_1CVODEBDFOptimizer.html", - "doxygen/classpele_1_1GenericMixedDescent.html", - ] - missing = [path for path in required if not (output / path).is_file()] - assert not missing, f"Missing generated documentation: {missing}" - PYTHON + conda activate pele-docs + python -I -m unittest discover -s doc/tests - uses: actions/upload-artifact@v4 with: name: pele-documentation path: doc/_build/html if-no-files-found: error + retention-days: 14 - uses: actions/configure-pages@v5 - if: github.repository == 'pele-python/pele' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + if: vars.DOCS_PUBLISH_ENABLED == 'true' && github.repository == 'pele-python/pele' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - uses: actions/upload-pages-artifact@v3 - if: github.repository == 'pele-python/pele' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + if: vars.DOCS_PUBLISH_ENABLED == 'true' && github.repository == 'pele-python/pele' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' with: path: doc/_build/html + retention-days: 14 deploy: name: Publish documentation - if: github.repository == 'pele-python/pele' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + # Enable only after the owner approves Pages and environment protection setup. + if: vars.DOCS_PUBLISH_ENABLED == 'true' && github.repository == 'pele-python/pele' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' needs: build runs-on: ubuntu-24.04 permissions: diff --git a/doc/Makefile b/doc/Makefile index 807497539..3a4b32fc9 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -12,11 +12,12 @@ PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest +.PHONY: help clean clean_sphinx clean_doxygen html sphinx_html doxygen_html combine_sphinx_doxygen check dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" + @echo " check to build HTML and check generated APIs and local links" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @@ -53,7 +54,13 @@ combine_sphinx_doxygen: sphinx_html doxygen_html rsync -av doxygen/_build/html/ $(BUILDDIR)/html/doxygen/ html: combine_sphinx_doxygen - + +check: html + python check_html.py $(BUILDDIR)/html + +# Breathe needs fresh Doxygen XML before any Sphinx builder reads the sources. +sphinx_html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest: doxygen_html + dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 000000000..1c5e9c404 --- /dev/null +++ b/doc/README.md @@ -0,0 +1,75 @@ +# Building and reviewing the documentation + +Use a supported Python environment with pele and its native dependencies +installed (see the repository README). The docs build imports the current +package, including compiled extensions; it does not mock pele. Install the docs +and GUI extras and Doxygen. On Ubuntu the OpenGL imports also need GLU and GLUT: + +```sh +sudo apt-get install doxygen rsync libglu1-mesa freeglut3-dev +python -m pip install '.[docs,gui]' \ + -Csetup-args=-Dgui=enabled -Csetup-args=-Dlammps=disabled -Ccompile-args=-j1 +OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MPLBACKEND=Agg QT_QPA_PLATFORM=offscreen \ + make -C doc check SPHINXOPTS='-n -W --keep-going -E -a' +python -I -m unittest discover -s doc/tests +``` + + + +`doc/Makefile` runs Doxygen first using `doc/doxygen/Doxyfile`. It extracts the +C++ sources into `doc/doxygen/_build/xml` and a full HTML reference. Sphinx's +Breathe extension reads that XML and renders the core interfaces in +`cpp_reference.rst`, with Sphinx search, navigation, and C++ reference targets. +The complete Doxygen HTML is copied under `doc/_build/html/doxygen`; its +navigation links back to Sphinx. References to types outside the selected +Breathe classes resolve to that complete reference using Doxygen's XML index. + +For Python, Sphinx autodoc imports the modules and reads their docstrings. +Autosummary follows the explicit catalogs in the `.rst` pages and package +docstrings, using `doc/_templates/autosummary` to generate class, method, and +attribute pages under `doc/generated`. New exports need an autosummary entry +to receive a reference page. Numpydoc renders the NumPy-style docstrings. +The version comes from `pyproject.toml`. Generated sources, Doxygen output, +and final HTML are ignored by Git; edit their inputs instead. + +`make -C doc check` builds the combined site, verifies representative Python +pages and actual Breathe C++ targets, then checks local HTML links, anchors, +images, scripts, and stylesheets. `-W --keep-going` makes Sphinx warnings fail +the build; `-n` also checks unresolved API references. Doxygen's existing source-comment warnings are reported separately; +they are not fatal. External URLs can be checked with `make -C doc linkcheck`, +but that requires network access and can fail for unavailable remote sites. + +For a clean regeneration, use `make -C doc clean` before the build. To review +the result, serve `doc/_build/html` locally or download the `pele-documentation` +artifact from the Documentation workflow. Artifacts are retained for 14 days. + +## CI and publication approval + +`.github/workflows/docs.yml` builds and checks documentation for pushes to +`docs-autogeneration` and `main`, PRs targeting `main`, and manual runs. PRs and +docs-branch runs upload review artifacts and cannot deploy. CI builds the +current checkout with native dependencies and serial compilation. + +Publication is disabled unless the repository variable `DOCS_PUBLISH_ENABLED` +is exactly `true`. Leave it unset until the repository owner approves the site +and the following setup: + +1. Require the owner's review of website changes and the documentation check + on `main` using branch protection or a ruleset. +2. Create/protect the `github-pages` environment, restrict deployments to + `main`, and require the owner as a deployment reviewer. An environment name + in YAML alone does not require approval. Do not prevent self-review if the + owner must also approve their own workflow runs. +3. Switch Settings → Pages → Source to **GitHub Actions**. The existing site + currently uses legacy publishing from `gh-pages`; changing that setting is + a separate, owner-approved hosting action. +4. After approval, enable `DOCS_PUBLISH_ENABLED`. Successful main builds then + upload a Pages artifact and wait for environment approval before deploying. + Manual main runs use the same gate. If approval takes longer than 14 days, + rerun the workflow to regenerate the expired artifact. + +Do not merge, change hosting settings, enable publishing, or deploy as part of +preparing a documentation PR. The current branch can be verified remotely +without any of those actions. Installation and getting-started pages are the +first website review priority; the older custom-potential and system-class +tutorials still need a separate Python 3 content review. diff --git a/doc/check_html.py b/doc/check_html.py new file mode 100644 index 000000000..ef3acac81 --- /dev/null +++ b/doc/check_html.py @@ -0,0 +1,76 @@ +"""Check generated APIs and local links in the combined Sphinx/Doxygen site.""" + +from html.parser import HTMLParser +from pathlib import Path +import sys +from urllib.parse import unquote, urlsplit + + +class Page(HTMLParser): + def __init__(self, text): + super().__init__(convert_charrefs=True) + self.anchors = set() + self.links = [] + self.feed(text) + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if attrs.get("id"): + self.anchors.add(attrs["id"]) + if tag == "a" and attrs.get("name"): + self.anchors.add(attrs["name"]) + if tag in ("a", "link") and attrs.get("href"): + self.links.append(attrs["href"]) + if tag in ("img", "script") and attrs.get("src"): + self.links.append(attrs["src"]) + + +def broken_links(root): + root = root.resolve() + pages = {path: Page(path.read_text(encoding="utf-8")) for path in root.rglob("*.html")} + errors = [] + for path, page in pages.items(): + for href in page.links: + url = urlsplit(href) + if url.scheme or url.netloc: + continue + if url.path.startswith("/"): + target = root / unquote(url.path).lstrip("/") + elif url.path: + target = path.parent / unquote(url.path) + else: + target = path + target = target.resolve() + if target.is_dir(): + target /= "index.html" + if not target.is_relative_to(root) or not target.is_file(): + errors.append(f"{path.relative_to(root)}: missing file {href}") + elif url.fragment and target in pages and unquote(url.fragment) not in pages[target].anchors: + errors.append(f"{path.relative_to(root)}: missing anchor {href}") + return errors + + +def check(root): + required = [ + "index.html", "installation.html", "getting_started.html", "cpp_reference.html", + "generated/pele.basinhopping.BasinHopping.html", + "generated/pele.potentials.LJ.html", "generated/pele.storage.Database.html", + "doxygen/namespacepele.html", "doxygen/annotated.html", + "doxygen/classpele_1_1CVODEBDFOptimizer.html", + "doxygen/classpele_1_1GenericMixedDescent.html", + ] + errors = [f"Missing generated page: {path}" for path in required if not (root / path).is_file()] + cpp = root / "cpp_reference.html" + if cpp.is_file(): + anchors = Page(cpp.read_text(encoding="utf-8")).anchors + for name in ("BasePotential", "LBFGS", "CVODEBDFOptimizer", "GenericMixedDescent", "get_energy_gradient"): + if not any(anchor.startswith("_CPPv") and name in anchor for anchor in anchors): + errors.append(f"Missing Breathe C++ API target: {name}") + errors.extend(broken_links(root)) + if errors: + raise SystemExit("\n".join(errors)) + print(f"Generated APIs and local links checked in {root}") + + +if __name__ == "__main__": + check(Path(sys.argv[1] if len(sys.argv) > 1 else "_build/html")) diff --git a/doc/conf.py b/doc/conf.py index 82775ca69..bb1bde017 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -13,6 +13,7 @@ import sys, os from pathlib import Path import tomllib +import xml.etree.ElementTree as ET import pele # If extensions (or modules to document with autodoc) are in another directory, @@ -38,8 +39,15 @@ #'viewcode-new', "sphinx.ext.autosummary", "numpydoc", + "breathe", ] +breathe_projects = {"pele": "doxygen/_build/xml"} +breathe_default_project = "pele" + +# Library typedefs have no reference page in pele. +nitpick_ignore = [("cpp:identifier", name) for name in ("size_t", "uint8_t", "xsum_small_accumulator")] + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -303,3 +311,53 @@ def linkcode_resolve(domain, info): fn, linespec, ) + + +def setup(app): + # References outside the selected Breathe classes use the complete Doxygen site. + from docutils import nodes + import posixpath + import re + + xml_dir = Path(__file__).parent / breathe_projects["pele"] + index = ET.parse(xml_dir / "index.xml").getroot() + targets = {} + names = {} + for compound in index.findall("compound"): + refid = compound.get("refid") + target = f"doxygen/{refid}.html" + targets[refid] = target + name = compound.findtext("name") + names[name] = target + for member in compound.findall("member"): + member_id = member.get("refid") + target = f"doxygen/{refid}.html#{member_id.rsplit('_1', 1)[-1]}" + targets[member_id] = target + names[f"{name}::{member.findtext('name')}"] = target + if member.get("kind") == "enumvalue": + for enum in compound.findall("member[@kind='enum']"): + if member_id.startswith(enum.get("refid")): + names[f"{name}::{enum.findtext('name')}::{member.findtext('name')}"] = target + + def doxygen_reference(app, env, node, contnode): + if app.builder.format != "html": + return None + target = None + if node.get("refdomain") == "std" and node.get("reftype") == "ref": + target = targets.get(node["reftarget"]) + elif node.get("refdomain") == "cpp": + name = re.sub(r"<.*>", "", node["reftarget"]) + target = names.get(name) or names.get(f"pele::{name}") + if target is None: + return None + source = app.builder.get_target_uri(node["refdoc"]) + uri = posixpath.relpath(target, posixpath.dirname(source) or ".") + return nodes.reference("", "", contnode, refuri=uri) + + def dict_update_signature(app, what, name, obj, options, signature, return_annotation): + # The builtin's first line includes prose after its return annotation. + if obj is dict.update: + return "([E, ]**F)", "None" + + app.connect("missing-reference", doxygen_reference) + app.connect("autodoc-process-signature", dict_update_signature, priority=100) diff --git a/doc/cpp_reference.rst b/doc/cpp_reference.rst index 4afce95a7..8c0f72d54 100644 --- a/doc/cpp_reference.rst +++ b/doc/cpp_reference.rst @@ -2,6 +2,9 @@ C++ reference ============= The C++ API reference is generated from pele's source files with Doxygen. +Breathe renders the interfaces below in Sphinx so they are searchable alongside +the Python API. The complete Doxygen reference includes all extracted classes +and functions: * `Class index `_ * `pele namespace and functions `_ @@ -9,3 +12,31 @@ The C++ API reference is generated from pele's source files with Doxygen. Run ``make -C doc html`` to build the Python and C++ documentation together. The combined site is written to ``doc/_build/html/``, with the C++ reference under ``doxygen/``. Its navigation links back to the Python documentation. + +Potential interface +------------------- + +.. doxygenclass:: pele::BasePotential + :members: + :undoc-members: + +LBFGS minimizer +--------------- + +.. doxygenclass:: pele::LBFGS + :members: + :undoc-members: + +CVODE minimizer +--------------- + +.. doxygenclass:: pele::CVODEBDFOptimizer + :members: + :undoc-members: + +Mixed descent +------------- + +.. doxygenclass:: pele::GenericMixedDescent + :members: + :undoc-members: diff --git a/doc/getting_started.rst b/doc/getting_started.rst index 3d4a84da3..8bc1ada1f 100644 --- a/doc/getting_started.rst +++ b/doc/getting_started.rst @@ -2,9 +2,11 @@ Getting started =============== pele is a package for global optimization and landscape analysis. +Follow :doc:`installation` to install pele before running these examples. + The general sequence of steps when using these methods is -1. Find the global minimum and the build up a database of other important minima +1. Find the global minimum and build up a database of other important minima - We use :ref:`Basinhopping ` to do the global optimization. @@ -15,7 +17,7 @@ The general sequence of steps when using these methods is point. - We use :ref:`DoubleEndedConnect ` which is a combination - of the Nudged Elastic Band (:class:`.NEBDriver`) and Hybred Eigenvector following (:class:`.FindTransitionState`) + of the Nudged Elastic Band (:class:`.NEBDriver`) and Hybrid Eigenvector Following (:class:`.FindTransitionState`) #. Landscape analysis. @@ -24,7 +26,7 @@ The general sequence of steps when using these methods is .. note:: - This tutorial mirrors some examples in the `examples/getting_started` + This tutorial mirrors the examples in ``examples/getting_started/``. .. automodule:: examples.getting_started.example1_minimization diff --git a/doc/index.rst b/doc/index.rst index 3fec84d6e..24235e038 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -14,7 +14,7 @@ Documentation: https://pele-python.github.io/pele/ pele is a package of tools for calculations -involving optimzation and exploration on energy landscapes. The core +involving optimization and exploration on energy landscapes. The core routines are broken into two parts: :ref:`Basinhopping `, for finding the global minimum of an energy landscape, and for building up databases of minima. And :ref:`DoubleEndedConnect `, for @@ -37,6 +37,7 @@ Tutorials .. toctree:: :maxdepth: 3 + installation getting_started tutorial_potential system_class_tutorial diff --git a/doc/installation.rst b/doc/installation.rst new file mode 100644 index 000000000..5d3031ada --- /dev/null +++ b/doc/installation.rst @@ -0,0 +1,72 @@ +.. _installation: + +Installation +============ + +Install from PyPI +----------------- + +pele wheels support Python 3.11–3.14 on Linux x86_64 with glibc 2.28 or +newer, and macOS 14 or newer on Intel and Apple Silicon. Windows is not +supported. Install pele into your Python environment with: + +.. code-block:: console + + python -m pip install pele + +Check that the compiled potential and optimization modules import successfully: + +.. code-block:: console + + python -c "from pele.potentials import LJ; from pele.optimize import lbfgs_py; print('pele is ready')" + +Then follow :doc:`getting_started` to minimize a configuration and store minima +from a basinhopping run. + +Build from source +----------------- + +Building from source requires Python 3.11 or newer, C/C++/Fortran compilers, +OpenMP, SUNDIALS in double precision, Eigen, and LAPACK headers. A conda +environment can supply these dependencies: + +.. code-block:: console + + conda create -n pele -c conda-forge python=3.12 pip compilers sundials eigen blas-devel pkg-config + conda activate pele + python -m pip install git+https://github.com/pele-python/pele + +If you already have GCC, G++, and GFortran, you can omit ``compilers`` from +the environment command. SUNDIALS, Eigen, and LAPACK are taken from the active +conda environment, or from the system when no conda environment is active. + +For editable installs and build options, see the +`development instructions in the README `_. + +Optional features +----------------- + +The GUI uses PyQt5 and PyOpenGL. With a wheel installation, add them with: + +.. code-block:: console + + python -m pip install 'pele[gui]' + +The OpenGL viewer also needs the system GLU and GLUT libraries. On Ubuntu: + +.. code-block:: console + + sudo apt install libglu1-mesa libglut3.12 + +LAMMPS potentials require the LAMMPS Python package: + +.. code-block:: console + + python -m pip install 'pele[lammps]' + +Wheels use the Python LAMMPS interface. To build the compiled interface or +generate GUI forms from source, follow the +`source installation instructions in the README `_. + +``scikit-sparse`` optionally provides sparse Cholesky for rate calculations, +and ``pymol-open-source`` provides structure viewing. diff --git a/doc/tests/test_check_html.py b/doc/tests/test_check_html.py new file mode 100644 index 000000000..af3971625 --- /dev/null +++ b/doc/tests/test_check_html.py @@ -0,0 +1,43 @@ +from pathlib import Path +import sys +from tempfile import TemporaryDirectory +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from check_html import broken_links, check + + +class CheckHtmlTests(unittest.TestCase): + def test_links_and_anchors(self): + with TemporaryDirectory() as directory: + root = Path(directory) + (root / "index.html").write_text(''' + API + Broken anchor + Broken page + Outside the site + + External + External + Mail + ''') + (root / "api.html").write_text('Home') + self.assertEqual(broken_links(root), [ + "index.html: missing anchor api.html#missing", + "index.html: missing file absent.html", + "index.html: missing file ../index.html", + "index.html: missing file missing.png", + ]) + + def test_missing_generation_fails(self): + with TemporaryDirectory() as directory: + root = Path(directory) + (root / "cpp_reference.html").write_text("Breathe has not rendered any APIs") + with self.assertRaises(SystemExit) as result: + check(root) + self.assertIn("Missing generated page: index.html", str(result.exception)) + self.assertIn("Missing Breathe C++ API target: BasePotential", str(result.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/doc/tests/test_doxygen_references.py b/doc/tests/test_doxygen_references.py new file mode 100644 index 000000000..fdf22642d --- /dev/null +++ b/doc/tests/test_doxygen_references.py @@ -0,0 +1,45 @@ +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +import sys +import unittest +from unittest.mock import patch + +from docutils import nodes + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import conf + + +class DoxygenReferenceTests(unittest.TestCase): + def test_missing_breathe_targets_link_to_full_doxygen_reference(self): + with TemporaryDirectory() as directory: + Path(directory, "index.xml").write_text(''' + pele::Array + pele + StopCriterionType + GRADIENT + + ''') + callbacks = {} + app = SimpleNamespace( + connect=lambda event, callback, **kwargs: callbacks.update({event: callback}), + builder=SimpleNamespace(format="html", get_target_uri=lambda doc: f"{doc}.html"), + ) + with patch.dict(conf.breathe_projects, {"pele": directory}): + conf.setup(app) + resolve = callbacks["missing-reference"] + for domain, target, expected in [ + ("cpp", "Array", "../doxygen/classpele_1_1Array.html"), + ("std", "classpele_1_1Array", "../doxygen/classpele_1_1Array.html"), + ("cpp", "StopCriterionType::GRADIENT", "../doxygen/namespacepele.html#a123a456"), + ]: + node = {"refdomain": domain, "reftype": "ref", "reftarget": target, "refdoc": "generated/example"} + result = resolve(app, None, node, nodes.Text(target)) + self.assertEqual(result["refuri"], expected) + node["reftarget"] = "UnknownType" + self.assertIsNone(resolve(app, None, node, nodes.Text("UnknownType"))) + signature = callbacks["autodoc-process-signature"]( + app, "method", "Result.update", dict.update, None, None, None + ) + self.assertEqual(signature, ("([E, ]**F)", "None")) diff --git a/doc/tests/test_getting_started.py b/doc/tests/test_getting_started.py new file mode 100644 index 000000000..9e35e3cc4 --- /dev/null +++ b/doc/tests/test_getting_started.py @@ -0,0 +1,22 @@ +from contextlib import chdir, redirect_stdout +from io import StringIO +from pathlib import Path +from runpy import run_path +from tempfile import TemporaryDirectory +import unittest + +from pele.storage import Database + + +class GettingStartedTests(unittest.TestCase): + def test_manual_basinhopping_stores_minima(self): + with TemporaryDirectory() as directory, chdir(directory), redirect_stdout(StringIO()): + example = Path(__file__).resolve().parents[2] / "examples/getting_started/example2_global_optimization.py" + bh_no_system_class = run_path(str(example))["bh_no_system_class"] + bh_no_system_class() + database = Database("lj17.sqlite", createdb=False) + self.assertGreater(database.number_of_minima(), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/doc/tutorial_potential.rst b/doc/tutorial_potential.rst index 68123c49c..c31f8b2df 100644 --- a/doc/tutorial_potential.rst +++ b/doc/tutorial_potential.rst @@ -227,15 +227,15 @@ We're not quite ready yet. The routine which searches for transition states (:c uses a routine which walks uphill in the direction of the lowest eigenvector (the eigenvector with the lowest eigenvalue) while walking downhill in all other directions. We find this lowest eigenvector by looking for the direction -with the largest negative curvature with :class:`.FindLowestEigenVector`. This search is a lot easier and less error +with the largest negative curvature with :func:`pele.transition_states.findLowestEigenVector`. This search is a lot easier and less error prone if the search space is reduced and made simpler by removing the trivial zero eigenvectors. These are directions in phase space which have zero eigenvalue and correspond to trivial global symmetries of the system, e.g. translational and rotational symmetry, or frozen degrees of freedom. -In order to implement this, MySystem.get_orthogonalize_to_zero_eigenvectors() +In order to implement this, ``MySystem.get_orthogonalize_to_zero_eigenvectors()`` must return a function which makes a given vector orthogonal to all trivial zero eigenvectors. See -:meth:`.BaseSystem.MySystem.get_orthogonalize_to_zero_eigenvectors` and +:meth:`pele.systems.BaseSystem.get_orthogonalize_to_zero_eigenvectors` and :ref:`transition state search ` for more information. If your system has no zero eigenvalues, that function can just return `None`. For our cluster system we have 3 zero eigenvectors diff --git a/examples/getting_started/example2_global_optimization.py b/examples/getting_started/example2_global_optimization.py index 72595df5d..8b77cc63e 100644 --- a/examples/getting_started/example2_global_optimization.py +++ b/examples/getting_started/example2_global_optimization.py @@ -16,7 +16,7 @@ value -Let's do an example where we find run :class:`.BasinHopping` on a Lennard-Jones cluster (:class:`.LJ`). +Let's run :class:`.BasinHopping` on a Lennard-Jones cluster (:class:`.LJ`). .. note:: This example involves quite a number of steps. Normally these are done automatically @@ -54,7 +54,7 @@ displace = RandomDisplacement() adaptive_displacement = AdaptiveStepsizeTemperature(displace) -The last step is to set up an sqlite database (:mod:`.storage`) which we use to store the +Next we set up a SQLite database (:mod:`.storage`) which we use to store the minima that we find during the basinhopping run. :: @@ -64,6 +64,16 @@ There is now an sqlite database named "lj17.sqlite" in the folder where this script was run. We use `sqlalchemy` to communicate with the sqlite database from python. + +Now construct and run :class:`.BasinHopping`. Calling ``database.minimum_adder()`` +returns the callback that stores each minimum found during the run:: + + from pele.basinhopping import BasinHopping + bh = BasinHopping( + x0, potential, adaptive_displacement, storage=database.minimum_adder() + ) + bh.run(10) + The minima in the database can be accessed simply by:: for m in database.minima(): @@ -105,7 +115,7 @@ def bh_no_system_class(): from pele.basinhopping import BasinHopping bh = BasinHopping( - x0, potential, adaptive_displacement, storage=database.minimum_adder + x0, potential, adaptive_displacement, storage=database.minimum_adder() ) bh.run(10) diff --git a/pele/angleaxis/rigidbody.py b/pele/angleaxis/rigidbody.py index 5086352dd..1dee17789 100644 --- a/pele/angleaxis/rigidbody.py +++ b/pele/angleaxis/rigidbody.py @@ -53,9 +53,7 @@ class RigidFragment(aatopology.AASiteType): including center of geometry, tensor of gyration, and internal symmetries will be computed automatically. - See Also - -------- - AASiteType + The base class is ``pele.angleaxis.AASiteType``. """ def __init__(self): diff --git a/pele/systems/basesystem.py b/pele/systems/basesystem.py index 4bc63f79d..7437085ca 100644 --- a/pele/systems/basesystem.py +++ b/pele/systems/basesystem.py @@ -290,7 +290,7 @@ def get_basinhopping( See Also -------- - pele.basinhopping + pele.basinhopping.BasinHopping """ kwargs = dict_copy_update(self.params["basinhopping"], kwargs) # extract max_n_minima from kwargs @@ -583,7 +583,7 @@ def smooth_path(self, images, **kwargs): See Also -------- - pele.landscape.smooth_path + pele.landscape.smoothPath """ from pele.landscape import smooth_path diff --git a/pele/utils/disconnectivity_graph.py b/pele/utils/disconnectivity_graph.py index 89ca0a869..491cdca63 100644 --- a/pele/utils/disconnectivity_graph.py +++ b/pele/utils/disconnectivity_graph.py @@ -606,13 +606,14 @@ class DisconnectivityGraph: See Also -------- - make_disconnectivity_graph.py : - a script (in pele/scripts) to make the disconnectivity graph from the command line pele.storage.Database : The database format in which minima and transition states are stored in pele Examples -------- + The ``scripts/make_disconnectivity_graph.py`` script makes a disconnectivity + graph from the command line. + These examples assume a Database with minima already exists >>> import matplotlib.pyplot as plt diff --git a/pele/utils/rbtools.py b/pele/utils/rbtools.py index 872bd945b..1ef742078 100644 --- a/pele/utils/rbtools.py +++ b/pele/utils/rbtools.py @@ -66,7 +66,7 @@ def __init__(self, nrigid=0, natoms=0, nlattice=0, coords=None): :param nlattice: number of lattice degrees of freedom :type nlattice: 0 :param coords: the coordinate array - :type coords: numpy.array + :type coords: ``numpy.ndarray`` """ if nrigid is 0 and natoms is 0: @@ -94,7 +94,7 @@ def updateCoords(self, coords): to a different coordinate array. :param coords: the coordinate array - :type coords: numpy.array + :type coords: ``numpy.ndarray`` """ natoms = self.natoms nrigid = self.nrigid diff --git a/pyproject.toml b/pyproject.toml index a099caa55..f55e60557 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ [project.optional-dependencies] test = ["pytest"] -docs = ["sphinx>=8,<9", "numpydoc>=1.8,<2"] +docs = ["sphinx>=8,<9", "numpydoc>=1.8,<2", "breathe>=4.36,<5"] gui = ["pyqt5", "PyOpenGL"] lammps = ["lammps[mpi]"] From 34a650b61c3b1228e09a8ddd0cc9903d013ea6a3 Mon Sep 17 00:00:00 2001 From: Praharsh Suryadevara Date: Sun, 11 Oct 2026 16:23:09 -0500 Subject: [PATCH 03/14] Normalize inherited dictionary signatures on Python 3.12 --- doc/conf.py | 6 ++++-- doc/tests/test_doxygen_references.py | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index bb1bde017..6a77bb88b 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -354,10 +354,12 @@ def doxygen_reference(app, env, node, contnode): uri = posixpath.relpath(target, posixpath.dirname(source) or ".") return nodes.reference("", "", contnode, refuri=uri) - def dict_update_signature(app, what, name, obj, options, signature, return_annotation): + def dict_method_signature(app, what, name, obj, options, signature, return_annotation): # The builtin's first line includes prose after its return annotation. if obj is dict.update: return "([E, ]**F)", "None" + if any(obj is method for method in (dict.clear, dict.copy, dict.items, dict.keys, dict.values)): + return signature, None app.connect("missing-reference", doxygen_reference) - app.connect("autodoc-process-signature", dict_update_signature, priority=100) + app.connect("autodoc-process-signature", dict_method_signature, priority=100) diff --git a/doc/tests/test_doxygen_references.py b/doc/tests/test_doxygen_references.py index fdf22642d..2032d55d8 100644 --- a/doc/tests/test_doxygen_references.py +++ b/doc/tests/test_doxygen_references.py @@ -43,3 +43,9 @@ def test_missing_breathe_targets_link_to_full_doxygen_reference(self): app, "method", "Result.update", dict.update, None, None, None ) self.assertEqual(signature, ("([E, ]**F)", "None")) + for method in (dict.clear, dict.copy, dict.items, dict.keys, dict.values): + signature = callbacks["autodoc-process-signature"]( + app, "method", f"Result.{method.__name__}", method, None, + "()", "prose describing the result", + ) + self.assertEqual(signature, ("()", None)) From 14700f6a3ea6420e0c61be6e05316e632da40502 Mon Sep 17 00:00:00 2001 From: Praharsh Suryadevara Date: Sun, 11 Oct 2026 16:54:10 -0500 Subject: [PATCH 04/14] Allow an approved first Pages deployment from the docs branch --- .github/workflows/docs.yml | 15 +++++++++---- doc/README.md | 44 +++++++++++++++++--------------------- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d988425e9..0c46c14fd 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -72,18 +72,25 @@ jobs: retention-days: 14 - uses: actions/configure-pages@v5 - if: vars.DOCS_PUBLISH_ENABLED == 'true' && github.repository == 'pele-python/pele' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + if: >- + vars.DOCS_PUBLISH_ENABLED == 'true' && github.repository == 'pele-python/pele' && + github.event_name != 'pull_request' && + (github.ref == 'refs/heads/main' || + (github.ref == 'refs/heads/docs-autogeneration' && vars.DOCS_PUBLISH_BRANCH == 'docs-autogeneration')) - uses: actions/upload-pages-artifact@v3 - if: vars.DOCS_PUBLISH_ENABLED == 'true' && github.repository == 'pele-python/pele' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' with: path: doc/_build/html retention-days: 14 deploy: name: Publish documentation - # Enable only after the owner approves Pages and environment protection setup. - if: vars.DOCS_PUBLISH_ENABLED == 'true' && github.repository == 'pele-python/pele' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + # A temporary branch switch allows an approved first deployment before merging. + if: >- + vars.DOCS_PUBLISH_ENABLED == 'true' && github.repository == 'pele-python/pele' && + github.event_name != 'pull_request' && + (github.ref == 'refs/heads/main' || + (github.ref == 'refs/heads/docs-autogeneration' && vars.DOCS_PUBLISH_BRANCH == 'docs-autogeneration')) needs: build runs-on: ubuntu-24.04 permissions: diff --git a/doc/README.md b/doc/README.md index 1c5e9c404..237904f8e 100644 --- a/doc/README.md +++ b/doc/README.md @@ -43,33 +43,29 @@ For a clean regeneration, use `make -C doc clean` before the build. To review the result, serve `doc/_build/html` locally or download the `pele-documentation` artifact from the Documentation workflow. Artifacts are retained for 14 days. -## CI and publication approval +## CI and publication `.github/workflows/docs.yml` builds and checks documentation for pushes to -`docs-autogeneration` and `main`, PRs targeting `main`, and manual runs. PRs and -docs-branch runs upload review artifacts and cannot deploy. CI builds the -current checkout with native dependencies and serial compilation. +`docs-autogeneration` and `main`, PRs targeting `main`, and manual runs. Every +successful build uploads both the review site and a deployable Pages artifact. +PRs cannot deploy. CI builds the current checkout with native dependencies and +serial compilation. Publication is disabled unless the repository variable `DOCS_PUBLISH_ENABLED` -is exactly `true`. Leave it unset until the repository owner approves the site -and the following setup: +is exactly `true`. Pages must use **GitHub Actions** as its publishing source. +Successful `main` builds then refresh the website; manual main runs use the +same deployment path. No custom deployment secret is needed: the deploy job +uses GitHub's Pages and OIDC permissions. -1. Require the owner's review of website changes and the documentation check - on `main` using branch protection or a ruleset. -2. Create/protect the `github-pages` environment, restrict deployments to - `main`, and require the owner as a deployment reviewer. An environment name - in YAML alone does not require approval. Do not prevent self-review if the - owner must also approve their own workflow runs. -3. Switch Settings → Pages → Source to **GitHub Actions**. The existing site - currently uses legacy publishing from `gh-pages`; changing that setting is - a separate, owner-approved hosting action. -4. After approval, enable `DOCS_PUBLISH_ENABLED`. Successful main builds then - upload a Pages artifact and wait for environment approval before deploying. - Manual main runs use the same gate. If approval takes longer than 14 days, - rerun the workflow to regenerate the expired artifact. +For an owner-approved first deployment before merging, temporarily set the +repository variable `DOCS_PUBLISH_BRANCH=docs-autogeneration` as well, then push +to that branch. Remove that variable after the deployment succeeds so later +docs-branch edits only build and check. Keep the old `gh-pages` branch for +rollback. Artifacts expire after 14 days; rerun the build if necessary. -Do not merge, change hosting settings, enable publishing, or deploy as part of -preparing a documentation PR. The current branch can be verified remotely -without any of those actions. Installation and getting-started pages are the -first website review priority; the older custom-potential and system-class -tutorials still need a separate Python 3 content review. +Publishing is a separate owner-approved action from preparing a PR. Repository +and environment review requirements can be configured separately; the +`github-pages` environment name in YAML alone does not require approval. +Installation and getting-started pages are the first website review priority; +the older custom-potential and system-class tutorials still need a separate +Python 3 content review. From 725c049b62e10731759a07e40491415204052ed5 Mon Sep 17 00:00:00 2001 From: Praharsh Suryadevara Date: Sun, 11 Oct 2026 17:40:51 -0500 Subject: [PATCH 05/14] Generate complete public and developer API documentation --- doc/README.md | 23 +- doc/_templates/autosummary/class.rst | 50 ++-- doc/check_html.py | 84 ++++++- doc/conf.py | 35 ++- doc/cpp_reference.rst | 73 +++++- doc/doxygen/Doxyfile | 6 +- doc/doxygen/DoxygenLayout.xml | 2 +- doc/index.rst | 8 + doc/sphinxext/source_api.py | 223 ++++++++++++++++++ doc/tests/test_check_html.py | 55 ++++- doc/tests/test_doxygen_references.py | 39 ++- doc/tests/test_source_api.py | 182 ++++++++++++++ pele/amber/read_amber.py | 11 +- pele/concurrent/_connect_server.py | 2 +- pele/distance/__init__.py | 2 +- pele/mindist/permutational_alignment.py | 2 +- pele/optimize/__init__.py | 108 ++++++++- pele/optimize/_fire.py | 10 +- pele/optimize/_lbfgs_py.py | 2 +- pele/optimize/_modified_fire_cpp.pyx | 2 +- pele/optimize/_mxd_end_only.pyx | 4 +- pele/optimize/_quench.py | 70 +++++- pele/optimize/cvode_opt.pyx | 4 +- pele/optimize/extended_mixed_descent.pyx | 6 +- pele/optimize/generic_mixed_descent.pyx | 4 +- pele/potentials/__init__.py | 104 ++++++-- pele/potentials/_fin_sin.py | 36 +-- pele/potentials/_frenkel.pyx | 4 +- pele/potentials/_gupta.py | 16 +- .../_inversepower_stillinger_cut_quad.pyx | 22 +- pele/potentials/_sumgaussianpot_cpp.pyx | 5 +- pele/potentials/_sutton_chen.py | 12 +- pele/potentials/atlj.pyx | 2 +- pele/potentials/combine_potentials.pyx | 6 +- pele/potentials/ml.py | 3 +- pele/systems/basesystem.py | 2 +- pele/utils/rbtools.py | 2 +- pyproject.toml | 2 +- source/generic_mixed_descent.cpp | 6 +- source/pele/EvaluatorInversePower.hpp | 4 +- source/pele/base_potential.hpp | 2 +- source/pele/generic_mixed_descent.hpp | 2 +- source/pele/hessian_translations.hpp | 2 +- 43 files changed, 1073 insertions(+), 166 deletions(-) create mode 100644 doc/sphinxext/source_api.py create mode 100644 doc/tests/test_source_api.py diff --git a/doc/README.md b/doc/README.md index 237904f8e..53d43c29e 100644 --- a/doc/README.md +++ b/doc/README.md @@ -27,14 +27,31 @@ Breathe classes resolve to that complete reference using Doxygen's XML index. For Python, Sphinx autodoc imports the modules and reads their docstrings. Autosummary follows the explicit catalogs in the `.rst` pages and package docstrings, using `doc/_templates/autosummary` to generate class, method, and -attribute pages under `doc/generated`. New exports need an autosummary entry -to receive a reference page. Numpydoc renders the NumPy-style docstrings. +attribute pages under `doc/generated`. These catalogs provide the overview. +The **Developer source API** also collects existing public module, class, +function, and method docstrings from tracked Python and Cython library files, +including optional backends and public interfaces defined in underscore-prefixed +modules. Private classes and methods, underscore-prefixed helpers, and definitions +nested inside functions are excluded. Python special methods that implement public +protocols, such as constructors, iteration, and calls, are retained. +It parses source without +importing those modules, so an unavailable optional dependency does not hide +its documentation. Tests and playground code are excluded. Numpydoc renders +the NumPy-style docstrings. Source declarations and links accompany each entry. +New source docstrings appear automatically without another catalog edit. The version comes from `pyproject.toml`. Generated sources, Doxygen output, and final HTML are ignored by Git; edit their inputs instead. `make -C doc check` builds the combined site, verifies representative Python pages and actual Breathe C++ targets, then checks local HTML links, anchors, -images, scripts, and stylesheets. `-W --keep-going` makes Sphinx warnings fail +images, scripts, and stylesheets. Coverage manifests verify that source +docstrings and documented Doxygen symbols have rendered targets. The native +reference includes public and protected developer interfaces and file-level +functions, including `SimplePairwisePotential` and `CellListPotential`. Private +class members and file-local helpers are excluded. Its inputs are the library's +own native source roots, excluding +the vendored JSON headers and unrelated local `source/gmin` directory. +`-W --keep-going` makes Sphinx warnings fail the build; `-n` also checks unresolved API references. Doxygen's existing source-comment warnings are reported separately; they are not fatal. External URLs can be checked with `make -C doc linkcheck`, but that requires network access and can fail for unavailable remote sites. diff --git a/doc/_templates/autosummary/class.rst b/doc/_templates/autosummary/class.rst index 822b737e6..235cda80d 100644 --- a/doc/_templates/autosummary/class.rst +++ b/doc/_templates/autosummary/class.rst @@ -1,29 +1,29 @@ -{% extends "!autosummary/class.rst" %} +{{ fullname | escape | underline }} - {% block methods %} - {% if methods %} - .. rubric:: Methods +.. currentmodule:: {{ module }} - .. autosummary:: - :toctree: - {% for item in all_methods %} - {%- if not item.startswith('_') or item in ['__call__'] %} - ~{{ objname }}.{{ item }} - {%- endif -%} - {%- endfor %} - {% endif %} - {% endblock %} +.. autoclass:: {{ objname }} - {% block attributes %} - {% if attributes %} - .. rubric:: Attributes +{% if all_methods %} +.. rubric:: Methods - .. autosummary:: - :toctree: - {% for item in all_attributes %} - {%- if not item.startswith('_') %} - ~{{ objname }}.{{ item }} - {%- endif -%} - {%- endfor %} - {% endif %} - {% endblock %} +.. autosummary:: + :toctree: + {% for item in all_methods %} + {%- if not item.startswith('_') or item in ['__call__'] %} + ~{{ objname }}.{{ item }} + {%- endif -%} + {%- endfor %} +{% endif %} + +{% if all_attributes %} +.. rubric:: Attributes + +.. autosummary:: + :toctree: + {% for item in all_attributes %} + {%- if not item.startswith('_') %} + ~{{ objname }}.{{ item }} + {%- endif -%} + {%- endfor %} +{% endif %} diff --git a/doc/check_html.py b/doc/check_html.py index ef3acac81..292a289a1 100644 --- a/doc/check_html.py +++ b/doc/check_html.py @@ -1,9 +1,11 @@ """Check generated APIs and local links in the combined Sphinx/Doxygen site.""" from html.parser import HTMLParser +import json from pathlib import Path import sys from urllib.parse import unquote, urlsplit +from xml.etree import ElementTree as ET class Page(HTMLParser): @@ -50,22 +52,100 @@ def broken_links(root): return errors +def coverage_errors(root, entries, label): + root = root.resolve() + pages = {} + errors = [] + for entry in entries: + path = (root / (entry["docname"] + ".html")).resolve() + if not path.is_relative_to(root) or not path.is_file(): + errors.append(f"Missing {label} page: {entry['fullname']}") + continue + if entry.get("anchor"): + if path not in pages: + pages[path] = Page(path.read_text(encoding="utf-8")) + if entry["anchor"] not in pages[path].anchors: + errors.append(f"Missing {label} target: {entry['fullname']}") + return errors + + +def documented_cpp_symbols(xml_dir): + entries = {} + for path in sorted(xml_dir.glob("*.xml")): + compound = ET.parse(path).getroot().find("compounddef") + if compound is None or compound.get("prot") == "private": + continue + location = compound.find("location") + if location is not None and location.get("file") == "[STL]": + continue + if any(part.startswith("_") for part in compound.findtext("compoundname", "").split("::")): + continue + nodes = [compound] + for member in compound.findall(".//memberdef"): + if member.get("prot") == "private": + continue + if member.findtext("name", "").startswith("_"): + continue + if compound.get("kind") in ("file", "namespace") and member.get("static") == "yes": + continue + nodes.extend([member, *(value for value in member.findall("enumvalue") + if not value.findtext("name", "").startswith("_"))]) + for node in nodes: + if not any(node.find(name) is not None and "".join(node.find(name).itertext()).strip() + for name in ("briefdescription", "detaileddescription", "inbodydescription")): + continue + identifier = node.get("id") + owner, _, anchor = identifier.rpartition("_1") if node is not compound else (identifier, "", "") + entries[identifier] = { + "fullname": node.findtext("definition") or node.findtext("compoundname") or node.findtext("name"), + "kind": node.get("kind", "enumvalue"), + "docname": "doxygen/" + owner, + "anchor": anchor, + } + return list(entries.values()) + + def check(root): required = [ "index.html", "installation.html", "getting_started.html", "cpp_reference.html", "generated/pele.basinhopping.BasinHopping.html", - "generated/pele.potentials.LJ.html", "generated/pele.storage.Database.html", + "generated/pele.optimize.Fire.html", "generated/pele.optimize.ModifiedFireCPP.html", + "generated/pele.optimize.modifiedfire_cpp.html", "generated/pele.optimize.LBFGS_CPP.html", + "generated/pele.optimize.CVODEBDFOptimizer.run.html", + "generated/pele.potentials.ATLJCPP.getEnergy.html", + "generated/pele.potentials.LJ.html", "generated/pele.potentials.HS_WCA.html", + "generated/pele.potentials.InversePower.html", "generated/pele.potentials.WCA.html", + "generated/pele.storage.Database.html", "doxygen/namespacepele.html", "doxygen/annotated.html", "doxygen/classpele_1_1CVODEBDFOptimizer.html", "doxygen/classpele_1_1GenericMixedDescent.html", + "doxygen/classpele_1_1SimplePairwisePotential.html", + "doxygen/classpele_1_1CellListPotential.html", + "generated/pele.potentials._pele.PairwisePotentialInterface.html", + "generated/source_api/index.html", "doxygen/files.html", "doxygen/globals_func.html", ] errors = [f"Missing generated page: {path}" for path in required if not (root / path).is_file()] cpp = root / "cpp_reference.html" if cpp.is_file(): anchors = Page(cpp.read_text(encoding="utf-8")).anchors - for name in ("BasePotential", "LBFGS", "CVODEBDFOptimizer", "GenericMixedDescent", "get_energy_gradient"): + for name in ("BasePotential", "PairwisePotentialInterface", "SimplePairwisePotential", "CellListPotential", "LBFGS", "MODIFIED_FIRE", "CVODEBDFOptimizer", "GenericMixedDescent", "get_energy_gradient"): if not any(anchor.startswith("_CPPv") and name in anchor for anchor in anchors): errors.append(f"Missing Breathe C++ API target: {name}") + source_manifest = root / "source_api_manifest.json" + if source_manifest.is_file(): + entries = json.loads(source_manifest.read_text(encoding="utf-8")) + errors.extend(coverage_errors(root, entries, "source API")) + print(f"Checked {len(entries)} documented Python/Cython source targets") + else: + errors.append("Missing source API coverage manifest") + xml_dir = Path(__file__).parent / "doxygen/_build/xml" + cpp_entries = documented_cpp_symbols(xml_dir) + if cpp_entries: + errors.extend(coverage_errors(root, cpp_entries, "Doxygen API")) + (root / "doxygen_api_manifest.json").write_text(json.dumps(cpp_entries, indent=2) + "\n", encoding="utf-8") + print(f"Checked {len(cpp_entries)} documented native source targets") + else: + errors.append("Missing documented Doxygen APIs") errors.extend(broken_links(root)) if errors: raise SystemExit("\n".join(errors)) diff --git a/doc/conf.py b/doc/conf.py index 6a77bb88b..b6b536830 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -11,6 +11,7 @@ # serve to show the default. import sys, os +import json from pathlib import Path import tomllib import xml.etree.ElementTree as ET @@ -40,13 +41,14 @@ "sphinx.ext.autosummary", "numpydoc", "breathe", + "source_api", ] breathe_projects = {"pele": "doxygen/_build/xml"} breathe_default_project = "pele" # Library typedefs have no reference page in pele. -nitpick_ignore = [("cpp:identifier", name) for name in ("size_t", "uint8_t", "xsum_small_accumulator")] +nitpick_ignore = [("cpp:identifier", name) for name in ("size_t", "uint8_t", "xsum_small_accumulator", "NULL")] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -307,7 +309,9 @@ def linkcode_resolve(domain, info): fn = relpath(fn, start=dirname(pele.__file__)) - return "https://github.com/pele-python/pele/blob/main/pele/{}{}".format( + revision = os.environ.get("GITHUB_SHA", "docs-autogeneration") + return "https://github.com/pele-python/pele/blob/{}/pele/{}{}".format( + revision, fn, linespec, ) @@ -361,5 +365,32 @@ def dict_method_signature(app, what, name, obj, options, signature, return_annot if any(obj is method for method in (dict.clear, dict.copy, dict.items, dict.keys, dict.values)): return signature, None + source_targets = None + + def source_reference(app, env, node, contnode): + nonlocal source_targets + if node.get("refdomain") != "py" or app.builder.format != "html": + return None + if source_targets is None: + entries = json.loads((Path(app.srcdir) / "generated/source_api/manifest.json").read_text()) + source_targets = {entry["fullname"]: entry for entry in entries} + name = node["reftarget"] + module = node.get("py:module") or "" + cls = node.get("py:class") or "" + entry = (source_targets.get(f"{module}.{cls}.{name}") + or source_targets.get(f"{module}.{name}") or source_targets.get(name)) + if entry is None: + matches = [item for fullname, item in source_targets.items() if fullname.endswith("." + name)] + nearby = [item for item in matches if item["fullname"].startswith(module.rsplit(".", 1)[0] + ".")] + if len(nearby) == 1: + entry = nearby[0] + elif len(matches) == 1: + entry = matches[0] + if entry is None: + return None + uri = app.builder.get_relative_uri(node["refdoc"], entry["docname"]) + "#" + entry["anchor"] + return nodes.reference("", "", contnode, refuri=uri) + app.connect("missing-reference", doxygen_reference) + app.connect("missing-reference", source_reference) app.connect("autodoc-process-signature", dict_method_signature, priority=100) diff --git a/doc/cpp_reference.rst b/doc/cpp_reference.rst index 8c0f72d54..5a19cc1c8 100644 --- a/doc/cpp_reference.rst +++ b/doc/cpp_reference.rst @@ -3,11 +3,14 @@ C++ reference The C++ API reference is generated from pele's source files with Doxygen. Breathe renders the interfaces below in Sphinx so they are searchable alongside -the Python API. The complete Doxygen reference includes all extracted classes -and functions: +the Python API. The complete Doxygen reference includes public and protected +developer interfaces and functions. Private class members and file-local +helpers are excluded: * `Class index `_ * `pele namespace and functions `_ +* `All file-level functions `_ +* `Source files `_ Run ``make -C doc html`` to build the Python and C++ documentation together. The combined site is written to ``doc/_build/html/``, with the C++ reference @@ -27,6 +30,19 @@ LBFGS minimizer :members: :undoc-members: +FIRE minimizer +-------------- + +The native modified FIRE implementation is exposed in Python as +:class:`pele.optimize.ModifiedFireCPP` and +:func:`pele.optimize.modifiedfire_cpp`. The Python implementation is +:class:`pele.optimize.Fire`, with the convenience function +:func:`pele.optimize.fire`. + +.. doxygenclass:: pele::MODIFIED_FIRE + :members: + :undoc-members: + CVODE minimizer --------------- @@ -40,3 +56,56 @@ Mixed descent .. doxygenclass:: pele::GenericMixedDescent :members: :undoc-members: + +Other optimizer interfaces +-------------------------- + +The complete Doxygen reference also documents these native optimizers: + +* :cpp:class:`pele::GradientDescent` +* :cpp:class:`pele::CosineGradientDescent` +* :cpp:class:`pele::ExtendedMixedOptimizer` +* :cpp:class:`pele::MixedDescentEndOnly` + +Potential interfaces +-------------------- + +Python constructors and methods are listed in :ref:`potentials_description`. + +Pairwise potential interface +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. doxygenclass:: pele::PairwisePotentialInterface + :members: + :undoc-members: + +Pairwise implementation interfaces +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Developers implementing pairwise potentials can use the direct pair loop or +the cell-list implementation. These public interfaces support the concrete +potential classes. + +.. doxygenclass:: pele::SimplePairwisePotential + :members: + :undoc-members: + :protected-members: + +.. doxygenclass:: pele::CellListPotential + :members: + :undoc-members: + +Potential implementations +~~~~~~~~~~~~~~~~~~~~~~~~~ + +The corresponding native interfaces, including their periodic and cell-list +variants, are available in the complete Doxygen reference: + +* :cpp:class:`pele::LJ` and :cpp:class:`pele::LJCut` +* :cpp:class:`pele::HS_WCA` and :cpp:class:`pele::WCA` +* :cpp:class:`pele::InversePower` and :cpp:class:`pele::InversePowerHS` +* :cpp:class:`pele::InversePowerStillinger`, + :cpp:class:`pele::InversePowerStillingerCut`, and + :cpp:class:`pele::InversePowerStillingerCutQuad` +* :cpp:class:`pele::Frenkel` and :cpp:class:`pele::Morse` +* :cpp:class:`pele::Harmonic` and :cpp:class:`pele::RadialGaussian` diff --git a/doc/doxygen/Doxyfile b/doc/doxygen/Doxyfile index cd1bb58ed..f808cb049 100644 --- a/doc/doxygen/Doxyfile +++ b/doc/doxygen/Doxyfile @@ -360,7 +360,7 @@ EXTRACT_STATIC = NO # defined locally in source files will be included in the documentation. # If set to NO only classes defined in header files are included. -EXTRACT_LOCAL_CLASSES = YES +EXTRACT_LOCAL_CLASSES = NO # This flag is only useful for Objective-C code. When set to YES local # methods, which are defined in the implementation section but not in @@ -547,7 +547,7 @@ SHOW_USED_FILES = YES # This will remove the Files entry from the Quick Index and from the # Folder Tree View (if specified). The default is YES. -SHOW_FILES = NO +SHOW_FILES = YES # Set the SHOW_NAMESPACES tag to NO to disable the generation of the # Namespaces page. @@ -699,7 +699,7 @@ EXCLUDE_PATTERNS = # wildcard * is used, a substring. Examples: ANamespace, AClass, # AClass::ANamespace, ANamespace::*Test -EXCLUDE_SYMBOLS = +EXCLUDE_SYMBOLS = *::_* _* # The EXAMPLE_PATH tag can be used to specify one or more files or # directories that contain example code fragments that are included (see diff --git a/doc/doxygen/DoxygenLayout.xml b/doc/doxygen/DoxygenLayout.xml index 391dba7a6..93b3ca943 100644 --- a/doc/doxygen/DoxygenLayout.xml +++ b/doc/doxygen/DoxygenLayout.xml @@ -15,7 +15,7 @@ - + diff --git a/doc/index.rst b/doc/index.rst index 24235e038..6e5895ab5 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -92,6 +92,14 @@ Modules utils angleaxis_module +Developer source API +++++++++++++++++++++ + +.. toctree:: + :maxdepth: 1 + + generated/source_api/index + C++ documentation +++++++++++++++++ diff --git a/doc/sphinxext/source_api.py b/doc/sphinxext/source_api.py new file mode 100644 index 000000000..3ec012f6b --- /dev/null +++ b/doc/sphinxext/source_api.py @@ -0,0 +1,223 @@ +"""Generate documented public APIs from tracked sources without importing pele.""" + +import ast +from collections import defaultdict +from hashlib import sha256 +from io import StringIO +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import textwrap +import tokenize +from urllib.parse import quote +import warnings + +from numpydoc.docscrape_sphinx import get_doc_object + + +def tracked_sources(root): + result = subprocess.run( + ["git", "ls-files", "-z", "--", "pele"], cwd=root, check=True, capture_output=True, + ) + return [root / os.fsdecode(path) for path in result.stdout.split(b"\0") if path] + + +def declaration(text, line): + """Keep the actual source signature, including Cython types and GIL clauses.""" + tokens = tokenize.generate_tokens(StringIO(text).readline) + start = None + depth = 0 + for token in tokens: + if start is None: + if token.start[0] >= line and token.type == tokenize.NAME and token.string in ("def", "class", "cdef", "cpdef", "async"): + start = token.start + else: + continue + if token.type == tokenize.OP: + if token.string in "([{": + depth += 1 + elif token.string in ")]}": + depth -= 1 + elif token.string == ":" and depth == 0: + lines = text.splitlines(keepends=True) + selected = lines[start[0] - 1:token.end[0]] + selected[-1] = selected[-1][:token.end[1]] + return textwrap.dedent("".join(selected)).strip() + raise ValueError(f"No source declaration found at line {line}") + + +def public_name(name, kind): + return not name.startswith("_") or kind == "method" and name.startswith("__") and name.endswith("__") + + +def python_docs(text, source): + tree = ast.parse(text, filename=str(source)) + records = [] + if ast.get_docstring(tree): + records.append(("", "module", tree.body[0].lineno, ast.get_docstring(tree), "")) + + def walk(node, scope=(), parent_kind=None): + for child in ast.iter_child_nodes(node): + if isinstance(child, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + kind = "class" if isinstance(child, ast.ClassDef) else "method" if parent_kind == "class" else "function" + if not public_name(child.name, kind): + continue + name = (*scope, child.name) + doc = ast.get_docstring(child) + if doc: + records.append((".".join(name), kind, child.lineno, doc, declaration(text, child.lineno))) + if kind == "class": + walk(child, name, kind) + else: + walk(child, scope, parent_kind) + + walk(tree) + return records + + +def cython_docs(text, source): + # Parsing stops before Cython's import analysis or native compilation. + from Cython.Compiler import Errors + from Cython.Compiler.TreeFragment import StringParseContext, parse_from_strings + + Errors.init_thread() + tree = parse_from_strings( + str(source), text, level="module_pxd" if source.suffix == ".pxd" else "module", + context=StringParseContext(str(source), cpp=True), + ) + records = [] + if tree.doc: + records.append(("", "module", 1, str(tree.doc), "")) + + def walk(node, scope=(), parent_kind=None): + node_type = type(node).__name__ + name = None + if node_type in ("PyClassDefNode", "CClassDefNode"): + name = getattr(node, "name", None) or node.class_name + kind = "class" + elif node_type == "DefNode": + name = node.name + kind = "method" if parent_kind == "class" else "function" + elif node_type == "CFuncDefNode": + if not (node.overridable or node.visibility in ("public", "extern") or node.api): + return + name = node.declarator.declared_name() + kind = "method" if parent_kind == "class" else "function" + if name: + if not public_name(name, kind): + return + scope = (*scope, name) + parent_kind = kind + if node.doc: + records.append((".".join(scope), kind, node.pos[1], str(node.doc), declaration(text, node.pos[1]))) + if kind != "class": + return + for attribute in getattr(node, "child_attrs", ()): + children = getattr(node, attribute, None) + for child in children if isinstance(children, list) else [children]: + if child is not None and hasattr(child, "child_attrs"): + walk(child, scope, parent_kind) + + walk(tree) + return records + + +def module_prose(doc): + """Replace module API catalogs with the generated source catalog.""" + lines = doc.splitlines() + result = [] + index = 0 + while index < len(lines): + line = lines[index] + directive = re.match(r"^(\s*)\.\. (?:auto\w+|currentmodule|module)::", line) + if directive: + indent = len(directive[1]) + index += 1 + while index < len(lines) and (not lines[index].strip() or len(lines[index]) - len(lines[index].lstrip()) > indent): + index += 1 + else: + result.append(line) + index += 1 + return "\n".join(result) + + +def render_docstring(doc, kind): + with warnings.catch_warnings(record=True) as issues: + warnings.simplefilter("always") + rendered = str(get_doc_object( + None, what="class" if kind == "class" else "function", doc=doc, + config={"show_class_members": False, "xref_param_type": False}, + )) + # shortcut: Numpydoc warnings render verbatim, fix source headings to restore formatting. + if issues: + return ".. code-block:: text\n\n" + textwrap.indent(doc, " ") + return rendered + + +def generate(root, output, revision=None): + root, output = Path(root).resolve(), Path(output) + revision = revision or os.environ.get("GITHUB_SHA") or "docs-autogeneration" + manifest = [] + modules = defaultdict(list) + occurrences = defaultdict(int) + for source in sorted(tracked_sources(root)): + relative = source.relative_to(root) + if source.suffix not in (".py", ".pyx", ".pxd") or "tests" in relative.parts or source.name.startswith("test_") or source.name.endswith("_test.py"): + continue + with tokenize.open(source) as stream: + text = stream.read() + module = ".".join(relative.with_suffix("").parts).removesuffix(".__init__") + records = python_docs(text, source) if source.suffix == ".py" else cython_docs(text, source) + if any(part.startswith("_") for part in module.split(".")) and not any(record[1] != "module" for record in records): + records = [] + for name, kind, line, doc, definition in records: + fullname = f"{module}.{name}" if name else module + key = f"{relative.as_posix()}:{kind}:{fullname}" + occurrences[key] += 1 + anchor = "source-api-" + sha256(f"{key}:{occurrences[key]}".encode()).hexdigest()[:20] + entry = { + "fullname": fullname, "kind": kind, "source": relative.as_posix(), "line": line, + "docstring": doc, "docname": f"generated/source_api/{module}", "anchor": anchor, + } + manifest.append(entry) + modules[module].append((entry, name, definition, source.suffix)) + + output.mkdir(parents=True, exist_ok=True) + for stale in output.glob("*.rst"): + if stale.stem not in modules and stale.name != "index.rst": + stale.unlink() + index = ["Developer source API", "====================", "", "Public library and developer interfaces are collected from source without importing optional backends. Private helpers and definitions nested inside functions are excluded.", "", ".. toctree::", " :maxdepth: 1", ""] + for module, records in sorted(modules.items()): + index.append(f" {module}") + page = [module, "=" * len(module), "", f".. py:currentmodule:: {module}", ""] + for entry, name, definition, suffix in records: + page.extend([f".. _{entry['anchor']}:", ""]) + url = f"https://github.com/pele-python/pele/blob/{quote(revision, safe='')}/{quote(entry['source'])}#L{entry['line']}" + if entry["kind"] == "module": + prose = module_prose(entry["docstring"]) + page.extend([f"`Module source <{url}>`__", "", prose, ""]) + continue + doc = render_docstring(entry["docstring"], entry["kind"]) + body = [f"`Source <{url}>`__", "", f".. code-block:: {'python' if suffix == '.py' else 'cython'}", "", textwrap.indent(definition, " "), "", doc] + page.extend([f".. py:{entry['kind']}:: {name}", " :no-index:", "", textwrap.indent("\n".join(body), " "), ""]) + (output / f"{module}.rst").write_text("\n".join(page), encoding="utf-8") + (output / "index.rst").write_text("\n".join(index) + "\n", encoding="utf-8") + (output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + return manifest + + +def setup(app): + app.add_config_value("source_api_revision", None, "env") + + def build_sources(app): + generate(Path(app.srcdir).parent, Path(app.srcdir) / "generated/source_api", app.config.source_api_revision) + + def copy_manifest(app, exception): + if exception is None and app.statuscode == 0 and app.builder.format == "html": + shutil.copyfile(Path(app.srcdir) / "generated/source_api/manifest.json", Path(app.outdir) / "source_api_manifest.json") + + app.connect("builder-inited", build_sources, priority=100) + app.connect("build-finished", copy_manifest) diff --git a/doc/tests/test_check_html.py b/doc/tests/test_check_html.py index af3971625..1a6f702d0 100644 --- a/doc/tests/test_check_html.py +++ b/doc/tests/test_check_html.py @@ -4,10 +4,63 @@ import unittest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from check_html import broken_links, check +from check_html import broken_links, check, coverage_errors, documented_cpp_symbols class CheckHtmlTests(unittest.TestCase): + def test_source_coverage_requires_the_rendered_anchor(self): + with TemporaryDirectory() as directory: + root = Path(directory) + (root / "api.html").write_text('') + entries = [ + {"fullname": "covered", "docname": "api", "anchor": "documented"}, + {"fullname": "omitted", "docname": "api", "anchor": "missing"}, + {"fullname": "outside", "docname": "../api", "anchor": "documented"}, + ] + self.assertEqual(coverage_errors(root, entries, "source API"), [ + "Missing source API target: omitted", "Missing source API page: outside", + ]) + + def test_native_coverage_keeps_developer_apis_and_excludes_private_helpers(self): + with TemporaryDirectory() as directory: + root = Path(directory) + (root / "api.xml").write_text(''' + Example + + + helperPrivate helper. + + + extension_pointDeveloper interface. + + + factoryPublic factory. + + + FLAGFile documentation. + + + undocumented + + + ''') + (root / "file.xml").write_text(''' + implementation.cpp + + local_helperPrivate implementation. + + ''') + (root / "stl.xml").write_text(''' + stdSTL stub. + + ''') + entries = documented_cpp_symbols(root) + self.assertEqual([(item["fullname"], item["docname"], item["anchor"]) for item in entries], [ + ("extension_point", "doxygen/classExample", "adeveloper"), + ("factory", "doxygen/classExample", "astatic"), + ("FLAG", "doxygen/file", "amacro"), + ]) + def test_links_and_anchors(self): with TemporaryDirectory() as directory: root = Path(directory) diff --git a/doc/tests/test_doxygen_references.py b/doc/tests/test_doxygen_references.py index 2032d55d8..1e547fab8 100644 --- a/doc/tests/test_doxygen_references.py +++ b/doc/tests/test_doxygen_references.py @@ -1,4 +1,5 @@ from pathlib import Path +import json from tempfile import TemporaryDirectory from types import SimpleNamespace import sys @@ -23,12 +24,12 @@ def test_missing_breathe_targets_link_to_full_doxygen_reference(self): ''') callbacks = {} app = SimpleNamespace( - connect=lambda event, callback, **kwargs: callbacks.update({event: callback}), + connect=lambda event, callback, **kwargs: callbacks.update({callback.__name__: callback}), builder=SimpleNamespace(format="html", get_target_uri=lambda doc: f"{doc}.html"), ) with patch.dict(conf.breathe_projects, {"pele": directory}): conf.setup(app) - resolve = callbacks["missing-reference"] + resolve = callbacks["doxygen_reference"] for domain, target, expected in [ ("cpp", "Array", "../doxygen/classpele_1_1Array.html"), ("std", "classpele_1_1Array", "../doxygen/classpele_1_1Array.html"), @@ -39,13 +40,43 @@ def test_missing_breathe_targets_link_to_full_doxygen_reference(self): self.assertEqual(result["refuri"], expected) node["reftarget"] = "UnknownType" self.assertIsNone(resolve(app, None, node, nodes.Text("UnknownType"))) - signature = callbacks["autodoc-process-signature"]( + signature = callbacks["dict_method_signature"]( app, "method", "Result.update", dict.update, None, None, None ) self.assertEqual(signature, ("([E, ]**F)", "None")) for method in (dict.clear, dict.copy, dict.items, dict.keys, dict.values): - signature = callbacks["autodoc-process-signature"]( + signature = callbacks["dict_method_signature"]( app, "method", f"Result.{method.__name__}", method, None, "()", "prose describing the result", ) self.assertEqual(signature, ("()", None)) + + def test_source_references_use_context_and_leave_ambiguous_names_unresolved(self): + with TemporaryDirectory() as directory: + root = Path(directory) + (root / "index.xml").write_text("") + output = root / "generated/source_api" + output.mkdir(parents=True) + entries = [ + {"fullname": "pele.example.Type.run", "docname": "api", "anchor": "method"}, + {"fullname": "pele.other.run", "docname": "other", "anchor": "function"}, + ] + (output / "manifest.json").write_text(json.dumps(entries)) + callbacks = {} + app = SimpleNamespace( + srcdir=directory, + connect=lambda event, callback, **kwargs: callbacks.update({callback.__name__: callback}), + builder=SimpleNamespace(format="html", get_relative_uri=lambda source, target: target + ".html"), + ) + with patch.dict(conf.breathe_projects, {"pele": directory}): + conf.setup(app) + resolve = callbacks["source_reference"] + node = {"refdomain": "py", "reftarget": "run", "refdoc": "index", + "py:module": "pele.example", "py:class": "Type"} + result = resolve(app, None, node, nodes.Text("run")) + self.assertEqual(result["refuri"], "api.html#method") + node.update({"py:module": None, "py:class": None}) + self.assertIsNone(resolve(app, None, node, nodes.Text("run"))) + node["reftarget"] = "pele.example.Type.run" + result = resolve(app, None, node, nodes.Text("run")) + self.assertEqual(result["refuri"], "api.html#method") diff --git a/doc/tests/test_source_api.py b/doc/tests/test_source_api.py new file mode 100644 index 000000000..ca7607416 --- /dev/null +++ b/doc/tests/test_source_api.py @@ -0,0 +1,182 @@ +import importlib.util +import json +from io import StringIO +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest +from unittest.mock import patch + +from docutils import nodes +from docutils.core import publish_doctree + + +class SourceApiTests(unittest.TestCase): + def generator(self): + path = Path(__file__).resolve().parents[1] / "sphinxext/source_api.py" + self.assertTrue(path.is_file(), "The source API generator has not been implemented") + spec = importlib.util.spec_from_file_location("source_api", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def test_public_python_apis_in_private_modules_exclude_private_and_local_helpers(self): + generator = self.generator() + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "pele/_example.py" + source.parent.mkdir() + source.write_text('''"""Library overview. + +.. automodule:: unavailable_backend + :members: +""" +import unavailable_backend +raise RuntimeError("Documentation must not import this module") + +class Example: + """A documented class.""" + def __init__(self): + """A documented public constructor.""" + def __call__(self): + """A documented public call protocol.""" + def __getitem__(self, key): + """A documented public indexing protocol.""" + def method(self, x=2): + """A documented public method.""" + def _hidden(self, x=2): + """A documented private method.""" + def __hidden(self): + """A documented name-mangled private method.""" + +class _Private: + """A private class must not be published.""" + def public_name(self): + """A method of a private class must not be published.""" + +def outer(value): + """A documented function.""" + class Nested: + """A documented nested class.""" + def local(): + """A documented nested function.""" +''') + skipped = root / "pele/tests/test_example.py" + skipped.parent.mkdir() + skipped.write_text('def test_example():\n """Do not publish tests."""\n') + (root / "pele/untracked.py").write_text('def hidden():\n """Do not publish untracked code."""\n') + private_only = root / "pele/_private_only.py" + private_only.write_text('"""Private-only module prose."""\ndef _helper():\n """A private helper."""\n') + public_prose = root / "pele/intro.py" + public_prose.write_text('"""Public module prose."""\n') + output = root / "doc/generated/source_api" + with patch.object(generator, "tracked_sources", return_value=[source, skipped, private_only, public_prose]): + manifest = generator.generate(root, output, revision="test-revision") + second = generator.generate(root, output, revision="test-revision") + self.assertEqual(manifest, second) + self.assertEqual({entry["fullname"] for entry in manifest}, { + "pele._example", "pele._example.Example", "pele._example.Example.__init__", + "pele._example.Example.__call__", "pele._example.Example.method", + "pele._example.Example.__getitem__", + "pele._example.outer", "pele.intro", + }) + page = (output / "pele._example.rst").read_text() + self.assertNotIn(".. automodule::", page) + self.assertIn("Library overview.", page) + self.assertIn("A documented public method.", page) + self.assertNotIn("A documented private method.", page) + self.assertNotIn("name-mangled private method", page) + self.assertNotIn("A documented nested", page) + self.assertFalse((output / "pele._private_only.rst").exists()) + self.assertIn("blob/test-revision/pele/_example.py#L", page) + links = "\n".join(line.strip() for line in page.splitlines() if line.lstrip().startswith("`Source <")) + tree = publish_doctree(links, settings_overrides={"warning_stream": StringIO()}) + self.assertFalse([message.astext() for message in tree.findall(nodes.system_message) if message["level"] >= 2]) + for entry in manifest: + symbol_page = output / (entry["docname"].rsplit("/", 1)[-1] + ".rst") + self.assertIn(f".. _{entry['anchor']}:", symbol_page.read_text()) + self.assertEqual(json.loads((output / "manifest.json").read_text()), manifest) + + def test_public_cython_types_in_private_modules_exclude_private_scopes(self): + generator = self.generator() + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "pele/_native.pyx" + source.parent.mkdir() + source.write_text('''import unavailable_backend +cimport unavailable_backend +raise RuntimeError("Do not execute Cython module code") + +cdef class Native: + """A documented extension class.""" + @unknown_decorator + def __cinit__(self, double value=1): + """A documented constructor.""" + cdef double _hidden(self, double value) noexcept: + """A documented native method.""" + cdef double private_method(self, double value) noexcept: + """An implicitly private native method.""" + cpdef double public_method(self, double value) noexcept: + """A documented public native method.""" + +cdef class _HiddenNative: + """A private extension class must not be published.""" + def public_name(self): + """A method of a private extension class must not be published.""" + +cpdef double bridge( + double value, + int count=2, +): + """A documented bridge.""" + class Nested: + """A nested Cython class must not be published.""" + +cdef public double public_export(double value): + """A documented exported native function.""" + +cdef api double api_export(double value): + """A documented Cython API function.""" +''') + declaration = root / "pele/helpers.pxd" + declaration.write_text('''cdef inline double helper(double x) noexcept: + """A documented declaration helper.""" + return x +''') + output = root / "doc/generated/source_api" + with patch.object(generator, "tracked_sources", return_value=[source, declaration]): + manifest = generator.generate(root, output, revision="test-revision") + self.assertEqual({entry["fullname"] for entry in manifest}, { + "pele._native.Native", "pele._native.Native.__cinit__", + "pele._native.Native.public_method", "pele._native.bridge", + "pele._native.public_export", "pele._native.api_export", + }) + page = (output / "pele._native.rst").read_text() + self.assertIn("A documented constructor.", page) + self.assertIn("double value", page) + self.assertIn("noexcept", page) + self.assertNotIn("A documented native method.", page) + self.assertNotIn("An implicitly private native method.", page) + self.assertNotIn("A nested Cython class", page) + self.assertFalse((output / "pele.helpers.rst").exists()) + + def test_unrecognized_docstring_sections_keep_their_content(self): + generator = self.generator() + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "pele/unusual.py" + source.parent.mkdir() + source.write_text('''def unusual(value): + """A documented function. + + Input + ----- + A description that Numpydoc does not recognize as a standard section. + """ +''') + output = root / "doc/generated/source_api" + with patch.object(generator, "tracked_sources", return_value=[source]): + generator.generate(root, output) + self.assertIn("A description that Numpydoc does not recognize", (output / "pele.unusual.rst").read_text()) + +if __name__ == "__main__": + unittest.main() diff --git a/pele/amber/read_amber.py b/pele/amber/read_amber.py index cd161de36..b61968d98 100755 --- a/pele/amber/read_amber.py +++ b/pele/amber/read_amber.py @@ -213,15 +213,16 @@ def group_rotation_file(molecule, params, filename): """ Defines group rotation for bonds between atoms defined in params. This function DOESN'T check whether the atoms are actually bonded at any point (so make sure you're referencing appropriate - atom pairs. + atom pairs). atoms and residues are the atoms and residues lists returned from create_molecule() - params has the format: + params has the format:: - params[(residue, (atom_x, atom_y))] = (rotation_scale, probability) - e.g. params[("ARG", ("CA", "CB"))] = (0.5, 0.2) rotates about the ARG CA-CB bond at most 90 degs each - time, with probability 0.2. + params[(residue, (atom_x, atom_y))] = (rotation_scale, probability) + + e.g. ``params[("ARG", ("CA", "CB"))] = (0.5, 0.2)`` rotates about the ARG CA-CB bond at most 90 degs each + time, with probability 0.2. """ # Loop through the parameters, creating GROUPROTATION information for any bonds described by an individual # parameter. diff --git a/pele/concurrent/_connect_server.py b/pele/concurrent/_connect_server.py index 2906d8598..312651e8e 100644 --- a/pele/concurrent/_connect_server.py +++ b/pele/concurrent/_connect_server.py @@ -245,7 +245,7 @@ class from the ConnectServer by get_system. This only works for pickleable See Also -------- ConnectServer - pele.Basinhopping + pele.basinhopping.BasinHopping """ def __init__(self, uri, system=None, **basinhopping_kwargs): diff --git a/pele/distance/__init__.py b/pele/distance/__init__.py index d1da2cb5c..f689e0015 100644 --- a/pele/distance/__init__.py +++ b/pele/distance/__init__.py @@ -2,7 +2,7 @@ .. currentmodule:: pele.distance Distance measurement (`pele.distance`) -================================= +====================================== """ from .distance_enum import Distance diff --git a/pele/mindist/permutational_alignment.py b/pele/mindist/permutational_alignment.py index 4bb7d7739..4a8894234 100644 --- a/pele/mindist/permutational_alignment.py +++ b/pele/mindist/permutational_alignment.py @@ -88,7 +88,7 @@ def find_permutations_munkres(X1, X2, make_cost_matrix=_make_cost_matrix): another package, hungarian, implements the same routine in comiled C http://pypi.python.org/pypi/hungarian/ When I first downloaded this package I got segfaults. The problem for me - was casing an integer pointer as (npy_intp *). I may add the corrected + was casing an integer pointer as ``(npy_intp *)``. I may add the corrected version to pele at some point """ ######################################### diff --git a/pele/optimize/__init__.py b/pele/optimize/__init__.py index 6193d3352..68a51235f 100644 --- a/pele/optimize/__init__.py +++ b/pele/optimize/__init__.py @@ -9,7 +9,7 @@ is they find the nearest local minimum. For global optimization see :ref:`global optimization `. -In our experience, we have found that the best minizers are lbfgs and fire. +In our experience, we have found that the best minimizers are lbfgs and fire. lbfgs seems to be by far the fastest, however there are some circumstances where the better stability of fire might be useful. These cases include where it is important to clearly define the boundary between basins of @@ -23,11 +23,11 @@ All of the optimizers that are functions in this package have the same form:: - res = optimizer(coords, getEnergyGradient, **kwargs) + res = optimizer(coords, potential, **kwargs) -where `coords` is the starting structure for the minimaztion, getEnergyGradient is a function -which return the energy and gradient, and kwargs are a collection of optional parameters. -The return value is a `pele.optimze.Result` object (similar to `scipy.optimize.Result`), +where ``coords`` is the starting structure, ``potential`` supplies the energy and +gradient, and ``kwargs`` contains optional parameters. +The return value is a :class:`Result` object, which is simply a dictionary where `__getattr__` is a wrapper for `__getitem__`. So the coords can be accessed as res.coords or as res["coords"]. @@ -41,7 +41,7 @@ 1. nsteps : number of iterations #. tol : tolerance criterion for the rms gradient -#. iprint : how often to print status informatation +#. iprint : how often to print status information LBFGS routines -------------- @@ -57,6 +57,22 @@ lbfgs potentially fail with non-Hamiltonian systems. +C++ LBFGS +~~~~~~~~~ +:func:`lbfgs_cpp` constructs :class:`LBFGS_CPP` and returns its result. +The system class uses this implementation for its default local minimizer. + +.. autosummary:: + :toctree: generated/ + + LBFGS_CPP + lbfgs_cpp + +Python and Fortran LBFGS +~~~~~~~~~~~~~~~~~~~~~~~~ +:class:`LBFGS` is the Python implementation; :class:`MYLBFGS` uses a Fortran +step update. Their function wrappers return the optimization result. + .. autosummary:: :toctree: generated/ @@ -64,27 +80,93 @@ lbfgs_py MYLBFGS mylbfgs - lbfgs_scipy -Fire +FIRE ---- + +Python FIRE +~~~~~~~~~~~ +:func:`fire` runs the Python :class:`Fire` implementation. + .. autosummary:: :toctree: generated/ Fire fire +Native modified FIRE +~~~~~~~~~~~~~~~~~~~~ +:func:`modifiedfire_cpp` runs :class:`ModifiedFireCPP`, the Python interface +to the C++ ``pele::MODIFIED_FIRE`` implementation:: + + from pele.optimize import modifiedfire_cpp + result = modifiedfire_cpp(coords, potential) + +To retain the optimizer object between calls, construct +``ModifiedFireCPP(coords, potential, **kwargs)`` and call its ``run()`` method. + +.. autosummary:: + :toctree: generated/ + + ModifiedFireCPP + modifiedfire_cpp + +Native gradient descent +----------------------- +These C++ optimizer classes take the potential before the initial coordinates. +Call ``run()`` on the optimizer to obtain its result. + +.. autosummary:: + :toctree: generated/ + + GradientDescent_CPP + CosineGradientDescent -Other routines ---------------- -most of these are simply wrappers to the -optimizers available in scipy.optimize. -These are not used very often and may be buggy. +CVODE and mixed descent +----------------------- +These native optimizers require a build with SUNDIALS and the ``cvode`` feature +enabled. Their constructors take the potential before the initial coordinates. +:class:`GenericMixedDescent` also takes the non-convex and convex optimizer +objects, a tolerance, and a translation offset. .. autosummary:: :toctree: generated/ + CVODEBDFOptimizer + MXDOptimizer + ExtendedMixedOptimizer + GenericMixedDescent + +Native optimizer options +------------------------ +:class:`StopCriterionType` selects the stopping criterion for native LBFGS, +modified FIRE, and extended mixed descent. :class:`HessianType` selects the +Hessian implementation used by :class:`CVODEBDFOptimizer`. + +.. autosummary:: + :toctree: generated/ + + StopCriterionType + HessianType + +SciPy optimizers +---------------- +These functions wrap the corresponding optimizers in SciPy. + +.. autosummary:: + :toctree: generated/ + + lbfgs_scipy + bfgs_scipy cg + +Python steepest descent +----------------------- +This implementation is intended for testing and is slow. + +.. autosummary:: + :toctree: generated/ + steepest_descent diff --git a/pele/optimize/_fire.py b/pele/optimize/_fire.py index d3edd1517..2d47c1e74 100644 --- a/pele/optimize/_fire.py +++ b/pele/optimize/_fire.py @@ -47,7 +47,7 @@ class Fire: adaptive parameter that controls the velocity used to evolve the system. iprint : int how often to print status information - alternative_stop_criterion : callable + alternate_stop_criterion : callable this criterion will be used rather than rms gradient to determine when to stop the iteration. must return a boolean and accept the following keywords: @@ -55,7 +55,7 @@ class Fire: 2. `gradient` 3. `tol` events : list of callables - these are called after each iteration. Events can also be added using Fire.attachEvent(). + callbacks supplied through the events argument, called after each iteration. Each event must accept keywords: 1. `coords` @@ -165,9 +165,9 @@ def step(self, f): def run(self, fmax=1e-3, steps=100000): """Run structure optimization algorithm. - This method will return when the forces on all individual - atoms are less than *fmax* or when the number of steps exceeds - *steps*. + By default, stop when the RMS gradient is less than ``fmax`` or after + at most ``steps`` iterations. If supplied, ``alternate_stop_criterion`` + determines convergence instead of the RMS gradient. """ self.fmax = fmax step = 0 diff --git a/pele/optimize/_lbfgs_py.py b/pele/optimize/_lbfgs_py.py index f2e799645..6af2865cc 100644 --- a/pele/optimize/_lbfgs_py.py +++ b/pele/optimize/_lbfgs_py.py @@ -81,7 +81,7 @@ class LBFGS: See Also -------- MYLBFGS : this implemented in a compiled language - lbfgs_py : a function wrapper + pele.optimize.lbfgs_py : a function wrapper """ diff --git a/pele/optimize/_modified_fire_cpp.pyx b/pele/optimize/_modified_fire_cpp.pyx index 3559d708a..5053b5449 100644 --- a/pele/optimize/_modified_fire_cpp.pyx +++ b/pele/optimize/_modified_fire_cpp.pyx @@ -119,7 +119,7 @@ cdef class _Cdef_MODIFIED_FIRE_CPP(_pele_opt.GradientOptimizer): return (_Cdef_MODIFIED_FIRE_CPP, (self.x0, self.pot, self.dtstart, self.dtmax, self.maxstep, self.Nmin, self.finc, self.fdec, self.fa, self.astart, self.tol, self.stepback, self.iprint, None, None, self.nsteps, self.verbosity, None)) class ModifiedFireCPP(_Cdef_MODIFIED_FIRE_CPP): - """This class is the python interface for the c++ MODIFED_FIRE implementation. + """Python interface for the C++ MODIFIED_FIRE implementation. """ # def reset(self, coords): diff --git a/pele/optimize/_mxd_end_only.pyx b/pele/optimize/_mxd_end_only.pyx index ba855791f..e6e2bc924 100644 --- a/pele/optimize/_mxd_end_only.pyx +++ b/pele/optimize/_mxd_end_only.pyx @@ -41,7 +41,7 @@ cdef extern from "pele/mxd_end_only.hpp" namespace "pele": cdef class _Cdef_MXDOptimizer_CPP(_pele_opt.GradientOptimizer): - """This class is the python interface for the c++ MXDOptimizer implementation + """Python interface for the C++ MixedDescentEndOnly implementation. Not quite an optimizer in the usual sense. Solves for the trajectory taken by the steepest descent path using the sundials solver CVODE_BDF. the solver terminates when it gets close to a minimum, just like an optimizer @@ -68,5 +68,5 @@ cdef class _Cdef_MXDOptimizer_CPP(_pele_opt.GradientOptimizer): return res class MXDOptimizer_CPP(_Cdef_MXDOptimizer_CPP): - """This class is the python interface for the c++ LBFGS implementation + """Python interface for the C++ MixedDescentEndOnly implementation. """ diff --git a/pele/optimize/_quench.py b/pele/optimize/_quench.py index bc040ad4d..ede9b8d06 100644 --- a/pele/optimize/_quench.py +++ b/pele/optimize/_quench.py @@ -232,6 +232,28 @@ def lbfgs_py(coords, pot, **kwargs): def lbfgs_cpp(coords, pot, **kwargs): + """Minimize a configuration with the C++ LBFGS implementation. + + Parameters + ---------- + coords : array_like + Initial coordinates for the minimization. + pot : object + Potential supplying the energy and gradient. + **kwargs : dict, optional + Additional constructor arguments passed unchanged to + :class:`pele.optimize.LBFGS_CPP`. + + Returns + ------- + result : pele.optimize.Result + The optimizer's ``run()`` result, including final coordinates, energy, + gradient, and convergence status. + + See Also + -------- + pele.optimize.LBFGS_CPP : The native optimizer class. + """ lbfgs = LBFGS_CPP(coords, pot, **kwargs) return lbfgs.run() @@ -242,6 +264,29 @@ def mylbfgs(coords, pot, **kwargs): def modifiedfire_cpp(coords, pot, **kwargs): + """Minimize a configuration with the C++ modified FIRE implementation. + + Parameters + ---------- + coords : array_like + Initial coordinates for the minimization. + pot : object + Potential supplying the energy and gradient. + **kwargs : dict, optional + Additional constructor arguments passed unchanged to + :class:`pele.optimize.ModifiedFireCPP`. + + Returns + ------- + result : pele.optimize.Result + The optimizer's ``run()`` result, including final coordinates, energy, + gradient, and convergence status. + + See Also + -------- + pele.optimize.ModifiedFireCPP : The native optimizer class. + pele.optimize.fire : The Python FIRE wrapper. + """ modifiedfire = ModifiedFireCPP(coords, pot, **kwargs) return modifiedfire.run() @@ -276,24 +321,29 @@ def ode_scipy_naive( **kwargs ): """This uses rk45 in a minimizer like approach to find the ode - The idea is to solve for the path - dx/dt = - \\grad{E} + + The idea is to solve for the path:: + + dx/dt = - \\grad{E} + Parameters ---------- - coords: array + coords : array coords input for the quench - pot: array + pot : array potential class that has a getenergygradient function that we need for stepping - nsteps: int + nsteps : int maximum number of steps - tol: float + tol : float gives a tolerance for making sure that we have found the minima - kwargs: ... + kwargs : ... extra arguments for the integrator - Return - ---------- - result container with steps and function evaluations + + Returns + ------- + result : Result + result container with steps and function evaluations """ class feval_pot: diff --git a/pele/optimize/cvode_opt.pyx b/pele/optimize/cvode_opt.pyx index 6d52481db..798270733 100644 --- a/pele/optimize/cvode_opt.pyx +++ b/pele/optimize/cvode_opt.pyx @@ -85,5 +85,5 @@ cdef class _Cdef_CVODEBDFOptimizer_CPP(_pele_opt.GradientOptimizer): return (_Cdef_CVODEBDFOptimizer_CPP, (self.pot, self.x0, self.tol, self.rtol, self.atol, self.nsteps, self.iterative, self.use_newton_stop_criterion)) class CVODEBDFOptimizer_CPP(_Cdef_CVODEBDFOptimizer_CPP): - """This class is the python interface for the c++ LBFGS implementation - """ \ No newline at end of file + """Python interface for the C++ CVODEBDFOptimizer implementation. + """ diff --git a/pele/optimize/extended_mixed_descent.pyx b/pele/optimize/extended_mixed_descent.pyx index 71fef61d2..f4c38eeaf 100644 --- a/pele/optimize/extended_mixed_descent.pyx +++ b/pele/optimize/extended_mixed_descent.pyx @@ -41,7 +41,7 @@ cdef extern from "pele/extended_mixed_descent.hpp" namespace "pele": int get_n_failed_phase_2_steps() except + cdef class _Cdef_ExtendedMixedOptimizer_CPP(_pele_opt.GradientOptimizer): - """This class is the python interface for the c++ MixedOptimizer implementation + """Python interface for the C++ ExtendedMixedOptimizer implementation. """ cdef _pele.BasePotential pot cdef _pele.BasePotential pot_ext @@ -85,5 +85,5 @@ cdef class _Cdef_ExtendedMixedOptimizer_CPP(_pele_opt.GradientOptimizer): return res class ExtendedMixedOptimizer_CPP(_Cdef_ExtendedMixedOptimizer_CPP): - """This class is the python interface for the c++ LBFGS implementation - """ \ No newline at end of file + """Python interface for the C++ ExtendedMixedOptimizer implementation. + """ diff --git a/pele/optimize/generic_mixed_descent.pyx b/pele/optimize/generic_mixed_descent.pyx index 9ecfaa198..f2ea50007 100644 --- a/pele/optimize/generic_mixed_descent.pyx +++ b/pele/optimize/generic_mixed_descent.pyx @@ -95,5 +95,5 @@ cdef class _Cdef_GenericMixedDescent_CPP(_pele_opt.GradientOptimizer): return res class GenericMixedDescent_CPP(_Cdef_GenericMixedDescent_CPP): - """This class is the python interface for the c++ LBFGS implementation - """ \ No newline at end of file + """Python interface for the C++ GenericMixedDescent implementation. + """ diff --git a/pele/potentials/__init__.py b/pele/potentials/__init__.py index 4ef4e4b3a..b2c3f35d2 100644 --- a/pele/potentials/__init__.py +++ b/pele/potentials/__init__.py @@ -4,51 +4,117 @@ Potentials (`pele.potentials`) ================================= -This module contains all the potentials that are included in pele. -The list is not very long because we have made it very easy for you -to write your own. +This module exports native potential bindings and Python implementations. +The reference below groups the available models and wrappers by purpose. -Base Potential --------------- +Potential interface +------------------- -All potentials are derived from the base class +Python potentials can derive from these base classes. .. autosummary:: :toctree: generated/ BasePotential + ~_pele.PairwisePotentialInterface + BasePotentialAtomistic -When creating your own potential, only member function which must absolutely -be overloaded is getEnergy(). Many routines in pele -also use gradient information, so it is highly recommended to also -implement getEnergyGradient(). Otherwise the gradients will be calculated -numerically and your system will run a lot slower. +When creating a potential, implement ``getEnergy()``. Many routines also use +gradients, so implement ``getEnergyGradient()`` to avoid calculating them +numerically. See :doc:`/tutorial_potential` for a worked example. -pele potentials ------------------ -these are potentials that exist completely within the pele package +Pair potentials +--------------- .. autosummary:: :toctree: generated/ LJ + BLJCut LJCut LJpshift + Frenkel + Morse + HS_WCA + WCA + WCANeighborList + WCAAtomList + +Inverse-power potentials +------------------------ + +.. autosummary:: + :toctree: generated/ + + InversePower + InversePowerHS + InversePowerStillinger + InversePowerStillingerCut + InversePowerStillingerCutQuad + PyInversePower + +Many-body potentials +-------------------- + +.. autosummary:: + :toctree: generated/ + ~ATLJ.ATLJ + ATLJCPP + +Harmonic and Gaussian potentials +-------------------------------- + +.. autosummary:: + :toctree: generated/ + + Harmonic + RadialGaussian + SumGaussianPot + +Spin potentials +--------------- + +.. autosummary:: + :toctree: generated/ + XYModel HeisenbergModel HeisenbergModelRA + MeanFieldPSpinSpherical + +Potential wrappers +------------------ + +.. autosummary:: + :toctree: generated/ + + CombinedPotential + FrozenPotentialWrapper + +Cost and test functions +----------------------- + +.. autosummary:: + :toctree: generated/ + + MLCost + PoweredCosineSum + +External potentials +------------------- -GMIN potentials ---------------- .. autosummary:: :toctree: generated/ GMINPotential -other external potentials -------------------------- -to be written +``LAMMPSPotential`` is available when the optional LAMMPS dependency is +installed. See the `Python adapter source +`_ +and `native adapter source +`_ +for its energy and gradient interface. """ diff --git a/pele/potentials/_fin_sin.py b/pele/potentials/_fin_sin.py index 69832b644..b0cba2b11 100644 --- a/pele/potentials/_fin_sin.py +++ b/pele/potentials/_fin_sin.py @@ -9,16 +9,16 @@ class FinSin(BasePotential): Parameters ---------- - d : float - d is a cutoff for the pseudo-density - A : float - A sets the energy scale for the attractive term - beta : float - beta adjusts the repulsive term at short distance - c : float - c is a cutoff for the pairwise repulsive term + d : float + d is a cutoff for the pseudo-density + A : float + A sets the energy scale for the attractive term + beta : float + beta adjusts the repulsive term at short distance + c : float + c is a cutoff for the pairwise repulsive term c0,c1,c2 : float - Additional fitting parameters + Additional fitting parameters Notes ----- @@ -27,17 +27,17 @@ class FinSin(BasePotential): clusters. Here is a selection of representative parameter values: - [ Phil. Mag. A 50, 45 (1984); ibid. 53, 161 (1986). ] + [ Phil. Mag. A 50, 45 (1984); ibid. 53, 161 (1986). ]:: - Metal: Vanadium Chromium Molybdenum Tungsten + Metal: Vanadium Chromium Molybdenum Tungsten - d= 3.692767 3.915720 4.114825 4.400224 - A= 2.010637 1.453418 1.887117 1.896373 - beta= 0.0 1.8 0.0 0.0 - c= 3.8 2.9 3.25 3.25 - c_0= -0.8816318 29.1429813 43.4475218 47.1346499 - c_1= 1.4907756 -23.3975027 -31.9332978 -33.7665655 - c_2= -0.3976370 4.7578297 6.0804249 6.2541999 + d= 3.692767 3.915720 4.114825 4.400224 + A= 2.010637 1.453418 1.887117 1.896373 + beta= 0.0 1.8 0.0 0.0 + c= 3.8 2.9 3.25 3.25 + c_0= -0.8816318 29.1429813 43.4475218 47.1346499 + c_1= 1.4907756 -23.3975027 -31.9332978 -33.7665655 + c_2= -0.3976370 4.7578297 6.0804249 6.2541999 """ diff --git a/pele/potentials/_frenkel.pyx b/pele/potentials/_frenkel.pyx index 664b99a4d..6164c6d81 100644 --- a/pele/potentials/_frenkel.pyx +++ b/pele/potentials/_frenkel.pyx @@ -31,7 +31,7 @@ cdef extern from "pele/frenkel.hpp" namespace "pele": cdef class Frenkel(_pele.BasePotential): - """define the python interface to the c++ MyPot implementation + """Python interface to the native Frenkel pair potential. """ def __cinit__(self, natoms, sig=1.0, eps=1.0, rcut=2.0, boxvec=None, celllists=False, ncellx_scale=1.0): cdef np.ndarray[double, ndim=1] bv @@ -50,4 +50,4 @@ cdef class Frenkel(_pele.BasePotential): # """define the python interface to the c++ Frenkel implementation # """ # def __cinit__(self, natoms, sig=1.0, eps=1.0, rcut=2.0): -# self.thisptr = _pele.shared_ptr[_pele.cBasePotential](<_pele.cBasePotential*> new cFrenkel(sig, eps, rcut)) \ No newline at end of file +# self.thisptr = _pele.shared_ptr[_pele.cBasePotential](<_pele.cBasePotential*> new cFrenkel(sig, eps, rcut)) diff --git a/pele/potentials/_gupta.py b/pele/potentials/_gupta.py index f4af02bfc..6fb7fdf9c 100644 --- a/pele/potentials/_gupta.py +++ b/pele/potentials/_gupta.py @@ -9,15 +9,15 @@ class gupta(BasePotential): Parameters ---------- - p : float - p is dimensionless, goes inside the repulsive exponential - q : float - q is dimensionless, goes inside the attractive exponential - (i.e. the pseudo-density) - A : float - A is an energy pre-factor for the pairwise repulsion + p : float + p is dimensionless, goes inside the repulsive exponential + q : float + q is dimensionless, goes inside the attractive exponential + (i.e. the pseudo-density) + A : float + A is an energy pre-factor for the pairwise repulsion xi : float - xi is an energy pre-factor for the many-body attraction + xi is an energy pre-factor for the many-body attraction Notes ----- diff --git a/pele/potentials/_inversepower_stillinger_cut_quad.pyx b/pele/potentials/_inversepower_stillinger_cut_quad.pyx index 47cfa9795..938ab7c99 100644 --- a/pele/potentials/_inversepower_stillinger_cut_quad.pyx +++ b/pele/potentials/_inversepower_stillinger_cut_quad.pyx @@ -44,24 +44,36 @@ cdef class InversePowerStillingerCutQuad(_pele.BasePotential): Parameters ---------- - pow : integer + power : integer Exponent value + v0 : float + Energy scale. + + cutoff_factor : float + Cutoff distance as a multiple of the pair interaction length. + radii : np.array List of particles radii ndim : integer Euclidean dimension of simulation box - rcut : float - cutoff distance - boxvec : array Box vector boxl : float In case the box is a cube, the cube length can be given as boxl instead of providing boxvec + + ncellx_scale : float, optional + Cell-list grid scale. + + use_cell_lists : bool, optional + Enable cell lists for periodic systems. + + non_additivity : float, optional + Non-additivity parameter passed to the pair interaction. """ cdef public bool periodic cdef _pele.Array[double] bv_, radii_ @@ -145,4 +157,4 @@ cdef class InversePowerStillingerCutQuad(_pele.BasePotential): self.thisptr = shared_ptr[_pele.cBasePotential](<_pele.cBasePotential*> new cInversePowerStillingerCutQuad[INT3](power, v0, cutoff_factor, radii_, non_additivity)) else: - raise Exception("InversePowerStillinger: illegal input, illegal ndim") \ No newline at end of file + raise Exception("InversePowerStillinger: illegal input, illegal ndim") diff --git a/pele/potentials/_sumgaussianpot_cpp.pyx b/pele/potentials/_sumgaussianpot_cpp.pyx index be5d03564..80c3fa3ce 100644 --- a/pele/potentials/_sumgaussianpot_cpp.pyx +++ b/pele/potentials/_sumgaussianpot_cpp.pyx @@ -48,10 +48,9 @@ cdef class SumGaussianPot(_pele.BasePotential): Parameters ---------- - means : array, (number of gaussians) * (number of degrees of - freedom) + means : array, shape (number of gaussians, number of degrees of freedom) Array of means for gaussian potential sum. - cov : array, (number of gaussians) * (number of degrees of freedom) + cov : array, shape (number of gaussians, number of degrees of freedom) Array of covariance matrix diagonals for gaussian potential sum. """ # The following line is based on the discussion here: diff --git a/pele/potentials/_sutton_chen.py b/pele/potentials/_sutton_chen.py index ce3458d0a..de0725f8c 100644 --- a/pele/potentials/_sutton_chen.py +++ b/pele/potentials/_sutton_chen.py @@ -9,12 +9,12 @@ class SuttonChen(BasePotential): for modelling the surfaces of metal crystals - : - First calculate the potential energy. Choosing SIG=sqrt(2) makes the - unit of length the bulk nearest-neighbour distance. Choosing SIG to - be the bulk lattice constant (=sqrt(2)x nn-distance) gives the original - SC form. Can also choose sigma=eps=1 and then scale the energies by eps - and the distances by sig. + First calculate the potential energy. Choosing SIG=sqrt(2) makes the + unit of length the bulk nearest-neighbour distance. Choosing SIG to + be the bulk lattice constant (=sqrt(2)x nn-distance) gives the original + SC form. Can also choose sigma=eps=1 and then scale the energies by eps + and the distances by sig:: + metal NN MM SCEPS/eV SCC SIG Cu 9 6 0.012382 39.432 Ni 9 6 0.015707 39.432 diff --git a/pele/potentials/atlj.pyx b/pele/potentials/atlj.pyx index 9644af80c..9e7622245 100644 --- a/pele/potentials/atlj.pyx +++ b/pele/potentials/atlj.pyx @@ -24,7 +24,7 @@ cdef extern from "pele/atlj.hpp" namespace "pele": cdef class ATLJ(_pele.BasePotential): - """define the python interface to the c++ LJ implementation + """Python interface to the native ATLJ potential. """ def __cinit__(self, eps=1.0, sig=1.0, Z=2): self.thisptr = shared_ptr[_pele.cBasePotential]( <_pele.cBasePotential*>new cATLJ(sig, eps, Z) ) diff --git a/pele/potentials/combine_potentials.pyx b/pele/potentials/combine_potentials.pyx index f45805579..c85c5e97f 100644 --- a/pele/potentials/combine_potentials.pyx +++ b/pele/potentials/combine_potentials.pyx @@ -19,7 +19,11 @@ cdef extern from "pele/combine_potentials.hpp" namespace "pele": cdef class CombinedPotential(_pele.BasePotential): + """Sum native potentials added with ``add_potential``. + + Create an empty potential, then add each component before evaluating it. + """ def __cinit__(self): self.thisptr = shared_ptr[_pele.cBasePotential](<_pele.cBasePotential*>new cppCombinedPotential()) def add_potential(self, _pele.BasePotential potential): - (self.thisptr.get()).add_potential(potential.thisptr) \ No newline at end of file + (self.thisptr.get()).add_potential(potential.thisptr) diff --git a/pele/potentials/ml.py b/pele/potentials/ml.py index ce302e571..6edfadf9c 100644 --- a/pele/potentials/ml.py +++ b/pele/potentials/ml.py @@ -8,8 +8,7 @@ class MLCost(BasePotential): Cost function to be used for maximum likelihood optimization. Parameters - ----------self.assertLessEqual(confidence_intervalsl[i][0], par) - self.assertLessEqual(par, confidence_intervalsl[i][1]) + ---------- data : array of floats The observed data. log_probf : callable `log_probf(data, parameters)` diff --git a/pele/systems/basesystem.py b/pele/systems/basesystem.py index 7437085ca..666a693e8 100644 --- a/pele/systems/basesystem.py +++ b/pele/systems/basesystem.py @@ -524,7 +524,7 @@ def get_normalmodes(self, coords): See Also -------- - pele.thermodynamics, get_log_product_normalmode_freq, get_metric_tensor + pele.thermodynamics, get_log_product_normalmode_freq, BaseSystem.get_metric_tensor """ mt = self.get_metric_tensor(coords) pot = self.get_potential() diff --git a/pele/utils/rbtools.py b/pele/utils/rbtools.py index 1ef742078..c26416e08 100644 --- a/pele/utils/rbtools.py +++ b/pele/utils/rbtools.py @@ -64,7 +64,7 @@ def __init__(self, nrigid=0, natoms=0, nlattice=0, coords=None): :param natoms: number of single atoms :type natoms: int :param nlattice: number of lattice degrees of freedom - :type nlattice: 0 + :type nlattice: int :param coords: the coordinate array :type coords: ``numpy.ndarray`` """ diff --git a/pyproject.toml b/pyproject.toml index f55e60557..2eb8f6d3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ [project.optional-dependencies] test = ["pytest"] -docs = ["sphinx>=8,<9", "numpydoc>=1.8,<2", "breathe>=4.36,<5"] +docs = ["sphinx>=8,<9", "numpydoc>=1.8,<2", "breathe>=4.36,<5", "cython>=3,<4"] gui = ["pyqt5", "PyOpenGL"] lammps = ["lammps[mpi]"] diff --git a/source/generic_mixed_descent.cpp b/source/generic_mixed_descent.cpp index 07210f753..198491cd8 100644 --- a/source/generic_mixed_descent.cpp +++ b/source/generic_mixed_descent.cpp @@ -1,7 +1,7 @@ /** - * @file mixed_descent_with_fire.cpp + * @file generic_mixed_descent.cpp * @author Praharsh Suryadevara (praharsharmm@gmail.com) - * @brief Implementations for mixed_descent_with_fire.hpp + * @brief Implementations for generic_mixed_descent.hpp * @version 0.1 * @date 2022-06-07 * @@ -127,4 +127,4 @@ void GenericMixedDescent::add_translation_offset_2d(Eigen::MatrixXd &hessian, // add the diagonal elements that were missed by the previous loop hessian.diagonal().array() += offset; } -} // namespace pele \ No newline at end of file +} // namespace pele diff --git a/source/pele/EvaluatorInversePower.hpp b/source/pele/EvaluatorInversePower.hpp index 4742aad45..82befcb3d 100644 --- a/source/pele/EvaluatorInversePower.hpp +++ b/source/pele/EvaluatorInversePower.hpp @@ -13,7 +13,7 @@ #include "hoomd/HOOMDMath.h" #include "hoomd/md/EvaluatorPairLJ.h" -/*! \file EvaluatorPairForceShiftedLJ.h +/*! \file EvaluatorInversePower.hpp \brief Defines the pair evaluator class for LJ potentials \details As the prototypical example of a MD pair potential, this also serves as the primary documentation and base reference for the implementation @@ -155,4 +155,4 @@ class EvaluatorPairForceShiftedLJ { } // end namespace md } // end namespace hoomd_pele -#endif // __PAIR_EVALUATOR_FORCE_SHIFTED_LJ_H__ \ No newline at end of file +#endif // __PAIR_EVALUATOR_FORCE_SHIFTED_LJ_H__ diff --git a/source/pele/base_potential.hpp b/source/pele/base_potential.hpp index afa661a9d..57104633a 100644 --- a/source/pele/base_potential.hpp +++ b/source/pele/base_potential.hpp @@ -16,7 +16,7 @@ extern "C" { namespace pele { -/*** +/** * basic potential interface for native potentials */ class BasePotential { diff --git a/source/pele/generic_mixed_descent.hpp b/source/pele/generic_mixed_descent.hpp index 56e966db6..9948069d3 100644 --- a/source/pele/generic_mixed_descent.hpp +++ b/source/pele/generic_mixed_descent.hpp @@ -1,5 +1,5 @@ /** - * @file mixed_descent_with_fire.cpp + * @file generic_mixed_descent.hpp * @author Praharsh Suryadevara (praharsharmm@gmail.com) * @brief Mixed Descent with Fire Algorithm. TODO: This should be refactored * Into a general mixed descent that can combine any two optimizers. not just diff --git a/source/pele/hessian_translations.hpp b/source/pele/hessian_translations.hpp index b33350326..be2f0a02e 100644 --- a/source/pele/hessian_translations.hpp +++ b/source/pele/hessian_translations.hpp @@ -1,5 +1,5 @@ /** - * @file translation_symmetry_matrices.hpp + * @file hessian_translations.hpp * @author Praharsh Suryadevara * @brief File contains functions that generate translation symmetries and * matrices. From 08c779b8ce814a72a97adf9e415ded04d3413e57 Mon Sep 17 00:00:00 2001 From: Praharsh Suryadevara Date: Sun, 11 Oct 2026 17:55:03 -0500 Subject: [PATCH 06/14] Fix source API headings and Python 3.12 enum documentation --- doc/conf.py | 7 ++++ doc/sphinxext/source_api.py | 5 ++- doc/tests/test_doxygen_references.py | 21 ++++++++++ doc/tests/test_source_api.py | 59 ++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index b6b536830..d5a947852 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -365,6 +365,12 @@ def dict_method_signature(app, what, name, obj, options, signature, return_annot if any(obj is method for method in (dict.clear, dict.copy, dict.items, dict.keys, dict.values)): return signature, None + def int_bytes_docstring(app, what, name, obj, options, lines): + # Python 3.12's inherited int docstrings use an unmatched quote. + if name in ("pele.optimize.HessianType.from_bytes", "pele.optimize.HessianType.to_bytes", + "pele.optimize.StopCriterionType.from_bytes", "pele.optimize.StopCriterionType.to_bytes"): + lines[:] = [line.replace("`sys.byteorder'", "``sys.byteorder``") for line in lines] + source_targets = None def source_reference(app, env, node, contnode): @@ -394,3 +400,4 @@ def source_reference(app, env, node, contnode): app.connect("missing-reference", doxygen_reference) app.connect("missing-reference", source_reference) app.connect("autodoc-process-signature", dict_method_signature, priority=100) + app.connect("autodoc-process-docstring", int_bytes_docstring, priority=100) diff --git a/doc/sphinxext/source_api.py b/doc/sphinxext/source_api.py index 3ec012f6b..df9fc1ce5 100644 --- a/doc/sphinxext/source_api.py +++ b/doc/sphinxext/source_api.py @@ -138,6 +138,7 @@ def module_prose(doc): index += 1 while index < len(lines) and (not lines[index].strip() or len(lines[index]) - len(lines[index].lstrip()) > indent): index += 1 + result.append("") else: result.append(line) index += 1 @@ -192,7 +193,7 @@ def generate(root, output, revision=None): index = ["Developer source API", "====================", "", "Public library and developer interfaces are collected from source without importing optional backends. Private helpers and definitions nested inside functions are excluded.", "", ".. toctree::", " :maxdepth: 1", ""] for module, records in sorted(modules.items()): index.append(f" {module}") - page = [module, "=" * len(module), "", f".. py:currentmodule:: {module}", ""] + page = ["=" * len(module), module, "=" * len(module), "", f".. py:currentmodule:: {module}", ""] for entry, name, definition, suffix in records: page.extend([f".. _{entry['anchor']}:", ""]) url = f"https://github.com/pele-python/pele/blob/{quote(revision, safe='')}/{quote(entry['source'])}#L{entry['line']}" @@ -216,7 +217,7 @@ def build_sources(app): generate(Path(app.srcdir).parent, Path(app.srcdir) / "generated/source_api", app.config.source_api_revision) def copy_manifest(app, exception): - if exception is None and app.statuscode == 0 and app.builder.format == "html": + if exception is None and app.statuscode == 0 and app._warncount == 0 and app.builder.format == "html": shutil.copyfile(Path(app.srcdir) / "generated/source_api/manifest.json", Path(app.outdir) / "source_api_manifest.json") app.connect("builder-inited", build_sources, priority=100) diff --git a/doc/tests/test_doxygen_references.py b/doc/tests/test_doxygen_references.py index 1e547fab8..21c3c9b4c 100644 --- a/doc/tests/test_doxygen_references.py +++ b/doc/tests/test_doxygen_references.py @@ -51,6 +51,27 @@ def test_missing_breathe_targets_link_to_full_doxygen_reference(self): ) self.assertEqual(signature, ("()", None)) + def test_python312_inherited_enum_docstrings_render_byteorder_literal(self): + from io import StringIO + from docutils.core import publish_doctree + with TemporaryDirectory() as directory: + Path(directory, "index.xml").write_text("") + callbacks = {} + app = SimpleNamespace(connect=lambda event, callback, **kwargs: callbacks.update({callback.__name__: callback})) + with patch.dict(conf.breathe_projects, {"pele": directory}): + conf.setup(app) + callback = callbacks["int_bytes_docstring"] + for enum in ("HessianType", "StopCriterionType"): + for method in ("from_bytes", "to_bytes"): + lines = ["To request the native byte order, use `sys.byteorder' as the byte order value."] + callback(app, "method", f"pele.optimize.{enum}.{method}", None, None, lines) + tree = publish_doctree("\n".join(lines), settings_overrides={"warning_stream": StringIO()}) + self.assertFalse(list(tree.findall(nodes.system_message))) + self.assertIn("``sys.byteorder``", lines[0]) + lines = ["Do not change another object's docstring."] + callback(app, "method", "Example.to_bytes", None, None, lines) + self.assertEqual(lines, ["Do not change another object's docstring."]) + def test_source_references_use_context_and_leave_ambiguous_names_unresolved(self): with TemporaryDirectory() as directory: root = Path(directory) diff --git a/doc/tests/test_source_api.py b/doc/tests/test_source_api.py index ca7607416..9adb89eb3 100644 --- a/doc/tests/test_source_api.py +++ b/doc/tests/test_source_api.py @@ -3,6 +3,7 @@ from io import StringIO from pathlib import Path from tempfile import TemporaryDirectory +from types import SimpleNamespace import unittest from unittest.mock import patch @@ -159,6 +160,64 @@ class Nested: self.assertNotIn("A nested Cython class", page) self.assertFalse((output / "pele.helpers.rst").exists()) + def test_module_catalog_removal_preserves_heading_levels_and_spacing(self): + generator = self.generator() + doc = """Utilities ++++++++++ +Helper functions +================ + +Rotations +--------- +.. automodule:: unavailable_backend + :members: + +Benchmarking +------------ +.. autosummary:: + :toctree: generated/ + + unavailable_backend.Function +""" + prose = generator.module_prose(doc) + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "pele/utils.py" + source.parent.mkdir() + source.write_text(repr(doc) + "\n") + output = root / "doc/generated/source_api" + with patch.object(generator, "tracked_sources", return_value=[source]): + generator.generate(root, output) + page = (output / "pele.utils.rst").read_text() + page = page.replace(".. py:currentmodule:: pele.utils", "") + tree = publish_doctree(page, settings_overrides={"warning_stream": StringIO()}) + self.assertFalse(list(tree.findall(nodes.system_message))) + self.assertIn("\n\nBenchmarking", prose) + self.assertNotIn("unavailable_backend", prose) + + def test_manifest_is_not_copied_when_strict_build_has_warnings(self): + generator = self.generator() + with TemporaryDirectory() as directory: + root = Path(directory) + source = root / "generated/source_api" + source.mkdir(parents=True) + (source / "manifest.json").write_text("[]\n") + output = root / "html" + output.mkdir() + callbacks = {} + app = SimpleNamespace( + srcdir=root, outdir=output, statuscode=0, _warncount=1, + builder=SimpleNamespace(format="html"), + add_config_value=lambda *args: None, + connect=lambda event, callback, **kwargs: callbacks.update({event: callback}), + ) + generator.setup(app) + callbacks["build-finished"](app, None) + self.assertFalse((output / "source_api_manifest.json").exists()) + app._warncount = 0 + callbacks["build-finished"](app, None) + self.assertEqual((output / "source_api_manifest.json").read_text(), "[]\n") + def test_unrecognized_docstring_sections_keep_their_content(self): generator = self.generator() with TemporaryDirectory() as directory: From c203e7d3e372fcac8530cbfc9b4ae85769d75c9e Mon Sep 17 00:00:00 2001 From: Praharsh Suryadevara Date: Sun, 11 Oct 2026 18:04:17 -0500 Subject: [PATCH 07/14] Link the Doxygen reference in every documentation sidebar --- doc/README.md | 3 ++- doc/_templates/api_navigation.html | 5 +++++ doc/conf.py | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 doc/_templates/api_navigation.html diff --git a/doc/README.md b/doc/README.md index 53d43c29e..24860b2fe 100644 --- a/doc/README.md +++ b/doc/README.md @@ -21,7 +21,8 @@ C++ sources into `doc/doxygen/_build/xml` and a full HTML reference. Sphinx's Breathe extension reads that XML and renders the core interfaces in `cpp_reference.rst`, with Sphinx search, navigation, and C++ reference targets. The complete Doxygen HTML is copied under `doc/_build/html/doxygen`; its -navigation links back to Sphinx. References to types outside the selected +navigation links back to Sphinx. Every Sphinx sidebar links directly to the +Doxygen reference and the integrated C++ API. References to types outside the selected Breathe classes resolve to that complete reference using Doxygen's XML index. For Python, Sphinx autodoc imports the modules and reads their docstrings. diff --git a/doc/_templates/api_navigation.html b/doc/_templates/api_navigation.html new file mode 100644 index 000000000..851a37349 --- /dev/null +++ b/doc/_templates/api_navigation.html @@ -0,0 +1,5 @@ +

Developer reference

+ diff --git a/doc/conf.py b/doc/conf.py index d5a947852..4bd1c7502 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -154,7 +154,9 @@ # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -# html_sidebars = {} +html_sidebars = {"**": [ + "api_navigation.html", "localtoc.html", "relations.html", "sourcelink.html", "searchbox.html", +]} # Additional templates that should be rendered to pages, maps page names to # template names. From 17ab7d1cc7a0eb6e264258ca942ae1c07a52dda5 Mon Sep 17 00:00:00 2001 From: Praharsh Suryadevara Date: Sun, 11 Oct 2026 18:48:00 -0500 Subject: [PATCH 08/14] Render the complete native API in Sphinx with Python and C++ navigation --- .github/workflows/docs.yml | 2 +- doc/Makefile | 11 +- doc/README.md | 29 ++- doc/_templates/api_navigation.html | 21 +- doc/check_html.py | 61 ++++-- doc/conf.py | 80 +++++-- doc/cpp_reference.rst | 111 ---------- doc/doxygen/Doxyfile | 2 +- doc/index.rst | 2 +- doc/sphinxext/cpp_api.py | 314 +++++++++++++++++++++++++++ doc/sphinxext/cpp_navigation.py | 62 ++++++ doc/tests/test_check_html.py | 45 +++- doc/tests/test_cpp_api.py | 225 +++++++++++++++++++ doc/tests/test_cpp_navigation.py | 107 +++++++++ doc/tests/test_doxygen_references.py | 20 +- pyproject.toml | 2 +- 16 files changed, 902 insertions(+), 192 deletions(-) delete mode 100644 doc/cpp_reference.rst create mode 100644 doc/sphinxext/cpp_api.py create mode 100644 doc/sphinxext/cpp_navigation.py create mode 100644 doc/tests/test_cpp_api.py create mode 100644 doc/tests/test_cpp_navigation.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0c46c14fd..a99165423 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -45,7 +45,7 @@ jobs: conda create -y -n pele-docs --override-channels -c conda-forge \ python=3.12 pip sundials eigen blas-devel pkg-config sudo apt-get update - sudo apt-get install -y gcc g++ gfortran doxygen rsync libglu1-mesa freeglut3-dev + sudo apt-get install -y gcc g++ gfortran doxygen libglu1-mesa freeglut3-dev - name: Install current package and documentation tools run: | diff --git a/doc/Makefile b/doc/Makefile index 3a4b32fc9..2498c1ba5 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -12,7 +12,7 @@ PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -.PHONY: help clean clean_sphinx clean_doxygen html sphinx_html doxygen_html combine_sphinx_doxygen check dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest +.PHONY: help clean clean_sphinx clean_doxygen html sphinx_html doxygen_xml check dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest help: @echo "Please use \`make ' where is one of" @@ -47,19 +47,16 @@ sphinx_html: @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." -doxygen_html: +doxygen_xml: cd doxygen && doxygen -combine_sphinx_doxygen: sphinx_html doxygen_html - rsync -av doxygen/_build/html/ $(BUILDDIR)/html/doxygen/ - -html: combine_sphinx_doxygen +html: sphinx_html check: html python check_html.py $(BUILDDIR)/html # Breathe needs fresh Doxygen XML before any Sphinx builder reads the sources. -sphinx_html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest: doxygen_html +sphinx_html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest: doxygen_xml dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml diff --git a/doc/README.md b/doc/README.md index 24860b2fe..83d241de2 100644 --- a/doc/README.md +++ b/doc/README.md @@ -6,7 +6,7 @@ package, including compiled extensions; it does not mock pele. Install the docs and GUI extras and Doxygen. On Ubuntu the OpenGL imports also need GLU and GLUT: ```sh -sudo apt-get install doxygen rsync libglu1-mesa freeglut3-dev +sudo apt-get install doxygen libglu1-mesa freeglut3-dev python -m pip install '.[docs,gui]' \ -Csetup-args=-Dgui=enabled -Csetup-args=-Dlammps=disabled -Ccompile-args=-j1 OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MPLBACKEND=Agg QT_QPA_PLATFORM=offscreen \ @@ -17,13 +17,21 @@ python -I -m unittest discover -s doc/tests `doc/Makefile` runs Doxygen first using `doc/doxygen/Doxyfile`. It extracts the -C++ sources into `doc/doxygen/_build/xml` and a full HTML reference. Sphinx's -Breathe extension reads that XML and renders the core interfaces in -`cpp_reference.rst`, with Sphinx search, navigation, and C++ reference targets. -The complete Doxygen HTML is copied under `doc/_build/html/doxygen`; its -navigation links back to Sphinx. Every Sphinx sidebar links directly to the -Doxygen reference and the integrated C++ API. References to types outside the selected -Breathe classes resolve to that complete reference using Doxygen's XML index. +native source comments into `doc/doxygen/_build/xml`. Exhale builds the complete +C++ class, namespace, function, and file hierarchy from that XML; Breathe renders +those entries as Sphinx pages under `generated/cpp`. The C++ reference shares the +Python site's theme, navigation, search, and signature formatting. Every sidebar +provides a dedicated C++ navigation section for optimizers, potentials, classes, +namespaces, functions, and source files. Optimizer and potential topics are +collected from the native inheritance hierarchy and link to each canonical API +page. There is one generated C++ reference, with no separately published +Doxygen HTML site or manually selected duplicate class pages. + +Generated XML is filtered before import so private class members, underscore +helpers, and file-local static helpers remain excluded. Public static class +methods and protected developer interfaces remain available. File and namespace +descriptions retain their coverage targets, and Doxygen group descriptions are +included without repeating their member definitions. For Python, Sphinx autodoc imports the modules and reads their docstrings. Autosummary follows the explicit catalogs in the `.rst` pages and package @@ -44,9 +52,10 @@ The version comes from `pyproject.toml`. Generated sources, Doxygen output, and final HTML are ignored by Git; edit their inputs instead. `make -C doc check` builds the combined site, verifies representative Python -pages and actual Breathe C++ targets, then checks local HTML links, anchors, +pages and the complete generated C++ reference, then checks local HTML links, anchors, images, scripts, and stylesheets. Coverage manifests verify that source -docstrings and documented Doxygen symbols have rendered targets. The native +docstrings and documented Doxygen symbols each have one rendered target in the +Sphinx site. Missing or duplicate C++ definitions fail the check. The native reference includes public and protected developer interfaces and file-level functions, including `SimplePairwisePotential` and `CellListPotential`. Private class members and file-local helpers are excluded. Its inputs are the library's diff --git a/doc/_templates/api_navigation.html b/doc/_templates/api_navigation.html index 851a37349..5398bc349 100644 --- a/doc/_templates/api_navigation.html +++ b/doc/_templates/api_navigation.html @@ -1,5 +1,20 @@ -

Developer reference

+

Python

    -
  • C++ API in Sphinx
  • -
  • Doxygen reference
  • +{% for label, docname in [ + ('Installation', 'installation'), ('Getting started', 'getting_started'), + ('Optimizers', 'quenching'), ('Potentials', 'potentials'), + ('Systems', 'system_class'), ('Landscape', 'landscape_module'), + ('Database', 'database'), ('Utilities', 'utils'), + ('Source API', 'generated/source_api/index') +] %} +
  • {{ label }}
  • +{% endfor %}
+{% if cpp_navigation %} +

C++

+ +{% endif %} diff --git a/doc/check_html.py b/doc/check_html.py index 292a289a1..88219aafa 100644 --- a/doc/check_html.py +++ b/doc/check_html.py @@ -1,4 +1,4 @@ -"""Check generated APIs and local links in the combined Sphinx/Doxygen site.""" +"""Check generated Python/Cython/C++ APIs and local links in the Sphinx site.""" from html.parser import HTMLParser import json @@ -69,6 +69,30 @@ def coverage_errors(root, entries, label): return errors +def sphinx_cpp_targets(root, entries): + root = root.resolve() + targets = {} + for path in sorted((root / "generated/cpp").rglob("*.html")): + for anchor in Page(path.read_text(encoding="utf-8")).anchors: + targets.setdefault(anchor, []).append(path) + rendered = [] + errors = [] + for entry in entries: + refid = entry["refid"] + matches = targets.get(refid, []) + if not matches: + errors.append(f"Missing Sphinx C++ API target: {entry['fullname']} ({refid})") + elif len(matches) > 1: + pages = ", ".join(path.relative_to(root).as_posix() for path in matches) + errors.append(f"Duplicate Sphinx C++ API target: {entry['fullname']} ({refid}): {pages}") + else: + rendered.append({ + **entry, "docname": matches[0].relative_to(root).with_suffix("").as_posix(), + "anchor": refid, + }) + return rendered, errors + + def documented_cpp_symbols(xml_dir): entries = {} for path in sorted(xml_dir.glob("*.xml")): @@ -95,19 +119,17 @@ def documented_cpp_symbols(xml_dir): for name in ("briefdescription", "detaileddescription", "inbodydescription")): continue identifier = node.get("id") - owner, _, anchor = identifier.rpartition("_1") if node is not compound else (identifier, "", "") entries[identifier] = { "fullname": node.findtext("definition") or node.findtext("compoundname") or node.findtext("name"), "kind": node.get("kind", "enumvalue"), - "docname": "doxygen/" + owner, - "anchor": anchor, + "refid": identifier, } return list(entries.values()) def check(root): required = [ - "index.html", "installation.html", "getting_started.html", "cpp_reference.html", + "index.html", "installation.html", "getting_started.html", "generated/cpp/index.html", "generated/pele.basinhopping.BasinHopping.html", "generated/pele.optimize.Fire.html", "generated/pele.optimize.ModifiedFireCPP.html", "generated/pele.optimize.modifiedfire_cpp.html", "generated/pele.optimize.LBFGS_CPP.html", @@ -116,21 +138,10 @@ def check(root): "generated/pele.potentials.LJ.html", "generated/pele.potentials.HS_WCA.html", "generated/pele.potentials.InversePower.html", "generated/pele.potentials.WCA.html", "generated/pele.storage.Database.html", - "doxygen/namespacepele.html", "doxygen/annotated.html", - "doxygen/classpele_1_1CVODEBDFOptimizer.html", - "doxygen/classpele_1_1GenericMixedDescent.html", - "doxygen/classpele_1_1SimplePairwisePotential.html", - "doxygen/classpele_1_1CellListPotential.html", "generated/pele.potentials._pele.PairwisePotentialInterface.html", - "generated/source_api/index.html", "doxygen/files.html", "doxygen/globals_func.html", + "generated/source_api/index.html", ] errors = [f"Missing generated page: {path}" for path in required if not (root / path).is_file()] - cpp = root / "cpp_reference.html" - if cpp.is_file(): - anchors = Page(cpp.read_text(encoding="utf-8")).anchors - for name in ("BasePotential", "PairwisePotentialInterface", "SimplePairwisePotential", "CellListPotential", "LBFGS", "MODIFIED_FIRE", "CVODEBDFOptimizer", "GenericMixedDescent", "get_energy_gradient"): - if not any(anchor.startswith("_CPPv") and name in anchor for anchor in anchors): - errors.append(f"Missing Breathe C++ API target: {name}") source_manifest = root / "source_api_manifest.json" if source_manifest.is_file(): entries = json.loads(source_manifest.read_text(encoding="utf-8")) @@ -140,15 +151,25 @@ def check(root): errors.append("Missing source API coverage manifest") xml_dir = Path(__file__).parent / "doxygen/_build/xml" cpp_entries = documented_cpp_symbols(xml_dir) + rendered_cpp = [] if cpp_entries: - errors.extend(coverage_errors(root, cpp_entries, "Doxygen API")) - (root / "doxygen_api_manifest.json").write_text(json.dumps(cpp_entries, indent=2) + "\n", encoding="utf-8") - print(f"Checked {len(cpp_entries)} documented native source targets") + documented_refids = {entry["refid"] for entry in cpp_entries} + required_classes = [ + {"fullname": f"pele::{name}", "kind": "class", "refid": f"classpele_1_1{name}"} + for name in ("BasePotential", "PairwisePotentialInterface", "SimplePairwisePotential", "CellListPotential", "LBFGS", "MODIFIED_FIRE", "CVODEBDFOptimizer", "GenericMixedDescent") + if f"classpele_1_1{name}" not in documented_refids + ] + rendered, target_errors = sphinx_cpp_targets(root, cpp_entries + required_classes) + errors.extend(target_errors) + errors.extend(coverage_errors(root, rendered, "Sphinx C++ API")) + rendered_cpp = [entry for entry in rendered if entry["refid"] in documented_refids] else: errors.append("Missing documented Doxygen APIs") errors.extend(broken_links(root)) if errors: raise SystemExit("\n".join(errors)) + (root / "doxygen_api_manifest.json").write_text(json.dumps(rendered_cpp, indent=2) + "\n", encoding="utf-8") + print(f"Checked {len(rendered_cpp)} documented native source targets") print(f"Generated APIs and local links checked in {root}") diff --git a/doc/conf.py b/doc/conf.py index 4bd1c7502..19246e325 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -41,14 +41,31 @@ "sphinx.ext.autosummary", "numpydoc", "breathe", + "cpp_api", + "exhale", + "cpp_navigation", "source_api", ] breathe_projects = {"pele": "doxygen/_build/xml"} breathe_default_project = "pele" - -# Library typedefs have no reference page in pele. -nitpick_ignore = [("cpp:identifier", name) for name in ("size_t", "uint8_t", "xsum_small_accumulator", "NULL")] +breathe_domain_by_extension = {"h": "c", "c": "c"} +exhale_args = { + "containmentFolder": "./generated/cpp", + "rootFileName": "index.rst", + "rootFileTitle": "C++ API reference", + "doxygenStripFromPath": "../source", + "exhaleExecutesDoxygen": False, + "createTreeView": False, + "fullToctreeMaxDepth": 1, + "afterTitleDescription": "Native optimizers, potentials, and developer interfaces generated from pele's Doxygen comments. Public and protected interfaces are included; private helpers are excluded.", +} + +cpp_maximum_signature_line_length = 88 +c_maximum_signature_line_length = 88 + +# External typedefs and macros have no reference page in pele. +nitpick_ignore = [("cpp:identifier", name) for name in ("size_t", "uint8_t", "NULL")] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -320,44 +337,59 @@ def linkcode_resolve(domain, info): def setup(app): - # References outside the selected Breathe classes use the complete Doxygen site. + # Doxygen references resolve to each declaration in the generated Sphinx API. from docutils import nodes import posixpath import re xml_dir = Path(__file__).parent / breathe_projects["pele"] - index = ET.parse(xml_dir / "index.xml").getroot() - targets = {} - names = {} - for compound in index.findall("compound"): - refid = compound.get("refid") - target = f"doxygen/{refid}.html" - targets[refid] = target - name = compound.findtext("name") - names[name] = target - for member in compound.findall("member"): - member_id = member.get("refid") - target = f"doxygen/{refid}.html#{member_id.rsplit('_1', 1)[-1]}" - targets[member_id] = target - names[f"{name}::{member.findtext('name')}"] = target - if member.get("kind") == "enumvalue": - for enum in compound.findall("member[@kind='enum']"): - if member_id.startswith(enum.get("refid")): - names[f"{name}::{enum.findtext('name')}::{member.findtext('name')}"] = target + targets = None + names = None def doxygen_reference(app, env, node, contnode): + nonlocal targets, names if app.builder.format != "html": return None + if targets is None: + targets, names = {}, {} + index = ET.parse(xml_dir / "index.xml").getroot() + for compound in index.findall("compound"): + refid = compound.get("refid") + name = compound.findtext("name") + targets[refid] = (refid, refid) + names[name] = targets[refid] + for member in compound.findall("member"): + member_id = member.get("refid") + owner = refid + if member.get("kind") == "enumvalue": + for enum in compound.findall("member[@kind='enum']"): + if member_id.startswith(enum.get("refid")): + owner = enum.get("refid") + names[f"{name}::{enum.findtext('name')}::{member.findtext('name')}"] = (member_id, owner) + targets[member_id] = (member_id, owner) + names[f"{name}::{member.findtext('name')}"] = targets[member_id] + if compound.get("kind") == "file": + names[member.findtext("name")] = targets[member_id] target = None if node.get("refdomain") == "std" and node.get("reftype") == "ref": target = targets.get(node["reftarget"]) - elif node.get("refdomain") == "cpp": + elif node.get("refdomain") in ("cpp", "c"): name = re.sub(r"<.*>", "", node["reftarget"]) target = names.get(name) or names.get(f"pele::{name}") if target is None: return None + refid, owner = target + api = app.exhale_root.node_by_refid + declaration = api.get(refid) + while declaration is not None and not declaration.file_name: + declaration = declaration.parent + if declaration is None: + declaration = api.get(owner) + if declaration is None or not declaration.file_name: + return None + page = (Path(app.exhale_root.root_directory) / declaration.file_name).relative_to(app.srcdir).with_suffix("").as_posix() source = app.builder.get_target_uri(node["refdoc"]) - uri = posixpath.relpath(target, posixpath.dirname(source) or ".") + uri = posixpath.relpath(page + ".html", posixpath.dirname(source) or ".") + "#" + refid return nodes.reference("", "", contnode, refuri=uri) def dict_method_signature(app, what, name, obj, options, signature, return_annotation): diff --git a/doc/cpp_reference.rst b/doc/cpp_reference.rst deleted file mode 100644 index 5a19cc1c8..000000000 --- a/doc/cpp_reference.rst +++ /dev/null @@ -1,111 +0,0 @@ -C++ reference -============= - -The C++ API reference is generated from pele's source files with Doxygen. -Breathe renders the interfaces below in Sphinx so they are searchable alongside -the Python API. The complete Doxygen reference includes public and protected -developer interfaces and functions. Private class members and file-local -helpers are excluded: - -* `Class index `_ -* `pele namespace and functions `_ -* `All file-level functions `_ -* `Source files `_ - -Run ``make -C doc html`` to build the Python and C++ documentation together. -The combined site is written to ``doc/_build/html/``, with the C++ reference -under ``doxygen/``. Its navigation links back to the Python documentation. - -Potential interface -------------------- - -.. doxygenclass:: pele::BasePotential - :members: - :undoc-members: - -LBFGS minimizer ---------------- - -.. doxygenclass:: pele::LBFGS - :members: - :undoc-members: - -FIRE minimizer --------------- - -The native modified FIRE implementation is exposed in Python as -:class:`pele.optimize.ModifiedFireCPP` and -:func:`pele.optimize.modifiedfire_cpp`. The Python implementation is -:class:`pele.optimize.Fire`, with the convenience function -:func:`pele.optimize.fire`. - -.. doxygenclass:: pele::MODIFIED_FIRE - :members: - :undoc-members: - -CVODE minimizer ---------------- - -.. doxygenclass:: pele::CVODEBDFOptimizer - :members: - :undoc-members: - -Mixed descent -------------- - -.. doxygenclass:: pele::GenericMixedDescent - :members: - :undoc-members: - -Other optimizer interfaces --------------------------- - -The complete Doxygen reference also documents these native optimizers: - -* :cpp:class:`pele::GradientDescent` -* :cpp:class:`pele::CosineGradientDescent` -* :cpp:class:`pele::ExtendedMixedOptimizer` -* :cpp:class:`pele::MixedDescentEndOnly` - -Potential interfaces --------------------- - -Python constructors and methods are listed in :ref:`potentials_description`. - -Pairwise potential interface -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. doxygenclass:: pele::PairwisePotentialInterface - :members: - :undoc-members: - -Pairwise implementation interfaces -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Developers implementing pairwise potentials can use the direct pair loop or -the cell-list implementation. These public interfaces support the concrete -potential classes. - -.. doxygenclass:: pele::SimplePairwisePotential - :members: - :undoc-members: - :protected-members: - -.. doxygenclass:: pele::CellListPotential - :members: - :undoc-members: - -Potential implementations -~~~~~~~~~~~~~~~~~~~~~~~~~ - -The corresponding native interfaces, including their periodic and cell-list -variants, are available in the complete Doxygen reference: - -* :cpp:class:`pele::LJ` and :cpp:class:`pele::LJCut` -* :cpp:class:`pele::HS_WCA` and :cpp:class:`pele::WCA` -* :cpp:class:`pele::InversePower` and :cpp:class:`pele::InversePowerHS` -* :cpp:class:`pele::InversePowerStillinger`, - :cpp:class:`pele::InversePowerStillingerCut`, and - :cpp:class:`pele::InversePowerStillingerCutQuad` -* :cpp:class:`pele::Frenkel` and :cpp:class:`pele::Morse` -* :cpp:class:`pele::Harmonic` and :cpp:class:`pele::RadialGaussian` diff --git a/doc/doxygen/Doxyfile b/doc/doxygen/Doxyfile index f808cb049..1d37ecc2c 100644 --- a/doc/doxygen/Doxyfile +++ b/doc/doxygen/Doxyfile @@ -848,7 +848,7 @@ IGNORE_PREFIX = # If the GENERATE_HTML tag is set to YES (the default) Doxygen will # generate HTML output. -GENERATE_HTML = YES +GENERATE_HTML = NO # The HTML_OUTPUT tag is used to specify where the HTML docs will be put. # If a relative path is entered the value of OUTPUT_DIRECTORY will be diff --git a/doc/index.rst b/doc/index.rst index 6e5895ab5..8513652cc 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -107,7 +107,7 @@ C++ documentation .. toctree:: :maxdepth: 1 - cpp_reference + generated/cpp/index Indices and tables ================== diff --git a/doc/sphinxext/cpp_api.py b/doc/sphinxext/cpp_api.py new file mode 100644 index 000000000..42ae4587d --- /dev/null +++ b/doc/sphinxext/cpp_api.py @@ -0,0 +1,314 @@ +"""Keep the complete Exhale reference public and provide exact Doxygen anchors.""" + +from copy import deepcopy +from html import escape +from pathlib import Path +import xml.etree.ElementTree as ET + + +def _private_name(name): + return any(part.strip().startswith("_") for part in name.split("::")) + + +def _normalize_paths(trees, project_root): + project_root = Path(project_root).resolve() + + def relative(value): + path = Path(value) + if path.is_absolute() and path.is_relative_to(project_root): + return path.relative_to(project_root).as_posix() + return value + + changed = set() + for path, tree in trees.items(): + for node in tree.getroot().iter(): + if node.tag == "location": + for attribute in ("file", "bodyfile", "declfile"): + value = node.get(attribute) + if value and relative(value) != value: + node.set(attribute, relative(value)) + changed.add(path) + elif node.tag in ("compoundname", "innerdir", "innerfile", "name") and node.text: + value = node.text.strip() + if relative(value) != value: + node.text = relative(value) + changed.add(path) + return changed + + +def _text(node): + return " ".join("".join(node.itertext()).split()) if node is not None else "" + + +def _description_key(node): + return (node.tag, tuple(sorted(node.attrib.items())), " ".join((node.text or "").split()), + tuple(_description_key(child) for child in node)) + + +def _merge_descriptions(destination, source): + for tag in ("briefdescription", "detaileddescription", "inbodydescription"): + description = source.find(tag) + if description is None or not _text(description): + continue + existing = destination.find(tag) + if existing is None: + destination.append(deepcopy(description)) + continue + keys = {_description_key(child) for child in existing} + for child in description: + key = _description_key(child) + if key not in keys: + existing.append(deepcopy(child)) + keys.add(key) + + +def _deduplicate_functions(trees, excluded_files): + """Join a global declaration and definition only when they share one body.""" + groups = {} + for path, tree in trees.items(): + if path in excluded_files: + continue + compound = tree.getroot().find("compounddef") + if compound is None or compound.get("kind") not in ("file", "namespace"): + continue + for member in compound.findall(".//memberdef"): + location = member.find("location") + if (member.get("kind") != "function" or member.get("static") == "yes" + or member.get("prot") == "private" or location is None + or not all(location.get(field) for field in ("bodyfile", "bodystart", "bodyend"))): + continue + signature = ( + member.findtext("qualifiedname") or member.findtext("name"), _text(member.find("type")), + tuple(_text(param.find("type")) for param in member.findall("param")), + tuple((_text(param.find("type")), _text(param.find("defval"))) + for param in member.findall("templateparamlist/param")), + tuple(location.get(field) for field in ("bodyfile", "bodystart", "bodyend")), + ) + groups.setdefault(signature, []).append((path, member)) + aliases = {} + changed = set() + for records in groups.values(): + if len({member.get("id") for _, member in records}) < 2: + continue + + def preference(record): + location = record[1].find("location") + header = Path(location.get("file", "")).suffix.lower() in (".h", ".hh", ".hpp", ".hxx", ".h++", ".inl", ".ipp") + documented = any(_text(record[1].find(tag)) for tag in ("briefdescription", "detaileddescription", "inbodydescription")) + return (header, bool(location.get("declfile")), documented, record[1].get("id")) + + _, canonical = max(records, key=preference) + canonical_id = canonical.get("id") + for _, member in records: + if member.get("id") != canonical_id: + aliases[member.get("id")] = canonical_id + for path, member in records: + if member.get("id") == canonical_id: + for _, source in records: + _merge_descriptions(member, source) + changed.add(path) + + def remap(parent): + modified = False + for child in list(parent): + if ((child.tag == "memberdef" and child.get("id") in aliases) + or (child.tag == "member" and child.get("refid") in aliases)): + parent.remove(child) + modified = True + continue + for attribute in ("id", "refid"): + identifier = child.get(attribute) + if identifier in aliases: + child.set(attribute, aliases[identifier]) + modified = True + modified = remap(child) or modified + return modified + + if aliases: + for path, tree in trees.items(): + if path in excluded_files: + continue + if remap(tree.getroot()): + changed.add(path) + return changed + + +def _deduplicate_aliases(trees): + """Doxygen may emit an unqualified alias for the same qualified class.""" + classes = [] + location_fields = ("file", "line", "column", "bodyfile", "bodystart", "bodyend") + for path, tree in trees.items(): + compound = tree.getroot().find("compounddef") + if compound is None or compound.get("kind") not in ("class", "struct", "union"): + continue + location = compound.find("location") + if location is None or not all(location.get(field) for field in location_fields): + continue + refid = compound.get("id") + members = tuple(sorted( + (member.get("kind"), member.get("id", "").removeprefix(refid), + member.findtext("definition", "").strip(), member.findtext("argsstring", "")) + for member in compound.findall(".//memberdef"))) + # Location alone cannot distinguish a template from its instantiations. + if not members or any(not member[2] for member in members): + continue + signature = (compound.get("kind"), tuple(location.get(field) for field in location_fields), members) + classes.append((path, refid, compound.findtext("compoundname", ""), signature)) + aliases = {} + removed_files = set() + for path, refid, name, signature in classes: + if "::" in name: + continue + qualified = [(other_id, other_name) for _, other_id, other_name, other_signature in classes + if "::" in other_name and other_name.rsplit("::", 1)[-1] == name + and other_signature == signature] + if qualified: + canonical, _ = max(qualified, key=lambda item: (item[1].count("::"), item[1])) + aliases[refid] = canonical + removed_files.add(path) + + def canonical_id(identifier): + for alias, canonical in aliases.items(): + if identifier == alias or identifier.startswith(alias + "_1"): + return canonical + identifier[len(alias):] + return identifier + + changed_files = set() + for path, tree in trees.items(): + if path in removed_files: + continue + root = tree.getroot() + # Remove the alias entry before remapping, otherwise index.xml has two copies. + for compound in list(root.findall("compound")): + if compound.get("refid") in aliases: + root.remove(compound) + changed_files.add(path) + for node in root.iter(): + for attribute in ("id", "refid"): + identifier = node.get(attribute) + if identifier and canonical_id(identifier) != identifier: + node.set(attribute, canonical_id(identifier)) + changed_files.add(path) + return removed_files, changed_files + + +def prune_xml(xml_dir, project_root=None): + """Filter private entries and duplicate aliases before Exhale reads generated XML.""" + xml_dir = Path(xml_dir) + trees = {path: ET.parse(path) for path in sorted(xml_dir.glob("*.xml"))} + excluded_files, changed_files = _deduplicate_aliases(trees) + if project_root is not None: + changed_files.update(_normalize_paths(trees, project_root)) + changed_files.update(_deduplicate_functions(trees, excluded_files)) + excluded = set() + for path, tree in trees.items(): + if path in excluded_files: + continue + compound = tree.getroot().find("compounddef") + if compound is None: + continue + location = compound.find("location") + if (compound.get("prot") == "private" + or _private_name(compound.findtext("compoundname", "")) + or location is not None and location.get("file") == "[STL]"): + excluded_files.add(path) + excluded.add(compound.get("id")) + excluded.update(node.get("id") for node in compound.iter() + if node.tag in ("memberdef", "enumvalue")) + for member in compound.findall(".//memberdef"): + if (member.get("prot") == "private" + or _private_name(member.findtext("name", "")) + or compound.get("kind") in ("file", "namespace") + and member.get("static") == "yes"): + excluded.add(member.get("id")) + excluded.update(value.get("id") for value in member.findall("enumvalue")) + else: + excluded.update(value.get("id") for value in member.findall("enumvalue") + if _private_name(value.findtext("name", ""))) + excluded.discard(None) + + def remove_references(parent): + changed = False + for child in list(parent): + if child.get("id") in excluded or child.get("refid") in excluded: + if child.tag == "ref": + # Keep prose and source listings readable when their link is private. + text = "".join(child.itertext()) + (child.tail or "") + position = list(parent).index(child) + if position: + previous = parent[position - 1] + previous.tail = (previous.tail or "") + text + else: + parent.text = (parent.text or "") + text + parent.remove(child) + changed = True + else: + changed = remove_references(child) or changed + return changed + + for path, tree in trees.items(): + if path in excluded_files: + path.unlink() + elif remove_references(tree.getroot()) or path in changed_files: + tree.write(path, encoding="utf-8", xml_declaration=True) + return excluded + + +def _raw_anchor(refid): + # reST labels normalize case and underscores; Doxygen identifiers must stay exact. + return f'.. raw:: html\n\n \n\n' + + +def add_compound_pages(xml_dir, exhale_root): + """Add header anchors and group descriptions without re-rendering declarations.""" + output = Path(exhale_root.root_directory).resolve() + groups = [] + aliases = {} + for path in sorted(Path(xml_dir).glob("*.xml")): + compound = ET.parse(path).getroot().find("compounddef") + if compound is None or compound.get("kind") not in ("file", "namespace", "group"): + continue + refid = compound.get("id") + node = exhale_root.node_by_refid.get(refid) + if node is None: + continue + destination = (output / node.file_name).resolve() + if not destination.is_relative_to(output): + raise ValueError(f"Exhale page escapes the generated reference: {destination}") + anchor = _raw_anchor(refid) + if compound.get("kind") == "group": + if not any(compound.find(tag) is not None + and "".join(compound.find(tag).itertext()).strip() + for tag in ("briefdescription", "detaileddescription", "inbodydescription")): + continue + title = compound.findtext("title") or compound.findtext("compoundname") + name = compound.findtext("compoundname") + destination.write_text( + anchor + f".. _{node.link_name}:\n\n{title}\n{'=' * len(title)}\n\n" + + f".. doxygengroup:: {name}\n :desc-only:\n :no-link:\n", + encoding="utf-8", + ) + node.file_name = destination.relative_to(output).as_posix() + groups.append(destination.relative_to(output).with_suffix("").as_posix()) + else: + contents = destination.read_text(encoding="utf-8") + if anchor not in contents: + destination.write_text(anchor + contents, encoding="utf-8") + aliases[refid] = destination + if groups: + root_page = Path(exhale_root.full_root_file_path) + listing = ("\nDocumentation groups\n--------------------\n\n.. toctree::\n" + " :maxdepth: 1\n\n" + "".join(f" {group}\n" for group in groups)) + contents = root_page.read_text(encoding="utf-8") + if listing not in contents: + root_page.write_text(contents + listing, encoding="utf-8") + return aliases + + +def _xml_dir(app): + return Path(app.confdir) / app.config.breathe_projects[app.config.breathe_default_project] + + +def setup(app): + app.connect("builder-inited", lambda app: prune_xml(_xml_dir(app), Path(app.confdir).parent), priority=100) + app.connect("builder-inited", lambda app: add_compound_pages(_xml_dir(app), app.exhale_root), priority=900) diff --git a/doc/sphinxext/cpp_navigation.py b/doc/sphinxext/cpp_navigation.py new file mode 100644 index 000000000..4185b15e8 --- /dev/null +++ b/doc/sphinxext/cpp_navigation.py @@ -0,0 +1,62 @@ +"""Link topic navigation to the canonical Exhale C++ reference pages.""" + +from pathlib import Path + + +def descendants(nodes, root_refid): + selected = {root_refid} + if not any(node.refid == root_refid for node in nodes): + raise ValueError(f"Missing C++ topic root: {root_refid}") + while True: + expanded = selected | { + node.refid for node in nodes + if any(refid in selected for _, refid, _ in node.base_compounds) + } + if expanded == selected: + return sorted((node for node in nodes if node.refid in selected), key=lambda node: node.name) + selected = expanded + + +def generate(app): + source = Path(app.srcdir).resolve() + root = app.exhale_root + output = (source / root.root_directory).resolve() + root_page = (source / root.full_root_file_path).resolve() + root_docname = root_page.relative_to(source).with_suffix("").as_posix() + nodes = [node for node in root.all_nodes if node.kind in ("class", "struct")] + topic_docnames = {} + for topic, title, root_refid in ( + ("optimizers", "C++ optimizers", "classpele_1_1GradientOptimizer"), + ("potentials", "C++ potentials", "classpele_1_1BasePotential"), + ): + page = [title, "=" * len(title), ""] + for node in descendants(nodes, root_refid): + canonical = (output / node.file_name).resolve() + if not canonical.is_relative_to(output): + raise ValueError(f"C++ topic target escapes the generated reference: {canonical}") + docname = canonical.relative_to(source).with_suffix("").as_posix() + page.append(f"- :doc:`{node.name} `") + destination = output / (topic + ".rst") + destination.write_text("\n".join(page) + "\n", encoding="utf-8") + topic_docnames[topic] = destination.relative_to(source).with_suffix("").as_posix() + listing = ("\nBrowse by topic\n---------------\n\n.. toctree::\n" + " :maxdepth: 1\n\n optimizers\n potentials\n") + contents = root_page.read_text(encoding="utf-8") + if listing not in contents: + root_page.write_text(contents + listing, encoding="utf-8") + app.config.html_context["cpp_navigation"] = [ + {"label": label, "docname": docname, "anchor": anchor} + for label, docname, anchor in ( + ("Overview", root_docname, ""), + ("Optimizers", topic_docnames["optimizers"], ""), + ("Potentials", topic_docnames["potentials"], ""), + ("Classes & interfaces", root_docname, "class-hierarchy"), + ("Namespaces", root_docname, "namespaces"), + ("Functions", root_docname, "functions"), + ("Source files", root_docname, "file-hierarchy"), + ) + ] + + +def setup(app): + app.connect("builder-inited", generate, priority=950) diff --git a/doc/tests/test_check_html.py b/doc/tests/test_check_html.py index 1a6f702d0..43385709e 100644 --- a/doc/tests/test_check_html.py +++ b/doc/tests/test_check_html.py @@ -2,9 +2,10 @@ import sys from tempfile import TemporaryDirectory import unittest +from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from check_html import broken_links, check, coverage_errors, documented_cpp_symbols +from check_html import broken_links, check, coverage_errors, documented_cpp_symbols, sphinx_cpp_targets class CheckHtmlTests(unittest.TestCase): @@ -55,12 +56,38 @@ def test_native_coverage_keeps_developer_apis_and_excludes_private_helpers(self) ''') entries = documented_cpp_symbols(root) - self.assertEqual([(item["fullname"], item["docname"], item["anchor"]) for item in entries], [ - ("extension_point", "doxygen/classExample", "adeveloper"), - ("factory", "doxygen/classExample", "astatic"), - ("FLAG", "doxygen/file", "amacro"), + self.assertEqual([(item["fullname"], item["refid"]) for item in entries], [ + ("extension_point", "classExample_1adeveloper"), + ("factory", "classExample_1astatic"), + ("FLAG", "file_1amacro"), ]) + def test_native_targets_use_exact_generated_anchors_and_reject_missing_or_duplicate_pages(self): + with TemporaryDirectory() as directory: + root = Path(directory) + native = root / "generated/cpp" + native.mkdir(parents=True) + (native / "type.html").write_text(''' + ''') + (native / "other.html").write_text('') + (root / "cpp_reference.html").write_text('') + entries = [ + {"fullname": "Example", "kind": "class", "refid": "classExample"}, + {"fullname": "Example::run", "kind": "function", "refid": "classExample_1arun"}, + {"fullname": "omitted", "kind": "function", "refid": "missing"}, + {"fullname": "repeated", "kind": "function", "refid": "duplicate"}, + ] + rendered, errors = sphinx_cpp_targets(root, entries) + self.assertEqual([(entry["refid"], entry["docname"], entry["anchor"]) for entry in rendered], [ + ("classExample", "generated/cpp/type", "classExample"), + ("classExample_1arun", "generated/cpp/type", "classExample_1arun"), + ]) + self.assertEqual(errors, [ + "Missing Sphinx C++ API target: omitted (missing)", + "Duplicate Sphinx C++ API target: repeated (duplicate): generated/cpp/other.html, generated/cpp/type.html", + ]) + self.assertEqual(coverage_errors(root, rendered, "Sphinx C++ API"), []) + def test_links_and_anchors(self): with TemporaryDirectory() as directory: root = Path(directory) @@ -85,11 +112,11 @@ def test_links_and_anchors(self): def test_missing_generation_fails(self): with TemporaryDirectory() as directory: root = Path(directory) - (root / "cpp_reference.html").write_text("Breathe has not rendered any APIs") - with self.assertRaises(SystemExit) as result: - check(root) + with patch("check_html.documented_cpp_symbols", return_value=[]): + with self.assertRaises(SystemExit) as result: + check(root) self.assertIn("Missing generated page: index.html", str(result.exception)) - self.assertIn("Missing Breathe C++ API target: BasePotential", str(result.exception)) + self.assertIn("Missing generated page: generated/cpp/index.html", str(result.exception)) if __name__ == "__main__": diff --git a/doc/tests/test_cpp_api.py b/doc/tests/test_cpp_api.py new file mode 100644 index 000000000..9c0208e9a --- /dev/null +++ b/doc/tests/test_cpp_api.py @@ -0,0 +1,225 @@ +import importlib.util +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +import unittest +import xml.etree.ElementTree as ET + + +def extension(): + source = Path(__file__).resolve().parents[1] / "sphinxext/cpp_api.py" + spec = importlib.util.spec_from_file_location("cpp_api", source) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class CppApiTests(unittest.TestCase): + def test_global_declarations_share_one_page_and_keep_complementary_documentation(self): + with TemporaryDirectory() as directory: + root = Path(directory) + functions = { + "header": ("source/pele/example.h", "source/example.c", "double", "header_function", "Declaration docs."), + "implementation": ("source/example.c", "source/example.c", "double", "body_function", "Implementation docs."), + "different_body": ("source/other.c", "source/other.c", "double", "other_function", "Other body docs."), + "overload": ("source/pele/example.h", "source/example.c", "int", "overload_function", "Overload docs."), + } + index = [] + for identifier, (filename, bodyfile, parameter_type, member_id, description) in functions.items(): + index.append(f'{filename}' + f'example') + declaration = f'declfile="{filename}"' if identifier == "header" else "" + extra = "Body details." if identifier == "implementation" else "" + (root / f"{identifier}.xml").write_text(f''' + {filename} + voidexamplevoid example + {parameter_type}{'' if identifier == 'header' else 'argument'} + {description} + Shared docs.{extra} + + + example + ''') + (root / "index.xml").write_text("" + "".join(index) + "") + module = extension() + module.prune_xml(root) + header = ET.parse(root / "header.xml").getroot() + member = header.find(".//memberdef") + descriptions = " ".join(member.itertext()) + for text in ("Declaration docs.", "Implementation docs.", "Shared docs.", "Body details."): + self.assertIn(text, descriptions) + self.assertEqual(descriptions.count("Shared docs."), 1) + self.assertEqual(ET.parse(root / "implementation.xml").getroot().findall(".//memberdef"), []) + self.assertEqual(ET.parse(root / "implementation.xml").getroot().findall(".//listofallmembers/member"), []) + for identifier in ("different_body", "overload"): + self.assertIsNotNone(ET.parse(root / f"{identifier}.xml").getroot().find(".//memberdef")) + index_members = [node.get("refid") for node in ET.parse(root / "index.xml").getroot().findall(".//member")] + self.assertEqual(index_members, ["header_function", "other_function", "overload_function"]) + for path in root.glob("*.xml"): + self.assertNotIn('refid="body_function"', path.read_text()) + before = {path: path.read_bytes() for path in root.glob("*.xml")} + module.prune_xml(root) + self.assertEqual(before, {path: path.read_bytes() for path in root.glob("*.xml")}) + + def test_generated_paths_use_the_same_project_relative_directory_and_file_names(self): + with TemporaryDirectory() as directory: + root = Path(directory) + project = Path("/checkout/pele") + (root / "index.xml").write_text(''' + /checkout/pele/source + /checkout/pele/source/pele/example.h + ''') + (root / "dirSource.xml").write_text(''' + /checkout/pele/source + /checkout/pele/source/pele + /checkout/pele/source/pele/example.h + ''') + (root / "fileExample.xml").write_text(''' + example.h + + example + + ''') + module = extension() + module.prune_xml(root, project_root=project) + for path in root.glob("*.xml"): + self.assertNotIn(str(project), path.read_text()) + directory = ET.parse(root / "dirSource.xml").getroot().find("compounddef") + self.assertEqual(directory.findtext("compoundname"), "source") + self.assertEqual(directory.findtext("innerdir"), "source/pele") + location = ET.parse(root / "fileExample.xml").getroot().find(".//memberdef/location") + self.assertEqual(location.attrib, {"file": "source/pele/example.h", "bodyfile": "source/example.c", "declfile": "source/pele/example.h"}) + + def test_only_identical_qualified_aliases_are_deduplicated_and_references_remapped(self): + with TemporaryDirectory() as directory: + root = Path(directory) + names = { + "classArray": ("Array", "array.hpp", "pele::Array::size"), + "classpeleArray": ("pele::Array", "array.hpp", "pele::Array::size"), + "classOtherArray": ("other::Array", "other.hpp", "pele::Array::size"), + "classSpecialized": ("pele::Array", "array.hpp", "pele::Array::size"), + "classDifferent": ("different::Array", "array.hpp", "different::Array::size"), + } + compounds = [] + for identifier, (name, filename, definition) in names.items(): + compounds.append(f'{name}') + (root / f"{identifier}.xml").write_text(f''' + {name.replace('<', '<').replace('>', '>')} + + + size{definition.replace('<', '<').replace('>', '>')} + () const + ''') + (root / "index.xml").write_text("" + "".join(compounds).replace("", "<int>") + "") + (root / "file.xml").write_text('''example.cpp + Array + Array::size + Array + ''') + module = extension() + module.prune_xml(root) + self.assertFalse((root / "classArray.xml").exists()) + for identifier in names.keys() - {"classArray"}: + self.assertTrue((root / f"{identifier}.xml").exists()) + index_ids = [node.get("refid") for node in ET.parse(root / "index.xml").getroot()] + self.assertEqual(index_ids, [identifier for identifier in names if identifier != "classArray"]) + references = [node.get("refid") for node in ET.parse(root / "file.xml").getroot().iter() if node.get("refid")] + self.assertEqual(references, ["classpeleArray", "classpeleArray_1size", "classpeleArray"]) + before = {path: path.read_bytes() for path in root.glob("*.xml")} + module.prune_xml(root) + self.assertEqual(before, {path: path.read_bytes() for path in root.glob("*.xml")}) + + def test_generated_xml_omits_helpers_and_preserves_developer_interfaces(self): + with TemporaryDirectory() as directory: + root = Path(directory) + (root / "index.xml").write_text(''' + Example + hidden + factory + extension_point + + pele + local_helper + public_function + + _Private + std + ''') + (root / "classExample.xml").write_text(''' + Example_Private + hidden + _helper + factory + extension_point + PrivateEnum + VALUE + Before hidden helper after. + + ''') + (root / "namespacepele.xml").write_text(''' + pele_Private + local_helper + public_function + ''') + (root / "classPrivate.xml").write_text(''' + _Private + public_spelling + ''') + (root / "namespacestd.xml").write_text(''' + std''') + module = extension() + excluded = module.prune_xml(root) + self.assertEqual(excluded, {"private", "underscore", "privateEnum", "privateValue", "local", + "classPrivate", "privateOwnerMember", "namespacestd"}) + index = ET.parse(root / "index.xml").getroot() + self.assertEqual([node.get("refid") for node in index.iter() if node.get("refid")], + ["classExample", "factory", "protected", "namespacepele", "global"]) + example = ET.parse(root / "classExample.xml").getroot() + self.assertEqual([node.get("id") for node in example.findall(".//memberdef")], ["factory", "protected"]) + self.assertEqual(example.findtext(".//para"), "Before hidden helper after.") + self.assertEqual(example.findall(".//innerclass"), []) + self.assertFalse((root / "classPrivate.xml").exists()) + self.assertFalse((root / "namespacestd.xml").exists()) + self.assertEqual(module.prune_xml(root), set()) + + def test_aliases_keep_exact_ids_and_group_descriptions_do_not_repeat_declarations(self): + with TemporaryDirectory() as directory: + root = Path(directory) + xml = root / "xml" + output = root / "generated/cpp" + xml.mkdir() + output.mkdir(parents=True) + nodes = {} + for identifier, kind in [("File_MixedCase", "file"), ("namespacepele", "namespace"), ("group__Enumerations", "group")]: + (xml / f"{identifier}.xml").write_text(f''' + {identifier}{identifier} + A public description. + Flags + ''') + nodes[identifier] = SimpleNamespace(file_name=f"{identifier}.rst", link_name=f"exhale_{identifier}") + if kind != "group": + (output / f"{identifier}.rst").write_text(f"{identifier}\n================\n\nExisting description.\n") + (output / "index.rst").write_text("C++ API\n=======\n") + exhale_root = SimpleNamespace(root_directory=str(output), full_root_file_path=str(output / "index.rst"), node_by_refid=nodes) + module = extension() + aliases = module.add_compound_pages(xml, exhale_root) + self.assertEqual(set(aliases), set(nodes)) + before = {path: path.read_text() for path in output.glob("*.rst")} + module.add_compound_pages(xml, exhale_root) + self.assertEqual(before, {path: path.read_text() for path in output.glob("*.rst")}) + for identifier in nodes: + contents = (output / f"{identifier}.rst").read_text() + self.assertEqual(contents.count(f''), 1) + self.assertNotIn(f".. _{identifier}:", contents) + group = (output / "group__Enumerations.rst").read_text() + self.assertIn(".. doxygengroup:: group__Enumerations", group) + self.assertIn(":desc-only:", group) + self.assertIn(":no-link:", group) + self.assertNotIn(".. doxygenenum::", group) + self.assertNotIn(":members:", group) + self.assertIn(" group__Enumerations\n", (output / "index.rst").read_text()) + + +if __name__ == "__main__": + unittest.main() diff --git a/doc/tests/test_cpp_navigation.py b/doc/tests/test_cpp_navigation.py new file mode 100644 index 000000000..bbb02e888 --- /dev/null +++ b/doc/tests/test_cpp_navigation.py @@ -0,0 +1,107 @@ +import importlib.util +from pathlib import Path +import posixpath +from tempfile import TemporaryDirectory +from types import SimpleNamespace +import unittest + +from jinja2 import Template + + +class CppNavigationTests(unittest.TestCase): + def extension(self): + path = Path(__file__).resolve().parents[1] / "sphinxext/cpp_navigation.py" + spec = importlib.util.spec_from_file_location("cpp_navigation", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def fixture(self, directory): + source = Path(directory) + output = source / "generated/cpp" + output.mkdir(parents=True) + nodes = [ + ("GradientOptimizer", [], "gradient.rst"), + ("LBFGS", [("public", "classpele_1_1GradientOptimizer", "pele::GradientOptimizer")], "lbfgs.rst"), + ("NestedOptimizer", [("protected", "classpele_1_1LBFGS", "pele::LBFGS")], output / "nested/optimizer.rst"), + ("BasePotential", [], "base_potential.rst"), + ("Potential", [("private", "classpele_1_1BasePotential", "pele::BasePotential")], "potential.rst"), + ("CycleA", [("public", "classpele_1_1Potential", "pele::Potential"), + ("public", "classpele_1_1CycleB", "pele::CycleB")], "cycle_a.rst"), + ("CycleB", [("public", "classpele_1_1CycleA", "pele::CycleA")], "cycle_b.rst"), + ("UnrelatedA", [("public", "classpele_1_1UnrelatedB", "pele::UnrelatedB")], "unrelated_a.rst"), + ("UnrelatedB", [("public", "classpele_1_1UnrelatedA", "pele::UnrelatedA")], "unrelated_b.rst"), + ] + nodes = [SimpleNamespace(kind="class", name="pele::" + name, refid="classpele_1_1" + name, + base_compounds=bases, file_name=str(filename)) + for name, bases, filename in nodes] + for node in nodes: + page = output / node.file_name + page.parent.mkdir(parents=True, exist_ok=True) + page.write_text(f"Canonical definition of {node.name}.\n") + root_page = output / "index.rst" + root_page.write_text("C++ API reference\n=================\n") + graph = SimpleNamespace(root_directory=str(output), full_root_file_path=str(root_page), + all_nodes=nodes, class_like=[node for node in nodes if node.name != "pele::NestedOptimizer"]) + return SimpleNamespace(srcdir=str(source), exhale_root=graph, + config=SimpleNamespace(html_context={"existing": "preserved"})) + + def test_topics_follow_all_public_class_inheritance_and_link_canonical_pages(self): + extension = self.extension() + with TemporaryDirectory() as directory: + app = self.fixture(directory) + output = Path(app.exhale_root.root_directory) + definitions = {page: page.read_text() for page in output.rglob("*.rst") if page.name != "index.rst"} + extension.generate(app) + extension.generate(app) + optimizers = (output / "optimizers.rst").read_text() + potentials = (output / "potentials.rst").read_text() + self.assertIn(":doc:`pele::GradientOptimizer `", optimizers) + self.assertIn(":doc:`pele::LBFGS `", optimizers) + self.assertIn(":doc:`pele::NestedOptimizer `", optimizers) + self.assertEqual(optimizers.count(":doc:"), 3) + for name in ("BasePotential", "Potential", "CycleA", "CycleB"): + self.assertIn("pele::" + name, potentials) + self.assertEqual(potentials.count(":doc:"), 4) + self.assertNotIn("Unrelated", potentials) + self.assertNotIn(".. doxygen", optimizers + potentials) + self.assertEqual((output / "index.rst").read_text().count("Browse by topic"), 1) + self.assertEqual(definitions, {page: page.read_text() for page in definitions}) + self.assertEqual(app.config.html_context["existing"], "preserved") + + def test_sidebar_categories_link_correctly_from_every_page_depth(self): + extension = self.extension() + with TemporaryDirectory() as directory: + app = self.fixture(directory) + extension.generate(app) + navigation = app.config.html_context["cpp_navigation"] + self.assertEqual([(entry["label"], entry["anchor"]) for entry in navigation], [ + ("Overview", ""), ("Optimizers", ""), ("Potentials", ""), + ("Classes & interfaces", "class-hierarchy"), ("Namespaces", "namespaces"), + ("Functions", "functions"), ("Source files", "file-hierarchy"), + ]) + template = Template((Path(__file__).resolve().parents[1] / "_templates/api_navigation.html").read_text()) + for pagename in ("index", "generated/cpp/index", "generated/cpp/type", "generated/cpp/nested/type", "quenching"): + def pathto(docname): + return posixpath.relpath(docname + ".html", posixpath.dirname(pagename) or ".") + rendered = template.render(cpp_navigation=navigation, pathto=pathto, pagename=pagename) + self.assertIn("

Python

", rendered) + self.assertIn("

C++

", rendered) + for entry in navigation: + href = pathto(entry["docname"]) + ("#" + entry["anchor"] if entry["anchor"] else "") + self.assertIn(f'href="{href}"', rendered) + for docname in ("installation", "getting_started", "quenching", "potentials", "system_class", + "landscape_module", "database", "utils", "generated/source_api/index"): + self.assertIn(f'href="{pathto(docname)}"', rendered) + if pagename in ("generated/cpp/index", "quenching"): + self.assertEqual(rendered.count('aria-current="page"'), 1) + + def test_builder_hook_runs_after_exhale_and_compound_pages(self): + extension = self.extension() + connections = [] + extension.setup(SimpleNamespace(connect=lambda *args, **kwargs: connections.append((args, kwargs)))) + self.assertEqual(connections, [(("builder-inited", extension.generate), {"priority": 950})]) + + +if __name__ == "__main__": + unittest.main() diff --git a/doc/tests/test_doxygen_references.py b/doc/tests/test_doxygen_references.py index 21c3c9b4c..0cfff69d1 100644 --- a/doc/tests/test_doxygen_references.py +++ b/doc/tests/test_doxygen_references.py @@ -13,7 +13,7 @@ class DoxygenReferenceTests(unittest.TestCase): - def test_missing_breathe_targets_link_to_full_doxygen_reference(self): + def test_doxygen_references_link_to_canonical_sphinx_cpp_pages(self): with TemporaryDirectory() as directory: Path(directory, "index.xml").write_text(''' pele::Array @@ -21,19 +21,31 @@ def test_missing_breathe_targets_link_to_full_doxygen_reference(self): StopCriterionType GRADIENT + xsum.h + xsum_small_accumulator + ''') callbacks = {} app = SimpleNamespace( connect=lambda event, callback, **kwargs: callbacks.update({callback.__name__: callback}), + srcdir=directory, + exhale_root=SimpleNamespace(root_directory=str(Path(directory, "generated/cpp")), node_by_refid={ + "classpele_1_1Array": SimpleNamespace(file_name=str(Path(directory, "generated/cpp/array.rst")), parent=None), + "namespacepele": SimpleNamespace(file_name=str(Path(directory, "generated/cpp/namespace.rst")), parent=None), + "namespacepele_1a123": SimpleNamespace(file_name=str(Path(directory, "generated/cpp/enum.rst")), parent=None), + "xsum_8h": SimpleNamespace(file_name="xsum.rst", parent=None), + }), builder=SimpleNamespace(format="html", get_target_uri=lambda doc: f"{doc}.html"), ) with patch.dict(conf.breathe_projects, {"pele": directory}): conf.setup(app) resolve = callbacks["doxygen_reference"] for domain, target, expected in [ - ("cpp", "Array", "../doxygen/classpele_1_1Array.html"), - ("std", "classpele_1_1Array", "../doxygen/classpele_1_1Array.html"), - ("cpp", "StopCriterionType::GRADIENT", "../doxygen/namespacepele.html#a123a456"), + ("cpp", "Array", "cpp/array.html#classpele_1_1Array"), + ("std", "classpele_1_1Array", "cpp/array.html#classpele_1_1Array"), + ("cpp", "StopCriterionType::GRADIENT", "cpp/enum.html#namespacepele_1a123a456"), + ("cpp", "xsum_small_accumulator", "cpp/xsum.html#xsum_8h_1typedef"), + ("c", "xsum_small_accumulator", "cpp/xsum.html#xsum_8h_1typedef"), ]: node = {"refdomain": domain, "reftype": "ref", "reftarget": target, "refdoc": "generated/example"} result = resolve(app, None, node, nodes.Text(target)) diff --git a/pyproject.toml b/pyproject.toml index 2eb8f6d3a..ac886bbde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ [project.optional-dependencies] test = ["pytest"] -docs = ["sphinx>=8,<9", "numpydoc>=1.8,<2", "breathe>=4.36,<5", "cython>=3,<4"] +docs = ["sphinx>=8,<9", "numpydoc>=1.8,<2", "breathe>=4.36,<5", "exhale>=0.3.7,<0.4", "cython>=3,<4"] gui = ["pyqt5", "PyOpenGL"] lammps = ["lammps[mpi]"] From 74eb61eb11ac4529d8aba11313a74133fe3f5f70 Mon Sep 17 00:00:00 2001 From: Praharsh Suryadevara Date: Sun, 11 Oct 2026 18:56:53 -0500 Subject: [PATCH 09/14] Preserve native source ownership and builder-relative reference links --- doc/README.md | 8 ++++++++ doc/conf.py | 4 +--- doc/sphinxext/cpp_api.py | 9 ++++++++- doc/tests/test_cpp_api.py | 5 ++++- doc/tests/test_doxygen_references.py | 9 ++++++++- 5 files changed, 29 insertions(+), 6 deletions(-) diff --git a/doc/README.md b/doc/README.md index 83d241de2..c92ee3eea 100644 --- a/doc/README.md +++ b/doc/README.md @@ -70,6 +70,14 @@ For a clean regeneration, use `make -C doc clean` before the build. To review the result, serve `doc/_build/html` locally or download the `pele-documentation` artifact from the Documentation workflow. Artifacts are retained for 14 days. +From the repository root, start a local server: + +```sh +python -m http.server 8765 --directory doc/_build/html +``` + +Open http://localhost:8765/ in your browser. + ## CI and publication `.github/workflows/docs.yml` builds and checks documentation for pushes to diff --git a/doc/conf.py b/doc/conf.py index 19246e325..0d9ac360a 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -339,7 +339,6 @@ def linkcode_resolve(domain, info): def setup(app): # Doxygen references resolve to each declaration in the generated Sphinx API. from docutils import nodes - import posixpath import re xml_dir = Path(__file__).parent / breathe_projects["pele"] @@ -388,8 +387,7 @@ def doxygen_reference(app, env, node, contnode): if declaration is None or not declaration.file_name: return None page = (Path(app.exhale_root.root_directory) / declaration.file_name).relative_to(app.srcdir).with_suffix("").as_posix() - source = app.builder.get_target_uri(node["refdoc"]) - uri = posixpath.relpath(page + ".html", posixpath.dirname(source) or ".") + "#" + refid + uri = app.builder.get_relative_uri(node["refdoc"], page) + "#" + refid return nodes.reference("", "", contnode, refuri=uri) def dict_method_signature(app, what, name, obj, options, signature, return_annotation): diff --git a/doc/sphinxext/cpp_api.py b/doc/sphinxext/cpp_api.py index 42ae4587d..955d56363 100644 --- a/doc/sphinxext/cpp_api.py +++ b/doc/sphinxext/cpp_api.py @@ -183,6 +183,11 @@ def canonical_id(identifier): if compound.get("refid") in aliases: root.remove(compound) changed_files.add(path) + for parent in root.iter(): + for child in list(parent): + if child.tag == "innerclass" and child.get("refid") in aliases: + parent.remove(child) + changed_files.add(path) for node in root.iter(): for attribute in ("id", "refid"): identifier = node.get(attribute) @@ -250,7 +255,9 @@ def remove_references(parent): if path in excluded_files: path.unlink() elif remove_references(tree.getroot()) or path in changed_files: - tree.write(path, encoding="utf-8", xml_declaration=True) + # Exhale's file scanner expects Doxygen's compact self-closing tags. + xml = ET.tostring(tree.getroot(), encoding="unicode", xml_declaration=True) + path.write_text(xml.replace(" />", "/>"), encoding="utf-8") return excluded diff --git a/doc/tests/test_cpp_api.py b/doc/tests/test_cpp_api.py index 9c0208e9a..f0b37dafb 100644 --- a/doc/tests/test_cpp_api.py +++ b/doc/tests/test_cpp_api.py @@ -90,6 +90,8 @@ def test_generated_paths_use_the_same_project_relative_directory_and_file_names( self.assertEqual(directory.findtext("innerdir"), "source/pele") location = ET.parse(root / "fileExample.xml").getroot().find(".//memberdef/location") self.assertEqual(location.attrib, {"file": "source/pele/example.h", "bodyfile": "source/example.c", "declfile": "source/pele/example.h"}) + self.assertRegex((root / "fileExample.xml").read_text(), + r'') def test_only_identical_qualified_aliases_are_deduplicated_and_references_remapped(self): with TemporaryDirectory() as directory: @@ -125,7 +127,8 @@ def test_only_identical_qualified_aliases_are_deduplicated_and_references_remapp index_ids = [node.get("refid") for node in ET.parse(root / "index.xml").getroot()] self.assertEqual(index_ids, [identifier for identifier in names if identifier != "classArray"]) references = [node.get("refid") for node in ET.parse(root / "file.xml").getroot().iter() if node.get("refid")] - self.assertEqual(references, ["classpeleArray", "classpeleArray_1size", "classpeleArray"]) + self.assertEqual(references, ["classpeleArray_1size", "classpeleArray"]) + self.assertEqual(ET.parse(root / "file.xml").getroot().findall(".//innerclass"), []) before = {path: path.read_bytes() for path in root.glob("*.xml")} module.prune_xml(root) self.assertEqual(before, {path: path.read_bytes() for path in root.glob("*.xml")}) diff --git a/doc/tests/test_doxygen_references.py b/doc/tests/test_doxygen_references.py index 0cfff69d1..b864388f6 100644 --- a/doc/tests/test_doxygen_references.py +++ b/doc/tests/test_doxygen_references.py @@ -1,5 +1,6 @@ from pathlib import Path import json +import posixpath from tempfile import TemporaryDirectory from types import SimpleNamespace import sys @@ -35,7 +36,8 @@ def test_doxygen_references_link_to_canonical_sphinx_cpp_pages(self): "namespacepele_1a123": SimpleNamespace(file_name=str(Path(directory, "generated/cpp/enum.rst")), parent=None), "xsum_8h": SimpleNamespace(file_name="xsum.rst", parent=None), }), - builder=SimpleNamespace(format="html", get_target_uri=lambda doc: f"{doc}.html"), + builder=SimpleNamespace(format="html", get_relative_uri=lambda source, target: + posixpath.relpath(target + ".html", posixpath.dirname(source) or ".")), ) with patch.dict(conf.breathe_projects, {"pele": directory}): conf.setup(app) @@ -52,6 +54,11 @@ def test_doxygen_references_link_to_canonical_sphinx_cpp_pages(self): self.assertEqual(result["refuri"], expected) node["reftarget"] = "UnknownType" self.assertIsNone(resolve(app, None, node, nodes.Text("UnknownType"))) + app.builder.get_relative_uri = lambda source, target: posixpath.relpath(target, source) + "/" + node["reftarget"] = "classpele_1_1Array" + node["refdomain"] = "std" + self.assertEqual(resolve(app, None, node, nodes.Text("Array"))["refuri"], + "../cpp/array/#classpele_1_1Array") signature = callbacks["dict_method_signature"]( app, "method", "Result.update", dict.update, None, None, None ) From 419f06c4c22b5f1942cca95a47e102107825f0c5 Mon Sep 17 00:00:00 2001 From: Praharsh Suryadevara Date: Sun, 11 Oct 2026 19:33:39 -0500 Subject: [PATCH 10/14] Resolve complete native API declarations and verify canonical documentation --- .github/workflows/docs.yml | 4 +- doc/README.md | 6 +- doc/_templates/api_navigation.html | 2 +- doc/check_html.py | 16 +- doc/conf.py | 29 +++- doc/doxygen/Doxyfile | 3 +- doc/sphinxext/cpp_api.py | 202 +++++++++++++++++++++++-- doc/tests/test_cpp_api.py | 217 ++++++++++++++++++++++++++- doc/tests/test_cpp_navigation.py | 7 +- doc/tests/test_doxygen_references.py | 34 +++++ 10 files changed, 493 insertions(+), 27 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a99165423..b7804f714 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -43,9 +43,9 @@ jobs: - name: Install native dependencies run: | conda create -y -n pele-docs --override-channels -c conda-forge \ - python=3.12 pip sundials eigen blas-devel pkg-config + python=3.12 pip sundials eigen blas-devel pkg-config doxygen=1.17 sudo apt-get update - sudo apt-get install -y gcc g++ gfortran doxygen libglu1-mesa freeglut3-dev + sudo apt-get install -y gcc g++ gfortran libglu1-mesa freeglut3-dev - name: Install current package and documentation tools run: | diff --git a/doc/README.md b/doc/README.md index c92ee3eea..1eca453d4 100644 --- a/doc/README.md +++ b/doc/README.md @@ -3,10 +3,12 @@ Use a supported Python environment with pele and its native dependencies installed (see the repository README). The docs build imports the current package, including compiled extensions; it does not mock pele. Install the docs -and GUI extras and Doxygen. On Ubuntu the OpenGL imports also need GLU and GLUT: +and GUI extras and Doxygen 1.15 or newer (CI uses conda-forge's Doxygen 1.17). +On Ubuntu the OpenGL imports also need GLU and GLUT: ```sh -sudo apt-get install doxygen libglu1-mesa freeglut3-dev +conda install -c conda-forge doxygen=1.17 +sudo apt-get install libglu1-mesa freeglut3-dev python -m pip install '.[docs,gui]' \ -Csetup-args=-Dgui=enabled -Csetup-args=-Dlammps=disabled -Ccompile-args=-j1 OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MPLBACKEND=Agg QT_QPA_PLATFORM=offscreen \ diff --git a/doc/_templates/api_navigation.html b/doc/_templates/api_navigation.html index 5398bc349..f1408cf7f 100644 --- a/doc/_templates/api_navigation.html +++ b/doc/_templates/api_navigation.html @@ -14,7 +14,7 @@

Python

C++

{% endif %} diff --git a/doc/check_html.py b/doc/check_html.py index 88219aafa..fd5cfcdbd 100644 --- a/doc/check_html.py +++ b/doc/check_html.py @@ -154,11 +154,17 @@ def check(root): rendered_cpp = [] if cpp_entries: documented_refids = {entry["refid"] for entry in cpp_entries} - required_classes = [ - {"fullname": f"pele::{name}", "kind": "class", "refid": f"classpele_1_1{name}"} - for name in ("BasePotential", "PairwisePotentialInterface", "SimplePairwisePotential", "CellListPotential", "LBFGS", "MODIFIED_FIRE", "CVODEBDFOptimizer", "GenericMixedDescent") - if f"classpele_1_1{name}" not in documented_refids - ] + class_ids = {compound.findtext("name"): compound.get("refid") + for compound in ET.parse(xml_dir / "index.xml").getroot().findall("compound") + if compound.get("kind") in ("class", "struct")} + required_classes = [] + for name in ("BasePotential", "PairwisePotentialInterface", "SimplePairwisePotential", "CellListPotential", "LBFGS", "MODIFIED_FIRE", "CVODEBDFOptimizer", "GenericMixedDescent"): + fullname = f"pele::{name}" + refid = class_ids.get(fullname) + if refid is None: + errors.append(f"Missing required Doxygen class: {fullname}") + elif refid not in documented_refids: + required_classes.append({"fullname": fullname, "kind": "class", "refid": refid}) rendered, target_errors = sphinx_cpp_targets(root, cpp_entries + required_classes) errors.extend(target_errors) errors.extend(coverage_errors(root, rendered, "Sphinx C++ API")) diff --git a/doc/conf.py b/doc/conf.py index 0d9ac360a..1c86ef82e 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -63,9 +63,16 @@ cpp_maximum_signature_line_length = 88 c_maximum_signature_line_length = 88 +cpp_id_attributes = ["DEVICE", "HOSTDEVICE", "restrict", "__restrict__"] # External typedefs and macros have no reference page in pele. -nitpick_ignore = [("cpp:identifier", name) for name in ("size_t", "uint8_t", "NULL")] +nitpick_ignore = [("cpp:identifier", name) for name in ( + "size_t", "uint8_t", "uintptr_t", "NULL", "PyObject", "sunrealtype", + "N_Vector", "SUNContext", "SUNMatrix", "EvaluatorPairLJ", "EvaluatorPairLJ::param_type", +)] +nitpick_ignore_regex = [("cpp:identifier", pattern) for pattern in ( + r"Eigen(?:::.*)?", r"vector<.*>", +)] + [("c:identifier", r"u?int(?:_fast|_least)?(?:8|16|32|64)_t")] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -369,12 +376,32 @@ def doxygen_reference(app, env, node, contnode): names[f"{name}::{member.findtext('name')}"] = targets[member_id] if compound.get("kind") == "file": names[member.findtext("name")] = targets[member_id] + definition = ET.parse(xml_dir / (refid + ".xml")).getroot().find("compounddef") + for member in definition.findall(".//memberdef"): + member_id = member.get("id") + qualified = member.findtext("qualifiedname") + if qualified and member_id in targets: + names[qualified] = targets[member_id] + if member.get("kind") == "enum": + scope = qualified.rsplit("::", 1)[0] if qualified and "::" in qualified else name + for value in member.findall("enumvalue"): + value_id = value.get("id") + owner = refid if compound.get("kind") in ("class", "struct", "union") else member_id + targets[value_id] = (value_id, owner) + names[f"{scope}::{value.findtext('name')}"] = targets[value_id] + if qualified: + names[f"{qualified}::{value.findtext('name')}"] = targets[value_id] target = None if node.get("refdomain") == "std" and node.get("reftype") == "ref": target = targets.get(node["reftarget"]) elif node.get("refdomain") in ("cpp", "c"): name = re.sub(r"<.*>", "", node["reftarget"]) target = names.get(name) or names.get(f"pele::{name}") + if target is None and "::" in name: + prefix = name.rsplit("::", 1)[0] + matches = {value for key, value in names.items() if key.endswith("::" + prefix)} + if len(matches) == 1: + target = matches.pop() if target is None: return None refid, owner = target diff --git a/doc/doxygen/Doxyfile b/doc/doxygen/Doxyfile index 1d37ecc2c..e04654db3 100644 --- a/doc/doxygen/Doxyfile +++ b/doc/doxygen/Doxyfile @@ -699,7 +699,8 @@ EXCLUDE_PATTERNS = # wildcard * is used, a substring. Examples: ANamespace, AClass, # AClass::ANamespace, ANamespace::*Test -EXCLUDE_SYMBOLS = *::_* _* +# The unfinished GetEnergyFunction draft has no valid declaration; retain its source listing. +EXCLUDE_SYMBOLS = *::_* _* GetEnergyFunction # The EXAMPLE_PATH tag can be used to specify one or more files or # directories that contain example code fragments that are included (see diff --git a/doc/sphinxext/cpp_api.py b/doc/sphinxext/cpp_api.py index 955d56363..86ccbeb97 100644 --- a/doc/sphinxext/cpp_api.py +++ b/doc/sphinxext/cpp_api.py @@ -1,8 +1,9 @@ """Keep the complete Exhale reference public and provide exact Doxygen anchors.""" -from copy import deepcopy +from copy import copy, deepcopy from html import escape from pathlib import Path +import re import xml.etree.ElementTree as ET @@ -40,6 +41,52 @@ def _text(node): return " ".join("".join(node.itertext()).split()) if node is not None else "" +def _documented(node): + return any(_text(node.find(tag)) for tag in ("briefdescription", "detaileddescription", "inbodydescription")) + + +def _private_macro(member, compound): + location = member.find("location") + filename = location.get("file", "") if location is not None else compound.findtext("compoundname", "") + return member.get("kind") == "define" and ( + Path(filename).suffix.lower() in (".c", ".cc", ".cpp", ".cxx", ".c++") + or member.findtext("name") == "restrict" and not _documented(member)) + + +def _anonymous_enums(trees): + complete = {member.get("id") for tree in trees.values() + for member in tree.getroot().findall(".//memberdef") + if member.get("kind") == "enum" and (member.findall("enumvalue") or _documented(member))} + changed = set() + names = {} + for path, tree in trees.items(): + compound = tree.getroot().find("compounddef") + if compound is None: + continue + for section in compound.findall("sectiondef"): + for member in list(section): + if member.tag != "memberdef" or member.get("kind") != "enum": + continue + identifier = member.get("id") + if (identifier in complete and not identifier.startswith(compound.get("id") + "_1") + and not member.findall("enumvalue") and not _documented(member)): + section.remove(member) + changed.add(path) + elif not member.findtext("name", "").strip(): + name = member.find("name") + if name is None: + name = ET.SubElement(member, "name") + name.text = "@anonymous_" + identifier + names[identifier] = name.text + changed.add(path) + for path, tree in trees.items(): + for member in tree.getroot().findall(".//member"): + if member.get("refid") in names and member.find("name") is not None: + member.find("name").text = names[member.get("refid")] + changed.add(path) + return changed + + def _description_key(node): return (node.tag, tuple(sorted(node.attrib.items())), " ".join((node.text or "").split()), tuple(_description_key(child) for child in node)) @@ -62,8 +109,38 @@ def _merge_descriptions(destination, source): keys.add(key) -def _deduplicate_functions(trees, excluded_files): - """Join a global declaration and definition only when they share one body.""" +def _function_groups(records, trees): + """Keep distinct bodies separate; link a C header only to its unique includer.""" + groups = {} + declarations = [] + for path, member in records: + location = member.find("location") + body = tuple(location.get(field) for field in ("bodyfile", "bodystart", "bodyend")) + if all(body): + groups.setdefault(body, []).append((path, member)) + else: + declarations.append((path, member)) + for path, member in declarations: + location = member.find("location") + if Path(location.get("file", "")).suffix.lower() != ".h": + continue + header_id = trees[path].getroot().find("compounddef").get("id") + candidates = set() + for body, implementations in groups.items(): + for implementation_path, implementation in implementations: + source = implementation.find("location") + compound = trees[implementation_path].getroot().find("compounddef") + if (compound.get("kind") == "file" and Path(source.get("file", "")).suffix.lower() == ".c" + and Path(body[0]).suffix.lower() == ".c" + and any(include.get("refid") == header_id for include in compound.findall("includes"))): + candidates.add(body) + if len(candidates) == 1: + groups[next(iter(candidates))].append((path, member)) + return groups.values() + + +def _deduplicate_globals(trees, excluded_files): + """Join equivalent global declarations while preserving their descriptions.""" groups = {} for path, tree in trees.items(): if path in excluded_files: @@ -73,28 +150,37 @@ def _deduplicate_functions(trees, excluded_files): continue for member in compound.findall(".//memberdef"): location = member.find("location") - if (member.get("kind") != "function" or member.get("static") == "yes" - or member.get("prot") == "private" or location is None - or not all(location.get(field) for field in ("bodyfile", "bodystart", "bodyend"))): + kind = member.get("kind") + if (kind not in ("function", "typedef", "variable", "define") or member.get("static") == "yes" + or member.get("prot") == "private" or location is None or _private_macro(member, compound)): continue signature = ( member.findtext("qualifiedname") or member.findtext("name"), _text(member.find("type")), - tuple(_text(param.find("type")) for param in member.findall("param")), + tuple(" ".join(re.sub(r"\b(?:restrict|__restrict|__restrict__)\b", "", _text(param.find("type"))).split()) + if kind == "function" else _text(param.find("type")) for param in member.findall("param")), tuple((_text(param.find("type")), _text(param.find("defval"))) for param in member.findall("templateparamlist/param")), - tuple(location.get(field) for field in ("bodyfile", "bodystart", "bodyend")), + kind, + "" if kind == "function" else _text(member.find("definition")) if kind in ("variable", "typedef") + else _text(member.find("initializer")), ) groups.setdefault(signature, []).append((path, member)) aliases = {} changed = set() + grouped_records = [] for records in groups.values(): + grouped_records.extend(_function_groups(records, trees) if records[0][1].get("kind") == "function" else [records]) + for records in grouped_records: if len({member.get("id") for _, member in records}) < 2: continue + if records[0][1].get("kind") == "variable" and len({ + _text(member.find("initializer")) for _, member in records if _text(member.find("initializer"))}) > 1: + continue def preference(record): location = record[1].find("location") header = Path(location.get("file", "")).suffix.lower() in (".h", ".hh", ".hpp", ".hxx", ".h++", ".inl", ".ipp") - documented = any(_text(record[1].find(tag)) for tag in ("briefdescription", "detaileddescription", "inbodydescription")) + documented = _documented(record[1]) return (header, bool(location.get("declfile")), documented, record[1].get("id")) _, canonical = max(records, key=preference) @@ -106,6 +192,11 @@ def preference(record): if member.get("id") == canonical_id: for _, source in records: _merge_descriptions(member, source) + if not _text(member.find("initializer")) and _text(source.find("initializer")): + existing = member.find("initializer") + if existing is not None: + member.remove(existing) + member.append(deepcopy(source.find("initializer"))) changed.add(path) def remap(parent): @@ -204,7 +295,8 @@ def prune_xml(xml_dir, project_root=None): excluded_files, changed_files = _deduplicate_aliases(trees) if project_root is not None: changed_files.update(_normalize_paths(trees, project_root)) - changed_files.update(_deduplicate_functions(trees, excluded_files)) + changed_files.update(_anonymous_enums(trees)) + changed_files.update(_deduplicate_globals(trees, excluded_files)) excluded = set() for path, tree in trees.items(): if path in excluded_files: @@ -224,13 +316,20 @@ def prune_xml(xml_dir, project_root=None): if (member.get("prot") == "private" or _private_name(member.findtext("name", "")) or compound.get("kind") in ("file", "namespace") - and member.get("static") == "yes"): + and member.get("static") == "yes" + or _private_macro(member, compound)): excluded.add(member.get("id")) excluded.update(value.get("id") for value in member.findall("enumvalue")) else: excluded.update(value.get("id") for value in member.findall("enumvalue") if _private_name(value.findtext("name", ""))) excluded.discard(None) + definitions = {node.get("id") for path, tree in trees.items() if path not in excluded_files + for node in tree.getroot().iter() if node.tag in ("memberdef", "enumvalue")} + index = xml_dir / "index.xml" + if index in trees: + excluded.update(member.get("refid") for member in trees[index].getroot().findall(".//member") + if member.get("refid") not in definitions) def remove_references(parent): changed = False @@ -312,10 +411,91 @@ def add_compound_pages(xml_dir, exhale_root): return aliases +def rewrite_item_pages(xml_dir, exhale_root): + """Use exact XML identities for standalone items and escape heading markup.""" + output = Path(exhale_root.root_directory).resolve() + owners = {} + for path in sorted(Path(xml_dir).glob("*.xml")): + compound = ET.parse(path).getroot().find("compounddef") + if compound is None: + continue + priority = {"namespace": 3, "file": 2, "group": 1}.get(compound.get("kind"), 0) + for member in compound.findall(".//memberdef"): + identifier = member.get("id") + if identifier not in owners or priority > owners[identifier][0]: + qualified = member.findtext("qualifiedname") if compound.get("kind") == "group" else None + owners[identifier] = (priority, compound.get("id"), qualified) + item_directive = re.compile(r"^\.\. doxygen(?:function|enum|define|typedef|variable)::[^\n]*", re.MULTILINE) + for identifier, node in exhale_root.node_by_refid.items(): + if (identifier not in owners or not node.file_name + or node.kind not in ("function", "enum", "define", "typedef", "variable")): + continue + destination = (output / node.file_name).resolve() + if not destination.is_relative_to(output): + raise ValueError(f"Exhale page escapes the generated reference: {destination}") + contents = destination.read_text(encoding="utf-8") + directive = f".. doxygenrefid:: {identifier}\n :owner: {owners[identifier][1]}" + if owners[identifier][2] and "::" in owners[identifier][2]: + directive += f"\n :qualified-name: {owners[identifier][2]}" + replaced = item_directive.sub(directive, contents) + if replaced != contents: + destination.write_text(replaced, encoding="utf-8") + for path in output.glob("*.rst"): + lines = path.read_text(encoding="utf-8").splitlines(keepends=True) + changed = False + for position in range(len(lines) - 1): + title = lines[position].rstrip("\n") + underline = lines[position + 1].rstrip("\n") + if (title.endswith("_") and not title.endswith(r"\_") and not title[:1].isspace() + and underline and len(set(underline)) == 1 and underline[0] in "=-~^\"'`:+*#"): + lines[position] = title[:-1] + "\\_\n" + lines[position + 1] = underline[0] * len(lines[position].rstrip("\n")) + "\n" + changed = True + if changed: + path.write_text("".join(lines), encoding="utf-8") + + +def _refid_directive(): + # Keep the source-only filtering tests independent of the Sphinx installation. + from breathe.directives import BaseDirective + from breathe.directives.item import _DoxygenBaseItemDirective + from breathe.renderer.mask import NullMaskFactory + from breathe.renderer.target import create_target_handler + from docutils.parsers.rst.directives import unchanged_required + + class DoxygenRefidDirective(BaseDirective): + required_arguments = 1 + option_spec = {**_DoxygenBaseItemDirective.option_spec, "owner": unchanged_required, "qualified-name": unchanged_required} + has_content = False + kind = "symbol" + + def run(self): + project = self.project_info_factory.create_project_info(self.options) + compound = self.parser_factory.create_compound_parser(project).parse(self.options["owner"]) + finder = self.finder_factory.create_finder_from_root(compound, project) + matches = [] + finder.filter_(self.filter_factory.create_id_filter("memberdef", self.arguments[0]), matches) + if not matches: + return self.create_warning(project).warn("Missing Doxygen API target: " + self.arguments[0]) + stack = list(matches[0]) + if self.options.get("qualified-name"): + # Breathe ignores XML qualifiedname; avoid modifying its cached member. + stack[0] = copy(stack[0]) + stack[0].name = self.options["qualified-name"] + return self.render( + stack, project, self.filter_factory.create_outline_filter(self.options), + create_target_handler(self.options, project, self.state.document), + NullMaskFactory(), self.directive_args) + + return DoxygenRefidDirective + + def _xml_dir(app): return Path(app.confdir) / app.config.breathe_projects[app.config.breathe_default_project] def setup(app): + app.add_directive("doxygenrefid", _refid_directive()) app.connect("builder-inited", lambda app: prune_xml(_xml_dir(app), Path(app.confdir).parent), priority=100) app.connect("builder-inited", lambda app: add_compound_pages(_xml_dir(app), app.exhale_root), priority=900) + app.connect("builder-inited", lambda app: rewrite_item_pages(_xml_dir(app), app.exhale_root), priority=910) diff --git a/doc/tests/test_cpp_api.py b/doc/tests/test_cpp_api.py index f0b37dafb..535fc25b2 100644 --- a/doc/tests/test_cpp_api.py +++ b/doc/tests/test_cpp_api.py @@ -15,6 +15,99 @@ def extension(): class CppApiTests(unittest.TestCase): + def test_global_types_variables_and_macros_keep_one_canonical_documented_definition(self): + with TemporaryDirectory() as directory: + root = Path(directory) + index = [] + for identifier, filename, variable_initializer in ( + ("header", "source/pele/example.hpp", ""), + ("second", "source/pele/second.hpp", ""), + ("body", "source/example.cpp", "= 0")): + records = f'''doubleScalar + using Scalar = double{identifier} type docs. + + intdebug + int debug{variable_initializer} + {identifier} variable docs. + MODE1 + {identifier} macro docs.''' + if identifier == "header": + records += '''restrict__restrict__ + PUBLIC_MACROPublic API. + intScalarusing Scalar = int''' + (root / f"{identifier}.xml").write_text(f''' + {filename}{records}''') + members = ''.join(f'{name}' + for kind, name in (("typedef", "Scalar"), ("variable", "debug"), ("define", "mode"))) + index.append(f'{filename}{members}' + 'missing_static_helper') + (root / "index.xml").write_text('' + ''.join(index) + '') + module = extension() + module.prune_xml(root) + members = [member for path in root.glob('*.xml') for member in ET.parse(path).getroot().findall('.//memberdef')] + self.assertEqual(sum(member.findtext('definition') == 'using Scalar = double' for member in members), 1) + variable = [member for member in members if member.findtext('name') == 'debug'] + self.assertEqual(len(variable), 1) + self.assertEqual(variable[0].findtext('initializer'), '= 0') + for identifier in ('header', 'second', 'body'): + self.assertIn(f'{identifier} variable docs.', ''.join(variable[0].itertext())) + self.assertEqual(sum(member.findtext('name') == 'MODE' for member in members), 1) + self.assertIn('public_macro', {member.get('id') for member in members}) + self.assertIn('different_type', {member.get('id') for member in members}) + self.assertNotIn('restrict', {member.get('id') for member in members}) + self.assertNotIn('refid="stale"', (root / 'index.xml').read_text()) + + def test_anonymous_enums_keep_values_and_drop_only_empty_foreign_copies(self): + with TemporaryDirectory() as directory: + root = Path(directory) + for identifier, copied in (("structPrimary", ""), ("structSpecialized", '')): + (root / f'{identifier}.xml').write_text(f''' + {identifier}{copied} + value + + ''') + module = extension() + module.prune_xml(root) + enums = [member for path in root.glob('*.xml') for member in ET.parse(path).getroot().findall('.//memberdef')] + self.assertEqual(len(enums), 4) + self.assertEqual(sum(member.get('id') == 'structPrimary_1enum' for member in enums), 1) + self.assertEqual(sum(len(member.findall('enumvalue')) for member in enums), 2) + for member in enums: + self.assertEqual(member.findtext('name'), '@anonymous_' + member.get('id')) + + def test_generated_item_directives_use_exact_ids_and_heading_names_stay_literal(self): + with TemporaryDirectory() as directory: + root = Path(directory) + xml = root / 'xml' + output = root / 'generated/cpp' + xml.mkdir() + output.mkdir(parents=True) + (xml / 'namespacepele.xml').write_text('''pele + Flags''') + (xml / 'classExample.xml').write_text('''Example + Inner''') + (xml / 'groupFlags.xml').write_text('''Flags + GroupOnlypele::GroupOnly''') + (output / 'enum.rst').write_text('''Enum Flags\n==========\n\n.. doxygenenum:: pele::Flags\n :project: pele\n''') + (output / 'struct.rst').write_text('''Struct UserData_\n================\n\n.. doxygenstruct:: pele::UserData_\n''') + (output / 'group-enum.rst').write_text('''Enum GroupOnly\n==============\n\n.. doxygenenum:: pele::GroupOnly\n''') + exhale_root = SimpleNamespace(root_directory=str(output), node_by_refid={ + 'group_Flags_1enum': SimpleNamespace(file_name='enum.rst', kind='enum'), + 'groupOnly_1enum': SimpleNamespace(file_name='group-enum.rst', kind='enum'), + 'classExample_1enum': SimpleNamespace(file_name=None, kind='enum')}) + module = extension() + module.rewrite_item_pages(xml, exhale_root) + self.assertIn('.. doxygenrefid:: group_Flags_1enum\n :owner: namespacepele', (output / 'enum.rst').read_text()) + self.assertIn(':project: pele', (output / 'enum.rst').read_text()) + self.assertIn(':qualified-name: pele::GroupOnly', (output / 'group-enum.rst').read_text()) + title = (output / 'struct.rst').read_text().splitlines() + self.assertEqual(title[0], r'Struct UserData\_') + self.assertEqual(len(title[0]), len(title[1])) + self.assertIn('.. doxygenstruct:: pele::UserData_', (output / 'struct.rst').read_text()) + before = {path: path.read_bytes() for path in output.glob('*.rst')} + module.rewrite_item_pages(xml, exhale_root) + self.assertEqual(before, {path: path.read_bytes() for path in output.glob('*.rst')}) + def test_global_declarations_share_one_page_and_keep_complementary_documentation(self): with TemporaryDirectory() as directory: root = Path(directory) @@ -23,6 +116,11 @@ def test_global_declarations_share_one_page_and_keep_complementary_documentation "implementation": ("source/example.c", "source/example.c", "double", "body_function", "Implementation docs."), "different_body": ("source/other.c", "source/other.c", "double", "other_function", "Other body docs."), "overload": ("source/pele/example.h", "source/example.c", "int", "overload_function", "Overload docs."), + "restrict_header": ("source/pele/restrict.h", "source/restrict.c", "double *", "restrict_header_function", "Restricted declaration docs."), + "restrict_body": ("source/restrict.c", "source/restrict.c", "double *restrict", "restrict_body_function", "Restricted implementation docs."), + "ambiguous_header": ("source/pele/ambiguous.h", "", "float *", "ambiguous_header_function", "Ambiguous declaration docs."), + "ambiguous_first": ("source/first.c", "source/first.c", "float *restrict", "ambiguous_first_function", "First distinct body."), + "ambiguous_second": ("source/second.c", "source/second.c", "float *", "ambiguous_second_function", "Second distinct body."), } index = [] for identifier, (filename, bodyfile, parameter_type, member_id, description) in functions.items(): @@ -30,13 +128,16 @@ def test_global_declarations_share_one_page_and_keep_complementary_documentation f'example') declaration = f'declfile="{filename}"' if identifier == "header" else "" extra = "Body details." if identifier == "implementation" else "" + body_location = '' if identifier in ('restrict_header', 'ambiguous_header') else f'bodyfile="{bodyfile}" bodystart="10" bodyend="20"' + includes = 'restrict.h' if identifier == 'restrict_body' else ( + 'ambiguous.h' if identifier.startswith('ambiguous_') and identifier != 'ambiguous_header' else '') (root / f"{identifier}.xml").write_text(f''' - {filename} + {filename}{includes} voidexamplevoid example {parameter_type}{'' if identifier == 'header' else 'argument'} {description} Shared docs.{extra} - + example ''') @@ -54,7 +155,11 @@ def test_global_declarations_share_one_page_and_keep_complementary_documentation for identifier in ("different_body", "overload"): self.assertIsNotNone(ET.parse(root / f"{identifier}.xml").getroot().find(".//memberdef")) index_members = [node.get("refid") for node in ET.parse(root / "index.xml").getroot().findall(".//member")] - self.assertEqual(index_members, ["header_function", "other_function", "overload_function"]) + self.assertEqual(index_members, ["header_function", "other_function", "overload_function", "restrict_header_function", + "ambiguous_header_function", "ambiguous_first_function", "ambiguous_second_function"]) + restricted = ET.parse(root / "restrict_header.xml").getroot().find(".//memberdef") + self.assertEqual(restricted.findtext("param/type"), "double *") + self.assertIn("Restricted implementation docs.", "".join(restricted.itertext())) for path in root.glob("*.xml"): self.assertNotIn('refid="body_function"', path.read_text()) before = {path: path.read_bytes() for path in root.glob("*.xml")} @@ -224,5 +329,111 @@ def test_aliases_keep_exact_ids_and_group_descriptions_do_not_repeat_declaration self.assertIn(" group__Enumerations\n", (output / "index.rst").read_text()) +class ExactRefidRenderingTests(unittest.TestCase): + def test_exact_refids_render_c_header_function_group_enum_and_macro(self): + try: + from sphinx.application import Sphinx + except ImportError: + self.skipTest("Sphinx/Breathe are needed for the rendering fixture") + from io import StringIO + CPP_API = Path(__file__).resolve().parents[1] / "sphinxext" + with TemporaryDirectory(prefix='pele-refid-fixture-') as directory: + root = Path(directory) + source = root / 'src' + xml = source / 'xml' + xml.mkdir(parents=True) + (source / 'conf.py').write_text(f''' +import sys +sys.path.insert(0, {str(CPP_API)!r}) +extensions = ['breathe'] +project = 'Exact native API IDs' +master_doc = 'index' +breathe_projects = {{'fixture': 'xml'}} +breathe_default_project = 'fixture' +breathe_domain_by_extension = {{'h': 'c', 'hpp': 'cpp'}} +def setup(app): + from cpp_api import _refid_directive + app.add_directive('doxygenrefid', _refid_directive()) +''') + (source / 'index.rst').write_text('''Exact native API IDs +==================== + +.. doxygenrefid:: file__fixture_8h_1c_restrict + :owner: file__fixture_8h + +.. doxygenrefid:: group__Flags_1Enum_MixedCase + :owner: namespacepele + +.. doxygenrefid:: file__fixture_8h_1PUBLIC_LIMIT + :owner: file__fixture_8h + +.. doxygenrefid:: group__Flags_1Enum_GroupOnly + :owner: group__Flags + :qualified-name: pele::GroupOnly +''') + (xml / 'index.xml').write_text(''' +fixture.h + sum_values + PUBLIC_LIMIT + +pele +Flags + Flags + +''') + (xml / 'file__fixture_8h.xml').write_text(''' +fixture.h + + doubledouble sum_values(double *restrict values)sum_values + double *restrictvalues + A documented C restrict function. + Sum the caller-owned values. + + + + PUBLIC_LIMIT32 + The documented public header macro. + + +''') + enum = ''' + intFlagspele::Flags + The documented group-owned enum. + + PUBLIC_VALUE= 1 + A documented enum value. + + +''' + (xml / 'namespacepele.xml').write_text('pele' + enum + '') + group_only = ''' + intGroupOnlypele::GroupOnly + The enum exists only in the group. + READY= 1 + The group-only value. + ''' + (xml / 'group__Flags.xml').write_text('FlagsFlags' + enum + group_only + '') + warnings = StringIO() + app = Sphinx(str(source), str(source), str(root / 'html'), str(root / 'doctrees'), + 'html', status=StringIO(), warning=warnings, freshenv=True, + warningiserror=True) + app.build(force_all=True) + self.assertEqual(warnings.getvalue(), '') + self.assertEqual(app.statuscode, 0) + html = (root / 'html/index.html').read_text() + for identifier in ('file__fixture_8h_1c_restrict', 'group__Flags_1Enum_MixedCase', + 'group__Flags_1Enum_MixedCase_1Public_Value', 'file__fixture_8h_1PUBLIC_LIMIT', + 'group__Flags_1Enum_GroupOnly', 'group__Flags_1Enum_GroupOnly_1Ready'): + self.assertEqual(html.count(f'id="{identifier}"'), 1, identifier) + for description in ('A documented C restrict function.', 'Sum the caller-owned values.', + 'The documented group-owned enum.', 'A documented enum value.', + 'The documented public header macro.'): + self.assertIn(description, html) + self.assertIn('restrict', html) + cpp_objects = {name for name, *_ in app.env.get_domain('cpp').get_objects()} + self.assertIn('pele::GroupOnly', cpp_objects) + self.assertIn('pele::GroupOnly::READY', cpp_objects) + + if __name__ == "__main__": unittest.main() diff --git a/doc/tests/test_cpp_navigation.py b/doc/tests/test_cpp_navigation.py index bbb02e888..1e2d0d799 100644 --- a/doc/tests/test_cpp_navigation.py +++ b/doc/tests/test_cpp_navigation.py @@ -83,13 +83,18 @@ def test_sidebar_categories_link_correctly_from_every_page_depth(self): template = Template((Path(__file__).resolve().parents[1] / "_templates/api_navigation.html").read_text()) for pagename in ("index", "generated/cpp/index", "generated/cpp/type", "generated/cpp/nested/type", "quenching"): def pathto(docname): + if docname == pagename: + return "#" return posixpath.relpath(docname + ".html", posixpath.dirname(pagename) or ".") rendered = template.render(cpp_navigation=navigation, pathto=pathto, pagename=pagename) self.assertIn("

Python

", rendered) self.assertIn("

C++

", rendered) for entry in navigation: - href = pathto(entry["docname"]) + ("#" + entry["anchor"] if entry["anchor"] else "") + href = pathto(entry["docname"]) + if entry["anchor"]: + href = href.rstrip("#") + "#" + entry["anchor"] self.assertIn(f'href="{href}"', rendered) + self.assertNotIn('href="##', rendered) for docname in ("installation", "getting_started", "quenching", "potentials", "system_class", "landscape_module", "database", "utils", "generated/source_api/index"): self.assertIn(f'href="{pathto(docname)}"', rendered) diff --git a/doc/tests/test_doxygen_references.py b/doc/tests/test_doxygen_references.py index b864388f6..4b2c4bec6 100644 --- a/doc/tests/test_doxygen_references.py +++ b/doc/tests/test_doxygen_references.py @@ -25,7 +25,34 @@ def test_doxygen_references_link_to_canonical_sphinx_cpp_pages(self): xsum.h xsum_small_accumulator + Enumerations + Termination + ARMIJO + + pele::Node + edge_list + + pele::Factorial + + value +
''') + for identifier, members in { + "classpele_1_1Array": "", + "namespacepele": ''' + pele::StopCriterionType + GRADIENT''', + "xsum_8h": "", + "group__Enumerations": ''' + pele::Termination + ARMIJO''', + "classpeleNode": ''' + pele::Node::edge_list''', + "structpeleFactorial": ''' + value''', + }.items(): + Path(directory, identifier + ".xml").write_text( + f"{members}") callbacks = {} app = SimpleNamespace( connect=lambda event, callback, **kwargs: callbacks.update({callback.__name__: callback}), @@ -35,6 +62,9 @@ def test_doxygen_references_link_to_canonical_sphinx_cpp_pages(self): "namespacepele": SimpleNamespace(file_name=str(Path(directory, "generated/cpp/namespace.rst")), parent=None), "namespacepele_1a123": SimpleNamespace(file_name=str(Path(directory, "generated/cpp/enum.rst")), parent=None), "xsum_8h": SimpleNamespace(file_name="xsum.rst", parent=None), + "group__Enumerations_1ga123": SimpleNamespace(file_name="termination.rst", parent=None), + "classpeleNode": SimpleNamespace(file_name="node.rst", parent=None), + "structpeleFactorial": SimpleNamespace(file_name="factorial.rst", parent=None), }), builder=SimpleNamespace(format="html", get_relative_uri=lambda source, target: posixpath.relpath(target + ".html", posixpath.dirname(source) or ".")), @@ -48,6 +78,10 @@ def test_doxygen_references_link_to_canonical_sphinx_cpp_pages(self): ("cpp", "StopCriterionType::GRADIENT", "cpp/enum.html#namespacepele_1a123a456"), ("cpp", "xsum_small_accumulator", "cpp/xsum.html#xsum_8h_1typedef"), ("c", "xsum_small_accumulator", "cpp/xsum.html#xsum_8h_1typedef"), + ("cpp", "ARMIJO", "cpp/termination.html#group__Enumerations_1gga123a456"), + ("std", "group__Enumerations_1gga123a456", "cpp/termination.html#group__Enumerations_1gga123a456"), + ("cpp", "edge_list::iterator", "cpp/node.html#classpeleNode_1typedef"), + ("cpp", "Factorial::value", "cpp/factorial.html#structpeleFactorial_1value"), ]: node = {"refdomain": domain, "reftype": "ref", "reftarget": target, "refdoc": "generated/example"} result = resolve(app, None, node, nodes.Text(target)) From 849397153149175cc0fbe3864fda8c41dc8e555a Mon Sep 17 00:00:00 2001 From: Praharsh Suryadevara Date: Sun, 11 Oct 2026 20:12:09 -0500 Subject: [PATCH 11/14] Repair native API comments for consistent Doxygen generation --- source/pele/aatopology.hpp | 10 +--------- source/pele/array.hpp | 4 +--- source/pele/frenkel.hpp | 7 +++---- source/pele/linesearch.hpp | 5 ++--- source/pele/lj_cut.hpp | 8 ++++---- source/pele/matrix.hpp | 6 ++---- source/pele/optimizer.hpp | 5 ++--- 7 files changed, 15 insertions(+), 30 deletions(-) diff --git a/source/pele/aatopology.hpp b/source/pele/aatopology.hpp index 6dee6abc1..09540e48c 100644 --- a/source/pele/aatopology.hpp +++ b/source/pele/aatopology.hpp @@ -322,15 +322,7 @@ class RigidFragment { VecN<3> &g_M, VecN<3> &g_P) const; }; -///** -// * Angle axis topology -// * -// * An angle axis topology stores all topology information for an angle axis -// * system. The AATopology is composed of several angle axis sites, -// * which describe the shape of the angle axis site and each site carries a -// * position and orientation. Therefore, the length of the coordinate array -// * must be 6*number_of_sites. -// */ +// Inactive angle-axis topology prototype. // class AATopology { //}; diff --git a/source/pele/array.hpp b/source/pele/array.hpp index b0f347414..89b098fad 100644 --- a/source/pele/array.hpp +++ b/source/pele/array.hpp @@ -230,9 +230,7 @@ class Array { return *this; } - // /** - // * assign each element of the array to be - // */ + // Inactive pointer-assignment prototype. // Array &assign(dtype const * const d) { // std::copy(rhs.begin(), rhs.end(), begin()); // for (size_t i = 0; i < size(); ++i) { diff --git a/source/pele/frenkel.hpp b/source/pele/frenkel.hpp index d45047fb9..beb192472 100644 --- a/source/pele/frenkel.hpp +++ b/source/pele/frenkel.hpp @@ -94,10 +94,9 @@ class FrenkelPeriodic : public SimplePairwisePotential>(boxvec)) {} }; -///** -// * Pairwise Lennard-Jones potential with smooth cutoff with loops done -// * using cell lists -// */ +/** + * Pairwise Frenkel potential with periodic distances, using cell lists. + */ template class FrenkelPeriodicCellLists diff --git a/source/pele/linesearch.hpp b/source/pele/linesearch.hpp index 50127b1ec..e7dffbeb7 100644 --- a/source/pele/linesearch.hpp +++ b/source/pele/linesearch.hpp @@ -39,8 +39,7 @@ class LineSearch { : opt_(opt), max_stepnorm_(max_stepnorm){}; virtual ~LineSearch(){}; - // * Computes the step size given a step and a position - // * + /** Compute the step size given a step and a position. */ virtual double line_search(Array &x, Array step) = 0; // // variable settings @@ -115,4 +114,4 @@ class OldLineSearch : public LineSearch { } // namespace pele -#endif /* End line search methods */ \ No newline at end of file +#endif /* End line search methods */ diff --git a/source/pele/lj_cut.hpp b/source/pele/lj_cut.hpp index c3bd97d48..1cbba9f23 100644 --- a/source/pele/lj_cut.hpp +++ b/source/pele/lj_cut.hpp @@ -150,10 +150,10 @@ class LJCutPeriodicAtomList std::make_shared>(boxvec), atoms1) {} }; -///** -// * Pairwise Lennard-Jones potential with smooth cutoff with loops done -// * using cell lists -// */ +/** + * Pairwise Lennard-Jones potential with smooth cutoff and periodic distances, + * using cell lists. + */ template class LJCutPeriodicCellLists : public CellListPotential hacky_mat_mul(MatrixAdapter const &A, return C; } -///** -// * multiply a matrix times an vector -// */ +// Inactive matrix-vector multiplication prototype. // template // pele::Array hacky_mat_mul(MatrixAdapter const & A, // pele::Array const & v) @@ -110,7 +108,7 @@ MatrixAdapter hacky_mat_mul(MatrixAdapter const &A, // return C; //} -// for matrix printing +/** Write the matrix entries to an output stream. */ template std::ostream &operator<<(std::ostream &out, const pele::MatrixAdapter &a) { diff --git a/source/pele/optimizer.hpp b/source/pele/optimizer.hpp index 9e05e2be4..eda4af552 100644 --- a/source/pele/optimizer.hpp +++ b/source/pele/optimizer.hpp @@ -343,9 +343,7 @@ class GradientOptimizer : public Optimizer { } protected: - // /** - // * Compute the func and gradient of the objective function - // */ + // Inactive array-gradient implementation. // void compute_func_gradient(Array x, double & func, // Array gradient) // { @@ -355,6 +353,7 @@ class GradientOptimizer : public Optimizer { // func = potential_->get_energy_gradient(x, gradient); // } + /** Compute the objective value and fill the supplied gradient accumulators. */ virtual void compute_func_gradient( Array x, double &func, std::vector &gradient) { From 0d03bc437e44008e0086ebf3f3a8d8663d4e4508 Mon Sep 17 00:00:00 2001 From: Praharsh Suryadevara Date: Sun, 11 Oct 2026 21:57:31 -0500 Subject: [PATCH 12/14] Restyle the documentation with Shibuya and speed up incremental builds Replace the classic theme and scipy.css with Shibuya, keeping the curated Python and C++ navigation as the left sidebar and fixing C++ base-class signatures that the theme split onto their own line. Update the copyright to the pele developers. Restore the mtime of generated pages whose content is unchanged so an incremental build only re-reads pages that actually changed. Use plain pip in the install guides. Co-Authored-By: Claude Opus 5.5 --- RELEASING.md | 4 +- doc/README.md | 2 +- doc/_static/pele.css | 32 ++++ doc/_static/scipy.css | 186 ------------------- doc/_templates/partials/globaltoc-above.html | 3 + doc/conf.py | 44 +++-- doc/installation.rst | 8 +- pyproject.toml | 2 +- 8 files changed, 76 insertions(+), 205 deletions(-) create mode 100644 doc/_static/pele.css delete mode 100644 doc/_static/scipy.css create mode 100644 doc/_templates/partials/globaltoc-above.html diff --git a/RELEASING.md b/RELEASING.md index 278692a79..2f3b54f5d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -41,7 +41,7 @@ To validate locally, first install C/C++/Fortran compilers, SUNDIALS, Eigen, LAP headers, and OpenMP, then run: ```sh -python -m pip install build twine +pip install build twine python -m build -Csetup-args=-Dnative=false -Csetup-args=-Dlammps=disabled python -m twine check --strict dist/pele-0.1.1.tar.gz ``` @@ -87,7 +87,7 @@ A dispatch with the input unchecked validates artifacts without publishing. Test a binary installation in a fresh supported environment: ```sh -python -m pip install --only-binary=:all: --no-deps --index-url https://test.pypi.org/simple/ \ +pip install --only-binary=:all: --no-deps --index-url https://test.pypi.org/simple/ \ pele==0.1.1 ``` diff --git a/doc/README.md b/doc/README.md index 1eca453d4..ede9835d1 100644 --- a/doc/README.md +++ b/doc/README.md @@ -9,7 +9,7 @@ On Ubuntu the OpenGL imports also need GLU and GLUT: ```sh conda install -c conda-forge doxygen=1.17 sudo apt-get install libglu1-mesa freeglut3-dev -python -m pip install '.[docs,gui]' \ +pip install '.[docs,gui]' \ -Csetup-args=-Dgui=enabled -Csetup-args=-Dlammps=disabled -Ccompile-args=-j1 OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MPLBACKEND=Agg QT_QPA_PLATFORM=offscreen \ make -C doc check SPHINXOPTS='-n -W --keep-going -E -a' diff --git a/doc/_static/pele.css b/doc/_static/pele.css new file mode 100644 index 000000000..b2fa640f5 --- /dev/null +++ b/doc/_static/pele.css @@ -0,0 +1,32 @@ +/* Give api_navigation.html the look of Shibuya's toctree captions and links. */ +.globaltoc > h3 { + font-size: .86rem; + font-weight: 500; + font-family: var(--sy-f-heading); + color: var(--sy-c-light); + border-top: 1px solid var(--sy-c-divider); + padding: .8rem 0 .4rem; +} +.globaltoc > h3:first-of-type { + border-top: none; + padding-top: 0; +} +.globaltoc > ul { + margin-bottom: 1.5rem; +} +.globaltoc a[aria-current="page"] { + color: var(--sy-c-link); + font-weight: 500; +} + +/* Shibuya moves direct signature links onto their own line as viewcode "[source]" + links. In C/C++ signatures those links are base classes, so keep them inline. */ +.sig.cpp > a.internal, +.sig.c > a.internal { + color: var(--yue-c-link-1); + font-size: inherit; +} +.sig.cpp > a.internal::before, +.sig.c > a.internal::before { + content: none; +} diff --git a/doc/_static/scipy.css b/doc/_static/scipy.css deleted file mode 100644 index f2f53d97e..000000000 --- a/doc/_static/scipy.css +++ /dev/null @@ -1,186 +0,0 @@ -@import "classic.css"; - -/** - * Spacing fixes - */ - -div.body p, div.body dd, div.body li { - line-height: 125%; -} - -ul.simple { - margin-top: 0; - margin-bottom: 0; - padding-top: 0; - padding-bottom: 0; -} - -/* spacing around blockquoted fields in parameters/attributes/returns */ -td.field-body > blockquote { - margin-top: 0.1em; - margin-bottom: 0.5em; -} - -/* spacing around example code */ -div.highlight > pre { - padding: 2px 5px 2px 5px; -} - -/* spacing in see also definition lists */ -dl.last > dd { - margin-top: 1px; - margin-bottom: 5px; - margin-left: 30px; -} - -/* hide overflowing content in the sidebar */ -div.sphinxsidebarwrapper p.topless { - overflow: hidden; -} - -/** - * Hide dummy toctrees - */ - -ul { - padding-top: 0; - padding-bottom: 0; - margin-top: 0; - margin-bottom: 0; -} -ul li { - padding-top: 0; - padding-bottom: 0; - margin-top: 0; - margin-bottom: 0; -} -ul li a.reference { - padding-top: 0; - padding-bottom: 0; - margin-top: 0; - margin-bottom: 0; -} - -/** - * Make high-level subsections easier to distinguish from top-level ones - */ -div.body h3 { - background-color: transparent; -} - -div.body h4 { - border: none; - background-color: transparent; -} - -/** - * Scipy colors - */ - -body { - background-color: rgb(100,135,220); -} - -div.document { - background-color: rgb(230,230,230); -} - -div.sphinxsidebar { - background-color: rgb(230,230,230); - overflow: hidden; -} - -div.related { - background-color: rgb(100,135,220); -} - -div.sphinxsidebar h3 { - color: rgb(0,102,204); -} - -div.sphinxsidebar h3 a { - color: rgb(0,102,204); -} - -div.sphinxsidebar h4 { - color: rgb(0,82,194); -} - -div.sphinxsidebar p { - color: black; -} - -div.sphinxsidebar a { - color: #355f7c; -} - -div.sphinxsidebar ul.want-points { - list-style: disc; -} - -.field-list th { - color: rgb(0,102,204); - white-space: nowrap; -} - -/** - * Extra admonitions - */ - -div.tip { - background-color: #ffffe4; - border: 1px solid #ee6; -} - -div.plot-output { - clear-after: both; -} - -div.plot-output .figure { - float: left; - text-align: center; - margin-bottom: 0; - padding-bottom: 0; -} - -div.plot-output .caption { - margin-top: 2; - padding-top: 0; -} - -div.plot-output:after { - content: ""; - display: block; - height: 0; - clear: both; -} - - -/* -div.admonition-example { - background-color: #e4ffe4; - border: 1px solid #ccc; -}*/ - - -/** - * Styling for field lists - */ - -table.field-list th { - border-left: 1px solid #aaa !important; - padding-left: 5px; -} - -table.field-list { - border-collapse: separate; - border-spacing: 10px; -} - -/** - * Styling for footnotes - */ - -table.footnote td, table.footnote th { - border: none; -} diff --git a/doc/_templates/partials/globaltoc-above.html b/doc/_templates/partials/globaltoc-above.html new file mode 100644 index 000000000..218aee839 --- /dev/null +++ b/doc/_templates/partials/globaltoc-above.html @@ -0,0 +1,3 @@ + diff --git a/doc/conf.py b/doc/conf.py index 1c86ef82e..7b5f832fc 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -14,6 +14,7 @@ import json from pathlib import Path import tomllib +from datetime import date import xml.etree.ElementTree as ET import pele @@ -88,7 +89,7 @@ # General information about the project. project = "pele" -copyright = "2017, Johannes Klicpera, Victor Rühle, Jacob Stevenson" +copyright = f"2012–{date.today().year}, the pele developers" # Read the documentation version from the package metadata. release = tomllib.loads( @@ -124,9 +125,6 @@ # output. They are ignored by default. # show_authors = False -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" - # A list of ignored prefixes for module index sorting. modindex_common_prefix = ["pele."] @@ -135,13 +133,17 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_style = "scipy.css" -html_theme = "classic" +html_theme = "shibuya" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -# html_theme_options = {} +html_theme_options = { + "accent_color": "indigo", + "github_url": "https://github.com/pele-python/pele", + "show_ai_links": False, +} +html_css_files = ["pele.css"] # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] @@ -177,10 +179,8 @@ # typographically correct entities. # html_use_smartypants = True -# Custom sidebar templates, maps document names to template names. -html_sidebars = {"**": [ - "api_navigation.html", "localtoc.html", "relations.html", "sourcelink.html", "searchbox.html", -]} +# The left sidebar is the curated API navigation (_templates/partials/globaltoc-above.html). +html_sidebars = {"**": ["sidebars/localtoc.html", "sidebars/repo-stats.html"]} # Additional templates that should be rendered to pages, maps page names to # template names. @@ -456,7 +456,29 @@ def source_reference(app, env, node, contnode): uri = app.builder.get_relative_uri(node["refdoc"], entry["docname"]) + "#" + entry["anchor"] return nodes.reference("", "", contnode, refuri=uri) + # Exhale and source_api rewrite every generated page on each build. Restore the old + # mtime of unchanged pages so incremental builds only re-read what actually changed. + generated = {} + + def snapshot_generated(app): + for path in (Path(app.srcdir) / "generated").rglob("*"): + if path.is_file(): + generated[path] = (path.read_bytes(), path.stat().st_mtime_ns) + + def restore_unchanged_mtimes(app): + for path, (content, mtime) in generated.items(): + if path.is_file() and path.read_bytes() == content: + os.utime(path, ns=(mtime, mtime)) + + def curated_navigation(app, pagename, templatename, context, doctree): + # Shibuya's left sidebar shows api_navigation.html instead of the full toctree, + # which would otherwise list every generated C++ page. + context["toctree"] = lambda **kwargs: "" + + app.connect("builder-inited", snapshot_generated, priority=0) + app.connect("builder-inited", restore_unchanged_mtimes, priority=999) app.connect("missing-reference", doxygen_reference) + app.connect("html-page-context", curated_navigation) app.connect("missing-reference", source_reference) app.connect("autodoc-process-signature", dict_method_signature, priority=100) app.connect("autodoc-process-docstring", int_bytes_docstring, priority=100) diff --git a/doc/installation.rst b/doc/installation.rst index 5d3031ada..ef7834f49 100644 --- a/doc/installation.rst +++ b/doc/installation.rst @@ -12,7 +12,7 @@ supported. Install pele into your Python environment with: .. code-block:: console - python -m pip install pele + pip install pele Check that the compiled potential and optimization modules import successfully: @@ -34,7 +34,7 @@ environment can supply these dependencies: conda create -n pele -c conda-forge python=3.12 pip compilers sundials eigen blas-devel pkg-config conda activate pele - python -m pip install git+https://github.com/pele-python/pele + pip install git+https://github.com/pele-python/pele If you already have GCC, G++, and GFortran, you can omit ``compilers`` from the environment command. SUNDIALS, Eigen, and LAPACK are taken from the active @@ -50,7 +50,7 @@ The GUI uses PyQt5 and PyOpenGL. With a wheel installation, add them with: .. code-block:: console - python -m pip install 'pele[gui]' + pip install 'pele[gui]' The OpenGL viewer also needs the system GLU and GLUT libraries. On Ubuntu: @@ -62,7 +62,7 @@ LAMMPS potentials require the LAMMPS Python package: .. code-block:: console - python -m pip install 'pele[lammps]' + pip install 'pele[lammps]' Wheels use the Python LAMMPS interface. To build the compiled interface or generate GUI forms from source, follow the diff --git a/pyproject.toml b/pyproject.toml index ac886bbde..ae42edb74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ [project.optional-dependencies] test = ["pytest"] -docs = ["sphinx>=8,<9", "numpydoc>=1.8,<2", "breathe>=4.36,<5", "exhale>=0.3.7,<0.4", "cython>=3,<4"] +docs = ["sphinx>=8,<9", "numpydoc>=1.8,<2", "breathe>=4.36,<5", "exhale>=0.3.7,<0.4", "shibuya>=2026.7", "cython>=3,<4"] gui = ["pyqt5", "PyOpenGL"] lammps = ["lammps[mpi]"] From 0bddde5c536acada6198baca6e58f08ea5bb262d Mon Sep 17 00:00:00 2001 From: Praharsh Suryadevara Date: Sun, 11 Oct 2026 21:57:31 -0500 Subject: [PATCH 13/14] Describe CVODE, mixed descent, and the LAMMPS interface in the docs Co-Authored-By: Claude Opus 5.5 --- doc/tutorial_potential.rst | 5 ++--- pele/optimize/__init__.py | 14 +++++++------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/doc/tutorial_potential.rst b/doc/tutorial_potential.rst index c31f8b2df..173405793 100644 --- a/doc/tutorial_potential.rst +++ b/doc/tutorial_potential.rst @@ -4,9 +4,8 @@ Creating Your Potential ----------------------- pele has a number of potentials built in, including the Lennard-Jones potential which is used for most of our examples. It also has an interface to the GMIN program with -which you can access any myriad of GMIN's built in potentials. -We also plan to build interfaces to some of the standard molecular dynamics packages -like gromacs and OpenMM. +which you can access any myriad of GMIN's built in potentials and has a LAMMPS interface +to import your own potentials. The flexibility of pele alows it to be easily used with just about any scalar function. Probably the biggest limitation is that, because we use a lot of diff --git a/pele/optimize/__init__.py b/pele/optimize/__init__.py index 68a51235f..68c54b0d4 100644 --- a/pele/optimize/__init__.py +++ b/pele/optimize/__init__.py @@ -3,17 +3,19 @@ Optimizers (`pele.optimize`) ================================ -This module contains all of the optimizers available in pele. There are so +This module contains all of the localoptimizers available in pele. There are so many available for testing purposes and because sometimes different optimizers are more appropriate in different circumstances. These optimizers are all local. That -is they find the nearest local minimum. For global optimization see :ref:`global +is they stop at a local minimum. For global optimization see :ref:`global optimization `. In our experience, we have found that the best minimizers are lbfgs and fire. lbfgs seems to be by far the fastest, however there are some circumstances -where the better stability of fire might be useful. These cases -include where it is important to clearly define the boundary between basins of -attraction. LBFGS can also fail in non-Hamiltonian situations where you have +where the better stability of fire might be useful. CVODE exists if you want guarantees +that the endpoint is the one connected by the steepest descent equation (see https://arxiv.org/abs/2409.12113). +Mixed Descent is also designed to increase the probability that the path is connected by steepest descent, +(but is currently WIP because it only improves performance over CVODE for LJ clusters) +LBFGS can also fail in non-Hamiltonian situations where you have forces, but no potential function. This is the case in the Nudged elastic band, where the NEB force doesn't correspond to any NEB energy. @@ -172,7 +174,6 @@ """ - from .result import * from ._lbfgs_py import * from ._mylbfgs import * @@ -184,7 +185,6 @@ CosineGradientDescent_CPP as CosineGradientDescent, ) - try: from .cvode_opt import ( CVODEBDFOptimizer_CPP as CVODEBDFOptimizer, From e21a591779b640c28ea33b33172d79fb06e4889b Mon Sep 17 00:00:00 2001 From: Praharsh Suryadevara Date: Sun, 11 Oct 2026 21:59:56 -0500 Subject: [PATCH 14/14] Fix spacing in the optimizer module description Co-Authored-By: Claude Opus 5.5 --- pele/optimize/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pele/optimize/__init__.py b/pele/optimize/__init__.py index 68c54b0d4..908c392b1 100644 --- a/pele/optimize/__init__.py +++ b/pele/optimize/__init__.py @@ -3,7 +3,7 @@ Optimizers (`pele.optimize`) ================================ -This module contains all of the localoptimizers available in pele. There are so +This module contains all of the local optimizers available in pele. There are so many available for testing purposes and because sometimes different optimizers are more appropriate in different circumstances. These optimizers are all local. That is they stop at a local minimum. For global optimization see :ref:`global