Skip to content

Fix programmatic dataframe selections to return AttributeDictionary#14455

Merged
lukasmasuch merged 9 commits intodevelopfrom
lukasmasuch/fix-issue-14454
Mar 27, 2026
Merged

Fix programmatic dataframe selections to return AttributeDictionary#14455
lukasmasuch merged 9 commits intodevelopfrom
lukasmasuch/fix-issue-14454

Conversation

@lukasmasuch
Copy link
Copy Markdown
Collaborator

@lukasmasuch lukasmasuch commented Mar 22, 2026

Describe your changes

Fixes programmatic dataframe selection returning a plain dict instead of AttributeDictionary, which caused AttributeError when accessing selection attributes (e.g., event.selection.rows).

Changes:

  • Wraps validated programmatic selection state in ReadOnlyAttributeDictionary to match the behavior of user-driven selections
  • ReadOnlyAttributeDictionary extends AttributeDictionary so isinstance checks still work
  • Selection state is read-only to prevent in-place modifications that wouldn't trigger UI updates (users get a clear error message guiding them to use full assignment instead)

GitHub Issue Link (if applicable)

Testing Plan

  • Unit Tests
    • lib/tests/streamlit/elements/arrow_dataframe_test.py::test_programmatic_selection_returns_attribute_dictionary — Verifies programmatic selection returns AttributeDictionary with working attribute access
    • lib/tests/streamlit/elements/arrow_dataframe_test.py::test_selection_state_is_read_only — Verifies selection state is read-only
    • lib/tests/streamlit/util_test.py::TestReadOnlyAttributeDictionary — Comprehensive tests for the new ReadOnlyAttributeDictionary class
  • E2E Tests
    • e2e_playwright/st_dataframe_selections_test.py — Verifies attribute access works via selection.selection.rows

Contribution License Agreement

By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.

Agent metrics
Type Name Count
skill checking-changes 2
skill fixing-pr 1
skill updating-internal-docs 2
subagent fixing-pr 3
subagent general-purpose 6
subagent reviewing-local-changes 2
subagent simplifying-local-changes 2

When setting dataframe selection state programmatically via session_state,
wrap the validated state in AttributeDictionary to enable attribute-style
access (e.g., event.selection.rows).

Closes #14454

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Copilot AI review requested due to automatic review settings March 22, 2026 11:22
@lukasmasuch lukasmasuch added change:bugfix PR contains bug fix implementation impact:users PR changes affect end users ai-review If applied to PR or issue will run AI review workflow labels Mar 22, 2026
@snyk-io
Copy link
Copy Markdown
Contributor

snyk-io bot commented Mar 22, 2026

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 22, 2026

✅ PR preview is ready!

Name Link
📦 Wheel file https://core-previews.s3-us-west-2.amazonaws.com/pr-14455/streamlit-1.55.0-py3-none-any.whl
📦 @streamlit/component-v2-lib Download from artifacts
🕹️ Preview app pr-14455.streamlit.app (☁️ Deploy here if not accessible)

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a regression where programmatic st.dataframe(..., on_select=...) selection state returned a plain dict instead of an AttributeDictionary, breaking attribute-style access like event.selection.rows.

Changes:

  • Wrap programmatically-validated dataframe selection state in AttributeDictionary to match user-driven selection return behavior.
  • Add a Python unit test covering attribute-style access for programmatic selections.
  • Extend the existing Playwright E2E coverage to assert attribute access works in the programmatic selection scenario.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
lib/streamlit/elements/arrow.py Ensures programmatic selection return values use AttributeDictionary for event.selection.* attribute access.
lib/tests/streamlit/elements/arrow_dataframe_test.py Adds regression unit test verifying programmatic selection supports attribute-style access.
e2e_playwright/st_dataframe_selections.py Emits selection.selection.rows to validate attribute access in the E2E app.
e2e_playwright/st_dataframe_selections_test.py Adds E2E assertions for the new attribute-access output.

@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Mar 22, 2026
Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Summary

