Skip to content

Commit cbc86fc

Browse files
MaxGhenisclaude
andcommitted
Fix pandas 3.0 compatibility issues with MicroSeries method access
The previous approach of modifying `__class__` on DataFrame columns no longer works in pandas 3.0 due to Copy-on-Write behavior. Each column access returns a new copy, so class modifications don't persist. This fix changes the strategy: - `__getitem__` now wraps all Series results as MicroSeries on access - Removed the broken `_link_weights` and `catch_series_relapse` logic - Simplified `_link_all_weights` since columns are wrapped on access This resolves the "AttributeError: 'Series' object has no attribute 'set_weights'" error that occurred when using MicroDataFrame with pandas 3.0. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c13a67d commit cbc86fc

3 files changed

Lines changed: 75 additions & 70 deletions

File tree

changelog_entry.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- bump: patch
2+
changes:
3+
fixed:
4+
- Fixed pandas 3.0 compatibility issues with MicroSeries method access and Copy-on-Write behavior

microdf/microdataframe.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -278,18 +278,18 @@ def __setitem__(self, *args, **kwargs) -> None:
278278
self._link_all_weights()
279279

280280
def _link_weights(self, column) -> None:
281-
# self[column] = ... triggers __setitem__, which forces pd.Series
282-
# this workaround avoids that
283-
self[column].__class__ = MicroSeries
284-
self[column].set_weights(self.weights)
281+
# In pandas 3.0+, we can't modify column classes in-place due to CoW.
282+
# Instead, we rely on __getitem__ to wrap columns as MicroSeries on
283+
# access. This method is kept for backward compatibility but is now
284+
# a no-op.
285+
pass
285286

286287
def _link_all_weights(self) -> None:
287288
if self.weights is None:
288289
if len(self) > 0:
289290
self.set_weights(np.ones((len(self))))
290-
for column in self.columns:
291-
if column != self.weights_col:
292-
self._link_weights(column)
291+
# In pandas 3.0+, columns are wrapped as MicroSeries on access via
292+
# __getitem__, not stored as MicroSeries internally.
293293

294294
def set_weights(
295295
self,
@@ -365,7 +365,7 @@ def nullify_weights(self) -> None:
365365

366366
def __getitem__(
367367
self, key: Union[str, List]
368-
) -> Union[pd.Series, pd.DataFrame]:
368+
) -> Union[MicroSeries, "MicroDataFrame"]:
369369
# Let pandas handle the initial slicing
370370
result = super().__getitem__(key)
371371

