Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
aaea0a1
fetch and display plot metadata
jmcphers Dec 17, 2025
ab61ffc
Merge remote-tracking branch 'origin/main' into feature/plots-attribu…
jmcphers Dec 18, 2025
6800c54
adapt to vertical history display
jmcphers Dec 18, 2025
0b30707
plot code button
jmcphers Dec 18, 2025
42ab978
add copy button
jmcphers Dec 18, 2025
d433448
add 'reveal execution'
jmcphers Dec 18, 2025
215a0de
always show the progress bar
jmcphers Dec 18, 2025
566a20e
run code again
jmcphers Dec 18, 2025
043fc1d
store execution ids/code for html plots
jmcphers Dec 19, 2025
042f101
basic python impl
jmcphers Dec 19, 2025
d0a7001
use plot name if known
jmcphers Dec 19, 2025
94380a1
respect session renames
jmcphers Dec 19, 2025
53da0ee
render name beneath thumbnail
jmcphers Dec 19, 2025
53f5fa7
simplify some css
jmcphers Dec 19, 2025
fa38c60
further simplify css
jmcphers Dec 19, 2025
4e747c2
properly constrain thumbnail
jmcphers Dec 19, 2025
6f98670
tighten margins
jmcphers Dec 19, 2025
71d1990
use smaller placeholder
jmcphers Dec 19, 2025
18695c0
make the close button work again
jmcphers Dec 20, 2025
ab4575e
lowercase python libs to match r
jmcphers Dec 20, 2025
1928164
improve keyboard accessibility
jmcphers Dec 20, 2025
a628321
move code actions to top toolbar
jmcphers Dec 20, 2025
64740ef
better layout for 2nd level toolbar
jmcphers Dec 20, 2025
4892c59
refine spacing
jmcphers Dec 22, 2025
6c2ca65
a11y for close button
jmcphers Dec 22, 2025
eccafe4
make the session name navigable
jmcphers Dec 22, 2025
64842dd
use plot name as more descriptive alt text
jmcphers Dec 22, 2025
d7726cd
make code commands more responsive
jmcphers Dec 22, 2025
ca4eb9c
update after metadata changes
jmcphers Dec 22, 2025
905fd11
update python types
jmcphers Dec 22, 2025
e7e0ac8
a few more for python
jmcphers Dec 22, 2025
9b39325
make ruff happy
jmcphers Dec 22, 2025
6cbe66c
update tests to expect new execution_id
jmcphers Dec 23, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
import hashlib
import io
import logging
from typing import TYPE_CHECKING, cast
import sys
from typing import TYPE_CHECKING, Any, cast

import matplotlib
from matplotlib.backend_bases import FigureManagerBase
Expand All @@ -38,6 +39,26 @@
matplotlib.interactive(True) # noqa: FBT003


def _detect_plotting_library() -> str:
"""
Detect the most likely high-level plotting library in use.

Checks sys.modules for known plotting libraries that build on matplotlib,
returning the most specific library name found.
"""
# Check for high-level libraries that build on matplotlib
# Order matters - check more specific libraries first
# Note: We don't include pandas here because pandas.plotting is auto-loaded
# when pandas is imported, even if not used for plotting.
library_priority = ["seaborn", "plotnine"]

for module_name in library_priority:
if module_name in sys.modules:
return module_name

return "matplotlib"


class FigureManagerPositron(FigureManagerBase):
"""
Interface for the matplotlib backend to interact with the Positron frontend.
Expand All @@ -62,9 +83,23 @@ def __init__(self, canvas: FigureCanvasPositron, num: int | str):

super().__init__(canvas, num)

kernel = cast("PositronIPyKernel", PositronIPyKernel.instance())

# Get the execution context from the current shell message
parent = kernel.get_parent("shell")
header: dict[str, Any] = cast("dict[str, Any]", parent.get("header", {}))
content: dict[str, Any] = cast("dict[str, Any]", parent.get("content", {}))
execution_id: str = header.get("msg_id", "")
code: str = content.get("code", "")

# Detect which plotting library was used
kind = _detect_plotting_library()

# Create the plot instance via the plots service.
self._plots_service = cast("PositronIPyKernel", PositronIPyKernel.instance()).plots_service
self._plot = self._plots_service.create_plot(canvas.render, canvas.intrinsic_size)
self._plots_service = kernel.plots_service
self._plot = self._plots_service.create_plot(
canvas.render, canvas.intrinsic_size, kind, execution_id, code, num
)

@property
def closed(self) -> bool:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,28 @@ class IntrinsicSize(BaseModel):
)


class PlotMetadata(BaseModel):
"""
The plot's metadata
"""

name: StrictStr = Field(
description="A human-readable name for the plot",
)

kind: StrictStr = Field(
description="The kind of plot e.g. 'Matplotlib', 'ggplot2', etc.",
)

execution_id: StrictStr = Field(
description="The ID of the code fragment that produced the plot",
)

code: StrictStr = Field(
description="The code fragment that produced the plot",
)


class PlotResult(BaseModel):
"""
A rendered plot
Expand Down Expand Up @@ -128,6 +150,9 @@ class PlotBackendRequest(str, enum.Enum):
# Get the intrinsic size of a plot, if known.
GetIntrinsicSize = "get_intrinsic_size"