This PR fixes a bug (#14454) where programmatic dataframe selection via st.session_state returned a plain dict instead of an AttributeDictionary, causing AttributeError when users accessed selection attributes with dot notation (e.g., result.selection.rows). The fix is a one-line change in lib/streamlit/elements/arrow.py (line 999) wrapping validated_state in AttributeDictionary(...) before returning, consistent with the deserialize path at line 231. The PR includes a unit test and E2E test additions.

Reviewer consensus: 3/3 expected models completed their reviews. Two reviewers (claude-4.6-opus-high-thinking, gpt-5.3-codex-high) approved; one (gemini-3.1-pro) requested changes citing a potential edge case on subsequent reruns. After independent verification, the edge case does not manifest in practice (see Code Quality section).

Code Quality

The fix is minimal, correct, and follows existing codebase patterns. The DataframeSelectionSerde.deserialize method (line 231) already wraps its return in AttributeDictionary; this change makes the programmatic-set path consistent.

Resolved disagreement: gemini-3.1-pro raised a concern that on subsequent reruns (where widget_state.value_changed is False), widget_state.value at line 1002 would be a plain dict, causing the bug to reappear. After tracing the full widget lifecycle through SessionState.register_widget_getitemWStates.__getitem__, this concern is not valid:

  1. On every rerun (both in production and AppTest), widget states are sent back from the frontend (or simulated via ElementTree.get_widget_states()), populating _new_widget_state with serialized proto values.
  2. WStates.__getitem__ deserializes these through serde.deserialize (line 231), which wraps the result in AttributeDictionary.
  3. Therefore widget_state.value at line 1002 is already an AttributeDictionary on subsequent reruns through the normal deserialization path.

A defensive AttributeDictionary wrap at line 1002 is a reasonable hardening but is not required for correctness. See inline comments for details.

Test Coverage

Coverage is adequate for this bug fix:

  • Unit test: test_programmatic_selection_returns_attribute_dictionary validates both initial (empty) and programmatically-set states with attribute-style access using AppTest. It would have failed before the fix, providing good regression coverage. Adding a third run (without modifying session state) would further strengthen coverage.
  • E2E test: The existing test_programmatic_row_selection_via_session_state is extended with expect_prefixed_markdown assertions for selection.selection.rows attribute access, covering both the initial and updated selection states.

Backwards Compatibility

No breaking changes. AttributeDictionary is a subclass of dict, so all existing code using dict-style access (result["selection"]["rows"]) continues to work. This change only adds the ability to also use attribute-style access on the programmatic-set path, matching the behavior of the non-programmatic path.

Security & Risk

No security concerns. The change wraps a return value in AttributeDictionary, a thin dict subclass. No routing, auth, WebSocket, embedding, asset serving, cross-origin, or session management behavior is affected. Regression risk is very low.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • lib/streamlit/elements/arrow.py: Pure return-type wrapping change; no routing, auth, WebSocket, embedding, asset, CORS, SiS, storage, or header changes.
    • e2e_playwright/st_dataframe_selections*.py: Test additions only; no infrastructure changes.
  • Suggested external_test focus areas: N/A
  • Confidence: High
  • Assumptions and gaps: None. The change is entirely internal to the Python return value of st.dataframe and has no observable effect on network, embedding, or cross-origin behavior.

Accessibility

No frontend changes; not applicable.

Recommendations

  1. Consider adding a third at.run() to the unit test (without modifying st.session_state) to verify attribute access is preserved across subsequent reruns — this would strengthen regression coverage at minimal cost.

Verdict

APPROVED: A correct, minimal bug fix that makes programmatic dataframe selection return AttributeDictionary consistent with user-driven selections, with adequate unit and E2E test coverage. The edge case raised by one reviewer was independently verified to not manifest due to the widget deserialization lifecycle.


Consolidated AI review by claude-4.6-opus-high-thinking. Individual reviews: claude-4.6-opus-high-thinking (APPROVED), gemini-3.1-pro (CHANGES REQUESTED), gpt-5.3-codex-high (APPROVED). All 3/3 expected models completed reviews.

This review also includes 2 inline comment(s) on specific code lines.

@lukasmasuch lukasmasuch added the update-snapshots Trigger snapshot autofix workflow label Mar 22, 2026
@github-actions github-actions bot removed the update-snapshots Trigger snapshot autofix workflow label Mar 22, 2026
- Add type: ignore comment for mypy error in E2E test
- Move AppTest import to module level per test guidance
- Add defensive AttributeDictionary wrapping on all return paths
- Add third at.run() to verify selection persists across reruns

Addresses review feedback from Copilot and AI review bots.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@lukasmasuch lukasmasuch added the update-snapshots Trigger snapshot autofix workflow label Mar 22, 2026
@github-actions github-actions bot removed the update-snapshots Trigger snapshot autofix workflow label Mar 22, 2026
## Describe your changes

Automated snapshot updates for #14455 created via the snapshot autofix
CI workflow.

This workflow was triggered by adding the `update-snapshots` label to a
PR after Playwright E2E tests failed with snapshot mismatches.

**Updated snapshots:** 2 file(s)

⚠️ **Please review the snapshot changes carefully** - they could mask
visual bugs if accepted blindly.

This PR targets a feature branch and can be merged without review
approval.

Co-authored-by: Streamlit Bot <[email protected]>
@lukasmasuch lukasmasuch added the ai-review If applied to PR or issue will run AI review workflow label Mar 22, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Mar 22, 2026
Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Summary

This PR fixes a bug (#14454) where programmatic dataframe selection via st.session_state returned a plain dict instead of an AttributeDictionary, causing AttributeError when users accessed selection attributes with dot notation (e.g., event.selection.rows). The fix wraps the return value in AttributeDictionary on both code paths in st.dataframe(): the value_changed path (the actual bug) and the normal widget-state path (defensive hardening).

All three reviewers (claude-4.6-opus-high-thinking, gemini-3.1-pro, gpt-5.3-codex-high) are in full agreement on every aspect of this PR.

Code Quality

The fix is minimal, well-targeted, and follows existing patterns. Two lines changed in lib/streamlit/elements/arrow.py, each wrapping a return value in AttributeDictionary(...) with an appropriate cast. The pattern is consistent with other chart widgets (plotly_chart, vega_charts, deck_gl_json_chart) that all wrap selections in AttributeDictionary via their deserializers. This PR closes the gap where st.dataframe was the only widget that could return a plain dict on the programmatic-selection path. No structural or maintainability concerns were raised by any reviewer.

Test Coverage

All reviewers agree coverage is thorough and appropriately layered:

  • Unit test (test_programmatic_selection_returns_attribute_dictionary): Uses AppTest.from_function to exercise both the value_changed path (second run sets selection via session state) and the normal path (third run verifies persistence). Asserts attribute-style access works, which would fail with AttributeError on the old code.
  • E2E test: Adds expect_prefixed_markdown assertions in the existing test_programmatic_row_selection_via_session_state test to verify attribute access in the full stack, covering both the initial programmatic selection and after changing selection via a button click.
  • Updated snapshots for st_dataframe-programmatic_col_cell_selection are expected — the new st.write line shifts the layout.

Backwards Compatibility

Fully backward compatible. AttributeDictionary extends dict, so all existing code using dict-style access (result["selection"]) continues to work unchanged. The fix restores the intended behavior (matching the deserializer path) that was broken specifically for the programmatic-selection path. All three reviewers concur.

Security & Risk

No security concerns. The change is limited to wrapping an already-validated return value in a dict subclass. No new dependencies, network calls, execution paths, or security-sensitive surfaces are modified. Regression risk is low and localized to dataframe selection event return typing/shape.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • lib/streamlit/elements/arrow.py: Pure data-wrapping change in the return path of st.dataframe(). No routing, auth, embedding, asset serving, cross-origin, or security header changes.
  • Suggested external_test focus areas: N/A
  • Confidence: High (unanimous across all three reviewers)
  • Assumptions and gaps: None. The change is entirely within the Python return value construction and has no network, iframe, or deployment-sensitive implications.

Accessibility

No frontend component changes — the fix is backend-only (Python return value wrapping). The E2E snapshot updates are layout shifts from the new st.write line, not visual changes to the dataframe component itself. No accessibility impact.

Recommendations

No blocking issues. Two minor observations (covered by inline comments):

  1. (Optional) The defensive wrapping on the normal widget-state path (line 1004) is redundant for current code since DataframeSelectionSerde.deserialize() already returns AttributeDictionary, but is reasonable as a guard against future changes.
  2. (Optional) The import comment in the test file (line 38) restates default import conventions and could be removed.

Verdict

APPROVED: Clean, minimal bug fix that correctly wraps programmatic dataframe selection return values in AttributeDictionary for attribute-style access consistency. All three reviewers unanimously approve with no blocking issues, strong test coverage (unit + E2E), full backward compatibility, and no security concerns.


Consolidated AI review by claude-4.6-opus-high-thinking from 3 of 3 expected reviews (claude-4.6-opus-high-thinking, gemini-3.1-pro, gpt-5.3-codex-high). All reviews completed successfully.

This review also includes 2 inline comment(s) on specific code lines.

return widget_state.value
# Defensive wrapping: ensure AttributeDictionary on all code paths for
# attribute-style access consistency (hardening for edge cases).
return cast("DataframeState", AttributeDictionary(widget_state.value))
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.

thought: This wrapping is defensive — widget_state.value already comes from DataframeSelectionSerde.deserialize() which returns an AttributeDictionary (line 231). Re-wrapping creates a shallow copy that behaves identically. The defensive approach is reasonable as it guards against future changes to the deserializer, but worth noting it's not strictly necessary for the current code path.

Address AI reviewer nitpick - top-level imports don't need justification.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@MathCatsAnd
Copy link
Copy Markdown
Contributor

MathCatsAnd commented Mar 22, 2026

@lukasmasuch I tested this wheel. Although it doesn't give the attribute error anymore, it still won't set the state from a manually created selection state dict. (Same problem if I pass it as a default value instead of try to set it with Session State.)

@MathCatsAnd
Copy link
Copy Markdown
Contributor

For reference, this is one of the examples I want to add to the docstrings:

import pandas as pd
import streamlit as st
from numpy.random import default_rng as rng

df = pd.DataFrame(
    rng(0).standard_normal((12, 5)), columns=["a", "b", "c", "d", "e"]
)

if st.button("Select the first row"):
    st.session_state.data.selection = {"rows" : [0]}
if st.button("Select column a"):
    st.session_state.data.selection = {"columns" : [0]}
if st.button("Select the first cell of column a"):
    st.session_state.data.selection = {"cells" : [0, "a"]}

event = st.dataframe(
    df,
    key="data",
    on_select="rerun",
    selection_mode=["single-cell", "single-row", "single-column"]
)

event.selection

@lukasmasuch
Copy link
Copy Markdown
Collaborator Author

Yeah, I can reproduce this. The tricky part is the in-place modification of the selection dict instead of setting the full object. I'll check whether we need to make the dict read-only or enable in-place modifications.

When users tried to modify selections via nested assignment like
`st.session_state.data.selection = {"rows": [0]}`, the modification
happened in-place but wasn't detected by the session state system,
causing silent failures where selections weren't applied visually.

This change makes the dataframe selection state read-only by introducing
`ReadOnlyAttributeDictionary`, which extends `AttributeDictionary` to
maintain backwards compatibility for isinstance checks. When users attempt
nested modifications, they now receive a clear TypeError with guidance to
use full assignment: `st.session_state["key"] = {"selection": {...}}`.

Changes:
- Add `ReadOnlyAttributeDictionary` class in util.py
- Update arrow.py to use `ReadOnlyAttributeDictionary` for selection state
- Add comprehensive tests for the new read-only behavior

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@lukasmasuch lukasmasuch changed the title [fix] Programmatic dataframe selections return AttributeDictionary Fix programmatic dataframe selections to return AttributeDictionary Mar 23, 2026
@lukasmasuch lukasmasuch added the ai-review If applied to PR or issue will run AI review workflow label Mar 23, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Mar 23, 2026
Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Summary

This PR fixes #14454 where programmatically setting dataframe selection state via st.session_state returned a plain dict instead of an AttributeDictionary, causing AttributeError when users accessed selection attributes (e.g., event.selection.rows).

The fix introduces a ReadOnlyAttributeDictionary class in lib/streamlit/util.py that extends AttributeDictionary with read-only enforcement, and wraps all return paths in lib/streamlit/elements/arrow.py to consistently produce this type. Comprehensive unit tests and an E2E regression test are included.

Reviewed by: claude-4.6-opus-high-thinking (APPROVED), gemini-3.1-pro (CHANGES_REQUESTED), gpt-5.3-codex-high (CHANGES_REQUESTED). All three expected reviewers completed their reviews.

Code Quality

The implementation is clean and follows existing patterns in streamlit.util. The ReadOnlyAttributeDictionary class properly extends AttributeDictionary, maintaining isinstance compatibility, and comprehensively blocks mutation methods (__setitem__, __delitem__, clear, pop, popitem, setdefault, update). The __copy__/__deepcopy__ implementations are correct.

All three reviewers flagged a gap in read-only enforcement for dict-style nested access. Currently, __getattr__ wraps nested dicts dynamically (line 132 of util.py), but __getitem__ and get() inherit from dict and return the raw inner dict. This means d["selection"]["rows"] = [3, 4] silently succeeds, bypassing read-only protection. Two reviewers (gemini, gpt) considered this blocking; one (claude) acknowledged it as an acceptable trade-off. The fix — wrapping nested dicts at init time — is small, clean, and closes the gap (see inline comment for the suggested approach).

Additionally, the error message string _READ_ONLY_ERROR_MSG uses {{ and }} in a plain string literal (not an f-string), causing these to display literally as doubled braces rather than single braces. This is a cosmetic bug in the error output.

Test Coverage

Test coverage is thorough across all three tiers, and all reviewers agreed on this:

  • Unit tests (util_test.py): 13 tests covering attribute/dict read access, all mutation methods, nested modification, copy/deepcopy, isinstance checks, and JSON serialization.
  • Unit tests (arrow_dataframe_test.py): Two AppTest-based tests verifying programmatic selection returns AttributeDictionary with working attribute access across multiple reruns, and that state is read-only.
  • E2E tests (st_dataframe_selections_test.py): Regression test verifying attribute access works via selection.selection.rows in both initial and updated programmatic selections.

One gap: the tests only verify attribute-style nested modification is blocked (d.selection.rows = ...) but not dict-style (d["selection"]["rows"] = ...). Tests for the latter should be added once the init-time wrapping fix is applied.

Backwards Compatibility

All reviewers agreed this change is backwards compatible. ReadOnlyAttributeDictionary extends AttributeDictionary extends dict, so all existing isinstance checks continue to work. The only behavioral change is that mutation of returned selection state now raises TypeError — previously, mutations would silently succeed but have no effect on the UI, so any code doing this was already buggy.

Security & Risk

No security concerns. All reviewers agreed this change is purely Python-side data wrapping with no impact on WebSocket handling, server endpoints, file serving, authentication, or frontend rendering.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence:
    • lib/streamlit/util.py: New utility class — pure Python data wrapping, no network/embedding/auth impact.
    • lib/streamlit/elements/arrow.py: Return value wrapping only — no changes to protobuf serialization, widget registration, or frontend communication.
  • Suggested external_test focus areas: N/A
  • Confidence: High (all three reviewers unanimously agreed)
  • Assumptions and gaps: None. Changes are confined to Python-side return value wrapping.

Accessibility

No frontend changes in this PR — only backend Python code and snapshot updates. No accessibility impact. All reviewers agreed.

Recommendations

  1. Wrap nested dicts at init time (blocking — all reviewers flagged this): Add an __init__ to ReadOnlyAttributeDictionary that recursively converts nested dict values to ReadOnlyAttributeDictionary using dict.__setitem__. This ensures dict-style access (d["selection"]), get(), values(), and iteration all return read-only objects, closing the mutation gap.

  2. Fix double braces in error message: Replace {{ with { and }} with } in _READ_ONLY_ERROR_MSG (lines 110, 112 of util.py) so the example code renders correctly.

  3. Add dict-style nested mutation tests: After applying fix #1, add test assertions for d["selection"]["rows"] = [0] in both util_test.py and arrow_dataframe_test.py.

  4. Consider applying ReadOnlyAttributeDictionary to other chart elements (follow-up): vega_charts.py, plotly_chart.py, and deck_gl_json_chart.py still use mutable AttributeDictionary for selection state. A follow-up PR could bring consistency across all chart elements.

Verdict

CHANGES_REQUESTED: Well-implemented bugfix with thorough test coverage and strong reviewer consensus. Two of three reviewers flagged the dict-style nested access gap as blocking — wrapping nested dicts at init time is a small, low-risk fix that should be applied before merge. The double-brace issue in the error message is non-blocking but should also be addressed.


This is a consolidated AI review by claude-4.6-opus-high-thinking, synthesizing reviews from claude-4.6-opus-high-thinking, gemini-3.1-pro, and gpt-5.3-codex-high.

This review also includes 4 inline comment(s) on specific code lines.

@github-actions github-actions bot added the do-not-merge PR is blocked from merging label Mar 23, 2026
- Fix double braces in error message (now displays correctly)
- Add __getitem__ override to wrap nested dicts for bracket access
- Add __ior__ override for |= operator
- Add test for nested bracket-style modification being read-only
- Consolidate mutation tests into parameterized format
- Simplify comment in arrow.py

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@lukasmasuch lukasmasuch added the ai-review If applied to PR or issue will run AI review workflow label Mar 23, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Mar 23, 2026
Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Summary

This PR fixes #14454 where programmatic dataframe selections via st.session_state returned a plain dict instead of an AttributeDictionary, causing AttributeError when users accessed selection attributes (e.g., event.selection.rows).

The fix introduces a ReadOnlyAttributeDictionary class (extending AttributeDictionary) and uses it in all return paths of st.dataframe when on_select is enabled. Beyond restoring attribute-style access, the read-only enforcement prevents users from modifying widget state in-place — mutations that previously silently succeeded but never triggered UI updates — and provides a clear error message guiding them toward correct usage patterns.

Reviewer consensus: All three reviewers (claude-4.6-opus-high-thinking, gemini-3.1-pro, gpt-5.3-codex-high) agreed this is a focused, well-implemented bug fix. Two approved outright; one requested changes based on a mutable-leaf limitation assessed here as non-blocking (see Recommendations).

Code Quality

The code is well-structured and follows existing Streamlit patterns. All three reviewers confirmed high code quality:

  • ReadOnlyAttributeDictionary properly inherits from AttributeDictionary, preserving backward compatibility for isinstance checks.
  • All dict mutation operations are comprehensively blocked (__setitem__, __delitem__, clear, pop, popitem, setdefault, update, __ior__, __setattr__).
  • Custom __copy__ and __deepcopy__ ensure the read-only wrapper survives copy operations.
  • The _READ_ONLY_ERROR_MSG constant is clear, actionable, and follows project conventions for private module-level declarations.
  • The three return paths in arrow.py (deserialize, programmatic selection, normal widget state) are consistently wrapped.

One minor optimization opportunity exists in __getattr__ (covered in inline comments): the current implementation creates unnecessary intermediate AttributeDictionary objects due to the super-call chain. This is functionally correct but slightly wasteful.

Test Coverage

All reviewers confirmed thorough, well-layered test coverage:

  • Unit tests (util_test.py): Comprehensive coverage of ReadOnlyAttributeDictionary including attribute/dict access, isinstance compatibility, all mutation operations (via @pytest.mark.parametrize), bracket access returning read-only nested dicts, deep/shallow copy, and JSON serialization.
  • Unit tests (arrow_dataframe_test.py): End-to-end AppTest verification across multiple runs (initial, programmatic set, persistence) plus read-only enforcement via both attribute and bracket access.
  • E2E tests: Adds expect_prefixed_markdown assertions for attribute access in the existing programmatic selection test.

All tests follow project conventions including meaningful docstrings and parameterization patterns.

Backwards Compatibility

This change is backward compatible for intended usage patterns:

  • ReadOnlyAttributeDictionary extends AttributeDictionary, so existing isinstance checks continue to work.
  • All read-access patterns (attribute and bracket style) work identically to before.
  • The only behavioral change is that in-place mutation of returned state now raises TypeError instead of silently succeeding without triggering UI updates. This is a positive breaking change that replaces confusing silent failure with an actionable error message.
  • json.dumps() serialization continues to work since the class is a dict subclass.

Security & Risk

No security concerns identified. All three reviewers confirmed:

  • No new dependencies added.
  • No changes to WebSocket handling, server endpoints, authentication, or session management.
  • No changes to file handling, HTML/Markdown rendering, or JavaScript execution.
  • Changes are entirely within Python-side return-value wrapping logic.

External test recommendation

  • Recommend external_test: No
  • Triggered categories: None
  • Evidence: All changed files (lib/streamlit/util.py, lib/streamlit/elements/arrow.py, test files) are limited to Python return-value wrapping. No routing, auth, websocket/session transport, embedding, assets, cross-origin behavior, or security header changes.
  • Suggested external_test focus areas: None needed.
  • Confidence: High (unanimous across all three reviewers)
  • Assumptions and gaps: None. Changes are purely to internal Python return types.

Accessibility

No frontend changes are included in this PR. Snapshot updates are a consequence of E2E test app script changes (adding st.write lines), not visual or interactive changes. No accessibility concerns. All reviewers concurred.

Recommendations

  1. Follow-up: Mutable leaf objectsReadOnlyAttributeDictionary does not freeze mutable leaf values such as lists. event.selection.rows.append(1) will silently mutate without triggering a UI update or raising an error. This is a pre-existing limitation (not introduced by this PR) and Python lacks a built-in immutable list type. One reviewer (gpt-5.3-codex-high) flagged this as blocking; the other two acknowledged it as a known limitation. Assessment: This is a valid improvement opportunity but is out of scope for this bug-fix PR. Consider returning tuple instead of list for leaf sequences in a follow-up.
  2. Minor optimization in __getattr__ — Delegate directly to self[key] instead of super().__getattr__(key) to avoid triple-wrapping through the parent class. See inline comment for details.
  3. Apply to other chart widgets — Consider applying ReadOnlyAttributeDictionary to other chart elements (st.plotly_chart, st.pydeck_chart, st.vega_lite_chart) that use AttributeDictionary for selection state and suffer from the same in-place mutation confusion.

Verdict

APPROVED: Clean, well-tested bug fix that correctly wraps dataframe selection state in a read-only attribute dictionary, fixing attribute access on programmatic selections while adding helpful guardrails against in-place mutation. The mutable-leaf limitation noted by one reviewer is a pre-existing condition outside this PR's scope and is recommended as a follow-up.

Reviewer agreement summary
Reviewer Verdict Notes
claude-4.6-opus-high-thinking APPROVED No blocking issues
gemini-3.1-pro APPROVED No blocking issues
gpt-5.3-codex-high CHANGES_REQUESTED Mutable leaf gap (assessed as non-blocking follow-up)

This is a consolidated AI review by claude-4.6-opus-high-thinking, synthesizing reviews from claude-4.6-opus-high-thinking, gemini-3.1-pro, and gpt-5.3-codex-high. Please verify the feedback and use your judgment.

This review also includes 2 inline comment(s) on specific code lines.

@github-actions github-actions bot removed the do-not-merge PR is blocked from merging label Mar 23, 2026
@MathCatsAnd
Copy link
Copy Markdown
Contributor

MathCatsAnd commented Mar 23, 2026

If you look at the originial bug report, it was also not working when I created the entire dictionary (with or without the empty "rows" and "columns"). If we have any limitation on how to set and modify state because of technical difficulty, I can also just document it. I was trying to test out some different cases and cover 1) setting a default selection, 2) overriding the entire selection, 3) appending to/removing from the existing selection

