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