Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 97 additions & 16 deletions .github/workflows/build-ultraplot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,34 @@ jobs:

- name: Test Ultraplot
run: |
status=0
filter_nodeids() {
local filtered=""
for nodeid in ${TEST_NODEIDS}; do
local path="${nodeid%%::*}"
if [ -f "$path" ]; then
filtered="${filtered} ${nodeid}"
fi
done
echo "${filtered}"
}
if [ "${TEST_MODE}" = "selected" ] && [ -n "${TEST_NODEIDS}" ]; then
pytest -n auto --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml ${TEST_NODEIDS}
FILTERED_NODEIDS="$(filter_nodeids)"
if [ -z "${FILTERED_NODEIDS}" ]; then
echo "No valid nodeids found; running full suite."
pytest -q --tb=short --disable-warnings -n 0 ultraplot || status=$?
else
pytest -q --tb=short --disable-warnings -n 0 ${FILTERED_NODEIDS} || status=$?
if [ "$status" -eq 4 ] || [ "$status" -eq 5 ]; then
echo "No tests collected from selected nodeids; running full suite."
status=0
pytest -q --tb=short --disable-warnings -n 0 ultraplot || status=$?
fi
fi
else
pytest -n auto --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml ultraplot
pytest -q --tb=short --disable-warnings -n 0 ultraplot || status=$?
fi

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: Ultraplot/ultraplot
exit "$status"

compare-baseline:
name: Compare baseline Python ${{ inputs.python-version }} with MPL ${{ inputs.matplotlib-version }}
Expand Down Expand Up @@ -98,9 +115,9 @@ jobs:
with:
path: ./ultraplot/tests/baseline # The directory to cache
# Key is based on OS, Python/Matplotlib versions, and the base commit SHA
key: ${{ runner.os }}-baseline-base-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }}
key: ${{ runner.os }}-baseline-base-v2-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }}
restore-keys: |
${{ runner.os }}-baseline-base-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }}-
${{ runner.os }}-baseline-base-v2-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }}-

# Conditional Baseline Generation (Only runs on cache miss)
- name: Generate baseline from main
Expand All @@ -120,12 +137,41 @@ jobs:
# Generate the baseline images and hash library
python -c "import ultraplot as plt; plt.config.Configurator()._save_yaml('ultraplot.yml')"
if [ "${TEST_MODE}" = "selected" ] && [ -n "${TEST_NODEIDS}" ]; then
pytest -W ignore \
status=0
filter_nodeids() {
local filtered=""
for nodeid in ${TEST_NODEIDS}; do
local path="${nodeid%%::*}"
if [ -f "$path" ]; then
filtered="${filtered} ${nodeid}"
fi
done
echo "${filtered}"
}
FILTERED_NODEIDS="$(filter_nodeids)"
if [ -z "${FILTERED_NODEIDS}" ]; then
echo "No valid nodeids found; running full suite."
pytest -q --tb=short --disable-warnings -W ignore \
--mpl-generate-path=./ultraplot/tests/baseline/ \
--mpl-default-style="./ultraplot.yml" \
ultraplot/tests || status=$?
else
pytest -q --tb=short --disable-warnings -W ignore \
--mpl-generate-path=./ultraplot/tests/baseline/ \
--mpl-default-style="./ultraplot.yml" \
${TEST_NODEIDS}
${FILTERED_NODEIDS} || status=$?
if [ "$status" -eq 4 ] || [ "$status" -eq 5 ]; then
echo "No tests collected from selected nodeids on base; running full suite."
status=0
pytest -q --tb=short --disable-warnings -W ignore \
--mpl-generate-path=./ultraplot/tests/baseline/ \
--mpl-default-style="./ultraplot.yml" \
ultraplot/tests || status=$?
fi
fi
exit "$status"
else
pytest -W ignore \
pytest -q --tb=short --disable-warnings -W ignore \
--mpl-generate-path=./ultraplot/tests/baseline/ \
--mpl-default-style="./ultraplot.yml" \
ultraplot/tests
Expand All @@ -145,15 +191,50 @@ jobs:
mkdir -p results
python -c "import ultraplot as plt; plt.config.Configurator()._save_yaml('ultraplot.yml')"
if [ "${TEST_MODE}" = "selected" ] && [ -n "${TEST_NODEIDS}" ]; then
pytest -W ignore \
status=0
filter_nodeids() {
local filtered=""
for nodeid in ${TEST_NODEIDS}; do
local path="${nodeid%%::*}"
if [ -f "$path" ]; then
filtered="${filtered} ${nodeid}"
fi
done
echo "${filtered}"
}
FILTERED_NODEIDS="$(filter_nodeids)"
if [ -z "${FILTERED_NODEIDS}" ]; then
echo "No valid nodeids found; running full suite."
pytest -q --tb=short --disable-warnings -W ignore \
--mpl \
--mpl-baseline-path=./ultraplot/tests/baseline \
--mpl-results-path=./results/ \
--mpl-generate-summary=html \
--mpl-default-style="./ultraplot.yml" \
ultraplot/tests || status=$?
else
pytest -q --tb=short --disable-warnings -W ignore \
--mpl \
--mpl-baseline-path=./ultraplot/tests/baseline \
--mpl-results-path=./results/ \
--mpl-generate-summary=html \
--mpl-default-style="./ultraplot.yml" \
${TEST_NODEIDS}
${FILTERED_NODEIDS} || status=$?
if [ "$status" -eq 4 ] || [ "$status" -eq 5 ]; then
echo "No tests collected from selected nodeids; running full suite."
status=0
pytest -q --tb=short --disable-warnings -W ignore \
--mpl \
--mpl-baseline-path=./ultraplot/tests/baseline \
--mpl-results-path=./results/ \
--mpl-generate-summary=html \
--mpl-default-style="./ultraplot.yml" \
ultraplot/tests || status=$?
fi
fi
exit "$status"
else
pytest -W ignore \
pytest -q --tb=short --disable-warnings -W ignore \
--mpl \
--mpl-baseline-path=./ultraplot/tests/baseline \
--mpl-results-path=./results/ \
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ jobs:
uses: actions/cache/restore@v4
with:
path: .ci/test-map.json
key: test-map-${{ github.event.pull_request.base.sha }}
key: test-map-v2-${{ github.event.pull_request.base.sha }}
restore-keys: |
test-map-
test-map-v2-

