pipelantic-polars 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,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pipelantic-polars
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Polars 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: pipelantic<0.6,>=0.5.0
|
|
9
|
+
Requires-Dist: polars<2,>=1.0
|
|
10
|
+
Provides-Extra: arrow
|
|
11
|
+
Requires-Dist: pyarrow>=14; extra == 'arrow'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# pipelantic-polars
|
|
15
|
+
|
|
16
|
+
Polars reference dataframe plugin for [Pipelantic](https://github.com/eddiethedean/pipelantic).
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install pipelantic-polars
|
|
20
|
+
# optional Arrow interchange
|
|
21
|
+
pip install 'pipelantic-polars[arrow]'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Supports eager `DataFrame` execution and `LazyFrame` preservation until an
|
|
25
|
+
explicit collection boundary declared in the `PipelinePlan`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# pipelantic-polars
|
|
2
|
+
|
|
3
|
+
Polars reference dataframe plugin for [Pipelantic](https://github.com/eddiethedean/pipelantic).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install pipelantic-polars
|
|
7
|
+
# optional Arrow interchange
|
|
8
|
+
pip install 'pipelantic-polars[arrow]'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Supports eager `DataFrame` execution and `LazyFrame` preservation until an
|
|
12
|
+
explicit collection boundary declared in the `PipelinePlan`.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pipelantic-polars"
|
|
3
|
+
version = "0.5.0"
|
|
4
|
+
description = "Polars 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
|
+
"polars>=1.0,<2",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
arrow = ["pyarrow>=14"]
|
|
16
|
+
|
|
17
|
+
[project.entry-points."pipelantic.dataframe_plugins"]
|
|
18
|
+
polars = "pipelantic_polars: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_polars"]
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Polars 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 polars as pl
|
|
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
|
+
logical_type_from_annotation,
|
|
14
|
+
schema_dict,
|
|
15
|
+
schema_from_contract,
|
|
16
|
+
split_valid_invalid_records,
|
|
17
|
+
)
|
|
18
|
+
from pipelantic.dataframe.protocol import (
|
|
19
|
+
DATAFRAME_PROTOCOL_VERSION,
|
|
20
|
+
ArtifactOwnership,
|
|
21
|
+
DataframeExecutionContext,
|
|
22
|
+
DataframeMetrics,
|
|
23
|
+
DataframeOutputBundle,
|
|
24
|
+
DataframePluginInfo,
|
|
25
|
+
DataframeValidationOutcome,
|
|
26
|
+
ValidationDecision,
|
|
27
|
+
)
|
|
28
|
+
from pipelantic.storage.protocol import as_records, records_to_dicts
|
|
29
|
+
|
|
30
|
+
__version__ = "0.5.0"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_plugin() -> PolarsDataframePlugin:
|
|
34
|
+
"""Entry-point factory."""
|
|
35
|
+
return PolarsDataframePlugin()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PolarsDataframePlugin:
|
|
39
|
+
"""Reference Polars dataframe execution plugin."""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
caps = PluginCapabilities(
|
|
43
|
+
engine="polars",
|
|
44
|
+
async_execution=True,
|
|
45
|
+
dataframe=True,
|
|
46
|
+
eager=True,
|
|
47
|
+
lazy=True,
|
|
48
|
+
arrow_import=arrow_available(),
|
|
49
|
+
arrow_export=arrow_available(),
|
|
50
|
+
zero_copy=False,
|
|
51
|
+
schema_inspection=True,
|
|
52
|
+
invalid_row_separation=True,
|
|
53
|
+
cancellation=False,
|
|
54
|
+
thread_safe=False,
|
|
55
|
+
extras=frozenset({"polars"}),
|
|
56
|
+
)
|
|
57
|
+
self._info = DataframePluginInfo(
|
|
58
|
+
name="pipelantic-polars",
|
|
59
|
+
engine="polars",
|
|
60
|
+
version=__version__,
|
|
61
|
+
protocol_version=DATAFRAME_PROTOCOL_VERSION,
|
|
62
|
+
capabilities=caps,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def info(self) -> DataframePluginInfo:
|
|
67
|
+
return self._info
|
|
68
|
+
|
|
69
|
+
def materialize_input(
|
|
70
|
+
self,
|
|
71
|
+
value: Any,
|
|
72
|
+
*,
|
|
73
|
+
contract_type: type[Any] | None,
|
|
74
|
+
context: DataframeExecutionContext,
|
|
75
|
+
port_name: str,
|
|
76
|
+
) -> Any:
|
|
77
|
+
if isinstance(value, (pl.DataFrame, pl.LazyFrame)):
|
|
78
|
+
return value
|
|
79
|
+
table = to_arrow_table(value)
|
|
80
|
+
if table is not None:
|
|
81
|
+
return pl.from_arrow(table)
|
|
82
|
+
records = as_records(value, None)
|
|
83
|
+
rows = records_to_dicts(records)
|
|
84
|
+
if not rows:
|
|
85
|
+
schema = self._empty_schema(contract_type)
|
|
86
|
+
return pl.DataFrame(schema=schema)
|
|
87
|
+
return pl.DataFrame(rows)
|
|
88
|
+
|
|
89
|
+
def invoke(
|
|
90
|
+
self,
|
|
91
|
+
*,
|
|
92
|
+
callable_: Any,
|
|
93
|
+
inputs: Mapping[str, Any],
|
|
94
|
+
parameters: Mapping[str, Any],
|
|
95
|
+
context: DataframeExecutionContext,
|
|
96
|
+
) -> Any:
|
|
97
|
+
# Inputs take precedence over parameters when names collide.
|
|
98
|
+
kwargs = {**dict(parameters), **dict(inputs)}
|
|
99
|
+
return callable_(**kwargs)
|
|
100
|
+
|
|
101
|
+
def normalize_output(
|
|
102
|
+
self,
|
|
103
|
+
result: Any,
|
|
104
|
+
*,
|
|
105
|
+
output_ports: tuple[str, ...],
|
|
106
|
+
context: DataframeExecutionContext,
|
|
107
|
+
) -> DataframeOutputBundle:
|
|
108
|
+
metrics = DataframeMetrics(converted=False)
|
|
109
|
+
if isinstance(result, dict) and any(p in result for p in output_ports):
|
|
110
|
+
valid = {k: result[k] for k in output_ports if k in result}
|
|
111
|
+
invalid = {
|
|
112
|
+
k: v
|
|
113
|
+
for k, v in result.items()
|
|
114
|
+
if k.endswith("_invalid") or k == "invalid"
|
|
115
|
+
}
|
|
116
|
+
side = {
|
|
117
|
+
k: v for k, v in result.items() if k not in valid and k not in invalid
|
|
118
|
+
}
|
|
119
|
+
return DataframeOutputBundle(
|
|
120
|
+
valid=valid,
|
|
121
|
+
invalid=invalid,
|
|
122
|
+
side=side,
|
|
123
|
+
metrics=metrics,
|
|
124
|
+
)
|
|
125
|
+
port = output_ports[0] if output_ports else "result"
|
|
126
|
+
return DataframeOutputBundle(valid={port: result}, metrics=metrics)
|
|
127
|
+
|
|
128
|
+
def validate_frame(
|
|
129
|
+
self,
|
|
130
|
+
value: Any,
|
|
131
|
+
*,
|
|
132
|
+
contract_type: type[Any] | None,
|
|
133
|
+
context: DataframeExecutionContext,
|
|
134
|
+
boundary: str,
|
|
135
|
+
port_name: str | None = None,
|
|
136
|
+
) -> tuple[Any, ValidationDecision, list[dict[str, Any]], Any | None]:
|
|
137
|
+
outcome = (
|
|
138
|
+
context.validation_policy.input_outcome
|
|
139
|
+
if boundary.startswith("input")
|
|
140
|
+
else context.validation_policy.output_outcome
|
|
141
|
+
)
|
|
142
|
+
if contract_type is None:
|
|
143
|
+
return value, ValidationDecision.SKIPPED, [], None
|
|
144
|
+
frame = value
|
|
145
|
+
if isinstance(frame, pl.LazyFrame):
|
|
146
|
+
schema = schema_from_contract(
|
|
147
|
+
contract_type, identity=contract_type.__name__
|
|
148
|
+
)
|
|
149
|
+
if schema is None:
|
|
150
|
+
return frame, ValidationDecision.SKIPPED, [], None
|
|
151
|
+
collected_schema = frame.collect_schema()
|
|
152
|
+
names = set(collected_schema.names())
|
|
153
|
+
diagnostics: list[dict[str, Any]] = []
|
|
154
|
+
missing = [
|
|
155
|
+
f.name for f in schema.fields if f.required and f.name not in names
|
|
156
|
+
]
|
|
157
|
+
if missing:
|
|
158
|
+
diagnostics.append(
|
|
159
|
+
{
|
|
160
|
+
"code": "PMDF411",
|
|
161
|
+
"message": f"Missing required columns: {missing}",
|
|
162
|
+
"severity": "error",
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
if outcome is DataframeValidationOutcome.FAIL:
|
|
166
|
+
return frame, ValidationDecision.FAILED, diagnostics, None
|
|
167
|
+
# Dtype checks without collecting rows.
|
|
168
|
+
for field in schema.fields:
|
|
169
|
+
if field.name not in collected_schema:
|
|
170
|
+
continue
|
|
171
|
+
expected = field.logical_type
|
|
172
|
+
actual = _polars_logical(collected_schema[field.name])
|
|
173
|
+
if not _logical_compatible(expected, actual):
|
|
174
|
+
diagnostics.append(
|
|
175
|
+
{
|
|
176
|
+
"code": "PMDF412",
|
|
177
|
+
"message": (
|
|
178
|
+
f"Column {field.name!r} logical type mismatch: "
|
|
179
|
+
f"expected {expected}, observed {actual}"
|
|
180
|
+
),
|
|
181
|
+
"severity": "error",
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
if diagnostics and outcome is DataframeValidationOutcome.FAIL:
|
|
185
|
+
return frame, ValidationDecision.FAILED, diagnostics, None
|
|
186
|
+
if diagnostics:
|
|
187
|
+
return frame, ValidationDecision.WARNED, diagnostics, None
|
|
188
|
+
return frame, ValidationDecision.PASSED, [], None
|
|
189
|
+
|
|
190
|
+
if not isinstance(frame, pl.DataFrame):
|
|
191
|
+
frame = self.materialize_input(
|
|
192
|
+
frame,
|
|
193
|
+
contract_type=contract_type,
|
|
194
|
+
context=context,
|
|
195
|
+
port_name=port_name or "value",
|
|
196
|
+
)
|
|
197
|
+
rows = frame.to_dicts()
|
|
198
|
+
valid, invalid, diagnostics = split_valid_invalid_records(
|
|
199
|
+
rows, contract_type=contract_type
|
|
200
|
+
)
|
|
201
|
+
if not invalid:
|
|
202
|
+
return frame, ValidationDecision.PASSED, [], None
|
|
203
|
+
if outcome is DataframeValidationOutcome.FAIL:
|
|
204
|
+
return frame, ValidationDecision.FAILED, diagnostics, None
|
|
205
|
+
if outcome is DataframeValidationOutcome.OBSERVE_ONLY:
|
|
206
|
+
return frame, ValidationDecision.OBSERVED, diagnostics, None
|
|
207
|
+
if outcome is DataframeValidationOutcome.WARN:
|
|
208
|
+
return frame, ValidationDecision.WARNED, diagnostics, None
|
|
209
|
+
valid_frame = pl.DataFrame(records_to_dicts(valid)) if valid else frame.clear()
|
|
210
|
+
invalid_frame = pl.DataFrame(records_to_dicts(invalid))
|
|
211
|
+
decision = (
|
|
212
|
+
ValidationDecision.QUARANTINED
|
|
213
|
+
if outcome is DataframeValidationOutcome.QUARANTINE
|
|
214
|
+
else ValidationDecision.REJECTED
|
|
215
|
+
)
|
|
216
|
+
return valid_frame, decision, diagnostics, invalid_frame
|
|
217
|
+
|
|
218
|
+
def inspect_schema(self, value: Any, *, identity: str) -> dict[str, Any] | None:
|
|
219
|
+
if isinstance(value, pl.LazyFrame):
|
|
220
|
+
schema = value.collect_schema()
|
|
221
|
+
fields = [
|
|
222
|
+
{
|
|
223
|
+
"name": name,
|
|
224
|
+
"logical_type": _polars_logical(dtype),
|
|
225
|
+
"required": True,
|
|
226
|
+
"nullable": True,
|
|
227
|
+
}
|
|
228
|
+
for name, dtype in schema.items()
|
|
229
|
+
]
|
|
230
|
+
from pipelantic.dataframe.helpers import normalized_from_field_dicts
|
|
231
|
+
|
|
232
|
+
return schema_dict(normalized_from_field_dicts(fields, identity=identity))
|
|
233
|
+
if isinstance(value, pl.DataFrame):
|
|
234
|
+
fields = [
|
|
235
|
+
{
|
|
236
|
+
"name": name,
|
|
237
|
+
"logical_type": _polars_logical(dtype),
|
|
238
|
+
"required": True,
|
|
239
|
+
"nullable": True,
|
|
240
|
+
}
|
|
241
|
+
for name, dtype in zip(value.columns, value.dtypes, strict=False)
|
|
242
|
+
]
|
|
243
|
+
from pipelantic.dataframe.helpers import normalized_from_field_dicts
|
|
244
|
+
|
|
245
|
+
return schema_dict(normalized_from_field_dicts(fields, identity=identity))
|
|
246
|
+
return schema_dict(schema_from_contract(None, identity=identity))
|
|
247
|
+
|
|
248
|
+
def ensure_ownership(
|
|
249
|
+
self,
|
|
250
|
+
value: Any,
|
|
251
|
+
*,
|
|
252
|
+
ownership: ArtifactOwnership,
|
|
253
|
+
context: DataframeExecutionContext,
|
|
254
|
+
) -> Any:
|
|
255
|
+
if ownership is ArtifactOwnership.COPIED and isinstance(value, pl.DataFrame):
|
|
256
|
+
return value.clone()
|
|
257
|
+
if ownership is ArtifactOwnership.COPIED and isinstance(value, pl.LazyFrame):
|
|
258
|
+
return value # immutable plan; cloning not required
|
|
259
|
+
return value
|
|
260
|
+
|
|
261
|
+
def collect_if_needed(
|
|
262
|
+
self,
|
|
263
|
+
value: Any,
|
|
264
|
+
*,
|
|
265
|
+
context: DataframeExecutionContext,
|
|
266
|
+
) -> Any:
|
|
267
|
+
if context.collect and isinstance(value, pl.LazyFrame):
|
|
268
|
+
return value.collect()
|
|
269
|
+
return value
|
|
270
|
+
|
|
271
|
+
def to_records(
|
|
272
|
+
self,
|
|
273
|
+
value: Any,
|
|
274
|
+
*,
|
|
275
|
+
contract_type: type[Any] | None,
|
|
276
|
+
) -> list[Any]:
|
|
277
|
+
if isinstance(value, pl.LazyFrame):
|
|
278
|
+
value = value.collect()
|
|
279
|
+
if isinstance(value, pl.DataFrame):
|
|
280
|
+
return as_records(value.to_dicts(), contract_type)
|
|
281
|
+
return as_records(value, contract_type)
|
|
282
|
+
|
|
283
|
+
def row_count(self, value: Any) -> int | None:
|
|
284
|
+
if isinstance(value, pl.DataFrame):
|
|
285
|
+
return value.height
|
|
286
|
+
if isinstance(value, pl.LazyFrame):
|
|
287
|
+
return None
|
|
288
|
+
if isinstance(value, list):
|
|
289
|
+
return len(value)
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
def _empty_schema(self, contract_type: type[Any] | None) -> dict[str, pl.DataType]:
|
|
293
|
+
if contract_type is None or not hasattr(contract_type, "model_fields"):
|
|
294
|
+
return {}
|
|
295
|
+
out: dict[str, pl.DataType] = {}
|
|
296
|
+
for name, field_info in contract_type.model_fields.items():
|
|
297
|
+
out[name] = _annotation_to_polars(field_info.annotation)
|
|
298
|
+
return out
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _annotation_to_polars(annotation: Any) -> pl.DataType:
|
|
302
|
+
logical = logical_type_from_annotation(annotation)
|
|
303
|
+
mapping = {
|
|
304
|
+
"string": pl.Utf8,
|
|
305
|
+
"integer": pl.Int64,
|
|
306
|
+
"number": pl.Float64,
|
|
307
|
+
"boolean": pl.Boolean,
|
|
308
|
+
"timestamp": pl.Datetime,
|
|
309
|
+
"date": pl.Date,
|
|
310
|
+
"decimal": pl.Decimal,
|
|
311
|
+
}
|
|
312
|
+
return mapping.get(logical, pl.Utf8)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _polars_logical(dtype: Any) -> str:
|
|
316
|
+
name = str(dtype)
|
|
317
|
+
if "Int" in name or "UInt" in name:
|
|
318
|
+
return "integer"
|
|
319
|
+
if "Float" in name:
|
|
320
|
+
return "number"
|
|
321
|
+
if "Bool" in name:
|
|
322
|
+
return "boolean"
|
|
323
|
+
if "Date" in name and "Time" not in name:
|
|
324
|
+
return "date"
|
|
325
|
+
if "Datetime" in name or "Timestamp" in name:
|
|
326
|
+
return "timestamp"
|
|
327
|
+
if "Decimal" in name:
|
|
328
|
+
return "decimal"
|
|
329
|
+
if "List" in name or "Array" in name:
|
|
330
|
+
return "array"
|
|
331
|
+
if "Struct" in name:
|
|
332
|
+
return "object"
|
|
333
|
+
return "string"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _logical_compatible(expected: str, actual: str) -> bool:
|
|
337
|
+
if expected == actual:
|
|
338
|
+
return True
|
|
339
|
+
# Soft compatibility: numbers accept integers; strings are a catch-all for Utf8.
|
|
340
|
+
if expected == "number" and actual == "integer":
|
|
341
|
+
return True
|
|
342
|
+
return expected == "string" and actual in {"string", "null"}
|