equiframe 0.12.4__py3-none-any.whl

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.
Files changed (170) hide show
  1. equiframe/__init__.py +832 -0
  2. equiframe/__main__.py +3 -0
  3. equiframe/_base.py +84 -0
  4. equiframe/_cli_support.py +125 -0
  5. equiframe/_comparison_io.py +161 -0
  6. equiframe/_dataset_provenance.py +146 -0
  7. equiframe/_decimal.py +22 -0
  8. equiframe/_diff_capture.py +433 -0
  9. equiframe/_doctor.py +123 -0
  10. equiframe/_document_io.py +61 -0
  11. equiframe/_documents.py +79 -0
  12. equiframe/_duckdb_transport.py +149 -0
  13. equiframe/_error_reporting.py +52 -0
  14. equiframe/_first_use.py +102 -0
  15. equiframe/_path_compat.py +26 -0
  16. equiframe/_physical.py +133 -0
  17. equiframe/_r_review.R +308 -0
  18. equiframe/_regular_io.py +167 -0
  19. equiframe/_report_format_v1.py +298 -0
  20. equiframe/_report_io.py +591 -0
  21. equiframe/_stability_inventory.py +1421 -0
  22. equiframe/_timezones.py +257 -0
  23. equiframe/_vendor/cyclonedx/LICENSE +201 -0
  24. equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
  25. equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
  26. equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
  27. equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
  28. equiframe/adapters.py +454 -0
  29. equiframe/assertion_types.py +122 -0
  30. equiframe/assertions.py +271 -0
  31. equiframe/benchmark.py +101 -0
  32. equiframe/bundle.py +240 -0
  33. equiframe/cli.py +693 -0
  34. equiframe/comparison.py +937 -0
  35. equiframe/comparison_report.py +660 -0
  36. equiframe/comparison_types.py +257 -0
  37. equiframe/compatibility.py +546 -0
  38. equiframe/contracts.py +879 -0
  39. equiframe/datasets.py +1301 -0
  40. equiframe/decision.py +34 -0
  41. equiframe/destinations.py +295 -0
  42. equiframe/detector_types.py +431 -0
  43. equiframe/detectors.py +389 -0
  44. equiframe/diff.py +192 -0
  45. equiframe/diff_contracts.py +523 -0
  46. equiframe/docs/adoption.md +329 -0
  47. equiframe/docs/api.md +534 -0
  48. equiframe/docs/arquitecto.md +143 -0
  49. equiframe/docs/choosing-api.md +84 -0
  50. equiframe/docs/cientifico.md +180 -0
  51. equiframe/docs/compatibility.md +254 -0
  52. equiframe/docs/contributing.md +171 -0
  53. equiframe/docs/dependencies.md +237 -0
  54. equiframe/docs/eda.md +166 -0
  55. equiframe/docs/extending.md +158 -0
  56. equiframe/docs/fast-ci.md +52 -0
  57. equiframe/docs/first-use.md +78 -0
  58. equiframe/docs/index.md +142 -0
  59. equiframe/docs/ingestion.md +145 -0
  60. equiframe/docs/inicio.md +129 -0
  61. equiframe/docs/installed-private.md +97 -0
  62. equiframe/docs/journeys.md +362 -0
  63. equiframe/docs/lectura-conversion.md +211 -0
  64. equiframe/docs/licensing.md +17 -0
  65. equiframe/docs/maintenance.md +233 -0
  66. equiframe/docs/migration-ir.md +333 -0
  67. equiframe/docs/option-audit.md +45 -0
  68. equiframe/docs/outcome-gates.md +134 -0
  69. equiframe/docs/performance.md +263 -0
  70. equiframe/docs/piloto-simulado.md +56 -0
  71. equiframe/docs/quickstart.md +156 -0
  72. equiframe/docs/r_review.md +212 -0
  73. equiframe/docs/recipes.md +248 -0
  74. equiframe/docs/releasing.md +183 -0
  75. equiframe/docs/security.md +218 -0
  76. equiframe/docs/semantics.md +274 -0
  77. equiframe/docs/sqlite-snapshots.md +262 -0
  78. equiframe/docs/universal-sources.md +49 -0
  79. equiframe/docs/universalidad-cinco-pasos.md +107 -0
  80. equiframe/docs/upgrading.md +538 -0
  81. equiframe/duckdb_capture.py +410 -0
  82. equiframe/eda/__init__.py +36 -0
  83. equiframe/eda/__main__.py +3 -0
  84. equiframe/eda/_backend.py +612 -0
  85. equiframe/eda/_inventory.py +49 -0
  86. equiframe/eda/_paths.py +49 -0
  87. equiframe/eda/_report.py +291 -0
  88. equiframe/eda/api.py +535 -0
  89. equiframe/eda/cli.py +102 -0
  90. equiframe/eda/models.py +239 -0
  91. equiframe/errors.py +83 -0
  92. equiframe/evidence.py +375 -0
  93. equiframe/examples/integrations/eda_generic.py +60 -0
  94. equiframe/examples/integrations/outcome_gates.py +94 -0
  95. equiframe/examples/integrations/sqlite_snapshot.py +246 -0
  96. equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
  97. equiframe/examples/integrations/universal_first_use.py +296 -0
  98. equiframe/examples/journeys/README.md +31 -0
  99. equiframe/examples/journeys/__init__.py +1 -0
  100. equiframe/examples/journeys/a_pandas_refactor.py +88 -0
  101. equiframe/examples/journeys/b_native_migration.py +181 -0
  102. equiframe/examples/journeys/c_retail_etl.py +460 -0
  103. equiframe/examples/journeys/common.py +136 -0
  104. equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
  105. equiframe/examples/journeys/e_telemetry.py +135 -0
  106. equiframe/examples/journeys/f_data_science_preparation.py +84 -0
  107. equiframe/examples/journeys/g_external_snapshots.py +197 -0
  108. equiframe/examples/journeys/h_portable_ci.py +129 -0
  109. equiframe/examples/journeys/requirements.lock +249 -0
  110. equiframe/examples/journeys/verify_journeys.py +117 -0
  111. equiframe/examples/load_convert_compare.py +85 -0
  112. equiframe/execution.py +355 -0
  113. equiframe/execution_types.py +177 -0
  114. equiframe/ingestion.py +744 -0
  115. equiframe/local.py +126 -0
  116. equiframe/migration/__init__.py +94 -0
  117. equiframe/migration/_physical.py +8 -0
  118. equiframe/migration/backends/_timestamp_transport.py +109 -0
  119. equiframe/migration/backends/duckdb.py +651 -0
  120. equiframe/migration/backends/duckdb_decimal.py +125 -0
  121. equiframe/migration/backends/duckdb_time.py +87 -0
  122. equiframe/migration/backends/pandas.py +544 -0
  123. equiframe/migration/backends/polars.py +915 -0
  124. equiframe/migration/backends/polars_decimal.py +231 -0
  125. equiframe/migration/io.py +198 -0
  126. equiframe/migration/ports.py +96 -0
  127. equiframe/migration/runner.py +545 -0
  128. equiframe/migration/runner_types.py +405 -0
  129. equiframe/migration/types.py +354 -0
  130. equiframe/migration/validation.py +619 -0
  131. equiframe/normalization.py +358 -0
  132. equiframe/pipeline.py +274 -0
  133. equiframe/pipeline_types.py +247 -0
  134. equiframe/ports.py +39 -0
  135. equiframe/preprocessing.py +110 -0
  136. equiframe/profile_types.py +343 -0
  137. equiframe/profiles.py +447 -0
  138. equiframe/py.typed +0 -0
  139. equiframe/r_review.py +706 -0
  140. equiframe/reduction.py +518 -0
  141. equiframe/registry.py +121 -0
  142. equiframe/report_rendering.py +366 -0
  143. equiframe/report_types.py +294 -0
  144. equiframe/reporting.py +26 -0
  145. equiframe/rule_types.py +178 -0
  146. equiframe/rules.py +819 -0
  147. equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
  148. equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
  149. equiframe/schemas/r-review-options-1.0.schema.json +38 -0
  150. equiframe/schemas/r-review-result-1.0.schema.json +207 -0
  151. equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
  152. equiframe/serialization.py +152 -0
  153. equiframe/snapshot.py +296 -0
  154. equiframe/sources.py +283 -0
  155. equiframe/tables.py +459 -0
  156. equiframe/types.py +71 -0
  157. equiframe/values.py +252 -0
  158. equiframe/verification_request.py +35 -0
  159. equiframe/verify.py +799 -0
  160. equiframe/verify_types.py +255 -0
  161. equiframe/workflow.py +510 -0
  162. equiframe/workflow_request.py +38 -0
  163. equiframe-0.12.4.dist-info/METADATA +268 -0
  164. equiframe-0.12.4.dist-info/RECORD +170 -0
  165. equiframe-0.12.4.dist-info/WHEEL +4 -0
  166. equiframe-0.12.4.dist-info/entry_points.txt +3 -0
  167. equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
  168. equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
  169. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
  170. equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/PROVENANCE.md +18 -0
@@ -0,0 +1,544 @@
1
+ """Pandas compiler: callbacks operate on native DataFrames, never another engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from decimal import ROUND_HALF_EVEN, ROUND_HALF_UP, Decimal, Inexact, localcontext
7
+ from typing import TYPE_CHECKING, Any, Literal, cast
8
+
9
+ from equiframe._decimal import exact_decimal_context
10
+ from equiframe.contracts import (
11
+ BooleanType,
12
+ Contract,
13
+ DateType,
14
+ DecimalType,
15
+ FloatType,
16
+ IntegerType,
17
+ LogicalType,
18
+ StringType,
19
+ TimestampType,
20
+ )
21
+ from equiframe.errors import CapabilityError
22
+ from equiframe.registry import PandasTransformation
23
+
24
+ from ..ports import CompiledPlan, compiler_check, require_supported
25
+ from ..types import (
26
+ Aggregate,
27
+ Arithmetic,
28
+ Boolean,
29
+ Cast,
30
+ Coalesce,
31
+ Column,
32
+ Compare,
33
+ CompilationCheck,
34
+ Concatenate,
35
+ Conditional,
36
+ DecimalRound,
37
+ Deduplicate,
38
+ EquiJoin,
39
+ Expression,
40
+ Filter,
41
+ IRColumn,
42
+ IsNaN,
43
+ IsNull,
44
+ LiteralExpression,
45
+ LocalizeTimestamp,
46
+ Not,
47
+ Project,
48
+ RelationalPlan,
49
+ Scan,
50
+ Window,
51
+ )
52
+ from ..validation import (
53
+ ValidatedIR,
54
+ compilation_identity,
55
+ decimal_limit,
56
+ expression_type,
57
+ integer_bounds,
58
+ literal_scalar,
59
+ )
60
+
61
+ if TYPE_CHECKING:
62
+ import pandas as pd
63
+
64
+ from equiframe.execution import RunContext
65
+
66
+ _COMPILER_VERSION = "migration-native-ir1.0/pandas/product-semantics-v4"
67
+
68
+
69
+ def _dtype(dtype: LogicalType) -> Any:
70
+ import pandas as pd
71
+ import pyarrow as pa
72
+
73
+ if isinstance(dtype, IntegerType):
74
+ arrow = getattr(pa, ("int" if dtype.signed else "uint") + str(dtype.bits))()
75
+ elif isinstance(dtype, DecimalType):
76
+ arrow = pa.decimal128(dtype.precision, dtype.scale)
77
+ elif isinstance(dtype, FloatType):
78
+ arrow = pa.float32() if dtype.bits == 32 else pa.float64()
79
+ elif isinstance(dtype, BooleanType):
80
+ arrow = pa.bool_()
81
+ elif isinstance(dtype, StringType):
82
+ arrow = pa.string()
83
+ elif isinstance(dtype, DateType):
84
+ arrow = pa.date32()
85
+ elif isinstance(dtype, TimestampType):
86
+ arrow = pa.timestamp(dtype.unit, tz=dtype.timezone)
87
+ else:
88
+ raise CapabilityError("unsupported Pandas IR type")
89
+ return pd.ArrowDtype(arrow)
90
+
91
+
92
+ def _expression(expression: Expression, frame: pd.DataFrame, columns: Any) -> pd.Series[Any]:
93
+ import pandas as pd
94
+
95
+ if isinstance(expression, Column):
96
+ return frame[expression.name]
97
+ if isinstance(expression, LiteralExpression):
98
+ import pyarrow as pa
99
+
100
+ scalar = literal_scalar(expression)
101
+ dtype = _dtype(expression.logical_type)
102
+ literal_series: pd.Series[Any] = pd.Series(
103
+ # pandas 2.2 stubs omit this documented runtime array class.
104
+ cast(Any, pd.arrays).ArrowExtensionArray(
105
+ pa.array([scalar] * len(frame), type=dtype.pyarrow_dtype, from_pandas=False)
106
+ ),
107
+ index=frame.index,
108
+ )
109
+ return literal_series
110
+ if isinstance(expression, Arithmetic):
111
+ left = _expression(expression.left, frame, columns).astype(object)
112
+ right = _expression(expression.right, frame, columns).astype(object)
113
+ operation = {"add": "add", "subtract": "sub", "multiply": "mul", "divide": "truediv"}[
114
+ expression.operation
115
+ ]
116
+ with localcontext(exact_decimal_context()) as decimal_context:
117
+ if expression.operation == "divide" and expression.rounding == "exact":
118
+ decimal_context.traps[Inexact] = True
119
+ result = cast("pd.Series[Any]", getattr(left, operation)(right))
120
+ if expression.operation == "divide":
121
+ if not (isinstance(expression.logical_type, DecimalType)):
122
+ raise AssertionError("Internal invariant check failed.")
123
+ quantum = Decimal(1).scaleb(-expression.logical_type.scale)
124
+ rounding = (
125
+ ROUND_HALF_UP
126
+ if expression.rounding == "half_away_from_zero"
127
+ else ROUND_HALF_EVEN
128
+ )
129
+ result = result.map(
130
+ lambda value: (
131
+ value if pd.isna(value) else value.quantize(quantum, rounding=rounding)
132
+ )
133
+ )
134
+ # Every expression has its own declared range. Checking only the final
135
+ # projected column lets a later subtraction or predicate conceal overflow.
136
+ return _checked_series(result, expression_type(expression, columns))
137
+ if isinstance(expression, DecimalRound):
138
+ argument = _expression(expression.argument, frame, columns).astype(object)
139
+ with localcontext(exact_decimal_context()):
140
+ quantum = Decimal(1).scaleb(-expression.logical_type.scale)
141
+ rounding = (
142
+ ROUND_HALF_UP if expression.rounding == "half_away_from_zero" else ROUND_HALF_EVEN
143
+ )
144
+ result = argument.map(
145
+ lambda value: (
146
+ value if pd.isna(value) else value.quantize(quantum, rounding=rounding)
147
+ )
148
+ )
149
+ return _checked_series(result, expression_type(expression, columns))
150
+ if isinstance(expression, LocalizeTimestamp):
151
+ from equiframe._timezones import timezone
152
+
153
+ dtype = expression_type(expression.argument, columns).logical_type
154
+ if not (isinstance(dtype, TimestampType)):
155
+ raise AssertionError("Internal invariant check failed.")
156
+ argument = _expression(expression.argument, frame, columns)
157
+ before = argument.astype(_dtype(IntegerType(bits=64, signed=True)))
158
+ if before.eq(-(2**63)).fillna(False).any():
159
+ raise CapabilityError("Pandas LocalizeTimestamp cannot preserve int64-min NaT sentinel")
160
+ native_dtype: Any = f"datetime64[{dtype.unit}]"
161
+ naive_values = argument.astype(native_dtype)
162
+ zone = timezone(expression.timezone)
163
+ if expression.ambiguous == "reject":
164
+ localized = naive_values.dt.tz_localize(zone, ambiguous="raise", nonexistent="raise")
165
+ else:
166
+ import numpy as np
167
+
168
+ first = naive_values.dt.tz_localize(
169
+ zone, ambiguous=np.ones(len(naive_values), dtype=bool), nonexistent="raise"
170
+ )
171
+ second = naive_values.dt.tz_localize(
172
+ zone, ambiguous=np.zeros(len(naive_values), dtype=bool), nonexistent="raise"
173
+ )
174
+ choose_first = (
175
+ first.le(second) if expression.ambiguous == "earlier" else first.ge(second)
176
+ )
177
+ localized = first.where(choose_first, second)
178
+ localized = _checked_series(localized, expression_type(expression, columns))
179
+ after = localized.astype(_dtype(IntegerType(bits=64, signed=True)))
180
+ # A local-to-UTC offset is less than one day. Compare in unbounded
181
+ # Python integers, because subtracting native int64 ticks can itself
182
+ # wrap and hide a timezone kernel overflow across centuries.
183
+ maximum_offset = 86400 * {"s": 1, "ms": 1000, "us": 1000000, "ns": 1000000000}[dtype.unit]
184
+ delta = (after.astype(object) - before.astype(object)).abs()
185
+ if (before.notna() & after.isna()).any() or delta.gt(maximum_offset).fillna(False).any():
186
+ raise CapabilityError("Pandas timestamp localization exceeds faithful int64 range")
187
+ return localized
188
+ if isinstance(expression, Coalesce):
189
+ result = _expression(expression.arguments[0], frame, columns)
190
+ for item in expression.arguments[1:]:
191
+ replacement = _expression(item, frame, columns)
192
+ result = result.where(result.notna(), replacement)
193
+ return result
194
+ if isinstance(expression, Conditional):
195
+ predicate = _expression(expression.predicate, frame, columns).fillna(False)
196
+ left = _expression(expression.then, frame, columns)
197
+ right = _expression(expression.otherwise, frame, columns)
198
+ return left.where(predicate, right)
199
+ if isinstance(expression, Compare):
200
+ left = _expression(expression.left, frame, columns)
201
+ right = _expression(expression.right, frame, columns)
202
+ result = getattr(left, expression.operation)(right)
203
+ if isinstance(expression_type(expression.left, columns).logical_type, FloatType):
204
+ # NaN is an unordered value; NULL remains SQL unknown.
205
+ nan = (left != left).fillna(False) | (right != right).fillna(False)
206
+ result = result.mask(nan, expression.operation == "ne")
207
+ return cast(
208
+ "pd.Series[Any]",
209
+ result.mask(left.isna() | right.isna(), pd.NA).astype(_dtype(BooleanType())),
210
+ )
211
+ if isinstance(expression, Boolean):
212
+ values = [
213
+ _expression(item, frame, columns).astype(_dtype(BooleanType()))
214
+ for item in expression.arguments
215
+ ]
216
+ result = values[0]
217
+ for value in values[1:]:
218
+ result = result & value if expression.operation == "and" else result | value
219
+ return cast("pd.Series[Any]", result)
220
+ argument = _expression(expression.argument, frame, columns)
221
+ if isinstance(expression, Not):
222
+ inverted: pd.Series[Any] = ~argument.astype(_dtype(BooleanType()))
223
+ return inverted
224
+ if isinstance(expression, IsNull):
225
+ null_flags: pd.Series[Any] = argument.isna().astype(_dtype(BooleanType()))
226
+ return null_flags
227
+ if isinstance(expression, IsNaN):
228
+ nan_flags: pd.Series[Any] = (
229
+ (argument != argument).fillna(False).astype(_dtype(BooleanType()))
230
+ )
231
+ return nan_flags
232
+ raise CapabilityError("unknown Pandas expression")
233
+
234
+
235
+ def _checked_series(series: pd.Series[Any], column: IRColumn) -> pd.Series[Any]:
236
+ import pandas as pd
237
+
238
+ dtype = column.logical_type
239
+ if isinstance(dtype, (IntegerType, DecimalType)) and len(series):
240
+ values = series.dropna()
241
+ if len(values):
242
+ lower, upper = (
243
+ integer_bounds(dtype)
244
+ if isinstance(dtype, IntegerType)
245
+ else (decimal_limit(dtype).copy_negate(), decimal_limit(dtype))
246
+ )
247
+ if values.min() < lower or values.max() > upper:
248
+ raise CapabilityError("IR output exceeds declared numeric range: " + column.name)
249
+ if not column.nullable and series.isna().any():
250
+ raise CapabilityError("IR output contains forbidden NULL: " + column.name)
251
+ if series.dtype != _dtype(dtype):
252
+ converted = pd.Series(series, dtype=_dtype(dtype), index=series.index, copy=True)
253
+ if not isinstance(converted, pd.Series):
254
+ raise CapabilityError("Pandas numeric conversion did not return a Series")
255
+ return converted
256
+ return series
257
+
258
+
259
+ def _coerce(frame: pd.DataFrame, validated: ValidatedIR, node_id: str) -> pd.DataFrame:
260
+ for column in validated.schema(node_id):
261
+ original = frame[column.name]
262
+ checked = _checked_series(original, column)
263
+ if checked is not original:
264
+ frame[column.name] = checked
265
+ return frame.reset_index(drop=True)
266
+
267
+
268
+ def _aggregate(frame: pd.DataFrame, node: Aggregate, validated: ValidatedIR) -> pd.DataFrame:
269
+ import pandas as pd
270
+
271
+ result = []
272
+ group = (
273
+ frame.groupby(list(node.group_by), dropna=node.null_groups == "drop", sort=False)
274
+ if node.group_by
275
+ else None
276
+ )
277
+ for measure in node.measures:
278
+ value: pd.Series[Any]
279
+ if measure.operation == "count_rows":
280
+ value = group.size() if group is not None else pd.Series([len(frame)])
281
+ else:
282
+ if not (measure.column is not None):
283
+ raise AssertionError("Internal invariant check failed.")
284
+ series = frame[measure.column]
285
+ if measure.operation == "count_valid":
286
+ value = (
287
+ group[measure.column].count()
288
+ if group is not None
289
+ else pd.Series([series.count()])
290
+ )
291
+ else:
292
+ # Object integers/Decimal avoid fixed-width Arrow groupby wraparound.
293
+ native = series.astype(object)
294
+ grouping = (
295
+ native.groupby(
296
+ [frame[key] for key in node.group_by],
297
+ dropna=node.null_groups == "drop",
298
+ sort=False,
299
+ )
300
+ if group is not None
301
+ else None
302
+ )
303
+ with localcontext(exact_decimal_context()):
304
+ if measure.operation == "sum":
305
+ value = (
306
+ grouping.sum(min_count=1)
307
+ if grouping is not None
308
+ else pd.Series([native.sum(min_count=1)], dtype=object)
309
+ )
310
+ count = (
311
+ group[measure.column].count()
312
+ if group is not None
313
+ else pd.Series([series.count()])
314
+ )
315
+ zero: int | Decimal = 0
316
+ if isinstance(measure.logical_type, DecimalType):
317
+ zero = Decimal(0).quantize(Decimal(10) ** -measure.logical_type.scale)
318
+ replacement = pd.Series(
319
+ [None if node.empty_sum == "null" else zero] * len(value),
320
+ index=value.index,
321
+ dtype=object,
322
+ )
323
+ value = value.mask(count == 0, replacement)
324
+ else:
325
+ value = (
326
+ getattr(grouping, measure.operation)()
327
+ if grouping is not None
328
+ else pd.Series([getattr(series, measure.operation)()], dtype=object)
329
+ )
330
+ result.append(value.rename(measure.name))
331
+ frame = pd.concat(result, axis=1)
332
+ if node.group_by:
333
+ frame = frame.reset_index()
334
+ return _coerce(frame, validated, node.node_id)
335
+
336
+
337
+ class PandasCompiler:
338
+ engine = "pandas"
339
+
340
+ def check(self, plan: RelationalPlan, contract: Contract) -> CompilationCheck:
341
+ return compiler_check(self.engine, plan, contract)
342
+
343
+ def compile(self, plan: RelationalPlan, contract: Contract) -> CompiledPlan:
344
+ validated = require_supported(self.engine, plan, contract)
345
+ identifier, ir_sha, contract_sha = compilation_identity(
346
+ validated, self.engine, implementation_version=_COMPILER_VERSION
347
+ )
348
+
349
+ def transform(tables: Mapping[str, pd.DataFrame], context: RunContext) -> pd.DataFrame:
350
+ import pandas as pd
351
+
352
+ frames: dict[str, pd.DataFrame] = {}
353
+ for node in plan.nodes:
354
+ context.check()
355
+ if isinstance(node, Scan):
356
+ frame = tables[node.table].copy(deep=True).reset_index(drop=True)
357
+ elif isinstance(node, Project):
358
+ source = frames[node.source]
359
+ frame = pd.DataFrame(
360
+ {
361
+ item.name: _expression(
362
+ item.expression, source, validated.schema(node.source)
363
+ )
364
+ for item in node.columns
365
+ },
366
+ index=source.index,
367
+ )
368
+ elif isinstance(node, Filter):
369
+ source = frames[node.source]
370
+ frame = source.loc[
371
+ _expression(node.predicate, source, validated.schema(node.source)).fillna(
372
+ False
373
+ )
374
+ ].copy()
375
+ elif isinstance(node, Cast):
376
+ frame = frames[node.source].copy(deep=True)
377
+ elif isinstance(node, EquiJoin):
378
+ left = frames[node.left]
379
+ right = frames[node.right]
380
+ for side, names, unique in (
381
+ (
382
+ left,
383
+ [key.left for key in node.keys],
384
+ node.cardinality in ("one_to_one", "one_to_many"),
385
+ ),
386
+ (
387
+ right,
388
+ [key.right for key in node.keys],
389
+ node.cardinality in ("one_to_one", "many_to_one"),
390
+ ),
391
+ ):
392
+ matchable = (
393
+ side
394
+ if node.null_keys == "equal"
395
+ else side.loc[side[names].notna().all(axis=1)]
396
+ )
397
+ if unique and matchable.duplicated(subset=names).any():
398
+ raise CapabilityError(
399
+ "Join violates declared cardinality: " + node.cardinality
400
+ )
401
+ if node.how in ("semi", "anti"):
402
+ keys_left = [key.left for key in node.keys]
403
+ keys_right = [key.right for key in node.keys]
404
+ keys = right[keys_right].drop_duplicates()
405
+ if node.null_keys == "never":
406
+ keys = keys.loc[keys.notna().all(axis=1)]
407
+ keys = keys.rename(columns=dict(zip(keys_right, keys_left, strict=True)))
408
+ matches = left[keys_left].merge(
409
+ keys, how="left", on=keys_left, indicator=True, sort=False
410
+ )
411
+ mask = matches["_merge"].eq("both").to_numpy()
412
+ frame = left.loc[mask if node.how == "semi" else ~mask].copy()
413
+ frames[node.node_id] = _coerce(frame, validated, node.node_id)
414
+ continue
415
+ incomplete_left = left.iloc[:0]
416
+ incomplete_right = right.iloc[:0]
417
+ if node.null_keys == "never":
418
+ left_complete = left[[key.left for key in node.keys]].notna().all(axis=1)
419
+ right_complete = right[[key.right for key in node.keys]].notna().all(axis=1)
420
+ incomplete_left, incomplete_right = (
421
+ left.loc[~left_complete],
422
+ right.loc[~right_complete],
423
+ )
424
+ left, right = left.loc[left_complete], right.loc[right_complete]
425
+ right = right.rename(
426
+ columns={name: node.right_prefix + name for name in right.columns}
427
+ )
428
+ if left.empty and right.empty:
429
+ # Captured empty Arrow columns can have zero chunks.
430
+ # pandas full-join factorization cannot infer their type;
431
+ # a native typed empty concatenation is the exact join.
432
+ frame = pd.concat([left, right], axis=1)
433
+ else:
434
+ frame = left.merge(
435
+ right,
436
+ how=cast(
437
+ 'Literal["inner", "left", "right", "outer"]',
438
+ "outer" if node.how == "full" else node.how,
439
+ ),
440
+ left_on=[key.left for key in node.keys],
441
+ right_on=[node.right_prefix + key.right for key in node.keys],
442
+ sort=False,
443
+ )
444
+ extras = []
445
+ if node.how in ("left", "full") and len(incomplete_left):
446
+ extras.append(incomplete_left)
447
+ if node.how in ("right", "full") and len(incomplete_right):
448
+ extras.append(
449
+ incomplete_right.rename(
450
+ columns={
451
+ name: node.right_prefix + name
452
+ for name in incomplete_right.columns
453
+ }
454
+ )
455
+ )
456
+ for extra in extras:
457
+ extra = extra.copy()
458
+ for column in validated.schema(node.node_id):
459
+ if column.name not in extra:
460
+ extra[column.name] = pd.Series(
461
+ pd.NA, index=extra.index, dtype=_dtype(column.logical_type)
462
+ )
463
+ frame = pd.concat([frame, extra[frame.columns]], ignore_index=True)
464
+ elif isinstance(node, Window):
465
+ frame = frames[node.source].copy(deep=True)
466
+ tie_keys = list(
467
+ dict.fromkeys((*node.partition_by, *(key.column for key in node.by)))
468
+ )
469
+ if frame.duplicated(subset=tie_keys).any():
470
+ raise CapabilityError("Window encountered forbidden order ties")
471
+ for key in reversed(node.by):
472
+ frame = frame.sort_values(
473
+ key.column,
474
+ ascending=key.direction == "ascending",
475
+ na_position=key.nulls,
476
+ kind="stable",
477
+ )
478
+ groups = (
479
+ frame.groupby(list(node.partition_by), dropna=False, sort=False)
480
+ if node.partition_by
481
+ else None
482
+ )
483
+ for window_measure in node.measures:
484
+ if window_measure.operation == "row_number":
485
+ value = (
486
+ groups.cumcount() + 1
487
+ if groups is not None
488
+ else pd.Series(range(1, len(frame) + 1), index=frame.index)
489
+ )
490
+ else:
491
+ if window_measure.column is None:
492
+ raise CapabilityError("lag/lead require an input column")
493
+ offset = (
494
+ window_measure.offset
495
+ if window_measure.operation == "lag"
496
+ else -window_measure.offset
497
+ )
498
+ value = (
499
+ groups[window_measure.column].shift(offset)
500
+ if groups is not None
501
+ else frame[window_measure.column].shift(offset)
502
+ )
503
+ frame[window_measure.name] = value
504
+ elif isinstance(node, Aggregate):
505
+ frame = _aggregate(frames[node.source], node, validated)
506
+ elif isinstance(node, Concatenate):
507
+ frame = pd.concat([frames[ref] for ref in node.sources], ignore_index=True)
508
+ elif isinstance(node, Deduplicate):
509
+ frame = frames[node.source]
510
+ if node.keep == "first":
511
+ tie_keys = list(
512
+ dict.fromkeys((*node.keys, *(key.column for key in node.by)))
513
+ )
514
+ if frame.duplicated(subset=tie_keys).any():
515
+ raise CapabilityError("Deduplicate encountered forbidden tiebreak ties")
516
+ for key in reversed(node.by):
517
+ frame = frame.sort_values(
518
+ key.column,
519
+ ascending=key.direction == "ascending",
520
+ na_position=key.nulls,
521
+ kind="stable",
522
+ )
523
+ frame = frame.drop_duplicates(subset=list(node.keys), keep="first").copy()
524
+ else:
525
+ frame = frames[node.source].copy(deep=True)
526
+ if (
527
+ node.ties == "reject"
528
+ and frame.duplicated(subset=[key.column for key in node.by]).any()
529
+ ):
530
+ raise CapabilityError("Sort encountered forbidden ties")
531
+ for key in reversed(node.by):
532
+ frame = frame.sort_values(
533
+ key.column,
534
+ ascending=key.direction == "ascending",
535
+ na_position=key.nulls,
536
+ kind="stable",
537
+ )
538
+ frames[node.node_id] = _coerce(frame, validated, node.node_id)
539
+ return frames[plan.output]
540
+
541
+ transformation = PandasTransformation(identifier, transform, _COMPILER_VERSION)
542
+ return CompiledPlan(
543
+ self.engine, transformation, ir_sha, contract_sha, self.check(plan, contract)
544
+ )