diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index 6d6b51deb..fac1f98f0 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -37,6 +37,12 @@ jobs: with: fetch-depth: 0 + - name: Install CA certificates (act) + if: ${{ env.ACT }} + run: | + sudo apt-get update + sudo apt-get install -y ca-certificates + - uses: mamba-org/setup-micromamba@v2.0.7 with: environment-file: ./environment.yml @@ -54,34 +60,27 @@ 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}" + if [ "${TEST_MODE}" = "selected" ] && [ -n "${TEST_NODEIDS}" ]; then + nodeids=() + for token in ${TEST_NODEIDS}; do + if [[ "${token}" == *"::"* || "${token}" == *.py ]]; then + nodeids+=("${token}") fi done - echo "${filtered}" - } - if [ "${TEST_MODE}" = "selected" ] && [ -n "${TEST_NODEIDS}" ]; then - 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=$? + if [ "${#nodeids[@]}" -gt 0 ]; then + pytest --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml "${nodeids[@]}" 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 + pytest --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml ultraplot fi else - pytest -q --tb=short --disable-warnings -n 0 ultraplot || status=$? + pytest --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml ultraplot fi - exit "$status" + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: Ultraplot/ultraplot compare-baseline: name: Compare baseline Python ${{ inputs.python-version }} with MPL ${{ inputs.matplotlib-version }} @@ -96,6 +95,12 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Install CA certificates (act) + if: ${{ env.ACT }} + run: | + sudo apt-get update + sudo apt-get install -y ca-certificates + - uses: mamba-org/setup-micromamba@v2.0.7 with: environment-file: ./environment.yml @@ -115,9 +120,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-v2-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }} + key: ${{ runner.os }}-baseline-base-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }} restore-keys: | - ${{ runner.os }}-baseline-base-v2-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }}- + ${{ runner.os }}-baseline-base-${{ 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 @@ -137,41 +142,12 @@ 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 - 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 \ + pytest -W ignore \ --mpl-generate-path=./ultraplot/tests/baseline/ \ --mpl-default-style="./ultraplot.yml" \ - ${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" + ${TEST_NODEIDS} else - pytest -q --tb=short --disable-warnings -W ignore \ + pytest -W ignore \ --mpl-generate-path=./ultraplot/tests/baseline/ \ --mpl-default-style="./ultraplot.yml" \ ultraplot/tests @@ -191,50 +167,15 @@ 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 - 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 \ + pytest -W ignore \ --mpl \ --mpl-baseline-path=./ultraplot/tests/baseline \ --mpl-results-path=./results/ \ --mpl-generate-summary=html \ --mpl-default-style="./ultraplot.yml" \ - ${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" + ${TEST_NODEIDS} else - pytest -q --tb=short --disable-warnings -W ignore \ + pytest -W ignore \ --mpl \ --mpl-baseline-path=./ultraplot/tests/baseline \ --mpl-results-path=./results/ \ diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c383287cb..5340c6549 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -32,6 +32,12 @@ jobs: with: fetch-depth: 0 + - name: Install git (act) + if: ${{ env.ACT }} + run: | + sudo apt-get update + sudo apt-get install -y git + - name: Prepare workspace run: mkdir -p .ci @@ -40,9 +46,9 @@ jobs: uses: actions/cache/restore@v4 with: path: .ci/test-map.json - key: test-map-v2-${{ github.event.pull_request.base.sha }} + key: test-map-${{ github.event.pull_request.base.sha }} restore-keys: | - test-map-v2- + test-map- - name: Select impacted tests id: select @@ -86,6 +92,12 @@ jobs: with: fetch-depth: 0 + - name: Install git (act) + if: ${{ env.ACT }} + run: | + sudo apt-get update + sudo apt-get install -y git + - uses: actions/setup-python@v6 with: python-version: "3.11" diff --git a/.github/workflows/test-map.yml b/.github/workflows/test-map.yml index a1e9ff107..9eb711919 100644 --- a/.github/workflows/test-map.yml +++ b/.github/workflows/test-map.yml @@ -18,6 +18,12 @@ jobs: with: fetch-depth: 0 + - name: Install CA certificates (act) + if: ${{ env.ACT }} + run: | + sudo apt-get update + sudo apt-get install -y ca-certificates + - uses: mamba-org/setup-micromamba@v2.0.7 with: environment-file: ./environment.yml @@ -36,7 +42,7 @@ jobs: - name: Generate test coverage map run: | mkdir -p .ci - pytest -q --tb=short --disable-warnings -n 0 -p pytest_cov --cov=ultraplot --cov-branch --cov-context=test --cov-report= ultraplot + pytest -n auto --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 diff --git a/pyproject.toml b/pyproject.toml index 9872f5853..9a8d8c178 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,3 +57,8 @@ ignore = ["I001", "I002", "I003", "I004"] [tool.basedpyright] exclude = ["**/*.ipynb"] + +[tool.pytest.ini_options] +filterwarnings = [ + "ignore:'resetCache' deprecated - use 'reset_cache':DeprecationWarning:matplotlib._fontconfig_pattern", +] diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index ef9d59b43..b4462ed32 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -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) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 01d449d36..5536f22fc 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -476,6 +476,17 @@ 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. @@ -483,7 +494,10 @@ def _canvas_preprocess(self, *args, **kwargs): 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 @@ -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) @@ -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"): @@ -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) @@ -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 @@ -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): @@ -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): """ diff --git a/ultraplot/tests/test_animation.py b/ultraplot/tests/test_animation.py new file mode 100644 index 000000000..6e8ad2efc --- /dev/null +++ b/ultraplot/tests/test_animation.py @@ -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() diff --git a/ultraplot/tests/test_tickers.py b/ultraplot/tests/test_tickers.py index bfb3d6e33..1bc40fdd0 100644 --- a/ultraplot/tests/test_tickers.py +++ b/ultraplot/tests/test_tickers.py @@ -1,8 +1,14 @@ -import pytest, numpy as np, xarray as xr, ultraplot as uplt, cftime -from ultraplot.ticker import AutoCFDatetimeLocator -from unittest.mock import patch import importlib +from unittest.mock import patch + import cartopy.crs as ccrs +import cftime +import numpy as np +import pytest +import xarray as xr + +import ultraplot as uplt +from ultraplot.ticker import AutoCFDatetimeLocator @pytest.mark.mpl_image_compare @@ -267,16 +273,20 @@ def test_missing_modules(module_name): assert cftime is None elif module_name == "ccrs": from ultraplot.ticker import ( - ccrs, LatitudeFormatter, LongitudeFormatter, _PlateCarreeFormatter, + ccrs, ) assert ccrs is None assert LatitudeFormatter is object assert LongitudeFormatter is object assert _PlateCarreeFormatter is object + # Restore module state for subsequent tests. + import ultraplot.ticker + + importlib.reload(ultraplot.ticker) def test_index_locator(): @@ -478,9 +488,10 @@ def test_auto_datetime_locator_tick_values( expected_exception, expected_resolution, ): - from ultraplot.ticker import AutoCFDatetimeLocator import cftime + from ultraplot.ticker import AutoCFDatetimeLocator + locator = AutoCFDatetimeLocator(calendar=calendar) resolution = expected_resolution if expected_exception == ValueError: @@ -659,10 +670,11 @@ def test_frac_formatter(formatter_args, value, expected): def test_frac_formatter_unicode_minus(): - from ultraplot.ticker import FracFormatter - from ultraplot.config import rc import numpy as np + from ultraplot.config import rc + from ultraplot.ticker import FracFormatter + formatter = FracFormatter(symbol=r"$\\pi$", number=np.pi) with rc.context({"axes.unicode_minus": True}): assert formatter(-np.pi / 2) == r"−$\\pi$/2" @@ -675,9 +687,10 @@ def test_frac_formatter_unicode_minus(): ], ) def test_cfdatetime_formatter_direct_call(fmt, calendar, dt_args, expected): - from ultraplot.ticker import CFDatetimeFormatter import cftime + from ultraplot.ticker import CFDatetimeFormatter + formatter = CFDatetimeFormatter(fmt, calendar=calendar) dt = cftime.datetime(*dt_args, calendar=calendar) assert formatter(dt) == expected @@ -694,9 +707,10 @@ def test_cfdatetime_formatter_direct_call(fmt, calendar, dt_args, expected): def test_autocftime_locator_subdaily( start_date_str, end_date_str, calendar, resolution ): - from ultraplot.ticker import AutoCFDatetimeLocator import cftime + from ultraplot.ticker import AutoCFDatetimeLocator + locator = AutoCFDatetimeLocator(calendar=calendar) units = locator.date_unit @@ -718,9 +732,10 @@ def test_autocftime_locator_subdaily( def test_autocftime_locator_safe_helpers(): - from ultraplot.ticker import AutoCFDatetimeLocator import cftime + from ultraplot.ticker import AutoCFDatetimeLocator + # Test _safe_num2date with invalid value locator_gregorian = AutoCFDatetimeLocator(calendar="gregorian") with pytest.raises(OverflowError): @@ -740,9 +755,10 @@ def test_autocftime_locator_safe_helpers(): ], ) def test_auto_formatter_options(formatter_args, values, expected, ylim): - from ultraplot.ticker import AutoFormatter import matplotlib.pyplot as plt + from ultraplot.ticker import AutoFormatter + fig, ax = plt.subplots() formatter = AutoFormatter(**formatter_args) ax.xaxis.set_major_formatter(formatter) @@ -771,9 +787,10 @@ def test_autocftime_locator_safe_daily_locator(): def test_latitude_locator(): - from ultraplot.ticker import LatitudeLocator import numpy as np + from ultraplot.ticker import LatitudeLocator + locator = LatitudeLocator() ticks = np.array(locator.tick_values(-100, 100)) assert np.all(ticks >= -90) @@ -781,10 +798,11 @@ def test_latitude_locator(): def test_cftime_converter(): - from ultraplot.ticker import CFTimeConverter, cftime - from ultraplot.config import rc import numpy as np + from ultraplot.config import rc + from ultraplot.ticker import CFTimeConverter, cftime + converter = CFTimeConverter() # test default_units