@@ -374,17 +374,22 @@ def __getitem__(
374374
new_weights = self.weights.reindex(result.index)
375375
return MicroDataFrame(result, weights=new_weights)
376376

377-
# Otherwise, the result is a Series or a scalar, so just return it
377+
# If the result is a Series (single column), wrap as MicroSeries
378+
if isinstance(result, pd.Series):
379+
return MicroSeries(result, weights=self.weights)
380+
381+
# Otherwise, the result is a scalar, so just return it
378382
return result
379383

380384
def catch_series_relapse(self) -> None:
381-
for col in self.columns:
382-
if self[col].__class__ == pd.Series:
383-
self._link_weights(col)
385+
# In pandas 3.0+, we don't need to track series class changes since
386+
# __getitem__ always wraps columns as MicroSeries on access.
387+
pass
384388

385389
def __setattr__(self, key, value) -> None:
386390
super().__setattr__(key, value)
387-
self.catch_series_relapse()
391+
# No need to call catch_series_relapse in pandas 3.0+ since we wrap
392+
# on access rather than store MicroSeries internally.
388393

389394
def reset_index(
390395
self,

microdf/tests/test_pandas3_compatibility.py

Lines changed: 53 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
"""
2-
Tests for pandas 3.0.0 compatibility in microdf.
1+
"""Tests for pandas 3.0.0 compatibility in microdf.
32
43
These tests verify that microdf works correctly with pandas 3.0.0,
54
which introduces:
@@ -10,18 +9,17 @@
109

1110
import numpy as np
1211
import pandas as pd
13-
import pytest
1412

15-
from microdf.microseries import MicroSeries
1613
from microdf.microdataframe import MicroDataFrame
14+
from microdf.microseries import MicroSeries
1715

1816

1917
class TestMicroSeriesSubclassPreservation:
2018
"""Test that MicroSeries subclass is preserved across operations."""
2119

2220
def test_microseries_set_weights_after_creation(self):
23-
"""
24-
Ensure set_weights works on MicroSeries.
21+
"""Ensure set_weights works on MicroSeries.
22+
2523
This is the error reported in pandas 3:
2624
AttributeError: 'Series' object has no attribute 'set_weights'
2725
"""
@@ -34,108 +32,115 @@ def test_microseries_set_weights_after_creation(self):
3432
assert np.allclose(ms.weights, [2.0, 2.0, 2.0])
3533

3634
def test_microseries_preserved_after_arithmetic(self):
37-
"""
38-
Arithmetic operations should return MicroSeries, not plain Series.
39-
"""
35+
"""Arithmetic operations should return MicroSeries, not plain
36+
Series."""
4037
ms = MicroSeries([1, 2, 3], weights=np.array([1.0, 2.0, 3.0]))
4138

4239
# Addition
4340
result = ms + 1
44-
assert isinstance(result, MicroSeries), f"Got {type(result)} instead of MicroSeries"
41+
assert isinstance(
42+
result, MicroSeries
43+
), f"Got {type(result)} instead of MicroSeries"
4544
assert hasattr(result, "weights")
4645
assert hasattr(result, "set_weights")
4746

4847
# Multiplication
4948
result = ms * 2
50-
assert isinstance(result, MicroSeries), f"Got {type(result)} instead of MicroSeries"
49+
assert isinstance(
50+
result, MicroSeries
51+
), f"Got {type(result)} instead of MicroSeries"
5152

5253
# Division
5354
result = ms / 2
54-
assert isinstance(result, MicroSeries), f"Got {type(result)} instead of MicroSeries"
55+
assert isinstance(
56+
result, MicroSeries
57+
), f"Got {type(result)} instead of MicroSeries"
5558

5659
def test_microseries_preserved_after_comparison(self):
57-
"""
58-
Comparison operations should return MicroSeries, not plain Series.
59-
"""
60+
"""Comparison operations should return MicroSeries, not plain
61+
Series."""
6062
ms = MicroSeries([1, 2, 3], weights=np.array([1.0, 2.0, 3.0]))
6163

6264
# Greater than
6365
result = ms > 1
64-
assert isinstance(result, MicroSeries), f"Got {type(result)} instead of MicroSeries"
66+
assert isinstance(
67+
result, MicroSeries
68+
), f"Got {type(result)} instead of MicroSeries"
6569
assert hasattr(result, "weights")
6670

6771
# Less than
6872
result = ms < 3
69-
assert isinstance(result, MicroSeries), f"Got {type(result)} instead of MicroSeries"
73+
assert isinstance(
74+
result, MicroSeries
75+
), f"Got {type(result)} instead of MicroSeries"
7076

7177
def test_microseries_preserved_after_indexing(self):
72-
"""
73-
Indexing operations should return MicroSeries, not plain Series.
74-
"""
75-
ms = MicroSeries([1, 2, 3, 4, 5], weights=np.array([1.0, 2.0, 3.0, 4.0, 5.0]))
78+
"""Indexing operations should return MicroSeries, not plain Series."""
79+
ms = MicroSeries(
80+
[1, 2, 3, 4, 5], weights=np.array([1.0, 2.0, 3.0, 4.0, 5.0])
81+
)
7682

7783
# Boolean indexing
7884
result = ms[ms > 2]
79-
assert isinstance(result, MicroSeries), f"Got {type(result)} instead of MicroSeries"
85+
assert isinstance(
86+
result, MicroSeries
87+
), f"Got {type(result)} instead of MicroSeries"
8088
assert hasattr(result, "weights")
8189

8290
# Slice indexing
8391
result = ms[1:3]
84-
assert isinstance(result, MicroSeries), f"Got {type(result)} instead of MicroSeries"
92+
assert isinstance(
93+
result, MicroSeries
94+
), f"Got {type(result)} instead of MicroSeries"
8595

8696

8797
class TestMicroDataFrameSubclassPreservation:
8898
"""Test that MicroDataFrame column access returns MicroSeries."""
8999

90100
def test_microdataframe_column_returns_microseries(self):
91-
"""
92-
Accessing a column from MicroDataFrame should return MicroSeries.
93-
"""
101+
"""Accessing a column from MicroDataFrame should return MicroSeries."""
94102
mdf = MicroDataFrame(
95-
{"a": [1, 2, 3], "b": [4, 5, 6]},
96-
weights=np.array([1.0, 2.0, 3.0])
103+
{"a": [1, 2, 3], "b": [4, 5, 6]}, weights=np.array([1.0, 2.0, 3.0])
97104
)
98105

99106
# Column access
100107
col = mdf["a"]
101-
assert isinstance(col, MicroSeries), f"Got {type(col)} instead of MicroSeries"
108+
assert isinstance(
109+
col, MicroSeries
110+
), f"Got {type(col)} instead of MicroSeries"
102111
assert hasattr(col, "weights")
103112
assert hasattr(col, "set_weights")
104113

105114
def test_microdataframe_operations_preserve_type(self):
106-
"""
107-
Operations on MicroDataFrame columns should preserve MicroSeries type.
108-
"""
115+
"""Operations on MicroDataFrame columns should preserve MicroSeries
116+
type."""
109117
mdf = MicroDataFrame(
110-
{"a": [1, 2, 3], "b": [4, 5, 6]},
111-
weights=np.array([1.0, 2.0, 3.0])
118+
{"a": [1, 2, 3], "b": [4, 5, 6]}, weights=np.array([1.0, 2.0, 3.0])
112119
)
113120

114121
# Column operations
115122
result = mdf["a"] + mdf["b"]
116-
assert isinstance(result, MicroSeries), f"Got {type(result)} instead of MicroSeries"
123+
assert isinstance(
124+
result, MicroSeries
125+
), f"Got {type(result)} instead of MicroSeries"
117126
assert hasattr(result, "weights")
118127

119128

120129
class TestStringDtypeHandling:
121130
"""Test that MicroSeries/MicroDataFrame handle pandas 3 string dtypes."""
122131

123132
def test_microseries_with_string_data(self):
124-
"""
125-
MicroSeries should work with string data in pandas 3.
126-
"""
133+
"""MicroSeries should work with string data in pandas 3."""
127134
# Create with string data
128135
ms = MicroSeries(["a", "b", "c"], weights=np.array([1.0, 2.0, 3.0]))
129136
assert len(ms) == 3
130137
assert hasattr(ms, "weights")
131138

132139
def test_microdataframe_with_string_columns(self):
133-
"""
134-
MicroDataFrame should work with string columns in pandas 3.
135-
"""
140+
"""MicroDataFrame should work with string columns in pandas 3."""
136141
mdf = MicroDataFrame(
137142
{"names": ["alice", "bob", "charlie"], "values": [1, 2, 3]},
138-
weights=np.array([1.0, 2.0, 3.0])
143+
weights=np.array([1.0, 2.0, 3.0]),
139144
)
140145
assert len(mdf) == 3
141146

@@ -169,9 +174,7 @@ class TestCopyOnWriteCompatibility:
169174
"""Test compatibility with pandas 3 Copy-on-Write."""
170175

171176
def test_microseries_copy_independent(self):
172-
"""
173-
Copying a MicroSeries should create an independent copy.
174-
"""
177+
"""Copying a MicroSeries should create an independent copy."""
175178
ms = MicroSeries([1, 2, 3], weights=np.array([1.0, 2.0, 3.0]))
176179
ms_copy = ms.copy()
177180

@@ -182,12 +185,9 @@ def test_microseries_copy_independent(self):
182185
assert np.allclose(ms_copy.weights, [1.0, 2.0, 3.0])
183186

184187
def test_microdataframe_copy_independent(self):
185-
"""
186-
Copying a MicroDataFrame should create an independent copy.
187-
"""
188+
"""Copying a MicroDataFrame should create an independent copy."""
188189
mdf = MicroDataFrame(
189-
{"a": [1, 2, 3]},
190-
weights=np.array([1.0, 2.0, 3.0])
190+
{"a": [1, 2, 3]}, weights=np.array([1.0, 2.0, 3.0])
191191
)
192192
mdf_copy = mdf.copy()
193193

@@ -202,9 +202,7 @@ class TestGroupByWithPandas3:
202202
"""Test groupby operations with pandas 3."""
203203

204204
def test_microseries_groupby_preserves_weights(self):
205-
"""
206-
GroupBy operations should preserve weights.
207-
"""
205+
"""GroupBy operations should preserve weights."""
208206
ms = MicroSeries([1, 2, 3, 4], weights=np.array([1.0, 2.0, 3.0, 4.0]))
209207
groups = pd.Series(["a", "a", "b", "b"])
210208

@@ -217,12 +215,10 @@ def test_microseries_groupby_preserves_weights(self):
217215
assert result["b"] == 25
218216

219217
def test_microdataframe_groupby_preserves_weights(self):
220-
"""
221-
MicroDataFrame groupby should preserve weights on columns.
222-
"""
218+
"""MicroDataFrame groupby should preserve weights on columns."""
223219
mdf = MicroDataFrame(
224220
{"group": ["a", "a", "b", "b"], "value": [1, 2, 3, 4]},
225-
weights=np.array([1.0, 2.0, 3.0, 4.0])
221+
weights=np.array([1.0, 2.0, 3.0, 4.0]),
226222
)
227223

228224
gb = mdf.groupby("group")

0 commit comments

Comments
 (0)