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.
- equiframe/__init__.py +832 -0
- equiframe/__main__.py +3 -0
- equiframe/_base.py +84 -0
- equiframe/_cli_support.py +125 -0
- equiframe/_comparison_io.py +161 -0
- equiframe/_dataset_provenance.py +146 -0
- equiframe/_decimal.py +22 -0
- equiframe/_diff_capture.py +433 -0
- equiframe/_doctor.py +123 -0
- equiframe/_document_io.py +61 -0
- equiframe/_documents.py +79 -0
- equiframe/_duckdb_transport.py +149 -0
- equiframe/_error_reporting.py +52 -0
- equiframe/_first_use.py +102 -0
- equiframe/_path_compat.py +26 -0
- equiframe/_physical.py +133 -0
- equiframe/_r_review.R +308 -0
- equiframe/_regular_io.py +167 -0
- equiframe/_report_format_v1.py +298 -0
- equiframe/_report_io.py +591 -0
- equiframe/_stability_inventory.py +1421 -0
- equiframe/_timezones.py +257 -0
- equiframe/_vendor/cyclonedx/LICENSE +201 -0
- equiframe/_vendor/cyclonedx/PROVENANCE.md +18 -0
- equiframe/_vendor/cyclonedx/bom-1.6.schema.json +5673 -0
- equiframe/_vendor/cyclonedx/jsf-0.82.schema.json +240 -0
- equiframe/_vendor/cyclonedx/spdx.schema.json +712 -0
- equiframe/adapters.py +454 -0
- equiframe/assertion_types.py +122 -0
- equiframe/assertions.py +271 -0
- equiframe/benchmark.py +101 -0
- equiframe/bundle.py +240 -0
- equiframe/cli.py +693 -0
- equiframe/comparison.py +937 -0
- equiframe/comparison_report.py +660 -0
- equiframe/comparison_types.py +257 -0
- equiframe/compatibility.py +546 -0
- equiframe/contracts.py +879 -0
- equiframe/datasets.py +1301 -0
- equiframe/decision.py +34 -0
- equiframe/destinations.py +295 -0
- equiframe/detector_types.py +431 -0
- equiframe/detectors.py +389 -0
- equiframe/diff.py +192 -0
- equiframe/diff_contracts.py +523 -0
- equiframe/docs/adoption.md +329 -0
- equiframe/docs/api.md +534 -0
- equiframe/docs/arquitecto.md +143 -0
- equiframe/docs/choosing-api.md +84 -0
- equiframe/docs/cientifico.md +180 -0
- equiframe/docs/compatibility.md +254 -0
- equiframe/docs/contributing.md +171 -0
- equiframe/docs/dependencies.md +237 -0
- equiframe/docs/eda.md +166 -0
- equiframe/docs/extending.md +158 -0
- equiframe/docs/fast-ci.md +52 -0
- equiframe/docs/first-use.md +78 -0
- equiframe/docs/index.md +142 -0
- equiframe/docs/ingestion.md +145 -0
- equiframe/docs/inicio.md +129 -0
- equiframe/docs/installed-private.md +97 -0
- equiframe/docs/journeys.md +362 -0
- equiframe/docs/lectura-conversion.md +211 -0
- equiframe/docs/licensing.md +17 -0
- equiframe/docs/maintenance.md +233 -0
- equiframe/docs/migration-ir.md +333 -0
- equiframe/docs/option-audit.md +45 -0
- equiframe/docs/outcome-gates.md +134 -0
- equiframe/docs/performance.md +263 -0
- equiframe/docs/piloto-simulado.md +56 -0
- equiframe/docs/quickstart.md +156 -0
- equiframe/docs/r_review.md +212 -0
- equiframe/docs/recipes.md +248 -0
- equiframe/docs/releasing.md +183 -0
- equiframe/docs/security.md +218 -0
- equiframe/docs/semantics.md +274 -0
- equiframe/docs/sqlite-snapshots.md +262 -0
- equiframe/docs/universal-sources.md +49 -0
- equiframe/docs/universalidad-cinco-pasos.md +107 -0
- equiframe/docs/upgrading.md +538 -0
- equiframe/duckdb_capture.py +410 -0
- equiframe/eda/__init__.py +36 -0
- equiframe/eda/__main__.py +3 -0
- equiframe/eda/_backend.py +612 -0
- equiframe/eda/_inventory.py +49 -0
- equiframe/eda/_paths.py +49 -0
- equiframe/eda/_report.py +291 -0
- equiframe/eda/api.py +535 -0
- equiframe/eda/cli.py +102 -0
- equiframe/eda/models.py +239 -0
- equiframe/errors.py +83 -0
- equiframe/evidence.py +375 -0
- equiframe/examples/integrations/eda_generic.py +60 -0
- equiframe/examples/integrations/outcome_gates.py +94 -0
- equiframe/examples/integrations/sqlite_snapshot.py +246 -0
- equiframe/examples/integrations/sqlite_snapshot_demo.py +226 -0
- equiframe/examples/integrations/universal_first_use.py +296 -0
- equiframe/examples/journeys/README.md +31 -0
- equiframe/examples/journeys/__init__.py +1 -0
- equiframe/examples/journeys/a_pandas_refactor.py +88 -0
- equiframe/examples/journeys/b_native_migration.py +181 -0
- equiframe/examples/journeys/c_retail_etl.py +460 -0
- equiframe/examples/journeys/common.py +136 -0
- equiframe/examples/journeys/d_financial_reconciliation.py +96 -0
- equiframe/examples/journeys/e_telemetry.py +135 -0
- equiframe/examples/journeys/f_data_science_preparation.py +84 -0
- equiframe/examples/journeys/g_external_snapshots.py +197 -0
- equiframe/examples/journeys/h_portable_ci.py +129 -0
- equiframe/examples/journeys/requirements.lock +249 -0
- equiframe/examples/journeys/verify_journeys.py +117 -0
- equiframe/examples/load_convert_compare.py +85 -0
- equiframe/execution.py +355 -0
- equiframe/execution_types.py +177 -0
- equiframe/ingestion.py +744 -0
- equiframe/local.py +126 -0
- equiframe/migration/__init__.py +94 -0
- equiframe/migration/_physical.py +8 -0
- equiframe/migration/backends/_timestamp_transport.py +109 -0
- equiframe/migration/backends/duckdb.py +651 -0
- equiframe/migration/backends/duckdb_decimal.py +125 -0
- equiframe/migration/backends/duckdb_time.py +87 -0
- equiframe/migration/backends/pandas.py +544 -0
- equiframe/migration/backends/polars.py +915 -0
- equiframe/migration/backends/polars_decimal.py +231 -0
- equiframe/migration/io.py +198 -0
- equiframe/migration/ports.py +96 -0
- equiframe/migration/runner.py +545 -0
- equiframe/migration/runner_types.py +405 -0
- equiframe/migration/types.py +354 -0
- equiframe/migration/validation.py +619 -0
- equiframe/normalization.py +358 -0
- equiframe/pipeline.py +274 -0
- equiframe/pipeline_types.py +247 -0
- equiframe/ports.py +39 -0
- equiframe/preprocessing.py +110 -0
- equiframe/profile_types.py +343 -0
- equiframe/profiles.py +447 -0
- equiframe/py.typed +0 -0
- equiframe/r_review.py +706 -0
- equiframe/reduction.py +518 -0
- equiframe/registry.py +121 -0
- equiframe/report_rendering.py +366 -0
- equiframe/report_types.py +294 -0
- equiframe/reporting.py +26 -0
- equiframe/rule_types.py +178 -0
- equiframe/rules.py +819 -0
- equiframe/schemas/r-execution-binding-1.0.schema.json +46 -0
- equiframe/schemas/r-review-binding-1.0.schema.json +28 -0
- equiframe/schemas/r-review-options-1.0.schema.json +38 -0
- equiframe/schemas/r-review-result-1.0.schema.json +207 -0
- equiframe/schemas/reviewed-decision-1.0.schema.json +3394 -0
- equiframe/serialization.py +152 -0
- equiframe/snapshot.py +296 -0
- equiframe/sources.py +283 -0
- equiframe/tables.py +459 -0
- equiframe/types.py +71 -0
- equiframe/values.py +252 -0
- equiframe/verification_request.py +35 -0
- equiframe/verify.py +799 -0
- equiframe/verify_types.py +255 -0
- equiframe/workflow.py +510 -0
- equiframe/workflow_request.py +38 -0
- equiframe-0.12.4.dist-info/METADATA +268 -0
- equiframe-0.12.4.dist-info/RECORD +170 -0
- equiframe-0.12.4.dist-info/WHEEL +4 -0
- equiframe-0.12.4.dist-info/entry_points.txt +3 -0
- equiframe-0.12.4.dist-info/licenses/LICENSE +21 -0
- equiframe-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +42 -0
- equiframe-0.12.4.dist-info/licenses/tools/cyclonedx/LICENSE +201 -0
- 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
|
+
)
|