…tr__

Fix __getattr__ to delegate directly to self[key] instead of calling
super().__getattr__(key). The previous implementation caused nested dicts
to be wrapped twice in ReadOnlyAttributeDictionary because:
1. AttributeDictionary.__getattr__ calls self.__getitem__
2. ReadOnlyAttributeDictionary.__getitem__ wraps dicts
3. Then __getattr__ was wrapping again

Also added test to verify attribute access returns ReadOnlyAttributeDictionary.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@MathCatsAnd
Copy link
Copy Markdown
Contributor

(I had a typo in my example.) I just tried the latest wheel and I think it's working.

import pandas as pd
import streamlit as st
from numpy.random import default_rng as rng

df = pd.DataFrame(
    rng(0).standard_normal((12, 5)), columns=["a", "b", "c", "d", "e"]
)

if st.button("Select the first row"):
    st.session_state.data = {"selection": {"rows" : [0]}}
if st.button("Select column a"):
    st.session_state.data = {"selection": {"columns" : ["a"]}}
if st.button("Select the first cell of column a"):
    st.session_state.data = {"selection" : {"cells" : [[0, "a"]]}}

event = st.dataframe(
    df,
    key="data",
    on_select="rerun",
    selection_mode=["single-cell", "single-row", "single-column"]
)

event.selection

Copy link
Copy Markdown
Collaborator

@sfc-gh-lwilby sfc-gh-lwilby left a comment

Choose a reason for hiding this comment

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

LGTM

Resolved conflicts:
- lib/streamlit/elements/arrow.py: Combined single-row-required auto-select
  logic from develop with ReadOnlyAttributeDictionary from HEAD
- lib/tests/streamlit/elements/arrow_dataframe_test.py: Kept both test sets
  (single-row-required tests and read-only state tests)
- e2e_playwright/__snapshots__/*/st_dataframe-programmatic_col_cell_selection[chromium].png:
  Accepted develop's version

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@lukasmasuch lukasmasuch enabled auto-merge (squash) March 27, 2026 08:10
@lukasmasuch lukasmasuch merged commit 720aa0e into develop Mar 27, 2026
43 checks passed
@lukasmasuch lukasmasuch deleted the lukasmasuch/fix-issue-14454 branch March 27, 2026 08:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:bugfix PR contains bug fix implementation impact:users PR changes affect end users

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Nightly] Programmatic dataframe selections don't recognize "selection"

5 participants