Skip to content

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

Description

@MAfarrag

Context

Raised from serapeum-org/pyramids#1077. pyramids'
Dataset.plot() forwards fig / ax straight into ArrayGlyph, and while documenting that passthrough we
exercised the partial forms. ax= alone works; fig= alone crashes inside cleopatra.

Passing a figure without an axes to ArrayGlyph leaves self.ax as None, and plot() then dereferences it —
raising an AttributeError that names an internal attribute and gives the caller no clue what they did wrong.

Problem / Current Behaviour

ArrayGlyph(array, fig=fig) (no ax) raises on .plot():

AttributeError: 'NoneType' object has no attribute '_cleo_projection_frame' and no __dict__ for setting new attributes

The two partial forms are asymmetric — verified on cleopatra 0.33.0:

Call Result
ArrayGlyph(arr, ax=ax).plot() works (axes adopted)
ArrayGlyph(arr, fig=fig).plot() AttributeError (above)
ArrayGlyph(arr, fig=fig, ax=ax).plot() works
ArrayGlyph(arr).plot() works (creates its own)

ax= alone has worked since #130; fig= alone appears never to have been handled.

Root cause

self.ax is never derived from the supplied fig, and _clear_projection_frame assigns the marker attribute
without a None guard:

# cleopatra/glyphs/base/glyph.py:264  (_clear_projection_frame)
frame = getattr(ax, "_cleo_projection_frame", None)
ax._cleo_projection_frame = None   # <-- ax is None when only `fig` was supplied

reached via:

cleopatra/glyphs/gridded/array_glyph.py:3604  plot()
    self._sync_projection_frame(projection_draws_frame(projection))
cleopatra/glyphs/gridded/array_glyph.py:2519  _sync_projection_frame()
    had_frame = _clear_projection_frame(self.ax)
cleopatra/glyphs/base/glyph.py:264            _clear_projection_frame()
    ax._cleo_projection_frame = None

Note the getattr(ax, ..., None) on the line above already tolerates a None ax; only the assignment does not.

Affected locations

File Symbol Notes
cleopatra/glyphs/base/glyph.py:264 _clear_projection_frame Assigns ax._cleo_projection_frame with no None guard
cleopatra/glyphs/gridded/array_glyph.py:2519 _sync_projection_frame Passes self.ax, which can be None
cleopatra/glyphs/gridded/array_glyph.py ArrayGlyph.__init__ Accepts fig= but never derives an axes from it

Steps to Reproduce

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from cleopatra.glyphs.gridded.array_glyph import ArrayGlyph

fig, _ = plt.subplots()
ArrayGlyph(np.arange(9, dtype="float32").reshape(3, 3), fig=fig).plot()
# AttributeError: 'NoneType' object has no attribute '_cleo_projection_frame'

Swapping fig=fig for ax=ax succeeds, which is what makes the failure surprising.

Proposed Solution

Preferred — honour fig on its own, since "draw into my figure" is a legitimate request and is the natural
counterpart to the already-supported ax:

  1. In ArrayGlyph.__init__ (or wherever fig / ax are normalised), when fig is supplied and ax is not, derive
    the axes from the figure — e.g. ax = fig.gca() (or fig.add_subplot(111) when the figure has no axes yet) —
    so the glyph draws into the caller's figure instead of leaving self.ax is None.
  2. Defensively guard _clear_projection_frame so a None axes is a no-op rather than an AttributeError:
def _clear_projection_frame(ax):
    if ax is None:
        return False
    ...

If honouring fig alone is not desired, then at minimum raise a clear, actionable error at construction time
(e.g. ValueError("fig= requires ax=; pass both, or pass ax= alone")) instead of the current internal
AttributeError.

Out of Scope

  • The ax=-only and fig=+ax= paths, which already work.
  • Any change to how the projection frame itself is drawn.

Effort Estimate

Size: XS
Rationale: derive the axes in one place plus a two-line None guard, with a regression test per partial form.

Definition of Done

  • ArrayGlyph(arr, fig=fig).plot() draws into the supplied figure instead of raising
  • _clear_projection_frame(None) is a no-op rather than an AttributeError
  • Regression tests cover all four combinations (neither, ax only, fig only, both)
  • Existing ax= / fig=+ax= behaviour is unchanged

Environment

  • cleopatra 0.33.0
  • matplotlib 3.11.1
  • Python 3.14.7

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions