pipelantic-pandas 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ .eggs/
7
+ dist/
8
+ build/
9
+ site/
10
+ .venv/
11
+ venv/
12
+ .env
13
+
14
+ # Tools
15
+ .pytest_cache/
16
+ .ruff_cache/
17
+ .mypy_cache/
18
+ .coverage
19
+ htmlcov/
20
+ .DS_Store
21
+ examples/_file_storage_out/
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: pipelantic-pandas
3
+ Version: 0.5.0
4
+ Summary: Pandas dataframe execution plugin for Pipelantic.
5
+ Author-email: Odo Matthews <odosmatthews@gmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: pandas<3,>=2.0
9
+ Requires-Dist: pipelantic<0.6,>=0.5.0
10
+ Provides-Extra: arrow
11
+ Requires-Dist: pyarrow>=14; extra == 'arrow'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # pipelantic-pandas
15
+
16
+ Pandas compatibility dataframe plugin for [Pipelantic](https://github.com/eddiethedean/pipelantic).
17
+
18
+ ```bash
19
+ pip install pipelantic-pandas
20
+ pip install 'pipelantic-pandas[arrow]' # optional Arrow interchange
21
+ ```
22
+
23
+ Eager `DataFrame` execution only. Planning fails closed when a pipeline
24
+ requires unsupported lazy or zero-copy behavior.
@@ -0,0 +1,11 @@
1
+ # pipelantic-pandas
2
+
3
+ Pandas compatibility dataframe plugin for [Pipelantic](https://github.com/eddiethedean/pipelantic).
4
+
5
+ ```bash
6
+ pip install pipelantic-pandas
7
+ pip install 'pipelantic-pandas[arrow]' # optional Arrow interchange
8
+ ```
9
+
10
+ Eager `DataFrame` execution only. Planning fails closed when a pipeline
11
+ requires unsupported lazy or zero-copy behavior.
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "pipelantic-pandas"
3
+ version = "0.5.0"
4
+ description = "Pandas dataframe execution plugin for Pipelantic."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ authors = [{ name = "Odo Matthews", email = "odosmatthews@gmail.com" }]
9
+ dependencies = [
10
+ "pipelantic>=0.5.0,<0.6",
11
+ "pandas>=2.0,<3",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ arrow = ["pyarrow>=14"]
16
+
17
+ [project.entry-points."pipelantic.dataframe_plugins"]
18
+ pandas = "pipelantic_pandas:create_plugin"
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/pipelantic_pandas"]
@@ -0,0 +1,267 @@
1
+ """Pandas dataframe plugin for Pipelantic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ import pandas as pd
9
+
10
+ from pipelantic.capabilities import PluginCapabilities
11
+ from pipelantic.dataframe.arrow import arrow_available, to_arrow_table
12
+ from pipelantic.dataframe.helpers import (
13
+ schema_dict,
14
+ schema_from_contract,
15
+ split_valid_invalid_records,
16
+ )
17
+ from pipelantic.dataframe.protocol import (
18
+ DATAFRAME_PROTOCOL_VERSION,
19
+ ArtifactOwnership,
20
+ DataframeExecutionContext,
21
+ DataframeMetrics,
22
+ DataframeOutputBundle,
23
+ DataframePluginInfo,
24
+ DataframeValidationOutcome,
25
+ ValidationDecision,
26
+ )
27
+ from pipelantic.storage.protocol import as_records, records_to_dicts
28
+
29
+ __version__ = "0.5.0"
30
+
31
+
32
+ def create_plugin() -> PandasDataframePlugin:
33
+ """Entry-point factory."""
34
+ return PandasDataframePlugin()
35
+
36
+
37
+ class PandasDataframePlugin:
38
+ """Compatibility Pandas dataframe execution plugin (eager only)."""
39
+
40
+ def __init__(self) -> None:
41
+ caps = PluginCapabilities(
42
+ engine="pandas",
43
+ async_execution=True,
44
+ dataframe=True,
45
+ eager=True,
46
+ lazy=False,
47
+ arrow_import=arrow_available(),
48
+ arrow_export=arrow_available(),
49
+ zero_copy=False,
50
+ schema_inspection=True,
51
+ invalid_row_separation=True,
52
+ cancellation=False,
53
+ thread_safe=False,
54
+ extras=frozenset({"pandas"}),
55
+ )
56
+ self._info = DataframePluginInfo(
57
+ name="pipelantic-pandas",
58
+ engine="pandas",
59
+ version=__version__,
60
+ protocol_version=DATAFRAME_PROTOCOL_VERSION,
61
+ capabilities=caps,
62
+ )
63
+
64
+ @property
65
+ def info(self) -> DataframePluginInfo:
66
+ return self._info
67
+
68
+ def materialize_input(
69
+ self,
70
+ value: Any,
71
+ *,
72
+ contract_type: type[Any] | None,
73
+ context: DataframeExecutionContext,
74
+ port_name: str,
75
+ ) -> Any:
76
+ if isinstance(value, pd.DataFrame):
77
+ return value.copy(deep=False)
78
+ table = to_arrow_table(value)
79
+ if table is not None:
80
+ return table.to_pandas()
81
+ # Polars → pandas without Arrow
82
+ if type(value).__module__.startswith("polars"):
83
+ if hasattr(value, "collect"):
84
+ value = value.collect()
85
+ if hasattr(value, "to_pandas"):
86
+ return value.to_pandas()
87
+ records = as_records(value, None)
88
+ rows = records_to_dicts(records)
89
+ if not rows:
90
+ columns = list(getattr(contract_type, "model_fields", {}) or {})
91
+ return pd.DataFrame(columns=columns)
92
+ return pd.DataFrame(rows)
93
+
94
+ def invoke(
95
+ self,
96
+ *,
97
+ callable_: Any,
98
+ inputs: Mapping[str, Any],
99
+ parameters: Mapping[str, Any],
100
+ context: DataframeExecutionContext,
101
+ ) -> Any:
102
+ # Inputs take precedence over parameters when names collide.
103
+ kwargs = {**dict(parameters), **dict(inputs)}
104
+ return callable_(**kwargs)
105
+
106
+ def normalize_output(
107
+ self,
108
+ result: Any,
109
+ *,
110
+ output_ports: tuple[str, ...],
111
+ context: DataframeExecutionContext,
112
+ ) -> DataframeOutputBundle:
113
+ metrics = DataframeMetrics(converted=False)
114
+ if isinstance(result, dict) and any(p in result for p in output_ports):
115
+ valid = {k: result[k] for k in output_ports if k in result}
116
+ invalid = {
117
+ k: v
118
+ for k, v in result.items()
119
+ if k.endswith("_invalid") or k == "invalid"
120
+ }
121
+ side = {
122
+ k: v for k, v in result.items() if k not in valid and k not in invalid
123
+ }
124
+ return DataframeOutputBundle(
125
+ valid=valid,
126
+ invalid=invalid,
127
+ side=side,
128
+ metrics=metrics,
129
+ )
130
+ port = output_ports[0] if output_ports else "result"
131
+ return DataframeOutputBundle(valid={port: result}, metrics=metrics)
132
+
133
+ def validate_frame(
134
+ self,
135
+ value: Any,
136
+ *,
137
+ contract_type: type[Any] | None,
138
+ context: DataframeExecutionContext,
139
+ boundary: str,
140
+ port_name: str | None = None,
141
+ ) -> tuple[Any, ValidationDecision, list[dict[str, Any]], Any | None]:
142
+ outcome = (
143
+ context.validation_policy.input_outcome
144
+ if boundary.startswith("input")
145
+ else context.validation_policy.output_outcome
146
+ )
147
+ if contract_type is None:
148
+ return value, ValidationDecision.SKIPPED, [], None
149
+ frame = value
150
+ if not isinstance(frame, pd.DataFrame):
151
+ frame = self.materialize_input(
152
+ frame,
153
+ contract_type=contract_type,
154
+ context=context,
155
+ port_name=port_name or "value",
156
+ )
157
+ rows = frame.to_dict(orient="records")
158
+ valid, invalid, diagnostics = split_valid_invalid_records(
159
+ rows, contract_type=contract_type
160
+ )
161
+ # Diagnose object-dtype ambiguity
162
+ for col in frame.columns:
163
+ if str(frame[col].dtype) == "object":
164
+ diagnostics.append(
165
+ {
166
+ "code": "PMDF420",
167
+ "message": (
168
+ f"Column {col!r} uses object dtype; logical type "
169
+ "may be ambiguous."
170
+ ),
171
+ "severity": "warning",
172
+ }
173
+ )
174
+ if not invalid:
175
+ return frame, ValidationDecision.PASSED, diagnostics, None
176
+ if outcome is DataframeValidationOutcome.FAIL:
177
+ return frame, ValidationDecision.FAILED, diagnostics, None
178
+ if outcome is DataframeValidationOutcome.OBSERVE_ONLY:
179
+ return frame, ValidationDecision.OBSERVED, diagnostics, None
180
+ if outcome is DataframeValidationOutcome.WARN:
181
+ return frame, ValidationDecision.WARNED, diagnostics, None
182
+ valid_frame = (
183
+ pd.DataFrame(records_to_dicts(valid)) if valid else frame.iloc[0:0]
184
+ )
185
+ invalid_frame = pd.DataFrame(records_to_dicts(invalid))
186
+ decision = (
187
+ ValidationDecision.QUARANTINED
188
+ if outcome is DataframeValidationOutcome.QUARANTINE
189
+ else ValidationDecision.REJECTED
190
+ )
191
+ return valid_frame, decision, diagnostics, invalid_frame
192
+
193
+ def inspect_schema(self, value: Any, *, identity: str) -> dict[str, Any] | None:
194
+ if not isinstance(value, pd.DataFrame):
195
+ return schema_dict(schema_from_contract(None, identity=identity))
196
+ fields = []
197
+ for name, dtype in value.dtypes.items():
198
+ fields.append(
199
+ {
200
+ "name": str(name),
201
+ "logical_type": _pandas_logical(dtype),
202
+ "required": True,
203
+ "nullable": bool(value[name].isna().any()) if len(value) else True,
204
+ }
205
+ )
206
+ from pipelantic.dataframe.helpers import normalized_from_field_dicts
207
+
208
+ return schema_dict(normalized_from_field_dicts(fields, identity=identity))
209
+
210
+ def ensure_ownership(
211
+ self,
212
+ value: Any,
213
+ *,
214
+ ownership: ArtifactOwnership,
215
+ context: DataframeExecutionContext,
216
+ ) -> Any:
217
+ if not isinstance(value, pd.DataFrame):
218
+ return value
219
+ # Always copy on fan-out / explicit copied ownership to prevent mutation.
220
+ if ownership in {
221
+ ArtifactOwnership.COPIED,
222
+ ArtifactOwnership.SHARED,
223
+ }:
224
+ return value.copy(deep=True)
225
+ return value
226
+
227
+ def collect_if_needed(
228
+ self,
229
+ value: Any,
230
+ *,
231
+ context: DataframeExecutionContext,
232
+ ) -> Any:
233
+ return value
234
+
235
+ def to_records(
236
+ self,
237
+ value: Any,
238
+ *,
239
+ contract_type: type[Any] | None,
240
+ ) -> list[Any]:
241
+ if isinstance(value, pd.DataFrame):
242
+ return as_records(value.to_dict(orient="records"), contract_type)
243
+ return as_records(value, contract_type)
244
+
245
+ def row_count(self, value: Any) -> int | None:
246
+ if isinstance(value, pd.DataFrame):
247
+ return len(value)
248
+ if isinstance(value, list):
249
+ return len(value)
250
+ return None
251
+
252
+
253
+ def _pandas_logical(dtype: Any) -> str:
254
+ name = str(dtype)
255
+ if name.startswith("int") or name.startswith("UInt") or "Int" in name:
256
+ return "integer"
257
+ if name.startswith("float") or "Float" in name:
258
+ return "number"
259
+ if name == "bool" or name.startswith("boolean"):
260
+ return "boolean"
261
+ if "datetime" in name:
262
+ return "timestamp"
263
+ if name == "object" or name == "string" or name.startswith("str"):
264
+ return "string"
265
+ if "category" in name:
266
+ return "string"
267
+ return "string"