Skip to content

Python: Respect figure sizing options in notebooks and Quarto inline output - #15234

Merged
seeM merged 30 commits into
mainfrom
feature/python-figure-sizing
Jul 31, 2026
Merged

Python: Respect figure sizing options in notebooks and Quarto inline output#15234
seeM merged 30 commits into
mainfrom
feature/python-figure-sizing

Conversation

@seeM

@seeM seeM commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #12660, #15253, #15254.

Relates to #15116: fixes switching to and from Positron's matplotlib backends with the %matplotlib magic. This was previously unhandled and buggy.

IPython's matplotlib_inline.backend_inline.set_matplotlib_formats still works with our notebook backend and is now a no-op in console sessions.

Summary

Python figures now respect the figure sizing options that Quarto and Positron's notebooks send with each execution. In a notebook session (Quarto inline output, Positron notebooks, Jupyter notebooks):

  1. a cell's #| fig-width / #| fig-height becomes the default figure size
  2. output_pixel_ratio drives the rendering DPI so plots are sharp on hiDPI displays instead of blurry and square

Getting there required our own notebook matplotlib backend (we were using IPython's matplotlib_backend.backend_inline, so matplotlib_backend.py is now a package:

  • notebook.py - new backend for notebook sessions, replacing matplotlib_inline. Intercepts new_figure_manager to apply the metadata size at figure creation (which is what makes the issue's same-cell import matplotlib repro work) and sets the canvas device pixel ratio.
  • console.py - the existing console backend, moved unchanged in behavior. Seaborn figure detaching moved out of the kernel's variables poll into the backend's own post_execute hook.
  • registry.py - manages which backend is active, and switching between them.
  • formats.py - image encoding and the output_pixel_ratio DPI scaling.
  • backend.py / compat.py - shared backend names and shims for matplotlib/IPython versions without backend-registry support.

Screenshots

Before (#| fig-width: 8 / #| fig-height: 4 ignored - blurry and square):

image

After (sized 2:1 and sharp):

image

Release Notes

New Features

Bug Fixes

  • Fixed matplotlib_inline.backend_inline.set_matplotlib_formats(...) breaking Python figure rendering
  • Fixed %matplotlib to toggle matplotlib's backend between the Plots pane and an interactive backend such as qt, and back again

Validation Steps

@:quarto @:plots @:positron-notebooks

Kernel-level tests, from extensions/positron-python/python_files/posit: python -m pytest positron/tests/test_matplotlib_backend positron/tests/test_execute_request.py

Figure sizing (Quarto inline output). Open a .qmd, start a Python session, and run:

#| fig-width: 8
#| fig-height: 4
import numpy as np
import matplotlib.pyplot as plt

r = np.arange(0, 2, 0.01)
theta = 2 * np.pi * r
fig, ax = plt.subplots(subplot_kw={'projection': 'polar'})
ax.plot(theta, r)
ax.grid(True)
plt.show()
  1. The plot is visibly wide (2:1) and sharp, not blurry and near-square.
  2. Remove the two options and re-run: the plot renders at matplotlib's default size, exactly as on main.
  3. Set only #| fig-width: 8 (no height) and re-run: still the default size - a lone dimension is a no-op.
  4. Run the sized cell, then an unsized cell in the same session: the unsized cell is default-sized, so sizing didn't leak.
  5. With both options set, use fig, ax = plt.subplots(figsize=(2, 2)) in the cell body: small and square - explicit code wins.
  6. Run plt.rcParams['figure.figsize'] = (12, 3) in one cell, then the sized cell: still 8x4. Then an unsized cell: 12x3, so the rcParams assignment survived.
  7. Repeat step 1 in a Positron notebook and a Jupyter notebook with a Python kernel.

Backend switching. In a console session:

  1. %matplotlib -l lists positron-console and positron-notebook.
  2. Plot without any magic - it appears in the Plots pane.
  3. %matplotlib qt, then plot - an interactive window opens.
  4. %matplotlib inline (or bare %matplotlib), then plot - back in the Plots pane, no stray or duplicated output.
  5. In a notebook, %matplotlib inline in the first cell, then re-run the sizing check above: the fig-width/fig-height options are still honored.

set_matplotlib_formats. In a notebook cell, from matplotlib_inline.backend_inline import set_matplotlib_formats; set_matplotlib_formats('retina'), then plot: the figure renders at the correct size (not doubled). Repeat with 'png', 'svg', and 'pdf'. In a console session, the same call leaves the Plots pane working with no extra inline image output.

No-matplotlib environment. Start a Python session in an environment without matplotlib installed and run some non-plotting code: the session starts and executes normally.

seeM added 26 commits July 30, 2026 10:47
also narrow the figsize passed to Figure for pyright on the 3.9 pins, and refresh the sizing-precedence and show() docs
the two module-level globals become instance state on a PositronBackendRegistry
singleton, mirroring matplotlib's own backend_registry. __init__.py is now
docs-only; every import site points at the new modules. also splits
test_switching.py into test_backend.py + test_enable_matplotlib.py and renames
'flavor' to 'backend' throughout.
the post-execute hook now skips figures a cell already displayed, instead of showing every figure again
@github-actions

Copy link
Copy Markdown

E2E Tests 🚀
This PR will run tests tagged with: @:critical @:quarto @:plots @:positron-notebooks @:interpreter @:console

Why these tags?
Tag Source
@:critical Always runs (required)
@:quarto PR description
@:plots PR description
@:positron-notebooks PR description
@:interpreter Changed files
@:console Changed files

More on automatic tags from changed files.

readme  valid tags

@isabelizimm
isabelizimm self-requested a review July 30, 2026 18:02

@isabelizimm isabelizimm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying out with this code to make some more complex plots, and I can't quite seem to get the fig-height hash pipes to work 🤔 are you seeing the same behavior?

```{python}
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

# Load the dataset
raw_data = "https://raw.githubusercontent.com/rfordatascience/tidytuesday/refs/heads/main/data/2023/2023-05-23/squirrel_data.csv"
df = pd.read_csv(raw_data)

categorical_cols = ["Age", "Primary Fur Color", "Location"]
for col in categorical_cols:
    df[col] = df[col].fillna("Unknown")

# Drop columns with excessive missing values that are not needed for analysis
df = df.drop(columns=["Color notes", "Specific Location", "Other Activities", "Other Interactions"])
```

```{python}
#| fig-height: 2
# What color occurs most often?

plt.figure()
sns.countplot(data=df, x="Primary Fur Color", order=df["Primary Fur Color"].value_counts().index)
plt.xlabel("Primary Fur Color")
plt.ylabel("Count")
plt.xticks(rotation=30)
plt.grid(axis="y", linestyle="--", alpha=0.7)
plt.show()
```

@isabelizimm

Copy link
Copy Markdown
Contributor

The failing tests are a real "3.9 no longer supported" problem 😓 we've been holding on for a while, but it might be time to drop it?

@seeM

seeM commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

@isabelizimm thanks for the review! The current behavior only applies when both fig-width and fig-height are set. If I understood correctly, this matches Ark and Quarto. If we apply a lone dimension, would we use the default value of the other one? Happy to reconsider this.

I'll see if there's an easy way to fix 3.9 support so we can kick that can down the road a little longer.

@seeM

seeM commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

The test that was failing on 3.9 is actually failing because that specific behavior doesn't support IPython < 8.24. The specific behavior is a rare edge case, so it's safe to skip that test on older IPython versions. No need to drop 3.9 just yet

@seeM
seeM requested a review from isabelizimm July 31, 2026 12:48
@juliasilge

Copy link
Copy Markdown
Member

Ah, folks will be expecting to be able to set just one of fig-width or fig-height, because that is supported with quarto render. I believe that yes, it uses the default for when one is not set:
https://quarto.org/docs/computations/execution-options.html#figure-options

If we don't do this correctly with ark yet, we should address that, but let's get it right here for Python if possible now.

@seeM

seeM commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Cool, makes sense! Fixed in bd23bf0:

image

I confirmed it is wrong in R:

image

Also confirmed that quarto render does respect lone dimensions like you said.

@seeM

seeM commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

I'll open an issue shortly for R.

@isabelizimm isabelizimm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks great! it feels really nice and acts as i expect when altering in code and via hash pipes.

@seeM

seeM commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Ty! I'm gonna merge despite the failing ext-host test. It appears to be unrelated to this PR.

@seeM
seeM merged commit 10e9b8f into main Jul 31, 2026
45 of 46 checks passed
@seeM
seeM deleted the feature/python-figure-sizing branch July 31, 2026 17:08
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 31, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: Figure sizing options not respected for Quarto inline output

3 participants