From 996290439616f44f4c346e393fbdd23cda1ff332 Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Sat, 24 May 2025 22:47:51 +0100 Subject: [PATCH 01/17] ENH: Support plugin DataFrame accessor via entry points (#29076) Allows external libraries to register DataFrame accessors using the 'pandas_dataframe_accessor' entry point group. This enables plugins to be automatically used without explicit import. Co-authored-by: Afonso Antunes --- pandas/__init__.py | 4 +++ pandas/core/accessor.py | 26 ++++++++++++++ pandas/tests/test_plugis_entrypoint_loader.py | 35 +++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 pandas/tests/test_plugis_entrypoint_loader.py diff --git a/pandas/__init__.py b/pandas/__init__.py index 7d6dd7b7c1a88..cad3134e9120c 100644 --- a/pandas/__init__.py +++ b/pandas/__init__.py @@ -346,3 +346,7 @@ "unique", "wide_to_long", ] + +from pandas.core.accessor import DataFrameAccessorLoader + +DataFrameAccessorLoader.load() diff --git a/pandas/core/accessor.py b/pandas/core/accessor.py index 0331c26c805b6..d0464403ceecd 100644 --- a/pandas/core/accessor.py +++ b/pandas/core/accessor.py @@ -26,6 +26,9 @@ from pandas.core.generic import NDFrame +from importlib_metadata import entry_points + + class DirNamesMixin: _accessors: set[str] = set() _hidden_attrs: frozenset[str] = frozenset() @@ -393,3 +396,26 @@ def register_index_accessor(name: str) -> Callable[[TypeT], TypeT]: from pandas import Index return _register_accessor(name, Index) + + +class DataFrameAccessorLoader: + """Loader class for registering DataFrame accessors via entry points.""" + + ENTRY_POINT_GROUP = "pandas_dataframe_accessor" + + @classmethod + def load(cls): + """loads and registers accessors defined by 'pandas_dataframe_accessor'.""" + eps = entry_points(group=cls.ENTRY_POINT_GROUP) + + for ep in eps: + name = ep.name + + def make_property(ep): + def accessor(self): + cls_ = ep.load() + return cls_(self) + + return accessor + + register_dataframe_accessor(name)(make_property(ep)) diff --git a/pandas/tests/test_plugis_entrypoint_loader.py b/pandas/tests/test_plugis_entrypoint_loader.py new file mode 100644 index 0000000000000..182c76e95d45f --- /dev/null +++ b/pandas/tests/test_plugis_entrypoint_loader.py @@ -0,0 +1,35 @@ +import pandas as pd +from pandas.core.accessor import DataFrameAccessorLoader + + +def test_load_dataframe_accessors(monkeypatch): + # GH29076 + # Mocked EntryPoint to simulate a plugin + class MockEntryPoint: + name = "test_accessor" + + def load(self): + class TestAccessor: + def __init__(self, df): + self._df = df + + def test_method(self): + return "success" + + return TestAccessor + + # Mock entry_points + def mock_entry_points(*, group): + if group == DataFrameAccessorLoader.ENTRY_POINT_GROUP: + return [MockEntryPoint()] + return [] + + # Patch entry_points in the correct module + monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) + + DataFrameAccessorLoader.load() + + # Create DataFrame and verify that the accessor was registered + df = pd.DataFrame({"a": [1, 2, 3]}) + assert hasattr(df, "test_accessor") + assert df.test_accessor.test_method() == "success" From ded0b0df853f327c11b8e7e2f610e61379fccab4 Mon Sep 17 00:00:00 2001 From: Pedro Date: Mon, 26 May 2025 22:30:44 +0100 Subject: [PATCH 02/17] add whatsnew entry && type annotations --- doc/source/whatsnew/v3.0.0.rst | 1 + pandas/core/accessor.py | 13 +++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 8695e196c4f38..b5c22a949d9f5 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -83,6 +83,7 @@ Other enhancements - Improved deprecation message for offset aliases (:issue:`60820`) - Multiplying two :class:`DateOffset` objects will now raise a ``TypeError`` instead of a ``RecursionError`` (:issue:`59442`) - Restore support for reading Stata 104-format and enable reading 103-format dta files (:issue:`58554`) +- Support :class:`DataFrame` plugin accessor via entry points (:issue:`29076`) - Support passing a :class:`Iterable[Hashable]` input to :meth:`DataFrame.drop_duplicates` (:issue:`59237`) - Support reading Stata 102-format (Stata 1) dta files (:issue:`58978`) - Support reading Stata 110-format (Stata 7) dta files (:issue:`47176`) diff --git a/pandas/core/accessor.py b/pandas/core/accessor.py index d0464403ceecd..bec06ec4a40ea 100644 --- a/pandas/core/accessor.py +++ b/pandas/core/accessor.py @@ -10,6 +10,7 @@ import functools from typing import ( TYPE_CHECKING, + Any, final, ) import warnings @@ -20,12 +21,12 @@ if TYPE_CHECKING: from collections.abc import Callable + from pandas.core.frame import DataFrame from pandas._typing import TypeT from pandas import Index from pandas.core.generic import NDFrame - from importlib_metadata import entry_points @@ -401,18 +402,18 @@ def register_index_accessor(name: str) -> Callable[[TypeT], TypeT]: class DataFrameAccessorLoader: """Loader class for registering DataFrame accessors via entry points.""" - ENTRY_POINT_GROUP = "pandas_dataframe_accessor" + ENTRY_POINT_GROUP: str = "pandas_dataframe_accessor" @classmethod - def load(cls): + def load(cls) -> None: """loads and registers accessors defined by 'pandas_dataframe_accessor'.""" eps = entry_points(group=cls.ENTRY_POINT_GROUP) for ep in eps: - name = ep.name + name: str = ep.name - def make_property(ep): - def accessor(self): + def make_property(ep) -> Callable[[DataFrame], Any]: + def accessor(self) -> Any: cls_ = ep.load() return cls_(self) From f2a036e136365b9e7eadef184bd3f5002ab6a584 Mon Sep 17 00:00:00 2001 From: Pedro Date: Tue, 27 May 2025 17:53:49 +0100 Subject: [PATCH 03/17] fix typo in importlib.metadata --- pandas/core/accessor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/core/accessor.py b/pandas/core/accessor.py index bec06ec4a40ea..033bb284c64e7 100644 --- a/pandas/core/accessor.py +++ b/pandas/core/accessor.py @@ -27,7 +27,7 @@ from pandas import Index from pandas.core.generic import NDFrame -from importlib_metadata import entry_points +from importlib.metadata import entry_points class DirNamesMixin: @@ -412,7 +412,7 @@ def load(cls) -> None: for ep in eps: name: str = ep.name - def make_property(ep) -> Callable[[DataFrame], Any]: + def make_property(ep): def accessor(self) -> Any: cls_ = ep.load() return cls_(self) From fcfa1556d3ea887f244bcaf60d00c2108f4b388c Mon Sep 17 00:00:00 2001 From: Pedro Date: Tue, 27 May 2025 23:40:21 +0100 Subject: [PATCH 04/17] trying to fix the pipeline errors Co-authored-by: Afonso Antunes --- pandas/__init__.py | 1 + pandas/core/accessor.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/pandas/__init__.py b/pandas/__init__.py index cad3134e9120c..6f1c6e1e7cc56 100644 --- a/pandas/__init__.py +++ b/pandas/__init__.py @@ -350,3 +350,4 @@ from pandas.core.accessor import DataFrameAccessorLoader DataFrameAccessorLoader.load() +del DataFrameAccessorLoader diff --git a/pandas/core/accessor.py b/pandas/core/accessor.py index 033bb284c64e7..bf12040ac4d3a 100644 --- a/pandas/core/accessor.py +++ b/pandas/core/accessor.py @@ -21,7 +21,6 @@ if TYPE_CHECKING: from collections.abc import Callable - from pandas.core.frame import DataFrame from pandas._typing import TypeT from pandas import Index From 678b2dc8fc8772734e67a2f7d04bc8390ddfdf58 Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Tue, 3 Jun 2025 15:56:06 +0100 Subject: [PATCH 05/17] feat: Warning for duplicated packages - Added tests - created doc .rst file Co-authored-by: Afonso Antunes --- doc/source/user_guide/entry_points.rst | 1 + pandas/core/accessor.py | 13 ++ pandas/tests/test_plugis_entrypoint_loader.py | 179 ++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 doc/source/user_guide/entry_points.rst diff --git a/doc/source/user_guide/entry_points.rst b/doc/source/user_guide/entry_points.rst new file mode 100644 index 0000000000000..30404ce4c5463 --- /dev/null +++ b/doc/source/user_guide/entry_points.rst @@ -0,0 +1 @@ +TODO \ No newline at end of file diff --git a/pandas/core/accessor.py b/pandas/core/accessor.py index bf12040ac4d3a..ba600926c3841 100644 --- a/pandas/core/accessor.py +++ b/pandas/core/accessor.py @@ -407,10 +407,23 @@ class DataFrameAccessorLoader: def load(cls) -> None: """loads and registers accessors defined by 'pandas_dataframe_accessor'.""" eps = entry_points(group=cls.ENTRY_POINT_GROUP) + names: set[str] = set() for ep in eps: name: str = ep.name + if name in names: # Verifies duplicated package names + warnings.warn( + f"Warning: you have two packages with the same name: '{name}'. " + "Uninstall the package you don't want to use " + "in order to remove this warning.\n", + UserWarning, + stacklevel=2, + ) + + else: + names.add(name) + def make_property(ep): def accessor(self) -> Any: cls_ = ep.load() diff --git a/pandas/tests/test_plugis_entrypoint_loader.py b/pandas/tests/test_plugis_entrypoint_loader.py index 182c76e95d45f..17f59c9e9d752 100644 --- a/pandas/tests/test_plugis_entrypoint_loader.py +++ b/pandas/tests/test_plugis_entrypoint_loader.py @@ -1,4 +1,5 @@ import pandas as pd +from pandas._testing._warnings import assert_produces_warning from pandas.core.accessor import DataFrameAccessorLoader @@ -33,3 +34,181 @@ def mock_entry_points(*, group): df = pd.DataFrame({"a": [1, 2, 3]}) assert hasattr(df, "test_accessor") assert df.test_accessor.test_method() == "success" + + +def test_duplicate_accessor_names(monkeypatch): + # GH29076 + # Create plugin + class MockEntryPoint1: + name = "duplicate_accessor" + + def load(self): + class Accessor1: + def __init__(self, df): + self._df = df + + def which(self): + return "Accessor1" + + return Accessor1 + + # Create plugin + class MockEntryPoint2: + name = "duplicate_accessor" + + def load(self): + class Accessor2: + def __init__(self, df): + self._df = df + + def which(self): + return "Accessor2" + + return Accessor2 + + def mock_entry_points(*, group): + if group == DataFrameAccessorLoader.ENTRY_POINT_GROUP: + return [MockEntryPoint1(), MockEntryPoint2()] + return [] + + monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) + + # Check that the UserWarning is raised + with assert_produces_warning(UserWarning, match="duplicate_accessor") as record: + DataFrameAccessorLoader.load() + + messages = [str(w.message) for w in record] + assert any("two packages with the same name" in msg for msg in messages) + + df = pd.DataFrame({"x": [1, 2, 3]}) + assert hasattr(df, "duplicate_accessor") + assert df.duplicate_accessor.which() in {"Accessor1", "Accessor2"} + + +def test_unique_accessor_names(monkeypatch): + # GH29076 + # Create plugin + class MockEntryPoint1: + name = "accessor1" + + def load(self): + class Accessor1: + def __init__(self, df): + self._df = df + + def which(self): + return "Accessor1" + + return Accessor1 + + # Create plugin + class MockEntryPoint2: + name = "accessor2" + + def load(self): + class Accessor2: + def __init__(self, df): + self._df = df + + def which(self): + return "Accessor2" + + return Accessor2 + + def mock_entry_points(*, group): + if group == DataFrameAccessorLoader.ENTRY_POINT_GROUP: + return [MockEntryPoint1(), MockEntryPoint2()] + return [] + + monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) + + # Check that no UserWarning is raised + with assert_produces_warning(None, check_stacklevel=False): + DataFrameAccessorLoader.load() + + df = pd.DataFrame({"x": [1, 2, 3]}) + assert hasattr(df, "accessor1"), "Accessor1 not registered" + assert hasattr(df, "accessor2"), "Accessor2 not registered" + assert df.accessor1.which() == "Accessor1", "Accessor1 method incorrect" + assert df.accessor2.which() == "Accessor2", "Accessor2 method incorrect" + + +def test_duplicate_and_unique_accessor_names(monkeypatch): + # GH29076 + # Create plugin + class MockEntryPoint1: + name = "duplicate_accessor" + + def load(self): + class Accessor1: + def __init__(self, df): + self._df = df + + def which(self): + return "Accessor1" + + return Accessor1 + + # Create plugin + class MockEntryPoint2: + name = "duplicate_accessor" + + def load(self): + class Accessor2: + def __init__(self, df): + self._df = df + + def which(self): + return "Accessor2" + + return Accessor2 + + # Create plugin + class MockEntryPoint3: + name = "unique_accessor" + + def load(self): + class Accessor3: + def __init__(self, df): + self._df = df + + def which(self): + return "Accessor3" + + return Accessor3 + + def mock_entry_points(*, group): + if group == DataFrameAccessorLoader.ENTRY_POINT_GROUP: + return [MockEntryPoint1(), MockEntryPoint2(), MockEntryPoint3()] + return [] + + monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) + + # Capture warnings + with assert_produces_warning(UserWarning, match="duplicate_accessor") as record: + DataFrameAccessorLoader.load() + + messages = [str(w.message) for w in record] + + # Filter warnings for the specific message about duplicate packages + duplicate_package_warnings = [ + msg + for msg in messages + if "you have two packages with the same name: 'duplicate_accessor'" in msg + ] + + # Assert one warning about duplicate packages + assert len(duplicate_package_warnings) == 1, ( + f"Expected exactly one warning about duplicate packages, " + f"got {len(duplicate_package_warnings)}: {duplicate_package_warnings}" + ) + + df = pd.DataFrame({"x": [1, 2, 3]}) + assert hasattr(df, "duplicate_accessor"), "duplicate_accessor not registered" + + assert hasattr(df, "unique_accessor"), "unique_accessor not registered" + + assert df.duplicate_accessor.which() in {"Accessor1", "Accessor2"}, ( + "duplicate_accessor method incorrect" + ) + assert df.unique_accessor.which() == "Accessor3", "unique_accessor method incorrect" From 2793f91c172c114693ad9d29f829c7853e19c63f Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Wed, 4 Jun 2025 10:23:56 +0100 Subject: [PATCH 06/17] Compliance with pre-commit - Added 1 test for no packages Co-authored-by: Afonso Antunes --- doc/source/user_guide/entry_points.rst | 2 +- pandas/tests/test_plugis_entrypoint_loader.py | 21 +++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/doc/source/user_guide/entry_points.rst b/doc/source/user_guide/entry_points.rst index 30404ce4c5463..1333ed77b7e1e 100644 --- a/doc/source/user_guide/entry_points.rst +++ b/doc/source/user_guide/entry_points.rst @@ -1 +1 @@ -TODO \ No newline at end of file +TODO diff --git a/pandas/tests/test_plugis_entrypoint_loader.py b/pandas/tests/test_plugis_entrypoint_loader.py index 17f59c9e9d752..d292257f6cad8 100644 --- a/pandas/tests/test_plugis_entrypoint_loader.py +++ b/pandas/tests/test_plugis_entrypoint_loader.py @@ -1,8 +1,21 @@ import pandas as pd -from pandas._testing._warnings import assert_produces_warning +import pandas._testing as tm from pandas.core.accessor import DataFrameAccessorLoader +def test_no_accessors(monkeypatch): + # GH29076 + + # Mock entry_points + def mock_entry_points(*, group): + return [] + + # Patch entry_points in the correct module + monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) + + DataFrameAccessorLoader.load() + + def test_load_dataframe_accessors(monkeypatch): # GH29076 # Mocked EntryPoint to simulate a plugin @@ -74,7 +87,7 @@ def mock_entry_points(*, group): monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) # Check that the UserWarning is raised - with assert_produces_warning(UserWarning, match="duplicate_accessor") as record: + with tm.assert_produces_warning(UserWarning, match="duplicate_accessor") as record: DataFrameAccessorLoader.load() messages = [str(w.message) for w in record] @@ -123,7 +136,7 @@ def mock_entry_points(*, group): monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) # Check that no UserWarning is raised - with assert_produces_warning(None, check_stacklevel=False): + with tm.assert_produces_warning(None, check_stacklevel=False): DataFrameAccessorLoader.load() df = pd.DataFrame({"x": [1, 2, 3]}) @@ -185,7 +198,7 @@ def mock_entry_points(*, group): monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) # Capture warnings - with assert_produces_warning(UserWarning, match="duplicate_accessor") as record: + with tm.assert_produces_warning(UserWarning, match="duplicate_accessor") as record: DataFrameAccessorLoader.load() messages = [str(w.message) for w in record] From 01a1bfc1a0b147a1aabe7ef10cfdac74a5700c21 Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Wed, 4 Jun 2025 14:23:17 +0100 Subject: [PATCH 07/17] Improve var and class names --- pandas/__init__.py | 6 ++--- pandas/core/accessor.py | 26 +++++++++---------- pandas/tests/test_plugis_entrypoint_loader.py | 22 ++++++++-------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/pandas/__init__.py b/pandas/__init__.py index 6f1c6e1e7cc56..0b5ca4c65c387 100644 --- a/pandas/__init__.py +++ b/pandas/__init__.py @@ -347,7 +347,7 @@ "wide_to_long", ] -from pandas.core.accessor import DataFrameAccessorLoader +from .core.accessor import AccessorEntryPointLoader -DataFrameAccessorLoader.load() -del DataFrameAccessorLoader +AccessorEntryPointLoader.load() +del AccessorEntryPointLoader diff --git a/pandas/core/accessor.py b/pandas/core/accessor.py index ba600926c3841..f50c7fb0e1423 100644 --- a/pandas/core/accessor.py +++ b/pandas/core/accessor.py @@ -398,23 +398,23 @@ def register_index_accessor(name: str) -> Callable[[TypeT], TypeT]: return _register_accessor(name, Index) -class DataFrameAccessorLoader: - """Loader class for registering DataFrame accessors via entry points.""" +class AccessorEntryPointLoader: # is this a good name for the class? + """Loader class for registering accessors via entry points.""" - ENTRY_POINT_GROUP: str = "pandas_dataframe_accessor" + ENTRY_POINT_GROUP: str = "pandas_accessor" @classmethod def load(cls) -> None: - """loads and registers accessors defined by 'pandas_dataframe_accessor'.""" - eps = entry_points(group=cls.ENTRY_POINT_GROUP) - names: set[str] = set() + """loads and registers accessors defined by 'pandas_accessor'.""" + packages = entry_points(group=cls.ENTRY_POINT_GROUP) + unique_packages_names: set[str] = set() - for ep in eps: - name: str = ep.name - - if name in names: # Verifies duplicated package names + for package in packages: + # Verifies duplicated package names + if package.name in unique_packages_names: warnings.warn( - f"Warning: you have two packages with the same name: '{name}'. " + "Warning: you have two packages with the same name:" + f" '{package.name}'\n" "Uninstall the package you don't want to use " "in order to remove this warning.\n", UserWarning, @@ -422,7 +422,7 @@ def load(cls) -> None: ) else: - names.add(name) + unique_packages_names.add(package.name) def make_property(ep): def accessor(self) -> Any: @@ -431,4 +431,4 @@ def accessor(self) -> Any: return accessor - register_dataframe_accessor(name)(make_property(ep)) + register_dataframe_accessor(package.name)(make_property(package)) diff --git a/pandas/tests/test_plugis_entrypoint_loader.py b/pandas/tests/test_plugis_entrypoint_loader.py index d292257f6cad8..d4d51fab017b7 100644 --- a/pandas/tests/test_plugis_entrypoint_loader.py +++ b/pandas/tests/test_plugis_entrypoint_loader.py @@ -1,6 +1,6 @@ import pandas as pd import pandas._testing as tm -from pandas.core.accessor import DataFrameAccessorLoader +from pandas.core.accessor import AccessorEntryPointLoader def test_no_accessors(monkeypatch): @@ -13,7 +13,7 @@ def mock_entry_points(*, group): # Patch entry_points in the correct module monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) - DataFrameAccessorLoader.load() + AccessorEntryPointLoader.load() def test_load_dataframe_accessors(monkeypatch): @@ -34,14 +34,14 @@ def test_method(self): # Mock entry_points def mock_entry_points(*, group): - if group == DataFrameAccessorLoader.ENTRY_POINT_GROUP: + if group == AccessorEntryPointLoader.ENTRY_POINT_GROUP: return [MockEntryPoint()] return [] # Patch entry_points in the correct module monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) - DataFrameAccessorLoader.load() + AccessorEntryPointLoader.load() # Create DataFrame and verify that the accessor was registered df = pd.DataFrame({"a": [1, 2, 3]}) @@ -80,7 +80,7 @@ def which(self): return Accessor2 def mock_entry_points(*, group): - if group == DataFrameAccessorLoader.ENTRY_POINT_GROUP: + if group == AccessorEntryPointLoader.ENTRY_POINT_GROUP: return [MockEntryPoint1(), MockEntryPoint2()] return [] @@ -88,10 +88,10 @@ def mock_entry_points(*, group): # Check that the UserWarning is raised with tm.assert_produces_warning(UserWarning, match="duplicate_accessor") as record: - DataFrameAccessorLoader.load() + AccessorEntryPointLoader.load() messages = [str(w.message) for w in record] - assert any("two packages with the same name" in msg for msg in messages) + assert any("you have two packages with the same name:" in msg for msg in messages) df = pd.DataFrame({"x": [1, 2, 3]}) assert hasattr(df, "duplicate_accessor") @@ -129,7 +129,7 @@ def which(self): return Accessor2 def mock_entry_points(*, group): - if group == DataFrameAccessorLoader.ENTRY_POINT_GROUP: + if group == AccessorEntryPointLoader.ENTRY_POINT_GROUP: return [MockEntryPoint1(), MockEntryPoint2()] return [] @@ -137,7 +137,7 @@ def mock_entry_points(*, group): # Check that no UserWarning is raised with tm.assert_produces_warning(None, check_stacklevel=False): - DataFrameAccessorLoader.load() + AccessorEntryPointLoader.load() df = pd.DataFrame({"x": [1, 2, 3]}) assert hasattr(df, "accessor1"), "Accessor1 not registered" @@ -191,7 +191,7 @@ def which(self): return Accessor3 def mock_entry_points(*, group): - if group == DataFrameAccessorLoader.ENTRY_POINT_GROUP: + if group == AccessorEntryPointLoader.ENTRY_POINT_GROUP: return [MockEntryPoint1(), MockEntryPoint2(), MockEntryPoint3()] return [] @@ -199,7 +199,7 @@ def mock_entry_points(*, group): # Capture warnings with tm.assert_produces_warning(UserWarning, match="duplicate_accessor") as record: - DataFrameAccessorLoader.load() + AccessorEntryPointLoader.load() messages = [str(w.message) for w in record] From 2807683b009a8357aa74b720d4060b2ae03ae033 Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Wed, 4 Jun 2025 14:52:05 +0100 Subject: [PATCH 08/17] Improve warning message --- pandas/core/accessor.py | 31 ++++++++++++------- pandas/tests/test_plugis_entrypoint_loader.py | 12 ++++--- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/pandas/core/accessor.py b/pandas/core/accessor.py index f50c7fb0e1423..d859e273527c1 100644 --- a/pandas/core/accessor.py +++ b/pandas/core/accessor.py @@ -406,23 +406,29 @@ class AccessorEntryPointLoader: # is this a good name for the class? @classmethod def load(cls) -> None: """loads and registers accessors defined by 'pandas_accessor'.""" - packages = entry_points(group=cls.ENTRY_POINT_GROUP) - unique_packages_names: set[str] = set() - - for package in packages: - # Verifies duplicated package names - if package.name in unique_packages_names: + accessors = entry_points(group=cls.ENTRY_POINT_GROUP) + unique_accessors_names: set[str] = set() + + for accessor in accessors: + # Verifies duplicated accessor names + if accessor.name in unique_accessors_names: + try: + pkg_name: str = accessor.dist.name + except Exception: + pkg_name = "unknown" warnings.warn( - "Warning: you have two packages with the same name:" - f" '{package.name}'\n" - "Uninstall the package you don't want to use " - "in order to remove this warning.\n", + "Warning: you have two accessors with the same name:" + f" '{accessor.name}' has already been registered" + f" by the package '{pkg_name}'. So the '{accessor.name}' " + f"provided by the package '{pkg_name}' is not " + f"being used. Uninstall the package you don't want" + "to use if you want to get rid of this warning.\n", UserWarning, stacklevel=2, ) else: - unique_packages_names.add(package.name) + unique_accessors_names.add(accessor.name) def make_property(ep): def accessor(self) -> Any: @@ -431,4 +437,5 @@ def accessor(self) -> Any: return accessor - register_dataframe_accessor(package.name)(make_property(package)) + # _register_accessor() + register_dataframe_accessor(accessor.name)(make_property(accessor)) diff --git a/pandas/tests/test_plugis_entrypoint_loader.py b/pandas/tests/test_plugis_entrypoint_loader.py index d4d51fab017b7..5d8b7d284b0ef 100644 --- a/pandas/tests/test_plugis_entrypoint_loader.py +++ b/pandas/tests/test_plugis_entrypoint_loader.py @@ -2,6 +2,8 @@ import pandas._testing as tm from pandas.core.accessor import AccessorEntryPointLoader +# TODO: test for pkg names + def test_no_accessors(monkeypatch): # GH29076 @@ -91,7 +93,7 @@ def mock_entry_points(*, group): AccessorEntryPointLoader.load() messages = [str(w.message) for w in record] - assert any("you have two packages with the same name:" in msg for msg in messages) + assert any("you have two accessors with the same name:" in msg for msg in messages) df = pd.DataFrame({"x": [1, 2, 3]}) assert hasattr(df, "duplicate_accessor") @@ -203,16 +205,16 @@ def mock_entry_points(*, group): messages = [str(w.message) for w in record] - # Filter warnings for the specific message about duplicate packages + # Filter warnings for the specific message about duplicate accessors duplicate_package_warnings = [ msg for msg in messages - if "you have two packages with the same name: 'duplicate_accessor'" in msg + if "you have two accessors with the same name: 'duplicate_accessor'" in msg ] - # Assert one warning about duplicate packages + # Assert one warning about duplicate accessors assert len(duplicate_package_warnings) == 1, ( - f"Expected exactly one warning about duplicate packages, " + f"Expected exactly one warning about duplicate accessors, " f"got {len(duplicate_package_warnings)}: {duplicate_package_warnings}" ) From d2066a1081488b3c15d0d93342c89473d69416a8 Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Wed, 4 Jun 2025 15:23:08 +0100 Subject: [PATCH 09/17] rmv entry_points.rst --- doc/source/user_guide/entry_points.rst | 1 - 1 file changed, 1 deletion(-) delete mode 100644 doc/source/user_guide/entry_points.rst diff --git a/doc/source/user_guide/entry_points.rst b/doc/source/user_guide/entry_points.rst deleted file mode 100644 index 1333ed77b7e1e..0000000000000 --- a/doc/source/user_guide/entry_points.rst +++ /dev/null @@ -1 +0,0 @@ -TODO From 03885c564dbb16a0eded0c8db6c708e45f240ead Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Thu, 5 Jun 2025 12:38:53 +0100 Subject: [PATCH 10/17] Refactor: class to fn && Fix: warning - Added DocStrs - Fixed small typo in test file name Co-authored-by: Afonso Antunes --- pandas/__init__.py | 6 +- pandas/core/accessor.py | 109 ++++++++++++------ ...r.py => test_plugins_entrypoint_loader.py} | 22 ++-- 3 files changed, 88 insertions(+), 49 deletions(-) rename pandas/tests/{test_plugis_entrypoint_loader.py => test_plugins_entrypoint_loader.py} (92%) diff --git a/pandas/__init__.py b/pandas/__init__.py index 0b5ca4c65c387..ecb69548445d5 100644 --- a/pandas/__init__.py +++ b/pandas/__init__.py @@ -347,7 +347,7 @@ "wide_to_long", ] -from .core.accessor import AccessorEntryPointLoader +from .core.accessor import accessor_entry_point_loader -AccessorEntryPointLoader.load() -del AccessorEntryPointLoader +accessor_entry_point_loader() +del accessor_entry_point_loader diff --git a/pandas/core/accessor.py b/pandas/core/accessor.py index d859e273527c1..39bc9b0af3bd5 100644 --- a/pandas/core/accessor.py +++ b/pandas/core/accessor.py @@ -26,7 +26,10 @@ from pandas import Index from pandas.core.generic import NDFrame -from importlib.metadata import entry_points +from importlib.metadata import ( + EntryPoints, + entry_points, +) class DirNamesMixin: @@ -398,44 +401,78 @@ def register_index_accessor(name: str) -> Callable[[TypeT], TypeT]: return _register_accessor(name, Index) -class AccessorEntryPointLoader: # is this a good name for the class? - """Loader class for registering accessors via entry points.""" +def accessor_entry_point_loader() -> None: + """ + Load and register pandas accessors declared via entry points. - ENTRY_POINT_GROUP: str = "pandas_accessor" + This function scans the 'pandas.accessor' entry point group for accessors + registered by third-party packages. Each entry point is expected to follow + the format: - @classmethod - def load(cls) -> None: - """loads and registers accessors defined by 'pandas_accessor'.""" - accessors = entry_points(group=cls.ENTRY_POINT_GROUP) - unique_accessors_names: set[str] = set() - - for accessor in accessors: - # Verifies duplicated accessor names - if accessor.name in unique_accessors_names: - try: - pkg_name: str = accessor.dist.name - except Exception: - pkg_name = "unknown" - warnings.warn( - "Warning: you have two accessors with the same name:" - f" '{accessor.name}' has already been registered" - f" by the package '{pkg_name}'. So the '{accessor.name}' " - f"provided by the package '{pkg_name}' is not " - f"being used. Uninstall the package you don't want" - "to use if you want to get rid of this warning.\n", - UserWarning, - stacklevel=2, - ) + TODO - else: - unique_accessors_names.add(accessor.name) + For example: + + TODO + TODO + TODO + + + For each valid entry point: + - The accessor class is dynamically imported and registered using + the appropriate registration decorator function + (e.g. register_dataframe_accessor). + - If two packages declare the same accessor name, a warning is issued, + and only the first one is used. + + Notes + ----- + - This function is only intended to be called at pandas startup. + + Raises + ------ + UserWarning + If two accessors share the same name, the second one is ignored. + + Examples + -------- + >>> df.myplugin.do_something() # Assuming such accessor was registered + """ + + ENTRY_POINT_GROUP: str = "pandas.accessor" + + accessors: EntryPoints = entry_points(group=ENTRY_POINT_GROUP) + accessor_package_dict: dict[str, str] = {} + + for new_accessor in accessors: + try: + new_pkg_name: str = new_accessor.dist.name + except AttributeError: + new_pkg_name: str = "Unknown" + + # Verifies duplicated accessor names + if new_accessor.name in accessor_package_dict: + loaded_pkg_name: str = accessor_package_dict.get(new_accessor.name) + + warnings.warn( + "Warning: you have two accessors with the same name:" + f" '{new_accessor.name}' has already been registered" + f" by the package '{new_pkg_name}'. So the " + f"'{new_accessor.name}' provided by the package " + f"'{loaded_pkg_name}' is not being used. " + "Uninstall the package you don't want" + "to use if you want to get rid of this warning.\n", + UserWarning, + stacklevel=2, + ) + + accessor_package_dict.update({new_accessor.name: new_pkg_name}) - def make_property(ep): - def accessor(self) -> Any: - cls_ = ep.load() - return cls_(self) + def make_accessor(ep): + def accessor(self) -> Any: + cls_ = ep.load() + return cls_(self) - return accessor + return accessor - # _register_accessor() - register_dataframe_accessor(accessor.name)(make_property(accessor)) + register_dataframe_accessor(new_accessor.name)(make_accessor(new_accessor)) diff --git a/pandas/tests/test_plugis_entrypoint_loader.py b/pandas/tests/test_plugins_entrypoint_loader.py similarity index 92% rename from pandas/tests/test_plugis_entrypoint_loader.py rename to pandas/tests/test_plugins_entrypoint_loader.py index 5d8b7d284b0ef..155957879d9b4 100644 --- a/pandas/tests/test_plugis_entrypoint_loader.py +++ b/pandas/tests/test_plugins_entrypoint_loader.py @@ -1,9 +1,11 @@ import pandas as pd import pandas._testing as tm -from pandas.core.accessor import AccessorEntryPointLoader +from pandas.core.accessor import accessor_entry_point_loader # TODO: test for pkg names +PANDAS_ENTRY_POINT_GROUP: str = "pandas.accessor" + def test_no_accessors(monkeypatch): # GH29076 @@ -15,7 +17,7 @@ def mock_entry_points(*, group): # Patch entry_points in the correct module monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) - AccessorEntryPointLoader.load() + accessor_entry_point_loader() def test_load_dataframe_accessors(monkeypatch): @@ -36,14 +38,14 @@ def test_method(self): # Mock entry_points def mock_entry_points(*, group): - if group == AccessorEntryPointLoader.ENTRY_POINT_GROUP: + if group == PANDAS_ENTRY_POINT_GROUP: return [MockEntryPoint()] return [] # Patch entry_points in the correct module monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) - AccessorEntryPointLoader.load() + accessor_entry_point_loader() # Create DataFrame and verify that the accessor was registered df = pd.DataFrame({"a": [1, 2, 3]}) @@ -82,7 +84,7 @@ def which(self): return Accessor2 def mock_entry_points(*, group): - if group == AccessorEntryPointLoader.ENTRY_POINT_GROUP: + if group == PANDAS_ENTRY_POINT_GROUP: return [MockEntryPoint1(), MockEntryPoint2()] return [] @@ -90,7 +92,7 @@ def mock_entry_points(*, group): # Check that the UserWarning is raised with tm.assert_produces_warning(UserWarning, match="duplicate_accessor") as record: - AccessorEntryPointLoader.load() + accessor_entry_point_loader() messages = [str(w.message) for w in record] assert any("you have two accessors with the same name:" in msg for msg in messages) @@ -131,7 +133,7 @@ def which(self): return Accessor2 def mock_entry_points(*, group): - if group == AccessorEntryPointLoader.ENTRY_POINT_GROUP: + if group == PANDAS_ENTRY_POINT_GROUP: return [MockEntryPoint1(), MockEntryPoint2()] return [] @@ -139,7 +141,7 @@ def mock_entry_points(*, group): # Check that no UserWarning is raised with tm.assert_produces_warning(None, check_stacklevel=False): - AccessorEntryPointLoader.load() + accessor_entry_point_loader() df = pd.DataFrame({"x": [1, 2, 3]}) assert hasattr(df, "accessor1"), "Accessor1 not registered" @@ -193,7 +195,7 @@ def which(self): return Accessor3 def mock_entry_points(*, group): - if group == AccessorEntryPointLoader.ENTRY_POINT_GROUP: + if group == PANDAS_ENTRY_POINT_GROUP: return [MockEntryPoint1(), MockEntryPoint2(), MockEntryPoint3()] return [] @@ -201,7 +203,7 @@ def mock_entry_points(*, group): # Capture warnings with tm.assert_produces_warning(UserWarning, match="duplicate_accessor") as record: - AccessorEntryPointLoader.load() + accessor_entry_point_loader() messages = [str(w.message) for w in record] From 001c05a8e3c7237fbda5d344f1fb75dfd49018c7 Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Thu, 5 Jun 2025 20:07:26 +0100 Subject: [PATCH 11/17] Compliance with Code Checks && fixed tests --- pandas/core/accessor.py | 13 +- .../tests/test_plugins_entrypoint_loader.py | 237 ++++++++---------- 2 files changed, 115 insertions(+), 135 deletions(-) diff --git a/pandas/core/accessor.py b/pandas/core/accessor.py index 39bc9b0af3bd5..b0a78e49d3a3f 100644 --- a/pandas/core/accessor.py +++ b/pandas/core/accessor.py @@ -436,7 +436,7 @@ def accessor_entry_point_loader() -> None: Examples -------- - >>> df.myplugin.do_something() # Assuming such accessor was registered + df.myplugin.do_something() # Assuming such accessor was registered """ ENTRY_POINT_GROUP: str = "pandas.accessor" @@ -445,15 +445,20 @@ def accessor_entry_point_loader() -> None: accessor_package_dict: dict[str, str] = {} for new_accessor in accessors: - try: - new_pkg_name: str = new_accessor.dist.name - except AttributeError: + if new_accessor.dist is not None: + # Try to get new_accessor.dist.name, + # if that's not possible: new_pkg_name = 'Unknown' + new_pkg_name: str = getattr(new_accessor.dist, "name", "Unknown") + else: new_pkg_name: str = "Unknown" # Verifies duplicated accessor names if new_accessor.name in accessor_package_dict: loaded_pkg_name: str = accessor_package_dict.get(new_accessor.name) + if loaded_pkg_name is None: + loaded_pkg_name = "Unknown" + warnings.warn( "Warning: you have two accessors with the same name:" f" '{new_accessor.name}' has already been registered" diff --git a/pandas/tests/test_plugins_entrypoint_loader.py b/pandas/tests/test_plugins_entrypoint_loader.py index 155957879d9b4..4adcf6ed5a511 100644 --- a/pandas/tests/test_plugins_entrypoint_loader.py +++ b/pandas/tests/test_plugins_entrypoint_loader.py @@ -2,47 +2,73 @@ import pandas._testing as tm from pandas.core.accessor import accessor_entry_point_loader -# TODO: test for pkg names - PANDAS_ENTRY_POINT_GROUP: str = "pandas.accessor" -def test_no_accessors(monkeypatch): - # GH29076 - - # Mock entry_points - def mock_entry_points(*, group): - return [] +def create_mock_entry_points(entry_points): + """ + Auxiliary function to create mock entry points for testing accessor loading. - # Patch entry_points in the correct module - monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) + Parameters: + ----------- + entry_points : list of tuple + List of (name, accessor_class, dist_name) where: + - name: str, the name of the accessor + - accessor_class: class, the accessor class to be returned by load() + - dist_name: str, the name of the distribution (package) - accessor_entry_point_loader() + Returns: + -------- + function + A mock_entry_points function that returns the mocked entry points. + """ + class MockDistribution: + def __init__(self, name): + self.name = name -def test_load_dataframe_accessors(monkeypatch): - # GH29076 - # Mocked EntryPoint to simulate a plugin class MockEntryPoint: - name = "test_accessor" + def __init__(self, name, accessor_class, dist_name): + self.name = name + self._accessor_class = accessor_class + self.dist = MockDistribution(dist_name) def load(self): - class TestAccessor: - def __init__(self, df): - self._df = df + return self._accessor_class - def test_method(self): - return "success" - - return TestAccessor + # Create list of MockEntryPoint instances + mock_eps = [ + MockEntryPoint(name, accessor_class, dist_name) + for name, accessor_class, dist_name in entry_points + ] - # Mock entry_points def mock_entry_points(*, group): if group == PANDAS_ENTRY_POINT_GROUP: - return [MockEntryPoint()] + return mock_eps return [] - # Patch entry_points in the correct module + return mock_entry_points + + +def test_no_accessors(monkeypatch): + # No entry points + mock_entry_points = create_mock_entry_points([]) + monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) + + accessor_entry_point_loader() + + +def test_load_dataframe_accessors(monkeypatch): + class TestAccessor: + def __init__(self, df): + self._df = df + + def test_method(self): + return "success" + + mock_entry_points = create_mock_entry_points( + [("test_accessor", TestAccessor, "TestPackage")] + ) monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) accessor_entry_point_loader() @@ -54,40 +80,26 @@ def mock_entry_points(*, group): def test_duplicate_accessor_names(monkeypatch): - # GH29076 - # Create plugin - class MockEntryPoint1: - name = "duplicate_accessor" + class Accessor1: + def __init__(self, df): + self._df = df - def load(self): - class Accessor1: - def __init__(self, df): - self._df = df + def which(self): + return "Accessor1" - def which(self): - return "Accessor1" + class Accessor2: + def __init__(self, df): + self._df = df - return Accessor1 - - # Create plugin - class MockEntryPoint2: - name = "duplicate_accessor" - - def load(self): - class Accessor2: - def __init__(self, df): - self._df = df - - def which(self): - return "Accessor2" - - return Accessor2 - - def mock_entry_points(*, group): - if group == PANDAS_ENTRY_POINT_GROUP: - return [MockEntryPoint1(), MockEntryPoint2()] - return [] + def which(self): + return "Accessor2" + mock_entry_points = create_mock_entry_points( + [ + ("duplicate_accessor", Accessor1, "Package1"), + ("duplicate_accessor", Accessor2, "Package2"), + ] + ) monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) # Check that the UserWarning is raised @@ -99,44 +111,27 @@ def mock_entry_points(*, group): df = pd.DataFrame({"x": [1, 2, 3]}) assert hasattr(df, "duplicate_accessor") - assert df.duplicate_accessor.which() in {"Accessor1", "Accessor2"} + assert df.duplicate_accessor.which() == "Accessor2" # Last registered accessor def test_unique_accessor_names(monkeypatch): - # GH29076 - # Create plugin - class MockEntryPoint1: - name = "accessor1" - - def load(self): - class Accessor1: - def __init__(self, df): - self._df = df - - def which(self): - return "Accessor1" + class Accessor1: + def __init__(self, df): + self._df = df - return Accessor1 + def which(self): + return "Accessor1" - # Create plugin - class MockEntryPoint2: - name = "accessor2" - - def load(self): - class Accessor2: - def __init__(self, df): - self._df = df + class Accessor2: + def __init__(self, df): + self._df = df - def which(self): - return "Accessor2" - - return Accessor2 - - def mock_entry_points(*, group): - if group == PANDAS_ENTRY_POINT_GROUP: - return [MockEntryPoint1(), MockEntryPoint2()] - return [] + def which(self): + return "Accessor2" + mock_entry_points = create_mock_entry_points( + [("accessor1", Accessor1, "Package1"), ("accessor2", Accessor2, "Package2")] + ) monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) # Check that no UserWarning is raised @@ -146,59 +141,40 @@ def mock_entry_points(*, group): df = pd.DataFrame({"x": [1, 2, 3]}) assert hasattr(df, "accessor1"), "Accessor1 not registered" assert hasattr(df, "accessor2"), "Accessor2 not registered" + assert df.accessor1.which() == "Accessor1", "Accessor1 method incorrect" assert df.accessor2.which() == "Accessor2", "Accessor2 method incorrect" def test_duplicate_and_unique_accessor_names(monkeypatch): - # GH29076 - # Create plugin - class MockEntryPoint1: - name = "duplicate_accessor" + class Accessor1: + def __init__(self, df): + self._df = df - def load(self): - class Accessor1: - def __init__(self, df): - self._df = df + def which(self): + return "Accessor1" - def which(self): - return "Accessor1" + class Accessor2: + def __init__(self, df): + self._df = df - return Accessor1 + def which(self): + return "Accessor2" - # Create plugin - class MockEntryPoint2: - name = "duplicate_accessor" + class Accessor3: + def __init__(self, df): + self._df = df - def load(self): - class Accessor2: - def __init__(self, df): - self._df = df - - def which(self): - return "Accessor2" - - return Accessor2 - - # Create plugin - class MockEntryPoint3: - name = "unique_accessor" - - def load(self): - class Accessor3: - def __init__(self, df): - self._df = df - - def which(self): - return "Accessor3" - - return Accessor3 - - def mock_entry_points(*, group): - if group == PANDAS_ENTRY_POINT_GROUP: - return [MockEntryPoint1(), MockEntryPoint2(), MockEntryPoint3()] - return [] + def which(self): + return "Accessor3" + mock_entry_points = create_mock_entry_points( + [ + ("duplicate_accessor", Accessor1, "Package1"), + ("duplicate_accessor", Accessor2, "Package2"), + ("unique_accessor", Accessor3, "Package3"), + ] + ) monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) # Capture warnings @@ -222,10 +198,9 @@ def mock_entry_points(*, group): df = pd.DataFrame({"x": [1, 2, 3]}) assert hasattr(df, "duplicate_accessor"), "duplicate_accessor not registered" - assert hasattr(df, "unique_accessor"), "unique_accessor not registered" - assert df.duplicate_accessor.which() in {"Accessor1", "Accessor2"}, ( - "duplicate_accessor method incorrect" + assert df.duplicate_accessor.which() == "Accessor2", ( + "duplicate_accessor should use Accessor2" ) assert df.unique_accessor.which() == "Accessor3", "unique_accessor method incorrect" From 101393418f82e557a76266640f8ddc9d4e699a0a Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Sun, 15 Jun 2025 12:54:16 +0100 Subject: [PATCH 12/17] Feat: compatible with multiple pandas objects accessors (df, series, idx) - Improved docstrs - Improved tests - Improved whatsnew entry Co-authored-by: Afonso Antunes --- doc/source/whatsnew/v3.0.0.rst | 2 +- pandas/core/accessor.py | 133 +++++---- .../tests/test_plugins_entrypoint_loader.py | 280 ++++++++++++++++-- 3 files changed, 340 insertions(+), 75 deletions(-) diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index b5c22a949d9f5..1fb543de46652 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -83,7 +83,7 @@ Other enhancements - Improved deprecation message for offset aliases (:issue:`60820`) - Multiplying two :class:`DateOffset` objects will now raise a ``TypeError`` instead of a ``RecursionError`` (:issue:`59442`) - Restore support for reading Stata 104-format and enable reading 103-format dta files (:issue:`58554`) -- Support :class:`DataFrame` plugin accessor via entry points (:issue:`29076`) +- Support :class:`DataFrame`, :class:`Series` and :class:`Index` plugin accessors via entry points (:issue:`29076`) - Support passing a :class:`Iterable[Hashable]` input to :meth:`DataFrame.drop_duplicates` (:issue:`59237`) - Support reading Stata 102-format (Stata 1) dta files (:issue:`58978`) - Support reading Stata 110-format (Stata 7) dta files (:issue:`47176`) diff --git a/pandas/core/accessor.py b/pandas/core/accessor.py index b0a78e49d3a3f..4534dbb861661 100644 --- a/pandas/core/accessor.py +++ b/pandas/core/accessor.py @@ -10,7 +10,6 @@ import functools from typing import ( TYPE_CHECKING, - Any, final, ) import warnings @@ -405,17 +404,31 @@ def accessor_entry_point_loader() -> None: """ Load and register pandas accessors declared via entry points. - This function scans the 'pandas.accessor' entry point group for accessors - registered by third-party packages. Each entry point is expected to follow - the format: + This function scans the 'pandas..accessor' entry point group for + accessors registered by third-party packages. Each entry point is expected + to follow the format: - TODO + # setup.py + entry_points={ + 'pandas.DataFrame.accessor': [ = :, ... ], + 'pandas.Series.accessor': [ = :, ... ], + 'pandas.Index.accessor': [ = :, ... ], + } - For example: + OR for pyproject.toml file: - TODO - TODO - TODO + # pyproject.toml + [project.entry-points."pandas.DataFrame.accessor"] + = ":" + + [project.entry-points."pandas.Series.accessor"] + = ":" + + [project.entry-points."pandas.Index.accessor"] + = ":" + + For more information about entrypoints: + https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/#plugin-entry-points For each valid entry point: @@ -428,6 +441,7 @@ def accessor_entry_point_loader() -> None: Notes ----- - This function is only intended to be called at pandas startup. + - For more information about accessors read their documentation. Raises ------ @@ -436,48 +450,67 @@ def accessor_entry_point_loader() -> None: Examples -------- - df.myplugin.do_something() # Assuming such accessor was registered - """ - - ENTRY_POINT_GROUP: str = "pandas.accessor" - - accessors: EntryPoints = entry_points(group=ENTRY_POINT_GROUP) - accessor_package_dict: dict[str, str] = {} - - for new_accessor in accessors: - if new_accessor.dist is not None: - # Try to get new_accessor.dist.name, - # if that's not possible: new_pkg_name = 'Unknown' - new_pkg_name: str = getattr(new_accessor.dist, "name", "Unknown") - else: - new_pkg_name: str = "Unknown" + # setup.py + entry_points={ + 'pandas.DataFrame.accessor': [ + 'myplugin = myplugin.accessor:MyPluginAccessor', + ], + } + # END setup.py - # Verifies duplicated accessor names - if new_accessor.name in accessor_package_dict: - loaded_pkg_name: str = accessor_package_dict.get(new_accessor.name) + - That entrypoint would allow the following code: - if loaded_pkg_name is None: - loaded_pkg_name = "Unknown" + import pandas as pd - warnings.warn( - "Warning: you have two accessors with the same name:" - f" '{new_accessor.name}' has already been registered" - f" by the package '{new_pkg_name}'. So the " - f"'{new_accessor.name}' provided by the package " - f"'{loaded_pkg_name}' is not being used. " - "Uninstall the package you don't want" - "to use if you want to get rid of this warning.\n", - UserWarning, - stacklevel=2, - ) - - accessor_package_dict.update({new_accessor.name: new_pkg_name}) - - def make_accessor(ep): - def accessor(self) -> Any: - cls_ = ep.load() - return cls_(self) - - return accessor + df = pd.DataFrame({"A": [1, 2, 3]}) + df.myplugin.do_something() # Calls MyPluginAccessor.do_something() + """ - register_dataframe_accessor(new_accessor.name)(make_accessor(new_accessor)) + PD_OBJECTS_ENTRYPOINTS: list[str] = [ + "pandas.DataFrame.accessor", + "pandas.Series.accessor", + "pandas.Index.accessor", + ] + + ACCESSOR_REGISTRY_FUNCTIONS: dict[str, Callable] = { + "pandas.DataFrame.accessor": register_dataframe_accessor, + "pandas.Series.accessor": register_series_accessor, + "pandas.Index.accessor": register_index_accessor, + } + + for pd_obj_entrypoint in PD_OBJECTS_ENTRYPOINTS: + accessors: EntryPoints = entry_points(group=pd_obj_entrypoint) + accessor_package_dict: dict[str, str] = {} + + for new_accessor in accessors: + dist = getattr(new_accessor, "dist", None) + new_pkg_name = getattr(dist, "name", "Unknown") if dist else "Unknown" + + # Verifies duplicated accessor names + if new_accessor.name in accessor_package_dict: + loaded_pkg_name: str = accessor_package_dict.get(new_accessor.name) + + if loaded_pkg_name is None: + loaded_pkg_name: str = "Unknown" + + warnings.warn( + "Warning: you have two accessors with the same name:" + f" '{new_accessor.name}' has already been registered" + f" by the package '{new_pkg_name}'. The " + f"'{new_accessor.name}' provided by the package " + f"'{loaded_pkg_name}' is not being used. " + "Uninstall the package you don't want" + "to use if you want to get rid of this warning.\n", + UserWarning, + stacklevel=2, + ) + + accessor_package_dict.update({new_accessor.name: new_pkg_name}) + + def make_accessor(ep): + return lambda self, ep=ep: ep.load()(self) + + register_fn = ACCESSOR_REGISTRY_FUNCTIONS.get(pd_obj_entrypoint) + + if register_fn is not None: + register_fn(new_accessor.name)(make_accessor(new_accessor)) diff --git a/pandas/tests/test_plugins_entrypoint_loader.py b/pandas/tests/test_plugins_entrypoint_loader.py index 4adcf6ed5a511..97f22c8ce8a23 100644 --- a/pandas/tests/test_plugins_entrypoint_loader.py +++ b/pandas/tests/test_plugins_entrypoint_loader.py @@ -1,11 +1,17 @@ +from typing import Any + import pandas as pd import pandas._testing as tm from pandas.core.accessor import accessor_entry_point_loader -PANDAS_ENTRY_POINT_GROUP: str = "pandas.accessor" +PD_OBJECTS_ENTRYPOINTS = [ + "pandas.DataFrame.accessor", + "pandas.Series.accessor", + "pandas.Index.accessor", +] -def create_mock_entry_points(entry_points): +def create_mock_entry_points(entry_points: dict[str, list[tuple[str, Any, str]]]): """ Auxiliary function to create mock entry points for testing accessor loading. @@ -36,23 +42,28 @@ def __init__(self, name, accessor_class, dist_name): def load(self): return self._accessor_class - # Create list of MockEntryPoint instances - mock_eps = [ - MockEntryPoint(name, accessor_class, dist_name) - for name, accessor_class, dist_name in entry_points - ] + # Create a dictionary of MockEntryPoint instances + group_map = {g: [] for g in PD_OBJECTS_ENTRYPOINTS} + + for ep_group, ep_properties in entry_points.items(): + for name, accessor_class, dist_name in ep_properties: + group_map[ep_group].append(MockEntryPoint(name, accessor_class, dist_name)) def mock_entry_points(*, group): - if group == PANDAS_ENTRY_POINT_GROUP: - return mock_eps - return [] + return group_map.get(group, []) return mock_entry_points def test_no_accessors(monkeypatch): # No entry points - mock_entry_points = create_mock_entry_points([]) + mock_entry_points = create_mock_entry_points( + { + "pandas.DataFrame.accessor": [], + "pandas.Series.accessor": [], + "pandas.Index.accessor": [], + } + ) monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) accessor_entry_point_loader() @@ -67,7 +78,15 @@ def test_method(self): return "success" mock_entry_points = create_mock_entry_points( - [("test_accessor", TestAccessor, "TestPackage")] + { + "pandas.DataFrame.accessor": [ + ( + "test_accessor", + TestAccessor, + "TestPackage", + ) + ], + } ) monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) @@ -79,7 +98,53 @@ def test_method(self): assert df.test_accessor.test_method() == "success" -def test_duplicate_accessor_names(monkeypatch): +def test_load_series_accessors(monkeypatch): + class TestAccessor: + def __init__(self, ser): + self._ser = ser + + def test_method(self): + return "success" + + mock_entry_points = create_mock_entry_points( + { + "pandas.Series.accessor": [("test_accessor", TestAccessor, "TestPackage")], + } + ) + monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) + + accessor_entry_point_loader() + + # Create Series and verify that the accessor was registered + s = pd.Series([1, 2, 3]) + assert hasattr(s, "test_accessor") + assert s.test_accessor.test_method() == "success" + + +def test_load_index_accessors(monkeypatch): + class TestAccessor: + def __init__(self, idx): + self._idx = idx + + def test_method(self): + return "success" + + mock_entry_points = create_mock_entry_points( + { + "pandas.Index.accessor": [("test_accessor", TestAccessor, "TestPackage")], + } + ) + monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) + + accessor_entry_point_loader() + + # Create Index and verify that the accessor was registered + idx = pd.Index([1, 2, 3]) + assert hasattr(idx, "test_accessor") + assert idx.test_accessor.test_method() == "success" + + +def test_duplicate_dataframe_accessor_names(monkeypatch): class Accessor1: def __init__(self, df): self._df = df @@ -95,10 +160,12 @@ def which(self): return "Accessor2" mock_entry_points = create_mock_entry_points( - [ - ("duplicate_accessor", Accessor1, "Package1"), - ("duplicate_accessor", Accessor2, "Package2"), - ] + { + "pandas.DataFrame.accessor": [ + ("duplicate_accessor", Accessor1, "TestPackage1"), + ("duplicate_accessor", Accessor2, "TestPackage2"), + ] + } ) monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) @@ -114,6 +181,108 @@ def which(self): assert df.duplicate_accessor.which() == "Accessor2" # Last registered accessor +def test_duplicate_series_accessor_names(monkeypatch): + class Accessor1: + def __init__(self, series): + self._series = series + + def which(self): + return "Accessor1" + + class Accessor2: + def __init__(self, series): + self._series = series + + def which(self): + return "Accessor2" + + mock_entry_points = create_mock_entry_points( + { + "pandas.Series.accessor": [ + ("duplicate_accessor", Accessor1, "TestPackage1"), + ("duplicate_accessor", Accessor2, "TestPackage2"), + ] + } + ) + monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) + + # Check that the UserWarning is raised + with tm.assert_produces_warning(UserWarning, match="duplicate_accessor") as record: + accessor_entry_point_loader() + + messages = [str(w.message) for w in record] + assert any("you have two accessors with the same name:" in msg for msg in messages) + + s = pd.Series([1, 2, 3]) + assert hasattr(s, "duplicate_accessor") + assert s.duplicate_accessor.which() == "Accessor2" # Last registered accessor + + +def test_duplicate_index_accessor_names(monkeypatch): + class Accessor1: + def __init__(self, idx): + self._idx = idx + + def which(self): + return "Accessor1" + + class Accessor2: + def __init__(self, idx): + self._idx = idx + + def which(self): + return "Accessor2" + + mock_entry_points = create_mock_entry_points( + { + "pandas.Index.accessor": [ + ("duplicate_accessor", Accessor1, "TestPackage1"), + ("duplicate_accessor", Accessor2, "TestPackage2"), + ] + } + ) + monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) + + # Check that the UserWarning is raised + with tm.assert_produces_warning(UserWarning, match="duplicate_accessor") as record: + accessor_entry_point_loader() + + messages = [str(w.message) for w in record] + assert any("you have two accessors with the same name:" in msg for msg in messages) + + idx = pd.Index([1, 2, 3]) + assert hasattr(idx, "duplicate_accessor") + assert idx.duplicate_accessor.which() == "Accessor2" # Last registered accessor + + +def test_wrong_obj_accessor(monkeypatch): + class Accessor1: + def __init__(self, obj): + self._obj = obj + + def which(self): + return "Accessor1" + + mock_entry_points = create_mock_entry_points( + { + "pandas.DataFrame.accessor": [ + ("accessor", Accessor1, "TestPackage1"), + ] + } + ) + monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) + + accessor_entry_point_loader() + + # Check that the accessor is not registered for Index + idx = pd.Index([1, 2, 3]) + assert not hasattr(idx, "accessor"), "Accessor should not be registered for Index" + + df = pd.DataFrame({"x": [1, 2, 3]}) + assert hasattr(df, "accessor") + assert df.accessor.which() == "Accessor1" + + def test_unique_accessor_names(monkeypatch): class Accessor1: def __init__(self, df): @@ -130,7 +299,12 @@ def which(self): return "Accessor2" mock_entry_points = create_mock_entry_points( - [("accessor1", Accessor1, "Package1"), ("accessor2", Accessor2, "Package2")] + { + "pandas.DataFrame.accessor": [ + ("accessor1", Accessor1, "Package1"), + ("accessor2", Accessor2, "Package2"), + ] + } ) monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) @@ -141,7 +315,7 @@ def which(self): df = pd.DataFrame({"x": [1, 2, 3]}) assert hasattr(df, "accessor1"), "Accessor1 not registered" assert hasattr(df, "accessor2"), "Accessor2 not registered" - + assert df.accessor1.which() == "Accessor1", "Accessor1 method incorrect" assert df.accessor2.which() == "Accessor2", "Accessor2 method incorrect" @@ -169,11 +343,13 @@ def which(self): return "Accessor3" mock_entry_points = create_mock_entry_points( - [ - ("duplicate_accessor", Accessor1, "Package1"), - ("duplicate_accessor", Accessor2, "Package2"), - ("unique_accessor", Accessor3, "Package3"), - ] + { + "pandas.DataFrame.accessor": [ + ("duplicate_accessor", Accessor1, "Package1"), + ("duplicate_accessor", Accessor2, "Package2"), + ("unique_accessor", Accessor3, "Package3"), + ] + } ) monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) @@ -204,3 +380,59 @@ def which(self): "duplicate_accessor should use Accessor2" ) assert df.unique_accessor.which() == "Accessor3", "unique_accessor method incorrect" + + +def test_duplicate_names_different_pandas_objs(monkeypatch): + class Accessor1: + def __init__(self, obj): + self._obj = obj + + def which(self): + return "Accessor1" + + class Accessor2: + def __init__(self, obj): + self._obj = obj + + def which(self): + return "Accessor2" + + mock_entry_points = create_mock_entry_points( + { + "pandas.DataFrame.accessor": [ + ("acc1", Accessor1, "Package1"), + ("acc2", Accessor2, "Package2"), + ], + "pandas.Series.accessor": [ + ("acc1", Accessor1, "Package1"), + ("acc2", Accessor2, "Package2"), + ], + "pandas.Index.accessor": [ + ("acc1", Accessor1, "Package1"), + ("acc2", Accessor2, "Package2"), + ], + } + ) + monkeypatch.setattr("pandas.core.accessor.entry_points", mock_entry_points) + + # Check that no UserWarning is raised + with tm.assert_produces_warning(None, check_stacklevel=False): + accessor_entry_point_loader() + + df = pd.DataFrame({"x": [1, 2, 3]}) + assert hasattr(df, "acc1") + assert df.acc1.which() == "Accessor1" + assert hasattr(df, "acc2") + assert df.acc2.which() == "Accessor2" + + s = pd.Series([1, 2, 3]) + assert hasattr(s, "acc1") + assert s.acc1.which() == "Accessor1" + assert hasattr(s, "acc2") + assert s.acc2.which() == "Accessor2" + + idx = pd.Index([1, 2, 3]) + assert hasattr(idx, "acc1") + assert idx.acc1.which() == "Accessor1" + assert hasattr(idx, "acc2") + assert idx.acc2.which() == "Accessor2" From 95b7a5ceb6292036878c59253996d99f443a19e1 Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Sun, 15 Jun 2025 15:03:13 +0100 Subject: [PATCH 13/17] small improvements + fix pipeline --- pandas/core/accessor.py | 17 ++++++----------- pandas/tests/test_plugins_entrypoint_loader.py | 2 +- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/pandas/core/accessor.py b/pandas/core/accessor.py index 4534dbb861661..3894aedab23c4 100644 --- a/pandas/core/accessor.py +++ b/pandas/core/accessor.py @@ -466,19 +466,15 @@ def accessor_entry_point_loader() -> None: df.myplugin.do_something() # Calls MyPluginAccessor.do_something() """ - PD_OBJECTS_ENTRYPOINTS: list[str] = [ - "pandas.DataFrame.accessor", - "pandas.Series.accessor", - "pandas.Index.accessor", - ] - ACCESSOR_REGISTRY_FUNCTIONS: dict[str, Callable] = { "pandas.DataFrame.accessor": register_dataframe_accessor, "pandas.Series.accessor": register_series_accessor, "pandas.Index.accessor": register_index_accessor, } - for pd_obj_entrypoint in PD_OBJECTS_ENTRYPOINTS: + pd_objects_entrypoints: list[str] = ACCESSOR_REGISTRY_FUNCTIONS.keys() + + for pd_obj_entrypoint in pd_objects_entrypoints: accessors: EntryPoints = entry_points(group=pd_obj_entrypoint) accessor_package_dict: dict[str, str] = {} @@ -488,10 +484,9 @@ def accessor_entry_point_loader() -> None: # Verifies duplicated accessor names if new_accessor.name in accessor_package_dict: - loaded_pkg_name: str = accessor_package_dict.get(new_accessor.name) - - if loaded_pkg_name is None: - loaded_pkg_name: str = "Unknown" + loaded_pkg_name: str = accessor_package_dict.get( + new_accessor.name, "Unknown" + ) warnings.warn( "Warning: you have two accessors with the same name:" diff --git a/pandas/tests/test_plugins_entrypoint_loader.py b/pandas/tests/test_plugins_entrypoint_loader.py index 97f22c8ce8a23..5699cf773b20b 100644 --- a/pandas/tests/test_plugins_entrypoint_loader.py +++ b/pandas/tests/test_plugins_entrypoint_loader.py @@ -43,7 +43,7 @@ def load(self): return self._accessor_class # Create a dictionary of MockEntryPoint instances - group_map = {g: [] for g in PD_OBJECTS_ENTRYPOINTS} + group_map: dict[str, list[MockEntryPoint]] = {g: [] for g in PD_OBJECTS_ENTRYPOINTS} for ep_group, ep_properties in entry_points.items(): for name, accessor_class, dist_name in ep_properties: From 0eb5bc1d4a8a8725dd7fa1ea82a6c274ea767502 Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Sat, 21 Jun 2025 21:24:07 +0100 Subject: [PATCH 14/17] typing done right --- pandas/core/accessor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pandas/core/accessor.py b/pandas/core/accessor.py index 3894aedab23c4..544425e7210e1 100644 --- a/pandas/core/accessor.py +++ b/pandas/core/accessor.py @@ -472,9 +472,9 @@ def accessor_entry_point_loader() -> None: "pandas.Index.accessor": register_index_accessor, } - pd_objects_entrypoints: list[str] = ACCESSOR_REGISTRY_FUNCTIONS.keys() + PD_OBJ_ENTRYPOINTS: tuple[str] = tuple(ACCESSOR_REGISTRY_FUNCTIONS.keys()) - for pd_obj_entrypoint in pd_objects_entrypoints: + for pd_obj_entrypoint in PD_OBJ_ENTRYPOINTS: accessors: EntryPoints = entry_points(group=pd_obj_entrypoint) accessor_package_dict: dict[str, str] = {} From 1c5ac850e5959d7ab7f87760bcd1d35f16f667ba Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Sat, 21 Jun 2025 23:27:19 +0100 Subject: [PATCH 15/17] Added doc at extending.rst - Improved accessor.py docstr Co-authored-by: Afonso Antunes --- doc/source/development/extending.rst | 56 ++++++++++++++++++++++++++++ pandas/core/accessor.py | 22 +++++++---- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/doc/source/development/extending.rst b/doc/source/development/extending.rst index e67829b8805eb..e772a86435c48 100644 --- a/doc/source/development/extending.rst +++ b/doc/source/development/extending.rst @@ -69,6 +69,62 @@ For a ``Series`` accessor, you should validate the ``dtype`` if the accessor applies only to certain dtypes. +Registering accessors via entry points +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can create a custom accessor for a pandas object and expose it via Python's +entry point system. Once installed using pip, the accessor can be automatically +discovered and registered by pandas at runtime, without requiring manual import. + +To register the entry point for your accessor, follow the format shown below: + +.. code-block:: python + + # setup.py + entry_points={ + 'pandas.DataFrame.accessor': [ ' = :', ... ], + 'pandas.Series.accessor': [ ' = :', ... ], + 'pandas.Index.accessor': [ ' = :', ... ], + } + +Alternatively, if you are using a ``pyproject.toml``-based build: + +.. code-block:: toml + + # pyproject.toml + [project.entry-points."pandas.DataFrame.accessor"] + = ":" + + [project.entry-points."pandas.Series.accessor"] + = ":" + + [project.entry-points."pandas.Index.accessor"] + = ":" + + +Assuming the accessor class ``GeoAccessor`` is defined in the module +``geoPlugin.geo_accessor``, and using the accessor name ``geo`` as in the +example above: + +.. code-block:: python + + # setup.py + entry_points={ + 'pandas.DataFrame.accessor': [ 'geo = geoPlugin.geo_accessor:GeoAccessor' ], + } + +Or, for a ``pyproject.toml``-based build: + +.. code-block:: toml + + # pyproject.toml + [project.entry-points."pandas.DataFrame.accessor"] + geo = "geoPlugin.geo_accessor:GeoAccessor" + + +For background on Python's Entry Point system and Plugins: +https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/#plugin-entry-points + .. _extending.extension-types: Extension types diff --git a/pandas/core/accessor.py b/pandas/core/accessor.py index 544425e7210e1..8e4dcb6f8afd2 100644 --- a/pandas/core/accessor.py +++ b/pandas/core/accessor.py @@ -405,8 +405,10 @@ def accessor_entry_point_loader() -> None: Load and register pandas accessors declared via entry points. This function scans the 'pandas..accessor' entry point group for - accessors registered by third-party packages. Each entry point is expected - to follow the format: + accessors registered by third-party packages. These accessors extend + core pandas objects (`DataFrame`, `Series`, `Index`). + + Each entry point is expected to follow the format: # setup.py entry_points={ @@ -415,7 +417,7 @@ def accessor_entry_point_loader() -> None: 'pandas.Index.accessor': [ = :, ... ], } - OR for pyproject.toml file: + OR using pyproject.toml file: # pyproject.toml [project.entry-points."pandas.DataFrame.accessor"] @@ -427,9 +429,6 @@ def accessor_entry_point_loader() -> None: [project.entry-points."pandas.Index.accessor"] = ":" - For more information about entrypoints: - https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/#plugin-entry-points - For each valid entry point: - The accessor class is dynamically imported and registered using @@ -441,7 +440,16 @@ def accessor_entry_point_loader() -> None: Notes ----- - This function is only intended to be called at pandas startup. - - For more information about accessors read their documentation. + - For more information about accessors, refer to: + - Pandas documentation on extending accessors: + https://pandas.pydata.org/docs/development/extending.html#registering-custom-accessors + - Series accessor API reference: + https://pandas.pydata.org/docs/reference/series.html#accessors + - Note: DataFrame and Index accessors (e.g., `.sparse`, `.str`) use the same + mechanism but are not listed in separate reference pages as of now. + + - For background on Python plugin entry points: + https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/#plugin-entry-points Raises ------ From 63e48825fb8f89772e1e8eb193dc3a80403d98af Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Sun, 22 Jun 2025 00:13:25 +0100 Subject: [PATCH 16/17] small pipeline fixes --- doc/source/development/extending.rst | 2 +- pandas/core/accessor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/source/development/extending.rst b/doc/source/development/extending.rst index e772a86435c48..ed6e320693663 100644 --- a/doc/source/development/extending.rst +++ b/doc/source/development/extending.rst @@ -89,7 +89,7 @@ To register the entry point for your accessor, follow the format shown below: Alternatively, if you are using a ``pyproject.toml``-based build: -.. code-block:: toml +.. code-block:: none # pyproject.toml [project.entry-points."pandas.DataFrame.accessor"] diff --git a/pandas/core/accessor.py b/pandas/core/accessor.py index 8e4dcb6f8afd2..6ba724ccd96cd 100644 --- a/pandas/core/accessor.py +++ b/pandas/core/accessor.py @@ -480,7 +480,7 @@ def accessor_entry_point_loader() -> None: "pandas.Index.accessor": register_index_accessor, } - PD_OBJ_ENTRYPOINTS: tuple[str] = tuple(ACCESSOR_REGISTRY_FUNCTIONS.keys()) + PD_OBJ_ENTRYPOINTS: tuple[str, ...] = tuple(ACCESSOR_REGISTRY_FUNCTIONS.keys()) for pd_obj_entrypoint in PD_OBJ_ENTRYPOINTS: accessors: EntryPoints = entry_points(group=pd_obj_entrypoint) From 55a60f39f791cf69b924cac208470f14ff7ce9ca Mon Sep 17 00:00:00 2001 From: Pedro Marques Date: Fri, 27 Jun 2025 20:36:13 +0100 Subject: [PATCH 17/17] rmv toml references in rst - small changes --- doc/source/development/extending.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/doc/source/development/extending.rst b/doc/source/development/extending.rst index ed6e320693663..e97d434c814f3 100644 --- a/doc/source/development/extending.rst +++ b/doc/source/development/extending.rst @@ -94,12 +94,15 @@ Alternatively, if you are using a ``pyproject.toml``-based build: # pyproject.toml [project.entry-points."pandas.DataFrame.accessor"] = ":" + ... [project.entry-points."pandas.Series.accessor"] = ":" + ... [project.entry-points."pandas.Index.accessor"] = ":" + ... Assuming the accessor class ``GeoAccessor`` is defined in the module @@ -115,7 +118,7 @@ example above: Or, for a ``pyproject.toml``-based build: -.. code-block:: toml +.. code-block:: none # pyproject.toml [project.entry-points."pandas.DataFrame.accessor"]