# Get metadata for the plot
GetMetadata = "get_metadata"

# Render a plot
Render = "render"

Expand All @@ -148,6 +173,21 @@ class GetIntrinsicSizeRequest(BaseModel):
)


class GetMetadataRequest(BaseModel):
"""
Get metadata for the plot
"""

method: Literal[PlotBackendRequest.GetMetadata] = Field(
description="The JSON-RPC method name (get_metadata)",
)

jsonrpc: str = Field(
default="2.0",
description="The JSON-RPC version specifier",
)


class RenderParams(BaseModel):
"""
Requests a plot to be rendered. The plot data is returned in a
Expand Down Expand Up @@ -192,6 +232,7 @@ class PlotBackendMessageContent(BaseModel):
comm_id: str
data: Union[
GetIntrinsicSizeRequest,
GetMetadataRequest,
RenderRequest,
] = Field(..., discriminator="method")

Expand Down Expand Up @@ -221,6 +262,8 @@ class UpdateParams(BaseModel):

IntrinsicSize.update_forward_refs()

PlotMetadata.update_forward_refs()

PlotResult.update_forward_refs()

PlotSize.update_forward_refs()
Expand All @@ -229,6 +272,8 @@ class UpdateParams(BaseModel):

GetIntrinsicSizeRequest.update_forward_refs()

GetMetadataRequest.update_forward_refs()

RenderParams.update_forward_refs()

RenderRequest.update_forward_refs()
Expand Down
53 changes: 50 additions & 3 deletions extensions/positron-python/python_files/posit/positron/plots.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@

from .plot_comm import (
GetIntrinsicSizeRequest,
GetMetadataRequest,
IntrinsicSize,
PlotBackendMessageContent,
PlotFrontendEvent,
PlotMetadata,
PlotResult,
PlotSize,
PlotUnit,
Expand Down Expand Up @@ -50,17 +52,33 @@ class Plot:
A callable that renders the plot. See `plot_comm.RenderRequest` for parameter details.
intrinsic_size
The intrinsic size of the plot in inches.
kind
The kind of plot, e.g., 'Matplotlib', 'Seaborn', 'plotnine'.
execution_id
The ID of the execute_request that produced the plot.
code
The code fragment that produced the plot.
figure_num
The matplotlib figure number, used for generating plot names.
"""

def __init__(
self,
comm: PositronComm,
render: Renderer,
intrinsic_size: tuple[int, int],
kind: str,
execution_id: str,
code: str,
figure_num: int | str,
) -> None:
self._comm = comm
self._render = render
self._intrinsic_size = intrinsic_size
self._kind = kind
self._execution_id = execution_id
self._code = code
self._figure_num = figure_num

self._closed = False

Expand Down Expand Up @@ -115,6 +133,8 @@ def _handle_msg(
)
elif isinstance(request, GetIntrinsicSizeRequest):
self._handle_get_intrinsic_size()
elif isinstance(request, GetMetadataRequest):
self._handle_get_metadata()
else:
logger.warning(f"Unhandled request: {request}")

Expand All @@ -137,10 +157,21 @@ def _handle_get_intrinsic_size(self) -> None:
width=self._intrinsic_size[0],
height=self._intrinsic_size[1],
unit=PlotUnit.Inches,
source="Matplotlib",
source=self._kind,
).dict()
self._comm.send_result(data=result)

def _handle_get_metadata(self) -> None:
# Generate a short but meaningful name for the plot
name = f"{self._kind} {self._figure_num}"
result = PlotMetadata(
name=name,
kind=self._kind,
execution_id=self._execution_id,
code=self._code,
).dict()
self._comm.send_result(data=result)

