diff --git a/.claude/skills/dataverse-sdk-use/SKILL.md b/.claude/skills/dataverse-sdk-use/SKILL.md index ce74fc01..00987029 100644 --- a/.claude/skills/dataverse-sdk-use/SKILL.md +++ b/.claude/skills/dataverse-sdk-use/SKILL.md @@ -253,7 +253,7 @@ client.records.delete("account", [id1, id2, id3], use_bulk_delete=True) ### DataFrame Operations -The SDK provides DataFrame wrappers for all CRUD operations via the `client.dataframe` namespace, using pandas DataFrames and Series as input/output. +The SDK provides DataFrame wrappers for all CRUD operations via the `client.dataframe` namespace, using pandas DataFrames and Series as input/output. Requires the `[dataframe]` extra: `pip install "PowerPlatform-Dataverse-Client[dataframe]"`. > **Note:** `client.dataframe.get()` is deprecated. Use `client.query.builder(table).select(...).where(...).execute().to_dataframe()` instead. `QueryBuilder.to_dataframe()` (without `.execute()`) is also deprecated — always call `.execute()` first. @@ -686,6 +686,8 @@ result = await batch.execute() ``` ### DataFrame Operations + +Requires the `[dataframe]` extra: `pip install "PowerPlatform-Dataverse-Client[dataframe]"`. ```python # given: client is an open AsyncDataverseClient import pandas as pd diff --git a/README.md b/README.md index 4a518dd3..f8992268 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,9 @@ The Dataverse SDK for Python lets Python developers access, manage, and manipula pip install PowerPlatform-Dataverse-Client ``` -The `pandas` library is installed automatically and powers the `client.dataframe` namespace. The async client requires an optional extra: `pip install "PowerPlatform-Dataverse-Client[async]"`. +The core SDK installs with minimal dependencies. Optional extras: +- DataFrame integration: `pip install "PowerPlatform-Dataverse-Client[dataframe]"` +- Async client: `pip install "PowerPlatform-Dataverse-Client[async]"` ### Authenticate diff --git a/pyproject.toml b/pyproject.toml index 23bfe080..92cb60cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ dependencies = [ "azure-identity>=1.17.0", "azure-core>=1.30.2", "requests>=2.32.0", - "pandas>=2.0.0", ] [project.urls] @@ -46,6 +45,9 @@ dataverse-migrate = "PowerPlatform.Dataverse.migration.migrate_v0_to_v1:main" async = [ "aiohttp>=3.9", ] +dataframe = [ + "pandas>=2.0.0", +] dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", @@ -55,6 +57,7 @@ dev = [ "mypy>=1.0.0", "ruff>=0.1.0", "libcst>=1.0.0", + "pandas>=2.0.0", ] migration = ["libcst>=1.0.0"] diff --git a/src/PowerPlatform/Dataverse/aio/async_client.py b/src/PowerPlatform/Dataverse/aio/async_client.py index fc46992f..ae4a26db 100644 --- a/src/PowerPlatform/Dataverse/aio/async_client.py +++ b/src/PowerPlatform/Dataverse/aio/async_client.py @@ -126,9 +126,20 @@ def __init__( self.query = AsyncQueryOperations(self) self.tables = AsyncTableOperations(self) self.files = AsyncFileOperations(self) - self.dataframe = AsyncDataFrameOperations(self) + self._dataframe: Optional[AsyncDataFrameOperations] = None self.batch = AsyncBatchOperations(self) + @property + def dataframe(self) -> AsyncDataFrameOperations: + """pandas DataFrame wrappers for record CRUD operations.""" + if self._dataframe is None: + self._dataframe = AsyncDataFrameOperations(self) + return self._dataframe + + @dataframe.setter + def dataframe(self, value: AsyncDataFrameOperations) -> None: + self._dataframe = value + def _get_odata(self) -> _AsyncODataClient: """ Get or create the internal async OData client instance. diff --git a/src/PowerPlatform/Dataverse/aio/operations/async_batch.py b/src/PowerPlatform/Dataverse/aio/operations/async_batch.py index 3b95fea3..99148b99 100644 --- a/src/PowerPlatform/Dataverse/aio/operations/async_batch.py +++ b/src/PowerPlatform/Dataverse/aio/operations/async_batch.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, List +from typing import TYPE_CHECKING, Any, List, Optional from ...data._batch_base import _ChangeSet from ...operations.batch import ( @@ -103,7 +103,18 @@ def __init__(self, client: "AsyncDataverseClient") -> None: self.records = BatchRecordOperations(self) self.tables = BatchTableOperations(self) self.query = BatchQueryOperations(self) - self.dataframe = BatchDataFrameOperations(self) + self._dataframe: Optional[BatchDataFrameOperations] = None + + @property + def dataframe(self) -> BatchDataFrameOperations: + """pandas DataFrame wrappers for batch operations.""" + if self._dataframe is None: + self._dataframe = BatchDataFrameOperations(self) + return self._dataframe + + @dataframe.setter + def dataframe(self, value: BatchDataFrameOperations) -> None: + self._dataframe = value def changeset(self) -> AsyncChangeSet: """ diff --git a/src/PowerPlatform/Dataverse/aio/operations/async_dataframe.py b/src/PowerPlatform/Dataverse/aio/operations/async_dataframe.py index fba7b334..31be0ba2 100644 --- a/src/PowerPlatform/Dataverse/aio/operations/async_dataframe.py +++ b/src/PowerPlatform/Dataverse/aio/operations/async_dataframe.py @@ -7,11 +7,8 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional -import pandas as pd - -from ...utils._pandas import dataframe_to_records - if TYPE_CHECKING: + import pandas as pd from ..async_client import AsyncDataverseClient @@ -51,6 +48,15 @@ class AsyncDataFrameOperations: """ def __init__(self, client: "AsyncDataverseClient") -> None: + try: + import importlib + + importlib.import_module("pandas") + except ImportError as exc: + raise ImportError( + "pandas is required to use DataFrame features. " + "Install it with: pip install 'PowerPlatform-Dataverse-Client[dataframe]'" + ) from exc self._client = client # --------------------------------------------------------------------- sql @@ -90,6 +96,8 @@ async def sql(self, sql: str) -> pd.DataFrame: "GROUP BY a.name" ) """ + import pandas as pd + rows = await self._client.query.sql(sql) if not rows: return pd.DataFrame() @@ -132,6 +140,9 @@ async def create( ]) df["accountid"] = await client.dataframe.create("account", df) """ + import pandas as pd + from ...utils._pandas import dataframe_to_records + if not isinstance(records, pd.DataFrame): raise TypeError("records must be a pandas DataFrame") @@ -218,6 +229,9 @@ async def update( df = pd.DataFrame([{"accountid": "guid-1", "websiteurl": None}]) await client.dataframe.update("account", df, id_column="accountid", clear_nulls=True) """ + import pandas as pd + from ...utils._pandas import dataframe_to_records + if not isinstance(changes, pd.DataFrame): raise TypeError("changes must be a pandas DataFrame") if changes.empty: @@ -289,6 +303,8 @@ async def delete( ids = pd.Series(["guid-1", "guid-2", "guid-3"]) await client.dataframe.delete("account", ids) """ + import pandas as pd + if not isinstance(ids, pd.Series): raise TypeError("ids must be a pandas Series") diff --git a/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md b/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md index 459a06a5..db055a68 100644 --- a/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md +++ b/src/PowerPlatform/Dataverse/claude_skill/dataverse-sdk-use/SKILL.md @@ -253,7 +253,7 @@ client.records.delete("account", [id1, id2, id3], use_bulk_delete=True) ### DataFrame Operations -The SDK provides DataFrame wrappers for all CRUD operations via the `client.dataframe` namespace, using pandas DataFrames and Series as input/output. +The SDK provides DataFrame wrappers for all CRUD operations via the `client.dataframe` namespace, using pandas DataFrames and Series as input/output. Requires the `[dataframe]` extra: `pip install "PowerPlatform-Dataverse-Client[dataframe]"`. > **Note:** `client.dataframe.get()` is deprecated. Use `client.query.builder(table).select(...).where(...).execute().to_dataframe()` instead. `QueryBuilder.to_dataframe()` (without `.execute()`) is also deprecated — always call `.execute()` first. @@ -686,6 +686,8 @@ result = await batch.execute() ``` ### DataFrame Operations + +Requires the `[dataframe]` extra: `pip install "PowerPlatform-Dataverse-Client[dataframe]"`. ```python # given: client is an open AsyncDataverseClient import pandas as pd diff --git a/src/PowerPlatform/Dataverse/client.py b/src/PowerPlatform/Dataverse/client.py index fc163d26..612a5d15 100644 --- a/src/PowerPlatform/Dataverse/client.py +++ b/src/PowerPlatform/Dataverse/client.py @@ -170,9 +170,20 @@ def __init__( self.query = QueryOperations(self) self.tables = TableOperations(self) self.files = FileOperations(self) - self.dataframe = DataFrameOperations(self) + self._dataframe: Optional[DataFrameOperations] = None self.batch = BatchOperations(self) + @property + def dataframe(self) -> DataFrameOperations: + """pandas DataFrame wrappers for record CRUD operations.""" + if self._dataframe is None: + self._dataframe = DataFrameOperations(self) + return self._dataframe + + @dataframe.setter + def dataframe(self, value: DataFrameOperations) -> None: + self._dataframe = value + def _get_odata(self) -> _ODataClient: """ Get or create the internal OData client instance. diff --git a/src/PowerPlatform/Dataverse/models/query_builder.py b/src/PowerPlatform/Dataverse/models/query_builder.py index c53a4929..864ec798 100644 --- a/src/PowerPlatform/Dataverse/models/query_builder.py +++ b/src/PowerPlatform/Dataverse/models/query_builder.py @@ -51,7 +51,7 @@ import sys import warnings -from typing import Any, Iterator, List, Optional, TypedDict, Union +from typing import TYPE_CHECKING, Any, Iterator, List, Optional, TypedDict, Union # typing.Self (PEP 673, Python 3.11+) makes fluent methods return the concrete # subclass type. TypeVar fallback for Python 3.10 uses the same name so docs render identically. @@ -62,7 +62,8 @@ Self = TypeVar("Self", bound="_QueryBuilderBase") # type: ignore[assignment] -import pandas as pd +if TYPE_CHECKING: + import pandas as pd from . import filters from .record import QueryResult, Record @@ -666,6 +667,14 @@ def to_dataframe(self) -> pd.DataFrame: DeprecationWarning, stacklevel=2, ) + try: + import pandas as pd + except ImportError as exc: + raise ImportError( + "pandas is required for to_dataframe(). " + "Install it with: pip install 'PowerPlatform-Dataverse-Client[dataframe]'" + ) from exc + if self._query_ops is None: raise RuntimeError( "Cannot execute: query was not created via client.query.builder(). " diff --git a/src/PowerPlatform/Dataverse/models/record.py b/src/PowerPlatform/Dataverse/models/record.py index ff672620..1c8ca246 100644 --- a/src/PowerPlatform/Dataverse/models/record.py +++ b/src/PowerPlatform/Dataverse/models/record.py @@ -162,7 +162,10 @@ def to_dataframe(self) -> Any: try: import pandas as pd except ImportError as exc: - raise ImportError("pandas is required for to_dataframe(). " "Install it with: pip install pandas") from exc + raise ImportError( + "pandas is required for to_dataframe(). " + "Install it with: pip install 'PowerPlatform-Dataverse-Client[dataframe]'" + ) from exc if not self.records: return pd.DataFrame() diff --git a/src/PowerPlatform/Dataverse/operations/batch.py b/src/PowerPlatform/Dataverse/operations/batch.py index e55c874b..f1950dbe 100644 --- a/src/PowerPlatform/Dataverse/operations/batch.py +++ b/src/PowerPlatform/Dataverse/operations/batch.py @@ -8,7 +8,8 @@ import warnings from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Union -import pandas as pd +if TYPE_CHECKING: + import pandas as pd from ..core.errors import ValidationError from ..core._error_codes import VALIDATION_SQL_EMPTY @@ -798,6 +799,15 @@ class BatchDataFrameOperations: """ def __init__(self, batch: "_BatchContext") -> None: + try: + import importlib + + importlib.import_module("pandas") + except ImportError as exc: + raise ImportError( + "pandas is required to use DataFrame features. " + "Install it with: pip install 'PowerPlatform-Dataverse-Client[dataframe]'" + ) from exc self._batch = batch def create(self, table: str, records: pd.DataFrame) -> None: @@ -819,6 +829,8 @@ def create(self, table: str, records: pd.DataFrame) -> None: df = pd.DataFrame([{"name": "Contoso"}, {"name": "Fabrikam"}]) batch.dataframe.create("account", df) """ + import pandas as pd + if not isinstance(records, pd.DataFrame): raise TypeError("records must be a pandas DataFrame") if records.empty: @@ -870,6 +882,8 @@ def update( ]) batch.dataframe.update("account", df, id_column="accountid") """ + import pandas as pd + if not isinstance(changes, pd.DataFrame): raise TypeError("changes must be a pandas DataFrame") if changes.empty: @@ -927,6 +941,8 @@ def delete( ids_series = pd.Series(["guid-1", "guid-2", "guid-3"]) batch.dataframe.delete("account", ids_series) """ + import pandas as pd + if not isinstance(ids, pd.Series): raise TypeError("ids must be a pandas Series") raw_list = ids.tolist() @@ -981,7 +997,18 @@ def __init__(self, client: "DataverseClient") -> None: self.records = BatchRecordOperations(self) self.tables = BatchTableOperations(self) self.query = BatchQueryOperations(self) - self.dataframe = BatchDataFrameOperations(self) + self._dataframe: Optional[BatchDataFrameOperations] = None + + @property + def dataframe(self) -> BatchDataFrameOperations: + """pandas DataFrame wrappers for batch operations.""" + if self._dataframe is None: + self._dataframe = BatchDataFrameOperations(self) + return self._dataframe + + @dataframe.setter + def dataframe(self, value: BatchDataFrameOperations) -> None: + self._dataframe = value def changeset(self) -> ChangeSet: """ diff --git a/src/PowerPlatform/Dataverse/operations/dataframe.py b/src/PowerPlatform/Dataverse/operations/dataframe.py index 28647c58..50becb80 100644 --- a/src/PowerPlatform/Dataverse/operations/dataframe.py +++ b/src/PowerPlatform/Dataverse/operations/dataframe.py @@ -8,11 +8,8 @@ import warnings from typing import TYPE_CHECKING, Any, Dict, List, Optional -import pandas as pd - -from ..utils._pandas import dataframe_to_records - if TYPE_CHECKING: + import pandas as pd from ..client import DataverseClient @@ -50,6 +47,15 @@ class DataFrameOperations: """ def __init__(self, client: DataverseClient) -> None: + try: + import importlib + + importlib.import_module("pandas") + except ImportError as exc: + raise ImportError( + "pandas is required to use DataFrame features. " + "Install it with: pip install 'PowerPlatform-Dataverse-Client[dataframe]'" + ) from exc self._client = client # --------------------------------------------------------------------- sql @@ -89,6 +95,8 @@ def sql(self, sql: str) -> pd.DataFrame: "GROUP BY a.name" ) """ + import pandas as pd + rows = self._client.query.sql(sql) if not rows: return pd.DataFrame() @@ -165,6 +173,8 @@ def get( df = client.dataframe.get("account", select=["name"], top=100) """ + import pandas as pd + warnings.warn( "'dataframe.get()' is deprecated; use " "client.query.builder(table).where(...).execute().to_dataframe() instead.", @@ -247,6 +257,9 @@ def create( ]) df["accountid"] = client.dataframe.create("account", df) """ + import pandas as pd + from ..utils._pandas import dataframe_to_records + if not isinstance(records, pd.DataFrame): raise TypeError("records must be a pandas DataFrame") @@ -333,8 +346,12 @@ def update( df = pd.DataFrame([{"accountid": "guid-1", "websiteurl": None}]) client.dataframe.update("account", df, id_column="accountid", clear_nulls=True) """ + import pandas as pd + from ..utils._pandas import dataframe_to_records + if not isinstance(changes, pd.DataFrame): raise TypeError("changes must be a pandas DataFrame") + if changes.empty: raise ValueError("changes must be a non-empty DataFrame") if id_column not in changes.columns: @@ -403,6 +420,8 @@ def delete( ids = pd.Series(["guid-1", "guid-2", "guid-3"]) client.dataframe.delete("account", ids) """ + import pandas as pd + if not isinstance(ids, pd.Series): raise TypeError("ids must be a pandas Series") diff --git a/tests/unit/test_optional_pandas.py b/tests/unit/test_optional_pandas.py new file mode 100644 index 00000000..845125ef --- /dev/null +++ b/tests/unit/test_optional_pandas.py @@ -0,0 +1,161 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import sys +import unittest +from unittest.mock import MagicMock, patch + +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential + +from PowerPlatform.Dataverse.aio.async_client import AsyncDataverseClient +from PowerPlatform.Dataverse.aio.operations.async_batch import AsyncBatchRequest +from PowerPlatform.Dataverse.aio.operations.async_dataframe import AsyncDataFrameOperations +from PowerPlatform.Dataverse.client import DataverseClient +from PowerPlatform.Dataverse.models.query_builder import QueryBuilder +from PowerPlatform.Dataverse.models.record import QueryResult +from PowerPlatform.Dataverse.operations.batch import BatchDataFrameOperations, BatchRequest +from PowerPlatform.Dataverse.operations.dataframe import DataFrameOperations + + +class TestOptionalPandasWithoutPandas(unittest.TestCase): + """Verify behavior when pandas is not installed.""" + + def setUp(self): + self.mock_cred = MagicMock(spec=TokenCredential) + self.async_mock_cred = MagicMock(spec=AsyncTokenCredential) + + def test_client_initialization_without_pandas(self): + """DataverseClient initializes and exposes non-dataframe namespaces without pandas.""" + with patch.dict(sys.modules, {"pandas": None}): + client = DataverseClient("https://example.crm.dynamics.com", self.mock_cred) + self.assertIsNotNone(client.records) + self.assertIsNotNone(client.query) + self.assertIsNotNone(client.tables) + self.assertIsNotNone(client.files) + self.assertIsNotNone(client.batch) + + def test_batch_creation_without_pandas(self): + """BatchRequest initializes and exposes non-dataframe namespaces without pandas.""" + with patch.dict(sys.modules, {"pandas": None}): + client = DataverseClient("https://example.crm.dynamics.com", self.mock_cred) + batch = client.batch.new() + self.assertIsInstance(batch, BatchRequest) + self.assertIsNotNone(batch.records) + self.assertIsNotNone(batch.tables) + self.assertIsNotNone(batch.query) + + def test_client_dataframe_access_raises_import_error(self): + """Accessing client.dataframe without pandas raises informative ImportError.""" + with patch.dict(sys.modules, {"pandas": None}): + client = DataverseClient("https://example.crm.dynamics.com", self.mock_cred) + with self.assertRaises(ImportError) as ctx: + _ = client.dataframe + self.assertIn("PowerPlatform-Dataverse-Client[dataframe]", str(ctx.exception)) + + def test_batch_dataframe_access_raises_import_error(self): + """Accessing batch.dataframe without pandas raises informative ImportError.""" + with patch.dict(sys.modules, {"pandas": None}): + client = DataverseClient("https://example.crm.dynamics.com", self.mock_cred) + batch = client.batch.new() + with self.assertRaises(ImportError) as ctx: + _ = batch.dataframe + self.assertIn("PowerPlatform-Dataverse-Client[dataframe]", str(ctx.exception)) + + def test_async_client_initialization_without_pandas(self): + """AsyncDataverseClient initializes without pandas.""" + with patch.dict(sys.modules, {"pandas": None}): + async_client = AsyncDataverseClient("https://example.crm.dynamics.com", self.async_mock_cred) + self.assertIsNotNone(async_client.records) + self.assertIsNotNone(async_client.query) + self.assertIsNotNone(async_client.tables) + self.assertIsNotNone(async_client.files) + self.assertIsNotNone(async_client.batch) + + def test_async_client_dataframe_access_raises_import_error(self): + """Accessing async_client.dataframe without pandas raises informative ImportError.""" + with patch.dict(sys.modules, {"pandas": None}): + async_client = AsyncDataverseClient("https://example.crm.dynamics.com", self.async_mock_cred) + with self.assertRaises(ImportError) as ctx: + _ = async_client.dataframe + self.assertIn("PowerPlatform-Dataverse-Client[dataframe]", str(ctx.exception)) + + def test_async_batch_dataframe_access_raises_import_error(self): + """Accessing async_batch.dataframe without pandas raises informative ImportError.""" + with patch.dict(sys.modules, {"pandas": None}): + async_client = AsyncDataverseClient("https://example.crm.dynamics.com", self.async_mock_cred) + async_batch = async_client.batch.new() + self.assertIsInstance(async_batch, AsyncBatchRequest) + with self.assertRaises(ImportError) as ctx: + _ = async_batch.dataframe + self.assertIn("PowerPlatform-Dataverse-Client[dataframe]", str(ctx.exception)) + + def test_query_result_to_dataframe_raises_import_error(self): + """QueryResult.to_dataframe() without pandas raises informative ImportError.""" + with patch.dict(sys.modules, {"pandas": None}): + qr = QueryResult([]) + with self.assertRaises(ImportError) as ctx: + qr.to_dataframe() + self.assertIn("PowerPlatform-Dataverse-Client[dataframe]", str(ctx.exception)) + + def test_query_builder_to_dataframe_raises_import_error(self): + """QueryBuilder.to_dataframe() without pandas raises informative ImportError.""" + with patch.dict(sys.modules, {"pandas": None}): + qb = QueryBuilder("account") + with self.assertRaises(ImportError) as ctx: + qb.to_dataframe() + self.assertIn("PowerPlatform-Dataverse-Client[dataframe]", str(ctx.exception)) + + +class TestOptionalPandasWithPandas(unittest.TestCase): + """Verify standard property behavior when pandas is present.""" + + def setUp(self): + self.mock_cred = MagicMock(spec=TokenCredential) + self.async_mock_cred = MagicMock(spec=AsyncTokenCredential) + + def test_client_dataframe_property(self): + """client.dataframe returns DataFrameOperations and allows setter assignment.""" + client = DataverseClient("https://example.crm.dynamics.com", self.mock_cred) + df_ops = client.dataframe + self.assertIsInstance(df_ops, DataFrameOperations) + self.assertIs(client.dataframe, df_ops) + + mock_ops = MagicMock(spec=DataFrameOperations) + client.dataframe = mock_ops + self.assertIs(client.dataframe, mock_ops) + + def test_async_client_dataframe_property(self): + """async_client.dataframe returns AsyncDataFrameOperations and allows setter assignment.""" + async_client = AsyncDataverseClient("https://example.crm.dynamics.com", self.async_mock_cred) + df_ops = async_client.dataframe + self.assertIsInstance(df_ops, AsyncDataFrameOperations) + self.assertIs(async_client.dataframe, df_ops) + + mock_ops = MagicMock(spec=AsyncDataFrameOperations) + async_client.dataframe = mock_ops + self.assertIs(async_client.dataframe, mock_ops) + + def test_batch_dataframe_property(self): + """batch.dataframe returns BatchDataFrameOperations and allows setter assignment.""" + client = DataverseClient("https://example.crm.dynamics.com", self.mock_cred) + batch = client.batch.new() + batch_df_ops = batch.dataframe + self.assertIsInstance(batch_df_ops, BatchDataFrameOperations) + self.assertIs(batch.dataframe, batch_df_ops) + + mock_ops = MagicMock(spec=BatchDataFrameOperations) + batch.dataframe = mock_ops + self.assertIs(batch.dataframe, mock_ops) + + def test_async_batch_dataframe_property(self): + """async_batch.dataframe returns BatchDataFrameOperations and allows setter assignment.""" + async_client = AsyncDataverseClient("https://example.crm.dynamics.com", self.async_mock_cred) + async_batch = async_client.batch.new() + batch_df_ops = async_batch.dataframe + self.assertIsInstance(batch_df_ops, BatchDataFrameOperations) + self.assertIs(async_batch.dataframe, batch_df_ops) + + mock_ops = MagicMock(spec=BatchDataFrameOperations) + async_batch.dataframe = mock_ops + self.assertIs(async_batch.dataframe, mock_ops)