diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7808647e4..3cbad3c43 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -112,7 +112,26 @@ jobs: ETS_TOOLKIT: qt4 # empty for push/pull_request, so those runs keep the committed image MAYAVI_RENDER_FLAKY: ${{ inputs.render_flaky }} - run: python scripts/render_docs.py + # under coverage because this is where sixty of the ninety examples are + # run -- the other thirty are `pytest examples` in tests.yml. Every + # example is a subprocess, so it reports anything at all only because of + # `patch = ["subprocess"]`; render_docs.py chdirs into docs/source, but + # the data files still land beside pyproject.toml, where combine wants + # them. + run: coverage run scripts/render_docs.py + - name: Combine coverage + if: '!cancelled()' + run: | + coverage combine + coverage xml + coverage report --show-missing --skip-covered + - uses: codecov/codecov-action@v7.0.0 + if: '!cancelled()' + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # informational only -- a failed upload must not fail the job + fail_ci_if_error: false - name: Report regenerated files # --stat alone shows nothing for a figure that is new rather than # changed, which is how the gallery went years with images CI rendered diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 15f1efa67..199af75ef 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -230,11 +230,76 @@ jobs: files: coverage.xml fail_ci_if_error: false + # The examples the gallery does not render, which is where every other one + # is run: examples/tvtk, the explorer application, the four top-level ones, + # and the handful that neither show a figure nor open a dialog. Which those + # are is asked of docs/source/render_examples.py rather than listed, so the + # two sets cannot drift apart -- between them every example in the repository + # is executed on every PR. + # + # A job of its own for the reasons `integration` is one: they want a display + # and the [app] extra, and a step conditioned on the matrix stops running in + # silence when the matrix moves under it. One VTK is enough -- what these + # actually exercise is the ETS API the examples are written against, which is + # why the `ets: main` row is here and not only on the matrix above: every bug + # this suite turned up when it was written (a pyface 8 widget that no longer + # creates its own control, taking IVTK's splitter down with a null; a + # PipelineBrowser attribute the workbench view had wrong) was that kind. + examples: + name: Examples ${{ (matrix.ets && 'ets-main') || '' }} + strategy: + matrix: + ets: ['', 'main'] + fail-fast: false + runs-on: ubuntu-latest + defaults: + run: + shell: bash + timeout-minutes: 20 # the suite takes about a minute + env: + ETS_TOOLKIT: qt4 + QT_API: pyside6 + PYTHONUNBUFFERED: '1' + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 # setuptools_scm needs the tags + - uses: pyvista/setup-headless-display-action@v4 + with: + qt: true + - uses: actions/setup-python@v7.0.0 + with: + python-version: '3.14' + - run: python -m pip install --upgrade pip # --group needs pip >= 25.1 + # scipy is the only third-party import in this set (array_animation.py); + # the rendered examples want more, and docs.yml's `docs` group has those + - run: python -m pip install --group test pyside6 scipy -ve ".[app]" + - uses: ./.github/actions/install-ets-main + if: matrix.ets == 'main' + - run: python -c "import vtk; print(f'VTK {vtk.VTK_VERSION}')" + # every case is a subprocess, so this reports anything at all only because + # of `patch = ["subprocess"]`; the wrapper's own per-example timeout is + # tighter than pytest's, which is the backstop behind it + - run: coverage run -m pytest -v --timeout=180 examples + - name: Combine coverage + if: '!cancelled()' + run: | + coverage combine + coverage xml + coverage report --show-missing --skip-covered + - uses: codecov/codecov-action@v7.0.0 + if: '!cancelled()' + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + fail_ci_if_error: false + # Scheduled runs have no associated PR where a failure would be noticed, so # open an issue (if there isn't one already) when they break issue-on-failure: name: Open issue on scheduled failure - needs: [tests, integration] + needs: [tests, integration, examples] if: failure() && github.event_name == 'schedule' runs-on: ubuntu-latest permissions: diff --git a/CLAUDE.md b/CLAUDE.md index d9d3a2b8b..5fd856e9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,7 @@ python -m build # Test suites (CI runs exactly these) pytest -v --timeout=10 mayavi pytest -sv --timeout=60 tvtk +pytest -v --timeout=180 examples ``` - The files in `integrationtests/mayavi/` are not pytest modules — each is an `optparse` script subclassing `TestCase(Mayavi)`, meant to be run as `python test_contour.py`, and importing one hands pytest `Test*` classes it cannot instantiate. @@ -45,6 +46,13 @@ pytest -sv --timeout=60 tvtk A *step* on a `tests` row was tried first and never ran: `!matrix.vtk` matched nothing because the `vtk-dev` include overrides no original matrix value, so GitHub merges it into the base ubuntu combination instead of adding a row, and every ubuntu row therefore has `vtk` set. CI was green throughout. A matrix-conditioned step fails silently that way; a job's absence from the checks is visible. +- `pytest examples` runs the examples the gallery does not, one subprocess each (30 of the 90, ~1 min), in `tests.yml`'s own `examples` job. + `mayavi/tests/common.py:run_example_headless` is what the child calls: it stubs out everything an example ends by blocking in — `mlab.show`, `GUI.start_event_loop`, `configure_traits`, `vtkRenderWindowInteractor::Start`, `QApplication.exec` — pushes the same re-raising traits handler the suites use, makes warnings fatal, and runs the rest of the script. + The filters are `EXAMPLE_WARNING_FILTERS` in the same file, shared with `scripts/render_docs.py` so that the two halves of the example set hold examples to one standard; pytest's own `filterwarnings` cannot do it, as it applies to the process pytest runs in and every case here is a subprocess. + Which examples those are is asked of `render_examples.rendered_examples()` rather than listed, so the two sets cannot drift: an example that stops being rendered starts being run here instead. + `user_mayavi.py` and `zzz_reader.py` are the exception (`RUN_AS_MODULE`) — both `sys.exit(1)` when run as `__main__`, being meant for the application to import, so they are run under their own module name for their module body. + `examples/conftest.py` keeps pytest from importing the example scripts themselves, as `integrationtests/conftest.py` does. + Both `ets` rows run, unlike `integration`'s reasoning about envisage: what these exercise is the ETS API the examples are written against, and every bug the suite found when it was written was ETS drift (pyface 8 widgets that no longer create their own control, which took `IVTK`'s `QSplitter` down with a null; `browser_view.py` reaching for a `PipelineBrowser.ui` that is spelled `_ui`). - Regeneration is skipped if `tvtk/tvtk_classes.zip` is < 120 s old (`_tvtk_built_recently` in `setup.py`). - Warnings are errors. The filters live in `mayavi/tests/conftest.py` and `tvtk/tests/conftest.py` rather than `pyproject.toml`, so that they ship in the wheel and so reach the `pytest --pyargs` runs below and in `wheel.yml`. @@ -78,6 +86,11 @@ If a warning is genuinely unfixable, add it to `nitpick_ignore` in `docs/source/ The toolkit is a per-process choice, so `capture_in_subprocess` sets `ETS_TOOLKIT=wx` in the child for any example whose source imports `wx` (`is_wx_example`), and `capture_one` sends it to `capture_wx_dialog` — the wx counterpart of `capture_dialog`, `WindowDC`/`MemoryDC` in place of `QWidget.grab`. Note it cannot use `keep_windows_in_background()`, which is Qt-only, so a local render of those two will take focus. wxPython has no Linux wheels on PyPI: `docs.yml` takes them from `extras.wxpython.org`, which is published per Ubuntu release and per Python — currently cp313 at the newest, which is why that job pins Python 3.13 and `ubuntu-24.04` rather than `-latest`. +- `capture_one` pushes a re-raising traits exception handler, as the suites' conftests do. + Without it an exception inside a notification handler is printed and swallowed, the example renders a figure with whatever that handler was going to draw missing from it, and the child exits 0 — which throws its output away. + That is how `coil_design_application` published a picture of two coils and no magnetic field: `np.NAN` went away in NumPy 2 and the `_get_Bnorm` property that computes the field raised on every call, silently (gh-1418). +- `render_examples.rendered_examples()` is the list of examples the gallery runs, and `examples/test_examples.py` runs the complement, so between the two every example is executed on every PR. + `EXAMPLE_DIR` is absolute for that reason — the helper has to answer the same thing from outside `docs/source`. - Parts of `docs/source/mayavi/auto/` are generated: `mlab_reference.py` (repo root) emits the mlab API reference, `docs/source/render_examples.py` emits the example gallery. Both are re-run in CI and both are also committed, so a plain `make -C docs html` works offline — which means a generator change must be committed **together with** its regenerated output, or `-W` fails on the stale copies. - Regenerate with `python scripts/render_docs.py`, which drives all of them in the right order. @@ -102,7 +115,8 @@ If a warning is genuinely unfixable, add it to `nitpick_ignore` in `docs/source/ They were `enthought_mayavi_mlab_*` until 2026-07, from the pre-2010 `enthought.mayavi` package name — which meant `mlab_reference.py` looked for names that did not exist and the mlab reference shipped with no illustrations at all for years. - A rebuild is byte-for-byte reproducible, so regenerating shows a diff only where something really changed: the doc version is truncated to `4.8.4.dev` (the commit and date would retitle every page), `html_last_updated_fmt` is off with the build date carried by the site landing page alone, and the renderers seed `np.random` because several `mlab.test_*` functions plot random data. `FLAKY_EXAMPLES` in `render_examples.py` names the examples that still are not reproducible: `tvtk_in_mayavi` and `magnetic_field`, which draw overlapping translucent actors that VTK composites differently in ~1% of pixels (roughly one run in five, and three of four, respectively), and `wx_mayavi_embed_in_notebook`, a screenshot of a wx window whose notebook lands differently — it came back changed in two of the four CI runs after it was added, on the committed bytes both times. - Their committed images are reused rather than re-rendered, so the published figures stop flipping back and forth; set `MAYAVI_RENDER_FLAKY=1` (or tick `render_flaky` on a `workflow_dispatch`) to redo them deliberately. + They are still *run* — that is the only place they ever run — but their figure goes to a scratch directory and the committed image stays put, so the published figures stop flipping back and forth; set `MAYAVI_RENDER_FLAKY=1` (or tick `render_flaky` on a `workflow_dispatch`) to redo them deliberately. + Skipping the run as well is what let `magnetic_field` sit broken: `np.arctan(x/y)` divides by zero on the axis of the coil, which is fatal under the render's warnings-as-errors, and nothing had executed it since the warnings became fatal. For the first two, enabling depth peeling (it does engage — `last_rendering_used_depth_peeling` is 1) and forcing a `scene.render()` before the capture were both measured over ten runs and neither helps, so leave it alone rather than re-testing. Beware that five runs is not enough to call this stable; that sample size gave a false positive twice. - `mlab.savefig` honours the display's device pixel ratio, and neither `magnification=1` nor an explicit `size` overrides it, so a HiDPI display would give images 2x the size of CI's. @@ -141,6 +155,8 @@ If the change touched a VTK workaround in any form — a `vtk_*_version` or `sys Hence the `pip uninstall` first, and `assert_from_main.py` after, which reads each distribution's `direct_url.json`: nothing else distinguishes a real `main` install from a fall back to PyPI, and the row would be green either way. The package list lives only in `requirements.txt` — `pip uninstall` takes it with `-r` and the script parses it — so the three uses cannot drift. - It runs *after* the package install so nothing can undo it, which is why `pip check` stands in for the floor checking that installing first would have got from the resolver. +- The `examples` job runs `pytest examples` — the thirty examples the gallery does not render — under coverage, on both `ets` rows. + See **Building and testing locally** above for what it covers and why the ETS matrix is there. - `.github/actions/open-issue` — composite action behind both `issue-on-failure` jobs: files an issue unless one with the same title is already open, appending the run URL. Callers need an `actions/checkout` (sparse is enough) because a local action has to be on disk to be used, and `permissions: issues: write`. - `wheel.yml` — mismatch matrix: build per-OS wheels against latest VTK, test them against all supported older VTKs (rows deliberately mirror `tests.yml` so failures are attributable to the mismatch), `twine check --strict`, and trusted publishing to PyPI on GitHub releases (environment `pypi`, `needs: [build, test, check]`). @@ -150,6 +166,9 @@ If the change touched a VTK workaround in any form — a `vtk_*_version` or `sys Doc build requirements live in the `docs` dependency group (PEP 735, `pip --group`), so the whole install is one `pip install --group docs -ve ".[app]"`. Unlike `tests.yml` this build is *not* `--no-build-isolation`: nothing here pins an older VTK, so letting the isolated build fetch the latest is fine. To review a PR's rendered docs, download the `docs-site` artifact and serve it (`python -m http.server`); GitHub has no linked HTML preview, and `deploy-pages`' `preview` input is alpha-gated. + The render step runs under `coverage run` and uploads to codecov like the other jobs: it is where sixty of the ninety examples execute, and every one of them is a subprocess, so it measures anything at all only because of `patch = ["subprocess"]`. + `render_docs.py` chdirs into `docs/source`, but coverage anchors its data files to the config it was started from, so they still land beside `pyproject.toml` where `coverage combine` wants them. + The generator itself stays unmeasured here — this build is isolated, and `tests.yml`'s install step is the one that covers it. See **Documentation** below. ## Gotchas diff --git a/docs/source/mayavi/auto/coil_design_application.py b/docs/source/mayavi/auto/coil_design_application.py index 4172ea42e..09e7ee7ae 100644 --- a/docs/source/mayavi/auto/coil_design_application.py +++ b/docs/source/mayavi/auto/coil_design_application.py @@ -185,10 +185,10 @@ def _get_Bnorm(self): # to use an ImageData Bmax = 10 * np.median(Bnorm) - Bx[Bnorm > Bmax] = np.NAN - By[Bnorm > Bmax] = np.NAN - Bz[Bnorm > Bmax] = np.NAN - Bnorm[Bnorm > Bmax] = np.NAN + Bx[Bnorm > Bmax] = np.nan + By[Bnorm > Bmax] = np.nan + Bz[Bnorm > Bmax] = np.nan + Bnorm[Bnorm > Bmax] = np.nan self.Bx = Bx self.By = By diff --git a/docs/source/mayavi/auto/datasets.py b/docs/source/mayavi/auto/datasets.py index a6d6a820a..47d156ea4 100644 --- a/docs/source/mayavi/auto/datasets.py +++ b/docs/source/mayavi/auto/datasets.py @@ -116,19 +116,20 @@ def unstructured_grid(): cells = array([4, 0, 1, 2, 3, # tetra 8, 4, 5, 6, 7, 8, 9, 10, 11 # hex ]) - # The offsets for the cells, i.e. the indices where the cells - # start. - offset = array([0, 5]) tetra_type = tvtk.Tetra().cell_type # VTK_TETRA == 10 hex_type = tvtk.Hexahedron().cell_type # VTK_HEXAHEDRON == 12 cell_types = array([tetra_type, hex_type]) - # Create the array of cells unambiguously. + # Create the array of cells unambiguously. The cell array keeps the + # offsets itself, so there is no separate list of them to build: VTK 9.6 + # deprecated both the CellArray.set_cells that took a count and the + # UnstructuredGrid.set_cells that took cell locations, and 9.7 removed the + # first of them outright. cell_array = tvtk.CellArray() - cell_array.set_cells(2, cells) + cell_array.import_legacy_format(cells) # Now create the UG. ug = tvtk.UnstructuredGrid(points=points) # Now just set the cell types and reuse the ug locations and cells. - ug.set_cells(cell_types, offset, cell_array) + ug.set_cells(cell_types, cell_array) scalars = random.random(points.shape[0]) ug.point_data.scalars = scalars ug.point_data.scalars.name = 'scalars' diff --git a/docs/source/mayavi/auto/magnetic_field.py b/docs/source/mayavi/auto/magnetic_field.py index 1b270b019..1cda3400f 100644 --- a/docs/source/mayavi/auto/magnetic_field.py +++ b/docs/source/mayavi/auto/magnetic_field.py @@ -108,19 +108,22 @@ def magnetic_field(r, n, r0, R): y = r[:, 1] z = r[:, 2] rho = np.sqrt(x**2 + y**2) - theta = np.arctan(x/y) - theta[y==0] = 0 - - E = special.ellipe((4 * R * rho)/( (R + rho)**2 + z**2)) - K = special.ellipk((4 * R * rho)/( (R + rho)**2 + z**2)) - Bz = 1/np.sqrt((R + rho)**2 + z**2) * ( - K - + E * (R**2 - rho**2 - z**2)/((R - rho)**2 + z**2) - ) - Brho = z/(rho*np.sqrt((R + rho)**2 + z**2)) * ( - -K - + E * (R**2 + rho**2 + z**2)/((R - rho)**2 + z**2) - ) + # on the axis of the coil rho and y are zero, so several of these divide by + # zero; what that produces is replaced just below + with np.errstate(divide='ignore', invalid='ignore'): + theta = np.arctan(x/y) + theta[y==0] = 0 + + E = special.ellipe((4 * R * rho)/( (R + rho)**2 + z**2)) + K = special.ellipk((4 * R * rho)/( (R + rho)**2 + z**2)) + Bz = 1/np.sqrt((R + rho)**2 + z**2) * ( + K + + E * (R**2 - rho**2 - z**2)/((R - rho)**2 + z**2) + ) + Brho = z/(rho*np.sqrt((R + rho)**2 + z**2)) * ( + -K + + E * (R**2 + rho**2 + z**2)/((R - rho)**2 + z**2) + ) # On the axis of the coil we get a divided by zero here. This returns a # NaN, where the field is actually zero : Brho[np.isnan(Brho)] = 0 diff --git a/docs/source/mayavi/auto/mlab_visual.py b/docs/source/mayavi/auto/mlab_visual.py index dc3661793..ea0a5cb8d 100644 --- a/docs/source/mayavi/auto/mlab_visual.py +++ b/docs/source/mayavi/auto/mlab_visual.py @@ -34,8 +34,8 @@ # Even sillier animation. b1 = visual.box() -b2 = visual.box(x=4., color=visual.color.red) -b3 = visual.box(x=-4, color=visual.color.red) +b2 = visual.box(x=4., color=(1, 0, 0)) +b3 = visual.box(x=-4, color=(1, 0, 0)) b1.v = 5.0 @mlab.show diff --git a/docs/source/mayavi/generated_images/example_coil_design_application.jpg b/docs/source/mayavi/generated_images/example_coil_design_application.jpg index cbd4bdfeb..ca6fa7783 100644 Binary files a/docs/source/mayavi/generated_images/example_coil_design_application.jpg and b/docs/source/mayavi/generated_images/example_coil_design_application.jpg differ diff --git a/docs/source/mayavi/generated_images/example_compute_in_thread.jpg b/docs/source/mayavi/generated_images/example_compute_in_thread.jpg index 8d011bc3c..cc97402ff 100644 Binary files a/docs/source/mayavi/generated_images/example_compute_in_thread.jpg and b/docs/source/mayavi/generated_images/example_compute_in_thread.jpg differ diff --git a/docs/source/mayavi/generated_images/example_contour.jpg b/docs/source/mayavi/generated_images/example_contour.jpg index 4c6db6477..7fc5f2448 100644 Binary files a/docs/source/mayavi/generated_images/example_contour.jpg and b/docs/source/mayavi/generated_images/example_contour.jpg differ diff --git a/docs/source/mayavi/generated_images/example_contour_contour.jpg b/docs/source/mayavi/generated_images/example_contour_contour.jpg index 5034794f2..1fed548b9 100644 Binary files a/docs/source/mayavi/generated_images/example_contour_contour.jpg and b/docs/source/mayavi/generated_images/example_contour_contour.jpg differ diff --git a/docs/source/mayavi/generated_images/example_delaunay_graph.jpg b/docs/source/mayavi/generated_images/example_delaunay_graph.jpg index 36e83ee55..b4f39fcc3 100644 Binary files a/docs/source/mayavi/generated_images/example_delaunay_graph.jpg and b/docs/source/mayavi/generated_images/example_delaunay_graph.jpg differ diff --git a/docs/source/mayavi/generated_images/example_glyph.jpg b/docs/source/mayavi/generated_images/example_glyph.jpg index 2503565b2..50e21e374 100644 Binary files a/docs/source/mayavi/generated_images/example_glyph.jpg and b/docs/source/mayavi/generated_images/example_glyph.jpg differ diff --git a/docs/source/mayavi/generated_images/example_lorenz.jpg b/docs/source/mayavi/generated_images/example_lorenz.jpg index 4fe5a9823..849ec8f22 100644 Binary files a/docs/source/mayavi/generated_images/example_lorenz.jpg and b/docs/source/mayavi/generated_images/example_lorenz.jpg differ diff --git a/docs/source/mayavi/generated_images/example_lorenz_ui.jpg b/docs/source/mayavi/generated_images/example_lorenz_ui.jpg index d8e4d4edb..767541c87 100644 Binary files a/docs/source/mayavi/generated_images/example_lorenz_ui.jpg and b/docs/source/mayavi/generated_images/example_lorenz_ui.jpg differ diff --git a/docs/source/mayavi/generated_images/example_mayavi_traits_ui.jpg b/docs/source/mayavi/generated_images/example_mayavi_traits_ui.jpg index a8d1c2a5c..fe9f9e25e 100644 Binary files a/docs/source/mayavi/generated_images/example_mayavi_traits_ui.jpg and b/docs/source/mayavi/generated_images/example_mayavi_traits_ui.jpg differ diff --git a/docs/source/mayavi/generated_images/example_mlab_interactive_dialog.jpg b/docs/source/mayavi/generated_images/example_mlab_interactive_dialog.jpg index e05f028dc..546a72ba5 100644 Binary files a/docs/source/mayavi/generated_images/example_mlab_interactive_dialog.jpg and b/docs/source/mayavi/generated_images/example_mlab_interactive_dialog.jpg differ diff --git a/docs/source/mayavi/generated_images/example_multi_block.jpg b/docs/source/mayavi/generated_images/example_multi_block.jpg index 610e1fb74..82c8ada27 100644 Binary files a/docs/source/mayavi/generated_images/example_multi_block.jpg and b/docs/source/mayavi/generated_images/example_multi_block.jpg differ diff --git a/docs/source/mayavi/generated_images/example_multiple_engines.jpg b/docs/source/mayavi/generated_images/example_multiple_engines.jpg index 729d5fed2..5843a5b1b 100644 Binary files a/docs/source/mayavi/generated_images/example_multiple_engines.jpg and b/docs/source/mayavi/generated_images/example_multiple_engines.jpg differ diff --git a/docs/source/mayavi/generated_images/example_multiple_mlab_scene_models.jpg b/docs/source/mayavi/generated_images/example_multiple_mlab_scene_models.jpg index fea1c9941..a1f46f9f0 100644 Binary files a/docs/source/mayavi/generated_images/example_multiple_mlab_scene_models.jpg and b/docs/source/mayavi/generated_images/example_multiple_mlab_scene_models.jpg differ diff --git a/docs/source/mayavi/generated_images/example_numeric_source.jpg b/docs/source/mayavi/generated_images/example_numeric_source.jpg index 8d011bc3c..cc97402ff 100644 Binary files a/docs/source/mayavi/generated_images/example_numeric_source.jpg and b/docs/source/mayavi/generated_images/example_numeric_source.jpg differ diff --git a/docs/source/mayavi/generated_images/example_poll_file.jpg b/docs/source/mayavi/generated_images/example_poll_file.jpg index 0d85cf7f4..b5ca50c7c 100644 Binary files a/docs/source/mayavi/generated_images/example_poll_file.jpg and b/docs/source/mayavi/generated_images/example_poll_file.jpg differ diff --git a/docs/source/mayavi/generated_images/example_polydata.jpg b/docs/source/mayavi/generated_images/example_polydata.jpg index bcdb142d4..b32899f15 100644 Binary files a/docs/source/mayavi/generated_images/example_polydata.jpg and b/docs/source/mayavi/generated_images/example_polydata.jpg differ diff --git a/docs/source/mayavi/generated_images/example_qt_embedding.jpg b/docs/source/mayavi/generated_images/example_qt_embedding.jpg index 24ec20930..cb610084e 100644 Binary files a/docs/source/mayavi/generated_images/example_qt_embedding.jpg and b/docs/source/mayavi/generated_images/example_qt_embedding.jpg differ diff --git a/docs/source/mayavi/generated_images/example_scatter_plot.jpg b/docs/source/mayavi/generated_images/example_scatter_plot.jpg index 9144f6428..2b9735d73 100644 Binary files a/docs/source/mayavi/generated_images/example_scatter_plot.jpg and b/docs/source/mayavi/generated_images/example_scatter_plot.jpg differ diff --git a/docs/source/mayavi/generated_images/example_streamline.jpg b/docs/source/mayavi/generated_images/example_streamline.jpg index e6b4b2686..562af180a 100644 Binary files a/docs/source/mayavi/generated_images/example_streamline.jpg and b/docs/source/mayavi/generated_images/example_streamline.jpg differ diff --git a/docs/source/mayavi/generated_images/example_structured_grid.jpg b/docs/source/mayavi/generated_images/example_structured_grid.jpg index e14168a8c..364c58f08 100644 Binary files a/docs/source/mayavi/generated_images/example_structured_grid.jpg and b/docs/source/mayavi/generated_images/example_structured_grid.jpg differ diff --git a/docs/source/mayavi/generated_images/example_structured_points2d.jpg b/docs/source/mayavi/generated_images/example_structured_points2d.jpg index 3591b38cc..67ba7152a 100644 Binary files a/docs/source/mayavi/generated_images/example_structured_points2d.jpg and b/docs/source/mayavi/generated_images/example_structured_points2d.jpg differ diff --git a/docs/source/mayavi/generated_images/example_structured_points3d.jpg b/docs/source/mayavi/generated_images/example_structured_points3d.jpg index 2ffb1f5dc..43494548d 100644 Binary files a/docs/source/mayavi/generated_images/example_structured_points3d.jpg and b/docs/source/mayavi/generated_images/example_structured_points3d.jpg differ diff --git a/docs/source/mayavi/generated_images/example_superquad_with_gui.jpg b/docs/source/mayavi/generated_images/example_superquad_with_gui.jpg index d0abe8e78..8d0f562c7 100644 Binary files a/docs/source/mayavi/generated_images/example_superquad_with_gui.jpg and b/docs/source/mayavi/generated_images/example_superquad_with_gui.jpg differ diff --git a/docs/source/mayavi/generated_images/example_surf_regular_mlab.jpg b/docs/source/mayavi/generated_images/example_surf_regular_mlab.jpg index 6cc828c96..8078fa24f 100644 Binary files a/docs/source/mayavi/generated_images/example_surf_regular_mlab.jpg and b/docs/source/mayavi/generated_images/example_surf_regular_mlab.jpg differ diff --git a/docs/source/mayavi/generated_images/example_unstructured_grid.jpg b/docs/source/mayavi/generated_images/example_unstructured_grid.jpg index c63e763d8..d83d58e87 100644 Binary files a/docs/source/mayavi/generated_images/example_unstructured_grid.jpg and b/docs/source/mayavi/generated_images/example_unstructured_grid.jpg differ diff --git a/docs/source/mayavi/generated_images/mayavi_mlab_fancy_mesh.jpg b/docs/source/mayavi/generated_images/mayavi_mlab_fancy_mesh.jpg index b6b7371af..1657abcea 100644 Binary files a/docs/source/mayavi/generated_images/mayavi_mlab_fancy_mesh.jpg and b/docs/source/mayavi/generated_images/mayavi_mlab_fancy_mesh.jpg differ diff --git a/docs/source/render_examples.py b/docs/source/render_examples.py index 7b3441f36..4ab5e11d3 100644 --- a/docs/source/render_examples.py +++ b/docs/source/render_examples.py @@ -23,11 +23,20 @@ # Enthought imports from mayavi import mlab +from traits.api import push_exception_handler # A global counter, for subsitutions. global_counter = itertools.count() -EXAMPLE_DIR = '../../examples/mayavi' +# absolute, so that `rendered_examples` below answers the same thing wherever +# it is called from -- examples/test_examples.py imports it to work out which +# examples the gallery leaves for it to run +EXAMPLE_DIR = str(Path(__file__).resolve().parents[2] / 'examples' / 'mayavi') + +# The gallery sections, in the order render_examples() writes them. The fifth, +# the top-level "Misc examples", is listed rather than rendered. +GALLERY_SECTIONS = ('mlab', 'interactive', 'advanced_visualization', + 'data_interaction') # Examples whose figure differs from run to run, so that re-rendering would flip # the published image back and forth for no gain. The first two draw @@ -501,6 +510,13 @@ def capture_one(filename, image_file): """ Renders one example, the way that suits it. Runs in the child. """ apply_unsettable_warning_filters() + # Traits prints an exception raised in a notification handler and carries + # on, so an example that breaks inside one still renders -- a figure with + # whatever the handler was going to draw missing from it -- and the child + # exits 0, which throws its output away. That is how coil_design_application + # published a picture of two coils and no magnetic field for as long as it + # did. The suites push the same handler from their conftests. + push_exception_handler(reraise_exceptions=True) # An error reported in a modal box is a hang here: nobody is watching to # click it away, the per-example timeout is what ends it, and the message # -- the only thing that says what went wrong -- dies inside the box. @@ -563,6 +579,39 @@ def capture_in_subprocess(filename, image_file): shutil.rmtree(state, ignore_errors=True) +def renders_a_figure(filename): + """ Whether the gallery runs this example to make a figure of it. + + `examples/test_examples.py` runs the ones this says no to, so that + between the two every example is executed somewhere. A flaky example + says yes: its committed image is kept, but it is still run. + """ + if os.path.splitext(os.path.basename(filename))[0] in SKIP_EXAMPLES: + return False + return (is_dialog_example(filename) or is_mlab_example(filename) + or is_app_example(filename)) + + +def collected_examples(section): + """ The examples one gallery section lists, shortest (simplest) first. + """ + files = glob.glob(os.path.join(EXAMPLE_DIR, section, '*.py')) + if section == 'mlab': + # that directory also holds examples belonging to no section + files = [name for name in files if is_mlab_example(name)] + return sorted(files, + key=lambda name: (len(Path(name).read_text().splitlines()), + name)) + + +def rendered_examples(): + """ Every example the gallery runs, as absolute paths. + """ + return {os.path.abspath(name) for section in GALLERY_SECTIONS + for name in collected_examples(section) + if renders_a_figure(name)} + + def capture_example(filename, short_file_name, image_file): """ Renders one example's figure, picking the way that suits it. @@ -572,20 +621,27 @@ def capture_example(filename, short_file_name, image_file): if short_file_name in SKIP_EXAMPLES: print("Skipping %s: %s" % (filename, SKIP_EXAMPLES[short_file_name])) return - if not should_render(short_file_name, image_file): - print("Keeping the committed image for %s; it does not render " - "reproducibly (set MAYAVI_RENDER_FLAKY=1 to redo it)" % filename) - return - if not (is_dialog_example(filename) or is_mlab_example(filename) - or is_app_example(filename)): + if not renders_a_figure(filename): print("Skipping %s: it neither shows a figure nor opens a dialog" % filename) return print("Generating images for %s" % filename, flush=True) + # A flaky example is still run -- it has to keep working, and this is the + # only place it ever runs -- but its figure goes to a scratch directory and + # the committed one stays put, so the published image stops flipping back + # and forth. + keep_committed = not should_render(short_file_name, image_file) + scratch = tempfile.mkdtemp(prefix='mayavi-flaky-') if keep_committed else None + target = (os.path.join(scratch, os.path.basename(image_file)) + if keep_committed else image_file) try: - capture_in_subprocess(filename, image_file) - if not os.path.exists(image_file): + capture_in_subprocess(filename, target) + if not os.path.exists(target): raise RuntimeError('rendered without leaving an image behind') + if keep_committed: + print("Keeping the committed image for %s; it does not render " + "reproducibly (set MAYAVI_RENDER_FLAKY=1 to redo it)" + % filename) except Exception as exc: # one broken example should not cost the gallery every later figure RENDER_FAILURES.append('%s: %s: %s' @@ -611,6 +667,9 @@ def capture_example(filename, short_file_name, image_file): lines = (lines[:30] + [' ... %d lines omitted ...' % (len(lines) - 70)] + lines[-40:]) print(textwrap.indent('\n'.join(lines), ' '), flush=True) + finally: + if scratch is not None: + shutil.rmtree(scratch, ignore_errors=True) def is_mlab_example(filename): @@ -1055,13 +1114,7 @@ def render_examples(render_images=False, out_dir='mayavi/auto'): ########################################################################## # Mlab examples - example_files = [ filename - for filename in glob.glob(os.path.join(EXAMPLE_DIR, - 'mlab', '*.py')) - if is_mlab_example(filename)] - # Sort by file length (gives a measure of the complexity of the - # example) - example_files.sort(key=lambda name: (len(Path(name).read_text().splitlines()), name)) + example_files = collected_examples('mlab') mlab_example_lister = MlabExampleLister(render_images=render_images, out_dir=out_dir, @@ -1074,12 +1127,7 @@ def render_examples(render_images=False, out_dir='mayavi/auto'): ########################################################################## # Interactive application examples - example_files = [ filename - for filename in glob.glob(os.path.join(EXAMPLE_DIR, - 'interactive', '*.py'))] - # Sort by file length (gives a measure of the complexity of the - # example) - example_files.sort(key=lambda name: (len(Path(name).read_text().splitlines()), name)) + example_files = collected_examples('interactive') example_lister = RenderedExampleLister( render_images=render_images, images_dir='mayavi/generated_images', @@ -1095,12 +1143,7 @@ def render_examples(render_images=False, out_dir='mayavi/auto'): ########################################################################## # Advanced visualization examples - example_files = [ filename - for filename in glob.glob(os.path.join(EXAMPLE_DIR, - 'advanced_visualization', '*.py'))] - # Sort by file length (gives a measure of the complexity of the - # example) - example_files.sort(key=lambda name: (len(Path(name).read_text().splitlines()), name)) + example_files = collected_examples('advanced_visualization') example_lister = RenderedExampleLister( render_images=render_images, images_dir='mayavi/generated_images', @@ -1115,12 +1158,7 @@ def render_examples(render_images=False, out_dir='mayavi/auto'): ########################################################################## # Data interaction examples - example_files = [ filename - for filename in glob.glob(os.path.join(EXAMPLE_DIR, - 'data_interaction', '*.py'))] - # Sort by file length (gives a measure of the complexity of the - # example) - example_files.sort(key=lambda name: (len(Path(name).read_text().splitlines()), name)) + example_files = collected_examples('data_interaction') example_lister = RenderedExampleLister( render_images=render_images, images_dir='mayavi/generated_images', diff --git a/examples/conftest.py b/examples/conftest.py new file mode 100644 index 000000000..567efff1b --- /dev/null +++ b/examples/conftest.py @@ -0,0 +1,9 @@ +"""Keep pytest out of the example scripts themselves.""" +# Copyright (c) Enthought, Inc. +# License: BSD Style. + +# The examples are scripts, not test modules: importing one builds a scene, +# opens a dialog or stands the Mayavi2 application up at import time. +# test_examples.py, beside this file, runs them the way they expect -- one +# subprocess each -- so it is collected normally. +collect_ignore_glob = ['mayavi/*', 'tvtk/*'] diff --git a/examples/mayavi/advanced_visualization/datasets.py b/examples/mayavi/advanced_visualization/datasets.py index a6d6a820a..47d156ea4 100644 --- a/examples/mayavi/advanced_visualization/datasets.py +++ b/examples/mayavi/advanced_visualization/datasets.py @@ -116,19 +116,20 @@ def unstructured_grid(): cells = array([4, 0, 1, 2, 3, # tetra 8, 4, 5, 6, 7, 8, 9, 10, 11 # hex ]) - # The offsets for the cells, i.e. the indices where the cells - # start. - offset = array([0, 5]) tetra_type = tvtk.Tetra().cell_type # VTK_TETRA == 10 hex_type = tvtk.Hexahedron().cell_type # VTK_HEXAHEDRON == 12 cell_types = array([tetra_type, hex_type]) - # Create the array of cells unambiguously. + # Create the array of cells unambiguously. The cell array keeps the + # offsets itself, so there is no separate list of them to build: VTK 9.6 + # deprecated both the CellArray.set_cells that took a count and the + # UnstructuredGrid.set_cells that took cell locations, and 9.7 removed the + # first of them outright. cell_array = tvtk.CellArray() - cell_array.set_cells(2, cells) + cell_array.import_legacy_format(cells) # Now create the UG. ug = tvtk.UnstructuredGrid(points=points) # Now just set the cell types and reuse the ug locations and cells. - ug.set_cells(cell_types, offset, cell_array) + ug.set_cells(cell_types, cell_array) scalars = random.random(points.shape[0]) ug.point_data.scalars = scalars ug.point_data.scalars.name = 'scalars' diff --git a/examples/mayavi/advanced_visualization/magnetic_field.py b/examples/mayavi/advanced_visualization/magnetic_field.py index 1b270b019..1cda3400f 100644 --- a/examples/mayavi/advanced_visualization/magnetic_field.py +++ b/examples/mayavi/advanced_visualization/magnetic_field.py @@ -108,19 +108,22 @@ def magnetic_field(r, n, r0, R): y = r[:, 1] z = r[:, 2] rho = np.sqrt(x**2 + y**2) - theta = np.arctan(x/y) - theta[y==0] = 0 - - E = special.ellipe((4 * R * rho)/( (R + rho)**2 + z**2)) - K = special.ellipk((4 * R * rho)/( (R + rho)**2 + z**2)) - Bz = 1/np.sqrt((R + rho)**2 + z**2) * ( - K - + E * (R**2 - rho**2 - z**2)/((R - rho)**2 + z**2) - ) - Brho = z/(rho*np.sqrt((R + rho)**2 + z**2)) * ( - -K - + E * (R**2 + rho**2 + z**2)/((R - rho)**2 + z**2) - ) + # on the axis of the coil rho and y are zero, so several of these divide by + # zero; what that produces is replaced just below + with np.errstate(divide='ignore', invalid='ignore'): + theta = np.arctan(x/y) + theta[y==0] = 0 + + E = special.ellipe((4 * R * rho)/( (R + rho)**2 + z**2)) + K = special.ellipk((4 * R * rho)/( (R + rho)**2 + z**2)) + Bz = 1/np.sqrt((R + rho)**2 + z**2) * ( + K + + E * (R**2 - rho**2 - z**2)/((R - rho)**2 + z**2) + ) + Brho = z/(rho*np.sqrt((R + rho)**2 + z**2)) * ( + -K + + E * (R**2 + rho**2 + z**2)/((R - rho)**2 + z**2) + ) # On the axis of the coil we get a divided by zero here. This returns a # NaN, where the field is actually zero : Brho[np.isnan(Brho)] = 0 diff --git a/examples/mayavi/interactive/mlab_visual.py b/examples/mayavi/interactive/mlab_visual.py index dc3661793..ea0a5cb8d 100644 --- a/examples/mayavi/interactive/mlab_visual.py +++ b/examples/mayavi/interactive/mlab_visual.py @@ -34,8 +34,8 @@ # Even sillier animation. b1 = visual.box() -b2 = visual.box(x=4., color=visual.color.red) -b3 = visual.box(x=-4, color=visual.color.red) +b2 = visual.box(x=4., color=(1, 0, 0)) +b3 = visual.box(x=-4, color=(1, 0, 0)) b1.v = 5.0 @mlab.show diff --git a/examples/test_examples.py b/examples/test_examples.py new file mode 100644 index 000000000..776c30df5 --- /dev/null +++ b/examples/test_examples.py @@ -0,0 +1,69 @@ +"""Run the examples the gallery does not, one subprocess per script. + +``docs/source/render_examples.py`` executes every example it can shoot a +figure of, which is most of ``examples/mayavi``. This covers the rest -- +``examples/tvtk``, the explorer application, the top-level ones, and the few +in the gallery directories that neither show a figure nor open a dialog -- so +that between the two every example in the repository is run somewhere. + +Which examples those are is asked of the renderer rather than listed here: one +that stops being rendered starts being run by this instead, with no list to +keep in step. +""" +# Copyright (c) Enthought, Inc. +# License: BSD Style. + +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +DOCS_SOURCE = HERE.parent / 'docs' / 'source' + +# No example takes anything like this long; it is here so that one that will +# never finish fails the run instead of hanging it. +TIMEOUT = 120 + + +def _rendered_examples(): + """The examples the gallery runs, from the renderer that runs them.""" + # docs/source holds mayavi/ and tvtk/ subdirectories, and a namespace + # package beats an installed one wherever it turns up on sys.path -- so + # bind the real ones before putting it there. See CLAUDE.md. + import mayavi # noqa: F401 + import tvtk.api # noqa: F401 + sys.path.insert(0, str(DOCS_SOURCE)) + try: + from render_examples import rendered_examples + return rendered_examples() + finally: + sys.path.remove(str(DOCS_SOURCE)) + + +def _unrendered_examples(): + every = {path.resolve() for path in HERE.glob('*/**/*.py') + if path.name != 'conftest.py'} + return sorted(every - {Path(name) for name in _rendered_examples()}) + + +EXAMPLES = _unrendered_examples() + + +@pytest.mark.parametrize( + 'example', EXAMPLES, ids=[str(path.relative_to(HERE)) for path in EXAMPLES]) +def test_example(example, tmp_path): + # a fresh working directory each time: several examples write a file + # beside themselves (off_screen.py's example.png, the mayavi2 application's + # saved window layout), and none of these read one + code = ('from mayavi.tests.common import run_example_headless\n' + 'run_example_headless(%r)\n' % str(example)) + proc = subprocess.run([sys.executable, '-c', code], cwd=tmp_path, + timeout=TIMEOUT, capture_output=True, text=True) + if proc.returncode != 0: + pytest.fail( + '%s failed (exit status %d)\n\n--- stdout ---\n%s\n--- stderr ---\n%s' + % (example.relative_to(HERE), proc.returncode, proc.stdout[-3000:], + proc.stderr[-3000:]), + pytrace=False) diff --git a/examples/tvtk/dscene.py b/examples/tvtk/dscene.py index 46e89a700..c84e30690 100644 --- a/examples/tvtk/dscene.py +++ b/examples/tvtk/dscene.py @@ -163,6 +163,7 @@ def _create_rhs(self, parent): """ Creates the right hand side or bottom depending on the style. """ self.python_shell = PythonShell(parent) + self.python_shell.create() self.python_shell.bind('scene', self.scene) self.python_shell.bind('s', self.scene) diff --git a/examples/tvtk/off_screen.py b/examples/tvtk/off_screen.py index 42f52bf56..3a781984e 100644 --- a/examples/tvtk/off_screen.py +++ b/examples/tvtk/off_screen.py @@ -47,7 +47,7 @@ rw.add_renderer(ren) w2if = tvtk.WindowToImageFilter() -w2if.magnification = 2 +w2if.scale = (2, 2) w2if.input = rw ex = tvtk.PNGWriter() ex.file_name = "example.png" diff --git a/examples/tvtk/scene.py b/examples/tvtk/scene.py index 9ce6ccd71..334d6a4c3 100644 --- a/examples/tvtk/scene.py +++ b/examples/tvtk/scene.py @@ -163,6 +163,7 @@ def _create_rhs(self, parent): """ Creates the right hand side or bottom depending on the style. """ self.python_shell = PythonShell(parent) + self.python_shell.create() self.python_shell.bind('scene', self.scene) self.python_shell.bind('s', self.scene) diff --git a/examples/tvtk/visual/gyro.py b/examples/tvtk/visual/gyro.py index 44692c659..e61cd731c 100644 --- a/examples/tvtk/visual/gyro.py +++ b/examples/tvtk/visual/gyro.py @@ -7,7 +7,7 @@ from math import atan, cos, sin, pi from tvtk.tools.visual import vector, MVector, Box, Helix, Frame, \ - Cylinder, curve, color, iterate, show + Cylinder, curve, iterate, show top = vector(0,1.,0) # where top of spring is held @@ -50,10 +50,10 @@ rotor = Cylinder(pos = 0.5*gyro1.axis*(Lshaft-Drotor), axis = gyro1.axis*Drotor, radius = Rrotor, color = (0.5,0.5,0.5), length = 0.1) -stripe1 = curve(color = color.green, +stripe1 = curve(color = (0, 1, 0), points = [rotor.pos+1.03*rotor.axis+vector(0,Rrotor,0), rotor.pos+1.03*rotor.axis-vector(0,Rrotor,0)]) -stripe2 = curve(color = color.green, +stripe2 = curve(color = (0, 1, 0), points = [rotor.pos-0.03*rotor.axis+vector(0,Rrotor,0), rotor.pos-0.03*rotor.axis-vector(0,Rrotor,0)]) diff --git a/mayavi/plugins/_workbench_fixes.py b/mayavi/plugins/_workbench_fixes.py index e185df135..3fdaae8b8 100644 --- a/mayavi/plugins/_workbench_fixes.py +++ b/mayavi/plugins/_workbench_fixes.py @@ -6,6 +6,11 @@ (mayavi gh-1409, pyface gh-1263). pyface gh-1264 fixed it on main, but pyface's latest release (8.0.0) predates that commit, so mayavi patches the method itself until a release carries the fix -- see tvtk/WORKAROUNDS.md. + +The Python shell view the workbench opens needs one more of these: pyface's +console widget names an enum PyQt6 dropped. That fix used to hang off a +`fix_python_shell_view` that envisage 8.0.1 made unnecessary, and went out with +it; it is independent of envisage and still needed. """ # License: BSD Style. @@ -36,3 +41,22 @@ def is_node_for(self, obj): IViewTreeNode.is_node_for = is_node_for logger.debug('Patched pyface IViewTreeNode.is_node_for (gh-1409)') + + +def restore_qfont_typewriter(): + """Put back the ``QFont.TypeWriter`` alias PyQt6 dropped, for pyface. + + pyface names that style hint unscoped, the way PyQt5 and PySide expose it, + in its console widget, its code editor and its font registry. PyQt6 has + only the scoped enums, so building the Python shell there raises + ``AttributeError: type object 'QFont' has no attribute 'TypeWriter'``. One + alias covers every one of those call sites. + """ + try: + from pyface.qt import QtGui + except ImportError: # a toolkit with no Qt behind it + return + if hasattr(QtGui.QFont, 'TypeWriter'): + return + QtGui.QFont.TypeWriter = QtGui.QFont.StyleHint.TypeWriter + logger.debug('Restored QFont.TypeWriter for pyface (gh-1409)') diff --git a/mayavi/plugins/mayavi_workbench_application.py b/mayavi/plugins/mayavi_workbench_application.py index 4fef254cd..b0c927a68 100644 --- a/mayavi/plugins/mayavi_workbench_application.py +++ b/mayavi/plugins/mayavi_workbench_application.py @@ -16,7 +16,8 @@ # Local imports. import mayavi.api from mayavi.preferences.api import preference_manager -from mayavi.plugins._workbench_fixes import fix_view_chooser +from mayavi.plugins._workbench_fixes import (fix_view_chooser, + restore_qfont_typewriter) IMG_DIR = dirname(mayavi.api.__file__) logger = logging.getLogger(__name__) @@ -70,6 +71,7 @@ def run(self): logger.debug('---------- workbench application ----------') fix_view_chooser() + restore_qfont_typewriter() # Make sure the GUI has been created (so that, if required, the splash # screen is shown). diff --git a/mayavi/tests/common.py b/mayavi/tests/common.py index a6725f47e..cc03d0c2f 100644 --- a/mayavi/tests/common.py +++ b/mayavi/tests/common.py @@ -64,3 +64,107 @@ def get_example_data(fname): p = os.path.join('data', fname) return os.path.abspath(fixpath(p)) + +# (action, message prefix, category) for running an example. One inventory for +# both places examples run: `run_example_headless` below applies it in Python, +# and `scripts/render_docs.py` both applies it and carries it into the gallery +# render's per-example children through `PYTHONWARNINGS`. A filter whose +# message begins with whitespace cannot go here -- `warnings._setoption` strips +# the message `PYTHONWARNINGS` gives it, so it could never match in those +# children; those live in `render_examples.UNSETTABLE_WARNING_FILTERS`. +EXAMPLE_WARNING_FILTERS = ( + ('error', '', Warning), + # unsatisfiable until pyface.workbench moves to apptools + ('ignore', 'Workbench will be moved from pyface', PendingDeprecationWarning), + # an example calling plt.show() is right; it is the renderer that has no + # interactive matplotlib backend + ('ignore', 'FigureCanvasAgg is non-interactive', UserWarning), + # tvtk_segmentation.py wants vtkImageThreshold, whose replacement + # vtkImageBinaryThreshold does not exist before VTK 9.7 -- see + # tvtk/WORKAROUNDS.md + ('ignore', 'Call to deprecated class vtkImageThreshold', DeprecationWarning), +) + + +def example_warning_filters(): + """`EXAMPLE_WARNING_FILTERS`, plus the ones this VTK needs.""" + filters = list(EXAMPLE_WARNING_FILTERS) + # VTK's own numpy_support.vtk_to_numpy assigns to .shape, which NumPy 2.5 + # deprecated; fixed in 9.7, so keep it fatal there. mayavi/tests/conftest.py + # carries the same gate for the suites -- see tvtk/WORKAROUNDS.md + from tvtk.common import vtk_major_version, vtk_minor_version + if (vtk_major_version, vtk_minor_version) < (9, 7): + filters.append(('ignore', + 'Setting the shape on a NumPy array has been deprecated', + DeprecationWarning)) + return filters + + +# Examples that are meant for the mayavi2 application to import rather than to +# be executed, and say so by exiting non-zero under `__main__`. Their module +# body is the part that does the work, so they get a name that skips the guard. +RUN_AS_MODULE = ('user_mayavi', 'zzz_reader') + + +def run_example_headless(filename): + """Run one example script to completion, with nothing left blocking. + + An example ends by handing itself to an event loop -- ``mlab.show()``, + ``GUI.start_event_loop()``, ``configure_traits()``, VTK's own + ``vtkRenderWindowInteractor::Start`` -- which never returns. Stub those + out and the whole script still runs, which is all this is checking: that + the example works against the installed VTK and ETS. + + The gallery renderer in ``docs/source/render_examples.py`` does the same + for the examples it shoots a figure of, but with the capture machinery + wrapped around it; ``examples/test_examples.py`` calls this for the rest. + """ + import re + import runpy + import sys + import warnings + + from traits.api import push_exception_handler + + # an example is expected to run warning-clean, as it is in the gallery + # render. This is the only thing making them fatal here: pytest's + # filterwarnings applies to the process it runs in, not to this child. + for action, message, category in example_warning_filters(): + warnings.filterwarnings(action, re.escape(message), category) + # as in the renderer: an exception in a notification handler is otherwise + # printed and swallowed, and the example "passes" with half its work undone + push_exception_handler(reraise_exceptions=True) + fail_instead_of_dialogs() + # an example that also draws with matplotlib would block in pyplot.show() + os.environ.setdefault('MPLBACKEND', 'Agg') + + from pyface.api import GUI + from mayavi import mlab + from tvtk.api import tvtk + from tvtk.tools import visual + + GUI.start_event_loop = lambda self: None + tvtk.RenderWindowInteractor.start = lambda self: None + visual.show = lambda: None + # mlab.show doubles as a decorator, and returning None from it would leave + # the example calling None() rather than its own function + mlab.show = lambda func=None, stop=False: func + HasTraits.configure_traits = \ + lambda self, *args, **kwargs: self.edit_traits(kind='live') + try: + from pyface.qt import QtGui + except Exception: + pass # a toolkit-less run has no loop to stop either + else: + for name in ('exec', 'exec_'): + if hasattr(QtGui.QApplication, name): + setattr(QtGui.QApplication, name, lambda *a, **kw: 0) + + filename = os.path.abspath(filename) + # examples/mayavi/explorer names its own modules as envisage services, and + # resolving one is an import: give the example the sys.path[0] that running + # it as a script would have given it + sys.path.insert(0, os.path.dirname(filename)) + name = os.path.splitext(os.path.basename(filename))[0] + runpy.run_path(filename, + run_name=name if name in RUN_AS_MODULE else '__main__') diff --git a/mayavi/tests/datasets.py b/mayavi/tests/datasets.py index b6d735cac..cd019437c 100644 --- a/mayavi/tests/datasets.py +++ b/mayavi/tests/datasets.py @@ -97,19 +97,16 @@ def mixed_type_ug(): cells = array([4, 0, 1, 2, 3, # tetra 8, 4, 5, 6, 7, 8, 9, 10, 11 # hex ]) - # The offsets for the cells, i.e. the indices where the cells - # start. - offset = array([0, 5]) tetra_type = tvtk.Tetra().cell_type # VTK_TETRA == 10 hex_type = tvtk.Hexahedron().cell_type # VTK_HEXAHEDRON == 12 cell_types = array([tetra_type, hex_type]) # Create the array of cells unambiguously. cell_array = tvtk.CellArray() - cell_array.set_cells(2, cells) + cell_array.import_legacy_format(cells) # Now create the UG. ug = tvtk.UnstructuredGrid(points=points) # Now just set the cell types and reuse the ug locations and cells. - ug.set_cells(cell_types, offset, cell_array) + ug.set_cells(cell_types, cell_array) return ug def generateStructuredGrid(): diff --git a/mayavi/tests/test_workbench_fixes.py b/mayavi/tests/test_workbench_fixes.py index d61e22004..a1b0d2aeb 100644 --- a/mayavi/tests/test_workbench_fixes.py +++ b/mayavi/tests/test_workbench_fixes.py @@ -48,3 +48,27 @@ def test_view_chooser_dialog(node_class): # 'panel' rather than 'live': same editors, no window on screen ui = chooser.edit_traits(kind='panel') ui.dispose() + + +@pytest.mark.skipif(ETSConfig.toolkit == 'null', + reason='the shell needs a UI toolkit') +def test_python_shell_builds(): + """Test that pyface's Python shell can be built (gh-1409). + + On PyQt6 it cannot without `restore_qfont_typewriter`, and this is what + catches the alias going missing again -- it already did once, having been + nested inside a `fix_python_shell_view` that envisage 8.0.1 retired. + """ + from pyface.api import GUI, PythonShell + from pyface.qt import QtGui + + from mayavi.plugins._workbench_fixes import restore_qfont_typewriter + + restore_qfont_typewriter() + GUI() # the toolkit application object the widget needs + shell = PythonShell(QtGui.QWidget()) + shell.create() + try: + assert shell.control is not None + finally: + shell.destroy() diff --git a/pyproject.toml b/pyproject.toml index e3bfc916f..d5d6415d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ test = [ "pytest-timeout", ] docs = [ + "coverage[toml]>=7.10", # docs.yml measures the gallery render "docutils", # mlab_reference.py validates docstrings with it directly "matplotlib", # used by the mlab_3D_to_2D example "networkx", # used by the delaunay_graph example diff --git a/scripts/render_docs.py b/scripts/render_docs.py index 140d11664..b50cea8d3 100644 --- a/scripts/render_docs.py +++ b/scripts/render_docs.py @@ -17,41 +17,21 @@ REPO = Path(__file__).resolve().parent.parent SOURCE = REPO / 'docs' / 'source' -# (action, message prefix, category) -- the examples and the generators run as -# plain scripts, so this is the only thing making their warnings fatal; the -# Makefiles' -W covers Sphinx's own diagnostics, not Python's. -# Filters whose message begins with whitespace cannot go here -- PYTHONWARNINGS -# carries this list into the children and warnings._setoption strips it. Those -# live in render_examples.UNSETTABLE_WARNING_FILTERS. -WARNING_FILTERS = ( - ('error', '', Warning), - # unsatisfiable until pyface.workbench moves to apptools - ('ignore', 'Workbench will be moved from pyface', PendingDeprecationWarning), - # an example calling plt.show() is right; it is this renderer that has no - # interactive matplotlib backend - ('ignore', 'FigureCanvasAgg is non-interactive', UserWarning), - # tvtk_segmentation.py wants vtkImageThreshold, whose replacement - # vtkImageBinaryThreshold does not exist before VTK 9.7 -- see - # tvtk/WORKAROUNDS.md - ('ignore', 'Call to deprecated class vtkImageThreshold', DeprecationWarning), -) - def apply_warning_filters(): """Make warnings fatal, here and in the per-example child processes. - `capture_in_subprocess` discards a child's output unless it exits - non-zero, so a warning there is only ever seen by being raised. + The examples and the generators run as plain scripts, so this is the only + thing making their warnings fatal; the Makefiles' -W covers Sphinx's own + diagnostics, not Python's. And `capture_in_subprocess` discards a child's + output unless it exits non-zero, so a warning there is only ever seen by + being raised. + + The list itself is `mayavi.tests.common.EXAMPLE_WARNING_FILTERS`, shared + with `pytest examples`, which runs the examples this render does not. """ - filters = list(WARNING_FILTERS) - # VTK's own numpy_support.vtk_to_numpy assigns to .shape, which NumPy 2.5 - # deprecated; fixed in 9.7, so keep it fatal there. mayavi/tests/conftest.py - # carries the same gate for the suites -- see tvtk/WORKAROUNDS.md - from tvtk.common import vtk_major_version, vtk_minor_version - if (vtk_major_version, vtk_minor_version) < (9, 7): - filters.append(('ignore', - 'Setting the shape on a NumPy array has been deprecated', - DeprecationWarning)) + from mayavi.tests.common import example_warning_filters + filters = example_warning_filters() for action, message, category in filters: warnings.filterwarnings(action, re.escape(message), category) # PYTHONWARNINGS matches the message as a literal prefix, not a regex diff --git a/tvtk/WORKAROUNDS.md b/tvtk/WORKAROUNDS.md index 771559748..e3d1a6ac0 100644 --- a/tvtk/WORKAROUNDS.md +++ b/tvtk/WORKAROUNDS.md @@ -285,12 +285,14 @@ Those obey the same marker and cull rules; they are keyed on `qVersion()` and Qt's own GL context instead of embedding a native X window. Until tvtk can require and adopt that, this is in the never-expires class. -## Outside the layers: pyface +## Outside the layers: pyface and traitsui Mayavi is the last consumer of `pyface.workbench`, which upstream considers -unmaintained, so bugs there are ours to carry. These are keyed on a pyface -release rather than a VTK version, and cull when `setup.py`'s `pyface` floor -passes the release that carries the upstream fix. Current case: +unmaintained, so bugs there are ours to carry — as are the ones in the Qt +backends of pyface and traitsui that only our windows reach. These are keyed +on a pyface or traitsui release rather than a VTK version, and cull when +`setup.py`'s floor passes the release that carries the upstream fix. Current +cases: - `mayavi/plugins/_workbench_fixes.py`: pyface's "View -> Other..." dialog adapts by *calling* the interface (`IView(obj, Undefined)` in @@ -314,9 +316,48 @@ passes the release that carries the upstream fix. Current case: per-example children through `PYTHONWARNINGS`, and `warnings._setoption` strips the message it is given, so a message starting with a newline — as PySide6's does — can never be matched there. -- `mayavi/tests/conftest.py` ignores pyface's "Workbench will be moved from - pyface" `PendingDeprecationWarning`. Unsatisfiable rather than deferred: - there is nowhere for the import to move to until the code does. +- `mayavi/tests/conftest.py`, `tvtk/tests/conftest.py` and + `mayavi/tests/common.py`'s `EXAMPLE_WARNING_FILTERS` ignore pyface's + "Workbench will be moved from pyface" `PendingDeprecationWarning`. + Unsatisfiable rather than deferred: there is nowhere for the import to move + to until the code does. `tvtk`'s copy is for `test_browser.py`, which builds + the workbench view wrapping `PipelineBrowser`; the examples' is for the four + that stand the workbench up (`explorer3d`, `nongui`, `plugins/test`, + `subclassing_mayavi_application`). + +- `mayavi/plugins/_workbench_fixes.py`: `restore_qfont_typewriter()` puts back + `QFont.TypeWriter`, which PyQt6 dropped along with the rest of the unscoped + enums but pyface still names in its console widget, its code editor and its + font registry. Building the Python shell view therefore raises + `AttributeError: type object 'QFont' has no attribute 'TypeWriter'` on PyQt6; + the one alias covers every call site, and + `mayavi_workbench_application.run()` applies it beside `fix_view_chooser()`. + Keyed on pyface, not on PyQt6 — the bindings are not going back. It was lost + once already, having been nested inside a `fix_python_shell_view` that + envisage 8.0.1 made unnecessary; `mayavi/tests/test_workbench_fixes.py` + now builds a shell so that cannot happen quietly again. +- `tvtk/tests/test_ivtk.py` skips its whole class on PyQt6. Three independent + upstream bugs, all of which `ivtk.viewer()` and every `IVTK*` window walk + straight into there, and none of which tvtk can paper over: + - `pyface.ui.qt.action.action_item._MenuItem` calls + `QMenu.addAction(text, slot, shortcut)`. PyQt6 has no such overload — its + three-argument forms are `(text, slot, type)` and `(text, shortcut, slot)` + — so building the menu bar raises `TypeError: arguments did not match any + overloaded call`. PySide6 accepts it. + - the same `QFont.TypeWriter` the entry above restores, which the two + `WithCrust` windows reach through pyface's console widget. Nothing applies + that alias on a tvtk-only path, and fixing just this one would not make the + windows work while the other two stand, so it is left to the skip. + - `traitsui.qt.ui_panel._GroupSplitter._resize_items` seeds its sizes from + `Item.width`, a `Float`, and returns early on `if avail <= 0` before + anything is coerced, so a splitter that is still zero-sized reaches + `QSplitter.setSizes([-1.0, -1.0])`. PyQt6 raises `TypeError: index 0 has + type 'float' but 'int' is expected`; because it happens inside a + `showEvent`, the exception is unraisable and Qt **aborts the process** + (exit 134), which no skip inside the test could have caught. + Both reproduce against pyface and traitsui `main` (checked 2026-08-19), so + the skip cannot be keyed on a release yet. The PySide6 and PyQt5 rows keep + covering what the tests are for; drop the skip once both are fixed upstream. ## Outside the layers: `mayavi/` @@ -330,13 +371,14 @@ and should go. Current case: `numpy_support.vtk_to_numpy` still assigns to `.shape`. 9.7 fixed it, so the filter is version-keyed rather than blanket — mayavi's own assignments all went through `tvtk.common.reshape_view` instead, and must stay errors. -- `scripts/render_docs.py` ignores VTK 9.7's "Call to deprecated class - vtkImageThreshold" for the example render, where warnings are fatal. - `tvtk_segmentation.py` needs that filter and cannot move to the replacement - `vtkImageBinaryThreshold`, which does not exist before 9.7; at a 9.7 floor - switch the example over and drop the filter. The suites need no such entry: - `tvtk/tests/conftest.py` already ignores every "Call to deprecated" message, - since they instantiate every VTK class. +- `mayavi/tests/common.py`'s `EXAMPLE_WARNING_FILTERS` ignores VTK 9.7's "Call + to deprecated class vtkImageThreshold" wherever an example runs with warnings + fatal — `scripts/render_docs.py` for the gallery, `run_example_headless` for + the rest. `tvtk_segmentation.py` needs that filter and cannot move to the + replacement `vtkImageBinaryThreshold`, which does not exist before 9.7; at a + 9.7 floor switch the example over and drop the filter. The suites need no + such entry: `tvtk/tests/conftest.py` already ignores every "Call to + deprecated" message, since they instantiate every VTK class. - `mayavi/core/utils.py` reduces composite arrays with `numpy` rather than `numpy_interface.algorithms` when the runtime VTK dispatches numpy functions on them (detected by `dsa.COMPOSITE_OVERRIDE`, added in 9.6 along with the diff --git a/tvtk/plugins/browser/browser_view.py b/tvtk/plugins/browser/browser_view.py index aeb8e165b..a148273bc 100644 --- a/tvtk/plugins/browser/browser_view.py +++ b/tvtk/plugins/browser/browser_view.py @@ -47,7 +47,8 @@ def create_control(self, parent): self.browser = PipelineBrowser() self.browser.show(parent=parent) - return self.browser.ui.control + # PipelineBrowser keeps its UI in _ui; there is no public `ui` + return self.browser._ui.control ########################################################################### # Private interface. @@ -64,6 +65,10 @@ def _on_scenes_changed(self, event): """ + # TODO: both of these are no-ops -- map() is lazy in Python 3 and + # nothing consumes it, so the browser has never tracked scenes being + # added or removed. No example or test exercises this path. + # Scenes that were removed. map(self._remove_scene, event.removed) diff --git a/tvtk/pyface/ui/qt4/actor_editor.py b/tvtk/pyface/ui/qt4/actor_editor.py index f4ad62679..c09c491cc 100644 --- a/tvtk/pyface/ui/qt4/actor_editor.py +++ b/tvtk/pyface/ui/qt4/actor_editor.py @@ -13,9 +13,9 @@ # Enthought library imports. from traits.api import Any, Bool, Callable, Dict, Str try: - from traitsui.qt4.editor import Editor -except ModuleNotFoundError: from traitsui.qt.editor import Editor +except ModuleNotFoundError: # traitsui < 8 + from traitsui.qt4.editor import Editor from traitsui.basic_editor_factory import BasicEditorFactory from .decorated_scene import DecoratedScene diff --git a/tvtk/tests/conftest.py b/tvtk/tests/conftest.py index 1ea968697..f57c0ada6 100644 --- a/tvtk/tests/conftest.py +++ b/tvtk/tests/conftest.py @@ -11,6 +11,8 @@ # these tests instantiate every VTK class and read every getter, deprecated # ones included; the parenthetical wording varies far too much to match on ignore:Call to deprecated .*Deprecated since version.*:DeprecationWarning +# unsatisfiable until pyface.workbench moves -- see tvtk/WORKAROUNDS.md +ignore:Workbench will be moved from pyface:PendingDeprecationWarning # should be fixed in traits ignore: module 'sre_.+' is deprecated:DeprecationWarning """ diff --git a/tvtk/tests/test_browser.py b/tvtk/tests/test_browser.py index 9ebcda556..634ceaf9d 100644 --- a/tvtk/tests/test_browser.py +++ b/tvtk/tests/test_browser.py @@ -179,3 +179,25 @@ def callback(): # Then self.assertTrue(self.count > 0) + + +class TestBrowserView(unittest.TestCase): + """The workbench view wrapping a PipelineBrowser.""" + + def test_create_control(self): + # it reached for a `ui` attribute PipelineBrowser does not have, and + # the workbench answers a failing view with a message box rather than + # an exception -- so the mayavi2 pipeline browser view just never + # opened. See examples/tvtk/plugins/test.py. + try: + from pyface.qt import QtGui + except (ImportError, RuntimeError): + self.skipTest('Qt is not available.') + from pyface.api import GUI + from tvtk.plugins.browser.browser_view import BrowserView + + GUI() # the view needs a QApplication to build against + parent = QtGui.QWidget() + control = BrowserView().create_control(parent) + + self.assertIsNotNone(control) diff --git a/tvtk/tests/test_ivtk.py b/tvtk/tests/test_ivtk.py new file mode 100644 index 000000000..c7d24ba3d --- /dev/null +++ b/tvtk/tests/test_ivtk.py @@ -0,0 +1,69 @@ +"""The ivtk viewer windows.""" +# Copyright (c) Enthought, Inc. +# License: BSD Style. + +import unittest + +try: + from pyface.api import GUI + from pyface.qt import QtGui, qt_api +except (ImportError, RuntimeError): + # no binding installed, or QT_API set but empty, as on the headless CI row + GUI = QtGui = qt_api = None + + +# Three separate upstream bugs bite here on PyQt6, none of them ours and the +# last not even catchable -- see tvtk/WORKAROUNDS.md. pyface's `_MenuItem` +# calls `QMenu.addAction(text, slot, shortcut)`, an overload PyQt6 does not +# have, so every one of these windows fails building its menu bar; pyface's +# console widget names the `QFont.TypeWriter` PyQt6 dropped, which the two +# `WithCrust` windows hit (mayavi2 itself is covered by +# `_workbench_fixes.restore_qfont_typewriter`, but nothing applies it on this +# tvtk-only path); and traitsui's `_GroupSplitter._resize_items` hands +# `QSplitter.setSizes` the floats it seeded from `Item.width` whenever the +# splitter is still zero-sized, which PyQt6 rejects from inside a `showEvent`, +# where the TypeError is unraisable and Qt aborts the process. PySide6 has the +# overload, has the alias and coerces the floats, so the rest of the matrix +# covers what these are here for. All three reproduce on pyface and traitsui +# `main` as of 2026-08-19. +@unittest.skipIf(QtGui is None, 'Qt is not available.') +@unittest.skipIf(qt_api == 'pyqt6', + 'pyface and traitsui break the ivtk windows on PyQt6') +class TestIVTKWindows(unittest.TestCase): + """Each of the four windows builds, all the way down to its control. + + pyface 8 stopped creating a widget's control from its constructor, which + left the ones built here handing a None to QSplitter.addWidget -- a + segfault, not an exception, and so invisible to anything short of running + the window. See examples/tvtk/ivtk_example.py. + """ + + def setUp(self): + from tvtk.tools import ivtk + self.ivtk = ivtk + GUI() # the windows need a QApplication to build against + + def _check(self, name): + from tvtk.pyface import actors + + window = getattr(self.ivtk, name)(size=(300, 200)) + try: + window.open() + self.assertIsNotNone(window.control, name) + self.assertIsNotNone(window.scene, name) + window.scene.add_actors(actors.cone_actor()) + window.scene.reset_zoom() + finally: + window.close() + + def test_ivtk(self): + self._check('IVTK') + + def test_ivtk_with_crust(self): + self._check('IVTKWithCrust') + + def test_ivtk_with_browser(self): + self._check('IVTKWithBrowser') + + def test_ivtk_with_crust_and_browser(self): + self._check('IVTKWithCrustAndBrowser') diff --git a/tvtk/tools/ivtk.py b/tvtk/tools/ivtk.py index 94621e457..92cb0babf 100755 --- a/tvtk/tools/ivtk.py +++ b/tvtk/tools/ivtk.py @@ -280,6 +280,7 @@ def _create_rhs(self, parent): style. 's' and 'scene' are bound to the Scene instance.""" self.python_shell = PythonShell(parent) + self.python_shell.create() self.python_shell.bind('scene', self.scene) self.python_shell.bind('s', self.scene) self.python_shell.bind('tvtk', tvtk) @@ -342,6 +343,7 @@ def close(self): def _create_lhs(self, parent): """ Creates the left hand side or top depending on the style. """ self.browser_scene = SceneWithBrowser(parent) + self.browser_scene.create() self.scene = self.browser_scene.scene self.browser = self.browser_scene.browser return self.browser_scene.control @@ -351,6 +353,7 @@ def _create_rhs(self, parent): style. 's' and 'scene' are bound to the Scene instance.""" self.python_shell = PythonShell(parent) + self.python_shell.create() self.python_shell.bind('scene', self.scene) self.python_shell.bind('s', self.scene) self.python_shell.bind('browser', self.browser) @@ -454,6 +457,7 @@ def _create_contents(self, parent): """ Create the contents of the window. """ self.browser_scene = SceneWithBrowser(parent) + self.browser_scene.create() self.scene = self.browser_scene.scene self.browser = self.browser_scene.browser return self.browser_scene.control