def _handle_close(self, _msg: JsonRecord) -> None:
self.close()

Expand Down Expand Up @@ -169,7 +200,15 @@ def __init__(self, target_name: str, session_mode: SessionMode):

self._plots: list[Plot] = []

def create_plot(self, render: Renderer, intrinsic_size: tuple[int, int]) -> Plot:
def create_plot(
self,
render: Renderer,
intrinsic_size: tuple[int, int],
kind: str,
execution_id: str,
code: str,
figure_num: int | str,
) -> Plot:
"""
Create a plot.

Expand All @@ -179,6 +218,14 @@ def create_plot(self, render: Renderer, intrinsic_size: tuple[int, int]) -> Plot
A callable that renders the plot. See `plot_comm.RenderRequest` for parameter details.
intrinsic_size
The intrinsic size of the plot in inches.
kind
The kind of plot, e.g., 'Matplotlib', 'Seaborn', 'plotnine'.
execution_id
The ID of the execute_request that produced the plot.
code
The code fragment that produced the plot.
figure_num
The matplotlib figure number, used for generating plot names.

See Also
--------
Expand All @@ -187,7 +234,7 @@ def create_plot(self, render: Renderer, intrinsic_size: tuple[int, int]) -> Plot
comm_id = str(uuid.uuid4())
logger.info(f"Creating plot with comm {comm_id}")
plot_comm = PositronComm.create(self._target_name, comm_id)
plot = Plot(plot_comm, render, intrinsic_size)
plot = Plot(plot_comm, render, intrinsic_size, kind, execution_id, code, figure_num)
self._plots.append(plot)
return plot

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,33 @@ def test_mpl_get_intrinsic_size(shell: PositronShell, plots_service: PlotsServic
"width": intrinsic_size[0],
"height": intrinsic_size[1],
"unit": PlotUnit.Inches.value,
"source": "Matplotlib",
"source": "matplotlib",
}
)
]


def test_mpl_get_metadata(shell: PositronShell, plots_service: PlotsService) -> None:
# Create a plot.
plot_comm = _create_mpl_plot(shell, plots_service)

# Send a get_metadata request to the plot comm.
msg = json_rpc_request("get_metadata", {}, comm_id="dummy_comm_id")
plot_comm.handle_msg(msg)

# Check that the response includes the expected metadata.
assert len(plot_comm.messages) == 1
response = plot_comm.messages[0]
result = response["data"]["result"]

# Verify the metadata structure
assert result["kind"] == "matplotlib"
assert result["name"] == "matplotlib 1"
# execution_id and code may be empty in test context since there's no real execute_request
assert "execution_id" in result
assert "code" in result


def test_mpl_show(shell: PositronShell, plots_service: PlotsService) -> None:
plot_comm = _create_mpl_plot(shell, plots_service)

Expand Down
38 changes: 38 additions & 0 deletions positron/comms/plot-backend-openrpc.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,44 @@
}
}
},
{
"name": "get_metadata",
"summary": "Get metadata for the plot",
"description": "Get metadata for the plot",
"params": [],
"result": {
"required": true,
"schema": {
"name": "plot_metadata",
"description": "The plot's metadata",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "A unique, human-readable name for the plot"
},
"kind": {
"type": "string",
"description": "The kind of plot e.g. 'Matplotlib', 'ggplot2', etc."
},
"execution_id": {
"type": "string",
"description": "The ID of the code fragment that produced the plot"
},
"code": {
"description": "The code fragment that produced the plot",
"type": "string"
}
},
"required": [
"name",
"kind",
"execution_id",
"code"
]
}
}
},
{
"name": "render",
"summary": "Render a plot",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,33 @@
position: absolute;
top: 0;
left: -10px;
opacity: 0;
}

@keyframes positronActivityInput-fadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
}

@keyframes positronActivityInput-revealFadeInOut {
0% { opacity: 0; }
15% { opacity: 1; }
85% { opacity: 1; }
100% { opacity: 0; }
}

.activity-input.executing .progress-bar {
background-color: var(--vscode-positronConsole-ansiGreen);
opacity: 0;
animation: positronActivityInput-fadeIn 0.25s ease-in 0.25s 1 forwards;
}

.activity-input.revealed .progress-bar {
background-color: var(--vscode-focusBorder);
opacity: 0;
animation: positronActivityInput-revealFadeInOut 2s ease-in-out 1 forwards;
}

.activity-input .prompt {
text-align: right;
display: inline-block;
Expand Down
Loading
Loading