-
Notifications
You must be signed in to change notification settings - Fork 9
Add pandas 3 support Status: Open. #96 #112
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
vrane-tibco
wants to merge
13
commits into
main
Choose a base branch
from
feature/pandas3_support
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
00cdac7
Add pandas 3 support
vrane-tibco 81939f0
Remove Python 3.9/3.10 from CI matrix (pandas 3 requires 3.11+)
vrane-tibco 7ce4606
Refactor array creation in test_sbdf.py to use explicit type annotations
vrane-tibco 85a7c8e
Remove unused import of spotfire from sbdf.pyx
vrane-tibco 1555c4b
Update numpy version to >= 2.0.0
vrane-tibco 0800b44
Add tzdata dependency for time zone support in minimal Linux containers
vrane-tibco 9c6d267
merged main branch
vrane-tibco f709687
Potential fix for pull request finding
vrane-tibco 7f53ea5
Add support for exporting Spotfire types from standalone Series
vrane-tibco 10e589e
Format long string in column metadata hint for lint fix
vrane-tibco 5eccf9b
Refactor metadata retrieval to avoid cython-lint crashes by using exp…
vrane-tibco 5cc3b5c
Enhance detach method documentation to clarify shallow copy behavior …
vrane-tibco 60783d1
Simplify numpy array conversion by consolidating dtype handling in ex…
vrane-tibco File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| [ "3.14", "3.13", "3.12", "3.11", "3.10", "3.9" ] | ||
| [ "3.14", "3.13", "3.12", "3.11" ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,220 @@ | ||
| # Copyright © 2026. Cloud Software Group, Inc. | ||
| # This file is subject to the license terms contained | ||
| # in the license file that is distributed with this file. | ||
|
|
||
| """Accessor helpers for Spotfire metadata on DataFrames. | ||
|
|
||
| Metadata is stored in ``df.attrs`` at the DataFrame level (never per-column). | ||
| This survives copy, drop, rename, concat, groupby, and inplace operations | ||
| in pandas 3. Only ``merge()`` loses ``df.attrs``. | ||
|
|
||
| For backward compatibility, the old monkey-patched ``df.spotfire_table_metadata`` | ||
| pattern is also checked (with a deprecation warning). | ||
|
|
||
| For the old ``df['col'].spotfire_column_metadata`` pattern, a Series accessor | ||
| is registered that raises a clear error — this pattern is silently broken | ||
| under pandas 3 Copy-on-Write. | ||
|
|
||
| This module is the single abstraction point for metadata storage on | ||
| pandas DataFrames. | ||
| """ | ||
|
|
||
| import copy | ||
| import warnings | ||
|
|
||
| _TABLE_METADATA_KEY = 'spotfire_table_metadata' | ||
| _COLUMN_METADATA_KEY = 'spotfire_column_metadata' | ||
| _SPOTFIRE_TYPES_KEY = 'spotfire_types' | ||
|
|
||
| # Scalar key set directly on a standalone Series' attrs (``series.attrs['spotfire_type']``). | ||
| # There is no DataFrame to hang a keyed dict off of in that case, so this remains | ||
| # the supported way to force a Spotfire type when exporting a bare Series. | ||
| _SERIES_TYPE_KEY = 'spotfire_type' | ||
|
|
||
| _ALL_KEYS = (_TABLE_METADATA_KEY, _COLUMN_METADATA_KEY, _SPOTFIRE_TYPES_KEY) | ||
|
|
||
| # Keys whose values grow with column count and need CoW optimization. | ||
| # Table metadata is excluded: it is small and passed to Cython's | ||
| # ``_export_metadata(dict md, ...)`` which rejects dict subclasses. | ||
| _COW_KEYS = (_COLUMN_METADATA_KEY, _SPOTFIRE_TYPES_KEY) | ||
|
|
||
|
|
||
| class _CowDict(dict): | ||
| """Copy-on-Write dict for pandas 3 ``deepcopy(df.attrs)`` performance. | ||
|
|
||
| Pandas 3 calls ``deepcopy(df.attrs)`` on every ``df[col]`` access. | ||
| With large metadata dicts (one entry per column), the recursive deep | ||
| copy is O(n) per access, making column loops O(n^2). | ||
|
|
||
| This subclass applies Copy-on-Write: ``__deepcopy__`` returns ``self`` | ||
| (O(1) — zero cost on read), and the ``_metadata`` setters detach by | ||
| shallow-copying on first write when the dict is shared. This gives | ||
| the speed of ``return self`` with the isolation of a full copy for the | ||
| metadata mapping itself — see ``detach`` for what that deliberately | ||
| does not cover. | ||
| """ | ||
|
|
||
| _shared = False | ||
|
|
||
| def __deepcopy__(self, memo): | ||
| self._shared = True | ||
| return self | ||
|
|
||
| def __copy__(self): | ||
| self._shared = True | ||
| return self | ||
|
|
||
| def is_shared(self): | ||
| """Return whether this dict has been handed out via deepcopy.""" | ||
| return self._shared | ||
|
|
||
| def detach(self): | ||
| """Create an independent shallow copy and clear the shared flag. | ||
|
|
||
| The copy is deliberately shallow. It isolates the mapping, which is all | ||
| the setters need: ``set_column_metadata`` and ``set_spotfire_type`` | ||
| rebind a whole entry, so a write through one DataFrame cannot be seen by | ||
| another that shares this dict. Nested values stay shared, so mutating a | ||
| dict returned by ``get_column_metadata`` in place is still visible to | ||
| those copies; go through the setters instead. Deep-copying here would | ||
| make each detach O(n) in the column count and reintroduce the O(n^2) | ||
| column loop this class exists to avoid. | ||
| """ | ||
| clone = _CowDict(self) | ||
| clone._shared = False # pylint: disable=protected-access | ||
| return clone | ||
|
|
||
|
|
||
| def _detach_if_shared(dataframe, key): | ||
| """If the dict for *key* is shared, replace it with an independent shallow copy.""" | ||
| val = dataframe.attrs.get(key) | ||
| if val is not None and isinstance(val, _CowDict) and val.is_shared(): | ||
| val = val.detach() | ||
| dataframe.attrs[key] = val | ||
| return val | ||
|
|
||
|
|
||
| def _get(dataframe, key, default=None): | ||
| """Read a metadata key from ``df.attrs``, with legacy ``__dict__`` fallback.""" | ||
| val = dataframe.attrs.get(key) | ||
| if val is not None: | ||
| return val | ||
|
|
||
| # Fallback: legacy monkey-patched attribute in __dict__ | ||
| val = dataframe.__dict__.get(key) | ||
| if val is not None: | ||
| api_hint = { | ||
| _TABLE_METADATA_KEY: "spotfire.get_table_metadata() / spotfire.set_table_metadata()", | ||
| _COLUMN_METADATA_KEY: "spotfire.get_column_metadata(df, col) / " | ||
| "spotfire.set_column_metadata(df, col, metadata)", | ||
| _SPOTFIRE_TYPES_KEY: "spotfire.get_spotfire_types(df) / spotfire.set_spotfire_types(df, column_types)", | ||
| }.get(key, "the Spotfire metadata APIs") | ||
| warnings.warn( | ||
| f"Accessing metadata via df.{key} is deprecated. Use {api_hint} instead.", | ||
| DeprecationWarning, | ||
| stacklevel=3 | ||
| ) | ||
| return val | ||
|
|
||
| return default | ||
|
|
||
|
|
||
| def _set(dataframe, key, value): | ||
| """Write a metadata key to ``df.attrs``.""" | ||
| if key in _COW_KEYS and isinstance(value, dict) and not isinstance(value, _CowDict): | ||
| value = _CowDict(value) | ||
| dataframe.attrs[key] = value | ||
|
|
||
|
|
||
| # --- Public API --- | ||
|
|
||
| def get_table_metadata(dataframe): | ||
| """Return the table-level Spotfire metadata dict, or ``{}`` if none.""" | ||
| return _get(dataframe, _TABLE_METADATA_KEY, {}) | ||
|
|
||
|
|
||
| def set_table_metadata(dataframe, metadata): | ||
| """Set the table-level Spotfire metadata dict.""" | ||
| _set(dataframe, _TABLE_METADATA_KEY, metadata) | ||
|
|
||
|
|
||
| def get_column_metadata(dataframe, col): | ||
| """Return the Spotfire metadata dict for *col*, or ``{}`` if none.""" | ||
| return _get(dataframe, _COLUMN_METADATA_KEY, {}).get(col, {}) | ||
|
|
||
|
|
||
| def set_column_metadata(dataframe, col, metadata): | ||
| """Set the Spotfire metadata dict for *col*.""" | ||
| col_meta = _detach_if_shared(dataframe, _COLUMN_METADATA_KEY) | ||
| if col_meta is None: | ||
| col_meta = _CowDict() | ||
| _set(dataframe, _COLUMN_METADATA_KEY, col_meta) | ||
| col_meta[col] = metadata | ||
|
|
||
|
|
||
| def get_spotfire_type(dataframe, col): | ||
| """Return the Spotfire type name for *col*, or ``None`` if not set.""" | ||
| return _get(dataframe, _SPOTFIRE_TYPES_KEY, {}).get(col) | ||
|
|
||
|
|
||
| def get_series_spotfire_type(series, col): | ||
| """Return the Spotfire type name to use when exporting a standalone ``Series``, or ``None`` if not set. | ||
|
|
||
| Looks in the DataFrame-style ``spotfire_types`` dict first (present when the metadata was | ||
| copied from a DataFrame), then falls back to the scalar ``series.attrs['spotfire_type']`` | ||
| that callers set directly on a bare Series. | ||
| """ | ||
| typename = get_spotfire_type(series, col) | ||
| if typename is None: | ||
| typename = series.attrs.get(_SERIES_TYPE_KEY) | ||
| return typename | ||
|
|
||
|
|
||
| def set_spotfire_type(dataframe, col, typename): | ||
| """Set the Spotfire type name for *col*.""" | ||
| types = _detach_if_shared(dataframe, _SPOTFIRE_TYPES_KEY) | ||
| if types is None: | ||
| types = _CowDict() | ||
| _set(dataframe, _SPOTFIRE_TYPES_KEY, types) | ||
| types[col] = typename | ||
|
|
||
|
|
||
| def get_all_spotfire_types(dataframe): | ||
| """Return a dict mapping column name -> Spotfire type name for all columns that have one.""" | ||
| return dict(_get(dataframe, _SPOTFIRE_TYPES_KEY, {})) | ||
|
|
||
|
|
||
| def copy_all_metadata(source, destination): | ||
| """Copy all Spotfire metadata from *source* to *destination*.""" | ||
| for key in _ALL_KEYS: | ||
| val = _get(source, key) | ||
| if val: | ||
| _set(destination, key, copy.deepcopy(val)) | ||
|
|
||
|
|
||
| # --- Deprecation helpers for old per-column patterns --- | ||
|
|
||
| def _register_deprecated_accessors(): | ||
| """Register pandas accessor that raises clear error for old per-column metadata pattern. | ||
|
|
||
| ``df['col'].spotfire_column_metadata = {...}`` is silently broken under pandas 3 | ||
| Copy-on-Write (writes are discarded). This accessor intercepts READ attempts | ||
| and raises a helpful error pointing to the new API. | ||
| """ | ||
| try: | ||
| import pandas as pd # pylint: disable=import-outside-toplevel | ||
|
|
||
| @pd.api.extensions.register_series_accessor('spotfire_column_metadata') | ||
| class _DeprecatedColumnMetadata: # pylint: disable=too-few-public-methods | ||
| def __init__(self, series): | ||
| raise AttributeError( | ||
| "df['col'].spotfire_column_metadata is not supported under pandas 3 " | ||
| "Copy-on-Write (writes are silently discarded). " | ||
| "Use spotfire.get_column_metadata(df, 'col') and " | ||
| "spotfire.set_column_metadata(df, 'col', metadata) instead." | ||
| ) | ||
| except Exception: # pylint: disable=broad-exception-caught | ||
| pass | ||
|
|
||
|
|
||
| _register_deprecated_accessors() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.