From c0d011534684f7ad934dff93d240628356e32b1c Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Sat, 29 Apr 2023 17:38:41 +0100 Subject: [PATCH 1/8] POC of PDEP-9 (I/O plugins) --- pandas/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pandas/__init__.py b/pandas/__init__.py index 6ddfbadcf91d1..ff5a4bb10d783 100644 --- a/pandas/__init__.py +++ b/pandas/__init__.py @@ -182,6 +182,17 @@ del get_versions, v +# load I/O plugins +from importlib.metadata import entry_points +for dataframe_io_entry_point in entry_points().get("dataframe.io", []): + io_plugin = dataframe_io_entry_point.load() + if hasattr(io_plugin, "read"): + globals()[f"read_{dataframe_io_entry_point.name}"] = io_plugin.read + if hasattr(io_plugin, "write"): + setattr(DataFrame, f"to_{dataframe_io_entry_point.name}", io_plugin.write) +del entry_points, dataframe_io_entry_point, io_plugin + + # module level doc-string __doc__ = """ pandas - a powerful data analysis and manipulation library for Python From 91da43a5d015d7896d6e82a8eb9315a2d63c8db4 Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Mon, 22 May 2023 10:58:33 +0300 Subject: [PATCH 2/8] Implementing the POC with a pyarrow fallback as the connector protocol --- pandas/__init__.py | 14 +---- pandas/io/_plugin_loader.py | 119 ++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 pandas/io/_plugin_loader.py diff --git a/pandas/__init__.py b/pandas/__init__.py index ff5a4bb10d783..1c2519e2f42c3 100644 --- a/pandas/__init__.py +++ b/pandas/__init__.py @@ -171,6 +171,8 @@ from pandas.io.json._normalize import json_normalize +from pandas.io._plugin_loader import load_io_plugins + from pandas.util._tester import test # use the closest tagged version if possible @@ -181,18 +183,6 @@ __git_version__ = v.get("full-revisionid") del get_versions, v - -# load I/O plugins -from importlib.metadata import entry_points -for dataframe_io_entry_point in entry_points().get("dataframe.io", []): - io_plugin = dataframe_io_entry_point.load() - if hasattr(io_plugin, "read"): - globals()[f"read_{dataframe_io_entry_point.name}"] = io_plugin.read - if hasattr(io_plugin, "write"): - setattr(DataFrame, f"to_{dataframe_io_entry_point.name}", io_plugin.write) -del entry_points, dataframe_io_entry_point, io_plugin - - # module level doc-string __doc__ = """ pandas - a powerful data analysis and manipulation library for Python diff --git a/pandas/io/_plugin_loader.py b/pandas/io/_plugin_loader.py new file mode 100644 index 0000000000000..b4708ef5f1a41 --- /dev/null +++ b/pandas/io/_plugin_loader.py @@ -0,0 +1,119 @@ +""" +Load I/O plugins from third-party libraries into the pandas namespace. + +Third-party libraries defining I/O plugins register an entrypoint in +the `dataframe.io` group. For example: + +``` +[project.entry-points."dataframe.io"] +repr = "pandas_repr:ReprDataFrameIO" +``` + +The class `ReprDataFrameIO` will implement readers and writers in at +least one of different exchange formats supported by the protocol. +For now a pandas DataFrame or a PyArrow table, in the future probably +nanoarrow, or a Python wrapper to the Arrow Rust implementations. +For example: + +```python +class FancyFormatDataFrameIO: + @staticmethod + def pandas_reader(self, fname): + with open(fname) as f: + return eval(f.read()) + + def pandas_writer(self, fname, mode='w'): + with open(fname, mode) as f: + f.write(repr(self)) +``` + +If the I/O plugin implements a reader or writer supported by pandas, +pandas will create a wrapper function or method to call the reader or +writer from the pandas standard I/O namespaces. For example, for the +entrypoint above with name `repr` and methods `pandas_reader` and +`pandas_writer` pandas will create the next functions and methods: + +- `pandas.read_repr(...)` +- `pandas.Series.to_repr(...)` +- `pandas.DataFrame.to_repr(...)` + +The reader wrappers validates that the returned object is a pandas +DataFrame when the exchange format is `pandas`, and will convert the +other supported objects (e.g. a PyArrow Table) to a pandas DataFrame, +so the result of `pandas.read_repr` is a pandas DataFrame, as the +user would expect. + +If more than one reader or writer with the same name is loaded, pandas +raises an exception. +""" +import functools +from importlib.metadata import entry_points + +import pandas as pd + + +supported_exchange_formats = ["pandas"] + +try: + import pyarrow as pa +except ImportError: + pa = None +else: + supported_exchange_formats.append("pyarrow") + + +def _create_reader_function(io_plugin, exchange_format): + original_reader = getattr(io_plugin, f"{exchange_format}_reader") + # TODO: Create this function dynamically so the resulting signature contains + # the original parameters and not `*args` and `**kwargs` + @functools.wraps(original_reader) + def reader_wrapper(*args, **kwargs): + result = original_reader(*args, **kwargs) + if exchange_format == "pyarrow": + result = result.to_pandas() + + # validate output type + if isinstance(result, list): + assert all((isinstance(item, pd.DataFrame) for item in result)) + elif isinstance(result, dict): + assert all(((isinstance(k, str) and isinstance(v, pd.DataFrame)) + for k, v in result.items())) + elif not isinstance(result, pd.DataFrame): + raise AssertionError("Returned object is not a DataFrame") + return result + + return reader_wrapper + + +def _create_series_writer_function(format_name): + def series_writer_wrapper(self, *args, **kwargs): + dataframe_writer = getattr(self.to_frame(), f"to_{format_name}") + dataframe_writer(*args, **kwargs) + + return series_writer_wrapper + + +def load_io_plugins(): + for dataframe_io_entry_point in entry_points().get("dataframe.io", []): + format_name = dataframe_io_entry_point.name + io_plugin = dataframe_io_entry_point.load() + + for exchange_format in supported_exchange_formats: + if hasattr(io_plugin, f"{exchange_format}_reader"): + if hasattr(pd, f"read_{format_name}"): + raise RuntimeError( + "More than one installed library provides the " + "`read_{format_name}` reader. Please uninstall one of " + "the I/O plugins to be able to load the pandas I/O plugins.") + setattr(pd, + f"read_{format_name}", + _create_reader_function(io_plugin, + exchange_format)) + + if hasattr(io_plugin, f"{exchange_format}_writer"): + setattr(pd.DataFrame, + f"to_{format_name}", + getattr(io_plugin, f"{exchange_format}_writer")) + setattr(pd.Series, + f"to_{format_name}", + _create_series_writer_function(format_name)) From 67a69a9a1d8f44be1817f63b05933d9eb40c5edf Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Mon, 22 May 2023 11:06:45 +0300 Subject: [PATCH 3/8] Black+isort --- pandas/io/_plugin_loader.py | 38 +++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/pandas/io/_plugin_loader.py b/pandas/io/_plugin_loader.py index b4708ef5f1a41..785ff29a47b6a 100644 --- a/pandas/io/_plugin_loader.py +++ b/pandas/io/_plugin_loader.py @@ -51,7 +51,6 @@ def pandas_writer(self, fname, mode='w'): import pandas as pd - supported_exchange_formats = ["pandas"] try: @@ -64,6 +63,7 @@ def pandas_writer(self, fname, mode='w'): def _create_reader_function(io_plugin, exchange_format): original_reader = getattr(io_plugin, f"{exchange_format}_reader") + # TODO: Create this function dynamically so the resulting signature contains # the original parameters and not `*args` and `**kwargs` @functools.wraps(original_reader) @@ -76,8 +76,12 @@ def reader_wrapper(*args, **kwargs): if isinstance(result, list): assert all((isinstance(item, pd.DataFrame) for item in result)) elif isinstance(result, dict): - assert all(((isinstance(k, str) and isinstance(v, pd.DataFrame)) - for k, v in result.items())) + assert all( + ( + (isinstance(k, str) and isinstance(v, pd.DataFrame)) + for k, v in result.items() + ) + ) elif not isinstance(result, pd.DataFrame): raise AssertionError("Returned object is not a DataFrame") return result @@ -104,16 +108,22 @@ def load_io_plugins(): raise RuntimeError( "More than one installed library provides the " "`read_{format_name}` reader. Please uninstall one of " - "the I/O plugins to be able to load the pandas I/O plugins.") - setattr(pd, - f"read_{format_name}", - _create_reader_function(io_plugin, - exchange_format)) + "the I/O plugins to be able to load the pandas I/O plugins." + ) + setattr( + pd, + f"read_{format_name}", + _create_reader_function(io_plugin, exchange_format), + ) if hasattr(io_plugin, f"{exchange_format}_writer"): - setattr(pd.DataFrame, - f"to_{format_name}", - getattr(io_plugin, f"{exchange_format}_writer")) - setattr(pd.Series, - f"to_{format_name}", - _create_series_writer_function(format_name)) + setattr( + pd.DataFrame, + f"to_{format_name}", + getattr(io_plugin, f"{exchange_format}_writer"), + ) + setattr( + pd.Series, + f"to_{format_name}", + _create_series_writer_function(format_name), + ) From 2439ed918a251ecee3fbd20eeef5a4d11390d070 Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Mon, 22 May 2023 11:13:50 +0300 Subject: [PATCH 4/8] Adding docstring and black --- pandas/io/_plugin_loader.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pandas/io/_plugin_loader.py b/pandas/io/_plugin_loader.py index 785ff29a47b6a..832f9f06f1591 100644 --- a/pandas/io/_plugin_loader.py +++ b/pandas/io/_plugin_loader.py @@ -62,6 +62,14 @@ def pandas_writer(self, fname, mode='w'): def _create_reader_function(io_plugin, exchange_format): + """ + Create and return a wrapper function for the original I/O reader. + + We can't directly call the original reader implemented in + the connector, since we want to make sure that the returned value + of `read_` is a pandas DataFrame, so we need to validate + it and possibly cast it. + """ original_reader = getattr(io_plugin, f"{exchange_format}_reader") # TODO: Create this function dynamically so the resulting signature contains @@ -86,10 +94,17 @@ def reader_wrapper(*args, **kwargs): raise AssertionError("Returned object is not a DataFrame") return result + # TODO `function.wraps` changes the name of the wrapped function to the + # original `pandas_reader`, change it to the function exposed in pandas. return reader_wrapper def _create_series_writer_function(format_name): + """ + When calling `Series.to_` we call the dataframe writer, so + we need to convert the Series to a one column dataframe. + """ + def series_writer_wrapper(self, *args, **kwargs): dataframe_writer = getattr(self.to_frame(), f"to_{format_name}") dataframe_writer(*args, **kwargs) @@ -98,6 +113,10 @@ def series_writer_wrapper(self, *args, **kwargs): def load_io_plugins(): + """ + Looks for entrypoints in the `dataframe.io` group and creates the + corresponding pandas I/O methods. + """ for dataframe_io_entry_point in entry_points().get("dataframe.io", []): format_name = dataframe_io_entry_point.name io_plugin = dataframe_io_entry_point.load() From 2b0e13f7bbfec1d54b062ca258091cd5c8fcaf3a Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Mon, 22 May 2023 11:15:51 +0300 Subject: [PATCH 5/8] Minor fixes to __init__.py --- pandas/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pandas/__init__.py b/pandas/__init__.py index 1c2519e2f42c3..79559fe752502 100644 --- a/pandas/__init__.py +++ b/pandas/__init__.py @@ -183,6 +183,7 @@ __git_version__ = v.get("full-revisionid") del get_versions, v + # module level doc-string __doc__ = """ pandas - a powerful data analysis and manipulation library for Python @@ -292,6 +293,7 @@ "isna", "isnull", "json_normalize", + "load_io_plugins", "lreshape", "melt", "merge", From 000ea21adfba4f5c7a91260a1c16bb1f8da9b048 Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Mon, 22 May 2023 11:33:39 +0300 Subject: [PATCH 6/8] raising if to_ method exists --- pandas/io/_plugin_loader.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pandas/io/_plugin_loader.py b/pandas/io/_plugin_loader.py index 832f9f06f1591..e96342d9687e2 100644 --- a/pandas/io/_plugin_loader.py +++ b/pandas/io/_plugin_loader.py @@ -127,7 +127,7 @@ def load_io_plugins(): raise RuntimeError( "More than one installed library provides the " "`read_{format_name}` reader. Please uninstall one of " - "the I/O plugins to be able to load the pandas I/O plugins." + "the I/O plugins providing connectors for this format." ) setattr( pd, @@ -136,6 +136,12 @@ def load_io_plugins(): ) if hasattr(io_plugin, f"{exchange_format}_writer"): + if hasattr(pd.DataFrame, f"to_{format_name}"): + raise RuntimeError( + "More than one installed library provides the " + "`to_{format_name}` reader. Please uninstall one of " + "the I/O plugins providing connectors for this format." + ) setattr( pd.DataFrame, f"to_{format_name}", From 59b0c3a96f10c6bc914ce63f1941ed148401240c Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Sat, 27 May 2023 15:22:00 +0400 Subject: [PATCH 7/8] Use the dataframe interchange protocol instead --- pandas/io/_plugin_loader.py | 134 ++++++++++++++++-------------------- 1 file changed, 60 insertions(+), 74 deletions(-) diff --git a/pandas/io/_plugin_loader.py b/pandas/io/_plugin_loader.py index e96342d9687e2..4e988db3fe910 100644 --- a/pandas/io/_plugin_loader.py +++ b/pandas/io/_plugin_loader.py @@ -9,89 +9,77 @@ repr = "pandas_repr:ReprDataFrameIO" ``` -The class `ReprDataFrameIO` will implement readers and writers in at -least one of different exchange formats supported by the protocol. -For now a pandas DataFrame or a PyArrow table, in the future probably -nanoarrow, or a Python wrapper to the Arrow Rust implementations. +The class `ReprDataFrameIO` will implement at least one of a reader +and a writer that supports the dataframe interchange protocol: + +https://data-apis.org/dataframe-protocol/latest/API.html + For example: ```python -class FancyFormatDataFrameIO: +class ReprDataFrameIO: @staticmethod - def pandas_reader(self, fname): + def reader(self, fname): with open(fname) as f: + # for simplicity this assumes eval will create a DataFrame object return eval(f.read()) - def pandas_writer(self, fname, mode='w'): + def writer(self, fname, mode='w'): with open(fname, mode) as f: f.write(repr(self)) ``` -If the I/O plugin implements a reader or writer supported by pandas, -pandas will create a wrapper function or method to call the reader or +pandas will create wrapper functions or methods to call the reader or writer from the pandas standard I/O namespaces. For example, for the -entrypoint above with name `repr` and methods `pandas_reader` and -`pandas_writer` pandas will create the next functions and methods: +entrypoint above with name `repr` and both methods `reader` and +`writer` implemented, pandas will create the next functions and methods: - `pandas.read_repr(...)` - `pandas.Series.to_repr(...)` - `pandas.DataFrame.to_repr(...)` -The reader wrappers validates that the returned object is a pandas -DataFrame when the exchange format is `pandas`, and will convert the -other supported objects (e.g. a PyArrow Table) to a pandas DataFrame, -so the result of `pandas.read_repr` is a pandas DataFrame, as the -user would expect. +The reader wrappers make sure that the returned object is a pandas +DataFrame, since the user always expects the return of `read_*()` +to be a pandas DataFrame, not matter what the connector returns. +In few cases, the return can be a list or dict of dataframes, which +is supported. If more than one reader or writer with the same name is loaded, pandas -raises an exception. +raises an exception. For example, if two connectors use the name +`arrow` pandas will raise when `load_io_plugins()` is called, since +only one `pandas.read_arrow` function can exist, and pandas should not +make an arbitrary decision on which to use. """ import functools from importlib.metadata import entry_points import pandas as pd -supported_exchange_formats = ["pandas"] - -try: - import pyarrow as pa -except ImportError: - pa = None -else: - supported_exchange_formats.append("pyarrow") - -def _create_reader_function(io_plugin, exchange_format): +def _create_reader_function(io_plugin): """ Create and return a wrapper function for the original I/O reader. We can't directly call the original reader implemented in - the connector, since we want to make sure that the returned value - of `read_` is a pandas DataFrame, so we need to validate - it and possibly cast it. + the connector, since the return of third-party connectors is not necessarily + a pandas DataFrame but any object supporting the dataframe interchange + protocol. We make sure here that `read_` returns a pandas DataFrame. """ - original_reader = getattr(io_plugin, f"{exchange_format}_reader") # TODO: Create this function dynamically so the resulting signature contains # the original parameters and not `*args` and `**kwargs` - @functools.wraps(original_reader) + @functools.wraps(io_plugin.reader) def reader_wrapper(*args, **kwargs): - result = original_reader(*args, **kwargs) - if exchange_format == "pyarrow": - result = result.to_pandas() + result = io_plugin.reader(*args, **kwargs) - # validate output type if isinstance(result, list): - assert all((isinstance(item, pd.DataFrame) for item in result)) + result = [pd.api.interchange.from_dataframe(df) for df in result] elif isinstance(result, dict): - assert all( - ( - (isinstance(k, str) and isinstance(v, pd.DataFrame)) - for k, v in result.items() - ) - ) - elif not isinstance(result, pd.DataFrame): - raise AssertionError("Returned object is not a DataFrame") + result = {k: pd.api.interchange.from_dataframe(df) + for k, df in result.items()} + else: + result = pd.api.interchange.from_dataframe(result) + return result # TODO `function.wraps` changes the name of the wrapped function to the @@ -104,7 +92,6 @@ def _create_series_writer_function(format_name): When calling `Series.to_` we call the dataframe writer, so we need to convert the Series to a one column dataframe. """ - def series_writer_wrapper(self, *args, **kwargs): dataframe_writer = getattr(self.to_frame(), f"to_{format_name}") dataframe_writer(*args, **kwargs) @@ -121,34 +108,33 @@ def load_io_plugins(): format_name = dataframe_io_entry_point.name io_plugin = dataframe_io_entry_point.load() - for exchange_format in supported_exchange_formats: - if hasattr(io_plugin, f"{exchange_format}_reader"): - if hasattr(pd, f"read_{format_name}"): - raise RuntimeError( - "More than one installed library provides the " - "`read_{format_name}` reader. Please uninstall one of " - "the I/O plugins providing connectors for this format." - ) - setattr( - pd, - f"read_{format_name}", - _create_reader_function(io_plugin, exchange_format), + if hasattr(io_plugin, "reader"): + if hasattr(pd, f"read_{format_name}"): + raise RuntimeError( + "More than one installed library provides the " + "`read_{format_name}` reader. Please uninstall one of " + "the I/O plugins providing connectors for this format." ) + setattr( + pd, + f"read_{format_name}", + _create_reader_function(io_plugin), + ) - if hasattr(io_plugin, f"{exchange_format}_writer"): - if hasattr(pd.DataFrame, f"to_{format_name}"): - raise RuntimeError( - "More than one installed library provides the " - "`to_{format_name}` reader. Please uninstall one of " - "the I/O plugins providing connectors for this format." - ) - setattr( - pd.DataFrame, - f"to_{format_name}", - getattr(io_plugin, f"{exchange_format}_writer"), - ) - setattr( - pd.Series, - f"to_{format_name}", - _create_series_writer_function(format_name), + if hasattr(io_plugin, "writer"): + if hasattr(pd.DataFrame, f"to_{format_name}"): + raise RuntimeError( + "More than one installed library provides the " + "`to_{format_name}` reader. Please uninstall one of " + "the I/O plugins providing connectors for this format." ) + setattr( + pd.DataFrame, + f"to_{format_name}", + getattr(io_plugin, "writer"), + ) + setattr( + pd.Series, + f"to_{format_name}", + _create_series_writer_function(format_name), + ) From 51f7588df955812670a1d391375cd91984b3d82f Mon Sep 17 00:00:00 2001 From: Marc Garcia Date: Sun, 28 May 2023 13:29:10 +0400 Subject: [PATCH 8/8] Warning on conflict --- pandas/io/_plugin_loader.py | 90 ++++++++++++++++++++++++++----------- 1 file changed, 63 insertions(+), 27 deletions(-) diff --git a/pandas/io/_plugin_loader.py b/pandas/io/_plugin_loader.py index 4e988db3fe910..7344fd7f10d86 100644 --- a/pandas/io/_plugin_loader.py +++ b/pandas/io/_plugin_loader.py @@ -51,7 +51,9 @@ def writer(self, fname, mode='w'): make an arbitrary decision on which to use. """ import functools +import warnings from importlib.metadata import entry_points +import importlib_metadata import pandas as pd @@ -75,8 +77,9 @@ def reader_wrapper(*args, **kwargs): if isinstance(result, list): result = [pd.api.interchange.from_dataframe(df) for df in result] elif isinstance(result, dict): - result = {k: pd.api.interchange.from_dataframe(df) - for k, df in result.items()} + result = { + k: pd.api.interchange.from_dataframe(df) for k, df in result.items() + } else: result = pd.api.interchange.from_dataframe(result) @@ -92,6 +95,7 @@ def _create_series_writer_function(format_name): When calling `Series.to_` we call the dataframe writer, so we need to convert the Series to a one column dataframe. """ + def series_writer_wrapper(self, *args, **kwargs): dataframe_writer = getattr(self.to_frame(), f"to_{format_name}") dataframe_writer(*args, **kwargs) @@ -99,42 +103,74 @@ def series_writer_wrapper(self, *args, **kwargs): return series_writer_wrapper +def _warn_conflict(func_name, format_name, loaded_plugins, module): + package_to_load = importlib_metadata.packages_distributions()[module.__name__] + if format_name in loaded_plugins: + # conflict with a third-party connector + loaded_module = loaded_plugins[format_name] + loaded_package = importlib_metadata.packages_distributions()[ + loaded_module.__name__ + ] + msg = ( + f"Unable to create `{func_name}`. " + f"A conflict exists, because the packages `{loaded_package}` and " + f"`{package_to_load}` both provide the connector for the '{format_name}' format. " + "Please uninstall one of the packages and leave in the current " + "environment only the one you want to use for the '{format_name}' format." + ) + else: + # conflict with a pandas connector + msg = ( + f"The package `{package_to_load}` registers `{func_name}`, which is " + "already provided by pandas. The plugin will be ignored." + ) + + warnings.warn(msg, UserWarning, stacklevel=1) + + def load_io_plugins(): """ Looks for entrypoints in the `dataframe.io` group and creates the corresponding pandas I/O methods. """ + loaded_plugins = {} + for dataframe_io_entry_point in entry_points().get("dataframe.io", []): format_name = dataframe_io_entry_point.name io_plugin = dataframe_io_entry_point.load() if hasattr(io_plugin, "reader"): - if hasattr(pd, f"read_{format_name}"): - raise RuntimeError( - "More than one installed library provides the " - "`read_{format_name}` reader. Please uninstall one of " - "the I/O plugins providing connectors for this format." + func_name = f"read_{format_name}" + if hasattr(pd, func_name): + _warn_conflict( + f"pandas.{func_name}", format_name, loaded_plugins, io_plugin + ) + delattr(pd, func_name) + else: + setattr( + pd, + f"read_{format_name}", + _create_reader_function(io_plugin), ) - setattr( - pd, - f"read_{format_name}", - _create_reader_function(io_plugin), - ) if hasattr(io_plugin, "writer"): - if hasattr(pd.DataFrame, f"to_{format_name}"): - raise RuntimeError( - "More than one installed library provides the " - "`to_{format_name}` reader. Please uninstall one of " - "the I/O plugins providing connectors for this format." + func_name = f"to_{format_name}" + if hasattr(pd.DataFrame, func_name): + _warn_conflict( + f"DataFrame.{func_name}", format_name, loaded_plugins, io_plugin + ) + delattr(pd.DataFrame, func_name) + delattr(pd.Series, func_name) + else: + setattr( + pd.DataFrame, + func_name, + getattr(io_plugin, "writer"), ) - setattr( - pd.DataFrame, - f"to_{format_name}", - getattr(io_plugin, "writer"), - ) - setattr( - pd.Series, - f"to_{format_name}", - _create_series_writer_function(format_name), - ) + setattr( + pd.Series, + func_name, + _create_series_writer_function(format_name), + ) + + loaded_plugins[format_name] = io_plugin