diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 006ff23..23527b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,22 +12,22 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.9 - uses: actions/setup-python@v1 + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.9 - - uses: pre-commit/action@v2.0.0 + python-version: "3.11" + - uses: pre-commit/action@v3.0.1 tests: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8, 3.9] + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -36,14 +36,14 @@ jobs: pip install -e.[testing] - name: Run pytest run: | - pytest --duration=10 --cov=sphinx_exercise --cov-report=xml --cov-report=term-missing + pytest --durations=10 --cov=sphinx_exercise --cov-report=xml --cov-report=term-missing - name: Create cov run: coverage xml - name: Upload to Codecov - if: matrix.python-version == 3.8 - uses: codecov/codecov-action@v1 + if: matrix.python-version == '3.11' + uses: codecov/codecov-action@v4 with: - name: sphinx-exercise-pytest-py3.8 + name: sphinx-exercise-pytest-py3.11 flags: pytests file: ./coverage.xml fail_ci_if_error: true @@ -52,11 +52,11 @@ jobs: name: Documentation build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.9 - uses: actions/setup-python@v1 + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.11" - name: Install dependencies run: | python -m pip install --upgrade pip @@ -74,15 +74,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout source - uses: actions/checkout@v2 - - name: Set up Python 3.9 - uses: actions/setup-python@v1 + uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.11" - name: Build package run: | - pip install wheel - python setup.py bdist_wheel sdist + pip install wheel build + python -m build - name: Publish uses: pypa/gh-action-pypi-publish@v1.3.1 with: diff --git a/.gitignore b/.gitignore index 6db21ef..6fd03ab 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ dist/ coverage.* coverage.xml .tox/ +.venv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 87ce973..e44f9e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,12 +22,9 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/PyCQA/flake8 - rev: 5.0.4 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.4 hooks: - - id: flake8 - - - repo: https://github.com/psf/black - rev: 22.8.0 - hooks: - - id: black + - id: ruff + args: ["--fix", "--show-fixes"] + - id: ruff-format diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 7bb18ac..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,11 +0,0 @@ -exclude .flake8 -exclude .pre-commit-config.yaml -exclude .readthedocs.yml -exclude tox.ini - -include LICENSE -include MANIFEST.in -include README.md - -recursive-include sphinx_exercise *.js -recursive-include sphinx_exercise *.css diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..370b607 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,74 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "sphinx-exercise" +dynamic = ["version"] +description = "A Sphinx extension for producing exercises and solutions." +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.9" +authors = [ + { name = "QuantEcon", email = "admin@quantecon.org" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Plugins", + "Environment :: Web Environment", + "Framework :: Sphinx :: Extension", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Documentation", + "Topic :: Documentation :: Sphinx", + "Topic :: Software Development :: Documentation", + "Topic :: Text Processing", + "Topic :: Utilities", +] +dependencies = [ + "sphinx-book-theme", + "sphinx>=5", +] + +[project.optional-dependencies] +all = [ + "sphinx-exercise[code_style]", + "sphinx-exercise[rtd]", + "sphinx-exercise[testing]" +] +code_style = [ + "black", + "flake8<3.8.0,>=3.7.0", + "pre-commit", +] +rtd = [ + "myst-nb~=1.0.0", + "sphinx-book-theme", + "sphinx>=5,<8", +] +testing = [ + "beautifulsoup4", + "coverage", + "matplotlib", + "myst-nb~=1.0.0", + "pytest-cov", + "pytest-regressions", + "pytest~=8.0.0", + "sphinx>=5,<8", + "texsoup", +] + +[project.urls] +Homepage = "https://github.com/executablebooks/sphinx-exercise" +Source = "https://github.com/executablebooks/sphinx-exercise" +Tracker = "https://github.com/executablebooks/sphinx-exercise/issues" + +[tool.hatch.version] +path = "sphinx_exercise/__init__.py" diff --git a/setup.py b/setup.py deleted file mode 100644 index 7950697..0000000 --- a/setup.py +++ /dev/null @@ -1,81 +0,0 @@ -# -*- coding: utf-8 -*- - -from setuptools import setup, find_packages - -VERSION = "v0.4.1" - -LONG_DESCRIPTION = """ -This package contains a [Sphinx](http://www.sphinx-doc.org/) extension -for producing exercise and solution directives. - -This project is maintained and supported by the Executable Books Project. -""" - -SHORT_DESCRIPTION = "A Sphinx extension for producing exercises and solutions." - -BASE_URL = "https://github.com/executablebooks/sphinx-exercise" -URL = f"{BASE_URL}/archive/{VERSION}.tar.gz" - -# Define all extras -extras = { - "code_style": ["flake8<3.8.0,>=3.7.0", "black", "pre-commit"], - "testing": [ - "coverage", - "pytest>=3.6,<4", - "pytest-cov", - "pytest-regressions", - "beautifulsoup4", - "myst-nb~=0.17.1", - "sphinx>=4,<6", - "docutils>=0.15,<0.19", - "texsoup", - "matplotlib", - ], - "rtd": [ - "sphinx>=4,<6", - "sphinx-book-theme", - "myst-nb~=0.17.1", - ], -} - -extras["all"] = set(ii for jj in extras.values() for ii in jj) - -setup( - name="sphinx-exercise", - version=VERSION, - python_requires=">=3.8", - author="QuantEcon", - author_email="admin@quantecon.org", - url=BASE_URL, - download_url=URL, - project_urls={ - "Source": BASE_URL, - "Tracker": f"{BASE_URL}/issues", - }, - description=SHORT_DESCRIPTION, - long_description=LONG_DESCRIPTION, - long_description_content_type="text/markdown", - license="BSD", - packages=find_packages(), - install_requires=["sphinx>=4", "sphinx-book-theme"], - extras_require=extras, - include_package_data=True, - classifiers=[ - "Development Status :: 4 - Beta", - "Environment :: Plugins", - "Environment :: Web Environment", - "Framework :: Sphinx :: Extension", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Natural Language :: English", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python", - "Topic :: Documentation :: Sphinx", - "Topic :: Documentation", - "Topic :: Software Development :: Documentation", - "Topic :: Text Processing", - "Topic :: Utilities", - ], -) diff --git a/sphinx_exercise/__init__.py b/sphinx_exercise/__init__.py index 217164a..d729f72 100644 --- a/sphinx_exercise/__init__.py +++ b/sphinx_exercise/__init__.py @@ -7,6 +7,9 @@ :license: MIT, see LICENSE for details. """ +__version__ = "0.4.1" + + from pathlib import Path from typing import Any, Dict, Set, Union, cast from sphinx.config import Config @@ -17,6 +20,7 @@ from sphinx.util import logging from sphinx.util.fileutil import copy_asset +from ._compat import findall from .directive import ( ExerciseDirective, ExerciseStartDirective, @@ -129,7 +133,7 @@ def doctree_read(app: Sphinx, document: Node) -> None: domain = cast(StandardDomain, app.env.get_domain("std")) # Traverse sphinx-exercise nodes - for node in document.traverse(): + for node in findall(document): if is_extension_node(node): name = node.get("names", [])[0] label = document.nameids[name] @@ -140,7 +144,6 @@ def doctree_read(app: Sphinx, document: Node) -> None: def setup(app: Sphinx) -> Dict[str, Any]: - app.add_config_value("hide_solutions", False, "env") app.connect("config-inited", init_numfig) # event order - 1 diff --git a/sphinx_exercise/_compat.py b/sphinx_exercise/_compat.py new file mode 100644 index 0000000..99e70b8 --- /dev/null +++ b/sphinx_exercise/_compat.py @@ -0,0 +1,9 @@ +from docutils.nodes import Element +from typing import Iterator + + +def findall(node: Element, *args, **kwargs) -> Iterator[Element]: + # findall replaces traverse in docutils v0.18 + # note a difference is that findall is an iterator + impl = getattr(node, "findall", node.traverse) + return iter(impl(*args, **kwargs)) diff --git a/sphinx_exercise/directive.py b/sphinx_exercise/directive.py index 0b0d36b..1a1f21d 100644 --- a/sphinx_exercise/directive.py +++ b/sphinx_exercise/directive.py @@ -88,7 +88,6 @@ class : str, } def run(self) -> List[Node]: - self.defaults = {"title_text": "Exercise"} self.serial_number = self.env.new_serialno() @@ -160,7 +159,10 @@ def run(self) -> List[Node]: self.env.sphinx_exercise_registry[label] = { "type": self.name, "docname": self.env.docname, - "node": node, + # Copy the node so that the post transforms do not modify this original state + # Prior to Sphinx 6.1.0, the doctree was not cached, and Sphinx loaded a new copy + # c.f. https://github.com/sphinx-doc/sphinx/commit/463a69664c2b7f51562eb9d15597987e6e6784cd + "node": node.deepcopy(), } # TODO: Could tag this as Hidden to prevent the cell showing @@ -214,7 +216,6 @@ class : str, solution_node = solution_node def run(self) -> List[Node]: - self.defaults = {"title_text": "Solution to"} target_label = self.arguments[0] self.serial_number = self.env.new_serialno() diff --git a/sphinx_exercise/latex.py b/sphinx_exercise/latex.py index dd24ee1..6eb04fd 100644 --- a/sphinx_exercise/latex.py +++ b/sphinx_exercise/latex.py @@ -2,7 +2,6 @@ class LaTeXMarkup(object): - CR = "\n" def visit_admonition(self): diff --git a/sphinx_exercise/post_transforms.py b/sphinx_exercise/post_transforms.py index 98e0666..32d49d2 100644 --- a/sphinx_exercise/post_transforms.py +++ b/sphinx_exercise/post_transforms.py @@ -4,6 +4,7 @@ from sphinx.builders.latex import LaTeXBuilder from docutils import nodes as docutil_nodes +from ._compat import findall from .utils import get_node_number, find_parent from .nodes import ( exercise_enumerable_node, @@ -46,11 +47,10 @@ class UpdateReferencesToEnumerated(SphinxPostTransform): default_priority = 5 def run(self): - if not hasattr(self.env, "sphinx_exercise_registry"): return - for node in self.document.traverse(sphinx_nodes.pending_xref): + for node in findall(self.document, sphinx_nodes.pending_xref): if node.get("reftype") != "numref": target_label = node.get("reftarget") if target_label in self.env.sphinx_exercise_registry: @@ -112,11 +112,10 @@ def resolve_title(self, node): return node def run(self): - if not hasattr(self.env, "sphinx_exercise_registry"): return - for node in self.document.traverse(is_exercise_node): + for node in findall(self.document, is_exercise_node): node = self.resolve_title(node) @@ -171,16 +170,14 @@ def resolve_solution_title(app, node, exercise_node): class ResolveTitlesInSolutions(SphinxPostTransform): - default_priority = 21 def run(self): - if not hasattr(self.env, "sphinx_exercise_registry"): return # Update Solution Directives - for node in self.document.traverse(solution_node): + for node in findall(self.document, solution_node): label = node.get("label") target_label = node.get("target_label") try: @@ -210,12 +207,11 @@ class ResolveLinkTextToSolutions(SphinxPostTransform): default_priority = 22 def run(self): - if not hasattr(self.env, "sphinx_exercise_registry"): return # Update Solution References - for node in self.document.traverse(docutil_nodes.reference): + for node in findall(self.document, docutil_nodes.reference): refid = node.get("refid") if refid in self.env.sphinx_exercise_registry: target = self.env.sphinx_exercise_registry[refid] diff --git a/sphinx_exercise/transforms.py b/sphinx_exercise/transforms.py index 72f8699..fdf2f48 100644 --- a/sphinx_exercise/transforms.py +++ b/sphinx_exercise/transforms.py @@ -7,6 +7,7 @@ # from sphinx.errors import ExtensionError +from ._compat import findall from .nodes import ( exercise_node, exercise_enumerable_node, @@ -88,7 +89,7 @@ def find_nodes(self, label, node): def apply(self): # Process all matching solution-start and solution-end nodes - for node in self.document.traverse(solution_start_node): + for node in findall(self.document, solution_start_node): label = node.get("label") parent_start, parent_end = self.find_nodes(label, node) if not parent_end: @@ -173,11 +174,11 @@ def merge_nodes(self, node): def apply(self): # Process all matching exercise and exercise-enumerable (gated=True) # and exercise-end nodes - for node in self.document.traverse(exercise_node): + for node in findall(self.document, exercise_node): if node.gated: self.merge_nodes(node) node.gated = False - for node in self.document.traverse(exercise_enumerable_node): + for node in findall(self.document, exercise_enumerable_node): if node.gated: self.merge_nodes(node) node.gated = False diff --git a/tests/conftest.py b/tests/conftest.py index e12ebf6..df54536 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,20 +1,35 @@ import shutil import pytest +import packaging.version +import sphinx +import re from pathlib import Path -from sphinx.testing.path import path pytest_plugins = "sphinx.testing.fixtures" -@pytest.fixture -def rootdir(tmpdir): - src = path(__file__).parent.abspath() / "books" - dst = tmpdir.join("books") - shutil.copytree(src, dst) - books = path(dst) - yield books - shutil.rmtree(dst) +if packaging.version.Version(sphinx.__version__) < packaging.version.Version("7.2.0"): + + @pytest.fixture + def rootdir(tmpdir): + from sphinx.testing.path import path + + src = path(__file__).parent.absolute() / "books" + dst = tmpdir.join("books") + shutil.copytree(src, dst) + yield path(dst) + shutil.rmtree(dst) + +else: + + @pytest.fixture + def rootdir(tmp_path): + src = Path(__file__).parent.absolute() / "books" + dst = tmp_path / "books" + shutil.copytree(src, dst) + yield dst + shutil.rmtree(dst) @pytest.fixture @@ -46,7 +61,8 @@ def read( extension = sphinx_version + extension # convert absolute filenames - for node in doctree.traverse(lambda n: "source" in n): + findall = getattr(doctree, "findall", doctree.traverse) + for node in findall(lambda n: "source" in n): node["source"] = Path(node["source"]).name if flatten_outdir: @@ -60,3 +76,33 @@ def read( return doctree return read + + +# comparison files will need updating +# alternatively the resolution of https://github.com/ESSS/pytest-regressions/issues/32 +@pytest.fixture() +def file_regression(file_regression): + return FileRegression(file_regression) + + +class FileRegression: + ignores = () + changes = ( + # TODO: Remove when support for Sphinx<=6 is dropped, + (re.escape(" translation_progress=\"{'total': 0, 'translated': 0}\""), ""), + # TODO: Remove when support for Sphinx<7.2 is dropped, + (r"original_uri=\"[^\"]*\"\s", ""), + # TODO: Remove when support for Sphinx<7.2 is dropped + ("Link to", "Permalink to"), + ) + + def __init__(self, file_regression): + self.file_regression = file_regression + + def check(self, data, **kwargs): + return self.file_regression.check(self._strip_ignores(data), **kwargs) + + def _strip_ignores(self, data): + for src, dst in self.changes: + data = re.sub(src, dst, data) + return data diff --git a/tests/test_gateddirective.py b/tests/test_gateddirective.py index dd1fe2a..4a493ca 100644 --- a/tests/test_gateddirective.py +++ b/tests/test_gateddirective.py @@ -4,7 +4,7 @@ import sphinx from bs4 import BeautifulSoup from sphinx.errors import ExtensionError - +from pathlib import Path from sphinx.testing.util import strip_escseq SPHINX_VERSION = f".sphinx{sphinx.version_info[0]}" @@ -14,7 +14,7 @@ @pytest.mark.parametrize("docname", ["exercise-gated.html"]) def test_gated_exercise_build(app, docname, file_regression): app.build() - path_to_html = app.outdir / docname + path_to_html = Path(app.outdir) / docname # get content markup soup = BeautifulSoup(path_to_html.read_text(encoding="utf8"), "html.parser") exercise_directives = soup.select("div.exercise") @@ -27,8 +27,7 @@ def test_gated_exercise_build(app, docname, file_regression): @pytest.mark.parametrize("docname", ["exercise-gated"]) def test_gated_exercise_doctree(app, docname, get_sphinx_app_doctree): # Clean Up Build Directory from Previous Runs - build_dir = "/".join(app.outdir.split("/")[:-1]) - shutil.rmtree(build_dir) + shutil.rmtree(str(app.outdir)) # Test app.build() get_sphinx_app_doctree( @@ -45,7 +44,7 @@ def test_gated_exercise_doctree(app, docname, get_sphinx_app_doctree): ) def test_gated_solution_build(app, docname, file_regression): app.build() - path_to_html = app.outdir / docname + path_to_html = Path(app.outdir) / docname # get content markup soup = BeautifulSoup(path_to_html.read_text(encoding="utf8"), "html.parser") solution_directives = soup.select("div.solution") @@ -60,8 +59,7 @@ def test_gated_solution_build(app, docname, file_regression): @pytest.mark.parametrize("docname", ["solution-exercise", "solution-exercise-gated"]) def test_gated_solution_doctree(app, docname, get_sphinx_app_doctree): # Clean Up Build Directory from Previous Runs - build_dir = "/".join(app.outdir.split("/")[:-1]) - shutil.rmtree(build_dir) + shutil.rmtree(str(app.outdir)) # Test app.build() get_sphinx_app_doctree( diff --git a/tests/test_gateddirective/solution-exercise-0.sphinx7.html b/tests/test_gateddirective/solution-exercise-0.sphinx7.html new file mode 100644 index 0000000..201fdd3 --- /dev/null +++ b/tests/test_gateddirective/solution-exercise-0.sphinx7.html @@ -0,0 +1,43 @@ +
Solution to Exercise 1
+This is a solution to Non-Gated Exercise 1
+import numpy as np
+import matplotlib.pyplot as plt
+
+# Fixing random state for reproducibility
+np.random.seed(19680801)
+
+dt = 0.01
+t = np.arange(0, 30, dt)
+nse1 = np.random.randn(len(t)) # white noise 1
+nse2 = np.random.randn(len(t)) # white noise 2
+
+# Two signals with a coherent part at 10Hz and a random part
+s1 = np.sin(2 * np.pi * 10 * t) + nse1
+s2 = np.sin(2 * np.pi * 10 * t) + nse2
+
+fig, axs = plt.subplots(2, 1)
+axs[0].plot(t, s1, t, s2)
+axs[0].set_xlim(0, 2)
+axs[0].set_xlabel('time')
+axs[0].set_ylabel('s1 and s2')
+axs[0].grid(True)
+
+cxy, f = axs[1].cohere(s1, s2, 256, 1. / dt)
+axs[1].set_ylabel('coherence')
+
+fig.tight_layout()
+plt.show()
+
With some follow up text to the solution
+Solution to Exercise 2 (Replicate Matplotlib Plot)
+This is a solution to Non-Gated Exercise 1
+import numpy as np
+import matplotlib.pyplot as plt
+
+# Fixing random state for reproducibility
+np.random.seed(19680801)
+
+dt = 0.01
+t = np.arange(0, 30, dt)
+nse1 = np.random.randn(len(t)) # white noise 1
+nse2 = np.random.randn(len(t)) # white noise 2
+
+# Two signals with a coherent part at 10Hz and a random part
+s1 = np.sin(2 * np.pi * 10 * t) + nse1
+s2 = np.sin(2 * np.pi * 10 * t) + nse2
+
+fig, axs = plt.subplots(2, 1)
+axs[0].plot(t, s1, t, s2)
+axs[0].set_xlim(0, 2)
+axs[0].set_xlabel('time')
+axs[0].set_ylabel('s1 and s2')
+axs[0].grid(True)
+
+cxy, f = axs[1].cohere(s1, s2, 256, 1. / dt)
+axs[1].set_ylabel('coherence')
+
+fig.tight_layout()
+plt.show()
+
With some follow up text to the solution
+Solution to Exercise 3
+This is a solution to Gated Exercise 1
+import numpy as np
+import matplotlib.pyplot as plt
+
+# Fixing random state for reproducibility
+np.random.seed(19680801)
+
+dt = 0.01
+t = np.arange(0, 30, dt)
+nse1 = np.random.randn(len(t)) # white noise 1
+nse2 = np.random.randn(len(t)) # white noise 2
+
+# Two signals with a coherent part at 10Hz and a random part
+s1 = np.sin(2 * np.pi * 10 * t) + nse1
+s2 = np.sin(2 * np.pi * 10 * t) + nse2
+
+fig, axs = plt.subplots(2, 1)
+axs[0].plot(t, s1, t, s2)
+axs[0].set_xlim(0, 2)
+axs[0].set_xlabel('time')
+axs[0].set_ylabel('s1 and s2')
+axs[0].grid(True)
+
+cxy, f = axs[1].cohere(s1, s2, 256, 1. / dt)
+axs[1].set_ylabel('coherence')
+
+fig.tight_layout()
+plt.show()
+
With some follow up text to the solution
+Solution to Exercise 4 (Replicate Matplotlib Plot)
+This is a solution to Gated Exercise 2
+import numpy as np
+import matplotlib.pyplot as plt
+
+# Fixing random state for reproducibility
+np.random.seed(19680801)
+
+dt = 0.01
+t = np.arange(0, 30, dt)
+nse1 = np.random.randn(len(t)) # white noise 1
+nse2 = np.random.randn(len(t)) # white noise 2
+
+# Two signals with a coherent part at 10Hz and a random part
+s1 = np.sin(2 * np.pi * 10 * t) + nse1
+s2 = np.sin(2 * np.pi * 10 * t) + nse2
+
+fig, axs = plt.subplots(2, 1)
+axs[0].plot(t, s1, t, s2)
+axs[0].set_xlim(0, 2)
+axs[0].set_xlabel('time')
+axs[0].set_ylabel('s1 and s2')
+axs[0].grid(True)
+
+cxy, f = axs[1].cohere(s1, s2, 256, 1. / dt)
+axs[1].set_ylabel('coherence')
+
+fig.tight_layout()
+plt.show()
+
With some follow up text to the solution
+