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"
|