- name: Select impacted tests
id: select
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test-map.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
- name: Generate test coverage map
run: |
mkdir -p .ci
pytest -n auto --cov=ultraplot --cov-branch --cov-context=test --cov-report= ultraplot
pytest -q --tb=short --disable-warnings -n 0 -p pytest_cov --cov=ultraplot --cov-branch --cov-context=test --cov-report= ultraplot
python tools/ci/build_test_map.py --coverage-file .coverage --output .ci/test-map.json --root .

- name: Cache test map
Expand Down
2 changes: 2 additions & 0 deletions ultraplot/axes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3371,6 +3371,8 @@ def format(
ultraplot.gridspec.SubplotGrid.format
ultraplot.config.Configurator.context
"""
if self.figure is not None:
self.figure._layout_dirty = True
skip_figure = kwargs.pop("skip_figure", False) # internal keyword arg
params = _pop_params(kwargs, self.figure._format_signature)

Expand Down
35 changes: 34 additions & 1 deletion ultraplot/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,14 +476,28 @@ def _canvas_preprocess(self, *args, **kwargs):
else:
return

skip_autolayout = getattr(fig, "_skip_autolayout", False)
layout_dirty = getattr(fig, "_layout_dirty", False)
if (
skip_autolayout
and getattr(fig, "_layout_initialized", False)
and not layout_dirty
):
fig._skip_autolayout = False
return func(self, *args, **kwargs)
fig._skip_autolayout = False

# Adjust layout
# NOTE: The authorized_context is needed because some backends disable
# constrained layout or tight layout before printing the figure.
ctx1 = fig._context_adjusting(cache=cache)
ctx2 = fig._context_authorized() # skip backend set_constrained_layout()
ctx3 = rc.context(fig._render_context) # draw with figure-specific setting
with ctx1, ctx2, ctx3:
fig.auto_layout()
if not fig._layout_initialized or layout_dirty:
fig.auto_layout()
fig._layout_initialized = True
fig._layout_dirty = False
return func(self, *args, **kwargs)

# Add preprocessor
Expand Down Expand Up @@ -797,6 +811,9 @@ def __init__(
self._subplot_counter = 0 # avoid add_subplot() returning an existing subplot
self._is_adjusting = False
self._is_authorized = False
self._layout_initialized = False
self._layout_dirty = True
self._skip_autolayout = False
self._includepanels = None
self._render_context = {}
rc_kw, rc_mode = _pop_rc(kwargs)
Expand Down Expand Up @@ -1546,6 +1563,7 @@ def _add_figure_panel(
"""
Add a figure panel.
"""
self._layout_dirty = True
# Interpret args and enforce sensible keyword args
side = _translate_loc(side, "panel", default="right")
if side in ("left", "right"):
Expand Down Expand Up @@ -1579,6 +1597,7 @@ def _add_subplot(self, *args, **kwargs):
"""
The driver function for adding single subplots.
"""
self._layout_dirty = True
# Parse arguments
kwargs = self._parse_proj(**kwargs)

Expand Down Expand Up @@ -2549,6 +2568,7 @@ def format(
ultraplot.gridspec.SubplotGrid.format
ultraplot.config.Configurator.context
"""
self._layout_dirty = True
# Initiate context block
axs = axs or self._subplot_dict.values()
skip_axes = kwargs.pop("skip_axes", False) # internal keyword arg
Expand Down Expand Up @@ -3134,6 +3154,17 @@ def set_canvas(self, canvas):
# method = '_draw' if callable(getattr(canvas, '_draw', None)) else 'draw'
_add_canvas_preprocessor(canvas, "print_figure", cache=False) # saves, inlines
_add_canvas_preprocessor(canvas, method, cache=True) # renderer displays

orig_draw_idle = getattr(type(canvas), "draw_idle", None)
if orig_draw_idle is not None:

def _draw_idle(self, *args, **kwargs):
fig = self.figure
if fig is not None:
fig._skip_autolayout = True
return orig_draw_idle(self, *args, **kwargs)

canvas.draw_idle = _draw_idle.__get__(canvas)
super().set_canvas(canvas)

def _is_same_size(self, figsize, eps=None):
Expand Down Expand Up @@ -3200,6 +3231,8 @@ def set_size_inches(self, w, h=None, *, forward=True, internal=False, eps=None):
super().set_size_inches(figsize, forward=forward)
if not samesize: # gridspec positions will resolve differently
self.gridspec.update()
if not backend and not internal:
self._layout_dirty = True

def _iter_axes(self, hidden=False, children=False, panels=True):
"""
Expand Down
60 changes: 60 additions & 0 deletions ultraplot/tests/test_animation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from unittest.mock import MagicMock

import numpy as np
import pytest
from matplotlib.animation import FuncAnimation

import ultraplot as uplt


def test_auto_layout_not_called_on_every_frame():
"""
Test that auto_layout is not called on every frame of a FuncAnimation.
"""
fig, ax = uplt.subplots()
fig.auto_layout = MagicMock()

x = np.linspace(0, 2 * np.pi, 100)
y = np.sin(x)
(line,) = ax.plot(x, y)

def update(frame):
line.set_ydata(np.sin(x + frame / 10.0))
return (line,)

ani = FuncAnimation(fig, update, frames=10, blit=False)
# The animation is not actually run, but the initial draw will call auto_layout once
fig.canvas.draw()

assert fig.auto_layout.call_count == 1


def test_draw_idle_skips_auto_layout_after_first_draw():
"""
draw_idle should not re-run auto_layout after the initial draw.
"""
fig, ax = uplt.subplots()
fig.auto_layout = MagicMock()

fig.canvas.draw()
assert fig.auto_layout.call_count == 1

fig.canvas.draw_idle()
assert fig.auto_layout.call_count == 1


def test_layout_array_no_crash():
"""
Test that using layout_array with FuncAnimation does not crash.
"""
layout = [[1, 1], [2, 3]]
fig, axs = uplt.subplots(array=layout)

def update(frame):
for ax in axs:
ax.clear()
ax.plot(np.sin(np.linspace(0, 2 * np.pi) + frame / 10.0))

ani = FuncAnimation(fig, update, frames=10)
# The test passes if no exception is raised
fig.canvas.draw()