Skip to content

fix(array_glyph): draw into a figure supplied without an axes - #331

Merged
MAfarrag merged 10 commits into
mainfrom
fix/array-glyph-fig-without-ax
Sep 5, 2026
Merged

fix(array_glyph): draw into a figure supplied without an axes#331
MAfarrag merged 10 commits into
mainfrom
fix/array-glyph-fig-without-ax

Conversation

@MAfarrag

@MAfarrag MAfarrag commented Sep 4, 2026

Copy link
Copy Markdown
Member

Description

Fixes a crash when an ArrayGlyph is bound to a figure without an axes. ArrayGlyph(arr, fig=fig).plot()
left self.ax as None and then raised an internal AttributeError in _clear_projection_frame
('NoneType' object has no attribute '_cleo_projection_frame'), while ax-alone and fig+ax both worked —
an asymmetry surfaced while documenting pyramids' Dataset.plot(fig=…, ax=…) passthrough (serapeum-org/pyramids#1077).

  • Derive the axes from the figure at render time — its current axes, or a fresh add_subplot if it has none —
    in both plot() and animate(), so a figure supplied without an axes is drawn into rather than left None.
    Doing it at render (not construction) means a later plot(ax=…) override still wins and leaves no stray axes
    on the bound figure.
  • Guard _clear_projection_frame(None) to a no-op (returns False) as defense-in-depth, so a not-yet-resolved
    axes can never raise there.

ax-alone and fig+ax behaviour is unchanged.

No new runtime dependencies.

Issues

Type of change

Check relevant points.

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update
  • Dev changes (CI/pyproject.toml/docs/examples/testing)

How Has This Been Tested?

New TestFigAxResolution class in tests/test_array_glyph.py covering the issue's Definition of Done:

  • All four fig/ax forms renderneither (own figure), ax only (adopts axes), fig only (draws into the
    supplied figure — the regression)
    , and fig+ax (uses the axes).
  • fig only reuses the figure's existing axes; on an empty figure it adds one.
  • plot(ax=…) override on a fig-bound glyph wins and leaves no stray axes on the bound figure.
  • animate(fig=…) (the same latent gap) also draws into the supplied figure.
  • _clear_projection_frame(None) is a no-op (False).

Reproduce (external uv env, worktree on src):

VIRTUAL_ENV=C:/python-environments/uv/cleopatra PYTHONPATH=src \
  C:/python-environments/uv/cleopatra/Scripts/python.exe -m pytest tests/test_array_glyph.py::TestFigAxResolution -q
  • Test A — tests/test_array_glyph.py::TestFigAxResolution (8 passed)
  • Test B — full suite pytest tests/ -q → 2684 passed; ruff check clean

Checklist:

  • updated version number in pyproject.toml
  • added changes to History.rst
  • updated the latest version in README file
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

ArrayGlyph(arr, fig=fig).plot() -- a figure bound without an axes -- left
self.ax None and then crashed in _clear_projection_frame with an internal
AttributeError ('NoneType' object has no attribute '_cleo_projection_frame'),
while ax-alone and fig+ax both worked.

- Derive the axes from the figure at render time (its current axes, or a fresh
  one) in both plot() and animate(), so fig-alone draws into the caller's
  figure. Deriving at render rather than construction means a later
  plot(ax=...) override leaves no stray axes on the bound figure.
- Guard _clear_projection_frame(None) to a no-op (return False) as
  defense-in-depth, so a not-yet-resolved axes can never raise there.
- Add regression tests for all four fig/ax forms plus the None guard.

Closes #326
MeshGlyph shared the same fig-without-ax root cause as ArrayGlyph: a figure
bound at construction with no axes left self.ax = None, so the first plot() or
animate() render crashed on a None axes. Mirror the ArrayGlyph resolution: when
a figure is bound but its axes is unresolved, draw into the figure's first axes
(or a fresh add_subplot(111) if it has none) in both plot() and animate().
plot()'s fig-only branch already clears _auto_figure/_owns_figure so teardown
never tightens or repaints a caller-owned figure; animate()'s mirror branch set
only self.ax. Set both flags there too so the two branches are identical and the
caller's figure is never treated as cleopatra-owned.
The fig-only path adopts self.fig.axes[0] (the figure's first axes), but the fig
docstring and code comment called it the 'current axes' — which diverges from
fig.axes[0] for multi-axes figures. Reword both to 'first axes' to match the
implementation, and add the fig-derived tier to plot()'s ax resolution-priority
list so the two docstrings agree.
TestFigAxResolution exercises MeshGlyph's four construction forms (neither, ax
only, fig only, fig+ax) plus both animate() fig-only halves, asserting the mesh
is actually drawn (glyph.im) and the right axes is adopted -- pinning the new
fig-only resolution branches in plot() and animate().
…r/ownership

Close the coverage gaps the review flagged on TestFigAxResolution: add
test_animate_fig_only_empty_figure_creates_axes for animate()'s untested
add_subplot(111) half; assert glyph.im is not None (a render actually happened,
not just axes resolution) on the fig-only draw tests; assert _owns_figure /
_auto_figure stay False after a fig-only render; and assert the plot(ax=)
override switches self.fig to the override's figure.
The animate() fig-only resolution branch had its True edge covered but not the
False edge (fig AND ax bound at construction, so the axes is used without
re-resolution). Add test_animate_fig_and_ax_uses_bound_axes to ArrayGlyph and
MeshGlyph, closing the partial branch (array_glyph 4485->4491, mesh_glyph
1338->1341).
Lock the contracts the animate fig-only branch actually provides: assert the
ArrayGlyph animate() test resets _owns_figure/_auto_figure to False (the L1
lines were otherwise unexercised), and assert glyph.im is not None on the three
MeshGlyph animate tests so they confirm a frame was drawn, not just that the
axes resolved.
Mirror ArrayGlyph.plot()'s ax docstring: spell out the full resolution priority
(bound axes > an axes derived from a figure bound at construction > a new
figure/axes) instead of the terse 'uses stored axes or creates new', which did
not mention the fig-only tier this PR added to MeshGlyph.plot().
SonarCloud S9073 (MAJOR) flagged six 'assert A and B' composite assertions in the
new TestFigAxResolution classes. Split each into separate assert statements so a
failure pinpoints which condition broke.
@sonarqubecloud

sonarqubecloud Bot commented Sep 5, 2026

Copy link
Copy Markdown

@MAfarrag
MAfarrag merged commit 50d381c into main Sep 5, 2026
10 checks passed
@MAfarrag
MAfarrag deleted the fix/array-glyph-fig-without-ax branch September 5, 2026 00:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(array_glyph): ArrayGlyph(fig=...) without ax crashes in _clear_projection_frame

1 participant