From 1445c9823218172ac9ee24adb685e4d3d103944b Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Wed, 20 Aug 2025 14:30:03 -0700 Subject: [PATCH 001/313] chore: implement floordiv_op compiler (#1995) Fixes internal issue 430133370 --- .../sqlglot/expressions/binary_compiler.py | 44 ++++- .../compile/sqlglot/expressions/constants.py | 25 +++ .../sqlglot/expressions/unary_compiler.py | 39 ++--- .../system/small/engines/test_numeric_ops.py | 4 +- .../test_div_numeric/out.sql | 130 +++++++++++---- .../test_floordiv_numeric/out.sql | 154 ++++++++++++++++++ .../test_floordiv_timedelta/out.sql | 18 ++ .../expressions/test_binary_compiler.py | 33 +++- 8 files changed, 387 insertions(+), 60 deletions(-) create mode 100644 bigframes/core/compile/sqlglot/expressions/constants.py create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_numeric/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_timedelta/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/binary_compiler.py b/bigframes/core/compile/sqlglot/expressions/binary_compiler.py index b5d665e2e5..61f1eba607 100644 --- a/bigframes/core/compile/sqlglot/expressions/binary_compiler.py +++ b/bigframes/core/compile/sqlglot/expressions/binary_compiler.py @@ -14,11 +14,12 @@ from __future__ import annotations -import bigframes_vendored.constants as constants +import bigframes_vendored.constants as bf_constants import sqlglot.expressions as sge from bigframes import dtypes from bigframes import operations as ops +import bigframes.core.compile.sqlglot.expressions.constants as constants from bigframes.core.compile.sqlglot.expressions.op_registration import OpRegistration from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr @@ -69,7 +70,7 @@ def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: return sge.Add(this=left.expr, expression=right.expr) raise TypeError( - f"Cannot add type {left.dtype} and {right.dtype}. {constants.FEEDBACK_LINK}" + f"Cannot add type {left.dtype} and {right.dtype}. {bf_constants.FEEDBACK_LINK}" ) @@ -89,6 +90,43 @@ def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: return result +@BINARY_OP_REGISTRATION.register(ops.floordiv_op) +def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = left.expr + if left.dtype == dtypes.BOOL_DTYPE: + left_expr = sge.Cast(this=left_expr, to="INT64") + right_expr = right.expr + if right.dtype == dtypes.BOOL_DTYPE: + right_expr = sge.Cast(this=right_expr, to="INT64") + + result: sge.Expression = sge.Cast( + this=sge.Floor(this=sge.func("IEEE_DIVIDE", left_expr, right_expr)), to="INT64" + ) + + # DIV(N, 0) will error in bigquery, but needs to return `0` for int, and + # `inf`` for float in BQ so we short-circuit in this case. + # Multiplying left by zero propogates nulls. + zero_result = ( + constants._INF + if (left.dtype == dtypes.FLOAT_DTYPE or right.dtype == dtypes.FLOAT_DTYPE) + else constants._ZERO + ) + result = sge.Case( + ifs=[ + sge.If( + this=sge.EQ(this=right_expr, expression=constants._ZERO), + true=zero_result * left_expr, + ) + ], + default=result, + ) + + if dtypes.is_numeric(right.dtype) and left.dtype == dtypes.TIMEDELTA_DTYPE: + result = sge.Cast(this=sge.Floor(this=result), to="INT64") + + return result + + @BINARY_OP_REGISTRATION.register(ops.ge_op) def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: return sge.GTE(this=left.expr, expression=right.expr) @@ -156,7 +194,7 @@ def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: return sge.Sub(this=left.expr, expression=right.expr) raise TypeError( - f"Cannot subtract type {left.dtype} and {right.dtype}. {constants.FEEDBACK_LINK}" + f"Cannot subtract type {left.dtype} and {right.dtype}. {bf_constants.FEEDBACK_LINK}" ) diff --git a/bigframes/core/compile/sqlglot/expressions/constants.py b/bigframes/core/compile/sqlglot/expressions/constants.py new file mode 100644 index 0000000000..20857f6291 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/constants.py @@ -0,0 +1,25 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sqlglot.expressions as sge + +_ZERO = sge.Cast(this=sge.convert(0), to="INT64") +_NAN = sge.Cast(this=sge.convert("NaN"), to="FLOAT64") +_INF = sge.Cast(this=sge.convert("Infinity"), to="FLOAT64") +_NEG_INF = sge.Cast(this=sge.convert("-Infinity"), to="FLOAT64") + +# Approx Highest number you can pass in to EXP function and get a valid FLOAT64 result +# FLOAT64 has 11 exponent bits, so max values is about 2**(2**10) +# ln(2**(2**10)) == (2**10)*ln(2) ~= 709.78, so EXP(x) for x>709.78 will overflow. +_FLOAT64_EXP_BOUND = sge.convert(709.78) diff --git a/bigframes/core/compile/sqlglot/expressions/unary_compiler.py b/bigframes/core/compile/sqlglot/expressions/unary_compiler.py index 1551c555a7..ddaf04ae97 100644 --- a/bigframes/core/compile/sqlglot/expressions/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/expressions/unary_compiler.py @@ -23,17 +23,10 @@ from bigframes import operations as ops from bigframes.core.compile.constants import UNIT_TO_US_CONVERSION_FACTORS +import bigframes.core.compile.sqlglot.expressions.constants as constants from bigframes.core.compile.sqlglot.expressions.op_registration import OpRegistration from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr -_NAN = sge.Cast(this=sge.convert("NaN"), to="FLOAT64") -_INF = sge.Cast(this=sge.convert("Infinity"), to="FLOAT64") - -# Approx Highest number you can pass in to EXP function and get a valid FLOAT64 result -# FLOAT64 has 11 exponent bits, so max values is about 2**(2**10) -# ln(2**(2**10)) == (2**10)*ln(2) ~= 709.78, so EXP(x) for x>709.78 will overflow. -_FLOAT64_EXP_BOUND = sge.convert(709.78) - UNARY_OP_REGISTRATION = OpRegistration() @@ -52,7 +45,7 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ifs=[ sge.If( this=expr.expr < sge.convert(1), - true=_NAN, + true=constants._NAN, ) ], default=sge.func("ACOSH", expr.expr), @@ -65,7 +58,7 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ifs=[ sge.If( this=sge.func("ABS", expr.expr) > sge.convert(1), - true=_NAN, + true=constants._NAN, ) ], default=sge.func("ACOS", expr.expr), @@ -78,7 +71,7 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ifs=[ sge.If( this=sge.func("ABS", expr.expr) > sge.convert(1), - true=_NAN, + true=constants._NAN, ) ], default=sge.func("ASIN", expr.expr), @@ -101,7 +94,7 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ifs=[ sge.If( this=sge.func("ABS", expr.expr) > sge.convert(1), - true=_NAN, + true=constants._NAN, ) ], default=sge.func("ATANH", expr.expr), @@ -177,7 +170,7 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ifs=[ sge.If( this=sge.func("ABS", expr.expr) > sge.convert(709.78), - true=_INF, + true=constants._INF, ) ], default=sge.func("COSH", expr.expr), @@ -222,8 +215,8 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( - this=expr.expr > _FLOAT64_EXP_BOUND, - true=_INF, + this=expr.expr > constants._FLOAT64_EXP_BOUND, + true=constants._INF, ) ], default=sge.func("EXP", expr.expr), @@ -235,8 +228,8 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( - this=expr.expr > _FLOAT64_EXP_BOUND, - true=_INF, + this=expr.expr > constants._FLOAT64_EXP_BOUND, + true=constants._INF, ) ], default=sge.func("EXP", expr.expr), @@ -403,7 +396,7 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ifs=[ sge.If( this=expr.expr < sge.convert(0), - true=_NAN, + true=constants._NAN, ) ], default=sge.Ln(this=expr.expr), @@ -416,7 +409,7 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ifs=[ sge.If( this=expr.expr < sge.convert(0), - true=_NAN, + true=constants._NAN, ) ], default=sge.Log(this=expr.expr, expression=sge.convert(10)), @@ -429,7 +422,7 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ifs=[ sge.If( this=expr.expr < sge.convert(-1), - true=_NAN, + true=constants._NAN, ) ], default=sge.Ln(this=sge.convert(1) + expr.expr), @@ -512,7 +505,7 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ifs=[ sge.If( this=expr.expr < sge.convert(0), - true=_NAN, + true=constants._NAN, ) ], default=sge.Sqrt(this=expr.expr), @@ -534,8 +527,8 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( - this=sge.func("ABS", expr.expr) > _FLOAT64_EXP_BOUND, - true=sge.func("SIGN", expr.expr) * _INF, + this=sge.func("ABS", expr.expr) > constants._FLOAT64_EXP_BOUND, + true=sge.func("SIGN", expr.expr) * constants._INF, ) ], default=sge.func("SINH", expr.expr), diff --git a/tests/system/small/engines/test_numeric_ops.py b/tests/system/small/engines/test_numeric_ops.py index b46a2f1c56..7928922e41 100644 --- a/tests/system/small/engines/test_numeric_ops.py +++ b/tests/system/small/engines/test_numeric_ops.py @@ -117,7 +117,7 @@ def test_engines_project_div_durations( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_project_floordiv( scalars_array_value: array_value.ArrayValue, engine, @@ -130,7 +130,7 @@ def test_engines_project_floordiv( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_project_floordiv_durations( scalars_array_value: array_value.ArrayValue, engine ): diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_div_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_div_numeric/out.sql index c1f4e0cb69..03d48276a0 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_div_numeric/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_div_numeric/out.sql @@ -2,53 +2,121 @@ WITH `bfcte_0` AS ( SELECT `bool_col` AS `bfcol_0`, `int64_col` AS `bfcol_1`, - `rowindex` AS `bfcol_2` + `float64_col` AS `bfcol_2`, + `rowindex` AS `bfcol_3` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_2` AS `bfcol_6`, - `bfcol_1` AS `bfcol_7`, - `bfcol_0` AS `bfcol_8`, - IEEE_DIVIDE(`bfcol_1`, `bfcol_1`) AS `bfcol_9` + `bfcol_3` AS `bfcol_8`, + `bfcol_1` AS `bfcol_9`, + `bfcol_0` AS `bfcol_10`, + `bfcol_2` AS `bfcol_11`, + IEEE_DIVIDE(`bfcol_1`, `bfcol_1`) AS `bfcol_12` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT *, - `bfcol_6` AS `bfcol_14`, - `bfcol_7` AS `bfcol_15`, - `bfcol_8` AS `bfcol_16`, - `bfcol_9` AS `bfcol_17`, - IEEE_DIVIDE(`bfcol_7`, 1) AS `bfcol_18` + `bfcol_8` AS `bfcol_18`, + `bfcol_9` AS `bfcol_19`, + `bfcol_10` AS `bfcol_20`, + `bfcol_11` AS `bfcol_21`, + `bfcol_12` AS `bfcol_22`, + IEEE_DIVIDE(`bfcol_9`, 1) AS `bfcol_23` FROM `bfcte_1` ), `bfcte_3` AS ( SELECT *, - `bfcol_14` AS `bfcol_24`, - `bfcol_15` AS `bfcol_25`, - `bfcol_16` AS `bfcol_26`, - `bfcol_17` AS `bfcol_27`, - `bfcol_18` AS `bfcol_28`, - IEEE_DIVIDE(`bfcol_15`, CAST(`bfcol_16` AS INT64)) AS `bfcol_29` + `bfcol_18` AS `bfcol_30`, + `bfcol_19` AS `bfcol_31`, + `bfcol_20` AS `bfcol_32`, + `bfcol_21` AS `bfcol_33`, + `bfcol_22` AS `bfcol_34`, + `bfcol_23` AS `bfcol_35`, + IEEE_DIVIDE(`bfcol_19`, 0.0) AS `bfcol_36` FROM `bfcte_2` ), `bfcte_4` AS ( SELECT *, - `bfcol_24` AS `bfcol_36`, - `bfcol_25` AS `bfcol_37`, - `bfcol_26` AS `bfcol_38`, - `bfcol_27` AS `bfcol_39`, - `bfcol_28` AS `bfcol_40`, - `bfcol_29` AS `bfcol_41`, - IEEE_DIVIDE(CAST(`bfcol_26` AS INT64), `bfcol_25`) AS `bfcol_42` + `bfcol_30` AS `bfcol_44`, + `bfcol_31` AS `bfcol_45`, + `bfcol_32` AS `bfcol_46`, + `bfcol_33` AS `bfcol_47`, + `bfcol_34` AS `bfcol_48`, + `bfcol_35` AS `bfcol_49`, + `bfcol_36` AS `bfcol_50`, + IEEE_DIVIDE(`bfcol_31`, `bfcol_33`) AS `bfcol_51` FROM `bfcte_3` +), `bfcte_5` AS ( + SELECT + *, + `bfcol_44` AS `bfcol_60`, + `bfcol_45` AS `bfcol_61`, + `bfcol_46` AS `bfcol_62`, + `bfcol_47` AS `bfcol_63`, + `bfcol_48` AS `bfcol_64`, + `bfcol_49` AS `bfcol_65`, + `bfcol_50` AS `bfcol_66`, + `bfcol_51` AS `bfcol_67`, + IEEE_DIVIDE(`bfcol_47`, `bfcol_45`) AS `bfcol_68` + FROM `bfcte_4` +), `bfcte_6` AS ( + SELECT + *, + `bfcol_60` AS `bfcol_78`, + `bfcol_61` AS `bfcol_79`, + `bfcol_62` AS `bfcol_80`, + `bfcol_63` AS `bfcol_81`, + `bfcol_64` AS `bfcol_82`, + `bfcol_65` AS `bfcol_83`, + `bfcol_66` AS `bfcol_84`, + `bfcol_67` AS `bfcol_85`, + `bfcol_68` AS `bfcol_86`, + IEEE_DIVIDE(`bfcol_63`, 0.0) AS `bfcol_87` + FROM `bfcte_5` +), `bfcte_7` AS ( + SELECT + *, + `bfcol_78` AS `bfcol_98`, + `bfcol_79` AS `bfcol_99`, + `bfcol_80` AS `bfcol_100`, + `bfcol_81` AS `bfcol_101`, + `bfcol_82` AS `bfcol_102`, + `bfcol_83` AS `bfcol_103`, + `bfcol_84` AS `bfcol_104`, + `bfcol_85` AS `bfcol_105`, + `bfcol_86` AS `bfcol_106`, + `bfcol_87` AS `bfcol_107`, + IEEE_DIVIDE(`bfcol_79`, CAST(`bfcol_80` AS INT64)) AS `bfcol_108` + FROM `bfcte_6` +), `bfcte_8` AS ( + SELECT + *, + `bfcol_98` AS `bfcol_120`, + `bfcol_99` AS `bfcol_121`, + `bfcol_100` AS `bfcol_122`, + `bfcol_101` AS `bfcol_123`, + `bfcol_102` AS `bfcol_124`, + `bfcol_103` AS `bfcol_125`, + `bfcol_104` AS `bfcol_126`, + `bfcol_105` AS `bfcol_127`, + `bfcol_106` AS `bfcol_128`, + `bfcol_107` AS `bfcol_129`, + `bfcol_108` AS `bfcol_130`, + IEEE_DIVIDE(CAST(`bfcol_100` AS INT64), `bfcol_99`) AS `bfcol_131` + FROM `bfcte_7` ) SELECT - `bfcol_36` AS `rowindex`, - `bfcol_37` AS `int64_col`, - `bfcol_38` AS `bool_col`, - `bfcol_39` AS `int_div_int`, - `bfcol_40` AS `int_div_1`, - `bfcol_41` AS `int_div_bool`, - `bfcol_42` AS `bool_div_int` -FROM `bfcte_4` \ No newline at end of file + `bfcol_120` AS `rowindex`, + `bfcol_121` AS `int64_col`, + `bfcol_122` AS `bool_col`, + `bfcol_123` AS `float64_col`, + `bfcol_124` AS `int_div_int`, + `bfcol_125` AS `int_div_1`, + `bfcol_126` AS `int_div_0`, + `bfcol_127` AS `int_div_float`, + `bfcol_128` AS `float_div_int`, + `bfcol_129` AS `float_div_0`, + `bfcol_130` AS `int_div_bool`, + `bfcol_131` AS `bool_div_int` +FROM `bfcte_8` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_numeric/out.sql new file mode 100644 index 0000000000..c38bc18523 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_numeric/out.sql @@ -0,0 +1,154 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `float64_col` AS `bfcol_2`, + `rowindex` AS `bfcol_3` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_3` AS `bfcol_8`, + `bfcol_1` AS `bfcol_9`, + `bfcol_0` AS `bfcol_10`, + `bfcol_2` AS `bfcol_11`, + CASE + WHEN `bfcol_1` = CAST(0 AS INT64) + THEN CAST(0 AS INT64) * `bfcol_1` + ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_1`, `bfcol_1`)) AS INT64) + END AS `bfcol_12` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_8` AS `bfcol_18`, + `bfcol_9` AS `bfcol_19`, + `bfcol_10` AS `bfcol_20`, + `bfcol_11` AS `bfcol_21`, + `bfcol_12` AS `bfcol_22`, + CASE + WHEN 1 = CAST(0 AS INT64) + THEN CAST(0 AS INT64) * `bfcol_9` + ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_9`, 1)) AS INT64) + END AS `bfcol_23` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_18` AS `bfcol_30`, + `bfcol_19` AS `bfcol_31`, + `bfcol_20` AS `bfcol_32`, + `bfcol_21` AS `bfcol_33`, + `bfcol_22` AS `bfcol_34`, + `bfcol_23` AS `bfcol_35`, + CASE + WHEN 0.0 = CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) * `bfcol_19` + ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_19`, 0.0)) AS INT64) + END AS `bfcol_36` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_30` AS `bfcol_44`, + `bfcol_31` AS `bfcol_45`, + `bfcol_32` AS `bfcol_46`, + `bfcol_33` AS `bfcol_47`, + `bfcol_34` AS `bfcol_48`, + `bfcol_35` AS `bfcol_49`, + `bfcol_36` AS `bfcol_50`, + CASE + WHEN `bfcol_33` = CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) * `bfcol_31` + ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_31`, `bfcol_33`)) AS INT64) + END AS `bfcol_51` + FROM `bfcte_3` +), `bfcte_5` AS ( + SELECT + *, + `bfcol_44` AS `bfcol_60`, + `bfcol_45` AS `bfcol_61`, + `bfcol_46` AS `bfcol_62`, + `bfcol_47` AS `bfcol_63`, + `bfcol_48` AS `bfcol_64`, + `bfcol_49` AS `bfcol_65`, + `bfcol_50` AS `bfcol_66`, + `bfcol_51` AS `bfcol_67`, + CASE + WHEN `bfcol_45` = CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) * `bfcol_47` + ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_47`, `bfcol_45`)) AS INT64) + END AS `bfcol_68` + FROM `bfcte_4` +), `bfcte_6` AS ( + SELECT + *, + `bfcol_60` AS `bfcol_78`, + `bfcol_61` AS `bfcol_79`, + `bfcol_62` AS `bfcol_80`, + `bfcol_63` AS `bfcol_81`, + `bfcol_64` AS `bfcol_82`, + `bfcol_65` AS `bfcol_83`, + `bfcol_66` AS `bfcol_84`, + `bfcol_67` AS `bfcol_85`, + `bfcol_68` AS `bfcol_86`, + CASE + WHEN 0.0 = CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) * `bfcol_63` + ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_63`, 0.0)) AS INT64) + END AS `bfcol_87` + FROM `bfcte_5` +), `bfcte_7` AS ( + SELECT + *, + `bfcol_78` AS `bfcol_98`, + `bfcol_79` AS `bfcol_99`, + `bfcol_80` AS `bfcol_100`, + `bfcol_81` AS `bfcol_101`, + `bfcol_82` AS `bfcol_102`, + `bfcol_83` AS `bfcol_103`, + `bfcol_84` AS `bfcol_104`, + `bfcol_85` AS `bfcol_105`, + `bfcol_86` AS `bfcol_106`, + `bfcol_87` AS `bfcol_107`, + CASE + WHEN CAST(`bfcol_80` AS INT64) = CAST(0 AS INT64) + THEN CAST(0 AS INT64) * `bfcol_79` + ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_79`, CAST(`bfcol_80` AS INT64))) AS INT64) + END AS `bfcol_108` + FROM `bfcte_6` +), `bfcte_8` AS ( + SELECT + *, + `bfcol_98` AS `bfcol_120`, + `bfcol_99` AS `bfcol_121`, + `bfcol_100` AS `bfcol_122`, + `bfcol_101` AS `bfcol_123`, + `bfcol_102` AS `bfcol_124`, + `bfcol_103` AS `bfcol_125`, + `bfcol_104` AS `bfcol_126`, + `bfcol_105` AS `bfcol_127`, + `bfcol_106` AS `bfcol_128`, + `bfcol_107` AS `bfcol_129`, + `bfcol_108` AS `bfcol_130`, + CASE + WHEN `bfcol_99` = CAST(0 AS INT64) + THEN CAST(0 AS INT64) * CAST(`bfcol_100` AS INT64) + ELSE CAST(FLOOR(IEEE_DIVIDE(CAST(`bfcol_100` AS INT64), `bfcol_99`)) AS INT64) + END AS `bfcol_131` + FROM `bfcte_7` +) +SELECT + `bfcol_120` AS `rowindex`, + `bfcol_121` AS `int64_col`, + `bfcol_122` AS `bool_col`, + `bfcol_123` AS `float64_col`, + `bfcol_124` AS `int_div_int`, + `bfcol_125` AS `int_div_1`, + `bfcol_126` AS `int_div_0`, + `bfcol_127` AS `int_div_float`, + `bfcol_128` AS `float_div_int`, + `bfcol_129` AS `float_div_0`, + `bfcol_130` AS `int_div_bool`, + `bfcol_131` AS `bool_div_int` +FROM `bfcte_8` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_timedelta/out.sql new file mode 100644 index 0000000000..bc4f94d306 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_timedelta/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `date_col` AS `bfcol_0`, + `rowindex` AS `bfcol_1`, + `timestamp_col` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + 43200000000 AS `bfcol_6` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `rowindex`, + `bfcol_2` AS `timestamp_col`, + `bfcol_0` AS `date_col`, + `bfcol_6` AS `timedelta_div_numeric` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py b/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py index 0d3fd42607..49426fe6c3 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py @@ -83,10 +83,15 @@ def test_add_unsupported_raises(scalar_types_df: bpd.DataFrame): def test_div_numeric(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col", "bool_col"]] + bf_df = scalar_types_df[["int64_col", "bool_col", "float64_col"]] bf_df["int_div_int"] = bf_df["int64_col"] / bf_df["int64_col"] bf_df["int_div_1"] = bf_df["int64_col"] / 1 + bf_df["int_div_0"] = bf_df["int64_col"] / 0.0 + + bf_df["int_div_float"] = bf_df["int64_col"] / bf_df["float64_col"] + bf_df["float_div_int"] = bf_df["float64_col"] / bf_df["int64_col"] + bf_df["float_div_0"] = bf_df["float64_col"] / 0.0 bf_df["int_div_bool"] = bf_df["int64_col"] / bf_df["bool_col"] bf_df["bool_div_int"] = bf_df["bool_col"] / bf_df["int64_col"] @@ -102,6 +107,32 @@ def test_div_timedelta(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(bf_df.sql, "out.sql") +def test_floordiv_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col", "float64_col"]] + + bf_df["int_div_int"] = bf_df["int64_col"] // bf_df["int64_col"] + bf_df["int_div_1"] = bf_df["int64_col"] // 1 + bf_df["int_div_0"] = bf_df["int64_col"] // 0.0 + + bf_df["int_div_float"] = bf_df["int64_col"] // bf_df["float64_col"] + bf_df["float_div_int"] = bf_df["float64_col"] // bf_df["int64_col"] + bf_df["float_div_0"] = bf_df["float64_col"] // 0.0 + + bf_df["int_div_bool"] = bf_df["int64_col"] // bf_df["bool_col"] + bf_df["bool_div_int"] = bf_df["bool_col"] // bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_floordiv_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col", "date_col"]] + timedelta = pd.Timedelta(1, unit="d") + + bf_df["timedelta_div_numeric"] = timedelta // 2 + + snapshot.assert_match(bf_df.sql, "out.sql") + + def test_json_set(json_types_df: bpd.DataFrame, snapshot): bf_df = json_types_df[["json_col"]] sql = _apply_binary_op( From f61b0446625b89bc2b227ade5ba50e9d65779dda Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:16:33 -0700 Subject: [PATCH 002/313] chore(main): release 2.16.0 (#1983) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 35 +++++++++++++++++++++++ bigframes/version.py | 4 +-- third_party/bigframes_vendored/version.py | 4 +-- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c36d024ce7..bfa43f7fc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ [1]: https://pypi.org/project/bigframes/#history +## [2.16.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.15.0...v2.16.0) (2025-08-20) + + +### Features + +* Add `bigframes.pandas.options.display.precision` option ([#1979](https://github.com/googleapis/python-bigquery-dataframes/issues/1979)) ([15e6175](https://github.com/googleapis/python-bigquery-dataframes/commit/15e6175ec0aeb1b7b02d0bba9e8e1e018bd11c31)) +* Add level, inplace params to reset_index ([#1988](https://github.com/googleapis/python-bigquery-dataframes/issues/1988)) ([3446950](https://github.com/googleapis/python-bigquery-dataframes/commit/34469504b79a082d3380f9f25c597483aef2068a)) +* Add ML code samples from dbt blog post ([#1978](https://github.com/googleapis/python-bigquery-dataframes/issues/1978)) ([ebaa244](https://github.com/googleapis/python-bigquery-dataframes/commit/ebaa244a9eb7b87f7f9fd9c3bebe5c7db24cd013)) +* Add where, coalesce, fillna, casewhen, invert local impl ([#1976](https://github.com/googleapis/python-bigquery-dataframes/issues/1976)) ([f7f686c](https://github.com/googleapis/python-bigquery-dataframes/commit/f7f686cf85ab7e265d9c07ebc7f0cd59babc5357)) +* Adjust anywidget CSS to prevent overflow ([#1981](https://github.com/googleapis/python-bigquery-dataframes/issues/1981)) ([204f083](https://github.com/googleapis/python-bigquery-dataframes/commit/204f083a2f00fcc9fd1500dcd7a738eda3904d2f)) +* Format page number in table widget ([#1992](https://github.com/googleapis/python-bigquery-dataframes/issues/1992)) ([e83836e](https://github.com/googleapis/python-bigquery-dataframes/commit/e83836e8e1357f009f3f95666f1661bdbe0d3751)) +* Or, And, Xor can execute locally ([#1994](https://github.com/googleapis/python-bigquery-dataframes/issues/1994)) ([59c52a5](https://github.com/googleapis/python-bigquery-dataframes/commit/59c52a55ebea697855eb4c70529e226cc077141f)) +* Support callable bigframes function for dataframe where ([#1990](https://github.com/googleapis/python-bigquery-dataframes/issues/1990)) ([44c1ec4](https://github.com/googleapis/python-bigquery-dataframes/commit/44c1ec48cc4db1c4c9c15ec1fab43d4ef0758e56)) +* Support callable for series where method ([#2005](https://github.com/googleapis/python-bigquery-dataframes/issues/2005)) ([768b82a](https://github.com/googleapis/python-bigquery-dataframes/commit/768b82af96a5dd0c434edcb171036eb42cfb9b41)) +* When using `repr_mode = "anywidget"`, numeric values align right ([15e6175](https://github.com/googleapis/python-bigquery-dataframes/commit/15e6175ec0aeb1b7b02d0bba9e8e1e018bd11c31)) + + +### Bug Fixes + +* Address the packages issue for bigframes function ([#1991](https://github.com/googleapis/python-bigquery-dataframes/issues/1991)) ([68f1d22](https://github.com/googleapis/python-bigquery-dataframes/commit/68f1d22d5ed8457a5cabc7751ed1d178063dd63e)) +* Correct pypdf dependency specifier for remote PDF functions ([#1980](https://github.com/googleapis/python-bigquery-dataframes/issues/1980)) ([0bd5e1b](https://github.com/googleapis/python-bigquery-dataframes/commit/0bd5e1b3c004124d2100c3fbec2fbe1e965d1e96)) +* Enable default retries in calls to BQ Storage Read API ([#1985](https://github.com/googleapis/python-bigquery-dataframes/issues/1985)) ([f25d7bd](https://github.com/googleapis/python-bigquery-dataframes/commit/f25d7bd30800dffa65b6c31b0b7ac711a13d790f)) +* Fix the copyright year in dbt sample files ([#1996](https://github.com/googleapis/python-bigquery-dataframes/issues/1996)) ([fad5722](https://github.com/googleapis/python-bigquery-dataframes/commit/fad57223d129f0c95d0c6a066179bb66880edd06)) + + +### Performance Improvements + +* Faster session startup by defering anon dataset fetch ([#1982](https://github.com/googleapis/python-bigquery-dataframes/issues/1982)) ([2720c4c](https://github.com/googleapis/python-bigquery-dataframes/commit/2720c4cf070bf57a0930d7623bfc41d89cc053ee)) + + +### Documentation + +* Add examples of running bigframes in kaggle ([#2002](https://github.com/googleapis/python-bigquery-dataframes/issues/2002)) ([7d89d76](https://github.com/googleapis/python-bigquery-dataframes/commit/7d89d76976595b75cb0105fbe7b4f7ca2fdf49f2)) +* Remove preview warning from partial ordering mode sample notebook ([#1986](https://github.com/googleapis/python-bigquery-dataframes/issues/1986)) ([132e0ed](https://github.com/googleapis/python-bigquery-dataframes/commit/132e0edfe9f96c15753649d77fcb6edd0b0708a3)) + ## [2.15.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.14.0...v2.15.0) (2025-08-11) diff --git a/bigframes/version.py b/bigframes/version.py index 7aff17a40d..6b84e2eb1d 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.15.0" +__version__ = "2.16.0" # {x-release-please-start-date} -__release_date__ = "2025-08-11" +__release_date__ = "2025-08-20" # {x-release-please-end} diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index 7aff17a40d..6b84e2eb1d 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.15.0" +__version__ = "2.16.0" # {x-release-please-start-date} -__release_date__ = "2025-08-11" +__release_date__ = "2025-08-20" # {x-release-please-end} From b4542565f5c72f40a34bc823bfe6283fdcecd591 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Wed, 20 Aug 2025 17:31:28 -0700 Subject: [PATCH 003/313] chore: implement eq, eq_null_match, ne compilers (#2008) Fixes internal issue 430133370 --- .../sqlglot/expressions/binary_compiler.py | 106 ++++++++++-------- .../test_eq_null_match/out.sql | 14 +++ .../test_eq_numeric/out.sql | 54 +++++++++ .../test_ne_numeric/out.sql | 54 +++++++++ .../expressions/test_binary_compiler.py | 32 +++++- 5 files changed, 214 insertions(+), 46 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_eq_null_match/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_eq_numeric/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_ne_numeric/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/binary_compiler.py b/bigframes/core/compile/sqlglot/expressions/binary_compiler.py index 61f1eba607..84e783bb66 100644 --- a/bigframes/core/compile/sqlglot/expressions/binary_compiler.py +++ b/bigframes/core/compile/sqlglot/expressions/binary_compiler.py @@ -38,21 +38,15 @@ def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: return sge.Concat(expressions=[left.expr, right.expr]) if dtypes.is_numeric(left.dtype) and dtypes.is_numeric(right.dtype): - left_expr = left.expr - if left.dtype == dtypes.BOOL_DTYPE: - left_expr = sge.Cast(this=left_expr, to="INT64") - right_expr = right.expr - if right.dtype == dtypes.BOOL_DTYPE: - right_expr = sge.Cast(this=right_expr, to="INT64") + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) return sge.Add(this=left_expr, expression=right_expr) if ( dtypes.is_time_or_date_like(left.dtype) and right.dtype == dtypes.TIMEDELTA_DTYPE ): - left_expr = left.expr - if left.dtype == dtypes.DATE_DTYPE: - left_expr = sge.Cast(this=left_expr, to="DATETIME") + left_expr = _coerce_date_to_datetime(left) return sge.TimestampAdd( this=left_expr, expression=right.expr, unit=sge.Var(this="MICROSECOND") ) @@ -60,9 +54,7 @@ def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: dtypes.is_time_or_date_like(right.dtype) and left.dtype == dtypes.TIMEDELTA_DTYPE ): - right_expr = right.expr - if right.dtype == dtypes.DATE_DTYPE: - right_expr = sge.Cast(this=right_expr, to="DATETIME") + right_expr = _coerce_date_to_datetime(right) return sge.TimestampAdd( this=right_expr, expression=left.expr, unit=sge.Var(this="MICROSECOND") ) @@ -74,14 +66,37 @@ def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: ) -@BINARY_OP_REGISTRATION.register(ops.div_op) +@BINARY_OP_REGISTRATION.register(ops.eq_op) +def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.EQ(this=left_expr, expression=right_expr) + + +@BINARY_OP_REGISTRATION.register(ops.eq_null_match_op) def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: left_expr = left.expr - if left.dtype == dtypes.BOOL_DTYPE: - left_expr = sge.Cast(this=left_expr, to="INT64") + if right.dtype != dtypes.BOOL_DTYPE: + left_expr = _coerce_bool_to_int(left) + right_expr = right.expr - if right.dtype == dtypes.BOOL_DTYPE: - right_expr = sge.Cast(this=right_expr, to="INT64") + if left.dtype != dtypes.BOOL_DTYPE: + right_expr = _coerce_bool_to_int(right) + + sentinel = sge.convert("$NULL_SENTINEL$") + left_coalesce = sge.Coalesce( + this=sge.Cast(this=left_expr, to="STRING"), expressions=[sentinel] + ) + right_coalesce = sge.Coalesce( + this=sge.Cast(this=right_expr, to="STRING"), expressions=[sentinel] + ) + return sge.EQ(this=left_coalesce, expression=right_coalesce) + + +@BINARY_OP_REGISTRATION.register(ops.div_op) +def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) result = sge.func("IEEE_DIVIDE", left_expr, right_expr) if left.dtype == dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right.dtype): @@ -92,12 +107,8 @@ def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: @BINARY_OP_REGISTRATION.register(ops.floordiv_op) def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: - left_expr = left.expr - if left.dtype == dtypes.BOOL_DTYPE: - left_expr = sge.Cast(this=left_expr, to="INT64") - right_expr = right.expr - if right.dtype == dtypes.BOOL_DTYPE: - right_expr = sge.Cast(this=right_expr, to="INT64") + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) result: sge.Expression = sge.Cast( this=sge.Floor(this=sge.func("IEEE_DIVIDE", left_expr, right_expr)), to="INT64" @@ -139,12 +150,8 @@ def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: @BINARY_OP_REGISTRATION.register(ops.mul_op) def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: - left_expr = left.expr - if left.dtype == dtypes.BOOL_DTYPE: - left_expr = sge.Cast(this=left_expr, to="INT64") - right_expr = right.expr - if right.dtype == dtypes.BOOL_DTYPE: - right_expr = sge.Cast(this=right_expr, to="INT64") + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) result = sge.Mul(this=left_expr, expression=right_expr) @@ -156,36 +163,33 @@ def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: return result +@BINARY_OP_REGISTRATION.register(ops.ne_op) +def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.NEQ(this=left_expr, expression=right_expr) + + @BINARY_OP_REGISTRATION.register(ops.sub_op) def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: if dtypes.is_numeric(left.dtype) and dtypes.is_numeric(right.dtype): - left_expr = left.expr - if left.dtype == dtypes.BOOL_DTYPE: - left_expr = sge.Cast(this=left_expr, to="INT64") - right_expr = right.expr - if right.dtype == dtypes.BOOL_DTYPE: - right_expr = sge.Cast(this=right_expr, to="INT64") + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) return sge.Sub(this=left_expr, expression=right_expr) if ( dtypes.is_time_or_date_like(left.dtype) and right.dtype == dtypes.TIMEDELTA_DTYPE ): - left_expr = left.expr - if left.dtype == dtypes.DATE_DTYPE: - left_expr = sge.Cast(this=left_expr, to="DATETIME") + left_expr = _coerce_date_to_datetime(left) return sge.TimestampSub( this=left_expr, expression=right.expr, unit=sge.Var(this="MICROSECOND") ) if dtypes.is_time_or_date_like(left.dtype) and dtypes.is_time_or_date_like( right.dtype ): - left_expr = left.expr - if left.dtype == dtypes.DATE_DTYPE: - left_expr = sge.Cast(this=left_expr, to="DATETIME") - right_expr = right.expr - if right.dtype == dtypes.DATE_DTYPE: - right_expr = sge.Cast(this=right_expr, to="DATETIME") + left_expr = _coerce_date_to_datetime(left) + right_expr = _coerce_date_to_datetime(right) return sge.TimestampDiff( this=left_expr, expression=right_expr, unit=sge.Var(this="MICROSECOND") ) @@ -201,3 +205,17 @@ def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: @BINARY_OP_REGISTRATION.register(ops.obj_make_ref_op) def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: return sge.func("OBJ.MAKE_REF", left.expr, right.expr) + + +def _coerce_bool_to_int(typed_expr: TypedExpr) -> sge.Expression: + """Coerce boolean expression to integer.""" + if typed_expr.dtype == dtypes.BOOL_DTYPE: + return sge.Cast(this=typed_expr.expr, to="INT64") + return typed_expr.expr + + +def _coerce_date_to_datetime(typed_expr: TypedExpr) -> sge.Expression: + """Coerce date expression to datetime.""" + if typed_expr.dtype == dtypes.DATE_DTYPE: + return sge.Cast(this=typed_expr.expr, to="DATETIME") + return typed_expr.expr diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_eq_null_match/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_eq_null_match/out.sql new file mode 100644 index 0000000000..90cbcfe5c7 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_eq_null_match/out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + COALESCE(CAST(`bfcol_1` AS STRING), '$NULL_SENTINEL$') = COALESCE(CAST(CAST(`bfcol_0` AS INT64) AS STRING), '$NULL_SENTINEL$') AS `bfcol_4` + FROM `bfcte_0` +) +SELECT + `bfcol_4` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_eq_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_eq_numeric/out.sql new file mode 100644 index 0000000000..8e3c52310d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_eq_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_1` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + `bfcol_1` = `bfcol_1` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` = 1 AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_15` = CAST(`bfcol_16` AS INT64) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CAST(`bfcol_26` AS INT64) = `bfcol_25` AS `bfcol_42` + FROM `bfcte_3` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `int64_col`, + `bfcol_38` AS `bool_col`, + `bfcol_39` AS `int_ne_int`, + `bfcol_40` AS `int_ne_1`, + `bfcol_41` AS `int_ne_bool`, + `bfcol_42` AS `bool_ne_int` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_ne_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_ne_numeric/out.sql new file mode 100644 index 0000000000..6fba4b960f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_ne_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_1` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + `bfcol_1` <> `bfcol_1` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` <> 1 AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_15` <> CAST(`bfcol_16` AS INT64) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CAST(`bfcol_26` AS INT64) <> `bfcol_25` AS `bfcol_42` + FROM `bfcte_3` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `int64_col`, + `bfcol_38` AS `bool_col`, + `bfcol_39` AS `int_ne_int`, + `bfcol_40` AS `int_ne_1`, + `bfcol_41` AS `int_ne_bool`, + `bfcol_42` AS `bool_ne_int` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py b/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py index 49426fe6c3..11586cad02 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py @@ -107,6 +107,24 @@ def test_div_timedelta(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(bf_df.sql, "out.sql") +def test_eq_null_match(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + sql = _apply_binary_op(bf_df, ops.eq_null_match_op, "int64_col", "bool_col") + snapshot.assert_match(sql, "out.sql") + + +def test_eq_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_ne_int"] = bf_df["int64_col"] == bf_df["int64_col"] + bf_df["int_ne_1"] = bf_df["int64_col"] == 1 + + bf_df["int_ne_bool"] = bf_df["int64_col"] == bf_df["bool_col"] + bf_df["bool_ne_int"] = bf_df["bool_col"] == bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + def test_floordiv_numeric(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["int64_col", "bool_col", "float64_col"]] @@ -121,8 +139,6 @@ def test_floordiv_numeric(scalar_types_df: bpd.DataFrame, snapshot): bf_df["int_div_bool"] = bf_df["int64_col"] // bf_df["bool_col"] bf_df["bool_div_int"] = bf_df["bool_col"] // bf_df["int64_col"] - snapshot.assert_match(bf_df.sql, "out.sql") - def test_floordiv_timedelta(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["timestamp_col", "date_col"]] @@ -200,3 +216,15 @@ def test_mul_timedelta(scalar_types_df: bpd.DataFrame, snapshot): def test_obj_make_ref(scalar_types_df: bpd.DataFrame, snapshot): blob_df = scalar_types_df["string_col"].str.to_blob() snapshot.assert_match(blob_df.to_frame().sql, "out.sql") + + +def test_ne_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_ne_int"] = bf_df["int64_col"] != bf_df["int64_col"] + bf_df["int_ne_1"] = bf_df["int64_col"] != 1 + + bf_df["int_ne_bool"] = bf_df["int64_col"] != bf_df["bool_col"] + bf_df["bool_ne_int"] = bf_df["bool_col"] != bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") From 26df6e691bb27ed09322a81214faedbf3639b32e Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 21 Aug 2025 09:36:23 -0700 Subject: [PATCH 004/313] feat: Add isin local execution impl (#1993) --- .../ibis_compiler/scalar_op_registry.py | 2 +- bigframes/core/compile/polars/compiler.py | 8 ++--- bigframes/core/compile/polars/lowering.py | 32 ++++++++++++++++++ bigframes/dataframe.py | 4 +-- bigframes/session/polars_executor.py | 1 + .../system/small/engines/test_generic_ops.py | 33 +++++++++++++++++++ tests/system/small/test_dataframe.py | 17 +++++++++- 7 files changed, 88 insertions(+), 9 deletions(-) diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index bc077c1ce3..f3653efc56 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -1062,7 +1062,7 @@ def isin_op_impl(x: ibis_types.Value, op: ops.IsInOp): if op.match_nulls and contains_nulls: return x.isnull() | x.isin(matchable_ibis_values) else: - return x.isin(matchable_ibis_values) + return x.isin(matchable_ibis_values).fillna(False) @scalar_op_compiler.register_unary_op(ops.ToDatetimeOp, pass_op=True) diff --git a/bigframes/core/compile/polars/compiler.py b/bigframes/core/compile/polars/compiler.py index 8ae896816f..1ba76dee5b 100644 --- a/bigframes/core/compile/polars/compiler.py +++ b/bigframes/core/compile/polars/compiler.py @@ -263,11 +263,9 @@ def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: # TODO: Filter out types that can't be coerced to right type assert isinstance(op, gen_ops.IsInOp) - if op.match_nulls or not any(map(pd.isna, op.values)): - # newer polars version have nulls_equal arg - return input.is_in(op.values) - else: - return input.is_in(op.values) or input.is_null() + assert not op.match_nulls # should be stripped by a lowering step rn + values = pl.Series(op.values, strict=False) + return input.is_in(values) @compile_op.register(gen_ops.FillNaOp) @compile_op.register(gen_ops.CoalesceOp) diff --git a/bigframes/core/compile/polars/lowering.py b/bigframes/core/compile/polars/lowering.py index f6ed6c676c..876ff2794f 100644 --- a/bigframes/core/compile/polars/lowering.py +++ b/bigframes/core/compile/polars/lowering.py @@ -13,8 +13,10 @@ # limitations under the License. import dataclasses +from typing import cast import numpy as np +import pandas as pd from bigframes import dtypes from bigframes.core import bigframe_node, expression @@ -316,6 +318,35 @@ def lower(self, expr: expression.OpExpression) -> expression.Expression: return expr +class LowerIsinOp(op_lowering.OpLoweringRule): + @property + def op(self) -> type[ops.ScalarOp]: + return generic_ops.IsInOp + + def lower(self, expr: expression.OpExpression) -> expression.Expression: + assert isinstance(expr.op, generic_ops.IsInOp) + arg = expr.children[0] + new_values = [] + match_nulls = False + for val in expr.op.values: + # coercible, non-coercible + # float NaN/inf should be treated as distinct from 'true' null values + if cast(bool, pd.isna(val)) and not isinstance(val, float): + if expr.op.match_nulls: + match_nulls = True + elif dtypes.is_compatible(val, arg.output_type): + new_values.append(val) + else: + pass + + new_isin = ops.IsInOp(tuple(new_values), match_nulls=False).as_expr(arg) + if match_nulls: + return ops.coalesce_op.as_expr(new_isin, expression.const(True)) + else: + # polars propagates nulls, so need to coalesce to false + return ops.coalesce_op.as_expr(new_isin, expression.const(False)) + + def _coerce_comparables( expr1: expression.Expression, expr2: expression.Expression, @@ -414,6 +445,7 @@ def _lower_cast(cast_op: ops.AsTypeOp, arg: expression.Expression): LowerModRule(), LowerAsTypeRule(), LowerInvertOp(), + LowerIsinOp(), ) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index c58cbaba6a..a76027fbd6 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -2755,11 +2755,11 @@ def isin(self, values) -> DataFrame: False, label=label, dtype=pandas.BooleanDtype() ) result_ids.append(result_id) - return DataFrame(block.select_columns(result_ids)).fillna(value=False) + return DataFrame(block.select_columns(result_ids)) elif utils.is_list_like(values): return self._apply_unary_op( ops.IsInOp(values=tuple(values), match_nulls=True) - ).fillna(value=False) + ) else: raise TypeError( "only list-like objects are allowed to be passed to " diff --git a/bigframes/session/polars_executor.py b/bigframes/session/polars_executor.py index 9c45a884e5..6e3f0ca10f 100644 --- a/bigframes/session/polars_executor.py +++ b/bigframes/session/polars_executor.py @@ -66,6 +66,7 @@ generic_ops.FillNaOp, generic_ops.CaseWhenOp, generic_ops.InvertOp, + generic_ops.IsInOp, generic_ops.IsNullOp, generic_ops.NotNullOp, ) diff --git a/tests/system/small/engines/test_generic_ops.py b/tests/system/small/engines/test_generic_ops.py index 9fdb6bca78..1d28c335a6 100644 --- a/tests/system/small/engines/test_generic_ops.py +++ b/tests/system/small/engines/test_generic_ops.py @@ -390,3 +390,36 @@ def test_engines_invert_op(scalars_array_value: array_value.ArrayValue, engine): ) assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +def test_engines_isin_op(scalars_array_value: array_value.ArrayValue, engine): + arr, col_ids = scalars_array_value.compute_values( + [ + ops.IsInOp((1, 2, 3)).as_expr(expression.deref("int64_col")), + ops.IsInOp((None, 123456)).as_expr(expression.deref("int64_col")), + ops.IsInOp((None, 123456), match_nulls=False).as_expr( + expression.deref("int64_col") + ), + ops.IsInOp((1.0, 2.0, 3.0)).as_expr(expression.deref("int64_col")), + ops.IsInOp(("1.0", "2.0")).as_expr(expression.deref("int64_col")), + ops.IsInOp(("1.0", 2.5, 3)).as_expr(expression.deref("int64_col")), + ops.IsInOp(()).as_expr(expression.deref("int64_col")), + ops.IsInOp((1, 2, 3, None)).as_expr(expression.deref("float64_col")), + ] + ) + new_names = ( + "int in ints", + "int in ints w null", + "int in ints w null wo match nulls", + "int in floats", + "int in strings", + "int in mixed", + "int in empty", + "float in ints", + ) + arr = arr.rename_columns( + {old_name: new_names[i] for i, old_name in enumerate(col_ids)} + ) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 3b70dec0e9..f752346bef 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -1591,7 +1591,7 @@ def test_itertuples(scalars_df_index, index, name): assert bf_tuple == pd_tuple -def test_df_isin_list(scalars_dfs): +def test_df_isin_list_w_null(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs values = ["Hello, World!", 55555, 2.51, pd.NA, True] bf_result = ( @@ -1606,6 +1606,21 @@ def test_df_isin_list(scalars_dfs): pandas.testing.assert_frame_equal(bf_result, pd_result.astype("boolean")) +def test_df_isin_list_wo_null(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + values = ["Hello, World!", 55555, 2.51, True] + bf_result = ( + scalars_df[["int64_col", "float64_col", "string_col", "bool_col"]] + .isin(values) + .to_pandas() + ) + pd_result = scalars_pandas_df[ + ["int64_col", "float64_col", "string_col", "bool_col"] + ].isin(values) + + pandas.testing.assert_frame_equal(bf_result, pd_result.astype("boolean")) + + def test_df_isin_dict(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs values = { From 09b67da4cf24d280d24aea1143674587bb0cd7d4 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Thu, 21 Aug 2025 12:11:36 -0700 Subject: [PATCH 005/313] chore: implement ge, gt, le, gt compilers (#2009) --- .../sqlglot/expressions/binary_compiler.py | 35 +++++++++--- .../small/engines/test_comparison_ops.py | 2 +- .../test_ge_numeric/out.sql | 54 +++++++++++++++++++ .../test_gt_numeric/out.sql | 54 +++++++++++++++++++ .../test_le_numeric/out.sql | 54 +++++++++++++++++++ .../test_lt_numeric/out.sql | 54 +++++++++++++++++++ .../expressions/test_binary_compiler.py | 48 +++++++++++++++++ 7 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_ge_numeric/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_gt_numeric/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_le_numeric/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_lt_numeric/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/binary_compiler.py b/bigframes/core/compile/sqlglot/expressions/binary_compiler.py index 84e783bb66..3fcba04cfd 100644 --- a/bigframes/core/compile/sqlglot/expressions/binary_compiler.py +++ b/bigframes/core/compile/sqlglot/expressions/binary_compiler.py @@ -140,7 +140,16 @@ def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: @BINARY_OP_REGISTRATION.register(ops.ge_op) def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: - return sge.GTE(this=left.expr, expression=right.expr) + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.GTE(this=left_expr, expression=right_expr) + + +@BINARY_OP_REGISTRATION.register(ops.gt_op) +def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.GT(this=left_expr, expression=right_expr) @BINARY_OP_REGISTRATION.register(ops.JSONSet) @@ -148,6 +157,20 @@ def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: return sge.func("JSON_SET", left.expr, sge.convert(op.json_path), right.expr) +@BINARY_OP_REGISTRATION.register(ops.lt_op) +def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.LT(this=left_expr, expression=right_expr) + + +@BINARY_OP_REGISTRATION.register(ops.le_op) +def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.LTE(this=left_expr, expression=right_expr) + + @BINARY_OP_REGISTRATION.register(ops.mul_op) def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: left_expr = _coerce_bool_to_int(left) @@ -170,6 +193,11 @@ def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: return sge.NEQ(this=left_expr, expression=right_expr) +@BINARY_OP_REGISTRATION.register(ops.obj_make_ref_op) +def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.func("OBJ.MAKE_REF", left.expr, right.expr) + + @BINARY_OP_REGISTRATION.register(ops.sub_op) def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: if dtypes.is_numeric(left.dtype) and dtypes.is_numeric(right.dtype): @@ -202,11 +230,6 @@ def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: ) -@BINARY_OP_REGISTRATION.register(ops.obj_make_ref_op) -def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: - return sge.func("OBJ.MAKE_REF", left.expr, right.expr) - - def _coerce_bool_to_int(typed_expr: TypedExpr) -> sge.Expression: """Coerce boolean expression to integer.""" if typed_expr.dtype == dtypes.BOOL_DTYPE: diff --git a/tests/system/small/engines/test_comparison_ops.py b/tests/system/small/engines/test_comparison_ops.py index fefff93f58..0fcc48b10a 100644 --- a/tests/system/small/engines/test_comparison_ops.py +++ b/tests/system/small/engines/test_comparison_ops.py @@ -48,7 +48,7 @@ def apply_op_pairwise( return new_arr -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) @pytest.mark.parametrize( "op", [ diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_ge_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_ge_numeric/out.sql new file mode 100644 index 0000000000..494cb861a7 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_ge_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_1` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + `bfcol_1` >= `bfcol_1` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` >= 1 AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_15` >= CAST(`bfcol_16` AS INT64) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CAST(`bfcol_26` AS INT64) >= `bfcol_25` AS `bfcol_42` + FROM `bfcte_3` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `int64_col`, + `bfcol_38` AS `bool_col`, + `bfcol_39` AS `int_ge_int`, + `bfcol_40` AS `int_ge_1`, + `bfcol_41` AS `int_ge_bool`, + `bfcol_42` AS `bool_ge_int` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_gt_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_gt_numeric/out.sql new file mode 100644 index 0000000000..b0c8768850 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_gt_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_1` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + `bfcol_1` > `bfcol_1` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` > 1 AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_15` > CAST(`bfcol_16` AS INT64) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CAST(`bfcol_26` AS INT64) > `bfcol_25` AS `bfcol_42` + FROM `bfcte_3` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `int64_col`, + `bfcol_38` AS `bool_col`, + `bfcol_39` AS `int_gt_int`, + `bfcol_40` AS `int_gt_1`, + `bfcol_41` AS `int_gt_bool`, + `bfcol_42` AS `bool_gt_int` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_le_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_le_numeric/out.sql new file mode 100644 index 0000000000..2f642d8cbb --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_le_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_1` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + `bfcol_1` <= `bfcol_1` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` <= 1 AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_15` <= CAST(`bfcol_16` AS INT64) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CAST(`bfcol_26` AS INT64) <= `bfcol_25` AS `bfcol_42` + FROM `bfcte_3` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `int64_col`, + `bfcol_38` AS `bool_col`, + `bfcol_39` AS `int_le_int`, + `bfcol_40` AS `int_le_1`, + `bfcol_41` AS `int_le_bool`, + `bfcol_42` AS `bool_le_int` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_lt_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_lt_numeric/out.sql new file mode 100644 index 0000000000..b244e3cbcc --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_lt_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_1` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + `bfcol_1` < `bfcol_1` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` < 1 AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_15` < CAST(`bfcol_16` AS INT64) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CAST(`bfcol_26` AS INT64) < `bfcol_25` AS `bfcol_42` + FROM `bfcte_3` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `int64_col`, + `bfcol_38` AS `bool_col`, + `bfcol_39` AS `int_lt_int`, + `bfcol_40` AS `int_lt_1`, + `bfcol_41` AS `int_lt_bool`, + `bfcol_42` AS `bool_lt_int` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py b/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py index 11586cad02..a2218d0afa 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py @@ -149,6 +149,30 @@ def test_floordiv_timedelta(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(bf_df.sql, "out.sql") +def test_gt_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_gt_int"] = bf_df["int64_col"] > bf_df["int64_col"] + bf_df["int_gt_1"] = bf_df["int64_col"] > 1 + + bf_df["int_gt_bool"] = bf_df["int64_col"] > bf_df["bool_col"] + bf_df["bool_gt_int"] = bf_df["bool_col"] > bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_ge_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_ge_int"] = bf_df["int64_col"] >= bf_df["int64_col"] + bf_df["int_ge_1"] = bf_df["int64_col"] >= 1 + + bf_df["int_ge_bool"] = bf_df["int64_col"] >= bf_df["bool_col"] + bf_df["bool_ge_int"] = bf_df["bool_col"] >= bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + def test_json_set(json_types_df: bpd.DataFrame, snapshot): bf_df = json_types_df[["json_col"]] sql = _apply_binary_op( @@ -158,6 +182,30 @@ def test_json_set(json_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_lt_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_lt_int"] = bf_df["int64_col"] < bf_df["int64_col"] + bf_df["int_lt_1"] = bf_df["int64_col"] < 1 + + bf_df["int_lt_bool"] = bf_df["int64_col"] < bf_df["bool_col"] + bf_df["bool_lt_int"] = bf_df["bool_col"] < bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_le_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_le_int"] = bf_df["int64_col"] <= bf_df["int64_col"] + bf_df["int_le_1"] = bf_df["int64_col"] <= 1 + + bf_df["int_le_bool"] = bf_df["int64_col"] <= bf_df["bool_col"] + bf_df["bool_le_int"] = bf_df["bool_col"] <= bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + def test_sub_numeric(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["int64_col", "bool_col"]] From 0c0c3fa7a8cd373127fe3af3a2046d973af1f5a6 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Thu, 21 Aug 2025 16:59:16 -0700 Subject: [PATCH 006/313] test: Add unit test for get_remote_function_locations (#2016) --- .../functions/test_remote_function_utils.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/unit/functions/test_remote_function_utils.py b/tests/unit/functions/test_remote_function_utils.py index 91fe01e986..0fe6759b1a 100644 --- a/tests/unit/functions/test_remote_function_utils.py +++ b/tests/unit/functions/test_remote_function_utils.py @@ -21,6 +21,25 @@ from bigframes.functions import _utils, function_typing +@pytest.mark.parametrize( + ("input_location", "expected_bq_location", "expected_cf_region"), + [ + (None, "us", "us-central1"), + ("us", "us", "us-central1"), + ("eu", "eu", "europe-west1"), + ("US-east4", "us-east4", "us-east4"), + ], +) +def test_get_remote_function_locations( + input_location, expected_bq_location, expected_cf_region +): + """Tests getting remote function locations for various locations.""" + bq_location, cf_region = _utils.get_remote_function_locations(input_location) + + assert bq_location == expected_bq_location + assert cf_region == expected_cf_region + + def test_get_updated_package_requirements_no_extra_package(): """Tests with no extra package.""" result = _utils.get_updated_package_requirements(capture_references=False) From c02a1b67d27758815430bb8006ac3a72cea55a89 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 22 Aug 2025 12:04:37 -0700 Subject: [PATCH 007/313] feat: Add reset_index names, col_level, col_fill, allow_duplicates args (#2017) --- bigframes/core/blocks.py | 24 ++++++++-- bigframes/dataframe.py | 47 +++++++++++++++++-- bigframes/series.py | 8 +++- tests/system/small/test_dataframe.py | 26 ++++++++++ tests/system/small/test_multiindex.py | 22 +++++++-- tests/system/small/test_series.py | 26 ++++++++++ .../bigframes_vendored/pandas/core/frame.py | 17 +++++++ .../bigframes_vendored/pandas/core/series.py | 3 ++ 8 files changed, 162 insertions(+), 11 deletions(-) diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index d2662da509..1a2544704c 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -387,12 +387,21 @@ def reversed(self) -> Block: index_labels=self.index.names, ) - def reset_index(self, level: LevelsType = None, drop: bool = True) -> Block: + def reset_index( + self, + level: LevelsType = None, + drop: bool = True, + *, + col_level: Union[str, int] = 0, + col_fill: typing.Hashable = "", + allow_duplicates: bool = False, + ) -> Block: """Reset the index of the block, promoting the old index to a value column. Arguments: level: the label or index level of the index levels to remove. name: this is the column id for the new value id derived from the old index + allow_duplicates: Returns: A new Block because dropping index columns can break references @@ -438,6 +447,11 @@ def reset_index(self, level: LevelsType = None, drop: bool = True) -> Block: ) else: # Add index names to column index + col_level_n = ( + col_level + if isinstance(col_level, int) + else self.column_labels.names.index(col_level) + ) column_labels_modified = self.column_labels for position, level_id in enumerate(level_ids): label = self.col_id_to_index_name[level_id] @@ -447,11 +461,15 @@ def reset_index(self, level: LevelsType = None, drop: bool = True) -> Block: else: label = f"level_{self.index_columns.index(level_id)}" - if label in self.column_labels: + if (not allow_duplicates) and (label in self.column_labels): raise ValueError(f"cannot insert {label}, already exists") + if isinstance(self.column_labels, pd.MultiIndex): nlevels = self.column_labels.nlevels - label = tuple(label if i == 0 else "" for i in range(nlevels)) + label = tuple( + label if i == col_level_n else col_fill for i in range(nlevels) + ) + # Create index copy with label inserted # See: https://pandas.pydata.org/docs/reference/api/pandas.Index.insert.html column_labels_modified = column_labels_modified.insert(position, label) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index a76027fbd6..921893fb83 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -2321,6 +2321,10 @@ def reset_index( level: blocks.LevelsType = ..., drop: bool = ..., inplace: Literal[False] = ..., + col_level: Union[int, str] = ..., + col_fill: Hashable = ..., + allow_duplicates: Optional[bool] = ..., + names: Union[None, Hashable, Sequence[Hashable]] = ..., ) -> DataFrame: ... @@ -2330,19 +2334,56 @@ def reset_index( level: blocks.LevelsType = ..., drop: bool = ..., inplace: Literal[True] = ..., + col_level: Union[int, str] = ..., + col_fill: Hashable = ..., + allow_duplicates: Optional[bool] = ..., + names: Union[None, Hashable, Sequence[Hashable]] = ..., ) -> None: ... @overload def reset_index( - self, level: blocks.LevelsType = None, drop: bool = False, inplace: bool = ... + self, + level: blocks.LevelsType = None, + drop: bool = False, + inplace: bool = ..., + col_level: Union[int, str] = ..., + col_fill: Hashable = ..., + allow_duplicates: Optional[bool] = ..., + names: Union[None, Hashable, Sequence[Hashable]] = ..., ) -> Optional[DataFrame]: ... def reset_index( - self, level: blocks.LevelsType = None, drop: bool = False, inplace: bool = False + self, + level: blocks.LevelsType = None, + drop: bool = False, + inplace: bool = False, + col_level: Union[int, str] = 0, + col_fill: Hashable = "", + allow_duplicates: Optional[bool] = None, + names: Union[None, Hashable, Sequence[Hashable]] = None, ) -> Optional[DataFrame]: - block = self._block.reset_index(level, drop) + block = self._block + if names: + if isinstance(names, blocks.Label) and not isinstance(names, tuple): + names = [names] + else: + names = list(names) + + if len(names) != self.index.nlevels: + raise ValueError("'names' must be same length as levels") + + block = block.with_index_labels(names) + if allow_duplicates is None: + allow_duplicates = False + block = block.reset_index( + level, + drop, + col_level=col_level, + col_fill=col_fill, + allow_duplicates=allow_duplicates, + ) if inplace: self._set_block(block) return None diff --git a/bigframes/series.py b/bigframes/series.py index 6f48935ec9..58bd47bff0 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -414,6 +414,7 @@ def reset_index( name: typing.Optional[str] = ..., drop: Literal[False] = ..., inplace: Literal[False] = ..., + allow_duplicates: Optional[bool] = ..., ) -> bigframes.dataframe.DataFrame: ... @@ -425,6 +426,7 @@ def reset_index( name: typing.Optional[str] = ..., drop: Literal[True] = ..., inplace: Literal[False] = ..., + allow_duplicates: Optional[bool] = ..., ) -> Series: ... @@ -436,6 +438,7 @@ def reset_index( name: typing.Optional[str] = ..., drop: bool = ..., inplace: Literal[True] = ..., + allow_duplicates: Optional[bool] = ..., ) -> None: ... @@ -447,8 +450,11 @@ def reset_index( name: typing.Optional[str] = None, drop: bool = False, inplace: bool = False, + allow_duplicates: Optional[bool] = None, ) -> bigframes.dataframe.DataFrame | Series | None: - block = self._block.reset_index(level, drop) + if allow_duplicates is None: + allow_duplicates = False + block = self._block.reset_index(level, drop, allow_duplicates=allow_duplicates) if drop: if inplace: self._set_block(block) diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index f752346bef..8a570ade45 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -2085,6 +2085,32 @@ def test_reset_index(scalars_df_index, scalars_pandas_df_index, drop): pandas.testing.assert_frame_equal(bf_result, pd_result) +def test_reset_index_allow_duplicates(scalars_df_index, scalars_pandas_df_index): + scalars_df_index = scalars_df_index.copy() + scalars_df_index.index.name = "int64_col" + df = scalars_df_index.reset_index(allow_duplicates=True, drop=False) + assert df.index.name is None + + bf_result = df.to_pandas() + + scalars_pandas_df_index = scalars_pandas_df_index.copy() + scalars_pandas_df_index.index.name = "int64_col" + pd_result = scalars_pandas_df_index.reset_index(allow_duplicates=True, drop=False) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + + # reset_index should maintain the original ordering. + pandas.testing.assert_frame_equal(bf_result, pd_result) + + +def test_reset_index_duplicates_error(scalars_df_index): + scalars_df_index = scalars_df_index.copy() + scalars_df_index.index.name = "int64_col" + with pytest.raises(ValueError): + scalars_df_index.reset_index(allow_duplicates=False, drop=False) + + @pytest.mark.parametrize( ("drop",), ((True,), (False,)), diff --git a/tests/system/small/test_multiindex.py b/tests/system/small/test_multiindex.py index 0c23ea97ae..f15b8d8b21 100644 --- a/tests/system/small/test_multiindex.py +++ b/tests/system/small/test_multiindex.py @@ -929,16 +929,30 @@ def test_column_multi_index_rename(scalars_df_index, scalars_pandas_df_index): pandas.testing.assert_frame_equal(bf_result, pd_result) -def test_column_multi_index_reset_index(scalars_df_index, scalars_pandas_df_index): +@pytest.mark.parametrize( + ("names", "col_fill", "col_level"), + [ + (None, "", "l2"), + (("new_name"), "fill", 1), + ("new_name", "fill", 0), + ], +) +def test_column_multi_index_reset_index( + scalars_df_index, scalars_pandas_df_index, names, col_fill, col_level +): columns = ["int64_too", "int64_col", "float64_col"] - multi_columns = pandas.MultiIndex.from_tuples(zip(["a", "b", "a"], ["a", "b", "b"])) + multi_columns = pandas.MultiIndex.from_tuples( + zip(["a", "b", "a"], ["a", "b", "b"]), names=["l1", "l2"] + ) bf_df = scalars_df_index[columns].copy() bf_df.columns = multi_columns pd_df = scalars_pandas_df_index[columns].copy() pd_df.columns = multi_columns - bf_result = bf_df.reset_index().to_pandas() - pd_result = pd_df.reset_index() + bf_result = bf_df.reset_index( + names=names, col_fill=col_fill, col_level=col_level + ).to_pandas() + pd_result = pd_df.reset_index(names=names, col_fill=col_fill, col_level=col_level) # Pandas uses int64 instead of Int64 (nullable) dtype. pd_result.index = pd_result.index.astype(pandas.Int64Dtype()) diff --git a/tests/system/small/test_series.py b/tests/system/small/test_series.py index 2172962046..60a3d73dd4 100644 --- a/tests/system/small/test_series.py +++ b/tests/system/small/test_series.py @@ -1339,6 +1339,32 @@ def test_reset_index_drop(scalars_df_index, scalars_pandas_df_index): pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) +def test_series_reset_index_allow_duplicates(scalars_df_index, scalars_pandas_df_index): + bf_series = scalars_df_index["int64_col"].copy() + bf_series.index.name = "int64_col" + df = bf_series.reset_index(allow_duplicates=True, drop=False) + assert df.index.name is None + + bf_result = df.to_pandas() + + pd_series = scalars_pandas_df_index["int64_col"].copy() + pd_series.index.name = "int64_col" + pd_result = pd_series.reset_index(allow_duplicates=True, drop=False) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + + # reset_index should maintain the original ordering. + pd.testing.assert_frame_equal(bf_result, pd_result) + + +def test_series_reset_index_duplicates_error(scalars_df_index): + scalars_df_index = scalars_df_index["int64_col"].copy() + scalars_df_index.index.name = "int64_col" + with pytest.raises(ValueError): + scalars_df_index.reset_index(allow_duplicates=False, drop=False) + + def test_series_reset_index_inplace(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index.sort_index(ascending=False)["float64_col"] bf_result.reset_index(drop=True, inplace=True) diff --git a/third_party/bigframes_vendored/pandas/core/frame.py b/third_party/bigframes_vendored/pandas/core/frame.py index 00984935a4..44ca558070 100644 --- a/third_party/bigframes_vendored/pandas/core/frame.py +++ b/third_party/bigframes_vendored/pandas/core/frame.py @@ -1605,6 +1605,10 @@ def reset_index( *, drop: bool = False, inplace: bool = False, + col_level: Hashable = 0, + col_fill: Hashable = "", + allow_duplicates: Optional[bool] = None, + names: Hashable | Sequence[Hashable] | None = None, ) -> DataFrame | None: """Reset the index. @@ -1706,6 +1710,19 @@ class name speed max the index to the default integer index. inplace (bool, default False): Whether to modify the DataFrame rather than creating a new one. + col_level (int or str, default 0): + If the columns have multiple levels, determines which level the + labels are inserted into. By default it is inserted into the first + level. + col_fill (object, default ''): + If the columns have multiple levels, determines how the other + levels are named. If None then the index name is repeated. + allow_duplicates (bool, optional, default None): + Allow duplicate column labels to be created. + names (str or 1-dimensional list, default None): + Using the given string, rename the DataFrame column which contains the + index data. If the DataFrame has a MultiIndex, this has to be a list or + tuple with length equal to the number of levels Returns: bigframes.pandas.DataFrame: DataFrame with the new index. diff --git a/third_party/bigframes_vendored/pandas/core/series.py b/third_party/bigframes_vendored/pandas/core/series.py index 7b420cf6e3..932959a826 100644 --- a/third_party/bigframes_vendored/pandas/core/series.py +++ b/third_party/bigframes_vendored/pandas/core/series.py @@ -326,6 +326,7 @@ def reset_index( drop: bool = False, name=pd_ext.no_default, inplace: bool = False, + allow_duplicates: Optional[bool] = None, ) -> DataFrame | Series | None: """ Generate a new DataFrame or Series with the index reset. @@ -413,6 +414,8 @@ def reset_index( when `drop` is True. inplace (bool, default False): Modify the Series in place (do not create a new object). + allow_duplicates (bool, optional, default None): + Allow duplicate column labels to be created. Returns: bigframes.pandas.Series or bigframes.pandas.DataFrame or None: From d442f41980336cdeb3218f20f05f2567dc815565 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Fri, 22 Aug 2025 15:37:18 -0700 Subject: [PATCH 008/313] test: Add unit test for get_cloud_function_name (#2018) --- .../functions/test_remote_function_utils.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/unit/functions/test_remote_function_utils.py b/tests/unit/functions/test_remote_function_utils.py index 0fe6759b1a..0e4ca7a2ac 100644 --- a/tests/unit/functions/test_remote_function_utils.py +++ b/tests/unit/functions/test_remote_function_utils.py @@ -40,6 +40,42 @@ def test_get_remote_function_locations( assert cf_region == expected_cf_region +@pytest.mark.parametrize( + "func_hash, session_id, uniq_suffix, expected_name", + [ + ( + "hash123", + None, + None, + "bigframes-hash123", + ), + ( + "hash456", + "session789", + None, + "bigframes-session789-hash456", + ), + ( + "hash123", + None, + "suffixABC", + "bigframes-hash123-suffixABC", + ), + ( + "hash456", + "session789", + "suffixDEF", + "bigframes-session789-hash456-suffixDEF", + ), + ], +) +def test_get_cloud_function_name(func_hash, session_id, uniq_suffix, expected_name): + """Tests the construction of the cloud function name from its parts.""" + result = _utils.get_cloud_function_name(func_hash, session_id, uniq_suffix) + + assert result == expected_name + + def test_get_updated_package_requirements_no_extra_package(): """Tests with no extra package.""" result = _utils.get_updated_package_requirements(capture_references=False) From 5ac32ebe17cfda447870859f5dd344b082b4d3d0 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Fri, 22 Aug 2025 16:21:13 -0700 Subject: [PATCH 009/313] feat: Support callable for series mask method (#2014) --- bigframes/series.py | 9 ++------- .../large/functions/test_managed_function.py | 16 ++++++++++++--- .../large/functions/test_remote_function.py | 16 ++++++++++++--- tests/system/small/test_series.py | 20 +++++++++++++++++++ 4 files changed, 48 insertions(+), 13 deletions(-) diff --git a/bigframes/series.py b/bigframes/series.py index 58bd47bff0..80952f38bc 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -2113,13 +2113,8 @@ def duplicated(self, keep: str = "first") -> Series: ) def mask(self, cond, other=None) -> Series: - if callable(cond): - if hasattr(cond, "bigframes_bigquery_function"): - cond = self.apply(cond) - else: - # For non-BigQuery function assume that it is applicable on Series - cond = self.apply(cond, by_row=False) - + cond = self._apply_callable(cond) + other = self._apply_callable(other) if not isinstance(cond, Series): raise TypeError( f"Only bigframes series condition is supported, received {type(cond).__name__}. " diff --git a/tests/system/large/functions/test_managed_function.py b/tests/system/large/functions/test_managed_function.py index 262f5f0fe2..43fb322567 100644 --- a/tests/system/large/functions/test_managed_function.py +++ b/tests/system/large/functions/test_managed_function.py @@ -1077,7 +1077,7 @@ def func_for_other(x): ) -def test_managed_function_series_where(session, dataset_id, scalars_dfs): +def test_managed_function_series_where_mask(session, dataset_id, scalars_dfs): try: # The return type has to be bool type for callable where condition. @@ -1098,8 +1098,8 @@ def _is_positive(s): pd_int64 = scalars_pandas["int64_col"] pd_int64_filtered = pd_int64.dropna() - # The cond is a callable (managed function) and the other is not a - # callable in series.where method. + # Test series.where method: the cond is a callable (managed function) + # and the other is not a callable. bf_result = bf_int64_filtered.where( cond=is_positive_mf, other=-bf_int64_filtered ).to_pandas() @@ -1108,6 +1108,16 @@ def _is_positive(s): # Ignore any dtype difference. pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + # Test series.mask method: the cond is a callable (managed function) + # and the other is not a callable. + bf_result = bf_int64_filtered.mask( + cond=is_positive_mf, other=-bf_int64_filtered + ).to_pandas() + pd_result = pd_int64_filtered.mask(cond=_is_positive, other=-pd_int64_filtered) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + finally: # Clean up the gcp assets created for the managed function. cleanup_function_assets(is_positive_mf, session.bqclient, ignore_failures=False) diff --git a/tests/system/large/functions/test_remote_function.py b/tests/system/large/functions/test_remote_function.py index 9e2c1e2c81..1c44b7e5fb 100644 --- a/tests/system/large/functions/test_remote_function.py +++ b/tests/system/large/functions/test_remote_function.py @@ -2933,7 +2933,7 @@ def func_for_other(x): @pytest.mark.flaky(retries=2, delay=120) -def test_remote_function_series_where(session, dataset_id, scalars_dfs): +def test_remote_function_series_where_mask(session, dataset_id, scalars_dfs): try: def _ten_times(x): @@ -2954,8 +2954,8 @@ def _ten_times(x): pd_int64 = scalars_pandas["float64_col"] pd_int64_filtered = pd_int64.dropna() - # The cond is not a callable and the other is a callable (remote - # function) in series.where method. + # Test series.where method: the cond is not a callable and the other is + # a callable (remote function). bf_result = bf_int64_filtered.where( cond=bf_int64_filtered < 0, other=ten_times_mf ).to_pandas() @@ -2966,6 +2966,16 @@ def _ten_times(x): # Ignore any dtype difference. pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + # Test series.mask method: the cond is not a callable and the other is + # a callable (remote function). + bf_result = bf_int64_filtered.mask( + cond=bf_int64_filtered < 0, other=ten_times_mf + ).to_pandas() + pd_result = pd_int64_filtered.mask(cond=pd_int64_filtered < 0, other=_ten_times) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + finally: # Clean up the gcp assets created for the remote function. cleanup_function_assets(ten_times_mf, session.bqclient, ignore_failures=False) diff --git a/tests/system/small/test_series.py b/tests/system/small/test_series.py index 60a3d73dd4..165e3b6df0 100644 --- a/tests/system/small/test_series.py +++ b/tests/system/small/test_series.py @@ -3603,6 +3603,26 @@ def test_mask_custom_value(scalars_dfs): assert_pandas_df_equal(bf_result, pd_result) +def test_mask_with_callable(scalars_df_index, scalars_pandas_df_index): + def _ten_times(x): + return x * 10 + + # Both cond and other are callable. + bf_result = ( + scalars_df_index["int64_col"] + .mask(cond=lambda x: x > 0, other=_ten_times) + .to_pandas() + ) + pd_result = scalars_pandas_df_index["int64_col"].mask( + cond=lambda x: x > 0, other=_ten_times + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + @pytest.mark.parametrize( ("lambda_",), [ From 655987725109ee9c07c30ae32eb6b1473bc984d4 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 25 Aug 2025 11:40:47 -0700 Subject: [PATCH 010/313] chore(main): release 2.17.0 (#2012) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 9 +++++++++ bigframes/version.py | 4 ++-- third_party/bigframes_vendored/version.py | 4 ++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa43f7fc4..fc4362cc87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ [1]: https://pypi.org/project/bigframes/#history +## [2.17.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.16.0...v2.17.0) (2025-08-22) + + +### Features + +* Add isin local execution impl ([#1993](https://github.com/googleapis/python-bigquery-dataframes/issues/1993)) ([26df6e6](https://github.com/googleapis/python-bigquery-dataframes/commit/26df6e691bb27ed09322a81214faedbf3639b32e)) +* Add reset_index names, col_level, col_fill, allow_duplicates args ([#2017](https://github.com/googleapis/python-bigquery-dataframes/issues/2017)) ([c02a1b6](https://github.com/googleapis/python-bigquery-dataframes/commit/c02a1b67d27758815430bb8006ac3a72cea55a89)) +* Support callable for series mask method ([#2014](https://github.com/googleapis/python-bigquery-dataframes/issues/2014)) ([5ac32eb](https://github.com/googleapis/python-bigquery-dataframes/commit/5ac32ebe17cfda447870859f5dd344b082b4d3d0)) + ## [2.16.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.15.0...v2.16.0) (2025-08-20) diff --git a/bigframes/version.py b/bigframes/version.py index 6b84e2eb1d..b9aa5d1855 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.16.0" +__version__ = "2.17.0" # {x-release-please-start-date} -__release_date__ = "2025-08-20" +__release_date__ = "2025-08-22" # {x-release-please-end} diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index 6b84e2eb1d..b9aa5d1855 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.16.0" +__version__ = "2.17.0" # {x-release-please-start-date} -__release_date__ = "2025-08-20" +__release_date__ = "2025-08-22" # {x-release-please-end} From e300ed13658368e38d39919a49a0170ef1223891 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Mon, 25 Aug 2025 13:38:48 -0700 Subject: [PATCH 011/313] chore: implement StrPadOp, StrFindOp, StrExtractOp, StrRepeatOp, RegexReplaceStrOp and ReplaceStrOp compilers (#2015) --- .../sqlglot/expressions/unary_compiler.py | 105 ++++++++++++++++-- .../test_regex_replace_str/out.sql | 13 +++ .../test_replace_str/out.sql | 13 +++ .../test_str_extract/out.sql | 13 +++ .../test_unary_compiler/test_str_find/out.sql | 13 +++ .../test_str_find/out_with_end.sql | 13 +++ .../test_str_find/out_with_start.sql | 13 +++ .../test_str_find/out_with_start_and_end.sql | 13 +++ .../test_unary_compiler/test_str_pad/both.sql | 21 ++++ .../test_unary_compiler/test_str_pad/left.sql | 13 +++ .../test_str_pad/right.sql | 13 +++ .../test_str_repeat/out.sql | 13 +++ .../expressions/test_unary_compiler.py | 58 ++++++++++ 13 files changed, 306 insertions(+), 8 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_regex_replace_str/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_replace_str/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_extract/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_end.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start_and_end.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/both.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/left.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/right.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_repeat/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/unary_compiler.py b/bigframes/core/compile/sqlglot/expressions/unary_compiler.py index ddaf04ae97..a5cffdc10a 100644 --- a/bigframes/core/compile/sqlglot/expressions/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/expressions/unary_compiler.py @@ -177,14 +177,96 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ) +@UNARY_OP_REGISTRATION.register(ops.StrContainsOp) +def _(op: ops.StrContainsOp, expr: TypedExpr) -> sge.Expression: + return sge.Like(this=expr.expr, expression=sge.convert(f"%{op.pat}%")) + + @UNARY_OP_REGISTRATION.register(ops.StrContainsRegexOp) def _(op: ops.StrContainsRegexOp, expr: TypedExpr) -> sge.Expression: return sge.RegexpLike(this=expr.expr, expression=sge.convert(op.pat)) -@UNARY_OP_REGISTRATION.register(ops.StrContainsOp) -def _(op: ops.StrContainsOp, expr: TypedExpr) -> sge.Expression: - return sge.Like(this=expr.expr, expression=sge.convert(f"%{op.pat}%")) +@UNARY_OP_REGISTRATION.register(ops.StrExtractOp) +def _(op: ops.StrExtractOp, expr: TypedExpr) -> sge.Expression: + return sge.RegexpExtract( + this=expr.expr, expression=sge.convert(op.pat), group=sge.convert(op.n) + ) + + +@UNARY_OP_REGISTRATION.register(ops.StrFindOp) +def _(op: ops.StrFindOp, expr: TypedExpr) -> sge.Expression: + # INSTR is 1-based, so we need to adjust the start position. + start = sge.convert(op.start + 1) if op.start is not None else sge.convert(1) + if op.end is not None: + # BigQuery's INSTR doesn't support `end`, so we need to use SUBSTR. + return sge.func( + "INSTR", + sge.Substring( + this=expr.expr, + start=start, + length=sge.convert(op.end - (op.start or 0)), + ), + sge.convert(op.substr), + ) - sge.convert(1) + else: + return sge.func( + "INSTR", + expr.expr, + sge.convert(op.substr), + start, + ) - sge.convert(1) + + +@UNARY_OP_REGISTRATION.register(ops.StrLstripOp) +def _(op: ops.StrLstripOp, expr: TypedExpr) -> sge.Expression: + return sge.Trim(this=expr.expr, expression=sge.convert(op.to_strip), side="LEFT") + + +@UNARY_OP_REGISTRATION.register(ops.StrPadOp) +def _(op: ops.StrPadOp, expr: TypedExpr) -> sge.Expression: + pad_length = sge.func( + "GREATEST", sge.Length(this=expr.expr), sge.convert(op.length) + ) + if op.side == "left": + return sge.func( + "LPAD", + expr.expr, + pad_length, + sge.convert(op.fillchar), + ) + elif op.side == "right": + return sge.func( + "RPAD", + expr.expr, + pad_length, + sge.convert(op.fillchar), + ) + else: # side == both + lpad_amount = sge.Cast( + this=sge.func( + "SAFE_DIVIDE", + sge.Sub(this=pad_length, expression=sge.Length(this=expr.expr)), + sge.convert(2), + ), + to="INT64", + ) + sge.Length(this=expr.expr) + return sge.func( + "RPAD", + sge.func( + "LPAD", + expr.expr, + lpad_amount, + sge.convert(op.fillchar), + ), + pad_length, + sge.convert(op.fillchar), + ) + + +@UNARY_OP_REGISTRATION.register(ops.StrRepeatOp) +def _(op: ops.StrRepeatOp, expr: TypedExpr) -> sge.Expression: + return sge.Repeat(this=expr.expr, times=sge.convert(op.repeats)) @UNARY_OP_REGISTRATION.register(ops.date_op) @@ -444,11 +526,6 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: return sge.Extract(this=sge.Identifier(this="MONTH"), expression=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.StrLstripOp) -def _(op: ops.StrLstripOp, expr: TypedExpr) -> sge.Expression: - return sge.Trim(this=expr.expr, expression=sge.convert(op.to_strip), side="LEFT") - - @UNARY_OP_REGISTRATION.register(ops.neg_op) def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: return sge.Neg(this=expr.expr) @@ -484,6 +561,18 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: return sge.Extract(this=sge.Identifier(this="QUARTER"), expression=expr.expr) +@UNARY_OP_REGISTRATION.register(ops.ReplaceStrOp) +def _(op: ops.ReplaceStrOp, expr: TypedExpr) -> sge.Expression: + return sge.func("REPLACE", expr.expr, sge.convert(op.pat), sge.convert(op.repl)) + + +@UNARY_OP_REGISTRATION.register(ops.RegexReplaceStrOp) +def _(op: ops.RegexReplaceStrOp, expr: TypedExpr) -> sge.Expression: + return sge.func( + "REGEXP_REPLACE", expr.expr, sge.convert(op.pat), sge.convert(op.repl) + ) + + @UNARY_OP_REGISTRATION.register(ops.reverse_op) def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: return sge.func("REVERSE", expr.expr) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_regex_replace_str/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_regex_replace_str/out.sql new file mode 100644 index 0000000000..149df6706c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_regex_replace_str/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_REPLACE(`bfcol_0`, 'e', 'a') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_replace_str/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_replace_str/out.sql new file mode 100644 index 0000000000..3bd7e0e47e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_replace_str/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REPLACE(`bfcol_0`, 'e', 'a') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_extract/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_extract/out.sql new file mode 100644 index 0000000000..a7fac093e2 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_extract/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_EXTRACT(`bfcol_0`, '([a-z]*)') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out.sql new file mode 100644 index 0000000000..dfc100e413 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + INSTR(`bfcol_0`, 'e', 1) - 1 AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_end.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_end.sql new file mode 100644 index 0000000000..78edf662b9 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_end.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + INSTR(SUBSTRING(`bfcol_0`, 1, 5), 'e') - 1 AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start.sql new file mode 100644 index 0000000000..d0dfc11a53 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + INSTR(`bfcol_0`, 'e', 3) - 1 AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start_and_end.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start_and_end.sql new file mode 100644 index 0000000000..a91ab32946 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start_and_end.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + INSTR(SUBSTRING(`bfcol_0`, 3, 3), 'e') - 1 AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/both.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/both.sql new file mode 100644 index 0000000000..4701b0237a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/both.sql @@ -0,0 +1,21 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + RPAD( + LPAD( + `bfcol_0`, + CAST(SAFE_DIVIDE(GREATEST(LENGTH(`bfcol_0`), 10) - LENGTH(`bfcol_0`), 2) AS INT64) + LENGTH(`bfcol_0`), + '-' + ), + GREATEST(LENGTH(`bfcol_0`), 10), + '-' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/left.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/left.sql new file mode 100644 index 0000000000..ee95900b3e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/left.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LPAD(`bfcol_0`, GREATEST(LENGTH(`bfcol_0`), 10), '-') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/right.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/right.sql new file mode 100644 index 0000000000..17e59c553f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/right.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + RPAD(`bfcol_0`, GREATEST(LENGTH(`bfcol_0`), 10), '-') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_repeat/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_repeat/out.sql new file mode 100644 index 0000000000..1c94cfafe2 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_repeat/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REPEAT(`bfcol_0`, 2) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py index 4a5b586c77..5c51068ce7 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py @@ -431,6 +431,18 @@ def test_quarter(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_replace_str(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.ReplaceStrOp("e", "a"), "string_col") + snapshot.assert_match(sql, "out.sql") + + +def test_regex_replace_str(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.RegexReplaceStrOp(r"e", "a"), "string_col") + snapshot.assert_match(sql, "out.sql") + + def test_reverse(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["string_col"]] sql = _apply_unary_op(bf_df, ops.reverse_op, "string_col") @@ -466,6 +478,24 @@ def test_str_get(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_str_pad(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op( + bf_df, ops.StrPadOp(length=10, fillchar="-", side="left"), "string_col" + ) + snapshot.assert_match(sql, "left.sql") + + sql = _apply_unary_op( + bf_df, ops.StrPadOp(length=10, fillchar="-", side="right"), "string_col" + ) + snapshot.assert_match(sql, "right.sql") + + sql = _apply_unary_op( + bf_df, ops.StrPadOp(length=10, fillchar="-", side="both"), "string_col" + ) + snapshot.assert_match(sql, "both.sql") + + def test_str_slice(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["string_col"]] sql = _apply_unary_op(bf_df, ops.StrSliceOp(1, 3), "string_col") @@ -506,6 +536,34 @@ def test_str_contains_regex(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_str_extract(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.StrExtractOp(r"([a-z]*)", 1), "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_str_repeat(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.StrRepeatOp(2), "string_col") + snapshot.assert_match(sql, "out.sql") + + +def test_str_find(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.StrFindOp("e", start=None, end=None), "string_col") + snapshot.assert_match(sql, "out.sql") + + sql = _apply_unary_op(bf_df, ops.StrFindOp("e", start=2, end=None), "string_col") + snapshot.assert_match(sql, "out_with_start.sql") + + sql = _apply_unary_op(bf_df, ops.StrFindOp("e", start=None, end=5), "string_col") + snapshot.assert_match(sql, "out_with_end.sql") + + sql = _apply_unary_op(bf_df, ops.StrFindOp("e", start=2, end=5), "string_col") + snapshot.assert_match(sql, "out_with_start_and_end.sql") + + def test_strip(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["string_col"]] sql = _apply_unary_op(bf_df, ops.StrStripOp(" "), "string_col") From d9d725cfbc3dca9e66b460cae4084e25162f2acf Mon Sep 17 00:00:00 2001 From: jialuoo Date: Mon, 25 Aug 2025 15:40:33 -0700 Subject: [PATCH 012/313] feat: Support args in series apply method (#2013) * feat: Support args in series apply method * resolve the comments --- bigframes/series.py | 32 ++++++++++++--- .../large/functions/test_managed_function.py | 28 +++++++++++++ .../large/functions/test_remote_function.py | 39 +++++++++++++++++++ 3 files changed, 94 insertions(+), 5 deletions(-) diff --git a/bigframes/series.py b/bigframes/series.py index 80952f38bc..c95b2ca37f 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -1904,9 +1904,22 @@ def _groupby_values( ) def apply( - self, func, by_row: typing.Union[typing.Literal["compat"], bool] = "compat" + self, + func, + by_row: typing.Union[typing.Literal["compat"], bool] = "compat", + *, + args: typing.Tuple = (), ) -> Series: - # TODO(shobs, b/274645634): Support convert_dtype, args, **kwargs + # Note: This signature differs from pandas.Series.apply. Specifically, + # `args` is keyword-only and `by_row` is a custom parameter here. Full + # alignment would involve breaking changes. However, given that by_row + # is not frequently used, we defer any such changes until there is a + # clear need based on user feedback. + # + # See pandas docs for reference: + # https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.apply.html + + # TODO(shobs, b/274645634): Support convert_dtype, **kwargs # is actually a ternary op if by_row not in ["compat", False]: @@ -1950,10 +1963,19 @@ def apply( raise # We are working with bigquery function at this point - result_series = self._apply_unary_op( - ops.RemoteFunctionOp(function_def=func.udf_def, apply_on_null=True) - ) + if args: + result_series = self._apply_nary_op( + ops.NaryRemoteFunctionOp(function_def=func.udf_def), args + ) + # TODO(jialuo): Investigate why `_apply_nary_op` drops the series + # `name`. Manually reassigning it here as a temporary fix. + result_series.name = self.name + else: + result_series = self._apply_unary_op( + ops.RemoteFunctionOp(function_def=func.udf_def, apply_on_null=True) + ) result_series = func._post_process_series(result_series) + return result_series def combine( diff --git a/tests/system/large/functions/test_managed_function.py b/tests/system/large/functions/test_managed_function.py index 43fb322567..6f5ef5b534 100644 --- a/tests/system/large/functions/test_managed_function.py +++ b/tests/system/large/functions/test_managed_function.py @@ -1121,3 +1121,31 @@ def _is_positive(s): finally: # Clean up the gcp assets created for the managed function. cleanup_function_assets(is_positive_mf, session.bqclient, ignore_failures=False) + + +def test_managed_function_series_apply_args(session, dataset_id, scalars_dfs): + try: + + with pytest.warns(bfe.PreviewWarning, match="udf is in preview."): + + @session.udf(dataset=dataset_id, name=prefixer.create_prefix()) + def foo_list(x: int, y0: float, y1: bytes, y2: bool) -> list[str]: + return [str(x), str(y0), str(y1), str(y2)] + + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = ( + scalars_df["int64_too"] + .apply(foo_list, args=(12.34, b"hello world", False)) + .to_pandas() + ) + pd_result = scalars_pandas_df["int64_too"].apply( + foo_list, args=(12.34, b"hello world", False) + ) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets(foo_list, session.bqclient, ignore_failures=False) diff --git a/tests/system/large/functions/test_remote_function.py b/tests/system/large/functions/test_remote_function.py index 1c44b7e5fb..cb61d3769c 100644 --- a/tests/system/large/functions/test_remote_function.py +++ b/tests/system/large/functions/test_remote_function.py @@ -2979,3 +2979,42 @@ def _ten_times(x): finally: # Clean up the gcp assets created for the remote function. cleanup_function_assets(ten_times_mf, session.bqclient, ignore_failures=False) + + +@pytest.mark.flaky(retries=2, delay=120) +def test_remote_function_series_apply_args(session, dataset_id, scalars_dfs): + try: + + @session.remote_function( + dataset=dataset_id, + reuse=False, + cloud_function_service_account="default", + ) + def foo(x: int, y: bool, z: float) -> str: + if y: + return f"{x}: y is True." + if z > 0.0: + return f"{x}: y is False and z is positive." + return f"{x}: y is False and z is non-positive." + + scalars_df, scalars_pandas_df = scalars_dfs + + args1 = (True, 10.0) + bf_result = scalars_df["int64_too"].apply(foo, args=args1).to_pandas() + pd_result = scalars_pandas_df["int64_too"].apply(foo, args=args1) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + + args2 = (False, -10.0) + foo_ref = session.read_gbq_function(foo.bigframes_bigquery_function) + + bf_result = scalars_df["int64_too"].apply(foo_ref, args=args2).to_pandas() + pd_result = scalars_pandas_df["int64_too"].apply(foo, args=args2) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + + finally: + # Clean up the gcp assets created for the remote function. + cleanup_function_assets(foo, session.bqclient, ignore_failures=False) From 8f2cad24a6a2fcacbfe49552861726be16ed41d9 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Mon, 25 Aug 2025 16:53:14 -0700 Subject: [PATCH 013/313] test: Add unit test for has_conflict_input_type (#2022) --- .../functions/test_remote_function_utils.py | 65 ++++++++++++++++--- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/tests/unit/functions/test_remote_function_utils.py b/tests/unit/functions/test_remote_function_utils.py index 0e4ca7a2ac..687c599985 100644 --- a/tests/unit/functions/test_remote_function_utils.py +++ b/tests/unit/functions/test_remote_function_utils.py @@ -217,13 +217,62 @@ def test_package_existed_helper(): assert not _utils._package_existed([], "pandas") +# Helper functions for signature inspection tests +def _func_one_arg_annotated(x: int) -> int: + """A function with one annotated arg and an annotated return type.""" + return x + + +def _func_one_arg_unannotated(x): + """A function with one unannotated arg and no return type annotation.""" + return x + + +def _func_two_args_annotated(x: int, y: str): + """A function with two annotated args and no return type annotation.""" + return f"{x}{y}" + + +def _func_two_args_unannotated(x, y): + """A function with two unannotated args and no return type annotation.""" + return f"{x}{y}" + + +def test_has_conflict_input_type_too_few_inputs(): + """Tests conflict when there are fewer input types than parameters.""" + signature = inspect.signature(_func_one_arg_annotated) + assert _utils.has_conflict_input_type(signature, input_types=[]) + + +def test_has_conflict_input_type_too_many_inputs(): + """Tests conflict when there are more input types than parameters.""" + signature = inspect.signature(_func_one_arg_annotated) + assert _utils.has_conflict_input_type(signature, input_types=[int, str]) + + +def test_has_conflict_input_type_type_mismatch(): + """Tests has_conflict_input_type with a conflicting type annotation.""" + signature = inspect.signature(_func_two_args_annotated) + + # The second type (bool) conflicts with the annotation (str). + assert _utils.has_conflict_input_type(signature, input_types=[int, bool]) + + +def test_has_conflict_input_type_no_conflict_annotated(): + """Tests that a matching, annotated signature is compatible.""" + signature = inspect.signature(_func_two_args_annotated) + assert not _utils.has_conflict_input_type(signature, input_types=[int, str]) + + +def test_has_conflict_input_type_no_conflict_unannotated(): + """Tests that a signature with no annotations is always compatible.""" + signature = inspect.signature(_func_two_args_unannotated) + assert not _utils.has_conflict_input_type(signature, input_types=[int, float]) + + def test_has_conflict_output_type_no_conflict(): """Tests has_conflict_output_type with type annotation.""" - # Helper functions with type annotation for has_conflict_output_type. - def _func_with_return_type(x: int) -> int: - return x - - signature = inspect.signature(_func_with_return_type) + signature = inspect.signature(_func_one_arg_annotated) assert _utils.has_conflict_output_type(signature, output_type=float) assert not _utils.has_conflict_output_type(signature, output_type=int) @@ -231,11 +280,7 @@ def _func_with_return_type(x: int) -> int: def test_has_conflict_output_type_no_annotation(): """Tests has_conflict_output_type without type annotation.""" - # Helper functions without type annotation for has_conflict_output_type. - def _func_without_return_type(x): - return x - - signature = inspect.signature(_func_without_return_type) + signature = inspect.signature(_func_one_arg_unannotated) assert not _utils.has_conflict_output_type(signature, output_type=int) assert not _utils.has_conflict_output_type(signature, output_type=float) From 9d4504be310d38b63515d67c0f60d2e48e68c7b5 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Mon, 25 Aug 2025 17:27:47 -0700 Subject: [PATCH 014/313] feat: Support callable for dataframe mask method (#2020) --- bigframes/dataframe.py | 27 +++++++------ .../large/functions/test_managed_function.py | 38 ++++++++++++++++--- .../large/functions/test_remote_function.py | 25 ++++++++++-- tests/system/small/test_dataframe.py | 12 ++++++ 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 921893fb83..85b8245272 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -2828,6 +2828,19 @@ def itertuples( for item in df.itertuples(index=index, name=name): yield item + def _apply_callable(self, condition): + """Executes the possible callable condition as needed.""" + if callable(condition): + # When it's a bigframes function. + if hasattr(condition, "bigframes_bigquery_function"): + return self.apply(condition, axis=1) + + # When it's a plain Python function. + return condition(self) + + # When it's not a callable. + return condition + def where(self, cond, other=None): if isinstance(other, bigframes.series.Series): raise ValueError("Seires is not a supported replacement type!") @@ -2839,16 +2852,8 @@ def where(self, cond, other=None): # Execute it with the DataFrame when cond or/and other is callable. # It can be either a plain python function or remote/managed function. - if callable(cond): - if hasattr(cond, "bigframes_bigquery_function"): - cond = self.apply(cond, axis=1) - else: - cond = cond(self) - if callable(other): - if hasattr(other, "bigframes_bigquery_function"): - other = self.apply(other, axis=1) - else: - other = other(self) + cond = self._apply_callable(cond) + other = self._apply_callable(other) aligned_block, (_, _) = self._block.join(cond._block, how="left") # No left join is needed when 'other' is None or constant. @@ -2899,7 +2904,7 @@ def where(self, cond, other=None): return result def mask(self, cond, other=None): - return self.where(~cond, other=other) + return self.where(~self._apply_callable(cond), other=other) def dropna( self, diff --git a/tests/system/large/functions/test_managed_function.py b/tests/system/large/functions/test_managed_function.py index 6f5ef5b534..73335afa3c 100644 --- a/tests/system/large/functions/test_managed_function.py +++ b/tests/system/large/functions/test_managed_function.py @@ -965,7 +965,7 @@ def float_parser(row): ) -def test_managed_function_df_where(session, dataset_id, scalars_dfs): +def test_managed_function_df_where_mask(session, dataset_id, scalars_dfs): try: # The return type has to be bool type for callable where condition. @@ -987,7 +987,7 @@ def is_sum_positive(a, b): pd_int64_df = scalars_pandas_df[int64_cols] pd_int64_df_filtered = pd_int64_df.dropna() - # Use callable condition in dataframe.where method. + # Test callable condition in dataframe.where method. bf_result = bf_int64_df_filtered.where(is_sum_positive_mf).to_pandas() # Pandas doesn't support such case, use following as workaround. pd_result = pd_int64_df_filtered.where(pd_int64_df_filtered.sum(axis=1) > 0) @@ -995,7 +995,7 @@ def is_sum_positive(a, b): # Ignore any dtype difference. pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) - # Make sure the read_gbq_function path works for this function. + # Make sure the read_gbq_function path works for dataframe.where method. is_sum_positive_ref = session.read_gbq_function( function_name=is_sum_positive_mf.bigframes_bigquery_function ) @@ -1012,6 +1012,19 @@ def is_sum_positive(a, b): bf_result_gbq, pd_result_gbq, check_dtype=False ) + # Test callable condition in dataframe.mask method. + bf_result_gbq = bf_int64_df_filtered.mask( + is_sum_positive_ref, -bf_int64_df_filtered + ).to_pandas() + pd_result_gbq = pd_int64_df_filtered.mask( + pd_int64_df_filtered.sum(axis=1) > 0, -pd_int64_df_filtered + ) + + # Ignore any dtype difference. + pandas.testing.assert_frame_equal( + bf_result_gbq, pd_result_gbq, check_dtype=False + ) + finally: # Clean up the gcp assets created for the managed function. cleanup_function_assets( @@ -1019,7 +1032,7 @@ def is_sum_positive(a, b): ) -def test_managed_function_df_where_series(session, dataset_id, scalars_dfs): +def test_managed_function_df_where_mask_series(session, dataset_id, scalars_dfs): try: # The return type has to be bool type for callable where condition. @@ -1041,14 +1054,14 @@ def is_sum_positive_series(s): pd_int64_df = scalars_pandas_df[int64_cols] pd_int64_df_filtered = pd_int64_df.dropna() - # Use callable condition in dataframe.where method. + # Test callable condition in dataframe.where method. bf_result = bf_int64_df_filtered.where(is_sum_positive_series).to_pandas() pd_result = pd_int64_df_filtered.where(is_sum_positive_series) # Ignore any dtype difference. pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) - # Make sure the read_gbq_function path works for this function. + # Make sure the read_gbq_function path works for dataframe.where method. is_sum_positive_series_ref = session.read_gbq_function( function_name=is_sum_positive_series_mf.bigframes_bigquery_function, is_row_processor=True, @@ -1070,6 +1083,19 @@ def func_for_other(x): bf_result_gbq, pd_result_gbq, check_dtype=False ) + # Test callable condition in dataframe.mask method. + bf_result_gbq = bf_int64_df_filtered.mask( + is_sum_positive_series_ref, func_for_other + ).to_pandas() + pd_result_gbq = pd_int64_df_filtered.mask( + is_sum_positive_series, func_for_other + ) + + # Ignore any dtype difference. + pandas.testing.assert_frame_equal( + bf_result_gbq, pd_result_gbq, check_dtype=False + ) + finally: # Clean up the gcp assets created for the managed function. cleanup_function_assets( diff --git a/tests/system/large/functions/test_remote_function.py b/tests/system/large/functions/test_remote_function.py index cb61d3769c..3c453a52a4 100644 --- a/tests/system/large/functions/test_remote_function.py +++ b/tests/system/large/functions/test_remote_function.py @@ -2850,7 +2850,7 @@ def foo(x: int) -> int: @pytest.mark.flaky(retries=2, delay=120) -def test_remote_function_df_where(session, dataset_id, scalars_dfs): +def test_remote_function_df_where_mask(session, dataset_id, scalars_dfs): try: # The return type has to be bool type for callable where condition. @@ -2873,7 +2873,7 @@ def is_sum_positive(a, b): pd_int64_df = scalars_pandas_df[int64_cols] pd_int64_df_filtered = pd_int64_df.dropna() - # Use callable condition in dataframe.where method. + # Test callable condition in dataframe.where method. bf_result = bf_int64_df_filtered.where(is_sum_positive_mf, 0).to_pandas() # Pandas doesn't support such case, use following as workaround. pd_result = pd_int64_df_filtered.where(pd_int64_df_filtered.sum(axis=1) > 0, 0) @@ -2881,6 +2881,14 @@ def is_sum_positive(a, b): # Ignore any dtype difference. pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + # Test callable condition in dataframe.mask method. + bf_result = bf_int64_df_filtered.mask(is_sum_positive_mf, 0).to_pandas() + # Pandas doesn't support such case, use following as workaround. + pd_result = pd_int64_df_filtered.mask(pd_int64_df_filtered.sum(axis=1) > 0, 0) + + # Ignore any dtype difference. + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + finally: # Clean up the gcp assets created for the remote function. cleanup_function_assets( @@ -2889,7 +2897,7 @@ def is_sum_positive(a, b): @pytest.mark.flaky(retries=2, delay=120) -def test_remote_function_df_where_series(session, dataset_id, scalars_dfs): +def test_remote_function_df_where_mask_series(session, dataset_id, scalars_dfs): try: # The return type has to be bool type for callable where condition. @@ -2916,7 +2924,7 @@ def is_sum_positive_series(s): def func_for_other(x): return -x - # Use callable condition in dataframe.where method. + # Test callable condition in dataframe.where method. bf_result = bf_int64_df_filtered.where( is_sum_positive_series, func_for_other ).to_pandas() @@ -2925,6 +2933,15 @@ def func_for_other(x): # Ignore any dtype difference. pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + # Test callable condition in dataframe.mask method. + bf_result = bf_int64_df_filtered.mask( + is_sum_positive_series_mf, func_for_other + ).to_pandas() + pd_result = pd_int64_df_filtered.mask(is_sum_positive_series, func_for_other) + + # Ignore any dtype difference. + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + finally: # Clean up the gcp assets created for the remote function. cleanup_function_assets( diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 8a570ade45..51f4674ba4 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -406,6 +406,18 @@ def test_mask_series_cond(scalars_df_index, scalars_pandas_df_index): pandas.testing.assert_frame_equal(bf_result, pd_result) +def test_mask_callable(scalars_df_index, scalars_pandas_df_index): + def is_positive(x): + return x > 0 + + bf_df = scalars_df_index[["int64_too", "int64_col", "float64_col"]] + pd_df = scalars_pandas_df_index[["int64_too", "int64_col", "float64_col"]] + bf_result = bf_df.mask(cond=is_positive, other=lambda x: x + 1).to_pandas() + pd_result = pd_df.mask(cond=is_positive, other=lambda x: x + 1) + + pandas.testing.assert_frame_equal(bf_result, pd_result) + + def test_where_multi_column(scalars_df_index, scalars_pandas_df_index): # Test when a dataframe has multi-columns. columns = ["int64_col", "float64_col"] From 9ed00780bd7ec2f8c528dcc762bf8bb49fcc98ea Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Tue, 26 Aug 2025 11:18:13 -0700 Subject: [PATCH 015/313] chore: implement GeoStBufferOp, geo_st_centroid_op, geo_st_convexhull_op and MapOp for sqlglot compilers (#2021) --- .../sqlglot/expressions/unary_compiler.py | 32 +++++++++++++++++++ .../test_geo_st_buffer/out.sql | 13 ++++++++ .../test_geo_st_centroid/out.sql | 13 ++++++++ .../test_geo_st_convexhull/out.sql | 13 ++++++++ .../test_unary_compiler/test_map/out.sql | 13 ++++++++ .../expressions/test_unary_compiler.py | 30 +++++++++++++++++ 6 files changed, 114 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_buffer/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_centroid/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_convexhull/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_map/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/unary_compiler.py b/bigframes/core/compile/sqlglot/expressions/unary_compiler.py index a5cffdc10a..3d527f2a2f 100644 --- a/bigframes/core/compile/sqlglot/expressions/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/expressions/unary_compiler.py @@ -344,6 +344,27 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: return sge.func("ST_BOUNDARY", expr.expr) +@UNARY_OP_REGISTRATION.register(ops.GeoStBufferOp) +def _(op: ops.GeoStBufferOp, expr: TypedExpr) -> sge.Expression: + return sge.func( + "ST_BUFFER", + expr.expr, + sge.convert(op.buffer_radius), + sge.convert(op.num_seg_quarter_circle), + sge.convert(op.use_spheroid), + ) + + +@UNARY_OP_REGISTRATION.register(ops.geo_st_centroid_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("ST_CENTROID", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.geo_st_convexhull_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("ST_CONVEXHULL", expr.expr) + + @UNARY_OP_REGISTRATION.register(ops.geo_st_geogfromtext_op) def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: return sge.func("SAFE.ST_GEOGFROMTEXT", expr.expr) @@ -516,6 +537,17 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: return sge.Lower(this=expr.expr) +@UNARY_OP_REGISTRATION.register(ops.MapOp) +def _(op: ops.MapOp, expr: TypedExpr) -> sge.Expression: + return sge.Case( + this=expr.expr, + ifs=[ + sge.If(this=sge.convert(key), true=sge.convert(value)) + for key, value in op.mappings + ], + ) + + @UNARY_OP_REGISTRATION.register(ops.minute_op) def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: return sge.Extract(this=sge.Identifier(this="MINUTE"), expression=expr.expr) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_buffer/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_buffer/out.sql new file mode 100644 index 0000000000..9669c39a9f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_buffer/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_BUFFER(`bfcol_0`, 1.0, 8.0, FALSE) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `geography_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_centroid/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_centroid/out.sql new file mode 100644 index 0000000000..97867318ad --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_centroid/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_CENTROID(`bfcol_0`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `geography_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_convexhull/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_convexhull/out.sql new file mode 100644 index 0000000000..8bb5801173 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_convexhull/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_CONVEXHULL(`bfcol_0`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `geography_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_map/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_map/out.sql new file mode 100644 index 0000000000..a17d6584ce --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_map/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE `bfcol_0` WHEN 'value1' THEN 'mapped1' END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py index 5c51068ce7..2a3297a46c 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py @@ -174,6 +174,27 @@ def test_geo_st_boundary(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_geo_st_buffer(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["geography_col"]] + sql = _apply_unary_op(bf_df, ops.GeoStBufferOp(1.0, 8.0, False), "geography_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_centroid(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["geography_col"]] + sql = _apply_unary_op(bf_df, ops.geo_st_centroid_op, "geography_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_convexhull(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["geography_col"]] + sql = _apply_unary_op(bf_df, ops.geo_st_convexhull_op, "geography_col") + + snapshot.assert_match(sql, "out.sql") + + def test_geo_st_geogfromtext(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["string_col"]] sql = _apply_unary_op(bf_df, ops.geo_st_geogfromtext_op, "string_col") @@ -370,6 +391,15 @@ def test_lower(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_map(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op( + bf_df, ops.MapOp(mappings=(("value1", "mapped1"),)), "string_col" + ) + + snapshot.assert_match(sql, "out.sql") + + def test_lstrip(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["string_col"]] sql = _apply_unary_op(bf_df, ops.StrLstripOp(" "), "string_col") From cfa4b2a5e059164d1d961f69191fb7541e5882a1 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Tue, 26 Aug 2025 21:33:13 -0700 Subject: [PATCH 016/313] chore: implement StartsWithOp, EndsWithOp, StringSplitOp and ZfillOp for sqlglot compilers (#2027) --- .../sqlglot/expressions/unary_compiler.py | 58 +++++++++++++++++++ .../test_endswith/multiple_patterns.sql | 13 +++++ .../test_endswith/no_pattern.sql | 13 +++++ .../test_endswith/single_pattern.sql | 13 +++++ .../test_startswith/multiple_patterns.sql | 13 +++++ .../test_startswith/no_pattern.sql | 13 +++++ .../test_startswith/single_pattern.sql | 13 +++++ .../test_string_split/out.sql | 13 +++++ .../test_unary_compiler/test_zfill/out.sql | 17 ++++++ .../expressions/test_unary_compiler.py | 36 ++++++++++++ 10 files changed, 202 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/multiple_patterns.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/no_pattern.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/single_pattern.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/multiple_patterns.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/no_pattern.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/single_pattern.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_string_split/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_zfill/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/unary_compiler.py b/bigframes/core/compile/sqlglot/expressions/unary_compiler.py index 3d527f2a2f..98f1603be7 100644 --- a/bigframes/core/compile/sqlglot/expressions/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/expressions/unary_compiler.py @@ -14,6 +14,7 @@ from __future__ import annotations +import functools import typing import pandas as pd @@ -292,6 +293,18 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: return sge.Extract(this=sge.Identifier(this="DAYOFYEAR"), expression=expr.expr) +@UNARY_OP_REGISTRATION.register(ops.EndsWithOp) +def _(op: ops.EndsWithOp, expr: TypedExpr) -> sge.Expression: + if not op.pat: + return sge.false() + + def to_endswith(pat: str) -> sge.Expression: + return sge.func("ENDS_WITH", expr.expr, sge.convert(pat)) + + conditions = [to_endswith(pat) for pat in op.pat] + return functools.reduce(lambda x, y: sge.Or(this=x, expression=y), conditions) + + @UNARY_OP_REGISTRATION.register(ops.exp_op) def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: return sge.Case( @@ -633,6 +646,18 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ) +@UNARY_OP_REGISTRATION.register(ops.StartsWithOp) +def _(op: ops.StartsWithOp, expr: TypedExpr) -> sge.Expression: + if not op.pat: + return sge.false() + + def to_startswith(pat: str) -> sge.Expression: + return sge.func("STARTS_WITH", expr.expr, sge.convert(pat)) + + conditions = [to_startswith(pat) for pat in op.pat] + return functools.reduce(lambda x, y: sge.Or(this=x, expression=y), conditions) + + @UNARY_OP_REGISTRATION.register(ops.StrStripOp) def _(op: ops.StrStripOp, expr: TypedExpr) -> sge.Expression: return sge.Trim(this=sge.convert(op.to_strip), expression=expr.expr) @@ -656,6 +681,11 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ) +@UNARY_OP_REGISTRATION.register(ops.StringSplitOp) +def _(op: ops.StringSplitOp, expr: TypedExpr) -> sge.Expression: + return sge.Split(this=expr.expr, expression=sge.convert(op.pat)) + + @UNARY_OP_REGISTRATION.register(ops.StrGetOp) def _(op: ops.StrGetOp, expr: TypedExpr) -> sge.Expression: return sge.Substring( @@ -808,3 +838,31 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: @UNARY_OP_REGISTRATION.register(ops.year_op) def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: return sge.Extract(this=sge.Identifier(this="YEAR"), expression=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.ZfillOp) +def _(op: ops.ZfillOp, expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=sge.EQ( + this=sge.Substring( + this=expr.expr, start=sge.convert(1), length=sge.convert(1) + ), + expression=sge.convert("-"), + ), + true=sge.Concat( + expressions=[ + sge.convert("-"), + sge.func( + "LPAD", + sge.Substring(this=expr.expr, start=sge.convert(1)), + sge.convert(op.width - 1), + sge.convert("0"), + ), + ] + ), + ) + ], + default=sge.func("LPAD", expr.expr, sge.convert(op.width), sge.convert("0")), + ) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/multiple_patterns.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/multiple_patterns.sql new file mode 100644 index 0000000000..f224471e79 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/multiple_patterns.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ENDS_WITH(`bfcol_0`, 'ab') OR ENDS_WITH(`bfcol_0`, 'cd') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/no_pattern.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/no_pattern.sql new file mode 100644 index 0000000000..e9f61ddd7c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/no_pattern.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + FALSE AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/single_pattern.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/single_pattern.sql new file mode 100644 index 0000000000..a4e259f0b2 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/single_pattern.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ENDS_WITH(`bfcol_0`, 'ab') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/multiple_patterns.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/multiple_patterns.sql new file mode 100644 index 0000000000..061b57e208 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/multiple_patterns.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + STARTS_WITH(`bfcol_0`, 'ab') OR STARTS_WITH(`bfcol_0`, 'cd') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/no_pattern.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/no_pattern.sql new file mode 100644 index 0000000000..e9f61ddd7c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/no_pattern.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + FALSE AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/single_pattern.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/single_pattern.sql new file mode 100644 index 0000000000..726ce05b8c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/single_pattern.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + STARTS_WITH(`bfcol_0`, 'ab') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_string_split/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_string_split/out.sql new file mode 100644 index 0000000000..fea0d6eaf1 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_string_split/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + SPLIT(`bfcol_0`, ',') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_zfill/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_zfill/out.sql new file mode 100644 index 0000000000..e5d70ab44b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_zfill/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN SUBSTRING(`bfcol_0`, 1, 1) = '-' + THEN CONCAT('-', LPAD(SUBSTRING(`bfcol_0`, 1), 9, '0')) + ELSE LPAD(`bfcol_0`, 10, '0') + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py index 2a3297a46c..f011721ee5 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py @@ -125,6 +125,18 @@ def test_dayofyear(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_endswith(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.EndsWithOp(pat=("ab",)), "string_col") + snapshot.assert_match(sql, "single_pattern.sql") + + sql = _apply_unary_op(bf_df, ops.EndsWithOp(pat=("ab", "cd")), "string_col") + snapshot.assert_match(sql, "multiple_patterns.sql") + + sql = _apply_unary_op(bf_df, ops.EndsWithOp(pat=()), "string_col") + snapshot.assert_match(sql, "no_pattern.sql") + + def test_exp(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["float64_col"]] sql = _apply_unary_op(bf_df, ops.exp_op, "float64_col") @@ -501,6 +513,18 @@ def test_sqrt(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_startswith(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.StartsWithOp(pat=("ab",)), "string_col") + snapshot.assert_match(sql, "single_pattern.sql") + + sql = _apply_unary_op(bf_df, ops.StartsWithOp(pat=("ab", "cd")), "string_col") + snapshot.assert_match(sql, "multiple_patterns.sql") + + sql = _apply_unary_op(bf_df, ops.StartsWithOp(pat=()), "string_col") + snapshot.assert_match(sql, "no_pattern.sql") + + def test_str_get(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["string_col"]] sql = _apply_unary_op(bf_df, ops.StrGetOp(1), "string_col") @@ -650,6 +674,12 @@ def test_sinh(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_string_split(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.StringSplitOp(pat=","), "string_col") + snapshot.assert_match(sql, "out.sql") + + def test_tan(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["float64_col"]] sql = _apply_unary_op(bf_df, ops.tan_op, "float64_col") @@ -790,3 +820,9 @@ def test_year(scalar_types_df: bpd.DataFrame, snapshot): sql = _apply_unary_op(bf_df, ops.year_op, "timestamp_col") snapshot.assert_match(sql, "out.sql") + + +def test_zfill(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.ZfillOp(width=10), "string_col") + snapshot.assert_match(sql, "out.sql") From 6bf06a7e16f6aec9f19f748b07e9e0fb2c276a4a Mon Sep 17 00:00:00 2001 From: jialuoo Date: Wed, 27 Aug 2025 10:27:46 -0700 Subject: [PATCH 017/313] test: Add unit test for get_bigframes_function_name (#2031) --- .../functions/test_remote_function_utils.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/unit/functions/test_remote_function_utils.py b/tests/unit/functions/test_remote_function_utils.py index 687c599985..e46e04b427 100644 --- a/tests/unit/functions/test_remote_function_utils.py +++ b/tests/unit/functions/test_remote_function_utils.py @@ -76,6 +76,32 @@ def test_get_cloud_function_name(func_hash, session_id, uniq_suffix, expected_na assert result == expected_name +@pytest.mark.parametrize( + "function_hash, session_id, uniq_suffix, expected_name", + [ + ( + "hash123", + "session456", + None, + "bigframes_session456_hash123", + ), + ( + "hash789", + "sessionABC", + "suffixDEF", + "bigframes_sessionABC_hash789_suffixDEF", + ), + ], +) +def test_get_bigframes_function_name( + function_hash, session_id, uniq_suffix, expected_name +): + """Tests the construction of the BigQuery function name from its parts.""" + result = _utils.get_bigframes_function_name(function_hash, session_id, uniq_suffix) + + assert result == expected_name + + def test_get_updated_package_requirements_no_extra_package(): """Tests with no extra package.""" result = _utils.get_updated_package_requirements(capture_references=False) From fc44bc8f3a96daf6996623e9b6938975f4dfd6c5 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Wed, 27 Aug 2025 10:59:50 -0700 Subject: [PATCH 018/313] test: Add unit test for get_hash (#2025) --- .../functions/test_remote_function_utils.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/unit/functions/test_remote_function_utils.py b/tests/unit/functions/test_remote_function_utils.py index e46e04b427..8ddd39d857 100644 --- a/tests/unit/functions/test_remote_function_utils.py +++ b/tests/unit/functions/test_remote_function_utils.py @@ -243,6 +243,78 @@ def test_package_existed_helper(): assert not _utils._package_existed([], "pandas") +def _function_add_one(x): + return x + 1 + + +def _function_add_two(x): + return x + 2 + + +@pytest.mark.parametrize( + "func1, func2, should_be_equal, description", + [ + ( + _function_add_one, + _function_add_one, + True, + "Identical functions should have the same hash.", + ), + ( + _function_add_one, + _function_add_two, + False, + "Different functions should have different hashes.", + ), + ], +) +def test_get_hash_without_package_requirements( + func1, func2, should_be_equal, description +): + """Tests function hashes without any requirements.""" + hash1 = _utils.get_hash(func1) + hash2 = _utils.get_hash(func2) + + if should_be_equal: + assert hash1 == hash2, f"FAILED: {description}" + else: + assert hash1 != hash2, f"FAILED: {description}" + + +@pytest.mark.parametrize( + "reqs1, reqs2, should_be_equal, description", + [ + ( + None, + ["pandas>=1.0"], + False, + "Hash with or without requirements should differ from hash.", + ), + ( + ["pandas", "numpy", "scikit-learn"], + ["numpy", "scikit-learn", "pandas"], + True, + "Same requirements should produce the same hash.", + ), + ( + ["pandas==1.0"], + ["pandas==2.0"], + False, + "Different requirement versions should produce different hashes.", + ), + ], +) +def test_get_hash_with_package_requirements(reqs1, reqs2, should_be_equal, description): + """Tests how package requirements affect the final hash.""" + hash1 = _utils.get_hash(_function_add_one, package_requirements=reqs1) + hash2 = _utils.get_hash(_function_add_one, package_requirements=reqs2) + + if should_be_equal: + assert hash1 == hash2, f"FAILED: {description}" + else: + assert hash1 != hash2, f"FAILED: {description}" + + # Helper functions for signature inspection tests def _func_one_arg_annotated(x: int) -> int: """A function with one annotated arg and an annotated return type.""" From 2c72c56fb5893eb01d5aec6273d11945c9c532c5 Mon Sep 17 00:00:00 2001 From: Garrett Wu <6505921+GarrettWu@users.noreply.github.com> Date: Wed, 27 Aug 2025 11:52:13 -0700 Subject: [PATCH 019/313] feat: add parameter shuffle for ml.model_selection.train_test_split (#2030) * feat: add parameter shuffle for ml.model_selection.train_test_split * mypy * rename --- bigframes/ml/model_selection.py | 26 ++++++- bigframes/ml/utils.py | 24 ++++++ tests/system/small/ml/test_model_selection.py | 74 +++++++++++++++++++ 3 files changed, 120 insertions(+), 4 deletions(-) diff --git a/bigframes/ml/model_selection.py b/bigframes/ml/model_selection.py index abb4b0f26c..ca089bb551 100644 --- a/bigframes/ml/model_selection.py +++ b/bigframes/ml/model_selection.py @@ -18,6 +18,7 @@ import inspect +from itertools import chain import time from typing import cast, Generator, List, Optional, Union @@ -36,12 +37,9 @@ def train_test_split( train_size: Union[float, None] = None, random_state: Union[int, None] = None, stratify: Union[bpd.Series, None] = None, + shuffle: bool = True, ) -> List[Union[bpd.DataFrame, bpd.Series]]: - # TODO(garrettwu): scikit-learn throws an error when the dataframes don't have the same - # number of rows. We probably want to do something similar. Now the implementation is based - # on index. We'll move to based on ordering first. - if test_size is None: if train_size is None: test_size = 0.25 @@ -61,6 +59,26 @@ def train_test_split( f"The sum of train_size and test_size exceeds 1.0. train_size: {train_size}. test_size: {test_size}" ) + if not shuffle: + if stratify is not None: + raise ValueError( + "Stratified train/test split is not implemented for shuffle=False" + ) + bf_arrays = list(utils.batch_convert_to_bf_equivalent(*arrays)) + + total_rows = len(bf_arrays[0]) + train_rows = int(total_rows * train_size) + test_rows = total_rows - train_rows + + return list( + chain.from_iterable( + [ + [bf_array.head(train_rows), bf_array.tail(test_rows)] + for bf_array in bf_arrays + ] + ) + ) + dfs = list(utils.batch_convert_to_dataframe(*arrays)) def _stratify_split(df: bpd.DataFrame, stratify: bpd.Series) -> List[bpd.DataFrame]: diff --git a/bigframes/ml/utils.py b/bigframes/ml/utils.py index 5c02789576..80630c4f81 100644 --- a/bigframes/ml/utils.py +++ b/bigframes/ml/utils.py @@ -79,6 +79,30 @@ def batch_convert_to_series( ) +def batch_convert_to_bf_equivalent( + *input: ArrayType, session: Optional[Session] = None +) -> Generator[Union[bpd.DataFrame, bpd.Series], None, None]: + """Converts the input to BigFrames DataFrame or Series. + + Args: + session: + The session to convert local pandas instances to BigFrames counter-parts. + It is not used if the input itself is already a BigFrame data frame or series. + + """ + _validate_sessions(*input, session=session) + + for frame in input: + if isinstance(frame, bpd.DataFrame) or isinstance(frame, pd.DataFrame): + yield convert.to_bf_dataframe(frame, default_index=None, session=session) + elif isinstance(frame, bpd.Series) or isinstance(frame, pd.Series): + yield convert.to_bf_series( + _get_only_column(frame), default_index=None, session=session + ) + else: + raise ValueError(f"Unsupported type: {type(frame)}") + + def _validate_sessions(*input: ArrayType, session: Optional[Session]): session_ids = set( i._session.session_id diff --git a/tests/system/small/ml/test_model_selection.py b/tests/system/small/ml/test_model_selection.py index c1a1e073b9..ebce6e405a 100644 --- a/tests/system/small/ml/test_model_selection.py +++ b/tests/system/small/ml/test_model_selection.py @@ -13,12 +13,14 @@ # limitations under the License. import math +from typing import cast import pandas as pd import pytest from bigframes.ml import model_selection import bigframes.pandas as bpd +import bigframes.session @pytest.mark.parametrize( @@ -219,6 +221,78 @@ def test_train_test_split_seeded_correct_rows( ) +def test_train_test_split_no_shuffle_correct_shape( + penguins_df_default_index: bpd.DataFrame, +): + X = penguins_df_default_index[["species"]] + y = penguins_df_default_index["body_mass_g"] + X_train, X_test, y_train, y_test = model_selection.train_test_split( + X, y, shuffle=False + ) + assert isinstance(X_train, bpd.DataFrame) + assert isinstance(X_test, bpd.DataFrame) + assert isinstance(y_train, bpd.Series) + assert isinstance(y_test, bpd.Series) + + assert X_train.shape == (258, 1) + assert X_test.shape == (86, 1) + assert y_train.shape == (258,) + assert y_test.shape == (86,) + + +def test_train_test_split_no_shuffle_correct_rows( + session: bigframes.session.Session, penguins_pandas_df_default_index: bpd.DataFrame +): + # Note that we're using `penguins_pandas_df_default_index` as this test depends + # on a stable row order being present end to end + # filter down to the chunkiest penguins, to keep our test code a reasonable size + all_data = penguins_pandas_df_default_index[ + penguins_pandas_df_default_index.body_mass_g > 5500 + ].sort_index() + + # Note that bigframes loses the index if it doesn't have a name + all_data.index.name = "rowindex" + + df = session.read_pandas(all_data) + + X = df[ + [ + "species", + "island", + "culmen_length_mm", + ] + ] + y = df["body_mass_g"] + X_train, X_test, y_train, y_test = model_selection.train_test_split( + X, y, shuffle=False + ) + + X_train_pd = cast(bpd.DataFrame, X_train).to_pandas() + X_test_pd = cast(bpd.DataFrame, X_test).to_pandas() + y_train_pd = cast(bpd.Series, y_train).to_pandas() + y_test_pd = cast(bpd.Series, y_test).to_pandas() + + total_rows = len(all_data) + train_size = 0.75 + train_rows = int(total_rows * train_size) + test_rows = total_rows - train_rows + + expected_X_train = all_data.head(train_rows)[ + ["species", "island", "culmen_length_mm"] + ] + expected_y_train = all_data.head(train_rows)["body_mass_g"] + + expected_X_test = all_data.tail(test_rows)[ + ["species", "island", "culmen_length_mm"] + ] + expected_y_test = all_data.tail(test_rows)["body_mass_g"] + + pd.testing.assert_frame_equal(X_train_pd, expected_X_train) + pd.testing.assert_frame_equal(X_test_pd, expected_X_test) + pd.testing.assert_series_equal(y_train_pd, expected_y_train) + pd.testing.assert_series_equal(y_test_pd, expected_y_test) + + @pytest.mark.parametrize( ("train_size", "test_size"), [ From ba0d23b59c44ba5a46ace8182ad0e0cfc703b3ab Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Wed, 27 Aug 2025 12:30:23 -0700 Subject: [PATCH 020/313] feat: support multi-column assignment for DataFrame (#2028) * feat: support multi-column assignment for DataFrame * fix lint * fix mypy * fix Sequence type checking bug --- bigframes/dataframe.py | 41 +++++++++++-- tests/system/small/test_dataframe.py | 61 +++++++++++++++++++ .../bigframes_vendored/pandas/core/frame.py | 34 ++++++++++- 3 files changed, 131 insertions(+), 5 deletions(-) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 85b8245272..b2947f7493 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -26,6 +26,7 @@ import traceback import typing from typing import ( + Any, Callable, Dict, Hashable, @@ -91,6 +92,7 @@ import bigframes.session SingleItemValue = Union[bigframes.series.Series, int, float, str, Callable] + MultiItemValue = Union["DataFrame", Sequence[int | float | str | Callable]] LevelType = typing.Hashable LevelsType = typing.Union[LevelType, typing.Sequence[LevelType]] @@ -884,8 +886,13 @@ def __delitem__(self, key: str): df = self.drop(columns=[key]) self._set_block(df._get_block()) - def __setitem__(self, key: str, value: SingleItemValue): - df = self._assign_single_item(key, value) + def __setitem__( + self, key: str | list[str], value: SingleItemValue | MultiItemValue + ): + if isinstance(key, list): + df = self._assign_multi_items(key, value) + else: + df = self._assign_single_item(key, value) self._set_block(df._get_block()) __setitem__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__setitem__) @@ -2212,7 +2219,7 @@ def assign(self, **kwargs) -> DataFrame: def _assign_single_item( self, k: str, - v: SingleItemValue, + v: SingleItemValue | MultiItemValue, ) -> DataFrame: if isinstance(v, bigframes.series.Series): return self._assign_series_join_on_index(k, v) @@ -2230,7 +2237,33 @@ def _assign_single_item( elif utils.is_list_like(v): return self._assign_single_item_listlike(k, v) else: - return self._assign_scalar(k, v) + return self._assign_scalar(k, v) # type: ignore + + def _assign_multi_items( + self, + k: list[str], + v: SingleItemValue | MultiItemValue, + ) -> DataFrame: + value_sources: Sequence[Any] = [] + if isinstance(v, DataFrame): + value_sources = [v[col] for col in v.columns] + elif isinstance(v, bigframes.series.Series): + # For behavior consistency with Pandas. + raise ValueError("Columns must be same length as key") + elif isinstance(v, Sequence): + value_sources = v + else: + # We assign the same scalar value to all target columns. + value_sources = [v] * len(k) + + if len(value_sources) != len(k): + raise ValueError("Columns must be same length as key") + + # Repeatedly assign columns in order. + result = self._assign_single_item(k[0], value_sources[0]) + for target, source in zip(k[1:], value_sources[1:]): + result = result._assign_single_item(target, source) + return result def _assign_single_item_listlike(self, k: str, v: Sequence) -> DataFrame: given_rows = len(v) diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 51f4674ba4..c7f9627531 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -1138,6 +1138,67 @@ def test_assign_new_column_w_setitem_list_error(scalars_dfs): bf_df["new_col"] = [1, 2, 3] +@pytest.mark.parametrize( + ("key", "value"), + [ + pytest.param(["int64_col", "int64_too"], 1, id="scalar_to_existing_column"), + pytest.param( + ["int64_col", "int64_too"], [1, 2], id="sequence_to_existing_column" + ), + pytest.param( + ["int64_col", "new_col"], [1, 2], id="sequence_to_partial_new_column" + ), + pytest.param( + ["new_col", "new_col_too"], [1, 2], id="sequence_to_full_new_column" + ), + ], +) +def test_setitem_multicolumn_with_literals(scalars_dfs, key, value): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df.copy() + pd_result = scalars_pandas_df.copy() + + bf_result[key] = value + pd_result[key] = value + + pd.testing.assert_frame_equal(pd_result, bf_result.to_pandas(), check_dtype=False) + + +def test_setitem_multicolumn_with_literals_different_lengths_raise_error(scalars_dfs): + scalars_df, _ = scalars_dfs + bf_result = scalars_df.copy() + + with pytest.raises(ValueError): + bf_result[["int64_col", "int64_too"]] = [1] + + +def test_setitem_multicolumn_with_dataframes(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df.copy() + pd_result = scalars_pandas_df.copy() + + bf_result[["int64_col", "int64_too"]] = bf_result[["int64_too", "int64_col"]] / 2 + pd_result[["int64_col", "int64_too"]] = pd_result[["int64_too", "int64_col"]] / 2 + + pd.testing.assert_frame_equal(pd_result, bf_result.to_pandas(), check_dtype=False) + + +def test_setitem_multicolumn_with_dataframes_series_on_rhs_raise_error(scalars_dfs): + scalars_df, _ = scalars_dfs + bf_result = scalars_df.copy() + + with pytest.raises(ValueError): + bf_result[["int64_col", "int64_too"]] = bf_result["int64_col"] / 2 + + +def test_setitem_multicolumn_with_dataframes_different_lengths_raise_error(scalars_dfs): + scalars_df, _ = scalars_dfs + bf_result = scalars_df.copy() + + with pytest.raises(ValueError): + bf_result[["int64_col"]] = bf_result[["int64_col", "int64_too"]] / 2 + + def test_assign_existing_column(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs kwargs = {"int64_col": 2} diff --git a/third_party/bigframes_vendored/pandas/core/frame.py b/third_party/bigframes_vendored/pandas/core/frame.py index 44ca558070..953ece9beb 100644 --- a/third_party/bigframes_vendored/pandas/core/frame.py +++ b/third_party/bigframes_vendored/pandas/core/frame.py @@ -7626,11 +7626,43 @@ def __setitem__(self, key, value): [3 rows x 5 columns] + You can assign a scalar to multiple columns. + + >>> df[["age", "new_age"]] = 25 + >>> df + name age location country new_age + 0 alpha 25 WA USA 25 + 1 beta 25 NY USA 25 + 2 gamma 25 CA USA 25 + + [3 rows x 5 columns] + + You can use a sequence of scalars for assignment of multiple columns: + + >>> df[["age", "is_happy"]] = [20, True] + >>> df + name age location country new_age is_happy + 0 alpha 20 WA USA 25 True + 1 beta 20 NY USA 25 True + 2 gamma 20 CA USA 25 True + + [3 rows x 6 columns] + + You can use a dataframe for assignment of multiple columns: + >>> df[["age", "new_age"]] = df[["new_age", "age"]] + >>> df + name age location country new_age is_happy + 0 alpha 25 WA USA 20 True + 1 beta 25 NY USA 20 True + 2 gamma 25 CA USA 20 True + + [3 rows x 6 columns] + Args: key (column index): It can be a new column to be inserted, or an existing column to be modified. - value (scalar or Series): + value (scalar, Sequence, DataFrame, or Series): Value to be assigned to the column """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) From c0b54f03849ee3115413670e690e68f3ef10f2ec Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Wed, 27 Aug 2025 13:48:55 -0700 Subject: [PATCH 021/313] feat: Support string matching in local executor (#2032) --- bigframes/core/compile/polars/compiler.py | 28 ++++++++ bigframes/session/polars_executor.py | 12 +++- tests/system/small/engines/test_strings.py | 77 ++++++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 tests/system/small/engines/test_strings.py diff --git a/bigframes/core/compile/polars/compiler.py b/bigframes/core/compile/polars/compiler.py index 1ba76dee5b..1bfbe0f734 100644 --- a/bigframes/core/compile/polars/compiler.py +++ b/bigframes/core/compile/polars/compiler.py @@ -301,6 +301,34 @@ def _(self, op: ops.ScalarOp, l_input: pl.Expr, r_input: pl.Expr) -> pl.Expr: assert isinstance(op, string_ops.StrConcatOp) return pl.concat_str(l_input, r_input) + @compile_op.register(string_ops.StrContainsOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, string_ops.StrContainsOp) + return input.str.contains(pattern=op.pat, literal=True) + + @compile_op.register(string_ops.StrContainsRegexOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, string_ops.StrContainsRegexOp) + return input.str.contains(pattern=op.pat, literal=False) + + @compile_op.register(string_ops.StartsWithOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, string_ops.StartsWithOp) + if len(op.pat) == 1: + return input.str.starts_with(op.pat[0]) + else: + return pl.any_horizontal( + *(input.str.starts_with(pat) for pat in op.pat) + ) + + @compile_op.register(string_ops.EndsWithOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, string_ops.EndsWithOp) + if len(op.pat) == 1: + return input.str.ends_with(op.pat[0]) + else: + return pl.any_horizontal(*(input.str.ends_with(pat) for pat in op.pat)) + @compile_op.register(dt_ops.StrftimeOp) def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: assert isinstance(op, dt_ops.StrftimeOp) diff --git a/bigframes/session/polars_executor.py b/bigframes/session/polars_executor.py index 6e3f0ca10f..b93d31d255 100644 --- a/bigframes/session/polars_executor.py +++ b/bigframes/session/polars_executor.py @@ -21,7 +21,13 @@ from bigframes.core import array_value, bigframe_node, expression, local_data, nodes import bigframes.operations from bigframes.operations import aggregations as agg_ops -from bigframes.operations import bool_ops, comparison_ops, generic_ops, numeric_ops +from bigframes.operations import ( + bool_ops, + comparison_ops, + generic_ops, + numeric_ops, + string_ops, +) from bigframes.session import executor, semi_executor if TYPE_CHECKING: @@ -69,6 +75,10 @@ generic_ops.IsInOp, generic_ops.IsNullOp, generic_ops.NotNullOp, + string_ops.StartsWithOp, + string_ops.EndsWithOp, + string_ops.StrContainsOp, + string_ops.StrContainsRegexOp, ) _COMPATIBLE_AGG_OPS = ( agg_ops.SizeOp, diff --git a/tests/system/small/engines/test_strings.py b/tests/system/small/engines/test_strings.py new file mode 100644 index 0000000000..cbab517ef0 --- /dev/null +++ b/tests/system/small/engines/test_strings.py @@ -0,0 +1,77 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes.core import array_value +import bigframes.operations as ops +from bigframes.session import polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +def test_engines_str_contains(scalars_array_value: array_value.ArrayValue, engine): + arr, _ = scalars_array_value.compute_values( + [ + ops.StrContainsOp("(?i)hEllo").as_expr("string_col"), + ops.StrContainsOp("Hello").as_expr("string_col"), + ops.StrContainsOp("T").as_expr("string_col"), + ops.StrContainsOp(".*").as_expr("string_col"), + ] + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +def test_engines_str_contains_regex( + scalars_array_value: array_value.ArrayValue, engine +): + arr, _ = scalars_array_value.compute_values( + [ + ops.StrContainsRegexOp("(?i)hEllo").as_expr("string_col"), + ops.StrContainsRegexOp("Hello").as_expr("string_col"), + ops.StrContainsRegexOp("T").as_expr("string_col"), + ops.StrContainsRegexOp(".*").as_expr("string_col"), + ] + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +def test_engines_str_startswith(scalars_array_value: array_value.ArrayValue, engine): + arr, _ = scalars_array_value.compute_values( + [ + ops.StartsWithOp("He").as_expr("string_col"), + ops.StartsWithOp("llo").as_expr("string_col"), + ops.StartsWithOp(("He", "T", "ca")).as_expr("string_col"), + ] + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +def test_engines_str_endswith(scalars_array_value: array_value.ArrayValue, engine): + arr, _ = scalars_array_value.compute_values( + [ + ops.EndsWithOp("!").as_expr("string_col"), + ops.EndsWithOp("llo").as_expr("string_col"), + ops.EndsWithOp(("He", "T", "ca")).as_expr("string_col"), + ] + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) From 935af107ef98837fb2b81d72185d0b6a9e09fbcf Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Wed, 27 Aug 2025 14:06:25 -0700 Subject: [PATCH 022/313] fix: Fix scalar op lowering tree walk (#2029) --- bigframes/core/expression.py | 5 +++++ bigframes/core/rewrite/op_lowering.py | 2 +- tests/system/small/engines/test_generic_ops.py | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/bigframes/core/expression.py b/bigframes/core/expression.py index 7b20e430ff..0e94193bd3 100644 --- a/bigframes/core/expression.py +++ b/bigframes/core/expression.py @@ -253,6 +253,11 @@ def is_identity(self) -> bool: def transform_children(self, t: Callable[[Expression], Expression]) -> Expression: ... + def bottom_up(self, t: Callable[[Expression], Expression]) -> Expression: + expr = self.transform_children(lambda child: child.bottom_up(t)) + expr = t(expr) + return expr + def walk(self) -> Generator[Expression, None, None]: yield self for child in self.children: diff --git a/bigframes/core/rewrite/op_lowering.py b/bigframes/core/rewrite/op_lowering.py index a64a4cc8c4..6473c3bf8a 100644 --- a/bigframes/core/rewrite/op_lowering.py +++ b/bigframes/core/rewrite/op_lowering.py @@ -44,7 +44,7 @@ def lower_expr_step(expr: expression.Expression) -> expression.Expression: return maybe_rule.lower(expr) return expr - return lower_expr_step(expr.transform_children(lower_expr_step)) + return expr.bottom_up(lower_expr_step) def lower_node(node: bigframe_node.BigFrameNode) -> bigframe_node.BigFrameNode: if isinstance( diff --git a/tests/system/small/engines/test_generic_ops.py b/tests/system/small/engines/test_generic_ops.py index 1d28c335a6..8deef3638e 100644 --- a/tests/system/small/engines/test_generic_ops.py +++ b/tests/system/small/engines/test_generic_ops.py @@ -423,3 +423,18 @@ def test_engines_isin_op(scalars_array_value: array_value.ArrayValue, engine): ) assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +def test_engines_isin_op_nested_filter( + scalars_array_value: array_value.ArrayValue, engine +): + isin_clause = ops.IsInOp((1, 2, 3)).as_expr(expression.deref("int64_col")) + filter_clause = ops.invert_op.as_expr( + ops.or_op.as_expr( + expression.deref("bool_col"), ops.invert_op.as_expr(isin_clause) + ) + ) + arr = scalars_array_value.filter(filter_clause) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) From 5b8bdec771324fdb128c5fee7c4b376bef19d1a1 Mon Sep 17 00:00:00 2001 From: Garrett Wu <6505921+GarrettWu@users.noreply.github.com> Date: Wed, 27 Aug 2025 18:04:20 -0700 Subject: [PATCH 023/313] chore: fix typos in bigframes.ml (#2035) * Fix: Fix typos in bigframes/ml * Fix: Fix mypy error in dataframe.py --- bigframes/dataframe.py | 1 + bigframes/ml/cluster.py | 2 +- bigframes/ml/forecasting.py | 2 +- bigframes/ml/llm.py | 2 +- bigframes/ml/model_selection.py | 2 +- bigframes/ml/preprocessing.py | 2 +- 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index b2947f7493..85760d94bc 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -582,6 +582,7 @@ def __getitem__( # Index of column labels can be treated the same as a sequence of column labels. pandas.Index, bigframes.series.Series, + slice, ], ): # No return type annotations (like pandas) as type cannot always be determined statically # NOTE: This implements the operations described in diff --git a/bigframes/ml/cluster.py b/bigframes/ml/cluster.py index cd27357680..9ce4649c5e 100644 --- a/bigframes/ml/cluster.py +++ b/bigframes/ml/cluster.py @@ -59,7 +59,7 @@ def __init__( warm_start: bool = False, ): self.n_clusters = n_clusters - # allow the alias to be compatible with sklean + # allow the alias to be compatible with sklearn self.init = "kmeans++" if init == "k-means++" else init self.init_col = init_col self.distance_type = distance_type diff --git a/bigframes/ml/forecasting.py b/bigframes/ml/forecasting.py index 2e93e5485f..d26abdfa71 100644 --- a/bigframes/ml/forecasting.py +++ b/bigframes/ml/forecasting.py @@ -211,7 +211,7 @@ def _fit( Args: X (bigframes.dataframe.DataFrame or bigframes.series.Series, or pandas.core.frame.DataFrame or pandas.core.series.Series): - A dataframe or series of trainging timestamp. + A dataframe or series of training timestamp. y (bigframes.dataframe.DataFrame, or bigframes.series.Series, or pandas.core.frame.DataFrame, or pandas.core.series.Series): Target values for training. diff --git a/bigframes/ml/llm.py b/bigframes/ml/llm.py index 11861c786e..eba15909b4 100644 --- a/bigframes/ml/llm.py +++ b/bigframes/ml/llm.py @@ -834,7 +834,7 @@ def to_gbq(self, model_name: str, replace: bool = False) -> GeminiTextGenerator: class Claude3TextGenerator(base.RetriableRemotePredictor): """Claude3 text generator LLM model. - Go to Google Cloud Console -> Vertex AI -> Model Garden page to enabe the models before use. Must have the Consumer Procurement Entitlement Manager Identity and Access Management (IAM) role to enable the models. + Go to Google Cloud Console -> Vertex AI -> Model Garden page to enable the models before use. Must have the Consumer Procurement Entitlement Manager Identity and Access Management (IAM) role to enable the models. https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models#grant-permissions .. note:: diff --git a/bigframes/ml/model_selection.py b/bigframes/ml/model_selection.py index ca089bb551..6eba4f81c2 100644 --- a/bigframes/ml/model_selection.py +++ b/bigframes/ml/model_selection.py @@ -82,7 +82,7 @@ def train_test_split( dfs = list(utils.batch_convert_to_dataframe(*arrays)) def _stratify_split(df: bpd.DataFrame, stratify: bpd.Series) -> List[bpd.DataFrame]: - """Split a single DF accoding to the stratify Series.""" + """Split a single DF according to the stratify Series.""" stratify = stratify.rename("bigframes_stratify_col") # avoid name conflicts merged_df = df.join(stratify.to_frame(), how="outer") diff --git a/bigframes/ml/preprocessing.py b/bigframes/ml/preprocessing.py index 0448d8544a..2e8dc64a53 100644 --- a/bigframes/ml/preprocessing.py +++ b/bigframes/ml/preprocessing.py @@ -434,7 +434,7 @@ def _compile_to_sql( if columns is None: columns = X.columns drop = self.drop if self.drop is not None else "none" - # minus one here since BQML's inplimentation always includes index 0, and top_k is on top of that. + # minus one here since BQML's implementation always includes index 0, and top_k is on top of that. top_k = ( (self.max_categories - 1) if self.max_categories is not None From 209d0d48956fafc3cf40cded2d8a2468eefd8813 Mon Sep 17 00:00:00 2001 From: Huan Chen <142538604+Genesis929@users.noreply.github.com> Date: Wed, 27 Aug 2025 18:09:22 -0700 Subject: [PATCH 024/313] chore: add bq execution time to benchmark (#2033) * chore: update benchmark metrics * fix metric calculation --- bigframes/session/metrics.py | 7 +- scripts/run_and_publish_benchmark.py | 134 +++++++++++---------------- testing/constraints-3.11.txt | 2 +- 3 files changed, 60 insertions(+), 83 deletions(-) diff --git a/bigframes/session/metrics.py b/bigframes/session/metrics.py index 8ec8d525cc..8d43a83d73 100644 --- a/bigframes/session/metrics.py +++ b/bigframes/session/metrics.py @@ -45,12 +45,17 @@ def count_job_stats( bytes_processed = getattr(row_iterator, "total_bytes_processed", 0) or 0 query_char_count = len(getattr(row_iterator, "query", "") or "") slot_millis = getattr(row_iterator, "slot_millis", 0) or 0 - exec_seconds = 0.0 + created = getattr(row_iterator, "created", None) + ended = getattr(row_iterator, "ended", None) + exec_seconds = ( + (ended - created).total_seconds() if created and ended else 0.0 + ) self.execution_count += 1 self.query_char_count += query_char_count self.bytes_processed += bytes_processed self.slot_millis += slot_millis + self.execution_secs += exec_seconds elif query_job.configuration.dry_run: query_char_count = len(query_job.query) diff --git a/scripts/run_and_publish_benchmark.py b/scripts/run_and_publish_benchmark.py index 248322f619..859d68e60e 100644 --- a/scripts/run_and_publish_benchmark.py +++ b/scripts/run_and_publish_benchmark.py @@ -84,43 +84,36 @@ def collect_benchmark_result( path = pathlib.Path(benchmark_path) try: results_dict: Dict[str, List[Union[int, float, None]]] = {} - bytes_files = sorted(path.rglob("*.bytesprocessed")) - millis_files = sorted(path.rglob("*.slotmillis")) - bq_seconds_files = sorted(path.rglob("*.bq_exec_time_seconds")) + # Use local_seconds_files as the baseline local_seconds_files = sorted(path.rglob("*.local_exec_time_seconds")) - query_char_count_files = sorted(path.rglob("*.query_char_count")) - error_files = sorted(path.rglob("*.error")) - - if not ( - len(millis_files) - == len(bq_seconds_files) - <= len(bytes_files) - == len(query_char_count_files) - == len(local_seconds_files) - ): - raise ValueError( - "Mismatch in the number of report files for bytes, millis, seconds and query char count: \n" - f"millis_files: {len(millis_files)}\n" - f"bq_seconds_files: {len(bq_seconds_files)}\n" - f"bytes_files: {len(bytes_files)}\n" - f"query_char_count_files: {len(query_char_count_files)}\n" - f"local_seconds_files: {len(local_seconds_files)}\n" - ) - - has_full_metrics = len(bq_seconds_files) == len(local_seconds_files) - - for idx in range(len(local_seconds_files)): - query_char_count_file = query_char_count_files[idx] - local_seconds_file = local_seconds_files[idx] - bytes_file = bytes_files[idx] - filename = query_char_count_file.relative_to(path).with_suffix("") - if filename != local_seconds_file.relative_to(path).with_suffix( - "" - ) or filename != bytes_file.relative_to(path).with_suffix(""): - raise ValueError( - "File name mismatch among query_char_count, bytes and seconds reports." - ) + benchmarks_with_missing_files = [] + + for local_seconds_file in local_seconds_files: + base_name = local_seconds_file.name.removesuffix(".local_exec_time_seconds") + base_path = local_seconds_file.parent / base_name + filename = base_path.relative_to(path) + + # Construct paths for other metric files + bytes_file = pathlib.Path(f"{base_path}.bytesprocessed") + millis_file = pathlib.Path(f"{base_path}.slotmillis") + bq_seconds_file = pathlib.Path(f"{base_path}.bq_exec_time_seconds") + query_char_count_file = pathlib.Path(f"{base_path}.query_char_count") + + # Check if all corresponding files exist + missing_files = [] + if not bytes_file.exists(): + missing_files.append(bytes_file.name) + if not millis_file.exists(): + missing_files.append(millis_file.name) + if not bq_seconds_file.exists(): + missing_files.append(bq_seconds_file.name) + if not query_char_count_file.exists(): + missing_files.append(query_char_count_file.name) + + if missing_files: + benchmarks_with_missing_files.append((str(filename), missing_files)) + continue with open(query_char_count_file, "r") as file: lines = file.read().splitlines() @@ -135,26 +128,13 @@ def collect_benchmark_result( lines = file.read().splitlines() total_bytes = sum(int(line) for line in lines) / iterations - if not has_full_metrics: - total_slot_millis = None - bq_seconds = None - else: - millis_file = millis_files[idx] - bq_seconds_file = bq_seconds_files[idx] - if filename != millis_file.relative_to(path).with_suffix( - "" - ) or filename != bq_seconds_file.relative_to(path).with_suffix(""): - raise ValueError( - "File name mismatch among query_char_count, bytes, millis, and seconds reports." - ) - - with open(millis_file, "r") as file: - lines = file.read().splitlines() - total_slot_millis = sum(int(line) for line in lines) / iterations + with open(millis_file, "r") as file: + lines = file.read().splitlines() + total_slot_millis = sum(int(line) for line in lines) / iterations - with open(bq_seconds_file, "r") as file: - lines = file.read().splitlines() - bq_seconds = sum(float(line) for line in lines) / iterations + with open(bq_seconds_file, "r") as file: + lines = file.read().splitlines() + bq_seconds = sum(float(line) for line in lines) / iterations results_dict[str(filename)] = [ query_count, @@ -207,13 +187,9 @@ def collect_benchmark_result( f"{index} - query count: {row['Query_Count']}," + f" query char count: {row['Query_Char_Count']}," + f" bytes processed sum: {row['Bytes_Processed']}," - + (f" slot millis sum: {row['Slot_Millis']}," if has_full_metrics else "") - + f" local execution time: {formatted_local_exec_time} seconds" - + ( - f", bigquery execution time: {round(row['BigQuery_Execution_Time_Sec'], 1)} seconds" - if has_full_metrics - else "" - ) + + f" slot millis sum: {row['Slot_Millis']}," + + f" local execution time: {formatted_local_exec_time}" + + f", bigquery execution time: {round(row['BigQuery_Execution_Time_Sec'], 1)} seconds" ) geometric_mean_queries = geometric_mean_excluding_zeros( @@ -239,30 +215,26 @@ def collect_benchmark_result( f"---Geometric mean of queries: {geometric_mean_queries}," + f" Geometric mean of queries char counts: {geometric_mean_query_char_count}," + f" Geometric mean of bytes processed: {geometric_mean_bytes}," - + ( - f" Geometric mean of slot millis: {geometric_mean_slot_millis}," - if has_full_metrics - else "" - ) + + f" Geometric mean of slot millis: {geometric_mean_slot_millis}," + f" Geometric mean of local execution time: {geometric_mean_local_seconds} seconds" - + ( - f", Geometric mean of BigQuery execution time: {geometric_mean_bq_seconds} seconds---" - if has_full_metrics - else "" - ) + + f", Geometric mean of BigQuery execution time: {geometric_mean_bq_seconds} seconds---" ) - error_message = ( - "\n" - + "\n".join( - [ - f"Failed: {error_file.relative_to(path).with_suffix('')}" - for error_file in error_files - ] + all_errors: List[str] = [] + if error_files: + all_errors.extend( + f"Failed: {error_file.relative_to(path).with_suffix('')}" + for error_file in error_files ) - if error_files - else None - ) + if ( + benchmarks_with_missing_files + and os.getenv("BENCHMARK_AND_PUBLISH", "false") == "true" + ): + all_errors.extend( + f"Missing files for benchmark '{name}': {files}" + for name, files in benchmarks_with_missing_files + ) + error_message = "\n" + "\n".join(all_errors) if all_errors else None return ( benchmark_metrics.reset_index().rename(columns={"index": "Benchmark_Name"}), error_message, diff --git a/testing/constraints-3.11.txt b/testing/constraints-3.11.txt index 8fd20d453b..8c274bd9fb 100644 --- a/testing/constraints-3.11.txt +++ b/testing/constraints-3.11.txt @@ -152,7 +152,7 @@ google-auth==2.38.0 google-auth-httplib2==0.2.0 google-auth-oauthlib==1.2.2 google-cloud-aiplatform==1.106.0 -google-cloud-bigquery==3.35.1 +google-cloud-bigquery==3.36.0 google-cloud-bigquery-connection==1.18.3 google-cloud-bigquery-storage==2.32.0 google-cloud-core==2.4.3 From b0d620bbe8227189bbdc2ba5a913b03c70575296 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Thu, 28 Aug 2025 13:34:16 -0700 Subject: [PATCH 025/313] fix: read_csv fails when check file size for wildcard gcs files (#2019) --- bigframes/session/__init__.py | 22 ++++++++++++++++++---- tests/system/small/test_session.py | 26 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/bigframes/session/__init__.py b/bigframes/session/__init__.py index 10a112c779..66b0196286 100644 --- a/bigframes/session/__init__.py +++ b/bigframes/session/__init__.py @@ -18,6 +18,8 @@ from collections import abc import datetime +import fnmatch +import inspect import logging import os import secrets @@ -1344,12 +1346,24 @@ def read_json( def _check_file_size(self, filepath: str): max_size = 1024 * 1024 * 1024 # 1 GB in bytes if filepath.startswith("gs://"): # GCS file path + bucket_name, blob_path = filepath.split("/", 3)[2:] + client = storage.Client() - bucket_name, blob_name = filepath.split("/", 3)[2:] bucket = client.bucket(bucket_name) - blob = bucket.blob(blob_name) - blob.reload() - file_size = blob.size + + list_blobs_params = inspect.signature(bucket.list_blobs).parameters + if "match_glob" in list_blobs_params: + # Modern, efficient method for new library versions + matching_blobs = bucket.list_blobs(match_glob=blob_path) + file_size = sum(blob.size for blob in matching_blobs) + else: + # Fallback method for older library versions + prefix = blob_path.split("*", 1)[0] + all_blobs = bucket.list_blobs(prefix=prefix) + matching_blobs = [ + blob for blob in all_blobs if fnmatch.fnmatch(blob.name, blob_path) + ] + file_size = sum(blob.size for blob in matching_blobs) elif os.path.exists(filepath): # local file path file_size = os.path.getsize(filepath) else: diff --git a/tests/system/small/test_session.py b/tests/system/small/test_session.py index a04da64af0..f0a6302c7b 100644 --- a/tests/system/small/test_session.py +++ b/tests/system/small/test_session.py @@ -1287,6 +1287,32 @@ def test_read_csv_raises_error_for_invalid_index_col( session.read_csv(path, engine="bigquery", index_col=index_col) +def test_read_csv_for_gcs_wildcard_path(session, df_and_gcs_csv): + scalars_pandas_df, path = df_and_gcs_csv + path = path.replace(".csv", "*.csv") + + index_col = "rowindex" + bf_df = session.read_csv(path, engine="bigquery", index_col=index_col) + + # Convert default pandas dtypes to match BigQuery DataFrames dtypes. + # Also, `expand=True` is needed to read from wildcard paths. See details: + # https://github.com/fsspec/gcsfs/issues/616, + if not pd.__version__.startswith("1."): + storage_options = {"expand": True} + else: + storage_options = None + pd_df = session.read_csv( + path, + index_col=index_col, + dtype=scalars_pandas_df.dtypes.to_dict(), + storage_options=storage_options, + ) + + assert bf_df.shape == pd_df.shape + assert bf_df.columns.tolist() == pd_df.columns.tolist() + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + + def test_read_csv_for_names(session, df_and_gcs_csv_for_two_columns): _, path = df_and_gcs_csv_for_two_columns From 3c87e9725b4dd19f22c77a85aca3215b425e5526 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Thu, 28 Aug 2025 13:38:11 -0700 Subject: [PATCH 026/313] test: add unit test for remap_variables (#2037) --- .../{test_rewrite.py => rewrite/conftest.py} | 36 ++--- tests/unit/core/rewrite/test_identifiers.py | 132 ++++++++++++++++++ tests/unit/core/rewrite/test_slices.py | 34 +++++ 3 files changed, 181 insertions(+), 21 deletions(-) rename tests/unit/core/{test_rewrite.py => rewrite/conftest.py} (56%) create mode 100644 tests/unit/core/rewrite/test_identifiers.py create mode 100644 tests/unit/core/rewrite/test_slices.py diff --git a/tests/unit/core/test_rewrite.py b/tests/unit/core/rewrite/conftest.py similarity index 56% rename from tests/unit/core/test_rewrite.py rename to tests/unit/core/rewrite/conftest.py index 1f1a2c3db9..22b897f3bf 100644 --- a/tests/unit/core/test_rewrite.py +++ b/tests/unit/core/rewrite/conftest.py @@ -1,4 +1,4 @@ -# Copyright 2024 Google LLC +# Copyright 2025 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,10 +14,9 @@ import unittest.mock as mock import google.cloud.bigquery +import pytest import bigframes.core as core -import bigframes.core.nodes as nodes -import bigframes.core.rewrite.slices import bigframes.core.schema TABLE_REF = google.cloud.bigquery.TableReference.from_string("project.dataset.table") @@ -31,27 +30,22 @@ ) FAKE_SESSION = mock.create_autospec(bigframes.Session, instance=True) type(FAKE_SESSION)._strictly_ordered = mock.PropertyMock(return_value=True) -LEAF = core.ArrayValue.from_table( - session=FAKE_SESSION, - table=TABLE, - schema=bigframes.core.schema.ArraySchema.from_bq_table(TABLE), -).node -def test_rewrite_noop_slice(): - slice = nodes.SliceNode(LEAF, None, None) - result = bigframes.core.rewrite.slices.rewrite_slice(slice) - assert result == LEAF +@pytest.fixture +def table(): + return TABLE -def test_rewrite_reverse_slice(): - slice = nodes.SliceNode(LEAF, None, None, -1) - result = bigframes.core.rewrite.slices.rewrite_slice(slice) - assert result == nodes.ReversedNode(LEAF) +@pytest.fixture +def fake_session(): + return FAKE_SESSION -def test_rewrite_filter_slice(): - slice = nodes.SliceNode(LEAF, None, 2) - result = bigframes.core.rewrite.slices.rewrite_slice(slice) - assert list(result.fields) == list(LEAF.fields) - assert isinstance(result.child, nodes.FilterNode) +@pytest.fixture +def leaf(fake_session, table): + return core.ArrayValue.from_table( + session=fake_session, + table=table, + schema=bigframes.core.schema.ArraySchema.from_bq_table(table), + ).node diff --git a/tests/unit/core/rewrite/test_identifiers.py b/tests/unit/core/rewrite/test_identifiers.py new file mode 100644 index 0000000000..fd12df60a8 --- /dev/null +++ b/tests/unit/core/rewrite/test_identifiers.py @@ -0,0 +1,132 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import bigframes.core as core +import bigframes.core.identifiers as identifiers +import bigframes.core.nodes as nodes +import bigframes.core.rewrite.identifiers as id_rewrite + + +def test_remap_variables_single_node(leaf): + node = leaf + id_generator = (identifiers.ColumnId(f"id_{i}") for i in range(100)) + new_node, mapping = id_rewrite.remap_variables(node, id_generator) + assert new_node is not node + assert len(mapping) == 2 + assert set(mapping.keys()) == {f.id for f in node.fields} + assert set(mapping.values()) == { + identifiers.ColumnId("id_0"), + identifiers.ColumnId("id_1"), + } + + +def test_remap_variables_projection(leaf): + node = nodes.ProjectionNode( + leaf, + ( + ( + core.expression.DerefOp(leaf.fields[0].id), + identifiers.ColumnId("new_col"), + ), + ), + ) + id_generator = (identifiers.ColumnId(f"id_{i}") for i in range(100)) + new_node, mapping = id_rewrite.remap_variables(node, id_generator) + assert new_node is not node + assert len(mapping) == 3 + assert set(mapping.keys()) == {f.id for f in node.fields} + assert set(mapping.values()) == {identifiers.ColumnId(f"id_{i}") for i in range(3)} + + +def test_remap_variables_nested_join_stability(leaf, fake_session, table): + # Create two more distinct leaf nodes + leaf2_uncached = core.ArrayValue.from_table( + session=fake_session, + table=table, + schema=leaf.schema, + ).node + leaf2 = leaf2_uncached.remap_vars( + { + field.id: identifiers.ColumnId(f"leaf2_{field.id.name}") + for field in leaf2_uncached.fields + } + ) + leaf3_uncached = core.ArrayValue.from_table( + session=fake_session, + table=table, + schema=leaf.schema, + ).node + leaf3 = leaf3_uncached.remap_vars( + { + field.id: identifiers.ColumnId(f"leaf3_{field.id.name}") + for field in leaf3_uncached.fields + } + ) + + # Create a nested join: (leaf JOIN leaf2) JOIN leaf3 + inner_join = nodes.JoinNode( + left_child=leaf, + right_child=leaf2, + conditions=( + ( + core.expression.DerefOp(leaf.fields[0].id), + core.expression.DerefOp(leaf2.fields[0].id), + ), + ), + type="inner", + propogate_order=False, + ) + outer_join = nodes.JoinNode( + left_child=inner_join, + right_child=leaf3, + conditions=( + ( + core.expression.DerefOp(inner_join.fields[0].id), + core.expression.DerefOp(leaf3.fields[0].id), + ), + ), + type="inner", + propogate_order=False, + ) + + # Run remap_variables twice and assert stability + id_generator1 = (identifiers.ColumnId(f"id_{i}") for i in range(100)) + new_node1, mapping1 = id_rewrite.remap_variables(outer_join, id_generator1) + + id_generator2 = (identifiers.ColumnId(f"id_{i}") for i in range(100)) + new_node2, mapping2 = id_rewrite.remap_variables(outer_join, id_generator2) + + assert new_node1 == new_node2 + assert mapping1 == mapping2 + + +def test_remap_variables_concat_self_stability(leaf): + # Create a concat node with the same child twice + node = nodes.ConcatNode( + children=(leaf, leaf), + output_ids=( + identifiers.ColumnId("concat_a"), + identifiers.ColumnId("concat_b"), + ), + ) + + # Run remap_variables twice and assert stability + id_generator1 = (identifiers.ColumnId(f"id_{i}") for i in range(100)) + new_node1, mapping1 = id_rewrite.remap_variables(node, id_generator1) + + id_generator2 = (identifiers.ColumnId(f"id_{i}") for i in range(100)) + new_node2, mapping2 = id_rewrite.remap_variables(node, id_generator2) + + assert new_node1 == new_node2 + assert mapping1 == mapping2 diff --git a/tests/unit/core/rewrite/test_slices.py b/tests/unit/core/rewrite/test_slices.py new file mode 100644 index 0000000000..6d49ffb80a --- /dev/null +++ b/tests/unit/core/rewrite/test_slices.py @@ -0,0 +1,34 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import bigframes.core.nodes as nodes +import bigframes.core.rewrite.slices + + +def test_rewrite_noop_slice(leaf): + slice = nodes.SliceNode(leaf, None, None) + result = bigframes.core.rewrite.slices.rewrite_slice(slice) + assert result == leaf + + +def test_rewrite_reverse_slice(leaf): + slice = nodes.SliceNode(leaf, None, None, -1) + result = bigframes.core.rewrite.slices.rewrite_slice(slice) + assert result == nodes.ReversedNode(leaf) + + +def test_rewrite_filter_slice(leaf): + slice = nodes.SliceNode(leaf, None, 2) + result = bigframes.core.rewrite.slices.rewrite_slice(slice) + assert list(result.fields) == list(leaf.fields) + assert isinstance(result.child, nodes.FilterNode) From 39616374bba424996ebeb9a12096bfaf22660b44 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Thu, 28 Aug 2025 14:10:14 -0700 Subject: [PATCH 027/313] perf: improve iter_nodes_topo performance using Kahn's algorithm (#2038) --- bigframes/core/bigframe_node.py | 52 +++++++++++++-------------------- 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/bigframes/core/bigframe_node.py b/bigframes/core/bigframe_node.py index 9054ab9ba0..0c6f56f35a 100644 --- a/bigframes/core/bigframe_node.py +++ b/bigframes/core/bigframe_node.py @@ -20,17 +20,7 @@ import functools import itertools import typing -from typing import ( - Callable, - Dict, - Generator, - Iterable, - Mapping, - Sequence, - Set, - Tuple, - Union, -) +from typing import Callable, Dict, Generator, Iterable, Mapping, Sequence, Tuple, Union from bigframes.core import expression, field, identifiers import bigframes.core.schema as schemata @@ -309,33 +299,31 @@ def unique_nodes( seen.add(item) stack.extend(item.child_nodes) - def edges( + def iter_nodes_topo( self: BigFrameNode, - ) -> Generator[Tuple[BigFrameNode, BigFrameNode], None, None]: - for item in self.unique_nodes(): - for child in item.child_nodes: - yield (item, child) - - def iter_nodes_topo(self: BigFrameNode) -> Generator[BigFrameNode, None, None]: - """Returns nodes from bottom up.""" - queue = collections.deque( - [node for node in self.unique_nodes() if not node.child_nodes] - ) - + ) -> Generator[BigFrameNode, None, None]: + """Returns nodes in reverse topological order, using Kahn's algorithm.""" child_to_parents: Dict[ - BigFrameNode, Set[BigFrameNode] - ] = collections.defaultdict(set) - for parent, child in self.edges(): - child_to_parents[child].add(parent) - - yielded = set() + BigFrameNode, list[BigFrameNode] + ] = collections.defaultdict(list) + out_degree: Dict[BigFrameNode, int] = collections.defaultdict(int) + + queue: collections.deque["BigFrameNode"] = collections.deque() + for node in list(self.unique_nodes()): + num_children = len(node.child_nodes) + out_degree[node] = num_children + if num_children == 0: + queue.append(node) + for child in node.child_nodes: + child_to_parents[child].append(node) while queue: item = queue.popleft() yield item - yielded.add(item) - for parent in child_to_parents[item]: - if set(parent.child_nodes).issubset(yielded): + parents = child_to_parents.get(item, []) + for parent in parents: + out_degree[parent] -= 1 + if out_degree[parent] == 0: queue.append(parent) def top_down( From fbb209468297a8057d9d49c40e425c3bfdeb92bd Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 28 Aug 2025 15:24:57 -0700 Subject: [PATCH 028/313] perf: Improve axis=1 aggregation performance (#2036) --- bigframes/core/blocks.py | 44 ++------------ .../ibis_compiler/aggregate_compiler.py | 2 +- .../ibis_compiler/scalar_op_registry.py | 22 +++++++ bigframes/core/compile/polars/compiler.py | 31 ++++++++++ bigframes/operations/__init__.py | 10 +++- bigframes/operations/array_ops.py | 27 ++++++++- tests/system/small/engines/conftest.py | 7 +++ tests/system/small/engines/test_array_ops.py | 60 +++++++++++++++++++ .../sql/compilers/bigquery/__init__.py | 3 + .../ibis/expr/operations/arrays.py | 15 +++++ .../bigframes_vendored/ibis/expr/rewrites.py | 2 +- .../ibis/expr/types/arrays.py | 18 ++++++ .../ibis/expr/types/logical.py | 3 + 13 files changed, 200 insertions(+), 44 deletions(-) create mode 100644 tests/system/small/engines/test_array_ops.py diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index 1a2544704c..283f56fd39 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -1232,46 +1232,10 @@ def aggregate_all_and_stack( index_labels=[None], ).transpose(original_row_index=pd.Index([None]), single_row_mode=True) else: # axis_n == 1 - # using offsets as identity to group on. - # TODO: Allow to promote identity/total_order columns instead for better perf - expr_with_offsets, offset_col = self.expr.promote_offsets() - stacked_expr, (_, value_col_ids, passthrough_cols,) = unpivot( - expr_with_offsets, - row_labels=self.column_labels, - unpivot_columns=[tuple(self.value_columns)], - passthrough_columns=[*self.index_columns, offset_col], - ) - # these corresponed to passthrough_columns provided to unpivot - index_cols = passthrough_cols[:-1] - og_offset_col = passthrough_cols[-1] - index_aggregations = [ - ( - ex.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(col_id)), - col_id, - ) - for col_id in index_cols - ] - # TODO: may need add NullaryAggregation in main_aggregation - # when agg add support for axis=1, needed for agg("size", axis=1) - assert isinstance( - operation, agg_ops.UnaryAggregateOp - ), f"Expected a unary operation, but got {operation}. Please report this error and how you got here to the BigQuery DataFrames team (bit.ly/bigframes-feedback)." - main_aggregation = ( - ex.UnaryAggregation(operation, ex.deref(value_col_ids[0])), - value_col_ids[0], - ) - # Drop row identity after aggregating over it - result_expr = stacked_expr.aggregate( - [*index_aggregations, main_aggregation], - by_column_ids=[og_offset_col], - dropna=dropna, - ).drop_columns([og_offset_col]) - return Block( - result_expr, - index_columns=index_cols, - column_labels=[None], - index_labels=self.index.names, - ) + as_array = ops.ToArrayOp().as_expr(*(col for col in self.value_columns)) + reduced = ops.ArrayReduceOp(operation).as_expr(as_array) + block, id = self.project_expr(reduced, None) + return block.select_column(id) def aggregate_size( self, diff --git a/bigframes/core/compile/ibis_compiler/aggregate_compiler.py b/bigframes/core/compile/ibis_compiler/aggregate_compiler.py index 4e0bf477fc..291db44524 100644 --- a/bigframes/core/compile/ibis_compiler/aggregate_compiler.py +++ b/bigframes/core/compile/ibis_compiler/aggregate_compiler.py @@ -165,7 +165,7 @@ def _( ) -> ibis_types.NumericValue: # Will be null if all inputs are null. Pandas defaults to zero sum though. bq_sum = _apply_window_if_present(column.sum(), window) - return bq_sum.fill_null(ibis_types.literal(0)) + return bq_sum.coalesce(ibis_types.literal(0)) @compile_unary_agg.register diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index f3653efc56..969ae2659d 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -1201,6 +1201,28 @@ def array_slice_op_impl(x: ibis_types.Value, op: ops.ArraySliceOp): return res +@scalar_op_compiler.register_nary_op(ops.ToArrayOp, pass_op=False) +def to_arry_op_impl(*values: ibis_types.Value): + do_upcast_bool = any(t.type().is_numeric() for t in values) + if do_upcast_bool: + values = tuple( + val.cast(ibis_dtypes.int64) if val.type().is_boolean() else val + for val in values + ) + return ibis_api.array(values) + + +@scalar_op_compiler.register_unary_op(ops.ArrayReduceOp, pass_op=True) +def array_reduce_op_impl(x: ibis_types.Value, op: ops.ArrayReduceOp): + import bigframes.core.compile.ibis_compiler.aggregate_compiler as agg_compilers + + return typing.cast(ibis_types.ArrayValue, x).reduce( + lambda arr_vals: agg_compilers.compile_unary_agg( + op.aggregation, typing.cast(ibis_types.Column, arr_vals) + ) + ) + + # JSON Ops @scalar_op_compiler.register_binary_op(ops.JSONSet, pass_op=True) def json_set_op_impl(x: ibis_types.Value, y: ibis_types.Value, op: ops.JSONSet): diff --git a/bigframes/core/compile/polars/compiler.py b/bigframes/core/compile/polars/compiler.py index 1bfbe0f734..3316154de7 100644 --- a/bigframes/core/compile/polars/compiler.py +++ b/bigframes/core/compile/polars/compiler.py @@ -31,6 +31,7 @@ import bigframes.dtypes import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops +import bigframes.operations.array_ops as arr_ops import bigframes.operations.bool_ops as bool_ops import bigframes.operations.comparison_ops as comp_ops import bigframes.operations.datetime_ops as dt_ops @@ -353,6 +354,36 @@ def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: assert isinstance(op, json_ops.JSONDecode) return input.str.json_decode(_DTYPE_MAPPING[op.to_type]) + @compile_op.register(arr_ops.ToArrayOp) + def _(self, op: ops.ToArrayOp, *inputs: pl.Expr) -> pl.Expr: + return pl.concat_list(*inputs) + + @compile_op.register(arr_ops.ArrayReduceOp) + def _(self, op: ops.ArrayReduceOp, input: pl.Expr) -> pl.Expr: + # TODO: Unify this with general aggregation compilation? + if isinstance(op.aggregation, agg_ops.MinOp): + return input.list.min() + if isinstance(op.aggregation, agg_ops.MaxOp): + return input.list.max() + if isinstance(op.aggregation, agg_ops.SumOp): + return input.list.sum() + if isinstance(op.aggregation, agg_ops.MeanOp): + return input.list.mean() + if isinstance(op.aggregation, agg_ops.CountOp): + return input.list.len() + if isinstance(op.aggregation, agg_ops.StdOp): + return input.list.std() + if isinstance(op.aggregation, agg_ops.VarOp): + return input.list.var() + if isinstance(op.aggregation, agg_ops.AnyOp): + return input.list.any() + if isinstance(op.aggregation, agg_ops.AllOp): + return input.list.all() + else: + raise NotImplementedError( + f"Haven't implemented array aggregation: {op.aggregation}" + ) + @dataclasses.dataclass(frozen=True) class PolarsAggregateCompiler: scalar_compiler = PolarsExpressionCompiler() diff --git a/bigframes/operations/__init__.py b/bigframes/operations/__init__.py index e10a972790..e5888ace00 100644 --- a/bigframes/operations/__init__.py +++ b/bigframes/operations/__init__.py @@ -14,7 +14,13 @@ from __future__ import annotations -from bigframes.operations.array_ops import ArrayIndexOp, ArraySliceOp, ArrayToStringOp +from bigframes.operations.array_ops import ( + ArrayIndexOp, + ArrayReduceOp, + ArraySliceOp, + ArrayToStringOp, + ToArrayOp, +) from bigframes.operations.base_ops import ( BinaryOp, NaryOp, @@ -405,4 +411,6 @@ # Numpy ops mapping "NUMPY_TO_BINOP", "NUMPY_TO_OP", + "ToArrayOp", + "ArrayReduceOp", ] diff --git a/bigframes/operations/array_ops.py b/bigframes/operations/array_ops.py index c1e644fc11..61ada59cc7 100644 --- a/bigframes/operations/array_ops.py +++ b/bigframes/operations/array_ops.py @@ -13,10 +13,11 @@ # limitations under the License. import dataclasses +import functools import typing from bigframes import dtypes -from bigframes.operations import base_ops +from bigframes.operations import aggregations, base_ops @dataclasses.dataclass(frozen=True) @@ -63,3 +64,27 @@ def output_type(self, *input_types): return input_type else: raise TypeError("Input type must be an array or string-like type.") + + +class ToArrayOp(base_ops.NaryOp): + name: typing.ClassVar[str] = "array" + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + # very permissive, maybe should force caller to do this? + common_type = functools.reduce( + lambda t1, t2: dtypes.coerce_to_common(t1, t2), + input_types, + ) + return dtypes.list_type(common_type) + + +@dataclasses.dataclass(frozen=True) +class ArrayReduceOp(base_ops.UnaryOp): + name: typing.ClassVar[str] = "array_reduce" + aggregation: aggregations.AggregateOp + + def output_type(self, *input_types): + input_type = input_types[0] + assert dtypes.is_array_like(input_type) + inner_type = dtypes.get_array_inner_type(input_type) + return self.aggregation.output_type(inner_type) diff --git a/tests/system/small/engines/conftest.py b/tests/system/small/engines/conftest.py index 4f0f875b34..9699cc6a61 100644 --- a/tests/system/small/engines/conftest.py +++ b/tests/system/small/engines/conftest.py @@ -90,3 +90,10 @@ def repeated_data_source( repeated_pandas_df: pd.DataFrame, ) -> local_data.ManagedArrowTable: return local_data.ManagedArrowTable.from_pandas(repeated_pandas_df) + + +@pytest.fixture(scope="module") +def arrays_array_value( + repeated_data_source: local_data.ManagedArrowTable, fake_session: bigframes.Session +): + return ArrayValue.from_managed(repeated_data_source, fake_session) diff --git a/tests/system/small/engines/test_array_ops.py b/tests/system/small/engines/test_array_ops.py new file mode 100644 index 0000000000..c53b9e9dc1 --- /dev/null +++ b/tests/system/small/engines/test_array_ops.py @@ -0,0 +1,60 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes.core import array_value, expression +import bigframes.operations as ops +import bigframes.operations.aggregations as agg_ops +from bigframes.session import polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +def test_engines_to_array_op(scalars_array_value: array_value.ArrayValue, engine): + # Bigquery won't allow you to materialize arrays with null, so use non-nullable + int64_non_null = ops.coalesce_op.as_expr("int64_col", expression.const(0)) + bool_col_non_null = ops.coalesce_op.as_expr("bool_col", expression.const(False)) + float_col_non_null = ops.coalesce_op.as_expr("float64_col", expression.const(0.0)) + string_col_non_null = ops.coalesce_op.as_expr("string_col", expression.const("")) + + arr, _ = scalars_array_value.compute_values( + [ + ops.ToArrayOp().as_expr(int64_non_null), + ops.ToArrayOp().as_expr( + int64_non_null, bool_col_non_null, float_col_non_null + ), + ops.ToArrayOp().as_expr(string_col_non_null, string_col_non_null), + ] + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +def test_engines_array_reduce_op(arrays_array_value: array_value.ArrayValue, engine): + arr, _ = arrays_array_value.compute_values( + [ + ops.ArrayReduceOp(agg_ops.SumOp()).as_expr("float_list_col"), + ops.ArrayReduceOp(agg_ops.StdOp()).as_expr("float_list_col"), + ops.ArrayReduceOp(agg_ops.MaxOp()).as_expr("date_list_col"), + ops.ArrayReduceOp(agg_ops.CountOp()).as_expr("string_list_col"), + ops.ArrayReduceOp(agg_ops.AnyOp()).as_expr("bool_list_col"), + ] + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) diff --git a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py index 08bf0d7650..61bafeeca2 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py @@ -699,6 +699,9 @@ def visit_ArrayFilter(self, op, *, arg, body, param): def visit_ArrayMap(self, op, *, arg, body, param): return self.f.array(sg.select(body).from_(self._unnest(arg, as_=param))) + def visit_ArrayReduce(self, op, *, arg, body, param): + return sg.select(body).from_(self._unnest(arg, as_=param)).subquery() + def visit_ArrayZip(self, op, *, arg): lengths = [self.f.array_length(arr) - 1 for arr in arg] idx = sg.to_identifier(util.gen_name("bq_arr_idx")) diff --git a/third_party/bigframes_vendored/ibis/expr/operations/arrays.py b/third_party/bigframes_vendored/ibis/expr/operations/arrays.py index 638b24a212..8134506255 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/arrays.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/arrays.py @@ -105,6 +105,21 @@ def dtype(self) -> dt.DataType: return dt.Array(self.body.dtype) +@public +class ArrayReduce(Value): + """Apply a function to every element of an array.""" + + arg: Value[dt.Array] + body: Value + param: str + + shape = rlz.shape_like("arg") + + @attribute + def dtype(self) -> dt.DataType: + return self.body.dtype + + @public class ArrayFilter(Value): """Filter array elements with a function.""" diff --git a/third_party/bigframes_vendored/ibis/expr/rewrites.py b/third_party/bigframes_vendored/ibis/expr/rewrites.py index a85498b30b..b0569846da 100644 --- a/third_party/bigframes_vendored/ibis/expr/rewrites.py +++ b/third_party/bigframes_vendored/ibis/expr/rewrites.py @@ -252,7 +252,7 @@ def rewrite_project_input(value, relation): # relation return value.replace( project_wrap_analytic | project_wrap_reduction, - filter=p.Value & ~p.WindowFunction, + filter=p.Value & ~p.WindowFunction & ~p.ArrayReduce, context={"rel": relation}, ) diff --git a/third_party/bigframes_vendored/ibis/expr/types/arrays.py b/third_party/bigframes_vendored/ibis/expr/types/arrays.py index a8f64490c1..72f01334c1 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/arrays.py +++ b/third_party/bigframes_vendored/ibis/expr/types/arrays.py @@ -486,6 +486,24 @@ def map(self, func: Deferred | Callable[[ir.Value], ir.Value]) -> ir.ArrayValue: body = resolve(parameter.to_expr()) return ops.ArrayMap(self, param=parameter.param, body=body).to_expr() + def reduce(self, func: Deferred | Callable[[ir.Value], ir.Value]) -> ir.ArrayValue: + if isinstance(func, Deferred): + name = "_" + resolve = func.resolve + elif callable(func): + name = next(iter(inspect.signature(func).parameters.keys())) + resolve = func + else: + raise TypeError( + f"`func` must be a Deferred or Callable, got `{type(func).__name__}`" + ) + + parameter = ops.Argument( + name=name, shape=self.op().shape, dtype=self.type().value_type + ) + body = resolve(parameter.to_expr()) + return ops.ArrayReduce(self, param=parameter.param, body=body).to_expr() + def filter( self, predicate: Deferred | Callable[[ir.Value], bool | ir.BooleanValue] ) -> ir.ArrayValue: diff --git a/third_party/bigframes_vendored/ibis/expr/types/logical.py b/third_party/bigframes_vendored/ibis/expr/types/logical.py index 80a8527a04..cc86c747f6 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/logical.py +++ b/third_party/bigframes_vendored/ibis/expr/types/logical.py @@ -353,6 +353,9 @@ def resolve_exists_subquery(outer): return Deferred(Call(resolve_exists_subquery, _)) elif len(parents) == 1: op = ops.Any(self, where=self._bind_to_parent_table(where)) + elif len(parents) == 0: + # array reduction case + op = ops.Any(self, where=self._bind_to_parent_table(where)) else: raise NotImplementedError( f'Cannot compute "any" for expression of type {type(self)} ' From 7ac6fe16f7f2c09d2efac6ab813ec841c21baef8 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 29 Aug 2025 12:00:45 -0700 Subject: [PATCH 029/313] feat: Local date accessor execution support (#2034) --- bigframes/core/compile/polars/compiler.py | 53 +++++++++++++++ bigframes/operations/datetimes.py | 5 +- bigframes/operations/frequency_ops.py | 15 ++++- bigframes/session/polars_executor.py | 11 ++++ .../system/small/engines/test_temporal_ops.py | 66 +++++++++++++++++++ .../test_unary_compiler/test_floor_dt/out.sql | 2 +- .../expressions/test_unary_compiler.py | 2 +- 7 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 tests/system/small/engines/test_temporal_ops.py diff --git a/bigframes/core/compile/polars/compiler.py b/bigframes/core/compile/polars/compiler.py index 3316154de7..70fa516e51 100644 --- a/bigframes/core/compile/polars/compiler.py +++ b/bigframes/core/compile/polars/compiler.py @@ -34,7 +34,9 @@ import bigframes.operations.array_ops as arr_ops import bigframes.operations.bool_ops as bool_ops import bigframes.operations.comparison_ops as comp_ops +import bigframes.operations.date_ops as date_ops import bigframes.operations.datetime_ops as dt_ops +import bigframes.operations.frequency_ops as freq_ops import bigframes.operations.generic_ops as gen_ops import bigframes.operations.json_ops as json_ops import bigframes.operations.numeric_ops as num_ops @@ -75,6 +77,20 @@ def decorator(func): if polars_installed: + _FREQ_MAPPING = { + "Y": "1y", + "Q": "1q", + "M": "1mo", + "W": "1w", + "D": "1d", + "h": "1h", + "min": "1m", + "s": "1s", + "ms": "1ms", + "us": "1us", + "ns": "1ns", + } + _DTYPE_MAPPING = { # Direct mappings bigframes.dtypes.INT_DTYPE: pl.Int64(), @@ -330,11 +346,48 @@ def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: else: return pl.any_horizontal(*(input.str.ends_with(pat) for pat in op.pat)) + @compile_op.register(freq_ops.FloorDtOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, freq_ops.FloorDtOp) + return input.dt.truncate(every=_FREQ_MAPPING[op.freq]) + @compile_op.register(dt_ops.StrftimeOp) def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: assert isinstance(op, dt_ops.StrftimeOp) return input.dt.strftime(op.date_format) + @compile_op.register(date_ops.YearOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.dt.year() + + @compile_op.register(date_ops.QuarterOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.dt.quarter() + + @compile_op.register(date_ops.MonthOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.dt.month() + + @compile_op.register(date_ops.DayOfWeekOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.dt.weekday() - 1 + + @compile_op.register(date_ops.DayOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.dt.day() + + @compile_op.register(date_ops.IsoYearOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.dt.iso_year() + + @compile_op.register(date_ops.IsoWeekOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.dt.week() + + @compile_op.register(date_ops.IsoDayOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + return input.dt.weekday() + @compile_op.register(dt_ops.ParseDatetimeOp) def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: assert isinstance(op, dt_ops.ParseDatetimeOp) diff --git a/bigframes/operations/datetimes.py b/bigframes/operations/datetimes.py index 14bf10f463..95896ddc97 100644 --- a/bigframes/operations/datetimes.py +++ b/bigframes/operations/datetimes.py @@ -30,6 +30,7 @@ _ONE_DAY = pandas.Timedelta("1d") _ONE_SECOND = pandas.Timedelta("1s") _ONE_MICRO = pandas.Timedelta("1us") +_SUPPORTED_FREQS = ("Y", "Q", "M", "W", "D", "h", "min", "s", "ms", "us") @log_adapter.class_logger @@ -155,4 +156,6 @@ def normalize(self) -> series.Series: return self._apply_unary_op(ops.normalize_op) def floor(self, freq: str) -> series.Series: - return self._apply_unary_op(ops.FloorDtOp(freq=freq)) + if freq not in _SUPPORTED_FREQS: + raise ValueError(f"freq must be one of {_SUPPORTED_FREQS}") + return self._apply_unary_op(ops.FloorDtOp(freq=freq)) # type: ignore diff --git a/bigframes/operations/frequency_ops.py b/bigframes/operations/frequency_ops.py index 2d5a854c32..b94afa7271 100644 --- a/bigframes/operations/frequency_ops.py +++ b/bigframes/operations/frequency_ops.py @@ -27,9 +27,22 @@ @dataclasses.dataclass(frozen=True) class FloorDtOp(base_ops.UnaryOp): name: typing.ClassVar[str] = "floor_dt" - freq: str + freq: typing.Literal[ + "Y", + "Q", + "M", + "W", + "D", + "h", + "min", + "s", + "ms", + "us", + ] def output_type(self, *input_types): + if not dtypes.is_datetime_like(input_types[0]): + raise TypeError("dt floor requires datetime-like arguments") return input_types[0] diff --git a/bigframes/session/polars_executor.py b/bigframes/session/polars_executor.py index b93d31d255..d8df558fe4 100644 --- a/bigframes/session/polars_executor.py +++ b/bigframes/session/polars_executor.py @@ -24,6 +24,8 @@ from bigframes.operations import ( bool_ops, comparison_ops, + date_ops, + frequency_ops, generic_ops, numeric_ops, string_ops, @@ -60,6 +62,15 @@ comparison_ops.GtOp, comparison_ops.LeOp, comparison_ops.GeOp, + date_ops.YearOp, + date_ops.QuarterOp, + date_ops.MonthOp, + date_ops.DayOfWeekOp, + date_ops.DayOp, + date_ops.IsoYearOp, + date_ops.IsoWeekOp, + date_ops.IsoDayOp, + frequency_ops.FloorDtOp, numeric_ops.AddOp, numeric_ops.SubOp, numeric_ops.MulOp, diff --git a/tests/system/small/engines/test_temporal_ops.py b/tests/system/small/engines/test_temporal_ops.py new file mode 100644 index 0000000000..5a39587886 --- /dev/null +++ b/tests/system/small/engines/test_temporal_ops.py @@ -0,0 +1,66 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes.core import array_value +import bigframes.operations as ops +from bigframes.session import polars_executor +from bigframes.testing.engine_utils import assert_equivalence_execution + +pytest.importorskip("polars") + +# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. +REFERENCE_ENGINE = polars_executor.PolarsExecutor() + + +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +def test_engines_dt_floor(scalars_array_value: array_value.ArrayValue, engine): + arr, _ = scalars_array_value.compute_values( + [ + ops.FloorDtOp("us").as_expr("timestamp_col"), + ops.FloorDtOp("ms").as_expr("timestamp_col"), + ops.FloorDtOp("s").as_expr("timestamp_col"), + ops.FloorDtOp("min").as_expr("timestamp_col"), + ops.FloorDtOp("h").as_expr("timestamp_col"), + ops.FloorDtOp("D").as_expr("timestamp_col"), + ops.FloorDtOp("W").as_expr("timestamp_col"), + ops.FloorDtOp("M").as_expr("timestamp_col"), + ops.FloorDtOp("Q").as_expr("timestamp_col"), + ops.FloorDtOp("Y").as_expr("timestamp_col"), + ops.FloorDtOp("Q").as_expr("datetime_col"), + ops.FloorDtOp("us").as_expr("datetime_col"), + ] + ) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +def test_engines_date_accessors(scalars_array_value: array_value.ArrayValue, engine): + datelike_cols = ["datetime_col", "timestamp_col", "date_col"] + accessors = [ + ops.day_op, + ops.dayofweek_op, + ops.month_op, + ops.quarter_op, + ops.year_op, + ops.iso_day_op, + ops.iso_week_op, + ops.iso_year_op, + ] + + exprs = [acc.as_expr(col) for acc in accessors for col in datelike_cols] + + arr, _ = scalars_array_value.compute_values(exprs) + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_floor_dt/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_floor_dt/out.sql index 3c7efd3098..ad4fdb23a1 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_floor_dt/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_floor_dt/out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - TIMESTAMP_TRUNC(`bfcol_0`, DAY) AS `bfcol_1` + TIMESTAMP_TRUNC(`bfcol_0`, D) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py index f011721ee5..8f3af11842 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py @@ -153,7 +153,7 @@ def test_expm1(scalar_types_df: bpd.DataFrame, snapshot): def test_floor_dt(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.FloorDtOp("DAY"), "timestamp_col") + sql = _apply_unary_op(bf_df, ops.FloorDtOp("D"), "timestamp_col") snapshot.assert_match(sql, "out.sql") From 70726270e580977ad4e1750d8e0cc2c6c1338ce5 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Fri, 29 Aug 2025 13:38:01 -0700 Subject: [PATCH 030/313] test: Add unit test for get_python_version (#2041) * test: Add unit test for get_python_version * fix --- .../functions/test_remote_function_utils.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/unit/functions/test_remote_function_utils.py b/tests/unit/functions/test_remote_function_utils.py index 8ddd39d857..812d65bbad 100644 --- a/tests/unit/functions/test_remote_function_utils.py +++ b/tests/unit/functions/test_remote_function_utils.py @@ -13,6 +13,7 @@ # limitations under the License. import inspect +import sys from unittest.mock import patch import bigframes_vendored.constants as constants @@ -227,6 +228,26 @@ def test_get_updated_package_requirements_with_existing_cloudpickle(): assert result == expected +# Dynamically generate expected python versions for the test +_major = sys.version_info.major +_minor = sys.version_info.minor +_compat_version = f"python{_major}{_minor}" +_standard_version = f"python-{_major}.{_minor}" + + +@pytest.mark.parametrize( + "is_compat, expected_version", + [ + (True, _compat_version), + (False, _standard_version), + ], +) +def test_get_python_version(is_compat, expected_version): + """Tests the python version for both standard and compat modes.""" + result = _utils.get_python_version(is_compat=is_compat) + assert result == expected_version + + def test_package_existed_helper(): """Tests the _package_existed helper function directly.""" reqs = ["pandas==1.0", "numpy", "scikit-learn>=1.2.0"] From 164c4818bc4ff2990dca16b9f22a798f47e0a60b Mon Sep 17 00:00:00 2001 From: jialuoo Date: Fri, 29 Aug 2025 14:04:16 -0700 Subject: [PATCH 031/313] feat: Support args in dataframe apply method (#2026) * feat: Allow passing args to managed functions in DataFrame apply method * remove a test * support remote function * resolve the comments * improve the message * fix the tests --- bigframes/dataframe.py | 73 +++++++--- bigframes/functions/_function_session.py | 13 +- bigframes/functions/function.py | 7 - bigframes/functions/function_template.py | 26 +++- .../large/functions/test_managed_function.py | 117 +++++++++++++++- .../large/functions/test_remote_function.py | 126 ++++++++++++++++-- .../small/functions/test_remote_function.py | 14 -- 7 files changed, 320 insertions(+), 56 deletions(-) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 85760d94bc..d618d13aa4 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -77,6 +77,7 @@ import bigframes.exceptions as bfe import bigframes.formatting_helpers as formatter import bigframes.functions +from bigframes.functions import function_typing import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops import bigframes.operations.ai @@ -4835,37 +4836,73 @@ def apply(self, func, *, axis=0, args: typing.Tuple = (), **kwargs): ) # Apply the function - result_series = rows_as_json_series._apply_unary_op( - ops.RemoteFunctionOp(function_def=func.udf_def, apply_on_null=True) - ) + if args: + result_series = rows_as_json_series._apply_nary_op( + ops.NaryRemoteFunctionOp(function_def=func.udf_def), + list(args), + ) + else: + result_series = rows_as_json_series._apply_unary_op( + ops.RemoteFunctionOp( + function_def=func.udf_def, apply_on_null=True + ) + ) else: # This is a special case where we are providing not-pandas-like # extension. If the bigquery function can take one or more - # params then we assume that here the user intention is to use - # the column values of the dataframe as arguments to the - # function. For this to work the following condition must be - # true: - # 1. The number or input params in the function must be same - # as the number of columns in the dataframe + # params (excluding the args) then we assume that here the user + # intention is to use the column values of the dataframe as + # arguments to the function. For this to work the following + # condition must be true: + # 1. The number or input params (excluding the args) in the + # function must be same as the number of columns in the + # dataframe. # 2. The dtypes of the columns in the dataframe must be - # compatible with the data types of the input params + # compatible with the data types of the input params. # 3. The order of the columns in the dataframe must correspond - # to the order of the input params in the function + # to the order of the input params in the function. udf_input_dtypes = func.udf_def.signature.bf_input_types - if len(udf_input_dtypes) != len(self.columns): + if not args and len(udf_input_dtypes) != len(self.columns): raise ValueError( - f"BigFrames BigQuery function takes {len(udf_input_dtypes)}" - f" arguments but DataFrame has {len(self.columns)} columns." + f"Parameter count mismatch: BigFrames BigQuery function" + f" expected {len(udf_input_dtypes)} parameters but" + f" received {len(self.columns)} DataFrame columns." ) - if udf_input_dtypes != tuple(self.dtypes.to_list()): + if args and len(udf_input_dtypes) != len(self.columns) + len(args): raise ValueError( - f"BigFrames BigQuery function takes arguments of types " - f"{udf_input_dtypes} but DataFrame dtypes are {tuple(self.dtypes)}." + f"Parameter count mismatch: BigFrames BigQuery function" + f" expected {len(udf_input_dtypes)} parameters but" + f" received {len(self.columns) + len(args)} values" + f" ({len(self.columns)} DataFrame columns and" + f" {len(args)} args)." ) + end_slice = -len(args) if args else None + if udf_input_dtypes[:end_slice] != tuple(self.dtypes.to_list()): + raise ValueError( + f"Data type mismatch for DataFrame columns:" + f" Expected {udf_input_dtypes[:end_slice]}" + f" Received {tuple(self.dtypes)}." + ) + if args: + bq_types = ( + function_typing.sdk_type_from_python_type(type(arg)) + for arg in args + ) + args_dtype = tuple( + function_typing.sdk_type_to_bf_type(bq_type) + for bq_type in bq_types + ) + if udf_input_dtypes[end_slice:] != args_dtype: + raise ValueError( + f"Data type mismatch for 'args' parameter:" + f" Expected {udf_input_dtypes[end_slice:]}" + f" Received {args_dtype}." + ) series_list = [self[col] for col in self.columns] + op_list = series_list[1:] + list(args) result_series = series_list[0]._apply_nary_op( - ops.NaryRemoteFunctionOp(function_def=func.udf_def), series_list[1:] + ops.NaryRemoteFunctionOp(function_def=func.udf_def), op_list ) result_series.name = None diff --git a/bigframes/functions/_function_session.py b/bigframes/functions/_function_session.py index 90bfb89c56..a2fb66539b 100644 --- a/bigframes/functions/_function_session.py +++ b/bigframes/functions/_function_session.py @@ -959,11 +959,16 @@ def _convert_row_processor_sig( ) -> Optional[inspect.Signature]: import bigframes.series as bf_series - if len(signature.parameters) == 1: - only_param = next(iter(signature.parameters.values())) - param_type = only_param.annotation + if len(signature.parameters) >= 1: + first_param = next(iter(signature.parameters.values())) + param_type = first_param.annotation if (param_type == bf_series.Series) or (param_type == pandas.Series): msg = bfe.format_message("input_types=Series is in preview.") warnings.warn(msg, stacklevel=1, category=bfe.PreviewWarning) - return signature.replace(parameters=[only_param.replace(annotation=str)]) + return signature.replace( + parameters=[ + p.replace(annotation=str) if i == 0 else p + for i, p in enumerate(signature.parameters.values()) + ] + ) return None diff --git a/bigframes/functions/function.py b/bigframes/functions/function.py index a62da57075..99b89131e7 100644 --- a/bigframes/functions/function.py +++ b/bigframes/functions/function.py @@ -178,13 +178,6 @@ def read_gbq_function( ValueError, f"Unknown function '{routine_ref}'." ) - if is_row_processor and len(routine.arguments) > 1: - raise bf_formatting.create_exception_with_feedback_link( - ValueError, - "A multi-input function cannot be a row processor. A row processor function " - "takes in a single input representing the row.", - ) - if is_row_processor: return _try_import_row_routine(routine, session) else: diff --git a/bigframes/functions/function_template.py b/bigframes/functions/function_template.py index 5f04fcc8e2..dd31de7243 100644 --- a/bigframes/functions/function_template.py +++ b/bigframes/functions/function_template.py @@ -195,7 +195,9 @@ def udf_http_row_processor(request): calls = request_json["calls"] replies = [] for call in calls: - reply = convert_to_bq_json(output_type, udf(get_pd_series(call[0]))) + reply = convert_to_bq_json( + output_type, udf(get_pd_series(call[0]), *call[1:]) + ) if type(reply) is list: # Since the BQ remote function does not support array yet, # return a json serialized version of the reply. @@ -332,6 +334,28 @@ def generate_managed_function_code( f"""def bigframes_handler(str_arg): return {udf_name}({get_pd_series.__name__}(str_arg))""" ) + + sig = inspect.signature(def_) + params = list(sig.parameters.values()) + additional_params = params[1:] + + # Build the parameter list for the new handler function definition. + # e.g., "str_arg, y: bool, z" + handler_def_parts = ["str_arg"] + handler_def_parts.extend(str(p) for p in additional_params) + handler_def_str = ", ".join(handler_def_parts) + + # Build the argument list for the call to the original UDF. + # e.g., "get_pd_series(str_arg), y, z" + udf_call_parts = [f"{get_pd_series.__name__}(str_arg)"] + udf_call_parts.extend(p.name for p in additional_params) + udf_call_str = ", ".join(udf_call_parts) + + bigframes_handler_code = textwrap.dedent( + f"""def bigframes_handler({handler_def_str}): + return {udf_name}({udf_call_str})""" + ) + else: udf_code = "" bigframes_handler_code = textwrap.dedent( diff --git a/tests/system/large/functions/test_managed_function.py b/tests/system/large/functions/test_managed_function.py index 73335afa3c..b0e44b648f 100644 --- a/tests/system/large/functions/test_managed_function.py +++ b/tests/system/large/functions/test_managed_function.py @@ -468,20 +468,20 @@ def foo(x, y, z): # Fails to apply on dataframe with incompatible number of columns. with pytest.raises( ValueError, - match="^BigFrames BigQuery function takes 3 arguments but DataFrame has 2 columns\\.$", + match="^Parameter count mismatch:.* expected 3 parameters but received 2 DataFrame columns.", ): bf_df[["Id", "Age"]].apply(foo, axis=1) with pytest.raises( ValueError, - match="^BigFrames BigQuery function takes 3 arguments but DataFrame has 4 columns\\.$", + match="^Parameter count mismatch:.* expected 3 parameters but received 4 DataFrame columns.", ): bf_df.assign(Country="lalaland").apply(foo, axis=1) # Fails to apply on dataframe with incompatible column datatypes. with pytest.raises( ValueError, - match="^BigFrames BigQuery function takes arguments of types .* but DataFrame dtypes are .*", + match="^Data type mismatch for DataFrame columns: Expected .* Received .*", ): bf_df.assign(Age=bf_df["Age"].astype("Int64")).apply(foo, axis=1) @@ -965,6 +965,117 @@ def float_parser(row): ) +def test_managed_function_df_apply_axis_1_args(session, dataset_id, scalars_dfs): + columns = ["int64_col", "int64_too"] + scalars_df, scalars_pandas_df = scalars_dfs + + try: + + def the_sum(s1, s2, x): + return s1 + s2 + x + + the_sum_mf = session.udf( + input_types=[int, int, int], + output_type=int, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(the_sum) + + args1 = (1,) + + # Fails to apply on dataframe with incompatible number of columns and args. + with pytest.raises( + ValueError, + match="^Parameter count mismatch:.* expected 3 parameters but received 4 values \\(3 DataFrame columns and 1 args\\)", + ): + scalars_df[columns + ["float64_col"]].apply(the_sum_mf, axis=1, args=args1) + + # Fails to apply on dataframe with incompatible column datatypes. + with pytest.raises( + ValueError, + match="^Data type mismatch for DataFrame columns: Expected .* Received .*", + ): + scalars_df[columns].assign( + int64_col=lambda df: df["int64_col"].astype("Float64") + ).apply(the_sum_mf, axis=1, args=args1) + + # Fails to apply on dataframe with incompatible args datatypes. + with pytest.raises( + ValueError, + match="^Data type mismatch for 'args' parameter: Expected .* Received .*", + ): + scalars_df[columns].apply(the_sum_mf, axis=1, args=(1.3,)) + + bf_result = ( + scalars_df[columns] + .dropna() + .apply(the_sum_mf, axis=1, args=args1) + .to_pandas() + ) + pd_result = scalars_pandas_df[columns].dropna().apply(sum, axis=1, args=args1) + + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + finally: + # clean up the gcp assets created for the managed function. + cleanup_function_assets(the_sum_mf, session.bqclient, ignore_failures=False) + + +def test_managed_function_df_apply_axis_1_series_args(session, dataset_id, scalars_dfs): + columns = ["int64_col", "float64_col"] + scalars_df, scalars_pandas_df = scalars_dfs + + try: + + def analyze(s, x, y): + value = f"value is {s['int64_col']} and {s['float64_col']}" + if x: + return f"{value}, x is True!" + if y > 0: + return f"{value}, x is False, y is positive!" + return f"{value}, x is False, y is non-positive!" + + analyze_mf = session.udf( + input_types=[bigframes.series.Series, bool, float], + output_type=str, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(analyze) + + args1 = (True, 10.0) + bf_result = ( + scalars_df[columns] + .dropna() + .apply(analyze_mf, axis=1, args=args1) + .to_pandas() + ) + pd_result = ( + scalars_pandas_df[columns].dropna().apply(analyze, axis=1, args=args1) + ) + + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + args2 = (False, -10.0) + analyze_mf_ref = session.read_gbq_function( + analyze_mf.bigframes_bigquery_function, is_row_processor=True + ) + bf_result = ( + scalars_df[columns] + .dropna() + .apply(analyze_mf_ref, axis=1, args=args2) + .to_pandas() + ) + pd_result = ( + scalars_pandas_df[columns].dropna().apply(analyze, axis=1, args=args2) + ) + + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + finally: + # clean up the gcp assets created for the managed function. + cleanup_function_assets(analyze_mf, session.bqclient, ignore_failures=False) + + def test_managed_function_df_where_mask(session, dataset_id, scalars_dfs): try: diff --git a/tests/system/large/functions/test_remote_function.py b/tests/system/large/functions/test_remote_function.py index 3c453a52a4..e6372d768b 100644 --- a/tests/system/large/functions/test_remote_function.py +++ b/tests/system/large/functions/test_remote_function.py @@ -1937,6 +1937,114 @@ def float_parser(row): ) +@pytest.mark.flaky(retries=2, delay=120) +def test_df_apply_axis_1_args(session, scalars_dfs): + columns = ["int64_col", "int64_too"] + scalars_df, scalars_pandas_df = scalars_dfs + + try: + + def the_sum(s1, s2, x): + return s1 + s2 + x + + the_sum_mf = session.remote_function( + input_types=[int, int, int], + output_type=int, + reuse=False, + cloud_function_service_account="default", + )(the_sum) + + args1 = (1,) + + # Fails to apply on dataframe with incompatible number of columns and args. + with pytest.raises( + ValueError, + match="^Parameter count mismatch:.* expected 3 parameters but received 4 values \\(2 DataFrame columns and 2 args\\)", + ): + scalars_df[columns].apply( + the_sum_mf, + axis=1, + args=( + 1, + 1, + ), + ) + + # Fails to apply on dataframe with incompatible column datatypes. + with pytest.raises( + ValueError, + match="^Data type mismatch for DataFrame columns: Expected .* Received .*", + ): + scalars_df[columns].assign( + int64_col=lambda df: df["int64_col"].astype("Float64") + ).apply(the_sum_mf, axis=1, args=args1) + + # Fails to apply on dataframe with incompatible args datatypes. + with pytest.raises( + ValueError, + match="^Data type mismatch for 'args' parameter: Expected .* Received .*", + ): + scalars_df[columns].apply(the_sum_mf, axis=1, args=("hello world",)) + + bf_result = ( + scalars_df[columns] + .dropna() + .apply(the_sum_mf, axis=1, args=args1) + .to_pandas() + ) + pd_result = scalars_pandas_df[columns].dropna().apply(sum, axis=1, args=args1) + + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + finally: + # clean up the gcp assets created for the remote function. + cleanup_function_assets(the_sum_mf, session.bqclient, ignore_failures=False) + + +@pytest.mark.flaky(retries=2, delay=120) +def test_df_apply_axis_1_series_args(session, scalars_dfs): + columns = ["int64_col", "float64_col"] + scalars_df, scalars_pandas_df = scalars_dfs + + try: + + @session.remote_function( + input_types=[bigframes.series.Series, float, str, bool], + output_type=list[str], + reuse=False, + cloud_function_service_account="default", + ) + def foo_list(x, y0: float, y1, y2) -> list[str]: + return ( + [str(x["int64_col"]), str(y0), str(y1), str(y2)] + if y2 + else [str(x["float64_col"])] + ) + + args1 = (12.34, "hello world", True) + bf_result = scalars_df[columns].apply(foo_list, axis=1, args=args1).to_pandas() + pd_result = scalars_pandas_df[columns].apply(foo_list, axis=1, args=args1) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + + args2 = (43.21, "xxx3yyy", False) + foo_list_ref = session.read_gbq_function( + foo_list.bigframes_bigquery_function, is_row_processor=True + ) + bf_result = ( + scalars_df[columns].apply(foo_list_ref, axis=1, args=args2).to_pandas() + ) + pd_result = scalars_pandas_df[columns].apply(foo_list, axis=1, args=args2) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + + finally: + # Clean up the gcp assets created for the remote function. + cleanup_function_assets(foo_list, session.bqclient, ignore_failures=False) + + @pytest.mark.parametrize( ("memory_mib_args", "expected_memory"), [ @@ -2200,19 +2308,19 @@ def foo(x, y, z): # Fails to apply on dataframe with incompatible number of columns with pytest.raises( ValueError, - match="^BigFrames BigQuery function takes 3 arguments but DataFrame has 2 columns\\.$", + match="^Parameter count mismatch:.* expected 3 parameters but received 2 DataFrame columns.", ): bf_df[["Id", "Age"]].apply(foo, axis=1) with pytest.raises( ValueError, - match="^BigFrames BigQuery function takes 3 arguments but DataFrame has 4 columns\\.$", + match="^Parameter count mismatch:.* expected 3 parameters but received 4 DataFrame columns.", ): bf_df.assign(Country="lalaland").apply(foo, axis=1) # Fails to apply on dataframe with incompatible column datatypes with pytest.raises( ValueError, - match="^BigFrames BigQuery function takes arguments of types .* but DataFrame dtypes are .*", + match="^Data type mismatch for DataFrame columns: Expected .* Received .*", ): bf_df.assign(Age=bf_df["Age"].astype("Int64")).apply(foo, axis=1) @@ -2284,19 +2392,19 @@ def foo(x, y, z): # Fails to apply on dataframe with incompatible number of columns with pytest.raises( ValueError, - match="^BigFrames BigQuery function takes 3 arguments but DataFrame has 2 columns\\.$", + match="^Parameter count mismatch:.* expected 3 parameters but received 2 DataFrame columns.", ): bf_df[["Id", "Age"]].apply(foo, axis=1) with pytest.raises( ValueError, - match="^BigFrames BigQuery function takes 3 arguments but DataFrame has 4 columns\\.$", + match="^Parameter count mismatch:.* expected 3 parameters but received 4 DataFrame columns.", ): bf_df.assign(Country="lalaland").apply(foo, axis=1) # Fails to apply on dataframe with incompatible column datatypes with pytest.raises( ValueError, - match="^BigFrames BigQuery function takes arguments of types .* but DataFrame dtypes are .*", + match="^Data type mismatch for DataFrame columns: Expected .* Received .*", ): bf_df.assign(Age=bf_df["Age"].astype("Int64")).apply(foo, axis=1) @@ -2358,19 +2466,19 @@ def foo(x): # Fails to apply on dataframe with incompatible number of columns with pytest.raises( ValueError, - match="^BigFrames BigQuery function takes 1 arguments but DataFrame has 0 columns\\.$", + match="^Parameter count mismatch:.* expected 1 parameters but received 0 DataFrame.*", ): bf_df[[]].apply(foo, axis=1) with pytest.raises( ValueError, - match="^BigFrames BigQuery function takes 1 arguments but DataFrame has 2 columns\\.$", + match="^Parameter count mismatch:.* expected 1 parameters but received 2 DataFrame.*", ): bf_df.assign(Country="lalaland").apply(foo, axis=1) # Fails to apply on dataframe with incompatible column datatypes with pytest.raises( ValueError, - match="^BigFrames BigQuery function takes arguments of types .* but DataFrame dtypes are .*", + match="^Data type mismatch for DataFrame columns: Expected .* Received .*", ): bf_df.assign(Id=bf_df["Id"].astype("Float64")).apply(foo, axis=1) diff --git a/tests/system/small/functions/test_remote_function.py b/tests/system/small/functions/test_remote_function.py index 86076e764f..28fab19144 100644 --- a/tests/system/small/functions/test_remote_function.py +++ b/tests/system/small/functions/test_remote_function.py @@ -1154,20 +1154,6 @@ def test_df_apply_scalar_func(session, scalars_dfs): ) -def test_read_gbq_function_multiple_inputs_not_a_row_processor(session): - with pytest.raises(ValueError) as context: - # The remote function has two args, which cannot be row processed. Throw - # a ValueError for it. - session.read_gbq_function( - function_name="bqutil.fn.cw_regexp_instr_2", - is_row_processor=True, - ) - assert str(context.value) == ( - "A multi-input function cannot be a row processor. A row processor function " - f"takes in a single input representing the row. {constants.FEEDBACK_LINK}" - ) - - @pytest.mark.flaky(retries=2, delay=120) def test_df_apply_axis_1(session, scalars_dfs, dataset_id_permanent): columns = [ From 1a0f710ac11418fd71ab3373f3f6002fa581b180 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 29 Aug 2025 14:12:13 -0700 Subject: [PATCH 032/313] feat: Can pivot unordered, unindexed dataframe (#2040) --- bigframes/core/blocks.py | 14 +++++++++++--- bigframes/dataframe.py | 4 ---- tests/system/conftest.py | 12 ++++++++++++ tests/system/small/test_unordered.py | 24 ++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index 283f56fd39..f7d456bf9d 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -2129,9 +2129,17 @@ def _get_unique_values( import bigframes.core.block_transforms as block_tf import bigframes.dataframe as df - unique_value_block = block_tf.drop_duplicates( - self.select_columns(columns), columns - ) + if self.explicitly_ordered: + unique_value_block = block_tf.drop_duplicates( + self.select_columns(columns), columns + ) + else: + unique_value_block, _ = self.aggregate(by_column_ids=columns, dropna=False) + col_labels = self._get_labels_for_columns(columns) + unique_value_block = unique_value_block.reset_index( + drop=False + ).with_column_labels(col_labels) + pd_values = ( df.DataFrame(unique_value_block).head(max_unique_values + 1).to_pandas() ) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index d618d13aa4..7f3d51a03e 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -3347,8 +3347,6 @@ def _pivot( ) return DataFrame(pivot_block) - @validations.requires_index - @validations.requires_ordering() def pivot( self, *, @@ -3362,8 +3360,6 @@ def pivot( ) -> DataFrame: return self._pivot(columns=columns, index=index, values=values) - @validations.requires_index - @validations.requires_ordering() def pivot_table( self, values: typing.Optional[ diff --git a/tests/system/conftest.py b/tests/system/conftest.py index a75918ed23..70a379fe0e 100644 --- a/tests/system/conftest.py +++ b/tests/system/conftest.py @@ -585,6 +585,18 @@ def scalars_df_null_index( ).sort_values("rowindex") +@pytest.fixture(scope="session") +def scalars_df_unordered( + scalars_table_id: str, unordered_session: bigframes.Session +) -> bigframes.dataframe.DataFrame: + """DataFrame pointing at test data.""" + df = unordered_session.read_gbq( + scalars_table_id, index_col=bigframes.enums.DefaultIndexKind.NULL + ) + assert not df._block.explicitly_ordered + return df + + @pytest.fixture(scope="session") def scalars_df_2_default_index( scalars_df_2_index: bigframes.dataframe.DataFrame, diff --git a/tests/system/small/test_unordered.py b/tests/system/small/test_unordered.py index 0825b78037..ccb2140799 100644 --- a/tests/system/small/test_unordered.py +++ b/tests/system/small/test_unordered.py @@ -265,3 +265,27 @@ def test__resample_with_index(unordered_session, rule, origin, data): pd.testing.assert_frame_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) + + +@pytest.mark.parametrize( + ("values", "index", "columns"), + [ + ("int64_col", "int64_too", ["string_col"]), + (["int64_col"], "int64_too", ["string_col"]), + (["int64_col", "float64_col"], "int64_too", ["string_col"]), + ], +) +def test_unordered_df_pivot( + scalars_df_unordered, scalars_pandas_df_index, values, index, columns +): + bf_result = scalars_df_unordered.pivot( + values=values, index=index, columns=columns + ).to_pandas() + pd_result = scalars_pandas_df_index.pivot( + values=values, index=index, columns=columns + ) + + # Pandas produces NaN, where bq dataframes produces pd.NA + bf_result = bf_result.fillna(float("nan")) + pd_result = pd_result.fillna(float("nan")) + pd.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) From 8689199aa82212ed300fff592097093812e0290e Mon Sep 17 00:00:00 2001 From: jialuoo Date: Fri, 29 Aug 2025 15:40:54 -0700 Subject: [PATCH 033/313] fix: Resolve the validation issue for other arg in dataframe where method (#2042) --- bigframes/dataframe.py | 6 ++-- .../large/functions/test_managed_function.py | 31 ++++++++++++++++++ .../large/functions/test_remote_function.py | 32 +++++++++++++++++++ tests/system/small/test_dataframe.py | 12 +++++++ 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 7f3d51a03e..a5ecd82d47 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -2877,9 +2877,6 @@ def _apply_callable(self, condition): return condition def where(self, cond, other=None): - if isinstance(other, bigframes.series.Series): - raise ValueError("Seires is not a supported replacement type!") - if self.columns.nlevels > 1: raise NotImplementedError( "The dataframe.where() method does not support multi-column." @@ -2890,6 +2887,9 @@ def where(self, cond, other=None): cond = self._apply_callable(cond) other = self._apply_callable(other) + if isinstance(other, bigframes.series.Series): + raise ValueError("Seires is not a supported replacement type!") + aligned_block, (_, _) = self._block.join(cond._block, how="left") # No left join is needed when 'other' is None or constant. if isinstance(other, bigframes.dataframe.DataFrame): diff --git a/tests/system/large/functions/test_managed_function.py b/tests/system/large/functions/test_managed_function.py index b0e44b648f..0a04480a78 100644 --- a/tests/system/large/functions/test_managed_function.py +++ b/tests/system/large/functions/test_managed_function.py @@ -1214,6 +1214,37 @@ def func_for_other(x): ) +def test_managed_function_df_where_other_issue(session, dataset_id, scalars_df_index): + try: + + def the_sum(s): + return s["int64_col"] + s["int64_too"] + + the_sum_mf = session.udf( + input_types=bigframes.series.Series, + output_type=int, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(the_sum) + + int64_cols = ["int64_col", "int64_too"] + + bf_int64_df = scalars_df_index[int64_cols] + bf_int64_df_filtered = bf_int64_df.dropna() + + with pytest.raises( + ValueError, + match="Seires is not a supported replacement type!", + ): + # The execution of the callable other=the_sum_mf will return a + # Series, which is not a supported replacement type. + bf_int64_df_filtered.where(cond=bf_int64_df_filtered, other=the_sum_mf) + + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets(the_sum_mf, session.bqclient, ignore_failures=False) + + def test_managed_function_series_where_mask(session, dataset_id, scalars_dfs): try: diff --git a/tests/system/large/functions/test_remote_function.py b/tests/system/large/functions/test_remote_function.py index e6372d768b..f60786437f 100644 --- a/tests/system/large/functions/test_remote_function.py +++ b/tests/system/large/functions/test_remote_function.py @@ -3004,6 +3004,38 @@ def is_sum_positive(a, b): ) +@pytest.mark.flaky(retries=2, delay=120) +def test_remote_function_df_where_other_issue(session, dataset_id, scalars_df_index): + try: + + def the_sum(a, b): + return a + b + + the_sum_mf = session.remote_function( + input_types=[int, float], + output_type=float, + dataset=dataset_id, + reuse=False, + cloud_function_service_account="default", + )(the_sum) + + int64_cols = ["int64_col", "float64_col"] + bf_int64_df = scalars_df_index[int64_cols] + bf_int64_df_filtered = bf_int64_df.dropna() + + with pytest.raises( + ValueError, + match="Seires is not a supported replacement type!", + ): + # The execution of the callable other=the_sum_mf will return a + # Series, which is not a supported replacement type. + bf_int64_df_filtered.where(cond=bf_int64_df > 100, other=the_sum_mf) + + finally: + # Clean up the gcp assets created for the remote function. + cleanup_function_assets(the_sum_mf, session.bqclient, ignore_failures=False) + + @pytest.mark.flaky(retries=2, delay=120) def test_remote_function_df_where_mask_series(session, dataset_id, scalars_dfs): try: diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index c7f9627531..dce0a649f6 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -570,6 +570,18 @@ def func(x): pandas.testing.assert_frame_equal(bf_result, pd_result) +def test_where_series_other(scalars_df_index): + # When other is a series, throw an error. + columns = ["int64_col", "float64_col"] + dataframe_bf = scalars_df_index[columns] + + with pytest.raises( + ValueError, + match="Seires is not a supported replacement type!", + ): + dataframe_bf.where(dataframe_bf > 0, dataframe_bf["int64_col"]) + + def test_drop_column(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs col_name = "int64_col" From a7963fe57a0e141debf726f0bc7b0e953ebe9634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Tue, 2 Sep 2025 18:32:07 +0000 Subject: [PATCH 034/313] feat!: add `allow_large_results` option to `read_gbq_query`, aligning with `bpd.options.compute.allow_large_results` option (#1935) Release-As: 2.18.0 --- bigframes/bigquery/_operations/search.py | 17 ++- bigframes/dataframe.py | 2 +- bigframes/ml/core.py | 92 ++++++++++-- bigframes/operations/ai.py | 4 + bigframes/pandas/io/api.py | 28 +++- bigframes/session/__init__.py | 125 +++++++++++++--- .../session/_io/bigquery/read_gbq_query.py | 57 +++++-- bigframes/session/loader.py | 21 ++- .../small/bigquery/test_vector_search.py | 141 ++++++++---------- tests/system/small/ml/test_forecasting.py | 42 ++++-- tests/system/small/ml/test_preprocessing.py | 2 +- .../small/session/test_read_gbq_query.py | 113 ++++++++++++++ tests/system/small/test_pandas_options.py | 20 +-- tests/system/small/test_session.py | 2 +- tests/system/small/test_unordered.py | 2 +- tests/unit/ml/test_golden_sql.py | 31 ++-- tests/unit/session/test_read_gbq_query.py | 2 +- .../bigframes_vendored/pandas/io/gbq.py | 6 + 18 files changed, 529 insertions(+), 178 deletions(-) create mode 100644 tests/system/small/session/test_read_gbq_query.py diff --git a/bigframes/bigquery/_operations/search.py b/bigframes/bigquery/_operations/search.py index 9a1e4b5ac9..5063fc9118 100644 --- a/bigframes/bigquery/_operations/search.py +++ b/bigframes/bigquery/_operations/search.py @@ -99,6 +99,7 @@ def vector_search( distance_type: Optional[Literal["euclidean", "cosine", "dot_product"]] = None, fraction_lists_to_search: Optional[float] = None, use_brute_force: Optional[bool] = None, + allow_large_results: Optional[bool] = None, ) -> dataframe.DataFrame: """ Conduct vector search which searches embeddings to find semantically similar entities. @@ -163,12 +164,12 @@ def vector_search( ... query=search_query, ... distance_type="cosine", ... query_column_to_search="another_embedding", - ... top_k=2) + ... top_k=2).sort_values("id") query_id embedding another_embedding id my_embedding distance - 1 cat [3. 5.2] [3.3 5.2] 2 [2. 4.] 0.005181 - 0 dog [1. 2.] [0.7 2.2] 4 [1. 3.2] 0.000013 1 cat [3. 5.2] [3.3 5.2] 1 [1. 2.] 0.005181 + 1 cat [3. 5.2] [3.3 5.2] 2 [2. 4.] 0.005181 0 dog [1. 2.] [0.7 2.2] 3 [1.5 7. ] 0.004697 + 0 dog [1. 2.] [0.7 2.2] 4 [1. 3.2] 0.000013 [4 rows x 6 columns] @@ -199,6 +200,10 @@ def vector_search( use_brute_force (bool): Determines whether to use brute force search by skipping the vector index if one is available. Default to False. + allow_large_results (bool, optional): + Whether to allow large query results. If ``True``, the query + results can be larger than the maximum response size. + Defaults to ``bpd.options.compute.allow_large_results``. Returns: bigframes.dataframe.DataFrame: A DataFrame containing vector search result. @@ -236,9 +241,11 @@ def vector_search( options=options, ) if index_col_ids is not None: - df = query._session.read_gbq(sql, index_col=index_col_ids) + df = query._session.read_gbq_query( + sql, index_col=index_col_ids, allow_large_results=allow_large_results + ) df.index.names = index_labels else: - df = query._session.read_gbq(sql) + df = query._session.read_gbq_query(sql, allow_large_results=allow_large_results) return df diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index a5ecd82d47..75be1c256e 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -4496,7 +4496,7 @@ def to_dict( allow_large_results: Optional[bool] = None, **kwargs, ) -> dict | list[dict]: - return self.to_pandas(allow_large_results=allow_large_results).to_dict(orient, into, **kwargs) # type: ignore + return self.to_pandas(allow_large_results=allow_large_results).to_dict(orient=orient, into=into, **kwargs) # type: ignore def to_excel( self, diff --git a/bigframes/ml/core.py b/bigframes/ml/core.py index 73b8ba8dbc..28f795a0b6 100644 --- a/bigframes/ml/core.py +++ b/bigframes/ml/core.py @@ -45,7 +45,11 @@ def ai_forecast( result_sql = self._sql_generator.ai_forecast( source_sql=input_data.sql, options=options ) - return self._session.read_gbq(result_sql) + + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query(result_sql, allow_large_results=True) class BqmlModel(BaseBqml): @@ -95,7 +99,17 @@ def _apply_ml_tvf( ) result_sql = apply_sql_tvf(input_sql) - df = self._session.read_gbq(result_sql, index_col=index_col_ids) + df = self._session.read_gbq_query( + result_sql, + index_col=index_col_ids, + # Many ML methods use nested JSON, which isn't yet compatible with + # joining local results. Also, there is a chance that the results + # are greater than 10 GB. + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + allow_large_results=True, + ) if df._has_index: df.index.names = index_labels # Restore column labels @@ -159,7 +173,10 @@ def explain_predict( def global_explain(self, options: Mapping[str, bool]) -> bpd.DataFrame: sql = self._sql_generator.ml_global_explain(struct_options=options) return ( - self._session.read_gbq(sql) + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + self._session.read_gbq_query(sql, allow_large_results=True) .sort_values(by="attribution", ascending=False) .set_index("feature") ) @@ -234,26 +251,49 @@ def forecast(self, options: Mapping[str, int | float]) -> bpd.DataFrame: sql = self._sql_generator.ml_forecast(struct_options=options) timestamp_col_name = "forecast_timestamp" index_cols = [timestamp_col_name] - first_col_name = self._session.read_gbq(sql).columns.values[0] + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + first_col_name = self._session.read_gbq_query( + sql, allow_large_results=True + ).columns.values[0] if timestamp_col_name != first_col_name: index_cols.append(first_col_name) - return self._session.read_gbq(sql, index_col=index_cols).reset_index() + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query( + sql, index_col=index_cols, allow_large_results=True + ).reset_index() def explain_forecast(self, options: Mapping[str, int | float]) -> bpd.DataFrame: sql = self._sql_generator.ml_explain_forecast(struct_options=options) timestamp_col_name = "time_series_timestamp" index_cols = [timestamp_col_name] - first_col_name = self._session.read_gbq(sql).columns.values[0] + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + first_col_name = self._session.read_gbq_query( + sql, allow_large_results=True + ).columns.values[0] if timestamp_col_name != first_col_name: index_cols.append(first_col_name) - return self._session.read_gbq(sql, index_col=index_cols).reset_index() + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query( + sql, index_col=index_cols, allow_large_results=True + ).reset_index() def evaluate(self, input_data: Optional[bpd.DataFrame] = None): sql = self._sql_generator.ml_evaluate( input_data.sql if (input_data is not None) else None ) - return self._session.read_gbq(sql) + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query(sql, allow_large_results=True) def llm_evaluate( self, @@ -262,25 +302,37 @@ def llm_evaluate( ): sql = self._sql_generator.ml_llm_evaluate(input_data.sql, task_type) - return self._session.read_gbq(sql) + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query(sql, allow_large_results=True) def arima_evaluate(self, show_all_candidate_models: bool = False): sql = self._sql_generator.ml_arima_evaluate(show_all_candidate_models) - return self._session.read_gbq(sql) + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query(sql, allow_large_results=True) def arima_coefficients(self) -> bpd.DataFrame: sql = self._sql_generator.ml_arima_coefficients() - return self._session.read_gbq(sql) + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query(sql, allow_large_results=True) def centroids(self) -> bpd.DataFrame: assert self._model.model_type == "KMEANS" sql = self._sql_generator.ml_centroids() - return self._session.read_gbq( - sql, index_col=["centroid_id", "feature"] + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query( + sql, index_col=["centroid_id", "feature"], allow_large_results=True ).reset_index() def principal_components(self) -> bpd.DataFrame: @@ -288,8 +340,13 @@ def principal_components(self) -> bpd.DataFrame: sql = self._sql_generator.ml_principal_components() - return self._session.read_gbq( - sql, index_col=["principal_component_id", "feature"] + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query( + sql, + index_col=["principal_component_id", "feature"], + allow_large_results=True, ).reset_index() def principal_component_info(self) -> bpd.DataFrame: @@ -297,7 +354,10 @@ def principal_component_info(self) -> bpd.DataFrame: sql = self._sql_generator.ml_principal_component_info() - return self._session.read_gbq(sql) + # TODO(b/395912450): Once the limitations with local data are + # resolved, consider setting allow_large_results only when expected + # data size is large. + return self._session.read_gbq_query(sql, allow_large_results=True) def copy(self, new_model_name: str, replace: bool = False) -> BqmlModel: job_config = self._session._prepare_copy_job_config() diff --git a/bigframes/operations/ai.py b/bigframes/operations/ai.py index 8c7628059a..ac294b0fbd 100644 --- a/bigframes/operations/ai.py +++ b/bigframes/operations/ai.py @@ -566,6 +566,10 @@ def search( column_to_search=embedding_result_column, query=query_df, top_k=top_k, + # TODO(tswast): set allow_large_results based on Series size. + # If we expect small results, it could be faster to set + # allow_large_results to False. + allow_large_results=True, ) .rename(columns={"content": search_column}) .set_index("index") diff --git a/bigframes/pandas/io/api.py b/bigframes/pandas/io/api.py index cf4b4eb19c..483bc5e530 100644 --- a/bigframes/pandas/io/api.py +++ b/bigframes/pandas/io/api.py @@ -187,6 +187,7 @@ def read_gbq( # type: ignore[overload-overlap] use_cache: Optional[bool] = ..., col_order: Iterable[str] = ..., dry_run: Literal[False] = ..., + allow_large_results: Optional[bool] = ..., ) -> bigframes.dataframe.DataFrame: ... @@ -203,6 +204,7 @@ def read_gbq( use_cache: Optional[bool] = ..., col_order: Iterable[str] = ..., dry_run: Literal[True] = ..., + allow_large_results: Optional[bool] = ..., ) -> pandas.Series: ... @@ -218,6 +220,7 @@ def read_gbq( use_cache: Optional[bool] = None, col_order: Iterable[str] = (), dry_run: bool = False, + allow_large_results: Optional[bool] = None, ) -> bigframes.dataframe.DataFrame | pandas.Series: _set_default_session_location_if_possible(query_or_table) return global_session.with_default_session( @@ -231,6 +234,7 @@ def read_gbq( use_cache=use_cache, col_order=col_order, dry_run=dry_run, + allow_large_results=allow_large_results, ) @@ -400,6 +404,7 @@ def read_gbq_query( # type: ignore[overload-overlap] col_order: Iterable[str] = ..., filters: vendored_pandas_gbq.FiltersType = ..., dry_run: Literal[False] = ..., + allow_large_results: Optional[bool] = ..., ) -> bigframes.dataframe.DataFrame: ... @@ -416,6 +421,7 @@ def read_gbq_query( col_order: Iterable[str] = ..., filters: vendored_pandas_gbq.FiltersType = ..., dry_run: Literal[True] = ..., + allow_large_results: Optional[bool] = ..., ) -> pandas.Series: ... @@ -431,6 +437,7 @@ def read_gbq_query( col_order: Iterable[str] = (), filters: vendored_pandas_gbq.FiltersType = (), dry_run: bool = False, + allow_large_results: Optional[bool] = None, ) -> bigframes.dataframe.DataFrame | pandas.Series: _set_default_session_location_if_possible(query) return global_session.with_default_session( @@ -444,6 +451,7 @@ def read_gbq_query( col_order=col_order, filters=filters, dry_run=dry_run, + allow_large_results=allow_large_results, ) @@ -617,7 +625,11 @@ def from_glob_path( def _get_bqclient() -> bigquery.Client: - clients_provider = bigframes.session.clients.ClientsProvider( + # Address circular imports in doctest due to bigframes/session/__init__.py + # containing a lot of logic and samples. + from bigframes.session import clients + + clients_provider = clients.ClientsProvider( project=config.options.bigquery.project, location=config.options.bigquery.location, use_regional_endpoints=config.options.bigquery.use_regional_endpoints, @@ -631,11 +643,15 @@ def _get_bqclient() -> bigquery.Client: def _dry_run(query, bqclient) -> bigquery.QueryJob: + # Address circular imports in doctest due to bigframes/session/__init__.py + # containing a lot of logic and samples. + from bigframes.session import metrics as bf_metrics + job = bqclient.query(query, bigquery.QueryJobConfig(dry_run=True)) # Fix for b/435183833. Log metrics even if a Session isn't available. - if bigframes.session.metrics.LOGGING_NAME_ENV_VAR in os.environ: - metrics = bigframes.session.metrics.ExecutionMetrics() + if bf_metrics.LOGGING_NAME_ENV_VAR in os.environ: + metrics = bf_metrics.ExecutionMetrics() metrics.count_job_stats(job) return job @@ -645,6 +661,10 @@ def _set_default_session_location_if_possible(query): def _set_default_session_location_if_possible_deferred_query(create_query): + # Address circular imports in doctest due to bigframes/session/__init__.py + # containing a lot of logic and samples. + from bigframes.session._io import bigquery + # Set the location as per the query if this is the first query the user is # running and: # (1) Default session has not started yet, and @@ -666,7 +686,7 @@ def _set_default_session_location_if_possible_deferred_query(create_query): query = create_query() bqclient = _get_bqclient() - if bigframes.session._io.bigquery.is_query(query): + if bigquery.is_query(query): # Intentionally run outside of the session so that we can detect the # location before creating the session. Since it's a dry_run, labels # aren't necessary. diff --git a/bigframes/session/__init__.py b/bigframes/session/__init__.py index 66b0196286..432e73159a 100644 --- a/bigframes/session/__init__.py +++ b/bigframes/session/__init__.py @@ -62,6 +62,7 @@ from bigframes import exceptions as bfe from bigframes import version +import bigframes._config import bigframes._config.bigquery_options as bigquery_options import bigframes.clients import bigframes.constants @@ -134,6 +135,10 @@ def __init__( context: Optional[bigquery_options.BigQueryOptions] = None, clients_provider: Optional[bigframes.session.clients.ClientsProvider] = None, ): + # Address circular imports in doctest due to bigframes/session/__init__.py + # containing a lot of logic and samples. + from bigframes.session import anonymous_dataset, clients, loader, metrics + _warn_if_bf_version_is_obsolete() if context is None: @@ -169,7 +174,7 @@ def __init__( if clients_provider: self._clients_provider = clients_provider else: - self._clients_provider = bigframes.session.clients.ClientsProvider( + self._clients_provider = clients.ClientsProvider( project=context.project, location=self._location, use_regional_endpoints=context.use_regional_endpoints, @@ -221,15 +226,13 @@ def __init__( else bigframes.enums.DefaultIndexKind.NULL ) - self._metrics = bigframes.session.metrics.ExecutionMetrics() + self._metrics = metrics.ExecutionMetrics() self._function_session = bff_session.FunctionSession() - self._anon_dataset_manager = ( - bigframes.session.anonymous_dataset.AnonymousDatasetManager( - self._clients_provider.bqclient, - location=self._location, - session_id=self._session_id, - kms_key=self._bq_kms_key_name, - ) + self._anon_dataset_manager = anonymous_dataset.AnonymousDatasetManager( + self._clients_provider.bqclient, + location=self._location, + session_id=self._session_id, + kms_key=self._bq_kms_key_name, ) # Session temp tables don't support specifying kms key, so use anon dataset if kms key specified self._session_resource_manager = ( @@ -243,7 +246,7 @@ def __init__( self._temp_storage_manager = ( self._session_resource_manager or self._anon_dataset_manager ) - self._loader = bigframes.session.loader.GbqDataLoader( + self._loader = loader.GbqDataLoader( session=self, bqclient=self._clients_provider.bqclient, storage_manager=self._temp_storage_manager, @@ -397,6 +400,7 @@ def read_gbq( # type: ignore[overload-overlap] use_cache: Optional[bool] = ..., col_order: Iterable[str] = ..., dry_run: Literal[False] = ..., + allow_large_results: Optional[bool] = ..., ) -> dataframe.DataFrame: ... @@ -413,6 +417,7 @@ def read_gbq( use_cache: Optional[bool] = ..., col_order: Iterable[str] = ..., dry_run: Literal[True] = ..., + allow_large_results: Optional[bool] = ..., ) -> pandas.Series: ... @@ -427,8 +432,8 @@ def read_gbq( filters: third_party_pandas_gbq.FiltersType = (), use_cache: Optional[bool] = None, col_order: Iterable[str] = (), - dry_run: bool = False - # Add a verify index argument that fails if the index is not unique. + dry_run: bool = False, + allow_large_results: Optional[bool] = None, ) -> dataframe.DataFrame | pandas.Series: # TODO(b/281571214): Generate prompt to show the progress of read_gbq. if columns and col_order: @@ -438,6 +443,9 @@ def read_gbq( elif col_order: columns = col_order + if allow_large_results is None: + allow_large_results = bigframes._config.options._allow_large_results + if bf_io_bigquery.is_query(query_or_table): return self._loader.read_gbq_query( # type: ignore # for dry_run overload query_or_table, @@ -448,6 +456,7 @@ def read_gbq( use_cache=use_cache, filters=filters, dry_run=dry_run, + allow_large_results=allow_large_results, ) else: if configuration is not None: @@ -523,6 +532,8 @@ def _read_gbq_colab( if pyformat_args is None: pyformat_args = {} + allow_large_results = bigframes._config.options._allow_large_results + query = bigframes.core.pyformat.pyformat( query, pyformat_args=pyformat_args, @@ -535,10 +546,7 @@ def _read_gbq_colab( index_col=bigframes.enums.DefaultIndexKind.NULL, force_total_order=False, dry_run=typing.cast(Union[Literal[False], Literal[True]], dry_run), - # TODO(tswast): we may need to allow allow_large_results to be overwritten - # or possibly a general configuration object for an explicit - # destination table and write disposition. - allow_large_results=False, + allow_large_results=allow_large_results, ) @overload @@ -554,6 +562,7 @@ def read_gbq_query( # type: ignore[overload-overlap] col_order: Iterable[str] = ..., filters: third_party_pandas_gbq.FiltersType = ..., dry_run: Literal[False] = ..., + allow_large_results: Optional[bool] = ..., ) -> dataframe.DataFrame: ... @@ -570,6 +579,7 @@ def read_gbq_query( col_order: Iterable[str] = ..., filters: third_party_pandas_gbq.FiltersType = ..., dry_run: Literal[True] = ..., + allow_large_results: Optional[bool] = ..., ) -> pandas.Series: ... @@ -585,6 +595,7 @@ def read_gbq_query( col_order: Iterable[str] = (), filters: third_party_pandas_gbq.FiltersType = (), dry_run: bool = False, + allow_large_results: Optional[bool] = None, ) -> dataframe.DataFrame | pandas.Series: """Turn a SQL query into a DataFrame. @@ -634,9 +645,48 @@ def read_gbq_query( See also: :meth:`Session.read_gbq`. + Args: + query (str): + A SQL query to execute. + index_col (Iterable[str] or str, optional): + The column(s) to use as the index for the DataFrame. This can be + a single column name or a list of column names. If not provided, + a default index will be used. + columns (Iterable[str], optional): + The columns to read from the query result. If not + specified, all columns will be read. + configuration (dict, optional): + A dictionary of query job configuration options. See the + BigQuery REST API documentation for a list of available options: + https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs#configuration.query + max_results (int, optional): + The maximum number of rows to retrieve from the query + result. If not specified, all rows will be loaded. + use_cache (bool, optional): + Whether to use cached results for the query. Defaults to ``True``. + Setting this to ``False`` will force a re-execution of the query. + col_order (Iterable[str], optional): + The desired order of columns in the resulting DataFrame. This + parameter is deprecated and will be removed in a future version. + Use ``columns`` instead. + filters (list[tuple], optional): + A list of filters to apply to the data. Filters are specified + as a list of tuples, where each tuple contains a column name, + an operator (e.g., '==', '!='), and a value. + dry_run (bool, optional): + If ``True``, the function will not actually execute the query but + will instead return statistics about the query. Defaults to + ``False``. + allow_large_results (bool, optional): + Whether to allow large query results. If ``True``, the query + results can be larger than the maximum response size. + Defaults to ``bpd.options.compute.allow_large_results``. + Returns: - bigframes.pandas.DataFrame: - A DataFrame representing results of the query or table. + bigframes.pandas.DataFrame or pandas.Series: + A DataFrame representing the result of the query. If ``dry_run`` + is ``True``, a ``pandas.Series`` containing query statistics is + returned. Raises: ValueError: @@ -651,6 +701,9 @@ def read_gbq_query( elif col_order: columns = col_order + if allow_large_results is None: + allow_large_results = bigframes._config.options._allow_large_results + return self._loader.read_gbq_query( # type: ignore # for dry_run overload query=query, index_col=index_col, @@ -660,6 +713,7 @@ def read_gbq_query( use_cache=use_cache, filters=filters, dry_run=dry_run, + allow_large_results=allow_large_results, ) @overload @@ -717,9 +771,40 @@ def read_gbq_table( See also: :meth:`Session.read_gbq`. + Args: + table_id (str): + The identifier of the BigQuery table to read. + index_col (Iterable[str] or str, optional): + The column(s) to use as the index for the DataFrame. This can be + a single column name or a list of column names. If not provided, + a default index will be used. + columns (Iterable[str], optional): + The columns to read from the table. If not specified, all + columns will be read. + max_results (int, optional): + The maximum number of rows to retrieve from the table. If not + specified, all rows will be loaded. + filters (list[tuple], optional): + A list of filters to apply to the data. Filters are specified + as a list of tuples, where each tuple contains a column name, + an operator (e.g., '==', '!='), and a value. + use_cache (bool, optional): + Whether to use cached results for the query. Defaults to ``True``. + Setting this to ``False`` will force a re-execution of the query. + col_order (Iterable[str], optional): + The desired order of columns in the resulting DataFrame. This + parameter is deprecated and will be removed in a future version. + Use ``columns`` instead. + dry_run (bool, optional): + If ``True``, the function will not actually execute the query but + will instead return statistics about the table. Defaults to + ``False``. + Returns: - bigframes.pandas.DataFrame: - A DataFrame representing results of the query or table. + bigframes.pandas.DataFrame or pandas.Series: + A DataFrame representing the contents of the table. If + ``dry_run`` is ``True``, a ``pandas.Series`` containing table + statistics is returned. Raises: ValueError: diff --git a/bigframes/session/_io/bigquery/read_gbq_query.py b/bigframes/session/_io/bigquery/read_gbq_query.py index 70c83d7875..aed77615ce 100644 --- a/bigframes/session/_io/bigquery/read_gbq_query.py +++ b/bigframes/session/_io/bigquery/read_gbq_query.py @@ -16,7 +16,7 @@ from __future__ import annotations -from typing import Optional +from typing import cast, Iterable, Optional, Tuple from google.cloud import bigquery import google.cloud.bigquery.table @@ -28,6 +28,7 @@ import bigframes.core.blocks as blocks import bigframes.core.guid import bigframes.core.schema as schemata +import bigframes.enums import bigframes.session @@ -53,7 +54,11 @@ def create_dataframe_from_query_job_stats( def create_dataframe_from_row_iterator( - rows: google.cloud.bigquery.table.RowIterator, *, session: bigframes.session.Session + rows: google.cloud.bigquery.table.RowIterator, + *, + session: bigframes.session.Session, + index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind, + columns: Iterable[str], ) -> dataframe.DataFrame: """Convert a RowIterator into a DataFrame wrapping a LocalNode. @@ -61,11 +66,27 @@ def create_dataframe_from_row_iterator( 'jobless' case where there's no destination table. """ pa_table = rows.to_arrow() + bq_schema = list(rows.schema) + is_default_index = not index_col or isinstance( + index_col, bigframes.enums.DefaultIndexKind + ) - # TODO(tswast): Use array_value.promote_offsets() instead once that node is - # supported by the local engine. - offsets_col = bigframes.core.guid.generate_guid() - pa_table = pyarrow_utils.append_offsets(pa_table, offsets_col=offsets_col) + if is_default_index: + # We get a sequential index for free, so use that if no index is specified. + # TODO(tswast): Use array_value.promote_offsets() instead once that node is + # supported by the local engine. + offsets_col = bigframes.core.guid.generate_guid() + pa_table = pyarrow_utils.append_offsets(pa_table, offsets_col=offsets_col) + bq_schema += [bigquery.SchemaField(offsets_col, "INTEGER")] + index_columns: Tuple[str, ...] = (offsets_col,) + index_labels: Tuple[Optional[str], ...] = (None,) + elif isinstance(index_col, str): + index_columns = (index_col,) + index_labels = (index_col,) + else: + index_col = cast(Iterable[str], index_col) + index_columns = tuple(index_col) + index_labels = cast(Tuple[Optional[str], ...], tuple(index_col)) # We use the ManagedArrowTable constructor directly, because the # results of to_arrow() should be the source of truth with regards @@ -74,17 +95,27 @@ def create_dataframe_from_row_iterator( # like the output of the BQ Storage Read API. mat = local_data.ManagedArrowTable( pa_table, - schemata.ArraySchema.from_bq_schema( - list(rows.schema) + [bigquery.SchemaField(offsets_col, "INTEGER")] - ), + schemata.ArraySchema.from_bq_schema(bq_schema), ) mat.validate() + column_labels = [ + field.name for field in rows.schema if field.name not in index_columns + ] + array_value = core.ArrayValue.from_managed(mat, session) block = blocks.Block( array_value, - (offsets_col,), - [field.name for field in rows.schema], - (None,), + index_columns=index_columns, + column_labels=column_labels, + index_labels=index_labels, ) - return dataframe.DataFrame(block) + df = dataframe.DataFrame(block) + + if columns: + df = df[list(columns)] + + if not is_default_index: + df = df.sort_index() + + return df diff --git a/bigframes/session/loader.py b/bigframes/session/loader.py index 6500701324..49b1195235 100644 --- a/bigframes/session/loader.py +++ b/bigframes/session/loader.py @@ -721,6 +721,9 @@ def read_gbq_table( columns=columns, use_cache=use_cache, dry_run=dry_run, + # If max_results has been set, we almost certainly have < 10 GB + # of results. + allow_large_results=False, ) return df @@ -895,7 +898,7 @@ def read_gbq_query( # type: ignore[overload-overlap] filters: third_party_pandas_gbq.FiltersType = ..., dry_run: Literal[False] = ..., force_total_order: Optional[bool] = ..., - allow_large_results: bool = ..., + allow_large_results: bool, ) -> dataframe.DataFrame: ... @@ -912,7 +915,7 @@ def read_gbq_query( filters: third_party_pandas_gbq.FiltersType = ..., dry_run: Literal[True] = ..., force_total_order: Optional[bool] = ..., - allow_large_results: bool = ..., + allow_large_results: bool, ) -> pandas.Series: ... @@ -928,7 +931,7 @@ def read_gbq_query( filters: third_party_pandas_gbq.FiltersType = (), dry_run: bool = False, force_total_order: Optional[bool] = None, - allow_large_results: bool = True, + allow_large_results: bool, ) -> dataframe.DataFrame | pandas.Series: configuration = _transform_read_gbq_configuration(configuration) @@ -953,6 +956,7 @@ def read_gbq_query( True if use_cache is None else use_cache ) + _check_duplicates("columns", columns) index_cols = _to_index_cols(index_col) _check_index_col_param(index_cols, columns) @@ -1040,10 +1044,19 @@ def read_gbq_query( # local node. Likely there are a wide range of sizes in which it # makes sense to download the results beyond the first page, even if # there is a job and destination table available. - if rows is not None and destination is None: + if ( + rows is not None + and destination is None + and ( + query_job_for_metrics is None + or query_job_for_metrics.statement_type == "SELECT" + ) + ): return bf_read_gbq_query.create_dataframe_from_row_iterator( rows, session=self._session, + index_col=index_col, + columns=columns, ) # If there was no destination table and we've made it this far, that diff --git a/tests/system/small/bigquery/test_vector_search.py b/tests/system/small/bigquery/test_vector_search.py index a282135fa6..3107795730 100644 --- a/tests/system/small/bigquery/test_vector_search.py +++ b/tests/system/small/bigquery/test_vector_search.py @@ -123,12 +123,17 @@ def test_vector_search_basic_params_with_df(): "embedding": [[1.0, 2.0], [3.0, 5.2]], } ) - vector_search_result = bbq.vector_search( - base_table="bigframes-dev.bigframes_tests_sys.base_table", - column_to_search="my_embedding", - query=search_query, - top_k=2, - ).to_pandas() # type:ignore + vector_search_result = ( + bbq.vector_search( + base_table="bigframes-dev.bigframes_tests_sys.base_table", + column_to_search="my_embedding", + query=search_query, + top_k=2, + ) + .sort_values("distance") + .sort_index() + .to_pandas() + ) # type:ignore expected = pd.DataFrame( { "query_id": ["cat", "dog", "dog", "cat"], @@ -157,80 +162,60 @@ def test_vector_search_basic_params_with_df(): ) -def test_vector_search_different_params_with_query(): - search_query = bpd.Series([[1.0, 2.0], [3.0, 5.2]]) - vector_search_result = bbq.vector_search( - base_table="bigframes-dev.bigframes_tests_sys.base_table", - column_to_search="my_embedding", - query=search_query, - distance_type="cosine", - top_k=2, - ).to_pandas() # type:ignore - expected = pd.DataFrame( +def test_vector_search_different_params_with_query(session): + base_df = bpd.DataFrame( { - "0": [ - np.array([1.0, 2.0]), - np.array([1.0, 2.0]), - np.array([3.0, 5.2]), - np.array([3.0, 5.2]), - ], - "id": [2, 1, 1, 2], + "id": [1, 2, 3, 4], "my_embedding": [ - np.array([2.0, 4.0]), - np.array([1.0, 2.0]), - np.array([1.0, 2.0]), - np.array([2.0, 4.0]), + np.array([0.0, 1.0]), + np.array([1.0, 0.0]), + np.array([0.0, -1.0]), + np.array([-1.0, 0.0]), ], - "distance": [0.0, 0.0, 0.001777, 0.001777], }, - index=pd.Index([0, 0, 1, 1], dtype="Int64"), - ) - pd.testing.assert_frame_equal( - vector_search_result, expected, check_dtype=False, rtol=0.1 - ) - - -def test_vector_search_df_with_query_column_to_search(): - search_query = bpd.DataFrame( - { - "query_id": ["dog", "cat"], - "embedding": [[1.0, 2.0], [3.0, 5.2]], - "another_embedding": [[1.0, 2.5], [3.3, 5.2]], - } - ) - vector_search_result = bbq.vector_search( - base_table="bigframes-dev.bigframes_tests_sys.base_table", - column_to_search="my_embedding", - query=search_query, - query_column_to_search="another_embedding", - top_k=2, - ).to_pandas() # type:ignore - expected = pd.DataFrame( - { - "query_id": ["dog", "dog", "cat", "cat"], - "embedding": [ - np.array([1.0, 2.0]), - np.array([1.0, 2.0]), - np.array([3.0, 5.2]), - np.array([3.0, 5.2]), - ], - "another_embedding": [ - np.array([1.0, 2.5]), - np.array([1.0, 2.5]), - np.array([3.3, 5.2]), - np.array([3.3, 5.2]), - ], - "id": [1, 4, 2, 5], - "my_embedding": [ - np.array([1.0, 2.0]), - np.array([1.0, 3.2]), - np.array([2.0, 4.0]), - np.array([5.0, 5.4]), - ], - "distance": [0.5, 0.7, 1.769181, 1.711724], - }, - index=pd.Index([0, 0, 1, 1], dtype="Int64"), - ) - pd.testing.assert_frame_equal( - vector_search_result, expected, check_dtype=False, rtol=0.1 + session=session, ) + base_table = base_df.to_gbq() + try: + search_query = bpd.Series([[0.75, 0.25], [-0.25, -0.75]], session=session) + vector_search_result = ( + bbq.vector_search( + base_table=base_table, + column_to_search="my_embedding", + query=search_query, + distance_type="cosine", + top_k=2, + ) + .sort_values("distance") + .sort_index() + .to_pandas() + ) # type:ignore + expected = pd.DataFrame( + { + "0": [ + [0.75, 0.25], + [0.75, 0.25], + [-0.25, -0.75], + [-0.25, -0.75], + ], + "id": [2, 1, 3, 4], + "my_embedding": [ + [1.0, 0.0], + [0.0, 1.0], + [0.0, -1.0], + [-1.0, 0.0], + ], + "distance": [ + 0.051317, + 0.683772, + 0.051317, + 0.683772, + ], + }, + index=pd.Index([0, 0, 1, 1], dtype="Int64"), + ) + pd.testing.assert_frame_equal( + vector_search_result, expected, check_dtype=False, rtol=0.1 + ) + finally: + session.bqclient.delete_table(base_table, not_found_ok=True) diff --git a/tests/system/small/ml/test_forecasting.py b/tests/system/small/ml/test_forecasting.py index d1b6b18fbe..134f82e96e 100644 --- a/tests/system/small/ml/test_forecasting.py +++ b/tests/system/small/ml/test_forecasting.py @@ -432,8 +432,10 @@ def test_arima_plus_detect_anomalies_params( }, ) pd.testing.assert_frame_equal( - anomalies[["is_anomaly", "lower_bound", "upper_bound", "anomaly_probability"]], - expected, + anomalies[["is_anomaly", "lower_bound", "upper_bound", "anomaly_probability"]] + .sort_values("anomaly_probability") + .reset_index(drop=True), + expected.sort_values("anomaly_probability").reset_index(drop=True), rtol=0.1, check_index_type=False, check_dtype=False, @@ -449,11 +451,16 @@ def test_arima_plus_score( id_col_name, ): if id_col_name: - result = time_series_arima_plus_model_w_id.score( - new_time_series_df_w_id[["parsed_date"]], - new_time_series_df_w_id[["total_visits"]], - new_time_series_df_w_id[["id"]], - ).to_pandas() + result = ( + time_series_arima_plus_model_w_id.score( + new_time_series_df_w_id[["parsed_date"]], + new_time_series_df_w_id[["total_visits"]], + new_time_series_df_w_id[["id"]], + ) + .to_pandas() + .sort_values("id") + .reset_index(drop=True) + ) else: result = time_series_arima_plus_model.score( new_time_series_df[["parsed_date"]], new_time_series_df[["total_visits"]] @@ -472,6 +479,8 @@ def test_arima_plus_score( ) expected["id"] = expected["id"].astype(str).str.replace(r"\.0$", "", regex=True) expected["id"] = expected["id"].astype("string[pyarrow]") + expected = expected.sort_values("id") + expected = expected.reset_index(drop=True) else: expected = pd.DataFrame( { @@ -488,6 +497,7 @@ def test_arima_plus_score( expected, rtol=0.1, check_index_type=False, + check_dtype=False, ) @@ -542,11 +552,16 @@ def test_arima_plus_score_series( id_col_name, ): if id_col_name: - result = time_series_arima_plus_model_w_id.score( - new_time_series_df_w_id["parsed_date"], - new_time_series_df_w_id["total_visits"], - new_time_series_df_w_id["id"], - ).to_pandas() + result = ( + time_series_arima_plus_model_w_id.score( + new_time_series_df_w_id["parsed_date"], + new_time_series_df_w_id["total_visits"], + new_time_series_df_w_id["id"], + ) + .to_pandas() + .sort_values("id") + .reset_index(drop=True) + ) else: result = time_series_arima_plus_model.score( new_time_series_df["parsed_date"], new_time_series_df["total_visits"] @@ -565,6 +580,8 @@ def test_arima_plus_score_series( ) expected["id"] = expected["id"].astype(str).str.replace(r"\.0$", "", regex=True) expected["id"] = expected["id"].astype("string[pyarrow]") + expected = expected.sort_values("id") + expected = expected.reset_index(drop=True) else: expected = pd.DataFrame( { @@ -581,6 +598,7 @@ def test_arima_plus_score_series( expected, rtol=0.1, check_index_type=False, + check_dtype=False, ) diff --git a/tests/system/small/ml/test_preprocessing.py b/tests/system/small/ml/test_preprocessing.py index 34be48be1e..65a851efc3 100644 --- a/tests/system/small/ml/test_preprocessing.py +++ b/tests/system/small/ml/test_preprocessing.py @@ -245,7 +245,7 @@ def test_max_abs_scaler_save_load(new_penguins_df, dataset_id): index=pd.Index([1633, 1672, 1690], name="tag_number", dtype="Int64"), ) - pd.testing.assert_frame_equal(result, expected, rtol=0.1) + pd.testing.assert_frame_equal(result.sort_index(), expected.sort_index(), rtol=0.1) def test_min_max_scaler_normalized_fit_transform(new_penguins_df): diff --git a/tests/system/small/session/test_read_gbq_query.py b/tests/system/small/session/test_read_gbq_query.py new file mode 100644 index 0000000000..c1408febca --- /dev/null +++ b/tests/system/small/session/test_read_gbq_query.py @@ -0,0 +1,113 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + +import pytest + +import bigframes +import bigframes.core.nodes as nodes + + +def test_read_gbq_query_w_allow_large_results(session: bigframes.Session): + if not hasattr(session.bqclient, "default_job_creation_mode"): + pytest.skip("Jobless query only available on newer google-cloud-bigquery.") + + query = "SELECT 1" + + # Make sure we don't get a cached table. + configuration = {"query": {"useQueryCache": False}} + + # Very small results should wrap a local node. + df_false = session.read_gbq( + query, + configuration=configuration, + allow_large_results=False, + ) + assert df_false.shape == (1, 1) + roots_false = df_false._get_block().expr.node.roots + assert any(isinstance(node, nodes.ReadLocalNode) for node in roots_false) + assert not any(isinstance(node, nodes.ReadTableNode) for node in roots_false) + + # Large results allowed should wrap a table. + df_true = session.read_gbq( + query, + configuration=configuration, + allow_large_results=True, + ) + assert df_true.shape == (1, 1) + roots_true = df_true._get_block().expr.node.roots + assert any(isinstance(node, nodes.ReadTableNode) for node in roots_true) + + +def test_read_gbq_query_w_columns(session: bigframes.Session): + query = """ + SELECT 1 as int_col, + 'a' as str_col, + TIMESTAMP('2025-08-21 10:41:32.123456') as timestamp_col + """ + + result = session.read_gbq( + query, + columns=["timestamp_col", "int_col"], + ) + assert list(result.columns) == ["timestamp_col", "int_col"] + assert result.to_dict(orient="records") == [ + { + "timestamp_col": datetime.datetime( + 2025, 8, 21, 10, 41, 32, 123456, tzinfo=datetime.timezone.utc + ), + "int_col": 1, + } + ] + + +@pytest.mark.parametrize( + ("index_col", "expected_index_names"), + ( + pytest.param( + "my_custom_index", + ("my_custom_index",), + id="string", + ), + pytest.param( + ("my_custom_index",), + ("my_custom_index",), + id="iterable", + ), + pytest.param( + ("my_custom_index", "int_col"), + ("my_custom_index", "int_col"), + id="multiindex", + ), + ), +) +def test_read_gbq_query_w_index_col( + session: bigframes.Session, index_col, expected_index_names +): + query = """ + SELECT 1 as int_col, + 'a' as str_col, + 0 as my_custom_index, + TIMESTAMP('2025-08-21 10:41:32.123456') as timestamp_col + """ + + result = session.read_gbq( + query, + index_col=index_col, + ) + assert tuple(result.index.names) == expected_index_names + assert frozenset(result.columns) == frozenset( + {"int_col", "str_col", "my_custom_index", "timestamp_col"} + ) - frozenset(expected_index_names) diff --git a/tests/system/small/test_pandas_options.py b/tests/system/small/test_pandas_options.py index 1d360e0d4f..7a750ddfd3 100644 --- a/tests/system/small/test_pandas_options.py +++ b/tests/system/small/test_pandas_options.py @@ -280,6 +280,17 @@ def test_credentials_need_reauthentication( session = bpd.get_global_session() assert session.bqclient._http.credentials.valid + # We look at the thread-local session because of the + # reset_default_session_and_location fixture and that this test mutates + # state that might otherwise be used by tests running in parallel. + current_session = ( + bigframes.core.global_session._global_session_state.thread_local_session + ) + assert current_session is not None + + # Force a temp table to be created, so there is something to cleanup. + current_session._anon_dataset_manager.create_temp_table(schema=()) + with monkeypatch.context() as m: # Simulate expired credentials to trigger the credential refresh flow m.setattr( @@ -303,15 +314,6 @@ def test_credentials_need_reauthentication( with pytest.raises(google.auth.exceptions.RefreshError): bpd.read_gbq(test_query) - # Now verify that closing the session works We look at the - # thread-local session because of the - # reset_default_session_and_location fixture and that this test mutates - # state that might otherwise be used by tests running in parallel. - assert ( - bigframes.core.global_session._global_session_state.thread_local_session - is not None - ) - with warnings.catch_warnings(record=True) as warned: bpd.close_session() # CleanupFailedWarning: can't clean up diff --git a/tests/system/small/test_session.py b/tests/system/small/test_session.py index f0a6302c7b..6343f0cc53 100644 --- a/tests/system/small/test_session.py +++ b/tests/system/small/test_session.py @@ -619,7 +619,7 @@ def test_read_gbq_wildcard( pytest.param( {"query": {"useQueryCache": False, "maximumBytesBilled": "100"}}, marks=pytest.mark.xfail( - raises=google.api_core.exceptions.InternalServerError, + raises=google.api_core.exceptions.BadRequest, reason="Expected failure when the query exceeds the maximum bytes billed limit.", ), ), diff --git a/tests/system/small/test_unordered.py b/tests/system/small/test_unordered.py index ccb2140799..867067a161 100644 --- a/tests/system/small/test_unordered.py +++ b/tests/system/small/test_unordered.py @@ -103,7 +103,7 @@ def test_unordered_mode_read_gbq(unordered_session): } ) # Don't need ignore_order as there is only 1 row - assert_pandas_df_equal(df.to_pandas(), expected) + assert_pandas_df_equal(df.to_pandas(), expected, check_index_type=False) @pytest.mark.parametrize( diff --git a/tests/unit/ml/test_golden_sql.py b/tests/unit/ml/test_golden_sql.py index 10fefcc457..7f6843aacf 100644 --- a/tests/unit/ml/test_golden_sql.py +++ b/tests/unit/ml/test_golden_sql.py @@ -143,9 +143,10 @@ def test_linear_regression_predict(mock_session, bqml_model, mock_X): model._bqml_model = bqml_model model.predict(mock_X) - mock_session.read_gbq.assert_called_once_with( + mock_session.read_gbq_query.assert_called_once_with( "SELECT * FROM ML.PREDICT(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_sql))", index_col=["index_column_id"], + allow_large_results=True, ) @@ -154,8 +155,9 @@ def test_linear_regression_score(mock_session, bqml_model, mock_X, mock_y): model._bqml_model = bqml_model model.score(mock_X, mock_y) - mock_session.read_gbq.assert_called_once_with( - "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_y_sql))" + mock_session.read_gbq_query.assert_called_once_with( + "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_y_sql))", + allow_large_results=True, ) @@ -167,7 +169,7 @@ def test_logistic_regression_default_fit( model.fit(mock_X, mock_y) mock_session._start_query_ml_ddl.assert_called_once_with( - "CREATE OR REPLACE MODEL `test-project`.`_anon123`.`temp_model_id`\nOPTIONS(\n model_type='LOGISTIC_REG',\n data_split_method='NO_SPLIT',\n fit_intercept=True,\n auto_class_weights=False,\n optimize_strategy='auto_strategy',\n l2_reg=0.0,\n max_iterations=20,\n learn_rate_strategy='line_search',\n min_rel_progress=0.01,\n calculate_p_values=False,\n enable_global_explain=False,\n INPUT_LABEL_COLS=['input_column_label'])\nAS input_X_y_no_index_sql" + "CREATE OR REPLACE MODEL `test-project`.`_anon123`.`temp_model_id`\nOPTIONS(\n model_type='LOGISTIC_REG',\n data_split_method='NO_SPLIT',\n fit_intercept=True,\n auto_class_weights=False,\n optimize_strategy='auto_strategy',\n l2_reg=0.0,\n max_iterations=20,\n learn_rate_strategy='line_search',\n min_rel_progress=0.01,\n calculate_p_values=False,\n enable_global_explain=False,\n INPUT_LABEL_COLS=['input_column_label'])\nAS input_X_y_no_index_sql", ) @@ -198,9 +200,10 @@ def test_logistic_regression_predict(mock_session, bqml_model, mock_X): model._bqml_model = bqml_model model.predict(mock_X) - mock_session.read_gbq.assert_called_once_with( + mock_session.read_gbq_query.assert_called_once_with( "SELECT * FROM ML.PREDICT(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_sql))", index_col=["index_column_id"], + allow_large_results=True, ) @@ -209,8 +212,9 @@ def test_logistic_regression_score(mock_session, bqml_model, mock_X, mock_y): model._bqml_model = bqml_model model.score(mock_X, mock_y) - mock_session.read_gbq.assert_called_once_with( - "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_y_sql))" + mock_session.read_gbq_query.assert_called_once_with( + "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_y_sql))", + allow_large_results=True, ) @@ -243,9 +247,10 @@ def test_decomposition_mf_predict(mock_session, bqml_model, mock_X): model._bqml_model = bqml_model model.predict(mock_X) - mock_session.read_gbq.assert_called_once_with( + mock_session.read_gbq_query.assert_called_once_with( "SELECT * FROM ML.RECOMMEND(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_sql))", index_col=["index_column_id"], + allow_large_results=True, ) @@ -260,8 +265,9 @@ def test_decomposition_mf_score(mock_session, bqml_model): ) model._bqml_model = bqml_model model.score() - mock_session.read_gbq.assert_called_once_with( - "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`)" + mock_session.read_gbq_query.assert_called_once_with( + "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`)", + allow_large_results=True, ) @@ -276,6 +282,7 @@ def test_decomposition_mf_score_with_x(mock_session, bqml_model, mock_X): ) model._bqml_model = bqml_model model.score(mock_X) - mock_session.read_gbq.assert_called_once_with( - "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_sql_property))" + mock_session.read_gbq_query.assert_called_once_with( + "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_sql_property))", + allow_large_results=True, ) diff --git a/tests/unit/session/test_read_gbq_query.py b/tests/unit/session/test_read_gbq_query.py index afd9922426..1f9d2fb945 100644 --- a/tests/unit/session/test_read_gbq_query.py +++ b/tests/unit/session/test_read_gbq_query.py @@ -25,7 +25,7 @@ def test_read_gbq_query_sets_destination_table(): # Use partial ordering mode to skip column uniqueness checks. session = mocks.create_bigquery_session(ordering_mode="partial") - _ = session.read_gbq_query("SELECT 'my-test-query';") + _ = session.read_gbq_query("SELECT 'my-test-query';", allow_large_results=True) queries = session._queries # type: ignore configs = session._job_configs # type: ignore diff --git a/third_party/bigframes_vendored/pandas/io/gbq.py b/third_party/bigframes_vendored/pandas/io/gbq.py index 3dae2b6bbe..0fdca4dde1 100644 --- a/third_party/bigframes_vendored/pandas/io/gbq.py +++ b/third_party/bigframes_vendored/pandas/io/gbq.py @@ -25,6 +25,7 @@ def read_gbq( filters: FiltersType = (), use_cache: Optional[bool] = None, col_order: Iterable[str] = (), + allow_large_results: Optional[bool] = None, ): """Loads a DataFrame from BigQuery. @@ -156,6 +157,11 @@ def read_gbq( `configuration` to avoid conflicts. col_order (Iterable[str]): Alias for columns, retained for backwards compatibility. + allow_large_results (bool, optional): + Whether to allow large query results. If ``True``, the query + results can be larger than the maximum response size. This + option is only applicable when ``query_or_table`` is a query. + Defaults to ``bpd.options.compute.allow_large_results``. Raises: bigframes.exceptions.DefaultIndexWarning: From cbbbce335544004bd7bc7acee99c82ff23c8eaee Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Tue, 2 Sep 2025 13:02:55 -0700 Subject: [PATCH 035/313] refactor: Unify bigquery execution paths (#2007) --- bigframes/core/blocks.py | 66 +++- bigframes/core/indexes/base.py | 11 +- bigframes/dataframe.py | 56 +-- bigframes/session/bq_caching_executor.py | 446 ++++++++++------------- bigframes/session/execution_spec.py | 53 +++ bigframes/session/executor.py | 47 +-- bigframes/testing/compiler_session.py | 7 + bigframes/testing/polars_session.py | 37 +- tests/system/small/test_session.py | 11 +- 9 files changed, 375 insertions(+), 359 deletions(-) create mode 100644 bigframes/session/execution_spec.py diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index f7d456bf9d..07d7e4c45b 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -69,7 +69,7 @@ import bigframes.exceptions as bfe import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops -from bigframes.session import dry_runs +from bigframes.session import dry_runs, execution_spec from bigframes.session import executor as executors # Type constraint for wherever column labels are used @@ -257,7 +257,10 @@ def shape(self) -> typing.Tuple[int, int]: except Exception: pass - row_count = self.session._executor.execute(self.expr.row_count()).to_py_scalar() + row_count = self.session._executor.execute( + self.expr.row_count(), + execution_spec.ExecutionSpec(promise_under_10gb=True, ordered=False), + ).to_py_scalar() return (row_count, len(self.value_columns)) @property @@ -557,8 +560,17 @@ def to_arrow( allow_large_results: Optional[bool] = None, ) -> Tuple[pa.Table, Optional[bigquery.QueryJob]]: """Run query and download results as a pyarrow Table.""" + under_10gb = ( + (not allow_large_results) + if (allow_large_results is not None) + else not bigframes.options._allow_large_results + ) execute_result = self.session._executor.execute( - self.expr, ordered=ordered, use_explicit_destination=allow_large_results + self.expr, + execution_spec.ExecutionSpec( + promise_under_10gb=under_10gb, + ordered=ordered, + ), ) pa_table = execute_result.to_arrow_table() @@ -647,8 +659,15 @@ def try_peek( self, n: int = 20, force: bool = False, allow_large_results=None ) -> typing.Optional[pd.DataFrame]: if force or self.expr.supports_fast_peek: - result = self.session._executor.peek( - self.expr, n, use_explicit_destination=allow_large_results + # really, we should just block insane peek values and always assume <10gb + under_10gb = ( + (not allow_large_results) + if (allow_large_results is not None) + else not bigframes.options._allow_large_results + ) + result = self.session._executor.execute( + self.expr, + execution_spec.ExecutionSpec(promise_under_10gb=under_10gb, peek=n), ) df = result.to_pandas() return self._copy_index_to_pandas(df) @@ -665,10 +684,18 @@ def to_pandas_batches( page_size and max_results determine the size and number of batches, see https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.job.QueryJob#google_cloud_bigquery_job_QueryJob_result""" + + under_10gb = ( + (not allow_large_results) + if (allow_large_results is not None) + else not bigframes.options._allow_large_results + ) execute_result = self.session._executor.execute( self.expr, - ordered=True, - use_explicit_destination=allow_large_results, + execution_spec.ExecutionSpec( + promise_under_10gb=under_10gb, + ordered=True, + ), ) # To reduce the number of edge cases to consider when working with the @@ -714,10 +741,17 @@ def _materialize_local( ) -> Tuple[pd.DataFrame, Optional[bigquery.QueryJob]]: """Run query and download results as a pandas DataFrame. Return the total number of results as well.""" # TODO(swast): Allow for dry run and timeout. + under_10gb = ( + (not materialize_options.allow_large_results) + if (materialize_options.allow_large_results is not None) + else (not bigframes.options._allow_large_results) + ) execute_result = self.session._executor.execute( self.expr, - ordered=materialize_options.ordered, - use_explicit_destination=materialize_options.allow_large_results, + execution_spec.ExecutionSpec( + promise_under_10gb=under_10gb, + ordered=materialize_options.ordered, + ), ) sample_config = materialize_options.downsampling if execute_result.total_bytes is not None: @@ -1598,9 +1632,19 @@ def retrieve_repr_request_results( config=executors.CacheConfig(optimize_for="head", if_cached="reuse-strict"), ) head_result = self.session._executor.execute( - self.expr.slice(start=None, stop=max_results, step=None) + self.expr.slice(start=None, stop=max_results, step=None), + execution_spec.ExecutionSpec( + promise_under_10gb=True, + ordered=True, + ), ) - row_count = self.session._executor.execute(self.expr.row_count()).to_py_scalar() + row_count = self.session._executor.execute( + self.expr.row_count(), + execution_spec.ExecutionSpec( + promise_under_10gb=True, + ordered=False, + ), + ).to_py_scalar() head_df = head_result.to_pandas() return self._copy_index_to_pandas(head_df), row_count, head_result.query_job diff --git a/bigframes/core/indexes/base.py b/bigframes/core/indexes/base.py index e022b3f151..f8ec38621d 100644 --- a/bigframes/core/indexes/base.py +++ b/bigframes/core/indexes/base.py @@ -38,6 +38,7 @@ import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops import bigframes.series +import bigframes.session.execution_spec as ex_spec if typing.TYPE_CHECKING: import bigframes.dataframe @@ -283,8 +284,9 @@ def get_loc(self, key) -> typing.Union[int, slice, "bigframes.series.Series"]: # Check if key exists at all by counting count_agg = ex.UnaryAggregation(agg_ops.count_op, ex.deref(offsets_id)) count_result = filtered_block._expr.aggregate([(count_agg, "count")]) + count_scalar = self._block.session._executor.execute( - count_result + count_result, ex_spec.ExecutionSpec(promise_under_10gb=True) ).to_py_scalar() if count_scalar == 0: @@ -295,7 +297,7 @@ def get_loc(self, key) -> typing.Union[int, slice, "bigframes.series.Series"]: min_agg = ex.UnaryAggregation(agg_ops.min_op, ex.deref(offsets_id)) position_result = filtered_block._expr.aggregate([(min_agg, "position")]) position_scalar = self._block.session._executor.execute( - position_result + position_result, ex_spec.ExecutionSpec(promise_under_10gb=True) ).to_py_scalar() return int(position_scalar) @@ -326,7 +328,10 @@ def _get_monotonic_slice(self, filtered_block, offsets_id: str) -> slice: combined_result = filtered_block._expr.aggregate(min_max_aggs) # Execute query and extract positions - result_df = self._block.session._executor.execute(combined_result).to_pandas() + result_df = self._block.session._executor.execute( + combined_result, + execution_spec=ex_spec.ExecutionSpec(promise_under_10gb=True), + ).to_pandas() min_pos = int(result_df["min_pos"].iloc[0]) max_pos = int(result_df["max_pos"].iloc[0]) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 75be1c256e..f9de117b29 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -86,6 +86,7 @@ import bigframes.operations.structs import bigframes.series import bigframes.session._io.bigquery +import bigframes.session.execution_spec as ex_spec if typing.TYPE_CHECKING: from _typeshed import SupportsRichComparison @@ -4268,17 +4269,19 @@ def to_csv( index=index and self._has_index, ordering_id=bigframes.session._io.bigquery.IO_ORDERING_ID, ) - options = { + options: dict[str, Union[bool, str]] = { "field_delimiter": sep, "header": header, } - query_job = self._session._executor.export_gcs( + result = self._session._executor.execute( export_array.rename_columns(id_overrides), - path_or_buf, - format="csv", - export_options=options, + ex_spec.ExecutionSpec( + ex_spec.GcsOutputSpec( + uri=path_or_buf, format="csv", export_options=tuple(options.items()) + ) + ), ) - self._set_internal_query_job(query_job) + self._set_internal_query_job(result.query_job) return None def to_json( @@ -4321,13 +4324,13 @@ def to_json( index=index and self._has_index, ordering_id=bigframes.session._io.bigquery.IO_ORDERING_ID, ) - query_job = self._session._executor.export_gcs( + result = self._session._executor.execute( export_array.rename_columns(id_overrides), - path_or_buf, - format="json", - export_options={}, + ex_spec.ExecutionSpec( + ex_spec.GcsOutputSpec(uri=path_or_buf, format="json", export_options=()) + ), ) - self._set_internal_query_job(query_job) + self._set_internal_query_job(result.query_job) return None def to_gbq( @@ -4400,16 +4403,21 @@ def to_gbq( ) ) - query_job = self._session._executor.export_gbq( + result = self._session._executor.execute( export_array.rename_columns(id_overrides), - destination=destination, - cluster_cols=clustering_fields, - if_exists=if_exists, + ex_spec.ExecutionSpec( + ex_spec.TableOutputSpec( + destination, + cluster_cols=tuple(clustering_fields), + if_exists=if_exists, + ) + ), ) - self._set_internal_query_job(query_job) + assert result.query_job is not None + self._set_internal_query_job(result.query_job) # The query job should have finished, so there should be always be a result table. - result_table = query_job.destination + result_table = result.query_job.destination assert result_table is not None if temp_table_ref: @@ -4477,13 +4485,17 @@ def to_parquet( index=index and self._has_index, ordering_id=bigframes.session._io.bigquery.IO_ORDERING_ID, ) - query_job = self._session._executor.export_gcs( + result = self._session._executor.execute( export_array.rename_columns(id_overrides), - path, - format="parquet", - export_options=export_options, + ex_spec.ExecutionSpec( + ex_spec.GcsOutputSpec( + uri=path, + format="parquet", + export_options=tuple(export_options.items()), + ) + ), ) - self._set_internal_query_job(query_job) + self._set_internal_query_job(result.query_job) return None def to_dict( diff --git a/bigframes/session/bq_caching_executor.py b/bigframes/session/bq_caching_executor.py index a970e75a0f..b428cd646c 100644 --- a/bigframes/session/bq_caching_executor.py +++ b/bigframes/session/bq_caching_executor.py @@ -14,11 +14,9 @@ from __future__ import annotations -import dataclasses import math -import os import threading -from typing import cast, Literal, Mapping, Optional, Sequence, Tuple, Union +from typing import Literal, Mapping, Optional, Sequence, Tuple import warnings import weakref @@ -35,12 +33,12 @@ from bigframes.core import compile, local_data, rewrite import bigframes.core.compile.sqlglot.sqlglot_ir as sqlglot_ir import bigframes.core.guid +import bigframes.core.identifiers import bigframes.core.nodes as nodes import bigframes.core.ordering as order import bigframes.core.schema as schemata import bigframes.core.tree_properties as tree_properties import bigframes.dtypes -import bigframes.features from bigframes.session import ( executor, loader, @@ -49,6 +47,7 @@ semi_executor, ) import bigframes.session._io.bigquery as bq_io +import bigframes.session.execution_spec as ex_spec import bigframes.session.metrics import bigframes.session.planner import bigframes.session.temporary_storage @@ -61,21 +60,6 @@ MAX_SMALL_RESULT_BYTES = 10 * 1024 * 1024 * 1024 # 10G -@dataclasses.dataclass -class OutputSpec: - require_bq_table: bool - cluster_cols: tuple[str, ...] - - def with_require_table(self, value: bool) -> OutputSpec: - return dataclasses.replace(self, require_bq_table=value) - - -def _get_default_output_spec() -> OutputSpec: - return OutputSpec( - require_bq_table=bigframes.options._allow_large_results, cluster_cols=() - ) - - SourceIdMapping = Mapping[str, str] @@ -189,7 +173,11 @@ def to_sql( ) -> str: if offset_column: array_value, _ = array_value.promote_offsets() - node = self.logical_plan(array_value.node) if enable_cache else array_value.node + node = ( + self.prepare_plan(array_value.node, target="simplify") + if enable_cache + else array_value.node + ) node = self._substitute_large_local_sources(node) compiled = compile.compile_sql(compile.CompileRequest(node, sort_rows=ordered)) return compiled.sql @@ -197,86 +185,113 @@ def to_sql( def execute( self, array_value: bigframes.core.ArrayValue, - *, - ordered: bool = True, - use_explicit_destination: Optional[bool] = None, + execution_spec: ex_spec.ExecutionSpec, ) -> executor.ExecuteResult: - if bigframes.options.compute.enable_multi_query_execution: - self._simplify_with_caching(array_value) - - output_spec = _get_default_output_spec() - if use_explicit_destination is not None: - output_spec = output_spec.with_require_table(use_explicit_destination) - - plan = self.logical_plan(array_value.node) - return self._execute_plan( - plan, - ordered=ordered, - output_spec=output_spec, - ) + # TODO: Support export jobs in combination with semi executors + if execution_spec.destination_spec is None: + plan = self.prepare_plan(array_value.node, target="simplify") + for exec in self._semi_executors: + maybe_result = exec.execute( + plan, ordered=execution_spec.ordered, peek=execution_spec.peek + ) + if maybe_result: + return maybe_result - def peek( - self, - array_value: bigframes.core.ArrayValue, - n_rows: int, - use_explicit_destination: Optional[bool] = None, - ) -> executor.ExecuteResult: - """ - A 'peek' efficiently accesses a small number of rows in the dataframe. - """ - plan = self.logical_plan(array_value.node) - if not tree_properties.can_fast_peek(plan): - msg = bfe.format_message("Peeking this value cannot be done efficiently.") - warnings.warn(msg) + if isinstance(execution_spec.destination_spec, ex_spec.TableOutputSpec): + if execution_spec.peek or execution_spec.ordered: + raise NotImplementedError( + "Ordering and peeking not supported for gbq export" + ) + # separate path for export_gbq, as it has all sorts of annoying logic, such as possibly running as dml + return self._export_gbq(array_value, execution_spec.destination_spec) + + result = self._execute_plan_gbq( + array_value.node, + ordered=execution_spec.ordered, + peek=execution_spec.peek, + cache_spec=execution_spec.destination_spec + if isinstance(execution_spec.destination_spec, ex_spec.CacheSpec) + else None, + must_create_table=not execution_spec.promise_under_10gb, + ) + # post steps: export + if isinstance(execution_spec.destination_spec, ex_spec.GcsOutputSpec): + self._export_result_gcs(result, execution_spec.destination_spec) - output_spec = _get_default_output_spec() - if use_explicit_destination is not None: - output_spec = output_spec.with_require_table(use_explicit_destination) + return result - return self._execute_plan( - plan, ordered=False, output_spec=output_spec, peek=n_rows + def _export_result_gcs( + self, result: executor.ExecuteResult, gcs_export_spec: ex_spec.GcsOutputSpec + ): + query_job = result.query_job + assert query_job is not None + result_table = query_job.destination + assert result_table is not None + export_data_statement = bq_io.create_export_data_statement( + f"{result_table.project}.{result_table.dataset_id}.{result_table.table_id}", + uri=gcs_export_spec.uri, + format=gcs_export_spec.format, + export_options=dict(gcs_export_spec.export_options), + ) + bq_io.start_query_with_client( + self.bqclient, + export_data_statement, + job_config=bigquery.QueryJobConfig(), + metrics=self.metrics, + project=None, + location=None, + timeout=None, + query_with_job=True, ) - def export_gbq( - self, - array_value: bigframes.core.ArrayValue, - destination: bigquery.TableReference, - if_exists: Literal["fail", "replace", "append"] = "fail", - cluster_cols: Sequence[str] = [], - ): + def _maybe_find_existing_table( + self, spec: ex_spec.TableOutputSpec + ) -> Optional[bigquery.Table]: + # validate destination table + try: + table = self.bqclient.get_table(spec.table) + if spec.if_exists == "fail": + raise ValueError(f"Table already exists: {spec.table.__str__()}") + + if len(spec.cluster_cols) != 0: + if (table.clustering_fields is None) or ( + tuple(table.clustering_fields) != spec.cluster_cols + ): + raise ValueError( + "Table clustering fields cannot be changed after the table has " + f"been created. Requested clustering fields: {spec.cluster_cols}, existing clustering fields: {table.clustering_fields}" + ) + return table + except google.api_core.exceptions.NotFound: + return None + + def _export_gbq( + self, array_value: bigframes.core.ArrayValue, spec: ex_spec.TableOutputSpec + ) -> executor.ExecuteResult: """ Export the ArrayValue to an existing BigQuery table. """ - if bigframes.options.compute.enable_multi_query_execution: - self._simplify_with_caching(array_value) + plan = self.prepare_plan(array_value.node, target="bq_execution") - table_exists = True - try: - table = self.bqclient.get_table(destination) - if if_exists == "fail": - raise ValueError(f"Table already exists: {destination.__str__()}") - except google.api_core.exceptions.NotFound: - table_exists = False + # validate destination table + existing_table = self._maybe_find_existing_table(spec) - if len(cluster_cols) != 0: - if table_exists and table.clustering_fields != cluster_cols: - raise ValueError( - "Table clustering fields cannot be changed after the table has " - f"been created. Existing clustering fields: {table.clustering_fields}" - ) + compiled = compile.compile_sql(compile.CompileRequest(plan, sort_rows=False)) + sql = compiled.sql - sql = self.to_sql(array_value, ordered=False) - if table_exists and _if_schema_match(table.schema, array_value.schema): + if (existing_table is not None) and _if_schema_match( + existing_table.schema, array_value.schema + ): # b/409086472: Uses DML for table appends and replacements to avoid # BigQuery `RATE_LIMIT_EXCEEDED` errors, as per quota limits: # https://cloud.google.com/bigquery/quotas#standard_tables job_config = bigquery.QueryJobConfig() ir = sqlglot_ir.SQLGlotIR.from_query_string(sql) - if if_exists == "append": - sql = ir.insert(destination) + if spec.if_exists == "append": + sql = ir.insert(spec.table) else: # for "replace" - assert if_exists == "replace" - sql = ir.replace(destination) + assert spec.if_exists == "replace" + sql = ir.replace(spec.table) else: dispositions = { "fail": bigquery.WriteDisposition.WRITE_EMPTY, @@ -284,14 +299,14 @@ def export_gbq( "append": bigquery.WriteDisposition.WRITE_APPEND, } job_config = bigquery.QueryJobConfig( - write_disposition=dispositions[if_exists], - destination=destination, - clustering_fields=cluster_cols if cluster_cols else None, + write_disposition=dispositions[spec.if_exists], + destination=spec.table, + clustering_fields=spec.cluster_cols if spec.cluster_cols else None, ) # TODO(swast): plumb through the api_name of the user-facing api that # caused this query. - _, query_job = self._run_execute_query( + row_iter, query_job = self._run_execute_query( sql=sql, job_config=job_config, ) @@ -300,48 +315,16 @@ def export_gbq( t == bigframes.dtypes.TIMEDELTA_DTYPE for t in array_value.schema.dtypes ) - if if_exists != "append" and has_timedelta_col: + if spec.if_exists != "append" and has_timedelta_col: # Only update schema if this is not modifying an existing table, and the # new table contains timedelta columns. - table = self.bqclient.get_table(destination) + table = self.bqclient.get_table(spec.table) table.schema = array_value.schema.to_bigquery() self.bqclient.update_table(table, ["schema"]) - return query_job - - def export_gcs( - self, - array_value: bigframes.core.ArrayValue, - uri: str, - format: Literal["json", "csv", "parquet"], - export_options: Mapping[str, Union[bool, str]], - ): - query_job = self.execute( - array_value, - ordered=False, - use_explicit_destination=True, - ).query_job - assert query_job is not None - result_table = query_job.destination - assert result_table is not None - export_data_statement = bq_io.create_export_data_statement( - f"{result_table.project}.{result_table.dataset_id}.{result_table.table_id}", - uri=uri, - format=format, - export_options=dict(export_options), - ) - - bq_io.start_query_with_client( - self.bqclient, - export_data_statement, - job_config=bigquery.QueryJobConfig(), - metrics=self.metrics, - project=None, - location=None, - timeout=None, - query_with_job=True, + return executor.ExecuteResult( + row_iter.to_arrow_iterable(), array_value.schema, query_job ) - return query_job def dry_run( self, array_value: bigframes.core.ArrayValue, ordered: bool = True @@ -446,59 +429,56 @@ def _is_trivially_executable(self, array_value: bigframes.core.ArrayValue): # Once rewriting is available, will want to rewrite before # evaluating execution cost. return tree_properties.is_trivially_executable( - self.logical_plan(array_value.node) + self.prepare_plan(array_value.node) ) - def logical_plan(self, root: nodes.BigFrameNode) -> nodes.BigFrameNode: + def prepare_plan( + self, + plan: nodes.BigFrameNode, + target: Literal["simplify", "bq_execution"] = "simplify", + ) -> nodes.BigFrameNode: """ - Apply universal logical simplifications that are helpful regardless of engine. + Prepare the plan by simplifying it with caches, removing unused operators. Has modes for different contexts. + + "simplify" removes unused operations and subsitutes subtrees with their previously cached equivalents + "bq_execution" is the most heavy option, preparing the plan for bq execution by also caching subtrees, uploading large local sources """ - plan = self.replace_cached_subtrees(root) + # TODO: We should model plan decomposition and data uploading as work steps rather than as plan preparation. + if ( + target == "bq_execution" + and bigframes.options.compute.enable_multi_query_execution + ): + self._simplify_with_caching(plan) + + plan = self.replace_cached_subtrees(plan) plan = rewrite.column_pruning(plan) plan = plan.top_down(rewrite.fold_row_counts) + + if target == "bq_execution": + plan = self._substitute_large_local_sources(plan) + return plan def _cache_with_cluster_cols( self, array_value: bigframes.core.ArrayValue, cluster_cols: Sequence[str] ): """Executes the query and uses the resulting table to rewrite future executions.""" - plan = self.logical_plan(array_value.node) - plan = self._substitute_large_local_sources(plan) - compiled = compile.compile_sql( - compile.CompileRequest( - plan, sort_rows=False, materialize_all_order_keys=True - ) - ) - tmp_table_ref, num_rows = self._sql_as_cached_temp_table( - compiled.sql, - compiled.sql_schema, - cluster_cols=bq_io.select_cluster_cols(compiled.sql_schema, cluster_cols), + execution_spec = ex_spec.ExecutionSpec( + destination_spec=ex_spec.CacheSpec(cluster_cols=tuple(cluster_cols)) ) - tmp_table = self.bqclient.get_table(tmp_table_ref) - assert compiled.row_order is not None - self.cache.cache_results_table( - array_value.node, tmp_table, compiled.row_order, num_rows=num_rows + self.execute( + array_value, + execution_spec=execution_spec, ) def _cache_with_offsets(self, array_value: bigframes.core.ArrayValue): """Executes the query and uses the resulting table to rewrite future executions.""" - offset_column = bigframes.core.guid.generate_guid("bigframes_offsets") - w_offsets, offset_column = array_value.promote_offsets() - compiled = compile.compile_sql( - compile.CompileRequest( - self.logical_plan(self._substitute_large_local_sources(w_offsets.node)), - sort_rows=False, - ) + execution_spec = ex_spec.ExecutionSpec( + destination_spec=ex_spec.CacheSpec(cluster_cols=tuple()) ) - tmp_table_ref, num_rows = self._sql_as_cached_temp_table( - compiled.sql, - compiled.sql_schema, - cluster_cols=[offset_column], - ) - tmp_table = self.bqclient.get_table(tmp_table_ref) - assert compiled.row_order is not None - self.cache.cache_results_table( - array_value.node, tmp_table, compiled.row_order, num_rows=num_rows + self.execute( + array_value, + execution_spec=execution_spec, ) def _cache_with_session_awareness( @@ -520,17 +500,17 @@ def _cache_with_session_awareness( else: self._cache_with_cluster_cols(bigframes.core.ArrayValue(target), []) - def _simplify_with_caching(self, array_value: bigframes.core.ArrayValue): + def _simplify_with_caching(self, plan: nodes.BigFrameNode): """Attempts to handle the complexity by caching duplicated subtrees and breaking the query into pieces.""" # Apply existing caching first for _ in range(MAX_SUBTREE_FACTORINGS): if ( - self.logical_plan(array_value.node).planning_complexity + self.prepare_plan(plan, "simplify").planning_complexity < QUERY_COMPLEXITY_LIMIT ): return - did_cache = self._cache_most_complex_subtree(array_value.node) + did_cache = self._cache_most_complex_subtree(plan) if not did_cache: return @@ -552,52 +532,6 @@ def _cache_most_complex_subtree(self, node: nodes.BigFrameNode) -> bool: self._cache_with_cluster_cols(bigframes.core.ArrayValue(selection), []) return True - def _sql_as_cached_temp_table( - self, - sql: str, - schema: Sequence[bigquery.SchemaField], - cluster_cols: Sequence[str], - ) -> tuple[bigquery.TableReference, Optional[int]]: - assert len(cluster_cols) <= _MAX_CLUSTER_COLUMNS - temp_table = self.storage_manager.create_temp_table(schema, cluster_cols) - - # TODO: Get default job config settings - job_config = cast( - bigquery.QueryJobConfig, - bigquery.QueryJobConfig.from_api_repr({}), - ) - job_config.destination = temp_table - _, query_job = self._run_execute_query( - sql, - job_config=job_config, - ) - assert query_job is not None - iter = query_job.result() - return query_job.destination, iter.total_rows - - def _validate_result_schema( - self, - array_value: bigframes.core.ArrayValue, - bq_schema: list[bigquery.SchemaField], - ): - actual_schema = _sanitize(tuple(bq_schema)) - ibis_schema = compile.test_only_ibis_inferred_schema( - self.logical_plan(array_value.node) - ).to_bigquery() - internal_schema = _sanitize(array_value.schema.to_bigquery()) - if not bigframes.features.PANDAS_VERSIONS.is_arrow_list_dtype_usable: - return - - if internal_schema != actual_schema: - raise ValueError( - f"This error should only occur while testing. BigFrames internal schema: {internal_schema} does not match actual schema: {actual_schema}" - ) - - if ibis_schema != actual_schema: - raise ValueError( - f"This error should only occur while testing. Ibis schema: {ibis_schema} does not match actual schema: {actual_schema}" - ) - def _substitute_large_local_sources(self, original_root: nodes.BigFrameNode): """ Replace large local sources with the uploaded version of those datasources. @@ -646,52 +580,80 @@ def _upload_local_data(self, local_table: local_data.ManagedArrowTable): ) self.cache.cache_remote_replacement(local_table, uploaded) - def _execute_plan( + def _execute_plan_gbq( self, plan: nodes.BigFrameNode, ordered: bool, - output_spec: OutputSpec, peek: Optional[int] = None, + cache_spec: Optional[ex_spec.CacheSpec] = None, + must_create_table: bool = True, ) -> executor.ExecuteResult: """Just execute whatever plan as is, without further caching or decomposition.""" - # First try to execute fast-paths - if not output_spec.require_bq_table: - for exec in self._semi_executors: - maybe_result = exec.execute(plan, ordered=ordered, peek=peek) - if maybe_result: - return maybe_result + # TODO(swast): plumb through the api_name of the user-facing api that + # caused this query. + + og_plan = plan + og_schema = plan.schema + + plan = self.prepare_plan(plan, target="bq_execution") + create_table = must_create_table + cluster_cols: Sequence[str] = [] + if cache_spec is not None: + if peek is not None: + raise ValueError("peek is not compatible with caching.") + + create_table = True + if not cache_spec.cluster_cols: + assert len(cache_spec.cluster_cols) <= _MAX_CLUSTER_COLUMNS + offsets_id = bigframes.core.identifiers.ColumnId( + bigframes.core.guid.generate_guid() + ) + plan = nodes.PromoteOffsetsNode(plan, offsets_id) + cluster_cols = [offsets_id.sql] + else: + cluster_cols = cache_spec.cluster_cols - # Use explicit destination to avoid 10GB limit of temporary table - destination_table = ( - self.storage_manager.create_temp_table( - plan.schema.to_bigquery(), cluster_cols=output_spec.cluster_cols + compiled = compile.compile_sql( + compile.CompileRequest( + plan, + sort_rows=ordered, + peek_count=peek, + materialize_all_order_keys=(cache_spec is not None), ) - if output_spec.require_bq_table - else None ) + # might have more columns than og schema, for hidden ordering columns + compiled_schema = compiled.sql_schema + + destination_table: Optional[bigquery.TableReference] = None - # TODO(swast): plumb through the api_name of the user-facing api that - # caused this query. job_config = bigquery.QueryJobConfig() - # Use explicit destination to avoid 10GB limit of temporary table - if destination_table is not None: + if create_table: + destination_table = self.storage_manager.create_temp_table( + compiled_schema, cluster_cols + ) job_config.destination = destination_table - plan = self._substitute_large_local_sources(plan) - compiled = compile.compile_sql( - compile.CompileRequest(plan, sort_rows=ordered, peek_count=peek) - ) iterator, query_job = self._run_execute_query( sql=compiled.sql, job_config=job_config, query_with_job=(destination_table is not None), ) - if query_job: - size_bytes = self.bqclient.get_table(query_job.destination).num_bytes + table_info: Optional[bigquery.Table] = None + if query_job and query_job.destination: + table_info = self.bqclient.get_table(query_job.destination) + size_bytes = table_info.num_bytes else: size_bytes = None + # we could actually cache even when caching is not explicitly requested, but being conservative for now + if cache_spec is not None: + assert table_info is not None + assert compiled.row_order is not None + self.cache.cache_results_table( + og_plan, table_info, compiled.row_order, num_rows=table_info.num_rows + ) + if size_bytes is not None and size_bytes >= MAX_SMALL_RESULT_BYTES: msg = bfe.format_message( "The query result size has exceeded 10 GB. In BigFrames 2.0 and " @@ -700,18 +662,12 @@ def _execute_plan( "`bigframes.options.compute.allow_large_results=True`." ) warnings.warn(msg, FutureWarning) - # Runs strict validations to ensure internal type predictions and ibis are completely in sync - # Do not execute these validations outside of testing suite. - if "PYTEST_CURRENT_TEST" in os.environ: - self._validate_result_schema( - bigframes.core.ArrayValue(plan), iterator.schema - ) return executor.ExecuteResult( _arrow_batches=iterator.to_arrow_iterable( bqstorage_client=self.bqstoragereadclient ), - schema=plan.schema, + schema=og_schema, query_job=query_job, total_bytes=size_bytes, total_rows=iterator.total_rows, @@ -731,19 +687,3 @@ def _if_schema_match( ): return False return True - - -def _sanitize( - schema: Tuple[bigquery.SchemaField, ...] -) -> Tuple[bigquery.SchemaField, ...]: - # Schema inferred from SQL strings and Ibis expressions contain only names, types and modes, - # so we disregard other fields (e.g timedelta description for timedelta columns) for validations. - return tuple( - bigquery.SchemaField( - f.name, - f.field_type, - f.mode, # type:ignore - fields=_sanitize(f.fields), - ) - for f in schema - ) diff --git a/bigframes/session/execution_spec.py b/bigframes/session/execution_spec.py new file mode 100644 index 0000000000..c9431dbd11 --- /dev/null +++ b/bigframes/session/execution_spec.py @@ -0,0 +1,53 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import dataclasses +from typing import Literal, Optional, Union + +from google.cloud import bigquery + + +@dataclasses.dataclass(frozen=True) +class ExecutionSpec: + destination_spec: Union[TableOutputSpec, GcsOutputSpec, CacheSpec, None] = None + peek: Optional[int] = None + ordered: bool = ( + False # ordered and promise_under_10gb must both be together for bq execution + ) + # This is an optimization flag for gbq execution, it doesn't change semantics, but if promise is falsely made, errors may occur + promise_under_10gb: bool = False + + +# This one is temporary, in future, caching will not be done through immediate execution, but will label nodes +# that will be cached only when a super-tree is executed +@dataclasses.dataclass(frozen=True) +class CacheSpec: + cluster_cols: tuple[str, ...] + + +@dataclasses.dataclass(frozen=True) +class TableOutputSpec: + table: bigquery.TableReference + cluster_cols: tuple[str, ...] + if_exists: Literal["fail", "replace", "append"] = "fail" + + +@dataclasses.dataclass(frozen=True) +class GcsOutputSpec: + uri: str + format: Literal["json", "csv", "parquet"] + # sequence of (option, value) pairs + export_options: tuple[tuple[str, Union[bool, str]], ...] diff --git a/bigframes/session/executor.py b/bigframes/session/executor.py index cc8f086f9f..748b10647a 100644 --- a/bigframes/session/executor.py +++ b/bigframes/session/executor.py @@ -18,7 +18,7 @@ import dataclasses import functools import itertools -from typing import Iterator, Literal, Mapping, Optional, Sequence, Union +from typing import Iterator, Literal, Optional, Union from google.cloud import bigquery import pandas as pd @@ -29,6 +29,7 @@ from bigframes.core import pyarrow_utils import bigframes.core.schema import bigframes.session._io.pandas as io_pandas +import bigframes.session.execution_spec as ex_spec _ROW_LIMIT_EXCEEDED_TEMPLATE = ( "Execution has downloaded {result_rows} rows so far, which exceeds the " @@ -147,41 +148,16 @@ def to_sql( """ raise NotImplementedError("to_sql not implemented for this executor") + @abc.abstractmethod def execute( self, array_value: bigframes.core.ArrayValue, - *, - ordered: bool = True, - use_explicit_destination: Optional[bool] = False, + execution_spec: ex_spec.ExecutionSpec, ) -> ExecuteResult: """ - Execute the ArrayValue, storing the result to a temporary session-owned table. - """ - raise NotImplementedError("execute not implemented for this executor") - - def export_gbq( - self, - array_value: bigframes.core.ArrayValue, - destination: bigquery.TableReference, - if_exists: Literal["fail", "replace", "append"] = "fail", - cluster_cols: Sequence[str] = [], - ) -> bigquery.QueryJob: - """ - Export the ArrayValue to an existing BigQuery table. + Execute the ArrayValue. """ - raise NotImplementedError("export_gbq not implemented for this executor") - - def export_gcs( - self, - array_value: bigframes.core.ArrayValue, - uri: str, - format: Literal["json", "csv", "parquet"], - export_options: Mapping[str, Union[bool, str]], - ) -> bigquery.QueryJob: - """ - Export the ArrayValue to gcs. - """ - raise NotImplementedError("export_gcs not implemented for this executor") + ... def dry_run( self, array_value: bigframes.core.ArrayValue, ordered: bool = True @@ -193,17 +169,6 @@ def dry_run( """ raise NotImplementedError("dry_run not implemented for this executor") - def peek( - self, - array_value: bigframes.core.ArrayValue, - n_rows: int, - use_explicit_destination: Optional[bool] = False, - ) -> ExecuteResult: - """ - A 'peek' efficiently accesses a small number of rows in the dataframe. - """ - raise NotImplementedError("peek not implemented for this executor") - def cached( self, array_value: bigframes.core.ArrayValue, diff --git a/bigframes/testing/compiler_session.py b/bigframes/testing/compiler_session.py index 35114d95d0..289b2600fd 100644 --- a/bigframes/testing/compiler_session.py +++ b/bigframes/testing/compiler_session.py @@ -41,3 +41,10 @@ def to_sql( return self.compiler.SQLGlotCompiler().compile( array_value.node, ordered=ordered ) + + def execute( + self, + array_value, + execution_spec, + ): + raise NotImplementedError("SQLCompilerExecutor.execute not implemented") diff --git a/bigframes/testing/polars_session.py b/bigframes/testing/polars_session.py index 3710c40eae..29eae20b7a 100644 --- a/bigframes/testing/polars_session.py +++ b/bigframes/testing/polars_session.py @@ -13,7 +13,7 @@ # limitations under the License. import dataclasses -from typing import Optional, Union +from typing import Union import weakref import pandas @@ -23,48 +23,31 @@ import bigframes.core.blocks import bigframes.core.compile.polars import bigframes.dataframe +import bigframes.session.execution_spec import bigframes.session.executor import bigframes.session.metrics -# Does not support to_sql, export_gbq, export_gcs, dry_run, peek, head, get_row_count, cached +# Does not support to_sql, dry_run, peek, cached @dataclasses.dataclass class TestExecutor(bigframes.session.executor.Executor): compiler = bigframes.core.compile.polars.PolarsCompiler() - def peek( - self, - array_value: bigframes.core.ArrayValue, - n_rows: int, - use_explicit_destination: Optional[bool] = False, - ): - """ - A 'peek' efficiently accesses a small number of rows in the dataframe. - """ - lazy_frame: polars.LazyFrame = self.compiler.compile(array_value.node) - pa_table = lazy_frame.collect().limit(n_rows).to_arrow() - # Currently, pyarrow types might not quite be exactly the ones in the bigframes schema. - # Nullability may be different, and might use large versions of list, string datatypes. - return bigframes.session.executor.ExecuteResult( - _arrow_batches=pa_table.to_batches(), - schema=array_value.schema, - total_bytes=pa_table.nbytes, - total_rows=pa_table.num_rows, - ) - def execute( self, array_value: bigframes.core.ArrayValue, - *, - ordered: bool = True, - use_explicit_destination: Optional[bool] = False, - page_size: Optional[int] = None, - max_results: Optional[int] = None, + execution_spec: bigframes.session.execution_spec.ExecutionSpec, ): """ Execute the ArrayValue, storing the result to a temporary session-owned table. """ + if execution_spec.destination_spec is not None: + raise ValueError( + f"TestExecutor does not support destination spec: {execution_spec.destination_spec}" + ) lazy_frame: polars.LazyFrame = self.compiler.compile(array_value.node) + if execution_spec.peek is not None: + lazy_frame = lazy_frame.limit(execution_spec.peek) pa_table = lazy_frame.collect().to_arrow() # Currently, pyarrow types might not quite be exactly the ones in the bigframes schema. # Nullability may be different, and might use large versions of list, string datatypes. diff --git a/tests/system/small/test_session.py b/tests/system/small/test_session.py index 6343f0cc53..892f8c8898 100644 --- a/tests/system/small/test_session.py +++ b/tests/system/small/test_session.py @@ -36,6 +36,7 @@ import bigframes.dataframe import bigframes.dtypes import bigframes.ml.linear_model +import bigframes.session.execution_spec from bigframes.testing import utils all_write_engines = pytest.mark.parametrize( @@ -113,7 +114,10 @@ def test_read_gbq_tokyo( # use_explicit_destination=True, otherwise might use path with no query_job exec_result = session_tokyo._executor.execute( - df._block.expr, use_explicit_destination=True + df._block.expr, + bigframes.session.execution_spec.ExecutionSpec( + bigframes.session.execution_spec.CacheSpec(()), promise_under_10gb=False + ), ) assert exec_result.query_job is not None assert exec_result.query_job.location == tokyo_location @@ -896,7 +900,10 @@ def test_read_pandas_tokyo( expected = scalars_pandas_df_index result = session_tokyo._executor.execute( - df._block.expr, use_explicit_destination=True + df._block.expr, + bigframes.session.execution_spec.ExecutionSpec( + bigframes.session.execution_spec.CacheSpec(()), promise_under_10gb=False + ), ) assert result.query_job is not None assert result.query_job.location == tokyo_location From 6370d3bdd8ac997884b44ca7d8e06773b70947c5 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Tue, 2 Sep 2025 15:17:56 -0700 Subject: [PATCH 036/313] chore: refactor test_unary_compiler to apply multiple ops (#2043) --- .../test_endswith/no_pattern.sql | 13 - .../{multiple_patterns.sql => out.sql} | 8 +- .../test_endswith/single_pattern.sql | 13 - .../test_startswith/no_pattern.sql | 13 - .../{multiple_patterns.sql => out.sql} | 8 +- .../test_startswith/single_pattern.sql | 13 - .../test_unary_compiler/test_str_find/out.sql | 10 +- .../test_str_find/out_with_end.sql | 13 - .../test_str_find/out_with_start.sql | 13 - .../test_str_find/out_with_start_and_end.sql | 13 - .../test_unary_compiler/test_str_pad/left.sql | 13 - .../test_str_pad/{both.sql => out.sql} | 8 +- .../test_str_pad/right.sql | 13 - .../test_struct_field/out.sql | 6 +- .../expressions/test_unary_compiler.py | 671 +++++++++++------- 15 files changed, 444 insertions(+), 384 deletions(-) delete mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/no_pattern.sql rename tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/{multiple_patterns.sql => out.sql} (62%) delete mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/single_pattern.sql delete mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/no_pattern.sql rename tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/{multiple_patterns.sql => out.sql} (61%) delete mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/single_pattern.sql delete mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_end.sql delete mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start.sql delete mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start_and_end.sql delete mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/left.sql rename tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/{both.sql => out.sql} (63%) delete mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/right.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/no_pattern.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/no_pattern.sql deleted file mode 100644 index e9f61ddd7c..0000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/no_pattern.sql +++ /dev/null @@ -1,13 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `string_col` AS `bfcol_0` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_1` AS ( - SELECT - *, - FALSE AS `bfcol_1` - FROM `bfcte_0` -) -SELECT - `bfcol_1` AS `string_col` -FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/multiple_patterns.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/out.sql similarity index 62% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/multiple_patterns.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/out.sql index f224471e79..e3ac5ec033 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/multiple_patterns.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/out.sql @@ -5,9 +5,13 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - ENDS_WITH(`bfcol_0`, 'ab') OR ENDS_WITH(`bfcol_0`, 'cd') AS `bfcol_1` + ENDS_WITH(`bfcol_0`, 'ab') AS `bfcol_1`, + ENDS_WITH(`bfcol_0`, 'ab') OR ENDS_WITH(`bfcol_0`, 'cd') AS `bfcol_2`, + FALSE AS `bfcol_3` FROM `bfcte_0` ) SELECT - `bfcol_1` AS `string_col` + `bfcol_1` AS `single`, + `bfcol_2` AS `double`, + `bfcol_3` AS `empty` FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/single_pattern.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/single_pattern.sql deleted file mode 100644 index a4e259f0b2..0000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/single_pattern.sql +++ /dev/null @@ -1,13 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `string_col` AS `bfcol_0` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_1` AS ( - SELECT - *, - ENDS_WITH(`bfcol_0`, 'ab') AS `bfcol_1` - FROM `bfcte_0` -) -SELECT - `bfcol_1` AS `string_col` -FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/no_pattern.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/no_pattern.sql deleted file mode 100644 index e9f61ddd7c..0000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/no_pattern.sql +++ /dev/null @@ -1,13 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `string_col` AS `bfcol_0` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_1` AS ( - SELECT - *, - FALSE AS `bfcol_1` - FROM `bfcte_0` -) -SELECT - `bfcol_1` AS `string_col` -FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/multiple_patterns.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/out.sql similarity index 61% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/multiple_patterns.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/out.sql index 061b57e208..9679c95f75 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/multiple_patterns.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/out.sql @@ -5,9 +5,13 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - STARTS_WITH(`bfcol_0`, 'ab') OR STARTS_WITH(`bfcol_0`, 'cd') AS `bfcol_1` + STARTS_WITH(`bfcol_0`, 'ab') AS `bfcol_1`, + STARTS_WITH(`bfcol_0`, 'ab') OR STARTS_WITH(`bfcol_0`, 'cd') AS `bfcol_2`, + FALSE AS `bfcol_3` FROM `bfcte_0` ) SELECT - `bfcol_1` AS `string_col` + `bfcol_1` AS `single`, + `bfcol_2` AS `double`, + `bfcol_3` AS `empty` FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/single_pattern.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/single_pattern.sql deleted file mode 100644 index 726ce05b8c..0000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/single_pattern.sql +++ /dev/null @@ -1,13 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `string_col` AS `bfcol_0` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_1` AS ( - SELECT - *, - STARTS_WITH(`bfcol_0`, 'ab') AS `bfcol_1` - FROM `bfcte_0` -) -SELECT - `bfcol_1` AS `string_col` -FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out.sql index dfc100e413..b850262d80 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out.sql @@ -5,9 +5,15 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - INSTR(`bfcol_0`, 'e', 1) - 1 AS `bfcol_1` + INSTR(`bfcol_0`, 'e', 1) - 1 AS `bfcol_1`, + INSTR(`bfcol_0`, 'e', 3) - 1 AS `bfcol_2`, + INSTR(SUBSTRING(`bfcol_0`, 1, 5), 'e') - 1 AS `bfcol_3`, + INSTR(SUBSTRING(`bfcol_0`, 3, 3), 'e') - 1 AS `bfcol_4` FROM `bfcte_0` ) SELECT - `bfcol_1` AS `string_col` + `bfcol_1` AS `none_none`, + `bfcol_2` AS `start_none`, + `bfcol_3` AS `none_end`, + `bfcol_4` AS `start_end` FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_end.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_end.sql deleted file mode 100644 index 78edf662b9..0000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_end.sql +++ /dev/null @@ -1,13 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `string_col` AS `bfcol_0` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_1` AS ( - SELECT - *, - INSTR(SUBSTRING(`bfcol_0`, 1, 5), 'e') - 1 AS `bfcol_1` - FROM `bfcte_0` -) -SELECT - `bfcol_1` AS `string_col` -FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start.sql deleted file mode 100644 index d0dfc11a53..0000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start.sql +++ /dev/null @@ -1,13 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `string_col` AS `bfcol_0` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_1` AS ( - SELECT - *, - INSTR(`bfcol_0`, 'e', 3) - 1 AS `bfcol_1` - FROM `bfcte_0` -) -SELECT - `bfcol_1` AS `string_col` -FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start_and_end.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start_and_end.sql deleted file mode 100644 index a91ab32946..0000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start_and_end.sql +++ /dev/null @@ -1,13 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `string_col` AS `bfcol_0` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_1` AS ( - SELECT - *, - INSTR(SUBSTRING(`bfcol_0`, 3, 3), 'e') - 1 AS `bfcol_1` - FROM `bfcte_0` -) -SELECT - `bfcol_1` AS `string_col` -FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/left.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/left.sql deleted file mode 100644 index ee95900b3e..0000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/left.sql +++ /dev/null @@ -1,13 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `string_col` AS `bfcol_0` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_1` AS ( - SELECT - *, - LPAD(`bfcol_0`, GREATEST(LENGTH(`bfcol_0`), 10), '-') AS `bfcol_1` - FROM `bfcte_0` -) -SELECT - `bfcol_1` AS `string_col` -FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/both.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/out.sql similarity index 63% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/both.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/out.sql index 4701b0237a..4226843122 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/both.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/out.sql @@ -5,6 +5,8 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, + LPAD(`bfcol_0`, GREATEST(LENGTH(`bfcol_0`), 10), '-') AS `bfcol_1`, + RPAD(`bfcol_0`, GREATEST(LENGTH(`bfcol_0`), 10), '-') AS `bfcol_2`, RPAD( LPAD( `bfcol_0`, @@ -13,9 +15,11 @@ WITH `bfcte_0` AS ( ), GREATEST(LENGTH(`bfcol_0`), 10), '-' - ) AS `bfcol_1` + ) AS `bfcol_3` FROM `bfcte_0` ) SELECT - `bfcol_1` AS `string_col` + `bfcol_1` AS `left`, + `bfcol_2` AS `right`, + `bfcol_3` AS `both` FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/right.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/right.sql deleted file mode 100644 index 17e59c553f..0000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/right.sql +++ /dev/null @@ -1,13 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `string_col` AS `bfcol_0` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_1` AS ( - SELECT - *, - RPAD(`bfcol_0`, GREATEST(LENGTH(`bfcol_0`), 10), '-') AS `bfcol_1` - FROM `bfcte_0` -) -SELECT - `bfcol_1` AS `string_col` -FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_struct_field/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_struct_field/out.sql index b3e8fde0b2..60ae78b755 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_struct_field/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_struct_field/out.sql @@ -5,9 +5,11 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - `bfcol_0`.`name` AS `bfcol_1` + `bfcol_0`.`name` AS `bfcol_1`, + `bfcol_0`.`name` AS `bfcol_2` FROM `bfcte_0` ) SELECT - `bfcol_1` AS `people` + `bfcol_1` AS `string`, + `bfcol_2` AS `int` FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py index 8f3af11842..815bb84a9a 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py @@ -12,437 +12,525 @@ # See the License for the specific language governing permissions and # limitations under the License. +import typing + import pytest from bigframes import operations as ops +from bigframes.core import expression as expr from bigframes.operations._op_converters import convert_index, convert_slice import bigframes.pandas as bpd pytest.importorskip("pytest_snapshot") -def _apply_unary_op(obj: bpd.DataFrame, op: ops.UnaryOp, arg: str) -> str: +def _apply_unary_ops( + obj: bpd.DataFrame, + ops_list: typing.Sequence[expr.Expression], + new_names: typing.Sequence[str], +) -> str: array_value = obj._block.expr - op_expr = op.as_expr(arg) - result, col_ids = array_value.compute_values([op_expr]) + result, old_names = array_value.compute_values(ops_list) # Rename columns for deterministic golden SQL results. - assert len(col_ids) == 1 - result = result.rename_columns({col_ids[0]: arg}).select_columns([arg]) + assert len(old_names) == len(new_names) + col_ids = {old_name: new_name for old_name, new_name in zip(old_names, new_names)} + result = result.rename_columns(col_ids).select_columns(new_names) sql = result.session._executor.to_sql(result, enable_cache=False) return sql def test_arccosh(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.arccosh_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.arccosh_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_arccos(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.arccos_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.arccos_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_arcsin(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.arcsin_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.arcsin_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_arcsinh(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.arcsinh_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.arcsinh_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_arctan(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.arctan_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.arctan_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_arctanh(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.arctanh_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.arctanh_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_abs(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.abs_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.abs_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_capitalize(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.capitalize_op, "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.capitalize_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_ceil(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.ceil_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.ceil_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_date(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.date_op, "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.date_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_day(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.day_op, "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.day_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_dayofweek(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.dayofweek_op, "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.dayofweek_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_dayofyear(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.dayofyear_op, "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.dayofyear_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_endswith(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.EndsWithOp(pat=("ab",)), "string_col") - snapshot.assert_match(sql, "single_pattern.sql") - - sql = _apply_unary_op(bf_df, ops.EndsWithOp(pat=("ab", "cd")), "string_col") - snapshot.assert_match(sql, "multiple_patterns.sql") - - sql = _apply_unary_op(bf_df, ops.EndsWithOp(pat=()), "string_col") - snapshot.assert_match(sql, "no_pattern.sql") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + ops_map = { + "single": ops.EndsWithOp(pat=("ab",)).as_expr(col_name), + "double": ops.EndsWithOp(pat=("ab", "cd")).as_expr(col_name), + "empty": ops.EndsWithOp(pat=()).as_expr(col_name), + } + sql = _apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") def test_exp(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.exp_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.exp_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_expm1(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.expm1_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.expm1_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_floor_dt(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.FloorDtOp("D"), "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.FloorDtOp("D").as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_floor(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.floor_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.floor_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_geo_area(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["geography_col"]] - sql = _apply_unary_op(bf_df, ops.geo_area_op, "geography_col") + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.geo_area_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_geo_st_astext(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["geography_col"]] - sql = _apply_unary_op(bf_df, ops.geo_st_astext_op, "geography_col") + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.geo_st_astext_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_geo_st_boundary(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["geography_col"]] - sql = _apply_unary_op(bf_df, ops.geo_st_boundary_op, "geography_col") + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.geo_st_boundary_op.as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_geo_st_buffer(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["geography_col"]] - sql = _apply_unary_op(bf_df, ops.GeoStBufferOp(1.0, 8.0, False), "geography_col") + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.GeoStBufferOp(1.0, 8.0, False).as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_geo_st_centroid(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["geography_col"]] - sql = _apply_unary_op(bf_df, ops.geo_st_centroid_op, "geography_col") + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.geo_st_centroid_op.as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_geo_st_convexhull(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["geography_col"]] - sql = _apply_unary_op(bf_df, ops.geo_st_convexhull_op, "geography_col") + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.geo_st_convexhull_op.as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_geo_st_geogfromtext(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.geo_st_geogfromtext_op, "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.geo_st_geogfromtext_op.as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_geo_st_isclosed(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["geography_col"]] - sql = _apply_unary_op(bf_df, ops.geo_st_isclosed_op, "geography_col") + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.geo_st_isclosed_op.as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_geo_st_length(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["geography_col"]] - sql = _apply_unary_op(bf_df, ops.GeoStLengthOp(True), "geography_col") + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.GeoStLengthOp(True).as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_geo_x(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["geography_col"]] - sql = _apply_unary_op(bf_df, ops.geo_x_op, "geography_col") + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.geo_x_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_geo_y(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["geography_col"]] - sql = _apply_unary_op(bf_df, ops.geo_y_op, "geography_col") + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.geo_y_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_array_to_string(repeated_types_df: bpd.DataFrame, snapshot): - bf_df = repeated_types_df[["string_list_col"]] - sql = _apply_unary_op(bf_df, ops.ArrayToStringOp(delimiter="."), "string_list_col") + col_name = "string_list_col" + bf_df = repeated_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.ArrayToStringOp(delimiter=".").as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_array_index(repeated_types_df: bpd.DataFrame, snapshot): - bf_df = repeated_types_df[["string_list_col"]] - sql = _apply_unary_op(bf_df, convert_index(1), "string_list_col") + col_name = "string_list_col" + bf_df = repeated_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [convert_index(1).as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_array_slice_with_only_start(repeated_types_df: bpd.DataFrame, snapshot): - bf_df = repeated_types_df[["string_list_col"]] - sql = _apply_unary_op(bf_df, convert_slice(slice(1, None)), "string_list_col") + col_name = "string_list_col" + bf_df = repeated_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [convert_slice(slice(1, None)).as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_array_slice_with_start_and_stop(repeated_types_df: bpd.DataFrame, snapshot): - bf_df = repeated_types_df[["string_list_col"]] - sql = _apply_unary_op(bf_df, convert_slice(slice(1, 5)), "string_list_col") + col_name = "string_list_col" + bf_df = repeated_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [convert_slice(slice(1, 5)).as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_cos(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.cos_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.cos_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_cosh(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.cosh_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.cosh_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_hash(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.hash_op, "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.hash_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_hour(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.hour_op, "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.hour_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_invert(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col"]] - sql = _apply_unary_op(bf_df, ops.invert_op, "int64_col") + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.invert_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_is_in(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col"]] - sql = _apply_unary_op(bf_df, ops.IsInOp(values=(1, 2, 3)), "int64_col") + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.IsInOp(values=(1, 2, 3)).as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_isalnum(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.isalnum_op, "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.isalnum_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_isalpha(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.isalpha_op, "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.isalpha_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_isdecimal(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.isdecimal_op, "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.isdecimal_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_isdigit(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.isdigit_op, "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.isdigit_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_islower(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.islower_op, "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.islower_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_isnumeric(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.isnumeric_op, "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.isnumeric_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_isspace(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.isspace_op, "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.isspace_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_isupper(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.isupper_op, "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.isupper_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_len(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.len_op, "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.len_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_ln(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.ln_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.ln_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_log10(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.log10_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.log10_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_log1p(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.log1p_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.log1p_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_lower(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.lower_op, "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.lower_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_map(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op( - bf_df, ops.MapOp(mappings=(("value1", "mapped1"),)), "string_col" + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, + [ops.MapOp(mappings=(("value1", "mapped1"),)).as_expr(col_name)], + [col_name], ) snapshot.assert_match(sql, "out.sql") def test_lstrip(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.StrLstripOp(" "), "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.StrLstripOp(" ").as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_minute(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.minute_op, "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.minute_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_month(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.month_op, "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.month_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_neg(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.neg_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.neg_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_normalize(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.normalize_op, "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.normalize_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -460,257 +548,297 @@ def test_obj_get_access_url(scalar_types_df: bpd.DataFrame, snapshot): def test_pos(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.pos_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.pos_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_quarter(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.quarter_op, "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.quarter_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_replace_str(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.ReplaceStrOp("e", "a"), "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.ReplaceStrOp("e", "a").as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_regex_replace_str(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.RegexReplaceStrOp(r"e", "a"), "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.RegexReplaceStrOp(r"e", "a").as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_reverse(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.reverse_op, "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.reverse_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_second(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.second_op, "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.second_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_rstrip(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.StrRstripOp(" "), "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.StrRstripOp(" ").as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_sqrt(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.sqrt_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.sqrt_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_startswith(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.StartsWithOp(pat=("ab",)), "string_col") - snapshot.assert_match(sql, "single_pattern.sql") - - sql = _apply_unary_op(bf_df, ops.StartsWithOp(pat=("ab", "cd")), "string_col") - snapshot.assert_match(sql, "multiple_patterns.sql") - sql = _apply_unary_op(bf_df, ops.StartsWithOp(pat=()), "string_col") - snapshot.assert_match(sql, "no_pattern.sql") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + ops_map = { + "single": ops.StartsWithOp(pat=("ab",)).as_expr(col_name), + "double": ops.StartsWithOp(pat=("ab", "cd")).as_expr(col_name), + "empty": ops.StartsWithOp(pat=()).as_expr(col_name), + } + sql = _apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") def test_str_get(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.StrGetOp(1), "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.StrGetOp(1).as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_str_pad(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op( - bf_df, ops.StrPadOp(length=10, fillchar="-", side="left"), "string_col" - ) - snapshot.assert_match(sql, "left.sql") - - sql = _apply_unary_op( - bf_df, ops.StrPadOp(length=10, fillchar="-", side="right"), "string_col" - ) - snapshot.assert_match(sql, "right.sql") - - sql = _apply_unary_op( - bf_df, ops.StrPadOp(length=10, fillchar="-", side="both"), "string_col" - ) - snapshot.assert_match(sql, "both.sql") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + ops_map = { + "left": ops.StrPadOp(length=10, fillchar="-", side="left").as_expr(col_name), + "right": ops.StrPadOp(length=10, fillchar="-", side="right").as_expr(col_name), + "both": ops.StrPadOp(length=10, fillchar="-", side="both").as_expr(col_name), + } + sql = _apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") def test_str_slice(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.StrSliceOp(1, 3), "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.StrSliceOp(1, 3).as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_strftime(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.StrftimeOp("%Y-%m-%d"), "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.StrftimeOp("%Y-%m-%d").as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_struct_field(nested_structs_types_df: bpd.DataFrame, snapshot): - bf_df = nested_structs_types_df[["people"]] + col_name = "people" + bf_df = nested_structs_types_df[[col_name]] - # When a name string is provided. - sql = _apply_unary_op(bf_df, ops.StructFieldOp("name"), "people") - snapshot.assert_match(sql, "out.sql") + ops_map = { + # When a name string is provided. + "string": ops.StructFieldOp("name").as_expr(col_name), + # When an index integer is provided. + "int": ops.StructFieldOp(0).as_expr(col_name), + } + sql = _apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) - # When an index integer is provided. - sql = _apply_unary_op(bf_df, ops.StructFieldOp(0), "people") snapshot.assert_match(sql, "out.sql") def test_str_contains(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.StrContainsOp("e"), "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.StrContainsOp("e").as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_str_contains_regex(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.StrContainsRegexOp("e"), "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.StrContainsRegexOp("e").as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_str_extract(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.StrExtractOp(r"([a-z]*)", 1), "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.StrExtractOp(r"([a-z]*)", 1).as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_str_repeat(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.StrRepeatOp(2), "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.StrRepeatOp(2).as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_str_find(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.StrFindOp("e", start=None, end=None), "string_col") - snapshot.assert_match(sql, "out.sql") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + ops_map = { + "none_none": ops.StrFindOp("e", start=None, end=None).as_expr(col_name), + "start_none": ops.StrFindOp("e", start=2, end=None).as_expr(col_name), + "none_end": ops.StrFindOp("e", start=None, end=5).as_expr(col_name), + "start_end": ops.StrFindOp("e", start=2, end=5).as_expr(col_name), + } + sql = _apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) - sql = _apply_unary_op(bf_df, ops.StrFindOp("e", start=2, end=None), "string_col") - snapshot.assert_match(sql, "out_with_start.sql") - - sql = _apply_unary_op(bf_df, ops.StrFindOp("e", start=None, end=5), "string_col") - snapshot.assert_match(sql, "out_with_end.sql") - - sql = _apply_unary_op(bf_df, ops.StrFindOp("e", start=2, end=5), "string_col") - snapshot.assert_match(sql, "out_with_start_and_end.sql") + snapshot.assert_match(sql, "out.sql") def test_strip(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.StrStripOp(" "), "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.StrStripOp(" ").as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_iso_day(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.iso_day_op, "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.iso_day_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_iso_week(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.iso_week_op, "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.iso_week_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_iso_year(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.iso_year_op, "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.iso_year_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_isnull(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.isnull_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.isnull_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_notnull(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.notnull_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.notnull_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_sin(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.sin_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.sin_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_sinh(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.sinh_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.sinh_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_string_split(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.StringSplitOp(pat=","), "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.StringSplitOp(pat=",").as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_tan(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.tan_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.tan_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_tanh(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - sql = _apply_unary_op(bf_df, ops.tanh_op, "float64_col") + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.tanh_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_time(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.time_op, "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.time_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_to_datetime(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col"]] - sql = _apply_unary_op(bf_df, ops.ToDatetimeOp(), "int64_col") + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.ToDatetimeOp().as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_to_timestamp(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col"]] - sql = _apply_unary_op(bf_df, ops.ToTimestampOp(), "int64_col") + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.ToTimestampOp().as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -725,104 +853,133 @@ def test_to_timedelta(scalar_types_df: bpd.DataFrame, snapshot): def test_unix_micros(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.UnixMicros(), "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.UnixMicros().as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_unix_millis(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.UnixMillis(), "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.UnixMillis().as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_unix_seconds(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.UnixSeconds(), "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.UnixSeconds().as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_timedelta_floor(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col"]] - sql = _apply_unary_op(bf_df, ops.timedelta_floor_op, "int64_col") + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.timedelta_floor_op.as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_json_extract(json_types_df: bpd.DataFrame, snapshot): - bf_df = json_types_df[["json_col"]] - sql = _apply_unary_op(bf_df, ops.JSONExtract(json_path="$"), "json_col") + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.JSONExtract(json_path="$").as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_json_extract_array(json_types_df: bpd.DataFrame, snapshot): - bf_df = json_types_df[["json_col"]] - sql = _apply_unary_op(bf_df, ops.JSONExtractArray(json_path="$"), "json_col") + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.JSONExtractArray(json_path="$").as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_json_extract_string_array(json_types_df: bpd.DataFrame, snapshot): - bf_df = json_types_df[["json_col"]] - sql = _apply_unary_op(bf_df, ops.JSONExtractStringArray(json_path="$"), "json_col") + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.JSONExtractStringArray(json_path="$").as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_json_query(json_types_df: bpd.DataFrame, snapshot): - bf_df = json_types_df[["json_col"]] - sql = _apply_unary_op(bf_df, ops.JSONQuery(json_path="$"), "json_col") + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.JSONQuery(json_path="$").as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_json_query_array(json_types_df: bpd.DataFrame, snapshot): - bf_df = json_types_df[["json_col"]] - sql = _apply_unary_op(bf_df, ops.JSONQueryArray(json_path="$"), "json_col") + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.JSONQueryArray(json_path="$").as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_json_value(json_types_df: bpd.DataFrame, snapshot): - bf_df = json_types_df[["json_col"]] - sql = _apply_unary_op(bf_df, ops.JSONValue(json_path="$"), "json_col") + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = _apply_unary_ops( + bf_df, [ops.JSONValue(json_path="$").as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") def test_parse_json(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.ParseJSON(), "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.ParseJSON().as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_to_json_string(json_types_df: bpd.DataFrame, snapshot): - bf_df = json_types_df[["json_col"]] - sql = _apply_unary_op(bf_df, ops.ToJSONString(), "json_col") + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.ToJSONString().as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_upper(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.upper_op, "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.upper_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_year(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col"]] - sql = _apply_unary_op(bf_df, ops.year_op, "timestamp_col") + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.year_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_zfill(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, ops.ZfillOp(width=10), "string_col") + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = _apply_unary_ops(bf_df, [ops.ZfillOp(width=10).as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") From 2d2460650f8e0241d2b860aa915d51122db2509d Mon Sep 17 00:00:00 2001 From: Garrett Wu <6505921+GarrettWu@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:46:13 -0700 Subject: [PATCH 037/313] test: fix blob snippets tests gcs folder wipeout (#2044) --- samples/snippets/conftest.py | 11 +++++++++++ samples/snippets/multimodal_test.py | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/samples/snippets/conftest.py b/samples/snippets/conftest.py index 81595967ec..e19cfbceb4 100644 --- a/samples/snippets/conftest.py +++ b/samples/snippets/conftest.py @@ -63,6 +63,17 @@ def gcs_bucket(storage_client: storage.Client) -> Generator[str, None, None]: blob.delete() +@pytest.fixture(scope="session") +def gcs_bucket_snippets(storage_client: storage.Client) -> Generator[str, None, None]: + bucket_name = "bigframes_blob_test_snippet_with_data_wipeout" + + yield bucket_name + + bucket = storage_client.get_bucket(bucket_name) + for blob in bucket.list_blobs(): + blob.delete() + + @pytest.fixture(autouse=True) def reset_session() -> None: """An autouse fixture ensuring each sample runs in a fresh session. diff --git a/samples/snippets/multimodal_test.py b/samples/snippets/multimodal_test.py index 1ea6a3f0a6..033fead33e 100644 --- a/samples/snippets/multimodal_test.py +++ b/samples/snippets/multimodal_test.py @@ -13,9 +13,9 @@ # limitations under the License. -def test_multimodal_dataframe(gcs_bucket: str) -> None: +def test_multimodal_dataframe(gcs_bucket_snippets: str) -> None: # destination folder must be in a GCS bucket that the BQ connection service account (default or user provided) has write access to. - dst_bucket = f"gs://{gcs_bucket}" + dst_bucket = f"gs://{gcs_bucket_snippets}" # [START bigquery_dataframes_multimodal_dataframe_create] import bigframes From 88115fadbf366bd7bcfa3b7ddd2cd4e6d4ad15e2 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 12:07:09 -0700 Subject: [PATCH 038/313] chore(main): release 2.18.0 (#2023) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 32 +++++++++++++++++++++++ bigframes/version.py | 4 +-- third_party/bigframes_vendored/version.py | 4 +-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc4362cc87..433956da3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,38 @@ [1]: https://pypi.org/project/bigframes/#history +## [2.18.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.17.0...v2.18.0) (2025-09-03) + + +### ⚠ BREAKING CHANGES + +* add `allow_large_results` option to `read_gbq_query`, aligning with `bpd.options.compute.allow_large_results` option ([#1935](https://github.com/googleapis/python-bigquery-dataframes/issues/1935)) + +### Features + +* Add `allow_large_results` option to `read_gbq_query`, aligning with `bpd.options.compute.allow_large_results` option ([#1935](https://github.com/googleapis/python-bigquery-dataframes/issues/1935)) ([a7963fe](https://github.com/googleapis/python-bigquery-dataframes/commit/a7963fe57a0e141debf726f0bc7b0e953ebe9634)) +* Add parameter shuffle for ml.model_selection.train_test_split ([#2030](https://github.com/googleapis/python-bigquery-dataframes/issues/2030)) ([2c72c56](https://github.com/googleapis/python-bigquery-dataframes/commit/2c72c56fb5893eb01d5aec6273d11945c9c532c5)) +* Can pivot unordered, unindexed dataframe ([#2040](https://github.com/googleapis/python-bigquery-dataframes/issues/2040)) ([1a0f710](https://github.com/googleapis/python-bigquery-dataframes/commit/1a0f710ac11418fd71ab3373f3f6002fa581b180)) +* Local date accessor execution support ([#2034](https://github.com/googleapis/python-bigquery-dataframes/issues/2034)) ([7ac6fe1](https://github.com/googleapis/python-bigquery-dataframes/commit/7ac6fe16f7f2c09d2efac6ab813ec841c21baef8)) +* Support args in dataframe apply method ([#2026](https://github.com/googleapis/python-bigquery-dataframes/issues/2026)) ([164c481](https://github.com/googleapis/python-bigquery-dataframes/commit/164c4818bc4ff2990dca16b9f22a798f47e0a60b)) +* Support args in series apply method ([#2013](https://github.com/googleapis/python-bigquery-dataframes/issues/2013)) ([d9d725c](https://github.com/googleapis/python-bigquery-dataframes/commit/d9d725cfbc3dca9e66b460cae4084e25162f2acf)) +* Support callable for dataframe mask method ([#2020](https://github.com/googleapis/python-bigquery-dataframes/issues/2020)) ([9d4504b](https://github.com/googleapis/python-bigquery-dataframes/commit/9d4504be310d38b63515d67c0f60d2e48e68c7b5)) +* Support multi-column assignment for DataFrame ([#2028](https://github.com/googleapis/python-bigquery-dataframes/issues/2028)) ([ba0d23b](https://github.com/googleapis/python-bigquery-dataframes/commit/ba0d23b59c44ba5a46ace8182ad0e0cfc703b3ab)) +* Support string matching in local executor ([#2032](https://github.com/googleapis/python-bigquery-dataframes/issues/2032)) ([c0b54f0](https://github.com/googleapis/python-bigquery-dataframes/commit/c0b54f03849ee3115413670e690e68f3ef10f2ec)) + + +### Bug Fixes + +* Fix scalar op lowering tree walk ([#2029](https://github.com/googleapis/python-bigquery-dataframes/issues/2029)) ([935af10](https://github.com/googleapis/python-bigquery-dataframes/commit/935af107ef98837fb2b81d72185d0b6a9e09fbcf)) +* Read_csv fails when check file size for wildcard gcs files ([#2019](https://github.com/googleapis/python-bigquery-dataframes/issues/2019)) ([b0d620b](https://github.com/googleapis/python-bigquery-dataframes/commit/b0d620bbe8227189bbdc2ba5a913b03c70575296)) +* Resolve the validation issue for other arg in dataframe where method ([#2042](https://github.com/googleapis/python-bigquery-dataframes/issues/2042)) ([8689199](https://github.com/googleapis/python-bigquery-dataframes/commit/8689199aa82212ed300fff592097093812e0290e)) + + +### Performance Improvements + +* Improve axis=1 aggregation performance ([#2036](https://github.com/googleapis/python-bigquery-dataframes/issues/2036)) ([fbb2094](https://github.com/googleapis/python-bigquery-dataframes/commit/fbb209468297a8057d9d49c40e425c3bfdeb92bd)) +* Improve iter_nodes_topo performance using Kahn's algorithm ([#2038](https://github.com/googleapis/python-bigquery-dataframes/issues/2038)) ([3961637](https://github.com/googleapis/python-bigquery-dataframes/commit/39616374bba424996ebeb9a12096bfaf22660b44)) + ## [2.17.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.16.0...v2.17.0) (2025-08-22) diff --git a/bigframes/version.py b/bigframes/version.py index b9aa5d1855..78b6498d2d 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.17.0" +__version__ = "2.18.0" # {x-release-please-start-date} -__release_date__ = "2025-08-22" +__release_date__ = "2025-09-03" # {x-release-please-end} diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index b9aa5d1855..78b6498d2d 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.17.0" +__version__ = "2.18.0" # {x-release-please-start-date} -__release_date__ = "2025-08-22" +__release_date__ = "2025-09-03" # {x-release-please-end} From 425a6917d5442eeb4df486c6eed1fd136bbcedfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Thu, 4 Sep 2025 10:25:22 -0500 Subject: [PATCH 039/313] fix: remove warning for slot_millis_sum (#2047) This warning is no longer relevant when bigframes is used with the latest google-cloud-bigquery. Follow-up to internal issue b/432238967 --- bigframes/session/__init__.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/bigframes/session/__init__.py b/bigframes/session/__init__.py index 432e73159a..df67e64e9e 100644 --- a/bigframes/session/__init__.py +++ b/bigframes/session/__init__.py @@ -345,15 +345,6 @@ def bytes_processed_sum(self): @property def slot_millis_sum(self): """The sum of all slot time used by bigquery jobs in this session.""" - if not bigframes.options._allow_large_results: - msg = bfe.format_message( - "Queries executed with `allow_large_results=False` within the session will not " - "have their slot milliseconds counted in this sum. If you need precise slot " - "milliseconds information, query the `INFORMATION_SCHEMA` tables " - "to get relevant metrics.", - ) - warnings.warn(msg, UserWarning) - return self._metrics.slot_millis @property From 200458d56e10ba16dcd92f293f6cb1c2e367257c Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Thu, 4 Sep 2025 08:26:35 -0700 Subject: [PATCH 040/313] chore: add logging to bigframes.bigquery functions (#2045) * chore: add logging to bigframes.bigquery functions * Add logs in the __init__ file * add comments --- bigframes/bigquery/__init__.py | 70 ++++++++++++++---------- bigframes/bigquery/_operations/search.py | 1 - bigframes/bigquery/_operations/sql.py | 2 - 3 files changed, 40 insertions(+), 33 deletions(-) diff --git a/bigframes/bigquery/__init__.py b/bigframes/bigquery/__init__.py index dbaea57005..32412648d6 100644 --- a/bigframes/bigquery/__init__.py +++ b/bigframes/bigquery/__init__.py @@ -16,6 +16,8 @@ such as array functions: https://cloud.google.com/bigquery/docs/reference/standard-sql/array_functions. """ +import sys + from bigframes.bigquery._operations.approx_agg import approx_top_count from bigframes.bigquery._operations.array import ( array_agg, @@ -52,43 +54,51 @@ from bigframes.bigquery._operations.search import create_vector_index, vector_search from bigframes.bigquery._operations.sql import sql_scalar from bigframes.bigquery._operations.struct import struct +from bigframes.core import log_adapter -__all__ = [ +_functions = [ # approximate aggregate ops - "approx_top_count", + approx_top_count, # array ops - "array_agg", - "array_length", - "array_to_string", + array_agg, + array_length, + array_to_string, # datetime ops - "unix_micros", - "unix_millis", - "unix_seconds", + unix_micros, + unix_millis, + unix_seconds, # geo ops - "st_area", - "st_buffer", - "st_centroid", - "st_convexhull", - "st_difference", - "st_distance", - "st_intersection", - "st_isclosed", - "st_length", + st_area, + st_buffer, + st_centroid, + st_convexhull, + st_difference, + st_distance, + st_intersection, + st_isclosed, + st_length, # json ops - "json_extract", - "json_extract_array", - "json_extract_string_array", - "json_query", - "json_query_array", - "json_set", - "json_value", - "json_value_array", - "parse_json", + json_extract, + json_extract_array, + json_extract_string_array, + json_query, + json_query_array, + json_set, + json_value, + json_value_array, + parse_json, # search ops - "create_vector_index", - "vector_search", + create_vector_index, + vector_search, # sql ops - "sql_scalar", + sql_scalar, # struct ops - "struct", + struct, ] + +__all__ = [f.__name__ for f in _functions] + +_module = sys.modules[__name__] +for f in _functions: + _decorated_object = log_adapter.method_logger(f, custom_base_name="bigquery") + setattr(_module, f.__name__, _decorated_object) diff --git a/bigframes/bigquery/_operations/search.py b/bigframes/bigquery/_operations/search.py index 5063fc9118..c16c2af1a9 100644 --- a/bigframes/bigquery/_operations/search.py +++ b/bigframes/bigquery/_operations/search.py @@ -20,7 +20,6 @@ import google.cloud.bigquery as bigquery -import bigframes.core.sql import bigframes.ml.utils as utils if typing.TYPE_CHECKING: diff --git a/bigframes/bigquery/_operations/sql.py b/bigframes/bigquery/_operations/sql.py index a84c074e01..a2de61fc21 100644 --- a/bigframes/bigquery/_operations/sql.py +++ b/bigframes/bigquery/_operations/sql.py @@ -21,8 +21,6 @@ import google.cloud.bigquery import bigframes.core.compile.sqlglot.sqlglot_ir as sqlglot_ir -import bigframes.core.sql -import bigframes.dataframe import bigframes.dtypes import bigframes.operations import bigframes.series From d5d66c96dcf8c829d3494bcf2b4fe292112bf572 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Thu, 4 Sep 2025 11:15:27 -0700 Subject: [PATCH 041/313] chore: fix ops.ToTimedeltaOp and ops.IsInOp sqlglot compiler (#2039) --- .../sqlglot/expressions/unary_compiler.py | 32 +++++++++++++++++-- .../system/small/engines/test_generic_ops.py | 2 +- .../test_mul_timedelta/out.sql | 2 +- .../test_sub_timedelta/out.sql | 2 +- .../test_unary_compiler/test_is_in/out.sql | 25 +++++++++++++-- .../test_to_timedelta/out.sql | 6 ++-- .../expressions/test_unary_compiler.py | 23 ++++++++++--- 7 files changed, 76 insertions(+), 16 deletions(-) diff --git a/bigframes/core/compile/sqlglot/expressions/unary_compiler.py b/bigframes/core/compile/sqlglot/expressions/unary_compiler.py index 98f1603be7..f519aef70d 100644 --- a/bigframes/core/compile/sqlglot/expressions/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/expressions/unary_compiler.py @@ -27,6 +27,7 @@ import bigframes.core.compile.sqlglot.expressions.constants as constants from bigframes.core.compile.sqlglot.expressions.op_registration import OpRegistration from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.dtypes as dtypes UNARY_OP_REGISTRATION = OpRegistration() @@ -420,7 +421,28 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: @UNARY_OP_REGISTRATION.register(ops.IsInOp) def _(op: ops.IsInOp, expr: TypedExpr) -> sge.Expression: - return sge.In(this=expr.expr, expressions=[sge.convert(v) for v in op.values]) + values = [] + is_numeric_expr = dtypes.is_numeric(expr.dtype) + for value in op.values: + if value is None: + continue + dtype = dtypes.bigframes_type(type(value)) + if expr.dtype == dtype or is_numeric_expr and dtypes.is_numeric(dtype): + values.append(sge.convert(value)) + + if op.match_nulls: + contains_nulls = any(_is_null(value) for value in op.values) + if contains_nulls: + return sge.Is(this=expr.expr, expression=sge.Null()) | sge.In( + this=expr.expr, expressions=values + ) + + if len(values) == 0: + return sge.convert(False) + + return sge.func( + "COALESCE", sge.In(this=expr.expr, expressions=values), sge.convert(False) + ) @UNARY_OP_REGISTRATION.register(ops.isalnum_op) @@ -767,7 +789,7 @@ def _(op: ops.ToTimedeltaOp, expr: TypedExpr) -> sge.Expression: factor = UNIT_TO_US_CONVERSION_FACTORS[op.unit] if factor != 1: value = sge.Mul(this=value, expression=sge.convert(factor)) - return sge.Interval(this=value, unit=sge.Identifier(this="MICROSECOND")) + return value @UNARY_OP_REGISTRATION.register(ops.UnixMicros) @@ -866,3 +888,9 @@ def _(op: ops.ZfillOp, expr: TypedExpr) -> sge.Expression: ], default=sge.func("LPAD", expr.expr, sge.convert(op.width), sge.convert("0")), ) + + +# Helpers +def _is_null(value) -> bool: + # float NaN/inf should be treated as distinct from 'true' null values + return typing.cast(bool, pd.isna(value)) and not isinstance(value, float) diff --git a/tests/system/small/engines/test_generic_ops.py b/tests/system/small/engines/test_generic_ops.py index 8deef3638e..14c6e9a454 100644 --- a/tests/system/small/engines/test_generic_ops.py +++ b/tests/system/small/engines/test_generic_ops.py @@ -392,7 +392,7 @@ def test_engines_invert_op(scalars_array_value: array_value.ArrayValue, engine): assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_isin_op(scalars_array_value: array_value.ArrayValue, engine): arr, col_ids = scalars_array_value.compute_values( [ diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_mul_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_mul_timedelta/out.sql index c8a8cf6cbf..f8752d0a60 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_mul_timedelta/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_mul_timedelta/out.sql @@ -11,7 +11,7 @@ WITH `bfcte_0` AS ( `bfcol_1` AS `bfcol_8`, `bfcol_2` AS `bfcol_9`, `bfcol_0` AS `bfcol_10`, - INTERVAL `bfcol_3` MICROSECOND AS `bfcol_11` + `bfcol_3` AS `bfcol_11` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_sub_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_sub_timedelta/out.sql index 460f941d1b..2d615fcca6 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_sub_timedelta/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_sub_timedelta/out.sql @@ -11,7 +11,7 @@ WITH `bfcte_0` AS ( `bfcol_1` AS `bfcol_8`, `bfcol_2` AS `bfcol_9`, `bfcol_0` AS `bfcol_10`, - INTERVAL `bfcol_3` MICROSECOND AS `bfcol_11` + `bfcol_3` AS `bfcol_11` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_is_in/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_is_in/out.sql index 36941df71b..7a1a2a743d 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_is_in/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_is_in/out.sql @@ -1,13 +1,32 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` AS `bfcol_0`, + `float64_col` AS `bfcol_1` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_0` IN (1, 2, 3) AS `bfcol_1` + COALESCE(`bfcol_0` IN (1, 2, 3), FALSE) AS `bfcol_2`, + ( + `bfcol_0` IS NULL + ) OR `bfcol_0` IN (123456) AS `bfcol_3`, + COALESCE(`bfcol_0` IN (1.0, 2.0, 3.0), FALSE) AS `bfcol_4`, + FALSE AS `bfcol_5`, + COALESCE(`bfcol_0` IN (2.5, 3), FALSE) AS `bfcol_6`, + FALSE AS `bfcol_7`, + COALESCE(`bfcol_0` IN (123456), FALSE) AS `bfcol_8`, + ( + `bfcol_1` IS NULL + ) OR `bfcol_1` IN (1, 2, 3) AS `bfcol_9` FROM `bfcte_0` ) SELECT - `bfcol_1` AS `int64_col` + `bfcol_2` AS `ints`, + `bfcol_3` AS `ints_w_null`, + `bfcol_4` AS `floats`, + `bfcol_5` AS `strings`, + `bfcol_6` AS `mixed`, + `bfcol_7` AS `empty`, + `bfcol_8` AS `ints_wo_match_nulls`, + `bfcol_9` AS `float_in_ints` FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_timedelta/out.sql index 01ebebc455..057e6c778e 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_timedelta/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_timedelta/out.sql @@ -8,7 +8,7 @@ WITH `bfcte_0` AS ( *, `bfcol_1` AS `bfcol_4`, `bfcol_0` AS `bfcol_5`, - INTERVAL `bfcol_0` MICROSECOND AS `bfcol_6` + `bfcol_0` AS `bfcol_6` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT @@ -16,7 +16,7 @@ WITH `bfcte_0` AS ( `bfcol_4` AS `bfcol_10`, `bfcol_5` AS `bfcol_11`, `bfcol_6` AS `bfcol_12`, - INTERVAL (`bfcol_5` * 1000000) MICROSECOND AS `bfcol_13` + `bfcol_5` * 1000000 AS `bfcol_13` FROM `bfcte_1` ), `bfcte_3` AS ( SELECT @@ -25,7 +25,7 @@ WITH `bfcte_0` AS ( `bfcol_11` AS `bfcol_19`, `bfcol_12` AS `bfcol_20`, `bfcol_13` AS `bfcol_21`, - INTERVAL (`bfcol_11` * 604800000000) MICROSECOND AS `bfcol_22` + `bfcol_11` * 604800000000 AS `bfcol_22` FROM `bfcte_2` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py index 815bb84a9a..fced18f5be 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py @@ -370,12 +370,25 @@ def test_invert(scalar_types_df: bpd.DataFrame, snapshot): def test_is_in(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "int64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.IsInOp(values=(1, 2, 3)).as_expr(col_name)], [col_name] - ) + int_col = "int64_col" + float_col = "float64_col" + bf_df = scalar_types_df[[int_col, float_col]] + ops_map = { + "ints": ops.IsInOp(values=(1, 2, 3)).as_expr(int_col), + "ints_w_null": ops.IsInOp(values=(None, 123456)).as_expr(int_col), + "floats": ops.IsInOp(values=(1.0, 2.0, 3.0), match_nulls=False).as_expr( + int_col + ), + "strings": ops.IsInOp(values=("1.0", "2.0")).as_expr(int_col), + "mixed": ops.IsInOp(values=("1.0", 2.5, 3)).as_expr(int_col), + "empty": ops.IsInOp(values=()).as_expr(int_col), + "ints_wo_match_nulls": ops.IsInOp( + values=(None, 123456), match_nulls=False + ).as_expr(int_col), + "float_in_ints": ops.IsInOp(values=(1, 2, 3, None)).as_expr(float_col), + } + sql = _apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") From f7196d1c72a959bf0d68be6bbea2487f0c700d2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Thu, 4 Sep 2025 14:42:28 -0500 Subject: [PATCH 042/313] chore: add total_bytes_processed to PandasBatches (#2052) Fixes internal issue b/443094245 --- bigframes/core/blocks.py | 15 +++++++++++++-- bigframes/session/bq_caching_executor.py | 6 +++++- bigframes/session/direct_gbq_execution.py | 1 + bigframes/session/executor.py | 1 + tests/system/large/test_dataframe_io.py | 9 +++++---- tests/system/small/session/test_read_gbq_colab.py | 3 +++ tests/system/small/test_dataframe_io.py | 7 +++++++ 7 files changed, 35 insertions(+), 7 deletions(-) diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index 07d7e4c45b..597eedcf27 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -102,15 +102,24 @@ class PandasBatches(Iterator[pd.DataFrame]): """Interface for mutable objects with state represented by a block value object.""" def __init__( - self, pandas_batches: Iterator[pd.DataFrame], total_rows: Optional[int] = 0 + self, + pandas_batches: Iterator[pd.DataFrame], + total_rows: Optional[int] = 0, + *, + total_bytes_processed: Optional[int] = 0, ): self._dataframes: Iterator[pd.DataFrame] = pandas_batches self._total_rows: Optional[int] = total_rows + self._total_bytes_processed: Optional[int] = total_bytes_processed @property def total_rows(self) -> Optional[int]: return self._total_rows + @property + def total_bytes_processed(self) -> Optional[int]: + return self._total_bytes_processed + def __next__(self) -> pd.DataFrame: return next(self._dataframes) @@ -721,7 +730,9 @@ def to_pandas_batches( if (total_rows is not None) and (max_results is not None): total_rows = min(total_rows, max_results) - return PandasBatches(dfs, total_rows) + return PandasBatches( + dfs, total_rows, total_bytes_processed=execute_result.total_bytes_processed + ) def _copy_index_to_pandas(self, df: pd.DataFrame) -> pd.DataFrame: """Set the index on pandas DataFrame to match this block.""" diff --git a/bigframes/session/bq_caching_executor.py b/bigframes/session/bq_caching_executor.py index b428cd646c..b7412346bd 100644 --- a/bigframes/session/bq_caching_executor.py +++ b/bigframes/session/bq_caching_executor.py @@ -323,7 +323,10 @@ def _export_gbq( self.bqclient.update_table(table, ["schema"]) return executor.ExecuteResult( - row_iter.to_arrow_iterable(), array_value.schema, query_job + row_iter.to_arrow_iterable(), + array_value.schema, + query_job, + total_bytes_processed=row_iter.total_bytes_processed, ) def dry_run( @@ -671,6 +674,7 @@ def _execute_plan_gbq( query_job=query_job, total_bytes=size_bytes, total_rows=iterator.total_rows, + total_bytes_processed=iterator.total_bytes_processed, ) diff --git a/bigframes/session/direct_gbq_execution.py b/bigframes/session/direct_gbq_execution.py index ff91747a62..7538c9300f 100644 --- a/bigframes/session/direct_gbq_execution.py +++ b/bigframes/session/direct_gbq_execution.py @@ -63,6 +63,7 @@ def execute( schema=plan.schema, query_job=query_job, total_rows=iterator.total_rows, + total_bytes_processed=iterator.total_bytes_processed, ) def _run_execute_query( diff --git a/bigframes/session/executor.py b/bigframes/session/executor.py index 748b10647a..d0cfe5f4f7 100644 --- a/bigframes/session/executor.py +++ b/bigframes/session/executor.py @@ -45,6 +45,7 @@ class ExecuteResult: query_job: Optional[bigquery.QueryJob] = None total_bytes: Optional[int] = None total_rows: Optional[int] = None + total_bytes_processed: Optional[int] = None @property def arrow_batches(self) -> Iterator[pyarrow.RecordBatch]: diff --git a/tests/system/large/test_dataframe_io.py b/tests/system/large/test_dataframe_io.py index 87d2acd34b..c60940109d 100644 --- a/tests/system/large/test_dataframe_io.py +++ b/tests/system/large/test_dataframe_io.py @@ -48,11 +48,12 @@ def test_to_pandas_batches_override_global_option( ): with bigframes.option_context(LARGE_TABLE_OPTION, False): df = session.read_gbq(WIKIPEDIA_TABLE) - pages = list( - df.to_pandas_batches( - page_size=500, max_results=1500, allow_large_results=True - ) + batches = df.sort_values("id").to_pandas_batches( + page_size=500, max_results=1500, allow_large_results=True ) + assert batches.total_rows > 0 + assert batches.total_bytes_processed > 0 + pages = list(batches) assert all((len(page) <= 500) for page in pages) assert sum(len(page) for page in pages) == 1500 diff --git a/tests/system/small/session/test_read_gbq_colab.py b/tests/system/small/session/test_read_gbq_colab.py index 9ace2dbed7..6d3cf6fe88 100644 --- a/tests/system/small/session/test_read_gbq_colab.py +++ b/tests/system/small/session/test_read_gbq_colab.py @@ -48,6 +48,9 @@ def test_read_gbq_colab_to_pandas_batches_preserves_order_by(maybe_ordered_sessi batches = df.to_pandas_batches( page_size=100, ) + assert batches.total_rows > 0 + assert batches.total_bytes_processed is None # No additional query. + executions_after = maybe_ordered_session._metrics.execution_count num_batches = 0 diff --git a/tests/system/small/test_dataframe_io.py b/tests/system/small/test_dataframe_io.py index 1d6ae370c5..96d7881d67 100644 --- a/tests/system/small/test_dataframe_io.py +++ b/tests/system/small/test_dataframe_io.py @@ -339,6 +339,13 @@ def test_to_arrow_override_global_option(scalars_df_index): assert scalars_df_index._query_job.destination.table_id == table_id +def test_to_pandas_batches_populates_total_bytes_processed(scalars_df_default_index): + batches = scalars_df_default_index.sort_values( + "int64_col" + ).to_pandas_batches() # Do a sort to force query execution. + assert batches.total_bytes_processed > 0 + + def test_to_pandas_batches_w_correct_dtypes(scalars_df_default_index): """Verify to_pandas_batches() APIs returns the expected dtypes.""" expected = scalars_df_default_index.dtypes From a3edeabd90118ebf43a349a772a7a658d6856cc4 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 4 Sep 2025 12:50:28 -0700 Subject: [PATCH 043/313] refactor: Aggregation is now an expression subclass (#2048) --- bigframes/core/agg_expressions.py | 151 ++++++++++++++++++ bigframes/core/array_value.py | 9 +- bigframes/core/bigframe_node.py | 9 +- bigframes/core/block_transforms.py | 22 +-- bigframes/core/blocks.py | 36 +++-- bigframes/core/compile/compiled.py | 11 +- .../ibis_compiler/aggregate_compiler.py | 18 +-- bigframes/core/compile/polars/compiler.py | 24 +-- .../compile/sqlglot/aggregate_compiler.py | 18 +-- bigframes/core/expression.py | 113 ------------- bigframes/core/groupby/aggs.py | 8 +- bigframes/core/groupby/dataframe_group_by.py | 5 +- bigframes/core/indexes/base.py | 9 +- bigframes/core/nodes.py | 15 +- bigframes/core/rewrite/order.py | 12 +- bigframes/core/rewrite/schema_binding.py | 16 +- bigframes/core/rewrite/timedeltas.py | 19 +-- bigframes/dataframe.py | 12 +- bigframes/operations/aggregations.py | 46 +++++- bigframes/series.py | 12 +- bigframes/session/polars_executor.py | 11 +- .../system/small/engines/test_aggregation.py | 12 +- tests/system/small/engines/test_windowing.py | 13 +- .../aggregations/test_unary_compiler.py | 4 +- 24 files changed, 360 insertions(+), 245 deletions(-) create mode 100644 bigframes/core/agg_expressions.py diff --git a/bigframes/core/agg_expressions.py b/bigframes/core/agg_expressions.py new file mode 100644 index 0000000000..f77525706b --- /dev/null +++ b/bigframes/core/agg_expressions.py @@ -0,0 +1,151 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import abc +import dataclasses +import functools +import itertools +import typing +from typing import Callable, Mapping, TypeVar + +from bigframes import dtypes +from bigframes.core import expression +import bigframes.core.identifiers as ids +import bigframes.operations.aggregations as agg_ops + +TExpression = TypeVar("TExpression", bound="Aggregation") + + +@dataclasses.dataclass(frozen=True) +class Aggregation(expression.Expression): + """Represents windowing or aggregation over a column.""" + + op: agg_ops.WindowOp = dataclasses.field() + + @property + def column_references(self) -> typing.Tuple[ids.ColumnId, ...]: + return tuple( + itertools.chain.from_iterable( + map(lambda x: x.column_references, self.inputs) + ) + ) + + @functools.cached_property + def is_resolved(self) -> bool: + return all(input.is_resolved for input in self.inputs) + + @functools.cached_property + def output_type(self) -> dtypes.ExpressionType: + if not self.is_resolved: + raise ValueError(f"Type of expression {self.op} has not been fixed.") + + input_types = [input.output_type for input in self.inputs] + + return self.op.output_type(*input_types) + + @property + @abc.abstractmethod + def inputs( + self, + ) -> typing.Tuple[expression.Expression, ...]: + ... + + @property + def free_variables(self) -> typing.Tuple[str, ...]: + return tuple( + itertools.chain.from_iterable(map(lambda x: x.free_variables, self.inputs)) + ) + + @property + def is_const(self) -> bool: + return all(child.is_const for child in self.inputs) + + @abc.abstractmethod + def replace_args(self: TExpression, *arg) -> TExpression: + ... + + def transform_children( + self: TExpression, t: Callable[[expression.Expression], expression.Expression] + ) -> TExpression: + return self.replace_args(*(t(arg) for arg in self.inputs)) + + def bind_variables( + self: TExpression, + bindings: Mapping[str, expression.Expression], + allow_partial_bindings: bool = False, + ) -> TExpression: + return self.transform_children( + lambda x: x.bind_variables(bindings, allow_partial_bindings) + ) + + def bind_refs( + self: TExpression, + bindings: Mapping[ids.ColumnId, expression.Expression], + allow_partial_bindings: bool = False, + ) -> TExpression: + return self.transform_children( + lambda x: x.bind_refs(bindings, allow_partial_bindings) + ) + + +@dataclasses.dataclass(frozen=True) +class NullaryAggregation(Aggregation): + op: agg_ops.NullaryWindowOp = dataclasses.field() + + @property + def inputs( + self, + ) -> typing.Tuple[expression.Expression, ...]: + return () + + def replace_args(self, *arg) -> NullaryAggregation: + return self + + +@dataclasses.dataclass(frozen=True) +class UnaryAggregation(Aggregation): + op: agg_ops.UnaryWindowOp + arg: expression.Expression + + @property + def inputs( + self, + ) -> typing.Tuple[expression.Expression, ...]: + return (self.arg,) + + def replace_args(self, arg: expression.Expression) -> UnaryAggregation: + return UnaryAggregation( + self.op, + arg, + ) + + +@dataclasses.dataclass(frozen=True) +class BinaryAggregation(Aggregation): + op: agg_ops.BinaryAggregateOp = dataclasses.field() + left: expression.Expression = dataclasses.field() + right: expression.Expression = dataclasses.field() + + @property + def inputs( + self, + ) -> typing.Tuple[expression.Expression, ...]: + return (self.left, self.right) + + def replace_args( + self, larg: expression.Expression, rarg: expression.Expression + ) -> BinaryAggregation: + return BinaryAggregation(self.op, larg, rarg) diff --git a/bigframes/core/array_value.py b/bigframes/core/array_value.py index b47637cb59..b37c581a4a 100644 --- a/bigframes/core/array_value.py +++ b/bigframes/core/array_value.py @@ -24,6 +24,7 @@ import pandas import pyarrow as pa +from bigframes.core import agg_expressions import bigframes.core.expression as ex import bigframes.core.guid import bigframes.core.identifiers as ids @@ -190,7 +191,7 @@ def row_count(self) -> ArrayValue: child=self.node, aggregations=( ( - ex.NullaryAggregation(agg_ops.size_op), + agg_expressions.NullaryAggregation(agg_ops.size_op), ids.ColumnId(bigframes.core.guid.generate_guid()), ), ), @@ -379,7 +380,7 @@ def drop_columns(self, columns: Iterable[str]) -> ArrayValue: def aggregate( self, - aggregations: typing.Sequence[typing.Tuple[ex.Aggregation, str]], + aggregations: typing.Sequence[typing.Tuple[agg_expressions.Aggregation, str]], by_column_ids: typing.Sequence[str] = (), dropna: bool = True, ) -> ArrayValue: @@ -420,7 +421,7 @@ def project_window_op( """ return self.project_window_expr( - ex.UnaryAggregation(op, ex.deref(column_name)), + agg_expressions.UnaryAggregation(op, ex.deref(column_name)), window_spec, never_skip_nulls, skip_reproject_unsafe, @@ -428,7 +429,7 @@ def project_window_op( def project_window_expr( self, - expression: ex.Aggregation, + expression: agg_expressions.Aggregation, window: WindowSpec, never_skip_nulls=False, skip_reproject_unsafe: bool = False, diff --git a/bigframes/core/bigframe_node.py b/bigframes/core/bigframe_node.py index 0c6f56f35a..7e40248a00 100644 --- a/bigframes/core/bigframe_node.py +++ b/bigframes/core/bigframe_node.py @@ -20,15 +20,12 @@ import functools import itertools import typing -from typing import Callable, Dict, Generator, Iterable, Mapping, Sequence, Tuple, Union +from typing import Callable, Dict, Generator, Iterable, Mapping, Sequence, Tuple from bigframes.core import expression, field, identifiers import bigframes.core.schema as schemata import bigframes.dtypes -if typing.TYPE_CHECKING: - import bigframes.session - COLUMN_SET = frozenset[identifiers.ColumnId] T = typing.TypeVar("T") @@ -281,8 +278,8 @@ def field_by_id(self) -> Mapping[identifiers.ColumnId, field.Field]: @property def _node_expressions( self, - ) -> Sequence[Union[expression.Expression, expression.Aggregation]]: - """List of scalar expressions. Intended for checking engine compatibility with used ops.""" + ) -> Sequence[expression.Expression]: + """List of expressions. Intended for checking engine compatibility with used ops.""" return () # Plan algorithms diff --git a/bigframes/core/block_transforms.py b/bigframes/core/block_transforms.py index 465728b0ef..279643b91d 100644 --- a/bigframes/core/block_transforms.py +++ b/bigframes/core/block_transforms.py @@ -21,12 +21,12 @@ import pandas as pd import bigframes.constants +from bigframes.core import agg_expressions import bigframes.core as core import bigframes.core.blocks as blocks import bigframes.core.expression as ex import bigframes.core.ordering as ordering import bigframes.core.window_spec as windows -import bigframes.dtypes import bigframes.dtypes as dtypes import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops @@ -133,7 +133,7 @@ def quantile( block, _ = block.aggregate( grouping_column_ids, tuple( - ex.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(col)) + agg_expressions.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(col)) for col in quantile_cols ), column_labels=pd.Index(labels), @@ -363,7 +363,7 @@ def value_counts( block = dropna(block, columns, how="any") block, agg_ids = block.aggregate( by_column_ids=(*grouping_keys, *columns), - aggregations=[ex.NullaryAggregation(agg_ops.size_op)], + aggregations=[agg_expressions.NullaryAggregation(agg_ops.size_op)], dropna=drop_na and not grouping_keys, ) count_id = agg_ids[0] @@ -647,15 +647,15 @@ def skew( # counts, moment3 for each column aggregations = [] for i, col in enumerate(original_columns): - count_agg = ex.UnaryAggregation( + count_agg = agg_expressions.UnaryAggregation( agg_ops.count_op, ex.deref(col), ) - moment3_agg = ex.UnaryAggregation( + moment3_agg = agg_expressions.UnaryAggregation( agg_ops.mean_op, ex.deref(delta3_ids[i]), ) - variance_agg = ex.UnaryAggregation( + variance_agg = agg_expressions.UnaryAggregation( agg_ops.PopVarOp(), ex.deref(col), ) @@ -698,9 +698,13 @@ def kurt( # counts, moment4 for each column aggregations = [] for i, col in enumerate(original_columns): - count_agg = ex.UnaryAggregation(agg_ops.count_op, ex.deref(col)) - moment4_agg = ex.UnaryAggregation(agg_ops.mean_op, ex.deref(delta4_ids[i])) - variance_agg = ex.UnaryAggregation(agg_ops.PopVarOp(), ex.deref(col)) + count_agg = agg_expressions.UnaryAggregation(agg_ops.count_op, ex.deref(col)) + moment4_agg = agg_expressions.UnaryAggregation( + agg_ops.mean_op, ex.deref(delta4_ids[i]) + ) + variance_agg = agg_expressions.UnaryAggregation( + agg_ops.PopVarOp(), ex.deref(col) + ) aggregations.extend([count_agg, moment4_agg, variance_agg]) block, agg_ids = block.aggregate( diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index 597eedcf27..d62173b7d6 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -51,8 +51,9 @@ from bigframes import session from bigframes._config import sampling_options import bigframes.constants -from bigframes.core import local_data +from bigframes.core import agg_expressions, local_data import bigframes.core as core +import bigframes.core.agg_expressions as ex_types import bigframes.core.compile.googlesql as googlesql import bigframes.core.expression as ex import bigframes.core.expression as scalars @@ -1154,7 +1155,7 @@ def apply_window_op( skip_reproject_unsafe: bool = False, never_skip_nulls: bool = False, ) -> typing.Tuple[Block, str]: - agg_expr = ex.UnaryAggregation(op, ex.deref(column)) + agg_expr = agg_expressions.UnaryAggregation(op, ex.deref(column)) return self.apply_analytic( agg_expr, window_spec, @@ -1166,7 +1167,7 @@ def apply_window_op( def apply_analytic( self, - agg_expr: ex.Aggregation, + agg_expr: agg_expressions.Aggregation, window: windows.WindowSpec, result_label: Label, *, @@ -1259,9 +1260,9 @@ def aggregate_all_and_stack( if axis_n == 0: aggregations = [ ( - ex.UnaryAggregation(operation, ex.deref(col_id)) + agg_expressions.UnaryAggregation(operation, ex.deref(col_id)) if isinstance(operation, agg_ops.UnaryAggregateOp) - else ex.NullaryAggregation(operation), + else agg_expressions.NullaryAggregation(operation), col_id, ) for col_id in self.value_columns @@ -1290,7 +1291,10 @@ def aggregate_size( ): """Returns a block object to compute the size(s) of groups.""" agg_specs = [ - (ex.NullaryAggregation(agg_ops.SizeOp()), guid.generate_guid()), + ( + agg_expressions.NullaryAggregation(agg_ops.SizeOp()), + guid.generate_guid(), + ), ] output_col_ids = [agg_spec[1] for agg_spec in agg_specs] result_expr = self.expr.aggregate(agg_specs, by_column_ids, dropna=dropna) @@ -1361,7 +1365,7 @@ def remap_f(x): def aggregate( self, by_column_ids: typing.Sequence[str] = (), - aggregations: typing.Sequence[ex.Aggregation] = (), + aggregations: typing.Sequence[agg_expressions.Aggregation] = (), column_labels: Optional[pd.Index] = None, *, dropna: bool = True, @@ -1430,9 +1434,9 @@ def get_stat( aggregations = [ ( - ex.UnaryAggregation(stat, ex.deref(column_id)) + agg_expressions.UnaryAggregation(stat, ex.deref(column_id)) if isinstance(stat, agg_ops.UnaryAggregateOp) - else ex.NullaryAggregation(stat), + else agg_expressions.NullaryAggregation(stat), stat.name, ) for stat in stats_to_fetch @@ -1458,7 +1462,7 @@ def get_binary_stat( # TODO(kemppeterson): Add a cache here. aggregations = [ ( - ex.BinaryAggregation( + agg_expressions.BinaryAggregation( stat, ex.deref(column_id_left), ex.deref(column_id_right) ), f"{stat.name}_{column_id_left}{column_id_right}", @@ -1485,9 +1489,9 @@ def summarize( labels = pd.Index([stat.name for stat in stats]) aggregations = [ ( - ex.UnaryAggregation(stat, ex.deref(col_id)) + agg_expressions.UnaryAggregation(stat, ex.deref(col_id)) if isinstance(stat, agg_ops.UnaryAggregateOp) - else ex.NullaryAggregation(stat), + else agg_expressions.NullaryAggregation(stat), f"{col_id}-{stat.name}", ) for stat in stats @@ -1761,7 +1765,7 @@ def pivot( block = block.select_columns(column_ids) aggregations = [ - ex.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(col_id)) + agg_expressions.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(col_id)) for col_id in column_ids ] result_block, _ = block.aggregate( @@ -2029,7 +2033,7 @@ def _generate_resample_label( agg_specs = [ ( - ex.UnaryAggregation(agg_ops.min_op, ex.deref(col_id)), + agg_expressions.UnaryAggregation(agg_ops.min_op, ex.deref(col_id)), guid.generate_guid(), ), ] @@ -2058,13 +2062,13 @@ def _generate_resample_label( # Generate integer label sequence. min_agg_specs = [ ( - ex.UnaryAggregation(agg_ops.min_op, ex.deref(label_col_id)), + ex_types.UnaryAggregation(agg_ops.min_op, ex.deref(label_col_id)), guid.generate_guid(), ), ] max_agg_specs = [ ( - ex.UnaryAggregation(agg_ops.max_op, ex.deref(label_col_id)), + ex_types.UnaryAggregation(agg_ops.max_op, ex.deref(label_col_id)), guid.generate_guid(), ), ] diff --git a/bigframes/core/compile/compiled.py b/bigframes/core/compile/compiled.py index f7de5c051a..b28880d498 100644 --- a/bigframes/core/compile/compiled.py +++ b/bigframes/core/compile/compiled.py @@ -30,6 +30,7 @@ import pyarrow as pa from bigframes.core import utils +import bigframes.core.agg_expressions as ex_types import bigframes.core.compile.googlesql import bigframes.core.compile.ibis_compiler.aggregate_compiler as agg_compiler import bigframes.core.compile.ibis_compiler.scalar_op_compiler as op_compilers @@ -215,7 +216,7 @@ def filter(self, predicate: ex.Expression) -> UnorderedIR: def aggregate( self, - aggregations: typing.Sequence[tuple[ex.Aggregation, str]], + aggregations: typing.Sequence[tuple[ex_types.Aggregation, str]], by_column_ids: typing.Sequence[ex.DerefOp] = (), order_by: typing.Sequence[OrderingExpression] = (), ) -> UnorderedIR: @@ -401,7 +402,7 @@ def isin_join( def project_window_op( self, - expression: ex.Aggregation, + expression: ex_types.Aggregation, window_spec: WindowSpec, output_name: str, *, @@ -467,7 +468,9 @@ def project_window_op( lambda x, y: x & y, per_col_does_count ).cast(int) observation_count = agg_compiler.compile_analytic( - ex.UnaryAggregation(agg_ops.sum_op, ex.deref("_observation_count")), + ex_types.UnaryAggregation( + agg_ops.sum_op, ex.deref("_observation_count") + ), window, bindings={"_observation_count": is_observation}, ) @@ -476,7 +479,7 @@ def project_window_op( # notnull is just used to convert null values to non-null (FALSE) values to be counted is_observation = inputs[0].notnull() observation_count = agg_compiler.compile_analytic( - ex.UnaryAggregation( + ex_types.UnaryAggregation( agg_ops.count_op, ex.deref("_observation_count") ), window, diff --git a/bigframes/core/compile/ibis_compiler/aggregate_compiler.py b/bigframes/core/compile/ibis_compiler/aggregate_compiler.py index 291db44524..5e9cba7f8c 100644 --- a/bigframes/core/compile/ibis_compiler/aggregate_compiler.py +++ b/bigframes/core/compile/ibis_compiler/aggregate_compiler.py @@ -26,10 +26,10 @@ import bigframes_vendored.ibis.expr.types as ibis_types import pandas as pd +from bigframes.core import agg_expressions from bigframes.core.compile import constants as compiler_constants import bigframes.core.compile.ibis_compiler.scalar_op_compiler as scalar_compilers import bigframes.core.compile.ibis_types as compile_ibis_types -import bigframes.core.expression as ex import bigframes.core.window_spec as window_spec import bigframes.operations.aggregations as agg_ops @@ -48,19 +48,19 @@ def approx_quantiles(expression: float, number) -> List[float]: def compile_aggregate( - aggregate: ex.Aggregation, + aggregate: agg_expressions.Aggregation, bindings: typing.Dict[str, ibis_types.Value], order_by: typing.Sequence[ibis_types.Value] = [], ) -> ibis_types.Value: - if isinstance(aggregate, ex.NullaryAggregation): + if isinstance(aggregate, agg_expressions.NullaryAggregation): return compile_nullary_agg(aggregate.op) - if isinstance(aggregate, ex.UnaryAggregation): + if isinstance(aggregate, agg_expressions.UnaryAggregation): input = scalar_compiler.compile_expression(aggregate.arg, bindings=bindings) if not aggregate.op.order_independent: return compile_ordered_unary_agg(aggregate.op, input, order_by=order_by) # type: ignore else: return compile_unary_agg(aggregate.op, input) # type: ignore - elif isinstance(aggregate, ex.BinaryAggregation): + elif isinstance(aggregate, agg_expressions.BinaryAggregation): left = scalar_compiler.compile_expression(aggregate.left, bindings=bindings) right = scalar_compiler.compile_expression(aggregate.right, bindings=bindings) return compile_binary_agg(aggregate.op, left, right) # type: ignore @@ -69,16 +69,16 @@ def compile_aggregate( def compile_analytic( - aggregate: ex.Aggregation, + aggregate: agg_expressions.Aggregation, window: window_spec.WindowSpec, bindings: typing.Dict[str, ibis_types.Value], ) -> ibis_types.Value: - if isinstance(aggregate, ex.NullaryAggregation): + if isinstance(aggregate, agg_expressions.NullaryAggregation): return compile_nullary_agg(aggregate.op, window) - elif isinstance(aggregate, ex.UnaryAggregation): + elif isinstance(aggregate, agg_expressions.UnaryAggregation): input = scalar_compiler.compile_expression(aggregate.arg, bindings=bindings) return compile_unary_agg(aggregate.op, input, window) # type: ignore - elif isinstance(aggregate, ex.BinaryAggregation): + elif isinstance(aggregate, agg_expressions.BinaryAggregation): raise NotImplementedError("binary analytic operations not yet supported") else: raise ValueError(f"Unexpected analytic operation: {aggregate}") diff --git a/bigframes/core/compile/polars/compiler.py b/bigframes/core/compile/polars/compiler.py index 70fa516e51..df84f08852 100644 --- a/bigframes/core/compile/polars/compiler.py +++ b/bigframes/core/compile/polars/compiler.py @@ -22,7 +22,7 @@ import pandas as pd import bigframes.core -from bigframes.core import identifiers, nodes, ordering, window_spec +from bigframes.core import agg_expressions, identifiers, nodes, ordering, window_spec from bigframes.core.compile.polars import lowering import bigframes.core.expression as ex import bigframes.core.guid as guid @@ -443,15 +443,15 @@ class PolarsAggregateCompiler: def get_args( self, - agg: ex.Aggregation, + agg: agg_expressions.Aggregation, ) -> Sequence[pl.Expr]: """Prepares arguments for aggregation by compiling them.""" - if isinstance(agg, ex.NullaryAggregation): + if isinstance(agg, agg_expressions.NullaryAggregation): return [] - elif isinstance(agg, ex.UnaryAggregation): + elif isinstance(agg, agg_expressions.UnaryAggregation): arg = self.scalar_compiler.compile_expression(agg.arg) return [arg] - elif isinstance(agg, ex.BinaryAggregation): + elif isinstance(agg, agg_expressions.BinaryAggregation): larg = self.scalar_compiler.compile_expression(agg.left) rarg = self.scalar_compiler.compile_expression(agg.right) return [larg, rarg] @@ -460,13 +460,13 @@ def get_args( f"Aggregation {agg} not yet supported in polars engine." ) - def compile_agg_expr(self, expr: ex.Aggregation): - if isinstance(expr, ex.NullaryAggregation): + def compile_agg_expr(self, expr: agg_expressions.Aggregation): + if isinstance(expr, agg_expressions.NullaryAggregation): inputs: Tuple = () - elif isinstance(expr, ex.UnaryAggregation): + elif isinstance(expr, agg_expressions.UnaryAggregation): assert isinstance(expr.arg, ex.DerefOp) inputs = (expr.arg.id.sql,) - elif isinstance(expr, ex.BinaryAggregation): + elif isinstance(expr, agg_expressions.BinaryAggregation): assert isinstance(expr.left, ex.DerefOp) assert isinstance(expr.right, ex.DerefOp) inputs = ( @@ -769,7 +769,9 @@ def compile_agg(self, node: nodes.AggregateNode): def _aggregate( self, df: pl.LazyFrame, - aggregations: Sequence[Tuple[ex.Aggregation, identifiers.ColumnId]], + aggregations: Sequence[ + Tuple[agg_expressions.Aggregation, identifiers.ColumnId] + ], grouping_keys: Tuple[ex.DerefOp, ...], ) -> pl.LazyFrame: # Need to materialize columns to broadcast constants @@ -858,7 +860,7 @@ def compile_window(self, node: nodes.WindowOpNode): def _calc_row_analytic_func( self, frame: pl.LazyFrame, - agg_expr: ex.Aggregation, + agg_expr: agg_expressions.Aggregation, window: window_spec.WindowSpec, name: str, ) -> pl.LazyFrame: diff --git a/bigframes/core/compile/sqlglot/aggregate_compiler.py b/bigframes/core/compile/sqlglot/aggregate_compiler.py index 52ef4cc26c..ccfba1ce0f 100644 --- a/bigframes/core/compile/sqlglot/aggregate_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregate_compiler.py @@ -15,7 +15,7 @@ import sqlglot.expressions as sge -from bigframes.core import expression, window_spec +from bigframes.core import agg_expressions, window_spec from bigframes.core.compile.sqlglot.aggregations import ( binary_compiler, nullary_compiler, @@ -27,13 +27,13 @@ def compile_aggregate( - aggregate: expression.Aggregation, + aggregate: agg_expressions.Aggregation, order_by: tuple[sge.Expression, ...], ) -> sge.Expression: """Compiles BigFrames aggregation expression into SQLGlot expression.""" - if isinstance(aggregate, expression.NullaryAggregation): + if isinstance(aggregate, agg_expressions.NullaryAggregation): return nullary_compiler.compile(aggregate.op) - if isinstance(aggregate, expression.UnaryAggregation): + if isinstance(aggregate, agg_expressions.UnaryAggregation): column = typed_expr.TypedExpr( scalar_compiler.compile_scalar_expression(aggregate.arg), aggregate.arg.output_type, @@ -44,7 +44,7 @@ def compile_aggregate( ) else: return unary_compiler.compile(aggregate.op, column) - elif isinstance(aggregate, expression.BinaryAggregation): + elif isinstance(aggregate, agg_expressions.BinaryAggregation): left = typed_expr.TypedExpr( scalar_compiler.compile_scalar_expression(aggregate.left), aggregate.left.output_type, @@ -59,18 +59,18 @@ def compile_aggregate( def compile_analytic( - aggregate: expression.Aggregation, + aggregate: agg_expressions.Aggregation, window: window_spec.WindowSpec, ) -> sge.Expression: - if isinstance(aggregate, expression.NullaryAggregation): + if isinstance(aggregate, agg_expressions.NullaryAggregation): return nullary_compiler.compile(aggregate.op) - if isinstance(aggregate, expression.UnaryAggregation): + if isinstance(aggregate, agg_expressions.UnaryAggregation): column = typed_expr.TypedExpr( scalar_compiler.compile_scalar_expression(aggregate.arg), aggregate.arg.output_type, ) return unary_compiler.compile(aggregate.op, column, window) - elif isinstance(aggregate, expression.BinaryAggregation): + elif isinstance(aggregate, agg_expressions.BinaryAggregation): raise NotImplementedError("binary analytic operations not yet supported") else: raise ValueError(f"Unexpected analytic operation: {aggregate}") diff --git a/bigframes/core/expression.py b/bigframes/core/expression.py index 0e94193bd3..59679f1bc4 100644 --- a/bigframes/core/expression.py +++ b/bigframes/core/expression.py @@ -27,7 +27,6 @@ from bigframes.core import field import bigframes.core.identifiers as ids import bigframes.operations -import bigframes.operations.aggregations as agg_ops def const( @@ -44,118 +43,6 @@ def free_var(id: str) -> UnboundVariableExpression: return UnboundVariableExpression(id) -@dataclasses.dataclass(frozen=True) -class Aggregation(abc.ABC): - """Represents windowing or aggregation over a column.""" - - op: agg_ops.WindowOp = dataclasses.field() - - @abc.abstractmethod - def output_type( - self, input_fields: Mapping[ids.ColumnId, field.Field] - ) -> dtypes.ExpressionType: - ... - - @property - def column_references(self) -> typing.Tuple[ids.ColumnId, ...]: - return () - - @abc.abstractmethod - def remap_column_refs( - self, - name_mapping: Mapping[ids.ColumnId, ids.ColumnId], - allow_partial_bindings: bool = False, - ) -> Aggregation: - ... - - -@dataclasses.dataclass(frozen=True) -class NullaryAggregation(Aggregation): - op: agg_ops.NullaryWindowOp = dataclasses.field() - - def output_type( - self, input_fields: Mapping[ids.ColumnId, field.Field] - ) -> dtypes.ExpressionType: - return self.op.output_type() - - def remap_column_refs( - self, - name_mapping: Mapping[ids.ColumnId, ids.ColumnId], - allow_partial_bindings: bool = False, - ) -> NullaryAggregation: - return self - - -@dataclasses.dataclass(frozen=True) -class UnaryAggregation(Aggregation): - op: agg_ops.UnaryWindowOp - arg: Union[DerefOp, ScalarConstantExpression] - - def output_type( - self, input_fields: Mapping[ids.ColumnId, field.Field] - ) -> dtypes.ExpressionType: - # TODO(b/419300717) Remove resolutions once defers are cleaned up. - resolved_expr = bind_schema_fields(self.arg, input_fields) - assert resolved_expr.is_resolved - - return self.op.output_type(resolved_expr.output_type) - - @property - def column_references(self) -> typing.Tuple[ids.ColumnId, ...]: - return self.arg.column_references - - def remap_column_refs( - self, - name_mapping: Mapping[ids.ColumnId, ids.ColumnId], - allow_partial_bindings: bool = False, - ) -> UnaryAggregation: - return UnaryAggregation( - self.op, - self.arg.remap_column_refs( - name_mapping, allow_partial_bindings=allow_partial_bindings - ), - ) - - -@dataclasses.dataclass(frozen=True) -class BinaryAggregation(Aggregation): - op: agg_ops.BinaryAggregateOp = dataclasses.field() - left: Union[DerefOp, ScalarConstantExpression] = dataclasses.field() - right: Union[DerefOp, ScalarConstantExpression] = dataclasses.field() - - def output_type( - self, input_fields: Mapping[ids.ColumnId, field.Field] - ) -> dtypes.ExpressionType: - # TODO(b/419300717) Remove resolutions once defers are cleaned up. - left_resolved_expr = bind_schema_fields(self.left, input_fields) - assert left_resolved_expr.is_resolved - right_resolved_expr = bind_schema_fields(self.right, input_fields) - assert right_resolved_expr.is_resolved - - return self.op.output_type( - left_resolved_expr.output_type, left_resolved_expr.output_type - ) - - @property - def column_references(self) -> typing.Tuple[ids.ColumnId, ...]: - return (*self.left.column_references, *self.right.column_references) - - def remap_column_refs( - self, - name_mapping: Mapping[ids.ColumnId, ids.ColumnId], - allow_partial_bindings: bool = False, - ) -> BinaryAggregation: - return BinaryAggregation( - self.op, - self.left.remap_column_refs( - name_mapping, allow_partial_bindings=allow_partial_bindings - ), - self.right.remap_column_refs( - name_mapping, allow_partial_bindings=allow_partial_bindings - ), - ) - - TExpression = TypeVar("TExpression", bound="Expression") diff --git a/bigframes/core/groupby/aggs.py b/bigframes/core/groupby/aggs.py index 26257cc9b6..9d8b957d54 100644 --- a/bigframes/core/groupby/aggs.py +++ b/bigframes/core/groupby/aggs.py @@ -14,13 +14,13 @@ from __future__ import annotations -from bigframes.core import expression +from bigframes.core import agg_expressions, expression from bigframes.operations import aggregations as agg_ops -def agg(input: str, op: agg_ops.AggregateOp) -> expression.Aggregation: +def agg(input: str, op: agg_ops.AggregateOp) -> agg_expressions.Aggregation: if isinstance(op, agg_ops.UnaryAggregateOp): - return expression.UnaryAggregation(op, expression.deref(input)) + return agg_expressions.UnaryAggregation(op, expression.deref(input)) else: assert isinstance(op, agg_ops.NullaryAggregateOp) - return expression.NullaryAggregation(op) + return agg_expressions.NullaryAggregation(op) diff --git a/bigframes/core/groupby/dataframe_group_by.py b/bigframes/core/groupby/dataframe_group_by.py index e4e4b313f9..3f5480436a 100644 --- a/bigframes/core/groupby/dataframe_group_by.py +++ b/bigframes/core/groupby/dataframe_group_by.py @@ -24,6 +24,7 @@ import pandas as pd from bigframes import session +from bigframes.core import agg_expressions from bigframes.core import expression as ex from bigframes.core import log_adapter import bigframes.core.block_transforms as block_ops @@ -327,7 +328,7 @@ def cumcount(self, ascending: bool = True) -> series.Series: ) ) block, result_id = self._block.apply_analytic( - ex.NullaryAggregation(agg_ops.size_op), + agg_expressions.NullaryAggregation(agg_ops.size_op), window=window_spec, result_label=None, ) @@ -488,7 +489,7 @@ def _agg_string(self, func: str) -> df.DataFrame: return dataframe if self._as_index else self._convert_index(dataframe) def _agg_dict(self, func: typing.Mapping) -> df.DataFrame: - aggregations: typing.List[ex.Aggregation] = [] + aggregations: typing.List[agg_expressions.Aggregation] = [] column_labels = [] want_aggfunc_level = any(utils.is_list_like(aggs) for aggs in func.values()) diff --git a/bigframes/core/indexes/base.py b/bigframes/core/indexes/base.py index f8ec38621d..2a35ab6546 100644 --- a/bigframes/core/indexes/base.py +++ b/bigframes/core/indexes/base.py @@ -27,6 +27,7 @@ import pandas from bigframes import dtypes +import bigframes.core.agg_expressions as ex_types import bigframes.core.block_transforms as block_ops import bigframes.core.blocks as blocks import bigframes.core.expression as ex @@ -282,7 +283,7 @@ def get_loc(self, key) -> typing.Union[int, slice, "bigframes.series.Series"]: filtered_block = block_with_offsets.filter_by_id(match_col_id) # Check if key exists at all by counting - count_agg = ex.UnaryAggregation(agg_ops.count_op, ex.deref(offsets_id)) + count_agg = ex_types.UnaryAggregation(agg_ops.count_op, ex.deref(offsets_id)) count_result = filtered_block._expr.aggregate([(count_agg, "count")]) count_scalar = self._block.session._executor.execute( @@ -294,7 +295,7 @@ def get_loc(self, key) -> typing.Union[int, slice, "bigframes.series.Series"]: # If only one match, return integer position if count_scalar == 1: - min_agg = ex.UnaryAggregation(agg_ops.min_op, ex.deref(offsets_id)) + min_agg = ex_types.UnaryAggregation(agg_ops.min_op, ex.deref(offsets_id)) position_result = filtered_block._expr.aggregate([(min_agg, "position")]) position_scalar = self._block.session._executor.execute( position_result, ex_spec.ExecutionSpec(promise_under_10gb=True) @@ -317,11 +318,11 @@ def _get_monotonic_slice(self, filtered_block, offsets_id: str) -> slice: # Combine min and max aggregations into a single query for efficiency min_max_aggs = [ ( - ex.UnaryAggregation(agg_ops.min_op, ex.deref(offsets_id)), + ex_types.UnaryAggregation(agg_ops.min_op, ex.deref(offsets_id)), "min_pos", ), ( - ex.UnaryAggregation(agg_ops.max_op, ex.deref(offsets_id)), + ex_types.UnaryAggregation(agg_ops.max_op, ex.deref(offsets_id)), "max_pos", ), ] diff --git a/bigframes/core/nodes.py b/bigframes/core/nodes.py index cf6e8a7e5c..b6483689dc 100644 --- a/bigframes/core/nodes.py +++ b/bigframes/core/nodes.py @@ -33,7 +33,7 @@ import google.cloud.bigquery as bq -from bigframes.core import identifiers, local_data, sequences +from bigframes.core import agg_expressions, identifiers, local_data, sequences from bigframes.core.bigframe_node import BigFrameNode, COLUMN_SET import bigframes.core.expression as ex from bigframes.core.field import Field @@ -1337,7 +1337,9 @@ def remap_refs( @dataclasses.dataclass(frozen=True, eq=False) class AggregateNode(UnaryNode): - aggregations: typing.Tuple[typing.Tuple[ex.Aggregation, identifiers.ColumnId], ...] + aggregations: typing.Tuple[ + typing.Tuple[agg_expressions.Aggregation, identifiers.ColumnId], ... + ] by_column_ids: typing.Tuple[ex.DerefOp, ...] = tuple([]) order_by: Tuple[OrderingExpression, ...] = () dropna: bool = True @@ -1360,9 +1362,7 @@ def fields(self) -> Sequence[Field]: agg_items = ( Field( id, - bigframes.dtypes.dtype_for_etype( - agg.output_type(self.child.field_by_id) - ), + ex.bind_schema_fields(agg, self.child.field_by_id).output_type, nullable=True, ) for agg, id in self.aggregations @@ -1437,7 +1437,7 @@ def remap_refs( @dataclasses.dataclass(frozen=True, eq=False) class WindowOpNode(UnaryNode, AdditiveNode): - expression: ex.Aggregation + expression: agg_expressions.Aggregation window_spec: window.WindowSpec output_name: identifiers.ColumnId never_skip_nulls: bool = False @@ -1478,11 +1478,10 @@ def row_count(self) -> Optional[int]: @functools.cached_property def added_field(self) -> Field: - input_fields = self.child.field_by_id # TODO: Determine if output could be non-null return Field( self.output_name, - bigframes.dtypes.dtype_for_etype(self.expression.output_type(input_fields)), + ex.bind_schema_fields(self.expression, self.child.field_by_id).output_type, ) @property diff --git a/bigframes/core/rewrite/order.py b/bigframes/core/rewrite/order.py index 5b5fb10753..881badd603 100644 --- a/bigframes/core/rewrite/order.py +++ b/bigframes/core/rewrite/order.py @@ -15,7 +15,7 @@ import functools from typing import Mapping, Tuple -from bigframes.core import expression, identifiers +from bigframes.core import agg_expressions, expression, identifiers import bigframes.core.nodes import bigframes.core.ordering import bigframes.core.window_spec @@ -167,9 +167,7 @@ def pull_up_order_inner( ) else: # Otherwise we need to generate offsets - agg = bigframes.core.expression.NullaryAggregation( - agg_ops.RowNumberOp() - ) + agg = agg_expressions.NullaryAggregation(agg_ops.RowNumberOp()) window_spec = bigframes.core.window_spec.unbound( ordering=tuple(child_order.all_ordering_columns) ) @@ -287,9 +285,7 @@ def pull_order_concat( new_source, ((order_expression.scalar_expression, offsets_id),) ) else: - agg = bigframes.core.expression.NullaryAggregation( - agg_ops.RowNumberOp() - ) + agg = agg_expressions.NullaryAggregation(agg_ops.RowNumberOp()) window_spec = bigframes.core.window_spec.unbound( ordering=tuple(order.all_ordering_columns) ) @@ -423,7 +419,7 @@ def remove_order_strict( def rewrite_promote_offsets( node: bigframes.core.nodes.PromoteOffsetsNode, ) -> bigframes.core.nodes.WindowOpNode: - agg = bigframes.core.expression.NullaryAggregation(agg_ops.RowNumberOp()) + agg = agg_expressions.NullaryAggregation(agg_ops.RowNumberOp()) window_spec = bigframes.core.window_spec.unbound() return bigframes.core.nodes.WindowOpNode(node.child, agg, window_spec, node.col_id) diff --git a/bigframes/core/rewrite/schema_binding.py b/bigframes/core/rewrite/schema_binding.py index cbecf83035..8a0bcc4921 100644 --- a/bigframes/core/rewrite/schema_binding.py +++ b/bigframes/core/rewrite/schema_binding.py @@ -15,7 +15,7 @@ import dataclasses import typing -from bigframes.core import bigframe_node +from bigframes.core import agg_expressions, bigframe_node from bigframes.core import expression as ex from bigframes.core import nodes, ordering @@ -118,16 +118,16 @@ def bind_schema_to_node( def _bind_schema_to_aggregation_expr( - aggregation: ex.Aggregation, + aggregation: agg_expressions.Aggregation, child: bigframe_node.BigFrameNode, -) -> ex.Aggregation: +) -> agg_expressions.Aggregation: assert isinstance( - aggregation, ex.Aggregation + aggregation, agg_expressions.Aggregation ), f"Expected Aggregation, got {type(aggregation)}" - if isinstance(aggregation, ex.UnaryAggregation): + if isinstance(aggregation, agg_expressions.UnaryAggregation): return typing.cast( - ex.Aggregation, + agg_expressions.Aggregation, dataclasses.replace( aggregation, arg=typing.cast( @@ -136,9 +136,9 @@ def _bind_schema_to_aggregation_expr( ), ), ) - elif isinstance(aggregation, ex.BinaryAggregation): + elif isinstance(aggregation, agg_expressions.BinaryAggregation): return typing.cast( - ex.Aggregation, + agg_expressions.Aggregation, dataclasses.replace( aggregation, left=typing.cast( diff --git a/bigframes/core/rewrite/timedeltas.py b/bigframes/core/rewrite/timedeltas.py index ea8e608a84..91c6ab83c6 100644 --- a/bigframes/core/rewrite/timedeltas.py +++ b/bigframes/core/rewrite/timedeltas.py @@ -20,6 +20,7 @@ from bigframes import dtypes from bigframes import operations as ops +from bigframes.core import agg_expressions as ex_types from bigframes.core import expression as ex from bigframes.core import nodes, schema, utils from bigframes.operations import aggregations as aggs @@ -219,33 +220,33 @@ def _rewrite_to_timedelta_op(op: ops.ToTimedeltaOp, arg: _TypedExpr): @functools.cache def _rewrite_aggregation( - aggregation: ex.Aggregation, schema: schema.ArraySchema -) -> ex.Aggregation: - if not isinstance(aggregation, ex.UnaryAggregation): + aggregation: ex_types.Aggregation, schema: schema.ArraySchema +) -> ex_types.Aggregation: + if not isinstance(aggregation, ex_types.UnaryAggregation): return aggregation if isinstance(aggregation.arg, ex.DerefOp): input_type = schema.get_type(aggregation.arg.id.sql) else: - input_type = aggregation.arg.dtype + input_type = aggregation.arg.output_type if isinstance(aggregation.op, aggs.DiffOp): if dtypes.is_datetime_like(input_type): - return ex.UnaryAggregation( + return ex_types.UnaryAggregation( aggs.TimeSeriesDiffOp(aggregation.op.periods), aggregation.arg ) elif input_type == dtypes.DATE_DTYPE: - return ex.UnaryAggregation( + return ex_types.UnaryAggregation( aggs.DateSeriesDiffOp(aggregation.op.periods), aggregation.arg ) if isinstance(aggregation.op, aggs.StdOp) and input_type == dtypes.TIMEDELTA_DTYPE: - return ex.UnaryAggregation( + return ex_types.UnaryAggregation( aggs.StdOp(should_floor_result=True), aggregation.arg ) if isinstance(aggregation.op, aggs.MeanOp) and input_type == dtypes.TIMEDELTA_DTYPE: - return ex.UnaryAggregation( + return ex_types.UnaryAggregation( aggs.MeanOp(should_floor_result=True), aggregation.arg ) @@ -253,7 +254,7 @@ def _rewrite_aggregation( isinstance(aggregation.op, aggs.QuantileOp) and input_type == dtypes.TIMEDELTA_DTYPE ): - return ex.UnaryAggregation( + return ex_types.UnaryAggregation( aggs.QuantileOp(q=aggregation.op.q, should_floor_result=True), aggregation.arg, ) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index f9de117b29..c65bbdd2c8 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -57,7 +57,7 @@ import bigframes._config.display_options as display_options import bigframes.constants import bigframes.core -from bigframes.core import log_adapter +from bigframes.core import agg_expressions, log_adapter import bigframes.core.block_transforms as block_ops import bigframes.core.blocks as blocks import bigframes.core.convert @@ -1363,7 +1363,9 @@ def _fast_stat_matrix(self, op: agg_ops.BinaryAggregateOp) -> DataFrame: block = frame._block aggregations = [ - ex.BinaryAggregation(op, ex.deref(left_col), ex.deref(right_col)) + agg_expressions.BinaryAggregation( + op, ex.deref(left_col), ex.deref(right_col) + ) for left_col in block.value_columns for right_col in block.value_columns ] @@ -1630,7 +1632,7 @@ def corrwith( block, _ = block.aggregate( aggregations=tuple( - ex.BinaryAggregation(agg_ops.CorrOp(), left_ex, right_ex) + agg_expressions.BinaryAggregation(agg_ops.CorrOp(), left_ex, right_ex) for left_ex, right_ex in expr_pairs ), column_labels=labels, @@ -3189,9 +3191,9 @@ def agg( for agg_func in agg_func_list: agg_op = agg_ops.lookup_agg_func(typing.cast(str, agg_func)) agg_expr = ( - ex.UnaryAggregation(agg_op, ex.deref(col_id)) + agg_expressions.UnaryAggregation(agg_op, ex.deref(col_id)) if isinstance(agg_op, agg_ops.UnaryAggregateOp) - else ex.NullaryAggregation(agg_op) + else agg_expressions.NullaryAggregation(agg_op) ) aggs.append(agg_expr) labels.append(col_label) diff --git a/bigframes/operations/aggregations.py b/bigframes/operations/aggregations.py index 6889997a10..81ab18272c 100644 --- a/bigframes/operations/aggregations.py +++ b/bigframes/operations/aggregations.py @@ -17,14 +17,18 @@ import abc import dataclasses import typing -from typing import ClassVar, Iterable, Optional +from typing import ClassVar, Iterable, Optional, TYPE_CHECKING import pandas as pd import pyarrow as pa +from bigframes.core import agg_expressions import bigframes.dtypes as dtypes import bigframes.operations.type as signatures +if TYPE_CHECKING: + from bigframes.core import expression + @dataclasses.dataclass(frozen=True) class WindowOp: @@ -110,6 +114,14 @@ class NullaryAggregateOp(AggregateOp, NullaryWindowOp): def arguments(self) -> int: return 0 + def as_expr( + self, + *exprs: typing.Union[str, expression.Expression], + ) -> agg_expressions.NullaryAggregation: + from bigframes.core import agg_expressions + + return agg_expressions.NullaryAggregation(self) + @dataclasses.dataclass(frozen=True) class UnaryAggregateOp(AggregateOp, UnaryWindowOp): @@ -117,6 +129,23 @@ class UnaryAggregateOp(AggregateOp, UnaryWindowOp): def arguments(self) -> int: return 1 + def as_expr( + self, + *exprs: typing.Union[str, expression.Expression], + ) -> agg_expressions.UnaryAggregation: + from bigframes.core import agg_expressions + from bigframes.operations.base_ops import _convert_expr_input + + # Keep this in sync with output_type and compilers + inputs: list[expression.Expression] = [] + + for expr in exprs: + inputs.append(_convert_expr_input(expr)) + return agg_expressions.UnaryAggregation( + self, + inputs[0], + ) + @dataclasses.dataclass(frozen=True) class BinaryAggregateOp(AggregateOp): @@ -124,6 +153,21 @@ class BinaryAggregateOp(AggregateOp): def arguments(self) -> int: return 2 + def as_expr( + self, + *exprs: typing.Union[str, expression.Expression], + ) -> agg_expressions.BinaryAggregation: + from bigframes.core import agg_expressions + from bigframes.operations.base_ops import _convert_expr_input + + # Keep this in sync with output_type and compilers + inputs: list[expression.Expression] = [] + + for expr in exprs: + inputs.append(_convert_expr_input(expr)) + + return agg_expressions.BinaryAggregation(self, inputs[0], inputs[1]) + @dataclasses.dataclass(frozen=True) class SizeOp(NullaryAggregateOp): diff --git a/bigframes/series.py b/bigframes/series.py index c95b2ca37f..3e24a75d9b 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -49,7 +49,7 @@ import typing_extensions import bigframes.core -from bigframes.core import groupby, log_adapter +from bigframes.core import agg_expressions, groupby, log_adapter import bigframes.core.block_transforms as block_ops import bigframes.core.blocks as blocks import bigframes.core.expression as ex @@ -1391,7 +1391,9 @@ def mode(self) -> Series: block, agg_ids = block.aggregate( by_column_ids=[self._value_column], aggregations=( - ex.UnaryAggregation(agg_ops.count_op, ex.deref(self._value_column)), + agg_expressions.UnaryAggregation( + agg_ops.count_op, ex.deref(self._value_column) + ), ), ) value_count_col_id = agg_ids[0] @@ -2116,7 +2118,11 @@ def unique(self, keep_order=True) -> Series: return self.drop_duplicates() block, result = self._block.aggregate( [self._value_column], - [ex.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(self._value_column))], + [ + agg_expressions.UnaryAggregation( + agg_ops.AnyValueOp(), ex.deref(self._value_column) + ) + ], column_labels=self._block.column_labels, dropna=False, ) diff --git a/bigframes/session/polars_executor.py b/bigframes/session/polars_executor.py index d8df558fe4..a1e1d436e1 100644 --- a/bigframes/session/polars_executor.py +++ b/bigframes/session/polars_executor.py @@ -18,7 +18,14 @@ import pyarrow as pa -from bigframes.core import array_value, bigframe_node, expression, local_data, nodes +from bigframes.core import ( + agg_expressions, + array_value, + bigframe_node, + expression, + local_data, + nodes, +) import bigframes.operations from bigframes.operations import aggregations as agg_ops from bigframes.operations import ( @@ -112,7 +119,7 @@ def _is_node_polars_executable(node: nodes.BigFrameNode): if not isinstance(node, _COMPATIBLE_NODES): return False for expr in node._node_expressions: - if isinstance(expr, expression.Aggregation): + if isinstance(expr, agg_expressions.Aggregation): if not type(expr.op) in _COMPATIBLE_AGG_OPS: return False if isinstance(expr, expression.Expression): diff --git a/tests/system/small/engines/test_aggregation.py b/tests/system/small/engines/test_aggregation.py index c2fc9ad706..a4a49c622a 100644 --- a/tests/system/small/engines/test_aggregation.py +++ b/tests/system/small/engines/test_aggregation.py @@ -14,7 +14,7 @@ import pytest -from bigframes.core import array_value, expression, identifiers, nodes +from bigframes.core import agg_expressions, array_value, expression, identifiers, nodes import bigframes.operations.aggregations as agg_ops from bigframes.session import polars_executor from bigframes.testing.engine_utils import assert_equivalence_execution @@ -37,7 +37,7 @@ def apply_agg_to_all_valid( continue try: _ = op.output_type(array.get_column_type(arg)) - expr = expression.UnaryAggregation(op, expression.deref(arg)) + expr = agg_expressions.UnaryAggregation(op, expression.deref(arg)) name = f"{arg}-{op.name}" exprs_by_name.append((expr, name)) except TypeError: @@ -56,11 +56,11 @@ def test_engines_aggregate_size( scalars_array_value.node, aggregations=( ( - expression.NullaryAggregation(agg_ops.SizeOp()), + agg_expressions.NullaryAggregation(agg_ops.SizeOp()), identifiers.ColumnId("size_op"), ), ( - expression.UnaryAggregation( + agg_expressions.UnaryAggregation( agg_ops.SizeUnaryOp(), expression.deref("string_col") ), identifiers.ColumnId("unary_size_op"), @@ -103,11 +103,11 @@ def test_engines_grouped_aggregate( scalars_array_value.node, aggregations=( ( - expression.NullaryAggregation(agg_ops.SizeOp()), + agg_expressions.NullaryAggregation(agg_ops.SizeOp()), identifiers.ColumnId("size_op"), ), ( - expression.UnaryAggregation( + agg_expressions.UnaryAggregation( agg_ops.SizeUnaryOp(), expression.deref("string_col") ), identifiers.ColumnId("unary_size_op"), diff --git a/tests/system/small/engines/test_windowing.py b/tests/system/small/engines/test_windowing.py index a5f20a47cd..f344a3b60a 100644 --- a/tests/system/small/engines/test_windowing.py +++ b/tests/system/small/engines/test_windowing.py @@ -15,7 +15,14 @@ from google.cloud import bigquery import pytest -from bigframes.core import array_value, expression, identifiers, nodes, window_spec +from bigframes.core import ( + agg_expressions, + array_value, + expression, + identifiers, + nodes, + window_spec, +) import bigframes.operations.aggregations as agg_ops from bigframes.session import direct_gbq_execution, polars_executor from bigframes.testing.engine_utils import assert_equivalence_execution @@ -48,7 +55,9 @@ def test_engines_with_rows_window( ) window_node = nodes.WindowOpNode( child=scalars_array_value.node, - expression=expression.UnaryAggregation(agg_op, expression.deref("int64_too")), + expression=agg_expressions.UnaryAggregation( + agg_op, expression.deref("int64_too") + ), window_spec=window, output_name=identifiers.ColumnId("agg_int64"), never_skip_nulls=never_skip_nulls, diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index 96cdceb3c6..d12b4dda17 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -14,7 +14,7 @@ import pytest -from bigframes.core import array_value, expression, identifiers, nodes +from bigframes.core import agg_expressions, array_value, expression, identifiers, nodes from bigframes.operations import aggregations as agg_ops import bigframes.pandas as bpd @@ -26,7 +26,7 @@ def _apply_unary_op(obj: bpd.DataFrame, op: agg_ops.UnaryWindowOp, arg: str) -> obj._block.expr.node, aggregations=( ( - expression.UnaryAggregation(op, expression.deref(arg)), + agg_expressions.UnaryAggregation(op, expression.deref(arg)), identifiers.ColumnId(arg + "_agg"), ), ), From 873d0eee474ed34f1d5164c37383f2737dbec4db Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 5 Sep 2025 07:50:52 -0700 Subject: [PATCH 044/313] fix: Fix issue mishandling chunked array while loading data (#2051) --- bigframes/core/local_data.py | 5 ++++- tests/system/small/test_dataframe.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/bigframes/core/local_data.py b/bigframes/core/local_data.py index 958113dda3..c214d0bb7e 100644 --- a/bigframes/core/local_data.py +++ b/bigframes/core/local_data.py @@ -277,7 +277,10 @@ def _adapt_pandas_series( ) return pa.array(series, type=pa.string()), bigframes.dtypes.GEO_DTYPE try: - return _adapt_arrow_array(pa.array(series)) + pa_arr = pa.array(series) + if isinstance(pa_arr, pa.ChunkedArray): + return _adapt_chunked_array(pa_arr) + return _adapt_arrow_array(pa_arr) except pa.ArrowInvalid as e: if series.dtype == np.dtype("O"): try: diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index dce0a649f6..58bfc615ef 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -138,6 +138,16 @@ def test_df_construct_structs(session): ) +def test_df_construct_local_concat_pd(scalars_pandas_df_index, session): + pd_df = pd.concat([scalars_pandas_df_index, scalars_pandas_df_index]) + + bf_df = session.read_pandas(pd_df) + + pd.testing.assert_frame_equal( + bf_df.to_pandas(), pd_df, check_index_type=False, check_dtype=False + ) + + def test_df_construct_pandas_set_dtype(scalars_dfs): columns = [ "int64_too", From 5229e07b4535c01b0cdbd731455ff225a373b5c8 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 5 Sep 2025 12:30:59 -0700 Subject: [PATCH 045/313] feat: Support display.max_colwidth option (#2053) --- bigframes/_config/display_options.py | 3 +++ tests/system/small/test_dataframe.py | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/bigframes/_config/display_options.py b/bigframes/_config/display_options.py index 360292dd80..b7ce29e47e 100644 --- a/bigframes/_config/display_options.py +++ b/bigframes/_config/display_options.py @@ -35,6 +35,7 @@ class DisplayOptions: progress_bar: Optional[str] = "auto" repr_mode: Literal["head", "deferred", "anywidget"] = "head" + max_colwidth: Optional[int] = 50 max_info_columns: int = 100 max_info_rows: Optional[int] = 200000 memory_usage: bool = True @@ -52,6 +53,8 @@ def pandas_repr(display_options: DisplayOptions): so that we don't override pandas behavior. """ with pd.option_context( + "display.max_colwidth", + display_options.max_colwidth, "display.max_columns", display_options.max_columns, "display.max_rows", diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 58bfc615ef..323956b038 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -888,6 +888,30 @@ def test_join_repr(scalars_dfs_maybe_ordered): assert actual == expected +def test_repr_w_display_options(scalars_dfs, session): + metrics = session._metrics + scalars_df, _ = scalars_dfs + # get a pandas df of the expected format + df, _ = scalars_df._block.to_pandas() + pandas_df = df.set_axis(scalars_df._block.column_labels, axis=1) + pandas_df.index.name = scalars_df.index.name + + executions_pre = metrics.execution_count + with bigframes.option_context( + "display.max_rows", 10, "display.max_columns", 5, "display.max_colwidth", 10 + ): + + # When there are 10 or fewer rows, the outputs should be identical except for the extra note. + actual = scalars_df.head(10).__repr__() + executions_post = metrics.execution_count + + with display_options.pandas_repr(bigframes.options.display): + pandas_repr = pandas_df.head(10).__repr__() + + assert actual == pandas_repr + assert (executions_post - executions_pre) <= 3 + + def test_repr_html_w_all_rows(scalars_dfs, session): metrics = session._metrics scalars_df, _ = scalars_dfs From 8804adaf8ba23fdcad6e42a7bf034bd0a11c890f Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 5 Sep 2025 16:13:46 -0700 Subject: [PATCH 046/313] feat: Add str.join method (#2054) --- .../ibis_compiler/aggregate_compiler.py | 23 +++++++++++ .../ibis_compiler/scalar_op_registry.py | 15 +++++-- bigframes/operations/aggregations.py | 23 +++++++++-- bigframes/operations/strings.py | 6 +++ tests/system/small/operations/test_strings.py | 11 +++++ .../sql/compilers/bigquery/__init__.py | 16 ++++++++ .../ibis/expr/operations/reductions.py | 17 ++++++++ .../pandas/core/strings/accessor.py | 40 +++++++++++++++++++ 8 files changed, 144 insertions(+), 7 deletions(-) diff --git a/bigframes/core/compile/ibis_compiler/aggregate_compiler.py b/bigframes/core/compile/ibis_compiler/aggregate_compiler.py index 5e9cba7f8c..1907078690 100644 --- a/bigframes/core/compile/ibis_compiler/aggregate_compiler.py +++ b/bigframes/core/compile/ibis_compiler/aggregate_compiler.py @@ -676,6 +676,29 @@ def _( ).to_expr() +@compile_ordered_unary_agg.register +def _( + op: agg_ops.StringAggOp, + column: ibis_types.Column, + window=None, + order_by: typing.Sequence[ibis_types.Value] = [], +) -> ibis_types.ArrayValue: + if window is not None: + raise NotImplementedError( + f"StringAgg with windowing is not supported. {constants.FEEDBACK_LINK}" + ) + + return ( + ibis_ops.StringAgg( + column, # type: ignore + sep=op.sep, # type: ignore + order_by=order_by, # type: ignore + ) + .to_expr() + .fill_null(ibis_types.literal("")) + ) + + @compile_binary_agg.register def _( op: agg_ops.CorrOp, left: ibis_types.Column, right: ibis_types.Column, window=None diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index 969ae2659d..044fc90306 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -1216,11 +1216,18 @@ def to_arry_op_impl(*values: ibis_types.Value): def array_reduce_op_impl(x: ibis_types.Value, op: ops.ArrayReduceOp): import bigframes.core.compile.ibis_compiler.aggregate_compiler as agg_compilers - return typing.cast(ibis_types.ArrayValue, x).reduce( - lambda arr_vals: agg_compilers.compile_unary_agg( - op.aggregation, typing.cast(ibis_types.Column, arr_vals) + if op.aggregation.order_independent: + return typing.cast(ibis_types.ArrayValue, x).reduce( + lambda arr_vals: agg_compilers.compile_unary_agg( + op.aggregation, typing.cast(ibis_types.Column, arr_vals) + ) + ) + else: + return typing.cast(ibis_types.ArrayValue, x).reduce( + lambda arr_vals: agg_compilers.compile_ordered_unary_agg( + op.aggregation, typing.cast(ibis_types.Column, arr_vals) + ) ) - ) # JSON Ops diff --git a/bigframes/operations/aggregations.py b/bigframes/operations/aggregations.py index 81ab18272c..0ee80fd74b 100644 --- a/bigframes/operations/aggregations.py +++ b/bigframes/operations/aggregations.py @@ -379,9 +379,26 @@ def skips_nulls(self): return True def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: - return pd.ArrowDtype( - pa.list_(dtypes.bigframes_dtype_to_arrow_dtype(input_types[0])) - ) + return dtypes.list_type(input_types[0]) + + +@dataclasses.dataclass(frozen=True) +class StringAggOp(UnaryAggregateOp): + name: ClassVar[str] = "string_agg" + sep: str = "," + + @property + def order_independent(self): + return False + + @property + def skips_nulls(self): + return True + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + if input_types[0] != dtypes.STRING_DTYPE: + raise TypeError(f"Type {input_types[0]} is not string-like") + return dtypes.STRING_DTYPE @dataclasses.dataclass(frozen=True) diff --git a/bigframes/operations/strings.py b/bigframes/operations/strings.py index 9022a1665e..4743483954 100644 --- a/bigframes/operations/strings.py +++ b/bigframes/operations/strings.py @@ -24,6 +24,7 @@ import bigframes.dataframe as df import bigframes.operations as ops from bigframes.operations._op_converters import convert_index, convert_slice +import bigframes.operations.aggregations as agg_ops import bigframes.operations.base import bigframes.series as series @@ -295,6 +296,11 @@ def cat( ) -> series.Series: return self._apply_binary_op(others, ops.strconcat_op, alignment=join) + def join(self, sep: str) -> series.Series: + return self._apply_unary_op( + ops.ArrayReduceOp(aggregation=agg_ops.StringAggOp(sep=sep)) + ) + def to_blob(self, connection: Optional[str] = None) -> series.Series: """Create a BigFrames Blob series from a series of URIs. diff --git a/tests/system/small/operations/test_strings.py b/tests/system/small/operations/test_strings.py index a720614892..afd1a74dff 100644 --- a/tests/system/small/operations/test_strings.py +++ b/tests/system/small/operations/test_strings.py @@ -736,3 +736,14 @@ def test_getitem_w_struct_array(): expected = bpd.Series(expected_data, dtype=bpd.ArrowDtype((pa_struct))) assert_series_equal(result.to_pandas(), expected.to_pandas()) + + +def test_string_join(session): + pd_series = pd.Series([["a", "b", "c"], ["100"], ["hello", "world"], []]) + bf_series = session.read_pandas(pd_series) + + pd_result = pd_series.str.join("--") + bf_result = bf_series.str.join("--").to_pandas() + + pd_result = pd_result.astype("string[pyarrow]") + assert_series_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) diff --git a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py index 61bafeeca2..9af2a4afe4 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py @@ -1088,6 +1088,22 @@ def visit_ArrayAggregate(self, op, *, arg, order_by, where): expr = arg return sge.IgnoreNulls(this=self.agg.array_agg(expr, where=where)) + def visit_StringAgg(self, op, *, arg, sep, order_by, where): + if len(order_by) > 0: + expr = sge.Order( + this=arg, + expressions=[ + # Avoid adding NULLS FIRST / NULLS LAST in SQL, which is + # unsupported in ARRAY_AGG by reconstructing the node as + # plain SQL text. + f"({order_column.args['this'].sql(dialect='bigquery')}) {'DESC' if order_column.args.get('desc') else 'ASC'}" + for order_column in order_by + ], + ) + else: + expr = arg + return self.agg.string_agg(expr, sep, where=where) + def visit_FirstNonNullValue(self, op, *, arg): return sge.IgnoreNulls(this=sge.FirstValue(this=arg)) diff --git a/third_party/bigframes_vendored/ibis/expr/operations/reductions.py b/third_party/bigframes_vendored/ibis/expr/operations/reductions.py index 34f6406e0c..c3f2a03223 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/reductions.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/reductions.py @@ -401,3 +401,20 @@ class ArrayAggregate(Filterable, Reduction): @attribute def dtype(self): return dt.Array(self.arg.dtype) + + +@public +class StringAgg(Filterable, Reduction): + """ + Collects the elements of this expression into a string. Similar to + the ibis `GroupConcat`, but adds `order_by_*` parameter. + """ + + arg: Column + sep: Value[dt.String] + + order_by: VarTuple[Value] = () + + @attribute + def dtype(self): + return dt.string diff --git a/third_party/bigframes_vendored/pandas/core/strings/accessor.py b/third_party/bigframes_vendored/pandas/core/strings/accessor.py index 9b5b461ea5..fe94bf3049 100644 --- a/third_party/bigframes_vendored/pandas/core/strings/accessor.py +++ b/third_party/bigframes_vendored/pandas/core/strings/accessor.py @@ -1298,3 +1298,43 @@ def center( bigframes.series.Series: Returns Series or Index with minimum number of char in object. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def join(self, sep: str): + """ + Join lists contained as elements in the Series/Index with passed delimiter. + + If the elements of a Series are lists themselves, join the content of these + lists using the delimiter passed to the function. + This function is an equivalent to :meth:`str.join`. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None + >>> import pandas as pd + + Example with a list that contains non-string elements. + + >>> s = bpd.Series([['lion', 'elephant', 'zebra'], + ... ['dragon'], + ... ['duck', 'swan', 'fish', 'guppy']]) + >>> s + 0 ['lion' 'elephant' 'zebra'] + 1 ['dragon'] + 2 ['duck' 'swan' 'fish' 'guppy'] + dtype: list[pyarrow] + + >>> s.str.join('-') + 0 lion-elephant-zebra + 1 dragon + 2 duck-swan-fish-guppy + dtype: string + + Args: + sep (str): + Delimiter to use between list entries. + + Returns: + bigframes.series.Series: The list entries concatenated by intervening occurrences of the delimiter. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) From 5df779d4f421d3ba777cfd928d99ca2e8a3f79ad Mon Sep 17 00:00:00 2001 From: jialuoo Date: Tue, 9 Sep 2025 07:48:32 -0700 Subject: [PATCH 047/313] feat: Support VPC egress setting in remote function (#2059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Tim Sweña (Swast) --- bigframes/functions/_function_client.py | 21 +++++++++++++++++++ bigframes/functions/_function_session.py | 11 ++++++++++ bigframes/pandas/__init__.py | 4 ++++ bigframes/session/__init__.py | 11 ++++++++++ .../large/functions/test_remote_function.py | 8 ++++++- 5 files changed, 54 insertions(+), 1 deletion(-) diff --git a/bigframes/functions/_function_client.py b/bigframes/functions/_function_client.py index a8c9f9c301..d994d6353a 100644 --- a/bigframes/functions/_function_client.py +++ b/bigframes/functions/_function_client.py @@ -51,6 +51,15 @@ } ) +# https://cloud.google.com/functions/docs/reference/rest/v2/projects.locations.functions#vpconnectoregresssettings +_VPC_EGRESS_SETTINGS_MAP = types.MappingProxyType( + { + "all": functions_v2.ServiceConfig.VpcConnectorEgressSettings.ALL_TRAFFIC, + "private-ranges-only": functions_v2.ServiceConfig.VpcConnectorEgressSettings.PRIVATE_RANGES_ONLY, + "unspecified": functions_v2.ServiceConfig.VpcConnectorEgressSettings.VPC_CONNECTOR_EGRESS_SETTINGS_UNSPECIFIED, + } +) + # BQ managed functions (@udf) currently only support Python 3.11. _MANAGED_FUNC_PYTHON_VERSION = "python-3.11" @@ -375,6 +384,7 @@ def create_cloud_function( max_instance_count=None, is_row_processor=False, vpc_connector=None, + vpc_connector_egress_settings="private-ranges-only", memory_mib=1024, ingress_settings="internal-only", ): @@ -472,6 +482,15 @@ def create_cloud_function( function.service_config.max_instance_count = max_instance_count if vpc_connector is not None: function.service_config.vpc_connector = vpc_connector + if vpc_connector_egress_settings not in _VPC_EGRESS_SETTINGS_MAP: + raise bf_formatting.create_exception_with_feedback_link( + ValueError, + f"'{vpc_connector_egress_settings}' not one of the supported vpc egress settings values: {list(_VPC_EGRESS_SETTINGS_MAP)}", + ) + function.service_config.vpc_connector_egress_settings = cast( + functions_v2.ServiceConfig.VpcConnectorEgressSettings, + _VPC_EGRESS_SETTINGS_MAP[vpc_connector_egress_settings], + ) function.service_config.service_account_email = ( self._cloud_function_service_account ) @@ -532,6 +551,7 @@ def provision_bq_remote_function( cloud_function_max_instance_count, is_row_processor, cloud_function_vpc_connector, + cloud_function_vpc_connector_egress_settings, cloud_function_memory_mib, cloud_function_ingress_settings, bq_metadata, @@ -580,6 +600,7 @@ def provision_bq_remote_function( max_instance_count=cloud_function_max_instance_count, is_row_processor=is_row_processor, vpc_connector=cloud_function_vpc_connector, + vpc_connector_egress_settings=cloud_function_vpc_connector_egress_settings, memory_mib=cloud_function_memory_mib, ingress_settings=cloud_function_ingress_settings, ) diff --git a/bigframes/functions/_function_session.py b/bigframes/functions/_function_session.py index a2fb66539b..6b5c9bf071 100644 --- a/bigframes/functions/_function_session.py +++ b/bigframes/functions/_function_session.py @@ -245,6 +245,9 @@ def remote_function( cloud_function_timeout: Optional[int] = 600, cloud_function_max_instances: Optional[int] = None, cloud_function_vpc_connector: Optional[str] = None, + cloud_function_vpc_connector_egress_settings: Literal[ + "all", "private-ranges-only", "unspecified" + ] = "private-ranges-only", cloud_function_memory_mib: Optional[int] = 1024, cloud_function_ingress_settings: Literal[ "all", "internal-only", "internal-and-gclb" @@ -425,6 +428,13 @@ def remote_function( function. This is useful if your code needs access to data or service(s) that are on a VPC network. See for more details https://cloud.google.com/functions/docs/networking/connecting-vpc. + cloud_function_vpc_connector_egress_settings (str, Optional): + Egress settings for the VPC connector, controlling what outbound + traffic is routed through the VPC connector. + Options are: `all`, `private-ranges-only`, or `unspecified`. + If not specified, `private-ranges-only` is used by default. + See for more details + https://cloud.google.com/run/docs/configuring/vpc-connectors#egress-job. cloud_function_memory_mib (int, Optional): The amounts of memory (in mebibytes) to allocate for the cloud function (2nd gen) created. This also dictates a corresponding @@ -616,6 +626,7 @@ def wrapper(func): cloud_function_max_instance_count=cloud_function_max_instances, is_row_processor=is_row_processor, cloud_function_vpc_connector=cloud_function_vpc_connector, + cloud_function_vpc_connector_egress_settings=cloud_function_vpc_connector_egress_settings, cloud_function_memory_mib=cloud_function_memory_mib, cloud_function_ingress_settings=cloud_function_ingress_settings, bq_metadata=bqrf_metadata, diff --git a/bigframes/pandas/__init__.py b/bigframes/pandas/__init__.py index 6ffed5b53f..9d4fc101f6 100644 --- a/bigframes/pandas/__init__.py +++ b/bigframes/pandas/__init__.py @@ -87,6 +87,9 @@ def remote_function( cloud_function_timeout: Optional[int] = 600, cloud_function_max_instances: Optional[int] = None, cloud_function_vpc_connector: Optional[str] = None, + cloud_function_vpc_connector_egress_settings: Literal[ + "all", "private-ranges-only", "unspecified" + ] = "private-ranges-only", cloud_function_memory_mib: Optional[int] = 1024, cloud_function_ingress_settings: Literal[ "all", "internal-only", "internal-and-gclb" @@ -109,6 +112,7 @@ def remote_function( cloud_function_timeout=cloud_function_timeout, cloud_function_max_instances=cloud_function_max_instances, cloud_function_vpc_connector=cloud_function_vpc_connector, + cloud_function_vpc_connector_egress_settings=cloud_function_vpc_connector_egress_settings, cloud_function_memory_mib=cloud_function_memory_mib, cloud_function_ingress_settings=cloud_function_ingress_settings, cloud_build_service_account=cloud_build_service_account, diff --git a/bigframes/session/__init__.py b/bigframes/session/__init__.py index df67e64e9e..6252a59e31 100644 --- a/bigframes/session/__init__.py +++ b/bigframes/session/__init__.py @@ -1510,6 +1510,9 @@ def remote_function( cloud_function_timeout: Optional[int] = 600, cloud_function_max_instances: Optional[int] = None, cloud_function_vpc_connector: Optional[str] = None, + cloud_function_vpc_connector_egress_settings: Literal[ + "all", "private-ranges-only", "unspecified" + ] = "private-ranges-only", cloud_function_memory_mib: Optional[int] = 1024, cloud_function_ingress_settings: Literal[ "all", "internal-only", "internal-and-gclb" @@ -1675,6 +1678,13 @@ def remote_function( function. This is useful if your code needs access to data or service(s) that are on a VPC network. See for more details https://cloud.google.com/functions/docs/networking/connecting-vpc. + cloud_function_vpc_connector_egress_settings (str, Optional): + Egress settings for the VPC connector, controlling what outbound + traffic is routed through the VPC connector. + Options are: `all`, `private-ranges-only`, or `unspecified`. + If not specified, `private-ranges-only` is used by default. + See for more details + https://cloud.google.com/run/docs/configuring/vpc-connectors#egress-job. cloud_function_memory_mib (int, Optional): The amounts of memory (in mebibytes) to allocate for the cloud function (2nd gen) created. This also dictates a corresponding @@ -1732,6 +1742,7 @@ def remote_function( cloud_function_timeout=cloud_function_timeout, cloud_function_max_instances=cloud_function_max_instances, cloud_function_vpc_connector=cloud_function_vpc_connector, + cloud_function_vpc_connector_egress_settings=cloud_function_vpc_connector_egress_settings, cloud_function_memory_mib=cloud_function_memory_mib, cloud_function_ingress_settings=cloud_function_ingress_settings, cloud_build_service_account=cloud_build_service_account, diff --git a/tests/system/large/functions/test_remote_function.py b/tests/system/large/functions/test_remote_function.py index f60786437f..22b623193d 100644 --- a/tests/system/large/functions/test_remote_function.py +++ b/tests/system/large/functions/test_remote_function.py @@ -1478,14 +1478,20 @@ def square_num(x): reuse=False, cloud_function_service_account="default", cloud_function_vpc_connector=gcf_vpc_connector, + cloud_function_vpc_connector_egress_settings="all", cloud_function_ingress_settings="all", )(square_num) - # assert that the GCF is created with the intended vpc connector gcf = rf_session.cloudfunctionsclient.get_function( name=square_num_remote.bigframes_cloud_function ) + + # assert that the GCF is created with the intended vpc connector and + # egress settings. assert gcf.service_config.vpc_connector == gcf_vpc_connector + # The value is since we set + # cloud_function_vpc_connector_egress_settings="all" earlier. + assert gcf.service_config.vpc_connector_egress_settings == 2 # assert that the function works as expected on data scalars_df, scalars_pandas_df = scalars_dfs From 7deb6c0cdef79292b5730db9ba4af41b5df18b20 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 10:27:43 -0500 Subject: [PATCH 048/313] chore(main): release 2.19.0 (#2050) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 +++++++++++++++ bigframes/version.py | 4 ++-- third_party/bigframes_vendored/version.py | 4 ++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 433956da3d..fdd060f1f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ [1]: https://pypi.org/project/bigframes/#history +## [2.19.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.18.0...v2.19.0) (2025-09-09) + + +### Features + +* Add str.join method ([#2054](https://github.com/googleapis/python-bigquery-dataframes/issues/2054)) ([8804ada](https://github.com/googleapis/python-bigquery-dataframes/commit/8804adaf8ba23fdcad6e42a7bf034bd0a11c890f)) +* Support display.max_colwidth option ([#2053](https://github.com/googleapis/python-bigquery-dataframes/issues/2053)) ([5229e07](https://github.com/googleapis/python-bigquery-dataframes/commit/5229e07b4535c01b0cdbd731455ff225a373b5c8)) +* Support VPC egress setting in remote function ([#2059](https://github.com/googleapis/python-bigquery-dataframes/issues/2059)) ([5df779d](https://github.com/googleapis/python-bigquery-dataframes/commit/5df779d4f421d3ba777cfd928d99ca2e8a3f79ad)) + + +### Bug Fixes + +* Fix issue mishandling chunked array while loading data ([#2051](https://github.com/googleapis/python-bigquery-dataframes/issues/2051)) ([873d0ee](https://github.com/googleapis/python-bigquery-dataframes/commit/873d0eee474ed34f1d5164c37383f2737dbec4db)) +* Remove warning for slot_millis_sum ([#2047](https://github.com/googleapis/python-bigquery-dataframes/issues/2047)) ([425a691](https://github.com/googleapis/python-bigquery-dataframes/commit/425a6917d5442eeb4df486c6eed1fd136bbcedfb)) + ## [2.18.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.17.0...v2.18.0) (2025-09-03) diff --git a/bigframes/version.py b/bigframes/version.py index 78b6498d2d..558f26d68e 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.18.0" +__version__ = "2.19.0" # {x-release-please-start-date} -__release_date__ = "2025-09-03" +__release_date__ = "2025-09-09" # {x-release-please-end} diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index 78b6498d2d..558f26d68e 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.18.0" +__version__ = "2.19.0" # {x-release-please-start-date} -__release_date__ = "2025-09-03" +__release_date__ = "2025-09-09" # {x-release-please-end} From 913de1b31f3bb0b306846fddae5dcaff6be3cec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Tue, 9 Sep 2025 14:49:01 -0500 Subject: [PATCH 049/313] perf: avoid re-authenticating if credentials have already been fetched (#2058) * perf: avoid re-authenticating if credentials have already been fetched * Update bigframes/_config/bigquery_options.py * move lock to module --- bigframes/_config/auth.py | 57 +++++++++++++++++++++++++++ bigframes/_config/bigquery_options.py | 44 +++++++++++++++++++-- bigframes/session/__init__.py | 3 +- bigframes/session/clients.py | 31 ++++++++++----- tests/unit/pandas/io/test_api.py | 46 +++++++++++++++++++++ 5 files changed, 166 insertions(+), 15 deletions(-) create mode 100644 bigframes/_config/auth.py diff --git a/bigframes/_config/auth.py b/bigframes/_config/auth.py new file mode 100644 index 0000000000..1574fc4883 --- /dev/null +++ b/bigframes/_config/auth.py @@ -0,0 +1,57 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import threading +from typing import Optional + +import google.auth.credentials +import google.auth.transport.requests +import pydata_google_auth + +_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"] + +# Put the lock here rather than in BigQueryOptions so that BigQueryOptions +# remains deepcopy-able. +_AUTH_LOCK = threading.Lock() +_cached_credentials: Optional[google.auth.credentials.Credentials] = None +_cached_project_default: Optional[str] = None + + +def get_default_credentials_with_project() -> tuple[ + google.auth.credentials.Credentials, Optional[str] +]: + global _AUTH_LOCK, _cached_credentials, _cached_project_default + + with _AUTH_LOCK: + if _cached_credentials is not None: + return _cached_credentials, _cached_project_default + + _cached_credentials, _cached_project_default = pydata_google_auth.default( + scopes=_SCOPES, use_local_webserver=False + ) + + # Ensure an access token is available. + _cached_credentials.refresh(google.auth.transport.requests.Request()) + + return _cached_credentials, _cached_project_default + + +def reset_default_credentials_and_project(): + global _AUTH_LOCK, _cached_credentials, _cached_project_default + + with _AUTH_LOCK: + _cached_credentials = None + _cached_project_default = None diff --git a/bigframes/_config/bigquery_options.py b/bigframes/_config/bigquery_options.py index 648b69dea7..2456a88073 100644 --- a/bigframes/_config/bigquery_options.py +++ b/bigframes/_config/bigquery_options.py @@ -22,6 +22,7 @@ import google.auth.credentials import requests.adapters +import bigframes._config.auth import bigframes._importing import bigframes.enums import bigframes.exceptions as bfe @@ -37,6 +38,7 @@ def _get_validated_location(value: Optional[str]) -> Optional[str]: import bigframes._tools.strings + import bigframes.constants if value is None or value in bigframes.constants.ALL_BIGQUERY_LOCATIONS: return value @@ -141,20 +143,52 @@ def application_name(self, value: Optional[str]): ) self._application_name = value + def _try_set_default_credentials_and_project( + self, + ) -> tuple[google.auth.credentials.Credentials, Optional[str]]: + # Don't fetch credentials or project if credentials is already set. + # If it's set, we've already authenticated, so if the user wants to + # re-auth, they should explicitly reset the credentials. + if self._credentials is not None: + return self._credentials, self._project + + ( + credentials, + credentials_project, + ) = bigframes._config.auth.get_default_credentials_with_project() + self._credentials = credentials + + # Avoid overriding an explicitly set project with a default value. + if self._project is None: + self._project = credentials_project + + return credentials, self._project + @property - def credentials(self) -> Optional[google.auth.credentials.Credentials]: + def credentials(self) -> google.auth.credentials.Credentials: """The OAuth2 credentials to use for this client. + Set to None to force re-authentication. + Returns: None or google.auth.credentials.Credentials: google.auth.credentials.Credentials if exists; otherwise None. """ - return self._credentials + if self._credentials: + return self._credentials + + credentials, _ = self._try_set_default_credentials_and_project() + return credentials @credentials.setter def credentials(self, value: Optional[google.auth.credentials.Credentials]): if self._session_started and self._credentials is not value: raise ValueError(SESSION_STARTED_MESSAGE.format(attribute="credentials")) + + if value is None: + # The user has _explicitly_ asked that we re-authenticate. + bigframes._config.auth.reset_default_credentials_and_project() + self._credentials = value @property @@ -183,7 +217,11 @@ def project(self) -> Optional[str]: None or str: Google Cloud project ID as a string; otherwise None. """ - return self._project + if self._project: + return self._project + + _, project = self._try_set_default_credentials_and_project() + return project @project.setter def project(self, value: Optional[str]): diff --git a/bigframes/session/__init__.py b/bigframes/session/__init__.py index 6252a59e31..4c824256b1 100644 --- a/bigframes/session/__init__.py +++ b/bigframes/session/__init__.py @@ -49,7 +49,6 @@ import bigframes_vendored.pandas.io.parsers.readers as third_party_pandas_readers import bigframes_vendored.pandas.io.pickle as third_party_pandas_pickle import google.cloud.bigquery as bigquery -import google.cloud.storage as storage # type: ignore import numpy as np import pandas from pandas._typing import ( @@ -1424,7 +1423,7 @@ def _check_file_size(self, filepath: str): if filepath.startswith("gs://"): # GCS file path bucket_name, blob_path = filepath.split("/", 3)[2:] - client = storage.Client() + client = self._clients_provider.storageclient bucket = client.bucket(bucket_name) list_blobs_params = inspect.signature(bucket.list_blobs).parameters diff --git a/bigframes/session/clients.py b/bigframes/session/clients.py index d680b94b8a..42bfab2682 100644 --- a/bigframes/session/clients.py +++ b/bigframes/session/clients.py @@ -29,9 +29,10 @@ import google.cloud.bigquery_storage_v1 import google.cloud.functions_v2 import google.cloud.resourcemanager_v3 -import pydata_google_auth +import google.cloud.storage # type: ignore import requests +import bigframes._config import bigframes.constants import bigframes.version @@ -39,7 +40,6 @@ _ENV_DEFAULT_PROJECT = "GOOGLE_CLOUD_PROJECT" _APPLICATION_NAME = f"bigframes/{bigframes.version.__version__} ibis/9.2.0" -_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"] # BigQuery is a REST API, which requires the protocol as part of the URL. @@ -50,10 +50,6 @@ _BIGQUERYSTORAGE_REGIONAL_ENDPOINT = "bigquerystorage.{location}.rep.googleapis.com" -def _get_default_credentials_with_project(): - return pydata_google_auth.default(scopes=_SCOPES, use_local_webserver=False) - - def _get_application_names(): apps = [_APPLICATION_NAME] @@ -88,10 +84,8 @@ def __init__( ): credentials_project = None if credentials is None: - credentials, credentials_project = _get_default_credentials_with_project() - - # Ensure an access token is available. - credentials.refresh(google.auth.transport.requests.Request()) + credentials = bigframes._config.options.bigquery.credentials + credentials_project = bigframes._config.options.bigquery.project # Prefer the project in this order: # 1. Project explicitly specified by the user @@ -165,6 +159,9 @@ def __init__( google.cloud.resourcemanager_v3.ProjectsClient ] = None + self._storageclient_lock = threading.Lock() + self._storageclient: Optional[google.cloud.storage.Client] = None + def _create_bigquery_client(self): bq_options = None if "bqclient" in self._client_endpoints_override: @@ -347,3 +344,17 @@ def resourcemanagerclient(self): ) return self._resourcemanagerclient + + @property + def storageclient(self): + with self._storageclient_lock: + if not self._storageclient: + storage_info = google.api_core.client_info.ClientInfo( + user_agent=self._application_name + ) + self._storageclient = google.cloud.storage.Client( + client_info=storage_info, + credentials=self._credentials, + ) + + return self._storageclient diff --git a/tests/unit/pandas/io/test_api.py b/tests/unit/pandas/io/test_api.py index 1e69fa9df3..ba401d1ce6 100644 --- a/tests/unit/pandas/io/test_api.py +++ b/tests/unit/pandas/io/test_api.py @@ -14,11 +14,14 @@ from unittest import mock +import google.cloud.bigquery import pytest import bigframes.dataframe +import bigframes.pandas import bigframes.pandas.io.api as bf_io_api import bigframes.session +import bigframes.session.clients # _read_gbq_colab requires the polars engine. pytest.importorskip("polars") @@ -47,6 +50,49 @@ def test_read_gbq_colab_dry_run_doesnt_call_set_location( mock_set_location.assert_not_called() +@mock.patch("bigframes._config.auth.get_default_credentials_with_project") +@mock.patch("bigframes.core.global_session.with_default_session") +def test_read_gbq_colab_dry_run_doesnt_authenticate_multiple_times( + mock_with_default_session, mock_get_credentials, monkeypatch +): + """ + Ensure that we authenticate too often, which is an expensive operation, + performance-wise (2+ seconds). + """ + bigframes.pandas.close_session() + + mock_get_credentials.return_value = (mock.Mock(), "unit-test-project") + mock_create_bq_client = mock.Mock() + mock_bq_client = mock.create_autospec(google.cloud.bigquery.Client, instance=True) + mock_create_bq_client.return_value = mock_bq_client + mock_query_job = mock.create_autospec(google.cloud.bigquery.QueryJob, instance=True) + type(mock_query_job).schema = mock.PropertyMock(return_value=[]) + mock_query_job._properties = {} + mock_bq_client.query.return_value = mock_query_job + monkeypatch.setattr( + bigframes.session.clients.ClientsProvider, + "_create_bigquery_client", + mock_create_bq_client, + ) + mock_df = mock.create_autospec(bigframes.dataframe.DataFrame) + mock_with_default_session.return_value = mock_df + + query_or_table = "SELECT {param1} AS param1" + sample_pyformat_args = {"param1": "value1"} + bf_io_api._read_gbq_colab( + query_or_table, pyformat_args=sample_pyformat_args, dry_run=True + ) + + mock_with_default_session.assert_not_called() + mock_get_credentials.reset_mock() + + # Repeat the operation so that the credentials would have have been cached. + bf_io_api._read_gbq_colab( + query_or_table, pyformat_args=sample_pyformat_args, dry_run=True + ) + mock_get_credentials.assert_not_called() + + @mock.patch( "bigframes.pandas.io.api._set_default_session_location_if_possible_deferred_query" ) From dc54f585c262a29c76b95ac1c563af4a62c817c6 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Wed, 10 Sep 2025 06:51:03 -0700 Subject: [PATCH 050/313] chore: allow method_logger to directly decorate functions with param in parentheses (#2065) * chore: allow method_logger to directly decorate functions with params in parentheses * fix format --- bigframes/core/log_adapter.py | 90 ++++++++++++++++------------- tests/unit/core/test_log_adapter.py | 11 ++++ 2 files changed, 62 insertions(+), 39 deletions(-) diff --git a/bigframes/core/log_adapter.py b/bigframes/core/log_adapter.py index 6021c7075a..3ec1e86dc7 100644 --- a/bigframes/core/log_adapter.py +++ b/bigframes/core/log_adapter.py @@ -149,49 +149,61 @@ def wrap(cls): return wrap(decorated_cls) -def method_logger(method, /, *, custom_base_name: Optional[str] = None): +def method_logger(method=None, /, *, custom_base_name: Optional[str] = None): """Decorator that adds logging functionality to a method.""" - @functools.wraps(method) - def wrapper(*args, **kwargs): - api_method_name = getattr(method, LOG_OVERRIDE_NAME, method.__name__) - if custom_base_name is None: - qualname_parts = getattr(method, "__qualname__", method.__name__).split(".") - class_name = qualname_parts[-2] if len(qualname_parts) > 1 else "" - base_name = ( - class_name if class_name else "_".join(method.__module__.split(".")[1:]) - ) - else: - base_name = custom_base_name - - full_method_name = f"{base_name.lower()}-{api_method_name}" - # Track directly called methods - if len(_call_stack) == 0: - add_api_method(full_method_name) - - _call_stack.append(full_method_name) - - try: - return method(*args, **kwargs) - except (NotImplementedError, TypeError) as e: - # Log method parameters that are implemented in pandas but either missing (TypeError) - # or not fully supported (NotImplementedError) in BigFrames. - # Logging is currently supported only when we can access the bqclient through - # _block.session.bqclient. - if len(_call_stack) == 1: - submit_pandas_labels( - _get_bq_client(*args, **kwargs), - base_name, - api_method_name, - args, - kwargs, - task=PANDAS_PARAM_TRACKING_TASK, + def outer_wrapper(method): + @functools.wraps(method) + def wrapper(*args, **kwargs): + api_method_name = getattr(method, LOG_OVERRIDE_NAME, method.__name__) + if custom_base_name is None: + qualname_parts = getattr(method, "__qualname__", method.__name__).split( + "." + ) + class_name = qualname_parts[-2] if len(qualname_parts) > 1 else "" + base_name = ( + class_name + if class_name + else "_".join(method.__module__.split(".")[1:]) ) - raise e - finally: - _call_stack.pop() + else: + base_name = custom_base_name - return wrapper + full_method_name = f"{base_name.lower()}-{api_method_name}" + # Track directly called methods + if len(_call_stack) == 0: + add_api_method(full_method_name) + + _call_stack.append(full_method_name) + + try: + return method(*args, **kwargs) + except (NotImplementedError, TypeError) as e: + # Log method parameters that are implemented in pandas but either missing (TypeError) + # or not fully supported (NotImplementedError) in BigFrames. + # Logging is currently supported only when we can access the bqclient through + # _block.session.bqclient. + if len(_call_stack) == 1: + submit_pandas_labels( + _get_bq_client(*args, **kwargs), + base_name, + api_method_name, + args, + kwargs, + task=PANDAS_PARAM_TRACKING_TASK, + ) + raise e + finally: + _call_stack.pop() + + return wrapper + + if method is None: + # Called with parentheses + return outer_wrapper + + # Called without parentheses + return outer_wrapper(method) def property_logger(prop): diff --git a/tests/unit/core/test_log_adapter.py b/tests/unit/core/test_log_adapter.py index eba015dd9d..c236bb6886 100644 --- a/tests/unit/core/test_log_adapter.py +++ b/tests/unit/core/test_log_adapter.py @@ -101,6 +101,17 @@ def test_method_logging_with_custom_base_name(test_method_w_custom_base): assert "pandas-method1" in api_methods +def test_method_logging_with_custom_base__logger_as_decorator(): + @log_adapter.method_logger(custom_base_name="pandas") + def my_method(): + pass + + my_method() + + api_methods = log_adapter.get_and_reset_api_methods() + assert "pandas-my_method" in api_methods + + def test_property_logging(test_instance): test_instance.my_field From b3cf8248e3b8ea76637ded64fb12028d439448d1 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Wed, 10 Sep 2025 10:28:36 -0700 Subject: [PATCH 051/313] feat: support pandas.Index as key for DataFrame.__setitem__() (#2062) * feat: support pandas.Index as key for DataFrame.__setitem__() * fix format --- bigframes/dataframe.py | 8 +++++--- tests/system/small/test_dataframe.py | 5 +++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index c65bbdd2c8..5a93646677 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -890,9 +890,11 @@ def __delitem__(self, key: str): self._set_block(df._get_block()) def __setitem__( - self, key: str | list[str], value: SingleItemValue | MultiItemValue + self, + key: str | list[str] | pandas.Index, + value: SingleItemValue | MultiItemValue, ): - if isinstance(key, list): + if isinstance(key, (list, pandas.Index)): df = self._assign_multi_items(key, value) else: df = self._assign_single_item(key, value) @@ -2246,7 +2248,7 @@ def _assign_single_item( def _assign_multi_items( self, - k: list[str], + k: list[str] | pandas.Index, v: SingleItemValue | MultiItemValue, ) -> DataFrame: value_sources: Sequence[Any] = [] diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 323956b038..15f800097b 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -1197,6 +1197,11 @@ def test_assign_new_column_w_setitem_list_error(scalars_dfs): pytest.param( ["new_col", "new_col_too"], [1, 2], id="sequence_to_full_new_column" ), + pytest.param( + pd.Index(("new_col", "new_col_too")), + [1, 2], + id="sequence_to_full_new_column_as_index", + ), ], ) def test_setitem_multicolumn_with_literals(scalars_dfs, key, value): From 21eb213c5f0e0f696f2d1ca1f1263678d791cf7c Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Wed, 10 Sep 2025 11:00:16 -0700 Subject: [PATCH 052/313] feat: support pd.cut() for array-like type (#2064) Fixes internal issue 329866195 --- bigframes/core/reshape/tile.py | 8 ++++++-- tests/system/small/test_pandas.py | 12 ++++++++++++ tests/unit/test_pandas.py | 3 +++ .../bigframes_vendored/pandas/core/reshape/tile.py | 6 +++--- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/bigframes/core/reshape/tile.py b/bigframes/core/reshape/tile.py index 86ccf52408..74a941be54 100644 --- a/bigframes/core/reshape/tile.py +++ b/bigframes/core/reshape/tile.py @@ -20,6 +20,7 @@ import bigframes_vendored.pandas.core.reshape.tile as vendored_pandas_tile import pandas as pd +import bigframes import bigframes.constants import bigframes.core.expression as ex import bigframes.core.ordering as order @@ -32,7 +33,7 @@ def cut( - x: bigframes.series.Series, + x, bins: typing.Union[ int, pd.IntervalIndex, @@ -60,9 +61,12 @@ def cut( f"but found {type(list(labels)[0])}. {constants.FEEDBACK_LINK}" ) - if x.size == 0: + if len(x) == 0: raise ValueError("Cannot cut empty array.") + if not isinstance(x, bigframes.series.Series): + x = bigframes.series.Series(x) + if isinstance(bins, int): if bins <= 0: raise ValueError("`bins` should be a positive integer.") diff --git a/tests/system/small/test_pandas.py b/tests/system/small/test_pandas.py index 550a75e1bb..d2cde59729 100644 --- a/tests/system/small/test_pandas.py +++ b/tests/system/small/test_pandas.py @@ -520,6 +520,18 @@ def _convert_pandas_category(pd_s: pd.Series): ) +def test_cut_for_array(): + """Avoid regressions for internal issue 329866195""" + sc = [30, 80, 40, 90, 60, 45, 95, 75, 55, 100, 65, 85] + x = [20, 40, 60, 80, 100] + + pd_result: pd.Series = pd.Series(pd.cut(sc, x)) + bf_result = bpd.cut(sc, x) + + pd_result = _convert_pandas_category(pd_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + @pytest.mark.parametrize( ("right", "labels"), [ diff --git a/tests/unit/test_pandas.py b/tests/unit/test_pandas.py index e8383512a6..73e0b7f2d6 100644 --- a/tests/unit/test_pandas.py +++ b/tests/unit/test_pandas.py @@ -122,6 +122,7 @@ def test_method_matches_session(method_name: str): ) def test_cut_raises_with_invalid_labels(bins: int, labels, error_message: str): mock_series = mock.create_autospec(bigframes.pandas.Series, instance=True) + mock_series.__len__.return_value = 5 with pytest.raises(ValueError, match=error_message): bigframes.pandas.cut(mock_series, bins, labels=labels) @@ -160,6 +161,8 @@ def test_cut_raises_with_unsupported_labels(): ) def test_cut_raises_with_invalid_bins(bins: int, error_message: str): mock_series = mock.create_autospec(bigframes.pandas.Series, instance=True) + mock_series.__len__.return_value = 5 + with pytest.raises(ValueError, match=error_message): bigframes.pandas.cut(mock_series, bins, labels=False) diff --git a/third_party/bigframes_vendored/pandas/core/reshape/tile.py b/third_party/bigframes_vendored/pandas/core/reshape/tile.py index fccaffdadf..697c17f23c 100644 --- a/third_party/bigframes_vendored/pandas/core/reshape/tile.py +++ b/third_party/bigframes_vendored/pandas/core/reshape/tile.py @@ -8,11 +8,11 @@ import pandas as pd -from bigframes import constants, series +from bigframes import constants def cut( - x: series.Series, + x, bins: typing.Union[ int, pd.IntervalIndex, @@ -113,7 +113,7 @@ def cut( dtype: struct[pyarrow] Args: - x (bigframes.pandas.Series): + x (array-like): The input Series to be binned. Must be 1-dimensional. bins (int, pd.IntervalIndex, Iterable): The criteria to bin by. From b0ff718a04fadda33cfa3613b1d02822cde34bc2 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Wed, 10 Sep 2025 14:59:12 -0700 Subject: [PATCH 053/313] feat: support to cast struct to json (#2067) Fixes internal issue 444196993 --- .../ibis_compiler/scalar_op_registry.py | 4 +++- bigframes/dtypes.py | 9 ++++--- bigframes/operations/generic_ops.py | 2 ++ tests/system/small/test_series.py | 24 +++++++++++++++++++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index 044fc90306..804ee6f926 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -1023,6 +1023,8 @@ def astype_op_impl(x: ibis_types.Value, op: ops.AsTypeOp): x, ibis_dtypes.string, safe=op.safe ) return parse_json_in_safe(x_str) if op.safe else parse_json(x_str) + if x.type().is_struct(): + return to_json_string(typing.cast(ibis_types.StructValue, x)) if x.type() == ibis_dtypes.json: if to_type == ibis_dtypes.int64: @@ -2069,7 +2071,7 @@ def json_extract_string_array( # type: ignore[empty-body] @ibis_udf.scalar.builtin(name="to_json_string") def to_json_string( # type: ignore[empty-body] - json_obj: ibis_dtypes.JSON, + json_obj, ) -> ibis_dtypes.String: """Convert JSON to STRING.""" diff --git a/bigframes/dtypes.py b/bigframes/dtypes.py index ef1b9e7871..ae68dbe7d3 100644 --- a/bigframes/dtypes.py +++ b/bigframes/dtypes.py @@ -641,6 +641,9 @@ def _dtype_from_string(dtype_string: str) -> typing.Optional[Dtype]: return BIGFRAMES_STRING_TO_BIGFRAMES[ typing.cast(DtypeString, str(dtype_string)) ] + if isinstance(dtype_string, str) and dtype_string.lower() == "json": + return JSON_DTYPE + raise TypeError( textwrap.dedent( f""" @@ -652,9 +655,9 @@ def _dtype_from_string(dtype_string: str) -> typing.Optional[Dtype]: The following pandas.ExtensionDtype are supported: pandas.BooleanDtype(), pandas.Float64Dtype(), pandas.Int64Dtype(), pandas.StringDtype(storage="pyarrow"), - pd.ArrowDtype(pa.date32()), pd.ArrowDtype(pa.time64("us")), - pd.ArrowDtype(pa.timestamp("us")), - pd.ArrowDtype(pa.timestamp("us", tz="UTC")). + pandas.ArrowDtype(pa.date32()), pandas.ArrowDtype(pa.time64("us")), + pandas.ArrowDtype(pa.timestamp("us")), + pandas.ArrowDtype(pa.timestamp("us", tz="UTC")). {constants.FEEDBACK_LINK} """ ) diff --git a/bigframes/operations/generic_ops.py b/bigframes/operations/generic_ops.py index d6155a770c..ea25086aa9 100644 --- a/bigframes/operations/generic_ops.py +++ b/bigframes/operations/generic_ops.py @@ -324,6 +324,8 @@ def _valid_cast(src: dtypes.Dtype, dst: dtypes.Dtype): if not _valid_cast(src_dtype, dst_dtype): return False return True + if dtypes.is_struct_like(src) and dst == dtypes.JSON_DTYPE: + return True return _valid_scalar_cast(src, dst) diff --git a/tests/system/small/test_series.py b/tests/system/small/test_series.py index 165e3b6df0..70dcafdb22 100644 --- a/tests/system/small/test_series.py +++ b/tests/system/small/test_series.py @@ -3866,6 +3866,30 @@ def test_string_astype_timestamp(): pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) +def test_struct_astype_json(): + """See internal issue 444196993.""" + s = series.Series( + [ + {"version": 1, "project": "pandas"}, + {"version": 2, "project": "numpy"}, + ] + ) + assert dtypes.is_struct_like(s.dtype) + + expected = series.Series(s, dtype=dtypes.JSON_DTYPE) + assert expected.dtype == dtypes.JSON_DTYPE + + result = s.astype("json") + pd.testing.assert_series_equal( + result.to_pandas(), expected.to_pandas(), check_index_type=False + ) + + result = s.astype(dtypes.JSON_DTYPE) + pd.testing.assert_series_equal( + result.to_pandas(), expected.to_pandas(), check_index_type=False + ) + + def test_timestamp_astype_string(): bf_series = series.Series( [ From a63cbae24ff2dc191f0a53dced885bc95f38ec96 Mon Sep 17 00:00:00 2001 From: Garrett Wu <6505921+GarrettWu@users.noreply.github.com> Date: Thu, 11 Sep 2025 11:44:06 -0700 Subject: [PATCH 054/313] feat: add StreamingDataFrame.to_bigtable and .to_pubsub start_timestamp parameter (#2066) * feat: add StreamingDataFrame.to_bigtable and .to_pubsub start_timestamp parameter * fix test --- bigframes/pandas/__init__.py | 6 +-- bigframes/streaming/dataframe.py | 51 ++++++++++++++++--- tests/system/large/streaming/test_bigtable.py | 6 ++- tests/system/large/streaming/test_pubsub.py | 6 ++- 4 files changed, 54 insertions(+), 15 deletions(-) diff --git a/bigframes/pandas/__init__.py b/bigframes/pandas/__init__.py index 9d4fc101f6..a19b1397ce 100644 --- a/bigframes/pandas/__init__.py +++ b/bigframes/pandas/__init__.py @@ -17,7 +17,7 @@ from __future__ import annotations from collections import namedtuple -from datetime import datetime +from datetime import date, datetime import inspect import sys import typing @@ -198,7 +198,7 @@ def to_datetime( @typing.overload def to_datetime( - arg: Union[int, float, str, datetime], + arg: Union[int, float, str, datetime, date], *, utc: bool = False, format: Optional[str] = None, @@ -209,7 +209,7 @@ def to_datetime( def to_datetime( arg: Union[ - Union[int, float, str, datetime], + Union[int, float, str, datetime, date], vendored_pandas_datetimes.local_iterables, bigframes.series.Series, bigframes.dataframe.DataFrame, diff --git a/bigframes/streaming/dataframe.py b/bigframes/streaming/dataframe.py index 69247879d1..7dc9e964bc 100644 --- a/bigframes/streaming/dataframe.py +++ b/bigframes/streaming/dataframe.py @@ -15,13 +15,16 @@ """Module for bigquery continuous queries""" from __future__ import annotations +from abc import abstractmethod +from datetime import date, datetime import functools import inspect import json -from typing import Optional +from typing import Optional, Union import warnings from google.cloud import bigquery +import pandas as pd from bigframes import dataframe from bigframes.core import log_adapter, nodes @@ -54,9 +57,14 @@ def _curate_df_doc(doc: Optional[str]): class StreamingBase: - _appends_sql: str _session: bigframes.session.Session + @abstractmethod + def _appends_sql( + self, start_timestamp: Optional[Union[int, float, str, datetime, date]] + ) -> str: + pass + def to_bigtable( self, *, @@ -70,6 +78,8 @@ def to_bigtable( bigtable_options: Optional[dict] = None, job_id: Optional[str] = None, job_id_prefix: Optional[str] = None, + start_timestamp: Optional[Union[int, float, str, datetime, date]] = None, + end_timestamp: Optional[Union[int, float, str, datetime, date]] = None, ) -> bigquery.QueryJob: """ Export the StreamingDataFrame as a continue job and returns a @@ -115,7 +125,8 @@ def to_bigtable( If specified, a job id prefix for the query, see job_id_prefix parameter of https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.client.Client#google_cloud_bigquery_client_Client_query - + start_timestamp (int, float, str, datetime, date, default None): + The starting timestamp for the query. Possible values are to 7 days in the past. If don't specify a timestamp (None), the query will default to the earliest possible time, 7 days ago. If provide a time-zone-naive timestamp, it will be treated as UTC. Returns: google.cloud.bigquery.QueryJob: See https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.job.QueryJob @@ -123,8 +134,15 @@ def to_bigtable( For example, the job can be cancelled or its error status can be examined. """ + if not isinstance( + start_timestamp, (int, float, str, datetime, date, type(None)) + ): + raise ValueError( + f"Unsupported start_timestamp type {type(start_timestamp)}" + ) + return _to_bigtable( - self._appends_sql, + self._appends_sql(start_timestamp), instance=instance, table=table, service_account_email=service_account_email, @@ -145,6 +163,7 @@ def to_pubsub( service_account_email: str, job_id: Optional[str] = None, job_id_prefix: Optional[str] = None, + start_timestamp: Optional[Union[int, float, str, datetime, date]] = None, ) -> bigquery.QueryJob: """ Export the StreamingDataFrame as a continue job and returns a @@ -172,6 +191,8 @@ def to_pubsub( If specified, a job id prefix for the query, see job_id_prefix parameter of https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.client.Client#google_cloud_bigquery_client_Client_query + start_timestamp (int, float, str, datetime, date, default None): + The starting timestamp for the query. Possible values are to 7 days in the past. If don't specify a timestamp (None), the query will default to the earliest possible time, 7 days ago. If provide a time-zone-naive timestamp, it will be treated as UTC. Returns: google.cloud.bigquery.QueryJob: @@ -180,8 +201,15 @@ def to_pubsub( For example, the job can be cancelled or its error status can be examined. """ + if not isinstance( + start_timestamp, (int, float, str, datetime, date, type(None)) + ): + raise ValueError( + f"Unsupported start_timestamp type {type(start_timestamp)}" + ) + return _to_pubsub( - self._appends_sql, + self._appends_sql(start_timestamp), topic=topic, service_account_email=service_account_email, session=self._session, @@ -280,14 +308,21 @@ def sql(self): sql.__doc__ = _curate_df_doc(inspect.getdoc(dataframe.DataFrame.sql)) # Patch for the required APPENDS clause - @property - def _appends_sql(self): + def _appends_sql( + self, start_timestamp: Optional[Union[int, float, str, datetime, date]] + ) -> str: sql_str = self.sql original_table = self._original_table assert original_table is not None # TODO(b/405691193): set start time back to NULL. Now set it slightly after 7 days max interval to avoid the bug. - appends_clause = f"APPENDS(TABLE `{original_table}`, CURRENT_TIMESTAMP() - (INTERVAL 7 DAY - INTERVAL 5 MINUTE))" + start_ts_str = ( + str(f"TIMESTAMP('{pd.to_datetime(start_timestamp)}')") + if start_timestamp + else "CURRENT_TIMESTAMP() - (INTERVAL 7 DAY - INTERVAL 5 MINUTE)" + ) + + appends_clause = f"APPENDS(TABLE `{original_table}`, {start_ts_str})" sql_str = sql_str.replace(f"`{original_table}`", appends_clause) return sql_str diff --git a/tests/system/large/streaming/test_bigtable.py b/tests/system/large/streaming/test_bigtable.py index e57b7e6e0e..38e01f44bc 100644 --- a/tests/system/large/streaming/test_bigtable.py +++ b/tests/system/large/streaming/test_bigtable.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from datetime import datetime, timedelta import time from typing import Generator import uuid @@ -91,11 +92,12 @@ def test_streaming_df_to_bigtable( bigtable_options={}, job_id=None, job_id_prefix=job_id_prefix, + start_timestamp=datetime.now() - timedelta(days=1), ) - # wait 100 seconds in order to ensure the query doesn't stop + # wait 200 seconds in order to ensure the query doesn't stop # (i.e. it is continuous) - time.sleep(100) + time.sleep(200) assert query_job.running() assert query_job.error_result is None assert str(query_job.job_id).startswith(job_id_prefix) diff --git a/tests/system/large/streaming/test_pubsub.py b/tests/system/large/streaming/test_pubsub.py index 277b44c93b..9ff965fd77 100644 --- a/tests/system/large/streaming/test_pubsub.py +++ b/tests/system/large/streaming/test_pubsub.py @@ -13,6 +13,7 @@ # limitations under the License. from concurrent import futures +from datetime import datetime, timedelta from typing import Generator import uuid @@ -99,11 +100,12 @@ def callback(message): service_account_email="streaming-testing@bigframes-load-testing.iam.gserviceaccount.com", job_id=None, job_id_prefix=job_id_prefix, + start_timestamp=datetime.now() - timedelta(days=1), ) try: - # wait 100 seconds in order to ensure the query doesn't stop + # wait 200 seconds in order to ensure the query doesn't stop # (i.e. it is continuous) - future.result(timeout=100) + future.result(timeout=200) except futures.TimeoutError: future.cancel() assert query_job.running() From fdf623d499a1910caf5bb1f389114cab4a2bad67 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Thu, 11 Sep 2025 13:04:59 -0700 Subject: [PATCH 055/313] Revert "feat: support to cast struct to json (#2067)" (#2072) This reverts commit b0ff718a04fadda33cfa3613b1d02822cde34bc2. --- .../ibis_compiler/scalar_op_registry.py | 4 +--- bigframes/dtypes.py | 9 +++---- bigframes/operations/generic_ops.py | 2 -- tests/system/small/test_series.py | 24 ------------------- 4 files changed, 4 insertions(+), 35 deletions(-) diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index 804ee6f926..044fc90306 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -1023,8 +1023,6 @@ def astype_op_impl(x: ibis_types.Value, op: ops.AsTypeOp): x, ibis_dtypes.string, safe=op.safe ) return parse_json_in_safe(x_str) if op.safe else parse_json(x_str) - if x.type().is_struct(): - return to_json_string(typing.cast(ibis_types.StructValue, x)) if x.type() == ibis_dtypes.json: if to_type == ibis_dtypes.int64: @@ -2071,7 +2069,7 @@ def json_extract_string_array( # type: ignore[empty-body] @ibis_udf.scalar.builtin(name="to_json_string") def to_json_string( # type: ignore[empty-body] - json_obj, + json_obj: ibis_dtypes.JSON, ) -> ibis_dtypes.String: """Convert JSON to STRING.""" diff --git a/bigframes/dtypes.py b/bigframes/dtypes.py index ae68dbe7d3..ef1b9e7871 100644 --- a/bigframes/dtypes.py +++ b/bigframes/dtypes.py @@ -641,9 +641,6 @@ def _dtype_from_string(dtype_string: str) -> typing.Optional[Dtype]: return BIGFRAMES_STRING_TO_BIGFRAMES[ typing.cast(DtypeString, str(dtype_string)) ] - if isinstance(dtype_string, str) and dtype_string.lower() == "json": - return JSON_DTYPE - raise TypeError( textwrap.dedent( f""" @@ -655,9 +652,9 @@ def _dtype_from_string(dtype_string: str) -> typing.Optional[Dtype]: The following pandas.ExtensionDtype are supported: pandas.BooleanDtype(), pandas.Float64Dtype(), pandas.Int64Dtype(), pandas.StringDtype(storage="pyarrow"), - pandas.ArrowDtype(pa.date32()), pandas.ArrowDtype(pa.time64("us")), - pandas.ArrowDtype(pa.timestamp("us")), - pandas.ArrowDtype(pa.timestamp("us", tz="UTC")). + pd.ArrowDtype(pa.date32()), pd.ArrowDtype(pa.time64("us")), + pd.ArrowDtype(pa.timestamp("us")), + pd.ArrowDtype(pa.timestamp("us", tz="UTC")). {constants.FEEDBACK_LINK} """ ) diff --git a/bigframes/operations/generic_ops.py b/bigframes/operations/generic_ops.py index ea25086aa9..d6155a770c 100644 --- a/bigframes/operations/generic_ops.py +++ b/bigframes/operations/generic_ops.py @@ -324,8 +324,6 @@ def _valid_cast(src: dtypes.Dtype, dst: dtypes.Dtype): if not _valid_cast(src_dtype, dst_dtype): return False return True - if dtypes.is_struct_like(src) and dst == dtypes.JSON_DTYPE: - return True return _valid_scalar_cast(src, dst) diff --git a/tests/system/small/test_series.py b/tests/system/small/test_series.py index 70dcafdb22..165e3b6df0 100644 --- a/tests/system/small/test_series.py +++ b/tests/system/small/test_series.py @@ -3866,30 +3866,6 @@ def test_string_astype_timestamp(): pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) -def test_struct_astype_json(): - """See internal issue 444196993.""" - s = series.Series( - [ - {"version": 1, "project": "pandas"}, - {"version": 2, "project": "numpy"}, - ] - ) - assert dtypes.is_struct_like(s.dtype) - - expected = series.Series(s, dtype=dtypes.JSON_DTYPE) - assert expected.dtype == dtypes.JSON_DTYPE - - result = s.astype("json") - pd.testing.assert_series_equal( - result.to_pandas(), expected.to_pandas(), check_index_type=False - ) - - result = s.astype(dtypes.JSON_DTYPE) - pd.testing.assert_series_equal( - result.to_pandas(), expected.to_pandas(), check_index_type=False - ) - - def test_timestamp_astype_string(): bf_series = series.Series( [ From f4547796623036531b26ab72c166a235af630b19 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Thu, 11 Sep 2025 13:46:08 -0700 Subject: [PATCH 056/313] refactor: fix the remap varaibles errors on InNode (#2069) --- bigframes/core/nodes.py | 10 ++- bigframes/core/rewrite/identifiers.py | 89 +++++++++++++++------ tests/unit/core/rewrite/conftest.py | 36 ++++++++- tests/unit/core/rewrite/test_identifiers.py | 23 ++++++ 4 files changed, 130 insertions(+), 28 deletions(-) diff --git a/bigframes/core/nodes.py b/bigframes/core/nodes.py index b6483689dc..0d20509877 100644 --- a/bigframes/core/nodes.py +++ b/bigframes/core/nodes.py @@ -300,7 +300,15 @@ def remap_vars( def remap_refs( self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] ) -> InNode: - return dataclasses.replace(self, left_col=self.left_col.remap_column_refs(mappings, allow_partial_bindings=True), right_col=self.right_col.remap_column_refs(mappings, allow_partial_bindings=True)) # type: ignore + return dataclasses.replace( + self, + left_col=self.left_col.remap_column_refs( + mappings, allow_partial_bindings=True + ), + right_col=self.right_col.remap_column_refs( + mappings, allow_partial_bindings=True + ), + ) # type: ignore @dataclasses.dataclass(frozen=True, eq=False) diff --git a/bigframes/core/rewrite/identifiers.py b/bigframes/core/rewrite/identifiers.py index 0093e183b4..e911d81895 100644 --- a/bigframes/core/rewrite/identifiers.py +++ b/bigframes/core/rewrite/identifiers.py @@ -13,6 +13,7 @@ # limitations under the License. from __future__ import annotations +import dataclasses import typing from bigframes.core import identifiers, nodes @@ -26,32 +27,68 @@ def remap_variables( nodes.BigFrameNode, dict[identifiers.ColumnId, identifiers.ColumnId], ]: - """Remaps `ColumnId`s in the BFET to produce deterministic and sequential UIDs. + """Remaps `ColumnId`s in the expression tree to be deterministic and sequential. - Note: this will convert a DAG to a tree. + This function performs a post-order traversal. It recursively remaps children + nodes first, then remaps the current node's references and definitions. + + Note: this will convert a DAG to a tree by duplicating shared nodes. + + Args: + root: The root node of the expression tree. + id_generator: An iterator that yields new column IDs. + + Returns: + A tuple of the new root node and a mapping from old to new column IDs + visible to the parent node. """ - child_replacement_map = dict() - ref_mapping = dict() - # Sequential ids are assigned bottom-up left-to-right + # Step 1: Recursively remap children to get their new nodes and ID mappings. + new_child_nodes: list[nodes.BigFrameNode] = [] + new_child_mappings: list[dict[identifiers.ColumnId, identifiers.ColumnId]] = [] for child in root.child_nodes: - new_child, child_var_mapping = remap_variables(child, id_generator=id_generator) - child_replacement_map[child] = new_child - ref_mapping.update(child_var_mapping) - - # This is actually invalid until we've replaced all of children, refs and var defs - with_new_children = root.transform_children( - lambda node: child_replacement_map[node] - ) - - with_new_refs = with_new_children.remap_refs(ref_mapping) - - node_var_mapping = {old_id: next(id_generator) for old_id in root.node_defined_ids} - with_new_vars = with_new_refs.remap_vars(node_var_mapping) - with_new_vars._validate() - - return ( - with_new_vars, - node_var_mapping - if root.defines_namespace - else (ref_mapping | node_var_mapping), - ) + new_child, child_mappings = remap_variables(child, id_generator=id_generator) + new_child_nodes.append(new_child) + new_child_mappings.append(child_mappings) + + # Step 2: Transform children to use their new nodes. + remapped_children: dict[nodes.BigFrameNode, nodes.BigFrameNode] = { + child: new_child for child, new_child in zip(root.child_nodes, new_child_nodes) + } + new_root = root.transform_children(lambda node: remapped_children[node]) + + # Step 3: Transform the current node using the mappings from its children. + downstream_mappings: dict[identifiers.ColumnId, identifiers.ColumnId] = { + k: v for mapping in new_child_mappings for k, v in mapping.items() + } + if isinstance(new_root, nodes.InNode): + new_root = typing.cast(nodes.InNode, new_root) + new_root = dataclasses.replace( + new_root, + left_col=new_root.left_col.remap_column_refs( + new_child_mappings[0], allow_partial_bindings=True + ), + right_col=new_root.right_col.remap_column_refs( + new_child_mappings[1], allow_partial_bindings=True + ), + ) + else: + new_root = new_root.remap_refs(downstream_mappings) + + # Step 4: Create new IDs for columns defined by the current node. + node_defined_mappings = { + old_id: next(id_generator) for old_id in root.node_defined_ids + } + new_root = new_root.remap_vars(node_defined_mappings) + + new_root._validate() + + # Step 5: Determine which mappings to propagate up to the parent. + if root.defines_namespace: + # If a node defines a new namespace (e.g., a join), mappings from its + # children are not visible to its parents. + mappings_for_parent = node_defined_mappings + else: + # Otherwise, pass up the combined mappings from children and the current node. + mappings_for_parent = downstream_mappings | node_defined_mappings + + return new_root, mappings_for_parent diff --git a/tests/unit/core/rewrite/conftest.py b/tests/unit/core/rewrite/conftest.py index 22b897f3bf..bbfbde46f3 100644 --- a/tests/unit/core/rewrite/conftest.py +++ b/tests/unit/core/rewrite/conftest.py @@ -34,7 +34,32 @@ @pytest.fixture def table(): - return TABLE + table_ref = google.cloud.bigquery.TableReference.from_string( + "project.dataset.table" + ) + schema = ( + google.cloud.bigquery.SchemaField("col_a", "INTEGER"), + google.cloud.bigquery.SchemaField("col_b", "INTEGER"), + ) + return google.cloud.bigquery.Table( + table_ref=table_ref, + schema=schema, + ) + + +@pytest.fixture +def table_too(): + table_ref = google.cloud.bigquery.TableReference.from_string( + "project.dataset.table_too" + ) + schema = ( + google.cloud.bigquery.SchemaField("col_a", "INTEGER"), + google.cloud.bigquery.SchemaField("col_c", "INTEGER"), + ) + return google.cloud.bigquery.Table( + table_ref=table_ref, + schema=schema, + ) @pytest.fixture @@ -49,3 +74,12 @@ def leaf(fake_session, table): table=table, schema=bigframes.core.schema.ArraySchema.from_bq_table(table), ).node + + +@pytest.fixture +def leaf_too(fake_session, table_too): + return core.ArrayValue.from_table( + session=fake_session, + table=table_too, + schema=bigframes.core.schema.ArraySchema.from_bq_table(table_too), + ).node diff --git a/tests/unit/core/rewrite/test_identifiers.py b/tests/unit/core/rewrite/test_identifiers.py index fd12df60a8..f95cd696d0 100644 --- a/tests/unit/core/rewrite/test_identifiers.py +++ b/tests/unit/core/rewrite/test_identifiers.py @@ -11,8 +11,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import typing import bigframes.core as core +import bigframes.core.expression as ex import bigframes.core.identifiers as identifiers import bigframes.core.nodes as nodes import bigframes.core.rewrite.identifiers as id_rewrite @@ -130,3 +132,24 @@ def test_remap_variables_concat_self_stability(leaf): assert new_node1 == new_node2 assert mapping1 == mapping2 + + +def test_remap_variables_in_node_converts_dag_to_tree(leaf, leaf_too): + # Create an InNode with the same child twice, should create a tree from a DAG + node = nodes.InNode( + left_child=leaf, + right_child=leaf_too, + left_col=ex.DerefOp(identifiers.ColumnId("col_a")), + right_col=ex.DerefOp(identifiers.ColumnId("col_a")), + indicator_col=identifiers.ColumnId("indicator"), + ) + + id_generator = (identifiers.ColumnId(f"id_{i}") for i in range(100)) + new_node, _ = id_rewrite.remap_variables(node, id_generator) + new_node = typing.cast(nodes.InNode, new_node) + + left_col_id = new_node.left_col.id.name + right_col_id = new_node.right_col.id.name + assert left_col_id.startswith("id_") + assert right_col_id.startswith("id_") + assert left_col_id != right_col_id From 6bd67386341de7a92ada948381702430c399406e Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Thu, 11 Sep 2025 14:24:20 -0700 Subject: [PATCH 057/313] feat: support astype to json (#2073) --- bigframes/dtypes.py | 9 ++++++--- tests/system/small/test_series.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/bigframes/dtypes.py b/bigframes/dtypes.py index ef1b9e7871..ae68dbe7d3 100644 --- a/bigframes/dtypes.py +++ b/bigframes/dtypes.py @@ -641,6 +641,9 @@ def _dtype_from_string(dtype_string: str) -> typing.Optional[Dtype]: return BIGFRAMES_STRING_TO_BIGFRAMES[ typing.cast(DtypeString, str(dtype_string)) ] + if isinstance(dtype_string, str) and dtype_string.lower() == "json": + return JSON_DTYPE + raise TypeError( textwrap.dedent( f""" @@ -652,9 +655,9 @@ def _dtype_from_string(dtype_string: str) -> typing.Optional[Dtype]: The following pandas.ExtensionDtype are supported: pandas.BooleanDtype(), pandas.Float64Dtype(), pandas.Int64Dtype(), pandas.StringDtype(storage="pyarrow"), - pd.ArrowDtype(pa.date32()), pd.ArrowDtype(pa.time64("us")), - pd.ArrowDtype(pa.timestamp("us")), - pd.ArrowDtype(pa.timestamp("us", tz="UTC")). + pandas.ArrowDtype(pa.date32()), pandas.ArrowDtype(pa.time64("us")), + pandas.ArrowDtype(pa.timestamp("us")), + pandas.ArrowDtype(pa.timestamp("us", tz="UTC")). {constants.FEEDBACK_LINK} """ ) diff --git a/tests/system/small/test_series.py b/tests/system/small/test_series.py index 165e3b6df0..ca08f8dece 100644 --- a/tests/system/small/test_series.py +++ b/tests/system/small/test_series.py @@ -3903,6 +3903,18 @@ def test_float_astype_json(errors): pd.testing.assert_series_equal(bf_result.to_pandas(), expected_result) +def test_float_astype_json_str(): + data = ["1.25", "2500000000", None, "-12323.24"] + bf_series = series.Series(data, dtype=dtypes.FLOAT_DTYPE) + + bf_result = bf_series.astype("json") + assert bf_result.dtype == dtypes.JSON_DTYPE + + expected_result = pd.Series(data, dtype=dtypes.JSON_DTYPE) + expected_result.index = expected_result.index.astype("Int64") + pd.testing.assert_series_equal(bf_result.to_pandas(), expected_result) + + @pytest.mark.parametrize("errors", ["raise", "null"]) def test_string_astype_json(errors): data = [ From af76f56097692fc80c361f536845e7ce5592975d Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Thu, 11 Sep 2025 14:24:45 -0700 Subject: [PATCH 058/313] tests: add engine tests for casting to json (#2070) * tests: add engine tests for casting to json * remove test_engines_astype_struct_to_json for internal discussions 444196993 --- .../system/small/engines/test_generic_ops.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/system/small/engines/test_generic_ops.py b/tests/system/small/engines/test_generic_ops.py index 14c6e9a454..fc40b7e59d 100644 --- a/tests/system/small/engines/test_generic_ops.py +++ b/tests/system/small/engines/test_generic_ops.py @@ -275,6 +275,29 @@ def test_engines_astype_from_json(scalars_array_value: array_value.ArrayValue, e assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +def test_engines_astype_to_json(scalars_array_value: array_value.ArrayValue, engine): + exprs = [ + ops.AsTypeOp(to_type=bigframes.dtypes.JSON_DTYPE).as_expr( + expression.deref("int64_col") + ), + ops.AsTypeOp(to_type=bigframes.dtypes.JSON_DTYPE).as_expr( + # Use a const since float to json has precision issues + expression.const(5.2, bigframes.dtypes.FLOAT_DTYPE) + ), + ops.AsTypeOp(to_type=bigframes.dtypes.JSON_DTYPE).as_expr( + expression.deref("bool_col") + ), + ops.AsTypeOp(to_type=bigframes.dtypes.JSON_DTYPE).as_expr( + # Use a const since "str_col" has special chars. + expression.const('"hello world"', bigframes.dtypes.STRING_DTYPE) + ), + ] + arr, _ = scalars_array_value.compute_values(exprs) + + assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) + + @pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_astype_timedelta(scalars_array_value: array_value.ArrayValue, engine): arr = apply_op( From 36ee4d1c978e3b91af0b3a16b7875211eaa2c2b1 Mon Sep 17 00:00:00 2001 From: Garrett Wu <6505921+GarrettWu@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:17:25 -0700 Subject: [PATCH 059/313] chore: deprecate claude-3-sonnet model (#2074) --- bigframes/ml/llm.py | 6 +++++- tests/system/load/test_llm.py | 8 ++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/bigframes/ml/llm.py b/bigframes/ml/llm.py index eba15909b4..531a043c45 100644 --- a/bigframes/ml/llm.py +++ b/bigframes/ml/llm.py @@ -849,10 +849,14 @@ class Claude3TextGenerator(base.RetriableRemotePredictor): The models only available in specific regions. Check https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions for details. + .. note:: + + claude-3-sonnet model is deprecated. Use other models instead. + Args: model_name (str, Default to "claude-3-sonnet"): The model for natural language tasks. Possible values are "claude-3-sonnet", "claude-3-haiku", "claude-3-5-sonnet" and "claude-3-opus". - "claude-3-sonnet" is Anthropic's dependable combination of skills and speed. It is engineered to be dependable for scaled AI deployments across a variety of use cases. + "claude-3-sonnet" (deprecated) is Anthropic's dependable combination of skills and speed. It is engineered to be dependable for scaled AI deployments across a variety of use cases. "claude-3-haiku" is Anthropic's fastest, most compact vision and text model for near-instant responses to simple queries, meant for seamless AI experiences mimicking human interactions. "claude-3-5-sonnet" is Anthropic's most powerful AI model and maintains the speed and cost of Claude 3 Sonnet, which is a mid-tier model. "claude-3-opus" is Anthropic's second-most powerful AI model, with strong performance on highly complex tasks. diff --git a/tests/system/load/test_llm.py b/tests/system/load/test_llm.py index fc04956749..9630952e67 100644 --- a/tests/system/load/test_llm.py +++ b/tests/system/load/test_llm.py @@ -100,7 +100,7 @@ def test_llm_gemini_w_ground_with_google_search(llm_remote_text_df): # (b/366290533): Claude models are of extremely low capacity. The tests should reside in small tests. Moving these here just to protect BQML's shared capacity(as load test only runs once per day.) and make sure we still have minimum coverage. @pytest.mark.parametrize( "model_name", - ("claude-3-sonnet", "claude-3-haiku", "claude-3-5-sonnet", "claude-3-opus"), + ("claude-3-haiku", "claude-3-5-sonnet", "claude-3-opus"), ) @pytest.mark.flaky(retries=3, delay=120) def test_claude3_text_generator_create_load( @@ -125,7 +125,7 @@ def test_claude3_text_generator_create_load( @pytest.mark.parametrize( "model_name", - ("claude-3-sonnet", "claude-3-haiku", "claude-3-5-sonnet", "claude-3-opus"), + ("claude-3-haiku", "claude-3-5-sonnet", "claude-3-opus"), ) @pytest.mark.flaky(retries=3, delay=120) def test_claude3_text_generator_predict_default_params_success( @@ -144,7 +144,7 @@ def test_claude3_text_generator_predict_default_params_success( @pytest.mark.parametrize( "model_name", - ("claude-3-sonnet", "claude-3-haiku", "claude-3-5-sonnet", "claude-3-opus"), + ("claude-3-haiku", "claude-3-5-sonnet", "claude-3-opus"), ) @pytest.mark.flaky(retries=3, delay=120) def test_claude3_text_generator_predict_with_params_success( @@ -165,7 +165,7 @@ def test_claude3_text_generator_predict_with_params_success( @pytest.mark.parametrize( "model_name", - ("claude-3-sonnet", "claude-3-haiku", "claude-3-5-sonnet", "claude-3-opus"), + ("claude-3-haiku", "claude-3-5-sonnet", "claude-3-opus"), ) @pytest.mark.flaky(retries=3, delay=120) def test_claude3_text_generator_predict_multi_col_success( From 17a1ed99ec8c6d3215d3431848814d5d458d4ff1 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 12 Sep 2025 12:19:28 -0700 Subject: [PATCH 060/313] feat: Can call agg with some callables (#2055) --- bigframes/core/groupby/dataframe_group_by.py | 26 ++++++------ bigframes/core/groupby/series_group_by.py | 17 ++++---- bigframes/dataframe.py | 25 +++++------- bigframes/operations/aggregations.py | 42 +++++++++++++------ bigframes/series.py | 6 +-- tests/system/small/test_dataframe.py | 17 +++++++- tests/system/small/test_groupby.py | 43 +++++++++++++------- 7 files changed, 106 insertions(+), 70 deletions(-) diff --git a/bigframes/core/groupby/dataframe_group_by.py b/bigframes/core/groupby/dataframe_group_by.py index 3f5480436a..7d3d3ada69 100644 --- a/bigframes/core/groupby/dataframe_group_by.py +++ b/bigframes/core/groupby/dataframe_group_by.py @@ -461,23 +461,19 @@ def expanding(self, min_periods: int = 1) -> windows.Window: def agg(self, func=None, **kwargs) -> typing.Union[df.DataFrame, series.Series]: if func: - if isinstance(func, str): - return self.size() if func == "size" else self._agg_string(func) - elif utils.is_dict_like(func): + if utils.is_dict_like(func): return self._agg_dict(func) elif utils.is_list_like(func): return self._agg_list(func) else: - raise NotImplementedError( - f"Aggregate with {func} not supported. {constants.FEEDBACK_LINK}" - ) + return self.size() if func == "size" else self._agg_func(func) else: return self._agg_named(**kwargs) - def _agg_string(self, func: str) -> df.DataFrame: + def _agg_func(self, func) -> df.DataFrame: ids, labels = self._aggregated_columns() aggregations = [ - aggs.agg(col_id, agg_ops.lookup_agg_func(func)) for col_id in ids + aggs.agg(col_id, agg_ops.lookup_agg_func(func)[0]) for col_id in ids ] agg_block, _ = self._block.aggregate( by_column_ids=self._by_col_ids, @@ -500,7 +496,7 @@ def _agg_dict(self, func: typing.Mapping) -> df.DataFrame: funcs_for_id if utils.is_list_like(funcs_for_id) else [funcs_for_id] ) for f in func_list: - aggregations.append(aggs.agg(col_id, agg_ops.lookup_agg_func(f))) + aggregations.append(aggs.agg(col_id, agg_ops.lookup_agg_func(f)[0])) column_labels.append(label) agg_block, _ = self._block.aggregate( by_column_ids=self._by_col_ids, @@ -525,19 +521,23 @@ def _agg_dict(self, func: typing.Mapping) -> df.DataFrame: def _agg_list(self, func: typing.Sequence) -> df.DataFrame: ids, labels = self._aggregated_columns() aggregations = [ - aggs.agg(col_id, agg_ops.lookup_agg_func(f)) for col_id in ids for f in func + aggs.agg(col_id, agg_ops.lookup_agg_func(f)[0]) + for col_id in ids + for f in func ] if self._block.column_labels.nlevels > 1: # Restructure MultiIndex for proper format: (idx1, idx2, func) # rather than ((idx1, idx2), func). column_labels = [ - tuple(label) + (f,) + tuple(label) + (agg_ops.lookup_agg_func(f)[1],) for label in labels.to_frame(index=False).to_numpy() for f in func ] else: # Single-level index - column_labels = [(label, f) for label in labels for f in func] + column_labels = [ + (label, agg_ops.lookup_agg_func(f)[1]) for label in labels for f in func + ] agg_block, _ = self._block.aggregate( by_column_ids=self._by_col_ids, @@ -563,7 +563,7 @@ def _agg_named(self, **kwargs) -> df.DataFrame: if not isinstance(v, tuple) or (len(v) != 2): raise TypeError("kwargs values must be 2-tuples of column, aggfunc") col_id = self._resolve_label(v[0]) - aggregations.append(aggs.agg(col_id, agg_ops.lookup_agg_func(v[1]))) + aggregations.append(aggs.agg(col_id, agg_ops.lookup_agg_func(v[1])[0])) column_labels.append(k) agg_block, _ = self._block.aggregate( by_column_ids=self._by_col_ids, diff --git a/bigframes/core/groupby/series_group_by.py b/bigframes/core/groupby/series_group_by.py index 7a8bdcb6cf..041cc1b3dd 100644 --- a/bigframes/core/groupby/series_group_by.py +++ b/bigframes/core/groupby/series_group_by.py @@ -216,18 +216,17 @@ def prod(self, *args) -> series.Series: def agg(self, func=None) -> typing.Union[df.DataFrame, series.Series]: column_names: list[str] = [] - if isinstance(func, str): - aggregations = [aggs.agg(self._value_column, agg_ops.lookup_agg_func(func))] - column_names = [func] - elif utils.is_list_like(func): - aggregations = [ - aggs.agg(self._value_column, agg_ops.lookup_agg_func(f)) for f in func - ] - column_names = list(func) - else: + if utils.is_dict_like(func): raise NotImplementedError( f"Aggregate with {func} not supported. {constants.FEEDBACK_LINK}" ) + if not utils.is_list_like(func): + func = [func] + + aggregations = [ + aggs.agg(self._value_column, agg_ops.lookup_agg_func(f)[0]) for f in func + ] + column_names = [agg_ops.lookup_agg_func(f)[1] for f in func] agg_block, _ = self._block.aggregate( by_column_ids=self._by_col_ids, diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 5a93646677..f9fa29b10c 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -3172,12 +3172,7 @@ def nunique(self) -> bigframes.series.Series: block = self._block.aggregate_all_and_stack(agg_ops.nunique_op) return bigframes.series.Series(block) - def agg( - self, - func: str - | typing.Sequence[str] - | typing.Mapping[blocks.Label, typing.Sequence[str] | str], - ) -> DataFrame | bigframes.series.Series: + def agg(self, func) -> DataFrame | bigframes.series.Series: if utils.is_dict_like(func): # Must check dict-like first because dictionaries are list-like # according to Pandas. @@ -3191,15 +3186,17 @@ def agg( if col_id is None: raise KeyError(f"Column {col_label} does not exist") for agg_func in agg_func_list: - agg_op = agg_ops.lookup_agg_func(typing.cast(str, agg_func)) + op_and_label = agg_ops.lookup_agg_func(agg_func) agg_expr = ( - agg_expressions.UnaryAggregation(agg_op, ex.deref(col_id)) - if isinstance(agg_op, agg_ops.UnaryAggregateOp) - else agg_expressions.NullaryAggregation(agg_op) + agg_expressions.UnaryAggregation( + op_and_label[0], ex.deref(col_id) + ) + if isinstance(op_and_label[0], agg_ops.UnaryAggregateOp) + else agg_expressions.NullaryAggregation(op_and_label[0]) ) aggs.append(agg_expr) labels.append(col_label) - funcnames.append(agg_func) + funcnames.append(op_and_label[1]) # if any list in dict values, format output differently if any(utils.is_list_like(v) for v in func.values()): @@ -3220,7 +3217,7 @@ def agg( ) ) elif utils.is_list_like(func): - aggregations = [agg_ops.lookup_agg_func(f) for f in func] + aggregations = [agg_ops.lookup_agg_func(f)[0] for f in func] for dtype, agg in itertools.product(self.dtypes, aggregations): agg.output_type( @@ -3236,9 +3233,7 @@ def agg( else: # function name string return bigframes.series.Series( - self._block.aggregate_all_and_stack( - agg_ops.lookup_agg_func(typing.cast(str, func)) - ) + self._block.aggregate_all_and_stack(agg_ops.lookup_agg_func(func)[0]) ) aggregate = agg diff --git a/bigframes/operations/aggregations.py b/bigframes/operations/aggregations.py index 0ee80fd74b..02b475d198 100644 --- a/bigframes/operations/aggregations.py +++ b/bigframes/operations/aggregations.py @@ -17,8 +17,9 @@ import abc import dataclasses import typing -from typing import ClassVar, Iterable, Optional, TYPE_CHECKING +from typing import Callable, ClassVar, Iterable, Optional, TYPE_CHECKING +import numpy as np import pandas as pd import pyarrow as pa @@ -678,7 +679,7 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT # TODO: Alternative names and lookup from numpy function objects -_AGGREGATIONS_LOOKUP: typing.Dict[ +_STRING_TO_AGG_OP: typing.Dict[ str, typing.Union[UnaryAggregateOp, NullaryAggregateOp] ] = { op.name: op @@ -705,17 +706,32 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT ] } +_CALLABLE_TO_AGG_OP: typing.Dict[ + Callable, typing.Union[UnaryAggregateOp, NullaryAggregateOp] +] = { + np.sum: sum_op, + np.mean: mean_op, + np.median: median_op, + np.prod: product_op, + np.max: max_op, + np.min: min_op, + np.std: std_op, + np.var: var_op, + np.all: all_op, + np.any: any_op, + np.unique: nunique_op, + # TODO(b/443252872): Solve + # list: ArrayAggOp(), + np.size: size_op, +} -def lookup_agg_func(key: str) -> typing.Union[UnaryAggregateOp, NullaryAggregateOp]: - if callable(key): - raise NotImplementedError( - "Aggregating with callable object not supported, pass method name as string instead (eg. 'sum' instead of np.sum)." - ) - if not isinstance(key, str): - raise ValueError( - f"Cannot aggregate using object of type: {type(key)}. Use string method name (eg. 'sum')" - ) - if key in _AGGREGATIONS_LOOKUP: - return _AGGREGATIONS_LOOKUP[key] + +def lookup_agg_func( + key, +) -> tuple[typing.Union[UnaryAggregateOp, NullaryAggregateOp], str]: + if key in _STRING_TO_AGG_OP: + return (_STRING_TO_AGG_OP[key], key) + if key in _CALLABLE_TO_AGG_OP: + return (_CALLABLE_TO_AGG_OP[key], key.__name__) else: raise ValueError(f"Unrecognize aggregate function: {key}") diff --git a/bigframes/series.py b/bigframes/series.py index 3e24a75d9b..e44cf417ab 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -1330,7 +1330,7 @@ def agg(self, func: str | typing.Sequence[str]) -> scalars.Scalar | Series: raise NotImplementedError( f"Multiple aggregations only supported on numeric series. {constants.FEEDBACK_LINK}" ) - aggregations = [agg_ops.lookup_agg_func(f) for f in func] + aggregations = [agg_ops.lookup_agg_func(f)[0] for f in func] return Series( self._block.summarize( [self._value_column], @@ -1338,9 +1338,7 @@ def agg(self, func: str | typing.Sequence[str]) -> scalars.Scalar | Series: ) ) else: - return self._apply_aggregation( - agg_ops.lookup_agg_func(typing.cast(str, func)) - ) + return self._apply_aggregation(agg_ops.lookup_agg_func(func)[0]) aggregate = agg aggregate.__doc__ = inspect.getdoc(vendored_pandas_series.Series.agg) diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 15f800097b..95aec9906f 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -6016,7 +6016,7 @@ def test_astype_invalid_type_fail(scalars_dfs): bf_df.astype(123) -def test_agg_with_dict_lists(scalars_dfs): +def test_agg_with_dict_lists_strings(scalars_dfs): bf_df, pd_df = scalars_dfs agg_funcs = { "int64_too": ["min", "max"], @@ -6031,6 +6031,21 @@ def test_agg_with_dict_lists(scalars_dfs): ) +def test_agg_with_dict_lists_callables(scalars_dfs): + bf_df, pd_df = scalars_dfs + agg_funcs = { + "int64_too": [np.min, np.max], + "int64_col": [np.min, np.var], + } + + bf_result = bf_df.agg(agg_funcs).to_pandas() + pd_result = pd_df.agg(agg_funcs) + + pd.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + def test_agg_with_dict_list_and_str(scalars_dfs): bf_df, pd_df = scalars_dfs agg_funcs = { diff --git a/tests/system/small/test_groupby.py b/tests/system/small/test_groupby.py index 5c89363e9b..dba8d46676 100644 --- a/tests/system/small/test_groupby.py +++ b/tests/system/small/test_groupby.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import numpy as np import pandas as pd import pytest @@ -218,16 +219,21 @@ def test_dataframe_groupby_agg_size_string(scalars_df_index, scalars_pandas_df_i def test_dataframe_groupby_agg_list(scalars_df_index, scalars_pandas_df_index): col_names = ["int64_too", "float64_col", "int64_col", "bool_col", "string_col"] bf_result = ( - scalars_df_index[col_names].groupby("string_col").agg(["count", "min", "size"]) + scalars_df_index[col_names].groupby("string_col").agg(["count", np.min, "size"]) ) pd_result = ( scalars_pandas_df_index[col_names] .groupby("string_col") - .agg(["count", "min", "size"]) + .agg(["count", np.min, "size"]) ) bf_result_computed = bf_result.to_pandas() - pd.testing.assert_frame_equal(pd_result, bf_result_computed, check_dtype=False) + # some inconsistency between versions, so normalize to bigframes behavior + pd_result = pd_result.rename({"amin": "min"}, axis="columns") + bf_result_computed = bf_result_computed.rename({"amin": "min"}, axis="columns") + pd.testing.assert_frame_equal( + pd_result, bf_result_computed, check_dtype=False, check_index_type=False + ) def test_dataframe_groupby_agg_list_w_column_multi_index( @@ -240,8 +246,8 @@ def test_dataframe_groupby_agg_list_w_column_multi_index( pd_df = scalars_pandas_df_index[columns].copy() pd_df.columns = multi_columns - bf_result = bf_df.groupby(level=0).agg(["count", "min", "size"]) - pd_result = pd_df.groupby(level=0).agg(["count", "min", "size"]) + bf_result = bf_df.groupby(level=0).agg(["count", np.min, "size"]) + pd_result = pd_df.groupby(level=0).agg(["count", np.min, "size"]) bf_result_computed = bf_result.to_pandas() pd.testing.assert_frame_equal(pd_result, bf_result_computed, check_dtype=False) @@ -261,15 +267,21 @@ def test_dataframe_groupby_agg_dict_with_list( bf_result = ( scalars_df_index[col_names] .groupby("string_col", as_index=as_index) - .agg({"int64_too": ["mean", "max"], "string_col": "count", "bool_col": "size"}) + .agg( + {"int64_too": [np.mean, np.max], "string_col": "count", "bool_col": "size"} + ) ) pd_result = ( scalars_pandas_df_index[col_names] .groupby("string_col", as_index=as_index) - .agg({"int64_too": ["mean", "max"], "string_col": "count", "bool_col": "size"}) + .agg( + {"int64_too": [np.mean, np.max], "string_col": "count", "bool_col": "size"} + ) ) bf_result_computed = bf_result.to_pandas() + # some inconsistency between versions, so normalize to bigframes behavior + pd_result = pd_result.rename({"amax": "max"}, axis="columns") pd.testing.assert_frame_equal( pd_result, bf_result_computed, check_dtype=False, check_index_type=False ) @@ -280,12 +292,12 @@ def test_dataframe_groupby_agg_dict_no_lists(scalars_df_index, scalars_pandas_df bf_result = ( scalars_df_index[col_names] .groupby("string_col") - .agg({"int64_too": "mean", "string_col": "count"}) + .agg({"int64_too": np.mean, "string_col": "count"}) ) pd_result = ( scalars_pandas_df_index[col_names] .groupby("string_col") - .agg({"int64_too": "mean", "string_col": "count"}) + .agg({"int64_too": np.mean, "string_col": "count"}) ) bf_result_computed = bf_result.to_pandas() @@ -298,7 +310,7 @@ def test_dataframe_groupby_agg_named(scalars_df_index, scalars_pandas_df_index): scalars_df_index[col_names] .groupby("string_col") .agg( - agg1=bpd.NamedAgg("int64_too", "sum"), + agg1=bpd.NamedAgg("int64_too", np.sum), agg2=bpd.NamedAgg("float64_col", "max"), ) ) @@ -306,7 +318,8 @@ def test_dataframe_groupby_agg_named(scalars_df_index, scalars_pandas_df_index): scalars_pandas_df_index[col_names] .groupby("string_col") .agg( - agg1=pd.NamedAgg("int64_too", "sum"), agg2=pd.NamedAgg("float64_col", "max") + agg1=pd.NamedAgg("int64_too", np.sum), + agg2=pd.NamedAgg("float64_col", "max"), ) ) bf_result_computed = bf_result.to_pandas() @@ -320,14 +333,14 @@ def test_dataframe_groupby_agg_kw_tuples(scalars_df_index, scalars_pandas_df_ind scalars_df_index[col_names] .groupby("string_col") .agg( - agg1=("int64_too", "sum"), + agg1=("int64_too", np.sum), agg2=("float64_col", "max"), ) ) pd_result = ( scalars_pandas_df_index[col_names] .groupby("string_col") - .agg(agg1=("int64_too", "sum"), agg2=("float64_col", "max")) + .agg(agg1=("int64_too", np.sum), agg2=("float64_col", "max")) ) bf_result_computed = bf_result.to_pandas() @@ -709,12 +722,12 @@ def test_series_groupby_agg_list(scalars_df_index, scalars_pandas_df_index): bf_result = ( scalars_df_index["int64_col"] .groupby(scalars_df_index["string_col"]) - .agg(["sum", "mean", "size"]) + .agg(["sum", np.mean, "size"]) ) pd_result = ( scalars_pandas_df_index["int64_col"] .groupby(scalars_pandas_df_index["string_col"]) - .agg(["sum", "mean", "size"]) + .agg(["sum", np.mean, "size"]) ) bf_result_computed = bf_result.to_pandas() From 12e438051134577e911c1a6ce9d5a5885a0b45ad Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 12 Sep 2025 14:07:03 -0700 Subject: [PATCH 061/313] perf: Improve apply axis=1 performance (#2077) --- bigframes/core/blocks.py | 111 ++++-------------- .../ibis_compiler/scalar_op_registry.py | 8 +- bigframes/core/compile/ibis_types.py | 4 - bigframes/dtypes.py | 3 +- bigframes/operations/json_ops.py | 6 - bigframes/operations/struct_ops.py | 2 +- tests/unit/core/test_dtypes.py | 7 -- 7 files changed, 31 insertions(+), 110 deletions(-) diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index d62173b7d6..b2d9d10107 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -27,7 +27,6 @@ import functools import itertools import random -import textwrap import typing from typing import ( Iterable, @@ -54,7 +53,6 @@ from bigframes.core import agg_expressions, local_data import bigframes.core as core import bigframes.core.agg_expressions as ex_types -import bigframes.core.compile.googlesql as googlesql import bigframes.core.expression as ex import bigframes.core.expression as scalars import bigframes.core.guid as guid @@ -62,8 +60,6 @@ import bigframes.core.join_def as join_defs import bigframes.core.ordering as ordering import bigframes.core.pyarrow_utils as pyarrow_utils -import bigframes.core.schema as bf_schema -import bigframes.core.sql as sql import bigframes.core.utils as utils import bigframes.core.window_spec as windows import bigframes.dtypes @@ -2776,14 +2772,6 @@ def _throw_if_null_index(self, opname: str): ) def _get_rows_as_json_values(self) -> Block: - # We want to preserve any ordering currently present before turning to - # direct SQL manipulation. We will restore the ordering when we rebuild - # expression. - # TODO(shobs): Replace direct SQL manipulation by structured expression - # manipulation - expr, ordering_column_name = self.expr.promote_offsets() - expr_sql = self.session._executor.to_sql(expr) - # Names of the columns to serialize for the row. # We will use the repr-eval pattern to serialize a value here and # deserialize in the cloud function. Let's make sure that would work. @@ -2799,93 +2787,44 @@ def _get_rows_as_json_values(self) -> Block: ) column_names.append(serialized_column_name) - column_names_csv = sql.csv(map(sql.simple_literal, column_names)) - - # index columns count - index_columns_count = len(self.index_columns) # column references to form the array of values for the row column_types = list(self.index.dtypes) + list(self.dtypes) column_references = [] for type_, col in zip(column_types, self.expr.column_ids): - if isinstance(type_, pd.ArrowDtype) and pa.types.is_binary( - type_.pyarrow_dtype - ): - column_references.append(sql.to_json_string(col)) + if type_ == bigframes.dtypes.BYTES_DTYPE: + column_references.append(ops.ToJSONString().as_expr(col)) + elif type_ == bigframes.dtypes.BOOL_DTYPE: + # cast operator produces True/False, but function template expects lower case + column_references.append( + ops.lower_op.as_expr( + ops.AsTypeOp(bigframes.dtypes.STRING_DTYPE).as_expr(col) + ) + ) else: - column_references.append(sql.cast_as_string(col)) - - column_references_csv = sql.csv(column_references) - - # types of the columns to serialize for the row - column_types_csv = sql.csv( - [sql.simple_literal(str(typ)) for typ in column_types] - ) + column_references.append( + ops.AsTypeOp(bigframes.dtypes.STRING_DTYPE).as_expr(col) + ) # row dtype to use for deserializing the row as pandas series pandas_row_dtype = bigframes.dtypes.lcd_type(*column_types) if pandas_row_dtype is None: pandas_row_dtype = "object" - pandas_row_dtype = sql.simple_literal(str(pandas_row_dtype)) - - # create a json column representing row through SQL manipulation - row_json_column_name = guid.generate_guid() - select_columns = ( - [ordering_column_name] + list(self.index_columns) + [row_json_column_name] - ) - select_columns_csv = sql.csv( - [googlesql.identifier(col) for col in select_columns] - ) - json_sql = f"""\ -With T0 AS ( -{textwrap.indent(expr_sql, " ")} -), -T1 AS ( - SELECT *, - TO_JSON_STRING(JSON_OBJECT( - "names", [{column_names_csv}], - "types", [{column_types_csv}], - "values", [{column_references_csv}], - "indexlength", {index_columns_count}, - "dtype", {pandas_row_dtype} - )) AS {googlesql.identifier(row_json_column_name)} FROM T0 -) -SELECT {select_columns_csv} FROM T1 -""" - # The only ways this code is used is through df.apply(axis=1) cope path - destination, query_job = self.session._loader._query_to_destination( - json_sql, cluster_candidates=[ordering_column_name] - ) - if not destination: - raise ValueError(f"Query job {query_job} did not produce result table") - - new_schema = ( - self.expr.schema.select([*self.index_columns]) - .append( - bf_schema.SchemaItem( - row_json_column_name, bigframes.dtypes.STRING_DTYPE - ) - ) - .append( - bf_schema.SchemaItem(ordering_column_name, bigframes.dtypes.INT_DTYPE) - ) - ) + pandas_row_dtype = str(pandas_row_dtype) - dest_table = self.session.bqclient.get_table(destination) - expr = core.ArrayValue.from_table( - dest_table, - schema=new_schema, - session=self.session, - offsets_col=ordering_column_name, - n_rows=dest_table.num_rows, - ).drop_columns([ordering_column_name]) - block = Block( - expr, - index_columns=self.index_columns, - column_labels=[row_json_column_name], - index_labels=self._index_labels, + struct_op = ops.StructOp( + column_names=("names", "types", "values", "indexlength", "dtype") ) - return block + names_val = ex.const(tuple(column_names)) + types_val = ex.const(tuple(map(str, column_types))) + values_val = ops.ToArrayOp().as_expr(*column_references) + indexlength_val = ex.const(len(self.index_columns)) + dtype_val = ex.const(str(pandas_row_dtype)) + struct_expr = struct_op.as_expr( + names_val, types_val, values_val, indexlength_val, dtype_val + ) + block, col_id = self.project_expr(ops.ToJSONString().as_expr(struct_expr)) + return block.select_column(col_id) class BlockIndexProperties: diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index 044fc90306..a37d390b51 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -1301,8 +1301,8 @@ def parse_json_op_impl(x: ibis_types.Value, op: ops.ParseJSON): @scalar_op_compiler.register_unary_op(ops.ToJSONString) -def to_json_string_op_impl(json_obj: ibis_types.Value): - return to_json_string(json_obj=json_obj) +def to_json_string_op_impl(x: ibis_types.Value): + return to_json_string(value=x) @scalar_op_compiler.register_unary_op(ops.JSONValue, pass_op=True) @@ -2069,9 +2069,9 @@ def json_extract_string_array( # type: ignore[empty-body] @ibis_udf.scalar.builtin(name="to_json_string") def to_json_string( # type: ignore[empty-body] - json_obj: ibis_dtypes.JSON, + value, ) -> ibis_dtypes.String: - """Convert JSON to STRING.""" + """Convert value to JSON-formatted string.""" @ibis_udf.scalar.builtin(name="json_value") diff --git a/bigframes/core/compile/ibis_types.py b/bigframes/core/compile/ibis_types.py index 0a61be716a..25b59d4582 100644 --- a/bigframes/core/compile/ibis_types.py +++ b/bigframes/core/compile/ibis_types.py @@ -386,10 +386,6 @@ def literal_to_ibis_scalar( ibis_dtype = bigframes_dtype_to_ibis_dtype(force_dtype) if force_dtype else None if pd.api.types.is_list_like(literal): - if validate: - raise ValueError( - f"List types can't be stored in BigQuery DataFrames. {constants.FEEDBACK_LINK}" - ) # "correct" way would be to use ibis.array, but this produces invalid BQ SQL syntax return tuple(literal) diff --git a/bigframes/dtypes.py b/bigframes/dtypes.py index ae68dbe7d3..2c4cccefd2 100644 --- a/bigframes/dtypes.py +++ b/bigframes/dtypes.py @@ -671,8 +671,7 @@ def infer_literal_type(literal) -> typing.Optional[Dtype]: if pd.api.types.is_list_like(literal): element_types = [infer_literal_type(i) for i in literal] common_type = lcd_type(*element_types) - as_arrow = bigframes_dtype_to_arrow_dtype(common_type) - return pd.ArrowDtype(as_arrow) + return list_type(common_type) if pd.api.types.is_dict_like(literal): fields = [] for key in literal.keys(): diff --git a/bigframes/operations/json_ops.py b/bigframes/operations/json_ops.py index b1f4f2f689..d3f62fb4f2 100644 --- a/bigframes/operations/json_ops.py +++ b/bigframes/operations/json_ops.py @@ -107,12 +107,6 @@ class ToJSONString(base_ops.UnaryOp): name: typing.ClassVar[str] = "to_json_string" def output_type(self, *input_types): - input_type = input_types[0] - if not dtypes.is_json_like(input_type): - raise TypeError( - "Input type must be a valid JSON object or JSON-formatted string type." - + f" Received type: {input_type}" - ) return dtypes.STRING_DTYPE diff --git a/bigframes/operations/struct_ops.py b/bigframes/operations/struct_ops.py index 0926142b17..de51efd8a4 100644 --- a/bigframes/operations/struct_ops.py +++ b/bigframes/operations/struct_ops.py @@ -43,7 +43,7 @@ def output_type(self, *input_types): @dataclasses.dataclass(frozen=True) class StructOp(base_ops.NaryOp): name: typing.ClassVar[str] = "struct" - column_names: tuple[str] + column_names: tuple[str, ...] def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: num_input_types = len(input_types) diff --git a/tests/unit/core/test_dtypes.py b/tests/unit/core/test_dtypes.py index cd23614bbf..b72a781e56 100644 --- a/tests/unit/core/test_dtypes.py +++ b/tests/unit/core/test_dtypes.py @@ -267,13 +267,6 @@ def test_literal_to_ibis_scalar_converts(literal, ibis_scalar): ) -def test_literal_to_ibis_scalar_throws_on_incompatible_literal(): - with pytest.raises( - ValueError, - ): - bigframes.core.compile.ibis_types.literal_to_ibis_scalar({"mykey": "myval"}) - - @pytest.mark.parametrize( ["scalar", "expected_dtype"], [ From 49b91e878de651de23649756259ee35709e3f5a8 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Fri, 12 Sep 2025 15:14:52 -0700 Subject: [PATCH 062/313] fix: Use the remote and managed functions for bigframes results (#2079) --- tests/system/large/functions/test_managed_function.py | 2 +- tests/system/large/functions/test_remote_function.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/system/large/functions/test_managed_function.py b/tests/system/large/functions/test_managed_function.py index 0a04480a78..dd08ed17d9 100644 --- a/tests/system/large/functions/test_managed_function.py +++ b/tests/system/large/functions/test_managed_function.py @@ -1166,7 +1166,7 @@ def is_sum_positive_series(s): pd_int64_df_filtered = pd_int64_df.dropna() # Test callable condition in dataframe.where method. - bf_result = bf_int64_df_filtered.where(is_sum_positive_series).to_pandas() + bf_result = bf_int64_df_filtered.where(is_sum_positive_series_mf).to_pandas() pd_result = pd_int64_df_filtered.where(is_sum_positive_series) # Ignore any dtype difference. diff --git a/tests/system/large/functions/test_remote_function.py b/tests/system/large/functions/test_remote_function.py index 22b623193d..99a614532d 100644 --- a/tests/system/large/functions/test_remote_function.py +++ b/tests/system/large/functions/test_remote_function.py @@ -3072,7 +3072,7 @@ def func_for_other(x): # Test callable condition in dataframe.where method. bf_result = bf_int64_df_filtered.where( - is_sum_positive_series, func_for_other + is_sum_positive_series_mf, func_for_other ).to_pandas() pd_result = pd_int64_df_filtered.where(is_sum_positive_series, func_for_other) From 41e8f33ceb46a7c2a75d1c59a4a3f2f9413d281d Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Fri, 12 Sep 2025 16:06:14 -0700 Subject: [PATCH 063/313] feat: add bigframes.bigquery.to_json_string (#2076) --- bigframes/bigquery/__init__.py | 2 ++ bigframes/bigquery/_operations/json.py | 34 +++++++++++++++++++ .../ibis_compiler/scalar_op_registry.py | 4 +-- bigframes/operations/json_ops.py | 6 ++++ tests/system/small/bigquery/test_json.py | 25 ++++++++++++++ 5 files changed, 68 insertions(+), 3 deletions(-) diff --git a/bigframes/bigquery/__init__.py b/bigframes/bigquery/__init__.py index 32412648d6..7b74c1eb88 100644 --- a/bigframes/bigquery/__init__.py +++ b/bigframes/bigquery/__init__.py @@ -50,6 +50,7 @@ json_value, json_value_array, parse_json, + to_json_string, ) from bigframes.bigquery._operations.search import create_vector_index, vector_search from bigframes.bigquery._operations.sql import sql_scalar @@ -87,6 +88,7 @@ json_value, json_value_array, parse_json, + to_json_string, # search ops create_vector_index, vector_search, diff --git a/bigframes/bigquery/_operations/json.py b/bigframes/bigquery/_operations/json.py index 7ad7855dba..a972380334 100644 --- a/bigframes/bigquery/_operations/json.py +++ b/bigframes/bigquery/_operations/json.py @@ -430,6 +430,40 @@ def json_value_array( return input._apply_unary_op(ops.JSONValueArray(json_path=json_path)) +def to_json_string( + input: series.Series, +) -> series.Series: + """Converts a series to a JSON-formatted STRING value. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> bpd.options.display.progress_bar = None + + >>> s = bpd.Series([1, 2, 3]) + >>> bbq.to_json_string(s) + 0 1 + 1 2 + 2 3 + dtype: string + + >>> s = bpd.Series([{"int": 1, "str": "pandas"}, {"int": 2, "str": "numpy"}]) + >>> bbq.to_json_string(s) + 0 {"int":1,"str":"pandas"} + 1 {"int":2,"str":"numpy"} + dtype: string + + Args: + input (bigframes.series.Series): + The Series to be converted. + + Returns: + bigframes.series.Series: A new Series with the JSON-formatted STRING value. + """ + return input._apply_unary_op(ops.ToJSONString()) + + @utils.preview(name="The JSON-related API `parse_json`") def parse_json( input: series.Series, diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index a37d390b51..af98252643 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -2068,9 +2068,7 @@ def json_extract_string_array( # type: ignore[empty-body] @ibis_udf.scalar.builtin(name="to_json_string") -def to_json_string( # type: ignore[empty-body] - value, -) -> ibis_dtypes.String: +def to_json_string(value) -> ibis_dtypes.String: # type: ignore[empty-body] """Convert value to JSON-formatted string.""" diff --git a/bigframes/operations/json_ops.py b/bigframes/operations/json_ops.py index d3f62fb4f2..b1186e433c 100644 --- a/bigframes/operations/json_ops.py +++ b/bigframes/operations/json_ops.py @@ -107,6 +107,12 @@ class ToJSONString(base_ops.UnaryOp): name: typing.ClassVar[str] = "to_json_string" def output_type(self, *input_types): + input_type = input_types[0] + if not dtypes.is_json_encoding_type(input_type): + raise TypeError( + "The value to be assigned must be a type that can be encoded as JSON." + + f"Received type: {input_type}" + ) return dtypes.STRING_DTYPE diff --git a/tests/system/small/bigquery/test_json.py b/tests/system/small/bigquery/test_json.py index 4ecbd01318..213db0849e 100644 --- a/tests/system/small/bigquery/test_json.py +++ b/tests/system/small/bigquery/test_json.py @@ -384,3 +384,28 @@ def test_parse_json_w_invalid_series_type(): s = bpd.Series([1, 2]) with pytest.raises(TypeError): bbq.parse_json(s) + + +def test_to_json_string_from_int(): + s = bpd.Series([1, 2, None, 3]) + actual = bbq.to_json_string(s) + expected = bpd.Series(["1", "2", "null", "3"], dtype=dtypes.STRING_DTYPE) + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_to_json_string_from_struct(): + s = bpd.Series( + [ + {"version": 1, "project": "pandas"}, + {"version": 2, "project": "numpy"}, + ] + ) + assert dtypes.is_struct_like(s.dtype) + + actual = bbq.to_json_string(s) + expected = bpd.Series( + ['{"project":"pandas","version":1}', '{"project":"numpy","version":2}'], + dtype=dtypes.STRING_DTYPE, + ) + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) From 5a1d1de2755f4ddaa986aeb8a4b9666a30b8b406 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Fri, 12 Sep 2025 16:42:14 -0700 Subject: [PATCH 064/313] refactor: add compile_isin_join (#1886) --- bigframes/core/compile/sqlglot/compiler.py | 22 +++++++ bigframes/core/compile/sqlglot/sqlglot_ir.py | 62 +++++++++++++++++++ tests/unit/core/compile/sqlglot/conftest.py | 2 +- .../test_compile_isin/out.sql | 37 +++++++++++ .../test_compile_isin_not_nullable/out.sql | 30 +++++++++ .../core/compile/sqlglot/test_compile_isin.py | 39 ++++++++++++ 6 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin/out.sql create mode 100644 tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin_not_nullable/out.sql create mode 100644 tests/unit/core/compile/sqlglot/test_compile_isin.py diff --git a/bigframes/core/compile/sqlglot/compiler.py b/bigframes/core/compile/sqlglot/compiler.py index b4dc6174be..8364e757a1 100644 --- a/bigframes/core/compile/sqlglot/compiler.py +++ b/bigframes/core/compile/sqlglot/compiler.py @@ -244,6 +244,28 @@ def compile_join( joins_nulls=node.joins_nulls, ) + @_compile_node.register + def compile_isin_join( + self, node: nodes.InNode, left: ir.SQLGlotIR, right: ir.SQLGlotIR + ) -> ir.SQLGlotIR: + conditions = ( + typed_expr.TypedExpr( + scalar_compiler.compile_scalar_expression(node.left_col), + node.left_col.output_type, + ), + typed_expr.TypedExpr( + scalar_compiler.compile_scalar_expression(node.right_col), + node.right_col.output_type, + ), + ) + + return left.isin_join( + right, + indicator_col=node.indicator_col.sql, + conditions=conditions, + joins_nulls=node.joins_nulls, + ) + @_compile_node.register def compile_concat( self, node: nodes.ConcatNode, *children: ir.SQLGlotIR diff --git a/bigframes/core/compile/sqlglot/sqlglot_ir.py b/bigframes/core/compile/sqlglot/sqlglot_ir.py index 1a00cd0a93..9c81eda044 100644 --- a/bigframes/core/compile/sqlglot/sqlglot_ir.py +++ b/bigframes/core/compile/sqlglot/sqlglot_ir.py @@ -336,6 +336,68 @@ def join( return SQLGlotIR(expr=new_expr, uid_gen=self.uid_gen) + def isin_join( + self, + right: SQLGlotIR, + indicator_col: str, + conditions: tuple[typed_expr.TypedExpr, typed_expr.TypedExpr], + joins_nulls: bool = True, + ) -> SQLGlotIR: + """Joins the current query with another SQLGlotIR instance.""" + left_cte_name = sge.to_identifier( + next(self.uid_gen.get_uid_stream("bfcte_")), quoted=self.quoted + ) + + left_select = _select_to_cte(self.expr, left_cte_name) + # Prefer subquery over CTE for the IN clause's right side to improve SQL readability. + right_select = right.expr + + left_ctes = left_select.args.pop("with", []) + right_ctes = right_select.args.pop("with", []) + merged_ctes = [*left_ctes, *right_ctes] + + left_condition = typed_expr.TypedExpr( + sge.Column(this=conditions[0].expr, table=left_cte_name), + conditions[0].dtype, + ) + + new_column: sge.Expression + if joins_nulls: + right_table_name = sge.to_identifier( + next(self.uid_gen.get_uid_stream("bft_")), quoted=self.quoted + ) + right_condition = typed_expr.TypedExpr( + sge.Column(this=conditions[1].expr, table=right_table_name), + conditions[1].dtype, + ) + new_column = sge.Exists( + this=sge.Select() + .select(sge.convert(1)) + .from_(sge.Alias(this=right_select.subquery(), alias=right_table_name)) + .where( + _join_condition(left_condition, right_condition, joins_nulls=True) + ) + ) + else: + new_column = sge.In( + this=left_condition.expr, + expressions=[right_select.subquery()], + ) + + new_column = sge.Alias( + this=new_column, + alias=sge.to_identifier(indicator_col, quoted=self.quoted), + ) + + new_expr = ( + sge.Select() + .select(sge.Column(this=sge.Star(), table=left_cte_name), new_column) + .from_(sge.Table(this=left_cte_name)) + ) + new_expr.set("with", sge.With(expressions=merged_ctes)) + + return SQLGlotIR(expr=new_expr, uid_gen=self.uid_gen) + def explode( self, column_names: tuple[str, ...], diff --git a/tests/unit/core/compile/sqlglot/conftest.py b/tests/unit/core/compile/sqlglot/conftest.py index f65343fd66..3279b3a259 100644 --- a/tests/unit/core/compile/sqlglot/conftest.py +++ b/tests/unit/core/compile/sqlglot/conftest.py @@ -85,7 +85,7 @@ def scalar_types_table_schema() -> typing.Sequence[bigquery.SchemaField]: bigquery.SchemaField("numeric_col", "NUMERIC"), bigquery.SchemaField("float64_col", "FLOAT"), bigquery.SchemaField("rowindex", "INTEGER"), - bigquery.SchemaField("rowindex_2", "INTEGER"), + bigquery.SchemaField("rowindex_2", "INTEGER", mode="REQUIRED"), bigquery.SchemaField("string_col", "STRING"), bigquery.SchemaField("time_col", "TIME"), bigquery.SchemaField("timestamp_col", "TIMESTAMP"), diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin/out.sql new file mode 100644 index 0000000000..e3bb0f9eba --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin/out.sql @@ -0,0 +1,37 @@ +WITH `bfcte_1` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `rowindex` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( + SELECT + `bfcol_1` AS `bfcol_2`, + `bfcol_0` AS `bfcol_3` + FROM `bfcte_1` +), `bfcte_0` AS ( + SELECT + `int64_too` AS `bfcol_4` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + `bfcte_2`.*, + EXISTS( + SELECT + 1 + FROM ( + SELECT + `bfcol_4` + FROM `bfcte_0` + GROUP BY + `bfcol_4` + ) AS `bft_0` + WHERE + COALESCE(`bfcte_2`.`bfcol_3`, 0) = COALESCE(`bft_0`.`bfcol_4`, 0) + AND COALESCE(`bfcte_2`.`bfcol_3`, 1) = COALESCE(`bft_0`.`bfcol_4`, 1) + ) AS `bfcol_5` + FROM `bfcte_2` +) +SELECT + `bfcol_2` AS `rowindex`, + `bfcol_5` AS `int64_col` +FROM `bfcte_3` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin_not_nullable/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin_not_nullable/out.sql new file mode 100644 index 0000000000..f96a9816dc --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin_not_nullable/out.sql @@ -0,0 +1,30 @@ +WITH `bfcte_1` AS ( + SELECT + `rowindex` AS `bfcol_0`, + `rowindex_2` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( + SELECT + `bfcol_0` AS `bfcol_2`, + `bfcol_1` AS `bfcol_3` + FROM `bfcte_1` +), `bfcte_0` AS ( + SELECT + `rowindex_2` AS `bfcol_4` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + `bfcte_2`.*, + `bfcte_2`.`bfcol_3` IN (( + SELECT + `bfcol_4` + FROM `bfcte_0` + GROUP BY + `bfcol_4` + )) AS `bfcol_5` + FROM `bfcte_2` +) +SELECT + `bfcol_2` AS `rowindex`, + `bfcol_5` AS `rowindex_2` +FROM `bfcte_3` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/test_compile_isin.py b/tests/unit/core/compile/sqlglot/test_compile_isin.py new file mode 100644 index 0000000000..94a533abe6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/test_compile_isin.py @@ -0,0 +1,39 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys + +import pytest + +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + +if sys.version_info < (3, 12): + pytest.skip( + "Skipping test due to inconsistent SQL formatting on Python < 3.12.", + allow_module_level=True, + ) + + +def test_compile_isin(scalar_types_df: bpd.DataFrame, snapshot): + bf_isin = scalar_types_df["int64_col"].isin(scalar_types_df["int64_too"]).to_frame() + snapshot.assert_match(bf_isin.sql, "out.sql") + + +def test_compile_isin_not_nullable(scalar_types_df: bpd.DataFrame, snapshot): + bf_isin = ( + scalar_types_df["rowindex_2"].isin(scalar_types_df["rowindex_2"]).to_frame() + ) + snapshot.assert_match(bf_isin.sql, "out.sql") From cce496605385f2ac7ab0becc0773800ed5901aa5 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Mon, 15 Sep 2025 09:19:44 -0700 Subject: [PATCH 065/313] fix: Fix the potential invalid VPC egress configuration (#2068) --- bigframes/functions/_function_client.py | 10 ++++- bigframes/functions/_function_session.py | 16 ++++++-- bigframes/pandas/__init__.py | 6 +-- bigframes/session/__init__.py | 6 +-- .../large/functions/test_remote_function.py | 40 +++++++++++++++++++ 5 files changed, 68 insertions(+), 10 deletions(-) diff --git a/bigframes/functions/_function_client.py b/bigframes/functions/_function_client.py index d994d6353a..641bf52dc9 100644 --- a/bigframes/functions/_function_client.py +++ b/bigframes/functions/_function_client.py @@ -25,9 +25,11 @@ import textwrap import types from typing import Any, cast, Optional, Sequence, Tuple, TYPE_CHECKING +import warnings import requests +import bigframes.exceptions as bfe import bigframes.formatting_helpers as bf_formatting import bigframes.functions.function_template as bff_template @@ -482,10 +484,16 @@ def create_cloud_function( function.service_config.max_instance_count = max_instance_count if vpc_connector is not None: function.service_config.vpc_connector = vpc_connector + if vpc_connector_egress_settings is None: + msg = bfe.format_message( + "The 'vpc_connector_egress_settings' was not specified. Defaulting to 'private-ranges-only'.", + ) + warnings.warn(msg, category=UserWarning) + vpc_connector_egress_settings = "private-ranges-only" if vpc_connector_egress_settings not in _VPC_EGRESS_SETTINGS_MAP: raise bf_formatting.create_exception_with_feedback_link( ValueError, - f"'{vpc_connector_egress_settings}' not one of the supported vpc egress settings values: {list(_VPC_EGRESS_SETTINGS_MAP)}", + f"'{vpc_connector_egress_settings}' is not one of the supported vpc egress settings values: {list(_VPC_EGRESS_SETTINGS_MAP)}", ) function.service_config.vpc_connector_egress_settings = cast( functions_v2.ServiceConfig.VpcConnectorEgressSettings, diff --git a/bigframes/functions/_function_session.py b/bigframes/functions/_function_session.py index 6b5c9bf071..9a38ef1957 100644 --- a/bigframes/functions/_function_session.py +++ b/bigframes/functions/_function_session.py @@ -245,9 +245,9 @@ def remote_function( cloud_function_timeout: Optional[int] = 600, cloud_function_max_instances: Optional[int] = None, cloud_function_vpc_connector: Optional[str] = None, - cloud_function_vpc_connector_egress_settings: Literal[ - "all", "private-ranges-only", "unspecified" - ] = "private-ranges-only", + cloud_function_vpc_connector_egress_settings: Optional[ + Literal["all", "private-ranges-only", "unspecified"] + ] = None, cloud_function_memory_mib: Optional[int] = 1024, cloud_function_ingress_settings: Literal[ "all", "internal-only", "internal-and-gclb" @@ -514,6 +514,16 @@ def remote_function( " For more details see https://cloud.google.com/functions/docs/securing/cmek#before_you_begin.", ) + # A VPC connector is required to specify VPC egress settings. + if ( + cloud_function_vpc_connector_egress_settings is not None + and cloud_function_vpc_connector is None + ): + raise bf_formatting.create_exception_with_feedback_link( + ValueError, + "cloud_function_vpc_connector must be specified before cloud_function_vpc_connector_egress_settings.", + ) + if cloud_function_ingress_settings is None: cloud_function_ingress_settings = "internal-only" msg = bfe.format_message( diff --git a/bigframes/pandas/__init__.py b/bigframes/pandas/__init__.py index a19b1397ce..2ea10132bc 100644 --- a/bigframes/pandas/__init__.py +++ b/bigframes/pandas/__init__.py @@ -87,9 +87,9 @@ def remote_function( cloud_function_timeout: Optional[int] = 600, cloud_function_max_instances: Optional[int] = None, cloud_function_vpc_connector: Optional[str] = None, - cloud_function_vpc_connector_egress_settings: Literal[ - "all", "private-ranges-only", "unspecified" - ] = "private-ranges-only", + cloud_function_vpc_connector_egress_settings: Optional[ + Literal["all", "private-ranges-only", "unspecified"] + ] = None, cloud_function_memory_mib: Optional[int] = 1024, cloud_function_ingress_settings: Literal[ "all", "internal-only", "internal-and-gclb" diff --git a/bigframes/session/__init__.py b/bigframes/session/__init__.py index 4c824256b1..f0cec864b4 100644 --- a/bigframes/session/__init__.py +++ b/bigframes/session/__init__.py @@ -1509,9 +1509,9 @@ def remote_function( cloud_function_timeout: Optional[int] = 600, cloud_function_max_instances: Optional[int] = None, cloud_function_vpc_connector: Optional[str] = None, - cloud_function_vpc_connector_egress_settings: Literal[ - "all", "private-ranges-only", "unspecified" - ] = "private-ranges-only", + cloud_function_vpc_connector_egress_settings: Optional[ + Literal["all", "private-ranges-only", "unspecified"] + ] = None, cloud_function_memory_mib: Optional[int] = 1024, cloud_function_ingress_settings: Literal[ "all", "internal-only", "internal-and-gclb" diff --git a/tests/system/large/functions/test_remote_function.py b/tests/system/large/functions/test_remote_function.py index 99a614532d..55643d9a60 100644 --- a/tests/system/large/functions/test_remote_function.py +++ b/tests/system/large/functions/test_remote_function.py @@ -1512,6 +1512,46 @@ def square_num(x): ) +@pytest.mark.flaky(retries=2, delay=120) +def test_remote_function_no_vpc_connector(session): + def foo(x): + return x + + with pytest.raises( + ValueError, + match="^cloud_function_vpc_connector must be specified before cloud_function_vpc_connector_egress_settings", + ): + session.remote_function( + input_types=[int], + output_type=int, + reuse=False, + cloud_function_service_account="default", + cloud_function_vpc_connector=None, + cloud_function_vpc_connector_egress_settings="all", + cloud_function_ingress_settings="all", + )(foo) + + +@pytest.mark.flaky(retries=2, delay=120) +def test_remote_function_wrong_vpc_egress_value(session): + def foo(x): + return x + + with pytest.raises( + ValueError, + match="^'wrong-egress-value' is not one of the supported vpc egress settings values:", + ): + session.remote_function( + input_types=[int], + output_type=int, + reuse=False, + cloud_function_service_account="default", + cloud_function_vpc_connector="dummy-value", + cloud_function_vpc_connector_egress_settings="wrong-egress-value", + cloud_function_ingress_settings="all", + )(foo) + + @pytest.mark.parametrize( ("max_batching_rows"), [ From a63fc02c87e7ba90be2e27062ecddadb2ab8a58b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Mon, 15 Sep 2025 12:12:16 -0500 Subject: [PATCH 066/313] chore: fix e2e tests after move of bigquery-storage package (#2083) --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index cc38a3b8c0..f2be8045b1 100644 --- a/noxfile.py +++ b/noxfile.py @@ -665,7 +665,7 @@ def prerelease(session: nox.sessions.Session, tests_path, extra_pytest_options=( session.install( "--upgrade", "-e", - "git+https://github.com/googleapis/python-bigquery-storage.git#egg=google-cloud-bigquery-storage", + "git+https://github.com/googleapis/google-cloud-python.git#egg=google-cloud-bigquery-storage&subdirectory=packages/google-cloud-bigquery-storage", ) already_installed.add("google-cloud-bigquery-storage") session.install( From 3b46a0d91eb379c61ced45ae0b25339281326c3d Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Mon, 15 Sep 2025 10:44:48 -0700 Subject: [PATCH 067/313] feat: Add `__dataframe__` interchange support (#2063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Tim Sweña (Swast) --- bigframes/core/blocks.py | 31 +-- bigframes/core/interchange.py | 155 +++++++++++++++ bigframes/dataframe.py | 6 + .../bq_dataframes_covid_line_graphs.ipynb | 178 +++++------------- tests/unit/test_interchange.py | 108 +++++++++++ 5 files changed, 335 insertions(+), 143 deletions(-) create mode 100644 bigframes/core/interchange.py create mode 100644 tests/unit/test_interchange.py diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index b2d9d10107..aedcc6f25e 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -37,6 +37,7 @@ Optional, Sequence, Tuple, + TYPE_CHECKING, Union, ) import warnings @@ -69,6 +70,9 @@ from bigframes.session import dry_runs, execution_spec from bigframes.session import executor as executors +if TYPE_CHECKING: + from bigframes.session.executor import ExecuteResult + # Type constraint for wherever column labels are used Label = typing.Hashable @@ -404,13 +408,15 @@ def reset_index( col_level: Union[str, int] = 0, col_fill: typing.Hashable = "", allow_duplicates: bool = False, + replacement: Optional[bigframes.enums.DefaultIndexKind] = None, ) -> Block: """Reset the index of the block, promoting the old index to a value column. Arguments: level: the label or index level of the index levels to remove. name: this is the column id for the new value id derived from the old index - allow_duplicates: + allow_duplicates: if false, duplicate col labels will result in error + replacement: if not null, will override default index replacement type Returns: A new Block because dropping index columns can break references @@ -425,23 +431,19 @@ def reset_index( level_ids = self.index_columns expr = self._expr + replacement_idx_type = replacement or self.session._default_index_type if set(self.index_columns) > set(level_ids): new_index_cols = [col for col in self.index_columns if col not in level_ids] new_index_labels = [self.col_id_to_index_name[id] for id in new_index_cols] - elif ( - self.session._default_index_type - == bigframes.enums.DefaultIndexKind.SEQUENTIAL_INT64 - ): + elif replacement_idx_type == bigframes.enums.DefaultIndexKind.SEQUENTIAL_INT64: expr, new_index_col_id = expr.promote_offsets() new_index_cols = [new_index_col_id] new_index_labels = [None] - elif self.session._default_index_type == bigframes.enums.DefaultIndexKind.NULL: + elif replacement_idx_type == bigframes.enums.DefaultIndexKind.NULL: new_index_cols = [] new_index_labels = [] else: - raise ValueError( - f"Unrecognized default index kind: {self.session._default_index_type}" - ) + raise ValueError(f"Unrecognized default index kind: {replacement_idx_type}") if drop: # Even though the index might be part of the ordering, keep that @@ -630,15 +632,17 @@ def to_pandas( max_download_size, sampling_method, random_state ) - df, query_job = self._materialize_local( + ex_result = self._materialize_local( materialize_options=MaterializationOptions( downsampling=sampling, allow_large_results=allow_large_results, ordered=ordered, ) ) + df = ex_result.to_pandas() + df = self._copy_index_to_pandas(df) df.set_axis(self.column_labels, axis=1, copy=False) - return df, query_job + return df, ex_result.query_job def _get_sampling_option( self, @@ -746,7 +750,7 @@ def _copy_index_to_pandas(self, df: pd.DataFrame) -> pd.DataFrame: def _materialize_local( self, materialize_options: MaterializationOptions = MaterializationOptions() - ) -> Tuple[pd.DataFrame, Optional[bigquery.QueryJob]]: + ) -> ExecuteResult: """Run query and download results as a pandas DataFrame. Return the total number of results as well.""" # TODO(swast): Allow for dry run and timeout. under_10gb = ( @@ -815,8 +819,7 @@ def _materialize_local( MaterializationOptions(ordered=materialize_options.ordered) ) else: - df = execute_result.to_pandas() - return self._copy_index_to_pandas(df), execute_result.query_job + return execute_result def _downsample( self, total_rows: int, sampling_method: str, fraction: float, random_state diff --git a/bigframes/core/interchange.py b/bigframes/core/interchange.py new file mode 100644 index 0000000000..f6f0bdd103 --- /dev/null +++ b/bigframes/core/interchange.py @@ -0,0 +1,155 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import dataclasses +import functools +from typing import Any, Dict, Iterable, Optional, Sequence, TYPE_CHECKING + +from bigframes.core import blocks +import bigframes.enums + +if TYPE_CHECKING: + import bigframes.dataframe + + +@dataclasses.dataclass(frozen=True) +class InterchangeColumn: + _dataframe: InterchangeDataFrame + _pos: int + + @functools.cache + def _arrow_column(self): + # Conservatively downloads the whole underlying dataframe + # This is much better if multiple columns end up being used, + # but does incur a lot of overhead otherwise. + return self._dataframe._arrow_dataframe().get_column(self._pos) + + def size(self) -> int: + return self._arrow_column().size() + + @property + def offset(self) -> int: + return self._arrow_column().offset + + @property + def dtype(self): + return self._arrow_column().dtype + + @property + def describe_categorical(self): + raise TypeError(f"Column type {self.dtype} is not categorical") + + @property + def describe_null(self): + return self._arrow_column().describe_null + + @property + def null_count(self): + return self._arrow_column().null_count + + @property + def metadata(self) -> Dict[str, Any]: + return self._arrow_column().metadata + + def num_chunks(self) -> int: + return self._arrow_column().num_chunks() + + def get_chunks(self, n_chunks: Optional[int] = None) -> Iterable: + return self._arrow_column().get_chunks(n_chunks=n_chunks) + + def get_buffers(self): + return self._arrow_column().get_buffers() + + +@dataclasses.dataclass(frozen=True) +class InterchangeDataFrame: + """ + Implements the dataframe interchange format. + + Mostly implemented by downloading result to pyarrow, and using pyarrow interchange implementation. + """ + + _value: blocks.Block + + version: int = 0 # version of the protocol + + def __dataframe__( + self, nan_as_null: bool = False, allow_copy: bool = True + ) -> InterchangeDataFrame: + return self + + @classmethod + def _from_bigframes(cls, df: bigframes.dataframe.DataFrame): + block = df._block.with_column_labels( + [str(label) for label in df._block.column_labels] + ) + return cls(block) + + # In future, could potentially rely on executor to refetch batches efficiently with caching, + # but safest for now to just request a single execution and save the whole table. + @functools.cache + def _arrow_dataframe(self): + arrow_table, _ = self._value.reset_index( + replacement=bigframes.enums.DefaultIndexKind.NULL + ).to_arrow(allow_large_results=False) + return arrow_table.__dataframe__() + + @property + def metadata(self): + # Allows round-trip without materialization + return {"bigframes.block": self._value} + + def num_columns(self) -> int: + """ + Return the number of columns in the DataFrame. + """ + return len(self._value.value_columns) + + def num_rows(self) -> Optional[int]: + return self._value.shape[0] + + def num_chunks(self) -> int: + return self._arrow_dataframe().num_chunks() + + def column_names(self) -> Iterable[str]: + return [col for col in self._value.column_labels] + + def get_column(self, i: int) -> InterchangeColumn: + return InterchangeColumn(self, i) + + # For single column getters, we download the whole dataframe still + # This is inefficient in some cases, but more efficient in other + def get_column_by_name(self, name: str) -> InterchangeColumn: + col_id = self._value.resolve_label_exact(name) + assert col_id is not None + pos = self._value.value_columns.index(col_id) + return InterchangeColumn(self, pos) + + def get_columns(self) -> Iterable[InterchangeColumn]: + return [InterchangeColumn(self, i) for i in range(self.num_columns())] + + def select_columns(self, indices: Sequence[int]) -> InterchangeDataFrame: + col_ids = [self._value.value_columns[i] for i in indices] + new_value = self._value.select_columns(col_ids) + return InterchangeDataFrame(new_value) + + def select_columns_by_name(self, names: Sequence[str]) -> InterchangeDataFrame: + col_ids = [self._value.resolve_label_exact(name) for name in names] + assert all(id is not None for id in col_ids) + new_value = self._value.select_columns(col_ids) # type: ignore + return InterchangeDataFrame(new_value) + + def get_chunks(self, n_chunks: Optional[int] = None) -> Iterable: + return self._arrow_dataframe().get_chunks(n_chunks) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index f9fa29b10c..ff730be4a8 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -67,6 +67,7 @@ import bigframes.core.guid import bigframes.core.indexers as indexers import bigframes.core.indexes as indexes +import bigframes.core.interchange import bigframes.core.ordering as order import bigframes.core.utils as utils import bigframes.core.validations as validations @@ -1647,6 +1648,11 @@ def corrwith( ) return bigframes.pandas.Series(block) + def __dataframe__( + self, nan_as_null: bool = False, allow_copy: bool = True + ) -> bigframes.core.interchange.InterchangeDataFrame: + return bigframes.core.interchange.InterchangeDataFrame._from_bigframes(self) + def to_arrow( self, *, diff --git a/notebooks/visualization/bq_dataframes_covid_line_graphs.ipynb b/notebooks/visualization/bq_dataframes_covid_line_graphs.ipynb index f0dd5eb678..d69aecd8c3 100644 --- a/notebooks/visualization/bq_dataframes_covid_line_graphs.ipynb +++ b/notebooks/visualization/bq_dataframes_covid_line_graphs.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 16, + "execution_count": 1, "metadata": { "id": "9GIt_orUtNvA" }, @@ -135,7 +135,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 2, "metadata": { "id": "4aooKMmnxrWF" }, @@ -157,7 +157,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 3, "metadata": { "id": "bk03Rt_HyGx-" }, @@ -206,7 +206,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 4, "metadata": { "id": "R7STCS8xB5d2" }, @@ -238,23 +238,11 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 5, "metadata": { "id": "zDSwoBo1CU3G" }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/tbergeron/src/bigframes/venv/lib/python3.12/site-packages/IPython/core/interactiveshell.py:3579: UserWarning: Reading cached table from 2025-03-20 20:22:07.633084+00:00 to avoid\n", - "incompatibilies with previous reads of this table. To read the latest\n", - "version, set `use_cache=False` or close the current session with\n", - "Session.close() or bigframes.pandas.close_session().\n", - " exec(code_obj, self.user_global_ns, self.user_ns)\n" - ] - } - ], + "outputs": [], "source": [ "all_data = bpd.read_gbq(\"bigquery-public-data.covid19_open_data.covid19_open_data\")" ] @@ -270,7 +258,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 6, "metadata": { "id": "UjMT_qhjf8Fu" }, @@ -290,7 +278,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 7, "metadata": { "id": "IaoUf57ZwrJ8" }, @@ -320,7 +308,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 8, "metadata": { "id": "tYDoaKgJChiq" }, @@ -350,30 +338,18 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 9, "metadata": { "id": "gFbCgfFC2gHw" }, "outputs": [ - { - "data": { - "text/html": [ - "Query job e8946d0f-20f1-49ae-9af5-5136f45e792d is DONE. 372.9 MB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [ "" ] }, - "execution_count": 24, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" }, @@ -433,13 +409,14 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 55, "metadata": { "id": "LqqHzjty8jk0" }, "outputs": [], "source": [ - "symptom_data = all_data[[\"new_confirmed\", \"search_trends_cough\", \"search_trends_fever\", \"search_trends_bruise\"]]" + "regional_data = all_data[all_data[\"aggregation_level\"] == 1] # get only region level data,\n", + "symptom_data = regional_data[[\"location_key\", \"new_confirmed\", \"search_trends_cough\", \"search_trends_fever\", \"search_trends_bruise\", \"population\", \"date\"]]" ] }, { @@ -448,92 +425,45 @@ "id": "b3DlJX-k9SPk" }, "source": [ - "Not all rows have data for all of these columns, so let's select only the rows that do." + "Not all rows have data for all of these columns, so let's select only the rows that do. Finally, lets add a new column capturing new confirmed cases as a percentage of area population." ] }, { "cell_type": "code", - "execution_count": 26, + "execution_count": null, "metadata": { "id": "g4MeM8Oe9Q6X" }, "outputs": [], "source": [ - "symptom_data = symptom_data.dropna()" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "IlXt__om9QYI" - }, - "source": [ - "We want to use a line of best fit to make the correlation stand out. Matplotlib does not include a feature for lines of best fit, but seaborn, which is built on matplotlib, does.\n", + "symptom_data = symptom_data.dropna()\n", + "symptom_data = symptom_data[symptom_data[\"new_confirmed\"] > 0]\n", + "symptom_data[\"new_cases_percent_of_pop\"] = (symptom_data[\"new_confirmed\"] / symptom_data[\"population\"]) * 100\n", "\n", - "BigQuery DataFrames does not currently integrate with seaborn by default. So we will demonstrate how to downsample and download a DataFrame, and use seaborn on the downloaded data." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "MmfgKMaEXNbL" - }, - "source": [ - "### Downsample and download" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "wIuG1JRTPAk9" - }, - "source": [ - "BigQuery DataFrames options let us set up the sampling functionality we need. Calls to `to_pandas()` usually download all the data available in our BigQuery table and store it locally as a pandas DataFrame. `pd.options.sampling.enable_downsampling = True` will make future calls to `to_pandas` use downsampling to download only part of the data, and `pd.options.sampling.max_download_size` allows us to set the amount of data to download." + "\n", + "# remove impossible data points\n", + "symptom_data = symptom_data[(symptom_data[\"new_cases_percent_of_pop\"] >= 0)]\n" ] }, { "cell_type": "code", - "execution_count": 27, - "metadata": { - "id": "x95ZgBkyDMP4" - }, + "execution_count": null, + "metadata": {}, "outputs": [], "source": [ - "bpd.options.sampling.enable_downsampling = True # enable downsampling\n", - "bpd.options.sampling.max_download_size = 5 # download only 5 mb of data" + "# group data up by week\n", + "weekly_data = symptom_data.groupby([symptom_data.location_key, symptom_data.date.dt.isocalendar().week]).agg({\"new_cases_percent_of_pop\": \"sum\", \"search_trends_cough\": \"mean\", \"search_trends_fever\": \"mean\", \"search_trends_bruise\": \"mean\"})" ] }, { "cell_type": "markdown", "metadata": { - "id": "C6sCXkrQPJC_" - }, - "source": [ - "Download the data and note the message letting us know that downsampling is being used." - ] - }, - { - "cell_type": "code", - "execution_count": 28, - "metadata": { - "id": "V0OK02D7PJSL" + "id": "IlXt__om9QYI" }, - "outputs": [ - { - "data": { - "text/html": [ - "Query job 5b76ac5f-2de7-49a6-88e8-0ba5ea3df68f is DONE. 129.5 MB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], "source": [ - "local_symptom_data = symptom_data.to_pandas(sampling_method=\"uniform\")" + "We want to use a line of best fit to make the correlation stand out. Matplotlib does not include a feature for lines of best fit, but seaborn, which is built on matplotlib, does.\n", + "\n", + "BigQuery DataFrames does not currently integrate with seaborn by default. So we will demonstrate how to downsample and download a DataFrame, and use seaborn on the downloaded data." ] }, { @@ -554,12 +484,12 @@ "source": [ "We will now use seaborn to make the plots with the lines of best fit for cough, fever, and bruise. Note that since we're working with a local pandas dataframe, you could use any other Python library or technique you're familiar with, but we'll stick to seaborn for this notebook.\n", "\n", - "Seaborn will take a few seconds to calculate the lines. Since cough and fever are symptoms of COVID-19, but bruising isn't, we expect the slope of the line of best fit to be positive in the first two graphs, but not the third, indicating that there is a correlation between new COVID-19 cases and cough- and fever-related searches." + "Seaborn will take a few minutes to calculate the lines. Since cough and fever are symptoms of COVID-19, but bruising isn't, we expect the slope of the line of best fit to be positive in the first two graphs, but not the third, indicating that there is a correlation between new COVID-19 cases and cough- and fever-related searches." ] }, { "cell_type": "code", - "execution_count": 29, + "execution_count": 59, "metadata": { "id": "EG7qM3R18bOb" }, @@ -567,16 +497,16 @@ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 29, + "execution_count": 59, "metadata": {}, "output_type": "execute_result" }, { "data": { - "image/png": "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", + "image/png": "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N9z+2hBcuapN1XaZLoaoctRsO1O0dq100O4Avdnjder0yd2SMlQ8P+ClqSIBtDX4B2HI967neeriJC9OldZd74przSbSbKL9S+a2girLJA2FdEzb954SgUCwc9ouPt72trfxB3/wB7z22mvcd999vPDCC3zrW9/i937v91p+v23b2PYb463lcrndRwI2Tvk3REk2oXFjqUYmruIH4Z72D6zMCOSqNrmas0oknehLNk3PwjAkaahULLf5s5IksVixuZWrM1+2mS/bnOrf2JW1OS3iB4RhNJlwrDdJX0rfs+bT3WY3p0/2S9hYro8XBAxkYqRiats2zTpewFcvz/P0pSlu5errro91x/n4o2P82AODGNre91FIkkRSV5qvWSAQHHza/pv8a7/2a5TLZe6//34URcH3fX77t3+bn/mZn2n5/Z/61Kf4rd/6rXYfYx19KZ2RrlhzqqUvFW3qbIiSXNVBlSWm8iYxXSJpKIRhuGEJpp0BaOUdfMpQKZneKpEkSVLT9GyjhWqNczx+opebS9VNXVkbgmu0O7G85dZoBuatZiY2e/07fW/a+Z7u5vTJXo/VrvXqaNem2arl8cUXZvj8c9Pkas666w8MZ3jysXHedqp3X8oaoqwiEBxe2i4+nn76af77f//v/I//8T948MEHef755/nlX/5lRkZG+Lmf+7l13//JT36ST3ziE80/l8tlxsfH230sFis2l2fLVG2PpapNb1JnMBtvZh0qlstod5ybSzVML+C713NMdCc3LMHsVgBa2xfRl9JZKFvNP1cst2VQTRkqqiJjuT6j3ZHvxVZNzJK6suo5thL0N3v9d+qv2eh52vme7ub0yV6N1Xp+QKHuNrNc7WK+bPG5Z6f40otzmO56x9O3n+rlycfGeWj07qfAtosqy6RikeAQZRWB4PDSdvHxK7/yK/zar/0aP/3TPw3A2bNnuXXrFp/61Kdaig/DMDCM3U/v387XeX2xRldcY75cY6Insco+XZIkDFWmL20QhiG3l2qYKZdcNWxasa9kJ6OsWwnqa/sY1mY6RrpiLYPqdpo5135vEAR88+oSNdsjaai8896+O1rLb/b679Rfs5G4aGdQ383pk90eq/WDaGy2ZLpt9eq4tlDl6YuT/NWVhXVNpJoi8WMPDPHxC2PNnUZbJQxD8jWXuuM1S0HbyViJsopAcPRo+296vV5H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ueOOO3j00Ue7Hdaa+exnP4umaXz4wx/udigd8clPfhJN01Z87dixo9thddSRI0f4gz/4A8rlMq7rcsMNN/D00093O6yO2bRp0yl/h5qmsXv37m6HdtGSJOEv//Iv2bx5M67rsnXrVv7qr/7qvM5fWUvrIvn4l3/5Fz7ykY/wwAMP8Oyzz3LjjTfyG7/xG0xPT3c7tI7wPI8bb7yRL33pS90OZU08/vjj7N69m6eeeorHHnuMKIp4z3veg+d53Q6tI8bGxvjsZz/LM888w9NPP8073/lOfuu3fotf/epX3Q6t4/bs2cNXvvIVdu3a1e1QOur666/n2LFjy19PPvlkt0PqmIWFBd7ylrdgWRaPPvooL7zwAn/7t39Lb29vt0PrmD179qz4+3vssccAeN/73tflyC7e5z73Ob785S/zxS9+kRdffJHPfe5z/M3f/A1f+MIXuhuYWgduu+02tXv37uXvkyRRo6Oj6sEHH+xiVGsDUI888ki3w1hT09PTClCPP/54t0NZM729veof//Efux1GR9XrdXXttdeqxx57TL3jHe9Q999/f7dD6ogHHnhA3Xjjjd0OY8189KMfVW9961u7HcYldf/996utW7eqNE27HcpFu/vuu9V999234rHf+Z3fUffcc0+XImq76mc+wjDkmWee4d3vfvfyY7qu8+53v5v//d//7WJk4kJVq1UA+vr6uhxJ5yVJwje/+U08z+OOO+7odjgdtXv3bu6+++4V/y9eLV599VVGR0fZsmUL99xzDwcPHux2SB3z7//+79xyyy28733vY3BwkJtuuomvfe1r3Q5rzYRhyD//8z9z3333dfxw02648847+eEPf8grr7wCwC9+8QuefPJJ7rrrrq7GddkdLNdps7OzJEnC0NDQiseHhoZ46aWXuhSVuFBpmvLhD3+Yt7zlLezcubPb4XTMc889xx133IHv++TzeR555BHe9KY3dTusjvnmN7/Js88+y549e7odSsfdfvvtfP3rX2f79u0cO3aMT33qU7ztbW/j+eefp1AodDu8i7Z//36+/OUv85GPfIRPfOIT7Nmzhz/5kz/Btm3uvffebofXcd/5zneoVCp84AMf6HYoHfGxj32MWq3Gjh07MAyDJEn49Kc/zT333NPVuK765ENcXXbv3s3zzz9/Va2pA2zfvp29e/dSrVb513/9V+69914ef/zxqyIBOXToEPfffz+PPfYYmUym2+F03Il3kLt27eL2229nYmKCb33rW/zhH/5hFyPrjDRNueWWW/jMZz4DwE033cTzzz/PP/zDP1yVycc//dM/cddddzE6OtrtUDriW9/6Ft/4xjd4+OGHuf7669m7dy8f/vCHGR0d7erf31WffPT392MYBlNTUysen5qaYnh4uEtRiQvxoQ99iO9973s88cQTjI2NdTucjrJtm2uuuQaAN7/5zezZs4e///u/5ytf+UqXI7t4zzzzDNPT09x8883LjyVJwhNPPMEXv/hFgiDAMIwuRthZPT09bNu2jddee63boXTEyMjIKUnwddddx7/92791KaK1c+DAAX7wgx/w7W9/u9uhdMyf//mf87GPfYzf+73fA+CGG27gwIEDPPjgg11NPq76mg/btnnzm9/MD3/4w+XH0jTlhz/84VW3pn61UkrxoQ99iEceeYT//u//ZvPmzd0Oac2laUoQBN0OoyPe9a538dxzz7F3797lr1tuuYV77rmHvXv3XlWJB0Cj0WDfvn2MjIx0O5SOeMtb3nLK1vZXXnmFiYmJLkW0dh566CEGBwe5++67ux1KxzSbTXR95VBvGAZpmnYporarfuYD4CMf+Qj33nsvt9xyC7fddhuf//zn8TyPD37wg90OrSMajcaKu6zXX3+dvXv30tfXx/j4eBcj64zdu3fz8MMP893vfpdCocDk5CQApVIJ13W7HN3F+/jHP85dd93F+Pg49Xqdhx9+mB//+Md8//vf73ZoHVEoFE6pz8nlcpTL5auibufP/uzPeO9738vExARHjx7lgQcewDAMfv/3f7/boXXEn/7pn3LnnXfymc98ht/93d/lZz/7GV/96lf56le/2u3QOipNUx566CHuvfdeTPPqGRrf+9738ulPf5rx8XGuv/56fv7zn/N3f/d33Hfffd0NrKt7bS6hL3zhC2p8fFzZtq1uu+029dRTT3U7pI750Y9+pIBTvu69995uh9YRp3tvgHrooYe6HVpH3HfffWpiYkLZtq0GBgbUu971LvVf//Vf3Q5rTV1NW23f//73q5GREWXbttqwYYN6//vfr1577bVuh9VR//Ef/6F27typHMdRO3bsUF/96le7HVLHff/731eAevnll7sdSkfVajV1//33q/HxcZXJZNSWLVvUX/zFX6ggCLoal6ZUl9ucCSGEEGJdueprPoQQQghxeZHkQwghhBCXlCQfQgghhLikJPkQQgghxCUlyYcQQgghLilJPoQQQghxSUnyIYQQQohLSpIPIYQQQlxSknwIIYQQ4pKS5EMIIYQQl5QkH0IIIYS4pCT5EEIIIcQl9f8DvBq4eqmKlScAAAAASUVORK5CYII=", "text/plain": [ "
" ] @@ -588,19 +518,13 @@ "source": [ "import seaborn as sns\n", "\n", - "# first, convert to a data type that is suitable for seaborn\n", - "local_symptom_data[\"new_confirmed\"] = \\\n", - " local_symptom_data[\"new_confirmed\"].astype(float)\n", - "local_symptom_data[\"search_trends_cough\"] = \\\n", - " local_symptom_data[\"search_trends_cough\"].astype(float)\n", - "\n", "# draw the graph. This might take ~30 seconds.\n", - "sns.regplot(x=\"new_confirmed\", y=\"search_trends_cough\", data=local_symptom_data)" + "sns.regplot(x=\"new_cases_percent_of_pop\", y=\"search_trends_cough\", data=weekly_data, scatter_kws={'alpha': 0.2, \"s\" :5})" ] }, { "cell_type": "code", - "execution_count": 30, + "execution_count": 62, "metadata": { "id": "5nVy61rEGaM4" }, @@ -608,16 +532,16 @@ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 30, + "execution_count": 62, "metadata": {}, "output_type": "execute_result" }, { "data": { - "image/png": "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691Mdrsd//nPfzB69Gjk5OR0+RHxwAMPoLW1NZnjJeqTg774EnvjPY+IiFIvoa3Zc+fOjatL9v33389EYUovQkvueURElHIJBTPxEoI7Qii9eINxVgCO8zwiIkq9fg1miNKNLxRfgB3veURElHoMZmhQifcFz18MIqLMwfdsGlSUOF/x8Z5HRESpx7dsGlTMRimp5xERUer1azBz8sknw2plJVVKH23++BJ74z2PiIhSL6Fg5osvvsDmzZujn7/66qtYsGABbr/9dgSDwejxN998E6WlpX0fJVGSCCm+GZd4zyMiotRLKJi55pprsGPHDgDArl27cOGFF8Jms+Gll17CLbfcktQBEiWTLMW3Syne84iIKPUSCmZ27NiB4447DgDw0ksvYc6cOXjuuefwzDPP4B//+Ecyx0eUVKoaX5AS73lERJR6CQUzQghoml4h9d///jfOOussAMDw4cPR1NSUvNERJZkrGF+QEu95RESUegkFM9OnT8evfvUr/PWvf8WaNWtw9tlnAwB2796N4uLipA6QiIiIqCcJBTPLli3DF198geuuuw533HEHxo4dCwD4+9//jtmzZyd1gEREREQ9OXK3yC5Mnjw5ZjdTxIMPPghFUfo8KCIiIqJ4JRTMdMdisSTz4YiIiIiOKO5gJi8vD1KctTdaWloSHhARERFRb8QdzCxbtiz6383NzfjVr36FM844A7NmzQIArFu3Du+88w7uuuuupA+SKFkkAPHsU2LJPCKizCEJIXq9B/X888/Ht7/9bVx33XUxx//whz/g3//+N1555ZVkja/PXC4XcnJy4HQ64XA4Uj0cSrGxt76BcBznGQBUP3B2fw+HiIi60Zv7d0K7md555x3Mnz//sOPz58/Hv//970QekuioiCeQ6c15RESUegkFMwUFBXj11VcPO/7qq6+ioKCgz4MiIiIiildCu5mWLFmCK6+8EqtXr8aMGTMAAJ988gnefvttPPnkk0kdIBEREVFPEgpmLrvsMlRWVuL3v/89/vnPfwIAKisr8eGHH0aDGyIiIqKjIeE6MzNmzMCzzz6bzLEQERER9VrCwYymaaiurkZDQ0O06WTEnDlz+jwwIiIiongkFMx8/PHHuPjii7Fnzx503tktSRJUVU3K4IiIiIiOJKFg5ic/+QmmT5+ON954A6WlpXFXBiYiIiJKtoSCmZ07d+Lv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z7/++usxYcIEWK1WjBgxAjfccAOcTmfMeZIkHfaxcuXK/n46CXnssccwatQoWCwWzJgxA59++mmP57/00kuoqKiAxWLBpEmT8Oabb8Z8XQiBu+++G6WlpbBarZg3bx527tzZn08hqXpzPZ588kmcfPLJyMvLQ15eHubNm3fY+Zdddtlhr4X58+f399NImt5cj2eeeeaw52qxWGLOGUyvj1NOOaXL94Kzzz47ek4mvz7ef/99nHPOOSgrK4MkSXjllVeO+D2rV6/G8ccfD7PZjLFjx+KZZ5457Jzevielk95ek3/+85/4zne+gyFDhsDhcGDWrFl45513Ys659957D3uNVFRU9G2gggaU+fPniylTpoiPP/5YfPDBB2Ls2LHioosu6vb8zZs3i/POO0+89tprorq6WqxatUqMGzdOnH/++THnARArVqwQtbW10Q+fz9ffT6fXVq5cKUwmk/jTn/4ktmzZIq666iqRm5sr6uvruzz/o48+EoqiiN/85jdi69at4s477xRGo1Fs3rw5es4DDzwgcnJyxCuvvCI2bdokvvvd74rRo0en5fPvrLfX4+KLLxaPPfaY2LBhg6iqqhKXXXaZyMnJEfv374+ec+mll4r58+fHvBZaWlqO1lPqk95ejxUrVgiHwxHzXOvq6mLOGUyvj+bm5phr8dVXXwlFUcSKFSui52Ty6+PNN98Ud9xxh/jnP/8pAIiXX365x/N37dolbDabWLx4sdi6dat49NFHhaIo4u23346e09trnG56e01+9rOfiV//+tfi008/FTt27BC33XabMBqN4osvvoiec88994iJEyfGvEYaGxv7NE4GMwPI1q1bBQDx2WefRY+99dZbQpIk8c0338T9OC+++KIwmUwiFApFj8XzIk4HJ5xwgli0aFH0c1VVRVlZmVi6dGmX53//+98XZ599dsyxGTNmiGuuuUYIIYSmaaKkpEQ8+OCD0a+3trYKs9ksnn/++X54BsnV2+vRWTgcFtnZ2eLPf/5z9Nill14qzj333GQP9ajo7fVYsWKFyMnJ6fbxBvvr4+GHHxbZ2dnC4/FEj2Xy66OjeN7zbrnlFjFx4sSYYxdccIE444wzop/39Rqnk0TvA8ccc4xYsmRJ9PN77rlHTJkyJXkDE0JwmWkAWbduHXJzczF9+vTosXnz5kGWZXzyySdxP47T6YTD4YDB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MJlMYujQoeKCCy4Q1dXVfRonu2YTERFRRmPODBEREWU0BjNERESU0RjMEBERUUZjMENEREQZjcEMERERZTQGM0RERJTRGMwQERFRRmMwQ0RERL32/vvv45xzzkFZWRkkScIrr7zS68cQQuChhx7C+PHjYTabMXToUNx33329fhwGM0Q0oH300UeYNGkSjEYjFixYgNWrV0OSJLS2tqZ6aFGjRo3CsmXLUj0Mol5pa2vDlClT8NhjjyX8GD/72c/w1FNP4aGHHsK2bdvw2muvHdaJPR6GhEdARJQBFi9ejOOOOw5vvfUW7HY7bDYbamtrkZOTk+qhEWW0M888E2eeeWa3Xw8EArjjjjvw/PPPo7W1Fcceeyx+/etf45RTTgEAVFVVYfny5fjqq68wYcIEAHrz1kRwZoaIBrSamhqceuqpGDZsGHJzc2EymVBSUgJJkro8X1VVaJp2lEdJNPBcd911WLduHVauXIkvv/wS//3f/4358+dj586dAIDXX38dY8aMwb/+9S+MHj0ao0aNwpVXXomWlpZe/ywGM0SDzCmnnIIbbrgBt9xyC/Lz81FSUoJ77703+vXW1lZceeWVGDJkCBwOB0499VRs2rQJAOB0OqEoCj7//HMAgKZpyM/Px8yZM6Pf/7e//Q3Dhw+Payz79+/HRRddhPz8fGRlZWH69On45JNPol9fvnw5ysvLYTKZMGHCBPz1r3+N+X5JkvDUU09h4cKFsNlsGDduHF577TUAwNdffw1JktDc3Iwf//jHkCQJzzzzzGHLTM888wxyc3Px2muv4ZhjjoHZbMbevXsxatQo/OpXv8KPfvQj2O12jBw5Eq+99hoaGxtx7rnnwm63Y/LkydFrEfHhhx/i5JNPhtVqxfDhw3HDDTegra0t+vWGhgacc845sFqtGD16NJ599tm4rhVRJtm7dy9WrFiBl156CSeffDLKy8vx85//HCeddBJWrFgBANi1axf27NmDl156CX/5y1/wzDPPYP369fje977X+x/YpzaVRJRx5s6dKxwOh7j33nvFjh07xJ///GchSVK0C/K8efPEOeecIz777DOxY8cOcfPNN4uCggLR3NwshBDi+OOPFw8++KAQQoiNGzeK/Px8YTKZot3Zr7zySnHJJZcccRxut1uMGTNGnHzyyeKDDz4QO3fuFC+88IJYu3atEEKIf/7zn8JoNIrHHntMbN++Xfz2t78ViqKI//u//4s+BgAxbNgw8dxzz4mdO3eKG264QdjtdtHc3CzC4bCora0VDodDLFu2TNTW1gqv1xvtAnzw4EEhhBArVqwQRqNRzJ49W3z00Udi27Ztoq2tTYwcOVLk5+eLP/7xj2LHjh3i2muvFQ6HQ8yfP1+8+OKLYvv27WLBggWisrJSaJomhBCiurpaZGVliYcffljs2LFDfPTRR2Lq1Knisssui475zDPPFFOmTBHr1q0Tn3/+uZg9e7awWq3i4Ycf7ts/LFEKARAvv/xy9PN//etfAoDIysqK+TAYDOL73/++EEKIq666SgCI6TK/fv16AUBs27atdz8/Kc+CiDLG3LlzxUknnRRz7Fvf+pb4xS9+IT744APhcDiE3++P+Xp5ebl44oknhBBCLF68WJx99tlCCCGWLVsmLrjgAjFlyhTx1ltvCSGEGDt2rPh//+//HXEcTzzxhMjOzo4GSZ3Nnj1bXHXVVTHH/vu//1ucddZZ0c8BiDvvvDP6ucfjEQCiYxFCiJycHLFixYro510FMwDExo0bY37WyJEjxQ9+8IPo57W1tQKAuOuuu6LH1q1bJwCI2tpaIYQQV1xxhbj66qtjHueDDz4QsiwLn88ntm/fLgCITz/9NPr1qqoqAYDBDGW0zsHMypUrhaIoYtu2bWLnzp0xH5Hfl7vvvlsYDIaYx/F6vQJA9I+reDEBmGgQmjx5csznpaWlaGhowKZNm+DxeFBQUBDzdZ/Ph5qaGgDA3Llz8fTTT0NVVaxZswann346SkpKsHr1akyePBnV1dXRBL+ebNy4EVOnTkV+fn6XX6+qqsLVV18dc+zEE0/EI4880u1zycrKgsPhQENDwxF/fkcmk+mwa9L5sYuLiwEAkyZNOuxYQ0MDSkpKsGnTJnz55ZcxS0dCCGiaht27d2PHjh0wGAyYNm1a9OsVFRXIzc3t1XiJ0t3UqVOhqioaGhpw8sknd3nOiSeeiHA4jJqaGpSXlwMAduzYAQAYOXJkr34egxmiQchoNMZ8LkkSNE2Dx+NBaWkpVq9efdj3RG64c+bMgdvtxhdffIH3338f999/P0pKSvDAAw9gypQpKCsrw7hx4444BqvVmoyn0u1z6Q2r1dplQnDHx458vatjkZ/n8XhwzTXX4IYbbjjssUaMGBF9oyYaCDweD6qrq6Of7969Gxs3bkR+fj7Gjx+PSy65BD/60Y/w29/+FlOnTkVjYyNWrVqFyZMn4+yzz8a8efNw/PHH48c//jGWLVsGTdOwaNEifOc738H48eN7NRYmABNR1PHHH4+6ujoYDAaMHTs25qOwsBCAHtRMnjwZf/jDH2A0GlFRUYE5c+Zgw4YN+Ne//oW5c+fG9bMmT56MjRs3drtzobKyEh999FHMsY8++gjHHHNM355kPzr++OOxdevWw67d2LFjYTKZUFFRgXA4jPXr10e/Z/v27WlV84YoXp9//jmmTp2KqVOnAtDLIEydOhV33303AGDFihX40Y9+hJtvvhkTJkzAggUL8Nlnn2HEiBEAAFmW8frrr6OwsBBz5szB2WefjcrKSqxcubLXY+HMDBFFzZs3D7NmzcKCBQvwm9/8BuPHj8eBAwfwxhtvYOHChZg+fToAfUfUo48+Gt11kJ+fj8rKSrzwwgtxF9C66KKLcP/992PBggVYunQpSktLsWHDBpSVlWHWrFn4n//5H3z/+9/H1KlTMW/ePLz++uv45z//iX//+9/99vz76he/+AVmzpyJ6667DldeeSWysrKwdetWvPfee/jDH/6ACRMmYP78+bjmmmuwfPlyGAwG3HjjjUmbpSI6mk455RTo6TJdMxqNWLJkCZYsWdLtOWVlZfjHP/7R57FwZoaIoiRJwptvvok5c+bg8ssvx/jx43HhhRdiz5490fwQQM+bUVU1JjfmlFNOOexYT0wmE959910UFRXhrLPOwqRJk/DAAw9AURQAwIIFC/DII4/goYcewsSJE/HEE09gxYoVcT9+KkyePBlr1qzBjh07cPLJJ0f/Si0rK4ues2LFCpSVlWHu3Lk477zzcPXVV6OoqCiFoybKfJLoKawiIiIiSnOcmSEiIqKMxmCGiPrF/fffD7vd3uVHT/1ciIh6i8tMRNQvWlpaut2pZLVaMXTo0KM8IiIaqBjMEBERUUbjMhMRERFlNAYzRERElNEYzBAREVFGYzBDREREGY3BDBEREWU0BjNERESU0RjMEBERUUZjMENEREQZ7f8DTH3rzgZxiDYAAAAASUVORK5CYII=", + "image/png": "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99J6otlscvjwYb74xS/2eymb4qmnnuLIkSM8++yzPPnkkwRBwIc//GGazWa/l9YTY2NjfPazn+X555/n+9//Pr/4i7/IL//yL/PjH/+430vrueeee44vfelL3HTTTf1eSk/dcMMNnDp1qvvxzDPP9HtJPbO4uMidd96JZVl861vf4ic/+Ql//ud/zsDAQL+X1jPPPffcmr+/J598EoCPfexjfV7Zpfvc5z7Ho48+yhe+8AVeeeUVPve5z/Fnf/ZnfP7zn+/vwtQOcNttt6kjR450fx1FkRodHVWPPPJIH1e1OQD1xBNP9HsZm2pmZkYB6qmnnur3UjbNwMCA+tu//dt+L6On6vW6uvrqq9WTTz6pfv7nf1498MAD/V5STzz88MPq8OHD/V7Gpvn93/999b73va/fy9hSDzzwgDpw4ICK47jfS7lkd999t7rvvvvWfO5XfuVX1D333NOnFSW2febD932ef/55PvShD3U/p+s6H/rQh/if//mfPq5MXKylpSUASqVSn1fSe1EU8dWvfpVms8kdd9zR7+X01JEjR7j77rvX/L+4Xbz++uuMjo6yf/9+7rnnHo4fP97vJfXMv/7rv3LLLbfwsY99jKGhId797nfzN3/zN/1e1qbxfZ+///u/57777uv5cNN+eO9738t3vvMdXnvtNQB++MMf8swzz3DXXXf1dV2X3WC5XpubmyOKIoaHh9d8fnh4mJ/+9Kd9WpW4WHEc8+CDD3LnnXdy6NChfi+nZ1566SXuuOMOOp0O2WyWJ554guuvv77fy+qZr371q7zwwgs899xz/V5Kz91+++185Stf4dprr+XUqVP8yZ/8Ce9///t5+eWXyeVy/V7eJTt69CiPPvooDz30EH/wB3/Ac889x2//9m9j2zb33ntvv5fXc1//+tepVqt84hOf6PdSeuKTn/wktVqN6667DsMwiKKIT3/609xzzz19Xde2Dz7E9nLkyBFefvnlbXWmDnDttdfy4osvsrS0xD/90z9x77338tRTT22LAOTEiRM88MADPPnkk7iu2+/l9NzqnyBvuukmbr/9diYmJvja177Gb/3Wb/VxZb0RxzG33HILn/nMZwB497vfzcsvv8xf//Vfb8vg4+/+7u+46667GB0d7fdSeuJrX/sa//AP/8Djjz/ODTfcwIsvvsiDDz7I6OhoX//+tn3wUalUMAyD6enpNZ+fnp5m165dfVqVuBj3338/3/zmN3n66acZGxvr93J6yrZtrrrqKgBuvvlmnnvuOf7qr/6KL33pS31e2aV7/vnnmZmZ4T3veU/3c1EU8fTTT/OFL3wBz/MwDKOPK+ytYrHINddcwxtvvNHvpfTEyMjIWUHwwYMH+ed//uc+rWjzHDt2jP/8z//kX/7lX/q9lJ75vd/7PT75yU/ya7/2awDceOONHDt2jEceeaSvwce2r/mwbZubb76Z73znO93PxXHMd77znW13pr5dKaW4//77eeKJJ/jud7/Lvn37+r2kTRfHMZ7n9XsZPfHBD36Ql156iRdffLH7ccstt3DPPffw4osvbqvAA6DRaPDmm28yMjLS76X0xJ133nnW1fbXXnuNiYmJPq1o8zz22GMMDQ1x991393spPdNqtdD1tVu9YRjEcdynFSW2feYD4KGHHuLee+/llltu4bbbbuMv//IvaTab/OZv/ma/l9YTjUZjzU9Zb731Fi+++CKlUonx8fE+rqw3jhw5wuOPP843vvENcrkcp0+fBqBQKJBKpfq8ukv3qU99irvuuovx8XHq9TqPP/44//3f/823v/3tfi+tJ3K53Fn1OZlMhnK5vC3qdn73d3+Xj3zkI0xMTDA1NcXDDz+MYRj8+q//er+X1hO/8zu/w3vf+14+85nP8Ku/+qt873vf48tf/jJf/vKX+720norjmMcee4x7770X09w+W+NHPvIRPv3pTzM+Ps4NN9zAD37wA/7iL/6C++67r78L6+tdmy30+c9/Xo2PjyvbttVtt92mnn322X4vqWf+67/+SwFnfdx77739XlpPrPdsgHrsscf6vbSeuO+++9TExISybVsNDg6qD37wg+o//uM/+r2sTbWdrtp+/OMfVyMjI8q2bbV792718Y9/XL3xxhv9XlZP/du//Zs6dOiQchxHXXfdderLX/5yv5fUc9/+9rcVoF599dV+L6WnarWaeuCBB9T4+LhyXVft379f/eEf/qHyPK+v69KU6nObMyGEEELsKNu+5kMIIYQQlxcJPoQQQgixpST4EEIIIcSWkuBDCCGEEFtKgg8hhBBCbCkJPoQQQgixpST4EEIIIcSWkuBDCCGEEFtKgg8hhBBCbCkJPoQQQgixpST4EEIIIcSWkuBDCCGEEFvq/wfPqAyP2kEskwAAAABJRU5ErkJggg==", "text/plain": [ "
" ] @@ -628,15 +552,12 @@ ], "source": [ "# similarly, for fever\n", - "\n", - "local_symptom_data[\"search_trends_fever\"] = \\\n", - " local_symptom_data[\"search_trends_fever\"].astype(float)\n", - "sns.regplot(x=\"new_confirmed\", y=\"search_trends_fever\", data=local_symptom_data)" + "sns.regplot(x=\"new_cases_percent_of_pop\", y=\"search_trends_fever\", data=weekly_data, scatter_kws={'alpha': 0.2, \"s\" :5})" ] }, { "cell_type": "code", - "execution_count": 31, + "execution_count": 63, "metadata": { "id": "-S1A9E3WGaYH" }, @@ -644,16 +565,16 @@ { "data": { "text/plain": [ - "" + "" ] }, - "execution_count": 31, + "execution_count": 63, "metadata": {}, "output_type": "execute_result" }, { "data": { - "image/png": "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4Vf+3XffFYMGDRIGg0EMGzZM/Pvf//bbr6qqePzxx0VKSoowGo3i6quvFkeOHOmIlxISrTkfffr0afJaWLJkiRBCCLvdLiZPnix69Ogh9Hq96NOnj5g7d65m3piFaN35ePDBB31lU1JSxDXXXOM3X4YQkXV9CCHEt99+KwCITz/9tNGxtH59NPd+WHcO7rjjDjFhwoRGj7n00kuFwWAQ/fv395tzp05L57iza+05mTBhQovlhfAOX09LSxMGg0H07NlT3HzzzSI/P79NcXLVbCIiItI09pkhIiIiTWMyQ0RERJrGZIaIiIg0jckMERERaRqTGSIiItI0JjNERESkaUxmiIiISNOYzBAREVGrff7557juuuuQnp4OSZLwwQcftPoYQgi88MILGDRoEIxGI3r27In//d//bfVxmMwQUZe2detWZGdnQ6/XY/r06di8eTMkSUJFRUW4Q/Pp27cvVqxYEe4wiFqlqqoKI0aMwKuvvhr0MX75y1/ijTfewAsvvIBvv/0W69evb7QSeyB0QUdARKQBCxYswKWXXoqPP/4YcXFxiImJQXFxMcxmc7hDI9K0adOmYdq0ac3udzgcePTRR/HOO++goqICl1xyCZ599llMnDgRAJCXl4eVK1fi4MGDGDx4MADv4q3BYM0MEXVpBQUF+OEPf4hevXohISEBBoMBqampkCSpyfIejweqqnZwlERdz3333Yft27djzZo12L9/P37yk59g6tSpOHbsGADgX//6F/r3748PP/wQ/fr1Q9++fTFnzhyUl5e3+rmYzBBFmIkTJ+KBBx7Ar3/9ayQmJiI1NRVPPvmkb39FRQXmzJmDHj16wGQy4Yc//CH27dsHALBYLFAUBTt37gQAqKqKxMREXHHFFb7H//Wvf0VGRkZAsZw+fRq33HILEhMTERsbi5EjR+Lrr7/27V+5ciUyMzNhMBgwePBgvPXWW36PlyQJb7zxBmbMmIGYmBgMHDgQ69evBwAcP34ckiShrKwMd911FyRJwurVqxs1M61evRoJCQlYv349hg4dCqPRiJMnT6Jv3774zW9+g9tvvx1xcXHo06cP1q9fj7Nnz+KGG25AXFwchg8f7jsXdb788kuMGzcO0dHRyMjIwAMPPICqqirf/tLSUlx33XWIjo5Gv3798Le//S2gc0WkJSdPnsSqVavw3nvvYdy4ccjMzMRDDz2EK6+8EqtWrQIAfPfddzhx4gTee+89vPnmm1i9ejV27dqFH//4x61/wjYtU0lEmjNhwgRhMpnEk08+KY4ePSr+8pe/CEmSfKsgT5o0SVx33XXim2++EUePHhULFy4U3bt3F2VlZUIIIS677DLx/PPPCyGE2Lt3r0hMTBQGg8G3OvucOXPEbbfddtE4KisrRf/+/cW4cePEF198IY4dOyb+/ve/i23btgkhhHj//feFXq8Xr776qjhy5Ij47W9/KxRFEf/5z398xwAgevXqJd5++21x7Ngx8cADD4i4uDhRVlYm3G63KC4uFiaTSaxYsUIUFxcLu93uWwX4/PnzQgghVq1aJfR6vRgzZozYunWr+Pbbb0VVVZXo06ePSExMFH/84x/F0aNHxbx584TJZBJTp04V7777rjhy5IiYPn26yMrKEqqqCiGEyM/PF7GxseKll14SR48eFVu3bhU5OTli9uzZvpinTZsmRowYIbZv3y527twpxowZI6Kjo8VLL73Utj8sURgBEGvXrvXd//DDDwUAERsb63fT6XTipptuEkIIMXfuXAHAb5X5Xbt2CQDi22+/bd3zh+RVEJFmTJgwQVx55ZV+237wgx+Ihx9+WHzxxRfCZDKJmpoav/2ZmZnitddeE0IIsWDBAnHttdcKIYRYsWKFuPnmm8WIESPExx9/LIQQYsCAAeL//u//LhrHa6+9JuLj431JUkNjxowRc+fO9dv2k5/8RFxzzTW++wDEY4895rtvs9kEAF8sQghhNpvFqlWrfPebSmYAiL179/o9V58+fcRPf/pT3/3i4mIBQDz++OO+bdu3bxcARHFxsRBCiLvvvlvcc889fsf54osvhCzLorq6Whw5ckQAEDt27PDtz8vLEwCYzJCmNUxm1qxZIxRFEd9++604duyY363u/8sTTzwhdDqd33HsdrsA4PtyFSh2ACaKQMOHD/e7n5aWhtLSUuzbtw82mw3du3f3219dXY2CggIAwIQJE/CnP/0JHo8HW7ZsweTJk5GamorNmzdj+PDhyM/P93Xwa8nevXuRk5ODxMTEJvfn5eXhnnvu8ds2duxY/O53v2v2tcTGxsJkMqG0tPSiz1+fwWBodE4aHjslJQUAkJ2d3WhbaWkpUlNTsW/fPuzfv9+v6UgIAVVVUVhYiKNHj0Kn0+Hyyy/37R8yZAgSEhJaFS9RZ5eTkwOPx4PS0lKMGzeuyTJjx46F2+1GQUEBMjMzAQBHjx4FAPTp06dVz8dkhigC6fV6v/uSJEFVVdhsNqSlpWHz5s2NHlP3gTt+/HhUVlZi9+7d+Pzzz/HMM88gNTUVy5cvx4gRI5Ceno6BAwdeNIbo6OhQvJRmX0trREdHN9khuP6x6/Y3ta3u+Ww2G+6991488MADjY7Vu3dv3xs1UVdgs9mQn5/vu19YWIi9e/ciMTERgwYNwm233Ybbb78dv/3tb5GTk4OzZ89i06ZNGD58OK699lpMmjQJl112Ge666y6sWLECqqpi/vz5+NGPfoRBgwa1KhZ2ACYin8suuwwlJSXQ6XQYMGCA3y0pKQmAN6kZPnw4XnnlFej1egwZMgTjx4/Hnj178OGHH2LChAkBPdfw4cOxd+/eZkcuZGVlYevWrX7btm7diqFDh7btRbajyy67DIcPH2507gYMGACDwYAhQ4bA7XZj165dvsccOXKkU815QxSonTt3IicnBzk5OQC80yDk5OTgiSeeAACsWrUKt99+OxYuXIjBgwdj+vTp+Oabb9C7d28AgCzL+Ne//oWkpCSMHz8e1157LbKysrBmzZpWx8KaGSLymTRpEkaPHo3p06fjueeew6BBg1BUVIR///vfmDFjBkaOHAnAOyLq5Zdf9o06SExMRFZWFv7+978HPIHWLbfcgmeeeQbTp0/HsmXLkJaWhj179iA9PR2jR4/Gr371K9x0003IycnBpEmT8K9//Qvvv/8+Pvvss3Z7/W318MMP44orrsB9992HOXPmIDY2FocPH8bGjRvxyiuvYPDgwZg6dSruvfderFy5EjqdDg8++GDIaqmIOtLEiRPh7S7TNL1ej6VLl2Lp0qXNlklPT8c///nPNsfCmhki8pEkCR999BHGjx+PO++8E4MGDcKsWbNw4sQJX/8QwNtvxuPx+PWNmThxYqNtLTEYDPj000+RnJyMa665BtnZ2Vi+fDkURQEATJ8+Hb/73e/wwgsvYNiwYXjttdewatWqgI8fDsOHD8eWLVtw9OhRjBs3zvctNT093Vdm1apVSE9Px4QJEzBz5kzcc889SE5ODmPURNoniZbSKiIiIqJOjjUzREREpGlMZoioXTzzzDOIi4tr8tbSei5ERK3FZiYiahfl5eXNjlSKjo5Gz549OzgiIuqqmMwQERGRprGZiYiIiDSNyQwRERFpGpMZIiIi0jQmM0RERKRpTGaIiIhI05jMEBERkaYxmSEiIiJNYzJDREREmvb/AWnb8RQ31dpTAAAAAElFTkSuQmCC", + "image/png": "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HN57773tDqllHnzwwfD6669vdxjr5k/+5E/Cj33sY+0O4wNz7733hjt37gyDIGh3KC1x1113hQcOHFjysU9+8pPhPffc06aIwnBTVkYcx+HVV1/l9ttvb35MVVVuv/12fvKTn7QxMnEhCoUCAJ2dnW2OpPV83+d73/selUqFffv2tTucljp48CB33XXXkv8PN5Jf/OIXDAwMsGPHDu655x5GRkbaHVLL/Pu//zs333wzn/rUp+jp6eGGG27g7/7u79od1rpwHId//Md/5MCBAy29qLWdPvrRj/Lss8/y7rvvAvCzn/2MF154gTvvvLNtMV0WF+W12szMDL7v09vbu+Tjvb29vPPOO22KSlyIIAi47777uO2227juuuvaHU7LvPnmm+zbtw/Lskgmkzz11FNcc8017Q6rZb73ve/x2muv8fLLL7c7lHVx66238vjjj3P11VczPj7OF7/4RT7+8Y/z1ltvkUql2h3eRTt27BiPPPII999/P3/6p3/Kyy+/zB/8wR9gGAb79+9vd3gt9fTTT5PP5/md3/mddofSMl/4whcoFovs2rULTdPwfZ8vfelL3HPPPW2LaVMmI2LjOHjwIG+99daG2o8HuPrqqzl06BCFQoF//ud/Zv/+/Tz33HMbIiE5deoU9957L8888wzRaLTd4ayLxe8w9+7dy6233srw8DBPPvkkv/d7v9fGyFojCAJuvvlmvvzlLwNwww038NZbb/Gtb31rwyUjf//3f8+dd97JwMBAu0NpmSeffJLvfve7PPHEE1x77bUcOnSI++67j4GBgba9fpsyGenq6kLTNCYnJ5d8fHJykr6+vjZFJc7X5z73Of7zP/+T559/nsHBwXaH01KGYXDFFVcAcNNNN/Hyyy/z9a9/nW9/+9ttjuzivfrqq0xNTXHjjTc2P+b7Ps8//zwPP/wwtm2jaVobI2y9bDbLVVddxdGjR9sdSkv09/efkRjv3r2bf/mXf2lTROvj5MmT/M///A//+q//2u5QWuqP/uiP+MIXvsBv/uZvArBnzx5OnjzJQw891LZkZFP2jBiGwU033cSzzz7b/FgQBDz77LMbbl9+IwrDkM997nM89dRT/OhHP2L79u3tDmndBUGAbdvtDqMlPvGJT/Dmm29y6NCh5p+bb76Ze+65h0OHDm24RASgXC7z3nvv0d/f3+5QWuK222474zj9u+++y/DwcJsiWh+PPfYYPT093HXXXe0OpaWq1SqqunT51zSNIAjaFNEmrYwA3H///ezfv5+bb76ZW265hb/927+lUqnwu7/7u+0OrSXK5fKSd2HHjx/n0KFDdHZ2MjQ01MbILt7Bgwd54okn+Ld/+zdSqRQTExMAZDIZYrFYm6O7eA888AB33nknQ0NDlEolnnjiCX784x/zwx/+sN2htUQqlTqjvyeRSJDL5TZM388f/uEfcvfddzM8PMzY2BgPPvggmqbxW7/1W+0OrSU+//nP89GPfpQvf/nLfPrTn+all17i0Ucf5dFHH213aC0TBAGPPfYY+/fvR9c31lJ5991386UvfYmhoSGuvfZaXn/9df76r/+aAwcOtC+otp3juQR84xvfCIeGhkLDMMJbbrklfPHFF9sdUsv87//+bwic8Wf//v3tDu2irfS8gPCxxx5rd2gtceDAgXB4eDg0DCPs7u4OP/GJT4T//d//3e6w1tVGO9r7mc98Juzv7w8Nwwi3bNkSfuYznwmPHj3a7rBa6j/+4z/C6667LjRNM9y1a1f46KOPtjuklvrhD38YAuGRI0faHUrLFYvF8N577w2HhobCaDQa7tixI/yzP/uz0LbttsWkhGEbR64JIYQQYtPblD0jQgghhLh0SDIihBBCiLaSZEQIIYQQbSXJiBBCCCHaSpIRIYQQQrSVJCNCCCGEaCtJRoQQQgjRVpKMCCGEEKKtJBkRQgghRFtJMiKEEEKItpJkRAghhBBtJcmIEEIIIdrq/wHRnL6tjOlowAAAAABJRU5ErkJggg==", "text/plain": [ "
" ] @@ -664,12 +585,11 @@ ], "source": [ "# similarly, for bruise\n", - "local_symptom_data[\"search_trends_bruise\"] = \\\n", - " local_symptom_data[\"search_trends_bruise\"].astype(float)\n", "sns.regplot(\n", - " x=\"new_confirmed\",\n", + " x=\"new_cases_percent_of_pop\",\n", " y=\"search_trends_bruise\",\n", - " data=local_symptom_data\n", + " data=weekly_data,\n", + " scatter_kws={'alpha': 0.2, \"s\" :5}\n", ")" ] }, @@ -695,7 +615,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "We used matplotlib to draw a line graph of COVID-19 cases over time in the USA. Then, we used downsampling to download only a portion of the available data, and used seaborn locally to plot lines of best fit to observe corellation between COVID-19 cases and searches for related vs. unrelated symptoms.\n", + "We used matplotlib to draw a line graph of COVID-19 cases over time in the USA. Then, we used downsampling to download only a portion of the available data, used seaborn to plot lines of best fit to observe corellation between COVID-19 cases and searches for related versus unrelated symptoms.\n", "\n", "Thank you for using BigQuery DataFrames!" ] diff --git a/tests/unit/test_interchange.py b/tests/unit/test_interchange.py new file mode 100644 index 0000000000..87f6c91e23 --- /dev/null +++ b/tests/unit/test_interchange.py @@ -0,0 +1,108 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pathlib +from typing import Generator + +import pandas as pd +import pandas.api.interchange as pd_interchange +import pandas.testing +import pytest + +import bigframes +import bigframes.pandas as bpd +from bigframes.testing.utils import convert_pandas_dtypes + +pytest.importorskip("polars") +pytest.importorskip("pandas", minversion="2.0.0") + +CURRENT_DIR = pathlib.Path(__file__).parent +DATA_DIR = CURRENT_DIR.parent / "data" + + +@pytest.fixture(scope="module", autouse=True) +def session() -> Generator[bigframes.Session, None, None]: + import bigframes.core.global_session + from bigframes.testing import polars_session + + session = polars_session.TestSession() + with bigframes.core.global_session._GlobalSessionContext(session): + yield session + + +@pytest.fixture(scope="module") +def scalars_pandas_df_index() -> pd.DataFrame: + """pd.DataFrame pointing at test data.""" + + df = pd.read_json( + DATA_DIR / "scalars.jsonl", + lines=True, + ) + convert_pandas_dtypes(df, bytes_col=True) + + df = df.set_index("rowindex", drop=False) + df.index.name = None + return df.set_index("rowindex").sort_index() + + +def test_interchange_df_logical_properties(session): + df = bpd.DataFrame({"a": [1, 2, 3], 2: [4, 5, 6]}, session=session) + interchange_df = df.__dataframe__() + assert interchange_df.num_columns() == 2 + assert interchange_df.num_rows() == 3 + assert interchange_df.column_names() == ["a", "2"] + + +def test_interchange_column_logical_properties(session): + df = bpd.DataFrame( + { + "nums": [1, 2, 3, None, None], + "animals": ["cat", "dog", "mouse", "horse", "turtle"], + }, + session=session, + ) + interchange_df = df.__dataframe__() + + assert interchange_df.get_column_by_name("nums").size() == 5 + assert interchange_df.get_column(0).null_count == 2 + + assert interchange_df.get_column_by_name("animals").size() == 5 + assert interchange_df.get_column(1).null_count == 0 + + +def test_interchange_to_pandas(session, scalars_pandas_df_index): + # A few limitations: + # 1) Limited datatype support + # 2) Pandas converts null to NaN/False, rather than use nullable or pyarrow types + # 3) Indices aren't preserved by interchange format + unsupported_cols = [ + "bytes_col", + "date_col", + "numeric_col", + "time_col", + "duration_col", + "geography_col", + ] + scalars_pandas_df_index = scalars_pandas_df_index.drop(columns=unsupported_cols) + scalars_pandas_df_index = scalars_pandas_df_index.bfill().ffill() + bf_df = session.read_pandas(scalars_pandas_df_index) + + from_ix = pd_interchange.from_dataframe(bf_df) + + # interchange format does not include index, so just reset both indices before comparison + pandas.testing.assert_frame_equal( + scalars_pandas_df_index.reset_index(drop=True), + from_ix.reset_index(drop=True), + check_dtype=False, + ) From 70d6562df64b2aef4ff0024df6f57702d52dcaf8 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Mon, 15 Sep 2025 11:58:44 -0700 Subject: [PATCH 068/313] feat: Add ai_generate_bool to the bigframes.bigquery package (#2060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add ai_generate_bool to the bigframes.bigquery package * fix stuffs * Fix format * fix doc format * fix format * fix code * expose ai module and rename the function * add ai module to doc * fix test * fix test * Update bigframes/bigquery/_operations/ai.py Co-authored-by: Tim Sweña (Swast) --------- Co-authored-by: Tim Sweña (Swast) --- bigframes/bigquery/__init__.py | 3 +- bigframes/bigquery/_operations/ai.py | 171 ++++++++++++++++++ .../ibis_compiler/scalar_op_registry.py | 26 +++ bigframes/operations/__init__.py | 3 + bigframes/operations/ai_ops.py | 47 +++++ docs/reference/bigframes.bigquery/ai.rst | 7 + docs/reference/bigframes.bigquery/index.rst | 6 +- docs/templates/toc.yml | 2 + tests/system/large/bigquery/__init__.py | 13 ++ tests/system/large/bigquery/test_ai.py | 35 ++++ tests/system/small/bigquery/test_ai.py | 62 +++++++ .../sql/compilers/bigquery/__init__.py | 15 ++ .../ibis/expr/operations/ai_ops.py | 32 ++++ 13 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 bigframes/bigquery/_operations/ai.py create mode 100644 bigframes/operations/ai_ops.py create mode 100644 docs/reference/bigframes.bigquery/ai.rst create mode 100644 tests/system/large/bigquery/__init__.py create mode 100644 tests/system/large/bigquery/test_ai.py create mode 100644 tests/system/small/bigquery/test_ai.py create mode 100644 third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py diff --git a/bigframes/bigquery/__init__.py b/bigframes/bigquery/__init__.py index 7b74c1eb88..072bd21da1 100644 --- a/bigframes/bigquery/__init__.py +++ b/bigframes/bigquery/__init__.py @@ -18,6 +18,7 @@ import sys +from bigframes.bigquery._operations import ai from bigframes.bigquery._operations.approx_agg import approx_top_count from bigframes.bigquery._operations.array import ( array_agg, @@ -98,7 +99,7 @@ struct, ] -__all__ = [f.__name__ for f in _functions] +__all__ = [f.__name__ for f in _functions] + ["ai"] _module = sys.modules[__name__] for f in _functions: diff --git a/bigframes/bigquery/_operations/ai.py b/bigframes/bigquery/_operations/ai.py new file mode 100644 index 0000000000..d7ea29322d --- /dev/null +++ b/bigframes/bigquery/_operations/ai.py @@ -0,0 +1,171 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This module integrates BigQuery built-in AI functions for use with Series/DataFrame objects, +such as AI.GENERATE_BOOL: +https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-ai-generate-bool""" + +from __future__ import annotations + +import json +from typing import Any, List, Literal, Mapping, Tuple + +from bigframes import clients, dtypes, series +from bigframes.core import log_adapter +from bigframes.operations import ai_ops + + +@log_adapter.method_logger(custom_base_name="bigquery_ai") +def generate_bool( + prompt: series.Series | List[str | series.Series] | Tuple[str | series.Series, ...], + *, + connection_id: str | None = None, + endpoint: str | None = None, + request_type: Literal["dedicated", "shared", "unspecified"] = "unspecified", + model_params: Mapping[Any, Any] | None = None, +) -> series.Series: + """ + Returns the AI analysis based on the prompt, which can be any combination of text and unstructured data. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> bpd.options.display.progress_bar = None + >>> df = bpd.DataFrame({ + ... "col_1": ["apple", "bear", "pear"], + ... "col_2": ["fruit", "animal", "animal"] + ... }) + >>> bbq.ai_generate_bool((df["col_1"], " is a ", df["col_2"])) + 0 {'result': True, 'full_response': '{"candidate... + 1 {'result': True, 'full_response': '{"candidate... + 2 {'result': False, 'full_response': '{"candidat... + dtype: struct[pyarrow] + + >>> bbq.ai_generate_bool((df["col_1"], " is a ", df["col_2"])).struct.field("result") + 0 True + 1 True + 2 False + Name: result, dtype: boolean + + >>> model_params = { + ... "generation_config": { + ... "thinking_config": { + ... "thinking_budget": 0 + ... } + ... } + ... } + >>> bbq.ai_generate_bool( + ... (df["col_1"], " is a ", df["col_2"]), + ... endpoint="gemini-2.5-pro", + ... model_params=model_params, + ... ).struct.field("result") + 0 True + 1 True + 2 False + Name: result, dtype: boolean + + Args: + prompt (series.Series | List[str|series.Series] | Tuple[str|series.Series, ...]): + A mixture of Series and string literals that specifies the prompt to send to the model. + connection_id (str, optional): + Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. + If not provided, the connection from the current session will be used. + endpoint (str, optional): + Specifies the Vertex AI endpoint to use for the model. For example `"gemini-2.5-flash"`. You can specify any + generally available or preview Gemini model. If you specify the model name, BigQuery ML automatically identifies and + uses the full endpoint of the model. If you don't specify an ENDPOINT value, BigQuery ML selects a recent stable + version of Gemini to use. + request_type (Literal["dedicated", "shared", "unspecified"]): + Specifies the type of inference request to send to the Gemini model. The request type determines what quota the request uses. + * "dedicated": function only uses Provisioned Throughput quota. The function returns the error Provisioned throughput is not + purchased or is not active if Provisioned Throughput quota isn't available. + * "shared": the function only uses dynamic shared quota (DSQ), even if you have purchased Provisioned Throughput quota. + * "unspecified": If you haven't purchased Provisioned Throughput quota, the function uses DSQ quota. + If you have purchased Provisioned Throughput quota, the function uses the Provisioned Throughput quota first. + If requests exceed the Provisioned Throughput quota, the overflow traffic uses DSQ quota. + model_params (Mapping[Any, Any]): + Provides additional parameters to the model. The MODEL_PARAMS value must conform to the generateContent request body format. + + Returns: + bigframes.series.Series: A new struct Series with the result data. The struct contains these fields: + * "result": a BOOL value containing the model's response to the prompt. The result is None if the request fails or is filtered by responsible AI. + * "full_response": a STRING value containing the JSON response from the projects.locations.endpoints.generateContent call to the model. + The generated text is in the text element. + * "status": a STRING value that contains the API response status for the corresponding row. This value is empty if the operation was successful. + """ + + prompt_context, series_list = _separate_context_and_series(prompt) + assert len(series_list) > 0 + + operator = ai_ops.AIGenerateBool( + prompt_context=tuple(prompt_context), + connection_id=_resolve_connection_id(series_list[0], connection_id), + endpoint=endpoint, + request_type=request_type, + model_params=json.dumps(model_params) if model_params else None, + ) + + return series_list[0]._apply_nary_op(operator, series_list[1:]) + + +def _separate_context_and_series( + prompt: series.Series | List[str | series.Series] | Tuple[str | series.Series, ...], +) -> Tuple[List[str | None], List[series.Series]]: + """ + Returns the two values. The first value is the prompt with all series replaced by None. The second value is all the series + in the prompt. The original item order is kept. + For example: + Input: ("str1", series1, "str2", "str3", series2) + Output: ["str1", None, "str2", "str3", None], [series1, series2] + """ + if not isinstance(prompt, (list, tuple, series.Series)): + raise ValueError(f"Unsupported prompt type: {type(prompt)}") + + if isinstance(prompt, series.Series): + if prompt.dtype == dtypes.OBJ_REF_DTYPE: + # Multi-model support + return [None], [prompt.blob.read_url()] + return [None], [prompt] + + prompt_context: List[str | None] = [] + series_list: List[series.Series] = [] + + for item in prompt: + if isinstance(item, str): + prompt_context.append(item) + + elif isinstance(item, series.Series): + prompt_context.append(None) + + if item.dtype == dtypes.OBJ_REF_DTYPE: + # Multi-model support + item = item.blob.read_url() + series_list.append(item) + + else: + raise TypeError(f"Unsupported type in prompt: {type(item)}") + + if not series_list: + raise ValueError("Please provide at least one Series in the prompt") + + return prompt_context, series_list + + +def _resolve_connection_id(series: series.Series, connection_id: str | None): + return clients.get_canonical_bq_connection_id( + connection_id or series._session._bq_connection, + series._session._project, + series._session._location, + ) diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index af98252643..95dd2bc6b6 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -17,8 +17,10 @@ import functools import typing +from bigframes_vendored import ibis import bigframes_vendored.ibis.expr.api as ibis_api import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes +import bigframes_vendored.ibis.expr.operations.ai_ops as ai_ops import bigframes_vendored.ibis.expr.operations.generic as ibis_generic import bigframes_vendored.ibis.expr.operations.udf as ibis_udf import bigframes_vendored.ibis.expr.types as ibis_types @@ -1963,6 +1965,30 @@ def struct_op_impl( return ibis_types.struct(data) +@scalar_op_compiler.register_nary_op(ops.AIGenerateBool, pass_op=True) +def ai_generate_bool( + *values: ibis_types.Value, op: ops.AIGenerateBool +) -> ibis_types.StructValue: + + prompt: dict[str, ibis_types.Value | str] = {} + column_ref_idx = 0 + + for idx, elem in enumerate(op.prompt_context): + if elem is None: + prompt[f"_field_{idx + 1}"] = values[column_ref_idx] + column_ref_idx += 1 + else: + prompt[f"_field_{idx + 1}"] = elem + + return ai_ops.AIGenerateBool( + ibis.struct(prompt), # type: ignore + op.connection_id, # type: ignore + op.endpoint, # type: ignore + op.request_type.upper(), # type: ignore + op.model_params, # type: ignore + ).to_expr() + + @scalar_op_compiler.register_nary_op(ops.RowKey, pass_op=True) def rowkey_op_impl(*values: ibis_types.Value, op: ops.RowKey) -> ibis_types.Value: return bigframes.core.compile.default_ordering.gen_row_key(values) diff --git a/bigframes/operations/__init__.py b/bigframes/operations/__init__.py index e5888ace00..bb9ec4d294 100644 --- a/bigframes/operations/__init__.py +++ b/bigframes/operations/__init__.py @@ -14,6 +14,7 @@ from __future__ import annotations +from bigframes.operations.ai_ops import AIGenerateBool from bigframes.operations.array_ops import ( ArrayIndexOp, ArrayReduceOp, @@ -408,6 +409,8 @@ "geo_x_op", "geo_y_op", "GeoStDistanceOp", + # AI ops + "AIGenerateBool", # Numpy ops mapping "NUMPY_TO_BINOP", "NUMPY_TO_OP", diff --git a/bigframes/operations/ai_ops.py b/bigframes/operations/ai_ops.py new file mode 100644 index 0000000000..fe5eb1406f --- /dev/null +++ b/bigframes/operations/ai_ops.py @@ -0,0 +1,47 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import dataclasses +from typing import ClassVar, Literal, Tuple + +import pandas as pd +import pyarrow as pa + +from bigframes import dtypes +from bigframes.operations import base_ops + + +@dataclasses.dataclass(frozen=True) +class AIGenerateBool(base_ops.NaryOp): + name: ClassVar[str] = "ai_generate_bool" + + # None are the placeholders for column references. + prompt_context: Tuple[str | None, ...] + connection_id: str + endpoint: str | None + request_type: Literal["dedicated", "shared", "unspecified"] + model_params: str | None + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.bool_()), + pa.field("full_response", pa.string()), + pa.field("status", pa.string()), + ) + ) + ) diff --git a/docs/reference/bigframes.bigquery/ai.rst b/docs/reference/bigframes.bigquery/ai.rst new file mode 100644 index 0000000000..2134125d6f --- /dev/null +++ b/docs/reference/bigframes.bigquery/ai.rst @@ -0,0 +1,7 @@ +bigframes.bigquery.ai +============================= + +.. automodule:: bigframes.bigquery._operations.ai + :members: + :inherited-members: + :undoc-members: \ No newline at end of file diff --git a/docs/reference/bigframes.bigquery/index.rst b/docs/reference/bigframes.bigquery/index.rst index 03e9bb48a4..f9d34f379d 100644 --- a/docs/reference/bigframes.bigquery/index.rst +++ b/docs/reference/bigframes.bigquery/index.rst @@ -5,5 +5,9 @@ BigQuery Built-in Functions .. automodule:: bigframes.bigquery :members: - :inherited-members: :undoc-members: + +.. toctree:: + :maxdepth: 2 + + ai diff --git a/docs/templates/toc.yml b/docs/templates/toc.yml index a27f162a9a..ad96977152 100644 --- a/docs/templates/toc.yml +++ b/docs/templates/toc.yml @@ -218,6 +218,8 @@ - items: - name: BigQuery built-in functions uid: bigframes.bigquery + - name: BigQuery AI Functions + uid: bigframes.bigquery.ai name: bigframes.bigquery - items: - name: GeoSeries diff --git a/tests/system/large/bigquery/__init__.py b/tests/system/large/bigquery/__init__.py new file mode 100644 index 0000000000..0a2669d7a2 --- /dev/null +++ b/tests/system/large/bigquery/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/system/large/bigquery/test_ai.py b/tests/system/large/bigquery/test_ai.py new file mode 100644 index 0000000000..be0216a526 --- /dev/null +++ b/tests/system/large/bigquery/test_ai.py @@ -0,0 +1,35 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pandas as pd +import pandas.testing + +import bigframes.bigquery as bbq + + +def test_ai_generate_bool_multi_model(session): + df = session.from_glob_path( + "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" + ) + + result = bbq.ai.generate_bool((df["image"], " contains an animal")).struct.field( + "result" + ) + + pandas.testing.assert_series_equal( + result.to_pandas(), + pd.Series([True, True, False, False, False], name="result"), + check_dtype=False, + check_index=False, + ) diff --git a/tests/system/small/bigquery/test_ai.py b/tests/system/small/bigquery/test_ai.py new file mode 100644 index 0000000000..01050ade04 --- /dev/null +++ b/tests/system/small/bigquery/test_ai.py @@ -0,0 +1,62 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys + +import pandas as pd +import pandas.testing +import pytest + +import bigframes.bigquery as bbq +import bigframes.pandas as bpd + + +def test_ai_generate_bool(session): + s1 = bpd.Series(["apple", "bear"], session=session) + s2 = bpd.Series(["fruit", "tree"], session=session) + prompt = (s1, " is a ", s2) + + result = bbq.ai.generate_bool(prompt, endpoint="gemini-2.5-flash").struct.field( + "result" + ) + + pandas.testing.assert_series_equal( + result.to_pandas(), + pd.Series([True, False], name="result"), + check_dtype=False, + check_index=False, + ) + + +def test_ai_generate_bool_with_model_params(session): + if sys.version_info < (3, 12): + pytest.skip( + "Skip test because SQLGLot cannot compile model params to JSON at this env." + ) + + s1 = bpd.Series(["apple", "bear"], session=session) + s2 = bpd.Series(["fruit", "tree"], session=session) + prompt = (s1, " is a ", s2) + model_params = {"generation_config": {"thinking_config": {"thinking_budget": 0}}} + + result = bbq.ai.generate_bool( + prompt, endpoint="gemini-2.5-flash", model_params=model_params + ).struct.field("result") + + pandas.testing.assert_series_equal( + result.to_pandas(), + pd.Series([True, False], name="result"), + check_dtype=False, + check_index=False, + ) diff --git a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py index 9af2a4afe4..6ea11d5215 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py @@ -1104,6 +1104,21 @@ def visit_StringAgg(self, op, *, arg, sep, order_by, where): expr = arg return self.agg.string_agg(expr, sep, where=where) + def visit_AIGenerateBool(self, op, **kwargs): + func_name = "AI.GENERATE_BOOL" + + args = [] + for key, val in kwargs.items(): + if val is None: + continue + + if key == "model_params": + val = sge.JSON(this=val) + + args.append(sge.Kwarg(this=sge.Identifier(this=key), expression=val)) + + return sge.func(func_name, *args) + def visit_FirstNonNullValue(self, op, *, arg): return sge.IgnoreNulls(this=sge.FirstValue(this=arg)) diff --git a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py new file mode 100644 index 0000000000..1f8306bad6 --- /dev/null +++ b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py @@ -0,0 +1,32 @@ +# Contains code from https://github.com/ibis-project/ibis/blob/9.2.0/ibis/expr/operations/maps.py + +"""Operations for working with maps.""" + +from __future__ import annotations + +from typing import Optional + +from bigframes_vendored.ibis.common.annotations import attribute +import bigframes_vendored.ibis.expr.datatypes as dt +from bigframes_vendored.ibis.expr.operations.core import Value +import bigframes_vendored.ibis.expr.rules as rlz +from public import public + + +@public +class AIGenerateBool(Value): + """Generate Bool based on the prompt""" + + prompt: Value + connection_id: Value[dt.String] + endpoint: Optional[Value[dt.String]] + request_type: Value[dt.String] + model_params: Optional[Value[dt.String]] + + shape = rlz.shape_like("prompt") + + @attribute + def dtype(self) -> dt.Struct: + return dt.Struct.from_tuples( + (("result", dt.bool), ("full_resposne", dt.string), ("status", dt.string)) + ) From a52b913d9d8794b4b959ea54744a38d9f2f174e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Mon, 15 Sep 2025 14:27:58 -0500 Subject: [PATCH 069/313] fix: return a DataFrame containing query stats for all non-SELECT statements (#2071) * fix: return a DataFrame containing query stats for all non-SELECT statements * return some results in SCRIPT if the last statement is a SELECT --- .../session/_io/bigquery/read_gbq_query.py | 22 ++++++ bigframes/session/loader.py | 47 ++++++++----- tests/system/small/test_session.py | 69 +++++++++++++++---- 3 files changed, 110 insertions(+), 28 deletions(-) diff --git a/bigframes/session/_io/bigquery/read_gbq_query.py b/bigframes/session/_io/bigquery/read_gbq_query.py index aed77615ce..b650266a0d 100644 --- a/bigframes/session/_io/bigquery/read_gbq_query.py +++ b/bigframes/session/_io/bigquery/read_gbq_query.py @@ -32,6 +32,28 @@ import bigframes.session +def should_return_query_results(query_job: bigquery.QueryJob) -> bool: + """Returns True if query_job is the kind of query we expect results from. + + If the query was DDL or DML, return some job metadata. See + https://cloud.google.com/bigquery/docs/reference/rest/v2/Job#JobStatistics2.FIELDS.statement_type + for possible statement types. Note that destination table does exist + for some DDL operations such as CREATE VIEW, but we don't want to + read from that. See internal issue b/444282709. + """ + + if query_job.statement_type == "SELECT": + return True + + if query_job.statement_type == "SCRIPT": + # Try to determine if the last statement is a SELECT. Alternatively, we + # could do a jobs.list request using query_job as the parent job and + # try to determine the statement type of the last child job. + return query_job.destination != query_job.ddl_target_table + + return False + + def create_dataframe_from_query_job_stats( query_job: Optional[bigquery.QueryJob], *, session: bigframes.session.Session ) -> dataframe.DataFrame: diff --git a/bigframes/session/loader.py b/bigframes/session/loader.py index 49b1195235..94d8db6f36 100644 --- a/bigframes/session/loader.py +++ b/bigframes/session/loader.py @@ -42,6 +42,7 @@ from google.cloud import bigquery_storage_v1 import google.cloud.bigquery import google.cloud.bigquery as bigquery +import google.cloud.bigquery.table from google.cloud.bigquery_storage_v1 import types as bq_storage_types import pandas import pyarrow as pa @@ -1004,7 +1005,7 @@ def read_gbq_query( configuration=configuration, ) query_job_for_metrics = query_job - rows = None + rows: Optional[google.cloud.bigquery.table.RowIterator] = None else: job_config = typing.cast( bigquery.QueryJobConfig, @@ -1037,21 +1038,14 @@ def read_gbq_query( query_job=query_job_for_metrics, row_iterator=rows ) - # It's possible that there's no job and corresponding destination table. - # In this case, we must create a local node. + # It's possible that there's no job and therefore no corresponding + # destination table. In this case, we must create a local node. # # TODO(b/420984164): Tune the threshold for which we download to # local node. Likely there are a wide range of sizes in which it # makes sense to download the results beyond the first page, even if # there is a job and destination table available. - if ( - rows is not None - and destination is None - and ( - query_job_for_metrics is None - or query_job_for_metrics.statement_type == "SELECT" - ) - ): + if query_job_for_metrics is None and rows is not None: return bf_read_gbq_query.create_dataframe_from_row_iterator( rows, session=self._session, @@ -1059,22 +1053,43 @@ def read_gbq_query( columns=columns, ) - # If there was no destination table and we've made it this far, that - # means the query must have been DDL or DML. Return some job metadata, - # instead. - if not destination: + # We already checked rows, so if there's no destination table, then + # there are no results to return. + if destination is None: return bf_read_gbq_query.create_dataframe_from_query_job_stats( query_job_for_metrics, session=self._session, ) + # If the query was DDL or DML, return some job metadata. See + # https://cloud.google.com/bigquery/docs/reference/rest/v2/Job#JobStatistics2.FIELDS.statement_type + # for possible statement types. Note that destination table does exist + # for some DDL operations such as CREATE VIEW, but we don't want to + # read from that. See internal issue b/444282709. + if ( + query_job_for_metrics is not None + and not bf_read_gbq_query.should_return_query_results(query_job_for_metrics) + ): + return bf_read_gbq_query.create_dataframe_from_query_job_stats( + query_job_for_metrics, + session=self._session, + ) + + # Speed up counts by getting counts from result metadata. + if rows is not None: + n_rows = rows.total_rows + elif query_job_for_metrics is not None: + n_rows = query_job_for_metrics.result().total_rows + else: + n_rows = None + return self.read_gbq_table( f"{destination.project}.{destination.dataset_id}.{destination.table_id}", index_col=index_col, columns=columns, use_cache=configuration["query"]["useQueryCache"], force_total_order=force_total_order, - n_rows=query_job.result().total_rows, + n_rows=n_rows, # max_results and filters are omitted because they are already # handled by to_query(), above. ) diff --git a/tests/system/small/test_session.py b/tests/system/small/test_session.py index 892f8c8898..38d66bceb2 100644 --- a/tests/system/small/test_session.py +++ b/tests/system/small/test_session.py @@ -430,18 +430,63 @@ def test_read_gbq_w_max_results( assert bf_result.shape[0] == max_results -def test_read_gbq_w_script_no_select(session, dataset_id: str): - ddl = f""" - CREATE TABLE `{dataset_id}.test_read_gbq_w_ddl` ( - `col_a` INT64, - `col_b` STRING - ); - - INSERT INTO `{dataset_id}.test_read_gbq_w_ddl` - VALUES (123, 'hello world'); - """ - df = session.read_gbq(ddl).to_pandas() - assert df["statement_type"][0] == "SCRIPT" +@pytest.mark.parametrize( + ("sql_template", "expected_statement_type"), + ( + pytest.param( + """ + CREATE OR REPLACE TABLE `{dataset_id}.test_read_gbq_w_ddl` ( + `col_a` INT64, + `col_b` STRING + ); + """, + "CREATE_TABLE", + id="ddl-create-table", + ), + pytest.param( + # From https://cloud.google.com/bigquery/docs/boosted-tree-classifier-tutorial + """ + CREATE OR REPLACE VIEW `{dataset_id}.test_read_gbq_w_create_view` + AS + SELECT + age, + workclass, + marital_status, + education_num, + occupation, + hours_per_week, + income_bracket, + CASE + WHEN MOD(functional_weight, 10) < 8 THEN 'training' + WHEN MOD(functional_weight, 10) = 8 THEN 'evaluation' + WHEN MOD(functional_weight, 10) = 9 THEN 'prediction' + END AS dataframe + FROM + `bigquery-public-data.ml_datasets.census_adult_income`; + """, + "CREATE_VIEW", + id="ddl-create-view", + ), + pytest.param( + """ + CREATE OR REPLACE TABLE `{dataset_id}.test_read_gbq_w_dml` ( + `col_a` INT64, + `col_b` STRING + ); + + INSERT INTO `{dataset_id}.test_read_gbq_w_dml` + VALUES (123, 'hello world'); + """, + "SCRIPT", + id="dml", + ), + ), +) +def test_read_gbq_w_script_no_select( + session, dataset_id: str, sql_template: str, expected_statement_type: str +): + df = session.read_gbq(sql_template.format(dataset_id=dataset_id)).to_pandas() + assert df["statement_type"][0] == expected_statement_type def test_read_gbq_twice_with_same_timestamp(session, penguins_table_id): From 5ce5d63fcb51bfb3df2769108b7486287896ccb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Mon, 15 Sep 2025 15:08:35 -0500 Subject: [PATCH 070/313] fix: do not scroll page selector in anywidget `repr_mode` (#2082) --- .pre-commit-config.yaml | 2 +- bigframes/display/table_widget.css | 3 +- notebooks/dataframes/anywidget_mode.ipynb | 48 +++++++++-------------- 3 files changed, 21 insertions(+), 32 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f839c3c0a4..90335cb8b9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: exclude: "^third_party" args: ["--check-untyped-defs", "--explicit-package-bases", "--ignore-missing-imports"] - repo: https://github.com/biomejs/pre-commit - rev: v2.0.2 + rev: v2.2.4 hooks: - id: biome-check files: '\.(js|css)$' diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css index 0c6c5fa5ef..9ae1e6fcf6 100644 --- a/bigframes/display/table_widget.css +++ b/bigframes/display/table_widget.css @@ -15,7 +15,8 @@ */ .bigframes-widget { - display: inline-block; + display: flex; + flex-direction: column; } .bigframes-widget .table-container { diff --git a/notebooks/dataframes/anywidget_mode.ipynb b/notebooks/dataframes/anywidget_mode.ipynb index 617329ba65..e5bfa88729 100644 --- a/notebooks/dataframes/anywidget_mode.ipynb +++ b/notebooks/dataframes/anywidget_mode.ipynb @@ -32,7 +32,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 1, "id": "ca22f059", "metadata": {}, "outputs": [], @@ -50,7 +50,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, "id": "1bc5aaf3", "metadata": {}, "outputs": [], @@ -69,22 +69,10 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 3, "id": "f289d250", "metadata": {}, "outputs": [ - { - "data": { - "text/html": [ - "Query job a643d120-4af9-44fc-ba3c-ed461cf1092b is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "name": "stdout", "output_type": "stream", @@ -108,7 +96,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 4, "id": "42bb02ab", "metadata": {}, "outputs": [ @@ -135,19 +123,19 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 5, "id": "ce250157", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "d2d4ef22ea9f414b89ea5bd85f0e6635", + "model_id": "a85f5799996d4de1a7912182c43fdf54", "version_major": 2, "version_minor": 1 }, "text/plain": [ - "TableWidget(page_size=10, row_count=5552452, table_html=' None: - self._registered_ops: dict[str, CompilationFunc] = {} - - def register( - self, op: ops.ScalarOp | type[ops.ScalarOp] - ) -> typing.Callable[[CompilationFunc], CompilationFunc]: - def decorator(item: CompilationFunc): - def arg_checker(*args, **kwargs): - if not isinstance(args[0], ops.ScalarOp): - raise ValueError( - f"The first parameter must be an operator. Got {type(args[0])}" - ) - return item(*args, **kwargs) - - key = typing.cast(str, op.name) - if key in self._registered_ops: - raise ValueError(f"{key} is already registered") - self._registered_ops[key] = item - return arg_checker - - return decorator - - def __getitem__(self, op: str | ops.ScalarOp) -> CompilationFunc: - if isinstance(op, ops.ScalarOp): - return self._registered_ops[op.name] - return self._registered_ops[op] diff --git a/bigframes/core/compile/sqlglot/expressions/ternary_compiler.py b/bigframes/core/compile/sqlglot/expressions/ternary_compiler.py deleted file mode 100644 index 9b00771f7d..0000000000 --- a/bigframes/core/compile/sqlglot/expressions/ternary_compiler.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import annotations - -import sqlglot.expressions as sge - -from bigframes import operations as ops -from bigframes.core.compile.sqlglot.expressions.op_registration import OpRegistration -from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr - -TERNATRY_OP_REGISTRATION = OpRegistration() - - -def compile( - op: ops.TernaryOp, expr1: TypedExpr, expr2: TypedExpr, expr3: TypedExpr -) -> sge.Expression: - return TERNATRY_OP_REGISTRATION[op](op, expr1, expr2, expr3) diff --git a/bigframes/core/compile/sqlglot/expressions/unary_compiler.py b/bigframes/core/compile/sqlglot/expressions/unary_compiler.py index f519aef70d..d93b1e681c 100644 --- a/bigframes/core/compile/sqlglot/expressions/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/expressions/unary_compiler.py @@ -25,24 +25,20 @@ from bigframes import operations as ops from bigframes.core.compile.constants import UNIT_TO_US_CONVERSION_FACTORS import bigframes.core.compile.sqlglot.expressions.constants as constants -from bigframes.core.compile.sqlglot.expressions.op_registration import OpRegistration from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler import bigframes.dtypes as dtypes -UNARY_OP_REGISTRATION = OpRegistration() +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op -def compile(op: ops.UnaryOp, expr: TypedExpr) -> sge.Expression: - return UNARY_OP_REGISTRATION[op](op, expr) - - -@UNARY_OP_REGISTRATION.register(ops.abs_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.abs_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Abs(this=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.arccosh_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.arccosh_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( @@ -54,8 +50,8 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.arccos_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.arccos_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( @@ -67,8 +63,8 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.arcsin_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.arcsin_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( @@ -80,18 +76,18 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.arcsinh_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.arcsinh_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("ASINH", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.arctan_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.arctan_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("ATAN", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.arctanh_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.arctanh_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( @@ -103,19 +99,19 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.AsTypeOp) -def _(op: ops.AsTypeOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.AsTypeOp, pass_op=True) +def _(expr: TypedExpr, op: ops.AsTypeOp) -> sge.Expression: # TODO: Support more types for casting, such as JSON, etc. return sge.Cast(this=expr.expr, to=op.to_type) -@UNARY_OP_REGISTRATION.register(ops.ArrayToStringOp) -def _(op: ops.ArrayToStringOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.ArrayToStringOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ArrayToStringOp) -> sge.Expression: return sge.ArrayToString(this=expr.expr, expression=f"'{op.delimiter}'") -@UNARY_OP_REGISTRATION.register(ops.ArrayIndexOp) -def _(op: ops.ArrayIndexOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.ArrayIndexOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ArrayIndexOp) -> sge.Expression: return sge.Bracket( this=expr.expr, expressions=[sge.Literal.number(op.index)], @@ -124,8 +120,8 @@ def _(op: ops.ArrayIndexOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.ArraySliceOp) -def _(op: ops.ArraySliceOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.ArraySliceOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ArraySliceOp) -> sge.Expression: slice_idx = sqlglot.to_identifier("slice_idx") conditions: typing.List[sge.Predicate] = [slice_idx >= op.start] @@ -151,23 +147,23 @@ def _(op: ops.ArraySliceOp, expr: TypedExpr) -> sge.Expression: return sge.array(selected_elements) -@UNARY_OP_REGISTRATION.register(ops.capitalize_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.capitalize_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Initcap(this=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.ceil_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.ceil_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Ceil(this=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.cos_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.cos_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("COS", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.cosh_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.cosh_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( @@ -179,25 +175,25 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.StrContainsOp) -def _(op: ops.StrContainsOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.StrContainsOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrContainsOp) -> sge.Expression: return sge.Like(this=expr.expr, expression=sge.convert(f"%{op.pat}%")) -@UNARY_OP_REGISTRATION.register(ops.StrContainsRegexOp) -def _(op: ops.StrContainsRegexOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.StrContainsRegexOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrContainsRegexOp) -> sge.Expression: return sge.RegexpLike(this=expr.expr, expression=sge.convert(op.pat)) -@UNARY_OP_REGISTRATION.register(ops.StrExtractOp) -def _(op: ops.StrExtractOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.StrExtractOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrExtractOp) -> sge.Expression: return sge.RegexpExtract( this=expr.expr, expression=sge.convert(op.pat), group=sge.convert(op.n) ) -@UNARY_OP_REGISTRATION.register(ops.StrFindOp) -def _(op: ops.StrFindOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.StrFindOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrFindOp) -> sge.Expression: # INSTR is 1-based, so we need to adjust the start position. start = sge.convert(op.start + 1) if op.start is not None else sge.convert(1) if op.end is not None: @@ -220,13 +216,13 @@ def _(op: ops.StrFindOp, expr: TypedExpr) -> sge.Expression: ) - sge.convert(1) -@UNARY_OP_REGISTRATION.register(ops.StrLstripOp) -def _(op: ops.StrLstripOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.StrLstripOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrLstripOp) -> sge.Expression: return sge.Trim(this=expr.expr, expression=sge.convert(op.to_strip), side="LEFT") -@UNARY_OP_REGISTRATION.register(ops.StrPadOp) -def _(op: ops.StrPadOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.StrPadOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrPadOp) -> sge.Expression: pad_length = sge.func( "GREATEST", sge.Length(this=expr.expr), sge.convert(op.length) ) @@ -266,36 +262,36 @@ def _(op: ops.StrPadOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.StrRepeatOp) -def _(op: ops.StrRepeatOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.StrRepeatOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrRepeatOp) -> sge.Expression: return sge.Repeat(this=expr.expr, times=sge.convert(op.repeats)) -@UNARY_OP_REGISTRATION.register(ops.date_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.date_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Date(this=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.day_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.day_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Extract(this=sge.Identifier(this="DAY"), expression=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.dayofweek_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.dayofweek_op) +def _(expr: TypedExpr) -> sge.Expression: # Adjust the 1-based day-of-week index (from SQL) to a 0-based index. return sge.Extract( this=sge.Identifier(this="DAYOFWEEK"), expression=expr.expr ) - sge.convert(1) -@UNARY_OP_REGISTRATION.register(ops.dayofyear_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.dayofyear_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Extract(this=sge.Identifier(this="DAYOFYEAR"), expression=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.EndsWithOp) -def _(op: ops.EndsWithOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.EndsWithOp, pass_op=True) +def _(expr: TypedExpr, op: ops.EndsWithOp) -> sge.Expression: if not op.pat: return sge.false() @@ -306,8 +302,8 @@ def to_endswith(pat: str) -> sge.Expression: return functools.reduce(lambda x, y: sge.Or(this=x, expression=y), conditions) -@UNARY_OP_REGISTRATION.register(ops.exp_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.exp_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( @@ -319,8 +315,8 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.expm1_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.expm1_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( @@ -332,34 +328,34 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ) - sge.convert(1) -@UNARY_OP_REGISTRATION.register(ops.FloorDtOp) -def _(op: ops.FloorDtOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.FloorDtOp, pass_op=True) +def _(expr: TypedExpr, op: ops.FloorDtOp) -> sge.Expression: # TODO: Remove this method when it is covered by ops.FloorOp return sge.TimestampTrunc(this=expr.expr, unit=sge.Identifier(this=op.freq)) -@UNARY_OP_REGISTRATION.register(ops.floor_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.floor_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Floor(this=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.geo_area_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.geo_area_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("ST_AREA", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.geo_st_astext_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.geo_st_astext_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("ST_ASTEXT", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.geo_st_boundary_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.geo_st_boundary_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("ST_BOUNDARY", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.GeoStBufferOp) -def _(op: ops.GeoStBufferOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.GeoStBufferOp, pass_op=True) +def _(expr: TypedExpr, op: ops.GeoStBufferOp) -> sge.Expression: return sge.func( "ST_BUFFER", expr.expr, @@ -369,58 +365,58 @@ def _(op: ops.GeoStBufferOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.geo_st_centroid_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.geo_st_centroid_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("ST_CENTROID", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.geo_st_convexhull_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.geo_st_convexhull_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("ST_CONVEXHULL", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.geo_st_geogfromtext_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.geo_st_geogfromtext_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("SAFE.ST_GEOGFROMTEXT", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.geo_st_isclosed_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.geo_st_isclosed_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("ST_ISCLOSED", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.GeoStLengthOp) -def _(op: ops.GeoStLengthOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.GeoStLengthOp, pass_op=True) +def _(expr: TypedExpr, op: ops.GeoStLengthOp) -> sge.Expression: return sge.func("ST_LENGTH", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.geo_x_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.geo_x_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("SAFE.ST_X", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.geo_y_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.geo_y_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("SAFE.ST_Y", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.hash_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.hash_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("FARM_FINGERPRINT", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.hour_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.hour_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Extract(this=sge.Identifier(this="HOUR"), expression=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.invert_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.invert_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.BitwiseNot(this=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.IsInOp) -def _(op: ops.IsInOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.IsInOp, pass_op=True) +def _(expr: TypedExpr, op: ops.IsInOp) -> sge.Expression: values = [] is_numeric_expr = dtypes.is_numeric(expr.dtype) for value in op.values: @@ -445,28 +441,28 @@ def _(op: ops.IsInOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.isalnum_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.isalnum_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^(\p{N}|\p{L})+$")) -@UNARY_OP_REGISTRATION.register(ops.isalpha_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.isalpha_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\p{L}+$")) -@UNARY_OP_REGISTRATION.register(ops.isdecimal_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.isdecimal_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\d+$")) -@UNARY_OP_REGISTRATION.register(ops.isdigit_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.isdigit_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\p{Nd}+$")) -@UNARY_OP_REGISTRATION.register(ops.islower_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.islower_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.And( this=sge.EQ( this=sge.Lower(this=expr.expr), @@ -479,38 +475,38 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.iso_day_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.iso_day_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Extract(this=sge.Identifier(this="DAYOFWEEK"), expression=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.iso_week_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.iso_week_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Extract(this=sge.Identifier(this="ISOWEEK"), expression=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.iso_year_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.iso_year_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Extract(this=sge.Identifier(this="ISOYEAR"), expression=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.isnull_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.isnull_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Is(this=expr.expr, expression=sge.Null()) -@UNARY_OP_REGISTRATION.register(ops.isnumeric_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.isnumeric_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\pN+$")) -@UNARY_OP_REGISTRATION.register(ops.isspace_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.isspace_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\s+$")) -@UNARY_OP_REGISTRATION.register(ops.isupper_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.isupper_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.And( this=sge.EQ( this=sge.Upper(this=expr.expr), @@ -523,13 +519,13 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.len_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.len_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Length(this=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.ln_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.ln_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( @@ -541,8 +537,8 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.log10_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.log10_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( @@ -554,8 +550,8 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.log1p_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.log1p_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( @@ -567,13 +563,13 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.lower_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.lower_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Lower(this=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.MapOp) -def _(op: ops.MapOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.MapOp, pass_op=True) +def _(expr: TypedExpr, op: ops.MapOp) -> sge.Expression: return sge.Case( this=expr.expr, ifs=[ @@ -583,80 +579,80 @@ def _(op: ops.MapOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.minute_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.minute_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Extract(this=sge.Identifier(this="MINUTE"), expression=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.month_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.month_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Extract(this=sge.Identifier(this="MONTH"), expression=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.neg_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.neg_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Neg(this=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.normalize_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.normalize_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.TimestampTrunc(this=expr.expr, unit=sge.Identifier(this="DAY")) -@UNARY_OP_REGISTRATION.register(ops.notnull_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.notnull_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Not(this=sge.Is(this=expr.expr, expression=sge.Null())) -@UNARY_OP_REGISTRATION.register(ops.obj_fetch_metadata_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.obj_fetch_metadata_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("OBJ.FETCH_METADATA", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.ObjGetAccessUrl) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.ObjGetAccessUrl) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("OBJ.GET_ACCESS_URL", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.pos_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.pos_op) +def _(expr: TypedExpr) -> sge.Expression: return expr.expr -@UNARY_OP_REGISTRATION.register(ops.quarter_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.quarter_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Extract(this=sge.Identifier(this="QUARTER"), expression=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.ReplaceStrOp) -def _(op: ops.ReplaceStrOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.ReplaceStrOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ReplaceStrOp) -> sge.Expression: return sge.func("REPLACE", expr.expr, sge.convert(op.pat), sge.convert(op.repl)) -@UNARY_OP_REGISTRATION.register(ops.RegexReplaceStrOp) -def _(op: ops.RegexReplaceStrOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.RegexReplaceStrOp, pass_op=True) +def _(expr: TypedExpr, op: ops.RegexReplaceStrOp) -> sge.Expression: return sge.func( "REGEXP_REPLACE", expr.expr, sge.convert(op.pat), sge.convert(op.repl) ) -@UNARY_OP_REGISTRATION.register(ops.reverse_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.reverse_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("REVERSE", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.second_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.second_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Extract(this=sge.Identifier(this="SECOND"), expression=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.StrRstripOp) -def _(op: ops.StrRstripOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.StrRstripOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrRstripOp) -> sge.Expression: return sge.Trim(this=expr.expr, expression=sge.convert(op.to_strip), side="RIGHT") -@UNARY_OP_REGISTRATION.register(ops.sqrt_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.sqrt_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( @@ -668,8 +664,8 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.StartsWithOp) -def _(op: ops.StartsWithOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.StartsWithOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StartsWithOp) -> sge.Expression: if not op.pat: return sge.false() @@ -680,18 +676,18 @@ def to_startswith(pat: str) -> sge.Expression: return functools.reduce(lambda x, y: sge.Or(this=x, expression=y), conditions) -@UNARY_OP_REGISTRATION.register(ops.StrStripOp) -def _(op: ops.StrStripOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.StrStripOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrStripOp) -> sge.Expression: return sge.Trim(this=sge.convert(op.to_strip), expression=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.sin_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.sin_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("SIN", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.sinh_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.sinh_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( @@ -703,13 +699,13 @@ def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.StringSplitOp) -def _(op: ops.StringSplitOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.StringSplitOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StringSplitOp) -> sge.Expression: return sge.Split(this=expr.expr, expression=sge.convert(op.pat)) -@UNARY_OP_REGISTRATION.register(ops.StrGetOp) -def _(op: ops.StrGetOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.StrGetOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrGetOp) -> sge.Expression: return sge.Substring( this=expr.expr, start=sge.convert(op.i + 1), @@ -717,8 +713,8 @@ def _(op: ops.StrGetOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.StrSliceOp) -def _(op: ops.StrSliceOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.StrSliceOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrSliceOp) -> sge.Expression: start = op.start + 1 if op.start is not None else None if op.end is None: length = None @@ -733,13 +729,13 @@ def _(op: ops.StrSliceOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.StrftimeOp) -def _(op: ops.StrftimeOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.StrftimeOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrftimeOp) -> sge.Expression: return sge.func("FORMAT_TIMESTAMP", sge.convert(op.date_format), expr.expr) -@UNARY_OP_REGISTRATION.register(ops.StructFieldOp) -def _(op: ops.StructFieldOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.StructFieldOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StructFieldOp) -> sge.Expression: if isinstance(op.name_or_index, str): name = op.name_or_index else: @@ -753,38 +749,38 @@ def _(op: ops.StructFieldOp, expr: TypedExpr) -> sge.Expression: ) -@UNARY_OP_REGISTRATION.register(ops.tan_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.tan_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("TAN", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.tanh_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.tanh_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("TANH", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.time_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.time_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("TIME", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.timedelta_floor_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.timedelta_floor_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Floor(this=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.ToDatetimeOp) -def _(op: ops.ToDatetimeOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.ToDatetimeOp) +def _(expr: TypedExpr) -> sge.Expression: return sge.Cast(this=sge.func("TIMESTAMP_SECONDS", expr.expr), to="DATETIME") -@UNARY_OP_REGISTRATION.register(ops.ToTimestampOp) -def _(op: ops.ToTimestampOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.ToTimestampOp) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("TIMESTAMP_SECONDS", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.ToTimedeltaOp) -def _(op: ops.ToTimedeltaOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.ToTimedeltaOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ToTimedeltaOp) -> sge.Expression: value = expr.expr factor = UNIT_TO_US_CONVERSION_FACTORS[op.unit] if factor != 1: @@ -792,78 +788,78 @@ def _(op: ops.ToTimedeltaOp, expr: TypedExpr) -> sge.Expression: return value -@UNARY_OP_REGISTRATION.register(ops.UnixMicros) -def _(op: ops.UnixMicros, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.UnixMicros) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("UNIX_MICROS", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.UnixMillis) -def _(op: ops.UnixMillis, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.UnixMillis) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("UNIX_MILLIS", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.UnixSeconds) -def _(op: ops.UnixSeconds, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.UnixSeconds, pass_op=True) +def _(expr: TypedExpr, op: ops.UnixSeconds) -> sge.Expression: return sge.func("UNIX_SECONDS", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.JSONExtract) -def _(op: ops.JSONExtract, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.JSONExtract, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONExtract) -> sge.Expression: return sge.func("JSON_EXTRACT", expr.expr, sge.convert(op.json_path)) -@UNARY_OP_REGISTRATION.register(ops.JSONExtractArray) -def _(op: ops.JSONExtractArray, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.JSONExtractArray, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONExtractArray) -> sge.Expression: return sge.func("JSON_EXTRACT_ARRAY", expr.expr, sge.convert(op.json_path)) -@UNARY_OP_REGISTRATION.register(ops.JSONExtractStringArray) -def _(op: ops.JSONExtractStringArray, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.JSONExtractStringArray, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONExtractStringArray) -> sge.Expression: return sge.func("JSON_EXTRACT_STRING_ARRAY", expr.expr, sge.convert(op.json_path)) -@UNARY_OP_REGISTRATION.register(ops.JSONQuery) -def _(op: ops.JSONQuery, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.JSONQuery, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONQuery) -> sge.Expression: return sge.func("JSON_QUERY", expr.expr, sge.convert(op.json_path)) -@UNARY_OP_REGISTRATION.register(ops.JSONQueryArray) -def _(op: ops.JSONQueryArray, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.JSONQueryArray, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONQueryArray) -> sge.Expression: return sge.func("JSON_QUERY_ARRAY", expr.expr, sge.convert(op.json_path)) -@UNARY_OP_REGISTRATION.register(ops.JSONValue) -def _(op: ops.JSONValue, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.JSONValue, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONValue) -> sge.Expression: return sge.func("JSON_VALUE", expr.expr, sge.convert(op.json_path)) -@UNARY_OP_REGISTRATION.register(ops.JSONValueArray) -def _(op: ops.JSONValueArray, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.JSONValueArray, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONValueArray) -> sge.Expression: return sge.func("JSON_VALUE_ARRAY", expr.expr, sge.convert(op.json_path)) -@UNARY_OP_REGISTRATION.register(ops.ParseJSON) -def _(op: ops.ParseJSON, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.ParseJSON) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("PARSE_JSON", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.ToJSONString) -def _(op: ops.ToJSONString, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.ToJSONString) +def _(expr: TypedExpr) -> sge.Expression: return sge.func("TO_JSON_STRING", expr.expr) -@UNARY_OP_REGISTRATION.register(ops.upper_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.upper_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Upper(this=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.year_op) -def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.year_op) +def _(expr: TypedExpr) -> sge.Expression: return sge.Extract(this=sge.Identifier(this="YEAR"), expression=expr.expr) -@UNARY_OP_REGISTRATION.register(ops.ZfillOp) -def _(op: ops.ZfillOp, expr: TypedExpr) -> sge.Expression: +@register_unary_op(ops.ZfillOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ZfillOp) -> sge.Expression: return sge.Case( ifs=[ sge.If( diff --git a/bigframes/core/compile/sqlglot/scalar_compiler.py b/bigframes/core/compile/sqlglot/scalar_compiler.py index 65c2501b71..3e12da6d92 100644 --- a/bigframes/core/compile/sqlglot/scalar_compiler.py +++ b/bigframes/core/compile/sqlglot/scalar_compiler.py @@ -14,60 +14,169 @@ from __future__ import annotations import functools +import typing import sqlglot.expressions as sge -from bigframes.core import expression -from bigframes.core.compile.sqlglot.expressions import ( - binary_compiler, - nary_compiler, - ternary_compiler, - typed_expr, - unary_compiler, -) +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr import bigframes.core.compile.sqlglot.sqlglot_ir as ir +import bigframes.core.expression as ex import bigframes.operations as ops -@functools.singledispatch -def compile_scalar_expression( - expr: expression.Expression, -) -> sge.Expression: - """Compiles BigFrames scalar expression into SQLGlot expression.""" - raise ValueError(f"Can't compile unrecognized node: {expression}") - - -@compile_scalar_expression.register -def compile_deref_expression(expr: expression.DerefOp) -> sge.Expression: - return sge.Column(this=sge.to_identifier(expr.id.sql, quoted=True)) - - -@compile_scalar_expression.register -def compile_constant_expression( - expr: expression.ScalarConstantExpression, -) -> sge.Expression: - return ir._literal(expr.value, expr.dtype) - - -@compile_scalar_expression.register -def compile_op_expression(expr: expression.OpExpression) -> sge.Expression: - # Non-recursively compiles the children scalar expressions. - args = tuple( - typed_expr.TypedExpr(compile_scalar_expression(input), input.output_type) - for input in expr.inputs - ) - - op = expr.op - if isinstance(op, ops.UnaryOp): - return unary_compiler.compile(op, args[0]) - elif isinstance(op, ops.BinaryOp): - return binary_compiler.compile(op, args[0], args[1]) - elif isinstance(op, ops.TernaryOp): - return ternary_compiler.compile(op, args[0], args[1], args[2]) - elif isinstance(op, ops.NaryOp): - return nary_compiler.compile(op, *args) - else: - raise TypeError( - f"Operator '{op.name}' has an unrecognized arity or type " - "and cannot be compiled." +class ScalarOpCompiler: + # Mapping of operation name to implemenations + _registry: dict[ + str, + typing.Callable[[typing.Sequence[TypedExpr], ops.RowOp], sge.Expression], + ] = {} + + @functools.singledispatchmethod + def compile_expression( + self, + expression: ex.Expression, + ) -> sge.Expression: + """Compiles BigFrames scalar expression into SQLGlot expression.""" + raise NotImplementedError(f"Unrecognized expression: {expression}") + + @compile_expression.register + def _(self, expr: ex.DerefOp) -> sge.Expression: + return sge.Column(this=sge.to_identifier(expr.id.sql, quoted=True)) + + @compile_expression.register + def _(self, expr: ex.ScalarConstantExpression) -> sge.Expression: + return ir._literal(expr.value, expr.dtype) + + @compile_expression.register + def _(self, expr: ex.OpExpression) -> sge.Expression: + # Non-recursively compiles the children scalar expressions. + inputs = tuple( + TypedExpr(self.compile_expression(sub_expr), sub_expr.output_type) + for sub_expr in expr.inputs ) + return self.compile_row_op(expr.op, inputs) + + def compile_row_op( + self, op: ops.RowOp, inputs: typing.Sequence[TypedExpr] + ) -> sge.Expression: + impl = self._registry[op.name] + return impl(inputs, op) + + def register_unary_op( + self, + op_ref: typing.Union[ops.UnaryOp, type[ops.UnaryOp]], + pass_op: bool = False, + ): + """ + Decorator to register a unary op implementation. + + Args: + op_ref (UnaryOp or UnaryOp type): + Class or instance of operator that is implemented by the decorated function. + pass_op (bool): + Set to true if implementation takes the operator object as the last argument. + This is needed for parameterized ops where parameters are part of op object. + """ + key = typing.cast(str, op_ref.name) + + def decorator(impl: typing.Callable[..., TypedExpr]): + def normalized_impl(args: typing.Sequence[TypedExpr], op: ops.RowOp): + if pass_op: + return impl(args[0], op) + else: + return impl(args[0]) + + self._register(key, normalized_impl) + return impl + + return decorator + + def register_binary_op( + self, + op_ref: typing.Union[ops.BinaryOp, type[ops.BinaryOp]], + pass_op: bool = False, + ): + """ + Decorator to register a binary op implementation. + + Args: + op_ref (BinaryOp or BinaryOp type): + Class or instance of operator that is implemented by the decorated function. + pass_op (bool): + Set to true if implementation takes the operator object as the last argument. + This is needed for parameterized ops where parameters are part of op object. + """ + key = typing.cast(str, op_ref.name) + + def decorator(impl: typing.Callable[..., TypedExpr]): + def normalized_impl(args: typing.Sequence[TypedExpr], op: ops.RowOp): + if pass_op: + return impl(args[0], args[1], op) + else: + return impl(args[0], args[1]) + + self._register(key, normalized_impl) + return impl + + return decorator + + def register_ternary_op( + self, op_ref: typing.Union[ops.TernaryOp, type[ops.TernaryOp]] + ): + """ + Decorator to register a ternary op implementation. + + Args: + op_ref (TernaryOp or TernaryOp type): + Class or instance of operator that is implemented by the decorated function. + """ + key = typing.cast(str, op_ref.name) + + def decorator(impl: typing.Callable[..., TypedExpr]): + def normalized_impl(args: typing.Sequence[TypedExpr], op: ops.RowOp): + return impl(args[0], args[1], args[2]) + + self._register(key, normalized_impl) + return impl + + return decorator + + def register_nary_op( + self, op_ref: typing.Union[ops.NaryOp, type[ops.NaryOp]], pass_op: bool = False + ): + """ + Decorator to register a nary op implementation. + + Args: + op_ref (NaryOp or NaryOp type): + Class or instance of operator that is implemented by the decorated function. + pass_op (bool): + Set to true if implementation takes the operator object as the last argument. + This is needed for parameterized ops where parameters are part of op object. + """ + key = typing.cast(str, op_ref.name) + + def decorator(impl: typing.Callable[..., TypedExpr]): + def normalized_impl(args: typing.Sequence[TypedExpr], op: ops.RowOp): + if pass_op: + return impl(*args, op=op) + else: + return impl(*args) + + self._register(key, normalized_impl) + return impl + + return decorator + + def _register( + self, + op_name: str, + impl: typing.Callable[[typing.Sequence[TypedExpr], ops.RowOp], sge.Expression], + ): + if op_name in self._registry: + raise ValueError(f"Operation name {op_name} already registered") + self._registry[op_name] = impl + + +# Singleton compiler +scalar_op_compiler = ScalarOpCompiler() diff --git a/tests/unit/core/compile/sqlglot/expressions/test_op_registration.py b/tests/unit/core/compile/sqlglot/expressions/test_op_registration.py deleted file mode 100644 index 1c49dde6ca..0000000000 --- a/tests/unit/core/compile/sqlglot/expressions/test_op_registration.py +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import pytest -from sqlglot import expressions as sge - -from bigframes.core.compile.sqlglot.expressions import op_registration -from bigframes.operations import numeric_ops - - -def test_register_then_get(): - reg = op_registration.OpRegistration() - input = sge.to_identifier("A") - op = numeric_ops.add_op - - @reg.register(numeric_ops.AddOp) - def test_func(op: numeric_ops.AddOp, input: sge.Expression) -> sge.Expression: - return input - - assert reg[numeric_ops.add_op](op, input) == test_func(op, input) - assert reg[numeric_ops.add_op.name](op, input) == test_func(op, input) - - -def test_register_function_first_argument_is_not_scalar_op_raise_error(): - reg = op_registration.OpRegistration() - - @reg.register(numeric_ops.AddOp) - def test_func(input: sge.Expression) -> sge.Expression: - return input - - with pytest.raises(ValueError, match=r".*first parameter must be an operator.*"): - test_func(sge.to_identifier("A")) diff --git a/tests/unit/core/compile/sqlglot/test_scalar_compiler.py b/tests/unit/core/compile/sqlglot/test_scalar_compiler.py new file mode 100644 index 0000000000..a2ee2c6331 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/test_scalar_compiler.py @@ -0,0 +1,189 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest.mock as mock + +import pytest +import sqlglot.expressions as sge + +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler +import bigframes.operations as ops + + +def test_register_unary_op(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockUnaryOp(ops.UnaryOp): + name = "mock_unary_op" + + mock_op = MockUnaryOp() + mock_impl = mock.Mock() + + @compiler.register_unary_op(mock_op) + def _(expr: TypedExpr) -> sge.Expression: + mock_impl(expr) + return sge.Identifier(this="output") + + arg = TypedExpr(sge.Identifier(this="input"), "string") + result = compiler.compile_row_op(mock_op, [arg]) + assert result == sge.Identifier(this="output") + mock_impl.assert_called_once_with(arg) + + +def test_register_unary_op_pass_op(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockUnaryOp(ops.UnaryOp): + name = "mock_unary_op_pass_op" + + mock_op = MockUnaryOp() + mock_impl = mock.Mock() + + @compiler.register_unary_op(mock_op, pass_op=True) + def _(expr: TypedExpr, op: ops.UnaryOp) -> sge.Expression: + mock_impl(expr, op) + return sge.Identifier(this="output") + + arg = TypedExpr(sge.Identifier(this="input"), "string") + result = compiler.compile_row_op(mock_op, [arg]) + assert result == sge.Identifier(this="output") + mock_impl.assert_called_once_with(arg, mock_op) + + +def test_register_binary_op(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockBinaryOp(ops.BinaryOp): + name = "mock_binary_op" + + mock_op = MockBinaryOp() + mock_impl = mock.Mock() + + @compiler.register_binary_op(mock_op) + def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + mock_impl(left, right) + return sge.Identifier(this="output") + + arg1 = TypedExpr(sge.Identifier(this="input1"), "string") + arg2 = TypedExpr(sge.Identifier(this="input2"), "string") + result = compiler.compile_row_op(mock_op, [arg1, arg2]) + assert result == sge.Identifier(this="output") + mock_impl.assert_called_once_with(arg1, arg2) + + +def test_register_binary_op_pass_on(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockBinaryOp(ops.BinaryOp): + name = "mock_binary_op_pass_op" + + mock_op = MockBinaryOp() + mock_impl = mock.Mock() + + @compiler.register_binary_op(mock_op, pass_op=True) + def _(left: TypedExpr, right: TypedExpr, op: ops.BinaryOp) -> sge.Expression: + mock_impl(left, right, op) + return sge.Identifier(this="output") + + arg1 = TypedExpr(sge.Identifier(this="input1"), "string") + arg2 = TypedExpr(sge.Identifier(this="input2"), "string") + result = compiler.compile_row_op(mock_op, [arg1, arg2]) + assert result == sge.Identifier(this="output") + mock_impl.assert_called_once_with(arg1, arg2, mock_op) + + +def test_register_ternary_op(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockTernaryOp(ops.TernaryOp): + name = "mock_ternary_op" + + mock_op = MockTernaryOp() + mock_impl = mock.Mock() + + @compiler.register_ternary_op(mock_op) + def _(arg1: TypedExpr, arg2: TypedExpr, arg3: TypedExpr) -> sge.Expression: + mock_impl(arg1, arg2, arg3) + return sge.Identifier(this="output") + + arg1 = TypedExpr(sge.Identifier(this="input1"), "string") + arg2 = TypedExpr(sge.Identifier(this="input2"), "string") + arg3 = TypedExpr(sge.Identifier(this="input3"), "string") + result = compiler.compile_row_op(mock_op, [arg1, arg2, arg3]) + assert result == sge.Identifier(this="output") + mock_impl.assert_called_once_with(arg1, arg2, arg3) + + +def test_register_nary_op(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockNaryOp(ops.NaryOp): + name = "mock_nary_op" + + mock_op = MockNaryOp() + mock_impl = mock.Mock() + + @compiler.register_nary_op(mock_op) + def _(*args: TypedExpr) -> sge.Expression: + mock_impl(*args) + return sge.Identifier(this="output") + + arg1 = TypedExpr(sge.Identifier(this="input1"), "string") + arg2 = TypedExpr(sge.Identifier(this="input2"), "string") + result = compiler.compile_row_op(mock_op, [arg1, arg2]) + assert result == sge.Identifier(this="output") + mock_impl.assert_called_once_with(arg1, arg2) + + +def test_register_nary_op_pass_on(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockNaryOp(ops.NaryOp): + name = "mock_nary_op_pass_op" + + mock_op = MockNaryOp() + mock_impl = mock.Mock() + + @compiler.register_nary_op(mock_op, pass_op=True) + def _(*args: TypedExpr, op: ops.NaryOp) -> sge.Expression: + mock_impl(*args, op=op) + return sge.Identifier(this="output") + + arg1 = TypedExpr(sge.Identifier(this="input1"), "string") + arg2 = TypedExpr(sge.Identifier(this="input2"), "string") + arg3 = TypedExpr(sge.Identifier(this="input3"), "string") + arg4 = TypedExpr(sge.Identifier(this="input4"), "string") + result = compiler.compile_row_op(mock_op, [arg1, arg2, arg3, arg4]) + assert result == sge.Identifier(this="output") + mock_impl.assert_called_once_with(arg1, arg2, arg3, arg4, op=mock_op) + + +def test_register_duplicate_op_raises(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockUnaryOp(ops.UnaryOp): + name = "mock_unary_op_duplicate" + + mock_op = MockUnaryOp() + + @compiler.register_unary_op(mock_op) + def _(expr: TypedExpr) -> sge.Expression: + return sge.Identifier(this="output") + + with pytest.raises(ValueError): + + @compiler.register_unary_op(mock_op) + def _(expr: TypedExpr) -> sge.Expression: + return sge.Identifier(this="output2") From c1e871d9327bf6c920d17e1476fed3088d506f5f Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Mon, 15 Sep 2025 17:36:08 -0700 Subject: [PATCH 072/313] feat: Add rank(pct=True) support (#2084) --- bigframes/core/block_transforms.py | 7 ++ bigframes/core/groupby/dataframe_group_by.py | 7 +- bigframes/core/groupby/series_group_by.py | 7 +- bigframes/dataframe.py | 5 +- bigframes/series.py | 5 +- tests/system/small/test_dataframe.py | 15 ++-- tests/system/small/test_groupby.py | 73 +++++-------------- tests/system/small/test_series.py | 44 ++++++++++- .../bigframes_vendored/pandas/core/generic.py | 4 + .../pandas/core/groupby/__init__.py | 2 + 10 files changed, 100 insertions(+), 69 deletions(-) diff --git a/bigframes/core/block_transforms.py b/bigframes/core/block_transforms.py index 279643b91d..2ee3dc38b3 100644 --- a/bigframes/core/block_transforms.py +++ b/bigframes/core/block_transforms.py @@ -417,6 +417,7 @@ def rank( ascending: bool = True, grouping_cols: tuple[str, ...] = (), columns: tuple[str, ...] = (), + pct: bool = False, ): if method not in ["average", "min", "max", "first", "dense"]: raise ValueError( @@ -459,6 +460,12 @@ def rank( ), skip_reproject_unsafe=(col != columns[-1]), ) + if pct: + block, max_id = block.apply_window_op( + rownum_id, agg_ops.max_op, windows.unbound(grouping_keys=grouping_cols) + ) + block, rownum_id = block.project_expr(ops.div_op.as_expr(rownum_id, max_id)) + rownum_col_ids.append(rownum_id) # Step 2: Apply aggregate to groups of like input values. diff --git a/bigframes/core/groupby/dataframe_group_by.py b/bigframes/core/groupby/dataframe_group_by.py index 7d3d3ada69..21f49fe563 100644 --- a/bigframes/core/groupby/dataframe_group_by.py +++ b/bigframes/core/groupby/dataframe_group_by.py @@ -181,7 +181,11 @@ def median(self, numeric_only: bool = False, *, exact: bool = True) -> df.DataFr return self._aggregate_all(agg_ops.median_op, numeric_only=True) def rank( - self, method="average", ascending: bool = True, na_option: str = "keep" + self, + method="average", + ascending: bool = True, + na_option: str = "keep", + pct: bool = False, ) -> df.DataFrame: return df.DataFrame( block_ops.rank( @@ -191,6 +195,7 @@ def rank( ascending, grouping_cols=tuple(self._by_col_ids), columns=tuple(self._selected_cols), + pct=pct, ) ) diff --git a/bigframes/core/groupby/series_group_by.py b/bigframes/core/groupby/series_group_by.py index 041cc1b3dd..8ab39d27cc 100644 --- a/bigframes/core/groupby/series_group_by.py +++ b/bigframes/core/groupby/series_group_by.py @@ -100,7 +100,11 @@ def mean(self, *args) -> series.Series: return self._aggregate(agg_ops.mean_op) def rank( - self, method="average", ascending: bool = True, na_option: str = "keep" + self, + method="average", + ascending: bool = True, + na_option: str = "keep", + pct: bool = False, ) -> series.Series: return series.Series( block_ops.rank( @@ -110,6 +114,7 @@ def rank( ascending, grouping_cols=tuple(self._by_col_ids), columns=(self._value_column,), + pct=pct, ) ) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index ff730be4a8..371f69e713 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -4990,9 +4990,12 @@ def rank( numeric_only=False, na_option: str = "keep", ascending=True, + pct: bool = False, ) -> DataFrame: df = self._drop_non_numeric() if numeric_only else self - return DataFrame(block_ops.rank(df._block, method, na_option, ascending)) + return DataFrame( + block_ops.rank(df._block, method, na_option, ascending, pct=pct) + ) def first_valid_index(self): return diff --git a/bigframes/series.py b/bigframes/series.py index e44cf417ab..da2f3f07c4 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -851,8 +851,11 @@ def rank( numeric_only=False, na_option: str = "keep", ascending: bool = True, + pct: bool = False, ) -> Series: - return Series(block_ops.rank(self._block, method, na_option, ascending)) + return Series( + block_ops.rank(self._block, method, na_option, ascending, pct=pct) + ) def fillna(self, value=None) -> Series: return self._apply_binary_op(value, ops.fillna_op) diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 95aec9906f..bad90d0562 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -5442,13 +5442,13 @@ def test_df_value_counts(scalars_dfs, subset, normalize, ascending, dropna): @pytest.mark.parametrize( - ("na_option", "method", "ascending", "numeric_only"), + ("na_option", "method", "ascending", "numeric_only", "pct"), [ - ("keep", "average", True, True), - ("top", "min", False, False), - ("bottom", "max", False, False), - ("top", "first", False, False), - ("bottom", "dense", False, False), + ("keep", "average", True, True, True), + ("top", "min", False, False, False), + ("bottom", "max", False, False, True), + ("top", "first", False, False, False), + ("bottom", "dense", False, False, True), ], ) def test_df_rank_with_nulls( @@ -5458,6 +5458,7 @@ def test_df_rank_with_nulls( method, ascending, numeric_only, + pct, ): unsupported_columns = ["geography_col"] bf_result = ( @@ -5467,6 +5468,7 @@ def test_df_rank_with_nulls( method=method, ascending=ascending, numeric_only=numeric_only, + pct=pct, ) .to_pandas() ) @@ -5477,6 +5479,7 @@ def test_df_rank_with_nulls( method=method, ascending=ascending, numeric_only=numeric_only, + pct=pct, ) .astype(pd.Float64Dtype()) ) diff --git a/tests/system/small/test_groupby.py b/tests/system/small/test_groupby.py index dba8d46676..553a12a14a 100644 --- a/tests/system/small/test_groupby.py +++ b/tests/system/small/test_groupby.py @@ -96,41 +96,22 @@ def test_dataframe_groupby_quantile(scalars_df_index, scalars_pandas_df_index, q @pytest.mark.parametrize( - ("na_option", "method", "ascending"), + ("na_option", "method", "ascending", "pct"), [ ( "keep", "average", True, - ), - ( - "top", - "min", - False, - ), - ( - "bottom", - "max", - False, - ), - ( - "top", - "first", - False, - ), - ( - "bottom", - "dense", False, ), + ("top", "min", False, False), + ("bottom", "max", False, False), + ("top", "first", False, True), + ("bottom", "dense", False, True), ], ) def test_dataframe_groupby_rank( - scalars_df_index, - scalars_pandas_df_index, - na_option, - method, - ascending, + scalars_df_index, scalars_pandas_df_index, na_option, method, ascending, pct ): # TODO: supply a reason why this isn't compatible with pandas 1.x pytest.importorskip("pandas", minversion="2.0.0") @@ -138,21 +119,13 @@ def test_dataframe_groupby_rank( bf_result = ( scalars_df_index[col_names] .groupby("string_col") - .rank( - na_option=na_option, - method=method, - ascending=ascending, - ) + .rank(na_option=na_option, method=method, ascending=ascending, pct=pct) ).to_pandas() pd_result = ( ( scalars_pandas_df_index[col_names] .groupby("string_col") - .rank( - na_option=na_option, - method=method, - ascending=ascending, - ) + .rank(na_option=na_option, method=method, ascending=ascending, pct=pct) ) .astype("float64") .astype("Float64") @@ -737,41 +710,37 @@ def test_series_groupby_agg_list(scalars_df_index, scalars_pandas_df_index): @pytest.mark.parametrize( - ("na_option", "method", "ascending"), + ("na_option", "method", "ascending", "pct"), [ - ( - "keep", - "average", - True, - ), + ("keep", "average", True, False), ( "top", "min", False, + True, ), ( "bottom", "max", False, + True, ), ( "top", "first", False, + True, ), ( "bottom", "dense", False, + False, ), ], ) def test_series_groupby_rank( - scalars_df_index, - scalars_pandas_df_index, - na_option, - method, - ascending, + scalars_df_index, scalars_pandas_df_index, na_option, method, ascending, pct ): # TODO: supply a reason why this isn't compatible with pandas 1.x pytest.importorskip("pandas", minversion="2.0.0") @@ -779,21 +748,13 @@ def test_series_groupby_rank( bf_result = ( scalars_df_index[col_names] .groupby("string_col")["int64_col"] - .rank( - na_option=na_option, - method=method, - ascending=ascending, - ) + .rank(na_option=na_option, method=method, ascending=ascending, pct=pct) ).to_pandas() pd_result = ( ( scalars_pandas_df_index[col_names] .groupby("string_col")["int64_col"] - .rank( - na_option=na_option, - method=method, - ascending=ascending, - ) + .rank(na_option=na_option, method=method, ascending=ascending, pct=pct) ) .astype("float64") .astype("Float64") diff --git a/tests/system/small/test_series.py b/tests/system/small/test_series.py index ca08f8dece..0a761a3a3a 100644 --- a/tests/system/small/test_series.py +++ b/tests/system/small/test_series.py @@ -2704,10 +2704,48 @@ def test_series_nsmallest(scalars_df_index, scalars_pandas_df_index, keep): ) -def test_rank_ints(scalars_df_index, scalars_pandas_df_index): +@pytest.mark.parametrize( + ("na_option", "method", "ascending", "numeric_only", "pct"), + [ + ("keep", "average", True, True, False), + ("top", "min", False, False, True), + ("bottom", "max", False, False, False), + ("top", "first", False, False, True), + ("bottom", "dense", False, False, False), + ], +) +def test_series_rank( + scalars_df_index, + scalars_pandas_df_index, + na_option, + method, + ascending, + numeric_only, + pct, +): col_name = "int64_too" - bf_result = scalars_df_index[col_name].rank().to_pandas() - pd_result = scalars_pandas_df_index[col_name].rank().astype(pd.Float64Dtype()) + bf_result = ( + scalars_df_index[col_name] + .rank( + na_option=na_option, + method=method, + ascending=ascending, + numeric_only=numeric_only, + pct=pct, + ) + .to_pandas() + ) + pd_result = ( + scalars_pandas_df_index[col_name] + .rank( + na_option=na_option, + method=method, + ascending=ascending, + numeric_only=numeric_only, + pct=pct, + ) + .astype(pd.Float64Dtype()) + ) pd.testing.assert_series_equal( bf_result, diff --git a/third_party/bigframes_vendored/pandas/core/generic.py b/third_party/bigframes_vendored/pandas/core/generic.py index 4c9d1338f4..48f33c67fd 100644 --- a/third_party/bigframes_vendored/pandas/core/generic.py +++ b/third_party/bigframes_vendored/pandas/core/generic.py @@ -1042,6 +1042,10 @@ def rank( ascending (bool, default True): Whether or not the elements should be ranked in ascending order. + pct (bool, default False): + Whether or not to display the returned rankings in percentile + form. + Returns: bigframes.pandas.DataFrame or bigframes.pandas.Series: Return a Series or DataFrame with data ranks as values. diff --git a/third_party/bigframes_vendored/pandas/core/groupby/__init__.py b/third_party/bigframes_vendored/pandas/core/groupby/__init__.py index f0bc6348f8..b6b91388e3 100644 --- a/third_party/bigframes_vendored/pandas/core/groupby/__init__.py +++ b/third_party/bigframes_vendored/pandas/core/groupby/__init__.py @@ -428,6 +428,8 @@ def rank( * keep: leave NA values where they are. * top: smallest rank if ascending. * bottom: smallest rank if descending. + pct (bool, default False): + Compute percentage rank of data within each group Returns: DataFrame with ranking of values within each group From 566a37a30ad5677aef0c5f79bdd46bca2139cc1e Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Mon, 15 Sep 2025 17:36:30 -0700 Subject: [PATCH 073/313] fix: deflake ai_gen_bool multimodel test (#2085) * fix: deflake ai_gen_bool multimodel test * fix lint * fix doctest too * consolidates tests under system/small * fix doctest --- bigframes/bigquery/_operations/ai.py | 21 +-------- tests/system/large/bigquery/__init__.py | 13 ------ tests/system/large/bigquery/test_ai.py | 35 --------------- tests/system/small/bigquery/test_ai.py | 60 ++++++++++++++++++------- 4 files changed, 47 insertions(+), 82 deletions(-) delete mode 100644 tests/system/large/bigquery/__init__.py delete mode 100644 tests/system/large/bigquery/test_ai.py diff --git a/bigframes/bigquery/_operations/ai.py b/bigframes/bigquery/_operations/ai.py index d7ea29322d..d82023e4b5 100644 --- a/bigframes/bigquery/_operations/ai.py +++ b/bigframes/bigquery/_operations/ai.py @@ -47,30 +47,13 @@ def generate_bool( ... "col_1": ["apple", "bear", "pear"], ... "col_2": ["fruit", "animal", "animal"] ... }) - >>> bbq.ai_generate_bool((df["col_1"], " is a ", df["col_2"])) + >>> bbq.ai.generate_bool((df["col_1"], " is a ", df["col_2"])) 0 {'result': True, 'full_response': '{"candidate... 1 {'result': True, 'full_response': '{"candidate... 2 {'result': False, 'full_response': '{"candidat... dtype: struct[pyarrow] - >>> bbq.ai_generate_bool((df["col_1"], " is a ", df["col_2"])).struct.field("result") - 0 True - 1 True - 2 False - Name: result, dtype: boolean - - >>> model_params = { - ... "generation_config": { - ... "thinking_config": { - ... "thinking_budget": 0 - ... } - ... } - ... } - >>> bbq.ai_generate_bool( - ... (df["col_1"], " is a ", df["col_2"]), - ... endpoint="gemini-2.5-pro", - ... model_params=model_params, - ... ).struct.field("result") + >>> bbq.ai.generate_bool((df["col_1"], " is a ", df["col_2"])).struct.field("result") 0 True 1 True 2 False diff --git a/tests/system/large/bigquery/__init__.py b/tests/system/large/bigquery/__init__.py deleted file mode 100644 index 0a2669d7a2..0000000000 --- a/tests/system/large/bigquery/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. diff --git a/tests/system/large/bigquery/test_ai.py b/tests/system/large/bigquery/test_ai.py deleted file mode 100644 index be0216a526..0000000000 --- a/tests/system/large/bigquery/test_ai.py +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import pandas as pd -import pandas.testing - -import bigframes.bigquery as bbq - - -def test_ai_generate_bool_multi_model(session): - df = session.from_glob_path( - "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" - ) - - result = bbq.ai.generate_bool((df["image"], " contains an animal")).struct.field( - "result" - ) - - pandas.testing.assert_series_equal( - result.to_pandas(), - pd.Series([True, True, False, False, False], name="result"), - check_dtype=False, - check_index=False, - ) diff --git a/tests/system/small/bigquery/test_ai.py b/tests/system/small/bigquery/test_ai.py index 01050ade04..443d4c54a3 100644 --- a/tests/system/small/bigquery/test_ai.py +++ b/tests/system/small/bigquery/test_ai.py @@ -15,9 +15,10 @@ import sys import pandas as pd -import pandas.testing +import pyarrow as pa import pytest +from bigframes import series import bigframes.bigquery as bbq import bigframes.pandas as bpd @@ -27,15 +28,17 @@ def test_ai_generate_bool(session): s2 = bpd.Series(["fruit", "tree"], session=session) prompt = (s1, " is a ", s2) - result = bbq.ai.generate_bool(prompt, endpoint="gemini-2.5-flash").struct.field( - "result" - ) + result = bbq.ai.generate_bool(prompt, endpoint="gemini-2.5-flash") - pandas.testing.assert_series_equal( - result.to_pandas(), - pd.Series([True, False], name="result"), - check_dtype=False, - check_index=False, + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.bool_()), + pa.field("full_response", pa.string()), + pa.field("status", pa.string()), + ) + ) ) @@ -52,11 +55,38 @@ def test_ai_generate_bool_with_model_params(session): result = bbq.ai.generate_bool( prompt, endpoint="gemini-2.5-flash", model_params=model_params - ).struct.field("result") + ) + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.bool_()), + pa.field("full_response", pa.string()), + pa.field("status", pa.string()), + ) + ) + ) + - pandas.testing.assert_series_equal( - result.to_pandas(), - pd.Series([True, False], name="result"), - check_dtype=False, - check_index=False, +def test_ai_generate_bool_multi_model(session): + df = session.from_glob_path( + "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" ) + + result = bbq.ai.generate_bool((df["image"], " contains an animal")) + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.bool_()), + pa.field("full_response", pa.string()), + pa.field("status", pa.string()), + ) + ) + ) + + +def _contains_no_nulls(s: series.Series) -> bool: + return len(s) == s.count() From 2821fd0d947955226363b716346fa9783de5e745 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Tue, 16 Sep 2025 09:24:20 -0500 Subject: [PATCH 074/313] chore(main): release 2.20.0 (#2061) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 31 +++++++++++++++++++++++ bigframes/version.py | 4 +-- third_party/bigframes_vendored/version.py | 4 +-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdd060f1f3..a67f6f8b86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ [1]: https://pypi.org/project/bigframes/#history +## [2.20.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.19.0...v2.20.0) (2025-09-16) + + +### Features + +* Add `__dataframe__` interchange support ([#2063](https://github.com/googleapis/python-bigquery-dataframes/issues/2063)) ([3b46a0d](https://github.com/googleapis/python-bigquery-dataframes/commit/3b46a0d91eb379c61ced45ae0b25339281326c3d)) +* Add ai_generate_bool to the bigframes.bigquery package ([#2060](https://github.com/googleapis/python-bigquery-dataframes/issues/2060)) ([70d6562](https://github.com/googleapis/python-bigquery-dataframes/commit/70d6562df64b2aef4ff0024df6f57702d52dcaf8)) +* Add bigframes.bigquery.to_json_string ([#2076](https://github.com/googleapis/python-bigquery-dataframes/issues/2076)) ([41e8f33](https://github.com/googleapis/python-bigquery-dataframes/commit/41e8f33ceb46a7c2a75d1c59a4a3f2f9413d281d)) +* Add rank(pct=True) support ([#2084](https://github.com/googleapis/python-bigquery-dataframes/issues/2084)) ([c1e871d](https://github.com/googleapis/python-bigquery-dataframes/commit/c1e871d9327bf6c920d17e1476fed3088d506f5f)) +* Add StreamingDataFrame.to_bigtable and .to_pubsub start_timestamp parameter ([#2066](https://github.com/googleapis/python-bigquery-dataframes/issues/2066)) ([a63cbae](https://github.com/googleapis/python-bigquery-dataframes/commit/a63cbae24ff2dc191f0a53dced885bc95f38ec96)) +* Can call agg with some callables ([#2055](https://github.com/googleapis/python-bigquery-dataframes/issues/2055)) ([17a1ed9](https://github.com/googleapis/python-bigquery-dataframes/commit/17a1ed99ec8c6d3215d3431848814d5d458d4ff1)) +* Support astype to json ([#2073](https://github.com/googleapis/python-bigquery-dataframes/issues/2073)) ([6bd6738](https://github.com/googleapis/python-bigquery-dataframes/commit/6bd67386341de7a92ada948381702430c399406e)) +* Support pandas.Index as key for DataFrame.__setitem__() ([#2062](https://github.com/googleapis/python-bigquery-dataframes/issues/2062)) ([b3cf824](https://github.com/googleapis/python-bigquery-dataframes/commit/b3cf8248e3b8ea76637ded64fb12028d439448d1)) +* Support pd.cut() for array-like type ([#2064](https://github.com/googleapis/python-bigquery-dataframes/issues/2064)) ([21eb213](https://github.com/googleapis/python-bigquery-dataframes/commit/21eb213c5f0e0f696f2d1ca1f1263678d791cf7c)) +* Support to cast struct to json ([#2067](https://github.com/googleapis/python-bigquery-dataframes/issues/2067)) ([b0ff718](https://github.com/googleapis/python-bigquery-dataframes/commit/b0ff718a04fadda33cfa3613b1d02822cde34bc2)) + + +### Bug Fixes + +* Deflake ai_gen_bool multimodel test ([#2085](https://github.com/googleapis/python-bigquery-dataframes/issues/2085)) ([566a37a](https://github.com/googleapis/python-bigquery-dataframes/commit/566a37a30ad5677aef0c5f79bdd46bca2139cc1e)) +* Do not scroll page selector in anywidget `repr_mode` ([#2082](https://github.com/googleapis/python-bigquery-dataframes/issues/2082)) ([5ce5d63](https://github.com/googleapis/python-bigquery-dataframes/commit/5ce5d63fcb51bfb3df2769108b7486287896ccb9)) +* Fix the potential invalid VPC egress configuration ([#2068](https://github.com/googleapis/python-bigquery-dataframes/issues/2068)) ([cce4966](https://github.com/googleapis/python-bigquery-dataframes/commit/cce496605385f2ac7ab0becc0773800ed5901aa5)) +* Return a DataFrame containing query stats for all non-SELECT statements ([#2071](https://github.com/googleapis/python-bigquery-dataframes/issues/2071)) ([a52b913](https://github.com/googleapis/python-bigquery-dataframes/commit/a52b913d9d8794b4b959ea54744a38d9f2f174e7)) +* Use the remote and managed functions for bigframes results ([#2079](https://github.com/googleapis/python-bigquery-dataframes/issues/2079)) ([49b91e8](https://github.com/googleapis/python-bigquery-dataframes/commit/49b91e878de651de23649756259ee35709e3f5a8)) + + +### Performance Improvements + +* Avoid re-authenticating if credentials have already been fetched ([#2058](https://github.com/googleapis/python-bigquery-dataframes/issues/2058)) ([913de1b](https://github.com/googleapis/python-bigquery-dataframes/commit/913de1b31f3bb0b306846fddae5dcaff6be3cec4)) +* Improve apply axis=1 performance ([#2077](https://github.com/googleapis/python-bigquery-dataframes/issues/2077)) ([12e4380](https://github.com/googleapis/python-bigquery-dataframes/commit/12e438051134577e911c1a6ce9d5a5885a0b45ad)) + ## [2.19.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.18.0...v2.19.0) (2025-09-09) diff --git a/bigframes/version.py b/bigframes/version.py index 558f26d68e..9d5d4361c0 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.19.0" +__version__ = "2.20.0" # {x-release-please-start-date} -__release_date__ = "2025-09-09" +__release_date__ = "2025-09-16" # {x-release-please-end} diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index 558f26d68e..9d5d4361c0 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.19.0" +__version__ = "2.20.0" # {x-release-please-start-date} -__release_date__ = "2025-09-09" +__release_date__ = "2025-09-16" # {x-release-please-end} From 090ce8e25da08919c4973df7d95a8f12de84c533 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Tue, 16 Sep 2025 11:22:22 -0700 Subject: [PATCH 075/313] refactor: Define window column expression type (#2081) --- bigframes/core/agg_expressions.py | 67 ++++- bigframes/core/blocks.py | 2 +- bigframes/core/compile/compiled.py | 248 +++--------------- .../ibis_compiler/aggregate_compiler.py | 113 +++++++- .../ibis_compiler/scalar_op_compiler.py | 66 ++++- bigframes/core/compile/polars/compiler.py | 5 +- bigframes/core/ordering.py | 11 +- bigframes/core/window/rolling.py | 4 +- bigframes/core/window_spec.py | 23 +- .../ibis/expr/types/generic.py | 4 +- 10 files changed, 324 insertions(+), 219 deletions(-) diff --git a/bigframes/core/agg_expressions.py b/bigframes/core/agg_expressions.py index f77525706b..e65718bdc4 100644 --- a/bigframes/core/agg_expressions.py +++ b/bigframes/core/agg_expressions.py @@ -22,7 +22,7 @@ from typing import Callable, Mapping, TypeVar from bigframes import dtypes -from bigframes.core import expression +from bigframes.core import expression, window_spec import bigframes.core.identifiers as ids import bigframes.operations.aggregations as agg_ops @@ -149,3 +149,68 @@ def replace_args( self, larg: expression.Expression, rarg: expression.Expression ) -> BinaryAggregation: return BinaryAggregation(self.op, larg, rarg) + + +@dataclasses.dataclass(frozen=True) +class WindowExpression(expression.Expression): + analytic_expr: Aggregation + window: window_spec.WindowSpec + + @property + def column_references(self) -> typing.Tuple[ids.ColumnId, ...]: + return tuple( + itertools.chain.from_iterable( + map(lambda x: x.column_references, self.inputs) + ) + ) + + @functools.cached_property + def is_resolved(self) -> bool: + return all(input.is_resolved for input in self.inputs) + + @property + def output_type(self) -> dtypes.ExpressionType: + return self.analytic_expr.output_type + + @property + def inputs( + self, + ) -> typing.Tuple[expression.Expression, ...]: + return (self.analytic_expr, *self.window.expressions) + + @property + def free_variables(self) -> typing.Tuple[str, ...]: + return tuple( + itertools.chain.from_iterable(map(lambda x: x.free_variables, self.inputs)) + ) + + @property + def is_const(self) -> bool: + return all(child.is_const for child in self.inputs) + + def transform_children( + self: WindowExpression, + t: Callable[[expression.Expression], expression.Expression], + ) -> WindowExpression: + return WindowExpression( + self.analytic_expr.transform_children(t), + self.window.transform_exprs(t), + ) + + def bind_variables( + self: WindowExpression, + bindings: Mapping[str, expression.Expression], + allow_partial_bindings: bool = False, + ) -> WindowExpression: + return self.transform_children( + lambda x: x.bind_variables(bindings, allow_partial_bindings) + ) + + def bind_refs( + self: WindowExpression, + bindings: Mapping[ids.ColumnId, expression.Expression], + allow_partial_bindings: bool = False, + ) -> WindowExpression: + return self.transform_children( + lambda x: x.bind_refs(bindings, allow_partial_bindings) + ) diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index aedcc6f25e..6e22baabec 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -1177,7 +1177,7 @@ def apply_analytic( block = self if skip_null_groups: for key in window.grouping_keys: - block = block.filter(ops.notnull_op.as_expr(key.id.name)) + block = block.filter(ops.notnull_op.as_expr(key)) expr, result_id = block._expr.project_window_expr( agg_expr, window, diff --git a/bigframes/core/compile/compiled.py b/bigframes/core/compile/compiled.py index b28880d498..91d72d96b2 100644 --- a/bigframes/core/compile/compiled.py +++ b/bigframes/core/compile/compiled.py @@ -21,15 +21,13 @@ import bigframes_vendored.ibis import bigframes_vendored.ibis.backends.bigquery.backend as ibis_bigquery import bigframes_vendored.ibis.common.deferred as ibis_deferred # type: ignore -from bigframes_vendored.ibis.expr import builders as ibis_expr_builders import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes -from bigframes_vendored.ibis.expr.operations import window as ibis_expr_window import bigframes_vendored.ibis.expr.operations as ibis_ops import bigframes_vendored.ibis.expr.types as ibis_types from google.cloud import bigquery import pyarrow as pa -from bigframes.core import utils +from bigframes.core import agg_expressions import bigframes.core.agg_expressions as ex_types import bigframes.core.compile.googlesql import bigframes.core.compile.ibis_compiler.aggregate_compiler as agg_compiler @@ -38,8 +36,9 @@ import bigframes.core.expression as ex from bigframes.core.ordering import OrderingExpression import bigframes.core.sql -from bigframes.core.window_spec import RangeWindowBounds, RowsWindowBounds, WindowSpec +from bigframes.core.window_spec import WindowSpec import bigframes.dtypes +import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops op_compiler = op_compilers.scalar_op_compiler @@ -167,18 +166,6 @@ def get_column_type(self, key: str) -> bigframes.dtypes.Dtype: bigframes.core.compile.ibis_types.ibis_dtype_to_bigframes_dtype(ibis_type), ) - def row_count(self, name: str) -> UnorderedIR: - original_table = self._to_ibis_expr() - ibis_table = original_table.agg( - [ - original_table.count().name(name), - ] - ) - return UnorderedIR( - ibis_table, - (ibis_table[name],), - ) - def _to_ibis_expr( self, *, @@ -237,7 +224,9 @@ def aggregate( col_out: agg_compiler.compile_aggregate( aggregate, bindings, - order_by=_convert_row_ordering_to_table_values(table, order_by), + order_by=op_compiler._convert_row_ordering_to_table_values( + table, order_by + ), ) for aggregate, col_out in aggregations } @@ -442,113 +431,64 @@ def project_window_op( if expression.op.order_independent and window_spec.is_unbounded: # notably percentile_cont does not support ordering clause window_spec = window_spec.without_order() - window = self._ibis_window_from_spec(window_spec) - bindings = {col: self._get_ibis_column(col) for col in self.column_ids} - - window_op = agg_compiler.compile_analytic( - expression, - window, - bindings=bindings, - ) - inputs = tuple( - typing.cast(ibis_types.Column, self._compile_expression(ex.DerefOp(column))) - for column in expression.column_references + # TODO: Turn this logic into a true rewriter + result_expr: ex.Expression = agg_expressions.WindowExpression( + expression, window_spec ) - clauses = [] + clauses: list[tuple[ex.Expression, ex.Expression]] = [] if expression.op.skips_nulls and not never_skip_nulls: - for column in inputs: - clauses.append((column.isnull(), ibis_types.null())) - if window_spec.min_periods and len(inputs) > 0: + for input in expression.inputs: + clauses.append((ops.isnull_op.as_expr(input), ex.const(None))) + if window_spec.min_periods and len(expression.inputs) > 0: if not expression.op.nulls_count_for_min_values: + is_observation = ops.notnull_op.as_expr() + # Most operations do not count NULL values towards min_periods - per_col_does_count = (column.notnull() for column in inputs) + per_col_does_count = ( + ops.notnull_op.as_expr(input) for input in expression.inputs + ) # All inputs must be non-null for observation to count is_observation = functools.reduce( - lambda x, y: x & y, per_col_does_count - ).cast(int) - observation_count = agg_compiler.compile_analytic( - ex_types.UnaryAggregation( - agg_ops.sum_op, ex.deref("_observation_count") - ), - window, - bindings={"_observation_count": is_observation}, + lambda x, y: ops.and_op.as_expr(x, y), per_col_does_count + ) + observation_sentinel = ops.AsTypeOp(bigframes.dtypes.INT_DTYPE).as_expr( + is_observation + ) + observation_count_expr = agg_expressions.WindowExpression( + ex_types.UnaryAggregation(agg_ops.sum_op, observation_sentinel), + window_spec, ) else: # Operations like count treat even NULLs as valid observations for the sake of min_periods # notnull is just used to convert null values to non-null (FALSE) values to be counted - is_observation = inputs[0].notnull() - observation_count = agg_compiler.compile_analytic( - ex_types.UnaryAggregation( - agg_ops.count_op, ex.deref("_observation_count") - ), - window, - bindings={"_observation_count": is_observation}, + is_observation = ops.notnull_op.as_expr(expression.inputs[0]) + observation_count_expr = agg_expressions.WindowExpression( + agg_ops.count_op.as_expr(is_observation), + window_spec, ) clauses.append( ( - observation_count < ibis_types.literal(window_spec.min_periods), - ibis_types.null(), + ops.lt_op.as_expr( + observation_count_expr, ex.const(window_spec.min_periods) + ), + ex.const(None), ) ) if clauses: - case_statement = bigframes_vendored.ibis.case() - for clause in clauses: - case_statement = case_statement.when(clause[0], clause[1]) - case_statement = case_statement.else_(window_op).end() # type: ignore - window_op = case_statement # type: ignore - - return UnorderedIR(self._table, (*self.columns, window_op.name(output_name))) - - def _compile_expression(self, expr: ex.Expression): - return op_compiler.compile_expression(expr, self._ibis_bindings) - - def _ibis_window_from_spec(self, window_spec: WindowSpec): - group_by: typing.List[ibis_types.Value] = ( - [ - typing.cast( - ibis_types.Column, _as_groupable(self._compile_expression(column)) - ) - for column in window_spec.grouping_keys + case_inputs = [ + *itertools.chain.from_iterable(clauses), + ex.const(True), + result_expr, ] - if window_spec.grouping_keys - else [] - ) + result_expr = ops.CaseWhenOp().as_expr(*case_inputs) - # Construct ordering. There are basically 3 main cases - # 1. Order-independent op (aggregation, cut, rank) with unbound window - no ordering clause needed - # 2. Order-independent op (aggregation, cut, rank) with range window - use ordering clause, ties allowed - # 3. Order-depedenpent op (navigation functions, array_agg) or rows bounds - use total row order to break ties. - if window_spec.is_row_bounded: - if not window_spec.ordering: - # If window spec has following or preceding bounds, we need to apply an unambiguous ordering. - raise ValueError("No ordering provided for ordered analytic function") - order_by = _convert_row_ordering_to_table_values( - self._column_names, - window_spec.ordering, - ) + ibis_expr = op_compiler.compile_expression(result_expr, self._ibis_bindings) - elif window_spec.is_range_bounded: - order_by = [ - _convert_range_ordering_to_table_value( - self._column_names, - window_spec.ordering[0], - ) - ] - # The rest if branches are for unbounded windows - elif window_spec.ordering: - # Unbound grouping window. Suitable for aggregations but not for analytic function application. - order_by = _convert_row_ordering_to_table_values( - self._column_names, - window_spec.ordering, - ) - else: - order_by = None + return UnorderedIR(self._table, (*self.columns, ibis_expr.name(output_name))) - window = bigframes_vendored.ibis.window(order_by=order_by, group_by=group_by) - if window_spec.bounds is not None: - return _add_boundary(window_spec.bounds, window) - return window + def _compile_expression(self, expr: ex.Expression): + return op_compiler.compile_expression(expr, self._ibis_bindings) def is_literal(column: ibis_types.Value) -> bool: @@ -567,58 +507,6 @@ def is_window(column: ibis_types.Value) -> bool: return any(isinstance(op, ibis_ops.WindowFunction) for op in matches) -def _convert_row_ordering_to_table_values( - value_lookup: typing.Mapping[str, ibis_types.Value], - ordering_columns: typing.Sequence[OrderingExpression], -) -> typing.Sequence[ibis_types.Value]: - column_refs = ordering_columns - ordering_values = [] - for ordering_col in column_refs: - expr = op_compiler.compile_expression( - ordering_col.scalar_expression, value_lookup - ) - ordering_value = ( - bigframes_vendored.ibis.asc(expr) # type: ignore - if ordering_col.direction.is_ascending - else bigframes_vendored.ibis.desc(expr) # type: ignore - ) - # Bigquery SQL considers NULLS to be "smallest" values, but we need to override in these cases. - if (not ordering_col.na_last) and (not ordering_col.direction.is_ascending): - # Force nulls to be first - is_null_val = typing.cast(ibis_types.Column, expr.isnull()) - ordering_values.append(bigframes_vendored.ibis.desc(is_null_val)) - elif (ordering_col.na_last) and (ordering_col.direction.is_ascending): - # Force nulls to be last - is_null_val = typing.cast(ibis_types.Column, expr.isnull()) - ordering_values.append(bigframes_vendored.ibis.asc(is_null_val)) - ordering_values.append(ordering_value) - return ordering_values - - -def _convert_range_ordering_to_table_value( - value_lookup: typing.Mapping[str, ibis_types.Value], - ordering_column: OrderingExpression, -) -> ibis_types.Value: - """Converts the ordering for range windows to Ibis references. - - Note that this method is different from `_convert_row_ordering_to_table_values` in - that it does not arrange null values. There are two reasons: - 1. Manipulating null positions requires more than one ordering key, which is forbidden - by SQL window syntax for range rolling. - 2. Pandas does not allow range rolling on timeseries with nulls. - - Therefore, we opt for the simplest approach here: generate the simplest SQL and follow - the BigQuery engine behavior. - """ - expr = op_compiler.compile_expression( - ordering_column.scalar_expression, value_lookup - ) - - if ordering_column.direction.is_ascending: - return bigframes_vendored.ibis.asc(expr) # type: ignore - return bigframes_vendored.ibis.desc(expr) # type: ignore - - def _string_cast_join_cond( lvalue: ibis_types.Column, rvalue: ibis_types.Column ) -> ibis_types.BooleanColumn: @@ -678,53 +566,3 @@ def _join_condition( else: return _string_cast_join_cond(lvalue, rvalue) return typing.cast(ibis_types.BooleanColumn, lvalue == rvalue) - - -def _as_groupable(value: ibis_types.Value): - from bigframes.core.compile.ibis_compiler import scalar_op_registry - - # Some types need to be converted to another type to enable groupby - if value.type().is_float64(): - return value.cast(ibis_dtypes.str) - elif value.type().is_geospatial(): - return typing.cast(ibis_types.GeoSpatialColumn, value).as_binary() - elif value.type().is_json(): - return scalar_op_registry.to_json_string(value) - else: - return value - - -def _to_ibis_boundary( - boundary: Optional[int], -) -> Optional[ibis_expr_window.WindowBoundary]: - if boundary is None: - return None - return ibis_expr_window.WindowBoundary( - abs(boundary), preceding=boundary <= 0 # type:ignore - ) - - -def _add_boundary( - bounds: typing.Union[RowsWindowBounds, RangeWindowBounds], - ibis_window: ibis_expr_builders.LegacyWindowBuilder, -) -> ibis_expr_builders.LegacyWindowBuilder: - if isinstance(bounds, RangeWindowBounds): - return ibis_window.range( - start=_to_ibis_boundary( - None - if bounds.start is None - else utils.timedelta_to_micros(bounds.start) - ), - end=_to_ibis_boundary( - None if bounds.end is None else utils.timedelta_to_micros(bounds.end) - ), - ) - if isinstance(bounds, RowsWindowBounds): - if bounds.start is not None or bounds.end is not None: - return ibis_window.rows( - start=_to_ibis_boundary(bounds.start), - end=_to_ibis_boundary(bounds.end), - ) - return ibis_window - else: - raise ValueError(f"unrecognized window bounds {bounds}") diff --git a/bigframes/core/compile/ibis_compiler/aggregate_compiler.py b/bigframes/core/compile/ibis_compiler/aggregate_compiler.py index 1907078690..b101f4e09f 100644 --- a/bigframes/core/compile/ibis_compiler/aggregate_compiler.py +++ b/bigframes/core/compile/ibis_compiler/aggregate_compiler.py @@ -19,8 +19,11 @@ from typing import cast, List, Optional import bigframes_vendored.constants as constants +import bigframes_vendored.ibis +from bigframes_vendored.ibis.expr import builders as ibis_expr_builders import bigframes_vendored.ibis.expr.api as ibis_api import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes +from bigframes_vendored.ibis.expr.operations import window as ibis_expr_window import bigframes_vendored.ibis.expr.operations as ibis_ops import bigframes_vendored.ibis.expr.operations.udf as ibis_udf import bigframes_vendored.ibis.expr.types as ibis_types @@ -30,6 +33,8 @@ from bigframes.core.compile import constants as compiler_constants import bigframes.core.compile.ibis_compiler.scalar_op_compiler as scalar_compilers import bigframes.core.compile.ibis_types as compile_ibis_types +import bigframes.core.utils +from bigframes.core.window_spec import RangeWindowBounds, RowsWindowBounds, WindowSpec import bigframes.core.window_spec as window_spec import bigframes.operations.aggregations as agg_ops @@ -73,11 +78,12 @@ def compile_analytic( window: window_spec.WindowSpec, bindings: typing.Dict[str, ibis_types.Value], ) -> ibis_types.Value: + ibis_window = _ibis_window_from_spec(window, bindings=bindings) if isinstance(aggregate, agg_expressions.NullaryAggregation): - return compile_nullary_agg(aggregate.op, window) + return compile_nullary_agg(aggregate.op, ibis_window) elif isinstance(aggregate, agg_expressions.UnaryAggregation): input = scalar_compiler.compile_expression(aggregate.arg, bindings=bindings) - return compile_unary_agg(aggregate.op, input, window) # type: ignore + return compile_unary_agg(aggregate.op, input, ibis_window) # type: ignore elif isinstance(aggregate, agg_expressions.BinaryAggregation): raise NotImplementedError("binary analytic operations not yet supported") else: @@ -729,6 +735,109 @@ def _apply_window_if_present(value: ibis_types.Value, window): return value.over(window) if (window is not None) else value +def _ibis_window_from_spec( + window_spec: WindowSpec, bindings: typing.Dict[str, ibis_types.Value] +): + group_by: typing.List[ibis_types.Value] = ( + [ + typing.cast( + ibis_types.Column, + _as_groupable(scalar_compiler.compile_expression(column, bindings)), + ) + for column in window_spec.grouping_keys + ] + if window_spec.grouping_keys + else [] + ) + + # Construct ordering. There are basically 3 main cases + # 1. Order-independent op (aggregation, cut, rank) with unbound window - no ordering clause needed + # 2. Order-independent op (aggregation, cut, rank) with range window - use ordering clause, ties allowed + # 3. Order-depedenpent op (navigation functions, array_agg) or rows bounds - use total row order to break ties. + if window_spec.is_row_bounded: + if not window_spec.ordering: + # If window spec has following or preceding bounds, we need to apply an unambiguous ordering. + raise ValueError("No ordering provided for ordered analytic function") + order_by = scalar_compiler._convert_row_ordering_to_table_values( + bindings, + window_spec.ordering, + ) + + elif window_spec.is_range_bounded: + order_by = [ + scalar_compiler._convert_range_ordering_to_table_value( + bindings, + window_spec.ordering[0], + ) + ] + # The rest if branches are for unbounded windows + elif window_spec.ordering: + # Unbound grouping window. Suitable for aggregations but not for analytic function application. + order_by = scalar_compiler._convert_row_ordering_to_table_values( + bindings, + window_spec.ordering, + ) + else: + order_by = None + + window = bigframes_vendored.ibis.window(order_by=order_by, group_by=group_by) + if window_spec.bounds is not None: + return _add_boundary(window_spec.bounds, window) + return window + + +def _as_groupable(value: ibis_types.Value): + from bigframes.core.compile.ibis_compiler import scalar_op_registry + + # Some types need to be converted to another type to enable groupby + if value.type().is_float64(): + return value.cast(ibis_dtypes.str) + elif value.type().is_geospatial(): + return typing.cast(ibis_types.GeoSpatialColumn, value).as_binary() + elif value.type().is_json(): + return scalar_op_registry.to_json_string(value) + else: + return value + + +def _to_ibis_boundary( + boundary: Optional[int], +) -> Optional[ibis_expr_window.WindowBoundary]: + if boundary is None: + return None + return ibis_expr_window.WindowBoundary( + abs(boundary), preceding=boundary <= 0 # type:ignore + ) + + +def _add_boundary( + bounds: typing.Union[RowsWindowBounds, RangeWindowBounds], + ibis_window: ibis_expr_builders.LegacyWindowBuilder, +) -> ibis_expr_builders.LegacyWindowBuilder: + if isinstance(bounds, RangeWindowBounds): + return ibis_window.range( + start=_to_ibis_boundary( + None + if bounds.start is None + else bigframes.core.utils.timedelta_to_micros(bounds.start) + ), + end=_to_ibis_boundary( + None + if bounds.end is None + else bigframes.core.utils.timedelta_to_micros(bounds.end) + ), + ) + if isinstance(bounds, RowsWindowBounds): + if bounds.start is not None or bounds.end is not None: + return ibis_window.rows( + start=_to_ibis_boundary(bounds.start), + end=_to_ibis_boundary(bounds.end), + ) + return ibis_window + else: + raise ValueError(f"unrecognized window bounds {bounds}") + + def _map_to_literal( original: ibis_types.Value, literal: ibis_types.Scalar ) -> ibis_types.Column: diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_compiler.py b/bigframes/core/compile/ibis_compiler/scalar_op_compiler.py index d5f3e15d34..1197f6b9da 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_compiler.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_compiler.py @@ -20,8 +20,10 @@ import typing from typing import TYPE_CHECKING +import bigframes_vendored.ibis import bigframes_vendored.ibis.expr.types as ibis_types +from bigframes.core import agg_expressions, ordering import bigframes.core.compile.ibis_types import bigframes.core.expression as ex @@ -29,7 +31,7 @@ import bigframes.operations as ops -class ScalarOpCompiler: +class ExpressionCompiler: # Mapping of operation name to implemenations _registry: dict[ str, @@ -67,6 +69,18 @@ def _( else: return bindings[expression.id.sql] + @compile_expression.register + def _( + self, + expression: agg_expressions.WindowExpression, + bindings: typing.Dict[str, ibis_types.Value], + ) -> ibis_types.Value: + import bigframes.core.compile.ibis_compiler.aggregate_compiler as agg_compile + + return agg_compile.compile_analytic( + expression.analytic_expr, expression.window, bindings + ) + @compile_expression.register def _( self, @@ -202,6 +216,54 @@ def _register( raise ValueError(f"Operation name {op_name} already registered") self._registry[op_name] = impl + def _convert_row_ordering_to_table_values( + self, + value_lookup: typing.Mapping[str, ibis_types.Value], + ordering_columns: typing.Sequence[ordering.OrderingExpression], + ) -> typing.Sequence[ibis_types.Value]: + column_refs = ordering_columns + ordering_values = [] + for ordering_col in column_refs: + expr = self.compile_expression(ordering_col.scalar_expression, value_lookup) + ordering_value = ( + bigframes_vendored.ibis.asc(expr) # type: ignore + if ordering_col.direction.is_ascending + else bigframes_vendored.ibis.desc(expr) # type: ignore + ) + # Bigquery SQL considers NULLS to be "smallest" values, but we need to override in these cases. + if (not ordering_col.na_last) and (not ordering_col.direction.is_ascending): + # Force nulls to be first + is_null_val = typing.cast(ibis_types.Column, expr.isnull()) + ordering_values.append(bigframes_vendored.ibis.desc(is_null_val)) + elif (ordering_col.na_last) and (ordering_col.direction.is_ascending): + # Force nulls to be last + is_null_val = typing.cast(ibis_types.Column, expr.isnull()) + ordering_values.append(bigframes_vendored.ibis.asc(is_null_val)) + ordering_values.append(ordering_value) + return ordering_values + + def _convert_range_ordering_to_table_value( + self, + value_lookup: typing.Mapping[str, ibis_types.Value], + ordering_column: ordering.OrderingExpression, + ) -> ibis_types.Value: + """Converts the ordering for range windows to Ibis references. + + Note that this method is different from `_convert_row_ordering_to_table_values` in + that it does not arrange null values. There are two reasons: + 1. Manipulating null positions requires more than one ordering key, which is forbidden + by SQL window syntax for range rolling. + 2. Pandas does not allow range rolling on timeseries with nulls. + + Therefore, we opt for the simplest approach here: generate the simplest SQL and follow + the BigQuery engine behavior. + """ + expr = self.compile_expression(ordering_column.scalar_expression, value_lookup) + + if ordering_column.direction.is_ascending: + return bigframes_vendored.ibis.asc(expr) # type: ignore + return bigframes_vendored.ibis.desc(expr) # type: ignore + # Singleton compiler -scalar_op_compiler = ScalarOpCompiler() +scalar_op_compiler = ExpressionCompiler() diff --git a/bigframes/core/compile/polars/compiler.py b/bigframes/core/compile/polars/compiler.py index df84f08852..f7c742e852 100644 --- a/bigframes/core/compile/polars/compiler.py +++ b/bigframes/core/compile/polars/compiler.py @@ -828,7 +828,10 @@ def compile_window(self, node: nodes.WindowOpNode): # polars will automatically broadcast the aggregate to the matching input rows agg_pl = self.agg_compiler.compile_agg_expr(node.expression) if window.grouping_keys: - agg_pl = agg_pl.over(id.id.sql for id in window.grouping_keys) + agg_pl = agg_pl.over( + self.expr_compiler.compile_expression(key) + for key in window.grouping_keys + ) result = df.with_columns(agg_pl.alias(node.output_name.sql)) else: # row-bounded window window_result = self._calc_row_analytic_func( diff --git a/bigframes/core/ordering.py b/bigframes/core/ordering.py index 2fc7573b21..50b3cee8aa 100644 --- a/bigframes/core/ordering.py +++ b/bigframes/core/ordering.py @@ -17,7 +17,7 @@ from dataclasses import dataclass, field from enum import Enum import typing -from typing import Mapping, Optional, Sequence, Set, Union +from typing import Callable, Mapping, Optional, Sequence, Set, Union import bigframes.core.expression as expression import bigframes.core.identifiers as ids @@ -82,6 +82,15 @@ def with_reverse(self) -> OrderingExpression: self.scalar_expression, self.direction.reverse(), not self.na_last ) + def transform_exprs( + self, t: Callable[[expression.Expression], expression.Expression] + ) -> OrderingExpression: + return OrderingExpression( + t(self.scalar_expression), + self.direction, + self.na_last, + ) + # Encoding classes specify additional properties for some ordering representations @dataclass(frozen=True) diff --git a/bigframes/core/window/rolling.py b/bigframes/core/window/rolling.py index a9c6dfdfa7..1f3466874f 100644 --- a/bigframes/core/window/rolling.py +++ b/bigframes/core/window/rolling.py @@ -108,8 +108,10 @@ def _aggregate_block(self, op: agg_ops.UnaryAggregateOp) -> blocks.Block: if self._window_spec.grouping_keys: original_index_ids = block.index_columns block = block.reset_index(drop=False) + # grouping keys will always be direct column references, but we should probably + # refactor this class to enforce this statically index_ids = ( - *[col.id.name for col in self._window_spec.grouping_keys], + *[col.id.name for col in self._window_spec.grouping_keys], # type: ignore *original_index_ids, ) block = block.set_index(col_ids=index_ids) diff --git a/bigframes/core/window_spec.py b/bigframes/core/window_spec.py index bef5fbea7c..9e4ee17103 100644 --- a/bigframes/core/window_spec.py +++ b/bigframes/core/window_spec.py @@ -16,7 +16,7 @@ from dataclasses import dataclass, replace import datetime import itertools -from typing import Literal, Mapping, Optional, Sequence, Set, Tuple, Union +from typing import Callable, Literal, Mapping, Optional, Sequence, Set, Tuple, Union import numpy as np import pandas as pd @@ -215,13 +215,13 @@ class WindowSpec: Specifies a window over which aggregate and analytic function may be applied. Attributes: - grouping_keys: A set of column ids to group on + grouping_keys: A set of columns to group on bounds: The window boundaries ordering: A list of columns ids and ordering direction to override base ordering min_periods: The minimum number of observations in window required to have a value """ - grouping_keys: Tuple[ex.DerefOp, ...] = tuple() + grouping_keys: Tuple[ex.Expression, ...] = tuple() ordering: Tuple[orderings.OrderingExpression, ...] = tuple() bounds: Union[RowsWindowBounds, RangeWindowBounds, None] = None min_periods: int = 0 @@ -273,7 +273,10 @@ def all_referenced_columns(self) -> Set[ids.ColumnId]: ordering_vars = itertools.chain.from_iterable( item.scalar_expression.column_references for item in self.ordering ) - return set(itertools.chain((i.id for i in self.grouping_keys), ordering_vars)) + grouping_vars = itertools.chain.from_iterable( + item.column_references for item in self.grouping_keys + ) + return set(itertools.chain(grouping_vars, ordering_vars)) def without_order(self, force: bool = False) -> WindowSpec: """Removes ordering clause if ordering isn't required to define bounds.""" @@ -298,3 +301,15 @@ def remap_column_refs( bounds=self.bounds, min_periods=self.min_periods, ) + + def transform_exprs( + self: WindowSpec, t: Callable[[ex.Expression], ex.Expression] + ) -> WindowSpec: + return WindowSpec( + grouping_keys=tuple(t(key) for key in self.grouping_keys), + ordering=tuple( + order_part.transform_exprs(t) for order_part in self.ordering + ), + bounds=self.bounds, + min_periods=self.min_periods, + ) diff --git a/third_party/bigframes_vendored/ibis/expr/types/generic.py b/third_party/bigframes_vendored/ibis/expr/types/generic.py index 7de357b138..596d3134f6 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/generic.py +++ b/third_party/bigframes_vendored/ibis/expr/types/generic.py @@ -773,7 +773,9 @@ def over( @deferrable def bind(table): - winfunc = rewrite_window_input(node, window.bind(table)) + winfunc = rewrite_window_input( + node, window.bind(table) if (table is not None) else window + ) if winfunc == node: raise com.IbisTypeError( "No reduction or analytic function found to construct a window expression" From 0fc795a9fb56f469b62603462c3f0f56f52bfe04 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Wed, 17 Sep 2025 10:16:54 -0700 Subject: [PATCH 076/313] feat: add bigframes.bigquery.to_json (#2078) --- bigframes/bigquery/__init__.py | 2 ++ bigframes/bigquery/_operations/json.py | 34 +++++++++++++++++++ .../ibis_compiler/scalar_op_registry.py | 10 ++++++ bigframes/operations/__init__.py | 2 ++ bigframes/operations/json_ops.py | 14 ++++++++ tests/system/small/bigquery/test_json.py | 25 ++++++++++++++ 6 files changed, 87 insertions(+) diff --git a/bigframes/bigquery/__init__.py b/bigframes/bigquery/__init__.py index 072bd21da1..e8c7a524d9 100644 --- a/bigframes/bigquery/__init__.py +++ b/bigframes/bigquery/__init__.py @@ -51,6 +51,7 @@ json_value, json_value_array, parse_json, + to_json, to_json_string, ) from bigframes.bigquery._operations.search import create_vector_index, vector_search @@ -89,6 +90,7 @@ json_value, json_value_array, parse_json, + to_json, to_json_string, # search ops create_vector_index, diff --git a/bigframes/bigquery/_operations/json.py b/bigframes/bigquery/_operations/json.py index a972380334..656e59af0d 100644 --- a/bigframes/bigquery/_operations/json.py +++ b/bigframes/bigquery/_operations/json.py @@ -430,6 +430,40 @@ def json_value_array( return input._apply_unary_op(ops.JSONValueArray(json_path=json_path)) +def to_json( + input: series.Series, +) -> series.Series: + """Converts a series with a JSON value to a JSON-formatted STRING value. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> bpd.options.display.progress_bar = None + + >>> s = bpd.Series([1, 2, 3]) + >>> bbq.to_json(s) + 0 1 + 1 2 + 2 3 + dtype: extension>[pyarrow] + + >>> s = bpd.Series([{"int": 1, "str": "pandas"}, {"int": 2, "str": "numpy"}]) + >>> bbq.to_json(s) + 0 {"int":1,"str":"pandas"} + 1 {"int":2,"str":"numpy"} + dtype: extension>[pyarrow] + + Args: + input (bigframes.series.Series): + The Series containing JSON or JSON-formatted string values. + + Returns: + bigframes.series.Series: A new Series with the JSON value. + """ + return input._apply_unary_op(ops.ToJSON()) + + def to_json_string( input: series.Series, ) -> series.Series: diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index 95dd2bc6b6..8ffc556f76 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -1302,6 +1302,11 @@ def parse_json_op_impl(x: ibis_types.Value, op: ops.ParseJSON): return parse_json(json_str=x) +@scalar_op_compiler.register_unary_op(ops.ToJSON) +def to_json_op_impl(json_obj: ibis_types.Value): + return to_json(json_obj=json_obj) + + @scalar_op_compiler.register_unary_op(ops.ToJSONString) def to_json_string_op_impl(x: ibis_types.Value): return to_json_string(value=x) @@ -2093,6 +2098,11 @@ def json_extract_string_array( # type: ignore[empty-body] """Extracts a JSON array and converts it to a SQL ARRAY of STRINGs.""" +@ibis_udf.scalar.builtin(name="to_json") +def to_json(json_obj) -> ibis_dtypes.JSON: # type: ignore[empty-body] + """Convert to JSON.""" + + @ibis_udf.scalar.builtin(name="to_json_string") def to_json_string(value) -> ibis_dtypes.String: # type: ignore[empty-body] """Convert value to JSON-formatted string.""" diff --git a/bigframes/operations/__init__.py b/bigframes/operations/__init__.py index bb9ec4d294..6239b88e9e 100644 --- a/bigframes/operations/__init__.py +++ b/bigframes/operations/__init__.py @@ -124,6 +124,7 @@ JSONValue, JSONValueArray, ParseJSON, + ToJSON, ToJSONString, ) from bigframes.operations.numeric_ops import ( @@ -376,6 +377,7 @@ "JSONValue", "JSONValueArray", "ParseJSON", + "ToJSON", "ToJSONString", # Bool ops "and_op", diff --git a/bigframes/operations/json_ops.py b/bigframes/operations/json_ops.py index b1186e433c..487c193cc5 100644 --- a/bigframes/operations/json_ops.py +++ b/bigframes/operations/json_ops.py @@ -102,6 +102,20 @@ def output_type(self, *input_types): return dtypes.JSON_DTYPE +@dataclasses.dataclass(frozen=True) +class ToJSON(base_ops.UnaryOp): + name: typing.ClassVar[str] = "to_json" + + def output_type(self, *input_types): + input_type = input_types[0] + if not dtypes.is_json_encoding_type(input_type): + raise TypeError( + "The value to be assigned must be a type that can be encoded as JSON." + + f"Received type: {input_type}" + ) + return dtypes.JSON_DTYPE + + @dataclasses.dataclass(frozen=True) class ToJSONString(base_ops.UnaryOp): name: typing.ClassVar[str] = "to_json_string" diff --git a/tests/system/small/bigquery/test_json.py b/tests/system/small/bigquery/test_json.py index 213db0849e..5a44c75f17 100644 --- a/tests/system/small/bigquery/test_json.py +++ b/tests/system/small/bigquery/test_json.py @@ -386,6 +386,31 @@ def test_parse_json_w_invalid_series_type(): bbq.parse_json(s) +def test_to_json_from_int(): + s = bpd.Series([1, 2, None, 3]) + actual = bbq.to_json(s) + expected = bpd.Series(["1.0", "2.0", "null", "3.0"], dtype=dtypes.JSON_DTYPE) + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_to_json_from_struct(): + s = bpd.Series( + [ + {"version": 1, "project": "pandas"}, + {"version": 2, "project": "numpy"}, + ] + ) + assert dtypes.is_struct_like(s.dtype) + + actual = bbq.to_json(s) + expected = bpd.Series( + ['{"project":"pandas","version":1}', '{"project":"numpy","version":2}'], + dtype=dtypes.JSON_DTYPE, + ) + + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + def test_to_json_string_from_int(): s = bpd.Series([1, 2, None, 3]) actual = bbq.to_json_string(s) From 81dbf9a53fe37c20b9eb94c000ad941cac524469 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Wed, 17 Sep 2025 13:01:06 -0700 Subject: [PATCH 077/313] chore: remove linked table test case (#2091) --- tests/system/small/test_session.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/system/small/test_session.py b/tests/system/small/test_session.py index 38d66bceb2..001e02c2fa 100644 --- a/tests/system/small/test_session.py +++ b/tests/system/small/test_session.py @@ -511,8 +511,6 @@ def test_read_gbq_twice_with_same_timestamp(session, penguins_table_id): [ # Wildcard tables "bigquery-public-data.noaa_gsod.gsod194*", - # Linked datasets - "bigframes-dev.thelook_ecommerce.orders", # Materialized views "bigframes-dev.bigframes_tests_sys.base_table_mat_view", ], From 78f4001e8fcfc77fc82f3893d58e0d04c0f6d3db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Wed, 17 Sep 2025 15:58:02 -0500 Subject: [PATCH 078/313] fix: allow bigframes.options.bigquery.credentials to be `None` (#2092) * fix: allow bigframes.options.bigquery.credentials to be `None` This is a partial revert of "perf: avoid re-authenticating if credentials have already been fetched (#2058)", commit 913de1b31f3bb0b306846fddae5dcaff6be3cec4. * add unit test --- bigframes/_config/bigquery_options.py | 44 ++------------------- bigframes/session/clients.py | 9 +++-- tests/unit/_config/test_bigquery_options.py | 5 +++ tests/unit/pandas/io/test_api.py | 5 ++- 4 files changed, 18 insertions(+), 45 deletions(-) diff --git a/bigframes/_config/bigquery_options.py b/bigframes/_config/bigquery_options.py index 2456a88073..648b69dea7 100644 --- a/bigframes/_config/bigquery_options.py +++ b/bigframes/_config/bigquery_options.py @@ -22,7 +22,6 @@ import google.auth.credentials import requests.adapters -import bigframes._config.auth import bigframes._importing import bigframes.enums import bigframes.exceptions as bfe @@ -38,7 +37,6 @@ def _get_validated_location(value: Optional[str]) -> Optional[str]: import bigframes._tools.strings - import bigframes.constants if value is None or value in bigframes.constants.ALL_BIGQUERY_LOCATIONS: return value @@ -143,52 +141,20 @@ def application_name(self, value: Optional[str]): ) self._application_name = value - def _try_set_default_credentials_and_project( - self, - ) -> tuple[google.auth.credentials.Credentials, Optional[str]]: - # Don't fetch credentials or project if credentials is already set. - # If it's set, we've already authenticated, so if the user wants to - # re-auth, they should explicitly reset the credentials. - if self._credentials is not None: - return self._credentials, self._project - - ( - credentials, - credentials_project, - ) = bigframes._config.auth.get_default_credentials_with_project() - self._credentials = credentials - - # Avoid overriding an explicitly set project with a default value. - if self._project is None: - self._project = credentials_project - - return credentials, self._project - @property - def credentials(self) -> google.auth.credentials.Credentials: + def credentials(self) -> Optional[google.auth.credentials.Credentials]: """The OAuth2 credentials to use for this client. - Set to None to force re-authentication. - Returns: None or google.auth.credentials.Credentials: google.auth.credentials.Credentials if exists; otherwise None. """ - if self._credentials: - return self._credentials - - credentials, _ = self._try_set_default_credentials_and_project() - return credentials + return self._credentials @credentials.setter def credentials(self, value: Optional[google.auth.credentials.Credentials]): if self._session_started and self._credentials is not value: raise ValueError(SESSION_STARTED_MESSAGE.format(attribute="credentials")) - - if value is None: - # The user has _explicitly_ asked that we re-authenticate. - bigframes._config.auth.reset_default_credentials_and_project() - self._credentials = value @property @@ -217,11 +183,7 @@ def project(self) -> Optional[str]: None or str: Google Cloud project ID as a string; otherwise None. """ - if self._project: - return self._project - - _, project = self._try_set_default_credentials_and_project() - return project + return self._project @project.setter def project(self, value: Optional[str]): diff --git a/bigframes/session/clients.py b/bigframes/session/clients.py index 42bfab2682..31a021cdd6 100644 --- a/bigframes/session/clients.py +++ b/bigframes/session/clients.py @@ -32,7 +32,7 @@ import google.cloud.storage # type: ignore import requests -import bigframes._config +import bigframes._config.auth import bigframes.constants import bigframes.version @@ -50,6 +50,10 @@ _BIGQUERYSTORAGE_REGIONAL_ENDPOINT = "bigquerystorage.{location}.rep.googleapis.com" +def _get_default_credentials_with_project(): + return bigframes._config.auth.get_default_credentials_with_project() + + def _get_application_names(): apps = [_APPLICATION_NAME] @@ -84,8 +88,7 @@ def __init__( ): credentials_project = None if credentials is None: - credentials = bigframes._config.options.bigquery.credentials - credentials_project = bigframes._config.options.bigquery.project + credentials, credentials_project = _get_default_credentials_with_project() # Prefer the project in this order: # 1. Project explicitly specified by the user diff --git a/tests/unit/_config/test_bigquery_options.py b/tests/unit/_config/test_bigquery_options.py index 3c80f00a37..57486125b7 100644 --- a/tests/unit/_config/test_bigquery_options.py +++ b/tests/unit/_config/test_bigquery_options.py @@ -203,3 +203,8 @@ def test_default_options(): assert options.allow_large_results is False assert options.ordering_mode == "strict" + + # We should default to None as an indicator that the user hasn't set these + # explicitly. See internal issue b/445731915. + assert options.credentials is None + assert options.project is None diff --git a/tests/unit/pandas/io/test_api.py b/tests/unit/pandas/io/test_api.py index ba401d1ce6..14419236c9 100644 --- a/tests/unit/pandas/io/test_api.py +++ b/tests/unit/pandas/io/test_api.py @@ -17,6 +17,7 @@ import google.cloud.bigquery import pytest +import bigframes._config.auth import bigframes.dataframe import bigframes.pandas import bigframes.pandas.io.api as bf_io_api @@ -50,7 +51,7 @@ def test_read_gbq_colab_dry_run_doesnt_call_set_location( mock_set_location.assert_not_called() -@mock.patch("bigframes._config.auth.get_default_credentials_with_project") +@mock.patch("bigframes._config.auth.pydata_google_auth.default") @mock.patch("bigframes.core.global_session.with_default_session") def test_read_gbq_colab_dry_run_doesnt_authenticate_multiple_times( mock_with_default_session, mock_get_credentials, monkeypatch @@ -77,12 +78,14 @@ def test_read_gbq_colab_dry_run_doesnt_authenticate_multiple_times( mock_df = mock.create_autospec(bigframes.dataframe.DataFrame) mock_with_default_session.return_value = mock_df + bigframes._config.auth._cached_credentials = None query_or_table = "SELECT {param1} AS param1" sample_pyformat_args = {"param1": "value1"} bf_io_api._read_gbq_colab( query_or_table, pyformat_args=sample_pyformat_args, dry_run=True ) + mock_get_credentials.assert_called() mock_with_default_session.assert_not_called() mock_get_credentials.reset_mock() From 920f381aec7e0a0b986886cdbc333e86335c6d7d Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Wed, 17 Sep 2025 14:49:31 -0700 Subject: [PATCH 079/313] feat: support average='binary' in precision_score() (#2080) * feat: support 'binary' for precision_score * add test * use unique(keep_order=False) to count unique items * use local variables to hold unique classes * use concat before checking unique labels * fix test --- bigframes/ml/metrics/_metrics.py | 83 +++++++++++++++---- tests/system/small/ml/test_metrics.py | 65 +++++++++++++++ .../sklearn/metrics/_classification.py | 2 +- 3 files changed, 133 insertions(+), 17 deletions(-) diff --git a/bigframes/ml/metrics/_metrics.py b/bigframes/ml/metrics/_metrics.py index c9639f4b16..8787a68c58 100644 --- a/bigframes/ml/metrics/_metrics.py +++ b/bigframes/ml/metrics/_metrics.py @@ -15,9 +15,11 @@ """Metrics functions for evaluating models. This module is styled after scikit-learn's metrics module: https://scikit-learn.org/stable/modules/metrics.html.""" +from __future__ import annotations + import inspect import typing -from typing import Tuple, Union +from typing import Literal, overload, Tuple, Union import bigframes_vendored.constants as constants import bigframes_vendored.sklearn.metrics._classification as vendored_metrics_classification @@ -259,31 +261,64 @@ def recall_score( recall_score.__doc__ = inspect.getdoc(vendored_metrics_classification.recall_score) +@overload def precision_score( - y_true: Union[bpd.DataFrame, bpd.Series], - y_pred: Union[bpd.DataFrame, bpd.Series], + y_true: bpd.DataFrame | bpd.Series, + y_pred: bpd.DataFrame | bpd.Series, *, - average: typing.Optional[str] = "binary", + pos_label: int | float | bool | str = ..., + average: Literal["binary"] = ..., +) -> float: + ... + + +@overload +def precision_score( + y_true: bpd.DataFrame | bpd.Series, + y_pred: bpd.DataFrame | bpd.Series, + *, + pos_label: int | float | bool | str = ..., + average: None = ..., ) -> pd.Series: - # TODO(ashleyxu): support more average type, default to "binary" - if average is not None: - raise NotImplementedError( - f"Only average=None is supported. {constants.FEEDBACK_LINK}" - ) + ... + +def precision_score( + y_true: bpd.DataFrame | bpd.Series, + y_pred: bpd.DataFrame | bpd.Series, + *, + pos_label: int | float | bool | str = 1, + average: Literal["binary"] | None = "binary", +) -> pd.Series | float: y_true_series, y_pred_series = utils.batch_convert_to_series(y_true, y_pred) - is_accurate = y_true_series == y_pred_series + if average is None: + return _precision_score_per_label(y_true_series, y_pred_series) + + if average == "binary": + return _precision_score_binary_pos_only(y_true_series, y_pred_series, pos_label) + + raise NotImplementedError( + f"Unsupported 'average' param value: {average}. {constants.FEEDBACK_LINK}" + ) + + +precision_score.__doc__ = inspect.getdoc( + vendored_metrics_classification.precision_score +) + + +def _precision_score_per_label(y_true: bpd.Series, y_pred: bpd.Series) -> pd.Series: + is_accurate = y_true == y_pred unique_labels = ( - bpd.concat([y_true_series, y_pred_series], join="outer") + bpd.concat([y_true, y_pred], join="outer") .drop_duplicates() .sort_values(inplace=False) ) index = unique_labels.to_list() precision = ( - is_accurate.groupby(y_pred_series).sum() - / is_accurate.groupby(y_pred_series).count() + is_accurate.groupby(y_pred).sum() / is_accurate.groupby(y_pred).count() ).to_pandas() precision_score = pd.Series(0, index=index) @@ -293,9 +328,25 @@ def precision_score( return precision_score -precision_score.__doc__ = inspect.getdoc( - vendored_metrics_classification.precision_score -) +def _precision_score_binary_pos_only( + y_true: bpd.Series, y_pred: bpd.Series, pos_label: int | float | bool | str +) -> float: + unique_labels = bpd.concat([y_true, y_pred]).unique(keep_order=False) + + if unique_labels.count() != 2: + raise ValueError( + "Target is multiclass but average='binary'. Please choose another average setting." + ) + + if not (unique_labels == pos_label).any(): + raise ValueError( + f"pos_labe={pos_label} is not a valid label. It should be one of {unique_labels.to_list()}" + ) + + target_elem_idx = y_pred == pos_label + is_accurate = y_pred[target_elem_idx] == y_true[target_elem_idx] + + return is_accurate.sum() / is_accurate.count() def f1_score( diff --git a/tests/system/small/ml/test_metrics.py b/tests/system/small/ml/test_metrics.py index fd5dbef2e3..040d4d97f6 100644 --- a/tests/system/small/ml/test_metrics.py +++ b/tests/system/small/ml/test_metrics.py @@ -743,6 +743,71 @@ def test_precision_score_series(session): ) +@pytest.mark.parametrize( + ("pos_label", "expected_score"), + [ + ("a", 1 / 3), + ("b", 0), + ], +) +def test_precision_score_binary(session, pos_label, expected_score): + pd_df = pd.DataFrame( + { + "y_true": ["a", "a", "a", "b", "b"], + "y_pred": ["b", "b", "a", "a", "a"], + } + ) + df = session.read_pandas(pd_df) + + precision_score = metrics.precision_score( + df["y_true"], df["y_pred"], average="binary", pos_label=pos_label + ) + + assert precision_score == pytest.approx(expected_score) + + +def test_precision_score_binary_default_arguments(session): + pd_df = pd.DataFrame( + { + "y_true": [1, 1, 1, 0, 0], + "y_pred": [0, 0, 1, 1, 1], + } + ) + df = session.read_pandas(pd_df) + + precision_score = metrics.precision_score(df["y_true"], df["y_pred"]) + + assert precision_score == pytest.approx(1 / 3) + + +@pytest.mark.parametrize( + ("y_true", "y_pred", "pos_label"), + [ + pytest.param( + pd.Series([1, 2, 3]), pd.Series([1, 0]), 1, id="y_true-non-binary-label" + ), + pytest.param( + pd.Series([1, 0]), pd.Series([1, 2, 3]), 1, id="y_pred-non-binary-label" + ), + pytest.param( + pd.Series([1, 0]), pd.Series([1, 2]), 1, id="combined-non-binary-label" + ), + pytest.param(pd.Series([1, 0]), pd.Series([1, 0]), 2, id="invalid-pos_label"), + ], +) +def test_precision_score_binary_invalid_input_raise_error( + session, y_true, y_pred, pos_label +): + + bf_y_true = session.read_pandas(y_true) + bf_y_pred = session.read_pandas(y_pred) + + with pytest.raises(ValueError): + metrics.precision_score( + bf_y_true, bf_y_pred, average="binary", pos_label=pos_label + ) + + def test_f1_score(session): pd_df = pd.DataFrame( { diff --git a/third_party/bigframes_vendored/sklearn/metrics/_classification.py b/third_party/bigframes_vendored/sklearn/metrics/_classification.py index c1a909e849..fd6e8678ea 100644 --- a/third_party/bigframes_vendored/sklearn/metrics/_classification.py +++ b/third_party/bigframes_vendored/sklearn/metrics/_classification.py @@ -201,7 +201,7 @@ def precision_score( default='binary' This parameter is required for multiclass/multilabel targets. Possible values are 'None', 'micro', 'macro', 'samples', 'weighted', 'binary'. - Only average=None is supported. + Only None and 'binary' is supported. Returns: precision: float (if average is not None) or Series of float of shape \ From bbd95e5603c01323652c04c962aaf7d0a6eed96f Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Wed, 17 Sep 2025 15:03:52 -0700 Subject: [PATCH 080/313] refactor: reorganize the sqlglot scalar compiler layout - part 2 (#2093) This change follows up on #2075 by splitting unary_compiler.py and its unit test file into smaller files. --- bigframes/core/compile/sqlglot/__init__.py | 13 +- .../compile/sqlglot/expressions/array_ops.py | 68 ++ .../compile/sqlglot/expressions/blob_ops.py | 33 + .../sqlglot/expressions/comparison_ops.py | 59 ++ .../compile/sqlglot/expressions/date_ops.py | 61 ++ .../sqlglot/expressions/datetime_ops.py | 99 ++ .../sqlglot/expressions/generic_ops.py | 55 + .../compile/sqlglot/expressions/geo_ops.py | 84 ++ .../compile/sqlglot/expressions/json_ops.py | 68 ++ .../sqlglot/expressions/numeric_ops.py | 240 +++++ .../compile/sqlglot/expressions/string_ops.py | 304 ++++++ .../compile/sqlglot/expressions/struct_ops.py | 42 + .../sqlglot/expressions/timedelta_ops.py | 38 + .../sqlglot/expressions/unary_compiler.py | 892 ---------------- bigframes/testing/utils.py | 22 +- .../test_array_index/out.sql | 0 .../test_array_slice_with_only_start/out.sql | 0 .../out.sql | 0 .../test_array_to_string/out.sql | 0 .../test_floordiv_numeric/out.sql | 154 --- .../test_obj_fetch_metadata/out.sql | 0 .../test_obj_get_access_url/out.sql | 0 .../test_is_in/out.sql | 0 .../test_date/out.sql | 0 .../test_day/out.sql | 0 .../test_dayofweek/out.sql | 0 .../test_dayofyear/out.sql | 0 .../test_floor_dt/out.sql | 0 .../test_hour/out.sql | 0 .../test_iso_day/out.sql | 0 .../test_iso_week/out.sql | 0 .../test_iso_year/out.sql | 0 .../test_minute/out.sql | 0 .../test_month/out.sql | 0 .../test_normalize/out.sql | 0 .../test_quarter/out.sql | 0 .../test_second/out.sql | 0 .../test_strftime/out.sql | 0 .../test_time/out.sql | 0 .../test_to_datetime/out.sql | 0 .../test_to_timestamp/out.sql | 0 .../test_unix_micros/out.sql | 0 .../test_unix_millis/out.sql | 0 .../test_unix_seconds/out.sql | 0 .../test_year/out.sql | 0 .../test_hash/out.sql | 0 .../test_isnull/out.sql | 0 .../test_map/out.sql | 0 .../test_notnull/out.sql | 0 .../test_geo_area/out.sql | 0 .../test_geo_st_astext/out.sql | 0 .../test_geo_st_boundary/out.sql | 0 .../test_geo_st_buffer/out.sql | 0 .../test_geo_st_centroid/out.sql | 0 .../test_geo_st_convexhull/out.sql | 0 .../test_geo_st_geogfromtext/out.sql | 0 .../test_geo_st_isclosed/out.sql | 0 .../test_geo_st_length/out.sql | 0 .../test_geo_x/out.sql | 0 .../test_geo_y/out.sql | 0 .../test_json_extract/out.sql | 0 .../test_json_extract_array/out.sql | 0 .../test_json_extract_string_array/out.sql | 0 .../test_json_query/out.sql | 0 .../test_json_query_array/out.sql | 0 .../test_json_value/out.sql | 0 .../test_parse_json/out.sql | 0 .../test_to_json_string/out.sql | 0 .../test_abs/out.sql | 0 .../test_arccos/out.sql | 0 .../test_arccosh/out.sql | 0 .../test_arcsin/out.sql | 0 .../test_arcsinh/out.sql | 0 .../test_arctan/out.sql | 0 .../test_arctanh/out.sql | 0 .../test_ceil/out.sql | 0 .../test_cos/out.sql | 0 .../test_cosh/out.sql | 0 .../test_exp/out.sql | 0 .../test_expm1/out.sql | 0 .../test_floor/out.sql | 0 .../test_invert/out.sql | 0 .../test_ln/out.sql | 0 .../test_log10/out.sql | 0 .../test_log1p/out.sql | 0 .../test_neg/out.sql | 0 .../test_pos/out.sql | 0 .../test_sin/out.sql | 0 .../test_sinh/out.sql | 0 .../test_sqrt/out.sql | 0 .../test_tan/out.sql | 0 .../test_tanh/out.sql | 0 .../test_capitalize/out.sql | 0 .../test_endswith/out.sql | 0 .../test_isalnum/out.sql | 0 .../test_isalpha/out.sql | 0 .../test_isdecimal/out.sql | 0 .../test_isdigit/out.sql | 0 .../test_islower/out.sql | 0 .../test_isnumeric/out.sql | 0 .../test_isspace/out.sql | 0 .../test_isupper/out.sql | 0 .../test_len/out.sql | 0 .../test_lower/out.sql | 0 .../test_lstrip/out.sql | 0 .../test_regex_replace_str/out.sql | 0 .../test_replace_str/out.sql | 0 .../test_reverse/out.sql | 0 .../test_rstrip/out.sql | 0 .../test_startswith/out.sql | 0 .../test_str_contains/out.sql | 0 .../test_str_contains_regex/out.sql | 0 .../test_str_extract/out.sql | 0 .../test_str_find/out.sql | 0 .../test_str_get/out.sql | 0 .../test_str_pad/out.sql | 0 .../test_str_repeat/out.sql | 0 .../test_str_slice/out.sql | 0 .../test_string_split/out.sql | 0 .../test_strip/out.sql | 0 .../test_upper/out.sql | 0 .../test_zfill/out.sql | 0 .../test_struct_field/out.sql | 0 .../test_timedelta_floor/out.sql | 0 .../test_to_timedelta/out.sql | 0 .../out.sql | 16 - .../test_compile_string_add/out.sql | 16 - .../sqlglot/expressions/test_array_ops.py | 62 ++ .../sqlglot/expressions/test_blob_ops.py | 31 + .../expressions/test_comparison_ops.py | 44 + .../sqlglot/expressions/test_datetime_ops.py | 217 ++++ .../sqlglot/expressions/test_generic_ops.py | 57 + .../sqlglot/expressions/test_geo_ops.py | 125 +++ .../sqlglot/expressions/test_json_ops.py | 99 ++ .../sqlglot/expressions/test_numeric_ops.py | 213 ++++ .../sqlglot/expressions/test_string_ops.py | 305 ++++++ .../sqlglot/expressions/test_struct_ops.py | 36 + .../sqlglot/expressions/test_timedelta_ops.py | 40 + .../expressions/test_unary_compiler.py | 998 ------------------ 139 files changed, 2413 insertions(+), 2078 deletions(-) create mode 100644 bigframes/core/compile/sqlglot/expressions/array_ops.py create mode 100644 bigframes/core/compile/sqlglot/expressions/blob_ops.py create mode 100644 bigframes/core/compile/sqlglot/expressions/comparison_ops.py create mode 100644 bigframes/core/compile/sqlglot/expressions/date_ops.py create mode 100644 bigframes/core/compile/sqlglot/expressions/datetime_ops.py create mode 100644 bigframes/core/compile/sqlglot/expressions/generic_ops.py create mode 100644 bigframes/core/compile/sqlglot/expressions/geo_ops.py create mode 100644 bigframes/core/compile/sqlglot/expressions/json_ops.py create mode 100644 bigframes/core/compile/sqlglot/expressions/numeric_ops.py create mode 100644 bigframes/core/compile/sqlglot/expressions/string_ops.py create mode 100644 bigframes/core/compile/sqlglot/expressions/struct_ops.py create mode 100644 bigframes/core/compile/sqlglot/expressions/timedelta_ops.py delete mode 100644 bigframes/core/compile/sqlglot/expressions/unary_compiler.py rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_array_ops}/test_array_index/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_array_ops}/test_array_slice_with_only_start/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_array_ops}/test_array_slice_with_start_and_stop/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_array_ops}/test_array_to_string/out.sql (100%) delete mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_numeric/out.sql rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_blob_ops}/test_obj_fetch_metadata/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_blob_ops}/test_obj_get_access_url/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_comparison_ops}/test_is_in/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_date/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_day/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_dayofweek/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_dayofyear/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_floor_dt/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_hour/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_iso_day/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_iso_week/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_iso_year/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_minute/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_month/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_normalize/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_quarter/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_second/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_strftime/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_time/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_to_datetime/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_to_timestamp/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_unix_micros/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_unix_millis/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_unix_seconds/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_datetime_ops}/test_year/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_generic_ops}/test_hash/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_generic_ops}/test_isnull/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_generic_ops}/test_map/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_generic_ops}/test_notnull/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_geo_ops}/test_geo_area/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_geo_ops}/test_geo_st_astext/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_geo_ops}/test_geo_st_boundary/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_geo_ops}/test_geo_st_buffer/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_geo_ops}/test_geo_st_centroid/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_geo_ops}/test_geo_st_convexhull/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_geo_ops}/test_geo_st_geogfromtext/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_geo_ops}/test_geo_st_isclosed/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_geo_ops}/test_geo_st_length/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_geo_ops}/test_geo_x/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_geo_ops}/test_geo_y/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_json_ops}/test_json_extract/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_json_ops}/test_json_extract_array/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_json_ops}/test_json_extract_string_array/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_json_ops}/test_json_query/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_json_ops}/test_json_query_array/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_json_ops}/test_json_value/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_json_ops}/test_parse_json/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_json_ops}/test_to_json_string/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_abs/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_arccos/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_arccosh/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_arcsin/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_arcsinh/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_arctan/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_arctanh/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_ceil/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_cos/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_cosh/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_exp/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_expm1/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_floor/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_invert/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_ln/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_log10/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_log1p/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_neg/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_pos/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_sin/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_sinh/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_sqrt/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_tan/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_numeric_ops}/test_tanh/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_capitalize/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_endswith/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_isalnum/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_isalpha/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_isdecimal/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_isdigit/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_islower/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_isnumeric/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_isspace/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_isupper/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_len/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_lower/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_lstrip/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_regex_replace_str/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_replace_str/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_reverse/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_rstrip/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_startswith/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_str_contains/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_str_contains_regex/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_str_extract/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_str_find/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_str_get/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_str_pad/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_str_repeat/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_str_slice/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_string_split/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_strip/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_upper/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_string_ops}/test_zfill/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_struct_ops}/test_struct_field/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_timedelta_ops}/test_timedelta_floor/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_unary_compiler => test_timedelta_ops}/test_to_timedelta/out.sql (100%) delete mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_compile_numerical_add_w_scalar/out.sql delete mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_compile_string_add/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/test_array_ops.py create mode 100644 tests/unit/core/compile/sqlglot/expressions/test_blob_ops.py create mode 100644 tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py create mode 100644 tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py create mode 100644 tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py create mode 100644 tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py create mode 100644 tests/unit/core/compile/sqlglot/expressions/test_json_ops.py create mode 100644 tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py create mode 100644 tests/unit/core/compile/sqlglot/expressions/test_string_ops.py create mode 100644 tests/unit/core/compile/sqlglot/expressions/test_struct_ops.py create mode 100644 tests/unit/core/compile/sqlglot/expressions/test_timedelta_ops.py delete mode 100644 tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py diff --git a/bigframes/core/compile/sqlglot/__init__.py b/bigframes/core/compile/sqlglot/__init__.py index 8a1172b704..5fe8099043 100644 --- a/bigframes/core/compile/sqlglot/__init__.py +++ b/bigframes/core/compile/sqlglot/__init__.py @@ -14,7 +14,18 @@ from __future__ import annotations from bigframes.core.compile.sqlglot.compiler import SQLGlotCompiler +import bigframes.core.compile.sqlglot.expressions.array_ops # noqa: F401 import bigframes.core.compile.sqlglot.expressions.binary_compiler # noqa: F401 -import bigframes.core.compile.sqlglot.expressions.unary_compiler # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.blob_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.comparison_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.date_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.datetime_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.generic_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.geo_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.json_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.numeric_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.string_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.struct_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.timedelta_ops # noqa: F401 __all__ = ["SQLGlotCompiler"] diff --git a/bigframes/core/compile/sqlglot/expressions/array_ops.py b/bigframes/core/compile/sqlglot/expressions/array_ops.py new file mode 100644 index 0000000000..57ff2ee459 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/array_ops.py @@ -0,0 +1,68 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import typing + +import sqlglot +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op + + +@register_unary_op(ops.ArrayToStringOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ArrayToStringOp) -> sge.Expression: + return sge.ArrayToString(this=expr.expr, expression=f"'{op.delimiter}'") + + +@register_unary_op(ops.ArrayIndexOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ArrayIndexOp) -> sge.Expression: + return sge.Bracket( + this=expr.expr, + expressions=[sge.Literal.number(op.index)], + safe=True, + offset=False, + ) + + +@register_unary_op(ops.ArraySliceOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ArraySliceOp) -> sge.Expression: + slice_idx = sqlglot.to_identifier("slice_idx") + + conditions: typing.List[sge.Predicate] = [slice_idx >= op.start] + + if op.stop is not None: + conditions.append(slice_idx < op.stop) + + # local name for each element in the array + el = sqlglot.to_identifier("el") + + selected_elements = ( + sge.select(el) + .from_( + sge.Unnest( + expressions=[expr.expr], + alias=sge.TableAlias(columns=[el]), + offset=slice_idx, + ) + ) + .where(*conditions) + ) + + return sge.array(selected_elements) diff --git a/bigframes/core/compile/sqlglot/expressions/blob_ops.py b/bigframes/core/compile/sqlglot/expressions/blob_ops.py new file mode 100644 index 0000000000..58f905087d --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/blob_ops.py @@ -0,0 +1,33 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op + + +@register_unary_op(ops.obj_fetch_metadata_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("OBJ.FETCH_METADATA", expr.expr) + + +@register_unary_op(ops.ObjGetAccessUrl) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("OBJ.GET_ACCESS_URL", expr.expr) diff --git a/bigframes/core/compile/sqlglot/expressions/comparison_ops.py b/bigframes/core/compile/sqlglot/expressions/comparison_ops.py new file mode 100644 index 0000000000..3bf94cf8ab --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/comparison_ops.py @@ -0,0 +1,59 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import typing + +import pandas as pd +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler +import bigframes.dtypes as dtypes + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op + + +@register_unary_op(ops.IsInOp, pass_op=True) +def _(expr: TypedExpr, op: ops.IsInOp) -> sge.Expression: + values = [] + is_numeric_expr = dtypes.is_numeric(expr.dtype) + for value in op.values: + if value is None: + continue + dtype = dtypes.bigframes_type(type(value)) + if expr.dtype == dtype or is_numeric_expr and dtypes.is_numeric(dtype): + values.append(sge.convert(value)) + + if op.match_nulls: + contains_nulls = any(_is_null(value) for value in op.values) + if contains_nulls: + return sge.Is(this=expr.expr, expression=sge.Null()) | sge.In( + this=expr.expr, expressions=values + ) + + if len(values) == 0: + return sge.convert(False) + + return sge.func( + "COALESCE", sge.In(this=expr.expr, expressions=values), sge.convert(False) + ) + + +# Helpers +def _is_null(value) -> bool: + # float NaN/inf should be treated as distinct from 'true' null values + return typing.cast(bool, pd.isna(value)) and not isinstance(value, float) diff --git a/bigframes/core/compile/sqlglot/expressions/date_ops.py b/bigframes/core/compile/sqlglot/expressions/date_ops.py new file mode 100644 index 0000000000..f5922ecc8d --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/date_ops.py @@ -0,0 +1,61 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op + + +@register_unary_op(ops.date_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Date(this=expr.expr) + + +@register_unary_op(ops.day_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="DAY"), expression=expr.expr) + + +@register_unary_op(ops.dayofweek_op) +def _(expr: TypedExpr) -> sge.Expression: + # Adjust the 1-based day-of-week index (from SQL) to a 0-based index. + return sge.Extract( + this=sge.Identifier(this="DAYOFWEEK"), expression=expr.expr + ) - sge.convert(1) + + +@register_unary_op(ops.dayofyear_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="DAYOFYEAR"), expression=expr.expr) + + +@register_unary_op(ops.iso_day_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="DAYOFWEEK"), expression=expr.expr) + + +@register_unary_op(ops.iso_week_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="ISOWEEK"), expression=expr.expr) + + +@register_unary_op(ops.iso_year_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="ISOYEAR"), expression=expr.expr) diff --git a/bigframes/core/compile/sqlglot/expressions/datetime_ops.py b/bigframes/core/compile/sqlglot/expressions/datetime_ops.py new file mode 100644 index 0000000000..77f4233e1c --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/datetime_ops.py @@ -0,0 +1,99 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op + + +@register_unary_op(ops.FloorDtOp, pass_op=True) +def _(expr: TypedExpr, op: ops.FloorDtOp) -> sge.Expression: + # TODO: Remove this method when it is covered by ops.FloorOp + return sge.TimestampTrunc(this=expr.expr, unit=sge.Identifier(this=op.freq)) + + +@register_unary_op(ops.hour_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="HOUR"), expression=expr.expr) + + +@register_unary_op(ops.minute_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="MINUTE"), expression=expr.expr) + + +@register_unary_op(ops.month_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="MONTH"), expression=expr.expr) + + +@register_unary_op(ops.normalize_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.TimestampTrunc(this=expr.expr, unit=sge.Identifier(this="DAY")) + + +@register_unary_op(ops.quarter_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="QUARTER"), expression=expr.expr) + + +@register_unary_op(ops.second_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="SECOND"), expression=expr.expr) + + +@register_unary_op(ops.StrftimeOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrftimeOp) -> sge.Expression: + return sge.func("FORMAT_TIMESTAMP", sge.convert(op.date_format), expr.expr) + + +@register_unary_op(ops.time_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("TIME", expr.expr) + + +@register_unary_op(ops.ToDatetimeOp) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Cast(this=sge.func("TIMESTAMP_SECONDS", expr.expr), to="DATETIME") + + +@register_unary_op(ops.ToTimestampOp) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("TIMESTAMP_SECONDS", expr.expr) + + +@register_unary_op(ops.UnixMicros) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("UNIX_MICROS", expr.expr) + + +@register_unary_op(ops.UnixMillis) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("UNIX_MILLIS", expr.expr) + + +@register_unary_op(ops.UnixSeconds, pass_op=True) +def _(expr: TypedExpr, op: ops.UnixSeconds) -> sge.Expression: + return sge.func("UNIX_SECONDS", expr.expr) + + +@register_unary_op(ops.year_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="YEAR"), expression=expr.expr) diff --git a/bigframes/core/compile/sqlglot/expressions/generic_ops.py b/bigframes/core/compile/sqlglot/expressions/generic_ops.py new file mode 100644 index 0000000000..5ee4ede94a --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/generic_ops.py @@ -0,0 +1,55 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op + + +@register_unary_op(ops.AsTypeOp, pass_op=True) +def _(expr: TypedExpr, op: ops.AsTypeOp) -> sge.Expression: + # TODO: Support more types for casting, such as JSON, etc. + return sge.Cast(this=expr.expr, to=op.to_type) + + +@register_unary_op(ops.hash_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("FARM_FINGERPRINT", expr.expr) + + +@register_unary_op(ops.isnull_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Is(this=expr.expr, expression=sge.Null()) + + +@register_unary_op(ops.MapOp, pass_op=True) +def _(expr: TypedExpr, op: ops.MapOp) -> sge.Expression: + return sge.Case( + this=expr.expr, + ifs=[ + sge.If(this=sge.convert(key), true=sge.convert(value)) + for key, value in op.mappings + ], + ) + + +@register_unary_op(ops.notnull_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Not(this=sge.Is(this=expr.expr, expression=sge.Null())) diff --git a/bigframes/core/compile/sqlglot/expressions/geo_ops.py b/bigframes/core/compile/sqlglot/expressions/geo_ops.py new file mode 100644 index 0000000000..53a50fab47 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/geo_ops.py @@ -0,0 +1,84 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op + + +@register_unary_op(ops.geo_area_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("ST_AREA", expr.expr) + + +@register_unary_op(ops.geo_st_astext_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("ST_ASTEXT", expr.expr) + + +@register_unary_op(ops.geo_st_boundary_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("ST_BOUNDARY", expr.expr) + + +@register_unary_op(ops.GeoStBufferOp, pass_op=True) +def _(expr: TypedExpr, op: ops.GeoStBufferOp) -> sge.Expression: + return sge.func( + "ST_BUFFER", + expr.expr, + sge.convert(op.buffer_radius), + sge.convert(op.num_seg_quarter_circle), + sge.convert(op.use_spheroid), + ) + + +@register_unary_op(ops.geo_st_centroid_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("ST_CENTROID", expr.expr) + + +@register_unary_op(ops.geo_st_convexhull_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("ST_CONVEXHULL", expr.expr) + + +@register_unary_op(ops.geo_st_geogfromtext_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("SAFE.ST_GEOGFROMTEXT", expr.expr) + + +@register_unary_op(ops.geo_st_isclosed_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("ST_ISCLOSED", expr.expr) + + +@register_unary_op(ops.GeoStLengthOp, pass_op=True) +def _(expr: TypedExpr, op: ops.GeoStLengthOp) -> sge.Expression: + return sge.func("ST_LENGTH", expr.expr) + + +@register_unary_op(ops.geo_x_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("SAFE.ST_X", expr.expr) + + +@register_unary_op(ops.geo_y_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("SAFE.ST_Y", expr.expr) diff --git a/bigframes/core/compile/sqlglot/expressions/json_ops.py b/bigframes/core/compile/sqlglot/expressions/json_ops.py new file mode 100644 index 0000000000..754e8d80eb --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/json_ops.py @@ -0,0 +1,68 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op + + +@register_unary_op(ops.JSONExtract, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONExtract) -> sge.Expression: + return sge.func("JSON_EXTRACT", expr.expr, sge.convert(op.json_path)) + + +@register_unary_op(ops.JSONExtractArray, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONExtractArray) -> sge.Expression: + return sge.func("JSON_EXTRACT_ARRAY", expr.expr, sge.convert(op.json_path)) + + +@register_unary_op(ops.JSONExtractStringArray, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONExtractStringArray) -> sge.Expression: + return sge.func("JSON_EXTRACT_STRING_ARRAY", expr.expr, sge.convert(op.json_path)) + + +@register_unary_op(ops.JSONQuery, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONQuery) -> sge.Expression: + return sge.func("JSON_QUERY", expr.expr, sge.convert(op.json_path)) + + +@register_unary_op(ops.JSONQueryArray, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONQueryArray) -> sge.Expression: + return sge.func("JSON_QUERY_ARRAY", expr.expr, sge.convert(op.json_path)) + + +@register_unary_op(ops.JSONValue, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONValue) -> sge.Expression: + return sge.func("JSON_VALUE", expr.expr, sge.convert(op.json_path)) + + +@register_unary_op(ops.JSONValueArray, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONValueArray) -> sge.Expression: + return sge.func("JSON_VALUE_ARRAY", expr.expr, sge.convert(op.json_path)) + + +@register_unary_op(ops.ParseJSON) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("PARSE_JSON", expr.expr) + + +@register_unary_op(ops.ToJSONString) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("TO_JSON_STRING", expr.expr) diff --git a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py new file mode 100644 index 0000000000..09c08e2095 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py @@ -0,0 +1,240 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import operations as ops +import bigframes.core.compile.sqlglot.expressions.constants as constants +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op + + +@register_unary_op(ops.abs_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Abs(this=expr.expr) + + +@register_unary_op(ops.arccosh_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=expr.expr < sge.convert(1), + true=constants._NAN, + ) + ], + default=sge.func("ACOSH", expr.expr), + ) + + +@register_unary_op(ops.arccos_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=sge.func("ABS", expr.expr) > sge.convert(1), + true=constants._NAN, + ) + ], + default=sge.func("ACOS", expr.expr), + ) + + +@register_unary_op(ops.arcsin_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=sge.func("ABS", expr.expr) > sge.convert(1), + true=constants._NAN, + ) + ], + default=sge.func("ASIN", expr.expr), + ) + + +@register_unary_op(ops.arcsinh_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("ASINH", expr.expr) + + +@register_unary_op(ops.arctan_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("ATAN", expr.expr) + + +@register_unary_op(ops.arctanh_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=sge.func("ABS", expr.expr) > sge.convert(1), + true=constants._NAN, + ) + ], + default=sge.func("ATANH", expr.expr), + ) + + +@register_unary_op(ops.ceil_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Ceil(this=expr.expr) + + +@register_unary_op(ops.cos_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("COS", expr.expr) + + +@register_unary_op(ops.cosh_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=sge.func("ABS", expr.expr) > sge.convert(709.78), + true=constants._INF, + ) + ], + default=sge.func("COSH", expr.expr), + ) + + +@register_unary_op(ops.exp_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=expr.expr > constants._FLOAT64_EXP_BOUND, + true=constants._INF, + ) + ], + default=sge.func("EXP", expr.expr), + ) + + +@register_unary_op(ops.expm1_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=expr.expr > constants._FLOAT64_EXP_BOUND, + true=constants._INF, + ) + ], + default=sge.func("EXP", expr.expr), + ) - sge.convert(1) + + +@register_unary_op(ops.floor_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Floor(this=expr.expr) + + +@register_unary_op(ops.invert_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.BitwiseNot(this=expr.expr) + + +@register_unary_op(ops.ln_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=expr.expr < sge.convert(0), + true=constants._NAN, + ) + ], + default=sge.Ln(this=expr.expr), + ) + + +@register_unary_op(ops.log10_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=expr.expr < sge.convert(0), + true=constants._NAN, + ) + ], + default=sge.Log(this=expr.expr, expression=sge.convert(10)), + ) + + +@register_unary_op(ops.log1p_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=expr.expr < sge.convert(-1), + true=constants._NAN, + ) + ], + default=sge.Ln(this=sge.convert(1) + expr.expr), + ) + + +@register_unary_op(ops.neg_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Neg(this=expr.expr) + + +@register_unary_op(ops.pos_op) +def _(expr: TypedExpr) -> sge.Expression: + return expr.expr + + +@register_unary_op(ops.sqrt_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=expr.expr < sge.convert(0), + true=constants._NAN, + ) + ], + default=sge.Sqrt(this=expr.expr), + ) + + +@register_unary_op(ops.sin_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("SIN", expr.expr) + + +@register_unary_op(ops.sinh_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=sge.func("ABS", expr.expr) > constants._FLOAT64_EXP_BOUND, + true=sge.func("SIGN", expr.expr) * constants._INF, + ) + ], + default=sge.func("SINH", expr.expr), + ) + + +@register_unary_op(ops.tan_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("TAN", expr.expr) + + +@register_unary_op(ops.tanh_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("TANH", expr.expr) diff --git a/bigframes/core/compile/sqlglot/expressions/string_ops.py b/bigframes/core/compile/sqlglot/expressions/string_ops.py new file mode 100644 index 0000000000..403cf403f5 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/string_ops.py @@ -0,0 +1,304 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import functools + +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op + + +@register_unary_op(ops.capitalize_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Initcap(this=expr.expr) + + +@register_unary_op(ops.StrContainsOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrContainsOp) -> sge.Expression: + return sge.Like(this=expr.expr, expression=sge.convert(f"%{op.pat}%")) + + +@register_unary_op(ops.StrContainsRegexOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrContainsRegexOp) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(op.pat)) + + +@register_unary_op(ops.StrExtractOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrExtractOp) -> sge.Expression: + return sge.RegexpExtract( + this=expr.expr, expression=sge.convert(op.pat), group=sge.convert(op.n) + ) + + +@register_unary_op(ops.StrFindOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrFindOp) -> sge.Expression: + # INSTR is 1-based, so we need to adjust the start position. + start = sge.convert(op.start + 1) if op.start is not None else sge.convert(1) + if op.end is not None: + # BigQuery's INSTR doesn't support `end`, so we need to use SUBSTR. + return sge.func( + "INSTR", + sge.Substring( + this=expr.expr, + start=start, + length=sge.convert(op.end - (op.start or 0)), + ), + sge.convert(op.substr), + ) - sge.convert(1) + else: + return sge.func( + "INSTR", + expr.expr, + sge.convert(op.substr), + start, + ) - sge.convert(1) + + +@register_unary_op(ops.StrLstripOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrLstripOp) -> sge.Expression: + return sge.Trim(this=expr.expr, expression=sge.convert(op.to_strip), side="LEFT") + + +@register_unary_op(ops.StrPadOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrPadOp) -> sge.Expression: + pad_length = sge.func( + "GREATEST", sge.Length(this=expr.expr), sge.convert(op.length) + ) + if op.side == "left": + return sge.func( + "LPAD", + expr.expr, + pad_length, + sge.convert(op.fillchar), + ) + elif op.side == "right": + return sge.func( + "RPAD", + expr.expr, + pad_length, + sge.convert(op.fillchar), + ) + else: # side == both + lpad_amount = sge.Cast( + this=sge.func( + "SAFE_DIVIDE", + sge.Sub(this=pad_length, expression=sge.Length(this=expr.expr)), + sge.convert(2), + ), + to="INT64", + ) + sge.Length(this=expr.expr) + return sge.func( + "RPAD", + sge.func( + "LPAD", + expr.expr, + lpad_amount, + sge.convert(op.fillchar), + ), + pad_length, + sge.convert(op.fillchar), + ) + + +@register_unary_op(ops.StrRepeatOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrRepeatOp) -> sge.Expression: + return sge.Repeat(this=expr.expr, times=sge.convert(op.repeats)) + + +@register_unary_op(ops.EndsWithOp, pass_op=True) +def _(expr: TypedExpr, op: ops.EndsWithOp) -> sge.Expression: + if not op.pat: + return sge.false() + + def to_endswith(pat: str) -> sge.Expression: + return sge.func("ENDS_WITH", expr.expr, sge.convert(pat)) + + conditions = [to_endswith(pat) for pat in op.pat] + return functools.reduce(lambda x, y: sge.Or(this=x, expression=y), conditions) + + +@register_unary_op(ops.isalnum_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^(\p{N}|\p{L})+$")) + + +@register_unary_op(ops.isalpha_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\p{L}+$")) + + +@register_unary_op(ops.isdecimal_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\d+$")) + + +@register_unary_op(ops.isdigit_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\p{Nd}+$")) + + +@register_unary_op(ops.islower_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.And( + this=sge.EQ( + this=sge.Lower(this=expr.expr), + expression=expr.expr, + ), + expression=sge.NEQ( + this=sge.Upper(this=expr.expr), + expression=expr.expr, + ), + ) + + +@register_unary_op(ops.isnumeric_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\pN+$")) + + +@register_unary_op(ops.isspace_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\s+$")) + + +@register_unary_op(ops.isupper_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.And( + this=sge.EQ( + this=sge.Upper(this=expr.expr), + expression=expr.expr, + ), + expression=sge.NEQ( + this=sge.Lower(this=expr.expr), + expression=expr.expr, + ), + ) + + +@register_unary_op(ops.len_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Length(this=expr.expr) + + +@register_unary_op(ops.lower_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Lower(this=expr.expr) + + +@register_unary_op(ops.ReplaceStrOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ReplaceStrOp) -> sge.Expression: + return sge.func("REPLACE", expr.expr, sge.convert(op.pat), sge.convert(op.repl)) + + +@register_unary_op(ops.RegexReplaceStrOp, pass_op=True) +def _(expr: TypedExpr, op: ops.RegexReplaceStrOp) -> sge.Expression: + return sge.func( + "REGEXP_REPLACE", expr.expr, sge.convert(op.pat), sge.convert(op.repl) + ) + + +@register_unary_op(ops.reverse_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("REVERSE", expr.expr) + + +@register_unary_op(ops.StrRstripOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrRstripOp) -> sge.Expression: + return sge.Trim(this=expr.expr, expression=sge.convert(op.to_strip), side="RIGHT") + + +@register_unary_op(ops.StartsWithOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StartsWithOp) -> sge.Expression: + if not op.pat: + return sge.false() + + def to_startswith(pat: str) -> sge.Expression: + return sge.func("STARTS_WITH", expr.expr, sge.convert(pat)) + + conditions = [to_startswith(pat) for pat in op.pat] + return functools.reduce(lambda x, y: sge.Or(this=x, expression=y), conditions) + + +@register_unary_op(ops.StrStripOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrStripOp) -> sge.Expression: + return sge.Trim(this=sge.convert(op.to_strip), expression=expr.expr) + + +@register_unary_op(ops.StringSplitOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StringSplitOp) -> sge.Expression: + return sge.Split(this=expr.expr, expression=sge.convert(op.pat)) + + +@register_unary_op(ops.StrGetOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrGetOp) -> sge.Expression: + return sge.Substring( + this=expr.expr, + start=sge.convert(op.i + 1), + length=sge.convert(1), + ) + + +@register_unary_op(ops.StrSliceOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrSliceOp) -> sge.Expression: + start = op.start + 1 if op.start is not None else None + if op.end is None: + length = None + elif op.start is None: + length = op.end + else: + length = op.end - op.start + return sge.Substring( + this=expr.expr, + start=sge.convert(start) if start is not None else None, + length=sge.convert(length) if length is not None else None, + ) + + +@register_unary_op(ops.upper_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Upper(this=expr.expr) + + +@register_unary_op(ops.ZfillOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ZfillOp) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=sge.EQ( + this=sge.Substring( + this=expr.expr, start=sge.convert(1), length=sge.convert(1) + ), + expression=sge.convert("-"), + ), + true=sge.Concat( + expressions=[ + sge.convert("-"), + sge.func( + "LPAD", + sge.Substring(this=expr.expr, start=sge.convert(1)), + sge.convert(op.width - 1), + sge.convert("0"), + ), + ] + ), + ) + ], + default=sge.func("LPAD", expr.expr, sge.convert(op.width), sge.convert("0")), + ) diff --git a/bigframes/core/compile/sqlglot/expressions/struct_ops.py b/bigframes/core/compile/sqlglot/expressions/struct_ops.py new file mode 100644 index 0000000000..ebd3a38397 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/struct_ops.py @@ -0,0 +1,42 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import typing + +import pandas as pd +import pyarrow as pa +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op + + +@register_unary_op(ops.StructFieldOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StructFieldOp) -> sge.Expression: + if isinstance(op.name_or_index, str): + name = op.name_or_index + else: + pa_type = typing.cast(pd.ArrowDtype, expr.dtype) + pa_struct_type = typing.cast(pa.StructType, pa_type.pyarrow_dtype) + name = pa_struct_type.field(op.name_or_index).name + + return sge.Column( + this=sge.to_identifier(name, quoted=True), + catalog=expr.expr, + ) diff --git a/bigframes/core/compile/sqlglot/expressions/timedelta_ops.py b/bigframes/core/compile/sqlglot/expressions/timedelta_ops.py new file mode 100644 index 0000000000..667c828b13 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/timedelta_ops.py @@ -0,0 +1,38 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.constants import UNIT_TO_US_CONVERSION_FACTORS +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op + + +@register_unary_op(ops.timedelta_floor_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Floor(this=expr.expr) + + +@register_unary_op(ops.ToTimedeltaOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ToTimedeltaOp) -> sge.Expression: + value = expr.expr + factor = UNIT_TO_US_CONVERSION_FACTORS[op.unit] + if factor != 1: + value = sge.Mul(this=value, expression=sge.convert(factor)) + return value diff --git a/bigframes/core/compile/sqlglot/expressions/unary_compiler.py b/bigframes/core/compile/sqlglot/expressions/unary_compiler.py deleted file mode 100644 index d93b1e681c..0000000000 --- a/bigframes/core/compile/sqlglot/expressions/unary_compiler.py +++ /dev/null @@ -1,892 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import annotations - -import functools -import typing - -import pandas as pd -import pyarrow as pa -import sqlglot -import sqlglot.expressions as sge - -from bigframes import operations as ops -from bigframes.core.compile.constants import UNIT_TO_US_CONVERSION_FACTORS -import bigframes.core.compile.sqlglot.expressions.constants as constants -from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr -import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler -import bigframes.dtypes as dtypes - -register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op - - -@register_unary_op(ops.abs_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Abs(this=expr.expr) - - -@register_unary_op(ops.arccosh_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Case( - ifs=[ - sge.If( - this=expr.expr < sge.convert(1), - true=constants._NAN, - ) - ], - default=sge.func("ACOSH", expr.expr), - ) - - -@register_unary_op(ops.arccos_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Case( - ifs=[ - sge.If( - this=sge.func("ABS", expr.expr) > sge.convert(1), - true=constants._NAN, - ) - ], - default=sge.func("ACOS", expr.expr), - ) - - -@register_unary_op(ops.arcsin_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Case( - ifs=[ - sge.If( - this=sge.func("ABS", expr.expr) > sge.convert(1), - true=constants._NAN, - ) - ], - default=sge.func("ASIN", expr.expr), - ) - - -@register_unary_op(ops.arcsinh_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("ASINH", expr.expr) - - -@register_unary_op(ops.arctan_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("ATAN", expr.expr) - - -@register_unary_op(ops.arctanh_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Case( - ifs=[ - sge.If( - this=sge.func("ABS", expr.expr) > sge.convert(1), - true=constants._NAN, - ) - ], - default=sge.func("ATANH", expr.expr), - ) - - -@register_unary_op(ops.AsTypeOp, pass_op=True) -def _(expr: TypedExpr, op: ops.AsTypeOp) -> sge.Expression: - # TODO: Support more types for casting, such as JSON, etc. - return sge.Cast(this=expr.expr, to=op.to_type) - - -@register_unary_op(ops.ArrayToStringOp, pass_op=True) -def _(expr: TypedExpr, op: ops.ArrayToStringOp) -> sge.Expression: - return sge.ArrayToString(this=expr.expr, expression=f"'{op.delimiter}'") - - -@register_unary_op(ops.ArrayIndexOp, pass_op=True) -def _(expr: TypedExpr, op: ops.ArrayIndexOp) -> sge.Expression: - return sge.Bracket( - this=expr.expr, - expressions=[sge.Literal.number(op.index)], - safe=True, - offset=False, - ) - - -@register_unary_op(ops.ArraySliceOp, pass_op=True) -def _(expr: TypedExpr, op: ops.ArraySliceOp) -> sge.Expression: - slice_idx = sqlglot.to_identifier("slice_idx") - - conditions: typing.List[sge.Predicate] = [slice_idx >= op.start] - - if op.stop is not None: - conditions.append(slice_idx < op.stop) - - # local name for each element in the array - el = sqlglot.to_identifier("el") - - selected_elements = ( - sge.select(el) - .from_( - sge.Unnest( - expressions=[expr.expr], - alias=sge.TableAlias(columns=[el]), - offset=slice_idx, - ) - ) - .where(*conditions) - ) - - return sge.array(selected_elements) - - -@register_unary_op(ops.capitalize_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Initcap(this=expr.expr) - - -@register_unary_op(ops.ceil_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Ceil(this=expr.expr) - - -@register_unary_op(ops.cos_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("COS", expr.expr) - - -@register_unary_op(ops.cosh_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Case( - ifs=[ - sge.If( - this=sge.func("ABS", expr.expr) > sge.convert(709.78), - true=constants._INF, - ) - ], - default=sge.func("COSH", expr.expr), - ) - - -@register_unary_op(ops.StrContainsOp, pass_op=True) -def _(expr: TypedExpr, op: ops.StrContainsOp) -> sge.Expression: - return sge.Like(this=expr.expr, expression=sge.convert(f"%{op.pat}%")) - - -@register_unary_op(ops.StrContainsRegexOp, pass_op=True) -def _(expr: TypedExpr, op: ops.StrContainsRegexOp) -> sge.Expression: - return sge.RegexpLike(this=expr.expr, expression=sge.convert(op.pat)) - - -@register_unary_op(ops.StrExtractOp, pass_op=True) -def _(expr: TypedExpr, op: ops.StrExtractOp) -> sge.Expression: - return sge.RegexpExtract( - this=expr.expr, expression=sge.convert(op.pat), group=sge.convert(op.n) - ) - - -@register_unary_op(ops.StrFindOp, pass_op=True) -def _(expr: TypedExpr, op: ops.StrFindOp) -> sge.Expression: - # INSTR is 1-based, so we need to adjust the start position. - start = sge.convert(op.start + 1) if op.start is not None else sge.convert(1) - if op.end is not None: - # BigQuery's INSTR doesn't support `end`, so we need to use SUBSTR. - return sge.func( - "INSTR", - sge.Substring( - this=expr.expr, - start=start, - length=sge.convert(op.end - (op.start or 0)), - ), - sge.convert(op.substr), - ) - sge.convert(1) - else: - return sge.func( - "INSTR", - expr.expr, - sge.convert(op.substr), - start, - ) - sge.convert(1) - - -@register_unary_op(ops.StrLstripOp, pass_op=True) -def _(expr: TypedExpr, op: ops.StrLstripOp) -> sge.Expression: - return sge.Trim(this=expr.expr, expression=sge.convert(op.to_strip), side="LEFT") - - -@register_unary_op(ops.StrPadOp, pass_op=True) -def _(expr: TypedExpr, op: ops.StrPadOp) -> sge.Expression: - pad_length = sge.func( - "GREATEST", sge.Length(this=expr.expr), sge.convert(op.length) - ) - if op.side == "left": - return sge.func( - "LPAD", - expr.expr, - pad_length, - sge.convert(op.fillchar), - ) - elif op.side == "right": - return sge.func( - "RPAD", - expr.expr, - pad_length, - sge.convert(op.fillchar), - ) - else: # side == both - lpad_amount = sge.Cast( - this=sge.func( - "SAFE_DIVIDE", - sge.Sub(this=pad_length, expression=sge.Length(this=expr.expr)), - sge.convert(2), - ), - to="INT64", - ) + sge.Length(this=expr.expr) - return sge.func( - "RPAD", - sge.func( - "LPAD", - expr.expr, - lpad_amount, - sge.convert(op.fillchar), - ), - pad_length, - sge.convert(op.fillchar), - ) - - -@register_unary_op(ops.StrRepeatOp, pass_op=True) -def _(expr: TypedExpr, op: ops.StrRepeatOp) -> sge.Expression: - return sge.Repeat(this=expr.expr, times=sge.convert(op.repeats)) - - -@register_unary_op(ops.date_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Date(this=expr.expr) - - -@register_unary_op(ops.day_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Extract(this=sge.Identifier(this="DAY"), expression=expr.expr) - - -@register_unary_op(ops.dayofweek_op) -def _(expr: TypedExpr) -> sge.Expression: - # Adjust the 1-based day-of-week index (from SQL) to a 0-based index. - return sge.Extract( - this=sge.Identifier(this="DAYOFWEEK"), expression=expr.expr - ) - sge.convert(1) - - -@register_unary_op(ops.dayofyear_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Extract(this=sge.Identifier(this="DAYOFYEAR"), expression=expr.expr) - - -@register_unary_op(ops.EndsWithOp, pass_op=True) -def _(expr: TypedExpr, op: ops.EndsWithOp) -> sge.Expression: - if not op.pat: - return sge.false() - - def to_endswith(pat: str) -> sge.Expression: - return sge.func("ENDS_WITH", expr.expr, sge.convert(pat)) - - conditions = [to_endswith(pat) for pat in op.pat] - return functools.reduce(lambda x, y: sge.Or(this=x, expression=y), conditions) - - -@register_unary_op(ops.exp_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Case( - ifs=[ - sge.If( - this=expr.expr > constants._FLOAT64_EXP_BOUND, - true=constants._INF, - ) - ], - default=sge.func("EXP", expr.expr), - ) - - -@register_unary_op(ops.expm1_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Case( - ifs=[ - sge.If( - this=expr.expr > constants._FLOAT64_EXP_BOUND, - true=constants._INF, - ) - ], - default=sge.func("EXP", expr.expr), - ) - sge.convert(1) - - -@register_unary_op(ops.FloorDtOp, pass_op=True) -def _(expr: TypedExpr, op: ops.FloorDtOp) -> sge.Expression: - # TODO: Remove this method when it is covered by ops.FloorOp - return sge.TimestampTrunc(this=expr.expr, unit=sge.Identifier(this=op.freq)) - - -@register_unary_op(ops.floor_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Floor(this=expr.expr) - - -@register_unary_op(ops.geo_area_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("ST_AREA", expr.expr) - - -@register_unary_op(ops.geo_st_astext_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("ST_ASTEXT", expr.expr) - - -@register_unary_op(ops.geo_st_boundary_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("ST_BOUNDARY", expr.expr) - - -@register_unary_op(ops.GeoStBufferOp, pass_op=True) -def _(expr: TypedExpr, op: ops.GeoStBufferOp) -> sge.Expression: - return sge.func( - "ST_BUFFER", - expr.expr, - sge.convert(op.buffer_radius), - sge.convert(op.num_seg_quarter_circle), - sge.convert(op.use_spheroid), - ) - - -@register_unary_op(ops.geo_st_centroid_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("ST_CENTROID", expr.expr) - - -@register_unary_op(ops.geo_st_convexhull_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("ST_CONVEXHULL", expr.expr) - - -@register_unary_op(ops.geo_st_geogfromtext_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("SAFE.ST_GEOGFROMTEXT", expr.expr) - - -@register_unary_op(ops.geo_st_isclosed_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("ST_ISCLOSED", expr.expr) - - -@register_unary_op(ops.GeoStLengthOp, pass_op=True) -def _(expr: TypedExpr, op: ops.GeoStLengthOp) -> sge.Expression: - return sge.func("ST_LENGTH", expr.expr) - - -@register_unary_op(ops.geo_x_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("SAFE.ST_X", expr.expr) - - -@register_unary_op(ops.geo_y_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("SAFE.ST_Y", expr.expr) - - -@register_unary_op(ops.hash_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("FARM_FINGERPRINT", expr.expr) - - -@register_unary_op(ops.hour_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Extract(this=sge.Identifier(this="HOUR"), expression=expr.expr) - - -@register_unary_op(ops.invert_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.BitwiseNot(this=expr.expr) - - -@register_unary_op(ops.IsInOp, pass_op=True) -def _(expr: TypedExpr, op: ops.IsInOp) -> sge.Expression: - values = [] - is_numeric_expr = dtypes.is_numeric(expr.dtype) - for value in op.values: - if value is None: - continue - dtype = dtypes.bigframes_type(type(value)) - if expr.dtype == dtype or is_numeric_expr and dtypes.is_numeric(dtype): - values.append(sge.convert(value)) - - if op.match_nulls: - contains_nulls = any(_is_null(value) for value in op.values) - if contains_nulls: - return sge.Is(this=expr.expr, expression=sge.Null()) | sge.In( - this=expr.expr, expressions=values - ) - - if len(values) == 0: - return sge.convert(False) - - return sge.func( - "COALESCE", sge.In(this=expr.expr, expressions=values), sge.convert(False) - ) - - -@register_unary_op(ops.isalnum_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^(\p{N}|\p{L})+$")) - - -@register_unary_op(ops.isalpha_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\p{L}+$")) - - -@register_unary_op(ops.isdecimal_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\d+$")) - - -@register_unary_op(ops.isdigit_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\p{Nd}+$")) - - -@register_unary_op(ops.islower_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.And( - this=sge.EQ( - this=sge.Lower(this=expr.expr), - expression=expr.expr, - ), - expression=sge.NEQ( - this=sge.Upper(this=expr.expr), - expression=expr.expr, - ), - ) - - -@register_unary_op(ops.iso_day_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Extract(this=sge.Identifier(this="DAYOFWEEK"), expression=expr.expr) - - -@register_unary_op(ops.iso_week_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Extract(this=sge.Identifier(this="ISOWEEK"), expression=expr.expr) - - -@register_unary_op(ops.iso_year_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Extract(this=sge.Identifier(this="ISOYEAR"), expression=expr.expr) - - -@register_unary_op(ops.isnull_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Is(this=expr.expr, expression=sge.Null()) - - -@register_unary_op(ops.isnumeric_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\pN+$")) - - -@register_unary_op(ops.isspace_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\s+$")) - - -@register_unary_op(ops.isupper_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.And( - this=sge.EQ( - this=sge.Upper(this=expr.expr), - expression=expr.expr, - ), - expression=sge.NEQ( - this=sge.Lower(this=expr.expr), - expression=expr.expr, - ), - ) - - -@register_unary_op(ops.len_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Length(this=expr.expr) - - -@register_unary_op(ops.ln_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Case( - ifs=[ - sge.If( - this=expr.expr < sge.convert(0), - true=constants._NAN, - ) - ], - default=sge.Ln(this=expr.expr), - ) - - -@register_unary_op(ops.log10_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Case( - ifs=[ - sge.If( - this=expr.expr < sge.convert(0), - true=constants._NAN, - ) - ], - default=sge.Log(this=expr.expr, expression=sge.convert(10)), - ) - - -@register_unary_op(ops.log1p_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Case( - ifs=[ - sge.If( - this=expr.expr < sge.convert(-1), - true=constants._NAN, - ) - ], - default=sge.Ln(this=sge.convert(1) + expr.expr), - ) - - -@register_unary_op(ops.lower_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Lower(this=expr.expr) - - -@register_unary_op(ops.MapOp, pass_op=True) -def _(expr: TypedExpr, op: ops.MapOp) -> sge.Expression: - return sge.Case( - this=expr.expr, - ifs=[ - sge.If(this=sge.convert(key), true=sge.convert(value)) - for key, value in op.mappings - ], - ) - - -@register_unary_op(ops.minute_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Extract(this=sge.Identifier(this="MINUTE"), expression=expr.expr) - - -@register_unary_op(ops.month_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Extract(this=sge.Identifier(this="MONTH"), expression=expr.expr) - - -@register_unary_op(ops.neg_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Neg(this=expr.expr) - - -@register_unary_op(ops.normalize_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.TimestampTrunc(this=expr.expr, unit=sge.Identifier(this="DAY")) - - -@register_unary_op(ops.notnull_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Not(this=sge.Is(this=expr.expr, expression=sge.Null())) - - -@register_unary_op(ops.obj_fetch_metadata_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("OBJ.FETCH_METADATA", expr.expr) - - -@register_unary_op(ops.ObjGetAccessUrl) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("OBJ.GET_ACCESS_URL", expr.expr) - - -@register_unary_op(ops.pos_op) -def _(expr: TypedExpr) -> sge.Expression: - return expr.expr - - -@register_unary_op(ops.quarter_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Extract(this=sge.Identifier(this="QUARTER"), expression=expr.expr) - - -@register_unary_op(ops.ReplaceStrOp, pass_op=True) -def _(expr: TypedExpr, op: ops.ReplaceStrOp) -> sge.Expression: - return sge.func("REPLACE", expr.expr, sge.convert(op.pat), sge.convert(op.repl)) - - -@register_unary_op(ops.RegexReplaceStrOp, pass_op=True) -def _(expr: TypedExpr, op: ops.RegexReplaceStrOp) -> sge.Expression: - return sge.func( - "REGEXP_REPLACE", expr.expr, sge.convert(op.pat), sge.convert(op.repl) - ) - - -@register_unary_op(ops.reverse_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("REVERSE", expr.expr) - - -@register_unary_op(ops.second_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Extract(this=sge.Identifier(this="SECOND"), expression=expr.expr) - - -@register_unary_op(ops.StrRstripOp, pass_op=True) -def _(expr: TypedExpr, op: ops.StrRstripOp) -> sge.Expression: - return sge.Trim(this=expr.expr, expression=sge.convert(op.to_strip), side="RIGHT") - - -@register_unary_op(ops.sqrt_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Case( - ifs=[ - sge.If( - this=expr.expr < sge.convert(0), - true=constants._NAN, - ) - ], - default=sge.Sqrt(this=expr.expr), - ) - - -@register_unary_op(ops.StartsWithOp, pass_op=True) -def _(expr: TypedExpr, op: ops.StartsWithOp) -> sge.Expression: - if not op.pat: - return sge.false() - - def to_startswith(pat: str) -> sge.Expression: - return sge.func("STARTS_WITH", expr.expr, sge.convert(pat)) - - conditions = [to_startswith(pat) for pat in op.pat] - return functools.reduce(lambda x, y: sge.Or(this=x, expression=y), conditions) - - -@register_unary_op(ops.StrStripOp, pass_op=True) -def _(expr: TypedExpr, op: ops.StrStripOp) -> sge.Expression: - return sge.Trim(this=sge.convert(op.to_strip), expression=expr.expr) - - -@register_unary_op(ops.sin_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("SIN", expr.expr) - - -@register_unary_op(ops.sinh_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Case( - ifs=[ - sge.If( - this=sge.func("ABS", expr.expr) > constants._FLOAT64_EXP_BOUND, - true=sge.func("SIGN", expr.expr) * constants._INF, - ) - ], - default=sge.func("SINH", expr.expr), - ) - - -@register_unary_op(ops.StringSplitOp, pass_op=True) -def _(expr: TypedExpr, op: ops.StringSplitOp) -> sge.Expression: - return sge.Split(this=expr.expr, expression=sge.convert(op.pat)) - - -@register_unary_op(ops.StrGetOp, pass_op=True) -def _(expr: TypedExpr, op: ops.StrGetOp) -> sge.Expression: - return sge.Substring( - this=expr.expr, - start=sge.convert(op.i + 1), - length=sge.convert(1), - ) - - -@register_unary_op(ops.StrSliceOp, pass_op=True) -def _(expr: TypedExpr, op: ops.StrSliceOp) -> sge.Expression: - start = op.start + 1 if op.start is not None else None - if op.end is None: - length = None - elif op.start is None: - length = op.end - else: - length = op.end - op.start - return sge.Substring( - this=expr.expr, - start=sge.convert(start) if start is not None else None, - length=sge.convert(length) if length is not None else None, - ) - - -@register_unary_op(ops.StrftimeOp, pass_op=True) -def _(expr: TypedExpr, op: ops.StrftimeOp) -> sge.Expression: - return sge.func("FORMAT_TIMESTAMP", sge.convert(op.date_format), expr.expr) - - -@register_unary_op(ops.StructFieldOp, pass_op=True) -def _(expr: TypedExpr, op: ops.StructFieldOp) -> sge.Expression: - if isinstance(op.name_or_index, str): - name = op.name_or_index - else: - pa_type = typing.cast(pd.ArrowDtype, expr.dtype) - pa_struct_type = typing.cast(pa.StructType, pa_type.pyarrow_dtype) - name = pa_struct_type.field(op.name_or_index).name - - return sge.Column( - this=sge.to_identifier(name, quoted=True), - catalog=expr.expr, - ) - - -@register_unary_op(ops.tan_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("TAN", expr.expr) - - -@register_unary_op(ops.tanh_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("TANH", expr.expr) - - -@register_unary_op(ops.time_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("TIME", expr.expr) - - -@register_unary_op(ops.timedelta_floor_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Floor(this=expr.expr) - - -@register_unary_op(ops.ToDatetimeOp) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Cast(this=sge.func("TIMESTAMP_SECONDS", expr.expr), to="DATETIME") - - -@register_unary_op(ops.ToTimestampOp) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("TIMESTAMP_SECONDS", expr.expr) - - -@register_unary_op(ops.ToTimedeltaOp, pass_op=True) -def _(expr: TypedExpr, op: ops.ToTimedeltaOp) -> sge.Expression: - value = expr.expr - factor = UNIT_TO_US_CONVERSION_FACTORS[op.unit] - if factor != 1: - value = sge.Mul(this=value, expression=sge.convert(factor)) - return value - - -@register_unary_op(ops.UnixMicros) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("UNIX_MICROS", expr.expr) - - -@register_unary_op(ops.UnixMillis) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("UNIX_MILLIS", expr.expr) - - -@register_unary_op(ops.UnixSeconds, pass_op=True) -def _(expr: TypedExpr, op: ops.UnixSeconds) -> sge.Expression: - return sge.func("UNIX_SECONDS", expr.expr) - - -@register_unary_op(ops.JSONExtract, pass_op=True) -def _(expr: TypedExpr, op: ops.JSONExtract) -> sge.Expression: - return sge.func("JSON_EXTRACT", expr.expr, sge.convert(op.json_path)) - - -@register_unary_op(ops.JSONExtractArray, pass_op=True) -def _(expr: TypedExpr, op: ops.JSONExtractArray) -> sge.Expression: - return sge.func("JSON_EXTRACT_ARRAY", expr.expr, sge.convert(op.json_path)) - - -@register_unary_op(ops.JSONExtractStringArray, pass_op=True) -def _(expr: TypedExpr, op: ops.JSONExtractStringArray) -> sge.Expression: - return sge.func("JSON_EXTRACT_STRING_ARRAY", expr.expr, sge.convert(op.json_path)) - - -@register_unary_op(ops.JSONQuery, pass_op=True) -def _(expr: TypedExpr, op: ops.JSONQuery) -> sge.Expression: - return sge.func("JSON_QUERY", expr.expr, sge.convert(op.json_path)) - - -@register_unary_op(ops.JSONQueryArray, pass_op=True) -def _(expr: TypedExpr, op: ops.JSONQueryArray) -> sge.Expression: - return sge.func("JSON_QUERY_ARRAY", expr.expr, sge.convert(op.json_path)) - - -@register_unary_op(ops.JSONValue, pass_op=True) -def _(expr: TypedExpr, op: ops.JSONValue) -> sge.Expression: - return sge.func("JSON_VALUE", expr.expr, sge.convert(op.json_path)) - - -@register_unary_op(ops.JSONValueArray, pass_op=True) -def _(expr: TypedExpr, op: ops.JSONValueArray) -> sge.Expression: - return sge.func("JSON_VALUE_ARRAY", expr.expr, sge.convert(op.json_path)) - - -@register_unary_op(ops.ParseJSON) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("PARSE_JSON", expr.expr) - - -@register_unary_op(ops.ToJSONString) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("TO_JSON_STRING", expr.expr) - - -@register_unary_op(ops.upper_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Upper(this=expr.expr) - - -@register_unary_op(ops.year_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Extract(this=sge.Identifier(this="YEAR"), expression=expr.expr) - - -@register_unary_op(ops.ZfillOp, pass_op=True) -def _(expr: TypedExpr, op: ops.ZfillOp) -> sge.Expression: - return sge.Case( - ifs=[ - sge.If( - this=sge.EQ( - this=sge.Substring( - this=expr.expr, start=sge.convert(1), length=sge.convert(1) - ), - expression=sge.convert("-"), - ), - true=sge.Concat( - expressions=[ - sge.convert("-"), - sge.func( - "LPAD", - sge.Substring(this=expr.expr, start=sge.convert(1)), - sge.convert(op.width - 1), - sge.convert("0"), - ), - ] - ), - ) - ], - default=sge.func("LPAD", expr.expr, sge.convert(op.width), sge.convert("0")), - ) - - -# Helpers -def _is_null(value) -> bool: - # float NaN/inf should be treated as distinct from 'true' null values - return typing.cast(bool, pd.isna(value)) and not isinstance(value, float) diff --git a/bigframes/testing/utils.py b/bigframes/testing/utils.py index 5da24c5b9b..d38e323d57 100644 --- a/bigframes/testing/utils.py +++ b/bigframes/testing/utils.py @@ -14,7 +14,7 @@ import base64 import decimal -from typing import Iterable, Optional, Set, Union +from typing import Iterable, Optional, Sequence, Set, Union import geopandas as gpd # type: ignore import google.api_core.operation @@ -25,6 +25,7 @@ import pyarrow as pa # type: ignore import pytest +from bigframes.core import expression as expr import bigframes.functions._utils as bff_utils import bigframes.pandas @@ -448,3 +449,22 @@ def get_function_name(func, package_requirements=None, is_row_processor=False): function_hash = bff_utils.get_hash(func, package_requirements) return f"bigframes_{function_hash}" + + +def _apply_unary_ops( + obj: bigframes.pandas.DataFrame, + ops_list: Sequence[expr.Expression], + new_names: Sequence[str], +) -> str: + """Applies a list of unary ops to the given DataFrame and returns the SQL + representing the resulting DataFrames.""" + array_value = obj._block.expr + result, old_names = array_value.compute_values(ops_list) + + # Rename columns for deterministic golden SQL results. + assert len(old_names) == len(new_names) + col_ids = {old_name: new_name for old_name, new_name in zip(old_names, new_names)} + result = result.rename_columns(col_ids).select_columns(new_names) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_array_index/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_index/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_array_index/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_index/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_array_slice_with_only_start/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_only_start/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_array_slice_with_only_start/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_only_start/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_array_slice_with_start_and_stop/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_start_and_stop/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_array_slice_with_start_and_stop/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_start_and_stop/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_array_to_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_to_string/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_array_to_string/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_to_string/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_numeric/out.sql deleted file mode 100644 index c38bc18523..0000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_numeric/out.sql +++ /dev/null @@ -1,154 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `float64_col` AS `bfcol_2`, - `rowindex` AS `bfcol_3` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_1` AS ( - SELECT - *, - `bfcol_3` AS `bfcol_8`, - `bfcol_1` AS `bfcol_9`, - `bfcol_0` AS `bfcol_10`, - `bfcol_2` AS `bfcol_11`, - CASE - WHEN `bfcol_1` = CAST(0 AS INT64) - THEN CAST(0 AS INT64) * `bfcol_1` - ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_1`, `bfcol_1`)) AS INT64) - END AS `bfcol_12` - FROM `bfcte_0` -), `bfcte_2` AS ( - SELECT - *, - `bfcol_8` AS `bfcol_18`, - `bfcol_9` AS `bfcol_19`, - `bfcol_10` AS `bfcol_20`, - `bfcol_11` AS `bfcol_21`, - `bfcol_12` AS `bfcol_22`, - CASE - WHEN 1 = CAST(0 AS INT64) - THEN CAST(0 AS INT64) * `bfcol_9` - ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_9`, 1)) AS INT64) - END AS `bfcol_23` - FROM `bfcte_1` -), `bfcte_3` AS ( - SELECT - *, - `bfcol_18` AS `bfcol_30`, - `bfcol_19` AS `bfcol_31`, - `bfcol_20` AS `bfcol_32`, - `bfcol_21` AS `bfcol_33`, - `bfcol_22` AS `bfcol_34`, - `bfcol_23` AS `bfcol_35`, - CASE - WHEN 0.0 = CAST(0 AS INT64) - THEN CAST('Infinity' AS FLOAT64) * `bfcol_19` - ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_19`, 0.0)) AS INT64) - END AS `bfcol_36` - FROM `bfcte_2` -), `bfcte_4` AS ( - SELECT - *, - `bfcol_30` AS `bfcol_44`, - `bfcol_31` AS `bfcol_45`, - `bfcol_32` AS `bfcol_46`, - `bfcol_33` AS `bfcol_47`, - `bfcol_34` AS `bfcol_48`, - `bfcol_35` AS `bfcol_49`, - `bfcol_36` AS `bfcol_50`, - CASE - WHEN `bfcol_33` = CAST(0 AS INT64) - THEN CAST('Infinity' AS FLOAT64) * `bfcol_31` - ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_31`, `bfcol_33`)) AS INT64) - END AS `bfcol_51` - FROM `bfcte_3` -), `bfcte_5` AS ( - SELECT - *, - `bfcol_44` AS `bfcol_60`, - `bfcol_45` AS `bfcol_61`, - `bfcol_46` AS `bfcol_62`, - `bfcol_47` AS `bfcol_63`, - `bfcol_48` AS `bfcol_64`, - `bfcol_49` AS `bfcol_65`, - `bfcol_50` AS `bfcol_66`, - `bfcol_51` AS `bfcol_67`, - CASE - WHEN `bfcol_45` = CAST(0 AS INT64) - THEN CAST('Infinity' AS FLOAT64) * `bfcol_47` - ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_47`, `bfcol_45`)) AS INT64) - END AS `bfcol_68` - FROM `bfcte_4` -), `bfcte_6` AS ( - SELECT - *, - `bfcol_60` AS `bfcol_78`, - `bfcol_61` AS `bfcol_79`, - `bfcol_62` AS `bfcol_80`, - `bfcol_63` AS `bfcol_81`, - `bfcol_64` AS `bfcol_82`, - `bfcol_65` AS `bfcol_83`, - `bfcol_66` AS `bfcol_84`, - `bfcol_67` AS `bfcol_85`, - `bfcol_68` AS `bfcol_86`, - CASE - WHEN 0.0 = CAST(0 AS INT64) - THEN CAST('Infinity' AS FLOAT64) * `bfcol_63` - ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_63`, 0.0)) AS INT64) - END AS `bfcol_87` - FROM `bfcte_5` -), `bfcte_7` AS ( - SELECT - *, - `bfcol_78` AS `bfcol_98`, - `bfcol_79` AS `bfcol_99`, - `bfcol_80` AS `bfcol_100`, - `bfcol_81` AS `bfcol_101`, - `bfcol_82` AS `bfcol_102`, - `bfcol_83` AS `bfcol_103`, - `bfcol_84` AS `bfcol_104`, - `bfcol_85` AS `bfcol_105`, - `bfcol_86` AS `bfcol_106`, - `bfcol_87` AS `bfcol_107`, - CASE - WHEN CAST(`bfcol_80` AS INT64) = CAST(0 AS INT64) - THEN CAST(0 AS INT64) * `bfcol_79` - ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_79`, CAST(`bfcol_80` AS INT64))) AS INT64) - END AS `bfcol_108` - FROM `bfcte_6` -), `bfcte_8` AS ( - SELECT - *, - `bfcol_98` AS `bfcol_120`, - `bfcol_99` AS `bfcol_121`, - `bfcol_100` AS `bfcol_122`, - `bfcol_101` AS `bfcol_123`, - `bfcol_102` AS `bfcol_124`, - `bfcol_103` AS `bfcol_125`, - `bfcol_104` AS `bfcol_126`, - `bfcol_105` AS `bfcol_127`, - `bfcol_106` AS `bfcol_128`, - `bfcol_107` AS `bfcol_129`, - `bfcol_108` AS `bfcol_130`, - CASE - WHEN `bfcol_99` = CAST(0 AS INT64) - THEN CAST(0 AS INT64) * CAST(`bfcol_100` AS INT64) - ELSE CAST(FLOOR(IEEE_DIVIDE(CAST(`bfcol_100` AS INT64), `bfcol_99`)) AS INT64) - END AS `bfcol_131` - FROM `bfcte_7` -) -SELECT - `bfcol_120` AS `rowindex`, - `bfcol_121` AS `int64_col`, - `bfcol_122` AS `bool_col`, - `bfcol_123` AS `float64_col`, - `bfcol_124` AS `int_div_int`, - `bfcol_125` AS `int_div_1`, - `bfcol_126` AS `int_div_0`, - `bfcol_127` AS `int_div_float`, - `bfcol_128` AS `float_div_int`, - `bfcol_129` AS `float_div_0`, - `bfcol_130` AS `int_div_bool`, - `bfcol_131` AS `bool_div_int` -FROM `bfcte_8` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_obj_fetch_metadata/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_fetch_metadata/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_obj_fetch_metadata/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_fetch_metadata/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_obj_get_access_url/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_get_access_url/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_obj_get_access_url/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_get_access_url/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_is_in/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_is_in/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_is_in/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_is_in/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_date/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_date/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_date/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_date/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_day/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_day/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_day/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_day/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_dayofweek/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofweek/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_dayofweek/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofweek/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_dayofyear/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofyear/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_dayofyear/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofyear/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_floor_dt/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_floor_dt/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_floor_dt/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_floor_dt/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_hour/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_hour/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_hour/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_hour/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_iso_day/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_day/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_iso_day/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_day/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_iso_week/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_week/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_iso_week/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_week/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_iso_year/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_year/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_iso_year/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_year/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_minute/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_minute/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_minute/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_minute/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_month/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_month/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_month/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_month/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_normalize/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_normalize/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_normalize/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_normalize/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_quarter/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_quarter/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_quarter/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_quarter/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_second/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_second/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_second/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_second/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_strftime/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_strftime/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_strftime/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_strftime/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_time/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_time/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_time/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_time/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_datetime/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_datetime/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_datetime/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_datetime/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_timestamp/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_timestamp/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_timestamp/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_timestamp/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_unix_micros/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_micros/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_unix_micros/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_micros/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_unix_millis/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_millis/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_unix_millis/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_millis/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_unix_seconds/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_seconds/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_unix_seconds/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_seconds/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_year/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_year/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_year/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_year/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_hash/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_hash/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_hash/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_hash/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isnull/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_isnull/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isnull/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_isnull/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_map/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_map/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_map/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_map/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_notnull/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_notnull/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_notnull/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_notnull/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_area/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_area/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_area/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_area/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_astext/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_astext/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_astext/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_astext/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_boundary/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_boundary/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_boundary/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_boundary/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_buffer/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_buffer/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_buffer/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_buffer/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_centroid/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_centroid/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_centroid/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_centroid/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_convexhull/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_convexhull/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_convexhull/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_convexhull/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_geogfromtext/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogfromtext/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_geogfromtext/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogfromtext/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_isclosed/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_isclosed/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_isclosed/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_isclosed/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_length/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_length/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_length/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_length/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_x/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_x/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_x/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_x/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_y/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_y/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_y/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_y/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_extract/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_extract/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_extract_array/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_array/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_extract_array/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_array/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_extract_string_array/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_string_array/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_extract_string_array/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_string_array/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_query/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_query/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_query_array/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query_array/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_query_array/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query_array/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_value/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_value/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_value/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_value/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_parse_json/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_parse_json/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_parse_json/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_parse_json/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_json_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json_string/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_json_string/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json_string/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_abs/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_abs/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_abs/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_abs/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arccos/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccos/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arccos/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccos/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arccosh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccosh/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arccosh/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccosh/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arcsin/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsin/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arcsin/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsin/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arcsinh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsinh/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arcsinh/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsinh/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arctan/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arctan/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arctanh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctanh/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arctanh/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctanh/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_ceil/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ceil/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_ceil/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ceil/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_cos/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cos/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_cos/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cos/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_cosh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosh/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_cosh/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosh/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_exp/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_exp/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_exp/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_exp/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_expm1/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_expm1/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_expm1/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_expm1/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_floor/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floor/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_floor/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floor/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_invert/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_invert/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_invert/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_invert/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_ln/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ln/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_ln/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ln/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_log10/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log10/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_log10/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log10/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_log1p/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log1p/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_log1p/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log1p/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_neg/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_neg/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_neg/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_neg/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_pos/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pos/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_pos/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pos/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_sin/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sin/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_sin/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sin/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_sinh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sinh/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_sinh/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sinh/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_sqrt/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sqrt/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_sqrt/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sqrt/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_tan/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tan/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_tan/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tan/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_tanh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tanh/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_tanh/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tanh/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_capitalize/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_capitalize/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_capitalize/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_capitalize/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_endswith/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_endswith/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isalnum/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalnum/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isalnum/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalnum/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isalpha/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalpha/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isalpha/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalpha/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isdecimal/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdecimal/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isdecimal/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdecimal/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isdigit/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdigit/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isdigit/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdigit/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_islower/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_islower/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_islower/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_islower/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isnumeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isnumeric/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isnumeric/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isnumeric/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isspace/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isspace/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isspace/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isspace/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isupper/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isupper/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isupper/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isupper/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_len/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_len/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_lower/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lower/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_lower/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lower/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_lstrip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lstrip/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_lstrip/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lstrip/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_regex_replace_str/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_regex_replace_str/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_regex_replace_str/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_regex_replace_str/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_replace_str/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_replace_str/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_replace_str/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_replace_str/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_reverse/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_reverse/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_reverse/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_reverse/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_rstrip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_rstrip/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_rstrip/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_rstrip/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_startswith/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_startswith/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_contains/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_contains/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_contains_regex/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains_regex/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_contains_regex/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains_regex/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_extract/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_extract/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_extract/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_extract/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_find/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_find/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_get/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_get/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_get/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_get/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_pad/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_pad/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_repeat/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_repeat/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_repeat/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_repeat/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_slice/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_slice/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_slice/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_slice/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_string_split/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_string_split/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_string_split/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_string_split/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_strip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strip/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_strip/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strip/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_upper/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_upper/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_upper/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_upper/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_zfill/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_zfill/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_zfill/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_zfill/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_struct_field/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_field/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_struct_field/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_field/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_timedelta_floor/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_timedelta_floor/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_timedelta_floor/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_timedelta_floor/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_to_timedelta/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_timedelta/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_to_timedelta/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_compile_numerical_add_w_scalar/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_compile_numerical_add_w_scalar/out.sql deleted file mode 100644 index 9c4b01a6df..0000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_compile_numerical_add_w_scalar/out.sql +++ /dev/null @@ -1,16 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `int64_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_1` AS ( - SELECT - *, - `bfcol_1` AS `bfcol_4`, - `bfcol_0` + 1 AS `bfcol_5` - FROM `bfcte_0` -) -SELECT - `bfcol_4` AS `rowindex`, - `bfcol_5` AS `int64_col` -FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_compile_string_add/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_compile_string_add/out.sql deleted file mode 100644 index 7a8ab83df1..0000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_compile_string_add/out.sql +++ /dev/null @@ -1,16 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `rowindex` AS `bfcol_0`, - `string_col` AS `bfcol_1` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_1` AS ( - SELECT - *, - `bfcol_0` AS `bfcol_4`, - CONCAT(`bfcol_1`, 'a') AS `bfcol_5` - FROM `bfcte_0` -) -SELECT - `bfcol_4` AS `rowindex`, - `bfcol_5` AS `string_col` -FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_array_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_array_ops.py new file mode 100644 index 0000000000..407c7bbb3c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_array_ops.py @@ -0,0 +1,62 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes import operations as ops +from bigframes.operations._op_converters import convert_index, convert_slice +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_array_to_string(repeated_types_df: bpd.DataFrame, snapshot): + col_name = "string_list_col" + bf_df = repeated_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.ArrayToStringOp(delimiter=".").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_array_index(repeated_types_df: bpd.DataFrame, snapshot): + col_name = "string_list_col" + bf_df = repeated_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [convert_index(1).as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_array_slice_with_only_start(repeated_types_df: bpd.DataFrame, snapshot): + col_name = "string_list_col" + bf_df = repeated_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [convert_slice(slice(1, None)).as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_array_slice_with_start_and_stop(repeated_types_df: bpd.DataFrame, snapshot): + col_name = "string_list_col" + bf_df = repeated_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [convert_slice(slice(1, 5)).as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_blob_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_blob_ops.py new file mode 100644 index 0000000000..7876a754ee --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_blob_ops.py @@ -0,0 +1,31 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def test_obj_fetch_metadata(scalar_types_df: bpd.DataFrame, snapshot): + blob_s = scalar_types_df["string_col"].str.to_blob() + sql = blob_s.blob.version().to_frame().sql + snapshot.assert_match(sql, "out.sql") + + +def test_obj_get_access_url(scalar_types_df: bpd.DataFrame, snapshot): + blob_s = scalar_types_df["string_col"].str.to_blob() + sql = blob_s.blob.read_url().to_frame().sql + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py new file mode 100644 index 0000000000..9a901687fa --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py @@ -0,0 +1,44 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes import operations as ops +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_is_in(scalar_types_df: bpd.DataFrame, snapshot): + int_col = "int64_col" + float_col = "float64_col" + bf_df = scalar_types_df[[int_col, float_col]] + ops_map = { + "ints": ops.IsInOp(values=(1, 2, 3)).as_expr(int_col), + "ints_w_null": ops.IsInOp(values=(None, 123456)).as_expr(int_col), + "floats": ops.IsInOp(values=(1.0, 2.0, 3.0), match_nulls=False).as_expr( + int_col + ), + "strings": ops.IsInOp(values=("1.0", "2.0")).as_expr(int_col), + "mixed": ops.IsInOp(values=("1.0", 2.5, 3)).as_expr(int_col), + "empty": ops.IsInOp(values=()).as_expr(int_col), + "ints_wo_match_nulls": ops.IsInOp( + values=(None, 123456), match_nulls=False + ).as_expr(int_col), + "float_in_ints": ops.IsInOp(values=(1, 2, 3, None)).as_expr(float_col), + } + + sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py new file mode 100644 index 0000000000..0a8aa320bb --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py @@ -0,0 +1,217 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes import operations as ops +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_date(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.date_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_day(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.day_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_dayofweek(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.dayofweek_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_dayofyear(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.dayofyear_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_floor_dt(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.FloorDtOp("D").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_hour(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.hour_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_minute(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.minute_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_month(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.month_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_normalize(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.normalize_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_quarter(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.quarter_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_second(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.second_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_strftime(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.StrftimeOp("%Y-%m-%d").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_time(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.time_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_to_datetime(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.ToDatetimeOp().as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_to_timestamp(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.ToTimestampOp().as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_unix_micros(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.UnixMicros().as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_unix_millis(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.UnixMillis().as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_unix_seconds(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.UnixSeconds().as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_year(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.year_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_iso_day(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.iso_day_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_iso_week(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.iso_week_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_iso_year(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.iso_year_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py new file mode 100644 index 0000000000..130d34a2fa --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py @@ -0,0 +1,57 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes import operations as ops +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_hash(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.hash_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_isnull(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.isnull_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_notnull(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.notnull_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_map(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, + [ops.MapOp(mappings=(("value1", "mapped1"),)).as_expr(col_name)], + [col_name], + ) + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py new file mode 100644 index 0000000000..e136d172f6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py @@ -0,0 +1,125 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes import operations as ops +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_geo_area(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.geo_area_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_astext(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.geo_st_astext_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_boundary(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.geo_st_boundary_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_buffer(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.GeoStBufferOp(1.0, 8.0, False).as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_centroid(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.geo_st_centroid_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_convexhull(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.geo_st_convexhull_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_geogfromtext(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.geo_st_geogfromtext_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_isclosed(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.geo_st_isclosed_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_length(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.GeoStLengthOp(True).as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_x(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.geo_x_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_y(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.geo_y_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py new file mode 100644 index 0000000000..ecbac10ef2 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py @@ -0,0 +1,99 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes import operations as ops +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_json_extract(json_types_df: bpd.DataFrame, snapshot): + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.JSONExtract(json_path="$").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_json_extract_array(json_types_df: bpd.DataFrame, snapshot): + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.JSONExtractArray(json_path="$").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_json_extract_string_array(json_types_df: bpd.DataFrame, snapshot): + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.JSONExtractStringArray(json_path="$").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_json_query(json_types_df: bpd.DataFrame, snapshot): + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.JSONQuery(json_path="$").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_json_query_array(json_types_df: bpd.DataFrame, snapshot): + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.JSONQueryArray(json_path="$").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_json_value(json_types_df: bpd.DataFrame, snapshot): + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.JSONValue(json_path="$").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_parse_json(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.ParseJSON().as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_to_json_string(json_types_df: bpd.DataFrame, snapshot): + col_name = "json_col" + bf_df = json_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.ToJSONString().as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py new file mode 100644 index 0000000000..10fd4b2427 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py @@ -0,0 +1,213 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes import operations as ops +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_arccosh(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.arccosh_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_arccos(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.arccos_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_arcsin(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.arcsin_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_arcsinh(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.arcsinh_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_arctan(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.arctan_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_arctanh(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.arctanh_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_abs(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.abs_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_ceil(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.ceil_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_cos(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.cos_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_cosh(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.cosh_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_exp(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.exp_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_expm1(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.expm1_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_floor(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.floor_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_invert(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.invert_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_ln(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.ln_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_log10(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.log10_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_log1p(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.log1p_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_neg(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.neg_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_pos(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.pos_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_sqrt(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.sqrt_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_sin(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.sin_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_sinh(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.sinh_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_tan(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.tan_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_tanh(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "float64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.tanh_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py new file mode 100644 index 0000000000..79c67a09ca --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py @@ -0,0 +1,305 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes import operations as ops +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_capitalize(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.capitalize_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_endswith(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + ops_map = { + "single": ops.EndsWithOp(pat=("ab",)).as_expr(col_name), + "double": ops.EndsWithOp(pat=("ab", "cd")).as_expr(col_name), + "empty": ops.EndsWithOp(pat=()).as_expr(col_name), + } + sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_isalnum(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.isalnum_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_isalpha(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.isalpha_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_isdecimal(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.isdecimal_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_isdigit(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.isdigit_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_islower(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.islower_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_isnumeric(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.isnumeric_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_isspace(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.isspace_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_isupper(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.isupper_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_len(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.len_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_lower(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.lower_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_lstrip(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.StrLstripOp(" ").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_replace_str(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.ReplaceStrOp("e", "a").as_expr(col_name)], [col_name] + ) + snapshot.assert_match(sql, "out.sql") + + +def test_regex_replace_str(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.RegexReplaceStrOp(r"e", "a").as_expr(col_name)], [col_name] + ) + snapshot.assert_match(sql, "out.sql") + + +def test_reverse(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.reverse_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_rstrip(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.StrRstripOp(" ").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_startswith(scalar_types_df: bpd.DataFrame, snapshot): + + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + ops_map = { + "single": ops.StartsWithOp(pat=("ab",)).as_expr(col_name), + "double": ops.StartsWithOp(pat=("ab", "cd")).as_expr(col_name), + "empty": ops.StartsWithOp(pat=()).as_expr(col_name), + } + sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_str_get(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.StrGetOp(1).as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_str_pad(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + ops_map = { + "left": ops.StrPadOp(length=10, fillchar="-", side="left").as_expr(col_name), + "right": ops.StrPadOp(length=10, fillchar="-", side="right").as_expr(col_name), + "both": ops.StrPadOp(length=10, fillchar="-", side="both").as_expr(col_name), + } + sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_str_slice(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.StrSliceOp(1, 3).as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_strip(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.StrStripOp(" ").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_str_contains(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.StrContainsOp("e").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_str_contains_regex(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.StrContainsRegexOp("e").as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_str_extract(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.StrExtractOp(r"([a-z]*)", 1).as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_str_repeat(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.StrRepeatOp(2).as_expr(col_name)], [col_name] + ) + snapshot.assert_match(sql, "out.sql") + + +def test_str_find(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + ops_map = { + "none_none": ops.StrFindOp("e", start=None, end=None).as_expr(col_name), + "start_none": ops.StrFindOp("e", start=2, end=None).as_expr(col_name), + "none_end": ops.StrFindOp("e", start=None, end=5).as_expr(col_name), + "start_end": ops.StrFindOp("e", start=2, end=5).as_expr(col_name), + } + sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + + snapshot.assert_match(sql, "out.sql") + + +def test_string_split(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.StringSplitOp(pat=",").as_expr(col_name)], [col_name] + ) + snapshot.assert_match(sql, "out.sql") + + +def test_upper(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops(bf_df, [ops.upper_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_zfill(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.ZfillOp(width=10).as_expr(col_name)], [col_name] + ) + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_struct_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_struct_ops.py new file mode 100644 index 0000000000..19156ead99 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_struct_ops.py @@ -0,0 +1,36 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes import operations as ops +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_struct_field(nested_structs_types_df: bpd.DataFrame, snapshot): + col_name = "people" + bf_df = nested_structs_types_df[[col_name]] + + ops_map = { + # When a name string is provided. + "string": ops.StructFieldOp("name").as_expr(col_name), + # When an index integer is provided. + "int": ops.StructFieldOp(0).as_expr(col_name), + } + sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_timedelta_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_timedelta_ops.py new file mode 100644 index 0000000000..1f01047ba9 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_timedelta_ops.py @@ -0,0 +1,40 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes import operations as ops +import bigframes.pandas as bpd +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_to_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col"]] + bf_df["duration_us"] = bpd.to_timedelta(bf_df["int64_col"], "us") + bf_df["duration_s"] = bpd.to_timedelta(bf_df["int64_col"], "s") + bf_df["duration_w"] = bpd.to_timedelta(bf_df["int64_col"], "W") + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_timedelta_floor(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_unary_ops( + bf_df, [ops.timedelta_floor_op.as_expr(col_name)], [col_name] + ) + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py deleted file mode 100644 index fced18f5be..0000000000 --- a/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py +++ /dev/null @@ -1,998 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import typing - -import pytest - -from bigframes import operations as ops -from bigframes.core import expression as expr -from bigframes.operations._op_converters import convert_index, convert_slice -import bigframes.pandas as bpd - -pytest.importorskip("pytest_snapshot") - - -def _apply_unary_ops( - obj: bpd.DataFrame, - ops_list: typing.Sequence[expr.Expression], - new_names: typing.Sequence[str], -) -> str: - array_value = obj._block.expr - result, old_names = array_value.compute_values(ops_list) - - # Rename columns for deterministic golden SQL results. - assert len(old_names) == len(new_names) - col_ids = {old_name: new_name for old_name, new_name in zip(old_names, new_names)} - result = result.rename_columns(col_ids).select_columns(new_names) - - sql = result.session._executor.to_sql(result, enable_cache=False) - return sql - - -def test_arccosh(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.arccosh_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_arccos(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.arccos_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_arcsin(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.arcsin_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_arcsinh(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.arcsinh_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_arctan(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.arctan_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_arctanh(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.arctanh_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_abs(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.abs_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_capitalize(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.capitalize_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_ceil(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.ceil_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_date(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.date_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_day(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.day_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_dayofweek(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.dayofweek_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_dayofyear(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.dayofyear_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_endswith(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - ops_map = { - "single": ops.EndsWithOp(pat=("ab",)).as_expr(col_name), - "double": ops.EndsWithOp(pat=("ab", "cd")).as_expr(col_name), - "empty": ops.EndsWithOp(pat=()).as_expr(col_name), - } - sql = _apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) - snapshot.assert_match(sql, "out.sql") - - -def test_exp(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.exp_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_expm1(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.expm1_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_floor_dt(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.FloorDtOp("D").as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_floor(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.floor_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_geo_area(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "geography_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.geo_area_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_geo_st_astext(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "geography_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.geo_st_astext_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_geo_st_boundary(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "geography_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.geo_st_boundary_op.as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_geo_st_buffer(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "geography_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.GeoStBufferOp(1.0, 8.0, False).as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_geo_st_centroid(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "geography_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.geo_st_centroid_op.as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_geo_st_convexhull(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "geography_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.geo_st_convexhull_op.as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_geo_st_geogfromtext(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.geo_st_geogfromtext_op.as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_geo_st_isclosed(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "geography_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.geo_st_isclosed_op.as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_geo_st_length(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "geography_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.GeoStLengthOp(True).as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_geo_x(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "geography_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.geo_x_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_geo_y(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "geography_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.geo_y_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_array_to_string(repeated_types_df: bpd.DataFrame, snapshot): - col_name = "string_list_col" - bf_df = repeated_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.ArrayToStringOp(delimiter=".").as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_array_index(repeated_types_df: bpd.DataFrame, snapshot): - col_name = "string_list_col" - bf_df = repeated_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [convert_index(1).as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_array_slice_with_only_start(repeated_types_df: bpd.DataFrame, snapshot): - col_name = "string_list_col" - bf_df = repeated_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [convert_slice(slice(1, None)).as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_array_slice_with_start_and_stop(repeated_types_df: bpd.DataFrame, snapshot): - col_name = "string_list_col" - bf_df = repeated_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [convert_slice(slice(1, 5)).as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_cos(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.cos_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_cosh(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.cosh_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_hash(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.hash_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_hour(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.hour_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_invert(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "int64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.invert_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_is_in(scalar_types_df: bpd.DataFrame, snapshot): - int_col = "int64_col" - float_col = "float64_col" - bf_df = scalar_types_df[[int_col, float_col]] - ops_map = { - "ints": ops.IsInOp(values=(1, 2, 3)).as_expr(int_col), - "ints_w_null": ops.IsInOp(values=(None, 123456)).as_expr(int_col), - "floats": ops.IsInOp(values=(1.0, 2.0, 3.0), match_nulls=False).as_expr( - int_col - ), - "strings": ops.IsInOp(values=("1.0", "2.0")).as_expr(int_col), - "mixed": ops.IsInOp(values=("1.0", 2.5, 3)).as_expr(int_col), - "empty": ops.IsInOp(values=()).as_expr(int_col), - "ints_wo_match_nulls": ops.IsInOp( - values=(None, 123456), match_nulls=False - ).as_expr(int_col), - "float_in_ints": ops.IsInOp(values=(1, 2, 3, None)).as_expr(float_col), - } - - sql = _apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) - snapshot.assert_match(sql, "out.sql") - - -def test_isalnum(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.isalnum_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_isalpha(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.isalpha_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_isdecimal(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.isdecimal_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_isdigit(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.isdigit_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_islower(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.islower_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_isnumeric(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.isnumeric_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_isspace(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.isspace_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_isupper(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.isupper_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_len(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.len_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_ln(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.ln_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_log10(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.log10_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_log1p(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.log1p_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_lower(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.lower_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_map(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, - [ops.MapOp(mappings=(("value1", "mapped1"),)).as_expr(col_name)], - [col_name], - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_lstrip(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.StrLstripOp(" ").as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_minute(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.minute_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_month(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.month_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_neg(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.neg_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_normalize(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.normalize_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_obj_fetch_metadata(scalar_types_df: bpd.DataFrame, snapshot): - blob_s = scalar_types_df["string_col"].str.to_blob() - sql = blob_s.blob.version().to_frame().sql - snapshot.assert_match(sql, "out.sql") - - -def test_obj_get_access_url(scalar_types_df: bpd.DataFrame, snapshot): - blob_s = scalar_types_df["string_col"].str.to_blob() - sql = blob_s.blob.read_url().to_frame().sql - snapshot.assert_match(sql, "out.sql") - - -def test_pos(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.pos_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_quarter(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.quarter_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_replace_str(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.ReplaceStrOp("e", "a").as_expr(col_name)], [col_name] - ) - snapshot.assert_match(sql, "out.sql") - - -def test_regex_replace_str(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.RegexReplaceStrOp(r"e", "a").as_expr(col_name)], [col_name] - ) - snapshot.assert_match(sql, "out.sql") - - -def test_reverse(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.reverse_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_second(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.second_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_rstrip(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.StrRstripOp(" ").as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_sqrt(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.sqrt_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_startswith(scalar_types_df: bpd.DataFrame, snapshot): - - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - ops_map = { - "single": ops.StartsWithOp(pat=("ab",)).as_expr(col_name), - "double": ops.StartsWithOp(pat=("ab", "cd")).as_expr(col_name), - "empty": ops.StartsWithOp(pat=()).as_expr(col_name), - } - sql = _apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) - snapshot.assert_match(sql, "out.sql") - - -def test_str_get(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.StrGetOp(1).as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_str_pad(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - ops_map = { - "left": ops.StrPadOp(length=10, fillchar="-", side="left").as_expr(col_name), - "right": ops.StrPadOp(length=10, fillchar="-", side="right").as_expr(col_name), - "both": ops.StrPadOp(length=10, fillchar="-", side="both").as_expr(col_name), - } - sql = _apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) - snapshot.assert_match(sql, "out.sql") - - -def test_str_slice(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.StrSliceOp(1, 3).as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_strftime(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.StrftimeOp("%Y-%m-%d").as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_struct_field(nested_structs_types_df: bpd.DataFrame, snapshot): - col_name = "people" - bf_df = nested_structs_types_df[[col_name]] - - ops_map = { - # When a name string is provided. - "string": ops.StructFieldOp("name").as_expr(col_name), - # When an index integer is provided. - "int": ops.StructFieldOp(0).as_expr(col_name), - } - sql = _apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) - - snapshot.assert_match(sql, "out.sql") - - -def test_str_contains(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.StrContainsOp("e").as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_str_contains_regex(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.StrContainsRegexOp("e").as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_str_extract(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.StrExtractOp(r"([a-z]*)", 1).as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_str_repeat(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.StrRepeatOp(2).as_expr(col_name)], [col_name]) - snapshot.assert_match(sql, "out.sql") - - -def test_str_find(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - ops_map = { - "none_none": ops.StrFindOp("e", start=None, end=None).as_expr(col_name), - "start_none": ops.StrFindOp("e", start=2, end=None).as_expr(col_name), - "none_end": ops.StrFindOp("e", start=None, end=5).as_expr(col_name), - "start_end": ops.StrFindOp("e", start=2, end=5).as_expr(col_name), - } - sql = _apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) - - snapshot.assert_match(sql, "out.sql") - - -def test_strip(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.StrStripOp(" ").as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_iso_day(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.iso_day_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_iso_week(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.iso_week_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_iso_year(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.iso_year_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_isnull(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.isnull_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_notnull(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.notnull_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_sin(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.sin_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_sinh(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.sinh_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_string_split(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.StringSplitOp(pat=",").as_expr(col_name)], [col_name] - ) - snapshot.assert_match(sql, "out.sql") - - -def test_tan(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.tan_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_tanh(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "float64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.tanh_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_time(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.time_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_to_datetime(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "int64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.ToDatetimeOp().as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_to_timestamp(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "int64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.ToTimestampOp().as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_to_timedelta(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col"]] - bf_df["duration_us"] = bpd.to_timedelta(bf_df["int64_col"], "us") - bf_df["duration_s"] = bpd.to_timedelta(bf_df["int64_col"], "s") - bf_df["duration_w"] = bpd.to_timedelta(bf_df["int64_col"], "W") - - snapshot.assert_match(bf_df.sql, "out.sql") - - -def test_unix_micros(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.UnixMicros().as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_unix_millis(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.UnixMillis().as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_unix_seconds(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.UnixSeconds().as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_timedelta_floor(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "int64_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.timedelta_floor_op.as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_json_extract(json_types_df: bpd.DataFrame, snapshot): - col_name = "json_col" - bf_df = json_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.JSONExtract(json_path="$").as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_json_extract_array(json_types_df: bpd.DataFrame, snapshot): - col_name = "json_col" - bf_df = json_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.JSONExtractArray(json_path="$").as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_json_extract_string_array(json_types_df: bpd.DataFrame, snapshot): - col_name = "json_col" - bf_df = json_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.JSONExtractStringArray(json_path="$").as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_json_query(json_types_df: bpd.DataFrame, snapshot): - col_name = "json_col" - bf_df = json_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.JSONQuery(json_path="$").as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_json_query_array(json_types_df: bpd.DataFrame, snapshot): - col_name = "json_col" - bf_df = json_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.JSONQueryArray(json_path="$").as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_json_value(json_types_df: bpd.DataFrame, snapshot): - col_name = "json_col" - bf_df = json_types_df[[col_name]] - sql = _apply_unary_ops( - bf_df, [ops.JSONValue(json_path="$").as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_parse_json(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.ParseJSON().as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_to_json_string(json_types_df: bpd.DataFrame, snapshot): - col_name = "json_col" - bf_df = json_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.ToJSONString().as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_upper(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.upper_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_year(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.year_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - -def test_zfill(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "string_col" - bf_df = scalar_types_df[[col_name]] - sql = _apply_unary_ops(bf_df, [ops.ZfillOp(width=10).as_expr(col_name)], [col_name]) - snapshot.assert_match(sql, "out.sql") From a3de53f68b2a24f4ed85a474dfaff9b59570a2f1 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Wed, 17 Sep 2025 16:46:01 -0700 Subject: [PATCH 081/313] feat: support pandas series in ai.generate_bool (#2086) * feat: support pandas series in ai.generate_bool * fix mypy error * define PROMPT_TYPE with Union * fix type * update test * update comment * fix mypy * fix return type * update doc * fix doctest --- bigframes/bigquery/_operations/ai.py | 54 +++++++++++++++++++------- bigframes/operations/ai_ops.py | 2 +- tests/system/small/bigquery/test_ai.py | 27 +++++++++++-- 3 files changed, 63 insertions(+), 20 deletions(-) diff --git a/bigframes/bigquery/_operations/ai.py b/bigframes/bigquery/_operations/ai.py index d82023e4b5..3bafce6166 100644 --- a/bigframes/bigquery/_operations/ai.py +++ b/bigframes/bigquery/_operations/ai.py @@ -19,16 +19,25 @@ from __future__ import annotations import json -from typing import Any, List, Literal, Mapping, Tuple +from typing import Any, List, Literal, Mapping, Tuple, Union -from bigframes import clients, dtypes, series -from bigframes.core import log_adapter +import pandas as pd + +from bigframes import clients, dtypes, series, session +from bigframes.core import convert, log_adapter from bigframes.operations import ai_ops +PROMPT_TYPE = Union[ + series.Series, + pd.Series, + List[Union[str, series.Series, pd.Series]], + Tuple[Union[str, series.Series, pd.Series], ...], +] + @log_adapter.method_logger(custom_base_name="bigquery_ai") def generate_bool( - prompt: series.Series | List[str | series.Series] | Tuple[str | series.Series, ...], + prompt: PROMPT_TYPE, *, connection_id: str | None = None, endpoint: str | None = None, @@ -51,7 +60,7 @@ def generate_bool( 0 {'result': True, 'full_response': '{"candidate... 1 {'result': True, 'full_response': '{"candidate... 2 {'result': False, 'full_response': '{"candidat... - dtype: struct[pyarrow] + dtype: struct>, status: string>[pyarrow] >>> bbq.ai.generate_bool((df["col_1"], " is a ", df["col_2"])).struct.field("result") 0 True @@ -60,8 +69,9 @@ def generate_bool( Name: result, dtype: boolean Args: - prompt (series.Series | List[str|series.Series] | Tuple[str|series.Series, ...]): - A mixture of Series and string literals that specifies the prompt to send to the model. + prompt (Series | List[str|Series] | Tuple[str|Series, ...]): + A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series + or pandas Series. connection_id (str, optional): Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. If not provided, the connection from the current session will be used. @@ -84,7 +94,7 @@ def generate_bool( Returns: bigframes.series.Series: A new struct Series with the result data. The struct contains these fields: * "result": a BOOL value containing the model's response to the prompt. The result is None if the request fails or is filtered by responsible AI. - * "full_response": a STRING value containing the JSON response from the projects.locations.endpoints.generateContent call to the model. + * "full_response": a JSON value containing the response from the projects.locations.endpoints.generateContent call to the model. The generated text is in the text element. * "status": a STRING value that contains the API response status for the corresponding row. This value is empty if the operation was successful. """ @@ -104,7 +114,7 @@ def generate_bool( def _separate_context_and_series( - prompt: series.Series | List[str | series.Series] | Tuple[str | series.Series, ...], + prompt: PROMPT_TYPE, ) -> Tuple[List[str | None], List[series.Series]]: """ Returns the two values. The first value is the prompt with all series replaced by None. The second value is all the series @@ -123,18 +133,19 @@ def _separate_context_and_series( return [None], [prompt] prompt_context: List[str | None] = [] - series_list: List[series.Series] = [] + series_list: List[series.Series | pd.Series] = [] + session = None for item in prompt: if isinstance(item, str): prompt_context.append(item) - elif isinstance(item, series.Series): + elif isinstance(item, (series.Series, pd.Series)): prompt_context.append(None) - if item.dtype == dtypes.OBJ_REF_DTYPE: - # Multi-model support - item = item.blob.read_url() + if isinstance(item, series.Series) and session is None: + # Use the first available BF session if there's any. + session = item._session series_list.append(item) else: @@ -143,7 +154,20 @@ def _separate_context_and_series( if not series_list: raise ValueError("Please provide at least one Series in the prompt") - return prompt_context, series_list + converted_list = [_convert_series(s, session) for s in series_list] + + return prompt_context, converted_list + + +def _convert_series( + s: series.Series | pd.Series, session: session.Session | None +) -> series.Series: + result = convert.to_bf_series(s, default_index=None, session=session) + + if result.dtype == dtypes.OBJ_REF_DTYPE: + # Support multimodel + return result.blob.read_url() + return result def _resolve_connection_id(series: series.Series, connection_id: str | None): diff --git a/bigframes/operations/ai_ops.py b/bigframes/operations/ai_ops.py index fe5eb1406f..680c1585fb 100644 --- a/bigframes/operations/ai_ops.py +++ b/bigframes/operations/ai_ops.py @@ -40,7 +40,7 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT pa.struct( ( pa.field("result", pa.bool_()), - pa.field("full_response", pa.string()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), pa.field("status", pa.string()), ) ) diff --git a/tests/system/small/bigquery/test_ai.py b/tests/system/small/bigquery/test_ai.py index 443d4c54a3..be67a0d580 100644 --- a/tests/system/small/bigquery/test_ai.py +++ b/tests/system/small/bigquery/test_ai.py @@ -18,7 +18,7 @@ import pyarrow as pa import pytest -from bigframes import series +from bigframes import dtypes, series import bigframes.bigquery as bbq import bigframes.pandas as bpd @@ -35,7 +35,26 @@ def test_ai_generate_bool(session): pa.struct( ( pa.field("result", pa.bool_()), - pa.field("full_response", pa.string()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +def test_ai_generate_bool_with_pandas(session): + s1 = pd.Series(["apple", "bear"]) + s2 = bpd.Series(["fruit", "tree"], session=session) + prompt = (s1, " is a ", s2) + + result = bbq.ai.generate_bool(prompt, endpoint="gemini-2.5-flash") + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.bool_()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), pa.field("status", pa.string()), ) ) @@ -62,7 +81,7 @@ def test_ai_generate_bool_with_model_params(session): pa.struct( ( pa.field("result", pa.bool_()), - pa.field("full_response", pa.string()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), pa.field("status", pa.string()), ) ) @@ -81,7 +100,7 @@ def test_ai_generate_bool_multi_model(session): pa.struct( ( pa.field("result", pa.bool_()), - pa.field("full_response", pa.string()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), pa.field("status", pa.string()), ) ) From fd4b264dae065d499a5d5e4ea5187811a79e3062 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Thu, 18 Sep 2025 09:14:41 -0500 Subject: [PATCH 082/313] chore(main): release 2.21.0 (#2090) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 14 ++++++++++++++ bigframes/version.py | 4 ++-- third_party/bigframes_vendored/version.py | 4 ++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a67f6f8b86..c1868c0dbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ [1]: https://pypi.org/project/bigframes/#history +## [2.21.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.20.0...v2.21.0) (2025-09-17) + + +### Features + +* Add bigframes.bigquery.to_json ([#2078](https://github.com/googleapis/python-bigquery-dataframes/issues/2078)) ([0fc795a](https://github.com/googleapis/python-bigquery-dataframes/commit/0fc795a9fb56f469b62603462c3f0f56f52bfe04)) +* Support average='binary' in precision_score() ([#2080](https://github.com/googleapis/python-bigquery-dataframes/issues/2080)) ([920f381](https://github.com/googleapis/python-bigquery-dataframes/commit/920f381aec7e0a0b986886cdbc333e86335c6d7d)) +* Support pandas series in ai.generate_bool ([#2086](https://github.com/googleapis/python-bigquery-dataframes/issues/2086)) ([a3de53f](https://github.com/googleapis/python-bigquery-dataframes/commit/a3de53f68b2a24f4ed85a474dfaff9b59570a2f1)) + + +### Bug Fixes + +* Allow bigframes.options.bigquery.credentials to be `None` ([#2092](https://github.com/googleapis/python-bigquery-dataframes/issues/2092)) ([78f4001](https://github.com/googleapis/python-bigquery-dataframes/commit/78f4001e8fcfc77fc82f3893d58e0d04c0f6d3db)) + ## [2.20.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.19.0...v2.20.0) (2025-09-16) diff --git a/bigframes/version.py b/bigframes/version.py index 9d5d4361c0..f8f4376098 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.20.0" +__version__ = "2.21.0" # {x-release-please-start-date} -__release_date__ = "2025-09-16" +__release_date__ = "2025-09-17" # {x-release-please-end} diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index 9d5d4361c0..f8f4376098 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.20.0" +__version__ = "2.21.0" # {x-release-please-start-date} -__release_date__ = "2025-09-16" +__release_date__ = "2025-09-17" # {x-release-please-end} From a2daa3fffe6743327edb9f4c74db93198bd12f8e Mon Sep 17 00:00:00 2001 From: Garrett Wu <6505921+GarrettWu@users.noreply.github.com> Date: Thu, 18 Sep 2025 10:34:28 -0700 Subject: [PATCH 083/313] fix: Transformers with non-standard column names throw errors (#2089) * fix: Transformers with non-standard column names through errors * fix --- bigframes/ml/compose.py | 4 +-- bigframes/ml/impute.py | 2 ++ bigframes/ml/preprocessing.py | 8 +++++ tests/system/small/ml/test_preprocessing.py | 34 ++++++++++++++++++++- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/bigframes/ml/compose.py b/bigframes/ml/compose.py index 46d40d5fc8..92c98695cd 100644 --- a/bigframes/ml/compose.py +++ b/bigframes/ml/compose.py @@ -29,6 +29,7 @@ from bigframes.core import log_adapter import bigframes.core.compile.googlesql as sql_utils +import bigframes.core.utils as core_utils from bigframes.ml import base, core, globals, impute, preprocessing, utils import bigframes.pandas as bpd @@ -103,13 +104,12 @@ def __init__(self, sql: str, target_column: str = "transformed_{0}"): # TODO: More robust unescaping self._target_column = target_column.replace("`", "") - PLAIN_COLNAME_RX = re.compile("^[a-z][a-z0-9_]*$", re.IGNORECASE) - def _compile_to_sql( self, X: bpd.DataFrame, columns: Optional[Iterable[str]] = None ) -> List[str]: if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) result = [] for column in columns: current_sql = self._sql.format(sql_utils.identifier(column)) diff --git a/bigframes/ml/impute.py b/bigframes/ml/impute.py index f19c8e2cd3..818151a4f9 100644 --- a/bigframes/ml/impute.py +++ b/bigframes/ml/impute.py @@ -23,6 +23,7 @@ import bigframes_vendored.sklearn.impute._base from bigframes.core import log_adapter +import bigframes.core.utils as core_utils from bigframes.ml import base, core, globals, utils import bigframes.pandas as bpd @@ -62,6 +63,7 @@ def _compile_to_sql( Returns: a list of tuples sql_expr.""" if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) return [ self._base_sql_generator.ml_imputer( column, self.strategy, f"imputer_{column}" diff --git a/bigframes/ml/preprocessing.py b/bigframes/ml/preprocessing.py index 2e8dc64a53..94c61674f6 100644 --- a/bigframes/ml/preprocessing.py +++ b/bigframes/ml/preprocessing.py @@ -27,6 +27,7 @@ import bigframes_vendored.sklearn.preprocessing._polynomial from bigframes.core import log_adapter +import bigframes.core.utils as core_utils from bigframes.ml import base, core, globals, utils import bigframes.pandas as bpd @@ -59,6 +60,7 @@ def _compile_to_sql( Returns: a list of tuples sql_expr.""" if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) return [ self._base_sql_generator.ml_standard_scaler( column, f"standard_scaled_{column}" @@ -136,6 +138,7 @@ def _compile_to_sql( Returns: a list of tuples sql_expr.""" if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) return [ self._base_sql_generator.ml_max_abs_scaler( column, f"max_abs_scaled_{column}" @@ -214,6 +217,7 @@ def _compile_to_sql( Returns: a list of tuples sql_expr.""" if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) return [ self._base_sql_generator.ml_min_max_scaler( column, f"min_max_scaled_{column}" @@ -304,6 +308,7 @@ def _compile_to_sql( Returns: a list of tuples sql_expr.""" if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) array_split_points = {} if self.strategy == "uniform": for column in columns: @@ -433,6 +438,7 @@ def _compile_to_sql( Returns: a list of tuples sql_expr.""" if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) drop = self.drop if self.drop is not None else "none" # minus one here since BQML's implementation always includes index 0, and top_k is on top of that. top_k = ( @@ -547,6 +553,7 @@ def _compile_to_sql( Returns: a list of tuples sql_expr.""" if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) # minus one here since BQML's inplimentation always includes index 0, and top_k is on top of that. top_k = ( @@ -644,6 +651,7 @@ def _compile_to_sql( Returns: a list of tuples sql_expr.""" if columns is None: columns = X.columns + columns, _ = core_utils.get_standardized_ids(columns) output_name = "poly_feat" return [ self._base_sql_generator.ml_polynomial_expand( diff --git a/tests/system/small/ml/test_preprocessing.py b/tests/system/small/ml/test_preprocessing.py index 65a851efc3..3280b16f42 100644 --- a/tests/system/small/ml/test_preprocessing.py +++ b/tests/system/small/ml/test_preprocessing.py @@ -19,6 +19,7 @@ import bigframes.features from bigframes.ml import preprocessing +import bigframes.pandas as bpd from bigframes.testing import utils ONE_HOT_ENCODED_DTYPE = ( @@ -62,7 +63,7 @@ def test_standard_scaler_normalizes(penguins_df_default_index, new_penguins_df): pd.testing.assert_frame_equal(result, expected, rtol=0.1) -def test_standard_scaler_normalizeds_fit_transform(new_penguins_df): +def test_standard_scaler_normalizes_fit_transform(new_penguins_df): # TODO(http://b/292431644): add a second test that compares output to sklearn.preprocessing.StandardScaler, when BQML's change is in prod. scaler = preprocessing.StandardScaler() result = scaler.fit_transform( @@ -114,6 +115,37 @@ def test_standard_scaler_series_normalizes(penguins_df_default_index, new_pengui pd.testing.assert_frame_equal(result, expected, rtol=0.1) +def test_standard_scaler_normalizes_non_standard_column_names( + new_penguins_df: bpd.DataFrame, +): + new_penguins_df = new_penguins_df.rename( + columns={ + "culmen_length_mm": "culmen?metric", + "culmen_depth_mm": "culmen/metric", + } + ) + scaler = preprocessing.StandardScaler() + result = scaler.fit_transform( + new_penguins_df[["culmen?metric", "culmen/metric", "flipper_length_mm"]] + ).to_pandas() + + # If standard-scaled correctly, mean should be 0.0 + for column in result.columns: + assert math.isclose(result[column].mean(), 0.0, abs_tol=1e-3) + + expected = pd.DataFrame( + { + "standard_scaled_culmen_metric": [1.313249, -0.20198, -1.111118], + "standard_scaled_culmen_metric_1": [1.17072, -1.272416, 0.101848], + "standard_scaled_flipper_length_mm": [1.251089, -1.196588, -0.054338], + }, + dtype="Float64", + index=pd.Index([1633, 1672, 1690], name="tag_number", dtype="Int64"), + ) + + pd.testing.assert_frame_equal(result, expected, rtol=0.1) + + def test_standard_scaler_save_load(new_penguins_df, dataset_id): transformer = preprocessing.StandardScaler() transformer.fit( From 328a765e746138806a021bea22475e8c03512aeb Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 18 Sep 2025 12:03:11 -0700 Subject: [PATCH 084/313] feat: Add Groupby.describe() (#2088) --- bigframes/core/blocks.py | 8 +- bigframes/core/compile/api.py | 7 +- bigframes/core/groupby/dataframe_group_by.py | 14 ++ bigframes/core/groupby/series_group_by.py | 14 ++ bigframes/core/rewrite/implicit_align.py | 4 - bigframes/operations/aggregations.py | 7 +- bigframes/pandas/core/methods/describe.py | 185 +++++++++--------- tests/system/small/pandas/test_describe.py | 122 ++++++++++++ .../pandas/core/groupby/__init__.py | 60 ++++++ 9 files changed, 310 insertions(+), 111 deletions(-) diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index 6e22baabec..db59881c21 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -1780,7 +1780,9 @@ def pivot( else: return result_block.with_column_labels(columns_values) - def stack(self, how="left", levels: int = 1): + def stack( + self, how="left", levels: int = 1, *, override_labels: Optional[pd.Index] = None + ): """Unpivot last column axis level into row axis""" if levels == 0: return self @@ -1788,7 +1790,9 @@ def stack(self, how="left", levels: int = 1): # These are the values that will be turned into rows col_labels, row_labels = utils.split_index(self.column_labels, levels=levels) - row_labels = row_labels.drop_duplicates() + row_labels = ( + row_labels.drop_duplicates() if override_labels is None else override_labels + ) if col_labels is None: result_index: pd.Index = pd.Index([None]) diff --git a/bigframes/core/compile/api.py b/bigframes/core/compile/api.py index 3a4695c50d..dde6f3a325 100644 --- a/bigframes/core/compile/api.py +++ b/bigframes/core/compile/api.py @@ -15,19 +15,18 @@ from typing import TYPE_CHECKING -from bigframes.core import rewrite -from bigframes.core.compile.ibis_compiler import ibis_compiler - if TYPE_CHECKING: import bigframes.core.nodes def test_only_ibis_inferred_schema(node: bigframes.core.nodes.BigFrameNode): """Use only for testing paths to ensure ibis inferred schema does not diverge from bigframes inferred schema.""" + from bigframes.core.compile.ibis_compiler import ibis_compiler + import bigframes.core.rewrite import bigframes.core.schema node = ibis_compiler._replace_unsupported_ops(node) - node = rewrite.bake_order(node) + node = bigframes.core.rewrite.bake_order(node) ir = ibis_compiler.compile_node(node) items = tuple( bigframes.core.schema.SchemaItem(name, ir.get_column_type(ibis_id)) diff --git a/bigframes/core/groupby/dataframe_group_by.py b/bigframes/core/groupby/dataframe_group_by.py index 21f49fe563..f9c98d320c 100644 --- a/bigframes/core/groupby/dataframe_group_by.py +++ b/bigframes/core/groupby/dataframe_group_by.py @@ -149,6 +149,20 @@ def head(self, n: int = 5) -> df.DataFrame: ) ) + def describe(self, include: None | Literal["all"] = None): + from bigframes.pandas.core.methods import describe + + return df.DataFrame( + describe._describe( + self._block, + self._selected_cols, + include, + as_index=self._as_index, + by_col_ids=self._by_col_ids, + dropna=self._dropna, + ) + ) + def size(self) -> typing.Union[df.DataFrame, series.Series]: agg_block, _ = self._block.aggregate_size( by_column_ids=self._by_col_ids, diff --git a/bigframes/core/groupby/series_group_by.py b/bigframes/core/groupby/series_group_by.py index 8ab39d27cc..1839180b0e 100644 --- a/bigframes/core/groupby/series_group_by.py +++ b/bigframes/core/groupby/series_group_by.py @@ -75,6 +75,20 @@ def head(self, n: int = 5) -> series.Series: ) ) + def describe(self, include: None | Literal["all"] = None): + from bigframes.pandas.core.methods import describe + + return df.DataFrame( + describe._describe( + self._block, + columns=[self._value_column], + include=include, + as_index=True, + by_col_ids=self._by_col_ids, + dropna=self._dropna, + ) + ).droplevel(level=0, axis=1) + def all(self) -> series.Series: return self._aggregate(agg_ops.all_op) diff --git a/bigframes/core/rewrite/implicit_align.py b/bigframes/core/rewrite/implicit_align.py index 1989b1a543..a20b698ff4 100644 --- a/bigframes/core/rewrite/implicit_align.py +++ b/bigframes/core/rewrite/implicit_align.py @@ -18,12 +18,8 @@ from typing import cast, Optional, Sequence, Set, Tuple import bigframes.core.expression -import bigframes.core.guid import bigframes.core.identifiers -import bigframes.core.join_def import bigframes.core.nodes -import bigframes.core.window_spec -import bigframes.operations.aggregations # Combination of selects and additive nodes can be merged as an explicit keyless "row join" ALIGNABLE_NODES = ( diff --git a/bigframes/operations/aggregations.py b/bigframes/operations/aggregations.py index 02b475d198..7b6998b90e 100644 --- a/bigframes/operations/aggregations.py +++ b/bigframes/operations/aggregations.py @@ -251,12 +251,7 @@ def name(self): def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: if not dtypes.is_orderable(input_types[0]): raise TypeError(f"Type {input_types[0]} is not orderable") - if pd.api.types.is_bool_dtype(input_types[0]) or pd.api.types.is_integer_dtype( - input_types[0] - ): - return dtypes.FLOAT_DTYPE - else: - return input_types[0] + return input_types[0] @dataclasses.dataclass(frozen=True) diff --git a/bigframes/pandas/core/methods/describe.py b/bigframes/pandas/core/methods/describe.py index 18d2318379..f8a8721cf2 100644 --- a/bigframes/pandas/core/methods/describe.py +++ b/bigframes/pandas/core/methods/describe.py @@ -16,8 +16,15 @@ import typing +import pandas as pd + from bigframes import dataframe, dtypes, series -from bigframes.core.reshape import api as rs +from bigframes.core import agg_expressions, blocks +from bigframes.operations import aggregations + +_DEFAULT_DTYPES = ( + dtypes.NUMERIC_BIGFRAMES_TYPES_RESTRICTIVE + dtypes.TEMPORAL_NUMERIC_BIGFRAMES_TYPES +) def describe( @@ -30,100 +37,88 @@ def describe( elif not isinstance(input, dataframe.DataFrame): raise TypeError(f"Unsupported type: {type(input)}") + block = input._block + + describe_block = _describe(block, columns=block.value_columns, include=include) + # we override default stack behavior, because we want very specific ordering + stack_cols = pd.Index( + [ + "count", + "nunique", + "top", + "freq", + "mean", + "std", + "min", + "25%", + "50%", + "75%", + "max", + ] + ).intersection(describe_block.column_labels.get_level_values(-1)) + describe_block = describe_block.stack(override_labels=stack_cols) + + return dataframe.DataFrame(describe_block).droplevel(level=0) + + +def _describe( + block: blocks.Block, + columns: typing.Sequence[str], + include: None | typing.Literal["all"] = None, + *, + as_index: bool = True, + by_col_ids: typing.Sequence[str] = [], + dropna: bool = False, +) -> blocks.Block: + stats: list[agg_expressions.Aggregation] = [] + column_labels: list[typing.Hashable] = [] + + # include=None behaves like include='all' if no numeric columns present if include is None: - numeric_df = _select_dtypes( - input, - dtypes.NUMERIC_BIGFRAMES_TYPES_RESTRICTIVE - + dtypes.TEMPORAL_NUMERIC_BIGFRAMES_TYPES, - ) - if len(numeric_df.columns) == 0: - # Describe eligible non-numeric columns - return _describe_non_numeric(input) - - # Otherwise, only describe numeric columns - return _describe_numeric(input) - - elif include == "all": - numeric_result = _describe_numeric(input) - non_numeric_result = _describe_non_numeric(input) - - if len(numeric_result.columns) == 0: - return non_numeric_result - elif len(non_numeric_result.columns) == 0: - return numeric_result - else: - # Use reindex after join to preserve the original column order. - return rs.concat( - [non_numeric_result, numeric_result], axis=1 - )._reindex_columns(input.columns) - - else: - raise ValueError(f"Unsupported include type: {include}") - - -def _describe_numeric(df: dataframe.DataFrame) -> dataframe.DataFrame: - number_df_result = typing.cast( - dataframe.DataFrame, - _select_dtypes(df, dtypes.NUMERIC_BIGFRAMES_TYPES_RESTRICTIVE).agg( - [ - "count", - "mean", - "std", - "min", - "25%", - "50%", - "75%", - "max", - ] - ), - ) - temporal_df_result = typing.cast( - dataframe.DataFrame, - _select_dtypes(df, dtypes.TEMPORAL_NUMERIC_BIGFRAMES_TYPES).agg(["count"]), + if not any( + block.expr.get_column_type(col) in _DEFAULT_DTYPES for col in columns + ): + include = "all" + + for col_id in columns: + label = block.col_id_to_label[col_id] + dtype = block.expr.get_column_type(col_id) + if include != "all" and dtype not in _DEFAULT_DTYPES: + continue + agg_ops = _get_aggs_for_dtype(dtype) + stats.extend(op.as_expr(col_id) for op in agg_ops) + label_tuple = (label,) if block.column_labels.nlevels == 1 else label + column_labels.extend((*label_tuple, op.name) for op in agg_ops) # type: ignore + + agg_block, _ = block.aggregate( + by_column_ids=by_col_ids, + aggregations=stats, + dropna=dropna, + column_labels=pd.Index(column_labels, name=(*block.column_labels.names, None)), ) - - if len(number_df_result.columns) == 0: - return temporal_df_result - elif len(temporal_df_result.columns) == 0: - return number_df_result + return agg_block if as_index else agg_block.reset_index(drop=False) + + +def _get_aggs_for_dtype(dtype) -> list[aggregations.UnaryAggregateOp]: + if dtype in dtypes.NUMERIC_BIGFRAMES_TYPES_RESTRICTIVE: + return [ + aggregations.count_op, + aggregations.mean_op, + aggregations.std_op, + aggregations.min_op, + aggregations.ApproxQuartilesOp(1), + aggregations.ApproxQuartilesOp(2), + aggregations.ApproxQuartilesOp(3), + aggregations.max_op, + ] + elif dtype in dtypes.TEMPORAL_NUMERIC_BIGFRAMES_TYPES: + return [aggregations.count_op] + elif dtype in [ + dtypes.STRING_DTYPE, + dtypes.BOOL_DTYPE, + dtypes.BYTES_DTYPE, + dtypes.TIME_DTYPE, + ]: + return [aggregations.count_op, aggregations.nunique_op] else: - import bigframes.core.reshape.api as rs - - original_columns = _select_dtypes( - df, - dtypes.NUMERIC_BIGFRAMES_TYPES_RESTRICTIVE - + dtypes.TEMPORAL_NUMERIC_BIGFRAMES_TYPES, - ).columns - - # Use reindex after join to preserve the original column order. - return rs.concat( - [number_df_result, temporal_df_result], - axis=1, - )._reindex_columns(original_columns) - - -def _describe_non_numeric(df: dataframe.DataFrame) -> dataframe.DataFrame: - return typing.cast( - dataframe.DataFrame, - _select_dtypes( - df, - [ - dtypes.STRING_DTYPE, - dtypes.BOOL_DTYPE, - dtypes.BYTES_DTYPE, - dtypes.TIME_DTYPE, - ], - ).agg(["count", "nunique"]), - ) - - -def _select_dtypes( - df: dataframe.DataFrame, dtypes: typing.Sequence[dtypes.Dtype] -) -> dataframe.DataFrame: - """Selects columns without considering inheritance relationships.""" - columns = [ - col_id - for col_id, dtype in zip(df._block.value_columns, df._block.dtypes) - if dtype in dtypes - ] - return dataframe.DataFrame(df._block.select_columns(columns)) + return [] diff --git a/tests/system/small/pandas/test_describe.py b/tests/system/small/pandas/test_describe.py index 5971e47997..6f28811512 100644 --- a/tests/system/small/pandas/test_describe.py +++ b/tests/system/small/pandas/test_describe.py @@ -230,3 +230,125 @@ def test_series_describe_temporal(scalars_dfs): check_dtype=False, check_index_type=False, ) + + +def test_df_groupby_describe(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + numeric_columns = [ + "int64_col", + "float64_col", + ] + non_numeric_columns = ["string_col"] + supported_columns = numeric_columns + non_numeric_columns + + bf_full_result = ( + scalars_df.groupby("bool_col")[supported_columns] + .describe(include="all") + .to_pandas() + ) + + pd_full_result = scalars_pandas_df.groupby("bool_col")[supported_columns].describe( + include="all" + ) + + for col in supported_columns: + pd_result = pd_full_result[col] + bf_result = bf_full_result[col] + + if col in numeric_columns: + # Drop quartiles, as they are approximate + bf_min = bf_result["min"] + bf_p25 = bf_result["25%"] + bf_p50 = bf_result["50%"] + bf_p75 = bf_result["75%"] + bf_max = bf_result["max"] + + # Reindex results with the specified keys and their order, because + # the relative order is not important. + bf_result = bf_result.reindex( + columns=["count", "mean", "std", "min", "max"] + ) + pd_result = pd_result.reindex( + columns=["count", "mean", "std", "min", "max"] + ) + + # Double-check that quantiles are at least plausible. + assert ( + (bf_min <= bf_p25) + & (bf_p25 <= bf_p50) + & (bf_p50 <= bf_p50) + & (bf_p75 <= bf_max) + ).all() + else: + # Reindex results with the specified keys and their order, because + # the relative order is not important. + bf_result = bf_result.reindex(columns=["count", "nunique"]) + pd_result = pd_result.reindex(columns=["count", "unique"]) + pandas.testing.assert_frame_equal( + # BF counter part of "unique" is called "nunique" + pd_result.astype("Float64").rename(columns={"unique": "nunique"}), + bf_result, + check_dtype=False, + check_index_type=False, + ) + + +def test_series_groupby_describe(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + numeric_columns = [ + "int64_col", + "float64_col", + ] + non_numeric_columns = ["string_col"] + supported_columns = numeric_columns + non_numeric_columns + + bf_df = scalars_df.groupby("bool_col") + + pd_df = scalars_pandas_df.groupby("bool_col") + + for col in supported_columns: + pd_result = pd_df[col].describe(include="all") + bf_result = bf_df[col].describe(include="all").to_pandas() + + if col in numeric_columns: + # Drop quartiles, as they are approximate + bf_min = bf_result["min"] + bf_p25 = bf_result["25%"] + bf_p50 = bf_result["50%"] + bf_p75 = bf_result["75%"] + bf_max = bf_result["max"] + + # Reindex results with the specified keys and their order, because + # the relative order is not important. + bf_result = bf_result.reindex( + columns=["count", "mean", "std", "min", "max"] + ) + pd_result = pd_result.reindex( + columns=["count", "mean", "std", "min", "max"] + ) + + # Double-check that quantiles are at least plausible. + assert ( + (bf_min <= bf_p25) + & (bf_p25 <= bf_p50) + & (bf_p50 <= bf_p50) + & (bf_p75 <= bf_max) + ).all() + else: + # Reindex results with the specified keys and their order, because + # the relative order is not important. + bf_result = bf_result.reindex(columns=["count", "nunique"]) + pd_result = pd_result.reindex(columns=["count", "unique"]) + pandas.testing.assert_frame_equal( + # BF counter part of "unique" is called "nunique" + pd_result.astype("Float64").rename(columns={"unique": "nunique"}), + bf_result, + check_dtype=False, + check_index_type=False, + ) diff --git a/third_party/bigframes_vendored/pandas/core/groupby/__init__.py b/third_party/bigframes_vendored/pandas/core/groupby/__init__.py index b6b91388e3..306b65806b 100644 --- a/third_party/bigframes_vendored/pandas/core/groupby/__init__.py +++ b/third_party/bigframes_vendored/pandas/core/groupby/__init__.py @@ -9,6 +9,8 @@ class providing the base-class of operations. """ from __future__ import annotations +from typing import Literal + from bigframes import constants @@ -17,6 +19,64 @@ class GroupBy: Class for grouping and aggregating relational data. """ + def describe(self, include: None | Literal["all"] = None): + """ + Generate descriptive statistics. + + Descriptive statistics include those that summarize the central + tendency, dispersion and shape of a + dataset's distribution, excluding ``NaN`` values. + + Args: + include ("all" or None, optional): + If "all": All columns of the input will be included in the output. + If None: The result will include all numeric columns. + + .. note:: + Percentile values are approximates only. + + .. note:: + For numeric data, the result's index will include ``count``, + ``mean``, ``std``, ``min``, ``max`` as well as lower, ``50`` and + upper percentiles. By default the lower percentile is ``25`` and the + upper percentile is ``75``. The ``50`` percentile is the + same as the median. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None + + >>> df = bpd.DataFrame({"A": [1, 1, 1, 2, 2], "B": [0, 2, 8, 2, 7], "C": ["cat", "cat", "dog", "mouse", "cat"]}) + >>> df + A B C + 0 1 0 cat + 1 1 2 cat + 2 1 8 dog + 3 2 2 mouse + 4 2 7 cat + + [5 rows x 3 columns] + + >>> df.groupby("A").describe(include="all") + B C + count mean std min 25% 50% 75% max count nunique + A + 1 3 3.333333 4.163332 0 0 2 8 8 3 2 + 2 2 4.5 3.535534 2 2 2 7 7 2 2 + + [2 rows x 10 columns] + + Returns: + bigframes.pandas.DataFrame: + Summary statistics of the Series or Dataframe provided. + + Raises: + ValueError: + If unsupported ``include`` type is provided. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def any(self): """ Return True if any value in the group is true, else False. From 9dc96959a84b751d18b290129c2926df6e50b3f5 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 18 Sep 2025 12:04:02 -0700 Subject: [PATCH 085/313] fix: Throw type error for incomparable join keys (#2098) --- bigframes/core/array_value.py | 8 ++++++++ bigframes/dtypes.py | 5 +++++ bigframes/operations/type.py | 13 ++----------- tests/system/small/test_dataframe.py | 17 +++++++++++++++-- 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/bigframes/core/array_value.py b/bigframes/core/array_value.py index b37c581a4a..878d62bcb5 100644 --- a/bigframes/core/array_value.py +++ b/bigframes/core/array_value.py @@ -480,6 +480,14 @@ def relational_join( type: typing.Literal["inner", "outer", "left", "right", "cross"] = "inner", propogate_order: Optional[bool] = None, ) -> typing.Tuple[ArrayValue, typing.Tuple[dict[str, str], dict[str, str]]]: + for lcol, rcol in conditions: + ltype = self.get_column_type(lcol) + rtype = other.get_column_type(rcol) + if not bigframes.dtypes.can_compare(ltype, rtype): + raise TypeError( + f"Cannot join with non-comparable join key types: {ltype}, {rtype}" + ) + l_mapping = { # Identity mapping, only rename right side lcol.name: lcol.name for lcol in self.node.ids } diff --git a/bigframes/dtypes.py b/bigframes/dtypes.py index 2c4cccefd2..3695110672 100644 --- a/bigframes/dtypes.py +++ b/bigframes/dtypes.py @@ -358,6 +358,11 @@ def is_comparable(type_: ExpressionType) -> bool: return (type_ is not None) and is_orderable(type_) +def can_compare(type1: ExpressionType, type2: ExpressionType) -> bool: + coerced_type = coerce_to_common(type1, type2) + return is_comparable(coerced_type) + + def get_struct_fields(type_: ExpressionType) -> dict[str, Dtype]: assert isinstance(type_, pd.ArrowDtype) assert isinstance(type_.pyarrow_dtype, pa.StructType) diff --git a/bigframes/operations/type.py b/bigframes/operations/type.py index b4029d74c7..020bd0ea57 100644 --- a/bigframes/operations/type.py +++ b/bigframes/operations/type.py @@ -174,15 +174,7 @@ class CoerceCommon(BinaryTypeSignature): def output_type( self, left_type: ExpressionType, right_type: ExpressionType ) -> ExpressionType: - try: - return bigframes.dtypes.coerce_to_common(left_type, right_type) - except TypeError: - pass - if bigframes.dtypes.can_coerce(left_type, right_type): - return right_type - if bigframes.dtypes.can_coerce(right_type, left_type): - return left_type - raise TypeError(f"Cannot coerce {left_type} and {right_type} to a common type.") + return bigframes.dtypes.coerce_to_common(left_type, right_type) @dataclasses.dataclass @@ -192,8 +184,7 @@ class Comparison(BinaryTypeSignature): def output_type( self, left_type: ExpressionType, right_type: ExpressionType ) -> ExpressionType: - common_type = CoerceCommon().output_type(left_type, right_type) - if not bigframes.dtypes.is_comparable(common_type): + if not bigframes.dtypes.can_compare(left_type, right_type): raise TypeError(f"Types {left_type} and {right_type} are not comparable") return bigframes.dtypes.BOOL_DTYPE diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index bad90d0562..1a942a023e 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -3129,8 +3129,6 @@ def test_series_binop_add_different_table( @all_joins def test_join_same_table(scalars_dfs_maybe_ordered, how): bf_df, pd_df = scalars_dfs_maybe_ordered - if not bf_df._session._strictly_ordered and how == "cross": - pytest.skip("Cross join not supported in partial ordering mode.") bf_df_a = bf_df.set_index("int64_too")[["string_col", "int64_col"]] bf_df_a = bf_df_a.sort_index() @@ -3153,6 +3151,21 @@ def test_join_same_table(scalars_dfs_maybe_ordered, how): assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) +def test_join_incompatible_key_type_error(scalars_dfs): + bf_df, _ = scalars_dfs + + bf_df_a = bf_df.set_index("int64_too")[["string_col", "int64_col"]] + bf_df_a = bf_df_a.sort_index() + + bf_df_b = bf_df.set_index("date_col")[["float64_col"]] + bf_df_b = bf_df_b[bf_df_b.float64_col > 0] + bf_df_b = bf_df_b.sort_values("float64_col") + + with pytest.raises(TypeError): + # joining incompatible date, int columns + bf_df_a.join(bf_df_b, how="left") + + @all_joins def test_join_different_table( scalars_df_index, scalars_df_2_index, scalars_pandas_df_index, how From 999dd998e0cbee431a053e9eb6a2de552e46fe45 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Thu, 18 Sep 2025 14:34:24 -0700 Subject: [PATCH 086/313] refactor: add agg_ops.MinOp and MaxOp for sqlglot compiler (#2097) * refactor: add agg_ops.MinOp and MaxOp for sqlglot compiler * allow int timedelta to micro * address comments --- .pre-commit-config.yaml | 2 +- .../sqlglot/aggregations/unary_compiler.py | 38 +++++++++--- .../test_unary_compiler/test_count/out.sql | 12 ++++ .../test_unary_compiler/test_max/out.sql | 12 ++++ .../test_unary_compiler/test_min/out.sql | 12 ++++ .../{test_size => test_size_unary}/out.sql | 4 +- .../test_unary_compiler/test_sum/out.sql | 9 ++- .../aggregations/test_unary_compiler.py | 59 ++++++++++++++----- 8 files changed, 118 insertions(+), 30 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/out.sql rename tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/{test_size => test_size_unary}/out.sql (73%) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 90335cb8b9..b697d2324b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: hooks: - id: trailing-whitespace - id: end-of-file-fixer - exclude: "^tests/unit/core/compile/sqlglot/snapshots" + exclude: "^tests/unit/core/compile/sqlglot/.*snapshots" - id: check-yaml - repo: https://github.com/pycqa/isort rev: 5.12.0 diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py index c7eb84cba6..542bb10670 100644 --- a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -16,6 +16,7 @@ import typing +import pandas as pd import sqlglot.expressions as sge from bigframes import dtypes @@ -46,18 +47,22 @@ def _( return apply_window_if_present(sge.func("COUNT", column.expr), window) -@UNARY_OP_REGISTRATION.register(agg_ops.SumOp) +@UNARY_OP_REGISTRATION.register(agg_ops.MaxOp) def _( - op: agg_ops.SumOp, + op: agg_ops.MaxOp, column: typed_expr.TypedExpr, window: typing.Optional[window_spec.WindowSpec] = None, ) -> sge.Expression: - expr = column.expr - if column.dtype == dtypes.BOOL_DTYPE: - expr = sge.Cast(this=column.expr, to="INT64") - # Will be null if all inputs are null. Pandas defaults to zero sum though. - expr = apply_window_if_present(sge.func("SUM", expr), window) - return sge.func("IFNULL", expr, ir._literal(0, column.dtype)) + return apply_window_if_present(sge.func("MAX", column.expr), window) + + +@UNARY_OP_REGISTRATION.register(agg_ops.MinOp) +def _( + op: agg_ops.MinOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return apply_window_if_present(sge.func("MIN", column.expr), window) @UNARY_OP_REGISTRATION.register(agg_ops.SizeUnaryOp) @@ -67,3 +72,20 @@ def _( window: typing.Optional[window_spec.WindowSpec] = None, ) -> sge.Expression: return apply_window_if_present(sge.func("COUNT", sge.convert(1)), window) + + +@UNARY_OP_REGISTRATION.register(agg_ops.SumOp) +def _( + op: agg_ops.SumOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + expr = column.expr + if column.dtype == dtypes.BOOL_DTYPE: + expr = sge.Cast(this=column.expr, to="INT64") + + expr = apply_window_if_present(sge.func("SUM", expr), window) + + # Will be null if all inputs are null. Pandas defaults to zero sum though. + zero = pd.to_timedelta(0) if column.dtype == dtypes.TIMEDELTA_DTYPE else 0 + return sge.func("IFNULL", expr, ir._literal(zero, column.dtype)) diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/out.sql new file mode 100644 index 0000000000..01684b4af6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + COUNT(`bfcol_0`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/out.sql new file mode 100644 index 0000000000..c88fa58d0f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + MAX(`bfcol_0`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/out.sql new file mode 100644 index 0000000000..b067817218 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + MIN(`bfcol_0`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_size/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_size_unary/out.sql similarity index 73% rename from tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_size/out.sql rename to tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_size_unary/out.sql index 78104eb578..fffb4831b9 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_size/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_size_unary/out.sql @@ -1,6 +1,6 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `float64_col` AS `bfcol_0` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT @@ -8,5 +8,5 @@ WITH `bfcte_0` AS ( FROM `bfcte_0` ) SELECT - `bfcol_1` AS `string_col_agg` + `bfcol_1` AS `float64_col` FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/out.sql index e748f71278..be684f6768 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/out.sql @@ -1,12 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - COALESCE(SUM(`bfcol_0`), 0) AS `bfcol_1` + COALESCE(SUM(`bfcol_1`), 0) AS `bfcol_4`, + COALESCE(SUM(CAST(`bfcol_0` AS INT64)), 0) AS `bfcol_5` FROM `bfcte_0` ) SELECT - `bfcol_1` AS `int64_col_agg` + `bfcol_4` AS `int64_col`, + `bfcol_5` AS `bool_col` FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index d12b4dda17..311c039e11 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -12,40 +12,67 @@ # See the License for the specific language governing permissions and # limitations under the License. +import typing + import pytest -from bigframes.core import agg_expressions, array_value, expression, identifiers, nodes +from bigframes.core import agg_expressions as agg_exprs +from bigframes.core import array_value, identifiers, nodes from bigframes.operations import aggregations as agg_ops import bigframes.pandas as bpd pytest.importorskip("pytest_snapshot") -def _apply_unary_op(obj: bpd.DataFrame, op: agg_ops.UnaryWindowOp, arg: str) -> str: - agg_node = nodes.AggregateNode( - obj._block.expr.node, - aggregations=( - ( - agg_expressions.UnaryAggregation(op, expression.deref(arg)), - identifiers.ColumnId(arg + "_agg"), - ), - ), - ) +def _apply_unary_agg_ops( + obj: bpd.DataFrame, + ops_list: typing.Sequence[agg_exprs.UnaryAggregation], + new_names: typing.Sequence[str], +) -> str: + aggs = [(op, identifiers.ColumnId(name)) for op, name in zip(ops_list, new_names)] + + agg_node = nodes.AggregateNode(obj._block.expr.node, aggregations=tuple(aggs)) result = array_value.ArrayValue(agg_node) sql = result.session._executor.to_sql(result, enable_cache=False) return sql -def test_size(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_unary_op(bf_df, agg_ops.SizeUnaryOp(), "string_col") +def test_count(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.CountOp().as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_max(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.MaxOp().as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + +def test_min(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.MinOp().as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) snapshot.assert_match(sql, "out.sql") def test_sum(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col"]] - sql = _apply_unary_op(bf_df, agg_ops.SumOp(), "int64_col") + bf_df = scalar_types_df[["int64_col", "bool_col"]] + agg_ops_map = { + "int64_col": agg_ops.SumOp().as_expr("int64_col"), + "bool_col": agg_ops.SumOp().as_expr("bool_col"), + } + sql = _apply_unary_agg_ops( + bf_df, list(agg_ops_map.values()), list(agg_ops_map.keys()) + ) snapshot.assert_match(sql, "out.sql") From fb81eeaf13af059f32cb38e7f117fb3504243d51 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 18 Sep 2025 14:47:02 -0700 Subject: [PATCH 087/313] feat: Support df.info() with null index (#2094) --- bigframes/core/blocks.py | 4 ++++ bigframes/dataframe.py | 16 ++++++++----- tests/system/small/test_null_index.py | 34 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index db59881c21..95d9aee996 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -252,6 +252,10 @@ def from_local( pass return block + @property + def has_index(self) -> bool: + return len(self._index_columns) > 0 + @property def index(self) -> BlockIndexProperties: """Row identities for values in the Block.""" diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 371f69e713..f4d968a336 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -489,7 +489,6 @@ def memory_usage(self, index: bool = True): column_sizes = pandas.concat([index_size, column_sizes]) return column_sizes - @validations.requires_index def info( self, verbose: Optional[bool] = None, @@ -512,12 +511,17 @@ def info( obuf.write(f"{type(self)}\n") - index_type = "MultiIndex" if self.index.nlevels > 1 else "Index" + if self._block.has_index: + index_type = "MultiIndex" if self.index.nlevels > 1 else "Index" - # These accessses are kind of expensive, maybe should try to skip? - first_indice = self.index[0] - last_indice = self.index[-1] - obuf.write(f"{index_type}: {n_rows} entries, {first_indice} to {last_indice}\n") + # These accessses are kind of expensive, maybe should try to skip? + first_indice = self.index[0] + last_indice = self.index[-1] + obuf.write( + f"{index_type}: {n_rows} entries, {first_indice} to {last_indice}\n" + ) + else: + obuf.write("NullIndex\n") dtype_strings = self.dtypes.astype("string") if show_all_columns: diff --git a/tests/system/small/test_null_index.py b/tests/system/small/test_null_index.py index a1c7c0f1a3..4aa7ba8c77 100644 --- a/tests/system/small/test_null_index.py +++ b/tests/system/small/test_null_index.py @@ -13,6 +13,8 @@ # limitations under the License. +import io + import pandas as pd import pytest @@ -44,6 +46,38 @@ def test_null_index_materialize(scalars_df_null_index, scalars_pandas_df_default ) +def test_null_index_info(scalars_df_null_index): + expected = ( + "\n" + "NullIndex\n" + "Data columns (total 14 columns):\n" + " # Column Non-Null Count Dtype\n" + "--- ------------- ---------------- ------------------------------\n" + " 0 bool_col 8 non-null boolean\n" + " 1 bytes_col 6 non-null binary[pyarrow]\n" + " 2 date_col 7 non-null date32[day][pyarrow]\n" + " 3 datetime_col 6 non-null timestamp[us][pyarrow]\n" + " 4 geography_col 4 non-null geometry\n" + " 5 int64_col 8 non-null Int64\n" + " 6 int64_too 9 non-null Int64\n" + " 7 numeric_col 6 non-null decimal128(38, 9)[pyarrow]\n" + " 8 float64_col 7 non-null Float64\n" + " 9 rowindex_2 9 non-null Int64\n" + " 10 string_col 8 non-null string\n" + " 11 time_col 6 non-null time64[us][pyarrow]\n" + " 12 timestamp_col 6 non-null timestamp[us, tz=UTC][pyarrow]\n" + " 13 duration_col 7 non-null duration[us][pyarrow]\n" + "dtypes: Float64(1), Int64(3), binary[pyarrow](1), boolean(1), date32[day][pyarrow](1), decimal128(38, 9)[pyarrow](1), duration[us][pyarrow](1), geometry(1), string(1), time64[us][pyarrow](1), timestamp[us, tz=UTC][pyarrow](1), timestamp[us][pyarrow](1)\n" + "memory usage: 1269 bytes\n" + ) + + bf_result = io.StringIO() + + scalars_df_null_index.drop(columns="rowindex").info(buf=bf_result) + + assert expected == bf_result.getvalue() + + def test_null_index_series_repr(scalars_df_null_index, scalars_pandas_df_default_index): bf_result = scalars_df_null_index["int64_too"].head(5).__repr__() pd_result = ( From 801be1b0008ff4b54d4abf5f2660c10dcd9ad108 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Thu, 18 Sep 2025 15:15:09 -0700 Subject: [PATCH 088/313] chore: fix SQLGlot compiler op register type hints (#2101) --- bigframes/core/compile/sqlglot/scalar_compiler.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bigframes/core/compile/sqlglot/scalar_compiler.py b/bigframes/core/compile/sqlglot/scalar_compiler.py index 3e12da6d92..8167f40fc3 100644 --- a/bigframes/core/compile/sqlglot/scalar_compiler.py +++ b/bigframes/core/compile/sqlglot/scalar_compiler.py @@ -79,7 +79,7 @@ def register_unary_op( """ key = typing.cast(str, op_ref.name) - def decorator(impl: typing.Callable[..., TypedExpr]): + def decorator(impl: typing.Callable[..., sge.Expression]): def normalized_impl(args: typing.Sequence[TypedExpr], op: ops.RowOp): if pass_op: return impl(args[0], op) @@ -108,7 +108,7 @@ def register_binary_op( """ key = typing.cast(str, op_ref.name) - def decorator(impl: typing.Callable[..., TypedExpr]): + def decorator(impl: typing.Callable[..., sge.Expression]): def normalized_impl(args: typing.Sequence[TypedExpr], op: ops.RowOp): if pass_op: return impl(args[0], args[1], op) @@ -132,7 +132,7 @@ def register_ternary_op( """ key = typing.cast(str, op_ref.name) - def decorator(impl: typing.Callable[..., TypedExpr]): + def decorator(impl: typing.Callable[..., sge.Expression]): def normalized_impl(args: typing.Sequence[TypedExpr], op: ops.RowOp): return impl(args[0], args[1], args[2]) @@ -156,7 +156,7 @@ def register_nary_op( """ key = typing.cast(str, op_ref.name) - def decorator(impl: typing.Callable[..., TypedExpr]): + def decorator(impl: typing.Callable[..., sge.Expression]): def normalized_impl(args: typing.Sequence[TypedExpr], op: ops.RowOp): if pass_op: return impl(*args, op=op) From 10a38d74da5c6e27e9968bc77366e4e4d599c654 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 18 Sep 2025 16:24:12 -0700 Subject: [PATCH 089/313] chore: Fix join doc example (#2102) --- third_party/bigframes_vendored/pandas/core/frame.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/third_party/bigframes_vendored/pandas/core/frame.py b/third_party/bigframes_vendored/pandas/core/frame.py index 953ece9beb..1d8f5cbace 100644 --- a/third_party/bigframes_vendored/pandas/core/frame.py +++ b/third_party/bigframes_vendored/pandas/core/frame.py @@ -4672,10 +4672,10 @@ def join( Another option to join using the key columns is to use the on parameter: - >>> df1.join(df2, on="col1", how="right") + >>> df1.join(df2, on="col2", how="right") col1 col2 col3 col4 - 11 foo 3 - 22 baz 4 + 11 foo 3 + 22 baz 4 [2 rows x 4 columns] From c56a78cd509a535d4998d5b9a99ec3ecd334b883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Fri, 19 Sep 2025 09:28:55 -0500 Subject: [PATCH 090/313] feat: add `GroupBy.__iter__` (#1394) * feat: add `GroupBy.__iter__` * iterate over keys * match by key * implement it * refactor * revert notebook change --- bigframes/core/blocks.py | 6 + bigframes/core/groupby/dataframe_group_by.py | 18 +- bigframes/core/groupby/group_by.py | 91 ++++++ bigframes/core/groupby/series_group_by.py | 23 +- bigframes/dataframe.py | 11 + bigframes/series.py | 9 + tests/unit/core/test_groupby.py | 271 ++++++++++++++++++ .../pandas/core/groupby/__init__.py | 72 ++++- 8 files changed, 495 insertions(+), 6 deletions(-) create mode 100644 bigframes/core/groupby/group_by.py create mode 100644 tests/unit/core/test_groupby.py diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index 95d9aee996..f9896784bb 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -1375,10 +1375,16 @@ def aggregate( ) -> typing.Tuple[Block, typing.Sequence[str]]: """ Apply aggregations to the block. + Arguments: by_column_id: column id of the aggregation key, this is preserved through the transform and used as index. aggregations: input_column_id, operation tuples dropna: whether null keys should be dropped + + Returns: + Tuple[Block, Sequence[str]]: + The first element is the grouped block. The second is the + column IDs corresponding to each applied aggregation. """ if column_labels is None: column_labels = pd.Index(range(len(aggregations))) diff --git a/bigframes/core/groupby/dataframe_group_by.py b/bigframes/core/groupby/dataframe_group_by.py index f9c98d320c..40e96f6f42 100644 --- a/bigframes/core/groupby/dataframe_group_by.py +++ b/bigframes/core/groupby/dataframe_group_by.py @@ -16,7 +16,7 @@ import datetime import typing -from typing import Literal, Optional, Sequence, Tuple, Union +from typing import Iterable, Literal, Optional, Sequence, Tuple, Union import bigframes_vendored.constants as constants import bigframes_vendored.pandas.core.groupby as vendored_pandas_groupby @@ -29,7 +29,7 @@ from bigframes.core import log_adapter import bigframes.core.block_transforms as block_ops import bigframes.core.blocks as blocks -from bigframes.core.groupby import aggs, series_group_by +from bigframes.core.groupby import aggs, group_by, series_group_by import bigframes.core.ordering as order import bigframes.core.utils as utils import bigframes.core.validations as validations @@ -54,6 +54,7 @@ def __init__( selected_cols: typing.Optional[typing.Sequence[str]] = None, dropna: bool = True, as_index: bool = True, + by_key_is_singular: bool = False, ): # TODO(tbergeron): Support more group-by expression types self._block = block @@ -64,6 +65,9 @@ def __init__( ) } self._by_col_ids = by_col_ids + self._by_key_is_singular = by_key_is_singular + if by_key_is_singular: + assert len(by_col_ids) == 1, "singular key should be exactly one group key" self._dropna = dropna self._as_index = as_index @@ -163,6 +167,16 @@ def describe(self, include: None | Literal["all"] = None): ) ) + def __iter__(self) -> Iterable[Tuple[blocks.Label, df.DataFrame]]: + for group_keys, filtered_block in group_by.block_groupby_iter( + self._block, + by_col_ids=self._by_col_ids, + by_key_is_singular=self._by_key_is_singular, + dropna=self._dropna, + ): + filtered_df = df.DataFrame(filtered_block) + yield group_keys, filtered_df + def size(self) -> typing.Union[df.DataFrame, series.Series]: agg_block, _ = self._block.aggregate_size( by_column_ids=self._by_col_ids, diff --git a/bigframes/core/groupby/group_by.py b/bigframes/core/groupby/group_by.py new file mode 100644 index 0000000000..f00ff7c0b0 --- /dev/null +++ b/bigframes/core/groupby/group_by.py @@ -0,0 +1,91 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import functools +from typing import Sequence + +import pandas as pd + +from bigframes.core import blocks +from bigframes.core import expression as ex +import bigframes.enums +import bigframes.operations as ops + + +def block_groupby_iter( + block: blocks.Block, + *, + by_col_ids: Sequence[str], + by_key_is_singular: bool, + dropna: bool, +): + original_index_columns = block._index_columns + original_index_labels = block._index_labels + by_col_ids = by_col_ids + block = block.reset_index( + level=None, + # Keep the original index columns so they can be recovered. + drop=False, + allow_duplicates=True, + replacement=bigframes.enums.DefaultIndexKind.NULL, + ).set_index( + by_col_ids, + # Keep by_col_ids in-place so the ordering doesn't change. + drop=False, + append=False, + ) + block.cached( + force=True, + # All DataFrames will be filtered by by_col_ids, so + # force block.cached() to cluster by the new index by explicitly + # setting `session_aware=False`. This will ensure that the filters + # are more efficient. + session_aware=False, + ) + keys_block, _ = block.aggregate(by_col_ids, dropna=dropna) + for chunk in keys_block.to_pandas_batches(): + # Convert to MultiIndex to make sure we get tuples, + # even for singular keys. + by_keys_index = chunk.index + if not isinstance(by_keys_index, pd.MultiIndex): + by_keys_index = pd.MultiIndex.from_frame(by_keys_index.to_frame()) + + for by_keys in by_keys_index: + filtered_block = ( + # To ensure the cache is used, filter first, then reset the + # index before yielding the DataFrame. + block.filter( + functools.reduce( + ops.and_op.as_expr, + ( + ops.eq_op.as_expr(by_col, ex.const(by_key)) + for by_col, by_key in zip(by_col_ids, by_keys) + ), + ), + ).set_index( + original_index_columns, + # We retained by_col_ids in the set_index call above, + # so it's safe to drop the duplicates now. + drop=True, + append=False, + index_labels=original_index_labels, + ) + ) + + if by_key_is_singular: + yield by_keys[0], filtered_block + else: + yield by_keys, filtered_block diff --git a/bigframes/core/groupby/series_group_by.py b/bigframes/core/groupby/series_group_by.py index 1839180b0e..1f2632078d 100644 --- a/bigframes/core/groupby/series_group_by.py +++ b/bigframes/core/groupby/series_group_by.py @@ -16,7 +16,7 @@ import datetime import typing -from typing import Literal, Sequence, Union +from typing import Iterable, Literal, Sequence, Tuple, Union import bigframes_vendored.constants as constants import bigframes_vendored.pandas.core.groupby as vendored_pandas_groupby @@ -28,7 +28,7 @@ from bigframes.core import log_adapter import bigframes.core.block_transforms as block_ops import bigframes.core.blocks as blocks -from bigframes.core.groupby import aggs +from bigframes.core.groupby import aggs, group_by import bigframes.core.ordering as order import bigframes.core.utils as utils import bigframes.core.validations as validations @@ -52,6 +52,8 @@ def __init__( by_col_ids: typing.Sequence[str], value_name: blocks.Label = None, dropna=True, + *, + by_key_is_singular: bool = False, ): # TODO(tbergeron): Support more group-by expression types self._block = block @@ -60,6 +62,10 @@ def __init__( self._value_name = value_name self._dropna = dropna # Applies to aggregations but not windowing + self._by_key_is_singular = by_key_is_singular + if by_key_is_singular: + assert len(by_col_ids) == 1, "singular key should be exactly one group key" + @property def _session(self) -> session.Session: return self._block.session @@ -89,6 +95,19 @@ def describe(self, include: None | Literal["all"] = None): ) ).droplevel(level=0, axis=1) + def __iter__(self) -> Iterable[Tuple[blocks.Label, series.Series]]: + for group_keys, filtered_block in group_by.block_groupby_iter( + self._block, + by_col_ids=self._by_col_ids, + by_key_is_singular=self._by_key_is_singular, + dropna=self._dropna, + ): + filtered_series = series.Series( + filtered_block.select_column(self._value_column) + ) + filtered_series.name = self._value_name + yield group_keys, filtered_series + def all(self) -> series.Series: return self._aggregate(agg_ops.all_op) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index f4d968a336..ea5136f6f5 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -3913,11 +3913,17 @@ def _groupby_level( as_index: bool = True, dropna: bool = True, ): + if utils.is_list_like(level): + by_key_is_singular = False + else: + by_key_is_singular = True + return groupby.DataFrameGroupBy( self._block, by_col_ids=self._resolve_levels(level), as_index=as_index, dropna=dropna, + by_key_is_singular=by_key_is_singular, ) def _groupby_series( @@ -3930,10 +3936,14 @@ def _groupby_series( as_index: bool = True, dropna: bool = True, ): + # Pandas makes a distinction between groupby with a list of keys + # versus groupby with a single item in some methods, like __iter__. if not isinstance(by, bigframes.series.Series) and utils.is_list_like(by): by = list(by) + by_key_is_singular = False else: by = [typing.cast(typing.Union[blocks.Label, bigframes.series.Series], by)] + by_key_is_singular = True block = self._block col_ids: typing.Sequence[str] = [] @@ -3963,6 +3973,7 @@ def _groupby_series( by_col_ids=col_ids, as_index=as_index, dropna=dropna, + by_key_is_singular=by_key_is_singular, ) def abs(self) -> DataFrame: diff --git a/bigframes/series.py b/bigframes/series.py index da2f3f07c4..4e51181617 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -1854,12 +1854,18 @@ def _groupby_level( level: int | str | typing.Sequence[int] | typing.Sequence[str], dropna: bool = True, ) -> bigframes.core.groupby.SeriesGroupBy: + if utils.is_list_like(level): + by_key_is_singular = False + else: + by_key_is_singular = True + return groupby.SeriesGroupBy( self._block, self._value_column, by_col_ids=self._resolve_levels(level), value_name=self.name, dropna=dropna, + by_key_is_singular=by_key_is_singular, ) def _groupby_values( @@ -1871,8 +1877,10 @@ def _groupby_values( ) -> bigframes.core.groupby.SeriesGroupBy: if not isinstance(by, Series) and _is_list_like(by): by = list(by) + by_key_is_singular = False else: by = [typing.cast(typing.Union[blocks.Label, Series], by)] + by_key_is_singular = True block = self._block grouping_cols: typing.Sequence[str] = [] @@ -1904,6 +1912,7 @@ def _groupby_values( by_col_ids=grouping_cols, value_name=self.name, dropna=dropna, + by_key_is_singular=by_key_is_singular, ) def apply( diff --git a/tests/unit/core/test_groupby.py b/tests/unit/core/test_groupby.py new file mode 100644 index 0000000000..8df0e5344e --- /dev/null +++ b/tests/unit/core/test_groupby.py @@ -0,0 +1,271 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pandas as pd +import pandas.testing +import pytest + +import bigframes.core.utils as utils +import bigframes.pandas as bpd + +pytest.importorskip("polars") +pytest.importorskip("pandas", minversion="2.0.0") + + +# All tests in this file require polars to be installed to pass. +@pytest.fixture(scope="module") +def polars_session(): + from bigframes.testing import polars_session + + return polars_session.TestSession() + + +def test_groupby_df_iter_by_key_singular(polars_session): + pd_df = pd.DataFrame({"colA": ["a", "a", "b", "c", "c"], "colB": [1, 2, 3, 4, 5]}) + bf_df = bpd.DataFrame(pd_df, session=polars_session) + + for bf_group, pd_group in zip(bf_df.groupby("colA"), pd_df.groupby("colA")): # type: ignore + bf_key, bf_group_df = bf_group + bf_result = bf_group_df.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + pandas.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_df_iter_by_key_list(polars_session): + pd_df = pd.DataFrame({"colA": ["a", "a", "b", "c", "c"], "colB": [1, 2, 3, 4, 5]}) + bf_df = bpd.DataFrame(pd_df, session=polars_session) + + for bf_group, pd_group in zip(bf_df.groupby(["colA"]), pd_df.groupby(["colA"])): # type: ignore + bf_key, bf_group_df = bf_group + bf_result = bf_group_df.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + pandas.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_df_iter_by_key_list_multiple(polars_session): + pd_df = pd.DataFrame( + { + "colA": ["a", "a", "b", "c", "c"], + "colB": [1, 2, 3, 4, 5], + "colC": [True, False, True, False, True], + } + ) + bf_df = bpd.DataFrame(pd_df, session=polars_session) + + for bf_group, pd_group in zip( # type: ignore + bf_df.groupby(["colA", "colB"]), pd_df.groupby(["colA", "colB"]) + ): + bf_key, bf_group_df = bf_group + bf_result = bf_group_df.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + pandas.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_df_iter_by_level_singular(polars_session): + pd_df = pd.DataFrame( + {"colA": ["a", "a", "b", "c", "c"], "colB": [1, 2, 3, 4, 5]} + ).set_index("colA") + bf_df = bpd.DataFrame(pd_df, session=polars_session) + + for bf_group, pd_group in zip(bf_df.groupby(level=0), pd_df.groupby(level=0)): # type: ignore + bf_key, bf_group_df = bf_group + bf_result = bf_group_df.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + pandas.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_df_iter_by_level_list_one_item(polars_session): + pd_df = pd.DataFrame( + {"colA": ["a", "a", "b", "c", "c"], "colB": [1, 2, 3, 4, 5]} + ).set_index("colA") + bf_df = bpd.DataFrame(pd_df, session=polars_session) + + for bf_group, pd_group in zip(bf_df.groupby(level=[0]), pd_df.groupby(level=[0])): # type: ignore + bf_key, bf_group_df = bf_group + bf_result = bf_group_df.to_pandas() + pd_key, pd_result = pd_group + + # In pandas 2.x, we get a warning from pandas: "Creating a Groupby + # object with a length-1 list-like level parameter will yield indexes + # as tuples in a future version. To keep indexes as scalars, create + # Groupby objects with a scalar level parameter instead. + if utils.is_list_like(pd_key): + assert bf_key == tuple(pd_key) + else: + assert bf_key == (pd_key,) + pandas.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_df_iter_by_level_list_multiple(polars_session): + pd_df = pd.DataFrame( + { + "colA": ["a", "a", "b", "c", "c"], + "colB": [1, 2, 3, 4, 5], + "colC": [True, False, True, False, True], + } + ).set_index(["colA", "colB"]) + bf_df = bpd.DataFrame(pd_df, session=polars_session) + + for bf_group, pd_group in zip( # type: ignore + bf_df.groupby(level=[0, 1]), pd_df.groupby(level=[0, 1]) + ): + bf_key, bf_group_df = bf_group + bf_result = bf_group_df.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + pandas.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_series_iter_by_level_singular(polars_session): + series_index = ["a", "a", "b"] + pd_series = pd.Series([1, 2, 3], index=series_index) + bf_series = bpd.Series(pd_series, session=polars_session) + bf_series.name = pd_series.name + + for bf_group, pd_group in zip( # type: ignore + bf_series.groupby(level=0), pd_series.groupby(level=0) + ): + bf_key, bf_group_series = bf_group + bf_result = bf_group_series.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + pandas.testing.assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_series_iter_by_level_list_one_item(polars_session): + series_index = ["a", "a", "b"] + pd_series = pd.Series([1, 2, 3], index=series_index) + bf_series = bpd.Series(pd_series, session=polars_session) + bf_series.name = pd_series.name + + for bf_group, pd_group in zip( # type: ignore + bf_series.groupby(level=[0]), pd_series.groupby(level=[0]) + ): + bf_key, bf_group_series = bf_group + bf_result = bf_group_series.to_pandas() + pd_key, pd_result = pd_group + + # In pandas 2.x, we get a warning from pandas: "Creating a Groupby + # object with a length-1 list-like level parameter will yield indexes + # as tuples in a future version. To keep indexes as scalars, create + # Groupby objects with a scalar level parameter instead. + if utils.is_list_like(pd_key): + assert bf_key == tuple(pd_key) + else: + assert bf_key == (pd_key,) + pandas.testing.assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_series_iter_by_level_list_multiple(polars_session): + pd_df = pd.DataFrame( + { + "colA": ["a", "a", "b", "c", "c"], + "colB": [1, 2, 3, 4, 5], + "colC": [True, False, True, False, True], + } + ).set_index(["colA", "colB"]) + pd_series = pd_df["colC"] + bf_df = bpd.DataFrame(pd_df, session=polars_session) + bf_series = bf_df["colC"] + + for bf_group, pd_group in zip( # type: ignore + bf_series.groupby(level=[0, 1]), pd_series.groupby(level=[0, 1]) + ): + bf_key, bf_group_df = bf_group + bf_result = bf_group_df.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + pandas.testing.assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_series_iter_by_series(polars_session): + pd_groups = pd.Series(["a", "a", "b"]) + bf_groups = bpd.Series(pd_groups, session=polars_session) + pd_series = pd.Series([1, 2, 3]) + bf_series = bpd.Series(pd_series, session=polars_session) + bf_series.name = pd_series.name + + for bf_group, pd_group in zip( # type: ignore + bf_series.groupby(bf_groups), pd_series.groupby(pd_groups) + ): + bf_key, bf_group_series = bf_group + bf_result = bf_group_series.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + pandas.testing.assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_series_iter_by_series_list_one_item(polars_session): + pd_groups = pd.Series(["a", "a", "b"]) + bf_groups = bpd.Series(pd_groups, session=polars_session) + pd_series = pd.Series([1, 2, 3]) + bf_series = bpd.Series(pd_series, session=polars_session) + bf_series.name = pd_series.name + + for bf_group, pd_group in zip( # type: ignore + bf_series.groupby([bf_groups]), pd_series.groupby([pd_groups]) + ): + bf_key, bf_group_series = bf_group + bf_result = bf_group_series.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + pandas.testing.assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +def test_groupby_series_iter_by_series_list_multiple(polars_session): + pd_group_a = pd.Series(["a", "a", "b", "c", "c"]) + bf_group_a = bpd.Series(pd_group_a, session=polars_session) + pd_group_b = pd.Series([0, 0, 0, 1, 1]) + bf_group_b = bpd.Series(pd_group_b, session=polars_session) + pd_series = pd.Series([1, 2, 3, 4, 5]) + bf_series = bpd.Series(pd_series, session=polars_session) + bf_series.name = pd_series.name + + for bf_group, pd_group in zip( # type: ignore + bf_series.groupby([bf_group_a, bf_group_b]), + pd_series.groupby([pd_group_a, pd_group_b]), + ): + bf_key, bf_group_series = bf_group + bf_result = bf_group_series.to_pandas() + pd_key, pd_result = pd_group + assert bf_key == pd_key + pandas.testing.assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) diff --git a/third_party/bigframes_vendored/pandas/core/groupby/__init__.py b/third_party/bigframes_vendored/pandas/core/groupby/__init__.py index 306b65806b..1e39ec8f94 100644 --- a/third_party/bigframes_vendored/pandas/core/groupby/__init__.py +++ b/third_party/bigframes_vendored/pandas/core/groupby/__init__.py @@ -1259,11 +1259,11 @@ def size(self): **Examples:** - For SeriesGroupBy: - >>> import bigframes.pandas as bpd >>> bpd.options.display.progress_bar = None + For SeriesGroupBy: + >>> lst = ['a', 'a', 'b'] >>> ser = bpd.Series([1, 2, 3], index=lst) >>> ser @@ -1301,6 +1301,74 @@ def size(self): """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def __iter__(self): + r""" + Groupby iterator. + + This method provides an iterator over the groups created by the ``resample`` + or ``groupby`` operation on the object. The method yields tuples where + the first element is the label (group key) corresponding to each group or + resampled bin, and the second element is the subset of the data that falls + within that group or bin. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None + + For SeriesGroupBy: + + >>> lst = ["a", "a", "b"] + >>> ser = bpd.Series([1, 2, 3], index=lst) + >>> ser + a 1 + a 2 + b 3 + dtype: Int64 + >>> for x, y in ser.groupby(level=0): + ... print(f"{x}\n{y}\n") + a + a 1 + a 2 + dtype: Int64 + b + b 3 + dtype: Int64 + + For DataFrameGroupBy: + + >>> data = [[1, 2, 3], [1, 5, 6], [7, 8, 9]] + >>> df = bpd.DataFrame(data, columns=["a", "b", "c"]) + >>> df + a b c + 0 1 2 3 + 1 1 5 6 + 2 7 8 9 + + [3 rows x 3 columns] + >>> for x, y in df.groupby(by=["a"]): + ... print(f'{x}\n{y}\n') + (1,) + a b c + 0 1 2 3 + 1 1 5 6 + + [2 rows x 3 columns] + (7,) + + a b c + 2 7 8 9 + + [1 rows x 3 columns] + + + Returns: + Iterable[Label | Tuple, bigframes.pandas.Series | bigframes.pandas.DataFrame]: + Generator yielding sequence of (name, subsetted object) + for each group. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + class SeriesGroupBy(GroupBy): def agg(self, func): From ac25618feed2da11fe4fb85058d498d262c085c0 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Fri, 19 Sep 2025 11:46:40 -0700 Subject: [PATCH 091/313] feat: Support callable for series map method (#2100) --- bigframes/series.py | 4 +++- tests/system/large/functions/test_managed_function.py | 9 ++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/bigframes/series.py b/bigframes/series.py index 4e51181617..87387a4333 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -25,6 +25,7 @@ import typing from typing import ( Any, + Callable, cast, Iterable, List, @@ -2339,7 +2340,7 @@ def _throw_if_index_contains_duplicates( def map( self, - arg: typing.Union[Mapping, Series], + arg: typing.Union[Mapping, Series, Callable], na_action: Optional[str] = None, *, verify_integrity: bool = False, @@ -2361,6 +2362,7 @@ def map( ) map_df = map_df.set_index("keys") elif callable(arg): + # This is for remote function and managed funtion. return self.apply(arg) else: # Mirroring pandas, call the uncallable object diff --git a/tests/system/large/functions/test_managed_function.py b/tests/system/large/functions/test_managed_function.py index dd08ed17d9..e74bc8579f 100644 --- a/tests/system/large/functions/test_managed_function.py +++ b/tests/system/large/functions/test_managed_function.py @@ -1245,7 +1245,7 @@ def the_sum(s): cleanup_function_assets(the_sum_mf, session.bqclient, ignore_failures=False) -def test_managed_function_series_where_mask(session, dataset_id, scalars_dfs): +def test_managed_function_series_where_mask_map(session, dataset_id, scalars_dfs): try: # The return type has to be bool type for callable where condition. @@ -1286,6 +1286,13 @@ def _is_positive(s): # Ignore any dtype difference. pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + # Test series.map method. + bf_result = bf_int64_filtered.map(is_positive_mf).to_pandas() + pd_result = pd_int64_filtered.map(_is_positive) + + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + finally: # Clean up the gcp assets created for the managed function. cleanup_function_assets(is_positive_mf, session.bqclient, ignore_failures=False) From c4efa68d6d88197890e65612d86863689d0a3764 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Fri, 19 Sep 2025 15:03:09 -0700 Subject: [PATCH 092/313] refactor: reorganize the sqlglot scalar compiler layout - part 3 (#2095) --- bigframes/core/compile/sqlglot/__init__.py | 1 - .../sqlglot/expressions/binary_compiler.py | 241 --------------- .../compile/sqlglot/expressions/blob_ops.py | 6 + .../sqlglot/expressions/comparison_ops.py | 72 ++++- .../compile/sqlglot/expressions/json_ops.py | 6 + .../sqlglot/expressions/numeric_ops.py | 144 +++++++++ bigframes/testing/utils.py | 39 ++- .../test_mul_timedelta/out.sql | 43 --- .../test_obj_make_ref/out.sql | 0 .../test_eq_null_match/out.sql | 0 .../test_eq_numeric/out.sql | 0 .../test_ge_numeric/out.sql | 0 .../test_gt_numeric/out.sql | 0 .../test_le_numeric/out.sql | 0 .../test_lt_numeric/out.sql | 0 .../test_ne_numeric/out.sql | 0 .../test_add_timedelta/out.sql | 0 .../test_sub_timedelta/out.sql | 0 .../test_json_set/out.sql | 0 .../test_add_numeric/out.sql | 0 .../test_div_numeric/out.sql | 0 .../test_div_timedelta/out.sql | 0 .../test_floordiv_timedelta/out.sql | 0 .../test_mul_numeric/out.sql | 0 .../test_sub_numeric/out.sql | 0 .../test_add_string/out.sql | 0 .../expressions/test_binary_compiler.py | 278 ------------------ .../sqlglot/expressions/test_blob_ops.py | 5 + .../expressions/test_comparison_ops.py | 78 +++++ .../sqlglot/expressions/test_datetime_ops.py | 27 ++ .../sqlglot/expressions/test_json_ops.py | 10 + .../sqlglot/expressions/test_numeric_ops.py | 86 ++++++ .../sqlglot/expressions/test_string_ops.py | 8 + 33 files changed, 469 insertions(+), 575 deletions(-) delete mode 100644 bigframes/core/compile/sqlglot/expressions/binary_compiler.py delete mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_mul_timedelta/out.sql rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_blob_ops}/test_obj_make_ref/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_comparison_ops}/test_eq_null_match/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_comparison_ops}/test_eq_numeric/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_comparison_ops}/test_ge_numeric/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_comparison_ops}/test_gt_numeric/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_comparison_ops}/test_le_numeric/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_comparison_ops}/test_lt_numeric/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_comparison_ops}/test_ne_numeric/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_datetime_ops}/test_add_timedelta/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_datetime_ops}/test_sub_timedelta/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_json_ops}/test_json_set/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_numeric_ops}/test_add_numeric/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_numeric_ops}/test_div_numeric/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_numeric_ops}/test_div_timedelta/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_numeric_ops}/test_floordiv_timedelta/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_numeric_ops}/test_mul_numeric/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_numeric_ops}/test_sub_numeric/out.sql (100%) rename tests/unit/core/compile/sqlglot/expressions/snapshots/{test_binary_compiler => test_string_ops}/test_add_string/out.sql (100%) delete mode 100644 tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py diff --git a/bigframes/core/compile/sqlglot/__init__.py b/bigframes/core/compile/sqlglot/__init__.py index 5fe8099043..fdfb6f2161 100644 --- a/bigframes/core/compile/sqlglot/__init__.py +++ b/bigframes/core/compile/sqlglot/__init__.py @@ -15,7 +15,6 @@ from bigframes.core.compile.sqlglot.compiler import SQLGlotCompiler import bigframes.core.compile.sqlglot.expressions.array_ops # noqa: F401 -import bigframes.core.compile.sqlglot.expressions.binary_compiler # noqa: F401 import bigframes.core.compile.sqlglot.expressions.blob_ops # noqa: F401 import bigframes.core.compile.sqlglot.expressions.comparison_ops # noqa: F401 import bigframes.core.compile.sqlglot.expressions.date_ops # noqa: F401 diff --git a/bigframes/core/compile/sqlglot/expressions/binary_compiler.py b/bigframes/core/compile/sqlglot/expressions/binary_compiler.py deleted file mode 100644 index b18d15cae6..0000000000 --- a/bigframes/core/compile/sqlglot/expressions/binary_compiler.py +++ /dev/null @@ -1,241 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import annotations - -import bigframes_vendored.constants as bf_constants -import sqlglot.expressions as sge - -from bigframes import dtypes -from bigframes import operations as ops -import bigframes.core.compile.sqlglot.expressions.constants as constants -from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr -import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler - -register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op - -# TODO: add parenthesize for operators - - -@register_binary_op(ops.add_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - if left.dtype == dtypes.STRING_DTYPE and right.dtype == dtypes.STRING_DTYPE: - # String addition - return sge.Concat(expressions=[left.expr, right.expr]) - - if dtypes.is_numeric(left.dtype) and dtypes.is_numeric(right.dtype): - left_expr = _coerce_bool_to_int(left) - right_expr = _coerce_bool_to_int(right) - return sge.Add(this=left_expr, expression=right_expr) - - if ( - dtypes.is_time_or_date_like(left.dtype) - and right.dtype == dtypes.TIMEDELTA_DTYPE - ): - left_expr = _coerce_date_to_datetime(left) - return sge.TimestampAdd( - this=left_expr, expression=right.expr, unit=sge.Var(this="MICROSECOND") - ) - if ( - dtypes.is_time_or_date_like(right.dtype) - and left.dtype == dtypes.TIMEDELTA_DTYPE - ): - right_expr = _coerce_date_to_datetime(right) - return sge.TimestampAdd( - this=right_expr, expression=left.expr, unit=sge.Var(this="MICROSECOND") - ) - if left.dtype == dtypes.TIMEDELTA_DTYPE and right.dtype == dtypes.TIMEDELTA_DTYPE: - return sge.Add(this=left.expr, expression=right.expr) - - raise TypeError( - f"Cannot add type {left.dtype} and {right.dtype}. {bf_constants.FEEDBACK_LINK}" - ) - - -@register_binary_op(ops.eq_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - left_expr = _coerce_bool_to_int(left) - right_expr = _coerce_bool_to_int(right) - return sge.EQ(this=left_expr, expression=right_expr) - - -@register_binary_op(ops.eq_null_match_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - left_expr = left.expr - if right.dtype != dtypes.BOOL_DTYPE: - left_expr = _coerce_bool_to_int(left) - - right_expr = right.expr - if left.dtype != dtypes.BOOL_DTYPE: - right_expr = _coerce_bool_to_int(right) - - sentinel = sge.convert("$NULL_SENTINEL$") - left_coalesce = sge.Coalesce( - this=sge.Cast(this=left_expr, to="STRING"), expressions=[sentinel] - ) - right_coalesce = sge.Coalesce( - this=sge.Cast(this=right_expr, to="STRING"), expressions=[sentinel] - ) - return sge.EQ(this=left_coalesce, expression=right_coalesce) - - -@register_binary_op(ops.div_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - left_expr = _coerce_bool_to_int(left) - right_expr = _coerce_bool_to_int(right) - - result = sge.func("IEEE_DIVIDE", left_expr, right_expr) - if left.dtype == dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right.dtype): - return sge.Cast(this=sge.Floor(this=result), to="INT64") - else: - return result - - -@register_binary_op(ops.floordiv_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - left_expr = _coerce_bool_to_int(left) - right_expr = _coerce_bool_to_int(right) - - result: sge.Expression = sge.Cast( - this=sge.Floor(this=sge.func("IEEE_DIVIDE", left_expr, right_expr)), to="INT64" - ) - - # DIV(N, 0) will error in bigquery, but needs to return `0` for int, and - # `inf`` for float in BQ so we short-circuit in this case. - # Multiplying left by zero propogates nulls. - zero_result = ( - constants._INF - if (left.dtype == dtypes.FLOAT_DTYPE or right.dtype == dtypes.FLOAT_DTYPE) - else constants._ZERO - ) - result = sge.Case( - ifs=[ - sge.If( - this=sge.EQ(this=right_expr, expression=constants._ZERO), - true=zero_result * left_expr, - ) - ], - default=result, - ) - - if dtypes.is_numeric(right.dtype) and left.dtype == dtypes.TIMEDELTA_DTYPE: - result = sge.Cast(this=sge.Floor(this=result), to="INT64") - - return result - - -@register_binary_op(ops.ge_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - left_expr = _coerce_bool_to_int(left) - right_expr = _coerce_bool_to_int(right) - return sge.GTE(this=left_expr, expression=right_expr) - - -@register_binary_op(ops.gt_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - left_expr = _coerce_bool_to_int(left) - right_expr = _coerce_bool_to_int(right) - return sge.GT(this=left_expr, expression=right_expr) - - -@register_binary_op(ops.JSONSet, pass_op=True) -def _(left: TypedExpr, right: TypedExpr, op) -> sge.Expression: - return sge.func("JSON_SET", left.expr, sge.convert(op.json_path), right.expr) - - -@register_binary_op(ops.lt_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - left_expr = _coerce_bool_to_int(left) - right_expr = _coerce_bool_to_int(right) - return sge.LT(this=left_expr, expression=right_expr) - - -@register_binary_op(ops.le_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - left_expr = _coerce_bool_to_int(left) - right_expr = _coerce_bool_to_int(right) - return sge.LTE(this=left_expr, expression=right_expr) - - -@register_binary_op(ops.mul_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - left_expr = _coerce_bool_to_int(left) - right_expr = _coerce_bool_to_int(right) - - result = sge.Mul(this=left_expr, expression=right_expr) - - if (dtypes.is_numeric(left.dtype) and right.dtype == dtypes.TIMEDELTA_DTYPE) or ( - left.dtype == dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right.dtype) - ): - return sge.Cast(this=sge.Floor(this=result), to="INT64") - else: - return result - - -@register_binary_op(ops.ne_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - left_expr = _coerce_bool_to_int(left) - right_expr = _coerce_bool_to_int(right) - return sge.NEQ(this=left_expr, expression=right_expr) - - -@register_binary_op(ops.obj_make_ref_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - return sge.func("OBJ.MAKE_REF", left.expr, right.expr) - - -@register_binary_op(ops.sub_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - if dtypes.is_numeric(left.dtype) and dtypes.is_numeric(right.dtype): - left_expr = _coerce_bool_to_int(left) - right_expr = _coerce_bool_to_int(right) - return sge.Sub(this=left_expr, expression=right_expr) - - if ( - dtypes.is_time_or_date_like(left.dtype) - and right.dtype == dtypes.TIMEDELTA_DTYPE - ): - left_expr = _coerce_date_to_datetime(left) - return sge.TimestampSub( - this=left_expr, expression=right.expr, unit=sge.Var(this="MICROSECOND") - ) - if dtypes.is_time_or_date_like(left.dtype) and dtypes.is_time_or_date_like( - right.dtype - ): - left_expr = _coerce_date_to_datetime(left) - right_expr = _coerce_date_to_datetime(right) - return sge.TimestampDiff( - this=left_expr, expression=right_expr, unit=sge.Var(this="MICROSECOND") - ) - - if left.dtype == dtypes.TIMEDELTA_DTYPE and right.dtype == dtypes.TIMEDELTA_DTYPE: - return sge.Sub(this=left.expr, expression=right.expr) - - raise TypeError( - f"Cannot subtract type {left.dtype} and {right.dtype}. {bf_constants.FEEDBACK_LINK}" - ) - - -def _coerce_bool_to_int(typed_expr: TypedExpr) -> sge.Expression: - """Coerce boolean expression to integer.""" - if typed_expr.dtype == dtypes.BOOL_DTYPE: - return sge.Cast(this=typed_expr.expr, to="INT64") - return typed_expr.expr - - -def _coerce_date_to_datetime(typed_expr: TypedExpr) -> sge.Expression: - """Coerce date expression to datetime.""" - if typed_expr.dtype == dtypes.DATE_DTYPE: - return sge.Cast(this=typed_expr.expr, to="DATETIME") - return typed_expr.expr diff --git a/bigframes/core/compile/sqlglot/expressions/blob_ops.py b/bigframes/core/compile/sqlglot/expressions/blob_ops.py index 58f905087d..03708f80c6 100644 --- a/bigframes/core/compile/sqlglot/expressions/blob_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/blob_ops.py @@ -21,6 +21,7 @@ import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op @register_unary_op(ops.obj_fetch_metadata_op) @@ -31,3 +32,8 @@ def _(expr: TypedExpr) -> sge.Expression: @register_unary_op(ops.ObjGetAccessUrl) def _(expr: TypedExpr) -> sge.Expression: return sge.func("OBJ.GET_ACCESS_URL", expr.expr) + + +@register_binary_op(ops.obj_make_ref_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.func("OBJ.MAKE_REF", left.expr, right.expr) diff --git a/bigframes/core/compile/sqlglot/expressions/comparison_ops.py b/bigframes/core/compile/sqlglot/expressions/comparison_ops.py index 3bf94cf8ab..eb08144b8a 100644 --- a/bigframes/core/compile/sqlglot/expressions/comparison_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/comparison_ops.py @@ -19,12 +19,13 @@ import pandas as pd import sqlglot.expressions as sge +from bigframes import dtypes from bigframes import operations as ops from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler -import bigframes.dtypes as dtypes register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op @register_unary_op(ops.IsInOp, pass_op=True) @@ -53,7 +54,76 @@ def _(expr: TypedExpr, op: ops.IsInOp) -> sge.Expression: ) +@register_binary_op(ops.eq_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.EQ(this=left_expr, expression=right_expr) + + +@register_binary_op(ops.eq_null_match_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = left.expr + if right.dtype != dtypes.BOOL_DTYPE: + left_expr = _coerce_bool_to_int(left) + + right_expr = right.expr + if left.dtype != dtypes.BOOL_DTYPE: + right_expr = _coerce_bool_to_int(right) + + sentinel = sge.convert("$NULL_SENTINEL$") + left_coalesce = sge.Coalesce( + this=sge.Cast(this=left_expr, to="STRING"), expressions=[sentinel] + ) + right_coalesce = sge.Coalesce( + this=sge.Cast(this=right_expr, to="STRING"), expressions=[sentinel] + ) + return sge.EQ(this=left_coalesce, expression=right_coalesce) + + +@register_binary_op(ops.ge_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.GTE(this=left_expr, expression=right_expr) + + +@register_binary_op(ops.gt_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.GT(this=left_expr, expression=right_expr) + + +@register_binary_op(ops.lt_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.LT(this=left_expr, expression=right_expr) + + +@register_binary_op(ops.le_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.LTE(this=left_expr, expression=right_expr) + + +@register_binary_op(ops.ne_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.NEQ(this=left_expr, expression=right_expr) + + # Helpers def _is_null(value) -> bool: # float NaN/inf should be treated as distinct from 'true' null values return typing.cast(bool, pd.isna(value)) and not isinstance(value, float) + + +def _coerce_bool_to_int(typed_expr: TypedExpr) -> sge.Expression: + """Coerce boolean expression to integer.""" + if typed_expr.dtype == dtypes.BOOL_DTYPE: + return sge.Cast(this=typed_expr.expr, to="INT64") + return typed_expr.expr diff --git a/bigframes/core/compile/sqlglot/expressions/json_ops.py b/bigframes/core/compile/sqlglot/expressions/json_ops.py index 754e8d80eb..442eb9fdf5 100644 --- a/bigframes/core/compile/sqlglot/expressions/json_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/json_ops.py @@ -21,6 +21,7 @@ import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op @register_unary_op(ops.JSONExtract, pass_op=True) @@ -66,3 +67,8 @@ def _(expr: TypedExpr) -> sge.Expression: @register_unary_op(ops.ToJSONString) def _(expr: TypedExpr) -> sge.Expression: return sge.func("TO_JSON_STRING", expr.expr) + + +@register_binary_op(ops.JSONSet, pass_op=True) +def _(left: TypedExpr, right: TypedExpr, op) -> sge.Expression: + return sge.func("JSON_SET", left.expr, sge.convert(op.json_path), right.expr) diff --git a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py index 09c08e2095..1a6447ceb7 100644 --- a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py @@ -14,14 +14,17 @@ from __future__ import annotations +import bigframes_vendored.constants as bf_constants import sqlglot.expressions as sge +from bigframes import dtypes from bigframes import operations as ops import bigframes.core.compile.sqlglot.expressions.constants as constants from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op @register_unary_op(ops.abs_op) @@ -238,3 +241,144 @@ def _(expr: TypedExpr) -> sge.Expression: @register_unary_op(ops.tanh_op) def _(expr: TypedExpr) -> sge.Expression: return sge.func("TANH", expr.expr) + + +@register_binary_op(ops.add_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + if left.dtype == dtypes.STRING_DTYPE and right.dtype == dtypes.STRING_DTYPE: + # String addition + return sge.Concat(expressions=[left.expr, right.expr]) + + if dtypes.is_numeric(left.dtype) and dtypes.is_numeric(right.dtype): + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.Add(this=left_expr, expression=right_expr) + + if ( + dtypes.is_time_or_date_like(left.dtype) + and right.dtype == dtypes.TIMEDELTA_DTYPE + ): + left_expr = _coerce_date_to_datetime(left) + return sge.TimestampAdd( + this=left_expr, expression=right.expr, unit=sge.Var(this="MICROSECOND") + ) + if ( + dtypes.is_time_or_date_like(right.dtype) + and left.dtype == dtypes.TIMEDELTA_DTYPE + ): + right_expr = _coerce_date_to_datetime(right) + return sge.TimestampAdd( + this=right_expr, expression=left.expr, unit=sge.Var(this="MICROSECOND") + ) + if left.dtype == dtypes.TIMEDELTA_DTYPE and right.dtype == dtypes.TIMEDELTA_DTYPE: + return sge.Add(this=left.expr, expression=right.expr) + + raise TypeError( + f"Cannot add type {left.dtype} and {right.dtype}. {bf_constants.FEEDBACK_LINK}" + ) + + +@register_binary_op(ops.div_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + + result = sge.func("IEEE_DIVIDE", left_expr, right_expr) + if left.dtype == dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right.dtype): + return sge.Cast(this=sge.Floor(this=result), to="INT64") + else: + return result + + +@register_binary_op(ops.floordiv_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + + result: sge.Expression = sge.Cast( + this=sge.Floor(this=sge.func("IEEE_DIVIDE", left_expr, right_expr)), to="INT64" + ) + + # DIV(N, 0) will error in bigquery, but needs to return `0` for int, and + # `inf`` for float in BQ so we short-circuit in this case. + # Multiplying left by zero propogates nulls. + zero_result = ( + constants._INF + if (left.dtype == dtypes.FLOAT_DTYPE or right.dtype == dtypes.FLOAT_DTYPE) + else constants._ZERO + ) + result = sge.Case( + ifs=[ + sge.If( + this=sge.EQ(this=right_expr, expression=constants._ZERO), + true=zero_result * left_expr, + ) + ], + default=result, + ) + + if dtypes.is_numeric(right.dtype) and left.dtype == dtypes.TIMEDELTA_DTYPE: + result = sge.Cast(this=sge.Floor(this=result), to="INT64") + + return result + + +@register_binary_op(ops.mul_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + + result = sge.Mul(this=left_expr, expression=right_expr) + + if (dtypes.is_numeric(left.dtype) and right.dtype == dtypes.TIMEDELTA_DTYPE) or ( + left.dtype == dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right.dtype) + ): + return sge.Cast(this=sge.Floor(this=result), to="INT64") + else: + return result + + +@register_binary_op(ops.sub_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + if dtypes.is_numeric(left.dtype) and dtypes.is_numeric(right.dtype): + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.Sub(this=left_expr, expression=right_expr) + + if ( + dtypes.is_time_or_date_like(left.dtype) + and right.dtype == dtypes.TIMEDELTA_DTYPE + ): + left_expr = _coerce_date_to_datetime(left) + return sge.TimestampSub( + this=left_expr, expression=right.expr, unit=sge.Var(this="MICROSECOND") + ) + if dtypes.is_time_or_date_like(left.dtype) and dtypes.is_time_or_date_like( + right.dtype + ): + left_expr = _coerce_date_to_datetime(left) + right_expr = _coerce_date_to_datetime(right) + return sge.TimestampDiff( + this=left_expr, expression=right_expr, unit=sge.Var(this="MICROSECOND") + ) + + if left.dtype == dtypes.TIMEDELTA_DTYPE and right.dtype == dtypes.TIMEDELTA_DTYPE: + return sge.Sub(this=left.expr, expression=right.expr) + + raise TypeError( + f"Cannot subtract type {left.dtype} and {right.dtype}. {bf_constants.FEEDBACK_LINK}" + ) + + +def _coerce_bool_to_int(typed_expr: TypedExpr) -> sge.Expression: + """Coerce boolean expression to integer.""" + if typed_expr.dtype == dtypes.BOOL_DTYPE: + return sge.Cast(this=typed_expr.expr, to="INT64") + return typed_expr.expr + + +def _coerce_date_to_datetime(typed_expr: TypedExpr) -> sge.Expression: + """Coerce date expression to datetime.""" + if typed_expr.dtype == dtypes.DATE_DTYPE: + return sge.Cast(this=typed_expr.expr, to="DATETIME") + return typed_expr.expr diff --git a/bigframes/testing/utils.py b/bigframes/testing/utils.py index d38e323d57..b4daab7aad 100644 --- a/bigframes/testing/utils.py +++ b/bigframes/testing/utils.py @@ -25,9 +25,10 @@ import pyarrow as pa # type: ignore import pytest -from bigframes.core import expression as expr +from bigframes import operations as ops +from bigframes.core import expression as ex import bigframes.functions._utils as bff_utils -import bigframes.pandas +import bigframes.pandas as bpd ML_REGRESSION_METRICS = [ "mean_absolute_error", @@ -67,17 +68,13 @@ # Prefer this function for tests that run in both ordered and unordered mode -def assert_dfs_equivalent( - pd_df: pd.DataFrame, bf_df: bigframes.pandas.DataFrame, **kwargs -): +def assert_dfs_equivalent(pd_df: pd.DataFrame, bf_df: bpd.DataFrame, **kwargs): bf_df_local = bf_df.to_pandas() ignore_order = not bf_df._session._strictly_ordered assert_pandas_df_equal(bf_df_local, pd_df, ignore_order=ignore_order, **kwargs) -def assert_series_equivalent( - pd_series: pd.Series, bf_series: bigframes.pandas.Series, **kwargs -): +def assert_series_equivalent(pd_series: pd.Series, bf_series: bpd.Series, **kwargs): bf_df_local = bf_series.to_pandas() ignore_order = not bf_series._session._strictly_ordered assert_series_equal(bf_df_local, pd_series, ignore_order=ignore_order, **kwargs) @@ -452,12 +449,12 @@ def get_function_name(func, package_requirements=None, is_row_processor=False): def _apply_unary_ops( - obj: bigframes.pandas.DataFrame, - ops_list: Sequence[expr.Expression], + obj: bpd.DataFrame, + ops_list: Sequence[ex.Expression], new_names: Sequence[str], ) -> str: """Applies a list of unary ops to the given DataFrame and returns the SQL - representing the resulting DataFrames.""" + representing the resulting DataFrame.""" array_value = obj._block.expr result, old_names = array_value.compute_values(ops_list) @@ -468,3 +465,23 @@ def _apply_unary_ops( sql = result.session._executor.to_sql(result, enable_cache=False) return sql + + +def _apply_binary_op( + obj: bpd.DataFrame, + op: ops.BinaryOp, + l_arg: str, + r_arg: Union[str, ex.Expression], +) -> str: + """Applies a binary op to the given DataFrame and return the SQL representing + the resulting DataFrame.""" + array_value = obj._block.expr + op_expr = op.as_expr(l_arg, r_arg) + result, col_ids = array_value.compute_values([op_expr]) + + # Rename columns for deterministic golden SQL results. + assert len(col_ids) == 1 + result = result.rename_columns({col_ids[0]: l_arg}).select_columns([l_arg]) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_mul_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_mul_timedelta/out.sql deleted file mode 100644 index f8752d0a60..0000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_mul_timedelta/out.sql +++ /dev/null @@ -1,43 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `int64_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1`, - `timestamp_col` AS `bfcol_2`, - `duration_col` AS `bfcol_3` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_1` AS ( - SELECT - *, - `bfcol_1` AS `bfcol_8`, - `bfcol_2` AS `bfcol_9`, - `bfcol_0` AS `bfcol_10`, - `bfcol_3` AS `bfcol_11` - FROM `bfcte_0` -), `bfcte_2` AS ( - SELECT - *, - `bfcol_8` AS `bfcol_16`, - `bfcol_9` AS `bfcol_17`, - `bfcol_10` AS `bfcol_18`, - `bfcol_11` AS `bfcol_19`, - CAST(FLOOR(`bfcol_11` * `bfcol_10`) AS INT64) AS `bfcol_20` - FROM `bfcte_1` -), `bfcte_3` AS ( - SELECT - *, - `bfcol_16` AS `bfcol_26`, - `bfcol_17` AS `bfcol_27`, - `bfcol_18` AS `bfcol_28`, - `bfcol_19` AS `bfcol_29`, - `bfcol_20` AS `bfcol_30`, - CAST(FLOOR(`bfcol_18` * `bfcol_19`) AS INT64) AS `bfcol_31` - FROM `bfcte_2` -) -SELECT - `bfcol_26` AS `rowindex`, - `bfcol_27` AS `timestamp_col`, - `bfcol_28` AS `int64_col`, - `bfcol_29` AS `duration_col`, - `bfcol_30` AS `timedelta_mul_numeric`, - `bfcol_31` AS `numeric_mul_timedelta` -FROM `bfcte_3` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_obj_make_ref/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_make_ref/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_obj_make_ref/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_make_ref/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_eq_null_match/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_null_match/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_eq_null_match/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_null_match/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_eq_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_numeric/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_eq_numeric/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_numeric/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_ge_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ge_numeric/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_ge_numeric/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ge_numeric/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_gt_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_gt_numeric/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_gt_numeric/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_gt_numeric/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_le_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_le_numeric/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_le_numeric/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_le_numeric/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_lt_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_lt_numeric/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_lt_numeric/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_lt_numeric/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_ne_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ne_numeric/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_ne_numeric/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ne_numeric/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_add_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_add_timedelta/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_add_timedelta/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_add_timedelta/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_sub_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_sub_timedelta/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_sub_timedelta/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_sub_timedelta/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_json_set/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_set/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_json_set/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_set/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_add_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_numeric/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_add_numeric/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_numeric/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_div_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_numeric/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_div_numeric/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_numeric/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_div_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_timedelta/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_div_timedelta/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_timedelta/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floordiv_timedelta/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_timedelta/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floordiv_timedelta/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_mul_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_numeric/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_mul_numeric/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_numeric/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_sub_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_numeric/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_sub_numeric/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_numeric/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_add_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_add_string/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_add_string/out.sql rename to tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_add_string/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py b/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py deleted file mode 100644 index a2218d0afa..0000000000 --- a/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py +++ /dev/null @@ -1,278 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import typing - -import pandas as pd -import pytest - -from bigframes import operations as ops -import bigframes.core.expression as ex -import bigframes.pandas as bpd - -pytest.importorskip("pytest_snapshot") - - -def _apply_binary_op( - obj: bpd.DataFrame, - op: ops.BinaryOp, - l_arg: str, - r_arg: typing.Union[str, ex.Expression], -) -> str: - array_value = obj._block.expr - op_expr = op.as_expr(l_arg, r_arg) - result, col_ids = array_value.compute_values([op_expr]) - - # Rename columns for deterministic golden SQL results. - assert len(col_ids) == 1 - result = result.rename_columns({col_ids[0]: l_arg}).select_columns([l_arg]) - - sql = result.session._executor.to_sql(result, enable_cache=False) - return sql - - -def test_add_numeric(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col", "bool_col"]] - - bf_df["int_add_int"] = bf_df["int64_col"] + bf_df["int64_col"] - bf_df["int_add_1"] = bf_df["int64_col"] + 1 - - bf_df["int_add_bool"] = bf_df["int64_col"] + bf_df["bool_col"] - bf_df["bool_add_int"] = bf_df["bool_col"] + bf_df["int64_col"] - - snapshot.assert_match(bf_df.sql, "out.sql") - - -def test_add_string(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["string_col"]] - sql = _apply_binary_op(bf_df, ops.add_op, "string_col", ex.const("a")) - - snapshot.assert_match(sql, "out.sql") - - -def test_add_timedelta(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col", "date_col"]] - timedelta = pd.Timedelta(1, unit="d") - - bf_df["date_add_timedelta"] = bf_df["date_col"] + timedelta - bf_df["timestamp_add_timedelta"] = bf_df["timestamp_col"] + timedelta - bf_df["timedelta_add_date"] = timedelta + bf_df["date_col"] - bf_df["timedelta_add_timestamp"] = timedelta + bf_df["timestamp_col"] - bf_df["timedelta_add_timedelta"] = timedelta + timedelta - - snapshot.assert_match(bf_df.sql, "out.sql") - - -def test_add_unsupported_raises(scalar_types_df: bpd.DataFrame): - with pytest.raises(TypeError): - _apply_binary_op(scalar_types_df, ops.add_op, "timestamp_col", "date_col") - - with pytest.raises(TypeError): - _apply_binary_op(scalar_types_df, ops.add_op, "int64_col", "string_col") - - -def test_div_numeric(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col", "bool_col", "float64_col"]] - - bf_df["int_div_int"] = bf_df["int64_col"] / bf_df["int64_col"] - bf_df["int_div_1"] = bf_df["int64_col"] / 1 - bf_df["int_div_0"] = bf_df["int64_col"] / 0.0 - - bf_df["int_div_float"] = bf_df["int64_col"] / bf_df["float64_col"] - bf_df["float_div_int"] = bf_df["float64_col"] / bf_df["int64_col"] - bf_df["float_div_0"] = bf_df["float64_col"] / 0.0 - - bf_df["int_div_bool"] = bf_df["int64_col"] / bf_df["bool_col"] - bf_df["bool_div_int"] = bf_df["bool_col"] / bf_df["int64_col"] - - snapshot.assert_match(bf_df.sql, "out.sql") - - -def test_div_timedelta(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col", "int64_col"]] - timedelta = pd.Timedelta(1, unit="d") - bf_df["timedelta_div_numeric"] = timedelta / bf_df["int64_col"] - - snapshot.assert_match(bf_df.sql, "out.sql") - - -def test_eq_null_match(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col", "bool_col"]] - sql = _apply_binary_op(bf_df, ops.eq_null_match_op, "int64_col", "bool_col") - snapshot.assert_match(sql, "out.sql") - - -def test_eq_numeric(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col", "bool_col"]] - - bf_df["int_ne_int"] = bf_df["int64_col"] == bf_df["int64_col"] - bf_df["int_ne_1"] = bf_df["int64_col"] == 1 - - bf_df["int_ne_bool"] = bf_df["int64_col"] == bf_df["bool_col"] - bf_df["bool_ne_int"] = bf_df["bool_col"] == bf_df["int64_col"] - - snapshot.assert_match(bf_df.sql, "out.sql") - - -def test_floordiv_numeric(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col", "bool_col", "float64_col"]] - - bf_df["int_div_int"] = bf_df["int64_col"] // bf_df["int64_col"] - bf_df["int_div_1"] = bf_df["int64_col"] // 1 - bf_df["int_div_0"] = bf_df["int64_col"] // 0.0 - - bf_df["int_div_float"] = bf_df["int64_col"] // bf_df["float64_col"] - bf_df["float_div_int"] = bf_df["float64_col"] // bf_df["int64_col"] - bf_df["float_div_0"] = bf_df["float64_col"] // 0.0 - - bf_df["int_div_bool"] = bf_df["int64_col"] // bf_df["bool_col"] - bf_df["bool_div_int"] = bf_df["bool_col"] // bf_df["int64_col"] - - -def test_floordiv_timedelta(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col", "date_col"]] - timedelta = pd.Timedelta(1, unit="d") - - bf_df["timedelta_div_numeric"] = timedelta // 2 - - snapshot.assert_match(bf_df.sql, "out.sql") - - -def test_gt_numeric(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col", "bool_col"]] - - bf_df["int_gt_int"] = bf_df["int64_col"] > bf_df["int64_col"] - bf_df["int_gt_1"] = bf_df["int64_col"] > 1 - - bf_df["int_gt_bool"] = bf_df["int64_col"] > bf_df["bool_col"] - bf_df["bool_gt_int"] = bf_df["bool_col"] > bf_df["int64_col"] - - snapshot.assert_match(bf_df.sql, "out.sql") - - -def test_ge_numeric(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col", "bool_col"]] - - bf_df["int_ge_int"] = bf_df["int64_col"] >= bf_df["int64_col"] - bf_df["int_ge_1"] = bf_df["int64_col"] >= 1 - - bf_df["int_ge_bool"] = bf_df["int64_col"] >= bf_df["bool_col"] - bf_df["bool_ge_int"] = bf_df["bool_col"] >= bf_df["int64_col"] - - snapshot.assert_match(bf_df.sql, "out.sql") - - -def test_json_set(json_types_df: bpd.DataFrame, snapshot): - bf_df = json_types_df[["json_col"]] - sql = _apply_binary_op( - bf_df, ops.JSONSet(json_path="$.a"), "json_col", ex.const(100) - ) - - snapshot.assert_match(sql, "out.sql") - - -def test_lt_numeric(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col", "bool_col"]] - - bf_df["int_lt_int"] = bf_df["int64_col"] < bf_df["int64_col"] - bf_df["int_lt_1"] = bf_df["int64_col"] < 1 - - bf_df["int_lt_bool"] = bf_df["int64_col"] < bf_df["bool_col"] - bf_df["bool_lt_int"] = bf_df["bool_col"] < bf_df["int64_col"] - - snapshot.assert_match(bf_df.sql, "out.sql") - - -def test_le_numeric(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col", "bool_col"]] - - bf_df["int_le_int"] = bf_df["int64_col"] <= bf_df["int64_col"] - bf_df["int_le_1"] = bf_df["int64_col"] <= 1 - - bf_df["int_le_bool"] = bf_df["int64_col"] <= bf_df["bool_col"] - bf_df["bool_le_int"] = bf_df["bool_col"] <= bf_df["int64_col"] - - snapshot.assert_match(bf_df.sql, "out.sql") - - -def test_sub_numeric(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col", "bool_col"]] - - bf_df["int_add_int"] = bf_df["int64_col"] - bf_df["int64_col"] - bf_df["int_add_1"] = bf_df["int64_col"] - 1 - - bf_df["int_add_bool"] = bf_df["int64_col"] - bf_df["bool_col"] - bf_df["bool_add_int"] = bf_df["bool_col"] - bf_df["int64_col"] - - snapshot.assert_match(bf_df.sql, "out.sql") - - -def test_sub_timedelta(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col", "duration_col", "date_col"]] - bf_df["duration_col"] = bpd.to_timedelta(bf_df["duration_col"], unit="us") - - bf_df["date_sub_timedelta"] = bf_df["date_col"] - bf_df["duration_col"] - bf_df["timestamp_sub_timedelta"] = bf_df["timestamp_col"] - bf_df["duration_col"] - bf_df["timestamp_sub_date"] = bf_df["date_col"] - bf_df["date_col"] - bf_df["date_sub_timestamp"] = bf_df["timestamp_col"] - bf_df["timestamp_col"] - bf_df["timedelta_sub_timedelta"] = bf_df["duration_col"] - bf_df["duration_col"] - - snapshot.assert_match(bf_df.sql, "out.sql") - - -def test_sub_unsupported_raises(scalar_types_df: bpd.DataFrame): - with pytest.raises(TypeError): - _apply_binary_op(scalar_types_df, ops.sub_op, "string_col", "string_col") - - with pytest.raises(TypeError): - _apply_binary_op(scalar_types_df, ops.sub_op, "int64_col", "string_col") - - -def test_mul_numeric(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col", "bool_col"]] - - bf_df["int_mul_int"] = bf_df["int64_col"] * bf_df["int64_col"] - bf_df["int_mul_1"] = bf_df["int64_col"] * 1 - - bf_df["int_mul_bool"] = bf_df["int64_col"] * bf_df["bool_col"] - bf_df["bool_mul_int"] = bf_df["bool_col"] * bf_df["int64_col"] - - snapshot.assert_match(bf_df.sql, "out.sql") - - -def test_mul_timedelta(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["timestamp_col", "int64_col", "duration_col"]] - bf_df["duration_col"] = bpd.to_timedelta(bf_df["duration_col"], unit="us") - - bf_df["timedelta_mul_numeric"] = bf_df["duration_col"] * bf_df["int64_col"] - bf_df["numeric_mul_timedelta"] = bf_df["int64_col"] * bf_df["duration_col"] - - snapshot.assert_match(bf_df.sql, "out.sql") - - -def test_obj_make_ref(scalar_types_df: bpd.DataFrame, snapshot): - blob_df = scalar_types_df["string_col"].str.to_blob() - snapshot.assert_match(blob_df.to_frame().sql, "out.sql") - - -def test_ne_numeric(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col", "bool_col"]] - - bf_df["int_ne_int"] = bf_df["int64_col"] != bf_df["int64_col"] - bf_df["int_ne_1"] = bf_df["int64_col"] != 1 - - bf_df["int_ne_bool"] = bf_df["int64_col"] != bf_df["bool_col"] - bf_df["bool_ne_int"] = bf_df["bool_col"] != bf_df["int64_col"] - - snapshot.assert_match(bf_df.sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_blob_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_blob_ops.py index 7876a754ee..80aa22aaac 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_blob_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_blob_ops.py @@ -29,3 +29,8 @@ def test_obj_get_access_url(scalar_types_df: bpd.DataFrame, snapshot): blob_s = scalar_types_df["string_col"].str.to_blob() sql = blob_s.blob.read_url().to_frame().sql snapshot.assert_match(sql, "out.sql") + + +def test_obj_make_ref(scalar_types_df: bpd.DataFrame, snapshot): + blob_df = scalar_types_df["string_col"].str.to_blob() + snapshot.assert_match(blob_df.to_frame().sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py index 9a901687fa..6c3eb64414 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py @@ -42,3 +42,81 @@ def test_is_in(scalar_types_df: bpd.DataFrame, snapshot): sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") + + +def test_eq_null_match(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + sql = utils._apply_binary_op(bf_df, ops.eq_null_match_op, "int64_col", "bool_col") + snapshot.assert_match(sql, "out.sql") + + +def test_eq_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_ne_int"] = bf_df["int64_col"] == bf_df["int64_col"] + bf_df["int_ne_1"] = bf_df["int64_col"] == 1 + + bf_df["int_ne_bool"] = bf_df["int64_col"] == bf_df["bool_col"] + bf_df["bool_ne_int"] = bf_df["bool_col"] == bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_gt_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_gt_int"] = bf_df["int64_col"] > bf_df["int64_col"] + bf_df["int_gt_1"] = bf_df["int64_col"] > 1 + + bf_df["int_gt_bool"] = bf_df["int64_col"] > bf_df["bool_col"] + bf_df["bool_gt_int"] = bf_df["bool_col"] > bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_ge_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_ge_int"] = bf_df["int64_col"] >= bf_df["int64_col"] + bf_df["int_ge_1"] = bf_df["int64_col"] >= 1 + + bf_df["int_ge_bool"] = bf_df["int64_col"] >= bf_df["bool_col"] + bf_df["bool_ge_int"] = bf_df["bool_col"] >= bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_lt_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_lt_int"] = bf_df["int64_col"] < bf_df["int64_col"] + bf_df["int_lt_1"] = bf_df["int64_col"] < 1 + + bf_df["int_lt_bool"] = bf_df["int64_col"] < bf_df["bool_col"] + bf_df["bool_lt_int"] = bf_df["bool_col"] < bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_le_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_le_int"] = bf_df["int64_col"] <= bf_df["int64_col"] + bf_df["int_le_1"] = bf_df["int64_col"] <= 1 + + bf_df["int_le_bool"] = bf_df["int64_col"] <= bf_df["bool_col"] + bf_df["bool_le_int"] = bf_df["bool_col"] <= bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_ne_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_ne_int"] = bf_df["int64_col"] != bf_df["int64_col"] + bf_df["int_ne_1"] = bf_df["int64_col"] != 1 + + bf_df["int_ne_bool"] = bf_df["int64_col"] != bf_df["bool_col"] + bf_df["bool_ne_int"] = bf_df["bool_col"] != bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py index 0a8aa320bb..91926e7bdd 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pandas as pd import pytest from bigframes import operations as ops @@ -215,3 +216,29 @@ def test_iso_year(scalar_types_df: bpd.DataFrame, snapshot): sql = utils._apply_unary_ops(bf_df, [ops.iso_year_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") + + +def test_add_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col", "date_col"]] + timedelta = pd.Timedelta(1, unit="d") + + bf_df["date_add_timedelta"] = bf_df["date_col"] + timedelta + bf_df["timestamp_add_timedelta"] = bf_df["timestamp_col"] + timedelta + bf_df["timedelta_add_date"] = timedelta + bf_df["date_col"] + bf_df["timedelta_add_timestamp"] = timedelta + bf_df["timestamp_col"] + bf_df["timedelta_add_timedelta"] = timedelta + timedelta + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_sub_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col", "duration_col", "date_col"]] + bf_df["duration_col"] = bpd.to_timedelta(bf_df["duration_col"], unit="us") + + bf_df["date_sub_timedelta"] = bf_df["date_col"] - bf_df["duration_col"] + bf_df["timestamp_sub_timedelta"] = bf_df["timestamp_col"] - bf_df["duration_col"] + bf_df["timestamp_sub_date"] = bf_df["date_col"] - bf_df["date_col"] + bf_df["date_sub_timestamp"] = bf_df["timestamp_col"] - bf_df["timestamp_col"] + bf_df["timedelta_sub_timedelta"] = bf_df["duration_col"] - bf_df["duration_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py index ecbac10ef2..75206091e0 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py @@ -15,6 +15,7 @@ import pytest from bigframes import operations as ops +import bigframes.core.expression as ex import bigframes.pandas as bpd from bigframes.testing import utils @@ -97,3 +98,12 @@ def test_to_json_string(json_types_df: bpd.DataFrame, snapshot): ) snapshot.assert_match(sql, "out.sql") + + +def test_json_set(json_types_df: bpd.DataFrame, snapshot): + bf_df = json_types_df[["json_col"]] + sql = utils._apply_binary_op( + bf_df, ops.JSONSet(json_path="$.a"), "json_col", ex.const(100) + ) + + snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py index 10fd4b2427..e0c41857e9 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pandas as pd import pytest from bigframes import operations as ops @@ -211,3 +212,88 @@ def test_tanh(scalar_types_df: bpd.DataFrame, snapshot): sql = utils._apply_unary_ops(bf_df, [ops.tanh_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") + + +def test_add_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_add_int"] = bf_df["int64_col"] + bf_df["int64_col"] + bf_df["int_add_1"] = bf_df["int64_col"] + 1 + + bf_df["int_add_bool"] = bf_df["int64_col"] + bf_df["bool_col"] + bf_df["bool_add_int"] = bf_df["bool_col"] + bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_div_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col", "float64_col"]] + + bf_df["int_div_int"] = bf_df["int64_col"] / bf_df["int64_col"] + bf_df["int_div_1"] = bf_df["int64_col"] / 1 + bf_df["int_div_0"] = bf_df["int64_col"] / 0.0 + + bf_df["int_div_float"] = bf_df["int64_col"] / bf_df["float64_col"] + bf_df["float_div_int"] = bf_df["float64_col"] / bf_df["int64_col"] + bf_df["float_div_0"] = bf_df["float64_col"] / 0.0 + + bf_df["int_div_bool"] = bf_df["int64_col"] / bf_df["bool_col"] + bf_df["bool_div_int"] = bf_df["bool_col"] / bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_div_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col", "int64_col"]] + timedelta = pd.Timedelta(1, unit="d") + bf_df["timedelta_div_numeric"] = timedelta / bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_floordiv_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col", "float64_col"]] + + bf_df["int_div_int"] = bf_df["int64_col"] // bf_df["int64_col"] + bf_df["int_div_1"] = bf_df["int64_col"] // 1 + bf_df["int_div_0"] = bf_df["int64_col"] // 0.0 + + bf_df["int_div_float"] = bf_df["int64_col"] // bf_df["float64_col"] + bf_df["float_div_int"] = bf_df["float64_col"] // bf_df["int64_col"] + bf_df["float_div_0"] = bf_df["float64_col"] // 0.0 + + bf_df["int_div_bool"] = bf_df["int64_col"] // bf_df["bool_col"] + bf_df["bool_div_int"] = bf_df["bool_col"] // bf_df["int64_col"] + + +def test_floordiv_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col", "date_col"]] + timedelta = pd.Timedelta(1, unit="d") + + bf_df["timedelta_div_numeric"] = timedelta // 2 + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_mul_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_mul_int"] = bf_df["int64_col"] * bf_df["int64_col"] + bf_df["int_mul_1"] = bf_df["int64_col"] * 1 + + bf_df["int_mul_bool"] = bf_df["int64_col"] * bf_df["bool_col"] + bf_df["bool_mul_int"] = bf_df["bool_col"] * bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_sub_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + + bf_df["int_add_int"] = bf_df["int64_col"] - bf_df["int64_col"] + bf_df["int_add_1"] = bf_df["int64_col"] - 1 + + bf_df["int_add_bool"] = bf_df["int64_col"] - bf_df["bool_col"] + bf_df["bool_add_int"] = bf_df["bool_col"] - bf_df["int64_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py index 79c67a09ca..9121334811 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py @@ -15,6 +15,7 @@ import pytest from bigframes import operations as ops +import bigframes.core.expression as ex import bigframes.pandas as bpd from bigframes.testing import utils @@ -303,3 +304,10 @@ def test_zfill(scalar_types_df: bpd.DataFrame, snapshot): bf_df, [ops.ZfillOp(width=10).as_expr(col_name)], [col_name] ) snapshot.assert_match(sql, "out.sql") + + +def test_add_string(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = utils._apply_binary_op(bf_df, ops.add_op, "string_col", ex.const("a")) + + snapshot.assert_match(sql, "out.sql") From 6b0653fba9f4ed71d7ed62720d383692ec69b408 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Mon, 22 Sep 2025 13:45:13 -0700 Subject: [PATCH 093/313] chore: implement ai.generate_bool in SQLGlot compiler (#2103) * chore: implement ai.generate_bool in SQLGlot compiler * fix lint * fix test * add comment on sge.JSON --- bigframes/core/compile/sqlglot/__init__.py | 1 + .../compile/sqlglot/expressions/ai_ops.py | 65 ++++++++++++++++++ .../test_ai_ops/test_ai_generate_bool/out.sql | 18 +++++ .../out.sql | 18 +++++ .../sqlglot/expressions/test_ai_ops.py | 67 +++++++++++++++++++ 5 files changed, 169 insertions(+) create mode 100644 bigframes/core/compile/sqlglot/expressions/ai_ops.py create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py diff --git a/bigframes/core/compile/sqlglot/__init__.py b/bigframes/core/compile/sqlglot/__init__.py index fdfb6f2161..1fc22e1af6 100644 --- a/bigframes/core/compile/sqlglot/__init__.py +++ b/bigframes/core/compile/sqlglot/__init__.py @@ -14,6 +14,7 @@ from __future__ import annotations from bigframes.core.compile.sqlglot.compiler import SQLGlotCompiler +import bigframes.core.compile.sqlglot.expressions.ai_ops # noqa: F401 import bigframes.core.compile.sqlglot.expressions.array_ops # noqa: F401 import bigframes.core.compile.sqlglot.expressions.blob_ops # noqa: F401 import bigframes.core.compile.sqlglot.expressions.comparison_ops # noqa: F401 diff --git a/bigframes/core/compile/sqlglot/expressions/ai_ops.py b/bigframes/core/compile/sqlglot/expressions/ai_ops.py new file mode 100644 index 0000000000..8395461575 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/ai_ops.py @@ -0,0 +1,65 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot import scalar_compiler +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr + +register_nary_op = scalar_compiler.scalar_op_compiler.register_nary_op + + +@register_nary_op(ops.AIGenerateBool, pass_op=True) +def _(*exprs: TypedExpr, op: ops.AIGenerateBool) -> sge.Expression: + + prompt: list[str | sge.Expression] = [] + column_ref_idx = 0 + + for elem in op.prompt_context: + if elem is None: + prompt.append(exprs[column_ref_idx].expr) + else: + prompt.append(sge.Literal.string(elem)) + + args = [sge.Kwarg(this="prompt", expression=sge.Tuple(expressions=prompt))] + + args.append( + sge.Kwarg(this="connection_id", expression=sge.Literal.string(op.connection_id)) + ) + + if op.endpoint is not None: + args.append( + sge.Kwarg(this="endpoint", expression=sge.Literal.string(op.endpoint)) + ) + + args.append( + sge.Kwarg( + this="request_type", expression=sge.Literal.string(op.request_type.upper()) + ) + ) + + if op.model_params is not None: + args.append( + sge.Kwarg( + this="model_params", + # sge.JSON requires a newer SQLGlot version than 23.6.3. + # PARSE_JSON won't work as the function requires a JSON literal. + expression=sge.JSON(this=sge.Literal.string(op.model_params)), + ) + ) + + return sge.func("AI.GENERATE_BOOL", *args) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql new file mode 100644 index 0000000000..584ccd9ce1 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_BOOL( + prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + connection_id => 'test_connection_id', + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql new file mode 100644 index 0000000000..fca2b965bf --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_BOOL( + prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + connection_id => 'test_connection_id', + request_type => 'SHARED', + model_params => JSON '{}' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py new file mode 100644 index 0000000000..15b9ae516b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py @@ -0,0 +1,67 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import sys + +import pytest + +from bigframes import dataframe +from bigframes import operations as ops +from bigframes.testing import utils + +pytest.importorskip("pytest_snapshot") + + +def test_ai_generate_bool(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + + op = ops.AIGenerateBool( + prompt_context=(None, " is the same as ", None), + connection_id="test_connection_id", + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + ) + + sql = utils._apply_unary_ops( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_bool_with_model_param( + scalar_types_df: dataframe.DataFrame, snapshot +): + if sys.version_info < (3, 10): + pytest.skip( + "Skip test because SQLGLot cannot compile model params to JSON at this env." + ) + + col_name = "string_col" + + op = ops.AIGenerateBool( + prompt_context=(None, " is the same as ", None), + connection_id="test_connection_id", + endpoint=None, + request_type="shared", + model_params=json.dumps(dict()), + ) + + sql = utils._apply_unary_ops( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") From f57a348f1935a4e2bb14c501bb4c47cd552d102a Mon Sep 17 00:00:00 2001 From: Garrett Wu <6505921+GarrettWu@users.noreply.github.com> Date: Mon, 22 Sep 2025 14:34:19 -0700 Subject: [PATCH 094/313] fix: negative start and stop parameter values in Series.str.slice() (#2104) --- tests/system/small/operations/test_strings.py | 15 ++++++++- .../bigframes_vendored/ibis/expr/rewrites.py | 31 +++++++++++-------- .../ibis/expr/types/strings.py | 9 ------ 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/tests/system/small/operations/test_strings.py b/tests/system/small/operations/test_strings.py index afd1a74dff..d3e868db59 100644 --- a/tests/system/small/operations/test_strings.py +++ b/tests/system/small/operations/test_strings.py @@ -236,7 +236,20 @@ def test_reverse(scalars_dfs): @pytest.mark.parametrize( - ["start", "stop"], [(0, 1), (3, 5), (100, 101), (None, 1), (0, 12), (0, None)] + ["start", "stop"], + [ + (0, 1), + (3, 5), + (100, 101), + (None, 1), + (0, 12), + (0, None), + (None, -1), + (-1, None), + (-5, -1), + (1, -1), + (-10, 10), + ], ) def test_slice(scalars_dfs, start, stop): scalars_df, scalars_pandas_df = scalars_dfs diff --git a/third_party/bigframes_vendored/ibis/expr/rewrites.py b/third_party/bigframes_vendored/ibis/expr/rewrites.py index b0569846da..779a5081ca 100644 --- a/third_party/bigframes_vendored/ibis/expr/rewrites.py +++ b/third_party/bigframes_vendored/ibis/expr/rewrites.py @@ -206,21 +206,26 @@ def replace_parameter(_, params, **kwargs): @replace(p.StringSlice) def lower_stringslice(_, **kwargs): """Rewrite StringSlice in terms of Substring.""" - if _.end is None: - return ops.Substring(_.arg, start=_.start) if _.start is None: - return ops.Substring(_.arg, start=0, length=_.end) - if ( - isinstance(_.start, ops.Literal) - and isinstance(_.start.value, int) - and isinstance(_.end, ops.Literal) - and isinstance(_.end.value, int) - ): - # optimization for constant values - length = _.end.value - _.start.value + real_start = 0 else: - length = ops.Subtract(_.end, _.start) - return ops.Substring(_.arg, start=_.start, length=length) + real_start = ops.IfElse( + ops.GreaterEqual(_.start, 0), + _.start, + ops.Greatest((0, ops.Add(ops.StringLength(_.arg), _.start))), + ) + + if _.end is None: + real_end = ops.StringLength(_.arg) + else: + real_end = ops.IfElse( + ops.GreaterEqual(_.end, 0), + _.end, + ops.Greatest((0, ops.Add(ops.StringLength(_.arg), _.end))), + ) + + length = ops.Greatest((0, ops.Subtract(real_end, real_start))) + return ops.Substring(_.arg, start=real_start, length=length) @replace(p.Analytic) diff --git a/third_party/bigframes_vendored/ibis/expr/types/strings.py b/third_party/bigframes_vendored/ibis/expr/types/strings.py index 85b455e66e..f63cf96e72 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/strings.py +++ b/third_party/bigframes_vendored/ibis/expr/types/strings.py @@ -96,15 +96,6 @@ def __getitem__(self, key: slice | int | ir.IntegerScalar) -> StringValue: if isinstance(step, ir.Expr) or (step is not None and step != 1): raise ValueError("Step can only be 1") - if start is not None and not isinstance(start, ir.Expr) and start < 0: - raise ValueError( - "Negative slicing not yet supported, got start value " - f"of {start:d}" - ) - if stop is not None and not isinstance(stop, ir.Expr) and stop < 0: - raise ValueError( - "Negative slicing not yet supported, got stop value " f"of {stop:d}" - ) if start is None and stop is None: return self return ops.StringSlice(self, start, stop).to_expr() From 60056ca06511f99092647fe55fc02eeab486b4ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Tue, 23 Sep 2025 14:25:42 -0500 Subject: [PATCH 095/313] feat: implement `Index.to_list()` (#2106) * feat: implement Index.to_list() This commit implements the `Index.to_list()` method, which is an alias for `tolist()`. This new method provides a way to convert a BigQuery DataFrames Index to a Python list, similar to the existing `Series.to_list()` method. The implementation follows the pattern of other methods in the library by first converting the Index to a pandas Index using `to_pandas()` and then calling the corresponding `.to_list()` method. A unit test has been added to verify the functionality of the new method. * Update bigframes/core/indexes/base.py * Update tests/unit/test_index.py --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- bigframes/core/indexes/base.py | 3 +++ tests/system/small/test_index.py | 6 ++++++ tests/unit/test_index.py | 11 +++++++++++ 3 files changed, 20 insertions(+) diff --git a/bigframes/core/indexes/base.py b/bigframes/core/indexes/base.py index 2a35ab6546..c5e2657629 100644 --- a/bigframes/core/indexes/base.py +++ b/bigframes/core/indexes/base.py @@ -740,6 +740,9 @@ def to_numpy(self, dtype=None, *, allow_large_results=None, **kwargs) -> np.ndar __array__ = to_numpy + def to_list(self, *, allow_large_results: Optional[bool] = None) -> list: + return self.to_pandas(allow_large_results=allow_large_results).to_list() + def __len__(self): return self.shape[0] diff --git a/tests/system/small/test_index.py b/tests/system/small/test_index.py index a82bdf7635..90986c989a 100644 --- a/tests/system/small/test_index.py +++ b/tests/system/small/test_index.py @@ -638,6 +638,12 @@ def test_index_item_with_empty(session): bf_idx_empty.item() +def test_index_to_list(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.index.to_list() + pd_result = scalars_pandas_df_index.index.to_list() + assert bf_result == pd_result + + @pytest.mark.parametrize( ("key", "value"), [ diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py index 97f1e4419e..b875d56e7a 100644 --- a/tests/unit/test_index.py +++ b/tests/unit/test_index.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pandas as pd import pytest from bigframes.testing import mocks @@ -38,3 +39,13 @@ def test_index_rename_inplace_returns_none(monkeypatch: pytest.MonkeyPatch): # Make sure the linked DataFrame is updated, too. assert dataframe.index.name == "my_index_name" assert index.name == "my_index_name" + + +def test_index_to_list(monkeypatch: pytest.MonkeyPatch): + pd_index = pd.Index([1, 2, 3], name="my_index") + df = mocks.create_dataframe( + monkeypatch, + data={"my_index": [1, 2, 3]}, + ).set_index("my_index") + bf_index = df.index + assert bf_index.to_list() == pd_index.to_list() From af6b862de5c3921684210ec169338815f45b19dd Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Tue, 23 Sep 2025 14:07:23 -0700 Subject: [PATCH 096/313] feat: add ai.generate_int to bigframes.bigquery package (#2109) --- bigframes/bigquery/_operations/ai.py | 75 +++++++++++++++++++ .../ibis_compiler/scalar_op_registry.py | 38 +++++++--- .../compile/sqlglot/expressions/ai_ops.py | 51 +++++++++---- bigframes/operations/__init__.py | 3 +- bigframes/operations/ai_ops.py | 23 +++++- tests/system/small/bigquery/test_ai.py | 73 +++++++++++++----- .../test_ai_ops/test_ai_generate_int/out.sql | 18 +++++ .../out.sql | 18 +++++ .../sqlglot/expressions/test_ai_ops.py | 52 ++++++++++++- .../sql/compilers/bigquery/__init__.py | 9 ++- .../ibis/expr/operations/ai_ops.py | 19 +++++ 11 files changed, 332 insertions(+), 47 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql diff --git a/bigframes/bigquery/_operations/ai.py b/bigframes/bigquery/_operations/ai.py index 3bafce6166..f0b4f51611 100644 --- a/bigframes/bigquery/_operations/ai.py +++ b/bigframes/bigquery/_operations/ai.py @@ -113,6 +113,81 @@ def generate_bool( return series_list[0]._apply_nary_op(operator, series_list[1:]) +@log_adapter.method_logger(custom_base_name="bigquery_ai") +def generate_int( + prompt: PROMPT_TYPE, + *, + connection_id: str | None = None, + endpoint: str | None = None, + request_type: Literal["dedicated", "shared", "unspecified"] = "unspecified", + model_params: Mapping[Any, Any] | None = None, +) -> series.Series: + """ + Returns the AI analysis based on the prompt, which can be any combination of text and unstructured data. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> bpd.options.display.progress_bar = None + >>> animal = bpd.Series(["Kangaroo", "Rabbit", "Spider"]) + >>> bbq.ai.generate_int(("How many legs does a ", animal, " have?")) + 0 {'result': 2, 'full_response': '{"candidates":... + 1 {'result': 4, 'full_response': '{"candidates":... + 2 {'result': 8, 'full_response': '{"candidates":... + dtype: struct>, status: string>[pyarrow] + + >>> bbq.ai.generate_int(("How many legs does a ", animal, " have?")).struct.field("result") + 0 2 + 1 4 + 2 8 + Name: result, dtype: Int64 + + Args: + prompt (Series | List[str|Series] | Tuple[str|Series, ...]): + A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series + or pandas Series. + connection_id (str, optional): + Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. + If not provided, the connection from the current session will be used. + endpoint (str, optional): + Specifies the Vertex AI endpoint to use for the model. For example `"gemini-2.5-flash"`. You can specify any + generally available or preview Gemini model. If you specify the model name, BigQuery ML automatically identifies and + uses the full endpoint of the model. If you don't specify an ENDPOINT value, BigQuery ML selects a recent stable + version of Gemini to use. + request_type (Literal["dedicated", "shared", "unspecified"]): + Specifies the type of inference request to send to the Gemini model. The request type determines what quota the request uses. + * "dedicated": function only uses Provisioned Throughput quota. The function returns the error Provisioned throughput is not + purchased or is not active if Provisioned Throughput quota isn't available. + * "shared": the function only uses dynamic shared quota (DSQ), even if you have purchased Provisioned Throughput quota. + * "unspecified": If you haven't purchased Provisioned Throughput quota, the function uses DSQ quota. + If you have purchased Provisioned Throughput quota, the function uses the Provisioned Throughput quota first. + If requests exceed the Provisioned Throughput quota, the overflow traffic uses DSQ quota. + model_params (Mapping[Any, Any]): + Provides additional parameters to the model. The MODEL_PARAMS value must conform to the generateContent request body format. + + Returns: + bigframes.series.Series: A new struct Series with the result data. The struct contains these fields: + * "result": an integer (INT64) value containing the model's response to the prompt. The result is None if the request fails or is filtered by responsible AI. + * "full_response": a JSON value containing the response from the projects.locations.endpoints.generateContent call to the model. + The generated text is in the text element. + * "status": a STRING value that contains the API response status for the corresponding row. This value is empty if the operation was successful. + """ + + prompt_context, series_list = _separate_context_and_series(prompt) + assert len(series_list) > 0 + + operator = ai_ops.AIGenerateInt( + prompt_context=tuple(prompt_context), + connection_id=_resolve_connection_id(series_list[0], connection_id), + endpoint=endpoint, + request_type=request_type, + model_params=json.dumps(model_params) if model_params else None, + ) + + return series_list[0]._apply_nary_op(operator, series_list[1:]) + + def _separate_context_and_series( prompt: PROMPT_TYPE, ) -> Tuple[List[str | None], List[series.Series]]: diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index 8ffc556f76..a750a625ad 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -1975,23 +1975,43 @@ def ai_generate_bool( *values: ibis_types.Value, op: ops.AIGenerateBool ) -> ibis_types.StructValue: + return ai_ops.AIGenerateBool( + _construct_prompt(values, op.prompt_context), # type: ignore + op.connection_id, # type: ignore + op.endpoint, # type: ignore + op.request_type.upper(), # type: ignore + op.model_params, # type: ignore + ).to_expr() + + +@scalar_op_compiler.register_nary_op(ops.AIGenerateInt, pass_op=True) +def ai_generate_int( + *values: ibis_types.Value, op: ops.AIGenerateBool +) -> ibis_types.StructValue: + + return ai_ops.AIGenerateInt( + _construct_prompt(values, op.prompt_context), # type: ignore + op.connection_id, # type: ignore + op.endpoint, # type: ignore + op.request_type.upper(), # type: ignore + op.model_params, # type: ignore + ).to_expr() + + +def _construct_prompt( + col_refs: tuple[ibis_types.Value], prompt_context: tuple[str | None] +) -> ibis_types.StructValue: prompt: dict[str, ibis_types.Value | str] = {} column_ref_idx = 0 - for idx, elem in enumerate(op.prompt_context): + for idx, elem in enumerate(prompt_context): if elem is None: - prompt[f"_field_{idx + 1}"] = values[column_ref_idx] + prompt[f"_field_{idx + 1}"] = col_refs[column_ref_idx] column_ref_idx += 1 else: prompt[f"_field_{idx + 1}"] = elem - return ai_ops.AIGenerateBool( - ibis.struct(prompt), # type: ignore - op.connection_id, # type: ignore - op.endpoint, # type: ignore - op.request_type.upper(), # type: ignore - op.model_params, # type: ignore - ).to_expr() + return ibis.struct(prompt) @scalar_op_compiler.register_nary_op(ops.RowKey, pass_op=True) diff --git a/bigframes/core/compile/sqlglot/expressions/ai_ops.py b/bigframes/core/compile/sqlglot/expressions/ai_ops.py index 8395461575..50d56611b1 100644 --- a/bigframes/core/compile/sqlglot/expressions/ai_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/ai_ops.py @@ -14,6 +14,9 @@ from __future__ import annotations +from dataclasses import asdict +import typing + import sqlglot.expressions as sge from bigframes import operations as ops @@ -25,41 +28,61 @@ @register_nary_op(ops.AIGenerateBool, pass_op=True) def _(*exprs: TypedExpr, op: ops.AIGenerateBool) -> sge.Expression: + args = [_construct_prompt(exprs, op.prompt_context)] + _construct_named_args(op) + + return sge.func("AI.GENERATE_BOOL", *args) + + +@register_nary_op(ops.AIGenerateInt, pass_op=True) +def _(*exprs: TypedExpr, op: ops.AIGenerateInt) -> sge.Expression: + args = [_construct_prompt(exprs, op.prompt_context)] + _construct_named_args(op) + + return sge.func("AI.GENERATE_INT", *args) + +def _construct_prompt( + exprs: tuple[TypedExpr, ...], prompt_context: tuple[str | None, ...] +) -> sge.Kwarg: prompt: list[str | sge.Expression] = [] column_ref_idx = 0 - for elem in op.prompt_context: + for elem in prompt_context: if elem is None: prompt.append(exprs[column_ref_idx].expr) else: prompt.append(sge.Literal.string(elem)) - args = [sge.Kwarg(this="prompt", expression=sge.Tuple(expressions=prompt))] + return sge.Kwarg(this="prompt", expression=sge.Tuple(expressions=prompt)) + +def _construct_named_args(op: ops.NaryOp) -> list[sge.Kwarg]: + args = [] + + op_args = asdict(op) + + connection_id = typing.cast(str, op_args["connection_id"]) args.append( - sge.Kwarg(this="connection_id", expression=sge.Literal.string(op.connection_id)) + sge.Kwarg(this="connection_id", expression=sge.Literal.string(connection_id)) ) - if op.endpoint is not None: - args.append( - sge.Kwarg(this="endpoint", expression=sge.Literal.string(op.endpoint)) - ) + endpoit = typing.cast(str, op_args.get("endpoint", None)) + if endpoit is not None: + args.append(sge.Kwarg(this="endpoint", expression=sge.Literal.string(endpoit))) + request_type = typing.cast(str, op_args["request_type"]).upper() args.append( - sge.Kwarg( - this="request_type", expression=sge.Literal.string(op.request_type.upper()) - ) + sge.Kwarg(this="request_type", expression=sge.Literal.string(request_type)) ) - if op.model_params is not None: + model_params = typing.cast(str, op_args.get("model_params", None)) + if model_params is not None: args.append( sge.Kwarg( this="model_params", - # sge.JSON requires a newer SQLGlot version than 23.6.3. + # sge.JSON requires the SQLGlot version to be at least 25.18.0 # PARSE_JSON won't work as the function requires a JSON literal. - expression=sge.JSON(this=sge.Literal.string(op.model_params)), + expression=sge.JSON(this=sge.Literal.string(model_params)), ) ) - return sge.func("AI.GENERATE_BOOL", *args) + return args diff --git a/bigframes/operations/__init__.py b/bigframes/operations/__init__.py index 6239b88e9e..17e1f7534f 100644 --- a/bigframes/operations/__init__.py +++ b/bigframes/operations/__init__.py @@ -14,7 +14,7 @@ from __future__ import annotations -from bigframes.operations.ai_ops import AIGenerateBool +from bigframes.operations.ai_ops import AIGenerateBool, AIGenerateInt from bigframes.operations.array_ops import ( ArrayIndexOp, ArrayReduceOp, @@ -413,6 +413,7 @@ "GeoStDistanceOp", # AI ops "AIGenerateBool", + "AIGenerateInt", # Numpy ops mapping "NUMPY_TO_BINOP", "NUMPY_TO_OP", diff --git a/bigframes/operations/ai_ops.py b/bigframes/operations/ai_ops.py index 680c1585fb..7a8202abd2 100644 --- a/bigframes/operations/ai_ops.py +++ b/bigframes/operations/ai_ops.py @@ -28,7 +28,6 @@ class AIGenerateBool(base_ops.NaryOp): name: ClassVar[str] = "ai_generate_bool" - # None are the placeholders for column references. prompt_context: Tuple[str | None, ...] connection_id: str endpoint: str | None @@ -45,3 +44,25 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT ) ) ) + + +@dataclasses.dataclass(frozen=True) +class AIGenerateInt(base_ops.NaryOp): + name: ClassVar[str] = "ai_generate_int" + + prompt_context: Tuple[str | None, ...] + connection_id: str + endpoint: str | None + request_type: Literal["dedicated", "shared", "unspecified"] + model_params: str | None + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.int64()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) diff --git a/tests/system/small/bigquery/test_ai.py b/tests/system/small/bigquery/test_ai.py index be67a0d580..9f6feb0bbc 100644 --- a/tests/system/small/bigquery/test_ai.py +++ b/tests/system/small/bigquery/test_ai.py @@ -12,19 +12,19 @@ # See the License for the specific language governing permissions and # limitations under the License. -import sys - +from packaging import version import pandas as pd import pyarrow as pa import pytest +import sqlglot from bigframes import dtypes, series import bigframes.bigquery as bbq import bigframes.pandas as bpd -def test_ai_generate_bool(session): - s1 = bpd.Series(["apple", "bear"], session=session) +def test_ai_function_pandas_input(session): + s1 = pd.Series(["apple", "bear"]) s2 = bpd.Series(["fruit", "tree"], session=session) prompt = (s1, " is a ", s2) @@ -42,12 +42,20 @@ def test_ai_generate_bool(session): ) -def test_ai_generate_bool_with_pandas(session): - s1 = pd.Series(["apple", "bear"]) +def test_ai_function_compile_model_params(session): + if version.Version(sqlglot.__version__) < version.Version("25.18.0"): + pytest.skip( + "Skip test because SQLGLot cannot compile model params to JSON at this version." + ) + + s1 = bpd.Series(["apple", "bear"], session=session) s2 = bpd.Series(["fruit", "tree"], session=session) prompt = (s1, " is a ", s2) + model_params = {"generation_config": {"thinking_config": {"thinking_budget": 0}}} - result = bbq.ai.generate_bool(prompt, endpoint="gemini-2.5-flash") + result = bbq.ai.generate_bool( + prompt, endpoint="gemini-2.5-flash", model_params=model_params + ) assert _contains_no_nulls(result) assert result.dtype == pd.ArrowDtype( @@ -61,20 +69,12 @@ def test_ai_generate_bool_with_pandas(session): ) -def test_ai_generate_bool_with_model_params(session): - if sys.version_info < (3, 12): - pytest.skip( - "Skip test because SQLGLot cannot compile model params to JSON at this env." - ) - +def test_ai_generate_bool(session): s1 = bpd.Series(["apple", "bear"], session=session) s2 = bpd.Series(["fruit", "tree"], session=session) prompt = (s1, " is a ", s2) - model_params = {"generation_config": {"thinking_config": {"thinking_budget": 0}}} - result = bbq.ai.generate_bool( - prompt, endpoint="gemini-2.5-flash", model_params=model_params - ) + result = bbq.ai.generate_bool(prompt, endpoint="gemini-2.5-flash") assert _contains_no_nulls(result) assert result.dtype == pd.ArrowDtype( @@ -107,5 +107,44 @@ def test_ai_generate_bool_multi_model(session): ) +def test_ai_generate_int(session): + s = bpd.Series(["Cat"], session=session) + prompt = ("How many legs does a ", s, " have?") + + result = bbq.ai.generate_int(prompt, endpoint="gemini-2.5-flash") + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.int64()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +def test_ai_generate_int_multi_model(session): + df = session.from_glob_path( + "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" + ) + + result = bbq.ai.generate_int( + ("How many animals are there in the picture ", df["image"]) + ) + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.int64()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + def _contains_no_nulls(s: series.Series) -> bool: return len(s) == s.count() diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql new file mode 100644 index 0000000000..e48b64bead --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_INT( + prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + connection_id => 'test_connection_id', + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql new file mode 100644 index 0000000000..6f406dea18 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_INT( + prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + connection_id => 'test_connection_id', + request_type => 'SHARED', + model_params => JSON '{}' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py index 15b9ae516b..33a257f9a9 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py @@ -13,9 +13,10 @@ # limitations under the License. import json -import sys +from packaging import version import pytest +import sqlglot from bigframes import dataframe from bigframes import operations as ops @@ -45,9 +46,9 @@ def test_ai_generate_bool(scalar_types_df: dataframe.DataFrame, snapshot): def test_ai_generate_bool_with_model_param( scalar_types_df: dataframe.DataFrame, snapshot ): - if sys.version_info < (3, 10): + if version.Version(sqlglot.__version__) < version.Version("25.18.0"): pytest.skip( - "Skip test because SQLGLot cannot compile model params to JSON at this env." + "Skip test because SQLGLot cannot compile model params to JSON at this version." ) col_name = "string_col" @@ -65,3 +66,48 @@ def test_ai_generate_bool_with_model_param( ) snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_int(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + + op = ops.AIGenerateInt( + # The prompt does not make semantic sense but we only care about syntax correctness. + prompt_context=(None, " is the same as ", None), + connection_id="test_connection_id", + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + ) + + sql = utils._apply_unary_ops( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_int_with_model_param( + scalar_types_df: dataframe.DataFrame, snapshot +): + if version.Version(sqlglot.__version__) < version.Version("25.18.0"): + pytest.skip( + "Skip test because SQLGLot cannot compile model params to JSON at this version." + ) + + col_name = "string_col" + + op = ops.AIGenerateInt( + # The prompt does not make semantic sense but we only care about syntax correctness. + prompt_context=(None, " is the same as ", None), + connection_id="test_connection_id", + endpoint=None, + request_type="shared", + model_params=json.dumps(dict()), + ) + + sql = utils._apply_unary_ops( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") diff --git a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py index 6ea11d5215..ef150534ee 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py @@ -1105,9 +1105,14 @@ def visit_StringAgg(self, op, *, arg, sep, order_by, where): return self.agg.string_agg(expr, sep, where=where) def visit_AIGenerateBool(self, op, **kwargs): - func_name = "AI.GENERATE_BOOL" + return sge.func("AI.GENERATE_BOOL", *self._compile_ai_args(**kwargs)) + def visit_AIGenerateInt(self, op, **kwargs): + return sge.func("AI.GENERATE_INT", *self._compile_ai_args(**kwargs)) + + def _compile_ai_args(self, **kwargs): args = [] + for key, val in kwargs.items(): if val is None: continue @@ -1117,7 +1122,7 @@ def visit_AIGenerateBool(self, op, **kwargs): args.append(sge.Kwarg(this=sge.Identifier(this=key), expression=val)) - return sge.func(func_name, *args) + return args def visit_FirstNonNullValue(self, op, *, arg): return sge.IgnoreNulls(this=sge.FirstValue(this=arg)) diff --git a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py index 1f8306bad6..4b855f71c0 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py @@ -30,3 +30,22 @@ def dtype(self) -> dt.Struct: return dt.Struct.from_tuples( (("result", dt.bool), ("full_resposne", dt.string), ("status", dt.string)) ) + + +@public +class AIGenerateInt(Value): + """Generate integers based on the prompt""" + + prompt: Value + connection_id: Value[dt.String] + endpoint: Optional[Value[dt.String]] + request_type: Value[dt.String] + model_params: Optional[Value[dt.String]] + + shape = rlz.shape_like("prompt") + + @attribute + def dtype(self) -> dt.Struct: + return dt.Struct.from_tuples( + (("result", dt.int64), ("full_resposne", dt.string), ("status", dt.string)) + ) From ca1e44c1037b255aec64830d1797147c31977547 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Tue, 23 Sep 2025 14:12:11 -0700 Subject: [PATCH 097/313] refactor: add agg_ops.MedianOp compiler to sqlglot (#2108) * refactor: add agg_ops.MedianOp compiler to sqlglot * enable engine tests * enable non-numeric for ibis compiler too --- .../ibis_compiler/aggregate_compiler.py | 4 ---- .../sqlglot/aggregations/unary_compiler.py | 12 +++++++++++ .../system/small/engines/test_aggregation.py | 18 ++++++++++++++++- tests/system/small/test_series.py | 20 +++++++++++++++---- .../test_unary_compiler/test_median/out.sql | 18 +++++++++++++++++ .../aggregations/test_unary_compiler.py | 12 +++++++++++ 6 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_median/out.sql diff --git a/bigframes/core/compile/ibis_compiler/aggregate_compiler.py b/bigframes/core/compile/ibis_compiler/aggregate_compiler.py index b101f4e09f..0106b150e2 100644 --- a/bigframes/core/compile/ibis_compiler/aggregate_compiler.py +++ b/bigframes/core/compile/ibis_compiler/aggregate_compiler.py @@ -175,15 +175,11 @@ def _( @compile_unary_agg.register -@numeric_op def _( op: agg_ops.MedianOp, column: ibis_types.NumericColumn, window=None, ) -> ibis_types.NumericValue: - # TODO(swast): Allow switching between exact and approximate median. - # For now, the best we can do is an approximate median when we're doing - # an aggregation, as PERCENTILE_CONT is only an analytic function. return cast(ibis_types.NumericValue, column.approx_median()) diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py index 542bb10670..4cb0000894 100644 --- a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -56,6 +56,18 @@ def _( return apply_window_if_present(sge.func("MAX", column.expr), window) +@UNARY_OP_REGISTRATION.register(agg_ops.MedianOp) +def _( + op: agg_ops.MedianOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + approx_quantiles = sge.func("APPROX_QUANTILES", column.expr, sge.convert(2)) + return sge.Bracket( + this=approx_quantiles, expressions=[sge.func("OFFSET", sge.convert(1))] + ) + + @UNARY_OP_REGISTRATION.register(agg_ops.MinOp) def _( op: agg_ops.MinOp, diff --git a/tests/system/small/engines/test_aggregation.py b/tests/system/small/engines/test_aggregation.py index a4a49c622a..98d5cd4ac8 100644 --- a/tests/system/small/engines/test_aggregation.py +++ b/tests/system/small/engines/test_aggregation.py @@ -12,11 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +from google.cloud import bigquery import pytest from bigframes.core import agg_expressions, array_value, expression, identifiers, nodes import bigframes.operations.aggregations as agg_ops -from bigframes.session import polars_executor +from bigframes.session import direct_gbq_execution, polars_executor from bigframes.testing.engine_utils import assert_equivalence_execution pytest.importorskip("polars") @@ -84,6 +85,21 @@ def test_engines_unary_aggregates( assert_equivalence_execution(node, REFERENCE_ENGINE, engine) +def test_sql_engines_median_op_aggregates( + scalars_array_value: array_value.ArrayValue, + bigquery_client: bigquery.Client, +): + node = apply_agg_to_all_valid( + scalars_array_value, + agg_ops.MedianOp(), + ).node + left_engine = direct_gbq_execution.DirectGbqExecutor(bigquery_client) + right_engine = direct_gbq_execution.DirectGbqExecutor( + bigquery_client, compiler="sqlglot" + ) + assert_equivalence_execution(node, left_engine, right_engine) + + @pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) @pytest.mark.parametrize( "grouping_cols", diff --git a/tests/system/small/test_series.py b/tests/system/small/test_series.py index 0a761a3a3a..d1a252f8dc 100644 --- a/tests/system/small/test_series.py +++ b/tests/system/small/test_series.py @@ -1919,10 +1919,22 @@ def test_mean(scalars_dfs): assert math.isclose(pd_result, bf_result) -def test_median(scalars_dfs): +@pytest.mark.parametrize( + ("col_name"), + [ + "int64_col", + # Non-numeric column + "bytes_col", + "date_col", + "datetime_col", + "time_col", + "timestamp_col", + "string_col", + ], +) +def test_median(scalars_dfs, col_name): scalars_df, scalars_pandas_df = scalars_dfs - col_name = "int64_col" - bf_result = scalars_df[col_name].median() + bf_result = scalars_df[col_name].median(exact=False) pd_max = scalars_pandas_df[col_name].max() pd_min = scalars_pandas_df[col_name].min() # Median is approximate, so just check for plausibility. @@ -1932,7 +1944,7 @@ def test_median(scalars_dfs): def test_median_exact(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs col_name = "int64_col" - bf_result = scalars_df[col_name].median(exact=True) + bf_result = scalars_df[col_name].median() pd_result = scalars_pandas_df[col_name].median() assert math.isclose(pd_result, bf_result) diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_median/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_median/out.sql new file mode 100644 index 0000000000..bf7006ef87 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_median/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `date_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `string_col` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + APPROX_QUANTILES(`bfcol_1`, 2)[OFFSET(1)] AS `bfcol_3`, + APPROX_QUANTILES(`bfcol_0`, 2)[OFFSET(1)] AS `bfcol_4`, + APPROX_QUANTILES(`bfcol_2`, 2)[OFFSET(1)] AS `bfcol_5` + FROM `bfcte_0` +) +SELECT + `bfcol_3` AS `int64_col`, + `bfcol_4` AS `date_col`, + `bfcol_5` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index 311c039e11..4f0016a6e7 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -56,6 +56,18 @@ def test_max(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_median(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + ops_map = { + "int64_col": agg_ops.MedianOp().as_expr("int64_col"), + "date_col": agg_ops.MedianOp().as_expr("date_col"), + "string_col": agg_ops.MedianOp().as_expr("string_col"), + } + sql = _apply_unary_agg_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + + snapshot.assert_match(sql, "out.sql") + + def test_min(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] From b3dbbcc2643686a4c5a7fe83577f8b2ca50c4d06 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Tue, 23 Sep 2025 16:45:27 -0700 Subject: [PATCH 098/313] refactor: enable "astype" engine tests for the sqlglot compiler (#2107) --- .../sqlglot/expressions/generic_ops.py | 102 +++++++++++- .../system/small/engines/test_generic_ops.py | 36 ++--- .../test_generic_ops/test_astype_bool/out.sql | 18 +++ .../test_astype_float/out.sql | 17 ++ .../test_astype_from_json/out.sql | 21 +++ .../test_generic_ops/test_astype_int/out.sql | 33 ++++ .../test_generic_ops/test_astype_json/out.sql | 26 ++++ .../test_astype_string/out.sql | 18 +++ .../test_astype_time_like/out.sql | 19 +++ .../sqlglot/expressions/test_generic_ops.py | 147 ++++++++++++++++++ 10 files changed, 417 insertions(+), 20 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_bool/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_float/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_from_json/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_int/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_json/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_string/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_time_like/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/generic_ops.py b/bigframes/core/compile/sqlglot/expressions/generic_ops.py index 5ee4ede94a..8a792c0753 100644 --- a/bigframes/core/compile/sqlglot/expressions/generic_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/generic_ops.py @@ -16,17 +16,54 @@ import sqlglot.expressions as sge +from bigframes import dtypes from bigframes import operations as ops from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler +from bigframes.core.compile.sqlglot.sqlglot_types import SQLGlotType register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op @register_unary_op(ops.AsTypeOp, pass_op=True) def _(expr: TypedExpr, op: ops.AsTypeOp) -> sge.Expression: - # TODO: Support more types for casting, such as JSON, etc. - return sge.Cast(this=expr.expr, to=op.to_type) + from_type = expr.dtype + to_type = op.to_type + sg_to_type = SQLGlotType.from_bigframes_dtype(to_type) + sg_expr = expr.expr + + if to_type == dtypes.JSON_DTYPE: + return _cast_to_json(expr, op) + + if from_type == dtypes.JSON_DTYPE: + return _cast_from_json(expr, op) + + if to_type == dtypes.INT_DTYPE: + result = _cast_to_int(expr, op) + if result is not None: + return result + + if to_type == dtypes.FLOAT_DTYPE and from_type == dtypes.BOOL_DTYPE: + sg_expr = _cast(sg_expr, "INT64", op.safe) + return _cast(sg_expr, sg_to_type, op.safe) + + if to_type == dtypes.BOOL_DTYPE: + if from_type == dtypes.BOOL_DTYPE: + return sg_expr + else: + return sge.NEQ(this=sg_expr, expression=sge.convert(0)) + + if to_type == dtypes.STRING_DTYPE: + sg_expr = _cast(sg_expr, sg_to_type, op.safe) + if from_type == dtypes.BOOL_DTYPE: + sg_expr = sge.func("INITCAP", sg_expr) + return sg_expr + + if dtypes.is_time_like(to_type) and from_type == dtypes.INT_DTYPE: + sg_expr = sge.func("TIMESTAMP_MICROS", sg_expr) + return _cast(sg_expr, sg_to_type, op.safe) + + return _cast(sg_expr, sg_to_type, op.safe) @register_unary_op(ops.hash_op) @@ -53,3 +90,64 @@ def _(expr: TypedExpr, op: ops.MapOp) -> sge.Expression: @register_unary_op(ops.notnull_op) def _(expr: TypedExpr) -> sge.Expression: return sge.Not(this=sge.Is(this=expr.expr, expression=sge.Null())) + + +# Helper functions +def _cast_to_json(expr: TypedExpr, op: ops.AsTypeOp) -> sge.Expression: + from_type = expr.dtype + sg_expr = expr.expr + + if from_type == dtypes.STRING_DTYPE: + func_name = "PARSE_JSON_IN_SAFE" if op.safe else "PARSE_JSON" + return sge.func(func_name, sg_expr) + if from_type in (dtypes.INT_DTYPE, dtypes.BOOL_DTYPE, dtypes.FLOAT_DTYPE): + sg_expr = sge.Cast(this=sg_expr, to="STRING") + return sge.func("PARSE_JSON", sg_expr) + raise TypeError(f"Cannot cast from {from_type} to {dtypes.JSON_DTYPE}") + + +def _cast_from_json(expr: TypedExpr, op: ops.AsTypeOp) -> sge.Expression: + to_type = op.to_type + sg_expr = expr.expr + func_name = "" + if to_type == dtypes.INT_DTYPE: + func_name = "INT64" + elif to_type == dtypes.FLOAT_DTYPE: + func_name = "FLOAT64" + elif to_type == dtypes.BOOL_DTYPE: + func_name = "BOOL" + elif to_type == dtypes.STRING_DTYPE: + func_name = "STRING" + if func_name: + func_name = "SAFE." + func_name if op.safe else func_name + return sge.func(func_name, sg_expr) + raise TypeError(f"Cannot cast from {dtypes.JSON_DTYPE} to {to_type}") + + +def _cast_to_int(expr: TypedExpr, op: ops.AsTypeOp) -> sge.Expression | None: + from_type = expr.dtype + sg_expr = expr.expr + # Cannot cast DATETIME to INT directly so need to convert to TIMESTAMP first. + if from_type == dtypes.DATETIME_DTYPE: + sg_expr = _cast(sg_expr, "TIMESTAMP", op.safe) + return sge.func("UNIX_MICROS", sg_expr) + if from_type == dtypes.TIMESTAMP_DTYPE: + return sge.func("UNIX_MICROS", sg_expr) + if from_type == dtypes.TIME_DTYPE: + return sge.func( + "TIME_DIFF", + _cast(sg_expr, "TIME", op.safe), + sge.convert("00:00:00"), + "MICROSECOND", + ) + if from_type == dtypes.NUMERIC_DTYPE or from_type == dtypes.FLOAT_DTYPE: + sg_expr = sge.func("TRUNC", sg_expr) + return _cast(sg_expr, "INT64", op.safe) + return None + + +def _cast(expr: sge.Expression, to: str, safe: bool): + if safe: + return sge.TryCast(this=expr, to=to) + else: + return sge.Cast(this=expr, to=to) diff --git a/tests/system/small/engines/test_generic_ops.py b/tests/system/small/engines/test_generic_ops.py index fc40b7e59d..fc491d358b 100644 --- a/tests/system/small/engines/test_generic_ops.py +++ b/tests/system/small/engines/test_generic_ops.py @@ -52,7 +52,7 @@ def apply_op( return new_arr -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_int(scalars_array_value: array_value.ArrayValue, engine): arr = apply_op( scalars_array_value, @@ -63,7 +63,7 @@ def test_engines_astype_int(scalars_array_value: array_value.ArrayValue, engine) assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_string_int(scalars_array_value: array_value.ArrayValue, engine): vals = ["1", "100", "-3"] arr, _ = scalars_array_value.compute_values( @@ -78,7 +78,7 @@ def test_engines_astype_string_int(scalars_array_value: array_value.ArrayValue, assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_float(scalars_array_value: array_value.ArrayValue, engine): arr = apply_op( scalars_array_value, @@ -89,7 +89,7 @@ def test_engines_astype_float(scalars_array_value: array_value.ArrayValue, engin assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_string_float( scalars_array_value: array_value.ArrayValue, engine ): @@ -106,7 +106,7 @@ def test_engines_astype_string_float( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_bool(scalars_array_value: array_value.ArrayValue, engine): arr = apply_op( scalars_array_value, ops.AsTypeOp(to_type=bigframes.dtypes.BOOL_DTYPE) @@ -115,7 +115,7 @@ def test_engines_astype_bool(scalars_array_value: array_value.ArrayValue, engine assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_string(scalars_array_value: array_value.ArrayValue, engine): # floats work slightly different with trailing zeroes rn arr = apply_op( @@ -127,7 +127,7 @@ def test_engines_astype_string(scalars_array_value: array_value.ArrayValue, engi assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_numeric(scalars_array_value: array_value.ArrayValue, engine): arr = apply_op( scalars_array_value, @@ -138,7 +138,7 @@ def test_engines_astype_numeric(scalars_array_value: array_value.ArrayValue, eng assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_string_numeric( scalars_array_value: array_value.ArrayValue, engine ): @@ -155,7 +155,7 @@ def test_engines_astype_string_numeric( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_date(scalars_array_value: array_value.ArrayValue, engine): arr = apply_op( scalars_array_value, @@ -166,7 +166,7 @@ def test_engines_astype_date(scalars_array_value: array_value.ArrayValue, engine assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_string_date( scalars_array_value: array_value.ArrayValue, engine ): @@ -183,7 +183,7 @@ def test_engines_astype_string_date( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_datetime(scalars_array_value: array_value.ArrayValue, engine): arr = apply_op( scalars_array_value, @@ -194,7 +194,7 @@ def test_engines_astype_datetime(scalars_array_value: array_value.ArrayValue, en assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_string_datetime( scalars_array_value: array_value.ArrayValue, engine ): @@ -211,7 +211,7 @@ def test_engines_astype_string_datetime( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_timestamp(scalars_array_value: array_value.ArrayValue, engine): arr = apply_op( scalars_array_value, @@ -222,7 +222,7 @@ def test_engines_astype_timestamp(scalars_array_value: array_value.ArrayValue, e assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_string_timestamp( scalars_array_value: array_value.ArrayValue, engine ): @@ -243,7 +243,7 @@ def test_engines_astype_string_timestamp( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_time(scalars_array_value: array_value.ArrayValue, engine): arr = apply_op( scalars_array_value, @@ -254,7 +254,7 @@ def test_engines_astype_time(scalars_array_value: array_value.ArrayValue, engine assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_from_json(scalars_array_value: array_value.ArrayValue, engine): exprs = [ ops.AsTypeOp(to_type=bigframes.dtypes.INT_DTYPE).as_expr( @@ -275,7 +275,7 @@ def test_engines_astype_from_json(scalars_array_value: array_value.ArrayValue, e assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_to_json(scalars_array_value: array_value.ArrayValue, engine): exprs = [ ops.AsTypeOp(to_type=bigframes.dtypes.JSON_DTYPE).as_expr( @@ -298,7 +298,7 @@ def test_engines_astype_to_json(scalars_array_value: array_value.ArrayValue, eng assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_timedelta(scalars_array_value: array_value.ArrayValue, engine): arr = apply_op( scalars_array_value, diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_bool/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_bool/out.sql new file mode 100644 index 0000000000..440aea9161 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_bool/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `float64_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_0` AS `bfcol_2`, + `bfcol_1` <> 0 AS `bfcol_3`, + `bfcol_1` <> 0 AS `bfcol_4` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `bool_col`, + `bfcol_3` AS `float64_col`, + `bfcol_4` AS `float64_w_safe` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_float/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_float/out.sql new file mode 100644 index 0000000000..81a8805f47 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_float/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CAST(CAST(`bfcol_0` AS INT64) AS FLOAT64) AS `bfcol_1`, + CAST('1.34235e4' AS FLOAT64) AS `bfcol_2`, + SAFE_CAST(SAFE_CAST(`bfcol_0` AS INT64) AS FLOAT64) AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `bool_col`, + `bfcol_2` AS `str_const`, + `bfcol_3` AS `bool_w_safe` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_from_json/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_from_json/out.sql new file mode 100644 index 0000000000..25d51b26b3 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_from_json/out.sql @@ -0,0 +1,21 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + INT64(`bfcol_0`) AS `bfcol_1`, + FLOAT64(`bfcol_0`) AS `bfcol_2`, + BOOL(`bfcol_0`) AS `bfcol_3`, + STRING(`bfcol_0`) AS `bfcol_4`, + SAFE.INT64(`bfcol_0`) AS `bfcol_5` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col`, + `bfcol_2` AS `float64_col`, + `bfcol_3` AS `bool_col`, + `bfcol_4` AS `string_col`, + `bfcol_5` AS `int64_w_safe` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_int/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_int/out.sql new file mode 100644 index 0000000000..22aa2cf91a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_int/out.sql @@ -0,0 +1,33 @@ +WITH `bfcte_0` AS ( + SELECT + `datetime_col` AS `bfcol_0`, + `numeric_col` AS `bfcol_1`, + `float64_col` AS `bfcol_2`, + `time_col` AS `bfcol_3`, + `timestamp_col` AS `bfcol_4` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + UNIX_MICROS(CAST(`bfcol_0` AS TIMESTAMP)) AS `bfcol_5`, + UNIX_MICROS(SAFE_CAST(`bfcol_0` AS TIMESTAMP)) AS `bfcol_6`, + TIME_DIFF(CAST(`bfcol_3` AS TIME), '00:00:00', MICROSECOND) AS `bfcol_7`, + TIME_DIFF(SAFE_CAST(`bfcol_3` AS TIME), '00:00:00', MICROSECOND) AS `bfcol_8`, + UNIX_MICROS(`bfcol_4`) AS `bfcol_9`, + CAST(TRUNC(`bfcol_1`) AS INT64) AS `bfcol_10`, + CAST(TRUNC(`bfcol_2`) AS INT64) AS `bfcol_11`, + SAFE_CAST(TRUNC(`bfcol_2`) AS INT64) AS `bfcol_12`, + CAST('100' AS INT64) AS `bfcol_13` + FROM `bfcte_0` +) +SELECT + `bfcol_5` AS `datetime_col`, + `bfcol_6` AS `datetime_w_safe`, + `bfcol_7` AS `time_col`, + `bfcol_8` AS `time_w_safe`, + `bfcol_9` AS `timestamp_col`, + `bfcol_10` AS `numeric_col`, + `bfcol_11` AS `float64_col`, + `bfcol_12` AS `float64_w_safe`, + `bfcol_13` AS `str_const` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_json/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_json/out.sql new file mode 100644 index 0000000000..8230b4a60b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_json/out.sql @@ -0,0 +1,26 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `float64_col` AS `bfcol_2`, + `string_col` AS `bfcol_3` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + PARSE_JSON(CAST(`bfcol_1` AS STRING)) AS `bfcol_4`, + PARSE_JSON(CAST(`bfcol_2` AS STRING)) AS `bfcol_5`, + PARSE_JSON(CAST(`bfcol_0` AS STRING)) AS `bfcol_6`, + PARSE_JSON(`bfcol_3`) AS `bfcol_7`, + PARSE_JSON(CAST(`bfcol_0` AS STRING)) AS `bfcol_8`, + PARSE_JSON_IN_SAFE(`bfcol_3`) AS `bfcol_9` + FROM `bfcte_0` +) +SELECT + `bfcol_4` AS `int64_col`, + `bfcol_5` AS `float64_col`, + `bfcol_6` AS `bool_col`, + `bfcol_7` AS `string_col`, + `bfcol_8` AS `bool_w_safe`, + `bfcol_9` AS `string_w_safe` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_string/out.sql new file mode 100644 index 0000000000..f230a3799e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_string/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CAST(`bfcol_1` AS STRING) AS `bfcol_2`, + INITCAP(CAST(`bfcol_0` AS STRING)) AS `bfcol_3`, + INITCAP(SAFE_CAST(`bfcol_0` AS STRING)) AS `bfcol_4` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `int64_col`, + `bfcol_3` AS `bool_col`, + `bfcol_4` AS `bool_w_safe` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_time_like/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_time_like/out.sql new file mode 100644 index 0000000000..141b7ffa9a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_time_like/out.sql @@ -0,0 +1,19 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CAST(TIMESTAMP_MICROS(`bfcol_0`) AS DATETIME) AS `bfcol_1`, + CAST(TIMESTAMP_MICROS(`bfcol_0`) AS TIME) AS `bfcol_2`, + CAST(TIMESTAMP_MICROS(`bfcol_0`) AS TIMESTAMP) AS `bfcol_3`, + SAFE_CAST(TIMESTAMP_MICROS(`bfcol_0`) AS TIME) AS `bfcol_4` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_to_datetime`, + `bfcol_2` AS `int64_to_time`, + `bfcol_3` AS `int64_to_timestamp`, + `bfcol_4` AS `int64_to_time_safe` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py index 130d34a2fa..d9ae6ab539 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py @@ -14,13 +14,160 @@ import pytest +from bigframes import dtypes from bigframes import operations as ops +from bigframes.core import expression as ex import bigframes.pandas as bpd from bigframes.testing import utils pytest.importorskip("pytest_snapshot") +def test_astype_int(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + to_type = dtypes.INT_DTYPE + + ops_map = { + "datetime_col": ops.AsTypeOp(to_type=to_type).as_expr("datetime_col"), + "datetime_w_safe": ops.AsTypeOp(to_type=to_type, safe=True).as_expr( + "datetime_col" + ), + "time_col": ops.AsTypeOp(to_type=to_type).as_expr("time_col"), + "time_w_safe": ops.AsTypeOp(to_type=to_type, safe=True).as_expr("time_col"), + "timestamp_col": ops.AsTypeOp(to_type=to_type).as_expr("timestamp_col"), + "numeric_col": ops.AsTypeOp(to_type=to_type).as_expr("numeric_col"), + "float64_col": ops.AsTypeOp(to_type=to_type).as_expr("float64_col"), + "float64_w_safe": ops.AsTypeOp(to_type=to_type, safe=True).as_expr( + "float64_col" + ), + "str_const": ops.AsTypeOp(to_type=to_type).as_expr(ex.const("100")), + } + + sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_astype_float(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + to_type = dtypes.FLOAT_DTYPE + + ops_map = { + "bool_col": ops.AsTypeOp(to_type=to_type).as_expr("bool_col"), + "str_const": ops.AsTypeOp(to_type=to_type).as_expr(ex.const("1.34235e4")), + "bool_w_safe": ops.AsTypeOp(to_type=to_type, safe=True).as_expr("bool_col"), + } + sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_astype_bool(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + to_type = dtypes.BOOL_DTYPE + + ops_map = { + "bool_col": ops.AsTypeOp(to_type=to_type).as_expr("bool_col"), + "float64_col": ops.AsTypeOp(to_type=to_type).as_expr("float64_col"), + "float64_w_safe": ops.AsTypeOp(to_type=to_type, safe=True).as_expr( + "float64_col" + ), + } + sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_astype_time_like(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + + ops_map = { + "int64_to_datetime": ops.AsTypeOp(to_type=dtypes.DATETIME_DTYPE).as_expr( + "int64_col" + ), + "int64_to_time": ops.AsTypeOp(to_type=dtypes.TIME_DTYPE).as_expr("int64_col"), + "int64_to_timestamp": ops.AsTypeOp(to_type=dtypes.TIMESTAMP_DTYPE).as_expr( + "int64_col" + ), + "int64_to_time_safe": ops.AsTypeOp( + to_type=dtypes.TIME_DTYPE, safe=True + ).as_expr("int64_col"), + } + sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_astype_string(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + to_type = dtypes.STRING_DTYPE + + ops_map = { + "int64_col": ops.AsTypeOp(to_type=to_type).as_expr("int64_col"), + "bool_col": ops.AsTypeOp(to_type=to_type).as_expr("bool_col"), + "bool_w_safe": ops.AsTypeOp(to_type=to_type, safe=True).as_expr("bool_col"), + } + sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_astype_json(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + + ops_map = { + "int64_col": ops.AsTypeOp(to_type=dtypes.JSON_DTYPE).as_expr("int64_col"), + "float64_col": ops.AsTypeOp(to_type=dtypes.JSON_DTYPE).as_expr("float64_col"), + "bool_col": ops.AsTypeOp(to_type=dtypes.JSON_DTYPE).as_expr("bool_col"), + "string_col": ops.AsTypeOp(to_type=dtypes.JSON_DTYPE).as_expr("string_col"), + "bool_w_safe": ops.AsTypeOp(to_type=dtypes.JSON_DTYPE, safe=True).as_expr( + "bool_col" + ), + "string_w_safe": ops.AsTypeOp(to_type=dtypes.JSON_DTYPE, safe=True).as_expr( + "string_col" + ), + } + sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_astype_from_json(json_types_df: bpd.DataFrame, snapshot): + bf_df = json_types_df + + ops_map = { + "int64_col": ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr("json_col"), + "float64_col": ops.AsTypeOp(to_type=dtypes.FLOAT_DTYPE).as_expr("json_col"), + "bool_col": ops.AsTypeOp(to_type=dtypes.BOOL_DTYPE).as_expr("json_col"), + "string_col": ops.AsTypeOp(to_type=dtypes.STRING_DTYPE).as_expr("json_col"), + "int64_w_safe": ops.AsTypeOp(to_type=dtypes.INT_DTYPE, safe=True).as_expr( + "json_col" + ), + } + sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_astype_json_invalid( + scalar_types_df: bpd.DataFrame, json_types_df: bpd.DataFrame +): + # Test invalid cast to JSON + with pytest.raises(TypeError, match="Cannot cast timestamp.* to .*json.*"): + ops_map_to = { + "datetime_to_json": ops.AsTypeOp(to_type=dtypes.JSON_DTYPE).as_expr( + "datetime_col" + ), + } + utils._apply_unary_ops( + scalar_types_df, list(ops_map_to.values()), list(ops_map_to.keys()) + ) + + # Test invalid cast from JSON + with pytest.raises(TypeError, match="Cannot cast .*json.* to timestamp.*"): + ops_map_from = { + "json_to_datetime": ops.AsTypeOp(to_type=dtypes.DATETIME_DTYPE).as_expr( + "json_col" + ), + } + utils._apply_unary_ops( + json_types_df, list(ops_map_from.values()), list(ops_map_from.keys()) + ) + + def test_hash(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] From 3487f13d12e34999b385c2e11551b5e27bfbf4ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Wed, 24 Sep 2025 09:47:10 -0500 Subject: [PATCH 099/313] feat: implement inplace parameter for `DataFrame.drop` (#2105) * feat: implement inplace parameter for drop method This commit implements the `inplace` parameter for the `DataFrame.drop` method. When `inplace=True`, the DataFrame is modified in place and the method returns `None`. When `inplace=False` (the default), a new DataFrame is returned. Unit tests have been added to verify the functionality for both column and row dropping. * update drop index test --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- bigframes/dataframe.py | 40 +++++++++++++++++++++++++++++++-- tests/unit/conftest.py | 24 ++++++++++++++++++++ tests/unit/core/test_groupby.py | 8 ------- tests/unit/test_dataframe.py | 34 ++++++++++++++++++++++++++++ tests/unit/test_local_engine.py | 8 ------- 5 files changed, 96 insertions(+), 18 deletions(-) create mode 100644 tests/unit/conftest.py diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index ea5136f6f5..eb5ed997a1 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -2006,6 +2006,7 @@ def insert( self._set_block(block) + @overload def drop( self, labels: typing.Any = None, @@ -2014,7 +2015,33 @@ def drop( index: typing.Any = None, columns: Union[blocks.Label, Sequence[blocks.Label]] = None, level: typing.Optional[LevelType] = None, + inplace: Literal[False] = False, ) -> DataFrame: + ... + + @overload + def drop( + self, + labels: typing.Any = None, + *, + axis: typing.Union[int, str] = 0, + index: typing.Any = None, + columns: Union[blocks.Label, Sequence[blocks.Label]] = None, + level: typing.Optional[LevelType] = None, + inplace: Literal[True], + ) -> None: + ... + + def drop( + self, + labels: typing.Any = None, + *, + axis: typing.Union[int, str] = 0, + index: typing.Any = None, + columns: Union[blocks.Label, Sequence[blocks.Label]] = None, + level: typing.Optional[LevelType] = None, + inplace: bool = False, + ) -> Optional[DataFrame]: if labels: if index or columns: raise ValueError("Cannot specify both 'labels' and 'index'/'columns") @@ -2056,7 +2083,11 @@ def drop( inverse_condition_id, ops.invert_op ) elif isinstance(index, indexes.Index): - return self._drop_by_index(index) + dropped_block = self._drop_by_index(index)._get_block() + if inplace: + self._set_block(dropped_block) + return None + return DataFrame(dropped_block) else: block, condition_id = block.project_expr( ops.ne_op.as_expr(level_id, ex.const(index)) @@ -2068,7 +2099,12 @@ def drop( block = block.drop_columns(self._sql_names(columns)) if index is None and not columns: raise ValueError("Must specify 'labels' or 'index'/'columns") - return DataFrame(block) + + if inplace: + self._set_block(block) + return None + else: + return DataFrame(block) def _drop_by_index(self, index: indexes.Index) -> DataFrame: block = index._block diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000000..a9b26afeef --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,24 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + + +@pytest.fixture(scope="session") +def polars_session(): + pytest.importorskip("polars") + + from bigframes.testing import polars_session + + return polars_session.TestSession() diff --git a/tests/unit/core/test_groupby.py b/tests/unit/core/test_groupby.py index 8df0e5344e..f3d9218123 100644 --- a/tests/unit/core/test_groupby.py +++ b/tests/unit/core/test_groupby.py @@ -23,14 +23,6 @@ pytest.importorskip("pandas", minversion="2.0.0") -# All tests in this file require polars to be installed to pass. -@pytest.fixture(scope="module") -def polars_session(): - from bigframes.testing import polars_session - - return polars_session.TestSession() - - def test_groupby_df_iter_by_key_singular(polars_session): pd_df = pd.DataFrame({"colA": ["a", "a", "b", "c", "c"], "colB": [1, 2, 3, 4, 5]}) bf_df = bpd.DataFrame(pd_df, session=polars_session) diff --git a/tests/unit/test_dataframe.py b/tests/unit/test_dataframe.py index d630380e7a..6aaccd644e 100644 --- a/tests/unit/test_dataframe.py +++ b/tests/unit/test_dataframe.py @@ -13,9 +13,11 @@ # limitations under the License. import google.cloud.bigquery +import pandas as pd import pytest import bigframes.dataframe +import bigframes.session from bigframes.testing import mocks @@ -129,6 +131,38 @@ def test_dataframe_rename_axis_inplace_returns_none(monkeypatch: pytest.MonkeyPa assert list(dataframe.index.names) == ["a", "b"] +def test_dataframe_drop_columns_inplace_returns_none(monkeypatch: pytest.MonkeyPatch): + dataframe = mocks.create_dataframe( + monkeypatch, data={"col1": [1], "col2": [2], "col3": [3]} + ) + assert dataframe.columns.to_list() == ["col1", "col2", "col3"] + assert dataframe.drop(columns=["col1", "col3"], inplace=True) is None + assert dataframe.columns.to_list() == ["col2"] + + +def test_dataframe_drop_index_inplace_returns_none( + # Drop index depends on the actual data, not just metadata, so use the + # local engine for more robust testing. + polars_session: bigframes.session.Session, +): + dataframe = polars_session.read_pandas( + pd.DataFrame({"col1": [1, 2, 3], "index_col": [0, 1, 2]}).set_index("index_col") + ) + assert dataframe.index.to_list() == [0, 1, 2] + assert dataframe.drop(index=[0, 2], inplace=True) is None + assert dataframe.index.to_list() == [1] + + +def test_dataframe_drop_columns_returns_new_dataframe(monkeypatch: pytest.MonkeyPatch): + dataframe = mocks.create_dataframe( + monkeypatch, data={"col1": [1], "col2": [2], "col3": [3]} + ) + assert dataframe.columns.to_list() == ["col1", "col2", "col3"] + new_dataframe = dataframe.drop(columns=["col1", "col3"]) + assert dataframe.columns.to_list() == ["col1", "col2", "col3"] + assert new_dataframe.columns.to_list() == ["col2"] + + def test_dataframe_semantics_property_future_warning( monkeypatch: pytest.MonkeyPatch, ): diff --git a/tests/unit/test_local_engine.py b/tests/unit/test_local_engine.py index 509bc6ade2..7d3d532d88 100644 --- a/tests/unit/test_local_engine.py +++ b/tests/unit/test_local_engine.py @@ -24,14 +24,6 @@ pytest.importorskip("pandas", minversion="2.0.0") -# All tests in this file require polars to be installed to pass. -@pytest.fixture(scope="module") -def polars_session(): - from bigframes.testing import polars_session - - return polars_session.TestSession() - - @pytest.fixture(scope="module") def small_inline_frame() -> pd.DataFrame: df = pd.DataFrame( From caa824a267249f8046fde030cbf154afbf9852dd Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Wed, 24 Sep 2025 10:10:44 -0700 Subject: [PATCH 100/313] refactor: add agg_ops.MeanOp for sqlglot compiler (#2096) --- .../sqlglot/aggregations/unary_compiler.py | 20 ++++++++++++++ .../system/small/engines/test_aggregation.py | 2 +- .../test_unary_compiler/test_mean/out.sql | 27 +++++++++++++++++++ .../aggregations/test_unary_compiler.py | 27 +++++++++++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/out.sql diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py index 4cb0000894..8ed5510ec2 100644 --- a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -56,6 +56,26 @@ def _( return apply_window_if_present(sge.func("MAX", column.expr), window) +@UNARY_OP_REGISTRATION.register(agg_ops.MeanOp) +def _( + op: agg_ops.MeanOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + expr = column.expr + if column.dtype == dtypes.BOOL_DTYPE: + expr = sge.Cast(this=expr, to="INT64") + + expr = sge.func("AVG", expr) + + should_floor_result = ( + op.should_floor_result or column.dtype == dtypes.TIMEDELTA_DTYPE + ) + if should_floor_result: + expr = sge.Cast(this=sge.func("FLOOR", expr), to="INT64") + return apply_window_if_present(expr, window) + + @UNARY_OP_REGISTRATION.register(agg_ops.MedianOp) def _( op: agg_ops.MedianOp, diff --git a/tests/system/small/engines/test_aggregation.py b/tests/system/small/engines/test_aggregation.py index 98d5cd4ac8..9b4efe8cbe 100644 --- a/tests/system/small/engines/test_aggregation.py +++ b/tests/system/small/engines/test_aggregation.py @@ -71,7 +71,7 @@ def test_engines_aggregate_size( assert_equivalence_execution(node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) @pytest.mark.parametrize( "op", [agg_ops.min_op, agg_ops.max_op, agg_ops.mean_op, agg_ops.sum_op, agg_ops.count_op], diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/out.sql new file mode 100644 index 0000000000..6d4bb6f89a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/out.sql @@ -0,0 +1,27 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `duration_col` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_1` AS `bfcol_6`, + `bfcol_0` AS `bfcol_7`, + `bfcol_2` AS `bfcol_8` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + AVG(`bfcol_6`) AS `bfcol_12`, + AVG(CAST(`bfcol_7` AS INT64)) AS `bfcol_13`, + CAST(FLOOR(AVG(`bfcol_8`)) AS INT64) AS `bfcol_14`, + CAST(FLOOR(AVG(`bfcol_6`)) AS INT64) AS `bfcol_15` + FROM `bfcte_1` +) +SELECT + `bfcol_12` AS `int64_col`, + `bfcol_13` AS `bool_col`, + `bfcol_14` AS `duration_col`, + `bfcol_15` AS `int64_col_w_floor` +FROM `bfcte_2` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index 4f0016a6e7..a5ffda0e65 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -56,6 +56,33 @@ def test_max(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_mean(scalar_types_df: bpd.DataFrame, snapshot): + col_names = ["int64_col", "bool_col", "duration_col"] + bf_df = scalar_types_df[col_names] + bf_df["duration_col"] = bpd.to_timedelta(bf_df["duration_col"], unit="us") + + # The `to_timedelta` creates a new mapping for the column id. + col_names.insert(0, "rowindex") + name2id = { + col_name: col_id + for col_name, col_id in zip(col_names, bf_df._block.expr.column_ids) + } + + agg_ops_map = { + "int64_col": agg_ops.MeanOp().as_expr(name2id["int64_col"]), + "bool_col": agg_ops.MeanOp().as_expr(name2id["bool_col"]), + "duration_col": agg_ops.MeanOp().as_expr(name2id["duration_col"]), + "int64_col_w_floor": agg_ops.MeanOp(should_floor_result=True).as_expr( + name2id["int64_col"] + ), + } + sql = _apply_unary_agg_ops( + bf_df, list(agg_ops_map.values()), list(agg_ops_map.keys()) + ) + + snapshot.assert_match(sql, "out.sql") + + def test_median(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df ops_map = { From 7ef667b0f46f13bcc8ad4f2ed8f81278132b5aec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Thu, 25 Sep 2025 12:01:10 -0500 Subject: [PATCH 101/313] fix: avoid ibis fillna warning in compiler (#2113) * fix: avoid ibis fillna warning in compiler * fix mypy --- .../compile/{ => ibis_compiler}/default_ordering.py | 5 +---- .../core/compile/ibis_compiler/scalar_op_registry.py | 12 ++++++------ bigframes/session/_io/bigquery/read_gbq_table.py | 6 ------ tests/unit/test_notebook.py | 7 +++++-- 4 files changed, 12 insertions(+), 18 deletions(-) rename bigframes/core/compile/{ => ibis_compiler}/default_ordering.py (95%) diff --git a/bigframes/core/compile/default_ordering.py b/bigframes/core/compile/ibis_compiler/default_ordering.py similarity index 95% rename from bigframes/core/compile/default_ordering.py rename to bigframes/core/compile/ibis_compiler/default_ordering.py index 1a1350cfd6..3f2628d10c 100644 --- a/bigframes/core/compile/default_ordering.py +++ b/bigframes/core/compile/ibis_compiler/default_ordering.py @@ -47,10 +47,7 @@ def _convert_to_nonnull_string(column: ibis_types.Value) -> ibis_types.StringVal result = ibis_ops.ToJsonString(column).to_expr() # type: ignore # Escape backslashes and use backslash as delineator escaped = cast( - ibis_types.StringColumn, - result.fill_null(ibis_types.literal("")) - if hasattr(result, "fill_null") - else result.fillna(""), + ibis_types.StringColumn, result.fill_null(ibis_types.literal("")) ).replace( "\\", # type: ignore "\\\\", # type: ignore diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index a750a625ad..8426a86375 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -28,7 +28,7 @@ import pandas as pd from bigframes.core.compile.constants import UNIT_TO_US_CONVERSION_FACTORS -import bigframes.core.compile.default_ordering +import bigframes.core.compile.ibis_compiler.default_ordering from bigframes.core.compile.ibis_compiler.scalar_op_compiler import ( scalar_op_compiler, # TODO(tswast): avoid import of variables ) @@ -1064,7 +1064,7 @@ def isin_op_impl(x: ibis_types.Value, op: ops.IsInOp): if op.match_nulls and contains_nulls: return x.isnull() | x.isin(matchable_ibis_values) else: - return x.isin(matchable_ibis_values).fillna(False) + return x.isin(matchable_ibis_values).fill_null(ibis.literal(False)) @scalar_op_compiler.register_unary_op(ops.ToDatetimeOp, pass_op=True) @@ -1383,8 +1383,8 @@ def eq_nulls_match_op( left = x.cast(ibis_dtypes.str).fill_null(literal) right = y.cast(ibis_dtypes.str).fill_null(literal) else: - left = x.cast(ibis_dtypes.str).fillna(literal) - right = y.cast(ibis_dtypes.str).fillna(literal) + left = x.cast(ibis_dtypes.str).fill_null(literal) + right = y.cast(ibis_dtypes.str).fill_null(literal) return left == right @@ -1813,7 +1813,7 @@ def fillna_op( if hasattr(x, "fill_null"): return x.fill_null(typing.cast(ibis_types.Scalar, y)) else: - return x.fillna(typing.cast(ibis_types.Scalar, y)) + return x.fill_null(typing.cast(ibis_types.Scalar, y)) @scalar_op_compiler.register_binary_op(ops.round_op) @@ -2016,7 +2016,7 @@ def _construct_prompt( @scalar_op_compiler.register_nary_op(ops.RowKey, pass_op=True) def rowkey_op_impl(*values: ibis_types.Value, op: ops.RowKey) -> ibis_types.Value: - return bigframes.core.compile.default_ordering.gen_row_key(values) + return bigframes.core.compile.ibis_compiler.default_ordering.gen_row_key(values) # Helpers diff --git a/bigframes/session/_io/bigquery/read_gbq_table.py b/bigframes/session/_io/bigquery/read_gbq_table.py index 30a25762eb..00531ce25d 100644 --- a/bigframes/session/_io/bigquery/read_gbq_table.py +++ b/bigframes/session/_io/bigquery/read_gbq_table.py @@ -27,15 +27,9 @@ import google.api_core.exceptions import google.cloud.bigquery as bigquery -import bigframes.clients -import bigframes.core.compile -import bigframes.core.compile.default_ordering import bigframes.core.sql -import bigframes.dtypes import bigframes.exceptions as bfe import bigframes.session._io.bigquery -import bigframes.session.clients -import bigframes.version # Avoid circular imports. if typing.TYPE_CHECKING: diff --git a/tests/unit/test_notebook.py b/tests/unit/test_notebook.py index a41854fb29..3feacd52b2 100644 --- a/tests/unit/test_notebook.py +++ b/tests/unit/test_notebook.py @@ -12,12 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pathlib -import os.path +REPO_ROOT = pathlib.Path(__file__).parent.parent.parent def test_template_notebook_exists(): # This notebook is meant for being used as a BigFrames usage template and # could be dynamically linked in places such as BQ Studio and IDE extensions. # Let's make sure it exists in the well known path. - assert os.path.exists("notebooks/getting_started/bq_dataframes_template.ipynb") + assert ( + REPO_ROOT / "notebooks" / "getting_started" / "bq_dataframes_template.ipynb" + ).exists() From afe4331e27b400902838b1a9495601ae8750557f Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Thu, 25 Sep 2025 10:01:21 -0700 Subject: [PATCH 102/313] refactor: support agg_ops.DenseRankOp and RankOp for sqlglot compiler (#2114) --- .../sqlglot/aggregations/unary_compiler.py | 24 +++++++++ .../compile/sqlglot/aggregations/windows.py | 4 ++ bigframes/operations/aggregations.py | 2 + .../test_dense_rank/out.sql | 13 +++++ .../test_unary_compiler/test_rank/out.sql | 13 +++++ .../aggregations/test_unary_compiler.py | 50 ++++++++++++++++++- 6 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_dense_rank/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_rank/out.sql diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py index 8ed5510ec2..598a89e4eb 100644 --- a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -47,6 +47,18 @@ def _( return apply_window_if_present(sge.func("COUNT", column.expr), window) +@UNARY_OP_REGISTRATION.register(agg_ops.DenseRankOp) +def _( + op: agg_ops.DenseRankOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + # Ranking functions do not support window framing clauses. + return apply_window_if_present( + sge.func("DENSE_RANK"), window, include_framing_clauses=False + ) + + @UNARY_OP_REGISTRATION.register(agg_ops.MaxOp) def _( op: agg_ops.MaxOp, @@ -106,6 +118,18 @@ def _( return apply_window_if_present(sge.func("COUNT", sge.convert(1)), window) +@UNARY_OP_REGISTRATION.register(agg_ops.RankOp) +def _( + op: agg_ops.RankOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + # Ranking functions do not support window framing clauses. + return apply_window_if_present( + sge.func("RANK"), window, include_framing_clauses=False + ) + + @UNARY_OP_REGISTRATION.register(agg_ops.SumOp) def _( op: agg_ops.SumOp, diff --git a/bigframes/core/compile/sqlglot/aggregations/windows.py b/bigframes/core/compile/sqlglot/aggregations/windows.py index 4d7a3f7406..1bfa72b878 100644 --- a/bigframes/core/compile/sqlglot/aggregations/windows.py +++ b/bigframes/core/compile/sqlglot/aggregations/windows.py @@ -25,6 +25,7 @@ def apply_window_if_present( value: sge.Expression, window: typing.Optional[window_spec.WindowSpec] = None, + include_framing_clauses: bool = True, ) -> sge.Expression: if window is None: return value @@ -64,6 +65,9 @@ def apply_window_if_present( if not window.bounds and not order: return sge.Window(this=value, partition_by=group_by) + if not window.bounds and not include_framing_clauses: + return sge.Window(this=value, partition_by=group_by, order=order) + kind = ( "ROWS" if isinstance(window.bounds, window_spec.RowsWindowBounds) else "RANGE" ) diff --git a/bigframes/operations/aggregations.py b/bigframes/operations/aggregations.py index 7b6998b90e..f6e8600d42 100644 --- a/bigframes/operations/aggregations.py +++ b/bigframes/operations/aggregations.py @@ -519,6 +519,8 @@ def implicitly_inherits_order(self): @dataclasses.dataclass(frozen=True) class DenseRankOp(UnaryWindowOp): + name: ClassVar[str] = "dense_rank" + @property def skips_nulls(self): return False diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_dense_rank/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_dense_rank/out.sql new file mode 100644 index 0000000000..38b6ed9f5c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_dense_rank/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + DENSE_RANK() OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_rank/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_rank/out.sql new file mode 100644 index 0000000000..5de2330ef6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_rank/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + RANK() OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index a5ffda0e65..bf2523930f 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -17,7 +17,14 @@ import pytest from bigframes.core import agg_expressions as agg_exprs -from bigframes.core import array_value, identifiers, nodes +from bigframes.core import ( + array_value, + expression, + identifiers, + nodes, + ordering, + window_spec, +) from bigframes.operations import aggregations as agg_ops import bigframes.pandas as bpd @@ -38,6 +45,24 @@ def _apply_unary_agg_ops( return sql +def _apply_unary_window_op( + obj: bpd.DataFrame, + op: agg_exprs.UnaryAggregation, + window_spec: window_spec.WindowSpec, + new_name: str, +) -> str: + win_node = nodes.WindowOpNode( + obj._block.expr.node, + expression=op, + window_spec=window_spec, + output_name=identifiers.ColumnId(new_name), + ) + result = array_value.ArrayValue(win_node).select_columns([new_name]) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return sql + + def test_count(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] @@ -47,6 +72,18 @@ def test_count(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_dense_rank(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_exprs.UnaryAggregation( + agg_ops.DenseRankOp(), expression.deref(col_name) + ) + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + + snapshot.assert_match(sql, "out.sql") + + def test_max(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] @@ -104,6 +141,17 @@ def test_min(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_rank(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_exprs.UnaryAggregation(agg_ops.RankOp(), expression.deref(col_name)) + + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + + snapshot.assert_match(sql, "out.sql") + + def test_sum(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["int64_col", "bool_col"]] agg_ops_map = { From 8fc098ac67870dc349cdba2794da21a1e1bbb4fe Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Thu, 25 Sep 2025 11:01:09 -0700 Subject: [PATCH 103/313] chore(main): release 2.22.0 (#2099) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 21 +++++++++++++++++++++ bigframes/version.py | 4 ++-- third_party/bigframes_vendored/version.py | 4 ++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1868c0dbc..9911d2cb2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ [1]: https://pypi.org/project/bigframes/#history +## [2.22.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.21.0...v2.22.0) (2025-09-25) + + +### Features + +* Add `GroupBy.__iter__` ([#1394](https://github.com/googleapis/python-bigquery-dataframes/issues/1394)) ([c56a78c](https://github.com/googleapis/python-bigquery-dataframes/commit/c56a78cd509a535d4998d5b9a99ec3ecd334b883)) +* Add ai.generate_int to bigframes.bigquery package ([#2109](https://github.com/googleapis/python-bigquery-dataframes/issues/2109)) ([af6b862](https://github.com/googleapis/python-bigquery-dataframes/commit/af6b862de5c3921684210ec169338815f45b19dd)) +* Add Groupby.describe() ([#2088](https://github.com/googleapis/python-bigquery-dataframes/issues/2088)) ([328a765](https://github.com/googleapis/python-bigquery-dataframes/commit/328a765e746138806a021bea22475e8c03512aeb)) +* Implement `Index.to_list()` ([#2106](https://github.com/googleapis/python-bigquery-dataframes/issues/2106)) ([60056ca](https://github.com/googleapis/python-bigquery-dataframes/commit/60056ca06511f99092647fe55fc02eeab486b4ca)) +* Implement inplace parameter for `DataFrame.drop` ([#2105](https://github.com/googleapis/python-bigquery-dataframes/issues/2105)) ([3487f13](https://github.com/googleapis/python-bigquery-dataframes/commit/3487f13d12e34999b385c2e11551b5e27bfbf4ff)) +* Support callable for series map method ([#2100](https://github.com/googleapis/python-bigquery-dataframes/issues/2100)) ([ac25618](https://github.com/googleapis/python-bigquery-dataframes/commit/ac25618feed2da11fe4fb85058d498d262c085c0)) +* Support df.info() with null index ([#2094](https://github.com/googleapis/python-bigquery-dataframes/issues/2094)) ([fb81eea](https://github.com/googleapis/python-bigquery-dataframes/commit/fb81eeaf13af059f32cb38e7f117fb3504243d51)) + + +### Bug Fixes + +* Avoid ibis fillna warning in compiler ([#2113](https://github.com/googleapis/python-bigquery-dataframes/issues/2113)) ([7ef667b](https://github.com/googleapis/python-bigquery-dataframes/commit/7ef667b0f46f13bcc8ad4f2ed8f81278132b5aec)) +* Negative start and stop parameter values in Series.str.slice() ([#2104](https://github.com/googleapis/python-bigquery-dataframes/issues/2104)) ([f57a348](https://github.com/googleapis/python-bigquery-dataframes/commit/f57a348f1935a4e2bb14c501bb4c47cd552d102a)) +* Throw type error for incomparable join keys ([#2098](https://github.com/googleapis/python-bigquery-dataframes/issues/2098)) ([9dc9695](https://github.com/googleapis/python-bigquery-dataframes/commit/9dc96959a84b751d18b290129c2926df6e50b3f5)) +* Transformers with non-standard column names throw errors ([#2089](https://github.com/googleapis/python-bigquery-dataframes/issues/2089)) ([a2daa3f](https://github.com/googleapis/python-bigquery-dataframes/commit/a2daa3fffe6743327edb9f4c74db93198bd12f8e)) + ## [2.21.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.20.0...v2.21.0) (2025-09-17) diff --git a/bigframes/version.py b/bigframes/version.py index f8f4376098..5b669176e8 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.21.0" +__version__ = "2.22.0" # {x-release-please-start-date} -__release_date__ = "2025-09-17" +__release_date__ = "2025-09-25" # {x-release-please-end} diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index f8f4376098..5b669176e8 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.21.0" +__version__ = "2.22.0" # {x-release-please-start-date} -__release_date__ = "2025-09-17" +__release_date__ = "2025-09-25" # {x-release-please-end} From a3c252217ab86c77b0e2a0c404426c83fe5e6d36 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Thu, 25 Sep 2025 13:39:53 -0700 Subject: [PATCH 104/313] refactor: add agg_ops.QuantileOp, ApproxQuartilesOp and ApproxTopCountOp to sqlglot compiler (#2110) --- .../sqlglot/aggregations/op_registration.py | 20 +++---- .../sqlglot/aggregations/unary_compiler.py | 58 +++++++++++++++++-- .../test_approx_quartiles/out.sql | 16 +++++ .../test_approx_top_count/out.sql | 12 ++++ .../test_unary_compiler/test_quantile/out.sql | 14 +++++ .../aggregations/test_op_registration.py | 1 - .../aggregations/test_unary_compiler.py | 40 +++++++++++++ 7 files changed, 143 insertions(+), 18 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_quartiles/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_top_count/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_quantile/out.sql diff --git a/bigframes/core/compile/sqlglot/aggregations/op_registration.py b/bigframes/core/compile/sqlglot/aggregations/op_registration.py index 996bf5b362..eb02b8bd50 100644 --- a/bigframes/core/compile/sqlglot/aggregations/op_registration.py +++ b/bigframes/core/compile/sqlglot/aggregations/op_registration.py @@ -41,22 +41,16 @@ def arg_checker(*args, **kwargs): ) return item(*args, **kwargs) - if hasattr(op, "name"): - key = typing.cast(str, op.name) - if key in self._registered_ops: - raise ValueError(f"{key} is already registered") - else: - raise ValueError(f"The operator must have a 'name' attribute. Got {op}") + key = str(op) + if key in self._registered_ops: + raise ValueError(f"{key} is already registered") self._registered_ops[key] = item return arg_checker return decorator def __getitem__(self, op: str | agg_ops.WindowOp) -> CompilationFunc: - if isinstance(op, agg_ops.WindowOp): - if not hasattr(op, "name"): - raise ValueError(f"The operator must have a 'name' attribute. Got {op}") - else: - key = typing.cast(str, op.name) - return self._registered_ops[key] - return self._registered_ops[op] + key = op if isinstance(op, type) else type(op) + if str(key) not in self._registered_ops: + raise ValueError(f"{key} is already not registered") + return self._registered_ops[str(key)] diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py index 598a89e4eb..11d53cdd4c 100644 --- a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -38,6 +38,37 @@ def compile( return UNARY_OP_REGISTRATION[op](op, column, window=window) +@UNARY_OP_REGISTRATION.register(agg_ops.ApproxQuartilesOp) +def _( + op: agg_ops.ApproxQuartilesOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + if window is not None: + raise NotImplementedError("Approx Quartiles with windowing is not supported.") + # APPROX_QUANTILES returns an array of the quartiles, so we need to index it. + # The op.quartile is 1-based for the quartile, but array is 0-indexed. + # The quartiles are Q0, Q1, Q2, Q3, Q4. op.quartile is 1, 2, or 3. + # The array has 5 elements (for N=4 intervals). + # So we want the element at index `op.quartile`. + approx_quantiles_expr = sge.func("APPROX_QUANTILES", column.expr, sge.convert(4)) + return sge.Bracket( + this=approx_quantiles_expr, + expressions=[sge.func("OFFSET", sge.convert(op.quartile))], + ) + + +@UNARY_OP_REGISTRATION.register(agg_ops.ApproxTopCountOp) +def _( + op: agg_ops.ApproxTopCountOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + if window is not None: + raise NotImplementedError("Approx top count with windowing is not supported.") + return sge.func("APPROX_TOP_COUNT", column.expr, sge.convert(op.number)) + + @UNARY_OP_REGISTRATION.register(agg_ops.CountOp) def _( op: agg_ops.CountOp, @@ -109,13 +140,23 @@ def _( return apply_window_if_present(sge.func("MIN", column.expr), window) -@UNARY_OP_REGISTRATION.register(agg_ops.SizeUnaryOp) +@UNARY_OP_REGISTRATION.register(agg_ops.QuantileOp) def _( - op: agg_ops.SizeUnaryOp, - _, + op: agg_ops.QuantileOp, + column: typed_expr.TypedExpr, window: typing.Optional[window_spec.WindowSpec] = None, ) -> sge.Expression: - return apply_window_if_present(sge.func("COUNT", sge.convert(1)), window) + # TODO: Support interpolation argument + # TODO: Support percentile_disc + result: sge.Expression = sge.func("PERCENTILE_CONT", column.expr, sge.convert(op.q)) + if window is None: + # PERCENTILE_CONT is a navigation function, not an aggregate function, so it always needs an OVER clause. + result = sge.Window(this=result) + else: + result = apply_window_if_present(result, window) + if op.should_floor_result: + result = sge.Cast(this=sge.func("FLOOR", result), to="INT64") + return result @UNARY_OP_REGISTRATION.register(agg_ops.RankOp) @@ -130,6 +171,15 @@ def _( ) +@UNARY_OP_REGISTRATION.register(agg_ops.SizeUnaryOp) +def _( + op: agg_ops.SizeUnaryOp, + _, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return apply_window_if_present(sge.func("COUNT", sge.convert(1)), window) + + @UNARY_OP_REGISTRATION.register(agg_ops.SumOp) def _( op: agg_ops.SumOp, diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_quartiles/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_quartiles/out.sql new file mode 100644 index 0000000000..e7bb16e57c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_quartiles/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + APPROX_QUANTILES(`bfcol_0`, 4)[OFFSET(1)] AS `bfcol_1`, + APPROX_QUANTILES(`bfcol_0`, 4)[OFFSET(2)] AS `bfcol_2`, + APPROX_QUANTILES(`bfcol_0`, 4)[OFFSET(3)] AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `q1`, + `bfcol_2` AS `q2`, + `bfcol_3` AS `q3` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_top_count/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_top_count/out.sql new file mode 100644 index 0000000000..b61a72d1b2 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_top_count/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + APPROX_TOP_COUNT(`bfcol_0`, 10) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_quantile/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_quantile/out.sql new file mode 100644 index 0000000000..c1b3d1fffa --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_quantile/out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + PERCENTILE_CONT(`bfcol_0`, 0.5) OVER () AS `bfcol_1`, + CAST(FLOOR(PERCENTILE_CONT(`bfcol_0`, 0.5) OVER ()) AS INT64) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `quantile`, + `bfcol_2` AS `quantile_floor` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_op_registration.py b/tests/unit/core/compile/sqlglot/aggregations/test_op_registration.py index e3688f19df..dbdeb2307e 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_op_registration.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_op_registration.py @@ -29,7 +29,6 @@ def test_func(op: agg_ops.SizeOp, input: sge.Expression) -> sge.Expression: return input assert reg[agg_ops.SizeOp()](op, input) == test_func(op, input) - assert reg[agg_ops.SizeOp.name](op, input) == test_func(op, input) def test_register_function_first_argument_is_not_agg_op_raise_error(): diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index bf2523930f..4abf80df19 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -63,6 +63,30 @@ def _apply_unary_window_op( return sql +def test_approx_quartiles(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_ops_map = { + "q1": agg_ops.ApproxQuartilesOp(quartile=1).as_expr(col_name), + "q2": agg_ops.ApproxQuartilesOp(quartile=2).as_expr(col_name), + "q3": agg_ops.ApproxQuartilesOp(quartile=3).as_expr(col_name), + } + sql = _apply_unary_agg_ops( + bf_df, list(agg_ops_map.values()), list(agg_ops_map.keys()) + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_approx_top_count(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.ApproxTopCountOp(number=10).as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + def test_count(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] @@ -141,6 +165,22 @@ def test_min(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_quantile(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_ops_map = { + "quantile": agg_ops.QuantileOp(q=0.5).as_expr(col_name), + "quantile_floor": agg_ops.QuantileOp(q=0.5, should_floor_result=True).as_expr( + col_name + ), + } + sql = _apply_unary_agg_ops( + bf_df, list(agg_ops_map.values()), list(agg_ops_map.keys()) + ) + + snapshot.assert_match(sql, "out.sql") + + def test_rank(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] From 1e5918ba0a6817ada4a91e8b41c48923c2f9cd2c Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Thu, 25 Sep 2025 15:57:54 -0700 Subject: [PATCH 105/313] refactor: support agg_ops.CovOp and CorrOp in sqlglot compiler (#2116) --- .../sqlglot/aggregations/binary_compiler.py | 23 ++++++++ .../test_binary_compiler/test_corr/out.sql | 13 +++++ .../test_binary_compiler/test_cov/out.sql | 13 +++++ .../aggregations/test_binary_compiler.py | 54 +++++++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_corr/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_cov/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/test_binary_compiler.py diff --git a/bigframes/core/compile/sqlglot/aggregations/binary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/binary_compiler.py index a162a9c18a..856b5e2f3a 100644 --- a/bigframes/core/compile/sqlglot/aggregations/binary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/binary_compiler.py @@ -20,6 +20,7 @@ from bigframes.core import window_spec import bigframes.core.compile.sqlglot.aggregations.op_registration as reg +from bigframes.core.compile.sqlglot.aggregations.windows import apply_window_if_present import bigframes.core.compile.sqlglot.expressions.typed_expr as typed_expr from bigframes.operations import aggregations as agg_ops @@ -33,3 +34,25 @@ def compile( window: typing.Optional[window_spec.WindowSpec] = None, ) -> sge.Expression: return BINARY_OP_REGISTRATION[op](op, left, right, window=window) + + +@BINARY_OP_REGISTRATION.register(agg_ops.CorrOp) +def _( + op: agg_ops.CorrOp, + left: typed_expr.TypedExpr, + right: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + result = sge.func("CORR", left.expr, right.expr) + return apply_window_if_present(result, window) + + +@BINARY_OP_REGISTRATION.register(agg_ops.CovOp) +def _( + op: agg_ops.CovOp, + left: typed_expr.TypedExpr, + right: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + result = sge.func("COVAR_SAMP", left.expr, right.expr) + return apply_window_if_present(result, window) diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_corr/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_corr/out.sql new file mode 100644 index 0000000000..8922a71de4 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_corr/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `float64_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + CORR(`bfcol_0`, `bfcol_1`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `corr_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_cov/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_cov/out.sql new file mode 100644 index 0000000000..6cf189da31 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_cov/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `float64_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + COVAR_SAMP(`bfcol_0`, `bfcol_1`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `cov_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_binary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_binary_compiler.py new file mode 100644 index 0000000000..0897b535be --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/test_binary_compiler.py @@ -0,0 +1,54 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import typing + +import pytest + +from bigframes.core import agg_expressions as agg_exprs +from bigframes.core import array_value, identifiers, nodes +from bigframes.operations import aggregations as agg_ops +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def _apply_binary_agg_ops( + obj: bpd.DataFrame, + ops_list: typing.Sequence[agg_exprs.BinaryAggregation], + new_names: typing.Sequence[str], +) -> str: + aggs = [(op, identifiers.ColumnId(name)) for op, name in zip(ops_list, new_names)] + + agg_node = nodes.AggregateNode(obj._block.expr.node, aggregations=tuple(aggs)) + result = array_value.ArrayValue(agg_node) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return sql + + +def test_corr(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col"]] + agg_expr = agg_ops.CorrOp().as_expr("int64_col", "float64_col") + sql = _apply_binary_agg_ops(bf_df, [agg_expr], ["corr_col"]) + + snapshot.assert_match(sql, "out.sql") + + +def test_cov(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col"]] + agg_expr = agg_ops.CovOp().as_expr("int64_col", "float64_col") + sql = _apply_binary_agg_ops(bf_df, [agg_expr], ["cov_col"]) + + snapshot.assert_match(sql, "out.sql") From 1fc563c45288002d79b70a84176141714ad64f1a Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Thu, 25 Sep 2025 16:01:29 -0700 Subject: [PATCH 106/313] refactor: support agg_ops.RowNumberOp for sqlglot compiler (#2118) --- .../compile/sqlglot/aggregate_compiler.py | 2 +- .../sqlglot/aggregations/nullary_compiler.py | 12 +++ .../sqlglot/aggregations/unary_compiler.py | 10 +-- .../compile/sqlglot/aggregations/windows.py | 3 +- .../test_row_number/out.sql | 13 +++ .../test_row_number_with_window/out.sql | 13 +++ .../test_nullary_compiler/test_size/out.sql | 12 +++ .../aggregations/test_nullary_compiler.py | 85 +++++++++++++++++++ 8 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_size/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/test_nullary_compiler.py diff --git a/bigframes/core/compile/sqlglot/aggregate_compiler.py b/bigframes/core/compile/sqlglot/aggregate_compiler.py index 08bca535a8..b86ae196f6 100644 --- a/bigframes/core/compile/sqlglot/aggregate_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregate_compiler.py @@ -63,7 +63,7 @@ def compile_analytic( window: window_spec.WindowSpec, ) -> sge.Expression: if isinstance(aggregate, agg_expressions.NullaryAggregation): - return nullary_compiler.compile(aggregate.op) + return nullary_compiler.compile(aggregate.op, window) if isinstance(aggregate, agg_expressions.UnaryAggregation): column = typed_expr.TypedExpr( scalar_compiler.scalar_op_compiler.compile_expression(aggregate.arg), diff --git a/bigframes/core/compile/sqlglot/aggregations/nullary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/nullary_compiler.py index 99e3562b42..c6418591ba 100644 --- a/bigframes/core/compile/sqlglot/aggregations/nullary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/nullary_compiler.py @@ -39,3 +39,15 @@ def _( window: typing.Optional[window_spec.WindowSpec] = None, ) -> sge.Expression: return apply_window_if_present(sge.func("COUNT", sge.convert(1)), window) + + +@NULLARY_OP_REGISTRATION.register(agg_ops.RowNumberOp) +def _( + op: agg_ops.RowNumberOp, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + result: sge.Expression = sge.func("ROW_NUMBER") + if window is None: + # ROW_NUMBER always needs an OVER clause. + return sge.Window(this=result) + return apply_window_if_present(result, window) diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py index 11d53cdd4c..e8baa15bce 100644 --- a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -84,10 +84,7 @@ def _( column: typed_expr.TypedExpr, window: typing.Optional[window_spec.WindowSpec] = None, ) -> sge.Expression: - # Ranking functions do not support window framing clauses. - return apply_window_if_present( - sge.func("DENSE_RANK"), window, include_framing_clauses=False - ) + return apply_window_if_present(sge.func("DENSE_RANK"), window) @UNARY_OP_REGISTRATION.register(agg_ops.MaxOp) @@ -165,10 +162,7 @@ def _( column: typed_expr.TypedExpr, window: typing.Optional[window_spec.WindowSpec] = None, ) -> sge.Expression: - # Ranking functions do not support window framing clauses. - return apply_window_if_present( - sge.func("RANK"), window, include_framing_clauses=False - ) + return apply_window_if_present(sge.func("RANK"), window) @UNARY_OP_REGISTRATION.register(agg_ops.SizeUnaryOp) diff --git a/bigframes/core/compile/sqlglot/aggregations/windows.py b/bigframes/core/compile/sqlglot/aggregations/windows.py index 1bfa72b878..5e38bf120e 100644 --- a/bigframes/core/compile/sqlglot/aggregations/windows.py +++ b/bigframes/core/compile/sqlglot/aggregations/windows.py @@ -25,7 +25,6 @@ def apply_window_if_present( value: sge.Expression, window: typing.Optional[window_spec.WindowSpec] = None, - include_framing_clauses: bool = True, ) -> sge.Expression: if window is None: return value @@ -65,7 +64,7 @@ def apply_window_if_present( if not window.bounds and not order: return sge.Window(this=value, partition_by=group_by) - if not window.bounds and not include_framing_clauses: + if not window.bounds: return sge.Window(this=value, partition_by=group_by, order=order) kind = ( diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql new file mode 100644 index 0000000000..d20a635e3d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ROW_NUMBER() OVER () AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `row_number` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql new file mode 100644 index 0000000000..2cee8a228f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ROW_NUMBER() OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `row_number` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_size/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_size/out.sql new file mode 100644 index 0000000000..19ae8aa3fd --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_size/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `rowindex` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + COUNT(1) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `size` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_nullary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_nullary_compiler.py new file mode 100644 index 0000000000..2348b95496 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/test_nullary_compiler.py @@ -0,0 +1,85 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import typing + +import pytest + +from bigframes.core import agg_expressions as agg_exprs +from bigframes.core import array_value, identifiers, nodes, ordering, window_spec +from bigframes.operations import aggregations as agg_ops +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def _apply_nullary_agg_ops( + obj: bpd.DataFrame, + ops_list: typing.Sequence[agg_exprs.NullaryAggregation], + new_names: typing.Sequence[str], +) -> str: + aggs = [(op, identifiers.ColumnId(name)) for op, name in zip(ops_list, new_names)] + + agg_node = nodes.AggregateNode(obj._block.expr.node, aggregations=tuple(aggs)) + result = array_value.ArrayValue(agg_node) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return sql + + +def _apply_nullary_window_op( + obj: bpd.DataFrame, + op: agg_exprs.NullaryAggregation, + window_spec: window_spec.WindowSpec, + new_name: str, +) -> str: + win_node = nodes.WindowOpNode( + obj._block.expr.node, + expression=op, + window_spec=window_spec, + output_name=identifiers.ColumnId(new_name), + ) + result = array_value.ArrayValue(win_node).select_columns([new_name]) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return sql + + +def test_size(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + agg_expr = agg_ops.SizeOp().as_expr() + sql = _apply_nullary_agg_ops(bf_df, [agg_expr], ["size"]) + + snapshot.assert_match(sql, "out.sql") + + +def test_row_number(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df + agg_expr = agg_exprs.NullaryAggregation(agg_ops.RowNumberOp()) + window = window_spec.WindowSpec() + sql = _apply_nullary_window_op(bf_df, agg_expr, window, "row_number") + + snapshot.assert_match(sql, "out.sql") + + +def test_row_number_with_window(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name, "int64_too"]] + agg_expr = agg_exprs.NullaryAggregation(agg_ops.RowNumberOp()) + + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + # window = window_spec.unbound(ordering=(ordering.ascending_over(col_name),ordering.ascending_over("int64_too"))) + sql = _apply_nullary_window_op(bf_df, agg_expr, window, "row_number") + + snapshot.assert_match(sql, "out.sql") From 6b8154c578bb1a276e9cf8fe494d91f8cd6260f2 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Fri, 26 Sep 2025 15:54:22 -0700 Subject: [PATCH 107/313] feat: add ai.generate_double to bigframes.bigquery package (#2111) * feat: add ai.generate_double to bigframes.bigquery package * fix lint * fix doctest --- bigframes/bigquery/_operations/ai.py | 75 +++++++++++++++++++ .../ibis_compiler/scalar_op_registry.py | 16 +++- .../compile/sqlglot/expressions/ai_ops.py | 7 ++ bigframes/operations/__init__.py | 3 +- bigframes/operations/ai_ops.py | 22 ++++++ tests/system/small/bigquery/test_ai.py | 39 ++++++++++ .../test_ai_generate_double/out.sql | 18 +++++ .../out.sql | 18 +++++ .../sqlglot/expressions/test_ai_ops.py | 45 +++++++++++ .../sql/compilers/bigquery/__init__.py | 3 + .../ibis/expr/operations/ai_ops.py | 23 ++++++ 11 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql diff --git a/bigframes/bigquery/_operations/ai.py b/bigframes/bigquery/_operations/ai.py index f0b4f51611..5c7a4d682e 100644 --- a/bigframes/bigquery/_operations/ai.py +++ b/bigframes/bigquery/_operations/ai.py @@ -188,6 +188,81 @@ def generate_int( return series_list[0]._apply_nary_op(operator, series_list[1:]) +@log_adapter.method_logger(custom_base_name="bigquery_ai") +def generate_double( + prompt: PROMPT_TYPE, + *, + connection_id: str | None = None, + endpoint: str | None = None, + request_type: Literal["dedicated", "shared", "unspecified"] = "unspecified", + model_params: Mapping[Any, Any] | None = None, +) -> series.Series: + """ + Returns the AI analysis based on the prompt, which can be any combination of text and unstructured data. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> bpd.options.display.progress_bar = None + >>> animal = bpd.Series(["Kangaroo", "Rabbit", "Spider"]) + >>> bbq.ai.generate_double(("How many legs does a ", animal, " have?")) + 0 {'result': 2.0, 'full_response': '{"candidates... + 1 {'result': 4.0, 'full_response': '{"candidates... + 2 {'result': 8.0, 'full_response': '{"candidates... + dtype: struct>, status: string>[pyarrow] + + >>> bbq.ai.generate_double(("How many legs does a ", animal, " have?")).struct.field("result") + 0 2.0 + 1 4.0 + 2 8.0 + Name: result, dtype: Float64 + + Args: + prompt (Series | List[str|Series] | Tuple[str|Series, ...]): + A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series + or pandas Series. + connection_id (str, optional): + Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. + If not provided, the connection from the current session will be used. + endpoint (str, optional): + Specifies the Vertex AI endpoint to use for the model. For example `"gemini-2.5-flash"`. You can specify any + generally available or preview Gemini model. If you specify the model name, BigQuery ML automatically identifies and + uses the full endpoint of the model. If you don't specify an ENDPOINT value, BigQuery ML selects a recent stable + version of Gemini to use. + request_type (Literal["dedicated", "shared", "unspecified"]): + Specifies the type of inference request to send to the Gemini model. The request type determines what quota the request uses. + * "dedicated": function only uses Provisioned Throughput quota. The function returns the error Provisioned throughput is not + purchased or is not active if Provisioned Throughput quota isn't available. + * "shared": the function only uses dynamic shared quota (DSQ), even if you have purchased Provisioned Throughput quota. + * "unspecified": If you haven't purchased Provisioned Throughput quota, the function uses DSQ quota. + If you have purchased Provisioned Throughput quota, the function uses the Provisioned Throughput quota first. + If requests exceed the Provisioned Throughput quota, the overflow traffic uses DSQ quota. + model_params (Mapping[Any, Any]): + Provides additional parameters to the model. The MODEL_PARAMS value must conform to the generateContent request body format. + + Returns: + bigframes.series.Series: A new struct Series with the result data. The struct contains these fields: + * "result": an DOUBLE value containing the model's response to the prompt. The result is None if the request fails or is filtered by responsible AI. + * "full_response": a JSON value containing the response from the projects.locations.endpoints.generateContent call to the model. + The generated text is in the text element. + * "status": a STRING value that contains the API response status for the corresponding row. This value is empty if the operation was successful. + """ + + prompt_context, series_list = _separate_context_and_series(prompt) + assert len(series_list) > 0 + + operator = ai_ops.AIGenerateDouble( + prompt_context=tuple(prompt_context), + connection_id=_resolve_connection_id(series_list[0], connection_id), + endpoint=endpoint, + request_type=request_type, + model_params=json.dumps(model_params) if model_params else None, + ) + + return series_list[0]._apply_nary_op(operator, series_list[1:]) + + def _separate_context_and_series( prompt: PROMPT_TYPE, ) -> Tuple[List[str | None], List[series.Series]]: diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index 8426a86375..e8a2b0b6ce 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -1986,7 +1986,7 @@ def ai_generate_bool( @scalar_op_compiler.register_nary_op(ops.AIGenerateInt, pass_op=True) def ai_generate_int( - *values: ibis_types.Value, op: ops.AIGenerateBool + *values: ibis_types.Value, op: ops.AIGenerateInt ) -> ibis_types.StructValue: return ai_ops.AIGenerateInt( @@ -1998,6 +1998,20 @@ def ai_generate_int( ).to_expr() +@scalar_op_compiler.register_nary_op(ops.AIGenerateDouble, pass_op=True) +def ai_generate_double( + *values: ibis_types.Value, op: ops.AIGenerateDouble +) -> ibis_types.StructValue: + + return ai_ops.AIGenerateDouble( + _construct_prompt(values, op.prompt_context), # type: ignore + op.connection_id, # type: ignore + op.endpoint, # type: ignore + op.request_type.upper(), # type: ignore + op.model_params, # type: ignore + ).to_expr() + + def _construct_prompt( col_refs: tuple[ibis_types.Value], prompt_context: tuple[str | None] ) -> ibis_types.StructValue: diff --git a/bigframes/core/compile/sqlglot/expressions/ai_ops.py b/bigframes/core/compile/sqlglot/expressions/ai_ops.py index 50d56611b1..0e6d079bd7 100644 --- a/bigframes/core/compile/sqlglot/expressions/ai_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/ai_ops.py @@ -40,6 +40,13 @@ def _(*exprs: TypedExpr, op: ops.AIGenerateInt) -> sge.Expression: return sge.func("AI.GENERATE_INT", *args) +@register_nary_op(ops.AIGenerateDouble, pass_op=True) +def _(*exprs: TypedExpr, op: ops.AIGenerateDouble) -> sge.Expression: + args = [_construct_prompt(exprs, op.prompt_context)] + _construct_named_args(op) + + return sge.func("AI.GENERATE_DOUBLE", *args) + + def _construct_prompt( exprs: tuple[TypedExpr, ...], prompt_context: tuple[str | None, ...] ) -> sge.Kwarg: diff --git a/bigframes/operations/__init__.py b/bigframes/operations/__init__.py index 17e1f7534f..b14d15245a 100644 --- a/bigframes/operations/__init__.py +++ b/bigframes/operations/__init__.py @@ -14,7 +14,7 @@ from __future__ import annotations -from bigframes.operations.ai_ops import AIGenerateBool, AIGenerateInt +from bigframes.operations.ai_ops import AIGenerateBool, AIGenerateDouble, AIGenerateInt from bigframes.operations.array_ops import ( ArrayIndexOp, ArrayReduceOp, @@ -413,6 +413,7 @@ "GeoStDistanceOp", # AI ops "AIGenerateBool", + "AIGenerateDouble", "AIGenerateInt", # Numpy ops mapping "NUMPY_TO_BINOP", diff --git a/bigframes/operations/ai_ops.py b/bigframes/operations/ai_ops.py index 7a8202abd2..4404558497 100644 --- a/bigframes/operations/ai_ops.py +++ b/bigframes/operations/ai_ops.py @@ -66,3 +66,25 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT ) ) ) + + +@dataclasses.dataclass(frozen=True) +class AIGenerateDouble(base_ops.NaryOp): + name: ClassVar[str] = "ai_generate_double" + + prompt_context: Tuple[str | None, ...] + connection_id: str + endpoint: str | None + request_type: Literal["dedicated", "shared", "unspecified"] + model_params: str | None + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.float64()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) diff --git a/tests/system/small/bigquery/test_ai.py b/tests/system/small/bigquery/test_ai.py index 9f6feb0bbc..7d32149726 100644 --- a/tests/system/small/bigquery/test_ai.py +++ b/tests/system/small/bigquery/test_ai.py @@ -146,5 +146,44 @@ def test_ai_generate_int_multi_model(session): ) +def test_ai_generate_double(session): + s = bpd.Series(["Cat"], session=session) + prompt = ("How many legs does a ", s, " have?") + + result = bbq.ai.generate_double(prompt, endpoint="gemini-2.5-flash") + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.float64()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +def test_ai_generate_double_multi_model(session): + df = session.from_glob_path( + "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" + ) + + result = bbq.ai.generate_double( + ("How many animals are there in the picture ", df["image"]) + ) + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.float64()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + def _contains_no_nulls(s: series.Series) -> bool: return len(s) == s.count() diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql new file mode 100644 index 0000000000..0baab06c3b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_DOUBLE( + prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + connection_id => 'test_connection_id', + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql new file mode 100644 index 0000000000..4756cbb509 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_DOUBLE( + prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + connection_id => 'test_connection_id', + request_type => 'SHARED', + model_params => JSON '{}' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py index 33a257f9a9..c95889e1b2 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py @@ -111,3 +111,48 @@ def test_ai_generate_int_with_model_param( ) snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_double(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + + op = ops.AIGenerateDouble( + # The prompt does not make semantic sense but we only care about syntax correctness. + prompt_context=(None, " is the same as ", None), + connection_id="test_connection_id", + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + ) + + sql = utils._apply_unary_ops( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_double_with_model_param( + scalar_types_df: dataframe.DataFrame, snapshot +): + if version.Version(sqlglot.__version__) < version.Version("25.18.0"): + pytest.skip( + "Skip test because SQLGLot cannot compile model params to JSON at this version." + ) + + col_name = "string_col" + + op = ops.AIGenerateDouble( + # The prompt does not make semantic sense but we only care about syntax correctness. + prompt_context=(None, " is the same as ", None), + connection_id="test_connection_id", + endpoint=None, + request_type="shared", + model_params=json.dumps(dict()), + ) + + sql = utils._apply_unary_ops( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") diff --git a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py index ef150534ee..e4122e88da 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py @@ -1110,6 +1110,9 @@ def visit_AIGenerateBool(self, op, **kwargs): def visit_AIGenerateInt(self, op, **kwargs): return sge.func("AI.GENERATE_INT", *self._compile_ai_args(**kwargs)) + def visit_AIGenerateDouble(self, op, **kwargs): + return sge.func("AI.GENERATE_DOUBLE", *self._compile_ai_args(**kwargs)) + def _compile_ai_args(self, **kwargs): args = [] diff --git a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py index 4b855f71c0..708a459072 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py @@ -49,3 +49,26 @@ def dtype(self) -> dt.Struct: return dt.Struct.from_tuples( (("result", dt.int64), ("full_resposne", dt.string), ("status", dt.string)) ) + + +@public +class AIGenerateDouble(Value): + """Generate integers based on the prompt""" + + prompt: Value + connection_id: Value[dt.String] + endpoint: Optional[Value[dt.String]] + request_type: Value[dt.String] + model_params: Optional[Value[dt.String]] + + shape = rlz.shape_like("prompt") + + @attribute + def dtype(self) -> dt.Struct: + return dt.Struct.from_tuples( + ( + ("result", dt.float64), + ("full_resposne", dt.string), + ("status", dt.string), + ) + ) From 10b2a38f7faed86e71a96d386cf3554559f24019 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Mon, 29 Sep 2025 13:55:59 -0700 Subject: [PATCH 108/313] refactor: support and, or, xor for sqlglot compiler (#2117) --- bigframes/core/compile/sqlglot/__init__.py | 1 + .../compile/sqlglot/expressions/bool_ops.py | 47 +++++++++++++++++++ bigframes/operations/type.py | 4 +- tests/system/small/engines/test_bool_ops.py | 2 +- .../test_bool_ops/test_and_op/out.sql | 31 ++++++++++++ .../test_bool_ops/test_or_op/out.sql | 31 ++++++++++++ .../test_bool_ops/test_xor_op/out.sql | 31 ++++++++++++ .../sqlglot/expressions/test_bool_ops.py | 43 +++++++++++++++++ 8 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 bigframes/core/compile/sqlglot/expressions/bool_ops.py create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_and_op/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_or_op/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_xor_op/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/test_bool_ops.py diff --git a/bigframes/core/compile/sqlglot/__init__.py b/bigframes/core/compile/sqlglot/__init__.py index 1fc22e1af6..4ceb4118cd 100644 --- a/bigframes/core/compile/sqlglot/__init__.py +++ b/bigframes/core/compile/sqlglot/__init__.py @@ -17,6 +17,7 @@ import bigframes.core.compile.sqlglot.expressions.ai_ops # noqa: F401 import bigframes.core.compile.sqlglot.expressions.array_ops # noqa: F401 import bigframes.core.compile.sqlglot.expressions.blob_ops # noqa: F401 +import bigframes.core.compile.sqlglot.expressions.bool_ops # noqa: F401 import bigframes.core.compile.sqlglot.expressions.comparison_ops # noqa: F401 import bigframes.core.compile.sqlglot.expressions.date_ops # noqa: F401 import bigframes.core.compile.sqlglot.expressions.datetime_ops # noqa: F401 diff --git a/bigframes/core/compile/sqlglot/expressions/bool_ops.py b/bigframes/core/compile/sqlglot/expressions/bool_ops.py new file mode 100644 index 0000000000..41076b666a --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/bool_ops.py @@ -0,0 +1,47 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import dtypes +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler + +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op + + +@register_binary_op(ops.and_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + if left.dtype == dtypes.BOOL_DTYPE and right.dtype == dtypes.BOOL_DTYPE: + return sge.And(this=left.expr, expression=right.expr) + return sge.BitwiseAnd(this=left.expr, expression=right.expr) + + +@register_binary_op(ops.or_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + if left.dtype == dtypes.BOOL_DTYPE and right.dtype == dtypes.BOOL_DTYPE: + return sge.Or(this=left.expr, expression=right.expr) + return sge.BitwiseOr(this=left.expr, expression=right.expr) + + +@register_binary_op(ops.xor_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + if left.dtype == dtypes.BOOL_DTYPE and right.dtype == dtypes.BOOL_DTYPE: + left_expr = sge.And(this=left.expr, expression=sge.Not(this=right.expr)) + right_expr = sge.And(this=sge.Not(this=left.expr), expression=right.expr) + return sge.Or(this=left_expr, expression=right_expr) + return sge.BitwiseXor(this=left.expr, expression=right.expr) diff --git a/bigframes/operations/type.py b/bigframes/operations/type.py index 020bd0ea57..6542233081 100644 --- a/bigframes/operations/type.py +++ b/bigframes/operations/type.py @@ -204,7 +204,7 @@ def output_type( raise TypeError(f"Type {right_type} is not binary") if left_type != right_type: raise TypeError( - "Bitwise operands {left_type} and {right_type} do not match" + f"Bitwise operands {left_type} and {right_type} do not match" ) return left_type @@ -222,7 +222,7 @@ def output_type( raise TypeError(f"Type {right_type} is not array-like") if left_type != right_type: raise TypeError( - "Vector op operands {left_type} and {right_type} do not match" + f"Vector op operands {left_type} and {right_type} do not match" ) return bigframes.dtypes.FLOAT_DTYPE diff --git a/tests/system/small/engines/test_bool_ops.py b/tests/system/small/engines/test_bool_ops.py index 065a43c209..a77d52b356 100644 --- a/tests/system/small/engines/test_bool_ops.py +++ b/tests/system/small/engines/test_bool_ops.py @@ -46,7 +46,7 @@ def apply_op_pairwise( return new_arr -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) @pytest.mark.parametrize( "op", [ diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_and_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_and_op/out.sql new file mode 100644 index 0000000000..42c5847401 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_and_op/out.sql @@ -0,0 +1,31 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_0` AS `bfcol_7`, + `bfcol_1` AS `bfcol_8`, + `bfcol_1` & `bfcol_1` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` AND `bfcol_7` AS `bfcol_18` + FROM `bfcte_1` +) +SELECT + `bfcol_14` AS `rowindex`, + `bfcol_15` AS `bool_col`, + `bfcol_16` AS `int64_col`, + `bfcol_17` AS `int_and_int`, + `bfcol_18` AS `bool_and_bool` +FROM `bfcte_2` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_or_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_or_op/out.sql new file mode 100644 index 0000000000..d1e7bd1822 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_or_op/out.sql @@ -0,0 +1,31 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_0` AS `bfcol_7`, + `bfcol_1` AS `bfcol_8`, + `bfcol_1` | `bfcol_1` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` OR `bfcol_7` AS `bfcol_18` + FROM `bfcte_1` +) +SELECT + `bfcol_14` AS `rowindex`, + `bfcol_15` AS `bool_col`, + `bfcol_16` AS `int64_col`, + `bfcol_17` AS `int_and_int`, + `bfcol_18` AS `bool_and_bool` +FROM `bfcte_2` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_xor_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_xor_op/out.sql new file mode 100644 index 0000000000..7d5f74ede7 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_xor_op/out.sql @@ -0,0 +1,31 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_0` AS `bfcol_7`, + `bfcol_1` AS `bfcol_8`, + `bfcol_1` ^ `bfcol_1` AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_7` AND NOT `bfcol_7` OR NOT `bfcol_7` AND `bfcol_7` AS `bfcol_18` + FROM `bfcte_1` +) +SELECT + `bfcol_14` AS `rowindex`, + `bfcol_15` AS `bool_col`, + `bfcol_16` AS `int64_col`, + `bfcol_17` AS `int_and_int`, + `bfcol_18` AS `bool_and_bool` +FROM `bfcte_2` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_bool_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_bool_ops.py new file mode 100644 index 0000000000..08b60d6ddf --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_bool_ops.py @@ -0,0 +1,43 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def test_and_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["bool_col", "int64_col"]] + + bf_df["int_and_int"] = bf_df["int64_col"] & bf_df["int64_col"] + bf_df["bool_and_bool"] = bf_df["bool_col"] & bf_df["bool_col"] + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_or_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["bool_col", "int64_col"]] + + bf_df["int_and_int"] = bf_df["int64_col"] | bf_df["int64_col"] + bf_df["bool_and_bool"] = bf_df["bool_col"] | bf_df["bool_col"] + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_xor_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["bool_col", "int64_col"]] + + bf_df["int_and_int"] = bf_df["int64_col"] ^ bf_df["int64_col"] + bf_df["bool_and_bool"] = bf_df["bool_col"] ^ bf_df["bool_col"] + snapshot.assert_match(bf_df.sql, "out.sql") From c311876b2adbc0b66ae5e463c6e56466c6a6a495 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Mon, 29 Sep 2025 14:27:42 -0700 Subject: [PATCH 109/313] fix: Prevent invalid syntax for no-op .replace ops (#2112) --- bigframes/core/compile/ibis_compiler/scalar_op_registry.py | 4 ++++ bigframes/core/compile/sqlglot/expressions/generic_ops.py | 2 ++ tests/system/small/test_series.py | 2 ++ 3 files changed, 8 insertions(+) diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index e8a2b0b6ce..635ba516e4 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -1173,6 +1173,10 @@ def udf(*inputs): @scalar_op_compiler.register_unary_op(ops.MapOp, pass_op=True) def map_op_impl(x: ibis_types.Value, op: ops.MapOp): + # this should probably be handled by a rewriter + if len(op.mappings) == 0: + return x + case = ibis_api.case() for mapping in op.mappings: case = case.when(x == mapping[0], mapping[1]) diff --git a/bigframes/core/compile/sqlglot/expressions/generic_ops.py b/bigframes/core/compile/sqlglot/expressions/generic_ops.py index 8a792c0753..6a3825309c 100644 --- a/bigframes/core/compile/sqlglot/expressions/generic_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/generic_ops.py @@ -78,6 +78,8 @@ def _(expr: TypedExpr) -> sge.Expression: @register_unary_op(ops.MapOp, pass_op=True) def _(expr: TypedExpr, op: ops.MapOp) -> sge.Expression: + if len(op.mappings) == 0: + return expr.expr return sge.Case( this=expr.expr, ifs=[ diff --git a/tests/system/small/test_series.py b/tests/system/small/test_series.py index d1a252f8dc..65b170df32 100644 --- a/tests/system/small/test_series.py +++ b/tests/system/small/test_series.py @@ -733,10 +733,12 @@ def test_series_replace_nans_with_pd_na(scalars_dfs): ( ({"Hello, World!": "Howdy, Planet!", "T": "R"},), ({},), + ({0: "Hello, World!"},), ), ids=[ "non-empty", "empty", + "off-type", ], ) def test_series_replace_dict(scalars_dfs, replacement_dict): From d1a9888a2b47de6aca5dddc94d0c8f280344b58a Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Mon, 29 Sep 2025 15:05:20 -0700 Subject: [PATCH 110/313] docs: add timedelta notebook sample (#2124) --- notebooks/data_types/timedelta.ipynb | 576 +++++++++++++++++++++++++++ 1 file changed, 576 insertions(+) create mode 100644 notebooks/data_types/timedelta.ipynb diff --git a/notebooks/data_types/timedelta.ipynb b/notebooks/data_types/timedelta.ipynb new file mode 100644 index 0000000000..dce5b06d58 --- /dev/null +++ b/notebooks/data_types/timedelta.ipynb @@ -0,0 +1,576 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "8ebb6e6a", + "metadata": {}, + "outputs": [], + "source": [ + "# Copyright 2025 Google LLC\n", + "#\n", + "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "# you may not use this file except in compliance with the License.\n", + "# You may obtain a copy of the License at\n", + "#\n", + "# https://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing, software\n", + "# distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "# See the License for the specific language governing permissions and\n", + "# limitations under the License." + ] + }, + { + "cell_type": "markdown", + "id": "c4f3bbfa", + "metadata": {}, + "source": [ + "# BigFrames Timedelta\n", + "\n", + "
\n", + "\n", + " \n", + " \n", + " \n", + "
\n", + " \n", + " \"Colab Run in Colab\n", + " \n", + " \n", + " \n", + " \"GitHub\n", + " View on GitHub\n", + " \n", + " \n", + " \n", + " \"BQ\n", + " Open in BQ Studio\n", + " \n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "f74e2573", + "metadata": {}, + "source": [ + "In this notebook, you will use timedeltas to analyze the taxi trips in NYC. " + ] + }, + { + "cell_type": "markdown", + "id": "8f74dec4", + "metadata": {}, + "source": [ + "# Setup" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "51173665", + "metadata": {}, + "outputs": [], + "source": [ + "import bigframes.exceptions\n", + "import warnings\n", + "import bigframes.pandas as bpd\n", + "\n", + "PROJECT = \"bigframes-dev\" # replace this with your project\n", + "LOCATION = \"us\" # replace this with your location\n", + "\n", + "bpd.options.bigquery.project = PROJECT\n", + "bpd.options.bigquery.location = LOCATION\n", + "bpd.options.display.progress_bar = None\n", + "\n", + "bpd.options.bigquery.ordering_mode = \"partial\"\n", + "\n", + "warnings.filterwarnings(\"ignore\", category=bigframes.exceptions.AmbiguousWindowWarning)\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "id": "d64fd3e3", + "metadata": {}, + "source": [ + "# Timedelta arithmetics and comparisons" + ] + }, + { + "cell_type": "markdown", + "id": "e10bd798", + "metadata": {}, + "source": [ + "First, you load the taxi data from the BigQuery public dataset `bigquery-public-data.new_york_taxi_trips.tlc_yellow_trips_2021`. The size of this table is about 6.3 GB." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "f1b11138", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
vendor_idpickup_datetimedropoff_datetimepassenger_counttrip_distancerate_codestore_and_fwd_flagpayment_typefare_amountextramta_taxtip_amounttolls_amountimp_surchargeairport_feetotal_amountpickup_location_iddropoff_location_iddata_file_yeardata_file_month
022021-08-03 10:47:38+00:002021-08-03 10:48:28+00:0010E-91.0N10E-90E-90E-90E-90E-90E-90E-90E-919319320218
112021-08-03 13:03:25+00:002021-08-03 13:03:25+00:0010E-95.0Y20E-90E-90E-90E-90E-90E-90E-90E-926526420218
222021-08-30 15:47:27+00:002021-08-30 15:47:46+00:0010E-91.0N10E-90E-90E-90E-90E-90E-90E-90E-919319320218
322021-08-18 16:27:00+00:002021-08-19 15:28:35+00:0010E-91.0N10E-90E-90E-90E-90E-90E-90E-90E-919319320218
422021-08-14 10:13:10+00:002021-08-14 10:13:36+00:0010E-91.0N10E-90E-90E-90E-90E-90E-90E-90E-919319320218
\n", + "
" + ], + "text/plain": [ + " vendor_id pickup_datetime dropoff_datetime \\\n", + "0 2 2021-08-03 10:47:38+00:00 2021-08-03 10:48:28+00:00 \n", + "1 1 2021-08-03 13:03:25+00:00 2021-08-03 13:03:25+00:00 \n", + "2 2 2021-08-30 15:47:27+00:00 2021-08-30 15:47:46+00:00 \n", + "3 2 2021-08-18 16:27:00+00:00 2021-08-19 15:28:35+00:00 \n", + "4 2 2021-08-14 10:13:10+00:00 2021-08-14 10:13:36+00:00 \n", + "\n", + " passenger_count trip_distance rate_code store_and_fwd_flag payment_type \\\n", + "0 1 0E-9 1.0 N 1 \n", + "1 1 0E-9 5.0 Y 2 \n", + "2 1 0E-9 1.0 N 1 \n", + "3 1 0E-9 1.0 N 1 \n", + "4 1 0E-9 1.0 N 1 \n", + "\n", + " fare_amount extra mta_tax tip_amount tolls_amount imp_surcharge \\\n", + "0 0E-9 0E-9 0E-9 0E-9 0E-9 0E-9 \n", + "1 0E-9 0E-9 0E-9 0E-9 0E-9 0E-9 \n", + "2 0E-9 0E-9 0E-9 0E-9 0E-9 0E-9 \n", + "3 0E-9 0E-9 0E-9 0E-9 0E-9 0E-9 \n", + "4 0E-9 0E-9 0E-9 0E-9 0E-9 0E-9 \n", + "\n", + " airport_fee total_amount pickup_location_id dropoff_location_id \\\n", + "0 0E-9 0E-9 193 193 \n", + "1 0E-9 0E-9 265 264 \n", + "2 0E-9 0E-9 193 193 \n", + "3 0E-9 0E-9 193 193 \n", + "4 0E-9 0E-9 193 193 \n", + "\n", + " data_file_year data_file_month \n", + "0 2021 8 \n", + "1 2021 8 \n", + "2 2021 8 \n", + "3 2021 8 \n", + "4 2021 8 " + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "taxi_trips = bpd.read_gbq(\"bigquery-public-data.new_york_taxi_trips.tlc_yellow_trips_2021\").dropna()\n", + "taxi_trips = taxi_trips[taxi_trips['pickup_datetime'].dt.year == 2021]\n", + "taxi_trips.peek(5)" + ] + }, + { + "cell_type": "markdown", + "id": "f5b13623", + "metadata": {}, + "source": [ + "Based on the dataframe content, you calculate the trip durations and store them under the column “trip_duration”. You can see that the values under \"trip_duartion\" are timedeltas." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "12fc1a5a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "duration[us][pyarrow]" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "taxi_trips['trip_duration'] = taxi_trips['dropoff_datetime'] - taxi_trips['pickup_datetime']\n", + "taxi_trips['trip_duration'].dtype" + ] + }, + { + "cell_type": "markdown", + "id": "4b18b8d9", + "metadata": {}, + "source": [ + "To remove data outliers, you filter the taxi_trips to keep only the trips that were less than 2 hours." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "62b2d42e", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "\n", + "taxi_trips = taxi_trips[taxi_trips['trip_duration'] <= pd.Timedelta(\"2h\")]" + ] + }, + { + "cell_type": "markdown", + "id": "665fb8a7", + "metadata": {}, + "source": [ + "Finally, you calculate the average speed of each trip, and find the median speed of all trips." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "e79e23c3", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The median speed of an average taxi trip is: 10.58 mph.\n" + ] + } + ], + "source": [ + "average_speed = taxi_trips[\"trip_distance\"] / (taxi_trips['trip_duration'] / pd.Timedelta(\"1h\"))\n", + "print(f\"The median speed of an average taxi trip is: {average_speed.median():.2f} mph.\")" + ] + }, + { + "cell_type": "markdown", + "id": "261dbdf1", + "metadata": {}, + "source": [ + "Given how packed NYC is, a median taxi speed of 10.58 mph totally makes sense." + ] + }, + { + "cell_type": "markdown", + "id": "6122c32e", + "metadata": {}, + "source": [ + "# Use timedelta for rolling aggregation" + ] + }, + { + "cell_type": "markdown", + "id": "05ae6fbb", + "metadata": {}, + "source": [ + "Using your existing dataset, you can now calculate the taxi trip count over a period of two days, and find out when NYC is at its busiest and when it is fast asleep.\n", + "\n", + "First, you pick two workdays (a Thursday and a Friday) as your target dates:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "7dc50b1c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Number of records: 255434\n" + ] + } + ], + "source": [ + "import datetime\n", + "\n", + "target_dates = [\n", + " datetime.date(2021, 12, 2), \n", + " datetime.date(2021, 12, 3)\n", + "]\n", + "\n", + "two_day_taxi_trips = taxi_trips[taxi_trips['pickup_datetime'].dt.date.isin(target_dates)]\n", + "print(f\"Number of records: {len(two_day_taxi_trips)}\")\n", + "# Number of records: 255434\n" + ] + }, + { + "cell_type": "markdown", + "id": "a5f39bd8", + "metadata": {}, + "source": [ + "Your next step involves aggregating the number of records associated with each unique \"pickup_datetime\" value. Additionally, the data undergo upsampling to account for any absent timestamps, which are populated with a count of 0." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "f25b34f5", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "\n", + "two_day_trip_count = two_day_taxi_trips['pickup_datetime'].value_counts()\n", + "\n", + "full_index = pd.date_range(\n", + " start='2021-12-02 00:00:00',\n", + " end='2021-12-04 00:00:00',\n", + " freq='s',\n", + " tz='UTC'\n", + ")\n", + "two_day_trip_count = two_day_trip_count.reindex(full_index).fillna(0)" + ] + }, + { + "cell_type": "markdown", + "id": "3394b2ba", + "metadata": {}, + "source": [ + "You'll then calculate the sum of trip counts within a 5-minute rolling window. This involves using the `rolling()` method, which can accept a time window in the form of either a string or a timedelta object." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "4d5987d8", + "metadata": {}, + "outputs": [], + "source": [ + "two_day_trip_rolling_count = two_day_trip_count.sort_index().rolling(window=\"5m\").sum()" + ] + }, + { + "cell_type": "markdown", + "id": "3811b1fb", + "metadata": {}, + "source": [ + "Finally, you visualize the trip counts throughout the target dates." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "871c32c5", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAABS8AAAJeCAYAAABVkfCjAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjMsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvZiW1igAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs3Xd4HOW5NvB7tqv3brn3hm1MN70bCC2FhDRSSE8IpJ+QL5BwEpKQQ0gjJAdySCghIRCKKaaDbdx7t+Um2ep1e5vvj5l3dmZ2V9KuVtJKvn/XlSva3dnVyKxW7zzvUyRZlmUQERERERERERERZRnLaJ8AERERERERERERUSIMXhIREREREREREVFWYvCSiIiIiIiIiIiIshKDl0RERERERERERJSVGLwkIiIiIiIiIiKirMTgJREREREREREREWUlBi+JiIiIiIiIiIgoKzF4SURERERERERERFnJNtonkI2i0SiOHz+OgoICSJI02qdDREREREREREQ0psiyjL6+PtTW1sJiST9/ksHLBI4fP476+vrRPg0iIiIiIiIiIqIx7dixY5gwYULaz2fwMoGCggIAyj9uYWHhKJ9NZoVCIbz66qu47LLLYLfbR/t0aAzge4ZSxfcMpYrvGUoV3zOUKr5nKFV8z1A6+L6hVI3390xvby/q6+u1OFu6GLxMQJSKFxYWjsvgZW5uLgoLC8flLwZlHt8zlCq+ZyhVfM9QqvieoVTxPUOp4nuG0sH3DaXqZHnPDLUlIwf2EBERERERERERUVZi8JKIiIiIiIiIiIiyEoOXRERERERERERElJUYvCQiIiIiIiIiIqKsxOAlERERERERERERZSUGL4mIiIiIiIiIiCgrMXhJREREREREREREWYnBSyIiIiIiIiIiIspKDF4SERERERERERFRVmLwkoiIiIiIiIiIiLISg5dERERERERERESUlRi8JCIiIiIiIiIioqzE4CURERERERERERFlJQYviYiIiIiIiIiIKCsxeElERERERERERERZicFLIiIiIiIiIiIiykoMXhIREREREREREVFWYvCSiIiIiIiIiIiIshKDl0RERERERERERJSVGLwkIiIiIiIiIiKirMTgJRERERERERFRFvnzOw342hObEYnKo30qRKOOwUsiIiIiIiIioixyz4rdeH7rcbyzrw2yLCPKICad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jBu0FWOmdViwBwAAAAAAAEBYovISAAAAAAAAQFgieQkAAAAAAAAgLJG8BAAAAAAAABCWSF4CAAAAAAAACEs9Nnn5xRdf6JxzzlGvXr1kMpn03//+N2C/w+HQzTffrFGjRikuLk69evXST37yE+3Zs6fJuWpqahQXF6fNmzdLkhYsWKBjjjlGUVFRGjhwoF588cWA4++77z4dd9xxSkhIUGZmpn74wx9qw4YNzcbZr18/ffrpp1qwYIHOO+885eTkKC4uTmPGjNErr7wScOyaNWt0wQUXKD8/XyaTSY8++mirvhcrV67USSedpOjoaOXm5urBBx9scsxbb72loUOHKjo6WqNGjdKcOXOOeN4dO3Zo2rRpio2NVWZmpm666SY1NDQEHHOk71VzSkpKdPnllysxMVHJycm6+uqrVVlZ2eb31FaMmUat+f6WlpZq5syZysnJUVRUlAYPHnzEcdNZP9va2lrNnDlTaWlpio+P1wUXXKCioqKAY1ozXtuKMeNWW1urq666SqNGjZLVatUPf/jDJse8/fbbOv3005WRkaHExERNnDhRH3/88RHPzZjpuWNGkl555RUdddRRio2NVU5Ojn7605/qwIEDRzx3Z/xsDcPQn//8Z+Xk5CgmJkZTpkzRpk2bAo5pzXhtq1COmaefflqjR49WYmKi79/thx9+2Gyc4fLZxPUMY8Yf1zOtw5hx43qm9RgzblzPtE0ox42/+++/XyaTSddff32z+8Pl86nHXdMYPdScOXOMW2+91Xj77bcNScY777wTsL+0tNSYMmWK8cYbbxjr1683Fi5caIwbN8449thjm5zr3XffNYYNG2YYhmFs3brViI2NNW688UZj7dq1xt///nfDYrEYH330ke/4qVOnGrNnzzZWr15tLF++3Dj77LONvLw8o7KyMuC8K1asMJKSkoz6+nrjnnvuMW677Tbj66+/NjZv3mw8+uijhtlsNt577z3f8UuWLDF+97vfGa+99pqRnZ1tPPLII0f8PpSVlRlZWVnG5Zdfbqxevdp47bXXjJiYGOOZZ57xHfP1118bFovFePDBB421a9cat912m2Gz2YxVq1a1eN6GhgZj5MiRxpQpU4xly5YZc+bMMdLT041bbrnFd0xrvlfNOfPMM42jjjrKWLRokfHll18aAwcONC699NI2vadgMGbcWvP9raurM8aOHWucffbZxldffWVs27bNWLBggbF8+fLDnruzfrbXXHONkZuba8ybN8/47rvvjAkTJhjHH3+8b39rxmswGDNulZWVxjXXXGM8++yzxtSpU43zzjuvyTHXXXed8cADDxhLliwxNm7caNxyyy2GzWYzvv/++8OemzHTc8fMV199ZZjNZuOxxx4ztm7danz55ZfGiBEjjB/96EeHPXdn/Wzvv/9+Iykpyfjvf/9rrFixwjj33HONfv36GTU1Nb5jjjRegxHKMfO///3P+OCDD4yNGzcaGzZsMP74xz8aNpvNWL16dcB5w+WziesZN8aMG9czrceYceN6pvUYM25cz7RNKMeN15IlS4z8/Hxj9OjRxnXXXddkf7h8PvXEa5oem7z019w/jOYsWbLEkGRs3749YPtPf/pT4+abbzYMwzB+//vfGyNGjAjYf/HFFxtTp05t8bzFxcWGJOPzzz8P2H7XXXcZF198cYuvO/vss40ZM2Y0u69v376t+ofx1FNPGSkpKUZdXZ1v280332wMGTLE9/yiiy4ypk2bFvC68ePHG7/85S9bPO+cOXMMs9lsFBYW+rY9/fTTRmJiou9rBfO9Wrt2rSHJ+Pbbb33bPvzwQ8NkMhm7d+9u9XtqL8bM4b+/Tz/9tNG/f3+jvr7+iOfz6qyfbWlpqWGz2Yy33nrLt23dunWGJGPhwoWGYbRuvLZXTx4z/qZPn97shVtzhg8fbtx5550t7mfMuPXUMfPQQw8Z/fv3D9j2+OOPG717927xXJ31s3W5XEZ2drbx0EMPBXytqKgo47XXXjMMo3Xjtb1CPWYMwzBSUlKMf/7znwHbwuWzieuZphgzXM+0VU8eM/64nmk9xowb1zNtE4pxU1FRYQwaNMiYO3euccoppzSbvAyXz6eeeE3TY6eNB6OsrEwmk0nJycm+bS6XS++//77OO+88SdLChQs1ZcqUgNdNnTpVCxcuPOx5JSk1NTVg+//+9z/feVt63aGvaauFCxfq5JNPlt1uD4h3w4YNOnjwoO+YI72nO+64Q/n5+QHnHTVqlLKysgJeU15erjVr1rT6vC+++KJMJlPAeZOTkzV27FjftilTpshsNmvx4sWtfk9dpaeOmf/973+aOHGiZs6cqaysLI0cOVL33nuvnE6n7zWd9bNdsGCBTCaTCgoKJElLly6Vw+EI+B4PHTpUeXl5vu9xa8ZrV4nEMRMMl8ulioqKgK/NmGleTx0zEydO1M6dOzVnzhwZhqGioiL95z//0dlnn+07prN+tgUFBTKZTFqwYIEkadu2bSosLAw4b1JSksaPHx9w3iON167SGWPG6XTq9ddfV1VVlSZOnBiwL1w+m7ieCV5PHTNczwQvEsdMMLieab2eOma4nmmfjhw3M2fO1LRp05oc6y9cPp964jUNyctWqq2t1c0336xLL71UiYmJvu2LFi2SJI0fP16SVFhYGDAYJCkrK0vl5eWqqalpcl6Xy6Xrr79eJ5xwgkaOHOnbvnv3bq1cuVJnnXVWs/G8+eab+vbbbzVjxox2va+W4vXuO9wx3v2SlJ6ergEDBnTIef2/V0lJSRoyZEjAeTMzMwNeY7ValZqaesTz+n/trtCTx8zWrVv1n//8R06nU3PmzNGf/vQn/e1vf9Nf/vIX32s662cbGxurIUOGyGaz+bbb7faADzTv6xgzXTNmgvHXv/5VlZWVuuiii3zbGDNN9eQxc8IJJ+iVV17RxRdfLLvdruzsbCUlJenJJ5/0HdNZP1ubzaYhQ4YoNjY2YPvhPitbM167QkePmVWrVik+Pl5RUVG65ppr9M4772j48OG+/eH02cT1THB68pjheiY4kTpmgsH1TOv05DHD9UzwOnLcvP766/r+++913333tfj1wunzqSde05C8bAWHw6GLLrpIhmHo6aefDtj37rvv6gc/+IHM5uC+lTNnztTq1av1+uuvB2z/3//+pxNPPLHJLyRJmj9/vmbMmKHnnntOI0aMCOrrdrRZs2Zp3rx5HX7eH/3oR1q/fn2Hn7ez9fQx43K5lJmZqWeffVbHHnusLr74Yt166636xz/+4Tums36248aN0/r169W7d+8OP3dn6uljxt+rr76qO++8U2+++WbAByFjJlBPHzNr167Vddddpz//+c9aunSpPvroIxUUFOiaa67xHdNZP9vevXtr/fr1GjduXIeet7N1xpgZMmSIli9frsWLF+vaa6/V9OnTtXbtWt/+cBozrcH1TKCePma4nmm7nj5m/HE90zo9fcxwPROcjhw3O3fu1HXXXadXXnlF0dHRLR4XTuOmNSLtmobk5RF4/1Fs375dc+fODcjoS+4BfO655/qeZ2dnN1nlq6ioSImJiYqJiQnYPmvWLL3//vuaP3+++vTpc9jzen3++ec655xz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AAAAAAAAAGGJ5CUAAAAAAACAsETyEgAAAAAAAEBYInkJAAAAAAAAICyRvAQAAAAAAAAQlkheAgAAoEVXXXWV8vPz2/w6k8mkWbNmdXxAnSjY9woAAIDOQ/ISAACgB3rxxRdlMpl8/0VHR2vw4MGaNWuWioqKQh1eh/F/j4f7b8GCBaEOFQAAAM2whjoAAAAAhM5dd92lfv36qba2Vl999ZWefvppzZkzR6tXr1ZsbKyee+45uVyuUIcZtJdffjng+UsvvaS5c+c22T5s2LBu/14BAAAiEclLAACAHuyss87S2LFjJUk/+9nPlJaWpocffljvvvuuLr30UtlsthBH2D5XXHFFwPNFixZp7ty5TbYDAAAgPDFtHAAAAD6TJ0+WJG3btk1S830gXS6XHnvsMY0aNUrR0dHKyMjQmWeeqe++++6w5/7LX/4is9msv//975Kk/Px8XXXVVU2OmzRpkiZNmuR7vmDBAplMJr3xxhv64x//qOzsbMXFxencc8/Vzp07g3+zhzj0vRYUFMhkMumvf/2rnnzySfXv31+xsbE644wztHPnThmGobvvvlt9+vRRTEyMzjvvPJWUlDQ574cffqiTTjpJcXFxSkhI0LRp07RmzZoOixsAACCSUXkJAAAAny1btkiS0tLSWjzm6quv1osvvqizzjpLP/vZz9TQ0KAvv/xSixYt8lVxHuq2227Tvffeq2eeeUY///nPg4rtnnvukclk0s0336zi4mI9+uijmjJlipYvX66YmJigztkar7zyiurr6/XrX/9aJSUlevDBB3XRRRdp8uTJWrBggW6++WZt3rxZf//73/W73/1OL7zwgu+1L7/8sqZPn66pU6fqgQceUHV1tZ5++mmdeOKJWrZsGQsEAQAAHAHJSwAAgB6srKxM+/fvV21trb7++mvdddddiomJ0Q9+8INmj58/f75efPFF/eY3v9Fjjz3m2/7b3/5WhmE0+5rf/e53euSRRzR79mxNnz496FhLSkq0bt06JSQkSJKOOeYYXXTRRXruuef0m9/8JujzHsnu3bu1adMmJSUlSZKcTqfuu+8+1dTU6LvvvpPV6r6k3rdvn1555RU9/fTTioqKUmVlpX7zm9/oZz/7mZ599lnf+aZPn64hQ4bo3nvvDdgOAACAppg2DgAA0INNmTJFGRkZys3N1SWXXKL4+Hi988476t27d7PH/9///Z9MJpNuv/32JvtMJlPAc8MwNGvWLD322GP697//3a7EpST95Cc/8SUuJenCCy9UTk6O5syZ067zHsmPf/xjX+JSksaPHy/J3U/Tm7j0bq+vr9fu3bslSXPnzlVpaakuvfRS7d+/3/efxWLR+PHjNX/+/E6NGwAAIBJQeQkAANCDPfnkkxo8eLCsVquysrI0ZMgQmc0t39/esmWLevXqpdTU1COe+6WXXlJlZaWefvppXXrppe2OddCgQQHPTSaTBg4cqIKCgnaf+3Dy8vICnnsTmbm5uc1uP3jwoCRp06ZNkhr7iB4qMTGxQ+MEAACIRCQvAQAAerBx48a12KeyvU444QQtX75cTzzxhC666KImCc9DKzW9nE6nLBZLp8QUjJZiaWm7d/q8y+WS5O57mZ2d3eQ4/6pNAAAANI8rJgAAALTagAED9PHHH6ukpOSI1ZcDBw7Ugw8+qEmTJunMM8/UvHnzAqZ9p6SkqLS0tMnrtm/frv79+zfZ7q1k9DIMQ5s3b9bo0aODezOdbMCAAZKkzMxMTZkyJcTRAAAAdE/0vAQAAECrXXDBBTIMQ3feeWeTfc0t2DN69GjNmTNH69at0znnnKOamhrfvgEDBmjRokWqr6/3bXv//fe1c+fOZr/2Sy+9pIqKCt/z//znP9q7d6/OOuus9rylTjN16lQlJibq3nvvlcPhaLJ/3759IYgKAACge6HyEgAAAK126qmn6sorr9Tjjz+uTZs26cwzz5TL5dKXX36pU089VbNmzWrymgkTJujdd9/V2WefrQsvvFD//e9/ZbPZ9LOf/Uz/+c9/dOaZZ+qiiy7Sli1b9O9//9tXsXio1NRUnXjiiZoxY4aKior06KOPauDAgfr5z3/e2W87KImJiXr66ad15ZVX6phjjtEll1yijIwM7dixQx988IFOOOEEPfHEE6EOEwAAIKxReQkAAIA2mT17th566CFt27ZNN910k+69917V1NTo+OOPb/E1kydP1ptvvqlPPvlEV155pVwul6ZOnaq//e1v2rhxo66//notXLhQ77//vvr06dPsOf74xz9q2rRpuu+++/TYY4/ptNNO07x58xQbG9tZb7XdLrvsMs2bN0+9e/fWQw89pOuuu06vv/66xowZoxkzZoQ6PAAAgLBnMpqb3wMAAACEiQULFujUU0/VW2+9pQsvvDDU4QAAAKALUXkJAAAAAAAAICyRvAQAAAAAAAAQlkheAgAAAAAAAAhL9LwEAAAAAAAAEJaovAQAAAAAAAAQlkheAgAAAAAAAAhLJC8BAAAAAAAAhCWSlwAAAAAAAADCEslLAAAAAAAAAGGJ5CUAAAAAAACAsETyEgAAAAAAAEBYInkJAAAAAAAAICyRvAQAAAAAAAAQlv4fHTWyf+ju86YAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from matplotlib import pyplot as plt\n", + "import matplotlib.dates as mdates\n", + "\n", + "plt.figure(figsize=(16,7))\n", + "ax = plt.gca()\n", + "formatter = mdates.DateFormatter(\"%D %H:%M:%S\")\n", + "ax.xaxis.set_major_formatter(formatter)\n", + "\n", + "ax.tick_params(axis='both', labelsize=10)\n", + "\n", + "two_day_trip_rolling_count.plot(ax=ax, legend=False)\n", + "plt.xlabel(\"Pickup Time\", fontsize=12)\n", + "plt.ylabel(\"Trip count in last 5 minutes\", fontsize=12)\n", + "plt.grid()\n", + "plt.show()\n" + ] + }, + { + "cell_type": "markdown", + "id": "336b78ce", + "metadata": {}, + "source": [ + "The taxi ride count reached its lowest point around 5:00 a.m., and peaked around 7:00 p.m. on a workday. Such is the rhythm of NYC." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv (3.10.17)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.17" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 9130a610fef69f100b8c2885d5d41478aa2ade18 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 16:30:00 -0700 Subject: [PATCH 111/313] chore(main): release 2.23.0 (#2122) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 17 +++++++++++++++++ bigframes/version.py | 4 ++-- third_party/bigframes_vendored/version.py | 4 ++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9911d2cb2e..394e04331b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ [1]: https://pypi.org/project/bigframes/#history +## [2.23.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.22.0...v2.23.0) (2025-09-29) + + +### Features + +* Add ai.generate_double to bigframes.bigquery package ([#2111](https://github.com/googleapis/python-bigquery-dataframes/issues/2111)) ([6b8154c](https://github.com/googleapis/python-bigquery-dataframes/commit/6b8154c578bb1a276e9cf8fe494d91f8cd6260f2)) + + +### Bug Fixes + +* Prevent invalid syntax for no-op .replace ops ([#2112](https://github.com/googleapis/python-bigquery-dataframes/issues/2112)) ([c311876](https://github.com/googleapis/python-bigquery-dataframes/commit/c311876b2adbc0b66ae5e463c6e56466c6a6a495)) + + +### Documentation + +* Add timedelta notebook sample ([#2124](https://github.com/googleapis/python-bigquery-dataframes/issues/2124)) ([d1a9888](https://github.com/googleapis/python-bigquery-dataframes/commit/d1a9888a2b47de6aca5dddc94d0c8f280344b58a)) + ## [2.22.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.21.0...v2.22.0) (2025-09-25) diff --git a/bigframes/version.py b/bigframes/version.py index 5b669176e8..80776a5511 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.22.0" +__version__ = "2.23.0" # {x-release-please-start-date} -__release_date__ = "2025-09-25" +__release_date__ = "2025-09-29" # {x-release-please-end} diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index 5b669176e8..80776a5511 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.22.0" +__version__ = "2.23.0" # {x-release-please-start-date} -__release_date__ = "2025-09-25" +__release_date__ = "2025-09-29" # {x-release-please-end} From 8035e013ced5236c91496435db9b965f5682e173 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Tue, 30 Sep 2025 16:25:04 -0700 Subject: [PATCH 112/313] refactor: support agg_ops.AllOp and AnyValueOp in sqlglot compiler (#2127) --- .../sqlglot/aggregations/unary_compiler.py | 20 +++++++++++++++++++ .../test_unary_compiler/test_all/out.sql | 12 +++++++++++ .../test_any_value/out.sql | 12 +++++++++++ .../aggregations/test_unary_compiler.py | 18 +++++++++++++++++ 4 files changed, 62 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/out.sql diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py index e8baa15bce..1e87fd1fc5 100644 --- a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -38,6 +38,17 @@ def compile( return UNARY_OP_REGISTRATION[op](op, column, window=window) +@UNARY_OP_REGISTRATION.register(agg_ops.AllOp) +def _( + op: agg_ops.AllOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + # BQ will return null for empty column, result would be false in pandas. + result = apply_window_if_present(sge.func("LOGICAL_AND", column.expr), window) + return sge.func("IFNULL", result, sge.true()) + + @UNARY_OP_REGISTRATION.register(agg_ops.ApproxQuartilesOp) def _( op: agg_ops.ApproxQuartilesOp, @@ -69,6 +80,15 @@ def _( return sge.func("APPROX_TOP_COUNT", column.expr, sge.convert(op.number)) +@UNARY_OP_REGISTRATION.register(agg_ops.AnyValueOp) +def _( + op: agg_ops.AnyValueOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return apply_window_if_present(sge.func("ANY_VALUE", column.expr), window) + + @UNARY_OP_REGISTRATION.register(agg_ops.CountOp) def _( op: agg_ops.CountOp, diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/out.sql new file mode 100644 index 0000000000..7303d758cc --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + COALESCE(LOGICAL_AND(`bfcol_0`), TRUE) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `bool_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/out.sql new file mode 100644 index 0000000000..f95b094a13 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + ANY_VALUE(`bfcol_0`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index 4abf80df19..ea7faca7fb 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -63,6 +63,15 @@ def _apply_unary_window_op( return sql +def test_all(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "bool_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.AllOp().as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + def test_approx_quartiles(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] @@ -87,6 +96,15 @@ def test_approx_top_count(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_any_value(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.AnyValueOp().as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + def test_count(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] From 3810452f16d8d6c9d3eb9075f1537177d98b4725 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Wed, 1 Oct 2025 10:22:25 -0700 Subject: [PATCH 113/313] feat: add ai.generate() to bigframes.bigquery module (#2128) * feat: add ai.generate() to bigframes.bigquery module * fix doc * fix doc test --- bigframes/bigquery/_operations/ai.py | 74 +++++++++++++++++++ .../ibis_compiler/scalar_op_registry.py | 14 ++++ .../compile/sqlglot/expressions/ai_ops.py | 7 ++ bigframes/operations/__init__.py | 8 +- bigframes/operations/ai_ops.py | 22 ++++++ tests/system/small/bigquery/test_ai.py | 18 +++++ .../test_ai_ops/test_ai_generate/out.sql | 18 +++++ .../test_ai_ops/test_ai_generate_bool/out.sql | 2 +- .../out.sql | 2 +- .../test_ai_generate_double/out.sql | 2 +- .../out.sql | 2 +- .../test_ai_ops/test_ai_generate_int/out.sql | 2 +- .../out.sql | 2 +- .../test_ai_generate_with_model_param/out.sql | 18 +++++ .../sqlglot/expressions/test_ai_ops.py | 55 ++++++++++++-- .../sql/compilers/bigquery/__init__.py | 3 + .../ibis/expr/operations/ai_ops.py | 21 +++++- 17 files changed, 256 insertions(+), 14 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_model_param/out.sql diff --git a/bigframes/bigquery/_operations/ai.py b/bigframes/bigquery/_operations/ai.py index 5c7a4d682e..3893ad12d1 100644 --- a/bigframes/bigquery/_operations/ai.py +++ b/bigframes/bigquery/_operations/ai.py @@ -35,6 +35,80 @@ ] +@log_adapter.method_logger(custom_base_name="bigquery_ai") +def generate( + prompt: PROMPT_TYPE, + *, + connection_id: str | None = None, + endpoint: str | None = None, + request_type: Literal["dedicated", "shared", "unspecified"] = "unspecified", + model_params: Mapping[Any, Any] | None = None, + # TODO(b/446974666) Add output_schema parameter +) -> series.Series: + """ + Returns the AI analysis based on the prompt, which can be any combination of text and unstructured data. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> bpd.options.display.progress_bar = None + >>> country = bpd.Series(["Japan", "Canada"]) + >>> bbq.ai.generate(("What's the capital city of ", country, " one word only")) + 0 {'result': 'Tokyo\\n', 'full_response': '{"cand... + 1 {'result': 'Ottawa\\n', 'full_response': '{"can... + dtype: struct>, status: string>[pyarrow] + + >>> bbq.ai.generate(("What's the capital city of ", country, " one word only")).struct.field("result") + 0 Tokyo\\n + 1 Ottawa\\n + Name: result, dtype: string + + Args: + prompt (Series | List[str|Series] | Tuple[str|Series, ...]): + A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series + or pandas Series. + connection_id (str, optional): + Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. + If not provided, the connection from the current session will be used. + endpoint (str, optional): + Specifies the Vertex AI endpoint to use for the model. For example `"gemini-2.5-flash"`. You can specify any + generally available or preview Gemini model. If you specify the model name, BigQuery ML automatically identifies and + uses the full endpoint of the model. If you don't specify an ENDPOINT value, BigQuery ML selects a recent stable + version of Gemini to use. + request_type (Literal["dedicated", "shared", "unspecified"]): + Specifies the type of inference request to send to the Gemini model. The request type determines what quota the request uses. + * "dedicated": function only uses Provisioned Throughput quota. The function returns the error Provisioned throughput is not + purchased or is not active if Provisioned Throughput quota isn't available. + * "shared": the function only uses dynamic shared quota (DSQ), even if you have purchased Provisioned Throughput quota. + * "unspecified": If you haven't purchased Provisioned Throughput quota, the function uses DSQ quota. + If you have purchased Provisioned Throughput quota, the function uses the Provisioned Throughput quota first. + If requests exceed the Provisioned Throughput quota, the overflow traffic uses DSQ quota. + model_params (Mapping[Any, Any]): + Provides additional parameters to the model. The MODEL_PARAMS value must conform to the generateContent request body format. + + Returns: + bigframes.series.Series: A new struct Series with the result data. The struct contains these fields: + * "result": a STRING value containing the model's response to the prompt. The result is None if the request fails or is filtered by responsible AI. + * "full_response": a JSON value containing the response from the projects.locations.endpoints.generateContent call to the model. + The generated text is in the text element. + * "status": a STRING value that contains the API response status for the corresponding row. This value is empty if the operation was successful. + """ + + prompt_context, series_list = _separate_context_and_series(prompt) + assert len(series_list) > 0 + + operator = ai_ops.AIGenerate( + prompt_context=tuple(prompt_context), + connection_id=_resolve_connection_id(series_list[0], connection_id), + endpoint=endpoint, + request_type=request_type, + model_params=json.dumps(model_params) if model_params else None, + ) + + return series_list[0]._apply_nary_op(operator, series_list[1:]) + + @log_adapter.method_logger(custom_base_name="bigquery_ai") def generate_bool( prompt: PROMPT_TYPE, diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index 635ba516e4..a0750ec73d 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -1974,6 +1974,20 @@ def struct_op_impl( return ibis_types.struct(data) +@scalar_op_compiler.register_nary_op(ops.AIGenerate, pass_op=True) +def ai_generate( + *values: ibis_types.Value, op: ops.AIGenerate +) -> ibis_types.StructValue: + + return ai_ops.AIGenerate( + _construct_prompt(values, op.prompt_context), # type: ignore + op.connection_id, # type: ignore + op.endpoint, # type: ignore + op.request_type.upper(), # type: ignore + op.model_params, # type: ignore + ).to_expr() + + @scalar_op_compiler.register_nary_op(ops.AIGenerateBool, pass_op=True) def ai_generate_bool( *values: ibis_types.Value, op: ops.AIGenerateBool diff --git a/bigframes/core/compile/sqlglot/expressions/ai_ops.py b/bigframes/core/compile/sqlglot/expressions/ai_ops.py index 0e6d079bd7..3f909ebc92 100644 --- a/bigframes/core/compile/sqlglot/expressions/ai_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/ai_ops.py @@ -26,6 +26,13 @@ register_nary_op = scalar_compiler.scalar_op_compiler.register_nary_op +@register_nary_op(ops.AIGenerate, pass_op=True) +def _(*exprs: TypedExpr, op: ops.AIGenerate) -> sge.Expression: + args = [_construct_prompt(exprs, op.prompt_context)] + _construct_named_args(op) + + return sge.func("AI.GENERATE", *args) + + @register_nary_op(ops.AIGenerateBool, pass_op=True) def _(*exprs: TypedExpr, op: ops.AIGenerateBool) -> sge.Expression: args = [_construct_prompt(exprs, op.prompt_context)] + _construct_named_args(op) diff --git a/bigframes/operations/__init__.py b/bigframes/operations/__init__.py index b14d15245a..031e42cf03 100644 --- a/bigframes/operations/__init__.py +++ b/bigframes/operations/__init__.py @@ -14,7 +14,12 @@ from __future__ import annotations -from bigframes.operations.ai_ops import AIGenerateBool, AIGenerateDouble, AIGenerateInt +from bigframes.operations.ai_ops import ( + AIGenerate, + AIGenerateBool, + AIGenerateDouble, + AIGenerateInt, +) from bigframes.operations.array_ops import ( ArrayIndexOp, ArrayReduceOp, @@ -412,6 +417,7 @@ "geo_y_op", "GeoStDistanceOp", # AI ops + "AIGenerate", "AIGenerateBool", "AIGenerateDouble", "AIGenerateInt", diff --git a/bigframes/operations/ai_ops.py b/bigframes/operations/ai_ops.py index 4404558497..5d710bf6b5 100644 --- a/bigframes/operations/ai_ops.py +++ b/bigframes/operations/ai_ops.py @@ -24,6 +24,28 @@ from bigframes.operations import base_ops +@dataclasses.dataclass(frozen=True) +class AIGenerate(base_ops.NaryOp): + name: ClassVar[str] = "ai_generate" + + prompt_context: Tuple[str | None, ...] + connection_id: str + endpoint: str | None + request_type: Literal["dedicated", "shared", "unspecified"] + model_params: str | None + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.string()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + @dataclasses.dataclass(frozen=True) class AIGenerateBool(base_ops.NaryOp): name: ClassVar[str] = "ai_generate_bool" diff --git a/tests/system/small/bigquery/test_ai.py b/tests/system/small/bigquery/test_ai.py index 7d32149726..890cd4fb2b 100644 --- a/tests/system/small/bigquery/test_ai.py +++ b/tests/system/small/bigquery/test_ai.py @@ -69,6 +69,24 @@ def test_ai_function_compile_model_params(session): ) +def test_ai_generate(session): + country = bpd.Series(["Japan", "Canada"], session=session) + prompt = ("What's the capital city of ", country, "? one word only") + + result = bbq.ai.generate(prompt, endpoint="gemini-2.5-flash") + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.string()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + def test_ai_generate_bool(session): s1 = bpd.Series(["apple", "bear"], session=session) s2 = bpd.Series(["fruit", "tree"], session=session) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate/out.sql new file mode 100644 index 0000000000..5c4ccefd7b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE( + prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + connection_id => 'bigframes-dev.us.bigframes-default-connection', + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql index 584ccd9ce1..28905d0349 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql @@ -7,7 +7,7 @@ WITH `bfcte_0` AS ( *, AI.GENERATE_BOOL( prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), - connection_id => 'test_connection_id', + connection_id => 'bigframes-dev.us.bigframes-default-connection', endpoint => 'gemini-2.5-flash', request_type => 'SHARED' ) AS `bfcol_1` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql index fca2b965bf..bf52361a52 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql @@ -7,7 +7,7 @@ WITH `bfcte_0` AS ( *, AI.GENERATE_BOOL( prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), - connection_id => 'test_connection_id', + connection_id => 'bigframes-dev.us.bigframes-default-connection', request_type => 'SHARED', model_params => JSON '{}' ) AS `bfcol_1` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql index 0baab06c3b..cbb05264e9 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql @@ -7,7 +7,7 @@ WITH `bfcte_0` AS ( *, AI.GENERATE_DOUBLE( prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), - connection_id => 'test_connection_id', + connection_id => 'bigframes-dev.us.bigframes-default-connection', endpoint => 'gemini-2.5-flash', request_type => 'SHARED' ) AS `bfcol_1` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql index 4756cbb509..a1c1a18664 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql @@ -7,7 +7,7 @@ WITH `bfcte_0` AS ( *, AI.GENERATE_DOUBLE( prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), - connection_id => 'test_connection_id', + connection_id => 'bigframes-dev.us.bigframes-default-connection', request_type => 'SHARED', model_params => JSON '{}' ) AS `bfcol_1` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql index e48b64bead..ba5febe0cd 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql @@ -7,7 +7,7 @@ WITH `bfcte_0` AS ( *, AI.GENERATE_INT( prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), - connection_id => 'test_connection_id', + connection_id => 'bigframes-dev.us.bigframes-default-connection', endpoint => 'gemini-2.5-flash', request_type => 'SHARED' ) AS `bfcol_1` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql index 6f406dea18..996906fe9c 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql @@ -7,7 +7,7 @@ WITH `bfcte_0` AS ( *, AI.GENERATE_INT( prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), - connection_id => 'test_connection_id', + connection_id => 'bigframes-dev.us.bigframes-default-connection', request_type => 'SHARED', model_params => JSON '{}' ) AS `bfcol_1` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_model_param/out.sql new file mode 100644 index 0000000000..8726910619 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_model_param/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE( + prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + connection_id => 'bigframes-dev.us.bigframes-default-connection', + request_type => 'SHARED', + model_params => JSON '{}' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py index c95889e1b2..f20b39bc74 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py @@ -24,13 +24,56 @@ pytest.importorskip("pytest_snapshot") +CONNECTION_ID = "bigframes-dev.us.bigframes-default-connection" + + +def test_ai_generate(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + + op = ops.AIGenerate( + prompt_context=(None, " is the same as ", None), + connection_id=CONNECTION_ID, + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + ) + + sql = utils._apply_unary_ops( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_with_model_param(scalar_types_df: dataframe.DataFrame, snapshot): + if version.Version(sqlglot.__version__) < version.Version("25.18.0"): + pytest.skip( + "Skip test because SQLGLot cannot compile model params to JSON at this version." + ) + + col_name = "string_col" + + op = ops.AIGenerate( + prompt_context=(None, " is the same as ", None), + connection_id=CONNECTION_ID, + endpoint=None, + request_type="shared", + model_params=json.dumps(dict()), + ) + + sql = utils._apply_unary_ops( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + def test_ai_generate_bool(scalar_types_df: dataframe.DataFrame, snapshot): col_name = "string_col" op = ops.AIGenerateBool( prompt_context=(None, " is the same as ", None), - connection_id="test_connection_id", + connection_id=CONNECTION_ID, endpoint="gemini-2.5-flash", request_type="shared", model_params=None, @@ -55,7 +98,7 @@ def test_ai_generate_bool_with_model_param( op = ops.AIGenerateBool( prompt_context=(None, " is the same as ", None), - connection_id="test_connection_id", + connection_id=CONNECTION_ID, endpoint=None, request_type="shared", model_params=json.dumps(dict()), @@ -74,7 +117,7 @@ def test_ai_generate_int(scalar_types_df: dataframe.DataFrame, snapshot): op = ops.AIGenerateInt( # The prompt does not make semantic sense but we only care about syntax correctness. prompt_context=(None, " is the same as ", None), - connection_id="test_connection_id", + connection_id=CONNECTION_ID, endpoint="gemini-2.5-flash", request_type="shared", model_params=None, @@ -100,7 +143,7 @@ def test_ai_generate_int_with_model_param( op = ops.AIGenerateInt( # The prompt does not make semantic sense but we only care about syntax correctness. prompt_context=(None, " is the same as ", None), - connection_id="test_connection_id", + connection_id=CONNECTION_ID, endpoint=None, request_type="shared", model_params=json.dumps(dict()), @@ -119,7 +162,7 @@ def test_ai_generate_double(scalar_types_df: dataframe.DataFrame, snapshot): op = ops.AIGenerateDouble( # The prompt does not make semantic sense but we only care about syntax correctness. prompt_context=(None, " is the same as ", None), - connection_id="test_connection_id", + connection_id=CONNECTION_ID, endpoint="gemini-2.5-flash", request_type="shared", model_params=None, @@ -145,7 +188,7 @@ def test_ai_generate_double_with_model_param( op = ops.AIGenerateDouble( # The prompt does not make semantic sense but we only care about syntax correctness. prompt_context=(None, " is the same as ", None), - connection_id="test_connection_id", + connection_id=CONNECTION_ID, endpoint=None, request_type="shared", model_params=json.dumps(dict()), diff --git a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py index e4122e88da..836d15118c 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py @@ -1104,6 +1104,9 @@ def visit_StringAgg(self, op, *, arg, sep, order_by, where): expr = arg return self.agg.string_agg(expr, sep, where=where) + def visit_AIGenerate(self, op, **kwargs): + return sge.func("AI.GENERATE", *self._compile_ai_args(**kwargs)) + def visit_AIGenerateBool(self, op, **kwargs): return sge.func("AI.GENERATE_BOOL", *self._compile_ai_args(**kwargs)) diff --git a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py index 708a459072..05c5e7e0af 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py @@ -13,6 +13,25 @@ from public import public +@public +class AIGenerate(Value): + """Generate content based on the prompt""" + + prompt: Value + connection_id: Value[dt.String] + endpoint: Optional[Value[dt.String]] + request_type: Value[dt.String] + model_params: Optional[Value[dt.String]] + + shape = rlz.shape_like("prompt") + + @attribute + def dtype(self) -> dt.Struct: + return dt.Struct.from_tuples( + (("result", dt.string), ("full_resposne", dt.string), ("status", dt.string)) + ) + + @public class AIGenerateBool(Value): """Generate Bool based on the prompt""" @@ -53,7 +72,7 @@ def dtype(self) -> dt.Struct: @public class AIGenerateDouble(Value): - """Generate integers based on the prompt""" + """Generate doubles based on the prompt""" prompt: Value connection_id: Value[dt.String] From 27b422f9563021cd67649b7427c71ae17bffde02 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Wed, 1 Oct 2025 10:52:19 -0700 Subject: [PATCH 114/313] refactor: support ops.mod_op for the sqlglot compiler (#2126) --- .../sqlglot/expressions/numeric_ops.py | 43 +++ .../system/small/engines/test_numeric_ops.py | 2 +- .../test_numeric_ops/test_mod_numeric/out.sql | 252 ++++++++++++++++++ .../sqlglot/expressions/test_numeric_ops.py | 16 ++ 4 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mod_numeric/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py index 1a6447ceb7..d86df93921 100644 --- a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py @@ -323,6 +323,49 @@ def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: return result +@register_binary_op(ops.mod_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + # In BigQuery returned value has the same sign as X. In pandas, the sign of y is used, so we need to flip the result if sign(x) != sign(y) + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + + # BigQuery MOD function doesn't support float types, so cast to BIGNUMERIC + if left.dtype == dtypes.FLOAT_DTYPE or right.dtype == dtypes.FLOAT_DTYPE: + left_expr = sge.Cast(this=left_expr, to="BIGNUMERIC") + right_expr = sge.Cast(this=right_expr, to="BIGNUMERIC") + + # MOD(N, 0) will error in bigquery, but needs to return null + bq_mod = sge.Mod(this=left_expr, expression=right_expr) + zero_result = ( + constants._NAN + if (left.dtype == dtypes.FLOAT_DTYPE or right.dtype == dtypes.FLOAT_DTYPE) + else constants._ZERO + ) + return sge.Case( + ifs=[ + sge.If( + this=sge.EQ(this=right_expr, expression=constants._ZERO), + true=zero_result * left_expr, + ), + sge.If( + this=sge.and_( + right_expr < constants._ZERO, + bq_mod > constants._ZERO, + ), + true=right_expr + bq_mod, + ), + sge.If( + this=sge.and_( + right_expr > constants._ZERO, + bq_mod < constants._ZERO, + ), + true=right_expr + bq_mod, + ), + ], + default=bq_mod, + ) + + @register_binary_op(ops.mul_op) def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: left_expr = _coerce_bool_to_int(left) diff --git a/tests/system/small/engines/test_numeric_ops.py b/tests/system/small/engines/test_numeric_ops.py index 7928922e41..ef0f8d9d0d 100644 --- a/tests/system/small/engines/test_numeric_ops.py +++ b/tests/system/small/engines/test_numeric_ops.py @@ -161,7 +161,7 @@ def test_engines_project_floordiv_durations( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_project_mod( scalars_array_value: array_value.ArrayValue, engine, diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mod_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mod_numeric/out.sql new file mode 100644 index 0000000000..7913b43aa6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mod_numeric/out.sql @@ -0,0 +1,252 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `float64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_0` AS `bfcol_7`, + `bfcol_1` AS `bfcol_8`, + CASE + WHEN `bfcol_0` = CAST(0 AS INT64) + THEN CAST(0 AS INT64) * `bfcol_0` + WHEN `bfcol_0` < CAST(0 AS INT64) + AND ( + MOD(`bfcol_0`, `bfcol_0`) + ) > CAST(0 AS INT64) + THEN `bfcol_0` + ( + MOD(`bfcol_0`, `bfcol_0`) + ) + WHEN `bfcol_0` > CAST(0 AS INT64) + AND ( + MOD(`bfcol_0`, `bfcol_0`) + ) < CAST(0 AS INT64) + THEN `bfcol_0` + ( + MOD(`bfcol_0`, `bfcol_0`) + ) + ELSE MOD(`bfcol_0`, `bfcol_0`) + END AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + CASE + WHEN -`bfcol_7` = CAST(0 AS INT64) + THEN CAST(0 AS INT64) * `bfcol_7` + WHEN -`bfcol_7` < CAST(0 AS INT64) + AND ( + MOD(`bfcol_7`, -`bfcol_7`) + ) > CAST(0 AS INT64) + THEN -`bfcol_7` + ( + MOD(`bfcol_7`, -`bfcol_7`) + ) + WHEN -`bfcol_7` > CAST(0 AS INT64) + AND ( + MOD(`bfcol_7`, -`bfcol_7`) + ) < CAST(0 AS INT64) + THEN -`bfcol_7` + ( + MOD(`bfcol_7`, -`bfcol_7`) + ) + ELSE MOD(`bfcol_7`, -`bfcol_7`) + END AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + CASE + WHEN 1 = CAST(0 AS INT64) + THEN CAST(0 AS INT64) * `bfcol_15` + WHEN 1 < CAST(0 AS INT64) AND ( + MOD(`bfcol_15`, 1) + ) > CAST(0 AS INT64) + THEN 1 + ( + MOD(`bfcol_15`, 1) + ) + WHEN 1 > CAST(0 AS INT64) AND ( + MOD(`bfcol_15`, 1) + ) < CAST(0 AS INT64) + THEN 1 + ( + MOD(`bfcol_15`, 1) + ) + ELSE MOD(`bfcol_15`, 1) + END AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CASE + WHEN 0 = CAST(0 AS INT64) + THEN CAST(0 AS INT64) * `bfcol_25` + WHEN 0 < CAST(0 AS INT64) AND ( + MOD(`bfcol_25`, 0) + ) > CAST(0 AS INT64) + THEN 0 + ( + MOD(`bfcol_25`, 0) + ) + WHEN 0 > CAST(0 AS INT64) AND ( + MOD(`bfcol_25`, 0) + ) < CAST(0 AS INT64) + THEN 0 + ( + MOD(`bfcol_25`, 0) + ) + ELSE MOD(`bfcol_25`, 0) + END AS `bfcol_42` + FROM `bfcte_3` +), `bfcte_5` AS ( + SELECT + *, + `bfcol_36` AS `bfcol_50`, + `bfcol_37` AS `bfcol_51`, + `bfcol_38` AS `bfcol_52`, + `bfcol_39` AS `bfcol_53`, + `bfcol_40` AS `bfcol_54`, + `bfcol_41` AS `bfcol_55`, + `bfcol_42` AS `bfcol_56`, + CASE + WHEN CAST(`bfcol_38` AS BIGNUMERIC) = CAST(0 AS INT64) + THEN CAST('NaN' AS FLOAT64) * CAST(`bfcol_38` AS BIGNUMERIC) + WHEN CAST(`bfcol_38` AS BIGNUMERIC) < CAST(0 AS INT64) + AND ( + MOD(CAST(`bfcol_38` AS BIGNUMERIC), CAST(`bfcol_38` AS BIGNUMERIC)) + ) > CAST(0 AS INT64) + THEN CAST(`bfcol_38` AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_38` AS BIGNUMERIC), CAST(`bfcol_38` AS BIGNUMERIC)) + ) + WHEN CAST(`bfcol_38` AS BIGNUMERIC) > CAST(0 AS INT64) + AND ( + MOD(CAST(`bfcol_38` AS BIGNUMERIC), CAST(`bfcol_38` AS BIGNUMERIC)) + ) < CAST(0 AS INT64) + THEN CAST(`bfcol_38` AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_38` AS BIGNUMERIC), CAST(`bfcol_38` AS BIGNUMERIC)) + ) + ELSE MOD(CAST(`bfcol_38` AS BIGNUMERIC), CAST(`bfcol_38` AS BIGNUMERIC)) + END AS `bfcol_57` + FROM `bfcte_4` +), `bfcte_6` AS ( + SELECT + *, + `bfcol_50` AS `bfcol_66`, + `bfcol_51` AS `bfcol_67`, + `bfcol_52` AS `bfcol_68`, + `bfcol_53` AS `bfcol_69`, + `bfcol_54` AS `bfcol_70`, + `bfcol_55` AS `bfcol_71`, + `bfcol_56` AS `bfcol_72`, + `bfcol_57` AS `bfcol_73`, + CASE + WHEN CAST(-`bfcol_52` AS BIGNUMERIC) = CAST(0 AS INT64) + THEN CAST('NaN' AS FLOAT64) * CAST(`bfcol_52` AS BIGNUMERIC) + WHEN CAST(-`bfcol_52` AS BIGNUMERIC) < CAST(0 AS INT64) + AND ( + MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-`bfcol_52` AS BIGNUMERIC)) + ) > CAST(0 AS INT64) + THEN CAST(-`bfcol_52` AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-`bfcol_52` AS BIGNUMERIC)) + ) + WHEN CAST(-`bfcol_52` AS BIGNUMERIC) > CAST(0 AS INT64) + AND ( + MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-`bfcol_52` AS BIGNUMERIC)) + ) < CAST(0 AS INT64) + THEN CAST(-`bfcol_52` AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-`bfcol_52` AS BIGNUMERIC)) + ) + ELSE MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-`bfcol_52` AS BIGNUMERIC)) + END AS `bfcol_74` + FROM `bfcte_5` +), `bfcte_7` AS ( + SELECT + *, + `bfcol_66` AS `bfcol_84`, + `bfcol_67` AS `bfcol_85`, + `bfcol_68` AS `bfcol_86`, + `bfcol_69` AS `bfcol_87`, + `bfcol_70` AS `bfcol_88`, + `bfcol_71` AS `bfcol_89`, + `bfcol_72` AS `bfcol_90`, + `bfcol_73` AS `bfcol_91`, + `bfcol_74` AS `bfcol_92`, + CASE + WHEN CAST(1 AS BIGNUMERIC) = CAST(0 AS INT64) + THEN CAST('NaN' AS FLOAT64) * CAST(`bfcol_68` AS BIGNUMERIC) + WHEN CAST(1 AS BIGNUMERIC) < CAST(0 AS INT64) + AND ( + MOD(CAST(`bfcol_68` AS BIGNUMERIC), CAST(1 AS BIGNUMERIC)) + ) > CAST(0 AS INT64) + THEN CAST(1 AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_68` AS BIGNUMERIC), CAST(1 AS BIGNUMERIC)) + ) + WHEN CAST(1 AS BIGNUMERIC) > CAST(0 AS INT64) + AND ( + MOD(CAST(`bfcol_68` AS BIGNUMERIC), CAST(1 AS BIGNUMERIC)) + ) < CAST(0 AS INT64) + THEN CAST(1 AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_68` AS BIGNUMERIC), CAST(1 AS BIGNUMERIC)) + ) + ELSE MOD(CAST(`bfcol_68` AS BIGNUMERIC), CAST(1 AS BIGNUMERIC)) + END AS `bfcol_93` + FROM `bfcte_6` +), `bfcte_8` AS ( + SELECT + *, + `bfcol_84` AS `bfcol_104`, + `bfcol_85` AS `bfcol_105`, + `bfcol_86` AS `bfcol_106`, + `bfcol_87` AS `bfcol_107`, + `bfcol_88` AS `bfcol_108`, + `bfcol_89` AS `bfcol_109`, + `bfcol_90` AS `bfcol_110`, + `bfcol_91` AS `bfcol_111`, + `bfcol_92` AS `bfcol_112`, + `bfcol_93` AS `bfcol_113`, + CASE + WHEN CAST(0 AS BIGNUMERIC) = CAST(0 AS INT64) + THEN CAST('NaN' AS FLOAT64) * CAST(`bfcol_86` AS BIGNUMERIC) + WHEN CAST(0 AS BIGNUMERIC) < CAST(0 AS INT64) + AND ( + MOD(CAST(`bfcol_86` AS BIGNUMERIC), CAST(0 AS BIGNUMERIC)) + ) > CAST(0 AS INT64) + THEN CAST(0 AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_86` AS BIGNUMERIC), CAST(0 AS BIGNUMERIC)) + ) + WHEN CAST(0 AS BIGNUMERIC) > CAST(0 AS INT64) + AND ( + MOD(CAST(`bfcol_86` AS BIGNUMERIC), CAST(0 AS BIGNUMERIC)) + ) < CAST(0 AS INT64) + THEN CAST(0 AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_86` AS BIGNUMERIC), CAST(0 AS BIGNUMERIC)) + ) + ELSE MOD(CAST(`bfcol_86` AS BIGNUMERIC), CAST(0 AS BIGNUMERIC)) + END AS `bfcol_114` + FROM `bfcte_7` +) +SELECT + `bfcol_104` AS `rowindex`, + `bfcol_105` AS `int64_col`, + `bfcol_106` AS `float64_col`, + `bfcol_107` AS `int_mod_int`, + `bfcol_108` AS `int_mod_int_neg`, + `bfcol_109` AS `int_mod_1`, + `bfcol_110` AS `int_mod_0`, + `bfcol_111` AS `float_mod_float`, + `bfcol_112` AS `float_mod_float_neg`, + `bfcol_113` AS `float_mod_1`, + `bfcol_114` AS `float_mod_0` +FROM `bfcte_8` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py index e0c41857e9..231d9d5bf0 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py @@ -287,6 +287,22 @@ def test_mul_numeric(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(bf_df.sql, "out.sql") +def test_mod_numeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col"]] + + bf_df["int_mod_int"] = bf_df["int64_col"] % bf_df["int64_col"] + bf_df["int_mod_int_neg"] = bf_df["int64_col"] % -bf_df["int64_col"] + bf_df["int_mod_1"] = bf_df["int64_col"] % 1 + bf_df["int_mod_0"] = bf_df["int64_col"] % 0 + + bf_df["float_mod_float"] = bf_df["float64_col"] % bf_df["float64_col"] + bf_df["float_mod_float_neg"] = bf_df["float64_col"] % -bf_df["float64_col"] + bf_df["float_mod_1"] = bf_df["float64_col"] % 1 + bf_df["float_mod_0"] = bf_df["float64_col"] % 0 + + snapshot.assert_match(bf_df.sql, "out.sql") + + def test_sub_numeric(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["int64_col", "bool_col"]] From c3c292cd6ba6513419f50d2dafbe8848bb0f8e73 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Wed, 1 Oct 2025 11:33:13 -0700 Subject: [PATCH 115/313] refactor: enable engine tests for sqlglot concat, filtering and string compilers (#2120) * refactor: enable engine tests for sqlglot concat compiler * enable engine tests for filtering and strings * fix a compatible issue where the sg.union cannot support multiple expressions in the older version of sqlglot --- bigframes/core/compile/sqlglot/sqlglot_ir.py | 14 +- tests/system/small/engines/test_concat.py | 4 +- tests/system/small/engines/test_filtering.py | 8 +- tests/system/small/engines/test_strings.py | 8 +- .../test_compile_concat/out.sql | 80 +++++----- .../test_compile_concat_filter_sorted/out.sql | 142 ++++++++++++++++++ .../compile/sqlglot/test_compile_concat.py | 33 +++- 7 files changed, 228 insertions(+), 61 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql diff --git a/bigframes/core/compile/sqlglot/sqlglot_ir.py b/bigframes/core/compile/sqlglot/sqlglot_ir.py index 9c81eda044..98dbed4cdd 100644 --- a/bigframes/core/compile/sqlglot/sqlglot_ir.py +++ b/bigframes/core/compile/sqlglot/sqlglot_ir.py @@ -175,7 +175,7 @@ def from_union( ), f"At least two select expressions must be provided, but got {selects}." existing_ctes: list[sge.CTE] = [] - union_selects: list[sge.Select] = [] + union_selects: list[sge.Expression] = [] for select in selects: assert isinstance( select, sge.Select @@ -204,10 +204,14 @@ def from_union( sge.Select().select(*selections).from_(sge.Table(this=new_cte_name)) ) - union_expr = sg.union( - *union_selects, - distinct=False, - copy=False, + union_expr = typing.cast( + sge.Select, + functools.reduce( + lambda x, y: sge.Union( + this=x, expression=y, distinct=False, copy=False + ), + union_selects, + ), ) final_select_expr = sge.Select().select(sge.Star()).from_(union_expr.subquery()) final_select_expr.set("with", sge.With(expressions=existing_ctes)) diff --git a/tests/system/small/engines/test_concat.py b/tests/system/small/engines/test_concat.py index e10570fab2..5786cfc419 100644 --- a/tests/system/small/engines/test_concat.py +++ b/tests/system/small/engines/test_concat.py @@ -24,7 +24,7 @@ REFERENCE_ENGINE = polars_executor.PolarsExecutor() -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_concat_self( scalars_array_value: array_value.ArrayValue, engine, @@ -34,7 +34,7 @@ def test_engines_concat_self( assert_equivalence_execution(result.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_concat_filtered_sorted( scalars_array_value: array_value.ArrayValue, engine, diff --git a/tests/system/small/engines/test_filtering.py b/tests/system/small/engines/test_filtering.py index 9b7cd034b4..817bb4c3f7 100644 --- a/tests/system/small/engines/test_filtering.py +++ b/tests/system/small/engines/test_filtering.py @@ -24,7 +24,7 @@ REFERENCE_ENGINE = polars_executor.PolarsExecutor() -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_filter_bool_col( scalars_array_value: array_value.ArrayValue, engine, @@ -35,7 +35,7 @@ def test_engines_filter_bool_col( assert_equivalence_execution(node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_filter_expr_cond( scalars_array_value: array_value.ArrayValue, engine, @@ -47,7 +47,7 @@ def test_engines_filter_expr_cond( assert_equivalence_execution(node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_filter_true( scalars_array_value: array_value.ArrayValue, engine, @@ -57,7 +57,7 @@ def test_engines_filter_true( assert_equivalence_execution(node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_filter_false( scalars_array_value: array_value.ArrayValue, engine, diff --git a/tests/system/small/engines/test_strings.py b/tests/system/small/engines/test_strings.py index cbab517ef0..d450474504 100644 --- a/tests/system/small/engines/test_strings.py +++ b/tests/system/small/engines/test_strings.py @@ -25,7 +25,7 @@ REFERENCE_ENGINE = polars_executor.PolarsExecutor() -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_str_contains(scalars_array_value: array_value.ArrayValue, engine): arr, _ = scalars_array_value.compute_values( [ @@ -38,7 +38,7 @@ def test_engines_str_contains(scalars_array_value: array_value.ArrayValue, engin assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_str_contains_regex( scalars_array_value: array_value.ArrayValue, engine ): @@ -53,7 +53,7 @@ def test_engines_str_contains_regex( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_str_startswith(scalars_array_value: array_value.ArrayValue, engine): arr, _ = scalars_array_value.compute_values( [ @@ -65,7 +65,7 @@ def test_engines_str_startswith(scalars_array_value: array_value.ArrayValue, eng assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_str_endswith(scalars_array_value: array_value.ArrayValue, engine): arr, _ = scalars_array_value.compute_values( [ diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat/out.sql index 62e22a6a19..faff452761 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat/out.sql @@ -1,78 +1,82 @@ WITH `bfcte_1` AS ( SELECT - * - FROM UNNEST(ARRAY>[STRUCT(0, 123456789, 0, 'Hello, World!', 0), STRUCT(1, -987654321, 1, 'こんにちは', 1), STRUCT(2, 314159, 2, ' ¡Hola Mundo! ', 2), STRUCT(3, CAST(NULL AS INT64), 3, CAST(NULL AS STRING), 3), STRUCT(4, -234892, 4, 'Hello, World!', 4), STRUCT(5, 55555, 5, 'Güten Tag!', 5), STRUCT(6, 101202303, 6, 'capitalize, This ', 6), STRUCT(7, -214748367, 7, ' سلام', 7), STRUCT(8, 2, 8, 'T', 8)]) + `int64_col` AS `bfcol_0`, + `rowindex` AS `bfcol_1`, + `string_col` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_3` AS ( SELECT *, - `bfcol_4` AS `bfcol_10` + ROW_NUMBER() OVER () AS `bfcol_7` FROM `bfcte_1` ), `bfcte_5` AS ( SELECT *, - 0 AS `bfcol_16` + 0 AS `bfcol_8` FROM `bfcte_3` ), `bfcte_6` AS ( SELECT - `bfcol_0` AS `bfcol_17`, - `bfcol_2` AS `bfcol_18`, - `bfcol_1` AS `bfcol_19`, - `bfcol_3` AS `bfcol_20`, - `bfcol_16` AS `bfcol_21`, - `bfcol_10` AS `bfcol_22` + `bfcol_1` AS `bfcol_9`, + `bfcol_1` AS `bfcol_10`, + `bfcol_0` AS `bfcol_11`, + `bfcol_2` AS `bfcol_12`, + `bfcol_8` AS `bfcol_13`, + `bfcol_7` AS `bfcol_14` FROM `bfcte_5` ), `bfcte_0` AS ( SELECT - * - FROM UNNEST(ARRAY>[STRUCT(0, 123456789, 0, 'Hello, World!', 0), STRUCT(1, -987654321, 1, 'こんにちは', 1), STRUCT(2, 314159, 2, ' ¡Hola Mundo! ', 2), STRUCT(3, CAST(NULL AS INT64), 3, CAST(NULL AS STRING), 3), STRUCT(4, -234892, 4, 'Hello, World!', 4), STRUCT(5, 55555, 5, 'Güten Tag!', 5), STRUCT(6, 101202303, 6, 'capitalize, This ', 6), STRUCT(7, -214748367, 7, ' سلام', 7), STRUCT(8, 2, 8, 'T', 8)]) + `int64_col` AS `bfcol_15`, + `rowindex` AS `bfcol_16`, + `string_col` AS `bfcol_17` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_2` AS ( SELECT *, - `bfcol_27` AS `bfcol_33` + ROW_NUMBER() OVER () AS `bfcol_22` FROM `bfcte_0` ), `bfcte_4` AS ( SELECT *, - 1 AS `bfcol_39` + 1 AS `bfcol_23` FROM `bfcte_2` ), `bfcte_7` AS ( SELECT - `bfcol_23` AS `bfcol_40`, - `bfcol_25` AS `bfcol_41`, - `bfcol_24` AS `bfcol_42`, - `bfcol_26` AS `bfcol_43`, - `bfcol_39` AS `bfcol_44`, - `bfcol_33` AS `bfcol_45` + `bfcol_16` AS `bfcol_24`, + `bfcol_16` AS `bfcol_25`, + `bfcol_15` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_23` AS `bfcol_28`, + `bfcol_22` AS `bfcol_29` FROM `bfcte_4` ), `bfcte_8` AS ( SELECT * FROM ( SELECT - `bfcol_17` AS `bfcol_46`, - `bfcol_18` AS `bfcol_47`, - `bfcol_19` AS `bfcol_48`, - `bfcol_20` AS `bfcol_49`, - `bfcol_21` AS `bfcol_50`, - `bfcol_22` AS `bfcol_51` + `bfcol_9` AS `bfcol_30`, + `bfcol_10` AS `bfcol_31`, + `bfcol_11` AS `bfcol_32`, + `bfcol_12` AS `bfcol_33`, + `bfcol_13` AS `bfcol_34`, + `bfcol_14` AS `bfcol_35` FROM `bfcte_6` UNION ALL SELECT - `bfcol_40` AS `bfcol_46`, - `bfcol_41` AS `bfcol_47`, - `bfcol_42` AS `bfcol_48`, - `bfcol_43` AS `bfcol_49`, - `bfcol_44` AS `bfcol_50`, - `bfcol_45` AS `bfcol_51` + `bfcol_24` AS `bfcol_30`, + `bfcol_25` AS `bfcol_31`, + `bfcol_26` AS `bfcol_32`, + `bfcol_27` AS `bfcol_33`, + `bfcol_28` AS `bfcol_34`, + `bfcol_29` AS `bfcol_35` FROM `bfcte_7` ) ) SELECT - `bfcol_46` AS `rowindex`, - `bfcol_47` AS `rowindex_1`, - `bfcol_48` AS `int64_col`, - `bfcol_49` AS `string_col` + `bfcol_30` AS `rowindex`, + `bfcol_31` AS `rowindex_1`, + `bfcol_32` AS `int64_col`, + `bfcol_33` AS `string_col` FROM `bfcte_8` ORDER BY - `bfcol_50` ASC NULLS LAST, - `bfcol_51` ASC NULLS LAST \ No newline at end of file + `bfcol_34` ASC NULLS LAST, + `bfcol_35` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql new file mode 100644 index 0000000000..5043435688 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql @@ -0,0 +1,142 @@ +WITH `bfcte_3` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `float64_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_7` AS ( + SELECT + *, + ROW_NUMBER() OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST) AS `bfcol_4` + FROM `bfcte_3` +), `bfcte_11` AS ( + SELECT + *, + 0 AS `bfcol_5` + FROM `bfcte_7` +), `bfcte_14` AS ( + SELECT + `bfcol_1` AS `bfcol_6`, + `bfcol_0` AS `bfcol_7`, + `bfcol_5` AS `bfcol_8`, + `bfcol_4` AS `bfcol_9` + FROM `bfcte_11` +), `bfcte_2` AS ( + SELECT + `bool_col` AS `bfcol_10`, + `int64_too` AS `bfcol_11`, + `float64_col` AS `bfcol_12` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_6` AS ( + SELECT + * + FROM `bfcte_2` + WHERE + `bfcol_10` +), `bfcte_10` AS ( + SELECT + *, + ROW_NUMBER() OVER () AS `bfcol_15` + FROM `bfcte_6` +), `bfcte_13` AS ( + SELECT + *, + 1 AS `bfcol_16` + FROM `bfcte_10` +), `bfcte_15` AS ( + SELECT + `bfcol_12` AS `bfcol_17`, + `bfcol_11` AS `bfcol_18`, + `bfcol_16` AS `bfcol_19`, + `bfcol_15` AS `bfcol_20` + FROM `bfcte_13` +), `bfcte_1` AS ( + SELECT + `int64_col` AS `bfcol_21`, + `float64_col` AS `bfcol_22` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_5` AS ( + SELECT + *, + ROW_NUMBER() OVER (ORDER BY `bfcol_21` IS NULL ASC NULLS LAST, `bfcol_21` ASC NULLS LAST) AS `bfcol_25` + FROM `bfcte_1` +), `bfcte_9` AS ( + SELECT + *, + 2 AS `bfcol_26` + FROM `bfcte_5` +), `bfcte_16` AS ( + SELECT + `bfcol_22` AS `bfcol_27`, + `bfcol_21` AS `bfcol_28`, + `bfcol_26` AS `bfcol_29`, + `bfcol_25` AS `bfcol_30` + FROM `bfcte_9` +), `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_31`, + `int64_too` AS `bfcol_32`, + `float64_col` AS `bfcol_33` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_4` AS ( + SELECT + * + FROM `bfcte_0` + WHERE + `bfcol_31` +), `bfcte_8` AS ( + SELECT + *, + ROW_NUMBER() OVER () AS `bfcol_36` + FROM `bfcte_4` +), `bfcte_12` AS ( + SELECT + *, + 3 AS `bfcol_37` + FROM `bfcte_8` +), `bfcte_17` AS ( + SELECT + `bfcol_33` AS `bfcol_38`, + `bfcol_32` AS `bfcol_39`, + `bfcol_37` AS `bfcol_40`, + `bfcol_36` AS `bfcol_41` + FROM `bfcte_12` +), `bfcte_18` AS ( + SELECT + * + FROM ( + SELECT + `bfcol_6` AS `bfcol_42`, + `bfcol_7` AS `bfcol_43`, + `bfcol_8` AS `bfcol_44`, + `bfcol_9` AS `bfcol_45` + FROM `bfcte_14` + UNION ALL + SELECT + `bfcol_17` AS `bfcol_42`, + `bfcol_18` AS `bfcol_43`, + `bfcol_19` AS `bfcol_44`, + `bfcol_20` AS `bfcol_45` + FROM `bfcte_15` + UNION ALL + SELECT + `bfcol_27` AS `bfcol_42`, + `bfcol_28` AS `bfcol_43`, + `bfcol_29` AS `bfcol_44`, + `bfcol_30` AS `bfcol_45` + FROM `bfcte_16` + UNION ALL + SELECT + `bfcol_38` AS `bfcol_42`, + `bfcol_39` AS `bfcol_43`, + `bfcol_40` AS `bfcol_44`, + `bfcol_41` AS `bfcol_45` + FROM `bfcte_17` + ) +) +SELECT + `bfcol_42` AS `float64_col`, + `bfcol_43` AS `int64_col` +FROM `bfcte_18` +ORDER BY + `bfcol_44` ASC NULLS LAST, + `bfcol_45` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/test_compile_concat.py b/tests/unit/core/compile/sqlglot/test_compile_concat.py index 79f73d3113..c176b2e116 100644 --- a/tests/unit/core/compile/sqlglot/test_compile_concat.py +++ b/tests/unit/core/compile/sqlglot/test_compile_concat.py @@ -12,21 +12,38 @@ # See the License for the specific language governing permissions and # limitations under the License. -import pandas as pd import pytest -import bigframes +from bigframes.core import ordering import bigframes.pandas as bpd pytest.importorskip("pytest_snapshot") -def test_compile_concat( - scalar_types_pandas_df: pd.DataFrame, compiler_session: bigframes.Session, snapshot -): +def test_compile_concat(scalar_types_df: bpd.DataFrame, snapshot): # TODO: concat two same dataframes, which SQL does not get reused. - # TODO: concat dataframes from a gbq table but trigger a windows compiler. - df1 = bpd.DataFrame(scalar_types_pandas_df, session=compiler_session) - df1 = df1[["rowindex", "int64_col", "string_col"]] + df1 = scalar_types_df[["rowindex", "int64_col", "string_col"]] concat_df = bpd.concat([df1, df1]) snapshot.assert_match(concat_df.sql, "out.sql") + + +def test_compile_concat_filter_sorted(scalar_types_df: bpd.DataFrame, snapshot): + + scalars_array_value = scalar_types_df._block.expr + input_1 = scalars_array_value.select_columns(["float64_col", "int64_col"]).order_by( + [ordering.ascending_over("int64_col")] + ) + input_2 = scalars_array_value.filter_by_id("bool_col").select_columns( + ["float64_col", "int64_too"] + ) + + result = input_1.concat([input_2, input_1, input_2]) + + new_names = ["float64_col", "int64_col"] + col_ids = { + old_name: new_name for old_name, new_name in zip(result.column_ids, new_names) + } + result = result.rename_columns(col_ids).select_columns(new_names) + + sql = result.session._executor.to_sql(result, enable_cache=False) + snapshot.assert_match(sql, "out.sql") From 4607f86ebd77b916aafc37f69725b676e203b332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Thu, 2 Oct 2025 11:21:03 -0500 Subject: [PATCH 116/313] fix: remove noisy AmbiguousWindowWarning from partial ordering mode (#2129) Also fixes type errors identified when updating notebooks. --- bigframes/core/array_value.py | 28 - bigframes/dataframe.py | 79 +- bigframes/exceptions.py | 6 +- bigframes/series.py | 26 +- notebooks/data_types/timedelta.ipynb | 109 +- notebooks/dataframes/anywidget_mode.ipynb | 38 +- .../bq_dataframes_ai_forecast.ipynb | 486 +++-- .../bq_dataframes_llm_vector_search.ipynb | 1005 +++------- .../bq_dataframes_template.ipynb | 268 +-- .../kaggle/bq_dataframes_ai_forecast.ipynb | 1742 ++++++++++++++++- notebooks/visualization/tutorial.ipynb | 338 ++-- noxfile.py | 1 - tests/system/large/blob/test_function.py | 2 +- tests/system/small/test_unordered.py | 6 +- .../bigframes_vendored/pandas/core/generic.py | 3 + 15 files changed, 2659 insertions(+), 1478 deletions(-) diff --git a/bigframes/core/array_value.py b/bigframes/core/array_value.py index 878d62bcb5..e7bf7b14fa 100644 --- a/bigframes/core/array_value.py +++ b/bigframes/core/array_value.py @@ -224,11 +224,6 @@ def reversed(self) -> ArrayValue: def slice( self, start: Optional[int], stop: Optional[int], step: Optional[int] ) -> ArrayValue: - if self.node.order_ambiguous and not (self.session._strictly_ordered): - msg = bfe.format_message( - "Window ordering may be ambiguous, this can cause unstable results." - ) - warnings.warn(msg, bfe.AmbiguousWindowWarning) return ArrayValue( nodes.SliceNode( self.node, @@ -243,17 +238,6 @@ def promote_offsets(self) -> Tuple[ArrayValue, str]: Convenience function to promote copy of column offsets to a value column. Can be used to reset index. """ col_id = self._gen_namespaced_uid() - if self.node.order_ambiguous and not (self.session._strictly_ordered): - if not self.session._allows_ambiguity: - raise ValueError( - "Generating offsets not supported in partial ordering mode" - ) - else: - msg = bfe.format_message( - "Window ordering may be ambiguous, this can cause unstable results." - ) - warnings.warn(msg, category=bfe.AmbiguousWindowWarning) - return ( ArrayValue( nodes.PromoteOffsetsNode(child=self.node, col_id=ids.ColumnId(col_id)) @@ -434,18 +418,6 @@ def project_window_expr( never_skip_nulls=False, skip_reproject_unsafe: bool = False, ): - # TODO: Support non-deterministic windowing - if window.is_row_bounded or not expression.op.order_independent: - if self.node.order_ambiguous and not self.session._strictly_ordered: - if not self.session._allows_ambiguity: - raise ValueError( - "Generating offsets not supported in partial ordering mode" - ) - else: - msg = bfe.format_message( - "Window ordering may be ambiguous, this can cause unstable results." - ) - warnings.warn(msg, category=bfe.AmbiguousWindowWarning) output_name = self._gen_namespaced_uid() return ( ArrayValue( diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index eb5ed997a1..a0933ee6a5 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -28,6 +28,7 @@ from typing import ( Any, Callable, + cast, Dict, Hashable, Iterable, @@ -94,8 +95,12 @@ import bigframes.session - SingleItemValue = Union[bigframes.series.Series, int, float, str, Callable] - MultiItemValue = Union["DataFrame", Sequence[int | float | str | Callable]] + SingleItemValue = Union[ + bigframes.series.Series, int, float, str, pandas.Timedelta, Callable + ] + MultiItemValue = Union[ + "DataFrame", Sequence[int | float | str | pandas.Timedelta | Callable] + ] LevelType = typing.Hashable LevelsType = typing.Union[LevelType, typing.Sequence[LevelType]] @@ -581,11 +586,51 @@ def select_dtypes(self, include=None, exclude=None) -> DataFrame: def _set_internal_query_job(self, query_job: Optional[bigquery.QueryJob]): self._query_job = query_job + @overload + def __getitem__( + self, + key: bigframes.series.Series, + ) -> DataFrame: + ... + + @overload + def __getitem__( + self, + key: slice, + ) -> DataFrame: + ... + + @overload + def __getitem__( + self, + key: List[str], + ) -> DataFrame: + ... + + @overload + def __getitem__( + self, + key: List[blocks.Label], + ) -> DataFrame: + ... + + @overload + def __getitem__(self, key: pandas.Index) -> DataFrame: + ... + + @overload + def __getitem__( + self, + key: blocks.Label, + ) -> bigframes.series.Series: + ... + def __getitem__( self, key: Union[ blocks.Label, - Sequence[blocks.Label], + List[str], + List[blocks.Label], # Index of column labels can be treated the same as a sequence of column labels. pandas.Index, bigframes.series.Series, @@ -601,24 +646,20 @@ def __getitem__( if isinstance(key, slice): return self.iloc[key] - if isinstance(key, typing.Hashable): + # TODO(tswast): Fix this pylance warning: Class overlaps "Hashable" + # unsafely and could produce a match at runtime + if isinstance(key, blocks.Label): return self._getitem_label(key) - # Select a subset of columns or re-order columns. - # In Ibis after you apply a projection, any column objects from the - # table before the projection can't be combined with column objects - # from the table after the projection. This is because the table after - # a projection is considered a totally separate table expression. - # - # This is unexpected behavior for a pandas user, who expects their old - # Series objects to still work with the new / mutated DataFrame. We - # avoid applying a projection in Ibis until it's absolutely necessary - # to provide pandas-like semantics. - # TODO(swast): Do we need to apply implicit join when doing a - # projection? - # Select a number of columns as DF. - key = key if utils.is_list_like(key) else [key] # type:ignore + if utils.is_list_like(key): + return self._getitem_columns(key) + else: + # TODO(tswast): What case is this supposed to be handling? + return self._getitem_columns([cast(Hashable, key)]) + __getitem__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__getitem__) + + def _getitem_columns(self, key: Sequence[blocks.Label]) -> DataFrame: selected_ids: Tuple[str, ...] = () for label in key: col_ids = self._block.label_to_col_id[label] @@ -626,8 +667,6 @@ def __getitem__( return DataFrame(self._block.select_columns(selected_ids)) - __getitem__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__getitem__) - def _getitem_label(self, key: blocks.Label): col_ids = self._block.cols_matching_label(key) if len(col_ids) == 0: diff --git a/bigframes/exceptions.py b/bigframes/exceptions.py index 8236a1a2f6..743aebe6c7 100644 --- a/bigframes/exceptions.py +++ b/bigframes/exceptions.py @@ -84,7 +84,11 @@ class TimeTravelCacheWarning(Warning): class AmbiguousWindowWarning(Warning): - """A query may produce nondeterministic results as the window may be ambiguously ordered.""" + """A query may produce nondeterministic results as the window may be ambiguously ordered. + + Deprecated. Kept for backwards compatibility for code that filters warnings + from this category. + """ class UnknownDataTypeWarning(Warning): diff --git a/bigframes/series.py b/bigframes/series.py index 87387a4333..62ddcece21 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -1039,7 +1039,7 @@ def nsmallest(self, n: int = 5, keep: str = "first") -> Series: block_ops.nsmallest(self._block, n, [self._value_column], keep=keep) ) - def isin(self, values) -> "Series" | None: + def isin(self, values) -> "Series": if isinstance(values, Series): return Series(self._block.isin(values._block)) if isinstance(values, indexes.Index): @@ -1086,20 +1086,20 @@ def __xor__(self, other: bool | int | Series) -> Series: __rxor__ = __xor__ - def __add__(self, other: float | int | Series) -> Series: + def __add__(self, other: float | int | pandas.Timedelta | Series) -> Series: return self.add(other) __add__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__add__) - def __radd__(self, other: float | int | Series) -> Series: + def __radd__(self, other: float | int | pandas.Timedelta | Series) -> Series: return self.radd(other) __radd__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__radd__) - def add(self, other: float | int | Series) -> Series: + def add(self, other: float | int | pandas.Timedelta | Series) -> Series: return self._apply_binary_op(other, ops.add_op) - def radd(self, other: float | int | Series) -> Series: + def radd(self, other: float | int | pandas.Timedelta | Series) -> Series: return self._apply_binary_op(other, ops.add_op, reverse=True) def __sub__(self, other: float | int | Series) -> Series: @@ -1140,20 +1140,20 @@ def rmul(self, other: float | int | Series) -> Series: multiply = mul multiply.__doc__ = inspect.getdoc(vendored_pandas_series.Series.mul) - def __truediv__(self, other: float | int | Series) -> Series: + def __truediv__(self, other: float | int | pandas.Timedelta | Series) -> Series: return self.truediv(other) __truediv__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__truediv__) - def __rtruediv__(self, other: float | int | Series) -> Series: + def __rtruediv__(self, other: float | int | pandas.Timedelta | Series) -> Series: return self.rtruediv(other) __rtruediv__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__rtruediv__) - def truediv(self, other: float | int | Series) -> Series: + def truediv(self, other: float | int | pandas.Timedelta | Series) -> Series: return self._apply_binary_op(other, ops.div_op) - def rtruediv(self, other: float | int | Series) -> Series: + def rtruediv(self, other: float | int | pandas.Timedelta | Series) -> Series: return self._apply_binary_op(other, ops.div_op, reverse=True) truediv.__doc__ = inspect.getdoc(vendored_pandas_series.Series.truediv) @@ -1162,20 +1162,20 @@ def rtruediv(self, other: float | int | Series) -> Series: rdiv = rtruediv rdiv.__doc__ = inspect.getdoc(vendored_pandas_series.Series.rtruediv) - def __floordiv__(self, other: float | int | Series) -> Series: + def __floordiv__(self, other: float | int | pandas.Timedelta | Series) -> Series: return self.floordiv(other) __floordiv__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__floordiv__) - def __rfloordiv__(self, other: float | int | Series) -> Series: + def __rfloordiv__(self, other: float | int | pandas.Timedelta | Series) -> Series: return self.rfloordiv(other) __rfloordiv__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__rfloordiv__) - def floordiv(self, other: float | int | Series) -> Series: + def floordiv(self, other: float | int | pandas.Timedelta | Series) -> Series: return self._apply_binary_op(other, ops.floordiv_op) - def rfloordiv(self, other: float | int | Series) -> Series: + def rfloordiv(self, other: float | int | pandas.Timedelta | Series) -> Series: return self._apply_binary_op(other, ops.floordiv_op, reverse=True) def __pow__(self, other: float | int | Series) -> Series: diff --git a/notebooks/data_types/timedelta.ipynb b/notebooks/data_types/timedelta.ipynb index dce5b06d58..d65c812d83 100644 --- a/notebooks/data_types/timedelta.ipynb +++ b/notebooks/data_types/timedelta.ipynb @@ -74,8 +74,6 @@ "metadata": {}, "outputs": [], "source": [ - "import bigframes.exceptions\n", - "import warnings\n", "import bigframes.pandas as bpd\n", "\n", "PROJECT = \"bigframes-dev\" # replace this with your project\n", @@ -85,10 +83,7 @@ "bpd.options.bigquery.location = LOCATION\n", "bpd.options.display.progress_bar = None\n", "\n", - "bpd.options.bigquery.ordering_mode = \"partial\"\n", - "\n", - "warnings.filterwarnings(\"ignore\", category=bigframes.exceptions.AmbiguousWindowWarning)\n", - "\n" + "bpd.options.bigquery.ordering_mode = \"partial\"" ] }, { @@ -159,14 +154,14 @@ " \n", " \n", " 0\n", - " 2\n", - " 2021-08-03 10:47:38+00:00\n", - " 2021-08-03 10:48:28+00:00\n", " 1\n", - " 0E-9\n", + " 2021-06-09 07:44:46+00:00\n", + " 2021-06-09 07:45:24+00:00\n", + " 1\n", + " 2.200000000\n", " 1.0\n", " N\n", - " 1\n", + " 4\n", " 0E-9\n", " 0E-9\n", " 0E-9\n", @@ -175,20 +170,20 @@ " 0E-9\n", " 0E-9\n", " 0E-9\n", - " 193\n", - " 193\n", + " 263\n", + " 263\n", " 2021\n", - " 8\n", + " 6\n", " \n", " \n", " 1\n", - " 1\n", - " 2021-08-03 13:03:25+00:00\n", - " 2021-08-03 13:03:25+00:00\n", - " 1\n", - " 0E-9\n", - " 5.0\n", - " Y\n", + " 2\n", + " 2021-06-07 11:59:46+00:00\n", + " 2021-06-07 12:00:00+00:00\n", + " 2\n", + " 0.010000000\n", + " 3.0\n", + " N\n", " 2\n", " 0E-9\n", " 0E-9\n", @@ -198,16 +193,16 @@ " 0E-9\n", " 0E-9\n", " 0E-9\n", - " 265\n", - " 264\n", + " 263\n", + " 263\n", " 2021\n", - " 8\n", + " 6\n", " \n", " \n", " 2\n", " 2\n", - " 2021-08-30 15:47:27+00:00\n", - " 2021-08-30 15:47:46+00:00\n", + " 2021-06-23 15:03:58+00:00\n", + " 2021-06-23 15:04:34+00:00\n", " 1\n", " 0E-9\n", " 1.0\n", @@ -224,18 +219,18 @@ " 193\n", " 193\n", " 2021\n", - " 8\n", + " 6\n", " \n", " \n", " 3\n", - " 2\n", - " 2021-08-18 16:27:00+00:00\n", - " 2021-08-19 15:28:35+00:00\n", " 1\n", - " 0E-9\n", + " 2021-06-12 14:26:55+00:00\n", + " 2021-06-12 14:27:08+00:00\n", + " 0\n", + " 1.000000000\n", " 1.0\n", " N\n", - " 1\n", + " 3\n", " 0E-9\n", " 0E-9\n", " 0E-9\n", @@ -244,16 +239,16 @@ " 0E-9\n", " 0E-9\n", " 0E-9\n", - " 193\n", - " 193\n", + " 143\n", + " 143\n", " 2021\n", - " 8\n", + " 6\n", " \n", " \n", " 4\n", " 2\n", - " 2021-08-14 10:13:10+00:00\n", - " 2021-08-14 10:13:36+00:00\n", + " 2021-06-15 08:39:01+00:00\n", + " 2021-06-15 08:40:36+00:00\n", " 1\n", " 0E-9\n", " 1.0\n", @@ -270,7 +265,7 @@ " 193\n", " 193\n", " 2021\n", - " 8\n", + " 6\n", " \n", " \n", "\n", @@ -278,17 +273,17 @@ ], "text/plain": [ " vendor_id pickup_datetime dropoff_datetime \\\n", - "0 2 2021-08-03 10:47:38+00:00 2021-08-03 10:48:28+00:00 \n", - "1 1 2021-08-03 13:03:25+00:00 2021-08-03 13:03:25+00:00 \n", - "2 2 2021-08-30 15:47:27+00:00 2021-08-30 15:47:46+00:00 \n", - "3 2 2021-08-18 16:27:00+00:00 2021-08-19 15:28:35+00:00 \n", - "4 2 2021-08-14 10:13:10+00:00 2021-08-14 10:13:36+00:00 \n", + "0 1 2021-06-09 07:44:46+00:00 2021-06-09 07:45:24+00:00 \n", + "1 2 2021-06-07 11:59:46+00:00 2021-06-07 12:00:00+00:00 \n", + "2 2 2021-06-23 15:03:58+00:00 2021-06-23 15:04:34+00:00 \n", + "3 1 2021-06-12 14:26:55+00:00 2021-06-12 14:27:08+00:00 \n", + "4 2 2021-06-15 08:39:01+00:00 2021-06-15 08:40:36+00:00 \n", "\n", " passenger_count trip_distance rate_code store_and_fwd_flag payment_type \\\n", - "0 1 0E-9 1.0 N 1 \n", - "1 1 0E-9 5.0 Y 2 \n", + "0 1 2.200000000 1.0 N 4 \n", + "1 2 0.010000000 3.0 N 2 \n", "2 1 0E-9 1.0 N 1 \n", - "3 1 0E-9 1.0 N 1 \n", + "3 0 1.000000000 1.0 N 3 \n", "4 1 0E-9 1.0 N 1 \n", "\n", " fare_amount extra mta_tax tip_amount tolls_amount imp_surcharge \\\n", @@ -299,18 +294,18 @@ "4 0E-9 0E-9 0E-9 0E-9 0E-9 0E-9 \n", "\n", " airport_fee total_amount pickup_location_id dropoff_location_id \\\n", - "0 0E-9 0E-9 193 193 \n", - "1 0E-9 0E-9 265 264 \n", + "0 0E-9 0E-9 263 263 \n", + "1 0E-9 0E-9 263 263 \n", "2 0E-9 0E-9 193 193 \n", - "3 0E-9 0E-9 193 193 \n", + "3 0E-9 0E-9 143 143 \n", "4 0E-9 0E-9 193 193 \n", "\n", " data_file_year data_file_month \n", - "0 2021 8 \n", - "1 2021 8 \n", - "2 2021 8 \n", - "3 2021 8 \n", - "4 2021 8 " + "0 2021 6 \n", + "1 2021 6 \n", + "2 2021 6 \n", + "3 2021 6 \n", + "4 2021 6 " ] }, "execution_count": 3, @@ -516,7 +511,7 @@ "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAABS8AAAJeCAYAAABVkfCjAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjMsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvZiW1igAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs3Xd4HOW5NvB7tqv3brn3hm1MN70bCC2FhDRSSE8IpJ+QL5BwEpKQQ0gjJAdySCghIRCKKaaDbdx7t+Um2ep1e5vvj5l3dmZ2V9KuVtJKvn/XlSva3dnVyKxW7zzvUyRZlmUQERERERERERERZRnLaJ8AERERERERERERUSIMXhIREREREREREVFWYvCSiIiIiIiIiIiIshKDl0RERERERERERJSVGLwkIiIiIiIiIiKirMTgJREREREREREREWUlBi+JiIiIiIiIiIgoKzF4SURERERERERERFnJNtonkI2i0SiOHz+OgoICSJI02qdDREREREREREQ0psiyjL6+PtTW1sJiST9/ksHLBI4fP476+vrRPg0iIiIiIiIiIqIx7dixY5gwYULaz2fwMoGCggIAyj9uYWHhKJ9NZoVCIbz66qu47LLLYLfbR/t0aAzge4ZSxfcMpYrvGUoV3zOUKr5nKFV8z1A6+L6hVI3390xvby/q6+u1OFu6GLxMQJSKFxYWjsvgZW5uLgoLC8flLwZlHt8zlCq+ZyhVfM9QqvieoVTxPUOp4nuG0sH3DaXqZHnPDLUlIwf2EBERERERERERUVZi8JKIiIiIiIiIiIiyEoOXRERERERERERElJUYvCQiIiIiIiIiIqKsxOAlERERERERERERZSUGL4mIiIiIiIiIiCgrMXhJREREREREREREWYnBSyIiIiIiIiIiIspKDF4SERERERERERFRVmLwkoiIiIiIiIiIiLISg5dERERERERERESUlRi8JCIiIiIiIiIioqzE4CURERERERERERFlJQYviYiIiIiIiIiIKCsxeElERERERERERERZicFLIiIiIiIiIiIiykoMXhIREREREREREVFWYvCSiIiIiIiIiIiIshKDl0RERERERERERJSVGLwkIiIiIiIiIiKirMTgJRERERERERFRFvnzOw342hObEYnKo30qRKOOwUsiIiIiIiIioixyz4rdeH7rcbyzrw2yLCPKICadxBi8JCIiIiIiIiLKEuFIVPt61YF2LLv3TXzoT2sgywxg0snJNtonQEREREREREREim5fSPv6L+8dAgA0dfsQCEfhsltH67SIRg0zL4mIiIiIiIiIskS3N5jwfk8gPMJnQpQdGLwkIiIiIiIiIsoSnZ5Qwvu9wcgInwlRdmDwkoiIiIiIiIgoS3Qly7wMMvOSTk4MXhIRERERERERZYkuT7KycWZe0smJwUsiIiIiIiIioizR5TWWjVstEgDAy8xLOkkxeElERERERERElCXMZeML6ooAMPOSTl4MXhIRERERERERZQlz2XiBywaAmZd08mLwkoiIiIiIiIgoS+gzLz+3bAryHErw0sNp43SSYvCSiIiIiIiIiChLiJ6X37liFr6/fA5ynVYAgDfAzEs6OTF4SURERERERESUJUTm5ZKJJbBaJGZe0kmPwUsiIiIiIiIioiwhel6W5DoAgJmXdNJj8JKIiIiIiIiIKAtEojJ6fErZeEmeHQCYeUknPQYviYiIiIiIiIiyQK8vhKisfF2co2ZeOtTMS04bp5MUg5dERERERERERFlA9LsscNrgsCkhm1yReRlg5iWdnBi8JCIiIiIiIiLKAr1+JbuyMMeu3ZfnZOYlndwYvCQiIiIiIiIiygKBkJJd6bTHwjUi89LLnpd0kmLwkoiIiIiIiIgoCwTCUQCA02bV7stjz0s6yTF4SURERERERESUBWLBS13mpZM9L+nkxuAlEREREREREVEWCITVsnFd8JKZl3SyY/CSiIiIiIiIiCgLBNXMS0eizEv2vKSTFIOXRERERERERERZIFHPy2J18ngwHEWfPzQq50U0mhi8JCIiIiIiIiLKAommjec5bShSA5gnevyjcl5Eo4nBSyIiIiIiIiKiLJBoYA8A1BbnAACaunwjfk5Eo43BSyIiIiIiIiKiLJCobBwA6kTwspvBSzr5ZFXwMhKJ4M4778SUKVOQk5ODadOm4Sc/+QlkWdaOkWUZP/rRj1BTU4OcnBxccskl2L9/v+F1Ojs7cfPNN6OwsBDFxcX47Gc/C7fbPdI/DhERERERERHRoCWaNg4AdcUuAMDxJMHLzUe78N8rdnMiOY1LWRW8vPfee/HHP/4Rv/vd77B7927ce++9+MUvfoHf/va32jG/+MUv8MADD+DBBx/E2rVrkZeXh8svvxx+f6zvw80334ydO3di5cqVeOGFF/DOO+/g1ltvHY0fiYiIiIiIiIhoUAIhNfPSnqRsPEnw8jev78dD7zRg5a6W4T1BolFgG+0T0Fu9ejWuvfZaXHXVVQCAyZMn44knnsC6desAKFmX999/P374wx/i2muvBQA8+uijqKqqwrPPPoubbroJu3fvxssvv4z169dj6dKlAIDf/va3WL58OX71q1+htrZ2dH44IiIiIiIiIqJ++LXMS1PZeIkSvEyWednnVzIu293BYTw7otGRVcHLs88+Gw899BD27duHmTNnYuvWrXjvvffw61//GgBw6NAhNDc345JLLtGeU1RUhDPOOANr1qzBTTfdhDVr1qC4uFgLXALAJZdcAovFgrVr1+L666+P+76BQACBQEC73dvbCwAIhUIIhULD9eOOCvHzjLefi4YP3zOUKr5nKFV8z1Cq+J6hVPE9Q6nie4bSkYn3TbdHCT7m2SXD61TmK9PGm7p8CV/fp5aLd/T5+b4dQ8b7Z02mfq6sCl5+73vfQ29vL2bPng2r1YpIJIJ77rkHN998MwCgubkZAFBVVWV4XlVVlfZYc3MzKisrDY/bbDaUlpZqx5j97Gc/w1133RV3/6uvvorc3Nwh/1zZaOXKlaN9CjTG8D1DqeJ7hlLF9wyliu8ZShXfM5QqvmcoHUN53xw8ZgFgweH9u7GiZ5d2f08QAGxo7vHh+RdXwCoZn9fRbQUgYdueA1gR3Jf296fRMV4/a7xeb0ZeJ6uCl0899RQee+wxPP7445g3bx62bNmC2267DbW1tfjUpz41bN/3+9//Pm6//Xbtdm9vL+rr63HZZZehsLBw2L7vaAiFQli5ciUuvfRS2O320T4dGgP4nqFU8T1DqeJ7hlLF9wyliu8ZShXfM5SOTLxvHmlcC3T34NzTT8Wlc2OJWdGojJ9seQ2hCHDqOReitjgH6w93YXJZLioKnPjF7ncAnx9FFbVYvnxhpn4kGmbj/bNGVDYPVVYFL7/97W/je9/7Hm666SYAwIIFC3DkyBH87Gc/w6c+9SlUV1cDAFpaWlBTU6M9r6WlBYsWLQIAVFdXo7W11fC64XAYnZ2d2vPNnE4nnE5n3P12u31cvnmA8f2z0fDge4ZSxfcMpYrvGUoV3zOUKr5nKFV8z1A6hvK+6VV7V5YWuOJeo6YoB0c7vWj1hNHY04Ob/3c9Clw2bP/x5QiElUE/Pf4w37Nj0Hj9rMnUz5RV08a9Xi8sFuMpWa1WRKPKL+GUKVNQXV2N119/XXu8t7cXa9euxVlnnQUAOOuss9Dd3Y2NGzdqx7zxxhuIRqM444wzRuCnICIiIiIiIiJKXa9P6RFYlBMf9KktdgFQ+l6+slNpiycG9fjVKeXdPg7sofEnqzIvr7nmGtxzzz2YOHEi5s2bh82bN+PXv/41PvOZzwAAJEnCbbfdhp/+9KeYMWMGpkyZgjvvvBO1tbW47rrrAABz5szBFVdcgc9//vN48MEHEQqF8NWvfhU33XQTJ40TERERERERUVaSZRk9avCyMEHwsq44F0Anmrp9cAfChsf8IWVKeZdnfA5+oZNbVgUvf/vb3+LOO+/El7/8ZbS2tqK2thZf+MIX8KMf/Ug75jvf+Q48Hg9uvfVWdHd3Y9myZXj55Zfhcrm0Yx577DF89atfxcUXXwyLxYIbb7wRDzzwwGj8SEREREREREREA/KFIghFZACJMy/r1MzLxi4f3P5Y8DIciSIcVZ7X7WXmJY0/WRW8LCgowP3334/7778/6TGSJOHuu+/G3XffnfSY0tJSPP7448NwhkREREREREREmdflVbImrRYJeQ5r3OMTy/IAAMc6vYjKsna/X+13CQCeYATBcBQOW1Z1CSQaEr6biYiIiIiIiIhG2d5mZTLzlPI8SJIU9/jkslwAwOEOj6FsXJSMC+x7SeMNg5dERERERERERKNsW2MPAGBhXVHCxyeqwcvj3T50uGMBSl/QFLz0su8ljS8MXhIRERERERERjbLtIng5IXHwsiLfiVyHFVEZaOr2aff3+Y3De7o8zLyk8YXBSyIiIiIiIiKiFPR4Q/jjWwcNQcShkGUZW9Xg5YIJxQmPkSQJk9S+l3q9fmOmZbePmZc0vjB4SURERERERESUgu8+vQ33vrwHH35wTUZer7nXj3Z3AFaLhHm1hUmPE30v9XpMwUpOHKfxhsFLIiIiIiIiIqIkdh7vwUcfeh8vbT+h3ffu/jYAyFjmpeh3ObOqAC57/KRxIVHmpTl42cWelzTOMHhJRERERERERJTEH986iDUNHfjSY5u0+ywJpoEPxbbGbgDJh/UIiTIve+MyLxm8pPGFwUsiIiIiIiIioiT8odg071AkCgDIcOwyNmm8vv/g5USWjdNJiMFLIiIiIiIiIqIkaopytK8PtLoBAFaLMXr5xLqjuPl/18NrHPw9KLIsY3uTGrysK+732PkJMjPjy8aDaGhz4/o/rMIrO5tTPyGiLMPgJRERERERERFRElFZ1r7edbwXQHzZ+Pf/vR3rDndhZVPqYZZjnT50e0NwWC2YVV3Q77GFLjvuvXGBIfMzPvMyhLtf2IXNR7vxhb9tTPl8iLINg5dERERERERERElEorrg5QkleCklqRtPJ/NyW1M3AGBOTQEctoHDNB85bSLe//7FmK0GOkXwsjTPAUAJXrb2BlI/EaIsxeAlEREREREREVEShuClmnlpTRJN0SVpDprod7lgQv/9LvWqCl3IcShTycXAnupCFwClbFwfBNX37CQaixi8JCIiIiIiIiJKwpx5KcsybJbE4ZRoGq8fmzRenNLznGqAUmReVhcpwctuXwjuQCwFtKXXn8ZZEWUPBi+JiIiIiIiIiJKI6NIpe3whHO/xJy3vjqaYeRmNytjRpGRzDjRp3Mxps6rnpAQqq9TMy2A4iqMdXu24Ez0MXtLYxuAlEREREREREVESYVNEctfxXtitiXteplo23tDugTsQhstuwfSK/JSeKzIvRdl4WZ5DO69gJJYD2szgJY1xDF4SERERERERESURTRC8TFo2nmLwcrs6rGd+bRFsyRppJuG0K5mXIlDpsltQnOuIO+54jy+1kyLKMgxeEhERERERERElITIvp1XkAQB2neiBPUnZuCfFaePpDOsRnKZzcNmtKMm1xx3HzEsa6xi8JCIiIiIiIiJKQmReLqhTAoy7TvTCbomVjcu6WvG+UOJy8mRE8HJhGsFLc99Np92K4pz4zEv2vKSxjsFLIiIiIiIiIqIkROblfDV4eazTh10nerXHfaGI9rU7lMLrRqLYeVwEL4tTPq+4zEubBcW6zMup5UqmKDMvaaxj8JKIiIiIiIiIKImomllZlu/Qsi+9wVjAstcXqxV3hyVDJmZ/DrS54Q9Fke+0YUpZXsrnJaaNC0rZeCzz8tRJJQCAE+x5SWMcg5dEREREREREREmE1IE4NosFT956Jv548xJ8/MyJ2uO9fmO6ZZ9/cI0vtx1Tsi7n1xXCYkmt3BxI3PNSn3m5dLISvGx3BxEIR0A0VjF4SURERERERESUhD8kpnlbkee04coFNfjpdQuQ61AyH3t9xuClOzDI4KU6afyUNErGAcBpNwcvjdPG59UWaQHO1t5AWt+DKBsweElERERjkjcYHvTFAREREVG6/GpPS1dcsFAJXpozLQebebl9CJPGgWRl47HMy7riHNQUuQBwaA+NbQxeEhER0ZgTjcq4+oH3sOzeNxjAJCIiomEVCMcyL/VcalZjXNn4INYmwXAUu0/0AQAW1hWndV7xA3tiZeO5DuXrai14yb6XNHYxeElERERjztpDnWho96DbG8KxTu9onw4RERGNY1rmZYJMRyC9svG9zX0IRqIozrWjvjQnrfOK73lpwYSSXADA9Mp8SJKEmiLltZl5SWOZbbRPgIiIiChVz2xu1L4OqtkQRERERMPBN0DZeG+KZePBcBTX/O49AMCCuiJIUurDegDAac4EtVsxvTIfv//YEsyqLgAALfOymcFLGsOYeUlERERjSjQq46XtzdptkQ1BRERENBxiPS/NwcIkZeOm4OXru1tw36t7tQ3X9w60aY8tqEuv3yUQn3nptFkgSRKuWliD6ZX5AIBalo3TOMDMSyIiIhpTenwhQy8pPzMviYiIaJjIsmyYNq4XKxvvP/Pys/+3Qfv6jstmIRiWtdu1xemVjAOAwxy8NJ0fAFSrZePMvKSxjJmXRERENKZ0eIKG28y8JCIiouES0G2SJi8bN2ZeenSbrPqvH3z7IPa19KHbG1vLXLuoNu1zS9Tz0kxMGz/O4CWNYcy8JCIiojHj4fcO4ZWdzYb7GLwkIiKi4RII6YOXxszGHPW2OdNSXyFyuMOjfR2KyLj2d6u0AT0fOnUCClz2tM+tPN9puO2wxgcvRc/LdncAwXDUkK3pD0Vw57M7cPGcKlwxvzrt8yAabsy8JCIiojHheLcPd7+wC2sPdRruZ/CSiIiIhos/rKwzrBYJdqu5TFvteelL3vPycLsXADC5LBfLppfDF4pgX4sbQCwrMl3TKvINtxMN/inNdcBhtUCWgdY+Y/blo2sO458bG/HFv28c0nkQDTcGL4mIiGhU7Drei3N+/gYeW3dsUMe/vKM54f3+EHteEhER0fDwBdVhPbb48EmysnG3LvPyULsSqDx1Uin+7zOn4+sXz9AeO39WxZDOzWqRUJLbf+amxSIlnTh+gqXkNEawbJyIiIhGxXsH2tDU7cM9K/bg9vkDH79i+4mE9zPzkoiIiIaLyLw0l4wDsbLx/gb2HFIzL6dW5MFqkXD7pTNx7oxyHOv04tRJpUM+v1nVBXi/obPfY6qLXDja6WXfSxqzmHlJREREo8IXVDImQxEZTxywIhxJnkHZ3OPHhiNdiV+HwUsiIiIaJskmjSv3KSGVPjXzUvSTNAYvlczLyWV52n2nTS7FDUsmZOT8lg4iAFqjZV76MvI9iUYag5dEREQ0Kryh2ML+qEfCI2uOJD1WDOlZMrEYc2sKAcSa0rNsnIiIiIaLqPBINMnbZVMCmmIiealawu02DOxRMi+nlOdhOHzpgmk4b2YFfnzN3KTHiLJxc5m4hFiPzMYu77CcH1EmMHhJREREo8Kv9pCaXJYLALj/9YM41pl44SxKxpcvqMFTXzwLv//YEtxyzmTldZh5SURERMMkFrxMlHlpvK+iQJn+3e4OwB+KoNsbRKcnCACYXJ47LOeX57Th0c+cjk+fMyXpMTWFavCyO3nZ+Af/uCbj50aUKQxeEhER0ajwqsHLGxbXoj5PRjAcxbpD8T2b2voCWHdYuf/KBTXId9pw1ULl/wEgEB5c8LKp24fv/msb9jb3ZegnICIiorGs1x/CD57ZnnD9IfRbNu4w3jejMh/FDhm+UBSv7W7BoXYPAKC60IVcx+iNHJmsZn3uOtGb9JjmXvbDpOzF4CURERGNCtGrMtdhRblLBgD0+EJxx72ysxmyDJxSX4y64hztfnERIaaADuTZzU34x4ZjeOidhqGeOhEREY0D972yF4+vPYoP/yl51mF/ZeNOq/E+l92C0yqUNc3TGxtxuEMJXg5X1uVgLZ1cCqtFwtFOb9IqF6JsxuAlERERjQpxMZBjtyJHTUbo9RuDl+5AGE+uPwoAWD6/2vCYuIgYbM9Lj9p/qkFtnE9EREQntwY1M7I/WvDSFp95+fy244bbdqsFp1Uo65J39rdj3SFl2OCU8vyhnuqQ5DttWDihCACw4UgsyzQqy6N1SkQpYfCSiIiIRoUoG89xWJGrXg/oMy+PdXpx4x9WY0dTL3LsVlxzSq3h+U4189I/yLJx0Uz/0CAuVIiIiGj8kyRpwGP663l57oxyw22H1YKqHGBRfREiURlPrFM2YKeMcuYlAG3g4YHW2CauWBsJMoOZlKUYvCQiIqJR4dNlXubajGXj6w934rrfr8Lelj5UFDjxxK1nolZXMi6eBwC7T/Rif0t8H0vzAlz0xuz2htClNs8nIiKik9fAoUvAH07e83KOGhAU7GoZublaZLQzLwFgaoVyDg1tsU3cgGnooY9DEClLMXhJREREo8Kny7zUysZ9IfxrYyNu/vNadHiCmFdbiOe+eg4W1RfHPf/cGeWoLHCipTeAD/xuFZ7Z3AgAiERl/PDZ7TjtntdxsE2XXaArLx9MmRgRERGNb4NIvOy356XVYnwBh005pq7YZbg/GzIvp1UoQ3v0ayNz9UqvLzyi50Q0WAxeEhER0agwZl4q9713oB3f+udWBCNRXDm/Gv/84lmoKcpJ+PziXAde/Pq5WDa9HL5QBN/+5zY0dfvwjSc34+/vH0W7O4DVB9q14/WlUSwdJyIiSo0syzjY5kY0On5Kiy2DKhtPnnlpNT3fblVul+c7dd8DqC/NhuClknl5uN2LcET5mcx9w829x4myBYOXRERENCpEz0uX3aJlXopF9Ncumo7ff2wJch22fl+josCJRz9zOqaW5yEclXHxfW/hhW0ntMdP9Pi1rwO67ILDDF4SERGl5N+bmnDxfW/jT+80jPapZEyi0KUsy7j/tX14abuynugv89JmTZx5WZbv0O6rK8mBM8Gwn5FWV5wDp82CYCSKxi4fAOPaCAD6GLykLMXgJREREY0Kvxq8zHVYUeZUsjgcNgt+c9Mi3HHZLFgsg+lEBVgsEmZUKdkE/lAUDpsF582sAAA09+qDl8y8JCIiSte+VqW/dIOu7His0wcfRUbpmoMduP+1/fjSY5sA9D9t3Jy5KXpelufFgpdVBcYS8tFisUha30tROh6XecmyccpS/aczEBEREQ0Tn256Z7kL+OunT0V9Wb5W1pQKZTHeAgC46wPz4LJb8M6+NjTrMy/Z85KIiChtnoAS2PKbJlSPZSW5sSBjS58fNUU5aOr2afdFo3K/08ZtFmM+mEMNhuY5Y6GWAlf2hF2mVeRh94leHGxz4+I5VdrPJrBsnLJV9vwWERER0UkjGI4irGY45KoXA+dMK4Pdbk/r9aaW52lf1xS5tPKs/srGZVmGNJhO/URERAS3XwleioF740FUjvXvPNLhRU1RjuG+Xn9I1/MyvnDVFLuEwxp/TIErvbXNcBAbxAdblU1cEbwszrWj2xtCr4/BS8pOLBsnIiKiEefT7fQnymRI1VRdtqbDZkGtOuXzeLcPsnoRoi8b94UiaOkNDPn7EhERnSzcAeVvt7lP4lgW1K0NjnZ6AQB9/ljpdJc3pE3kHkzmpV0XvJxeqaxNblhSl7kTHqJplYnLxivUAUO9fpaNU3Zi5iURERGNOJG1YbNIWnP7oZhWEcu8DEdk1BTlQJKUgGWHJ4jyfKcheAkADe1uVBdlRx8qIiKibCfKxsdT5mUwElsbNKlDbNrdQe2+Tk+w37Jxc6Klw2aBePZTXzgLB9vcOG1yaWZPegjEekkEL0UgurLQif2tbpaNU9Zi5iURERGNOJF5mZOBrEsAKNb1rLJISkC0skDJIhAXI+Lio1Rtos+hPURERIPnCYqel+MoeKnb2BQ/V4c7VpnR7Q3qysYTBS/NmZexdjSleY6sClwCwNRyJfOyyxtCpyeo9QOvVIcKcWAPZSsGL4mIiGjEedULIJcjM8FLAPjdxxbj8+dOwdnTygAAtcU5AJTScSBWNj67ugAAcKiNwUsiIqLBGo89L/VVGSKQ1+mJZV52eUO6zMv48InV1Ds7E9UkwynHYUWduj462ObWArYVBaJsnJmXlJ2y+zeLiIiIxiVxIZCbweDl1Qtr8V9XzYXFolxIiMW5mBoaUL/nLBG8ZOYlERHRoLnFtPFQ8mnjh9s9eHnHCa3fdLbTZ16KQGa7LnipZF72UzZuNQYv7QkG9mQb0fdyX0sfQhHlv5PoednHnpeUpbL/N4uIiIjGHV9QuUDIVNl4InUlSvCyscuYeTmnuhAAcKiDwUsiIqLB8mjBy+SZlxfd9xa++PdNeHVXy0id1pDoe16KTU592XiXvmzcliB4ac68HAvBS7Xv5c7jvdp9lYVq5iWnjVOWyv7fLCIiIhp3RNl4TgYzL83qdGXjsizHysZrlMzLox1ehCPJs0eIiIhIEY3K8Kjl4r5+gpdRNeHy9d1jJHiZIPNSXzbe6YlNG89xJCgbt4zBzMsKJfNSH7xk2Thlu+z/zSIiIqJxJ9MDexLRl43rMysmleXBZbcgHJW1rEwiIiJKTgzrAZTMy4HKwp/a0DgmSseNwcsIvMEwvLqenvqycWeCzEtbXPBSijsm24jg5W41eOmwWlCUYwfAgT2UvRi8JCIiohEnmv1nsuelmSgbb+r2GRry59itmFymlEyx7yUREdHAPIFYQC8qG8utBXOw8rmtx4f9vIbKUDYejqLDHTQ83ukZYNq4dWwN7AGAaZXKGkj87E67BYUuJXjZx8xLylLZ/5tFREQ0Rhxq9+Crj2/CLl0ZDiXm66f5faaIaePd3hC61BIwSVKyIqaUKwv3BgYviYiIBuQOGINaiYb26DcKAWDlGOh7GTRNG+/wGIOXJ3r82teJpo2b+2COhbLxinwnClw27bbLbkWhmnkZCEdx/2v7RuvUMq7bGxz4IBoTsv83i4iIaIz4+hOb8cK2E1j+wLt4cduJ0T6drCZKsoazbLzQZdcW5yJI6bRZIEmx4OVhBi+JiIgG5A4Y+1wmGtojppELnZ7MB478oQj+8NYB7GnOzEZxKGIsG+/0KMN6CpzK+uFop1d7PNGGq8NmMfS9HAuZl5IkaaXjgLI2Ej8vANz/2n70+kMIRaL4/ZsHsKOpZzROc8jue3UvFt29Es+PgQxgGlj2/2YRERGNEQfb3NrXX3l8k2FBnMgbe1pwxn+/hnf3tw33qWUdcdEznGXjQKzvZUObCF4q308EL1k2TkRENDCPKTCZKHhpPmZPc1/G+16+tbcVv3h5L362Yk9GXk+fLeoPRdGulo2fUl9s6GdptUhJsyr1xznGQM9LAJiqThwHlKCsxdS7c19zH+57dR9++cpe3PyXtSN9ehnx2zcOAAB+9J8do3wmlAkMXhIREWVIvm7XGsCAwcvP/HUDWnoDuOWR9cN5WllJZF66Rih4eahdCSw71YwIsWhn8JKIiGhg5qzKRBPH+/zKMUU5dtitEjo9wYwPxuvyKuXrx7q8Axw5OOaBPaLnZWWhExNLc7XHXP1kVOqDmo4xUDYOwJB5magcfndzH/5v9WEAQI9vbPfBDEezf3AUDWxs/GYRERGNAfkuc/BycIulk3FRJS56cu22AY4cGjG0R8u8VBfoYmBPU7cvYfYIERERxbj9xuBle18QP3tpt6H9ighwluU7MLu6EACwrTGzJcdi4F9rb2DIrxWNyobMy0A4qpWNl+c7tSoNAMjpZ7NVXzY+FnpeAjCVjcf/bHtO9CYMUI9FkZNwnT0ejY3fLCIiojHAnHkZHiDzUpDGRoVRRvlFz0vH8C5FkpWNl+Y5UKgGm490ZCZ7g4iIaLzyBI3By7+814A/vd2An764O3aMGrzMd9qwcEIRAGBbY3dGz0ME1NyBcFw2aKrMA4bcgbCWeVma5zAELxMF+AR92bi5/Dpbzast1L5OFNzb09w3kqczrE7GJIHxiMFLIiKiDIkvGx/cYmms7NJnkjawxzG8mZdi4nhzrzItVJSNS5KEKWrWgSgpJyIiosTMgcLtakbl2oYObbNW/K0ty3PglAnFAICtGQ5e6qslWnv9/RyZ2msBStm7GNBTlufA5HJ9X8jkazXrGAlY6tWX5uLnNyxAca4dVy2oAWAcNtSYobL8bMDMy/Hh5LtaIiIiGibmDMqBel5qTsI1lcicGM5p40CsbFxw6hbmU9WLkgb2vSQiIuqXuWy8Q50k3hcIY8dxZfK3CPxNKsvDwnol83JHUy+iGQweibJxIBYsTfu11LWIw2pBgVqNseO4EpQtz3dq6wQg8aRxYaxuQt90+kRsvvNSfP68qQCA57+6DB88dQIAoEVXlj8GY7MGDF6OD2Pzt4yIiCgL+UPGYGV/ZSr6hVRwsEHOcURcfAx38HJCsTl4Gft+2sTxNgYviYiI+mOeJK636kA7AOCo2oZlYmkuplfkI8duhTsQRkMGKxx8hszLofW9FJmXTrsFtUU56n3Kmqw0z4Epuonctn4ClLYxMmE8EUm38z6rugA/vW5+wuMyPTWeKFUMXhIREWWIPhsA6L/npXlyo/m54502sGeYp42X5zsNkz+durIvUQ7GieNERET9cweSr1PWHOwAEMu8nFiaC5vVgvl1Sl/FrccyN7RHH7xsyVDmpctuRU2xy/BYWb4DVQWx+9r7kgdKx2LZeDIuu1XLQhWicvJWSJGojJseWoPvPb1tJE6PTmIMXhIREWWIP2xc2PfX81JMsxSaun3Dck7Zyqs2/u+vDCsTLBbJcEGSqGz8cAeDl0RERP1xB0Jx94nNwfWHOxGNyrHMy7JcAMBCte9lJof2+A3By6FmXiqbzDl2K2qKjJUaZXlOw/AdbzB55qltHAUvAWXj1yzZ5PHNR7vwfkMnnlx/bLhPi05yDF4SERFlSCCubDx55mWnx3gRcHwcBi9f3HYC/1h/NOFj4oJhuDMvgdjEccBYNi4yL9vdwbhMWCIiopOZuUzYo2Ze6oe6LJpYDECZ2n24w4M+tbS8vkQEL5W+l1sbM5h5Gcxc5qVfy7y0oLYottHpsluQo65PPn7mRADAXdcmLqcGAKtlfIVVyvMdcfcFkgQv9S2SMtnblMhsfP2WERERjSLz1Mr+My+DhtvjLfMyEI7gK49vwnef3p7w4kIb2DMCwctaQ/AytvTJd9pQUaBkFxxh9iUREREA4KkNx7DkJyvx2Noj2n1i2niFLitvVlUBinLsAIBNR7sBAJUFTu1vu5g4vutEL4LhzPT3zmTZuF83PLBaF7wszokF7/5r+Vy8fNu5+MAptUlfxz6Ge14mUuiyx92XLPNS/5OfjD3caeQweElERJQh+qmVQP8lRubg5XjLvDzWGft5zD8rEPu3Ge6BPYAx89Jcpi4Cm8e7h3YBRERENF6s3NWCLm8I//XMDvz3it2IRmVtYE+ZLitvSnmelqW36WgXAGCSWjIuvi7KsSMYjmJvc19Gzs2nq3Jp6ctMz0un3WrY6CzOjQXvchxWzK4u7Pd1xlPPSwB4a19b3H3Jgpd6gQwFqIkSYfCSiIgoA2RZ1nbwRZnUzuO9SY/v8poyL7sGH7wMjYGd7aOdsUxGc/AyGpVjfaZGomy8JHHmJQCtTOxEz+D//V/Z2Yz7Xt3LyZtERDQu6f9uP/ROA57fdlzLvNT3Q6wvzdVubzrSpd0nSJKkKx3vzsi5+YPGnpdD+Vts7HkZy7wszInPPOyPfZyVjUcSlH8nGyypPzJT2bWZoh/YyDXb2De+fsuIiIhGSSgiQ6z1zppWBkBpYp5Mh1u5MBDBs8ZBZl6e6PFh8d0r8cNntw/hbIefaNoPAI+sOoydx2P9rvSDjUY68zLPaZygKRr0n+gZfPbG3c/vwm/fOIBtGezhRURElC1E8HJqhdIben+LWxe8jGVeluTateDlHjWzcqIueAnENnQzNbRHnwEYDEfR7U2/Z7VP1/NSP7An1TzKkdiIHUlv3HE+Jpfl4pFPn4YZlfkAYoFeM33AMhAeODtzpPxsxW5DGTuzQsc+Bi+JiIgyQL+YPmuqErzcdLQ76U5vn19ZbM+uUUqRkpWNhyNRbD7ape2CP/zeIbgDYfz9/cSDcEZSMBzFhsOdCTNBu3QXE6/tbsFVD7yn3dbv3o908NI8IKhWnUSeStm+yJodb6X+REREANDhVqZ4T6tQAleeYFgrG9dnXhbl2OOGu+jLxgFgQZ0SvMxc2bgxQDaU0vGAruelPgA5mBJpvf93zVxUF7rw/66Zm/a5ZJOpFfl469sX4sLZldq/i7mvu6APXmZT5uWf3mkw3O7zJ2/lRGMDg5dEREQZIBbAkgQsmVQCm0VCW18ATd0+7G/pwzf/scWQjehVj59RpVwYNPf4E5bpPLWhEdf/YTXu/M8OAIAli/oqPbzqED744Bo8supQ3GOJmrbLsowOd0CbWOq0WUbk56kpjpWCmRfWqWZehiNReNXga/MQBwUQERFlm1Akil410DNBbbvS6Qlq1SVl+uClLvNSMGdeFucqwU2RuTlUomxcbH629AbSfy0t89K4selNUiKdzNSKfLz/g4txyzlT0j6XbOWyKf82yQK6YyW7USQN0NjF4CUREVEG6PsmuexWzK1VMio3H+3G9/+9Hc9sbsLDuiCfyD6cXJYHq0VCOCqjNUH2wL0v7wEAPL5WybS0ZVHwcsNhpSx+V4LenoEE5UVLf/oaTv3pa/jSYxsBxGdBDhenLfZ9ukzlZSKweWKQWZT6iy8GL4mIaLzpUkvGJQmoVTf4WtUAoSQZJ2sX5dgNwUwAmFiaZ7gtAoPJyo5TJYJoIsNzKBPHfUmCl/mmFjMnM5e6VkvW8zJbMy/NMhU8p9HD4CUREVEGmBfAi+uLAQD/+94hbFCb2OtLpkT5Vb7ThqoCZeHfnCD7L2rKxrRKoxu8/OY/tuD6P6xCOBLFgVbl52lKEPgLRuIXuR3qBZEYZDQSJeNmIqgsiAuzlr4AwoMYhKQvO2pJoU8mERHRWCD+VpfkOpDvUoJ4YnM132EzBKicNquhbDzHbo0rI3fZlZBDJvohhiJRhNV1kRa8HMLfYhFQFWu3P9y8BDMq8/GzGxYM8UzHjxz1v1/SzMtwdmZeTqswBtFZNj72MXhJREQ0gGhUxvee3obfv3kg6TFa6ZE6zXrxxBIAwJZj3dox+1piwUuxCMxzWpGr7vAn2tU2LxatltGbnOgNhvHM5iZsPtqNbU09ONKplMEf746/cDDvvhe6bHjmy2cb+k+OZIP7124/D/dcPx/XL64z3F+cq0wUjUTlQfW46tWVHTHzkoiIxhsxrKc0z6FVSLT2KZmXeU5bXIBKn3k5sTQXkmmTVSs7TrEUOxF9OffkciU4NZSel76QsQR9+YIarLz9fMypKezvaSeVHHv/PS8DkezMvDR3YmLwcuxj8JKIiGgAaxo68OT6Y/jlK3uTBgy14KW60F88sVh7zCIppVYdniDa1Sb4IvMyx27TshL8CbISwqbVl01XrhWKjGzw8nC7V/e1B+KfornXH5e1aL64uf3SmVg8sQSXzKnU7hvJ4OX0ygLcfMYkWE1l9/rbUfWU+wsKGzIvh9Bni4iIKBvpg5eifFr87ct32XCx+ne8pkhpu1JfEtuULHDFl1trZeMZCGyJtZZFAiaUiLLxTPS8ZFgkmVQG9oxkafbqA+348zsNCfvFA9CGSYpNava8HPv4W0pERDSAg21u7WuPbte/xxvCs5ub4A2GtUW5yDCYWJqL0jyldOrqhbWYpDawF6XjIgMh12HVnmPuB+UNxi8CLbqMhlSnYQ7VoXaP9vX2ph7t60hUjstCNO++l6ul8Z8/b6p2346m+F6ZI01fhh+RZTy1/hjm/79XsOZgR8Lj9cHL5h7/iGe/EhERDScRvCzLc8QN48lz2jC7uhBv3HE+Xv3meQCAysLYULxQgkCSCAxGorIWUEpXbO1kQ7X6fVuHUAUhAnIjuZk61jgHGtijW++tPtg+IucEADf/71rcs2I3/vhW4qqosLrBX6IOjGLm5djH4CUREdEA9EE70cgeAL76xCbc9o8t+OEzO7QFtVikS5KEj5xWj8oCJ75+8QxMr1SmijeogVCvrmzclaQkp9s0XEaWZciIXRgk2wUfLoc7Yv8OO3TBSyC+dNwcvCzOURaPE0pycc70MgDAKWpf0NGkn3Yeicr4ztPb4AlG8NE/v5/weP3OvS8U0SayEhERjQcdusxLsfEo5DuV9crUinwUuOza/fd/ZBEKXTb88Ko5ca+nH4Yz1HWLvr94VaHaL3xIwUvjxjPFy9EG9iQOPOvXey/vaI7r1T5cxN7xI6sOJ3xcVC6JzEsO7Bn7OEaLiIhoALtPxDIEu70h1JcqX7+7X9lh/vfmJpw/qwKAcZH+3Stm47tXzAagTBUHgENq6bU3IHb7dWXjpszLHp8xeBmMRBEKxxaFmegflYqGtsSZlwDQ1O0FUKrdNpeN5+tKyf78yaX48zuHcOncquE50RRZJKU3UlSWke+09bvANe/ct/b6UZRjT3I0ERHR2NLpUcqwy/IcKMszDt9JNoX7usV1uM7UU1pw2iyQJCXY5A9Fke+UEYnKsFlTz6PSelQ6LFrmZVtfAJGoHNcWZlCvFzS2/KF4sdYBicuu9QMaW/sC2HS0C0snlyY8NlP0GbzJhgSF1V5AscxLlo2Pdcy8JCIi6ocsy9h9IjZop9sXTHic39T03Uw0lj/c4UE4EkVQXXjl2q1wJsm87PIav5cvGDEs2Ea6bFyfeSkCrWIAT7LMy3m1hfjo6fU4ZUKR9liuw4ZvXDIjbvL3aBEXPJGojEW6bNBEU9TNi99EGR97m/vw+NqjLCknIqIxR9/z0mW3GvpY5iUJXvZHkiQ41WGGvmAE1/zuPVx+/ztpbcD6g7G1Vlm+U9t87HCn1/dS9BoXwxYpnghgd3oSr3/NlTYrtjcP+zn16jb3k/W8NJeNM/Ny7ONvKRERUT9O9PgNGZBd3sQ7t7Gy8cTByykieNnu0UrGASDXqet5aRrY02P6Xt5gRAt6AiM/1VFfPi9coGacNnYZA31i+uQ3L5mJn92wMG76aDYRfUQjUdnQtH9tQ3zfS3PmZXNPfPDyzmd34AfPbMfqJH0ziYiIslWHWw1eqv0uK3R9L5NlXg5ErI32t/ZhR1MvDrZ58NzWppRfRz8d3GqRUKGWtac7tEes3djzMjkxTb59gODl7OoCAMDLO04M++Ztt8/YwkecQyAcweqD7QiEY5v9pXlKdQzb/Ix9DF4SERH1w5x9163LhtRnWYqBPc4kEytF5uXRTq8WALNaJDislqRl492+BMFLXcAymKTxfZ8/hECCyeVD0esPJdx1X6hmVB43/TuJ83SMgWwGkXkpy8YJ7omG9pgXvy0JMi871JK7fS19cY8RERFlM/3AHgCGoT3pZF4CsZ6SW451a/c9uuZIykEufc9LAKhSS8cH0/ey2xuMy9ITJcfJNp4JKM9X3gftfYkDxGItesmcKuQ5rDje48fWxp6Ex2aKuSe8qIp6+L3D+Nif1+L/Vh/W9bzkwJ7xIvuvKIiIiEaROfuxyxO7rc+UHKhsvKbQBYfNgnBUxn41qJVrt0KSJG3RHBhE2bg+KBkIxQcvm7p9OOfnb+Czf90w4M+WijZ10VrgtGkLWatFQl2xMkXdHLwU5zkmgpci81KWDRc27x9KlHmp/PcXZXSJLphEEPpwgkxVIiKibKYvGweA8oJY38v0My+VtYA+eLnzeC82624PhjlTUgQvE20k6h1u9+C0e17D15/YnPj1GLxMSgSvOzyBhMFmEQAucNlw0Ryll/mrO4e3dLzXtLkvgplifb37RJ+2ntPKxtnzcszL/isKIiKiUdRrWuzoA4r6NZw2sTLJAthikTCpVAn07VIHAOWqUztjmZf9l417gmH0+mI7x4myK/+9sRG9/jDeO9CesKQ5XaKMrCzfgSdvPQunTCjCb25ahOqixNM+RealcwwELy1az8sojnZ6tfuPdfrQ2OU1HCt27meo0+Obe+IzEcR/x8Md3rjHiIiIRtqu471Ydu8b+OeGY/0eF43K2jonUeblUMvGRfBSBAv/tuaIdsz7DR040Np/xYJ5o1hMHG9V1yA7j/fg9HtewxPrjhqet/pgB0IRGZuPdhlfT/S8TFI1Q8q6D1DWud4EfUr1lTYL6pRe5icyuP5MxNx/vksNuLeqG+3HdGu5EnXaODMvxz7+lhIREfXDPPFb3DaXHvlDAy+ARen4ruNq8NKhXARoPS/NZeOm4KUvGDEEU80TFmVZxrNbYj2k3t3flvRcUiWa4ZflOzG9Mh//+eoyXL2wFpVq1kOfPwxvMLYwHEtl42JA6Vt723C004sCpw0zq5Tg5NqGTsOxIvNyRqXS2ylRtod4LxzpYOYlERGNvr+814DGLh9e2Hai3+O6fSGI5U1JBsvGRaakCCB99aLpAIBnNjfh6t++i5W7WnDTQ+/jkl+/0+/r+MzBywJj2fjqAx1o7QvgFVPm325107jdHTRkD4rMS6eNmZfJ5Dps2r+32MjW06/3xLpWvx4cDub1sehH39qnvA+O6TaexfuYA3vGvuy/oiAiIhpFItPRYVX+ZIqMBHPQSvTC7K/0SAztEZmX4liRkWAe2GMuG/cGI4aei+aBPTuPK03whXf3tyc9l1R1mHpgCQVOG3LVi5KW3oAW1A2MocxL0fPykVWHAQAfXDoBF81WSp/eNw3tEf/+00XmZaLgpfqzN3b5DNPhiYiIRlIkKsMfimDlzhYA8ROjn9pwDI+vjWUpdqo9mwtdNtjVdU8mMi/rinMMtz+0dALm1ChZejuaevHVxzdpj/lDEciyjB1NPfjNa/vx65X7tL+lvqBa5RJXNq6ct9jgNQfZRPAyGIlq67poVNbWKhzY0z+Rfdnuia82ET0vHVYL8tSKokQZmpkU1/PSa8y81A9wEu0PmHk59qX36UNERDTOdHuDeHVnC65cUI0Cl127XyyEJ5bl4kCrW9vdNU/XFkGs/pq+Ty5TgpcNaoAxb4Cy8fiBPWH0+fSZl8bj/6NmXU4szcXRTi/eO9COaFTWyqKHIlY27jTcL0kSqgtdaGj34Pmtx/HIqkO4fvEEXdl49l8QiGnjYjjTJ86chKOdXjz49sG4vpci83K6mpnZ7g4gFIlqF3mhSFQL4IajMpq6fFrGLRER0UjZ19KHG/6wGlPK89CnZp2JKgoAWH2gHd/51zYAwPIF1SjOdST8Wy/6XAPpBy+n6v4OluY5UJHvxK3nTcE3/7EVgLGS5POPbsD+Frdhc7Cu2IWPnDYxPvOyKNbz8miHF39dfTju54xGZexpjpWjt7n9KMq1G74nB/b0ryzPgcYuHzYe7sKSiSWGx/SZl2LNN9zBS3NVVJdXGVRpDmoCQLFaNu4OhBGJytqGNY092Z8OQURENAK+8eQWfOfpbfjhszsM94sFkuhXKXZ3zb0QxS6vs7/gZXmu4XaOWl4jnmMuGxc9LwvV4TC+UPKy8UhUxnNbjwMAvn/lbOQ7bej0BLFTLVEfKjFB25x5CQCVas+pX6/chy5vCA+vOhTbiR9DmZcAcN7MCkytyMdcNSOkscuHqK5FgNi5n1yWB5tFgiwrAUzBZwpAH2bpOBERjYL7X9sHdyCM7U2xyc/tnljZ9MNqtQEQW+uYh/UAQHmBvmw8vSDf1Ip87evZ1QWQJAnXLarTKlL03t3fjuZeP3LsVi0786F3GhBVs0iBBD0v+wI475dvan+j9T9nY5fPUDIssvP0G8auMbBWGU1ievg9K3bHPabvcS7eH55hLtHuNlUmdXuD2mBJPYsEFOoSEjzDXM5Ow4u/pURERADe3qf0h/zPluOG+8VEw4llSuBRNAU/1mnMvBRl5P0tgM2L9Fxz2XiSaeO1armVNxgxDOzRl42vbehAS28ARTl2XDynCmdPKwMAvJOhvpf6gT1m1WrZlmCzSFr2oSi3z2b6xvKfXTYFAFCYoyx2ZRlwq4vdcCTWrL4ox45K9YJOPxjJ/N/wyDAO7TF/LyIiIiHR399gOKoF8vTtb0TQryNB8LJCl4VZ4Eov81K//plVrfSMliQJ15xSm/D439y0CJt/dCme+sKZKHDacLDNgzf2tMZPG1d7XprL4fU/p2jVI7Sr6xmx2Wi3SrCNgbVKttJvVos2QuaN3EwTwfbJ6tq805M4eGmzWuCyW7XfBfE+l2V52PtyUubxt5SIiKgf5szLXr9SdmLOvPSaFtSJVBW4DD0gxSJPBDz9umCkLMta2bgIXnZ5gtoiETBmXopBPcsX1MBhs+DcGeUAgDUHjWXP6WrXDewxqzIFL8O6TEXnGJjgeeokpQTqqxdOx/kzKwAoAWWRNSoyYPXZKwUum6FcTQiYsmeHI/Oy1x/CFfe/g9l3voynNzZm/PWJiGjsy9cFGuuKc7Q1h9iM1Af8RFCnM0F/60wM7JlSEQteTiiJVaEk64t9zcJauOxWFLjsuPnMSQCAP71zUAuKiU3f4lx70goP8XPuNgUv20yZlywZHxqtbNxq1Qb2eALD3PNSBC/VoHiXN6Rl1OrZ1Moa8bvgVt/nP3hmOxbdvRIH29zDep6UWdl/RUFERDSKxIAWkXkJKAFNc89LwdVPj0eLRdL6XgJArtOYeRnQ7VT7Q1FtQViTIEimP94fiuCl7cpkzesWKVkMSyeXAgC2HOuOm4yeDpGNUZ6gbLy/oTxjIfPyDzcvwb+/fDa+dfksw/1FavalKNUXtwHAbrVoGaf9ZV4ebs988PL2f2zR+nf9+d2GjL8+ERGNfSKQBACnTynVKifE33N96a3o55yobDzHYcV1i2px7oxyLdMxVfrS3QV1RdrXydYP+l7dt5wzGXarhPWHu7D6oDKIUJSNS5KklY6biXY3IngpvpcIXpoDoZTc1IrkvbuN08ZFz8uhZzU+teEYLvzVWzjQGh9gFJvKYk3d7Q32G7wUGcPiff7EumMIhqP4P7VHKo0N2X9FQURENIpE2XhJrkNb/HR5gzimZl6aJ2gOtAjW970UFxaJysZFybjdKmlZD+bJ1gE1C/Otva3oC4RRW+TCaWrQcmZVAfKdNrgDYexr6cNQdfSTeXnZvGrYrRKWTS9Hha43lkXCmCjFqip0xTWgB2LBSpF9K4LA4qJOZJw29ybveTkcZeOv7W7Vvp6m6yNGREQk6DdEnTYLyvKUv88d7gCC4Sg8uqEqosQ6Udk4ANx/02L87bNnDGkA4L++eBZ+ceNCnD6lNHZeujVTvtMGiwT8v2vmGp5XVejC9YvrAMRKvnMcsbVFsoCqOHZ3sxK8PGNqmXq/yLxUJ40zeDmgO69W/pu4ElTTBAzBy1iP9ugQN85f2HYCh9o9eDdB+6NuU9l4lzeINtMaGYA2TFEMmurzhw3nlaz9ztEOrxbopOyR/VcUREREI0zfS1KUaTttVm1iYXtfQOuTOK+20PDcRAs7Pf3k6Ryt56WYNh7F0xsb8ciqQ1rGXlGOQ2uA3tJr3FUWJcobj3QBAC6dW6VdWFgtEhbVFxseT1c4EtUWiol6Xs6vK8LGOy/Fo5853VBqNhYmjfdHDEoSfUbFe8FuVf6NqxNkxJovho51eRGOGEvJh0p/4Sdj6Fm1REQ0/rh1pbu5Dps2NbzDE4wbeCKCl51qtqI5eJkJSyeX4sOn1Rvu02defv3i6dhx1+W45Zwpcc+99byphtv6gKO5dY3Q4Q7CHQhrPcrPna6004kvG2dIZCBiPWTucQ7oel5aY5mXsgz4w0MrHRcbx11qluWWY9349cp96NS9f8WaultXNq7PErVZTZmXgbAWoAeApzY0Yv3hTsP3fXlHM8775Zu49NfvIJTh9RsNDX9TiYjopBcwLbD006NFtp3NKqEkV1nM723pQyQqw26VMLOqQDu2wGnTGtEnM0VXNp5nKhtv7vXjjn9uxV3P78LN/7sWAFCSa9emkuvLk5XzVhZVnR5lYSd6MApL1F6Om44OLXjZ5Q1BlgFJgvZvYFbossNikQzBzbEwabw/Wtm4uoAORZT3gtjJ769sfGJpLhw2C0IR2TAQKBP02TT6QDsREZGgL9398oXTtIBkhzuATlPwUhvY406ceTlc9MHLebVFhlJ3vemVBbh4dqV2W1/lUpmsbNwd0IYsOm0WTK9SKhXa+gJYuasFN/9FWWcx83JgFkkJAoYTZFOKNYnDZjH8W3qDqQUvtxzrxgZdILFPBC/V/4b3vrQHD7y+H0t+shLiNMQgqG5fSNtIPmVCsfYaNovy/ipQ2xb0+UM43m1s+7ThcJfptnIOzb3+uGNpdKV1VbFlyxY88cQThvteeeUVnHfeeTjjjDPwm9/8JiMnR0RENBK6vcbSELGIB6BlzdksEorVwN32RmVwS11xjpaNCQC3LJuiLZCSMWReirLxBBmKsrowK8t3aFPJzSXJInDV41MWdubAohhEs2mImZeib1RJrgPWAUrGRFkaMPaDl7lqmZG4AAzpsguA2AWTMfMyoj7Xqg15OpThvpf69gHByNAzL+96fie+/c+tQy7xIiKi7CHKwu/70Ckoz3dqbV/a3UF0eRKve2IDexIHBDNNX6Ext6awnyOBL5w/Tft6anmsZUrSzEtPUNvkddosWnn5kQ4PPv/ohtg5MHg5IBEETLROiFUoWWCxSLG+lykM7YlGZXziL2vxsT+v1TJjRb9x0UbJvJZy2S3af/tIVMabe5Xy8oUTYj1VxZq1QFc23mQKSDZ1e023Y48n629PoyOtq4rvfOc7+Mc//qHdPnToEK6//nocOnQIAHD77bfjoYceyswZEhERDbMuUwZCr67PjdhltlksKFEDlWLq9ISSXEOA7rPL4kudzKbogpcOtZzFXLJ01tQyPP2ls/Dpsyfj9ktnaQtBM5ExKkpqSnKNgVNRNn64w2vIJk2VyMQoG0Qmhj7zsr9BPmOB3WLMNAiFRdm4KfOy1w9ZjTZrAwBsVkxSs2yPZHDieCQqawt7/TkN5fUeWXUY/9zYiA1qkDsQjuCnL+zCqgPtQ3ptIiIafr5gBPe8uEvbWBW8aim4qPIQf8M7E5aNhyDLsrYeKk3QImY45Kjrm9oiF0oGWGOcNrkEd187D7/60CmGIYoLdQOA9NrdAcNE8VnVBZhemW/o9Qkw83Iw1NhlXOZlJCprAWKRDasFL0ODH9rjD0fQFwgjGIni3f1tkGVZVzYeVF/fuKYsznEk7DOvD1561N+BAt20cXM2pTlAqb/d2JX5vuWUvrSuKrZu3Yply5Zptx999FFYrVZs3rwZa9euxQc/+EE8+OCDGTtJIiKi4dTpMZdPKYv4pzc2amUv+rJxMel5QkkOrj2lDhfPrsQfb15imEadTKVuoI3ou2Pe9a8pduHUSaX48Qfm4fQppdri3kwsGMXCrijHuPAvyrFjplomNZTsy3ZtWM8ggpd546dsXAQpRVaB1vPSZux56Q0qi24g1ofUZbdgijqc6XAGh/Z0uAPQXzuYs3FTpe/nJPo+/f39o/jLe4e0kjoiIspe9768B39+9xCu+d17hvtFH0tRii2G/3V4EpeN9/rDWnuUwWxWZsLSSSW4ZE4Vbrt05oDHSpKET541GR88dYLh/rOnl+Odb1+I2aa2PR1uXeal3QKrRcLtCb4Pe14OTGReBiNRbbMWUNaf4qbYQBfvN08KmZdi7QQA7+xrgz8U1d6LXZ4QWnv9cWupXGfitXFdcSywLdbZ+bpp4+bMS3PwUv94EzMvs0pav6k9PT0oKyvTbq9YsQKXXnopysuVJriXXnopDhw4kJkzJCIiGmaJysZXbG/GHf/cqt2nlI0bg5P1pbkoyrXjfz99Gq5cUDOo7yVJsbJrMancvHCuMfWuNPeAEuehlY2LzMu8+OCpVjp+tHtQ55dIQ5uSOZho0riZ/hjHGJg03h+7Gnxt7Q3gut+vwv+8tl+5X/25ch02bTe/VS3lFg3qXfZY5uX2pp4hD00SzBPnRT/OdOmDl7uOKxNZT7DHExHRmPGeLkv+b+8f0b4Wm69a5qUY2OMOxq173P6wtpGb67AmzGgbDnlOG/7yqaX48NL6gQ/ux8SyXG2DUejwBLR+jKI9zxXzquMGLTLzcmC1xS44bRZ0e0PYqsvwFe+Z4lw7bNraSM28DKaWeSm8u78d3b5YcL3LG0y4mZqs1U15go12redlIBwXkGzs8moBWW8wbEhoYNl4dknrqqKmpga7d+8GAJw4cQIbN27EZZddpj3udrthsYztCxYiIhp/Xt7RjJv/8j5ueWSdobwqrmzcF8LaQx2G+6wWCcWmzMoJJTlpncd/vnIOfrB8Nq5SA54OqwW6mCaqi4yvay4bF430A+EIZFnWJoEnGqazZOLQ+l7Ksown1h0FMHA/KsCYrTHW+0jZ1LLxZ7c0Ycuxbmw91g0gFrwE9EN7jNNLc+xWTFaDl+sOdeLGP67GKzubh3xOYuK8CHib37upCul6Zoo2BMnaFBARUXaRZVkbaAIAP1uxW/taBI/y1H5/Yu2g9LxUniM2UfsC4WGdND4SzD0WzZmXAGCxSPjW5bMMx41UoHYsK3DZcdVCZc36xNqj2v3agCfd+jMWvBx85qVfl3nZ4Qni/YbYGrzLG8T+VnfccxIND6orztGCqMbzj/W8PN5jDEj6Q1EtQzM+sMngZTZJK8J47bXX4re//S2+/vWv47rrroPT6cT111+vPb5161ZMnTo1YydJRESUCb98ZQ9WHejAm3vb8H9rDmv3mzMQev3huHIXm9US148p3eDlKfXFuPW8adoCS5Ikw9CeWlPmpblsXAQpA+Eoev1hbSJ6orJ1MXF8a2N3WpOpOz1BtKo9Fj999uQBj9dnXjrHeOalmK5pfn/oM0pF6bjIiBQLcKfdikm6nlwA8Jd3G4Z8TmI40Cx1yn2PLzSkQTthXaaKCGTm6DJ9OcSHiCh7HenwaoEXwNhrWqxj8kxl413eoJZdNlEdLNfnD6fU3zobeUyZfp3eoBZA06+xLphZgesW1Wq3GbwcnI+dPhEA8NzW4+gzDdPRB7xFtVAqmZeBsHHN/cLWE9rX+sCmXjjBwMJnvnI2AMRVSuU7Y2Xjx7v9cc8TQcr+Sshp9KV1VfHTn/4UN9xwA/72t7+htbUVf/3rX1FVVQUA6O3txb/+9S9DJiYREdFo84cihkmF+kwFn2l3uM8f1pp8CzaLFJeNMKHEGJwaCn3peLUpeJlnKhsXfYUCoajWdD/HnrjMa2p5Hopz7QiEo9h1ojfl8zqmLuSqCp1a9kZ/xlPPSynJYHW7NfaAmHQpgorawB67BbXFOYZAZyYukLTgpdrbKyrHpsSmI6QLTmqT0nXB8r5A+q9NRETDS/QqFn97xSZUJCprf4/EZ7rY+IxEZRxSB8nVlyqbsO5ASAtojtXMS/1aTpIAWQZOqFl2Tt0aS5Ik3Hn1XO32WF+rjJRTJ5VgRmU+fKEI/rPlOIBYT0lj8HJomZcA8Pqe1gGfIzIv9WuySnWivDkAX6iWjbf2BeL63AOxwTyNarBSbBCbB1vR6ErrNzU/Px+PPfYYurq6cOjQIXzoQx8yPNbY2Iif/OQnGTtJIiI6edz1/E78/KU9GX/do51ew6CTHl2vwJCpT1KfPxS3g2+zSqjQDdtx2CyoGEQPyMGy6CJlNaaycXPmpRjM09rnx/+s3AcgftK4IEkSTh1C6fixTmVBVz/IQK1+qE9UHttZe5Yk0cvEZeMi8zJWNm61SJhQGvtvKSWLhqZABC/rS3K1Pl363lCp0mdeivI6/WkOtacmERENH9FP+bJ51QCUIT2BcMSQ9SY2Hh02i1ahcUAtwzVkXmqBqMytbUbSgx8/FRYJ+NkNC7RArciy02deArEeiED8GpASkyQJN6nZl4+vPQpZltEpsnV1az/xfjOX8fdHrJ3yBtG2RgSbxVTxRD1Lf/mhU2C3Svi22iJADOwRPdzNmrTMS2XNO0MddukJRliBkkUyss3Q09ODSER5w1ksFhQVFcFuH3jiKhERkV67O4BHVh3Gg28f1IbQZIq59LdbF5QRTd5F9mOvP6wtpASbxaKVXAFK0MpiGXowStDvUJsDkeYehOLxdncQz6q739Mq85O+tigd36z2bBwMsVg7pi7k6ksHF7zM12Vnrj7Y0c+R2U//n3e67t/XrsvSqEpSNi6yLPXZueZs3nSInpdVhS7t/ZpOOwAhlCB4qe+D2cPgJRFR1hKZlxfNrtT6NHd6YuXSFslYSl6pbsKKjH3xt10/sKcswcCTseCSuVXY/ZMr8NHTJ2qZdyIY5TQNRtRnWw7lb+jJ5obFdXDYLNh1ohfbm3q0snF9z/WcNDIvxfpjUlleXMsds+e+eg5uPmMifn7DAgDAHZcpAcobl8Sm0C+ZWIIdd12Or1w4HUCs52UyolxcBDFF5iUQq6ih0Zd28HLDhg244oorkJubi7KyMrz99tsAgPb2dlx77bV46623MnWORER0kmh3B7SvRdAsU9wBJQgjyksSZV6W5YlFfcjQCFySlIE9JQkakmeKPlBmztCzWy2Gspilk0tx2dwqnD6lFJ9bNgW/uWkR/nDzkqSvLS5O2vsCSY/R+97T23D2z99AtzeoLejqB9nfMxPZhdlCn3l5/swK7Wu77j9WlXohKDIiA7qycQCoKYy1AMhM8FL5PlVFLq1naihB36fBMgzsUc9dH9Bk8JKIKDt1uAM4qGaSLZ1UovXl7nAHtb83eU6b4e/ytArjRqdYH4SjMo6rJbNjtWwcAJxqhmWtOojo1V0tAOIzL/WYeTl4JXkOLJ+vZPk+se5owrLxvHSmjavrD6fdgvNmVPR77OzqQtxz/QJUquurT541CS/fdi7uvXGB4Tin7r+5PtM2Ea1sXF3zTq/M16pQzJVYNHrSCl6uXr0ay5Ytw/79+/Hxj38c0WjsF768vBw9PT3405/+lNYJNTU14eMf/zjKysqQk5ODBQsWYMOGDdrjsizjRz/6EWpqapCTk4NLLrkE+/fvN7xGZ2cnbr75ZhQWFqK4uBif/exn4XbHT6giIqLsIprFA7Fy5UwRWQZismaPNwRZLWsWTb/F4qtPNwQHiGUtWHVBq0xP0h4o6Kcviyl02fDQJ5fiqS+chR9ePRfXLqrrd2GW6kLyyfXH0Nzrx782Nmr/HSYMMvNyPNH/N7lgVmwxrQ8VagN71LLxI+q/l+i7pO9fOpTelIIWvCx0av00h3LhFTZMG1czL3VZKPoNBSIiyh6iZHxGZT5K8hxatqE+89LcM3taZZ7h9oSSHC1Ic6RD+fs1loOXwjWn1BpumzMv9Zh5mZqPqqXj/9lyXFsj6rN1xdC/VIJ+Yv3hsllx3szEwctClw0rv3le3P2SJGF2dWHCKeNCvqlnu7gWEMwDe+pLc7XfHfMATxo9aQUvf/CDH2DOnDnYtWsX/vu//zvu8QsvvBBr165N+XW7urpwzjnnwG6346WXXsKuXbtw3333oaSkRDvmF7/4BR544AE8+OCDWLt2LfLy8nD55ZfD749Njbr55puxc+dOrFy5Ei+88ALeeecd3Hrrren8qERElCHRqBxXim2mD5QczXDw0q1mIdSpGYTBSFQr8RVl42Lx1ecPJZxiqOfKcIP3gfIVc3UXIOYemAMR/YfcKWb+hSKyLvMy9eDl9YvrUn5ONhGx6jyHFUsnlWr369sfiZ6X7e4AguEodqtDkebXFQIAanTBy64hNn4PhCPoUtsfVBW4YFOzccPR9C+8grrApz9B5mU6Q56IiGj4ieDl0snKtXKpLngpMi9zncb1gr4FSp7Diop8pxbYOaIO8Rmr08b19GW/gDELz6wsg/3LTwanTynF1Io8eIMRbFHbEekrk/LSGtgTy7w8a1pZwmM+fuYkzDD9dx0sc/BySrkxiN/Y5YM/FNGuQ+qKc5Cn/u5komqGMiOtK6/169fjlltugdPpTJgpUldXh+bm5pRf995770V9fT0eeeQRnH766ZgyZQouu+wyTJs2DYCSdXn//ffjhz/8Ia699losXLgQjz76KI4fP45nn30WALB79268/PLL+Mtf/oIzzjgDy5Ytw29/+1s8+eSTOH78eDo/LhERZcCtf9uIM3/2umHKt1m7LvMy48FLNeutqsCl9YUSg042H+0GEFv49/rChh43+rkzs9Upzx9aWp/R8xuo2lpfpp7rGHjqt57YPR7MQlIfuApFolr/nwmDLBsHgEc/czounVuF7185O6XzzDaibPy0KaWGafCy7g1Rlu+E1SIhKiu9x7zBCHLsVkwpVy4Qz5waW4R7g5EBA/j9aVX7XTpsFhTn2rXBQcFw+mXjiQb2BHWB++2NPWm/NhERDR/R71Jsrmll456glvVmzrycXhEL/kyrzIckSShwikw55e/TeMi8rC81rlkSZV7+6ROn4vJ5VfjaRdNH6rTGBUmS8NHTJhruK9MNedKmjSfIWOzyBPG3NYfjNtO1ljs2a1ygUagtHvw61MxqkQzDgMx9NX2hCLY3KeudPIcVxbn2lNbONDLSCl7a7XZDqbhZU1MT8vOTDw5I5rnnnsPSpUvxoQ99CJWVlVi8eDH+/Oc/a48fOnQIzc3NuOSSS7T7ioqKcMYZZ2DNmjUAgDVr1qC4uBhLly7VjrnkkktgsVjSygYlIqLMeG13C7q9ITy3NflGUoeh56Uvo99f7JwWuGzatE3Rz0+UvZTmxjIv9VOW9aGhxz9/Jv7vM6fjxiWZzSocsGxct+hKNFmxP2L3eDCZl/rS5qYuH4KRKKwWyZBBOJDzZlbgz59cqvUjGquWzShHXXEObj5jkuG/jz6YbbVI2gCE13e3AgDm1BRoLQYml+fhmS+frR3f0U/wfiCPrjkMQCkZlyRJC14OqWw8qi8bVxbo+oDm9qYeTtokIspCO48rmfGLJxYDgK5sPKCVupr7c+vLxkWQ0tx2pmyMThvXK8qxa0FZIHHPy8vnVeNPn1iK4tyxH6wdaTeeOsHQi700X98TXg36Jdis/fqTm3Hnf3biXxuOGe7XysbVIPPPb1iAiaW5+NgZsSCpudQ7Vfr3+eSy2O+BSGhY26AMmZxQkgtJkrSsZX3mpT8UwaoD7QO2GvCHIlg9iOMoNamlbqjOPPNM/Otf/8Jtt90W95jH48EjjzyC888/P+XXbWhowB//+Efcfvvt+MEPfoD169fj61//OhwOBz71qU9p2ZxVVVWG51VVVWmPNTc3o7Ky0vC4zWZDaWlp0mzQQCCAQCB2wdzbq/whCIVCCIXGV6N68fOMt5+Lhg/fM5SqRO8ZffCjo8+f9P3U1hdrAXK0w5PR912PmmWZa7egKMeGDk8Q7b0+uAsdWgBn+fxK/OW9Q/AEI4ZFlyzL2rkUOCScPaUY4XBmy0j0A3sS/dw5uqwBuyWa0r+Nw6L8fN5gBMFgsN9AaWdfLGi887iyC11T5IIcjSAUHZ7d52z9nDm1vhBv3XEuAOO5hSMRw+3KAidO9Pjx1l5lMMDs6nzD4/Nr8lFV6ERLbwAt3R5U5qW1/MLmo0qJYJHLjlAoBNG5wB9Mf73iC8SCqaGIDH8giEAo9t7u84dxsLXHsNDPBtn6nqHsxfcMpSqb3zOyLGsBn1ybco7F6kTl9j4/+tQNR5fdYjh/u+7PvxxV1jZ5ptLyAqeUlT9zqiaU5GB3cx8AwG4Zuf+O2fy+yZQCh4T5tYXYfExZJxbYY+8ZES/3+I1rk41HuvDu/nYAyntU/5jHHxuqGQqFcOPiGty4uAZrD3Xi8bVHAQBV+fYh/ZsW5djQrHbCqS+OBejrinNwpNOLNQeV4GVNkROhUAi5aqJArzegfd+fr9iDv645ilvPnYxvXzYz6ff6w5sH8cAbB/Gjq2bjE2dOTHqcMN7fM5n6udJaPd911104//zzcdVVV+GjH/0oAGDr1q1oaGjAr371K7S1teHOO+9M+XWj0SiWLl2q9dFcvHgxduzYgQcffBCf+tSn0jnVQfnZz36Gu+66K+7+V199Fbm543NAwcqVK0f7FGiM4XuGUqV/z/jCgPiT88CbB1Hn3gtXgr9AuxssEEUBxzo9eOHFFYag3lDsOaC8duPhA4j6LQAkvLlqLXblyABscFplHNq8SjtPfXZdNBrFihUrMnMiSQSDVojOl4m+V3dX7PF333wdzhSSL/3qv38kKuO5F19CP33rccytHAsAu0/0AJCQE/EM+88PjIXPGeXf5URzs+HfQ/Yq762GdiWDt7f5CFasOGx8Zlj57/fKW6txrCT1TEZZBnY2Kq9xZXknVqxYAXevcvv9dRvgOyhrx6Uy8H17pwQg9mZ67sWXcPBw7PcQAP724jtYUian9LojJfvfM5Rt+J6hVGXje0ZJkFf+Jr35xuvItQHHm5XP890NxxBuPwrAip6O1ri/3zMKLdjfa8FMawtWrFgBX2/sM98myXj7tVez8vM+VfZg7Oc6uH8PVrh3j+j3z8b3TSb19cT+fd987RXt/t3dyvuwub3L8N77/a7Y8Xv2H8CKwD7tsZ1Hlceam45hxYojhu9z42QJPUEJ+za8g/1DeF+eUyRB8kuYXADs374B4vcnL+oGYMH6Q+0AJER6lN8Zd7dyTms2bIZ8VFkD/XWN8pyH3j2MeeEDSb/XO3uV5767aRfKOncM+hzH63vG681MK7C0gpdnnHEGVqxYgS996Uv45Cc/CQC44447AADTpk3DihUrsHDhwpRft6amBnPnzjXcN2fOHDz99NMAgOrqagBAS0sLampqtGNaWlqwaNEi7ZjW1lbDa4TDYXR2dmrPN/v+97+P22+/Xbvd29uL+vp6XHbZZSgsLEz558hmoVAIK1euxKWXXgq7PflkWiKB7xlKVaL3zPFuH7D+Xe2YFT3V+MsnlsQ99+Fja4EuZRc3Iks4ddlFKZUr92fFE1uAtlacunAeeve14/C+dkybsxAVBQ5gy2ZMqSjEB64+C/+16TVtkI9gsViwfPnlGTmPZO7e9hY8YSULbvny5XGPP3ZiPdCrZN594KorDZPPBxKJyvjuemVBtOzCS/ptxr+moQPYvlF5nqx8j0Uz6rF8+bxBf79UjZXPmW+seRUAUFlZheXLF2v3b5D3YNv7R7XbZyxagOWnTTA896nWjWg62IFpc0/B8sXGKaiD0djlg+/9d2G3Svj09VfAYbPgsRPrcaivC6csWozlC6pxz4o9eG1vG/79xTMMzfP7Y9nZAuzdqt2+4OJL8P7L+4DWWHuHR/dbsSdUgr/dshSWTO0mDNFYec9Q9uB7hlKVze8ZXzACrH0dAHDF5Zch32mDtKMZ/zq0DY6CUkyZUQEc3o+pEydg+fL5hueee1EYDe0eLKwrhCRJeLVvG3Z3KxWK5QUuXHVV6hWU2WirtBfbViuBsMUL52P5aZntVZ5MNr9vMukfLRtwoFfpu6pft1Yd6cKDu9fD5srD8uXLAABrD3Vi35oN2jGTJk/B8itmabe3vbwXaDqCWdOnYvnlxozG+BVxevSvs62xB/dtV1oKnjl3CnatPoJgVFnfnLVoFpYvm4JX+7ZhV3czyifOwK+2nsCc6gIAsThTorW68EjjWqCzB9UTJmL58rlJjxPG+3tGVDYPVXp1SwAuuugi7N27F1u2bMH+/fsRjUYxbdo0nHrqqQP27UrmnHPOwd69ew337du3D5MmTQIATJkyBdXV1Xj99de1YGVvby/Wrl2LL33pSwCAs846C93d3di4cSNOPfVUAMAbb7yBaDSKM844I+H3dTqdcDrje3vY7fZx+eYBxvfPRsOD7xlKlf494w0b+1e+va894fup0zSN+XhvEBPL05ssaOZVA5JFeU4tsOMJRhHsVtqGTCrLhd1uR4HLDn8oYHiuLGPY3//6v52Jvpc+aORyptafyQ6lT6YvFEEoKvX7s3gTVHZMKssbkd//MfM5I1kM51lj6sNUXuCK+zmq1CD8+4e68OHTJ6X8Lfe1KeVM0ysLkJejrFmcaklTVFL+mz6/rRkdniC2NPbhsnmJN2zNoqY59xFYIOb1TCjJ0abNrzvchXZfZMg9pzJtzLxnKGvwPUP9kWUZDe0eTNG1ysjG94xf18Ulx+mA3W5FRaFSMdjpDSGo7sHmOePPvdRuR2lB7LO8MDf2eFm+M+t+1nRNrtBNVnc6Rvznysb3TSZZrbp2RrqfsyBXWaN4QxHY7XbIsowH3mwAoPSXDEdlRGFci4qZOLkO24j8m1mssYqTaaYJ5pPKCpTrAbU//sajPWjs8mnrIaEnEEV5kkn1YsBiICyn9POM1/dMpn6mtAb2PProozh8+DAAYNGiRfjQhz6Ej3zkI1i6dCkkScLhw4fx6KOPpvy63/zmN/H+++/jv//7v3HgwAE8/vjjeOihh/CVr3wFgHJhd9ttt+GnP/0pnnvuOWzfvh2f/OQnUVtbi+uuuw6Akql5xRVX4POf/zzWrVuHVatW4atf/Spuuukm1NamnulARERDpx9+058Oddq4mBJ5LIMTx8UgmnynXWvO3u0L4minshiZWKos+gsS1LOPxLiS4U5oy1Mb1w80tKfLGz9Qpr50fLZQSZd+2jgAVJsGEyVq/v+RpfWQJODfm5vwwrbkQ6uS2X1C2bWeUxNbZIsm86GIDH8oog0DOprk9+adfW34+Ut7DAN5whHjzxIIRbUBQHNrjNUnne70hw0REWWjF7Ydx+/e2K99rj/0TgMuvu9t/P7N5CWh2UD/2S2Gt5Xli4E9QfjUaeM5joF7zOgHmYyHSeNCfUls7ZJo2jgNzefPnQoAuGSOcd6ImNLtUyOSaw52YN2hTjisFly/WBl2Gbf2UAcGOlMcSJmuaZWxwLb+fQIAdSXKNYgYPNSZZNDiDnU6uVkkKqOlTwleeoKZ7Y9/skvrt/iWW27B6tWrkz6+du1a3HLLLSm/7mmnnYZnnnkGTzzxBObPn4+f/OQnuP/++3HzzTdrx3znO9/B1772Ndx666047bTT4Ha78fLLL8Plil04PPbYY5g9ezYuvvhiLF++HMuWLcNDDz2U8vkQEVFm9Prj/3ibA0DeYBhedaGzuL4EQGaDlyJol++0oVA3bVwEeiaqWRaFrvjdQZdt+Be9Ay2sJQwtuika8nsHWEid6PHH3TehJLuy7UZbdIDgZUle/HvojKll+MoF0wEA3//39qTv7X0tfZj3o5fx29f3G+4XwUt9QFE/bbypO5YRcKTDC1mW8ezmJhxodWv3f/LhdXjw7YN48O2D2n3hqLFFQiAcRUi9qJhXW2R4rLUv/r1BRDRWRaIyvvOvbfjVq/twTN3I/NlLewAA963c199TR11I99ktNj9F4LHbG9LWPDmDCAbl66Zy99dWZqwRG+EA4EwwbZyG5ryZFXjrWxfgDzefarhfTLj3BMOQZRm/Vn+XPnbGRG0zXL/2cAfCeGpDIwDANULBy0KXHev+62Js/X+Xxa1xxW2xbhZVYdMr83Hp3Njg6GTByw53AJFobFAmZU5aV2PmC04zj8cDmy29ivSrr74a27dvh9/vx+7du/H5z3/e8LgkSbj77rvR3NwMv9+P1157DTNnGvsilJaW4vHHH0dfXx96enrw8MMPIz8/H0REJ6Pfv3kANz20Bv7Q6P0BTZR5aQ5oiqxLh82COWqA5pipRKM/0aiMO57aih/9Z0fCv1MedSFf4LKhSA1edntDWhApUealRQLK8x34y6dOG/R5pOu3H12C8nwnfvWhU4bl9cUOsjvQ//vgRHf8v7l5V/pkFzW9vapMfVmT9Zv8xiUzsHhiMfr8YXzjyc2GDEjh1Z3N8AQjeHjVIcPjuxIFL9WgeigcVfrKqo50evHSjmbc9o8tuOTXb8d9j39vatK+DpqyH/yhiJZ5WVNs/Lna+oztFIiIxrJD7W4tuCCCfQXOtLuqjSgRHLFbJa3tTEmuQxu0c7xb2WwaXOZl7GcuzUtcBjsWTdCtXXyjuAYezyaX58Fh2uDPdcYGX64+2IENR7rgtFnwpQumacF0ny6o97c1sQE9zhFIFhAqC1woyrGjVtcOx2W3aAF8UbHUpWZe1hS58OdPLsV/LZ8DANieJHipTwLwMXiZUYP+dN62bRu2bNmi3X733XcRDsdnb3R3d+PBBx+MCygSEdHo+OUrSi/hZzc34abTJ47KOfT644OXrb1+LYgIQCt5Lc9zaIHEZOWveo+sOoSoDJw9rQxPb1J2br960XRUFhgDL25/LPOyOFHmpfo99ZmXHz9zEu76wLy0ezmnYlF9Mdb/18XD9r3yReblAGXjzb3G7DqnzYKKgvFzMTMU15xSi+e3HscXz59muN+ceal/X+vZrRY8cNNiLP/Nu9h0tBsPvL4ft182y3DM3hYlU7LLG8K6w504e1o5ev0hLStojj54qabbhKOyMXjZ4cG6Q52G19UH9BvaPZBlGZIkxQVQlcxL5T7zRUQrg5dENI7sOtGnfR1UP/eqi1zoUzPWI+adqiwiym71w/usFgnFOXZ0eUNaNn7KmZf54yfzUp/FZx0P49PHCP177pFVhwAo66eqQpcWKNe3MGpoi1WIzKzKTJ/7VLjsVlQWONHaF0BdcY62Ds9TA/9h9XNArInm1ylVKTuaEg+h0a+jmXmZWYMOXj7zzDO46667ACjZj3/605/wpz/9KeGxxcXFafW8JCKi4RMIx2d5jZRen7JIqSlyaTuSrX0BzNAtUjrcSmCkLN856J6XDW1u3PX8LgDAd3RTC/ec6DMEL6NRGW61XDrPGcu8PNjqhi8UgSRBG0Siz0DIcVhHJHAp9Pe9hnoaIvPSM8BCylw2PqEkZ0T/DbLZbz6yCD++Zi7KTA3a85w2FDht6AuEkWO39lv2VF+ai59ePx/feHILfvfmAVy7uA7TdEMF9jbHFsOv7mzB2dPKsUe9wK4pcqFEV9InysYD4Sh6dVnKTV2+uEzr7/97u+H2/lY3ZlYVJOw7FQor99ksFly1sAYvbjsBgJmXRDS+iHYcABBU10j6zPnjPYOv/hgsWZbx9/ePYOGEYpxSX5z264hNJrvFuMlUmudAlzeExi5l/ZR65uX4CV4CwC8/uBBrGjpw2byqgQ+mjLBaJLjsFvhDUby2W5nO/RF10nu++l7r01VfiWDf966cjdOnlI7w2SomlOQowUtdtq5YNwui9cC8OmUTuanbh05PMO53plmfecmM34wadF7urbfeivXr12PdunWQZRl333031q9fb/jfhg0bsHv3brS2tuLqq68ezvMmIqIUjWb8SWReXruoDsumlwMAWkwZfqJsvDw/lnnZ2hfot+RixfYT2tdv7mnVvt7b3Gc4zhuKQCSeFbhsKFYnax5XFxi1RTla2YsheDlCvXcG4+xpZQDSH+wjevd4Bsq8NAUvOawnxmKR4gKXgigdL8kdeKLitYvqsHBCEaKy8b0aDEfR0ObRbr+8oxnRqKwb1mMcoCNKmjyBMJq6Y//dwlEZhzs8hmOfXH/McPvtvW3K9zRlXvpDUe0+u1XCLz+4EJerF33seUlE48mu4/HBS32woaU38xs2r+xswZ3/2Ylrf79qSK8jskJtVuOioEwt+/aHlJ9nMOuY8TqwBwA+tLQev/7wIm2zj0ZGni7wN7U8D0snKb3s8xMMjxSb5gvqjH22R5JoMaDvf5nnNAcvlfdQocuOKeVKn/wlP1mpfXYIxsxLDuzJpEFnXtbU1KCmpgYA8Oabb2LOnDmorKwc4FlERDSa9KWio5k9J3peFubYUKmWIJtLUNs9sczLohy7lsnW1O3F9MrEZSQvbIsFLzcd7da+3mMKXoqScZtFgtNmiSvrnagL0OnLxrMpePn586aiwGXHeTMr0np+npZ5mXwh1eePNfmfUZmP/a1u9rscpOpCFw60uhNOGk9EyQzuMUx3b2h3IxyVke+0QZZlNPf6sa2pJ+GwHgBaEF4pETRmKR9oNQYvzd7e14bPnzc1YealaKRvt1mQ67Dh+sV1eGVnCzMviWhc0WdeikxG/QZfsinDQ7HrROJS01SFtLLx+MxLvZN5YA+NnhyHFVCXIVcuqNauQcxl47Isa73Wa0z9w0fSsunleGHbcZwzrVy7T2z6C/pJ6LXFLhxqV37APc29WDihWHtMnwTAsvHMSqsj8fnnn5/p8yAiomGgLxUfzcJfkXlZ4LKjUu0PmCzzsizfAUmSUJrvQF8gjG5vfL9MADjY5jYEKfW9qfY0Gy8O3AHlNfJdNkiShKLc5MFLc9l4tnDarPjU2ZPTfr4+S0/0OzQTC65Clw11JTnY3+rmpPFBqixUgvLFg8i8BIBSdSJ5l+7iWGRhzqouQHWRCy9uO4FXdjZrF7vmzEtR3tjtDWrDGYpy7OjxhdDujgUaEw2wWn2wHYfbPXHTxv2hqFY27lAzVSqSbDgQEY1V7e6A4TNNrJfchuBlCIVxzxwa/V9efyiS9nRl/cAevRJT8DH3JC8bp9GhH+Jz0exYyb7I8hVJBX2BsNbOqKZo9NabHz6tHtecUmtY98eXjcd+plrduZoDlCd07SY4sCez0gpeXnTRRQMeI0kSXn/99XRenoiIMkTf925Uy8bVnpeFLhtCSQIhoudluVryVKQbqpPICl3WpVCSqzSq39/qRjgShU0NvvTphvXoX1uYWKYPXsYeS/eiIhuJHeTfv3kQ/9lyHCu+ca4hyxSIle7UFufgI0vr0eML4cr5NSN+rmORGNqTbNK4mTiuSxec39eiBC9nVhXg7GlleHHbCby0/YT232VOjTEDWQRKOzxBbbF85tRSvLKzxXCcuefS/LpC7GjqxR/fOojiPON7IBCOTRsXZXaif2xbX0DJkujx4/2GDlw5vyarAvxERIO125QBGUySeZnp4KV+U7nDE9T6bacqpG48xZeNG/8GuQbxGZ3P4CVlmL4FziJdb1exkS7W5SfUjdfiXPuoryfM3z/fXDZujwUvv33FLPxzozIk1JxkoW83EY7KCIajcRPZKT1p/StGo1HIsmz4XzgcxsGDB/HWW2+hsbER0ejoDYYgIiKF6HkEKENrRovIvCzMsaNKDfK0mjMvPbHMSyAWYEw0qRwAXlT7XeqDslcuqEGuw4pgOIrDHR7sbe5DMBzVMinEQsRpsxpKqeqTZF6Op+Clfge5scuHZzY1xR0jAmDVRS5cuaAGz3z5HENgl5I7d0YF8hxWnDezfOCDAa28XF82LjIvZ1cX4MLZlXBYLTjc4UUgHEWuw4pJZXmG1xAXmftb+hCKyLBaJJw2Ob7ZvXkI0zcvmQkA+PfmxrihWAFdz0txUSwyLwPhKO56fhcu+NVbuP2prfjb+4cH9bMSEWWTv7zbgE/87zrDfcFwFNGobBhq1+nNfNl4hy4rPt1WHL3+kPbZbctA2XhZnhPz6wqxdFJJ3OYu0VDYLBKsumbtYoJ3MBJFOBKNrTsLR69kPBnz744Y2AMom7oXzVZaKPb4Yp8TygavcdAXsy8zJ63My7feeivpYy+88AJuvfVW/PrXv073nIiIKEP0GVejOm1cBC9ddi2AaG6E366VjTu1YwGgJ0HZ+IFWpWTcbpWwdFIp1jR0AABOn1yKXcd7seVYN375yl68srMFNyypw2VzlZIV/S5qUY5d+/eZpO95mZOdPS+HKs+0o5yo96UIco1m36Gx6qxpZdj+48thGeREpYRl47rMy3ynDctmlOMNdRDVrOoCwwUAEJ+9WV3oMkwuF5q6jAvpM6eW4cyppXi/oRMrtjcbHvPrMi9F2bjLbkWBy4Y+fxh/XX1YO3ZrY8+gflYiomzhD0Xw0xd3x90fDEcNE5ABtZ1N/EfqkLRnIHj52b+ux/rDXQCU4JCe2AAWBlM2brVIeO4ryyBJo9sfncaPT589GX9dfRi/+9hiw/36jXRvKGKo+Mk2oh2Q4LIbNwqK1euF7z69HVcuqEGhy45eX1hLHLFIQFQGvKEwisBNgUzIeP7q1VdfjY9//OO47bbbMv3SRESUIv8IBC/DkSj+/v4RHOlIPiBElI0X5dhQpZagtvb5Icsy3IEwHl1zWOtTKUqeCrXMy/ggm5gyfs70ckNPxlMnlWB2tVJaK0pn/72pKVY2rsuq1Pcm1Pe81N8/roKXpvIXf4KdYNHzsrow+xaRY8FgA5eAPvNSCTy6A2Ec61SCjLPU9/AV86q1483DepTXMC6G64pzEmbKHusyZlfarRZ89cIZCc8rEIpqgyD001nFZM2ZVfn40gXTAMSXXRIRZbuNR7oMt7VMsHAEbW5jMHE4Mi/1rULa3akHL/v8IS1wCSBuUyudzEtA+fvFwCVlyg+vmoO3v30BrjC1HnLYLFqfVm8gFryszsJNc5fdasgI1WdeAjD0z7/jqa0AgBO9yjquONeurbs5tCdzhqX4ftq0aVi/fv1wvDQREaVgJDIv/+e1ffjhsztwyyOJP/ejURl9usxLsZPpD0XR6w/j7+8fwY/+sxNipki5yLzMUf7oi56XT647iit/8y6Od/u04OVVC2q0gGRlgRMTSnK0wI+e6K+pz7wUwdECp80QBNI3DJcxeqX2mRYXvEzwfmDm5cgRF5iibHy/mnVZUeDUHrtkbhXEdal5WA8Q31+zttiFCSU5cf1tG02Zl3arhHOml+EUXR8qkWUZCEd1PS9jL/T7jy3Bw59eipe+cR5uOWcyAOBwu4flUEQ0prx3oN1we8GEIgBKGas5E7LTk7htzVB4dVUP+u/X2OXFVQ+8i3+pffSS2W7KeDf/bTcHLwfT85Io02xWS1yrG0EE1D3BsDZpvDZL150zqmKp105T30r9GmzlLiVhIpYE4NKynrlOypyMBy/D4TCeeuoplJcPrucTERENH2Pm5fD88Xzw7QYAQEN74sxLTzAM0W6zMMcOl92KQjXg2Nbnx5aj3YbjxcJb63mpBi+/9+/t2H2iF7f+bYNWMn7Z3GoU5yjHnzqpBJIkYXZ1fJDnfbWsXN/PUpR71JfmGrIN9P2erOMoC8F8gdOXoJdocxbvgI83JbnGsnExrGe2LvhemufA1Qtr4bJbcO6M+HVVrsOqBR0BpezKabMapmAC8WXjkqRk2Hz7sllwWC04dVIJbjq9HoDymWEe2AMovycXza6C1SKhssCFApcNURlo6jZmdRIRZbPVpuDlpFIlwBIMR7VMSJGNqW/rkSn6TWV98PI/W45j5/FefOufWxO2yxG2NHZrX8+ozMf3rpxteLwsz1jqOp4qSGh80DISAxE094p1Z3ZW/OgHYJmDl+bql2hUNqyjRYk8My8zJ62el5/5zGcS3t/d3Y33338fzc3N7HlJRJQFArqBPfqvM0WWZUQGGAQkyr4dVov2h7+q0IVevxstvQHs0pWeFrps2kQ+reeladr4jibl+GXTy1GUa8d1i2uxv7UPXzhPKWWdnSDzcv3hTgDxPS8BYFKCMtt7b1yAHU29OHNqWb8/21hi7nnZ4Y6/KBNNxmuLGbwcbmLHvtcfRjgSxZ7mWL9Lvfs+fApCkQWGPlGCJEkozrVrmcV1aguFiaW5aOqOBSwbuxIHGJfNKMfGOy9BvtOG375xAICSeRkMxwcvzcryHOjzh4clM4mIaDj0eEPY1hTLXFxQV6StOYLhWOblzOoCbD7ajU5vUKsKyRR9Fpa+bFz/N/qxdUfw5Qumxz135/Ee/OLlvQCUwWtfu2h6XLuSkrxYQMVulfr9HCcaDSIj0RsM43iWZ16W6jYDnKaNAHPLhgNtbi0YW1PkQqva29+boMc8pSet4OUbb7wR1xNDkiSUlJRg2bJl+NznPofLLrssIydIRETpG6hsXJZlvLO/HYsmFBt6twzW4Y5YUGRebXzGIxDL8CvMsWl/OyoLndjf6sbBNjeO6qYdi5JxYOBp41ctrAUATCrLw+8+tkS7vyTPgapCp2EgkGiene/Ul4crC6XplfHd+D9y2kR85LSE33bMMmdedpgySjyBsBZoztYd8PFE/9/DF4pomZfmtgd2q6Xfi8+SXIcWvBQN7yeV5WpDrID4snG9AnWTQDSi9+oypc1ZBnqleQ4c7vCi05PewAkiopG2pqEdsqz83b/3xgWYXlGAB97YDwAIRGKZl7OqlOBlKCKjI8lH3IrtJ3DX8zvxm5sWp7TRqQ9e6jMvw7qN4EdWHcZnl02J67F31QPvaV+fNa0sYZ9lp82KAqcNfYEwXMy6pCwkNmM9wXBW97wEjAOwXKY1kd+UFLLxSJeWeVlV6EKuww2AZeOZlFbw8vDhwxk+DSIiGg76snFfgp2/1Qc78KmH16E0z4FNd16a8uuvPhgrvzLvQApiWI/IpASgDe15e2+b4Vh9AFUEL3t8Smaant0q4VJ1gngis6oL0dLbFnd/njO2kL/lnCmoKHDimlNqk77OeJJnytzrMA0KEAvIAqfNkKFKw0Nf7h0MR7FXzbycVRWfOdwffdnSBC14aewz1TqIibbiIrlXl+nstPcfvASGpyccEdFwEP0ul00vx6mTSgHAkHnpVjfw6nVD/H6y2YZP3hD/Wk9vbERLbwBv7W0bdPBSlmV49WXj7sTBy7a+AP6z+Tg+fFp90teaX5d4wxgASvMd6AuEBzVpnGikifdlS29AK6muydJNc0PZuGkz4AOn1OI3r+3TNv43HunSNkBqilzI0TJMGbzMFOaRExGNY/rMS0+CP54b1ImVnZ4gdjT1xD0+kDUHY9ldwSQDgUQwRN9vskId2rPqYHvC5wC6aeO+kGE6JwCcO6PC0JvSLFHpuPkcSvIc+MRZk7Wpz+OdPnALAO2msnH2uxxZFoukDcQ50eNHuzsISTI2hx8MfcP4Gl3mZapElqVYhAPGAKtZLHjJzEsiGhtWHVDWLOdMj/UQFp9zIV3mZXn+wOuCHceVNVMqfTED4aihDL1dn3lp2qR96N0GRPtpy5OolYggPp/Z75KykQheHmxVMhNLcu1aoC/blPbT87KiwIn1P7wED396KQBg09Eu3Vo6J1YeH2LwMlOGlFqxa9cuNDQ0oKurC3KChiCf/OQnh/LyREQ0RPqSBk8gPvNSn1n19/eP4Oc3Lhz0a8uyrA3CAZIHLz1qxqe+TFZkXppLLvT9M6vUAOfxHh/e3NNqOO6qBTX9nluy4KW+bPxkYy4bdwfC8IciWlmZ6HfJ4OXIcVgtCEUi2K5uHEwsze33gjQR0d+sKMeuZcxOLE09eCneB6LNg9UiwdZfuTozL4loDGnq9uFQuwdWi4QzppZq9+szLz0BJchQ4Op/rdDa59da03R6Bx+8NJePeoIReAJh5DltCEWU9c+1i2rx+u5WHGh14829rbh4TvIqk2REthjLxikb5aprlQNtSvAym1sVlenaWSVqpeO0WbFkYgkAoKHNox1TXejSKp4SVb5RetIKXh48eBAf//jHsW7duoRBS0DpgcngJRHR6PIPkHnp1mVZJZsWnsz+Vrchey9oyhoIRaI42NGrLdb1GQBVhYkDZGLxDiglJNcvrsMzm5vw4+d3avc7rBZc0k/JOGDsGzi3plAbCpTvOnnLoRMtujo8QdSp2Xpit7iGwcsR47BZ4AnGgpfmYT2DITKHxX9HQMnenF1doA0B0vv02ZMTvo6WeemLDdjqTxkzL4loDFmlloyfMqHI0MbGqQteimqVgTIWdzbFBg12Jsi89AUj2N/ahwV1RYY5ESIDy2G1wGqR4AtF0O4OIM9pQziqrKFKch342BkT8dA7DfjT2w0Jg5eXzKns9/xEthjLxikbieFUB9XgZbYO6wHM08YT/z4V5zowvTIfB1rd2nyBapaND4u0ysa/8IUvYPv27bj//vuxadMmHDp0KO5/DQ0NmT5XIiJKkSF4mSDzsk83DCcUGdw08nAkipd3NOOy/3kHQGyRHAxH4Q9FtOzJ/35pL664/12s2NEMwJgBUFnoRCLdpgyGO6+ei9I8h+EP/4OfWNJvyThgHMJzSn2x9vXJ3MvRPGgPMPa9FAN89EOTaHiJhfD2RiV4mSxjuD9iYV2rC146bVa89I1zcc/18w3HPvjxJfjxB+YlPhe7KBsPGW4nIyZwmgc/ERENp3AkOuj1ip4IXupLxgFd5mUkFrwcKGNR32YnUdn4j5/biQ/8bhXueGqrIdFHZGDlOKyoKFA+Q8XQnrC6eWuzSLjlnMmwWSSsO9yJn720G4BxjXbP9Qv6PT+RGZ+tpbh0chMVJmKYYDZX/JTqWkhY+lkWLZlYrH2dY7ei0GXTNg84sCdz0gperlq1Ct/97nfxta99DYsWLcKkSZMS/o+IiEaX/g9m4uBl7L5EZd+RqIy/rTmMvc19aOn14zev7ceye9/EF/++UTvm/JkV2mud94s38aGH1iIYAZ7ZfBwAsPVYNwDjxYAoGweUATHnqa/xoVMnGL5/aZ7DEGy5Yl41Lpo9cAmV02bF96+cjY+eXo+P6BreF5zEmZeJdOgyZ70JyvtpeImL5j3NShZPOpmXVy6owaVzq/DZZVMM90uSFJd1U9hP0N9lM2YIDJR5WaqWq3elUDJJRDQU0aiMG/+4Ghf88i1to2WwEvW7BGKfdWIDFhg46Cf6XQLxGzjBcBRPbTwGAPj35ibsa3Frj/mCyjorVxe8FH02ReWJzWpBTVEObr9sJgDgT283YH9Ln2ETt2SAXt1l7HlJWUysTURcX7/5mm0KdGviyoLkQdZTJ5VoX9cUuSBJEnLUIC0zLzMnrSuU8vJyFBUVZfpciIgow/zhAYKXgf6Dly9sO447/6OUbFstkpZVme+0wa0+9/yZFXhmcxN8oQh8oQha+wJ4TbJoZeo96sCeHEcsGKLPvJxTW4g/3rwEG4504awEEzuvWViD57Y04bXdrSktcL5w/jQAQCAcQY7dCl8oMuCC/2Szr6UPF85Wys/E4oplZiNHBC/FRWs6mZd1xTn48yeXJnwsx25c5hX208fNnGk52MzLTjeDl0Q0MtYe6sRWNVP9ha0n8LEzJg7qee5AWAsSzq8zXsOKz+GAPnhpt6KywInWvsRtMXboysZ7fCGEI1GtR/C6Q52GoTxbj3VrrWzEJmGO3aoNBRKZlxG1bFwMcvvyBdOx6UgXXtvdiifWHcPnzp2iPe5I0AZG74JZlXhs7VFcMb///uBEo8G8SV6dpJVUNpAkCa/fcT58wYhheI+ZPngpWmPlsmw849LKvPziF7+Iv//974hE+B+CiCibiV1+QOl5ae5TrC8bN/esBIC9up55kaiMpZNK8JubFmHtDy7G9Mp8zKoqMPzBFlY2xZco6zMAXGpJBaD0pMxz2nD+zIqEC3JJknDfhxfhh1fNwRfPn9rfj5uQ02bFnz5xKu7/yCIt04EUv3hlL57e2AiAwcvRoO9DardKmFyel9HXj8u87Cd4aTdlWg6252WHJ5i0/zkRUSY9s7lR+/rpTY39HGnU2qv0dM532uLax4h1x7v727U+3i67BfeqAwztkvHzrdMTRFO3Uu4qurF0eWNrqZW7mg3Hb2ns1r726TI7zWXjoagoG4999t585iTtZxW9NQcz1G1mVQHe/vaF+KCpmoUoG5gzgmuKszd4CQDTKvLjNj3Mppbnay2tRO94rWw8xIE9mZJW5uXMmTMRiURwyimn4DOf+Qzq6+thtcZf7Nxwww1DPkEiovFOluWE/QgzQZ95GYnKCISjhvLtgcrG9bujL33jXMypKdRuv/yNcyEjllmpF5X7D14Cys5kr9+NebWFcceaFeXY8blzUw9cCqIsnRRzawoxt7YQ/9rYiDv+uRU9vpCWEZLqtGtKnz5YP60iPy6AOFTm4GV/A6usFuPv7MG2/gd4iZ5qAXXIBd83RDSc/KEIXtoeCwxuPNKFD/5xNR77/BlJB2kIYjJ4ZYINzESfuzl2K4pzlUBEgSnZaqdaMj6lPA/d3iC6vCF0eYOoKHBClmW8trsVAHDjkgl4elOj1joHgGGAYUW+EuBoUwOmYXUD2WaNfRafN6MCdcU5aOr24Qt/U9r1nMy9u2l8yHOagpdZPG18sCwWCUsmFuPNvW2oUoOX4rqHmZeZk9an30c+8hHt629961sJj5EkiZmZREQDiERl3PDH1Shw2vC3z56e8SBmIGT8HPYEwobgpXuAsnFRZvWlC6YZApcAtBKpgcqXBJcpkHLzGRPx7JbjCSdp0vCoKHCirS+Ay+ZV4esXzUChy46HVx3C3S/s0o5h5uXI0Wc3zkqjZHwg5r5t5gsGPXPwciB5DiscNguC4Sg63EHklvKCmoiGz2u7W9AXCKOuOAfnTC/DUxsaseFIFx5+7zC+dMG0fp/b2qdkXiaqvki0hnE5rFpQUxSl7DreC18ojO3qsJ55tYXYfaIXXd6Q0j+6Cth1ohdN3T647BZ8+cJpeHpTI/Y098EfisBlt2pBjByHFeUFxrJxMbDHrgteWi0SbjqtHvet3Kdle7a5E5eyE40V5s3Omiwe2JOKT549Ga19AVy1QGnXkMuelxmX1krzzTffzPR5EBGdlNrdAW1Xfufx3gHLElLlMwUvvcEI9F0lB8q8FGVKZf30eUlWXjqjMg/7W2PZW+bMy0+fMwWfPmeK+Wk0jJ776jl4d387rl1UC4tFwp1Xz0FJrh33rdynHcMMupHj1P1OpDOsZyD6/5YOq6Xf7CSraePk7msTTyUXJElCWZ4DJ3r86PIGUV+aO7STJSJSdbgDiMiyYUDGM5uaAADXLa7FNy6eiac2KGXj5uqPSFTGfz2zHSV5Dnz3itkAgP997xAAY1aj4EySeakFL2XlNT/2l/fR4wthovpZN7+uCC29fhxs82iDy17bpWRdnjujAlPL87QNw53He3DqpFJtTZbrsKIiXy0bdycvGweAD59Wj/tf36/1HE+0ViMaS/Sb5KV5DkNSxVh24axKXDirUrvNaeOZl9YVyvnnn5/p8yAiOinp/6C9trsl48FLf8i4yHWbhva4dcHLQIKel2IadVl+8uBlopKri2qiuO68afj6P7Zp93Hq5eirKcrBh5fGpq9LkoSvXTwDe5r78OL2EwCYeTmS9IH/dIb1DET/37K/rEsgPvNydvXA7RxKcpXgpXnaLhFRuqJRGZff/y7a3QHsuOty5Dtt6HAH8Pa+NgDA9Yvr4LBZ8NHT6/HEumNxa4tnNzfhyfXKtO/PnDMFXd4gtqlDfrYd64FZosxLu9WiZUBGZKC5149uta/lkQ4vAGBBXRE2HekCEJs4vnK3UtZ+6dwqSJKEUyYU47XdLdhyTA1e6svGxbRxLfPSOLBHqCp04aLZlVi5qwUAYEsxS54o2+g3VrN5WM9QieoXT5A9LzMls82ViIgoJfrMyNfVPkkZff2gOfMy9gfUH4oYhvSEItG4wRuibLwsL/mgG6tFMgQ+Zlfl49rJUW3anmAuYaXs8cGlsab+DF6OHP3AnuHIvNT/zg3U3sEcvBzMZoPY1ODEcSLKlA5PUFt7bD6qBAdf2HYC4aiMBXVFmF6pfFYW5yqfPyLrEVACgL9/84B2e+uxbjy65rB2u64kvrdess9GfeZlY5cv7vF5tYWGz0B/KKJNIb9gltJne1F9kXYeAHRl4zaU6zIvZVlGSC0btyXYEL7ptNim42Bb9RBlK/1mam2WD+sZCrGebmjzaJsPNDSDyry88MILYbFY8Morr8Bms+Giiy4a8DmSJOH1118f8gkSEY1n+uDl9qYeNPf4UZ3B3i/6gT0A4A7EbutLxgFAloFwVDbs+otsgv4yLwFo5UxAbOFdYGoqP1BDfRo9F8yswFULatDU7cOkssxOvKbkxEVonsOKCQkuqocqVxeAtAzQT9f8+GA2G0rz4oMHRERDIfpTAsC2xh6cO6MC/96slIxfv7hOe6xEHajTrfv8eXH7CTS0x9rVbDnWjdUHO7Tb99+0KO77DRS8DMvAMTV4Oa+2EJGojGkV+SjOdWgbu+3ugBZwdVgtWkn4KfXFAICt6sRxQ9m4mnkZDEfR6w8jHFUH9iTIrLxAV4paktv/eowo2+k3yTN5zZNt9D/n5x/dgH9+8SycNrl0FM9o7BtU8FKWZUSjseycaDQ64FAJc/YOERHFM2dGvr6nBTefMSljr+9XX99psyAQjsKrKxsXJeR2q6Tt+Lf1BVBbrARRolEZXVrPy+SZl2YnepQLD/NkY2ZeZi9JkvD7m5eM9mmcdETm5czqgowP6wKMGTwDBS/jMi8H8fsqLqJZNk5EmdLaFxtIs/FIFxra3Nh6rBtWi4RrTqnVHotlXsZ6Xm443GV4rU1Hu9DQ5oEkAVvuvAxFasBTL1nfbrGRG5UlHOtUgpcLJxTjZzcs0I6pLHSq5+zXeoSX5jm0z/OFdcUAlFLzLk8QPrX6JcduhctuRYHLhj5/GO3ugG5gT/z56D+fA+x5SWOcvmx8PEwaTybH1EP+P1uaGLwcokEFL996661+bxMRUXrigpe7WzMbvFQXueX5TjR1+ww9L/v8yoK/It+J2uIcbDjShb+8ewg/umYuAKDXH0JYzags7Wdgj5kIZOSbeuyx5yWRkcj4GY5+l2aWASoNzRfMuYMpGxeZl+rvfIc7gH9ubMQNS+oMgzaIiAarTRe83Hm8B8+qWZfnzig3TAsXmyfduoE9veq6ZmpFHhraPNio9qScUZmfMHAJJM+81AdYdjcr5eD1pcZAS2WBCF4GEvYIL8q1Y2p5HhraPdja2K1lXorNoYoCJ/r8YbT1BbQ1Wb6z/8tzH/vn0RiXZwhejt+1gnkd9dL2Zvz4mnkJW0PQ4PBfjohoFImFrOh99N6BdkNfylTd/fwuXPv7VdpriOCoWEx7dcFSMawn32XD1y+eAQB4bO0RrWSrXV2IF7psafVYyjPtODJ4SWR06dwqTC3Pw3WL6gY+eIjM08TNzBfMg8q8zDNmXv7fmiP4+Ut78Miqw+mdJBGd9PTBy5begDZ8R18yDiQuGxftcMQQEJGl2F/1SLL1TY7DqgUn1x1SgqD1JbmGYyrUTZrW3oD2OWje7NVKx4/1aGswUU6q9b3sC2hT05MFWefVKkPULp1blfRnIRoL9OuL8Z15aVxHdXiCeL+hc5TOZnxIa9q4EAqF0NTUhK6uroRl4kuWsASNiKg/Ing5v64QB1rdaOzy4b397bhsXrXhOH8ogjv+uRXnz6xAgdOGJ9cfw10fmIfJ5bH+hIfaPXh41SEASp+ns6aWaT0vRYbU4Y5YL6hedZFf4LLj3BnlWDyxGJuPduNPbzfgzqvnokPt3yQW16myWCTkO21atmeOg/tlRHrnzqjAG9+6YES+l2WACbUuu/H30zmIDQvxuSLKJRs7lSm8rb2BpM8hIupPa6/feLsvgHynDZfNNa6LitUgn5j+DcQqSsx99KZWJO/lbO8nLX1KeS5a+wLwqEHH+lJj8FIEN9v6Yj0vzWumUyYU4ZnNTdja2A3xKSw2cysKEgQvcxIHLx+55TS8uO0EblgyIeHjRGOFw2aBy26BPxRFXfH4DV4ahzLmY1+LG89vPY5lM8pH8azGtrSuJLu7u/G5z30OhYWFmDZtGpYuXYrTTjtN+5+4TURE/fPrmrdfMkfZTX9td/xEuj+8dRAvbjuB7/xrG778+Ca8va8Nf3rnoOGYx9ce0b5u6wsgGIlC7CuVqYvpR1YdxtMbGwHAUKIkSRJuu2QmACX7sq0vYOjflK4CXd9LFzMviUbNQD0vzT03B9ODs9RUNi561fXoyjiJiFKh73kpXDG/Oi6LqVg3uOZAqxsA0OtTNkvNpagzq5K35ijJc+AHy2cnfGxKuTHoWW8arKYN3YlEcahN2RxOnnnZrZs2rgYv82Nl5wMFLysLXLjlnClJHycaS/7fNfPwzUtmYmJZ7sAHj1GSJOEn183H7ZfOxI8/MA8A8PLOZgTZtzZtaWVefvrTn8bzzz+Pm266CWeccQaKiooyfV5ERCcFUdbtsivBy7+uPow39rQhGpUNmVKrDrRrX4uA5IrtzfjxB+bBabPCH4rgqQ2N2jFtfQH4g7E/jvoeTI+vO4obT52gZUSKAON5M8qxcEIRtjX2YOWuFkTUbzTQpHEAuPvaefjRf3bG3a8vRWXZONHoGahsPB2lprLxFjVjSvSdIyJKVaLgpblkHACKdUG8pzc14rtXzI5lXhYag5czqvL7/Z63njcNf3v/iDaYR5iiC6zkOqxxgUmX3YqiHDt6fCHsUftimtdMc2oKYbdK6PAEsV8Nsop+miL4ebjdA7XFOIOTdFL46OkTR/sURsQnzlTmGESiMioKnGjrC2DVgXZcOLtylM9sbEorePnqq6/i61//Ov7nf/4n0+dDRHRS0fc/On1KKQqcNrS7A9ja2I3FE0u04/a39Glff/2i6fjHhmNo6Q3gnX3tuHRuFV7YdsKQ7dTuDmol41aLZFgMbzrahdY+v9YbqsClPCZJEk6dVIJtjT040uHRMgPKBlE2/smzJuOB1/drfTIF/cRxThsnGj0DlY3rLVIzhQYiLuR7fCGEIlEt6NDLzEsiSlNbguDlmVPL4u7TD704VV0viXY41aY+ejMqBx6K9pubFuNTD6/Dd66IZWHqMy/rS3ITZqRXFjjR4wthd7OyTitLEOCcU1OIbY09Wmm5Vjaurq8OtilBTaWclmslovHGapFw1YIa/HX1YTy/7TiDl2lKq2y8rKwM06dPz/S5EBGddETZeI7dCofNglMnKwvwvc19hmNEb8xvXz4Lt182C9csrAUA/GeLMoXz7+8rJeP6/kkiqzPHbjVkQMoysHJXi5ahoC/tnqj2czra6dUmZ5YPsmz8O5crC/6PnR7rxyQCowDgsnFBTjTSbliiZCx94+KB121XL6wBANx59ZxBvXZxrgPiWr6l169toLBsnIjSIcuyNjRQWFRfDGuSzRcxxMZqlRCJylpFiT7zsiTXjvJBVJAsmViCrT+6TMuUAoCp+uBlaeLefJWFaum4WgpammA40CkTig239dPGgVg/cmZdEo1fYo21cmeLdv1HqUkreHnrrbfiySefRDTKen0ioqHw6YKXQCzYp58KvqOpB6GIjPJ8B758wTQAwLXqdOLXdrfg/YYObDnWDbtVwmeXTQEAtLkDWualy27RSpSEV3a2aE3uC3SBzUlqidSRDm/KPS8/fFo93vvuhfixLvAhXtths6SU+UVEmfGrD56CVd+7CFfMrxnw2F9/eBFWfe8inDqpdFCvbbVIWummfsOFmZdElI6+QBj+kHJ9+eDHT8XyBdX48yeXJj1eBDVlORa4BICqolgAcUp53qB6+ALxGep1xS5YJaWee0JJ4t58lQXGEvVErXZOMWWzm6eNhyLK92Dwkmj8WjKxBDVFLvQFwnhvf/vAT6A4aZWN33nnnQgEAli6dCk+8YlPYMKECbBa4zNqbrjhhiGfIBHReCDLMr72xGbIMvC7jy3WFtJaz0t1IZujTvz16XbkNh3tAgAsnliiPW9+XSGmluehod2D257cAgC4cn4NZlcrpVH6zEuX3Yp8p/Ez+t39bVrvzDN05Vgi8/JYp1cr+R5M2bgwoSQXoVAscCGyOtnvkmh0WCzSoKd5OmyWlCd/luY50OUNYY8ueOkJRhCKRGG3prVHTkTjwPf/vR09viAeuGmxocS7P629Sll1gdOGK+ZX44r51f0eL9ZE0Whs08Rps6A4JxZArEsSdBwMm9WCchfQ4gMmlCTJvCwwrpHMZeMAsKjeOB/CPG1cKGbwkmjcslgkLJlYghe3n0Bjl3e0T2dMSit42dTUhDfeeANbtmzBli1bEh4jSRIiEabDEhEBSg/KF7adAADc45uvTck0Z16KDElvMJZBsOlINwDg1EmxHpiSJOEDi2px/2v70awOyfjEWZO03fx2d0DLXnDZrYbMS4fVgmBEeeybl8zE6VNiWVYis6AvEMZBtbH8YAb2JCPK1Rm8JBqfyvKcONjmwe4TvYb7e32hQW98PL/1ON7e14Z7rp8PJ9tLEI15nkAYT6w7CgC46bQOnDezYlDPEyXjFYWD++ywqomSEVk29PF22GLB0lQ3ZMxmFclo80uGtZKeOQCZ6HNvank+8p02LTs0V+spblxfMfOSaHxzqkkqAU4cT0tawcvPfOYz2LRpE77//e9z2jgR0SDoy5nCYqQkYpmXYiEr+iCJsnFZlrFRzbxcohvgAyil4/e/th8AMKuqAEsnlWiN7jvcAXjU75ljtyJPVxp+9Sk1+PemJlw5vxpfu8jYB89lt6K60IXmXr82Qbg8hcxLM5G9yWE9RONTSZ5ysa3PvASUvpeDDV7+7o0D2NvSh+sX1+Gc6eUZP0ciGlliMA0A/GfL8aTBy1UH2tHQ7tH6TIo1jDmbMRmLFCsbF328C13Gy1t938p03DA5il9/5mKUFyYpGzdNNs9LsN6xWCQsnFCE1Qc7AMTWRHarBaV5Dq1ND4OXROObGMjlC0Xw3NbjKM6xD3pzh9IMXr733nv47ne/i7vuuivT50NENC7pB1jod9tE5qX4Y5Yr/qipwcvGLh/a+gKwqQtfvSnleVhUX4wtx7rx8bMmQZIklOYpAzSiMnCix6e+tkULjgLK0J+PLK3HqZNKEvahnFCSo2VzAoPveZmI6OHJ6ZlE45MYTnFAzdQWxNRfvR5vCFsau3HejHJDDzqxudPtZa9MovGgXR34BwCv7GzGPaH5CdcB3/nXNjR1+3DmlFLMqCrQgpcVpj6SyYjgZVSOfeYUqAHAL54/DVuOdeEDi2qH9LNIUv9BxdJc4xopWX/NU+qLY8FL3b9FeX4seFnI4CXRuFalfra9vrsV97+2H/lOG7b9v8tG+azGjrSaEVVXV6O0dHDN3ImICOjyxhbyAV0/S3PZuDnzUvS7nFdbmHDh/8BNi/HLDy7EzadPBKD0ZxL9lo51ieClVVvgA0BJrgNnTC1L2oOqODe2eJYk5fh0FWhl4+x9RzQeJervBiSeOP7zl3fjUw+vw3NbjxvuF5+DvX4GL4nGA33mpTsQxpt7WhMe1+FRjjvUrkzbbk0181JdWkSi8ZmX37tyNp689axh3zwdbLakmDjusFoM6y992TkzL4nGtxlV+QCA7U09AJTPR1HpRgNL62ryjjvuwF/+8he43e6BDyYiIvToMopEv0kglmEpgpaxnpfK/ZuPdgNQhvUkMrEsFx9aWm/IoBRl3sc6lWbQLrvV0Gjeaev/o19kSwJKRoF1CFPCp6t/pKeU56f9GkSUvUpSCF6Kz7M1avaRIHr89jF4STQu6IOXAOI2LAAl4Ch6czd1K5utrWrVx2CDl2Lw4MYjXdrAngJXWoWFaZtWObiy9KWTS5Bjt2KKqYy9Ip/BS6KTxcyq+OuhTUe70GH6zKTE0vp09/v9sNvtmD59Oj784Q+jvr4+btq4JEn45je/mZGTJCIa67oNmZex4KU/bmCP6IWiXMxvPKL2u5yUOHiZSEWBE3ua+3BMnWQnel6u/cHFsFstSUuahHxdf8yhDOsBlD6db9xxPuqSTOkkorHNnHmZ67DCG4xogQQhGpW17CqRcSDuFwGMXl98qTkRjT3tfcqa55QJRdja2IPX97Sizx8ybI56dIMJm9RKES3zcpADe9Ye6gQA/HX1Ydxx6UwAQKFrZAOA+oGI/SnPd2Ll7ecZ1ljifoHBS6LxbVJZHuxWCaFIbP7Bt57aCn84ghsmSVg+iuc2FqQVvPzWt76lff273/0u4TEMXhLRySQalbG9qQdzagoNUy6Fbl+SzMuQMfNSXzbuC0a0Cb6nphK8VBfCRztE5qVyPlWFg+shpc9aGEq/S2FqBbMuicYrc+bl9Mp8bGvsicu8bOr2af1+9zb3wR+KwGW3wh+OtdFg2TjR+CAyL8+bWQF3IIyDbR68srMFHzx1gnaMNxD73ReZl1rPy/zBrVf0OtVN4pHOvEzFhJL4oT8sGyc6editFkwtz8feltiQwz6173d9npzsaaRKq2z80KFDA/6voaEh0+dKRJS1nt7UiGt/vwq/eX1fwsf1gyj0mZeiPFzrean+/+aj3djW2I1wVEZVoRO1RYNfyIuFsGhen2q/J31mxGCnBRPRycmceTm9UtmsMGdeNqhZlwAQjsrYq04nF60zEj2HiMYm0cuyPN+JD5xSByC+dNyQedmdXual3tZj3QCMa5iRcrua9XnvjQtSfq4heJnL4CXReHfBrApYJGBWVYF237SKPNQz12NAaW1NTZo0KdPnQUQ0pm04rJR37zrem/Bx47Tx2MW635R5qS+n3KgO61kysWTAUm+9ClOvqMFmXAr5uqyF8gxkXhLR+KXPzrZbJUwuU/q5mTMvG9qMfdK3NfXglPpiLfscSDyhnIjGHlE2XpbvwHkzK/A/r+3DqgPtaHcHtDJpT8BYNu4PRbTPjcH2vJxfV4gdTcq6a5PaU7dwFDIvv3bRdHzktPqU11uAsWy8mJmXROPety+fhVvPm4rH1x7F3pXKRu7U8jwAPf0/kdLLvCQiIiOR/t/cm7jhsn7aeFAtnQxFolrPE5FxefXCGu24jWpANJWScSA+eDmxNL5MqT/i/ACgeAiTxolo/NMHL60WSSt7NJeAN7QpmZd2q7IRs6NRWaTrMy85sIdofBBl4+X5Tkwpz8PCCUWIRGWs2H5CO8ajKxvv8ATRqPbpdtgsgy6ffvzzZ2JymXGNMxqZl5IkpRW4BFg2TnSysVktKMt3okpXVVfP2QCDwuAlEdEQRaMy9ovgZY8v4TGGsnE1OOjXZRyJ0u664tgfrzf2tgJIPmk8mfL8oQUv9URGKBFRIvq2FP5QVLv4jsu8bFcyL8+fWQlAybwEYMy85MAeonGhTRe8BIAPnFILAHhpe7N2jDdo/H3frGZOVuQ7B11tUuiy4zPLphjuy+ael4mIoKdFAgoZvCQ6aVTrNjzqSxm8HIyx9elORJSFmrp98KjZQ13ekDaIQk9/IS8yG8VFu0UCnOqQH5vVgpJcO7q8Icgy4LBaML+uMKXzGWrmZb4zdu7OBMOHiIiSSRS87PQEsepABwDg2kW1eG13C/a3KEN7vEEO7CEaT/yhCPrUFhBigOCZU8sAALtO9EKWZUiSBHfAFLxUe1aa1zADmV1tXCONtQBgaZ4D3758Fpw2S8o9yolo7NJna08oyYG3YxRPZozgVSkR0RDtb+0z3G7p9ccd060rGxc9L326YT36LAP9kJz5dYVw2lJbzFbonu+0WVCcYgP4uTVF2tdcSBPRQG5YogzkuHRuFQpzlH1xfRblk+uPal+fN6MCZXkOhKMy9jT3mTIvGbwkGus6Pcp6x26VtM+D6ZX5sFok9PhCaFHb6+g3LgBgi5p5Odh+l4J+6AUw9jIvAeArF07H586dOtqnQUQjSJ95mern3smKwUsioiHa22wcRHGixxi8jEZl08AeY+aluTRbnym5JMWSccDYMynPaUtp2A8ALJhQpGVcXqXrwUlElMjd187Hz29YgF/cuDBh5uWxTqWdxmmTS1CUa8f8OmWDZHtjt6HnpScYQTgSBRFlv2Od3rjsSSDW77IsL1b+7bJbtd6Ue5qVATse03PF/alOGi/KtaNG1zuucBR6XhIRpaowx4alk0owozIfMyo5anwwGLwkIhqifS39Z172+cOIyrHbWvBSvWg3ZzfqB/QsSXFYDwBYLLFgZU6amZN7fnIFDv73cl4EENGA8p023HT6RJTkObSSzV5/CFH1g0/0Ar5xyQQAwMIJavCyqccQvASQMBhCRNmlucePi+57C59+eF3cY9qwngLjwD9R3r23WVkz6Qf2ANDWSZUFqQ++mVqRp33NdQsRjQWSJOGfXzwLr9x2HuxWhuUGY0j/StFoFE899RRuvfVWfPCDH8SXvvQlPPfcc5k6NyKiMUEsxAvVUiVz5mW3L2i4HZd5aQowLqov1r5OddK4WboDdyRJgtWSWsYmEZEIHMgy0KcGIsVnYrWaHSUyL7c19sAbMgYwOLSHKPvtae5FKCLjULsn7rH2PmXNYx4eOKtaKe8WayYxsKfaNKU7nfJJ/bDD/DFYNk5EJydJkgxJJ9S/QQcv586dixdffFG77fF4cMEFF+CjH/0oHn74Ybz77rt46KGHcP311+Pqq69GJBLp59WIiMaHSFTGgTalbHzZjHIASkaCnn7SOBAb2COmjeeaAoxLJ5fg9CmluHphjaGZcyry1Ne8YGZFWs8nIkqHy27V2k6IHpYiG72mSAkwiMzL/a1udHuMmzsc2kOUfWRZxpcf24jbn9oCWZa132lz30ogftK4oAUv1WoVjxq8nFFlLJdMdWAPYMy25MYrEdH4NOjg5Z49e9DT06Pd/u53v4v33nsPP/3pT+F2u9HS0oKenh7ccccdWLFiBe67775hOWEiomxypMODYDgKl92C0yaXAkgQvDQNoYgN7FGCmOaycafNiqe+cBZ+97ElaZ/Xs185B3dcOhO3XzYz7dcgIkqHvu+lPxRBl7qBIzKsqgtdKM93IBKVselol+G5HNpDlH06PUGs2N6Mf29qQltfAM09SoDSF4po7SGEDreyIVGWby4bV4KX+1vdCEei8Kpl4zNNA3fSKRuPyPLABxER0ZiWdtn4E088gU9/+tP4/ve/D5dL+SOTn5+PX/ziF7jyyivx97//PWMnSUSUrUS/yxmVBVpWUXOvOfPSmFkkMi9FyVS6pd39mVFVgK9dPAO5DpZPEdHI0vpe+kLaZk6O3apNHpYkCQvU0vF1hzoNz2XmJVH20WdY7mnuQ3OvT7vtDxuzL0XPywpT5mV9SS5y7FYEw1Ec7ogN+5lclguHrt9bqgN7gPgKFiIiGn/SCl729fWhq6sLV1xxRcLHr7jiChw4cGBIJ0ZENBbsa1FKxmdWFWjTLpt7/Fh9oB1n/ex1rNzVElc2Lnpe9vqVhXseA4xENI4U6Yb2NGsl4y5t8jAALXjpCbLnJVG28+l60+5r6TNUmJhLx9uTlI1bLBJmqiXie5v7tOcVuOyoKVbWT5IElOUZMzYH43PLpmJuTSF+sHx2ys8lIqKxIaXgpVh05uXlITc3FxZL8qdbrdwBI6LxT/RumlWdrwUvW/v8+PQj63Gix4/PP7ohLni5rbEbvmAEDWqvzElluSN70kREw0hfNi6CHOb+vWJojxkzL4myT3zmZUC77QtGEI5Etd/11Qc7AMQHL4FY38tdJ3q0ypV8p00buFOW54Qtjam7JXkOrPjGubj1vGkpP5eIiMaGlP46fPazn0VhYSGKi4vh9/uxadOmhMft2bMHtbW1GTlBIqJstk+dmjmzqgBl+U5YLRKiMhCMRLVjxLTxG5bUoTTPgX0tbnzrn1txoFUJXk6vzI9/YSKiMapQnfbb4wtpk8bF5o6wcEJxwueKjHQzmT3tiEaNaHMDKFmTLbr2OL5QBJ/9vw0482evY40auAQSD86ZVV0IAHh1Zwta+wL/v737Dm+rPPs4/tP03juJHWfvECBkMEMIBEiBFiibhpQOaNIyWkoptKyyW1YZBQqhUHZfKAXCCCFhZkDI3svZtpM43kuWzvuHhqXYTmx5SJa/n+viQjrn6PiW/cQ6vs/93I8sZpPG90/1JS+DWawHANAztHqu4vTp05ts85/+41VZWanXXntN55xzTvsiA4AwV9/g0rb9VZLcyUuL2aSshCjtOWTBnjJP5eWQrARdclyeLv/nIn2waq9v/4AMkpcAIodv2nhNgyo8lZTZhyQvsxKjZDJJ3pxkWpxdB6rqm12wZ8GGYv36tWW6//zRmjY6p3ODB9BEjV/l5YaiCl/vbsldlfn5xn2SpGe/2OLbPq5fapPz+C/aI0kpsTYlRNvUO8WdvMwkeQkAaEGrk5ezZ89u1XE2m03Lli1TcnJysDEBQFhpcLp0zb+/V0qsTbdOG6bkWHc/pm37q9TgMpQQZfVVFWUlRTdJXhYccCc4k2NtGtcvVX/54Ujd/H+rfPsHUHkJIIL4TxsvrnD/Pjw0eWkymRRnt/oW7chKjHYnL5uZNn7V7G8lSTNf/V7TRk/rzNABNMN/2rh/4tK9r7Eq0+m5GZEYbW2h8jJwZXGzpxDmlMEZev6rbTptWGZHhQwAiDBBrzbekqioKPXt21dJSc33MgKA7mbb/ip9uq5Iby3dpZv/b6Vvu7ff5eDsBF8l+qFTIyVp5a4ySfIlPS8+Lk/5fn0u46NYsAdA5EhspudldmLT340xfisEZ3lWGGbBHiD8+C/Ycyj/vt4ulzt72dJ1TXp8lNLjGxfksXoSnEfnpWjFn8/QTybmd0C0AIBI1OHJSwCINHV+VQafb9znqzpo7HfZWDl56KIUktTguZhP9vxBLzWtQgKASOGfvGzseRnT5Li4gOSl+3diBQv2AGGnpr7l5OX2A9W+x1WeKsz46JZvyvpXX1osjdWZ5mYqNQEA8CJ5CQBH4J+8rHW4tHJXqSRpfWG5JHcvSy//ystDqzC9lZeSdNu04eqdHKOHLzqqM0IGgJDxThsvqarXvkr3qsRZSU172cXYGxMcmZ7kZUsL9gAInerDJC93lFT5Hhd5blbEHWZGyZCsRN9jSzPrJwAA0BySlwBwBLWHTJdatNW9mubaPe7k5YjejW0y/BOUvzp1YMDrkmMbKy9H9k7S13+YrPOP6dPh8QJAKCVGu3/XbS6ulGG4p4amxzVNXvpXXnqnlTe3YI+X3cplK9BR9pbVaOYr32vp9pIjHlvjqaj0b3nj5V95WVzhvllxuHY4Q/0rL6m2BAC0EleBAHAEmzy9Lb0WbS3Rwap638I8/hfilX5VQ+eN6aUEvwv4JL9p4wAQqby/67x98rISo5udEtpsz8tDpo17e+hJUqrfzSEA7XP968v1waq9uuDphUc81lt5eXReSpN9/snLhiP0vJQCp41bzfwpCgBoHT4xAOAIVnsqLM8amS1JWrr9oFZ4po73TYtVQnRjUnJ3aY3vcWK0Tcf0bbzQj7Y1/qEOAJEqKTbwRk1LPX5jm+l5WVnXEJCw3F9V53uccJg+egDaZvG2I1dcelV7bkT0TYv1zSLxLrzjf93jdbjk5SC/PuEHq+tbHQMAoGcLKnlpsVj06quvtrj/jTfekMXCH+kAIsPq3e7Vwn94dG+lxtlV43DqjW93SpKG5yQGHHvpuDzF2S365cn9JTUmPAGgp0g8JMnYUvLSbm28Vsz0VF4ahlRZ31jB7l2tXGqs6gLQPobRtn9L3gV7Yu0WnTUyRwlRVh0/IL3F4w/X8zLWr9etd5o5AABHEtQt7CN94DmdTplowAwgAtQ6nNpUXClJGtU7SeP7perD1YX6cHWhpKbJy4GZ8Vp++xmyWdz3hi4am6sDVfVNjgOASBUfZZXFbJLTk2zMSWw+eel/pZgUY1OU1ay6BpfKqh2+vpl7ShuTl4db8RhA6232XNdI0qDM+MMc6eb9txdjt+q+80fprvNG6Mn5m1s8/khV0v6/HwAAaI2gp423lJwsLy/Xxx9/rPT0lu/GAUB34HIZeuCj9XK6DKXG2ZWTFK0J/dMCjhnYzEW/N3EpSWazSTNPHahTh2Z2erwAEA5MJlNA9WVLlZf+l5J2i1m9k2MkSY/M3ei7UV5Y1jgltcZB8hLoCF9t3u973JqKZu+08VhP+xubxRzQ9uFQh6u8lFqXMAUAwF+rk5d33nmnLBaLLBaLTCaTrrjiCt9z//9SUlL08ssv65JLLunMuAGg0726ZIdmf10gSRrRK1Emk6lJ8pI+lgDQlP8CZS0mL/0fm0z60znDZTGb9Pay3br/o/WSpAK/xUDKahwqq255NXIArfO1X/Kyqq7hMEe6eVcb909YxthbTlAeruelJOW08DsBAICWtHra+Lhx4/SrX/1KhmHoqaee0umnn67BgwcHHGMymRQXF6djjz1W559/focHCwBd6cVvCnyPvX+ID8qMV0qsTQc9f0DTIQMAmkr0T162MG38UKcOydQDF4zW795aoWc+36rclFh9uWlfwDFr9pTp+IHM7gFaq7q+QXe/v1ZnjczRyYMz5HC6tGhrid/+I1c0e4+J9ktexh7m5u2RkpfXThqo+Rv2adqonCN+bQAApDYkL8866yydddZZkqSqqipdc801Gj9+fKcFBgChVFxeqy37GntC/XhsriT3NPBx/VL18ZoiSVJGQlRI4gOAcFbncPkeD8lOaPYYczN3fy48to/2lNbo4bkb9dDHG1RW45DFbNL4fqn6ZssBrdlTTvISaIPnvtim15bs1GtLdqrg/mlauatUlX7VllX1DTIM47DrFfgW7PFLWB5u2viRkpfj+qXqu9umKDXW3tq3AQDo4YLqeTl79mwSlwC6pb1lNVq6veSIx32waq8Mw93T8q1rJuqUwRm+fZeN76ucpGhdd9ogFuIBgGZsLK7wPU6ItjV/UAu5kguP7SPJPU1cko7OTdZET8uO1XvKOi5IoAfYdbA64PlXmw5Ikk4d4r6uMYzD95NdsKFYW/dXSQpcKTzGL3k5vl+q7NbGPyuP1PNSktLjo2Q2M30FANA6QSUv582bp4ceeihg2wsvvKC8vDxlZWXphhtukNNJU3UA4eekB+brgqcXavXuwD+ADcNQcXnjqrbvrdgjSbp8fJ6Oy08NOPaUwRlaeMtpuuH0wYetVACAnmpotvvGTr/0uBaP6Zva/L7sxGhF2xovUU8enKGRvZMkSWv2lHdglEDks1kD/9zz9rucMjzL1/qm8jB9L6+a/a3vsX/C0j+RedKgdCX4JSyPtNo4AABtFVTy8o477tCKFSt8z1etWqVf/vKXysjI0KRJk/T444/rr3/9a4cFCQBttbesRg99vF6FZbUB272ravqvtClJD368QePunaf3VuzRzpJqfb+jVGaT6McEAEF49OIxuuCYPnrpp+NaPOYXJ/fXZePzmhxjNpuUn9aY2DxlcIZG9HInQ7fuq1R1/ZEXGAHgZrc0/rlXUevQ9zsOSpJOGpihOE8CsrqudUUn/lPF/SstTxiYrni/hGVrKi8BAGiLoJKX69at09ixY33PX375ZSUmJurLL7/UG2+8oZ///Od66aWXOixIAGirq174Vk/O36Lb/rvKt83hbOzBVnvIFKmnF2yRJP353dV6f+VeSdKE/mnKbOVCEwCARkOyE/S3i45Sbmpsi8fE2C2690ejdLJfWw4vb/IyJdamkb2TlJkYrYyEKLkMad3eiibHA2ie1W9q9ty1RWpwGcpNjVFeWqwvGXm4ykt//snLtLjGfpWj+yQH9Lk8Us9LAADaKqjkZVVVlRITG/u8ffTRRzrzzDMVG+u+QD3uuOO0ffv2jokQANqoqq5BG4rcf9z6r6hZ6lkhXJLW7inXJc8ubLKSba3D5Zsyfs5RvbogWgDAoQZmxkuSThqUIYsn+eKtvlxL30ug1er9btx+4Lk5e6Jn0StvktG7mviHq/bqsucWqbi8VnUNTu04ENgv03/aeG5qrJ77yVi9N+tEWcwmkpcAgE4VVPIyNzdX337r7n+yefNmrV69WmeccYZvf0lJiaKiWIEXQGiM/cunvsf56Y1VP6XV9b7Hn6wt0qKtJbry+SUBr61xOLV2b7msZpPOHJHd+cECAJqYcUK+fnFyf91y9lDfNm/ycvVu+l4CreVfVfmF54btCZ7kZWyUOxlZVd+g0up6/f7/VuqbLQf0waq9euijDTr5ofm+1/7hrKGKsgauMH768CyN6uPuR+vtc2kxmwJ61gIA0BGCui12+eWX66677tLu3bu1Zs0apaSk6LzzzvPtX7p0qQYPHtxhQQJAW/ivmmk1N15Al1TVN3d4s04enKEUvylRAICukxYfpT+ePSxg28henkV79lJ5CbRWlV/y0uF09/0+foAneenpeVlV16CnF2xRRa372L1ltfrnV9t8r7NbzPrlyf0P+3W81ZZxdguLGQIAOlxQyctbb71V9fX1mjNnjvLy8vTiiy8qOTlZkrvqcsGCBbruuus6Mk4ACIp/wvKg37TxIznnKBbqAYBwMsKTvNxYWKn6BtcRjgYgNU4J9xrRK1Gpnpuz3oTj5uJKzf6mwHfMntKagNekxtmPmJD0LtjDlHEAQGcI6tPFarXqnnvu0T333NNkX2pqqgoLC9sdGAB0hIN+yUv/aeP+GpyBfwRPGpKhH4ym3yUAhJPc1BglRFtVUdugzfsqQx0O0C0cuhiPt9+l1LgAz3NfbFV9g0sxNotqHE4VltUqMdqqck8lZmF57RG/TnyUzf3/aJKXAICOR0MSABGtoq7BV6FT0kzycnBWvO/iXJJ+flI/PXvlWNks/HoEgHBiMpkaF+1hxXGgVaoOSV6e4Je89FZJVnmqM397hrvt186D1QHXRjbLkaeBe3texlF5CQDoBEF/utTW1ur//u//9P3336usrEwuV2Dlkslk0vPPP9/uAAGgvUqr65WZGB2w2rhXg8tQWY17e5zdolunDe/q8AAArTSyV5IWbS3R2r0VGktbPeCIquoap43bLWYdl5/qe+7teSlJU0dkadroHP3lg3UqKq/zbR+anaDfnznkiF/Hmwhl2jgAoDME9emyfft2nXrqqSooKFBycrLKysqUmpqq0tJSOZ1OpaenKz4+vqNjBYAjcrmMJtsOVLmTl80t2FNT7/QlL5NibJ0eHwAgeCN6eyov95RrbO8QBwN0A1X1jRWUx/ZNUYy9ccXweM9q42aTdNPUIcpMiJbFbJLTcy2VGmfXR9ef3KqvMyY3WXarWeP7pR75YAAA2iioeZE33XSTysrKtGjRIm3cuFGGYeiNN95QZWWlHnjgAcXExOjjjz/u6FgB4IgcflXgWYlRkhr7Xnp7Xp43ppfOGJ4lyT2dqtyTvEwkeQkAYc27aM+6wgo1c68KwCG808YnD83UH84aGrBvUFaCJOny8X01MDNBFrNJWQlRvv3ehX1a46jcZK264wzNmjyoA6IGACBQUJWXn332mX71q19p3LhxKikpkSQZhqGoqCjddNNNWrduna6//np98MEHHRosAByJ/wq02YnRKiqv8/W69K42ftbIbB2Vm6xP1hap2q/ykuQlAIS3/ulxiraZVV3v1L4jryEC9Gj1DS45nO4s/yMXjVFSbOB1zrRRORqSnaCBGY0z5nKSY7SnzP2Pqy3JS0mKslqOfBAAAEEIqvKyurpa+fn5kqTExESZTCaVlZX59k+cOFFfffVVhwQIAG3hvUiXpKzEaEmNlZfe/yfH2n19nhpchvZXuns7MW0cAMKb1WLW0Gz31PHdVTS9ROSqa3Bq+gtL9OT8zUGfw3+xnriopolFs9mkwVkJMpsb/y3lJEX7HqfHty15CQBAZwkqeZmXl6ddu3ZJkqxWq3r37q1Fixb59q9du1bR0dEtvRwAOo238tJqNikt3j316YA3eempwEyJtSs+yupbPXPrvipJJC8BoDsY6el7uYvkJSLYsh2l+nzjPr34TUHQ56j0JC+jrGZZLa37s88/ednWyksAADpLUNPGJ0+erHfffVe33367JOmqq67Sfffdp4MHD8rlcunll1/WT37ykw4NFABaw5u8tFnMSo1zJyMPVtXL6beqeEqcTRazSbkpsdq6v0ord5VKkhKjSV4CQLjz9r3cWRXiQIBOtOtgjSSpsrbhCEe2rLrevdJ4W1YAz0uN9T1Oi4s6zJEAAHSdoJKXf/jDH/Ttt9+qrq5OUVFR+uMf/6g9e/boP//5jywWiy677DI9/PDDHR0rABxRvdOdvLRbzUqJdVcMlFQ7VF7j8C3ukBzj3p6fHqet+6u0dm+5JCovAaA7GNGrcdq4YbBqDyLTroPVkqQah1NOlyGLue2Vxt7Ky9hmpoy35PiB6b7H/iuTAwAQSkElL/Py8pSXl+d7Hh0drX/+85/65z//2WGBAUAw/Csv0zy9mg5W1fumjMdHWWW3uqdO5afFSWrsk5kUE9SvRABAFxqclSCr2aSqBmlvWa36ZjC1FZFnt6fyUnInIYO5werteRlnb/31Tf/0ON/jOJKXAIAwEVTPSwAIVw5P5WWUX+Xlgap630rjKXGNF//90mMDXstq4wAQ/qJtFg3McCdY1u6tCHE0QOfY5Ze89F94py28r2vLtHGTyaSXrx6nKyf01UXH5Qb1dQEA6Git+iS766672nxik8mkP/3pT21+HQC0h/+0cW+j+YNV9b6Vxr0JTck9bdwf08YBoHsY3itR64sqtXZvuc4a3TvU4QAdbldpte9xZbDJS0/Py9g2JC8l6aRBGTppUEZQXxMAgM7Qqk+yO+64o80nJnkJIBQap42b/HpeNk4bT/ZPXqaRvASA7mh4ToLeXiat2UPlJSKP02Vob2mt73nQyUtf5SXTvwEA3Vurkpcul6uz4wCADuFfeenteVnf4NLuUvf0q9TYxgRlr+QY2S1m32tIXgJA9zA8x71oj3fBNSCSFJXXqsHVuBiVd8VxwzBkMrV+4Z7KIHpeAgAQjuh5CSCi+C/YE2OzKMqzOM+WfVWSAisvLWaT8tIa+17S8xIAuodhOQmSpMLyOh2orAtxNEDH8u93KbkrKJ0uQz/+x0Kd+8RXcvolNg+nut6TvGzjtHEAAMINyUsAEcW7YI/dYpbJZPL1vdxSXClJvude/lPHqbwEgO4hPsqqjGh3AmfNHqovEVl2+/W7lKSKugYt3npA320/qJW7yrS/lQn7qjp3z8s4po0DALo5kpcAIsbOkmrNenWZpMYkpbfv5ZZ9lZ7ngQlK74rjdqtZ0TYu7gGgu+gTR/ISkWlXSWDlZWVtQ0CLhNb2wPRNG6fyEgDQzZG8BBAxpr+wxPd4TG6ypMYkZp1nOrn/tHGpccXxxGiqLgGgO/EmL1fvKQtxJEDHam7aeLVn5XCpsQfmkXinjceTvAQAdHMkLwFEjK37q3yPj+mbIqnpNPFDnw/JcvdNy0qM6uToAAAdqben68fGQlYcR2TZU+ZOXiZ7ZotUHpq8bHXlpfs1sSzYAwDo5vgkAxARvAv1eI3qnSSpabIy+ZBp48f2TdG9PxrlOx4A0D2k2N2Vl8UVLNiDyLLPM6YHZMRr6faDqqxrkNXcuMp4RSsrL6vqvJWXtMUBAHRvVF4CiAibihsrbx6+6Chf/8qUQ6aJH/rcZDLpsvF5GtWH5CUAdCcJnntRZTUO1TU4D38w0I14k5f9PK1tDq28rGpF5WV9g0ubPYsVZiQwuwQA0L0FXXm5bt06zZ49W1u3btXBgwdlGEbAfpPJpHnz5rU7QABoDe+CDRP7p+n8Y/r4tqfGBVZaHpq8BAB0TzFWyWYxyeE0dKCyXr2SY0IdEtBuDqdLB6rqJTUmL6vqGtTgt6hga6aNf7V5n8pqHMpIiNKY3JTOCRYAgC4SVPLy5Zdf1owZM2Sz2TRkyBClpDT9QDw0mQkAnWnNbveCDSN7JwZsP8qzcI9XjJ2pUwAQCcwmKS3OrsLyOu2rqCN5iYhwoNKduLSYTeqT4h7TFbUNcvn9adWa5OV7K/ZKkqaNypHFb8o5AADdUVDJyzvuuENHH320PvzwQ6Wnp3d0TADQZt7KyxG9Aqd/j+6TrBMHpuurzftDERYAoBOlx0f5kpdAJPCO5bQ4uxJj3LNHquobZPLLPx6p52Wtw6lP1hRKks45qlfnBAoAQBcKquflnj179NOf/pTEJYCw4HIZWrvXm7xMbLL/qSuO0blH9dK9PxrV1aEBADpRery7Fcj+SpKXiAz7KmslSZmJUUqIcteZVNY2qKYNPS8/W1+sqnqneifH6Ji85E6LFQCArhJU8nL06NHas2dPR8cS4P7775fJZNL111/v21ZbW6uZM2cqLS1N8fHxuuCCC1RUVBTwuh07dmjatGmKjY1VZmambrrpJjU0tG5FPgDd07YDVaqudyraZlb/jPgm+xOjbXr80qN12fi8EEQHAOgs3oVIqLxEpPCO5Yz4KMV5k5eHLNhzpGnj761w/532g6NyZDIxZRwA0P0Flbx8+OGH9fzzz+ubb77p6HgkSd9++62eeeYZjR49OmD7DTfcoPfee09vvfWWPv/8c+3Zs0fnn3++b7/T6dS0adNUX1+vb775Rv/617/04osv6s9//nOnxAkgPHinjA/LSaSvEwD0IOlx7srLfVReIkL4kpcJUYpvIXl5uGnjFbUOfba+WJJ0zmimjAMAIkNQPS8feOABJSUl6aSTTtLw4cOVl5cniyVwEQyTyaR33323zeeurKzU5Zdfrueee05/+ctffNvLysr0/PPP69VXX9XkyZMlSbNnz9awYcO0aNEiTZgwQZ988onWrl2rTz/9VFlZWRozZozuvvtu3Xzzzbrjjjtkt7PKMBCJfIv1HNLvEgAQ2dI9lZdMG0ekaC55WetwqbzW4Tumss7R7Gsl6dN1RaprcKl/elyzrXQAAOiOgkperly5UiaTSXl5eaqsrNTatWubHBPsFIWZM2dq2rRpmjJlSkDycunSpXI4HJoyZYpv29ChQ5WXl6eFCxdqwoQJWrhwoUaNGqWsrCzfMVOnTtW1116rNWvW6Oijj272a9bV1amurvGit7zcXcXlcDjkcLR8cdAded9PpL0vdJ5wHjOzXluuooo6RVndReRDs+LCMs6eJpzHDMITYwZt5R0rKTHum+fF5bWMHxxWd/k9U1Tu7nmZGmuT3dy4xLh/tWVlbUOL7+OLjfskSVOHZ9I6q526y5hBeGHcoK0ifcx01PsKKnlZUFDQIV/8UK+//rq+//57ffvtt032FRYWym63Kzk5OWB7VlaWCgsLfcf4Jy69+737WnLffffpzjvvbLL9k08+UWxsbFvfRrcwd+7cUIeAbibcxkx1g/Tx2sBfYQe3rtSc4pUhigiHCrcxg/DHmEFbbVu3QpJVBUUHNWfOnFCHg24g3H/PbNxhkWTSzk1r9GnJatnNFtW7AotCikrKWhzv67aaJZlVtnuz5szZ1PkB9wDhPmYQnhg3aKtIHTPV1dUdcp6gkpedYefOnbruuus0d+5cRUdHd+nXvuWWW3TjjTf6npeXlys3N1dnnHGGEhMja7qFw+HQ3Llzdfrpp8tms4U6HHQD4TpmVuwqk75d7HtuNZt01fln+qowETrhOmYQvhgzaCvvmDlr0gn6+5rFqjGsOvvsqaEOC2Gsu/yeeXjDV5KqdcZJE3Rcfooe3vCVtpcE/uFnWKN09tmTmn39s9sXSmUVmjRxrE4dktH5AUew7jJmEF4YN2irSB8z3pnN7dWq5OWOHTskSXl5eQHPj8R7fGssXbpUxcXFOuaYY3zbnE6nvvjiCz3xxBP6+OOPVV9fr9LS0oDqy6KiImVnZ0uSsrOztWTJkoDzelcj9x7TnKioKEVFRTXZbrPZInLwSJH93tA5wm3M7CytDXg+KCtB8TFN/x0jdMJtzCD8MWbQVtnJcZKkqjqnHIZJsfawuS+PMBXuv2e8/VtzUuJks9mUmRjVJHlZWdfQ4ns4WOWenpeZFBvW77M7Cfcxg/DEuEFbReqY6aj31KorvPz8fJlMJtXU1Mhut/ueH4nT6TziMV6nnXaaVq1aFbBtxowZGjp0qG6++Wbl5ubKZrNp3rx5uuCCCyRJGzZs0I4dOzRx4kRJ0sSJE3XPPfeouLhYmZmZktylt4mJiRo+fHirYwEQ/rbtqwp4PpKm9ADQ48RHWRRlNauuwaX9FfXKSyN5ie6rqq5BVZ5VxTM8i1F5/++v1uFSRa1Dv3rle2UlRutPPxiupBibDMPQgap6SVJqLAuVAgAiR6uu8F544QWZTCZfxtT7vCMlJCRo5MiRAdvi4uKUlpbm23711VfrxhtvVGpqqhITE/XrX/9aEydO1IQJEyRJZ5xxhoYPH64rr7xSDz74oAoLC3Xbbbdp5syZzVZWAui+tuwPTF4OJ3kJAD2OyWRSRkKUdh2s0b7KOuWlRWavcvQM3qrLGJtFcXb3YlSZCY3ttJJibCqrcVdW/nf5Hn25ab8kaeGWA3r0kjEanpOougaXJCk1nuQlACBytCp5edVVVx32eVd55JFHZDabdcEFF6iurk5Tp07VU0895dtvsVj0/vvv69prr9XEiRMVFxen6dOn66677gpJvAA6z6GVl335gxUAeiRf8rKiLtShAO3iHcOZiVG+QhH/ysteyTGqcThV3+DSf5bukuTu+b27tEYXP7NQl4xzt+yyW82+5CcAAJEgrOfWLFiwIOB5dHS0nnzyST355JMtvqZv376sNglEOMMwtM2v8nJYTqJOGJgewogAAKGSHu9O7uzzVK2VVNVrc3GljstP6fCZQkBn8iYvM+IbE5ZJMY29wn49eaD+9N/VOtBQrxU7SyVJL109Tv9Zuktvf79bry52r0uQFmdn7AMAIgrL8gLodorK61TjcMpiNmnTPWfpw+tOUpSVCgMA6Im8lWn7PYmfMx/9Qhc9s9A3pRboLrwJeP9qy4kD0mSzmHTOUb101shsxUc31p70SorWxP5peviiMXrskjFKiHLvS4+nXRYAILKEdeUlADRn+wF31WWflBjZLNyDAYCeLMOv8tIwDBV7kpjfbDmgkwdnhDI0oE18lZd+ycsBGfFacfsZirFZZDKZFGdv/PNt6shsX4XleWN665i8FD0+b5Omjsju2sABAOhkJC8BdDt7ymokSb2SYkIcCQAg1NI9iZ59FXUBfS/zUumFjO6luWnjkhTrl7D0r7w885AkZW5qrB768VGdGCEAAKFByRKAbmdPaa0kKSc5+ghHAgAinTfRs7+yThuKKnzbzbT8QzfTXOXlobxTw9Pi7Bqbn9olcQEAEGpBJS9feuklFRQUtLi/oKBAL730UrAxAUCALfsqdcf/1qi43J20bJw2TlUNAPR0GX6VlxsKG5OX9U5XqEICglJU4b7OOVzPygRP5eUZI7JkIUMPAOghgkpezpgxQ998802L+xcvXqwZM2YEHRQA+Lv4mYV68ZsCzXptmST5/jgdmp0QyrAAAGHA1/Oyok67Dtb4ttc3kLxE9+FwurSpqFKSNCAzvsXjLp/QV6cOydC1pwzsqtAAAAi5oHpeGoZx2P1VVVWyWmmnCaBj7K+slyQt2VaiqroGbfRc3A8heQkAPV56gl2SVNfg0ka/aeOl1Y5QhQS02aaiStU1uJQQbVXfw/RrPS4/VbNnjOvCyAAACL1WZxhXrlyp5cuX+55/+eWXamhoaHJcaWmp/vGPf2jw4MEdEiAAHJWbrBU7SyVJFzz9jWocTkVZzcpPiwttYACAkIu1WxUfZVVlXYNW7irzbX9i/mb9buqQEEYGtN7q3e6xO6p3ksxMBwcAIECrk5fvvPOO7rzzTkmSyWTSM888o2eeeabZY5OTk+l5CaDDZPo1rl/vmTI+KCueXk8AAElSerxdlXUNqqxremMd6A5W7i6V5E5eAgCAQK1OXv7iF7/QD37wAxmGoXHjxumuu+7SWWedFXCMyWRSXFycBgwYwLRxAB3G27csOzFahZ5Fe4ZkJYYyJABAGMlIiFLBgeom2w3DkMnEjS6Ev1WequFRfUheAgBwqFZnGHNycpSTkyNJmj9/voYNG6bMzMxOCwwAvLzJyyHZCb7kJYv1AAC8MhKaX535QFX9YVduBsJBfYNL6zwzS0b3Tg5tMAAAhKGgyiNPOeWUjo4DAFpU73QnL/P8GtizWA8AwMs/QRlrtyjGZtGBqnptP1BF8hJhb2NRheobXEqKsSk3NSbU4QAAEHaCntv98ccf6/nnn9fWrVt18ODBJiuQm0wmbdmypd0BAoC38rJXcuMFPZWXAACvDL8EZZTVrCHZCfpmywEV7K/WsX1TQxgZcGSr/Bbroc0BAABNBZW8fOihh/SHP/xBWVlZGjdunEaNGtXRcQGAjzd5OTQnQUOyEpQSZ2txiiAAoOfx/0yItlnUNy1O32w5oO0HqkIYFdA63uTlSBbrAQCgWUElLx977DFNnjxZc+bMkc1m6+iYACCAd9p4nN2qD687SSaTqEwAAPikH1J5mZ/mbjOyrZlFfIBw412sZzSL9QAA0KygkpcHDx7UhRdeSOISQJfwVl7arWaZzSQtAQCB/Csvo6zuyktJVF4i7NU1OLW+sFySe9o4AABoyhzMi8aNG6cNGzZ0dCwA0Cxv5aXdEtSvLABAhPNPXtqsJvVLdycvt+2vatKXHQgnGwsr5XAaSo61qU8Ki/UAANCcoDIBTz31lN5++229+uqrHR0PADThX3kJAMCh0uLtvse1DpfyUt3TxitqG1Ra7QhVWMARrdxdKonFegAAOJygpo1ffPHFamho0JVXXqlrr71Wffr0kcViCTjGZDJpxYoVHRIkgJ7Nm7yMInkJAGhGlLXxOrS8xqEYu0XZidEqLK9VwYEqpcTZD/NqIHTodwkAwJEFlbxMTU1VWlqaBg0a1NHxAEATvmnjJC8BAEdQU++UJPVNi1Vhea22H6jW0XkpIY4KaJ53pXH6XQIA0LKgkpcLFizo4DAAoHlOlyGny92vjJ6XAIAjqXG4k5f5aXFavK1EBSzagzBV63BqQ2GFJGlUn+TQBgMAQBgjEwAgrHmnjEuSjcpLAMARNHhueHkXP9lbWhvKcIAWrS+sUIPLUGqcXb2SokMdDgAAYSuoyssvvviiVcedfPLJwZweAHz8k5dUXgIAWjJlWJY+XVeki8fmSpKSYm2SpIo6FuxBePKfMs5iPQAAtCyo5OWkSZNa9QHrdDqDOT0A+NT5/R6xWbiwBwA075GLj9KCDft02rBMSVJitDt5WV7TEMqwgBat2lUqicV6AAA4kqCSl/Pnz2+yzel0qqCgQM8++6xcLpfuv//+dgcHAKXV7oqZhGgrVQkAgBYlRNt0zlG9/J67L3PLa6m8RHha6VlpfCSL9QAAcFhBJS9POeWUFvddddVVOumkk7RgwQJNnjw56MAAQJJ2HKiW5F41FgCA1kqM8VZekrxE+Kl1OLWpuFISlZcAABxJhzeQM5vNuuSSS/TPf/6zo08NoAfaXuJJXqbGhTgSAEB34ps2Xsu0cYSftXvL5XQZSo+PUnYii/UAAHA4nbL6RUlJiUpLSzvj1AB6mB0HqiRJualUXgIAWi8xxj3BqKLWIcMwQhwN0Gh/ZZ3Of+obSdKo3om0xQEA4AiCmja+Y8eOZreXlpbqiy++0EMPPaSTTjqpXYGhc325eb+27q/R1Sf244IJYW1HCdPGAQBt5628dDgN1TpcirFbQhwR4DZ3bZHv8fED0kMYCQAA3UNQycv8/PwWE16GYWjChAl65pln2hUYOtdP//W9JGl4TqKOH8hFE8LHl5v2KTsxWoOyEiQ1ThvPo/ISANAGsXaLLGaTnC5D5bUOkpcIGyVV9ZKk3skxmnFCfmiDAQCgGwgqefnCCy80SV6aTCalpKRowIABGj58eIcEh863ZV8lyUuEja37KnXl80skSQX3T5PTZWhXSY0kkpcAgLYxmUxKiLaqtNqh8hqHsugriDBR5llE6uxR2bJaOqWLFwAAESWo5OVVV13VwWEgVLbsqwp1CIDPzoM1vsf7KupU73Sp3umS1WxSr+SYEEYGAOiOEqNt7uRlLSuOI3yUVrsrL5Nj7SGOBACA7iGo5KW/tWvXavv27ZKkvn37UnXZzXyxcV+oQwB86htcvscbCitkMbsrvPukxPgeAwDQWt5Fe8prWHEc4cNbeZkUYwtxJAAAdA9BJy/fffdd3XjjjSooKAjY3q9fPz388MM699xz2xsbusDW/VReInx4KxEkaUNRheKj3P3J8tLiQhUSAKAb8y7aQ+UlwklptXs8JseSvAQAoDWCSl7OmTNHF1xwgfr27at7771Xw4YNkyStW7dOzz77rM4//3y9//77OvPMMzs0WHSOWodT0Taa2CP0vJUIkrSntEZRVncfqL70uwQABCEh2lN5WUvlJcIHlZcAALRNUMnLu+++W6NHj9aXX36puLjGiqhzzz1Xs2bN0oknnqg777yT5GUYs1lMcjgNSdKOkmoN9qzsDISSf/KysKxW3nXBWKwHABAMb3KotKr+CEcCXcdXeRlDz0sAAFojqOXtVq5cqenTpwckLr3i4uJ01VVXaeXKle0ODp3H6TJ8j/dX1IUwEqCR92JekvaU1WhHSbUkKS+N5CUAoO3S46MkSQdIXiKMlNZ4F+yh8hIAgNYIqvIyOjpaJSUlLe4vKSlRdHR00EGhcxmG5Je7VGkNfaAQHvzH4t7SWtU4nJKkviQvAQBB8CYv91VyoxbhodbhVK3DvUBhEslLAABaJajKy8mTJ+uxxx7TwoULm+xbvHixHn/8cU2ZMqXdwaFzGIc8P1hNNQLCQ8C08fJa3/PcFJKXAIC2S09wJy+ZZYJw4b22sZhNSogKeu1UAAB6lKA+MR988EFNnDhRJ554osaNG6chQ4ZIkjZs2KAlS5YoMzNTDzzwQIcGio7jOiR76T9VFwilsmYS6enxUYrj4h4AEIT0eHdPwf1UXiJM+C/WY/I29wYAAIcVVOVlv379tHLlSv3mN7/RwYMH9cYbb+iNN97QwYMHdd1112nFihXKz8/v4FDRUZomL6m8RHhoroUBU8YBAMHK8Ewb31/JtQ7Cg7dogJXGAQBovaDLmTIzM/XII4/okUce6ch40AVchzyn8hLhwluNkBJr00HPuGSlcQBAsLw9L8tqHKpvcMluDeq+PdBhvEUDJC8BAGi9oK7gGhoaVF5e3uL+8vJyNTQ0BB0UOtehlZcHSV4iDLhchi95OSwn0bed5CUAIFhJMTZZze6puQeqmDqO0PPOMmGlcQAAWi+o5OVvfvMbHX/88S3uP+GEE/Tb3/426KDQuQ5NXpbVMJUKoVdR2yDDMzYHZyX4tjNtHAAQLLPZpDRv38sKrncQemWeooFkKi8BAGi1oJKXH330kS688MIW91944YWaM2dO0EGhc7FgD8KRt+oy1m5RQnRjRwsqLwEA7ZHu63tJ5SVCr9RTNJAcaw9xJAAAdB9BJS/37Nmj3r17t7i/V69e2r17d9BBoXMdkrtk2jjCgvdiPinGJpfROErzqLwEALSDN3m5j+QlwoD/auMAAKB1gkpepqWlacOGDS3uX7dunRITE1vcj9Bqbtq4YRya0gS6lv/qm3vLan3bvSvFAgAQDCovEU681zv0vAQAoPWCSl6eeeaZeuaZZ7Rs2bIm+77//ns9++yzOuuss9odHDrHoclLh9NQVb0zNMEAHmV+Dex3H6zxbTeZTKEKCQAQAdIT6HmJ8EHlJQAAbWc98iFN3X333froo480btw4nXvuuRoxYoQkafXq1XrvvfeUmZmpu+++u0MDRcfxJi/jo6yqd7pU3+BSaXW94qOCGg5Ahyj1u5ivdbhCHA0AIFJkUHmJMLB1X6Xe/G6Xdnlu0FJ5CQBA6wWVrerVq5e+++47/eEPf9C7776rd955R5KUmJioyy+/XPfee6969erVoYGi43jTQmaTlBJrU1F5nUqrHeqTEtKw0MOVVXsa2MfYdc1ZA/THt1fp2kkDQhwVAKC78/W8rCB5idD52Uvfaeu+Kt/zpBgW7AEAoLWCLrXLycnRv/71LxmGoX379kmSMjIymOLZDXjbW1rMJiXH2H3JSyCU/KeN90uP02u/mBDiiAAAkYCelwgH/olLicpLAADaot3zhE0mkzIzMzsiFnQRpy95afZdOHlXegZCxZtAT6QHFACgA3l7Xu6rrJNhGNxoR1hI5noHAIBWC2rBHnRv3vV6LObGu74HqbxEiJXWsPomAKDj9U6Okd1qVmm1Q3e9v1aGYRz5RUAH8s4u8ceCPQAAtB7Jyx7Iu2CPxeSeNi419hsEQsU3bZweUACADpQQbdM9PxwpSZr9dYH++smGEEeEnmZnSXWTbVYLf4YBANBafGr2QN7kpdlsUnIclZcID2XVjauNAwDQkX48Nld3nzdCkvTk/C164rNNIY4IPcmOZpKXAACg9Uhe9kDe5KXV3Fh5yYI9CDVv31WmjQMAOsOVE/N169nDJEl//WSj/vnl1hBHhJ6C5CUAAO1D8rIHcnn+bzablOJdsIdp4wgx77RxKi8BAJ3l5yf3142nD5Yk/eWDdfr3ou0hjgg9AclLAADaJ+jkZV1dnZ544gmdffbZGj58uIYPH66zzz5bTzzxhGprazsyRnQww3CvsmkxmfxWG6fyEqFT63Cq1uFOq1N5CQDoTL+ePFDXThogSbrr/bWqqmsIcUSIdM31vAQAAK0XVPJy165dGjNmjH7zm99oxYoVysjIUEZGhlasWKHf/OY3GjNmjHbt2tXRsaKD+BbsMZuU5Js2TuUlQsdbdWkxmxQfZQ1xNACASGYymfT7qUMUZ7eovsGl4oq6UIeECNXgdMnlMnyVlz86urck6Zen9A9lWAAAdDtBZQlmzpyp7du3680339SFF14YsO+tt97S9OnTNXPmTL377rsdEiQ6ln/yMiXOO22cykuEjv+UcZPJFOJoAACRzmQyKTXerqqSGpVU1alfelyoQ0KEcThdmvrIF0qMsWn3wRpJ0k1Th+j6KYOUmxIb4ugAAOhegkpezps3TzfccEOTxKUk/fjHP9b333+vv//97+0ODp3D2/PS4r9gT41DhmGQOEJIeJPnyfS7BAB0kdS4KO0sqdGBSmafoONtKqrU1v1Vvud2q1nZidEym7nWBgCgrYKaNp6QkKDMzMwW92dnZyshISHooNC5vJWXZr+el06XoQp6PiFEvG0LEkleAgC6SHqc+wbugSqSl+h4h9YD5KbEkLgEACBIQSUvZ8yYoRdffFHV1U2bT1dWVmr27Nm6+uqr2x0cOof/tPFom0XRNvcwKGPqOELEO22cxXoAAF0l1ZO8LCF5iU5Q63AGPM9LZao4AADBCmra+JgxY/TBBx9o6NChmj59ugYOHChJ2rRpk1566SWlpqZq9OjRevvttwNed/7557c/YrSb/7RxSUqLi9Lu0hoVV9QqlwsrhIAveUnlJQCgi6TFR0mS9leyYA86XnU9yUsAADpKUMnLSy65xPf4nnvuabJ/165duvTSS2UYhm+byWSS0+lsciy6nvfHYvHMZxmQGa/dpTXaUFipY/umhjAy9FTenpdJJC8BAF0kjcpLdKKqQ9oxUSAAAEDwgkpezp8/v6PjQBfynzYuScOyE/TFxn1aX1gewqjQk1XUupOX9LwEAHQVpo2jM1F5CQBAxwkqeXnKKad0dBzoQt5p496m4UNz3IsrrS+sCFFE6OmqPBf4sfagfiUBANBmafHu5OXestoQR4JIVHlI5WVeGslLAACCFdSCPejevJWXVm/yMjtRkrR+b3nAVH+gq9T4kpeWEEcCAOgphuUkymYxaXNxpb7ZvD/U4SDCVNcfMm08heQlAADBalWZ06mnniqz2ayPP/5YVqtVkydPPuJrTCaT5s2b1+4A0fG8yUuzt+dlRrysZpPKaxu0t6xWvZJjQhgdeqIqzwV+DMlLAEAXyUqM1qXj8vTSwu164OMN+u+ANB2oqtejn27UVcfna2BmQqhDRDdWVRc4bTwuitklAAAEq1WVl4ZhyOVy+Z67XC4ZhnHY//yPR3hp7Hnp/r/datbAzHhJou8lQsLbFyqOaeMAgC40a/JAxdgsWrGzVJ+sLdLVL36rfy/aoVmvLgt1aOjmDq28BAAAwWtVpmDBggWHfY7uxTsx3LtgjyQNyU7Q+sIKrdtboclDs0ITGHospo0DAEIhMyFaV5/YT0/M36yHPt6gzcWVkugDjvar8luwJysxKoSRAADQ/bW552VNTY1uvPFGvffee50RD7pAY+Vl44/f1/eSi3WEANPGAQCh8vOT+yspxuZLXEpSHJ9HaKeKWve1TXZitP7v2uNDHA0AAN1bm5OXMTExeuaZZ1RUVNQZ8aAL+JKXjYWXjSuO72XaOLpeDdPGAQAhkhRj068mDQjYVlXvVGl1fYgiQiTwjp/fnzlEfVisBwCAdglqtfFjjz1Wq1ev7uhY0EV8C/b4TRsf5qm83Lq/SrUOZ3MvAzqNt+cllZcAgFCYfny+shOjA7ZtP1AdomgQCcpqHJKk5FhbiCMBAKD7Cyp5+eijj+r111/XP//5TzU00Iy6u/EupWQxNSYvsxKjlBxrk9NlBEybArqCt6k9PS8BAKEQbbPo3z8br8cuGaNx+amSpIIDVSGOCt1ZabU7eZkUYw9xJAAAdH+tTl5+8cUX2rdvnyRp+vTpMpvN+uUvf6nExEQNGjRIo0ePDvjvqKOO6rSg0T6Gp/LS6jdv3GQyaWi2Z+o4fS/RhRxOlxxO96AkeQkACJWBmfE6b0xv5ae7p/gW7KfyEsHzThtPiqHyEgCA9mp1g7lTTz1V//73v3XppZcqLS1N6enpGjJkSGfGhk7i9E4b96u8lNyL9izaWkLfS3Spar/VOGPpeQkACLG+aXGSpO1UXiJITpehcs+CPUwbBwCg/VqdKTAMQ4anZG/BggWdFQ+6gGG4k5YWc2DyclgOlZfoet4p41azSXZrUJ0sAADoMPme5CXTxhGsck+/S4nKSwAAOgJlTj2Qt+floZWXQzyL9qwvLNfnG/fp3WW7dWx+iqYMy1LWIU3sgY7CYj0AgHDinTbOgj0IVqkneRkfZZXNwo1ZAADaq02fpqZDkl3onryrjVsPqbwcnBUvk0naX1mv3765XG8v261b31mtkx+cr4L9VB+gc9R4kpdxTBkHAIQB77TxA1X1Kq91HOFooCn6XQIA0LHalLy84oorZLFYWvWf1UoiIlx5k5eHThuPtVuVleCusNxfWe/ZZlFdg0vbSF6ik1TVsdI4ACB8xEdZlR4fJUnazqI9CIK38pJ+lwAAdIw2ZRinTJmiwYMHd1Ys6CK+aePmppW0mYlRKiyvleS+eB+QEacVu8rk9GY8gQ5W7WDaOAAgvOSnxWp/ZZ0KDlRpVJ+kUIeDbqasmuQlAAAdqU3Jy+nTp+uyyy7rrFjQRQxv5WUzbQAyE6J8j93TyN3HNJC8RAcxDCOgBcV3BSWSRF9VAEDY6JsWp++2H2TFcQTFO208OcYe4kgAAIgMdJDugVqaNi5JGQmNCaQh2Ym+BKfLIHmJ9iuuqNW4e+fp0mcXaXNxpWrqnXp18Q5J0kVjc0McHQAAbv08i/YUsGgPguCdNp5E5SUAAB2CxpQ90OGTl42Vl0Oy4rWjxF1x4F0RGmiPZTtKta+iTvsq6nT2Y18qPd6ug9UO9UmJ0enDs0IdHgAAkhoX7WHBQgSj1DttnAV7AADoEFRe9kDenpfNJS/9p40PyU7UwIx4SdK6veVdERoi3L6KOkmS3WJWvdOlPWXu/qpXHZ/f7HgEACAU8r3JSyovEYQyFuwBAKBDtbry0uVyHfkgdAveyktzMz0vE6Ibh8SQ7ATtLauRFm7Xip2lXRQdIpk3eXnBsX10dF6ybv6/lYqPsuqi45gyDgAIH3lp7mnj+yvrVFnXoPgoJiuh9eh5CQBAx+JKrAfytq+0NlPplpMU43ucGmfXUbnJkqTVe8rkcLpks1Csi+Dtq3QnLzMTonTR2FwNyoxXXJRVidFUJgAAwkdSjE2pcXaVVNVr+4EqjejFiuNoPXpeAgDQsUhe9kBOb+VlM8nL4/JTdOe5IzQ4K0GS1C8tTglRVlXUNWhjUQUX72gXb+Wlt7fq0XkpoQwHAIAW5afFepKX1Vz/oE3K6HkJAECHooyuB/KuG25ppsWgyWTS9OPzNXFAmiR3gnN0rvuCfcXOsi6KEJHq0OQlAADhqrHvJYv2oG1KfT0vmTYOAEBHIHnZAx1utfHmHNUnWZK0cldp5wSEHoPkJQCgu2DFcQTD5TIae14ybRwAgA5B8rIHakxetu7HP9qTvFzOoj1oB8MwfD0vM+JJXgIAwlt+unvRHlYcR1tU1jf4rrWTmDYOAECHIHnZA3nXjW/t2jtjPIv2bCyqUGVdQ6fEhMhXXtug+gb36KPyEgAQ7ryVl9uZNo428Pa7jLaZFW2zhDgaAAAiA8nLHsh7N9hsat208eykaOWlxsplSN9s3t+JkSGSeaeMJ0ZbuZgHAIS9/DR35WVReZ2q67l5i9Yp9S3WQ79LAAA6CsnLHshoY89LSZo0JEOStGDjvs4ICT0A/S4BAN1Jcqzd17NwRwlTx9E6pTX0uwQAoKORvOyBGqeNtz15+fmGfTK82U+gDYoraiWRvAQAdB8s2oO22nWwRhLXOwAAdCSSlz2Qy3AnLduSvJzYP112q1m7S2u0ubiys0JDBGusvIwOcSQAALSOd+o4i/agtdbsKZMkDe+VGOJIAACIHCQveyDfauOt7HkpSTF2i8b3S5UkLdjA1HG0HSuNAwC6GxbtQVut3VMuSRqeQ/ISAICOQvKyB/It2NOGyktJmjQkU5K0YGNxR4eEHoCelwCA7sZXebmfykscmdNlaH1hhSRpBJWXAAB0GJKXPZC3Y6W1zclLd9/Lb7cdVFUdq26ibQrL3D0vM0leAgC6ifx0Ki/RegUHqlRd71S0zax+6fGhDgcAgIhB8rIHcgZZedk/PU69k2NU73Rpxc7Sjg8MEW3nQXfVSp6nigUAgHCX75k2vqesVrUOZ4ijQbjzThkfmp3Ypt7yAADg8Ehe9kBGED0vJclkMmlgpvsu8o4Spk+h9RqcLu0pdVde5qaQvAQAdA8psTYlRFslce2DI1u719PvkinjAAB0KJKXPZBvwZ4g7gjnpsZIaqyiA1pjb1mtnC5DdquZaeMAgG7DZDL5qi8L9jN1HIfnrbyk3yUAAB2L5GUP5PL8P6jkpadqbmdJTQdGhEjnrVbpkxLT5nYFAACEUl9Pu5PtB7hxi8Nbw0rjAAB0CpKXPVD7Ki89yUsqL9EGOz3Jy7xUpowDALqXfp5FewpYtAeHUVxRq/2VdTKb3D0vAQBAxyF52QN5k5fmNva8lKi8RHC8lZf0uwQAdDd9PdPGX1m8Q19v3h/iaBCuvFWX/TPiFWO3hDgaAAAiS1glL++77z4dd9xxSkhIUGZmpn74wx9qw4YNAcfU1tZq5syZSktLU3x8vC644AIVFRUFHLNjxw5NmzZNsbGxyszM1E033aSGhoaufCthzZO7bFfPy/2VdaqpZ9VNtM7Og+5kN5WXAIDuJj+t8bPr8n8uDmEkCGevLNouSTqqT3JoAwEAIAKFVfLy888/18yZM7Vo0SLNnTtXDodDZ5xxhqqqGqfp3HDDDXrvvff01ltv6fPPP9eePXt0/vnn+/Y7nU5NmzZN9fX1+uabb/Svf/1LL774ov785z+H4i2FJW/lpTWI5GVSjE0JUe5VN3cxdRytYBiG3luxR1Jj8hsAgO7CW3kJtGT++mJ9uq5YVrNJ104aEOpwAACIONZQB+Dvo48+Cnj+4osvKjMzU0uXLtXJJ5+ssrIyPf/883r11Vc1efJkSdLs2bM1bNgwLVq0SBMmTNAnn3yitWvX6tNPP1VWVpbGjBmju+++WzfffLPuuOMO2e32ULy1sNKeaeMmk0l9UmO1bm+5dh6s1qCshA6ODpHm+x0HfY9zqbwEAHQz6fGB145l1Q4lxdpCFA3CTV2DU3e+t0aS9NMT+2lgZnyIIwIAIPKEVfLyUGVlZZKk1NRUSdLSpUvlcDg0ZcoU3zFDhw5VXl6eFi5cqAkTJmjhwoUaNWqUsrKyfMdMnTpV1157rdasWaOjjz66ydepq6tTXV2d73l5ubtnjcPhkMPh6JT3FioOh8OXvDRcDUG9vz7J0Vq3t1wF+yrlGJDawREi3HjHSLD/FtbsLvU97pNkj7h/U2iqvWMGPQ9jBm0VyjGzsbBUY3KTJUnvr9wrp8vQeWN6dXkcaJvOGjPPfbFNBQeqlRFv1zUn5fN7LILw2YRgMG7QVpE+ZjrqfYVt8tLlcun666/XCSecoJEjR0qSCgsLZbfblZycHHBsVlaWCgsLfcf4Jy69+737mnPffffpzjvvbLL9k08+UWxs5FWKueRuIv7Vl19qcxBvz1FqlmTWF9+vVVrJ6o4NDmFr7ty5Qb3uwy3u8TIqxaUFn37SsUEhrAU7ZtBzMWbQVl03Zhovmd/9bKH2ZBgqqpHuXe7eXrd9ueIpxuwWOnLMlNZJjy+3SDJpanaNvvyM65xIxGcTgsG4QVtF6pipru6YdoNhm7ycOXOmVq9era+++qrTv9Ytt9yiG2+80fe8vLxcubm5OuOMM5SYmNjpX78rORwO/WHJZ5Kkyaeeovwg+jgdWLRDCz5YL1tyts4+e0wHR4hw43A4NHfuXJ1++umy2dr2l5lhGLr3oS8k1en6c8bq5EHpnRMkwkp7xgx6JsYM2qqrx8wDa7/QnrJaSVJM9gCdPXWw7vpgvaQdkqRBxxyvoz3VmAhPHTVmymocqqxrUO/kGN3w5krVuwp1TF6y/vyT42QKoiUTwhefTQgG4wZtFeljxjuzub3CMnk5a9Ysvf/++/riiy/Up08f3/bs7GzV19ertLQ0oPqyqKhI2dnZvmOWLFkScD7vauTeYw4VFRWlqKioJtttNltEDh6nZ9p4lM0e1PvrleJOeM5dV6y/zNmgW84epmibpSNDRBgK5t/D6t1lKqqoU4zNohMGZcrGOOlRIvV3KDoPYwZt1VVj5o1fTtT5T3+jfRV1WlxwUHUuk95Ztse3f3dZneat36wxuck6a1ROp8eD4AUzZhqcLlnMJhWW1+q8J75Rea1Dj11ytN5fVSiTSbrrvJH01Y9gfDYhGIwbtFWkjpmOek9htdq4YRiaNWuW3nnnHX322Wfq169fwP5jjz1WNptN8+bN823bsGGDduzYoYkTJ0qSJk6cqFWrVqm4uNh3zNy5c5WYmKjhw4d3zRsJc4Z3wZ4gf/pxUY0JqH8t3K4Xvylof1CISPPXu/8dnjAwnQQ3AKDbyk2N1fu/PlGStGp3mV74apsq6xp8+2d/XaBnvtiqa1/5PlQhopPsq6jTMXfP1cxXv9c1Ly9VcUWdah0u/fLlpZKkS47L08jeSSGOEgCAyBZWlZczZ87Uq6++qnfffVcJCQm+HpVJSUmKiYlRUlKSrr76at14441KTU1VYmKifv3rX2vixImaMGGCJOmMM87Q8OHDdeWVV+rBBx9UYWGhbrvtNs2cObPZ6sqeyOX5v8Uc3NSWWHtgEqpgf1U7I0KkmudJXp42LDPEkQAA0D5ZidEalBmvTcWVenjuRklSQpRVFXUNWl9Y4Tuusq5B8VFhdYmNdli1u1TltQ2as6pp7/ykGJtumjokBFEBANCzhFXl5dNPP62ysjJNmjRJOTk5vv/eeOMN3zGPPPKIfvCDH+iCCy7QySefrOzsbL399tu+/RaLRe+//74sFosmTpyoK664Qj/5yU901113heIthSXvauPBJi9jbIEX5MGeB5Ftf2WdVuwqlSSdOoTkJQCg+zthYGPv5ji7RVedkC9Jqm9w+bZzUzeyuBp/tDKbpOTYxulvvz1jsFLjmC4OAEBnC6vbwoZ3PvNhREdH68knn9STTz7Z4jF9+/bVnDlzOjK0iGEYhgy5k42WIJuKH1p5WVxR1+64EHkWbNgnw5BG9EpUdlJ0qMMBAKDdfnZSPy3YUKyCA9W6fEJfTR2Rrb9/tjngmIIDVUwjjiAOZ2P28pGLx+jP767xPb9sXF4oQgIAoMcJq8pLdD6nqzFB3FHTxveU1rQrJkQmb7/L04ZSdQkAiAx9UmL10fUn6z/XTNTNZw7VyN5JOmFgWsAxG/ymkKP7q/ckL48fkKbzxvRWWY3Dt89q4U8pAAC6Ap+4PYzTr7jVHOy0cZKXaIWvt+yXJE0ieQkAiCDRNovG5qf6bgL/4uQBAfu/LSgJRVjoJA7PxbPd6v6z6d4fjZLVbNILV40NZVgAAPQoYTVtHJ3PJOmoVJcys7JlD/Jucaw9cNgcrHao1uFkNWn41DqcKq12VyYMyIgPcTQAAHSekwela1hOotbtLZckLdtRqroGp6KsXBdFAm8/U5vnuvmy8Xm68Ng+vmQmAADofHzq9jB2q1k/HeLSU5eNCTrZ2Nx084PV9e0NDRHEm7i0mE1KjOYeCQAgcplMJr11zUR9+ftTlR5vV12DS6t2lYU6LHQQb89L/5v+JC4BAOhafPIiKNNG5WhQZrySYtwrLh6oJHmJRiVV7vGQEmuTKciFoQAA6C7io6zKTY3VcfmpkqTF25g6Himq652SxAwjAABCiOQlgvLEZUfrkxtOVq/kGEnSgSqSl2jkrcRNibWHOBIAALrOuH7u5OXCLQdCHAk6SnmtezZJYgwzSQAACBWSlwiKyWSSyWRSerw7OXWgsi7EESGc+Cov40heAgB6jpMGZUiSFm874Et6oXsr96wunhhtC3EkAAD0XCQv0S6pnuRUCZWX8FPqqbxMpfISANCDDMyMV/+MODmchj7fsC/U4aADVNQ2SJIS6OENAEDIkLxEu3iTl/vpeQk/JVXuKgUqLwEAPc0Zw7MlSXPXFoU4EnSExmnjVF4CABAqJC/RLunxUZKkkiqmjaORt+dlahwX+gCAnuX04VmSpPkbiuV0GSGOBu3lrbxMpPISAICQIXmJdvFWXrLaOPw1rjZO5SUAoGc5OjdZcXaLKmobtHVfZajDQTvR8xIAgNAjeYl2SfMmL+l5CT+sNg4A6KnMZpOG90qUJK3eUxbiaNBejT0vSV4CABAqJC/RLmne1caZNg4/3srLVHpeAgB6oBG9kiRJq3eX69uCEt03Z53qG1whjgrBaOx5ybRxAABChU9htIu35+W+ijoZhqGFWw+ovsGlSUMyQxwZulpxRa0WbNin88b0Umk1C/YAAHqukb3dycvlO0v1/FfbJLlXIv/x2NxQhoU2cjhdqq53SmLaOAAAoUTyEu3SKzlGFrNJtQ6Xdh2s0WXPLZYkLb1titI8iU30DI/M3ajXluzUgx+t960+n8q0cQBADzSyt3va+NLtB33b9pTWhiocBKnSM2VckuJZsAcAgJBh2jjaxWYxq3dyjCTp03VFvu07SqpDFRJC5D9Ld0mSL3EpSSmsNg4A6IEGZsQ32VZUQfKyu/FOGY+1W2Sz8GcTAAChwqcw2q1vWqwk6c731vq2kbzseQZlJgQ8N5mk+CiqFAAAPY+1mUTXroM1IYgE7dG4WA/XMwAAhBLJS7TbgGaqC7YfIHnZ0xy6aJNhSCaTKUTRAAAQWpOGZEiSb4bKLm7sdjvlNZ7Feuh3CQBASJG8RLsNz0lsso3kZc9iGIZvhXEAACA9dvHRuvuHIzV7xnGSpF2lNXK5jBBHhbYop/ISAICwQPIS7fbDo3vrpyf0C9i2o6QqRNEgFCrqGuRw8gcZAABeSbE2XTmhr/qlx8lskuobXNpfWXfkFyJseHteJsZQeQkAQCiRvES72a1m/fmc4frnT8Yq2uYeUlRe9iwHPIv0xNktSmeVeQAAfGwWs3KS3FPHd9L3sltp7HlJ8hIAgFAieYkOM2V4lhbdcpokqbiiTtX1DSGOCF2lxNPvMjXericvO1oJUVY9eMHoEEcFAEB46JPi6Xt5kJu73Uljz0umjQMAEEokL9GhkmPtSvJMrWHF8Z7DW3mZFhel8f3TtOL2M3TRcbkhjgoAgPDQJyVWknvF8eLyWj08d6P+9skGOemBGda808apvAQAILS4jYgO1zctVit3lWn7gWoNzW66mA8iz4Eqb/LSLkkym1llHAAAL2/l5UMfb9Bjn25SvdMlSYq2WTTz1IGhDA2H4Z02nhjDn0wAAIQSlZfocHmp7uqCHfS97DG8K42nepKXAACgkTd5KUn1TpcGZcZLkh6Zu1GrdpWFKiwcQWm1d9o4lZcAAIQSyUt0uL5p7uTldlYc7zF808ZZrAcAgCZyPTd2Jemq4/P1yQ0n66yR2WpwGbrujWWqqXeGMDq0pLiiVpKUlRgd4kgAAOjZSF6iw/VNjZPEiuM9yQHPgj1pVF4CANCEf+VlZmKUTCaT7v3RKGUmRGnrvio9vWBzCKNDS/aWuZOXOUkkLwEACCWSl+hwed7KS5KXPYZ32nhaPMlLAAAOle1XueetskyJs+uWs4dKkuasLgxJXGhZfYNL+yvdN2ezSV4CABBSJC/R4fLT3JWXu0tr5PA0pEdk804bp+clAABNWS2Nl9zePoqSNHlolixmkzYXV2pnCTd9w0lxRa0MQ7JbzMwsAQAgxEheosNlJkQpymqW02VoT2mNDMOQ02WEOix0IJfL0N8+2aD5G4ol+U8bp+clAADNObZviiTph0f39m1LirFprGf7819tC0lcCLShsEJ3v79Wa/eUS3JXXZpMphBHBQBAz2YNdQCIPGazSXmpsdpUXKntB6p1039Wan9FnT68/iRFWS2hDg8d4JO1Rfr7Z+7+XNvuO5tp4wAAHMErPxuvwrJa5afHBWyfcUI/Ld5Wohe/KVC/9DhNPz4/NAFCkjT10S8kSW9+u1MSU8YBAAgHVF6iU3hXHP9u+0Et2VairfurtKWY1ccjxT7P6puSVFReJ4fTXVnLtHEAAJoXbbM0SVxK0pkjs3XT1CGSpDvfW6NP1tD/MhxU1DVIknJTYo9wJAAA6GwkL9Ep8jwrjvtfgG8/QPIyUniTlZL0bUGJJCk+yqpoG5W1AAC01a8mDdCl43LlMqTfvL5My3eWhjqkHsm7mJJXenyUrjmlf4iiAQAAXiQv0Sm8lZfrCyt82wpYfTxi7POsvik1Ji+pugQAIDgmk0l3nzdSpwzOUK3Dpatf/FY7uG7qcpuKG69b+2fE6Z1fHa9BWQkhjAgAAEgkL9FJ8tKaTrHZUeKuvFy5q1STHpqv615fpm37qcbsjvZXNCYvX1q4XZLUOzkmVOEAANDtWS1mPXn5MRrRK1EHqup11ewl2lBYoe88NwnR+Tb43XT/36wTlZvKlHEAAMIByUt0ivy0pj2dCva7Kwie+GyzCg5U693le3TNy0u7OjR0AP/KS0nKSIjSLWcPDVE0AABEhvgoq1646jj1To7R1v1VmvroF7rwHwv1/Y6DoQ6tR9hY5E5eXnV8vuKjWNcUAIBwQfISnaK5KrwdJdU6UFmnz9YX+7ZtL6mSYRhNjkV42+epvBzfL1UXj83VB78+UaP7JIc2KAAAIkBWYrRmzzhOCdGNybMvN+4PYUQ9h7fd0ZBspooDABBOSF6iU9itTYfWnrIafbi6UA0uQ/09q23WOlyqOqQ5OsLffk/l5Z9+MFwPXDhamYnRIY4IAIDIMTgrQc9ceazvuSFu9HY2wzC0dk+5JJKXAACEG5KX6DTRtsbhlZ8WK8OQbvvvaknS8QPTFGt3r0zt3z8R4c/lMrS/sl6Se7o4AADoeMcPSNdRfZIkuT970bk2F1fqQFW9om1mjeyVFOpwAACAH5KX6DSPX3K0JOmGKYM189SBAfsGZyX4El/7K0ledidlNQ45PX9EscI4AACd5/ThWZKkvWW1IY4kMrlchhqcLknSoq0HJEnH9k1pdgYRAAAIHTpRo9OcMSJb3/xhsjITolR0SHXlwMx4pcdHafuBal//RHQPZTUOSVKc3SKbhYt7AAA6S5anLUthOcnLznDJc4tUWFarT244WYu2uld1n9AvLcRRAQCAQ5G8RKfq5Vm4p1dStGLtFlV7+lsOzkpQery7ao/Ky+7Fm7xMirGFOBIAACJbdpI7eVlE8rLD1Te4tGSbO2E59E8f+baP70/yEgCAcEPZFLqEyWRSrL0xV54WZ1d6vHva+D5P/0R0D97kZSLJSwAAOlWOJ3nJtPGOV1rd9PozymrWUbn0uwQAINxQeYkuMyAjzldlaTKZfMlLKi+7FyovAQDoGt5p4xW1Daqubwi4EYz2OVjt8D1+9efj9d9luzU2P1VRVksIowIAAM3hCghd5qELj9KvXl2qa04ZIElK9y7YQ8/LboXkJQAAXSMh2qY4u0VV9U4VltWqf0Z8qEOKGAc9lZf9M+J0/IB0HT8gPcQRAQCAlpC8RJfJS4vV+78+yfc8g56X3RLJSwAAuk52UrS27KsiednBvNPGU2LtIY4EAAAcCT0vETKN08bpeRlOXC5Dj8/bpC837Wt2fznJSwAAuox30Z7C8lot2FCspxdsUYPTFeKouj/vtPGUWK5nAAAId1ReImToeRmePllbpIfnbpQkFdw/rcn+0mqSlwAAdBVv38u9ZbW68c0VkiSTSb42PGi9XVXSAx9v1KbiKn2+0X2TlspLAADCH8lLhIy352V1vZMm9GFk18Fq32PDMAL2bSqq0Bvf7ZQkJVGpAABAp8v2JC+/3rzft+2Fr7bplyf3l8lkClVYYe1gVb2qHU71To7xbXO5DD27zqIyR4Fvm8Vs0qQhmSGIEAAAtAXZIoRMnN2iaJtZtQ6X9lfUKy+N4RgOrObGP4Sq6p2yGC65PDnMV5fs8O1LplIBAIBOl+OZNv7NlgO+bcUVdVqxq0xvfbdTM07I18DMhFCFF5aueH6x1uwp14LfTVJ+epwkac3ecpU5TIqzW/THacM0NDtBg7ISlBjNzVgAAMIdPS8RMiaTyTd1fB9Tx8NGXUNjH63Vu8s09r75+vdm96+KRVtLfPtOGZTR5bEBANDTeKeNH+riZxbqlcU7NOXhL7o4ovDmchlas6dckvTSwu2+7Qs2uitXTxiYpsvH99WxfVNJXAIA0E2QvERI0fcy/Hgb2EvSeyv2qKrOqaX7zfrH51u1bq/7j4Fvb53CtHEAALqAd8EerxG9EiUF3mw8wHWUT2V9g+/xgg3FcroM3fG/NXr8sy2SpEmD00MVGgAACBLJS4RURgLJy3BTWt24+vtCvylqf/t0syRpUGa87+cGAAA616HJy+YW6vlwdWFXhRP2yvxuwm7dX6XH523Si98U+LadPIjkJQAA3Q3JS4SUb9p4BcnLcFF6yEX/oSYOSOvKcAAA6NHS4xpvGNqtZp06NFPJsTaZTNKPj+0jSfpg5d5QhRd2ymocAc8fm7fJ93hAgtHiNHwAABC+WCEFIZUR7170hcrL8HHQr/KyORP6k7wEAKCrmP0W0kuMtio+yqr3Zp0ok0kyDOmtpbu0eNsBFVfUKjOBxFy5J3k5KDNeg7MTfIndnKRoXT2oMpShAQCAIFF5iZBK904br6jX/so6zXz1ez21YHOIo+rZ/CsvvZJshu8xyUsAAEIjwbPATG5qrPqkxCo3NVZH5SbLZUgfM3VcUmPlZVKMTff+cJR6J8dIkn40ppfiaNcNAEC3RPISIeWdNr5tf5Uuf26xPli5Vw9+tEFzVjH9KRQcTpd2lFRLkk4bmunbPjHLUJTVrAn9U5UaZw9VeAAA9EiXHJcrSbr5zCFN9p0zOkeS9B5TxyVJi7a6+3UnxdiUFGvTC1cdp6uOz9eVE3JDHBkAAAgWyUuElDd5uaGoQhuKKmSzuKdG/fGdVSosqw1laD3Syl1lqnE4lRxr089P7u/bnhdvaN4NJ+qf048LYXQAAPRMd/9wpOb99hRNHZHdZN9Zo9zJy28LSlRUzrXTyt1lkqTEGHeZ5ZDsBN1x7gjfNScAAOh+SF4ipNLj7X6Po/T+r0/S6D5JKq126HdvrZDLZRzm1ehoi7e5qxXG5adqbN8UpcS6L/yT7e4G9/FRtMkFAKCr2SxmDciIl8lkarKvd3KMjslLlmFIH/bwmSu1DqfW7CmXJF0xIS/E0QAAgI5C8hIh1Ss5RhkJUcpMiNLrvxivIdkJeuTiMYq2mfXV5v2a/U1BqEPsURZvLZEkje+fJqvFrGeuHKu7zh2m3nEhDgwAALRo2uhekqQPenjy8tuCEtU3uJSdGK1j8lJCHQ4AAOggJC8RUtE2i+b/bpI+v+lUDcxMkCQNyIjXrdOGS5Ie+Gi9iiuYAtUVGpwufVfgSV72S5UkjeuXqkuPo0cUAADhbJpv6vhB7S2r8W2fv764R/UR/2rzfknSCQPTm61SBQAA3RPJS4RcfJRVMXZLwLYrxudpQEac6htcWrO7PESR9Sxr9pSrqt6pxGirhuUkhjocAADQStlJ0Rrb111p+PmGfZKkuganZrz4rX71yvcqq3aEMrwu89Umd/LypEHpIY4EAAB0JJKXCEsmk0mDPJWYBQeqQhxNz+BdnXNcv1RZzFQrAADQnRydlyxJWl9YIUnaW9o4c2WPXzVmpCqpqvf1uzxhIMlLAAAiCclLhK2+6bGSpO0HqkMcSc+weJt3ynhaiCMBAABtNSTbPWti3V53Am9PaWPC8tkvtoYkpq70tWfK+NDsBGUksLI4AACRhKWDEbby09yrxLz+7Q4t21mq/ulx6pcepx8d3Vu5qbEhji6yOF2GvvUmL/unhjgaAADQVkOz3TNWNhRVyDAM7fJLXr67fLdmTR6oARnxoQqv03mnjJ9I1SUAABGHykuErYn905QQbVWtw6UVO0v1zrLdenjuRt3y9qpQhxZx1u0tV0Vdg+KjrBpOv0sAALqdgZnxMpuk0mqHiivqtPtgY/LSZUhPfLY5hNF1LsMwGhfrod8lAAARh+QlwlZ+epy+vXWKPrr+JD19+TG6+sR+ktwrSd7+7uqA6VBoH2+/y+PyU2S18GsBAIDuJtpmUX66e9bKnFV7tfOgu+3O1BFZktzVl1v2VTZ5XV2Ds+uC7CTb9ldpd2mNbBaTxvdjBgkAAJGGLAXCWrTNoqHZiTprVI5+NWmAb/u/Fm7Xtf9eKofTFcLouj+Xy9BrS3botSU7JEnj+9PvEgCA7uqoPsmSpDvfW6u3v98tSZo6IltThmXJZUh//XiD71iH06X75qzTiD9/rBe+2haKcDvMvHXFktyLDsba6YoFAECkIXmJbiM1zq4Ym8X3fMWuMj08d6PmrNqrsx/7UgX7WZW8rV77dodueXuVtuxzf++oVgAAoPu6bdow/eLk/kqOtfm29UuP0w2nD5LFbNKHqwv14aq92l1ao4ufWahnvtiqBpehb7YcCGHU7Td3XZEk6fRhWSGOBAAAdAZuTaLbMJlMyk2N0caixilPTy/Y4nv82LxNeuTiMSGIrPv6ZE2R73Gs3aKRvZNCGA0AAGiPtPgo/fHsYbrx9MH6YOVeOZwuHZ2XIkm69pQBemL+Zv3xnVVyGVJZjUMmk2QY0v7KuhBHHrySqnp9V+BedHDKcJKXAABEIiov0a30SWlcZfzQKkH/KeTfbNmvN7/dKcMwuiy27uhgdb3v8ZDsBNnodwkAQLcXbbPogmP76JJxeb5tvz5toIZkJehgtUNlNQ4d1SdJj3pu+u6r6L7Jy/nri+UypGE5iQHXiQAAIHJQeYluJTXO7ns8a/JALX5+ie95SVVjIm7G7G9V1+BSndOlKyf07dIYu5MDlY3fs36eJv8AACDyRFktevSSMbrpPyt0woB0/faMISoqr5Xkrrw0DEMmkynEUbbdp74p45khjgQAAHQWkpfoVuzWxsrAiYcsLuNdVbPW4VRdg7sK8+/zNpG8PAz/hG9WYnQIIwEAAJ1tWE6i3v/1Sb7n6fFRkqS6Bpcq6xqUEG1r6aVhqa7Bqc837pPElHEAACIZc0TRrWT7JdisFrOevfJYXTouV5K0p7RWDU5XwNSn/ZV1qm9gRfLmlFU7VONw+p6n+VW1AgCAyBdjtyg+yl3LsN9vNkZ3samoUtX1TqXE2jSKvt0AAEQskpfoVmackK8TB6brLz8cKUk6Y0S27vnhKNmtZjldhvaW1foqMCXJZUg7SqpbOl2Ptu1A4OrsEw6pZAUAAJEvPd5987I79r3cXVojScpLi+uWU94BAEDrkLxEt5IQbdO/fzZeV/hNBTebTeqTHCNJ2llSrRvfWBHwmm37A5N0cNu2371qe1qcXS/OOI6VxgEA6IG8U8e9K46v2lWmHz31tb7Zsj+UYbXK7oPu5KX3OhAAAEQmkpeICH1S3atL7jxYrUJP83kvb5IOgbbtcyd1zxiRrUlDaHIPAEBPlJHQmLw0DEPnPPGVlu0o1W3/Xd3qczicLjldRgHafb8AACreSURBVGeF2CJv5WWvZPp2AwAQyUheIiLkprjvuBccqFac3SJJmuJZdbK1lZd1DU69+PW2HlOpudXzPgdksMo4AAA9lbfycl9FnT5dV+zbXlnb0KrX76uo0zF3z9W1/14qw+jaBKa38rI3lZcAAEQ0kpeICLmeysv564tVVe9UnN2iM0fmSGp98nL21wW64721mvLw550WZzjZ6qm87JdO8hIAgJ7Km7xcvbtMv/9PY+udhsNUUtY3uPT297t0y9srddw9n6qitkGfrC3Sl5u6dqq5t/Kyd0psl35dAADQtayhDgDoCLmei9b1hRWSpNF9kjUwM15S65OX3xUclKSQTHvqaoZh+L4vJC8BAOi50hPcC/bM37BPktQnJUa7DtaopKpetQ6nom2WJq954ettuv/D9U22/23uRp00KL3LFs/xJS+pvAQAIKJReYmIkJcaeMd9XL9U9UtzJ+WKyutUVXfkqU92a+OFdldPe+pqReV1qnE4ZTGbfFWrAACg58nwVF5KUpzdopevHq9YTwuevWW1zb5mybYSSdLUEVkB21fsLNXfPtmo57/a1qZrqaLyWv193iZV1Dpa/ZqKWodKquolSb1TSF4CABDJSF4iIuSmNl60xtgsmnFCvpJibUqLc1cTtKb6srre6XtcXtO6Pk/d1VbPIkZ5qbGyWfg1AABAT5We0Ji8nH58vvqlxyknyb0Azl5PZaM/wzC0YmepJOmXpwzQf2eeoOE5iRrVO0mS9MT8zbr7/bX6og1TyH/2r+/0t7kbddNbK1v9mgWeStH8tFglxdha/ToAAND9kLVARPC/aP3NaYOUHOtOWuZ7pkQfmrx8cv5mzXzl+4CKTG/Td6lxGlKkYso4AACQAisvvddNvTzTsPc0U3m5u7RGB6rqZTWbNDwnUWNykzXnupP00k/HBRy362B1q2NYtbtMkvTRmsLDHldd36ACzzXMnFV7JUlnjcpp9dcBAADdE8lLRASTyaRnrjxWN00dol+c3N+33ZucW7e33LetpKpeD8/dqA9W7dVDH2+QJDU4XSo40Jjg3FsW2cnLzcXuykuSlwAA9GzpfsnLZM/N4MNVXq7c5U40DslOCOiHmRJn1+/PHOJ7vr+ivlVfvy3Ty2e9ukyT/rpAD8/dqPkb3CujTyN5CQBAxCN5iYgxdUS2Zp46UBZzY+/K0X3cU5ieWrBFd7+/VrUOpz5dV+RblOdfCwv0XUGJVu8pl8PZePG8J0IrL6vqGvTq4h36ZE2RJPmmeAEAgJ4pxt6YgByanShJyk5qufJyxa5SSe7FEQ917SkD9IPR7mRiYXnjtdTm4gq9/f2uZhOVReV1Ac/3VdQ1OUaSistrfQnLx+dtUq3DpdzUGI3oldjSWwMAABGC1cYR0S45Lk/r9lbotSU79PxX2/TFxn2+JvRJMTaV1Th02XOLAy7cJWl3afMN6ru7O/63Rm8t3eV7PqF/WgijAQAA4eCTG07Wwap65aW5F/Hr5a28bGYmysqd7srLo/o0vQFqMpl00qB0vb9yr2+xnz2lNZry8BeSpFi7VWeOzA54zabiioDna/eW65SEjCbn/mhNoQxDSoy2qrzW3fbn7JE5XbayOQAACB0qLxHR7Faz7jt/lF64aqzS46O0qbhSKzzTnZ77yVhlJESp3ulSWY1D2YnR+uUp7innkTZt3OkydP+H6wMSl/3S45Tt+eMEAAD0XIOzEjTe74Zmjqfn5YIN+3T3+2u1eneZDMOQy2Votac/ZXOVl1Jj1WZhWa0+XVuksx770rdvwYZiGYahWkfjIombiioDXu89/6HeX+nucfmb0wbpH1cco1+e3F/XnDKgje8UAAB0R1ReokeYPDRLn9yQotv+u0pzVhVqeE6ijstP0eOXHK3H5m1UrN2q354xWFv3ufteRtq08Qc+Wq9nv9gasG1C/9QQRQMAAMJZL7+bm89/tU3Pf7VNQ7MTdMqQDFXUNSjaZtbgrPhmX5ud6H7txqIK/eyl7wL2pcXbdc8H6/TC19v0v1knamTvJG3e505extgsqnE4tdyzkrm/4vJafVtQIkk6e1SOeiXH6MyR9LoEAKCnIHmJHiM1zq4nLztGa/aUKysxWiaTSRMHpGnigIm+Y2odLknSmj3lum/OOl02Pk9901q3qE1VXYOumr1EI3sn6fZzRnTKe/BXVu3QT15YrLH5qfrTD4a3eNzb3+9qkriUmDIOAACa1ys5RnaLWfVOl47LT9GKnWVaX1ih9YXuKd4jeiXJaml+Apd3VoenvbiuPrGfUuPseujjDfp220Et8SQhX1m8Q/edP0qbPZWXFx7bRy8v2q7lO0u1obBCJVX1GpufIpvFrA9Xu6eMH5OX7FsJHQAA9BwkL9GjmEwmjTzMIjWDs+KVFmfXgap6PfPFVr21dJc+uu4kZSYGTq8uq3HIZJISo22+bW98u1PfFhzUtwUHdevZw1q8qO8or327Qyt2lWnFrjJF28y6aerQJses2FmqP7y9SpLUNy1W2w9U+/aN60flJQAAaCouyqp/XHmMisvrdPFxuSqrcei9lXv1f0t3afnOUp0zuuWqx8Roq0b1TtLu0ho9eMFoTRmepZW7SvXQxxt8iUtJeuPbHRrdJ0kbPT0vf3h0L726ZIf2VdRp6qPuHpmpcXadNTJbry7ZIclddQkAAHoekpeAn4Rom+bfNEkLNuzT3+dt0qbiSv3+/1Zq9lXH+RrCO5wunf7w56prcOnbW6fIbnUnKRdtPeA7z66DNcpPb13FZjBKqur1zy8bqymfnL9FpdUOXXJcnkZ5GuiX1zp07b+Xqr7BpSnDMvXk5cfosU83ac2ecp0+PEs5SVQuAACA5k0emuV7nBxr15UT+urKCX3lcLpkO8wNWpPJpLd/dbwk+Y4b2StJ6fF27a+s92w3yeE0dIvnBqvJ5K7mHJKVoLV7y33nKqmq1yuLd/iek7wEAKBnInkJHCIx2qZzj+qlodkJ+sHfv9KCDfv070XbdUzfFH26tlgHq+tVXFEnSVq1u0zH9k3RP7/cqk/WFvnO8d32g+1KXn6wcq9u/e8qPXvl2GYrJP/87mrfHwBeryzeoVcW79BRfZJ0+YS++q6gRHvKapWXGqtHLh6jKKtFvz+zaXUmAABAax0ucdnSMWazSScPztDb3+9Wapxd3/xhsu58b61e81RU9k6OUbTNojF5yb7k5Ze/P1UFB6p05fNLfOdhyjgAAD0Tq40DLRiclaBbznIn+/7ywTpNe/wrPfLpRr34TYHvmH98vkX3fbhOf/lgXcBr312+u9Vfp9bh1PrCchmGIafL0K6D1Zr56vcqrXboomcWqqQqMEn5wcq9en/lXlnMJr0360Q9funRSoi2qrenP9WKXWX6/X9W6s3v3CuLP3DBaCX4TW8HAADoapePz1N8lFXXTxmkaJtFN585xLfP23P85EHpvm0J0VadNChDs2ccp8Roq5698tgujxkAAIQHKi+Bw5g+MV8LNuzT5xv3Nbt/rl+15a8mDdDFx+XqlIcW6OvN+1VUXqusQ3plNuePb6/S28vcyU5vc3x/1/57qV6+erzsVrP2VdTptv+6p1jNnDRAo/okaVSfJJ0zOkcmk0kHKuv05ne79OqS7dpZUqOrjs/XxAEszAMAAELr2L6pWn3nVN/z5Fi7/vSD4br7/bW6dtIASdLJgzOUEG1VcqxN8VHuP1NOHZKplXdMbfacAACgZyB5CRyG2WzSP644Vre+404w2i1mfXnzqbrrvbX6bH2xahxO5SRF6/ZzRujMkdmSpGP7pmjp9oN6b8Ue/eyk/kf8Gt7EpSTVO12yWUyKsVlUXtsgSVq8rUS3/2+17v3RKN36ziodrHZoeE6iZk0e5Hudtx9nWnyUrp00QL88ub8KDlSpXyf23QQAAGiPq0/spzNHZivHc7M31m7VVzdPlsVs6vSFDwEAQPdB8hI4ghi7RQ9fPEaXjMtTapxdWYnRevLyY1TrcOrrzfs1oX+a4qIa/yn98OjeWrr9oN5ZttuXvNxYVKFfvPSdkmLtmjoiS2eOyFb/jHgZhuGrtvzzD4br9OFZ6pUcI4vZnYz8bH2Rrv7Xd3ptyU5tKKzQ9ztKZbOY9NcfH+VbKKg5ZrNJ/TPiO/cbAwAA0E69D+ljmRRDqxsAABCIW5pAK43rl6qBmY0JwWibRacNywpIXErSD0blyGo2ac2ecv3pv6sluaeXFxyo1oqdpXrwow2a/LfPdcYjn+u2/65WvdOlKKtZl47LU25qrC9xKblX+rz17GGSpO93lEqSfnpCPw3vldjJ7xYAAAAAACD0SF4CHSzFU50pSS8v2q4l20pUXF7r3hdr08mDM2Q1m7SxqFKvLHavsjl1RLZi7JZmz3f1if108dhcSVL/9Dj94uQjT0UHAAAAAACIBEwbBzrBNZMG+Koub3hjuXaX1kiSrp8yWNOPz1dZtUOfbSjSR6sLtW1/la9RfXNMJpPuO3+Ufjy2j4b3SlSsnX+2AAAAAACgZyALAnSCK8bnySTptv+u9iUuJSkrMUqSlBRr04+O7qMfHd2nVeczm00am5/aGaECAAAAAACELaaNA53AZDLpigl99fdLjw7YPjSbXpUAAAAAAACtReUl0InOOaqXRvZOUmK0VRW1DcpPjwt1SAAAAAAAAN0GyUugk/XzJCzT4qNCHAkAAAAAAED3wrRxAAAAAAAAAGGJ5CUAAAAAAACAsETyEgAAAAAAAEBYInkJAAAAAAAAICxFbPLyySefVH5+vqKjozV+/HgtWbIk1CEBAAAAAAAAaIOITF6+8cYbuvHGG3X77bfr+++/11FHHaWpU6equLg41KEBAAAAAAAAaKWITF4+/PDD+vnPf64ZM2Zo+PDh+sc//qHY2Fi98MILoQ4NAAAAAAAAQCtZQx1AR6uvr9fSpUt1yy23+LaZzWZNmTJFCxcubPY1dXV1qqur8z0vLy+XJDkcDjkcjs4NuIt530+kvS90HsYM2ooxg7ZizKCtGDNoK8YM2ooxg2AwbtBWkT5mOup9mQzDMDrkTGFiz5496t27t7755htNnDjRt/33v/+9Pv/8cy1evLjJa+644w7deeedTba/+uqrio2N7dR4AQAAAAAAgEhTXV2tyy67TGVlZUpMTAz6PBFXeRmMW265RTfeeKPveXl5uXJzc3XGGWe065sbjhwOh+bOnavTTz9dNpst1OGgG2DMoK0YM2grxgzaijGDtmLMoK0YMwgG4wZtFeljxjuzub0iLnmZnp4ui8WioqKigO1FRUXKzs5u9jVRUVGKiopqst1ms0Xk4JEi+72hczBm0FaMGbQVYwZtxZhBWzFm0FaMGQSDcYO2itQx01HvKeIW7LHb7Tr22GM1b9483zaXy6V58+YFTCMHAAAAAAAAEN4irvJSkm688UZNnz5dY8eO1bhx4/Too4+qqqpKM2bMCHVoAAAAAAAAAFopIpOXF198sfbt26c///nPKiws1JgxY/TRRx8pKysr1KEBAAAAAAAAaKWITF5K0qxZszRr1qxQhwEAAAAAAAAgSBHX8xIAAAAAAABAZCB5CQAAAAAAACAskbwEAAAAAAAAEJZIXgIAAAAAAAAISyQvAQAAAAAAAIQlkpcAAAAAAAAAwpI11AGEI8MwJEnl5eUhjqTjORwOVVdXq7y8XDabLdThoBtgzKCtGDNoK8YM2ooxg7ZizKCtGDMIBuMGbRXpY8abV/Pm2YJF8rIZFRUVkqTc3NwQRwIAAAAAAAB0XxUVFUpKSgr69SajvenPCORyubRnzx4lJCTIZDKFOpwOVV5ertzcXO3cuVOJiYmhDgfdAGMGbcWYQVsxZtBWjBm0FWMGbcWYQTAYN2irSB8zhmGooqJCvXr1ktkcfOdKKi+bYTab1adPn1CH0akSExMj8h8GOg9jBm3FmEFbMWbQVowZtBVjBm3FmEEwGDdoq0geM+2puPRiwR4AAAAAAAAAYYnkJQAAAAAAAICwRPKyh4mKitLtt9+uqKioUIeCboIxg7ZizKCtGDNoK8YM2ooxg7ZizCAYjBu0FWOmdViwBwAAAAAAAEBYovISAAAAAAAAQFgieQkAAAAAAAAgLJG8BAAAAAAAABCWSF4CAAAAAAAACEs9Nnn5xRdf6JxzzlGvXr1kMpn03//+N2C/w+HQzTffrFGjRikuLk69evXST37yE+3Zs6fJuWpqahQXF6fNmzdLkhYsWKBjjjlGUVFRGjhwoF588cWA4++77z4dd9xxSkhIUGZmpn74wx9qw4YNzcbZr18/ffrpp1qwYIHOO+885eTkKC4uTmPGjNErr7wScOyaNWt0wQUXKD8/XyaTSY8++mirvhcrV67USSedpOjoaOXm5urBBx9scsxbb72loUOHKjo6WqNGjdKcOXOOeN4dO3Zo2rRpio2NVWZmpm666SY1NDQEHHOk71VzSkpKdPnllysxMVHJycm6+uqrVVlZ2eb31FaMmUat+f6WlpZq5syZysnJUVRUlAYPHnzEcdNZP9va2lrNnDlTaWlpio+P1wUXXKCioqKAY1ozXtuKMeNWW1urq666SqNGjZLVatUPf/jDJse8/fbbOv3005WRkaHExERNnDhRH3/88RHPzZjpuWNGkl555RUdddRRio2NVU5Ojn7605/qwIEDRzx3Z/xsDcPQn//8Z+Xk5CgmJkZTpkzRpk2bAo5pzXhtq1COmaefflqjR49WYmKi79/thx9+2Gyc4fLZxPUMY8Yf1zOtw5hx43qm9RgzblzPtE0ox42/+++/XyaTSddff32z+8Pl86nHXdMYPdScOXOMW2+91Xj77bcNScY777wTsL+0tNSYMmWK8cYbbxjr1683Fi5caIwbN8449thjm5zr3XffNYYNG2YYhmFs3brViI2NNW688UZj7dq1xt///nfDYrEYH330ke/4qVOnGrNnzzZWr15tLF++3Dj77LONvLw8o7KyMuC8K1asMJKSkoz6+nrjnnvuMW677Tbj66+/NjZv3mw8+uijhtlsNt577z3f8UuWLDF+97vfGa+99pqRnZ1tPPLII0f8PpSVlRlZWVnG5Zdfbqxevdp47bXXjJiYGOOZZ57xHfP1118bFovFePDBB421a9cat912m2Gz2YxVq1a1eN6GhgZj5MiRxpQpU4xly5YZc+bMMdLT041bbrnFd0xrvlfNOfPMM42jjjrKWLRokfHll18aAwcONC699NI2vadgMGbcWvP9raurM8aOHWucffbZxldffWVs27bNWLBggbF8+fLDnruzfrbXXHONkZuba8ybN8/47rvvjAkTJhjHH3+8b39rxmswGDNulZWVxjXXXGM8++yzxtSpU43zzjuvyTHXXXed8cADDxhLliwxNm7caNxyyy2GzWYzvv/++8OemzHTc8fMV199ZZjNZuOxxx4ztm7danz55ZfGiBEjjB/96EeHPXdn/Wzvv/9+Iykpyfjvf/9rrFixwjj33HONfv36GTU1Nb5jjjRegxHKMfO///3P+OCDD4yNGzcaGzZsMP74xz8aNpvNWL16dcB5w+WziesZN8aMG9czrceYceN6pvUYM25cz7RNKMeN15IlS4z8/Hxj9OjRxnXXXddkf7h8PvXEa5oem7z019w/jOYsWbLEkGRs3749YPtPf/pT4+abbzYMwzB+//vfGyNGjAjYf/HFFxtTp05t8bzFxcWGJOPzzz8P2H7XXXcZF198cYuvO/vss40ZM2Y0u69v376t+ofx1FNPGSkpKUZdXZ1v280332wMGTLE9/yiiy4ypk2bFvC68ePHG7/85S9bPO+cOXMMs9lsFBYW+rY9/fTTRmJiou9rBfO9Wrt2rSHJ+Pbbb33bPvzwQ8NkMhm7d+9u9XtqL8bM4b+/Tz/9tNG/f3+jvr7+iOfz6qyfbWlpqWGz2Yy33nrLt23dunWGJGPhwoWGYbRuvLZXTx4z/qZPn97shVtzhg8fbtx5550t7mfMuPXUMfPQQw8Z/fv3D9j2+OOPG717927xXJ31s3W5XEZ2drbx0EMPBXytqKgo47XXXjMMo3Xjtb1CPWYMwzBSUlKMf/7znwHbwuWzieuZphgzXM+0VU8eM/64nmk9xowb1zNtE4pxU1FRYQwaNMiYO3euccoppzSbvAyXz6eeeE3TY6eNB6OsrEwmk0nJycm+bS6XS++//77OO+88SdLChQs1ZcqUgNdNnTpVCxcuPOx5JSk1NTVg+//+9z/feVt63aGvaauFCxfq5JNPlt1uD4h3w4YNOnjwoO+YI72nO+64Q/n5+QHnHTVqlLKysgJeU15erjVr1rT6vC+++KJMJlPAeZOTkzV27FjftilTpshsNmvx4sWtfk9dpaeOmf/973+aOHGiZs6cqaysLI0cOVL33nuvnE6n7zWd9bNdsGCBTCaTCgoKJElLly6Vw+EI+B4PHTpUeXl5vu9xa8ZrV4nEMRMMl8ulioqKgK/NmGleTx0zEydO1M6dOzVnzhwZhqGioiL95z//0dlnn+07prN+tgUFBTKZTFqwYIEkadu2bSosLAw4b1JSksaPHx9w3iON167SGWPG6XTq9ddfV1VVlSZOnBiwL1w+m7ieCV5PHTNczwQvEsdMMLieab2eOma4nmmfjhw3M2fO1LRp05oc6y9cPp964jUNyctWqq2t1c0336xLL71UiYmJvu2LFi2SJI0fP16SVFhYGDAYJCkrK0vl5eWqqalpcl6Xy6Xrr79eJ5xwgkaOHOnbvnv3bq1cuVJnnXVWs/G8+eab+vbbbzVjxox2va+W4vXuO9wx3v2SlJ6ergEDBnTIef2/V0lJSRoyZEjAeTMzMwNeY7ValZqaesTz+n/trtCTx8zWrVv1n//8R06nU3PmzNGf/vQn/e1vf9Nf/vIX32s662cbGxurIUOGyGaz+bbb7faADzTv6xgzXTNmgvHXv/5VlZWVuuiii3zbGDNN9eQxc8IJJ+iVV17RxRdfLLvdruzsbCUlJenJJ5/0HdNZP1ubzaYhQ4YoNjY2YPvhPitbM167QkePmVWrVik+Pl5RUVG65ppr9M4772j48OG+/eH02cT1THB68pjheiY4kTpmgsH1TOv05DHD9UzwOnLcvP766/r+++913333tfj1wunzqSde05C8bAWHw6GLLrpIhmHo6aefDtj37rvv6gc/+IHM5uC+lTNnztTq1av1+uuvB2z/3//+pxNPPLHJLyRJmj9/vmbMmKHnnntOI0aMCOrrdrRZs2Zp3rx5HX7eH/3oR1q/fn2Hn7ez9fQx43K5lJmZqWeffVbHHnusLr74Yt166636xz/+4Tums36248aN0/r169W7d+8OP3dn6uljxt+rr76qO++8U2+++WbAByFjJlBPHzNr167Vddddpz//+c9aunSpPvroIxUUFOiaa67xHdNZP9vevXtr/fr1GjduXIeet7N1xpgZMmSIli9frsWLF+vaa6/V9OnTtXbtWt/+cBozrcH1TKCePma4nmm7nj5m/HE90zo9fcxwPROcjhw3O3fu1HXXXadXXnlF0dHRLR4XTuOmNSLtmobk5RF4/1Fs375dc+fODcjoS+4BfO655/qeZ2dnN1nlq6ioSImJiYqJiQnYPmvWLL3//vuaP3+++vTpc9jzen3++ec655xz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AAAAAAAAAGGJ5CUAAAAAAACAsETyEgAAAAAAAEBYInkJAAAAAAAAICyRvAQAAAAAAAAQlkheAgAAoEVXXXWV8vPz2/w6k8mkWbNmdXxAnSjY9woAAIDOQ/ISAACgB3rxxRdlMpl8/0VHR2vw4MGaNWuWioqKQh1eh/F/j4f7b8GCBaEOFQAAAM2whjoAAAAAhM5dd92lfv36qba2Vl999ZWefvppzZkzR6tXr1ZsbKyee+45uVyuUIcZtJdffjng+UsvvaS5c+c22T5s2LBu/14BAAAiEclLAACAHuyss87S2LFjJUk/+9nPlJaWpocffljvvvuuLr30UtlsthBH2D5XXHFFwPNFixZp7ty5TbYDAAAgPDFtHAAAAD6TJ0+WJG3btk1S830gXS6XHnvsMY0aNUrR0dHKyMjQmWeeqe++++6w5/7LX/4is9msv//975Kk/Px8XXXVVU2OmzRpkiZNmuR7vmDBAplMJr3xxhv64x//qOzsbMXFxencc8/Vzp07g3+zhzj0vRYUFMhkMumvf/2rnnzySfXv31+xsbE644wztHPnThmGobvvvlt9+vRRTEyMzjvvPJWUlDQ574cffqiTTjpJcXFxSkhI0LRp07RmzZoOixsAACCSUXkJAAAAny1btkiS0tLSWjzm6quv1osvvqizzjpLP/vZz9TQ0KAvv/xSixYt8lVxHuq2227Tvffeq2eeeUY///nPg4rtnnvukclk0s0336zi4mI9+uijmjJlipYvX66YmJigztkar7zyiurr6/XrX/9aJSUlevDBB3XRRRdp8uTJWrBggW6++WZt3rxZf//73/W73/1OL7zwgu+1L7/8sqZPn66pU6fqgQceUHV1tZ5++mmdeOKJWrZsGQsEAQAAHAHJSwAAgB6srKxM+/fvV21trb7++mvdddddiomJ0Q9+8INmj58/f75efPFF/eY3v9Fjjz3m2/7b3/5WhmE0+5rf/e53euSRRzR79mxNnz496FhLSkq0bt06JSQkSJKOOeYYXXTRRXruuef0m9/8JujzHsnu3bu1adMmJSUlSZKcTqfuu+8+1dTU6LvvvpPV6r6k3rdvn1555RU9/fTTioqKUmVlpX7zm9/oZz/7mZ599lnf+aZPn64hQ4bo3nvvDdgOAACAppg2DgAA0INNmTJFGRkZys3N1SWXXKL4+Hi988476t27d7PH/9///Z9MJpNuv/32JvtMJlPAc8MwNGvWLD322GP697//3a7EpST95Cc/8SUuJenCCy9UTk6O5syZ067zHsmPf/xjX+JSksaPHy/J3U/Tm7j0bq+vr9fu3bslSXPnzlVpaakuvfRS7d+/3/efxWLR+PHjNX/+/E6NGwAAIBJQeQkAANCDPfnkkxo8eLCsVquysrI0ZMgQmc0t39/esmWLevXqpdTU1COe+6WXXlJlZaWefvppXXrppe2OddCgQQHPTSaTBg4cqIKCgnaf+3Dy8vICnnsTmbm5uc1uP3jwoCRp06ZNkhr7iB4qMTGxQ+MEAACIRCQvAQAAerBx48a12KeyvU444QQtX75cTzzxhC666KImCc9DKzW9nE6nLBZLp8QUjJZiaWm7d/q8y+WS5O57mZ2d3eQ4/6pNAAAANI8rJgAAALTagAED9PHHH6ukpOSI1ZcDBw7Ugw8+qEmTJunMM8/UvHnzAqZ9p6SkqLS0tMnrtm/frv79+zfZ7q1k9DIMQ5s3b9bo0aODezOdbMCAAZKkzMxMTZkyJcTRAAAAdE/0vAQAAECrXXDBBTIMQ3feeWeTfc0t2DN69GjNmTNH69at0znnnKOamhrfvgEDBmjRokWqr6/3bXv//fe1c+fOZr/2Sy+9pIqKCt/z//znP9q7d6/OOuus9rylTjN16lQlJibq3nvvlcPhaLJ/3759IYgKAACge6HyEgAAAK126qmn6sorr9Tjjz+uTZs26cwzz5TL5dKXX36pU089VbNmzWrymgkTJujdd9/V2WefrQsvvFD//e9/ZbPZ9LOf/Uz/+c9/dOaZZ+qiiy7Sli1b9O9//9tXsXio1NRUnXjiiZoxY4aKior06KOPauDAgfr5z3/e2W87KImJiXr66ad15ZVX6phjjtEll1yijIwM7dixQx988IFOOOEEPfHEE6EOEwAAIKxReQkAAIA2mT17th566CFt27ZNN910k+69917V1NTo+OOPb/E1kydP1ptvvqlPPvlEV155pVwul6ZOnaq//e1v2rhxo66//notXLhQ77//vvr06dPsOf74xz9q2rRpuu+++/TYY4/ptNNO07x58xQbG9tZb7XdLrvsMs2bN0+9e/fWQw89pOuuu06vv/66xowZoxkzZoQ6PAAAgLBnMpqb3wMAAACEiQULFujUU0/VW2+9pQsvvDDU4QAAAKALUXkJAAAAAAAAICyRvAQAAAAAAAAQlkheAgAAAAAAAAhL9LwEAAAAAAAAEJaovAQAAAAAAAAQlkheAgAAAAAAAAhLJC8BAAAAAAAAhCWSlwAAAAAAAADCEslLAAAAAAAAAGGJ5CUAAAAAAACAsETyEgAAAAAAAEBYInkJAAAAAAAAICyRvAQAAAAAAAAQlv4fHTWyf+ju86YAAAAASUVORK5CYII=", + "image/png": "iVBORw0KGgoAAAANSUhEUgAABS8AAAJeCAYAAABVkfCjAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs3Xd4HOW5NvB7tqv3brn3hm1MN70bCC2FhDRSSE8IpJ+QL5BwEpKQQ0gjJAdySCghIRCKKaaDbdx7t+Um2ep1e5vvj5l3dmZ2V9KuVtJKvn/XlSva3dnVyKxW7zzvUyRZlmUQERERERERERERZRnLaJ8AERERERERERERUSIMXhIREREREREREVFWYvCSiIiIiIiIiIiIshKDl0RERERERERERJSVGLwkIiIiIiIiIiKirMTgJREREREREREREWUlBi+JiIiIiIiIiIgoKzF4SURERERERERERFnJNtonkI2i0SiOHz+OgoICSJI02qdDREREREREREQ0psiyjL6+PtTW1sJiST9/ksHLBI4fP476+vrRPg0iIiIiIiIiIqIx7dixY5gwYULaz2fwMoGCggIAyj9uYWHhKJ9NZoVCIbz66qu47LLLYLfbR/t0aAzge4ZSxfcMpYrvGUoV3zOUKr5nKFV8z1A6+L6hVI3390xvby/q6+u1OFu6GLxMQJSKFxYWjsvgZW5uLgoLC8flLwZlHt8zlCq+ZyhVfM9QqvieoVTxPUOp4nuG0sH3DaXqZHnPDLUlIwf2EBERERERERERUVZi8JKIiIiIiIiIiIiyEoOXRERERERERERElJUYvCQiIiIiIiIiIqKsxOAlERERERERERERZSUGL4mIiIiIiIiIiCgrMXhJREREREREREREWYnBSyIiIiIiIiIiIspKDF4SERERERERERFRVmLwkoiIiIiIiIiIiLISg5dERERERERERESUlRi8JCIiIiIiIiIioqzE4CURERERERERERFlJQYviYiIiIiIiIiIKCsxeElERERERERERERZicFLIiIiIiIiIiIiykoMXhIREREREREREVFWYvCSiIiIiIiIiIiIshKDl0RERERERERERJSVGLwkIiIiIiIiIiKirMTgJRERERERERFRFvnzOw342hObEYnKo30qRKOOwUsiIiIiIiIioixyz4rdeH7rcbyzrw2yLCPKICad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jBu0FWOmdViwBwAAAAAAAEBYovISAAAAAAAAQFgieQkAAAAAAAAgLJG8BAAAAAAAABCWSF4CAAAAAAAACEs9Nnn5xRdf6JxzzlGvXr1kMpn03//+N2C/w+HQzTffrFGjRikuLk69evXST37yE+3Zs6fJuWpqahQXF6fNmzdLkhYsWKBjjjlGUVFRGjhwoF588cWA4++77z4dd9xxSkhIUGZmpn74wx9qw4YNzcbZr18/ffrpp1qwYIHOO+885eTkKC4uTmPGjNErr7wScOyaNWt0wQUXKD8/XyaTSY8++mirvhcrV67USSedpOjoaOXm5urBBx9scsxbb72loUOHKjo6WqNGjdKcOXOOeN4dO3Zo2rRpio2NVWZmpm666SY1NDQEHHOk71VzSkpKdPnllysxMVHJycm6+uqrVVlZ2eb31FaMmUat+f6WlpZq5syZysnJUVRUlAYPHnzEcdNZP9va2lrNnDlTaWlpio+P1wUXXKCioqKAY1ozXtuKMeNWW1urq666SqNGjZLVatUPf/jDJse8/fbbOv3005WRkaHExERNnDhRH3/88RHPzZjpuWNGkl555RUdddRRio2NVU5Ojn7605/qwIEDRzx3Z/xsDcPQn//8Z+Xk5CgmJkZTpkzRpk2bAo5pzXhtq1COmaefflqjR49WYmKi79/thx9+2Gyc4fLZxPUMY8Yf1zOtw5hx43qm9RgzblzPtE0ox42/+++/XyaTSddff32z+8Pl86nHXdMYPdScOXOMW2+91Xj77bcNScY777wTsL+0tNSYMmWK8cYbbxjr1683Fi5caIwbN8449thjm5zr3XffNYYNG2YYhmFs3brViI2NNW688UZj7dq1xt///nfDYrEYH330ke/4qVOnGrNnzzZWr15tLF++3Dj77LONvLw8o7KyMuC8K1asMJKSkoz6+nrjnnvuMW677Tbj66+/NjZv3mw8+uijhtlsNt577z3f8UuWLDF+97vfGa+99pqRnZ1tPPLII0f8PpSVlRlZWVnG5Zdfbqxevdp47bXXjJiYGOOZZ57xHfP1118bFovFePDBB421a9cat912m2Gz2YxVq1a1eN6GhgZj5MiRxpQpU4xly5YZc+bMMdLT041bbrnFd0xrvlfNOfPMM42jjjrKWLRokfHll18aAwcONC699NI2vadgMGbcWvP9raurM8aOHWucffbZxldffWVs27bNWLBggbF8+fLDnruzfrbXXHONkZuba8ybN8/47rvvjAkTJhjHH3+8b39rxmswGDNulZWVxjXXXGM8++yzxtSpU43zzjuvyTHXXXed8cADDxhLliwxNm7caNxyyy2GzWYzvv/++8OemzHTc8fMV199ZZjNZuOxxx4ztm7danz55ZfGiBEjjB/96EeHPXdn/Wzvv/9+Iykpyfjvf/9rrFixwjj33HONfv36GTU1Nb5jjjRegxHKMfO///3P+OCDD4yNGzcaGzZsMP74xz8aNpvNWL16dcB5w+WziesZN8aMG9czrceYceN6pvUYM25cz7RNKMeN15IlS4z8/Hxj9OjRxnXXXddkf7h8PvXEa5oem7z019w/jOYsWbLEkGRs3749YPtPf/pT4+abbzYMwzB+//vfGyNGjAjYf/HFFxtTp05t8bzFxcWGJOPzzz8P2H7XXXcZF198cYuvO/vss40ZM2Y0u69v376t+ofx1FNPGSkpKUZdXZ1v280332wMGTLE9/yiiy4ypk2bFvC68ePHG7/85S9bPO+cOXMMs9lsFBYW+rY9/fTTRmJiou9rBfO9Wrt2rSHJ+Pbbb33bPvzwQ8NkMhm7d+9u9XtqL8bM4b+/Tz/9tNG/f3+jvr7+iOfz6qyfbWlpqWGz2Yy33nrLt23dunWGJGPhwoWGYbRuvLZXTx4z/qZPn97shVtzhg8fbtx5550t7mfMuPXUMfPQQw8Z/fv3D9j2+OOPG717927xXJ31s3W5XEZ2drbx0EMPBXytqKgo47XXXjMMo3Xjtb1CPWYMwzBSUlKMf/7znwHbwuWzieuZphgzXM+0VU8eM/64nmk9xowb1zNtE4pxU1FRYQwaNMiYO3euccoppzSbvAyXz6eeeE3TY6eNB6OsrEwmk0nJycm+bS6XS++//77OO+88SdLChQs1ZcqUgNdNnTpVCxcuPOx5JSk1NTVg+//+9z/feVt63aGvaauFCxfq5JNPlt1uD4h3w4YNOnjwoO+YI72nO+64Q/n5+QHnHTVqlLKysgJeU15erjVr1rT6vC+++KJMJlPAeZOTkzV27FjftilTpshsNmvx4sWtfk9dpaeOmf/973+aOHGiZs6cqaysLI0cOVL33nuvnE6n7zWd9bNdsGCBTCaTCgoKJElLly6Vw+EI+B4PHTpUeXl5vu9xa8ZrV4nEMRMMl8ulioqKgK/NmGleTx0zEydO1M6dOzVnzhwZhqGioiL95z//0dlnn+07prN+tgUFBTKZTFqwYIEkadu2bSosLAw4b1JSksaPHx9w3iON167SGWPG6XTq9ddfV1VVlSZOnBiwL1w+m7ieCV5PHTNczwQvEsdMMLieab2eOma4nmmfjhw3M2fO1LRp05oc6y9cPp964jUNyctWqq2t1c0336xLL71UiYmJvu2LFi2SJI0fP16SVFhYGDAYJCkrK0vl5eWqqalpcl6Xy6Xrr79eJ5xwgkaOHOnbvnv3bq1cuVJnnXVWs/G8+eab+vbbbzVjxox2va+W4vXuO9wx3v2SlJ6ergEDBnTIef2/V0lJSRoyZEjAeTMzMwNeY7ValZqaesTz+n/trtCTx8zWrVv1n//8R06nU3PmzNGf/vQn/e1vf9Nf/vIX32s662cbGxurIUOGyGaz+bbb7faADzTv6xgzXTNmgvHXv/5VlZWVuuiii3zbGDNN9eQxc8IJJ+iVV17RxRdfLLvdruzsbCUlJenJJ5/0HdNZP1ubzaYhQ4YoNjY2YPvhPitbM167QkePmVWrVik+Pl5RUVG65ppr9M4772j48OG+/eH02cT1THB68pjheiY4kTpmgsH1TOv05DHD9UzwOnLcvP766/r+++913333tfj1wunzqSde05C8bAWHw6GLLrpIhmHo6aefDtj37rvv6gc/+IHM5uC+lTNnztTq1av1+uuvB2z/3//+pxNPPLHJLyRJmj9/vmbMmKHnnntOI0aMCOrrdrRZs2Zp3rx5HX7eH/3oR1q/fn2Hn7ez9fQx43K5lJmZqWeffVbHHnusLr74Yt166636xz/+4Tums36248aN0/r169W7d+8OP3dn6uljxt+rr76qO++8U2+++WbAByFjJlBPHzNr167Vddddpz//+c9aunSpPvroIxUUFOiaa67xHdNZP9vevXtr/fr1GjduXIeet7N1xpgZMmSIli9frsWLF+vaa6/V9OnTtXbtWt/+cBozrcH1TKCePma4nmm7nj5m/HE90zo9fcxwPROcjhw3O3fu1HXXXadXXnlF0dHRLR4XTuOmNSLtmobk5RF4/1Fs375dc+fODcjoS+4BfO655/qeZ2dnN1nlq6ioSImJiYqJiQnYPmvWLL3//vuaP3+++vTpc9jzen3++ec655xz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AAAAAAAAAGGJ5CUAAAAAAACAsETyEgAAAAAAAEBYInkJAAAAAAAAICyRvAQAAAAAAAAQlkheAgAAoEVXXXWV8vPz2/w6k8mkWbNmdXxAnSjY9woAAIDOQ/ISAACgB3rxxRdlMpl8/0VHR2vw4MGaNWuWioqKQh1eh/F/j4f7b8GCBaEOFQAAAM2whjoAAAAAhM5dd92lfv36qba2Vl999ZWefvppzZkzR6tXr1ZsbKyee+45uVyuUIcZtJdffjng+UsvvaS5c+c22T5s2LBu/14BAAAiEclLAACAHuyss87S2LFjJUk/+9nPlJaWpocffljvvvuuLr30UtlsthBH2D5XXHFFwPNFixZp7ty5TbYDAAAgPDFtHAAAAD6TJ0+WJG3btk1S830gXS6XHnvsMY0aNUrR0dHKyMjQmWeeqe++++6w5/7LX/4is9msv//975Kk/Px8XXXVVU2OmzRpkiZNmuR7vmDBAplMJr3xxhv64x//qOzsbMXFxencc8/Vzp07g3+zhzj0vRYUFMhkMumvf/2rnnzySfXv31+xsbE644wztHPnThmGobvvvlt9+vRRTEyMzjvvPJWUlDQ574cffqiTTjpJcXFxSkhI0LRp07RmzZoOixsAACCSUXkJAAAAny1btkiS0tLSWjzm6quv1osvvqizzjpLP/vZz9TQ0KAvv/xSixYt8lVxHuq2227Tvffeq2eeeUY///nPg4rtnnvukclk0s0336zi4mI9+uijmjJlipYvX66YmJigztkar7zyiurr6/XrX/9aJSUlevDBB3XRRRdp8uTJWrBggW6++WZt3rxZf//73/W73/1OL7zwgu+1L7/8sqZPn66pU6fqgQceUHV1tZ5++mmdeOKJWrZsGQsEAQAAHAHJSwAAgB6srKxM+/fvV21trb7++mvdddddiomJ0Q9+8INmj58/f75efPFF/eY3v9Fjjz3m2/7b3/5WhmE0+5rf/e53euSRRzR79mxNnz496FhLSkq0bt06JSQkSJKOOeYYXXTRRXruuef0m9/8JujzHsnu3bu1adMmJSUlSZKcTqfuu+8+1dTU6LvvvpPV6r6k3rdvn1555RU9/fTTioqKUmVlpX7zm9/oZz/7mZ599lnf+aZPn64hQ4bo3nvvDdgOAACAppg2DgAA0INNmTJFGRkZys3N1SWXXKL4+Hi988476t27d7PH/9///Z9MJpNuv/32JvtMJlPAc8MwNGvWLD322GP697//3a7EpST95Cc/8SUuJenCCy9UTk6O5syZ067zHsmPf/xjX+JSksaPHy/J3U/Tm7j0bq+vr9fu3bslSXPnzlVpaakuvfRS7d+/3/efxWLR+PHjNX/+/E6NGwAAIBJQeQkAANCDPfnkkxo8eLCsVquysrI0ZMgQmc0t39/esmWLevXqpdTU1COe+6WXXlJlZaWefvppXXrppe2OddCgQQHPTSaTBg4cqIKCgnaf+3Dy8vICnnsTmbm5uc1uP3jwoCRp06ZNkhr7iB4qMTGxQ+MEAACIRCQvAQAAerBx48a12KeyvU444QQtX75cTzzxhC666KImCc9DKzW9nE6nLBZLp8QUjJZiaWm7d/q8y+WS5O57mZ2d3eQ4/6pNAAAANI8rJgAAALTagAED9PHHH6ukpOSI1ZcDBw7Ugw8+qEmTJunMM8/UvHnzAqZ9p6SkqLS0tMnrtm/frv79+zfZ7q1k9DIMQ5s3b9bo0aODezOdbMCAAZKkzMxMTZkyJcTRAAAAdE/0vAQAAECrXXDBBTIMQ3feeWeTfc0t2DN69GjNmTNH69at0znnnKOamhrfvgEDBmjRokWqr6/3bXv//fe1c+fOZr/2Sy+9pIqKCt/z//znP9q7d6/OOuus9rylTjN16lQlJibq3nvvlcPhaLJ/3759IYgKAACge6HyEgAAAK126qmn6sorr9Tjjz+uTZs26cwzz5TL5dKXX36pU089VbNmzWrymgkTJujdd9/V2WefrQsvvFD//e9/ZbPZ9LOf/Uz/+c9/dOaZZ+qiiy7Sli1b9O9//9tXsXio1NRUnXjiiZoxY4aKior06KOPauDAgfr5z3/e2W87KImJiXr66ad15ZVX6phjjtEll1yijIwM7dixQx988IFOOOEEPfHEE6EOEwAAIKxReQkAAIA2mT17th566CFt27ZNN910k+69917V1NTo+OOPb/E1kydP1ptvvqlPPvlEV155pVwul6ZOnaq//e1v2rhxo66//notXLhQ77//vvr06dPsOf74xz9q2rRpuu+++/TYY4/ptNNO07x58xQbG9tZb7XdLrvsMs2bN0+9e/fWQw89pOuuu06vv/66xowZoxkzZoQ6PAAAgLBnMpqb3wMAAACEiQULFujUU0/VW2+9pQsvvDDU4QAAAKALUXkJAAAAAAAAICyRvAQAAAAAAAAQlkheAgAAAAAAAAhL9LwEAAAAAAAAEJaovAQAAAAAAAAQlkheAgAAAAAAAAhLJC8BAAAAAAAAhCWSlwAAAAAAAADCEslLAAAAAAAAAGGJ5CUAAAAAAACAsETyEgAAAAAAAEBYInkJAAAAAAAAICyRvAQAAAAAAAAQlv4fHTWyf+ju86YAAAAASUVORK5CYII=", "text/plain": [ "
" ] @@ -554,7 +549,7 @@ ], "metadata": { "kernelspec": { - "display_name": "venv (3.10.17)", + "display_name": "venv", "language": "python", "name": "python3" }, @@ -568,7 +563,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.17" + "version": "3.10.16" } }, "nbformat": 4, diff --git a/notebooks/dataframes/anywidget_mode.ipynb b/notebooks/dataframes/anywidget_mode.ipynb index e5bfa88729..328d4a05f1 100644 --- a/notebooks/dataframes/anywidget_mode.ipynb +++ b/notebooks/dataframes/anywidget_mode.ipynb @@ -32,7 +32,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 2, "id": "ca22f059", "metadata": {}, "outputs": [], @@ -50,7 +50,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 3, "id": "1bc5aaf3", "metadata": {}, "outputs": [], @@ -69,7 +69,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 4, "id": "f289d250", "metadata": {}, "outputs": [ @@ -96,7 +96,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 5, "id": "42bb02ab", "metadata": {}, "outputs": [ @@ -123,14 +123,14 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "id": "ce250157", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "a85f5799996d4de1a7912182c43fdf54", + "model_id": "9e3e413eb0774a62818c58d217af8488", "version_major": 2, "version_minor": 1 }, @@ -148,7 +148,7 @@ "Computation deferred. Computation will process 171.4 MB" ] }, - "execution_count": 5, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } @@ -167,7 +167,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 7, "id": "6920d49b", "metadata": {}, "outputs": [ @@ -181,7 +181,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "261075a0d1d2487f804926884fe55eb2", + "model_id": "df5e93f0d03f45cda67aa6da7f9ef1ae", "version_major": 2, "version_minor": 1 }, @@ -189,7 +189,7 @@ "TableWidget(page_size=10, row_count=5552452, table_html='" ] }, - "execution_count": 9, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" }, { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAABREAAAKnCAYAAAARNgr5AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvc2/+5QAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs/Xu8JVdd5o8/q2rvfbo7Tec2SXeiIQSJA4EAMfiFNo4wEBNCRIEgioxDNKM/eQUZyIDKGEkICMgPUISgqBBwEJ3BLzAYuSREAkhCuF8EBxSIHcyNEZJOk3SfvavW94+qVbXW2rXP6VprnbX2qfO8X69+7XPpXadq712raj3r+XweIaWUIIQQQgghhBBCCCGEkAVkqXeAEEIIIYQQQgghhBCy3FBEJIQQQgghhBBCCCGErAlFREIIIYQQQgghhBBCyJpQRCSEEEIIIYQQQgghhKwJRURCCCGEEEIIIYQQQsiaUEQkhBBCCCGEEEIIIYSsCUVEQgghhBBCCCGEEELImlBEJIQQQgghhBBCCCGErMko9Q64UpYlbr31VtzvfveDECL17hBCCCGEEEIIIYQQsqmQUuKee+7BiSeeiCxb22u4aUXEW2+9FSeddFLq3SCEEEIIIYQQQgghZFNzyy234Ad/8AfX/D+bVkS83/3uB6A6yF27diXeG0IIIYQQQgghhBBCNhf79+/HSSed1Ohsa7FpRURVwrxr1y6KiIQQQgghhBBCCCGEOHI4rQIZrEIIIYQQQgghhBBCCFkTioiEEEIIIYQQQgghhJA1oYhICCGEEEIIIYQQQghZk03bE/FwkFJiNpuhKIrUu0KIN3meYzQaHVafAkIIIYQQQgghhJCQDFZEXF1dxW233YZ777039a4QEowdO3bghBNOwGQySb0rhBBCCCGEEEII2UIMUkQsyxLf+ta3kOc5TjzxREwmE7q3yKZGSonV1VV85zvfwbe+9S2ceuqpyDJ2IyCEEEIIIYQQQkgcBikirq6uoixLnHTSSdixY0fq3SEkCNu3b8d4PMa//Mu/YHV1Fdu2bUu9S4QQQgghhBBCCNkiDNrKRKcWGRr8TBNCCCGEEEIIISQFVCQIIYQQQgghhBBCCCFrQhGREEIIIYQQQgghhBCyJhQRSVAuv/xyPPKRj0y9G4QQQgghhBBCCCEkIBQRybo87nGPw/Of//zD+r8vfOELcd11123sDhFCCCGEEEIIIYSQqAwynZnER0qJoiiwc+dO7Ny5M/XuEEIIIYQQQgghhJCAbBknopQS967OkvyTUh72fj7ucY/D8573PPzGb/wGjjnmGOzZsweXX345AODmm2+GEAJf+MIXmv9/1113QQiB66+/HgBw/fXXQwiBD33oQzjjjDOwfft2PP7xj8edd96JD3zgA3jIQx6CXbt24Rd+4Rdw7733rrs/F154IT760Y/i9a9/PYQQEELg5ptvbv7OBz7wAZx55plYWVnB3//938+VM1944YV4ylOegpe+9KU47rjjsGvXLvzar/0aVldXm//z13/91zj99NOxfft2HHvssTj77LPx/e9//7BfM0IIIYQQQgghhBCysWwZJ+J90wKnveRDSf72V684Fzsmh/9Sv/3tb8cll1yCm266CTfeeCMuvPBCnHXWWTj11FMPexuXX3453vjGN2LHjh14xjOegWc84xlYWVnBO9/5Thw4cABPfepT8YY3vAG/+Zu/ueZ2Xv/61+PrX/86Hvawh+GKK64AABx33HG4+eabAQC/9Vu/hde85jV44AMfiKOPProRM3Wuu+46bNu2Dddffz1uvvlm/NIv/RKOPfZY/O7v/i5uu+02PPOZz8SrX/1qPPWpT8U999yDj3/8472EV0IIIYQQQgghhBCysWwZEXEz8fCHPxyXXXYZAODUU0/FG9/4Rlx33XW9RMSXv/zlOOusswAAF110EV784hfjG9/4Bh74wAcCAJ7+9KfjIx/5yLoi4pFHHonJZIIdO3Zgz549c7+/4oor8JM/+ZNrbmMymeCtb30rduzYgYc+9KG44oor8KIXvQgve9nLcNttt2E2m+FpT3saTj75ZADA6aefftjHSQghhBBCCCGEEEI2ni0jIm4f5/jqFecm+9t9ePjDH258f8IJJ+DOO+903sbu3buxY8eORkBUP/vUpz7Va5tdPOpRj1r3/zziEY/Ajh07mu/37t2LAwcO4JZbbsEjHvEIPOEJT8Dpp5+Oc889F+eccw6e/vSn4+ijj/beN0IIIYQQQgghhBAShi0jIgohepUUp2Q8HhvfCyFQliWyrGphqZf6TqfTdbchhFi4TV+OOOIIr+fneY5rr70WN9xwA6655hq84Q1vwG//9m/jpptuwimnnOK9f4QQQgghhBBCCCHEny0TrDIEjjvuOADAbbfd1vxMD1nZKCaTCYqicH7+F7/4Rdx3333N95/85Cexc+dOnHTSSQAqQfOss87CS1/6Unz+85/HZDLBe97zHu/9JoQQQgghhBBCCCFh2BzWPAIA2L59Ox7zmMfgVa96FU455RTceeeduPTSSzf87z7gAQ/ATTfdhJtvvhk7d+7EMccc0+v5q6uruOiii3DppZfi5ptvxmWXXYbnPve5yLIMN910E6677jqcc845OP7443HTTTfhO9/5Dh7ykIds0NEQQgghhBBCCCGEkL7QibjJeOtb34rZbIYzzzwTz3/+8/Hyl798w//mC1/4QuR5jtNOOw3HHXcc9u3b1+v5T3jCE3DqqafiJ37iJ/BzP/dz+Omf/mlcfvnlAIBdu3bhYx/7GJ70pCfhh3/4h3HppZfita99Lc4777wNOBJCCCGEEEIIIYQQ4oKQeoO9TcT+/ftx5JFH4u6778auXbuM3x08eBDf+ta3cMopp2Dbtm2J9pAAwIUXXoi77roL733ve1PvyiDgZ5sQQgghhBBCCCGhWEtfs6ETkRBCCCGEEEIIIYQQsiYUEbc4+/btw86dOxf+61u6TAghhBBCCCGEELJsTIsS//sL/4rb7z6Yelc2LQxW2eKceOKJayY8n3jiiV7bf9vb3ub1fEIIIYQQQgghhBBfPvq17+C//tUX8ORHnIg3PPOM1LuzKaGIuMUZjUZ40IMelHo3CCGEEEIIIYQQQjaM7967Wj1+/1DiPdm8sJyZEEIIIYQQQgghhAwalSs8LTZlvvBSQBGREEIIIYQQQgghhAyastYOp0WZdkc2MRQRCSGEEEIIIYRsOAenBa7+0q24+95p6l0hhGxBytqJOKMT0RmKiIQQQgghhBBCNpx3f+5f8dx3fh5XXv/PqXeFELIFoRPRH4qIhBBCCCGEEEI2nO/VoQb/dmA18Z4QQrYibU9EioiuUEQkQbn88svxyEc+Murf2717N4QQeO973xvt7xJCCCGEEEL6UdY2oKLkBJ4QEh81Bs1KljO7QhGRrMvjHvc4PP/5zz+s//vCF74Q11133cbuUM0//uM/4qUvfSne/OY347bbbsN5550X5e9uBH1eY0IIIYQQQjYjat7OCTwhJAVNOfOMCxmujFLvABkGUkoURYGdO3di586dUf7mN77xDQDAz/zMz0AI4byd6XSK8XgcarcIIYQQQgghHRR1KaEKNyCEkJiosWfKhQxnto4TUUpg9ftp/vW4SD7ucY/D8573PPzGb/wGjjnmGOzZsweXX345AODmm2+GEAJf+MIXmv9/1113QQiB66+/HgBw/fXXQwiBD33oQzjjjDOwfft2PP7xj8edd96JD3zgA3jIQx6CXbt24Rd+4Rdw7733rrs/F154IT760Y/i9a9/PYQQEELg5ptvbv7OBz7wAZx55plYWVnB3//938+VM1944YV4ylOegpe+9KU47rjjsGvXLvzar/0aVlfbPih//dd/jdNPPx3bt2/Hsccei7PPPhvf//7319yvyy+/HE9+8pMBAFmWNSJiWZa44oor8IM/+INYWVnBIx/5SHzwgx9snqdew//5P/8nHvvYx2Lbtm34i7/4CwDAn/3Zn+EhD3kItm3bhgc/+MF405veZPzNb3/723jmM5+JY445BkcccQQe9ahH4aabbgJQCZo/8zM/g927d2Pnzp340R/9UXz4wx82nv+mN70Jp556KrZt24bdu3fj6U9/+pqvMSGEEEIIIUNCMhmVEJKQkj0Rvdk6TsTpvcArTkzzt//7rcDkiMP+729/+9txySWX4KabbsKNN96ICy+8EGeddRZOPfXUw97G5Zdfjje+8Y3YsWMHnvGMZ+AZz3gGVlZW8M53vhMHDhzAU5/6VLzhDW/Ab/7mb665nde//vX4+te/joc97GG44oorAADHHXdcI3L91m/9Fl7zmtfggQ98II4++uhGzNS57rrrsG3bNlx//fW4+eab8Uu/9Es49thj8bu/+7u47bbb8MxnPhOvfvWr8dSnPhX33HMPPv7xjzc3GIt44QtfiAc84AH4pV/6Jdx2223G/r72ta/Fm9/8Zpxxxhl461vfip/+6Z/GV77yFeP1+63f+i289rWvxRlnnNEIiS95yUvwxje+EWeccQY+//nP41d+5VdwxBFH4NnPfjYOHDiAxz72sfiBH/gBvO9978OePXvwuc99DmXdz+XAgQN40pOehN/93d/FysoK/vzP/xxPfvKT8bWvfQ33v//98ZnPfAbPe97z8D/+x//Aj/3Yj+G73/0uPv7xj6/5GhNCCCGEEDIk1AS+oAuIEJKApqUCFzKc2Toi4ibi4Q9/OC677DIAwKmnnoo3vvGNuO6663qJiC9/+ctx1llnAQAuuugivPjFL8Y3vvENPPCB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", + "image/png": "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wSpD4IQTTgAA3HLLLc3P9JCVZTGZTFAUhfPf/93f/R3uv//+5vtPf/rTOOqoo3DKKacAqAqa55xzDl75ylfii1/8IiaTCd773vd67zchhBBCCCGEEEIICcPOkOYRAMCePXvwmMc8Br/7u7+L0047DbfffjsuvvjipT/v93//9+O6667DDTfcgKOOOgrHHXdcr7/f2NjAhRdeiIsvvhg33HADXvGKV+D5z38+sizDddddh6uuugpPfOITceKJJ+K6667DHXfcgYc97GFLOhpCCCGEEEIIIYQQ0hcqEXcY73znOzGbzXD22WfjhS98IV796lcv/Tlf8pKXIM9znHHGGTjhhBNw44039vr7JzzhCTj99NPx4z/+4/i3//bf4qd+6qdwySWXAAD279+PT3ziE3jKU56CH/zBH8TFF1+MN7zhDXjyk5+8hCMhhBBCCCGEEEIIIS4IqTfY20HcfffdOProo3HXXXdh//79xu8OHz6Mb37zmzjttNOwvr6eaA8JAFxwwQW488478b73vS/1rgwCfrYJIYQQQgghhBASis3qazZUIhJCCCGEEEIIIYQQQjaFRcRdzo033oijjjpq4b++1mVCCCGEEEIIIYSQVWNalPh/r/82br3rcOpd2bEwWGWXc/LJJ2+a8HzyySd7bf9d73qX198TQgghhBBCCCGE+PLxr96BX//z6/HUR5yMtzzrrNS7syNhEXGXMxqN8JCHPCT1bhBCCCGEEEIIIYQsje8e2qge7zuSeE92LrQzE0IIIYQQQgghhJBBo3KFp8WOzBdeCVhEJIQQQgghhBBCCCGDpqxrh9OiTLsjOxgWEQkhhBBCCCGELJ27D0/xx5/8Bm656/7Uu0II2YWUjRKRRURXWEQkhBBCCCGEELJ03vuFb+PVf/33ePvHv5F6VwghuxClRJzRzuwMi4iEEEIIIYQQQpbOPYenACpFIiGExEb1RNygEtEZFhFJUC655BI88pGPjPp8J510EoQQeN/73hfteQkhhBBCCCH9UCqgsqQKiBASn6Iee6hEdIdFRLIlj3vc4/DCF75wW//3JS95Ca666qrl7lDN3//93+OVr3wl3v72t+OWW27Bk5/85CjPuwz6vMaEEEIIIYTsRFQ/shmLiISQBDBYxZ9R6h0gw0BKiaIocNRRR+Goo46K8pxf//rXAQA//dM/DSGE83am0ynG43Go3SKEEEIIIYR0oCbwBYuIhJAEyCZYhWOQK7tHiSglsHFfmn9y+x/Qxz3ucXjBC16A3/iN38Bxxx2HAwcO4JJLLgEA3HDDDRBC4Prrr2/+/5133gkhBK6++moAwNVXXw0hBD7ykY/grLPOwp49e/D4xz8et99+Oz70oQ/hYQ97GPbv349/9+/+HQ4dOrTl/lxwwQX4+Mc/jje96U0QQkAIgRtuuKF5ng996EM4++yzsba2hr/927+dszNfcMEF+Jmf+Rm88pWvxAknnID9+/fjV3/1V7GxsdH8n//xP/4HzjzzTOzZswfHH388zj33XNx3332b7tcll1yCpz71qQCALMuaImJZlrj00kvxL//lv8Ta2hoe+chH4sMf/nDzd+o1/G//7b/hsY99LNbX1/Fnf/ZnAIA//uM/xsMe9jCsr6/joQ99KN72trcZz/mtb30Lz3rWs3Dcccdh3759eNSjHoXrrrsOQFXQ/Omf/mmcdNJJOOqoo/CjP/qj+OhHP2r8/dve9jacfvrpWF9fx0knnYRnPvOZm77GhBBCCCGEDAlJJSIhJCFMZ/Zn9ygRp4eA15yc5rn/r5uByb5t//d3v/vdePGLX4zrrrsO1157LS644AKcc845OP3007e9jUsuuQRvfetbsXfvXvzcz/0cfu7nfg5ra2t4z3veg3vvvRdPe9rT8Ja3vAW/+Zu/uel23vSmN+FrX/safviHfxiXXnopAOCEE05oily/9Vu/hde//vV48IMfjGOPPbYpZupcddVVWF9fx9VXX40bbrgBv/RLv4Tjjz8ev/M7v4NbbrkFz3rWs/C6170OT3va03DPPffgk5/8ZHODsYiXvOQl+P7v/3780i/9Em655RZjf9/whjfg7W9/O8466yy8853vxE/91E/hy1/+svH6/dZv/Rbe8IY34KyzzmoKiS9/+cvx1re+FWeddRa++MUv4rnPfS727duH5zznObj33nvx2Mc+Ft/3fd+H97///Thw4AC+8IUvoCyrwefee+/FU57yFPzO7/wO1tbW8Cd/8id46lOfiq9+9at40IMehM997nN4wQtegP/yX/4LfuzHfgzf/e538clPfnLT15gQQgghhJAhoSbwVCISQlLQpjOziOjK7iki7iAe/vCH4xWveAUA4PTTT8db3/pWXHXVVb2KiK9+9atxzjnnAAAuvPBCvOxlL8PXv/51PPjBDwYAPPOZz8THPvaxLYuIRx99NCaTCfbu3YsDBw7M/f7SSy/FT/zET2y6jclkgne+853Yu3cvfuiHfgiXXnopXvrSl+JVr3oVbrnlFsxmMzz96U/HqaeeCgA488wztzy+o446CscccwwAGPv1+te/Hr/5m7+Jn//5nwcA/Kf/9J/wsY99DL//+7+Pyy67rPl/L3zhC/H0pz+9+f4Vr3gF3vCGNzQ/O+200/CVr3wFb3/72/Gc5zwH73nPe3DHHXfgs5/9LI477jgAwEMe8pDm7x/xiEfgEY94RPP9q171Krz3ve/F+9//fjz/+c/HjTfeiH379uEnf/In8YAHPACnnnoqzjrrrG29xoQQQgghhAyBZgLPIiIhJAEl7cze7J4i4nhvpQhM9dw9ePjDH258/8AHPhC333678zZOOukk7N27tykgqp995jOf6bXNLh71qEdt+X8e8YhHYO/e9jU4ePAg7r33Xtx00014xCMegSc84Qk488wzcd555+GJT3winvnMZ+LYY4/tvS933303br755qZ4qjjnnHPwd3/3dwv3+7777sPXv/51XHjhhXjuc5/b/Hw2m+Hoo48GAFx//fU466yzmgKizb333otLLrkEf/3Xf90URu+//37ceOONAICf+ImfwKmnnooHP/jBeNKTnoQnPelJeNrTnma8LoQQQgghhAyZVolIFRAhJD4qGX5alpBSemUr7FZ2TxFRiF6W4pTYIR9CCJRliSyrWljqVt/pdLrlNoQQC7fpy759fq9pnue48sorcc011+CKK67AW97yFvz2b/82rrvuOpx22mne+7cIfb/vvfdeAMAf/dEf4dGPfvTc/gHAnj17Nt3eS17yElx55ZV4/etfj4c85CHYs2cPnvnMZza9Hx/wgAfgC1/4Aq6++mpcccUVePnLX45LLrkEn/3sZxtFJSGEEEIIIUNGNlZCqoAIIfFRImgpq7YKo5xFxL7snmCVAaD65Ok9APWQlWUxmUxQFIXz3//d3/0d7r///ub7T3/60zjqqKNwyimnAKgKmueccw5e+cpX4otf/CImkwne+9739n6e/fv34+STT8anPvUp4+ef+tSncMYZZyz8u5NOOgknn3wyvvGNb+AhD3mI8U8VMh/+8Ifj+uuvx3e/+93ObXzqU5/CBRdcgKc97Wk488wzceDAgblwlNFo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cceOHejp6cH8+fMBANXV1Whubh7ye1u2bLH82gRBEARBEETmkIhIEARBEARxEjBt2jSsX78ehw4dQkdHR1ZOwHg8jk996lPYsWMH/vrXv+Kee+7BrbfemlE/xPG45JJLsGjRInzsYx/D5s2bsWHDBtx44404//zztVLriy66CG+//TaeeOIJ7N27F/fccw+2bdtm+bUJgiAIgiCIzCERkSAIgiAI4iTgjjvugMvlwvz581FdXY2mpqaMf/fiiy/GrFmzcN555+G6667D+9//ftx777227JckSfjjH/+I8vJynHfeebjkkkswY8YMPPPMM9pzLr/8cnz961/HnXfeidNPPx39/f248cYbbXl9giAIgiAIIjMkZXiDGYIgCIIgCIJQ+fjHP46enh784Q9/cHpXCIIgCIIgCAchJyJBEARBEARBEARBEARBEGNCIiJBEARBEARhiqamJoRCoVH/ZVMyTRAEQRAEQYgNlTMTBEEQBEEQpkgmkzh06NCoP582bRrcbnf+doggCIIgCILIGSQiEgRBEARBEARBEARBEAQxJlTOTBAEQRAEQRAEQRAEQRDEmJCISBAEQRAEQRAEQRAEQRDEmJCISBAEQRAEQRAEQRAEQRDEmJCISBAEQRAEQRAEQRAEQRDEmJCISBAEQRAEQRAEQRAEQRDEmJCISBAEQRAEQRAEQRAEQRDEmJCISBAEQRAEQRAEQRAEQRDEmJCISBAEQRAEQRAEQRAEQRDEmPx/iE/owHFEEpYAAAAASUVORK5CYII=", "text/plain": [ "
" ] diff --git a/notebooks/generative_ai/bq_dataframes_llm_vector_search.ipynb b/notebooks/generative_ai/bq_dataframes_llm_vector_search.ipynb index b964117b67..72651f1972 100644 --- a/notebooks/generative_ai/bq_dataframes_llm_vector_search.ipynb +++ b/notebooks/generative_ai/bq_dataframes_llm_vector_search.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": { "id": "TpJu6BBeooES" }, @@ -196,7 +196,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 2, "metadata": { "executionInfo": { "elapsed": 2, @@ -213,7 +213,7 @@ "outputs": [], "source": [ "# set your project ID below\n", - "PROJECT_ID = \"\" # @param {type:\"string\"}\n", + "PROJECT_ID = \"bigframes-dev\" # @param {type:\"string\"}\n", "\n", "# set your region\n", "REGION = \"US\" # @param {type: \"string\"}\n", @@ -257,7 +257,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": { "id": "3cGhUVM0ooEW" }, @@ -279,7 +279,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 4, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -297,15 +297,7 @@ "id": "j3lmnsh7ooEW", "outputId": "eb68daf5-5558-487a-91d2-4b4f9e476da0" }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "WARNING: google.colab.auth.authenticate_user() is not supported in Colab Enterprise.\n" - ] - } - ], + "outputs": [], "source": [ "# from google.colab import auth\n", "# auth.authenticate_user()" @@ -340,7 +332,7 @@ }, { "cell_type": "code", - "execution_count": 33, + "execution_count": 5, "metadata": { "executionInfo": { "elapsed": 947, @@ -394,7 +386,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 6, "metadata": { "executionInfo": { "elapsed": 2, @@ -433,7 +425,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 7, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -451,26 +443,14 @@ "id": "zDSwoBo1CU3G", "outputId": "83edbc2f-5a23-407b-8890-f968eb31be44" }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/lib/python3.10/dist-packages/IPython/core/interactiveshell.py:3553: UserWarning: \u001b[93mReading cached table from 2025-03-17 06:07:09.526507+00:00 to avoid\n", - "incompatibilies with previous reads of this table. To read the latest\n", - "version, set `use_cache=False` or close the current session with\n", - "Session.close() or bigframes.pandas.close_session().\u001b[0m\n", - " exec(code_obj, self.user_global_ns, self.user_ns)\n" - ] - } - ], + "outputs": [], "source": [ "publications = bf.read_gbq('patents-public-data.google_patents_research.publications')" ] }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 8, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -489,20 +469,7 @@ "id": "tYDoaKgJChiq", "outputId": "9174da29-a051-4a99-e38f-6a2b09cfe4e9" }, - "outputs": [ - { - "data": { - "text/html": [ - "Query job 6f15ad71-cc7b-49c1-90e9-274bea7afbb9 is DONE. 477.4 GB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "## create patents base table (subset of 10k out of ~110M records)\n", "\n", @@ -514,7 +481,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 9, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -536,15 +503,8 @@ "outputs": [ { "data": { - "application/vnd.google.colaboratory.intrinsic+json": { - "repr_error": "Function 'unique' has no kernel matching input types (list not null>>)", - "type": "dataframe", - "variable_name": "publications" - }, "text/html": [ - "\n", - "
\n", - "
\n", + "
\n", "\n", - "\n", - " \n", - "
\n", - "\n", - "\n", - "
\n", - " \n", - "\n", - "\n", - "\n", - " \n", - "
\n", - "\n", - "
\n", - "
\n" + "" ], "text/plain": [ " publication_number title \\\n", - "0 AU-338190-S Compressor wheel \n", - "1 CN-100525651-C Method for processing egg products \n", - "2 TW-I725505-B Improved carbon molecular sieve adsorbent \n", - "3 EP-0248026-B1 A system for supplying strip to a processing line \n", - "4 MY-135762-A Method for producing acrylic acid \n", + "0 WO-2007022924-B1 Pharmaceutical compositions with melting point... \n", + "1 WO-03043855-B1 Convenience lighting for interior and exterior... \n", + "2 AU-2020396918-A2 Shot detection and verification system \n", + "3 PL-347539-A1 Concrete mix of increased fire resistance \n", + "4 AU-PS049302-A0 Methods and systems (ap53) \n", "\n", " title_translated abstract \\\n", - "0 False Newness and distinctiveness is claimed in the ... \n", - "1 False The invention discloses a processing method of... \n", - "2 False Disclosed herein are rapid cycle pressure swin... \n", - "3 False A system (10) for supplying strip material (S)... \n", - "4 False A PROCESS FOR THE FRACTIONAL CONDENSATION OF A... \n", + "0 False The invention relates to the use of chemical f... \n", + "1 False A lighting apparatus for a vehicle(21) include... \n", + "2 False A shot detection system for a projectile weapo... \n", + "3 False The burning resistance of concrete containing ... \n", + "4 False A charging stand for charging a mobile phone, ... \n", "\n", " abstract_translated cpc \\\n", - "0 False [] \n", - "1 False [] \n", - "2 False [{'code': 'B01D2253/116', 'inventive': False, ... \n", - "3 False [{'code': 'B65H2701/37', 'inventive': False, '... \n", - "4 False [{'code': 'C07C51/50', 'inventive': True, 'fir... \n", + "0 False [{'code': 'A61K47/32', 'inventive': True, 'fir... \n", + "1 False [{'code': 'B60Q1/247', 'inventive': True, 'fir... \n", + "2 False [{'code': 'F41A19/01', 'inventive': True, 'fir... \n", + "3 False [{'code': 'Y02W30/91', 'inventive': False, 'fi... \n", + "4 False [{'code': 'H02J7/00', 'inventive': True, 'firs... \n", "\n", " cpc_low \\\n", - "0 [] \n", - "1 [] \n", - "2 ['B01D2253/116' 'B01D2253/10' 'B01D2253/00' 'B... \n", - "3 ['B65H2701/37' 'B65H2701/30' 'B65H2701/00' 'B6... \n", - "4 ['C07C51/50' 'C07C51/42' 'C07C51/00' 'C07C' 'C... \n", + "0 ['A61K47/32' 'A61K47/30' 'A61K47/00' 'A61K' 'A... \n", + "1 ['B60Q1/247' 'B60Q1/24' 'B60Q1/02' 'B60Q1/00' ... \n", + "2 ['F41A19/01' 'F41A19/00' 'F41A' 'F41' 'F' 'H04... \n", + "3 ['Y02W30/91' 'Y02W30/50' 'Y02W30/00' 'Y02W' 'Y... \n", + "4 ['H02J7/00' 'H02J' 'H02' 'H' 'H04B1/40' 'H04B1... \n", "\n", " cpc_inventive_low \\\n", - "0 [] \n", - "1 [] \n", - "2 ['B01D2253/116' 'B01D2253/10' 'B01D2253/00' 'B... \n", - "3 ['B65H2701/37' 'B65H2701/30' 'B65H2701/00' 'B6... \n", - "4 ['C07C51/50' 'C07C51/42' 'C07C51/00' 'C07C' 'C... \n", + "0 ['A61K47/32' 'A61K47/30' 'A61K47/00' 'A61K' 'A... \n", + "1 ['B60Q1/247' 'B60Q1/24' 'B60Q1/02' 'B60Q1/00' ... \n", + "2 ['F41A19/01' 'F41A19/00' 'F41A' 'F41' 'F' 'H04... \n", + "3 ['Y02W30/91' 'Y02W30/50' 'Y02W30/00' 'Y02W' 'Y... \n", + "4 ['H02J7/00' 'H02J' 'H02' 'H' 'H04B1/40' 'H04B1... \n", "\n", " top_terms \\\n", - "0 ['compressor wheel' 'newness' 'distinctiveness... \n", - "1 ['egg' 'processing method' 'egg body' 'pack' '... \n", - "2 ['swing adsorption' 'pressure swing' 'molecula... \n", - "3 ['strip material' 'assembly' 'coil' 'take' 'pr... \n", - "4 ['acrylic acid' 'producing acrylic' 'stabilize... \n", + "0 ['composition' 'mucosa' 'melting point' 'agent... \n", + "1 ['vehicle' 'light' 'apparatus defined' 'pillar... \n", + "2 ['interest' 'region' 'property' 'shot' 'test' ... \n", + "3 ['fire resistance' 'concrete mix' 'increased f... \n", + "4 ['connection pin' 'mobile phone' 'cartridge' '... \n", "\n", " similar \\\n", - "0 [{'publication_number': 'AU-338190-S', 'applic... \n", - "1 [{'publication_number': 'CN-101396133-B', 'app... \n", - "2 [{'publication_number': 'EP-1867379-B1', 'appl... \n", - "3 [{'publication_number': 'EP-0248026-B1', 'appl... \n", - "4 [{'publication_number': 'SG-157371-A1', 'appli... \n", + "0 [{'publication_number': 'WO-2007022924-B1', 'a... \n", + "1 [{'publication_number': 'WO-03043855-B1', 'app... \n", + "2 [{'publication_number': 'US-2023228510-A1', 'a... \n", + "3 [{'publication_number': 'DK-1564194-T3', 'appl... \n", + "4 [{'publication_number': 'AU-PS049302-A0', 'app... \n", "\n", - " url country \\\n", - "0 https://patents.google.com/patent/AU338190S Australia \n", - "1 https://patents.google.com/patent/CN100525651C China \n", - "2 https://patents.google.com/patent/TWI725505B Taiwan \n", - "3 https://patents.google.com/patent/EP0248026B1 European Patent Office \n", - "4 https://patents.google.com/patent/MY135762A Malaysia \n", + " url country \\\n", + "0 https://patents.google.com/patent/WO2007022924B1 WIPO (PCT) \n", + "1 https://patents.google.com/patent/WO2003043855B1 WIPO (PCT) \n", + "2 https://patents.google.com/patent/AU2020396918A2 Australia \n", + "3 https://patents.google.com/patent/PL347539A1 Poland \n", + "4 https://patents.google.com/patent/AUPS049302A0 Australia \n", "\n", - " publication_description cited_by \\\n", - "0 Design [] \n", - "1 Granted Patent [] \n", - "2 Granted Patent or patent of addition [] \n", - "3 Granted patent [] \n", - "4 Granted patent / Utility model [] \n", + " publication_description cited_by \\\n", + "0 Amended claims [] \n", + "1 Amended claims [] \n", + "2 Amended post open to public inspection [] \n", + "3 Application [] \n", + "4 Application filed, as announced in the Gazette... [] \n", "\n", " embedding_v1 \n", - "0 [ 5.2067090e-02 -1.5462303e-01 -1.3415462e-01 ... \n", - "1 [-0.05154578 -0.00437102 0.01365495 -0.168424... \n", - "2 [ 0.0163008 -0.20972364 0.02052403 -0.003073... \n", - "3 [-0.04377723 0.04111805 -0.0929429 0.043924... \n", - "4 [ 0.10407669 0.01262973 -0.22623734 -0.171453... " + "0 [ 5.3550040e-02 -9.3632710e-02 1.4337189e-02 ... \n", + "1 [ 0.00484032 -0.02695554 -0.20798226 -0.207528... \n", + "2 [-1.49729420e-02 -2.27105440e-01 -2.68012730e-... \n", + "3 [ 0.01849568 -0.05340371 -0.19257502 -0.174919... \n", + "4 [ 0.00064732 -0.2136009 0.0040593 -0.024562... " ] }, - "execution_count": 11, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -977,7 +728,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 10, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -1000,7 +751,19 @@ { "data": { "text/html": [ - "Query job 127fb090-1c9e-4d7a-acdd-86f077a87b07 is DONE. 0 Bytes processed. Open Job" + "Query job 0e9d9117-4981-4f5c-b785-ed831c08e7aa is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job fa4f1a54-85d4-4030-992e-fddda5edf3e3 is DONE. 0 Bytes processed. Open Job" ], "text/plain": [ "" @@ -1013,12 +776,15 @@ "source": [ "from bigframes.ml.llm import TextEmbeddingGenerator\n", "\n", - "text_model = TextEmbeddingGenerator() # No connection id needed" + "text_model = TextEmbeddingGenerator(\n", + " model_name=\"text-embedding-005\",\n", + " # No connection id needed\n", + ")" ] }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 11, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -1041,7 +807,7 @@ { "data": { "text/html": [ - "Load job b8079d70-7d99-4198-898f-2921915f305f is DONE. Open Job" + "Load job 70377d71-bb13-46af-80c1-71ef16bf2949 is DONE. Open Job" ], "text/plain": [ "" @@ -1053,7 +819,7 @@ { "data": { "text/html": [ - "Query job 17338b11-420c-4d3d-bd55-0bba1247f705 is DONE. 8.9 MB processed. Open Job" + "Query job cc3b609d-b6b7-404f-9447-c76d3a52698b is DONE. 9.5 MB processed. Open Job" ], "text/plain": [ "" @@ -1066,34 +832,10 @@ "name": "stderr", "output_type": "stream", "text": [ - "/usr/local/lib/python3.10/dist-packages/bigframes/core/array_value.py:114: PreviewWarning: \u001b[93mJSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\u001b[0m\n", + "/usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/bigframes/core/array_value.py:109: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", + "`db_dtypes` is a preview feature and subject to change.\n", " warnings.warn(msg, bfe.PreviewWarning)\n" ] - }, - { - "data": { - "text/html": [ - "Query job ebf3eb36-3199-4551-ad07-5fa5abb200be is DONE. 20.0 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 9e9c5aae-9045-4750-a34e-c98493369a90 is DONE. 20.0 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" } ], "source": [ @@ -1110,7 +852,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 12, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -1130,32 +872,10 @@ "outputId": "d04c994a-a0c8-44b0-e897-d871036eeb1f" }, "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/lib/python3.10/dist-packages/bigframes/core/array_value.py:238: AmbiguousWindowWarning: \u001b[93mWindow ordering may be ambiguous, this can cause unstable results.\u001b[0m\n", - " warnings.warn(msg, bfe.AmbiguousWindowWarning)\n", - "/usr/local/lib/python3.10/dist-packages/bigframes/core/array_value.py:262: AmbiguousWindowWarning: \u001b[93mWindow ordering may be ambiguous, this can cause unstable results.\u001b[0m\n", - " warnings.warn(msg, category=bfe.AmbiguousWindowWarning)\n" - ] - }, - { - "data": { - "text/html": [ - "Query job 1bc3517f-df67-456c-8d31-14a6432b8629 is DONE. 70.4 MB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/html": [ - "Query job ae92602b-0eab-437f-a02d-102a4defa99a is DONE. 31.3 kB processed. Open Job" + "Query job 5b15fc4a-fa9a-4608-825f-be5af9953a38 is DONE. 71.0 MB processed. Open Job" ], "text/plain": [ "" @@ -1194,43 +914,43 @@ " \n", "
\n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", @@ -1240,37 +960,37 @@ ], "text/plain": [ " publication_number title \\\n", - "5753 HN-1996000102-A NEW PESTICIDES \n", - "8115 AU-325874-S Baby sling \n", - "5415 AU-2016256863-A1 Microbial compositions and methods for denitri... \n", - "8886 FR-2368509-A1 NEW DEODORANTS OR FRESHENERS AND COMPOSITIONS ... \n", - "5661 US-2006051255-A1 Gas generator \n", + "5611 WO-2014005277-A1 Resource management in a cloud computing envir... \n", + "6895 AU-2011325479-B2 7-([1,2,3]triazol-4-yl)-pyrrolo[2,3-b]pyrazine... \n", + "6 IL-45347-A 7h-indolizino(5,6,7-ij)isoquinoline derivative... \n", + "5923 WO-2005111625-A3 Method to predict prostate cancer \n", + "6370 US-7868678-B2 Configurable differential lines \n", "\n", " content \\\n", - "5753 THE PRESENT INVENTION REFERS TO \n", - "8115 Adjustable baby sling with velcro. \n", - "5415 The present invention provides compositions an... \n", - "8886 Polyanionic polyamide salts comprising a conca... \n", - "5661 A gas generator insulated by a vacuum-jacket v... \n", + "5611 Technologies and implementations for managing ... \n", + "6895 Compounds of formula I, in which R \n", + "6 Compounds of the formula:\\n[US3946019A] \n", + "5923 A method for predicting the probability or ris... \n", + "6370 Embodiments related to configurable differenti... \n", "\n", " ml_generate_embedding_result \\\n", - "5753 [-0.02709213 0.0366395 0.03931784 -0.003942... \n", - "8115 [ 6.44167811e-02 -2.01051459e-02 -3.39564607e-... \n", - "5415 [-5.90537786e-02 2.38401629e-03 7.22754598e-... \n", - "8886 [-3.44522446e-02 5.64815439e-02 -1.35829514e-... \n", - "5661 [-1.50892800e-02 6.56989636e-03 2.34969519e-... \n", + "5611 [-2.92946529e-02 -1.24640828e-02 1.27173709e-... \n", + "6895 [-6.45397678e-02 1.19616119e-02 -9.85191786e-... \n", + "6 [-3.82784344e-02 -2.31682733e-02 -4.35006060e-... \n", + "5923 [ 0.02480386 -0.01648765 0.03873815 -0.025998... \n", + "6370 [ 2.71715336e-02 -1.93733890e-02 2.82729534e-... \n", "\n", " ml_generate_embedding_status \n", - "5753 \n", - "8115 \n", - "5415 \n", - "8886 \n", - "5661 \n", + "5611 \n", + "6895 \n", + "6 \n", + "5923 \n", + "6370 \n", "\n", "[5 rows x 5 columns]" ] }, - "execution_count": 20, + "execution_count": 12, "metadata": {}, "output_type": "execute_result" } @@ -1281,7 +1001,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 13, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -1304,7 +1024,7 @@ { "data": { "text/html": [ - "Query job 7370fb69-9589-4a9a-a5cf-7f7c8d50c53c is DONE. 70.3 MB processed. Open Job" + "Query job 06ce090b-e3f9-4252-b847-45c2a296ca61 is DONE. 70.9 MB processed. Open Job" ], "text/plain": [ "" @@ -1315,22 +1035,19 @@ }, { "data": { - "application/vnd.google.colaboratory.intrinsic+json": { - "type": "string" - }, "text/plain": [ - "'bqml_llm_trial.patent_embedding_BF-n'" + "'my_dataset.my_embeddings_table'" ] }, - "execution_count": 21, + "execution_count": 13, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# store embeddings in a BQ table\n", - "DATASET_ID = \"\" # @param {type:\"string\"}\n", - "TEXT_EMBEDDING_TABLE_ID = \"\" # @param {type:\"string\"}\n", + "DATASET_ID = \"my_dataset\" # @param {type:\"string\"}\n", + "TEXT_EMBEDDING_TABLE_ID = \"my_embeddings_table\" # @param {type:\"string\"}\n", "embedding.to_gbq(f\"{DATASET_ID}.{TEXT_EMBEDDING_TABLE_ID}\", if_exists='replace')" ] }, @@ -1360,7 +1077,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 14, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -1379,20 +1096,7 @@ "id": "6SBVdv6gyU5A", "outputId": "6583e113-de27-4b44-972d-c1cc061e3c76" }, - "outputs": [ - { - "data": { - "text/html": [ - "Query job 775f872d-ea2d-48f3-8b65-a85ed573dac0 is DONE. 61.4 MB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "## create vector index (note only works of tables >5000 rows)\n", "\n", @@ -1419,7 +1123,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 15, "metadata": { "executionInfo": { "elapsed": 639, @@ -1443,7 +1147,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 16, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -1466,7 +1170,7 @@ { "data": { "text/html": [ - "Query job 3a352b3b-b968-4347-80fe-6a9ef9045358 is DONE. 0 Bytes processed. Open Job" + "Query job 016ad678-9609-4c78-8f07-3f9887ce67ac is DONE. 0 Bytes processed. Open Job" ], "text/plain": [ "" @@ -1479,34 +1183,10 @@ "name": "stderr", "output_type": "stream", "text": [ - "/usr/local/lib/python3.10/dist-packages/bigframes/core/array_value.py:114: PreviewWarning: \u001b[93mJSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\u001b[0m\n", + "/usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/bigframes/core/array_value.py:109: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", + "`db_dtypes` is a preview feature and subject to change.\n", " warnings.warn(msg, bfe.PreviewWarning)\n" ] - }, - { - "data": { - "text/html": [ - "Query job 0e6d609b-9818-45fe-b26d-7247722bbea4 is DONE. 2 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job 66602cee-78a8-4955-96fb-6a2d603d5d7d is DONE. 2 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" } ], "source": [ @@ -1519,7 +1199,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 17, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -1538,56 +1218,23 @@ "id": "sx0AGAdn5FYX", "outputId": "551ebac3-594f-4303-ca97-5301dfee72bb" }, - "outputs": [ - { - "data": { - "text/html": [ - "Query job 4768061f-d5a6-4638-8396-5c15a098ad7b is RUNNING. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job e6175f9a-6bbd-4cbe-967b-b04421b33b02 is DONE. 132.7 MB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/lib/python3.10/dist-packages/bigframes/core/array_value.py:114: PreviewWarning: \u001b[93mJSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\u001b[0m\n", - " warnings.warn(msg, bfe.PreviewWarning)\n" - ] - } - ], + "outputs": [], "source": [ "## search the base table for the user's query\n", "\n", "vector_search_results = bf_bq.vector_search(\n", - " base_table=f\"{DATASET_ID}.{TEXT_EMBEDDING_TABLE_ID}\",\n", - " column_to_search=\"ml_generate_embedding_result\",\n", - " query=search_query,\n", - " distance_type=\"COSINE\",\n", - " query_column_to_search=\"ml_generate_embedding_result\",\n", - " top_k=5)" + " base_table=f\"{DATASET_ID}.{TEXT_EMBEDDING_TABLE_ID}\",\n", + " column_to_search=\"ml_generate_embedding_result\",\n", + " query=search_query,\n", + " distance_type=\"cosine\",\n", + " query_column_to_search=\"ml_generate_embedding_result\",\n", + " top_k=5,\n", + ")" ] }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 18, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -1610,7 +1257,19 @@ { "data": { "text/html": [ - "Query job 61c1138d-f4da-4971-a7dd-aa7150bafe50 is DONE. 0 Bytes processed. Open Job" + "Load job b6b88844-9ed7-4c92-8984-556414592f0b is DONE. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job aa95f59c-7229-4e76-bd2c-3a63deea3285 is DONE. 4.7 kB processed. Open Job" ], "text/plain": [ "" @@ -1651,42 +1310,42 @@ " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", "
5753HN-1996000102-ANEW PESTICIDESTHE PRESENT INVENTION REFERS TO[-0.02709213 0.0366395 0.03931784 -0.003942...5611WO-2014005277-A1Resource management in a cloud computing envir...Technologies and implementations for managing ...[-2.92946529e-02 -1.24640828e-02 1.27173709e-...
8115AU-325874-SBaby slingAdjustable baby sling with velcro.[ 6.44167811e-02 -2.01051459e-02 -3.39564607e-...6895AU-2011325479-B27-([1,2,3]triazol-4-yl)-pyrrolo[2,3-b]pyrazine...Compounds of formula I, in which R[-6.45397678e-02 1.19616119e-02 -9.85191786e-...
5415AU-2016256863-A1Microbial compositions and methods for denitri...The present invention provides compositions an...[-5.90537786e-02 2.38401629e-03 7.22754598e-...6IL-45347-A7h-indolizino(5,6,7-ij)isoquinoline derivative...Compounds of the formula:\\n[US3946019A][-3.82784344e-02 -2.31682733e-02 -4.35006060e-...
8886FR-2368509-A1NEW DEODORANTS OR FRESHENERS AND COMPOSITIONS ...Polyanionic polyamide salts comprising a conca...[-3.44522446e-02 5.64815439e-02 -1.35829514e-...5923WO-2005111625-A3Method to predict prostate cancerA method for predicting the probability or ris...[ 0.02480386 -0.01648765 0.03873815 -0.025998...
5661US-2006051255-A1Gas generatorA gas generator insulated by a vacuum-jacket v...[-1.50892800e-02 6.56989636e-03 2.34969519e-...6370US-7868678-B2Configurable differential linesEmbodiments related to configurable differenti...[ 2.71715336e-02 -1.93733890e-02 2.82729534e-...
0Chip assemblies employing solder bonds to back...KR-102569815-B1electronic device packageAn electronic device package technology is dis...0.357673CN-103515336-AChip package, chip arrangement, circuit board ...A chip package is provided, the chip package i...0.287274
0Chip assemblies employing solder bonds to back...US-8962389-B2Microelectronic packages including patterned d...Embodiments of microelectronic packages and me...0.344263US-9548145-B2Microelectronic assembly with multi-layer supp...A method of forming a microelectronic assembly...0.290519
0Chip assemblies employing solder bonds to back...TW-I256279-BSubstrate for electrical device and methods of...Substrate for electrical devices and methods o...0.3687JP-2012074505-ASemiconductor mounting device substrate, semic...To provide a substrate for a semiconductor mou...0.294241
0Chip assemblies employing solder bonds to back...US-2005230147-A1Wiring board, and electronic device with an el...An electronic device is mounted on a wiring bo...0.304293US-2015380164-A1Ceramic electronic componentA ceramic electronic component includes an ele...0.295716
0Chip assemblies employing solder bonds to back...US-6686652-B1Locking lead tips and die attach pad for a lea...An assembly and method suitable for use in pac...0.364334US-2012153447-A1Microelectronic flip chip packages with solder...Processes of assembling microelectronic packag...0.300337
\n", @@ -1695,30 +1354,30 @@ ], "text/plain": [ " query publication_number \\\n", - "0 Chip assemblies employing solder bonds to back... KR-102569815-B1 \n", - "0 Chip assemblies employing solder bonds to back... US-8962389-B2 \n", - "0 Chip assemblies employing solder bonds to back... TW-I256279-B \n", - "0 Chip assemblies employing solder bonds to back... US-2005230147-A1 \n", - "0 Chip assemblies employing solder bonds to back... US-6686652-B1 \n", + "0 Chip assemblies employing solder bonds to back... CN-103515336-A \n", + "0 Chip assemblies employing solder bonds to back... US-9548145-B2 \n", + "0 Chip assemblies employing solder bonds to back... JP-2012074505-A \n", + "0 Chip assemblies employing solder bonds to back... US-2015380164-A1 \n", + "0 Chip assemblies employing solder bonds to back... US-2012153447-A1 \n", "\n", " title (relevant match) \\\n", - "0 electronic device package \n", - "0 Microelectronic packages including patterned d... \n", - "0 Substrate for electrical device and methods of... \n", - "0 Wiring board, and electronic device with an el... \n", - "0 Locking lead tips and die attach pad for a lea... \n", + "0 Chip package, chip arrangement, circuit board ... \n", + "0 Microelectronic assembly with multi-layer supp... \n", + "0 Semiconductor mounting device substrate, semic... \n", + "0 Ceramic electronic component \n", + "0 Microelectronic flip chip packages with solder... \n", "\n", " abstract (relevant match) distance \n", - "0 An electronic device package technology is dis... 0.357673 \n", - "0 Embodiments of microelectronic packages and me... 0.344263 \n", - "0 Substrate for electrical devices and methods o... 0.3687 \n", - "0 An electronic device is mounted on a wiring bo... 0.304293 \n", - "0 An assembly and method suitable for use in pac... 0.364334 \n", + "0 A chip package is provided, the chip package i... 0.287274 \n", + "0 A method of forming a microelectronic assembly... 0.290519 \n", + "0 To provide a substrate for a semiconductor mou... 0.294241 \n", + "0 A ceramic electronic component includes an ele... 0.295716 \n", + "0 Processes of assembling microelectronic packag... 0.300337 \n", "\n", "[5 rows x 5 columns]" ] }, - "execution_count": 27, + "execution_count": 18, "metadata": {}, "output_type": "execute_result" } @@ -1726,13 +1385,24 @@ "source": [ "## View the returned results based on simalirity with the user's query\n", "\n", - "vector_search_results[['content', 'publication_number',\n", - " 'title', 'content_1', 'distance']].rename(columns={'content': 'query', 'content_1':'abstract (relevant match)' , 'title':'title (relevant match)'})" + "vector_search_results[\n", + " [\n", + " 'content',\n", + " 'publication_number',\n", + " 'title',\n", + " 'content_1',\n", + " 'distance',\n", + " ]\n", + "].rename(columns={\n", + " 'content': 'query',\n", + " 'content_1':'abstract (relevant match)' ,\n", + " 'title':'title (relevant match)',\n", + "})" ] }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 19, "metadata": { "executionInfo": { "elapsed": 1622, @@ -1752,12 +1422,13 @@ "\n", "\n", "brute_force_result = bf_bq.vector_search(\n", - " table_id = f\"{DATASET_ID}.{TEXT_EMBEDDING_TABLE_ID}\",\n", - " column_to_search=\"ml_generate_embedding_result\",\n", - " query=search_query,\n", - " top_k=5,\n", - " distance_type=\"COSINE\",\n", - " use_brute_force=True)\n" + " base_table=f\"{DATASET_ID}.{TEXT_EMBEDDING_TABLE_ID}\",\n", + " column_to_search=\"ml_generate_embedding_result\",\n", + " query=search_query,\n", + " top_k=5,\n", + " distance_type=\"cosine\",\n", + " use_brute_force=True,\n", + ")\n" ] }, { @@ -1780,7 +1451,7 @@ }, { "cell_type": "code", - "execution_count": 37, + "execution_count": 20, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -1803,7 +1474,7 @@ { "data": { "text/html": [ - "Query job 093debfb-08f1-4bba-8b39-c3da575793a4 is DONE. 0 Bytes processed. Open Job" + "Query job 3fabe659-f95b-49cb-b0c7-9d32b09177bf is DONE. 0 Bytes processed. Open Job" ], "text/plain": [ "" @@ -1830,7 +1501,7 @@ }, { "cell_type": "code", - "execution_count": 35, + "execution_count": 21, "metadata": { "executionInfo": { "elapsed": 1474, @@ -1851,7 +1522,7 @@ }, { "cell_type": "code", - "execution_count": 34, + "execution_count": 22, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -1871,23 +1542,11 @@ "outputId": "c34bc931-5be8-410e-ac1f-604df31ef533" }, "outputs": [ - { - "data": { - "text/html": [ - "Query job f77bbae5-ea1f-4ba9-92bc-bfc7bc474cd9 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "name": "stdout", "output_type": "stream", "text": [ - "['{\"abstract\": \"Substrate for electrical devices and methods of fabricating such substrate are disclosed. An embodiment for an electrical device with substrate comprised of a chip having an active surface; a substrate being coupled with the chip; and a plurality of conductive wires (bumps) electrically connecting the chip to the substrate. In additionally, the present invention of the substrate for electrical devices may be comprised of an adhesive mean or a submember as required, and furthermore, by mean of using substrate, the present invention may be capable of affording a number of advantages, it is possible to include a thinner electrical device thickness, enhanced reliability, and a decreased cost in production.\"}', '{\"abstract\": \"An electronic device is mounted on a wiring board, which includes: a substrate having through holes, and lands extending on surfaces of the substrate and adjacent to openings of the through holes. Further, at least one coating layer is provided, which coats at least one part of an outer peripheral region of the at least one land, in order to cause that the at least one part is separated from a lead-less solder, thereby preventing any peel of the land from the surface of the substrate.\"}', '{\"abstract\": \"An assembly and method suitable for use in packaging integrated circuits including a support substrate for supporting an integrated circuit die embedded in a molded encapsulating cap. The substrate includes a conductive die attach pad adapted to be molded into the encapsulating cap. The pad includes an interior facing support surface and a spaced-apart exterior facing exposed surface defined by a peripheral edge. The support surface is adapted to support the embedded die, while the exposed surface is to be exposed from the encapsulating cap. The attach pad further includes a locking ledge portion extending outward peripherally beyond at least a portion of the exposed surface peripheral edge. This ledge is adapted to be subtended in the encapsulating cap in a manner substantially preventing a pull-out of the attach pad in a direction away from the encapsulating cap.\"}', '{\"abstract\": \"Embodiments of microelectronic packages and methods for fabricating microelectronic packages are provided. In one embodiment, the fabrication method includes printing a patterned die attach material onto the backside of a wafer including an array of non-singulated microelectronic die each having an interior keep-out area, such as a central keep-out area. The die attach material, such as a B-stage epoxy, is printed onto the wafer in a predetermined pattern such that the die attach material does not encroaching into the interior keep-out areas. The wafer is singulated to produce singulated microelectronic die each including a layer of die attach material. The singulated microelectronic die are then placed onto leadframes or other package substrates with the die attach material contacting the package substrates. The layer of die attach material is then fully cured to adhere an outer peripheral portion of the singulated microelectronic die to its package substrate.\"}', '{\"abstract\": \"An electronic device package technology is disclosed. An electronic device package may include a substrate. The electronic device package may also include a first electronic component and a second electronic component in a stacked configuration. Each of the first electronic component and the second electronic component can include electrical interconnections exposed toward the substrate. The electronic device package may further include a mold compound encapsulating the first electronic component and the second electronic component. Additionally, the electronic device package can include electrically conductive posts extending through the mold compound between the electrical interconnection of at least one of the first electronic component and the second electronic component and the substrate. Related systems and methods are also disclosed.\"}']\n" + "['{\"abstract\": \"A chip package is provided, the chip package including: a chip carrier; a chip disposed over and electrically connected to a chip carrier top side; an electrically insulating material disposed over and at least partially surrounding the chip; one or more electrically conductive contact regions formed over the electrically insulating material and in electrical connection with the chip; and another electrically insulating material disposed over a chip carrier bottom side. An electrically conductive contact region on the chip carrier bottom side is released from the further electrically insulating material.\"}', '{\"abstract\": \"A method of forming a microelectronic assembly includes positioning a support structure adjacent to an active region of a device but not extending onto the active region. The support structure has planar sections. Each planar section has a substantially uniform composition. The composition of at least one of the planar sections differs from the composition of at least one of the other planar sections. A lid is positioned in contact with the support structure and extends over the active region. The support structure is bonded to the device and to the lid.\"}', '{\"abstract\": \"To provide a substrate for a semiconductor mounting device capable of obtaining high reliability. In a semiconductor mounting device substrate of the present invention, a semiconductor chip can be surface-mounted by a flip chip connection method on a semiconductor chip mounting region of a first main surface of a multilayer wiring substrate. A plurality of second main surface side solder bumps 52 forming a plate-like component mounting region 53 are formed at a location immediately below the semiconductor chip 21 on the second main surface 13 of the multilayer wiring board 11. A plate-like component 101 mainly composed of an inorganic material is surface-mounted on the multilayer wiring board 11 by a flip chip connection method via a plurality of second main surface side solder bumps 52. A plurality of second main surface side solder bumps 52 are sealed by a second main surface side underfill 107 provided in the gap S <b> 2 between the second main surface 13 and the plate-like component 101. [Selection] Figure 1\"}', '{\"abstract\": \"A ceramic electronic component includes an electronic component body, an inner electrode, and an outer electrode. The outer electrode includes a fired electrode layer and first and second plated layers. The fired electrode layer is disposed on the electronic component body. The first plated layer is disposed on the fired electrode layer. The thickness of the first plated layer is about 3 \\\\u03bcm to about 8 \\\\u03bcm, for example. The first plated layer contains nickel. The second plated layer is disposed on the first plated layer. The thickness of the second plated layer is about 0.025 \\\\u03bcm to about 1 \\\\u03bcm, for example. The second plated layer contains lead.\"}', '{\"abstract\": \"Processes of assembling microelectronic packages with lead frames and/or other suitable substrates are described herein. In one embodiment, a method for fabricating a semiconductor assembly includes forming an attachment area and a non-attachment area on a lead finger of a lead frame. The attachment area is more wettable to the solder ball than the non-attachment area during reflow. The method also includes contacting a solder ball carried by a semiconductor die with the attachment area of the lead finger, reflowing the solder ball while the solder ball is in contact with the attachment area of the lead finger, and controllably collapsing the solder ball to establish an electrical connection between the semiconductor die and the lead finger of the lead frame.\"}']\n" ] } ], @@ -1902,7 +1561,7 @@ }, { "cell_type": "code", - "execution_count": 41, + "execution_count": 23, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -1935,7 +1594,7 @@ "be : Summary of the top 5 abstracts that are semantically closest to the user query.\n", "\n", "User Query: Chip assemblies employing solder bonds to back-side lands including an electrolytic nickel layer\n", - "Top 5 abstracts: ['{\"abstract\": \"Substrate for electrical devices and methods of fabricating such substrate are disclosed. An embodiment for an electrical device with substrate comprised of a chip having an active surface; a substrate being coupled with the chip; and a plurality of conductive wires (bumps) electrically connecting the chip to the substrate. In additionally, the present invention of the substrate for electrical devices may be comprised of an adhesive mean or a submember as required, and furthermore, by mean of using substrate, the present invention may be capable of affording a number of advantages, it is possible to include a thinner electrical device thickness, enhanced reliability, and a decreased cost in production.\"}', '{\"abstract\": \"An electronic device is mounted on a wiring board, which includes: a substrate having through holes, and lands extending on surfaces of the substrate and adjacent to openings of the through holes. Further, at least one coating layer is provided, which coats at least one part of an outer peripheral region of the at least one land, in order to cause that the at least one part is separated from a lead-less solder, thereby preventing any peel of the land from the surface of the substrate.\"}', '{\"abstract\": \"An assembly and method suitable for use in packaging integrated circuits including a support substrate for supporting an integrated circuit die embedded in a molded encapsulating cap. The substrate includes a conductive die attach pad adapted to be molded into the encapsulating cap. The pad includes an interior facing support surface and a spaced-apart exterior facing exposed surface defined by a peripheral edge. The support surface is adapted to support the embedded die, while the exposed surface is to be exposed from the encapsulating cap. The attach pad further includes a locking ledge portion extending outward peripherally beyond at least a portion of the exposed surface peripheral edge. This ledge is adapted to be subtended in the encapsulating cap in a manner substantially preventing a pull-out of the attach pad in a direction away from the encapsulating cap.\"}', '{\"abstract\": \"Embodiments of microelectronic packages and methods for fabricating microelectronic packages are provided. In one embodiment, the fabrication method includes printing a patterned die attach material onto the backside of a wafer including an array of non-singulated microelectronic die each having an interior keep-out area, such as a central keep-out area. The die attach material, such as a B-stage epoxy, is printed onto the wafer in a predetermined pattern such that the die attach material does not encroaching into the interior keep-out areas. The wafer is singulated to produce singulated microelectronic die each including a layer of die attach material. The singulated microelectronic die are then placed onto leadframes or other package substrates with the die attach material contacting the package substrates. The layer of die attach material is then fully cured to adhere an outer peripheral portion of the singulated microelectronic die to its package substrate.\"}', '{\"abstract\": \"An electronic device package technology is disclosed. An electronic device package may include a substrate. The electronic device package may also include a first electronic component and a second electronic component in a stacked configuration. Each of the first electronic component and the second electronic component can include electrical interconnections exposed toward the substrate. The electronic device package may further include a mold compound encapsulating the first electronic component and the second electronic component. Additionally, the electronic device package can include electrically conductive posts extending through the mold compound between the electrical interconnection of at least one of the first electronic component and the second electronic component and the substrate. Related systems and methods are also disclosed.\"}']\n", + "Top 5 abstracts: ['{\"abstract\": \"A chip package is provided, the chip package including: a chip carrier; a chip disposed over and electrically connected to a chip carrier top side; an electrically insulating material disposed over and at least partially surrounding the chip; one or more electrically conductive contact regions formed over the electrically insulating material and in electrical connection with the chip; and another electrically insulating material disposed over a chip carrier bottom side. An electrically conductive contact region on the chip carrier bottom side is released from the further electrically insulating material.\"}', '{\"abstract\": \"A method of forming a microelectronic assembly includes positioning a support structure adjacent to an active region of a device but not extending onto the active region. The support structure has planar sections. Each planar section has a substantially uniform composition. The composition of at least one of the planar sections differs from the composition of at least one of the other planar sections. A lid is positioned in contact with the support structure and extends over the active region. The support structure is bonded to the device and to the lid.\"}', '{\"abstract\": \"To provide a substrate for a semiconductor mounting device capable of obtaining high reliability. In a semiconductor mounting device substrate of the present invention, a semiconductor chip can be surface-mounted by a flip chip connection method on a semiconductor chip mounting region of a first main surface of a multilayer wiring substrate. A plurality of second main surface side solder bumps 52 forming a plate-like component mounting region 53 are formed at a location immediately below the semiconductor chip 21 on the second main surface 13 of the multilayer wiring board 11. A plate-like component 101 mainly composed of an inorganic material is surface-mounted on the multilayer wiring board 11 by a flip chip connection method via a plurality of second main surface side solder bumps 52. A plurality of second main surface side solder bumps 52 are sealed by a second main surface side underfill 107 provided in the gap S <b> 2 between the second main surface 13 and the plate-like component 101. [Selection] Figure 1\"}', '{\"abstract\": \"A ceramic electronic component includes an electronic component body, an inner electrode, and an outer electrode. The outer electrode includes a fired electrode layer and first and second plated layers. The fired electrode layer is disposed on the electronic component body. The first plated layer is disposed on the fired electrode layer. The thickness of the first plated layer is about 3 \\\\u03bcm to about 8 \\\\u03bcm, for example. The first plated layer contains nickel. The second plated layer is disposed on the first plated layer. The thickness of the second plated layer is about 0.025 \\\\u03bcm to about 1 \\\\u03bcm, for example. The second plated layer contains lead.\"}', '{\"abstract\": \"Processes of assembling microelectronic packages with lead frames and/or other suitable substrates are described herein. In one embodiment, a method for fabricating a semiconductor assembly includes forming an attachment area and a non-attachment area on a lead finger of a lead frame. The attachment area is more wettable to the solder ball than the non-attachment area during reflow. The method also includes contacting a solder ball carried by a semiconductor die with the attachment area of the lead finger, reflowing the solder ball while the solder ball is in contact with the attachment area of the lead finger, and controllably collapsing the solder ball to establish an electrical connection between the semiconductor die and the lead finger of the lead frame.\"}']\n", "\n", "Instructions:\n", "\n", @@ -1978,7 +1637,7 @@ }, { "cell_type": "code", - "execution_count": 38, + "execution_count": 24, "metadata": { "executionInfo": { "elapsed": 1, @@ -2010,7 +1669,7 @@ }, { "cell_type": "code", - "execution_count": 42, + "execution_count": 25, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -2033,7 +1692,7 @@ { "data": { "text/html": [ - "Load job 53681d07-ddc6-4f62-a170-ac5cafc1c7af is DONE. Open Job" + "Load job 34f3b649-6e45-46db-a6e5-405ae0a8bf69 is DONE. Open Job" ], "text/plain": [ "" @@ -2045,7 +1704,7 @@ { "data": { "text/html": [ - "Query job 259907b0-1bae-402f-be4f-d45e478832f1 is DONE. 5.3 kB processed. Open Job" + "Query job a574725f-64ae-4a19-aac0-959bec0bffeb is DONE. 5.0 kB processed. Open Job" ], "text/plain": [ "" @@ -2058,69 +1717,25 @@ "name": "stderr", "output_type": "stream", "text": [ - "/usr/local/lib/python3.10/dist-packages/bigframes/core/array_value.py:114: PreviewWarning: \u001b[93mJSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\u001b[0m\n", + "/usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/bigframes/core/array_value.py:109: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", + "`db_dtypes` is a preview feature and subject to change.\n", " warnings.warn(msg, bfe.PreviewWarning)\n" ] }, - { - "data": { - "text/html": [ - "Query job f3e6dca3-7674-41f6-a4ba-0daec387e25e is DONE. 2 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Query job e06bc512-d746-433b-b431-3e7426b6cd9c is DONE. 2 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/lib/python3.10/dist-packages/bigframes/core/array_value.py:238: AmbiguousWindowWarning: \u001b[93mWindow ordering may be ambiguous, this can cause unstable results.\u001b[0m\n", - " warnings.warn(msg, bfe.AmbiguousWindowWarning)\n" - ] - }, - { - "data": { - "text/html": [ - "Query job bcf9e83c-a420-4282-86b1-d005244c97f2 is DONE. 1.5 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/markdown": [ "User Query: Chip assemblies employing solder bonds to back-side lands including an electrolytic nickel layer\n", "\n", - "Summary of the top 5 abstracts that are semantically closest to the user query.\n", + "Summary of the top 5 abstracts that are semantically closest to the user query:\n", "\n", - "The top five patent abstracts describe advancements in microelectronic packaging, focusing on improved chip-to-substrate interconnection and enhanced reliability. A common thread is the development of novel substrate designs and assembly methods to facilitate robust electrical connections. Several abstracts highlight techniques for creating reliable connections between chips and substrates, emphasizing the use of conductive materials and adhesives to ensure strong and durable bonds. These methods aim to improve the overall reliability and performance of electronic devices. The innovations include improved techniques for preventing delamination or peeling of conductive lands, leading to more robust assemblies. The use of encapsulating materials and specialized die-attach methods are also prominent, suggesting a focus on protecting the chip and its connections from environmental factors. These advancements collectively contribute to the creation of thinner, more reliable, and cost-effective electronic devices, with applications spanning various consumer electronics and other industries. While the abstracts don't explicitly mention electrolytic nickel layers, the focus on improved solder bond reliability and substrate design suggests that such a layer could be a complementary enhancement to the described technologies.\n" + "The abstracts describe various aspects of microelectronic assembly and packaging, with a focus on enhancing reliability and electrical connectivity. A common theme is the use of solder bumps or balls for creating electrical connections between different components, such as semiconductor chips and substrates or lead frames. Several abstracts highlight methods for improving the solderability and wettability of contact regions, often involving the use of multiple layers with differing compositions. The use of electrically insulating materials to provide support and protection to the chip and electrical connections is also described. One abstract specifically mentions a nickel-containing plated layer as part of an outer electrode, suggesting its role in improving the electrical or mechanical properties of the connection. The innovations aim to improve the reliability and performance of microelectronic devices through optimized material selection, assembly processes, and structural designs.\n" ], "text/plain": [ "" ] }, - "execution_count": 42, + "execution_count": 25, "metadata": {}, "output_type": "execute_result" } @@ -2153,7 +1768,7 @@ "toc_visible": true }, "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "venv", "language": "python", "name": "python3" }, @@ -2167,7 +1782,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.9" + "version": "3.10.16" } }, "nbformat": 4, diff --git a/notebooks/getting_started/bq_dataframes_template.ipynb b/notebooks/getting_started/bq_dataframes_template.ipynb index e8002fd611..0970dcedc9 100644 --- a/notebooks/getting_started/bq_dataframes_template.ipynb +++ b/notebooks/getting_started/bq_dataframes_template.ipynb @@ -144,7 +144,7 @@ }, "outputs": [], "source": [ - "PROJECT_ID = \"\" # @param {type: \"string\"}\n", + "PROJECT_ID = \"bigframes-dev\" # @param {type: \"string\"}\n", "LOCATION = \"US\" # @param {type: \"string\"}" ] }, @@ -180,7 +180,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "metadata": { "id": "NPPMuw2PXGeo" }, @@ -200,7 +200,7 @@ "# Note: BigQuery DataFrames objects are by default fully ordered like Pandas.\n", "# If ordering is not important for you, you can uncomment the following\n", "# expression to run BigQuery DataFrames in partial ordering mode.\n", - "# bpd.options.bigquery.ordering_mode = \"partial\"\n", + "bpd.options.bigquery.ordering_mode = \"partial\"\n", "\n", "# Note: By default BigQuery DataFrames emits out BigQuery job metadata via a\n", "# progress bar. But in this notebook let's disable the progress bar to keep the\n", @@ -643,16 +643,6 @@ "execution_count": 13, "metadata": {}, "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/swast/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/array_value.py:263: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", - " warnings.warn(msg, category=bfe.AmbiguousWindowWarning)\n", - "/home/swast/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/array_value.py:239: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", - " warnings.warn(msg, bfe.AmbiguousWindowWarning)\n" - ] - }, { "data": { "text/plain": [ @@ -665,7 +655,7 @@ }, { "data": { - "image/png": "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adSoEU8//TRr1qxRXzt06FDy8/OpV68eY8eOZenSpRgMhiu28eTJk+j1elq3bq0+1qBBA7y8vCr9Pv9rJJMihBDVQOPkRKP9+6rt2DWJvb09I0aMYMGCBdxzzz0sXryY2bNnX9U+rO9yrNFoytz1WKPR2NS3DBw4kDp16jBv3jyCg4MxmUw0bdqUoqIiAFq3bk1MTAwrV65k3bp1DBs2jL59+/LLL78QEhLCyZMnWbduHWvXruWpp57i/fffZ/Pmzdf1bsv/RRKkCCFENdBoNFc15FKddu3aZfPzzp07CQ8PJzIyEoPBwK5du9ThntTUVE6ePElkZCQAERER5b6+tDFjxtC0aVM+++wzDAYD99xzz3V6N5faOG/ePLp16wbAtm3bymzn7u7O/fffz/333899991H//79SUtLw9vbGycnJwYOHMjAgQMZP348jRs35siRIzZZktIaNWqEwWDgwIEDtGljHuqLiooiPT39+rzRW4AEKUIIIS4rNjaWyZMn8/jjj7N//34++eQTPvzwQ8LDwxk0aBBjx45l7ty5uLm58eKLL1KrVi0GDRoEwNNPP02XLl344IMPGDRoEKtXr7apR7GIiIigY8eOTJkyhcceewyn65jt8fLywsfHhy+//JKgoCBiY2N58cUXbbb56KOPCAoKolWrVmi1Wn7++WcCAwPx9PRk4cKFGI1GOnTogLOzM9999x1OTk42dSvlady4MX379mXcuHF8/vnn2NnZ8eyzz+J0lYXM/yVSkyKEEOKyRowYQX5+Pu3bt2f8+PE888wzjBs3DjDPwmnTpg133XUXnTp1QlEUVqxYoQ57dOzYkXnz5jF79mxatGjBmjVrePXVV8s9zujRoykqKuKxxx67ru9Hq9WyZMkS9u3bR9OmTfm///s/3n//fZtt3NzceO+992jbti3t2rXj7NmzrFixAq1Wi6enJ/PmzaNLly40b96cdevW8ccff+Dj43PFY3/77bcEBATQvXt3hgwZwtixY3Fzc8PR0fF6vd2bmkaxTHa/SVimIGdmZuLu7l7dzRFCiEopKCggJiaGunXrSodUgbfeeouff/6Zw4cPV3dTbpjz588TEhLCunXr6NOnT3U351+73Pf8WvpvGe4RQghRrXJycjh79iyffvop06ZNq+7mXFcbNmwgJyeHZs2akZCQwAsvvEBYWBjdu3ev7qbV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", + "image/png": "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yMhI7r33Xi5cuEDjxo3566+/2LlzJ48//jj9+/enc+fOANx33324uLiwdu1avLy8+Pzzz+nXrx9nz56lVq1a6npXrlzJ77//Tnp6OiNHjmT27NmlTsYVMWnSJE6ePMkPP/xA7dq1WbFiBYMGDeLYsWM0atQIgLy8PN5//32WLl2KVqvlkUce4fnnn+e7774D4J133uG7775j8eLFREREMHfuXFauXMltt90GwNy5czl79iwtWrTgjTfeAMDf318NVF5++WU++OAD/P39efLJJ3n88cfZsWPHDTnG1sD17QULeOell5jz2We8NH06Dz30EA0aNGDatGmEhoby+OOPM2nSJNauXVvpY3o9JEgRQghRruzsbObOncv8+fN57LHHAGjYsCE9evSwyyxUhtlsZtGiRXh4eNCsWTNuu+02zpw5w5o1a9BqtTRp0oR33nmHzZs307lzZ7Zv387evXtJTk7GyckJgPfff5+VK1eyfPlyJkyYoK53yZIleHh4APDoo4+yadOmSgcpsbGxLF68mNjYWGrXrg3A888/z7p161i8eDFvvfUWYJmg77PPPqNhw4aAJbCxBhsA8+bNY9q0aQwfPhyA+fPns2bNGvV5Ly8vHB0dcXV1JSgoqFQ7Zs2aRe/evQF46aWXGDJkCAUFBRWaJO1qx1gDNAwI4J233mLjihW09vICs9lyzIYN44ExY9B7ezN16lS6du3Kq6++ysCBAwF47rnnGDNmTKWO599xywQpCxYsYMGCBZhMpupuihBC/G0uDjpOvjGw2rZdUadOncJgMNCvX78q235YWJgaSAAEBgai0+nsJgILDAwkOTkZgCNHjpCTk4Ovr6/devLz8+26HkquNzg4WF1HZRw7dgyTyUTjxo3tHjcYDHZtcHV1VQOUktvLzMwkKSmJTp06qc/rdDrat2+PuTgguJZWrVrZrRsgOTmZ0NDQa742LCwMd3d3FKMRxWjE38sLbcOGmJKTMefkYC4owN/Li6RLl1CMRvV1rTt0QOflBVjeA4CWLVuqzwcGBlJQUEBWVhaenp4V2o+/45YJUiZOnMjEiRPJysrCq/gACiHErUqj0VSqy6W6XG0Kf2tQYVvjUpE7PDs4ONj9rtFoynzMejLPyckhODi4zMyN7fDmq62jMnJyctDpdBw4cACdzj6gc3d3v+r2qvLmkbbrt85UbLs/itls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", "text/plain": [ "
" ] @@ -700,7 +690,7 @@ }, { "data": { - "image/png": "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GGzevLncHZKePHky0tLSFF9JSUnv3bZojMO7d0QmqmiKPt8c00NEFdEnHYGJjo7GkydPlO4fk5+fj9OnT2Pp0qU4cuQIcnJykJqaqnQU5vHjx7CwsAAAWFhYIDIyUmm/RVcpvbvN369cevz4MeRy+XsnVdPW1oa2tvZHvQ91dXUYGRkp7sejp6fHycuowigsLERmZiaePHkCIyMjqKurCx2JiKjEfVKBadu2rcoU7UOGDEHNmjURGBgIa2traGpqIiwsTHGH4bi4OCQmJipuGujp6Ym5c+fiyZMnivEUoaGhkMvliinvPT09cfDgQaXXCQ0NVbnx4H9RVJbevakgUUViZGSk+JwTEVU0n1RgDAwMULt2baVllSpVgomJiWK5n58fxo8fj8qVK0Mul2PUqFHw9PREkyZNAADt27eHq6srBg4ciKCgIKSkpGDKlCkYOXKk4gjK8OHDsXTpUkyaNAm+vr44fvw4tm/fjgMHDpTEewbw9oaClpaWMDMz443/qMLR1NTkkRciqtBK/FYCixYtgpqaGnr16oXs7Gx4e3tj+fLlivXq6urYv38/RowYAU9PT1SqVAmDBg3CrFmzFNvY29vjwIEDGDduHBYvXoxq1aphzZo18Pb2Lum4UFdX5w96IiIikZHkrQSIiIjEgPPAlNKtBIiIiIiEwAJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREolPiN3MkIhKrkr7vTHm/5wyRmPEIDBEREYkOj8CQIKR4h1UiIio5PAJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLzSQVmxYoVqFOnDuRyOeRyOTw9PXHo0CHF+jdv3mDkyJEwMTGBvr4+evXqhcePHyvtIzExEZ07d4aenh7MzMwwceJE5OXlKW1z8uRJeHh4QFtbG46OjggJCfn375CIiIgqnE8qMNWqVcOCBQsQHR2NqKgotGnTBt26dcO1a9cAAOPGjcO+ffuwY8cOnDp1Co8ePULPnj0Vz8/Pz0fnzp2Rk5OD8PBwbNiwASEhIZg2bZpim4SEBHTu3BmtW7dGTEwMxo4dC39/fxw5cqSE3jIRERGJnaywsLDwv+ygcuXK+N///ocvvvgCpqam2LJlC7744gsAwM2bN+Hi4oKIiAg0adIEhw4dQpcuXfDo0SOYm5sDAFauXInAwEA8ffoUWlpaCAwMxIEDB3D16lXFa/Tt2xepqak4fPjwR+dKT0+HoaEh0tLSIJfL/8tbpFJwo6ZLie/T5eaNEt8nSUtJfy75maT/Soo/Kz/29/e/HgOTn5+PrVu34vXr1/D09ER0dDRyc3Ph5eWl2KZmzZqwsbFBREQEACAiIgJubm6K8gIA3t7eSE9PVxzFiYiIUNpH0TZF+3if7OxspKenK30RERFRxfTJBebKlSvQ19eHtrY2hg8fjt27d8PV1RUpKSnQ0tKCkZGR0vbm5uZISUkBAKSkpCiVl6L1Res+tE16ejqysrLem2v+/PkwNDRUfFlbW3/qWyMiIiKR+OQC4+zsjJiYGJw/fx4jRozAoEGDcP369dLI9kkmT56MtLQ0xVdSUpLQkYiIiKiUaHzqE7S0tODo6AgAqF+/Pi5cuIDFixfjyy+/RE5ODlJTU5WOwjx+/BgWFhYAAAsLC0RGRirtr+gqpXe3+fuVS48fP4ZcLoeuru57c2lra0NbW/tT3w4RERGJ0H+eB6agoADZ2dmoX78+NDU1ERYWplgXFxeHxMREeHp6AgA8PT1x5coVPHnyRLFNaGgo5HI5XF1dFdu8u4+ibYr2QURERPRJR2AmT56Mjh07wsbGBq9evcKWLVtw8uRJHDlyBIaGhvDz88P48eNRuXJlyOVyjBo1Cp6enmjSpAkAoH379nB1dcXAgQMRFBSElJQUTJkyBSNHjlQcPRk+fDiWLl2KSZMmwdfXF8ePH8f27dtx4MCBkn/3REREJEqfVGCePHkCHx8fJCcnw9DQEHXq1MGRI0fQrl07AMCiRYugpqaGXr16ITs7G97e3li+fLni+erq6ti/fz9GjBgBT09PVKpUCYMGDcKsWbMU29jb2+PAgQMYN24cFi9ejGrVqmHNmjXw9vYuobdMREREYvef54EprzgPTPkmxbkNqPzjPDBU3kjxZ2WpzwNDREREJBQWGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEp1PKjDz589Hw4YNYWBgADMzM3Tv3h1xcXFK27x58wYjR46EiYkJ9PX10atXLzx+/Fhpm8TERHTu3Bl6enowMzPDxIkTkZeXp7TNyZMn4eHhAW1tbTg6OiIkJOTfvUMiIiKqcD6pwJw6dQojR47EuXPnEBoaitzcXLRv3x6vX79WbDNu3Djs27cPO3bswKlTp/Do0SP07NlTsT4/Px+dO3dGTk4OwsPDsWHDBoSEhGDatGmKbRISEtC5c2e0bt0aMTExGDt2LPz9/XHkyJESeMtEREQkdrLCwsLCf/vkp0+fwszMDKdOnULLli2RlpYGU1NTbNmyBV988QUA4ObNm3BxcUFERASaNGmCQ4cOoUuXLnj06BHMzc0BACtXrkRgYCCePn0KLS0tBAYG4sCBA7h69aritfr27YvU1FQcPnz4o7Klp6fD0NAQaWlpkMvl//YtUim5UdOlxPfpcvNGie+TpKWkP5f8TNJ/JcWflR/7+/s/jYFJS0sDAFSuXBkAEB0djdzcXHh5eSm2qVmzJmxsbBAREQEAiIiIgJubm6K8AIC3tzfS09Nx7do1xTbv7qNom6J9FCc7Oxvp6elKX0RERFQx/esCU1BQgLFjx6JZs2aoXbs2ACAlJQVaWlowMjJS2tbc3BwpKSmKbd4tL0Xri9Z9aJv09HRkZWUVm2f+/PkwNDRUfFlbW//bt0ZERETl3L8uMCNHjsTVq1exdevWkszzr02ePBlpaWmKr6SkJKEjERERUSnR+DdPCggIwP79+3H69GlUq1ZNsdzCwgI5OTlITU1VOgrz+PFjWFhYKLaJjIxU2l/RVUrvbvP3K5ceP34MuVwOXV3dYjNpa2tDW1v737wdIiIiEplPOgJTWFiIgIAA7N69G8ePH4e9vb3S+vr160NTUxNhYWGKZXFxcUhMTISnpycAwNPTE1euXMGTJ08U24SGhkIul8PV1VWxzbv7KNqmaB9EREQkbZ90BGbkyJHYsmUL/vzzTxgYGCjGrBgaGkJXVxeGhobw8/PD+PHjUblyZcjlcowaNQqenp5o0qQJAKB9+/ZwdXXFwIEDERQUhJSUFEyZMgUjR45UHEEZPnw4li5dikmTJsHX1xfHjx/H9u3bceDAgRJ++0RERCRGn3QEZsWKFUhLS0OrVq1gaWmp+Nq2bZtim0WLFqFLly7o1asXWrZsCQsLC+zatUuxXl1dHfv374e6ujo8PT3x1VdfwcfHB7NmzVJsY29vjwMHDiA0NBR169bFwoULsWbNGnh7e5fAWyYiIiKx+0/zwJRnnAemfJPi3AZU/nEeGCpvpPizskzmgSEiIiISAgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERic4nF5jTp0+ja9eusLKygkwmw549e5TWFxYWYtq0abC0tISuri68vLxw+/ZtpW1evHiBAQMGQC6Xw8jICH5+fsjIyFDa5vLly2jRogV0dHRgbW2NoKCgT393REREVCF9coF5/fo16tati2XLlhW7PigoCEuWLMHKlStx/vx5VKpUCd7e3njz5o1imwEDBuDatWsIDQ3F/v37cfr0aQwbNkyxPj09He3bt4etrS2io6Pxv//9DzNmzMCvv/76L94iERERVTQan/qEjh07omPHjsWuKywsRHBwMKZMmYJu3boBADZu3Ahzc3Ps2bMHffv2xY0bN3D48GFcuHABDRo0AAD88ssv6NSpE3766SdYWVlh8+bNyMnJwbp166ClpYVatWohJiYGP//8s1LRISIiImkq0TEwCQkJSElJgZeXl2KZoaEhGjdujIiICABAREQEjIyMFOUFALy8vKCmpobz588rtmnZsiW0tLQU23h7eyMuLg4vX74s9rWzs7ORnp6u9EVEREQVU4kWmJSUFACAubm50nJzc3PFupSUFJiZmSmt19DQQOXKlZW2KW4f777G382fPx+GhoaKL2tr6//+hoiIiKhcqjBXIU2ePBlpaWmKr6SkJKEjERERUSkp0QJjYWEBAHj8+LHS8sePHyvWWVhY4MmTJ0rr8/Ly8OLFC6VtitvHu6/xd9ra2pDL5UpfREREVDGVaIGxt7eHhYUFwsLCFMvS09Nx/vx5eHp6AgA8PT2RmpqK6OhoxTbHjx9HQUEBGjdurNjm9OnTyM3NVWwTGhoKZ2dnGBsbl2RkIiIiEqFPLjAZGRmIiYlBTEwMgLcDd2NiYpCYmAiZTIaxY8dizpw52Lt3L65cuQIfHx9YWVmhe/fuAAAXFxd06NABQ4cORWRkJM6ePYuAgAD07dsXVlZWAID+/ftDS0sLfn5+uHbtGrZt24bFixdj/PjxJfbGiYiISLw++TLqqKgotG7dWvG4qFQMGjQIISEhmDRpEl6/fo1hw4YhNTUVzZs3x+HDh6Gjo6N4zubNmxEQEIC2bdtCTU0NvXr1wpIlSxTrDQ0NcfToUYwcORL169dHlSpVMG3aNF5CTURERAAAWWFhYaHQIUpDeno6DA0NkZaWxvEw5dCNmi4lvk+XmzdKfJ8kLSX9ueRnkv4rKf6s/Njf3xXmKiQiIiKSDhYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0NoQMQERFVBG4b3Ep8n9tLfI8VBwsMEYkSf1kQSRsLDH2Ukv5lwV8URET0X3AMDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJTrkuMMuWLYOdnR10dHTQuHFjREZGCh2JiIiIyoFyOxPvtm3bMH78eKxcuRKNGzdGcHAwvL29ERcXBzMzM6HjlRi77w6U+D7vLehc4vskaSnpzyU/k/Rf8Wcl/V25PQLz888/Y+jQoRgyZAhcXV2xcuVK6OnpYd26dUJHIyIiIoGVyyMwOTk5iI6OxuTJkxXL1NTU4OXlhYiIiGKfk52djezsbMXjtLQ0AEB6enrphv2PCrIzS3yf6ZPlJb7PfNtqJbq/jPz8Et0fUP7/W4tJSX8uxfCZBEr+c8nPZMkRw89KMXwmgfL/uSzKV1hY+MHtymWBefbsGfLz82Fubq603NzcHDdv3iz2OfPnz8fMmTNVlltbW5dKxvLMsFT2eqNE99aoRPf2/xmWzjun/04Mn0mgFD6X/EyWayX/X0cEn0lANJ/LV69ewfADWctlgfk3Jk+ejPHjxyseFxQU4MWLFzAxMYFMJhMwmfilp6fD2toaSUlJkMtL/i9pok/FzySVN/xMlpzCwkK8evUKVlZWH9yuXBaYKlWqQF1dHY8fP1Za/vjxY1hYWBT7HG1tbWhraystMzIyKq2IkiSXy/k/JpUr/ExSecPPZMn40JGXIuVyEK+Wlhbq16+PsLAwxbKCggKEhYXB09NTwGRERERUHpTLIzAAMH78eAwaNAgNGjRAo0aNEBwcjNevX2PIkCFCRyMiIiKBldsC8+WXX+Lp06eYNm0aUlJSUK9ePRw+fFhlYC+VPm1tbUyfPl3lFB2RUPiZpPKGn8myJyv8p+uUiIiIiMqZcjkGhoiIiOhDWGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdMrtZdRUPmRnZ/OyQBJcQkIC/vrrL9y/fx+ZmZkwNTWFu7s7PD09oaOjI3Q8kiB+JoXHAkNKDh06hK1bt+Kvv/5CUlISCgoKUKlSJbi7u6N9+/YYMmTIP96fgqikbN68GYsXL0ZUVBTMzc1hZWUFXV1dvHjxAvHx8dDR0cGAAQMQGBgIW1tboeOSBPAzWX5wHhgCAOzevRuBgYF49eoVOnXqhEaNGin9j3n16lX89ddfiIiIwODBgzF79myYmpoKHZsqMHd3d2hpaWHQoEHo2rWryp3ls7OzERERga1bt+KPP/7A8uXL0bt3b4HSkhTwM1m+sMAQAMDT0xNTpkxBx44doab2/qFRDx8+xC+//AJzc3OMGzeuDBOS1Bw5cgTe3t4fte3z589x79491K9fv5RTkZTxM1m+sMAQERGR6HAMDL1XTk4OEhISUL16dWho8KNC5cObN2+Qk5OjtEwulwuUhoifSaHwMmpSkZmZCT8/P+jp6aFWrVpITEwEAIwaNQoLFiwQOB1JUWZmJgICAmBmZoZKlSrB2NhY6YuorPEzKTwWGFIxefJkxMbG4uTJk0qXA3p5eWHbtm0CJiOpmjhxIo4fP44VK1ZAW1sba9aswcyZM2FlZYWNGzcKHY8kiJ9J4XEMDKmwtbXFtm3b0KRJExgYGCA2NhYODg64c+cOPDw8kJ6eLnREkhgbGxts3LgRrVq1glwux8WLF+Ho6IhNmzbh999/x8GDB4WOSBLDz6TweASGVDx9+hRmZmYqy1+/fg2ZTCZAIpK6Fy9ewMHBAcDbsQUvXrwAADRv3hynT58WMhpJFD+TwmOBIRUNGjTAgQMHFI+LSsuaNWvg6ekpVCySMAcHByQkJAAAatasie3btwMA9u3bByMjIwGTkVTxMyk8XlpCKubNm4eOHTvi+vXryMvLw+LFi3H9+nWEh4fj1KlTQscjCRoyZAhiY2Px2Wef4bvvvkPXrl2xdOlS5Obm4ueffxY6HkkQP5PC4xgYKlZ8fDwWLFiA2NhYZGRkwMPDA4GBgXBzcxM6GhHu37+P6OhoODo6ok6dOkLHIeJnUgAsMERERCQ6PIVEH8QJmqg8GD16NBwdHTF69Gil5UuXLsWdO3cQHBwsTDCSrFmzZn1w/bRp08ooiXTxCAypyMzMxKRJk7B9+3Y8f/5cZX1+fr4AqUjKqlatir1796rcV+bixYv4/PPP8eDBA4GSkVS5u7srPc7NzUVCQgI0NDRQvXp1XLx4UaBk0sEjMKRi4sSJOHHiBFasWIGBAwdi2bJlePjwIVatWsWZeEkQz58/h6GhocpyuVyOZ8+eCZCIpO7SpUsqy9LT0zF48GD06NFDgETSw8uoScW+ffuwfPly9OrVCxoaGmjRogWmTJmCefPmYfPmzULHIwlydHTE4cOHVZYfOnRIMRcHkdDkcjlmzpyJqVOnCh1FEngEhlR8aIKmESNGCBmNJGr8+PEICAjA06dP0aZNGwBAWFgYFi5cyPEvVK6kpaUhLS1N6BiSwAJDKoomaLKxsVFM0NSoUSNO0ESC8fX1RXZ2NubOnYvZs2cDAOzs7LBixQr4+PgInI6kaMmSJUqPCwsLkZycjE2bNqFjx44CpZIWDuIlFYsWLYK6ujpGjx6NY8eOoWvXrigsLFRM0DRmzBihI5KEPX36FLq6utDX1xc6CkmYvb290mM1NTWYmpqiTZs2mDx5MgwMDARKJh0sMPSPOEETERGVNywwRFQueXh4ICwsDMbGxnB3d//gjUR5ySoJKSkpCQBgbW0tcBJp4RgYAvD2fO6wYcOgo6Ojcm737/4+mRhRaejWrRu0tbUV/+ad0Kk8ycvLw8yZM7FkyRJkZGQAAPT19TFq1ChMnz4dmpqaAies+HgEhgC8PZ8bFRUFExMTlXO775LJZLh7924ZJiMiKn9GjBiBXbt2YdasWfD09AQAREREYMaMGejevTtWrFghcMKKjwWGiMo9BwcHXLhwASYmJkrLU1NT4eHhwVJNZc7Q0BBbt25VueLo4MGD6NevHy+lLgOcyI6Iyr179+4VewuL7Oxs3kaABKGtrQ07OzuV5fb29tDS0ir7QBLEMTAE4O1EYR/r559/LsUkRP9n7969in8fOXJE6XYC+fn5CAsL++ApT6LSEhAQgNmzZ2P9+vWKsVpFcxUFBAQInE4aeAqJAACtW7f+qO1kMhmOHz9eymmI3lJTe3uQWCaT4e8/qjQ1NWFnZ4eFCxeiS5cuQsQjCevRowfCwsKgra2NunXrAgBiY2ORk5ODtm3bKm27a9cuISJWeDwCQwCAEydOCB2BSEVBQQGAt4flL1y4gCpVqgiciOgtIyMj9OrVS2kZL6MuWzwCQ+91584dxMfHo2XLltDV1UVhYSEvZSUionKBg3hJxfPnz9G2bVvUqFEDnTp1QnJyMgDAz88P3377rcDpSIpGjx5d7PxES5cuxdixY8s+EBEJjgWGVIwbNw6amppITEyEnp6eYvmXX36Jw4cPC5iMpOqPP/5As2bNVJY3bdoUO3fuFCAREbBz50706dMHTZo0gYeHh9IXlT4WGFJx9OhR/Pjjj6hWrZrScicnJ9y/f1+gVCRlz58/V7oCqYhcLsezZ88ESERSt2TJEgwZMgTm5ua4dOkSGjVqBBMTE9y9e5d3oy4jLDCk4vXr10pHXoq8ePFCcbkgUVlydHQs9ujfoUOH4ODgIEAikrrly5fj119/xS+//AItLS1MmjQJoaGhGD16NCexKyO8ColUtGjRAhs3bsTs2bMBvL2EtaCgAEFBQR99uTVRSRo/fjwCAgLw9OlTtGnTBgAQFhaGhQsXIjg4WNhwJEmJiYlo2rQpAEBXVxevXr0CAAwcOBBNmjTB0qVLhYwnCSwwpCIoKAht27ZFVFQUcnJyMGnSJFy7dg0vXrzA2bNnhY5HEuTr66uYJKyoWNvZ2WHFihXw8fEROB1JkYWFBV68eAFbW1vY2Njg3LlzqFu3LhISElTmLKLSwVNIpKJ27dq4desWmjdvjm7duuH169fo2bMnLl26hOrVqwsdjyQmLy8PGzduRM+ePfHgwQM8fvwY6enpuHv3LssLCaZNmzaKmaKHDBmCcePGoV27dvjyyy/Ro0cPgdNJA+eBIaJyT09PDzdu3ICtra3QUYgAvJ1ksaCgABoab09kbN26FeHh4XBycsLXX3/N+yGVARYYAgBcvnz5o7etU6dOKSYhUtWqVSuMHTsW3bt3FzoKEZUTHANDAIB69eop7jfz7my7Rf323WXF3RWYqDR98803+Pbbb/HgwQPUr18flSpVUlrPUk1CePnyJdauXYsbN24AAFxdXTFkyBBUrlxZ4GTSwCMwBABK87tcunQJEyZMwMSJE+Hp6QkAiIiIwMKFCxEUFMS/gqnMFd3U8V3vFm6Waiprp0+fxueffw65XI4GDRoAAKKjo5Gamop9+/ahZcuWAies+FhgSEWjRo0wY8YMdOrUSWn5wYMHMXXqVERHRwuUjKTqnyZQ5NgYKmtubm7w9PTEihUroK6uDuDt0elvvvkG4eHhuHLlisAJKz4WGFKhq6uLixcvwsXFRWn5jRs34OHhgaysLIGSERGVD7q6uoiJiYGzs7PS8ri4ONSrV48/J8sAx8CQChcXF8yfPx9r1qxRjKTPycnB/PnzVUoNUVm6fv06EhMTkZOTo7T8888/FygRSZWHhwdu3LihUmBu3LiBunXrCpRKWlhgSMXKlSvRtWtXVKtWTTE48vLly5DJZNi3b5/A6UiK7t69ix49euDKlSuKsS/A/w0u5xgYKmujR4/GmDFjcOfOHTRp0gQAcO7cOSxbtgwLFixQurKTg8xLB08hUbFev36NzZs34+bNmwDeHpXp37+/ytUfRGWha9euUFdXx5o1a2Bvb4/IyEg8f/4c3377LX766Se0aNFC6IgkMcUNLH8XB5mXPhYYIir3qlSpguPHj6NOnTowNDREZGQknJ2dcfz4cXz77be4dOmS0BFJYv5pYPm7OMi8dPAUEr0XxxtQeZGfnw8DAwMAb8vMo0eP4OzsDFtbW8TFxQmcjqSIpUR4LDCkguMNqLypXbs2YmNjYW9vj8aNGyMoKAhaWlr49ddf4eDgIHQ8IhIAb+ZIKsaMGQN7e3s8efIEenp6uHbtGk6fPo0GDRrg5MmTQscjCZoyZQoKCgoAALNmzUJCQgJatGiBgwcPYvHixQKnIyIhcAwMqeB4AxKDFy9ewNjYWOk2F0QkHTwCQyqKG28AgOMNSDC+vr549eqV0rLKlSsjMzMTvr6+AqUiIiGxwJCKovEGABTjDc6ePYtZs2ZxvAEJYsOGDcXObJqVlYWNGzcKkIikLikpCQ8ePFA8joyMxNixY/Hrr78KmEpaWGBIxYfGGyxZskTgdCQl6enpSEtLQ2FhIV69eoX09HTF18uXL3Hw4EGYmZkJHZMkqH///jhx4gQAICUlBe3atUNkZCR++OEHzJo1S+B00sAxMPRRON6AhKCmpvbBz5xMJsPMmTPxww8/lGEqIsDY2Bjnzp2Ds7MzlixZgm3btuHs2bM4evQohg8fjrt37wodscLjZdT0USpXrix0BJKgEydOoLCwEG3atMEff/yh9DnU0tKCra0trKysBExIUpWbmwttbW0AwLFjxxTzY9WsWRPJyclCRpMMFhgiKrc+++wzAEBCQgKsra3/cfp2orJSq1YtrFy5Ep07d0ZoaChmz54NAHj06BFMTEwETicNPIVERKKQmpqKyMhIPHnyRDFGq4iPj49AqUiqTp48iR49eiA9PR2DBg3CunXrAADff/89bt68iV27dgmcsOJjgSGicm/fvn0YMGAAMjIyIJfLlcbFyGQyvHjxQsB0JFX5+flIT0+HsbGxYtm9e/egp6fHweVlgAWGiMq9GjVqoFOnTpg3bx709PSEjkNE5QALDKnYsGEDqlSpgs6dOwMAJk2ahF9//RWurq74/fffeRMzKnOVKlXClStXOA8RCcrDwwNhYWEwNjaGu7v7B6+Qu3jxYhkmkyYO4iUV8+bNw4oVKwAAERERWLZsGRYtWoT9+/dj3LhxPLdLZc7b2xtRUVEsMCSobt26Ka486t69u7BhiEdgSJWenh5u3rwJGxsbBAYGIjk5GRs3bsS1a9fQqlUrPH36VOiIJDFr167FrFmzMGTIELi5uUFTU1NpfdElrEQkHTwCQyr09fXx/Plz2NjY4OjRoxg/fjwAQEdHp9jp3IlK29ChQwGg2BlOZTIZ8vPzyzoSEQmMBYZUtGvXDv7+/nB3d8etW7fQqVMnAMC1a9dgZ2cnbDiSpL9fNk0khE+ZjZxXxpU+FhhSsWzZMkyZMgVJSUn4448/FJMyRUdHo1+/fgKnIyISRnBwsNAR6B0cA0NEovD69WucOnUKiYmJyMnJUVo3evRogVIRkVBYYAgAcPnyZdSuXRtqamq4fPnyB7etU6dOGaUieuvSpUvo1KkTMjMz8fr1a1SuXBnPnj1TTBjGG+eREOLj47F+/XrEx8dj8eLFMDMzw6FDh2BjY4NatWoJHa/CY4EhAG/v+puSkgIzMzPFHYDf/WgUPeaASRJCq1atUKNGDaxcuRKGhoaIjY2FpqYmvvrqK4wZMwY9e/YUOiJJzKlTp9CxY0c0a9YMp0+fxo0bN+Dg4IAFCxYgKioKO3fuFDpihccCQwCA+/fvw8bGBjKZDPfv3//gtpzIjsqakZERzp8/D2dnZxgZGSEiIgIuLi44f/48Bg0ahJs3bwodkSTG09MTvXv3xvjx42FgYIDY2Fg4ODggMjISPXv2xIMHD4SOWOFxEC8BUC4lLChU3mhqairuRG1mZobExES4uLjA0NAQSUlJAqcjKbpy5Qq2bNmistzMzAzPnj0TIJH0sMAQAGDv3r0fvS0nDaOy5u7ujgsXLsDJyQmfffYZpk2bhmfPnmHTpk2oXbu20PFIgoyMjJCcnAx7e3ul5ZcuXULVqlUFSiUtPIVEAKD46/afcAwMCSEqKgqvXr1C69at8eTJE/j4+CA8PBxOTk5Yt24d6tatK3REkpgJEybg/Pnz2LFjB2rUqIGLFy/i8ePH8PHxgY+PD6ZPny50xAqPBYaIiOgT5eTkYOTIkQgJCUF+fj40NDSQn5+P/v37IyQkBOrq6kJHrPBYYOiD3rx5Ax0dHaFjEBGVS0lJSbhy5QoyMjLg7u4OJycnoSNJBgsMqcjPz8e8efOwcuVKPH78GLdu3YKDgwOmTp0KOzs7+Pn5CR2RiIgk7uMGPpCkzJ07FyEhIQgKCoKWlpZiee3atbFmzRoBkxERlQ+9evXCjz/+qLI8KCgIvXv3FiCR9LDAkIqNGzfi119/xYABA5TO49atW5fzbRARATh9+rTiRrfv6tixI06fPi1AIulhgSEVDx8+hKOjo8rygoIC5ObmCpCISFVqaqrQEUjCMjIylI5QF9HU1ER6eroAiaSHBYZUuLq64q+//lJZvnPnTri7uwuQiKTuxx9/xLZt2xSP+/TpAxMTE1StWhWxsbECJiOpcnNzU/pMFtm6dStcXV0FSCQ9nMiOVEybNg2DBg3Cw4cPUVBQgF27diEuLg4bN27E/v37hY5HErRy5Ups3rwZABAaGorQ0FAcOnQI27dvx8SJE3H06FGBE5LUTJ06FT179kR8fDzatGkDAAgLC8Pvv/+OHTt2CJxOGngVEhXrr7/+wqxZsxAbG4uMjAx4eHhg2rRpaN++vdDRSIJ0dXVx69YtWFtbY8yYMXjz5g1WrVqFW7duoXHjxnj58qXQEUmCDhw4gHnz5iEmJga6urqoU6cOpk+fjs8++0zoaJLAAkNE5Z6VlRV27tyJpk2bwtnZGXPmzEHv3r0RFxeHhg0bcswBkQTxFBKpuHDhAgoKCtC4cWOl5efPn4e6ujoaNGggUDKSqp49e6J///5wcnLC8+fP0bFjRwBv7ztT3IBzotKWlJQEmUyGatWqAQAiIyOxZcsWuLq6YtiwYQKnkwYO4iUVI0eOLPYOvw8fPsTIkSMFSERSt2jRIgQEBMDV1RWhoaHQ19cHACQnJ+Obb74ROB1JUf/+/XHixAkAQEpKCry8vBAZGYkffvgBs2bNEjidNPAUEqnQ19fH5cuX4eDgoLQ8ISEBderUwatXrwRKRkRUPhgbG+PcuXNwdnbGkiVLsG3bNpw9exZHjx7F8OHDcffuXaEjVng8hUQqtLW18fjxY5UCk5ycDA0NfmSobOzduxcdO3aEpqYm9u7d+8FtP//88zJKRfRWbm4utLW1AQDHjh1TfAZr1qyJ5ORkIaNJBo/AkIp+/fohOTkZf/75JwwNDQG8nTSse/fuMDMzw/bt2wVOSFKgpqaGlJQUmJmZQU3t/We7ZTIZ8vPzyzAZEdC4cWO0bt0anTt3Rvv27XHu3DnUrVsX586dwxdffIEHDx4IHbHCY4EhFQ8fPkTLli3x/PlzxcR1MTExMDc3R2hoKKytrQVOSEQkrJMnT6JHjx5IT0/HoEGDsG7dOgDA999/j5s3b2LXrl0CJ6z4WGCoWK9fv8bmzZsRGxurmN+gX79+0NTUFDoaEVG5kJ+fj/T0dBgbGyuW3bt3D3p6ejAzMxMwmTSwwBBRubRkyZKP3nb06NGlmITo/Z4+fYq4uDgAgLOzM0xNTQVOJB0sMKRiw4YNqFKlCjp37gwAmDRpEn799Ve4urri999/h62trcAJSQrs7e0/ajuZTMYrPqjMvX79GqNGjcLGjRtRUFAAAFBXV4ePjw9++eUX6OnpCZyw4mOBIRXOzs5YsWIF2rRpg4iICLRt2xbBwcHYv38/NDQ0eG6XiCTv66+/xrFjx7B06VI0a9YMAHDmzBmMHj0a7dq1w4oVKwROWPGxwJAKPT093Lx5EzY2NggMDERycjI2btyIa9euoVWrVnj69KnQEUmicnJykJCQgOrVq/OSfhJUlSpVsHPnTrRq1Upp+YkTJ9CnTx/+nCwDnImXVOjr6+P58+cAgKNHj6Jdu3YAAB0dHWRlZQkZjSQqMzMTfn5+0NPTQ61atZCYmAgAGDVqFBYsWCBwOpKizMxMmJubqyw3MzNDZmamAImkhwWGVLRr1w7+/v7w9/fHrVu30KlTJwDAtWvXYGdnJ2w4kqTJkycjNjYWJ0+ehI6OjmK5l5cXtm3bJmAykipPT09Mnz4db968USzLysrCzJkz4enpKWAy6eAxWFKxbNkyTJkyBUlJSfjjjz9gYmICAIiOjka/fv0ETkdStGfPHmzbtg1NmjSBTCZTLK9Vqxbi4+MFTEZStXjxYnh7e6NatWqoW7cuACA2NhY6Ojo4cuSIwOmkgWNgiKjc09PTw9WrV+Hg4AADAwPExsbCwcEBsbGxaNmyJdLS0oSOSBKUmZmJzZs34+bNmwAAFxcXDBgwALq6ugInkwYegaFipaamYu3atbhx4waAt3/p+vr6Km4tQFSWGjRogAMHDmDUqFEAoDgKs2bNGh6uJ8Ho6elh6NChQseQLB6BIRVRUVHw9vaGrq4uGjVqBAC4cOECsrKycPToUXh4eAickKTmzJkz6NixI7766iuEhITg66+/xvXr1xEeHo5Tp06hfv36QkckiXnfDUZlMhl0dHTg6Oj40XMZ0b/DAkMqWrRoAUdHR6xevVpxqWpeXh78/f1x9+5dnD59WuCEJEXx8fFYsGABYmNjkZGRAQ8PDwQGBsLNzU3oaCRBampqkMlk+Puv0KJlMpkMzZs3x549e5RuNUAlhwWGVOjq6uLSpUuoWbOm0vLr16+jQYMGvESQiCQvLCwMP/zwA+bOnas4Uh0ZGYmpU6diypQpMDQ0xNdff43GjRtj7dq1AqetmDgGhlTI5XIkJiaqFJikpCQYGBgIlIqk7ODBg1BXV4e3t7fS8iNHjqCgoAAdO3YUKBlJ1ZgxY/Drr7+iadOmimVt27aFjo4Ohg0bhmvXriE4OBi+vr4CpqzYOA8Mqfjyyy/h5+eHbdu2ISkpCUlJSdi6dSv8/f15GTUJ4rvvvkN+fr7K8sLCQnz33XcCJCKpi4+Ph1wuV1kul8sV9+ZycnLCs2fPyjqaZPAIDKn46aefIJPJ4OPjg7y8PACApqYmRowYwVlPSRC3b9+Gq6uryvKaNWvizp07AiQiqatfvz4mTpyIjRs3Ku5A/fTpU0yaNAkNGzYE8PZza21tLWTMCo0FhlRoaWlh8eLFmD9/vmKSsOrVq/PuqiQYQ0ND3L17V2Um6Dt37qBSpUrChCJJW7t2Lbp164Zq1aopSkpSUhIcHBzw559/AgAyMjIwZcoUIWNWaBzES0Tl3tdff42IiAjs3r0b1atXB/C2vPTq1QsNGzbEmjVrBE5IUlRQUICjR4/i1q1bAABnZ2e0a9cOamocnVEWWGBIRY8ePZSmay/y7vwG/fv3h7OzswDpSIrS0tLQoUMHREVFoVq1agCABw8eoEWLFti1axeMjIyEDUiSc/fuXTg4OAgdQ9JYYEjF4MGDsWfPHhgZGSkmCLt48SJSU1PRvn17xMbG4t69ewgLC0OzZs0ETktSUVhYiNDQUMTGxkJXVxd16tRBy5YthY5FEqWmpobPPvsMfn5++OKLL5RuMkplgwWGVHz33XdIT0/H0qVLFYdCCwoKMGbMGBgYGGDu3LkYPnw4rl27hjNnzgiclqQqNTWVR15IMDExMVi/fj1+//135OTk4Msvv4Svry8aN24sdDTJYIEhFaampjh79ixq1KihtPzWrVto2rQpnj17hitXrqBFixZITU0VJiRJyo8//gg7Ozt8+eWXAIA+ffrgjz/+gIWFBQ4ePKi4GzBRWcvLy8PevXsREhKCw4cPo0aNGvD19cXAgQMVVydR6eBII1KRl5enuLvqu27evKmYi0NHR6fYcTJEpWHlypWKKz1CQ0MRGhqKQ4cOoWPHjpg4caLA6UjKNDQ00LNnT+zYsQM//vgj7ty5gwkTJsDa2ho+Pj5ITk4WOmKFxcuoScXAgQPh5+eH77//XjGfwYULFzBv3jz4+PgAAE6dOoVatWoJGZMkJCUlRVFg9u/fjz59+qB9+/aws7PjIXsSVFRUFNatW4etW7eiUqVKmDBhAvz8/PDgwQPMnDkT3bp1Q2RkpNAxKyQWGFKxaNEimJubIygoCI8fPwYAmJubY9y4cQgMDAQAtG/fHh06dBAyJkmIsbExkpKSYG1tjcOHD2POnDkA3g7sLW6GXqLS9vPPP2P9+vWIi4tDp06dsHHjRnTq1EkxbtDe3h4hISEqcxdRyeEYGPqg9PR0ACh2ymyishIQEID9+/fDyckJly5dwr1796Cvr4+tW7ciKCgIFy9eFDoiSYyTkxN8fX0xePBgWFpaFrtNTk4Ofv/9dwwaNKiM00kDCwypmD59Onx9fWFrayt0FCIAQG5uLhYvXoykpCQMHjwY7u7uAN4eLTQwMIC/v7/ACYmorLHAkIp69erh6tWrijkOevXqBW1tbaFjEREJ7vXr15gwYQL27t2LnJwctG3bFr/88guvOBIACwwV69KlS4o5DvLy8tC3b1/4+voqBvUSlbX4+HgEBwfjxo0bAABXV1eMHTuWs6FSmRo/fjx+/fVXDBgwADo6Ovj999/RrFkz7N69W+hoksMCQx+Um5uLffv2Yf369Thy5Ahq1qwJPz8/DB48GIaGhkLHI4k4cuQIPv/8c9SrV08x+/PZs2cRGxuLffv2oV27dgInJKmwt7dHUFAQevfuDQCIjo5GkyZNkJWVBQ0NXhdTllhg6INycnKwe/durFu3DsePH0fTpk3x6NEjPH78GKtXr1ZMLEZUmtzd3eHt7Y0FCxYoLf/uu+9w9OhRDuKlMqOpqYn79+/DyspKsUxPTw83b96EjY2NgMmkhxPZUbGio6MREBAAS0tLjBs3Du7u7rhx4wZOnTqF27dvY+7cuRg9erTQMUkibty4AT8/P5Xlvr6+uH79ugCJSKoKCgqgqamptExDQ4OX8wuAx7tIhZubG27evIn27dtj7dq16Nq1K9TV1ZW26devH8aMGSNQQpIaU1NTxMTEwMnJSWl5TEwMzMzMBEpFUlRYWIi2bdsqnS7KzMxE165doaWlpVjGo4KljwWGVPTp0we+vr6oWrXqe7epUqUKCgoKyjAVSdnQoUMxbNgw3L17F02bNgXwdgzMjz/+iPHjxwucjqRk+vTpKsu6desmQBLiGBhSkp6ejvPnzyMnJweNGjXipYFULhQWFiI4OBgLFy7Eo0ePAABWVlaYOHEiRo8ezftyEUkQCwwpxMTEoFOnTnj8+DEKCwthYGCA7du3w9vbW+hoRAqvXr0CABgYGAichIiExEG8pBAYGAh7e3ucOXMG0dHRaNu2LQICAoSORaTEwMCA5YUE0aFDB5w7d+4ft3v16hV+/PFHLFu2rAxSSRePwJBClSpVcPToUXh4eAAAUlNTUblyZaSmpvJeSCQod3f3Yk8TyWQy6OjowNHREYMHD0br1q0FSEdSsXbtWkybNg2Ghobo2rUrGjRoACsrK+jo6ODly5e4fv06zpw5g4MHD6Jz58743//+x0urSxELDCmoqakhJSVF6aoOAwMDXL58Gfb29gImI6mbPHkyVqxYATc3NzRq1AgAcOHCBVy+fBmDBw/G9evXERYWhl27dnFAJZWq7Oxs7NixA9u2bcOZM2eQlpYG4G2ZdnV1hbe3N/z8/ODi4iJw0oqPBYYU1NTUcPz4cVSuXFmxrGnTpti+fTuqVaumWFanTh0h4pGEDR06FDY2Npg6darS8jlz5uD+/ftYvXo1pk+fjgMHDiAqKkqglCRFaWlpyMrKgomJicr8MFS6WGBIQU1NDTKZDMV9JIqWy2QyTthEZc7Q0BDR0dFwdHRUWn7nzh3Ur18faWlpuHnzJho2bKgY5EtEFRvngSGFhIQEoSMQFUtHRwfh4eEqBSY8PBw6OjoA3s6QWvRvIqr4WGBIwdbWVugIRMUaNWoUhg8fjujoaMUd0S9cuIA1a9bg+++/B/D2ho/16tUTMCURlSWeQiIAQGJi4ieNln/48OEHZ+olKmmbN2/G0qVLERcXBwBwdnbGqFGj0L9/fwBAVlaW4qokIqr4WGAIAGBubo7u3bvD399f8Rfu36WlpWH79u1YvHgxhg0bxps5EhGRYHgKiQAA169fx9y5c9GuXTvo6Oigfv36KvMbXLt2DR4eHggKCkKnTp2EjkwSMmjQIPj5+aFly5ZCRyFSkpOTgydPnqjcG47zv5Q+HoEhJVlZWThw4ADOnDmD+/fvIysrC1WqVIG7uzu8vb1Ru3ZtoSOSBHXv3h0HDx6Era0thgwZgkGDBvEUJgnq9u3b8PX1RXh4uNJyXq1ZdlhgiEgUnj59ik2bNmHDhg24fv06vLy84Ofnh27dunH+DSpzzZo1g4aGBr777jtYWlqqzBRdt25dgZJJBwsMEYnOxYsXsX79eqxZswb6+vr46quv8M0338DJyUnoaCQRlSpVQnR0NGrWrCl0FMnizRyJSFSSk5MRGhqK0NBQqKuro1OnTrhy5QpcXV2xaNEioeORRLi6uuLZs2dCx5A0HoEhonIvNzcXe/fuxfr163H06FHUqVMH/v7+6N+/v+JGo7t374avry9evnwpcFqSguPHj2PKlCmYN28e3NzcVE5j8ga4pY8FhojKvSpVqqCgoAD9+vXD0KFDi52wLjU1Fe7u7pxRmsqEmtrbExh/H/vCQbxlhwWGiMq9TZs2oXfv3pykjsqNU6dOfXD9Z599VkZJpIsFhop1+/ZtnDhxotj5DaZNmyZQKpKie/fuITQ0FLm5ufjss89Qq1YtoSMRUTnAAkMqVq9ejREjRqBKlSqwsLBQOkQqk8lw8eJFAdORlJw4cQJdunRBVlYWAEBDQwPr1q3DV199JXAykqLLly+jdu3aUFNTw+XLlz+4bZ06dcoolXSxwJAKW1tbfPPNNwgMDBQ6Cklc8+bNUaVKFaxYsQI6OjqYMmUKdu/ejUePHgkdjSRITU0NKSkpMDMzg5qaGmQyGYr7FcoxMGWDBYZUyOVyxMTEwMHBQegoJHFGRkYIDw+Hq6srACAzMxNyuRyPHz+GiYmJwOlIau7fvw8bGxvIZDLcv3//g9va2tqWUSrpYoEhFX5+fmjYsCGGDx8udBSSuHf/4i1iYGCA2NhYFmwiiePNHEmFo6Mjpk6dinPnzhU7vwHvQk1l6ciRIzA0NFQ8LigoQFhYGK5evapY9vnnnwsRjSRs48aNH1zv4+NTRkmki0dgSIW9vf1718lkMty9e7cM05CUFc218SEcb0BCMDY2Vnqcm5uLzMxMaGlpQU9PDy9evBAomXTwCAyp4ERgVF78/RJ+ovKiuBmfb9++jREjRmDixIkCJJIeHoEhIiIqIVFRUfjqq69w8+ZNoaNUeDwCQwCA8ePHY/bs2ahUqRLGjx//wW1//vnnMkpFUnbu3Dk0adLko7bNzMxEQkICJ7kjwWloaPAy/zLCAkMAgEuXLiE3N1fx7/f5+30/iErLwIED4eDgAH9/f3Tq1AmVKlVS2eb69ev47bffsH79evz4448sMFRm9u7dq/S4sLAQycnJWLp0KZo1ayZQKmnhKSQiKpdyc3OxYsUKLFu2DHfv3kWNGjVgZWUFHR0dvHz5Ejdv3kRGRgZ69OiB77//Hm5ubkJHJgn5+wBzmUwGU1NTtGnTBgsXLoSlpaVAyaSDBYaIyr2oqCicOXMG9+/fR1ZWFqpUqQJ3d3e0bt0alStXFjoeEQmABYZUtG7d+oOnio4fP16GaYiIiFRxDAypqFevntLj3NxcxMTE4OrVqxg0aJAwoYiIypH3Xewgk8mgo6MDR0dHdOvWjUcISxGPwNBHmzFjBjIyMvDTTz8JHYWISFCtW7fGxYsXkZ+fD2dnZwDArVu3oK6ujpo1ayIuLg4ymQxnzpxR3MuLShYLDH20O3fuoFGjRpxhkogkLzg4GH/99RfWr18PuVwOAEhLS4O/vz+aN2+OoUOHon///sjKysKRI0cETlsxscDQR9u0aRMCAwM5xwERSV7VqlURGhqqcnTl2rVraN++PR4+fIiLFy+iffv2ePbsmUApKzaOgSEVPXv2VHpcNL9BVFQUpk6dKlAqIqLyIy0tDU+ePFEpME+fPkV6ejoAwMjICDk5OULEkwQWGFLx7p1/gbfzHTg7O2PWrFlo3769QKlI6sLCwhAWFoYnT56o3CNp3bp1AqUiqerWrRt8fX2xcOFCNGzYEABw4cIFTJgwAd27dwcAREZGokaNGgKmrNh4ComIyr2ZM2di1qxZaNCgASwtLVUu89+9e7dAyUiqMjIyMG7cOGzcuBF5eXkA3t5GYNCgQVi0aBEqVaqEmJgYAKpXdlLJYIEhonLP0tISQUFBGDhwoNBRiJRkZGTg7t27AAAHBwfo6+sLnEg6WGBIhbGxcbET2b07v8HgwYMxZMgQAdKRFJmYmCAyMhLVq1cXOgoRlRMcA0Mqpk2bhrlz56Jjx45o1KgRgLfncg8fPoyRI0ciISEBI0aMQF5eHoYOHSpwWpICf39/bNmyhYPIqdx4/fo1FixY8N5xWUVHZaj0sMCQijNnzmDOnDkYPny40vJVq1bh6NGj+OOPP1CnTh0sWbKEBYbKxJs3b/Drr7/i2LFjqFOnDjQ1NZXW//zzzwIlI6ny9/fHqVOnMHDgwGLHZVHp4ykkUqGvr4+YmBg4OjoqLb9z5w7q1auHjIwMxMfHo06dOnj9+rVAKUlKWrdu/d51MpmM9+eiMmdkZIQDBw6gWbNmQkeRLB6BIRWVK1fGvn37MG7cOKXl+/btU9zX4/Xr1zAwMBAiHknQiRMnhI5ApMTY2Jj3ORIYCwypmDp1KkaMGIETJ04oxsBcuHABBw8exMqVKwEAoaGh+Oyzz4SMSUQkmNmzZ2PatGnYsGED9PT0hI4jSTyFRMU6e/Ysli5diri4OACAs7MzRo0ahaZNmwqcjKSiZ8+eCAkJgVwuV5kd+u927dpVRqmI3nJ3d0d8fDwKCwthZ2enMi7r4sWLAiWTDh6BoWI1a9aM53ZJUIaGhoqBkX+fHZpIaEWz7ZJweASGilVQUIA7d+4Ue3lgy5YtBUpFRET0Fo/AkIpz586hf//+uH//Pv7eb2UyGfLz8wVKRkRUfqSmpmLnzp2Ij4/HxIkTUblyZVy8eBHm5uaoWrWq0PEqPB6BIRX16tVDjRo1MHPmzGLnN+DhfCpr9vb2H5xng5OGUVm7fPkyvLy8YGhoiHv37iEuLg4ODg6YMmUKEhMTsXHjRqEjVng8AkMqbt++jZ07d6rMA0MklLFjxyo9zs3NxaVLl3D48GFMnDhRmFAkaePHj8fgwYMRFBSkNKVEp06d0L9/fwGTSQcLDKlo3Lgx7ty5wwJD5caYMWOKXb5s2TJERUWVcRqit1NLrFq1SmV51apVkZKSIkAi6WGBIRWjRo3Ct99+i5SUFLi5ualcHlinTh2BkhEp69ixIyZPnoz169cLHYUkRltbG+np6SrLb926BVNTUwESSQ/HwJAKNTU1lWUymQyFhYUcxEvlSlBQEJYvX4579+4JHYUkxt/fH8+fP8f27dtRuXJlXL58Gerq6ujevTtatmyJ4OBgoSNWeCwwpOL+/fsfXG9ra1tGSYjecnd3VxrEW1hYiJSUFDx9+hTLly/HsGHDBExHUpSWloYvvvgCUVFRePXqFaysrJCSkgJPT08cPHgQlSpVEjpihccCQ0Tl3syZM5Ueq6mpwdTUFK1atULNmjUFSkUEnDlzBpcvX0ZGRgY8PDzg5eUldCTJYIGhYm3atAkrV65EQkICIiIiYGtri+DgYNjb26Nbt25CxyMiIolTHexAkrdixQqMHz8enTp1QmpqqmLMi5GREc/rkiDS09OL/Xr16hVycnKEjkcSFRYWhi5duqB69eqoXr06unTpgmPHjgkdSzJYYEjFL7/8gtWrV+OHH36Aurq6YnmDBg1w5coVAZORVBkZGcHY2Fjly8jICLq6urC1tcX06dNVbntBVFqWL1+ODh06wMDAAGPGjMGYMWMgl8vRqVMnLFu2TOh4ksDLqElFQkIC3N3dVZZra2vj9evXAiQiqQsJCcEPP/yAwYMHo1GjRgCAyMhIbNiwAVOmTMHTp0/x008/QVtbG99//73AaUkK5s2bh0WLFiEgIECxbPTo0WjWrBnmzZuHkSNHCphOGlhgSIW9vT1iYmJUrjY6fPgwXFxcBEpFUrZhwwYsXLgQffr0USzr2rUr3NzcsGrVKoSFhcHGxgZz585lgaEykZqaig4dOqgsb9++PQIDAwVIJD08hUQqxo8fj5EjR2Lbtm0oLCxEZGQk5s6di8mTJ2PSpElCxyMJCg8PL/aooLu7OyIiIgAAzZs3R2JiYllHI4n6/PPPsXv3bpXlf/75J7p06SJAIunhERhS4e/vD11dXUyZMgWZmZno378/rKyssHjxYvTt21foeCRB1tbWWLt2LRYsWKC0fO3atbC2tgYAPH/+HMbGxkLEIwlydXXF3LlzcfLkSXh6egIAzp07h7Nnz+Lbb7/FkiVLFNuOHj1aqJgVGi+jJhXZ2dnIy8tDpUqVkJmZiYyMDJiZmQkdiyRs79696N27N2rWrImGDRsCAKKionDz5k3s3LkTXbp0wYoVK3D79m38/PPPAqclKbC3t/+o7WQyGe+WXkpYYEjh6dOn8PHxwbFjx1BQUICGDRti8+bNqF69utDRiJCQkIBVq1bh1q1bAABnZ2d8/fXXsLOzEzYYEQmCBYYUfH19cejQIYwePRo6OjpYtWoVLC0tceLECaGjERERKWGBIQVra2usWbMG3t7eAIDbt2/DxcUFr1+/hra2tsDpSOpSU1MRGRmJJ0+eqMz34uPjI1AqIhIKCwwpqKur4+HDh7CwsFAsq1SpEq5du8bD9CSoffv2YcCAAcjIyIBcLle6saNMJsOLFy8ETEdEQuBl1KTk3Zl3ix6z45LQvv32W/j6+iIjIwOpqal4+fKl4ovlhUiaeASGFNTU1GBoaKj0121qairkcjnU1P6v6/IXBpW1SpUq4cqVK3BwcBA6ChGVE5wHhhTWr18vdASiYnl7eyMqKooFhsqV1NRUrF27Fjdu3AAA1KpVC76+vjA0NBQ4mTTwCAwRlXtr167FrFmzMGTIELi5uUFTU1Np/eeffy5QMpKqqKgoeHt7Q1dXV3F/rgsXLiArKwtHjx6Fh4eHwAkrPhYYIir33j2F+XcymQz5+fllmIYIaNGiBRwdHbF69WpoaLw9mZGXlwd/f3/cvXsXp0+fFjhhxccCQ0RE9Il0dXVx6dIl1KxZU2n59evX0aBBA2RmZgqUTDp4FRIRicqbN2+EjkAEuVxe7M1Dk5KSYGBgIEAi6WGBIaJyLz8/H7Nnz0bVqlWhr6+vuLfM1KlTsXbtWoHTkRR9+eWX8PPzw7Zt25CUlISkpCRs3boV/v7+6Nevn9DxJIEFht4rJycHcXFxyMvLEzoKSdzcuXMREhKCoKAgaGlpKZbXrl0ba9asETAZSdVPP/2Enj17wsfHB3Z2drCzs8PgwYPxxRdf4McffxQ6niRwDAypyMzMxKhRo7BhwwYAwK1bt+Dg4IBRo0ahatWq+O677wROSFLj6OiIVatWoW3btjAwMEBsbCwcHBxw8+ZNeHp64uXLl0JHJInKzMxEfHw8AKB69erQ09MTOJF08AgMqZg8eTJiY2Nx8uRJ6OjoKJZ7eXlh27ZtAiYjqXr48CEcHR1VlhcUFCA3N1eARERv6enpwdjYGMbGxiwvZYwFhlTs2bMHS5cuRfPmzZVm5a1Vq5biLw2isuTq6oq//vpLZfnOnTvh7u4uQCKSuoKCAsyaNQuGhoawtbWFra0tjIyMMHv2bJWbjVLp4Ey8pOLp06cwMzNTWf769WulQkNUVqZNm4ZBgwbh4cOHKCgowK5duxAXF4eNGzdi//79QscjCfrhhx+wdu1aLFiwAM2aNQMAnDlzBjNmzMCbN28wd+5cgRNWfBwDQypatmyJ3r17Y9SoUTAwMMDly5dhb2+PUaNG4fbt2zh8+LDQEUmC/vrrL8yaNQuxsbHIyMiAh4cHpk2bhvbt2wsdjSTIysoKK1euVJkF+s8//8Q333yDhw8fCpRMOngEhlTMmzcPHTt2xPXr15GXl4fFixfj+vXrCA8Px6lTp4SORxLVokULhIaGCh2DCMDbm9r+fRI7AKhZsyZveFtGOAaGVDRv3hwxMTHIy8uDm5sbjh49CjMzM0RERKB+/fpCxyMJi4qKwqZNm7Bp0yZER0cLHYckrG7duli6dKnK8qVLl6Ju3boCJJIenkIionLvwYMH6NevH86ePQsjIyMAb+8E3LRpU2zduhXVqlUTNiBJzqlTp9C5c2fY2NjA09MTABAREYGkpCQcPHgQLVq0EDhhxccjMAQASE9PV/r3h76Iypq/vz9yc3Nx48YNvHjxAi9evMCNGzdQUFAAf39/oeORBH322We4desWevTogdTUVKSmpqJnz56Ii4tjeSkjPAJDAAB1dXUkJyfDzMwMampqxV5tVFhYyDv/kiB0dXURHh6ucsl0dHQ0WrRowRvnUZlLTEyEtbV1sT8rExMTYWNjI0AqaeEgXgIAHD9+HJUrVwYAnDhxQuA0RMqsra2LnbAuPz8fVlZWAiQiqbO3t1f80feu58+fw97enn/olQEWGALw9nBocf8mKg/+97//YdSoUVi2bBkaNGgA4O2A3jFjxuCnn34SOB1JUdER6b/LyMhQmsGcSg9PIREA4PLlyx+9bZ06dUoxCZEqY2NjZGZmIi8vDxoab//uKvp3pUqVlLblJaxUmsaPHw8AWLx4MYYOHap0+4D8/HycP38e6urqOHv2rFARJYNHYAgAUK9ePchkMvxTn+UYGBJCcHCw0BGIAACXLl0C8PYIzJUrV5Tujq6lpYW6detiwoQJQsWTFB6BIQDA/fv3P3pbW1vbUkxCRFT+DRkyBIsXL4ZcLhc6imSxwBAREZHocB4YKtamTZvQrFkzWFlZKY7OBAcH488//xQ4GRGR8F6/fo2pU6eiadOmcHR0hIODg9IXlT6OgSEVK1aswLRp0zB27FjMnTtXMebFyMgIwcHB6Natm8AJiYiE5e/vj1OnTmHgwIGwtLQs9ookKl08hUQqXF1dMW/ePHTv3h0GBgaIjY2Fg4MDrl69ilatWuHZs2dCRyQiEpSRkREOHDiAZs2aCR1FsngKiVQkJCSozHgKANra2nj9+rUAiYj+T1JSEpKSkoSOQRJnbGysmPyThMECQyrs7e0RExOjsvzw4cNwcXEp+0AkeXl5eZg6dSoMDQ1hZ2cHOzs7GBoaYsqUKcXO0EtU2mbPno1p06bxNhYC4hgYUjF+/HiMHDkSb968QWFhISIjI/H7779j/vz5WLNmjdDxSIJGjRqFXbt2ISgoSOnOvzNmzMDz58+xYsUKgROS1CxcuBDx8fEwNzeHnZ0dNDU1ldZfvHhRoGTSwTEwVKzNmzdjxowZiI+PBwBYWVlh5syZ8PPzEzgZSZGhoSG2bt2Kjh07Ki0/ePAg+vXrh7S0NIGSkVTNnDnzg+unT59eRkmkiwWGPigzMxMZGRkqNywjKktmZmY4deqUyinMGzduoGXLlnj69KlAyYhIKBwDQx+kp6fH8kKCCwgIwOzZs5Gdna1Ylp2djblz5yIgIEDAZCRlqampWLNmDSZPnqy4B9fFixfx8OFDgZNJA4/AEADA3d39o+cx4LldKms9evRAWFgYtLW1UbduXQBAbGwscnJy0LZtW6Vtd+3aJUREkpjLly/Dy8sLhoaGuHfvHuLi4uDg4IApU6YgMTERGzduFDpihcdBvAQA6N69u+Lfb968wfLly+Hq6qoYMHnu3Dlcu3YN33zzjUAJScqMjIzQq1cvpWXW1tYCpSF6e7HD4MGDERQUBAMDA8XyTp06oX///gImkw4egSEV/v7+sLS0xOzZs5WWT58+HUlJSVi3bp1AyYiIygdDQ0NcvHgR1atXV5rw8/79+3B2dsabN2+EjljhcQwMqdixYwd8fHxUln/11Vf4448/BEhERFS+aGtrIz09XWX5rVu3YGpqKkAi6eEpJFKhq6uLs2fPwsnJSWn52bNnoaOjI1AqkrqdO3di+/btSExMRE5OjtI6jsuisvb5559j1qxZ2L59OwBAJpMhMTERgYGBKqc7qXTwCAypGDt2LEaMGIHRo0fjt99+w2+//YZRo0Zh5MiRGDdunNDxSIKWLFmCIUOGwNzcHJcuXUKjRo1gYmKCu3fvqswNQ1QWFi5cqJhiIisrC5999hkcHR1hYGCAuXPnCh1PEjgGhoq1fft2LF68GDdu3AAAuLi4YMyYMejTp4/AyUiKatasienTp6Nfv35K4w2mTZuGFy9eYOnSpUJHJIk6c+YMLl++jIyMDHh4eMDLy0voSJLBAkOf5OrVq6hdu7bQMUhi9PT0cOPGDdja2sLMzAyhoaGoW7cubt++jSZNmuD58+dCRySiMsYxMPSPXr16hd9//x1r1qxBdHQ08vPzhY5EEmNhYYEXL17A1tYWNjY2OHfuHOrWrYuEhATwbzAqS1lZWQgLC0OXLl0AAJMnT1aaYFFdXR2zZ8/meMEywAJD73X69GmsWbMGu3btgpWVFXr27Illy5YJHYskqE2bNti7dy/c3d0xZMgQjBs3Djt37kRUVBR69uwpdDySkA0bNuDAgQOKArN06VLUqlULurq6AICbN2/CysqK4wXLAE8hkZKUlBSEhIRg7dq1SE9PR58+fbBy5UrExsbC1dVV6HgkUQUFBSgoKICGxtu/ubZu3Yrw8HA4OTnh66+/hpaWlsAJSSpatGiBSZMmoWvXrgCgNCYLAH777TcsW7YMERERQsaUBBYYUujatStOnz6Nzp07Y8CAAejQoQPU1dWhqanJAkOCycvLw7x58+Dr64tq1aoJHYckztLSEhEREbCzswMAmJqa4sKFC4rHt27dQsOGDXmH9DLAy6hJ4dChQ/Dz88PMmTPRuXNnqKurCx2JCBoaGggKCkJeXp7QUYiQmpqqNObl6dOnivICvD1a+O56Kj0sMKRw5swZvHr1CvXr10fjxo2xdOlSPHv2TOhYRGjbti1OnToldAwiVKtWDVevXn3v+suXL/NIYRnhKSRS8fr1a2zbtg3r1q1DZGQk8vPz8fPPP8PX11fppmVEZWXlypWYOXMmBgwYgPr166NSpUpK6z///HOBkpHUjBkzBseOHUN0dLTKlUZZWVlo0KABvLy8sHjxYoESSgcLDH1QXFwc1q5di02bNiE1NRXt2rXD3r17hY5FEqOm9v6DxTKZjJf2U5l5/Pgx6tWrBy0tLQQEBKBGjRoA3v6sXLp0KfLy8nDp0iWYm5sLnLTiY4Ghj5Kfn499+/Zh3bp1LDBEJGkJCQkYMWIEQkNDFfMQyWQytGvXDsuXL1dckUSliwWGiMq9jRs34ssvv4S2trbS8pycHGzdurXYu6cTlbYXL17gzp07AABHR0dUrlxZ4ETSwgJDROWeuro6kpOTYWZmprT8+fPnMDMz4ykkIgniVUhEVO4VFhZCJpOpLH/w4AEMDQ0FSEREQuOtBIio3HJ3d4dMJoNMJkPbtm0VM/ECb8dlJSQkoEOHDgImJCKhsMAQUbnVvXt3AEBMTAy8vb2hr6+vWKelpQU7Ozv06tVLoHREJCSOgSGicm/Dhg3o27evyiBeIpIuFhgiKveSkpIgk8kUM5xGRkZiy5YtcHV1xbBhwwROR0RC4CBeIir3+vfvjxMnTgB4e8d0Ly8vREZG4ocffsCsWbMETkdEQmCBIaJy7+rVq2jUqBEAYPv27XBzc0N4eDg2b96MkJAQYcMRkSBYYIio3MvNzVWMfzl27Jji3kc1a9ZEcnKykNGISCAsMERU7tWqVQsrV67EX3/9hdDQUMWl048ePYKJiYnA6YhICCwwRFTu/fjjj1i1ahVatWqFfv36oW7dugCAvXv3Kk4tEZG08CokIhKF/Px8pKenw9jYWLHs3r170NPTU7nFABFVfCwwREREJDo8hURE5d7jx48xcOBAWFlZQUNDA+rq6kpfRCQ9vJUAEZV7gwcPRmJiIqZOnQpLS8tib+xIRNLCU0hEVO4ZGBjgr7/+Qr169YSOQkTlBE8hEVG5Z21tDf6tRUTvYoEhonIvODgY3333He7duyd0FCIqJ3gKiYjKPWNjY2RmZiIvLw96enrQ1NRUWv/ixQuBkhGRUDiIl4jKveDgYKEjEFE5wyMwREREJDo8AkNE5VJ6ejrkcrni3x9StB0RSQePwBBRuaSuro7k5GSYmZlBTU2t2LlfCgsLIZPJkJ+fL0BCIhISj8AQUbl0/PhxVK5cGQBw4sQJgdMQUXnDIzBEREQkOjwCQ0SikJqaisjISDx58gQFBQVK63x8fARKRURC4REYIir39u3bhwEDBiAjIwNyuVxpPIxMJuM8MEQSxAJDROVejRo10KlTJ8ybNw96enpCxyGicoAFhojKvUqVKuHKlStwcHAQOgoRlRO8FxIRlXve3t6IiooSOgYRlSMcxEtE5dLevXsV/+7cuTMmTpyI69evw83NTeVeSJ9//nlZxyMigfEUEhGVS2pqH3eAmBPZEUkTCwwRERGJDsfAEBERkeiwwBBRuXX8+HG4uroWezPHtLQ01KpVC6dPnxYgGREJjQWGiMqt4OBgDB06tNi7TRsaGuLrr7/GokWLBEhGREJjgSGicis2NhYdOnR47/r27dsjOjq6DBMRUXnBAkNE5dbjx49VLpl+l4aGBp4+fVqGiYiovGCBIaJyq2rVqrh69ep711++fBmWlpZlmIiIygsWGCIqtzp16oSpU6fizZs3KuuysrIwffp0dOnSRYBkRCQ0zgNDROXW48eP4eHhAXV1dQQEBMDZ2RkAcPPmTSxbtgz5+fm4ePEizM3NBU5KRGWNBYaIyrX79+9jxIgROHLkCIp+XMlkMnh7e2PZsmWwt7cXOCERCYEFhohE4eXLl7hz5w4KCwvh5OQEY2NjoSMRkYBYYIiIiEh0OIiXiIiIRIcFhoiIiESHBYaIiIhEhwWGiIiIRIcFhogqnMGDB6N79+5CxyCiUsSrkIiowklLS0NhYSGMjIyEjkJEpYQFhoiIiESHp5CIqFTs3LkTbm5u0NXVhYmJCby8vPD69WvF6Z2ZM2fC1NQUcrkcw4cPR05OjuK5BQUFmD9/Puzt7aGrq4u6deti586dSvu/du0aunTpArlcDgMDA7Ro0QLx8fEAVE8h/dP+Xr58iQEDBsDU1BS6urpwcnLC+vXrS/cbRET/iYbQAYio4klOTka/fv0QFBSEHj164NWrV/jrr78UtwIICwuDjo4OTp48iXv37mHIkCEwMTHB3LlzAQDz58/Hb7/9hpUrV8LJyQmnT5/GV199BVNTU3z22Wd4+PAhWrZsiVatWuH48eOQy+U4e/Ys8vLyis3zT/ubOnUqrl+/jkOHDqFKlSq4c+cOsrKyyuz7RUSfjqeQiKjEXbx4EfXr18e9e/dga2urtG7w4MHYt28fkpKSoKenBwBYuXIlJk6ciLS0NOTm5qJy5co4duwYPD09Fc/z9/dHZmYmtmzZgu+//x5bt25FXFwcNDU1VV5/8ODBSE1NxZ49e5Cdnf2P+/v8889RpUoVrFu3rpS+I0RU0ngEhohKXN26ddG2bVu4ubnB29sb7du3xxdffKG4f1HdunUV5QUAPD09kZGRgaSkJGRkZCAzMxPt2rVT2mdOTg7c3d0BADExMWjRokWx5eXv7ty584/7GzFiBHr16oWLFy+iffv26N69O5o2bfqfvgdEVLpYYIioxKmrqyM0NBTh4eE4evQofvnlF/zwww84f/78Pz43IyMDAHDgwAFUrVpVaZ22tjYAQFdX96OzfMz+OnbsiPv37+PgwYMIDQ1F27ZtMXLkSPz0008f/TpEVLZYYIioVMhkMjRr1gzNmjXDtGnTYGtri927dwMAYmNjkZWVpSgi586dg76+PqytrVG5cmVoa2sjMTERn332WbH7rlOnDjZs2IDc3Nx/PArj6ur6j/sDAFNTUwwaNAiDBg1CixYtMHHiRBYYonKMBYaIStz58+cRFhaG9u3bw8zMDOfPn8fTp0/h4uKCy5cvIycnB35+fpgyZQru3buH6dOnIyAgAGpqajAwMMCECf+vnftVUS0IADD+gSBYjoKYTSJiEQWL/4LJN9DkC1iENRyDCAaTIPgS2s0GQfApxFfQdorcdtmFC3dZdu9l4Pv1GYZJHzPMvDGdTnm9XrTbbR6PB5fLhSiKGI/HTCYTdrsdw+GQOI7JZrNcr1eazSblcvnDWj4z32KxoNFoUK1WSZKE4/FIpVL5T7sn6TMMGEnfLooizucz2+2W5/NJsVhks9kwGAw4HA70+31KpRLdbpckSRiNRiyXy9/jV6sVhUKB9XrN7XYjl8tRr9eZz+cA5PN5TqcTs9mMXq9HKpWiVqvRarX+uJ6/zZdOp4njmPv9TiaTodPpsN/vf3yfJH2dr5Ak/VPvXwhJ0lf5kZ0kSQqOASNJkoLjFZIkSQqOJzCSJCk4BowkSQqOASNJkoJjwEiSpOAYMJIkKTgGjCRJCo4BI0mSgmPASJKk4PwCwpjOvTnIzUoAAAAASUVORK5CYII=", + "image/png": "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GGzevLncHZKePHky0tLSFF9JSUnv3bZojMO7d0QmqmiKPt8c00NEFdEnHYGJjo7GkydPlO4fk5+fj9OnT2Pp0qU4cuQIcnJykJqaqnQU5vHjx7CwsAAAWFhYIDIyUmm/RVcpvbvN369cevz4MeRy+XsnVdPW1oa2tvZHvQ91dXUYGRkp7sejp6fHycuowigsLERmZiaePHkCIyMjqKurCx2JiKjEfVKBadu2rcoU7UOGDEHNmjURGBgIa2traGpqIiwsTHGH4bi4OCQmJipuGujp6Ym5c+fiyZMnivEUoaGhkMvliinvPT09cfDgQaXXCQ0NVbnx4H9RVJbevakgUUViZGSk+JwTEVU0n1RgDAwMULt2baVllSpVgomJiWK5n58fxo8fj8qVK0Mul2PUqFHw9PREkyZNAADt27eHq6srBg4ciKCgIKSkpGDKlCkYOXKk4gjK8OHDsXTpUkyaNAm+vr44fvw4tm/fjgMHDpTEewbw9oaClpaWMDMz443/qMLR1NTkkRciqtBK/FYCixYtgpqaGnr16oXs7Gx4e3tj+fLlivXq6urYv38/RowYAU9PT1SqVAmDBg3CrFmzFNvY29vjwIEDGDduHBYvXoxq1aphzZo18Pb2Lum4UFdX5w96IiIikZHkrQSIiIjEgPPAlNKtBIiIiIiEwAJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREolPiN3MkIhKrkr7vTHm/5wyRmPEIDBEREYkOj8CQIKR4h1UiIio5PAJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RE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", "text/plain": [ "
" ] @@ -730,7 +720,7 @@ "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAyUAAAGFCAYAAADjF1xYAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlHJYcgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAaqdJREFUeJzt3XdYU2f/BvA7CXvvoaKIIOIAcY/WhQquVl9brVqr1bpn3f5srVZttY5W62hrrVrrfm2rtdZdfRUnKrgQEcEJIlN2IMnvD2pqZAgynoTcn+vi0uScPOc+IUC+ecaRqFQqFYiIiIiIiASRig5ARERERET6jUUJEREREREJxaKEiIiIiIiEYlFCRERERERCsSghIiIiIiKhWJQQEREREZFQLEqIiIiIiEgoFiVERERERCQUixIiIiIiIhKKRQkREREREQnFooSIiIiIiIRiUUJEREREREKxKCEiIiIiIqFYlBARERERkVAsSoiIiIiISCgWJUREREREJBSLEiIiIiIiEopFCRERERERCcWihIiIiIiIhGJRQkREREREQrEoISIiIiIioViUEBERERGRUCxKiIiIiIhIKBYlREREREQkFIsSIiIiIiISikUJEREREREJxaKEiIiIiIiEYlFCRERCzZs3D40bNy7x/jExMZBIJAgNDQUAnDhxAhKJBCkpKRWST9t06NABkydPLnM7iYmJcHJyQkxMTJnb0iUSiQS///47gIKvpcrwOsd8+Xvu7u6Ob775plxztWrVCnv27CnXNolKg0UJERGVq7Nnz0Imk6FHjx6Vcrw2bdogNjYW1tbWr93Gpk2bIJFIIJFIIJVKUaNGDXz44YeIj48vx6Tl49dff8WCBQvK3M6iRYvw9ttvw93dHcC/b5aff9nb26Nr1664cuVKmY+lrdzc3BAbG4uGDRuKjlIqFy9exMiRI8u1zU8++QSzZs2CUqks13aJSopFCRERlasNGzZgwoQJ+N///ofHjx9X+PGMjIzg4uICiURSpnasrKwQGxuLhw8fYv369fjrr78wePDgckpZfuzs7GBpaVmmNjIzM7FhwwYMHz68wLajR48iNjYWhw4dQnp6Orp161Zle6FkMhlcXFxgYGAgOkqpODo6wszMrFzb7NatG9LS0vDXX3+Va7tEJcWihIiIyk16ejp27tyJMWPGoEePHti0aVOBfRYvXgxnZ2dYWlpi+PDhyM7OLrDPjz/+CB8fH5iYmKBevXpYu3ZtkccsbPjW6dOn8eabb8LU1BRubm6YOHEiMjIyis0ukUjg4uKCatWqoVu3bpg4cSKOHj2KrKysV2Z63svw66+/omPHjjAzM4Ofnx/Onj2rcYz169fDzc0NZmZm6NOnD1asWAEbGxv19qFDh6J3794aj5k8eTI6dOigvl3YUJ4vvvgCw4YNg6WlJWrWrIkffvih2HM9cOAAjI2N0apVqwLb7O3t4eLigmbNmmHZsmV48uQJzp8/j88//7zQHoXGjRvj008/BQDk5eVh4sSJsLGxgb29PWbOnIkhQ4ZonFNOTg4mTpwIJycnmJiY4I033sDFixfV25OTkzFo0CA4OjrC1NQUXl5e2Lhxo3r7w4cPMWDAANjZ2cHc3BzNmjXD+fPn1dv37t2LJk2awMTEBB4eHpg/fz7y8vIKfR5eHkr1qmO/7ODBg3jjjTfU59uzZ09ERUVp7HPhwgX4+/vDxMQEzZo1K7Tn6fr16+jWrRssLCzg7OyMwYMHIyEhocjjvjx8a8WKFWjUqBHMzc3h5uaGsWPHIj09XeMxr/qZkMlk6N69O3bs2FHkcYkqEosSIiIqN7t27UK9evXg7e2N999/Hz/99BNUKpXG9nnz5uGLL75ASEgIXF1dCxQcW7duxdy5c7Fo0SKEh4fjiy++wKefforNmzeXKENUVBSCgoLQt29fXL16FTt37sTp06cxfvz4Up2LqakplEol8vLySpxpzpw5mDZtGkJDQ1G3bl0MGDBA/YY4ODgYo0ePxqRJkxAaGoouXbpg0aJFpcpUlOXLl6vf8I4dOxZjxoxBREREkfufOnUKTZs2fWW7pqamAAC5XI5hw4YhPDxco4C4cuUKrl69ig8//BAAsGTJEmzduhUbN25EcHAwnj17pp6/8dyMGTOwZ88ebN68GZcvX4anpycCAwORlJQEAPj0009x8+ZN/PXXXwgPD8e6devg4OAAIL/obd++PR49eoR9+/YhLCwMM2bMUA85OnXqFD744ANMmjQJN2/exPfff49NmzaV+Hku7tiFycjIwJQpUxASEoJjx45BKpWiT58+6jzp6eno2bMn6tevj0uXLmHevHmYNm2aRhspKSno1KkT/P39ERISgoMHD+LJkyfo169fiTIDgFQqxapVq3Djxg1s3rwZx48fx4wZM9TbS/oz0aJFC5w6darExyUqVyoiIqJy0qZNG9U333yjUqlUqtzcXJWDg4Pq77//Vm9v3bq1auzYsRqPadmypcrPz099u06dOqpt27Zp7LNgwQJV69atVSqVShUdHa0CoLpy5YpKpVKp/v77bxUAVXJyskqlUqmGDx+uGjlypMbjT506pZJKpaqsrKxCc2/cuFFlbW2tvn379m1V3bp1Vc2aNStVph9//FG9/caNGyoAqvDwcJVKpVL1799f1aNHD402Bg0apHHcIUOGqN5++22NfSZNmqRq3769+nb79u1VkyZNUt+uVauW6v3331ffViqVKicnJ9W6desKPVeVSqV6++23VcOGDdO47+XnNTk5WdWnTx+VhYWFKi4uTqVSqVTdunVTjRkzRv2YCRMmqDp06KC+7ezsrFq6dKn6dl5enqpmzZrqc0pPT1cZGhqqtm7dqt5HLperqlWrpvrqq69UKpVK1atXL9WHH35YaO7vv/9eZWlpqUpMTCx0e0BAgOqLL77QuG/Lli0qV1dX9W0Aqt9++63Qcy7u2CXx9OlTFQDVtWvX1Hnt7e01Xnfr1q3TOOaCBQtUXbt21WjnwYMHKgCqiIgIlUpV+Pf866+/LjLH7t27Vfb29urbJf2Z2Lt3r0oqlaoUCkWpzpuoPLCnhIiIykVERAQuXLiAAQMGAAAMDAzQv39/bNiwQb1PeHg4WrZsqfG41q1bq/+fkZGBqKgoDB8+HBYWFuqvhQsXFhgWU5SwsDBs2rRJ4/GBgYFQKpWIjo4u8nGpqamwsLCAmZkZvL294ezsjK1bt5Yqk6+vr/r/rq6uAKCeLB8REYEWLVpo7P/y7df14nGfD0MrbpJ+VlYWTExMCt3Wpk0bWFhYwNbWFmFhYdi5cyecnZ0BACNGjMD27duRnZ0NuVyObdu2YdiwYQDyn78nT55onJNMJtPokYmKikJubi7atm2rvs/Q0BAtWrRAeHg4AGDMmDHYsWMHGjdujBkzZuDMmTPqfUNDQ+Hv7w87O7tCs4eFheHzzz/X+D6NGDECsbGxyMzMLPL5eK64YxcmMjISAwYMgIeHB6ysrNSLBty/fx9A/uvd19dX47l+8fX+PPPff/+tkblevXrq56skjh49ioCAAFSvXh2WlpYYPHgwEhMT1edc0p+J572DOTk5JTouUXnSrZldRESktTZs2IC8vDxUq1ZNfZ9KpYKxsTFWr15dotWxno+DX79+fYHiRSaTlShHeno6Ro0ahYkTJxbYVrNmzSIfZ2lpicuXL0MqlcLV1VU9dOnJkyclzmRoaKj+//OJ96VZzUgqlWoMdwOA3NzcVz7uxeM+P3Zxx3VwcEBycnKh23bu3In69evD3t5eY74LAPTq1QvGxsb47bffYGRkhNzcXLzzzjuvzFca3bp1w71793DgwAEcOXIEAQEBGDduHJYtW6b+nhQlPT0d8+fPx3/+858C24oqwkp67ML06tULtWrVwvr161GtWjUolUo0bNgQcrm8ZCf7T+ZevXphyZIlBbY9L2yLExMTg549e2LMmDFYtGgR7OzscPr0aQwfPhxyuRxmZmYl/plISkqCubn5K59noorAooSIiMosLy8PP//8M5YvX46uXbtqbOvduze2b9+O0aNHw8fHB+fPn8cHH3yg3n7u3Dn1/52dnVGtWjXcvXsXgwYNeq0sTZo0wc2bN+Hp6Vmqx0ml0kIfUx6ZAMDb21tjPgaAArcdHR1x/fp1jftCQ0MLFB1l5e/vj19++aXQbW5ubqhTp06h2wwMDDBkyBBs3LgRRkZGeO+999RvYK2treHs7IyLFy+iXbt2AACFQoHLly+rr0NTp04dGBkZITg4GLVq1QKQX3RdvHhRY/K+o6MjhgwZgiFDhuDNN9/E9OnTsWzZMvj6+uLHH39EUlJSob0lTZo0QURERKm/9y8q6tgvS0xMREREBNavX48333wTQP5k8hf5+Phgy5YtyM7OVhdFL77en2fes2cP3N3dX2sVsEuXLkGpVGL58uWQSvMHwOzatavAMUryM3H9+nX4+/uXOgNReWBRQkREZbZ//34kJydj+PDhBXpE+vbtiw0bNqgneQ8dOhTNmjVD27ZtsXXrVty4cQMeHh7q/efPn4+JEyfC2toaQUFByMnJQUhICJKTkzFlypRXZpk5cyZatWqF8ePH46OPPoK5uTlu3ryJI0eOYPXq1a91fmXNBAATJkxAu3btsGLFCvTq1QvHjx/HX3/9pbGUcadOnbB06VL8/PPPaN26NX755ZcKeaMYGBiI2bNnIzk5Gba2tqV67EcffQQfHx8A+ZP3XzRhwgR8+eWX8PT0RL169fDtt98iOTlZfY7m5uYYM2YMpk+fDjs7O9SsWRNfffUVMjMz1csTz507F02bNkWDBg2Qk5OD/fv3q483YMAAfPHFF+jduze+/PJLuLq64sqVK6hWrRpat26NuXPnomfPnqhZsybeeecdSKVShIWF4fr161i4cOErz624Y7/M1tYW9vb2+OGHH+Dq6or79+9j1qxZGvsMHDgQc+bMwYgRIzB79mzExMQUKHDGjRuH9evXY8CAAZgxYwbs7Oxw584d7NixAz/++OMrewg9PT2Rm5uLb7/9Fr169UJwcDC+++47jX1K+jNx6tSpAh8qEFUWzikhIqIy27BhAzp37lzoEK2+ffsiJCQEV69eRf/+/fHpp59ixowZaNq0Ke7du4cxY8Zo7P/RRx/hxx9/xMaNG9GoUSO0b98emzZtQu3atUuUxdfXFydPnsTt27fx5ptvwt/fH3PnztUYVlZaZc0EAG3btsV3332HFStWwM/PDwcPHsTHH3+sMawoMDBQ/fw0b94caWlpGr1K5aVRo0Zo0qRJgU/US8LLywtt2rRBvXr1CgxnmzlzJgYMGIAPPvgArVu3Vs9dePEcFy9ejL59+2Lw4MFo0qQJ7ty5g0OHDqmLIyMjI8yePRu+vr5o164dZDKZeplaIyMjHD58GE5OTujevTsaNWqExYsXq9+4BwYGYv/+/Th8+DCaN2+OVq1a4euvv1b3yrxKccd+mVQqxY4dO3Dp0iU0bNgQH3/8MZYuXaqxj4WFBf744w9cu3YN/v7+mDNnToFhWtWqVUNwcDAUCgW6du2KRo0aYfLkybCxsVH3fBTHz88PK1aswJIlS9CwYUNs3boVX375pcY+JfmZePToEc6cOaNeSY2osklULw9eJSIiokoxYsQI3Lp1S8gyrH/++SemT5+O69evl+jN73MqlQpeXl4YO3bsK3uJlEolfHx80K9fv3K5Cj1VnJkzZyI5OfmV17ghqigcvkVERFRJli1bhi5dusDc3Bx//fUXNm/eXOyFIStSjx49EBkZiUePHsHNza1Ej3n69Cl27NiBuLi4Qj9Rv3fvHg4fPoz27dsjJycHq1evRnR0NAYOHFje8amcOTk5lXgoIlFFYE8JERFRJenXrx9OnDiBtLQ0eHh4YMKECRg9erToWCUmkUjg4OCAlStXFlpoPHjwAO+99x6uX78OlUqFhg0bYvHixeqJ70RERWFRQkREREREQnGiOxERERERCcWihIiIiIiIhGJRQkREREREQnH1LSIiLZadq0BShhxJGXIkZ/7zb4YcSZm5SM2UI0+pglQigVQCSKWSf/8vkUDywv+lkvxJykYGUtibG8HR0hhOliZwtDSGg4URDGT8jIqIiMRhUUJEJEh2rgJ34tMRGZ+GyCfpeJSSpS4+kjNykZQhR1auosJzSCWArVl+ofJiseL0z+1qNqbwcraAlYlhhWchIiL9xNW3iIgqWJY8v/i4/SQNkfHpiPzn34fJmVDq0G9gV2sTeDlboq6TBeq6WMLb2RLeLpYwMZSJjkZERDqORQkRUTlKy87FhegkXIxJxu0nabj9JA2PUrJQVX/TGkgl8HSyQKPq1mhUwxoNq1ujvqsVCxUiIioVFiVERGWQJVcg5F4SzkQl4kxUIq4/SoVCl7o/KoCBVIKG1a3Rrq4j2td1QGM3W8ikEtGxiIhIi7EoISIqBXmeEpfvJ+NsVCLORiUi9EEK5Aql6FhazcrEAG09HdCuriPa1XVEdRtT0ZGIiEjLsCghInqFiLg0HA1/gjNRCbh0LxnZuSxCysLD0RztvBzRvq4jWnnYw9SIQ72IiPQdixIiokI8SMrEvrDH2Bf6GBFP0kTHqbKMDKRo7m6Ljt5OeKtxNThZmoiOREREArAoISL6R0J6Dv68Gou9oY9w+X6K6Dh6RyaVoJ2XA95p6obO9Z1gbMAeFCIifcGihIj0WnpOHg5ej8Pe0Ec4E5Wo95PUtYW1qSF6+bmib5Ma8K9pKzoOERFVMBYlRKR35HlKHL/1BHtDH+P4rXjk5HGOiDar42iOvk1r4D/+NeBizeFdRERVEYsSItIbKZly/HLuHjafvYenaTmi41ApSSVAW08HvNO0BgIbuPBaKEREVQiLEiKq8u4nZmLD6bvYfekhMuUK0XGoHNiaGWJIG3d82KY2rM0MRcchIqIyYlFCRFXWpXvJ+PHUXRy6EQdOFamazI1kGNSqFj56ozacrDi0i4hIV7EoIaIqRalU4fDNOPzwv7tcQUuPGBlI8U7TGhjdrg5q2puJjkNERKXEooSIqoQsuQK7Lz3AT6ejEZOYKToOCSKTStDT1xVjO3jC28VSdBwiIiohFiVEpNNy8hTYfCYG605EITkzV3Qc0hISCRBQzwljO3qiCZcUJiLSeixKiEgnKZUq/HblEVYcuY1HKVmi45AWa+1hjxlB3rzeCRGRFmNRQkQ65++IeCz56xZuxaWJjkI6QiIB/uNfAzO7ecPJkhPiiYi0DYsSItIZt+KeYcH+mwi+kyg6CukoS2MDTAjwxIdta8NQJhUdh4iI/sGihIi0XkqmHMsP38a2C/eh4Nq+VA48HM0xt2d9dPB2Eh2FiIjAooSItJhCqcIv5+7h66O3kcJJ7FQBAuo5YW6v+qhlby46ChGRXmNRQkRaKSQmCXN+u46IJ5w3QhXLyECK4W/UxoROnjAzMhAdh4hIL7EoISKtkpOnwPLDt/Hjqbu8CjtVKhcrE8zqVg+9/auLjkJEpHdYlBCR1rj+KBVTdoXi9pN00VFIjwXUc8KSd3zhYGEsOgoRkd5gUUJEwuUplFjzdxRW/x2JXAV/JZF4DhZG+OodX3Sq5yw6ChGRXmBRQkRC3YlPw5RdYbj6MFV0FKICBreqhTk9fGBiKBMdhYioSmNRQkRCKJUqbDgdjWWHI5CTpxQdh6hInk4WWPleYzSoZi06ChFRlcWihIgq3YOkTEzdHYYL0UmioxCViJFMiqld62LEmx6QSiWi4xARVTksSoioUm2/cB8L999EhlwhOgpRqbX2sMeK/n5wtTYVHYWIqEphUUJElUKep8Sc365h96WHoqMQlYm1qSEW9WmInr7VREchIqoyWJQQUYV7mpaD0b9cwqV7yaKjEJWbAS3c8PnbDWEok4qOQkSk81iUEFGFuvYwFSO3hCA2NVt0FKJy18rDDt+93xQ2ZkaioxAR6TQWJURUYfaGPsLMPVeRncvVtajqqu1gjp+GNkdtB3PRUYiIdBaLEiIqd0qlCksPR2DdiSjRUYgqhY2ZIdYNaorWdexFRyEi0kksSoioXKVl52LyjlAcuxUvOgpRpTKUSbCodyP0a+4mOgoRkc5hUUJE5SYmIQMf/RyCO/HpoqMQCTOqvQdmBdWDRMLrmRARlRSLEiIqF6cjEzBu22WkZuWKjkIkXGADZ3zT3x+mRjLRUYiIdAKLEiIqs7+uxWLijivIVfDXCdFzDatbYcOQ5nC2MhEdhYhI67EoIaIy2Rv6CFN3hSFPyV8lRC9zsTLBxg+bw8fVSnQUIiKtxqKEiF7bnksPMf2/YWA9QlQ0O3MjbBvREvVcWJgQERWFl6Elotey48J9FiREJZCUIceg9edx+0ma6ChERFqLRQkRldqWszGY/ds1FiREJZSYIcfA9ecQycKEiKhQHL5FRKWy4XQ0Fuy/KToGkU5ysDDGjpEt4elkKToKEZFWYVFCRCW27kQUlhy8JToGkU5ztDTG9hGt4OlkIToKEZHWYFFCRCWy8mgkvj56W3QMoirB0dIYO0a2Qh1HFiZERACLEiIqgeWHI/Dt8TuiYxBVKU7/FCYeLEyIiDjRnYiKtzE4mgUJUQWIT8vBgPXnEJ2QIToKEZFwLEqIqEhHbj7hpHaiCvTkWQ4G/HAO9xMzRUchIhKKRQkRFeraw1RM2nGFy/4SVbC4Z9kYuvECUjLloqMQEQnDooSICniUkoVhmy8iU64QHYVIL9xNyMCoLZcgz1OKjkJEJASLEiLSkJadi2EbL+JpWo7oKER65Xx0Emb9elV0DCIiIViUEJFankKJsVsvI4JXnSYS4tfLj7DqWKToGERElY5FCRGpzfntOk5FJoiOQaTXVhy5jb2hj0THICKqVCxKiAgAsObvO9gZ8kB0DCICMHPPVVx/lCo6BhFRpWFRQkTYF/YYyw5HiI5BRP/IzlVi5M8hSEjn3C4i0g+8ojuRngt9kIJ+35/lqj9VROq53Ug5uRmWTd+CXeeRGttUKhXid89DdvQlOPaZA7O6rQttQ6XIQ8qpLciKCkFeahykxuYwqeUHm/ZDYWBpn79PXi4SD65CZuQ5yMxtYdd1LEzdG/+b4/weKJ49hV2X0RV2rvqghbsdto5oCUMZP0MkoqqNv+WI9Fhadi4mbr/CgqSKyIm9jbTQgzB0dC90e1rIXkDy6nZUeTmQx0XBus17cB2yEo69/w+5SY/w9NcF/7YVdhDyuDtweX8ZLPyCkPDHUjz/jCs3JQ7pYYdg0+6D8jgtvXYhJgmf/8ELmBJR1ceihEiPffr7ddxP4pWkqwKlPAsJfyyDfdAESE0sCmyXP7mLZxd+g0O3ya9sS2psDuf3FsLc500Y2teAcfV6sOsyGvK4O8h7Fg8AyE18AFPPljByrAXLJj2gzEyFMusZACDp8FrYdhgKqbFZuZ6jvtpy7h52XrwvOgYRUYViUUKkp369/BC/hz4WHYPKSdKRdTCt01xjCNVzytxsJPyxFHZdx0BmYfta7StzMgFIIDXOL3iMnGoj5+FNKHNzkB19GTILO0hNrZB+429IDIxgVrdNGc6GXjZv301EJ2SIjkFEVGFYlBDpoXuJGZi794boGFROMm6ehDwuCrbthxS6PfnYjzCu7gMzr1av1b4qT46UExthVr+duvfDolEXGDrVxuMNY5F6dhcc3p4JZXY6Uk9vhV3nUUj+3xY8+n4Enuz8FHlpXGa6rLJyFZi8MxR5Cg61JKKqiUUJkZ7JVSgxcfsVpOfkiY5C5SDv2VMkHVsPh17TIDEwKrA9M/I8su+HwTZgxGu1r1Lk4enexQAA+67j1PdLZAaw7zoGNUZvgOuQr2FSowGSj2+AZdNekD+5i6zIs3D98FsYV6uH5KM/vN7JkYawBylY83eU6BhERBWCq28R6ZnFf93Cdyf5xqaqyLx9Fk9/WwRIXviMSaUEIAEkElj6d0fa5T8BiURzu0QK4xr14TJwcZFtPy9I8lLi4DzgC8hMrYrcN/veVSSf3AiX95ch+e+fIJHKYNtxGORP7+HJtllwm7S9HM6WDKQS/Dq2DXxr2IiOQkRUrgxEByCiyhN8JwHf/48FSVViUssPrsNWa9yXeGAlDO1rwKplX8hMrWHROEhje+xP42Hb6SOYerYosl11QZL8GM4Dviy2IFHlyZF0ZF1+b41UBqiU+XURACgVUKk45Ki85ClVmLwzFAcmvgkTQ5noOERE5YbDt4j0RFKGHB/vDAX7RqsWqbEZjBzdNb4khsaQmljCyNEdMgvbAtsBwMDKEYY2Lup2Hq0fjczbZwD8U5D8/iXkcXfg0GsaoFRCkZ4MRXoyVIrcAhlSzuyAqUczGDnXAQAYV6+PzNtnII+PRtrl/TCp7lPxT4Qeufs0A18eCBcdg4ioXLGnhEhPTN8dhvg0Xh2aCpeX9PCfFbYARXoisu6cBwDEbpyosZ/zgC9gUtNXfVv+NAaZt07Bdei36vvM6rVF9oNriNs6E4b21eHQa3olnIF++fncPQT4OKNdXUfRUYiIygXnlBDpgc1nYvDZPq62RVSVOFsZ49DkdrAxK7jAARGRruHwLaIqLjY1C0sO3hIdg4jK2ZNnOfjk9+uiYxARlQsWJURV3MI/w5EpV4iOQUQVYP/VWOwNfSQ6BhFRmbEoIarCgu8k4M+rsaJjEFEFmrv3BpIy5KJjEBGVCYsSoioqV6HE3L0c2kFU1aVm5WLFkQjRMYiIyoRFCVEV9dPpaEQ9zRAdg4gqwfYLDxARlyY6BhHRa2NRQlQFxaVmY9WxSNExiKiSKJQqLNh/U3QMIqLXxqKEqApa+OdNZHByO5FeOX0nAUdvPhEdg4jotbAoIapiztxJwH5ObifSS4sOhCNXoRQdg4io1FiUEFUhuQolL5JIpMeiEzKw+UyM6BhERKXGooSoCtkYHI3I+HTRMYhIoJXHIrlEMBHpHBYlRFVE/LNsrDp2R3QMIhIsLTsPyw9ziWAi0i0sSoiqiLUnopCekyc6BhFpgR0XH+BW3DPRMYiISoxFCVEV8DQtB9sv3Bcdg4i0BJcIJiJdw6KEqApYf+oucvK44g4R/Sv4TiL+jogXHYOIqERYlBDpuKQMOX45d090DCLSQutORImOQERUIixKiHTchtN3kckLJRJRIS5EJ+Hy/WTRMYiIXolFCZEOS83Kxc9n2EtCREX7/iR7S4hI+7EoIdJhm4JjkMYVt4ioGEduPsHdp7x+ERFpNxYlRDoqPScPG89Ei45BRFpOqQJ++N9d0TGIiIrFooRIR205ew8pmbmiYxCRDvj1yiPEp2WLjkFEVCQWJUQ6KEuuwIbT/OSTiEpGnqfET6djRMcgIioSixIiHbTtwn0kpMtFxyAiHbL1/D2kcw4aEWkpFiVEOiZPocSPp9hLQkSlk5adh23nuVofEWknFiVEOubYrXjEpnJsOBGV3k+nYyDPU4qOQURUAIsSIh2z/cJ90RGISEfFPcvG76GPRMcgIiqARQmRDnmYnIn/3X4qOgYR6bCt5/nBBhFpHxYlRDpk58UHUKpEpyAiXRb2IIUXUyQircOihEhHKJQq7Ap5IDoGEVUBv1/hEC4i0i4sSoh0RHrUWQyzvQZTmUJ0FCLScb+FPoJKxW5XItIeLEqIdIT15XUY9WQeblh9jD+8/kRXhyTRkYhIRz1IykLIvWTRMYiI1CQqflRCpP2ykoFldQGF5gUTMx18ccS4CxY/aoTYbCNB4YhIFw1sWRNf9GkkOgYREQAWJUS64eKPwJ9Ti9ysMjDFQ5dO+DnrTfz42A0qlaQSwxGRLrI2NcSFOQEwNpCJjkJExKKESCesDwAehZRo1zwrN1y0DsLS+Ga4nGpZwcGISJd9934TBDV0FR2DiIhFCZHWS4oGVjUu9cNUkCDVpTX2Sjph2YO6SMszKP9sRKTTAhs44/vBzUTHICLiRHcirRdx4LUeJoEKNnFnMCR2Ia6aT8Ahr9/Rxzm+nMMRkS77+9ZTpGTKX70jEVEFY1FCpO0i/ipzE5KcVHg/2IWvUyfjVrUF+M7zPDzMssshHBHpMrlCif1XY0XHICLi8C0irZaVAiytAyjzyr1plcwIT1w6YLu8HdY+qo1cJSfHE+mjZrVs8d8xbUTHICI9x6KESJtd+y+wZ3iFH0Zh7oJQuyB8ndgSp5OsK/x4RKQ9JBIgZE5n2FsYi45CRHqMw7eItNlrzicpLVlGHJo+2IRfMsfgWs0VWOJxFfZGuZVybCISS6UCgqMSRccgIj3HooRIWylygTtHK/2wlvEh6P94MUJMxuK4524MdH1c6RmIqHIFRyaIjkBEeo7Dt4i01d2TwM9viU4BAJDb1EGwZRC+ivVHeLqZ6DhEVM6q25gieFYn0TGISI+xp4RIW5XDqlvlxSglCh0frMEBxSiE1P4Bs2rdhqlMIToWEZWTRylZiE7IEB2DiPQYixIibXVbe4qS5yQqBRxiT2D0k3m4YfUx/vD6E10ckkTHIqJycPoOh3ARkTgcvkWkjRIigdW6c5XlDAc/HDHugiWPGiI220h0HCJ6DUENXPDd4KaiYxCRnjIQHYCICnH/rOgEpWKeEIbeCMPbBqZ46BmAn7PewI+P3aBS8donRLriTFQClEoVpFL+3BJR5ePwLSJt9OC86ASvRZKXBbeH+zEncRYiHWdhm9cJNLFOFx2LiErgWXYerj5KFR2DiPQUixIibfTggugEZWbw7AHaPPgBe+SjccV9DebVDoelQflfmZ6Iyk8w55UQkSCcU0KkbTKTgK88AFS9H02liQ1uOwZi3bPW2PvESXQcInpJKw877BjZWnQMItJDLEqItM3tQ8C2fqJTVLhs+/r427QLljzyQ0yWieg4RATAyECKsLldYWokEx2FiPQMh28RaZv750QnqBQmiTfR7eFK/C0djbN1NmFizbswlPIzEiKR5HlKXHmQLDoGEekhFiVE2qYKzCcpDYlCDtdHhzEl/hPcspuGPV5H0NaWk22JRLkVmyY6AhHpIRYlRNpEkQc8viw6hTCy9Fg0fbARW7PG4GrNr7HY4xrsjXJFxyLSKxFxLEqIqPLxOiVE2iTuKpCbKTqFVrCKv4j3cBH9TSxwt2ZX/JjRBttjq4mORVTl3XrCooSIKh97Soi0ycMQ0Qm0jkSejjoPf8WXydNw22UufvIKRj0LFm5EFSXySRq4Bg4RVTb2lBBpk6e3RCfQakYpd9Ap5Q46Sg2QUPtN7FZ2wLcPPZCl4EpBROUlU67A/aRM1LI3Fx2FiPQIe0qItEnCbdEJdIJEmQfH2L8x9slnuGH9MfZ5HUCAfZLoWERVxi3OKyGiSsaihEibJN4RnUDnSDMT4PvgF2zIGI8bNZbg6zqX4WIsFx2LSKdxsjsRVTYO3yLSFjlpQFqs6BQ6zTwhDH0Qht6Gpnjg1hk/Z72BDY9rQKWSiI5GpFNYlBBRZWNPCZG2SIgUnaDKkORloebDP/BJ4kxEOs7GNq8TaGyVLjoWkc64FfdMdAQi0jMsSoi0BYduVQiDZ/fR5sEP+C13NK64r8Fn7uEwN1CIjkWk1WISM5Gdy58TIqo8LEqItAUnuVcoiUoJ27hgfBi3ANcsJuCg11685RQvOhaRVlIoVbgTz95FIqo8LEqItAWHb1UaaXYK6j3YiVXPJiO8+iKs87wAd9Ns0bGItArnlRBRZeJEdyJtweFbQpgm3kA33ECQzAhxdTpim7wd1j6sBYWKn9mQfnuYnCU6AhHpEf7VJdIGKhWQGCU6hV6TKORwfXQIU5/OwW37Gfiv1xG0tk0VHYtImIT0HNERiEiPsCgh0gaZSUAeP5XUFrL0x2j2YCO2ZY3F1Vrf4AuPa7A1zBMdi6hSJWawKCGiysPhW0TaIDNBdAIqhAQqWD25gIG4gAGmFohyD8SP6W2wI9ZVdDSiCpeQxouQElHlYU8JkTbIYFGi7STydHg+2IPFyVNx22UufvIKRl1z9m5R1ZXAnhIiqkTsKSHSBuwp0SlGKXfQKeUOOkoNkODRDrsU7bHqQR3kKPk5D1UdCWksSoio8vAvKJE2yHgqOgG9BokyD46Pj2Pck88QbvMx9nr9hQD7JNGxiMrFs+w8yPOUomMQkZ5gUUKkDTISRSegMpJmPoXfgy3YkDEeN9y+woo6V+BizDH5pNs42Z2IKguHbxFpAw7fqlLMn4biPwhFHyMz3HfrjM1ZbbHxcQ2oVBLR0YhKJTFdDldrU9ExiEgPsKeESBtwonuVJMnNRK2H+zA3cSZuO/4ftnqdRGOrdNGxiErsKa9VQkSVhD0lRNqAPSVVnuGze2j77Hu0kUiR7N4Gv0k6YsWDusjIk4mORlSkxHQOQSSiysGihEgbcE6J3pColLCLO43hOI0PLWwR4RiINSmtsf+po+hoRAXwqu5EVFk4fItIG2Snik5AAkizk+HzYAdWp01CePVFWOd5ATVNs0XHIlJLymBPCRFVDvaUEGkDZZ7oBCSYaeINdMMNBMmMEVunI7bK2+G7hzWhUPGzIxInO1chOgIR6Qn+tSPSBir+4ad8EkUOqj06iOlP/w8RDjOx2+soWtuyJ43EyFOqREcgIj3BooRIGyhZlFBBBmmP0PzBT9iWNRZhtVbii9rXYGvIXjWqPAoFixIiqhwcvkWkDdhTQsWQQAXrJ+cxEOcxwMwSUU5d8UNaW+yKcxEdjao4hYpFCRFVDvaUEGkDpVJ0AtIRkpw0eD7Yg69SpuC262fY4HUGdc2zRMeiKkrB4VtEVEnYU0KkDdhTQq/BKDkSAcmR6CQ1wFOPdtiV1wHfPvRAjpKfN1H54JwSIqosLEqItAHnlFAZSJR5cHp8HONxHEMda+L9Wj5QgK8pKjsn59YA/EXHICI9wKKESBuoOHyLyodF6n04SOvifOpt0VGoCqjv4Ck6AhHpCfbxE2kDDt+ictQtV3QCqipkEpnoCESkJ1iUEGkD9pRQOeocfQkGUnaEU9nJpCxKiKhysCgh0gaGZqITUBVinZmMVlYcdkNlx54SIqosLEqItIGxlegEVMV0y+aQQCo7qYRvE4iocvC3DZE2MGFRQuWrU/RFGEmNRMcgHWdmwF5cIqocLEqItAF7SqicWWQ/Q1sO4aIysjGxER2BiPQEixIibcCeEqoA3bKyRUcgHWdrbCs6AhHpCRYlRNqAPSVUAdrfvQhTmYnoGKTD2FNCRJWFRQmRNmBPCVUAM3kG3rSqIzoG6TD2lBBRZWFRQqQN2FNCFaRberroCKTD2FNCRJWFRQmRNjCxFp2Aqqg3716EOVdQotfEnhIiqiwsSoi0AXtKqIIY52Wjg6WH6Bikg6QSKayN+YEJEVUOFiVE2sDcQXQCqsK6PUsVHYF0kJWRFS+eSESVhr9tiLSBrbvoBFSFtYm+CEtDC9ExSMfYGNuIjkBEeoRFCZE2YFFCFchQIUeARW3RMUjH2JpwPgkRVR4WJUTawNwBMOIn2VRxuqUkio5AOsbBlMNKiajysCgh0hY2tUQnoCqsRUwIbI04aZlKrpYVfycRUeVhUUKkLWz5BoAqjoEyD53Na4qOQTqktjWH/BFR5WFRQqQtOK+EKli3pHjREUiH1LZiUUJElYdFCZG24PAtqmBN712Co4md6BikI9hTQkSViUUJkbZgTwlVMKlKiS4m1UXHIB3gZOoECy6+QUSViEUJkbbgnBKqBN0SHouOQDqAvSREVNlYlBBpC1t3gFdPpgrm9yAULqaOomOQlnO3dhcdgYj0DN8BEWkLQ1PAzkN0CqriJFAh0NhFdAzScuwpIaLKxqKESJu4+IpOQHqgW/x90RFIy7EoIaLKxqKESJu4siihitfg0TW4mbG3hIrmYc1eWyKqXCxKiLQJe0qokgQacl4JFc7MwAzOZs6iYxCRnmFRQqRNXP1EJyA9ERR3V3QE0lKNHBpBIpGIjkFEeoZFCZE2MXcAbGqKTkF6wDsuHLXNec0SKqiJcxPREYhID7EoIdI21ZuJTkB6IkhmKzoCaSEWJUQkAosSIm1To7noBKQngmJvi45AWsZAYgBfB85tI6LKx6KESNvUYE8JVQ6P+Duoa8HhgvSvenb1YGZoJjoGEekhFiVE2sbVD5AZiU5BeiJIaik6AmkRDt0iIlFYlBBpGwNjoEYL0SlITwQ9vCk6AmkRFiVEJAqLEiJt5BkgOgHpCbfEe2hgxat3EyCBBE2cWJQQkRgsSoi0EYsSqkRBKlPREUgL1LauDVsTrshGRGKwKCHSRi6+gLmT6BSkJ4LuX4cEvFievvN38hcdgYj0GIsSIm0kkQB1OolOQXrCJeUh/Kw8RMcgwZo6NxUdgYj0GIsSIm3FIVxUiYIUXPFNn0klUrSp1kZ0DCLSYyxKiLRVnU4Ah9RQJel6PwxSCf8k6KvGjo1hb2ovOgYR6TH+BSLSVuYO+dcsIaoEjs/i0NSqjugYJEiXWl1ERyAiPceihEibcQgXVaKgXP5J0Feda3UWHYGI9Bz/AhFpM0++UaDK0yXmMgwkBqJjUCVrYN8ALuYuomMQkZ5jUUKkzdxaAhbOolOQnrDNSEQLaw7h0jfsJSEibcCPxIi0mVQGNOwLnFsrOgnpiaAcFc6IDvGCjIgMJBxIQNa9LOSl5KHmhJqwamql3p6Xmoe4XXFIv5EORaYC5nXN4fq+K4xdjIttN+FQApL+TkJuYi5kljJYN7OG8zvOkBrlf1aXciYFcf+NgzJbCds3beE6wFX9WPlTOWKWxaDOvDqQmcoq5sQrUeeaLEqISDz2lBBpu0bvik5AeiQg+iIMpYaiY6gpc5QwqWmCaoOrFdimUqlwb9U9yJ/KUXNiTXjO94ShgyFilsZAmaMsss2Usyl4svsJnN52gtcXXqg+rDpSL6TiyZ4nAIC8tDw82vgIrv1d4T7NHSlnUvAs9Jn68Y+3PIbzu85VoiCpY10H7tbuomMQEbEoIdJ61ZsA9l6iU5CesMpKRRsrT9Ex1Cx9LeHc11mjd+Q5+RM5sqKyUG1INZh5mMHY1RjVPqgGpVyJlHMpRbaZeScTZl5msGltAyNHI1g2tIR1S2tk3c3Kb/epHDJTGaxbWsPMwwzmPubIeZwDAEg5lwKJTALrZtYVcr6VjUO3iEhbsCgh0gW+/UQnID0SmCUXHaFEVLkqAIDE8N/r+UikEkgMJci8nVnk48w8zZAVk4XMu/n7yOPlSL+aDgtfCwCAsbMxlHJl/pCx9DxkRWfBxM0EigwF4n+Nh+v7rkW2rWtYlBCRtuCcEiJd0Ogd4O9FolOQnugUHQJjNxfkKHJERymWsasxDO0N8WT3E1QfWh0SYwkSDyUiLykPeal5RT7OprUNFOkKRC+KhgoqQAHYdbSDUy8nAIDMXIYaI2rg4fqHUMlVsGljA8tGlni44SHsAuyQm5CL+yvvQ6VQwam3E6yb62avSQ2LGqhnV090DCIiACxKiHSDnQdQoznw8KLoJKQHzHPS8KbVGziafEN0lGJJDCSoOaEmHm14hPBx4YAUsKhvkd/joSr6cenh6Xj6x1O4fuAKMw8zyOPliN0ai/i98XB6O78wsWpqpTFkLONWBnIe5qDa+9Vwe+ZtuI12g4G1AaI+j4K5tzkMrHTvz2lvz96iIxARqeneb1EifeXbn0UJVZrA9EwcFR2iBEzdTeG5wBOKTAVUeSoYWOUXCqbupkU+Jv63eNi0sYFdezsAgImbCZQ5Sjza9AiOvRwhkUo09lfmKvH458eoMbIG5PFyqBQqmNczBwAYuxgjMyoTVv4F57xoMwOpAfrW7Ss6BhGRGueUEOmKBv8BpPwcgSpH+5iLMDUo+o29tpGZyWBgZYCcuBxkRWfBsollkfsqc5QF//oV89fw6b6nsGhkAVN3U6iUKuCFhb1UeZq3dUVHt45wMHUQHYOISI1FCZGuMLfnFd6p0pjKM9HBUvyFFBXZCmTdy0LWvX9WxkqQI+teFuSJ+ZPxUy+kIj08HfJ4OZ5dfoaYpTGwamIFy4b/FiUPf3iIuN1x6tuWjS2RdDwJKedSIH8qR/r1dMT/Gg/LxpYFekmyH2Uj9UIqnP+TfxFTY1djQAIknUxCWmgacmJzYOqhO8Xbc/29+4uOQESkgR+7EumSZsOB2wdFpyA9EZj2DH8JzpAVnYWYJTHq23Hb84sLm7Y2qDGiBvJS8xC7IxaKVAUMbAxg08YGjm87arQhT5QDL9QaTm85QSKRIP7XeOQm58LA0gCWjfOXHn6RSqXC402P4TLABVLj/M/wpEZSVP+oOmK3xEKVq4LrYFcY2mrPdV1Kwt3KHS1dW4qOQUSkQaJSqYqZDkhEWkWlAlY3BxIjRScpN+suyrEuRI6YlPwxMA2cZJjbzgjdvP59o3f2QR7mHM/B+UcKyCRAYxcZDr1vBlNDSVHNYs0FOZaeyUFcugp+LlJ8280ULar/e7G7KYeysSlUDnMjCRYHmGCQ77/H230jFz9fzcUfA8wq4Ix1h1xmjA516iAtN110FCpH05tNxwcNPhAdg4hIA4dvEekSiQRoOUp0inJVw0qCxZ2NcWmkOUJGmqOTuwxv78jCjXgFgPyCJGhrJrrWMcCFj8xxcYQ5xrcwgrToegQ7r+diyuFsfNbeGJdHmcPPWYbAXzIQn5Ff+PwRkYtt13JxeLA5vupsgo/+yEJCZv621GwV5hzPwZruJhV+7trOSJGDjha1RcegcmQiM8Hbnm+LjkFEVACLEiJd03ggYGIjOkW56eVtiO5ehvCyl6GuvQyLAkxgYQSce5hflHx8KAcTWxhh1hvGaOAkg7eDDP0aGMLYoOiqZMW5HIxoYogP/Y1Q31GG73qawMxQgp+u5AIAwhOU6OAuQ7NqMgxoZAgrYwmik/M7jWccycaYZoaoac1fjwAQmJokOgKVo67uXWFtrJvXVSGiqo1/dYl0jZE50HSI6BQVQqFUYcf1XGTkAq3dZIjPUOL8IwWczKVosyEDzsvS0H5TBk7fL/rCeHKFCpceK9HZ498pc1KJBJ09DHD2n0LHz1mGkMcKJGepcOmxAlm5KnjaSXH6fh4uxykwsaVRhZ+rrmgdHQJrI91a7paKxgnuRKStWJQQ6aIWI6vU8sDXnihg8cUzGC9Mw+j9WfitvynqO8pwNzl/SNW8k/k9HwcHmaGJiwwBP2ciMlFRaFsJmSooVICzuWZPirO5BHHp+e0FehrgfV9DNF+fjqF7s7C5tynMjYAxf2bjux6mWBeSC+/V6Wj7U4Z6GJm+MlTmorN5LdExqBz42PnA19FXdAwiokKxKCHSRdY1AJ9eolOUG28HKUJHW+D8R+YY08wIQ37Pxs2nCij/WYZjVNP8oVj+rjJ8HWQCb3upeijW65rXwQR3Jlri2hgL9PExxJen5Ohc2wCGMmDh/3Jw+kMzfORviA9+zyqHM9RtQclPRUegcjCg3gDREYiIisSihEhXtRonOkG5MZJJ4GknRdNqMnzZ2QR+zlKsPCeHq0X+r6j6jpq/qnwcpbj/rPAr1jmYSSCTAE8yNBcWfJKhgotF4b/ybiUo8Mu1XCzoZIwTMXloV0sGR3Mp+jUwxOVYJdJy9HuRwuYxl2BnbCs6BpVBdYvq6FWn6nyQQURVD4sSIl3l1hyo3kx0igqhVAE5CsDdRoJqlhJEJGgWILcTlahVxER0I5kETatJcezuv/NOlCoVjt3NQ+sasgL7q1QqjNqfjRVdjWFhJIFCCeT+c7jn/yr0uyaBTKVAF7MaomNQGYzyHQWDKjTkk4iqHhYlRLqs7STRCcps9tFs/O9eHmJSlLj2RIHZR7NxIkaBQY0MIZFIML2NEVZdkOO/N3NxJ0mJT49n41aCEsP9/52MHvBzBlZfkKtvT2lljPWXc7E5VI7wpwqM2Z+NjFwVPmxc8CJ3P17OhaOZBL2887e1rWmA49F5OPcwD1+fzUF9RylsTIpZf1hPdEuIe/VOpJVqWNRgLwkRaT1+bEKky3x6Aa6NgdhQ0UleW3yGCh/8loXYdBWsjSXwdZbi0Ptm6FIn/9fT5FbGyM4DPj6UjaQsFfycZTgy2Ax17P79TCUqSam+zggA9G9oiKeZKsw9kX/xxMYuUhwcZAbnl4ZvPUlXYtGpHJwZbq6+r0V1Gaa2NkaPbVlwMpdgc2/TCn4GdEOT+5fh5OOP+OwE0VGolEb6jmQvCRFpPV7RnUjX3TkK/NJXdArSA0v8e+CXlGuiY1ApuFm6YV/vfSxKiEjrcfgWka7z7AzUekN0CtID3Z4+FB2BSom9JESkK1iUEFUFAXNFJyA94PswDNXNnEXHoBKqaVkTvTw4l4SIdAOLEqKqoGZLoG6Q6BSkB7oaOYmOQCU00nckZNKCK84REWkjFiVEVUWnTwFwlSiqWN3iokVHoBKoZVULPT16io5BRFRiLEqIqgqXhkBDTniniuUTexO1zKuJjkGvMMp3FHtJiEinsCghqko6/h/ASa1UwQIN7EVHoGI0dmzMXhIi0jksSoiqEvs6QJMholNQFdct9o7oCFQEmUSGT1p9AomEQzmJSLewKCGqagI+BcwcRKegKszzSQQ8LdxEx6BC9PfuD287b9ExiIhKjUUJUVVjagsELhKdgqq4QKm16Aj0EgdTB4z3Hy86BhHRa2FRQlQV+b0HuL8pOgVVYd0e3RIdgV4ypekUWBpZio5BRPRaWJQQVVU9vwZkRqJTUBVVK+EufCxriY5B/2jq3BS96vBCiUSku1iUEFVVDl5A28miU1AVFggL0REIgIHEAHNazhEdg4ioTFiUEFVlb04F7DxEp6AqKujhDdERCMBAn4HwsvUSHYOIqExYlBBVZYYmQI/lolNQFVU96T58rVj0iuRk6oSxjceKjkFEVGYsSoiqujqdeKV3qjCBShPREfTajBYzYG5oLjoGEVGZsSgh0geBXwKmdqJTUBUUeP8qJOCF+kToVrsbAt0DRccgIioXLEqI9IGlM/D2GtEpqApyTn0Mf+s6omPoHWczZ3zS6hPRMYiIyg2LEiJ9Ua870Pwj0SmoCgrKMxAdQa9IIMGiNxbByshKdBQionLDooRIn3RdBDj6iE5BVUzXmCuQSWSiY+iNQT6D0NK1pegYRETlikUJkT4xNAHe+Qkw4ORkKj/26U/RjEO4KkVd27qY3HSy6BhEROWORQmRvnGuD3RdKDoFVTFBctEJqj5TA1Msbb8UxjJj0VGIiModixIifdRiBODdXXQKqkK6RF+CgZRzSyrS7Baz4WFdsdeFkUgk+P3334vcfuLECUgkEqSkpFRoDira0KFD0bt37zK3I5fL4enpiTNnzpQ9lA5xd3fHN998o779qte8Ppg3bx4aN25cbu0dPHgQjRs3hlKpLNXjWJQQ6au31wCWrqJTUBVhnZmMVlaeomNUWd1rd0cfrz5laiMuLg4TJkyAh4cHjI2N4ebmhl69euHYsWMlbqNNmzaIjY2FtbV1mbI8V95vhvTBypUrsWnTpjK3891336F27dpo06aN+j6JRKL+sra2Rtu2bXH8+PEyH0ubxcbGolu3bsKOX9mFfmFF2LRp00r1e+BVgoKCYGhoiK1bt5bqcSxKiPSVmR3Q53tAwl8DVD6CshWiI1RJbpZumNt6bpnaiImJQdOmTXH8+HEsXboU165dw8GDB9GxY0eMGzeuxO0YGRnBxcUFEknlXpsmNze3Uo+nzaytrWFjY1OmNlQqFVavXo3hw4cX2LZx40bExsYiODgYDg4O6NmzJ+7evVum42kzFxcXGBtXjSGRr/tzYmFhAXt7+3LNMnToUKxatapUj+G7ESJ95tEeCCjbmx2i5wKiL8JIaiQ6RpViYWiBbzt9W+arto8dOxYSiQQXLlxA3759UbduXTRo0ABTpkzBuXPnNPZNSEhAnz59YGZmBi8vL+zbt0+97eVPdTdt2gQbGxscOnQIPj4+sLCwQFBQEGJjYzUe06JFC5ibm8PGxgZt27bFvXv3sGnTJsyfPx9hYWHqT+ef9wBIJBKsW7cOb731FszNzbFo0SIoFAoMHz4ctWvXhqmpKby9vbFy5UqN7M+HNs2fPx+Ojo6wsrLC6NGjIZcXPenp+Tn8/vvv8PLygomJCQIDA/HgwQON/fbu3YsmTZrAxMQEHh4emD9/PvLy8tTbJRIJfvzxxyKfOwDYt2+f+hgdO3bE5s2bNZ7PwnqOvvnmG7i7uxc4x+c6dOiAiRMnYsaMGbCzs4OLiwvmzZtX5PkCwKVLlxAVFYUePXoU2GZjYwMXFxc0bNgQ69atQ1ZWFo4cOYKff/4Z9vb2yMnJ0di/d+/eGDx4sPr2woUL4eTkBEtLS3z00UeYNWuWxjkplUp8/vnnqFGjBoyNjdG4cWMcPHhQvV0ul2P8+PFwdXWFiYkJatWqhS+//FK9PSUlBaNGjYKzszNMTEzQsGFD7N+/X7399OnTePPNN2Fqago3NzdMnDgRGRkZRT4XL/YcvOrYL7t48SK6dOkCBwcHWFtbo3379rh8+XKB9ot6XcTExKBjx44AAFtbW0gkEgwdOhRA/hCoN954AzY2NrC3t0fPnj0RFRWlbjcmJgYSiQQ7d+5E+/btYWJiou6Z+Omnn9CgQQMYGxvD1dUV48ePBwD166hPnz6QSCTq24W97opqAwBWrFiBRo0awdzcHG5ubhg7dizS09M1Ht+rVy+EhIRoZH4VFiVE+u6NjwG/gaJTUBVgkf0Mba05hKu8yCQyLG2/FHVsyrayWVJSEg4ePIhx48bB3LxgcfPyp+7z589Hv379cPXqVXTv3h2DBg1CUlJSke1nZmZi2bJl2LJlC/73v//h/v37mDZtGgAgLy8PvXv3Rvv27XH16lWcPXsWI0eOhEQiQf/+/TF16lQ0aNAAsbGxiI2NRf/+/dXtzps3D3369MG1a9cwbNgwKJVK1KhRA7t378bNmzcxd+5c/N///R927dqlkefYsWMIDw/HiRMnsH37dvz666+YP39+sc9RZmYmFi1ahJ9//hnBwcFISUnBe++9p95+6tQpfPDBB5g0aRJu3ryJ77//Hps2bcKiRYtK/NxFR0fjnXfeQe/evREWFoZRo0Zhzpw5xeYqqc2bN8Pc3Bznz5/HV199hc8//xxHjhwpcv9Tp06hbt26sLS0LLZdU1NTAPlv1t99910oFAqNQis+Ph5//vknhg0bBgDYunUrFi1ahCVLluDSpUuoWbMm1q1bp9HmypUrsXz5cixbtgxXr15FYGAg3nrrLURGRgIAVq1ahX379mHXrl2IiIjA1q1b1W+elUolunXrhuDgYPzyyy+4efMmFi9eDJksf0nyqKgoBAUFoW/fvrh69Sp27tyJ06dPa7yhLk5xxy5MWloahgwZgtOnT+PcuXPw8vJC9+7dkZaWprFfUa8LNzc37NmzBwAQERGB2NhYdaGdkZGBKVOmICQkBMeOHYNUKkWfPn0KzNOYNWsWJk2ahPDwcAQGBmLdunUYN24cRo4ciWvXrmHfvn3w9Mz/vXzx4kUA//aGPb/9suLaAACpVIpVq1bhxo0b2Lx5M44fP44ZM2ZotFGzZk04Ozvj1KlTJXjm83FWIhEBvVYCSXeBB+devS9RMYIys/G36BBVxPTm0/FG9TfK3M6dO3egUqlQr169Eu0/dOhQDBgwAADwxRdfYNWqVbhw4QKCgoIK3T83Nxffffcd6tTJL57Gjx+Pzz//HADw7NkzpKamomfPnurtPj7/XivJwsICBgYGcHFxKdDuwIED8eGHH2rc92JxUbt2bZw9exa7du1Cv3791PcbGRnhp59+gpmZGRo0aIDPP/8c06dPx4IFCyCVFv5ZbG5uLlavXo2WLfOv/7J582b4+PjgwoULaNGiBebPn49Zs2ZhyJAhAAAPDw8sWLAAM2bMwGeffVai5+7777+Ht7c3li5dCgDw9vbG9evXCxQ2r8PX11edw8vLC6tXr8axY8fQpUuXQve/d+8eqlWrVmybmZmZ+OSTTyCTydC+fXuYmppi4MCB2LhxI959910AwC+//IKaNWuiQ4cOAIBvv/0Ww4cPV3/f5s6di8OHD2t8ir5s2TLMnDlTXfQtWbIEf//9N7755husWbMG9+/fh5eXF9544w1IJBLUqlVL/dijR4/iwoULCA8PR926dQHkfy+e+/LLLzFo0CBMnjxZ/VysWrUK7du3x7p162BiUvxy+MUduzCdOnXSuP3DDz/AxsYGJ0+eRM+ePdX3F/e6sLOzAwA4OTlpfEDQt29fjbZ/+uknODo64ubNm2jYsKH6/smTJ+M///mP+vbChQsxdepUTJo0SX1f8+bNAQCOjo4A/u0NK0pxbTw/5nPu7u5YuHAhRo8ejbVr12q0U61aNdy7d6/I47yMPSVEBBgYAe9tBaxrik5COq7D3YswlfE6OGXVr24/DPIZVC5tqVSqUu3v6+ur/r+5uTmsrKwQHx9f5P5mZmbqggMAXF1d1fvb2dlh6NChCAwMRK9evbBy5UqNoV3FadasWYH71qxZg6ZNm8LR0REWFhb44YcfcP/+fY19/Pz8YGZmpr7dunVrpKenFxiO9SIDAwONN1316tWDjY0NwsPDAQBhYWH4/PPPYWFhof4aMWIEYmNjkZmZqX5ccc9dRESExjEAoEWLFiV5Kl7pxeMCmt+DwmRlZRX5Bn3AgAGwsLCApaUl9uzZgw0bNqjbHzFiBA4fPoxHjx4ByB/6NnToUPUco4iIiALn9OLtZ8+e4fHjx2jbtq3GPm3btlU/10OHDkVoaCi8vb0xceJEHD58WL1faGgoatSooS5IXhYWFoZNmzZpfJ8CAwOhVCoRHR1d5PPxXHHHLsyTJ08wYsQIeHl5wdraGlZWVkhPTy/wmiztzxQAREZGYsCAAfDw8ICVlZW6x+bltl/8OYmPj8fjx48REBDwynMtSknaOHr0KAICAlC9enVYWlpi8ODBSExM1PhZAPJ72l6+rzgsSogon7kDMHAHYFR8dz5RcczkGXjTihdSLItWrq0wu+XscmvPy8sLEokEt27dKtH+hoaGGrclEkmxS3sWtv+LhdDGjRtx9uxZtGnTBjt37kTdunULzGMpzMtDzXbs2IFp06Zh+PDhOHz4MEJDQ/Hhhx8WO1+kvKSnp2P+/PkIDQ1Vf127dg2RkZEab+5L+9y9TCqVFigiSzJ5ubTHdXBwQHJycqHbvv76a4SGhiIuLg5xcXHq3iEA8Pf3h5+fH37++WdcunQJN27cUM+BKC9NmjRBdHQ0FixYgKysLPTr1w/vvPMOgH+HkxUlPT0do0aN0vg+hYWFITIyUqNwfp1jF2bIkCEIDQ3FypUrcebMGYSGhsLe3r7Aa/J1Xhe9evVCUlIS1q9fj/Pnz+P8+fMAUKDtF39OXvX8lMSr2oiJiUHPnj3h6+uLPXv24NKlS1izZk2h2ZKSktS9MyXBooSI/uXcAOj7I1fkojIJemnCI5Wcu5U7lndYXq7XfLGzs0NgYCDWrFlT6ITfyliK1N/fH7Nnz8aZM2fQsGFDbNu2DUD+UCuFomSrtgUHB6NNmzYYO3Ys/P394enpWegk2rCwMGRlZalvnzt3DhYWFnBzcyuy7by8PISEhKhvR0REICUlRT3UrEmTJoiIiICnp2eBr6KGhL3M29tb4xgACozpd3R0RFxcnEZhEhoaWqL2S8Pf3x+3bt0qtBfNxcUFnp6eRb6Z/Oijj7Bp0yZs3LgRnTt31nhevb29C5zTi7etrKxQrVo1BAcHa+wTHByM+vXra+zXv39/rF+/Hjt37sSePXuQlJQEX19fPHz4ELdv3y40W5MmTXDz5s1Cv09GRiVbhKOoYxcmODgYEydORPfu3dWTwhMSEkp0nOee53rx5yAxMRERERH45JNPEBAQAB8fnyKLyBdZWlrC3d292OV9DQ0Ni/2Ze1Ubly5dglKpxPLly9GqVSvUrVsXjx8/LrBfdnY2oqKi4O/v/8rcz/GdBxFp8g4COhc/KZSoOO3uXoS5gdmrdyQN1sbWWBOwBlZGVuXe9po1a6BQKNCiRQvs2bMHkZGRCA8Px6pVq9C6detyP95z0dHRmD17Ns6ePYt79+7h8OHDiIyMVL/Zd3d3R3R0NEJDQ5GQkFBgZacXeXl5ISQkBIcOHcLt27fx6aefFjpRVy6XY/jw4bh58yYOHDiAzz77DOPHjy+2eDA0NMSECRNw/vx5XLp0CUOHDkWrVq3UQ4/mzp2Ln3/+GfPnz8eNGzcQHh6OHTt24JNPPinxczFq1CjcunULM2fOxO3bt7Fr1y6N1caA/JW0nj59iq+++gpRUVFYs2YN/vrrrxIfo6Q6duyI9PR03Lhxo9SPHThwIB4+fIj169erJ7g/N2HCBGzYsAGbN29GZGQkFi5ciKtXr2osIT19+nQsWbIEO3fuREREBGbNmoXQ0FD1/IUVK1Zg+/btuHXrFm7fvo3du3fDxcUFNjY2aN++Pdq1a4e+ffviyJEjiI6Oxl9//aVevWvmzJk4c+YMxo8fj9DQUERGRmLv3r0lnuhe3LEL4+XlhS1btiA8PBznz5/HoEGDSt1bUatWLUgkEuzfvx9Pnz5Feno6bG1tYW9vjx9++AF37tzB8ePHMWXKlBK1N2/ePCxfvhyrVq1CZGQkLl++jG+//Va9/XnBERcXV2ShU1wbnp6eyM3Nxbfffou7d+9iy5Yt+O677wq0ce7cORgbG5fq9wuLEiIqqO1EwP990SlIRxnnZaODZcVeebyqMZAa4OsOX6OmVcXM6/Lw8MDly5fRsWNHTJ06FQ0bNkSXLl1w7NixAqsjlSczMzPcunVLvQzxyJEjMW7cOIwaNQpA/mTeoKAgdOzYEY6Ojti+fXuRbY0aNQr/+c9/0L9/f7Rs2RKJiYkYO3Zsgf0CAgLg5eWFdu3aoX///njrrbdeuUSumZkZZs6ciYEDB6Jt27awsLDAzp071dsDAwOxf/9+HD58GM2bN0erVq3w9ddfv3Ii9Itq166N//73v/j111/h6+uLdevWqVffen6dDB8fH6xduxZr1qyBn58fLly4oF7JrDzZ29ujT58+pb64HZB/nZS+ffvCwsKiwJXlBw0ahNmzZ2PatGnqoVBDhw7VGOI2ceJETJkyBVOnTkWjRo1w8OBB9VLJQP4n9V999RWaNWuG5s2bIyYmBgcOHFAXlXv27EHz5s0xYMAA1K9fHzNmzFB/8u/r64uTJ0/i9u3bePPNN+Hv74+5c+e+clL/c6869ss2bNiA5ORkNGnSBIMHD8bEiRPh5ORUquezevXq6oUUnJ2d1QX0jh07cOnSJTRs2BAff/yxeoGEVxkyZAi++eYbrF27Fg0aNEDPnj3VK5sBwPLly3HkyBG4ubkV2YtRXBt+fn5YsWIFlixZgoYNG2Lr1q2FLpu8fft2DBo0SGN+16tIVKWdAUdE+kGRC+wYCEQWP9GPqDAnPNtigqLoicWk6fM2n5f5iu2UP1E5JSWlwBWri7Np0yZMnjy50q6o/aJFixbhu+++K3YSfkW5evUqunTpgqioKFhYWJTqsQEBAWjQoEGJLo7XpUsXuLi4YMuWLa8blXRMQkKCerhi7dq1S/w4LglMRIWTGQL9fgZ+eQe4d1p0GtIxbaMvwrKOJ9JyOb/kVWa1mMWCRE+sXbsWzZs3h729PYKDg7F06dISDy0qb76+vliyZAmio6PRqFGjEj0mOTkZJ06cwIkTJwos/wrkLyP83XffITAwEDKZDNu3b8fRo0eLvWYKVT0xMTFYu3ZtqQoSgEUJERXH0DR/Ra7NbwGPL796f6J/GCrkCLCojd+Tr4mOotWmNZtWbkv/kvZ7Ps8iKSkJNWvWxNSpUzF7dvmttFZapV05y9/fH8nJyViyZAm8vb0LbJdIJDhw4AAWLVqE7OxseHt7Y8+ePejcuXM5JSZd0KxZs0KX9H4VDt8iolfLTAI29QDib4pOQjok2KMlRqtKdk0KffRx048xrOGwV+9IRKQHONGdiF7NzA74YB/gUPCTMaKitIy5BFsja9ExtNJE/4ksSIiIXsCihIhKxsIRGPIHYO8lOgnpCANlHjqbV8xqUrpsrN9YjPAdIToGEZFWYVFCRCVn6ZxfmNjxit1UMkFJ8aIjaJWRviMxpvEY0TGIiLQOixIiKh0rV2DofsDeU3QS0gHN7l2Co4md6BhaYVjDYZjgP0F0DCIircSiRAfFxMRAIpEgNDS0zG1t2LABXbt2LXsoHbJp0yaNq7POmzcPjRs3FpanMh08eBCNGzeGUqksW0NW1YBhh4HqTcsnGFVZUpUSXUxriI4h3JD6Q/Bx049FxyAi0lqlLkri4uIwadIkeHp6wsTEBM7Ozmjbti3WrVuHzMzMcg3XoUMHTJ48uVzbrArc3NwQGxuLhg0blqmd7OxsfPrpp/jss8/U982bNw8SiQQSiQQGBgZwd3fHxx9/jPT0qnutgWnTpuHYsWOiY1SKoKAgGBoavtZVfAswtweG7Ae89KuopdILevpIdARhJJDg46YfY1rz8r8qNxFRVVKqouTu3bvw9/fH4cOH8cUXX+DKlSs4e/YsZsyYgf379+Po0aMVlZNeIJPJ4OLiAgODsl1m5r///S+srKzQtm1bjfsbNGiA2NhYxMTEYMmSJfjhhx8wderUMh1Lm1lYWMDe3l50jEozdOjQEl2Ft0SMzID3tgONeZ0FKlrjB6FwMXUUHaPSGUmN8FW7r7jKFhFRCZSqKBk7diwMDAwQEhKCfv36wcfHBx4eHnj77bfx559/olevXup9U1JS8NFHH8HR0RFWVlbo1KkTwsLC1NufD5nZsmUL3N3dYW1tjffeew9paWkA8t84nTx5EitXrlR/ch8TEwMAOHnyJFq0aAFjY2O4urpi1qxZyMvLU7edk5ODiRMnwsnJCSYmJnjjjTdw8eLFYs/N3d0dCxYswIABA2Bubo7q1atjzZo1GvuU9ZwAIC0tDYMGDYK5uTlcXV3x9ddfF+gRkkgk+P333zWObWNjg02bNgEoOHzrxIkTkEgkOHbsGJo1awYzMzO0adMGERERxZ7zjh07NL5nzxkYGMDFxQU1atRA//79MWjQIOzbtw8qlQqenp5YtmyZxv6hoaGQSCS4c+cOAODWrVt44403YGJigvr16+Po0aMFzunatWvo1KkTTE1NYW9vj5EjR2r0xpw4cQItWrSAubk5bGxs0LZtW9y7d0+9/Y8//kDz5s1hYmICBwcH9Onz79WQc3JyMG3aNFSvXh3m5uZo2bIlTpw4UeTz8PLwrVcd+0XPvxc7duxAmzZtYGJigoYNG+LkyZPqfRQKBYYPH47atWvD1NQU3t7eWLlypUY7Q4cORe/evTF//nz162v06NGQy+Xqfdzd3fHNN99oPK5x48aYN2+e+vaKFSvQqFEjmJubw83NDWPHji3Qy9WrVy+EhIQgKiqqyOekVGQGQO+1wBtTyqc9qnIkUCHQ2FV0jEplbWyN9V3XI6h2kOgoREQ6ocRFSWJiIg4fPoxx48bB3Ny80H0kEon6/++++y7i4+Px119/4dKlS2jSpAkCAgKQlJSk3icqKgq///479u/fj/379+PkyZNYvHgxAGDlypVo3bo1RowYgdjYWMTGxsLNzQ2PHj1C9+7d0bx5c4SFhWHdunXYsGEDFi5cqG53xowZ2LNnDzZv3ozLly/D09MTgYGBGscuzNKlS+Hn54crV65g1qxZmDRpEo4cOVJu5wQAU6ZMQXBwMPbt24cjR47g1KlTuHy5fK6UPWfOHCxfvhwhISEwMDDAsGHFfzp3+vTpEl1x09TUFHK5HBKJBMOGDcPGjRs1tm/cuBHt2rWDp6cnFAoFevfuDTMzM5w/fx4//PAD5syZo7F/RkYGAgMDYWtri4sXL2L37t04evQoxo8fDwDIy8tD79690b59e1y9ehVnz57FyJEj1a+vP//8E3369EH37t1x5coVHDt2DC1atFC3P378eJw9exY7duzA1atX8e677yIoKAiRkZGvPNdXHbso06dPx9SpU3HlyhW0bt0avXr1QmJiIgBAqVSiRo0a2L17N27evIm5c+fi//7v/7Br1y6NNo4dO4bw8HCcOHEC27dvx6+//or58+e/MvOLpFIpVq1ahRs3bmDz5s04fvw4ZsyYobFPzZo14ezsjFOnTpWq7Vfq/BnQbSkg4VQ1KigovvDCviqqYVEDv3T7BU2cm4iOQkSkM0o8/ufOnTtQqVTw9ta8eJqDgwOys7MBAOPGjcOSJUtw+vRpXLhwAfHx8TA2NgYALFu2DL///jv++9//YuTIkQDy36xt2rQJlpaWAIDBgwfj2LFjWLRoEaytrWFkZAQzMzO4uLioj7d27Vq4ublh9erVkEgkqFevHh4/foyZM2di7ty5yMrKwrp167Bp0yZ069YNALB+/XocOXIEGzZswPTp04s8x7Zt22LWrFkAgLp16yI4OBhff/01unTpUi7nlJaWhs2bN2Pbtm0ICAgAkP+Gvlq1aiX9NhRr0aJFaN++PQBg1qxZ6NGjB7Kzs2FiYlJg35SUFKSmpr7y2JcuXcK2bdvQqVMnAPmf6M+dOxcXLlxAixYtkJubi23btql7T44cOYKoqCicOHFC/X1btGgRunTpom5z27ZtyM7Oxs8//6wucFevXo1evXphyZIlMDQ0RGpqKnr27Ik6dfKXnvXx8dE4z/fee0/jDbufnx8A4P79+9i4cSPu37+vPrdp06bh4MGD2LhxI7744otiz/fZs2fFHrso48ePR9++fQEA69atw8GDB7FhwwbMmDEDhoaGGllr166Ns2fPYteuXejXr5/6fiMjI/z0008wMzNDgwYN8Pnnn2P69OlYsGABpNKSvdF/scfN3d0dCxcuxOjRo7F27VqN/apVq1Zk70+ZtByZfz2TX0cBipzyb590VsNH1+DWoAUeZMaJjlKhfB18sarTKtib6s+QUCKi8lDmjzQvXLiA0NBQNGjQADk5+W9CwsLCkJ6eDnt7e1hYWKi/oqOjNYaMuLu7q9+8A4Crqyvi44tf0z48PBytW7fW+OS6bdu2SE9Px8OHDxEVFYXc3FyNeRKGhoZo0aIFwsPDi227devWBW4/f0x5nNPdu3eRm5ur8am+tbV1gULvdfn6+mocF0CRz2dWVhYAFFqwXLt2DRYWFjA1NUWLFi3QunVrrF69GkD+m9kePXrgp59+ApA/jConJwfvvvsuACAiIgJubm4aheSL5wvkfw/9/Pw0etzatm0LpVKJiIgI2NnZYejQoQgMDESvXr2wcuVKxMbGqvcNDQ1VF3WFZVcoFKhbt67G9+nkyZMlGq70qmMX5cXXjoGBAZo1a6bxeluzZg2aNm0KR0dHWFhY4IcffsD9+/c12vDz84OZmZlGm+np6Xjw4MErj//c0aNHERAQgOrVq8PS0hKDBw9GYmJigUUoTE1Ny31hCrUGfYD39wCmXAaWNAUaVe15JZ3cOmFD4AYWJEREr6HEPSWenp6QSCQF5il4eHgAyH+T81x6ejpcXV0LHcf/4lKshoaGGtskEknZlyqtIJV5ThKJBCqVSuO+3NzcVz7uxWM/L9qKOra9vT0kEgmSk5MLbPP29sa+fftgYGCAatWqwcjISGP7Rx99hMGDB+Prr7/Gxo0b0b9/f4030+Vh48aNmDhxIg4ePIidO3fik08+wZEjR9CqVSuN19rL0tPTIZPJcOnSJchkMo1tFhYWZT7269ixYwemTZuG5cuXo3Xr1rC0tMTSpUtx/vz5UrUjlUqLfV3ExMSgZ8+eGDNmDBYtWgQ7OzucPn0aw4cPh1wu1/geJSUlwdGxAt8g1n4TGHUS2DkYiA2tuOOQTgmKvYsfi/7x1Wnv+7yP6c2nQ8rhi0REr6XEvz3t7e3RpUsXrF69GhkZGcXu26RJE8TFxcHAwACenp4aXw4ODiUOZ2RkBIVCoXGfj48Pzp49q/HmLDg4GJaWlqhRowbq1KkDIyMjBAcHq7fn5ubi4sWLqF+/frHHO3fuXIHbz4fulMc5eXh4wNDQUGPSfWpqKm7fvq2xn6Ojo8an85GRkeX+qbaRkRHq16+PmzdvFrrN09MT7u7uBQoSAOjevTvMzc3Vw5RenLvi7e2NBw8e4MmTJ+r7Xl5kwMfHB2FhYRqvo+DgYEilUo1eI39/f8yePRtnzpxBw4YNsW3bNgD5PUJFLeHr7+8PhUKB+Pj4At+nF3tvXqWoYxflxddOXl4eLl26pH7tBAcHo02bNhg7diz8/f3h6elZaK9NWFiYugfreZsWFhZwc3MDUPB18ezZM0RHR6tvX7p0CUqlEsuXL0erVq1Qt25dPH78uMBxsrOzERUVBX9//xI+G6/JpiYw/DDQ5IOKPQ7pDO+4cNQ2ry46RrkykhphTss5mNliJgsSIqIyKNVv0LVr1yIvLw/NmjXDzp07ER4ejoiICPzyyy+4deuW+pPpzp07o3Xr1ujduzcOHz6MmJgYnDlzBnPmzEFISEiJj+fu7o7z588jJiYGCQkJUCqVGDt2LB48eIAJEybg1q1b2Lt3Lz777DNMmTIFUqkU5ubmGDNmDKZPn46DBw/i5s2bGDFiBDIzMzF8+PBijxccHIyvvvoKt2/fxpo1a7B7925MmjSp3M7J0tISQ4YMwfTp0/H333/jxo0bGD58OKRSqcZwtE6dOmH16tW4cuUKQkJCMHr06AI9MOUhMDAQp0+fLvXjZDIZhg4ditmzZ8PLy0tj6FKXLl1Qp04dDBkyBFevXkVwcDA++eQTAP/23gwaNAgmJiYYMmQIrl+/jr///hsTJkzA4MGD4ezsjOjoaMyePRtnz57FvXv3cPjwYURGRqrf5H/22WfYvn07PvvsM4SHh+PatWtYsmQJgPy5QIMGDcIHH3yAX3/9FdHR0bhw4QK+/PJL/Pnnn688t1cduyhr1qzBb7/9hlu3bmHcuHFITk5WF2teXl4ICQnBoUOHcPv2bXz66aeFrgYnl8sxfPhw3Lx5EwcOHMBnn32G8ePHq+eTdOrUCVu2bMGpU6dw7do1DBkyRKM3yNPTE7m5ufj2229x9+5dbNmyBd99912B45w7dw7GxsYFhitWCANj4K1vgbdWAwYFhwqS/gkyqDrD+mpa1sSW7lvwXr33REchItJ5pSpK6tSpgytXrqBz586YPXs2/Pz80KxZM3z77beYNm0aFixYACD/zeeBAwfQrl07fPjhh6hbty7ee+893Lt3D87OziU+3rRp0yCTyVC/fn04Ojri/v37qF69Og4cOIALFy7Az88Po0ePxvDhw9VvfAFg8eLF6Nu3LwYPHowmTZrgzp07OHToEGxtbYs93tSpUxESEgJ/f38sXLgQK1asQGBgYLme04oVK9C6dWv07NkTnTt3Rtu2beHj46Mxt2P58uVwc3PDm2++iYEDB2LatGnlPjwKAIYPH44DBw4gNTX1tR4rl8vx4Ycfatwvk8nw+++/Iz09Hc2bN8dHH32kXn3r+TmamZnh0KFDSEpKQvPmzfHOO+8gICBAPW/FzMwMt27dQt++fVG3bl2MHDkS48aNw6hRowDkX1Rz9+7d2LdvHxo3boxOnTrhwoUL6gwbN27EBx98gKlTp8Lb2xu9e/fGxYsXUbNmzVee16uOXZTFixdj8eLF8PPzw+nTp7Fv3z51D9qoUaPwn//8B/3790fLli2RmJiIsWPHFmgjICAAXl5eaNeuHfr374+33npLY7nf2bNno3379ujZsyd69OiB3r17qyfjA/lzUlasWIElS5agYcOG2Lp1K7788ssCx9m+fTsGDRpUIa+pIjUZDAw7lN97Qnot6PHtV++kA7q5d8OuXrtQ3774HngiIioZierlQep6yt3dHZMnT670K8hnZGSgevXqWL58+St7cirCu+++iyZNmmD27NmletypU6cQEBCABw8evLIoCw4OxhtvvIE7d+5ovImuCmJiYlC7dm1cuXJF41onpTV06FCkpKQUuD5NeUtISIC3tzdCQkJQu3btCj1WoTKTgF9HAneOvHpfqrL6NnoDt9Pvv3pHLWQiM8HMFjPxTt13REchIqpSynZJcCq1K1eu4NatW2jRogVSU1Px+eefAwDefvttIXmWLl2KP/74o8T75+Tk4OnTp5g3bx7efffdQguS3377DRYWFvDy8sKdO3cwadIktG3btsoVJLooJiYGa9euFVOQAICZHTBwF/C/r4CTSwCVdi5sQRUrSGoFXewvqW1dG8vaL0Nd27qioxARVTmclSfAsmXL4Ofnh86dOyMjIwOnTp0q1QIA5cnd3R0TJkwo8f7bt29HrVq1kJKSgq+++qrQfdLS0jBu3DjUq1cPQ4cORfPmzbF3797yikxl0KxZM/Tv319sCKkU6DALGPIHYOsuNgsJEfSw4AIb2u6tOm9hR48dLEiIiCoIh28RkTjyDODIXODiBgD8VaRP3vNrjxvPol+9o2CmBqaY03IO3vYU05tNRKQv2FNCROIYmQM9lgND9nESvJ4JUlXiQguvqZlzM+zquYsFCRFRJWBPCRFph5x04PAnwKWNopNQJYi1dUOgjRQqLewhsza2xtSmU9Hbs7fGcu1ERFRxWJQQkXaJOg7smwikPhCdhCrYYL+OCH1W8EKiInWv3R0zms+Avam96ChERHqFw7eISLvU6QSMOQM0GQKAn1JXZUFKY9ER1GpY1MB3nb/DknZLWJAQEQnAnhIi0l4PQ4CDs4CHF0UnoQrw1MoFnR1MoBS4NLSBxACDGwzGWL+xMDEwefUDiIioQrAoISLtplIB13YDR+cBzx6JTkPlbFjjAFxMjRRy7EYOjfBZ68/gbect5PhERPQvXjyRiLSbRAL49gPq9QSCV+Z/5WWJTkXlJChPhsruB3MydcLoxqPR16svpBKOYiYi0gbsKSEi3ZL6EDjyGXD9v6KTUDlIMndAgLMV8lR5FX4sG2MbDG84HO/Ve49DtYiItAyLEiLSTQ8uAH/NBB5fFp2EymiUfxecSYmosPbNDMwwuP5gDG0wFBZGFhV2HCIien0sSohId6lUQMQB4H/LWJzosN/qd8bcrNvl3q6R1Aj9vPthhO8I2JnYlXv7RERUfliUEFHVcOdYfnFy/4zoJFRKqaY26FjNHrnK3HJpTyaR4a06b2GM3xi4WriWS5tERFSxWJQQUdUSEwycWpZ/EUbSGeP9A3EyJbxMbRhIDNClVheMaTwGta1rl1MyIiKqDFx9i4iqFve2+V+PLuX3nET8BYCfvWi7wOxcnHzNx9oa2+Kduu+gv3d/OJs7l2suIiKqHOwpIaKq7ckNIHgVcPN3IC9bdBoqQoaxJdq7uSBHkVPix3jbemOQzyB09+gOY5n2XB2eiIhKj0UJEemHrGQgbAdwaRPw9JboNFSIj5t0w9HkG8XuI5PI0NGtIwb5DEIzl2aVlIyIiCoaixIi0j/3z+UXJzd+54UYtchB7/aYLo8udJuVkRX61u2LAd4DOHmdiKgKYlFCRPorKxkI2/lP70nZJllT2WUZmaF9LTdk/VMoyiQytKrWCj1q90DnWp1hamAqOCEREVUUFiVERABw/zxwbRdw608gLVZ0Gr01o0l3PJJJ0d2jO4Lcg2Bvai86EhERVQIWJUREL1KpgIchwK0/gPA/gKS7ohPph2r+QP23kdugDwxt3UWnISKiSsaihIioOE9uAOH78wuUJ9dEp6k6pAZA9WaAT0/A5y3AtpboREREJBCLEiKikkqOyS9O7hzNH+7FSfKl4+gDeHQAPNoDtdoCJlaiExERkZZgUUJE9Dry5MCjECD6FBBzCnh4kddBeZlVjX+LkNrtAUte2JCIiArHooSIqDzkyYHY0Pzlhh+cz//KeCo6VeUxMAGcfACXRvnzQ2q3B+zriE5FREQ6gkUJEVFFSbkPxN/KX274+b9PbwO5GaKTlY2pbX7x4eL7z1cjwKEuIDMQnYyIiHQUixIiosqkUgEp94CnEUB8eP7V5Z9G5C9DnB4PqBSiE+YzcwCsa2h+2dXJL0Bs3ESnIyKiKoZFCRGRtlAqgcxEID0OSH8CpD3J//f5V9oTICcNUMgBRQ6gyAXy/vlXkZN/v0qp2aaBKWBkBhiZA0YW+f8amv37fyNzwML5heLDDbCuDhjyQoVERFR5WJQQEVUlSkV+oaJS5hcfUqnoRERERK/EooSIiIiIiITiR2hERERERCQUixIiIiIiIhKKRQkREREREQnFooSIiIiIiIRiUUJEREREREKxKCEiIiIiIqFYlBARERERkVAsSoiIiIiISCgWJUREREREJBSLEiIiIiIiEopFCRERERERCcWihIiIiIiIhGJRQkREREREQrEoISIiIiIioViUEBERERGRUCxKiIiIiIhIKBYlREREREQkFIsSIiIiIiISikUJEREREREJxaKEiIiIiIiEYlFCRERERERCsSghIiIiIiKhWJQQEREREZFQLEqIiIiIiEgoFiVERERERCQUixIiIiIiIhKKRQkREREREQnFooSIiIiIiIRiUUJEREREREKxKCEiIiIiIqFYlBARERERkVAsSoiIiIiISCgWJUREREREJBSLEiIiIiIiEopFCRERERERCcWihIiIiIiIhGJRQkREREREQrEoISIiIiIioViUEBERERGRUCxKiIiIiIhIKBYlREREREQkFIsSIiIiIiISikUJEREREREJxaKEiIiIiIiEYlFCRERERERC/T+jLVh8VAgPvQAAAABJRU5ErkJggg==", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAyUAAAGFCAYAAADjF1xYAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAaqdJREFUeJzt3XdYU2f/BvA7CXvvoaKIIOIAcY/WhQquVl9brVqr1bpn3f5srVZttY5W62hrrVrrfm2rtdZdfRUnKrgQEcEJIlN2IMnvD2pqZAgynoTcn+vi0uScPOc+IUC+ecaRqFQqFYiIiIiIiASRig5ARERERET6jUUJEREREREJxaKEiIiIiIiEYlFCRERERERCsSghIiIiIiKhWJQQEREREZFQLEqIiIiIiEgoFiVERERERCQUixIiIiIiIhKKRQkREREREQnFooSIiIiIiIRiUUJEREREREKxKCEiIiIiIqFYlBARERERkVAsSoiIiIiISCgWJUREREREJBSLEiIiIiIiEopFCRERERERCcWihIiIiIiIhGJRQkREREREQrEoISIiIiIioViUEBERERGRUCxKiIiIiIhIKBYlREREREQkFIsSIiIiIiISikUJEREREREJxaKEiIiIiIiEYlFCRERCzZs3D40bNy7x/jExMZBIJAgNDQUAnDhxAhKJBCkpKRWST9t06NABkydPLnM7iYmJcHJyQkxMTJnb0iUSiQS///47gIKvpcrwOsd8+Xvu7u6Ob775plxztWrVCnv27CnXNolKg0UJERGVq7Nnz0Imk6FHjx6Vcrw2bdogNjYW1tbWr93Gpk2bIJFIIJFIIJVKUaNGDXz44YeIj48vx6Tl49dff8WCBQvK3M6iRYvw9ttvw93dHcC/b5aff9nb26Nr1664cuVKmY+lrdzc3BAbG4uGDRuKjlIqFy9exMiRI8u1zU8++QSzZs2CUqks13aJSopFCRERlasNGzZgwoQJ+N///ofHjx9X+PGMjIzg4uICiURSpnasrKwQGxuLhw8fYv369fjrr78wePDgckpZfuzs7GBpaVmmNjIzM7FhwwYMHz68wLajR48iNjYWhw4dQnp6Orp161Zle6FkMhlcXFxgYGAgOkqpODo6wszMrFzb7NatG9LS0vDXX3+Va7tEJcWihIiIyk16ejp27tyJMWPGoEePHti0aVOBfRYvXgxnZ2dYWlpi+PDhyM7OLrDPjz/+CB8fH5iYmKBevXpYu3ZtkccsbPjW6dOn8eabb8LU1BRubm6YOHEiMjIyis0ukUjg4uKCatWqoVu3bpg4cSKOHj2KrKysV2Z63svw66+/omPHjjAzM4Ofnx/Onj2rcYz169fDzc0NZmZm6NOnD1asWAEbGxv19qFDh6J3794aj5k8eTI6dOigvl3YUJ4vvvgCw4YNg6WlJWrWrIkffvih2HM9cOAAjI2N0apVqwLb7O3t4eLigmbNmmHZsmV48uQJzp8/j88//7zQHoXGjRvj008/BQDk5eVh4sSJsLGxgb29PWbOnIkhQ4ZonFNOTg4mTpwIJycnmJiY4I033sDFixfV25OTkzFo0CA4OjrC1NQUXl5e2Lhxo3r7w4cPMWDAANjZ2cHc3BzNmjXD+fPn1dv37t2LJk2awMTEBB4eHpg/fz7y8vIKfR5eHkr1qmO/7ODBg3jjjTfU59uzZ09ERUVp7HPhwgX4+/vDxMQEzZo1K7Tn6fr16+jWrRssLCzg7OyMwYMHIyEhocjjvjx8a8WKFWjUqBHMzc3h5uaGsWPHIj09XeMxr/qZkMlk6N69O3bs2FHkcYkqEosSIiIqN7t27UK9evXg7e2N999/Hz/99BNUKpXG9nnz5uGLL75ASEgIXF1dCxQcW7duxdy5c7Fo0SKEh4fjiy++wKefforNmzeXKENUVBSCgoLQt29fXL16FTt37sTp06cxfvz4Up2LqakplEol8vLySpxpzpw5mDZtGkJDQ1G3bl0MGDBA/YY4ODgYo0ePxqRJkxAaGoouXbpg0aJFpcpUlOXLl6vf8I4dOxZjxoxBREREkfufOnUKTZs2fWW7pqamAAC5XI5hw4YhPDxco4C4cuUKrl69ig8//BAAsGTJEmzduhUbN25EcHAwnj17pp6/8dyMGTOwZ88ebN68GZcvX4anpycCAwORlJQEAPj0009x8+ZN/PXXXwgPD8e6devg4OAAIL/obd++PR49eoR9+/YhLCwMM2bMUA85OnXqFD744ANMmjQJN2/exPfff49NmzaV+Hku7tiFycjIwJQpUxASEoJjx45BKpWiT58+6jzp6eno2bMn6tevj0uXLmHevHmYNm2aRhspKSno1KkT/P39ERISgoMHD+LJkyfo169fiTIDgFQqxapVq3Djxg1s3rwZx48fx4wZM9TbS/oz0aJFC5w6darExyUqVyoiIqJy0qZNG9U333yjUqlUqtzcXJWDg4Pq77//Vm9v3bq1auzYsRqPadmypcrPz099u06dOqpt27Zp7LNgwQJV69atVSqVShUdHa0CoLpy5YpKpVKp/v77bxUAVXJyskqlUqmGDx+uGjlypMbjT506pZJKpaqsrKxCc2/cuFFlbW2tvn379m1V3bp1Vc2aNStVph9//FG9/caNGyoAqvDwcJVKpVL1799f1aNHD402Bg0apHHcIUOGqN5++22NfSZNmqRq3769+nb79u1VkyZNUt+uVauW6v3331ffViqVKicnJ9W6desKPVeVSqV6++23VcOGDdO47+XnNTk5WdWnTx+VhYWFKi4uTqVSqVTdunVTjRkzRv2YCRMmqDp06KC+7ezsrFq6dKn6dl5enqpmzZrqc0pPT1cZGhqqtm7dqt5HLperqlWrpvrqq69UKpVK1atXL9WHH35YaO7vv/9eZWlpqUpMTCx0e0BAgOqLL77QuG/Lli0qV1dX9W0Aqt9++63Qcy7u2CXx9OlTFQDVtWvX1Hnt7e01Xnfr1q3TOOaCBQtUXbt21WjnwYMHKgCqiIgIlUpV+Pf866+/LjLH7t27Vfb29urbJf2Z2Lt3r0oqlaoUCkWpzpuoPLCnhIiIykVERAQuXLiAAQMGAAAMDAzQv39/bNiwQb1PeHg4WrZsqfG41q1bq/+fkZGBqKgoDB8+HBYWFuqvhQsXFhgWU5SwsDBs2rRJ4/GBgYFQKpWIjo4u8nGpqamwsLCAmZkZvL294ezsjK1bt5Yqk6+vr/r/rq6uAKCeLB8REYEWLVpo7P/y7df14nGfD0MrbpJ+VlYWTExMCt3Wpk0bWFhYwNbWFmFhYdi5cyecnZ0BACNGjMD27duRnZ0NuVyObdu2YdiwYQDyn78nT55onJNMJtPokYmKikJubi7atm2rvs/Q0BAtWrRAeHg4AGDMmDHYsWMHGjdujBkzZuDMmTPqfUNDQ+Hv7w87O7tCs4eFheHzzz/X+D6NGDECsbGxyMzMLPL5eK64YxcmMjISAwYMgIeHB6ysrNSLBty/fx9A/uvd19dX47l+8fX+PPPff/+tkblevXrq56skjh49ioCAAFSvXh2WlpYYPHgwEhMT1edc0p+J572DOTk5JTouUXnSrZldRESktTZs2IC8vDxUq1ZNfZ9KpYKxsTFWr15dotWxno+DX79+fYHiRSaTlShHeno6Ro0ahYkTJxbYVrNmzSIfZ2lpicuXL0MqlcLV1VU9dOnJkyclzmRoaKj+//OJ96VZzUgqlWoMdwOA3NzcVz7uxeM+P3Zxx3VwcEBycnKh23bu3In69evD3t5eY74LAPTq1QvGxsb47bffYGRkhNzcXLzzzjuvzFca3bp1w71793DgwAEcOXIEAQEBGDduHJYtW6b+nhQlPT0d8+fPx3/+858C24oqwkp67ML06tULtWrVwvr161GtWjUolUo0bNgQcrm8ZCf7T+ZevXphyZIlBbY9L2yLExMTg549e2LMmDFYtGgR7OzscPr0aQwfPhxyuRxmZmYl/plISkqCubn5K59noorAooSIiMosLy8PP//8M5YvX46uXbtqbOvduze2b9+O0aNHw8fHB+fPn8cHH3yg3n7u3Dn1/52dnVGtWjXcvXsXgwYNeq0sTZo0wc2bN+Hp6Vmqx0ml0kIfUx6ZAMDb21tjPgaAArcdHR1x/fp1jftCQ0MLFB1l5e/vj19++aXQbW5ubqhTp06h2wwMDDBkyBBs3LgRRkZGeO+999RvYK2treHs7IyLFy+iXbt2AACFQoHLly+rr0NTp04dGBkZITg4GLVq1QKQX3RdvHhRY/K+o6MjhgwZgiFDhuDNN9/E9OnTsWzZMvj6+uLHH39EUlJSob0lTZo0QURERKm/9y8q6tgvS0xMREREBNavX48333wTQP5k8hf5+Phgy5YtyM7OVhdFL77en2fes2cP3N3dX2sVsEuXLkGpVGL58uWQSvMHwOzatavAMUryM3H9+nX4+/uXOgNReWBRQkREZbZ//34kJydj+PDhBXpE+vbtiw0bNqgneQ8dOhTNmjVD27ZtsXXrVty4cQMeHh7q/efPn4+JEyfC2toaQUFByMnJQUhICJKTkzFlypRXZpk5cyZatWqF8ePH46OPPoK5uTlu3ryJI0eOYPXq1a91fmXNBAATJkxAu3btsGLFCvTq1QvHjx/HX3/9pbGUcadOnbB06VL8/PPPaN26NX755ZcKeaMYGBiI2bNnIzk5Gba2tqV67EcffQQfHx8A+ZP3XzRhwgR8+eWX8PT0RL169fDtt98iOTlZfY7m5uYYM2YMpk+fDjs7O9SsWRNfffUVMjMz1csTz507F02bNkWDBg2Qk5OD/fv3q483YMAAfPHFF+jduze+/PJLuLq64sqVK6hWrRpat26NuXPnomfPnqhZsybeeecdSKVShIWF4fr161i4cOErz624Y7/M1tYW9vb2+OGHH+Dq6or79+9j1qxZGvsMHDgQc+bMwYgRIzB79mzExMQUKHDGjRuH9evXY8CAAZgxYwbs7Oxw584d7NixAz/++OMrewg9PT2Rm5uLb7/9Fr169UJwcDC+++47jX1K+jNx6tSpAh8qEFUWzikhIqIy27BhAzp37lzoEK2+ffsiJCQEV69eRf/+/fHpp59ixowZaNq0Ke7du4cxY8Zo7P/RRx/hxx9/xMaNG9GoUSO0b98emzZtQu3atUuUxdfXFydPnsTt27fx5ptvwt/fH3PnztUYVlZaZc0EAG3btsV3332HFStWwM/PDwcPHsTHH3+sMawoMDBQ/fw0b94caWlpGr1K5aVRo0Zo0qRJgU/US8LLywtt2rRBvXr1CgxnmzlzJgYMGIAPPvgArVu3Vs9dePEcFy9ejL59+2Lw4MFo0qQJ7ty5g0OHDqmLIyMjI8yePRu+vr5o164dZDKZeplaIyMjHD58GE5OTujevTsaNWqExYsXq9+4BwYGYv/+/Th8+DCaN2+OVq1a4euvv1b3yrxKccd+mVQqxY4dO3Dp0iU0bNgQH3/8MZYuXaqxj4WFBf744w9cu3YN/v7+mDNnToFhWtWqVUNwcDAUCgW6du2KRo0aYfLkybCxsVH3fBTHz88PK1aswJIlS9CwYUNs3boVX375pcY+JfmZePToEc6cOaNeSY2osklULw9eJSIiokoxYsQI3Lp1S8gyrH/++SemT5+O69evl+jN73MqlQpeXl4YO3bsK3uJlEolfHx80K9fv3K5Cj1VnJkzZyI5OfmV17ghqigcvkVERFRJli1bhi5dusDc3Bx//fUXNm/eXOyFIStSjx49EBkZiUePHsHNza1Ej3n69Cl27NiBuLi4Qj9Rv3fvHg4fPoz27dsjJycHq1evRnR0NAYOHFje8amcOTk5lXgoIlFFYE8JERFRJenXrx9OnDiBtLQ0eHh4YMKECRg9erToWCUmkUjg4OCAlStXFlpoPHjwAO+99x6uX78OlUqFhg0bYvHixeqJ70RERWFRQkREREREQnGiOxERERERCcWihIiIiIiIhGJRQkREREREQnH1LSIiLZadq0BShhxJGXIkZ/7zb4YcSZm5SM2UI0+pglQigVQCSKWSf/8vkUDywv+lkvxJykYGUtibG8HR0hhOliZwtDSGg4URDGT8jIqIiMRhUUJEJEh2rgJ34tMRGZ+GyCfpeJSSpS4+kjNykZQhR1auosJzSCWArVl+ofJiseL0z+1qNqbwcraAlYlhhWchIiL9xNW3iIgqWJY8v/i4/SQNkfHpiPzn34fJmVDq0G9gV2sTeDlboq6TBeq6WMLb2RLeLpYwMZSJjkZERDqORQkRUTlKy87FhegkXIxJxu0nabj9JA2PUrJQVX/TGkgl8HSyQKPq1mhUwxoNq1ujvqsVCxUiIioVFiVERGWQJVcg5F4SzkQl4kxUIq4/SoVCl7o/KoCBVIKG1a3Rrq4j2td1QGM3W8ikEtGxiIhIi7EoISIqBXmeEpfvJ+NsVCLORiUi9EEK5Aql6FhazcrEAG09HdCuriPa1XVEdRtT0ZGIiEjLsCghInqFiLg0HA1/gjNRCbh0LxnZuSxCysLD0RztvBzRvq4jWnnYw9SIQ72IiPQdixIiokI8SMrEvrDH2Bf6GBFP0kTHqbKMDKRo7m6Ljt5OeKtxNThZmoiOREREArAoISL6R0J6Dv68Gou9oY9w+X6K6Dh6RyaVoJ2XA95p6obO9Z1gbMAeFCIifcGihIj0WnpOHg5ej8Pe0Ec4E5Wo95PUtYW1qSF6+bmib5Ma8K9pKzoOERFVMBYlRKR35HlKHL/1BHtDH+P4rXjk5HGOiDar42iOvk1r4D/+NeBizeFdRERVEYsSItIbKZly/HLuHjafvYenaTmi41ApSSVAW08HvNO0BgIbuPBaKEREVQiLEiKq8u4nZmLD6bvYfekhMuUK0XGoHNiaGWJIG3d82KY2rM0MRcchIqIyYlFCRFXWpXvJ+PHUXRy6EQdOFamazI1kGNSqFj56ozacrDi0i4hIV7EoIaIqRalU4fDNOPzwv7tcQUuPGBlI8U7TGhjdrg5q2puJjkNERKXEooSIqoQsuQK7Lz3AT6ejEZOYKToOCSKTStDT1xVjO3jC28VSdBwiIiohFiVEpNNy8hTYfCYG605EITkzV3Qc0hISCRBQzwljO3qiCZcUJiLSeixKiEgnKZUq/HblEVYcuY1HKVmi45AWa+1hjxlB3rzeCRGRFmNRQkQ65++IeCz56xZuxaWJjkI6QiIB/uNfAzO7ecPJkhPiiYi0DYsSItIZt+KeYcH+mwi+kyg6CukoS2MDTAjwxIdta8NQJhUdh4iI/sGihIi0XkqmHMsP38a2C/eh4Nq+VA48HM0xt2d9dPB2Eh2FiIjAooSItJhCqcIv5+7h66O3kcJJ7FQBAuo5YW6v+qhlby46ChGRXmNRQkRaKSQmCXN+u46IJ5w3QhXLyECK4W/UxoROnjAzMhAdh4hIL7EoISKtkpOnwPLDt/Hjqbu8CjtVKhcrE8zqVg+9/auLjkJEpHdYlBCR1rj+KBVTdoXi9pN00VFIjwXUc8KSd3zhYGEsOgoRkd5gUUJEwuUplFjzdxRW/x2JXAV/JZF4DhZG+OodX3Sq5yw6ChGRXmBRQkRC3YlPw5RdYbj6MFV0FKICBreqhTk9fGBiKBMdhYioSmNRQkRCKJUqbDgdjWWHI5CTpxQdh6hInk4WWPleYzSoZi06ChFRlcWihIgq3YOkTEzdHYYL0UmioxCViJFMiqld62LEmx6QSiWi4xARVTksSoioUm2/cB8L999EhlwhOgpRqbX2sMeK/n5wtTYVHYWIqEphUUJElUKep8Sc365h96WHoqMQlYm1qSEW9WmInr7VREchIqoyWJQQUYV7mpaD0b9cwqV7yaKjEJWbAS3c8PnbDWEok4qOQkSk81iUEFGFuvYwFSO3hCA2NVt0FKJy18rDDt+93xQ2ZkaioxAR6TQWJURUYfaGPsLMPVeRncvVtajqqu1gjp+GNkdtB3PRUYiIdBaLEiIqd0qlCksPR2DdiSjRUYgqhY2ZIdYNaorWdexFRyEi0kksSoioXKVl52LyjlAcuxUvOgpRpTKUSbCodyP0a+4mOgoRkc5hUUJE5SYmIQMf/RyCO/HpoqMQCTOqvQdmBdWDRMLrmRARlRSLEiIqF6cjEzBu22WkZuWKjkIkXGADZ3zT3x+mRjLRUYiIdAKLEiIqs7+uxWLijivIVfDXCdFzDatbYcOQ5nC2MhEdhYhI67EoIaIy2Rv6CFN3hSFPyV8lRC9zsTLBxg+bw8fVSnQUIiKtxqKEiF7bnksPMf2/YWA9QlQ0O3MjbBvREvVcWJgQERWFl6Elotey48J9FiREJZCUIceg9edx+0ma6ChERFqLRQkRldqWszGY/ds1FiREJZSYIcfA9ecQycKEiKhQHL5FRKWy4XQ0Fuy/KToGkU5ysDDGjpEt4elkKToKEZFWYVFCRCW27kQUlhy8JToGkU5ztDTG9hGt4OlkIToKEZHWYFFCRCWy8mgkvj56W3QMoirB0dIYO0a2Qh1HFiZERACLEiIqgeWHI/Dt8TuiYxBVKU7/FCYeLEyIiDjRnYiKtzE4mgUJUQWIT8vBgPXnEJ2QIToKEZFwLEqIqEhHbj7hpHaiCvTkWQ4G/HAO9xMzRUchIhKKRQkRFeraw1RM2nGFy/4SVbC4Z9kYuvECUjLloqMQEQnDooSICniUkoVhmy8iU64QHYVIL9xNyMCoLZcgz1OKjkJEJASLEiLSkJadi2EbL+JpWo7oKER65Xx0Emb9elV0DCIiIViUEJFankKJsVsvI4JXnSYS4tfLj7DqWKToGERElY5FCRGpzfntOk5FJoiOQaTXVhy5jb2hj0THICKqVCxKiAgAsObvO9gZ8kB0DCICMHPPVVx/lCo6BhFRpWFRQkTYF/YYyw5HiI5BRP/IzlVi5M8hSEjn3C4i0g+8ojuRngt9kIJ+35/lqj9VROq53Ug5uRmWTd+CXeeRGttUKhXid89DdvQlOPaZA7O6rQttQ6XIQ8qpLciKCkFeahykxuYwqeUHm/ZDYWBpn79PXi4SD65CZuQ5yMxtYdd1LEzdG/+b4/weKJ49hV2X0RV2rvqghbsdto5oCUMZP0MkoqqNv+WI9Fhadi4mbr/CgqSKyIm9jbTQgzB0dC90e1rIXkDy6nZUeTmQx0XBus17cB2yEo69/w+5SY/w9NcF/7YVdhDyuDtweX8ZLPyCkPDHUjz/jCs3JQ7pYYdg0+6D8jgtvXYhJgmf/8ELmBJR1ceihEiPffr7ddxP4pWkqwKlPAsJfyyDfdAESE0sCmyXP7mLZxd+g0O3ya9sS2psDuf3FsLc500Y2teAcfV6sOsyGvK4O8h7Fg8AyE18AFPPljByrAXLJj2gzEyFMusZACDp8FrYdhgKqbFZuZ6jvtpy7h52XrwvOgYRUYViUUKkp369/BC/hz4WHYPKSdKRdTCt01xjCNVzytxsJPyxFHZdx0BmYfta7StzMgFIIDXOL3iMnGoj5+FNKHNzkB19GTILO0hNrZB+429IDIxgVrdNGc6GXjZv301EJ2SIjkFEVGFYlBDpoXuJGZi794boGFROMm6ehDwuCrbthxS6PfnYjzCu7gMzr1av1b4qT46UExthVr+duvfDolEXGDrVxuMNY5F6dhcc3p4JZXY6Uk9vhV3nUUj+3xY8+n4Enuz8FHlpXGa6rLJyFZi8MxR5Cg61JKKqiUUJkZ7JVSgxcfsVpOfkiY5C5SDv2VMkHVsPh17TIDEwKrA9M/I8su+HwTZgxGu1r1Lk4enexQAA+67j1PdLZAaw7zoGNUZvgOuQr2FSowGSj2+AZdNekD+5i6zIs3D98FsYV6uH5KM/vN7JkYawBylY83eU6BhERBWCq28R6ZnFf93Cdyf5xqaqyLx9Fk9/WwRIXviMSaUEIAEkElj6d0fa5T8BiURzu0QK4xr14TJwcZFtPy9I8lLi4DzgC8hMrYrcN/veVSSf3AiX95ch+e+fIJHKYNtxGORP7+HJtllwm7S9HM6WDKQS/Dq2DXxr2IiOQkRUrgxEByCiyhN8JwHf/48FSVViUssPrsNWa9yXeGAlDO1rwKplX8hMrWHROEhje+xP42Hb6SOYerYosl11QZL8GM4Dviy2IFHlyZF0ZF1+b41UBqiU+XURACgVUKk45Ki85ClVmLwzFAcmvgkTQ5noOERE5YbDt4j0RFKGHB/vDAX7RqsWqbEZjBzdNb4khsaQmljCyNEdMgvbAtsBwMDKEYY2Lup2Hq0fjczbZwD8U5D8/iXkcXfg0GsaoFRCkZ4MRXoyVIrcAhlSzuyAqUczGDnXAQAYV6+PzNtnII+PRtrl/TCp7lPxT4Qeufs0A18eCBcdg4ioXLGnhEhPTN8dhvg0Xh2aCpeX9PCfFbYARXoisu6cBwDEbpyosZ/zgC9gUtNXfVv+NAaZt07Bdei36vvM6rVF9oNriNs6E4b21eHQa3olnIF++fncPQT4OKNdXUfRUYiIygXnlBDpgc1nYvDZPq62RVSVOFsZ49DkdrAxK7jAARGRruHwLaIqLjY1C0sO3hIdg4jK2ZNnOfjk9+uiYxARlQsWJURV3MI/w5EpV4iOQUQVYP/VWOwNfSQ6BhFRmbEoIarCgu8k4M+rsaJjEFEFmrv3BpIy5KJjEBGVCYsSoioqV6HE3L0c2kFU1aVm5WLFkQjRMYiIyoRFCVEV9dPpaEQ9zRAdg4gqwfYLDxARlyY6BhHRa2NRQlQFxaVmY9WxSNExiKiSKJQqLNh/U3QMIqLXxqKEqApa+OdNZHByO5FeOX0nAUdvPhEdg4jotbAoIapiztxJwH5ObifSS4sOhCNXoRQdg4io1FiUEFUhuQolL5JIpMeiEzKw+UyM6BhERKXGooSoCtkYHI3I+HTRMYhIoJXHIrlEMBHpHBYlRFVE/LNsrDp2R3QMIhIsLTsPyw9ziWAi0i0sSoiqiLUnopCekyc6BhFpgR0XH+BW3DPRMYiISoxFCVEV8DQtB9sv3Bcdg4i0BJcIJiJdw6KEqApYf+oucvK44g4R/Sv4TiL+jogXHYOIqERYlBDpuKQMOX45d090DCLSQutORImOQERUIixKiHTchtN3kckLJRJRIS5EJ+Hy/WTRMYiIXolFCZEOS83Kxc9n2EtCREX7/iR7S4hI+7EoIdJhm4JjkMYVt4ioGEduPsHdp7x+ERFpNxYlRDoqPScPG89Ei45BRFpOqQJ++N9d0TGIiIrFooRIR205ew8pmbmiYxCRDvj1yiPEp2WLjkFEVCQWJUQ6KEuuwIbT/OSTiEpGnqfET6djRMcgIioSixIiHbTtwn0kpMtFxyAiHbL1/D2kcw4aEWkpFiVEOiZPocSPp9hLQkSlk5adh23nuVofEWknFiVEOubYrXjEpnJsOBGV3k+nYyDPU4qOQURUAIsSIh2z/cJ90RGISEfFPcvG76GPRMcgIiqARQmRDnmYnIn/3X4qOgYR6bCt5/nBBhFpHxYlRDpk58UHUKpEpyAiXRb2IIUXUyQircOihEhHKJQq7Ap5IDoGEVUBv1/hEC4i0i4sSoh0RHrUWQyzvQZTmUJ0FCLScb+FPoJKxW5XItIeLEqIdIT15XUY9WQeblh9jD+8/kRXhyTRkYhIRz1IykLIvWTRMYiI1CQqflRCpP2ykoFldQGF5gUTMx18ccS4CxY/aoTYbCNB4YhIFw1sWRNf9GkkOgYREQAWJUS64eKPwJ9Ti9ysMjDFQ5dO+DnrTfz42A0qlaQSwxGRLrI2NcSFOQEwNpCJjkJExKKESCesDwAehZRo1zwrN1y0DsLS+Ga4nGpZwcGISJd9934TBDV0FR2DiIhFCZHWS4oGVjUu9cNUkCDVpTX2Sjph2YO6SMszKP9sRKTTAhs44/vBzUTHICLiRHcirRdx4LUeJoEKNnFnMCR2Ia6aT8Ahr9/Rxzm+nMMRkS77+9ZTpGTKX70jEVEFY1FCpO0i/ipzE5KcVHg/2IWvUyfjVrUF+M7zPDzMssshHBHpMrlCif1XY0XHICLi8C0irZaVAiytAyjzyr1plcwIT1w6YLu8HdY+qo1cJSfHE+mjZrVs8d8xbUTHICI9x6KESJtd+y+wZ3iFH0Zh7oJQuyB8ndgSp5OsK/x4RKQ9JBIgZE5n2FsYi45CRHqMw7eItNlrzicpLVlGHJo+2IRfMsfgWs0VWOJxFfZGuZVybCISS6UCgqMSRccgIj3HooRIWylygTtHK/2wlvEh6P94MUJMxuK4524MdH1c6RmIqHIFRyaIjkBEeo7Dt4i01d2TwM9viU4BAJDb1EGwZRC+ivVHeLqZ6DhEVM6q25gieFYn0TGISI+xp4RIW5XDqlvlxSglCh0frMEBxSiE1P4Bs2rdhqlMIToWEZWTRylZiE7IEB2DiPQYixIibXVbe4qS5yQqBRxiT2D0k3m4YfUx/vD6E10ckkTHIqJycPoOh3ARkTgcvkWkjRIigdW6c5XlDAc/HDHugiWPGiI220h0HCJ6DUENXPDd4KaiYxCRnjIQHYCICnH/rOgEpWKeEIbeCMPbBqZ46BmAn7PewI+P3aBS8donRLriTFQClEoVpFL+3BJR5ePwLSJt9OC86ASvRZKXBbeH+zEncRYiHWdhm9cJNLFOFx2LiErgWXYerj5KFR2DiPQUixIibfTggugEZWbw7AHaPPgBe+SjccV9DebVDoelQflfmZ6Iyk8w55UQkSCcU0KkbTKTgK88AFS9H02liQ1uOwZi3bPW2PvESXQcInpJKw877BjZWnQMItJDLEqItM3tQ8C2fqJTVLhs+/r427QLljzyQ0yWieg4RATAyECKsLldYWokEx2FiPQMh28RaZv750QnqBQmiTfR7eFK/C0djbN1NmFizbswlPIzEiKR5HlKXHmQLDoGEekhFiVE2qYKzCcpDYlCDtdHhzEl/hPcspuGPV5H0NaWk22JRLkVmyY6AhHpIRYlRNpEkQc8viw6hTCy9Fg0fbARW7PG4GrNr7HY4xrsjXJFxyLSKxFxLEqIqPLxOiVE2iTuKpCbKTqFVrCKv4j3cBH9TSxwt2ZX/JjRBttjq4mORVTl3XrCooSIKh97Soi0ycMQ0Qm0jkSejjoPf8WXydNw22UufvIKRj0LFm5EFSXySRq4Bg4RVTb2lBBpk6e3RCfQakYpd9Ap5Q46Sg2QUPtN7FZ2wLcPPZCl4EpBROUlU67A/aRM1LI3Fx2FiPQIe0qItEnCbdEJdIJEmQfH2L8x9slnuGH9MfZ5HUCAfZLoWERVxi3OKyGiSsaihEibJN4RnUDnSDMT4PvgF2zIGI8bNZbg6zqX4WIsFx2LSKdxsjsRVTYO3yLSFjlpQFqs6BQ6zTwhDH0Qht6Gpnjg1hk/Z72BDY9rQKWSiI5GpFNYlBBRZWNPCZG2SIgUnaDKkORloebDP/BJ4kxEOs7GNq8TaGyVLjoWkc64FfdMdAQi0jMsSoi0BYduVQiDZ/fR5sEP+C13NK64r8Fn7uEwN1CIjkWk1WISM5Gdy58TIqo8LEqItAUnuVcoiUoJ27hgfBi3ANcsJuCg11685RQvOhaRVlIoVbgTz95FIqo8LEqItAWHb1UaaXYK6j3YiVXPJiO8+iKs87wAd9Ns0bGItArnlRBRZeJEdyJtweFbQpgm3kA33ECQzAhxdTpim7wd1j6sBYWKn9mQfnuYnCU6AhHpEf7VJdIGKhWQGCU6hV6TKORwfXQIU5/OwW37Gfiv1xG0tk0VHYtImIT0HNERiEiPsCgh0gaZSUAeP5XUFrL0x2j2YCO2ZY3F1Vrf4AuPa7A1zBMdi6hSJWawKCGiysPhW0TaIDNBdAIqhAQqWD25gIG4gAGmFohyD8SP6W2wI9ZVdDSiCpeQxouQElHlYU8JkTbIYFGi7STydHg+2IPFyVNx22UufvIKRl1z9m5R1ZXAnhIiqkTsKSHSBuwp0SlGKXfQKeUOOkoNkODRDrsU7bHqQR3kKPk5D1UdCWksSoio8vAvKJE2yHgqOgG9BokyD46Pj2Pck88QbvMx9nr9hQD7JNGxiMrFs+w8yPOUomMQkZ5gUUKkDTISRSegMpJmPoXfgy3YkDEeN9y+woo6V+BizDH5pNs42Z2IKguHbxFpAw7fqlLMn4biPwhFHyMz3HfrjM1ZbbHxcQ2oVBLR0YhKJTFdDldrU9ExiEgPsKeESBtwonuVJMnNRK2H+zA3cSZuO/4ftnqdRGOrdNGxiErsKa9VQkSVhD0lRNqAPSVVnuGze2j77Hu0kUiR7N4Gv0k6YsWDusjIk4mORlSkxHQOQSSiysGihEgbcE6J3pColLCLO43hOI0PLWwR4RiINSmtsf+po+hoRAXwqu5EVFk4fItIG2Snik5AAkizk+HzYAdWp01CePVFWOd5ATVNs0XHIlJLymBPCRFVDvaUEGkDZZ7oBCSYaeINdMMNBMmMEVunI7bK2+G7hzWhUPGzIxInO1chOgIR6Qn+tSPSBir+4ad8EkUOqj06iOlP/w8RDjOx2+soWtuyJ43EyFOqREcgIj3BooRIGyhZlFBBBmmP0PzBT9iWNRZhtVbii9rXYGvIXjWqPAoFixIiqhwcvkWkDdhTQsWQQAXrJ+cxEOcxwMwSUU5d8UNaW+yKcxEdjao4hYpFCRFVDvaUEGkDpVJ0AtIRkpw0eD7Yg69SpuC262fY4HUGdc2zRMeiKkrB4VtEVEnYU0KkDdhTQq/BKDkSAcmR6CQ1wFOPdtiV1wHfPvRAjpKfN1H54JwSIqosLEqItAHnlFAZSJR5cHp8HONxHEMda+L9Wj5QgK8pKjsn59YA/EXHICI9wKKESBuoOHyLyodF6n04SOvifOpt0VGoCqjv4Ck6AhHpCfbxE2kDDt+ictQtV3QCqipkEpnoCESkJ1iUEGkD9pRQOeocfQkGUnaEU9nJpCxKiKhysCgh0gaGZqITUBVinZmMVlYcdkNlx54SIqosLEqItIGxlegEVMV0y+aQQCo7qYRvE4iocvC3DZE2MGFRQuWrU/RFGEmNRMcgHWdmwF5cIqocLEqItAF7SqicWWQ/Q1sO4aIysjGxER2BiPQEixIibcCeEqoA3bKyRUcgHWdrbCs6AhHpCRYlRNqAPSVUAdrfvQhTmYnoGKTD2FNCRJWFRQmRNmBPCVUAM3kG3rSqIzoG6TD2lBBRZWFRQqQN2FNCFaRberroCKTD2FNCRJWFRQmRNjCxFp2Aqqg3716EOVdQotfEnhIiqiwsSoi0AXtKqIIY52Wjg6WH6Bikg6QSKayN+YEJEVUOFiVE2sDcQXQCqsK6PUsVHYF0kJWRFS+eSESVhr9tiLSBrbvoBFSFtYm+CEtDC9ExSMfYGNuIjkBEeoRFCZE2YFFCFchQIUeARW3RMUjH2JpwPgkRVR4WJUTawNwBMOIn2VRxuqUkio5AOsbBlMNKiajysCgh0hY2tUQnoCqsRUwIbI04aZlKrpYVfycRUeVhUUKkLWz5BoAqjoEyD53Na4qOQTqktjWH/BFR5WFRQqQtOK+EKli3pHjREUiH1LZiUUJElYdFCZG24PAtqmBN712Co4md6BikI9hTQkSViUUJkbZgTwlVMKlKiS4m1UXHIB3gZOoECy6+QUSViEUJkbbgnBKqBN0SHouOQDqAvSREVNlYlBBpC1t3gFdPpgrm9yAULqaOomOQlnO3dhcdgYj0DN8BEWkLQ1PAzkN0CqriJFAh0NhFdAzScuwpIaLKxqKESJu4+IpOQHqgW/x90RFIy7EoIaLKxqKESJu4siihitfg0TW4mbG3hIrmYc1eWyKqXCxKiLQJe0qokgQacl4JFc7MwAzOZs6iYxCRnmFRQqRNXP1EJyA9ERR3V3QE0lKNHBpBIpGIjkFEeoZFCZE2MXcAbGqKTkF6wDsuHLXNec0SKqiJcxPREYhID7EoIdI21ZuJTkB6IkhmKzoCaSEWJUQkAosSIm1To7noBKQngmJvi45AWsZAYgBfB85tI6LKx6KESNvUYE8JVQ6P+Duoa8HhgvSvenb1YGZoJjoGEekhFiVE2sbVD5AZiU5BeiJIaik6AmkRDt0iIlFYlBBpGwNjoEYL0SlITwQ9vCk6AmkRFiVEJAqLEiJt5BkgOgHpCbfEe2hgxat3EyCBBE2cWJQQkRgsSoi0EYsSqkRBKlPREUgL1LauDVsTrshGRGKwKCHSRi6+gLmT6BSkJ4LuX4cEvFievvN38hcdgYj0GIsSIm0kkQB1OolOQXrCJeUh/Kw8RMcgwZo6NxUdgYj0GIsSIm3FIVxUiYIUXPFNn0klUrSp1kZ0DCLSYyxKiLRVnU4Ah9RQJel6PwxSCf8k6KvGjo1hb2ovOgYR6TH+BSLSVuYO+dcsIaoEjs/i0NSqjugYJEiXWl1ERyAiPceihEibcQgXVaKgXP5J0Feda3UWHYGI9Bz/AhFpM0++UaDK0yXmMgwkBqJjUCVrYN8ALuYuomMQkZ5jUUKkzdxaAhbOolOQnrDNSEQLaw7h0jfsJSEibcCPxIi0mVQGNOwLnFsrOgnpiaAcFc6IDvGCjIgMJBxIQNa9LOSl5KHmhJqwamql3p6Xmoe4XXFIv5EORaYC5nXN4fq+K4xdjIttN+FQApL+TkJuYi5kljJYN7OG8zvOkBrlf1aXciYFcf+NgzJbCds3beE6wFX9WPlTOWKWxaDOvDqQmcoq5sQrUeeaLEqISDz2lBBpu0bvik5AeiQg+iIMpYaiY6gpc5QwqWmCaoOrFdimUqlwb9U9yJ/KUXNiTXjO94ShgyFilsZAmaMsss2Usyl4svsJnN52gtcXXqg+rDpSL6TiyZ4nAIC8tDw82vgIrv1d4T7NHSlnUvAs9Jn68Y+3PIbzu85VoiCpY10H7tbuomMQEbEoIdJ61ZsA9l6iU5CesMpKRRsrT9Ex1Cx9LeHc11mjd+Q5+RM5sqKyUG1INZh5mMHY1RjVPqgGpVyJlHMpRbaZeScTZl5msGltAyNHI1g2tIR1S2tk3c3Kb/epHDJTGaxbWsPMwwzmPubIeZwDAEg5lwKJTALrZtYVcr6VjUO3iEhbsCgh0gW+/UQnID0SmCUXHaFEVLkqAIDE8N/r+UikEkgMJci8nVnk48w8zZAVk4XMu/n7yOPlSL+aDgtfCwCAsbMxlHJl/pCx9DxkRWfBxM0EigwF4n+Nh+v7rkW2rWtYlBCRtuCcEiJd0Ogd4O9FolOQnugUHQJjNxfkKHJERymWsasxDO0N8WT3E1QfWh0SYwkSDyUiLykPeal5RT7OprUNFOkKRC+KhgoqQAHYdbSDUy8nAIDMXIYaI2rg4fqHUMlVsGljA8tGlni44SHsAuyQm5CL+yvvQ6VQwam3E6yb62avSQ2LGqhnV090DCIiACxKiHSDnQdQoznw8KLoJKQHzHPS8KbVGziafEN0lGJJDCSoOaEmHm14hPBx4YAUsKhvkd/joSr6cenh6Xj6x1O4fuAKMw8zyOPliN0ai/i98XB6O78wsWpqpTFkLONWBnIe5qDa+9Vwe+ZtuI12g4G1AaI+j4K5tzkMrHTvz2lvz96iIxARqeneb1EifeXbn0UJVZrA9EwcFR2iBEzdTeG5wBOKTAVUeSoYWOUXCqbupkU+Jv63eNi0sYFdezsAgImbCZQ5Sjza9AiOvRwhkUo09lfmKvH458eoMbIG5PFyqBQqmNczBwAYuxgjMyoTVv4F57xoMwOpAfrW7Ss6BhGRGueUEOmKBv8BpPwcgSpH+5iLMDUo+o29tpGZyWBgZYCcuBxkRWfBsollkfsqc5QF//oV89fw6b6nsGhkAVN3U6iUKuCFhb1UeZq3dUVHt45wMHUQHYOISI1FCZGuMLfnFd6p0pjKM9HBUvyFFBXZCmTdy0LWvX9WxkqQI+teFuSJ+ZPxUy+kIj08HfJ4OZ5dfoaYpTGwamIFy4b/FiUPf3iIuN1x6tuWjS2RdDwJKedSIH8qR/r1dMT/Gg/LxpYFekmyH2Uj9UIqnP+TfxFTY1djQAIknUxCWmgacmJzYOqhO8Xbc/29+4uOQESkgR+7EumSZsOB2wdFpyA9EZj2DH8JzpAVnYWYJTHq23Hb84sLm7Y2qDGiBvJS8xC7IxaKVAUMbAxg08YGjm87arQhT5QDL9QaTm85QSKRIP7XeOQm58LA0gCWjfOXHn6RSqXC402P4TLABVLj/M/wpEZSVP+oOmK3xEKVq4LrYFcY2mrPdV1Kwt3KHS1dW4qOQUSkQaJSqYqZDkhEWkWlAlY3BxIjRScpN+suyrEuRI6YlPwxMA2cZJjbzgjdvP59o3f2QR7mHM/B+UcKyCRAYxcZDr1vBlNDSVHNYs0FOZaeyUFcugp+LlJ8280ULar/e7G7KYeysSlUDnMjCRYHmGCQ77/H230jFz9fzcUfA8wq4Ix1h1xmjA516iAtN110FCpH05tNxwcNPhAdg4hIA4dvEekSiQRoOUp0inJVw0qCxZ2NcWmkOUJGmqOTuwxv78jCjXgFgPyCJGhrJrrWMcCFj8xxcYQ5xrcwgrToegQ7r+diyuFsfNbeGJdHmcPPWYbAXzIQn5Ff+PwRkYtt13JxeLA5vupsgo/+yEJCZv621GwV5hzPwZruJhV+7trOSJGDjha1RcegcmQiM8Hbnm+LjkFEVACLEiJd03ggYGIjOkW56eVtiO5ehvCyl6GuvQyLAkxgYQSce5hflHx8KAcTWxhh1hvGaOAkg7eDDP0aGMLYoOiqZMW5HIxoYogP/Y1Q31GG73qawMxQgp+u5AIAwhOU6OAuQ7NqMgxoZAgrYwmik/M7jWccycaYZoaoac1fjwAQmJokOgKVo67uXWFtrJvXVSGiqo1/dYl0jZE50HSI6BQVQqFUYcf1XGTkAq3dZIjPUOL8IwWczKVosyEDzsvS0H5TBk7fL/rCeHKFCpceK9HZ498pc1KJBJ09DHD2n0LHz1mGkMcKJGepcOmxAlm5KnjaSXH6fh4uxykwsaVRhZ+rrmgdHQJrI91a7paKxgnuRKStWJQQ6aIWI6vU8sDXnihg8cUzGC9Mw+j9WfitvynqO8pwNzl/SNW8k/k9HwcHmaGJiwwBP2ciMlFRaFsJmSooVICzuWZPirO5BHHp+e0FehrgfV9DNF+fjqF7s7C5tynMjYAxf2bjux6mWBeSC+/V6Wj7U4Z6GJm+MlTmorN5LdExqBz42PnA19FXdAwiokKxKCHSRdY1AJ9eolOUG28HKUJHW+D8R+YY08wIQ37Pxs2nCij/WYZjVNP8oVj+rjJ8HWQCb3upeijW65rXwQR3Jlri2hgL9PExxJen5Ohc2wCGMmDh/3Jw+kMzfORviA9+zyqHM9RtQclPRUegcjCg3gDREYiIisSihEhXtRonOkG5MZJJ4GknRdNqMnzZ2QR+zlKsPCeHq0X+r6j6jpq/qnwcpbj/rPAr1jmYSSCTAE8yNBcWfJKhgotF4b/ybiUo8Mu1XCzoZIwTMXloV0sGR3Mp+jUwxOVYJdJy9HuRwuYxl2BnbCs6BpVBdYvq6FWn6nyQQURVD4sSIl3l1hyo3kx0igqhVAE5CsDdRoJqlhJEJGgWILcTlahVxER0I5kETatJcezuv/NOlCoVjt3NQ+sasgL7q1QqjNqfjRVdjWFhJIFCCeT+c7jn/yr0uyaBTKVAF7MaomNQGYzyHQWDKjTkk4iqHhYlRLqs7STRCcps9tFs/O9eHmJSlLj2RIHZR7NxIkaBQY0MIZFIML2NEVZdkOO/N3NxJ0mJT49n41aCEsP9/52MHvBzBlZfkKtvT2lljPWXc7E5VI7wpwqM2Z+NjFwVPmxc8CJ3P17OhaOZBL2887e1rWmA49F5OPcwD1+fzUF9RylsTIpZf1hPdEuIe/VOpJVqWNRgLwkRaT1+bEKky3x6Aa6NgdhQ0UleW3yGCh/8loXYdBWsjSXwdZbi0Ptm6FIn/9fT5FbGyM4DPj6UjaQsFfycZTgy2Ax17P79TCUqSam+zggA9G9oiKeZKsw9kX/xxMYuUhwcZAbnl4ZvPUlXYtGpHJwZbq6+r0V1Gaa2NkaPbVlwMpdgc2/TCn4GdEOT+5fh5OOP+OwE0VGolEb6jmQvCRFpPV7RnUjX3TkK/NJXdArSA0v8e+CXlGuiY1ApuFm6YV/vfSxKiEjrcfgWka7z7AzUekN0CtID3Z4+FB2BSom9JESkK1iUEFUFAXNFJyA94PswDNXNnEXHoBKqaVkTvTw4l4SIdAOLEqKqoGZLoG6Q6BSkB7oaOYmOQCU00nckZNKCK84REWkjFiVEVUWnTwFwlSiqWN3iokVHoBKoZVULPT16io5BRFRiLEqIqgqXhkBDTniniuUTexO1zKuJjkGvMMp3FHtJiEinsCghqko6/h/ASa1UwQIN7EVHoGI0dmzMXhIi0jksSoiqEvs6QJMholNQFdct9o7oCFQEmUSGT1p9AomEQzmJSLewKCGqagI+BcwcRKegKszzSQQ8LdxEx6BC9PfuD287b9ExiIhKjUUJUVVjagsELhKdgqq4QKm16Aj0EgdTB4z3Hy86BhHRa2FRQlQV+b0HuL8pOgVVYd0e3RIdgV4ypekUWBpZio5BRPRaWJQQVVU9vwZkRqJTUBVVK+EufCxriY5B/2jq3BS96vBCiUSku1iUEFVVDl5A28miU1AVFggL0REIgIHEAHNazhEdg4ioTFiUEFVlb04F7DxEp6AqKujhDdERCMBAn4HwsvUSHYOIqExYlBBVZYYmQI/lolNQFVU96T58rVj0iuRk6oSxjceKjkFEVGYsSoiqujqdeKV3qjCBShPREfTajBYzYG5oLjoGEVGZsSgh0geBXwKmdqJTUBUUeP8qJOCF+kToVrsbAt0DRccgIioXLEqI9IGlM/D2GtEpqApyTn0Mf+s6omPoHWczZ3zS6hPRMYiIyg2LEiJ9Ua870Pwj0SmoCgrKMxAdQa9IIMGiNxbByshKdBQionLDooRIn3RdBDj6iE5BVUzXmCuQSWSiY+iNQT6D0NK1pegYRETlikUJkT4xNAHe+Qkw4ORkKj/26U/RjEO4KkVd27qY3HSy6BhEROWORQmRvnGuD3RdKDoFVTFBctEJqj5TA1Msbb8UxjJj0VGIiModixIifdRiBODdXXQKqkK6RF+CgZRzSyrS7Baz4WFdsdeFkUgk+P3334vcfuLECUgkEqSkpFRoDira0KFD0bt37zK3I5fL4enpiTNnzpQ9lA5xd3fHN998o779qte8Ppg3bx4aN25cbu0dPHgQjRs3hlKpLNXjWJQQ6au31wCWrqJTUBVhnZmMVlaeomNUWd1rd0cfrz5laiMuLg4TJkyAh4cHjI2N4ebmhl69euHYsWMlbqNNmzaIjY2FtbV1mbI8V95vhvTBypUrsWnTpjK3891336F27dpo06aN+j6JRKL+sra2Rtu2bXH8+PEyH0ubxcbGolu3bsKOX9mFfmFF2LRp00r1e+BVgoKCYGhoiK1bt5bqcSxKiPSVmR3Q53tAwl8DVD6CshWiI1RJbpZumNt6bpnaiImJQdOmTXH8+HEsXboU165dw8GDB9GxY0eMGzeuxO0YGRnBxcUFEknlXpsmNze3Uo+nzaytrWFjY1OmNlQqFVavXo3hw4cX2LZx40bExsYiODgYDg4O6NmzJ+7evVum42kzFxcXGBtXjSGRr/tzYmFhAXt7+3LNMnToUKxatapUj+G7ESJ95tEeCCjbmx2i5wKiL8JIaiQ6RpViYWiBbzt9W+arto8dOxYSiQQXLlxA3759UbduXTRo0ABTpkzBuXPnNPZNSEhAnz59YGZmBi8vL+zbt0+97eVPdTdt2gQbGxscOnQIPj4+sLCwQFBQEGJjYzUe06JFC5ibm8PGxgZt27bFvXv3sGnTJsyfPx9hYWHqT+ef9wBIJBKsW7cOb731FszNzbFo0SIoFAoMHz4ctWvXhqmpKby9vbFy5UqN7M+HNs2fPx+Ojo6wsrLC6NGjIZcXPenp+Tn8/vvv8PLygomJCQIDA/HgwQON/fbu3YsmTZrAxMQEHh4emD9/PvLy8tTbJRIJfvzxxyKfOwDYt2+f+hgdO3bE5s2bNZ7PwnqOvvnmG7i7uxc4x+c6dOiAiRMnYsaMGbCzs4OLiwvmzZtX5PkCwKVLlxAVFYUePXoU2GZjYwMXFxc0bNgQ69atQ1ZWFo4cOYKff/4Z9vb2yMnJ0di/d+/eGDx4sPr2woUL4eTkBEtLS3z00UeYNWuWxjkplUp8/vnnqFGjBoyNjdG4cWMcPHhQvV0ul2P8+PFwdXWFiYkJatWqhS+//FK9PSUlBaNGjYKzszNMTEzQsGFD7N+/X7399OnTePPNN2Fqago3NzdMnDgRGRkZRT4XL/YcvOrYL7t48SK6dOkCBwcHWFtbo3379rh8+XKB9ot6XcTExKBjx44AAFtbW0gkEgwdOhRA/hCoN954AzY2NrC3t0fPnj0RFRWlbjcmJgYSiQQ7d+5E+/btYWJiou6Z+Omnn9CgQQMYGxvD1dUV48ePBwD166hPnz6QSCTq24W97opqAwBWrFiBRo0awdzcHG5ubhg7dizS09M1Ht+rVy+EhIRoZH4VFiVE+u6NjwG/gaJTUBVgkf0Mba05hKu8yCQyLG2/FHVsyrayWVJSEg4ePIhx48bB3LxgcfPyp+7z589Hv379cPXqVXTv3h2DBg1CUlJSke1nZmZi2bJl2LJlC/73v//h/v37mDZtGgAgLy8PvXv3Rvv27XH16lWcPXsWI0eOhEQiQf/+/TF16lQ0aNAAsbGxiI2NRf/+/dXtzps3D3369MG1a9cwbNgwKJVK1KhRA7t378bNmzcxd+5c/N///R927dqlkefYsWMIDw/HiRMnsH37dvz666+YP39+sc9RZmYmFi1ahJ9//hnBwcFISUnBe++9p95+6tQpfPDBB5g0aRJu3ryJ77//Hps2bcKiRYtK/NxFR0fjnXfeQe/evREWFoZRo0Zhzpw5xeYqqc2bN8Pc3Bznz5/HV199hc8//xxHjhwpcv9Tp06hbt26sLS0LLZdU1NTAPlv1t99910oFAqNQis+Ph5//vknhg0bBgDYunUrFi1ahCVLluDSpUuoWbMm1q1bp9HmypUrsXz5cixbtgxXr15FYGAg3nrrLURGRgIAVq1ahX379mHXrl2IiIjA1q1b1W+elUolunXrhuDgYPzyyy+4efMmFi9eDJksf0nyqKgoBAUFoW/fvrh69Sp27tyJ06dPa7yhLk5xxy5MWloahgwZgtOnT+PcuXPw8vJC9+7dkZaWprFfUa8LNzc37NmzBwAQERGB2NhYdaGdkZGBKVOmICQkBMeOHYNUKkWfPn0KzNOYNWsWJk2ahPDwcAQGBmLdunUYN24cRo4ciWvXrmHfvn3w9Mz/vXzx4kUA//aGPb/9suLaAACpVIpVq1bhxo0b2Lx5M44fP44ZM2ZotFGzZk04Ozvj1KlTJXjm83FWIhEBvVYCSXeBB+devS9RMYIys/G36BBVxPTm0/FG9TfK3M6dO3egUqlQr169Eu0/dOhQDBgwAADwxRdfYNWqVbhw4QKCgoIK3T83Nxffffcd6tTJL57Gjx+Pzz//HADw7NkzpKamomfPnurtPj7/XivJwsICBgYGcHFxKdDuwIED8eGHH2rc92JxUbt2bZw9exa7du1Cv3791PcbGRnhp59+gpmZGRo0aIDPP/8c06dPx4IFCyCVFv5ZbG5uLlavXo2WLfOv/7J582b4+PjgwoULaNGiBebPn49Zs2ZhyJAhAAAPDw8sWLAAM2bMwGeffVai5+7777+Ht7c3li5dCgDw9vbG9evXCxQ2r8PX11edw8vLC6tXr8axY8fQpUuXQve/d+8eqlWrVmybmZmZ+OSTTyCTydC+fXuYmppi4MCB2LhxI959910AwC+//IKaNWuiQ4cOAIBvv/0Ww4cPV3/f5s6di8OHD2t8ir5s2TLMnDlTXfQtWbIEf//9N7755husWbMG9+/fh5eXF9544w1IJBLUqlVL/dijR4/iwoULCA8PR926dQHkfy+e+/LLLzFo0CBMnjxZ/VysWrUK7du3x7p162BiUvxy+MUduzCdOnXSuP3DDz/AxsYGJ0+eRM+ePdX3F/e6sLOzAwA4OTlpfEDQt29fjbZ/+uknODo64ubNm2jYsKH6/smTJ+M///mP+vbChQsxdepUTJo0SX1f8+bNAQCOjo4A/u0NK0pxbTw/5nPu7u5YuHAhRo8ejbVr12q0U61aNdy7d6/I47yMPSVEBBgYAe9tBaxrik5COq7D3YswlfE6OGXVr24/DPIZVC5tqVSqUu3v6+ur/r+5uTmsrKwQHx9f5P5mZmbqggMAXF1d1fvb2dlh6NChCAwMRK9evbBy5UqNoV3FadasWYH71qxZg6ZNm8LR0REWFhb44YcfcP/+fY19/Pz8YGZmpr7dunVrpKenFxiO9SIDAwONN1316tWDjY0NwsPDAQBhYWH4/PPPYWFhof4aMWIEYmNjkZmZqX5ccc9dRESExjEAoEWLFiV5Kl7pxeMCmt+DwmRlZRX5Bn3AgAGwsLCApaUl9uzZgw0bNqjbHzFiBA4fPoxHjx4ByB/6NnToUPUco4iIiALn9OLtZ8+e4fHjx2jbtq3GPm3btlU/10OHDkVoaCi8vb0xceJEHD58WL1faGgoatSooS5IXhYWFoZNmzZpfJ8CAwOhVCoRHR1d5PPxXHHHLsyTJ08wYsQIeHl5wdraGlZWVkhPTy/wmiztzxQAREZGYsCAAfDw8ICVlZW6x+bltl/8OYmPj8fjx48REBDwynMtSknaOHr0KAICAlC9enVYWlpi8ODBSExM1PhZAPJ72l6+rzgsSogon7kDMHAHYFR8dz5RcczkGXjTihdSLItWrq0wu+XscmvPy8sLEokEt27dKtH+hoaGGrclEkmxS3sWtv+LhdDGjRtx9uxZtGnTBjt37kTdunULzGMpzMtDzXbs2IFp06Zh+PDhOHz4MEJDQ/Hhhx8WO1+kvKSnp2P+/PkIDQ1Vf127dg2RkZEab+5L+9y9TCqVFigiSzJ5ubTHdXBwQHJycqHbvv76a4SGhiIuLg5xcXHq3iEA8Pf3h5+fH37++WdcunQJN27cUM+BKC9NmjRBdHQ0FixYgKysLPTr1w/vvPMOgH+HkxUlPT0do0aN0vg+hYWFITIyUqNwfp1jF2bIkCEIDQ3FypUrcebMGYSGhsLe3r7Aa/J1Xhe9evVCUlIS1q9fj/Pnz+P8+fMAUKDtF39OXvX8lMSr2oiJiUHPnj3h6+uLPXv24NKlS1izZk2h2ZKSktS9MyXBooSI/uXcAOj7I1fkojIJemnCI5Wcu5U7lndYXq7XfLGzs0NgYCDWrFlT6ITfyliK1N/fH7Nnz8aZM2fQsGFDbNu2DUD+UCuFomSrtgUHB6NNmzYYO3Ys/P394enpWegk2rCwMGRlZalvnzt3DhYWFnBzcyuy7by8PISEhKhvR0REICUlRT3UrEmTJoiIiICnp2eBr6KGhL3M29tb4xgACozpd3R0RFxcnEZhEhoaWqL2S8Pf3x+3bt0qtBfNxcUFnp6eRb6Z/Oijj7Bp0yZs3LgRnTt31nhevb29C5zTi7etrKxQrVo1BAcHa+wTHByM+vXra+zXv39/rF+/Hjt37sSePXuQlJQEX19fPHz4ELdv3y40W5MmTXDz5s1Cv09GRiVbhKOoYxcmODgYEydORPfu3dWTwhMSEkp0nOee53rx5yAxMRERERH45JNPEBAQAB8fnyKLyBdZWlrC3d292OV9DQ0Ni/2Ze1Ubly5dglKpxPLly9GqVSvUrVsXjx8/LrBfdnY2oqKi4O/v/8rcz/GdBxFp8g4COhc/KZSoOO3uXoS5gdmrdyQN1sbWWBOwBlZGVuXe9po1a6BQKNCiRQvs2bMHkZGRCA8Px6pVq9C6detyP95z0dHRmD17Ns6ePYt79+7h8OHDiIyMVL/Zd3d3R3R0NEJDQ5GQkFBgZacXeXl5ISQkBIcOHcLt27fx6aefFjpRVy6XY/jw4bh58yYOHDiAzz77DOPHjy+2eDA0NMSECRNw/vx5XLp0CUOHDkWrVq3UQ4/mzp2Ln3/+GfPnz8eNGzcQHh6OHTt24JNPPinxczFq1CjcunULM2fOxO3bt7Fr1y6N1caA/JW0nj59iq+++gpRUVFYs2YN/vrrrxIfo6Q6duyI9PR03Lhxo9SPHThwIB4+fIj169erJ7g/N2HCBGzYsAGbN29GZGQkFi5ciKtXr2osIT19+nQsWbIEO3fuREREBGbNmoXQ0FD1/IUVK1Zg+/btuHXrFm7fvo3du3fDxcUFNjY2aN++Pdq1a4e+ffviyJEjiI6Oxl9//aVevWvmzJk4c+YMxo8fj9DQUERGRmLv3r0lnuhe3LEL4+XlhS1btiA8PBznz5/HoEGDSt1bUatWLUgkEuzfvx9Pnz5Feno6bG1tYW9vjx9++AF37tzB8ePHMWXKlBK1N2/ePCxfvhyrVq1CZGQkLl++jG+//Va9/XnBERcXV2ShU1wbnp6eyM3Nxbfffou7d+9iy5Yt+O677wq0ce7cORgbG5fq9wuLEiIqqO1EwP990SlIRxnnZaODZcVeebyqMZAa4OsOX6OmVcXM6/Lw8MDly5fRsWNHTJ06FQ0bNkSXLl1w7NixAqsjlSczMzPcunVLvQzxyJEjMW7cOIwaNQpA/mTeoKAgdOzYEY6Ojti+fXuRbY0aNQr/+c9/0L9/f7Rs2RKJiYkYO3Zsgf0CAgLg5eWFdu3aoX///njrrbdeuUSumZkZZs6ciYEDB6Jt27awsLDAzp071dsDAwOxf/9+HD58GM2bN0erVq3w9ddfv3Ii9Itq166N//73v/j111/h6+uLdevWqVffen6dDB8fH6xduxZr1qyBn58fLly4oF7JrDzZ29ujT58+pb64HZB/nZS+ffvCwsKiwJXlBw0ahNmzZ2PatGnqoVBDhw7VGOI2ceJETJkyBVOnTkWjRo1w8OBB9VLJQP4n9V999RWaNWuG5s2bIyYmBgcOHFAXlXv27EHz5s0xYMAA1K9fHzNmzFB/8u/r64uTJ0/i9u3bePPNN+Hv74+5c+e+clL/c6869ss2bNiA5ORkNGnSBIMHD8bEiRPh5ORUquezevXq6oUUnJ2d1QX0jh07cOnSJTRs2BAff/yxeoGEVxkyZAi++eYbrF27Fg0aNEDPnj3VK5sBwPLly3HkyBG4ubkV2YtRXBt+fn5YsWIFlixZgoYNG2Lr1q2FLpu8fft2DBo0SGN+16tIVKWdAUdE+kGRC+wYCEQWP9GPqDAnPNtigqLoicWk6fM2n5f5iu2UP1E5JSWlwBWri7Np0yZMnjy50q6o/aJFixbhu+++K3YSfkW5evUqunTpgqioKFhYWJTqsQEBAWjQoEGJLo7XpUsXuLi4YMuWLa8blXRMQkKCerhi7dq1S/w4LglMRIWTGQL9fgZ+eQe4d1p0GtIxbaMvwrKOJ9JyOb/kVWa1mMWCRE+sXbsWzZs3h729PYKDg7F06dISDy0qb76+vliyZAmio6PRqFGjEj0mOTkZJ06cwIkTJwos/wrkLyP83XffITAwEDKZDNu3b8fRo0eLvWYKVT0xMTFYu3ZtqQoSgEUJERXH0DR/Ra7NbwGPL796f6J/GCrkCLCojd+Tr4mOotWmNZtWbkv/kvZ7Ps8iKSkJNWvWxNSpUzF7dvmttFZapV05y9/fH8nJyViyZAm8vb0LbJdIJDhw4AAWLVqE7OxseHt7Y8+ePejcuXM5JSZd0KxZs0KX9H4VDt8iolfLTAI29QDib4pOQjok2KMlRqtKdk0KffRx048xrOGwV+9IRKQHONGdiF7NzA74YB/gUPCTMaKitIy5BFsja9ExtNJE/4ksSIiIXsCihIhKxsIRGPIHYO8lOgnpCANlHjqbV8xqUrpsrN9YjPAdIToGEZFWYVFCRCVn6ZxfmNjxit1UMkFJ8aIjaJWRviMxpvEY0TGIiLQOixIiKh0rV2DofsDeU3QS0gHN7l2Co4md6BhaYVjDYZjgP0F0DCIircSiRAfFxMRAIpEgNDS0zG1t2LABXbt2LXsoHbJp0yaNq7POmzcPjRs3FpanMh08eBCNGzeGUqksW0NW1YBhh4HqTcsnGFVZUpUSXUxriI4h3JD6Q/Bx049FxyAi0lqlLkri4uIwadIkeHp6wsTEBM7Ozmjbti3WrVuHzMzMcg3XoUMHTJ48uVzbrArc3NwQGxuLhg0blqmd7OxsfPrpp/jss8/U982bNw8SiQQSiQQGBgZwd3fHxx9/jPT0qnutgWnTpuHYsWOiY1SKoKAgGBoavtZVfAswtweG7Ae89KuopdILevpIdARhJJDg46YfY1rz8r8qNxFRVVKqouTu3bvw9/fH4cOH8cUXX+DKlSs4e/YsZsyYgf379+Po0aMVlZNeIJPJ4OLiAgODsl1m5r///S+srKzQtm1bjfsbNGiA2NhYxMTEYMmSJfjhhx8wderUMh1Lm1lYWMDe3l50jEozdOjQEl2Ft0SMzID3tgONeZ0FKlrjB6FwMXUUHaPSGUmN8FW7r7jKFhFRCZSqKBk7diwMDAwQEhKCfv36wcfHBx4eHnj77bfx559/olevXup9U1JS8NFHH8HR0RFWVlbo1KkTwsLC1NufD5nZsmUL3N3dYW1tjffeew9paWkA8t84nTx5EitXrlR/ch8TEwMAOHnyJFq0aAFjY2O4urpi1qxZyMvLU7edk5ODiRMnwsnJCSYmJnjjjTdw8eLFYs/N3d0dCxYswIABA2Bubo7q1atjzZo1GvuU9ZwAIC0tDYMGDYK5uTlcXV3x9ddfF+gRkkgk+P333zWObWNjg02bNgEoOHzrxIkTkEgkOHbsGJo1awYzMzO0adMGERERxZ7zjh07NL5nzxkYGMDFxQU1atRA//79MWjQIOzbtw8qlQqenp5YtmyZxv6hoaGQSCS4c+cOAODWrVt44403YGJigvr16+Po0aMFzunatWvo1KkTTE1NYW9vj5EjR2r0xpw4cQItWrSAubk5bGxs0LZtW9y7d0+9/Y8//kDz5s1hYmICBwcH9Onz79WQc3JyMG3aNFSvXh3m5uZo2bIlTpw4UeTz8PLwrVcd+0XPvxc7duxAmzZtYGJigoYNG+LkyZPqfRQKBYYPH47atWvD1NQU3t7eWLlypUY7Q4cORe/evTF//nz162v06NGQy+Xqfdzd3fHNN99oPK5x48aYN2+e+vaKFSvQqFEjmJubw83NDWPHji3Qy9WrVy+EhIQgKiqqyOekVGQGQO+1wBtTyqc9qnIkUCHQ2FV0jEplbWyN9V3XI6h2kOgoREQ6ocRFSWJiIg4fPoxx48bB3Ny80H0kEon6/++++y7i4+Px119/4dKlS2jSpAkCAgKQlJSk3icqKgq///479u/fj/379+PkyZNYvHgxAGDlypVo3bo1RowYgdjYWMTGxsLNzQ2PHj1C9+7d0bx5c4SFhWHdunXYsGEDFi5cqG53xowZ2LNnDzZv3ozLly/D09MTgYGBGscuzNKlS+Hn54crV65g1qxZmDRpEo4cOVJu5wQAU6ZMQXBwMPbt24cjR47g1KlTuHy5fK6UPWfOHCxfvhwhISEwMDDAsGHFfzp3+vTpEl1x09TUFHK5HBKJBMOGDcPGjRs1tm/cuBHt2rWDp6cnFAoFevfuDTMzM5w/fx4//PAD5syZo7F/RkYGAgMDYWtri4sXL2L37t04evQoxo8fDwDIy8tD79690b59e1y9ehVnz57FyJEj1a+vP//8E3369EH37t1x5coVHDt2DC1atFC3P378eJw9exY7duzA1atX8e677yIoKAiRkZGvPNdXHbso06dPx9SpU3HlyhW0bt0avXr1QmJiIgBAqVSiRo0a2L17N27evIm5c+fi//7v/7Br1y6NNo4dO4bw8HCcOHEC27dvx6+//or58+e/MvOLpFIpVq1ahRs3bmDz5s04fvw4ZsyYobFPzZo14ezsjFOnTpWq7Vfq/BnQbSkg4VQ1KigovvDCviqqYVEDv3T7BU2cm4iOQkSkM0o8/ufOnTtQqVTw9ta8eJqDgwOys7MBAOPGjcOSJUtw+vRpXLhwAfHx8TA2NgYALFu2DL///jv++9//YuTIkQDy36xt2rQJlpaWAIDBgwfj2LFjWLRoEaytrWFkZAQzMzO4uLioj7d27Vq4ublh9erVkEgkqFevHh4/foyZM2di7ty5yMrKwrp167Bp0yZ069YNALB+/XocOXIEGzZswPTp04s8x7Zt22LWrFkAgLp16yI4OBhff/01unTpUi7nlJaWhs2bN2Pbtm0ICAgAkP+Gvlq1aiX9NhRr0aJFaN++PQBg1qxZ6NGjB7Kzs2FiYlJg35SUFKSmpr7y2JcuXcK2bdvQqVMnAPmf6M+dOxcXLlxAixYtkJubi23btql7T44cOYKoqCicOHFC/X1btGgRunTpom5z27ZtyM7Oxs8//6wucFevXo1evXphyZIlMDQ0RGpqKnr27Ik6dfKXnvXx8dE4z/fee0/jDbufnx8A4P79+9i4cSPu37+vPrdp06bh4MGD2LhxI7744otiz/fZs2fFHrso48ePR9++fQEA69atw8GDB7FhwwbMmDEDhoaGGllr166Ns2fPYteuXejXr5/6fiMjI/z0008wMzNDgwYN8Pnnn2P69OlYsGABpNKSvdF/scfN3d0dCxcuxOjRo7F27VqN/apVq1Zk70+ZtByZfz2TX0cBipzyb590VsNH1+DWoAUeZMaJjlKhfB18sarTKtib6s+QUCKi8lDmjzQvXLiA0NBQNGjQADk5+W9CwsLCkJ6eDnt7e1hYWKi/oqOjNYaMuLu7q9+8A4Crqyvi44tf0z48PBytW7fW+OS6bdu2SE9Px8OHDxEVFYXc3FyNeRKGhoZo0aIFwsPDi227devWBW4/f0x5nNPdu3eRm5ur8am+tbV1gULvdfn6+mocF0CRz2dWVhYAFFqwXLt2DRYWFjA1NUWLFi3QunVrrF69GkD+m9kePXrgp59+ApA/jConJwfvvvsuACAiIgJubm4aheSL5wvkfw/9/Pw0etzatm0LpVKJiIgI2NnZYejQoQgMDESvXr2wcuVKxMbGqvcNDQ1VF3WFZVcoFKhbt67G9+nkyZMlGq70qmMX5cXXjoGBAZo1a6bxeluzZg2aNm0KR0dHWFhY4IcffsD9+/c12vDz84OZmZlGm+np6Xjw4MErj//c0aNHERAQgOrVq8PS0hKDBw9GYmJigUUoTE1Ny31hCrUGfYD39wCmXAaWNAUaVe15JZ3cOmFD4AYWJEREr6HEPSWenp6QSCQF5il4eHgAyH+T81x6ejpcXV0LHcf/4lKshoaGGtskEknZlyqtIJV5ThKJBCqVSuO+3NzcVz7uxWM/L9qKOra9vT0kEgmSk5MLbPP29sa+fftgYGCAatWqwcjISGP7Rx99hMGDB+Prr7/Gxo0b0b9/f4030+Vh48aNmDhxIg4ePIidO3fik08+wZEjR9CqVSuN19rL0tPTIZPJcOnSJchkMo1tFhYWZT7269ixYwemTZuG5cuXo3Xr1rC0tMTSpUtx/vz5UrUjlUqLfV3ExMSgZ8+eGDNmDBYtWgQ7OzucPn0aw4cPh1wu1/geJSUlwdGxAt8g1n4TGHUS2DkYiA2tuOOQTgmKvYsfi/7x1Wnv+7yP6c2nQ8rhi0REr6XEvz3t7e3RpUsXrF69GhkZGcXu26RJE8TFxcHAwACenp4aXw4ODiUOZ2RkBIVCoXGfj48Pzp49q/HmLDg4GJaWlqhRowbq1KkDIyMjBAcHq7fn5ubi4sWLqF+/frHHO3fuXIHbz4fulMc5eXh4wNDQUGPSfWpqKm7fvq2xn6Ojo8an85GRkeX+qbaRkRHq16+PmzdvFrrN09MT7u7uBQoSAOjevTvMzc3Vw5RenLvi7e2NBw8e4MmTJ+r7Xl5kwMfHB2FhYRqvo+DgYEilUo1eI39/f8yePRtnzpxBw4YNsW3bNgD5PUJFLeHr7+8PhUKB+Pj4At+nF3tvXqWoYxflxddOXl4eLl26pH7tBAcHo02bNhg7diz8/f3h6elZaK9NWFiYugfreZsWFhZwc3MDUPB18ezZM0RHR6tvX7p0CUqlEsuXL0erVq1Qt25dPH78uMBxsrOzERUVBX9//xI+G6/JpiYw/DDQ5IOKPQ7pDO+4cNQ2ry46RrkykhphTss5mNliJgsSIqIyKNVv0LVr1yIvLw/NmjXDzp07ER4ejoiICPzyyy+4deuW+pPpzp07o3Xr1ujduzcOHz6MmJgYnDlzBnPmzEFISEiJj+fu7o7z588jJiYGCQkJUCqVGDt2LB48eIAJEybg1q1b2Lt3Lz777DNMmTIFUqkU5ubmGDNmDKZPn46DBw/i5s2bGDFiBDIzMzF8+PBijxccHIyvvvoKt2/fxpo1a7B7925MmjSp3M7J0tISQ4YMwfTp0/H333/jxo0bGD58OKRSqcZwtE6dOmH16tW4cuUKQkJCMHr06AI9MOUhMDAQp0+fLvXjZDIZhg4ditmzZ8PLy0tj6FKXLl1Qp04dDBkyBFevXkVwcDA++eQTAP/23gwaNAgmJiYYMmQIrl+/jr///hsTJkzA4MGD4ezsjOjoaMyePRtnz57FvXv3cPjwYURGRqrf5H/22WfYvn07PvvsM4SHh+PatWtYsmQJgPy5QIMGDcIHH3yAX3/9FdHR0bhw4QK+/PJL/Pnnn688t1cduyhr1qzBb7/9hlu3bmHcuHFITk5WF2teXl4ICQnBoUOHcPv2bXz66aeFrgYnl8sxfPhw3Lx5EwcOHMBnn32G8ePHq+eTdOrUCVu2bMGpU6dw7do1DBkyRKM3yNPTE7m5ufj2229x9+5dbNmyBd99912B45w7dw7GxsYFhitWCANj4K1vgbdWAwYFhwqS/gkyqDrD+mpa1sSW7lvwXr33REchItJ5pSpK6tSpgytXrqBz586YPXs2/Pz80KxZM3z77beYNm0aFixYACD/zeeBAwfQrl07fPjhh6hbty7ee+893Lt3D87OziU+3rRp0yCTyVC/fn04Ojri/v37qF69Og4cOIALFy7Az88Po0ePxvDhw9VvfAFg8eLF6Nu3LwYPHowmTZrgzp07OHToEGxtbYs93tSpUxESEgJ/f38sXLgQK1asQGBgYLme04oVK9C6dWv07NkTnTt3Rtu2beHj46Mxt2P58uVwc3PDm2++iYEDB2LatGnlPjwKAIYPH44DBw4gNTX1tR4rl8vx4Ycfatwvk8nw+++/Iz09Hc2bN8dHH32kXn3r+TmamZnh0KFDSEpKQvPmzfHOO+8gICBAPW/FzMwMt27dQt++fVG3bl2MHDkS48aNw6hRowDkX1Rz9+7d2LdvHxo3boxOnTrhwoUL6gwbN27EBx98gKlTp8Lb2xu9e/fGxYsXUbNmzVee16uOXZTFixdj8eLF8PPzw+nTp7Fv3z51D9qoUaPwn//8B/3790fLli2RmJiIsWPHFmgjICAAXl5eaNeuHfr374+33npLY7nf2bNno3379ujZsyd69OiB3r17qyfjA/lzUlasWIElS5agYcOG2Lp1K7788ssCx9m+fTsGDRpUIa+pIjUZDAw7lN97Qnot6PHtV++kA7q5d8OuXrtQ3774HngiIioZierlQep6yt3dHZMnT670K8hnZGSgevXqWL58+St7cirCu+++iyZNmmD27NmletypU6cQEBCABw8evLIoCw4OxhtvvIE7d+5ovImuCmJiYlC7dm1cuXJF41onpTV06FCkpKQUuD5NeUtISIC3tzdCQkJQu3btCj1WoTKTgF9HAneOvHpfqrL6NnoDt9Pvv3pHLWQiM8HMFjPxTt13REchIqpSynZJcCq1K1eu4NatW2jRogVSU1Px+eefAwDefvttIXmWLl2KP/74o8T75+Tk4OnTp5g3bx7efffdQguS3377DRYWFvDy8sKdO3cwadIktG3btsoVJLooJiYGa9euFVOQAICZHTBwF/C/r4CTSwCVdi5sQRUrSGoFXewvqW1dG8vaL0Nd27qioxARVTmclSfAsmXL4Ofnh86dOyMjIwOnTp0q1QIA5cnd3R0TJkwo8f7bt29HrVq1kJKSgq+++qrQfdLS0jBu3DjUq1cPQ4cORfPmzbF3797yikxl0KxZM/Tv319sCKkU6DALGPIHYOsuNgsJEfSw4AIb2u6tOm9hR48dLEiIiCoIh28RkTjyDODIXODiBgD8VaRP3vNrjxvPol+9o2CmBqaY03IO3vYU05tNRKQv2FNCROIYmQM9lgND9nESvJ4JUlXiQguvqZlzM+zquYsFCRFRJWBPCRFph5x04PAnwKWNopNQJYi1dUOgjRQqLewhsza2xtSmU9Hbs7fGcu1ERFRxWJQQkXaJOg7smwikPhCdhCrYYL+OCH1W8EKiInWv3R0zms+Avam96ChERHqFw7eISLvU6QSMOQM0GQKAn1JXZUFKY9ER1GpY1MB3nb/DknZLWJAQEQnAnhIi0l4PQ4CDs4CHF0UnoQrw1MoFnR1MoBS4NLSBxACDGwzGWL+xMDEwefUDiIioQrAoISLtplIB13YDR+cBzx6JTkPlbFjjAFxMjRRy7EYOjfBZ68/gbect5PhERPQvXjyRiLSbRAL49gPq9QSCV+Z/5WWJTkXlJChPhsruB3MydcLoxqPR16svpBKOYiYi0gbsKSEi3ZL6EDjyGXD9v6KTUDlIMndAgLMV8lR5FX4sG2MbDG84HO/Ve49DtYiItAyLEiLSTQ8uAH/NBB5fFp2EymiUfxecSYmosPbNDMwwuP5gDG0wFBZGFhV2HCIien0sSohId6lUQMQB4H/LWJzosN/qd8bcrNvl3q6R1Aj9vPthhO8I2JnYlXv7RERUfliUEFHVcOdYfnFy/4zoJFRKqaY26FjNHrnK3HJpTyaR4a06b2GM3xi4WriWS5tERFSxWJQQUdUSEwycWpZ/EUbSGeP9A3EyJbxMbRhIDNClVheMaTwGta1rl1MyIiKqDFx9i4iqFve2+V+PLuX3nET8BYCfvWi7wOxcnHzNx9oa2+Kduu+gv3d/OJs7l2suIiKqHOwpIaKq7ckNIHgVcPN3IC9bdBoqQoaxJdq7uSBHkVPix3jbemOQzyB09+gOY5n2XB2eiIhKj0UJEemHrGQgbAdwaRPw9JboNFSIj5t0w9HkG8XuI5PI0NGtIwb5DEIzl2aVlIyIiCoaixIi0j/3z+UXJzd+54UYtchB7/aYLo8udJuVkRX61u2LAd4DOHmdiKgKYlFCRPorKxkI2/lP70nZJllT2WUZmaF9LTdk/VMoyiQytKrWCj1q90DnWp1hamAqOCEREVUUFiVERABw/zxwbRdw608gLVZ0Gr01o0l3PJJJ0d2jO4Lcg2Bvai86EhERVQIWJUREL1KpgIchwK0/gPA/gKS7ohPph2r+QP23kdugDwxt3UWnISKiSsaihIioOE9uAOH78wuUJ9dEp6k6pAZA9WaAT0/A5y3AtpboREREJBCLEiKikkqOyS9O7hzNH+7FSfKl4+gDeHQAPNoDtdoCJlaiExERkZZgUUJE9Dry5MCjECD6FBBzCnh4kddBeZlVjX+LkNrtAUte2JCIiArHooSIqDzkyYHY0Pzlhh+cz//KeCo6VeUxMAGcfACXRvnzQ2q3B+zriE5FREQ6gkUJEVFFSbkPxN/KX274+b9PbwO5GaKTlY2pbX7x4eL7z1cjwKEuIDMQnYyIiHQUixIiosqkUgEp94CnEUB8eP7V5Z9G5C9DnB4PqBSiE+YzcwCsa2h+2dXJL0Bs3ESnIyKiKoZFCRGRtlAqgcxEID0OSH8CpD3J//f5V9oTICcNUMgBRQ6gyAXy/vlXkZN/v0qp2aaBKWBkBhiZA0YW+f8amv37fyNzwML5heLDDbCuDhjyQoVERFR5WJQQEVUlSkV+oaJS5hcfUqnoRERERK/EooSIiIiIiITiR2hERERERCQUixIiIiIiIhKKRQkREREREQnFooSIiIiIiIRiUUJEREREREKxKCEiIiIiIqFYlBARERERkVAsSoiIiIiISCgWJUREREREJBSLEiIiIiIiEopFCRERERERCcWihIiIiIiIhGJRQkREREREQrEoISIiIiIioViUEBERERGRUCxKiIiIiIhIKBYlREREREQkFIsSIiIiIiISikUJEREREREJxaKEiIiIiIiEYlFCRERERERCsSghIiIiIiKhWJQQEREREZFQLEqIiIiIiEgoFiVERERERCQUixIiIiIiIhKKRQkREREREQnFooSIiIiIiIRiUUJEREREREKxKCEiIiIiIqFYlBARERERkVAsSoiIiIiISCgWJUREREREJBSLEiIiIiIiEopFCRERERERCcWihIiIiIiIhGJRQkREREREQrEoISIiIiIioViUEBERERGRUCxKiIiIiIhIKBYlREREREQkFIsSIiIiIiISikUJEREREREJxaKEiIiIiIiEYlFCRERERERC/T+jLVh8VAgPvQAAAABJRU5ErkJggg==", "text/plain": [ "
" ] @@ -839,16 +829,6 @@ "execution_count": 18, "metadata": {}, "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/swast/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/array_value.py:263: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", - " warnings.warn(msg, category=bfe.AmbiguousWindowWarning)\n", - "/home/swast/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/array_value.py:239: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", - " warnings.warn(msg, bfe.AmbiguousWindowWarning)\n" - ] - }, { "name": "stdout", "output_type": "stream", @@ -1030,77 +1010,77 @@ " \n", " \n", " 0\n", - " 3444.135246\n", - " Dream\n", - " 42.2\n", - " 18.5\n", - " 180.0\n", + " 3271.548077\n", + " Biscoe\n", + " 37.9\n", + " 18.6\n", + " 172.0\n", " FEMALE\n", " Adelie Penguin (Pygoscelis adeliae)\n", - " 3550.0\n", + " 3150.0\n", " \n", " \n", " 1\n", - " 3735.564386\n", - " Torgersen\n", - " 39.1\n", - " 18.7\n", - " 181.0\n", - " MALE\n", + " 3224.661209\n", + " Biscoe\n", + " 37.7\n", + " 16.0\n", + " 183.0\n", + " FEMALE\n", " Adelie Penguin (Pygoscelis adeliae)\n", - " 3750.0\n", + " 3075.0\n", " \n", " \n", " 2\n", - " 3879.370094\n", - " Dream\n", - " 40.9\n", - " 18.9\n", - " 184.0\n", - " MALE\n", + " 3395.403541\n", + " Biscoe\n", + " 34.5\n", + " 18.1\n", + " 187.0\n", + " FEMALE\n", " Adelie Penguin (Pygoscelis adeliae)\n", - " 3900.0\n", + " 2900.0\n", " \n", " \n", " 3\n", - " 3787.401253\n", + " 3943.436439\n", " Biscoe\n", - " 38.2\n", - " 18.1\n", - " 185.0\n", + " 40.1\n", + " 18.9\n", + " 188.0\n", " MALE\n", " Adelie Penguin (Pygoscelis adeliae)\n", - " 3950.0\n", + " 4300.0\n", " \n", " \n", " 4\n", - " 3435.804331\n", - " Dream\n", - " 36.0\n", - " 18.5\n", - " 186.0\n", - " FEMALE\n", + " 3986.662895\n", + " Biscoe\n", + " 41.4\n", + " 18.6\n", + " 191.0\n", + " MALE\n", " Adelie Penguin (Pygoscelis adeliae)\n", - " 3100.0\n", + " 3700.0\n", " \n", " \n", "\n", "" ], "text/plain": [ - " predicted_body_mass_g island culmen_length_mm culmen_depth_mm \\\n", - "0 3444.135246 Dream 42.2 18.5 \n", - "1 3735.564386 Torgersen 39.1 18.7 \n", - "2 3879.370094 Dream 40.9 18.9 \n", - "3 3787.401253 Biscoe 38.2 18.1 \n", - "4 3435.804331 Dream 36.0 18.5 \n", + " predicted_body_mass_g island culmen_length_mm culmen_depth_mm \\\n", + "0 3271.548077 Biscoe 37.9 18.6 \n", + "1 3224.661209 Biscoe 37.7 16.0 \n", + "2 3395.403541 Biscoe 34.5 18.1 \n", + "3 3943.436439 Biscoe 40.1 18.9 \n", + "4 3986.662895 Biscoe 41.4 18.6 \n", "\n", " flipper_length_mm sex species body_mass_g \n", - "0 180.0 FEMALE Adelie Penguin (Pygoscelis adeliae) 3550.0 \n", - "1 181.0 MALE Adelie Penguin (Pygoscelis adeliae) 3750.0 \n", - "2 184.0 MALE Adelie Penguin (Pygoscelis adeliae) 3900.0 \n", - "3 185.0 MALE Adelie Penguin (Pygoscelis adeliae) 3950.0 \n", - "4 186.0 FEMALE Adelie Penguin (Pygoscelis adeliae) 3100.0 " + "0 172.0 FEMALE Adelie Penguin (Pygoscelis adeliae) 3150.0 \n", + "1 183.0 FEMALE Adelie Penguin (Pygoscelis adeliae) 3075.0 \n", + "2 187.0 FEMALE Adelie Penguin (Pygoscelis adeliae) 2900.0 \n", + "3 188.0 MALE Adelie Penguin (Pygoscelis adeliae) 4300.0 \n", + "4 191.0 MALE Adelie Penguin (Pygoscelis adeliae) 3700.0 " ] }, "execution_count": 21, @@ -1165,12 +1145,12 @@ " \n", " \n", " 0\n", - " 212.800303\n", - " 72655.272611\n", - " 0.004369\n", - " 144.426983\n", - " 0.877546\n", - " 0.877991\n", + " 231.914252\n", + " 78873.600421\n", + " 0.005172\n", + " 178.724985\n", + " 0.890549\n", + " 0.890566\n", " \n", " \n", "\n", @@ -1179,10 +1159,10 @@ ], "text/plain": [ " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", - " 212.800303 72655.272611 0.004369 \n", + " 231.914252 78873.600421 0.005172 \n", "\n", " median_absolute_error r2_score explained_variance \n", - " 144.426983 0.877546 0.877991 \n", + " 178.724985 0.890549 0.890566 \n", "\n", "[1 rows x 6 columns]" ] @@ -1211,7 +1191,7 @@ { "data": { "text/plain": [ - "np.float64(0.8775458183087934)" + "np.float64(0.8905492944632485)" ] }, "execution_count": 23, @@ -1320,103 +1300,14 @@ "source": [ "### Generate responses\n", "\n", - "Here we will use the [`GeminiTextGenerator`](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator) LLM to answer the questions. Read the API documentation for all the model versions supported via the `model_name` param." + "Here we will use the [`GeminiTextGenerator`](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator) LLM to answer the questions. Read the [GeminiTextGenerator API documentation](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator) for all the model versions supported via the `model_name` param." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 25, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/swast/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/array_value.py:109: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\n", - " warnings.warn(msg, bfe.PreviewWarning)\n", - "/home/swast/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/array_value.py:263: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", - " warnings.warn(msg, category=bfe.AmbiguousWindowWarning)\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
ml_generate_text_llm_resultml_generate_text_rai_resultml_generate_text_statusprompt
1BQML stands for **BigQuery Machine Learning**....<NA>What is BQML?
0BigQuery is a fully managed, serverless data w...<NA>What is BigQuery?
2BigQuery DataFrames are a Python library that ...<NA>What is BigQuery DataFrames?
\n", - "

3 rows × 4 columns

\n", - "
[3 rows x 4 columns in total]" - ], - "text/plain": [ - " ml_generate_text_llm_result \\\n", - "1 BQML stands for **BigQuery Machine Learning**.... \n", - "0 BigQuery is a fully managed, serverless data w... \n", - "2 BigQuery DataFrames are a Python library that ... \n", - "\n", - " ml_generate_text_rai_result ml_generate_text_status \\\n", - "1 \n", - "0 \n", - "2 \n", - "\n", - " prompt \n", - "1 What is BQML? \n", - "0 What is BigQuery? \n", - "2 What is BigQuery DataFrames? \n", - "\n", - "[3 rows x 4 columns]" - ] - }, - "execution_count": 25, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# from bigframes.ml.llm import GeminiTextGenerator\n", "\n", @@ -1435,40 +1326,9 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 26, "metadata": {}, - "outputs": [ - { - "data": { - "text/markdown": [ - "BigQuery DataFrames are a Python library that provides a Pandas-like interface for interacting with BigQuery data. Instead of loading entire datasets into memory (which is impossible for very large BigQuery tables), BigQuery DataFrames allow you to work with BigQuery data in a way that feels familiar if you've used Pandas, but leverages BigQuery's processing power for efficiency. This means you can perform data analysis and manipulation on datasets that are too large for Pandas to handle directly.\n", - "\n", - "Key features and characteristics include:\n", - "\n", - "* **Lazy Evaluation:** BigQuery DataFrames don't load the entire dataset into memory. Operations are expressed as queries that are executed in BigQuery only when necessary (e.g., when you call `.to_dataframe()` to materialize a result, or when you explicitly trigger execution). This significantly reduces memory consumption.\n", - "\n", - "* **Pandas-like API:** The library aims for a familiar API similar to Pandas. You can use many of the same functions and methods you would use with Pandas DataFrames, such as filtering, selecting columns, aggregations, and joining.\n", - "\n", - "* **Integration with BigQuery:** The library seamlessly integrates with BigQuery. It allows you to read data from BigQuery tables and write data back to BigQuery.\n", - "\n", - "* **Scalability:** Because the processing happens in BigQuery, BigQuery DataFrames can scale to handle datasets of virtually any size. It's designed to efficiently process terabytes or even petabytes of data.\n", - "\n", - "* **Performance:** While providing a user-friendly interface, BigQuery DataFrames leverages BigQuery's optimized query engine for fast execution of operations.\n", - "\n", - "* **SQL integration:** While providing a Pythonic interface, you can easily incorporate SQL queries directly within the DataFrame operations providing flexibility and control over the data manipulation.\n", - "\n", - "\n", - "**In short:** BigQuery DataFrames provide a powerful and efficient way to work with large BigQuery datasets using a familiar Pandas-like syntax without the memory limitations of loading the entire dataset into local memory. They bridge the gap between the ease of use of Pandas and the scalability of BigQuery.\n" - ], - "text/plain": [ - "" - ] - }, - "execution_count": 26, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "# import IPython.display\n", "\n", @@ -1539,7 +1399,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.6" + "version": "3.10.16" } }, "nbformat": 4, diff --git a/notebooks/kaggle/bq_dataframes_ai_forecast.ipynb b/notebooks/kaggle/bq_dataframes_ai_forecast.ipynb index ebccb2c754..87ef9f6e96 100644 --- a/notebooks/kaggle/bq_dataframes_ai_forecast.ipynb +++ b/notebooks/kaggle/bq_dataframes_ai_forecast.ipynb @@ -1 +1,1741 @@ -{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[{"sourceId":110281,"databundleVersionId":13391012,"sourceType":"competition"}],"dockerImageVersionId":31089,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# BigQuery DataFrames (BigFrames) AI Forecast\n\nThis notebook is adapted from https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb to work in the Kaggle runtime. It introduces forecasting with GenAI Foundation Model with BigFrames AI.\n\nInstall the bigframes package and upgrade other packages that are already included in Kaggle but have versions incompatible with bigframes.","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19"}},{"cell_type":"code","source":"%pip install --upgrade bigframes google-cloud-automl google-cloud-translate google-ai-generativelanguage tensorflow ","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"**Important:** restart the kernel by going to \"Run -> Restart & clear cell outputs\" before continuing.\n\nConfigure bigframes to use your GCP project. First, go to \"Add-ons -> Google Cloud SDK\" and click the \"Attach\" button. Then,","metadata":{}},{"cell_type":"code","source":"from kaggle_secrets import UserSecretsClient\nuser_secrets = UserSecretsClient()\nuser_credential = user_secrets.get_gcloud_credential()\nuser_secrets.set_tensorflow_credential(user_credential)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T19:16:10.449563Z","iopub.execute_input":"2025-08-18T19:16:10.449828Z","iopub.status.idle":"2025-08-18T19:16:10.618943Z","shell.execute_reply.started":"2025-08-18T19:16:10.449803Z","shell.execute_reply":"2025-08-18T19:16:10.617631Z"}},"outputs":[],"execution_count":1},{"cell_type":"code","source":"PROJECT = \"swast-scratch\" # replace with your project\n\n\nimport bigframes.pandas as bpd\nbpd.options.bigquery.project = PROJECT\nbpd.options.bigquery.ordering_mode = \"partial\" # Optional: partial ordering mode can accelerate executions and save costs\n\nimport bigframes.exceptions\nimport warnings\nwarnings.filterwarnings(\"ignore\", category=bigframes.exceptions.AmbiguousWindowWarning)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T19:20:00.851472Z","iopub.execute_input":"2025-08-18T19:20:00.851870Z","iopub.status.idle":"2025-08-18T19:20:00.858175Z","shell.execute_reply.started":"2025-08-18T19:20:00.851842Z","shell.execute_reply":"2025-08-18T19:20:00.857098Z"}},"outputs":[],"execution_count":4},{"cell_type":"markdown","source":"## 1. Create a BigFrames DataFrames from BigQuery public data.","metadata":{}},{"cell_type":"code","source":"df = bpd.read_gbq(\"bigquery-public-data.san_francisco_bikeshare.bikeshare_trips\")\ndf","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T19:20:02.254706Z","iopub.execute_input":"2025-08-18T19:20:02.255184Z","iopub.status.idle":"2025-08-18T19:20:04.754064Z","shell.execute_reply.started":"2025-08-18T19:20:02.255149Z","shell.execute_reply":"2025-08-18T19:20:04.752940Z"}},"outputs":[{"name":"stderr","text":"/usr/local/lib/python3.11/dist-packages/bigframes/core/log_adapter.py:175: TimeTravelCacheWarning: Reading cached table from 2025-08-18 19:19:20.590271+00:00 to avoid\nincompatibilies with previous reads of this table. To read the latest\nversion, set `use_cache=False` or close the current session with\nSession.close() or bigframes.pandas.close_session().\n return method(*args, **kwargs)\n","output_type":"stream"},{"execution_count":5,"output_type":"execute_result","data":{"text/plain":" trip_id duration_sec start_date \\\n201802092135083596 788 2018-02-09 21:35:08+00:00 \n201708152357422491 965 2017-08-15 23:57:42+00:00 \n201802281657253632 560 2018-02-28 16:57:25+00:00 \n201711170046091337 497 2017-11-17 00:46:09+00:00 \n201802201913231257 596 2018-02-20 19:13:23+00:00 \n201708242325001279 1341 2017-08-24 23:25:00+00:00 \n201801161800473291 489 2018-01-16 18:00:47+00:00 \n 20180408155601183 1105 2018-04-08 15:56:01+00:00 \n201803141857032204 619 2018-03-14 18:57:03+00:00 \n201708192053311490 743 2017-08-19 20:53:31+00:00 \n201711181823281960 353 2017-11-18 18:23:28+00:00 \n 20170810204454839 1256 2017-08-10 20:44:54+00:00 \n201801171656553504 500 2018-01-17 16:56:55+00:00 \n201801111613101305 858 2018-01-11 16:13:10+00:00 \n201802241826551215 1235 2018-02-24 18:26:55+00:00 \n201803091621483450 857 2018-03-09 16:21:48+00:00 \n201801021932232717 914 2018-01-02 19:32:23+00:00 \n201803161910283751 564 2018-03-16 19:10:28+00:00 \n 20171212152403227 854 2017-12-12 15:24:03+00:00 \n201803131437033724 917 2018-03-13 14:37:03+00:00 \n201712061755593426 519 2017-12-06 17:55:59+00:00 \n 20180404210034451 366 2018-04-04 21:00:34+00:00 \n201801231907161787 626 2018-01-23 19:07:16+00:00 \n201708271057061157 973 2017-08-27 10:57:06+00:00 \n201709071348372074 11434 2017-09-07 13:48:37+00:00 \n\n start_station_name start_station_id end_date \\\n 10th Ave at E 15th St 222 2018-02-09 21:48:17+00:00 \n 10th St at Fallon St 201 2017-08-16 00:13:48+00:00 \n 10th St at Fallon St 201 2018-02-28 17:06:46+00:00 \n 10th St at Fallon St 201 2017-11-17 00:54:26+00:00 \n 10th St at Fallon St 201 2018-02-20 19:23:19+00:00 \n 10th St at Fallon St 201 2017-08-24 23:47:22+00:00 \n 10th St at Fallon St 201 2018-01-16 18:08:56+00:00 \n 13th St at Franklin St 338 2018-04-08 16:14:26+00:00 \n 13th St at Franklin St 338 2018-03-14 19:07:23+00:00 \n 2nd Ave at E 18th St 200 2017-08-19 21:05:54+00:00 \n 2nd Ave at E 18th St 200 2017-11-18 18:29:22+00:00 \n 2nd Ave at E 18th St 200 2017-08-10 21:05:50+00:00 \nEl Embarcadero at Grand Ave 197 2018-01-17 17:05:16+00:00 \n Frank H Ogawa Plaza 7 2018-01-11 16:27:28+00:00 \n Frank H Ogawa Plaza 7 2018-02-24 18:47:31+00:00 \n Frank H Ogawa Plaza 7 2018-03-09 16:36:06+00:00 \n Frank H Ogawa Plaza 7 2018-01-02 19:47:38+00:00 \n Frank H Ogawa Plaza 7 2018-03-16 19:19:52+00:00 \n Frank H Ogawa Plaza 7 2017-12-12 15:38:17+00:00 \n Grand Ave at Webster St 181 2018-03-13 14:52:20+00:00 \n Lake Merritt BART Station 163 2017-12-06 18:04:39+00:00 \n Lake Merritt BART Station 163 2018-04-04 21:06:41+00:00 \n Lake Merritt BART Station 163 2018-01-23 19:17:43+00:00 \n Lake Merritt BART Station 163 2017-08-27 11:13:19+00:00 \n Lake Merritt BART Station 163 2017-09-07 16:59:12+00:00 \n\n end_station_name end_station_id bike_number zip_code ... \\\n10th Ave at E 15th St 222 3596 ... \n10th Ave at E 15th St 222 2491 ... \n10th Ave at E 15th St 222 3632 ... \n10th Ave at E 15th St 222 1337 ... \n10th Ave at E 15th St 222 1257 ... \n10th Ave at E 15th St 222 1279 ... \n10th Ave at E 15th St 222 3291 ... \n10th Ave at E 15th St 222 183 ... \n10th Ave at E 15th St 222 2204 ... \n10th Ave at E 15th St 222 1490 ... \n10th Ave at E 15th St 222 1960 ... \n10th Ave at E 15th St 222 839 ... \n10th Ave at E 15th St 222 3504 ... \n10th Ave at E 15th St 222 1305 ... \n10th Ave at E 15th St 222 1215 ... \n10th Ave at E 15th St 222 3450 ... \n10th Ave at E 15th St 222 2717 ... \n10th Ave at E 15th St 222 3751 ... \n10th Ave at E 15th St 222 227 ... \n10th Ave at E 15th St 222 3724 ... \n10th Ave at E 15th St 222 3426 ... \n10th Ave at E 15th St 222 451 ... \n10th Ave at E 15th St 222 1787 ... \n10th Ave at E 15th St 222 1157 ... \n10th Ave at E 15th St 222 2074 ... \n\nc_subscription_type start_station_latitude start_station_longitude \\\n 37.792714 -122.24878 \n 37.797673 -122.262997 \n 37.797673 -122.262997 \n 37.797673 -122.262997 \n 37.797673 -122.262997 \n 37.797673 -122.262997 \n 37.797673 -122.262997 \n 37.803189 -122.270579 \n 37.803189 -122.270579 \n 37.800214 -122.25381 \n 37.800214 -122.25381 \n 37.800214 -122.25381 \n 37.808848 -122.24968 \n 37.804562 -122.271738 \n 37.804562 -122.271738 \n 37.804562 -122.271738 \n 37.804562 -122.271738 \n 37.804562 -122.271738 \n 37.804562 -122.271738 \n 37.811377 -122.265192 \n 37.79732 -122.26532 \n 37.79732 -122.26532 \n 37.79732 -122.26532 \n 37.79732 -122.26532 \n 37.79732 -122.26532 \n\n end_station_latitude end_station_longitude member_birth_year \\\n 37.792714 -122.24878 1984 \n 37.792714 -122.24878 \n 37.792714 -122.24878 1984 \n 37.792714 -122.24878 \n 37.792714 -122.24878 1984 \n 37.792714 -122.24878 1969 \n 37.792714 -122.24878 1984 \n 37.792714 -122.24878 1987 \n 37.792714 -122.24878 1982 \n 37.792714 -122.24878 \n 37.792714 -122.24878 1988 \n 37.792714 -122.24878 \n 37.792714 -122.24878 1987 \n 37.792714 -122.24878 1984 \n 37.792714 -122.24878 1969 \n 37.792714 -122.24878 1984 \n 37.792714 -122.24878 1984 \n 37.792714 -122.24878 1987 \n 37.792714 -122.24878 1984 \n 37.792714 -122.24878 1989 \n 37.792714 -122.24878 1986 \n 37.792714 -122.24878 1987 \n 37.792714 -122.24878 1987 \n 37.792714 -122.24878 \n 37.792714 -122.24878 \n\n member_gender bike_share_for_all_trip start_station_geom \\\n Male Yes POINT (-122.24878 37.79271) \n POINT (-122.26300 37.79767) \n Male Yes POINT (-122.26300 37.79767) \n POINT (-122.26300 37.79767) \n Male Yes POINT (-122.26300 37.79767) \n Male POINT (-122.26300 37.79767) \n Male Yes POINT (-122.26300 37.79767) \n Female No POINT (-122.27058 37.80319) \n Other No POINT (-122.27058 37.80319) \n POINT (-122.25381 37.80021) \n Male POINT (-122.25381 37.80021) \n POINT (-122.25381 37.80021) \n Male No POINT (-122.24968 37.80885) \n Male Yes POINT (-122.27174 37.80456) \n Male No POINT (-122.27174 37.80456) \n Male Yes POINT (-122.27174 37.80456) \n Male Yes POINT (-122.27174 37.80456) \n Male No POINT (-122.27174 37.80456) \n Male POINT (-122.27174 37.80456) \n Male No POINT (-122.26519 37.81138) \n Male POINT (-122.26532 37.79732) \n Male No POINT (-122.26532 37.79732) \n Male No POINT (-122.26532 37.79732) \n POINT (-122.26532 37.79732) \n POINT (-122.26532 37.79732) \n\n end_station_geom \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \n...\n\n[1947417 rows x 21 columns]","text/html":"
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
trip_idduration_secstart_datestart_station_namestart_station_idend_dateend_station_nameend_station_idbike_numberzip_code...c_subscription_typestart_station_latitudestart_station_longitudeend_station_latitudeend_station_longitudemember_birth_yearmember_genderbike_share_for_all_tripstart_station_geomend_station_geom
02018020921350835967882018-02-09 21:35:08+00:0010th Ave at E 15th St2222018-02-09 21:48:17+00:0010th Ave at E 15th St2223596<NA>...<NA>37.792714-122.2487837.792714-122.248781984MaleYesPOINT (-122.24878 37.79271)POINT (-122.24878 37.79271)
12017081523574224919652017-08-15 23:57:42+00:0010th St at Fallon St2012017-08-16 00:13:48+00:0010th Ave at E 15th St2222491<NA>...<NA>37.797673-122.26299737.792714-122.24878<NA><NA><NA>POINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
22018022816572536325602018-02-28 16:57:25+00:0010th St at Fallon St2012018-02-28 17:06:46+00:0010th Ave at E 15th St2223632<NA>...<NA>37.797673-122.26299737.792714-122.248781984MaleYesPOINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
32017111700460913374972017-11-17 00:46:09+00:0010th St at Fallon St2012017-11-17 00:54:26+00:0010th Ave at E 15th St2221337<NA>...<NA>37.797673-122.26299737.792714-122.24878<NA><NA><NA>POINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
42018022019132312575962018-02-20 19:13:23+00:0010th St at Fallon St2012018-02-20 19:23:19+00:0010th Ave at E 15th St2221257<NA>...<NA>37.797673-122.26299737.792714-122.248781984MaleYesPOINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
520170824232500127913412017-08-24 23:25:00+00:0010th St at Fallon St2012017-08-24 23:47:22+00:0010th Ave at E 15th St2221279<NA>...<NA>37.797673-122.26299737.792714-122.248781969Male<NA>POINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
62018011618004732914892018-01-16 18:00:47+00:0010th St at Fallon St2012018-01-16 18:08:56+00:0010th Ave at E 15th St2223291<NA>...<NA>37.797673-122.26299737.792714-122.248781984MaleYesPOINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
72018040815560118311052018-04-08 15:56:01+00:0013th St at Franklin St3382018-04-08 16:14:26+00:0010th Ave at E 15th St222183<NA>...<NA>37.803189-122.27057937.792714-122.248781987FemaleNoPOINT (-122.27058 37.80319)POINT (-122.24878 37.79271)
82018031418570322046192018-03-14 18:57:03+00:0013th St at Franklin St3382018-03-14 19:07:23+00:0010th Ave at E 15th St2222204<NA>...<NA>37.803189-122.27057937.792714-122.248781982OtherNoPOINT (-122.27058 37.80319)POINT (-122.24878 37.79271)
92017081920533114907432017-08-19 20:53:31+00:002nd Ave at E 18th St2002017-08-19 21:05:54+00:0010th Ave at E 15th St2221490<NA>...<NA>37.800214-122.2538137.792714-122.24878<NA><NA><NA>POINT (-122.25381 37.80021)POINT (-122.24878 37.79271)
102017111818232819603532017-11-18 18:23:28+00:002nd Ave at E 18th St2002017-11-18 18:29:22+00:0010th Ave at E 15th St2221960<NA>...<NA>37.800214-122.2538137.792714-122.248781988Male<NA>POINT (-122.25381 37.80021)POINT (-122.24878 37.79271)
112017081020445483912562017-08-10 20:44:54+00:002nd Ave at E 18th St2002017-08-10 21:05:50+00:0010th Ave at E 15th St222839<NA>...<NA>37.800214-122.2538137.792714-122.24878<NA><NA><NA>POINT (-122.25381 37.80021)POINT (-122.24878 37.79271)
122018011716565535045002018-01-17 16:56:55+00:00El Embarcadero at Grand Ave1972018-01-17 17:05:16+00:0010th Ave at E 15th St2223504<NA>...<NA>37.808848-122.2496837.792714-122.248781987MaleNoPOINT (-122.24968 37.80885)POINT (-122.24878 37.79271)
132018011116131013058582018-01-11 16:13:10+00:00Frank H Ogawa Plaza72018-01-11 16:27:28+00:0010th Ave at E 15th St2221305<NA>...<NA>37.804562-122.27173837.792714-122.248781984MaleYesPOINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
1420180224182655121512352018-02-24 18:26:55+00:00Frank H Ogawa Plaza72018-02-24 18:47:31+00:0010th Ave at E 15th St2221215<NA>...<NA>37.804562-122.27173837.792714-122.248781969MaleNoPOINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
152018030916214834508572018-03-09 16:21:48+00:00Frank H Ogawa Plaza72018-03-09 16:36:06+00:0010th Ave at E 15th St2223450<NA>...<NA>37.804562-122.27173837.792714-122.248781984MaleYesPOINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
162018010219322327179142018-01-02 19:32:23+00:00Frank H Ogawa Plaza72018-01-02 19:47:38+00:0010th Ave at E 15th St2222717<NA>...<NA>37.804562-122.27173837.792714-122.248781984MaleYesPOINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
172018031619102837515642018-03-16 19:10:28+00:00Frank H Ogawa Plaza72018-03-16 19:19:52+00:0010th Ave at E 15th St2223751<NA>...<NA>37.804562-122.27173837.792714-122.248781987MaleNoPOINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
18201712121524032278542017-12-12 15:24:03+00:00Frank H Ogawa Plaza72017-12-12 15:38:17+00:0010th Ave at E 15th St222227<NA>...<NA>37.804562-122.27173837.792714-122.248781984Male<NA>POINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
192018031314370337249172018-03-13 14:37:03+00:00Grand Ave at Webster St1812018-03-13 14:52:20+00:0010th Ave at E 15th St2223724<NA>...<NA>37.811377-122.26519237.792714-122.248781989MaleNoPOINT (-122.26519 37.81138)POINT (-122.24878 37.79271)
202017120617555934265192017-12-06 17:55:59+00:00Lake Merritt BART Station1632017-12-06 18:04:39+00:0010th Ave at E 15th St2223426<NA>...<NA>37.79732-122.2653237.792714-122.248781986Male<NA>POINT (-122.26532 37.79732)POINT (-122.24878 37.79271)
21201804042100344513662018-04-04 21:00:34+00:00Lake Merritt BART Station1632018-04-04 21:06:41+00:0010th Ave at E 15th St222451<NA>...<NA>37.79732-122.2653237.792714-122.248781987MaleNoPOINT (-122.26532 37.79732)POINT (-122.24878 37.79271)
222018012319071617876262018-01-23 19:07:16+00:00Lake Merritt BART Station1632018-01-23 19:17:43+00:0010th Ave at E 15th St2221787<NA>...<NA>37.79732-122.2653237.792714-122.248781987MaleNoPOINT (-122.26532 37.79732)POINT (-122.24878 37.79271)
232017082710570611579732017-08-27 10:57:06+00:00Lake Merritt BART Station1632017-08-27 11:13:19+00:0010th Ave at E 15th St2221157<NA>...<NA>37.79732-122.2653237.792714-122.24878<NA><NA><NA>POINT (-122.26532 37.79732)POINT (-122.24878 37.79271)
24201709071348372074114342017-09-07 13:48:37+00:00Lake Merritt BART Station1632017-09-07 16:59:12+00:0010th Ave at E 15th St2222074<NA>...<NA>37.79732-122.2653237.792714-122.24878<NA><NA><NA>POINT (-122.26532 37.79732)POINT (-122.24878 37.79271)
\n

25 rows × 21 columns

\n
[1947417 rows x 21 columns in total]"},"metadata":{}}],"execution_count":5},{"cell_type":"markdown","source":"## 2. Preprocess Data\n\nOnly take the `start_date` after 2018 and the \"Subscriber\" category as input. `start_date` are truncated to each hour.","metadata":{}},{"cell_type":"code","source":"df = df[df[\"start_date\"] >= \"2018-01-01\"]\ndf = df[df[\"subscriber_type\"] == \"Subscriber\"]\ndf[\"trip_hour\"] = df[\"start_date\"].dt.floor(\"h\")\ndf = df[[\"trip_hour\", \"trip_id\"]]","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T19:20:44.397712Z","iopub.execute_input":"2025-08-18T19:20:44.398876Z","iopub.status.idle":"2025-08-18T19:20:44.421504Z","shell.execute_reply.started":"2025-08-18T19:20:44.398742Z","shell.execute_reply":"2025-08-18T19:20:44.420509Z"}},"outputs":[],"execution_count":6},{"cell_type":"markdown","source":"Group and count each hour's num of trips.","metadata":{}},{"cell_type":"code","source":"df_grouped = df.groupby(\"trip_hour\").count()\ndf_grouped = df_grouped.reset_index().rename(columns={\"trip_id\": \"num_trips\"})\ndf_grouped","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T19:20:57.499571Z","iopub.execute_input":"2025-08-18T19:20:57.500413Z","iopub.status.idle":"2025-08-18T19:21:02.999663Z","shell.execute_reply.started":"2025-08-18T19:20:57.500376Z","shell.execute_reply":"2025-08-18T19:21:02.998792Z"}},"outputs":[{"output_type":"display_data","data":{"text/plain":"","text/html":"Query job e3df71d2-9248-491a-8e5f-4bb5bfedb686 is DONE. 58.7 MB processed. Open Job"},"metadata":{}},{"execution_count":7,"output_type":"execute_result","data":{"text/plain":" trip_hour num_trips\n2018-01-01 00:00:00+00:00 20\n2018-01-01 01:00:00+00:00 25\n2018-01-01 02:00:00+00:00 13\n2018-01-01 03:00:00+00:00 11\n2018-01-01 05:00:00+00:00 4\n2018-01-01 06:00:00+00:00 8\n2018-01-01 07:00:00+00:00 8\n2018-01-01 08:00:00+00:00 20\n2018-01-01 09:00:00+00:00 30\n2018-01-01 10:00:00+00:00 41\n2018-01-01 11:00:00+00:00 45\n2018-01-01 12:00:00+00:00 54\n2018-01-01 13:00:00+00:00 57\n2018-01-01 14:00:00+00:00 68\n2018-01-01 15:00:00+00:00 86\n2018-01-01 16:00:00+00:00 72\n2018-01-01 17:00:00+00:00 72\n2018-01-01 18:00:00+00:00 47\n2018-01-01 19:00:00+00:00 32\n2018-01-01 20:00:00+00:00 34\n2018-01-01 21:00:00+00:00 27\n2018-01-01 22:00:00+00:00 15\n2018-01-01 23:00:00+00:00 6\n2018-01-02 00:00:00+00:00 2\n2018-01-02 01:00:00+00:00 1\n...\n\n[2842 rows x 2 columns]","text/html":"
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
trip_hournum_trips
02018-01-01 00:00:00+00:0020
12018-01-01 01:00:00+00:0025
22018-01-01 02:00:00+00:0013
32018-01-01 03:00:00+00:0011
42018-01-01 05:00:00+00:004
52018-01-01 06:00:00+00:008
62018-01-01 07:00:00+00:008
72018-01-01 08:00:00+00:0020
82018-01-01 09:00:00+00:0030
92018-01-01 10:00:00+00:0041
102018-01-01 11:00:00+00:0045
112018-01-01 12:00:00+00:0054
122018-01-01 13:00:00+00:0057
132018-01-01 14:00:00+00:0068
142018-01-01 15:00:00+00:0086
152018-01-01 16:00:00+00:0072
162018-01-01 17:00:00+00:0072
172018-01-01 18:00:00+00:0047
182018-01-01 19:00:00+00:0032
192018-01-01 20:00:00+00:0034
202018-01-01 21:00:00+00:0027
212018-01-01 22:00:00+00:0015
222018-01-01 23:00:00+00:006
232018-01-02 00:00:00+00:002
242018-01-02 01:00:00+00:001
\n

25 rows × 2 columns

\n
[2842 rows x 2 columns in total]"},"metadata":{}}],"execution_count":7},{"cell_type":"markdown","source":"## 3. Make forecastings for next 1 week with DataFrames.ai.forecast API","metadata":{}},{"cell_type":"code","source":"# Using all the data except the last week (2842-168) for training. And predict the last week (168).\nresult = df_grouped.head(2842-168).ai.forecast(timestamp_column=\"trip_hour\", data_column=\"num_trips\", horizon=168) \nresult","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T19:22:58.943589Z","iopub.execute_input":"2025-08-18T19:22:58.944068Z","iopub.status.idle":"2025-08-18T19:23:11.364356Z","shell.execute_reply.started":"2025-08-18T19:22:58.944036Z","shell.execute_reply":"2025-08-18T19:23:11.363152Z"}},"outputs":[{"output_type":"display_data","data":{"text/plain":"","text/html":"Query job 3f1225a8-b80b-4dfa-a7cf-94b93e7c18c2 is DONE. 68.2 kB processed. Open Job"},"metadata":{}},{"execution_count":8,"output_type":"execute_result","data":{"text/plain":" forecast_timestamp forecast_value confidence_level \\\n2018-04-24 12:00:00+00:00 144.577728 0.95 \n2018-04-25 00:00:00+00:00 54.215515 0.95 \n2018-04-26 05:00:00+00:00 8.140533 0.95 \n2018-04-26 14:00:00+00:00 198.744949 0.95 \n2018-04-27 02:00:00+00:00 9.91806 0.95 \n2018-04-29 03:00:00+00:00 32.063339 0.95 \n2018-04-27 04:00:00+00:00 25.757111 0.95 \n2018-04-30 06:00:00+00:00 89.808456 0.95 \n2018-04-30 02:00:00+00:00 -10.584175 0.95 \n2018-04-30 05:00:00+00:00 18.118111 0.95 \n2018-04-24 07:00:00+00:00 359.036957 0.95 \n2018-04-25 10:00:00+00:00 227.272049 0.95 \n2018-04-27 15:00:00+00:00 208.631363 0.95 \n2018-04-25 13:00:00+00:00 159.799911 0.95 \n2018-04-26 12:00:00+00:00 190.226944 0.95 \n2018-04-24 04:00:00+00:00 11.162338 0.95 \n2018-04-24 14:00:00+00:00 136.70816 0.95 \n2018-04-28 21:00:00+00:00 65.308899 0.95 \n2018-04-29 20:00:00+00:00 71.788849 0.95 \n2018-04-30 15:00:00+00:00 142.560944 0.95 \n2018-04-26 18:00:00+00:00 533.783813 0.95 \n2018-04-28 03:00:00+00:00 25.379761 0.95 \n2018-04-30 12:00:00+00:00 158.313385 0.95 \n2018-04-25 07:00:00+00:00 358.756592 0.95 \n2018-04-27 22:00:00+00:00 103.589096 0.95 \n\n prediction_interval_lower_bound prediction_interval_upper_bound \\\n 120.01921 169.136247 \n 46.8394 61.591631 \n -14.613272 30.894339 \n 174.982268 222.50763 \n -26.749948 46.586069 \n -35.730978 99.857656 \n 8.178037 43.336184 \n 15.214961 164.401952 \n -60.772024 39.603674 \n -40.902133 77.138355 \n 250.880334 467.193579 \n 170.918819 283.625279 \n 188.977435 228.285291 \n 150.066363 169.53346 \n 177.898865 202.555023 \n -18.581041 40.905717 \n 134.165413 139.250907 \n 63.000915 67.616883 \n -2.49023 146.067928 \n 41.495553 243.626334 \n 412.068752 655.498875 \n 22.565752 28.193769 \n 79.466457 237.160313 \n 276.305603 441.207581 \n 94.45235 112.725842 \n\nai_forecast_status \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n...\n\n[168 rows x 6 columns]","text/html":"
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
forecast_timestampforecast_valueconfidence_levelprediction_interval_lower_boundprediction_interval_upper_boundai_forecast_status
02018-04-24 12:00:00+00:00144.5777280.95120.01921169.136247
12018-04-25 00:00:00+00:0054.2155150.9546.839461.591631
22018-04-26 05:00:00+00:008.1405330.95-14.61327230.894339
32018-04-26 14:00:00+00:00198.7449490.95174.982268222.50763
42018-04-27 02:00:00+00:009.918060.95-26.74994846.586069
52018-04-29 03:00:00+00:0032.0633390.95-35.73097899.857656
62018-04-27 04:00:00+00:0025.7571110.958.17803743.336184
72018-04-30 06:00:00+00:0089.8084560.9515.214961164.401952
82018-04-30 02:00:00+00:00-10.5841750.95-60.77202439.603674
92018-04-30 05:00:00+00:0018.1181110.95-40.90213377.138355
102018-04-24 07:00:00+00:00359.0369570.95250.880334467.193579
112018-04-25 10:00:00+00:00227.2720490.95170.918819283.625279
122018-04-27 15:00:00+00:00208.6313630.95188.977435228.285291
132018-04-25 13:00:00+00:00159.7999110.95150.066363169.53346
142018-04-26 12:00:00+00:00190.2269440.95177.898865202.555023
152018-04-24 04:00:00+00:0011.1623380.95-18.58104140.905717
162018-04-24 14:00:00+00:00136.708160.95134.165413139.250907
172018-04-28 21:00:00+00:0065.3088990.9563.00091567.616883
182018-04-29 20:00:00+00:0071.7888490.95-2.49023146.067928
192018-04-30 15:00:00+00:00142.5609440.9541.495553243.626334
202018-04-26 18:00:00+00:00533.7838130.95412.068752655.498875
212018-04-28 03:00:00+00:0025.3797610.9522.56575228.193769
222018-04-30 12:00:00+00:00158.3133850.9579.466457237.160313
232018-04-25 07:00:00+00:00358.7565920.95276.305603441.207581
242018-04-27 22:00:00+00:00103.5890960.9594.45235112.725842
\n

25 rows × 6 columns

\n
[168 rows x 6 columns in total]"},"metadata":{}}],"execution_count":8},{"cell_type":"markdown","source":"# 4. Process the raw result and draw a line plot along with the training data","metadata":{}},{"cell_type":"code","source":"result = result.sort_values(\"forecast_timestamp\")\nresult = result[[\"forecast_timestamp\", \"forecast_value\"]]\nresult = result.rename(columns={\"forecast_timestamp\": \"trip_hour\", \"forecast_value\": \"num_trips_forecast\"})\ndf_all = bpd.concat([df_grouped, result])\ndf_all = df_all.tail(672) # 4 weeks","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T19:27:08.305886Z","iopub.execute_input":"2025-08-18T19:27:08.306367Z","iopub.status.idle":"2025-08-18T19:27:08.318514Z","shell.execute_reply.started":"2025-08-18T19:27:08.306336Z","shell.execute_reply":"2025-08-18T19:27:08.317016Z"}},"outputs":[],"execution_count":9},{"cell_type":"markdown","source":"Plot a line chart and compare with the actual result.","metadata":{}},{"cell_type":"code","source":"df_all = df_all.set_index(\"trip_hour\")\ndf_all.plot.line(figsize=(16, 8))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T19:27:19.461164Z","iopub.execute_input":"2025-08-18T19:27:19.461528Z","iopub.status.idle":"2025-08-18T19:27:20.737558Z","shell.execute_reply.started":"2025-08-18T19:27:19.461497Z","shell.execute_reply":"2025-08-18T19:27:20.736422Z"}},"outputs":[{"execution_count":10,"output_type":"execute_result","data":{"text/plain":""},"metadata":{}},{"output_type":"display_data","data":{"text/plain":"
","image/png":"iVBORw0KGgoAAAANSUhEUgAABREAAAKnCAYAAAARNgr5AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8pXeV/AAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOz9e7wlV13mjz+rau99ujtN5zZJd6IhBIkDgQAx+IU2jjAQE0JEgSCKjEM0oz95BRnIgMoYSQgIyA9QhKCoEHAQncEvMBi5JEQCSEK4XwQHFIgdzI0Rkk6TdJ+9q9b3j6pVtdbatc/pWmudtfap87xfr37tc+ldp2rvXatqPev5fB4hpZQghBBCCCGEEEIIIYSQBWSpd4AQQgghhBBCCCGEELLcUEQkhBBCCCGEEEIIIYSsCUVEQgghhBBCCCGEEELImlBEJIQQQgghhBBCCCGErAlFREIIIYQQQgghhBBCyJpQRCSEEEIIIYQQQgghhKwJRURCCCGEEEIIIYQQQsiaUEQkhBBCCCGEEEIIIYSsySj1DrhSliVuvfVW3O9+94MQIvXuEEIIIYQQQgghhBCyqZBS4p577sGJJ56ILFvba7hpRcRbb70VJ510UurdIIQQQgghhBBCCCFkU3PLLbfgB3/wB9f8P5tWRLzf/e4HoDrIXbt2Jd4bQgghhBBCCCGEEEI2F/v378dJJ53U6GxrsWlFRFXCvGvXLoqIhBBCCCGEEEIIIYQ4cjitAhmsQgghhBBCCCGEEEIIWROKiIQQQgghhBBCCCGEkDWhiEgIIYQQQgghhBBCCFmTTdsT8XCQUmI2m6EoitS7Qog3eZ5jNBodVp8CQgghhBBCCCGEkJAMVkRcXV3FbbfdhnvvvTf1rhASjB07duCEE07AZDJJvSuEEEIIIYQQQgjZQgxSRCzLEt/61reQ5zlOPPFETCYTurfIpkZKidXVVXznO9/Bt771LZx66qnIMnYjIIQQQgghhBBCSBwGKSKurq6iLEucdNJJ2LFjR+rdISQI27dvx3g8xr/8y79gdXUV27ZtS71LhBBCCCGEEEII2SIM2spEpxYZGvxME0IIIYQQQgghJAVUJAghhBBCCCGEEEIIIWtCEZEQQgghhBBCCCGEELImFBFJUC6//HI88pGPTL0bhBBCCCGEEEIIISQgFBHJujzucY/D85///MP6vy984Qtx3XXXbewOEUIIIYQQQgghhJCoDDKdmcRHSomiKLBz507s3Lkz9e4QQgghhBBCCCGEkIBsGSeilBL3rs6S/JNSHvZ+Pu5xj8Pznvc8/MZv/AaOOeYY7NmzB5dffjkA4Oabb4YQAl/4whea/3/XXXdBCIHrr78eAHD99ddDCIEPfehDOOOMM7B9+3Y8/vGPx5133okPfOADeMhDHoJdu3bhF37hF3Dvvfeuuz8XXnghPvrRj+L1r389hBAQQuDmm29u/s4HPvABnHnmmVhZWcHf//3fz5UzX3jhhXjKU56Cl770pTjuuOOwa9cu/Nqv/RpWV1eb//PXf/3XOP3007F9+3Yce+yxOPvss/H973//sF8zQgghhBBCCCGEELKxbBkn4n3TAqe95ENJ/vZXrzgXOyaH/1K//e1vxyWXXIKbbroJN954Iy688EKcddZZOPXUUw97G5dffjne+MY3YseOHXjGM56BZzzjGVhZWcE73/lOHDhwAE996lPxhje8Ab/5m7+55nZe//rX4+tf/zoe9rCH4YorrgAAHHfccbj55psBAL/1W7+F17zmNXjgAx+Io48+uhEzda677jps27YN119/PW6++Wb80i/9Eo499lj87u/+Lm677TY885nPxKtf/Wo89alPxT333IOPf/zjvYRXQgghhBBCCCGEELKxbBkRcTPx8Ic/HJdddhkA4NRTT8Ub3/hGXHfddb1ExJe//OU466yzAAAXXXQRXvziF+Mb3/gGHvjABwIAnv70p+MjH/nIuiLikUceiclkgh07dmDPnj1zv7/iiivwkz/5k2tuYzKZ4K1vfSt27NiBhz70objiiivwohe9CC972ctw2223YTab4WlPexpOPvlkAMDpp59+2MdJCCGEEEIIIYQQQjaeLSMibh/n+OoV5yb72314+MMfbnx/wgkn4M4773Texu7du7Fjx45GQFQ/+9SnPtVrm1086lGPWvf/POIRj8COHTua7/fu3YsDBw7glltuwSMe8Qg84QlPwOmnn45zzz0X55xzDp7+9Kfj6KOP9t43QgghhBBCCCGEEBKGLSMiCiF6lRSnZDweG98LIVCWJbKsamGpl/pOp9N1tyGEWLhNX4444giv5+d5jmuvvRY33HADrrnmGrzhDW/Ab//2b+Omm27CKaec4r1/hBBCCCGEEEIIIcSfLROsMgSOO+44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BAA8/elPx0c+8pF1RcQjjzwSk8kEO3bswJ49e+Z+f8UVV+Anf/In19zGZDLBW9/6VuzYsQMPfehDccUVV+BFL3oRXvayl+G2227DbDbD0572NJx88skAgNNPP33d49u5cyeOOuooADD26zWveQ1+8zd/Ez//8z8PAPi93/s9fOQjH8Ef/MEf4Morr2z+3/Of/3w87WlPa76/7LLL8NrXvrb52SmnnIKvfvWrePOb34xnP/vZeOc734nvfOc7+PSnP41jjjkGAPCgBz2oef4jHvEIPOIRj2i+f9nLXob3vOc9eN/73ofnPve52LdvH4444gj81E/9FO53v/vh5JNPxhlnnHFYrzEhhBBCCCFDgD0RCSEpoRPRn60jIo53VI7AVH+7Bw9/+MON70844QTceeedztvYvXs3duzY0QiI6mef+tSnem2zi0c96lHr/p9HPOIR2LGjfQ327t2LAwcO4JZbbsEjHvEIPOEJT8Dpp5+Oc889F+eccw6e/vSn4+ijj+69L/v378ett97aiKeKs846C1/84hcX7vf3v/99fOMb38BFF12EX/mVX2l+PpvNcOSRRwIAvvCFL+CMM85oBESbAwcO4PLLL8ff/u3fNsLofffdh3379gEAfvInfxInn3wyHvjAB+KJT3winvjEJ+KpT32q8boQQgghhBAyZOhEJISkRBU8UkR0Z+uIiEL0KilOiR3yIYRAWZbIsqqFpV7qO51O192GEGLhNn054gi/1zTPc1x77bW44YYbcM011+ANb3gDfvu3fxs33XQTTjnlFO/9W4S+3wcOHAAA/Omf/ike/ehHz+0fAGzfvn3N7b3whS/Etddei9e85jV40IMehO3bt+PpT3960/vxfve7Hz73uc/h+uuvxzXXXIOXvOQluPzyy/HpT3+6cVQSQgghhBAyZGTjROQEnhASn7JU4U7VYkaeuQe0blW2TrDKAFB98vQegHrIykYxmUxQFIXz87/4xS/ivvvua77/5Cc/iZ07d+Kkk04CUAmaZ511Fl760pfi85//PCaTCd7znvf0/ju7du3CiSeeiE984hPGzz/xiU/gtNNOW/i83bt348QTT8Q3v/lNPOhBDzL+KSHz4Q9/OL7whS/gu9/9buc2PvGJT+DCCy/EU5/6VJx++unYs2fPXDjKaDTC2WefjVe/+tX40pe+hJtvvhl/93d/B8D/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\n"},"metadata":{}}],"execution_count":10},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]} +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "_cell_guid": "b1076dfc-b9ad-4769-8c92-a6c4dae69d19", + "_uuid": "8f2839f25d086af736a60e9eeb907d3b93b6e0e5" + }, + "source": [ + "# BigQuery DataFrames (BigFrames) AI Forecast\n", + "\n", + "This notebook is adapted from https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb to work in the Kaggle runtime. It introduces forecasting with GenAI Foundation Model with BigFrames AI.\n", + "\n", + "Install the bigframes package and upgrade other packages that are already included in Kaggle but have versions incompatible with bigframes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "trusted": true + }, + "outputs": [], + "source": [ + "%pip install --upgrade bigframes google-cloud-automl google-cloud-translate google-ai-generativelanguage tensorflow " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Important:** restart the kernel by going to \"Run -> Restart & clear cell outputs\" before continuing.\n", + "\n", + "Configure bigframes to use your GCP project. First, go to \"Add-ons -> Google Cloud SDK\" and click the \"Attach\" button. Then," + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T19:16:10.449828Z", + "iopub.status.busy": "2025-08-18T19:16:10.449563Z", + "iopub.status.idle": "2025-08-18T19:16:10.618943Z", + "shell.execute_reply": "2025-08-18T19:16:10.617631Z", + "shell.execute_reply.started": "2025-08-18T19:16:10.449803Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "from kaggle_secrets import UserSecretsClient\n", + "user_secrets = UserSecretsClient()\n", + "user_credential = user_secrets.get_gcloud_credential()\n", + "user_secrets.set_tensorflow_credential(user_credential)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T19:20:00.851870Z", + "iopub.status.busy": "2025-08-18T19:20:00.851472Z", + "iopub.status.idle": "2025-08-18T19:20:00.858175Z", + "shell.execute_reply": "2025-08-18T19:20:00.857098Z", + "shell.execute_reply.started": "2025-08-18T19:20:00.851842Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "PROJECT = \"swast-scratch\" # replace with your project\n", + "\n", + "\n", + "import bigframes.pandas as bpd\n", + "bpd.options.bigquery.project = PROJECT\n", + "bpd.options.bigquery.ordering_mode = \"partial\" # Optional: partial ordering mode can accelerate executions and save costs" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Create a BigFrames DataFrames from BigQuery public data." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T19:20:02.255184Z", + "iopub.status.busy": "2025-08-18T19:20:02.254706Z", + "iopub.status.idle": "2025-08-18T19:20:04.754064Z", + "shell.execute_reply": "2025-08-18T19:20:04.752940Z", + "shell.execute_reply.started": "2025-08-18T19:20:02.255149Z" + }, + "trusted": true + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/lib/python3.11/dist-packages/bigframes/core/log_adapter.py:175: TimeTravelCacheWarning: Reading cached table from 2025-08-18 19:19:20.590271+00:00 to avoid\n", + "incompatibilies with previous reads of this table. To read the latest\n", + "version, set `use_cache=False` or close the current session with\n", + "Session.close() or bigframes.pandas.close_session().\n", + " return method(*args, **kwargs)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
trip_idduration_secstart_datestart_station_namestart_station_idend_dateend_station_nameend_station_idbike_numberzip_code...c_subscription_typestart_station_latitudestart_station_longitudeend_station_latitudeend_station_longitudemember_birth_yearmember_genderbike_share_for_all_tripstart_station_geomend_station_geom
02018020921350835967882018-02-09 21:35:08+00:0010th Ave at E 15th St2222018-02-09 21:48:17+00:0010th Ave at E 15th St2223596<NA>...<NA>37.792714-122.2487837.792714-122.248781984MaleYesPOINT (-122.24878 37.79271)POINT (-122.24878 37.79271)
12017081523574224919652017-08-15 23:57:42+00:0010th St at Fallon St2012017-08-16 00:13:48+00:0010th Ave at E 15th St2222491<NA>...<NA>37.797673-122.26299737.792714-122.24878<NA><NA><NA>POINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
22018022816572536325602018-02-28 16:57:25+00:0010th St at Fallon St2012018-02-28 17:06:46+00:0010th Ave at E 15th St2223632<NA>...<NA>37.797673-122.26299737.792714-122.248781984MaleYesPOINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
32017111700460913374972017-11-17 00:46:09+00:0010th St at Fallon St2012017-11-17 00:54:26+00:0010th Ave at E 15th St2221337<NA>...<NA>37.797673-122.26299737.792714-122.24878<NA><NA><NA>POINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
42018022019132312575962018-02-20 19:13:23+00:0010th St at Fallon St2012018-02-20 19:23:19+00:0010th Ave at E 15th St2221257<NA>...<NA>37.797673-122.26299737.792714-122.248781984MaleYesPOINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
520170824232500127913412017-08-24 23:25:00+00:0010th St at Fallon St2012017-08-24 23:47:22+00:0010th Ave at E 15th St2221279<NA>...<NA>37.797673-122.26299737.792714-122.248781969Male<NA>POINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
62018011618004732914892018-01-16 18:00:47+00:0010th St at Fallon St2012018-01-16 18:08:56+00:0010th Ave at E 15th St2223291<NA>...<NA>37.797673-122.26299737.792714-122.248781984MaleYesPOINT (-122.26300 37.79767)POINT (-122.24878 37.79271)
72018040815560118311052018-04-08 15:56:01+00:0013th St at Franklin St3382018-04-08 16:14:26+00:0010th Ave at E 15th St222183<NA>...<NA>37.803189-122.27057937.792714-122.248781987FemaleNoPOINT (-122.27058 37.80319)POINT (-122.24878 37.79271)
82018031418570322046192018-03-14 18:57:03+00:0013th St at Franklin St3382018-03-14 19:07:23+00:0010th Ave at E 15th St2222204<NA>...<NA>37.803189-122.27057937.792714-122.248781982OtherNoPOINT (-122.27058 37.80319)POINT (-122.24878 37.79271)
92017081920533114907432017-08-19 20:53:31+00:002nd Ave at E 18th St2002017-08-19 21:05:54+00:0010th Ave at E 15th St2221490<NA>...<NA>37.800214-122.2538137.792714-122.24878<NA><NA><NA>POINT (-122.25381 37.80021)POINT (-122.24878 37.79271)
102017111818232819603532017-11-18 18:23:28+00:002nd Ave at E 18th St2002017-11-18 18:29:22+00:0010th Ave at E 15th St2221960<NA>...<NA>37.800214-122.2538137.792714-122.248781988Male<NA>POINT (-122.25381 37.80021)POINT (-122.24878 37.79271)
112017081020445483912562017-08-10 20:44:54+00:002nd Ave at E 18th St2002017-08-10 21:05:50+00:0010th Ave at E 15th St222839<NA>...<NA>37.800214-122.2538137.792714-122.24878<NA><NA><NA>POINT (-122.25381 37.80021)POINT (-122.24878 37.79271)
122018011716565535045002018-01-17 16:56:55+00:00El Embarcadero at Grand Ave1972018-01-17 17:05:16+00:0010th Ave at E 15th St2223504<NA>...<NA>37.808848-122.2496837.792714-122.248781987MaleNoPOINT (-122.24968 37.80885)POINT (-122.24878 37.79271)
132018011116131013058582018-01-11 16:13:10+00:00Frank H Ogawa Plaza72018-01-11 16:27:28+00:0010th Ave at E 15th St2221305<NA>...<NA>37.804562-122.27173837.792714-122.248781984MaleYesPOINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
1420180224182655121512352018-02-24 18:26:55+00:00Frank H Ogawa Plaza72018-02-24 18:47:31+00:0010th Ave at E 15th St2221215<NA>...<NA>37.804562-122.27173837.792714-122.248781969MaleNoPOINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
152018030916214834508572018-03-09 16:21:48+00:00Frank H Ogawa Plaza72018-03-09 16:36:06+00:0010th Ave at E 15th St2223450<NA>...<NA>37.804562-122.27173837.792714-122.248781984MaleYesPOINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
162018010219322327179142018-01-02 19:32:23+00:00Frank H Ogawa Plaza72018-01-02 19:47:38+00:0010th Ave at E 15th St2222717<NA>...<NA>37.804562-122.27173837.792714-122.248781984MaleYesPOINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
172018031619102837515642018-03-16 19:10:28+00:00Frank H Ogawa Plaza72018-03-16 19:19:52+00:0010th Ave at E 15th St2223751<NA>...<NA>37.804562-122.27173837.792714-122.248781987MaleNoPOINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
18201712121524032278542017-12-12 15:24:03+00:00Frank H Ogawa Plaza72017-12-12 15:38:17+00:0010th Ave at E 15th St222227<NA>...<NA>37.804562-122.27173837.792714-122.248781984Male<NA>POINT (-122.27174 37.80456)POINT (-122.24878 37.79271)
192018031314370337249172018-03-13 14:37:03+00:00Grand Ave at Webster St1812018-03-13 14:52:20+00:0010th Ave at E 15th St2223724<NA>...<NA>37.811377-122.26519237.792714-122.248781989MaleNoPOINT (-122.26519 37.81138)POINT (-122.24878 37.79271)
202017120617555934265192017-12-06 17:55:59+00:00Lake Merritt BART Station1632017-12-06 18:04:39+00:0010th Ave at E 15th St2223426<NA>...<NA>37.79732-122.2653237.792714-122.248781986Male<NA>POINT (-122.26532 37.79732)POINT (-122.24878 37.79271)
21201804042100344513662018-04-04 21:00:34+00:00Lake Merritt BART Station1632018-04-04 21:06:41+00:0010th Ave at E 15th St222451<NA>...<NA>37.79732-122.2653237.792714-122.248781987MaleNoPOINT (-122.26532 37.79732)POINT (-122.24878 37.79271)
222018012319071617876262018-01-23 19:07:16+00:00Lake Merritt BART Station1632018-01-23 19:17:43+00:0010th Ave at E 15th St2221787<NA>...<NA>37.79732-122.2653237.792714-122.248781987MaleNoPOINT (-122.26532 37.79732)POINT (-122.24878 37.79271)
232017082710570611579732017-08-27 10:57:06+00:00Lake Merritt BART Station1632017-08-27 11:13:19+00:0010th Ave at E 15th St2221157<NA>...<NA>37.79732-122.2653237.792714-122.24878<NA><NA><NA>POINT (-122.26532 37.79732)POINT (-122.24878 37.79271)
24201709071348372074114342017-09-07 13:48:37+00:00Lake Merritt BART Station1632017-09-07 16:59:12+00:0010th Ave at E 15th St2222074<NA>...<NA>37.79732-122.2653237.792714-122.24878<NA><NA><NA>POINT (-122.26532 37.79732)POINT (-122.24878 37.79271)
\n", + "

25 rows × 21 columns

\n", + "
[1947417 rows x 21 columns in total]" + ], + "text/plain": [ + " trip_id duration_sec start_date \\\n", + "201802092135083596 788 2018-02-09 21:35:08+00:00 \n", + "201708152357422491 965 2017-08-15 23:57:42+00:00 \n", + "201802281657253632 560 2018-02-28 16:57:25+00:00 \n", + "201711170046091337 497 2017-11-17 00:46:09+00:00 \n", + "201802201913231257 596 2018-02-20 19:13:23+00:00 \n", + "201708242325001279 1341 2017-08-24 23:25:00+00:00 \n", + "201801161800473291 489 2018-01-16 18:00:47+00:00 \n", + " 20180408155601183 1105 2018-04-08 15:56:01+00:00 \n", + "201803141857032204 619 2018-03-14 18:57:03+00:00 \n", + "201708192053311490 743 2017-08-19 20:53:31+00:00 \n", + "201711181823281960 353 2017-11-18 18:23:28+00:00 \n", + " 20170810204454839 1256 2017-08-10 20:44:54+00:00 \n", + "201801171656553504 500 2018-01-17 16:56:55+00:00 \n", + "201801111613101305 858 2018-01-11 16:13:10+00:00 \n", + "201802241826551215 1235 2018-02-24 18:26:55+00:00 \n", + "201803091621483450 857 2018-03-09 16:21:48+00:00 \n", + "201801021932232717 914 2018-01-02 19:32:23+00:00 \n", + "201803161910283751 564 2018-03-16 19:10:28+00:00 \n", + " 20171212152403227 854 2017-12-12 15:24:03+00:00 \n", + "201803131437033724 917 2018-03-13 14:37:03+00:00 \n", + "201712061755593426 519 2017-12-06 17:55:59+00:00 \n", + " 20180404210034451 366 2018-04-04 21:00:34+00:00 \n", + "201801231907161787 626 2018-01-23 19:07:16+00:00 \n", + "201708271057061157 973 2017-08-27 10:57:06+00:00 \n", + "201709071348372074 11434 2017-09-07 13:48:37+00:00 \n", + "\n", + " start_station_name start_station_id end_date \\\n", + " 10th Ave at E 15th St 222 2018-02-09 21:48:17+00:00 \n", + " 10th St at Fallon St 201 2017-08-16 00:13:48+00:00 \n", + " 10th St at Fallon St 201 2018-02-28 17:06:46+00:00 \n", + " 10th St at Fallon St 201 2017-11-17 00:54:26+00:00 \n", + " 10th St at Fallon St 201 2018-02-20 19:23:19+00:00 \n", + " 10th St at Fallon St 201 2017-08-24 23:47:22+00:00 \n", + " 10th St at Fallon St 201 2018-01-16 18:08:56+00:00 \n", + " 13th St at Franklin St 338 2018-04-08 16:14:26+00:00 \n", + " 13th St at Franklin St 338 2018-03-14 19:07:23+00:00 \n", + " 2nd Ave at E 18th St 200 2017-08-19 21:05:54+00:00 \n", + " 2nd Ave at E 18th St 200 2017-11-18 18:29:22+00:00 \n", + " 2nd Ave at E 18th St 200 2017-08-10 21:05:50+00:00 \n", + "El Embarcadero at Grand Ave 197 2018-01-17 17:05:16+00:00 \n", + " Frank H Ogawa Plaza 7 2018-01-11 16:27:28+00:00 \n", + " Frank H Ogawa Plaza 7 2018-02-24 18:47:31+00:00 \n", + " Frank H Ogawa Plaza 7 2018-03-09 16:36:06+00:00 \n", + " Frank H Ogawa Plaza 7 2018-01-02 19:47:38+00:00 \n", + " Frank H Ogawa Plaza 7 2018-03-16 19:19:52+00:00 \n", + " Frank H Ogawa Plaza 7 2017-12-12 15:38:17+00:00 \n", + " Grand Ave at Webster St 181 2018-03-13 14:52:20+00:00 \n", + " Lake Merritt BART Station 163 2017-12-06 18:04:39+00:00 \n", + " Lake Merritt BART Station 163 2018-04-04 21:06:41+00:00 \n", + " Lake Merritt BART Station 163 2018-01-23 19:17:43+00:00 \n", + " Lake Merritt BART Station 163 2017-08-27 11:13:19+00:00 \n", + " Lake Merritt BART Station 163 2017-09-07 16:59:12+00:00 \n", + "\n", + " end_station_name end_station_id bike_number zip_code ... \\\n", + "10th Ave at E 15th St 222 3596 ... \n", + "10th Ave at E 15th St 222 2491 ... \n", + "10th Ave at E 15th St 222 3632 ... \n", + "10th Ave at E 15th St 222 1337 ... \n", + "10th Ave at E 15th St 222 1257 ... \n", + "10th Ave at E 15th St 222 1279 ... \n", + "10th Ave at E 15th St 222 3291 ... \n", + "10th Ave at E 15th St 222 183 ... \n", + "10th Ave at E 15th St 222 2204 ... \n", + "10th Ave at E 15th St 222 1490 ... \n", + "10th Ave at E 15th St 222 1960 ... \n", + "10th Ave at E 15th St 222 839 ... \n", + "10th Ave at E 15th St 222 3504 ... \n", + "10th Ave at E 15th St 222 1305 ... \n", + "10th Ave at E 15th St 222 1215 ... \n", + "10th Ave at E 15th St 222 3450 ... \n", + "10th Ave at E 15th St 222 2717 ... \n", + "10th Ave at E 15th St 222 3751 ... \n", + "10th Ave at E 15th St 222 227 ... \n", + "10th Ave at E 15th St 222 3724 ... \n", + "10th Ave at E 15th St 222 3426 ... \n", + "10th Ave at E 15th St 222 451 ... \n", + "10th Ave at E 15th St 222 1787 ... \n", + "10th Ave at E 15th St 222 1157 ... \n", + "10th Ave at E 15th St 222 2074 ... \n", + "\n", + "c_subscription_type start_station_latitude start_station_longitude \\\n", + " 37.792714 -122.24878 \n", + " 37.797673 -122.262997 \n", + " 37.797673 -122.262997 \n", + " 37.797673 -122.262997 \n", + " 37.797673 -122.262997 \n", + " 37.797673 -122.262997 \n", + " 37.797673 -122.262997 \n", + " 37.803189 -122.270579 \n", + " 37.803189 -122.270579 \n", + " 37.800214 -122.25381 \n", + " 37.800214 -122.25381 \n", + " 37.800214 -122.25381 \n", + " 37.808848 -122.24968 \n", + " 37.804562 -122.271738 \n", + " 37.804562 -122.271738 \n", + " 37.804562 -122.271738 \n", + " 37.804562 -122.271738 \n", + " 37.804562 -122.271738 \n", + " 37.804562 -122.271738 \n", + " 37.811377 -122.265192 \n", + " 37.79732 -122.26532 \n", + " 37.79732 -122.26532 \n", + " 37.79732 -122.26532 \n", + " 37.79732 -122.26532 \n", + " 37.79732 -122.26532 \n", + "\n", + " end_station_latitude end_station_longitude member_birth_year \\\n", + " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 \n", + " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 \n", + " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 1969 \n", + " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 1987 \n", + " 37.792714 -122.24878 1982 \n", + " 37.792714 -122.24878 \n", + " 37.792714 -122.24878 1988 \n", + " 37.792714 -122.24878 \n", + " 37.792714 -122.24878 1987 \n", + " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 1969 \n", + " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 1987 \n", + " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 1989 \n", + " 37.792714 -122.24878 1986 \n", + " 37.792714 -122.24878 1987 \n", + " 37.792714 -122.24878 1987 \n", + " 37.792714 -122.24878 \n", + " 37.792714 -122.24878 \n", + "\n", + " member_gender bike_share_for_all_trip start_station_geom \\\n", + " Male Yes POINT (-122.24878 37.79271) \n", + " POINT (-122.26300 37.79767) \n", + " Male Yes POINT (-122.26300 37.79767) \n", + " POINT (-122.26300 37.79767) \n", + " Male Yes POINT (-122.26300 37.79767) \n", + " Male POINT (-122.26300 37.79767) \n", + " Male Yes POINT (-122.26300 37.79767) \n", + " Female No POINT (-122.27058 37.80319) \n", + " Other No POINT (-122.27058 37.80319) \n", + " POINT (-122.25381 37.80021) \n", + " Male POINT (-122.25381 37.80021) \n", + " POINT (-122.25381 37.80021) \n", + " Male No POINT (-122.24968 37.80885) \n", + " Male Yes POINT (-122.27174 37.80456) \n", + " Male No POINT (-122.27174 37.80456) \n", + " Male Yes POINT (-122.27174 37.80456) \n", + " Male Yes POINT (-122.27174 37.80456) \n", + " Male No POINT (-122.27174 37.80456) \n", + " Male POINT (-122.27174 37.80456) \n", + " Male No POINT (-122.26519 37.81138) \n", + " Male POINT (-122.26532 37.79732) \n", + " Male No POINT (-122.26532 37.79732) \n", + " Male No POINT (-122.26532 37.79732) \n", + " POINT (-122.26532 37.79732) \n", + " POINT (-122.26532 37.79732) \n", + "\n", + " end_station_geom \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "POINT (-122.24878 37.79271) \n", + "...\n", + "\n", + "[1947417 rows x 21 columns]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = bpd.read_gbq(\"bigquery-public-data.san_francisco_bikeshare.bikeshare_trips\")\n", + "df" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Preprocess Data\n", + "\n", + "Only take the `start_date` after 2018 and the \"Subscriber\" category as input. `start_date` are truncated to each hour." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T19:20:44.398876Z", + "iopub.status.busy": "2025-08-18T19:20:44.397712Z", + "iopub.status.idle": "2025-08-18T19:20:44.421504Z", + "shell.execute_reply": "2025-08-18T19:20:44.420509Z", + "shell.execute_reply.started": "2025-08-18T19:20:44.398742Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "df = df[df[\"start_date\"] >= \"2018-01-01\"]\n", + "df = df[df[\"subscriber_type\"] == \"Subscriber\"]\n", + "df[\"trip_hour\"] = df[\"start_date\"].dt.floor(\"h\")\n", + "df = df[[\"trip_hour\", \"trip_id\"]]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Group and count each hour's num of trips." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T19:20:57.500413Z", + "iopub.status.busy": "2025-08-18T19:20:57.499571Z", + "iopub.status.idle": "2025-08-18T19:21:02.999663Z", + "shell.execute_reply": "2025-08-18T19:21:02.998792Z", + "shell.execute_reply.started": "2025-08-18T19:20:57.500376Z" + }, + "trusted": true + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job e3df71d2-9248-491a-8e5f-4bb5bfedb686 is DONE. 58.7 MB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
trip_hournum_trips
02018-01-01 00:00:00+00:0020
12018-01-01 01:00:00+00:0025
22018-01-01 02:00:00+00:0013
32018-01-01 03:00:00+00:0011
42018-01-01 05:00:00+00:004
52018-01-01 06:00:00+00:008
62018-01-01 07:00:00+00:008
72018-01-01 08:00:00+00:0020
82018-01-01 09:00:00+00:0030
92018-01-01 10:00:00+00:0041
102018-01-01 11:00:00+00:0045
112018-01-01 12:00:00+00:0054
122018-01-01 13:00:00+00:0057
132018-01-01 14:00:00+00:0068
142018-01-01 15:00:00+00:0086
152018-01-01 16:00:00+00:0072
162018-01-01 17:00:00+00:0072
172018-01-01 18:00:00+00:0047
182018-01-01 19:00:00+00:0032
192018-01-01 20:00:00+00:0034
202018-01-01 21:00:00+00:0027
212018-01-01 22:00:00+00:0015
222018-01-01 23:00:00+00:006
232018-01-02 00:00:00+00:002
242018-01-02 01:00:00+00:001
\n", + "

25 rows × 2 columns

\n", + "
[2842 rows x 2 columns in total]" + ], + "text/plain": [ + " trip_hour num_trips\n", + "2018-01-01 00:00:00+00:00 20\n", + "2018-01-01 01:00:00+00:00 25\n", + "2018-01-01 02:00:00+00:00 13\n", + "2018-01-01 03:00:00+00:00 11\n", + "2018-01-01 05:00:00+00:00 4\n", + "2018-01-01 06:00:00+00:00 8\n", + "2018-01-01 07:00:00+00:00 8\n", + "2018-01-01 08:00:00+00:00 20\n", + "2018-01-01 09:00:00+00:00 30\n", + "2018-01-01 10:00:00+00:00 41\n", + "2018-01-01 11:00:00+00:00 45\n", + "2018-01-01 12:00:00+00:00 54\n", + "2018-01-01 13:00:00+00:00 57\n", + "2018-01-01 14:00:00+00:00 68\n", + "2018-01-01 15:00:00+00:00 86\n", + "2018-01-01 16:00:00+00:00 72\n", + "2018-01-01 17:00:00+00:00 72\n", + "2018-01-01 18:00:00+00:00 47\n", + "2018-01-01 19:00:00+00:00 32\n", + "2018-01-01 20:00:00+00:00 34\n", + "2018-01-01 21:00:00+00:00 27\n", + "2018-01-01 22:00:00+00:00 15\n", + "2018-01-01 23:00:00+00:00 6\n", + "2018-01-02 00:00:00+00:00 2\n", + "2018-01-02 01:00:00+00:00 1\n", + "...\n", + "\n", + "[2842 rows x 2 columns]" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_grouped = df.groupby(\"trip_hour\").count()\n", + "df_grouped = df_grouped.reset_index().rename(columns={\"trip_id\": \"num_trips\"})\n", + "df_grouped" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Make forecastings for next 1 week with DataFrames.ai.forecast API" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T19:22:58.944068Z", + "iopub.status.busy": "2025-08-18T19:22:58.943589Z", + "iopub.status.idle": "2025-08-18T19:23:11.364356Z", + "shell.execute_reply": "2025-08-18T19:23:11.363152Z", + "shell.execute_reply.started": "2025-08-18T19:22:58.944036Z" + }, + "trusted": true + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 3f1225a8-b80b-4dfa-a7cf-94b93e7c18c2 is DONE. 68.2 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
forecast_timestampforecast_valueconfidence_levelprediction_interval_lower_boundprediction_interval_upper_boundai_forecast_status
02018-04-24 12:00:00+00:00144.5777280.95120.01921169.136247
12018-04-25 00:00:00+00:0054.2155150.9546.839461.591631
22018-04-26 05:00:00+00:008.1405330.95-14.61327230.894339
32018-04-26 14:00:00+00:00198.7449490.95174.982268222.50763
42018-04-27 02:00:00+00:009.918060.95-26.74994846.586069
52018-04-29 03:00:00+00:0032.0633390.95-35.73097899.857656
62018-04-27 04:00:00+00:0025.7571110.958.17803743.336184
72018-04-30 06:00:00+00:0089.8084560.9515.214961164.401952
82018-04-30 02:00:00+00:00-10.5841750.95-60.77202439.603674
92018-04-30 05:00:00+00:0018.1181110.95-40.90213377.138355
102018-04-24 07:00:00+00:00359.0369570.95250.880334467.193579
112018-04-25 10:00:00+00:00227.2720490.95170.918819283.625279
122018-04-27 15:00:00+00:00208.6313630.95188.977435228.285291
132018-04-25 13:00:00+00:00159.7999110.95150.066363169.53346
142018-04-26 12:00:00+00:00190.2269440.95177.898865202.555023
152018-04-24 04:00:00+00:0011.1623380.95-18.58104140.905717
162018-04-24 14:00:00+00:00136.708160.95134.165413139.250907
172018-04-28 21:00:00+00:0065.3088990.9563.00091567.616883
182018-04-29 20:00:00+00:0071.7888490.95-2.49023146.067928
192018-04-30 15:00:00+00:00142.5609440.9541.495553243.626334
202018-04-26 18:00:00+00:00533.7838130.95412.068752655.498875
212018-04-28 03:00:00+00:0025.3797610.9522.56575228.193769
222018-04-30 12:00:00+00:00158.3133850.9579.466457237.160313
232018-04-25 07:00:00+00:00358.7565920.95276.305603441.207581
242018-04-27 22:00:00+00:00103.5890960.9594.45235112.725842
\n", + "

25 rows × 6 columns

\n", + "
[168 rows x 6 columns in total]" + ], + "text/plain": [ + " forecast_timestamp forecast_value confidence_level \\\n", + "2018-04-24 12:00:00+00:00 144.577728 0.95 \n", + "2018-04-25 00:00:00+00:00 54.215515 0.95 \n", + "2018-04-26 05:00:00+00:00 8.140533 0.95 \n", + "2018-04-26 14:00:00+00:00 198.744949 0.95 \n", + "2018-04-27 02:00:00+00:00 9.91806 0.95 \n", + "2018-04-29 03:00:00+00:00 32.063339 0.95 \n", + "2018-04-27 04:00:00+00:00 25.757111 0.95 \n", + "2018-04-30 06:00:00+00:00 89.808456 0.95 \n", + "2018-04-30 02:00:00+00:00 -10.584175 0.95 \n", + "2018-04-30 05:00:00+00:00 18.118111 0.95 \n", + "2018-04-24 07:00:00+00:00 359.036957 0.95 \n", + "2018-04-25 10:00:00+00:00 227.272049 0.95 \n", + "2018-04-27 15:00:00+00:00 208.631363 0.95 \n", + "2018-04-25 13:00:00+00:00 159.799911 0.95 \n", + "2018-04-26 12:00:00+00:00 190.226944 0.95 \n", + "2018-04-24 04:00:00+00:00 11.162338 0.95 \n", + "2018-04-24 14:00:00+00:00 136.70816 0.95 \n", + "2018-04-28 21:00:00+00:00 65.308899 0.95 \n", + "2018-04-29 20:00:00+00:00 71.788849 0.95 \n", + "2018-04-30 15:00:00+00:00 142.560944 0.95 \n", + "2018-04-26 18:00:00+00:00 533.783813 0.95 \n", + "2018-04-28 03:00:00+00:00 25.379761 0.95 \n", + "2018-04-30 12:00:00+00:00 158.313385 0.95 \n", + "2018-04-25 07:00:00+00:00 358.756592 0.95 \n", + "2018-04-27 22:00:00+00:00 103.589096 0.95 \n", + "\n", + " prediction_interval_lower_bound prediction_interval_upper_bound \\\n", + " 120.01921 169.136247 \n", + " 46.8394 61.591631 \n", + " -14.613272 30.894339 \n", + " 174.982268 222.50763 \n", + " -26.749948 46.586069 \n", + " -35.730978 99.857656 \n", + " 8.178037 43.336184 \n", + " 15.214961 164.401952 \n", + " -60.772024 39.603674 \n", + " -40.902133 77.138355 \n", + " 250.880334 467.193579 \n", + " 170.918819 283.625279 \n", + " 188.977435 228.285291 \n", + " 150.066363 169.53346 \n", + " 177.898865 202.555023 \n", + " -18.581041 40.905717 \n", + " 134.165413 139.250907 \n", + " 63.000915 67.616883 \n", + " -2.49023 146.067928 \n", + " 41.495553 243.626334 \n", + " 412.068752 655.498875 \n", + " 22.565752 28.193769 \n", + " 79.466457 237.160313 \n", + " 276.305603 441.207581 \n", + " 94.45235 112.725842 \n", + "\n", + "ai_forecast_status \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "...\n", + "\n", + "[168 rows x 6 columns]" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Using all the data except the last week (2842-168) for training. And predict the last week (168).\n", + "result = df_grouped.head(2842-168).ai.forecast(timestamp_column=\"trip_hour\", data_column=\"num_trips\", horizon=168) \n", + "result" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 4. Process the raw result and draw a line plot along with the training data" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T19:27:08.306367Z", + "iopub.status.busy": "2025-08-18T19:27:08.305886Z", + "iopub.status.idle": "2025-08-18T19:27:08.318514Z", + "shell.execute_reply": "2025-08-18T19:27:08.317016Z", + "shell.execute_reply.started": "2025-08-18T19:27:08.306336Z" + }, + "trusted": true + }, + "outputs": [], + "source": [ + "result = result.sort_values(\"forecast_timestamp\")\n", + "result = result[[\"forecast_timestamp\", \"forecast_value\"]]\n", + "result = result.rename(columns={\"forecast_timestamp\": \"trip_hour\", \"forecast_value\": \"num_trips_forecast\"})\n", + "df_all = bpd.concat([df_grouped, result])\n", + "df_all = df_all.tail(672) # 4 weeks" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Plot a line chart and compare with the actual result." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T19:27:19.461528Z", + "iopub.status.busy": "2025-08-18T19:27:19.461164Z", + "iopub.status.idle": "2025-08-18T19:27:20.737558Z", + "shell.execute_reply": "2025-08-18T19:27:20.736422Z", + "shell.execute_reply.started": "2025-08-18T19:27:19.461497Z" + }, + "trusted": true + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAABREAAAKnCAYAAAARNgr5AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8pXeV/AAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOz9e7wlV13mjz+rau99ujtN5zZJd6IhBIkDgQAx+IU2jjAQE0JEgSCKjEM0oz95BRnIgMoYSQgIyA9QhKCoEHAQncEvMBi5JEQCSEK4XwQHFIgdzI0Rkk6TdJ+9q9b3j6pVtdbatc/pWmudtfap87xfr37tc+ldp2rvXatqPev5fB4hpZQghBBCCCGEEEIIIYSQBWSpd4AQQgghhBBCCCGEELLcUEQkhBBCCCGEEEIIIYSsCUVEQgghhBBCCCGEEELImlBEJIQQQgghhBBCCCGErAlFREIIIYQQQgghhBBCyJpQRCSEEEIIIYQQQgghhKwJRURCCCGEEEIIIYQQQsiaUEQkhBBCCCGEEEIIIYSsySj1DrhSliVuvfVW3O9+94MQIvXuEEIIIYQQQgghhBCyqZBS4p577sGJJ56ILFvba7hpRcRbb70VJ510UurdIIQQQgghhBBCCCFkU3PLLbfgB3/wB9f8P5tWRLzf/e4HoDrIXbt2Jd4bQgghhBBCCCGEEEI2F/v378dJJ53U6GxrsWlFRFXCvGvXLoqIhBBCCCGEEEIIIYQ4cjitAhmsQgghhBBCCCGEEEIIWROKiIQQQgghhBBCCCGEkDWhiEgIIYQQQgghhBBCCFmTTdsT8XCQUmI2m6EoitS7Qog3eZ5jNBodVp8CQgghhBBCCCGEkJAMVkRcXV3FbbfdhnvvvTf1rhASjB07duCEE07AZDJJvSuEEEIIIYQQQgjZQgxSRCzLEt/61reQ5zlOPPFETCYTurfIpkZKidXVVXznO9/Bt771LZx66qnIMnYjIIQQQgghhBBCSBwGKSKurq6iLEucdNJJ2LFjR+rdISQI27dvx3g8xr/8y79gdXUV27ZtS71LhBBCCCGEEEII2SIM2spEpxYZGvxME0IIIYQQQgghJAVUJAghhBBCCCGEEEIIIWtCEZEQQgghhBBCCCGEELImFBFJUC6//HI88pGPTL0bhBBCCCGEEEIIISQgFBHJujzucY/D85///MP6vy984Qtx3XXXbewOEUIIIYQQQgghhJCoDDKdmcRHSomiKLBz507s3Lkz9e4QQgghhBBCCCGEkIBsGSeilBL3rs6S/JNSHvZ+Pu5xj8Pznvc8/MZv/AaOOeYY7NmzB5dffjkA4Oabb4YQAl/4whea/3/XXXdBCIHrr78eAHD99ddDCIEPfehDOOOMM7B9+3Y8/vGPx5133okPfOADeMhDHoJdu3bhF37hF3Dvvfeuuz8XXnghPvrRj+L1r389hBAQQuDmm29u/s4HPvABnHnmmVhZWcHf//3fz5UzX3jhhXjKU56Cl770pTjuuOOwa9cu/Nqv/RpWV1eb//PXf/3XOP3007F9+3Yce+yxOPvss/H973//sF8zQgghhBBCCCGEELKxbBkn4n3TAqe95ENJ/vZXrzgXOyaH/1K//e1vxyWXXIKbbroJN954Iy688EKcddZZOPXUUw97G5dffjne+MY3YseOHXjGM56BZzzjGVhZWcE73/lOHDhwAE996lPxhje8Ab/5m7+55nZe//rX4+tf/zoe9rCH4YorrgAAHHfccbj55psBAL/1W7+F17zmNXjgAx+Io48+uhEzda677jps27YN119/PW6++Wb80i/9Eo499lj87u/+Lm677TY885nPxKtf/Wo89alPxT333IOPf/zjvYRXQgghhBBCCCGEELKxbBkRcTPx8Ic/HJdddhkA4NRTT8Ub3/hGXHfddb1ExJe//OU466yzAAAXXXQRXvziF+Mb3/gGHvjABwIAnv70p+MjH/nIuiLikUceiclkgh07dmDPnj1zv7/iiivwkz/5k2tuYzKZ4K1vfSt27NiBhz70objiiivwohe9CC972ctw2223YTab4WlPexpOPvlkAMDpp59+2MdJCCGEEEIIIYQQQjaeLSMibh/n+OoV5yb72314+MMfbnx/wgkn4M4773Texu7du7Fjx45GQFQ/+9SnPtVrm1086lGPWvf/POIRj8COHTua7/fu3YsDBw7glltuwSMe8Qg84QlPwOmnn45zzz0X55xzDp7+9Kfj6KOP9t43QgghhBBCCCGEEBKGLSMiCiF6lRSnZDweG98LIVCWJbKsamGpl/pOp9N1tyGEWLhNX4444giv5+d5jmuvvRY33HADrrnmGrzhDW/Ab//2b+Omm27CKaec4r1/hBBCCCGEEEIIIcSfLROsMgSOO+44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BAA8/elPx0c+8pF1RcQjjzwSk8kEO3bswJ49e+Z+f8UVV+Anf/In19zGZDLBW9/6VuzYsQMPfehDccUVV+BFL3oRXvayl+G2227DbDbD0572NJx88skAgNNPP33d49u5cyeOOuooADD26zWveQ1+8zd/Ez//8z8PAPi93/s9fOQjH8Ef/MEf4Morr2z+3/Of/3w87WlPa76/7LLL8NrXvrb52SmnnIKvfvWrePOb34xnP/vZeOc734nvfOc7+PSnP41jjjkGAPCgBz2oef4jHvEIPOIRj2i+f9nLXob3vOc9eN/73ofnPve52LdvH4444gj81E/9FO53v/vh5JNPxhlnnHFYrzEhhBBCCCFDgD0RCSEpoRPRn60jIo53VI7AVH+7Bw9/+MON70844QTceeedztvYvXs3duzY0QiI6mef+tSnem2zi0c96lHr/p9HPOIR2LGjfQ327t2LAwcO4JZbbsEjHvEIPOEJT8Dpp5+Oc889F+eccw6e/vSn4+ijj+69L/v378ett97aiKeKs846C1/84hcX7vf3v/99fOMb38BFF12EX/mVX2l+PpvNcOSRRwIAvvCFL+CMM85oBESbAwcO4PLLL8ff/u3fNsLofffdh3379gEAfvInfxInn3wyHvjAB+KJT3winvjEJ+KpT32q8boQQgghhBAyZOhEJISkRBU8UkR0Z+uIiEL0KilOiR3yIYRAWZbIsqqFpV7qO51O192GEGLhNn054gi/1zTPc1x77bW44YYbcM011+ANb3gDfvu3fxs33XQTTjnlFO/9W4S+3wcOHAAA/Omf/ike/ehHz+0fAGzfvn3N7b3whS/Etddei9e85jV40IMehO3bt+PpT3960/vxfve7Hz73uc/h+uuvxzXXXIOXvOQluPzyy/HpT3+6cVQSQgghhBAyZGTjROQEnhASn7JU4U7VYkaeuQe0blW2TrDKAFB98vQegHrIykYxmUxQFIXz87/4xS/ivvvua77/5Cc/iZ07d+Kkk04CUAmaZ511Fl760pfi85//PCaTCd7znvf0/ju7du3CiSeeiE984hPGzz/xiU/gtNNOW/i83bt348QTT8Q3v/lNPOhBDzL+KSHz4Q9/OL7whS/gu9/9buc2PvGJT+DCCy/EU5/6VJx++unYs2fPXDjKaDTC2WefjVe/+tX40pe+hJtvvhl/93d/B8D/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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "df_all = df_all.set_index(\"trip_hour\")\n", + "df_all.plot.line(figsize=(16, 8))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "trusted": true + }, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kaggle": { + "accelerator": "none", + "dataSources": [ + { + "databundleVersionId": 13391012, + "sourceId": 110281, + "sourceType": "competition" + } + ], + "dockerImageVersionId": 31089, + "isGpuEnabled": false, + "isInternetEnabled": true, + "language": "python", + "sourceType": "notebook" + }, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.13" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/notebooks/visualization/tutorial.ipynb b/notebooks/visualization/tutorial.ipynb index 0923e03bc7..ab838a89f0 100644 --- a/notebooks/visualization/tutorial.ipynb +++ b/notebooks/visualization/tutorial.ipynb @@ -90,7 +90,7 @@ "metadata": {}, "outputs": [], "source": [ - "PROJECT_ID = \"\" # @param {type:\"string\"}\n", + "PROJECT_ID = \"bigframes-dev\" # @param {type:\"string\"}\n", "REGION = \"US\" # @param {type: \"string\"}" ] }, @@ -147,18 +147,6 @@ "id": "fb595a8f", "metadata": {}, "outputs": [ - { - "data": { - "text/html": [ - "Query job caa8554f-5d26-48d7-b8be-25689cd6e307 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/html": [ @@ -296,7 +284,7 @@ }, { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjIAAAGdCAYAAAAIbpn/AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvc2/+5QAAAAlwSFlzAAAPYQAAD2EBqD+naQAAJvBJREFUeJzt3Xt0lPWBxvFnyOQKuRggmaQEgiQBEQFFiqzBglAusqwEquAFCNLuosGK4SJovaDYIApilUpPDybQHsXSBUQpeOESagE5oEDZpVxiQkACQTQJCZKEzOwfHmYdE0IymcnML3w/58w5vO+8875P8gby8Ht/M6/F4XA4BAAAYKBWvg4AAADgLooMAAAwFkUGAAAYiyIDAACMRZEBAADGosgAAABjUWQAAICxKDIAAMBYVl8H8Da73a5Tp04pPDxcFovF13EAAEADOBwOnT9/XvHx8WrV6srjLi2+yJw6dUoJCQm+jgEAANxw4sQJdejQ4YrPt/giEx4eLun7b0RERISP0wAAgIYoKytTQkKC8/f4lbT4InP5clJERARFBgAAw1xtWgiTfQEAgLEoMgAAwFgUGQAAYKwWP0cGAFoKh8OhS5cuqaamxtdRgCYLCAiQ1Wpt8kejUGQAwABVVVUqKirShQsXfB0F8JiwsDDFxcUpKCjI7X1QZADAz9ntduXn5ysgIEDx8fEKCgriAz5hNIfDoaqqKp09e1b5+flKTk6u90Pv6kORAQA/V1VVJbvdroSEBIWFhfk6DuARoaGhCgwM1PHjx1VVVaWQkBC39sNkXwAwhLv/YwX8lSd+pvlbAQAAjEWRAQAAxmKODAAYLHHOhmY9XsGCkc16vJycHE2fPl0lJSXNelxPGDhwoHr37q0lS5Z4/VgWi0Vr167V6NGjvX4sf8OIDAAAhnjuuefUu3dvX8fwKxQZAABgLIoMAMCr7Ha7Fi5cqKSkJAUHB6tjx4568cUXtW3bNlksFpfLRvv27ZPFYlFBQUGd+7o8IvHWW2+pY8eOatOmjR555BHV1NRo4cKFstlsiomJ0YsvvujyupKSEv3yl79U+/btFRERoTvvvFP79++vtd8//elPSkxMVGRkpMaPH6/z58836GusqKjQxIkT1aZNG8XFxWnRokW1tqmsrNTMmTP1k5/8RK1bt1a/fv20bds25/M5OTmKiorSunXrlJycrJCQEA0bNkwnTpxwPj9v3jzt379fFotFFotFOTk5ztd//fXXSktLU1hYmJKTk7V+/foGZb98Hj788EPdfPPNCg0N1Z133qni4mJt3LhRN9xwgyIiInT//fe7fCDjwIED9eijj2r69Om67rrrFBsbqz/+8Y+qqKjQ5MmTFR4erqSkJG3cuLFBOdzFHBkAfqcx8z6ae84GGm/u3Ln64x//qFdffVWpqakqKirSv/71L7f3l5eXp40bN2rTpk3Ky8vTL37xC3355ZdKSUlRbm6uduzYoYceekhDhgxRv379JEn33HOPQkNDtXHjRkVGRuoPf/iDBg8erCNHjig6Otq533Xr1umDDz7Qt99+q3vvvVcLFiyoVYrqMmvWLOXm5uq9995TTEyMnnzySX3++ecul4GmTZum//3f/9WqVasUHx+vtWvXavjw4frnP/+p5ORkSdKFCxf04osvauXKlQoKCtIjjzyi8ePH6x//+IfGjRungwcPatOmTfrkk08kSZGRkc79z5s3TwsXLtTLL7+s119/XQ888ICOHz/u/Pqu5rnnntMbb7yhsLAw3Xvvvbr33nsVHByst99+W+Xl5UpLS9Prr7+uJ554wvmaFStWaPbs2dq9e7feffddPfzww1q7dq3S0tL05JNP6tVXX9WECRNUWFjotc9AYkQGAOA158+f12uvvaaFCxdq0qRJ6tKli1JTU/XLX/7S7X3a7Xa99dZb6t69u0aNGqVBgwbp8OHDWrJkibp27arJkyera9eu2rp1qyTp008/1e7du7V69WrdeuutSk5O1iuvvKKoqCj99a9/ddlvTk6OevTooQEDBmjChAnavHnzVfOUl5dr+fLleuWVVzR48GDddNNNWrFihS5duuTcprCwUNnZ2Vq9erUGDBigLl26aObMmUpNTVV2drZzu+rqar3xxhvq37+/+vTpoxUrVmjHjh3avXu3QkND1aZNG1mtVtlsNtlsNoWGhjpfm56ervvuu09JSUn67W9/q/Lycu3evbvB39f58+fr9ttv180336wpU6YoNzdXb775pm6++WYNGDBAv/jFL5zf08t69eql3/zmN0pOTtbcuXMVEhKidu3a6Ve/+pWSk5P1zDPP6Ny5czpw4ECDczQWIzIAAK85dOiQKisrNXjwYI/tMzExUeHh4c7l2NhYBQQEuHy4WmxsrIqLiyVJ+/fvV3l5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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjIAAAGdCAYAAAAIbpn/AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAJvBJREFUeJzt3Xt0lPWBxvFnyOQKuRggmaQEgiQBEQFFiqzBglAusqwEquAFCNLuosGK4SJovaDYIApilUpPDybQHsXSBUQpeOESagE5oEDZpVxiQkACQTQJCZKEzOwfHmYdE0IymcnML3w/58w5vO+8875P8gby8Ht/M6/F4XA4BAAAYKBWvg4AAADgLooMAAAwFkUGAAAYiyIDAACMRZEBAADGosgAAABjUWQAAICxKDIAAMBYVl8H8Da73a5Tp04pPDxcFovF13EAAEADOBwOnT9/XvHx8WrV6srjLi2+yJw6dUoJCQm+jgEAANxw4sQJdejQ4YrPt/giEx4eLun7b0RERISP0wAAgIYoKytTQkKC8/f4lbT4InP5clJERARFBgAAw1xtWgiTfQEAgLEoMgAAwFgUGQAAYKwWP0cGAFoKh8OhS5cuqaamxtdRgCYLCAiQ1Wpt8kejUGQAwABVVVUqKirShQsXfB0F8JiwsDDFxcUpKCjI7X1QZADAz9ntduXn5ysgIEDx8fEKCgriAz5hNIfDoaqqKp09e1b5+flKTk6u90Pv6kORAQA/V1VVJbvdroSEBIWFhfk6DuARoaGhCgwM1PHjx1VVVaWQkBC39sNkXwAwhLv/YwX8lSd+pvlbAQAAjEWRAQAAxmKODAAYLHHOhmY9XsGCkc16vJycHE2fPl0lJSXNelxPGDhwoHr37q0lS5Z4/VgWi0Vr167V6NGjvX4sf8OIDAAAhnjuuefUu3dvX8fwKxQZAABgLIoMAMCr7Ha7Fi5cqKSkJAUHB6tjx4568cUXtW3bNlksFpfLRvv27ZPFYlFBQUGd+7o8IvHWW2+pY8eOatOmjR555BHV1NRo4cKFstlsiomJ0YsvvujyupKSEv3yl79U+/btFRERoTvvvFP79++vtd8//elPSkxMVGRkpMaPH6/z58836GusqKjQxIkT1aZNG8XFxWnRokW1tqmsrNTMmTP1k5/8RK1bt1a/fv20bds25/M5OTmKiorSunXrlJycrJCQEA0bNkwnTpxwPj9v3jzt379fFotFFotFOTk5ztd//fXXSktLU1hYmJKTk7V+/foGZb98Hj788EPdfPPNCg0N1Z133qni4mJt3LhRN9xwgyIiInT//fe7fCDjwIED9eijj2r69Om67rrrFBsbqz/+8Y+qqKjQ5MmTFR4erqSkJG3cuLFBOdzFHBkAfqcx8z6ae84GGm/u3Ln64x//qFdffVWpqakqKirSv/71L7f3l5eXp40bN2rTpk3Ky8vTL37xC3355ZdKSUlRbm6uduzYoYceekhDhgxRv379JEn33HOPQkNDtXHjRkVGRuoPf/iDBg8erCNHjig6Otq533Xr1umDDz7Qt99+q3vvvVcLFiyoVYrqMmvWLOXm5uq9995TTEyMnnzySX3++ecul4GmTZum//3f/9WqVasUHx+vtWvXavjw4frnP/+p5ORkSdKFCxf04osvauXKlQoKCtIjjzyi8ePH6x//+IfGjRungwcPatOmTfrkk08kSZGRkc79z5s3TwsXLtTLL7+s119/XQ888ICOHz/u/Pqu5rnnntMbb7yhsLAw3Xvvvbr33nsVHByst99+W+Xl5UpLS9Prr7+uJ554wvmaFStWaPbs2dq9e7feffddPfzww1q7dq3S0tL05JNP6tVXX9WECRNUWFjotc9AYkQGAOA158+f12uvvaaFCxdq0qRJ6tKli1JTU/XLX/7S7X3a7Xa99dZb6t69u0aNGqVBgwbp8OHDWrJkibp27arJkyera9eu2rp1qyTp008/1e7du7V69WrdeuutSk5O1iuvvKKoqCj99a9/ddlvTk6OevTooQEDBmjChAnavHnzVfOUl5dr+fLleuWVVzR48GDddNNNWrFihS5duuTcprCwUNnZ2Vq9erUGDBigLl26aObMmUpNTVV2drZzu+rqar3xxhvq37+/+vTpoxUrVmjHjh3avXu3QkND1aZNG1mtVtlsNtlsNoWGhjpfm56ervvuu09JSUn67W9/q/Lycu3evbvB39f58+fr9ttv180336wpU6YoNzdXb775pm6++WYNGDBAv/jFL5zf08t69eql3/zmN0pOTtbcuXMVEhKidu3a6Ve/+pWSk5P1zDPP6Ny5czpw4ECDczQWIzIAAK85dOiQKisrNXjwYI/tMzExUeHh4c7l2NhYBQQEuHy4WmxsrIqLiyVJ+/fvV3l5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", "text/plain": [ "
" ] @@ -378,18 +366,18 @@ " \n", " \n", " 0\n", - " 010014\n", + " 010030\n", " 99999\n", - " 2021-02-09\n", + " 2021-11-10\n", " 2021\n", - " 02\n", - " 09\n", - " 23.9\n", + " 11\n", + " 10\n", + " 26.4\n", " 4\n", - " 3.2\n", + " 17.9\n", " 4\n", " ...\n", - " *\n", + " <NA>\n", " 0.0\n", " I\n", " 999.9\n", @@ -402,20 +390,20 @@ " \n", " \n", " 1\n", - " 010014\n", + " 010030\n", " 99999\n", - " 2021-03-19\n", + " 2021-02-01\n", " 2021\n", - " 03\n", - " 19\n", - " 41.9\n", + " 02\n", + " 01\n", + " 8.9\n", " 4\n", - " 31.1\n", + " 0.5\n", " 4\n", " ...\n", - " *\n", - " 0.0\n", - " I\n", + " <NA>\n", + " 2.76\n", + " G\n", " 999.9\n", " 0\n", " 0\n", @@ -426,24 +414,24 @@ " \n", " \n", " 2\n", - " 010030\n", + " 010060\n", " 99999\n", - " 2021-02-23\n", + " 2021-07-22\n", " 2021\n", - " 02\n", - " 23\n", - " 31.5\n", - " 4\n", - " 30.1\n", + " 07\n", + " 22\n", + " 34.4\n", " 4\n", + " 9999.9\n", + " 0\n", " ...\n", " <NA>\n", - " 0.19\n", - " E\n", + " 0.0\n", + " I\n", " 999.9\n", " 0\n", - " 1\n", - " 1\n", + " 0\n", + " 0\n", " 0\n", " 0\n", " 0\n", @@ -452,13 +440,13 @@ " 3\n", " 010070\n", " 99999\n", - " 2021-02-21\n", + " 2021-04-05\n", " 2021\n", - " 02\n", - " 21\n", - " 24.7\n", + " 04\n", + " 05\n", + " 17.9\n", " 4\n", - " 15.7\n", + " 6.4\n", " 4\n", " ...\n", " <NA>\n", @@ -476,14 +464,14 @@ " 4\n", " 010070\n", " 99999\n", - " 2021-01-28\n", + " 2021-02-04\n", " 2021\n", - " 01\n", - " 28\n", - " 4.1\n", - " 4\n", - " -5.4\n", + " 02\n", + " 04\n", + " 19.1\n", " 4\n", + " 9999.9\n", + " 0\n", " ...\n", " <NA>\n", " 0.0\n", @@ -502,24 +490,24 @@ "" ], "text/plain": [ - " stn wban date year mo da temp count_temp dewp \\\n", - "0 010014 99999 2021-02-09 2021 02 09 23.9 4 3.2 \n", - "1 010014 99999 2021-03-19 2021 03 19 41.9 4 31.1 \n", - "2 010030 99999 2021-02-23 2021 02 23 31.5 4 30.1 \n", - "3 010070 99999 2021-02-21 2021 02 21 24.7 4 15.7 \n", - "4 010070 99999 2021-01-28 2021 01 28 4.1 4 -5.4 \n", + " stn wban date year mo da temp count_temp dewp \\\n", + "0 010030 99999 2021-11-10 2021 11 10 26.4 4 17.9 \n", + "1 010030 99999 2021-02-01 2021 02 01 8.9 4 0.5 \n", + "2 010060 99999 2021-07-22 2021 07 22 34.4 4 9999.9 \n", + "3 010070 99999 2021-04-05 2021 04 05 17.9 4 6.4 \n", + "4 010070 99999 2021-02-04 2021 02 04 19.1 4 9999.9 \n", "\n", " count_dewp ... flag_min prcp flag_prcp sndp fog rain_drizzle \\\n", - "0 4 ... * 0.0 I 999.9 0 0 \n", - "1 4 ... * 0.0 I 999.9 0 0 \n", - "2 4 ... 0.19 E 999.9 0 1 \n", + "0 4 ... 0.0 I 999.9 0 0 \n", + "1 4 ... 2.76 G 999.9 0 0 \n", + "2 0 ... 0.0 I 999.9 0 0 \n", "3 4 ... 0.0 I 999.9 0 0 \n", - "4 4 ... 0.0 I 999.9 0 0 \n", + "4 0 ... 0.0 I 999.9 0 0 \n", "\n", " snow_ice_pellets hail thunder tornado_funnel_cloud \n", "0 0 0 0 0 \n", "1 0 0 0 0 \n", - "2 1 0 0 0 \n", + "2 0 0 0 0 \n", "3 0 0 0 0 \n", "4 0 0 0 0 \n", "\n", @@ -553,7 +541,7 @@ { "data": { "text/html": [ - "Query job b4681e18-4185-4303-96a4-f0614223ee63 is DONE. 64.4 MB processed. Open Job" + "Query job a2cee421-0f51-49a8-918a-68177b3199dc is DONE. 64.4 MB processed. Open Job" ], "text/plain": [ "" @@ -653,7 +641,7 @@ }, { "data": { - "image/png": "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OKLpmik2bdokCoIglpWVBfQcwQj36+UN41z8OpbXat26dWJMTIz49ttvBzy2rKws8emnn1Zvs1gso1L8Ou6Byd133y2azWbxo48+Euvq6tSv7u5u9Zi77rpLLCgoED/88ENx586dYmlpqVhaWqrev3//fjE9PV28+eabfc7R2Njo81x79uwR9+zZI5aUlIg33nijuGfPHvHgwYODju+1114TjUaj+NJLL4mHDh0S77zzTjEpKUmsr69Xj6mrqxP37NkjvvDCCyIAcevWreKePXvElpYWXiNRFCsqKsQnnnhC3Llzp3jy5Enx008/FZcvXy6mpKSIDQ0NI3KNvEX69Tp27Jj42GOPiTt37hQrKyvFf/7zn+KkSZPE8847bwSvkiTSr5Xi6NGjoiAI4rvvvjsCV2Vw0XDN/vznP4vbtm0Tjx07Jv71r38VU1JSxAceeGCErpCvcL9eVqtVfRwA8de//rW4Z88e8eTJk+oxLS0t4p49e8R33nlHBCC+9tpr4p49e8S6uroRukqSsbpWr7zyiqjT6cQ1a9b4HNPe3j7o+J588kkxKSlJ/Oc//ynu27dPvOqqq8Ti4mKxp6dHPebkyZPinj17xEcffVQ0mUzqtbVarQFfh3EPTAD4/fL+JN3T0yN+5zvfEZOTk8W4uDjxmmuu8fmDeOSRR/yeo7CwcMjn6nuMP7/73e/EgoIC0WAwiGeddZa4fft2n/sHev6RygZE+jU6deqUeNlll4kZGRmiXq8X8/LyxBtvvHFUPp0N9DtE0vWqqqoSzzvvPDElJUU0Go3ilClTxAcffNBneedIifRrpVi1apWYn58vulyuUC9FwKLhmv3gBz8QMzMzRb1eL06dOlX81a9+Jbrd7uFclgGF+/VSMt19v2699Vb1mLVr1/o95pFHHhn+BRpi/KNxrc4///whf2d/3G63+JOf/ETMzMwUjUajeNFFF4nl5eU+x9x6661+z7158+aAr4MgXwwiIiKicRdWq3KIiIhoYmNgQkRERGGDgQkRERGFDQYmREREFDYYmBAREVHYYGBCREREYYOBCREREYUNBiZEREQUNhiYENGIWbJkCe67777xHgYRRTAGJkQ0Lj766CMIgoD29vbxHgoRhREGJkRERBQ2GJgQUUi6urpwyy23wGQyITs7G7/61a987v/rX/+KM844AwkJCcjKysKNN96IxsZGAMCJEydwwQUXAACSk5MhCAJuu+02AIDb7cbq1atRXFyM2NhYzJs3D2+88caY/m5ENH4YmBBRSB588EFs2bIF//znP/Hee+/ho48+wu7du9X7HQ4HHn/8cezduxdvv/02Tpw4oQYf+fn5+Mc//gEAKC8vR11dHZ599lkAwOrVq/Hyyy/j+eefx8GDB3H//ffj5ptvxpYtW8b8dySiscfdhYkoaJ2dnUhNTcXf/vY3XHfddQCA1tZW5OXl4c4778RvfvObfo/ZuXMnzjzzTFitVphMJnz00Ue44IIL0NbWhqSkJACAzWZDSkoKPvjgA5SWlqqPveOOO9Dd3Y1169aNxa9HRONIN94DIKLIU1FRAbvdjoULF6q3paSkYPr06erPu3btwk9/+lPs3bsXbW1tcLvdAICqqirMmjXL73mPHTuG7u5uXHzxxT632+12zJ8/fxR+EyIKNwxMiGjEdXV1YdmyZVi2bBleeeUVpKeno6qqCsuWLYPdbh/wcZ2dnQCAd955B7m5uT73GY3GUR0zEYUHBiZEFLTJkydDr9fj888/R0FBAQCgra0NR44cwfnnn4+ysjK0tLTgySefRH5+PgBpKsebwWAAALhcLvW2WbNmwWg0oqqqCueff/4Y/TZEFE4YmBBR0EwmE26//XY8+OCDSE1NRUZGBn784x9Do5Hq6QsKCmAwGPC73/0Od911Fw4cOIDHH3/c5xyFhYUQBAH//ve/8ZWvfAWxsbFISEjA9773Pdx///1wu90455xzYLFY8OmnnyIxMRG33nrrePy6RDSGuCqHiELy1FNP4dxzz8Xy5cuxdOlSnHPOOSgpKQEApKen46WXXsL69esxa9YsPPnkk3j66ad9Hp+bm4tHH30UP/zhD5GZmYl77rkHAPD444/jJz/5CVavXo2ZM2fi0ksvxTvvvIPi4uIx/x2JaOxxVQ4RERGFDWZMiIiIKGwwMCEiIqKwwcCEiIiIwgYDEyIiIgobDEyIiIgobDAwISIiorDBwISIiIjCBgMTIiIiChsMTIiIiChsMDAhIiKisMHAhIiIiMLG/wc+Xu84OfM/pQAAAABJRU5ErkJggg==", + "image/png": "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OKLpmik2bdokCoIglpWVBfQcwQj36+UN41z8OpbXat26dWJMTIz49ttvBzy2rKws8emnn1Zvs1gso1L8Ou6Byd133y2azWbxo48+Euvq6tSv7u5u9Zi77rpLLCgoED/88ENx586dYmlpqVhaWqrev3//fjE9PV28+eabfc7R2Njo81x79uwR9+zZI5aUlIg33nijuGfPHvHgwYODju+1114TjUaj+NJLL4mHDh0S77zzTjEpKUmsr69Xj6mrqxP37NkjvvDCCyIAcevWreKePXvElpYWXiNRFCsqKsQnnnhC3Llzp3jy5Enx008/FZcvXy6mpKSIDQ0NI3KNvEX69Tp27Jj42GOPiTt37hQrKyvFf/7zn+KkSZPE8847bwSvkiTSr5Xi6NGjoiAI4rvvvjsCV2Vw0XDN/vznP4vbtm0Tjx07Jv71r38VU1JSxAceeGCErpCvcL9eVqtVfRwA8de//rW4Z88e8eTJk+oxLS0t4p49e8R33nlHBCC+9tpr4p49e8S6uroRukqSsbpWr7zyiqjT6cQ1a9b4HNPe3j7o+J588kkxKSlJ/Oc//ynu27dPvOqqq8Ti4mKxp6dHPebkyZPinj17xEcffVQ0mUzqtbVarQFfh3EPTAD4/fL+JN3T0yN+5zvfEZOTk8W4uDjxmmuu8fmDeOSRR/yeo7CwcMjn6nuMP7/73e/EgoIC0WAwiGeddZa4fft2n/sHev6RygZE+jU6deqUeNlll4kZGRmiXq8X8/LyxBtvvHFUPp0N9DtE0vWqqqoSzzvvPDElJUU0Go3ilClTxAcffNBneedIifRrpVi1apWYn58vulyuUC9FwKLhmv3gBz8QMzMzRb1eL06dOlX81a9+Jbrd7uFclgGF+/VSMt19v2699Vb1mLVr1/o95pFHHhn+BRpi/KNxrc4///whf2d/3G63+JOf/ETMzMwUjUajeNFFF4nl5eU+x9x6661+z7158+aAr4MgXwwiIiKicRdWq3KIiIhoYmNgQkRERGGDgQkRERGFDQYmREREFDYYmBAREVHYYGBCREREYYOBCREREYUNBiZEREQUNhiYENGIWbJkCe67777xHgYRRTAGJkQ0Lj766CMIgoD29vbxHgoRhREGJkRERBQ2GJgQUUi6urpwyy23wGQyITs7G7/61a987v/rX/+KM844AwkJCcjKysKNN96IxsZGAMCJEydwwQUXAACSk5MhCAJuu+02AIDb7cbq1atRXFyM2NhYzJs3D2+88caY/m5ENH4YmBBRSB588EFs2bIF//znP/Hee+/ho48+wu7du9X7HQ4HHn/8cezduxdvv/02Tpw4oQYf+fn5+Mc//gEAKC8vR11dHZ599lkAwOrVq/Hyyy/j+eefx8GDB3H//ffj5ptvxpYtW8b8dySiscfdhYkoaJ2dnUhNTcXf/vY3XHfddQCA1tZW5OXl4c4778RvfvObfo/ZuXMnzjzzTFitVphMJnz00Ue44IIL0NbWhqSkJACAzWZDSkoKPvjgA5SWlqqPveOOO9Dd3Y1169aNxa9HRONIN94DIKLIU1FRAbvdjoULF6q3paSkYPr06erPu3btwk9/+lPs3bsXbW1tcLvdAICqqirMmjXL73mPHTuG7u5uXHzxxT632+12zJ8/fxR+EyIKNwxMiGjEdXV1YdmyZVi2bBleeeUVpKeno6qqCsuWLYPdbh/wcZ2dnQCAd955B7m5uT73GY3GUR0zEYUHBiZEFLTJkydDr9fj888/R0FBAQCgra0NR44cwfnnn4+ysjK0tLTgySefRH5+PgBpKsebwWAAALhcLvW2WbNmwWg0oqqqCueff/4Y/TZEFE4YmBBR0EwmE26//XY8+OCDSE1NRUZGBn784x9Do5Hq6QsKCmAwGPC73/0Od911Fw4cOIDHH3/c5xyFhYUQBAH//ve/8ZWvfAWxsbFISEjA9773Pdx///1wu90455xzYLFY8OmnnyIxMRG33nrrePy6RDSGuCqHiELy1FNP4dxzz8Xy5cuxdOlSnHPOOSgpKQEApKen46WXXsL69esxa9YsPPnkk3j66ad9Hp+bm4tHH30UP/zhD5GZmYl77rkHAPD444/jJz/5CVavXo2ZM2fi0ksvxTvvvIPi4uIx/x2JaOxxVQ4RERGFDWZMiIiIKGwwMCEiIqKwwcCEiIiIwgYDEyIiIgobDEyIiIgobDAwISIiorDBwISIiIjCBgMTIiIiChsMTIiIiChsMDAhIiKisMHAhIiIiMLG/wc+Xu84OfM/pQAAAABJRU5ErkJggg==", "text/plain": [ "
" ] @@ -722,52 +710,52 @@ " AL\n", " F\n", " 1910\n", - " Cora\n", - " 61\n", + " Sadie\n", + " 40\n", " \n", " \n", " 1\n", " AL\n", " F\n", " 1910\n", - " Anna\n", - " 74\n", + " Mary\n", + " 875\n", " \n", " \n", " 2\n", " AR\n", " F\n", " 1910\n", - " Willie\n", - " 132\n", + " Vera\n", + " 39\n", " \n", " \n", " 3\n", - " CO\n", + " AR\n", " F\n", " 1910\n", - " Anna\n", - " 42\n", + " Marie\n", + " 78\n", " \n", " \n", " 4\n", - " FL\n", + " AR\n", " F\n", " 1910\n", - " Louise\n", - " 70\n", + " Lucille\n", + " 66\n", " \n", " \n", "\n", "" ], "text/plain": [ - " state gender year name number\n", - "0 AL F 1910 Cora 61\n", - "1 AL F 1910 Anna 74\n", - "2 AR F 1910 Willie 132\n", - "3 CO F 1910 Anna 42\n", - "4 FL F 1910 Louise 70" + " state gender year name number\n", + "0 AL F 1910 Sadie 40\n", + "1 AL F 1910 Mary 875\n", + "2 AR F 1910 Vera 39\n", + "3 AR F 1910 Marie 78\n", + "4 AR F 1910 Lucille 66" ] }, "execution_count": 10, @@ -797,7 +785,7 @@ { "data": { "text/html": [ - "Query job 225ac92a-78d2-4739-af6c-df7552dd2345 is DONE. 132.6 MB processed. Open Job" + "Query job d1fc606b-18b0-4e7e-a669-0e505a95aa5a is DONE. 132.6 MB processed. Open Job" ], "text/plain": [ "" @@ -840,34 +828,34 @@ " \n", " \n", " \n", - " 1923\n", - " 2047\n", + " 1927\n", + " 1631\n", " 0\n", - " 71799\n", + " 70864\n", " \n", " \n", - " 1913\n", - " 1371\n", + " 1918\n", + " 2353\n", " 0\n", - " 36725\n", + " 67492\n", " \n", " \n", - " 1915\n", - " 2078\n", + " 1912\n", + " 1126\n", " 0\n", - " 58293\n", + " 32375\n", " \n", " \n", - " 1925\n", - " 1748\n", + " 1923\n", + " 2047\n", " 0\n", - " 70815\n", + " 71799\n", " \n", " \n", - " 1916\n", - " 2201\n", + " 1933\n", + " 1036\n", " 0\n", - " 61551\n", + " 55769\n", " \n", " \n", "\n", @@ -876,11 +864,11 @@ "text/plain": [ "name Emily Lisa Mary\n", "year \n", + "1927 1631 0 70864\n", + "1918 2353 0 67492\n", + "1912 1126 0 32375\n", "1923 2047 0 71799\n", - "1913 1371 0 36725\n", - "1915 2078 0 58293\n", - "1925 1748 0 70815\n", - "1916 2201 0 61551" + "1933 1036 0 55769" ] }, "execution_count": 11, @@ -912,7 +900,7 @@ }, { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjkAAAGwCAYAAABLvHTgAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvc2/+5QAAAAlwSFlzAAAPYQAAD2EBqD+naQAAsTxJREFUeJzs3Xt8XHWd+P/XOWfuk8nk0jTpvYUCbbm0UKBUhUXtUrXsVxZwvSAggits0YWuovzWB/LV/YqyCygrWEWhdYXlpiJSKJZCuZZboLRNr2nTJm3ul5nJXM/MOef3x3SGhl6TzC3J++kjD8nMZ875JG0z73w+78/7rViWZSGEEEIIMcqoxZ6AEEIIIUQ+SJAjhBBCiFFJghwhhBBCjEoS5AghhBBiVJIgRwghhBCjkgQ5QgghhBiVJMgRQgghxKhkK/YEisk0TVpbW/H5fCiKUuzpCCGEEOI4WJZFf38/EydORFWPvF4zpoOc1tZWpkyZUuxpCCGEEGIIWlpamDx58hGfH9NBjs/nA9LfpPLy8iLPRgghhBDHIxQKMWXKlOz7+JGM6SAns0VVXl4uQY4QQggxwhwr1UQSj4UQQggxKkmQI4QQQohRSYIcIYQQQoxKYzonRwghhBguwzBIJpPFnsaoYrfb0TRt2NeRIEcIIYQYAsuyaG9vJxAIFHsqo1JFRQV1dXXDqmMnQY4QQggxBJkAZ/z48Xg8HikqmyOWZRGNRuns7ARgwoQJQ76WBDlCCCHEIBmGkQ1wqquriz2dUcftdgPQ2dnJ+PHjh7x1JYnHQgghxCBlcnA8Hk+RZzJ6Zb63w8l3kiBHCCGEGCLZosqfXHxvJcgRQgghxKgkQY4QQgghRiUJcoQQQggxKkmQI4QQQohRSYIccYiEkaAr2lXsaQghhBDDIkGOOMSLzS/ywKYH+KDrg2JPRQghxrwLL7yQb3/729xyyy1UVVVRV1fH7bffnn3+7rvv5vTTT8fr9TJlyhT+5V/+hXA4nH1+xYoVVFRU8Mwzz3DKKafg8Xi4/PLLiUajrFy5kunTp1NZWcm3v/1tDMPIvi6RSPCd73yHSZMm4fV6WbBgAevWrSvgVz58EuSIARJGgl2BXWzp3sL/bv1fTMs8rteZlkl3rBvLsvI8QyGEGHtWrlyJ1+vlrbfe4s477+RHP/oRa9asAUBVVe69914aGhpYuXIlL774IrfccsuA10ejUe69914effRRVq9ezbp16/jHf/xHnn32WZ599ln+53/+h1//+tc8+eST2dfceOONrF+/nkcffZSNGzfyhS98gc985jPs3LmzoF/7cCjWGH5XCoVC+P1+gsEg5eXlxZ5OSdjZt5PHtj9GfXs9NtXGTfNv4uOTPn7M172671XebHuTC6dcyDl15xRgpkIIUTzxeJympiZmzJiBy+XK670uvPBCDMPg1VdfzT527rnn8qlPfYqf/vSnh4x/8sknuf766+nu7gbSKznXXHMNjY2NnHjiiQBcf/31/M///A8dHR2UlZUB8JnPfIbp06ezfPlympubOeGEE2hubmbixInZay9atIhzzz2Xn/zkJ/n8koGjf4+P9/1b2jqIAZqCTfTF+0BJr+o8vetpFk5ciKocedEvrIfZ1LWJhu4GQomQBDlCCJFjZ5xxxoDPJ0yYkO3t9MILL3DHHXewbds2QqEQqVSKeDxONBrNVg32eDzZAAegtraW6dOnZwOczGOZa27atAnDMDj55JMH3DeRSIyoNhYS5IgswzTYG9pLV7SLqb6p9MR72BXYxWv7X+OCyRcc8XXvd75Pe7SdvkQfCTNBb6yXKndVAWcuhBCjm91uH/C5oiiYpsmePXu4+OKLueGGG/h//+//UVVVxWuvvca1116LruvZIOdwrz/SNQHC4TCaplFfX39I36iDA6NSJ0GOyGoNt9IT7yFhJJhYNpFyRzkbujbw9K6n+fjEj6OphzZIiyajbO7eTEt/C6qiEkvGWN+2niUnLCnCVyCEEGNLfX09pmly1113oarpFffHH3982Nc988wzMQyDzs5Ozj///GFfr1gk8VhkNYWaCMQD2FQbZfYyxnvHU+GsYE9wD6/se+Wwr9nQtYGOaAexVIyTKk7CtEze63ivwDMXQoixaebMmSSTSf77v/+b3bt38z//8z8sX7582Nc9+eSTueKKK7jqqqv405/+RFNTE2+//TZ33HEHq1atysHMC0OCHAGAZVnsCe6hK9ZFhasCRVHQFI0Z5TNIGAme2f0MKSM14DWxVIxNXZto6W+h0lVJrbcWp+Zke+92wnr4CHcSQgiRK3PnzuXuu+/mZz/7GaeddhoPP/wwd9xxR06u/dBDD3HVVVfxb//2b5xyyilccsklvPPOO0ydOjUn1y8EOV0lp6sA6Ip2sbJh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+tWv8tprr7F371727t3L66+/zle/+tUBYy677DIuvfRSZs6cybx583jwwQfZtGkTW7ZsGTDupptu4tJLL2XGjBlMmDCB6667jueff562tnQds87OTp599lm+/vWvF+zrg0EGOX19fXz84x/Hbrfz3HPPsWXLFu666y4qKyuzY+68807uvfdeli9fzltvvYXX62Xx4sXE4x9mml9xxRU0NDSwZs0annnmGV555RX++Z//Oft8KBTioosuYtq0adTX1/Of//mf3H777fzmN7/JjnnjjTf48pe/zLXXXsv777/PJZdcwiWXXMLmzSOzZspghPUwbZE2AonAmM3HyXCoDmrcNUSSER7c/CA/r/85b7a9SSwVK/bUjiqTdKwoCm778QcpmaPkjYFGYskYpmWWfLXj45X5Puzr31fciRhJeOvX8M7vQOr4iFGspqaGJUuWsGLFCh566CGWLFnCuHEDTznu3LmTL3/5y5xwwgmUl5czffp0AJqbmweMO/vsswd8fu6553LqqaeycuVKAP7whz8wbdo0LrigsLmTg0o8/tnPfsaUKVMGLEHNmDEj+9+WZfHzn/+cH/zgB3z+858H4Pe//z21tbU89dRTfOlLX2Lr1q2sXr2ad955J/tN+e///m8+97nP8V//9V9MnDiRhx9+GF3XefDBB3E4HJx66qls2LCBu+++OxsM/eIXv+Azn/kM3/3udwH48Y9/zJo1a/jlL395SLb4aJPZqsJixJ0oyjVFUZhdPZsyRxm7ArvojHayM7CT6eXTufa0a6krK80gMJN07NScgzpN5NJc2aPk3fFuFEXBYRuZiccf5bF5UBWVfeEiBznhTujdDW0b4YQLofrE4s5HiDz6+te/zo033gjAfffdd8jz//AP/8C0adN44IEHmDhxIqZpctppp6HrA1fNvd5Df9m67rrruO+++/j+97/PQw89xDXXXFPwnYdBreQ8/fTTnH322XzhC19g/PjxnHnmmTzwwAPZ55uammhvb2fRokXZx/x+PwsWLGD9+vUArF+/noqKigFR36JFi1BVlbfeeis75oILLsDh+PCH9+LFi9m+fTt9fX3ZMQffJzMmc5/DSSQShEKhAR8jUXN/M4FEAJfNNWYach6NqqhMK5/GJ6d8kllVswgkArzb/i5/3PnHYk/tiHriPUSSkUEf/VcUhWpXNYZp0BfvQ0Ud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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjkAAAGwCAYAAABLvHTgAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAsTxJREFUeJzs3Xt8XHWd+P/XOWfuk8nk0jTpvYUCbbm0UKBUhUXtUrXsVxZwvSAggits0YWuovzWB/LV/YqyCygrWEWhdYXlpiJSKJZCuZZboLRNr2nTJm3ul5nJXM/MOef3x3SGhl6TzC3J++kjD8nMZ875JG0z73w+78/7rViWZSGEEEIIMcqoxZ6AEEIIIUQ+SJAjhBBCiFFJghwhhBBCjEoS5AghhBBiVJIgRwghhBCjkgQ5QgghhBiVJMgRQgghxKhkK/YEisk0TVpbW/H5fCiKUuzpCCGEEOI4WJZFf38/EydORFWPvF4zpoOc1tZWpkyZUuxpCCGEEGIIWlpamDx58hGfH9NBjs/nA9LfpPLy8iLPRgghhBDHIxQKMWXKlOz7+JGM6SAns0VVXl4uQY4QQggxwhwr1UQSj4UQQggxKkmQI4QQQohRSYIcIYQQQoxKYzonRwghhBguwzBIJpPFnsaoYrfb0TRt2NeRIEcIIYQYAsuyaG9vJxAIFHsqo1JFRQV1dXXDqmMnQY4QQggxBJkAZ/z48Xg8HikqmyOWZRGNRuns7ARgwoQJQ76WBDlCCCHEIBmGkQ1wqquriz2dUcftdgPQ2dnJ+PHjh7x1JYnHQgghxCBlcnA8Hk+RZzJ6Zb63w8l3kiBHCCGEGCLZosqfXHxvJcgRQgghxKgkQY4QQgghRiUJcoQQQggxKkmQI4QQQohRSYIccYiEkaAr2lXsaQghhBDDIkGOOMSLzS/ywKYH+KDrg2JPRQghxrwLL7yQb3/729xyyy1UVVVRV1fH7bffnn3+7rvv5vTTT8fr9TJlyhT+5V/+hXA4nH1+xYoVVFRU8Mwzz3DKKafg8Xi4/PLLiUajrFy5kunTp1NZWcm3v/1tDMPIvi6RSPCd73yHSZMm4fV6WbBgAevWrSvgVz58EuSIARJGgl2BXWzp3sL/bv1fTMs8rteZlkl3rBvLsvI8QyGEGHtWrlyJ1+vlrbfe4s477+RHP/oRa9asAUBVVe69914aGhpYuXIlL774IrfccsuA10ejUe69914effRRVq9ezbp16/jHf/xHnn32WZ599ln+53/+h1//+tc8+eST2dfceOONrF+/nkcffZSNGzfyhS98gc985jPs3LmzoF/7cCjWGH5XCoVC+P1+gsEg5eXlxZ5OSdjZt5PHtj9GfXs9NtXGTfNv4uOTPn7M172671XebHuTC6dcyDl15xRgpkIIUTzxeJympiZmzJiBy+XK670uvPBCDMPg1VdfzT527rnn8qlPfYqf/vSnh4x/8sknuf766+nu7gbSKznXXHMNjY2NnHjiiQBcf/31/M///A8dHR2UlZUB8JnPfIbp06ezfPlympubOeGEE2hubmbixInZay9atIhzzz2Xn/zkJ/n8koGjf4+P9/1b2jqIAZqCTfTF+0BJr+o8vetpFk5ciKocedEvrIfZ1LWJhu4GQomQBDlCCJFjZ5xxxoDPJ0yYkO3t9MILL3DHHXewbds2QqEQqVSKeDxONBrNVg32eDzZAAegtraW6dOnZwOczGOZa27atAnDMDj55JMH3DeRSIyoNhYS5IgswzTYG9pLV7SLqb6p9MR72BXYxWv7X+OCyRcc8XXvd75Pe7SdvkQfCTNBb6yXKndVAWcuhBCjm91uH/C5oiiYpsmePXu4+OKLueGGG/h//+//UVVVxWuvvca1116LruvZIOdwrz/SNQHC4TCaplFfX39I36iDA6NSJ0GOyGoNt9IT7yFhJJhYNpFyRzkbujbw9K6n+fjEj6OphzZIiyajbO7eTEt/C6qiEkvGWN+2niUnLCnCVyCEEGNLfX09pmly1113oarpFffHH3982Nc988wzMQyDzs5Ozj///GFfr1gk8VhkNYWaCMQD2FQbZfYyxnvHU+GsYE9wD6/se+Wwr9nQtYGOaAexVIyTKk7CtEze63ivwDMXQoixaebMmSSTSf77v/+b3bt38z//8z8sX7582Nc9+eSTueKKK7jqqqv405/+RFNTE2+//TZ33HEHq1atysHMC0OCHAGAZVnsCe6hK9ZFhasCRVHQFI0Z5TNIGAme2f0MKSM14DWxVIxNXZto6W+h0lVJrbcWp+Zke+92wnr4CHcSQgiRK3PnzuXuu+/mZz/7GaeddhoPP/wwd9xxR06u/dBDD3HVVVfxb//2b5xyyilccsklvPPOO0ydOjUn1y8EOV0lp6sA6Ip2sbJh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+tWv8tprr7F371727t3L66+/zle/+tUBYy677DIuvfRSZs6cybx583jwwQfZtGkTW7ZsGTDupptu4tJLL2XGjBlMmDCB6667jueff562tnQds87OTp599lm+/vWvF+zrg0EGOX19fXz84x/Hbrfz3HPPsWXLFu666y4qKyuzY+68807uvfdeli9fzltvvYXX62Xx4sXE4x9mml9xxRU0NDSwZs0annnmGV555RX++Z//Oft8KBTioosuYtq0adTX1/Of//mf3H777fzmN7/JjnnjjTf48pe/zLXXXsv777/PJZdcwiWXXMLmzSOzZspghPUwbZE2AonAmM3HyXCoDmrcNUSSER7c/CA/r/85b7a9SSwVK/bUjiqTdKwoCm778QcpmaPkjYFGYskYpmWWfLXj45X5Puzr31fciRhJeOvX8M7vQOr4iFGspqaGJUuWsGLFCh566CGWLFnCuHEDTznu3LmTL3/5y5xwwgmUl5czffp0AJqbmweMO/vsswd8fu6553LqqaeycuVKAP7whz8wbdo0LrigsLmTg0o8/tnPfsaUKVMGLEHNmDEj+9+WZfHzn/+cH/zgB3z+858H4Pe//z21tbU89dRTfOlLX2Lr1q2sXr2ad955J/tN+e///m8+97nP8V//9V9MnDiRhx9+GF3XefDBB3E4HJx66qls2LCBu+++OxsM/eIXv+Azn/kM3/3udwH48Y9/zJo1a/jlL395SLb4aJPZqsJixJ0oyjVFUZhdPZsyRxm7ArvojHayM7CT6eXTufa0a6krK80gMJN07NScgzpN5NJc2aPk3fFuFEXBYRuZiccf5bF5UBWVfeEiBznhTujdDW0b4YQLofrE4s5HiDz6+te/zo033gjAfffdd8jz//AP/8C0adN44IEHmDhxIqZpctppp6HrA1fNvd5Df9m67rrruO+++/j+97/PQw89xDXXXFPwnYdBreQ8/fTTnH322XzhC19g/PjxnHnmmTzwwAPZ55uammhvb2fRokXZx/x+PwsWLGD9+vUArF+/noqKigFR36JFi1BVlbfeeis75oILLsDh+PCH9+LFi9m+fTt9fX3ZMQffJzMmc5/DSSQShEKhAR8jUXN/M4FEAJfNNWYach6NqqhMK5/GJ6d8kllVswgkArzb/i5/3PnHYk/tiHriPUSSkUEf/VcUhWpXNYZp0BfvQ0Ud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", "text/plain": [ "
" ] @@ -952,7 +940,7 @@ }, { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] @@ -999,7 +987,7 @@ }, { "data": { - "image/png": "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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAigAAAHZCAYAAACsK8CkAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAJ0ZJREFUeJzt3X9UlHXe//HXIDqw6AxCB4Y5gbJ7W6KZsZrE6rb9YEM011a6yw4WqUe3zR+r3KeUu7TbtsJcK1YlqVbR9pa8c+9y0+6l9caCuw1JMe/KCHOz5KQDtcSM4DIizPePPc33nqQf2MB8wOfjnOucvX7MxXvO2cFnF9fMWHw+n08AAAAGCQv1AAAAAF9GoAAAAOMQKAAAwDgECgAAMA6BAgAAjEOgAAAA4xAoAADAOOGhHuB8dHZ26sSJExoyZIgsFkuoxwEAAN+Cz+fTqVOn5HQ6FRb29ddI+mSgnDhxQomJiaEeAwAAnIf6+npdfPHFX3tMnwyUIUOGSPrHE7TZbCGeBgAAfBsej0eJiYn+f8e/Tp8MlC/+rGOz2QgUAAD6mG9zewY3yQIAAOMQKAAAwDgECgAAMA6BAgAAjEOgAAAA4xAoAADAOAQKAAAwDoECAACMQ6AAAADjECgAAMA4BAoAADAOgQIAAIxDoAAAAOMQKAAAwDgECgAAME54qAcAAPzD8OUvh3oE9KKPVk8N9QhG4woKAAAwDoECAACMQ6AAAADjECgAAMA4BAoAADAO7+LpY7jL/8LCXf4ALlRcQQEAAMYhUAAAgHEIFAAAYBwCBQAAGIdAAQAAxiFQAACAcbodKJWVlZo2bZqcTqcsFot27tx5zjG1tbX62c9+JrvdrqioKF155ZU6fvy4f39bW5sWLFig2NhYDR48WNnZ2WpoaPhOTwQAAPQf3Q6U1tZWjR07VkVFRV3u/+tf/6pJkyZp5MiReu211/T2229rxYoVioiI8B+zdOlS7dq1Szt27FBFRYVOnDihGTNmnP+zAAAA/Uq3P6gtKytLWVlZX7n/vvvu05QpU7RmzRr/th/84Af+/+12u7Vp0yaVlpbquuuukySVlJQoJSVF+/bt01VXXdXdkQAAQD8T1HtQOjs79fLLL+uSSy5RZmam4uLilJaWFvBnoJqaGrW3tysjI8O/beTIkUpKSlJVVVWX5/V6vfJ4PAELAADov4IaKI2NjWppadHq1as1efJk/fnPf9bPf/5zzZgxQxUVFZIkl8ulQYMGKTo6OuCx8fHxcrlcXZ63oKBAdrvdvyQmJgZzbAAAYJigX0GRpOnTp2vp0qW64oortHz5ct14440qLi4+7/Pm5+fL7Xb7l/r6+mCNDAAADBTULwu86KKLFB4erlGjRgVsT0lJ0euvvy5JcjgcOnPmjJqbmwOuojQ0NMjhcHR5XqvVKqvVGsxRAQCAwYJ6BWXQoEG68sorVVdXF7D9yJEjGjZsmCRp3LhxGjhwoMrLy/376+rqdPz4caWnpwdzHAAA0Ed1+wpKS0uLjh496l8/duyYDh06pJiYGCUlJemee+7RrbfeqquvvlrXXnutysrKtGvXLr322muSJLvdrrlz5yovL08xMTGy2WxatGiR0tPTeQcPAACQdB6BcuDAAV177bX+9by8PElSbm6utmzZop///OcqLi5WQUGBFi9erEsvvVT/+Z//qUmTJvkf88QTTygsLEzZ2dnyer3KzMzUk08+GYSnAwAA+gOLz+fzhXqI7vJ4PLLb7XK73bLZbKEep1cNX/5yqEdAL/po9dRQj4BexOv7wnIhvr678+8338UDAACMQ6AAAADjECgAAMA4BAoAADAOgQIAAIxDoAAAAOMQKAAAwDgECgAAMA6BAgAAjEOgAAAA4xAoAADAOAQKAAAwDoECAACMQ6AAAADjECgAAMA4BAoAADAOgQIAAIxDoAAAAOMQKAAAwDgECgAAMA6BAgAAjEOgAAAA4xAoAADAOAQKAAAwDoECAACMQ6AAAADjECgAAMA4BAoAADAOgQIAAIxDoAAAAOMQKAAAwDjdDpTKykpNmzZNTqdTFotFO3fu/Mpj77rrLlksFhUWFgZsb2pqUk5Ojmw2m6KjozV37ly1tLR0dxQAANBPdTtQWltbNXbsWBUVFX3tcS+++KL27dsnp9N5zr6cnBwdPnxYe/bs0e7du1VZWan58+d3dxQAANBPhXf3AVlZWcrKyvraYz755BMtWrRIr7zyiqZOnRqwr7a2VmVlZdq/f7/Gjx8vSVq/fr2mTJmitWvXdhk0AADgwhL0e1A6Ozt1++2365577tHo0aPP2V9VVaXo6Gh/nEhSRkaGwsLCVF1d3eU5vV6vPB5PwAIAAPqvoAfKo48+qvDwcC1evLjL/S6XS3FxcQHbwsPDFRMTI5fL1eVjCgoKZLfb/UtiYmKwxwYAAAYJaqDU1NTot7/9rbZs2SKLxRK08+bn58vtdvuX+vr6oJ0bAACYJ6iB8j//8z9qbGxUUlKSwsPDFR4ero8//lj/8i//ouHDh0uSHA6HGhsbAx539uxZNTU1yeFwdHleq9Uqm80WsAAAgP6r2zfJfp3bb79dGRkZAdsyMzN1++23a/bs2ZKk9PR0NTc3q6amRuPGjZMk7d27V52dnUpLSwvmOAAAoI/qdqC0tLTo6NGj/vVjx47p0KFDiomJUVJSkmJjYwOOHzhwoBwOhy699FJJUkpKiiZPnqx58+apuLhY7e3tWrhwoWbOnMk7eAAAgKTz+BPPgQMHlJqaqtTUVElSXl6eUlNTtXLlym99jm3btmnkyJG6/vrrNWXKFE2aNElPP/10d0cBAAD9VLevoFxzzTXy+Xzf+viPPvronG0xMTEqLS3t7o8GAAAXCL6LBwAAGIdAAQAAxiFQAACAcQgUAABgHAIFAAAYh0ABAADGIVAAAIBxCBQAAGAcAgUAABiHQAEAAMYhUAAAgHEIFAAAYBwCBQAAGIdAAQAAxiFQAACAcQgUAABgHAIFAAAYh0ABAADGIVAAAIBxCBQAAGAcAgUAABiHQAEAAMYhUAAAgHEIFAAAYBwCBQAAGIdAAQAAxiFQAACAcQgUAABgHAIFAAAYh0ABAADGIVAAAIBxuh0olZWVmjZtmpxOpywWi3bu3Onf197ermXLlmnMmDGKioqS0+nUHXfcoRMnTgSco6mpSTk5ObLZbIqOjtbcuXPV0tLynZ8MAADoH7odKK2trRo7dqyKiorO2Xf69GkdPHhQK1as0MGDB/XCCy+orq5OP/vZzwKOy8nJ0eHDh7Vnzx7t3r1blZWVmj9//vk/CwAA0K+Ed/cBWVlZysrK6nKf3W7Xnj17ArZt2LBBEyZM0PHjx5WUlKTa2lqVlZVp//79Gj9+vCRp/fr1mjJlitauXSun03keTwMAAPQnPX4PitvtlsViUXR0tCSpqqpK0dHR/jiRpIyMDIWFham6urrLc3i9Xnk8noAFAAD0Xz0aKG1tbVq2bJluu+022Ww2SZLL5VJcXFzAceHh4YqJiZHL5eryPAUFBbLb7f4lMTGxJ8cGAAAh1mOB0t7erltuuUU+n08bN278TufKz8+X2+32L/X19UGaEgAAmKjb96B8G1/Eyccff6y9e/f6r55IksPhUGNjY8DxZ8+eVVNTkxwOR5fns1qtslqtPTEqAAAwUNCvoHwRJx988IH++7//W7GxsQH709PT1dzcrJqaGv+2vXv3qrOzU2lpacEeBwAA9EHdvoLS0tKio0eP+tePHTumQ4cOKSYmRgkJCbr55pt18OBB7d69Wx0dHf77SmJiYjRo0CClpKRo8uTJmjdvnoqLi9Xe3q6FCxdq5syZvIMHAABIOo9AOXDggK699lr/el5eniQpNzdX//Zv/6aXXnpJknTFFVcEPO7VV1/VNddcI0natm2bFi5cqOuvv15hYWHKzs7WunXrzvMpAACA/qbbgXLNNdfI5/N95f6v2/eFmJgYlZaWdvdHAwCACwTfxQMAAIxDoAAAAOMQKAAAwDgECgAAMA6BAgAAjEOgAAAA4xAoAADAOAQKAAAwDoECAACMQ6AAAADjECgAAMA4BAoAADAOgQIAAIxDoAAAAOMQKAAAwDgECgAAMA6BAgAAjEOgAAAA4xAoAADAOAQKAAAwDoECAACMQ6AAAADjECgAAMA4BAoAADAOgQIAAIxDoAAAAOMQKAAAwDgECgAAMA6BAgAAjEOgAAAA4xAoAADAON0OlMrKSk2bNk1Op1MWi0U7d+4M2O/z+bRy5UolJCQoMjJSGRkZ+uCDDwKOaWpqUk5Ojmw2m6KjozV37ly1tLR8pycCAAD6j24HSmtrq8aOHauioqIu969Zs0br1q1TcXGxqqurFRUVpczMTLW1tfmPycnJ0eHDh7Vnzx7t3r1blZWVmj9//vk/CwAA0K+Ed/cBWVlZysrK6nKfz+dTYWGh7r//fk2fPl2S9Oyzzyo+Pl47d+7UzJkzVVtbq7KyMu3fv1/jx4+XJK1fv15TpkzR2rVr5XQ6zzmv1+uV1+v1r3s8nu6ODQAA+pCg3oNy7NgxuVwuZWRk+LfZ7XalpaWpqqpKklRVVaXo6Gh/nEhSRkaGwsLCVF1d3eV5CwoKZLfb/UtiYmIwxwYAAIYJaqC4XC5JUnx8fMD2+Ph4/z6Xy6W4uLiA/eHh4YqJifEf82X5+flyu93+pb6+PphjAwAAw3T7TzyhYLVaZbVaQz0GAADoJUG9guJwOCRJDQ0NAdsbGhr8+xwOhxobGwP2nz17Vk1NTf5jAADAhS2ogZKcnCyHw6Hy8nL/No/Ho+rqaqWnp0uS0tPT1dzcrJqaGv8xe/fuVWdnp9LS0oI5DgAA6KO6/SeelpYWHT161L9+7NgxHTp0SDExMUpKStKSJUv00EMPacSIEUpOTtaKFSvkdDp10003SZJSUlI0efJkzZs3T8XFxWpvb9fChQs1c+bMLt/BAwAALjzdDpQDBw7o2muv9a/n5eVJknJzc7Vlyxbde++9am1t1fz589Xc3KxJkyaprKxMERER/sds27ZNCxcu1PXXX6+wsDBlZ2dr3bp1QXg6AACgP7D4fD5fqIfoLo/HI7vdLrfbLZvNFupxetXw5S+HegT0oo9WTw31COhFvL4vLBfi67s7/37zXTwAAMA4BAoAADAOgQIAAIxDoAAAAOMQKAAAwDgECgAAMA6BAgAAjEOgAAAA4xAoAADAOAQKAAAwDoECAACMQ6AAAADjECgAAMA4BAoAADAOgQIAAIxDoAAAAOMQKAAAwDgECgAAMA6BAgAAjEOgAAAA4xAoAADAOAQKAAAwDoECAACMQ6AAAADjECgAAMA4BAoAADAOgQIAAIxDoAAAAOMQKAAAwDgECgAAME7QA6Wjo0MrVqxQcnKyIiMj9YMf/EC//vWv5fP5/Mf4fD6tXLlSCQkJioyMVEZGhj744INgjwIAAPqooAfKo48+qo0bN2rDhg2qra3Vo48+qjVr1mj9+vX+Y9asWaN169apuLhY1dXVioqKUmZmptra2oI9DgAA6IPCg33CN954Q9OnT9fUqVMlScOHD9dzzz2nN998U9I/rp4UFhbq/vvv1/Tp0yVJzz77rOLj47Vz507NnDkz2CMBAIA+JuhXUH70ox+pvLxcR44ckST97//+r15//XVlZWVJko4dOyaXy6WMjAz/Y+x2u9LS0lRVVdXlOb1erzweT8ACAAD6r6BfQVm+fLk8Ho9GjhypAQMGqKOjQw8//LBycnIkSS6XS5IUHx8f8Lj4+Hj/vi8rKCjQqlWrgj0qAAAwVNCvoDz//PPatm2bSktLdfDgQW3dulVr167V1q1bz/uc+fn5crvd/qW+vj6IEwMAANME/QrKPffco+XLl/vvJRkzZow+/vhjFRQUKDc3Vw6HQ5LU0NCghIQE/+MaGhp0xRVXdHlOq9Uqq9Ua7FEBAIChgn4F5fTp0woLCzztgAED1NnZKUlKTk6Ww+FQeXm5f7/H41F1dbXS09ODPQ4AAOiDgn4FZdq0aXr44YeVlJSk0aNH66233tLjjz+uOXPmSJIsFouWLFmihx56SCNGjFBycrJWrFghp9Opm266KdjjAACAPijogbJ+/XqtWLFCd999txobG+V0OvWLX/xCK1eu9B9z7733qrW1VfPnz1dzc7MmTZqksrIyRUREBHscAADQB1l8//cjXvsIj8cju90ut9stm80W6nF61fDlL4d6BPSij1ZPDfUI6EW8vi8sF+Lruzv/fvNdPAAAwDgECgAAMA6BAgAAjEOgAAAA4xAoAADAOAQKAAAwDoECAACMQ6AAAADjECgAAMA4BAoAADAOgQIAAIxDoAAAAOMQKAAAwDgECgAAMA6BAgAAjEOgAAAA4xAoAADAOAQKAAAwDoECAACMQ6AAAADjECgAAMA4BAoAADAOgQIAAIxDoAAAAOMQKAAAwDgECgAAMA6BAgAAjEOgAAAA4xAoAADAOAQKAAAwDoECAACM0yOB8sknn2jWrFmKjY1VZGSkxowZowMHDvj3+3w+rVy5UgkJCYqMjFRGRoY++OCDnhgFAAD0QUEPlM8//1wTJ07UwIED9ac//UnvvfeeHnvsMQ0dOtR/zJo1a7Ru3ToVFxerurpaUVFRyszMVFtbW7DHAQAAfVB4sE/46KOPKjExUSUlJf5tycnJ/v/t8/lUWFio+++/X9OnT5ckPfvss4qPj9fOnTs1c+bMYI8EAAD6mKBfQXnppZc0fvx4/fM//7Pi4uKUmpqqZ555xr//2LFjcrlcysjI8G+z2+1KS0tTVVVVl+f0er3yeDwBCwAA6L+CHigffvihNm7cqBEjRuiVV17RL3/5Sy1evFhbt26VJLlcLklSfHx8wOPi4+P9+76soKBAdrvdvyQmJgZ7bAAAYJCgB0pnZ6d++MMf6pFHHlFqaqrmz5+vefPmqbi4+LzPmZ+fL7fb7V/q6+uDODEAADBN0AMlISFBo0aNCtiWkpKi48ePS5IcDockqaGhIeCYhoYG/74vs1qtstlsAQsAAOi/gh4oEydOVF1dXcC2I0eOaNiwYZL+ccOsw+FQeXm5f7/H41F1dbXS09ODPQ4AAOiDgv4unqVLl+pHP/qRHnnkEd1yyy1688039fTTT+vpp5+WJFksFi1ZskQPPfSQRowYoeTkZK1YsUJOp1M33XRTsMcBAAB9UNAD5corr9SLL76o/Px8Pfjgg0pOTlZhYaFycnL8x9x7771qbW3V/Pnz1dzcrEmTJqmsrEwRERHBHgcAAPRBQQ8USbrxxht14403fuV+i8WiBx98UA8++GBP/HgAANDH8V08AADAOAQKAAAwDoECAACMQ6AAAADjECgAAMA4BAoAADAOgQIAAIxDoAAAAOMQKAAAwDgECgAAMA6BAgAAjEOgAAAA4xAoAADAOAQKAAAwDoECAACMQ6AAAADjECgAAMA4BAoAADAOgQIAAIxDoAAAAOMQKAAAwDgECgAAMA6BAgAAjEOgAAAA4xAoAADAOAQKAAAwDoECAACMQ6AAAADjECgAAMA4BAoAADAOgQIAAIzT44GyevVqWSwWLVmyxL+tra1NCxYsUGxsrAYPHqzs7Gw1NDT09CgAAKCP6NFA2b9/v5566ildfvnlAduXLl2qXbt2aceOHaqoqNCJEyc0Y8aMnhwFAAD0IT0WKC0tLcrJydEzzzyjoUOH+re73W5t2rRJjz/+uK677jqNGzdOJSUleuONN7Rv376eGgcAAPQhPRYoCxYs0NSpU5WRkRGwvaamRu3t7QHbR44cqaSkJFVVVXV5Lq/XK4/HE7AAAID+K7wnTrp9+3YdPHhQ+/fvP2efy+XSoEGDFB0dHbA9Pj5eLpery/MVFBRo1apVPTEqAAAwUNCvoNTX1+tXv/qVtm3bpoiIiKCcMz8/X26327/U19cH5bwAAMBMQQ+UmpoaNTY26oc//KHCw8MVHh6uiooKrVu3TuHh4YqPj9eZM2fU3Nwc8LiGhgY5HI4uz2m1WmWz2QIWAADQfwX9TzzXX3+93nnnnYBts2fP1siRI7Vs2TIlJiZq4MCBKi8vV3Z2tiSprq5Ox48fV3p6erDHAQAAfVDQA2XIkCG67LLLArZFRUUpNjbWv33u3LnKy8tTTEyMbDabFi1apPT0dF111VXBHgcAAPRBPXKT7Dd54oknFBYWpuzsbHm9XmVmZurJJ58MxSgAAMBAvRIor732WsB6RESEioqKVFRU1Bs/HgAA9DF8Fw8AADAOgQIAAIxDoAAAAOMQKAAAwDgECgAAMA6BAgAAjEOgAAAA4xAoAADAOAQKAAAwDoECAACMQ6AAAADjECgAAMA4BAoAADAOgQIAAIxDoAAAAOMQKAAAwDgECgAAMA6BAgAAjEOgAAAA4xAoAADAOAQKAAAwDoECAACMQ6AAAADjECgAAMA4BAoAADAOgQIAAIxDoAAAAOMQKAAAwDgECgAAMA6BAgAAjEOgAAAA4wQ9UAoKCnTllVdqyJAhiouL00033aS6urqAY9ra2rRgwQLFxsZq8ODBys7OVkNDQ7BHAQAAfVTQA6WiokILFizQvn37tGfPHrW3t+uGG25Qa2ur/5ilS5dq165d2rFjhyoqKnTixAnNmDEj2KMAAIA+KjzYJywrKwtY37Jli+Li4lRTU6Orr75abrdbmzZtUmlpqa677jpJUklJiVJSUrRv3z5dddVVwR4JAAD0MT1+D4rb7ZYkxcTESJJqamrU3t6ujIwM/zEjR45UUlKSqqqqujyH1+uVx+MJWAAAQP/Vo4HS2dmpJUuWaOLEibrsssskSS6XS4MGDVJ0dHTAsfHx8XK5XF2ep6CgQHa73b8kJib25NgAACDEejRQFixYoHfffVfbt2//TufJz8+X2+32L/X19UGaEAAAmCjo96B8YeHChdq9e7cqKyt18cUX+7c7HA6dOXNGzc3NAVdRGhoa5HA4ujyX1WqV1WrtqVEBAIBhgn4FxefzaeHChXrxxRe1d+9eJScnB+wfN26cBg4cqPLycv+2uro6HT9+XOnp6cEeBwAA9EFBv4KyYMEClZaW6o9//KOGDBniv6/EbrcrMjJSdrtdc+fOVV5enmJiYmSz2bRo0SKlp6fzDh4AACCpBwJl48aNkqRrrrkmYHtJSYnuvPNOSdITTzyhsLAwZWdny+v1KjMzU08++WSwRwEAAH1U0APF5/N94zEREREqKipSUVFRsH88AADoB/guHgAAYBwCBQAAGIdAAQAAxiFQAACAcQgUAABgHAIFAAAYh0ABAADGIVAAAIBxCBQAAGAcAgUAABiHQAEAAMYhUAAAgHEIFAAAYBwCBQAAGIdAAQAAxiFQAACAcQgUAABgHAIFAAAYh0ABAADGIVAAAIBxCBQAAGAcAgUAABiHQAEAAMYhUAAAgHEIFAAAYBwCBQAAGIdAAQAAxiFQAACAcQgUAABgHAIFAAAYh0ABAADGCWmgFBUVafjw4YqIiFBaWprefPPNUI4DAAAMEbJA+Y//+A/l5eXpgQce0MGDBzV27FhlZmaqsbExVCMBAABDhCxQHn/8cc2bN0+zZ8/WqFGjVFxcrO9973vavHlzqEYCAACGCA/FDz1z5oxqamqUn5/v3xYWFqaMjAxVVVWdc7zX65XX6/Wvu91uSZLH4+n5YQ3T6T0d6hHQiy7E/49fyHh9X1guxNf3F8/Z5/N947EhCZTPPvtMHR0dio+PD9geHx+v999//5zjCwoKtGrVqnO2JyYm9tiMgAnshaGeAEBPuZBf36dOnZLdbv/aY0ISKN2Vn5+vvLw8/3pnZ6eampoUGxsri8USwsnQGzwejxITE1VfXy+bzRbqcQAEEa/vC4vP59OpU6fkdDq/8diQBMpFF12kAQMGqKGhIWB7Q0ODHA7HOcdbrVZZrdaAbdHR0T05Igxks9n4BQb0U7y+LxzfdOXkCyG5SXbQoEEaN26cysvL/ds6OztVXl6u9PT0UIwEAAAMErI/8eTl5Sk3N1fjx4/XhAkTVFhYqNbWVs2ePTtUIwEAAEOELFBuvfVWffrpp1q5cqVcLpeuuOIKlZWVnXPjLGC1WvXAAw+c82c+AH0fr298FYvv27zXBwAAoBfxXTwAAMA4BAoAADAOgQIAAIxDoAAAAOMQKAAAwDgECgAAMA6BAgAwhs/nU2NjY6jHgAEIFBhnypQpcrvd/vXVq1erubnZv/63v/1No0aNCsFkAL6r733ve/r000/961OnTtXJkyf9642NjUpISAjFaDAMgQLjvPLKK/J6vf71Rx55RE1NTf71s2fPqq6uLhSjAfiO2tra9H8/H7SyslJ///vfA47h80MhESgw0Jd/OfHLCriwWCyWUI8AAxAoAADAOAQKjGOxWM75Lyj+iwroH778+u7q9Q5IIfw2Y+Cr+Hw+3Xnnnf5vN21ra9Ndd92lqKgoSQq4PwVA3+Lz+XTJJZf4o6SlpUWpqakKCwvz7wckAgUGys3NDVifNWvWOcfccccdvTUOgCAqKSkJ9QjoIyw+chUAYIizZ8+qsbFRTqcz1KMgxLgHBX3O+++/r0suuSTUYwDoAYcPH1ZiYmKox4ABCBT0OV6vV3/9619DPQYAoAcRKAAAwDgECgAAMA7v4gEA9Jq33377a/fzNRb4Au/igXGGDh36tR/cdPbsWbW2tqqjo6MXpwIQDGFhYbJYLF1+3skX2y0WC69vcAUF5iksLAz1CAB6yLFjx0I9AvoIrqCgT+ro6NCAAQNCPQaAHvDuu+/qsssuC/UYCDFukkWfcuTIES1btkwXX3xxqEcBEESnTp3S008/rQkTJmjs2LGhHgcGIFBgvNOnT6ukpEQ//vGPNWrUKFVUVCgvLy/UYwEIgsrKSuXm5iohIUFr167Vddddp3379oV6LBiAe1BgrH379ul3v/udduzYoaSkJNXW1urVV1/Vj3/841CPBuA7cLlc2rJlizZt2iSPx6NbbrlFXq9XO3fu1KhRo0I9HgzBFRQY57HHHtPo0aN18803a+jQoaqsrNQ777wji8Wi2NjYUI8H4DuYNm2aLr30Ur399tsqLCzUiRMntH79+lCPBQNxBQXGWbZsmZYtW6YHH3yQG2GBfuZPf/qTFi9erF/+8pcaMWJEqMeBwbiCAuP8+te/1o4dO5ScnKxly5bp3XffDfVIAILk9ddf16lTpzRu3DilpaVpw4YN+uyzz0I9FgxEoMA4+fn5OnLkiH7/+9/L5XIpLS1NY8eOlc/n0+effx7q8QB8B1dddZWeeeYZnTx5Ur/4xS+0fft2OZ1OdXZ2as+ePTp16lSoR4Qh+BwUGO/UqVMqLS3V5s2bVVNTowkTJujmm2/mnTxAP1FXV6dNmzbp97//vZqbm/XTn/5UL730UqjHQogRKOhT3nnnHW3atEmlpaVqbGwM9TgAgqijo0O7d+/W5s2b9cc//jHU4yDECBT0Se3t7Ro4cGCoxwDQTXPmzPlWx23evLmHJ4HpCBQY59lnn/3GYywWi26//fZemAZAMIWFhWnYsGFKTU3t8gsDpX+8vl944YVengymIVBgnLCwMA0ePFjh4eFf+wusqamplycD8F0tWLBAzz33nIYNG6bZs2dr1qxZiomJCfVYMBCBAuOMHj1aDQ0NmjVrlubMmaPLL7881CMBCCKv16sXXnhBmzdv1htvvKGpU6dq7ty5uuGGG2SxWEI9HgzB24xhnMOHD+vll1/W3//+d1199dUaP368Nm7cKI/HE+rRAASB1WrVbbfdpj179ui9997T6NGjdffdd2v48OFqaWkJ9XgwBIECI6Wlpempp57SyZMntXjxYj3//PNKSEhQTk6OvF5vqMcDECRhYWGyWCzy+Xzq6OgI9TgwCIECo0VGRuqOO+7QqlWrNGHCBG3fvl2nT58O9VgAvgOv16vnnntOP/3pT3XJJZfonXfe0YYNG3T8+HENHjw41OPBEHwXD4z1ySefaOvWrSopKVFra6tmzZqljRs3aujQoaEeDcB5uvvuu7V9+3YlJiZqzpw5eu6553TRRReFeiwYiJtkYZznn39eJSUlqqioUGZmpmbPnq2pU6fyxYFAPxAWFqakpCSlpqZ+7Q2xvM0YBAqM88UvsJycHMXHx3/lcYsXL+7FqQAEw5133vmt3qlTUlLSC9PAZAQKjDN8+PBv/AVmsVj04Ycf9tJEAIDeRqAAAADj8C4eAABgHAIFxpkyZYrcbrd/ffXq1Wpubvav/+1vf9OoUaNCMBkAoLfwJx4YZ8CAATp58qTi4uIkSTabTYcOHdL3v/99SVJDQ4OcTicf6gQA/RhXUGCcLzczDQ0AFx4CBQAAGIdAgXEsFss5bzPmG04B4MLCR93DOD6fT3feeaesVqskqa2tTXfddZeioqIkiS8LBIALADfJwjh80iQAgECBcT788EMNHz5cYWH8BRIALlT8CwDjjBgxQp999pl//dZbb1VDQ0MIJwIA9DYCBcb58kW9//qv/1Jra2uIpgEAhAKBAgAAjEOgwDi8zRgAwNuMYZxvepvxF1544YVQjAcA6AUECoyTm5sbsD5r1qwQTQIACBXeZgwAAIzDPSgAAMA4BAoAADAOgQIAAIxDoAAAAOMQKAAAwDgECgAAMA6BAgAAjEOgAOg1f/jDHzRmzBhFRkYqNjZWGRkZ/i+C/N3vfqeUlBRFRERo5MiRevLJJ/2PmzNnji6//HJ5vV5J0pkzZ5Samqo77rgjJM8DQM8jUAD0ipMnT+q2227TnDlzVFtbq9dee00zZsyQz+fTtm3btHLlSj388MOqra3VI488ohUrVmjr1q2SpHXr1qm1tVXLly+XJN13331qbm7Whg0bQvmUAPQgPuoeQK84efKkzp49qxkzZmjYsGGSpDFjxkiSHnjgAT322GOaMWOGJCk5OVnvvfeennrqKeXm5mrw4MH693//d/3kJz/RkCFDVFhYqFdffVU2my1kzwdAz+Kj7gH0io6ODmVmZurNN99UZmambrjhBt18880aNGiQBg8erMjISIWF/f+LumfPnpXdbldDQ4N/27/+67+qoKBAy5Yt0+rVq0PxNAD0Eq6gAOgVAwYM0J49e/TGG2/oz3/+s9avX6/77rtPu3btkiQ988wzSktLO+cxX+js7NRf/vIXDRgwQEePHu3V2QH0Pu5BAdBrLBaLJk6cqFWrVumtt97SoEGD9Je//EVOp1Mffvih/umf/ilgSU5O9j/2N7/5jd5//31VVFSorKxMJSUlIXwmAHoaV1AA9Irq6mqVl5frhhtuUFxcnKqrq/Xpp58qJSVFq1at0uLFi2W32zV58mR5vV4dOHBAn3/+ufLy8vTWW29p5cqV+sMf/qCJEyfq8ccf169+9Sv95Cc/0fe///1QPzUAPYB7UAD0itraWi1dulQHDx6Ux+PRsGHDtGjRIi1cuFCSVFpaqt/85jd67733FBUVpTFjxmjJkiXKysrSuHHjNGnSJD311FP+802fPl2fffaZKisrA/4UBKB/IFAAAIBxuAcFAAAYh0ABAADGIVAAAIBxCBQAAGAcAgUAABiHQAEAAMYhUAAAgHEIFAAAYBwCBQAAGIdAAQAAxiFQAACAcf4fGOOYFqRqDtcAAAAASUVORK5CYII=", "text/plain": [ "
" ] @@ -1081,14 +1069,14 @@ " \n", " \n", " 0\n", + " 2\n", + " 2021-09-19 10:25:05+00:00\n", + " 2021-09-19 10:25:10+00:00\n", " 1\n", - " 2021-06-09 07:44:46+00:00\n", - " 2021-06-09 07:45:24+00:00\n", - " 1\n", - " 2.200000000\n", + " 0E-9\n", " 1.0\n", " N\n", - " 4\n", + " 1\n", " 0E-9\n", " 0E-9\n", " 0E-9\n", @@ -1097,21 +1085,21 @@ " 0E-9\n", " 0E-9\n", " 0E-9\n", - " 263\n", - " 263\n", + " 264\n", + " 264\n", " 2021\n", - " 6\n", + " 9\n", " \n", " \n", " 1\n", " 2\n", - " 2021-06-07 11:59:46+00:00\n", - " 2021-06-07 12:00:00+00:00\n", - " 2\n", - " 0.010000000\n", - " 3.0\n", + " 2021-09-20 14:53:02+00:00\n", + " 2021-09-20 14:53:23+00:00\n", + " 1\n", + " 0E-9\n", + " 1.0\n", " N\n", - " 2\n", + " 1\n", " 0E-9\n", " 0E-9\n", " 0E-9\n", @@ -1120,16 +1108,16 @@ " 0E-9\n", " 0E-9\n", " 0E-9\n", - " 263\n", - " 263\n", + " 193\n", + " 193\n", " 2021\n", - " 6\n", + " 9\n", " \n", " \n", " 2\n", - " 2\n", - " 2021-06-23 15:03:58+00:00\n", - " 2021-06-23 15:04:34+00:00\n", + " 1\n", + " 2021-09-14 12:01:02+00:00\n", + " 2021-09-14 12:07:19+00:00\n", " 1\n", " 0E-9\n", " 1.0\n", @@ -1143,21 +1131,21 @@ " 0E-9\n", " 0E-9\n", " 0E-9\n", - " 193\n", - " 193\n", + " 170\n", + " 170\n", " 2021\n", - " 6\n", + " 9\n", " \n", " \n", " 3\n", + " 2\n", + " 2021-09-12 10:40:32+00:00\n", + " 2021-09-12 10:41:26+00:00\n", " 1\n", - " 2021-06-12 14:26:55+00:00\n", - " 2021-06-12 14:27:08+00:00\n", - " 0\n", - " 1.000000000\n", + " 0E-9\n", " 1.0\n", " N\n", - " 3\n", + " 1\n", " 0E-9\n", " 0E-9\n", " 0E-9\n", @@ -1166,16 +1154,16 @@ " 0E-9\n", " 0E-9\n", " 0E-9\n", - " 143\n", - " 143\n", + " 193\n", + " 193\n", " 2021\n", - " 6\n", + " 9\n", " \n", " \n", " 4\n", - " 2\n", - " 2021-06-15 08:39:01+00:00\n", - " 2021-06-15 08:40:36+00:00\n", + " 1\n", + " 2021-09-25 11:57:21+00:00\n", + " 2021-09-25 11:58:32+00:00\n", " 1\n", " 0E-9\n", " 1.0\n", @@ -1189,10 +1177,10 @@ " 0E-9\n", " 0E-9\n", " 0E-9\n", - " 193\n", - " 193\n", + " 95\n", + " 95\n", " 2021\n", - " 6\n", + " 9\n", " \n", " \n", "\n", @@ -1200,17 +1188,17 @@ ], "text/plain": [ " vendor_id pickup_datetime dropoff_datetime \\\n", - "0 1 2021-06-09 07:44:46+00:00 2021-06-09 07:45:24+00:00 \n", - "1 2 2021-06-07 11:59:46+00:00 2021-06-07 12:00:00+00:00 \n", - "2 2 2021-06-23 15:03:58+00:00 2021-06-23 15:04:34+00:00 \n", - "3 1 2021-06-12 14:26:55+00:00 2021-06-12 14:27:08+00:00 \n", - "4 2 2021-06-15 08:39:01+00:00 2021-06-15 08:40:36+00:00 \n", + "0 2 2021-09-19 10:25:05+00:00 2021-09-19 10:25:10+00:00 \n", + "1 2 2021-09-20 14:53:02+00:00 2021-09-20 14:53:23+00:00 \n", + "2 1 2021-09-14 12:01:02+00:00 2021-09-14 12:07:19+00:00 \n", + "3 2 2021-09-12 10:40:32+00:00 2021-09-12 10:41:26+00:00 \n", + "4 1 2021-09-25 11:57:21+00:00 2021-09-25 11:58:32+00:00 \n", "\n", " passenger_count trip_distance rate_code store_and_fwd_flag payment_type \\\n", - "0 1 2.200000000 1.0 N 4 \n", - "1 2 0.010000000 3.0 N 2 \n", + "0 1 0E-9 1.0 N 1 \n", + "1 1 0E-9 1.0 N 1 \n", "2 1 0E-9 1.0 N 1 \n", - "3 0 1.000000000 1.0 N 3 \n", + "3 1 0E-9 1.0 N 1 \n", "4 1 0E-9 1.0 N 1 \n", "\n", " fare_amount extra mta_tax tip_amount tolls_amount imp_surcharge \\\n", @@ -1221,18 +1209,18 @@ "4 0E-9 0E-9 0E-9 0E-9 0E-9 0E-9 \n", "\n", " airport_fee total_amount pickup_location_id dropoff_location_id \\\n", - "0 0E-9 0E-9 263 263 \n", - "1 0E-9 0E-9 263 263 \n", - "2 0E-9 0E-9 193 193 \n", - "3 0E-9 0E-9 143 143 \n", - "4 0E-9 0E-9 193 193 \n", + "0 0E-9 0E-9 264 264 \n", + "1 0E-9 0E-9 193 193 \n", + "2 0E-9 0E-9 170 170 \n", + "3 0E-9 0E-9 193 193 \n", + "4 0E-9 0E-9 95 95 \n", "\n", " data_file_year data_file_month \n", - "0 2021 6 \n", - "1 2021 6 \n", - "2 2021 6 \n", - "3 2021 6 \n", - "4 2021 6 " + "0 2021 9 \n", + "1 2021 9 \n", + "2 2021 9 \n", + "3 2021 9 \n", + "4 2021 9 " ] }, "execution_count": 15, @@ -1288,16 +1276,6 @@ "id": "e34ab06d", "metadata": {}, "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:273: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", - " warnings.warn(msg, category=bfe.AmbiguousWindowWarning)\n", - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:249: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", - " warnings.warn(msg, bfe.AmbiguousWindowWarning)\n" - ] - }, { "data": { "text/plain": [ @@ -1310,7 +1288,7 @@ }, { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] @@ -1347,16 +1325,6 @@ "id": "51c4dfc7", "metadata": {}, "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:273: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", - " warnings.warn(msg, category=bfe.AmbiguousWindowWarning)\n", - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:249: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", - " warnings.warn(msg, bfe.AmbiguousWindowWarning)\n" - ] - }, { "data": { "text/plain": [ @@ -1369,7 +1337,7 @@ }, { "data": { - "image/png": "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RCeJdrLXoZPJ8ft87StfIf3Y9N/R0hEKsezb3570n1P1pBmBDFx8iBKeoI6ddFBHF55jq0fDx5oK5+PhXLJZNkpVNBPd66+/zs6dOznjjDNobW3ltttuA+Cyyy5LcmQiIiIiIokXxEs6HjoI9UvSxI5V6GfhT0ZoIqdMPWkSR0maEQri4v0UU0+Y7bRRc2z5UzpOZpDGLNLxpkxB1sh897vfZdeuXbjdbk4//XSee+45cnNzT+mcb538dKLHHnssYU2IRURERESGw4uLeRSynv34cOHHDcQTNDW0kUc608hJcpQikmxK0pwCA4N8POSTN+bXHmgE9kSyZMkSNmzYkPDzbtq0adB9JSUlCb+eiIiIiMhQLaGUNnrYQS31dPbW2+eRzgXM6k3ciEw0BqNf6ZIq61OUpJFJZcaMGckOQURERERkQG4cXMBM5lLAYVqIECMLP9PIUYJGRAAlaQCY5L2TZYLS96WIiIjI5GNiUkompWQmOxSRhFFPmsRJlfsckMvlAqCrqyvJkYj0d/z78vj3qYiIiIiIiExuKV1J43A4yMzMpK6uDgC/349hpMpKNxmvbNumq6uLuro6MjMzcTjUgFpERERERMYvx7HHaF8jFaR0kgagsLAQoDdRIzJeZGZm9n5/ioiIiIiIyOSX8kkawzAoKioiPz+fSCSS7HBEgPgSJ1XQiIiIiIjIRKCeNImT8kma4xwOh14Ui4iIiIiIiEjSpEoySkRERERERERkXFMljYiIiIiIiIiMmJY7JU6q3KeIiIiIiIiIyLimShoRERERERERGTFV0iROqtyniIiIiIiIiMi4pkoaERERERERERkxx7HHaF8jFaiSRkRERERERERkHFAljYiIiIiIiIiMmHrSJE6q3KeIiIiIiIiIpIhnn32WNWvWUFxcjGEYPPjgg33227bN1772NYqKivD5fFx44YXs2bMnOcG+hZI0IiIiIiIiIjJiBm9W04zWwxhmTJ2dnSxatIj//u//HnD/d77zHX74wx/y05/+lJdffpm0tDQuvvhienp6hnmlxNJyJxERERERERGZVC655BIuueSSAffZts3atWv593//dy677DIA7rnnHgoKCnjwwQf50Ic+NJah9qFKGhEREREREREZMccYPQDa2tr6PEKh0LDjPXDgAEePHuXCCy/s3ZaRkcGZZ57Jiy++OOzzJZKSNCIiIiIiIiIyIZSVlZGRkdH7uP3224d9jqNHjwJQUFDQZ3tBQUHvvmTRcicRERERERERGbGxnO5UVVVFMBjs3e7xeEb5ymNLlTQiIiIiIiIiMiEEg8E+j5EkaQoLCwGora3ts722trZ3X7IoSSMiIiIiIiIiI2YaYJqj/BjueKeTmDp1KoWFhTz99NO929ra2nj55ZdZsWJF4i40AlruJCIiIiIiIiKTSkdHB3v37u39+MCBA2zatIns7GzKy8v53Oc+xze/+U1mzpzJ1KlTufnmmykuLubyyy9PXtAoSSMiIiIiIiIip+B4tctoX2M4XnvtNc4777zejz//+c8DcO211/KrX/2KL3/5y3R2dnL99dfT0tLCueeey+OPP47X601k2MOmJI2IiIiIiIiITCqrV6/Gtu1B9xuGwW233cZtt902hlG9PSVpRERERERERGTEHEb8MdrXSAVJbRx85513snDhwt6uzCtWrOCxxx7r3b969WoMw+jz+OQnP5nEiEVERERERERERkdSK2lKS0v59re/zcyZM7Ftm7vvvpvLLruM119/nfnz5wPw8Y9/vE/5kd/vT1a4IiIiIiIiIiKjJqlJmjVr1vT5+Fvf+hZ33nknL730Um+Sxu/3J31OuYiIiIiIiIgMbDw2Dp6oxs1txmIx7rvvPjo7O/vMJf/Nb35Dbm4up512Gl/96lfp6uo66XlCoRBtbW19HiIiIiIiIiIi413SGwdv2bKFFStW0NPTQ3p6Og888ADz5s0D4KqrrmLKlCkUFxezefNm/uVf/oVdu3bxpz/9adDz3X777dx6661jFb6IiIiIiIhISnOY8cdoXyMVGPbJZlKNgXA4TGVlJa2trfzhD3/gF7/4BevWretN1LzV3/72Ny644AL27t3L9OnTBzxfKBQiFAr1ftzW1kZZWRmtra0Eg8FRuw8RERERERERiL8OzcjImPSvQ3vv85avEPR6RvdaPSEybvn2pP+cJr2Sxu12M2PGDABOP/10Xn31VX7wgx/ws5/9rN+xZ555JsBJkzQejwePZ3S/OURERERERETkGJPRb6aSIpU04+42LcvqUwnzVps2bQKgqKhoDCMSERERERERERl9Sa2k+epXv8oll1xCeXk57e3t3HvvvTzzzDM88cQT7Nu3j3vvvZd3v/vd5OTksHnzZm666SZWrlzJwoULkxm2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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAABGkAAAJfCAYAAADM54shAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs3Xd4XPWV//H3nT6jKerVKu6We8UFMIZACGwIJCTAhg2QRpINmwKk7pJAEhbSiEnjt9mwQDYhjQ0lECCUYKoLLrjbsi1ZLmpWr1Pv74+xBUJukkcaSfN55Zkn6N47954ryZLmzPmeY5imaSIiIiIiIiIiIkllSXYAIiIiIiIiIiKiJI2IiIiIiIiIyIigJI2IiIiIiIiIyAigJI2IiIiIiIiIyAigJI2IiIiIiIiIyAigJI2IiIiIiIiIyAigJI2IiIiIiIiIyAigJI2IiIiIiIiIyAigJI2IiIiIiIiIyAigJI2IiIiIiIiIyAigJI2IiIiIiIiIjCkvv/wyl112GYWFhRiGwWOPPdZnv2mafOtb36KgoAC3282FF15IRUVFcoJ9ByVpRERERERERGRM6ezsZM6cOfziF7847v4f/OAH/PSnP+X//b//x5o1a0hLS+Piiy+mp6dnmCPtyzBN00xqBCIiIiIiIiIiQ8QwDB599FGuuOIKIF5FU1hYyC233MKtt94KQGtrK3l5eTz44INcc801SYvVlrQrD5NYLMbhw4fx+XwYhpHscERERERERGSMM02T9vZ2CgsLsVjG9gKWnp4eQqHQsFzLNM1+r+udTidOp3NA56msrKS2tpYLL7ywd1sgEGDx4sW88cYbStIMpcOHD1NcXJzsMERERERERCTFHDhwgHHjxiU7jCHT09NDUU4OTR0dw3I9r9dLx7uu9e1vf5vbb799QOepra0FIC8vr8/2vLy83n3JMuaTND6fD4j/4/D7/UmORkRERERERMa6trY2iouLe1+PjlWhUIimjg7+9OUv4xlgNctAdQWDXPWTn/R7bT/QKpqRbswnaY6VQvn9fiVpREREREREZNikSssNr9NJ2hAnS44tGkvEa/v8/HwA6urqKCgo6N1eV1fH3Llzz+jcZ2psL44TEREREREREXmH8ePHk5+fzwsvvNC7ra2tjTVr1rB06dIkRpYClTQiIiIiIiIiMnQsDH0FyEDP39HRwZ49e3o/rqysZNOmTWRmZlJSUsKXvvQlvve97zF58mTGjx/PbbfdRmFhYe8EqGRRkkZERERERERExpQ333yT888/v/fjm2++GYDrr7+eBx98kK9+9at0dnZy44030tLSwjnnnMMzzzyDy+VKVsiAkjRAfIxXJBIhGo0mOxQRAKxWKzabLWXWsIqIiIiIyOg1EitpVqxYgWmaJ9xvGAbf+c53+M53vnNmgSVYyidpQqEQNTU1dHV1JTsUkT48Hg8FBQU4HI5khyIiIiIiIiLDIKWTNLFYjMrKSqxWK4WFhTgcDlUuSNKZpkkoFKKhoYHKykomT56MxaIe3yIiIiIiMjJZjz6G+hqpIKWTNKFQiFgsRnFxMR6PJ9nhiPRyu93Y7Xb2799PKBRK+rpIERERERERGXopnaQ5RlUKMhLp+1JEREREREYDg6HvSZMqa170KlBEREREREREZARQJc0Z6iHMfhppoZsYJnasFJJOPn4sKZPrExERERERkVQ1Eqc7jVZK0gxSiAibOMgOamjm7clQJuA8mqiZSzFlZCUvSBEREREREREZNVIlGZVQQSI8z05eYw9BIhQSoJgMismghAz8uKmmiWfZxg5qkx2ujECGYfDYY48lOwwREREREZEzZhmmRypIlftMGBOT19jLLmrJw08WaVjf9Wl0Y6eQACbwChUcpDk5waaYaDRKLBZLdhgiIiIiIiIig6IkzQA10kkFdWTgwXmS1WIGBtmk0U2YrRzGxExoHCtWrOCmm27ipptuIhAIkJ2dzW233YZpxq/zv//7vyxcuBCfz0d+fj4f/ehHqa+v731+c3Mz1157LTk5ObjdbiZPnswDDzwAxEeT33TTTRQUFOByuSgtLeWuu+7qfW5LSwuf+tSnyMnJwe/3c8EFF/DWW2/17r/99tuZO3cu//u//0tZWRmBQIBrrrmG9vb23mPa29u59tprSUtLo6CggJ/85CesWLGCL33pS73HBINBbr31VoqKikhLS2Px4sW89NJLvfsffPBB0tPTeeKJJ5g+fTpOp5Pq6upTfu7+53/+hxkzZuB0OikoKOCmm27q3VddXc3ll1+O1+vF7/dz1VVXUVdX17v/hhtu4Iorruhzvi996UusWLGiz9fmC1/4Al/96lfJzMwkPz+f22+/vXd/WVkZAB/84AcxDKP3YxEREREREUltStIM0F4a6CKEF+cpjzUwSMfNfhppojPhsTz00EPYbDbWrl3Lvffeyz333MOvf/1rAMLhMN/97nd56623eOyxx6iqquKGG27ofe5tt93G9u3befrpp9mxYwf33Xcf2dnZAPz0pz/liSee4E9/+hO7du3id7/7XZ9Ewkc+8hHq6+t5+umnWb9+PfPnz+c973kPTU1Nvcfs3buXxx57jCeffJInn3ySVatWcffdd/fuv/nmm3nttdd44okneO6553jllVfYsGFDn/u76aabeOONN/jDH/7A5s2b+chHPsL73vc+Kioqeo/p6uri+9//Pr/+9a/Ztm0bubm5J/2c3XfffXz+85/nxhtvZMuWLTzxxBNMmjQJgFgsxuWXX05TUxOrVq3iueeeY9++fVx99dUD+8IQ/9qkpaWxZs0afvCDH/Cd73yH5557DoB169YB8MADD1BTU9P7sYiIiIiIyGhkHaZHKlDj4AHaTyNu7BinObkpDQfNdFFHO1l4ExpLcXExP/nJTzAMg6lTp7JlyxZ+8pOf8OlPf5pPfOITvcdNmDCBn/70pyxatIiOjg68Xi/V1dXMmzePhQsXAvRJwlRXVzN58mTOOeccDMOgtLS0d9+rr77K2rVrqa+vx+mMJ6p+9KMf8dhjj/HII49w4403AvGEx4MPPojP5wPgYx/7GC+88AJ33nkn7e3tPPTQQzz88MO85z3vAeIJi8LCwj4xPPDAA1RXV/duv/XWW3nmmWd44IEH+M///E8gnoz65S9/yZw5c07rc/a9732PW265hS9+8Yu92xYtWgTACy+8wJYtW6isrKS4uBiA3/zmN8yYMYN169b1Hnc6Zs+ezbe//W0AJk+ezM9//nNeeOEFLrroInJycgBIT08nPz//tM8pIiIiIiIiY5sqaQYoSKRfD5qTMY6mc8JEEx7LkiVLMIy3k0VLly6loqKCaDTK+vXrueyyyygpKcHn83HeeecB9C4H+tznPscf/vAH5s6dy1e/+lVef/313vPccMMNbNq0ialTp/KFL3yBv//977373nrrLTo6OsjKysLr9fY+Kisr2bt3b+9xZWVlvQkagIKCgt7lVvv27SMcDnPWWWf17g8EAkydOrX34y1bthCNRpkyZUqf66xatarPdRwOB7Nnzz6tz1d9fT2HDx/uTQy9244dOyguLu5N0ABMnz6d9PR0duzYcVrXOObdMb3z/kVEREQktUWI0EIzLTQTHYLXCSLDTY2DE0eVNANkw0qM0Gkfbx7tRmMbxm+pnp4eLr74Yi6++GJ+97vfkZOTQ3V1NRdffDGhUDz2Sy65hP379/O3v/2N5557jve85z18/vOf50c/+hHz58+nsrKSp59+mueff56rrrqKCy+8kEceeYSOjg4KCgr69IY5Jj09vfe/7XZ7n32GYQyoqW9HRwdWq5X169djtfYtbPN6365IcrvdfRJVJ+N2u0/7+idisVh6+/4cEw6H+x13pvcvIiIiImNPjBhVVLGH3bQR79fox89kplBG2WlX64vI2KUkzQCNI503acPEPK0fot2EcWAji7SEx7JmzZo+H69evZrJkyezc+dOGhsbufvuu3urQt58881+z8/JyeH666/n+uuv59xzz+UrX/kKP/rRjwDw+/1cffXVXH311Xz4wx/mfe97H01NTcyfP5/a2lpsNtugG95OmDABu93OunXrKCkpAaC1tZXdu3ezfPlyAObNm0c0GqW+vp5zzz13UNd5N5/PR1lZGS+88ALnn39+v/3l5eUcOHCAAwcO9H7etm/fTktLC9OnTwfin7OtW7f2ed6mTZv6JWVOxW63E43qXRMRERGRVFLBbt5iExaseI+2QmijlTdZS4Qwk5mS5AhFBmc4Kl1SpZImVe4zYSaRixMb3fSvnjieZroZRzp5+BMeS3V1NTfffDO7du3i97//PT/72c/44he/SElJCQ6Hg5/97Gfs27ePJ554gu9+97t9nvutb32Lxx9/nD179rBt2zaefPJJysvLAbjnnnv4/e9/z86dO9m9ezd//vOfyc/PJz09nQsvvJClS5dyxRVX8Pe//52qqipef/11/v3f//24iaDj8fl8XH/99XzlK1/hH//4B9u2beOTn/wkFoultypmypQpXHvttVx33XX85S9/obKykrVr13LXXXfx1FNPDfpzdvvtt/PjH/+Yn/70p1RUVLBhwwZ+9rOfAXDhhRcya9Ysrr32WjZs2MDatWu57rrrOO+883p791xwwQW8+eab/OY3v6GiooJvf/vb/ZI2p+NYsqi2tpbmZo1oFxERERnruulmN7uw4yCTTBxH/5dJFlZs7GInPfQkO0wRSTIlaQYoDz+lZNFAB5FTrB9tpRsrBtMpGJLSxeuuu47u7m7OOussPv/5z/PFL36RG2+8kZycHB588EH+/Oc/M336dO6+++7eCpljHA4H3/jGN5g9ezbLly/HarXyhz/8AYgnUX7wgx+wcOFCFi1aRFVVFX/72996kyh/+9vfWL58OR//+MeZMmUK11xzDfv37ycvL++0Y7/nnntYunQp73//+7nwwgs5++yzKS8vx+Vy9R7zwAMPcN1113HLLbcwdepUrrjiij7VN4Nx/fXXs3LlSn75y18yY8YM3v/+9/dOizIMg8cff5yMjAyWL1/OhRdeyIQJE/jjH//Y+/yLL76Y2267ja9+9assWrSI9vZ2rrvuugHH8eMf/5jnnnuO4uJi5s2bN+j7EREREZHRoYF6OunEh6/fPj9+OujgCA1JiEzkzGm6U+IY5rsbbIwxbW1tBAIBWltb8fv7VrP09PRQWVnJ+PHj+yQHTqWdHp5lO9U0kYkHL84+SZgIUZrpIkyMxYxnEaUJT9KsWLGCuXPnsnLlyoSeN1k6OzspKirixz/+MZ/85CeTHc6IMNjvTxEREREZeSrZxxpWk0v/NzZNTBqoYylnU0rZ8AcnCXey16FjybH7fPPrX8d7dPrvUOkIBll4991j/nOqnjSD4MPFJczgdfayj0aaacaOFQsGYeLNYTPxMI8SZgxRFc1ot3HjRnbu3MlZZ51Fa2sr3/nOdwC4/PLLkxyZiIiIiEji+fBjw0aQIE76vpgNEsSO47hVNiKjgXrSJI6SNIOUhpMLKaeZLvbSwBE6CBPFg4MSMiklC6c+vSf1ox/9iF27duFwOFiwYAGvvPIK2dnZZ3TOd05+erenn346YU2IRUREREQGIpNM8sjnANVkk4Pt6GuFMGFaaKaUMjLITHKUIpJsyiKcAQODTNLIHILJTadyvBHYo8m8efNYv359ws+7adOmE+4rKipK+PVERERERE6HBQvzmE+ECPXUETtagW/BQiFFzGWeKvBl1DIY+kqXVPnXoSSNjCmTJk1KdggiIiIiIsflxcs5nEsttTTTBEAWWeSR31tZIyKpTT8JgDHeO1lGKX1fioiIiIw9duwUH/2fyFihnjSJkyr3eVx2ux2Arq6uJEci0t+x78tj36ciIiIiIiIytqV0JY3VaiU9PZ36+noAPB4PhpEqK91kpDJNk66uLurr60lPT8dqtSY7JBERERERkROyHn0M9TVSQUonaQDy8/MBehM1IiNFenp67/eniIiIiIiIjH0pn6QxDIOCggJyc3MJh8PJDkcEiC9xUgWNiIiIiIiMBupJkzgpn6Q5xmq16kWxiIiIiIiIiCRNqiSjRERERERERERGNFXSiIiIiIiIiMigablT4qTKfYqIiIiIiIiIjGiqpBERERERERGRQVMlTeKkyn2KiIiIiIiIiIxoqqQRERERERERkUGzHn0M9TVSgSppRERERERERERGAFXSiIiIiIiIiMigqSdN4qTKfYqIiIiIiIiIjGiqpBERERERERGRQTMY+goQY4jPP1KokkZEREREREREZARQJY2IiIiIiIiIDJqmOyWOKmlEREREREREREYAVdKIiIiIiIiIyKBpulPipMp9ioiIiIiIiIiMaKqkEREREREREZFBsxhgGeISEEuKjHdSJY2IiIiIiIiIyAigShoRERERERERGTSLZRgqaVKkxCRFblNEREREREREZGRTJY2IiIiIiIiIDJrViD+G+hqpIKmVNPfddx+zZ8/G7/fj9/tZunQpTz/9dO/+FStWYBhGn8dnP/vZJEYsIiIiIiIiIjI0klpJM27cOO6++24mT56MaZo89NBDXH755WzcuJEZM2YA8OlPf5rvfOc7vc/xeDzJCldEREREREREZMgkNUlz2WWX9fn4zjvv5L777mP16tW9SRqPx0N+fn4ywhMRERERERGRU1Dj4MQZMbcZjUb5wx/+QGdnJ0uXLu3d/rvf/Y7s7GxmzpzJN77xDbq6uk56nmAwSFtbW5+HiIiIiIiIiMhIl/TGwVu2bGHp0qX09PTg9Xp59NFHmT59OgAf/ehHKS0tpbCwkM2bN/O1r32NXbt28Ze//OWE57vrrru44447hit8ERERERERkZRmtcQfQ32NVGCYpmkmM4BQKER1dTWtra088sgj/PrXv2bVqlW9iZp3evHFF3nPe97Dnj17mDhx4nHPFwwGCQaDvR+3tbVRXFxMa2srfr9/yO5DREREREREBOKvQwOBwJh/Hdp7n7d/Hb/LObTX6gkSuP3uMf85TXoljcPhYNKkSQAsWLCAdevWce+99/Jf//Vf/Y5dvHgxwEmTNE6nE6dzaL85REREREREROQoC0PfTCVFKmlG3G3GYrE+lTDvtGnTJgAKCgqGMSIRERERERERkaGX1Eqab3zjG1xyySWUlJTQ3t7Oww8/zEsvvcSzzz7L3r17efjhh7n00kvJyspi8+bNfPnLX2b58uXMnj07mWGLiIiIiIiIyDGqpEmYpCZp6uvrue6666ipqSEQCDB79myeffZZLrroIg4cOMDzzz/PypUr6ezspLi4mCuvvJL/+I//SGbIIiIiIiIiIiJDIqlJmvvvv/+E+4qLi1m1atUwRiMiIiIiIiIiA6ZKmoRJkdsUERERERERERnZkj7dSURERERERERGMVXSJEyK3KaIiIiIiIiIyMimShoRERERERERGTzj6GOor5ECVEkjIiIiIiIiIjICqJJGRERERERERAbPYOhLQFRJIyIiIiIiIiIiw0WVNCIiIiIiIiIyeJrulDApcpsiIiIiIiIiIiObkjQiIiIiIiIiIiOAljuJiIiIiIiIyOBpuVPCpMhtioiIiIiIiIiMbKqkEREREREREZHBUyVNwqTIbYqIiIiIiIiIjGyqpBERERERERGRwVMlTcKkyG2KiIiIiIiIiIxsqqQRERERERERkcEzjj6G+hopQJU0IiIiIiIiIiIjgCppRERERERERGTw1JMmYVLkNkVERERERERERjZV0oiIiIiIiIjI4KmSJmFS5DZFREREREREREY2VdKIiIiIiIiIyOCpkiZhlKQREREREZHkCjfHH9Y0cOSCkSKzdkVE3kVJGhERERERSY5wM9Q9Bs2rIdoJFif4ZkP+B8FdkuzoROR0GQx9pUuK5G5TpGBIRERERERGlEgn7P851D0BhgVcRWD1QPMqqFoJPYeTHaGIyLBTkkZERERERIZf61po2wTeaeDMA6sbHFngnQldlXDkhWRHKCKnyzJMjxSQIrcpIiIiIiIjSus6sDjiS5zeybCAIzuexImFkxObiEiSqCeNiIiIiIgMv2g3GI7j77M4wAzHH9iHNSwRGQRNd0qYFLlNEREREREZUdImQ7QdTLP/vlAjuErA4h7+uEREkkhJGhERERERGX7pS8CeBd2VYMbi20wTgrXxEdxZKzSKW0RSjpY7iYiIiIjI8PNMgHE3wKH/hfat8YSMGQN7OuRdGU/iiMjoYDD0I7JTJGerJI2IiIiIiCRHxtngmQxt6+NLnGw+8M0Gd5mqaEQkJSlJIyIiIiIiyePMhZxLkh2FiJwJNQ5OmBS5TRERERERERGRkU2VNCIiIiIiIiIyeKqkSZgUuU0RERERERERkZFNlTQiIiIiIiKSFCYmERqIEcJGBlbSkh2SDIYqaRImRW5TRERERERERpIe9lHP/RxmJTX8lMPcQzN/I0Z3skOTMSAajXLbbbcxfvx43G43EydO5Lvf/S6maSY7tJNSJY2IiIiIiIgMqx4qaeC3hGnETh4WHERppZlnCHOEHP4ZA3uyw5TTNQIrab7//e9z33338dBDDzFjxgzefPNNPv7xjxMIBPjCF74wNDEmgJI0IiIiIiIiMmxMTFpZRZhGXEzCwADAggsLPjp5Cy8L8TA9yZHKaPb6669z+eWX80//9E8AlJWV8fvf/561a9cmObKT03InERERERERGTZRWuhhL3ZyehM0x1jxYBKhm4okRSeDYhmmB9DW1tbnEQwGjxvSsmXLeOGFF9i9ezcAb731Fq+++iqXXHJJgm8+sVRJIyIiIiIiIsPGJIxJBAPvcfcbWDA5/gtvkeLi4j4ff/vb3+b222/vd9zXv/512tramDZtGlarlWg0yp133sm11147TJEOjpI0IiIiIiIiMmyspGMjgwjN/aY5mcQwieCgIEnRyaAMY0+aAwcO4Pf7ezc7nc7jHv6nP/2J3/3udzz88MPMmDGDTZs28aUvfYnCwkKuv/76IQ528JSkEREREREZiFgEgoeBGDgLwHL8FwgicnwWHPhYTCOPEqEVGwEATKIEqcZOPh5mJjlKGan8fn+fJM2JfOUrX+HrX/8611xzDQCzZs1i//793HXXXUrSiIiIiIiMeqYJLa/BkWegpzr+sasAst4LmReAoXaPIqfLzzIiNNLOGsLUAQZgYiefbD6MjYxkhygDYRx9DPU1BqCrqwuLpe/PZavVSiwWS2BQiackjYiIiIjI6Wj6Bxy6P/7fjgIwDAjWwsH/hkg75H0wufGJjCIGdjK5gjTm0UMFMYLYycbN9N7KGpEzcdlll3HnnXdSUlLCjBkz2LhxI/fccw+f+MQnkh3aSSlJIyIiIiJyKpFOaHgcDBu4x7+93T0hvvTpyNOQcQ44cpIXo8goY2DBxXhcjD/1wTKyGQx9T5oBVtL87Gc/47bbbuNf//Vfqa+vp7CwkM985jN861vfGpr4EkRJGhERERGRU+naDcEacE/uv8+RD53boWMHZCpJIzIQsZhJJBLDbrdgGEO9XkZSic/nY+XKlaxcuTLZoQyIkjQiIiIiIqcSC4EZjVfSvNuxXjRmaHhjEhmlIpEY27bV8/rrB9i+vYFIJIbDYWPBggKWLBnHpEmZWCxK2EhqUpJGRERERORUnAVg9UOkGeyZffdFO8HiiB8jIidVW9vBr3+9ga1b64lEYmRmurFaDTo7Qzz22E6ee24vZ51VxPXXz8Xv1+S0UWMYR3CPdUrSiIiIiIiciqsY/POh6UWwuMDqiW+PBaF7L/gXQNq05MYoMhSiUThyGGJRyMwHp2vQp6qr62DlytXs3t3IpEmZeDz2PvuLiny0tQV58cVKurrC3HTTWaSlOc70DkRGFSVpREREREROxTCg8F/iVTPtm44ubTraKdM7C4o+CYY1yUGKJJBpwvZ18PrfoKYynqTJyIOFF8Dii8E2sJeSpmny8MNb2L27kenTc7DZ+pdFGIZBIOBi6tRs1qw5xPjxFVx11YxE3ZEMJVXSJIySNCIiIiIip8OeAWU3Q8cW6KwAMwaeCeCbA1Z3sqMTSaytq+Gx/4JQD2QXgdUKzQ3w1APQ3gIXfzSevDxN+/e38tZbdRQX+4+boHknl8tGZqabl1/ezyWXTMLn07InSR0pkosSEREREUkAiyO+tKngGij8KKQvUYJGxp5wCF5+DCJhKJ0GaT5weaCgFDJy4c0XoP7ggE65du0h2tqCpKef3nKp/HwvNTXtbNxYO4gbkGFnGaZHCkiR2xQREREREZHTcmgf1B2A3HH996XnQGcrVG4f0Cmrq1txu22nPWbbZouP5K6v7xzQdURGOy13EhERERERkbdFQvEqGttxmvYeS7KEBzZyPhKJDXistmmaRKOxAT1HkkQ9aRJGSRoRERERERF5W1YB+NKhtREyc/vuCwXBYoPsgY2cT093EQxGAbBZQ+Rl7SE/ay8uezvWYASz1STWaSMWtdEay+NQaCqmaWi6k6QcJWlEREREREYLMwqRQ0AMrPnxceAiJ2J2Q7QWDBtYCk9/AllGDsxcCq/9FdwecHvj2yNhOLAbysph4qwBhTJ3bj4vvliJ31PFopnPkuGvwRKL4Ws+gtdowuqPEEzz0NKeTyTkpNR4Dd/EKcyYunyANy1JoUqahEnqbd53333Mnj0bv9+P3+9n6dKlPP300737e3p6+PznP09WVhZer5crr7ySurq6JEYsIiIiIpIEpgk9a6DpDmj8j/ij6d+h429gRpIdnYw0Zhi6n4S2b0L7bdD2H9DxXQitO/1zXPARmH1uvEHwns3xR/UuKJkKl98IjoFNXJozJ485M7uYOeFPZPhraG4pwHo4jKulk85wBs1mATGPjbScFtrt6TS2WVleuo2y0BMQ0/e4pI6kVtKMGzeOu+++m8mTJ2OaJg899BCXX345GzduZMaMGXz5y1/mqaee4s9//jOBQICbbrqJD33oQ7z22mvJDFtEREREZHj1vA6tvwIzBLZCwAqRemh/AMxW8F4zoHHIMoaZJnT/AXqeAIsXrIXxRF5kJ0SqgM+CY8mpz5Pmg6u+AJXbYP8uiEYgvwSmzAN32oDDcrtsXH35HppqG6msHk+Bq5P0YC09ljSilviSpu4eHx5XK17nPurss8gqTceoWQV5SyB77oCvKcPIOPoY6mukgKQmaS677LI+H995553cd999rF69mnHjxnH//ffz8MMPc8EFFwDwwAMPUF5ezurVq1my5DR+sIiIiIiIjHZmEDofB6LgmPr2dksZROug63lwLwdbUbIilJEkWg3BF8GaC5ac+DYDTMNHd8d2Duy9nz/9rYPWtngj3/R0FwsXFrJwYSGZme8aJ2+zweQ58UcC4ppYcgArUzlUH8RoPEDMGiVis2MAJibhcIyeHgcBXweLFzjIKsiHpkaofU1JGkkZI6YnTTQa5c9//jOdnZ0sXbqU9evXEw6HufDCC3uPmTZtGiUlJbzxxhsnTNIEg0GCwWDvx21tbUMeu4iIiIjIkAnvhcgBsJX232fJhfAWCG1TkkbiItvj1VVGSe+m5pZuKnY3caQhgteznbaWHTS3xvcfOtTGm28eJifHw9Klxbz//VPIzvYkPq5YNQbtlJXNwOPuwFy/me4uO12dYY5mabDZLQTSfWSlGzjSji5xcmVC8/b4kifLiHn5Ku+mnjQJk/Tv8i1btrB06VJ6enrwer08+uijTJ8+nU2bNuFwOEhPT+9zfF5eHrW1tSc831133cUdd9wxxFGLiIiIiAwTM3S078yJxiFb4j1IRAAIAZbe5W+1dR1sWF9De3uQQMBJZoad8aVe0poDvc+IRmM0NHTx+OM7qaho5DOfWUhJSeAE5x8kMwKmBcNiIS/Xg5mbRjjkohsnpgkWi4HbZcNut0KsGzDjzzOsYMbiTbOT//JVZMglPRc1depUNm3axJo1a/jc5z7H9ddfz/bt2wd9vm984xu0trb2Pg4cOJDAaEVEREREhpm1ACwBiDX23xfriU/usQ5sHLKMYZYCMCxgBjnS2MWbbx6mqztMbl4aWRndBENeOroz+zzFarWQn+9lxoxcdu1q5L771tHQ0JnguAK9cWGxYjjcOCxRAn4X6QEXfp8znqAhdvQJRxsTh9vBmQ4WjeIe0SzD9EgBSb9Nh8PBpEmTWLBgAXfddRdz5szh3nvvJT8/n1AoREtLS5/j6+rqyM/PP+H5nE5n77SoYw8RERERkVHLlgeuxRA9DLF3vHA2QxCpAPsUcM5MXnwycpgmWPLBkkM0tIUtmw/S3RUmK8uN09aD19PEgboZdPUcv0rGZrMwbVo2O3ce4U9/2pbY2GzTwVIc/z42DMgtjY/0NmN9jzM7wUiLj5g3oxDuhILz1BhbUsaIqxeLxWIEg0EWLFiA3W7nhRde4MorrwRg165dVFdXs3Tp0iRHKSIiIiIyjHxXQ6wVet4kvpwFwAr2qeD/NBiqMkh50QPQ8xiEN0GskZ7OSkryesjPysHETjRmZ3/NbLbvW3HS09hsFoqK/GzYUMOhQ20UFSXoTW/DBa6LoOvXEGuCrCKo3QttjeDPjidhzCCYXWCbCTigZSf4yiD3rMTEIEPn6MrLIb9GCkhqkuYb3/gGl1xyCSUlJbS3t/Pwww/z0ksv8eyzzxIIBPjkJz/JzTffTGZmJn6/n3/7t39j6dKlmuwkIiIiIqnF4of0L8YbBId2x/t72EvBOQ8sQ9DkVUaXaA103AvRvWAdh2nNYt+BGBazAgwHu/efTW3jRBqaxxMzrac8XVaWm82b21m37nDikjQAjgvjE8mCT4PVhAmTYc92aDkUb7nksIG1BII+aNsC3hIovzHePFgkRSQ1SVNfX891111HTU0NgUCA2bNn8+yzz3LRRRcB8JOf/ASLxcKVV15JMBjk4osv5pe//GUyQxYRERERSQ7DDs658YfIOwX/EU/Q2GaCYaW7K0zl/jSczlkUF9TS1pVNXdOk0z6dYRgEAk5ee62ayy+fipGopUaGFdzXgm0ShFaBdxdMGQcNbmjqgVA6WLLA6YIJH4HC8yBNU8tGBU13SpikJmnuv//+k+53uVz84he/4Be/+MUwRSQiIiIiIjKKmBEIvxFPbhjxKplQKEokEsPtdhKN2inM3k3loQUDOq3HY6ejI0QwGMXlSuDLRsMKjmVgXwqxw+DrhnwnRLwQbI4f48oGh3qLSmoacT1pRERERERE5HRFjo5gf7svUSxmYmJiGAbRmA27rWfAZ7VYDMLhGNFo7NQHD4ZhgPUdVTJWwJkxNNcSGUVSpGBIRERERERkLHKCrRTMpt4tNrsFi8UgGovisHfR1DrwJUPhcBSbzZLYKhoZuzSCO2FS5DZFRERERETGIMMAx/mABaK1YJqkeex402x4nIfoCfo5UD9jwKdtauph2rRsrFa9ZBQZTvoXJyIiIiIiMprZzwLXh4EQRDZjjW1jVnkDXV02Nuy8mOa2gVXSdHeHsVoNzj67ZGjilbHHGKZHClDtmoiIiIiIyGhmWMD1QbDPh8hbEGvHlZnG399op6Mrg4KCgZ3u4ME2ysrSmTUrd2jiFZETUpJGRERERERktDMMsJXFH0C6B5Ys28Yf/7iNtDQHfr/ztE5TW9uBYRhcdtkU7Hbr0MUrY4tGcCdMitymiIiIiIhIarniimm8970T2b+/hfr6TkzTPOGxsZhJdXUrbW1BrrpqOsuWFQ9jpCJyjCppRERERERExiC73crHPz4Xn8/B3/++ly1b6snMdJObm4bdHn+/vqcnQk1NBx0dIXJz07j22llccMF4DCNFGoBIYqiSJmGUpBERERERERmj7HYr11wzk2XLilmz5hCvvlrNvn3NRCIxABwOK8XFfs47r4xFiwrJyUlLcsQiqU1JGhEREREZ0Uy6iFEP2LFQgJEqb6eKJIhhGJSWplNams6ll06murqV7u4wFouBx2Nn/PgMHI6R33+m++j/7Njx4sVIlXE/o4EqaRJGSRoRERERGZFMQoR4jjCriNEEWLEyASeXYGN2ssMTGZW8XgfTp+ckO4wB6aGHHWznANWECGHFSi55TGcGGWQkOzyRhFKSRkRERERGHBOTHn5PmL8DASzkAxGibKeb/bi5ERtzkxyliAy1MGHWsoaDHCANLz78hAlTzX5aaeFsziFAerLDFFXSJEyK3KaIiIiIjCYx9hLmVQwKsTIOAw8GfixMwaSTIH/DJJrsMEVkiB3iEIc5RBbZ+PBhx44HD7nk0UILe9mb7BBFEkpJGhEREREZcSLsxKQT413vkBsYWCgiSiUxDiYnOBEZNjUcxoKBHXuf7QYGaXg5xEHChJMUnfSyDNMjBaTIbYqIiIjI6BLGwHKCxqB2IAKEhjkmERluYcJYOH5TYytWYsSIqqpOxhAlaURERERkxLGQB4B5nHfITZqwkI5B7nCHJSLDLIMMQoQxMfvt66ILPwEcOJIQmfShSpqESZHbFBEREZHRxMYcLIwnxh5MIr3bTdqJ0YiNs7EQSGKEIiNPU1M3+/Y109DQOSTnb28PUlnZzOHD7Zhm/6TJUCihhDQ8NNNEjBgQbyzeSQdgMp7xWPSyVsYQTXcSERERkRHHIA03N9DN/xBj19GtJuDCwXk4uTSZ4YmMKI2NXTz66E7Wrj1EV1cYl8vGvHn5XHHFNIqK/Gd8/s7OEH/96y5eeaWa1tYgdruF8vIcLr98KlOnZifgDk4sQDrzWcgmNtJAfe92J07KmU4JpUN6fRmA461OlQFTkkZERERERiQrE/HwdaK8RZRDGDiwMhUrUzFO0KNCJNW0tQX52c/WsuWtw8wtamZ8Ri0E2+hY187L+3O45MPLSJ9xNqQNLpkSDkf57//ewKpV+8nJ8VBU5CMYjLJ27SGqqlr48peXMHlyVoLvqq9iiskkk8McpotOHDjII58MMk7Qt0pk9FKSRkRERERGLAs+LJzzrrkuInLM2ld2Eqt4gc9N3U+u5RA2QpgeK1G3QUfrNjpeWE/6gaegeDGULYOcaWCcfmJj8+Y6Vq8+yMSJGXi98d4vbredQMDJ1q31/O1vFXzhC5kYAzjnYKSRxmQmD+k1REYCJWlERERERERGo/Y6rKt/yvuztuOypNFONmHDHd9nQKu1h/ZGC0WGBWPX36DyZZj5ISh/P1hOrxpt8+Y6IpFob4LmGMMwKCjwsWVLPU1N3WRleRJ9dzKaDEdj3xRpPZQitykiIiIiIjKGtNfBayvJCe+iPlZIkzHu7QTNUVarhUgUzLQ8yJ8Fdjds+h1seQROs/FvT08Eq/X4CR2Hw0okEiMcjp3x7YhInJI0IiIiIiIio0moC9b+Chp2EcmYRmfw+C/rurvDZGS4sViOLkXy5UNaLmx7DPa+eFqXKi1NJxyOEov1T+o0NnaRm5tGZqb7OM+UlKIR3AmTIrcpIiIiIiJyCrEotFZDSxVEepIdzYkdWg81b0H2VMaVZuJyWWlp7cEknkgxMWnvCGKxGIwv8UCwGcLtgBlvIGx1wI4nIdx9ykstWlRIUZGPiopGotGjI7BNk6ambrq6wlxwwfh4RU1LCz379hGuqxu28dwiY5F60oiIiIiISGozTTi8FvY+Ba1VYMbAkwfjL4TxF4FlBL1sisXivWUsdrA5yc6CObPz2bK1jvr6TuJzkE3S3AZLpraRH62Ew0EwLODKhvSpECiCIxVweBOULj3p5XJy0vjUp+Zz//0b2batAcMwME2TtDQ7l146mfMWZFD/4IN0vPEGsc5ODKcTz+zZZF5xBc5SjcdOGepJkzAj6KeNiIiIiIhIEhx6HTb+F0TD4C2MN9XtqofN/wM9rTDjmmRH+LbGCqjfAf7C3k1lZelkZbupremguzuC02lQ4qrAFTmAYXrA7gMzCp2HIdQCuUvAsELVK1Cy5JTTnubMyeeOO1awfn0NdXUduFw2Zs3KY3yhi/p7V9Lx5ps48vJwFBUR6+qifdUqQvv3U3DLLTiKiob28yEyxihJIyIiIiIiqSsShF2Pxatnsqa+vT1QBp11UPUclJwLvhGSbKjfCeEucPr6bPZ5nfgmO+MfdNVCbQ04M8BydBt2cDuhuwFad4NvKjTsgs4G8Oae8rIZGW4uvHBCn21tL71E54YNuKdNw+KMX8fidmPNyKB7yxZan3+enOuvP+NbllFAlTQJkyK3KSIiIiIichwte6H9APjG9d/nyYVgCxzZPuxhnVCok1O+jOuuj1fO9CZojjHA7o0naowYRELxJsSD1LF+PYbd3pug6b2KxYItJ4eOtWuJBYODPr9IKlIljYiIiIiIpK5oCGKReDPddzMMwIgfM1LEIvG2MydjhuM9aI7HYoVoEDDj1UNmdNChmF1dGI7jfN4Aw+HADIcxw2FwvjtZJGOOKmkSJkVuU0RERERE5Di8BeBMh+7G/vsiPfGmwd6CYQ/rhByek++PdoDRDsYRiFVDrA7MLjg6+YlIN9g9gAOsNrC5Bh2Kc/JkYh0dx53mFGlsxDFuHBbPKeIVkT6UpBERERERkdSVlgdFi6H98NGlREdFQ9C0GzKnQM7M5MX3bt68+P9HI+/aEYOu7dD6IsQOgy0C0UYwG8DcD7FD8QRONAS+8RBsA3cmeDIHHYpvyRJs2dkE9+3DjL09njtcVwdA4PzzMSzveskZC0FXFXTth1h40NeWEcYyTI8UoOVOIiIiIiKS2sqvjk9xqn0znsQwDMACWdNg7qePvxQqWQrnga8AOmohcKyPjgldO6BrG1jc4CgASwC6aiDaA4TBqAWzA/wLwDcB6rZD+WVgdw86FGdZGTkf/zhHfvMburdujX/eYjGs6elkfuhDeJcte/tgMwZH/gENz0JPDWCAexzkXgKZ55xywpRIqlCSRkREREREUpvTD4u+CEe2xatnYhEIlELevKNLg0YQpxfKzoXNfwB/UTy5EW2Dnj1g8YDVGz/OlgbeMgi3x6tXMMEIgy8rnpByp0PxWWccjm/pUlwTJ9K5YQPhI0ewer145szBWVaG8c7ES92TcPDheMLLmR+Pp7saqu6DWBByLjzjWCSJjrZvGvJrpAAlaURERERERKx2yJsbf4x0pUtgz3PQegDSSyB4EGI9YH/XKG2LLT6G+5hII3Tvg55smHQR+AsTEo49N5f0973vxAeEGqHuKbD5wP2OUea2SfGlT7VPQMYSsHkTEo/IaJYiq7pERERERETGiPQSmPtRiASh7RCEG8BwcOpSAxc0HoTsCTD3n4dviVH79niixnWcBsyuIgjWQseu4YlFhoZ60iSMKmlERERERERGmwkr4kmaTb+H9gZwGSd+dWea0NMFnS2QHoCz/uWMGgYPmBk+uhzmOK+yDVt8DHhsBI05F0kiJWlERERERESGSrQbWt6KPyIt8SU/gdmQPi/eN2awDAOmvi+ebFl7GzRsh64YuNPio7WPNvEl2APhILjcUJQPE6ZAxpSE3d5pcRaA4YRIR/8lTcc+J8erspHRYzgqXVRJIyIiIiKJFIuZHD7cTjgcJS/Pi8djT3ZIIjKU2nZA1YPQVQkmYHGAGYL6F8FTCqUfg/S5Z3aN4rPA/23Ydgc0mdDUCMHuePWMxQIeDxROh5wCoBKyVrzdXHi4eKeAbwa0vglpU8HqjG+PdsebB2eeB+7S4Y1JZIRSkkZERERkGGzcWMOTT+5mz54molGT7GwP559fxiWXTMbhsCY7PBFJtI69sOcXEKwH76R4guaYWBi69sHeX8KkL0Jgxpldy7cAxi2BjK0wdQ5EzXgVjdUGDmc8WRPcB5Yc8J9zZtcaDMMKJZ+A/aF4fxozcnS7HQKLoPg6jeAe7Y5OrR/ya6QAJWlEREREhtj69Yf55S/X0dERoqjIj81m4ciRLn7zm7dobOzm4x+f23dUrYiMbqYJhx6DYA34ZvZPQFjskDYF2nfAof8D/7R4ImOwLE7IuxFqfg7dO8GWAc7seA+YyBHoqQdbJuR9Alzjz+jWBs2VD5O+Dm2boHMfYIB3Mvhn901giaQ4JWlEREREhlAkEuPxx3fR0RGmvDynd3tJSYCmJgcvvVTF8uWlTJo0jE08RWRodVVB6xZwFZ+4QsQwwFMC7bugfTf4y8/sms4iKLoVWl+GtpchdBgwwZoGGZdC4DxwTzqza5wpqys+ajtjSXLjEBnBlKQRERERGULV1a1UVjZTXOzvty8jw8XBg21s396gJI3IWNJVDZH2eN+Zk7F5IRqErv1nnqQBsGdB9gch42II14EZi1fV2PXzRYaYGgcnjJI0IiIiIkMoFIoSicSO23fGMAwsFoNQKJqEyERkyMQi8aVGp7OM0TDixyeS1QPWJC1rEpEzkiK5KBEREZHkyM/3kp7uorGxq9++cDiKYUBBwTBPWhGRoWUPAAbEQic/LhYBTHCkD0NQIkPIMkyPFJAitykiIiKSHOnpLs49t5SGhi7a2oK92yORGLt3NzJhQibz5hUkMcJhYJpQfxgOVUJne7KjERl6gRngKYbuwyc/LlgHznwIzD7pYZFIjOrqVqqqWggGE1x1IyIjipY7iYiIiAyxK66YRmNjF2+8cZCqqhYMI77UacKEDG68cT4ejz3ZIQ6dql3w0uNQuQPCYfAFYN65cN4HwO1JdnQiQ8Pqhtz3QNUDEGoGR0b/Y8JtEGqCkn8Ge/+eVQCmafL66wd4+uk9VFe3Ypom+fle3vveiVxwwXisVr3nLiOEetIkjJI0IiIiIkPM47Hzr/+6iPPPH8+OHQ2EQlHGjfMzf34BPp8z2eENnf0V8Lt7oake8seB3QntzfD3P0JjLVz1ebCP4QSVpLa890KwHmqfiVfMuPLB4oovgeqpATMKeRdB4QdOeIqXXqri/vs39iZnLBaD+vpOfv3rDbS1hbjyygQ0GxaREUVJGhEREZFhYLVamDkzl5kzc5MdyvAwTXj1KWiqg0kz326g6nJDmh82r4b5y6F8fnLjFBkqFhuUfgy8k6FhFXTsjidoDHt8klPOeZC1DCzHT1R2dYV54oldWCwGEya8PZ1p/HgHNTXtPPNMBeeeW0Jubtpw3ZHICZlG/DHU10gFStKIiIiISOK1NsGerZBT2H/CjccL0QhUbFaSRsY2wwrZZ0PW0nj1TLQ7Xk3jLojvO4nduxs5dKidSZP6j8/Oy/OybVsD27c3KEkjMsYoSSMiIiIiiRcOxRMxJ1rOZLFCsGd4YxJJFsMC7qIBPSUUihKNmtjt/RtxWCwGhhE/RmQkiFnij6G+RipQkkZERETkZEwTuvZCy5vQfTD+YsszHtLPAtdxqkQkLj0LMnLi/WjS3tUUNRaDSBgKSpMTm8goUFjow+930NzcQ2amu8++zs4QdruFggJvkqITkaGiJI2IiIiMbV2d8USBzQ65hWAZwFtxkQ448BA0r4FoR3xii2lC0ytQ9yTkXAgFHz5hT4mUZnfAogvgsfvjS58CR5dsRKNwoALyimD6wuTGmKJCtBChCxtpOAgkOxw5gaIiHwsXFvL88/twuWy9U+CCwQh79zYzb14+5eU5SY5SJM60xB9DfY1UoCSNiIiIjE2hIKx6CtatgpZGsNqgdBKsuAymzTn186NB2P/f0PQyuEvBNv7tqhnThFAD1PwfxCIw7l9UUXM8iy+MT3Fa9yLUHQSLASbxBM0Vn4JMvcAcTkGaqOdl2thFjCAWnPiZSi7LcdK/74kkl2EYXHvtbDo6QmzcWEsoFMUw4k3IZ8zI4ZOfnI/NliKvWkVSiJI0IiIiMvbEYvDYQ/Dqs+BLj1fQhMOwazMcqoKP3gTlc09+jtY3ofl18EwC27sacxoGOHPjjT8bnoOMxeCdMkQ3M4rZ7XDZ9TD3bKjYAqEeyMyD6QvAn5Hs6FJKmDaqeYROqnCSjR0/UbppZC091FPGNdjxn/pEMqzS0118+ctL2bq1noqKRqJRk/Hj05k7Nx+3WxV8MnLEe9IM7ZsV6kkjIiIiMlrtr4D1r0D+uLeTAS7A64d9O+Clv8LU2Sde+mSa0PgKYO2foHkneyb0HIbm1UrSnIjFAqVT4g9JmmbeopMq0ijDcvQlgBUnNrx0UkUzm8nlnCRHKcfjcFiZP7+A+fMLkh2KiAyDFMlFiYiISErZux26O/tXaxgG5I2D/Xug5sCJnx9pjzcLdmaf/DqGAfYAtG8985hFhlAr27Dh6U3QHGPBhg0PrSTue9g0TUKhKLGYmbBzisjIFrNYhuWRClRJIyIiImNPOHTiHjF2B0TD8elCJ2JG4tU0hvU0LmaF2EnOJTICRAlicPzlMQZ2YoTO6PymaVJZ2cKaNQdZu/YwPT1hrFYLU6dmsWxZMbNm5eFwnM6/JxGR1KYkjYiIiIw9OQXxJE0kArZ3/bnT0gj+TMjOO/Hzbd74I9IG9lP0Tol2QNqkU4YUpIk2dtLFQUwiOMjEzzTSKMFQcbMMMQ/FNLMR6N+sOUIHfga/HK2rK8xvf7uZ116rpr09REaGC4fDSigU5ZVXqnnttQNMnZrFJz85n5ISTZMSGYtMi4E5xD1phvr8I4WSNCIiIjL2lM+HwvGwfxeUTn07UdPRFk/SXHoNpPlO/HyLAzLPhUO/A1fxiatyYuH4I3PZCU9lEqWB12ngdcK0YcGOgYUoQRpZi4/JFPFPatoqQyqD2bSxnR7qcZKDgYGJSZAGLDhJZ/agzhsMRvjVr9bz8sv7GTfOT1lZOsY7/r0UFvro6YmwbVsDK1eu5uablzJuXP/v9eZ2aO6ANBfkpmtYmoikrqS+bXPXXXexaNEifD4fubm5XHHFFezatavPMStWrMAwjD6Pz372s0mKWEREREaFNC9cdSMUlEHlzvhkoV2bobEOll0I51926nNkLgVXIXRWxJc+vZsZhY5d4J0MgfknPE09r1HL8xgYeBlPGiV4GIePiTjIpIUtHOAxInQN/n5FTsHLRPK5CDDoYF/vAwwKuAgvEwZ13uee28crr+xn4sQMMjPdfRI0x7hcNqZPz2H//hYeemgT0Wisd19zOzzwLHz9fvjWg/DN/4GfPgrV9YO7TxGR0S6plTSrVq3i85//PIsWLSISifDNb36T9773vWzfvp20tLcnKXz605/mO9/5Tu/HHo8nGeGKiIjIaFI2Gf71Nti+AeoOxXvRTJwOE6aB9TR6Y7gKoeRTsP9X0L4FHLlgTwdiEGqEUBN4J0HpZ044AaqHIxzhdWx4cZLVb78ND2mU0MZuWtlKFmed2T2LnICBQTaL8TKBdnYTpgM7XnxMwXWcJVCno6cnwksvVeHzOUlLc5z0WIvFYPz4DLZvb2D37kbKy3Po7IafPw4bKiA/E4qyoSsIqzbD/nq49cNQeIre3SIyMsSwEBviGpChPv9IkdQkzTPPPNPn4wcffJDc3FzWr1/P8uXLe7d7PB7y8/OHOzwREREZ7bx+OGvF4J8fmAuTvgpHXoLmN6D7wNGJTpkw7n2QdR44c0/49Fa2E6YNLxNPeIwFB1acNLGRDOb3m74jkkgucgadlHm3LVvqqK5uZeLEU/RtOsrrddDTE2X16oOUl+ewdhds2gvTSsB5tKex2wkZXthSBS9sgo9dmJBQRURGjRH1V0BraysAmZmZfbb/7ne/47e//S35+flcdtll3HbbbSespgkGgwSDwd6P29rahi5gERERGfs8ZVByAxRcgRk6gmmYGI48DNupe8h0UoUVFwYnb7DhIJ0gRwjRgguVDsjoUF/fSSxm4nSe/ksKr9dOVVULAOt2g8P2doLmGIsFsv2wdgdcswLsI+oVi4gcTwyD2Cl+1yXiGqlgxPzIi8VifOlLX+Lss89m5syZvds/+tGPUlpaSmFhIZs3b+ZrX/sau3bt4i9/+ctxz3PXXXdxxx13DFfYIiIiMsaZmPRwhGb7LprtO4jQg4GBm1yymEmAiVhxnuC50dOa3GRgwcQEogmOXmToRKPH6dV0ChaLQSQS70nTHYwnaY7HYYNwFMIRJWlEJLWMmB95n//859m6dSuvvvpqn+033nhj73/PmjWLgoIC3vOe97B3714mTuxfOvyNb3yDm2++uffjtrY2iouLhy5wERERGbNiRKjhFerZQJgObKRhxUGMGK1U0MJuPORRwsX4KOn3fAcZR5uznlyEbqw4sXL83jYiI5HHY8c0TUzTPG7D4OPp6YmQkeEGYHIhvLU33pf73U9vbIfZ4+PLn0Rk5DOxYA5xz5ihPv9IMSLu8qabbuLJJ5/kH//4B+PGjTvpsYsXLwZgz549x93vdDrx+/19HiIiIiIDZRLlIC9yiFex4MDHeDzk4SQDF5l4KSGNIrqpZx+P0U51v3MEKO8dt31MNBqjpaWHlpYeotEYJiYhmgkwHTve4bzFIdUVhspmONAKsYEXXMgQ6OwMUVnZzKFDbcQS8EWZMSOH9HQXjY3dp3V8JBIjHI6xYEEBAEumQ5YfKmshdnTgk2lCbRMYwIo5GsUtIqknqZU0pmnyb//2bzz66KO89NJLjB8//pTP2bRpEwAFBQVDHJ2IiIiksiZ2Us+buMk+YfLEgo00iunkANU8yzSu67P0yctE0iijnT14zBIOVneyZ08TbW1BMMHrszNxRoyCgnQyjDnDdWtDKhyFp/fAP6qgoROsFpiUCf80Gebrz7ek6OmJ8NRTFaxaVUVTUzc2m4UpU7L4wAemMnPmiRtfn0pBgY+FCwt57rl9ZGUdf/z2Ox0+3E5+vpcFCwoBmFAAN7wX/vd52FoVT8jETEhPgyvPhSXlgw5NRIaZpjslTlKTNJ///Od5+OGHefzxx/H5fNTW1gIQCARwu93s3buXhx9+mEsvvZSsrCw2b97Ml7/8ZZYvX87s2bOTGbqIiIiMYSYxGtkMcMrqFgMDD4V0cohW9pLJ9N59FmyM4zIO8CiVDVupqOymp9uDL+DE5gxiOo6wa6eDnr3nMPPck1cTjwamCb/dAk/tBr8TCn0QicHW+nhVzb8ugoWFyY4ytUSjMR54YCPPPbePzEw3RUU+QqEoGzfWUFXVwr/921nMmpU36PO/732T2Lq1gV27GpkyJQuL5fiJmvr6Trq6wlx99Qz8/rcTmWfPhMlFsL4CGtvA54kvcyrLVxWNiKSmpCZp7rvvPgBWrFjRZ/sDDzzADTfcgMPh4Pnnn2flypV0dnZSXFzMlVdeyX/8x38kIVoRERFJFZ3U0E41TrJO63gLNgwMGtnaJ0kD4CSb7I4P8cfHO/EVV5JXHMYwwkTDdjoOTWfv2hy2NJgsm95FVtbxp1eOFvua4aUqKPDCO2/F74SdR+CJXTA3H2yp8WboiLBz5xFefbWa0tIAgYALALfbjt/vZPv2Bp58cjczZuSeMLlyKuPHZ/CZzyzgV79az9at9eTkeMjNTcNqtWCaJi0tPdTUdOBwWPnIR2Zw0UX9e0rmZsAlZ53RbYpIkpkYmEM8fWmozz9SJH2508kUFxezatWqYYpGREREJC5ICzFC2Dn9pIkNL93UH53oZO2zb++uIG89V8TU8ml0ZnRjGCaRHheRbg8+i8mBxgZ27DjCOef0bz48mmxvgPYQlAX67xvnjydx9rfAxMxhDy1lbd/eQHd3uDdBc4xhGBQV+dm1q5GamnaKigbfx3H27Dy++tWzeemlKt544wA7dhzBMOKVVV6vg8WLizjvvDIWLCg47QbDIiKpasRMdxIREREZOWIDfoaBgUkMk1i/JE0oFCUajWHBSbCl74vleAWDSSg0+sdvh2LxqRTHex3usELEhDFwm6NKMBjFMI5fuuRwWIlEYgn53ispCXDddXO47LIpVFQ00d0dxm63Uljoo7Q0oOSMyBhnDkNPmlSZ7qQkjYiIiMi7WHEBBjEiWE7zz6UoIZwEMI5zfEGBD5/PSUtLT+/44WM6OkI4nTYKCkb/ZKdCbzxBE4rGkzLv1NgNGS7IT+BtBoMR3nqrjk2bamlu7sHrtTNrVh7z5xfg9ToSd6FRrKjIB8QnK9netc6ssbGLzEw3ubmJG/2ekeHmrLOKEnY+EZFUoySNiIiIyLt4GYeLTII04ybnlMebmEToJJ8lGMdZM19aGmDevHxeeqkKl8uG220H4kmGffuaWLiwiKlTsxN+H8Ntbj5MyoDdjTA1C+xHEzXtQajvhCvL4V05qkHbs6eJ//mfjezd20QsZuJ02giHo/zjH1WMG+fnX/5lNgvVpZj58wsoKwuwe3e8se+xRE1raw9tbZ1cebkXh6OK5pgXqyUNHz6MFHm3WkQSJ4ZBbIh7xgz1+UeKASdpgsEga9asYf/+/XR1dZGTk8O8efNOa3y2iIiIyGhgw00mMzjES7jI7Ld86d1CtGLHSwZTj7vfMAw+9rE5dHaGeeutWsLh+HIqm83C7Nn5fOIT8wbduHUkcdvh0wvgv96EXY1gEu9L4rLB+WXwwWmJuc7+/S387GdrOXy4nUmTMnA63/6TNhKJUVnZzP/7f29y001nMXdufmIuOkoFAi4+/ekF/Pd/r2fHjgYg3hdy1pR9fPXGvUyaVU99czudVi8N7tlE3CuYaswkHyW4RESS4bSTNK+99hr33nsvf/3rXwmHw71jspuamggGg0yYMIEbb7yRz372s/h8vqGMWURERGTIZTOHFiro4ABeik+YqAnTQZAWCjkbFyeuhsnMdHPrrcvYvLmOiopGTBMmTMhg7tx8XK6xU9w8IQNuOw821MCBVnDYYFoWlOckbqrTX/+6mwMHWpk5s/9UIpvNwqRJmeza1cgjj2xn5szcfst8Us20adl861vnsWFDDYcPt1OUtYm5EzcS9bVSb7diwU9atAtf+yoqY+2s8bZxFkspQMuWROT0mFiGvGeMetK8wwc+8AE2bNjARz/6Uf7+97+zcOFC3O63a1X37dvHK6+8wu9//3vuuecefvOb33DRRRcNWdAiIiIiQ81JOmW8nyqepJ392PHhJBPL0WRNhC56aAJM8jmLQpYfd6nTOzkcVhYuLBzzy3C8DlheOjTnPnSojY0baygq8p2w+sgwDEpKAuzZ08S2bfXMmZPa1TQQr6g5//zxEOuB5ofoiUKl3Y8NG3bshC0B7NFmyroraHRNY5dtB3kUYEmRF0UiIiPFaSVp/umf/on/+7//w263H3f/hAkTmDBhAtdffz3bt2+npqYmoUGKiIiIJEMa+UziwxzhLRrZSicHiS/iAQsO/JSSxWwyKT/lkihJjOrqVlpbg4wbd/KR0R6PnXA4xv79rUrSvFOkAiIH6bQHiNKIi7ffeA1b0vGEq8gPt3DQ1kgbLaSjeekicmrxnjRDm9QdTE+aQ4cO8bWvfY2nn36arq4uJk2axAMPPMDChQuHIMLEOK0kzWc+85nTPuH06dOZPn36oAMSERERGUmcpFPEeeRxFh0cIEoQsOAknTQK1GR1mEUi8X4+pzPS2TAgGh34OPUxzQwBEaLYMKDvSx7DACzYzCgxYkTRvHQRGb2am5s5++yzOf/883n66afJycmhoqKCjIyMZId2UmNnAbSIiIjIELLhJp0pyQ4j5QUCLux2Cz09kZP28onFTEzTJBBwDUtcJiYd1HOEvXTRCBh4ySGbiXjIOuVSuGFjLQRLOu5YN1gNYphYjsZmicUTkG1WDy7ceFGfSRE5PSYG5hD/nDt2/ra2tj7bnU4nTqez3/Hf//73KS4u5oEHHujdNhoGHp3WWz8ZGRlkZmae1kNERERGNhOTbhrppJYwXckOR2RApk3LprQ0nUOH2k56XF1dC9lZUWbPimGa5pDGFKaH3TzHJh6hktdppJJG9rGPV9jIn9nLy0QJD2kMp81WAM6z8ERb8MQMuunCxMQww7gjh2l1FFDnyKKEMpwMT4JLRGQgiouLCQQCvY+77rrruMc98cQTLFy4kI985CPk5uYyb948/vu//3uYox2406qkWblyZe9/NzY28r3vfY+LL76YpUuXAvDGG2/w7LPPcttttw1JkCIiIpIYbRzkEKtp5yAmUex4yaGcAhZjo/+7UCIjjcNh5aKLJvBf/7WexsYusrI8ffabZpiO1p001NTwoQ80keX+O/RMxbR/AMM2I+HxxIiwh39QwzbSyMJLTm/VjIlJkHYO8CYmMSZx3shYHue9BmusjcLQ67RE6ggRxsSg3p5Ple8cyoypTCPxnysRkUQ4cOAAfv/bfcmOV0UD8QFH9913HzfffDPf/OY3WbduHV/4whdwOBxcf/31wxXugBnmAN9auPLKKzn//PO56aab+mz/+c9/zvPPP89jjz2WyPjOWFtbG4FAgNbW1j5fSBERkVTTzkF28wRB2nCTiRU7IToI0U4Os5jIpb2Ti0RGsmg0xh/+sJWnnqogGo1RUODD5bIRDIapq9lEuKeOc8+BT3/cicsVhNhBMDLB/SUMa2J7Jzawh208SRpZ2E9QeRKkgyAdzOaDpDMuodcfNDMMoa2Ew9toMY/Qak8n5Cgn21JKNrma6iRyhlLldeix+9zR+iN8fvepn3AG2tu6KQ/cetqfU4fDwcKFC3n99dd7t33hC19g3bp1vPHGG0MZ6hkZcE+aZ599lu9///v9tr/vfe/j61//ekKCEhERkcQyMTnMOoK04qek951+N07seDjCDrKZTgYTkxypyKlZrRb++Z9nMXFiJqtWVbFz5xHq66PYrK1MHn+Y5ee4OOfsNBwOADdYAhDbDqG/YrrKT6vp8OkwMalnB2CeMEED4MRLF03Us3vkJGkMOzjnYXfOIwfISXY8IiIJVlBQ0G+oUXl5Of/3f/+XpIhOz4CTNFlZWTz++OPccsstfbY//vjjZGVlJSwwERERSZwQbbRxABeZ/RqY2nBjEqWN/UrSyKhhsRgsWTKOxYuLqKnpoKsrjIOnKMw+hNUxu+/BhgFGIUR3glkLRkFCYogSoo1aXKfRYNdBGi1Ux/u/jJQmwiIiCRLDMgwjuAd2/rPPPptdu3b12bZ7925KS0sTGVbCDThJc8cdd/CpT32Kl156icWLFwOwZs0annnmmVHRhEdERCQVxYhgEsVygl/9BhYiI6WxqcgAGIZBYWE8SWL2xCB8gj/iDQeYkfgynwQxiWESw4L91HFiIUYUMEFJGhGRIfflL3+ZZcuW8Z//+Z9cddVVrF27ll/96lf86le/SnZoJzXgVNcNN9zAa6+9ht/v5y9/+Qt/+ctf8Pv9vPrqq9xwww1DEKKIiIicKQd+HPgJ0d5vn0mMGDHSyE5CZCIJZC0CTDCj/feZjWBkgSVx3+dWHDjwEKbnlMdG6MFNYGQ0DhYRSbBjI7iH+jEQixYt4tFHH+X3v/89M2fO5Lvf/S4rV67k2muvHaLPQmIMuJIGYPHixfzud79LdCwiIiIyRKzYyWMOlTxHiA4ceAEwidJODR6yyGBykqMUOUPWhWApAXM3MAWMo42wYy1gtoL9cgzDc7IzDIgFK7lMYw+rTrqMKUaUKGFymZawaxPugo46sNjAXwSGkj8iIu/2/ve/n/e///3JDmNABpyk2bBhA3a7nVmzZgHxXjQPPPAA06dP5/bbb8cR79AmIiIiI0wec+mhmXq20MWRdzQPzmYCF+Fk7E6fkNRgWNIxXTdCz6/ijYKB+PIiD9jfC473JfyaOUymhq20cRg/hf0SNSYxWjmMjzyymHDmF4yGYM8zUPUidB0BixUyJsHkf4KC+Wd+fhGRQRiJPWlGqwEnaT7zmc/w9a9/nVmzZrFv3z6uvvpqPvShD/HnP/+Zrq4uVq5cOQRhioiIyJmyYKOM95BNOa1UEyWEiwwymNhbWSMy2hnWckz37RBdD7EaMJxgnQGWqRhDUG3iJp0pvIddPEcL1Tjx4yANgCDtBGnHRx5TuBAHZ1jFY5qw5bdQ8Tdw+sFXCLEINGyFlkpY+K9QuDABdyUiIsky4CTN7t27mTt3LgB//vOfOe+883j44Yd57bXXuOaaa5SkERERGcEMLPgYh2+kjAEWGQKGJQCWC4btehmUMJPLqWM79eyii0bAwEkahZxNPtNxk37mF2reB1WrwFcA7ndMVXX64chO2P0E5M+NL4ESERlGg+kZM5hrpIIB/wQ3TZNYLAbA888/37u+q7i4mCNHjiQ2OhEREREZ+2LR+MNqj4/LHoW8ZONlOcUsJHi0QbeLAHZcibvIke0Qaof0sv77/OOguRJa9kPmxMRdU0REhtWAkzQLFy7ke9/7HhdeeCGrVq3ivvvuA6CyspK8vLyEBygiIiIiY1CoCw5vhKpXoaU6vpTH6YXSZVB8FvgLkx3hoDjwnPmyphOJhuINgo+XyLI6IBaOP0REhpl60iTOgJM0x0ZWPfbYY/z7v/87kyZNAuCRRx5h2bJlCQ9QRERERMaY2q2w/qF45YfFBq4AYIGO+vj2HX+FKe+DGR8E65kt3TEx6aCFOqpo4wgmJl4yyKOMANknnMg0InkLACOerLG+a1hHVyO4MsCbn5TQREQkMQb8W2/27Nls2bKl3/Yf/vCHWK3WhAQlIiJyJiIE6aINC1bSSMdIkXdeZOQzidFOGzGipOHDztifitlNFz1048BBGr54gub1n9HS2E6TbSppaXZyve8oDjHN+GjpzX+ESA/MvRYsg/s3HCVCBes5wE6CdGHFjoFBDXupYgsFTGAaS3AkcknSUMqfG1/K1LgbsqYStUKUIEawC1tXPUb5h8GVnuwoRSQFmcNQSWOmyN9zCesq5nKNkl9uIiIyZkUJc4DNHGY7PXRgwYqfPEqZRxYlyQ5PUlwtB9nDNlo4QgwTD2mUMImJlGNN3J9kI0YXnexiK4epJkwIK3byw1kUrPkbzz0Lqw9OobMHnHaYPR4+eA6U5BLP1vjy45Uiu56G7ClQsnjA1zeJsYu1VLIZN36yKOqtmjExCdJNNTuIEmE2K0bH18Dugfk3El3/c3oaX6PHbAWixGwOImXzSJu2BH+yYxQRkTMy4N9GFosF4yQN3aLR6BkFJCIiMhgmMSp4jQNsxoGbNDKIEaGJA7TTwEwuIovSZIcpKaqWA6znVcKE8BLAgoVuOtnKOrrpYjZnja5lN6cQpId1vEodh/Hiw0uAMCH2dG/kWSK8dTCXLAOKsqArCKu2wP56uOXDUJR99CSezHhFTeWqeI+aATYUbqGeanaSRjquoyOxjzEwcOHBipUa9pHPeAoYHc12oxnFVCxfQk9NF2ltQaxWL91ZOTTmOHFZXqQcDz4Kkh2miKQYTXdKnAEnaR599NE+H4fDYTZu3MhDDz3EHXfckbDAREREBqKVOmrYSRoZOHtfkDmw46aFGqrYQAbFWFKkVFZGjhhRKthKmBBZvD1kwY6DbhxUs4cSJpBBThKjTKyD7KeeGrLJ7a1QsZt26rY10eK3MfXCGLYN8WXybidkeGFLFby4CT524TtO5C+E2m3QXAWZ4wcUQw37iBDEdZLPqx0nAIeoIJ8JoyJR1sJ+GhwHSCtdRvfR+AECmLRQzSE2MpX8UXEvIiKj2Xe+8x1uvfVWPJ6+zeK7u7v54Q9/yLe+9a1BnXfAf6lefvnlfR4f/vCHufPOO/nBD37AE088MaggREREzlQLhwkTfEeCJs7AII0M2qink6YkRSeprI1mWmjER3q/fS48hAlyhLrhD2wI1XAAG7a+S4jMKDW1QWKdBvbyGBhm7y6LBbL9sGYnhN45nMjph3BnvKHwAB3hEM7TmLLkxkcLDYQJDvgaydBMFSYxbO9I0ED8Z52bDFrYT4jOJEUnIqnq2HSnoX6MJHfccQcdHR39tnd1dZ1RAUvC7nLJkiW88MILiTqdiIjIgESJnPCdYys2YkSJERnmqEQgSpQYMaz0H7AQ/541iI6x7814D5p3FWybMSJRA0sUDJvZ769Qhx3CEYi8c+X8sSVO5sCX08eInlbT8PjXwMQkNuBrJEOUMJbjfC8BWLBh6mediMiwME3zuK1g3nrrLTIzMwd93oR0SOvu7uanP/0pRUVFiTidiIjIgJgmdB1J53AIdh2MYkat+HxQVAQZGdBjdOAkDTeBZIcqKSgNP07cdNOF911tXaNEMTD6bR/tMsjhCA19N1psZPig2QJmvQXelXdpaoeZpfHlT72iITAs8Ya5A5RGgCMcPOVxYYI4cGMbJZO2vORSyxZMzH6J6RDtuMnA8a6KQhERSZyMjAwMw8AwDKZMmdInURONRuno6OCzn/3soM8/4CTNsYCOMU2T9vZ2PB4Pv/3tbwcdiIjIaHdszKwdB2l4U74fQEdHiIaGThwOK4WFvpM2nT8T4TA88gi8+EopWRfl4C2opacunwMHrOzZAwVl7RTPbGSKfQkO3EMSg8jJuHBTzAR28hZ2HDiPjnuOEaWZetLJJo9xSY5ycILBCDU1HVgsBoWFPmy2eOVKMWUcYB8tNBEgAwMD04Ds8kwO7mrg4GtWCmIGFks8yVrfAphw/tx39QdurwV/UXzC0wAVMJF69hMjesLKk/iUpy7GM2t0THcCspjEYTbQTg0+8jGwYGISooMIIfKYhRV7ssMUkRQTwyA2xH/7DvX5T9fKlSsxTZNPfOIT3HHHHQQCb78J6HA4KCsrY+nSpYM+/4B/G61cubLPxxaLhZycHBYvXkxGRsagAxERGa266WIn2znYO2bWRh4FlDODwHF6UIx13d1hnnyyglWrqmhp6cFmszB1ahaXXz6N6dMT2xjVNOEPf4DHH4fcXBdp1efjyf0H/ik1RI0IXZEgB5rt1Lycge2c/cScrzORuafVp0IkkaYwi246OUQVbb29kQzSyWIuS7GPkiqOY6LRGC+8UMlzz+2ltrYDwzAoKQlwySWTWLasmCwjh9ksZCsbaaC293lpeXksfeMgz+x1sLXdjWHE/x0H0uDKc2FJ+TsuEotAdzOUXwaOgf+bzaWEdHJpppYMCvo1DTcxaaGONNJHzWQnABcBJnIh+3iR1qOVQiYmNlwUsZB8ZiU5QhGRse36668HYPz48Sxbtgy7PbGJ8QEnaY4FJCIiECLEWt6ghkN48eLDT5gw+9lHK80sYzm+MbaM4WQikRj337+RF1+sJDPTTVGRj2AwyoYNNVRVtfDFLy5JaKKmogKeew4KCiArC6LN+bSvugKjcBdtvt2EohE6q4uoXFdGrnsn0WVv0UEL83hP71QXkeFgx8F8zqGUyTRSR5QIPtLJY1xvZc1o8thju/jTn7bictnIz/cSi5lUVjZz331vEgpFOf/88ZQykSxyqOUQ3XThxEWuo4DAJBtLL3uK9Q2lNPWkkeaCOROgLP8dVTSxCNTvgNxpMH75oGJ04GImy9nMSzRyCBde3KQBBkG66KYdD35mcg5po2wpZCbjSeMqmthHD61YcZBOSW9ljYjIcIuP4B7anz8jbQT3eeedRywWY/fu3dTX1xOL9e1ttnz54H5/nVaSprq6mpKSktM+6aFDh9SfRkRSwiEOUMthssnBdmzMLHbcuKijjn3sYQ7zkxzl8Nm+vYHXXqumrCwdvz+eBHG77QQCTrZta+DJJ3dTXp6dsKVPq1dDZyeMf8dkXjPkpr4qiybG48GHgYEFGztfLmbe0k4ajAPUUcU4piYkBkku04R9tbB2N+w4CJ09YLfFJwUtmgzzJ4BvhBROWbCQQwE5FCQ7lDNSX9/JM89UkJ7uoqDA17vd53Oyb18zTzyxm8WLx+Hx2PHiZ9I7E9UGMOcacqNhLql4Lj7WyVcIzqPniYbjS5y6m+IJmsWfhbSsQccaIJv5vJdD7OYwFXTQAsRHb09gDuOYgp/sQZ8/mZz4KGBOssMQEUlZq1ev5qMf/Sj79+/HNM0++wzDIBodeNN7OM0kzaJFi7jiiiv41Kc+xaJFi457TGtrK3/605+49957ufHGG/nCF74wqIBEREaTwxzEirU3QXOMgQUPHg5xgJnMOe5Ul7Fo69Z6gsFob4LmGMMwKCrysXPnEerqOsnP9yboehAI9O1hESNGBy3YcfT2BUrPCXJoXxqhdjeG30Id+8dUkiYWg0gE7PZ39fMY47buh7+9CVuroaMHfC6wWyFmQlUdvLETCjPh3Blw6QLwqiVRQmzf3kBTUw8zZvSvihs3zs+ePU3s3t3I3Ln5xz+BzQkLboj3malcBQ27oGU/YMS/gX0FMO0SmLAC0s48gZKGnykspIyZdNOBiYmbNC17FBFJoOEYkT3SRnB/9rOfZeHChTz11FMUFBQk7E3I00rSbN++nTvvvJOLLroIl8vFggULKCwsxOVy0dzczPbt29m2bRvz58/nBz/4AZdeemlCghMRGekihE+YgLFiPTr2OZoySZpgMILVevxfUA6HlUgkRjg8uHcVjiccBuu7PrUmsaNTT97+RW61QigGkYiBFSsRwgmLIVlCIdiyE159Eyr2QTQGLiecNRcWz4XxJWM7YbNqK/zmRWjrgnFZMD63//1GolDbAr9/GfbWwI0XQ1bqrD4cMqFQFMMAi6X/N5jdbiEaNU/979xqgwnLoewcaKyAziMQi4IjLV5B40j8dCIHLhyjcGmZiIiMTBUVFTzyyCNMmjQpoec9rSRNVlYW99xzD3feeSdPPfUUr776Kvv376e7u5vs7GyuvfZaLr74YmbOnJnQ4ERERroMsqil5rijULvpJo98bCk0ZWPcOD/RqEk0GsNq7ftuR2NjN1lZbrKzE/fudX4+bNzYd5sFKw5cdNPe24y1q8OKxxfF7Q3TRpAAiW1gPNz2H4Rf/wF274tX0WQE4omo1nb481PwzEtw9kL42JXgHoOvSVfvhAeej6+cmV584mSUzRpP4GT74suhDOCm96ui5kwVFHix2y10dYXxePr+fGtq6iYQcPZZBnVSFgvkTI0/RERk1Ir3pBnad4dGWk+axYsXs2fPnuQkaY5xu918+MMf5sMf/nBCgxARGa2KKaGKfbTQTPqxMbOYdNIBQBkTU2oU98KFhZSUBNi9u5EpU7J6EzUtLT20tgb5wIcmEXK3EcbAS+CEY3FP17JlsG5dvKLmWGN9A4MA2XTTQZggVtNJW5ODpZcepNvRgAsvhUw401tNmupDcO/9UF0Dk0ogFukiGAzidDnJy/ZQXADNrfFETU8QPnMtOMdQj+TmDvjtSxCNwsTTbO3icsDUIlhbAU+vh4+cM6Qhjnnl5TmUl+ewaVMtU6dm4XTG/5zs6gpz8GAbF144gaKit5M03XTRQzcOnKSRmKWOIiIiyfZv//Zv3HLLLdTW1jJr1qx+U55mz549qPMOeLqTiIi8LYMs5rKAzWygnjoM4qNQnbiZzkzGUZzsEIdVRoabG29cwH//93q2bz8CmJimiSfNzns+6cL33l28wnoMDPxkMIHpFDJ+0Ims+fNhyhTYtQvKy99e+uQlg0x6aDbrqaow8BUdoXDpdmw4KWfJqG0UGo3CQ4/A/sNQktfOlvU7qTlYQyQSwW63U1hcyLRZ08hMT8PpgJfXwKQyuPSCZEeeOG9WQE1zvIJmIFwOyPDCK9vh0oWQNgYrjIaLzWbhU5+az333rWPXrkai0RimGV/SuGTJOK69djaGYdBFJzvZxiEOECaMDRv5FDKNGfhH2TQlERE5uVTsSXPllVcC8IlPfKJ3m2EYmKY59I2DRUTkxEooI4tsDnOIbjpx4CSPgt7KmlQzfXoO3/72CjZsqKGmph2n00bmkiO0F+4haIlPezGJ0cIRNvIqUaKUMHlQ10pLgxtvhF/+Mt5EODMzPorbYjEItRTQWp/FuLw2rvxkLfMLF5BLCZ5RPBJ95x7YsQfyMrpY9+oajjQcwR/w405zEwqGqNhZQWtLK8tWLCPN4yLNA/94Hd5z9tiopolE471o3A6wDuLvtPx02H0YNuyNNxOWwSss9PHNb57Lpk21VFa2YLUaTJ6cxcyZuTgcVoL0sJbXqaUGLz58+AkTYh97aKGFZZyLl9NcEiUiIjICVVZWDsl5laQREUmANLxMHkPTgs5UerqLCy6Iz8XupI1XeAsXHrzvSJBk4qKFI+xhCwWU9vaPGaiyMrj1Vli1Cl55BQ4fjo9l9noNrvyAkxUrcigrG909aI5ZsyneMLixfT9HGo6Qm5+LxRLPVtjtdlxuFw21DRyoOsDk8skU5sK+A/EGwwvHwKTefbVQWQdFg5zIbLfFkztrK5SkSQS3287SpcUsXdq/rOkg1dRRSw45WI/+uWnHjgs39dSxn33M0PhoEZExIxUraUpLS4fkvErSiIjIkGqklh66yKb/OF4f6bTQSDMN5FI06Gvk5sJHPgKXXgr19fFmullZkJ5+BoGPQJUHIM1jUrHrAC6XqzdBc4zVasXusHNw/0Eml0/G6YwvkWpoSlLACdbeDcFwvJJmsNwOaGpPXExyfIc4iA1bb4LmGAsW3Lg5wH6mM6vPFDYREZHR5De/+c1J91933XWDOu+AkzQvv/wyy5Ytw2br+9RIJMLrr7/O8uXLBxWIiIiMTVGiGHDcpV+Wd4wpT4S0NBg/PiGnGpEiEbAYEAlHsNpOMPrdZiUcenvEuGHEnzcWRGNnfg6LBcJj5PMxkoUJYz1BY3ArVqJEiWGeYetwEREZKVJxutMXv/jFPh+Hw2G6urpwOBx4PJ7hS9Kcf/751NTUkJub22d7a2sr559//qCb44iIyNiUhh8LVsKE+i1p6qELJ27SRnGfmOGU7oeqgwaZOZlUV1Xj8/fv6RHsDjKuZBwQX/YVMyEtcVPPk8pljydZorH4eO3BCEfAOwabBnd2hti4sZbNm+tobw+Rnu5kzpx85szJw+22n/oECZZJFkeoP+6+bropYhwWVdGIiMgo1tzc3G9bRUUFn/vc5/jKV74y6PMOOElzrFPxuzU2NpKWljboQEREZGzKJp8s8qnnIJnk9i5/CBOinRbKmIb3DCe9dNFJkB6cuPAwdn8XLZoDa9+C4rJSDh84TFtrGz6/r3eSQFtLGw6ng5LxJQAcaYaMAEwfXF/mEWdcdnxC05E2yM8Y+PNNEzp6YNoYG7r21lu1/OY3b1Fd3YphGDgcVkKhKC+8UMn48enccMNcysuHty9TCaVUU0ULzQRIx8DAxKSDdixYKWVCSjZWFxEZq1KxJ83xTJ48mbvvvpt/+Zd/YefOnYM6x2knaT70oQ8B8ZFSN9xwA853jImIRqNs3ryZZcuWDSoIEREZuyxYmc1SNvEqjdRhEuvdXsh4ylkw6BdrHbSzk20c5hBhwtixU0gR5cwkDW8ib2NEWDAL8rKhuyefmfNmsnPLTupr6nuTNJ40DzPmziA7LxvThJp6uHg55Oee+tyjQaYPlk6Dv64dXJKmpRP8Hlg8JfGxJcu2bfX88pfraGsLMmVKFnb72yVGoVCUPXua+MUv1vGlLy1h0qTMYYsrixzmMJ8tbKKeOgwghokbNzOYRSHjhi0WERGR4WSz2Th8+PDgn3+6BwYC8Xc5TdPE5/Phdrt79zkcDpYsWcKnP/3pQQciIiJjl5cAi7mIeg7SShMWLGSQQzaFJ+xbcSrddLGG1zhCPV78uHETIshedtNKK8s4FzdjZJ3PUQE/XHYhPPSIQWbeFM4ryKP2cC3BniAut4uCogJ8AR+xGOyuhMI8uPi8ZEedWEunwvOb4gmX9AEUTZkmHGqEZeVQMjaGfRGNxvi//9tBc3MP5eXZ/SqdHQ4r5eXZbN3awOOP7+Tmm5cetxp6qJQxgWxyqOEQ3XThxEUeBb2VNSIiMnakYk+aJ554os/HpmlSU1PDz3/+c84+++xBn/e0kzQPPPAAAGVlZdx6661a2iQiIgNix0EREyhiQkLOt59KGqgnl1wsRxM9duy4cdNAHdVUMZXpCbnWSHLxedDRBY89C+FwgMJxAQL+eIPgaDRePVPfCMUFcOO1UDbGlvZMLoSzy+G5TeC0n96kJ9OEqnrI8MElC+Kfq7Fg165Gdu06QklJ4ITJF8MwKC72s2VLPVVVLYwfP4gSpDPgxcdkpg3rNUVERIbDFVdc0edjwzDIycnhggsu4Mc//vGgzzvgnjTf/va3B30xERGRRDlANU6cvQmaYyxYceDkINVjMkljscCHL4Xx4+Cl1bB1FxysjSceTCAnEz70PlixFIoLkx1t4lkscN0F8d4yb+yMV8WcrKImEoXKenDa4IYLoHwMJa2qq1sJBqN4vSfPVAUCTg4caGX//tZhT9KIiEhqMIehJ405wnrSxGIJGDt5HANO0tTV1XHrrbfywgsvUF9fj2maffZrupOIiAyHCOHeJsTvZsVKmPBx940FhgEL58CC2bD/IByui08tcrtgchlkpCc7wqGV5oJ/vTQ+pen1nVB9BHL8kO2LT30yTegKQU0T9IShKAuuOx8WjpEGysdEIrHTqgoyDAPDMIgmYoa5iIiI9HMsL5KIZcUDTtLccMMNVFdXc9ttt1FQUDCsa5tFREQg3oujZYeLV948SKyhHbvDoHiqm6kLvQSy7fTQQwFFyQ5zyBlGfDnTWFvSdDrSXPCZ98F75sDqXfGqmj01EInFPy8uO0wsgBUzYcEkCIzBVdqBgBPTjCdrbLYTv7sYCkWxWAwCgTE4e1xERCSJfvOb3/DDH/6QiooKAKZMmcJXvvIVPvaxjw36nANO0rz66qu88sorzJ07d9AXFRERGaz6+k7+5382snZzPYeD3XiyQtjcVra81corj9qYd4WbORe7KbGMT3aoMoSam7tpbu4h3evgY+d7uOwsg0ON0BOKV9N4XTA+D6yD60s9KsyZk09enpe6ug6KivwnPO7w4XaKi/3MmJGcjslNTd20tPTg9TrIzR2D2TIREUnJEdz33HMPt912GzfddFNvo+BXX32Vz372sxw5coQvf/nLgzrvgJM0xcXF/ZY4iYiIDIeWlh5++ct1bN5cx/gZmeTPi9Fd0oLpiGFGTNp29/Da30KUMZ7cS/KSHa4Mgebmbh57bCerVx+kszOM02ll7tx8rrhiGjNLA8kOb1j5/U7OP7+U3/9+Gz6fE7/f2e+Y5uZuOjtDXHXVdNxu+7DG19jYxWOP7WTNmkN0dYVxuWzMnZvPBz847aRJJRERkdHgZz/7Gffddx/XXXdd77YPfOADzJgxg9tvv33QSZoBp6JWrlzJ17/+daqqqgZ1QRERkcFataqKzZvrmDozE+O8dmzlUbz4cbWl4Qq7yZudRukHvKx5uYWmxu5khysJ1tER4uc/X8sTT+zCYjEoKvLh8dh58cUq7r13DTU17ckOcdh94APTuOiiCRw82MauXUdoawvS0xOhtbWHnTuPUFfXyT/90xTe+95JwxpXW1uQn/1sLU8+uRurNf61crlsvPjiPu69dzV1dR3DGo+IiAytYyO4h/oxktTU1LBs2bJ+25ctW0ZNTc2gzzvgSpqrr76arq4uJk6ciMfjwW7v+65MU1PToIMRERE5kZ6eCC+/XE0g4MQoCxIq6MbW6MSIWHAARMDsNnEU9dBQ2Mybbx7m4ouH94WpDK01aw6yaVMd06Zl43TG/4Rxu+2kp7vYurWeF1+s5NprZyc5yuHlcFj55CfnUV6ezapV+9m7t4lwOIbDYWX27DzOO6+UJUvGYbUOb4n46tUH2bKljvLyHByO+Jozt9tORkb8a/XSS/u5+uoZwxqTiIhIIk2aNIk//elPfPOb3+yz/Y9//COTJw9+WsGAkzQrV64c9MVEREQGq6amnbq6DgoLfYQKWjBiYET6vvA0MLB02XBO7WFHRcOYS9LEYiaRSAy73ZKSjfvffPMwDoelN0FzjNVqISvLw+rVB7nqqhnY7WO4Ec1x2O1WzjuvjHPOKaGmpoOenghut42CAh8WS3K+T9auPYTLZetN0BxjtVrIzHTz+usH+MhHpictPhERSawYxjD0pBlZvzPuuOMOrr76al5++eXenjSvvfYaL7zwAn/6058Gfd4BJ2muv/76QV9MRERksCKRGLGYidVqELPFIHb8X9RG1MBig1AsOswRDo1gMMLmzXW89toB9uxpIhqN4fHYOeuscSxeXERpaSBlEjZdXeF+L/qPcTishMOxo0ms1ErSHGO1Whg3bmT0eunuPtXXKko0GsNiSc2vlYiIjH5XXnkla9as4Sc/+QmPPfYYAOXl5axdu5Z58+YN+rwDTtJUV1efdH9JScmggxERETmRQMCFx2OnvT1EWrOD0LhuTEyMd72rEvNECVVayE/3JinSxNm3r5lf/3oDe/c2YZqQkeHCYjFobu7hj3/cytNPV3DeeaX88z/PwuUa8K/0UWfy5Cy2bKnHNM1+iammpm7mzMlLic9DMkXooJ2ddFJJlCB2AviYQhoTsbzjz8pJkzLZufPIcc/R1NTNokVFJx0bLiIio0u8kmZo3zQaaZU0AAsWLOC3v/1tQs854L9kysrKTvqOXTQ6Nt65FJHUFCZKK50YGKSThnWEjfpLZbm5acybl88//lFF+cF0ghM66HH1EKyyYrUapGVZMb0xIpEoRoWbhVcWJTXeYyOivV4HOTmeAVe7VFY2c++9qzl0qJ3JkzP7LfEpLvbT1NTNX/+6m+7uCJ/+9PwxX0GybFkxL71URVVVC6Wl6VgsBqZpUlfXicVisGLF8f9GCRKmnS6sWAjgxTIC/8gb6UxMWtlMPc8TohGwYGDDJEQza3FTTCEfwEU+AGefXcKrr1ZTVdVCSUmg92tVW9uBzWbhvPNKU6YCLBV100MnPTiw4SOtXzJdRGQsqa+vp76+nlgs1mf77NmD65M34CTNxo0b+3wcDofZuHEj99xzD3feeeeAznXXXXfxl7/8hZ07d+J2u1m2bBnf//73mTp1au8xPT093HLLLfzhD38gGAxy8cUX88tf/pK8PI1WFZHEiRFjO4fYxkFa6cIAMvExmxImkac/MEeIFSvKePPNw+zf1EnHbtjX1EJ3VwSLDdLL7Ixf6iF2wMFcRzFTp2YlJcbjjYieMyc+dri4+PRGRIfDUR58cBOHDrUzfXrOcft2GIZBVpYHh8PKP/5RyeTJmVx00cRE386IMmFCBjfcMJff/nYzW7fWYxgGsZhJerqTK6+czuLF4/ocHybCFqrYzSE66caChVwCzGYCxeQk6S5Gpza2cpgngBgeSjF4OyEYJUgnVRzkEYq5BifZTJmSxXXXzeHhh7e842sVIyPDzVVXzWDhwsLk3YwMmR6CbKWCSg7SQwgbVvLJZhZTySY92eGJyBAysWAO8ZubQ33+gVq/fj3XX389O3bswDTNPvsMwxh0AYthvvtsg/TUU0/xwx/+kJdeeum0n/O+972Pa665hkWLFhGJRPjmN7/J1q1b2b59O2lpaQB87nOf46mnnuLBBx8kEAhw0003YbFYeO21107rGm1tbQQCAVpbW/H7R8Y6bREZedaxl3XsxY4NPy5MoIUuDAzOYxrTSG5Vhrztuef2cttt/+DAgVb82Q68BVbCRGipDWGELFxy/lS+c8f5ZGd7hj22jo4QP/nJG2zYUEN+vhe/30lXV5hDh9oZPz6DW25ZSmGh75Tn2bSplrvvfpXS0gBut/2Ux+/d20RxcYA77lgx5qtpAOrqOtiwoYampm68Xgdz5uT3680Tw+RVtrKN/Xhw4sVFlBjNdODGyfnMUaLmNEUJUsl/EaQJD+OOe4xJjA72kcNyCri0d3tt7dtfK7/fyZw5eZSUpE4fpVQSJsLLvEkVh/DhwY2LMGFa6CCAj/NZRKYSNZJCUuV16LH7fLL1UdL8aUN6rc62Tt4f+OCI+ZzOmTOHiRMn8rWvfY28vLx+v9tKS0sHdd6ELdyeOnUq69atG9BznnnmmT4fP/jgg+Tm5rJ+/XqWL19Oa2sr999/Pw8//DAXXHABAA888ADl5eWsXr2aJUuWJCp8EUlhrXSxlQN4cJLO2y/s8wnQQBsbqGICeTgS9yNTzkBhoY+sLDcej42WliA9h6IYhp2CNA+WNIPcnDSystxJiW3t2kO89Vb/EdEZGW62bKnjH/84vRHRb7xxgGg0dloJGoh/Tvbta2b79gbmzMk/o3sYDfLyvFxyyclHW9bTwh4OkYkXD67e7S4c1NDEZvZRRLaWPp2GDnbTQx3uEyRoAAwsOMj4/+zdd3xc13ng/d+5907v6B0ECPYqFlFUL5YlWYpky07c4ia3ZB1Lsexs1im7tnc3To/9OnGNo7WduMRxl23JsiSqUuy9gAUAQfSOwfRbzvvHgCBBACQAEizi+frDj8Up555773Bm7jPPeR6G2UcRN+Mi/+W5rCzIm940+zakytWjjS5O0kkxMdzk37tcGPjw0kkfjTSzkdkX0lQU5crmoF2C7k5XViZNU1MTP/rRj2houLjdRGd8xRGPx8f9XUpJZ2cnn/nMZy6oFzjA8PAwAAUFBUA+fcg0Td7whjeMPWbx4sXU1NSwefPmSYM02WyWbDY75XwVRVHO1sEgSbJUEptwX4wAPcTpYogaii7D7JSzHTzYi8djsH59JclkjmzWRtMEwaCbRCLHiRNDdHYmppWxcrFt396ByzWxRbSmCYqKpt8iurl5iHDYM+3t+nwuLMuhpyc5q3m/HnXSTxaL4jMCNJBv0x4jRA9DDDJCIZf/l7grXYZuJA4a5w4auomR4gQZusaCNMq1o51uQIwFaE4RCEL4OUk3a8jhwX15JqgoinKR3XXXXezZs+fyB2mi0eiENB4pJdXV1Xz/+9+f9UQcx+GP//iPuemmm1i+fDkAXV1duN1uotHouMeWlpbS1dU16Tif//zn+exnPzvreSiKcu2xRmvFT1Z3RkfDQWLhTHyicllkMvZYjZZAwE3gjMzaU22Yc7nLU8T+XC2iXa7pt4i2LGfGy0GEANu+KCuYXxesc/SAMNCwcbDVv+tpkVgwrYwjQb7EsGoicS0ysdCneJ3oaOSw1L85RXkdkwjkHGenzvX4M/Wv//qvvO9972P//v0sX74cl2t8kPrBBx+c1bgzDtI8//zz4/6uaRrFxcU0NDRgGLNfCvCxj32M/fv38/LLL896DIBPf/rTPP7442N/j8fjVFdXX9CYiqK8vkXxo6GRxcJz1ttikiw+3MS49PVNZkMi6SXBcfrpI4kAigkwnyIKXycdNiorQ0gpsW0HXR+f9trfn6Kw0EdJydyuiZ5KQ0MBe/d2X3CL6GjUO6OsGMeRSAmBwPSWR10LIgQAgYlNkixDpMhho6EhkBTgJ3yV/Lu+3AxC5Kv8TGx5fyabNGK0n49y7SkgSjNtk75OkmQoJIpXZdEoivI6snnzZl555RV+/etfT7jvQgoHzziqctttt81qQ+fyR3/0Rzz55JO8+OKLVFWdXu9cVlZGLpdjaGhoXDZNd3c3ZWWTr7n3eDx4PNNPEVcURakgRgUxWumnnAjGaNeSLBYDJFlGFVEuz0X/TGSxeIkmGukmjYV7dD8O08MO2lhKGTdRh4uru7Ds2rXl1NZGOXKknwULCnEMSQ6L5FCOwaEMDzywEL//8gQrZtsi+hSJpB+L+g0lbNvdiePISTs7na23N0lhoY+lS8cXwnWQDJPEwiGED+95lqu8ntRQTAAfOzkx9subhsDCJouJjaCVfhZQ9roIXs6lEAvpJYRFHBdTdyjL0UeAOnyozk3XoloqOEIzfQxSSGw0HCpJksbBYQG1aFdYPQlFUS6ea7Emzcc//nF+//d/n7/8y7+8qN2nZ5X6cvz4cb7whS9w6NAhAJYuXcpjjz3G/Pkza/0ppeTjH/84P/nJT9i0aRN1dXXj7l+7di0ul4tnn32Wt771rQA0NjbS2trKxo0bZzN1RVGUCXQ0bmUJz3OAToZwkIDEQGc+pWxkwRV/EWfh8DxH2U8nhQQoJjg2Z4lkhCw7OImDw+00XNVflCMRLx/5yFq+/PWtvHS4hRGZxZYOHr/O9W+sYN2byi/b3GbaIvpM7eR4liGOkGZ4naDjx5J0Zy/rK4vwnON8OY6kuzvBAw8sorDQf8Z4A+yihS6GsHEI4GUR5aymFtc1UAQ7i4WFwB5NwNYAB3ChUUwhAXy8wCEEsIDL95q5GngoJswyBngNDQ/6WXV+AHIMAIIY6xBX8fuLMntRQmxgFVvZRxd9o4vfwIOb5SxgPiqzXVGU15f+/n4+8YlPXNQADcwiSPP000/z4IMPsnr1am666SYAXnnlFZYtW8YvfvEL7r777mmP9bGPfYzvfve7/OxnPyMUCo3VmYlEIvh8PiKRCB/84Ad5/PHHKSgoIBwO8/GPf5yNGzeqzk6KolxUMQI8wBpa6aOXETQEpUSoomAss+ZKdoIBDtNDCSF8kxRtDONFR2M/XTRQTM0kRZKvJvWLo2z4X2Vkd2awOyQBj5uSZX5ciwTPakd4gGUUXabsp5tvrmHBggJ27uykvz9NKDR5i+gzdZPjO/TQQY4SXEQLQ/TeX82Ofz+G5RngpqICXJNc+DqOpLGxj+rqKHffXT92ezsDPMM+kmSJEcBAI0GG1zjGCGluZ+lVHaibjv2cZIQsq6ljhDQZcmhohPARxItA0EOc7TRRQxGeayjLaDZKeSM2SeIcQMONiwI0DGwy5BhAYFDMHYRZfrmnqlxGNZRTQIQ2ukmQwo1BBSUUEr3if+xQFOXCOIhzVIO7eNu4kjz88MM8//zzM05WOZ8ZB2n+x//4H3ziE5/gr//6ryfc/qd/+qczCtJ85StfAeD2228fd/sTTzzB+9//fgD+6Z/+CU3TeOtb30o2m+Wee+7hy1/+8kynrSiKcl5uDBooo4Grq4WxRHKYHiRyQoDmTAHc9JPiCL1XfZDmOP10R4a59Y7asWVdkD8WrQyyjw7u4PK1/Z1Oi+gzvcYI7eRYgHesJfSN99dhJG22/aKFLb0mKypihMOe0TXODt3dSXp7U8ybF+GjH11HdXV+GYpEspsWkmSpOOPCqIAgPnIcoYuFVFBFwcXf8StEkizH6CaMDw+uKQMwBQToJj627EmZmoGfSt5GkAUMspMsPUhsNNyEWUaM6wiySF2IKwTxs5i68z9QURTlKrdw4UI+/elP8/LLL7NixYoJhYMfffTRWY0rpJQzagXh9XrZt2/fhHbbR44cYeXKlWQymVlNZK7E43EikQjDw8OEw6odpKIorz85bL7FVgQQwXfOxw6QwoXOB7j+qr6Y+jUHaaSHSqIT7hskhYbgvazHfRUs68nh8De0YyIpOSuY4DiSzdvasF4YwH8gTTJpcioZp7g4wM03V3PbbfOorDz9+TZIkh+xBT9u/Eys0XaSfq6ngQ1c3HaRV5JW+vgFOyknct6MoTYGWEs9Gy9jUO9q42CRow8HCx0fbgqu6vcTRVGUuXCtXIee2s8fDf+SQHhus5iT8SRvjdx/xRzTs8u1nEkIQVNT06zGnfG31+LiYnbv3j0hSLN7925KSkpmNQlFURRl9pzRriv6NJavnCrkeL4uLVe6LNaU+2ugY2Jj4VwVfUQsJBYSY5LzoWmCmg0l1FxfxX3NPrq6Epimjd/vYuHCQiKRibVBbGwcJPoUy/TEaPHc1zNnBq9xQf7fkDJ9GgZelXmkKIqinEGijVaAm9ttXEmam5vnZNwZB2k+/OEP85GPfISmpiZuvPFGIF+T5m/+5m/Gtb5WFEVRLg03On7cDJM5b+PbNCblhK76eiSlhDlG/6QX4iNkqSR81XQy8qFRjpujpCk462NZIknhME94qa+PUV9//mVqIXwE8JAkg4fguPscHHJZm+P7htn+ymtkszbFxX7Wrq1g6dJiDOPqfl2c4seNG4MMJr5zhOokEgdJYJKMI0VRFEVRlMthxkGav/zLvyQUCvEP//APfPrTnwagoqKCz3zmM7Nec6UoinI5ZDAZIY2BRpTAVZtZoqGxhFKe5xg2DjlsHBw8GOOKHts4mNgsnsNfwCUOafqR2JiJAIO9Fm63TkVF6Jytp6di4TBAGoAY3rH24QsoZh8ddDNCCaGxDKE4WSSSpZSN1Xa5kiQSOXp7k7jdOuXlITRNIBBcT5CjpOnDpBADgcBB0k6OGDrzBnWaBgcJBt2UlJw7ldiDiyVU8ipH8JIdW/JkS4e93R20H0gy8MQwbsuFYWik0ya/+c1xli0r4UMfWkNZWXDCmH1xiKcg4ofCGWYXT3UOp3y85dDRMYLjSMrKgni9M1+yVkSYMqK0M3DOIE1y9PjUUISUks7OBMlsDr1IIxhyT5hvNmvR2ZlA0wQVFaHXTVBLURRFUS6UvAQtuK+0TBqAtrY2fv7zn9Pa2koulxt33z/+4z/OaswZf/MRQvCJT3yCT3ziE4yMjAAQCp3vt1tFUZQrRw6L3ZygkQ5SZNHRKCPKdcyj8iotprqAYl6lme20IhBI8hk2xQQpJ4xA0EmcEkLMp3BO5jDIcTrYRn+6g5efzLH3BYPcUJiQUcTiRcU89NBili4tntZYEskBethFJ/2jF/iF+FhNOcsooYgAd7CAFzhOG0OjzwEfLq6nhsVc3FaIFyqTsXjyySO88EILg4MZDENj4cJCHnxwEcuXl7CaAL2YvECco2TGWtf6Bmy0n/bxxdf2kkqZeL0Gq1eX8eY3L6aqaupoyQpqGCbNETroJ4kAenuTNO0YwXglwLK6AnT99BedVMpk585OvvSlLTz++MaxVt5dg/DT12DHMUjlwO+BdQ3wlhugJHrufc6fw97Rc5gCoGD0HC6nZEIQTUrJli3t/OpXR2lpGUJKSUlJkLvvrufuu+vHzfd8NARLqaSDQYZJEcE/4TE5LAZJspxqehrT/OvPdvPqoTZ6zCRaBOpviXHL79SwIVDNEruI537bzDPPNNHdnUAIwbx5Ue67r4EbbqiaVQBSURRFUZSr27PPPsuDDz5IfX09hw8fZvny5bS0tCClZM2aNbMed8aFg68210rBJkVRpsfB4XkOcpB2AngI4sHCZpAUATzczYqrMlBzkkF+yG6aGQQkXgwkYGITxksYLyUEeSOLKefivxcOcJTj/IqMleaXX/Wy7bkcwQKLUKGFK1vGSHuUwgIfjz12w7QCNTvoYBMt6Aii5OuuDJHBRnIbtayjEoA4GZroZ4QMXlzUEKOE4BWVFWXbDl//+g6eeaaJggIfhYU+cjmbtrY4sZiPRx/dwPLlJcjRzJnDpEnj4B5xeP6f9tK4q4eysiDhsIdk0qSjI059fQGf+tSNk2a9nOIg6WKIdgaIpzL88BuHyTYKaksmf32bps3Bg728610r+d3fXUp/HP7+p3C4DSpiEPRBIg0dA7C0Bj71FohNvXl208lzNCMQxM46h7dSy/rRc3jKyy+38vWv78A0bSoq8llGvb0pkkmTt71tCW9/+8xaO0skO2lhO03Y2GOdnmwc4qTJYVFPKdVHy/jKP+2gsaePXKWNy6thD0oSvTlqbguz8WPV2D+V7PphFz6fQUlJAMfJZ93ouuBDH1rDbbfNm9HcFEVRlNe/a+U69NR+/ufwb/DPceHgVDzJ70XeeMUc0+uvv5777ruPz372s4RCIfbs2UNJSQnvfve7uffee/nDP/zDWY0743yh/v5+Pvaxj7F06VKKioooKCgY90dRFOVK1s4gR+miiCAFBHBj4MdDBVGSZNjDCSRXV+w6fzHaBgjWUkktMYzRt3eBIE6GRZTwIMvnJEDjYNPBFmyy9B4sZe8rNpXzPFRWhQj4/BjRfhYs9dHfn+bJJ49wvt8GkuTYTgdudMoI4sXAi0EZQTzo7KCTJPl00jBeVlPJLcxnPTWUErqiAjQAhw718dJLrdTWRqiqCuPzuYhEvCxdWszAwOljIhBU4eENRPkdCtBeG+bonl6WLCmmtDSIz+eiqMjPsmUlHD8+wKZN5y5WpyGoIMZ65iNe89P7ok1VYXTKx7tcOgUFPl566QSJRI6XDuYDNMuqoTgCPnf+/5dWw8GT8MqhqbedwmQr7bjQKJ/0HHaQ4HRKcCZj8bOfHcZxJIsWFREKeQgE3MybF6WoyMdvfnOcjo6RGR13gWAN87iHlcynlCwW/SSIk6aAILezlDvlUp79ZQsd3XF8ywyCMTcFPh/FFX7K5wfpei3JieeGefLpI4QK3NTVxQgE3IRCHhYuzGek/fznR0inzRnNTVEURVGUq9+hQ4d473vfC4BhGKTTaYLBIJ/73Of4m7/5m1mPO+PlTu95z3s4duwYH/zgByktLVUpvoqiXFU6GMDCnlCnQiCIEqCTIeKkJ10ecaUaIk07wxSMFowN4KGCCFksAHpIUIif2BztU4oeknTjo4hj+7PkspJgOB8k0nFjkiYn4lRWFnD4cB/d3clzZoC0M8IwGSonKYMcw0s7I7QRZxFFc7I/F9vBg71kMtaETkxCCKqqwjQ29tHZmaCiYvz+btvWgcdj4HaPr+Gi6xoFBT42b27jd3932bSWAR07NoCmifM+tqQkQEvLEO3tcTYfLiLiA+OsEjIuA0JeePUQPLB+8nHaiTNMlgomnucCfLQRp404i0fP4fHjA5w8GaeuLjrpnPbv7+Hgwd4Jx+h8BIJ5FFNLEXHSZDDR0Yjix0BnYDDN/v09BMvddIskoTPeF7xBA8eSnHh2iPhgFu/yiV+ZqqsjHD8+wNGjA6xceWUtsVMURVGUS8m5BDVp5nr8mQoEAmN1aMrLyzl+/DjLli0DoK+vb9bjzjhI89JLL/Hyyy+zatWqWW9UURTlcjGxp8yzMNCwca669sTWaBPuM4sEu9DHCp4OksKawxbDDhYONhoGuWwWbdxFvRitr+LgdutYloNpnvv4njr+kxX+1Ubr7czl/lxsmYyFpk3+qjt1THK5iccklTInBGjOfF4uZ2PbEv3cdXiBfDFeXT//jyqaJnAciWVJ0rl8QGbS7RuQyU1+H5x+TZ77HJ7e51zOxrIcXK6JOyOEQAgmPUbTJRBE8BM56/ZT29XdGhImZGFpuiCXshECpJiYAeZyaViWPO9rWlEURVGU158bbriBl19+mSVLlvCmN72JT37yk+zbt48f//jH3HDDDbMed8ZBmsWLF5NOp2e9QUVRlMspRhAHJr2ATJAliJcQvsszuVkK4yWIhxGyeM56W89fKkMBc7dG2EsMN0GyjFBa5caxGQ0eCCQOIDDw0defprDQR1HRuTN6Yvhwo5PGwn9WG+00Fh50YlfROaqqCiOlxLadCZks/f0pCgp8k3Zsamgo4NCh3vxSqLOyVgcHM6xZU47LNb1flAoL/eRy9qRjnSmRyOH3u4hGPSysgBf2Q+UkdaaHUrBuwdTbi+HDi0EKk8BZWWtpTNxnncPy8hDesJddXSnMaAAbCGhQYUAgZ2IYOuXl5yiAM0sFBT6Ki/2c6BnGCAtMbNyjwU3pSCzToXhxgN6+FDIFZyej9feniUY9lJerBgqKoijKtc1BXIJMmitrFc8//uM/kkgkAPjsZz9LIpHgBz/4AQsWLJh1ZyeYRU2aL3/5y/z5n/85L7zwAv39/cTj8XF/FEVRrmTzKKaQIF0M4ZyRjZEiS5oci6nAPfP49WXlwWAZpaTIjdVqgXyAppMRighQP0cdnQDcBCliKVmGWbQOymsMThwxsWybHCO4CZIZ8jI8nOWOO+bh87nOOV4ZQeYRpYcUuTOzLbDpJkktUcomWUZzpVq7tpza2ihHjvRjWfnXnOl36NHTDFoZbr99Hn7/xGNy003VRKNeTpwYxnHyWRz5NtEj6Lrg9tvnTXvJ8Zo1ZQSDbuLxbH4cJCksEuQwz/h30NmZYMWKUioqQtyyLF+Hpq0fTpURkhJO9kHAAzcvzc+nuztBU9MgQ0OZsXFKCTCPKL1TnsMI5aPL2RwJu4NBWhdXsuPECC3DObotaMzCC8M2vz44QNWCQpYvL5ly/0zT5sSJIVpahmaUceN269x1Vz1OSqIPaKQwsZE4tqTzaJJopZfat4ZZuKSQrmPJcWOnUibt7XHWrauY8TIsRVEURVGufvX19axcuRLIL3366le/yt69e/nRj35EbW3t2OO+973vkUwmpz3ujK9EotEo8XicO++8c9ztp36ds22V8qsoypUrgIfbWMILHKJjrHWzxI3BCqpZSc3lneAsraaKYbIcoot+Tn8IFBHgLhZOyGa42Cq5gRwj9McauecjWX7xDYcjB8HAg0/GiPhz3HvvfO699xzpF6MEgjupJ4fNCYaxR3830dCoJ8ad1E26jOZKFYl4+fCH1/CNb+zkYGcf5vUadr2G7tcof1sBosHNEFmieMY9b8GCQt73vtX8x3/sZf/+HoQQOI5DLObjd393GevXV0x7Dg0NBVx3XRkvvniC8sUhejwZ4uRwkLjRKcGHaJd4vQZ33JEP/qycB++8DX70Kuw9AZrIB1SKwvD7t0BQDvH//X+H2bu3m2zWJhBwsXFjNW9+82KiUS93UEcOmxaGxp3DOmLcSf3YOfxNEr41BLUPLseTzNK5pyO/fEiAJQTMK8J+61qSmk70rP2SUvLyy638+tfHaGuLI6WkvDzEPffM54476qZcZnamu+6qo7NzhKefO05vW4pukcSRkmiVlxUfKmFVeTm//6HVfO+r+zh8uA/HkUiZD/DceGM173rXimmfB0VRFEV5vZII5Bx/P5vr8efKRz/6UTZs2EB9ff20Hj/jFtzXX389hmHw2GOPTVo4+LbbbpvJcHPuWml9pijKzKTI0kIfwyQx0KmkgDKiV9XF/9kcJB0M08YQJjYx/NRTiH+OAzSnt28zwknitDE4lOHYTo1EZxi/x8/y5SUsXFg4rYvmU0xsmhmim3waaSlB6oiO1dq52rTF43xj4ADtepJg1qA8GsRf6GJAZKkhxNuYP65w7SldXQl27uxkYCBNKORm9eoyamoiMy7cPziY5vNffoWndzQhgoKiEj8uQ2MkaTLQlaLU7+dP3nkD992zYNzYbX2wqwmGkxANwnX1QCbOP/zDZlpahqisDOH3uxgeztLdnWDdugr++I9vIBBwY44G2jrJd2YqHc2SOrWkqN+CP+8BU0K1C2zTprexl75jfUhHEqmKULisnOOGi/dF4KGzPsZ/+9smnnhiF0IIysqCCAHd3fmMl3e+czkPPbR4WsfGcSTHjg2wa18nbZk4Wqlg0dpCGmKFY/NNpUx27+6ipWUIXRcsXJjP7pmsjo6iKIqiXCvXoaf28z+Gn8MfnttM51Q8wbsjd151x/RUe+45C9L4/X527drFokWLZjXBS+1a+cehKIqiXNlepZNnOEktQfQzVhvbOJwgwT3UcANlc7Z9ieTfE4d5+sXjDL6QYKgjg3QkLq9O+eowJbeF+fiKNSwQ0fOO9a1v7eanP21kxYqScYG3bNbi6NEB/uiPruf22+edd5ynE/C1QVjuzmfqTOWECQU6/N8S8I4eukQix5/92bPE41nmzRs/5/b2OJom+Ku/uovCwqunU5uiKIry+nGtXIee2s/vDG+6JEGa90Ruv+qO6UyDNDNe7rRu3TpOnjx51QRpFEVRFOVKcJDB0cbP48vB6Wj4MDjEwJwGaYbJ0RvMsPFNNfjfoDPUmcE2HXxhF6FiDy0iwQlGWDBhUdF42azF1q3tFBf7J2RGeTwGhqGxY0fntII0jTlwc+4ADUCxDh1W/k/9aLJRY2MfXV0JFiwomPD48vIQBw/2cvBgL7fcUjvhfkVRFEVRlCvVjIM0H//4x3nsscf4kz/5E1asWIHLNb7Y4anCOYqiKIqi5EkkOWyMKer1uxBk57iteL5RusRAw3DrFNWO7yilw7giv1MxTQfLcs7ZHjyVMqc1J9OBaXQGRwccwD4j9zffgtzBMCYe03zwSGKaV0+rdkVRFEW5mqmaNBfPjIM0b3/72wF45JFHxm4TQqjCwYqiKIoyBYGgkgD76KcI74T7E1jnzWC5UGHchHEzQg7/hFbtEhtJ8dk9picRCLioro5w4EAPoSIP/WQYJoeDg0fq9CaS3NMwf1pzKjEgM41F1yMO+AVEzogLlZeHCIU8DA1liMXGt2RPJHJ4PMactO1WFEVRFEWZSzMO0jQ3N8/FPBRFUZRLJJ2FrkEwdKgoAF3VPb1oLBz6ybeiLsQ7LnNmOYUcZIATJCjEQwAXIOkjgxed5XPYJh3Ajc51FPM0rYyQGytSbCPpIEkRPhZNI1AkhOD2O2p56cBJWrvbcJXo6EIDR9LXksJdqNO30WKQDLFJAlJnWuuDpxL5IExo8iQjHCk5NmSzQrPJRgQE8/OurY1w3XVlbNrUgtdrjLV2z2YtmpoGWLeukoUL5/aYKoqiKIqS56DhTJExfDG3cTWqra2dsALpXGYcpDmz37eiKIpy9bBseGonPLsHeodB16CuFO5fD+vP3xlbOQeJZC/9bKeHPtIAFOFjHSWspJAcDq2kGQFOEsfGwY9OAR7K8XMnldQSmvN5rqGYQbLsppdeMmNJw8X4uJdawtPsBObZ6KWwI8zAr3rJ7DXRtHxGbWGpj+vfU83QPIuf08zDU3SsOmWxG1Z5YXMKlnjAfVYWc9dAjue32Qy0Q6uWZnPAYuMiePSGKNVBN+95zyqSSZM9e7rGljYZhsbKlWU88sh16PrV+WVOURRFUZSrx/bt2zl06BAAS5YsYd26dePu379//4zGm3GQ5pSDBw/S2tpKLpcbd/uDDz442yEVRVGUOSIlfO9F+PkWCHrzGTSWDYfboKUH/uBeuGF63YqVSWyjm9/Sho4ghgeAPtL8khbSWHRisp1+wvi4Dh/9ZBggA7i4jWrWzHEWzSkGGndTzVIKaCE+2qrdSwORcwZTzjRCjs1aF2veVsm6deW07hkik7AIFXmYtyZGuMSLhUMLI+yhj5upmHIsTcAjUcg4sDsLIZFfAqUDrYMmzz7tkO0XzCtNURk0GUnBr7botPYN8s8PFVBc4ONTn7qRvXu7OXq0Hymhvj7G6tVleL2z/oqjKIqiKMoMXYuZNG1tbbzzne/klVdeIRqNAjA0NMSNN97I97//faqqqmY17oy/wTQ1NfGWt7yFffv2jdWigXz6M6Bq0iiKolxm+QKx4EYgRnMlWnvhuT1QEoHiyOnHhv1wpAN+tgXWNoBLXdfOWBKTLfTgQaeE07VRfBj0kuZZ2smgU4qP4OjHbhQv9UhaSHKAONdRMHau5pqGoJog1cyuXssRhhgiS60IodUJiusmjmOgEcTFfvpZRwnec3zdKDLgjwvhpRRsSkKXlS8SfPCwhTZos3Z+hpg7/93C54ZwwOZgs8ZPj4zw4VUFuN0669ZVsG7d1MEgRVEURVGUi+1DH/oQpmly6NChse7XjY2NfOADH+BDH/oQTz311KzGnfHX8ccee4y6ujqeffZZ6urq2Lp1K/39/Xzyk5/k7//+72c1CUVRFOXCjGBxmBF2McwQJg4SFxqLCLKcMAdO+hhOC2qKJz63qhBO9EJTFyyaXcD/mtbKCENkJw16FOBlJ30I3NSdtZxJICjCw0mS9JOdtKDwlaiDJDoC7TxBpQhu+sjQT4bK8wSEwjrcH4I3BKDTgoTl8AddCfxFDrGzEny8LjAMePmYyYdXXejeKIqiKIqizM4LL7zAq6++OhagAVi0aBFf+tKXuOWWW2Y97oyDNJs3b+a5556jqKgITdPQNI2bb76Zz3/+8zz66KPs2rVr1pNRFEVRZsZG8jL9vMYgA+TwoOFHRwMy2LxEP1sZJFPsxwmXIsTEQIDbyC99ylmXfv6vBxb5jNLJEnA1GG17PTkXGjYSk2m0OLpCWMjzBmgAdMRY16jp8mgwzw1D0kFIOWVml2FIEtlpD6soiqIoyhy7FltwV1dXY5rmhNtt26aiYvYZvjMO0ti2TSiU/zWwqKiIjo4OFi1aRG1tLY2NjbOeiKIoijIzNpJf080rDBBEpw4/+lkfXiV4SGFzIJYgsd5i6GQF0ez4dsX9IxANQHnBpZz960cBHtxopLBGOzadlsIiiAsLHRs54fwMYxLGRWya9WCuBBHc5HDO+7g0Fh70CcdkOsJunZpiyYFmjeLI+G05EtIZjcUVEiklJ0/G2b69g+bmIaSUVFdHWL++grq66NhSbEVRFEVRlIvt7/7u7/j4xz/Ov/zLv4wVC96+fTuPPfbYBa0ymnGQZvny5ezZs4e6ujo2bNjA3/7t3+J2u/n6179OfX39rCeiKIqizMzL9PMKAxTjJjT6du4g6cfEwiGCCz86fnRW+f10lqbYZ3ey9kQ1fid/4ZxIQ9cQPLgeisKXcWeuYhUEqCfCQQaoQsNNvqd5Dptu0qwgRq8J+zqylEs/hRUOhhsSmCSwuIkivFw9fdAXEGUb3aQwCZJFI4WDD+es5Vz9ZFlOAQWjhZRnQhOCB1Z4ONhi0j0IxdF8kWHHgZO9gnDQ4d46D9/+9h42bWpheDiLz5f/N/Dqqyf51a+OcOON1bznPavw+0df61gMY+JBo3C0YpOiKIqiKBfHtVg4+P3vfz+pVIoNGzZgGPnvIZZlYRgGjzzyCI888sjYYwcGBqY97oyDNH/xF39BMpkE4HOf+xwPPPAAt9xyC4WFhfzgBz+Y6XCKoijKLCSweI1BguhjAZoTpDjICPHRBTguNCrwcB1RvLrGTUV+XiLFgZMjeJryaTMeF9y6FH735su4M1c5geBuqslh08II9miWiY5Gg4xQuKWazT+XbGtLkZYOwXKLeW9MsuhOk+u1Qm5gkkJBV7BKAjQg6OO3hOlAx0TiIsc80qzFJkovabzorKJo1sGQ310Yofmmfn6+TdB4QuNUUkws4vChWw2O/PY4v/rVUcrKgtTURMayZqSUDA5mePrp45imw3s+uopXXUPsJU4SCxca9fi5jSKq8J1jBoqiKIqiKFP7whe+MCfjCnmqPdMFGBgYIBaLXZFpxfF4nEgkwvDwMOGw+plYUZTXh+0M8kM6qcOHjuAEKbYyiInEi4aGIIeDiaQUD7dThIHghJ3BHHSz7OA8vJrGokpYVgPG1ZPIccXKYdNEnE7yP2SUE6Dl2QDf/qZECCgsdxgSJh3dEjsL73qXzgcfCkyrvsuVxGKE43yHZvYxiB/w48fExRApSjnK7RhEuJMq1lB8QRkrjpTs7EnxfHOaobRDaVjn3voAZkecv/qrlygq8hOLTR5oGRnJ0tY9wqq/WsxQjSCGixAGWRx6yVKEh3dRSYUK1CiKoihz4Fq5Dj21n18f3oIvPLvOkdOVjif4SGTD6/6YXpRmqwUFqpCBoijKpbSbOG7EWHHWgyQwkYTQxy6KDXRyOPSS4yRp6vBTrrvpKMpyw60pFs6yBbMyOTc6i4mxmBgAiYTkyz8zcbth3jwN0CjAoL4e2tsl238FD98MhYWXd94zFWcvFi3Us5xecvSQIoEBuAnSyWp6WMJa5nHhX540IVhXGmBdaWDc7d/4rwNks9aUARqAUMhDxptmZ3qQW2UFPpGPRHrQCKFznBSvMcjDKkijKIqiKMo0xePxsQBRPB4/52NnG0i6KEEaRVEU5dKxkQyQwz9ax2QQkzgmXrQJWQtuNNJYdI4Gadyj3YRGmPtWTlJKTBN0HXT96soWuRgaGyVdXZIFCybue3k5HDwoOXjQ4ZZbrq40pjgH0PDhxk0lbsrwk8ZGIpEIgnRRe1Z9motJSsm+fT3nDNCc4lrmIz6SxbAEZ9YvFggKcNFIgiQWAfV1SFEURVEuiERDznHNmLkefzpisRidnZ2UlJQQjU7epEBKiRAC27ZntQ31rURRFOUq4yCRMBaOMXGQcM5lM2d/RDhz1PJZSklbm2TrVotXX3VIp/NLferqNG6+WWf1ah2v99oI2Jgm2DYYk3zSapoAJLncJZ/WBXPIoJ0R8dDRCI5+acrhxSYH416hF3n7jsS2HXT9/F/UpEcg7Pzr8mwuNFLYYy3UFUVRFEVRzue5554bW0n0xBNPUF1dja6P/8HNcRxaW1tnvQ0VpFEURbnKGAjcaCRHs2GiuHCjkcXBOKtL0KlgTHj0ojr/d4l7Dn6JyOUk//mfJs89ZzE0JIlEBF5vPlCxbZvN1q028+drfOADLhYuvPjZIxLJIP100kacIQSCCDEqqCFM5JJ38ykvh1AIhoYgFht/XyIh8XigvPzqC1j5qCbNFjyUTrjPIk6YVYg5/KVL1zVKSgIcOdJPWdm5l+zJNhN9oR/dmHichzEpx6uyaBRFURTlIrhWujvddtttY//9yCOPjGXVnKm/v583vOENvO9975vVNi7/XirKVcS2HU6eHKa5eZB02rzc07lkJJIecrSRJTUhJ0O5lIaGMjQ3DVHSrRGXJhKJF50qvNhIcqOdhSAfkElg40OnHj8Aw1iEMM7Z1WZ4WNLU5NDVJSfNQJiMZUm+/W2Tn/zEwucTrFihUVurUVqqUV6usWSJRkOD4Phxhy9+MceRIxf3dZQly4u8xNM8xV520UU7nZxkPzt5gafZzw7sOVriNTACTd3QOzz+9poawXXXabS2StLp08cxm5U0NUmWLdNYtGj6QZqeniRNTYMMDqan/RzHkbS3x2luHiSRuDhpO2FWoeMnQ9doTlf+PSJLLwI3Ua475/OlhE4TmrIQn+XL4Oaba8hkbCzLmfIxjiOx96aYFwrSrmXHApYSyRAmOSRriWJcZYWbFUVRFEW5Mpxa1nS2RCKB1+ud9biz+vnoO9/5Dl/96ldpbm5m8+bN1NbW8oUvfIG6ujoeeuihWU9GUa5kO3Z08OSTR2hqGsS2JcXFfu68s457723A5bq6akrMRBNpnmeQZjJYSEIYrCXI7cTwqjjvJTM0lOGnPz3Ma6+1kUjkMD0wvELH+5ZaqmujrCZCEptusqSxxi47veisJTLWpnsAkw1EKcQ9YRvxuOSnP7XZvNlhZETidsOKFRpvfrNOXd25z/Urr9g884xFTY0gEpn8otftFixZAocOSZ54wuQzn9HweC78AvkkIzzJJvo4gYMfHS9leKnFhwtBiiSNHMBGsoq1Fy3Lo38EfrIFth6DVBa8briuDt68HioLQQjBe95jkExa7NkjMU0HIfI1elas0HjkEWNatXra2uL89KeH2b27i0zGwu93sWFDJW95yxIKCqYOth040MPPftbIkSP9WJZDLOblttvm8cADC/F6Z5894qeOUu6jl2dIcZz8siYHgzAlvJEAC6d87vEs/HQIDqQhJyGsw40BeCgKoRm8ja5bV8H8+VEaG/tYsqR4dPnYaVJKGhv7mFcW4u3hWl4hThOp0ZlCAJ1bKWQN0Rnvv6IoiqIoE0kEco5/+Jjr8afr8ccfB/Lf9f7yL/8Sv98/dp9t22zZsoXVq1fPevwZf0v7yle+wv/8n/+TP/7jP+b//t//O1YMJxqN8oUvfEEFaZTXpa1b2/nKV7aRSllUVoYwDI2+vhT/7//tpr8/zfvet+qKbEF/oZpI8x90M4BJKW7caMSxeIoB+jF5O6XqV+hLIJUy+Zd/2cr27R2UlgaprAyRSpscf7GbX51I8rZPrqSwKshtFHKSNJ1ksXAI46IeP8GxAE0OLxoriUzYRjot+fKXLbZscSgtFVRWCtJpeOklh5YWySc/aVBTM3lww7YlmzZZ6DpTBmhOEUIwfz40NTns3euwfv2FBTi7yPB99pLgBF7CGLjJ4XCCFCkslhEmQBANjRaOUkUNRZMs05mpeAq+9GvY0wLlsXxQJpmF3+6DE73wqQehJAIFBYJPfcpg3z7JkSMOUkJ9vcbq1WJatXm6uxN88Yuvcfz4ABUVYQoLfcTjWZ588ghtbXEef3wjoZBnwvMOHOjhi1/cwuBgmqqqMG63Tn9/mu9+dx89PUk++tG106rpMhmBIMZ6/MwjQSMWIxgECbAQ7zmObXMWvtANHSZUucCrwZANPxrKZ9Y8WgKeaU4pEvHykY+s48tf3sa+fd0UFfnHAlZDQxl6elJUVob48IfXsLyghGXEOMQIQ5h40ZlPgCq8l3wJnKIoiqIoV79du3YBp5oZ7MPtPv3jp9vtZtWqVXzqU5+a9fgzDtJ86Utf4hvf+AZvfvOb+eu//uux29etW3dBE1GUK5Vp2vzsZ4fJZCyWLCkau72mJkJfn4tNm1q47bZa6upi5xjl6iORvMgQA1g04Bu7mPHiJoDObhKsI8wi/OcZSblQ27a1s2tXF4sWFY1lQPh8Lm6LVvHMvpO89NsTPPD+pRgIavFTO8k5GSDHMBZvoHhs6dOZtm932L7dYdGi08EDnw+iUdi3T/Lb3zo88sjkV9BHjjgcOeJQUTG9C16PRyCl5JVXrAsO0rzGAEOcJIKBi3ywwkDHjUYfOfrJUYoHH35GiNPGiYsSpNl8BPaegCVV4B79JPW5IRaA/a2w6QD83o35291uwdq1grVrZx4Uef75Zo4dG2D58pKxoIrP5yIW87F3bzevvdbG3XfPH/ccKSW/+MURBgfTLF1aPBZArqpyEQ57ePnlVm69tZbly0smbG8mPBTjoXjaj386Du0mrPDCqZi2T4OIDttSsCsNNwTOPcaZGhoK+JM/uZEXXjjByy+30t4+gpSSSMTLww8v5vbb51FdnQ9IRnBxAwUz2T1FURRFUWbAQVyCmjRXxo8rzz//PAAf+MAH+OIXvzjrVttTmXGQprm5meuum7je3OPxkEwmL8qkFOVK0tIyREvLEFVVE//xFRb66OgY4eDB3tddkGYAiybSlOCa8GtzAB0LyRFSKkhzCezY0YlhaBOWqIQ0FyuLCzi5Lc6xd44Q9LgoxoNn9APSGa29MTiaPXA3xdxK0aTZA7t2Oeg6E7I7NE1QUgLbtjm8850Sn2/ic3t68l2KgsHpf3BGItDc7Ey5lnc60tgcYhA/CXTGr/s1RveybzRIA+DFSw+do42iL+xDfutR8HtOB2hO0TWIBfNBnLfdANoFfFexbYfNm9soKPBNyHpxu3U8HoNt2zomBGk6OxM0NvZRWRmecGzDYQ8tLUMcONBzwUGamRixYVcKyozTAZpT/KO7tic1syANQHl5iHe8Yzn337+A3t4UkH9fjkRmvw5cURRFURRlOp544ok5GXfGQZq6ujp2795NbW3tuNufeuoplixZctEmpihXCtN0sCwHt3viL/5CCDRNkMu9/orpmkgswDXFxayGwFStay+JdNrE5Zr8aj/qduPKCe43SzjkSdNBGnvsvAjCGGyggFWEqcM/ZXAilQKXa9K7cLshk4FcLp9dczZ7Fi9/TQPLyheRne1KQQuJPfabzcRBNMQZxwIEGg5TF5qdiVQOpipF5TYga4IjL6w6v21Lcjl70vceyAdqUqmJBcxzOXvK9yzIB96y2Uv7npWTYMn8EqfJGEDqAk5NKOSZdNmXoiiKoiiXxrXS3elSmHGQ5vHHH+djH/sYmUwGKSVbt27le9/7Hp///Of513/917mYo6JcVmVlQaJRL/396QntXnM5G00TlJeHLtPs5k4MgxgGQ1j4J2nrbCMpm6T4rHLxzZ8fY8eOzkmzTvr7UyxdWszt/lJuRdJCmhEsbCQeNCrxUjCN8zR/vmDLlsmr1Pf3SxYu1AhN8TI/VSvNtuW0CuFCPuhTWSkmFHydCT86RQTowsCNCWdk00jyQRxP1uB4J/QNgBnMEsxEqXQJliwA4wI6Ly8ogyOdk983kIANC8C4wHriLpdGXV2MHTvytYjOJKVkZCRLQ8PEJTyhaICsv5yX23wEwiHcuk2pL0G5P4EmLaSUVFZe2vesiA5lLjiRg9hZx11KyEioU28niqIoiqIoMw/SfOhDH8Ln8/EXf/EXpFIp3vWud1FRUcEXv/hF3vGOd8zFHBXlsioo8HHTTTX85CeHCARcY7/WmqbNkSP9LFhQyOrVZZd5lhefB431hPgZ/cSxCI++XdhITpChDDfLmOHahGuYlJLu7iSplDnj5RgbN1bz3HMtNDUNUlcXQ9PyNV16evJLTO+4ow5Ny+fIhHExAgTQqBmtHzQdN9yg8dvfOhw/LqmvZ2wbvb1g24I77tCmDKgsWaJTXCzo7pZT16URDiIUB83GHgkQj7t529umnptEMsIIGcdiKBPAg4dyL7jP+AFFR7CeAn5KCVma8BJEQyCBEWlhJjQO73aTTtkYwRR+X4pjz1XyWiMsXwwfeheUjpZUydjQlQVdQIU3///nctNiePlwvkhwTVE+G0hK6BzKZ9jctvTcz58OIQS33z6PPXu66OwcoawsiBACx5G0tg4TjXq56aZqABwHOgbgQCs8tcNFu2cZrV1DBKWB7tJpHokSdmWIxA+xtCbCmrXl9JAjhySGMe3XyWwZAu4Mwdf6oM+CQj1/zBwJx7L5AM6G4PnHURRFURTlynQtdXeaazMK0liWxXe/+13uuece3v3ud5NKpUgkEpSUXLp17YpyObz1rUsYGEizZUsb2ewQQuQvYhsaCvjIR9ZcUDvbK9lNRBjAYhtxOskhyC8qKcPNwxQTmXmc95rU3DzIT396mH37esjlbEIhNzfeWM1DDy0mHD7/Eo3a2igf+MBqvv3tPezf3zN6oe4QjXp5y1uWcOON1bSR43v0s5kRhrHRgArcvJkYbyI6VqdmKtXV+ZbQ3/62xf79EiEkjpOvHfOWt+jcfPPUz49EBDffrPPDH1qUlk7MptFKOzAWHkAr7APNIdnnY17BfNasXz3peL30ckgeYlO/ZFdPCUPpIEGCLPaFeFOJwW2FcCpetJoInSxhJ10M0wdEkAjMEZ2hRh96xRChki48njjSNih7eAvsTLLrqVX887+5efQjsC0Lz/ZB92iQZp4f7i+F9dGpl2ItqoT33g7fexn2tZ4O0sQC8PYbYW39OQ/3tK1fX8Hv/d4yfvazRvbu7UbTNKSUFBf7efe7V7JgQSF7muEXW2HHcThwIv+8ZdVBInqCtpPDmCkbB0EbQVKhJbz9dz38ODpIMxksJCEM1hDkdqL45jBYc3so38XpmZF8AWGNfMZThQs+UATlUyy3UxRFURRFuZYIKeWMikr4/X4OHTo0oSbNlSoejxOJRBgeHr7oVZeVa4tlORw82Mvhw32Ypk11dYQ1a8oJBl/fOfrOaObMMdJkcSjCzVL8Y5k1yrm1tg7zD/+wmZMnh6mqCuPzGQwPZ+nuTrBhQxWPPbYBn296V6e9vUl27Oikry9FMOhm1apS5s2L0i1M/pEutpNARyOIho1kBAcfGr9HjPdQjD6NXx96eyU7dzr09Un8fsGqVYK6OnHe4r5dXQ5///c5TpxwWLxYjAVqtNIO3Ne/BJ4MciRKJinIiBS1izLcXLWcFdyIOCOA1Ecvm3mVV7rDbD65EENAyJPExMLOFhGVRbynSuP+M5LXJJJ9tLCVzWSI4zKDNO4OkK4cpKCwDUOYWDk/qUwh0uUQMHO49qxg9/dupe4hnbYyCOhQ4gFbQkcmn7Hz0Xlw43kaAnUOwq7m/BKnsA9Wz4PqotnX2ZmMlPnMmT17uonHsxQU+FizppyysiC7m+Cfn4ThFHQOQE8c/O58TZz55ZKaSIq+3hSW7RAMuOkigLMwyfK39VGmuXGjEcdiCIt1hHgHpRhz+CuVlHA8C/sykLKh2AVr/VCo3k4URVGU15lr5Tr01H5+cXgvvvDcLqdOx0d4LLLydX9MZ/y16Prrr2fXrl1XTZBGUS4Ww9BYubKUlSsvvH3v1URDUIePOiapGKuc129+c5zW1iFWrCgdWy7k87mIRr3s2NHBjh2d3HxzzbTGKi4OcO+9DRNuf4URDpDGg6DwjLd1PxoD2DzPCDcSZiHnX2JVXCy4556ZZ1OUlWn8wR+4+epXc+zf71BUJCktk/gX7QdPhkx7CSMj+f1vWOBlYUWGNo5RxQIKRltiSySNNNJjZjnY3YBfhxJfFjCwEOSMHkQ6wC97AtxUCNHR2JZAsJI6aojQShOv9Z4gFxskUtCF4Tikk+WY2TDScSEzkiF/mpKGo4SWLubp3nJuqoDKM5qULQrC0ST8rBPWRccvsTpbeSz/Zy4JIaitjVJbGx13u23Dz7dCPA1lUTjSDgVB8LognYPWXsG8kgBLl+aXJUok3akU7W06N3aGCFfmiwd7cRNEZzcJ1hJiyRwuYxQCGrz5P4qiKIqivH6owsEXz4yDNP/tv/03PvnJT9LW1sbatWsJBMZ/mVu5cuVFm5yiKMrVLJUyx4q+nl3Pxes1EEKwa9f0gzSTyeKwlSQWckJ2k4HAhWAAi6OkpxWkuRANDRqf+pSbTZssXn7ZprlvkGqtD6s5gjAFpaWC2nkaVVUCIQIkGaKfzrEgTYoUvfQwlChnOOumOpA8Y190MjgEvHF6EgEOj8ANZ2W5RCkgSgEvvLiEpvkHWVaYJJnxY1mni50IKXBsN6YvgawbZqS3HE8Wzo5LVHmhNQ3Hk7DkCq0L3tYPxzqhqhD6RyBnQ3T0JeBzQzwFvXEoGv2hKYNDxp9D73Uz2GNQVnm6w5MfHQvJUdJzGqRRFEVRFEVRzm3GQZpTxYEfffTRsduEEGMdQezZ9GJVFEV5HTrVCnmq5Uz5FsrWBW3DRI62QpeTVhPJ1/2Q5C5Ru/SyMo13vMPN/fdLdjXrHC+VeHNuQgGDghgT1gE5nP7MsLFxcHAcA8npujOn5EsjO0jyLZ2nksv4yDoBcAwcZ+JyRCEFthA4LslUh8Wt5VtG5y5Ox+45YVpg2fmW346Trxc17vAKsM+Yvw04gCYEziT7pSPIXaQW5YqiKIqiXFtU4eCLZ8ZBmubm5rmYh6IoyutOKOSmoiLEsWMDFBSMXy4mpSSVMqmvv7C1Mn40qnFzgDQZHIKjoRqBRZQ2ymkmQoIiihhgMSEW4WLu1/CGQoIbVkYxCWKTJsj4AskONgJB4Iy5+PETIIDPM4RHd0hZOgFXPogjR/9nW378GpSfo95yeURgdkbINHjwG2myuTMDNRJHt3HnBEa/F0MDJomh9efyy6nKr+BlOaVRiAXzWTQeN2NBGV3Ld00SQOiM+fvQcFkaJg6+wPjolIPERlLG+QtZK4qiKIqiXG3++q//mk9/+tM89thjfOELX7jc0zmnGQdpVC0aRVGU6dF1jTvvrOPw4X56e5MUFfnHWig3NQ1SXOxn48aqC9qGhuAWwmwjSTc5DAQFDLGAFwjSjoNNFC8h+mnnIG4KKOEuolw3mpkyd3IJD+7kfDpCO3H53HhEvvCLg80gPYQppITqsccbGNRRz0BgBzXhAY4MFFMm0iSkRsrM4E57GLF93FXm4BlM0NxrU1oaxO8fH2VZu0JQ8PMoPatqqYsdQLfd2LYPkOQ8FmFtCL2vhMzuEhauh04JEQdco8ucExZ0ZvMdnkpGYxZxbAZx8CFGSzBf/l9yQn64bRl8/yWoLMgXLk6k87f3xSEagPIzloTpCHxDXrwFaUK1aRgN6NlIWslQipvlaqmToiiKoiizcCXXpNm2bRtf+9rXrprSLLPup3Dw4EFaW1vJ5XLjbn/wwQcveFKKoiivFzffXENn5whPPXWcjo4eNC2/PLS8PMj73reaqqoLz2pZg593UsC/00+cAVbwND56GKCEQvwsJkgEA4lNhh46+QUCQZTrLsIeTpROS5580uSFF2yGkwsovmOI8rUtlFf3Ew5rCARhClnJzbjPqpMznwYSIkGy6hi7RoI83V1ENqfj2GA4NpET7WT+fjubNUkw6Kaw0M+dd87jvvsW4Hbngw4L6uFmv59fP7UK/wMpYpETuMQgIPDYAk9XCd2/ugWfE+CRpfCiB44k8p2dIL/U6ZYCeHsljODwGxLsIMMIDm4Ei3BzD0FqJ0vBucR+5/p8QOaVQ/nMmZ44JDJQHIG1DfmlUKcMJ8GddPHG9RkyPpNGsmNtsMtw8zDFRFXXNkVRFEVRXkcSiQTvfve7+cY3vsH/+T//53JPZ1pm/G2sqamJt7zlLezbt2+sFg0w1p5V1aRRFEU5Tdc13v725Vx/fRV79nSRSpkUFflZu7aCoiL/+QeYBg3BA8RYRYCt7AH6yTCPxfiowo1nNOsjh0YvxaRp5yi/YYRKggSow8USXLgvQnaIZUm++c0czz1nU1AAFSVusrs2sm9XPV2Le7j3flhcVUAJ1Xgm6Rimo7Oa6/jJ8Dz6LQNdmrizKXRNx+4eZuDXu9jbOUSN7XD99ZWMjGT5znf20teX4pFHrkMIgabBI28XpP9fIVv+5W5Sa05SUNONR9rQVETLljp8ws/vPwxvWAW32LBrOF8o2BD57k7LQmBpDv/GEDvJUoRGOToZJNvI0I7Fh4lSdZkDNT4PfPReuH6lw9N9WV7cAydP6FheaNEkiZxOyNTpHRI4Eu5bo/G+9WE6cHOMNFkcinCzBD8RFaBRFEVRFGWWLmUmTTweH3e7x+PB45l8yfbHPvYx7r//ft7whje8foM0jz32GHV1dTz77LPU1dWxdetW+vv7+eQnP8nf//3fz8UcFUVRrmpCCOrrYxdcf+ac20BQgckKmpGU4yE6dt8wDh3YtGORRCKIEaWDRg7SyTIMBNXo3ISXNbiJTVqCeHoOHnR45RWbefME4fCpluM6EVnOgWfK2DWs84Y/cY8F9ifTbkp+1e3DbVsER4Zxu3V0oP+F/ZipDPayStIH2mhtHWbjxioGBzO88MIJbr21lgULCgEoiMEff1jw0hYvmzYvoPPFBcQl+Lxwy2q4bSOsWJLfXsCAmwsnzmMXWfaQpR4D7+iXAi8QRuMwOV4gxbuJzPpYXQwdmOzQM2ytztBTbVG9EvyNLtr3uzncYYDlEDQsVtcI3rrM4N4lOoYuqMNH3SRBMkVRFEVRlCtddXX1uL//r//1v/jMZz4z4XHf//732blzJ9u2bbtEM7s4Zhyk2bx5M8899xxFRUVomoamadx88818/vOf59FHH2XXrl1zMU9FURTlPLL0YBLHRyWQX8bShsVBTDJIvAgK0dBw40Uwn15iuMgg6cbm30nwCgbvJci8WWaI7N9vk80yFqA5RQhBZSUcPmzT3S0pK5s6SPN8wmIwKwhks6QciaFrmF1xzM443kI/Oc3AKQ/T2xknlTKJxby0tcU5eLB3LEgDEArCm+6Cu26Gzh6wLAiHoKRoevtygCwaYixAc4o2WpdmP1lSOPjn4Fcj25bYNrhcTBnQ2kmGHxKnD4soOvNxYegCloKz2CQ+YJPKSYbcFhRY7NTcLCZMAxM7XimKoiiKolyIS9nd6eTJk4TDp0sGTJZFc/LkSR577DGeeeYZvN4ruBPEJGYcpLFtm1AoBEBRUREdHR0sWrSI2tpaGhsbL/oEFUVRlOmRo02WxWjQoBWLA+QQQBHaWR+bAm20/bUXQS0GNpKjWHyNET5MiPpZBGqyWdD1yftau935QIlpnnuM9GgXaGnLsZbS0nLyf9e1fNdsQ8NxJLYtEUIgRL7l+WQ8HphXPeld554HcsoPSReC9Ghr84uzaA0yGcmePTYvv2zT1OQgJfh8go0bNa6/3qC6WowFbLaS5nsM4wCLcU8oZKxpEC1yiAIV6NhoNGHybwzxfqIsVIEaRVEURVGuUuFweFyQZjI7duygp6eHNWvWjN1m2zYvvvgi//zP/0w2m0XXZ589PpdmHKRZvnw5e/bsoa6ujg0bNvC3f/u3uN1uvv71r1NfXz8Xc1QURVGmQSeIhgebFAP4OEQOjfzynPEkAhvrrFbcOoJFGBzB4jsk+Bhhis6z9CmLQyMZDpEmjs2JGyB+XCeje/Ha45/b3y8pLBQUFZ37V5aFHoFbB8fjRso0Eoke86MH3ViJLJrXgxjO4Pe78ftdmKaNEILy8tB0D9W0zMNgJxkkckIQZBCHWlyELlIWzdGjNv/2bybHjzsIAdFoPtAyMCD57ndtfvlLizvvNHj7212ccJv8kPxa7OkWL9YRNODiOBbfZZg/IEaZqkGjKIqiKMpFIi9BTRo5g/Hvuusu9u3bN+62D3zgAyxevJg//dM/vWIDNDCLIM1f/MVfkEwmAfjc5z7HAw88wC233EJhYSE/+MEPLvoEFUW5NsjRJTdZoBCN4By/yV9uOXIkSaChEyKEdo79NZF0jWa9lKJPWeDXSxkB6ojTSBPlmEDBJOPqJLHxk2I+KRxygAeBD4FA0IDBYSy2kuGNlo+OjhEcJ9+RyuM5/bHRQpYfMUArOSTgBlL1kvTbHF5qT7C6NUxxtw8pJR0dCTo6TO6+O4TPd+4gza0BgwVhk505N5rfRTpt4fO7cK8oZ+ilEwSMBK7eJDWryhjKQNOxfpYujHHddWXnO+wzshofL5CmBYtaDDQEEskADhaSG/GhTzOt1zRtOjpGkBIqKkJjnaggH6D54hdz9PRI5s8XeDzjx5RS0tcHP/2pRTojsR9JEdcdFp4VoLEth3hHEulIwuUBDM/4Lx8CQT0Gh8ixhTQPcXGDWoqiKIqiKFeKUCjE8uXLx90WCAQoLCyccPuVZlpBmr1797J8+XI0TeOee+4Zu72hoYHDhw8zMDBALBY7ZyFIRVGUqRzF5ClSHMXEIp/5cQMe3ohvTup9XE4WFkc5QjNNpEihoVFAIYtZTBnl4x4rkbxGludJ035GkOYOfNyEZ5IFTIIYa+niKCl6CVE4IYQgyOGml15WsoUIPWSwAANBGToLMPCjEZaCH3f289I3m2g/OoiUktLSIHffXc8b3lBPp27x7/TRg0ktbtynzpMHopUOm+0s2z1DeLcPMbC1lVSqj6Iih02bfNh2DQ89tIhQaOL64Q5MnhZJSquzeG03gyKElXEYztiIDQvw9yTwv3wM0+tnZ6tJtqWXUGkB9SvXsm/QzQY/XKyPonIM3kGYHxKnERMBOEAIwT0E2TCNwrtSSl5+uZVf//oYbW3x0fbrId74xvnceWcdpglPPGHS3S1ZulRM+jkqhKC4OL9c7MndWQI9GRaW62PZPVJKWl7t4vCvTzDYOoKUknBZgIVvrGbBnVVo+ul/QxqCQnS2keZOAhctE0hRFEVRlGubg8CZ45o0cz3+lWJaQZrrrruOzs5OSkpKqK+vZ9u2bRQWni7OWFBQMGcTVBTl9e04Jt9ghD5sKkazRIZw+BkpurF5hBCu0Tfkzs4Rdu3qoq8vSSJh4vHoRKNeli8vYcGCQjTtyn7jdnDYzS6OcQQPPkKEsbHpposhBtnADZRTMfb4F8nwPZJoQMnoxXTPaIHfFA73TFINJcQSOrkVyXOEacUkhoMXgY3BMBoZBlnI09xMNw5BBF4gh6QZixEc1uMhdzLJls5Bqq0sS4r9aJqgtzfFN7+5i6HhDLnfK6MLkwWTBIvKizXuC3l5aV8/TelDBAMWq1eHKS93MTyc5Uc/Okhn5wiPPrphXGZONxbfZJgT5Cj3GLy7wWJP3KI5Du6EzZq2JDffV8P+DYt48VAaFzaVlWGK55fT53j48jYwbbil9uKds9V4qcHFXjIMYuNFYwkeajEmLIGazLPPNvNv/7YLIaC8PIQQ0NWV4Bvf2EkymaOiYgHHjzs0NEweoDlTJCJIx3IMDNmsKXVxKr5yfFM7W755MB+cKQ8gBIz0pNjyjQNk4iar3jp/3DhF6BzDZD8ZNl60ijqKoiiKoihXtk2bNl3uKUzLtII00WiU5uZmSkpKaGlpwXGcuZ6XoijXAInkt6Tpw2bxGRe9PnTCCHaQY4OTQ9s/xCuvtLJjRycDA2k0TWAY2mgHHAe/38WSJUXcemst69dX4vVembU2BhighRZCRPCNZmG4cOHBQx+9HOYwpZShoZHA4SnSuIHqM96qA2h0YvFb0lyPZ0K7bBvYynW4ieDiAD6aMUiQz6EpYoQV7GQh3egUIsYCLAYCD5J+HE7aJscP9SOiGsG1hYR2JvLbDrjp6krw5LYTFD7gp9TvnhCgOcXnFbh2dpFNJ7j9nkqKRb5Qrd/vJhr1sm1bBzt3drJx4+mKvq+S4gQmi3DnlxHpcFsMNsYkTUjeu6yc+hE/LzwPqyugLHh6e4XA8QH4WSOsr4SL+RIoQOd2AjN+XiKR4+c/b8Tt1pk3Lzp2e329m/b2OL/61VFqa8uQ0j1hidNkJBK52iTdLRgoguJiyKVM9v+8GaELiupOtwMvrHMR70jS+PQJ6m8pJ1RyOhhjIDCAA+RUkEZRFEVRlItCos2oZsxst3EtmNbX2Le+9a3cdtttlJeXI4Rg3bp1UxbaaWpquqgTVBTl9WsQh8OYlKJNyEoIoJEzc/zbf+1l4JcnyWQsSksDrFhRMiHjIB7Psn9/D7t2dbFhQyUf/OAaCgrOvxTlUuulB5McMWLjbhcIwkQYoJ9hhokR4xgmPdg0TPI2XYLOUSyOYnH9WUGaDJIsIKmnhwYMhtBJI9ExKcDB4CRZPDgTAiw6AgNJUypLPJ4hUBIm6x3fqam0NMBWc4RcIk29f+pjnBnJ0b1zAG+Zl4SQFJ9xn8+Xr6Wye3cXGzZWYQECyU6yxNAm1HlxI3ABe8iQ6fUzlIHlxUxQFYbmITg2AMtLppzaJdPY2EdXV4IFCyZmm5aXhzhwoJf+/h5isappjefoIPzAACSTkuJiQe+RIeIdSYoaJnY4CJX56To4SPfBgXFBGsjXIIozeTcsRVEURVEU5fKZVpDm61//Og8//DDHjh3j0Ucf5cMf/vBYG25FUZTZMgELiWuSqLhjOzR95wipX57khvIYRUVT/+IfDnsIhz2k0yavvtpGMmny2GMbiMWurECNPXpRPNkyGR0dBwdn9DEmIGHS3ko6Agmj5XrP3ka+bko+ACOwiGGdERSSSCzklD2bNPKFiqUEXYBz1qkRIp/hIp3J92NsHqaDYznoQQ151jxtJAkDnk8naGUQG4mO4NhowG6ybkpuBBkkOZv8nk2yaZcOlpNf8nQlME0H23YwjImv71NL80zTQZvmj0JSgBQSpOBUQqudc3BsiTbJNsToNmxzYvarAKzpbVZRFEVRFOW88jVp5jbTRdWkOcu9994L5PuNP/bYYypIoyjKBYuiUYhOP/aEAqYHnmym9detrKmOUBSd3pIMny+/7GnPni6++c1dPPbYBlyuC2uv1+nY7HQsmqSNA1SgcZ3uol5oaDOsUBskhEBgY6OfFSZJkcKLjwD5NTwl6PgRxJGEJPRK6LQdkuSDHLom8GiCsz+rPKNLWaxJAjiQD97E0OjAnnQBTw4oNwwsj07GcnDlxo+TTpu4JAQ8BlkcPFN8GHvDboLlfgZahnDHTj9mBIc9MsvxdJr6Oj/VowGjNA7dSJoxGQKWY4x1sZJIEkjm4aI8CIYOaRN8Z3Wf7k9DxAsXuRP3rJWXBwmFPAwNZSYEDBOJHF6vTiAQJJ2e3ni6DVggNYk7v3qMULkfb8hFejCLv8A77vG5pInh1giXTzzTNuC/Rr7oKIqiKIqiXE1mHOp64oknVIBGUZSLwoPgFrykkAxx+tf+xGCa7U83E414aIjOrBaI263T0FDA9u0d7NvXkx8Ph1ZMurAmZHVMxZSSH1kZ/reZ4t+tLLtsi722xU/sLH9jJvm6lSEuT895RDq0ODadjo2Uk2+jnHJiFNBP31hWDUCGDGlSzGMeXvIX2jXoLMfNMWmzKWvz3FCW/QmTdlNyzJZ0moLv5HL81s4hpWTYhqYc9Fk2BXKQOB1YpMZt30ESxyE62kw6gTN2POTofW5gvs9DYSzGYK+OffJ0vkUuZ3Ps2ACrQ2EWh0P0TJGLYUuIaxrh28sgYTNyPM7ISJYRabPdydB0bIDCcj+rNxQTxaEInUoM1uLGAzRisQsTE4mD5CQWUTTW4mN5CSwqhGODkDsjYyaRlTSdTFAvB/HZmXHzkUiy9JKe5JjMVg5JKxYnsTCneE3V1ES47royWlvjpNPm2O3ZrEVT0wDLlpVw//3FjIyA45z/dSmkQLTquAokRUX5j+9oVZCqtcUMnUySS50+H1bWpu94nNKlBZQuGb+8TiJJIak6q4W3oiiKoijKbEnEJflzLbis1TVffPFF/u7v/o4dO3bQ2dnJT37yE9785jeP3f/+97+fb33rW+Oec8899/DUU09d4pkqijJXbsVLDzYvk6EDGwG07+jE6U6zYWnZrFpwBwJubFvy/CsnOHldgK0iQxwHF4IGXNxDkPm4p3y+lJKfWFl+4eQoRLBcaGN1cKSUxJG8YJtkkbxH97LJyfGKYzEk89tYqOm8SXezWBv/FuvBwzrWs52t9NM3FiAxMKinnsUsGXusQPBGx8u/Hx/g6EgW2TqCyNq4Qy6qGmLcVlNIHMm3zAzPmTCYcpPWjlHseYkCzwlqjCya7ifJCrxspBeDJkyGcJDkl7qkcUjBaMhG4ENQO+jmyGs+mhqrSfTl2H1skNZ0LxV6O349x5IlxXz0kTWc0AU/YIAhLKKjHyVSQosDR2xB92CWzqYk5pDNi3vacDsSK+pGlvsobPBT9IESWitGaCNBBB8VhCnHwzIkR8hxGIskNuVoFGLwMCHm4QIdPrwGvrodGvvBkZDoGaR722Fc/T3sDdn8j6fc3HhjNQ89tBg93EkvL5KmFQcLFyEirKaIm9GZ2AL8fBwkL5NlE2m6RwNtlejciY8NeMYt0xJC8J73rCKZNNmzpwvTdBACdF1jxYpSHnnkOsDFL35h090tKS8/9xcP25aw3UPhBgvNK/OvEiFY8+5FZBMW7bt7sXP5bQhdUL6ikA0fXDquBTfACJIAgtV4J9+QoiiKoiiKctlc1iBNMplk1apVPPLIIzz88MOTPubee+/liSeeGPu7xzPzL9WKoly5XAjeToD1eDiMScqyeHJTHxGfn3J99r/0l5QF+PGuVmrbC6mqClOOThbJHrK0YfEhotRPEahplg7POjmKERSJ8Re4QggiCOYj2WqbtDkO7dKmQGhUoJFFstOxaJUOf2h4WXRWoKaQQm7nTjroIM4wOjrFlFBMMdpZAan/2NVJVzpNQVcSw2eAy8BJWKR39NOZ05m/oJA9OZttVo6NeisLAz/AJRIM5IrJWDqF3n4K9BfpZJBd3I2FRgCBRj6N0gEiaJSi4UUjPGKw/Rc+ek5q2AUWq+d7iHiKaemM4QrX8sG7TW7dWILf76IMSS8WLxCnH5tiDNocjZ2WRiaZY+Cr+7B39hNtiOLUx/B1JujsTeD1S0o+WkZsRQwXOjYOfSRIkmMBxTTgpgSDw+RwAW8lyFq8FJ/xcVUVhj+7BXZ1wo7Dw/z6569ROBBncX2YYMBgaCjDj398mOb2Vh587AC6bwQvxQhcWMTp4RlyDFDJW9Bm+DH4DGl+RAo3UDx6vtqw+X8kyAK3nRX4KCjw8alP3ci+fd0cOdKPlFBfH2P16rKxLmT33mvw/e9beDySgoLJAzW2LTl8WLKs3oOn0KQHaywTxh/zctvjq+na30/v0WGk7VBQF6ZydTEu38T968FmBW5qL+9XAEVRFEVRFGUSl/Ub2n333cd99913zsd4PB7Kysou0YwURbkctNEMlwZcNJ8c5BcnEtSUXdiySrvARWdHhgWNCcqq8t11vEAYjcOYPCOTrLc1tlk2bY4kI8EnoFrTGMJiWEpqzlHR1S8EKSl50clxp+YmJE61DxdEpOSQdHjazrFQ6BO6UXnwUEfdOeffPZLhF31xvLpGReSMmjxuN/F4lqNHB9AroySkhlu3cTyH8YoR4s48PJog6Qh6zVI03UuEQwRYgs68sWFcCFxIsqPLXiJoHDhg0NOmEaqxsHSox0XpfC8L58GBk2CGwD86FR3B7xClFjdbSXJYZthlC2wkwZ39JPYMMm9hEW6vQVxK4lU+CmSEkf3dZHam8K4oGx1Hw43OMBl6GKGOQiJorMFDExaFuMYFaMaOvwtuqoFDvzmONznM+nWlY8V4fT4XkaiHLTv2Mm/nCDffNG8sw0WnGJ0AcfYR4zqCNJzzPJxpAJtnSBNEUH5GTaE6NE5i8RtSrMNN4Kxgm9uts3ZtBWvXVkw67pvf7CKdhl/9yqK7W1JZKQiF8gFBy5J0d0v6+qC+XuMPPuTiRNDPfxIniTO2LcOtU7WmhKo1525rNYiNBmzAf87Cz4qiKIqiKDPhOBrO2R0n5mAb14Irfi83bdpESUkJixYt4g//8A/p7+8/5+Oz2SzxeHzcH0VRrh6plEkuZ49lGcxWv3BAgJ4a3+pHSoFpC75vpfnrTIZNpk2n4zAiHToch+dMi//IWhw3BS02U9aXAbClJCElxlnXukIIyhAcdmz6plkD52yvNQ0wpAuKJilOHAy6SaVMjsdzaAh0kSMlsqRlEQKBEBAQEiydYelHw6SM1gnjeMnXVukfXbZzotEAn0NWlzRgUHIqAKBD2AebD49/voZgNQE+TDH3OcVUSD934CO8a5iAYeAePYdBBCkccprEXeyhf+sAdvbM8yLwYDBICmu0NpFrtINV3znaRCeTOXbs6KC0NDgWoIF8fRfDbWKLJEd3hSYEIwz8OJgkOD7l2JM5gskADqWTfHSWodONzbFZ9EwyDMG73+3i4x93c911On19kgMHJPv3OzQ2SjwewdvfbvAnf+JmwQKdW/BzAz5OYJFiYuemqQxj043NnfhZPYulXoqiKIqiKMrcu6Jzne+9914efvhh6urqOH78OH/2Z3/Gfffdx+bNm9H1yTu2fP7zn+ezn/3sJZ6poigXi23n2z/PsHHSBKe6GznW6YtYW8I+26bRdnA0ySoNCie54G6VMChhhyWJS1hhMGknp/wWxKSXyW4EcRxMKSd0YJqOjOUgBWiTxHjG2jfL0Ui7cHAAR55+S9cBLxp+KbCEhhwtb3vmVMRom26bfLBmIOdgGRpLcbEA17jghsuAVHbyuQoEQQx8mETRsFImuuv0cdVEvl6NFKC7BU5O4pgOuuf0+7iGwMYZrZiTJ+GcIQjTdLAsB5/PRTZr0dExQmvrMIlEDgeT4RQEQ5L+XpvC4vGfGQINh9w5Rp9ke+SPnzbJCTVG5zpZW/Tp0DTBjTcabNyoc/y4pLvbwbLA7xcsWqQRDp/epgfB7xHGAbaQJoZGETrGFC80E0k3FmkkbyDAA4Qm3QdFURRFUZTZchyB48zt94u5Hv9KcUUHad7xjneM/feKFStYuXIl8+fPZ9OmTdx1112TPufTn/40jz/++Njf4/E41dXVcz5XZXoyFuwZgN19MJSDoAtWFMCaovx/K4rXa6DrAsty0PXZJ/uF0ZCA7stfnEsJB2ybo7aDS5NE0IkyebA3pgnSUhKQkiNW/o1yqcGEZUu60NCwcZ0d/QAGcCgUGgVidvtQX+THMzBEQkiiZw2ezVoYhkaxR6NZgibduBG4tTgZJ9/qOScFfl1SISRDSIYppA8HF+BFoJMPZJlIBnCQWFRWuEntM1hSqE/IPhlKwpr5U8+3XGgEgDiSgvlR2nd2I6VECEFOgiEEBoJMv0nx0iiGf/zHTw6bAG5co0GzU0WVfecIJoRCbsrLQ2zZ0sbQUIaRkRy6LvB4DKTUSI0IjuzP8Q9/Eefuh7zcdq8XTROjPaMcvJRO72SMKkXHjRi3zOiU4dFivKVTvKamSwhBQ4OgoeHcr5sgGr9PmFJ0XiPNMUw8o+3VT2UhmUj6Rjt4lWHwO/i5FT+6CtAoiqIoiqJcsa7oIM3Z6uvrKSoq4tixY1MGaTwejyoufIU6MgRPNMLxeP4Xco+WzwTY1AFVAXjPwnywRrk6DDqSQSkJCigWYkIAY7ZKSgJEIl4GBzOUlQVnPU4kAz5dI17ixkZywoYDlsQ92sCvwvZMebFaKXTapYVbQABotCSFmqDsjOtvKSWGhAah04xDg9TQR4/BoHQYAR7SXHhncFxyOZvOzhEAVpaFWHpEY4cmCWRsXK78xi3LYWgoQ2VlmMVRDx0ZyYijEzZLcXm3kGOEjBNCAjFXDl10YFCMh4UsxkUvNkkkaRzio4GFNbh5Iz7kUg9fOiJo74PKwnw2k5TQ1g8BD9yyFEbIkSSHDxeRM5bM1AqNlZqLV+wcJevLCTzfymDzMKHaCEOapBSNwe4cGhC9o+CMxbaSLDYSSTGnlyYNIQnYglCXi2YbyorBl48/4TiSjo4RTNMmFvNy5Eg/LpdOaWkAXdeQUjI4aFNcEmD5xiFyiRw//rbEMuGuB12kRRteSgmxeNrnBqBe5luF75YmiwVERgNwaSTt2GzEQ/UFBmlmwovG7xDiNvzsI8trpOnEwh7NPzIQLMfNBnwsw4Pvyl/hrCiKoijKVUo6GnKOa8bM9fhXiqsqSNPW1kZ/fz/l5eWXeyrKDLWMwJcOQHca5ofhjFUOmA40j8BXDsDHl8PKwss3T+X8BhzJz3MmWy2bpJR4Baw0dB50uai+gMyXU6JRLzfcUMUvftF4QUGaoY4kG+cVEVlWzC8zkiZTkJI6PnQqhJew5plyGVKp0ChE0IekSEBCwklbUqafbsN9XDpU6zq/o7l52slxWDrgSBwgIARv1FzcpU/d5vtMjiN54YUWnn76OO3t+TpaVVVhbn1DLSdDgs5sCk93AsgviyktDbJ6dSkuTVLksQlkXPSk16I53VR69hDRe/HqkqBho1HCrbwZP0XsJEsMHR8O3UiCCKox6MFmJznum2fw7tsM/usV2HfidJCmMARvvj1He20bLzBAFhs3GvOJcQOVFOBDCME7DQ9pJPvmhYi9dynN/3GI/oO9hIRGFEE6qlHxloWU3RgjTgZBPmBroFNOhGIC+eOLZN8eAc8G+VKLjuNAcSHcdTOUxrp46teNHDs2QDJp0tjYh9dr4HLp9PWlEEIgpSQY9LBq9Xwi0W7S0U56u9I8+eMEJQssli0toYIHcRGe9uvphGPzpJ3juKPRjuAoNgWaTaUBPiFYjZvfI3BZivGG0blptE5NHIcMEo38sqgImioQrCiKoiiKchW5rEGaRCLBsWPHxv7e3NzM7t27KSgooKCggM9+9rO89a1vpaysjOPHj/Pf//t/p6GhgXvuuecyzlqZKSnhZy3QnoTlsXx9ijO5NFgQhsPD8F9NsDQGxrURJL3qJKTky5kse2ybUiGo1AQpCS+YNq225I99bsrP0RFpujZurOLZZ5uIx7OEwzPPjHMcSSKR4/fvXMFOO0LOMjGkTQkCPxopR7DdcbjBEAQnyXRxAas1g52OSe9ofZyTtqTOdrA16EVSrGm81/BynWawRhrsdiy6HQevECzRdBqEPmkdm8n8+tdH+c539uJyaZSW5gNTJ0/GOfHNvfzOf1vNzlWl9CZzhLMOpUEPhSV++jVISJvbDYOHw16aPRo91kOk5UoCrmMEjSxRUcxyllFAhFVIbsbLAXI8TYo0GvMxKEAnjcNmsrQJm/+2JsTyWoNdTTCUgGgQFtWbbC08wiGGieKlEB8ZLHbTTQ8p3sIiIngoFBqPGj4OOBbHb5xHfEEJyV09hAayxEIesquiPDtPxyckLrJksdDRiOAlgGc0aCPZvFPQ/G0f8zIGRaUCw4Defvinr6UxR9opi/RQVRUmkRgiHs/h8+kUF/spKvJjmg5+v4uKihB+vwtJBA+lBMoGObR/hBOvVnH/0rtxMf3uYW2Ozb9YadqlQzmCO/DSgkW77eBC44OGlzXCg/syB0N0BLFLmMmjKIqiKIpyiurudPFc1iDN9u3bueOOO8b+fqqWzPve9z6+8pWvsHfvXr71rW8xNDRERUUFb3zjG/nf//t/q+VMV5m2JOzuhyr/xADNKUJAbRCOxuHQUL5OjQJpR7LbhK1Zhy4bskBAQL0BN3g0Fk1R0HaubLFs9tkOizUN96mW0wKiEvY7Di+YFu/wTC975FwWLChk2bIStm5tZ9my4hnXpmlqGqS8PAhryjhqS2rRSUhByeicHSS9UtJiOyw3Jr+ojQjB9bqLk47NCcehCzgsJQ0I3qS5uUl3UaflnxsVGrfrbmZzfTw4mObJJ48QCLioro6M3d7QUEBr6zAd32/kv//V7ewuFuxx8sVf+4EyofGgNNhg6xS6BAsCkJ9Aw+if8QwEy3CP1p8RbOB0UMGLTgSNQ1i8TIa3FQapPCOjbQd9nCBOFWGM0SUzbnQCuGglzgF6uZEqADxCsEZ3sUZ3QYUXKk7/Y5ZIIqT5BSmSeClFJ5LvR4WNpB+HHtOh6+kA1VmDdQ2nD2h1peTA/gF6+gq4blmOQCBNZ2eCaNSL15vPolm6tJjCwjPalZMvauwmipsoNSVJDmx1GH6zTtEMllY+b5ucdGyWCZ1UyqS9fYT+rgSO5bCz0MMT+4cZLIuwbl0FxcWB6Q+sKIqiKIqiKGe5rEGa22+//ZztbZ9++ulLOBtlrpxIQDwHNee5dvEbYNpwYkQFabJS8nRa8nxG0mbng1uB0SUMA8BBU/BsxmaRIbjHp7HePbGo7VzYYdl4YCxAc4ouBIVCssWyeZtbYlzgXDRN8L73raK/P8WhQ30sWVI0rUCNlJITJ4YxDI33vW81m4Iu3JZNFsGZrY00IfBJSbuULJVyykBXAMFizaBeg92WzQOawe+5XcRmWQx4MocP99Hbm2LJknzUwBl9T9SEoKIiRGNjHxwe5CPXV9IrHU4OpTm4t5sDz5/glz0pfuFIPB6dtWsruOGGKhoaCsa1oz7bHnK4YELWh4agAI0d5HgIieuM+xsZwIM+FqA5RUcjgItD9LORyvMuqxEI7sFHJQavkOEgOTqRY8+KobG61c9wm5eFFWcV5h3OkE7Fcblj9A0FcOsjpNMmPp+Bx6MTj2fp7U1NCNKcKRbzcvz4IL29SYqKpn7cmTJSstMx8SUtdh7ppaMjv12XS0fXBWZAZ6+06P3GTn7yk8Ns2FDJ/fcvvKCleoqiKIqiKFcb6QjkHHdfmuvxrxRXVU0a5epkO/kgw7Su20W+TfK1LOFIvplweDELYSFZYJwKiow/gCMOHLDgyIjN7/k1HvDNfaAmJSVTNeFyky8EbXFx3ljKy0P84R+u52tf287+/fnlLQUFvin3MZHI0do6TDjs4b3vXUXN2gr2JDIcsCVJRzAkNdIaRDRJSMvnnNgyX0PmfCEXN/k6M0WadlEDNJAvFpx1JC0SWrM2qdGuRn4ENRpkbEkuZ2OaNi/+vJHf/raJnp4kPp+LUMiNpgmSSZOf/vQwzzxznGXLSnjve1dRUTH5cp40zpTLclyc7vh0ZpAmizUhQHOKgYaJPaG991QEghW4WY6Ldmw6sTGReBDUY9Bu6rxmgfusF1q+NbtE0wW2o+Vbeo8uRTv1mrDtczXszgf/HEdiz+BNJoekdyjDkX09ZLuSBINuSkoCY9uUXheR8iDLl5fQ25vkySeP0tjYxx/8wXrq62PT3o6iKIqiKIqigArSKJdAxA26gIwN3nMsB7FHL7oiF75a5qqVk5InEg6bstCgSwLnyIgIaYLFGnTa8L1UvoDv3b65DdIs0DUO2s5Ya+UzDQCrdY2LuRixvj7GJz6xkR/+8AC7dnXR3j5CNOolEvGg6xqOI0mlTHp7k3i9BsuWFfPgmxfTsqCEvxxyOCIFI4A+2tA54cCII/CK/GuxTpv+CiUJF3XfYDTzp9BPo0tDDqTxhT2cevkPImkdyGK6dL7icvFPX97ByeebmVfoY8mSIoyzlmlVVoYYGcmxdWs7fX0pHn10AzU1kQnbrMfFQUwkcmKbbSRLcE1oe11BiC66Jt2HBCZLKESbYT0WgaAKg6qzPoacEoiEoX8QSotP3x4MunG7XSRHLHBGaGoaoK8vCQiCQReWJQkGz/3mkU5beDw6fv9UocaJeluGOLG3g6GgTu0ZwRnIvyZMQxAbzI0VdC4q8nPoUB9f/vI2Hn9845TBMkVRFEVRlNcTVZPm4rk29lK5rJbEoCYInalzP64nDSU+WHUNd3d6ISN5MQvzJwnQSAfivTDYCbn06dvLdYEPyX+mJCetuU1DusEwiAk4Zkv6LcmwDcMjOY4MJzHsfq53DeMI+6Jus6wsyB/90fV87nN38Pu/v5JIxMPwcJaeniQDg2ky2Nz4plo++ec38ud/fguNDcV8LynRkGzUNAo00DWJISQ+AT4hSUnJgAUBtGllH6WlxACqpyiKnLahJQUn0xMzwZJJSXOzQ3u7g+OMv/O3OYenykP4llZg91h4TQ3D8KEbXnJpm8ETwySXFPPM3i5e+m0TA6Uh9lUX8HRhmNaghyFHMuhIcqNBs3DYw7JlxbS0DPG1r21neDgzYa5r8RBDoxUbZzRrRyLpIX/ebsI7IXizlCJ8GPSSGg135Z/TTxoXGsspZrqyWYuWliFaW4exrImZL4UFcNN66OqFRPL07bpmYHiKSY90s2f7Hg4d6kNKiMeztLePEI9n6OpKkMtN/frr7Byhri5Gbe3E4NVkbNvhP76zD9fWbkIRL+mAgSMlWSlJS8lgxIUvbVPeefofpK5rLFlSRFPTIN/97r5zLulVFEVRFEVRlLOpTBplznl0uLsKvnEIBjJQ4J34mBETejPwu/UQu0brQptSsikj8QpJ8KwATfdxaNwsGGgDxwZfGOatlizYAIYbqnTYa8GWrKTamLtsmnIhqHDc/Ffapj/nkElnqeEIi12HqBzuY5/HIFdZxUL3MmpYgLhIcWAhBFVVYaqqwtx//wLi8SxHs8PsdvXRF8iRDeq8Sj97Tckz6SJKNI0iXQA6KyXslSYDSOI4uBC4BbgcjbasxiIdvOeZZocjqdc1lp5VF8dy4OkeeLYPerL5jLE6P9xfCsu9kl/+0uSFF2wGByWGAQsX6jz4oMHy5TrNtsM3e0xOHDewG9YxSJKOnEBggZXC0ZKE1hVRd08ZzV/ZilMTIXnvQtLVQSyX4LAtcXdniO3uprAvwTwdFrjA0DUWLy7i0KFetm5t5+6754+b8zwM3kGQH5LgMBYCcIAIggfwsZaJ2SjVhLmDebxEKycYRiBwkIRwcyvV1BM97zm0bYdnn23mN785Tnd3AiEENTUR3vSmBWzcWDUuWPbW+2FwGLbshGzu1FJJSSycJuE9hJnJoesCj0dnZAQCATelpQFaWoYBWLeuYkINo2zWIpOxuf32edMuRH34cB+HD/expipE+9E4B+uDnCz2kJP5UJUnabL4wBD+eG7cek5d16ipibBvXzfNzUNq2ZOiKIqiKK9/joac60yXaySTRgVplEvizgroSsGvTkJ3Bsp94DUgZ0NXOn+xe1clvGXe5Z7p5XPQhOOWpPasf5Xdx2HLTwTZJISKQNchNQJ7nxEkh2Dt/RKhCQqE5KUs3Oc79zKp2TKl5OsJhz1ZQX3KJn2sj6qCFpbXHMJtOsiTOsf6k6TTLaQWDWPqORpYcdHn4fEYjBSn2EwfSczRHA+NuMyxmXbingwNds3Y46uFni+IKyyOOjZRBEE0XAj6HUGHJal3T53tYEpJCrjVZeA6c6mLhO+3w8+6IKjnGxlZDhxOQFNSUrbL5PivLWIxqKwU5HKwa5dNS4vDo4+6ebHUYccuAyMhsNHQiqO4MzZWzsbWvHgKC9BiXvp2H8dJW2Q/uByzzIOWcpAjNrYhoMbHQLQa/blW9vYlSUlY4wbD0PD5XGza1MLtt8/D5Rq/NOp6PNRhsJccg9gE0FiKmxr0KYv/rqKEakIcZ4gEOQK4qCNKMdMrwPuTnxzmP//zAD6fQVlZEMeRNDUN8uUvbyOXywdPTgkG4I8+AHfeBIePgWmBnRviyZ9sYeHtMWw7Sm9vEsty6OwcYWAgQyDgJhDIty6vqgpTWRkeGy+Xs2ls7Oe668q4/vrKac0X4NVXT5LLWQT9bmKHhxDtwxglPgKGhjvjILqSdKQs9giN6zRjXAHqSMRDa+swW7a0qSCNoiiKoiiKMm0qSKNcEroG714ADRF4sRMOD0FPBlwaLIzAbRVwUym4Z9HC+PVid87BRHBmWRnpwOFX8gGaoprTP9ZHvODxQes+mLcqf1+ZDkctOGTCujnIRtpnwms5qNMlB4/24WvvYV39UYyUQ58sRBRmKclodBzIUl5sc7z4AJXU4+PitiR2kLxGDwlMagmOBRU80kvS0nC5B0lnC/E7p7vrBIRgvebCcjQGpMQzWmxWB5pNmOeavD28LSWNjmSprnG9Pv7FeTKdz6ApcUPxqeOtQ9gF2zokO/oEd9UKCsKjrcp9EA7DwYOS//q1yUu36JhxQUkBNHcKXG4IBXQStk485cYfk0jDoe+Fk2gbapGlHhgwcUZX8+iWhIyDVeIitayIypeSnLCh1oEiHSoqQjQ1DXLkSD/LlpWMm7uUELJ1bhE+XNrkRb1tCTkH3Fo+QwigAB8F+GZ8zrq7Ezz99DFiMS/l5adrtIRCHpqaBvn5zxvZsKESn+90rRjDgJVL838A/vVfm3HsLP8/e/8dZlt21ve+3zFmXDlVrtq1c+odOie1pG6BJCSBQAHJuhgwSYB8uPfaHB8/9j3GNsfHj881Nhju8TE2wZhkDmAhCSWEcrfUSR337p1T5Vy18lozjXH/mLXz3oqdpB6f56lH3SvMNddcs1Zp/vod71urlQEYGkrPq/37B3n66QWmp9OpXlGkmJ5uMjZWIAwTFhbaNJsBt902wi/8wl3fsG/NRa1WwFNPLTA4mENrzRmVoOqKrfXoquMVIJjVikk0g1cEXEIIqtUMjz46y4/+6C3XBWWGYRiGYRjfU5RIf17q13gNMCGN8bKRAu4fhvuG0v403ThdCjWWTUOc17p1Bd41M3Kaq7A+D8WB6y+k/Tw0lmH5QhrSOEKggLb+ZufsfGuOhIpYgx0mLC11GB3t4vkdOu0KrpXQ8R1ExkHV+6zPQGGwwxqLTLDzG2/8W7BOwCxtBq/pndLRECmHnOzTtdpXhTQAGQG325InY8Wq1lSFxheCthKb04Wu1tea00qzQ0p+1nMpXZPiHGtDI077LV3LrmuavkANSgguV+kIIRgfh+fmEy7MWhSzml4giGLIbi4DjAUICUEbCgMJjY0e1t4dCK1RSfrZXvx10YDVVbTHc9iOJAoUK5shTTbrEEWKej3tS6M1nG/C40vwxDL04/ScKrnwxjG4awiKHjzXhkfqcHazv44lYGcG3lCGw3nwbvC7qjVEpH9QbhR2HTu2wvp677qwCGBiosjZs+ucPr3O4cPD1z+ZtMHykSNLlMvXB0Sua3HXXWMMDeW4cKHOwkKL06fXLo3m3rKlxHvfu583vnErpdIN1lreRLsd0u/H1GoZOsC6VhS4/vfQE9DQsKIUg9d8kWWzDr1eRLcbUSqZkMYwDMMwDMP4xkxIY7zshICxF7e44ntCpK+/wE2itAeNvNlvqgAVC9hs5iqAF7dt72V9nU5JShKNUhrb1ghAa4HUkEiBFgIpBUmczg5SL8HeRCgS9HUjoS/1ZxXc9HUrQnCvLXkmVqxtNoD1EMRK48q0N0tDw6LWSAGHLcnP+C5jN2gYHKr0eN+oCkXHoCUkN7jPdSEMIEkEtsOlypgrHypFWkWlldocK2Uhkouf8jUUYAmUZQHqusbFSaJZ6cEfnYTnVtP+TxUvDUi1hrkO/M4x+C9nIcqD76evX7HBFukSrsca8HgTtvrw/iG4q5g+dzqGx3vweB+6Op2UtdOFB3y41bsc6IRhgtg8N67lOJI4Vl+34a/W6fuwrBuHj7Yt2bGjwrZtZc6cWafdDvnFX7yLkZE8+/YN4Pvf+p+6i+e5EIKEdFS7dZPwU6BveMZ9OyO/DcMwDMMwvisp+dL3jDE9aQzDeDkVZBrUXClfhUwBek1wrhmgo5I0IMjXNqftaI1C4L0EVTQAk7YgAjzfJpdzqNddksTBdkL6IocTJ1hxQpJoijWJjU2Ob26KzreijEsBlyYhg1csvXEESNJj4OqbV0yUhOD1jsWy0hyJFA0U57RGqDRkyAl4wLF4nW1z0JK4N5n+NOqlIUaQpIHHVfICJ9RkA8W1VU1ra5qRYcFcWdPekJRzaSgSJ2Bb6ci9JAHbByVspGMhFzqordlLW7pYKyUA7QvctQjZj4D0PAIuTZLqWC6/8Rwc34CtedhWuD5YOh/DY33oNmBfDPcOp+/tyvcaKLjQh/84C393BGYs+FI3DbUqIq0oiYDHeunPThd+tpj+7+hoAceRtLshSVaxTp8+cdqAeF2RKTmMjt6gJGmTlILBwSwnT64xOnrThyGlwHUt9uyp8Za37PimGwTfiO/bOI4kihLyOGSEoKc1zjWfp9KgERRucJ5EUYJtS7zrThDDMAzDMAzDuLHXRhRlGN8FdtiCGIG6YmSvm4FthzW9FvTaELQCeo0+UT9hdRZypYiMW6e71qWuIZMAXZhppRePL6a7XcG4BWe1YOv2CvWNHCsrVdxcG+yEcrNPfbVLoWKTH48YYJTqNaOZtYalFTg3BfXGt7cfGWwOU6VNRIfo0u0lqXGdDlHsk4+/fjhkA6MCRoTkF7I2v6Q9PtDxeG/k8TOOz085LnfY1qWAJklgegEuzEM/SLdxuAi7c3CqC5GCVj9maq3H2fUAVYLdiWLmBU18xVj0el1Tr8MP3m3xuu0QaE2yudSpH0KigCCtnLJzmiCwyB0cwn5yBnoKKg4WaUiTAOQshBBUTtfZUJDP2Pgln67nsLzcoTSQ50txjRMbcKACZe/6gGYpSSeDZSzY4sJsG46uXX/MPAl7s+nr/vNp+JM1yEs47MCkA8M2jNqw34UdDpwO4d/NhXzp5Aalksf2fRUePjPNc+Eys7TYoM9Ct83R2RWiuxRzY61LI8EhDR3n51ucP79BqxXwhjdspdUSrKzYdDo2N5psrZSm0Qh48MGt31FAA1Cp+IyPF1ld7eIIwVYh6ZMug7v0ehrW0RSFYERc/3praz127KiQzTrX3WcYhmEYhmEYN2IqaQzjVeIuV/BhqVlWaRPgi/a8DhaONzn1SEC/FYPWCEvgeX108RxfOd5EZhyah/YxvHsrv+25WDJt0vyD2+GO69uAfFsGLMEH85LfbStmJspkteBrGz4HvKcYt+Zw4zaZ3TbbJqtM+JMc4r6rRnBPzcJffQqOHE/HKuezcN+d8K63QflbLLi5jyEahBxhnSV6aW2DEOwUGU72J9DC+oZteTY02DHUH7f4DzOCs1XoFqFagsOD8PYyvCMLzx2DTzySBjRKwXAVvv9eeOt98MGt8OunE/78hTbLq12iSCHRTMiQX9ybZ3qpyIkTCq01WqfNg9/yFosffIfLEIpnGwmtKQtbaIQSNJogLfDLim4M1Yqi8gOjzP5f50m+Mod43Th60AEtQGgIwXmhQXJ+g2j/KAyXeMS2cJIELV1eP1LlTM9lbxnsm/SSOZukbXMGxWZllgNTbdhRhOINeuxqCdMR7OxB7QZVOQCyH6E/c5rPPHKBR1p9DuWhMxrCkKZ5OkAoQIPtSg7fN8ztPzbKl8QMAsHdjHHixCof/egJTpxYJYoUvu+h9QgXLuznuecEtZrLyEiPffvqlErR5nvRnDq1xuRkkXvvnfjWTqgbsCzJgw9u5YUXlkkSxQ5p0RGaaa1oaI1M3wIlIbhN2vjXHIgwTEgSxRvfuPWq8eKGYRiGYRjfk/TL0DhYvzb+P5UJaQzjVaJmCe7z4K97giGpL43zXTu1RPvYk+TxKI4Oo2LYOD1NZ2Uaf0eOsbvGmHNGWFnPII+tctedQzi+w9G1tFHs3z8Md924H+u37LAr+NWy5KlQs5grU1+yUaduJ88QufGAHTsqjOdHGWIcm8vVA7Pz8B9+Jw1qJkZgoAqNFnz0b2B+Cf7Bz0H2m5vkDICLxTuY5DBVpmgTkVDDZ9Aq8tvS4lgE+2x91cjsK7WU5lxfoI7AF8/A0i2gXMh2YX0OnuzBooJHn4Olv0lHQI9tjj9fXof/+rF0/9/9ZsXUp55m40ST7EiZXNbBrrdonZznd2oZ/uUv3Ms7wwrz8wrPExw4INm7VyKl4C4teff+hM8NxuTqkqQv6Mdgu6BdzXxG0S1oIlFF767hf+oUA+s9NnbUUHmXWpQwMtfA3+gxd3iSfiVLth/hdgM2woT2/nEeGx5gS+sGy7E21XVaSVMUl8OWrA3LvbRXzbUhTQJciNOR440AGhGUr3mMihXP/MkznH/4PLlKhnCowIbVYObMBtVihkPvGgIhkJZgZHeeLYdK2I5kjS6PM484Cb//W8+wvNxhYqKIEJIvf7nDwsIJRkfHGBwcZGMjZGMjx+rqAHffvUCStFhZ6TI2VuCDH7zz0uSn79Sdd44xPJxnYaHNxESR26TNFjSrOm2ind+soLk2oAGYm2syMVHitttGXpR9MQzDMAzDMF4bTEhjGK8i3+9Lng4VpxPBHkuDhhOfOkXYDpg4mEeIDdbOrNPoL1IccenX+7Qil/rIMDUVYS1vsDzvsn/fIMUqnNiAj52D2wZvXEnx7ahIwZv9zYvSfAl2loB9X/c5f/swXJiBQ/vgYg/ejA/lIjx9BL72PLzxvm9tPywEWykwQRaFxsZCSMEv5jX/V1txLBJUhGbE4tKypbbSzCcQIRhfgfmnJeoegfBhOADhQNGC1RVQGfjEl2BnDHduvfy628ZgaR0++wTU5SpPf3WKrWMFCkkLWoAFQ/sGOHt8mT/83Gk++s/uR96g87MvBD+XdVADEU+UEkoS9ktx6YJ/I9E8H2tmEsHI+w8Rrn+N3pNz3N7ss9O3qG020T01VuN8NcdQo4ulNa1WgAwSDo6XOeI6jBZvfgwXEgiB8hW3CdJQZ7oFe8qXx28DrCawkUDVhvUQFnrXhzQrJ1eYfmKa8mQZr+CxlCjWMzFj1QL1F/qsz/X5of9lz3XVJRUyTOkGf/LJIywttTl4cAghBOfPK3q9PBMTLt3uMrffvoVez2NqqsHios+zzzrceqvg3e/ex4MPbmNy8sXrg1Qu+7z97bv4oz96no2NHpVKhgEEAzdY2nTVMVjpEIYJ73znnqvGihuGYRiGYXzPUps/L/VrvAaYkMYwXkUmbcHP5SW/3VacSARDCw3Wz61THC1cuqhtzjexXImbc2l2OiwEDp60mBR9ur7NzEyTffsGEAgm8nCuCVNN2Fl+Zd5Tvw9PPguDtcsBzUW+lzbLfepbCGm0hobusyAWuSBmaNNFb4Y0EwyzzR7nlwsVvtyHLwdwJkmfo0mb2+51BG/0BB9/RuBVBPUMFKPLq6MsCY4NiyehuQz2luv3YagCR8/CJx5ukcQJhcLVA7ylFAyMFDl5bIXji10O3GScWUUKfinn8OUw4UthwlSiiTb7stjAfa7kHzgWuyoDLP3yfXzk955i5swajbxLbqyA79vMDhRwopheK6DTifA8i0OHhiiMlzm6Klj1gODGx/LiRKbrxkrLtFFwpNLqoYs6Kv3b6Mr0ed34+m0un1gmCRO8zWPiyZguMSPCRo77zJ9o0lgKKI9c3dxZIojWEo68sMz+sdql831+XiElZLMe7XaTVmuD2247xJ49Vc6ejfC8Af7Vv/IYGLh+PPeL4R3v2M3aWo9PfOIUQZAwPJy76fIlrTVzcy3a7ZD3vGc/b3rTtpdknwzDMAzDMIzvXSakMYxXmVtdwf+rIPn9tuJIL6EeKVxH4m72K01iRSIkHZUuPykLgS/BVYLAEsTx5YjZlekI5egVTJ2jGOIY3JsUFDgO9Ppffxtaw1wEj3cUX03OEHrnELJLTtqM2S4DtkCJiKOc4RQXGLYGeFPuID+QKXAihrZKu6QPWLDbTgOB/9EHy0mXzlrXNKG1JERhOgZb3GCpkNhcHtQLNPIG47kh7beStBS9rzNaGiAnBG/3bL7ftTgRaxqbjWnLQrDPFpeWbN2yt8qt/+T1PP74LF/84gUuXKgTx4rFyVGinqKqNXv2VJmYKDIwkGW+I5AKYnF5GtS1bnZaCJEe82ubT6trHnOjydJxECOuGbWdvr7AciUq1iQ3OSFVqIljhetePuhRdDkoEkKQJOnxdF2LWk2glCCXu/k0r++UZUl+/McPUyh4fPKTp3j++SUqlQzDw7lL+xkECYuLbRqNgIGBDD/xE4d5+9t3m140hmEYhmG8dphKmheNCWkM41VovyP4lyXJV3YU+RfVDKtrPTqjTjrVp5whbmwwgIuyYK+bMAUkCHq9mK1bS4jNS/K1PlQ8GP4W+r18p2ZVzEfDPo+HMV0Ng0LQ2O+jnnIYqF4daGgNnS7s3Hbz7UUa/nID/rapiDJHKObPIZWHimqsaMkyULbg9iyM2QX6BMywSIceb5B3codbvuF294zD7EnIxNC102qai4IQRkahmYVOBCdGYcNLe5UVQqhtpEHOvm0ex48qVKKR1tUX5I31HuVajl1D39zBd4XgsHPzi/qYmF51jcG313n7WxzWF4tY6wW+FOZ51qtwV8m5aoqQLSGxoBLfvIeyK278ty7ZrLC5doncxa3rzcd4N8inimNFtNKoRCEtSaIkDpIIRXstJF9zKQx4BBEsrMNyIw3yMi6ossVANcvaWu9SdVK1Klha0iilUEpRKl1ev7W+DocPS/yXLqMBwLYl73nPPu69d5wnnpjj4YenOH++fikQdRzJ+HiR9753P3ffPc7IyM3HiRuGYRiGYRjG12NCGsN4lcpJwVsHPOpv2cbv/+lRBoKAbNGnsaPE8wsN+gtNRkcLHCglNFWX2cAhZ1tsnSwD0AphpQfv3QWVl/giNopgdlnx5/T4sBexrtNqFVtontcQvC4k9DTqnM1B30YJxbIKmVuGgarDfXfcuLNtouGP1uCTTRjJnyGTP4dUeQQ+WJDVsKEk50KLhUByTw52Z2BE1FhinUd5lu/jPrKkB0BrzeJim34/5o7JLE+e9igswPwkuAl4CTS7afNeazsU74PjEXgluNiGZ6oQExRidm+R/MODVR75WpELZ1YZ2zuEznlIpejMNei1Q37sR/dTzqZfsx06BAT4+GS5cXDT7mieO5GWp9x+iyCb2exPwzpP8RRrrKLRWLaNmkgIJ1a5PV6jXT/MWjRJRl+ugulZkLUg2wS8G74cNXn5OF+ZMXVimMyDc00IM2hDRkAzSYOfJIbpOgzlwd/8azJ22xil8RJrZ9eo7qgRYzOKz0a9TtxIuOtdYyx3LI5MQasLUqTL4GIrhtBGFnazdOoUxWKPajXDli2S8+djZmY2GBwsMDY2gtaapSWNlPCmN1kvS8WKEIKJiSITE0Xe9rZdXLhQp9eLEEKQzTps21bG982fVMMwDMMwXqNMJc2Lxvw/SsN4lfvhd+5lbbXLI49MszrbQAjBQDlDHcjlXM6fWiXvdSns2EtxzzjLdo6lVcjY8NAEvHvnS7dvWsPDT8OnH4Uv6oRj+yxsX7CtmjA0lmz2OtF0h2KOH5Q84mnOH+0SbqmTDPaxDmq6wubDC0U+NFLDv6Z049EOfKYJE04fL3sOrT2ETgOX+cTmRFBkrlel08+QKIsvroXcV2jzulKd/TlYEmtMMc9+dnDq1Bof/egJjh1bIYoUxaLH6NgkamYva8plbgASCV4Bxmvg2jC0H3Iz0FyEWCTILRu4wy2y+Zh2TvBf7Cw/8w8O8xufWOZIMY/yHUgUfqHIg/dJ/vGP7KJNm2O8wDxzREQ4OIwzzn4OkCetuIhjzW/9QcKf/1XM8mL612d4VPKB99r85N9r85h8lAYNqtSwr/jaVijq1ga3lp7khYbk+WACSfr3q2bBuzNwYh60e+NR2SMSShJaCsqb98ebS8Mm89dX4GQFTNjwSB2iAOZbgEiDmz0FeMM4ZMoZ7vzJO3nqj55i9uQqQmgSOyLMJ+x8W5Xa3SM8dSYdZz5QTAMaJRRBqYO7XGHd240a1Mwtnmd2tokQglJJEcc5CoXDnD3roZSmXIb3vtfm3ntvMrrqJZTNOtxyy+DL/rqGYRiGYRjG9z4T0hjGq5zv2/z8z9/JG9+4lWPHVgjDhPHxIqOjec6cWWdjo0+p5LH74BDLboGZFrgW7KvALdV0Wc5L5bOPwx98DEJbMfXGGMvTeKuS+WUL4ojhrWln2awU3DoZ8YwjWdnSZKjVJd93yLvQ8yL+R7TC+uMJv/q6IeTm5Byl4YutNFzIZxYJrC4yrgEwnzg80R9gvVsgCDJINLaMaWiXJ9p51gOfaEAyWWxyhmnkmQK/9ZtPsLTUZny8iO/bbGz0Of3UCxy6vc27ttzDWWXRyMP4EFRy8IkNqGShuh+WRhXnssv0Ck0ywqbousRCcZIWpwdqbH3XQYZWekTNPsKxyN41xnDV47Tus8pXWWGZAkUyZAkJOc1p6jR4gNeTJcv//v+L+f0/iLAcqA2k0cjivOLXfzOkvvU4u7+vzhBDSK7+MCWSqqihnFXeXDmK3RuhmdiULLjdgyQH//sczHZgyw1W4DgCtkp4TqXLymyRTm2qfZ0lchsb0AzTXj01nQY5fQ3PNNOlYe/YAYN7Bzn0yw/hP7vAHa0WryvZuAdtTu+q89Rsm8jzqbo2CE3oByRORGajzOCF7YxP2jyvbuP2kS3sryzT78cMD+fZsmWYc+dc1tY0hYLg1lsttm4Vpu+LYRiGYRjGq4GppHnRmJDGML4LWJbkwIEhDhwYuur2vXsHrvr3rz8I+8XV6sDHvpQ2BO7sj+nnNaUuWEVNvwPL0zaVkQTX2+wuq0CrkG7BYzyIydrp7YXEY0NFfFU2OLJY5tbRtFLmTAAn+jBmQ+zOILSNQKI0nArzdGMXFbo4IsG10jBIak0XSQfN440qu3LrrMsN/vqJ51lYaHPo0NCli/pMxqFU8jj5wiw//I6tvO+usUvv7U/Xoa9gYLMCJTPcxcu1KCsPW6eVGy6SxY7PbNvltlzAQ8UCgrRfitbwQh/+e6PBrd4KQ2IIi/R5Dg4+PqssM80U3sw+/uLDMX4GxsYvhzD5vGCt2+Rsd5Y9nTwyd/O0rUyZDWude/OLjDNx+Q4HPrAbfv84zLRhInd9Rc1OGzY0TCWgAyjZcPvg1cufLtoI4Ug7XWaWz0HggqOgkEA3gXM9ONkEkQFZ8PmFt23n/YXL2/rkhSZfObFCfscGkdsFLXD6HuWZCQrLg1iRAxLGqoKp/gA///0DDJUvv/7evTc9BIZhGIZhGIbxPeEl/G/shmF8Lzs5BYtrMD4IKxmFFmDp9Grcy2rCvqC9cfkrptFUSGISbBrXLGsqKZu+nfDwfOfSbctxWqGRtxTK6qZrdoCGlqwnWWwFibZw5OU50DaKREuUjFiPXJaCAkEUc3p2mbGxwnVVF7mcS5Jonntu8arbj/agaF0ONJp2Bw2XApqL4tglVpKWDAm5POpICBhz4LleTBAXLwU0F1lYuHjMMM3fPpJQr2uGRq5PRSb2t9Ben/mprz9e2sZOlz5Rv+6+7xuHn9pM745uwEIXkiv+K4RSMBCA2wdtQ62UTlPaHDKVjjyP4UQXnt4AYtjdh8kmDHZAaOg6oH3ouXCylVbx/L/L8HcKV4c93bkiPL2TySOHGHv+YPrz3CHKc2NpQLNpoAgbbZhe+bpv2zAMwzAMw3i1UC/Tz2uAqaQxDOPbEkbpiGrLgkTAlR1MLoYb+oov0jQYSK/81TXdTqRI51GFV8x8TvTFLSquHCKttEAhEBrQ+qrKkIv/eHF8dKwlttIorXCu7YK7ybYl/X581W3R5nSjS/si9A17uujNfUz/Zlw9j9oREKPR+sZfsxYWEXE6flyDfYPSFctVoCCOvrklPfqafYD0s3jLFthehK8uwKNLcLx+xWsImMjDe8fAz8GRLrzQgekgfW8ayEm4vQCHEji/mDZRthMY7kCtC103HWU+l8DrgX9cvXEPnESljYLtwMMObtLNmLRPzcXHG4ZhGIZhGMZriQlpDMO4ZG6uyZNPznPu3AZKacbHC9x99zg7d1auq0IZHYBcFhptyMVpVKE3h3/HEUgrrai5KJcR6K5EoPDU1VffAQqpBdvz7uXHb16oN5VFN7EJREiUgFIKgaKLS6IFQWJhS4UU+lK4bmGRsRIqTkBiScr5LEtrPUqlq8dcKaUJw4Rt28pX3T7iwHSY/nNrMWbpGEzNabwoIjsmqN4uye8Q2FYaIGW0xBVXh0BrCQzbFo7dANIms1rDwnmPk09mOX0+j1ofpbOgaSmLI0sCf0Dj5DQZoBBBsuxR0ZLKQMRNRzRxOZzxuPkYr12l9Oed2+BUA3pxGpgUHNhfuTyd6e06Xba0GEKg0jHbIy7syMCJLPzROahruLjQzu7PcGYAAMfpSURBVNZQDNLlYb6GeyZuHNAAlLKbgZa6HMTcSKefNm4u5W7+GMMwDMMwDONVxPSkedGYkMZ4TYvRLBOhgCFs3JdwBWC3C0ur4NgwNvL1L1K/HWGYsLDQAmBsrIDj3HjqzUq8Oc3HgurmQ6Io4S//8hif/ew5Njb6+L6NEPDVr87wqU+d4Z57xvl7f+9WCoXLQcG2Mbh9L3zpadhStDg6rAi8BD+JCdo2+YpCyoBuXePmbHJZia47eKpPNo65uNoy0pplP2C8n+GtOy9fle/yoAt8sieYcIYZzp2mERfohRo76RKSRVox/djBtSKE0ETCIScDVOKws9gg42ygRZY33LKF3/2bI5w7t87QUI5czkUpzZkz64yNpUEUQIKiQZv9OYtH6j7PfrrL1MMdOo2Ybl7SkgniCcHCZxSVOwT2+zuUPBvCLHjiUinPRgxtBT+Sz5CVFg0aOJ0yn/ujAV54NMfiisV6u4Du5+lF0IkEeklgBxI7B+5IwpKtiE9U2RlVKY9enqPd78f0ehGuY5HLOSAEHTpkyDDK6Dc8Tyo+HNYRi4ttbFsyNlzAuqK7tBCwM5v+XGv/INyfg093wU8gv3n+hApmNGyX8K7dN3/t23ZANQ9rLRgs3fxx8+uwazT9MQzDMAzDMIzXEhPSGK9JGs0zdPkSLRY2u4kMYPMABe4nj3Xd8OFvXxjCp74An38E1jbS5UG7tsE73wK3HfzOt6+U5otfvMDf/M0Z5udbCCGYmCjytrft4g1vmLxUATMfw1+14Jkg7fWSFXC3D+/Ka/7m/36Bj3zkBIOD2aua62qtaTQCPve5c0RRwt//+3fjeenXhhDwEz+YVj08d05T3b7G+qRFhhaVkRjdiTj3tYSoIXEzFvauMqXRPFumFBtOyJpMqz+EhpG+zz/ZO0LOvRwsfakH6xL6MYhwDJG5gIoDhPYYtRokwmEjUyLu2fRiH41AkuC6MYXCOiPVWVZFi53r25g+E9JsBhw5srw5vtlnbCzP7t01fuZnbmdwKMtZ5jnKBdZoEWQEc5/dxdQnHMZrDqP7fALLouF0iFBETZh+WFONJH/3FxXrXZdj/XS/Ly4PensR3l8sM81tPBse5SO/73HsCy6ZakCXQXw7gz1kEWhNsSFozyuSDYHqCoJViSgq7AMOF8b3cLL3JH2arJ7uMjfbJAwTLEswNJxn1/4SSanDXvZdGul9M3Gs+PSnT/O5z11gZaWDZQm2b6/wgz+4+1JQ9Y386/th7cvwbAjzSXqbBLYK+LU7oXjzYh5GKvDAPvjYk5D1IHeDx6400qVqb74V7Jd/urZhGIZhGIbx7TCVNC8aE9IYr0lP0uHPWUejGcRGIlgj5i9Zp03C2yi/KK+jNfzRX8KnPg/FAowNQxzD0RNwYQb+p5+COw5/Z6/xiU+c4k/+5AiuazEykkdrmJ5u8J//89cIgpi3vGUnKzH85gacCmHchppMq2k+2YHnlnusfmmKkZE8xWqW6QiaMQQaBALX88ltrfHIV2e5555xHnhg8tJr18rwyz+h+PO1Y2xhjq9Ek0wnJXQ/IFuIsQ+6dFaL1AMbOd/joZk5/vvb9/KFqRLPrHWJtGJPweeHbitQzV7+OlqI4eMd2OWCVDDXqyKtAQr+AjqogZBsY4WKbBHYGdqhR5RY5EWfHYUGh2vLtKwO7XbC0f9zkYVnOuzbV2P37ipzc01WVrrk8y4f+tBd7N8/yElmeYSjaDQlcnQv2KjHWniDWdZLNloJsriUE5uWDFF+wsBWweBT8KYXMtz6OsFTXViMwJdwMAO7vbTXzm52M/PMEMuP9Ni3PeDcTAmrn6VckEzF4AqBWwHflbTXNcmwIIoFuW0eOz4omJHbeWq+yUzzEZxul0w2SyHnEOuExWCJ1vk13rjtVg6WD32Dc1Hz3//7ET72sZPk8y5jYwXiWHHixCoXLtT5xV/U3HffxNfdBsBYAf78rfCx0/DoclpFc6AMP7oHhr5+RgTA+98A9Q589QR4DgyXwbGgF8JiHWwJ77kfXn/LN96WYRiGYRiGYXyvMSGN8ZrTR/FZmkhgyxV9PiZwWSHiEVrcRY4BnJtv5Jt05jx86dF0eVO1fPn2Qh5OnIGPfQZuPZBW13w71td7fOITp8nnXSYmipdu37WrytRUnY997CT33TfBl7THqRAOuGBvFgllJFQs+JuZEKpFYq14sgWtJK0GkZuPUxqkcIgDwW//zRS77t7CsHu50mjdXac/PM9EmOeeXptwqc+yKNGSFWwnRg4K7MUMpQiWjzf4wu427zxQ4Z0UCDcb7zrXFC492Ut7uhxy0yVZD9cFzy8d5NbRLsXMOu1+FSkled0nK3rU/IS8rchZikhJEhWRtxTHH/OZfm6N79u/BXezSmd8vEiSKI4eXebZZxfZub/Mc5xFIhjYDOfOP+7idiwO3tJiLYpIwgFaiYVG4imfnS5sK8PyWsjDX+7xhtdn+P7ijauvtNY884hPEYeqsDhSt6lkNU0NMXBxgZeXE3TboHOCoe0WUR2sdciOC859pcz5rxW46805alu7YIXYSuDXq5z7MCzsLuP+hHvD179oerrB5z9/nqGhHIODl5eVFYsep06t8dGPnuDOO0dvukzuSr4D778l/flW5Xz40Dvg0Db44tF0glOcpNu8exc8eAju2nXzvjaGYRiGYRjGq5CppHnRmJDGeM25QMASEVu5/qK2hs0ZAs4QvCghzbFT0O7C9smrbxcCJkbh3BTMzMO2Ld/e9o8fX2F1tcv+/QPX3Tc+XuTkyTVOnFzj0ckxyvJyQHORI2B9tUtz2xDt40tkJNTsq8cmA8Qa1ipZHj65wa+eDvifdvgc2JwKfTJe40SYsNZxSaKEoeYa49YKgeODk+DbfVoLO2nHRWZHBvnXs5Ivuunkp/7mF23BgteV4Z4SjPlwPIKMSI9TxUorapZbBc6u3sm2gWcpeWvEyiGJ8iTaIuvElG2NJfpIq0+gYIittJ5coe9pcK9+Q5YlqdWyPProLPe9b4i63Wbwiuqp6aMWmaKmZFtIu8G4l8FXRTRpuJXZbOGiBy0uXIip1xXV6o3DjV4PTp1S1GrQ6EAQpw1xO3E6QerKMEK5kKxp7Fs04YqgvwLZLTB7bBH7XJ6jn68xviVEeEk6UqvlkV3s8NTaIn/3/fGlpWg3cuzYCo1GwOTk9c1gJiaKTE01OHdug717rz+XXmy+C2++DR46lPafCaM0vBmpmHDGMAzDMAzDeG0zIY3xmhOhUegbnvxysxdNdINRxt+OMEovOm904ek6EMVpz5pve/th2hTkysavF1mWQGtNP0zoX59TADATwkZfoR2bYfvmF8i2gJIj8UPNQl/xW8vwS0Pp0qlP1RPWXUHFhihWbCQK2xa4KkAkMbYb0JUaL4xQS21miln+bBV2unAgk77mcgj/bR7+egXuK0PbuzoocgUULajJMq31+1CZecq5aYb8OiEJvh3jClDKY725jQFdpOC5JL0lhCuuG48N4LoWUZQQJjHK1lhXNI2OI4G8InOxpaJyg0bPlpX2BEqSm58vcZwue7MsgYrS6VdCXB7ffSUtQOgrtqXSfi+qH2E5kkQLVNvD7lz9PsIwIYoU3s0HQBGGCVKK66Z0XdxGHKtL59PLxbZgcvBlfUnDMAzDMAzjpaB56StdXpxLtFc9E9IYrzlDOOSxaJBQvuZXoIfCRjD4Iv1qjA6lF+RRBM41hTlrG1Apw/B3cJE6OlrA921areCqyUsAjUZALucwMZpntwtf7XHV7J+1GJ7tgS74ZE8ufsMKhqAT4OU8bqm4nFXwn1eglMCUyDOc0VhagWth23IzMJBYXkjSd+l3PWa6mk4CE75FwYV1BQ0FezcrcrSGtQg+uQIiDyoDF4uZfLkZaGjICB/V28FGbyurzhqh1aGYaVLEIwyKLHeL3FpoonWAmiyy/lSXJ8sCe7NKaMxLpxKtrXW5445RBt0CGVw6BOQ3R1hXRhSr0zYJCoHEu0HVFUCrpcnlJIXC9QlOkiiOH1/l8cfnOXmyQK/nUBvLopISSSJwBfSu+UMjI1AlgU7SD8PKpr2Bslsq9I8tULI0Fmn41mqFzM01OXVqnaGhLB/72AnuvXeCHTuuH5cO6cQvKQVBcH3Fzdpal3LZZ3S08PVPAsMwDMMwDMMwXlImpDFeVLGC+SANUUc98F66idbftiFsDpHhK7TxkGQ2KyhCFNOE7CPDLr7OiJpraA2LIfQSGHCheMVv1e2HYOdWOHUO9u4Ee/O+ZgtWN+D974RS8cbb/Wbs2VPj4MEhnnhijr17a5cuvnu9iKmpOq9//SSWJdi5tMFjbp556TBqAQJO92E1gYGyjz67Qj/nom0Li7QPDYAr0940Wmv69T6737Qbx7fZ2tJ8cUET+vD9w0MkSYHErmNRplz2mV2KiByLnJT0FweY70jW17oUyxlqQ3lsCZGGMwFs88CVmm4XRE9RabY4tqAJJvPsHnLISk02BieBRghVLw0gFBYr/SGGRUI3aNJ1Y3TsUXITKl7Ex+dyTE3sIfA6nDrdIDNa4pwjcNow3O4wJCR7Dm+jtVpgrDLMU6sLFLsZqjXBvvtjTjxm0Qj71NwceTI3+Nw16+sJP/p38qz5MZ0+0LLJ2xI76vD7v/8Mzz+/RBjGwCjz86PUG6vUrS10OzkKNYfEF4R5kAnIlsbWYI9K2hvglyG3HdYUDN+6hcXHp4nnA9acPHNn5jl/fo1GI0ApGBjI8Jd/eZxPf/os998/wU/8xK3k81cHS4cPD7NnT43jx1fYecsQQS6D1Bqx1mJxscMP//AeBgZuMHfbMAzDMAzDMIyXjQlpjBeF1vBoI62CmO6n/z7swVtr8P2163ucvJIEgh+iQgfFMXqEaARpf5Dd+LyPKvY3OYL7TBc+sgzH2umUm6IND5ThR4Ygb0MuCx/8u/Cf/wiOn0mfozX4HnzfA/DDb/3O3ouUgp/+6dsIgpgXXlghSRRag+NIRkcLLC93+JVf+QJJoumVsqzdu52lN+4isiyOR5CXsLfs8bzrcuLEKmwdJNYSCXgirWApOxo9v05+OM/IgXGOfjRk6vGE8zssgnFJ6YzNgdsP4E68QEN3mGaYWTdP0JHEKxbxrKBXb1Ms+owfHMV206+dgoSVBM7UNe0zCeeenKf+whnijQ2cDESTOT7+wDZGRrdTX5ds+IJmRdDNCEZqgpVY0ApANyx0XKTlRFBIuGPrGp9ccllq+gwVMzg7DzP/6AusLi+jHYGw4XzeZ+jWW4hPjPOHT0JjeT+dxghatfCLMZN3N8hv69I8VeDQvirymmY+WmtOn4kQI5rn72nxkdU6S02g4VA879H98POItUVuO1Aln3dptyW9nmBlZZAg1KyHYO/RqFskrYLAtYBpTXFGU6oKlpZBvA5mBMgAZKfM+L43MH+qyZFnukR1i0KoGCxtsGd3jVtuScux6vU+n/nMWcIw4UMfuvtSs2QAz7P56Z+7g3/y4fP8lZWl73sIrSm6Rd6ye4T3vW/fd3YyGoZhGIZhGK9dpnHwi0Zorb+nV3Y1m01KpRKNRoNi8TsoWTC+ri+tw+/OQgKMumn1xXIIPQXvG4b3jbzSe3i9CM0pekwRkqAZx+UWMvh8c+U/57vw76dgIYAJLw006nH6vh+owP9z8nIlUasNTx+B2YW0F83+3enPtzvV6Vr9fsxzzy1y7twGkPYf+dznzhMECePjBWxbsrLSZaneZ8/b9qJ+6Fa+1hPc5sKRHixM11n/4yfonF+HQgZRymJLgdvpE210GBzO8f0/eQczX6sx9ViCNSlYut8h6GnKDRguwq33xrwgI1ZDyNsK3Ze0WjarsQQ0+2sO/jXLbJb6mva8Qn5uls7TT0ESY+XzRLEktHqoMMC9Zw8jbzpEPoZFKdjwBTIrsCxBsSeoCehL6CUKr6PYO9Rno2TjzcLcEYmVE6xmu3SmllCrXZTvofeNILeU2VkHPhmzsSHIZ2HfQBcRdtlYFuzfD34AF84lVKuSWs1CSqjXFcvLCfGQIvezEe0JydqCg++AKMQ0Npos/+4FatMZHthjUctr1tE8/myVY4+MEGqL5D1Z9H4P2RGotoaMwB4VZLSgdkowtBvEQ+n5M34SNl6AwSzE7SZffmSayMrhu4K7hxa4ZaR/1fKmTidkerrBP/yH9181Ultr+ONl+PBSQlDvIVtdkJK4lGO44vOhccF95ivSMAzDMAzjRfFauQ69+D7/yZMNvPxL+z6DdpP/4+7v/WNqKmmM71gvgY8upz1D9lyxWmJ7Jg0wPrMGb6yklTWvJg6CA2Q5wLe3xOPTq+nSrkO5yw13MxaUbHiiAc824d5yenshDw/e/+Ls9434vs29905w770TRFHCv/yXXySKkqumPm3dWiKXc6g/eoHMrdvYM1xmPYLVGEYmynTf/wDJ01PoI1Mkay0iNE7OpfbGPWTu2EbfLjL7dEBlq6C9zUL7Aq8PdlXTrsNTUy69YZ9RVyA14EHWhU4HAgUdwXWLyKKWph4pCjOnkDohO5bur6c1Ky0HRJfSk+fJHJgk3FqhLMCLNMvL4GQFxVzaP2xUw1ZLknckX6jnGUKzejKmkIf2gCTOF6hWCsQaVvvpjmgbTpU07gOSgS4Ei7DQyPCWYZeorDl/RvNTP2XRe13Il7/cY34+RmvI5yVveWeGEw+26Q9ZzJ9wqfrg2aA6NssrbfJvLdH944QnFhWlHTGrsWZ2yUMOdHG2eyS7c+iZPlq6SGkh+5DpCdgqcHbCoVvg/gLs6cLvX0jHfQ+V4Zn5DnkfhocEa0GGeTXGXn0BW1zO2nO5dJnTl788xb33jl8KcKYD+PwGjGUsBot5IH/pOad68NE1uDMPzqtwiaJhGIZhGIbxKmcqaV40JqQxvmOnuzAXwM7r23Yw7MILHTjWefWFNN+JZgzPtmDEvX4iUtZKqxaebV0OaV5OFy7UuXChzsTE9elyrZZhbr7FyvFlJkbLnAnTyUlBDHEpR+ktt8AbdpFsdOjHmkwly9aqz3II545EqBjcnEBZ6RQiS0CMoFbQTAcwqNIqqotiDUqALaERQe2KNilaazpNjdioE6w1yFau2F8t0KFCZDM4Cy32fHmJ0VsLKAFRoPnEgs3EDsWdhywcAQXSkLDtQyuEzJomCDSlkmDeF9gKYglNC2IfIG3AmwiBVYWOC0kVGh2LfAh3NhNAc+EC/P2/X+Btb8uxvJyglKZatZiuBByjQTzjkag0oAEIeiHxQh+5K0Ows8+ZY4qxUOHWs8iWR2WgT/uAj85FYPWw8i7uSJZwQ7B3j2THjnTE1j8bhK0efOYcNLqwZXOE+/JyF99PX6zkBNQDn40gw2Cme9XnPDCQ5ezZdTqd6FJvmmNdaCQweYPfwwkXpvpwrg97TVsawzAMwzAMw3jFmJDG+I6FKr0Yd27QxkWK9OI5/B5LPS++58xNqg5skS71eiWEYUIcq6v6kVwkRDqCOYkSJOl7uNgo+OJIaJFxkRkXpYDN8eFCp+PCxeYmtbg8AU+TLttKxOWR0rGGVgyrYfq/aOgnsOZA0UnPFb05pk8kCVophH35YOqLW9+cXy7ihFJHs+EJzhUt6jVBkhPEGgY0bBEwtLlfAEmSvlctBEqk+9bxIBQgIkAJLBtUAlYC2VZ6DFounBuWhCWoTWl6vXR7uZxk+/bL+3d2c/+Sa0IptEYrTSCg72gcLSgrSaAkSgmEpdO+OFojbIVjK4oZQbOlWXFjKjaMKZvc5kbD+PIId41GJQq5mQpKoVAIEn39L55lScIwIUkun4Th5jjvG46Dl+lnFn5PL341DMMwDMMwXjKmkuZFY0Ia4zs26qVLfNavqZQA6CbpBfno91AVDUDZTiuDZnpQvma0ttbp2OTt3/yAqBfV6GiBctlnba3HyEj+qvvCMMGSgvJIuvSnZsH5BEoWWDKdzuVsBjExaWPhSIGUMFCBVgRaaaRKGzDrzWqaXhcKxc1qlRiW+ukSp4vX/ApApMvfVkMY9KDigJsRdAoF7KxH3O7hltL9lQKQIKIESwi8Yp6nhi1mCpK+BN0GYac9kKaAaQ01YL8GW0O2AIGAJNEkiabuC4QGEoG+GFbIzQCks/l+Nfh9GOpqVvOC5b0W795542M8iE0GSZBTKG2lAZdIJ051bYlaSeh9TWEtJ/SHNF4uxPFi4p6NvRERbMuhlMZyJCpKG2vbrTaPPtYjP99g5asvsGdnhYP3H0QySj+U+K4gn3dZWu5QAHqxg2/F5J3wuv1rtQIqlQzZ7OWTc8xLj2ugrp+6thal5/TojSeNG4ZhGIZhGIbxMjHdB4zv2JgHdxVhNkxDmYtClU4/2p+HW3JXP2d9vce5cxssL3de3p19kdgSvq+ahjGr4WZVCGk1xukuVG0YcuD5Bpxtw3KomdIx0zoh+jq9uleU4okg5tmeohlffZ9SMLsK5xdJxz3fRLWa4YEHtjA93WB2tkGvFwEQRQmnTq0xNpZnR8Vjca1HVaRVJ/UEMg4ECcQJBFojvYiMH7MWaWou7D9gURwVrJ3TRJ2EIFF0hUYEmiiC/TUItWCqkwY7OSv9kaQVNgUbMkAcw3wX1kKBXxI4WR93bIJeo0PYC9Fo+gFYGYWeX6OflDmyc4QzJYEXKZxZxXCoydgCJ4FBoAQsAZ9dDRhotxFugJXpc6KjqFvpsqaEtKpGiTRMihD4GkQ9ptOLCRMoOBpXaOIlTXfcYnZPmmMrFHXqbLBBRMQkLnvx0aWIXFax2oaNJcX5c6AGc/SfbhE8H5Osa2Yeg4XnIjLVNkHLxTkfIBohasTHzrl0VhI6azNMPztHd65L/Usnef65Bf7sz47yr//p/+DkF77EsfMh3QgqWyr0LYuGlKz5PkPlNnknuurz11rTaAS84Q2TOM7laqrDOdiTgVP99PO5qJ3AYgT3F2HgmsDRMAzDMAzDML4p6mX6eQ0wlTTGd0wI+LFRaCXwTDNdMrFZCMGBPPzseBpqAKytdfnIR07w+ONzdLsRvm9z220jvPvd+xgf/+7q0P1QJa0M+exa2pNHijSY6sXgxPBLS9CMNcLWuPmYgcGI7SMR212Lt0iP+4RzqanrvE74vU7Ap1dhpWchlGLCEXygZPFjA5Kz0/DXT8Lp+TREqRbgoYPwg3eDd82F9exsk+XlDmtrXY4dW0EIqFaz1GqZtPLFEkz9xsOciB3EjjGiN+wnHs4iJQg0Ua6PW+3ieAkNAfnYZlJkySuP3e+1+evPhLSVRIUSfAjChP3Dmr2TNqeW0yVHSkB3s5LGExAL6HUgCtJASwNTXShloNQV1LfdQm8lpD0/i0wSbKEJuwJFlenDdzDteTgnNEMSxmpw72HJkhZM1aGhQPVCmk+eoHN8irqICe7fRWh7JNqC/ihW4qF8iZZgeen+JYEinupDJ6YXaxwV0yg5rOZziBFBviL4iyaMLK6wdfg5GmIDjSZPnh3s5EfYRuBo4h19jp5XrLcUcQ3i83l6H5nFy8ZURgvoGNrL4BXWyA/ZdGYy6I+2kW+r0i65hMkasQOu9sjPLGB161S3lihq2NgIWDzyHBtYHA9fR9vKs3HndpKipujFLFgFnuqMsG99jVwcobXm1Kk1xscL3H//lqvOC0/CB0fht+fhZD8NFC/e/sYSvG/wJf+VMQzDMAzDMAzjGzAjuI0XTaTgSDutJEkUbMvC7YV04hGkSzD+/b9/lOeeW2RkJE+x6NHpRMzPN9m1q8r//D+/juHh/Nd/kVcZreFsD55vpY1xH16FCy1Y3qxWCGTCegK21FR8zeRAzNYdfWxL8BNWhgelx4pO+JftLn8770AkKbsaLTUbMXixzfclks7DknYHxqvg2LDWgnoH3no7fPCt6XIkgMXFNv/u332Vc+c2GB3N0+1Gl0IbpTTj40X8sSonI4epjZBwrY23fQj3vfcTl3zc4Q7+SIechIK28CyQXoIAtjdzfPqrgvW+g2gqlC1RRYlUCa6lODQoadouZQfa8eU+RbaGM/U0wHJVusxIa+iFYK3BSKIpORD2E9qzK7TnVmkuJuhukcLgMNHrfSILVB1cG975gODANonSsNaFxY2Yk3/xBBvPXaCPJvQ9vIzDxh07YfcI+C5uoPGqBRJPoC2g2SM5sY4dSoZLEisKWd1o0XdsKvvGGRvLIS3BVBxTsdZ5366j3DfYRyDo0iEiYj+3sI9DPNvr8W/+e5uZNc1G38aec7BWZ9g4fZwkDHHyOSzbobOiyQ/1sFyf3MAYW96yjfnuGqceO4OXkeQaTez1JoHW+EIwiQVCcCG2WO7Drvc9SHfvJE2R0O8FoEKqvR6uJxnsdNhz5CTN+QajowV+/ufv5NZbbzz3vpPAM22Y7qfh6d4MHMilfZQMwzAMwzCMF8dr5Tr00gjuh1+mEdxv+N4/pqaSxnjROBLuKKY/N/LYY7McObLE/v2Dl5raZjIOlYrP0aPLfOELF/jABw6+jHv8nRMCdmXTn0dW4ZP9tHIk0VDzNFNoCg6EgcASmtWGzZ6Gh6yGfFIF3CUcvpyEPL4hsSOLkYzabOwqyLmwLCM+fs5jJ/DAxOWmr1kPCj48/AI8eBD2TaS3f/7z5zh7dp2DB4ewrDS52bq1zPPPL/HYY7PsPzjMlJtFC3DLDirno6aWcU/M4N+3A0pdZCjwsRm9ODIpkbSsiKfiNhu6hGfHyEFJIjWIBJQmVJKjdcGuQY0vBf4VvU3W1sCqg10BZYOdpO9DRhA3wCsLqmUAGz0yyvPeMLoE+Z4iV4JmWZBrAXlNpy948gwc2AZ9BQ0BU80OC2NVkslBwnaA7PSJTi5Q+toFmhMVpGOhHIEQMSXLxWv3aRydwbMtQtcm70A2Bw07i6p3cTfWyWzLAZoMHVqJy9H5XdxXPYtrKVxc2rQ5yxm2sY3wqRzWJwX79iiOWDEDgwKGJikO5FifmqW1sETc6wOCsJPjwV+aYO/bJvCLLn/y/idxnlikvLN06Xg5CPo67akjLIugUkaeW6Fx7jzZBybZ71jEscdsUxJKRW5uhZlinvJAjQ/cM8aDD25l69byTc/ZnAWvL5GuETMMwzAMwzAM41XFhDTGy+bxx+fwffu6qUOWJalWMzz66Czve98tl8KF7zbP1NNlkitBeiHcE5pYa7IIsKATSlxLMdeQ3FOVnNUJp3TMI3FEt+tRdNLJQEiBSJ+CDAX1PsghENf0qCnnYXoVXphOQ5o4Vjz22By1WvaqY6i1ZmGhhe/bXJjv0MnVEKSVE1nPouM59E7MUXrrBKGjsPoOXbnZYHbzo8olNmfoYxVirJZNLATZMMJWipbvISX0tSSJFVzz+TYb4MXgB9D2ILRBSxANkAk0+zCs094ofQW9DY1MBCIniP10aZRQgBRYFiw2NU9saJZiQTeBZjsm9l0iBLrqEo7VUGMDZBc3sLoR1vQsXt6jUM2zdc8YaytNCCKcrEcYQi9K9ydGkM27dDe6RN0IsoCI8SyblZ7HbCvLznI7PR7kWGaJZZY5f34rWqcBlNbpOPNmX9BgiHjLEHqgixWHFB2NqGcZ2mfjFwVhN2T55Dpe4er1apLNSU5A33aIpcTPOqyeX2OXTpDCwnUsJqsW63mXO7dlaSvBrvuH+MmDpqmMYRiGYRiG8Qow051eNCakMV42/X58w7HQAK5rEUUJSaKxbvyQV71usjnOmjQAuTJTEUKjtEAITZykv3gK6CaKxdN1GlM+GwstZBAipCA3mKE8UYRMBq00yubqDV7abjoaG9KQJoqS646x1ul9ti2IogTFZvBBOr0pdi2ifnRp8xKR9o25YiGkIO3joi2IhSQTxeTCaHN8U8iG56KFINTXf3OqJF3i5G0+vOun7yWK0u11RTpdqGDD7izMqXRqk744+/vK9+tp4kHBqS6UXRh0IGr3CDs9Ei3SEeCdPsqyCbcNkQzmEM0edj9CdsP080kUm4/cnMh0+XhIKYl1jFLpYwQaIdLle5G6vB7o4vNjnbC0DPOLsK5hvmih+hIhBb6rcV2wMlmUzrIRQ9zRPDcjqexPUP0kHUF+1Qzvy2PMNRotNl9JSjQgr/hQpACEJFf0cRVok88YhmEYhmEYxnc9E9IYL5tdu6ocP75yw/vW13vceecYjvPdWUUDsCMHj66lYcN6CBlLIElDgCgRZBxNogXVrKaBxm2GfPZPTnDqyVlat+3HqRTwCVGJYuN8k/pUC1XzscuTyIYF14xHjpP0gn60mv6751ls21bm6acXGBq6PE5LSkG1mmFhoc3EaJGpapl2JkdHS8I4Im4E2OM1uh0LJxH0pSKLRG82Qk6Alb4mWPWJ6i5WoBGJJtYSG4UfxUgtsTKSrrQ4vtZHnVxAnV/GDmMiK0M0Mkq0fwhHWIxsQKUJ8wGstNPjdk8lnSzkSvhaWTC1obGizeohkYYo2oawLLByMOqnY6sB/JJPfb6ObUuiSIEtkN0Q2e6jhwsk2waJ59bJFDPpccp6aaWKSoMzV6avK4CgH+H6Do5/MfGwiBTk7YRaJh113WsnHH+6zpGnW/zp4zOcPgIrq0NpFU8kcQASTb8LSQj5PLgO2H1NPwPzkcVXzlncs0XhFV3aK12yV3yuF2MYicBNEtCaqBfhD5cI5OXfj14CvgV5Cy6EcG/5Ozp9DcMwDMMwDOPbZyppXjQmpDFeNg88sIWHH57iwoU6k5MlpBRorVlcbGPbkoce2nZp2tF3o/ur8NnltDGrAlQMvpQ0lUZosC3Ie5rBSsz5dkDz904zc2SDnUNFGlaP9XwN4YITR7h5aAcJ3dCmurCIWq7RGM9R2sxe4gROzcG2YbhjR3qbEIKHHtrG888vsbjYZmg4R8f2CBGIrIfas4Xp2w/TdMq0O4LQTuh7MeKOAzjjFay6g912iIsBcUuxkQhi16W14dJtWhS6Fp2+JpKanlD0tI0tQKoYpQUDMqb3zDzBF48SrbfRlkRLiQhiSM4hH6lRO7yfvONh+R50PdwwJpfAgOXgSonWsH1CMFOHfkdhqR7JYkgibcJSHjzBsC+whCCK0qokv1rAKjcJO1FagaPBTWKiXoTYaKNqBaLxGoVKGoXka3n8nE+n0cXKZcm7At8CN45p9BMGtg9g2Wk1Upj4JEmf0XKdZLnBF5/t8dVH+qwutumccuivNBHiFN1CDlXXWDkb27bTAiCdVjk1W1AsQLgKxT2C0V2ahYbFM9Jjz9u388TvHiXEwhJg6YQYsIXAAbwoxA8C2hoO3D1JKCTNOJ3I1EpgdwbWozSoeaD8sp7uxsukF8NiP63OG8vAd+lqUMMwDMMwDOObZEIa42Wze3eNn/zJW/nTPz3C0aPLCCFQSlGpZHj/+w9w991jr/QufkcmsvCz2+C/TqWTnpYCQV8JEiGwvQSRS6huCZlKYOpvQ1YYo/SGnWzohOH2OvHaKvVKlcj2iWPQlsDv9BFfPk7gFlnM3cfUStqvRgBbh+DnfwAKV5Rh3HPPOO973wH+6LNzPBJUaHkllBDYD21F6hz1rymCGYhCgZIShjQ8kCHO5SDS+F9qoYdW6VShpQVhWCUKa2RdyS25iFJtkRXHTkOTvkOvnSfpO1Q8wejSEuc++TQZAfnRAfodSRRB7EPS7NJ68hStJ04gyxVIYhSazPYRnm/s4vhUji0jObaNZJF5wciOPrNPHyc4PgdHwnSs09ZhuGcvUanM9IKi29IEliDyHaLJCeJuH9ULYLVNVG+hSZAbHRgqoIbyRF5aiuR4DkO7RrlwfAHR7dLUmiZQsCyy2yrokSpzfWho6CiP/IVlPv/HJ/n4hTqdDQWFDN6+/VivG2N8toGXJKzHPsnxhFj1kVvyOJYNAhwnXdZVn9FkpaZyr4VjC6o5xdS6RfGB20m+VGd5o4c7UcKPI0S/zaBW2Ju/H8mxOco7Rxi4eztNC+bDdHJWxQZEWlH0Y6NwS+4GJ6XxXStS8Ol5+PwSrPTTcGZHHn5oHO6svtJ7ZxiGYRiGcQ1TSfOiMSGN8bJ68MFt7N07wNNPL7C+3qNY9Lj11mEmJ0vf1VU0F91TTZfvfG0DjrdgsS+peRq/ALmSoh26fOGEpLHWpmRpyiqiZ9msVUbZ2l5n6Ng052QOYVlUoojSRptWophZWeLNuxvcMlAmjGG8BnfshGL26tcXQrD/TXvJFLZRXA0ZjwMynsXRfonVv5V465r8QIDSCVZskfRK2CctipPQOz1N8sWn6aPpH6ohJ/IkWRddX0WNOpyeTLClSpchkWC7EZlyn+ZGGR06qIdPkJOabrFKbw20AsuBpBuj5pdAh8hQofsNVBAgVEJmqc/wAKxuZJmbcyjcswVvZ43os0/inJknyufSMpQ4hhMXYL3Byg/dz0Y5T94VBBlBpCCJHUTWwsq66FIeUShhdzTWqCQq+USWxYxQeJGkFwpCL8ehe7YxJloE3RDblgwOZilVs0z3BF9rQhLBQL1F9mNPs9KOqGcHEU5CfqNB/0vn4bDF8r078df6uFmN3UzozEmCKMSasBAWqAD0uiaxBKUfsigeSs9xCay2JeuyysSP383cHzxGeH6VsJzHyeSRnSYL622azYCRkRz//JdvZ+jOAlM9qMcQaSjbMOCmY+7H/Jf9VDdeQlrDn1yAj89B0U4raGINxxow1YYP7YG7a6/0XhqGYRiGYRgvBRPSGC+7kZE873jH7ld6N14yAx68bST9SQnAQWuHX5+FjZUO3vIGtYEsUiucOMRTCcu5MtZUi4n6OuUr+s9kaz5TK00efmGOX/pHZWrezV9ba/jUgqDn+Lxln48QMN2B3mfAXgVGBcrNUBDgy3RJVnceStMJ4dMn6UaaYKCKPauJlI+YsPE6LbxMQNJ3CXSBsu5jkUAAsafp5yO6Z3sszDbZsafKqbl0OZbtpWF3XG8gWh10JYdaD6DeQNYKOPkC/XYHmnX2b3e4cGGFF76wyq5wF90zi1jjAyRWuqQqEi66kIHpJfSR8yQPHqbr6rSprgbpgFYSW3rgauxtPt66xs9oEg/WY01DC1bQjHiCbSXYWrJxrcp1x1AJCDSMuAr5+AnCSKNHatjtEJFzEZYNnSby2BTBvjHauwfIz7Vx7wqRA9BaSAg2XGwkwhH4t0jEbom4VVwKIht9SazSZUvDd45Tqj3I/N+eYu3pWaJ6xEo3ZsiR/OiP7uenf/p27rhjFIAHrt9d43vQ+Q58YQlG/PT75KKiAyeb8NFZuL0Ctln6ZBiGYRjGq4Xmpa900d/4Id8LTEhjGC+TlQCOtSAb9EFr5BVTfTyV0FeCIJNlV6d91fOEEOSzNssLTY424P4BcEQ6meha6yEcraf/5f3i/RsB9M6Cl4NQQphAaTMEkjZYHqw/20OtNYnKZZRO+1+EhRzECe6gjXQCklaCKkrU5hQrABVJHD8kchRdJLGycGIo+BCJtJ+GqjfBstL1GgKIEnxXIi1BYts0lpoMbBsgO1hk6ewa04+eRduS2LK5avCRFOnarrNzqNcfINRWOiVLgBMDgUa54LmQSJA5SDqCSUfhO4KlEKolzZsKN7+4VRpe6AACtjZbTB2ZQQ6WiSKNVAqJRd+yIOOj63XEhRXi3aMImQZFhV2aZKyOvcvDqeTxLIFVhjAUBMHl12n0BLaVvl6soLCrxsCue5lYO0xuNmTAht94fZbx8eI3e3oZ30OONaAVwbbs9fdNZOFCGy50YFfh5d83wzAMwzAM46VlQhrDeJlEmxfkltY3XtqlSRvtXnNzoqGnBJ1Owq+fgvE5yNnwumq6vGo8c/mxoUqXRVw5JCvRQJwGMpBW21z5GsKGpA86SdCbDXOFIA1FtL48Inpz/LO+Zg+F4OL8ahKVBtwZG3IyfUqiVLoNAQqNFlwKX7QUJFEauQtLgNLE/QhhWenI6YuvcfEQWRKiBBkphGeR6WvCUOCSHtvEveK9bb6+0DCW1bSSNNA51oOqDUNOGnYB9BUsRNBO0s9hvw9bVnpcSBTCsdJk6+KHJNLgTAAk6YgtLS4eC4GrBRXLojcmiCyQQdpH6MqR5kqD3BzL3tOaDooKktsHy/QKElvAyHd3iybjOxCq9Ny/0deEK9Pf8fA1sibbMAzDMAzjtcaENIbxMqm56dKFWdtGa42+IqzRgCUFmSAgUOloZYBODPN9WG/GiC0+59eh0YFCBk634OOL8NZheM9YGsxUXRj0YLmfLo0AyDpgD0J0AcilVSSJvhyUJF0o7LJod3ysTg+Vy6I00A8hnyHpKGwlEL6FRhBIhwg7DZushCSy0Am4UYTvahxbEMdpRYtnQT+XQa/1AZBIlBSozQtMEcbkhtJ+RFG7j5VxqO4cZOGZaQRpiGGJy5WNot2HiSGkY+FqjQNECWkgs1nhk5AGNToA2wHXhxhN3oGfHNfYS30+fr7P13oaJSCTdRiu+OyvuTxUFrzQhMfqUBjK4xd8es0uwvE3KzgFdpIQxQlCSqgW0sAoTvdQJQohBMXApbTosF5J6GcUgdRkEQSko7UdR1MPBMJK92ESi33SJo9kPoTX1S6PGDdeey5WwoUJuNbV960FUHLSpVCGYRiGYRivGqZx8IvGhDSG8TLxLbi3pnlyKUOU9QmCGN93UMCKmyEfBMi1DebbCYM+SMfifBta3ZhECQpbhwm7CbNdjduSDOYk+TL8xSz0E/jxyTQU+b4R+L2z6cWcKyErobAHlqfAb4FXTatKfCBZF1g+1O7I0AnHcZ8+g3YsetJFrDYR1SLdxQAx6uFscRGBIhI2MtGEWiMtCJoewlJUyx5Ju0epmGV5RaMCDQqsYpGw0YRWH0cnUMrQ7wXYkcJJBMotsl4PaS7WGTu0jeEH9rB+boVktU5QKyNIR7XTaiMsjb5zEuFqKpGm50mE1PQSsKUmYwm6UuBEGnpQGtdYjma2JxiQEXz0BF97eAqrEzM6UKSrLKIgRuQlWw/VePDHDzHpOhxtwUY+z8At45z+8hm8QYuu5yCVJh+FRI0meqiMLmfxT8wTuT4eEAQBfsYnm8siu5JsV9L1FXMywd2uaGuw0PgZhWjblICHXIeKTMtx5vvpRfkbB17RU9V4hd1aTpcynWrB3sLlyrhWBMsBvGsCql+nN5VhGIZhGIbx3cuENIbxMmgrzcd6ii95EIxYbFQLrLRCaq5HEEFvsUv4hbOEc21U1WUha4EP2pGI9SbeQIF8JUt9cQOtNdKxaOZzhInDgSHJpxZhdz6twHjzCDy7Bn8xDWv9dGlNZwD07ZreMdiY0iSJoJmAzGnyd8IFWxDedQA90yN+foHYUZBdAd+D8UGioECp38P3euggrVpREXRW8oTLPuWixdjtE6x89TQyZ9GbkvTroJUG20VnBxCNRTQhmWqReL5HGASE+Rznp9rI+T7l7aP88k8fZMrN0377bZz51POEM8vEkK5Zqrrod+6Ft42irJiVZQ3roPISpS0SnS4ps3sge+AMKPSoYlkJrEiz/ysv8OWvnGV4uEDRLnF+VtDpA0hi+vz5idPoKOGDH7yDHyxa/OYJmN55kPUzAf1z86gkIoliNoRGDObBklh/8RWsbkhvpEJSK2FNDFIZG0HK9Ko6kbAqIS4KXDft51ORgl2uxbGMoGgJZtowR/ofBqoOfGACbi+/Qieq8aqQseHnd8Fvn06DGqXTajJPwkND8N7JV3oPDcMwDMMwrmEqaV40JqQxjJdYoDW/3VZ8JYBBqXlwHIb3Sh796wVWLmi8UNJ7bImklWBZGrneJw48kqqNWG+SJcHdMUY/SPBcGyEhihS9tQbn4wJDWZ9MTvClFbi/CuebML0BgxJGC+myifVE8/TdmnAC5FmB7GtUVqAmoJkBq6vJ9j106zaswQn0YANdApUtoQdq4Fp0l32Cuo+lInAFStnIDZvqcwrdj9F/5xbiC12mPzmDimzsfBZlW6ggRixH5PNVHnjnEMvnSxzzPaIJCxlGiEhDrkp3yxC/+7jLb7wPfvDHJvnqvTWefGqeU80Wp4s2/f1DiPEyGUehREI0CWJMk1uJ8dqCsC9RkSbnK/ZM+gwVXRJhEYQCObdM74kpJiZKzDZ8js+Ca6fLxpQWtHoZ2pHkwx+fYv+hcZ6fHiO/AONlj/i+e8mMrKDOrxD3myTRKtHMCnI9xEpsfF/Qb3dpbzSpxJri4QMAJEIzXUloZBSDWUHNEUQalhUs9hT3FCT/y07BVBc2onQJy20l2HKDZrHGa8/2PPzKQXh6A2Y6aTXN/hLsL5qpToZhGIZhGN/LTEhjGC+xZ0N4IoDdlia72QjmjkNVrGaPz/zhc3SeWSXuZckUHKSUKA1RHMJiC41FeNtukvFhHNshVAJbg+dIfEvR6/Q4s2LzYMnheAvOtOGTM2lPmvsGLjce/ZtII2yN0xGIWzTSEmjSypPEBiuGYFqTK9oMHRijPrqFPoKkKFCRQCiNTDT9FQ/L97A6MNBRjLc1fgVOH9e4z1lkOYzIV3GdGVS/hRspPGmTq44S9Ce4f3KcPziisd/gULHBCS8fp2ZTc2ZK8+Fjil97SPL6oRyd+3byS502M+sOfgLJZtviuKOxc4okYxHlLEaDDpYNSaLpC0WQjdlmV4lii5kEamdnWYsTLM/n3CJkPchf0dPDc2C95TG71uBPPjzN+uAot04IHlmBgdiiOjaCGhlhaTEhOfI51noBiSeQvZheR1LM2URjFYK1Bp25FdyRUZZtTcvXjLqCkVLaaNgCej1oAFtGEw6WJAdLL895aHz3yTvwxqFXei8MwzAMwzC+CaaS5kVjQhrDeIk9Fyo0XApoLtL7xin/mM/K2WeRwSpxq8tmy1t0tQjbt8DgBNFgCdtJUHozWFHQS8CREh2GNLqKJIauhjNNeH4NRq4Ywa3QXLA1bqTpK0EmBzlXozU0I+hbYHVBJpqhbRKVkQgByoYYgS80qg/WkRh1XmGNSvxE4wnI5AEhqAzBzJEEzgu2FrZQGthCHLRx4gTbcnGcLGfPwkc+oqgXLZw82M2rj1M+D+urisfPSabugp1FOK0TXgggSQQ1S9NT0E40sUzDGklCnLHo2Rb5KMGyBLqjaQUx52QMocWbq5ojU8tUKj4rDehHMHRNtYrWmryv6YgMjz61yu0/lNBObBoRlC+OK5fg6ID6ep+d92yjri122hq7LVlec1nrCpZ6Xc5NLTEwPIo9qqhWYDQrUBpafehFgqyruWM0plHQdJSFE2tsW141kt0wDMMwDMMwjNcmE9IYxkusp8FGwzWjq2MN/nAJObmHzMAkvmqhkoQeEn3LVpioIJsJWscInRBHXJqKBJBYoGKwYk0rgrkO/JcWHFtLmxQPZGBrHgb8tFpGKtIJ0gLsixNj4sujfoUC6QhiCeLinGwNYjM4IgDZ1siOTqtWrvj2sGxIQo1OoCgEOSXAKYJz+TG2rej3NVQ0UgjEpZlNKSGAJJ1oszmVmxhNvDkyXJA2Qdaxpg5oJBqNRpAg0DqdvhRIjzhw6Hnw48PwrprmH2mNZcl0YrZgcyS2ptvs0lhr0Gl20ErT6SX4OZ/2yjL56uil6VKX9pGEJFbYrodre9SqsKcIQQCr63DstGBgMKZyKzxjQSMRLLfT45d34cBowpZSQn2xzonPLfCPn11GBTGWJdm9u8oDD0xy+PAw7rUjfQzDMAzDMAzj1cxU0rxoTEhjGC8hrcHvCs7MwFoPhBCUC5qxgXREdpCRZMoa1c/iVnw6MUQxyGweLQVKC4RMCPoKYctL1TFoSCJNoiSt2OKLM2mIYeWgG6UNg5d7cLoBwxlBtgprOYGwQMWabqNP3I9JYkXkO8i1Hklfs3BilaRQpjcwQGLb6JwmLIF0wBmQhMcSbK3RWpC54tuj3dAMjkqsQLGwoKlUrg6klNKEIdx1l2BtAYIoHT0tr8hpggBEXjBeSSuBAIaFxYAN54Qm0uAIyNqC/kZEnBFEOJBAFDs0sREaLNlnT63LP5p0ecj3EEJSq2U5e3adQi2PENDrRqzMLNButNFKYzs2sRLoJEL1Yp7+20fZOTeKved2unaG3OZ7jUWGQi1Lp95FDngUNm/3PBgd1qytKD74lhJvfB38x3XNJ3sJ26XAs6GW01hRxHN/9gJHvzKL2w6ZqGXxfZswTPjKV6b56ldn2LOnxs/93B1s3Vp+KU5JwzAMwzAMwzBexUxIY7wi6sS0SMhiUfseOQ3jWDE/30Ipzehonlja/PEx+NycYK0LKxLyEqaXBSemIFPWWDnJwC05Vpb61ANJ3xVYUsNaF130wBbIrI2IQwQJaJkmP2h0FCMKBbSwWOtB0YflDgRAT6UTYYIImg2w1yTh9gSdiekuRARhD01CaFvoZkh/tgtzkqDlQTeB9RWolSCXJVwVWGXIbJVQEagljRzTeEoRBIK4Lwm68MM/buMvS37zN0NWVhSFQhetYyzLZ37DpnxQ8zP/H4v6P9c8Nq9pT0jyHY3UEEWaZgjlLYL33QblzfHCE0h+KGtxpKtoBBYVqbGkwPcdWkmEFAm1doPdqo0EOp2AzNaIh0Zq7NOZS6HWG9+4lRdeWGZrXlHyEk69MIvud8jmM1i2RaIgDDQ5P+SOO0a50Mxz/oUZvEZE7657sQs+QQesjM3EA9s5/YVn2FLoMeSnaVKSKE6fXmd8vMjdd4+Ts+H/UbO40IvpasWoFKgw4cn/+hwnvzyNNZbn3h0VttqXK2bGxgr0+zHHj6/ym7/5OP/gH9zH5KRpWGMYhmEYhmF8FzCVNC+aV/Tq+Mtf/jK/9mu/xlNPPcXCwgJ/9Vd/xbve9a5L92ut+Rf/4l/wO7/zO9TrdR544AH+03/6T+zevfuV22njO7JBzGdp8Dxdemg8BPvI8FZKDF+5Nua7iNaar3xlhk9/+gzT0w201gwO5Ql372JuZDtbioI35+HZSNPS0O/D0gYEy5JqBuyRAfR9HVpN0EKC0sh2Ap0YOWSTzbn06wlRuw9JsrnmSUM+i7BtrCQmxKGuBN0cxAJUD8IlkAuQbI7w5bhA705g2CbqlSCIoRfCfAumBSxp6HUgaaTrl3pNmByDSpFkTbOBhf06m+grIc0zIf0gTJchFV0Ovs7mg+/OUHY1zzyzxqc+dZLz59fQjiL39hLln62x940lHt7t8Ibfc6n/b1lOLmRZLkmQ6TKr4gD87Osl79lxeXSNEIKfdDKcL3T5M61YTdJFUqLkoOuQXdkgzxIrToIA7BGLrKjwwtNj/EoiuWMI3rUD7rprjO3bK5w+vYrfj9BBB+Fl6cUSYhBC4yQ9JkZ8btlToNZyee7cAEsXFrDFcab33U5YBXsrNPwd2OttOmfP85WnmlT8dDnTxESRn/3ZOxgYSBvebLEkP+U7/HEQ8YLSLH7+POcenqa8vcT+gs+kdf2IHt+3ueWWQY4dW+EP/uBZ/tf/9Q1YN3icYRiGYRiGYRjfm17RkKbT6XDrrbfyMz/zM7znPe+57v5/+2//Lb/1W7/Ff/tv/43t27fzK7/yK/zAD/wAx44dw/f9G2zReDVrk/DHrHKCPgPYDGPRQ/EYbRYI+RkGGfguDGq+8IUL/N7vPQ3A6GgBIeDYhQ7PP/EUd7wloPrm/YCgYsHxDhypa7KJYDiT9pRRUiIG8/ilGKfRw9KCpOYQ2jaiaNOPFLHMgaXT9MV1wPXA99BhRCQTyELsuViAqyFuQTAFcQSOlxbfqLbGe1JgbU/oekuwUMe+sAGLPWJnF0QBxD2IFWgFIYjFJXQ1CzkbNjTWdo391gi1YCPrAk9FeNl1JpihPn+IrmtRLj/LLbc06fRyRD9SgAds3H4LZ12Ri0dYGw74/l8PeecnJM897tF2BHt2Sv7u90vumZBcm0mUheT/m8/xI17MnzUSpvog+pLOsot1DhytCUohS8KmIcrk7RKTgxY94G+nYaoF/+h2n1/4hTv5tV/7Kk88Mcdg0cXJaIIoIY5idBwxMOJx992j5HIuO3IwWLQ4ls/TC2ep3b6Xc/ksJR/GshbVH7uNs2e2EEwvc/tAzH0789x55yiVSuaqfb/HttkuJY93Qv7zV+fYXfA4XMpSFmkAdSNSCrZvL3PixConTqxy4IAZ72MYhmEYhmG8yplKmhfNKxrSvP3tb+ftb3/7De/TWvMf/sN/4J/9s3/Gj/zIjwDwh3/4hwwPD/ORj3yED3zgAy/nrhovgufocpI+O3BxSa/EfSRFLE4T8DhtfpDKK7yX35pOJ+SjHz2JbUu2b0/3XWuIKi5Os8XsU2fYc+ckuUoOH0G/Db4STGbT5rXngrQHzXBWsCAcdu1zKLgwNQerfchGivV+kjac8SQ4WQQCHccXB0FBnEASQmAjkUgL1AxYClQ5DWq8MCZwEoQlUOcl7qnj6OYqLK+jRg4gPButmmDbIBKESpBCoaMQ0WwjC3mUkoSLkN1rIWsSJSwO1lc53F5i+vklPvlJn0zGZm6uxZveNEx7yOLYGzzcnkYmgqWpJu2xIju2FDnnBWx7T4e/fE8ByY3Diiu5QvCQ6/DQoIPS8H98DZ4J4ZZDRYQocroOS8sw7EK9C90YBjNQ8eDoOnx5Dt67u8brXz/Js88uorUmCEI8oSkVbbZsGWTr1hKl0uXwt5CFuw/mePzZZZrLC7x+905q3sV7BQOHBjgxOUBYgocOg32TgpdBKRk9tU52tsWB7RX8b2KKUy7nEoYJjz46a0IawzAMwzAMw3gNedU2Azl//jyLi4u8+c1vvnRbqVTi3nvv5dFHH71pSBMEAUEQXPr3ZrN5w8cZL7/n6OIhLgU0F1kISlg8S5e3Ucb6Ji7aXy1OnVpjYaHF7t3VS7fFCtZ7UB3M051bYeXCCrlKjm4MK720YfDFIopYQajA2mw10wkh76S9ZDwJuqOxnwhguEOcDxCZDKDTJygFc00oCihZ0I9RjkvSAdUCmUsLYpSGRCmE0sSOhW5EWLGHCGMSIdCZAXQSpjsgZLpzElQUoW2J1etDLoeQCbqtGWm3KGQUdcenSEhJR4yNFXjmmQUARkZySCloDkoSB9wNDU76mS8sdpjYki5tmyZgkYgx3G/pmC904NQGTOQuH8e5dvoSORvaYdo0eTCTHqayB19dgPfsgl4vYvv2Mjt2VOh2IwAyGQffv/FXoZSCdiwIVtpUb7CbE1k434LpDuwo3Hyfl5c7JIm+6evcSKHgcuFC/Zt+vGEYhmEYhmG8YkwlzYvmVRvSLC4uAjA8PHzV7cPDw5fuu5F/82/+Db/6q7/6ku6b8e0JUDg3CWAcBBGaBP1dFdKEYYJSGvuKMorNjjFYUqDRqDj9Nkl0GphcOdL54mBuRfoPWl/qC5zerkHOx3jnlxHBecTwMEiBLmRItg+hFzvofH5ztJMg0mlRTRJvblOnG08iiUokGgkh6NwkstdCyT5CXGxGfJnYHGmdvqHNfxAaNNiJIqNj2igSkb5vx7EIwwQhwHXTahRlXf05WpYk3pytbSPS8drXjOH+po55ko4vd67I+qLNoOui5IovcFdCP0nfRhQppBR4no3nfXNff1oIdJxwo9VJrkxfO/wGfzCSRN/w+V+PEIIoSr61JxmGYRiGYRiG8V3tVRvSfLv+6T/9p/zyL//ypX9vNpts2bLlFdwj46KteJwluOF9DRIOkLlpiPOtCGJ4bhmeXYKNflqZcmgI7hiB/LdWtPENjY4WKBRcNjb6VKtpPxJHgmdBox1iOzb5Wh6ArJ3+dGNwN4f6WAJiuPSuPTstZpESogSKjiDyBXEvi1jtIDsrCNtB1SKSiQG0LdKNZXxgc6MZwNWoHmlI0weVSBAaHadBi7ayqIHD4I6CTkBmubR+SoPWCmHbaYTiOGhLoLSEvGJph8vcco/+s+eoH59hkZC8oziwr0Yu5zI726RSyeC3FEKDkiATiGJFpZKuF2oQU8L+tiZ75YkITi/yyKlFckmIm3VhYIRebYSC4yAEFFzQSrM2vcazX5tnOG7xW89azMw0aDZvfA7ejINC5zxClYYyV1oL0iVVI5kbP/eiXM5BKY1SGvlNLHcC6PdjyuVvsGHDMAzDMAzDeDUwlTQvmldtSDMyMgLA0tISo6Ojl25fWlritttuu+nzPM/D87yb3m+8cu4gx9foMEfIKA6StNJkhRgLwb3kEd9mSKO1ZmGhzcmlmI/PZ5kNPZROw5JIwRemYKIIP34Q7hqF1S40Ayh5UMteuZ10jHUngmoGyl+nP/ViG87rIgM7Rzn15HmiKMF1LfJ5l1Ffc+HEBnsPjzKwdQBIe5ZsK8Jzq9CL033zJfg21Ptg6QTZjwi1xM04qIag5knkLovlJyuQrZG4bUQxh252UWttGCuC7aZJjy8g0CSxQhc0zEiwRZrdWJsBzEaCsBK03YFuG7xSWjEjXdAWqIvTowTCsZG2TTKQh7wFG0CpwfLcItQyiHtqJPmI+l8dg3qbAIcffcdOjh5d5/z5DmN2hmzdplWVtJ8N6NZrLM0WKI71aBU1b4vLrMy1WBUwOppnfV3wxMkOWipGKxmGyx6ZIrRi6MTpUqaVqRX+x58+w+zROjMtyGVsIsshUMv0vVM07znE1j1DVFWfx/7sGc6+sEDYj6iO2Dy5DAsLbaam6jiOxeHDw98wMGm3Q0aKNvkDQ5xqwt7i5QqeVgQrAbx3K5S/Qfh3yy2DVCoZ1ta6DA7mvv6DSUd6B0HMPfeMfcPHGoZhGIZhGIbxveNVG9Js376dkZERPve5z10KZZrNJo8//jgf+tCHXtmdM74t2/B4FxU+Tp3TBJeW+RSxeAclDvHtVQ0cP77Cxz52kq89v8rzC4rY8zl41yQH37AXx0+nRcUKztfh1x+H7eU0iOnGaWXLXaPw7r1pcPKRk/D8MgRJWnVz3zi8a8/VYc16F/7VV+BzF6AVCEJxC/1uA/tvz+EGPVzXojacZ3z3Noqvvx0hL5df7CqmAdD5Jsx20uCh4sb0WhHJWpvTQYhtCTIZm2KSw7JyTBzI0vUEG5l7iL0GnDwPJ1dgQcHw/rQvTTwCGRcVadAyDWY8oK8hYLPfjACh0HEDOl1odyFuQ5iBnANuDdpNsGJ0BpJEQKkEOQ/me7CxDs88BkETyln0/lHC12/F913Knz3PqVnJb/zHaar5GsePb+B/rUn5ZJaTk4dpRQMkuBybAfdzAdtzc/QWvsqftSK6XZ9znVFmylk6UZH/P3v/HS3pdd53vt+995sq18mhT58OQHcjgyAAggApgsEMiqZH8rUlm5YsSxrbEseyrPFc2/eOwvJYVx7/4SvdZa0ljUfyeDlKskiLIqnAIUGKCSBA5NDoRucT+sTKb9p73z92dUAGqAYRuD9YhT5V9dZbb1W956xVv/Xs59GbVZTJaR6wtA5E2KaECsQU5GdTJrst3nlzRNGp8aSYIourYC221yd45DQLieZPHj3O9tFzTC62ecdSzKG2e/kHDrTZ3R3xwAMrhKHiuuumX3TKkrWWM2c6XHfdDD/ywSn+j+PwVHe8gsxCRcH75uGvLL/8OTo3V+f22xf57GePMzVVfdlwaGWlx9xcnVtv9SGN53me53me9yYwbrPwmj/Hd4DXNaTp9/scO3bs4vUTJ07w4IMPMjk5yfLyMj/7sz/LP//n/5xDhw5dHMG9uLjIRz/60dfvoL2/kHdQ5wAxjzOiQ0kNxTVUWCT8lqponnpqk1/7ta+zsTFkM2xim4pWOeKpLzxK3h1w+1++HRlIAgmLdfjUcbh/Fd6/H5YarsnsZ5+BRzdckLM+gD0NmK5CJ4VPHoVzPfiH74BqCGkJP/UZ+MpZaMRQJaOTaUbvvJPwqoPsW3uC6u4mIHjXO2bY3VPnkS3XxLY9LvCaiWFYcVU1d82WPPX5x2g8eobdyRnOiCqNMsOeWUWImM71d5Jf1UC+v0ol02SffJLikTPYegxNCUUOlQqcWIVZoO6WVpEDbUADvRQkkGawsg7bu26klNawrWGUQkPDdBsm2pAXoHPEVAJLs3B2hF1bh+4TEGaIegPZS7FfOQ7DnPL7r6dYVWSf2SY12zSvidl72/s4udXnWH+SopcgrCG0JRhJahMeO7uf9Uc3+PANAx5e28PJfQl60ESdqSIDQXZNwPnJgK0NS3LeMr0XtikoZAOzdDWfzwecqYeAolZmCCy6XSOrVDj3xHmWVla56+YplicCWpcV1oWh4rbbFrnnnlM8+OAqe/bUX3BJkbWWkyd3qdUi/vJfvoZDbcn/+yZ4YBvODNyyp2tacF2L540MfzEf/vDVPPLIeZ56apMjR6ZfNKjZ2BjQ7xf84A9eS/ulSrk8z/M8z/M8z3vLeV1Dmm984xu8733vu3j9Qi+ZH/3RH+V3fud3+Mf/+B8zGAz4qZ/6KXZ3d3n3u9/NZz/7WZLEf3F5M5sh5G7Cv/B+rLV86lNH2dgYsPfqWY6fFrTrUA1D4lrMmUfPsO+mfcwfckvnTnddQ9lAuhC2ErhLO4bPnnBfvD94AC58d67UYSKBb67BfStw9z7470fdzwt1qEeWU6dGqDJnohqzs7TM8cUFrsq3KdKCP3pixI8c6nP93gaPbMHZvuv60k7gJ2+Eu5fg4S+f5HP/90NEkSI8vUHQ1ewiadVDiqykvecYK9VbGJYCTu6iT6xgF2cQ9QSrgI0NaC7ARBWKPtgaaOFS7ABINAQF4nwfsdKDTgebZa7yJANyCzUFQsP6OaKpCnpyAS3aiGYVFUqCzgnywSl0fQgkoBR2KoR+inhqHXPHfnaOTNDctIyu2s+JhYjBnoR0tUV+RoI2yEBhjEIWGjoZoh6xu3wDDz58D6t7A6hEyCcCrNUEexRmQlIODTpQBMIwXANRHRJUE6DkmJyEIGeq6CLVhTMiZ6Bgy1ZYblS4cf6F/7zt2dPkne/cwz33nOLrXz/HrbcuMjVVQQiBMZatrSFra30mJir82I+9jbe9zZ0/9RDeM/eCu3xF9u9v83f/7m385m/ezyOPnGd2tsrsbA2lJNZaOp2M1dUeYaj4oR+6jo985NC3/mSe53me53me9+3ke9JcMa9rSPPe974Xa1+8ZkkIwS//8i/zy7/8y9/Go/LeLDY3hzz++AaLi026uSAtoTnuLxNXY3bLXdZPrF8Mac723PKmzLgeMPPj1iBCQD+DanQpoLkgDlyoc/+aC2m+cNpNFmpEkKaafmoo4xoDG1Ig2ZEVjtpJgggyU/Jv71e84xDcuQy3L7v+N7NVqIWWL3/5DL/6q1/m2LFtarWQKApoC8H5tGSjN0IUmp37nkDP19FXH0Sf3kCPCphJXB8ZDYwyOHsWJmsw24DJAoJw3ENYwFYKp7awA4Mb/WQJxqGALgwWCGsRVih0GjDT6BPO56xvpOh0h+mDLXbvfRJdi13CpUKsARTYWoxYGWGPbsKHr2XQasBAI7d2qak+vbMJGItINTJ0S7DKisKEMWKk0ZM1Tj7SJG8L1CboMsQmKUW9DgaEcMdZKrCpQEhFUjd0TUwmFFUJzy1iiSkxCLYrE0DxoufOwYOT7O5mGGNJ05LHHttACNeOZ3Iy4UMfuor3vnc/R45M/0VP02e54YZZ/uf/+S6+8IWTfPWrZ3nyyc2LU7RqtZDbb9/D3Xfv47bbFl90GZbneZ7neZ7neW9db9ieNJ73cvJcU5aGMJQY46pULv9eK6WkzErAtWQpx2OahXn2xGkzHnf9YnlhKGE4/r4/LC5NYuoX0FcVtAgBcXGcdiAskTUUWHq54r6z8Pg6nNiGj98FFWX43d99gk984knW1wfU69Gzmsk2NJxNJZt5TJEZ5J99k9pmB6FCeoHr72stLiwBN87q9BY8dRLunoK4CalwL+xUz/WeqURY9w5hpQSt3WsW7lYECClRoUIJCCnRRtPLCvLcQEO6UEiIi+PBpRRuzHYjgkaE3uwRdktEUWLtuP/wmGD8vhfGtYuuBVAKShkABnFhBjm4gMmKi1etsGDdSHAJFK8gvDDPi2+eb3Kywt69Tf7+37+dkyd3yXNNHAccONBmbq7+so//Vu3d2+JjH7uZ7//+Ixw9usVoVBCGioWFOvv3t30443me53me53nfwXxI471iOzsj7r9/lccf3yBNS6anq7z97Qtcf/0MYahefge4scIPPbTGgw+usbubUa+H3HjjHG9/+wL1Vzkfe2qqyvR0ja2tIZWZGCncJKdQuvHLRhtacy0slh1hyRPDasd9Ad5QliUkDSShAqUuTe25QFs4n8HjXdBN+LVTIBN3ey+HjZFyVR6AGkcg0liqpgQskSmYqSdEkZsk9cdPgzGWmwdH+b3//DhlUkVMTnL+1CYmsTRiQSWEQAqCpIESiiSCbDpi8MBxgtmJ8Rht42Z0CxDBuH/WaOimPNkYBhb6wiUacQw9i7AWlMBKRWks0iqMysEItBVgJRbDMKkSKhilBWK2gl5oEdZj9DB3476NvVhuZArtGvUst6GbY/slhbYoAUGgqFQsO0PxrP5eQuBKkQIBgSW5IaEvqhQTdVgJQFVhCLTA5hYCibKgArCBobABNQp6SMwLhGoWgcBSLUY8v87m2YbDnPn5OgsLDRYWGq/q3LsS2u2Ed7xjz7f9eT3P8zzP8zzvivPLna4YH9J4L8tayz33nOJ3f/cx1tb6hKEiCCRpWvInf3Kca6+d5id+4u3s2dN8yf0cPbrFb//2Nzl+fAdrLXEcUBSaL3zhJEtLTT72sZt5+9sXXnIfl+tniulDh3ng+FNcWx/RTip0M2hHhu1z2zRnm1QPLvClfsFWaBg2YbgTIoTlTFiyORLsU5K4CJieEiQlrA2gJmEntTy8nbE6ktg4oCok/U1IK5AreGILbCmQUmLKEiMFxgrC4YDClBSFIQoEIYbIFkxWQ9YH8ImvbPHJrzxJEFWAKrksSI3kzFZJ1KhQjyGsxuyWAVWbsTTdYNCIWQ/Aru8Q1CsMdkfYdhWRgB2UMBhAfwDXvQ1M4kqGhAUjXCPhbohNM6hWQUWAwITjxjwp6FRDZBGhxkw26HZzSmFRi9NEszXENUsU9x6lrEkISkQQujKZnQH2yBwcnIJjBYwUWmSYqIqK61QnLaIrsJWAMi8JpAQhsEq6oMeOGNx0CLMioBnCFLAZo3sWpiUkQA6mVNSmIY2Uq5wKLJNmRN9YUhQJGnB/s3eJqdouC9kWWk/R7+dY65YSXR4kpmmJEII77lh6lb8Nnud5nud5nud5rx0f0ngv60tfOs2//bcPoJTkuutmUJeNsxmNCh5+eJ1f//V7+Uf/6M5nLdu53MmTu/z6r9/L+nqPq66aJI4vnXpFoTlxYpff+I37+PjH7+Cmm166O2t/ZPkX/67k039asrs9w2DQ5omNHq3KCQb9VQYKWntnCe64lU+vJPSNJVGSWtVSCyyFEZw/GVLUBU9MQqVuuXkPlLngC0ctW0+XdAYarTVWZajJlE5coTHTYLkmWFyAJ592oYCQAo2AVENvRLrTYU0IlIRqYDh1fgMtFLbZgNlp+k+exZ7PmJuVqLVTdDsjstxQSM2gErBTbyFQJGHJbLtKsxnRANIwZi2TlNUG9tAR6Pawp07Ck0ddKBM1YKUHdgBJzRWRKKAewPweOL/mJjrNT0ISuaVE2sD5XXhmB4Y5zMRk50aoWkD9bXPQnGDUscgPXIfaSCmPn4Ksjw2UC1oOTsOH3oY4niBWI0xSBwkjqXhoLQQjkC2LziwmCMk1XJy7nhdQg1LVwfbhfB/2xW6Z05Z02ywLZCLIEsFmFZSso/pd6rrHu9UmDxR1zqg2AyEBiykN4XCXv3mD5tyXNZ/85FMwXoRWq4UcODDB1VdPYozl6ae3uPHGOW68cfZb/bXwPM/zPM/zPO8CX0lzxfiQxntJ/X7O7//+4wgh2L+//bz7K5WQa6+d4bHHzvOnf3qcH/mRm563jbWWT37ySc6d63LDDbPPGz0chopDhyZ58slNfu/3Hue662YIghdeqmKM5Wf+ZcGf/nFJtQ6zc5KijFk7G9AdNbj+9r10pgPOJQuMUklWWkIpGIwEvQ5ENUt7zlA2BJ2mQAiLKWG7D+VQsFHXZKqPWRtBvYHVMXZkKAer9IcZTy1NUwkFcQ10BmZ7gNnYxcoQkRdgSuwoQ4cho2YdU0/Q/R5mfZtgp4M+eQqM5fzTK4QYTBBhkgiR57C5AcZg5+axlYBhRZBrQaGh3wGqdcTWDvYwECaw7zBUW/DMOuyEcOYkdPuw/E5IE5iUbllRtQbL+4DSBTd63JRHWDjYhKk5MNvIuYhGTXHwmjabssJUKDi5bcmXI6L/6R3oZ/bTeeAk+U4X9k5g33ENnK0idgtMYSEMEEisUZhSEEYQVAQ6E+RDCxZkpiE2mCkJpuIa1UxXEUdLZNGDmT5UFdbWoRdRrVhuPpww0oK8kARWMPvkk+Rlj/fO1VmxLU7mCZ1eTr0c8SPvanPnjRP8fz6v6fdzlBLUahH9fs43vrHC2bNdpqYqXHfdDD/5k29/VljoeZ7neZ7neZ73evPfULyX9MADq6ys9Dh8eOpFtwkCycxMjS9/+Qzf+72HabWePSL97NkuDz64xtJS83kBzQVCCPbta/P001s88cQGN974wtU0n3vA8MUvlkzPCyYmLu2r0VAcfUpycm2B+rUx/S3IJJjCBa5SglAw7Ava02BnoSotFWsZWMu5s4ra0BKYAf15kKclpiIIswzbj1BJFVa2EK0mOypGNcF2DPYbD6Am56HRQJkSbS1aAEWO6fVJp6eIk4QoDhk8fRLb6UBeoLXBVqoX++AEQQWb5+huFyYmMWHCqISdHEapJTWWoBqhNzqI42ehWsM2m7DvAJyVEA8hrkB/AzZPw9QRaOJefAYEASQKSu1mjSNcbxijYTkh1IrKdIOi3aQjBbXAEgSW5bqguyPZ3xJs3TrP9i1z7OyM6PZTitMRYiNFGI2MasSRpMgVZe4+lziCQoORUGsL0gziWGKEIe0ZV+1TWkRdICsVQjEkUBmVacVuJaGiMggj9ijLtQeqGCN5aK3FjQffTnL8CY4d2ybOB9wYKa4+NMnddx/g1lsX+eVfvodWK+F7v/cQp051OHeui3YrotjaGvI3/+ZN/OAPXsvUVPVlz3/P8zzP8zzP814BX0lzxfiQxntJJ0/uYC0v2xh4ZqbKsWPbnDnTfV5Ic+pUh243Z3m59ZL7qFZDisJw6lTnRUOaz3/DkA5hafnSbcbC+YEgr8HqKcP8ecu+acFGqBEWAiFQ0rLVkZgc1joKlcFE5IICZQXZEIKyRO/mmLkKdlEi+wKJC11MXoWog+wPMJUYmwAnu9i8hEYDWeTuWMpyPPpIYYYDRDkBQYgoDdZYGKWgS0Qcc2GKNowHGUUhNh0ghgN0JQEDuzkUBaAsgRCkSGyng2g0XcAiFDRqrnGwkBDE0DsL+651Ic0WEFlXQZNasBJKXIVN3RJEGbQEdlBBdPsM6nVOP71FZdRBCRCBRMzNkBSKD+yvMggkJqrzsKrxjRyq0xqtJWkuCQJBOgSkK9LRGrDjop1xz+FSg7VmPE3LumOWbuyWyEO0yhnFNaSw2LzEqoi1nZJrl13QNluXdIIZ/l//eJrN8z3StCRJAhYXG0gpePLJTU6f7rB3b5NKJWRqqsq1104zGrlmzqdOdVhebvmAxvM8z/M8z/O8NyQf0ngvKc/Ni1a/XE4piTEWrZ8fb2ptXCXLKxwt/EL7uCDN3Bf+C8dkLKz1YDuFKHShhzIWJQVSuRNcjecLCQFhACKwFIWgl0paTcOF8UPWukRBCIEN3MhnbUBbiykkQgOZxUaAAmG0mxQt5KX53RY3X0iI8QRpe/F1iwtzvq11icNzCMSlWeDjMdfGusuFuxDCJR0XP5PxtCXBuP2KgrJ0QYwR7g2RFuatG9WtASWgCtQsaqTRKIwQDPsZptCIUl84IHReMtjo8uBDZxhOSm7+0M1UmhUqCJSCSkUyHI2PZPyxifGxXD7S3OKCG/fSBBdf4IW55cKOJzOBFfKyEIdnTXGKFOQaDIKlpec3qr4wlj2KLoWKlUpIpRICsLLSJ8/18x7neZ7neZ7ned5fgK+kuWJ8SOO9pKmpCmVpsNa+ZMjS62VUq+HzqmgAWq0EpeTFqocXo7V7nhfaxwVXLwuEgDy3RJFgc+gCmkRB2rXEdcH8nGSzdIOMNK6IBFxAk44gzoAU+plg1FHI0KI1aK0QsXJrdHYKtFVgQBgBUQ5CUIYRWoHKQdTqCGuxeeZmROsSoSSmKLHGgFRgDNnWLkVRYLISZAgGbFYg4giJO0YLbmKSENgocsOPBFQDSEsYaUEqwRoNtSq2LCFJ3JKhUT6ujgHKFBrLkArIgQhkhHvu7hqcWYEih0oCi9OYyQZaVbGDlKCSoIQgDgSBigilC0N0ElEJBae+eYphZ8gdP3gH9XoNFbpdqfEbLCQIqzH9FDq7aKkxKsDWajBZB6Hc65IWfTGdMS6F0SCkRgBhkZHHIVEgMUAtvhRobY3gpjkYZy7Ps7BQp9WK2doaMTv77CbWWVailGBhof6i55fneZ7neZ7ned7r6YW7s3re2C23LNBsxuzupi+53cpKj+uum2Hfvucvabr22mmWl1usrvZech/nzw+Yna1x881zGANnz8KJEzAcXtrmh96n2LNPcvoZyzCz7IxcixWdWrIuHLhZcc2yQAlBmAsy7QpPtHHbmBFkGwK9BjqEvLCkqaaMS0bAcKKGOTPCnOy5kg0bQ6BBdqHaxDaqBKHBbBSU3QwxP4/e3abIc8xggLDGBTRpCs0WIsspd3rkuwOsHfeCiRJsmmMzTVm66hJjDHqUIapNbFLnwpTqidiiA3coeaHdxpOTLgCSIegR5D0YWhh2XSVNaxkyCxsW2iDKHfjCF+Er98LJU3B+0/375/dSfOHr2GfOIEcG02hTyQqaIqMYF5vkYURc5EzKgun905x/5jwPfOoBKkVJVHUvUwnX8iY/uw4PfhM2t2A4RPcG2O0d7OmzDE+dR+VDkgjCQGGlcO+FsthCIMscG2RElISDDspoyrhKIg3X7E2wFtb7LtZ578Fxtc4LmJmpcddde1ld7dPv5xdvLwrN0aNbHDo09bLTwzzP8zzP8zzPe5XMt+nyHcBX0ngvad++FrfdtsjnPvcMlUr4gpUwq6s9wlDx/vcfeMFqmzgO+OAHD/Jbv/UA29sjJicrz9um18vY2BjyV//qdZw+XeEP/xCOHXMrd6am4H3vg+/5HphtC/7FP4r4f/7vOcefMgxySyhd1czyLYq7PxxSqcDBGbh3RdLLLV2AEkQGcWQpkejTBuoZLACRW56UFwXqTI75ZoDoBVCMsNMWOy+w4R5QMeIUsLmL7WuIE4qZRTi/jn3qSYrtbZdWTE3D0jISDVsdbC7BRBBXIdqCfAQ2xvZTtLQQKEiaUJ+FShX6kiKHwsAZA93IInWOXtt1FTtJ3T2mswtnTwADF7dmCdRvgGLWLXFSAB30Q/dBtwPTE1ALx8uLcE1jNnfhK49jlm+DXkxcGZJMhwwoKKRCK8V0ZxNlDASKieVZntoKOHf/iLLeICsgz4DNDco/vw8zSiFKYGoaG7jKGWEtttMhOHec8NZr0Y0pZCAwmYVYIs5nWDqIIkUUI6QSTNoBu6JCs6E40w843YdWAj94Pbxz70ufsz/0Q9exu5ty773nyDKXNkkpOHx4yk908jzP8zzP8zzvDc1/W/FekhCCj33sJgaDnHvvXaFaDZibqxMEksEgZ22tT5IE/LW/dj233bb4ovt5//sPsLbW59OfPsb6ep+FhQZJEpDnmrW1PmVp+MAHDnDw4LX8+q9Dtwt79kAYwtYW/Pt/D5ub8OM/Dh+4VfK7/9+YH/8tzblzhsm6YPkqyaHDrnntoIQNBdVJQVhYOqUl3RUYBaIikD2LbHcxcQ5pAH01blgj0K0hwU19qsemodIlrUi0rCOqASoyFGeHGF2DioCsB+c33EFecy2cO404fRpx+gSJTlH79tPPlQtoBK7kZGk/PPUQdCy0phBVhY0qEMYQBwglCCTYArKepWdAkMPpk8hBjjh8EJ31EBtd7Oo5ZKuOueUIUZSAmMOM2sjEYhcs8iCYe45i013s3Axajic6KdzSqgJIJmG4BeeegqTBsAgxukUxnVEqzVxvm8nuNuCWPm2099ItK9itITPTddZjge1assefILJD5FUzKLFJK++TU0coSSIKWq0e2yvnaKwLlu+8izKXPHh8xG5fUDE9Gs2CGAGyThZUkErxl6a2+Vvfs4eRgUYMN83D/okXr6K5oNGI+fjH7+Cxx87z1FNblKVhebnFLbfMU6tFV+pXw/M8z/M8z/O8C96APWl+5Vd+hf/23/4bTz75JJVKhbvuuotf/dVf5ciRI6/N8V0hPqTxXlarlfAzP/MOvvSl09xzz0nOnu1ijCVJAt71rmXe+9793Hzz3Ev2rFFK8jf+xk1cffUkX/ziKZ58cpPz5zVhKDl8eIq7797HO9+5zL/8l4peD6699tKX8WoV6nW45x54z3vg8GFIlWD+hoDb74LKc87iE0PYLmBPAySSnaHlxI5F1yArBFaU2D05woI4X4LVCOkmDZlagLlK0BoKqo1lzouSvKaREnQ3Jy8EBOOet8McRiPAQlhFzC8iBz0q2Qi9tUnRnMGWDYTCVbpUYqyMoVDANgwaiOkZrFLufusGMbWq0M8hzd0IcbISMSwJ21XiyZAy3aLQICaa2MEQtX8RPTtNPYekZikSqEcC0+mzcXaFYE+D0mi0Vm65lBYwspBLEAZaTdjaRqbrFPU5RFGhuiuIO+dopl1k6Dr3DoIa3bBJVQ/Q231qWZtKLSbNtjAbm0TTLZK6YKEOk5UUePYSueRgi9HmeQ43dmnunaB2qEJwokfvGynHd2IKkxAIy+Gm4S/fEvITH5mhXn3pqWIvJggkN988z803z39Lj/c8z/M8z/M8783tnnvu4ad/+qe5/fbbKcuSf/pP/ykf+tCHePzxx6nVai+/g9eJD2m8V6RWi/jIR67mAx84wMpKj6IwNBoRs7O1Vzy1SUrBnXfu5Z3vXGJ1tc9wWBDHisXFBkpJTp2C48dhaen51RLtNpw5A48/7kKaYen6tCTP+Q5vgbMjqMhLDZdsJoitoBJYzqegayNogNh97hEaGIZQDxlM7lCuNUmmc9pCI61iu5syEolrkKs1VgauGzEC0hSbVDDVGgKN7Q3I1zZhqoFVIBoVZKCwT57BDKugOqB3IWsiqnWEcnmJ0W5KUiOGUQH0C+hsIlotoqsmkUlEON5Ok2DzDpzbQDenqbaABCIxHgK12UEMMvRSg3zXcnGiUpG5yU8E7kmVa18cnT5JnJVMzTSpErNYH3G6sBgL9RAGYQ0rJHEgGA40MiuZq8ac2O5gdEEhYhZimHj+ajYA4kZMZ7XD1pldzk1PcKQq+LkPNpn57gYPPN2nM9DUK5Jbrq4Thb5dlud5nud5nue9abwBK2k++9nPPuv67/zO7zA7O8v999/Pe97znit4YFeWD2m8VyUMFfv2tf9C+xBCsLjYeN7tee560IQvMLlHjIOHfNwL9sWmdBsL2rqVRRdcmOAsEW6akrRudLZmPNf62ceGFVhpxtOqLQGWAIM0Fi4bre1mgYvxyGmDFQIrFbkGbQXGGGhWkdUAkeYEZ9bJggi+6zYo+nB2FTPqIfpDTLWODBKsFVhjCWyO7PfQhYF6C3FwCRH13VhrAWFlHNSUApFqaFom52C7Jy6+JGvMeIy3cO+BAMoCoQsgxDIObqx7g0VZILOUSCdYW+HqCUkrgqPbsD6EfiSx0aVR44W2kEGYuqlUSkEzft5bevm7S2EFJ4eGdyfwd+dhLnK333bk+eeD53me53me53nec3W73Wddj+OYOI5f9nGdTgeAycnJ1+S4rhQf0nhvGHNzMDHhetDs2fPs+4oCpISFBXc9Cdz0I20huCwVkAImQjgzhLKAQQaDgatKUQVuoFAaofMUEiB79vPYwCC0Rg0qYKDfjcjSkkooUIFEjAwWhRASYUvQJWEYYMOI0mhEWRCFikKCnWqhB7uIE+vQnqScnUKICJtlQAJX3YKUGrO+At1VbH8AwpCVgiIMEVMLcHAvIpaIzbNkvfHyJ+uGOKlQI2vAUp1SQdYHZWBYjCd9BzGlVchBgRISXYLQBkvk3kw1XmMFUGpkNURKgZEJ9bCgEeVMVWCxASs9eDDL6BrX8Le0goGWTAdw03zMowrC0DASkmEBVQmhuDhkm8zAMNdoBO9cSPi5RZj17WE8z/M8z/M8763B8tpX0oy/uuzd++xJIr/wC7/AL/7iL77kQ40x/OzP/izvete7uOGGG16jA7wyfEjjvSrWwtqaG708NQXN5qvfR6+Xsbk5JI4DFhbqF5dLNZuu58x/+S9Qr1uE6KG1JknqPPNMyP79cMstbh+zVVe1sZPCTPXSvgVQt7DRG1flAEjXL3erCzKAMI8pzwv0XhClhdwgsNgoxDYM4umU0bklalWLzSWDQpEiCGQNoYcYE4EQyMBgjHWXMCHsbNLsbyE2zxNoS7J+jt3N0+irD0GrAefPuQNJ6hBMQLNCMNmiqCxh0w7WDlEqQ1QjjKoQ1FrovRaxs4N+aB0bxQS1BCHAaINZ3SZZahN+3wz1qKTR71HrCYabdUajEDkzRbjcora9S14kdIoYKyO3xEleeLdCGPVdjVHYQokGpUnY3zxPpNxf2WbsLgtlj88Vk2zZkMm64a5rYqbrsNWc4+REnatUn6tbTc7ksJrDyLi/oxKIJUz0ehw81OCX3ztL1Qc0nud5nud5nud9C86cOUPzsi+ir6SK5qd/+qd59NFH+fM///PX8tCuCB/SeK/Y00/DJz8Jjz3mKluaTfiu74Lv/37X2PflDAY5f/iHT/GlL52m08kIQ8m1187w0Y9ew+HDUwD8wA/Agw+u8elPH2VnZxswRFGFG244wI/92CHqddeEZq4Oty3A5048O6RZ78LJc1CzkMZQAnrolkeZHGwG1krCUy0sm5hZoCHQQoCwcHQH8/WQcrSOaQcEUUxRjSkDgbYSEVYQRYYNFLrWgvmQcjiAsyvkj59gs1NAvEh7ehrVmEM2m5jRJjzwGIgRLCxDaxImp0DVyc9bWMshTqBaIwsMudGEoaLdsnRjQd5uopYXMWdOo7c6hIFAAXK2RXHdLewZbPG3bn2abrxDpzB0RjWObR3g9M5VNB+p0vn6ccpqC5VotNaQJBCP1yWlGez2scFB0t3D0JWE2zm9AvpJQL1eXnxvW0HBXXKFP+20qO9dYHOo2BhCLYr5yAcPsPLVh4lHEbe2EnIDuXXLzwIBw27KWp7z0Q9dS7X6AuvZPM/zPM/zPM978/o29qRpNpvPCmlezs/8zM/wqU99ii9+8YssLS29Rgd35fiQxntFjh+Hf/2vXRXN0pL7nr+zA//1v8LqKvzMz0D0EtURea75zd+8ny996TTT01X27GmQpiX33nuWU6d2+Yf/8E6uvnqSp55aY339XhYWMpaWGoDC2CG94AT/vz+OuLV3gLwU1GPQJRSlW95TDV0g8MQqZAXsr0MmYK0POx2oCiCCvIBCg95I4ckeLAaIxcSVCD19Bvulo8jmIkV7iv5Q0F6aJcksw1xiQmhUQ/JByGi7Q2GAoYAtAashiIPQqkFtmd1E0tCnkMUZzNrToBI4ciu0W1AOYfUcyHlXVTNVwloG/QSURMbnEfV1KvUp6s39rNcCsg/fglpZhnMbKKlR0w3KfQvsX1rjHQfuJ9Ga68IG56VgJxpwbeMhzp87y1ePdSkO7idd3yTOdihkyKiXIvIIWRTo/gjyJZCHqU/AvkoHZQwnnmnS64bcddd5KhV98XNMT6/wXck23333XsrYLTu7YQ72NQ7zf7WGfO5zz7C21md+vk4cB2RZyenVPgDf8z2H+MhHDr2Wp6nneZ7neZ7neR4A1lo+/vGP8wd/8Ad84Qtf4MCBA6/3Ib0iPqTxXpa18JnPuDDmxhsvTV6qVKDVgq9/3VXU3H77i+/j4YfX+drXznLVVRPUatH48SHtdsIjj5zn059+mr//92/jk598ksEg4447ptEozukGp3SLlVHM08fgZJTTasaUBkoDp3twagNuX3YtVrb6boS1EBAa15+3IlyoZAxkAQxTzWDQh6hG2JGIx8+Q7G5iRhmljDAbp5FTbbRWDDtD2vMJsbCMBiVlKkhFHVGdQz2aoc9oxFQD0ZjD1IybmiQCSA1Zs44+/yhIBfOL0JyEfAiRhF0DnAcj3SinCQudDDmICXdjKttH2RlMUq0tcn0rYa1QbC3Mki3MUCqIYtgXaN5z9VOEkeX8ygzXN+BgABCzk3foJE+y/8bDDMXbEGvnGJxdZbi5iypzZFFy5KYlSj3PU9+cRLVywkpEHBoSUVKtlmxsVDh1qs4113Sw1nL6dAdjLD/xI9fynjuqz/mEFT/+47dwzTXT3HPPSY4d26YoDFGkuOmmOd7znn3ceecSSvmpTZ7neZ7neZ7nvfZ++qd/mv/4H/8jn/zkJ2k0GqytrQHQarWoVF5kJO0bgA9pvJfV6cDDD7umvc8djV2rufDjoYdePqTR2l4MaC64MOnpkUfWeeyxDY4f32FpqUlKyAP5Aud0A4mlHWeIbpdWGXPNrFtzaC20EvjKGfjjx2Cx5YKbaDyWe5hBXkKoYJBCWrplT/naJvbRb8LEJEnRJTpznNmZGlv1Scpc0O+k1EbblK0Z8sGQvp5GKoWNQkY5ICEwlnzDQBXk+E0R1mJF4JqwCCizEdJk6ErbLW8yxpXo5QAx2CHoFHQE9QA6BbZaojcTVGWOrLNNutMlWU44YCR7c8tmITDAB/bD7NQWttGh6LXpp9DPXEAFMNiAXGbUr5PoJyVze6aYXJwk748oi4LdTs6R91/N05+rMLeYMygHdDPDZqpZqBmUkkSR5syZKs3mWTY3h0xNVfjRH72J7/qu5Rf8jINA8p737OPd715mZaVHmpZUKgELCw2kfGVj2j3P8zzP8zzPexN6A47g/o3f+A0A3vve9z7r9t/+7d/mx37sx67MMb0GfEjjvawLo7FrtRe+XykYjV56H2laEgQvXEURhpLBwDAcFpSlQQcx92d7WDd1JsWIULjfxg6gL5u9LQQcmIRGBf78FBw970Zvz9Td8KKidEEN47Hc2o7/dmgNoyGiWiPXliJpsz7QpIkgtC5MUNYQq5JYl8zJDlIFCCznBpJRJYZSupToeeHDeEa2uDACWyCkxCoF1lzaBDH+Ydy45cJ+LqRgIsAaC1pjx9O+AwTN8euYCABZgjQIqzDj3Vxg9PjwgksjsYUQxI0qMdAvB1gEZQ71esB0s8mZLU00CNjedqO+R6OS4VBz882SH/qha7nzzr0cODDx0h80IKVgaelb6CjteZ7neZ7neZ53hVhrX36jNyAf0ngva2ICZmdhZcUtb7qcMZBl8HLL+/bta5HnmrwUnC/rrGV1MhMQSo3eWuO6uYhDh6ZoTlT4am+aTq3OjByihPvFyjUMZY0zozo7Ry06z2HUR+ZDrJBU4yaBqbKTKR5ehekGdHuu/4yQLtiwwhW5qEYdHcWoYkQUSdJKHS1LjDYMC0AqdBiTZhIZxfR1lQqaqBxSF5JcSkRFIGsS0zWQXIxBAHtx/FzYqGCUAl24OeBTdde9OADX0jhws7IDAX0X4NgcLCW63EUkIUGjjrgs28o01CMIJJA1oUgo1IhQ1Ugu68dbbUiUUGQb7tguz4GyvCQMJY16xNSS4MRDUJtUNBuKO66fJ8ya5LnmxAnJ7bdb/tk/O0y7nbza08bzPM/zPM/zvO8Ub8BKmjcrH9J4LysM4f3vh9/6LdjagslJV9mhtWsovLj40kudAG6/fQ//6VOrfOLELDTcDhSGNDOMygNU23VO96vsv/Uwn/payFzZp0DTz0pGVFgvasg4YTuLOXV8yCDVWBOgZANjQGORcoiIYtIy4GwqsQYEhqJwVSRRIBAGyqSBnF8kOPcM1UqTFEvPJkQmwwz6mNY0O8EkqrQkE5NsDxRllmGSmOnFKnUhyFNBsi9g+FCOHmiQfbAaZALUIRTMTSRsdKYx22vYnR2YmHZTlcwARAaiDXHVJSi9ArSETkDJBp1uh/byPhqthKIURIELxPojmElgswfVvI5d3UvafpzpSLOTVjm+06Cfgk62kb0261/pkhzI6MuIpsgpS8P6VsrEXBNqFZavN5x9UrO2YpiaEyy2JErWOHfOsrwMH/tYTKulWO/CIIOJKky8SEWV53me53me53me9xfjQxrvFXn/+91kpz/7Mzh3zoU01sKePfB3/g7Mzb3044eihj50G8VjXcTuFgpLYS3VWHHNwQmSdot/82fQah2g3tjgmSdOMhwWaAJMkhPUNHv3h+T9HsUgp1kJyInYLWKQggBDaQxmqLFaEpYWPdCUhYEIbKhIpXDroIREHrqBcjRgfWXdpU0IUoELkPZchRUWE9coTq+B3oS3TcGBCc7UFaqWUSsSylzDyU04dQyy7XFIE0JtD8wcYGurRll5O6b+Teivw6qB+b0QVWC+7UKakYQzGayFkEeuGCeZxdT+CjtFyVSh2elDrODUJmQ5bGzB154GqXPkVsINd6QES1V++/79dCrT6DBE2OuR5woi8QTx46cp9yyzUaZ0Uk1Zq7PVmufUM4KFqmT2HRHPfLlAbhmeeNx9rlNTkr/xN0Jai5Jf+xw8fBayEmoxvPMgfPRtPqzxPM/zPM/zPG/MV9JcMT6k8V6RIICPfQzuvBMeecT1oJmdhVtvdZU1L8Va+OQDkAU1Pvq+iPW1hP4gJwwks7M1JicrgOCB0/Cl45rh5i5FYQijAKI6wlhEf5eV4zlBs8lEuwJCMChCrBUYKxBCkARACakB3Qcy40KZzIDGrXWiRFUCtAkxh+6C6XXobbiDrE3A1IzrHSNDePohGK7DD9wA8w3YHsHKJro1pDg8i5o5C1tfcU+a1EEL0DvQ3wXbJW3sQx7eC9e9GzbXobcNOoNaG+ptV0FjU2hI2AldQNMczxPXFewKPPO1nKW7NM/sKrSFauQelmYanWmE2sPXvvlelJzCLEXIYY4dFZRhEzkpyD9yC/beVerSsNGYpwwialMJcSTJSzi2IVhrBvy9fyC5VWl6PUu7LbjlFoWoSf7VH7scas8ETNehm8J/fxDO7cA//KALbTzP8zzP8zzP87wrw4c03ismBBw65C6vxtltePA0LLWhVg05ePCFm88mkWV10yB2c2q1EBkndMqYRBhMKRkNB5ggxrYSchtSaAVWILFYBBYIQuHavRiBCCTW4gIXXbrRT0YgRY62get4PLMAczPjdjLCpbO2gO11WD8L7z0Me1pwdjBObiuw1Sd7JMM0AjjQgm+eBYbuRcgGUId0BVNrYCYOuKY4U0vQXIK6RdYsBuHSlkkL/dA1mZkqXeMYKSC2EAv0RsTWcU1lUXGhNc0gA5UPocgw1Sp6Yhk7ZTAbGQwEwpSoVoa1CbYaY65t00nryHrCnBVkKegcQgEzbRhGIPdL/vLbn93Y+d9/FU5swY17XNYFUImgXYUHz8C9J+B917y6c8HzPM/zPM/zvLcgX0lzxbzwuB3Pu4JObUJ39PLLY7KsIM81RsbEcUBp3ekpwKUExmKLnKIwFEZiAIFFCrAIDG48NdpNV5JKumFJQiCEmzqEhCLHBTLjPVyaf2THVyVsnnW/HYenYFQiLjQGtwKCCLOxC6McDs+6ypsL94vq+FhLCHMIJRTWVeoICxlYMW4wrASoCNYERBohDMKC0MaN07IaMIxOwWwDrl2AVgWqkSU0OVEIVgpoBdhAwMhcnA5lswIkmBGUUxUGbYmoWPYfhANXwYEL/x6ARgu+sPrszyIvXQgzXb8U0FwQB26s+TdOvfJzwPM8z/M8z/M8z3t5vpLGe81p6/KOC9OlX3Q7bcdZhwtVLgYf7pbxVTv+j3HQYnnWbu2lx10+cE1cdrE8Z+c8Z0OLq7wJlJu8pJ8T2Rrr5nuXGrfGSlw2iltddgDm0vFYi0SABlm6/jkoBVkBpQL57OMR1r1SpIBSIHDFNlJAIC0FFiHc7VZeeMoL47u5NI9bA7HEStc0GSB5zqCmQMCwfPZtpXaTsSL1wm9TqGCYv/B9nud5nud5nud9h/GVNFeMD2m8K8YY+MIafOIUnOi5UObaFtyYuOKUtOBZY6Kfq5oEKKkRtqQsDUJajBVkVmIMWBFgwwpCKhQWIezlmczFFUsoXBiCRQPPCmQsCHUh5MFVvAwH7koQuuY70kKjDefPw3YKe+qIncItnbIGhl33ZNUYzp67FOwIgBxUDVQItaZLQAxQCkwJIgZbXjxaN7+8EcKWgOdNuRaQW6qLBmNDOiNX4TLMhWuonBcQWkjdYi8rrasistalKNY9nxrlRANXcfNCQdlQw13PWYFWiWB50jUMnmk8+z5roZ/BodkX/yw9z/M8z/M8z/O8V8+HNN5fSL9v2diwnDLwr04JHt0U5Bqi8RKZb2xCVUGtDmoXjsy8+L6yEqoVgd7J6Q0KitKQmwziKsKUEFUwtSn6JiRWGmks5Ui41U2xwViDtQIoxn1pCqyMQBiMdeGFEBDEkmKUAwEUGeQXSkIkpCUMN2F2GXF+FfvNUzB7HaYRQ7eEYgC6QBycg1GOfeScq1pRyo3XrodubVfUhuq0W+pUkdB3u7fKorWEEEgLwqxLsaBgt4odWEgMKOnKY4YSERfcfRc8NIT1jsVoQ1GCDWJKLVxT5JUedjGCiRDOpyAVthIjABlq1DMZ+6oRu5FkfQizFVeRYyysDKCi4K8eePZnIQS89wg8cg7WOjDXdLcZ6xoJT9XgzoNX9FTyPM/zPM/zPO/NylfSXDE+pPG+JWlq+dSnCj59n+aheclTe0IyY6lWYDYUzACRdV/qtzM4p6ALTHVhuvnsfRWl5qHHdnjsTElkNTuDEenRYzAaN+MNI+z0PMmNtyIjQVkY0tUQvSUglyDAJoZh20L3Gdg8DZUmZXsekhpEESRVF35EgkJr1xxYj9f4RFWXQCgF3TV48lHk3kXskZvhwSfg/i7cNg9TCkzbhT/DDO47C7vWPV4qCCPIM2jUoHYIUUxhT1mYtS6UCYWrdMmsC2/qGrG3glS7mLaCh0PYGTc+VhJqlokjmk1Vo9MpGOTmssoh5Roinz8Hp57Abu2H9+yBuRo2irChQmpDuJpzpAj4h+9NeDiA/3wcnu645WcGaEXwk9fC9+19/mf8zoOwsgt/9IgLay6MXZ9twMfuhAMvEbh5nud5nud5nud5r54PabxXTWvL//l/5vzRVzRnPxiyNi3JuiA1FKllI4RMCvZql0tMJ65yY9XCI0OY78NCC5II0tzypft3WFsfsj/ZZXd9ldWnN2DYhyhxS5DKAtbOUsQxrXe+m85Ri94QIA1EbmITXQunn4D0KahF7kCLEUpKdBARzs0SLixQEFLsbCK0xhrj1mHJAOIEBiNYPQ1RnXDlLHbyCPm1d8NWDl/KYa6ESgh5FY7ncKIByx8ARiA6EEu3vqg5j4gipBIIXWKMwhgJiYtX4twSzFv0Xkg3M+TKOkGySfmeediswNEObGywdHiKu9+3zBcezRl0BmAhpqS0kjLX0NtBdjYJ4irFmV3UH3aJb2lgagmViZj5wxH732H5+OEJPjwj+R8sfO8yfOoMbKYwV4GPLsPbpl74c5YSfvBWuHUfPHwO+ilM1tz12eYLP8bzPM/zPM/zvO9AvpLmivEhjfeqPfGE4c//XKPeqUj3SPItgbSuh26RA30YtixdKZga/yJNxrCVuSnUb7fw5Cqc70Gvm5JtrHPX9JCD9T6/8ZmjIBKYmkVkKVIpVL1GOUrR504xOnUEs7sIcQEmhVTj6kIG0DkFSQMigRrkyFpAvTIi76yhbR/mpjGdLrK/C3Gdi+2I4wDKFFbPoSyoOEKKCYalgDCAIERSwxzfddvFEsoaNBcQhNhMwv4lgtBico3RAqIKoqYRuUChCWSJLiwikRzYG7C2rMiHOWqjQxSEiCJFFSuUeybQ8wHq906gdkoefeYAW1sj5GhAWK9BKQg3d6hITTrsUgpBfc8eRo02otNj3+lnmNs/oHtixIc+fAvpjVWOsstfokIgBHfMwB2vogJGCFcx46tmPM/zPM/zPM/zXns+pPFetcce06QpbC1LVA6ZFgRu6jVKWcrcEhpBV8DlRRq1AM5p+Mc/AKu7bjrQp//wFF9IH+P6qVkef6rLqDNAVNwSJuIYk+coa1FJjB30MefWUWoPWo+wRQ5aIYXG9taxNoWiCmfOQTGkMnMAHUrCZhXT26HopejhAKkCjBCIOMIGASJNESur2OGIQLkMZlCZBi2R0qKkwBjGU54EaO2WNzWb2M0OohJgQ4nNXXNhId2YbZNJJAKpDEK4SMgY6MagQ4PuGVQYYVWCDCsIKxBDEDMJ7JtlZ+U8m80BWRkRyCaiKMllhAxC0DkXFj6N0hxRyxDVCp2NkD3XxBRpn5VHt7nuxhZnyVkhZ5n4232qeJ7neZ7neZ73ncDy2le6vMiA3rcaH9J4r1qWgZCWUgmksZcGGzFuLgswHjR0OTmeZm0tLI6nCdXEEDUeX53lGmstIsuQu9vo1gTECVYIpBinQFojAhDGXBy+bUOFDdX4ySVUJzA0sBraw5TKZMF5WwAFRb3mJkDJELIUsbGJ6HUJigKEC2gkYKUC4UZhSwvGWnfg4P6VF8ZuW+x4ZNLFSd3C/c9ad8wXJipd+NeMmypjQaMwRqCkIZIWYQ1GCFSgCKRGqZJhKbE2JMsjTGDce+GeGXGhUcz4Ccx4EpaUUGaaEIHGUn6n/EXzPM/zPM/zPM97E/Mhjfeq7dkjwQomdg2dOYkSFj0OJC4EBFZC9Tm5wEjDct3df8HevS20NmhtWFqsEoQBeVEgBgNUnmPaE9h6k8IYUIposkE6AIIYAgsqxCJh1IBSQFAABsKYNIjY0YLh2R6JyYg2tihSgxSSMpqE3CDKGIoKRalBGpSEwECY9ymtQY879VrEuERGuwbD1kKauaVSsnRpTiigtC7htRaUASNddc34vRFCEOcwNAIbKWxZEoWKQI7frFhBUcJ2F1mpUcY10KCNcYuzSoUxAUJIBBJrNUEQUAr3uEqlQCmDtdBeqrGLpoliyv+qe57neZ7neZ73WvE9aa4Y+fKbeN6z3XqrYnlZYr+hCTVUE4u2llJbtIGgJlBC0LospMkNlAa+d8ld3+7CsXOWvm2gooSHHt4gqlWYWprHCkVhwGYpcXeXcHcbs7KFiBZpHljGVCwmT0BEMCpgextMDRp7QPShzLCZoBiWDE3IdjhHv7KPcn0E0QFyO4cpqxhRRUdtdH0JU9uHyWoMN0K6uwK7eQZRuulP2oz/HqjQVc8I5aY4DTbdrHEDZAVEyv1GyQBsiU0sVhrKQlKMBKYUKGuZKCTsCkQ7hFAiiiHWWmwgse0ETm9Rnu+Tt6/CiAihFFYojApACHTUIjchxoKUgiQOsTZCDHZpL+ZsHOswsbfO1K2T7FDyduq0fEjjeZ7neZ7neZ73hue/uXmvWrst+KmfCvnN3yrof7Fk68YAGwpGFpJYEMcway5V0pQWTvVhbx0+MA3/9jPwe5/d5KF7n6K/uQFlRha3sRtzmKUjwFOwcxZdpoxKYHUG2At2jrU/FpiaBqFh7UlIz4DKIJDQmIDl66DfhU4fFq7G1KZAhfSDkEAJDBLUEEzpSluKkatc6dRhdx8Mh5APSKVCFvdjr78d4parokkqrlonHUFnBXpbkGv3vMNxk2Ep3XjvoAe2ii4iOC8vjgrXIWzmktoAzDIU+xcw/R0GRiO0QR5dJb73LGLpMGrhIEJCFEgyC7YssKXGypAsqBJFBZVaQt8qZLdDszhGp9ejsa/K4k8eYDAhuZ0af4n263eyeJ7neZ7neZ7nea+YD2m8b8m11yp+8Rck99+veXTT8H8n8LVckmfQ0IJqACOgU0CvgMUq/K83wO/9Cfzpl7Z44itfJx8OiOstutVrKOMWRBEiCqneeBvZ1lXo7U04PwnUCSogk4jcRLAD5JsgTkEdN9GpLKC7Bnkfrr4dltqgAWsIQkUpQkozXnekEtADt2xJ1uB8DpsGQgkRkBeg9mP6Bs73oZVBVIeoBlsrsHIUqlUIBBTrsNuFQQ1owVwNEg15CbsFbFVdcFM3kGtkEbJzFsI+HKlLlq+usiY0W6vbqK0eQSeif8O7mF+aZlQI0hLKqqCbSoZpgCmg1AJVmeaWQ9NkNiQh5d3TKxxauJp8MaL99gkm2gkHSbiKBHWxY5DneZ7neZ7ned5rwC93umJ8SON9y1otwfvfH/B+4H8CvrwO/+5p9+9a6vrnNiL4yBL85GFYPQEPHbdsPPM0xbDP5PwsI1nHxBMgFCKJEfmIOKkT75liZzAJhYCJApP30DYa933pQcdC8yA0N90vaxBBXIXuebe2qhaCEAjr+uMgACNcv5ggAFsHnbrHxgqCDigJUdV1ClYx5CGsbRBEA8xgh8q+ZdJ6A61LGHSh2XKZjxhhshHmzCZy1ID5FqZeg14F0KAyVGaoyJC4JRkMIBrAbXtgvqa4qdaGmTaFhs89DLKAKHCX1vi9ntGC3WFAJw3ICxiMQMbw8b8Ed19TYf/MVa/DGeB5nud5nud5nuddST6k8a6Yd825y6kenOi5ApLDTZivuvs/8RkoshGb6xtUmg2kEIxkDRCIKARjscbQOz9ASAM7CYgQKUqECtwYbIurlhEpFBOgt1zTXnBVMkkNVMVdVUCJa/4LLqi5+LMEWYVCQ81AffyziiAo3baRhFFCWY5QIscMBtjWPCzfBKcfhN42ttHGWHlx4BNbPeROD6PaoEOk7WMGA1qzbQ5eM4+UgmMrIHIY9oDpS+9fbwSDDJqV57+3kYLZBkzV3Aqr0+fhxgX40e+6gh+g53me53me53net8JX0lwxPqTxrrh9DXe53OoQHs7gySnF4H3Xo1RAnpaUGwrbddtYXbqgBgiTkMK6+dVCCGSgsEaOx3trV+1ihSuTEfrSE6lwPOv68sHgl3nuTYZxs18Fwlza5sLIb8aNgRWXRl1P7wcVwOojiN0NrAqxUR2CEAtIY1yljRyBGKFqk7QWZqklCnBhjrWXjeweuzDlW7zE6iQloSIhUL7rt+d5nud5nud53luND2m8F7S6Cfc9CcfPualMC5Nw+7VwaOnZI7RfjjbwidPwmTNwPIE0ChGtGnksKasJ+pDCdgU83oeuAl2gez1GK5vQrUFvDm1S15Q3jAALQcXtOEpBF5CNnywQkA1A54C4WDVzeQHNs7IbASQCdnFVNIzTk7J0oU1pQWaI0GANiCi+9MCpvTA9hx12ETsrsHEGO9hFCLBKQDAB1RoqbGPKlK3TK+yeA9usMQjrhEnIqYogNjAvIBIQh+5l5qVbvfVirHU9j2daL76N53me53me53net42vpLlifEjjPUtZwif+HD77dTcmO4lcZcfXcvjje+Hthy23XWWxBg4uC5bmn1/2sdEt+exDu/Q6GcPJNl8bVZmKBdfOwJmRxIg2upthcoGtWpgNoN2CR4dw71nYOAFWgZ0D24SuRYtNqBZQj6FsuiAmOwZ2PBa70G5KU27BloDAaldtEwiXtwCXEhtjXDVOKCA1MBIQA1rDqAQZQZlCC2wvRbSa6LCONeN9CBAqwtamEbVJ7PRBGHZQoiCoVBFRjfLUDkVHQtlh2Iww0y1MqGBQopsFx4KErVLRlnCzhLkE5ttwagOqLxHSdIbuc3nfjVf4w/c8z/M8z/M8z/NeVz6k8S6yFv7bF+F3vwBTDbjx4LOX3jzymOZX//eSoq+pV6HRELzvbsU/+3jI/IxglBn+3v+1wifu7TEoBKYWIxZSpmWHu29scX9ao3sW9NkIhgFWWDf1aF7DdADiLAw2oX4b5HNQBNBQoCUU86BLNy5KR9CswcwSRKFrGIwEk7mJTUUVdneh3gApKY24bB2RcVU4apy0FBrC3P0mDENIS0gnxqU3BYxqsNvEzMRkdeWCHAUE46ymHGF2N92xqZCyu03Z76CERqyewiZHkM0Z9EQNSolKNVEtpaiep/N0jfrVe+hNSu7TcAdwYBZWd1wQ06o+/zMqDWx04B1H4N3Xvbbng+d5nud5nud53iviK2muGB/SeBedXofPfN0to5mdePZ9J04avvQnOcOuRVUElTpobfn9/1Zy6qzlP/56xI/8H+f47KM5emQIdQ4LDcogZH1o+INnDKwYxJpEBAJRl9jCwLaAoXJddB9fgfpNsDHjuv1Gyl3KcX8YWUBtBHUNlTqUFZD6UuAS1t0Ep37uuvBWz8GeI+OOvkBauBBH4NIOCxgLrQSkgbUC+hZXLjPuUiwlFBYyDWmJikNiKdxqqHxEtnUWigwZxlihsM1pOPENGO4SN+dIGhvYG+qkUUw0yKg0SuJWTi8LGfS6bK03uareYhTB4wbe04Sb9sHDp+B81xUORYE7zEEGwwwm6/APfuDVLTvzPM/zPM/zPM/z3vh8SONd9PXHodOH5YPPvt1Yyze+VjDqWSYXBGkmGOWwb1FQb1i++YDmX/zOgM8fzbHDIVWbI2oJaT0hyjKKSkiRVVCrlrBhMVK4SdrCYJpAV8LRHPJlGMy78CSx41HZFqJxWFImUBlCNQdVBR24Br7msjVIxrplT9UGdM5BfRWiaRiWYHuQNCBWSJMjVIAWAfQ1bFjIhy7kEbjQRoTu+aWBKQWpYc+8JqoHVBM4dmwb8gxVrSEuVOnENWjPoHc3EUHI3qtizh8KqRVdwqK8+J5WhSIdCkadXbqDJtORYMvCFnBwDuoJnDwPa7swGrlDqkRQDeH774B3XfvtOSc8z/M8z/M8z/Nelq+kuWJ8SONd9NgJqFeeP12o24W1MwZVFXRTQVG6YpXsPDSrgryw/P6fjcjmJCodoqoxZRRglUSkBaIeY7ckpjRoOW4LYy3GCDcKO7CwVgPRgExCkF+a0GTHk5uUgkKAaQH5uJ8MLpS5wIznYAehC27iSRicgERDUkBRd31mhELmXRLdZ5QZ9GoTdAXKHHSEkBpEgL0w4CmQ2JoLaYpeydxcwCgzqGJEpRJhpbg4+MkYjYnqEFWIRIFsx2gVEA+zy99SQgX1Wkh3kLKxo5mbCCiBXQszAmZb7jLIICtgmMJGF24/BD/9fa7BsOd5nud5nud5nvfW4r/qeRcV2mUhl7MWjq1aBpmFQFy637pMZHsIwwLSXYudtcgLCY9wlS1uirWA0k3LvjBcyWgBjJcSdQECMMWlyhWecyBinILo0j25uJj2vMArGVfUCAF6CGEJJgUagEVYi7AGUWZIbTDGYG3pRno/j71sHPe437CCcrzKqlqRBJG7DlBISxZJUikRWOw4bHqhPcehoBJZQmXZ7EI/dG1xTDw+dAP9EZzfHTcKvgl+/EPQfIFeNZ7neZ7neZ7nea8bX0lzxfiQ5juAtZazZ7vcd98KJ07sYq1laanB7bfv4eDBifFSHZifhGNnL38cPLYCz+wKVCIpujn0+pSDAdYaxHZI0KxRphVEpCk1WCsJjEVoA9a6AGS3P54tLbElaKHcL5i2sCsgx/WbUSkEMRTjAIZxpQ12HKBYUOM0ROOWJiEvzde+MLlJly4YynturnWn616MzUDWXCuabERZFlgjIMggq4HogGhetiO3U2EMtrSgJNWaZJRCuyHoVRX9fkq7EhCoC4cliWxGWmZEUUBQlggLRgjkcwKlsixRQcDhJcWBPfDAOgx34InzLmNSEto1+J7b4c5r4cie54donud5nud5nud53luHD2ne4vJc81/+y6N8/vMn2dlJqVbdR/6Vr5zhM585xp137uVjH7uJWi3ijuvg8w/BqU2L0pbOAJ7cllQTS2RWGK6VYAeAAQXDkYGNDCLJ1KGcVEfoepveiSEMa+NykwL6PbgqgKiB2TTYlnSFMiMBKe7ndgDZAGp12ApAGFDjihkDZEBUQqUEmlAoXJAi3fFcqFUptfsx70F6CuptGKUQhlDuQq0FRYEZ9bHKoEuLrRrINbEy5HKENdXxBCjramCMhc0StS+mNRuSabhqj6Au23z9vhWGw4JKJcAKOW5cvEkkC5JEUu0OidOUPIlJRunFz8ViKfKSyuQUVy1Kogbc3YT/cS/Ywr2MJIL5CZhufXvOFc/zPM/zPM/zvG+Jr6S5YnxI8xZmjOU//IeH+aM/epq5uRp7985erJqx1rK7m/Inf3KcPNf8zb99G/etC77ZsWyctoQCih1DbaAxZ+6j88xDYPYBe0FUgXA8KSkFc5TNBwWt7Hp2Hp+A3jiEOaNgbwPq88htjako7EhBD4gF5OPFT03chKWBgHwLGlMwCGCIy2AQEGmY0hDUoVRu+dPFZUjC5TXWuPVIgxVY+7qrkFnvQb0J7SWozkLehyzFhhOkElwH4yGkT5PZOURZwkiDDVxQIwXWBKiRpjUp6WSCG/fDwQXYP99mezfjmRM7DAc5OmmhsvPsbVqWDy5w/OQOW9sztM/vsrZvniIMCIsSi2XUH2FkxPxik0YTVjP46wfg5uVv+2nieZ7neZ7neZ7nvUH4kOYt7KmnNvnc506wuNhgcrLyrPuEEExMVAhDxZe+dJrHWOSe9UXK1FCzrs9M1pJkJkNvVMBGEK+D7YOuuetJHZIcQUDZgZ37JtxSpYqASMDIwqkSZkLMRAKFgRkLBTAYH8i0hYZwgcv0PGydg8ppiFuQRm5JVGCgkoFNxo2Dgdy4aptAgtZuolOhQe8C2j1/0AQC2FqDnV1oJ5ArKLegaiCKEJGidn1Evt2lOLuOmJhjSiQ0i5g0VWhjqc8o7v5Im0O3TvDN0+5lbHShVRW8/91zzCxM8My5jLpK+eA1dX7iB99HJHJ+8X+7l3u+dh59f5/ESIZ7pxkIgdnuI0TE4sF5Fg7GnM/hg4vwA/u+baeG53me53me53nelWN57StdXqgd6VuQD2newr761bOMRuXzAprL1esROyl8409PoxbmODAtMFZwfA3y3ZwiKeDAIjy6iQxXkXJEmQ9BDxG2iZALGAIQB1xAEwtIpAtXLqxE2iphSrrKmV0Lsxa0hKGEBLecqGdhM4TGHkg7kHdB5JAaEMpNZgrq7meMW0ZVFGBj90L6mRuxbdYhbkMyC9mm63ETTMKwA1tPElT3Y1UVfXAekQQkvZjA5rTeFdN/+iwMMrLuDtdMG2abiuuvn+U979nH7bcvIoR7X+55FL7+NJzZcmHXkf0xP/W9Me+5rsl088I7W+VX//m7+P3/foo/+KNTPPPwUYpzG7A8T2XfLHv21Dm4J+b6Kbh7Ad41B6F8Lc8Gz/M8z/M8z/M8743OhzRvUdZaHnlknXY7ftlty7jK7jNb7FkoUDJGAWEElSCnHKbYuQienkcGq+OJ2AJUhC2HCFO66hbbdoFMeNkcIyncGZYa2DAwKyCXkGrX+FcDO7jlRem4mkYFUJ8BOwnksKvd8iZCN1bpwkhuPcI1qilBBBB1Id9xO7UawhqkG2AyUDUIG64njc2xtRZUIsRwhEgkxU4IJiFYnmF6aYYdlXDzkubj7wiZn69fXCIGcNW8u/yVd8J2373EuTZUX+Btnp2t8fd+4jr+1g9fzbGTA7a7lla7Sn02QYYQS1isugbBnud5nud5nud5nudDmrewojCoV5AAGATWWKS8VD9m7fh/2rqzRCmEGyo9Hn4kcSnLhaHa43+eNWt63PjXWrf0qWtc5UyZgg2BCHaB0rp51hcfZgAJUQKBdpU2zyudu/C8ejyO24xvG9fZCXnZdlw6DmvccwnXEFhIiy2FeymAlIJgukVtERYWXvw9m6i7yytRq0XcfH30yjb2PM/zPM/zPM/zvmP5kOYtSgjB5GTCV796lnPnumSZJkkC5ufrzM3VCMNLs5xDnRPVErIyvHibFGBtiLV12JHQqKHtMmK0CWXfjSASIdYEoAtco5lxD5lQjpv44q4L3JkmLJQG8hICBaGFC095Meu5MGobF85cyIGetdF4CRS5u26Nu0gFWrj79Pg+EbjKmnIEKsbKCJuWUJSYKKBIA6KqRiQahlCGIYGAI36ikud5nud5nud5nvdt5kOat6iHHlrjiSc2eeKJTer1CKEURWl44qkt2s2Ym9+2SKNZR0lLXGYcufUw56Rkp2Np1KHsSEbdCiLX2O4IYoFRBxHVvdA5C1tPQNTCFgHYEspTIK+BVEJkXeNebdxSp5qCqsUtRTJA4CZDYaGwrodNMQ5gLlTjBEB/HOogxwHO+GdrQQbjQEa6iU7WgoxBFm6/6bYLbVQCo00X2iQH0LKAKIZ0BJUWZaEQ031GRY6II7r1OtdV4PsOvD6fm+d5nud5nud5nved6w0d0vziL/4iv/RLv/Ss244cOcKTTz75Oh3Rm8Njj53n3/yb+7DWMjPfZGUzJQ9D8tyg85IzawMeP3aKyfk9RKpkabHJT37fMp84LnngacupY4JRR2AxiP42dvcsmCEAVlWhtgQkMOyPC1sGEPUgugpE4ipkjHDNVioCJoAgB1IXmlTrrqmwHo/OznDBTsDFQhlGBvrG3WAtyAvLmUJcaCMhqLr9ZSkEbTAFhHUYrkG+C1Ig9BCbd1wVz9UCrmpDpQIyRtgAljXFAdixFWqtOjc3Qv7VOyAJ8TzP8zzP8zzP87xvqzd0SANw/fXX82d/9mcXrwfBG/6QX1daG37/959gZydl8eAsR7Mmw60zZJtdpFJoW6GUIWXWZePsCRaWl4lm387936jzC38NPj1j+He/D8m85dTR83QH24T1hKKvXQiiR2AFNJegeAYGKyBDaF8HN0xA08D5AroDCA1MKwgLOHUSykloHAD1nGVMQrigZjjuTVMCI1zQgwQ1XsqEds8vE7CBe15rwJSgQghrCJNTi4HJGWzeRRY9RijKt18HB5ZhlENnExkHxAvTiAiCkxuEtSpvW5zn378PFhqvy0fneZ7neZ7neZ7nfYd7wyceQRAwPz//eh/Gm8bRo1s89dQmy8stntwRZEFMbbqFVJKim1EMc5QEG8VIJYnrs7ztpglOnIP7HoCGldy4DI1wxMkHdonjEBuGlGmJLcZrkWwONoOk6cKW2jLMLMGyclUwxTpUN1z1ioqgUYd9s7DWdiHMaOiqYOBin1+EcA2Ee9ptY3HVNmrcfLjU4+qccb8ZIV0wowEVoGxBZHvEQYUj+w5w99uv5+QzJ/nC57/IaGoScdUydAeoMieIwOoCc+4stUP72HdwL++/qsWZfsDZbR/SeJ7neZ7neZ7nea+PN3xI8/TTT7O4uEiSJNx55538yq/8CsvLyy+6fZZlZFl28Xq32/12HOYbxqlTHbJMEyYRq2dBmRKNoLU4RS8KMH1LFBpyGRIMt0mzkq2tIbNTEfc9CjaCiabliYd7lEXJZD2mkxtSXbrlS7ICKNfUt7XfLWdSCbQFxBa2LejheIrS+PTqDSDcA0nN9Y8Z9wZGXFjbhKvOiYRrKiyUC4LMyC2fUomrlpEhoKHMIakgig6TjQmmKoJaGBEFdUobUyAQClrtFtX2BL3Dh6DWpGIVrZbEWo3RhrSfMjtZxUw0sTGUHTi1Dbfvez0+Oc/zPM/zPM/zPO873Rs6pLnjjjv4nd/5HY4cOcLq6iq/9Eu/xHd913fx6KOP0mi8cLnDr/zKrzyvj813Eq0NQrihSsbihmbbcR4iAlSokJFBIbEIhLVobQik5Yknc85s5GxtWUbdIWmmscZVnlwsbRECRAiMwxQ1nt407gMM4zHXwMV53Ma4kd1i3F/mYkpz4cAu2/zCRQJaX9qXCsbTnnJEFEKeU0132b+3Tb0aX9xFmkNWuJ7FZaHJC9BBTBhImi3l+hUTQAD5KEdac2kI1fh98zzP8zzP8zzP87zXwxs6pPnu7/7uiz/fdNNN3HHHHezbt4//+l//K3/n7/ydF3zMP/kn/4Sf+7mfu3i92+2yd+/e1/xY3yharQSAwBrqkWQzDwiUpCwNQWTIRsqtMDKaAEMYRVit+IP/0qe/aTATAhFLlArBwigFkQkwCYh03MC3gLAKQeR6xVhxqYdMINyUJYZu9LWQECdgUyhjiCPQ5TicsZcqapSF3LjlTEK6psIydsulLkx2MpZQ5FTKDkaHtBuK0UgxHEKtCkEA3SFUY1hfhfX1BoEKqWZ9SGZQ+aX3SZcaqSQ6jmkoqMhxcVDybf7APM/zPM/zPM/z3vT0+PJaP8db3xs6pHmudrvN4cOHOXbs2ItuE8cxcRy/6P1vdTfdNMfcXJ2NjT5LjQlWd2NUYhn1tkniAqkC0lRCuoM1EY2JBb7wx7C9Llla6BO14VzRIFAV0qyG1oWrnBHRpd4xFBDWXG8YGYOMoK9hU8O0hFEbyp5rNBwAzRo8cw56KUR7IQxdRY0ZBzUXqnAGOWgJgXGhjZRgckiHMDDQ7zHdOIfJC8TMPg5fN8fBfYKVFVhdg/4IhjksT8LcHPzVv9Li/m8s8B/+7AxbgzmGjQrVdIQ1hlFvRDLbRlQS9legM4TJKtyy9Pp9dp7neZ7neZ7ned53tjdVSNPv9zl+/Dgf+9jHXu9DecNqNmPuvOsAv/ab26QskaUJg8xQmA5ZvkJR9Cg2MsiHlOEhHr1vEltELC72wXQ4c7rKoKowZRNMA2yKNTmIPqgSbAlRC7It2H0SCCBZhNpBOJrAroLFSWgnUO5AmcLpU/DQQ6DaEH8YGjPuzLPjkMYAHQ07AkwG2QpkT7ix37buRmyXBmixurlIECQsVBPyNGJty5I0BHtiSCJ499vhr30EJicgigTveufNrHQK/vChJxhedYC0VkWIgGixQWO+zVU1wUIMJ7fgB26Auebr+el5nud5nud5nue9GRku9ht9TZ/jre8NHdL8/M//PN///d/Pvn37WFlZ4Rd+4RdQSvHDP/zDr/ehvWGVJWyOjpAHu/R2dqmFOzQbCTuDNr3dCLYfJCyHVJvL1Br7WF8BbMbmusFUW5igihgKN2EpUEANyhBsFcI2BF3X0He45pr6FgPoPAF5Dq2bYdXAZuFGcVODtRVYOwm1RZjYC5UEigwK5frUaDEeo11A3oO+hWwI5bqb3iRbICouJGILqNJs7kPmASsP9dn3vhofuDui1RDceAiu3vfsNjeTkxV+7X+7i4O/v84ffGWHrilozreYW2gzGSuKFM5uw3uugv/HLa/DB+Z5nud5nud5nud5Y2/okObs2bP88A//MFtbW8zMzPDud7+br33ta8zMzLzeh/aG9dhR+NoDkrvf3WZ3R3Lq5C7bOyOqDNjO6gSVazi4nDI1NceZMxKJRMiMVMdIGRBgMBeb545/CGP3Y2Eh3QHRAwzIAKIaFBGMzkB1CeQ0ZBKe2YF0AMF+iGOYX3BNY4waT20S0K+AVSBLiCxUgWEHymNg64hoAisi97wyAfrANjJos7RUZ30945lHJH/3Y4p3v/vFT+UkUfwvP7LIB969yD3H4PE1SEuXCx2YhvcegncfhCR8DT8Yz/M8z/M8z/O8tyzfk+ZKeUOHNP/5P//n1/sQ3nQefcpNN2o3Je1mm+XlFv1ezhNPpmzvlJhomma7jxCGNHXLjYQw2EhhtEJLN/UJIaAcT2BCAgayXVdhpnJXaWMNIFxFje1CvgnRpGv8m7RhtAuihGrLNQRW4+YzAZCrcf8Z7XrPFAXEAfQ3wPZBzLt54MJcVtVWQ4gt0qxLWc5RrUq2tzUPPKBfMqQB93Ju3we3LcNa17W/iRQstsYFQ57neZ7neZ7neZ73OntDhzTeq5dmoOSl61IIms2YSsUipcBYcTHzsPY5/5oSrSVWalfhYo1LN8w4rLEaRDCenn3hQVyY733Z6O3xbWHVNRy2A6AEQsASCIO2F3ZhXW8ao8f7NyBDIEJJl5VenNot3QhvOz4epQTWWgaDVz43WwhYaL2ad9TzPM/zPM/zPM97ab4nzZXiQ5rX2fp6n298Y4WjR7cpS838fJ3bblvkyJFppBQvv4PnWF4EbUDrceEKLuRQUpKVIWUhWN9OqEQGZInFYk0BRQpBhDWBC2guVNMwDmqQbnlT3oHowrMJF6qg3c9BjfG8bChzEFWIJlxljLVQDiBoITCooKAUkQtorHYPS0tQi1BuAhmW8ZQuCWBdVY6VhGGEUorRyJAkEVdffVkq5Xme53me53me53lvUj6keZ1obfjUp47yqU8dZXNzSJIEKCX52tfO8sd/fJy3vW2eH//xW5icrLyq/d52kwtqjp6A/Xs0g2HO40/knDipKYoAkynObwjiWoSREaUsIMvcg+PauFJGuItQ7iINSOt6ygzXIKoAMa56RkC+5ZoKR/Nu6ZLOXT8aOeX2a1pAAiEIqTEEIHNEVGKLGBkbAhtieyWGSTQLIE66ljdRQCYMJgX0LlI1mZ5sMRwKskxy/fUB73ynP409z/M8z/M8z/NeP76S5krx325fJ3/4h0f5T//pEVqthBtvnHtW1Uyvl/HlL58hy0r+wT94J/V69BJ7eraJNnzsfyj4X3+1y3/68i6bq2fIBtsIU4BRWDNDYQ8y6iZY0QFzCuQulAWM9kHtBpDjipgLLWmMhKiEpAl6EbIzUHYuPWnUhtbbIEjc0qhR3z2mXIPBeZBNVP8AaiZG1qAwEqxAJiAzS0ULasOS1I5IA42tzmCDATrfIk8N1lqQIEyLQOyj05mgKBQ33hjx8z9fYe9eX0njeZ7neZ7neZ7nvfn5kOZ1sLLS41OfOkq7nbCw0Hje/Y1GzDXXTPHAA2t85Stn+NCHrnrF+y5Lw1e/+E2KzZPYboFJDYoqRZaAyJHyFEr20eURsAWgXbhCHVQbyp6btiSbl2ZZGwvbBUQdmLkJ4n2ueoYSwiYkc673jB5C/zjkI9c8uOhA2UHKMyTDbfbmO8zNXsOObpPpkFaUslTrEKaWvKPI9pQ8/vhRwmiTQ9dfxcrWPna2UzA51XBIIxbs21dlebnG294W8eEPx8zM+IDG8zzP8zzP8zzv9eWnO10pPqR5Hdx77zm2tkbcdNPsi24TxwHVasAXvnCS971vP2H4ykYQPfbYeb785dPEUUmWDlHhPPlIuJ4vIsbYGMwW2GfAzgLLwCrEExBMge6A3nHdhy9U1AgBUhGkJyiHAdRnoT53qdJGAGUJow7SrmKDyN0egFRNQrNGwz6M6Urq2ynvvW3xBY/97NkurZbgtttu5ty5LhPVFeySpVoNue22Je6+ez/XXDP9qt5rz/M8z/M8z/M8z3uz8CHN6+CJJzap1UKEeOnGwDMzNVZWemxsDFlcfH7FzQt59NEN0rTk9JkRvUGVQgusEQhpsVaNgxUJdgOYdj1oTAJBE9ckWIExYEdg626nwkIksDJx4U1UjnsFSzdxCcB0wYCpzJLk56mHhnw4YjQSBIGizDOUqrG62iPLSuL40qmnteH06Q7GWP72334bH/zgVaRpydpaH60NrVbC9HT1Vb/Pnud5nud5nud53reD70lzpfiQ5nVQFBqlXn5yk1ICYyxav/KTMU0L+gPJyprBWIWSFo0Lai6RQHlpOZMYNwm+UD4mADRCaSyXtrEiuHAnCgPWjMdjC4wpwFoUkMiUWiipNgOsgbIQpKkmTQvCUKK1wRhLmpasrvYYDAoWFur88A/fyLvetReAJAnYv7/9il+353me53me53me573Z+ZDmdTA3V+ORR9ZfdrteL6dSCWg241e872qtxYlTFhUkKDGiNM3xkiTrLgiwJVB3Y7GxQOH6yQSTQOp2JGKEGG+CQJicgC65KcGMhz/ZcWRjx2ubREooBgitsVYghCBOAupVQSgSrHUB1dNP76CUII4V+/a1ee9793PbbYuvepKV53me53me53me572V+JDmdfCOd+zh858/yWhUUKmEL7iNtZaNjQHf+72HabWSl92ntbDRgafPL5DaOmGwRiAhzUcgYzdtyVqQfYSIsSyDTUB2QWauD03QxPWgSUA2MGbcjwaL0l3i4CTa7ENnYBKJtAYBGGtB1BFRh7pZQSnFaFSSJCFGa4Kgx77lNpOTFX7gBw5z003zKCWo1SIOHGi/4n47nud5nud5nud53huR5bVfjmRf4/2/MfiQ5nVwww2zXHfdDA8+uMa1104/L6Sw1nLy5C4TExXe8559L7u/s5vwia/Bpz+3wtc/d5QsG2FHkGcCa7QLYqx1F5NgxSFQeyDaAHkGihFoC1kK0SEIFkGE498BjSwHCH2MSn1Eoz1kvV9gRIiRgSulsQYqlkMHZpgZzLNyZpWdnRG9nkBJweyEZc+eBt/3fYf563/9BpTyE5k8z/M8z/M8z/M877l8SPM6CEPFT/7k2/k3/+Y+Hn30PO12wsxMDSkF3W7G+nqfiYkKf/tvv42rr558yX2t7cC//u9w/zfOcfwr95Fvl1TbDdJkEmMXIJdIO8Jai7UxmGlQVdTeEqpLmLU6pOvYAqSqY3QDMgmyAGsQRYdQnaRWe5KwNkWt2qEoGwzzmCJUlBaUKrjhQIXvuauJNe9mbWWNo0c32d7WvONWy3f/pQpvf/siV1018bLNkj3P8zzP8zzP87w3G984+ErxIc3rZGGhwc/93J3cc88p7rnnFCsrPax146Y//OGrufvufRw58vLjpj//MDx91lCsPEU+Kqm0p6lWIMumsGqKONlFmAqa/RhCtBFQNQRzKWZLI1s11KG92J7CdANkoSjXchiAECPC8CTY+1hcaHHDzROcWR+h9Qp5NovtK5rVnFtvanHH7W4S1E43pJfuZf+hvfz898MPfORSf2LP8zzP8zzP8zzP816cD2leRxMTFT760Wv48IevYn19gNaGdjthauqVjZvWGr52FKJil+7GLkHSpByvairyFqCxQYBNBwjRQ4lJjDSEB1JkWGJtiZzSCCERTY2oF8hGjmxC/nAA9JieDZiYuZ4k7lMMN5mrQzOAQ3t2qDSvQoYLWFHh8acE1kKjDnfdDnffBW+70Qc0nud5nud5nud5b32ai9OCX9PneOvzIc0bQKUSfkvjpksDeQESjTEGK9XF89ZaiRBmPH5JAMZN2hag6gabSVctNm6HIxAIqVCRRLQ1pRKEYcievXu55pY5FmeH/JWPbJOmJVGk2Lu3xZ49DXZ2BU8/A2kKYQhLi7B3jw9nPM/zPM/zPM/zPO/V8iHNm1gUwIE5OHe2Rp7N0l+roW2FItYIU2JtBWlSrABE5IKTQqC3A5KbBmS7YIcSUbvQJdsiItCbCiEEQSCpN2NGIzi4v8oddzy/wmdyAu649dv4oj3P8zzP8zzP87w3GN+T5krxIc2bxGCQc/78gDBULC42kFIgBMyhOfolyc7WIfJ+F02ATiWlDbEqoRAjKlGCVDWyHBACvaP+/+3deXQV5f3H8c9dktzsISFkkQAxyBYg7DGiQSQ1tIUeWqrgsZxAKWgLRURtoRaC1l3hB8imHiWtIqJV0B8/RWyQQCqCgIlatqhhkSUhkBCyJ/fO7w/k1msSFiHMBd+vc+4f95mZZz6TzIHM9zzzPPLvUS1V+ahmh48MH5csfpI1zCmjXKo/4COLpVoBgT6KiAyUzSYN6GP2TwAAAAAAgKsbRRovV1vboP/7vwLl5OzT8ePVstut6tgxXMOHd5IUqU3v1apNgGRPCFadTunU8RNqqHPJMCyS0UYuv2BZAwNks1tldUk2w5Cr2KbqrQEKGHRKsrtU96VdrkpDzlKLnAetMmpqFBJqUXRsmKrr7bq+l5TYxeyfBAAAAADAOzGS5lKhSOPFXC5Df/97vt5//0uFhTkUGxus+nqnPvusSF9/fVI2W4pOnQrQzckWFRTWqqLYKmdNkJz19XJY69VQV6yqqgYZfjWKvKa17OEWWQxDtbUWVf47WEahQwHXVcsW7VTDN4ZcRw1ZayX/1kEKcDjk4++r3kkW/e43p+ebAQAAAAAALYcijRcrKDiuTZv2q23bELVq5f9tq49CQvy0eXONjhyp0NChgZKkQ/uPKUjVanVNgA4d8ZOPj5/swYaMY3WqLquXo61NrVqFfdvHmTlobNI3QaqqlsoqpTK7VOMwZLMaioy06Pd32TXyF5L7MAAAAAAAGmEkzaVCkcaL7dx5TJWV9UpICPdot1gs8vePUHl5g+rr61Rba6isrEYhIX7y8ZGqa6SSE5LF16KwcKuKi6Qjh8oVGBQqX7/Gyy4F+Et22+mRO5FhhpJ6WHTvVF/172+9XJcKAAAAAMCPHkUaL1ZX52x2KWubzSqXy5DLZcjpdMnlMmSznZ5MuE1ryTCkE6WSzWaRr68UEmboVLkhp9NQYJDkH3B6X5dTKi2TTpYZigiXBt1o1e9/76MuXWyX9VoBAAAAAFcq57eflj7H1Y8ijcmKiiq0bdth7d17Qg0NTkVHB6lfv1h17txaMTHBslgsamhwyW73HNVSW1slX99wORx2WSySn59N1dUNCgryldUqRbeRfH2kY8cNuVwWhUcHKaGjn44ddarosFNlpYZqak8XcwKDpBtvtGnCOJuSk20KDGymMgQAAAAAAFoMRRqTOJ0urVmzV2vW7NWxY5UyjNOvMRmGofff/0q9ekXr9tsTFR8fpj17StS5c2t3oaa8vFYWS6WuvTZBJ09aFRNjVbt2odq9u0Q+Plb5+Z0u3ISFulRWKsXHWzQ0PVKHi23y87cpPMql+lpDkeHSgL7SgL4WdU+0yGqlOAMAAAAAuFDMSXOpUKQxyf/+716tWPG56utdKiurVWlptQxDCgg4vYzSpk0HVFvboDvv7Knlyz/T7t0lMgxDhmHI399HP/tZOwUEhOuddwwFBxvq2rW1qqsb9M035Sorq5Ek1df7KjAwSH/+U5jGjPFV2UmppkYyZJXDTwoNkWy81QQAAAAAgFegSGOCw4dPac2avWpocKmwsFR1dU6FhPjJarWoqqpee/eeUHx8mLZvP6L+/a/RzJmDtGPHER06VC5fX5u6dYtUly6tVV9vUUVFnXJynPLxsahLlxjFxobp6NFqnThhVWCgj+64I1B33ukvi4VVmgAAAAAALcFQy490Mc69y1WAIo0Jtm49pJKSKp04Ua36epciIwPd20JDbfL1rdehQ+WKjw/Thg37NHhwB918c4dG/dhs0sSJvkpMdConp0GFhS7V1/srKspfgwbZlJp6eo4ZXmMCAAAAAMD7UaQxwa5dJZLkXjb7+xwOu8rLa2UYp0fdHDtWpdjY4Cb78vW1aPBgu1JTbTp82FBtrRQQIMXEWGRpbmkoAAAAAAAuGeakuVQo0pigvt4p6fTy2U2NcjlTXDEMuZfYPhebzaK4OIoyAAAAAABcqazn3gWXWlTU6debHA67qqvrG213Ol2yWE6vtuTvb29ytA0AAAAAALi6UKQxwYAB18jh8FFMTJCqqhpUW9vg3uZyGTp+vFqtWjlkGIaSk9sqNNRhYloAAAAAAM7GeZk+Vz+KNCbo3r2NunWLlMViUVxciCoq6lRUVKmiogqVlFQpLMyhqKhARUYGKjW1vdlxAQAAAADAZcCcNCbw8bFpwoQ+qq936vPPi5SQ0Mo9/4zNZpXL5VJ4eIDGjeuljh3DzY4LAAAAAMBZMHHwpUKRxiQxMcGaNi1FOTn7lZOzX8eOVcowDAUE+Kh//2s0aFB7de7c2uyYAAAAAADgMqFIY6JWrfw1YkQXpacnqKioUk6nS2FhDkVEBJgdDQAAAACA88RImkuFIo0X8Pf3UYcOYWbHAAAAAAAAJqJIAwAAAAAALsLlWH2J1Z0AAAAAAABwmTCSBgAAAAAAXATmpLlUGEkDAAAAAADgBRhJAwAAAAAALgIjaS4VRtIAAAAAAAB4AUbSAAAAAACAi2Co5Ue6GC3cv3dgJA0AAAAAAIAXYCQNAAAAAAC4CM5vPy19jqsfI2kAAAAAAAC8ACNpAAAAAADARWB1p0uFkTQAAAAAAABegCINAAAAAACAF6BIAwAAAAAALoLrMn0u3KJFi9ShQwc5HA4lJydr69atP+wSLxOKNAAAAAAA4KqzcuVKTZs2TZmZmdqxY4eSkpKUnp6u4uJis6M1iyINAAAAAAC4CN45kmbu3LmaMGGCxo0bp27dumnp0qUKCAjQSy+99MMvtYVd9as7GYYhSSovLzc5CQAAAADgx+DM8+eZ59GrXW1t1WU7x/ef7f38/OTn59do/7q6Om3fvl0zZsxwt1mtVqWlpWnz5s0tG/YiXPVFmlOnTkmS4uLiTE4CAAAAAPgxOXXqlEJDQ82O0WJ8fX0VHR2t//mf2y/L+YKCgho922dmZmr27NmN9i0pKZHT6VRUVJRHe1RUlHbv3t2SMS/KVV+kiY2N1cGDBxUcHCyLxWJajvLycsXFxengwYMKCQkxLQdwPrhfcaXhnsWVhPsVVxruWVxJvOV+NQxDp06dUmxsrGkZLgeHw6HCwkLV1dVdlvMZhtHoub6pUTRXsqu+SGO1WtW2bVuzY7iFhITwnxuuGNyvuNJwz+JKwv2KKw33LK4k3nC/Xs0jaL7L4XDI4XCYHaOR1q1by2azqaioyKO9qKhI0dHRJqU6NyYOBgAAAAAAVxVfX1/17dtX2dnZ7jaXy6Xs7GylpKSYmOzsrvqRNAAAAAAA4Mdn2rRpysjIUL9+/TRgwADNmzdPlZWVGjdunNnRmkWR5jLx8/NTZmbmVfe+HK5O3K+40nDP4krC/YorDfcsriTcr/iuUaNG6dixY5o1a5aOHj2qXr16ae3atY0mE/YmFuPHsiYYAAAAAACAF2NOGgAAAAAAAC9AkQYAAAAAAMALUKQBAAAAAADwAhRpAAAAAAAAvABFmstg0aJF6tChgxwOh5KTk7V161azIwFNevzxx9W/f38FBwerTZs2GjFihPbs2WN2LOC8PPHEE7JYLJo6darZUYBmHTp0SL/5zW8UEREhf39/9ejRQ9u2bTM7FtCI0+nUzJkzFR8fL39/fyUkJOhvf/ubWHME3mLjxo0aPny4YmNjZbFYtHr1ao/thmFo1qxZiomJkb+/v9LS0lRQUGBOWOACUKRpYStXrtS0adOUmZmpHTt2KCkpSenp6SouLjY7GtBITk6OJk2apI8//lgffPCB6uvrdeutt6qystLsaMBZffLJJ3ruuefUs2dPs6MAzSotLdXAgQPl4+Oj9957Tzt37tScOXPUqlUrs6MBjTz55JNasmSJFi5cqF27dunJJ5/UU089pWeffdbsaIAkqbKyUklJSVq0aFGT25966iktWLBAS5cu1ZYtWxQYGKj09HTV1NRc5qTAhWEJ7haWnJys/v37a+HChZIkl8uluLg4/fGPf9T06dNNTgec3bFjx9SmTRvl5OQoNTXV7DhAkyoqKtSnTx8tXrxYjzzyiHr16qV58+aZHQtoZPr06fr3v/+tTZs2mR0FOKdhw4YpKipKL774ortt5MiR8vf31yuvvGJiMqAxi8WiVatWacSIEZJOj6KJjY3Vfffdp/vvv1+SdPLkSUVFRSkrK0ujR482MS1wdoykaUF1dXXavn270tLS3G1Wq1VpaWnavHmzicmA83Py5ElJUnh4uMlJgOZNmjRJP//5zz3+rQW80TvvvKN+/frptttuU5s2bdS7d2+98MILZscCmnTDDTcoOztbe/fulSTl5+crNzdXP/3pT01OBpxbYWGhjh496vG3QWhoqJKTk3kOg9ezmx3galZSUiKn06moqCiP9qioKO3evdukVMD5cblcmjp1qgYOHKju3bubHQdo0muvvaYdO3bok08+MTsKcE5ff/21lixZomnTpukvf/mLPvnkE02ZMkW+vr7KyMgwOx7gYfr06SovL1eXLl1ks9nkdDr16KOP6s477zQ7GnBOR48elaQmn8PObAO8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", "text/plain": [ "
" ] @@ -1434,7 +1402,7 @@ }, { "data": { - "image/png": "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X34Zhw4dQmlpKSwWCwAgJycHvXv3bva4GRkZqK6uxoQJE5o8bzQaMWjQoHb4JkQkNwwmRGR3VVVVSEhIQEJCAr766isEBQUhJycHCQkJMBqNl/1cZWUlAGDTpk3o1KlTk9e0Wm271kxE8sBgQkQ269q1K9RqNfbu3YuoqCgAQGlpKdLT0zFmzBikpqaiuLgYS5YsQWRkJID6SzkX0mg0AACz2Wx9rnfv3tBqtcjJycGYMWMc9G2ISE4YTIjIZl5eXpg9ezaeeuopBAQEIDg4GM8//zwUivr19FFRUdBoNHj//ffxyCOP4OjRo3jttdeaHKNz584QBAE//vgjbrzxRri7u8Pb2xtPPvkk5s+fD4vFglGjRkGv12PXrl3w8fHBrFmzpPi6RORAvCuHiFrlX//6F6699lpMnjwZ48ePx6hRoxAXFwcACAoKwqeffoq1a9eid+/eWLJkCd58880mn+/UqRNeeeUVPPvsswgJCcHjjz8OAHjttdfw97//HYsXL0avXr1www03YNOmTejSpYvDvyMROR7vyiEiIiLZ4IwJERERyQaDCREREckGgwkRERHJBoMJERERyQaDCREREckGgwkRERHJBoMJERERyQaDCREREckGgwkRERHJBoMJERERyQaDCREREcnG/wO2QTfrb4+kxgAAAABJRU5ErkJggg==", + "image/png": "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X34Zhw4dQmlpKSwWCwAgJycHvXv3bva4GRkZqK6uxoQJE5o8bzQaMWjQoHb4JkQkNwwmRGR3VVVVSEhIQEJCAr766isEBQUhJycHCQkJMBqNl/1cZWUlAGDTpk3o1KlTk9e0Wm271kxE8sBgQkQ269q1K9RqNfbu3YuoqCgAQGlpKdLT0zFmzBikpqaiuLgYS5YsQWRkJID6SzkX0mg0AACz2Wx9rnfv3tBqtcjJycGYMWMc9G2ISE4YTIjIZl5eXpg9ezaeeuopBAQEIDg4GM8//zwUivr19FFRUdBoNHj//ffxyCOP4OjRo3jttdeaHKNz584QBAE//vgjbrzxRri7u8Pb2xtPPvkk5s+fD4vFglGjRkGv12PXrl3w8fHBrFmzpPi6RORAvCuHiFrlX//6F6699lpMnjwZ48ePx6hRoxAXFwcACAoKwqeffoq1a9eid+/eWLJkCd58880mn+/UqRNeeeUVPPvsswgJCcHjjz8OAHjttdfw97//HYsXL0avXr1www03YNOmTejSpYvDvyMROR7vyiEiIiLZ4IwJERERyQaDCREREckGgwkRERHJBoMJERERyQaDCREREckGgwkRERHJBoMJERERyQaDCREREckGgwkRERHJBoMJERERyQaDCREREcnG/wO2QTfrb4+kxgAAAABJRU5ErkJggg==", "text/plain": [ "
" ] @@ -1472,7 +1440,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.10" + "version": "3.10.16" } }, "nbformat": 4, diff --git a/noxfile.py b/noxfile.py index f2be8045b1..2ab56da8ad 100644 --- a/noxfile.py +++ b/noxfile.py @@ -803,7 +803,6 @@ def notebook(session: nox.Session): "notebooks/generative_ai/bq_dataframes_llm_code_generation.ipynb", # Needs BUCKET_URI. "notebooks/generative_ai/sentiment_analysis.ipynb", # Too slow "notebooks/generative_ai/bq_dataframes_llm_gemini_2.ipynb", # Gemini 2.0 backend hasn't ready in prod. - "notebooks/generative_ai/bq_dataframes_llm_vector_search.ipynb", # Needs DATASET_ID. "notebooks/generative_ai/bq_dataframes_ml_drug_name_generation.ipynb", # Needs CONNECTION. # TODO(b/366290533): to protect BQML quota "notebooks/generative_ai/bq_dataframes_llm_claude3_museum_art.ipynb", diff --git a/tests/system/large/blob/test_function.py b/tests/system/large/blob/test_function.py index c8fa63d493..70c3084261 100644 --- a/tests/system/large/blob/test_function.py +++ b/tests/system/large/blob/test_function.py @@ -419,7 +419,7 @@ def test_blob_transcribe( actual = ( audio_mm_df["audio"] .blob.audio_transcribe( - model_name=model_name, + model_name=model_name, # type: ignore verbose=verbose, ) .to_pandas() diff --git a/tests/system/small/test_unordered.py b/tests/system/small/test_unordered.py index 867067a161..9cfa54146a 100644 --- a/tests/system/small/test_unordered.py +++ b/tests/system/small/test_unordered.py @@ -195,15 +195,13 @@ def test_unordered_mode_no_ordering_error(unordered_session): df.merge(df, on="a").head(3) -def test_unordered_mode_ambiguity_warning(unordered_session): +def test_unordered_mode_allows_ambiguity(unordered_session): pd_df = pd.DataFrame( {"a": [1, 2, 3, 4, 5, 1], "b": [4, 5, 9, 3, 1, 6]}, dtype=pd.Int64Dtype() ) pd_df.index = pd_df.index.astype(pd.Int64Dtype()) df = bpd.DataFrame(pd_df, session=unordered_session) - - with pytest.warns(bigframes.exceptions.AmbiguousWindowWarning): - df.merge(df, on="a").sort_values("b_x").head(3) + df.merge(df, on="a").sort_values("b_x").head(3) def test_unordered_mode_no_ambiguity_warning(unordered_session): diff --git a/third_party/bigframes_vendored/pandas/core/generic.py b/third_party/bigframes_vendored/pandas/core/generic.py index 48f33c67fd..273339efcf 100644 --- a/third_party/bigframes_vendored/pandas/core/generic.py +++ b/third_party/bigframes_vendored/pandas/core/generic.py @@ -17,6 +17,9 @@ class NDFrame(indexing.IndexingMixin): size-mutable, labeled data structure """ + # Explicitly mark the class as unhashable + __hash__ = None # type: ignore + # ---------------------------------------------------------------------- # Axis From c390da11b7c2aa710bc2fbc692efb9f06059e4c4 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 2 Oct 2025 10:40:31 -0700 Subject: [PATCH 117/313] fix: Fix internal type errors with temporal accessors (#2125) --- bigframes/operations/datetimes.py | 14 ++++++-------- tests/system/small/operations/test_dates.py | 18 ++++++++++++++++++ .../ibis/expr/operations/strings.py | 4 ++-- .../ibis/expr/operations/temporal.py | 2 +- 4 files changed, 27 insertions(+), 11 deletions(-) diff --git a/bigframes/operations/datetimes.py b/bigframes/operations/datetimes.py index 95896ddc97..c80379cc2b 100644 --- a/bigframes/operations/datetimes.py +++ b/bigframes/operations/datetimes.py @@ -23,7 +23,6 @@ from bigframes import dataframe, dtypes, series from bigframes.core import log_adapter -from bigframes.core.reshape import concat import bigframes.operations as ops import bigframes.operations.base @@ -79,13 +78,12 @@ def month(self) -> series.Series: return self._apply_unary_op(ops.month_op) def isocalendar(self) -> dataframe.DataFrame: - years = self._apply_unary_op(ops.iso_year_op) - weeks = self._apply_unary_op(ops.iso_week_op) - days = self._apply_unary_op(ops.iso_day_op) - - result = concat.concat([years, weeks, days], axis=1) - result.columns = pandas.Index(["year", "week", "day"]) - return result + iso_ops = [ops.iso_year_op, ops.iso_week_op, ops.iso_day_op] + labels = pandas.Index(["year", "week", "day"]) + block = self._block.project_exprs( + [op.as_expr(self._value_column) for op in iso_ops], labels, drop=True + ) + return dataframe.DataFrame(block) # Time accessors @property diff --git a/tests/system/small/operations/test_dates.py b/tests/system/small/operations/test_dates.py index e183bbfe43..9e8da64209 100644 --- a/tests/system/small/operations/test_dates.py +++ b/tests/system/small/operations/test_dates.py @@ -15,8 +15,10 @@ import datetime +from packaging import version import pandas as pd import pandas.testing +import pytest from bigframes import dtypes @@ -71,3 +73,19 @@ def test_date_series_diff_agg(scalars_dfs): pandas.testing.assert_series_equal( actual_result, expected_result, check_index_type=False ) + + +def test_date_can_cast_after_accessor(scalars_dfs): + if version.Version(pd.__version__) <= version.Version("2.1.0"): + pytest.skip("pd timezone conversion bug") + bf_df, pd_df = scalars_dfs + + actual_result = bf_df["date_col"].dt.isocalendar().week.astype("Int64").to_pandas() + # convert to pd date type rather than arrow, as pandas doesn't handle arrow date well here + expected_result = ( + pd.to_datetime(pd_df["date_col"]).dt.isocalendar().week.astype("Int64") + ) + + pandas.testing.assert_series_equal( + actual_result, expected_result, check_dtype=False, check_index_type=False + ) diff --git a/third_party/bigframes_vendored/ibis/expr/operations/strings.py b/third_party/bigframes_vendored/ibis/expr/operations/strings.py index ffd93e6fdd..050c079b6b 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/strings.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/strings.py @@ -364,14 +364,14 @@ class ExtractFragment(ExtractURLField): class StringLength(StringUnary): """Compute the length of a string.""" - dtype = dt.int32 + dtype = dt.int64 @public class StringAscii(StringUnary): """Compute the ASCII code of the first character of a string.""" - dtype = dt.int32 + dtype = dt.int64 @public diff --git a/third_party/bigframes_vendored/ibis/expr/operations/temporal.py b/third_party/bigframes_vendored/ibis/expr/operations/temporal.py index 75a0ef9efc..b527e14f04 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/temporal.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/temporal.py @@ -105,7 +105,7 @@ class ExtractTemporalField(Unary): """Extract a field from a temporal value.""" arg: Value[dt.Temporal] - dtype = dt.int32 + dtype = dt.int64 @public From 32502f4195306d262788f39d1ab4206fc84ae50e Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Thu, 2 Oct 2025 16:18:13 -0700 Subject: [PATCH 118/313] feat: add ai.if_() and ai.score() to bigframes.bigquery package (#2132) * feat: add ai.if_() and ai.score() to bigframes.bigquery package * deflake ai score doc test --- bigframes/bigquery/_operations/ai.py | 94 +++++++++++++++++++ .../ibis_compiler/scalar_op_registry.py | 18 ++++ .../compile/sqlglot/expressions/ai_ops.py | 25 ++++- bigframes/operations/__init__.py | 4 + bigframes/operations/ai_ops.py | 22 +++++ tests/system/small/bigquery/test_ai.py | 44 +++++++++ .../snapshots/test_ai_ops/test_ai_if/out.sql | 16 ++++ .../test_ai_ops/test_ai_score/out.sql | 16 ++++ .../sqlglot/expressions/test_ai_ops.py | 30 ++++++ .../sql/compilers/bigquery/__init__.py | 6 ++ .../ibis/expr/operations/ai_ops.py | 28 ++++++ 11 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score/out.sql diff --git a/bigframes/bigquery/_operations/ai.py b/bigframes/bigquery/_operations/ai.py index 3893ad12d1..4759c99016 100644 --- a/bigframes/bigquery/_operations/ai.py +++ b/bigframes/bigquery/_operations/ai.py @@ -337,6 +337,100 @@ def generate_double( return series_list[0]._apply_nary_op(operator, series_list[1:]) +@log_adapter.method_logger(custom_base_name="bigquery_ai") +def if_( + prompt: PROMPT_TYPE, + *, + connection_id: str | None = None, +) -> series.Series: + """ + Evaluates the prompt to True or False. Compared to `ai.generate_bool()`, this function + provides optimization such that not all rows are evaluated with the LLM. + + **Examples:** + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> bpd.options.display.progress_bar = None + >>> us_state = bpd.Series(["Massachusetts", "Illinois", "Hawaii"]) + >>> bbq.ai.if_((us_state, " has a city called Springfield")) + 0 True + 1 True + 2 False + dtype: boolean + + >>> us_state[bbq.ai.if_((us_state, " has a city called Springfield"))] + 0 Massachusetts + 1 Illinois + dtype: string + + Args: + prompt (Series | List[str|Series] | Tuple[str|Series, ...]): + A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series + or pandas Series. + connection_id (str, optional): + Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. + If not provided, the connection from the current session will be used. + + Returns: + bigframes.series.Series: A new series of bools. + """ + + prompt_context, series_list = _separate_context_and_series(prompt) + assert len(series_list) > 0 + + operator = ai_ops.AIIf( + prompt_context=tuple(prompt_context), + connection_id=_resolve_connection_id(series_list[0], connection_id), + ) + + return series_list[0]._apply_nary_op(operator, series_list[1:]) + + +@log_adapter.method_logger(custom_base_name="bigquery_ai") +def score( + prompt: PROMPT_TYPE, + *, + connection_id: str | None = None, +) -> series.Series: + """ + Computes a score based on rubrics described in natural language. It will return a double value. + There is no fixed range for the score returned. To get high quality results, provide a scoring + rubric with examples in the prompt. + + **Examples:** + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> bpd.options.display.progress_bar = None + >>> animal = bpd.Series(["Tiger", "Rabbit", "Blue Whale"]) + >>> bbq.ai.score(("Rank the relative weights of ", animal, " on the scale from 1 to 3")) # doctest: +SKIP + 0 2.0 + 1 1.0 + 2 3.0 + dtype: Float64 + + Args: + prompt (Series | List[str|Series] | Tuple[str|Series, ...]): + A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series + or pandas Series. + connection_id (str, optional): + Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. + If not provided, the connection from the current session will be used. + + Returns: + bigframes.series.Series: A new series of double (float) values. + """ + + prompt_context, series_list = _separate_context_and_series(prompt) + assert len(series_list) > 0 + + operator = ai_ops.AIScore( + prompt_context=tuple(prompt_context), + connection_id=_resolve_connection_id(series_list[0], connection_id), + ) + + return series_list[0]._apply_nary_op(operator, series_list[1:]) + + def _separate_context_and_series( prompt: PROMPT_TYPE, ) -> Tuple[List[str | None], List[series.Series]]: diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index a0750ec73d..7280e9a40a 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -2030,6 +2030,24 @@ def ai_generate_double( ).to_expr() +@scalar_op_compiler.register_nary_op(ops.AIIf, pass_op=True) +def ai_if(*values: ibis_types.Value, op: ops.AIIf) -> ibis_types.StructValue: + + return ai_ops.AIIf( + _construct_prompt(values, op.prompt_context), # type: ignore + op.connection_id, # type: ignore + ).to_expr() + + +@scalar_op_compiler.register_nary_op(ops.AIScore, pass_op=True) +def ai_score(*values: ibis_types.Value, op: ops.AIScore) -> ibis_types.StructValue: + + return ai_ops.AIScore( + _construct_prompt(values, op.prompt_context), # type: ignore + op.connection_id, # type: ignore + ).to_expr() + + def _construct_prompt( col_refs: tuple[ibis_types.Value], prompt_context: tuple[str | None] ) -> ibis_types.StructValue: diff --git a/bigframes/core/compile/sqlglot/expressions/ai_ops.py b/bigframes/core/compile/sqlglot/expressions/ai_ops.py index 3f909ebc92..46a79d1440 100644 --- a/bigframes/core/compile/sqlglot/expressions/ai_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/ai_ops.py @@ -54,6 +54,20 @@ def _(*exprs: TypedExpr, op: ops.AIGenerateDouble) -> sge.Expression: return sge.func("AI.GENERATE_DOUBLE", *args) +@register_nary_op(ops.AIIf, pass_op=True) +def _(*exprs: TypedExpr, op: ops.AIIf) -> sge.Expression: + args = [_construct_prompt(exprs, op.prompt_context)] + _construct_named_args(op) + + return sge.func("AI.IF", *args) + + +@register_nary_op(ops.AIScore, pass_op=True) +def _(*exprs: TypedExpr, op: ops.AIScore) -> sge.Expression: + args = [_construct_prompt(exprs, op.prompt_context)] + _construct_named_args(op) + + return sge.func("AI.SCORE", *args) + + def _construct_prompt( exprs: tuple[TypedExpr, ...], prompt_context: tuple[str | None, ...] ) -> sge.Kwarg: @@ -83,10 +97,13 @@ def _construct_named_args(op: ops.NaryOp) -> list[sge.Kwarg]: if endpoit is not None: args.append(sge.Kwarg(this="endpoint", expression=sge.Literal.string(endpoit))) - request_type = typing.cast(str, op_args["request_type"]).upper() - args.append( - sge.Kwarg(this="request_type", expression=sge.Literal.string(request_type)) - ) + request_type = typing.cast(str, op_args.get("request_type", None)) + if request_type is not None: + args.append( + sge.Kwarg( + this="request_type", expression=sge.Literal.string(request_type.upper()) + ) + ) model_params = typing.cast(str, op_args.get("model_params", None)) if model_params is not None: diff --git a/bigframes/operations/__init__.py b/bigframes/operations/__init__.py index 031e42cf03..e7d0751fc9 100644 --- a/bigframes/operations/__init__.py +++ b/bigframes/operations/__init__.py @@ -19,6 +19,8 @@ AIGenerateBool, AIGenerateDouble, AIGenerateInt, + AIIf, + AIScore, ) from bigframes.operations.array_ops import ( ArrayIndexOp, @@ -421,6 +423,8 @@ "AIGenerateBool", "AIGenerateDouble", "AIGenerateInt", + "AIIf", + "AIScore", # Numpy ops mapping "NUMPY_TO_BINOP", "NUMPY_TO_OP", diff --git a/bigframes/operations/ai_ops.py b/bigframes/operations/ai_ops.py index 5d710bf6b5..05d37d2a90 100644 --- a/bigframes/operations/ai_ops.py +++ b/bigframes/operations/ai_ops.py @@ -110,3 +110,25 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT ) ) ) + + +@dataclasses.dataclass(frozen=True) +class AIIf(base_ops.NaryOp): + name: ClassVar[str] = "ai_if" + + prompt_context: Tuple[str | None, ...] + connection_id: str + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return dtypes.BOOL_DTYPE + + +@dataclasses.dataclass(frozen=True) +class AIScore(base_ops.NaryOp): + name: ClassVar[str] = "ai_score" + + prompt_context: Tuple[str | None, ...] + connection_id: str + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return dtypes.FLOAT_DTYPE diff --git a/tests/system/small/bigquery/test_ai.py b/tests/system/small/bigquery/test_ai.py index 890cd4fb2b..91499d0efe 100644 --- a/tests/system/small/bigquery/test_ai.py +++ b/tests/system/small/bigquery/test_ai.py @@ -203,5 +203,49 @@ def test_ai_generate_double_multi_model(session): ) +def test_ai_if(session): + s1 = bpd.Series(["apple", "bear"], session=session) + s2 = bpd.Series(["fruit", "tree"], session=session) + prompt = (s1, " is a ", s2) + + result = bbq.ai.if_(prompt) + + assert _contains_no_nulls(result) + assert result.dtype == dtypes.BOOL_DTYPE + + +def test_ai_if_multi_model(session): + df = session.from_glob_path( + "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" + ) + + result = bbq.ai.if_((df["image"], " contains an animal")) + + assert _contains_no_nulls(result) + assert result.dtype == dtypes.BOOL_DTYPE + + +def test_ai_score(session): + s = bpd.Series(["Tiger", "Rabbit"], session=session) + prompt = ("Rank the relative weights of ", s, " on the scale from 1 to 3") + + result = bbq.ai.score(prompt) + + assert _contains_no_nulls(result) + assert result.dtype == dtypes.FLOAT_DTYPE + + +def test_ai_score_multi_model(session): + df = session.from_glob_path( + "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" + ) + prompt = ("Rank the liveliness of ", df["image"], "on the scale from 1 to 3") + + result = bbq.ai.score(prompt) + + assert _contains_no_nulls(result) + assert result.dtype == dtypes.FLOAT_DTYPE + + def _contains_no_nulls(s: series.Series) -> bool: return len(s) == s.count() diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if/out.sql new file mode 100644 index 0000000000..d5b6a9330d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.IF( + prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + connection_id => 'bigframes-dev.us.bigframes-default-connection' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score/out.sql new file mode 100644 index 0000000000..e2be615921 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.SCORE( + prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + connection_id => 'bigframes-dev.us.bigframes-default-connection' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py index f20b39bc74..8f048a5bbf 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py @@ -199,3 +199,33 @@ def test_ai_generate_double_with_model_param( ) snapshot.assert_match(sql, "out.sql") + + +def test_ai_if(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + + op = ops.AIIf( + prompt_context=(None, " is the same as ", None), + connection_id=CONNECTION_ID, + ) + + sql = utils._apply_unary_ops( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_score(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + + op = ops.AIScore( + prompt_context=(None, " is the same as ", None), + connection_id=CONNECTION_ID, + ) + + sql = utils._apply_unary_ops( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") diff --git a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py index 836d15118c..8603c89cc8 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py @@ -1116,6 +1116,12 @@ def visit_AIGenerateInt(self, op, **kwargs): def visit_AIGenerateDouble(self, op, **kwargs): return sge.func("AI.GENERATE_DOUBLE", *self._compile_ai_args(**kwargs)) + def visit_AIIf(self, op, **kwargs): + return sge.func("AI.IF", *self._compile_ai_args(**kwargs)) + + def visit_AIScore(self, op, **kwargs): + return sge.func("AI.SCORE", *self._compile_ai_args(**kwargs)) + def _compile_ai_args(self, **kwargs): args = [] diff --git a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py index 05c5e7e0af..5289ee7e60 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py @@ -91,3 +91,31 @@ def dtype(self) -> dt.Struct: ("status", dt.string), ) ) + + +@public +class AIIf(Value): + """Generate True/False based on the prompt""" + + prompt: Value + connection_id: Value[dt.String] + + shape = rlz.shape_like("prompt") + + @attribute + def dtype(self) -> dt.Struct: + return dt.bool + + +@public +class AIScore(Value): + """Generate doubles based on the prompt""" + + prompt: Value + connection_id: Value[dt.String] + + shape = rlz.shape_like("prompt") + + @attribute + def dtype(self) -> dt.Struct: + return dt.float64 From 9c6aa8ed89b8e7686a3c20cd4cd5ea3e091a82df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Fri, 3 Oct 2025 15:36:43 -0500 Subject: [PATCH 119/313] chore: skip vector search notebook tests (#2134) Avoids error "Forbidden: 403 POST https://bigquery.googleapis.com/bigquery/v2/projects/bigframes-dev/queries?prettyPrint=false: Quota exceeded: Your table exceeded quota for vector index ddl statements on table. For more information, see https://cloud.google.com/bigquery/docs/troubleshoot-quotas " --- noxfile.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 2ab56da8ad..a46dc36b3e 100644 --- a/noxfile.py +++ b/noxfile.py @@ -8,7 +8,7 @@ # # https://www.apache.org/licenses/LICENSE-2.0 # -# Unless required by applicable law or agreed to in writing, software +# Unless required by applicable law or agreed to in writing, software/ # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and @@ -803,6 +803,7 @@ def notebook(session: nox.Session): "notebooks/generative_ai/bq_dataframes_llm_code_generation.ipynb", # Needs BUCKET_URI. "notebooks/generative_ai/sentiment_analysis.ipynb", # Too slow "notebooks/generative_ai/bq_dataframes_llm_gemini_2.ipynb", # Gemini 2.0 backend hasn't ready in prod. + "notebooks/generative_ai/bq_dataframes_llm_vector_search.ipynb", # Limited quota for vector index ddl statements on table. "notebooks/generative_ai/bq_dataframes_ml_drug_name_generation.ipynb", # Needs CONNECTION. # TODO(b/366290533): to protect BQML quota "notebooks/generative_ai/bq_dataframes_llm_claude3_museum_art.ipynb", From 67e46cd47933b84b55808003ed344b559e47c498 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Mon, 6 Oct 2025 10:23:18 -0700 Subject: [PATCH 120/313] perf: Scale read stream workers to cpu count (#2135) --- bigframes/session/bq_caching_executor.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/bigframes/session/bq_caching_executor.py b/bigframes/session/bq_caching_executor.py index b7412346bd..cbda9bc640 100644 --- a/bigframes/session/bq_caching_executor.py +++ b/bigframes/session/bq_caching_executor.py @@ -15,6 +15,7 @@ from __future__ import annotations import math +import os import threading from typing import Literal, Mapping, Optional, Sequence, Tuple import warnings @@ -58,7 +59,7 @@ MAX_SUBTREE_FACTORINGS = 5 _MAX_CLUSTER_COLUMNS = 4 MAX_SMALL_RESULT_BYTES = 10 * 1024 * 1024 * 1024 # 10G - +_MAX_READ_STREAMS = os.cpu_count() SourceIdMapping = Mapping[str, str] @@ -323,7 +324,10 @@ def _export_gbq( self.bqclient.update_table(table, ["schema"]) return executor.ExecuteResult( - row_iter.to_arrow_iterable(), + row_iter.to_arrow_iterable( + bqstorage_client=self.bqstoragereadclient, + max_stream_count=_MAX_READ_STREAMS, + ), array_value.schema, query_job, total_bytes_processed=row_iter.total_bytes_processed, @@ -668,7 +672,8 @@ def _execute_plan_gbq( return executor.ExecuteResult( _arrow_batches=iterator.to_arrow_iterable( - bqstorage_client=self.bqstoragereadclient + bqstorage_client=self.bqstoragereadclient, + max_stream_count=_MAX_READ_STREAMS, ), schema=og_schema, query_job=query_job, From eca22ee3104104cea96189391e527cad09bd7509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Mon, 6 Oct 2025 12:31:18 -0500 Subject: [PATCH 121/313] fix: only show JSON dtype warning when accessing dtypes directly (#2136) * fix: only show JSON dtype warning when accessing dtypes directly * Update license year * fix mypy * fix unit tests --------- Co-authored-by: Chelsea Lin --- bigframes/core/array_value.py | 8 -- bigframes/core/backports.py | 33 +++++++++ bigframes/core/indexes/base.py | 8 +- bigframes/dataframe.py | 4 +- bigframes/dtypes.py | 50 ++++++++++++- bigframes/exceptions.py | 4 + bigframes/series.py | 2 + .../{test_dtypes.py => test_ibis_types.py} | 33 --------- tests/unit/test_dtypes.py | 73 +++++++++++++++++++ 9 files changed, 167 insertions(+), 48 deletions(-) create mode 100644 bigframes/core/backports.py rename tests/unit/core/{test_dtypes.py => test_ibis_types.py} (87%) create mode 100644 tests/unit/test_dtypes.py diff --git a/bigframes/core/array_value.py b/bigframes/core/array_value.py index e7bf7b14fa..a89d224ad1 100644 --- a/bigframes/core/array_value.py +++ b/bigframes/core/array_value.py @@ -18,7 +18,6 @@ import functools import typing from typing import Iterable, List, Mapping, Optional, Sequence, Tuple -import warnings import google.cloud.bigquery import pandas @@ -37,7 +36,6 @@ import bigframes.core.tree_properties from bigframes.core.window_spec import WindowSpec import bigframes.dtypes -import bigframes.exceptions as bfe import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops @@ -101,12 +99,6 @@ def from_table( ): if offsets_col and primary_key: raise ValueError("must set at most one of 'offests', 'primary_key'") - if any(i.field_type == "JSON" for i in table.schema if i.name in schema.names): - msg = bfe.format_message( - "JSON column interpretation as a custom PyArrow extention in `db_dtypes` " - "is a preview feature and subject to change." - ) - warnings.warn(msg, bfe.PreviewWarning) # define data source only for needed columns, this makes row-hashing cheaper table_def = nodes.GbqTable.from_table(table, columns=schema.names) diff --git a/bigframes/core/backports.py b/bigframes/core/backports.py new file mode 100644 index 0000000000..09ba09731c --- /dev/null +++ b/bigframes/core/backports.py @@ -0,0 +1,33 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Helpers for working across versions of different depenencies.""" + +from typing import List + +import pyarrow + + +def pyarrow_struct_type_fields(struct_type: pyarrow.StructType) -> List[pyarrow.Field]: + """StructType.fields was added in pyarrow 18. + + See: https://arrow.apache.org/docs/18.0/python/generated/pyarrow.StructType.html + """ + + if hasattr(struct_type, "fields"): + return struct_type.fields + + return [ + struct_type.field(field_index) for field_index in range(struct_type.num_fields) + ] diff --git a/bigframes/core/indexes/base.py b/bigframes/core/indexes/base.py index c5e2657629..a6b18fcb43 100644 --- a/bigframes/core/indexes/base.py +++ b/bigframes/core/indexes/base.py @@ -171,12 +171,16 @@ def shape(self) -> typing.Tuple[int]: @property def dtype(self): - return self._block.index.dtypes[0] if self.nlevels == 1 else np.dtype("O") + dtype = self._block.index.dtypes[0] if self.nlevels == 1 else np.dtype("O") + bigframes.dtypes.warn_on_db_dtypes_json_dtype([dtype]) + return dtype @property def dtypes(self) -> pandas.Series: + dtypes = self._block.index.dtypes + bigframes.dtypes.warn_on_db_dtypes_json_dtype(dtypes) return pandas.Series( - data=self._block.index.dtypes, + data=dtypes, index=typing.cast(typing.Tuple, self._block.index.names), ) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index a0933ee6a5..de153fca48 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -321,7 +321,9 @@ def at(self) -> indexers.AtDataFrameIndexer: @property def dtypes(self) -> pandas.Series: - return pandas.Series(data=self._block.dtypes, index=self._block.column_labels) + dtypes = self._block.dtypes + bigframes.dtypes.warn_on_db_dtypes_json_dtype(dtypes) + return pandas.Series(data=dtypes, index=self._block.column_labels) @property def columns(self) -> pandas.Index: diff --git a/bigframes/dtypes.py b/bigframes/dtypes.py index 3695110672..18ecdede11 100644 --- a/bigframes/dtypes.py +++ b/bigframes/dtypes.py @@ -20,6 +20,7 @@ import textwrap import typing from typing import Any, Dict, List, Literal, Sequence, Union +import warnings import bigframes_vendored.constants as constants import db_dtypes # type: ignore @@ -30,6 +31,9 @@ import pyarrow as pa import shapely.geometry # type: ignore +import bigframes.core.backports +import bigframes.exceptions + # Type hints for Pandas dtypes supported by BigQuery DataFrame Dtype = Union[ pd.BooleanDtype, @@ -62,7 +66,8 @@ # No arrow equivalent GEO_DTYPE = gpd.array.GeometryDtype() # JSON -# TODO: switch to pyarrow.json_(pyarrow.string()) when available. +# TODO(https://github.com/pandas-dev/pandas/issues/60958): switch to +# pyarrow.json_(pyarrow.string()) when pandas 3+ and pyarrow 18+ is installed. JSON_ARROW_TYPE = db_dtypes.JSONArrowType() JSON_DTYPE = pd.ArrowDtype(JSON_ARROW_TYPE) OBJ_REF_DTYPE = pd.ArrowDtype( @@ -368,8 +373,7 @@ def get_struct_fields(type_: ExpressionType) -> dict[str, Dtype]: assert isinstance(type_.pyarrow_dtype, pa.StructType) struct_type = type_.pyarrow_dtype result: dict[str, Dtype] = {} - for field_no in range(struct_type.num_fields): - field = struct_type.field(field_no) + for field in bigframes.core.backports.pyarrow_struct_type_fields(struct_type): result[field.name] = arrow_dtype_to_bigframes_dtype(field.type) return result @@ -547,7 +551,8 @@ def arrow_type_to_literal( return [arrow_type_to_literal(arrow_type.value_type)] if pa.types.is_struct(arrow_type): return { - field.name: arrow_type_to_literal(field.type) for field in arrow_type.fields + field.name: arrow_type_to_literal(field.type) + for field in bigframes.core.backports.pyarrow_struct_type_fields(arrow_type) } if pa.types.is_string(arrow_type): return "string" @@ -915,3 +920,40 @@ def lcd_type_or_throw(dtype1: Dtype, dtype2: Dtype) -> Dtype: TIMEDELTA_DESCRIPTION_TAG = "#microseconds" + + +def contains_db_dtypes_json_arrow_type(type_): + if isinstance(type_, db_dtypes.JSONArrowType): + return True + + if isinstance(type_, pa.ListType): + return contains_db_dtypes_json_arrow_type(type_.value_type) + + if isinstance(type_, pa.StructType): + return any( + contains_db_dtypes_json_arrow_type(field.type) + for field in bigframes.core.backports.pyarrow_struct_type_fields(type_) + ) + return False + + +def contains_db_dtypes_json_dtype(dtype): + if not isinstance(dtype, pd.ArrowDtype): + return False + + return contains_db_dtypes_json_arrow_type(dtype.pyarrow_dtype) + + +def warn_on_db_dtypes_json_dtype(dtypes): + """Warn that the JSON dtype is changing. + + Note: only call this function if the user is explicitly checking the + dtypes. + """ + if any(contains_db_dtypes_json_dtype(dtype) for dtype in dtypes): + msg = bigframes.exceptions.format_message( + "JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_()) " + "instead of using `db_dtypes` in the future when available in pandas " + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow." + ) + warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning) diff --git a/bigframes/exceptions.py b/bigframes/exceptions.py index 743aebe6c7..ef51f96575 100644 --- a/bigframes/exceptions.py +++ b/bigframes/exceptions.py @@ -111,6 +111,10 @@ class FunctionAxisOnePreviewWarning(PreviewWarning): """Remote Function and Managed UDF with axis=1 preview.""" +class JSONDtypeWarning(PreviewWarning): + """JSON dtype will be pd.ArrowDtype(pa.json_()) in the future.""" + + class FunctionConflictTypeHintWarning(UserWarning): """Conflicting type hints in a BigFrames function.""" diff --git a/bigframes/series.py b/bigframes/series.py index 62ddcece21..490298d8dd 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -113,10 +113,12 @@ def dt(self) -> dt.DatetimeMethods: @property def dtype(self): + bigframes.dtypes.warn_on_db_dtypes_json_dtype([self._dtype]) return self._dtype @property def dtypes(self): + bigframes.dtypes.warn_on_db_dtypes_json_dtype([self._dtype]) return self._dtype @property diff --git a/tests/unit/core/test_dtypes.py b/tests/unit/core/test_ibis_types.py similarity index 87% rename from tests/unit/core/test_dtypes.py rename to tests/unit/core/test_ibis_types.py index b72a781e56..427e726179 100644 --- a/tests/unit/core/test_dtypes.py +++ b/tests/unit/core/test_ibis_types.py @@ -20,7 +20,6 @@ import pandas as pd import pyarrow as pa # type: ignore import pytest -import shapely.geometry # type: ignore import bigframes.core.compile.ibis_types import bigframes.dtypes @@ -225,22 +224,6 @@ def test_bigframes_string_dtype_converts(ibis_dtype, bigframes_dtype_str): assert result == ibis_dtype -@pytest.mark.parametrize( - ["python_type", "expected_dtype"], - [ - (bool, bigframes.dtypes.BOOL_DTYPE), - (int, bigframes.dtypes.INT_DTYPE), - (str, bigframes.dtypes.STRING_DTYPE), - (shapely.geometry.Point, bigframes.dtypes.GEO_DTYPE), - (shapely.geometry.Polygon, bigframes.dtypes.GEO_DTYPE), - (shapely.geometry.base.BaseGeometry, bigframes.dtypes.GEO_DTYPE), - ], -) -def test_bigframes_type_supports_python_types(python_type, expected_dtype): - got_dtype = bigframes.dtypes.bigframes_type(python_type) - assert got_dtype == expected_dtype - - def test_unsupported_dtype_raises_unexpected_datatype(): """Incompatible dtypes should fail when passed into BigQuery DataFrames""" with pytest.raises(ValueError, match="Datatype has no ibis type mapping"): @@ -265,19 +248,3 @@ def test_literal_to_ibis_scalar_converts(literal, ibis_scalar): assert bigframes.core.compile.ibis_types.literal_to_ibis_scalar(literal).equals( ibis_scalar ) - - -@pytest.mark.parametrize( - ["scalar", "expected_dtype"], - [ - (pa.scalar(1_000_000_000, type=pa.int64()), bigframes.dtypes.INT_DTYPE), - (pa.scalar(True, type=pa.bool_()), bigframes.dtypes.BOOL_DTYPE), - (pa.scalar("hello", type=pa.string()), bigframes.dtypes.STRING_DTYPE), - # Support NULL scalars. - (pa.scalar(None, type=pa.int64()), bigframes.dtypes.INT_DTYPE), - (pa.scalar(None, type=pa.bool_()), bigframes.dtypes.BOOL_DTYPE), - (pa.scalar(None, type=pa.string()), bigframes.dtypes.STRING_DTYPE), - ], -) -def test_infer_literal_type_arrow_scalar(scalar, expected_dtype): - assert bigframes.dtypes.infer_literal_type(scalar) == expected_dtype diff --git a/tests/unit/test_dtypes.py b/tests/unit/test_dtypes.py new file mode 100644 index 0000000000..0e600de964 --- /dev/null +++ b/tests/unit/test_dtypes.py @@ -0,0 +1,73 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import db_dtypes # type: ignore +import pyarrow as pa # type: ignore +import pytest +import shapely.geometry # type: ignore + +import bigframes.dtypes + + +@pytest.mark.parametrize( + ["python_type", "expected_dtype"], + [ + (bool, bigframes.dtypes.BOOL_DTYPE), + (int, bigframes.dtypes.INT_DTYPE), + (str, bigframes.dtypes.STRING_DTYPE), + (shapely.geometry.Point, bigframes.dtypes.GEO_DTYPE), + (shapely.geometry.Polygon, bigframes.dtypes.GEO_DTYPE), + (shapely.geometry.base.BaseGeometry, bigframes.dtypes.GEO_DTYPE), + ], +) +def test_bigframes_type_supports_python_types(python_type, expected_dtype): + got_dtype = bigframes.dtypes.bigframes_type(python_type) + assert got_dtype == expected_dtype + + +@pytest.mark.parametrize( + ["scalar", "expected_dtype"], + [ + (pa.scalar(1_000_000_000, type=pa.int64()), bigframes.dtypes.INT_DTYPE), + (pa.scalar(True, type=pa.bool_()), bigframes.dtypes.BOOL_DTYPE), + (pa.scalar("hello", type=pa.string()), bigframes.dtypes.STRING_DTYPE), + # Support NULL scalars. + (pa.scalar(None, type=pa.int64()), bigframes.dtypes.INT_DTYPE), + (pa.scalar(None, type=pa.bool_()), bigframes.dtypes.BOOL_DTYPE), + (pa.scalar(None, type=pa.string()), bigframes.dtypes.STRING_DTYPE), + ], +) +def test_infer_literal_type_arrow_scalar(scalar, expected_dtype): + assert bigframes.dtypes.infer_literal_type(scalar) == expected_dtype + + +@pytest.mark.parametrize( + ["type_", "expected"], + [ + (pa.int64(), False), + (db_dtypes.JSONArrowType(), True), + (pa.struct([("int", pa.int64()), ("str", pa.string())]), False), + (pa.struct([("int", pa.int64()), ("json", db_dtypes.JSONArrowType())]), True), + (pa.list_(pa.int64()), False), + (pa.list_(db_dtypes.JSONArrowType()), True), + ( + pa.list_( + pa.struct([("int", pa.int64()), ("json", db_dtypes.JSONArrowType())]) + ), + True, + ), + ], +) +def test_contains_db_dtypes_json_arrow_type(type_, expected): + assert bigframes.dtypes.contains_db_dtypes_json_arrow_type(type_) == expected From 56e50331d198b7f517f85695c208f893ab9389d2 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Mon, 6 Oct 2025 11:21:14 -0700 Subject: [PATCH 122/313] feat: add ai.classify() to bigframes.bigquery package (#2137) --- bigframes/bigquery/_operations/ai.py | 97 ++++++++++++++----- .../ibis_compiler/scalar_op_registry.py | 12 +++ .../compile/sqlglot/expressions/ai_ops.py | 21 +++- bigframes/operations/__init__.py | 2 + bigframes/operations/ai_ops.py | 12 +++ tests/system/small/bigquery/test_ai.py | 21 ++++ .../test_ai_ops/test_ai_classify/out.sql | 17 ++++ .../sqlglot/expressions/test_ai_ops.py | 14 +++ .../sql/compilers/bigquery/__init__.py | 3 + .../ibis/expr/operations/ai_ops.py | 15 +++ 10 files changed, 189 insertions(+), 25 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify/out.sql diff --git a/bigframes/bigquery/_operations/ai.py b/bigframes/bigquery/_operations/ai.py index 4759c99016..a789310683 100644 --- a/bigframes/bigquery/_operations/ai.py +++ b/bigframes/bigquery/_operations/ai.py @@ -348,20 +348,20 @@ def if_( provides optimization such that not all rows are evaluated with the LLM. **Examples:** - >>> import bigframes.pandas as bpd - >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None - >>> us_state = bpd.Series(["Massachusetts", "Illinois", "Hawaii"]) - >>> bbq.ai.if_((us_state, " has a city called Springfield")) - 0 True - 1 True - 2 False - dtype: boolean - - >>> us_state[bbq.ai.if_((us_state, " has a city called Springfield"))] - 0 Massachusetts - 1 Illinois - dtype: string + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> bpd.options.display.progress_bar = None + >>> us_state = bpd.Series(["Massachusetts", "Illinois", "Hawaii"]) + >>> bbq.ai.if_((us_state, " has a city called Springfield")) + 0 True + 1 True + 2 False + dtype: boolean + + >>> us_state[bbq.ai.if_((us_state, " has a city called Springfield"))] + 0 Massachusetts + 1 Illinois + dtype: string Args: prompt (Series | List[str|Series] | Tuple[str|Series, ...]): @@ -386,6 +386,56 @@ def if_( return series_list[0]._apply_nary_op(operator, series_list[1:]) +@log_adapter.method_logger(custom_base_name="bigquery_ai") +def classify( + input: PROMPT_TYPE, + categories: tuple[str, ...] | list[str], + *, + connection_id: str | None = None, +) -> series.Series: + """ + Classifies a given input into one of the specified categories. It will always return one of the provided categories best fit the prompt input. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> bpd.options.display.progress_bar = None + >>> df = bpd.DataFrame({'creature': ['Cat', 'Salmon']}) + >>> df['type'] = bbq.ai.classify(df['creature'], ['Mammal', 'Fish']) + >>> df + creature type + 0 Cat Mammal + 1 Salmon Fish + + [2 rows x 2 columns] + + Args: + input (Series | List[str|Series] | Tuple[str|Series, ...]): + A mixture of Series and string literals that specifies the input to send to the model. The Series can be BigFrames Series + or pandas Series. + categories (tuple[str, ...] | list[str]): + Categories to classify the input into. + connection_id (str, optional): + Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. + If not provided, the connection from the current session will be used. + + Returns: + bigframes.series.Series: A new series of strings. + """ + + prompt_context, series_list = _separate_context_and_series(input) + assert len(series_list) > 0 + + operator = ai_ops.AIClassify( + prompt_context=tuple(prompt_context), + categories=tuple(categories), + connection_id=_resolve_connection_id(series_list[0], connection_id), + ) + + return series_list[0]._apply_nary_op(operator, series_list[1:]) + + @log_adapter.method_logger(custom_base_name="bigquery_ai") def score( prompt: PROMPT_TYPE, @@ -398,15 +448,16 @@ def score( rubric with examples in the prompt. **Examples:** - >>> import bigframes.pandas as bpd - >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None - >>> animal = bpd.Series(["Tiger", "Rabbit", "Blue Whale"]) - >>> bbq.ai.score(("Rank the relative weights of ", animal, " on the scale from 1 to 3")) # doctest: +SKIP - 0 2.0 - 1 1.0 - 2 3.0 - dtype: Float64 + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + >>> bpd.options.display.progress_bar = None + >>> animal = bpd.Series(["Tiger", "Rabbit", "Blue Whale"]) + >>> bbq.ai.score(("Rank the relative weights of ", animal, " on the scale from 1 to 3")) # doctest: +SKIP + 0 2.0 + 1 1.0 + 2 3.0 + dtype: Float64 Args: prompt (Series | List[str|Series] | Tuple[str|Series, ...]): diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index 7280e9a40a..4c02e17d6f 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -2039,6 +2039,18 @@ def ai_if(*values: ibis_types.Value, op: ops.AIIf) -> ibis_types.StructValue: ).to_expr() +@scalar_op_compiler.register_nary_op(ops.AIClassify, pass_op=True) +def ai_classify( + *values: ibis_types.Value, op: ops.AIClassify +) -> ibis_types.StructValue: + + return ai_ops.AIClassify( + _construct_prompt(values, op.prompt_context), # type: ignore + op.categories, # type: ignore + op.connection_id, # type: ignore + ).to_expr() + + @scalar_op_compiler.register_nary_op(ops.AIScore, pass_op=True) def ai_score(*values: ibis_types.Value, op: ops.AIScore) -> ibis_types.StructValue: diff --git a/bigframes/core/compile/sqlglot/expressions/ai_ops.py b/bigframes/core/compile/sqlglot/expressions/ai_ops.py index 46a79d1440..4129c91906 100644 --- a/bigframes/core/compile/sqlglot/expressions/ai_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/ai_ops.py @@ -61,6 +61,21 @@ def _(*exprs: TypedExpr, op: ops.AIIf) -> sge.Expression: return sge.func("AI.IF", *args) +@register_nary_op(ops.AIClassify, pass_op=True) +def _(*exprs: TypedExpr, op: ops.AIClassify) -> sge.Expression: + category_literals = [sge.Literal.string(cat) for cat in op.categories] + categories_arg = sge.Kwarg( + this="categories", expression=sge.array(*category_literals) + ) + + args = [ + _construct_prompt(exprs, op.prompt_context, param_name="input"), + categories_arg, + ] + _construct_named_args(op) + + return sge.func("AI.CLASSIFY", *args) + + @register_nary_op(ops.AIScore, pass_op=True) def _(*exprs: TypedExpr, op: ops.AIScore) -> sge.Expression: args = [_construct_prompt(exprs, op.prompt_context)] + _construct_named_args(op) @@ -69,7 +84,9 @@ def _(*exprs: TypedExpr, op: ops.AIScore) -> sge.Expression: def _construct_prompt( - exprs: tuple[TypedExpr, ...], prompt_context: tuple[str | None, ...] + exprs: tuple[TypedExpr, ...], + prompt_context: tuple[str | None, ...], + param_name: str = "prompt", ) -> sge.Kwarg: prompt: list[str | sge.Expression] = [] column_ref_idx = 0 @@ -80,7 +97,7 @@ def _construct_prompt( else: prompt.append(sge.Literal.string(elem)) - return sge.Kwarg(this="prompt", expression=sge.Tuple(expressions=prompt)) + return sge.Kwarg(this=param_name, expression=sge.Tuple(expressions=prompt)) def _construct_named_args(op: ops.NaryOp) -> list[sge.Kwarg]: diff --git a/bigframes/operations/__init__.py b/bigframes/operations/__init__.py index e7d0751fc9..24a7d6542f 100644 --- a/bigframes/operations/__init__.py +++ b/bigframes/operations/__init__.py @@ -15,6 +15,7 @@ from __future__ import annotations from bigframes.operations.ai_ops import ( + AIClassify, AIGenerate, AIGenerateBool, AIGenerateDouble, @@ -419,6 +420,7 @@ "geo_y_op", "GeoStDistanceOp", # AI ops + "AIClassify", "AIGenerate", "AIGenerateBool", "AIGenerateDouble", diff --git a/bigframes/operations/ai_ops.py b/bigframes/operations/ai_ops.py index 05d37d2a90..7ba3737ba0 100644 --- a/bigframes/operations/ai_ops.py +++ b/bigframes/operations/ai_ops.py @@ -123,6 +123,18 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT return dtypes.BOOL_DTYPE +@dataclasses.dataclass(frozen=True) +class AIClassify(base_ops.NaryOp): + name: ClassVar[str] = "ai_classify" + + prompt_context: Tuple[str | None, ...] + categories: tuple[str, ...] + connection_id: str + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return dtypes.STRING_DTYPE + + @dataclasses.dataclass(frozen=True) class AIScore(base_ops.NaryOp): name: ClassVar[str] = "ai_score" diff --git a/tests/system/small/bigquery/test_ai.py b/tests/system/small/bigquery/test_ai.py index 91499d0efe..7a6e5aea4f 100644 --- a/tests/system/small/bigquery/test_ai.py +++ b/tests/system/small/bigquery/test_ai.py @@ -225,6 +225,27 @@ def test_ai_if_multi_model(session): assert result.dtype == dtypes.BOOL_DTYPE +def test_ai_classify(session): + s = bpd.Series(["cat", "orchid"], session=session) + bpd.options.display.repr_mode = "deferred" + + result = bbq.ai.classify(s, ["animal", "plant"]) + + assert _contains_no_nulls(result) + assert result.dtype == dtypes.STRING_DTYPE + + +def test_ai_classify_multi_model(session): + df = session.from_glob_path( + "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" + ) + + result = bbq.ai.classify(df["image"], ["photo", "cartoon"]) + + assert _contains_no_nulls(result) + assert result.dtype == dtypes.STRING_DTYPE + + def test_ai_score(session): s = bpd.Series(["Tiger", "Rabbit"], session=session) prompt = ("Rank the relative weights of ", s, " on the scale from 1 to 3") diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify/out.sql new file mode 100644 index 0000000000..bb06760e4d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.CLASSIFY( + input => (`bfcol_0`), + categories => ['greeting', 'rejection'], + connection_id => 'bigframes-dev.us.bigframes-default-connection' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py index 8f048a5bbf..c809e90a90 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py @@ -216,6 +216,20 @@ def test_ai_if(scalar_types_df: dataframe.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_ai_classify(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + + op = ops.AIClassify( + prompt_context=(None,), + categories=("greeting", "rejection"), + connection_id=CONNECTION_ID, + ) + + sql = utils._apply_unary_ops(scalar_types_df, [op.as_expr(col_name)], ["result"]) + + snapshot.assert_match(sql, "out.sql") + + def test_ai_score(scalar_types_df: dataframe.DataFrame, snapshot): col_name = "string_col" diff --git a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py index 8603c89cc8..cf205b69d6 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py @@ -1119,6 +1119,9 @@ def visit_AIGenerateDouble(self, op, **kwargs): def visit_AIIf(self, op, **kwargs): return sge.func("AI.IF", *self._compile_ai_args(**kwargs)) + def visit_AIClassify(self, op, **kwargs): + return sge.func("AI.CLASSIFY", *self._compile_ai_args(**kwargs)) + def visit_AIScore(self, op, **kwargs): return sge.func("AI.SCORE", *self._compile_ai_args(**kwargs)) diff --git a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py index 5289ee7e60..e9d704fa8e 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py @@ -107,6 +107,21 @@ def dtype(self) -> dt.Struct: return dt.bool +@public +class AIClassify(Value): + """Generate True/False based on the prompt""" + + input: Value + categories: Value[dt.Array[dt.String]] + connection_id: Value[dt.String] + + shape = rlz.shape_like("input") + + @attribute + def dtype(self) -> dt.Struct: + return dt.string + + @public class AIScore(Value): """Generate doubles based on the prompt""" From ece07623e354a1dde2bd37020349e13f682e863f Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Mon, 6 Oct 2025 14:21:15 -0700 Subject: [PATCH 123/313] fix: Fix row count local execution bug (#2133) --- bigframes/core/rewrite/pruning.py | 19 ++++++++---------- .../system/small/engines/test_aggregation.py | 19 ++++++++++++++++++ .../test_row_number/out.sql | 20 ++++++++++++++++--- .../test_nullary_compiler/test_size/out.sql | 20 ++++++++++++++++--- 4 files changed, 61 insertions(+), 17 deletions(-) diff --git a/bigframes/core/rewrite/pruning.py b/bigframes/core/rewrite/pruning.py index 8a07f0b87e..41664e1c47 100644 --- a/bigframes/core/rewrite/pruning.py +++ b/bigframes/core/rewrite/pruning.py @@ -13,6 +13,7 @@ # limitations under the License. import dataclasses import functools +import itertools import typing from bigframes.core import identifiers, nodes @@ -51,17 +52,9 @@ def prune_columns(node: nodes.BigFrameNode): if isinstance(node, nodes.SelectionNode): result = prune_selection_child(node) elif isinstance(node, nodes.ResultNode): - result = node.replace_child( - prune_node( - node.child, node.consumed_ids or frozenset(list(node.child.ids)[0:1]) - ) - ) + result = node.replace_child(prune_node(node.child, node.consumed_ids)) elif isinstance(node, nodes.AggregateNode): - result = node.replace_child( - prune_node( - node.child, node.consumed_ids or frozenset(list(node.child.ids)[0:1]) - ) - ) + result = node.replace_child(prune_node(node.child, node.consumed_ids)) elif isinstance(node, nodes.InNode): result = dataclasses.replace( node, @@ -149,9 +142,13 @@ def prune_node( if not (set(node.ids) - ids): return node else: + # If no child ids are needed, probably a size op or numbering op above, keep a single column always + ids_to_keep = tuple(id for id in node.ids if id in ids) or tuple( + itertools.islice(node.ids, 0, 1) + ) return nodes.SelectionNode( node, - tuple(nodes.AliasedRef.identity(id) for id in node.ids if id in ids), + tuple(nodes.AliasedRef.identity(id) for id in ids_to_keep), ) diff --git a/tests/system/small/engines/test_aggregation.py b/tests/system/small/engines/test_aggregation.py index 9b4efe8cbe..a25c167f71 100644 --- a/tests/system/small/engines/test_aggregation.py +++ b/tests/system/small/engines/test_aggregation.py @@ -48,6 +48,25 @@ def apply_agg_to_all_valid( return new_arr +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +def test_engines_aggregate_post_filter_size( + scalars_array_value: array_value.ArrayValue, + engine, +): + w_offsets, offsets_id = ( + scalars_array_value.select_columns(("bool_col", "string_col")) + .filter(expression.deref("bool_col")) + .promote_offsets() + ) + plan = ( + w_offsets.select_columns((offsets_id, "bool_col", "string_col")) + .row_count() + .node + ) + + assert_equivalence_execution(plan, REFERENCE_ENGINE, engine) + + @pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_aggregate_size( scalars_array_value: array_value.ArrayValue, diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql index d20a635e3d..b48dcfa01b 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql @@ -1,13 +1,27 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0` + `bool_col` AS `bfcol_0`, + `bytes_col` AS `bfcol_1`, + `date_col` AS `bfcol_2`, + `datetime_col` AS `bfcol_3`, + `geography_col` AS `bfcol_4`, + `int64_col` AS `bfcol_5`, + `int64_too` AS `bfcol_6`, + `numeric_col` AS `bfcol_7`, + `float64_col` AS `bfcol_8`, + `rowindex` AS `bfcol_9`, + `rowindex_2` AS `bfcol_10`, + `string_col` AS `bfcol_11`, + `time_col` AS `bfcol_12`, + `timestamp_col` AS `bfcol_13`, + `duration_col` AS `bfcol_14` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - ROW_NUMBER() OVER () AS `bfcol_1` + ROW_NUMBER() OVER () AS `bfcol_32` FROM `bfcte_0` ) SELECT - `bfcol_1` AS `row_number` + `bfcol_32` AS `row_number` FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_size/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_size/out.sql index 19ae8aa3fd..8cda9a3d80 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_size/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_size/out.sql @@ -1,12 +1,26 @@ WITH `bfcte_0` AS ( SELECT - `rowindex` AS `bfcol_0` + `bool_col` AS `bfcol_0`, + `bytes_col` AS `bfcol_1`, + `date_col` AS `bfcol_2`, + `datetime_col` AS `bfcol_3`, + `geography_col` AS `bfcol_4`, + `int64_col` AS `bfcol_5`, + `int64_too` AS `bfcol_6`, + `numeric_col` AS `bfcol_7`, + `float64_col` AS `bfcol_8`, + `rowindex` AS `bfcol_9`, + `rowindex_2` AS `bfcol_10`, + `string_col` AS `bfcol_11`, + `time_col` AS `bfcol_12`, + `timestamp_col` AS `bfcol_13`, + `duration_col` AS `bfcol_14` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - COUNT(1) AS `bfcol_2` + COUNT(1) AS `bfcol_32` FROM `bfcte_0` ) SELECT - `bfcol_2` AS `size` + `bfcol_32` AS `size` FROM `bfcte_1` \ No newline at end of file From b7118152bfecc6ecf67aa4df23ec3f0a2b08aa30 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Tue, 7 Oct 2025 09:41:47 -0700 Subject: [PATCH 124/313] fix: join on, how args are now positional (#2140) --- bigframes/dataframe.py | 1 - third_party/bigframes_vendored/pandas/core/frame.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index de153fca48..1bde29506d 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -3721,7 +3721,6 @@ def _validate_left_right_on( def join( self, other: Union[DataFrame, bigframes.series.Series], - *, on: Optional[str] = None, how: str = "left", lsuffix: str = "", diff --git a/third_party/bigframes_vendored/pandas/core/frame.py b/third_party/bigframes_vendored/pandas/core/frame.py index 1d8f5cbace..557c332797 100644 --- a/third_party/bigframes_vendored/pandas/core/frame.py +++ b/third_party/bigframes_vendored/pandas/core/frame.py @@ -4601,9 +4601,8 @@ def map(self, func, na_action: Optional[str] = None) -> DataFrame: def join( self, other, - *, on: Optional[str] = None, - how: str, + how: str = "left", lsuffix: str = "", rsuffix: str = "", ) -> DataFrame: From 62fe8e23afc3d287635eab224ad02507186cf188 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:37:51 -0700 Subject: [PATCH 125/313] chore(main): release 2.24.0 (#2131) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 23 +++++++++++++++++++++++ bigframes/version.py | 4 ++-- third_party/bigframes_vendored/version.py | 4 ++-- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 394e04331b..4b00fd956d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ [1]: https://pypi.org/project/bigframes/#history +## [2.24.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.23.0...v2.24.0) (2025-10-07) + + +### Features + +* Add ai.classify() to bigframes.bigquery package ([#2137](https://github.com/googleapis/python-bigquery-dataframes/issues/2137)) ([56e5033](https://github.com/googleapis/python-bigquery-dataframes/commit/56e50331d198b7f517f85695c208f893ab9389d2)) +* Add ai.generate() to bigframes.bigquery module ([#2128](https://github.com/googleapis/python-bigquery-dataframes/issues/2128)) ([3810452](https://github.com/googleapis/python-bigquery-dataframes/commit/3810452f16d8d6c9d3eb9075f1537177d98b4725)) +* Add ai.if_() and ai.score() to bigframes.bigquery package ([#2132](https://github.com/googleapis/python-bigquery-dataframes/issues/2132)) ([32502f4](https://github.com/googleapis/python-bigquery-dataframes/commit/32502f4195306d262788f39d1ab4206fc84ae50e)) + + +### Bug Fixes + +* Fix internal type errors with temporal accessors ([#2125](https://github.com/googleapis/python-bigquery-dataframes/issues/2125)) ([c390da1](https://github.com/googleapis/python-bigquery-dataframes/commit/c390da11b7c2aa710bc2fbc692efb9f06059e4c4)) +* Fix row count local execution bug ([#2133](https://github.com/googleapis/python-bigquery-dataframes/issues/2133)) ([ece0762](https://github.com/googleapis/python-bigquery-dataframes/commit/ece07623e354a1dde2bd37020349e13f682e863f)) +* Join on, how args are now positional ([#2140](https://github.com/googleapis/python-bigquery-dataframes/issues/2140)) ([b711815](https://github.com/googleapis/python-bigquery-dataframes/commit/b7118152bfecc6ecf67aa4df23ec3f0a2b08aa30)) +* Only show JSON dtype warning when accessing dtypes directly ([#2136](https://github.com/googleapis/python-bigquery-dataframes/issues/2136)) ([eca22ee](https://github.com/googleapis/python-bigquery-dataframes/commit/eca22ee3104104cea96189391e527cad09bd7509)) +* Remove noisy AmbiguousWindowWarning from partial ordering mode ([#2129](https://github.com/googleapis/python-bigquery-dataframes/issues/2129)) ([4607f86](https://github.com/googleapis/python-bigquery-dataframes/commit/4607f86ebd77b916aafc37f69725b676e203b332)) + + +### Performance Improvements + +* Scale read stream workers to cpu count ([#2135](https://github.com/googleapis/python-bigquery-dataframes/issues/2135)) ([67e46cd](https://github.com/googleapis/python-bigquery-dataframes/commit/67e46cd47933b84b55808003ed344b559e47c498)) + ## [2.23.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.22.0...v2.23.0) (2025-09-29) diff --git a/bigframes/version.py b/bigframes/version.py index 80776a5511..93445c0c0d 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.23.0" +__version__ = "2.24.0" # {x-release-please-start-date} -__release_date__ = "2025-09-29" +__release_date__ = "2025-10-07" # {x-release-please-end} diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index 80776a5511..93445c0c0d 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.23.0" +__version__ = "2.24.0" # {x-release-please-start-date} -__release_date__ = "2025-09-29" +__release_date__ = "2025-10-07" # {x-release-please-end} From 8fc051f21fa3f575e0a1bd8c44968077e54fb799 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Tue, 7 Oct 2025 11:42:55 -0700 Subject: [PATCH 126/313] fix!: Address the series input type issue in bigframes functions (#2123) * fix!: Address the series input type issue in bigframes functions * fix the tests * fix doctest * fix error message --- bigframes/functions/_function_session.py | 12 ++++++- bigframes/functions/function_template.py | 18 ++++++++--- bigframes/session/__init__.py | 3 +- .../large/functions/test_managed_function.py | 31 ++++++++++++------- .../large/functions/test_remote_function.py | 28 +++++++++++------ .../small/functions/test_remote_function.py | 13 ++++---- tests/unit/functions/test_remote_function.py | 19 ++---------- 7 files changed, 72 insertions(+), 52 deletions(-) diff --git a/bigframes/functions/_function_session.py b/bigframes/functions/_function_session.py index 9a38ef1957..a456f05417 100644 --- a/bigframes/functions/_function_session.py +++ b/bigframes/functions/_function_session.py @@ -983,7 +983,17 @@ def _convert_row_processor_sig( if len(signature.parameters) >= 1: first_param = next(iter(signature.parameters.values())) param_type = first_param.annotation - if (param_type == bf_series.Series) or (param_type == pandas.Series): + # Type hints for Series inputs should use pandas.Series because the + # underlying serialization process converts the input to a string + # representation of a pandas Series (not bigframes Series). Using + # bigframes Series will lead to TypeError when creating the function + # remotely. See more from b/445182819. + if param_type == bf_series.Series: + raise bf_formatting.create_exception_with_feedback_link( + TypeError, + "Argument type hint must be Pandas Series, not BigFrames Series.", + ) + if param_type == pandas.Series: msg = bfe.format_message("input_types=Series is in preview.") warnings.warn(msg, stacklevel=1, category=bfe.PreviewWarning) return signature.replace( diff --git a/bigframes/functions/function_template.py b/bigframes/functions/function_template.py index dd31de7243..a3680a7a88 100644 --- a/bigframes/functions/function_template.py +++ b/bigframes/functions/function_template.py @@ -363,8 +363,16 @@ def generate_managed_function_code( return {udf_name}(*args)""" ) - udf_code_block = textwrap.dedent( - f"{udf_code}\n{func_code}\n{bigframes_handler_code}" - ) - - return udf_code_block + udf_code_block = [] + if not capture_references and is_row_processor: + # Enable postponed evaluation of type annotations. This converts all + # type hints to strings at runtime, which is necessary for correctly + # handling the type annotation of pandas.Series after the UDF code is + # serialized for remote execution. See more from b/445182819. + udf_code_block.append("from __future__ import annotations") + + udf_code_block.append(udf_code) + udf_code_block.append(func_code) + udf_code_block.append(bigframes_handler_code) + + return textwrap.dedent("\n".join(udf_code_block)) diff --git a/bigframes/session/__init__.py b/bigframes/session/__init__.py index f0cec864b4..df0afb4c8d 100644 --- a/bigframes/session/__init__.py +++ b/bigframes/session/__init__.py @@ -2064,8 +2064,9 @@ def read_gbq_function( note, row processor implies that the function has only one input parameter. + >>> import pandas as pd >>> @bpd.remote_function(cloud_function_service_account="default") - ... def row_sum(s: bpd.Series) -> float: + ... def row_sum(s: pd.Series) -> float: ... return s['a'] + s['b'] + s['c'] >>> row_sum_ref = bpd.read_gbq_function( diff --git a/tests/system/large/functions/test_managed_function.py b/tests/system/large/functions/test_managed_function.py index e74bc8579f..732123ec84 100644 --- a/tests/system/large/functions/test_managed_function.py +++ b/tests/system/large/functions/test_managed_function.py @@ -701,8 +701,19 @@ def serialize_row(row): } ) + with pytest.raises( + TypeError, + match="Argument type hint must be Pandas Series, not BigFrames Series.", + ): + serialize_row_mf = session.udf( + input_types=bigframes.series.Series, + output_type=str, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(serialize_row) + serialize_row_mf = session.udf( - input_types=bigframes.series.Series, + input_types=pandas.Series, output_type=str, dataset=dataset_id, name=prefixer.create_prefix(), @@ -762,7 +773,7 @@ def analyze(row): ): analyze_mf = session.udf( - input_types=bigframes.series.Series, + input_types=pandas.Series, output_type=str, dataset=dataset_id, name=prefixer.create_prefix(), @@ -876,7 +887,7 @@ def serialize_row(row): ) serialize_row_mf = session.udf( - input_types=bigframes.series.Series, + input_types=pandas.Series, output_type=str, dataset=dataset_id, name=prefixer.create_prefix(), @@ -926,7 +937,7 @@ def test_managed_function_df_apply_axis_1_na_nan_inf(dataset_id, session): try: - def float_parser(row): + def float_parser(row: pandas.Series): import numpy as mynp import pandas as mypd @@ -937,7 +948,7 @@ def float_parser(row): return float(row["text"]) float_parser_mf = session.udf( - input_types=bigframes.series.Series, + input_types=pandas.Series, output_type=float, dataset=dataset_id, name=prefixer.create_prefix(), @@ -1027,7 +1038,7 @@ def test_managed_function_df_apply_axis_1_series_args(session, dataset_id, scala try: - def analyze(s, x, y): + def analyze(s: pandas.Series, x: bool, y: float) -> str: value = f"value is {s['int64_col']} and {s['float64_col']}" if x: return f"{value}, x is True!" @@ -1036,8 +1047,6 @@ def analyze(s, x, y): return f"{value}, x is False, y is non-positive!" analyze_mf = session.udf( - input_types=[bigframes.series.Series, bool, float], - output_type=str, dataset=dataset_id, name=prefixer.create_prefix(), )(analyze) @@ -1151,7 +1160,7 @@ def is_sum_positive_series(s): return s["int64_col"] + s["int64_too"] > 0 is_sum_positive_series_mf = session.udf( - input_types=bigframes.series.Series, + input_types=pandas.Series, output_type=bool, dataset=dataset_id, name=prefixer.create_prefix(), @@ -1217,12 +1226,10 @@ def func_for_other(x): def test_managed_function_df_where_other_issue(session, dataset_id, scalars_df_index): try: - def the_sum(s): + def the_sum(s: pandas.Series) -> int: return s["int64_col"] + s["int64_too"] the_sum_mf = session.udf( - input_types=bigframes.series.Series, - output_type=int, dataset=dataset_id, name=prefixer.create_prefix(), )(the_sum) diff --git a/tests/system/large/functions/test_remote_function.py b/tests/system/large/functions/test_remote_function.py index 55643d9a60..00b1b5f1f0 100644 --- a/tests/system/large/functions/test_remote_function.py +++ b/tests/system/large/functions/test_remote_function.py @@ -1722,7 +1722,7 @@ def serialize_row(row): ) serialize_row_remote = session.remote_function( - input_types=bigframes.series.Series, + input_types=pandas.Series, output_type=str, reuse=False, cloud_function_service_account="default", @@ -1771,7 +1771,7 @@ def analyze(row): ) analyze_remote = session.remote_function( - input_types=bigframes.series.Series, + input_types=pandas.Series, output_type=str, reuse=False, cloud_function_service_account="default", @@ -1895,7 +1895,7 @@ def serialize_row(row): ) serialize_row_remote = session.remote_function( - input_types=bigframes.series.Series, + input_types=pandas.Series, output_type=str, reuse=False, cloud_function_service_account="default", @@ -1944,7 +1944,7 @@ def test_df_apply_axis_1_na_nan_inf(session): try: - def float_parser(row): + def float_parser(row: pandas.Series): import numpy as mynp import pandas as mypd @@ -1955,7 +1955,6 @@ def float_parser(row): return float(row["text"]) float_parser_remote = session.remote_function( - input_types=bigframes.series.Series, output_type=float, reuse=False, cloud_function_service_account="default", @@ -2055,12 +2054,12 @@ def test_df_apply_axis_1_series_args(session, scalars_dfs): try: @session.remote_function( - input_types=[bigframes.series.Series, float, str, bool], + input_types=[pandas.Series, float, str, bool], output_type=list[str], reuse=False, cloud_function_service_account="default", ) - def foo_list(x, y0: float, y1, y2) -> list[str]: + def foo_list(x: pandas.Series, y0: float, y1, y2) -> list[str]: return ( [str(x["int64_col"]), str(y0), str(y1), str(y2)] if y2 @@ -3087,12 +3086,21 @@ def test_remote_function_df_where_mask_series(session, dataset_id, scalars_dfs): try: # The return type has to be bool type for callable where condition. - def is_sum_positive_series(s): + def is_sum_positive_series(s: pandas.Series) -> bool: return s["int64_col"] + s["int64_too"] > 0 + with pytest.raises( + TypeError, + match="Argument type hint must be Pandas Series, not BigFrames Series.", + ): + session.remote_function( + input_types=bigframes.series.Series, + dataset=dataset_id, + reuse=False, + cloud_function_service_account="default", + )(is_sum_positive_series) + is_sum_positive_series_mf = session.remote_function( - input_types=bigframes.series.Series, - output_type=bool, dataset=dataset_id, reuse=False, cloud_function_service_account="default", diff --git a/tests/system/small/functions/test_remote_function.py b/tests/system/small/functions/test_remote_function.py index 28fab19144..15070a3a29 100644 --- a/tests/system/small/functions/test_remote_function.py +++ b/tests/system/small/functions/test_remote_function.py @@ -20,6 +20,7 @@ import bigframes_vendored.constants as constants import google.api_core.exceptions from google.cloud import bigquery +import pandas import pandas as pd import pyarrow import pytest @@ -1166,7 +1167,7 @@ def test_df_apply_axis_1(session, scalars_dfs, dataset_id_permanent): ] scalars_df, scalars_pandas_df = scalars_dfs - def add_ints(row): + def add_ints(row: pandas.Series) -> int: return row["int64_col"] + row["int64_too"] with pytest.warns( @@ -1174,8 +1175,6 @@ def add_ints(row): match="input_types=Series is in preview.", ): add_ints_remote = session.remote_function( - input_types=bigframes.series.Series, - output_type=int, dataset=dataset_id_permanent, name=get_function_name(add_ints, is_row_processor=True), cloud_function_service_account="default", @@ -1223,11 +1222,11 @@ def test_df_apply_axis_1_ordering(session, scalars_dfs, dataset_id_permanent): ordering_columns = ["bool_col", "int64_col"] scalars_df, scalars_pandas_df = scalars_dfs - def add_ints(row): + def add_ints(row: pandas.Series) -> int: return row["int64_col"] + row["int64_too"] add_ints_remote = session.remote_function( - input_types=bigframes.series.Series, + input_types=pandas.Series, output_type=int, dataset=dataset_id_permanent, name=get_function_name(add_ints, is_row_processor=True), @@ -1267,7 +1266,7 @@ def add_numbers(row): return row["x"] + row["y"] add_numbers_remote = session.remote_function( - input_types=bigframes.series.Series, + input_types=pandas.Series, output_type=float, dataset=dataset_id_permanent, name=get_function_name(add_numbers, is_row_processor=True), @@ -1321,7 +1320,7 @@ def echo_len(row): return len(row) echo_len_remote = session.remote_function( - input_types=bigframes.series.Series, + input_types=pandas.Series, output_type=float, dataset=dataset_id_permanent, name=get_function_name(echo_len, is_row_processor=True), diff --git a/tests/unit/functions/test_remote_function.py b/tests/unit/functions/test_remote_function.py index ea09ac59d3..e9e0d0df67 100644 --- a/tests/unit/functions/test_remote_function.py +++ b/tests/unit/functions/test_remote_function.py @@ -17,25 +17,12 @@ import pandas import pytest +import bigframes.exceptions import bigframes.functions.function as bff -import bigframes.series from bigframes.testing import mocks -@pytest.mark.parametrize( - "series_type", - ( - pytest.param( - pandas.Series, - id="pandas.Series", - ), - pytest.param( - bigframes.series.Series, - id="bigframes.series.Series", - ), - ), -) -def test_series_input_types_to_str(series_type): +def test_series_input_types_to_str(): """Check that is_row_processor=True uses str as the input type to serialize a row.""" session = mocks.create_bigquery_session() remote_function_decorator = bff.remote_function( @@ -48,7 +35,7 @@ def test_series_input_types_to_str(series_type): ): @remote_function_decorator - def axis_1_function(myparam: series_type) -> str: # type: ignore + def axis_1_function(myparam: pandas.Series) -> str: # type: ignore return "Hello, " + myparam["str_col"] + "!" # type: ignore # Still works as a normal function. From fa4e46f4eed6b381f497f9f1043e5c8aa6491297 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Tue, 7 Oct 2025 12:19:41 -0700 Subject: [PATCH 127/313] refactor: make sqlglot.from_bf_dtype() a top-level function (#2144) --- .../sqlglot/expressions/generic_ops.py | 4 +- bigframes/core/compile/sqlglot/sqlglot_ir.py | 4 +- .../core/compile/sqlglot/sqlglot_types.py | 109 +++++++++--------- .../compile/sqlglot/test_sqlglot_types.py | 34 +++--- 4 files changed, 73 insertions(+), 78 deletions(-) diff --git a/bigframes/core/compile/sqlglot/expressions/generic_ops.py b/bigframes/core/compile/sqlglot/expressions/generic_ops.py index 6a3825309c..af3b57f77b 100644 --- a/bigframes/core/compile/sqlglot/expressions/generic_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/generic_ops.py @@ -18,9 +18,9 @@ from bigframes import dtypes from bigframes import operations as ops +from bigframes.core.compile.sqlglot import sqlglot_types from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler -from bigframes.core.compile.sqlglot.sqlglot_types import SQLGlotType register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op @@ -29,7 +29,7 @@ def _(expr: TypedExpr, op: ops.AsTypeOp) -> sge.Expression: from_type = expr.dtype to_type = op.to_type - sg_to_type = SQLGlotType.from_bigframes_dtype(to_type) + sg_to_type = sqlglot_types.from_bigframes_dtype(to_type) sg_expr = expr.expr if to_type == dtypes.JSON_DTYPE: diff --git a/bigframes/core/compile/sqlglot/sqlglot_ir.py b/bigframes/core/compile/sqlglot/sqlglot_ir.py index 98dbed4cdd..c7ee13f4e8 100644 --- a/bigframes/core/compile/sqlglot/sqlglot_ir.py +++ b/bigframes/core/compile/sqlglot/sqlglot_ir.py @@ -79,7 +79,7 @@ def from_pyarrow( expressions=[ sge.ColumnDef( this=sge.to_identifier(field.column, quoted=True), - kind=sgt.SQLGlotType.from_bigframes_dtype(field.dtype), + kind=sgt.from_bigframes_dtype(field.dtype), ) for field in schema.items ], @@ -620,7 +620,7 @@ def _select_to_cte(expr: sge.Select, cte_name: sge.Identifier) -> sge.Select: def _literal(value: typing.Any, dtype: dtypes.Dtype) -> sge.Expression: - sqlglot_type = sgt.SQLGlotType.from_bigframes_dtype(dtype) + sqlglot_type = sgt.from_bigframes_dtype(dtype) if value is None: return _cast(sge.Null(), sqlglot_type) elif dtype == dtypes.BYTES_DTYPE: diff --git a/bigframes/core/compile/sqlglot/sqlglot_types.py b/bigframes/core/compile/sqlglot/sqlglot_types.py index 5b0f70077d..64e4363ddf 100644 --- a/bigframes/core/compile/sqlglot/sqlglot_types.py +++ b/bigframes/core/compile/sqlglot/sqlglot_types.py @@ -25,62 +25,57 @@ import bigframes.dtypes -class SQLGlotType: - @classmethod - def from_bigframes_dtype( - cls, - bigframes_dtype: typing.Union[ - bigframes.dtypes.DtypeString, bigframes.dtypes.Dtype, np.dtype[typing.Any] - ], - ) -> str: - if bigframes_dtype == bigframes.dtypes.INT_DTYPE: - return "INT64" - elif bigframes_dtype == bigframes.dtypes.FLOAT_DTYPE: - return "FLOAT64" - elif bigframes_dtype == bigframes.dtypes.STRING_DTYPE: - return "STRING" - elif bigframes_dtype == bigframes.dtypes.BOOL_DTYPE: - return "BOOLEAN" - elif bigframes_dtype == bigframes.dtypes.DATE_DTYPE: - return "DATE" - elif bigframes_dtype == bigframes.dtypes.TIME_DTYPE: - return "TIME" - elif bigframes_dtype == bigframes.dtypes.DATETIME_DTYPE: - return "DATETIME" - elif bigframes_dtype == bigframes.dtypes.TIMESTAMP_DTYPE: - return "TIMESTAMP" - elif bigframes_dtype == bigframes.dtypes.BYTES_DTYPE: - return "BYTES" - elif bigframes_dtype == bigframes.dtypes.NUMERIC_DTYPE: - return "NUMERIC" - elif bigframes_dtype == bigframes.dtypes.BIGNUMERIC_DTYPE: - return "BIGNUMERIC" - elif bigframes_dtype == bigframes.dtypes.JSON_DTYPE: - return "JSON" - elif bigframes_dtype == bigframes.dtypes.GEO_DTYPE: - return "GEOGRAPHY" - elif bigframes_dtype == bigframes.dtypes.TIMEDELTA_DTYPE: - return "INT64" - elif isinstance(bigframes_dtype, pd.ArrowDtype): - if pa.types.is_list(bigframes_dtype.pyarrow_dtype): - inner_bigframes_dtype = bigframes.dtypes.arrow_dtype_to_bigframes_dtype( - bigframes_dtype.pyarrow_dtype.value_type +def from_bigframes_dtype( + bigframes_dtype: typing.Union[ + bigframes.dtypes.DtypeString, bigframes.dtypes.Dtype, np.dtype[typing.Any] + ], +) -> str: + if bigframes_dtype == bigframes.dtypes.INT_DTYPE: + return "INT64" + elif bigframes_dtype == bigframes.dtypes.FLOAT_DTYPE: + return "FLOAT64" + elif bigframes_dtype == bigframes.dtypes.STRING_DTYPE: + return "STRING" + elif bigframes_dtype == bigframes.dtypes.BOOL_DTYPE: + return "BOOLEAN" + elif bigframes_dtype == bigframes.dtypes.DATE_DTYPE: + return "DATE" + elif bigframes_dtype == bigframes.dtypes.TIME_DTYPE: + return "TIME" + elif bigframes_dtype == bigframes.dtypes.DATETIME_DTYPE: + return "DATETIME" + elif bigframes_dtype == bigframes.dtypes.TIMESTAMP_DTYPE: + return "TIMESTAMP" + elif bigframes_dtype == bigframes.dtypes.BYTES_DTYPE: + return "BYTES" + elif bigframes_dtype == bigframes.dtypes.NUMERIC_DTYPE: + return "NUMERIC" + elif bigframes_dtype == bigframes.dtypes.BIGNUMERIC_DTYPE: + return "BIGNUMERIC" + elif bigframes_dtype == bigframes.dtypes.JSON_DTYPE: + return "JSON" + elif bigframes_dtype == bigframes.dtypes.GEO_DTYPE: + return "GEOGRAPHY" + elif bigframes_dtype == bigframes.dtypes.TIMEDELTA_DTYPE: + return "INT64" + elif isinstance(bigframes_dtype, pd.ArrowDtype): + if pa.types.is_list(bigframes_dtype.pyarrow_dtype): + inner_bigframes_dtype = bigframes.dtypes.arrow_dtype_to_bigframes_dtype( + bigframes_dtype.pyarrow_dtype.value_type + ) + return f"ARRAY<{from_bigframes_dtype(inner_bigframes_dtype)}>" + elif pa.types.is_struct(bigframes_dtype.pyarrow_dtype): + struct_type = typing.cast(pa.StructType, bigframes_dtype.pyarrow_dtype) + inner_fields: list[str] = [] + for i in range(struct_type.num_fields): + field = struct_type.field(i) + key = sg.to_identifier(field.name).sql("bigquery") + dtype = from_bigframes_dtype( + bigframes.dtypes.arrow_dtype_to_bigframes_dtype(field.type) ) - return ( - f"ARRAY<{SQLGlotType.from_bigframes_dtype(inner_bigframes_dtype)}>" - ) - elif pa.types.is_struct(bigframes_dtype.pyarrow_dtype): - struct_type = typing.cast(pa.StructType, bigframes_dtype.pyarrow_dtype) - inner_fields: list[str] = [] - for i in range(struct_type.num_fields): - field = struct_type.field(i) - key = sg.to_identifier(field.name).sql("bigquery") - dtype = SQLGlotType.from_bigframes_dtype( - bigframes.dtypes.arrow_dtype_to_bigframes_dtype(field.type) - ) - inner_fields.append(f"{key} {dtype}") - return "STRUCT<{}>".format(", ".join(inner_fields)) + inner_fields.append(f"{key} {dtype}") + return "STRUCT<{}>".format(", ".join(inner_fields)) - raise ValueError( - f"Unsupported type for {bigframes_dtype}. {constants.FEEDBACK_LINK}" - ) + raise ValueError( + f"Unsupported type for {bigframes_dtype}. {constants.FEEDBACK_LINK}" + ) diff --git a/tests/unit/core/compile/sqlglot/test_sqlglot_types.py b/tests/unit/core/compile/sqlglot/test_sqlglot_types.py index a9108e5daf..5c2d84383d 100644 --- a/tests/unit/core/compile/sqlglot/test_sqlglot_types.py +++ b/tests/unit/core/compile/sqlglot/test_sqlglot_types.py @@ -20,34 +20,34 @@ def test_from_bigframes_simple_dtypes(): - assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.INT_DTYPE) == "INT64" - assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.FLOAT_DTYPE) == "FLOAT64" - assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.STRING_DTYPE) == "STRING" - assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.BOOL_DTYPE) == "BOOLEAN" - assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.DATE_DTYPE) == "DATE" - assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.TIME_DTYPE) == "TIME" - assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.DATETIME_DTYPE) == "DATETIME" - assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.TIMESTAMP_DTYPE) == "TIMESTAMP" - assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.BYTES_DTYPE) == "BYTES" - assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.NUMERIC_DTYPE) == "NUMERIC" - assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.BIGNUMERIC_DTYPE) == "BIGNUMERIC" - assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.JSON_DTYPE) == "JSON" - assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.GEO_DTYPE) == "GEOGRAPHY" + assert sgt.from_bigframes_dtype(dtypes.INT_DTYPE) == "INT64" + assert sgt.from_bigframes_dtype(dtypes.FLOAT_DTYPE) == "FLOAT64" + assert sgt.from_bigframes_dtype(dtypes.STRING_DTYPE) == "STRING" + assert sgt.from_bigframes_dtype(dtypes.BOOL_DTYPE) == "BOOLEAN" + assert sgt.from_bigframes_dtype(dtypes.DATE_DTYPE) == "DATE" + assert sgt.from_bigframes_dtype(dtypes.TIME_DTYPE) == "TIME" + assert sgt.from_bigframes_dtype(dtypes.DATETIME_DTYPE) == "DATETIME" + assert sgt.from_bigframes_dtype(dtypes.TIMESTAMP_DTYPE) == "TIMESTAMP" + assert sgt.from_bigframes_dtype(dtypes.BYTES_DTYPE) == "BYTES" + assert sgt.from_bigframes_dtype(dtypes.NUMERIC_DTYPE) == "NUMERIC" + assert sgt.from_bigframes_dtype(dtypes.BIGNUMERIC_DTYPE) == "BIGNUMERIC" + assert sgt.from_bigframes_dtype(dtypes.JSON_DTYPE) == "JSON" + assert sgt.from_bigframes_dtype(dtypes.GEO_DTYPE) == "GEOGRAPHY" def test_from_bigframes_struct_dtypes(): fields = [pa.field("int_col", pa.int64()), pa.field("bool_col", pa.bool_())] struct_type = pd.ArrowDtype(pa.struct(fields)) expected = "STRUCT" - assert sgt.SQLGlotType.from_bigframes_dtype(struct_type) == expected + assert sgt.from_bigframes_dtype(struct_type) == expected def test_from_bigframes_array_dtypes(): int_array_type = pd.ArrowDtype(pa.list_(pa.int64())) - assert sgt.SQLGlotType.from_bigframes_dtype(int_array_type) == "ARRAY" + assert sgt.from_bigframes_dtype(int_array_type) == "ARRAY" string_array_type = pd.ArrowDtype(pa.list_(pa.string())) - assert sgt.SQLGlotType.from_bigframes_dtype(string_array_type) == "ARRAY" + assert sgt.from_bigframes_dtype(string_array_type) == "ARRAY" def test_from_bigframes_multi_nested_dtypes(): @@ -61,4 +61,4 @@ def test_from_bigframes_multi_nested_dtypes(): expected = ( "ARRAY>>" ) - assert sgt.SQLGlotType.from_bigframes_dtype(array_type) == expected + assert sgt.from_bigframes_dtype(array_type) == expected From ef0b0b73843da2a93baf08e4cd5457fbb590b89c Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Tue, 7 Oct 2025 13:21:45 -0700 Subject: [PATCH 128/313] feat: add output_schema parameter to ai.generate() (#2139) * feat: add output_schema to ai.generate() * fix lint * fix lint * fix test * fix mypy * fix lint * code optimization * fix tests * support case-insensitive type parsing * fix test * fix: Fix row count local execution bug (#2133) * fix: join on, how args are now positional (#2140) --------- Co-authored-by: TrevorBergeron --- bigframes/bigquery/_operations/ai.py | 26 ++++- .../ibis_compiler/scalar_op_registry.py | 1 + .../compile/sqlglot/expressions/ai_ops.py | 18 +++- bigframes/operations/ai_ops.py | 10 +- bigframes/operations/output_schemas.py | 90 +++++++++++++++++ tests/system/small/bigquery/test_ai.py | 35 +++++++ .../out.sql | 19 ++++ .../sqlglot/expressions/test_ai_ops.py | 21 ++++ tests/unit/operations/test_output_schemas.py | 99 +++++++++++++++++++ .../ibis/expr/operations/ai_ops.py | 21 +++- 10 files changed, 328 insertions(+), 12 deletions(-) create mode 100644 bigframes/operations/output_schemas.py create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_output_schema/out.sql create mode 100644 tests/unit/operations/test_output_schemas.py diff --git a/bigframes/bigquery/_operations/ai.py b/bigframes/bigquery/_operations/ai.py index a789310683..0c5eba9496 100644 --- a/bigframes/bigquery/_operations/ai.py +++ b/bigframes/bigquery/_operations/ai.py @@ -25,7 +25,7 @@ from bigframes import clients, dtypes, series, session from bigframes.core import convert, log_adapter -from bigframes.operations import ai_ops +from bigframes.operations import ai_ops, output_schemas PROMPT_TYPE = Union[ series.Series, @@ -43,7 +43,7 @@ def generate( endpoint: str | None = None, request_type: Literal["dedicated", "shared", "unspecified"] = "unspecified", model_params: Mapping[Any, Any] | None = None, - # TODO(b/446974666) Add output_schema parameter + output_schema: Mapping[str, str] | None = None, ) -> series.Series: """ Returns the AI analysis based on the prompt, which can be any combination of text and unstructured data. @@ -64,6 +64,14 @@ def generate( 1 Ottawa\\n Name: result, dtype: string + You get structured output when the `output_schema` parameter is set: + + >>> animals = bpd.Series(["Rabbit", "Spider"]) + >>> bbq.ai.generate(animals, output_schema={"number_of_legs": "INT64", "is_herbivore": "BOOL"}) + 0 {'is_herbivore': True, 'number_of_legs': 4, 'f... + 1 {'is_herbivore': False, 'number_of_legs': 8, '... + dtype: struct>, status: string>[pyarrow] + Args: prompt (Series | List[str|Series] | Tuple[str|Series, ...]): A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series @@ -86,10 +94,14 @@ def generate( If requests exceed the Provisioned Throughput quota, the overflow traffic uses DSQ quota. model_params (Mapping[Any, Any]): Provides additional parameters to the model. The MODEL_PARAMS value must conform to the generateContent request body format. + output_schema (Mapping[str, str]): + A mapping value that specifies the schema of the output, in the form {field_name: data_type}. Supported data types include + `STRING`, `INT64`, `FLOAT64`, `BOOL`, `ARRAY`, and `STRUCT`. Returns: bigframes.series.Series: A new struct Series with the result data. The struct contains these fields: * "result": a STRING value containing the model's response to the prompt. The result is None if the request fails or is filtered by responsible AI. + If you specify an output schema then result is replaced by your custom schema. * "full_response": a JSON value containing the response from the projects.locations.endpoints.generateContent call to the model. The generated text is in the text element. * "status": a STRING value that contains the API response status for the corresponding row. This value is empty if the operation was successful. @@ -98,12 +110,22 @@ def generate( prompt_context, series_list = _separate_context_and_series(prompt) assert len(series_list) > 0 + if output_schema is None: + output_schema_str = None + else: + output_schema_str = ", ".join( + [f"{name} {sql_type}" for name, sql_type in output_schema.items()] + ) + # Validate user input + output_schemas.parse_sql_fields(output_schema_str) + operator = ai_ops.AIGenerate( prompt_context=tuple(prompt_context), connection_id=_resolve_connection_id(series_list[0], connection_id), endpoint=endpoint, request_type=request_type, model_params=json.dumps(model_params) if model_params else None, + output_schema=output_schema_str, ) return series_list[0]._apply_nary_op(operator, series_list[1:]) diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index 4c02e17d6f..e983fc7e21 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -1985,6 +1985,7 @@ def ai_generate( op.endpoint, # type: ignore op.request_type.upper(), # type: ignore op.model_params, # type: ignore + op.output_schema, # type: ignore ).to_expr() diff --git a/bigframes/core/compile/sqlglot/expressions/ai_ops.py b/bigframes/core/compile/sqlglot/expressions/ai_ops.py index 4129c91906..e40173d2fd 100644 --- a/bigframes/core/compile/sqlglot/expressions/ai_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/ai_ops.py @@ -15,7 +15,6 @@ from __future__ import annotations from dataclasses import asdict -import typing import sqlglot.expressions as sge @@ -105,16 +104,16 @@ def _construct_named_args(op: ops.NaryOp) -> list[sge.Kwarg]: op_args = asdict(op) - connection_id = typing.cast(str, op_args["connection_id"]) + connection_id = op_args["connection_id"] args.append( sge.Kwarg(this="connection_id", expression=sge.Literal.string(connection_id)) ) - endpoit = typing.cast(str, op_args.get("endpoint", None)) + endpoit = op_args.get("endpoint", None) if endpoit is not None: args.append(sge.Kwarg(this="endpoint", expression=sge.Literal.string(endpoit))) - request_type = typing.cast(str, op_args.get("request_type", None)) + request_type = op_args.get("request_type", None) if request_type is not None: args.append( sge.Kwarg( @@ -122,7 +121,7 @@ def _construct_named_args(op: ops.NaryOp) -> list[sge.Kwarg]: ) ) - model_params = typing.cast(str, op_args.get("model_params", None)) + model_params = op_args.get("model_params", None) if model_params is not None: args.append( sge.Kwarg( @@ -133,4 +132,13 @@ def _construct_named_args(op: ops.NaryOp) -> list[sge.Kwarg]: ) ) + output_schema = op_args.get("output_schema", None) + if output_schema is not None: + args.append( + sge.Kwarg( + this="output_schema", + expression=sge.Literal.string(output_schema), + ) + ) + return args diff --git a/bigframes/operations/ai_ops.py b/bigframes/operations/ai_ops.py index 7ba3737ba0..ea65b705e5 100644 --- a/bigframes/operations/ai_ops.py +++ b/bigframes/operations/ai_ops.py @@ -21,7 +21,7 @@ import pyarrow as pa from bigframes import dtypes -from bigframes.operations import base_ops +from bigframes.operations import base_ops, output_schemas @dataclasses.dataclass(frozen=True) @@ -33,12 +33,18 @@ class AIGenerate(base_ops.NaryOp): endpoint: str | None request_type: Literal["dedicated", "shared", "unspecified"] model_params: str | None + output_schema: str | None def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + if self.output_schema is None: + output_fields = (pa.field("result", pa.string()),) + else: + output_fields = output_schemas.parse_sql_fields(self.output_schema) + return pd.ArrowDtype( pa.struct( ( - pa.field("result", pa.string()), + *output_fields, pa.field("full_response", dtypes.JSON_ARROW_TYPE), pa.field("status", pa.string()), ) diff --git a/bigframes/operations/output_schemas.py b/bigframes/operations/output_schemas.py new file mode 100644 index 0000000000..ff9c9883dc --- /dev/null +++ b/bigframes/operations/output_schemas.py @@ -0,0 +1,90 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pyarrow as pa + + +def parse_sql_type(sql: str) -> pa.DataType: + """ + Parses a SQL type string to its PyArrow equivalence: + + For example: + "STRING" -> pa.string() + "ARRAY" -> pa.list_(pa.int64()) + "STRUCT, y BOOL>" -> pa.struct( + ( + pa.field("x", pa.list_(pa.float64())), + pa.field("y", pa.bool_()), + ) + ) + """ + sql = sql.strip() + + if sql.upper() == "STRING": + return pa.string() + + if sql.upper() == "INT64": + return pa.int64() + + if sql.upper() == "FLOAT64": + return pa.float64() + + if sql.upper() == "BOOL": + return pa.bool_() + + if sql.upper().startswith("ARRAY<") and sql.endswith(">"): + inner_type = sql[len("ARRAY<") : -1] + return pa.list_(parse_sql_type(inner_type)) + + if sql.upper().startswith("STRUCT<") and sql.endswith(">"): + inner_fields = parse_sql_fields(sql[len("STRUCT<") : -1]) + return pa.struct(inner_fields) + + raise ValueError(f"Unsupported SQL type: {sql}") + + +def parse_sql_fields(sql: str) -> tuple[pa.Field]: + sql = sql.strip() + + start_idx = 0 + nested_depth = 0 + fields: list[pa.field] = [] + + for end_idx in range(len(sql)): + c = sql[end_idx] + + if c == "<": + nested_depth += 1 + elif c == ">": + nested_depth -= 1 + elif c == "," and nested_depth == 0: + field = sql[start_idx:end_idx] + fields.append(parse_sql_field(field)) + start_idx = end_idx + 1 + + # Append the last field + fields.append(parse_sql_field(sql[start_idx:])) + + return tuple(sorted(fields, key=lambda f: f.name)) + + +def parse_sql_field(sql: str) -> pa.Field: + sql = sql.strip() + + space_idx = sql.find(" ") + + if space_idx == -1: + raise ValueError(f"Invalid struct field: {sql}") + + return pa.field(sql[:space_idx].strip(), parse_sql_type(sql[space_idx:])) diff --git a/tests/system/small/bigquery/test_ai.py b/tests/system/small/bigquery/test_ai.py index 7a6e5aea4f..2ccdb01944 100644 --- a/tests/system/small/bigquery/test_ai.py +++ b/tests/system/small/bigquery/test_ai.py @@ -87,6 +87,41 @@ def test_ai_generate(session): ) +def test_ai_generate_with_output_schema(session): + country = bpd.Series(["Japan", "Canada"], session=session) + prompt = ("Describe ", country) + + result = bbq.ai.generate( + prompt, + endpoint="gemini-2.5-flash", + output_schema={"population": "INT64", "is_in_north_america": "bool"}, + ) + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("is_in_north_america", pa.bool_()), + pa.field("population", pa.int64()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + +def test_ai_generate_with_invalid_output_schema_raise_error(session): + country = bpd.Series(["Japan", "Canada"], session=session) + prompt = ("Describe ", country) + + with pytest.raises(ValueError): + bbq.ai.generate( + prompt, + endpoint="gemini-2.5-flash", + output_schema={"population": "INT64", "is_in_north_america": "JSON"}, + ) + + def test_ai_generate_bool(session): s1 = bpd.Series(["apple", "bear"], session=session) s2 = bpd.Series(["fruit", "tree"], session=session) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_output_schema/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_output_schema/out.sql new file mode 100644 index 0000000000..62fc2f9db0 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_output_schema/out.sql @@ -0,0 +1,19 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE( + prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + connection_id => 'bigframes-dev.us.bigframes-default-connection', + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED', + output_schema => 'x INT64, y FLOAT64' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py index c809e90a90..13481d88c6 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py @@ -36,6 +36,26 @@ def test_ai_generate(scalar_types_df: dataframe.DataFrame, snapshot): endpoint="gemini-2.5-flash", request_type="shared", model_params=None, + output_schema=None, + ) + + sql = utils._apply_unary_ops( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_with_output_schema(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + + op = ops.AIGenerate( + prompt_context=(None, " is the same as ", None), + connection_id=CONNECTION_ID, + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + output_schema="x INT64, y FLOAT64", ) sql = utils._apply_unary_ops( @@ -59,6 +79,7 @@ def test_ai_generate_with_model_param(scalar_types_df: dataframe.DataFrame, snap endpoint=None, request_type="shared", model_params=json.dumps(dict()), + output_schema=None, ) sql = utils._apply_unary_ops( diff --git a/tests/unit/operations/test_output_schemas.py b/tests/unit/operations/test_output_schemas.py new file mode 100644 index 0000000000..c609098c98 --- /dev/null +++ b/tests/unit/operations/test_output_schemas.py @@ -0,0 +1,99 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pyarrow as pa +import pytest + +from bigframes.operations import output_schemas + + +@pytest.mark.parametrize( + ("sql", "expected"), + [ + ("INT64", pa.int64()), + (" INT64 ", pa.int64()), + ("int64", pa.int64()), + ("FLOAT64", pa.float64()), + ("STRING", pa.string()), + ("BOOL", pa.bool_()), + ("ARRAY", pa.list_(pa.int64())), + ( + "STRUCT", + pa.struct((pa.field("x", pa.int64()), pa.field("y", pa.float64()))), + ), + ( + "STRUCT< x INT64, y FLOAT64>", + pa.struct((pa.field("x", pa.int64()), pa.field("y", pa.float64()))), + ), + ( + "STRUCT", + pa.struct((pa.field("x", pa.float64()), pa.field("y", pa.int64()))), + ), + ( + "ARRAY>", + pa.list_(pa.struct((pa.field("x", pa.int64()), pa.field("y", pa.int64())))), + ), + ( + "STRUCT, x ARRAY>", + pa.struct( + ( + pa.field("x", pa.list_(pa.float64())), + pa.field( + "y", + pa.struct( + (pa.field("a", pa.bool_()), pa.field("b", pa.string())) + ), + ), + ) + ), + ), + ], +) +def test_parse_sql_to_pyarrow_dtype(sql, expected): + assert output_schemas.parse_sql_type(sql) == expected + + +@pytest.mark.parametrize( + "sql", + [ + "a INT64", + "ARRAY<>", + "ARRAY" "ARRAY" "STRUCT<>", + "DATE", + "STRUCT", + "ARRAY>", + ], +) +def test_parse_sql_to_pyarrow_dtype_invalid_input_raies_error(sql): + with pytest.raises(ValueError): + output_schemas.parse_sql_type(sql) + + +@pytest.mark.parametrize( + ("sql", "expected"), + [ + ("x INT64", (pa.field("x", pa.int64()),)), + ( + "x INT64, y FLOAT64", + (pa.field("x", pa.int64()), pa.field("y", pa.float64())), + ), + ( + "y FLOAT64, x INT64", + (pa.field("x", pa.int64()), pa.field("y", pa.float64())), + ), + ], +) +def test_parse_sql_fields(sql, expected): + assert output_schemas.parse_sql_fields(sql) == expected diff --git a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py index e9d704fa8e..da7f132de3 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py @@ -1,6 +1,6 @@ # Contains code from https://github.com/ibis-project/ibis/blob/9.2.0/ibis/expr/operations/maps.py -"""Operations for working with maps.""" +"""Operations for working with AI operators.""" from __future__ import annotations @@ -11,6 +11,9 @@ from bigframes_vendored.ibis.expr.operations.core import Value import bigframes_vendored.ibis.expr.rules as rlz from public import public +import pyarrow as pa + +from bigframes.operations import output_schemas @public @@ -22,15 +25,27 @@ class AIGenerate(Value): endpoint: Optional[Value[dt.String]] request_type: Value[dt.String] model_params: Optional[Value[dt.String]] + output_schema: Optional[Value[dt.String]] shape = rlz.shape_like("prompt") @attribute def dtype(self) -> dt.Struct: - return dt.Struct.from_tuples( - (("result", dt.string), ("full_resposne", dt.string), ("status", dt.string)) + if self.output_schema is None: + output_pa_fields = (pa.field("result", pa.string()),) + else: + output_pa_fields = output_schemas.parse_sql_fields(self.output_schema.value) + + pyarrow_output_type = pa.struct( + ( + *output_pa_fields, + pa.field("full_resposne", pa.string()), + pa.field("status", pa.string()), + ) ) + return dt.Struct.from_pyarrow(pyarrow_output_type) + @public class AIGenerateBool(Value): From 85142008ec895fa078d192bbab942d0257f70df3 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Tue, 7 Oct 2025 13:40:04 -0700 Subject: [PATCH 129/313] feat: Add Index.__eq__ for consts, aligned objects (#2141) --- bigframes/core/indexes/base.py | 52 +++++++++++++++++++++++++++ bigframes/core/indexes/multi.py | 25 +++++++++++++ tests/system/small/test_index.py | 17 +++++++++ tests/system/small/test_multiindex.py | 10 ++++++ 4 files changed, 104 insertions(+) diff --git a/bigframes/core/indexes/base.py b/bigframes/core/indexes/base.py index a6b18fcb43..83dd11dacb 100644 --- a/bigframes/core/indexes/base.py +++ b/bigframes/core/indexes/base.py @@ -754,6 +754,58 @@ def item(self): # Docstring is in third_party/bigframes_vendored/pandas/core/indexes/base.py return self.to_series().peek(2).item() + def __eq__(self, other) -> Index: # type: ignore + return self._apply_binop(other, ops.eq_op) + + def _apply_binop(self, other, op: ops.BinaryOp) -> Index: + # TODO: Handle local objects, or objects not implicitly alignable? Gets ambiguous with partial ordering though + if isinstance(other, (bigframes.series.Series, Index)): + other = Index(other) + if other.nlevels != self.nlevels: + raise ValueError("Dimensions do not match") + + lexpr = self._block.expr + rexpr = other._block.expr + join_result = lexpr.try_row_join(rexpr) + if join_result is None: + raise ValueError("Cannot align objects") + + expr, (lmap, rmap) = join_result + + expr, res_ids = expr.compute_values( + [ + op.as_expr(lmap[lid], rmap[rid]) + for lid, rid in zip(lexpr.column_ids, rexpr.column_ids) + ] + ) + return Index( + blocks.Block( + expr.select_columns(res_ids), + index_columns=res_ids, + column_labels=[], + index_labels=[None] * len(res_ids), + ) + ) + elif ( + isinstance(other, bigframes.dtypes.LOCAL_SCALAR_TYPES) and self.nlevels == 1 + ): + block, id = self._block.project_expr( + op.as_expr(self._block.index_columns[0], ex.const(other)) + ) + return Index(block.select_column(id)) + elif isinstance(other, tuple) and len(other) == self.nlevels: + block = self._block.project_exprs( + [ + op.as_expr(self._block.index_columns[i], ex.const(other[i])) + for i in range(self.nlevels) + ], + labels=[None] * self.nlevels, + drop=True, + ) + return Index(block.set_index(block.value_columns)) + else: + return NotImplemented + def _should_create_datetime_index(block: blocks.Block) -> bool: if len(block.index.dtypes) != 1: diff --git a/bigframes/core/indexes/multi.py b/bigframes/core/indexes/multi.py index 182d1f101c..a8b4b7dffe 100644 --- a/bigframes/core/indexes/multi.py +++ b/bigframes/core/indexes/multi.py @@ -19,6 +19,8 @@ import bigframes_vendored.pandas.core.indexes.multi as vendored_pandas_multindex import pandas +from bigframes.core import blocks +from bigframes.core import expression as ex from bigframes.core.indexes.base import Index @@ -46,3 +48,26 @@ def from_arrays( pd_index = pandas.MultiIndex.from_arrays(arrays, sortorder, names) # Index.__new__ should detect multiple levels and properly create a multiindex return cast(MultiIndex, Index(pd_index)) + + def __eq__(self, other) -> Index: # type: ignore + import bigframes.operations as ops + import bigframes.operations.aggregations as agg_ops + + eq_result = self._apply_binop(other, ops.eq_op)._block.expr + + as_array = ops.ToArrayOp().as_expr( + *( + ops.fillna_op.as_expr(col, ex.const(False)) + for col in eq_result.column_ids + ) + ) + reduced = ops.ArrayReduceOp(agg_ops.all_op).as_expr(as_array) + result_expr, result_ids = eq_result.compute_values([reduced]) + return Index( + blocks.Block( + result_expr.select_columns(result_ids), + index_columns=result_ids, + column_labels=(), + index_labels=[None], + ) + ) diff --git a/tests/system/small/test_index.py b/tests/system/small/test_index.py index 90986c989a..3fe479af6e 100644 --- a/tests/system/small/test_index.py +++ b/tests/system/small/test_index.py @@ -668,3 +668,20 @@ def test_custom_index_setitem_error(): with pytest.raises(TypeError, match="Index does not support mutable operations"): custom_index[2] = 999 + + +def test_index_eq_const(scalars_df_index, scalars_pandas_df_index): + bf_result = (scalars_df_index.index == 3).to_pandas() + pd_result = scalars_pandas_df_index.index == 3 + assert bf_result == pd.Index(pd_result) + + +def test_index_eq_aligned_index(scalars_df_index, scalars_pandas_df_index): + bf_result = ( + bpd.Index(scalars_df_index.int64_col) + == bpd.Index(scalars_df_index.int64_col.abs()) + ).to_pandas() + pd_result = pd.Index(scalars_pandas_df_index.int64_col) == pd.Index( + scalars_pandas_df_index.int64_col.abs() + ) + assert bf_result == pd.Index(pd_result) diff --git a/tests/system/small/test_multiindex.py b/tests/system/small/test_multiindex.py index f15b8d8b21..3a86d5f6c5 100644 --- a/tests/system/small/test_multiindex.py +++ b/tests/system/small/test_multiindex.py @@ -1474,3 +1474,13 @@ def test_multi_index_contains(scalars_df_index, scalars_pandas_df_index, key): pd_result = key in scalars_pandas_df_index.set_index(col_name).index assert bf_result == pd_result + + +def test_multiindex_eq_const(scalars_df_index, scalars_pandas_df_index): + col_name = ["int64_col", "bool_col"] + bf_result = scalars_df_index.set_index(col_name).index == (2, False) + pd_result = scalars_pandas_df_index.set_index(col_name).index == (2, False) + + pandas.testing.assert_index_equal( + pandas.Index(pd_result, dtype="boolean"), bf_result.to_pandas() + ) From 8997d4d7d9965e473195f98c550c80657035b7e1 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Tue, 7 Oct 2025 22:52:56 -0700 Subject: [PATCH 130/313] fix: Yield row count from read session if otherwise unknown (#2148) --- bigframes/session/read_api_execution.py | 2 +- tests/system/small/test_dataframe.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/bigframes/session/read_api_execution.py b/bigframes/session/read_api_execution.py index 037fde011f..2530a1dc8d 100644 --- a/bigframes/session/read_api_execution.py +++ b/bigframes/session/read_api_execution.py @@ -102,7 +102,7 @@ def process_page(page): if peek: batches = pyarrow_utils.truncate_pyarrow_iterable(batches, max_results=peek) - rows = node.source.n_rows + rows = node.source.n_rows or session.estimated_row_count if peek and rows: rows = min(peek, rows) diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 1a942a023e..851c934838 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -993,6 +993,12 @@ def test_filter_df(scalars_dfs): assert_pandas_df_equal(bf_result, pd_result) +def test_read_gbq_direct_to_batches_row_count(unordered_session): + df = unordered_session.read_gbq("bigquery-public-data.usa_names.usa_1910_2013") + iter = df.to_pandas_batches() + assert iter.total_rows == 5552452 + + def test_df_to_pandas_batches(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs From d13abadbcd68d03997e8dc11bb7a2b14bbd57fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Wed, 8 Oct 2025 11:00:09 -0500 Subject: [PATCH 131/313] docs: remove progress bar from getting started template (#2143) Fixes b/449999815 --- .../getting_started_bq_dataframes.ipynb | 443 +++++++++--------- 1 file changed, 214 insertions(+), 229 deletions(-) diff --git a/notebooks/getting_started/getting_started_bq_dataframes.ipynb b/notebooks/getting_started/getting_started_bq_dataframes.ipynb index 384f3b9c10..fa88cf65bb 100644 --- a/notebooks/getting_started/getting_started_bq_dataframes.ipynb +++ b/notebooks/getting_started/getting_started_bq_dataframes.ipynb @@ -137,11 +137,112 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": { "id": "mfPoOwPLGpSr" }, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Requirement already satisfied: bigframes in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (2.17.0)\n", + "Requirement already satisfied: cloudpickle>=2.0.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (3.1.1)\n", + "Requirement already satisfied: fsspec>=2023.3.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2025.9.0)\n", + "Requirement already satisfied: gcsfs!=2025.5.0,>=2023.3.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2025.9.0)\n", + "Requirement already satisfied: geopandas>=0.12.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (1.1.1)\n", + "Requirement already satisfied: google-auth<3.0,>=2.15.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2.40.3)\n", + "Requirement already satisfied: google-cloud-bigquery>=3.36.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-cloud-bigquery[bqstorage,pandas]>=3.36.0->bigframes) (3.36.0)\n", + "Requirement already satisfied: google-cloud-bigquery-storage<3.0.0,>=2.30.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2.33.0)\n", + "Requirement already satisfied: google-cloud-functions>=1.12.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (1.20.4)\n", + "Requirement already satisfied: google-cloud-bigquery-connection>=1.12.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (1.18.3)\n", + "Requirement already satisfied: google-cloud-resource-manager>=1.10.3 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (1.14.2)\n", + "Requirement already satisfied: google-cloud-storage>=2.0.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (3.3.1)\n", + "Requirement already satisfied: grpc-google-iam-v1>=0.14.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (0.14.2)\n", + "Requirement already satisfied: numpy>=1.24.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2.2.6)\n", + "Requirement already satisfied: pandas>=1.5.3 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2.3.2)\n", + "Requirement already satisfied: pandas-gbq>=0.26.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (0.29.2)\n", + "Requirement already satisfied: pyarrow>=15.0.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (21.0.0)\n", + "Requirement already satisfied: pydata-google-auth>=1.8.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (1.9.1)\n", + "Requirement already satisfied: requests>=2.27.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2.32.5)\n", + "Requirement already satisfied: shapely>=1.8.5 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2.1.1)\n", + "Requirement already satisfied: sqlglot>=23.6.3 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (27.11.0)\n", + "Requirement already satisfied: tabulate>=0.9 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (0.9.0)\n", + "Requirement already satisfied: ipywidgets>=7.7.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (8.1.7)\n", + "Requirement already satisfied: humanize>=4.6.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (4.13.0)\n", + "Requirement already satisfied: matplotlib>=3.7.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (3.10.6)\n", + "Requirement already satisfied: db-dtypes>=1.4.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (1.4.3)\n", + "Requirement already satisfied: atpublic<6,>=2.3 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (5.1)\n", + "Requirement already satisfied: python-dateutil<3,>=2.8.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2.9.0.post0)\n", + "Requirement already satisfied: pytz>=2022.7 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (2025.2)\n", + "Requirement already satisfied: toolz<2,>=0.11 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (1.0.0)\n", + "Requirement already satisfied: typing-extensions<5,>=4.5.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (4.15.0)\n", + "Requirement already satisfied: rich<14,>=12.4.4 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from bigframes) (13.9.4)\n", + "Requirement already satisfied: cachetools<6.0,>=2.0.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-auth<3.0,>=2.15.0->bigframes) (5.5.2)\n", + "Requirement already satisfied: pyasn1-modules>=0.2.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-auth<3.0,>=2.15.0->bigframes) (0.4.2)\n", + "Requirement already satisfied: rsa<5,>=3.1.4 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-auth<3.0,>=2.15.0->bigframes) (4.9.1)\n", + "Requirement already satisfied: google-api-core!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0->google-cloud-bigquery-storage<3.0.0,>=2.30.0->bigframes) (2.25.1)\n", + "Requirement already satisfied: proto-plus<2.0.0,>=1.22.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-cloud-bigquery-storage<3.0.0,>=2.30.0->bigframes) (1.26.1)\n", + "Requirement already satisfied: protobuf!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0,>=3.20.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-cloud-bigquery-storage<3.0.0,>=2.30.0->bigframes) (6.32.0)\n", + "Requirement already satisfied: googleapis-common-protos<2.0.0,>=1.56.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-api-core!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0->google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0->google-cloud-bigquery-storage<3.0.0,>=2.30.0->bigframes) (1.70.0)\n", + "Requirement already satisfied: grpcio<2.0.0,>=1.33.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0->google-cloud-bigquery-storage<3.0.0,>=2.30.0->bigframes) (1.74.0)\n", + "Requirement already satisfied: grpcio-status<2.0.0,>=1.33.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-api-core[grpc]!=2.0.*,!=2.1.*,!=2.10.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,<3.0.0,>=1.34.0->google-cloud-bigquery-storage<3.0.0,>=2.30.0->bigframes) (1.74.0)\n", + "Requirement already satisfied: six>=1.5 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from python-dateutil<3,>=2.8.2->bigframes) (1.17.0)\n", + "Requirement already satisfied: charset_normalizer<4,>=2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from requests>=2.27.1->bigframes) (3.4.3)\n", + "Requirement already satisfied: idna<4,>=2.5 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from requests>=2.27.1->bigframes) (3.10)\n", + "Requirement already satisfied: urllib3<3,>=1.21.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from requests>=2.27.1->bigframes) (2.5.0)\n", + "Requirement already satisfied: certifi>=2017.4.17 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from requests>=2.27.1->bigframes) (2025.8.3)\n", + "Requirement already satisfied: markdown-it-py>=2.2.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from rich<14,>=12.4.4->bigframes) (4.0.0)\n", + "Requirement already satisfied: pygments<3.0.0,>=2.13.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from rich<14,>=12.4.4->bigframes) (2.19.2)\n", + "Requirement already satisfied: pyasn1>=0.1.3 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from rsa<5,>=3.1.4->google-auth<3.0,>=2.15.0->bigframes) (0.6.1)\n", + "Requirement already satisfied: packaging>=24.2.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from db-dtypes>=1.4.2->bigframes) (25.0)\n", + "Requirement already satisfied: aiohttp!=4.0.0a0,!=4.0.0a1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from gcsfs!=2025.5.0,>=2023.3.0->bigframes) (3.12.15)\n", + "Requirement already satisfied: decorator>4.1.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from gcsfs!=2025.5.0,>=2023.3.0->bigframes) (5.2.1)\n", + "Requirement already satisfied: google-auth-oauthlib in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from gcsfs!=2025.5.0,>=2023.3.0->bigframes) (1.2.2)\n", + "Requirement already satisfied: aiohappyeyeballs>=2.5.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (2.6.1)\n", + "Requirement already satisfied: aiosignal>=1.4.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (1.4.0)\n", + "Requirement already satisfied: async-timeout<6.0,>=4.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (5.0.1)\n", + "Requirement already satisfied: attrs>=17.3.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (25.3.0)\n", + "Requirement already satisfied: frozenlist>=1.1.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (1.7.0)\n", + "Requirement already satisfied: multidict<7.0,>=4.5 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (6.6.4)\n", + "Requirement already satisfied: propcache>=0.2.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (0.3.2)\n", + "Requirement already satisfied: yarl<2.0,>=1.17.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (1.20.1)\n", + "Requirement already satisfied: pyogrio>=0.7.2 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from geopandas>=0.12.2->bigframes) (0.11.1)\n", + "Requirement already satisfied: pyproj>=3.5.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from geopandas>=0.12.2->bigframes) (3.7.1)\n", + "Requirement already satisfied: google-cloud-core<3.0.0,>=2.4.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-cloud-bigquery>=3.36.0->google-cloud-bigquery[bqstorage,pandas]>=3.36.0->bigframes) (2.4.3)\n", + "Requirement already satisfied: google-resumable-media<3.0.0,>=2.0.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-cloud-bigquery>=3.36.0->google-cloud-bigquery[bqstorage,pandas]>=3.36.0->bigframes) (2.7.2)\n", + "Requirement already satisfied: google-crc32c<2.0dev,>=1.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-resumable-media<3.0.0,>=2.0.0->google-cloud-bigquery>=3.36.0->google-cloud-bigquery[bqstorage,pandas]>=3.36.0->bigframes) (1.7.1)\n", + "Requirement already satisfied: comm>=0.1.3 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipywidgets>=7.7.1->bigframes) (0.2.3)\n", + "Requirement already satisfied: ipython>=6.1.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipywidgets>=7.7.1->bigframes) (8.37.0)\n", + "Requirement already satisfied: traitlets>=4.3.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipywidgets>=7.7.1->bigframes) (5.14.3)\n", + "Requirement already satisfied: widgetsnbextension~=4.0.14 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipywidgets>=7.7.1->bigframes) (4.0.14)\n", + "Requirement already satisfied: jupyterlab_widgets~=3.0.15 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipywidgets>=7.7.1->bigframes) (3.0.15)\n", + "Requirement already satisfied: exceptiongroup in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (1.3.0)\n", + "Requirement already satisfied: jedi>=0.16 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.19.2)\n", + "Requirement already satisfied: matplotlib-inline in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.1.7)\n", + "Requirement already satisfied: pexpect>4.3 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (4.9.0)\n", + "Requirement already satisfied: prompt_toolkit<3.1.0,>=3.0.41 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (3.0.52)\n", + "Requirement already satisfied: stack_data in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.6.3)\n", + "Requirement already satisfied: wcwidth in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from prompt_toolkit<3.1.0,>=3.0.41->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.2.13)\n", + "Requirement already satisfied: parso<0.9.0,>=0.8.4 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from jedi>=0.16->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.8.5)\n", + "Requirement already satisfied: mdurl~=0.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from markdown-it-py>=2.2.0->rich<14,>=12.4.4->bigframes) (0.1.2)\n", + "Requirement already satisfied: contourpy>=1.0.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from matplotlib>=3.7.1->bigframes) (1.3.2)\n", + "Requirement already satisfied: cycler>=0.10 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from matplotlib>=3.7.1->bigframes) (0.12.1)\n", + "Requirement already satisfied: fonttools>=4.22.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from matplotlib>=3.7.1->bigframes) (4.59.2)\n", + "Requirement already satisfied: kiwisolver>=1.3.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from matplotlib>=3.7.1->bigframes) (1.4.9)\n", + "Requirement already satisfied: pillow>=8 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from matplotlib>=3.7.1->bigframes) (11.3.0)\n", + "Requirement already satisfied: pyparsing>=2.3.1 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from matplotlib>=3.7.1->bigframes) (3.2.3)\n", + "Requirement already satisfied: tzdata>=2022.7 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from pandas>=1.5.3->bigframes) (2025.2)\n", + "Requirement already satisfied: setuptools in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from pandas-gbq>=0.26.1->bigframes) (65.5.0)\n", + "Requirement already satisfied: requests-oauthlib>=0.7.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from google-auth-oauthlib->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (2.0.0)\n", + "Requirement already satisfied: ptyprocess>=0.5 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from pexpect>4.3->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.7.0)\n", + "Requirement already satisfied: oauthlib>=3.0.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from requests-oauthlib>=0.7.0->google-auth-oauthlib->gcsfs!=2025.5.0,>=2023.3.0->bigframes) (3.3.1)\n", + "Requirement already satisfied: executing>=1.2.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from stack_data->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (2.2.1)\n", + "Requirement already satisfied: asttokens>=2.1.0 in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from stack_data->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (3.0.0)\n", + "Requirement already satisfied: pure-eval in /usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes-2/venv/lib/python3.10/site-packages (from stack_data->ipython>=6.1.0->ipywidgets>=7.7.1->bigframes) (0.2.3)\n" + ] + } + ], "source": [ "!pip install bigframes" ] @@ -230,20 +331,9 @@ "metadata": { "id": "oM1iC_MfAts1" }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Updated property [core/project].\n" - ] - } - ], + "outputs": [], "source": [ - "PROJECT_ID = \"\" # @param {type:\"string\"}\n", - "\n", - "# Set the project id\n", - "! gcloud config set project {PROJECT_ID}" + "PROJECT_ID = \"\" # @param {type:\"string\"}" ] }, { @@ -381,7 +471,13 @@ "# It defaults to the location of the first table or query\n", "# passed to read_gbq(). For APIs where a location can't be\n", "# auto-detected, the location defaults to the \"US\" location.\n", - "bpd.options.bigquery.location = REGION" + "bpd.options.bigquery.location = REGION\n", + "\n", + "# Note: By default BigQuery DataFrames emits out BigQuery job metadata via a\n", + "# progress bar. But in this notebook let's disable the progress bar to keep the\n", + "# experience less verbose. If you would like the default behavior, please\n", + "# comment out the following expression. \n", + "bpd.options.display.progress_bar = None" ] }, { @@ -432,20 +528,7 @@ "metadata": { "id": "Vyex9BQI-BNa" }, - "outputs": [ - { - "data": { - "text/html": [ - "Query job badadf0b-27c8-4dac-a468-be3c40745538 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# bq_df_sample = bpd.read_gbq(\"bigquery-samples.wikipedia_pageviews.200809h\")" ] @@ -477,18 +560,6 @@ "id": "XfGq5apK-D_e" }, "outputs": [ - { - "data": { - "text/html": [ - "Query job c8669c7f-bca3-4f54-b354-8e57b3321f5a is DONE. 34.9 GB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/html": [ @@ -716,20 +787,7 @@ "metadata": { "id": "EDAaIwHpQCDZ" }, - "outputs": [ - { - "data": { - "text/html": [ - "Load job 93903930-10b8-48b8-b41b-3da54917b281 is DONE. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "# If order is not important, use the \"bigquery\" engine to\n", "# allow BigQuery DataFrames to read directly from GCS.\n", @@ -752,18 +810,6 @@ "id": "_gPD0Zn1Stdb" }, "outputs": [ - { - "data": { - "text/html": [ - "Query job 17f58b5c-88b2-4b26-8d0d-cc3d9a979a06 is DONE. 28.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/html": [ @@ -796,53 +842,53 @@ " \n", " \n", " \n", - " 78\n", - " Chinstrap penguin (Pygoscelis antarctica)\n", - " Dream\n", - " 47.0\n", - " 17.3\n", - " 185\n", - " 3700\n", - " FEMALE\n", + " 41\n", + " Gentoo penguin (Pygoscelis papua)\n", + " Biscoe\n", + " 49.8\n", + " 16.8\n", + " 230\n", + " 5700\n", + " MALE\n", " \n", " \n", - " 130\n", - " Adelie Penguin (Pygoscelis adeliae)\n", + " 73\n", + " Gentoo penguin (Pygoscelis papua)\n", " Biscoe\n", - " 40.5\n", - " 17.9\n", - " 187\n", - " 3200\n", - " FEMALE\n", + " 46.8\n", + " 16.1\n", + " 215\n", + " 5500\n", + " MALE\n", " \n", " \n", - " 84\n", + " 75\n", " Gentoo penguin (Pygoscelis papua)\n", " Biscoe\n", - " 49.1\n", - " 14.5\n", - " 212\n", - " 4625\n", - " FEMALE\n", + " 49.6\n", + " 16.0\n", + " 225\n", + " 5700\n", + " MALE\n", " \n", " \n", - " 334\n", + " 93\n", " Adelie Penguin (Pygoscelis adeliae)\n", " Biscoe\n", - " 38.2\n", - " 20.0\n", - " 190\n", - " 3900\n", - " MALE\n", + " 35.5\n", + " 16.2\n", + " 195\n", + " 3350\n", + " FEMALE\n", " \n", " \n", - " 67\n", + " 299\n", " Chinstrap penguin (Pygoscelis antarctica)\n", " Dream\n", - " 55.8\n", - " 19.8\n", - " 207\n", - " 4000\n", + " 52.0\n", + " 18.1\n", + " 201\n", + " 4050\n", " MALE\n", " \n", " \n", @@ -851,18 +897,18 @@ ], "text/plain": [ " species island culmen_length_mm \\\n", - "78 Chinstrap penguin (Pygoscelis antarctica) Dream 47.0 \n", - "130 Adelie Penguin (Pygoscelis adeliae) Biscoe 40.5 \n", - "84 Gentoo penguin (Pygoscelis papua) Biscoe 49.1 \n", - "334 Adelie Penguin (Pygoscelis adeliae) Biscoe 38.2 \n", - "67 Chinstrap penguin (Pygoscelis antarctica) Dream 55.8 \n", + "41 Gentoo penguin (Pygoscelis papua) Biscoe 49.8 \n", + "73 Gentoo penguin (Pygoscelis papua) Biscoe 46.8 \n", + "75 Gentoo penguin (Pygoscelis papua) Biscoe 49.6 \n", + "93 Adelie Penguin (Pygoscelis adeliae) Biscoe 35.5 \n", + "299 Chinstrap penguin (Pygoscelis antarctica) Dream 52.0 \n", "\n", " culmen_depth_mm flipper_length_mm body_mass_g sex \n", - "78 17.3 185 3700 FEMALE \n", - "130 17.9 187 3200 FEMALE \n", - "84 14.5 212 4625 FEMALE \n", - "334 20.0 190 3900 MALE \n", - "67 19.8 207 4000 MALE " + "41 16.8 230 5700 MALE \n", + "73 16.1 215 5500 MALE \n", + "75 16.0 225 5700 MALE \n", + "93 16.2 195 3350 FEMALE \n", + "299 18.1 201 4050 MALE " ] }, "execution_count": 15, @@ -936,18 +982,6 @@ "id": "oP1NIAmUBjop" }, "outputs": [ - { - "data": { - "text/html": [ - "Query job 55aa9cc4-29b6-4052-aae4-5499dc5f1168 is DONE. 28.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [ @@ -992,18 +1026,6 @@ "id": "IBuo-d6dWfsA" }, "outputs": [ - { - "data": { - "text/html": [ - "Query job 7b2ff811-1563-4ac4-9d21-69f87e8e85bc is DONE. 28.9 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/html": [ @@ -1036,73 +1058,73 @@ " \n", " \n", " \n", - " 12\n", + " 79\n", " Gentoo penguin (Pygoscelis papua)\n", " Biscoe\n", - " 42.7\n", - " 13.7\n", + " 43.3\n", + " 14.0\n", " 208\n", - " 3950\n", + " 4575\n", " FEMALE\n", " \n", " \n", - " 24\n", - " Gentoo penguin (Pygoscelis papua)\n", + " 118\n", + " Adelie Penguin (Pygoscelis adeliae)\n", " Biscoe\n", - " 45.0\n", - " 15.4\n", - " 220\n", - " 5050\n", + " 40.6\n", + " 18.6\n", + " 183\n", + " 3550\n", " MALE\n", " \n", " \n", - " 62\n", + " 213\n", " Adelie Penguin (Pygoscelis adeliae)\n", - " Dream\n", - " 38.8\n", - " 20.0\n", - " 190\n", - " 3950\n", + " Torgersen\n", + " 42.1\n", + " 19.1\n", + " 195\n", + " 4000\n", " MALE\n", " \n", " \n", - " 123\n", - " Chinstrap penguin (Pygoscelis antarctica)\n", - " Dream\n", - " 42.5\n", - " 17.3\n", - " 187\n", - " 3350\n", + " 315\n", + " Adelie Penguin (Pygoscelis adeliae)\n", + " Torgersen\n", + " 38.7\n", + " 19.0\n", + " 195\n", + " 3450\n", " FEMALE\n", " \n", " \n", - " 27\n", - " Adelie Penguin (Pygoscelis adeliae)\n", + " 338\n", + " Chinstrap penguin (Pygoscelis antarctica)\n", " Dream\n", - " 44.1\n", - " 19.7\n", - " 196\n", - " 4400\n", - " MALE\n", + " 40.9\n", + " 16.6\n", + " 187\n", + " 3200\n", + " FEMALE\n", " \n", " \n", "\n", "" ], "text/plain": [ - " species island culmen_length_mm \\\n", - "12 Gentoo penguin (Pygoscelis papua) Biscoe 42.7 \n", - "24 Gentoo penguin (Pygoscelis papua) Biscoe 45.0 \n", - "62 Adelie Penguin (Pygoscelis adeliae) Dream 38.8 \n", - "123 Chinstrap penguin (Pygoscelis antarctica) Dream 42.5 \n", - "27 Adelie Penguin (Pygoscelis adeliae) Dream 44.1 \n", + " species island culmen_length_mm \\\n", + "79 Gentoo penguin (Pygoscelis papua) Biscoe 43.3 \n", + "118 Adelie Penguin (Pygoscelis adeliae) Biscoe 40.6 \n", + "213 Adelie Penguin (Pygoscelis adeliae) Torgersen 42.1 \n", + "315 Adelie Penguin (Pygoscelis adeliae) Torgersen 38.7 \n", + "338 Chinstrap penguin (Pygoscelis antarctica) Dream 40.9 \n", "\n", " culmen_depth_mm flipper_length_mm body_mass_g sex \n", - "12 13.7 208 3950 FEMALE \n", - "24 15.4 220 5050 MALE \n", - "62 20.0 190 3950 MALE \n", - "123 17.3 187 3350 FEMALE \n", - "27 19.7 196 4400 MALE " + "79 14.0 208 4575 FEMALE \n", + "118 18.6 183 3550 MALE \n", + "213 19.1 195 4000 MALE \n", + "315 19.0 195 3450 FEMALE \n", + "338 16.6 187 3200 FEMALE " ] }, "execution_count": 18, @@ -1152,18 +1174,6 @@ "id": "6i6HkFJZa8na" }, "outputs": [ - { - "data": { - "text/html": [ - "Query job b396baed-6242-4478-9092-f5e86811b045 is DONE. 31.7 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/plain": [ @@ -1171,10 +1181,10 @@ "279 3150\n", "34 3400\n", "96 3600\n", - "18 3800\n", "208 3950\n", - "310 3175\n", + "18 3800\n", "64 2850\n", + "310 3175\n", "118 3550\n", "2 3075\n", "Name: body_mass_g, dtype: Int64" @@ -1209,7 +1219,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "average_body_mass: 4201.754385964913\n" + "average_body_mass: 4201.754385964914\n" ] } ], @@ -1234,18 +1244,6 @@ "id": "4PyKMR61-Mjy" }, "outputs": [ - { - "data": { - "text/html": [ - "Query job fef05ee2-9690-41a4-bd35-7cded77310f2 is DONE. 15.6 kB processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "text/html": [ @@ -1366,20 +1364,7 @@ "metadata": { "id": "rSWTOG-vb2Fc" }, - "outputs": [ - { - "data": { - "text/html": [ - "Query job c7b6c009-d2c4-4739-a6f8-5ef51e6b1851 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "@bpd.remote_function(cloud_function_service_account=\"default\")\n", "def get_bucket(num: float) -> str:\n", @@ -1410,8 +1395,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "Cloud Function Name projects/bigframes-dev/locations/us-central1/functions/bigframes-sessiondf1983-1d02aa9bc80939ba72e7ff69e37e27c8\n", - "Remote Function Name bigframes-dev._f36a8f778c434a1ec421979eaa3bf562a8561e38.bigframes_sessiondf1983_1d02aa9bc80939ba72e7ff69e37e27c8\n" + "Cloud Function Name projects/bigframes-dev/locations/us-central1/functions/bigframes-sessioncf7a5d-aa59468b9d6c757c1256e46c9f71ebe3\n", + "Remote Function Name bigframes-dev._63cfa399614a54153cc386c27d6c0c6fdb249f9e.bigframes_sessioncf7a5d_aa59468b9d6c757c1256e46c9f71ebe3\n" ] } ], @@ -1485,19 +1470,14 @@ " at_or_above_3500\n", " \n", " \n", - " 18\n", - " 3800\n", - " at_or_above_3500\n", - " \n", - " \n", " 208\n", " 3950\n", " at_or_above_3500\n", " \n", " \n", - " 310\n", - " 3175\n", - " below_3500\n", + " 18\n", + " 3800\n", + " at_or_above_3500\n", " \n", " \n", " 64\n", @@ -1505,6 +1485,11 @@ " below_3500\n", " \n", " \n", + " 310\n", + " 3175\n", + " below_3500\n", + " \n", + " \n", " 118\n", " 3550\n", " at_or_above_3500\n", @@ -1524,10 +1509,10 @@ "279 3150 below_3500\n", "34 3400 below_3500\n", "96 3600 at_or_above_3500\n", - "18 3800 at_or_above_3500\n", "208 3950 at_or_above_3500\n", - "310 3175 below_3500\n", + "18 3800 at_or_above_3500\n", "64 2850 below_3500\n", + "310 3175 below_3500\n", "118 3550 at_or_above_3500\n", "2 3075 below_3500" ] @@ -1658,7 +1643,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.15" + "version": "3.10.16" } }, "nbformat": 4, From 095c0b85a25a2e51087880909597cc62a0341c93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Wed, 8 Oct 2025 11:01:07 -0500 Subject: [PATCH 132/313] fix: avoid possible circular imports in global session (#2115) --- bigframes/core/global_session.py | 16 ++++++++++--- .../pandas/_config/config.py | 24 ++++++++++++------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/bigframes/core/global_session.py b/bigframes/core/global_session.py index 4698e4c4c5..b055bdb854 100644 --- a/bigframes/core/global_session.py +++ b/bigframes/core/global_session.py @@ -14,16 +14,19 @@ """Utilities for managing a default, globally available Session object.""" +from __future__ import annotations + import threading import traceback -from typing import Callable, Optional, TypeVar +from typing import Callable, Optional, TYPE_CHECKING, TypeVar import warnings import google.auth.exceptions -import bigframes._config import bigframes.exceptions as bfe -import bigframes.session + +if TYPE_CHECKING: + import bigframes.session _global_session: Optional[bigframes.session.Session] = None _global_session_lock = threading.Lock() @@ -56,6 +59,9 @@ def close_session() -> None: Returns: None """ + # Avoid troubles with circular imports. + import bigframes._config + global _global_session, _global_session_lock, _global_session_state if bigframes._config.options.is_bigquery_thread_local: @@ -88,6 +94,10 @@ def get_global_session(): Creates the global session if it does not exist. """ + # Avoid troubles with circular imports. + import bigframes._config + import bigframes.session + global _global_session, _global_session_lock, _global_session_state if bigframes._config.options.is_bigquery_thread_local: diff --git a/third_party/bigframes_vendored/pandas/_config/config.py b/third_party/bigframes_vendored/pandas/_config/config.py index 13ccfdac89..418f5868e5 100644 --- a/third_party/bigframes_vendored/pandas/_config/config.py +++ b/third_party/bigframes_vendored/pandas/_config/config.py @@ -2,8 +2,6 @@ import contextlib import operator -import bigframes - class option_context(contextlib.ContextDecorator): """ @@ -35,8 +33,11 @@ def __init__(self, *args) -> None: self.ops = list(zip(args[::2], args[1::2])) def __enter__(self) -> None: + # Avoid problems with circular imports. + import bigframes._config + self.undo = [ - (pat, operator.attrgetter(pat)(bigframes.options)) + (pat, operator.attrgetter(pat)(bigframes._config.options)) for pat, _ in self.ops # Don't try to undo changes to bigquery options. We're starting and # closing a new thread-local session if those are set. @@ -47,6 +48,10 @@ def __enter__(self) -> None: self._set_option(pat, val) def __exit__(self, *args) -> None: + # Avoid problems with circular imports. + import bigframes._config + import bigframes.core.global_session + if self.undo: for pat, val in self.undo: self._set_option(pat, val) @@ -54,18 +59,21 @@ def __exit__(self, *args) -> None: # TODO(tswast): What to do if someone nests several context managers # with separate "bigquery" options? We might need a "stack" of # sessions if we allow that. - if bigframes.options.is_bigquery_thread_local: - bigframes.close_session() + if bigframes._config.options.is_bigquery_thread_local: + bigframes.core.global_session.close_session() # Reset bigquery_options so that we're no longer thread-local. - bigframes.options._local.bigquery_options = None + bigframes._config.options._local.bigquery_options = None def _set_option(self, pat, val): + # Avoid problems with circular imports. + import bigframes._config + root, attr = pat.rsplit(".", 1) # We are now using a thread-specific session. if root == "bigquery": - bigframes.options._init_bigquery_thread_local() + bigframes._config.options._init_bigquery_thread_local() - parent = operator.attrgetter(root)(bigframes.options) + parent = operator.attrgetter(root)(bigframes._config.options) setattr(parent, attr, val) From 1f48d3a62e7e6dac4acb39e911daf766b8e2fe62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Wed, 8 Oct 2025 11:04:17 -0500 Subject: [PATCH 133/313] fix: show progress even in job optional queries (#2119) * fix: show progress even in job optional queries * first attempt at publisher * report execution started/stopped in read_gbq_query * render bigquery sent events * Feat render more events (#2121) * feat: Render more BigQuery events in progress bar This change updates bigframes/formatting_helpers.py to render more event types from bigframes/core/events.py. Specifically, it adds rendering support for: - BigQueryRetryEvent - BigQueryReceivedEvent - BigQueryFinishedEvent - BigQueryUnknownEvent This provides users with more detailed feedback during query execution in both notebook (HTML) and terminal (plaintext) environments. * feat: Render more BigQuery events in progress bar This change updates bigframes/formatting_helpers.py to render more event types from bigframes/core/events.py. Specifically, it adds rendering support for: - BigQueryRetryEvent - BigQueryReceivedEvent - BigQueryFinishedEvent - BigQueryUnknownEvent This provides users with more detailed feedback during query execution in both notebook (HTML) and terminal (plaintext) environments. Unit tests have been added to verify the rendering of each new event type. --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> * fix job links * fix system tests * fix mypy * fix unit tests * support more event types * move publisher to session * fix remaining mypy errors * update text * add explicit unsubscribe * fix presubmits * add lock for publisher and publish temp table creations --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- bigframes/blob/_functions.py | 1 + bigframes/core/events.py | 237 +++++++++ bigframes/dataframe.py | 82 ++-- bigframes/formatting_helpers.py | 453 +++++++++++++----- bigframes/functions/_function_client.py | 1 + bigframes/functions/function.py | 16 +- bigframes/pandas/__init__.py | 1 + bigframes/session/__init__.py | 27 +- bigframes/session/_io/bigquery/__init__.py | 84 +++- .../session/_io/bigquery/read_gbq_table.py | 48 +- bigframes/session/anonymous_dataset.py | 6 +- bigframes/session/bigquery_session.py | 61 ++- bigframes/session/bq_caching_executor.py | 28 +- bigframes/session/direct_gbq_execution.py | 9 +- bigframes/session/loader.py | 69 ++- bigframes/testing/mocks.py | 1 + setup.py | 2 +- testing/constraints-3.9.txt | 2 +- tests/system/small/engines/conftest.py | 9 +- .../system/small/engines/test_aggregation.py | 16 +- tests/system/small/engines/test_windowing.py | 8 +- .../small/functions/test_remote_function.py | 4 + tests/system/small/test_bq_sessions.py | 10 +- tests/system/small/test_progress_bar.py | 36 +- tests/unit/session/test_io_bigquery.py | 2 + tests/unit/session/test_read_gbq_table.py | 68 ++- tests/unit/session/test_session.py | 15 +- tests/unit/test_formatting_helpers.py | 129 ++++- 28 files changed, 1155 insertions(+), 270 deletions(-) create mode 100644 bigframes/core/events.py diff --git a/bigframes/blob/_functions.py b/bigframes/blob/_functions.py index 8d1ca38e62..8dd9328fb8 100644 --- a/bigframes/blob/_functions.py +++ b/bigframes/blob/_functions.py @@ -99,6 +99,7 @@ def _create_udf(self): project=None, timeout=None, query_with_job=True, + publisher=self._session._publisher, ) return udf_name diff --git a/bigframes/core/events.py b/bigframes/core/events.py new file mode 100644 index 0000000000..d0e5f7ad69 --- /dev/null +++ b/bigframes/core/events.py @@ -0,0 +1,237 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import dataclasses +import datetime +import threading +from typing import Any, Callable, Optional, Set +import uuid + +import google.cloud.bigquery._job_helpers +import google.cloud.bigquery.job.query +import google.cloud.bigquery.table + +import bigframes.session.executor + + +class Subscriber: + def __init__(self, callback: Callable[[Event], None], *, publisher: Publisher): + self._publisher = publisher + self._callback = callback + self._subscriber_id = uuid.uuid4() + + def __call__(self, *args, **kwargs): + return self._callback(*args, **kwargs) + + def __hash__(self) -> int: + return hash(self._subscriber_id) + + def __eq__(self, value: object): + if not isinstance(value, Subscriber): + return NotImplemented + return value._subscriber_id == self._subscriber_id + + def close(self): + self._publisher.unsubscribe(self) + del self._publisher + del self._callback + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + if exc_value is not None: + self( + UnknownErrorEvent( + exc_type=exc_type, + exc_value=exc_value, + traceback=traceback, + ) + ) + self.close() + + +class Publisher: + def __init__(self): + self._subscribers_lock = threading.Lock() + self._subscribers: Set[Subscriber] = set() + + def subscribe(self, callback: Callable[[Event], None]) -> Subscriber: + # TODO(b/448176657): figure out how to handle subscribers/publishers in + # a background thread. Maybe subscribers should be thread-local? + subscriber = Subscriber(callback, publisher=self) + with self._subscribers_lock: + self._subscribers.add(subscriber) + return subscriber + + def unsubscribe(self, subscriber: Subscriber): + with self._subscribers_lock: + self._subscribers.remove(subscriber) + + def publish(self, event: Event): + with self._subscribers_lock: + for subscriber in self._subscribers: + subscriber(event) + + +class Event: + pass + + +@dataclasses.dataclass(frozen=True) +class SessionClosed(Event): + session_id: str + + +class ExecutionStarted(Event): + pass + + +class ExecutionRunning(Event): + pass + + +@dataclasses.dataclass(frozen=True) +class ExecutionFinished(Event): + result: Optional[bigframes.session.executor.ExecuteResult] = None + + +@dataclasses.dataclass(frozen=True) +class UnknownErrorEvent(Event): + exc_type: Any + exc_value: Any + traceback: Any + + +@dataclasses.dataclass(frozen=True) +class BigQuerySentEvent(ExecutionRunning): + """Query sent to BigQuery.""" + + query: str + billing_project: Optional[str] = None + location: Optional[str] = None + job_id: Optional[str] = None + request_id: Optional[str] = None + + @classmethod + def from_bqclient(cls, event: google.cloud.bigquery._job_helpers.QuerySentEvent): + return cls( + query=event.query, + billing_project=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=event.request_id, + ) + + +@dataclasses.dataclass(frozen=True) +class BigQueryRetryEvent(ExecutionRunning): + """Query sent another time because the previous attempt failed.""" + + query: str + billing_project: Optional[str] = None + location: Optional[str] = None + job_id: Optional[str] = None + request_id: Optional[str] = None + + @classmethod + def from_bqclient(cls, event: google.cloud.bigquery._job_helpers.QueryRetryEvent): + return cls( + query=event.query, + billing_project=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=event.request_id, + ) + + +@dataclasses.dataclass(frozen=True) +class BigQueryReceivedEvent(ExecutionRunning): + """Query received and acknowledged by the BigQuery API.""" + + billing_project: Optional[str] = None + location: Optional[str] = None + job_id: Optional[str] = None + statement_type: Optional[str] = None + state: Optional[str] = None + query_plan: Optional[list[google.cloud.bigquery.job.query.QueryPlanEntry]] = None + created: Optional[datetime.datetime] = None + started: Optional[datetime.datetime] = None + ended: Optional[datetime.datetime] = None + + @classmethod + def from_bqclient( + cls, event: google.cloud.bigquery._job_helpers.QueryReceivedEvent + ): + return cls( + billing_project=event.billing_project, + location=event.location, + job_id=event.job_id, + statement_type=event.statement_type, + state=event.state, + query_plan=event.query_plan, + created=event.created, + started=event.started, + ended=event.ended, + ) + + +@dataclasses.dataclass(frozen=True) +class BigQueryFinishedEvent(ExecutionRunning): + """Query finished successfully.""" + + billing_project: Optional[str] = None + location: Optional[str] = None + query_id: Optional[str] = None + job_id: Optional[str] = None + destination: Optional[google.cloud.bigquery.table.TableReference] = None + total_rows: Optional[int] = None + total_bytes_processed: Optional[int] = None + slot_millis: Optional[int] = None + created: Optional[datetime.datetime] = None + started: Optional[datetime.datetime] = None + ended: Optional[datetime.datetime] = None + + @classmethod + def from_bqclient( + cls, event: google.cloud.bigquery._job_helpers.QueryFinishedEvent + ): + return cls( + billing_project=event.billing_project, + location=event.location, + query_id=event.query_id, + job_id=event.job_id, + destination=event.destination, + total_rows=event.total_rows, + total_bytes_processed=event.total_bytes_processed, + slot_millis=event.slot_millis, + created=event.created, + started=event.started, + ended=event.ended, + ) + + +@dataclasses.dataclass(frozen=True) +class BigQueryUnknownEvent(ExecutionRunning): + """Got unknown event from the BigQuery client library.""" + + # TODO: should we just skip sending unknown events? + + event: object + + @classmethod + def from_bqclient(cls, event): + return cls(event) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 1bde29506d..bc2bbb963b 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -4670,24 +4670,24 @@ def to_string( ) -> str | None: return self.to_pandas(allow_large_results=allow_large_results).to_string( buf, - columns, # type: ignore - col_space, - header, # type: ignore - index, - na_rep, - formatters, - float_format, - sparsify, - index_names, - justify, - max_rows, - max_cols, - show_dimensions, - decimal, - line_width, - min_rows, - max_colwidth, - encoding, + columns=columns, # type: ignore + col_space=col_space, + header=header, # type: ignore + index=index, + na_rep=na_rep, + formatters=formatters, + float_format=float_format, + sparsify=sparsify, + index_names=index_names, + justify=justify, + max_rows=max_rows, + max_cols=max_cols, + show_dimensions=show_dimensions, + decimal=decimal, + line_width=line_width, + min_rows=min_rows, + max_colwidth=max_colwidth, + encoding=encoding, ) def to_html( @@ -4720,28 +4720,28 @@ def to_html( ) -> str: return self.to_pandas(allow_large_results=allow_large_results).to_html( buf, - columns, # type: ignore - col_space, - header, - index, - na_rep, - formatters, - float_format, - sparsify, - index_names, - justify, # type: ignore - max_rows, - max_cols, - show_dimensions, - decimal, - bold_rows, - classes, - escape, - notebook, - border, - table_id, - render_links, - encoding, + columns=columns, # type: ignore + col_space=col_space, + header=header, + index=index, + na_rep=na_rep, + formatters=formatters, + float_format=float_format, + sparsify=sparsify, + index_names=index_names, + justify=justify, # type: ignore + max_rows=max_rows, + max_cols=max_cols, + show_dimensions=show_dimensions, + decimal=decimal, + bold_rows=bold_rows, + classes=classes, + escape=escape, + notebook=notebook, + border=border, + table_id=table_id, + render_links=render_links, + encoding=encoding, ) def to_markdown( @@ -4753,7 +4753,7 @@ def to_markdown( allow_large_results: Optional[bool] = None, **kwargs, ) -> str | None: - return self.to_pandas(allow_large_results=allow_large_results).to_markdown(buf, mode, index, **kwargs) # type: ignore + return self.to_pandas(allow_large_results=allow_large_results).to_markdown(buf, mode=mode, index=index, **kwargs) # type: ignore def to_pickle(self, path, *, allow_large_results=None, **kwargs) -> None: return self.to_pandas(allow_large_results=allow_large_results).to_pickle( diff --git a/bigframes/formatting_helpers.py b/bigframes/formatting_helpers.py index 48afb4fdbd..f75394c47d 100644 --- a/bigframes/formatting_helpers.py +++ b/bigframes/formatting_helpers.py @@ -13,11 +13,13 @@ # limitations under the License. """Shared helper functions for formatting jobs related info.""" -# TODO(orrbradford): cleanup up typings and documenttion in this file + +from __future__ import annotations import datetime +import html import random -from typing import Any, Optional, Type, Union +from typing import Any, Optional, Type, TYPE_CHECKING, Union import bigframes_vendored.constants as constants import google.api_core.exceptions as api_core_exceptions @@ -25,7 +27,9 @@ import humanize import IPython import IPython.display as display -import ipywidgets as widgets + +if TYPE_CHECKING: + import bigframes.core.events GenericJob = Union[ bigquery.LoadJob, bigquery.ExtractJob, bigquery.QueryJob, bigquery.CopyJob @@ -58,39 +62,6 @@ def create_exception_with_feedback_link( return exception(constants.FEEDBACK_LINK) -def repr_query_job_html(query_job: Optional[bigquery.QueryJob]): - """Return query job in html format. - Args: - query_job (bigquery.QueryJob, Optional): - The job representing the execution of the query on the server. - Returns: - Pywidget html table. - """ - if query_job is None: - return display.HTML("No job information available") - if query_job.dry_run: - return display.HTML( - f"Computation deferred. Computation will process {get_formatted_bytes(query_job.total_bytes_processed)}" - ) - table_html = "" - table_html += "" - for key, value in query_job_prop_pairs.items(): - job_val = getattr(query_job, value) - if job_val is not None: - if key == "Job Id": # add link to job - table_html += f"""""" - elif key == "Slot Time": - table_html += ( - f"""""" - ) - elif key == "Bytes Processed": - table_html += f"""""" - else: - table_html += f"""""" - table_html += "
{key}{job_val}
{key}{get_formatted_time(job_val)}
{key}{get_formatted_bytes(job_val)}
{key}{job_val}
" - return widgets.HTML(table_html) - - def repr_query_job(query_job: Optional[bigquery.QueryJob]): """Return query job as a formatted string. Args: @@ -109,7 +80,11 @@ def repr_query_job(query_job: Optional[bigquery.QueryJob]): if job_val is not None: res += "\n" if key == "Job Id": # add link to job - res += f"""Job url: {get_job_url(query_job)}""" + res += f"""Job url: {get_job_url( + project_id=query_job.project, + location=query_job.location, + job_id=query_job.job_id, + )}""" elif key == "Slot Time": res += f"""{key}: {get_formatted_time(job_val)}""" elif key == "Bytes Processed": @@ -119,71 +94,90 @@ def repr_query_job(query_job: Optional[bigquery.QueryJob]): return res -def wait_for_query_job( - query_job: bigquery.QueryJob, - max_results: Optional[int] = None, - page_size: Optional[int] = None, - progress_bar: Optional[str] = None, -) -> bigquery.table.RowIterator: - """Return query results. Displays a progress bar while the query is running - Args: - query_job (bigquery.QueryJob, Optional): - The job representing the execution of the query on the server. - max_results (int, Optional): - The maximum number of rows the row iterator should return. - page_size (int, Optional): - The number of results to return on each results page. - progress_bar (str, Optional): - Which progress bar to show. - Returns: - A row iterator over the query results. - """ +current_display: Optional[display.HTML] = None +current_display_id: Optional[str] = None +previous_display_html: str = "" + + +def progress_callback( + event: bigframes.core.events.Event, +): + """Displays a progress bar while the query is running""" + global current_display, current_display_id, previous_display_html + + import bigframes._config + import bigframes.core.events + + progress_bar = bigframes._config.options.display.progress_bar + if progress_bar == "auto": progress_bar = "notebook" if in_ipython() else "terminal" - try: - if progress_bar == "notebook": - display_id = str(random.random()) - loading_bar = display.HTML(get_query_job_loading_html(query_job)) - display.display(loading_bar, display_id=display_id) - query_result = query_job.result( - max_results=max_results, page_size=page_size + if progress_bar == "notebook": + if ( + isinstance(event, bigframes.core.events.ExecutionStarted) + or current_display is None + or current_display_id is None + ): + previous_display_html = "" + current_display_id = str(random.random()) + current_display = display.HTML("Starting.") + display.display( + current_display, + display_id=current_display_id, ) - query_job.reload() + + if isinstance(event, bigframes.core.events.BigQuerySentEvent): + previous_display_html = render_bqquery_sent_event_html(event) display.update_display( - display.HTML(get_query_job_loading_html(query_job)), - display_id=display_id, + display.HTML(previous_display_html), + display_id=current_display_id, ) - elif progress_bar == "terminal": - initial_loading_bar = get_query_job_loading_string(query_job) - print(initial_loading_bar) - query_result = query_job.result( - max_results=max_results, page_size=page_size + elif isinstance(event, bigframes.core.events.BigQueryRetryEvent): + previous_display_html = render_bqquery_retry_event_html(event) + display.update_display( + display.HTML(previous_display_html), + display_id=current_display_id, ) - query_job.reload() - if initial_loading_bar != get_query_job_loading_string(query_job): - print(get_query_job_loading_string(query_job)) - else: - # No progress bar. - query_result = query_job.result( - max_results=max_results, page_size=page_size + elif isinstance(event, bigframes.core.events.BigQueryReceivedEvent): + previous_display_html = render_bqquery_received_event_html(event) + display.update_display( + display.HTML(previous_display_html), + display_id=current_display_id, ) - query_job.reload() - return query_result - except api_core_exceptions.RetryError as exc: - add_feedback_link(exc) - raise - except api_core_exceptions.GoogleAPICallError as exc: - add_feedback_link(exc) - raise - except KeyboardInterrupt: - query_job.cancel() - print( - f"Requested cancellation for {query_job.job_type.capitalize()}" - f" job {query_job.job_id} in location {query_job.location}..." - ) - # begin the cancel request before immediately rethrowing - raise + elif isinstance(event, bigframes.core.events.BigQueryFinishedEvent): + previous_display_html = render_bqquery_finished_event_html(event) + display.update_display( + display.HTML(previous_display_html), + display_id=current_display_id, + ) + elif isinstance(event, bigframes.core.events.ExecutionFinished): + display.update_display( + display.HTML(f"✅ Completed. {previous_display_html}"), + display_id=current_display_id, + ) + elif isinstance(event, bigframes.core.events.SessionClosed): + display.update_display( + display.HTML(f"Session {event.session_id} closed."), + display_id=current_display_id, + ) + elif progress_bar == "terminal": + if isinstance(event, bigframes.core.events.ExecutionStarted): + print("Starting execution.") + elif isinstance(event, bigframes.core.events.BigQuerySentEvent): + message = render_bqquery_sent_event_plaintext(event) + print(message) + elif isinstance(event, bigframes.core.events.BigQueryRetryEvent): + message = render_bqquery_retry_event_plaintext(event) + print(message) + elif isinstance(event, bigframes.core.events.BigQueryReceivedEvent): + message = render_bqquery_received_event_plaintext(event) + print(message) + elif isinstance(event, bigframes.core.events.BigQueryFinishedEvent): + message = render_bqquery_finished_event_plaintext(event) + print(message) + elif isinstance(event, bigframes.core.events.ExecutionFinished): + print("Execution done.") def wait_for_job(job: GenericJob, progress_bar: Optional[str] = None): @@ -234,24 +228,74 @@ def wait_for_job(job: GenericJob, progress_bar: Optional[str] = None): raise -def get_job_url(query_job: GenericJob): +def render_query_references( + *, + project_id: Optional[str], + location: Optional[str], + job_id: Optional[str], + request_id: Optional[str], +) -> str: + query_id = "" + if request_id and not job_id: + query_id = f" with request ID {project_id}:{location}.{request_id}" + return query_id + + +def render_job_link_html( + *, + project_id: Optional[str], + location: Optional[str], + job_id: Optional[str], +) -> str: + job_url = get_job_url( + project_id=project_id, + location=location, + job_id=job_id, + ) + if job_url: + job_link = f' [Job {project_id}:{location}.{job_id} details]' + else: + job_link = "" + return job_link + + +def render_job_link_plaintext( + *, + project_id: Optional[str], + location: Optional[str], + job_id: Optional[str], +) -> str: + job_url = get_job_url( + project_id=project_id, + location=location, + job_id=job_id, + ) + if job_url: + job_link = f" Job {project_id}:{location}.{job_id} details: {job_url}" + else: + job_link = "" + return job_link + + +def get_job_url( + *, + project_id: Optional[str], + location: Optional[str], + job_id: Optional[str], +): """Return url to the query job in cloud console. - Args: - query_job (GenericJob): - The job representing the execution of the query on the server. + Returns: String url. """ - if ( - query_job.project is None - or query_job.location is None - or query_job.job_id is None - ): + if project_id is None or location is None or job_id is None: return None - return f"""https://console.cloud.google.com/bigquery?project={query_job.project}&j=bq:{query_job.location}:{query_job.job_id}&page=queryresults""" + return f"""https://console.cloud.google.com/bigquery?project={project_id}&j=bq:{location}:{job_id}&page=queryresults""" -def get_query_job_loading_html(query_job: bigquery.QueryJob): +def render_bqquery_sent_event_html( + event: bigframes.core.events.BigQuerySentEvent, +) -> str: """Return progress bar html string Args: query_job (bigquery.QueryJob): @@ -259,18 +303,195 @@ def get_query_job_loading_html(query_job: bigquery.QueryJob): Returns: Html string. """ - return f"""Query job {query_job.job_id} is {query_job.state}. {get_bytes_processed_string(query_job.total_bytes_processed)}Open Job""" + job_link = render_job_link_html( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + ) + query_id = render_query_references( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=event.request_id, + ) + query_text_details = f"
SQL
{html.escape(event.query)}
" + + return f""" + Query started{query_id}.{job_link}{query_text_details} + """ -def get_query_job_loading_string(query_job: bigquery.QueryJob): - """Return progress bar string + +def render_bqquery_sent_event_plaintext( + event: bigframes.core.events.BigQuerySentEvent, +) -> str: + """Return progress bar html string Args: query_job (bigquery.QueryJob): The job representing the execution of the query on the server. Returns: - String + Html string. + """ + + job_link = render_job_link_plaintext( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + ) + query_id = render_query_references( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=event.request_id, + ) + + return f"Query started{query_id}.{job_link}" + + +def render_bqquery_retry_event_html( + event: bigframes.core.events.BigQueryRetryEvent, +) -> str: + """Return progress bar html string for retry event.""" + + job_link = render_job_link_html( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + ) + query_id = render_query_references( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=event.request_id, + ) + query_text_details = f"
SQL
{html.escape(event.query)}
" + + return f""" + Retrying query{query_id}.{job_link}{query_text_details} + """ + + +def render_bqquery_retry_event_plaintext( + event: bigframes.core.events.BigQueryRetryEvent, +) -> str: + """Return progress bar plaintext string for retry event.""" + + job_link = render_job_link_plaintext( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + ) + query_id = render_query_references( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=event.request_id, + ) + return f"Retrying query{query_id}.{job_link}" + + +def render_bqquery_received_event_html( + event: bigframes.core.events.BigQueryReceivedEvent, +) -> str: + """Return progress bar html string for received event.""" + + job_link = render_job_link_html( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + ) + query_id = render_query_references( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=None, + ) + + query_plan_details = "" + if event.query_plan: + plan_str = "\n".join([str(entry) for entry in event.query_plan]) + query_plan_details = f"
Query Plan
{html.escape(plan_str)}
" + + return f""" + Query{query_id} is {event.state}.{job_link}{query_plan_details} + """ + + +def render_bqquery_received_event_plaintext( + event: bigframes.core.events.BigQueryReceivedEvent, +) -> str: + """Return progress bar plaintext string for received event.""" + + job_link = render_job_link_plaintext( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + ) + query_id = render_query_references( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=None, + ) + return f"Query{query_id} is {event.state}.{job_link}" + + +def render_bqquery_finished_event_html( + event: bigframes.core.events.BigQueryFinishedEvent, +) -> str: + """Return progress bar html string for finished event.""" + + bytes_str = "" + if event.total_bytes_processed is not None: + bytes_str = f" {humanize.naturalsize(event.total_bytes_processed)}" + + slot_time_str = "" + if event.slot_millis is not None: + slot_time = datetime.timedelta(milliseconds=event.slot_millis) + slot_time_str = f" in {humanize.naturaldelta(slot_time)} of slot time" + + job_link = render_job_link_html( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + ) + query_id = render_query_references( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=None, + ) + return f""" + Query processed{bytes_str}{slot_time_str}{query_id}.{job_link} """ - return f"""Query job {query_job.job_id} is {query_job.state}.{get_bytes_processed_string(query_job.total_bytes_processed)} \n{get_job_url(query_job)}""" + + +def render_bqquery_finished_event_plaintext( + event: bigframes.core.events.BigQueryFinishedEvent, +) -> str: + """Return progress bar plaintext string for finished event.""" + + bytes_str = "" + if event.total_bytes_processed is not None: + bytes_str = f" {humanize.naturalsize(event.total_bytes_processed)} processed." + + slot_time_str = "" + if event.slot_millis is not None: + slot_time = datetime.timedelta(milliseconds=event.slot_millis) + slot_time_str = f" Slot time: {humanize.naturaldelta(slot_time)}." + + job_link = render_job_link_plaintext( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + ) + query_id = render_query_references( + project_id=event.billing_project, + location=event.location, + job_id=event.job_id, + request_id=None, + ) + return f"Query{query_id} finished.{bytes_str}{slot_time_str}{job_link}" def get_base_job_loading_html(job: GenericJob): @@ -281,7 +502,11 @@ def get_base_job_loading_html(job: GenericJob): Returns: Html string. """ - return f"""{job.job_type.capitalize()} job {job.job_id} is {job.state}. Open Job""" + return f"""{job.job_type.capitalize()} job {job.job_id} is {job.state}. Open Job""" def get_base_job_loading_string(job: GenericJob): @@ -292,7 +517,11 @@ def get_base_job_loading_string(job: GenericJob): Returns: String """ - return f"""{job.job_type.capitalize()} job {job.job_id} is {job.state}. \n{get_job_url(job)}""" + return f"""{job.job_type.capitalize()} job {job.job_id} is {job.state}. \n{get_job_url( + project_id=job.job_id, + location=job.location, + job_id=job.job_id, + )}""" def get_formatted_time(val): diff --git a/bigframes/functions/_function_client.py b/bigframes/functions/_function_client.py index 641bf52dc9..8a88a14040 100644 --- a/bigframes/functions/_function_client.py +++ b/bigframes/functions/_function_client.py @@ -145,6 +145,7 @@ def _create_bq_function(self, create_function_ddl: str) -> None: timeout=None, metrics=None, query_with_job=True, + publisher=self._session._publisher, ) logger.info(f"Created bigframes function {query_job.ddl_target_routine}") diff --git a/bigframes/functions/function.py b/bigframes/functions/function.py index 99b89131e7..242daf7525 100644 --- a/bigframes/functions/function.py +++ b/bigframes/functions/function.py @@ -219,7 +219,13 @@ def __call__(self, *args, **kwargs): args_string = ", ".join(map(bf_sql.simple_literal, args)) sql = f"SELECT `{str(self._udf_def.routine_ref)}`({args_string})" - iter, job = bf_io_bigquery.start_query_with_client(self._session.bqclient, sql=sql, query_with_job=True, job_config=bigquery.QueryJobConfig()) # type: ignore + iter, job = bf_io_bigquery.start_query_with_client( + self._session.bqclient, + sql=sql, + query_with_job=True, + job_config=bigquery.QueryJobConfig(), + publisher=self._session._publisher, + ) # type: ignore return list(iter.to_arrow().to_pydict().values())[0][0] @property @@ -297,7 +303,13 @@ def __call__(self, *args, **kwargs): args_string = ", ".join(map(bf_sql.simple_literal, args)) sql = f"SELECT `{str(self._udf_def.routine_ref)}`({args_string})" - iter, job = bf_io_bigquery.start_query_with_client(self._session.bqclient, sql=sql, query_with_job=True, job_config=bigquery.QueryJobConfig()) # type: ignore + iter, job = bf_io_bigquery.start_query_with_client( + self._session.bqclient, + sql=sql, + query_with_job=True, + job_config=bigquery.QueryJobConfig(), + publisher=self._session._publisher, + ) # type: ignore return list(iter.to_arrow().to_pydict().values())[0][0] @property diff --git a/bigframes/pandas/__init__.py b/bigframes/pandas/__init__.py index 2ea10132bc..2455637b0a 100644 --- a/bigframes/pandas/__init__.py +++ b/bigframes/pandas/__init__.py @@ -291,6 +291,7 @@ def clean_up_by_session_id( session.bqclient, location=location, project=project, + publisher=session._publisher, ) bigframes.session._io.bigquery.delete_tables_matching_session_id( diff --git a/bigframes/session/__init__.py b/bigframes/session/__init__.py index df0afb4c8d..46fb56b88e 100644 --- a/bigframes/session/__init__.py +++ b/bigframes/session/__init__.py @@ -67,18 +67,14 @@ import bigframes.constants import bigframes.core from bigframes.core import blocks, log_adapter, utils +import bigframes.core.events import bigframes.core.pyformat - -# Even though the ibis.backends.bigquery import is unused, it's needed -# to register new and replacement ops with the Ibis BigQuery backend. +import bigframes.formatting_helpers import bigframes.functions._function_session as bff_session import bigframes.functions.function as bff from bigframes.session import bigquery_session, bq_caching_executor, executor import bigframes.session._io.bigquery as bf_io_bigquery -import bigframes.session.anonymous_dataset import bigframes.session.clients -import bigframes.session.loader -import bigframes.session.metrics import bigframes.session.validation # Avoid circular imports. @@ -140,6 +136,11 @@ def __init__( _warn_if_bf_version_is_obsolete() + # Publisher needs to be created before the other objects, especially + # the executors, because they access it. + self._publisher = bigframes.core.events.Publisher() + self._publisher.subscribe(bigframes.formatting_helpers.progress_callback) + if context is None: context = bigquery_options.BigQueryOptions() @@ -232,12 +233,14 @@ def __init__( location=self._location, session_id=self._session_id, kms_key=self._bq_kms_key_name, + publisher=self._publisher, ) # Session temp tables don't support specifying kms key, so use anon dataset if kms key specified self._session_resource_manager = ( bigquery_session.SessionResourceManager( self.bqclient, self._location, + publisher=self._publisher, ) if (self._bq_kms_key_name is None) else None @@ -254,6 +257,7 @@ def __init__( scan_index_uniqueness=self._strictly_ordered, force_total_order=self._strictly_ordered, metrics=self._metrics, + publisher=self._publisher, ) self._executor: executor.Executor = bq_caching_executor.BigQueryCachingExecutor( bqclient=self._clients_provider.bqclient, @@ -263,6 +267,7 @@ def __init__( strictly_ordered=self._strictly_ordered, metrics=self._metrics, enable_polars_execution=context.enable_polars_execution, + publisher=self._publisher, ) def __del__(self): @@ -373,10 +378,16 @@ def close(self): remote_function_session = getattr(self, "_function_session", None) if remote_function_session: - self._function_session.clean_up( + remote_function_session.clean_up( self.bqclient, self.cloudfunctionsclient, self.session_id ) + publisher_session = getattr(self, "_publisher", None) + if publisher_session: + publisher_session.publish( + bigframes.core.events.SessionClosed(self.session_id) + ) + @overload def read_gbq( # type: ignore[overload-overlap] self, @@ -2154,6 +2165,7 @@ def _start_query_ml_ddl( timeout=None, query_with_job=True, job_retry=third_party_gcb_retry.DEFAULT_ML_JOB_RETRY, + publisher=self._publisher, ) return iterator, query_job @@ -2181,6 +2193,7 @@ def _create_object_table(self, path: str, connection: str) -> str: project=None, timeout=None, query_with_job=True, + publisher=self._publisher, ) return table diff --git a/bigframes/session/_io/bigquery/__init__.py b/bigframes/session/_io/bigquery/__init__.py index 83f63e8b9a..aa56dc0040 100644 --- a/bigframes/session/_io/bigquery/__init__.py +++ b/bigframes/session/_io/bigquery/__init__.py @@ -29,11 +29,13 @@ import google.api_core.exceptions import google.api_core.retry import google.cloud.bigquery as bigquery +import google.cloud.bigquery._job_helpers +import google.cloud.bigquery.table from bigframes.core import log_adapter import bigframes.core.compile.googlesql as googlesql +import bigframes.core.events import bigframes.core.sql -import bigframes.formatting_helpers as formatting_helpers import bigframes.session.metrics CHECK_DRIVE_PERMISSIONS = "\nCheck https://cloud.google.com/bigquery/docs/query-drive-data#Google_Drive_permissions." @@ -238,6 +240,24 @@ def add_and_trim_labels(job_config): ) +def create_bq_event_callback(publisher): + def publish_bq_event(event): + if isinstance(event, google.cloud.bigquery._job_helpers.QueryFinishedEvent): + bf_event = bigframes.core.events.BigQueryFinishedEvent.from_bqclient(event) + elif isinstance(event, google.cloud.bigquery._job_helpers.QueryReceivedEvent): + bf_event = bigframes.core.events.BigQueryReceivedEvent.from_bqclient(event) + elif isinstance(event, google.cloud.bigquery._job_helpers.QueryRetryEvent): + bf_event = bigframes.core.events.BigQueryRetryEvent.from_bqclient(event) + elif isinstance(event, google.cloud.bigquery._job_helpers.QuerySentEvent): + bf_event = bigframes.core.events.BigQuerySentEvent.from_bqclient(event) + else: + bf_event = bigframes.core.events.BigQueryUnknownEvent(event) + + publisher.publish(bf_event) + + return publish_bq_event + + @overload def start_query_with_client( bq_client: bigquery.Client, @@ -249,7 +269,8 @@ def start_query_with_client( timeout: Optional[float], metrics: Optional[bigframes.session.metrics.ExecutionMetrics], query_with_job: Literal[True], -) -> Tuple[bigquery.table.RowIterator, bigquery.QueryJob]: + publisher: bigframes.core.events.Publisher, +) -> Tuple[google.cloud.bigquery.table.RowIterator, bigquery.QueryJob]: ... @@ -264,7 +285,8 @@ def start_query_with_client( timeout: Optional[float], metrics: Optional[bigframes.session.metrics.ExecutionMetrics], query_with_job: Literal[False], -) -> Tuple[bigquery.table.RowIterator, Optional[bigquery.QueryJob]]: + publisher: bigframes.core.events.Publisher, +) -> Tuple[google.cloud.bigquery.table.RowIterator, Optional[bigquery.QueryJob]]: ... @@ -280,7 +302,8 @@ def start_query_with_client( metrics: Optional[bigframes.session.metrics.ExecutionMetrics], query_with_job: Literal[True], job_retry: google.api_core.retry.Retry, -) -> Tuple[bigquery.table.RowIterator, bigquery.QueryJob]: + publisher: bigframes.core.events.Publisher, +) -> Tuple[google.cloud.bigquery.table.RowIterator, bigquery.QueryJob]: ... @@ -296,7 +319,8 @@ def start_query_with_client( metrics: Optional[bigframes.session.metrics.ExecutionMetrics], query_with_job: Literal[False], job_retry: google.api_core.retry.Retry, -) -> Tuple[bigquery.table.RowIterator, Optional[bigquery.QueryJob]]: + publisher: bigframes.core.events.Publisher, +) -> Tuple[google.cloud.bigquery.table.RowIterator, Optional[bigquery.QueryJob]]: ... @@ -315,23 +339,26 @@ def start_query_with_client( # https://github.com/googleapis/python-bigquery/pull/2256 merged, likely # version 3.36.0 or later. job_retry: google.api_core.retry.Retry = third_party_gcb_retry.DEFAULT_JOB_RETRY, -) -> Tuple[bigquery.table.RowIterator, Optional[bigquery.QueryJob]]: + publisher: bigframes.core.events.Publisher, +) -> Tuple[google.cloud.bigquery.table.RowIterator, Optional[bigquery.QueryJob]]: """ Starts query job and waits for results. """ + # Note: Ensure no additional labels are added to job_config after this + # point, as `add_and_trim_labels` ensures the label count does not + # exceed MAX_LABELS_COUNT. + add_and_trim_labels(job_config) + try: - # Note: Ensure no additional labels are added to job_config after this - # point, as `add_and_trim_labels` ensures the label count does not - # exceed MAX_LABELS_COUNT. - add_and_trim_labels(job_config) if not query_with_job: - results_iterator = bq_client.query_and_wait( + results_iterator = bq_client._query_and_wait_bigframes( sql, job_config=job_config, location=location, project=project, api_timeout=timeout, job_retry=job_retry, + callback=create_bq_event_callback(publisher), ) if metrics is not None: metrics.count_job_stats(row_iterator=results_iterator) @@ -350,14 +377,32 @@ def start_query_with_client( ex.message += CHECK_DRIVE_PERMISSIONS raise - opts = bigframes.options.display - if opts.progress_bar is not None and not query_job.configuration.dry_run: - results_iterator = formatting_helpers.wait_for_query_job( - query_job, - progress_bar=opts.progress_bar, + if not query_job.configuration.dry_run: + publisher.publish( + bigframes.core.events.BigQuerySentEvent( + sql, + billing_project=query_job.project, + location=query_job.location, + job_id=query_job.job_id, + request_id=None, + ) + ) + results_iterator = query_job.result() + if not query_job.configuration.dry_run: + publisher.publish( + bigframes.core.events.BigQueryFinishedEvent( + billing_project=query_job.project, + location=query_job.location, + job_id=query_job.job_id, + destination=query_job.destination, + total_rows=results_iterator.total_rows, + total_bytes_processed=query_job.total_bytes_processed, + slot_millis=query_job.slot_millis, + created=query_job.created, + started=query_job.started, + ended=query_job.ended, + ) ) - else: - results_iterator = query_job.result() if metrics is not None: metrics.count_job_stats(query_job=query_job) @@ -399,6 +444,8 @@ def create_bq_dataset_reference( bq_client: bigquery.Client, location: Optional[str] = None, project: Optional[str] = None, + *, + publisher: bigframes.core.events.Publisher, ) -> bigquery.DatasetReference: """Create and identify dataset(s) for temporary BQ resources. @@ -430,6 +477,7 @@ def create_bq_dataset_reference( timeout=None, metrics=None, query_with_job=True, + publisher=publisher, ) # The anonymous dataset is used by BigQuery to write query results and diff --git a/bigframes/session/_io/bigquery/read_gbq_table.py b/bigframes/session/_io/bigquery/read_gbq_table.py index 00531ce25d..f8a379aee9 100644 --- a/bigframes/session/_io/bigquery/read_gbq_table.py +++ b/bigframes/session/_io/bigquery/read_gbq_table.py @@ -26,8 +26,9 @@ import bigframes_vendored.constants as constants import google.api_core.exceptions import google.cloud.bigquery as bigquery +import google.cloud.bigquery.table -import bigframes.core.sql +import bigframes.core.events import bigframes.exceptions as bfe import bigframes.session._io.bigquery @@ -43,6 +44,7 @@ def get_table_metadata( *, cache: Dict[bigquery.TableReference, Tuple[datetime.datetime, bigquery.Table]], use_cache: bool = True, + publisher: bigframes.core.events.Publisher, ) -> Tuple[datetime.datetime, google.cloud.bigquery.table.Table]: """Get the table metadata, either from cache or via REST API.""" @@ -59,6 +61,7 @@ def get_table_metadata( # Don't warn, because that will already have been taken care of. should_warn=False, should_dry_run=False, + publisher=publisher, ): # This warning should only happen if the cached snapshot_time will # have any effect on bigframes (b/437090788). For example, with @@ -101,13 +104,14 @@ def get_table_metadata( def is_time_travel_eligible( bqclient: bigquery.Client, - table: bigquery.table.Table, + table: google.cloud.bigquery.table.Table, columns: Optional[Sequence[str]], snapshot_time: datetime.datetime, filter_str: Optional[str] = None, *, should_warn: bool, should_dry_run: bool, + publisher: bigframes.core.events.Publisher, ): """Check if a table is eligible to use time-travel. @@ -184,6 +188,7 @@ def is_time_travel_eligible( timeout=None, metrics=None, query_with_job=False, + publisher=publisher, ) return True @@ -210,10 +215,8 @@ def is_time_travel_eligible( def infer_unique_columns( - bqclient: bigquery.Client, - table: bigquery.table.Table, + table: google.cloud.bigquery.table.Table, index_cols: List[str], - metadata_only: bool = False, ) -> Tuple[str, ...]: """Return a set of columns that can provide a unique row key or empty if none can be inferred. @@ -227,14 +230,37 @@ def infer_unique_columns( # Essentially, just reordering the primary key to match the index col order return tuple(index_col for index_col in index_cols if index_col in primary_keys) - if primary_keys or metadata_only or (not index_cols): - # Sometimes not worth scanning data to check uniqueness + if primary_keys: return primary_keys + + return () + + +def check_if_index_columns_are_unique( + bqclient: bigquery.Client, + table: google.cloud.bigquery.table.Table, + index_cols: List[str], + *, + publisher: bigframes.core.events.Publisher, +) -> Tuple[str, ...]: + import bigframes.core.sql + import bigframes.session._io.bigquery + # TODO(b/337925142): Avoid a "SELECT *" subquery here by ensuring # table_expression only selects just index_cols. is_unique_sql = bigframes.core.sql.is_distinct_sql(index_cols, table.reference) job_config = bigquery.QueryJobConfig() - results = bqclient.query_and_wait(is_unique_sql, job_config=job_config) + results, _ = bigframes.session._io.bigquery.start_query_with_client( + bq_client=bqclient, + sql=is_unique_sql, + job_config=job_config, + timeout=None, + location=None, + project=None, + metrics=None, + query_with_job=False, + publisher=publisher, + ) row = next(iter(results)) if row["total_count"] == row["distinct_count"]: @@ -243,7 +269,7 @@ def infer_unique_columns( def _get_primary_keys( - table: bigquery.table.Table, + table: google.cloud.bigquery.table.Table, ) -> List[str]: """Get primary keys from table if they are set.""" @@ -261,7 +287,7 @@ def _get_primary_keys( def _is_table_clustered_or_partitioned( - table: bigquery.table.Table, + table: google.cloud.bigquery.table.Table, ) -> bool: """Returns True if the table is clustered or partitioned.""" @@ -284,7 +310,7 @@ def _is_table_clustered_or_partitioned( def get_index_cols( - table: bigquery.table.Table, + table: google.cloud.bigquery.table.Table, index_col: Iterable[str] | str | Iterable[int] diff --git a/bigframes/session/anonymous_dataset.py b/bigframes/session/anonymous_dataset.py index ec624d4eb4..3c1757806b 100644 --- a/bigframes/session/anonymous_dataset.py +++ b/bigframes/session/anonymous_dataset.py @@ -20,6 +20,7 @@ import google.cloud.bigquery as bigquery from bigframes import constants +import bigframes.core.events from bigframes.session import temporary_storage import bigframes.session._io.bigquery as bf_io_bigquery @@ -37,10 +38,12 @@ def __init__( location: str, session_id: str, *, - kms_key: Optional[str] = None + kms_key: Optional[str] = None, + publisher: bigframes.core.events.Publisher, ): self.bqclient = bqclient self._location = location + self._publisher = publisher self.session_id = session_id self._table_ids: List[bigquery.TableReference] = [] @@ -62,6 +65,7 @@ def dataset(self) -> bigquery.DatasetReference: self._datset_ref = bf_io_bigquery.create_bq_dataset_reference( self.bqclient, location=self._location, + publisher=self._publisher, ) return self._datset_ref diff --git a/bigframes/session/bigquery_session.py b/bigframes/session/bigquery_session.py index 883087df07..99c13007d8 100644 --- a/bigframes/session/bigquery_session.py +++ b/bigframes/session/bigquery_session.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import datetime import logging import threading @@ -23,7 +25,9 @@ import google.cloud.bigquery as bigquery from bigframes.core.compile import googlesql +import bigframes.core.events from bigframes.session import temporary_storage +import bigframes.session._io.bigquery as bfbqio KEEPALIVE_QUERY_TIMEOUT_SECONDS = 5.0 @@ -38,12 +42,19 @@ class SessionResourceManager(temporary_storage.TemporaryStorageManager): Responsible for allocating and cleaning up temporary gbq tables used by a BigFrames session. """ - def __init__(self, bqclient: bigquery.Client, location: str): + def __init__( + self, + bqclient: bigquery.Client, + location: str, + *, + publisher: bigframes.core.events.Publisher, + ): self.bqclient = bqclient self._location = location self._session_id: Optional[str] = None self._sessiondaemon: Optional[RecurringTaskDaemon] = None self._session_lock = threading.RLock() + self._publisher = publisher @property def location(self): @@ -84,21 +95,38 @@ def create_temp_table( ddl = f"CREATE TEMP TABLE `_SESSION`.{googlesql.identifier(table_ref.table_id)} ({fields_string}){cluster_string}" - job = self.bqclient.query( - ddl, job_config=job_config, location=self.location + _, job = bfbqio.start_query_with_client( + self.bqclient, + ddl, + job_config=job_config, + location=self.location, + project=None, + timeout=None, + metrics=None, + query_with_job=True, + publisher=self._publisher, ) job.result() # return the fully qualified table, so it can be used outside of the session - return job.destination + destination = job.destination + assert destination is not None, "Failure to create temp table." + return destination def close(self): if self._sessiondaemon is not None: self._sessiondaemon.stop() if self._session_id is not None and self.bqclient is not None: - self.bqclient.query_and_wait( + bfbqio.start_query_with_client( + self.bqclient, f"CALL BQ.ABORT_SESSION('{self._session_id}')", + job_config=bigquery.QueryJobConfig(), location=self.location, + project=None, + timeout=None, + metrics=None, + query_with_job=False, + publisher=self._publisher, ) def _get_session_id(self) -> str: @@ -109,8 +137,16 @@ def _get_session_id(self) -> str: job_config = bigquery.QueryJobConfig(create_session=True) # Make sure the session is a new one, not one associated with another query. job_config.use_query_cache = False - query_job = self.bqclient.query( - "SELECT 1", job_config=job_config, location=self.location + _, query_job = bfbqio.start_query_with_client( + self.bqclient, + "SELECT 1", + job_config=job_config, + location=self.location, + project=None, + timeout=None, + metrics=None, + query_with_job=True, + publisher=self._publisher, ) query_job.result() # blocks until finished assert query_job.session_info is not None @@ -133,11 +169,16 @@ def _keep_session_alive(self): ] ) try: - self.bqclient.query_and_wait( + bfbqio.start_query_with_client( + self.bqclient, "SELECT 1", - location=self.location, job_config=job_config, - wait_timeout=KEEPALIVE_QUERY_TIMEOUT_SECONDS, + location=self.location, + project=None, + timeout=KEEPALIVE_QUERY_TIMEOUT_SECONDS, + metrics=None, + query_with_job=False, + publisher=self._publisher, ) except Exception as e: logging.warning("BigQuery session keep-alive query errored : %s", e) diff --git a/bigframes/session/bq_caching_executor.py b/bigframes/session/bq_caching_executor.py index cbda9bc640..d4cfa13aa4 100644 --- a/bigframes/session/bq_caching_executor.py +++ b/bigframes/session/bq_caching_executor.py @@ -33,6 +33,7 @@ import bigframes.core from bigframes.core import compile, local_data, rewrite import bigframes.core.compile.sqlglot.sqlglot_ir as sqlglot_ir +import bigframes.core.events import bigframes.core.guid import bigframes.core.identifiers import bigframes.core.nodes as nodes @@ -140,6 +141,7 @@ def __init__( strictly_ordered: bool = True, metrics: Optional[bigframes.session.metrics.ExecutionMetrics] = None, enable_polars_execution: bool = False, + publisher: bigframes.core.events.Publisher, ): self.bqclient = bqclient self.storage_manager = storage_manager @@ -149,6 +151,9 @@ def __init__( self.loader = loader self.bqstoragereadclient = bqstoragereadclient self._enable_polars_execution = enable_polars_execution + self._publisher = publisher + + # TODO(tswast): Send events from semi-executors, too. self._semi_executors: Sequence[semi_executor.SemiExecutor] = ( read_api_execution.ReadApiSemiExecutor( bqstoragereadclient=bqstoragereadclient, @@ -188,6 +193,8 @@ def execute( array_value: bigframes.core.ArrayValue, execution_spec: ex_spec.ExecutionSpec, ) -> executor.ExecuteResult: + self._publisher.publish(bigframes.core.events.ExecutionStarted()) + # TODO: Support export jobs in combination with semi executors if execution_spec.destination_spec is None: plan = self.prepare_plan(array_value.node, target="simplify") @@ -196,6 +203,11 @@ def execute( plan, ordered=execution_spec.ordered, peek=execution_spec.peek ) if maybe_result: + self._publisher.publish( + bigframes.core.events.ExecutionFinished( + result=maybe_result, + ) + ) return maybe_result if isinstance(execution_spec.destination_spec, ex_spec.TableOutputSpec): @@ -204,7 +216,13 @@ def execute( "Ordering and peeking not supported for gbq export" ) # separate path for export_gbq, as it has all sorts of annoying logic, such as possibly running as dml - return self._export_gbq(array_value, execution_spec.destination_spec) + result = self._export_gbq(array_value, execution_spec.destination_spec) + self._publisher.publish( + bigframes.core.events.ExecutionFinished( + result=result, + ) + ) + return result result = self._execute_plan_gbq( array_value.node, @@ -219,6 +237,11 @@ def execute( if isinstance(execution_spec.destination_spec, ex_spec.GcsOutputSpec): self._export_result_gcs(result, execution_spec.destination_spec) + self._publisher.publish( + bigframes.core.events.ExecutionFinished( + result=result, + ) + ) return result def _export_result_gcs( @@ -243,6 +266,7 @@ def _export_result_gcs( location=None, timeout=None, query_with_job=True, + publisher=self._publisher, ) def _maybe_find_existing_table( @@ -404,6 +428,7 @@ def _run_execute_query( location=None, timeout=None, query_with_job=True, + publisher=self._publisher, ) else: return bq_io.start_query_with_client( @@ -415,6 +440,7 @@ def _run_execute_query( location=None, timeout=None, query_with_job=False, + publisher=self._publisher, ) except google.api_core.exceptions.BadRequest as e: diff --git a/bigframes/session/direct_gbq_execution.py b/bigframes/session/direct_gbq_execution.py index 7538c9300f..9e7db87301 100644 --- a/bigframes/session/direct_gbq_execution.py +++ b/bigframes/session/direct_gbq_execution.py @@ -21,6 +21,7 @@ from bigframes.core import compile, nodes from bigframes.core.compile import sqlglot +import bigframes.core.events from bigframes.session import executor, semi_executor import bigframes.session._io.bigquery as bq_io @@ -31,7 +32,11 @@ # reference for validating more complex executors. class DirectGbqExecutor(semi_executor.SemiExecutor): def __init__( - self, bqclient: bigquery.Client, compiler: Literal["ibis", "sqlglot"] = "ibis" + self, + bqclient: bigquery.Client, + compiler: Literal["ibis", "sqlglot"] = "ibis", + *, + publisher: bigframes.core.events.Publisher, ): self.bqclient = bqclient self._compile_fn = ( @@ -39,6 +44,7 @@ def __init__( if compiler == "ibis" else sqlglot.SQLGlotCompiler()._compile_sql ) + self._publisher = publisher def execute( self, @@ -83,4 +89,5 @@ def _run_execute_query( timeout=None, metrics=None, query_with_job=False, + publisher=self._publisher, ) diff --git a/bigframes/session/loader.py b/bigframes/session/loader.py index 94d8db6f36..940fdc1352 100644 --- a/bigframes/session/loader.py +++ b/bigframes/session/loader.py @@ -50,6 +50,7 @@ from bigframes.core import guid, identifiers, local_data, nodes, ordering, utils import bigframes.core as core import bigframes.core.blocks as blocks +import bigframes.core.events import bigframes.core.schema as schemata import bigframes.dtypes import bigframes.formatting_helpers as formatting_helpers @@ -262,6 +263,8 @@ def __init__( scan_index_uniqueness: bool, force_total_order: bool, metrics: Optional[bigframes.session.metrics.ExecutionMetrics] = None, + *, + publisher: bigframes.core.events.Publisher, ): self._bqclient = bqclient self._write_client = write_client @@ -273,6 +276,7 @@ def __init__( bigquery.TableReference, Tuple[datetime.datetime, bigquery.Table] ] = {} self._metrics = metrics + self._publisher = publisher # Unfortunate circular reference, but need to pass reference when constructing objects self._session = session self._clock = session_time.BigQuerySyncedClock(bqclient) @@ -499,6 +503,7 @@ def read_gbq_table( # type: ignore[overload-overlap] force_total_order: Optional[bool] = ..., n_rows: Optional[int] = None, index_col_in_columns: bool = False, + publish_execution: bool = True, ) -> dataframe.DataFrame: ... @@ -522,6 +527,7 @@ def read_gbq_table( force_total_order: Optional[bool] = ..., n_rows: Optional[int] = None, index_col_in_columns: bool = False, + publish_execution: bool = True, ) -> pandas.Series: ... @@ -544,6 +550,7 @@ def read_gbq_table( force_total_order: Optional[bool] = None, n_rows: Optional[int] = None, index_col_in_columns: bool = False, + publish_execution: bool = True, ) -> dataframe.DataFrame | pandas.Series: """Read a BigQuery table into a BigQuery DataFrames DataFrame. @@ -603,8 +610,12 @@ def read_gbq_table( when the index is selected from the data columns (e.g., in a ``read_csv`` scenario). The column will be used as the DataFrame's index and removed from the list of value columns. + publish_execution (bool, optional): + If True, sends an execution started and stopped event if this + causes a query. Set to False if using read_gbq_table from + another function that is reporting execution. """ - import bigframes._tools.strings + import bigframes.core.events import bigframes.dataframe as dataframe # --------------------------------- @@ -636,6 +647,7 @@ def read_gbq_table( bq_time=self._clock.get_time(), cache=self._df_snapshot, use_cache=use_cache, + publisher=self._publisher, ) if table.location.casefold() != self._storage_manager.location.casefold(): @@ -756,6 +768,7 @@ def read_gbq_table( filter_str, should_warn=True, should_dry_run=True, + publisher=self._publisher, ) # ---------------------------- @@ -768,12 +781,27 @@ def read_gbq_table( # TODO(b/338065601): Provide a way to assume uniqueness and avoid this # check. primary_key = bf_read_gbq_table.infer_unique_columns( - bqclient=self._bqclient, table=table, index_cols=index_cols, - # If non in strict ordering mode, don't go through overhead of scanning index column(s) to determine if unique - metadata_only=not self._scan_index_uniqueness, ) + + # If non in strict ordering mode, don't go through overhead of scanning index column(s) to determine if unique + if not primary_key and self._scan_index_uniqueness and index_cols: + if publish_execution: + self._publisher.publish( + bigframes.core.events.ExecutionStarted(), + ) + primary_key = bf_read_gbq_table.check_if_index_columns_are_unique( + self._bqclient, + table=table, + index_cols=index_cols, + publisher=self._publisher, + ) + if publish_execution: + self._publisher.publish( + bigframes.core.events.ExecutionFinished(), + ) + schema = schemata.ArraySchema.from_bq_table(table) if not include_all_columns: schema = schema.select(index_cols + columns) @@ -991,6 +1019,12 @@ def read_gbq_query( query_job, list(columns), index_cols ) + # We want to make sure we show progress when we actually do execute a + # query. Since we have got this far, we know it's not a dry run. + self._publisher.publish( + bigframes.core.events.ExecutionStarted(), + ) + query_job_for_metrics: Optional[bigquery.QueryJob] = None destination: Optional[bigquery.TableReference] = None @@ -1046,20 +1080,28 @@ def read_gbq_query( # makes sense to download the results beyond the first page, even if # there is a job and destination table available. if query_job_for_metrics is None and rows is not None: - return bf_read_gbq_query.create_dataframe_from_row_iterator( + df = bf_read_gbq_query.create_dataframe_from_row_iterator( rows, session=self._session, index_col=index_col, columns=columns, ) + self._publisher.publish( + bigframes.core.events.ExecutionFinished(), + ) + return df # We already checked rows, so if there's no destination table, then # there are no results to return. if destination is None: - return bf_read_gbq_query.create_dataframe_from_query_job_stats( + df = bf_read_gbq_query.create_dataframe_from_query_job_stats( query_job_for_metrics, session=self._session, ) + self._publisher.publish( + bigframes.core.events.ExecutionFinished(), + ) + return df # If the query was DDL or DML, return some job metadata. See # https://cloud.google.com/bigquery/docs/reference/rest/v2/Job#JobStatistics2.FIELDS.statement_type @@ -1070,10 +1112,14 @@ def read_gbq_query( query_job_for_metrics is not None and not bf_read_gbq_query.should_return_query_results(query_job_for_metrics) ): - return bf_read_gbq_query.create_dataframe_from_query_job_stats( + df = bf_read_gbq_query.create_dataframe_from_query_job_stats( query_job_for_metrics, session=self._session, ) + self._publisher.publish( + bigframes.core.events.ExecutionFinished(), + ) + return df # Speed up counts by getting counts from result metadata. if rows is not None: @@ -1083,16 +1129,21 @@ def read_gbq_query( else: n_rows = None - return self.read_gbq_table( + df = self.read_gbq_table( f"{destination.project}.{destination.dataset_id}.{destination.table_id}", index_col=index_col, columns=columns, use_cache=configuration["query"]["useQueryCache"], force_total_order=force_total_order, n_rows=n_rows, + publish_execution=False, # max_results and filters are omitted because they are already # handled by to_query(), above. ) + self._publisher.publish( + bigframes.core.events.ExecutionFinished(), + ) + return df def _query_to_destination( self, @@ -1194,6 +1245,7 @@ def _start_query_with_job_optional( project=None, metrics=None, query_with_job=False, + publisher=self._publisher, ) return rows @@ -1219,6 +1271,7 @@ def _start_query_with_job( project=None, metrics=None, query_with_job=True, + publisher=self._publisher, ) return query_job diff --git a/bigframes/testing/mocks.py b/bigframes/testing/mocks.py index 8d9997b1df..ff210419fd 100644 --- a/bigframes/testing/mocks.py +++ b/bigframes/testing/mocks.py @@ -143,6 +143,7 @@ def query_and_wait_mock(query, *args, job_config=None, **kwargs): bqclient.query.side_effect = query_mock bqclient.query_and_wait.side_effect = query_and_wait_mock + bqclient._query_and_wait_bigframes.side_effect = query_and_wait_mock clients_provider = mock.create_autospec(bigframes.session.clients.ClientsProvider) type(clients_provider).bqclient = mock.PropertyMock(return_value=bqclient) diff --git a/setup.py b/setup.py index 2aef514749..abc760b691 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ "gcsfs >=2023.3.0, !=2025.5.0", "geopandas >=0.12.2", "google-auth >=2.15.0,<3.0", - "google-cloud-bigquery[bqstorage,pandas] >=3.31.0", + "google-cloud-bigquery[bqstorage,pandas] >=3.36.0", # 2.30 needed for arrow support. "google-cloud-bigquery-storage >= 2.30.0, < 3.0.0", "google-cloud-functions >=1.12.0", diff --git a/testing/constraints-3.9.txt b/testing/constraints-3.9.txt index 8df3a3a2c3..eceec07dc4 100644 --- a/testing/constraints-3.9.txt +++ b/testing/constraints-3.9.txt @@ -6,7 +6,7 @@ geopandas==0.12.2 google-auth==2.15.0 google-cloud-bigtable==2.24.0 google-cloud-pubsub==2.21.4 -google-cloud-bigquery==3.31.0 +google-cloud-bigquery==3.36.0 google-cloud-functions==1.12.0 google-cloud-bigquery-connection==1.12.0 google-cloud-iam==2.12.1 diff --git a/tests/system/small/engines/conftest.py b/tests/system/small/engines/conftest.py index 9699cc6a61..a775731cde 100644 --- a/tests/system/small/engines/conftest.py +++ b/tests/system/small/engines/conftest.py @@ -19,7 +19,7 @@ import pytest import bigframes -from bigframes.core import ArrayValue, local_data +from bigframes.core import ArrayValue, events, local_data from bigframes.session import ( direct_gbq_execution, local_scan_executor, @@ -50,11 +50,14 @@ def engine(request, bigquery_client: bigquery.Client) -> semi_executor.SemiExecu return local_scan_executor.LocalScanExecutor() if request.param == "polars": return polars_executor.PolarsExecutor() + publisher = events.Publisher() if request.param == "bq": - return direct_gbq_execution.DirectGbqExecutor(bigquery_client) + return direct_gbq_execution.DirectGbqExecutor( + bigquery_client, publisher=publisher + ) if request.param == "bq-sqlglot": return direct_gbq_execution.DirectGbqExecutor( - bigquery_client, compiler="sqlglot" + bigquery_client, compiler="sqlglot", publisher=publisher ) raise ValueError(f"Unrecognized param: {request.param}") diff --git a/tests/system/small/engines/test_aggregation.py b/tests/system/small/engines/test_aggregation.py index a25c167f71..d71013c648 100644 --- a/tests/system/small/engines/test_aggregation.py +++ b/tests/system/small/engines/test_aggregation.py @@ -15,7 +15,14 @@ from google.cloud import bigquery import pytest -from bigframes.core import agg_expressions, array_value, expression, identifiers, nodes +from bigframes.core import ( + agg_expressions, + array_value, + events, + expression, + identifiers, + nodes, +) import bigframes.operations.aggregations as agg_ops from bigframes.session import direct_gbq_execution, polars_executor from bigframes.testing.engine_utils import assert_equivalence_execution @@ -112,9 +119,12 @@ def test_sql_engines_median_op_aggregates( scalars_array_value, agg_ops.MedianOp(), ).node - left_engine = direct_gbq_execution.DirectGbqExecutor(bigquery_client) + publisher = events.Publisher() + left_engine = direct_gbq_execution.DirectGbqExecutor( + bigquery_client, publisher=publisher + ) right_engine = direct_gbq_execution.DirectGbqExecutor( - bigquery_client, compiler="sqlglot" + bigquery_client, compiler="sqlglot", publisher=publisher ) assert_equivalence_execution(node, left_engine, right_engine) diff --git a/tests/system/small/engines/test_windowing.py b/tests/system/small/engines/test_windowing.py index f344a3b60a..a34d7b8f38 100644 --- a/tests/system/small/engines/test_windowing.py +++ b/tests/system/small/engines/test_windowing.py @@ -18,6 +18,7 @@ from bigframes.core import ( agg_expressions, array_value, + events, expression, identifiers, nodes, @@ -64,8 +65,11 @@ def test_engines_with_rows_window( skip_reproject_unsafe=False, ) - bq_executor = direct_gbq_execution.DirectGbqExecutor(bigquery_client) + publisher = events.Publisher() + bq_executor = direct_gbq_execution.DirectGbqExecutor( + bigquery_client, publisher=publisher + ) bq_sqlgot_executor = direct_gbq_execution.DirectGbqExecutor( - bigquery_client, compiler="sqlglot" + bigquery_client, compiler="sqlglot", publisher=publisher ) assert_equivalence_execution(window_node, bq_executor, bq_sqlgot_executor) diff --git a/tests/system/small/functions/test_remote_function.py b/tests/system/small/functions/test_remote_function.py index 15070a3a29..26c4b89b24 100644 --- a/tests/system/small/functions/test_remote_function.py +++ b/tests/system/small/functions/test_remote_function.py @@ -28,6 +28,7 @@ import bigframes import bigframes.clients +import bigframes.core.events import bigframes.dtypes import bigframes.exceptions from bigframes.functions import _utils as bff_utils @@ -770,6 +771,7 @@ def test_read_gbq_function_runs_existing_udf_array_output(session, routine_id_un timeout=None, metrics=None, query_with_job=True, + publisher=bigframes.core.events.Publisher(), ) func = session.read_gbq_function(routine_id_unique) @@ -808,6 +810,7 @@ def test_read_gbq_function_runs_existing_udf_2_params_array_output( timeout=None, metrics=None, query_with_job=True, + publisher=bigframes.core.events.Publisher(), ) func = session.read_gbq_function(routine_id_unique) @@ -848,6 +851,7 @@ def test_read_gbq_function_runs_existing_udf_4_params_array_output( timeout=None, metrics=None, query_with_job=True, + publisher=bigframes.core.events.Publisher(), ) func = session.read_gbq_function(routine_id_unique) diff --git a/tests/system/small/test_bq_sessions.py b/tests/system/small/test_bq_sessions.py index 7aad19bd8f..801346600d 100644 --- a/tests/system/small/test_bq_sessions.py +++ b/tests/system/small/test_bq_sessions.py @@ -17,10 +17,10 @@ import google import google.api_core.exceptions -import google.cloud from google.cloud import bigquery import pytest +import bigframes.core.events from bigframes.session import bigquery_session TEST_SCHEMA = [ @@ -39,12 +39,14 @@ def session_resource_manager( bigquery_client, ) -> bigquery_session.SessionResourceManager: - return bigquery_session.SessionResourceManager(bigquery_client, "US") + return bigquery_session.SessionResourceManager( + bigquery_client, "US", publisher=bigframes.core.events.Publisher() + ) def test_bq_session_create_temp_table_clustered(bigquery_client: bigquery.Client): session_resource_manager = bigquery_session.SessionResourceManager( - bigquery_client, "US" + bigquery_client, "US", publisher=bigframes.core.events.Publisher() ) cluster_cols = ["string field", "bool field"] @@ -68,7 +70,7 @@ def test_bq_session_create_temp_table_clustered(bigquery_client: bigquery.Client def test_bq_session_create_multi_temp_tables(bigquery_client: bigquery.Client): session_resource_manager = bigquery_session.SessionResourceManager( - bigquery_client, "US" + bigquery_client, "US", publisher=bigframes.core.events.Publisher() ) def create_table(): diff --git a/tests/system/small/test_progress_bar.py b/tests/system/small/test_progress_bar.py index 8a323831b5..0c9c4070f4 100644 --- a/tests/system/small/test_progress_bar.py +++ b/tests/system/small/test_progress_bar.py @@ -23,7 +23,7 @@ import bigframes.formatting_helpers as formatting_helpers from bigframes.session import MAX_INLINE_DF_BYTES -job_load_message_regex = r"\w+ job [\w-]+ is \w+\." +job_load_message_regex = r"Query" EXPECTED_DRY_RUN_MESSAGE = "Computation deferred. Computation will process" @@ -56,7 +56,7 @@ def test_progress_bar_scalar(penguins_df_default_index: bf.dataframe.DataFrame, with bf.option_context("display.progress_bar", "terminal"): penguins_df_default_index["body_mass_g"].head(10).mean() - assert capsys.readouterr().out == "" + assert_loading_msg_exist(capsys.readouterr().out) def test_progress_bar_scalar_allow_large_results( @@ -100,37 +100,19 @@ def test_progress_bar_load_jobs( capsys.readouterr() # clear output session.read_csv(path) - assert_loading_msg_exist(capsys.readouterr().out) + assert_loading_msg_exist(capsys.readouterr().out, pattern="Load") -def assert_loading_msg_exist(capystOut: str, pattern=job_load_message_regex): - numLoadingMsg = 0 - lines = capystOut.split("\n") +def assert_loading_msg_exist(capstdout: str, pattern=job_load_message_regex): + num_loading_msg = 0 + lines = capstdout.split("\n") lines = [line for line in lines if len(line) > 0] assert len(lines) > 0 for line in lines: - if re.match(pattern, line) is not None: - numLoadingMsg += 1 - assert numLoadingMsg > 0 - - -def test_query_job_repr_html(penguins_df_default_index: bf.dataframe.DataFrame): - with bf.option_context("display.progress_bar", "terminal"): - penguins_df_default_index.to_pandas(allow_large_results=True) - query_job_repr = formatting_helpers.repr_query_job_html( - penguins_df_default_index.query_job - ).value - - string_checks = [ - "Job Id", - "Destination Table", - "Slot Time", - "Bytes Processed", - "Cache hit", - ] - for string in string_checks: - assert string in query_job_repr + if re.search(pattern, line) is not None: + num_loading_msg += 1 + assert num_loading_msg > 0 def test_query_job_repr(penguins_df_default_index: bf.dataframe.DataFrame): diff --git a/tests/unit/session/test_io_bigquery.py b/tests/unit/session/test_io_bigquery.py index c451d74d0f..57ac3d88f7 100644 --- a/tests/unit/session/test_io_bigquery.py +++ b/tests/unit/session/test_io_bigquery.py @@ -22,6 +22,7 @@ import bigframes from bigframes.core import log_adapter +import bigframes.core.events import bigframes.pandas as bpd import bigframes.session._io.bigquery as io_bq from bigframes.testing import mocks @@ -236,6 +237,7 @@ def test_start_query_with_client_labels_length_limit_met( timeout=timeout, metrics=None, query_with_job=True, + publisher=bigframes.core.events.Publisher(), ) assert job_config.labels is not None diff --git a/tests/unit/session/test_read_gbq_table.py b/tests/unit/session/test_read_gbq_table.py index 0c67e05813..d21f0000a9 100644 --- a/tests/unit/session/test_read_gbq_table.py +++ b/tests/unit/session/test_read_gbq_table.py @@ -24,13 +24,12 @@ @pytest.mark.parametrize( - ("index_cols", "primary_keys", "values_distinct", "expected"), + ("index_cols", "primary_keys", "expected"), ( - (["col1", "col2"], ["col1", "col2", "col3"], False, ("col1", "col2", "col3")), + (["col1", "col2"], ["col1", "col2", "col3"], ("col1", "col2", "col3")), ( ["col1", "col2", "col3"], ["col1", "col2", "col3"], - True, ("col1", "col2", "col3"), ), ( @@ -39,15 +38,14 @@ "col3", "col2", ], - True, ("col2", "col3"), ), - (["col1", "col2"], [], False, ()), - ([], ["col1", "col2", "col3"], False, ("col1", "col2", "col3")), - ([], [], False, ()), + (["col1", "col2"], [], ()), + ([], ["col1", "col2", "col3"], ("col1", "col2", "col3")), + ([], [], ()), ), ) -def test_infer_unique_columns(index_cols, primary_keys, values_distinct, expected): +def test_infer_unique_columns(index_cols, primary_keys, expected): """If a primary key is set on the table, we use that as the index column by default, no error should be raised in this case. @@ -79,6 +77,49 @@ def test_infer_unique_columns(index_cols, primary_keys, values_distinct, expecte "columns": primary_keys, }, } + + result = bf_read_gbq_table.infer_unique_columns(table, index_cols) + + assert result == expected + + +@pytest.mark.parametrize( + ("index_cols", "values_distinct", "expected"), + ( + ( + ["col1", "col2", "col3"], + True, + ("col1", "col2", "col3"), + ), + ( + ["col2", "col3", "col1"], + True, + ("col2", "col3", "col1"), + ), + (["col1", "col2"], False, ()), + ([], False, ()), + ), +) +def test_check_if_index_columns_are_unique(index_cols, values_distinct, expected): + table = google.cloud.bigquery.Table.from_api_repr( + { + "tableReference": { + "projectId": "my-project", + "datasetId": "my_dataset", + "tableId": "my_table", + }, + "clustering": { + "fields": ["col1", "col2"], + }, + }, + ) + table.schema = ( + google.cloud.bigquery.SchemaField("col1", "INT64"), + google.cloud.bigquery.SchemaField("col2", "INT64"), + google.cloud.bigquery.SchemaField("col3", "INT64"), + google.cloud.bigquery.SchemaField("col4", "INT64"), + ) + bqclient = mock.create_autospec(google.cloud.bigquery.Client, instance=True) bqclient.project = "test-project" session = mocks.create_bigquery_session( @@ -87,13 +128,18 @@ def test_infer_unique_columns(index_cols, primary_keys, values_distinct, expecte # Mock bqclient _after_ creating session to override its mocks. bqclient.get_table.return_value = table - bqclient.query_and_wait.side_effect = None - bqclient.query_and_wait.return_value = ( + bqclient._query_and_wait_bigframes.side_effect = None + bqclient._query_and_wait_bigframes.return_value = ( {"total_count": 3, "distinct_count": 3 if values_distinct else 2}, ) table._properties["location"] = session._location - result = bf_read_gbq_table.infer_unique_columns(bqclient, table, index_cols) + result = bf_read_gbq_table.check_if_index_columns_are_unique( + bqclient=bqclient, + table=table, + index_cols=index_cols, + publisher=session._publisher, + ) assert result == expected diff --git a/tests/unit/session/test_session.py b/tests/unit/session/test_session.py index 63c82eb30f..d05957b941 100644 --- a/tests/unit/session/test_session.py +++ b/tests/unit/session/test_session.py @@ -247,7 +247,7 @@ def test_read_gbq_cached_table(): table, ) - session.bqclient.query_and_wait = mock.MagicMock( + session.bqclient._query_and_wait_bigframes = mock.MagicMock( return_value=({"total_count": 3, "distinct_count": 2},) ) session.bqclient.get_table.return_value = table @@ -278,7 +278,7 @@ def test_read_gbq_cached_table_doesnt_warn_for_anonymous_tables_and_doesnt_inclu table, ) - session.bqclient.query_and_wait = mock.MagicMock( + session.bqclient._query_and_wait_bigframes = mock.MagicMock( return_value=({"total_count": 3, "distinct_count": 2},) ) session.bqclient.get_table.return_value = table @@ -306,7 +306,9 @@ def test_default_index_warning_raised_by_read_gbq(table): bqclient = mock.create_autospec(google.cloud.bigquery.Client, instance=True) bqclient.project = "test-project" bqclient.get_table.return_value = table - bqclient.query_and_wait.return_value = ({"total_count": 3, "distinct_count": 2},) + bqclient._query_and_wait_bigframes.return_value = ( + {"total_count": 3, "distinct_count": 2}, + ) session = mocks.create_bigquery_session( bqclient=bqclient, # DefaultIndexWarning is only relevant for strict mode. @@ -333,7 +335,9 @@ def test_default_index_warning_not_raised_by_read_gbq_index_col_sequential_int64 bqclient = mock.create_autospec(google.cloud.bigquery.Client, instance=True) bqclient.project = "test-project" bqclient.get_table.return_value = table - bqclient.query_and_wait.return_value = ({"total_count": 4, "distinct_count": 3},) + bqclient._query_and_wait_bigframes.return_value = ( + {"total_count": 4, "distinct_count": 3}, + ) session = mocks.create_bigquery_session( bqclient=bqclient, # DefaultIndexWarning is only relevant for strict mode. @@ -382,7 +386,7 @@ def test_default_index_warning_not_raised_by_read_gbq_index_col_columns( bqclient = mock.create_autospec(google.cloud.bigquery.Client, instance=True) bqclient.project = "test-project" bqclient.get_table.return_value = table - bqclient.query_and_wait.return_value = ( + bqclient._query_and_wait_bigframes.return_value = ( {"total_count": total_count, "distinct_count": distinct_count}, ) session = mocks.create_bigquery_session( @@ -492,6 +496,7 @@ def query_mock(query, *args, **kwargs): return session_query_mock(query, *args, **kwargs) session.bqclient.query_and_wait = query_mock + session.bqclient._query_and_wait_bigframes = query_mock def get_table_mock(table_ref): table = google.cloud.bigquery.Table( diff --git a/tests/unit/test_formatting_helpers.py b/tests/unit/test_formatting_helpers.py index 588ef6e824..9dc1379496 100644 --- a/tests/unit/test_formatting_helpers.py +++ b/tests/unit/test_formatting_helpers.py @@ -19,6 +19,7 @@ import google.cloud.bigquery as bigquery import pytest +import bigframes.core.events as bfevents import bigframes.formatting_helpers as formatting_helpers import bigframes.version @@ -30,7 +31,7 @@ def test_wait_for_query_job_error_includes_feedback_link(): ) with pytest.raises(api_core_exceptions.BadRequest) as cap_exc: - formatting_helpers.wait_for_query_job(mock_query_job) + formatting_helpers.wait_for_job(mock_query_job) cap_exc.match("Test message 123.") cap_exc.match(constants.FEEDBACK_LINK) @@ -70,3 +71,129 @@ def test_get_formatted_bytes(test_input, expected): ) def test_get_formatted_time(test_input, expected): assert formatting_helpers.get_formatted_time(test_input) == expected + + +def test_render_bqquery_sent_event_html(): + event = bfevents.BigQuerySentEvent( + query="SELECT * FROM my_table", + job_id="my-job-id", + location="us-central1", + billing_project="my-project", + ) + html = formatting_helpers.render_bqquery_sent_event_html(event) + assert "SELECT * FROM my_table" in html + assert "my-job-id" in html + assert "us-central1" in html + assert "my-project" in html + assert "
" in html + + +def test_render_bqquery_sent_event_plaintext(): + event = bfevents.BigQuerySentEvent( + query="SELECT * FROM my_table", + job_id="my-job-id", + location="us-central1", + billing_project="my-project", + ) + text = formatting_helpers.render_bqquery_sent_event_plaintext(event) + assert "my-job-id" in text + assert "us-central1" in text + assert "my-project" in text + assert "SELECT * FROM my_table" not in text + + +def test_render_bqquery_retry_event_html(): + event = bfevents.BigQueryRetryEvent( + query="SELECT * FROM my_table", + job_id="my-job-id", + location="us-central1", + billing_project="my-project", + ) + html = formatting_helpers.render_bqquery_retry_event_html(event) + assert "Retrying query" in html + assert "SELECT * FROM my_table" in html + assert "my-job-id" in html + assert "us-central1" in html + assert "my-project" in html + assert "
" in html + + +def test_render_bqquery_retry_event_plaintext(): + event = bfevents.BigQueryRetryEvent( + query="SELECT * FROM my_table", + job_id="my-job-id", + location="us-central1", + billing_project="my-project", + ) + text = formatting_helpers.render_bqquery_retry_event_plaintext(event) + assert "Retrying query" in text + assert "my-job-id" in text + assert "us-central1" in text + assert "my-project" in text + assert "SELECT * FROM my_table" not in text + + +def test_render_bqquery_received_event_html(): + mock_plan_entry = mock.create_autospec( + bigquery.job.query.QueryPlanEntry, instance=True + ) + mock_plan_entry.__str__.return_value = "mocked plan" + event = bfevents.BigQueryReceivedEvent( + job_id="my-job-id", + location="us-central1", + billing_project="my-project", + state="RUNNING", + query_plan=[mock_plan_entry], + ) + html = formatting_helpers.render_bqquery_received_event_html(event) + assert "Query" in html + assert "my-job-id" in html + assert "is RUNNING" in html + assert "
" in html + assert "mocked plan" in html + + +def test_render_bqquery_received_event_plaintext(): + event = bfevents.BigQueryReceivedEvent( + job_id="my-job-id", + location="us-central1", + billing_project="my-project", + state="RUNNING", + query_plan=[], + ) + text = formatting_helpers.render_bqquery_received_event_plaintext(event) + assert "Query" in text + assert "my-job-id" in text + assert "is RUNNING" in text + assert "Query Plan" not in text + + +def test_render_bqquery_finished_event_html(): + event = bfevents.BigQueryFinishedEvent( + job_id="my-job-id", + location="us-central1", + billing_project="my-project", + total_bytes_processed=1000, + slot_millis=2000, + ) + html = formatting_helpers.render_bqquery_finished_event_html(event) + assert "Query" in html + assert "my-job-id" in html + assert "processed 1.0 kB" in html + assert "2 seconds of slot time" in html + + +def test_render_bqquery_finished_event_plaintext(): + event = bfevents.BigQueryFinishedEvent( + job_id="my-job-id", + location="us-central1", + billing_project="my-project", + total_bytes_processed=1000, + slot_millis=2000, + ) + text = formatting_helpers.render_bqquery_finished_event_plaintext(event) + assert "Query" in text + assert "my-job-id" in text + assert "finished" in text + assert "1.0 kB processed" in text + assert "Slot time: 2 seconds" in text From cdf2dd55a0c03da50ab92de09788cafac0abf6f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Wed, 8 Oct 2025 13:09:35 -0500 Subject: [PATCH 134/313] fix: address typo in error message (#2142) --- tests/system/small/test_dataframe.py | 2 +- third_party/bigframes_vendored/constants.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 851c934838..d0847eee4e 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -6034,7 +6034,7 @@ def test_df_astype_python_types(scalars_dfs): def test_astype_invalid_type_fail(scalars_dfs): bf_df, _ = scalars_dfs - with pytest.raises(TypeError, match=r".*Share your usecase with.*"): + with pytest.raises(TypeError, match=r".*Share your use case with.*"): bf_df.astype(123) diff --git a/third_party/bigframes_vendored/constants.py b/third_party/bigframes_vendored/constants.py index 6d55817a27..9705b19c90 100644 --- a/third_party/bigframes_vendored/constants.py +++ b/third_party/bigframes_vendored/constants.py @@ -23,7 +23,7 @@ import bigframes_vendored.version FEEDBACK_LINK = ( - "Share your usecase with the BigQuery DataFrames team at the " + "Share your use case with the BigQuery DataFrames team at the " "https://bit.ly/bigframes-feedback survey. " f"You are currently running BigFrames version {bigframes_vendored.version.__version__}." ) From 1f434fb5c7c00601654b3ab19c6ad7fceb258bd6 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Wed, 8 Oct 2025 13:16:08 -0700 Subject: [PATCH 135/313] docs: add a brief intro notebook for bbq AI functions (#2150) * docs: add an intro notebook for bbq AI functions * deprecate old ai notebooks * fix grammar * remove the project ID value --- notebooks/experimental/ai_operators.ipynb | 3106 +---------------- .../experimental/semantic_operators.ipynb | 4 +- notebooks/generative_ai/ai_functions.ipynb | 555 +++ 3 files changed, 560 insertions(+), 3105 deletions(-) create mode 100644 notebooks/generative_ai/ai_functions.ipynb diff --git a/notebooks/experimental/ai_operators.ipynb b/notebooks/experimental/ai_operators.ipynb index 977f7b9d74..8aaa3f4b7c 100644 --- a/notebooks/experimental/ai_operators.ipynb +++ b/notebooks/experimental/ai_operators.ipynb @@ -29,3111 +29,11 @@ "id": "rWJnGj2ViouP" }, "source": [ - "# BigFrames AI Operator Tutorial\n", + "All AI operators except for `ai.forecast` have been deprecated.\n", "\n", - "\n", + "The tutorial notebook for AI functions is located at https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/generative_ai/ai_functions.ipynb\n", "\n", - " \n", - " \n", - " \n", - "
\n", - " \n", - " \"Colab Run in Colab\n", - " \n", - " \n", - " \n", - " \"GitHub\n", - " View on GitHub\n", - " \n", - " \n", - " \n", - " \"BQ\n", - " Open in BQ Studio\n", - " \n", - "
" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "mgOrr256iouQ" - }, - "source": [ - "This notebook provides a hands-on preview of AI operator APIs powered by the Gemini model.\n", - "\n", - "The notebook is divided into two sections. The first section introduces the API syntax with examples, aiming to familiarize you with how AI operators work. The second section applies AI operators to a large real-world dataset and presents performance statistics.\n", - "\n", - "This work is inspired by [this paper](https://arxiv.org/pdf/2407.11418) and powered by BigQuery ML and Vertex AI." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "2ymVbJV2iouQ" - }, - "source": [ - "# Preparation" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "vvVzFzo3iouQ" - }, - "source": [ - "First, import the BigFrames modules.\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": { - "id": "Jb9glT2ziouQ" - }, - "outputs": [], - "source": [ - "import bigframes\n", - "import bigframes.pandas as bpd" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "xQiCWj7OiouQ" - }, - "source": [ - "Make sure the BigFrames version is at least `1.42.0`" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": { - "id": "LTPpI8IpiouQ" - }, - "outputs": [], - "source": [ - "from packaging.version import Version\n", - "\n", - "assert Version(bigframes.__version__) >= Version(\"1.42.0\")" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "agxLmtlbiouR" - }, - "source": [ - "Turn on the AI operator experiment. You will see a warning sign saying that these operators are still under experiments. If you don't turn on the experiment before using the operators, you will get `NotImplemenetedError`s." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": { - "id": "1wXqdDr8iouR" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/_config/experiment_options.py:55: PreviewWarning: AI operators are still under experiments, and are subject to change in\n", - "the future.\n", - " warnings.warn(msg, category=bfe.PreviewWarning)\n" - ] - } - ], - "source": [ - "bigframes.options.experiments.ai_operators = True" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "W8TPUvnsqxhv" - }, - "source": [ - "Specify your GCP project and location." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": { - "id": "vCkraKOeqJFl" - }, - "outputs": [], - "source": [ - "bpd.options.bigquery.project = 'bigframes-dev'\n", - "bpd.options.bigquery.location = 'US'" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "n95MFlS0iouR" - }, - "source": [ - "**Optional**: turn off the display of progress bar so that only the operation results will be printed out" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": { - "id": "5r6ahx7MiouR" - }, - "outputs": [], - "source": [ - "bpd.options.display.progress_bar = None" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "93iYvp7niouR" - }, - "source": [ - "Create LLM instances. They will be passed in as parameters for each AI operator.\n", - "\n", - "This tutorial uses the \"gemini-2.0-flash-001\" model for text generation and \"text-embedding-005\" for embedding. While these are recommended, you can choose [other Vertex AI LLM models](https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models) based on your needs and availability. Ensure you have [sufficient quota](https://cloud.google.com/vertex-ai/generative-ai/docs/quotas) for your chosen models and adjust it if necessary." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": { - "id": "tHkymaLNiouR" - }, - "outputs": [], - "source": [ - "from bigframes.ml import llm\n", - "gemini_model = llm.GeminiTextGenerator(model_name=\"gemini-2.0-flash-001\")\n", - "text_embedding_model = llm.TextEmbeddingGenerator(model_name=\"text-embedding-005\")" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "mbFDcvnPiouR" - }, - "source": [ - "**Note**: AI operators could be expensive over a large set of data. As a result, our team added this option `bigframes.options.compute.ai_ops_confirmation_threshold` at `version 1.42.0` so that the BigFrames will ask for your confirmation if the amount of data to be processed is too large. If the amount of rows exceeds your threshold, you will see a prompt for your keyboard input -- 'y' to proceed and 'n' to abort. If you abort the operation, no LLM processing will be done.\n", - "\n", - "The default threshold is 0, which means the operators will always ask for confirmations. You are free to adjust the value as needed. You can also set the threshold to `None` to disable this feature." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": { - "id": "F4dZm4b7iouR" - }, - "outputs": [], - "source": [ - "if Version(bigframes.__version__) >= Version(\"1.42.0\"):\n", - " bigframes.options.compute.ai_ops_confirmation_threshold = 1000" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "_dEA3G9RiouR" - }, - "source": [ - "If you would like your operations to fail automatically when the data is too large, set `bigframes.options.compute.ai_ops_threshold_autofail` to `True`:" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": { - "id": "BoUK-cpbiouS" - }, - "outputs": [], - "source": [ - "# if Version(bigframes.__version__) >= Version(\"1.42.0\"):\n", - "# bigframes.options.compute.ai_ops_threshold_autofail = True" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "hQft3o3OiouS" - }, - "source": [ - "# API Examples" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "dt5Kl-QGiouS" - }, - "source": [ - "You will learn about each AI operator by trying some examples." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "J7XAT459iouS" - }, - "source": [ - "## AI Filtering" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "9d5HUIvliouS" - }, - "source": [ - "AI filtering allows you to filter your dataframe based on the instruction (i.e. prompt) you provided.\n", - "\n", - "First, create a dataframe:" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 190 - }, - "id": "NDpCRGd_iouS", - "outputId": "5048c935-06d3-4ef1-ad87-72e14a30b1b7" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
countrycity
0USASeattle
1GermanyBerlin
2JapanKyoto
\n", - "

3 rows × 2 columns

\n", - "
[3 rows x 2 columns in total]" - ], - "text/plain": [ - " country city\n", - "0 USA Seattle\n", - "1 Germany Berlin\n", - "2 Japan Kyoto\n", - "\n", - "[3 rows x 2 columns]" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df = bpd.DataFrame({'country': ['USA', 'Germany', 'Japan'], 'city': ['Seattle', 'Berlin', 'Kyoto']})\n", - "df" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "6AXmT7sniouS" - }, - "source": [ - "Now, filter this dataframe by keeping only the rows where the value in `city` column is the capital of the value in `country` column. The column references could be \"escaped\" by using a pair of braces in your instruction. In this example, your instruction should be like this:\n", - "```\n", - "The {city} is the capital of the {country}.\n", - "```\n", - "\n", - "Note that this is not a Python f-string, so you shouldn't prefix your instruction with an `f`." - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 127 - }, - "id": "ipW3Z_l4iouS", - "outputId": "ad447459-225a-419c-d4c8-fedac4a9ed0f" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:108: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\n", - " warnings.warn(msg, bfe.PreviewWarning)\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
countrycity
1GermanyBerlin
\n", - "

1 rows × 2 columns

\n", - "
[1 rows x 2 columns in total]" - ], - "text/plain": [ - " country city\n", - "1 Germany Berlin\n", - "\n", - "[1 rows x 2 columns]" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df.ai.filter(\"The {city} is the capital of the {country}\", model=gemini_model)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "swKvgfm1iouS" - }, - "source": [ - "The filter operator extracts the information from the referenced column to enrich your instruction with context. The instruction is then sent for the designated model for evaluation. For filtering operations, the LLM is asked to return only `True` and `False` for each row, and the operator removes the rows accordingly." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "r_2AAGGoiouS" - }, - "source": [ - "## AI Mapping" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "vT6skC57iouS" - }, - "source": [ - "AI mapping allows to you to combine values from multiple columns into a single output based your instruction.\n", - "\n", - "Here is an example:" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 190 - }, - "id": "BQ7xeUK3iouS", - "outputId": "33dcb742-77ed-4bea-8dbc-1cf775102a25" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
ingredient_1ingredient_2
0BunBeef Patty
1Soy BeanBittern
2SausageLong Bread
\n", - "

3 rows × 2 columns

\n", - "
[3 rows x 2 columns in total]" - ], - "text/plain": [ - " ingredient_1 ingredient_2\n", - "0 Bun Beef Patty\n", - "1 Soy Bean Bittern\n", - "2 Sausage Long Bread\n", - "\n", - "[3 rows x 2 columns]" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df = bpd.DataFrame({\n", - " \"ingredient_1\": [\"Bun\", \"Soy Bean\", \"Sausage\"],\n", - " \"ingredient_2\": [\"Beef Patty\", \"Bittern\", \"Long Bread\"]\n", - " })\n", - "df" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "VFObP2aFiouS" - }, - "source": [ - "Now, you ask LLM what kind of food can be made from the two ingredients in each row. The column reference syntax in your instruction stays the same. In addition, you need to specify the output column name." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "If you are using BigFrames version `2.5.0` or later, the column name is specified with the `output_schema` parameter. This parameter expects a dictionary input in the form of `{'col_name': 'type_name'}`." - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:108: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\n", - " warnings.warn(msg, bfe.PreviewWarning)\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
ingredient_1ingredient_2food
0BunBeef PattyHamburger
1Soy BeanBitternTofu
2SausageLong BreadHotdog
\n", - "

3 rows × 3 columns

\n", - "
[3 rows x 3 columns in total]" - ], - "text/plain": [ - " ingredient_1 ingredient_2 food\n", - "0 Bun Beef Patty Hamburger\n", - "1 Soy Bean Bittern Tofu\n", - "2 Sausage Long Bread Hotdog\n", - "\n", - "[3 rows x 3 columns]" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df.ai.map(\"What is the food made from {ingredient_1} and {ingredient_2}? One word only.\", model=gemini_model, output_schema={\"food\": \"string\"})" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "If you are using BigFrames version 2.4.0 or prior, the column name is specified wit the `output_column` parameter. The outputs are always strings." - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 190 - }, - "id": "PpL24AQFiouS", - "outputId": "e7aff038-bf4b-4833-def8-fe2648e8885b" - }, - "outputs": [], - "source": [ - "# df.ai.map(\"What is the food made from {ingredient_1} and {ingredient_2}? One word only.\", output_column=\"food\", model=gemini_model)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### AI Extraction\n", - "\n", - "AI mapping is also able to extract multiple pieces of information based on your prompt, because the output schema keys can carry semantic meanings:" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:108: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\n", - " warnings.warn(msg, bfe.PreviewWarning)\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
textpersonaddress
0Elmo lives at 123 Sesame Street.Elmo123 Sesame Street
1124 Conch Street is SpongeBob's homeSpongeBob124 Conch Street
\n", - "

2 rows × 3 columns

\n", - "
[2 rows x 3 columns in total]" - ], - "text/plain": [ - " text person address\n", - "0 Elmo lives at 123 Sesame Street. Elmo 123 Sesame Street\n", - "1 124 Conch Street is SpongeBob's home SpongeBob 124 Conch Street\n", - "\n", - "[2 rows x 3 columns]" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df = bpd.DataFrame({\n", - " \"text\": [\n", - " \"Elmo lives at 123 Sesame Street.\", \n", - " \"124 Conch Street is SpongeBob's home\",\n", - " ]\n", - "})\n", - "df.ai.map(\"{text}\", model=gemini_model, output_schema={\"person\": \"string\", \"address\": \"string\"})" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "70WTZZfdiouS" - }, - "source": [ - "## AI Joining" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "u93uieRaiouS" - }, - "source": [ - "AI joining can join two dataframes based on the instruction you provided.\n", - "\n", - "First, you prepare two dataframes:" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": { - "id": "dffIGEUEiouS" - }, - "outputs": [], - "source": [ - "cities = bpd.DataFrame({'city': ['Seattle', 'Ottawa', 'Berlin', 'Shanghai', 'New Delhi']})\n", - "continents = bpd.DataFrame({'continent': ['North America', 'Africa', 'Asia']})" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "Hz0X-0RtiouS" - }, - "source": [ - "You want to join the `cities` with `continents` to form a new dataframe such that, in each row the city from the `cities` data frame is in the continent from the `continents` dataframe. You could re-use the aforementioned column reference syntax:" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 221 - }, - "id": "WPIOHEwCiouT", - "outputId": "976586c3-b5db-4088-a46a-44dfbf822ecb" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:114: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\n", - " warnings.warn(msg, bfe.PreviewWarning)\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
citycontinent
0SeattleNorth America
1OttawaNorth America
2ShanghaiAsia
3New DelhiAsia
\n", - "

4 rows × 2 columns

\n", - "
[4 rows x 2 columns in total]" - ], - "text/plain": [ - " city continent\n", - "0 Seattle North America\n", - "1 Ottawa North America\n", - "2 Shanghai Asia\n", - "3 New Delhi Asia\n", - "\n", - "[4 rows x 2 columns]" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "cities.ai.join(continents, \"{city} is in {continent}\", model=gemini_model)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "4Qc97GMWiouT" - }, - "source": [ - "!! **Important:** AI join can trigger probihitively expensitve operations! This operation first cross joins two dataframes, then invokes AI filter on each row. That means if you have two dataframes of sizes `M` and `N`, the total amount of queries sent to the LLM is on the scale of `M * N`." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "MUEJXT1IiouT" - }, - "source": [ - "### Self Joins" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "QvX-nCogiouT" - }, - "source": [ - "This self-join example is for demonstrating a special case: what happens when the joining columns exist in both data frames? It turns out that you need to provide extra information in your column references: by attaching \"left.\" and \"right.\" prefixes to your column names.\n", - "\n", - "Create an example data frame:" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": { - "id": "OIGz5sqxiouW" - }, - "outputs": [], - "source": [ - "animals = bpd.DataFrame({'animal': ['cow', 'cat', 'spider', 'elephant']})" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "VmJbuWNniouX" - }, - "source": [ - "You want to compare the weights of these animals, and output all the pairs where the animal on the left is heavier than the animal on the right. In this case, you use `left.animal` and `right.animal` to differentiate the data sources:" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 284 - }, - "id": "UHfggdhBiouX", - "outputId": "a439e3aa-1382-4244-951f-127dc8da0fe3" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:114: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\n", - " warnings.warn(msg, bfe.PreviewWarning)\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
animal_leftanimal_right
0cowcat
1cowspider
2catspider
3elephantcow
4elephantcat
5elephantspider
\n", - "

6 rows × 2 columns

\n", - "
[6 rows x 2 columns in total]" - ], - "text/plain": [ - " animal_left animal_right\n", - "0 cow cat\n", - "1 cow spider\n", - "2 cat spider\n", - "3 elephant cow\n", - "4 elephant cat\n", - "5 elephant spider\n", - "\n", - "[6 rows x 2 columns]" - ] - }, - "execution_count": 17, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "animals.ai.join(animals, \"{left.animal} generally weighs heavier than {right.animal}\", model=gemini_model)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "sIszJ0zPiouX" - }, - "source": [ - "## AI Search" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "e4ojHRKAiouX" - }, - "source": [ - "AI search searches the most similar values to your query within a single column. Here is an example:" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 253 - }, - "id": "gnQSIZ5SiouX", - "outputId": "dd6e1ecb-1bad-4a7c-8065-e56c697d0863" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
creatures
0salmon
1sea urchin
2baboons
3frog
4chimpanzee
\n", - "

5 rows × 1 columns

\n", - "
[5 rows x 1 columns in total]" - ], - "text/plain": [ - " creatures\n", - "0 salmon\n", - "1 sea urchin\n", - "2 baboons\n", - "3 frog\n", - "4 chimpanzee\n", - "\n", - "[5 rows x 1 columns]" - ] - }, - "execution_count": 20, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df = bpd.DataFrame({\"creatures\": [\"salmon\", \"sea urchin\", \"baboons\", \"frog\", \"chimpanzee\"]})\n", - "df" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "5apfIaZMiouX" - }, - "source": [ - "You want to get the top 2 creatures that are most similar to \"monkey\":" - ] - }, - { - "cell_type": "code", - "execution_count": 21, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 159 - }, - "id": "CkAuFgPYiouY", - "outputId": "723c7604-f53c-43d7-c754-4c91ec198dff" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:114: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\n", - " warnings.warn(msg, bfe.PreviewWarning)\n", - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:114: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\n", - " warnings.warn(msg, bfe.PreviewWarning)\n", - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:114: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\n", - " warnings.warn(msg, bfe.PreviewWarning)\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
creaturessimilarity score
2baboons0.708434
4chimpanzee0.635844
\n", - "

2 rows × 2 columns

\n", - "
[2 rows x 2 columns in total]" - ], - "text/plain": [ - " creatures similarity score\n", - "2 baboons 0.708434\n", - "4 chimpanzee 0.635844\n", - "\n", - "[2 rows x 2 columns]" - ] - }, - "execution_count": 21, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df.ai.search(\"creatures\", query=\"monkey\", top_k = 2, model = text_embedding_model, score_column='similarity score')" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "GDZeVzFTiouY" - }, - "source": [ - "Note that you are using a text embedding model this time. This model generates embedding vectors for both your query as well as the values in the search space. The operator then uses BigQuery's built-in VECTOR_SEARCH function to find the nearest neighbors of your query.\n", - "\n", - "In addition, `score_column` is an optional parameter for storing the distances between the results and your query. If not set, the score column won't be attached to the result." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "EXNutIXqiouY" - }, - "source": [ - "## AI Similarity Join" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "BhWrhQMjiouY" - }, - "source": [ - "When you want to perform multiple similarity queries in the same value space, you could use similarity join to simplify your call. For example:" - ] - }, - { - "cell_type": "code", - "execution_count": 22, - "metadata": { - "id": "cUc7-8O6iouY" - }, - "outputs": [], - "source": [ - "df1 = bpd.DataFrame({'animal': ['monkey', 'spider', 'salmon', 'giraffe', 'sparrow']})\n", - "df2 = bpd.DataFrame({'animal': ['scorpion', 'baboon', 'owl', 'elephant', 'tuna']})" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "k96WerOviouY" - }, - "source": [ - "In this example, you want to pick the most related animal from `df2` for each value in `df1`." - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 253 - }, - "id": "wPV5EkfpiouY", - "outputId": "4be1211d-0353-4b94-8c27-ebd568e8e104" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:114: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\n", - " warnings.warn(msg, bfe.PreviewWarning)\n", - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:114: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\n", - " warnings.warn(msg, bfe.PreviewWarning)\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
animalanimal_1distance
0monkeybaboon0.620521
1spiderscorpion0.728024
2salmontuna0.782141
3giraffeelephant0.7135
4sparrowowl0.810864
\n", - "

5 rows × 3 columns

\n", - "
[5 rows x 3 columns in total]" - ], - "text/plain": [ - " animal animal_1 distance\n", - "0 monkey baboon 0.620521\n", - "1 spider scorpion 0.728024\n", - "2 salmon tuna 0.782141\n", - "3 giraffe elephant 0.7135\n", - "4 sparrow owl 0.810864\n", - "\n", - "[5 rows x 3 columns]" - ] - }, - "execution_count": 23, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df1.ai.sim_join(df2, left_on='animal', right_on='animal', top_k=1, model=text_embedding_model, score_column='distance')" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "GplzD7v0iouY" - }, - "source": [ - "!! **Important** Like AI join, this operator can also be very expensive. To guard against unexpected processing of large dataset, use the `bigframes.options.compute.sem_ops_confirmation_threshold` option to specify a threshold." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "hgj8GoQhiouY" - }, - "source": [ - "# Performance Analyses" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "EZomL0BciouY" - }, - "source": [ - "In this section, you will use BigQuery's public data of hacker news to perform some heavy work. We recommend you to check the code without executing them in order to save your time and money. The execution results are attached after each cell for your reference.\n", - "\n", - "First, load 3k rows from the table:" - ] - }, - { - "cell_type": "code", - "execution_count": 24, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 880 - }, - "id": "wRR0SrcSiouY", - "outputId": "3b25f3a3-09c7-4396-9107-4aa4cdb4b963" - }, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
titletextbyscoretimestamptype
0<NA><NA><NA><NA>2010-04-16 19:52:51+00:00comment
1<NA>I&#x27;d agree about border control with a cav...bandrami<NA>2023-06-04 06:12:00+00:00comment
2<NA>So 4 pickups? At least pickups are high margin...seanmcdirmid<NA>2023-09-19 14:19:46+00:00comment
3Workplace Wellness Programs Don’t Work Well. W...<NA>anarbadalov22018-08-07 12:17:45+00:00story
4<NA>Are you implying that to be a good developer y...ecesena<NA>2016-06-10 19:38:25+00:00comment
5<NA>It pretty much works with other carriers. My s...toast0<NA>2024-08-13 03:11:32+00:00comment
6<NA><NA><NA><NA>2020-06-07 22:43:03+00:00comment
7<NA>&quot;not operated for profit&quot; and &quot;...radford-neal<NA>2020-03-19 00:24:47+00:00comment
8<NA>It&#x27;s a good description of one applicatio...dkarl<NA>2024-10-07 13:38:18+00:00comment
9<NA>Might be a bit high, but....<p><i>&quot;For ex...tyingq<NA>2017-01-23 19:49:15+00:00comment
10Taiwan’s Tech King to Nancy Pelosi: U.S. Is in...<NA>dlcmh112023-02-18 02:51:11+00:00story
11Android’s new multitasking is terrible and sho...<NA>wowamit12018-10-22 09:50:36+00:00story
12<NA>SEEKING WORK | REMOTE | US Citizen<p>Location:...rasikjain<NA>2024-08-01 16:56:49+00:00comment
13<NA>I had a very similar experience last month tea...tmaly<NA>2020-01-22 18:26:36+00:00comment
14<NA><NA>mrtweetyhack<NA>2022-02-26 19:34:00+00:00comment
15<NA>&gt; Just do what most American cities do with...AnthonyMouse<NA>2021-10-04 23:10:50+00:00comment
16<NA>It&#x27;s not a space. The l and the C are at ...antninja<NA>2013-07-13 09:48:34+00:00comment
17<NA>I’ve knowingly paid the premium in the past, j...zwily<NA>2020-06-17 14:26:43+00:00comment
18<NA>&gt; Any sufficiently complicated C or Fortran...wavemode<NA>2025-02-07 06:42:53+00:00comment
19<NA>It&#x27;s similar to a lot of Japanese &quot;t...TillE<NA>2022-11-06 17:15:10+00:00comment
20<NA>Engineers are just people paid to code. If you...rchaud<NA>2023-04-12 14:31:42+00:00comment
21<NA>So don&#x27;t use itCyberDildonics<NA>2015-12-29 22:01:16+00:00comment
22<NA>Sure, but there are degrees of these things. T...dang<NA>2021-11-11 23:42:12+00:00comment
23<NA>I wish this would happen. There&#x27;s a &quo...coredog64<NA>2018-02-12 16:03:37+00:00comment
24<NA>I’m not sure why responsible riders wouldn’t w...mjmahone17<NA>2021-11-09 01:36:01+00:00comment
\n", - "

25 rows × 6 columns

\n", - "
[3000 rows x 6 columns in total]" - ], - "text/plain": [ - " title \\\n", - "0 \n", - "1 \n", - "2 \n", - "3 Workplace Wellness Programs Don’t Work Well. W... \n", - "4 \n", - "5 \n", - "6 \n", - "7 \n", - "8 \n", - "9 \n", - "10 Taiwan’s Tech King to Nancy Pelosi: U.S. Is in... \n", - "11 Android’s new multitasking is terrible and sho... \n", - "12 \n", - "13 \n", - "14 \n", - "15 \n", - "16 \n", - "17 \n", - "18 \n", - "19 \n", - "20 \n", - "21 \n", - "22 \n", - "23 \n", - "24 \n", - "\n", - " text by score \\\n", - "0 \n", - "1 I'd agree about border control with a cav... bandrami \n", - "2 So 4 pickups? At least pickups are high margin... seanmcdirmid \n", - "3 anarbadalov 2 \n", - "4 Are you implying that to be a good developer y... ecesena \n", - "5 It pretty much works with other carriers. My s... toast0 \n", - "6 \n", - "7 "not operated for profit" and "... radford-neal \n", - "8 It's a good description of one applicatio... dkarl \n", - "9 Might be a bit high, but....

"For ex... tyingq \n", - "10 dlcmh 11 \n", - "11 wowamit 1 \n", - "12 SEEKING WORK | REMOTE | US Citizen

Location:... rasikjain \n", - "13 I had a very similar experience last month tea... tmaly \n", - "14 mrtweetyhack \n", - "15 > Just do what most American cities do with... AnthonyMouse \n", - "16 It's not a space. The l and the C are at ... antninja \n", - "17 I’ve knowingly paid the premium in the past, j... zwily \n", - "18 > Any sufficiently complicated C or Fortran... wavemode \n", - "19 It's similar to a lot of Japanese "t... TillE \n", - "20 Engineers are just people paid to code. If you... rchaud \n", - "21 So don't use it CyberDildonics \n", - "22 Sure, but there are degrees of these things. T... dang \n", - "23 I wish this would happen. There's a &quo... coredog64 \n", - "24 I’m not sure why responsible riders wouldn’t w... mjmahone17 \n", - "\n", - " timestamp type \n", - "0 2010-04-16 19:52:51+00:00 comment \n", - "1 2023-06-04 06:12:00+00:00 comment \n", - "2 2023-09-19 14:19:46+00:00 comment \n", - "3 2018-08-07 12:17:45+00:00 story \n", - "4 2016-06-10 19:38:25+00:00 comment \n", - "5 2024-08-13 03:11:32+00:00 comment \n", - "6 2020-06-07 22:43:03+00:00 comment \n", - "7 2020-03-19 00:24:47+00:00 comment \n", - "8 2024-10-07 13:38:18+00:00 comment \n", - "9 2017-01-23 19:49:15+00:00 comment \n", - "10 2023-02-18 02:51:11+00:00 story \n", - "11 2018-10-22 09:50:36+00:00 story \n", - "12 2024-08-01 16:56:49+00:00 comment \n", - "13 2020-01-22 18:26:36+00:00 comment \n", - "14 2022-02-26 19:34:00+00:00 comment \n", - "15 2021-10-04 23:10:50+00:00 comment \n", - "16 2013-07-13 09:48:34+00:00 comment \n", - "17 2020-06-17 14:26:43+00:00 comment \n", - "18 2025-02-07 06:42:53+00:00 comment \n", - "19 2022-11-06 17:15:10+00:00 comment \n", - "20 2023-04-12 14:31:42+00:00 comment \n", - "21 2015-12-29 22:01:16+00:00 comment \n", - "22 2021-11-11 23:42:12+00:00 comment \n", - "23 2018-02-12 16:03:37+00:00 comment \n", - "24 2021-11-09 01:36:01+00:00 comment \n", - "...\n", - "\n", - "[3000 rows x 6 columns]" - ] - }, - "execution_count": 24, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "hacker_news = bpd.read_gbq(\"bigquery-public-data.hacker_news.full\")[['title', 'text', 'by', 'score', 'timestamp', 'type']].head(3000)\n", - "hacker_news" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "3e94DPOdiouY" - }, - "source": [ - "Then, keep only the rows that have text content:" - ] - }, - { - "cell_type": "code", - "execution_count": 25, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "mQl8hc1biouY", - "outputId": "2b4ffa85-9d95-4a20-9040-0420c67da2d4" - }, - "outputs": [ - { - "data": { - "text/plain": [ - "2533" - ] - }, - "execution_count": 25, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "hacker_news_with_texts = hacker_news[hacker_news['text'].isnull() == False]\n", - "len(hacker_news_with_texts)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "JWalDtLDiouZ" - }, - "source": [ - "You can get an idea of the input token length by calculating the average string length." - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" - }, - "id": "PZeg4LCUiouZ", - "outputId": "05b67cac-6b3d-42ef-d6d6-b578a9734f4c" - }, - "outputs": [ - { - "data": { - "text/plain": [ - "393.2356889064355" - ] - }, - "execution_count": 26, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "hacker_news_with_texts['text'].str.len().mean()" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "2IXqskHHiouZ" - }, - "source": [ - "**Optional**: You can raise the confirmation threshold for a smoother experience." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "EpjXQ4FViouZ" - }, - "outputs": [], - "source": [ - "if Version(bigframes.__version__) >= Version(\"1.42.0\"):\n", - " bigframes.options.compute.ai_ops_confirmation_threshold = 5000" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "SYFB-X1RiouZ" - }, - "source": [ - "Now it's LLM's turn. You want to keep only the rows whose texts are talking about iPhone. This will take several minutes to finish." - ] - }, - { - "cell_type": "code", - "execution_count": 28, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 253 - }, - "id": "rditQlmoiouZ", - "outputId": "2b44dcbf-2ef5-4119-ca05-9b082db9c0c1" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:114: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\n", - " warnings.warn(msg, bfe.PreviewWarning)\n" - ] - }, - { - "data": { - "text/html": [ - "

\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
titletextbyscoretimestamptype
445<NA>If I want to manipulate a device, I&#x27;ll bu...exelius<NA>2017-09-21 17:39:37+00:00comment
967<NA><a href=\"https:&#x2F;&#x2F;archive.ph&#x2F;nnE...blinding-streak<NA>2023-04-30 19:10:16+00:00comment
975<NA>I&#x27;ve had my 6S Plus now for 36 months and...throwaway427<NA>2019-01-03 18:06:33+00:00comment
1253<NA>Apple is far more closed and tyrannical with i...RyanMcGreal<NA>2012-12-21 00:45:40+00:00comment
1274<NA>An iOS version was released earlier this year....pls2halp<NA>2017-12-09 06:36:41+00:00comment
1548<NA>I’m not sure how that fits with Apple pursuing...alphabettsy<NA>2021-12-26 19:41:38+00:00comment
1630<NA>Not sure if you’re being ironic, but I use an ...lxgr<NA>2025-03-29 03:57:25+00:00comment
1664<NA>Quoting from the article I linked you:<p>&gt;&...StreamBright<NA>2017-09-11 19:57:34+00:00comment
1884<NA>&gt; Not all wireless headsets are the same, h...cptskippy<NA>2021-11-16 13:28:44+00:00comment
2251<NA>Will not buy any more apple product, iphone 4s...omi<NA>2012-09-11 14:42:52+00:00comment
2877<NA>I&#x27;ve been an iPhone user since the OG in ...vsnf<NA>2024-04-15 06:28:09+00:00comment
\n", - "

11 rows × 6 columns

\n", - "
[11 rows x 6 columns in total]" - ], - "text/plain": [ - " title text \\\n", - "445 If I want to manipulate a device, I'll bu... \n", - "967 I've had my 6S Plus now for 36 months and... \n", - "1253 Apple is far more closed and tyrannical with i... \n", - "1274 An iOS version was released earlier this year.... \n", - "1548 I’m not sure how that fits with Apple pursuing... \n", - "1630 Not sure if you’re being ironic, but I use an ... \n", - "1664 Quoting from the article I linked you:

>&... \n", - "1884 > Not all wireless headsets are the same, h... \n", - "2251 Will not buy any more apple product, iphone 4s... \n", - "2877 I've been an iPhone user since the OG in ... \n", - "\n", - " by score timestamp type \n", - "445 exelius 2017-09-21 17:39:37+00:00 comment \n", - "967 blinding-streak 2023-04-30 19:10:16+00:00 comment \n", - "975 throwaway427 2019-01-03 18:06:33+00:00 comment \n", - "1253 RyanMcGreal 2012-12-21 00:45:40+00:00 comment \n", - "1274 pls2halp 2017-12-09 06:36:41+00:00 comment \n", - "1548 alphabettsy 2021-12-26 19:41:38+00:00 comment \n", - "1630 lxgr 2025-03-29 03:57:25+00:00 comment \n", - "1664 StreamBright 2017-09-11 19:57:34+00:00 comment \n", - "1884 cptskippy 2021-11-16 13:28:44+00:00 comment \n", - "2251 omi 2012-09-11 14:42:52+00:00 comment \n", - "2877 vsnf 2024-04-15 06:28:09+00:00 comment \n", - "\n", - "[11 rows x 6 columns]" - ] - }, - "execution_count": 28, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "iphone_comments = hacker_news_with_texts.ai.filter(\"The {text} is mainly focused on iPhone\", gemini_model)\n", - "iphone_comments" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "yl24sJFIiouZ" - }, - "source": [ - "The performance of the ai operators depends on the length of your input as well as your quota. Here are our benchmarks for running the previous operation with Gemini Flash 1.5 over data of different sizes. Here are the estimates supposing your quota is [the default 200 requests per minute](https://cloud.google.com/vertex-ai/generative-ai/docs/quotas):\n", - "\n", - "* 800 Rows -> ~4m\n", - "* 2550 Rows -> ~13m\n", - "* 8500 Rows -> ~40m\n", - "\n", - "These numbers can give you a general idea of how fast the operators run." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "eo4nfISuiouZ" - }, - "source": [ - "Now, use LLM to summarize the sentiments towards iPhone:" - ] - }, - { - "cell_type": "code", - "execution_count": 29, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 253 - }, - "id": "IlKBrNxUiouZ", - "outputId": "818d01e4-1cdf-42a2-9e02-61c4736a8905" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:114: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\n", - " warnings.warn(msg, bfe.PreviewWarning)\n" - ] - }, - { - "data": { - "text/html": [ - "

\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
titletextbyscoretimestamptypesentiment
445<NA>If I want to manipulate a device, I&#x27;ll bu...exelius<NA>2017-09-21 17:39:37+00:00commentPragmatic, slightly annoyed
967<NA><a href=\"https:&#x2F;&#x2F;archive.ph&#x2F;nnE...blinding-streak<NA>2023-04-30 19:10:16+00:00commentI lack the ability to access external websites...
975<NA>I&#x27;ve had my 6S Plus now for 36 months and...throwaway427<NA>2019-01-03 18:06:33+00:00commentGenerally positive, impressed.
1253<NA>Apple is far more closed and tyrannical with i...RyanMcGreal<NA>2012-12-21 00:45:40+00:00commentNegative towards Apple
1274<NA>An iOS version was released earlier this year....pls2halp<NA>2017-12-09 06:36:41+00:00commentNeutral, factual statement.
1548<NA>I’m not sure how that fits with Apple pursuing...alphabettsy<NA>2021-12-26 19:41:38+00:00commentSkeptical and critical.
1630<NA>Not sure if you’re being ironic, but I use an ...lxgr<NA>2025-03-29 03:57:25+00:00commentWants interoperability, frustrated.
1664<NA>Quoting from the article I linked you:<p>&gt;&...StreamBright<NA>2017-09-11 19:57:34+00:00commentExtremely positive review
1884<NA>&gt; Not all wireless headsets are the same, h...cptskippy<NA>2021-11-16 13:28:44+00:00commentSkeptical and critical
2251<NA>Will not buy any more apple product, iphone 4s...omi<NA>2012-09-11 14:42:52+00:00commentNegative, regretful.
2877<NA>I&#x27;ve been an iPhone user since the OG in ...vsnf<NA>2024-04-15 06:28:09+00:00commentMildly annoyed, resigned
\n", - "

11 rows × 7 columns

\n", - "
[11 rows x 7 columns in total]" - ], - "text/plain": [ - " title text \\\n", - "445 If I want to manipulate a device, I'll bu... \n", - "967
I've had my 6S Plus now for 36 months and... \n", - "1253 Apple is far more closed and tyrannical with i... \n", - "1274 An iOS version was released earlier this year.... \n", - "1548 I’m not sure how that fits with Apple pursuing... \n", - "1630 Not sure if you’re being ironic, but I use an ... \n", - "1664 Quoting from the article I linked you:

>&... \n", - "1884 > Not all wireless headsets are the same, h... \n", - "2251 Will not buy any more apple product, iphone 4s... \n", - "2877 I've been an iPhone user since the OG in ... \n", - "\n", - " by score timestamp type \\\n", - "445 exelius 2017-09-21 17:39:37+00:00 comment \n", - "967 blinding-streak 2023-04-30 19:10:16+00:00 comment \n", - "975 throwaway427 2019-01-03 18:06:33+00:00 comment \n", - "1253 RyanMcGreal 2012-12-21 00:45:40+00:00 comment \n", - "1274 pls2halp 2017-12-09 06:36:41+00:00 comment \n", - "1548 alphabettsy 2021-12-26 19:41:38+00:00 comment \n", - "1630 lxgr 2025-03-29 03:57:25+00:00 comment \n", - "1664 StreamBright 2017-09-11 19:57:34+00:00 comment \n", - "1884 cptskippy 2021-11-16 13:28:44+00:00 comment \n", - "2251 omi 2012-09-11 14:42:52+00:00 comment \n", - "2877 vsnf 2024-04-15 06:28:09+00:00 comment \n", - "\n", - " sentiment \n", - "445 Pragmatic, slightly annoyed\n", - " \n", - "967 I lack the ability to access external websites... \n", - "975 Generally positive, impressed.\n", - " \n", - "1253 Negative towards Apple\n", - " \n", - "1274 Neutral, factual statement.\n", - " \n", - "1548 Skeptical and critical.\n", - " \n", - "1630 Wants interoperability, frustrated.\n", - " \n", - "1664 Extremely positive review\n", - " \n", - "1884 Skeptical and critical\n", - " \n", - "2251 Negative, regretful.\n", - " \n", - "2877 Mildly annoyed, resigned\n", - " \n", - "\n", - "[11 rows x 7 columns]" - ] - }, - "execution_count": 29, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "iphone_comments.ai.map(\"Summarize the sentiment of the {text}. Your answer should have at most 3 words\", output_column=\"sentiment\", model=gemini_model)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "y7_16T2xiouZ" - }, - "source": [ - "Here is another example: count the number of rows whose authors have animals in their names." - ] - }, - { - "cell_type": "code", - "execution_count": 30, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 880 - }, - "id": "CbGwc_uXiouZ", - "outputId": "138acca0-7fb9-495a-e797-0d42495d65e6" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/venv/lib/python3.11/site-packages/IPython/core/interactiveshell.py:3577: UserWarning: Reading cached table from 2025-04-02 18:00:55.801294+00:00 to avoid\n", - "incompatibilies with previous reads of this table. To read the latest\n", - "version, set `use_cache=False` or close the current session with\n", - "Session.close() or bigframes.pandas.close_session().\n", - " exec(code_obj, self.user_global_ns, self.user_ns)\n" - ] - }, - { - "data": { - "text/html": [ - "

\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
titletextbyscoretimestamptype
0<NA><NA><NA><NA>2010-04-16 19:52:51+00:00comment
1<NA>I&#x27;d agree about border control with a cav...bandrami<NA>2023-06-04 06:12:00+00:00comment
2<NA>So 4 pickups? At least pickups are high margin...seanmcdirmid<NA>2023-09-19 14:19:46+00:00comment
3Workplace Wellness Programs Don’t Work Well. W...<NA>anarbadalov22018-08-07 12:17:45+00:00story
4<NA>Are you implying that to be a good developer y...ecesena<NA>2016-06-10 19:38:25+00:00comment
5<NA>It pretty much works with other carriers. My s...toast0<NA>2024-08-13 03:11:32+00:00comment
6<NA><NA><NA><NA>2020-06-07 22:43:03+00:00comment
7<NA>&quot;not operated for profit&quot; and &quot;...radford-neal<NA>2020-03-19 00:24:47+00:00comment
8<NA>It&#x27;s a good description of one applicatio...dkarl<NA>2024-10-07 13:38:18+00:00comment
9<NA>Might be a bit high, but....<p><i>&quot;For ex...tyingq<NA>2017-01-23 19:49:15+00:00comment
10Taiwan’s Tech King to Nancy Pelosi: U.S. Is in...<NA>dlcmh112023-02-18 02:51:11+00:00story
11Android’s new multitasking is terrible and sho...<NA>wowamit12018-10-22 09:50:36+00:00story
12<NA>SEEKING WORK | REMOTE | US Citizen<p>Location:...rasikjain<NA>2024-08-01 16:56:49+00:00comment
13<NA>I had a very similar experience last month tea...tmaly<NA>2020-01-22 18:26:36+00:00comment
14<NA><NA>mrtweetyhack<NA>2022-02-26 19:34:00+00:00comment
15<NA>&gt; Just do what most American cities do with...AnthonyMouse<NA>2021-10-04 23:10:50+00:00comment
16<NA>It&#x27;s not a space. The l and the C are at ...antninja<NA>2013-07-13 09:48:34+00:00comment
17<NA>I’ve knowingly paid the premium in the past, j...zwily<NA>2020-06-17 14:26:43+00:00comment
18<NA>&gt; Any sufficiently complicated C or Fortran...wavemode<NA>2025-02-07 06:42:53+00:00comment
19<NA>It&#x27;s similar to a lot of Japanese &quot;t...TillE<NA>2022-11-06 17:15:10+00:00comment
20<NA>Engineers are just people paid to code. If you...rchaud<NA>2023-04-12 14:31:42+00:00comment
21<NA>So don&#x27;t use itCyberDildonics<NA>2015-12-29 22:01:16+00:00comment
22<NA>Sure, but there are degrees of these things. T...dang<NA>2021-11-11 23:42:12+00:00comment
23<NA>I wish this would happen. There&#x27;s a &quo...coredog64<NA>2018-02-12 16:03:37+00:00comment
24<NA>I’m not sure why responsible riders wouldn’t w...mjmahone17<NA>2021-11-09 01:36:01+00:00comment
\n", - "

25 rows × 6 columns

\n", - "
[3000 rows x 6 columns in total]" - ], - "text/plain": [ - " title \\\n", - "0 \n", - "1 \n", - "2 \n", - "3 Workplace Wellness Programs Don’t Work Well. W... \n", - "4 \n", - "5 \n", - "6 \n", - "7 \n", - "8 \n", - "9 \n", - "10 Taiwan’s Tech King to Nancy Pelosi: U.S. Is in... \n", - "11 Android’s new multitasking is terrible and sho... \n", - "12 \n", - "13 \n", - "14 \n", - "15 \n", - "16 \n", - "17 \n", - "18 \n", - "19 \n", - "20 \n", - "21 \n", - "22 \n", - "23 \n", - "24 \n", - "\n", - " text by score \\\n", - "0 \n", - "1 I'd agree about border control with a cav... bandrami \n", - "2 So 4 pickups? At least pickups are high margin... seanmcdirmid \n", - "3 anarbadalov 2 \n", - "4 Are you implying that to be a good developer y... ecesena \n", - "5 It pretty much works with other carriers. My s... toast0 \n", - "6 \n", - "7 "not operated for profit" and "... radford-neal \n", - "8 It's a good description of one applicatio... dkarl \n", - "9 Might be a bit high, but....

"For ex... tyingq \n", - "10 dlcmh 11 \n", - "11 wowamit 1 \n", - "12 SEEKING WORK | REMOTE | US Citizen

Location:... rasikjain \n", - "13 I had a very similar experience last month tea... tmaly \n", - "14 mrtweetyhack \n", - "15 > Just do what most American cities do with... AnthonyMouse \n", - "16 It's not a space. The l and the C are at ... antninja \n", - "17 I’ve knowingly paid the premium in the past, j... zwily \n", - "18 > Any sufficiently complicated C or Fortran... wavemode \n", - "19 It's similar to a lot of Japanese "t... TillE \n", - "20 Engineers are just people paid to code. If you... rchaud \n", - "21 So don't use it CyberDildonics \n", - "22 Sure, but there are degrees of these things. T... dang \n", - "23 I wish this would happen. There's a &quo... coredog64 \n", - "24 I’m not sure why responsible riders wouldn’t w... mjmahone17 \n", - "\n", - " timestamp type \n", - "0 2010-04-16 19:52:51+00:00 comment \n", - "1 2023-06-04 06:12:00+00:00 comment \n", - "2 2023-09-19 14:19:46+00:00 comment \n", - "3 2018-08-07 12:17:45+00:00 story \n", - "4 2016-06-10 19:38:25+00:00 comment \n", - "5 2024-08-13 03:11:32+00:00 comment \n", - "6 2020-06-07 22:43:03+00:00 comment \n", - "7 2020-03-19 00:24:47+00:00 comment \n", - "8 2024-10-07 13:38:18+00:00 comment \n", - "9 2017-01-23 19:49:15+00:00 comment \n", - "10 2023-02-18 02:51:11+00:00 story \n", - "11 2018-10-22 09:50:36+00:00 story \n", - "12 2024-08-01 16:56:49+00:00 comment \n", - "13 2020-01-22 18:26:36+00:00 comment \n", - "14 2022-02-26 19:34:00+00:00 comment \n", - "15 2021-10-04 23:10:50+00:00 comment \n", - "16 2013-07-13 09:48:34+00:00 comment \n", - "17 2020-06-17 14:26:43+00:00 comment \n", - "18 2025-02-07 06:42:53+00:00 comment \n", - "19 2022-11-06 17:15:10+00:00 comment \n", - "20 2023-04-12 14:31:42+00:00 comment \n", - "21 2015-12-29 22:01:16+00:00 comment \n", - "22 2021-11-11 23:42:12+00:00 comment \n", - "23 2018-02-12 16:03:37+00:00 comment \n", - "24 2021-11-09 01:36:01+00:00 comment \n", - "...\n", - "\n", - "[3000 rows x 6 columns]" - ] - }, - "execution_count": 30, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "hacker_news = bpd.read_gbq(\"bigquery-public-data.hacker_news.full\")[['title', 'text', 'by', 'score', 'timestamp', 'type']].head(3000)\n", - "hacker_news" - ] - }, - { - "cell_type": "code", - "execution_count": 31, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 880 - }, - "id": "9dzU8SNziouZ", - "outputId": "da8815c1-c411-4afc-d1ca-5e44c75b5b48" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:114: PreviewWarning: JSON column interpretation as a custom PyArrow extention in\n", - "`db_dtypes` is a preview feature and subject to change.\n", - " warnings.warn(msg, bfe.PreviewWarning)\n" - ] - }, - { - "data": { - "text/html": [ - "

\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
titletextbyscoretimestamptype
15<NA>&gt; Just do what most American cities do with...AnthonyMouse<NA>2021-10-04 23:10:50+00:00comment
16<NA>It&#x27;s not a space. The l and the C are at ...antninja<NA>2013-07-13 09:48:34+00:00comment
23<NA>I wish this would happen. There&#x27;s a &quo...coredog64<NA>2018-02-12 16:03:37+00:00comment
27<NA>Flash got close, but was too complex and expen...surfingdino<NA>2024-05-08 05:02:37+00:00comment
36<NA>I think the &quot;algo genius&quot; type of de...poisonborz<NA>2024-06-04 07:39:08+00:00comment
150<NA>No one will be doing anything practical with a...NeutralCrane<NA>2025-02-01 14:26:25+00:00comment
160<NA>I think this is more semantics than anything.<...superb-owl<NA>2022-06-08 16:55:54+00:00comment
205<NA>Interesting to think of sign language localisa...robin_reala<NA>2019-02-01 11:49:23+00:00comment
231<NA>Probably because of their key location.ape4<NA>2014-08-29 14:55:40+00:00comment
250<NA>I realize this is a bit passe, but there were ...FeepingCreature<NA>2023-10-15 11:32:44+00:00comment
320Protest against Bill C-11, Canada's SOPA, plan...<NA>magikarp12012-01-29 02:14:12+00:00story
344<NA>What? Are you suggesting we cannot criticize p...chickenpotpie<NA>2020-12-02 18:24:19+00:00comment
348The flu vaccine this year is only 10% effective<NA>maryfoxmarlow32018-02-02 02:19:42+00:00story
360<NA>Bomb ownership is okay AFAIK. Intent to commi...Ferret7446<NA>2023-06-25 20:04:30+00:00comment
3981 + 1 = 3<NA>oscar-the-horse22012-08-05 22:18:51+00:00story
407<NA>No (almost certainly), but you will become fru...AnimalMuppet<NA>2023-09-15 16:11:08+00:00comment
454<NA>48h is less than 5 kWh of batteries, one quart...tigershark<NA>2021-07-23 05:12:52+00:00comment
457Brazilian Rails Websites<NA>akitaonrails12008-07-27 17:27:47+00:00story
472<NA>&gt; When most people start as programmers, th...PavlovsCat<NA>2018-12-23 20:37:20+00:00comment
493<NA>Related anecdata + a study I found useful. Aft...TrainedMonkey<NA>2023-02-02 16:14:23+00:00comment
497<NA>That &quot;civilized&quot; country has too man...rantanplan<NA>2017-02-17 12:51:51+00:00comment
514<NA>The current Go 2 drafts do.tapirl<NA>2020-08-12 02:37:41+00:00comment
535<NA>Having walked this same path, this blog resona...curiousllama<NA>2020-10-07 20:35:18+00:00comment
607<NA>If people thought the reward for talking to a ...slapfrog<NA>2021-09-08 20:58:13+00:00comment
672<NA>Given that you say you&#x27;re 38 and looking ...strix_varius<NA>2023-08-04 02:41:50+00:00comment
\n", - "

25 rows × 6 columns

\n", - "
[112 rows x 6 columns in total]" - ], - "text/plain": [ - " title \\\n", - "15 \n", - "16 \n", - "23 \n", - "27 \n", - "36 \n", - "150 \n", - "160 \n", - "205 \n", - "231 \n", - "250 \n", - "320 Protest against Bill C-11, Canada's SOPA, plan... \n", - "344 \n", - "348 The flu vaccine this year is only 10% effective \n", - "360 \n", - "398 1 + 1 = 3 \n", - "407 \n", - "454 \n", - "457 Brazilian Rails Websites \n", - "472 \n", - "493 \n", - "497 \n", - "514 \n", - "535 \n", - "607 \n", - "672 \n", - "\n", - " text by \\\n", - "15 > Just do what most American cities do with... AnthonyMouse \n", - "16 It's not a space. The l and the C are at ... antninja \n", - "23 I wish this would happen. There's a &quo... coredog64 \n", - "27 Flash got close, but was too complex and expen... surfingdino \n", - "36 I think the "algo genius" type of de... poisonborz \n", - "150 No one will be doing anything practical with a... NeutralCrane \n", - "160 I think this is more semantics than anything.<... superb-owl \n", - "205 Interesting to think of sign language localisa... robin_reala \n", - "231 Probably because of their key location. ape4 \n", - "250 I realize this is a bit passe, but there were ... FeepingCreature \n", - "320 magikarp \n", - "344 What? Are you suggesting we cannot criticize p... chickenpotpie \n", - "348 maryfoxmarlow \n", - "360 Bomb ownership is okay AFAIK. Intent to commi... Ferret7446 \n", - "398 oscar-the-horse \n", - "407 No (almost certainly), but you will become fru... AnimalMuppet \n", - "454 48h is less than 5 kWh of batteries, one quart... tigershark \n", - "457 akitaonrails \n", - "472 > When most people start as programmers, th... PavlovsCat \n", - "493 Related anecdata + a study I found useful. Aft... TrainedMonkey \n", - "497 That "civilized" country has too man... rantanplan \n", - "514 The current Go 2 drafts do. tapirl \n", - "535 Having walked this same path, this blog resona... curiousllama \n", - "607 If people thought the reward for talking to a ... slapfrog \n", - "672 Given that you say you're 38 and looking ... strix_varius \n", - "\n", - " score timestamp type \n", - "15 2021-10-04 23:10:50+00:00 comment \n", - "16 2013-07-13 09:48:34+00:00 comment \n", - "23 2018-02-12 16:03:37+00:00 comment \n", - "27 2024-05-08 05:02:37+00:00 comment \n", - "36 2024-06-04 07:39:08+00:00 comment \n", - "150 2025-02-01 14:26:25+00:00 comment \n", - "160 2022-06-08 16:55:54+00:00 comment \n", - "205 2019-02-01 11:49:23+00:00 comment \n", - "231 2014-08-29 14:55:40+00:00 comment \n", - "250 2023-10-15 11:32:44+00:00 comment \n", - "320 1 2012-01-29 02:14:12+00:00 story \n", - "344 2020-12-02 18:24:19+00:00 comment \n", - "348 3 2018-02-02 02:19:42+00:00 story \n", - "360 2023-06-25 20:04:30+00:00 comment \n", - "398 2 2012-08-05 22:18:51+00:00 story \n", - "407 2023-09-15 16:11:08+00:00 comment \n", - "454 2021-07-23 05:12:52+00:00 comment \n", - "457 1 2008-07-27 17:27:47+00:00 story \n", - "472 2018-12-23 20:37:20+00:00 comment \n", - "493 2023-02-02 16:14:23+00:00 comment \n", - "497 2017-02-17 12:51:51+00:00 comment \n", - "514 2020-08-12 02:37:41+00:00 comment \n", - "535 2020-10-07 20:35:18+00:00 comment \n", - "607 2021-09-08 20:58:13+00:00 comment \n", - "672 2023-08-04 02:41:50+00:00 comment \n", - "...\n", - "\n", - "[112 rows x 6 columns]" - ] - }, - "execution_count": 31, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "hacker_news.ai.filter(\"{by} contains animal name\", model=gemini_model)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "3bpkaspoiouZ" - }, - "source": [ - "Here are the runtime numbers with 500 requests per minute [raised quota](https://cloud.google.com/vertex-ai/generative-ai/docs/quotas):\n", - "* 3000 rows -> ~6m\n", - "* 10000 rows -> ~26m" + "For `ai.forecast`, see https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb" ] } ], diff --git a/notebooks/experimental/semantic_operators.ipynb b/notebooks/experimental/semantic_operators.ipynb index fc46a43e7b..c32ac9042b 100644 --- a/notebooks/experimental/semantic_operators.ipynb +++ b/notebooks/experimental/semantic_operators.ipynb @@ -27,9 +27,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Semantic Operators have been deprecated since version 1.42.0. Please use AI Operators instead.\n", + "Semantic Operators have been deprecated since version 1.42.0. Please use AI functions instead.\n", "\n", - "The tutorial notebook for AI operators is located [here](https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/experimental/ai_operators.ipynb)." + "The tutorial notebook for AI functions is located at https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/generative_ai/ai_functions.ipynb" ] } ], diff --git a/notebooks/generative_ai/ai_functions.ipynb b/notebooks/generative_ai/ai_functions.ipynb new file mode 100644 index 0000000000..9362e93b59 --- /dev/null +++ b/notebooks/generative_ai/ai_functions.ipynb @@ -0,0 +1,555 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "acd53f9d", + "metadata": {}, + "outputs": [], + "source": [ + "# Copyright 2025 Google LLC\n", + "#\n", + "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "# you may not use this file except in compliance with the License.\n", + "# You may obtain a copy of the License at\n", + "#\n", + "# https://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing, software\n", + "# distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "# See the License for the specific language governing permissions and\n", + "# limitations under the License." + ] + }, + { + "cell_type": "markdown", + "id": "e75ce682", + "metadata": {}, + "source": [ + "# BigQuery DataFrames (BigFrames) AI Functions\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + "
\n", + " \n", + " \"Colab Run in Colab\n", + " \n", + " \n", + " \n", + " \"GitHub\n", + " View on GitHub\n", + " \n", + " \n", + " \n", + " \"BQ\n", + " Open in BQ Studio\n", + " \n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "aee05821", + "metadata": {}, + "source": [ + "This notebook provides a brief introduction to how to use BigFrames AI functions" + ] + }, + { + "cell_type": "markdown", + "id": "1232f400", + "metadata": {}, + "source": [ + "## Preparation\n", + "\n", + "First, set up your BigFrames environment:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c9f924aa", + "metadata": {}, + "outputs": [], + "source": [ + "import bigframes.pandas as bpd \n", + "\n", + "PROJECT_ID = \"\" # Your project ID here\n", + "\n", + "bpd.options.bigquery.project = PROJECT_ID\n", + "bpd.options.bigquery.ordering_mode = \"partial\"\n", + "bpd.options.display.progress_bar = None" + ] + }, + { + "cell_type": "markdown", + "id": "e2188773", + "metadata": {}, + "source": [ + "## ai.generate\n", + "\n", + "The `ai.generate` function lets you analyze any combination of text and unstructured data from BigQuery. You can mix BigFrames or Pandas series with string literals as your prompt in the form of a tuple. You are also allowed to provide only a series. Here is an example:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "471a47fe", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/global_session.py:103: DefaultLocationWarning: No explicit location is set, so using location US for the session.\n", + " _global_session = bigframes.session.connect(\n" + ] + }, + { + "data": { + "text/plain": [ + "0 {'result': 'Salad\\n', 'full_response': '{\"cand...\n", + "1 {'result': 'Sausageroll\\n', 'full_response': '...\n", + "dtype: struct>, status: string>[pyarrow]" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import bigframes.bigquery as bbq\n", + "\n", + "ingredients1 = bpd.Series([\"Lettuce\", \"Sausage\"])\n", + "ingredients2 = bpd.Series([\"Cucumber\", \"Long Bread\"])\n", + "\n", + "prompt = (\"What's the food made from \", ingredients1, \" and \", ingredients2, \" One word only\")\n", + "bbq.ai.generate(prompt)" + ] + }, + { + "cell_type": "markdown", + "id": "03953835", + "metadata": {}, + "source": [ + "The function returns a series of structs. The `'result'` field holds the answer, while more metadata can be found in the `'full_response'` field. The `'status'` field tells you whether LLM made a successful response for that specific row. " + ] + }, + { + "cell_type": "markdown", + "id": "b606c51f", + "metadata": {}, + "source": [ + "You can also include additional model parameters into your function call, as long as they satisfy the structure of `generateContent` [request body format](https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.endpoints/generateContent#request-body). In the next example, you use `maxOutputTokens` to limite the length of the generated content." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "4a3229a8", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "0 Lettuce\n", + "1 The food\n", + "Name: result, dtype: string" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model_params = {\n", + " \"generationConfig\": {\"maxOutputTokens\": 2}\n", + "}\n", + "\n", + "ingredients1 = bpd.Series([\"Lettuce\", \"Sausage\"])\n", + "ingredients2 = bpd.Series([\"Cucumber\", \"Long Bread\"])\n", + "\n", + "prompt = (\"What's the food made from \", ingredients1, \" and \", ingredients2)\n", + "bbq.ai.generate(prompt, model_params=model_params).struct.field(\"result\")" + ] + }, + { + "cell_type": "markdown", + "id": "3acba92d", + "metadata": {}, + "source": [ + "The answers are cut short as expected.\n", + "\n", + "In addition to `ai.generate`, you can use `ai.generate_bool`, `ai.generate_int`, and `ai.generate_double` for other type of outputs." + ] + }, + { + "cell_type": "markdown", + "id": "0bf9f1de", + "metadata": {}, + "source": [ + "## ai.if_\n", + "\n", + "`ai.if_` generates a series of booleans, unlike `ai.generate_bool` where you get a series of structs. It's a handy tool for filtering your data. not only because it directly returns a boolean, but also because it provides more optimization during data processing. Here is an example of using `ai.if_`:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "718c6622", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
creaturecategory
0Catmammal
1Salmonfish
\n", + "

2 rows × 2 columns

\n", + "
[2 rows x 2 columns in total]" + ], + "text/plain": [ + "creature category\n", + " Cat mammal\n", + " Salmon fish\n", + "\n", + "[2 rows x 2 columns]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "creatures = bpd.DataFrame({\"creature\": [\"Cat\", \"Salmon\"]})\n", + "categories = bpd.DataFrame({\"category\": [\"mammal\", \"fish\"]})\n", + "\n", + "joined_df = creatures.merge(categories, how=\"cross\")\n", + "condition = bbq.ai.if_((joined_df[\"creature\"], \" is a \", joined_df[\"category\"]))\n", + "\n", + "# Filter our dataframe\n", + "joined_df = joined_df[condition]\n", + "joined_df" + ] + }, + { + "cell_type": "markdown", + "id": "bb0999df", + "metadata": {}, + "source": [ + "## ai.score" + ] + }, + { + "cell_type": "markdown", + "id": "63b5a59f", + "metadata": {}, + "source": [ + "`ai.score` ranks your input based on the prompt. You can then sort your data based on their ranks. For example:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "6875fe36", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
animalsrelative_weight
1spider1.0
0tiger8.0
2blue whale10.0
\n", + "

3 rows × 2 columns

\n", + "
[3 rows x 2 columns in total]" + ], + "text/plain": [ + " animals relative_weight\n", + "1 spider 1.0\n", + "0 tiger 8.0\n", + "2 blue whale 10.0\n", + "\n", + "[3 rows x 2 columns]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = bpd.DataFrame({'animals': ['tiger', 'spider', 'blue whale']})\n", + "\n", + "df['relative_weight'] = bbq.ai.score((\"Rank the relative weight of \", df['animals'], \" on the scale from 1 to 10\"))\n", + "df.sort_values(by='relative_weight')" + ] + }, + { + "cell_type": "markdown", + "id": "1ed0dff1", + "metadata": {}, + "source": [ + "## ai.classify" + ] + }, + { + "cell_type": "markdown", + "id": "c56b91cf", + "metadata": {}, + "source": [ + "`ai.classify` categories your inputs into the specified categories. " + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "8cfb844b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
animalcategory
0tigermammal
1spideranthropod
2blue whalemammal
3salmonfish
\n", + "

4 rows × 2 columns

\n", + "
[4 rows x 2 columns in total]" + ], + "text/plain": [ + " animal category\n", + "0 tiger mammal\n", + "1 spider anthropod\n", + "2 blue whale mammal\n", + "3 salmon fish\n", + "\n", + "[4 rows x 2 columns]" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = bpd.DataFrame({'animal': ['tiger', 'spider', 'blue whale', 'salmon']})\n", + "\n", + "df['category'] = bbq.ai.classify(df['animal'], categories=['mammal', 'fish', 'anthropod'])\n", + "df" + ] + }, + { + "cell_type": "markdown", + "id": "9e4037bc", + "metadata": {}, + "source": [ + "Note that this function can only return the values that are present in your provided categories. If your categories do not cover all cases, your will get wrong answers:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "2e66110a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
animalcategory
0tigermammal
1spidermammal
\n", + "

2 rows × 2 columns

\n", + "
[2 rows x 2 columns in total]" + ], + "text/plain": [ + " animal category\n", + "0 tiger mammal\n", + "1 spider mammal\n", + "\n", + "[2 rows x 2 columns]" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = bpd.DataFrame({'animal': ['tiger', 'spider']})\n", + "\n", + "df['category'] = bbq.ai.classify(df['animal'], categories=['mammal', 'fish']) # Spider belongs to neither category\n", + "df" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv (3.10.17)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.17" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 760000122dc190ac8a3303234cf4cbee1bbb9493 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Wed, 8 Oct 2025 13:42:50 -0700 Subject: [PATCH 136/313] feat: support string literal inputs for AI functions (#2152) * feat: support string literal inputs for AI functions * polish code * update pydoc --- bigframes/bigquery/_operations/ai.py | 20 ++++++++++++-------- tests/system/small/bigquery/test_ai.py | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/bigframes/bigquery/_operations/ai.py b/bigframes/bigquery/_operations/ai.py index 0c5eba9496..5c001d4caf 100644 --- a/bigframes/bigquery/_operations/ai.py +++ b/bigframes/bigquery/_operations/ai.py @@ -28,6 +28,7 @@ from bigframes.operations import ai_ops, output_schemas PROMPT_TYPE = Union[ + str, series.Series, pd.Series, List[Union[str, series.Series, pd.Series]], @@ -73,7 +74,7 @@ def generate( dtype: struct>, status: string>[pyarrow] Args: - prompt (Series | List[str|Series] | Tuple[str|Series, ...]): + prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series or pandas Series. connection_id (str, optional): @@ -165,7 +166,7 @@ def generate_bool( Name: result, dtype: boolean Args: - prompt (Series | List[str|Series] | Tuple[str|Series, ...]): + prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series or pandas Series. connection_id (str, optional): @@ -240,7 +241,7 @@ def generate_int( Name: result, dtype: Int64 Args: - prompt (Series | List[str|Series] | Tuple[str|Series, ...]): + prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series or pandas Series. connection_id (str, optional): @@ -315,7 +316,7 @@ def generate_double( Name: result, dtype: Float64 Args: - prompt (Series | List[str|Series] | Tuple[str|Series, ...]): + prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series or pandas Series. connection_id (str, optional): @@ -386,7 +387,7 @@ def if_( dtype: string Args: - prompt (Series | List[str|Series] | Tuple[str|Series, ...]): + prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series or pandas Series. connection_id (str, optional): @@ -433,7 +434,7 @@ def classify( [2 rows x 2 columns] Args: - input (Series | List[str|Series] | Tuple[str|Series, ...]): + input (str | Series | List[str|Series] | Tuple[str|Series, ...]): A mixture of Series and string literals that specifies the input to send to the model. The Series can be BigFrames Series or pandas Series. categories (tuple[str, ...] | list[str]): @@ -482,7 +483,7 @@ def score( dtype: Float64 Args: - prompt (Series | List[str|Series] | Tuple[str|Series, ...]): + prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series or pandas Series. connection_id (str, optional): @@ -514,9 +515,12 @@ def _separate_context_and_series( Input: ("str1", series1, "str2", "str3", series2) Output: ["str1", None, "str2", "str3", None], [series1, series2] """ - if not isinstance(prompt, (list, tuple, series.Series)): + if not isinstance(prompt, (str, list, tuple, series.Series)): raise ValueError(f"Unsupported prompt type: {type(prompt)}") + if isinstance(prompt, str): + return [None], [series.Series([prompt])] + if isinstance(prompt, series.Series): if prompt.dtype == dtypes.OBJ_REF_DTYPE: # Multi-model support diff --git a/tests/system/small/bigquery/test_ai.py b/tests/system/small/bigquery/test_ai.py index 2ccdb01944..203de616ee 100644 --- a/tests/system/small/bigquery/test_ai.py +++ b/tests/system/small/bigquery/test_ai.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from unittest import mock + from packaging import version import pandas as pd import pyarrow as pa @@ -42,6 +44,27 @@ def test_ai_function_pandas_input(session): ) +def test_ai_function_string_input(session): + with mock.patch( + "bigframes.core.global_session.get_global_session" + ) as mock_get_session: + mock_get_session.return_value = session + prompt = "Is apple a fruit?" + + result = bbq.ai.generate_bool(prompt, endpoint="gemini-2.5-flash") + + assert _contains_no_nulls(result) + assert result.dtype == pd.ArrowDtype( + pa.struct( + ( + pa.field("result", pa.bool_()), + pa.field("full_response", dtypes.JSON_ARROW_TYPE), + pa.field("status", pa.string()), + ) + ) + ) + + def test_ai_function_compile_model_params(session): if version.Version(sqlglot.__version__) < version.Version("25.18.0"): pytest.skip( From a410d0ae43ef3b053b650804156eda0b1f569da9 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Thu, 9 Oct 2025 10:36:18 -0700 Subject: [PATCH 137/313] feat: Replace ML.GENERATE_TEXT with AI.GENERATE for audio transcription (#2151) * change to ai.generate * convert the input data type * remove default value setting --- bigframes/operations/blob.py | 28 +- .../multimodal/multimodal_dataframe.ipynb | 949 +++++++++++++++++- 2 files changed, 925 insertions(+), 52 deletions(-) diff --git a/bigframes/operations/blob.py b/bigframes/operations/blob.py index 63875ded99..7f419bc5d8 100644 --- a/bigframes/operations/blob.py +++ b/bigframes/operations/blob.py @@ -804,7 +804,6 @@ def audio_transcribe( raise ValueError("Must specify the engine, supported value is 'bigquery'.") import bigframes.bigquery as bbq - import bigframes.ml.llm as llm import bigframes.pandas as bpd # col name doesn't matter here. Rename to avoid column name conflicts @@ -812,27 +811,22 @@ def audio_transcribe( prompt_text = "**Task:** Transcribe the provided audio. **Instructions:** - Your response must contain only the verbatim transcription of the audio. - Do not include any introductory text, summaries, or conversational filler in your response. The output should begin directly with the first word of the audio." - llm_model = llm.GeminiTextGenerator( - model_name=model_name, - session=self._block.session, - connection_name=connection, - ) + # Convert the audio series to the runtime representation required by the model. + audio_runtime = audio_series.blob._get_runtime("R", with_metadata=True) - # transcribe audio using ML.GENERATE_TEXT - transcribed_results = llm_model.predict( - X=audio_series, - prompt=[prompt_text, audio_series], - temperature=0.0, + transcribed_results = bbq.ai.generate( + prompt=(prompt_text, audio_runtime), + connection_id=connection, + endpoint=model_name, + model_params={"generationConfig": {"temperature": 0.0}}, ) - transcribed_content_series = cast( - bpd.Series, transcribed_results["ml_generate_text_llm_result"] - ).rename("transcribed_content") + transcribed_content_series = transcribed_results.struct.field("result").rename( + "transcribed_content" + ) if verbose: - transcribed_status_series = cast( - bpd.Series, transcribed_results["ml_generate_text_status"] - ) + transcribed_status_series = transcribed_results.struct.field("status") results_df = bpd.DataFrame( { "status": transcribed_status_series, diff --git a/notebooks/multimodal/multimodal_dataframe.ipynb b/notebooks/multimodal/multimodal_dataframe.ipynb index f6f80b0009..c04463fc4c 100644 --- a/notebooks/multimodal/multimodal_dataframe.ipynb +++ b/notebooks/multimodal/multimodal_dataframe.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": {}, "outputs": [], "source": [ @@ -81,7 +81,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -90,7 +90,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -131,7 +131,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -139,7 +139,20 @@ "id": "fx6YcZJbeYru", "outputId": "d707954a-0dd0-4c50-b7bf-36b140cf76cf" }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/global_session.py:113: DefaultLocationWarning: No explicit location is set, so using location US for the session.\n", + " _global_session = bigframes.session.connect(\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + } + ], "source": [ "# Create blob columns from wildcard path.\n", "df_image = bpd.from_glob_path(\n", @@ -155,7 +168,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -164,7 +177,83 @@ "id": "HhCb8jRsLe9B", "outputId": "03081cf9-3a22-42c9-b38f-649f592fdada" }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
image
0
1
2
3
4
\n", + "

5 rows × 1 columns

\n", + "
[5 rows x 1 columns in total]" + ], + "text/plain": [ + " image\n", + "0 {'uri': 'gs://cloud-samples-data/bigquery/tuto...\n", + "1 {'uri': 'gs://cloud-samples-data/bigquery/tuto...\n", + "2 {'uri': 'gs://cloud-samples-data/bigquery/tuto...\n", + "3 {'uri': 'gs://cloud-samples-data/bigquery/tuto...\n", + "4 {'uri': 'gs://cloud-samples-data/bigquery/tuto...\n", + "\n", + "[5 rows x 1 columns]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "# Take only the 5 images to deal with. Preview the content of the Mutimodal DataFrame\n", "df_image = df_image.head(5)\n", @@ -191,11 +280,143 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "metadata": { "id": "YYYVn7NDH0Me" }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:124: UserWarning: The `json_extract` is deprecated and will be removed in a future\n", + "version. Use `json_query` instead.\n", + " warnings.warn(bfe.format_message(msg), category=UserWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:124: UserWarning: The `json_extract` is deprecated and will be removed in a future\n", + "version. Use `json_query` instead.\n", + " warnings.warn(bfe.format_message(msg), category=UserWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:124: UserWarning: The `json_extract` is deprecated and will be removed in a future\n", + "version. Use `json_query` instead.\n", + " warnings.warn(bfe.format_message(msg), category=UserWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
imageauthorcontent_typesizeupdated
0aliceimage/png15912402025-03-20 17:45:04+00:00
1bobimage/png11829512025-03-20 17:45:02+00:00
2bobimage/png15208842025-03-20 17:44:55+00:00
3aliceimage/png12354012025-03-20 17:45:19+00:00
4bobimage/png15919232025-03-20 17:44:47+00:00
\n", + "

5 rows × 5 columns

\n", + "
[5 rows x 5 columns in total]" + ], + "text/plain": [ + " image author content_type \\\n", + "0 {'uri': 'gs://cloud-samples-data/bigquery/tuto... alice image/png \n", + "1 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n", + "2 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n", + "3 {'uri': 'gs://cloud-samples-data/bigquery/tuto... alice image/png \n", + "4 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n", + "\n", + " size updated \n", + "0 1591240 2025-03-20 17:45:04+00:00 \n", + "1 1182951 2025-03-20 17:45:02+00:00 \n", + "2 1520884 2025-03-20 17:44:55+00:00 \n", + "3 1235401 2025-03-20 17:45:19+00:00 \n", + "4 1591923 2025-03-20 17:44:47+00:00 \n", + "\n", + "[5 rows x 5 columns]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "# Combine unstructured data with structured data\n", "df_image[\"author\"] = [\"alice\", \"bob\", \"bob\", \"alice\", \"bob\"] # type: ignore\n", @@ -216,7 +437,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -225,7 +446,53 @@ "id": "UGuAk9PNDRF3", "outputId": "73feb33d-4a05-48fb-96e5-3c48c2a456f3" }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:124: UserWarning: The `json_extract` is deprecated and will be removed in a future\n", + "version. Use `json_query` instead.\n", + " warnings.warn(bfe.format_message(msg), category=UserWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "# filter images and display, you can also display audio and video types\n", "df_image[df_image[\"author\"] == \"alice\"][\"image\"].blob.display()" @@ -243,7 +510,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -251,7 +518,32 @@ "id": "VWsl5BBPJ6N7", "outputId": "45d2356e-322b-4982-cfa7-42d034dc4344" }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:180: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + " return method(*args, **kwargs)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:180: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + " return method(*args, **kwargs)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:180: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + " return method(*args, **kwargs)\n" + ] + } + ], "source": [ "df_image[\"blurred\"] = df_image[\"image\"].blob.image_blur(\n", " (20, 20), dst=f\"gs://{OUTPUT_BUCKET}/image_blur_transformed/\", engine=\"opencv\"\n", @@ -270,7 +562,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -278,7 +570,20 @@ "id": "rWCAGC8w64vU", "outputId": "d7d456f0-8b56-492c-fe1b-967e9664d813" }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:180: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + " return method(*args, **kwargs)\n" + ] + } + ], "source": [ "# You can also chain functions together\n", "df_image[\"blur_resized\"] = df_image[\"blurred\"].blob.image_resize((300, 200), dst=f\"gs://{OUTPUT_BUCKET}/image_blur_resize_transformed/\", engine=\"opencv\")" @@ -286,7 +591,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 10, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -295,7 +600,182 @@ "id": "6NGK6GYSU44B", "outputId": "859101c1-2ee4-4f9a-e250-e8947127420a" }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
imageauthorcontent_typesizeupdatedblurredresizednormalizedblur_resized
0aliceimage/png15912402025-03-20 17:45:04+00:00
1bobimage/png11829512025-03-20 17:45:02+00:00
2bobimage/png15208842025-03-20 17:44:55+00:00
3aliceimage/png12354012025-03-20 17:45:19+00:00
4bobimage/png15919232025-03-20 17:44:47+00:00
\n", + "

5 rows × 9 columns

\n", + "
[5 rows x 9 columns in total]" + ], + "text/plain": [ + " image author content_type \\\n", + "0 {'uri': 'gs://cloud-samples-data/bigquery/tuto... alice image/png \n", + "1 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n", + "2 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n", + "3 {'uri': 'gs://cloud-samples-data/bigquery/tuto... alice image/png \n", + "4 {'uri': 'gs://cloud-samples-data/bigquery/tuto... bob image/png \n", + "\n", + " size updated \\\n", + "0 1591240 2025-03-20 17:45:04+00:00 \n", + "1 1182951 2025-03-20 17:45:02+00:00 \n", + "2 1520884 2025-03-20 17:44:55+00:00 \n", + "3 1235401 2025-03-20 17:45:19+00:00 \n", + "4 1591923 2025-03-20 17:44:47+00:00 \n", + "\n", + " blurred \\\n", + "0 {'uri': 'gs://bigframes_blob_test/image_blur_t... \n", + "1 {'uri': 'gs://bigframes_blob_test/image_blur_t... \n", + "2 {'uri': 'gs://bigframes_blob_test/image_blur_t... \n", + "3 {'uri': 'gs://bigframes_blob_test/image_blur_t... \n", + "4 {'uri': 'gs://bigframes_blob_test/image_blur_t... \n", + "\n", + " resized \\\n", + "0 {'uri': 'gs://bigframes_blob_test/image_resize... \n", + "1 {'uri': 'gs://bigframes_blob_test/image_resize... \n", + "2 {'uri': 'gs://bigframes_blob_test/image_resize... \n", + "3 {'uri': 'gs://bigframes_blob_test/image_resize... \n", + "4 {'uri': 'gs://bigframes_blob_test/image_resize... \n", + "\n", + " normalized \\\n", + "0 {'uri': 'gs://bigframes_blob_test/image_normal... \n", + "1 {'uri': 'gs://bigframes_blob_test/image_normal... \n", + "2 {'uri': 'gs://bigframes_blob_test/image_normal... \n", + "3 {'uri': 'gs://bigframes_blob_test/image_normal... \n", + "4 {'uri': 'gs://bigframes_blob_test/image_normal... \n", + "\n", + " blur_resized \n", + "0 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", + "1 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", + "2 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", + "3 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", + "4 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", + "\n", + "[5 rows x 9 columns]" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "df_image" ] @@ -311,11 +791,22 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 11, "metadata": { "id": "mRUGfcaFVW-3" }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:180: FutureWarning: Since upgrading the default model can cause unintended breakages, the\n", + "default model will be removed in BigFrames 3.0. Please supply an\n", + "explicit model to avoid this message.\n", + " return method(*args, **kwargs)\n" + ] + } + ], "source": [ "from bigframes.ml import llm\n", "gemini = llm.GeminiTextGenerator()" @@ -323,7 +814,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 12, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -332,7 +823,87 @@ "id": "DNFP7CbjWdR9", "outputId": "3f90a062-0abc-4bce-f53c-db57b06a14b9" }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
ml_generate_text_llm_resultimage
0The item is a tin of K9Guard Dog Paw Balm.
1The item is a bottle of K9 Guard Dog Hot Spot Spray.
\n", + "

2 rows × 2 columns

\n", + "
[2 rows x 2 columns in total]" + ], + "text/plain": [ + " ml_generate_text_llm_result \\\n", + "0 The item is a tin of K9Guard Dog Paw Balm. \n", + "1 The item is a bottle of K9 Guard Dog Hot Spot ... \n", + "\n", + " image \n", + "0 {'uri': 'gs://cloud-samples-data/bigquery/tuto... \n", + "1 {'uri': 'gs://cloud-samples-data/bigquery/tuto... \n", + "\n", + "[2 rows x 2 columns]" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "# Ask the same question on the images\n", "df_image = df_image.head(2)\n", @@ -342,11 +913,22 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 13, "metadata": { "id": "IG3J3HsKhyBY" }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + } + ], "source": [ "# Ask different questions\n", "df_image[\"question\"] = [\"what item is it?\", \"what color is the picture?\"]" @@ -354,7 +936,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 14, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -363,7 +945,87 @@ "id": "qKOb765IiVuD", "outputId": "731bafad-ea29-463f-c8c1-cb7acfd70e5d" }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
ml_generate_text_llm_resultimage
0The item is dog paw balm.
1The picture features a white bottle with a light blue spray nozzle and accents. The background is a neutral gray.\\n
\n", + "

2 rows × 2 columns

\n", + "
[2 rows x 2 columns in total]" + ], + "text/plain": [ + " ml_generate_text_llm_result \\\n", + "0 The item is dog paw balm. \n", + "1 The picture features a white bottle with a lig... \n", + "\n", + " image \n", + "0 {'uri': 'gs://cloud-samples-data/bigquery/tuto... \n", + "1 {'uri': 'gs://cloud-samples-data/bigquery/tuto... \n", + "\n", + "[2 rows x 2 columns]" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "answer_alt = gemini.predict(df_image, prompt=[df_image[\"question\"], df_image[\"image\"]])\n", "answer_alt[[\"ml_generate_text_llm_result\", \"image\"]]" @@ -371,7 +1033,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 15, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -380,7 +1042,104 @@ "id": "KATVv2CO5RT1", "outputId": "6ec01f27-70b6-4f69-c545-e5e3c879480c" }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:180: FutureWarning: Since upgrading the default model can cause unintended breakages, the\n", + "default model will be removed in BigFrames 3.0. Please supply an\n", + "explicit model to avoid this message.\n", + " return method(*args, **kwargs)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
ml_generate_embedding_resultml_generate_embedding_statusml_generate_embedding_start_secml_generate_embedding_end_seccontent
0[ 0.00638846 0.01666372 0.00451786 ... -0.02...<NA><NA>{\"access_urls\":{\"expiry_time\":\"2025-10-09T12:2...
1[ 0.0097399 0.0214815 0.00244266 ... 0.00...<NA><NA>{\"access_urls\":{\"expiry_time\":\"2025-10-09T12:2...
\n", + "

2 rows × 5 columns

\n", + "
[2 rows x 5 columns in total]" + ], + "text/plain": [ + " ml_generate_embedding_result \\\n", + "0 [ 0.00638846 0.01666372 0.00451786 ... -0.02... \n", + "1 [ 0.0097399 0.0214815 0.00244266 ... 0.00... \n", + "\n", + " ml_generate_embedding_status ml_generate_embedding_start_sec \\\n", + "0 \n", + "1 \n", + "\n", + " ml_generate_embedding_end_sec \\\n", + "0 \n", + "1 \n", + "\n", + " content \n", + "0 {\"access_urls\":{\"expiry_time\":\"2025-10-09T12:2... \n", + "1 {\"access_urls\":{\"expiry_time\":\"2025-10-09T12:2... \n", + "\n", + "[2 rows x 5 columns]" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "# Generate embeddings.\n", "embed_model = llm.MultimodalEmbeddingGenerator()\n", @@ -399,18 +1158,29 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 16, "metadata": { "id": "oDDuYtUm5Yiy" }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + } + ], "source": [ "df_pdf = bpd.from_glob_path(\"gs://cloud-samples-data/bigquery/tutorials/cymbal-pets/documents/*\", name=\"pdf\")" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 17, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -418,22 +1188,130 @@ "id": "7jLpMYaj7nj8", "outputId": "06d5456f-580f-4693-adff-2605104b056c" }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:180: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + " return method(*args, **kwargs)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:244: UserWarning: The `json_extract_string_array` is deprecated and will be removed in a\n", + "future version. Use `json_value_array` instead.\n", + " warnings.warn(bfe.format_message(msg), category=UserWarning)\n" + ] + } + ], "source": [ "df_pdf[\"chunked\"] = df_pdf[\"pdf\"].blob.pdf_chunk(engine=\"pypdf\")" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 18, "metadata": { "id": "kaPvJATN7zlw" }, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/plain": [ + "0 CritterCuisine Pro 5000 - Automatic Pet Feeder...\n", + "0 on a level, stable surface to prevent tipping....\n", + "0 included)\\nto maintain the schedule during pow...\n", + "0 digits for Meal 1 will flash.\\n\u0000. Use the UP/D...\n", + "0 paperclip) for 5\\nseconds. This will reset all...\n", + "0 unit with a damp cloth. Do not immerse the bas...\n", + "0 continues,\\ncontact customer support.\\nE2: Foo...\n", + "Name: chunked, dtype: string" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "chunked = df_pdf[\"chunked\"].explode()\n", "chunked" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 6. Audio transcribe function" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + } + ], + "source": [ + "audio_gcs_path = \"gs://bigframes_blob_test/audio/*\"\n", + "df = bpd.from_glob_path(audio_gcs_path, name=\"audio\")" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/plain": [ + "0 Now, as all books, not primarily intended as p...\n", + "Name: transcribed_content, dtype: string" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "transcribed_series = df['audio'].blob.audio_transcribe(model_name=\"gemini-2.0-flash-001\", verbose=False)\n", + "transcribed_series" + ] } ], "metadata": { @@ -441,7 +1319,8 @@ "provenance": [] }, "kernelspec": { - "display_name": "Python 3", + "display_name": "venv", + "language": "python", "name": "python3" }, "language_info": { @@ -454,7 +1333,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.14" + "version": "3.10.18" } }, "nbformat": 4, From 615a620dd512839df9ee72dfe623ecc37e198e8f Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Thu, 9 Oct 2025 11:20:28 -0700 Subject: [PATCH 138/313] refactor: support agg_ops.LastOp, LastNonNullOp, FirstOp, FirstNonNullOp in the sqlglot compiler (#2153) --- .../sqlglot/aggregations/nullary_compiler.py | 2 +- .../sqlglot/aggregations/unary_compiler.py | 50 ++++++++++++++- .../compile/sqlglot/aggregations/windows.py | 7 ++- .../test_unary_compiler/test_first/out.sql | 20 ++++++ .../test_first_non_null/out.sql | 16 +++++ .../test_unary_compiler/test_last/out.sql | 20 ++++++ .../test_last_non_null/out.sql | 16 +++++ .../aggregations/test_unary_compiler.py | 61 +++++++++++++++++++ 8 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first_non_null/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last_non_null/out.sql diff --git a/bigframes/core/compile/sqlglot/aggregations/nullary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/nullary_compiler.py index c6418591ba..95dad4ff3b 100644 --- a/bigframes/core/compile/sqlglot/aggregations/nullary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/nullary_compiler.py @@ -50,4 +50,4 @@ def _( if window is None: # ROW_NUMBER always needs an OVER clause. return sge.Window(this=result) - return apply_window_if_present(result, window) + return apply_window_if_present(result, window, include_framing_clauses=False) diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py index 1e87fd1fc5..16bd3ef099 100644 --- a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -104,7 +104,51 @@ def _( column: typed_expr.TypedExpr, window: typing.Optional[window_spec.WindowSpec] = None, ) -> sge.Expression: - return apply_window_if_present(sge.func("DENSE_RANK"), window) + return apply_window_if_present( + sge.func("DENSE_RANK"), window, include_framing_clauses=False + ) + + +@UNARY_OP_REGISTRATION.register(agg_ops.FirstOp) +def _( + op: agg_ops.FirstOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + # FIRST_VALUE in BQ respects nulls by default. + return apply_window_if_present(sge.FirstValue(this=column.expr), window) + + +@UNARY_OP_REGISTRATION.register(agg_ops.FirstNonNullOp) +def _( + op: agg_ops.FirstNonNullOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return apply_window_if_present( + sge.IgnoreNulls(this=sge.FirstValue(this=column.expr)), window + ) + + +@UNARY_OP_REGISTRATION.register(agg_ops.LastOp) +def _( + op: agg_ops.LastOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + # LAST_VALUE in BQ respects nulls by default. + return apply_window_if_present(sge.LastValue(this=column.expr), window) + + +@UNARY_OP_REGISTRATION.register(agg_ops.LastNonNullOp) +def _( + op: agg_ops.LastNonNullOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return apply_window_if_present( + sge.IgnoreNulls(this=sge.LastValue(this=column.expr)), window + ) @UNARY_OP_REGISTRATION.register(agg_ops.MaxOp) @@ -182,7 +226,9 @@ def _( column: typed_expr.TypedExpr, window: typing.Optional[window_spec.WindowSpec] = None, ) -> sge.Expression: - return apply_window_if_present(sge.func("RANK"), window) + return apply_window_if_present( + sge.func("RANK"), window, include_framing_clauses=False + ) @UNARY_OP_REGISTRATION.register(agg_ops.SizeUnaryOp) diff --git a/bigframes/core/compile/sqlglot/aggregations/windows.py b/bigframes/core/compile/sqlglot/aggregations/windows.py index 5e38bf120e..41b4c674f9 100644 --- a/bigframes/core/compile/sqlglot/aggregations/windows.py +++ b/bigframes/core/compile/sqlglot/aggregations/windows.py @@ -25,6 +25,7 @@ def apply_window_if_present( value: sge.Expression, window: typing.Optional[window_spec.WindowSpec] = None, + include_framing_clauses: bool = True, ) -> sge.Expression: if window is None: return value @@ -64,11 +65,11 @@ def apply_window_if_present( if not window.bounds and not order: return sge.Window(this=value, partition_by=group_by) - if not window.bounds: + if not window.bounds and not include_framing_clauses: return sge.Window(this=value, partition_by=group_by, order=order) kind = ( - "ROWS" if isinstance(window.bounds, window_spec.RowsWindowBounds) else "RANGE" + "RANGE" if isinstance(window.bounds, window_spec.RangeWindowBounds) else "ROWS" ) start: typing.Union[int, float, None] = None @@ -125,7 +126,7 @@ def get_window_order_by( nulls_first=nulls_first, ) ) - elif not nulls_first and not desc: + elif (not nulls_first) and (not desc): order_by.append( sge.Ordered( this=is_null_expr, diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql new file mode 100644 index 0000000000..6c7d39c24a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql @@ -0,0 +1,20 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `bfcol_0` IS NULL + THEN NULL + ELSE FIRST_VALUE(`bfcol_0`) OVER ( + ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first_non_null/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first_non_null/out.sql new file mode 100644 index 0000000000..ff90c6fcd9 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first_non_null/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + FIRST_VALUE(`bfcol_0` IGNORE NULLS) OVER ( + ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql new file mode 100644 index 0000000000..788c5ba466 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql @@ -0,0 +1,20 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `bfcol_0` IS NULL + THEN NULL + ELSE LAST_VALUE(`bfcol_0`) OVER ( + ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last_non_null/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last_non_null/out.sql new file mode 100644 index 0000000000..17e7dbd446 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last_non_null/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LAST_VALUE(`bfcol_0` IGNORE NULLS) OVER ( + ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index ea7faca7fb..ea15f155ad 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys import typing import pytest @@ -126,6 +127,66 @@ def test_dense_rank(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_first(scalar_types_df: bpd.DataFrame, snapshot): + if sys.version_info < (3, 12): + pytest.skip( + "Skipping test due to inconsistent SQL formatting on Python < 3.12.", + ) + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_exprs.UnaryAggregation(agg_ops.FirstOp(), expression.deref(col_name)) + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + + snapshot.assert_match(sql, "out.sql") + + +def test_first_non_null(scalar_types_df: bpd.DataFrame, snapshot): + if sys.version_info < (3, 12): + pytest.skip( + "Skipping test due to inconsistent SQL formatting on Python < 3.12.", + ) + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_exprs.UnaryAggregation( + agg_ops.FirstNonNullOp(), expression.deref(col_name) + ) + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + + snapshot.assert_match(sql, "out.sql") + + +def test_last(scalar_types_df: bpd.DataFrame, snapshot): + if sys.version_info < (3, 12): + pytest.skip( + "Skipping test due to inconsistent SQL formatting on Python < 3.12.", + ) + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_exprs.UnaryAggregation(agg_ops.LastOp(), expression.deref(col_name)) + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + + snapshot.assert_match(sql, "out.sql") + + +def test_last_non_null(scalar_types_df: bpd.DataFrame, snapshot): + if sys.version_info < (3, 12): + pytest.skip( + "Skipping test due to inconsistent SQL formatting on Python < 3.12.", + ) + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_exprs.UnaryAggregation( + agg_ops.LastNonNullOp(), expression.deref(col_name) + ) + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + + snapshot.assert_match(sql, "out.sql") + + def test_max(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] From 5e1e8098ecf212c91d73fa80d722d1cb3e46668b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Thu, 9 Oct 2025 13:52:28 -0500 Subject: [PATCH 139/313] feat: create session-scoped `cut`, `DataFrame`, `MultiIndex`, `Index`, `Series`, `to_datetime`, and `to_timedelta` methods (#2157) * docs: remove import bigframes.pandas as bpd boilerplate from many samples Also, fixes several constructors that didn't take a session for compatibility with multi-session applications. * fix docs * fix unit tests * skip sklearn test * fix snapshot * plumb through session for from_tuples and from_arrays * add from_frame * make sure polars session isnt skipped on Kokoro * fix apply doctest * make doctest conftest available everywhere * add python version flexibility for to_dict * disambiguate explicit names * disambiguate explicit name none versus no name * fix for column name comparison in pandas bin op * avoid setting column labels in special case of Series(block) * revert doctest changes * revert doctest changes * revert df docstrings * add polars series unit tests * restore a test * Revert "restore a test" This reverts commit 765b678b34a7976aef1017d2a1fdb34d7a4cfbe4. * skip null * skip unsupported tests * revert more docs changes * revert more docs * revert more docs * fix unit tests python 3.13 * add test to reproduce name error * add tests for session scoped methods * fix mypy errors --- bigframes/core/indexes/base.py | 11 +- bigframes/core/indexes/multi.py | 48 ++++++- bigframes/core/log_adapter.py | 4 +- bigframes/core/reshape/tile.py | 7 +- bigframes/core/tools/datetimes.py | 10 +- bigframes/formatting_helpers.py | 10 +- bigframes/pandas/__init__.py | 17 +-- bigframes/pandas/core/tools/timedeltas.py | 2 +- bigframes/session/__init__.py | 124 +++++++++++++++-- tests/system/small/test_session_as_bpd.py | 154 ++++++++++++++++++++++ tests/unit/test_pandas.py | 26 ++-- 11 files changed, 375 insertions(+), 38 deletions(-) create mode 100644 tests/system/small/test_session_as_bpd.py diff --git a/bigframes/core/indexes/base.py b/bigframes/core/indexes/base.py index 83dd11dacb..a258c01195 100644 --- a/bigframes/core/indexes/base.py +++ b/bigframes/core/indexes/base.py @@ -383,9 +383,16 @@ def to_series( name = self.name if name is None else name if index is None: - return bigframes.series.Series(data=self, index=self, name=name) + return bigframes.series.Series( + data=self, index=self, name=name, session=self._session + ) else: - return bigframes.series.Series(data=self, index=Index(index), name=name) + return bigframes.series.Series( + data=self, + index=Index(index, session=self._session), + name=name, + session=self._session, + ) def get_level_values(self, level) -> Index: level_n = level if isinstance(level, int) else self.names.index(level) diff --git a/bigframes/core/indexes/multi.py b/bigframes/core/indexes/multi.py index a8b4b7dffe..a611442b88 100644 --- a/bigframes/core/indexes/multi.py +++ b/bigframes/core/indexes/multi.py @@ -14,7 +14,7 @@ from __future__ import annotations -from typing import cast, Hashable, Iterable, Sequence +from typing import cast, Hashable, Iterable, Optional, Sequence, TYPE_CHECKING import bigframes_vendored.pandas.core.indexes.multi as vendored_pandas_multindex import pandas @@ -23,6 +23,9 @@ from bigframes.core import expression as ex from bigframes.core.indexes.base import Index +if TYPE_CHECKING: + import bigframes.session + class MultiIndex(Index, vendored_pandas_multindex.MultiIndex): __doc__ = vendored_pandas_multindex.MultiIndex.__doc__ @@ -33,10 +36,12 @@ def from_tuples( tuples: Iterable[tuple[Hashable, ...]], sortorder: int | None = None, names: Sequence[Hashable] | Hashable | None = None, + *, + session: Optional[bigframes.session.Session] = None, ) -> MultiIndex: pd_index = pandas.MultiIndex.from_tuples(tuples, sortorder, names) # Index.__new__ should detect multiple levels and properly create a multiindex - return cast(MultiIndex, Index(pd_index)) + return cast(MultiIndex, Index(pd_index, session=session)) @classmethod def from_arrays( @@ -44,10 +49,12 @@ def from_arrays( arrays, sortorder: int | None = None, names=None, + *, + session: Optional[bigframes.session.Session] = None, ) -> MultiIndex: pd_index = pandas.MultiIndex.from_arrays(arrays, sortorder, names) # Index.__new__ should detect multiple levels and properly create a multiindex - return cast(MultiIndex, Index(pd_index)) + return cast(MultiIndex, Index(pd_index, session=session)) def __eq__(self, other) -> Index: # type: ignore import bigframes.operations as ops @@ -71,3 +78,38 @@ def __eq__(self, other) -> Index: # type: ignore index_labels=[None], ) ) + + +class MultiIndexAccessor: + """Proxy to MultiIndex constructors to allow a session to be passed in.""" + + def __init__(self, session: bigframes.session.Session): + self._session = session + + def __call__(self, *args, **kwargs) -> MultiIndex: + """Construct a MultiIndex using the associated Session. + + See :class:`bigframes.pandas.MultiIndex`. + """ + return MultiIndex(*args, session=self._session, **kwargs) + + def from_arrays(self, *args, **kwargs) -> MultiIndex: + """Construct a MultiIndex using the associated Session. + + See :func:`bigframes.pandas.MultiIndex.from_arrays`. + """ + return MultiIndex.from_arrays(*args, session=self._session, **kwargs) + + def from_frame(self, *args, **kwargs) -> MultiIndex: + """Construct a MultiIndex using the associated Session. + + See :func:`bigframes.pandas.MultiIndex.from_frame`. + """ + return cast(MultiIndex, MultiIndex.from_frame(*args, **kwargs)) + + def from_tuples(self, *args, **kwargs) -> MultiIndex: + """Construct a MultiIndex using the associated Session. + + See :func:`bigframes.pandas.MultiIndex.from_tuples`. + """ + return MultiIndex.from_tuples(*args, session=self._session, **kwargs) diff --git a/bigframes/core/log_adapter.py b/bigframes/core/log_adapter.py index 3ec1e86dc7..8179ffbeed 100644 --- a/bigframes/core/log_adapter.py +++ b/bigframes/core/log_adapter.py @@ -155,7 +155,9 @@ def method_logger(method=None, /, *, custom_base_name: Optional[str] = None): def outer_wrapper(method): @functools.wraps(method) def wrapper(*args, **kwargs): - api_method_name = getattr(method, LOG_OVERRIDE_NAME, method.__name__) + api_method_name = getattr( + method, LOG_OVERRIDE_NAME, method.__name__ + ).lower() if custom_base_name is None: qualname_parts = getattr(method, "__qualname__", method.__name__).split( "." diff --git a/bigframes/core/reshape/tile.py b/bigframes/core/reshape/tile.py index 74a941be54..a2efa8f927 100644 --- a/bigframes/core/reshape/tile.py +++ b/bigframes/core/reshape/tile.py @@ -15,6 +15,7 @@ from __future__ import annotations import typing +from typing import Optional, TYPE_CHECKING import bigframes_vendored.constants as constants import bigframes_vendored.pandas.core.reshape.tile as vendored_pandas_tile @@ -31,6 +32,9 @@ import bigframes.operations.aggregations as agg_ops import bigframes.series +if TYPE_CHECKING: + import bigframes.session + def cut( x, @@ -42,6 +46,7 @@ def cut( *, right: typing.Optional[bool] = True, labels: typing.Union[typing.Iterable[str], bool, None] = None, + session: Optional[bigframes.session.Session] = None, ) -> bigframes.series.Series: if ( labels is not None @@ -65,7 +70,7 @@ def cut( raise ValueError("Cannot cut empty array.") if not isinstance(x, bigframes.series.Series): - x = bigframes.series.Series(x) + x = bigframes.series.Series(x, session=session) if isinstance(bins, int): if bins <= 0: diff --git a/bigframes/core/tools/datetimes.py b/bigframes/core/tools/datetimes.py index 7edf2fa2e4..0e5594d498 100644 --- a/bigframes/core/tools/datetimes.py +++ b/bigframes/core/tools/datetimes.py @@ -12,9 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + from collections.abc import Mapping from datetime import date, datetime -from typing import Optional, Union +from typing import Optional, TYPE_CHECKING, Union import bigframes_vendored.constants as constants import bigframes_vendored.pandas.core.tools.datetimes as vendored_pandas_datetimes @@ -25,6 +27,9 @@ import bigframes.operations as ops import bigframes.series +if TYPE_CHECKING: + import bigframes.session + def to_datetime( arg: Union[ @@ -37,6 +42,7 @@ def to_datetime( utc: bool = False, format: Optional[str] = None, unit: Optional[str] = None, + session: Optional[bigframes.session.Session] = None, ) -> Union[pd.Timestamp, datetime, bigframes.series.Series]: if isinstance(arg, (int, float, str, datetime, date)): return pd.to_datetime( @@ -52,7 +58,7 @@ def to_datetime( f"to datetime is not implemented. {constants.FEEDBACK_LINK}" ) - arg = bigframes.series.Series(arg) + arg = bigframes.series.Series(arg, session=session) if format and unit and arg.dtype in (bigframes.dtypes.INT_DTYPE, bigframes.dtypes.FLOAT_DTYPE): # type: ignore raise ValueError("cannot specify both format and unit") diff --git a/bigframes/formatting_helpers.py b/bigframes/formatting_helpers.py index f75394c47d..55731069a3 100644 --- a/bigframes/formatting_helpers.py +++ b/bigframes/formatting_helpers.py @@ -105,8 +105,14 @@ def progress_callback( """Displays a progress bar while the query is running""" global current_display, current_display_id, previous_display_html - import bigframes._config - import bigframes.core.events + try: + import bigframes._config + import bigframes.core.events + except ImportError: + # Since this gets called from __del__, skip if the import fails to avoid + # ImportError: sys.meta_path is None, Python is likely shutting down. + # This will allow cleanup to continue. + return progress_bar = bigframes._config.options.display.progress_bar diff --git a/bigframes/pandas/__init__.py b/bigframes/pandas/__init__.py index 2455637b0a..6fcb71f0d8 100644 --- a/bigframes/pandas/__init__.py +++ b/bigframes/pandas/__init__.py @@ -16,8 +16,8 @@ from __future__ import annotations -from collections import namedtuple -from datetime import date, datetime +import collections +import datetime import inspect import sys import typing @@ -198,18 +198,18 @@ def to_datetime( @typing.overload def to_datetime( - arg: Union[int, float, str, datetime, date], + arg: Union[int, float, str, datetime.datetime, datetime.date], *, utc: bool = False, format: Optional[str] = None, unit: Optional[str] = None, -) -> Union[pandas.Timestamp, datetime]: +) -> Union[pandas.Timestamp, datetime.datetime]: ... def to_datetime( arg: Union[ - Union[int, float, str, datetime, date], + Union[int, float, str, datetime.datetime, datetime.date], vendored_pandas_datetimes.local_iterables, bigframes.series.Series, bigframes.dataframe.DataFrame, @@ -218,8 +218,9 @@ def to_datetime( utc: bool = False, format: Optional[str] = None, unit: Optional[str] = None, -) -> Union[pandas.Timestamp, datetime, bigframes.series.Series]: - return bigframes.core.tools.to_datetime( +) -> Union[pandas.Timestamp, datetime.datetime, bigframes.series.Series]: + return global_session.with_default_session( + bigframes.session.Session.to_datetime, arg, utc=utc, format=format, @@ -322,7 +323,7 @@ def clean_up_by_session_id( __version__ = bigframes.version.__version__ # Other public pandas attributes -NamedAgg = namedtuple("NamedAgg", ["column", "aggfunc"]) +NamedAgg = collections.namedtuple("NamedAgg", ["column", "aggfunc"]) options = config.options """Global :class:`~bigframes._config.Options` to configure BigQuery DataFrames.""" diff --git a/bigframes/pandas/core/tools/timedeltas.py b/bigframes/pandas/core/tools/timedeltas.py index 070a41d62d..eb01f9f846 100644 --- a/bigframes/pandas/core/tools/timedeltas.py +++ b/bigframes/pandas/core/tools/timedeltas.py @@ -35,7 +35,7 @@ def to_timedelta( return arg._apply_unary_op(ops.ToTimedeltaOp(canonical_unit)) if pdtypes.is_list_like(arg): - return to_timedelta(series.Series(arg), unit, session=session) + return to_timedelta(series.Series(arg, session=session), unit, session=session) return pd.to_timedelta(arg, unit) diff --git a/bigframes/session/__init__.py b/bigframes/session/__init__.py index 46fb56b88e..886072b884 100644 --- a/bigframes/session/__init__.py +++ b/bigframes/session/__init__.py @@ -68,6 +68,8 @@ import bigframes.core from bigframes.core import blocks, log_adapter, utils import bigframes.core.events +import bigframes.core.indexes +import bigframes.core.indexes.multi import bigframes.core.pyformat import bigframes.formatting_helpers import bigframes.functions._function_session as bff_session @@ -79,7 +81,6 @@ # Avoid circular imports. if typing.TYPE_CHECKING: - import bigframes.core.indexes import bigframes.dataframe as dataframe import bigframes.series import bigframes.streaming.dataframe as streaming_dataframe @@ -320,6 +321,15 @@ def bqconnectionmanager(self): ) return self._bq_connection_manager + @property + def options(self) -> bigframes._config.Options: + """Options for configuring BigQuery DataFrames. + + Included for compatibility between bpd and Session. + """ + # TODO(tswast): Consider making a separate session-level options object. + return bigframes._config.options + @property def session_id(self): return self._session_id @@ -1826,7 +1836,7 @@ def udf( Turning an arbitrary python function into a BigQuery managed python udf: >>> bq_name = datetime.datetime.now().strftime("bigframes_%Y%m%d%H%M%S%f") - >>> @bpd.udf(dataset="bigfranes_testing", name=bq_name) + >>> @bpd.udf(dataset="bigfranes_testing", name=bq_name) # doctest: +SKIP ... def minutes_to_hours(x: int) -> float: ... return x/60 @@ -1839,8 +1849,8 @@ def udf( 4 120 dtype: Int64 - >>> hours = minutes.apply(minutes_to_hours) - >>> hours + >>> hours = minutes.apply(minutes_to_hours) # doctest: +SKIP + >>> hours # doctest: +SKIP 0 0.0 1 0.5 2 1.0 @@ -1853,7 +1863,7 @@ def udf( packages (optionally with the package version) via `packages` param. >>> bq_name = datetime.datetime.now().strftime("bigframes_%Y%m%d%H%M%S%f") - >>> @bpd.udf( + >>> @bpd.udf( # doctest: +SKIP ... dataset="bigfranes_testing", ... name=bq_name, ... packages=["cryptography"] @@ -1870,14 +1880,14 @@ def udf( ... return f.encrypt(input.encode()).decode() >>> names = bpd.Series(["Alice", "Bob"]) - >>> hashes = names.apply(get_hash) + >>> hashes = names.apply(get_hash) # doctest: +SKIP You can clean-up the BigQuery functions created above using the BigQuery client from the BigQuery DataFrames session: >>> session = bpd.get_global_session() - >>> session.bqclient.delete_routine(minutes_to_hours.bigframes_bigquery_function) - >>> session.bqclient.delete_routine(get_hash.bigframes_bigquery_function) + >>> session.bqclient.delete_routine(minutes_to_hours.bigframes_bigquery_function) # doctest: +SKIP + >>> session.bqclient.delete_routine(get_hash.bigframes_bigquery_function) # doctest: +SKIP Args: input_types (type or sequence(type), Optional): @@ -2297,6 +2307,104 @@ def read_gbq_object_table( s = self._loader.read_gbq_table(object_table)["uri"].str.to_blob(connection) return s.rename(name).to_frame() + # ========================================================================= + # bigframes.pandas attributes + # + # These are included so that Session and bigframes.pandas can be used + # interchangeably. + # ========================================================================= + def cut(self, *args, **kwargs) -> bigframes.series.Series: + """Cuts a BigQuery DataFrames object. + + Included for compatibility between bpd and Session. + + See :func:`bigframes.pandas.cut` for full documentation. + """ + import bigframes.core.reshape.tile + + return bigframes.core.reshape.tile.cut( + *args, + session=self, + **kwargs, + ) + + def DataFrame(self, *args, **kwargs): + """Constructs a DataFrame. + + Included for compatibility between bpd and Session. + + See :class:`bigframes.pandas.DataFrame` for full documentation. + """ + import bigframes.dataframe + + return bigframes.dataframe.DataFrame(*args, session=self, **kwargs) + + @property + def MultiIndex(self) -> bigframes.core.indexes.multi.MultiIndexAccessor: + """Constructs a MultiIndex. + + Included for compatibility between bpd and Session. + + See :class:`bigframes.pandas.MulitIndex` for full documentation. + """ + import bigframes.core.indexes.multi + + return bigframes.core.indexes.multi.MultiIndexAccessor(self) + + def Index(self, *args, **kwargs): + """Constructs a Index. + + Included for compatibility between bpd and Session. + + See :class:`bigframes.pandas.Index` for full documentation. + """ + import bigframes.core.indexes + + return bigframes.core.indexes.Index(*args, session=self, **kwargs) + + def Series(self, *args, **kwargs): + """Constructs a Series. + + Included for compatibility between bpd and Session. + + See :class:`bigframes.pandas.Series` for full documentation. + """ + import bigframes.series + + return bigframes.series.Series(*args, session=self, **kwargs) + + def to_datetime( + self, *args, **kwargs + ) -> Union[pandas.Timestamp, datetime.datetime, bigframes.series.Series]: + """Converts a BigQuery DataFrames object to datetime dtype. + + Included for compatibility between bpd and Session. + + See :func:`bigframes.pandas.to_datetime` for full documentation. + """ + import bigframes.core.tools + + return bigframes.core.tools.to_datetime( + *args, + session=self, + **kwargs, + ) + + def to_timedelta(self, *args, **kwargs): + """Converts a BigQuery DataFrames object to timedelta/duration dtype. + + Included for compatibility between bpd and Session. + + See :func:`bigframes.pandas.to_timedelta` for full documentation. + """ + import bigframes.pandas.core.tools.timedeltas + + return bigframes.pandas.core.tools.timedeltas.to_timedelta( + *args, + session=self, + **kwargs, + ) + def connect(context: Optional[bigquery_options.BigQueryOptions] = None) -> Session: return Session(context) diff --git a/tests/system/small/test_session_as_bpd.py b/tests/system/small/test_session_as_bpd.py new file mode 100644 index 0000000000..e280c551cb --- /dev/null +++ b/tests/system/small/test_session_as_bpd.py @@ -0,0 +1,154 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Check that bpd and Session can be used interchangablely.""" + +from __future__ import annotations + +from typing import cast + +import numpy as np +import pandas.testing + +import bigframes.pandas as bpd +import bigframes.session + + +def test_cut(session: bigframes.session.Session): + sc = [30, 80, 40, 90, 60, 45, 95, 75, 55, 100, 65, 85] + x = [20, 40, 60, 80, 100] + + bpd_result = bpd.cut(sc, x) + session_result = session.cut(sc, x) + + global_session = bpd.get_global_session() + assert global_session is not session + assert bpd_result._session is global_session + assert session_result._session is session + + bpd_pd = bpd_result.to_pandas() + session_pd = session_result.to_pandas() + pandas.testing.assert_series_equal(bpd_pd, session_pd) + + +def test_dataframe(session: bigframes.session.Session): + data = {"col": ["local", None, "data"]} + + bpd_result = bpd.DataFrame(data) + session_result = session.DataFrame(data) + + global_session = bpd.get_global_session() + assert global_session is not session + assert bpd_result._session is global_session + assert session_result._session is session + + bpd_pd = bpd_result.to_pandas() + session_pd = session_result.to_pandas() + pandas.testing.assert_frame_equal(bpd_pd, session_pd) + + +def test_multiindex_from_arrays(session: bigframes.session.Session): + arrays = [[1, 1, 2, 2], ["red", "blue", "red", "blue"]] + + bpd_result = bpd.MultiIndex.from_arrays(arrays, names=("number", "color")) + session_result = session.MultiIndex.from_arrays(arrays, names=("number", "color")) + + global_session = bpd.get_global_session() + assert global_session is not session + assert bpd_result._session is global_session + assert session_result._session is session + + bpd_pd = bpd_result.to_pandas() + session_pd = session_result.to_pandas() + pandas.testing.assert_index_equal(bpd_pd, session_pd) + + +def test_multiindex_from_tuples(session: bigframes.session.Session): + tuples = [(1, "red"), (1, "blue"), (2, "red"), (2, "blue")] + + bpd_result = bpd.MultiIndex.from_tuples(tuples, names=("number", "color")) + session_result = session.MultiIndex.from_tuples(tuples, names=("number", "color")) + + global_session = bpd.get_global_session() + assert global_session is not session + assert bpd_result._session is global_session + assert session_result._session is session + + bpd_pd = bpd_result.to_pandas() + session_pd = session_result.to_pandas() + pandas.testing.assert_index_equal(bpd_pd, session_pd) + + +def test_index(session: bigframes.session.Session): + index = [1, 2, 3] + + bpd_result = bpd.Index(index) + session_result = session.Index(index) + + global_session = bpd.get_global_session() + assert global_session is not session + assert bpd_result._session is global_session + assert session_result._session is session + + bpd_pd = bpd_result.to_pandas() + session_pd = session_result.to_pandas() + pandas.testing.assert_index_equal(bpd_pd, session_pd) + + +def test_series(session: bigframes.session.Session): + series = [1, 2, 3] + + bpd_result = bpd.Series(series) + session_result = session.Series(series) + + global_session = bpd.get_global_session() + assert global_session is not session + assert bpd_result._session is global_session + assert session_result._session is session + + bpd_pd = bpd_result.to_pandas() + session_pd = session_result.to_pandas() + pandas.testing.assert_series_equal(bpd_pd, session_pd) + + +def test_to_datetime(session: bigframes.session.Session): + datetimes = ["2018-10-26 12:00:00", "2018-10-26 13:00:15"] + + bpd_result = bpd.to_datetime(datetimes) + session_result = cast(bpd.Series, session.to_datetime(datetimes)) + + global_session = bpd.get_global_session() + assert global_session is not session + assert bpd_result._session is global_session + assert session_result._session is session + + bpd_pd = bpd_result.to_pandas() + session_pd = session_result.to_pandas() + pandas.testing.assert_series_equal(bpd_pd, session_pd) + + +def test_to_timedelta(session: bigframes.session.Session): + offsets = np.arange(5) + + bpd_result = bpd.to_timedelta(offsets, unit="s") + session_result = session.to_timedelta(offsets, unit="s") + + global_session = bpd.get_global_session() + assert global_session is not session + assert bpd_result._session is global_session + assert session_result._session is session + + bpd_pd = bpd_result.to_pandas() + session_pd = session_result.to_pandas() + pandas.testing.assert_series_equal(bpd_pd, session_pd) diff --git a/tests/unit/test_pandas.py b/tests/unit/test_pandas.py index 73e0b7f2d6..5e75e6b20f 100644 --- a/tests/unit/test_pandas.py +++ b/tests/unit/test_pandas.py @@ -64,8 +64,12 @@ def test_method_matches_session(method_name: str): pandas_method = getattr(bigframes.pandas, method_name) pandas_doc = inspect.getdoc(pandas_method) assert pandas_doc is not None, "docstrings are required" - assert re.sub(leading_whitespace, "", pandas_doc) == re.sub( - leading_whitespace, "", session_doc + + pandas_doc_stripped = re.sub(leading_whitespace, "", pandas_doc) + session_doc_stripped = re.sub(leading_whitespace, "", session_doc) + assert ( + pandas_doc_stripped == session_doc_stripped + or ":`bigframes.pandas" in session_doc_stripped ) # Add `eval_str = True` so that deferred annotations are turned into their @@ -75,18 +79,20 @@ def test_method_matches_session(method_name: str): eval_str=True, globals={**vars(bigframes.session), **{"dataframe": bigframes.dataframe}}, ) - pandas_signature = inspect.signature(pandas_method, eval_str=True) - assert [ - # Kind includes position, which will be an offset. - parameter.replace(kind=inspect.Parameter.POSITIONAL_ONLY) - for parameter in pandas_signature.parameters.values() - ] == [ + session_args = [ # Kind includes position, which will be an offset. parameter.replace(kind=inspect.Parameter.POSITIONAL_ONLY) for parameter in session_signature.parameters.values() # Don't include the first parameter, which is `self: Session` - ][ - 1: + ][1:] + pandas_signature = inspect.signature(pandas_method, eval_str=True) + pandas_args = [ + # Kind includes position, which will be an offset. + parameter.replace(kind=inspect.Parameter.POSITIONAL_ONLY) + for parameter in pandas_signature.parameters.values() + ] + assert session_args == pandas_args or ["args", "kwargs"] == [ + parameter.name for parameter in session_args ] assert pandas_signature.return_annotation == session_signature.return_annotation From 5cc3c5b1391a7dfa062b1d77f001726b013f6337 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 9 Oct 2025 13:33:19 -0700 Subject: [PATCH 140/313] feat: Add barh, pie plot types (#2146) --- bigframes/operations/_matplotlib/__init__.py | 2 + bigframes/operations/_matplotlib/core.py | 32 ++++-- bigframes/operations/plotting.py | 19 +++- .../system/small/operations/test_plotting.py | 36 ++++++ .../pandas/plotting/_core.py | 103 ++++++++++++++++++ 5 files changed, 182 insertions(+), 10 deletions(-) diff --git a/bigframes/operations/_matplotlib/__init__.py b/bigframes/operations/_matplotlib/__init__.py index 5f99d3b50a..caacadf5fe 100644 --- a/bigframes/operations/_matplotlib/__init__.py +++ b/bigframes/operations/_matplotlib/__init__.py @@ -22,6 +22,8 @@ PLOT_CLASSES: dict[str, PLOT_TYPES] = { "area": core.AreaPlot, "bar": core.BarPlot, + "barh": core.BarhPlot, + "pie": core.PiePlot, "line": core.LinePlot, "scatter": core.ScatterPlot, "hist": hist.HistPlot, diff --git a/bigframes/operations/_matplotlib/core.py b/bigframes/operations/_matplotlib/core.py index a5f53b9f64..06fb5235d7 100644 --- a/bigframes/operations/_matplotlib/core.py +++ b/bigframes/operations/_matplotlib/core.py @@ -55,7 +55,12 @@ def _kind(self): @property def _sampling_warning_msg(self) -> typing.Optional[str]: - return None + return ( + "To optimize plotting performance, your data has been downsampled to {sampling_n} " + "rows from the original {total_n} rows. This may result in some data points " + "not being displayed. For a more comprehensive view, consider pre-processing " + "your data by aggregating it or selecting the top categories." + ) def __init__(self, data, **kwargs) -> None: self.kwargs = kwargs @@ -92,6 +97,10 @@ def _compute_plot_data(self): class AreaPlot(SamplingPlot): + @property + def _sampling_warning_msg(self) -> typing.Optional[str]: + return None + @property def _kind(self) -> typing.Literal["area"]: return "area" @@ -102,14 +111,17 @@ class BarPlot(SamplingPlot): def _kind(self) -> typing.Literal["bar"]: return "bar" + +class BarhPlot(SamplingPlot): @property - def _sampling_warning_msg(self) -> typing.Optional[str]: - return ( - "To optimize plotting performance, your data has been downsampled to {sampling_n} " - "rows from the original {total_n} rows. This may result in some data points " - "not being displayed. For a more comprehensive view, consider pre-processing " - "your data by aggregating it or selecting the top categories." - ) + def _kind(self) -> typing.Literal["barh"]: + return "barh" + + +class PiePlot(SamplingPlot): + @property + def _kind(self) -> typing.Literal["pie"]: + return "pie" class LinePlot(SamplingPlot): @@ -123,6 +135,10 @@ class ScatterPlot(SamplingPlot): def _kind(self) -> typing.Literal["scatter"]: return "scatter" + @property + def _sampling_warning_msg(self) -> typing.Optional[str]: + return None + def __init__(self, data, **kwargs) -> None: super().__init__(data, **kwargs) diff --git a/bigframes/operations/plotting.py b/bigframes/operations/plotting.py index a741ed5dd9..df0c138f0f 100644 --- a/bigframes/operations/plotting.py +++ b/bigframes/operations/plotting.py @@ -25,8 +25,8 @@ class PlotAccessor(vendordt.PlotAccessor): __doc__ = vendordt.PlotAccessor.__doc__ - _common_kinds = ("line", "area", "hist", "bar") - _dataframe_kinds = ("scatter",) + _common_kinds = ("line", "area", "hist", "bar", "barh", "pie") + _dataframe_kinds = ("scatter", "hexbin,") _all_kinds = _common_kinds + _dataframe_kinds def __call__(self, **kwargs): @@ -82,6 +82,21 @@ def bar( ): return self(kind="bar", x=x, y=y, **kwargs) + def barh( + self, + x: typing.Optional[typing.Hashable] = None, + y: typing.Optional[typing.Hashable] = None, + **kwargs, + ): + return self(kind="barh", x=x, y=y, **kwargs) + + def pie( + self, + y: typing.Optional[typing.Hashable] = None, + **kwargs, + ): + return self(kind="pie", y=y, **kwargs) + def scatter( self, x: typing.Optional[typing.Hashable] = None, diff --git a/tests/system/small/operations/test_plotting.py b/tests/system/small/operations/test_plotting.py index c2f3ba423f..2585ac8e81 100644 --- a/tests/system/small/operations/test_plotting.py +++ b/tests/system/small/operations/test_plotting.py @@ -264,6 +264,42 @@ def test_bar(scalars_dfs, col_names, alias): tm.assert_almost_equal(line.get_data()[1], pd_line.get_data()[1]) +@pytest.mark.parametrize( + ("col_names",), + [ + pytest.param(["int64_col", "float64_col", "int64_too"], id="df"), + pytest.param(["int64_col"], id="series"), + ], +) +def test_barh(scalars_dfs, col_names): + scalars_df, scalars_pandas_df = scalars_dfs + ax = scalars_df[col_names].plot.barh() + pd_ax = scalars_pandas_df[col_names].plot.barh() + tm.assert_almost_equal(ax.get_xticks(), pd_ax.get_xticks()) + tm.assert_almost_equal(ax.get_yticks(), pd_ax.get_yticks()) + for line, pd_line in zip(ax.lines, pd_ax.lines): + # Compare y coordinates between the lines + tm.assert_almost_equal(line.get_data()[1], pd_line.get_data()[1]) + + +@pytest.mark.parametrize( + ("col_names",), + [ + pytest.param(["int64_col", "float64_col", "int64_too"], id="df"), + pytest.param(["int64_col"], id="series"), + ], +) +def test_pie(scalars_dfs, col_names): + scalars_df, scalars_pandas_df = scalars_dfs + ax = scalars_df[col_names].abs().plot.pie(y="int64_col") + pd_ax = scalars_pandas_df[col_names].abs().plot.pie(y="int64_col") + tm.assert_almost_equal(ax.get_xticks(), pd_ax.get_xticks()) + tm.assert_almost_equal(ax.get_yticks(), pd_ax.get_yticks()) + for line, pd_line in zip(ax.lines, pd_ax.lines): + # Compare y coordinates between the lines + tm.assert_almost_equal(line.get_data()[1], pd_line.get_data()[1]) + + @pytest.mark.parametrize( ("col_names", "alias"), [ diff --git a/third_party/bigframes_vendored/pandas/plotting/_core.py b/third_party/bigframes_vendored/pandas/plotting/_core.py index 4ed5c8eb0b..b0c28ddfe9 100644 --- a/third_party/bigframes_vendored/pandas/plotting/_core.py +++ b/third_party/bigframes_vendored/pandas/plotting/_core.py @@ -275,6 +275,109 @@ def bar( """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def barh( + self, + x: typing.Optional[typing.Hashable] = None, + y: typing.Optional[typing.Hashable] = None, + **kwargs, + ): + """ + Draw a horizontal bar plot. + + This function calls `pandas.plot` to generate a plot with a random sample + of items. For consistent results, the random sampling is reproducible. + Use the `sampling_random_state` parameter to modify the sampling seed. + + **Examples:** + + Basic plot. + + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None + >>> df = bpd.DataFrame({'lab':['A', 'B', 'C'], 'val':[10, 30, 20]}) + >>> ax = df.plot.barh(x='lab', y='val', rot=0) + + Plot a whole dataframe to a barh plot. Each column is assigned a distinct color, + and each row is nested in a group along the horizontal axis. + + >>> speed = [0.1, 17.5, 40, 48, 52, 69, 88] + >>> lifespan = [2, 8, 70, 1.5, 25, 12, 28] + >>> index = ['snail', 'pig', 'elephant', + ... 'rabbit', 'giraffe', 'coyote', 'horse'] + >>> df = bpd.DataFrame({'speed': speed, 'lifespan': lifespan}, index=index) + >>> ax = df.plot.barh(rot=0) + + Plot stacked barh charts for the DataFrame. + + >>> ax = df.plot.barh(stacked=True) + + If you don’t like the default colours, you can specify how you’d like each column + to be colored. + + >>> axes = df.plot.barh( + ... rot=0, subplots=True, color={"speed": "red", "lifespan": "green"} + ... ) + + Args: + x (label or position, optional): + Allows plotting of one column versus another. If not specified, the index + of the DataFrame is used. + y (label or position, optional): + Allows plotting of one column versus another. If not specified, all numerical + columns are used. + **kwargs: + Additional keyword arguments are documented in + :meth:`DataFrame.plot`. + + Returns: + matplotlib.axes.Axes or numpy.ndarray: + Area plot, or array of area plots if subplots is True. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def pie( + self, + y: typing.Optional[typing.Hashable] = None, + **kwargs, + ): + """ + Generate a pie plot. + + A pie plot is a proportional representation of the numerical data in a + column. This function wraps :meth:`matplotlib.pyplot.pie` for the + specified column. If no column reference is passed and + ``subplots=True`` a pie plot is drawn for each numerical column + independently. + + **Examples:** + + In the example below we have a DataFrame with the information about + planet's mass and radius. We pass the 'mass' column to the + pie function to get a pie plot. + + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None + + >>> df = bpd.DataFrame({'mass': [0.330, 4.87 , 5.97], + ... 'radius': [2439.7, 6051.8, 6378.1]}, + ... index=['Mercury', 'Venus', 'Earth']) + >>> plot = df.plot.pie(y='mass', figsize=(5, 5)) + + >>> plot = df.plot.pie(subplots=True, figsize=(11, 6)) + + Args: + y (int or label, optional): + Label or position of the column to plot. + If not provided, ``subplots=True`` argument must be passed. + **kwargs: + Keyword arguments to pass on to :meth:`DataFrame.plot`. + + Returns: + matplotlib.axes.Axes or np.ndarray: + A NumPy array is returned when `subplots` is True. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def scatter( self, x: typing.Optional[typing.Hashable] = None, From 35c1c33b85d1b92e402aab73677df3ffe43a51b4 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 9 Oct 2025 16:21:20 -0700 Subject: [PATCH 141/313] fix: Fix too many cluster columns requested by caching (#2155) --- bigframes/session/bq_caching_executor.py | 8 ++++++-- tests/system/small/test_dataframe.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/bigframes/session/bq_caching_executor.py b/bigframes/session/bq_caching_executor.py index d4cfa13aa4..c830ca1e29 100644 --- a/bigframes/session/bq_caching_executor.py +++ b/bigframes/session/bq_caching_executor.py @@ -637,14 +637,18 @@ def _execute_plan_gbq( create_table = True if not cache_spec.cluster_cols: - assert len(cache_spec.cluster_cols) <= _MAX_CLUSTER_COLUMNS offsets_id = bigframes.core.identifiers.ColumnId( bigframes.core.guid.generate_guid() ) plan = nodes.PromoteOffsetsNode(plan, offsets_id) cluster_cols = [offsets_id.sql] else: - cluster_cols = cache_spec.cluster_cols + cluster_cols = [ + col + for col in cache_spec.cluster_cols + if bigframes.dtypes.is_clusterable(plan.schema.get_type(col)) + ] + cluster_cols = cluster_cols[:_MAX_CLUSTER_COLUMNS] compiled = compile.compile_sql( compile.CompileRequest( diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index d0847eee4e..1e6151b7f4 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -5537,6 +5537,23 @@ def test_df_cached(scalars_df_index): pandas.testing.assert_frame_equal(df.to_pandas(), df_cached_copy.to_pandas()) +def test_df_cached_many_index_cols(scalars_df_index): + index_cols = [ + "int64_too", + "time_col", + "int64_col", + "bool_col", + "date_col", + "timestamp_col", + "string_col", + ] + df = scalars_df_index.set_index(index_cols) + df = df[df["rowindex_2"] % 2 == 0] + + df_cached_copy = df.cache() + pandas.testing.assert_frame_equal(df.to_pandas(), df_cached_copy.to_pandas()) + + def test_assign_after_binop_row_joins(): pd_df = pd.DataFrame( { From 7cb9e476b9742f59a7b00b43df1f5697903da2be Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Fri, 10 Oct 2025 07:49:30 -0700 Subject: [PATCH 142/313] test: fix test_read_gbq_query timeout on g3 python 3.13 tests (#2160) --- tests/unit/session/test_read_gbq_query.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/session/test_read_gbq_query.py b/tests/unit/session/test_read_gbq_query.py index 1f9d2fb945..d078c64af7 100644 --- a/tests/unit/session/test_read_gbq_query.py +++ b/tests/unit/session/test_read_gbq_query.py @@ -35,3 +35,4 @@ def test_read_gbq_query_sets_destination_table(): assert query == "SELECT 'my-test-query';" assert config.destination is not None + session.close() From 8714977aa21567264e304d01965a7bb7e34b09e5 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Fri, 10 Oct 2025 10:26:03 -0700 Subject: [PATCH 143/313] refactor: support agg_ops.ShiftOp and DiffOp for the sqlglot compiler (#2156) --- .../sqlglot/aggregations/unary_compiler.py | 38 ++++++++++++++ .../test_diff/diff_bool.sql | 13 +++++ .../test_diff/diff_int.sql | 13 +++++ .../test_unary_compiler/test_shift/lag.sql | 13 +++++ .../test_unary_compiler/test_shift/lead.sql | 13 +++++ .../test_unary_compiler/test_shift/noop.sql | 13 +++++ .../aggregations/test_unary_compiler.py | 49 +++++++++++++++++++ 7 files changed, 152 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_bool.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_int.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lag.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lead.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/noop.sql diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py index 16bd3ef099..cfa27909c6 100644 --- a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -151,6 +151,23 @@ def _( ) +@UNARY_OP_REGISTRATION.register(agg_ops.DiffOp) +def _( + op: agg_ops.DiffOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + shift_op_impl = UNARY_OP_REGISTRATION[agg_ops.ShiftOp(0)] + shifted = shift_op_impl(agg_ops.ShiftOp(op.periods), column, window) + if column.dtype in (dtypes.BOOL_DTYPE, dtypes.INT_DTYPE, dtypes.FLOAT_DTYPE): + if column.dtype == dtypes.BOOL_DTYPE: + return sge.NEQ(this=column.expr, expression=shifted) + else: + return sge.Sub(this=column.expr, expression=shifted) + else: + raise TypeError(f"Cannot perform diff on type {column.dtype}") + + @UNARY_OP_REGISTRATION.register(agg_ops.MaxOp) def _( op: agg_ops.MaxOp, @@ -240,6 +257,27 @@ def _( return apply_window_if_present(sge.func("COUNT", sge.convert(1)), window) +@UNARY_OP_REGISTRATION.register(agg_ops.ShiftOp) +def _( + op: agg_ops.ShiftOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + if op.periods == 0: # No-op + return column.expr + if op.periods > 0: + return apply_window_if_present( + sge.func("LAG", column.expr, sge.convert(op.periods)), + window, + include_framing_clauses=False, + ) + return apply_window_if_present( + sge.func("LEAD", column.expr, sge.convert(-op.periods)), + window, + include_framing_clauses=False, + ) + + @UNARY_OP_REGISTRATION.register(agg_ops.SumOp) def _( op: agg_ops.SumOp, diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_bool.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_bool.sql new file mode 100644 index 0000000000..6c7d37c037 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_bool.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_0` <> LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `diff_bool` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_int.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_int.sql new file mode 100644 index 0000000000..1ce4953d87 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_int.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_0` - LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `diff_int` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lag.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lag.sql new file mode 100644 index 0000000000..59e2c47edf --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lag.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `lag` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lead.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lead.sql new file mode 100644 index 0000000000..5c82b5db39 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lead.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LEAD(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `lead` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/noop.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/noop.sql new file mode 100644 index 0000000000..fef4a2bde8 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/noop.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_0` AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `noop` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index ea15f155ad..a83a494e55 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -127,6 +127,28 @@ def test_dense_rank(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_diff(scalar_types_df: bpd.DataFrame, snapshot): + # Test integer + int_col = "int64_col" + bf_df_int = scalar_types_df[[int_col]] + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(int_col),)) + int_op = agg_exprs.UnaryAggregation( + agg_ops.DiffOp(periods=1), expression.deref(int_col) + ) + int_sql = _apply_unary_window_op(bf_df_int, int_op, window, "diff_int") + snapshot.assert_match(int_sql, "diff_int.sql") + + # Test boolean + bool_col = "bool_col" + bf_df_bool = scalar_types_df[[bool_col]] + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(bool_col),)) + bool_op = agg_exprs.UnaryAggregation( + agg_ops.DiffOp(periods=1), expression.deref(bool_col) + ) + bool_sql = _apply_unary_window_op(bf_df_bool, bool_op, window, "diff_bool") + snapshot.assert_match(bool_sql, "diff_bool.sql") + + def test_first(scalar_types_df: bpd.DataFrame, snapshot): if sys.version_info < (3, 12): pytest.skip( @@ -271,6 +293,33 @@ def test_rank(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_shift(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + + # Test lag + lag_op = agg_exprs.UnaryAggregation( + agg_ops.ShiftOp(periods=1), expression.deref(col_name) + ) + lag_sql = _apply_unary_window_op(bf_df, lag_op, window, "lag") + snapshot.assert_match(lag_sql, "lag.sql") + + # Test lead + lead_op = agg_exprs.UnaryAggregation( + agg_ops.ShiftOp(periods=-1), expression.deref(col_name) + ) + lead_sql = _apply_unary_window_op(bf_df, lead_op, window, "lead") + snapshot.assert_match(lead_sql, "lead.sql") + + # Test no-op + noop_op = agg_exprs.UnaryAggregation( + agg_ops.ShiftOp(periods=0), expression.deref(col_name) + ) + noop_sql = _apply_unary_window_op(bf_df, noop_op, window, "noop") + snapshot.assert_match(noop_sql, "noop.sql") + + def test_sum(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["int64_col", "bool_col"]] agg_ops_map = { From e0aa9cc0d8ea032cbb0d8cd5907a97feee0e6165 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Fri, 10 Oct 2025 10:45:20 -0700 Subject: [PATCH 144/313] chore: improve wording of ai notebook (#2161) --- notebooks/generative_ai/ai_functions.ipynb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/notebooks/generative_ai/ai_functions.ipynb b/notebooks/generative_ai/ai_functions.ipynb index 9362e93b59..3783ad8365 100644 --- a/notebooks/generative_ai/ai_functions.ipynb +++ b/notebooks/generative_ai/ai_functions.ipynb @@ -56,7 +56,7 @@ "id": "aee05821", "metadata": {}, "source": [ - "This notebook provides a brief introduction to how to use BigFrames AI functions" + "This notebook provides a brief introduction to AI functions in BigQuery Dataframes." ] }, { @@ -145,7 +145,7 @@ "id": "b606c51f", "metadata": {}, "source": [ - "You can also include additional model parameters into your function call, as long as they satisfy the structure of `generateContent` [request body format](https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.endpoints/generateContent#request-body). In the next example, you use `maxOutputTokens` to limite the length of the generated content." + "You can also include additional model parameters into your function call, as long as they conform to the structure of `generateContent` [request body format](https://cloud.google.com/vertex-ai/docs/reference/rest/v1/projects.locations.endpoints/generateContent#request-body). In the next example, you use `maxOutputTokens` to limit the length of the generated content." ] }, { @@ -186,7 +186,7 @@ "source": [ "The answers are cut short as expected.\n", "\n", - "In addition to `ai.generate`, you can use `ai.generate_bool`, `ai.generate_int`, and `ai.generate_double` for other type of outputs." + "In addition to `ai.generate`, you can use `ai.generate_bool`, `ai.generate_int`, and `ai.generate_double` for other output types." ] }, { @@ -196,7 +196,7 @@ "source": [ "## ai.if_\n", "\n", - "`ai.if_` generates a series of booleans, unlike `ai.generate_bool` where you get a series of structs. It's a handy tool for filtering your data. not only because it directly returns a boolean, but also because it provides more optimization during data processing. Here is an example of using `ai.if_`:" + "`ai.if_` generates a series of booleans. It's a handy tool for joining and filtering your data, not only because it directly returns boolean values, but also because it provides more optimization during data processing. Here is an example of using `ai.if_`:" ] }, { @@ -284,7 +284,7 @@ "id": "63b5a59f", "metadata": {}, "source": [ - "`ai.score` ranks your input based on the prompt. You can then sort your data based on their ranks. For example:" + "`ai.score` ranks your input based on the prompt and assigns a double value (i.e. a score) to each item. You can then sort your data based on their scores. For example:" ] }, { @@ -460,7 +460,7 @@ "id": "9e4037bc", "metadata": {}, "source": [ - "Note that this function can only return the values that are present in your provided categories. If your categories do not cover all cases, your will get wrong answers:" + "Note that this function can only return the values that are provided in the `categories` argument. If your categories do not cover all cases, your may get wrong answers:" ] }, { From 8f9cbc3f35ef345009c632aadbcbce0d98402241 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Fri, 10 Oct 2025 13:16:21 -0700 Subject: [PATCH 145/313] refactor: add agg_ops.TimeSeriesDiffOp and DateSeriesDiffOp to sqlglot compiler (#2164) --- .../sqlglot/aggregations/unary_compiler.py | 38 +++++++++++++++++++ .../test_date_series_diff/out.sql | 17 +++++++++ .../test_time_series_diff/out.sql | 17 +++++++++ .../aggregations/test_unary_compiler.py | 22 +++++++++++ 4 files changed, 94 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_date_series_diff/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_time_series_diff/out.sql diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py index cfa27909c6..d157f07df2 100644 --- a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -98,6 +98,27 @@ def _( return apply_window_if_present(sge.func("COUNT", column.expr), window) +@UNARY_OP_REGISTRATION.register(agg_ops.DateSeriesDiffOp) +def _( + op: agg_ops.DateSeriesDiffOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + if column.dtype != dtypes.DATE_DTYPE: + raise TypeError(f"Cannot perform date series diff on type {column.dtype}") + shift_op_impl = UNARY_OP_REGISTRATION[agg_ops.ShiftOp(0)] + shifted = shift_op_impl(agg_ops.ShiftOp(op.periods), column, window) + # Conversion factor from days to microseconds + conversion_factor = 24 * 60 * 60 * 1_000_000 + return sge.Cast( + this=sge.DateDiff( + this=column.expr, expression=shifted, unit=sge.Identifier(this="DAY") + ) + * sge.convert(conversion_factor), + to="INT64", + ) + + @UNARY_OP_REGISTRATION.register(agg_ops.DenseRankOp) def _( op: agg_ops.DenseRankOp, @@ -293,3 +314,20 @@ def _( # Will be null if all inputs are null. Pandas defaults to zero sum though. zero = pd.to_timedelta(0) if column.dtype == dtypes.TIMEDELTA_DTYPE else 0 return sge.func("IFNULL", expr, ir._literal(zero, column.dtype)) + + +@UNARY_OP_REGISTRATION.register(agg_ops.TimeSeriesDiffOp) +def _( + op: agg_ops.TimeSeriesDiffOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + if column.dtype != dtypes.TIMESTAMP_DTYPE: + raise TypeError(f"Cannot perform time series diff on type {column.dtype}") + shift_op_impl = UNARY_OP_REGISTRATION[agg_ops.ShiftOp(0)] + shifted = shift_op_impl(agg_ops.ShiftOp(op.periods), column, window) + return sge.TimestampDiff( + this=column.expr, + expression=shifted, + unit=sge.Identifier(this="MICROSECOND"), + ) diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_date_series_diff/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_date_series_diff/out.sql new file mode 100644 index 0000000000..599d8333c9 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_date_series_diff/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `date_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CAST(DATE_DIFF( + `bfcol_0`, + LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST), + DAY + ) * 86400000000 AS INT64) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `diff_date` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_time_series_diff/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_time_series_diff/out.sql new file mode 100644 index 0000000000..8ed95b3c07 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_time_series_diff/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TIMESTAMP_DIFF( + `bfcol_0`, + LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST), + MICROSECOND + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `diff_time` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index a83a494e55..da388ccad1 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -127,6 +127,17 @@ def test_dense_rank(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_date_series_diff(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "date_col" + bf_df = scalar_types_df[[col_name]] + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + op = agg_exprs.UnaryAggregation( + agg_ops.DateSeriesDiffOp(periods=1), expression.deref(col_name) + ) + sql = _apply_unary_window_op(bf_df, op, window, "diff_date") + snapshot.assert_match(sql, "out.sql") + + def test_diff(scalar_types_df: bpd.DataFrame, snapshot): # Test integer int_col = "int64_col" @@ -331,3 +342,14 @@ def test_sum(scalar_types_df: bpd.DataFrame, snapshot): ) snapshot.assert_match(sql, "out.sql") + + +def test_time_series_diff(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df = scalar_types_df[[col_name]] + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + op = agg_exprs.UnaryAggregation( + agg_ops.TimeSeriesDiffOp(periods=1), expression.deref(col_name) + ) + sql = _apply_unary_window_op(bf_df, op, window, "diff_time") + snapshot.assert_match(sql, "out.sql") From 93a0749392b84f27162654fe5ea5baa329a23f99 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Mon, 13 Oct 2025 12:08:53 -0700 Subject: [PATCH 146/313] docs: fix ai function related docs (#2149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * doc: fix ai function related docs * fix docs * fix format * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --------- Co-authored-by: Owl Bot --- bigframes/bigquery/_operations/ai.py | 52 +++++++++++++++++++++++++++- docs/templates/toc.yml | 3 +- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/bigframes/bigquery/_operations/ai.py b/bigframes/bigquery/_operations/ai.py index 5c001d4caf..f4302f8ece 100644 --- a/bigframes/bigquery/_operations/ai.py +++ b/bigframes/bigquery/_operations/ai.py @@ -65,7 +65,7 @@ def generate( 1 Ottawa\\n Name: result, dtype: string - You get structured output when the `output_schema` parameter is set: + You get structured output when the `output_schema` parameter is set: >>> animals = bpd.Series(["Rabbit", "Spider"]) >>> bbq.ai.generate(animals, output_schema={"number_of_legs": "INT64", "is_herbivore": "BOOL"}) @@ -73,6 +73,13 @@ def generate( 1 {'is_herbivore': False, 'number_of_legs': 8, '... dtype: struct>, status: string>[pyarrow] + .. note:: + + This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + Args: prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series @@ -165,6 +172,13 @@ def generate_bool( 2 False Name: result, dtype: boolean + .. note:: + + This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + Args: prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series @@ -240,6 +254,13 @@ def generate_int( 2 8 Name: result, dtype: Int64 + .. note:: + + This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + Args: prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series @@ -315,6 +336,13 @@ def generate_double( 2 8.0 Name: result, dtype: Float64 + .. note:: + + This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + Args: prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series @@ -371,6 +399,7 @@ def if_( provides optimization such that not all rows are evaluated with the LLM. **Examples:** + >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq >>> bpd.options.display.progress_bar = None @@ -386,6 +415,13 @@ def if_( 1 Illinois dtype: string + .. note:: + + This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + Args: prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series @@ -433,6 +469,13 @@ def classify( [2 rows x 2 columns] + .. note:: + + This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + Args: input (str | Series | List[str|Series] | Tuple[str|Series, ...]): A mixture of Series and string literals that specifies the input to send to the model. The Series can be BigFrames Series @@ -482,6 +525,13 @@ def score( 2 3.0 dtype: Float64 + .. note:: + + This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + Args: prompt (str | Series | List[str|Series] | Tuple[str|Series, ...]): A mixture of Series and string literals that specifies the prompt to send to the model. The Series can be BigFrames Series diff --git a/docs/templates/toc.yml b/docs/templates/toc.yml index ad96977152..f368cf21ae 100644 --- a/docs/templates/toc.yml +++ b/docs/templates/toc.yml @@ -219,7 +219,8 @@ - name: BigQuery built-in functions uid: bigframes.bigquery - name: BigQuery AI Functions - uid: bigframes.bigquery.ai + uid: bigframes.bigquery._operations.ai + status: beta name: bigframes.bigquery - items: - name: GeoSeries From bbfdb207a5595d3621048fac9d8138bbf736fb7b Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 13:25:41 -0700 Subject: [PATCH 147/313] chore(main): release 2.25.0 (#2145) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 28 +++++++++++++++++++++++ bigframes/version.py | 4 ++-- third_party/bigframes_vendored/version.py | 4 ++-- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b00fd956d..86d7315896 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ [1]: https://pypi.org/project/bigframes/#history +## [2.25.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.24.0...v2.25.0) (2025-10-13) + + +### Features + +* Add barh, pie plot types ([#2146](https://github.com/googleapis/python-bigquery-dataframes/issues/2146)) ([5cc3c5b](https://github.com/googleapis/python-bigquery-dataframes/commit/5cc3c5b1391a7dfa062b1d77f001726b013f6337)) +* Add Index.__eq__ for consts, aligned objects ([#2141](https://github.com/googleapis/python-bigquery-dataframes/issues/2141)) ([8514200](https://github.com/googleapis/python-bigquery-dataframes/commit/85142008ec895fa078d192bbab942d0257f70df3)) +* Add output_schema parameter to ai.generate() ([#2139](https://github.com/googleapis/python-bigquery-dataframes/issues/2139)) ([ef0b0b7](https://github.com/googleapis/python-bigquery-dataframes/commit/ef0b0b73843da2a93baf08e4cd5457fbb590b89c)) +* Create session-scoped `cut`, `DataFrame`, `MultiIndex`, `Index`, `Series`, `to_datetime`, and `to_timedelta` methods ([#2157](https://github.com/googleapis/python-bigquery-dataframes/issues/2157)) ([5e1e809](https://github.com/googleapis/python-bigquery-dataframes/commit/5e1e8098ecf212c91d73fa80d722d1cb3e46668b)) +* Replace ML.GENERATE_TEXT with AI.GENERATE for audio transcription ([#2151](https://github.com/googleapis/python-bigquery-dataframes/issues/2151)) ([a410d0a](https://github.com/googleapis/python-bigquery-dataframes/commit/a410d0ae43ef3b053b650804156eda0b1f569da9)) +* Support string literal inputs for AI functions ([#2152](https://github.com/googleapis/python-bigquery-dataframes/issues/2152)) ([7600001](https://github.com/googleapis/python-bigquery-dataframes/commit/760000122dc190ac8a3303234cf4cbee1bbb9493)) + + +### Bug Fixes + +* Address typo in error message ([#2142](https://github.com/googleapis/python-bigquery-dataframes/issues/2142)) ([cdf2dd5](https://github.com/googleapis/python-bigquery-dataframes/commit/cdf2dd55a0c03da50ab92de09788cafac0abf6f6)) +* Avoid possible circular imports in global session ([#2115](https://github.com/googleapis/python-bigquery-dataframes/issues/2115)) ([095c0b8](https://github.com/googleapis/python-bigquery-dataframes/commit/095c0b85a25a2e51087880909597cc62a0341c93)) +* Fix too many cluster columns requested by caching ([#2155](https://github.com/googleapis/python-bigquery-dataframes/issues/2155)) ([35c1c33](https://github.com/googleapis/python-bigquery-dataframes/commit/35c1c33b85d1b92e402aab73677df3ffe43a51b4)) +* Show progress even in job optional queries ([#2119](https://github.com/googleapis/python-bigquery-dataframes/issues/2119)) ([1f48d3a](https://github.com/googleapis/python-bigquery-dataframes/commit/1f48d3a62e7e6dac4acb39e911daf766b8e2fe62)) +* Yield row count from read session if otherwise unknown ([#2148](https://github.com/googleapis/python-bigquery-dataframes/issues/2148)) ([8997d4d](https://github.com/googleapis/python-bigquery-dataframes/commit/8997d4d7d9965e473195f98c550c80657035b7e1)) + + +### Documentation + +* Add a brief intro notebook for bbq AI functions ([#2150](https://github.com/googleapis/python-bigquery-dataframes/issues/2150)) ([1f434fb](https://github.com/googleapis/python-bigquery-dataframes/commit/1f434fb5c7c00601654b3ab19c6ad7fceb258bd6)) +* Fix ai function related docs ([#2149](https://github.com/googleapis/python-bigquery-dataframes/issues/2149)) ([93a0749](https://github.com/googleapis/python-bigquery-dataframes/commit/93a0749392b84f27162654fe5ea5baa329a23f99)) +* Remove progress bar from getting started template ([#2143](https://github.com/googleapis/python-bigquery-dataframes/issues/2143)) ([d13abad](https://github.com/googleapis/python-bigquery-dataframes/commit/d13abadbcd68d03997e8dc11bb7a2b14bbd57fcc)) + ## [2.24.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.23.0...v2.24.0) (2025-10-07) diff --git a/bigframes/version.py b/bigframes/version.py index 93445c0c0d..0236e8236e 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.24.0" +__version__ = "2.25.0" # {x-release-please-start-date} -__release_date__ = "2025-10-07" +__release_date__ = "2025-10-13" # {x-release-please-end} diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index 93445c0c0d..0236e8236e 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.24.0" +__version__ = "2.25.0" # {x-release-please-start-date} -__release_date__ = "2025-10-07" +__release_date__ = "2025-10-13" # {x-release-please-end} From 305e57d43115ca4a4b2cf47e4a58f6377c7be58d Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Mon, 13 Oct 2025 13:56:58 -0700 Subject: [PATCH 148/313] refactor: support agg_ops.ArrayAggOp and StringAggOp to sqlglot compiler (#2163) --- .../aggregations/ordered_unary_compiler.py | 39 +++++++-- .../test_array_agg/out.sql | 12 +++ .../test_string_agg/out.sql | 15 ++++ .../test_ordered_unary_compiler.py | 80 +++++++++++++++++++ 4 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_array_agg/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_string_agg/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/test_ordered_unary_compiler.py diff --git a/bigframes/core/compile/sqlglot/aggregations/ordered_unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/ordered_unary_compiler.py index dea30ec206..9024a9ec89 100644 --- a/bigframes/core/compile/sqlglot/aggregations/ordered_unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/ordered_unary_compiler.py @@ -14,11 +14,8 @@ from __future__ import annotations -import typing - import sqlglot.expressions as sge -from bigframes.core import window_spec import bigframes.core.compile.sqlglot.aggregations.op_registration as reg import bigframes.core.compile.sqlglot.expressions.typed_expr as typed_expr from bigframes.operations import aggregations as agg_ops @@ -29,9 +26,35 @@ def compile( op: agg_ops.WindowOp, column: typed_expr.TypedExpr, - window: typing.Optional[window_spec.WindowSpec] = None, - order_by: typing.Sequence[sge.Expression] = [], + *, + order_by: tuple[sge.Expression, ...], +) -> sge.Expression: + return ORDERED_UNARY_OP_REGISTRATION[op](op, column, order_by=order_by) + + +@ORDERED_UNARY_OP_REGISTRATION.register(agg_ops.ArrayAggOp) +def _( + op: agg_ops.ArrayAggOp, + column: typed_expr.TypedExpr, + *, + order_by: tuple[sge.Expression, ...], ) -> sge.Expression: - return ORDERED_UNARY_OP_REGISTRATION[op]( - op, column, window=window, order_by=order_by - ) + expr = column.expr + if len(order_by) > 0: + expr = sge.Order(this=column.expr, expressions=list(order_by)) + return sge.IgnoreNulls(this=sge.ArrayAgg(this=expr)) + + +@ORDERED_UNARY_OP_REGISTRATION.register(agg_ops.StringAggOp) +def _( + op: agg_ops.StringAggOp, + column: typed_expr.TypedExpr, + *, + order_by: tuple[sge.Expression, ...], +) -> sge.Expression: + expr = column.expr + if len(order_by) > 0: + expr = sge.Order(this=expr, expressions=list(order_by)) + + expr = sge.GroupConcat(this=expr, separator=sge.convert(op.sep)) + return sge.func("COALESCE", expr, sge.convert("")) diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_array_agg/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_array_agg/out.sql new file mode 100644 index 0000000000..43e0a03db4 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_array_agg/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + ARRAY_AGG(`bfcol_0` IGNORE NULLS ORDER BY `bfcol_0` IS NULL ASC, `bfcol_0` ASC) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_string_agg/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_string_agg/out.sql new file mode 100644 index 0000000000..115d7e37ee --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_string_agg/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + COALESCE(STRING_AGG(`bfcol_0`, ',' + ORDER BY + `bfcol_0` IS NULL ASC, + `bfcol_0` ASC), '') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_ordered_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_ordered_unary_compiler.py new file mode 100644 index 0000000000..2f88fb5d0c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/test_ordered_unary_compiler.py @@ -0,0 +1,80 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +import typing + +import pytest + +from bigframes.core import agg_expressions as agg_exprs +from bigframes.core import array_value, identifiers, nodes, ordering +from bigframes.operations import aggregations as agg_ops +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def _apply_ordered_unary_agg_ops( + obj: bpd.DataFrame, + ops_list: typing.Sequence[agg_exprs.UnaryAggregation], + new_names: typing.Sequence[str], + ordering_args: typing.Sequence[str], +) -> str: + ordering_exprs = tuple(ordering.ascending_over(arg) for arg in ordering_args) + aggs = [(op, identifiers.ColumnId(name)) for op, name in zip(ops_list, new_names)] + + agg_node = nodes.AggregateNode( + obj._block.expr.node, + aggregations=tuple(aggs), + by_column_ids=(), + order_by=ordering_exprs, + ) + result = array_value.ArrayValue(agg_node) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return sql + + +def test_array_agg(scalar_types_df: bpd.DataFrame, snapshot): + # TODO: Verify "NULL LAST" syntax issue on Python < 3.12 + if sys.version_info < (3, 12): + pytest.skip( + "Skipping test due to inconsistent SQL formatting on Python < 3.12.", + ) + + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.ArrayAggOp().as_expr(col_name) + sql = _apply_ordered_unary_agg_ops( + bf_df, [agg_expr], [col_name], ordering_args=[col_name] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_string_agg(scalar_types_df: bpd.DataFrame, snapshot): + # TODO: Verify "NULL LAST" syntax issue on Python < 3.12 + if sys.version_info < (3, 12): + pytest.skip( + "Skipping test due to inconsistent SQL formatting on Python < 3.12.", + ) + + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.StringAggOp(sep=",").as_expr(col_name) + sql = _apply_ordered_unary_agg_ops( + bf_df, [agg_expr], [col_name], ordering_args=[col_name] + ) + + snapshot.assert_match(sql, "out.sql") From 60b28bf8106065f103a03a0c6563b990aba30c4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Tue, 14 Oct 2025 12:31:11 -0500 Subject: [PATCH 149/313] chore: make test_blob_transcribe robust to model flakes (#2162) --- tests/system/large/blob/test_function.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/system/large/blob/test_function.py b/tests/system/large/blob/test_function.py index 70c3084261..f006395d2f 100644 --- a/tests/system/large/blob/test_function.py +++ b/tests/system/large/blob/test_function.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import logging import os import traceback from typing import Generator @@ -434,6 +435,15 @@ def test_blob_transcribe( actual_text = actual[0]["content"] else: actual_text = actual[0] + + if pd.isna(actual_text) or actual_text == "": + # Ensure the tests are robust to flakes in the model, which isn't + # particularly useful information for the bigframes team. + logging.warning( + f"blob_transcribe() model {model_name} verbose={verbose} failure" + ) + return + actual_len = len(actual_text) relative_length_tolerance = 0.2 From ddb4df0dd991bef051e2a365c5cacf502803014d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Tue, 14 Oct 2025 13:56:58 -0500 Subject: [PATCH 150/313] fix: `blob.display()` shows for null rows (#2158) --- bigframes/operations/blob.py | 9 ++++++-- noxfile.py | 5 +++++ tests/system/small/blob/test_io.py | 33 ++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/bigframes/operations/blob.py b/bigframes/operations/blob.py index 7f419bc5d8..d505f096f4 100644 --- a/bigframes/operations/blob.py +++ b/bigframes/operations/blob.py @@ -228,9 +228,14 @@ def display( df._set_internal_query_job(query_job) def display_single_url( - read_url: str, content_type: Union[str, pd._libs.missing.NAType] + read_url: Union[str, pd._libs.missing.NAType], + content_type: Union[str, pd._libs.missing.NAType], ): - if content_type is pd.NA: # display as raw data or error + if pd.isna(read_url): + ipy_display.display("") + return + + if pd.isna(content_type): # display as raw data or error response = requests.get(read_url) ipy_display.display(response.content) return diff --git a/noxfile.py b/noxfile.py index a46dc36b3e..988847fc14 100644 --- a/noxfile.py +++ b/noxfile.py @@ -126,6 +126,11 @@ # Sessions are executed in the order so putting the smaller sessions # ahead to fail fast at presubmit running. nox.options.sessions = [ + # Include unit_noextras to ensure at least some unit tests contribute to + # coverage. + # TODO(tswast): Consider removing this when unit_noextras and cover is run + # from GitHub actions. + "unit_noextras", "system-3.9", # No extras. "system-3.11", "cover", diff --git a/tests/system/small/blob/test_io.py b/tests/system/small/blob/test_io.py index d3b4c4faa0..5ada4fabb0 100644 --- a/tests/system/small/blob/test_io.py +++ b/tests/system/small/blob/test_io.py @@ -12,6 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +from unittest import mock + +import IPython.display import pandas as pd import bigframes @@ -92,3 +95,33 @@ def test_blob_create_read_gbq_object_table( pd.testing.assert_frame_equal( pd_blob_df, expected_df, check_dtype=False, check_index_type=False ) + + +def test_display_images(monkeypatch, images_mm_df: bpd.DataFrame): + mock_display = mock.Mock() + monkeypatch.setattr(IPython.display, "display", mock_display) + + images_mm_df["blob_col"].blob.display() + + for call in mock_display.call_args_list: + args, _ = call + arg = args[0] + assert isinstance(arg, IPython.display.Image) + + +def test_display_nulls( + monkeypatch, + bq_connection: str, + session: bigframes.Session, +): + uri_series = bpd.Series([None, None, None], dtype="string", session=session) + blob_series = uri_series.str.to_blob(connection=bq_connection) + mock_display = mock.Mock() + monkeypatch.setattr(IPython.display, "display", mock_display) + + blob_series.blob.display() + + for call in mock_display.call_args_list: + args, _ = call + arg = args[0] + assert arg == "" From 6353d6ecad5139551ef68376c08f8749dd440014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Tue, 14 Oct 2025 15:02:51 -0500 Subject: [PATCH 151/313] feat: make `all` and `any` compatible with integer columns on Polars session (#2154) * docs: remove import bigframes.pandas as bpd boilerplate from many samples Also, fixes several constructors that didn't take a session for compatibility with multi-session applications. * fix docs * fix unit tests * skip sklearn test * fix snapshot * plumb through session for from_tuples and from_arrays * add from_frame * make sure polars session isnt skipped on Kokoro * fix apply doctest * make doctest conftest available everywhere * add python version flexibility for to_dict * disambiguate explicit names * disambiguate explicit name none versus no name * fix for column name comparison in pandas bin op * avoid setting column labels in special case of Series(block) * revert doctest changes * revert doctest changes * revert df docstrings * add polars series unit tests * restore a test * Revert "restore a test" This reverts commit 765b678b34a7976aef1017d2a1fdb34d7a4cfbe4. * skip null * skip unsupported tests * revert more docs changes * revert more docs * revert more docs * fix unit tests python 3.13 * add test to reproduce name error * revert new session methods * fix TestSession read_pandas for Series * revert more unnecessary changes * even more * add unit_noextras to improve code coverage * run system tests on latest fully supported * system-3.12 not found * cap polars version * hide progress bar * relax polars upper pin --- bigframes/core/compile/polars/compiler.py | 4 +- bigframes/testing/polars_session.py | 17 +- noxfile.py | 10 +- .../system/small/engines/test_generic_ops.py | 8 +- tests/unit/test_local_engine.py | 8 + tests/unit/test_series_polars.py | 5020 +++++++++++++++++ .../pandas/core/arrays/datetimelike.py | 2 + 7 files changed, 5058 insertions(+), 11 deletions(-) create mode 100644 tests/unit/test_series_polars.py diff --git a/bigframes/core/compile/polars/compiler.py b/bigframes/core/compile/polars/compiler.py index f7c742e852..059ec72076 100644 --- a/bigframes/core/compile/polars/compiler.py +++ b/bigframes/core/compile/polars/compiler.py @@ -493,9 +493,9 @@ def compile_agg_op( if isinstance(op, agg_ops.MedianOp): return pl.median(*inputs) if isinstance(op, agg_ops.AllOp): - return pl.all(*inputs) + return pl.col(inputs).cast(pl.Boolean).all() if isinstance(op, agg_ops.AnyOp): - return pl.any(*inputs) # type: ignore + return pl.col(inputs).cast(pl.Boolean).any() if isinstance(op, agg_ops.NuniqueOp): return pl.col(*inputs).drop_nulls().n_unique() if isinstance(op, agg_ops.MinOp): diff --git a/bigframes/testing/polars_session.py b/bigframes/testing/polars_session.py index 29eae20b7a..ba6d502fcc 100644 --- a/bigframes/testing/polars_session.py +++ b/bigframes/testing/polars_session.py @@ -94,11 +94,24 @@ def __init__(self): self._loader = None # type: ignore def read_pandas(self, pandas_dataframe, write_engine="default"): + original_input = pandas_dataframe + # override read_pandas to always keep data local-only - if isinstance(pandas_dataframe, pandas.Series): + if isinstance(pandas_dataframe, (pandas.Series, pandas.Index)): pandas_dataframe = pandas_dataframe.to_frame() + local_block = bigframes.core.blocks.Block.from_local(pandas_dataframe, self) - return bigframes.dataframe.DataFrame(local_block) + bf_df = bigframes.dataframe.DataFrame(local_block) + + if isinstance(original_input, pandas.Series): + series = bf_df[bf_df.columns[0]] + series.name = original_input.name + return series + + if isinstance(original_input, pandas.Index): + return bf_df.index + + return bf_df @property def bqclient(self): diff --git a/noxfile.py b/noxfile.py index 988847fc14..f9c20c999c 100644 --- a/noxfile.py +++ b/noxfile.py @@ -46,9 +46,7 @@ "3.11", ] -# pytest-retry is not yet compatible with pytest 8.x. -# https://github.com/str0zzapreti/pytest-retry/issues/32 -PYTEST_VERSION = "pytest<8.0.0dev" +PYTEST_VERSION = "pytest==8.4.2" SPHINX_VERSION = "sphinx==4.5.0" LINT_PATHS = [ "docs", @@ -91,7 +89,7 @@ # 3.10 is needed for Windows tests as it is the only version installed in the # bigframes-windows container image. For more information, search # bigframes/windows-docker, internally. -SYSTEM_TEST_PYTHON_VERSIONS = ["3.9", "3.10", "3.11", "3.13"] +SYSTEM_TEST_PYTHON_VERSIONS = ["3.9", "3.10", "3.11", "3.12", "3.13"] SYSTEM_TEST_STANDARD_DEPENDENCIES = [ "jinja2", "mock", @@ -115,7 +113,7 @@ # Make sure we leave some versions without "extras" so we know those # dependencies are actually optional. "3.10": ["tests", "scikit-learn", "anywidget"], - "3.11": ["tests", "scikit-learn", "polars", "anywidget"], + LATEST_FULLY_SUPPORTED_PYTHON: ["tests", "scikit-learn", "polars", "anywidget"], "3.13": ["tests", "polars", "anywidget"], } @@ -132,7 +130,7 @@ # from GitHub actions. "unit_noextras", "system-3.9", # No extras. - "system-3.11", + f"system-{LATEST_FULLY_SUPPORTED_PYTHON}", # All extras. "cover", # TODO(b/401609005): remove "cleanup", diff --git a/tests/system/small/engines/test_generic_ops.py b/tests/system/small/engines/test_generic_ops.py index fc491d358b..f252782dbd 100644 --- a/tests/system/small/engines/test_generic_ops.py +++ b/tests/system/small/engines/test_generic_ops.py @@ -22,7 +22,7 @@ from bigframes.session import polars_executor from bigframes.testing.engine_utils import assert_equivalence_execution -pytest.importorskip("polars") +polars = pytest.importorskip("polars") # Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree. REFERENCE_ENGINE = polars_executor.PolarsExecutor() @@ -54,6 +54,12 @@ def apply_op( @pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_astype_int(scalars_array_value: array_value.ArrayValue, engine): + polars_version = tuple([int(part) for part in polars.__version__.split(".")]) + if polars_version >= (1, 34, 0): + # TODO(https://github.com/pola-rs/polars/issues/24841): Remove this when + # polars fixes Decimal to Int cast. + scalars_array_value = scalars_array_value.drop_columns(["numeric_col"]) + arr = apply_op( scalars_array_value, ops.AsTypeOp(to_type=bigframes.dtypes.INT_DTYPE), diff --git a/tests/unit/test_local_engine.py b/tests/unit/test_local_engine.py index 7d3d532d88..8c8c2dcf0d 100644 --- a/tests/unit/test_local_engine.py +++ b/tests/unit/test_local_engine.py @@ -42,6 +42,14 @@ def small_inline_frame() -> pd.DataFrame: return df +def test_polars_local_engine_series(polars_session: bigframes.Session): + bf_series = bpd.Series([1, 2, 3], session=polars_session) + pd_series = pd.Series([1, 2, 3], dtype=bf_series.dtype) + bf_result = bf_series.to_pandas() + pd_result = pd_series + pandas.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) + + def test_polars_local_engine_add( small_inline_frame: pd.DataFrame, polars_session: bigframes.Session ): diff --git a/tests/unit/test_series_polars.py b/tests/unit/test_series_polars.py new file mode 100644 index 0000000000..64814126ea --- /dev/null +++ b/tests/unit/test_series_polars.py @@ -0,0 +1,5020 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime as dt +import json +import math +import pathlib +import re +import tempfile +from typing import Generator + +import db_dtypes # type: ignore +import geopandas as gpd # type: ignore +import google.api_core.exceptions +import numpy +from packaging.version import Version +import pandas as pd +import pyarrow as pa # type: ignore +import pytest +import shapely.geometry # type: ignore + +import bigframes +import bigframes.dtypes as dtypes +import bigframes.features +import bigframes.pandas +import bigframes.pandas as bpd +import bigframes.series as series +from bigframes.testing.utils import ( + assert_pandas_df_equal, + assert_series_equal, + convert_pandas_dtypes, + get_first_file_from_wildcard, +) + +pytest.importorskip("polars") +pytest.importorskip("pandas", minversion="2.0.0") + +CURRENT_DIR = pathlib.Path(__file__).parent +DATA_DIR = CURRENT_DIR.parent / "data" + + +@pytest.fixture(scope="module", autouse=True) +def session() -> Generator[bigframes.Session, None, None]: + import bigframes.core.global_session + from bigframes.testing import polars_session + + session = polars_session.TestSession() + with bigframes.core.global_session._GlobalSessionContext(session): + yield session + + +@pytest.fixture(scope="module") +def scalars_pandas_df_index() -> pd.DataFrame: + """pd.DataFrame pointing at test data.""" + + df = pd.read_json( + DATA_DIR / "scalars.jsonl", + lines=True, + ) + convert_pandas_dtypes(df, bytes_col=True) + + df = df.set_index("rowindex", drop=False) + df.index.name = None + return df.set_index("rowindex").sort_index() + + +@pytest.fixture(scope="module") +def scalars_df_default_index( + session: bigframes.Session, scalars_pandas_df_index +) -> bpd.DataFrame: + return session.read_pandas(scalars_pandas_df_index).reset_index(drop=False) + + +@pytest.fixture(scope="module") +def scalars_df_2_default_index( + session: bigframes.Session, scalars_pandas_df_index +) -> bpd.DataFrame: + return session.read_pandas(scalars_pandas_df_index).reset_index(drop=False) + + +@pytest.fixture(scope="module") +def scalars_df_index( + session: bigframes.Session, scalars_pandas_df_index +) -> bpd.DataFrame: + return session.read_pandas(scalars_pandas_df_index) + + +@pytest.fixture(scope="module") +def scalars_df_2_index( + session: bigframes.Session, scalars_pandas_df_index +) -> bpd.DataFrame: + return session.read_pandas(scalars_pandas_df_index) + + +@pytest.fixture(scope="module") +def scalars_dfs( + scalars_df_index, + scalars_pandas_df_index, +): + return scalars_df_index, scalars_pandas_df_index + + +def test_series_construct_copy(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = series.Series( + scalars_df["int64_col"], name="test_series", dtype="Float64" + ).to_pandas() + pd_result = pd.Series( + scalars_pandas_df["int64_col"], name="test_series", dtype="Float64" + ) + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_nullable_ints(): + bf_result = series.Series( + [1, 3, bigframes.pandas.NA], index=[0, 4, bigframes.pandas.NA] + ).to_pandas() + + # TODO(b/340885567): fix type error + expected_index = pd.Index( # type: ignore + [0, 4, None], + dtype=pd.Int64Dtype(), + ) + expected = pd.Series([1, 3, pd.NA], dtype=pd.Int64Dtype(), index=expected_index) + + pd.testing.assert_series_equal(bf_result, expected) + + +def test_series_construct_timestamps(): + datetimes = [ + dt.datetime(2020, 1, 20, 20, 20, 20, 20), + dt.datetime(2019, 1, 20, 20, 20, 20, 20), + None, + ] + bf_result = series.Series(datetimes).to_pandas() + pd_result = pd.Series(datetimes, dtype=pd.ArrowDtype(pa.timestamp("us"))) + + pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) + + +def test_series_construct_copy_with_index(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = series.Series( + scalars_df["int64_col"], + name="test_series", + dtype="Float64", + index=scalars_df["int64_too"], + ).to_pandas() + pd_result = pd.Series( + scalars_pandas_df["int64_col"], + name="test_series", + dtype="Float64", + index=scalars_pandas_df["int64_too"], + ) + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_copy_index(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = series.Series( + scalars_df.index, + name="test_series", + dtype="Float64", + index=scalars_df["int64_too"], + ).to_pandas() + pd_result = pd.Series( + scalars_pandas_df.index, + name="test_series", + dtype="Float64", + index=scalars_pandas_df["int64_too"], + ) + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_pandas(scalars_dfs): + _, scalars_pandas_df = scalars_dfs + bf_result = series.Series( + scalars_pandas_df["int64_col"], name="test_series", dtype="Float64" + ) + pd_result = pd.Series( + scalars_pandas_df["int64_col"], name="test_series", dtype="Float64" + ) + assert bf_result.shape == pd_result.shape + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + +def test_series_construct_from_list(): + bf_result = series.Series([1, 1, 2, 3, 5, 8, 13], dtype="Int64").to_pandas() + pd_result = pd.Series([1, 1, 2, 3, 5, 8, 13], dtype="Int64") + + # BigQuery DataFrame default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_reindex(): + bf_result = series.Series( + series.Series({1: 10, 2: 30, 3: 30}), index=[3, 2], dtype="Int64" + ).to_pandas() + pd_result = pd.Series(pd.Series({1: 10, 2: 30, 3: 30}), index=[3, 2], dtype="Int64") + + # BigQuery DataFrame default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_from_list_w_index(): + bf_result = series.Series( + [1, 1, 2, 3, 5, 8, 13], index=[10, 20, 30, 40, 50, 60, 70], dtype="Int64" + ).to_pandas() + pd_result = pd.Series( + [1, 1, 2, 3, 5, 8, 13], index=[10, 20, 30, 40, 50, 60, 70], dtype="Int64" + ) + + # BigQuery DataFrame default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_empty(session: bigframes.Session): + bf_series: series.Series = series.Series(session=session) + pd_series: pd.Series = pd.Series() + + bf_result = bf_series.empty + pd_result = pd_series.empty + + assert pd_result + assert bf_result == pd_result + + +def test_series_construct_scalar_no_index(): + bf_result = series.Series("hello world", dtype="string[pyarrow]").to_pandas() + pd_result = pd.Series("hello world", dtype="string[pyarrow]") + + # BigQuery DataFrame default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_scalar_w_index(): + bf_result = series.Series( + "hello world", dtype="string[pyarrow]", index=[0, 2, 1] + ).to_pandas() + pd_result = pd.Series("hello world", dtype="string[pyarrow]", index=[0, 2, 1]) + + # BigQuery DataFrame default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_nan(): + bf_result = series.Series(numpy.nan).to_pandas() + pd_result = pd.Series(numpy.nan) + + pd_result.index = pd_result.index.astype("Int64") + pd_result = pd_result.astype("Float64") + + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_scalar_w_bf_index(): + bf_result = series.Series( + "hello", index=bigframes.pandas.Index([1, 2, 3]) + ).to_pandas() + pd_result = pd.Series("hello", index=pd.Index([1, 2, 3], dtype="Int64")) + + pd_result = pd_result.astype("string[pyarrow]") + + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_series_construct_from_list_escaped_strings(): + """Check that special characters are supported.""" + strings = [ + "string\nwith\nnewline", + "string\twith\ttabs", + "string\\with\\backslashes", + ] + bf_result = series.Series(strings, name="test_series", dtype="string[pyarrow]") + pd_result = pd.Series(strings, name="test_series", dtype="string[pyarrow]") + + # BigQuery DataFrame default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + +def test_series_construct_geodata(): + pd_series = pd.Series( + [ + shapely.geometry.Point(1, 1), + shapely.geometry.Point(2, 2), + shapely.geometry.Point(3, 3), + ], + dtype=gpd.array.GeometryDtype(), + ) + + series = bigframes.pandas.Series(pd_series) + + pd.testing.assert_series_equal( + pd_series, series.to_pandas(), check_index_type=False + ) + + +@pytest.mark.parametrize( + ("dtype"), + [ + pytest.param(pd.Int64Dtype(), id="int"), + pytest.param(pd.Float64Dtype(), id="float"), + pytest.param(pd.StringDtype(storage="pyarrow"), id="string"), + ], +) +def test_series_construct_w_dtype(dtype): + data = [1, 2, 3] + expected = pd.Series(data, dtype=dtype) + expected.index = expected.index.astype("Int64") + series = bigframes.pandas.Series(data, dtype=dtype) + pd.testing.assert_series_equal(series.to_pandas(), expected) + + +def test_series_construct_w_dtype_for_struct(): + # The data shows the struct fields are disordered and correctly handled during + # construction. + data = [ + {"a": 1, "c": "pandas", "b": dt.datetime(2020, 1, 20, 20, 20, 20, 20)}, + {"a": 2, "c": "pandas", "b": dt.datetime(2019, 1, 20, 20, 20, 20, 20)}, + {"a": 1, "c": "numpy", "b": None}, + ] + dtype = pd.ArrowDtype( + pa.struct([("a", pa.int64()), ("c", pa.string()), ("b", pa.timestamp("us"))]) + ) + series = bigframes.pandas.Series(data, dtype=dtype) + expected = pd.Series(data, dtype=dtype) + expected.index = expected.index.astype("Int64") + pd.testing.assert_series_equal(series.to_pandas(), expected) + + +def test_series_construct_w_dtype_for_array_string(): + data = [["1", "2", "3"], [], ["4", "5"]] + dtype = pd.ArrowDtype(pa.list_(pa.string())) + series = bigframes.pandas.Series(data, dtype=dtype) + expected = pd.Series(data, dtype=dtype) + expected.index = expected.index.astype("Int64") + + # Skip dtype check due to internal issue b/321013333. This issue causes array types + # to be converted to the `object` dtype when calling `to_pandas()`, resulting in + # a mismatch with the expected Pandas type. + if bigframes.features.PANDAS_VERSIONS.is_arrow_list_dtype_usable: + check_dtype = True + else: + check_dtype = False + + pd.testing.assert_series_equal( + series.to_pandas(), expected, check_dtype=check_dtype + ) + + +def test_series_construct_w_dtype_for_array_struct(): + data = [[{"a": 1, "c": "aa"}, {"a": 2, "c": "bb"}], [], [{"a": 3, "c": "cc"}]] + dtype = pd.ArrowDtype(pa.list_(pa.struct([("a", pa.int64()), ("c", pa.string())]))) + series = bigframes.pandas.Series(data, dtype=dtype) + expected = pd.Series(data, dtype=dtype) + expected.index = expected.index.astype("Int64") + + # Skip dtype check due to internal issue b/321013333. This issue causes array types + # to be converted to the `object` dtype when calling `to_pandas()`, resulting in + # a mismatch with the expected Pandas type. + if bigframes.features.PANDAS_VERSIONS.is_arrow_list_dtype_usable: + check_dtype = True + else: + check_dtype = False + + pd.testing.assert_series_equal( + series.to_pandas(), expected, check_dtype=check_dtype + ) + + +def test_series_construct_local_unordered_has_sequential_index(session): + series = bigframes.pandas.Series( + ["Sun", "Mon", "Tues", "Wed", "Thurs", "Fri", "Sat"], session=session + ) + expected: pd.Index = pd.Index([0, 1, 2, 3, 4, 5, 6], dtype=pd.Int64Dtype()) + pd.testing.assert_index_equal(series.index.to_pandas(), expected) + + +@pytest.mark.parametrize( + ("json_type"), + [ + pytest.param(dtypes.JSON_DTYPE), + pytest.param("json"), + ], +) +def test_series_construct_w_json_dtype(json_type): + data = [ + "1", + '"str"', + "false", + '["a", {"b": 1}, null]', + None, + '{"a": {"b": [1, 2, 3], "c": true}}', + ] + s = bigframes.pandas.Series(data, dtype=json_type) + + assert s.dtype == dtypes.JSON_DTYPE + assert s[0] == "1" + assert s[1] == '"str"' + assert s[2] == "false" + assert s[3] == '["a",{"b":1},null]' + assert pd.isna(s[4]) + assert s[5] == '{"a":{"b":[1,2,3],"c":true}}' + + +def test_series_keys(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["int64_col"].keys().to_pandas() + pd_result = scalars_pandas_df["int64_col"].keys() + pd.testing.assert_index_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ["data", "index"], + [ + (["a", "b", "c"], None), + ([1, 2, 3], ["a", "b", "c"]), + ([1, 2, None], ["a", "b", "c"]), + ([1, 2, 3], [pd.NA, "b", "c"]), + ([numpy.nan, 2, 3], ["a", "b", "c"]), + ], +) +def test_series_items(data, index): + bf_series = series.Series(data, index=index) + pd_series = pd.Series(data, index=index) + + for (bf_index, bf_value), (pd_index, pd_value) in zip( + bf_series.items(), pd_series.items() + ): + # TODO(jialuo): Remove the if conditions after b/373699458 is addressed. + if not pd.isna(bf_index) or not pd.isna(pd_index): + assert bf_index == pd_index + if not pd.isna(bf_value) or not pd.isna(pd_value): + assert bf_value == pd_value + + +@pytest.mark.parametrize( + ["col_name", "expected_dtype"], + [ + ("bool_col", pd.BooleanDtype()), + # TODO(swast): Use a more efficient type. + ("bytes_col", pd.ArrowDtype(pa.binary())), + ("date_col", pd.ArrowDtype(pa.date32())), + ("datetime_col", pd.ArrowDtype(pa.timestamp("us"))), + ("float64_col", pd.Float64Dtype()), + ("geography_col", gpd.array.GeometryDtype()), + ("int64_col", pd.Int64Dtype()), + # TODO(swast): Use a more efficient type. + ("numeric_col", pd.ArrowDtype(pa.decimal128(38, 9))), + ("int64_too", pd.Int64Dtype()), + ("string_col", pd.StringDtype(storage="pyarrow")), + ("time_col", pd.ArrowDtype(pa.time64("us"))), + ("timestamp_col", pd.ArrowDtype(pa.timestamp("us", tz="UTC"))), + ], +) +def test_get_column(scalars_dfs, col_name, expected_dtype): + scalars_df, scalars_pandas_df = scalars_dfs + series = scalars_df[col_name] + series_pandas = series.to_pandas() + assert series_pandas.dtype == expected_dtype + assert series_pandas.shape[0] == scalars_pandas_df.shape[0] + + +def test_series_get_column_default(scalars_dfs): + scalars_df, _ = scalars_dfs + result = scalars_df.get(123123123123123, "default_val") + assert result == "default_val" + + +@pytest.mark.parametrize( + ("key",), + [ + ("hello",), + (2,), + ("int64_col",), + (None,), + ], +) +def test_series_contains(scalars_df_index, scalars_pandas_df_index, key): + bf_result = key in scalars_df_index["int64_col"] + pd_result = key in scalars_pandas_df_index["int64_col"] + + assert bf_result == pd_result + + +def test_series_equals_identical(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.int64_col.equals(scalars_df_index.int64_col) + pd_result = scalars_pandas_df_index.int64_col.equals( + scalars_pandas_df_index.int64_col + ) + + assert pd_result == bf_result + + +def test_series_equals_df(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_col"].equals(scalars_df_index[["int64_col"]]) + pd_result = scalars_pandas_df_index["int64_col"].equals( + scalars_pandas_df_index[["int64_col"]] + ) + + assert pd_result == bf_result + + +def test_series_equals_different_dtype(scalars_df_index, scalars_pandas_df_index): + bf_series = scalars_df_index["int64_col"] + pd_series = scalars_pandas_df_index["int64_col"] + + bf_result = bf_series.equals(bf_series.astype("Float64")) + pd_result = pd_series.equals(pd_series.astype("Float64")) + + assert pd_result == bf_result + + +def test_series_equals_different_values(scalars_df_index, scalars_pandas_df_index): + bf_series = scalars_df_index["int64_col"] + pd_series = scalars_pandas_df_index["int64_col"] + + bf_result = bf_series.equals(bf_series + 1) + pd_result = pd_series.equals(pd_series + 1) + + assert pd_result == bf_result + + +def test_series_get_with_default_index(scalars_dfs): + col_name = "float64_col" + key = 2 + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].get(key) + pd_result = scalars_pandas_df[col_name].get(key) + assert bf_result == pd_result + + +@pytest.mark.parametrize( + ("index_col", "key"), + ( + ("int64_too", 2), + ("string_col", "Hello, World!"), + ("int64_too", slice(2, 6)), + ), +) +def test_series___getitem__(scalars_dfs, index_col, key): + col_name = "float64_col" + scalars_df, scalars_pandas_df = scalars_dfs + scalars_df = scalars_df.set_index(index_col, drop=False) + scalars_pandas_df = scalars_pandas_df.set_index(index_col, drop=False) + bf_result = scalars_df[col_name][key] + pd_result = scalars_pandas_df[col_name][key] + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + +@pytest.mark.parametrize( + ("key",), + ( + (-2,), + (-1,), + (0,), + (1,), + ), +) +def test_series___getitem___with_int_key(scalars_dfs, key): + col_name = "int64_too" + index_col = "string_col" + scalars_df, scalars_pandas_df = scalars_dfs + scalars_df = scalars_df.set_index(index_col, drop=False) + scalars_pandas_df = scalars_pandas_df.set_index(index_col, drop=False) + bf_result = scalars_df[col_name][key] + pd_result = scalars_pandas_df[col_name][key] + assert bf_result == pd_result + + +def test_series___getitem___with_default_index(scalars_dfs): + col_name = "float64_col" + key = 2 + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name][key] + pd_result = scalars_pandas_df[col_name][key] + assert bf_result == pd_result + + +@pytest.mark.parametrize( + ("index_col", "key", "value"), + ( + ("int64_too", 2, "new_string_value"), + ("string_col", "Hello, World!", "updated_value"), + ("int64_too", 0, None), + ), +) +def test_series___setitem__(scalars_dfs, index_col, key, value): + col_name = "string_col" + scalars_df, scalars_pandas_df = scalars_dfs + scalars_df = scalars_df.set_index(index_col, drop=False) + scalars_pandas_df = scalars_pandas_df.set_index(index_col, drop=False) + + bf_series = scalars_df[col_name] + pd_series = scalars_pandas_df[col_name].copy() + + bf_series[key] = value + pd_series[key] = value + + pd.testing.assert_series_equal(bf_series.to_pandas(), pd_series) + + +@pytest.mark.parametrize( + ("key", "value"), + ( + (0, 999), + (1, 888), + (0, None), + (-2345, 777), + ), +) +def test_series___setitem___with_int_key_numeric(scalars_dfs, key, value): + col_name = "int64_col" + index_col = "int64_too" + scalars_df, scalars_pandas_df = scalars_dfs + scalars_df = scalars_df.set_index(index_col, drop=False) + scalars_pandas_df = scalars_pandas_df.set_index(index_col, drop=False) + + bf_series = scalars_df[col_name] + pd_series = scalars_pandas_df[col_name].copy() + + bf_series[key] = value + pd_series[key] = value + + pd.testing.assert_series_equal(bf_series.to_pandas(), pd_series) + + +def test_series___setitem___with_default_index(scalars_dfs): + col_name = "float64_col" + key = 2 + value = 123.456 + scalars_df, scalars_pandas_df = scalars_dfs + + bf_series = scalars_df[col_name] + pd_series = scalars_pandas_df[col_name].copy() + + bf_series[key] = value + pd_series[key] = value + + assert bf_series.to_pandas().iloc[key] == pd_series.iloc[key] + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("float64_col",), + ("int64_too",), + ), +) +def test_abs(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].abs().to_pandas() + pd_result = scalars_pandas_df[col_name].abs() + + assert_series_equal(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("float64_col",), + ("int64_too",), + ), +) +def test_series_pos(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = (+scalars_df[col_name]).to_pandas() + pd_result = +scalars_pandas_df[col_name] + + assert_series_equal(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("float64_col",), + ("int64_too",), + ), +) +def test_series_neg(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = (-scalars_df[col_name]).to_pandas() + pd_result = -scalars_pandas_df[col_name] + + assert_series_equal(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("bool_col",), + ("int64_col",), + ), +) +def test_series_invert(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = (~scalars_df[col_name]).to_pandas() + pd_result = ~scalars_pandas_df[col_name] + + assert_series_equal(pd_result, bf_result) + + +def test_fillna(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = scalars_df[col_name].fillna("Missing").to_pandas() + pd_result = scalars_pandas_df[col_name].fillna("Missing") + assert_series_equal( + pd_result, + bf_result, + ) + + +def test_series_replace_scalar_scalar(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = ( + scalars_df[col_name].replace("Hello, World!", "Howdy, Planet!").to_pandas() + ) + pd_result = scalars_pandas_df[col_name].replace("Hello, World!", "Howdy, Planet!") + + pd.testing.assert_series_equal( + pd_result, + bf_result, + ) + + +def test_series_replace_list_scalar(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = ( + scalars_df[col_name] + .replace(["Hello, World!", "T"], "Howdy, Planet!") + .to_pandas() + ) + pd_result = scalars_pandas_df[col_name].replace( + ["Hello, World!", "T"], "Howdy, Planet!" + ) + + pd.testing.assert_series_equal( + pd_result, + bf_result, + ) + + +@pytest.mark.parametrize( + ("replacement_dict",), + (({},),), + ids=[ + "empty", + ], +) +def test_series_replace_dict(scalars_dfs, replacement_dict): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = scalars_df[col_name].replace(replacement_dict).to_pandas() + pd_result = scalars_pandas_df[col_name].replace(replacement_dict) + + pd.testing.assert_series_equal( + pd_result, + bf_result, + ) + + +@pytest.mark.parametrize( + ("method",), + ( + ("linear",), + ("values",), + ("slinear",), + ("nearest",), + ("zero",), + ("pad",), + ), +) +def test_series_interpolate(method): + pytest.importorskip("scipy") + + values = [None, 1, 2, None, None, 16, None] + index = [-3.2, 11.4, 3.56, 4, 4.32, 5.55, 76.8] + pd_series = pd.Series(values, index) + bf_series = series.Series(pd_series) + + # Pandas can only interpolate on "float64" columns + # https://github.com/pandas-dev/pandas/issues/40252 + pd_result = pd_series.astype("float64").interpolate(method=method) + bf_result = bf_series.interpolate(method=method).to_pandas() + + # pd uses non-null types, while bf uses nullable types + pd.testing.assert_series_equal( + pd_result, + bf_result, + check_index_type=False, + check_dtype=False, + ) + + +@pytest.mark.parametrize( + ("ignore_index",), + ( + (True,), + (False,), + ), +) +def test_series_dropna(scalars_dfs, ignore_index): + if pd.__version__.startswith("1."): + pytest.skip("ignore_index parameter not supported in pandas 1.x.") + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = scalars_df[col_name].dropna(ignore_index=ignore_index).to_pandas() + pd_result = scalars_pandas_df[col_name].dropna(ignore_index=ignore_index) + pd.testing.assert_series_equal(pd_result, bf_result, check_index_type=False) + + +@pytest.mark.parametrize( + ("agg",), + ( + ("sum",), + ("size",), + ), +) +def test_series_agg_single_string(scalars_dfs, agg): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["int64_col"].agg(agg) + pd_result = scalars_pandas_df["int64_col"].agg(agg) + assert math.isclose(pd_result, bf_result) + + +def test_series_agg_multi_string(scalars_dfs): + aggregations = [ + "sum", + "mean", + "std", + "var", + "min", + "max", + "nunique", + "count", + "size", + ] + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["int64_col"].agg(aggregations).to_pandas() + pd_result = scalars_pandas_df["int64_col"].agg(aggregations) + + # Pandas may produce narrower numeric types, but bigframes always produces Float64 + pd_result = pd_result.astype("Float64") + + pd.testing.assert_series_equal(pd_result, bf_result, check_index_type=False) + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("string_col",), + ("int64_col",), + ), +) +def test_max(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].max() + pd_result = scalars_pandas_df[col_name].max() + assert pd_result == bf_result + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("string_col",), + ("int64_col",), + ), +) +def test_min(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].min() + pd_result = scalars_pandas_df[col_name].min() + assert pd_result == bf_result + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("float64_col",), + ("int64_col",), + ), +) +def test_std(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].std() + pd_result = scalars_pandas_df[col_name].std() + assert math.isclose(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("float64_col",), + ("int64_col",), + ), +) +def test_kurt(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].kurt() + pd_result = scalars_pandas_df[col_name].kurt() + assert math.isclose(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("float64_col",), + ("int64_col",), + ), +) +def test_skew(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].skew() + pd_result = scalars_pandas_df[col_name].skew() + assert math.isclose(pd_result, bf_result) + + +def test_skew_undefined(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["int64_col"].iloc[:2].skew() + pd_result = scalars_pandas_df["int64_col"].iloc[:2].skew() + # both should be pd.NA + assert pd_result is bf_result + + +def test_kurt_undefined(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["int64_col"].iloc[:3].kurt() + pd_result = scalars_pandas_df["int64_col"].iloc[:3].kurt() + # both should be pd.NA + assert pd_result is bf_result + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("float64_col",), + ("int64_col",), + ), +) +def test_var(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].var() + pd_result = scalars_pandas_df[col_name].var() + assert math.isclose(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("bool_col",), + ("int64_col",), + ), +) +def test_mode_stat(scalars_df_index, scalars_pandas_df_index, col_name): + bf_result = scalars_df_index[col_name].mode().to_pandas() + pd_result = scalars_pandas_df_index[col_name].mode() + + ## Mode implicitly resets index, and bigframes default indices use nullable Int64 + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("operator"), + [ + (lambda x, y: x + y), + (lambda x, y: x - y), + (lambda x, y: x * y), + (lambda x, y: x / y), + (lambda x, y: x // y), + (lambda x, y: x < y), + (lambda x, y: x > y), + (lambda x, y: x <= y), + (lambda x, y: x >= y), + ], + ids=[ + "add", + "subtract", + "multiply", + "divide", + "floordivide", + "less_than", + "greater_than", + "less_than_equal", + "greater_than_equal", + ], +) +@pytest.mark.parametrize( + ("other_scalar"), + [ + -1, + 0, + 14, + # TODO(tswast): Support pd.NA, + ], +) +@pytest.mark.parametrize(("reverse_operands"), [True, False]) +def test_series_int_int_operators_scalar( + scalars_dfs, operator, other_scalar, reverse_operands +): + scalars_df, scalars_pandas_df = scalars_dfs + + maybe_reversed_op = (lambda x, y: operator(y, x)) if reverse_operands else operator + + bf_result = maybe_reversed_op(scalars_df["int64_col"], other_scalar).to_pandas() + pd_result = maybe_reversed_op(scalars_pandas_df["int64_col"], other_scalar) + + assert_series_equal(pd_result, bf_result) + + +def test_series_pow_scalar(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = (scalars_df["int64_col"] ** 2).to_pandas() + pd_result = scalars_pandas_df["int64_col"] ** 2 + + assert_series_equal(pd_result, bf_result) + + +def test_series_pow_scalar_reverse(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = (0.8 ** scalars_df["int64_col"]).to_pandas() + pd_result = 0.8 ** scalars_pandas_df["int64_col"] + + assert_series_equal(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("operator"), + [ + (lambda x, y: x & y), + (lambda x, y: x | y), + (lambda x, y: x ^ y), + ], + ids=[ + "and", + "or", + "xor", + ], +) +@pytest.mark.parametrize( + ("other_scalar"), + [ + True, + False, + pytest.param( + pd.NA, + marks=[ + pytest.mark.skip( + reason="https://github.com/pola-rs/polars/issues/24809" + ) + ], + id="NULL", + ), + ], +) +@pytest.mark.parametrize(("reverse_operands"), [True, False]) +def test_series_bool_bool_operators_scalar( + scalars_dfs, operator, other_scalar, reverse_operands +): + scalars_df, scalars_pandas_df = scalars_dfs + + maybe_reversed_op = (lambda x, y: operator(y, x)) if reverse_operands else operator + + bf_result = maybe_reversed_op(scalars_df["bool_col"], other_scalar).to_pandas() + pd_result = maybe_reversed_op(scalars_pandas_df["bool_col"], other_scalar) + + assert_series_equal(pd_result.astype(pd.BooleanDtype()), bf_result) + + +@pytest.mark.parametrize( + ("operator"), + [ + (lambda x, y: x + y), + (lambda x, y: x - y), + (lambda x, y: x * y), + (lambda x, y: x / y), + (lambda x, y: x < y), + (lambda x, y: x > y), + (lambda x, y: x <= y), + (lambda x, y: x >= y), + (lambda x, y: x % y), + (lambda x, y: x // y), + (lambda x, y: x & y), + (lambda x, y: x | y), + (lambda x, y: x ^ y), + ], + ids=[ + "add", + "subtract", + "multiply", + "divide", + "less_than", + "greater_than", + "less_than_equal", + "greater_than_equal", + "modulo", + "floordivide", + "bitwise_and", + "bitwise_or", + "bitwise_xor", + ], +) +def test_series_int_int_operators_series(scalars_dfs, operator): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = operator(scalars_df["int64_col"], scalars_df["int64_too"]).to_pandas() + pd_result = operator(scalars_pandas_df["int64_col"], scalars_pandas_df["int64_too"]) + assert_series_equal(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_x",), + [ + ("int64_col",), + ("int64_too",), + ("float64_col",), + ], +) +@pytest.mark.parametrize( + ("col_y",), + [ + ("int64_col",), + ("int64_too",), + ("float64_col",), + ], +) +@pytest.mark.parametrize( + ("method",), + [ + ("mod",), + ("rmod",), + ], +) +def test_mods(scalars_dfs, col_x, col_y, method): + scalars_df, scalars_pandas_df = scalars_dfs + x_bf = scalars_df[col_x] + y_bf = scalars_df[col_y] + bf_series = getattr(x_bf, method)(y_bf) + # BigQuery's mod functions return [BIG]NUMERIC values unless both arguments are integers. + # https://cloud.google.com/bigquery/docs/reference/standard-sql/mathematical_functions#mod + if x_bf.dtype == pd.Int64Dtype() and y_bf.dtype == pd.Int64Dtype(): + bf_result = bf_series.to_pandas() + else: + bf_result = bf_series.astype("Float64").to_pandas() + pd_result = getattr(scalars_pandas_df[col_x], method)(scalars_pandas_df[col_y]) + pd.testing.assert_series_equal(pd_result, bf_result) + + +# We work around a pandas bug that doesn't handle correlating nullable dtypes by doing this +# manually with dumb self-correlation instead of parameterized as test_mods is above. +def test_series_corr(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["int64_too"].corr(scalars_df["int64_too"]) + pd_result = ( + scalars_pandas_df["int64_too"] + .astype("int64") + .corr(scalars_pandas_df["int64_too"].astype("int64")) + ) + assert math.isclose(pd_result, bf_result) + + +def test_series_autocorr(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["float64_col"].autocorr(2) + pd_result = scalars_pandas_df["float64_col"].autocorr(2) + assert math.isclose(pd_result, bf_result) + + +def test_series_cov(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["int64_too"].cov(scalars_df["int64_too"]) + pd_result = ( + scalars_pandas_df["int64_too"] + .astype("int64") + .cov(scalars_pandas_df["int64_too"].astype("int64")) + ) + assert math.isclose(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_x",), + [ + ("int64_col",), + ("float64_col",), + ], +) +@pytest.mark.parametrize( + ("col_y",), + [ + ("int64_col",), + ("float64_col",), + ], +) +@pytest.mark.parametrize( + ("method",), + [ + ("divmod",), + ("rdivmod",), + ], +) +def test_divmods_series(scalars_dfs, col_x, col_y, method): + scalars_df, scalars_pandas_df = scalars_dfs + bf_div_result, bf_mod_result = getattr(scalars_df[col_x], method)(scalars_df[col_y]) + pd_div_result, pd_mod_result = getattr(scalars_pandas_df[col_x], method)( + scalars_pandas_df[col_y] + ) + # BigQuery's mod functions return NUMERIC values for non-INT64 inputs. + if bf_div_result.dtype == pd.Int64Dtype(): + pd.testing.assert_series_equal(pd_div_result, bf_div_result.to_pandas()) + else: + pd.testing.assert_series_equal( + pd_div_result, bf_div_result.astype("Float64").to_pandas() + ) + + if bf_mod_result.dtype == pd.Int64Dtype(): + pd.testing.assert_series_equal(pd_mod_result, bf_mod_result.to_pandas()) + else: + pd.testing.assert_series_equal( + pd_mod_result, bf_mod_result.astype("Float64").to_pandas() + ) + + +@pytest.mark.parametrize( + ("col_x",), + [ + ("int64_col",), + ("float64_col",), + ], +) +@pytest.mark.parametrize( + ("other",), + [ + (-1000,), + (678,), + ], +) +@pytest.mark.parametrize( + ("method",), + [ + ("divmod",), + ("rdivmod",), + ], +) +def test_divmods_scalars(scalars_dfs, col_x, other, method): + scalars_df, scalars_pandas_df = scalars_dfs + bf_div_result, bf_mod_result = getattr(scalars_df[col_x], method)(other) + pd_div_result, pd_mod_result = getattr(scalars_pandas_df[col_x], method)(other) + # BigQuery's mod functions return NUMERIC values for non-INT64 inputs. + if bf_div_result.dtype == pd.Int64Dtype(): + pd.testing.assert_series_equal(pd_div_result, bf_div_result.to_pandas()) + else: + pd.testing.assert_series_equal( + pd_div_result, bf_div_result.astype("Float64").to_pandas() + ) + + if bf_mod_result.dtype == pd.Int64Dtype(): + pd.testing.assert_series_equal(pd_mod_result, bf_mod_result.to_pandas()) + else: + pd.testing.assert_series_equal( + pd_mod_result, bf_mod_result.astype("Float64").to_pandas() + ) + + +@pytest.mark.parametrize( + ("other",), + [ + (3,), + (-6.2,), + ], +) +def test_series_add_scalar(scalars_dfs, other): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = (scalars_df["float64_col"] + other).to_pandas() + pd_result = scalars_pandas_df["float64_col"] + other + + assert_series_equal(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("left_col", "right_col"), + [ + ("float64_col", "float64_col"), + ("int64_col", "float64_col"), + ("int64_col", "int64_too"), + ], +) +def test_series_add_bigframes_series(scalars_dfs, left_col, right_col): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = (scalars_df[left_col] + scalars_df[right_col]).to_pandas() + pd_result = scalars_pandas_df[left_col] + scalars_pandas_df[right_col] + + assert_series_equal(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("left_col", "right_col", "righter_col"), + [ + ("float64_col", "float64_col", "float64_col"), + ("int64_col", "int64_col", "int64_col"), + ], +) +def test_series_add_bigframes_series_nested( + scalars_dfs, left_col, right_col, righter_col +): + """Test that we can correctly add multiple times.""" + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = ( + (scalars_df[left_col] + scalars_df[right_col]) + scalars_df[righter_col] + ).to_pandas() + pd_result = ( + scalars_pandas_df[left_col] + scalars_pandas_df[right_col] + ) + scalars_pandas_df[righter_col] + + assert_series_equal(pd_result, bf_result) + + +def test_series_add_different_table_default_index( + scalars_df_default_index, + scalars_df_2_default_index, +): + bf_result = ( + scalars_df_default_index["float64_col"] + + scalars_df_2_default_index["float64_col"] + ).to_pandas() + pd_result = ( + # Default index may not have a well defined order, but it should at + # least be consistent across to_pandas() calls. + scalars_df_default_index["float64_col"].to_pandas() + + scalars_df_2_default_index["float64_col"].to_pandas() + ) + # TODO(swast): Can remove sort_index() when there's default ordering. + pd.testing.assert_series_equal(bf_result.sort_index(), pd_result.sort_index()) + + +def test_series_add_different_table_with_index( + scalars_df_index, scalars_df_2_index, scalars_pandas_df_index +): + scalars_pandas_df = scalars_pandas_df_index + bf_result = scalars_df_index["float64_col"] + scalars_df_2_index["int64_col"] + # When index values are unique, we can emulate with values from the same + # DataFrame. + pd_result = scalars_pandas_df["float64_col"] + scalars_pandas_df["int64_col"] + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + +def test_reset_index_drop(scalars_df_index, scalars_pandas_df_index): + scalars_pandas_df = scalars_pandas_df_index + bf_result = ( + scalars_df_index["float64_col"] + .sort_index(ascending=False) + .reset_index(drop=True) + ).iloc[::2] + pd_result = ( + scalars_pandas_df["float64_col"] + .sort_index(ascending=False) + .reset_index(drop=True) + ).iloc[::2] + + # BigQuery DataFrames default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + +def test_series_reset_index_allow_duplicates(scalars_df_index, scalars_pandas_df_index): + bf_series = scalars_df_index["int64_col"].copy() + bf_series.index.name = "int64_col" + df = bf_series.reset_index(allow_duplicates=True, drop=False) + assert df.index.name is None + + bf_result = df.to_pandas() + + pd_series = scalars_pandas_df_index["int64_col"].copy() + pd_series.index.name = "int64_col" + pd_result = pd_series.reset_index(allow_duplicates=True, drop=False) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + + # reset_index should maintain the original ordering. + pd.testing.assert_frame_equal(bf_result, pd_result) + + +def test_series_reset_index_duplicates_error(scalars_df_index): + scalars_df_index = scalars_df_index["int64_col"].copy() + scalars_df_index.index.name = "int64_col" + with pytest.raises(ValueError): + scalars_df_index.reset_index(allow_duplicates=False, drop=False) + + +def test_series_reset_index_inplace(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.sort_index(ascending=False)["float64_col"] + bf_result.reset_index(drop=True, inplace=True) + pd_result = scalars_pandas_df_index.sort_index(ascending=False)["float64_col"] + pd_result.reset_index(drop=True, inplace=True) + + # BigQuery DataFrames default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + +@pytest.mark.parametrize( + ("name",), + [ + ("some_name",), + (None,), + ], +) +def test_reset_index_no_drop(scalars_df_index, scalars_pandas_df_index, name): + scalars_pandas_df = scalars_pandas_df_index + kw_args = {"name": name} if name else {} + bf_result = ( + scalars_df_index["float64_col"] + .sort_index(ascending=False) + .reset_index(drop=False, **kw_args) + ) + pd_result = ( + scalars_pandas_df["float64_col"] + .sort_index(ascending=False) + .reset_index(drop=False, **kw_args) + ) + + # BigQuery DataFrames default indices use nullable Int64 always + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_frame_equal(bf_result.to_pandas(), pd_result) + + +def test_copy(scalars_df_index, scalars_pandas_df_index): + col_name = "float64_col" + # Expect mutation on original not to effect_copy + bf_series = scalars_df_index[col_name].copy() + bf_copy = bf_series.copy() + bf_copy.loc[0] = 5.6 + bf_series.loc[0] = 3.4 + + pd_series = scalars_pandas_df_index[col_name].copy() + pd_copy = pd_series.copy() + pd_copy.loc[0] = 5.6 + pd_series.loc[0] = 3.4 + + assert bf_copy.to_pandas().loc[0] != bf_series.to_pandas().loc[0] + pd.testing.assert_series_equal(bf_copy.to_pandas(), pd_copy) + + +def test_isin_raise_error(scalars_df_index, scalars_pandas_df_index): + col_name = "int64_too" + with pytest.raises(TypeError): + scalars_df_index[col_name].isin("whatever").to_pandas() + + +@pytest.mark.parametrize( + ( + "col_name", + "test_set", + ), + [ + ( + "int64_col", + [314159, 2.0, 3, pd.NA], + ), + ( + "int64_col", + [2, 55555, 4], + ), + ( + "float64_col", + [-123.456, 1.25, pd.NA], + ), + ( + "int64_too", + [1, 2, pd.NA], + ), + ( + "string_col", + ["Hello, World!", "Hi", "こんにちは"], + ), + ], +) +def test_isin(scalars_dfs, col_name, test_set): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].isin(test_set).to_pandas() + pd_result = scalars_pandas_df[col_name].isin(test_set).astype("boolean") + pd.testing.assert_series_equal( + pd_result, + bf_result, + ) + + +@pytest.mark.parametrize( + ( + "col_name", + "test_set", + ), + [ + ( + "int64_col", + [314159, 2.0, 3, pd.NA], + ), + ( + "int64_col", + [2, 55555, 4], + ), + ( + "float64_col", + [-123.456, 1.25, pd.NA], + ), + ( + "int64_too", + [1, 2, pd.NA], + ), + ( + "string_col", + ["Hello, World!", "Hi", "こんにちは"], + ), + ], +) +def test_isin_bigframes_values(scalars_dfs, col_name, test_set, session): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = ( + scalars_df[col_name].isin(series.Series(test_set, session=session)).to_pandas() + ) + pd_result = scalars_pandas_df[col_name].isin(test_set).astype("boolean") + pd.testing.assert_series_equal( + pd_result, + bf_result, + ) + + +def test_isin_bigframes_index(scalars_dfs, session): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = ( + scalars_df["string_col"] + .isin(bigframes.pandas.Index(["Hello, World!", "Hi", "こんにちは"], session=session)) + .to_pandas() + ) + pd_result = ( + scalars_pandas_df["string_col"] + .isin(pd.Index(["Hello, World!", "Hi", "こんにちは"])) + .astype("boolean") + ) + pd.testing.assert_series_equal( + pd_result, + bf_result, + ) + + +@pytest.mark.skip(reason="fixture 'scalars_dfs_maybe_ordered' not found") +@pytest.mark.parametrize( + ( + "col_name", + "test_set", + ), + [ + ( + "int64_col", + [314159, 2.0, 3, pd.NA], + ), + ( + "int64_col", + [2, 55555, 4], + ), + ( + "float64_col", + [-123.456, 1.25, pd.NA], + ), + ( + "int64_too", + [1, 2, pd.NA], + ), + ( + "string_col", + ["Hello, World!", "Hi", "こんにちは"], + ), + ], +) +def test_isin_bigframes_values_as_predicate( + scalars_dfs_maybe_ordered, col_name, test_set +): + scalars_df, scalars_pandas_df = scalars_dfs_maybe_ordered + bf_predicate = scalars_df[col_name].isin( + series.Series(test_set, session=scalars_df._session) + ) + bf_result = scalars_df[bf_predicate].to_pandas() + pd_predicate = scalars_pandas_df[col_name].isin(test_set) + pd_result = scalars_pandas_df[pd_predicate] + + pd.testing.assert_frame_equal( + pd_result.reset_index(), + bf_result.reset_index(), + ) + + +def test_isnull(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "float64_col" + bf_series = scalars_df[col_name].isnull().to_pandas() + pd_series = scalars_pandas_df[col_name].isnull() + + # One of dtype mismatches to be documented. Here, the `bf_series.dtype` is `BooleanDtype` but + # the `pd_series.dtype` is `bool`. + assert_series_equal(pd_series.astype(pd.BooleanDtype()), bf_series) + + +def test_notnull(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_series = scalars_df[col_name].notnull().to_pandas() + pd_series = scalars_pandas_df[col_name].notnull() + + # One of dtype mismatches to be documented. Here, the `bf_series.dtype` is `BooleanDtype` but + # the `pd_series.dtype` is `bool`. + assert_series_equal(pd_series.astype(pd.BooleanDtype()), bf_series) + + +def test_eq_scalar(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_result = scalars_df[col_name].eq(0).to_pandas() + pd_result = scalars_pandas_df[col_name].eq(0) + + assert_series_equal(pd_result, bf_result) + + +def test_eq_wider_type_scalar(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_result = scalars_df[col_name].eq(1.0).to_pandas() + pd_result = scalars_pandas_df[col_name].eq(1.0) + + assert_series_equal(pd_result, bf_result) + + +def test_ne_scalar(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_result = (scalars_df[col_name] != 0).to_pandas() + pd_result = scalars_pandas_df[col_name] != 0 + + assert_series_equal(pd_result, bf_result) + + +def test_eq_int_scalar(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_result = (scalars_df[col_name] == 0).to_pandas() + pd_result = scalars_pandas_df[col_name] == 0 + + assert_series_equal(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_name",), + ( + ("string_col",), + ("float64_col",), + ("int64_too",), + ), +) +def test_eq_same_type_series(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = (scalars_df[col_name] == scalars_df[col_name]).to_pandas() + pd_result = scalars_pandas_df[col_name] == scalars_pandas_df[col_name] + + # One of dtype mismatches to be documented. Here, the `bf_series.dtype` is `BooleanDtype` but + # the `pd_series.dtype` is `bool`. + assert_series_equal(pd_result.astype(pd.BooleanDtype()), bf_result) + + +def test_loc_setitem_cell(scalars_df_index, scalars_pandas_df_index): + bf_original = scalars_df_index["string_col"] + bf_series = scalars_df_index["string_col"] + pd_original = scalars_pandas_df_index["string_col"] + pd_series = scalars_pandas_df_index["string_col"].copy() + bf_series.loc[2] = "This value isn't in the test data." + pd_series.loc[2] = "This value isn't in the test data." + bf_result = bf_series.to_pandas() + pd_result = pd_series + pd.testing.assert_series_equal(bf_result, pd_result) + # Per Copy-on-Write semantics, other references to the original DataFrame + # should remain unchanged. + pd.testing.assert_series_equal(bf_original.to_pandas(), pd_original) + + +def test_at_setitem_row_label_scalar(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_series = scalars_df["int64_col"] + pd_series = scalars_pandas_df["int64_col"].copy() + bf_series.at[1] = 1000 + pd_series.at[1] = 1000 + bf_result = bf_series.to_pandas() + pd_result = pd_series.astype("Int64") + pd.testing.assert_series_equal(bf_result, pd_result) + + +def test_ne_obj_series(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = (scalars_df[col_name] != scalars_df[col_name]).to_pandas() + pd_result = scalars_pandas_df[col_name] != scalars_pandas_df[col_name] + + # One of dtype mismatches to be documented. Here, the `bf_series.dtype` is `BooleanDtype` but + # the `pd_series.dtype` is `bool`. + assert_series_equal(pd_result.astype(pd.BooleanDtype()), bf_result) + + +def test_indexing_using_unselected_series(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = scalars_df[col_name][scalars_df["int64_too"].eq(0)].to_pandas() + pd_result = scalars_pandas_df[col_name][scalars_pandas_df["int64_too"].eq(0)] + + assert_series_equal( + pd_result, + bf_result, + ) + + +def test_indexing_using_selected_series(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "string_col" + bf_result = scalars_df[col_name][ + scalars_df["string_col"].eq("Hello, World!") + ].to_pandas() + pd_result = scalars_pandas_df[col_name][ + scalars_pandas_df["string_col"].eq("Hello, World!") + ] + + assert_series_equal( + pd_result, + bf_result, + ) + + +@pytest.mark.parametrize( + ("indices"), + [ + ([1, 3, 5]), + ([5, -3, -5, -6]), + ([-2, -4, -6]), + ], +) +def test_take(scalars_dfs, indices): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df.take(indices).to_pandas() + pd_result = scalars_pandas_df.take(indices) + + assert_pandas_df_equal(bf_result, pd_result) + + +def test_nested_filter(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + string_col = scalars_df["string_col"] + int64_too = scalars_df["int64_too"] + bool_col = scalars_df["bool_col"] == bool( + True + ) # Convert from nullable bool to nonnullable bool usable as indexer + bf_result = string_col[int64_too == 0][~bool_col].to_pandas() + + pd_string_col = scalars_pandas_df["string_col"] + pd_int64_too = scalars_pandas_df["int64_too"] + pd_bool_col = scalars_pandas_df["bool_col"] == bool( + True + ) # Convert from nullable bool to nonnullable bool usable as indexer + pd_result = pd_string_col[pd_int64_too == 0][~pd_bool_col] + + assert_series_equal( + pd_result, + bf_result, + ) + + +def test_binop_opposite_filters(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + int64_col1 = scalars_df["int64_col"] + int64_col2 = scalars_df["int64_col"] + bool_col = scalars_df["bool_col"] + bf_result = (int64_col1[bool_col] + int64_col2[bool_col.__invert__()]).to_pandas() + + pd_int64_col1 = scalars_pandas_df["int64_col"] + pd_int64_col2 = scalars_pandas_df["int64_col"] + pd_bool_col = scalars_pandas_df["bool_col"] + pd_result = pd_int64_col1[pd_bool_col] + pd_int64_col2[pd_bool_col.__invert__()] + + # Passes with ignore_order=False only with some dependency sets + # TODO: Determine desired behavior and make test more strict + assert_series_equal(bf_result, pd_result, ignore_order=True) + + +def test_binop_left_filtered(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + int64_col = scalars_df["int64_col"] + float64_col = scalars_df["float64_col"] + bool_col = scalars_df["bool_col"] + bf_result = (int64_col[bool_col] + float64_col).to_pandas() + + pd_int64_col = scalars_pandas_df["int64_col"] + pd_float64_col = scalars_pandas_df["float64_col"] + pd_bool_col = scalars_pandas_df["bool_col"] + pd_result = pd_int64_col[pd_bool_col] + pd_float64_col + + # Passes with ignore_order=False only with some dependency sets + # TODO: Determine desired behavior and make test more strict + assert_series_equal(bf_result, pd_result, ignore_order=True) + + +def test_binop_right_filtered(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + int64_col = scalars_df["int64_col"] + float64_col = scalars_df["float64_col"] + bool_col = scalars_df["bool_col"] + bf_result = (float64_col + int64_col[bool_col]).to_pandas() + + pd_int64_col = scalars_pandas_df["int64_col"] + pd_float64_col = scalars_pandas_df["float64_col"] + pd_bool_col = scalars_pandas_df["bool_col"] + pd_result = pd_float64_col + pd_int64_col[pd_bool_col] + + assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("other",), + [ + ([-1.4, 2.3, None],), + (pd.Index([-1.4, 2.3, None]),), + (pd.Series([-1.4, 2.3, None], index=[44, 2, 1]),), + ], +) +def test_series_binop_w_other_types(scalars_dfs, other): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = (scalars_df["int64_col"].head(3) + other).to_pandas() + pd_result = scalars_pandas_df["int64_col"].head(3) + other + + assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("other",), + [ + ([-1.4, 2.3, None],), + (pd.Index([-1.4, 2.3, None]),), + (pd.Series([-1.4, 2.3, None], index=[44, 2, 1]),), + ], +) +def test_series_reverse_binop_w_other_types(scalars_dfs, other): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = (other + scalars_df["int64_col"].head(3)).to_pandas() + pd_result = other + scalars_pandas_df["int64_col"].head(3) + + assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_combine_first(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + int64_col = scalars_df["int64_col"].head(7) + float64_col = scalars_df["float64_col"].tail(7) + bf_result = int64_col.combine_first(float64_col).to_pandas() + + pd_int64_col = scalars_pandas_df["int64_col"].head(7) + pd_float64_col = scalars_pandas_df["float64_col"].tail(7) + pd_result = pd_int64_col.combine_first(pd_float64_col) + + assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_update(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + int64_col = scalars_df["int64_col"].head(7) + float64_col = scalars_df["float64_col"].tail(7).copy() + float64_col.update(int64_col) + + pd_int64_col = scalars_pandas_df["int64_col"].head(7) + pd_float64_col = scalars_pandas_df["float64_col"].tail(7).copy() + pd_float64_col.update(pd_int64_col) + + assert_series_equal( + float64_col.to_pandas(), + pd_float64_col, + ) + + +def test_mean(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + bf_result = scalars_df[col_name].mean() + pd_result = scalars_pandas_df[col_name].mean() + assert math.isclose(pd_result, bf_result) + + +@pytest.mark.parametrize( + ("col_name"), + [ + pytest.param( + "int64_col", + marks=[ + pytest.mark.skip( + reason="pyarrow.lib.ArrowInvalid: Float value 27778.500000 was truncated converting to int64" + ) + ], + ), + # Non-numeric column + pytest.param( + "bytes_col", + marks=[ + pytest.mark.skip( + reason="polars.exceptions.InvalidOperationError: `median` operation not supported for dtype `binary`" + ) + ], + ), + "date_col", + "datetime_col", + pytest.param( + "time_col", + marks=[ + pytest.mark.skip( + reason="pyarrow.lib.ArrowInvalid: Casting from time64[ns] to time64[us] would lose data: 42651538080500" + ) + ], + ), + "timestamp_col", + pytest.param( + "string_col", + marks=[ + pytest.mark.skip( + reason="polars.exceptions.InvalidOperationError: `median` operation not supported for dtype `str`" + ) + ], + ), + ], +) +def test_median(scalars_dfs, col_name): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df[col_name].median(exact=False) + pd_max = scalars_pandas_df[col_name].max() + pd_min = scalars_pandas_df[col_name].min() + # Median is approximate, so just check for plausibility. + assert pd_min < bf_result < pd_max + + +def test_numeric_literal(scalars_dfs): + scalars_df, _ = scalars_dfs + col_name = "numeric_col" + assert scalars_df[col_name].dtype == pd.ArrowDtype(pa.decimal128(38, 9)) + bf_result = scalars_df[col_name] + 42 + assert bf_result.size == scalars_df[col_name].size + assert bf_result.dtype == pd.ArrowDtype(pa.decimal128(38, 9)) + + +def test_series_small_repr(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + col_name = "int64_col" + bf_series = scalars_df[col_name] + pd_series = scalars_pandas_df[col_name] + assert repr(bf_series) == pd_series.to_string(length=False, dtype=True, name=True) + + +def test_sum(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + bf_result = scalars_df[col_name].sum() + pd_result = scalars_pandas_df[col_name].sum() + assert pd_result == bf_result + + +def test_product(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "float64_col" + bf_result = scalars_df[col_name].product() + pd_result = scalars_pandas_df[col_name].product() + assert math.isclose(pd_result, bf_result) + + +def test_cumprod(scalars_dfs): + if pd.__version__.startswith("1."): + pytest.skip("Series.cumprod NA mask are different in pandas 1.x.") + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "float64_col" + bf_result = scalars_df[col_name].cumprod() + pd_result = scalars_pandas_df[col_name].cumprod() + pd.testing.assert_series_equal( + pd_result, + bf_result.to_pandas(), + ) + + +def test_count(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + bf_result = scalars_df[col_name].count() + pd_result = scalars_pandas_df[col_name].count() + assert pd_result == bf_result + + +def test_nunique(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + bf_result = (scalars_df[col_name] % 3).nunique() + pd_result = (scalars_pandas_df[col_name] % 3).nunique() + assert pd_result == bf_result + + +def test_all(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + bf_result = scalars_df[col_name].all() + pd_result = scalars_pandas_df[col_name].all() + assert pd_result == bf_result + + +def test_any(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + bf_result = scalars_df[col_name].any() + pd_result = scalars_pandas_df[col_name].any() + assert pd_result == bf_result + + +def test_groupby_sum(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_series = ( + scalars_df[col_name] + .groupby([scalars_df["bool_col"], ~scalars_df["bool_col"]]) + .sum() + ) + pd_series = ( + scalars_pandas_df[col_name] + .groupby([scalars_pandas_df["bool_col"], ~scalars_pandas_df["bool_col"]]) + .sum() + ) + # TODO(swast): Update groupby to use index based on group by key(s). + bf_result = bf_series.to_pandas() + assert_series_equal( + pd_series, + bf_result, + check_exact=False, + ) + + +def test_groupby_std(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_series = scalars_df[col_name].groupby(scalars_df["string_col"]).std() + pd_series = ( + scalars_pandas_df[col_name] + .groupby(scalars_pandas_df["string_col"]) + .std() + .astype(pd.Float64Dtype()) + ) + bf_result = bf_series.to_pandas() + assert_series_equal( + pd_series, + bf_result, + check_exact=False, + ) + + +def test_groupby_var(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_series = scalars_df[col_name].groupby(scalars_df["string_col"]).var() + pd_series = ( + scalars_pandas_df[col_name].groupby(scalars_pandas_df["string_col"]).var() + ) + bf_result = bf_series.to_pandas() + assert_series_equal( + pd_series, + bf_result, + check_exact=False, + ) + + +def test_groupby_level_sum(scalars_dfs): + # TODO(tbergeron): Use a non-unique index once that becomes possible in tests + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + + bf_series = scalars_df[col_name].groupby(level=0).sum() + pd_series = scalars_pandas_df[col_name].groupby(level=0).sum() + # TODO(swast): Update groupby to use index based on group by key(s). + pd.testing.assert_series_equal( + pd_series.sort_index(), + bf_series.to_pandas().sort_index(), + ) + + +def test_groupby_level_list_sum(scalars_dfs): + # TODO(tbergeron): Use a non-unique index once that becomes possible in tests + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + + bf_series = scalars_df[col_name].groupby(level=["rowindex"]).sum() + pd_series = scalars_pandas_df[col_name].groupby(level=["rowindex"]).sum() + # TODO(swast): Update groupby to use index based on group by key(s). + pd.testing.assert_series_equal( + pd_series.sort_index(), + bf_series.to_pandas().sort_index(), + ) + + +def test_groupby_mean(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_series = ( + scalars_df[col_name].groupby(scalars_df["string_col"], dropna=False).mean() + ) + pd_series = ( + scalars_pandas_df[col_name] + .groupby(scalars_pandas_df["string_col"], dropna=False) + .mean() + ) + # TODO(swast): Update groupby to use index based on group by key(s). + bf_result = bf_series.to_pandas() + assert_series_equal( + pd_series, + bf_result, + ) + + +@pytest.mark.skip( + reason="Aggregate op QuantileOp(q=0.5, should_floor_result=False) not yet supported in polars engine." +) +def test_groupby_median_exact(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_result = ( + scalars_df[col_name].groupby(scalars_df["string_col"], dropna=False).median() + ) + pd_result = ( + scalars_pandas_df[col_name] + .groupby(scalars_pandas_df["string_col"], dropna=False) + .median() + ) + + assert_series_equal( + pd_result, + bf_result.to_pandas(), + ) + + +@pytest.mark.skip( + reason="pyarrow.lib.ArrowInvalid: Float value -1172.500000 was truncated converting to int64" +) +def test_groupby_median_inexact(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_series = ( + scalars_df[col_name] + .groupby(scalars_df["string_col"], dropna=False) + .median(exact=False) + ) + pd_max = ( + scalars_pandas_df[col_name] + .groupby(scalars_pandas_df["string_col"], dropna=False) + .max() + ) + pd_min = ( + scalars_pandas_df[col_name] + .groupby(scalars_pandas_df["string_col"], dropna=False) + .min() + ) + # TODO(swast): Update groupby to use index based on group by key(s). + bf_result = bf_series.to_pandas() + + # Median is approximate, so just check that it's plausible. + assert ((pd_min <= bf_result) & (bf_result <= pd_max)).all() + + +def test_groupby_prod(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + bf_series = scalars_df[col_name].groupby(scalars_df["int64_col"]).prod() + pd_series = ( + scalars_pandas_df[col_name].groupby(scalars_pandas_df["int64_col"]).prod() + ).astype(pd.Float64Dtype()) + # TODO(swast): Update groupby to use index based on group by key(s). + bf_result = bf_series.to_pandas() + assert_series_equal( + pd_series, + bf_result, + ) + + +@pytest.mark.skip(reason="AssertionError: Series are different") +@pytest.mark.parametrize( + ("operator"), + [ + (lambda x: x.cumsum()), + (lambda x: x.cumcount()), + (lambda x: x.cummin()), + (lambda x: x.cummax()), + # Pandas 2.2 casts to cumprod to float. + (lambda x: x.cumprod().astype("Float64")), + (lambda x: x.diff()), + (lambda x: x.shift(2)), + (lambda x: x.shift(-2)), + ], + ids=[ + "cumsum", + "cumcount", + "cummin", + "cummax", + "cumprod", + "diff", + "shiftpostive", + "shiftnegative", + ], +) +def test_groupby_window_ops(scalars_df_index, scalars_pandas_df_index, operator): + col_name = "int64_col" + group_key = "int64_too" # has some duplicates values, good for grouping + bf_series = ( + operator(scalars_df_index[col_name].groupby(scalars_df_index[group_key])) + ).to_pandas() + pd_series = operator( + scalars_pandas_df_index[col_name].groupby(scalars_pandas_df_index[group_key]) + ).astype(bf_series.dtype) + + pd.testing.assert_series_equal( + pd_series, + bf_series, + ) + + +@pytest.mark.parametrize( + ("label", "col_name"), + [ + (0, "bool_col"), + (1, "int64_col"), + ], +) +def test_drop_label(scalars_df_index, scalars_pandas_df_index, label, col_name): + bf_series = scalars_df_index[col_name].drop(label).to_pandas() + pd_series = scalars_pandas_df_index[col_name].drop(label) + pd.testing.assert_series_equal( + pd_series, + bf_series, + ) + + +def test_drop_label_list(scalars_df_index, scalars_pandas_df_index): + col_name = "int64_col" + bf_series = scalars_df_index[col_name].drop([1, 3]).to_pandas() + pd_series = scalars_pandas_df_index[col_name].drop([1, 3]) + pd.testing.assert_series_equal( + pd_series, + bf_series, + ) + + +@pytest.mark.skip(reason="AssertionError: Series.index are different") +@pytest.mark.parametrize( + ("col_name",), + [ + ("bool_col",), + ("int64_too",), + ], +) +@pytest.mark.parametrize( + ("keep",), + [ + ("first",), + ("last",), + (False,), + ], +) +def test_drop_duplicates(scalars_df_index, scalars_pandas_df_index, keep, col_name): + bf_series = scalars_df_index[col_name].drop_duplicates(keep=keep).to_pandas() + pd_series = scalars_pandas_df_index[col_name].drop_duplicates(keep=keep) + pd.testing.assert_series_equal( + pd_series, + bf_series, + ) + + +@pytest.mark.skip(reason="TypeError: boolean value of NA is ambiguous") +@pytest.mark.parametrize( + ("col_name",), + [ + ("bool_col",), + ("int64_too",), + ], +) +def test_unique(scalars_df_index, scalars_pandas_df_index, col_name): + bf_uniq = scalars_df_index[col_name].unique().to_numpy(na_value=None) + pd_uniq = scalars_pandas_df_index[col_name].unique() + numpy.array_equal(pd_uniq, bf_uniq) + + +@pytest.mark.skip(reason="AssertionError: Series are different") +@pytest.mark.parametrize( + ("col_name",), + [ + ("bool_col",), + ("int64_too",), + ], +) +@pytest.mark.parametrize( + ("keep",), + [ + ("first",), + ("last",), + (False,), + ], +) +def test_duplicated(scalars_df_index, scalars_pandas_df_index, keep, col_name): + bf_series = scalars_df_index[col_name].duplicated(keep=keep).to_pandas() + pd_series = scalars_pandas_df_index[col_name].duplicated(keep=keep) + pd.testing.assert_series_equal(pd_series, bf_series, check_dtype=False) + + +def test_shape(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].shape + pd_result = scalars_pandas_df["string_col"].shape + + assert pd_result == bf_result + + +def test_len(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = len(scalars_df["string_col"]) + pd_result = len(scalars_pandas_df["string_col"]) + + assert pd_result == bf_result + + +def test_size(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].size + pd_result = scalars_pandas_df["string_col"].size + + assert pd_result == bf_result + + +def test_series_hasnans_true(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].hasnans + pd_result = scalars_pandas_df["string_col"].hasnans + + assert pd_result == bf_result + + +def test_series_hasnans_false(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].dropna().hasnans + pd_result = scalars_pandas_df["string_col"].dropna().hasnans + + assert pd_result == bf_result + + +def test_empty_false(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].empty + pd_result = scalars_pandas_df["string_col"].empty + + assert pd_result == bf_result + + +def test_empty_true_row_filter(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"][ + scalars_df["string_col"] == "won't find this" + ].empty + pd_result = scalars_pandas_df["string_col"][ + scalars_pandas_df["string_col"] == "won't find this" + ].empty + + assert pd_result + assert pd_result == bf_result + + +def test_series_names(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].copy() + bf_result.index.name = "new index name" + bf_result.name = "new series name" + + pd_result = scalars_pandas_df["string_col"].copy() + pd_result.index.name = "new index name" + pd_result.name = "new series name" + + assert pd_result.name == bf_result.name + assert pd_result.index.name == bf_result.index.name + + +def test_dtype(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].dtype + pd_result = scalars_pandas_df["string_col"].dtype + + assert pd_result == bf_result + + +def test_dtypes(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["int64_col"].dtypes + pd_result = scalars_pandas_df["int64_col"].dtypes + + assert pd_result == bf_result + + +def test_head(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].head(2).to_pandas() + pd_result = scalars_pandas_df["string_col"].head(2) + + assert_series_equal( + pd_result, + bf_result, + ) + + +def test_tail(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["string_col"].tail(2).to_pandas() + pd_result = scalars_pandas_df["string_col"].tail(2) + + assert_series_equal( + pd_result, + bf_result, + ) + + +def test_head_then_scalar_operation(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = (scalars_df["float64_col"].head(1) + 4).to_pandas() + pd_result = scalars_pandas_df["float64_col"].head(1) + 4 + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_head_then_series_operation(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = ( + scalars_df["float64_col"].head(4) + scalars_df["float64_col"].head(2) + ).to_pandas() + pd_result = scalars_pandas_df["float64_col"].head(4) + scalars_pandas_df[ + "float64_col" + ].head(2) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_peek(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + peek_result = scalars_df["float64_col"].peek(n=3, force=False) + + pd.testing.assert_series_equal( + peek_result, + scalars_pandas_df["float64_col"].reindex_like(peek_result), + ) + assert len(peek_result) == 3 + + +def test_series_peek_with_large_results_not_allowed(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + session = scalars_df._block.session + slot_millis_sum = session.slot_millis_sum + peek_result = scalars_df["float64_col"].peek( + n=3, force=False, allow_large_results=False + ) + + # The metrics won't be fully updated when we call query_and_wait. + print(session.slot_millis_sum - slot_millis_sum) + assert session.slot_millis_sum - slot_millis_sum < 500 + pd.testing.assert_series_equal( + peek_result, + scalars_pandas_df["float64_col"].reindex_like(peek_result), + ) + assert len(peek_result) == 3 + + +def test_series_peek_multi_index(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_series = scalars_df.set_index(["string_col", "bool_col"])["float64_col"] + bf_series.name = ("2-part", "name") + pd_series = scalars_pandas_df.set_index(["string_col", "bool_col"])["float64_col"] + pd_series.name = ("2-part", "name") + peek_result = bf_series.peek(n=3, force=False) + pd.testing.assert_series_equal( + peek_result, + pd_series.reindex_like(peek_result), + ) + + +def test_series_peek_filtered(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + peek_result = scalars_df[scalars_df.int64_col > 0]["float64_col"].peek( + n=3, force=False + ) + pd_result = scalars_pandas_df[scalars_pandas_df.int64_col > 0]["float64_col"] + pd.testing.assert_series_equal( + peek_result, + pd_result.reindex_like(peek_result), + ) + + +def test_series_peek_force(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + cumsum_df = scalars_df[["int64_col", "int64_too"]].cumsum() + df_filtered = cumsum_df[cumsum_df.int64_col > 0]["int64_too"] + peek_result = df_filtered.peek(n=3, force=True) + pd_cumsum_df = scalars_pandas_df[["int64_col", "int64_too"]].cumsum() + pd_result = pd_cumsum_df[pd_cumsum_df.int64_col > 0]["int64_too"] + pd.testing.assert_series_equal( + peek_result, + pd_result.reindex_like(peek_result), + ) + + +def test_series_peek_force_float(scalars_dfs): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + + cumsum_df = scalars_df[["int64_col", "float64_col"]].cumsum() + df_filtered = cumsum_df[cumsum_df.float64_col > 0]["float64_col"] + peek_result = df_filtered.peek(n=3, force=True) + pd_cumsum_df = scalars_pandas_df[["int64_col", "float64_col"]].cumsum() + pd_result = pd_cumsum_df[pd_cumsum_df.float64_col > 0]["float64_col"] + pd.testing.assert_series_equal( + peek_result, + pd_result.reindex_like(peek_result), + ) + + +def test_shift(scalars_df_index, scalars_pandas_df_index): + col_name = "int64_col" + bf_result = scalars_df_index[col_name].shift().to_pandas() + # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA + pd_result = scalars_pandas_df_index[col_name].shift().astype(pd.Int64Dtype()) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_ffill(scalars_df_index, scalars_pandas_df_index): + col_name = "numeric_col" + bf_result = scalars_df_index[col_name].ffill(limit=1).to_pandas() + pd_result = scalars_pandas_df_index[col_name].ffill(limit=1) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_bfill(scalars_df_index, scalars_pandas_df_index): + col_name = "numeric_col" + bf_result = scalars_df_index[col_name].bfill(limit=2).to_pandas() + pd_result = scalars_pandas_df_index[col_name].bfill(limit=2) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cumsum_int(scalars_df_index, scalars_pandas_df_index): + if pd.__version__.startswith("1."): + pytest.skip("Series.cumsum NA mask are different in pandas 1.x.") + + col_name = "int64_col" + bf_result = scalars_df_index[col_name].cumsum().to_pandas() + # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA + pd_result = scalars_pandas_df_index[col_name].cumsum().astype(pd.Int64Dtype()) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cumsum_int_ordered(scalars_df_index, scalars_pandas_df_index): + if pd.__version__.startswith("1."): + pytest.skip("Series.cumsum NA mask are different in pandas 1.x.") + + col_name = "int64_col" + bf_result = ( + scalars_df_index.sort_values(by="rowindex_2")[col_name].cumsum().to_pandas() + ) + # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA + pd_result = ( + scalars_pandas_df_index.sort_values(by="rowindex_2")[col_name] + .cumsum() + .astype(pd.Int64Dtype()) + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.skip( + reason="NotImplementedError: Aggregate op RankOp() not yet supported in polars engine." +) +@pytest.mark.parametrize( + ("keep",), + [ + ("first",), + ("last",), + ("all",), + ], +) +def test_series_nlargest(scalars_df_index, scalars_pandas_df_index, keep): + col_name = "bool_col" + bf_result = scalars_df_index[col_name].nlargest(4, keep=keep).to_pandas() + pd_result = scalars_pandas_df_index[col_name].nlargest(4, keep=keep) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("periods",), + [ + (1,), + (2,), + (-1,), + ], +) +def test_diff(scalars_df_index, scalars_pandas_df_index, periods): + bf_result = scalars_df_index["int64_col"].diff(periods=periods).to_pandas() + # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA + pd_result = ( + scalars_pandas_df_index["int64_col"] + .diff(periods=periods) + .astype(pd.Int64Dtype()) + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("periods",), + [ + (1,), + (2,), + (-1,), + ], +) +def test_series_pct_change(scalars_df_index, scalars_pandas_df_index, periods): + bf_result = scalars_df_index["int64_col"].pct_change(periods=periods).to_pandas() + # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA + pd_result = scalars_pandas_df_index["int64_col"].pct_change(periods=periods) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.skip( + reason="NotImplementedError: Aggregate op RankOp() not yet supported in polars engine." +) +@pytest.mark.parametrize( + ("keep",), + [ + ("first",), + ("last",), + ("all",), + ], +) +def test_series_nsmallest(scalars_df_index, scalars_pandas_df_index, keep): + col_name = "bool_col" + bf_result = scalars_df_index[col_name].nsmallest(2, keep=keep).to_pandas() + pd_result = scalars_pandas_df_index[col_name].nsmallest(2, keep=keep) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.skip( + reason="NotImplementedError: Aggregate op DenseRankOp() not yet supported in polars engine." +) +@pytest.mark.parametrize( + ("na_option", "method", "ascending", "numeric_only", "pct"), + [ + ("keep", "average", True, True, False), + ("top", "min", False, False, True), + ("bottom", "max", False, False, False), + ("top", "first", False, False, True), + ("bottom", "dense", False, False, False), + ], +) +def test_series_rank( + scalars_df_index, + scalars_pandas_df_index, + na_option, + method, + ascending, + numeric_only, + pct, +): + col_name = "int64_too" + bf_result = ( + scalars_df_index[col_name] + .rank( + na_option=na_option, + method=method, + ascending=ascending, + numeric_only=numeric_only, + pct=pct, + ) + .to_pandas() + ) + pd_result = ( + scalars_pandas_df_index[col_name] + .rank( + na_option=na_option, + method=method, + ascending=ascending, + numeric_only=numeric_only, + pct=pct, + ) + .astype(pd.Float64Dtype()) + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cast_float_to_int(scalars_df_index, scalars_pandas_df_index): + col_name = "float64_col" + bf_result = scalars_df_index[col_name].astype(pd.Int64Dtype()).to_pandas() + # cumsum does not behave well on nullable floats in pandas, produces object type and never ignores NA + pd_result = scalars_pandas_df_index[col_name].astype(pd.Int64Dtype()) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cast_float_to_bool(scalars_df_index, scalars_pandas_df_index): + col_name = "float64_col" + bf_result = scalars_df_index[col_name].astype(pd.BooleanDtype()).to_pandas() + # cumsum does not behave well on nullable floats in pandas, produces object type and never ignores NA + pd_result = scalars_pandas_df_index[col_name].astype(pd.BooleanDtype()) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cumsum_nested(scalars_df_index, scalars_pandas_df_index): + col_name = "float64_col" + bf_result = scalars_df_index[col_name].cumsum().cumsum().cumsum().to_pandas() + # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA + pd_result = ( + scalars_pandas_df_index[col_name] + .cumsum() + .cumsum() + .cumsum() + .astype(pd.Float64Dtype()) + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.skip( + reason="NotImplementedError: min_period not yet supported for polars engine" +) +def test_nested_analytic_ops_align(scalars_df_index, scalars_pandas_df_index): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + col_name = "float64_col" + # set non-unique index to check implicit alignment + bf_series = scalars_df_index.set_index("bool_col")[col_name].fillna(0.0) + pd_series = scalars_pandas_df_index.set_index("bool_col")[col_name].fillna(0.0) + + bf_result = ( + (bf_series + 5) + + (bf_series.cumsum().cumsum().cumsum() + bf_series.rolling(window=3).mean()) + + bf_series.expanding().max() + ).to_pandas() + # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA + pd_result = ( + (pd_series + 5) + + ( + pd_series.cumsum().cumsum().cumsum().astype(pd.Float64Dtype()) + + pd_series.rolling(window=3).mean() + ) + + pd_series.expanding().max() + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cumsum_int_filtered(scalars_df_index, scalars_pandas_df_index): + col_name = "int64_col" + + bf_col = scalars_df_index[col_name] + bf_result = bf_col[bf_col > -2].cumsum().to_pandas() + + pd_col = scalars_pandas_df_index[col_name] + # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA + pd_result = pd_col[pd_col > -2].cumsum().astype(pd.Int64Dtype()) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cumsum_float(scalars_df_index, scalars_pandas_df_index): + col_name = "float64_col" + bf_result = scalars_df_index[col_name].cumsum().to_pandas() + # cumsum does not behave well on nullable floats in pandas, produces object type and never ignores NA + pd_result = scalars_pandas_df_index[col_name].cumsum().astype(pd.Float64Dtype()) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cummin_int(scalars_df_index, scalars_pandas_df_index): + col_name = "int64_col" + bf_result = scalars_df_index[col_name].cummin().to_pandas() + pd_result = scalars_pandas_df_index[col_name].cummin() + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_cummax_int(scalars_df_index, scalars_pandas_df_index): + col_name = "int64_col" + bf_result = scalars_df_index[col_name].cummax().to_pandas() + pd_result = scalars_pandas_df_index[col_name].cummax() + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("kwargs"), + [ + {}, + {"normalize": True}, + {"ascending": True}, + ], + ids=[ + "default", + "normalize", + "ascending", + ], +) +def test_value_counts(scalars_dfs, kwargs): + if pd.__version__.startswith("1."): + pytest.skip("pandas 1.x produces different column labels.") + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_too" + + # Pandas `value_counts` can produce non-deterministic results with tied counts. + # Remove duplicates to enforce a consistent output. + s = scalars_df[col_name].drop(0) + pd_s = scalars_pandas_df[col_name].drop(0) + + bf_result = s.value_counts(**kwargs).to_pandas() + pd_result = pd_s.value_counts(**kwargs) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_value_counts_with_na(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + + bf_result = scalars_df[col_name].value_counts(dropna=False).to_pandas() + pd_result = scalars_pandas_df[col_name].value_counts(dropna=False) + + # Older pandas version may not have these values, bigframes tries to emulate 2.0+ + pd_result.name = "count" + pd_result.index.name = col_name + + assert_series_equal( + bf_result, + pd_result, + # bigframes values_counts does not honor ordering in the original data + ignore_order=True, + ) + + +@pytest.mark.skip( + reason="NotImplementedError: Aggregate op CutOp(bins=3, right=True, labels=False) not yet supported in polars engine." +) +def test_value_counts_w_cut(scalars_dfs): + if pd.__version__.startswith("1."): + pytest.skip("value_counts results different in pandas 1.x.") + scalars_df, scalars_pandas_df = scalars_dfs + col_name = "int64_col" + + bf_cut = bigframes.pandas.cut(scalars_df[col_name], 3, labels=False) + pd_cut = pd.cut(scalars_pandas_df[col_name], 3, labels=False) + + bf_result = bf_cut.value_counts().to_pandas() + pd_result = pd_cut.value_counts() + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + + pd.testing.assert_series_equal( + bf_result, + pd_result.astype(pd.Int64Dtype()), + ) + + +def test_iloc_nested(scalars_df_index, scalars_pandas_df_index): + + bf_result = scalars_df_index["string_col"].iloc[1:].iloc[1:].to_pandas() + pd_result = scalars_pandas_df_index["string_col"].iloc[1:].iloc[1:] + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("start", "stop", "step"), + [ + (1, None, None), + (None, 4, None), + (None, None, 2), + (None, 50000000000, 1), + (5, 4, None), + (3, None, 2), + (1, 7, 2), + (1, 7, 50000000000), + (-1, -7, -2), + (None, -7, -2), + (-1, None, -2), + (-7, -1, 2), + (-7, -1, None), + (-7, 7, None), + (7, -7, -2), + ], +) +def test_series_iloc(scalars_df_index, scalars_pandas_df_index, start, stop, step): + bf_result = scalars_df_index["string_col"].iloc[start:stop:step].to_pandas() + pd_result = scalars_pandas_df_index["string_col"].iloc[start:stop:step] + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_at(scalars_df_index, scalars_pandas_df_index): + scalars_df_index = scalars_df_index.set_index("int64_too", drop=False) + scalars_pandas_df_index = scalars_pandas_df_index.set_index("int64_too", drop=False) + index = -2345 + bf_result = scalars_df_index["string_col"].at[index] + pd_result = scalars_pandas_df_index["string_col"].at[index] + + assert bf_result == pd_result + + +def test_iat(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].iat[3] + pd_result = scalars_pandas_df_index["int64_too"].iat[3] + + assert bf_result == pd_result + + +def test_iat_error(scalars_df_index, scalars_pandas_df_index): + with pytest.raises(ValueError): + scalars_pandas_df_index["int64_too"].iat["asd"] + with pytest.raises(ValueError): + scalars_df_index["int64_too"].iat["asd"] + + +def test_series_add_prefix(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].add_prefix("prefix_").to_pandas() + + pd_result = scalars_pandas_df_index["int64_too"].add_prefix("prefix_") + + # Index will be object type in pandas, string type in bigframes, but same values + pd.testing.assert_series_equal( + bf_result, + pd_result, + check_index_type=False, + ) + + +def test_series_add_suffix(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].add_suffix("_suffix").to_pandas() + + pd_result = scalars_pandas_df_index["int64_too"].add_suffix("_suffix") + + # Index will be object type in pandas, string type in bigframes, but same values + pd.testing.assert_series_equal( + bf_result, + pd_result, + check_index_type=False, + ) + + +def test_series_filter_items(scalars_df_index, scalars_pandas_df_index): + if pd.__version__.startswith("2.0") or pd.__version__.startswith("1."): + pytest.skip("pandas filter items behavior different pre-2.1") + bf_result = scalars_df_index["float64_col"].filter(items=[5, 1, 3]).to_pandas() + + pd_result = scalars_pandas_df_index["float64_col"].filter(items=[5, 1, 3]) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + # Ignore ordering as pandas order differently depending on version + assert_series_equal(bf_result, pd_result, check_names=False, ignore_order=True) + + +def test_series_filter_like(scalars_df_index, scalars_pandas_df_index): + scalars_df_index = scalars_df_index.copy().set_index("string_col") + scalars_pandas_df_index = scalars_pandas_df_index.copy().set_index("string_col") + + bf_result = scalars_df_index["float64_col"].filter(like="ello").to_pandas() + + pd_result = scalars_pandas_df_index["float64_col"].filter(like="ello") + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_filter_regex(scalars_df_index, scalars_pandas_df_index): + scalars_df_index = scalars_df_index.copy().set_index("string_col") + scalars_pandas_df_index = scalars_pandas_df_index.copy().set_index("string_col") + + bf_result = scalars_df_index["float64_col"].filter(regex="^[GH].*").to_pandas() + + pd_result = scalars_pandas_df_index["float64_col"].filter(regex="^[GH].*") + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_reindex(scalars_df_index, scalars_pandas_df_index): + bf_result = ( + scalars_df_index["float64_col"].reindex(index=[5, 1, 3, 99, 1]).to_pandas() + ) + + pd_result = scalars_pandas_df_index["float64_col"].reindex(index=[5, 1, 3, 99, 1]) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_reindex_nonunique(scalars_df_index): + with pytest.raises(ValueError): + # int64_too is non-unique + scalars_df_index.set_index("int64_too")["float64_col"].reindex( + index=[5, 1, 3, 99, 1], validate=True + ) + + +def test_series_reindex_like(scalars_df_index, scalars_pandas_df_index): + bf_reindex_target = scalars_df_index["float64_col"].reindex(index=[5, 1, 3, 99, 1]) + bf_result = ( + scalars_df_index["int64_too"].reindex_like(bf_reindex_target).to_pandas() + ) + + pd_reindex_target = scalars_pandas_df_index["float64_col"].reindex( + index=[5, 1, 3, 99, 1] + ) + pd_result = scalars_pandas_df_index["int64_too"].reindex_like(pd_reindex_target) + + # Pandas uses int64 instead of Int64 (nullable) dtype. + pd_result.index = pd_result.index.astype(pd.Int64Dtype()) + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_where_with_series(scalars_df_index, scalars_pandas_df_index): + bf_result = ( + scalars_df_index["int64_col"] + .where(scalars_df_index["bool_col"], scalars_df_index["int64_too"]) + .to_pandas() + ) + pd_result = scalars_pandas_df_index["int64_col"].where( + scalars_pandas_df_index["bool_col"], scalars_pandas_df_index["int64_too"] + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_where_with_different_indices(scalars_df_index, scalars_pandas_df_index): + bf_result = ( + scalars_df_index["int64_col"] + .iloc[::2] + .where( + scalars_df_index["bool_col"].iloc[2:], + scalars_df_index["int64_too"].iloc[:5], + ) + .to_pandas() + ) + pd_result = ( + scalars_pandas_df_index["int64_col"] + .iloc[::2] + .where( + scalars_pandas_df_index["bool_col"].iloc[2:], + scalars_pandas_df_index["int64_too"].iloc[:5], + ) + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_where_with_default(scalars_df_index, scalars_pandas_df_index): + bf_result = ( + scalars_df_index["int64_col"].where(scalars_df_index["bool_col"]).to_pandas() + ) + pd_result = scalars_pandas_df_index["int64_col"].where( + scalars_pandas_df_index["bool_col"] + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_where_with_callable(scalars_df_index, scalars_pandas_df_index): + def _is_positive(x): + return x > 0 + + # Both cond and other are callable. + bf_result = ( + scalars_df_index["int64_col"] + .where(cond=_is_positive, other=lambda x: x * 10) + .to_pandas() + ) + pd_result = scalars_pandas_df_index["int64_col"].where( + cond=_is_positive, other=lambda x: x * 10 + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.skip( + reason="NotImplementedError: Polars compiler hasn't implemented ClipOp()" +) +@pytest.mark.parametrize( + ("ordered"), + [ + (True), + (False), + ], +) +def test_clip(scalars_df_index, scalars_pandas_df_index, ordered): + col_bf = scalars_df_index["int64_col"] + lower_bf = scalars_df_index["int64_too"] - 1 + upper_bf = scalars_df_index["int64_too"] + 1 + bf_result = col_bf.clip(lower_bf, upper_bf).to_pandas(ordered=ordered) + + col_pd = scalars_pandas_df_index["int64_col"] + lower_pd = scalars_pandas_df_index["int64_too"] - 1 + upper_pd = scalars_pandas_df_index["int64_too"] + 1 + pd_result = col_pd.clip(lower_pd, upper_pd) + + assert_series_equal(bf_result, pd_result, ignore_order=not ordered) + + +@pytest.mark.skip( + reason="NotImplementedError: Polars compiler hasn't implemented ClipOp()" +) +def test_clip_int_with_float_bounds(scalars_df_index, scalars_pandas_df_index): + col_bf = scalars_df_index["int64_too"] + bf_result = col_bf.clip(-100, 3.14151593).to_pandas() + + col_pd = scalars_pandas_df_index["int64_too"] + # pandas doesn't work with Int64 and clip with floats + pd_result = col_pd.astype("int64").clip(-100, 3.14151593).astype("Float64") + + assert_series_equal(bf_result, pd_result) + + +@pytest.mark.skip( + reason="NotImplementedError: Polars compiler hasn't implemented ClipOp()" +) +def test_clip_filtered_two_sided(scalars_df_index, scalars_pandas_df_index): + col_bf = scalars_df_index["int64_col"].iloc[::2] + lower_bf = scalars_df_index["int64_too"].iloc[2:] - 1 + upper_bf = scalars_df_index["int64_too"].iloc[:5] + 1 + bf_result = col_bf.clip(lower_bf, upper_bf).to_pandas() + + col_pd = scalars_pandas_df_index["int64_col"].iloc[::2] + lower_pd = scalars_pandas_df_index["int64_too"].iloc[2:] - 1 + upper_pd = scalars_pandas_df_index["int64_too"].iloc[:5] + 1 + pd_result = col_pd.clip(lower_pd, upper_pd) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.skip( + reason="NotImplementedError: Polars compiler hasn't implemented maximum()" +) +def test_clip_filtered_one_sided(scalars_df_index, scalars_pandas_df_index): + col_bf = scalars_df_index["int64_col"].iloc[::2] + lower_bf = scalars_df_index["int64_too"].iloc[2:] - 1 + bf_result = col_bf.clip(lower_bf, None).to_pandas() + + col_pd = scalars_pandas_df_index["int64_col"].iloc[::2] + lower_pd = scalars_pandas_df_index["int64_too"].iloc[2:] - 1 + pd_result = col_pd.clip(lower_pd, None) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_dot(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = scalars_df["int64_too"] @ scalars_df["int64_too"] + + pd_result = scalars_pandas_df["int64_too"] @ scalars_pandas_df["int64_too"] + + assert bf_result == pd_result + + +@pytest.mark.parametrize( + ("left", "right", "inclusive"), + [ + (-234892, 55555, "left"), + (-234892, 55555, "both"), + (-234892, 55555, "neither"), + (-234892, 55555, "right"), + ], +) +def test_between(scalars_df_index, scalars_pandas_df_index, left, right, inclusive): + bf_result = ( + scalars_df_index["int64_col"].between(left, right, inclusive).to_pandas() + ) + pd_result = scalars_pandas_df_index["int64_col"].between(left, right, inclusive) + + pd.testing.assert_series_equal( + bf_result, + pd_result.astype(pd.BooleanDtype()), + ) + + +@pytest.mark.skip(reason="fixture 'scalars_dfs_maybe_ordered' not found") +def test_series_case_when(scalars_dfs_maybe_ordered): + pytest.importorskip( + "pandas", + minversion="2.2.0", + reason="case_when added in pandas 2.2.0", + ) + scalars_df, scalars_pandas_df = scalars_dfs_maybe_ordered + + bf_series = scalars_df["int64_col"] + pd_series = scalars_pandas_df["int64_col"] + + # TODO(tswast): pandas case_when appears to assume True when a value is + # null. I suspect this should be considered a bug in pandas. + + # Generate 150 conditions to test case_when with a large number of conditions + bf_conditions = ( + [((bf_series > 645).fillna(True), bf_series - 1)] + + [((bf_series > (-100 + i * 5)).fillna(True), i) for i in range(148, 0, -1)] + + [((bf_series <= -100).fillna(True), pd.NA)] + ) + + pd_conditions = ( + [((pd_series > 645), pd_series - 1)] + + [((pd_series > (-100 + i * 5)), i) for i in range(148, 0, -1)] + + [(pd_series <= -100, pd.NA)] + ) + + assert len(bf_conditions) == 150 + + bf_result = bf_series.case_when(bf_conditions).to_pandas() + pd_result = pd_series.case_when(pd_conditions) + + pd.testing.assert_series_equal( + bf_result, + pd_result.astype(pd.Int64Dtype()), + ) + + +@pytest.mark.skip(reason="fixture 'scalars_dfs_maybe_ordered' not found") +def test_series_case_when_change_type(scalars_dfs_maybe_ordered): + pytest.importorskip( + "pandas", + minversion="2.2.0", + reason="case_when added in pandas 2.2.0", + ) + scalars_df, scalars_pandas_df = scalars_dfs_maybe_ordered + + bf_series = scalars_df["int64_col"] + pd_series = scalars_pandas_df["int64_col"] + + # TODO(tswast): pandas case_when appears to assume True when a value is + # null. I suspect this should be considered a bug in pandas. + + bf_conditions = [ + ((bf_series > 645).fillna(True), scalars_df["string_col"]), + ((bf_series <= -100).fillna(True), pd.NA), + (True, "not_found"), + ] + + pd_conditions = [ + ((pd_series > 645).fillna(True), scalars_pandas_df["string_col"]), + ((pd_series <= -100).fillna(True), pd.NA), + # pandas currently fails if both the condition and the value are literals. + ([True] * len(pd_series), ["not_found"] * len(pd_series)), + ] + + bf_result = bf_series.case_when(bf_conditions).to_pandas() + pd_result = pd_series.case_when(pd_conditions) + + pd.testing.assert_series_equal( + bf_result, + pd_result.astype("string[pyarrow]"), + ) + + +def test_to_frame(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["int64_col"].to_frame().to_pandas() + pd_result = scalars_pandas_df["int64_col"].to_frame() + + assert_pandas_df_equal(bf_result, pd_result) + + +def test_to_frame_no_name(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_result = scalars_df["int64_col"].rename(None).to_frame().to_pandas() + pd_result = scalars_pandas_df["int64_col"].rename(None).to_frame() + + assert_pandas_df_equal(bf_result, pd_result) + + +@pytest.mark.skip(reason="fixture 'gcs_folder' not found") +def test_to_json(gcs_folder, scalars_df_index, scalars_pandas_df_index): + path = gcs_folder + "test_series_to_json*.jsonl" + scalars_df_index["int64_col"].to_json(path, lines=True, orient="records") + gcs_df = pd.read_json(get_first_file_from_wildcard(path), lines=True) + + pd.testing.assert_series_equal( + gcs_df["int64_col"].astype(pd.Int64Dtype()), + scalars_pandas_df_index["int64_col"], + check_dtype=False, + check_index=False, + ) + + +@pytest.mark.skip(reason="fixture 'gcs_folder' not found") +def test_to_csv(gcs_folder, scalars_df_index, scalars_pandas_df_index): + path = gcs_folder + "test_series_to_csv*.csv" + scalars_df_index["int64_col"].to_csv(path) + gcs_df = pd.read_csv(get_first_file_from_wildcard(path)) + + pd.testing.assert_series_equal( + gcs_df["int64_col"].astype(pd.Int64Dtype()), + scalars_pandas_df_index["int64_col"], + check_dtype=False, + check_index=False, + ) + + +def test_to_latex(scalars_df_index, scalars_pandas_df_index): + pytest.importorskip("jinja2") + bf_result = scalars_df_index["int64_col"].to_latex() + pd_result = scalars_pandas_df_index["int64_col"].to_latex() + + assert bf_result == pd_result + + +def test_series_to_json_local_str(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.int64_col.to_json() + pd_result = scalars_pandas_df_index.int64_col.to_json() + + assert bf_result == pd_result + + +def test_series_to_json_local_file(scalars_df_index, scalars_pandas_df_index): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + with tempfile.TemporaryFile() as bf_result_file, tempfile.TemporaryFile() as pd_result_file: + scalars_df_index.int64_col.to_json(bf_result_file) + scalars_pandas_df_index.int64_col.to_json(pd_result_file) + + bf_result = bf_result_file.read() + pd_result = pd_result_file.read() + + assert bf_result == pd_result + + +def test_series_to_csv_local_str(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.int64_col.to_csv() + # default_handler for arrow types that have no default conversion + pd_result = scalars_pandas_df_index.int64_col.to_csv() + + assert bf_result == pd_result + + +def test_series_to_csv_local_file(scalars_df_index, scalars_pandas_df_index): + with tempfile.TemporaryFile() as bf_result_file, tempfile.TemporaryFile() as pd_result_file: + scalars_df_index.int64_col.to_csv(bf_result_file) + scalars_pandas_df_index.int64_col.to_csv(pd_result_file) + + bf_result = bf_result_file.read() + pd_result = pd_result_file.read() + + assert bf_result == pd_result + + +def test_to_dict(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].to_dict() + + pd_result = scalars_pandas_df_index["int64_too"].to_dict() + + assert bf_result == pd_result + + +def test_to_excel(scalars_df_index, scalars_pandas_df_index): + pytest.importorskip("openpyxl") + bf_result_file = tempfile.TemporaryFile() + pd_result_file = tempfile.TemporaryFile() + scalars_df_index["int64_too"].to_excel(bf_result_file) + scalars_pandas_df_index["int64_too"].to_excel(pd_result_file) + bf_result = bf_result_file.read() + pd_result = bf_result_file.read() + + assert bf_result == pd_result + + +def test_to_pickle(scalars_df_index, scalars_pandas_df_index): + bf_result_file = tempfile.TemporaryFile() + pd_result_file = tempfile.TemporaryFile() + scalars_df_index["int64_too"].to_pickle(bf_result_file) + scalars_pandas_df_index["int64_too"].to_pickle(pd_result_file) + bf_result = bf_result_file.read() + pd_result = bf_result_file.read() + + assert bf_result == pd_result + + +def test_to_string(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].to_string() + + pd_result = scalars_pandas_df_index["int64_too"].to_string() + + assert bf_result == pd_result + + +def test_to_list(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].to_list() + + pd_result = scalars_pandas_df_index["int64_too"].to_list() + + assert bf_result == pd_result + + +def test_to_numpy(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].to_numpy() + + pd_result = scalars_pandas_df_index["int64_too"].to_numpy() + + assert (bf_result == pd_result).all() + + +def test_to_xarray(scalars_df_index, scalars_pandas_df_index): + pytest.importorskip("xarray") + bf_result = scalars_df_index["int64_too"].to_xarray() + + pd_result = scalars_pandas_df_index["int64_too"].to_xarray() + + assert bf_result.equals(pd_result) + + +def test_to_markdown(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].to_markdown() + + pd_result = scalars_pandas_df_index["int64_too"].to_markdown() + + assert bf_result == pd_result + + +def test_series_values(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["int64_too"].values + + pd_result = scalars_pandas_df_index["int64_too"].values + # Numpy isn't equipped to compare non-numeric objects, so convert back to dataframe + pd.testing.assert_series_equal( + pd.Series(bf_result), pd.Series(pd_result), check_dtype=False + ) + + +def test_series___array__(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["float64_col"].__array__() + + pd_result = scalars_pandas_df_index["float64_col"].__array__() + # Numpy isn't equipped to compare non-numeric objects, so convert back to dataframe + numpy.array_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("ascending", "na_position"), + [ + (True, "first"), + (True, "last"), + (False, "first"), + (False, "last"), + ], +) +def test_sort_values(scalars_df_index, scalars_pandas_df_index, ascending, na_position): + # Test needs values to be unique + bf_result = ( + scalars_df_index["int64_col"] + .sort_values(ascending=ascending, na_position=na_position) + .to_pandas() + ) + pd_result = scalars_pandas_df_index["int64_col"].sort_values( + ascending=ascending, na_position=na_position + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_sort_values_inplace(scalars_df_index, scalars_pandas_df_index): + # Test needs values to be unique + bf_series = scalars_df_index["int64_col"].copy() + bf_series.sort_values(ascending=False, inplace=True) + bf_result = bf_series.to_pandas() + pd_result = scalars_pandas_df_index["int64_col"].sort_values(ascending=False) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("ascending"), + [ + (True,), + (False,), + ], +) +def test_sort_index(scalars_df_index, scalars_pandas_df_index, ascending): + bf_result = ( + scalars_df_index["int64_too"].sort_index(ascending=ascending).to_pandas() + ) + pd_result = scalars_pandas_df_index["int64_too"].sort_index(ascending=ascending) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_series_sort_index_inplace(scalars_df_index, scalars_pandas_df_index): + bf_series = scalars_df_index["int64_too"].copy() + bf_series.sort_index(ascending=False, inplace=True) + bf_result = bf_series.to_pandas() + pd_result = scalars_pandas_df_index["int64_too"].sort_index(ascending=False) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +def test_mask_default_value(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df["int64_col"] + bf_col_masked = bf_col.mask(bf_col % 2 == 1) + bf_result = bf_col.to_frame().assign(int64_col_masked=bf_col_masked).to_pandas() + + pd_col = scalars_pandas_df["int64_col"] + pd_col_masked = pd_col.mask(pd_col % 2 == 1) + pd_result = pd_col.to_frame().assign(int64_col_masked=pd_col_masked) + + assert_pandas_df_equal(bf_result, pd_result) + + +def test_mask_custom_value(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df["int64_col"] + bf_col_masked = bf_col.mask(bf_col % 2 == 1, -1) + bf_result = bf_col.to_frame().assign(int64_col_masked=bf_col_masked).to_pandas() + + pd_col = scalars_pandas_df["int64_col"] + pd_col_masked = pd_col.mask(pd_col % 2 == 1, -1) + pd_result = pd_col.to_frame().assign(int64_col_masked=pd_col_masked) + + # TODO(shobs): There is a pd.NA value in the original series, which is not + # odd so should be left as is, but it is being masked in pandas. + # Accidentally the bigframes bahavior matches, but it should be updated + # after the resolution of https://github.com/pandas-dev/pandas/issues/52955 + assert_pandas_df_equal(bf_result, pd_result) + + +def test_mask_with_callable(scalars_df_index, scalars_pandas_df_index): + def _ten_times(x): + return x * 10 + + # Both cond and other are callable. + bf_result = ( + scalars_df_index["int64_col"] + .mask(cond=lambda x: x > 0, other=_ten_times) + .to_pandas() + ) + pd_result = scalars_pandas_df_index["int64_col"].mask( + cond=lambda x: x > 0, other=_ten_times + ) + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.parametrize( + ("lambda_",), + [ + pytest.param(lambda x: x > 0), + pytest.param( + lambda x: True if x > 0 else False, + marks=pytest.mark.xfail( + raises=ValueError, + ), + ), + ], + ids=[ + "lambda_arithmatic", + "lambda_arbitrary", + ], +) +def test_mask_lambda(scalars_dfs, lambda_): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df["int64_col"] + bf_result = bf_col.mask(lambda_).to_pandas() + + pd_col = scalars_pandas_df["int64_col"] + pd_result = pd_col.mask(lambda_) + + # ignore dtype check, which are Int64 and object respectively + assert_series_equal(bf_result, pd_result, check_dtype=False) + + +def test_mask_simple_udf(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + def foo(x): + return x < 1000000 + + bf_col = scalars_df["int64_col"] + bf_result = bf_col.mask(foo).to_pandas() + + pd_col = scalars_pandas_df["int64_col"] + pd_result = pd_col.mask(foo) + + # ignore dtype check, which are Int64 and object respectively + assert_series_equal(bf_result, pd_result, check_dtype=False) + + +@pytest.mark.skip( + reason="polars.exceptions.InvalidOperationError: decimal precision should be <= 38 & >= 1" +) +@pytest.mark.parametrize("errors", ["raise", "null"]) +@pytest.mark.parametrize( + ("column", "to_type"), + [ + ("int64_col", "Float64"), + ("int64_col", "Int64"), # No-op + ("int64_col", pd.Float64Dtype()), + ("int64_col", "string[pyarrow]"), + ("int64_col", "boolean"), + ("int64_col", pd.ArrowDtype(pa.decimal128(38, 9))), + ("int64_col", pd.ArrowDtype(pa.decimal256(76, 38))), + ("int64_col", pd.ArrowDtype(pa.timestamp("us"))), + ("int64_col", pd.ArrowDtype(pa.timestamp("us", tz="UTC"))), + ("int64_col", "time64[us][pyarrow]"), + ("int64_col", pd.ArrowDtype(db_dtypes.JSONArrowType())), + ("bool_col", "Int64"), + ("bool_col", "string[pyarrow]"), + ("bool_col", "Float64"), + ("bool_col", pd.ArrowDtype(db_dtypes.JSONArrowType())), + ("string_col", "binary[pyarrow]"), + ("bytes_col", "string[pyarrow]"), + # pandas actually doesn't let folks convert to/from naive timestamp and + # raises a deprecation warning to use tz_localize/tz_convert instead, + # but BigQuery always stores values as UTC and doesn't have to deal + # with timezone conversions, so we'll allow it. + ("timestamp_col", "date32[day][pyarrow]"), + ("timestamp_col", "time64[us][pyarrow]"), + ("timestamp_col", pd.ArrowDtype(pa.timestamp("us"))), + ("datetime_col", "date32[day][pyarrow]"), + pytest.param( + "datetime_col", + "string[pyarrow]", + marks=pytest.mark.skipif( + pd.__version__.startswith("2.2"), + reason="pandas 2.2 uses T as date/time separator whereas earlier versions use space", + ), + ), + ("datetime_col", "time64[us][pyarrow]"), + ("datetime_col", pd.ArrowDtype(pa.timestamp("us", tz="UTC"))), + ("date_col", "string[pyarrow]"), + ("date_col", pd.ArrowDtype(pa.timestamp("us"))), + ("date_col", pd.ArrowDtype(pa.timestamp("us", tz="UTC"))), + ("time_col", "string[pyarrow]"), + # TODO(bmil): fix Ibis bug: BigQuery backend rounds to nearest int + # ("float64_col", "Int64"), + # TODO(bmil): decide whether to fix Ibis bug: BigQuery backend + # formats floats with no decimal places if they have no fractional + # part, and does not switch to scientific notation for > 10^15 + # ("float64_col", "string[pyarrow]") + # TODO(bmil): add any other compatible conversions per + # https://cloud.google.com/bigquery/docs/reference/standard-sql/conversion_functions + ], +) +def test_astype(scalars_df_index, scalars_pandas_df_index, column, to_type, errors): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + bf_result = scalars_df_index[column].astype(to_type, errors=errors).to_pandas() + pd_result = scalars_pandas_df_index[column].astype(to_type) + pd.testing.assert_series_equal(bf_result, pd_result) + + +@pytest.mark.skip( + reason="AttributeError: 'DataFrame' object has no attribute 'dtype'. Did you mean: 'dtypes'?" +) +def test_series_astype_python(session): + input = pd.Series(["hello", "world", "3.11", "4000"]) + exepcted = pd.Series( + [None, None, 3.11, 4000], + dtype="Float64", + index=pd.Index([0, 1, 2, 3], dtype="Int64"), + ) + result = session.read_pandas(input).astype(float, errors="null").to_pandas() + pd.testing.assert_series_equal(result, exepcted) + + +@pytest.mark.skip( + reason="AttributeError: 'DataFrame' object has no attribute 'dtype'. Did you mean: 'dtypes'?" +) +def test_astype_safe(session): + input = pd.Series(["hello", "world", "3.11", "4000"]) + exepcted = pd.Series( + [None, None, 3.11, 4000], + dtype="Float64", + index=pd.Index([0, 1, 2, 3], dtype="Int64"), + ) + result = session.read_pandas(input).astype("Float64", errors="null").to_pandas() + pd.testing.assert_series_equal(result, exepcted) + + +def test_series_astype_w_invalid_error(session): + input = pd.Series(["hello", "world", "3.11", "4000"]) + with pytest.raises(ValueError): + session.read_pandas(input).astype("Float64", errors="bad_value") + + +@pytest.mark.parametrize( + ("column", "to_type"), + [ + ("timestamp_col", "int64[pyarrow]"), + ("datetime_col", "int64[pyarrow]"), + ("time_col", "int64[pyarrow]"), + ], +) +def test_date_time_astype_int( + scalars_df_index, scalars_pandas_df_index, column, to_type +): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + bf_result = scalars_df_index[column].astype(to_type).to_pandas() + pd_result = scalars_pandas_df_index[column].astype(to_type) + pd.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + assert bf_result.dtype == "Int64" + + +@pytest.mark.skip( + reason="polars.exceptions.InvalidOperationError: conversion from `str` to `i64` failed in column 'column_0' for 1 out of 4 values: [' -03']" +) +def test_string_astype_int(): + pd_series = pd.Series(["4", "-7", "0", " -03"]) + bf_series = series.Series(pd_series) + + pd_result = pd_series.astype("Int64") + bf_result = bf_series.astype("Int64").to_pandas() + + pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) + + +@pytest.mark.skip( + reason="polars.exceptions.InvalidOperationError: conversion from `str` to `f64` failed in column 'column_0' for 1 out of 10 values: [' -03.235']" +) +def test_string_astype_float(): + pd_series = pd.Series( + ["1", "-1", "-0", "000", " -03.235", "naN", "-inf", "INf", ".33", "7.235e-8"] + ) + + bf_series = series.Series(pd_series) + + pd_result = pd_series.astype("Float64") + bf_result = bf_series.astype("Float64").to_pandas() + + pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) + + +def test_string_astype_date(): + if int(pa.__version__.split(".")[0]) < 15: + pytest.skip( + "Avoid pyarrow.lib.ArrowNotImplementedError: " + "Unsupported cast from string to date32 using function cast_date32." + ) + + pd_series = pd.Series(["2014-08-15", "2215-08-15", "2016-02-29"]).astype( + pd.ArrowDtype(pa.string()) + ) + + bf_series = series.Series(pd_series) + + # TODO(b/340885567): fix type error + pd_result = pd_series.astype("date32[day][pyarrow]") # type: ignore + bf_result = bf_series.astype("date32[day][pyarrow]").to_pandas() + + pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) + + +def test_string_astype_datetime(): + pd_series = pd.Series( + ["2014-08-15 08:15:12", "2015-08-15 08:15:12.654754", "2016-02-29 00:00:00"] + ).astype(pd.ArrowDtype(pa.string())) + + bf_series = series.Series(pd_series) + + pd_result = pd_series.astype(pd.ArrowDtype(pa.timestamp("us"))) + bf_result = bf_series.astype(pd.ArrowDtype(pa.timestamp("us"))).to_pandas() + + pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) + + +def test_string_astype_timestamp(): + pd_series = pd.Series( + [ + "2014-08-15 08:15:12+00:00", + "2015-08-15 08:15:12.654754+05:00", + "2016-02-29 00:00:00+08:00", + ] + ).astype(pd.ArrowDtype(pa.string())) + + bf_series = series.Series(pd_series) + + pd_result = pd_series.astype(pd.ArrowDtype(pa.timestamp("us", tz="UTC"))) + bf_result = bf_series.astype( + pd.ArrowDtype(pa.timestamp("us", tz="UTC")) + ).to_pandas() + + pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) + + +@pytest.mark.skip(reason="AssertionError: Series are different") +def test_timestamp_astype_string(): + bf_series = series.Series( + [ + "2014-08-15 08:15:12+00:00", + "2015-08-15 08:15:12.654754+05:00", + "2016-02-29 00:00:00+08:00", + ] + ).astype(pd.ArrowDtype(pa.timestamp("us", tz="UTC"))) + + expected_result = pd.Series( + [ + "2014-08-15 08:15:12+00", + "2015-08-15 03:15:12.654754+00", + "2016-02-28 16:00:00+00", + ] + ) + bf_result = bf_series.astype(pa.string()).to_pandas() + + pd.testing.assert_series_equal( + bf_result, expected_result, check_index_type=False, check_dtype=False + ) + assert bf_result.dtype == "string[pyarrow]" + + +@pytest.mark.skip(reason="AssertionError: Series are different") +@pytest.mark.parametrize("errors", ["raise", "null"]) +def test_float_astype_json(errors): + data = ["1.25", "2500000000", None, "-12323.24"] + bf_series = series.Series(data, dtype=dtypes.FLOAT_DTYPE) + + bf_result = bf_series.astype(dtypes.JSON_DTYPE, errors=errors) + assert bf_result.dtype == dtypes.JSON_DTYPE + + expected_result = pd.Series(data, dtype=dtypes.JSON_DTYPE) + expected_result.index = expected_result.index.astype("Int64") + pd.testing.assert_series_equal(bf_result.to_pandas(), expected_result) + + +@pytest.mark.skip(reason="AssertionError: Series are different") +def test_float_astype_json_str(): + data = ["1.25", "2500000000", None, "-12323.24"] + bf_series = series.Series(data, dtype=dtypes.FLOAT_DTYPE) + + bf_result = bf_series.astype("json") + assert bf_result.dtype == dtypes.JSON_DTYPE + + expected_result = pd.Series(data, dtype=dtypes.JSON_DTYPE) + expected_result.index = expected_result.index.astype("Int64") + pd.testing.assert_series_equal(bf_result.to_pandas(), expected_result) + + +@pytest.mark.parametrize("errors", ["raise", "null"]) +def test_string_astype_json(errors): + data = [ + "1", + None, + '["1","3","5"]', + '{"a":1,"b":["x","y"],"c":{"x":[],"z":false}}', + ] + bf_series = series.Series(data, dtype=dtypes.STRING_DTYPE) + + bf_result = bf_series.astype(dtypes.JSON_DTYPE, errors=errors) + assert bf_result.dtype == dtypes.JSON_DTYPE + + pd_result = bf_series.to_pandas().astype(dtypes.JSON_DTYPE) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) + + +@pytest.mark.skip(reason="AssertionError: Series NA mask are different") +def test_string_astype_json_in_safe_mode(): + data = ["this is not a valid json string"] + bf_series = series.Series(data, dtype=dtypes.STRING_DTYPE) + bf_result = bf_series.astype(dtypes.JSON_DTYPE, errors="null") + assert bf_result.dtype == dtypes.JSON_DTYPE + + expected = pd.Series([None], dtype=dtypes.JSON_DTYPE) + expected.index = expected.index.astype("Int64") + pd.testing.assert_series_equal(bf_result.to_pandas(), expected) + + +@pytest.mark.skip( + reason="Failed: DID NOT RAISE " +) +def test_string_astype_json_raise_error(): + data = ["this is not a valid json string"] + bf_series = series.Series(data, dtype=dtypes.STRING_DTYPE) + with pytest.raises( + google.api_core.exceptions.BadRequest, + match="syntax error while parsing value", + ): + bf_series.astype(dtypes.JSON_DTYPE, errors="raise").to_pandas() + + +@pytest.mark.parametrize("errors", ["raise", "null"]) +@pytest.mark.parametrize( + ("data", "to_type"), + [ + pytest.param(["1", "10.0", None], dtypes.INT_DTYPE, id="to_int"), + pytest.param(["0.0001", "2500000000", None], dtypes.FLOAT_DTYPE, id="to_float"), + pytest.param(["true", "false", None], dtypes.BOOL_DTYPE, id="to_bool"), + pytest.param(['"str"', None], dtypes.STRING_DTYPE, id="to_string"), + pytest.param( + ['"str"', None], + dtypes.TIME_DTYPE, + id="invalid", + marks=pytest.mark.xfail(raises=TypeError), + ), + ], +) +def test_json_astype_others(data, to_type, errors): + bf_series = series.Series(data, dtype=dtypes.JSON_DTYPE) + + bf_result = bf_series.astype(to_type, errors=errors) + assert bf_result.dtype == to_type + + load_data = [json.loads(item) if item is not None else None for item in data] + expected = pd.Series(load_data, dtype=to_type) + expected.index = expected.index.astype("Int64") + pd.testing.assert_series_equal(bf_result.to_pandas(), expected) + + +@pytest.mark.skip( + reason="Failed: DID NOT RAISE " +) +@pytest.mark.parametrize( + ("data", "to_type"), + [ + pytest.param(["10.2", None], dtypes.INT_DTYPE, id="to_int"), + pytest.param(["false", None], dtypes.FLOAT_DTYPE, id="to_float"), + pytest.param(["10.2", None], dtypes.BOOL_DTYPE, id="to_bool"), + pytest.param(["true", None], dtypes.STRING_DTYPE, id="to_string"), + ], +) +def test_json_astype_others_raise_error(data, to_type): + bf_series = series.Series(data, dtype=dtypes.JSON_DTYPE) + with pytest.raises(google.api_core.exceptions.BadRequest): + bf_series.astype(to_type, errors="raise").to_pandas() + + +@pytest.mark.skip(reason="AssertionError: Series NA mask are different") +@pytest.mark.parametrize( + ("data", "to_type"), + [ + pytest.param(["10.2", None], dtypes.INT_DTYPE, id="to_int"), + pytest.param(["false", None], dtypes.FLOAT_DTYPE, id="to_float"), + pytest.param(["10.2", None], dtypes.BOOL_DTYPE, id="to_bool"), + pytest.param(["true", None], dtypes.STRING_DTYPE, id="to_string"), + ], +) +def test_json_astype_others_in_safe_mode(data, to_type): + bf_series = series.Series(data, dtype=dtypes.JSON_DTYPE) + bf_result = bf_series.astype(to_type, errors="null") + assert bf_result.dtype == to_type + + expected = pd.Series([None, None], dtype=to_type) + expected.index = expected.index.astype("Int64") + pd.testing.assert_series_equal(bf_result.to_pandas(), expected) + + +@pytest.mark.parametrize( + "index", + [0, 5, -2], +) +def test_iloc_single_integer(scalars_df_index, scalars_pandas_df_index, index): + bf_result = scalars_df_index.string_col.iloc[index] + pd_result = scalars_pandas_df_index.string_col.iloc[index] + + assert bf_result == pd_result + + +def test_iloc_single_integer_out_of_bound_error(scalars_df_index): + with pytest.raises(IndexError, match="single positional indexer is out-of-bounds"): + scalars_df_index.string_col.iloc[99] + + +def test_loc_bool_series_explicit_index(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.string_col.loc[scalars_df_index.bool_col].to_pandas() + pd_result = scalars_pandas_df_index.string_col.loc[scalars_pandas_df_index.bool_col] + + pd.testing.assert_series_equal( + bf_result, + pd_result, + ) + + +@pytest.mark.skip(reason="fixture 'scalars_pandas_df_default_index' not found") +def test_loc_bool_series_default_index( + scalars_df_default_index, scalars_pandas_df_default_index +): + bf_result = scalars_df_default_index.string_col.loc[ + scalars_df_default_index.bool_col + ].to_pandas() + pd_result = scalars_pandas_df_default_index.string_col.loc[ + scalars_pandas_df_default_index.bool_col + ] + + assert_pandas_df_equal( + bf_result.to_frame(), + pd_result.to_frame(), + ) + + +def test_argmin(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.string_col.argmin() + pd_result = scalars_pandas_df_index.string_col.argmin() + assert bf_result == pd_result + + +def test_argmax(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.int64_too.argmax() + pd_result = scalars_pandas_df_index.int64_too.argmax() + assert bf_result == pd_result + + +def test_series_idxmin(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.string_col.idxmin() + pd_result = scalars_pandas_df_index.string_col.idxmin() + assert bf_result == pd_result + + +def test_series_idxmax(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.int64_too.idxmax() + pd_result = scalars_pandas_df_index.int64_too.idxmax() + assert bf_result == pd_result + + +def test_getattr_attribute_error_when_pandas_has(scalars_df_index): + # asof is implemented in pandas but not in bigframes + with pytest.raises(AttributeError): + scalars_df_index.string_col.asof() + + +def test_getattr_attribute_error(scalars_df_index): + with pytest.raises(AttributeError): + scalars_df_index.string_col.not_a_method() + + +def test_rename(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.string_col.rename("newname") + pd_result = scalars_pandas_df_index.string_col.rename("newname") + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_rename_nonstring(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.string_col.rename((4, 2)) + pd_result = scalars_pandas_df_index.string_col.rename((4, 2)) + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_rename_dict_same_type(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.string_col.rename({1: 100, 2: 200}) + pd_result = scalars_pandas_df_index.string_col.rename({1: 100, 2: 200}) + + pd_result.index = pd_result.index.astype("Int64") + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_rename_axis(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index.string_col.rename_axis("newindexname") + pd_result = scalars_pandas_df_index.string_col.rename_axis("newindexname") + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_list_string_index(scalars_df_index, scalars_pandas_df_index): + index_list = scalars_pandas_df_index.string_col.iloc[[0, 1, 1, 5]].values + + scalars_df_index = scalars_df_index.set_index("string_col", drop=False) + scalars_pandas_df_index = scalars_pandas_df_index.set_index( + "string_col", drop=False + ) + + bf_result = scalars_df_index.string_col.loc[index_list] + pd_result = scalars_pandas_df_index.string_col.loc[index_list] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_list_integer_index(scalars_df_index, scalars_pandas_df_index): + index_list = [3, 2, 1, 3, 2, 1] + + bf_result = scalars_df_index.bool_col.loc[index_list] + pd_result = scalars_pandas_df_index.bool_col.loc[index_list] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_list_multiindex(scalars_df_index, scalars_pandas_df_index): + scalars_df_multiindex = scalars_df_index.set_index(["string_col", "int64_col"]) + scalars_pandas_df_multiindex = scalars_pandas_df_index.set_index( + ["string_col", "int64_col"] + ) + index_list = [("Hello, World!", -234892), ("Hello, World!", 123456789)] + + bf_result = scalars_df_multiindex.int64_too.loc[index_list] + pd_result = scalars_pandas_df_multiindex.int64_too.loc[index_list] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_iloc_list(scalars_df_index, scalars_pandas_df_index): + index_list = [0, 0, 0, 5, 4, 7] + + bf_result = scalars_df_index.string_col.iloc[index_list] + pd_result = scalars_pandas_df_index.string_col.iloc[index_list] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_iloc_list_nameless(scalars_df_index, scalars_pandas_df_index): + index_list = [0, 0, 0, 5, 4, 7] + + bf_series = scalars_df_index.string_col.rename(None) + bf_result = bf_series.iloc[index_list] + pd_series = scalars_pandas_df_index.string_col.rename(None) + pd_result = pd_series.iloc[index_list] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_list_nameless(scalars_df_index, scalars_pandas_df_index): + index_list = [0, 0, 0, 5, 4, 7] + + bf_series = scalars_df_index.string_col.rename(None) + bf_result = bf_series.loc[index_list] + + pd_series = scalars_pandas_df_index.string_col.rename(None) + pd_result = pd_series.loc[index_list] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_bf_series_string_index(scalars_df_index, scalars_pandas_df_index): + pd_string_series = scalars_pandas_df_index.string_col.iloc[[0, 5, 1, 1, 5]] + bf_string_series = scalars_df_index.string_col.iloc[[0, 5, 1, 1, 5]] + + scalars_df_index = scalars_df_index.set_index("string_col") + scalars_pandas_df_index = scalars_pandas_df_index.set_index("string_col") + + bf_result = scalars_df_index.date_col.loc[bf_string_series] + pd_result = scalars_pandas_df_index.date_col.loc[pd_string_series] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_bf_series_multiindex(scalars_df_index, scalars_pandas_df_index): + pd_string_series = scalars_pandas_df_index.string_col.iloc[[0, 5, 1, 1, 5]] + bf_string_series = scalars_df_index.string_col.iloc[[0, 5, 1, 1, 5]] + + scalars_df_multiindex = scalars_df_index.set_index(["string_col", "int64_col"]) + scalars_pandas_df_multiindex = scalars_pandas_df_index.set_index( + ["string_col", "int64_col"] + ) + + bf_result = scalars_df_multiindex.int64_too.loc[bf_string_series] + pd_result = scalars_pandas_df_multiindex.int64_too.loc[pd_string_series] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_bf_index_integer_index(scalars_df_index, scalars_pandas_df_index): + pd_index = scalars_pandas_df_index.iloc[[0, 5, 1, 1, 5]].index + bf_index = scalars_df_index.iloc[[0, 5, 1, 1, 5]].index + + bf_result = scalars_df_index.date_col.loc[bf_index] + pd_result = scalars_pandas_df_index.date_col.loc[pd_index] + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_single_index_with_duplicate(scalars_df_index, scalars_pandas_df_index): + scalars_df_index = scalars_df_index.set_index("string_col", drop=False) + scalars_pandas_df_index = scalars_pandas_df_index.set_index( + "string_col", drop=False + ) + index = "Hello, World!" + bf_result = scalars_df_index.date_col.loc[index] + pd_result = scalars_pandas_df_index.date_col.loc[index] + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_loc_single_index_no_duplicate(scalars_df_index, scalars_pandas_df_index): + scalars_df_index = scalars_df_index.set_index("int64_too", drop=False) + scalars_pandas_df_index = scalars_pandas_df_index.set_index("int64_too", drop=False) + index = -2345 + bf_result = scalars_df_index.date_col.loc[index] + pd_result = scalars_pandas_df_index.date_col.loc[index] + assert bf_result == pd_result + + +def test_series_bool_interpretation_error(scalars_df_index): + with pytest.raises(ValueError): + True if scalars_df_index["string_col"] else False + + +@pytest.mark.skip( + reason="NotImplementedError: dry_run not implemented for this executor" +) +def test_query_job_setters(scalars_dfs): + # if allow_large_results=False, might not create query job + with bigframes.option_context("compute.allow_large_results", True): + job_ids = set() + df, _ = scalars_dfs + series = df["int64_col"] + assert series.query_job is not None + repr(series) + job_ids.add(series.query_job.job_id) + series.to_pandas() + job_ids.add(series.query_job.job_id) + assert len(job_ids) == 2 + + +@pytest.mark.parametrize( + ("series_input",), + [ + ([1, 2, 3, 4, 5],), + ([1, 1, 3, 5, 5],), + ([1, pd.NA, 4, 5, 5],), + ([1, 3, 2, 5, 4],), + ([pd.NA, pd.NA],), + ([1, 1, 1, 1, 1],), + ], +) +def test_is_monotonic_increasing(series_input): + scalars_df = series.Series(series_input, dtype=pd.Int64Dtype()) + scalars_pandas_df = pd.Series(series_input, dtype=pd.Int64Dtype()) + assert ( + scalars_df.is_monotonic_increasing == scalars_pandas_df.is_monotonic_increasing + ) + + +@pytest.mark.parametrize( + ("series_input",), + [ + ([1],), + ([5, 4, 3, 2, 1],), + ([5, 5, 3, 1, 1],), + ([1, pd.NA, 4, 5, 5],), + ([5, pd.NA, 4, 2, 1],), + ([1, 1, 1, 1, 1],), + ], +) +def test_is_monotonic_decreasing(series_input): + scalars_df = series.Series(series_input) + scalars_pandas_df = pd.Series(series_input) + assert ( + scalars_df.is_monotonic_decreasing == scalars_pandas_df.is_monotonic_decreasing + ) + + +def test_map_dict_input(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + local_map = dict() + # construct a local map, incomplete to cover behavior + for s in scalars_pandas_df.string_col[:-3]: + if isinstance(s, str): + local_map[s] = ord(s[0]) + + pd_result = scalars_pandas_df.string_col.map(local_map) + pd_result = pd_result.astype("Int64") # pandas type differences + bf_result = scalars_df.string_col.map(local_map) + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_map_series_input(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + new_index = scalars_pandas_df.int64_too.drop_duplicates() + pd_map_series = scalars_pandas_df.string_col.iloc[0 : len(new_index)] + pd_map_series.index = new_index + bf_map_series = series.Series( + pd_map_series, session=scalars_df._get_block().expr.session + ) + + pd_result = scalars_pandas_df.int64_too.map(pd_map_series) + bf_result = scalars_df.int64_too.map(bf_map_series) + + pd.testing.assert_series_equal( + bf_result.to_pandas(), + pd_result, + ) + + +def test_map_series_input_duplicates_error(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + new_index = scalars_pandas_df.int64_too + pd_map_series = scalars_pandas_df.string_col.iloc[0 : len(new_index)] + pd_map_series.index = new_index + bf_map_series = series.Series( + pd_map_series, session=scalars_df._get_block().expr.session + ) + + with pytest.raises(pd.errors.InvalidIndexError): + scalars_pandas_df.int64_too.map(pd_map_series) + with pytest.raises(pd.errors.InvalidIndexError): + scalars_df.int64_too.map(bf_map_series, verify_integrity=True) + + +@pytest.mark.skip( + reason="NotImplementedError: Polars compiler hasn't implemented hash()" +) +@pytest.mark.parametrize( + ("frac", "n", "random_state"), + [ + (None, 4, None), + (0.5, None, None), + (None, 4, 10), + (0.5, None, 10), + (None, None, None), + ], + ids=[ + "n_wo_random_state", + "frac_wo_random_state", + "n_w_random_state", + "frac_w_random_state", + "n_default", + ], +) +def test_sample(scalars_dfs, frac, n, random_state): + scalars_df, _ = scalars_dfs + df = scalars_df.int64_col.sample(frac=frac, n=n, random_state=random_state) + bf_result = df.to_pandas() + + n = 1 if n is None else n + expected_sample_size = round(frac * scalars_df.shape[0]) if frac is not None else n + assert bf_result.shape[0] == expected_sample_size + + +def test_series_iter( + scalars_df_index, + scalars_pandas_df_index, +): + for bf_i, pd_i in zip( + scalars_df_index["int64_too"], scalars_pandas_df_index["int64_too"] + ): + assert bf_i == pd_i + + +@pytest.mark.parametrize( + ( + "col", + "lambda_", + ), + [ + pytest.param("int64_col", lambda x: x * x + x + 1), + pytest.param("int64_col", lambda x: x % 2 == 1), + pytest.param("string_col", lambda x: x + "_suffix"), + ], + ids=[ + "lambda_int_int", + "lambda_int_bool", + "lambda_str_str", + ], +) +def test_apply_lambda(scalars_dfs, col, lambda_): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df[col] + + # Can't be applied to BigFrames Series without by_row=False + with pytest.raises(ValueError, match="by_row=False"): + bf_col.apply(lambda_) + + bf_result = bf_col.apply(lambda_, by_row=False).to_pandas() + + pd_col = scalars_pandas_df[col] + if pd.__version__[:3] in ("2.2", "2.3"): + pd_result = pd_col.apply(lambda_, by_row=False) + else: + pd_result = pd_col.apply(lambda_) + + # ignore dtype check, which are Int64 and object respectively + # Some columns implicitly convert to floating point. Use check_exact=False to ensure we're "close enough" + assert_series_equal( + bf_result, pd_result, check_dtype=False, check_exact=False, rtol=0.001 + ) + + +@pytest.mark.skip( + reason="NotImplementedError: Polars compiler hasn't implemented log()" +) +@pytest.mark.parametrize( + ("ufunc",), + [ + pytest.param(numpy.log), + pytest.param(numpy.sqrt), + pytest.param(numpy.sin), + ], + ids=[ + "log", + "sqrt", + "sin", + ], +) +def test_apply_numpy_ufunc(scalars_dfs, ufunc): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df["int64_col"] + + # Can't be applied to BigFrames Series without by_row=False + with pytest.raises(ValueError, match="by_row=False"): + bf_col.apply(ufunc) + + bf_result = bf_col.apply(ufunc, by_row=False).to_pandas() + + pd_col = scalars_pandas_df["int64_col"] + pd_result = pd_col.apply(ufunc) + + assert_series_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("ufunc",), + [ + pytest.param(numpy.add), + pytest.param(numpy.divide), + ], + ids=[ + "add", + "divide", + ], +) +def test_combine_series_ufunc(scalars_dfs, ufunc): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df["int64_col"].dropna() + bf_result = bf_col.combine(bf_col, ufunc).to_pandas() + + pd_col = scalars_pandas_df["int64_col"].dropna() + pd_result = pd_col.combine(pd_col, ufunc) + + assert_series_equal(bf_result, pd_result, check_dtype=False) + + +def test_combine_scalar_ufunc(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df["int64_col"].dropna() + bf_result = bf_col.combine(2.5, numpy.add).to_pandas() + + pd_col = scalars_pandas_df["int64_col"].dropna() + pd_result = pd_col.combine(2.5, numpy.add) + + assert_series_equal(bf_result, pd_result, check_dtype=False) + + +def test_apply_simple_udf(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + def foo(x): + return x * x + 2 * x + 3 + + bf_col = scalars_df["int64_col"] + + # Can't be applied to BigFrames Series without by_row=False + with pytest.raises(ValueError, match="by_row=False"): + bf_col.apply(foo) + + bf_result = bf_col.apply(foo, by_row=False).to_pandas() + + pd_col = scalars_pandas_df["int64_col"] + + if pd.__version__[:3] in ("2.2", "2.3"): + pd_result = pd_col.apply(foo, by_row=False) + else: + pd_result = pd_col.apply(foo) + + # ignore dtype check, which are Int64 and object respectively + # Some columns implicitly convert to floating point. Use check_exact=False to ensure we're "close enough" + assert_series_equal( + bf_result, pd_result, check_dtype=False, check_exact=False, rtol=0.001 + ) + + +@pytest.mark.parametrize( + ("col", "lambda_", "exception"), + [ + pytest.param("int64_col", {1: 2, 3: 4}, ValueError), + pytest.param("int64_col", numpy.square, TypeError), + pytest.param("string_col", lambda x: x.capitalize(), AttributeError), + ], + ids=[ + "not_callable", + "numpy_ufunc", + "custom_lambda", + ], +) +def test_apply_not_supported(scalars_dfs, col, lambda_, exception): + scalars_df, _ = scalars_dfs + + bf_col = scalars_df[col] + with pytest.raises(exception): + bf_col.apply(lambda_, by_row=False) + + +def test_series_pipe( + scalars_df_index, + scalars_pandas_df_index, +): + column = "int64_too" + + def foo(x: int, y: int, df): + return (df + x) % y + + bf_result = ( + scalars_df_index[column] + .pipe((foo, "df"), x=7, y=9) + .pipe(lambda x: x**2) + .to_pandas() + ) + + pd_result = ( + scalars_pandas_df_index[column] + .pipe((foo, "df"), x=7, y=9) + .pipe(lambda x: x**2) + ) + + assert_series_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("data"), + [ + pytest.param([1, 2, 3], id="int"), + pytest.param([[1, 2, 3], [], numpy.nan, [3, 4]], id="int_array"), + pytest.param( + [["A", "AA", "AAA"], ["BB", "B"], numpy.nan, [], ["C"]], id="string_array" + ), + pytest.param( + [ + {"A": {"x": 1.0}, "B": "b"}, + {"A": {"y": 2.0}, "B": "bb"}, + {"A": {"z": 4.0}}, + {}, + numpy.nan, + ], + id="struct_array", + ), + ], +) +def test_series_explode(data): + s = bigframes.pandas.Series(data) + pd_s = s.to_pandas() + pd.testing.assert_series_equal( + s.explode().to_pandas(), + pd_s.explode(), + check_index_type=False, + check_dtype=False, + ) + + +@pytest.mark.parametrize( + ("index", "ignore_index"), + [ + pytest.param(None, True, id="default_index"), + pytest.param(None, False, id="ignore_default_index"), + pytest.param([5, 1, 3, 2], True, id="unordered_index"), + pytest.param([5, 1, 3, 2], False, id="ignore_unordered_index"), + pytest.param(["z", "x", "a", "b"], True, id="str_index"), + pytest.param(["z", "x", "a", "b"], False, id="ignore_str_index"), + pytest.param( + pd.Index(["z", "x", "a", "b"], name="idx"), True, id="str_named_index" + ), + pytest.param( + pd.Index(["z", "x", "a", "b"], name="idx"), + False, + id="ignore_str_named_index", + ), + pytest.param( + pd.MultiIndex.from_frame( + pd.DataFrame({"idx0": [5, 1, 3, 2], "idx1": ["z", "x", "a", "b"]}) + ), + True, + id="multi_index", + ), + pytest.param( + pd.MultiIndex.from_frame( + pd.DataFrame({"idx0": [5, 1, 3, 2], "idx1": ["z", "x", "a", "b"]}) + ), + False, + id="ignore_multi_index", + ), + ], +) +def test_series_explode_w_index(index, ignore_index): + data = [[], [200.0, 23.12], [4.5, -9.0], [1.0]] + s = bigframes.pandas.Series(data, index=index) + pd_s = pd.Series(data, index=index) + # TODO(b/340885567): fix type error + pd.testing.assert_series_equal( + s.explode(ignore_index=ignore_index).to_pandas(), # type: ignore + pd_s.explode(ignore_index=ignore_index).astype(pd.Float64Dtype()), # type: ignore + check_index_type=False, + ) + + +@pytest.mark.parametrize( + ("ignore_index", "ordered"), + [ + pytest.param(True, True, id="include_index_ordered"), + pytest.param(True, False, id="include_index_unordered"), + pytest.param(False, True, id="ignore_index_ordered"), + ], +) +def test_series_explode_reserve_order(ignore_index, ordered): + data = [numpy.random.randint(0, 10, 10) for _ in range(10)] + s = bigframes.pandas.Series(data) + pd_s = pd.Series(data) + + # TODO(b/340885567): fix type error + res = s.explode(ignore_index=ignore_index).to_pandas(ordered=ordered) # type: ignore + # TODO(b/340885567): fix type error + pd_res = pd_s.explode(ignore_index=ignore_index).astype(pd.Int64Dtype()) # type: ignore + pd_res.index = pd_res.index.astype(pd.Int64Dtype()) + pd.testing.assert_series_equal( + res if ordered else res.sort_index(), + pd_res, + ) + + +def test_series_explode_w_aggregate(): + data = [[1, 2, 3], [], numpy.nan, [3, 4]] + s = bigframes.pandas.Series(data) + pd_s = pd.Series(data) + assert s.explode().sum() == pd_s.explode().sum() + + +def test_series_construct_empty_array(): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + s = bigframes.pandas.Series([[]]) + expected = pd.Series( + [[]], + dtype=pd.ArrowDtype(pa.list_(pa.float64())), + index=pd.Index([0], dtype=pd.Int64Dtype()), + ) + pd.testing.assert_series_equal( + expected, + s.to_pandas(), + ) + + +@pytest.mark.parametrize( + ("data"), + [ + pytest.param(numpy.nan, id="null"), + pytest.param([numpy.nan], id="null_array"), + pytest.param([[]], id="empty_array"), + pytest.param([numpy.nan, []], id="null_and_empty_array"), + ], +) +def test_series_explode_null(data): + s = bigframes.pandas.Series(data) + pd.testing.assert_series_equal( + s.explode().to_pandas(), + s.to_pandas().explode(), + check_dtype=False, + ) + + +@pytest.mark.skip( + reason="NotImplementedError: Polars compiler hasn't implemented IntegerLabelToDatetimeOp(freq=<75 * Days>, label=None, origin='start_day')" +) +@pytest.mark.parametrize( + ("append", "level", "col", "rule"), + [ + pytest.param(False, None, "timestamp_col", "75D"), + pytest.param(True, 1, "timestamp_col", "25W"), + pytest.param(False, None, "datetime_col", "3ME"), + pytest.param(True, "timestamp_col", "timestamp_col", "1YE"), + ], +) +def test__resample(scalars_df_index, scalars_pandas_df_index, append, level, col, rule): + # TODO: supply a reason why this isn't compatible with pandas 1.x + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df_index = scalars_df_index.set_index(col, append=append)["int64_col"] + scalars_pandas_df_index = scalars_pandas_df_index.set_index(col, append=append)[ + "int64_col" + ] + bf_result = scalars_df_index._resample(rule=rule, level=level).min().to_pandas() + pd_result = scalars_pandas_df_index.resample(rule=rule, level=level).min() + pd.testing.assert_series_equal(bf_result, pd_result) + + +@pytest.mark.skip(reason="fixture 'nested_structs_df' not found") +def test_series_struct_get_field_by_attribute( + nested_structs_df, nested_structs_pandas_df +): + if Version(pd.__version__) < Version("2.2.0"): + pytest.skip("struct accessor is not supported before pandas 2.2") + + bf_series = nested_structs_df["person"] + df_series = nested_structs_pandas_df["person"] + + pd.testing.assert_series_equal( + bf_series.address.city.to_pandas(), + df_series.struct.field("address").struct.field("city"), + check_dtype=False, + check_index=False, + ) + pd.testing.assert_series_equal( + bf_series.address.country.to_pandas(), + df_series.struct.field("address").struct.field("country"), + check_dtype=False, + check_index=False, + ) + + +@pytest.mark.skip(reason="fixture 'nested_structs_df' not found") +def test_series_struct_fields_in_dir(nested_structs_df): + series = nested_structs_df["person"] + + assert "age" in dir(series) + assert "address" in dir(series) + assert "city" in dir(series.address) + assert "country" in dir(series.address) + + +@pytest.mark.skip(reason="fixture 'nested_structs_df' not found") +def test_series_struct_class_attributes_shadow_struct_fields(nested_structs_df): + series = nested_structs_df["person"] + + assert series.name == "person" + + +@pytest.mark.skip( + reason="NotImplementedError: dry_run not implemented for this executor" +) +def test_series_to_pandas_dry_run(scalars_df_index): + bf_series = scalars_df_index["int64_col"] + + result = bf_series.to_pandas(dry_run=True) + + assert isinstance(result, pd.Series) + assert len(result) > 0 + + +def test_series_item(session): + # Test with a single item + bf_s_single = bigframes.pandas.Series([42], session=session) + pd_s_single = pd.Series([42]) + assert bf_s_single.item() == pd_s_single.item() + + +def test_series_item_with_multiple(session): + # Test with multiple items + bf_s_multiple = bigframes.pandas.Series([1, 2, 3], session=session) + pd_s_multiple = pd.Series([1, 2, 3]) + + try: + pd_s_multiple.item() + except ValueError as e: + expected_message = str(e) + else: + raise AssertionError("Expected ValueError from pandas, but didn't get one") + + with pytest.raises(ValueError, match=re.escape(expected_message)): + bf_s_multiple.item() + + +def test_series_item_with_empty(session): + # Test with an empty Series + bf_s_empty = bigframes.pandas.Series([], dtype="Int64", session=session) + pd_s_empty = pd.Series([], dtype="Int64") + + try: + pd_s_empty.item() + except ValueError as e: + expected_message = str(e) + else: + raise AssertionError("Expected ValueError from pandas, but didn't get one") + + with pytest.raises(ValueError, match=re.escape(expected_message)): + bf_s_empty.item() diff --git a/third_party/bigframes_vendored/pandas/core/arrays/datetimelike.py b/third_party/bigframes_vendored/pandas/core/arrays/datetimelike.py index 1736a7f9ef..22e946edcd 100644 --- a/third_party/bigframes_vendored/pandas/core/arrays/datetimelike.py +++ b/third_party/bigframes_vendored/pandas/core/arrays/datetimelike.py @@ -53,6 +53,7 @@ def normalize(self): >>> import pandas as pd >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(pd.date_range( ... start='2014-08-01 10:00', ... freq='h', @@ -87,6 +88,7 @@ def floor(self, freq: str): >>> import pandas as pd >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> rng = pd.date_range('1/1/2018 11:59:00', periods=3, freq='min') >>> bpd.Series(rng).dt.floor("h") 0 2018-01-01 11:00:00 From a6f87a0b24190c91dc0cbb4046950ae081aff2d3 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Wed, 15 Oct 2025 10:12:12 -0700 Subject: [PATCH 152/313] refactor: add ops.clip_op and where_op to the sqlglot compiler (#2168) --- .../sqlglot/expressions/generic_ops.py | 20 ++++++++++++ .../system/small/engines/test_generic_ops.py | 2 +- .../test_generic_ops/test_clip/out.sql | 15 +++++++++ .../test_generic_ops/test_where/out.sql | 15 +++++++++ .../sqlglot/expressions/test_generic_ops.py | 32 +++++++++++++++++++ 5 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_clip/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_where/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/generic_ops.py b/bigframes/core/compile/sqlglot/expressions/generic_ops.py index af3b57f77b..1ed49b89eb 100644 --- a/bigframes/core/compile/sqlglot/expressions/generic_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/generic_ops.py @@ -23,6 +23,7 @@ import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_ternary_op = scalar_compiler.scalar_op_compiler.register_ternary_op @register_unary_op(ops.AsTypeOp, pass_op=True) @@ -66,6 +67,18 @@ def _(expr: TypedExpr, op: ops.AsTypeOp) -> sge.Expression: return _cast(sg_expr, sg_to_type, op.safe) +@register_ternary_op(ops.clip_op) +def _( + original: TypedExpr, + lower: TypedExpr, + upper: TypedExpr, +) -> sge.Expression: + return sge.Greatest( + this=sge.Least(this=original.expr, expressions=[upper.expr]), + expressions=[lower.expr], + ) + + @register_unary_op(ops.hash_op) def _(expr: TypedExpr) -> sge.Expression: return sge.func("FARM_FINGERPRINT", expr.expr) @@ -94,6 +107,13 @@ def _(expr: TypedExpr) -> sge.Expression: return sge.Not(this=sge.Is(this=expr.expr, expression=sge.Null())) +@register_ternary_op(ops.where_op) +def _( + original: TypedExpr, condition: TypedExpr, replacement: TypedExpr +) -> sge.Expression: + return sge.If(this=condition.expr, true=original.expr, false=replacement.expr) + + # Helper functions def _cast_to_json(expr: TypedExpr, op: ops.AsTypeOp) -> sge.Expression: from_type = expr.dtype diff --git a/tests/system/small/engines/test_generic_ops.py b/tests/system/small/engines/test_generic_ops.py index f252782dbd..ae7eafd347 100644 --- a/tests/system/small/engines/test_generic_ops.py +++ b/tests/system/small/engines/test_generic_ops.py @@ -314,7 +314,7 @@ def test_engines_astype_timedelta(scalars_array_value: array_value.ArrayValue, e assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_where_op(scalars_array_value: array_value.ArrayValue, engine): arr, _ = scalars_array_value.compute_values( [ diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_clip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_clip/out.sql new file mode 100644 index 0000000000..172e1f53e7 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_clip/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `int64_too` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + GREATEST(LEAST(`bfcol_2`, `bfcol_1`), `bfcol_0`) AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_3` AS `result_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_where/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_where/out.sql new file mode 100644 index 0000000000..678208e9ba --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_where/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `float64_col` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + IF(`bfcol_0`, `bfcol_1`, `bfcol_2`) AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_3` AS `result_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py index d9ae6ab539..261a630d3a 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py @@ -168,6 +168,22 @@ def test_astype_json_invalid( ) +def test_clip(scalar_types_df: bpd.DataFrame, snapshot): + op_expr = ops.clip_op.as_expr("rowindex", "int64_col", "int64_too") + + array_value = scalar_types_df._block.expr + result, col_ids = array_value.compute_values([op_expr]) + + # Rename columns for deterministic golden SQL results. + assert len(col_ids) == 1 + result = result.rename_columns({col_ids[0]: "result_col"}).select_columns( + ["result_col"] + ) + + sql = result.session._executor.to_sql(result, enable_cache=False) + snapshot.assert_match(sql, "out.sql") + + def test_hash(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] @@ -202,3 +218,19 @@ def test_map(scalar_types_df: bpd.DataFrame, snapshot): ) snapshot.assert_match(sql, "out.sql") + + +def test_where(scalar_types_df: bpd.DataFrame, snapshot): + op_expr = ops.where_op.as_expr("int64_col", "bool_col", "float64_col") + + array_value = scalar_types_df._block.expr + result, col_ids = array_value.compute_values([op_expr]) + + # Rename columns for deterministic golden SQL results. + assert len(col_ids) == 1 + result = result.rename_columns({col_ids[0]: "result_col"}).select_columns( + ["result_col"] + ) + + sql = result.session._executor.to_sql(result, enable_cache=False) + snapshot.assert_match(sql, "out.sql") From 62f7e9f38f26b6eb549219a4cbf2c9b9023c9c35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Wed, 15 Oct 2025 12:36:18 -0500 Subject: [PATCH 153/313] fix!: turn `Series.struct.dtypes` into a property to match pandas (#2169) Also, implement some struct operations in the polars compiler. --- bigframes/core/compile/polars/__init__.py | 1 + .../compile/polars/operations/struct_ops.py | 48 ++++++ bigframes/operations/structs.py | 18 ++- notebooks/data_types/struct.ipynb | 16 +- tests/unit/test_series_struct.py | 138 ++++++++++++++++++ .../pandas/core/arrays/arrow/accessors.py | 5 +- 6 files changed, 210 insertions(+), 16 deletions(-) create mode 100644 bigframes/core/compile/polars/operations/struct_ops.py create mode 100644 tests/unit/test_series_struct.py diff --git a/bigframes/core/compile/polars/__init__.py b/bigframes/core/compile/polars/__init__.py index 7ae6fcc755..0e54895835 100644 --- a/bigframes/core/compile/polars/__init__.py +++ b/bigframes/core/compile/polars/__init__.py @@ -24,6 +24,7 @@ # polars shouldn't be needed at import time, as register is a no-op if polars # isn't installed. import bigframes.core.compile.polars.operations.generic_ops # noqa: F401 +import bigframes.core.compile.polars.operations.struct_ops # noqa: F401 try: import bigframes._importing diff --git a/bigframes/core/compile/polars/operations/struct_ops.py b/bigframes/core/compile/polars/operations/struct_ops.py new file mode 100644 index 0000000000..1573d4aa9b --- /dev/null +++ b/bigframes/core/compile/polars/operations/struct_ops.py @@ -0,0 +1,48 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +BigFrames -> Polars compilation for the operations in bigframes.operations.generic_ops. + +Please keep implementations in sequential order by op name. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import bigframes_vendored.constants + +import bigframes.core.compile.polars.compiler as polars_compiler +from bigframes.operations import struct_ops + +if TYPE_CHECKING: + import polars as pl + + +@polars_compiler.register_op(struct_ops.StructFieldOp) +def struct_field_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: struct_ops.StructFieldOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + if isinstance(op.name_or_index, str): + name = op.name_or_index + else: + raise NotImplementedError( + "Referencing a struct field by number not implemented in polars compiler. " + f"{bigframes_vendored.constants.FEEDBACK_LINK}" + ) + + return input.struct.field(name) diff --git a/bigframes/operations/structs.py b/bigframes/operations/structs.py index 051023c299..fc277008e2 100644 --- a/bigframes/operations/structs.py +++ b/bigframes/operations/structs.py @@ -17,9 +17,8 @@ import bigframes_vendored.pandas.core.arrays.arrow.accessors as vendoracessors import pandas as pd -from bigframes.core import log_adapter +from bigframes.core import backports, log_adapter import bigframes.dataframe -import bigframes.dtypes import bigframes.operations import bigframes.operations.base import bigframes.series @@ -45,17 +44,24 @@ def explode(self) -> bigframes.dataframe.DataFrame: pa_type = self._dtype.pyarrow_dtype return bigframes.pandas.concat( - [self.field(i) for i in range(pa_type.num_fields)], axis="columns" + [ + self.field(field.name) + for field in backports.pyarrow_struct_type_fields(pa_type) + ], + axis="columns", ) + @property def dtypes(self) -> pd.Series: pa_type = self._dtype.pyarrow_dtype return pd.Series( data=[ - bigframes.dtypes.arrow_dtype_to_bigframes_dtype(pa_type.field(i).type) - for i in range(pa_type.num_fields) + pd.ArrowDtype(field.type) + for field in backports.pyarrow_struct_type_fields(pa_type) + ], + index=[ + field.name for field in backports.pyarrow_struct_type_fields(pa_type) ], - index=[pa_type.field(i).name for i in range(pa_type.num_fields)], ) diff --git a/notebooks/data_types/struct.ipynb b/notebooks/data_types/struct.ipynb index 74bf69d239..9df0780e30 100644 --- a/notebooks/data_types/struct.ipynb +++ b/notebooks/data_types/struct.ipynb @@ -211,11 +211,11 @@ { "data": { "text/plain": [ - "0 [{'tables': {'score': 0.9349926710128784, 'val...\n", - "1 [{'tables': {'score': 0.9690881371498108, 'val...\n", - "2 [{'tables': {'score': 0.8667634129524231, 'val...\n", - "3 [{'tables': {'score': 0.9351968765258789, 'val...\n", - "4 [{'tables': {'score': 0.8572560548782349, 'val...\n", + "0 [{'tables': {'score': 0.8667634129524231, 'val...\n", + "1 [{'tables': {'score': 0.9351968765258789, 'val...\n", + "2 [{'tables': {'score': 0.8572560548782349, 'val...\n", + "3 [{'tables': {'score': 0.9690881371498108, 'val...\n", + "4 [{'tables': {'score': 0.9349926710128784, 'val...\n", "Name: predicted_default_payment_next_month, dtype: list>>[pyarrow]" ] }, @@ -267,7 +267,7 @@ } ], "source": [ - "df['Address'].struct.dtypes()" + "df['Address'].struct.dtypes" ] }, { @@ -461,7 +461,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "venv", "language": "python", "name": "python3" }, @@ -475,7 +475,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.1" + "version": "3.12.9" } }, "nbformat": 4, diff --git a/tests/unit/test_series_struct.py b/tests/unit/test_series_struct.py new file mode 100644 index 0000000000..c92b87cf48 --- /dev/null +++ b/tests/unit/test_series_struct.py @@ -0,0 +1,138 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import pathlib +from typing import Generator, TYPE_CHECKING + +import pandas as pd +import pandas.testing +import pyarrow as pa # type: ignore +import pytest + +import bigframes + +if TYPE_CHECKING: + from bigframes.testing import polars_session + +pytest.importorskip("polars") +pytest.importorskip("pandas", minversion="2.2.0") + +CURRENT_DIR = pathlib.Path(__file__).parent +DATA_DIR = CURRENT_DIR.parent / "data" + + +@pytest.fixture(scope="module", autouse=True) +def session() -> Generator[bigframes.Session, None, None]: + import bigframes.core.global_session + from bigframes.testing import polars_session + + session = polars_session.TestSession() + with bigframes.core.global_session._GlobalSessionContext(session): + yield session + + +@pytest.fixture +def struct_df(session: polars_session.TestSession): + pa_type = pa.struct( + [ + ("str_field", pa.string()), + ("int_field", pa.int64()), + ] + ) + return session.DataFrame( + { + "struct_col": pd.Series( + pa.array( + [ + { + "str_field": "my string", + "int_field": 1, + }, + { + "str_field": None, + "int_field": 2, + }, + { + "str_field": "another string", + "int_field": None, + }, + { + "str_field": "some string", + "int_field": 3, + }, + ], + pa_type, + ), + dtype=pd.ArrowDtype(pa_type), + ), + } + ) + + +@pytest.fixture +def struct_series(struct_df): + return struct_df["struct_col"] + + +def test_struct_dtypes(struct_series): + bf_series = struct_series + pd_series = struct_series.to_pandas() + assert isinstance(pd_series.dtype, pd.ArrowDtype) + + bf_result = bf_series.struct.dtypes + pd_result = pd_series.struct.dtypes + + pandas.testing.assert_series_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("field_name", "common_dtype"), + ( + ("str_field", "string[pyarrow]"), + ("int_field", "int64[pyarrow]"), + # TODO(tswast): Support referencing fields by number, too. + ), +) +def test_struct_field(struct_series, field_name, common_dtype): + bf_series = struct_series + pd_series = struct_series.to_pandas() + assert isinstance(pd_series.dtype, pd.ArrowDtype) + + bf_result = bf_series.struct.field(field_name).to_pandas() + pd_result = pd_series.struct.field(field_name) + + # TODO(tswast): if/when we support arrowdtype for int/string, we can remove + # this cast. + bf_result = bf_result.astype(common_dtype) + pd_result = pd_result.astype(common_dtype) + + pandas.testing.assert_series_equal(bf_result, pd_result) + + +def test_struct_explode(struct_series): + bf_series = struct_series + pd_series = struct_series.to_pandas() + assert isinstance(pd_series.dtype, pd.ArrowDtype) + + bf_result = bf_series.struct.explode().to_pandas() + pd_result = pd_series.struct.explode() + + pandas.testing.assert_frame_equal( + bf_result, + pd_result, + # TODO(tswast): remove if/when we support arrowdtype for int/string. + check_dtype=False, + ) diff --git a/third_party/bigframes_vendored/pandas/core/arrays/arrow/accessors.py b/third_party/bigframes_vendored/pandas/core/arrays/arrow/accessors.py index fe15e7b40d..f4244ab499 100644 --- a/third_party/bigframes_vendored/pandas/core/arrays/arrow/accessors.py +++ b/third_party/bigframes_vendored/pandas/core/arrays/arrow/accessors.py @@ -158,6 +158,7 @@ def explode(self): """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + @property def dtypes(self): """ Return the dtype object of each child field of the struct. @@ -177,8 +178,8 @@ def dtypes(self): ... [("version", pa.int64()), ("project", pa.string())] ... )) ... ) - >>> s.struct.dtypes() - version Int64 + >>> s.struct.dtypes + version int64[pyarrow] project string[pyarrow] dtype: object From 118c26573ef41d4938c4a3e984a4602d089ff5f3 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Wed, 15 Oct 2025 12:24:18 -0700 Subject: [PATCH 154/313] refactor: enhance aggregation tests and optimize null-last SQL for sqlglot (#2165) --- .../compile/sqlglot/aggregations/windows.py | 21 +-- bigframes/core/compile/sqlglot/compiler.py | 2 + .../test_row_number_with_window/out.sql | 2 +- .../test_all/window_out.sql | 17 +++ .../test_all/window_partition_out.sql | 18 +++ .../test_any_value/window_out.sql | 13 ++ .../test_any_value/window_partition_out.sql | 18 +++ .../test_count/window_out.sql | 13 ++ .../test_count/window_partition_out.sql | 14 ++ .../test_date_series_diff/out.sql | 6 +- .../test_dense_rank/out.sql | 2 +- .../test_diff/diff_bool.sql | 2 +- .../test_diff/diff_int.sql | 2 +- .../test_unary_compiler/test_first/out.sql | 5 +- .../test_first_non_null/out.sql | 2 +- .../test_unary_compiler/test_last/out.sql | 5 +- .../test_last_non_null/out.sql | 2 +- .../test_max/window_out.sql | 13 ++ .../test_max/window_partition_out.sql | 18 +++ .../test_mean/window_out.sql | 13 ++ .../test_mean/window_partition_out.sql | 18 +++ .../test_min/window_out.sql | 13 ++ .../test_min/window_partition_out.sql | 18 +++ .../test_unary_compiler/test_rank/out.sql | 2 +- .../test_unary_compiler/test_shift/lag.sql | 2 +- .../test_unary_compiler/test_shift/lead.sql | 2 +- .../test_sum/window_out.sql | 13 ++ .../test_sum/window_partition_out.sql | 18 +++ .../test_time_series_diff/out.sql | 6 +- .../aggregations/test_unary_compiler.py | 127 +++++++++++++++++- .../sqlglot/aggregations/test_windows.py | 2 +- .../test_compile_concat_filter_sorted/out.sql | 4 +- .../out.sql | 8 +- .../out.sql | 10 +- .../out.sql | 10 +- 35 files changed, 375 insertions(+), 66 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_partition_out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_partition_out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_partition_out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_partition_out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_partition_out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_partition_out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_partition_out.sql diff --git a/bigframes/core/compile/sqlglot/aggregations/windows.py b/bigframes/core/compile/sqlglot/aggregations/windows.py index 41b4c674f9..099f5832da 100644 --- a/bigframes/core/compile/sqlglot/aggregations/windows.py +++ b/bigframes/core/compile/sqlglot/aggregations/windows.py @@ -40,14 +40,9 @@ def apply_window_if_present( # Unbound grouping window. order_by = None elif window.is_range_bounded: - # Note that, when the window is range-bounded, we only need one ordering key. - # There are two reasons: - # 1. Manipulating null positions requires more than one ordering key, which - # is forbidden by SQL window syntax for range rolling. - # 2. Pandas does not allow range rolling on timeseries with nulls. - order_by = get_window_order_by((window.ordering[0],), override_null_order=False) + order_by = get_window_order_by((window.ordering[0],)) else: - order_by = get_window_order_by(window.ordering, override_null_order=True) + order_by = get_window_order_by(window.ordering) order = sge.Order(expressions=order_by) if order_by else None @@ -102,7 +97,15 @@ def get_window_order_by( ordering: typing.Tuple[ordering_spec.OrderingExpression, ...], override_null_order: bool = False, ) -> typing.Optional[tuple[sge.Ordered, ...]]: - """Returns the SQL order by clause for a window specification.""" + """Returns the SQL order by clause for a window specification. + Args: + ordering (Tuple[ordering_spec.OrderingExpression, ...]): + A tuple of ordering specification objects. + override_null_order (bool): + If True, overrides BigQuery's default null ordering behavior, which + is sometimes incompatible with ordered aggregations. The generated SQL + will include extra expressions to correctly enforce NULL FIRST/LAST. + """ if not ordering: return None @@ -115,8 +118,6 @@ def get_window_order_by( nulls_first = not ordering_spec_item.na_last if override_null_order: - # Bigquery SQL considers NULLS to be "smallest" values, but we need - # to override in these cases. is_null_expr = sge.Is(this=expr, expression=sge.Null()) if nulls_first and desc: order_by.append( diff --git a/bigframes/core/compile/sqlglot/compiler.py b/bigframes/core/compile/sqlglot/compiler.py index 40795bbb48..47ad8db21b 100644 --- a/bigframes/core/compile/sqlglot/compiler.py +++ b/bigframes/core/compile/sqlglot/compiler.py @@ -299,6 +299,8 @@ def compile_random_sample( def compile_aggregate( self, node: nodes.AggregateNode, child: ir.SQLGlotIR ) -> ir.SQLGlotIR: + # The BigQuery ordered aggregation cannot support for NULL FIRST/LAST, + # so we need to add extra expressions to enforce the null ordering. ordering_cols = windows.get_window_order_by( node.order_by, override_null_order=True ) diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql index 2cee8a228f..8dc701e1f9 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - ROW_NUMBER() OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST) AS `bfcol_1` + ROW_NUMBER() OVER (ORDER BY `bfcol_0` ASC NULLS LAST) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_out.sql new file mode 100644 index 0000000000..a91381d3be --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `bfcol_0` IS NULL + THEN NULL + ELSE COALESCE(LOGICAL_AND(`bfcol_0`) OVER (), TRUE) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_bool` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_partition_out.sql new file mode 100644 index 0000000000..1a9a020b3e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_partition_out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `string_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `bfcol_0` IS NULL + THEN NULL + ELSE COALESCE(LOGICAL_AND(`bfcol_0`) OVER (PARTITION BY `bfcol_1`), TRUE) + END AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `agg_bool` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_out.sql new file mode 100644 index 0000000000..b262284b88 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN `bfcol_0` IS NULL THEN NULL ELSE ANY_VALUE(`bfcol_0`) OVER () END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_partition_out.sql new file mode 100644 index 0000000000..66933626b5 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_partition_out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `string_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `bfcol_0` IS NULL + THEN NULL + ELSE ANY_VALUE(`bfcol_0`) OVER (PARTITION BY `bfcol_1`) + END AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_out.sql new file mode 100644 index 0000000000..beda182992 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + COUNT(`bfcol_0`) OVER () AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_partition_out.sql new file mode 100644 index 0000000000..d4a12d73f8 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_partition_out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `string_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + COUNT(`bfcol_0`) OVER (PARTITION BY `bfcol_1`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_date_series_diff/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_date_series_diff/out.sql index 599d8333c9..cd1cf3ff3b 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_date_series_diff/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_date_series_diff/out.sql @@ -5,11 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CAST(DATE_DIFF( - `bfcol_0`, - LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST), - DAY - ) * 86400000000 AS INT64) AS `bfcol_1` + CAST(DATE_DIFF(`bfcol_0`, LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` ASC NULLS LAST), DAY) * 86400000000 AS INT64) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_dense_rank/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_dense_rank/out.sql index 38b6ed9f5c..0f704dd0cc 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_dense_rank/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_dense_rank/out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - DENSE_RANK() OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST) AS `bfcol_1` + DENSE_RANK() OVER (ORDER BY `bfcol_0` DESC) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_bool.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_bool.sql index 6c7d37c037..500056928d 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_bool.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_bool.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - `bfcol_0` <> LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST) AS `bfcol_1` + `bfcol_0` <> LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` DESC) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_int.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_int.sql index 1ce4953d87..f4fd46ee2d 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_int.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_int.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - `bfcol_0` - LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST) AS `bfcol_1` + `bfcol_0` - LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` ASC NULLS LAST) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql index 6c7d39c24a..df76aa1d33 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql @@ -8,10 +8,7 @@ WITH `bfcte_0` AS ( CASE WHEN `bfcol_0` IS NULL THEN NULL - ELSE FIRST_VALUE(`bfcol_0`) OVER ( - ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST - ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING - ) + ELSE FIRST_VALUE(`bfcol_0`) OVER (ORDER BY `bfcol_0` DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) END AS `bfcol_1` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first_non_null/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first_non_null/out.sql index ff90c6fcd9..70eeefda7b 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first_non_null/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first_non_null/out.sql @@ -6,7 +6,7 @@ WITH `bfcte_0` AS ( SELECT *, FIRST_VALUE(`bfcol_0` IGNORE NULLS) OVER ( - ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST + ORDER BY `bfcol_0` ASC NULLS LAST ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS `bfcol_1` FROM `bfcte_0` diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql index 788c5ba466..4a5af9af32 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql @@ -8,10 +8,7 @@ WITH `bfcte_0` AS ( CASE WHEN `bfcol_0` IS NULL THEN NULL - ELSE LAST_VALUE(`bfcol_0`) OVER ( - ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST - ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING - ) + ELSE LAST_VALUE(`bfcol_0`) OVER (ORDER BY `bfcol_0` DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) END AS `bfcol_1` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last_non_null/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last_non_null/out.sql index 17e7dbd446..a246618ff0 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last_non_null/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last_non_null/out.sql @@ -6,7 +6,7 @@ WITH `bfcte_0` AS ( SELECT *, LAST_VALUE(`bfcol_0` IGNORE NULLS) OVER ( - ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST + ORDER BY `bfcol_0` ASC NULLS LAST ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS `bfcol_1` FROM `bfcte_0` diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_out.sql new file mode 100644 index 0000000000..4c86cb38e0 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN `bfcol_0` IS NULL THEN NULL ELSE MAX(`bfcol_0`) OVER () END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_partition_out.sql new file mode 100644 index 0000000000..64dc97642b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_partition_out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `string_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `bfcol_0` IS NULL + THEN NULL + ELSE MAX(`bfcol_0`) OVER (PARTITION BY `bfcol_1`) + END AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_out.sql new file mode 100644 index 0000000000..bc89091ca9 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN `bfcol_0` IS NULL THEN NULL ELSE AVG(`bfcol_0`) OVER () END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_partition_out.sql new file mode 100644 index 0000000000..e6c1cf3bb4 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_partition_out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `string_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `bfcol_0` IS NULL + THEN NULL + ELSE AVG(`bfcol_0`) OVER (PARTITION BY `bfcol_1`) + END AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_out.sql new file mode 100644 index 0000000000..0e96b56c50 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN `bfcol_0` IS NULL THEN NULL ELSE MIN(`bfcol_0`) OVER () END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_partition_out.sql new file mode 100644 index 0000000000..4121e80f43 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_partition_out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `string_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `bfcol_0` IS NULL + THEN NULL + ELSE MIN(`bfcol_0`) OVER (PARTITION BY `bfcol_1`) + END AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_rank/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_rank/out.sql index 5de2330ef6..ca3b9e8e54 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_rank/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_rank/out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - RANK() OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST) AS `bfcol_1` + RANK() OVER (ORDER BY `bfcol_0` DESC NULLS FIRST) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lag.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lag.sql index 59e2c47edf..32e6eabb9b 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lag.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lag.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST) AS `bfcol_1` + LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` ASC) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lead.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lead.sql index 5c82b5db39..f0797f1e17 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lead.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lead.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - LEAD(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST) AS `bfcol_1` + LEAD(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` ASC) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_out.sql new file mode 100644 index 0000000000..939b491dd0 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN `bfcol_0` IS NULL THEN NULL ELSE COALESCE(SUM(`bfcol_0`) OVER (), 0) END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_partition_out.sql new file mode 100644 index 0000000000..a23842867e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_partition_out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `string_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `bfcol_0` IS NULL + THEN NULL + ELSE COALESCE(SUM(`bfcol_0`) OVER (PARTITION BY `bfcol_1`), 0) + END AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_time_series_diff/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_time_series_diff/out.sql index 8ed95b3c07..692685ee4d 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_time_series_diff/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_time_series_diff/out.sql @@ -5,11 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - TIMESTAMP_DIFF( - `bfcol_0`, - LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST), - MICROSECOND - ) AS `bfcol_1` + TIMESTAMP_DIFF(`bfcol_0`, LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` ASC NULLS LAST), MICROSECOND) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index da388ccad1..3d7e4287ac 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -72,6 +72,21 @@ def test_all(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") + # Window tests + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df, agg_expr, window, "agg_bool") + snapshot.assert_match(sql_window, "window_out.sql") + + bf_df_str = scalar_types_df[[col_name, "string_col"]] + window_partition = window_spec.WindowSpec( + grouping_keys=(expression.deref("string_col"),), + ordering=(ordering.descending_over(col_name),), + ) + sql_window_partition = _apply_unary_window_op( + bf_df_str, agg_expr, window_partition, "agg_bool" + ) + snapshot.assert_match(sql_window_partition, "window_partition_out.sql") + def test_approx_quartiles(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" @@ -105,6 +120,21 @@ def test_any_value(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") + # Window tests + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") + + bf_df_str = scalar_types_df[[col_name, "string_col"]] + window_partition = window_spec.WindowSpec( + grouping_keys=(expression.deref("string_col"),), + ordering=(ordering.ascending_over(col_name),), + ) + sql_window_partition = _apply_unary_window_op( + bf_df_str, agg_expr, window_partition, "agg_int64" + ) + snapshot.assert_match(sql_window_partition, "window_partition_out.sql") + def test_count(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" @@ -114,6 +144,21 @@ def test_count(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") + # Window tests + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") + + bf_df_str = scalar_types_df[[col_name, "string_col"]] + window_partition = window_spec.WindowSpec( + grouping_keys=(expression.deref("string_col"),), + ordering=(ordering.descending_over(col_name),), + ) + sql_window_partition = _apply_unary_window_op( + bf_df_str, agg_expr, window_partition, "agg_int64" + ) + snapshot.assert_match(sql_window_partition, "window_partition_out.sql") + def test_dense_rank(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" @@ -121,7 +166,7 @@ def test_dense_rank(scalar_types_df: bpd.DataFrame, snapshot): agg_expr = agg_exprs.UnaryAggregation( agg_ops.DenseRankOp(), expression.deref(col_name) ) - window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) sql = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") snapshot.assert_match(sql, "out.sql") @@ -152,7 +197,7 @@ def test_diff(scalar_types_df: bpd.DataFrame, snapshot): # Test boolean bool_col = "bool_col" bf_df_bool = scalar_types_df[[bool_col]] - window = window_spec.WindowSpec(ordering=(ordering.ascending_over(bool_col),)) + window = window_spec.WindowSpec(ordering=(ordering.descending_over(bool_col),)) bool_op = agg_exprs.UnaryAggregation( agg_ops.DiffOp(periods=1), expression.deref(bool_col) ) @@ -168,7 +213,7 @@ def test_first(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] agg_expr = agg_exprs.UnaryAggregation(agg_ops.FirstOp(), expression.deref(col_name)) - window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) sql = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") snapshot.assert_match(sql, "out.sql") @@ -198,7 +243,7 @@ def test_last(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] agg_expr = agg_exprs.UnaryAggregation(agg_ops.LastOp(), expression.deref(col_name)) - window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) sql = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") snapshot.assert_match(sql, "out.sql") @@ -228,6 +273,21 @@ def test_max(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") + # Window tests + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") + + bf_df_str = scalar_types_df[[col_name, "string_col"]] + window_partition = window_spec.WindowSpec( + grouping_keys=(expression.deref("string_col"),), + ordering=(ordering.descending_over(col_name),), + ) + sql_window_partition = _apply_unary_window_op( + bf_df_str, agg_expr, window_partition, "agg_int64" + ) + snapshot.assert_match(sql_window_partition, "window_partition_out.sql") + def test_mean(scalar_types_df: bpd.DataFrame, snapshot): col_names = ["int64_col", "bool_col", "duration_col"] @@ -255,6 +315,24 @@ def test_mean(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") + # Window tests + col_name = "int64_col" + bf_df_int = scalar_types_df[[col_name]] + agg_expr = agg_ops.MeanOp().as_expr(col_name) + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df_int, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") + + bf_df_str = scalar_types_df[[col_name, "string_col"]] + window_partition = window_spec.WindowSpec( + grouping_keys=(expression.deref("string_col"),), + ordering=(ordering.ascending_over(col_name),), + ) + sql_window_partition = _apply_unary_window_op( + bf_df_str, agg_expr, window_partition, "agg_int64" + ) + snapshot.assert_match(sql_window_partition, "window_partition_out.sql") + def test_median(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df @@ -276,6 +354,21 @@ def test_min(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") + # Window tests + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") + + bf_df_str = scalar_types_df[[col_name, "string_col"]] + window_partition = window_spec.WindowSpec( + grouping_keys=(expression.deref("string_col"),), + ordering=(ordering.descending_over(col_name),), + ) + sql_window_partition = _apply_unary_window_op( + bf_df_str, agg_expr, window_partition, "agg_int64" + ) + snapshot.assert_match(sql_window_partition, "window_partition_out.sql") + def test_quantile(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" @@ -298,7 +391,9 @@ def test_rank(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[[col_name]] agg_expr = agg_exprs.UnaryAggregation(agg_ops.RankOp(), expression.deref(col_name)) - window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + window = window_spec.WindowSpec( + ordering=(ordering.descending_over(col_name, nulls_last=False),) + ) sql = _apply_unary_window_op(bf_df, agg_expr, window, "agg_int64") snapshot.assert_match(sql, "out.sql") @@ -307,7 +402,9 @@ def test_rank(scalar_types_df: bpd.DataFrame, snapshot): def test_shift(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] - window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + window = window_spec.WindowSpec( + ordering=(ordering.ascending_over(col_name, nulls_last=False),) + ) # Test lag lag_op = agg_exprs.UnaryAggregation( @@ -343,6 +440,24 @@ def test_sum(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") + # Window tests + col_name = "int64_col" + bf_df_int = scalar_types_df[[col_name]] + agg_expr = agg_ops.SumOp().as_expr(col_name) + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df_int, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") + + bf_df_str = scalar_types_df[[col_name, "string_col"]] + window_partition = window_spec.WindowSpec( + grouping_keys=(expression.deref("string_col"),), + ordering=(ordering.ascending_over(col_name),), + ) + sql_window_partition = _apply_unary_window_op( + bf_df_str, agg_expr, window_partition, "agg_int64" + ) + snapshot.assert_match(sql_window_partition, "window_partition_out.sql") + def test_time_series_diff(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_windows.py b/tests/unit/core/compile/sqlglot/aggregations/test_windows.py index 609d3441a5..f1a3eced9a 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_windows.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_windows.py @@ -133,7 +133,7 @@ def test_apply_window_if_present_all_params(self): ) self.assertEqual( result.sql(dialect="bigquery"), - "value OVER (PARTITION BY `col1` ORDER BY `col2` IS NULL ASC NULLS LAST, `col2` ASC NULLS LAST ROWS BETWEEN 1 PRECEDING AND CURRENT ROW)", + "value OVER (PARTITION BY `col1` ORDER BY `col2` ASC NULLS LAST ROWS BETWEEN 1 PRECEDING AND CURRENT ROW)", ) diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql index 5043435688..90825afd20 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql @@ -6,7 +6,7 @@ WITH `bfcte_3` AS ( ), `bfcte_7` AS ( SELECT *, - ROW_NUMBER() OVER (ORDER BY `bfcol_0` IS NULL ASC NULLS LAST, `bfcol_0` ASC NULLS LAST) AS `bfcol_4` + ROW_NUMBER() OVER (ORDER BY `bfcol_0` ASC NULLS LAST) AS `bfcol_4` FROM `bfcte_3` ), `bfcte_11` AS ( SELECT @@ -57,7 +57,7 @@ WITH `bfcte_3` AS ( ), `bfcte_5` AS ( SELECT *, - ROW_NUMBER() OVER (ORDER BY `bfcol_21` IS NULL ASC NULLS LAST, `bfcol_21` ASC NULLS LAST) AS `bfcol_25` + ROW_NUMBER() OVER (ORDER BY `bfcol_21` ASC NULLS LAST) AS `bfcol_25` FROM `bfcte_1` ), `bfcte_9` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql index beb3caa073..f280933a74 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql @@ -24,14 +24,14 @@ WITH `bfcte_0` AS ( CASE WHEN SUM(CAST(NOT `bfcol_7` IS NULL AS INT64)) OVER ( PARTITION BY `bfcol_9` - ORDER BY `bfcol_9` IS NULL ASC NULLS LAST, `bfcol_9` ASC NULLS LAST, `bfcol_2` IS NULL ASC NULLS LAST, `bfcol_2` ASC NULLS LAST + ORDER BY `bfcol_9` ASC NULLS LAST, `bfcol_2` ASC NULLS LAST ROWS BETWEEN 3 PRECEDING AND CURRENT ROW ) < 3 THEN NULL ELSE COALESCE( SUM(CAST(`bfcol_7` AS INT64)) OVER ( PARTITION BY `bfcol_9` - ORDER BY `bfcol_9` IS NULL ASC NULLS LAST, `bfcol_9` ASC NULLS LAST, `bfcol_2` IS NULL ASC NULLS LAST, `bfcol_2` ASC NULLS LAST + ORDER BY `bfcol_9` ASC NULLS LAST, `bfcol_2` ASC NULLS LAST ROWS BETWEEN 3 PRECEDING AND CURRENT ROW ), 0 @@ -50,14 +50,14 @@ WITH `bfcte_0` AS ( CASE WHEN SUM(CAST(NOT `bfcol_8` IS NULL AS INT64)) OVER ( PARTITION BY `bfcol_9` - ORDER BY `bfcol_9` IS NULL ASC NULLS LAST, `bfcol_9` ASC NULLS LAST, `bfcol_2` IS NULL ASC NULLS LAST, `bfcol_2` ASC NULLS LAST + ORDER BY `bfcol_9` ASC NULLS LAST, `bfcol_2` ASC NULLS LAST ROWS BETWEEN 3 PRECEDING AND CURRENT ROW ) < 3 THEN NULL ELSE COALESCE( SUM(`bfcol_8`) OVER ( PARTITION BY `bfcol_9` - ORDER BY `bfcol_9` IS NULL ASC NULLS LAST, `bfcol_9` ASC NULLS LAST, `bfcol_2` IS NULL ASC NULLS LAST, `bfcol_2` ASC NULLS LAST + ORDER BY `bfcol_9` ASC NULLS LAST, `bfcol_2` ASC NULLS LAST ROWS BETWEEN 3 PRECEDING AND CURRENT ROW ), 0 diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_skips_nulls_op/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_skips_nulls_op/out.sql index 6d779a40ac..f22ef37b7e 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_skips_nulls_op/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_skips_nulls_op/out.sql @@ -7,16 +7,10 @@ WITH `bfcte_0` AS ( SELECT *, CASE - WHEN SUM(CAST(NOT `bfcol_0` IS NULL AS INT64)) OVER ( - ORDER BY `bfcol_1` IS NULL ASC NULLS LAST, `bfcol_1` ASC NULLS LAST - ROWS BETWEEN 2 PRECEDING AND CURRENT ROW - ) < 3 + WHEN SUM(CAST(NOT `bfcol_0` IS NULL AS INT64)) OVER (ORDER BY `bfcol_1` ASC NULLS LAST ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) < 3 THEN NULL ELSE COALESCE( - SUM(`bfcol_0`) OVER ( - ORDER BY `bfcol_1` IS NULL ASC NULLS LAST, `bfcol_1` ASC NULLS LAST - ROWS BETWEEN 2 PRECEDING AND CURRENT ROW - ), + SUM(`bfcol_0`) OVER (ORDER BY `bfcol_1` ASC NULLS LAST ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 0 ) END AS `bfcol_4` diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_wo_skips_nulls_op/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_wo_skips_nulls_op/out.sql index 1d5d9a9e45..dcf52f2e82 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_wo_skips_nulls_op/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_wo_skips_nulls_op/out.sql @@ -7,15 +7,9 @@ WITH `bfcte_0` AS ( SELECT *, CASE - WHEN COUNT(CAST(NOT `bfcol_0` IS NULL AS INT64)) OVER ( - ORDER BY `bfcol_1` IS NULL ASC NULLS LAST, `bfcol_1` ASC NULLS LAST - ROWS BETWEEN 4 PRECEDING AND CURRENT ROW - ) < 5 + WHEN COUNT(CAST(NOT `bfcol_0` IS NULL AS INT64)) OVER (ORDER BY `bfcol_1` ASC NULLS LAST ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) < 5 THEN NULL - ELSE COUNT(`bfcol_0`) OVER ( - ORDER BY `bfcol_1` IS NULL ASC NULLS LAST, `bfcol_1` ASC NULLS LAST - ROWS BETWEEN 4 PRECEDING AND CURRENT ROW - ) + ELSE COUNT(`bfcol_0`) OVER (ORDER BY `bfcol_1` ASC NULLS LAST ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) END AS `bfcol_4` FROM `bfcte_0` ) From 5613e4454f198691209ec28e58ce652104ac2de4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Wed, 15 Oct 2025 16:04:54 -0500 Subject: [PATCH 155/313] feat: implement cos, sin, and log operations for polars compiler (#2170) * feat: implement cos, sin, and log operations for polars compiler * fix domain for log * update snapshot * revert sqrt change * revert sqrt change --- bigframes/core/compile/polars/__init__.py | 1 + .../compile/polars/operations/numeric_ops.py | 91 +++++++++++++++++++ .../sqlglot/expressions/numeric_ops.py | 6 +- .../test_numeric_ops/test_ln/out.sql | 2 +- .../test_numeric_ops/test_log10/out.sql | 2 +- .../test_numeric_ops/test_log1p/out.sql | 2 +- tests/unit/test_series_polars.py | 17 ++-- 7 files changed, 104 insertions(+), 17 deletions(-) create mode 100644 bigframes/core/compile/polars/operations/numeric_ops.py diff --git a/bigframes/core/compile/polars/__init__.py b/bigframes/core/compile/polars/__init__.py index 0e54895835..215d6b088e 100644 --- a/bigframes/core/compile/polars/__init__.py +++ b/bigframes/core/compile/polars/__init__.py @@ -24,6 +24,7 @@ # polars shouldn't be needed at import time, as register is a no-op if polars # isn't installed. import bigframes.core.compile.polars.operations.generic_ops # noqa: F401 +import bigframes.core.compile.polars.operations.numeric_ops # noqa: F401 import bigframes.core.compile.polars.operations.struct_ops # noqa: F401 try: diff --git a/bigframes/core/compile/polars/operations/numeric_ops.py b/bigframes/core/compile/polars/operations/numeric_ops.py new file mode 100644 index 0000000000..2572d862e3 --- /dev/null +++ b/bigframes/core/compile/polars/operations/numeric_ops.py @@ -0,0 +1,91 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +BigFrames -> Polars compilation for the operations in bigframes.operations.numeric_ops. + +Please keep implementations in sequential order by op name. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import bigframes.core.compile.polars.compiler as polars_compiler +from bigframes.operations import numeric_ops + +if TYPE_CHECKING: + import polars as pl + + +@polars_compiler.register_op(numeric_ops.CosOp) +def cos_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.CosOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.cos() + + +@polars_compiler.register_op(numeric_ops.LnOp) +def ln_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.LnOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + import polars as pl + + return pl.when(input <= 0).then(float("nan")).otherwise(input.log()) + + +@polars_compiler.register_op(numeric_ops.Log10Op) +def log10_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.Log10Op, # type: ignore + input: pl.Expr, +) -> pl.Expr: + import polars as pl + + return pl.when(input <= 0).then(float("nan")).otherwise(input.log(base=10)) + + +@polars_compiler.register_op(numeric_ops.Log1pOp) +def log1p_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.Log1pOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + import polars as pl + + return pl.when(input <= -1).then(float("nan")).otherwise((input + 1).log()) + + +@polars_compiler.register_op(numeric_ops.SinOp) +def sin_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.SinOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.sin() + + +@polars_compiler.register_op(numeric_ops.SqrtOp) +def sqrt_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.SqrtOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + import polars as pl + + return pl.when(input < 0).then(float("nan")).otherwise(input.sqrt()) diff --git a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py index d86df93921..ac40e4a667 100644 --- a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py @@ -158,7 +158,7 @@ def _(expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( - this=expr.expr < sge.convert(0), + this=expr.expr <= sge.convert(0), true=constants._NAN, ) ], @@ -171,7 +171,7 @@ def _(expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( - this=expr.expr < sge.convert(0), + this=expr.expr <= sge.convert(0), true=constants._NAN, ) ], @@ -184,7 +184,7 @@ def _(expr: TypedExpr) -> sge.Expression: return sge.Case( ifs=[ sge.If( - this=expr.expr < sge.convert(-1), + this=expr.expr <= sge.convert(-1), true=constants._NAN, ) ], diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ln/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ln/out.sql index 1372c088d9..5d3d1ae09b 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ln/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ln/out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE WHEN `bfcol_0` < 0 THEN CAST('NaN' AS FLOAT64) ELSE LN(`bfcol_0`) END AS `bfcol_1` + CASE WHEN `bfcol_0` <= 0 THEN CAST('NaN' AS FLOAT64) ELSE LN(`bfcol_0`) END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log10/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log10/out.sql index b4cced439b..532776278d 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log10/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log10/out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE WHEN `bfcol_0` < 0 THEN CAST('NaN' AS FLOAT64) ELSE LOG(10, `bfcol_0`) END AS `bfcol_1` + CASE WHEN `bfcol_0` <= 0 THEN CAST('NaN' AS FLOAT64) ELSE LOG(10, `bfcol_0`) END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log1p/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log1p/out.sql index c3902ec174..3904025cf8 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log1p/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log1p/out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE WHEN `bfcol_0` < -1 THEN CAST('NaN' AS FLOAT64) ELSE LN(1 + `bfcol_0`) END AS `bfcol_1` + CASE WHEN `bfcol_0` <= -1 THEN CAST('NaN' AS FLOAT64) ELSE LN(1 + `bfcol_0`) END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/test_series_polars.py b/tests/unit/test_series_polars.py index 64814126ea..ee4ac245d3 100644 --- a/tests/unit/test_series_polars.py +++ b/tests/unit/test_series_polars.py @@ -4622,20 +4622,15 @@ def test_apply_lambda(scalars_dfs, col, lambda_): ) -@pytest.mark.skip( - reason="NotImplementedError: Polars compiler hasn't implemented log()" -) @pytest.mark.parametrize( ("ufunc",), [ - pytest.param(numpy.log), - pytest.param(numpy.sqrt), - pytest.param(numpy.sin), - ], - ids=[ - "log", - "sqrt", - "sin", + pytest.param(numpy.cos, id="cos"), + pytest.param(numpy.log, id="log"), + pytest.param(numpy.log10, id="log10"), + pytest.param(numpy.log1p, id="log1p"), + pytest.param(numpy.sqrt, id="sqrt"), + pytest.param(numpy.sin, id="sin"), ], ) def test_apply_numpy_ufunc(scalars_dfs, ufunc): From f9e28fe3f883cc4d486178fe241bc8b76473700f Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Wed, 15 Oct 2025 14:14:33 -0700 Subject: [PATCH 156/313] feat: enhanced multimodal error handling with verbose mode for blob image functions (#2024) * add error handling for one function, test is not clean * add verbose for image function * still have decoding issue with image normarlize to bytes * Add image normalize error handling * add eror handling for image functions * add error handling, mypy not clean * clean mypy * image function still have bug * test fix * fix testcase * Revert "fix testcase" This reverts commit 9a5bcd241990f74f4c4a06e9e4edd6713b8985a5. * Fix: Correctly handle destination blob series in image transformation functions * fix test * fix the bug for test_blob_image_*_to series and to_folder * fix test_blob_image_resize* and test_blob_image_normalize* * fix lint error * fix presubmit * fix mypy * cosmetic change * refactor testcase --- bigframes/blob/_functions.py | 468 +++++++++++++-------- bigframes/operations/blob.py | 219 ++++++++-- tests/system/large/blob/test_function.py | 508 ++++++++++++++++++++--- 3 files changed, 919 insertions(+), 276 deletions(-) diff --git a/bigframes/blob/_functions.py b/bigframes/blob/_functions.py index 8dd9328fb8..2a11974b8d 100644 --- a/bigframes/blob/_functions.py +++ b/bigframes/blob/_functions.py @@ -21,7 +21,13 @@ import bigframes.session import bigframes.session._io.bigquery as bf_io_bigquery -_PYTHON_TO_BQ_TYPES = {int: "INT64", float: "FLOAT64", str: "STRING", bytes: "BYTES"} +_PYTHON_TO_BQ_TYPES = { + int: "INT64", + float: "FLOAT64", + str: "STRING", + bytes: "BYTES", + bool: "BOOL", +} @dataclass(frozen=True) @@ -113,7 +119,7 @@ def udf(self): return self._session.read_gbq_function(udf_name) -def exif_func(src_obj_ref_rt: str) -> str: +def exif_func(src_obj_ref_rt: str, verbose: bool) -> str: import io import json @@ -121,25 +127,36 @@ def exif_func(src_obj_ref_rt: str) -> str: import requests from requests import adapters - session = requests.Session() - session.mount("https://", adapters.HTTPAdapter(max_retries=3)) - - src_obj_ref_rt_json = json.loads(src_obj_ref_rt) + result_dict = {"status": "", "content": "{}"} + try: + session = requests.Session() + session.mount("https://", adapters.HTTPAdapter(max_retries=3)) - src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + src_obj_ref_rt_json = json.loads(src_obj_ref_rt) - response = session.get(src_url, timeout=30) - bts = response.content + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] - image = Image.open(io.BytesIO(bts)) - exif_data = image.getexif() - exif_dict = {} - if exif_data: - for tag, value in exif_data.items(): - tag_name = ExifTags.TAGS.get(tag, tag) - exif_dict[tag_name] = value + response = session.get(src_url, timeout=30) + bts = response.content + + image = Image.open(io.BytesIO(bts)) + exif_data = image.getexif() + exif_dict = {} + if exif_data: + for tag, value in exif_data.items(): + tag_name = ExifTags.TAGS.get(tag, tag) + # Pillow might return bytes, which are not serializable. + if isinstance(value, bytes): + value = value.decode("utf-8", "replace") + exif_dict[tag_name] = value + result_dict["content"] = json.dumps(exif_dict) + except Exception as e: + result_dict["status"] = str(e) - return json.dumps(exif_dict) + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] exif_func_def = FunctionDef(exif_func, ["pillow", "requests"]) @@ -147,82 +164,109 @@ def exif_func(src_obj_ref_rt: str) -> str: # Blur images. Takes ObjectRefRuntime as JSON string. Outputs ObjectRefRuntime JSON string. def image_blur_func( - src_obj_ref_rt: str, dst_obj_ref_rt: str, ksize_x: int, ksize_y: int, ext: str + src_obj_ref_rt: str, + dst_obj_ref_rt: str, + ksize_x: int, + ksize_y: int, + ext: str, + verbose: bool, ) -> str: import json - import cv2 as cv # type: ignore - import numpy as np - import requests - from requests import adapters + result_dict = {"status": "", "content": dst_obj_ref_rt} - session = requests.Session() - session.mount("https://", adapters.HTTPAdapter(max_retries=3)) + try: + import cv2 as cv # type: ignore + import numpy as np + import requests + from requests import adapters + + session = requests.Session() + session.mount("https://", adapters.HTTPAdapter(max_retries=3)) + + ext = ext or ".jpeg" - ext = ext or ".jpeg" + src_obj_ref_rt_json = json.loads(src_obj_ref_rt) + dst_obj_ref_rt_json = json.loads(dst_obj_ref_rt) - src_obj_ref_rt_json = json.loads(src_obj_ref_rt) - dst_obj_ref_rt_json = json.loads(dst_obj_ref_rt) + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + dst_url = dst_obj_ref_rt_json["access_urls"]["write_url"] - src_url = src_obj_ref_rt_json["access_urls"]["read_url"] - dst_url = dst_obj_ref_rt_json["access_urls"]["write_url"] + response = session.get(src_url, timeout=30) + bts = response.content - response = session.get(src_url, timeout=30) - bts = response.content + nparr = np.frombuffer(bts, np.uint8) + img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + img_blurred = cv.blur(img, ksize=(ksize_x, ksize_y)) - nparr = np.frombuffer(bts, np.uint8) - img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) - img_blurred = cv.blur(img, ksize=(ksize_x, ksize_y)) + bts = cv.imencode(ext, img_blurred)[1].tobytes() - bts = cv.imencode(ext, img_blurred)[1].tobytes() + ext = ext.replace(".", "") + ext_mappings = {"jpg": "jpeg", "tif": "tiff"} + ext = ext_mappings.get(ext, ext) + content_type = "image/" + ext - ext = ext.replace(".", "") - ext_mappings = {"jpg": "jpeg", "tif": "tiff"} - ext = ext_mappings.get(ext, ext) - content_type = "image/" + ext + session.put( + url=dst_url, + data=bts, + headers={ + "Content-Type": content_type, + }, + timeout=30, + ) - session.put( - url=dst_url, - data=bts, - headers={ - "Content-Type": content_type, - }, - timeout=30, - ) + except Exception as e: + result_dict["status"] = str(e) - return dst_obj_ref_rt + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] image_blur_def = FunctionDef(image_blur_func, ["opencv-python", "numpy", "requests"]) def image_blur_to_bytes_func( - src_obj_ref_rt: str, ksize_x: int, ksize_y: int, ext: str -) -> bytes: + src_obj_ref_rt: str, ksize_x: int, ksize_y: int, ext: str, verbose: bool +) -> str: + import base64 import json - import cv2 as cv # type: ignore - import numpy as np - import requests - from requests import adapters + status = "" + content = b"" + + try: + import cv2 as cv # type: ignore + import numpy as np + import requests + from requests import adapters + + session = requests.Session() + session.mount("https://", adapters.HTTPAdapter(max_retries=3)) - session = requests.Session() - session.mount("https://", adapters.HTTPAdapter(max_retries=3)) + ext = ext or ".jpeg" - ext = ext or ".jpeg" + src_obj_ref_rt_json = json.loads(src_obj_ref_rt) + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] - src_obj_ref_rt_json = json.loads(src_obj_ref_rt) - src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + response = session.get(src_url, timeout=30) + bts = response.content - response = session.get(src_url, timeout=30) - bts = response.content + nparr = np.frombuffer(bts, np.uint8) + img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + img_blurred = cv.blur(img, ksize=(ksize_x, ksize_y)) + content = cv.imencode(ext, img_blurred)[1].tobytes() - nparr = np.frombuffer(bts, np.uint8) - img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) - img_blurred = cv.blur(img, ksize=(ksize_x, ksize_y)) - bts = cv.imencode(ext, img_blurred)[1].tobytes() + except Exception as e: + status = str(e) - return bts + encoded_content = base64.b64encode(content).decode("utf-8") + result_dict = {"status": status, "content": encoded_content} + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] image_blur_to_bytes_def = FunctionDef( @@ -238,49 +282,59 @@ def image_resize_func( fx: float, fy: float, ext: str, + verbose: bool, ) -> str: import json - import cv2 as cv # type: ignore - import numpy as np - import requests - from requests import adapters + result_dict = {"status": "", "content": dst_obj_ref_rt} + + try: + import cv2 as cv # type: ignore + import numpy as np + import requests + from requests import adapters + + session = requests.Session() + session.mount("https://", adapters.HTTPAdapter(max_retries=3)) - session = requests.Session() - session.mount("https://", adapters.HTTPAdapter(max_retries=3)) + ext = ext or ".jpeg" - ext = ext or ".jpeg" + src_obj_ref_rt_json = json.loads(src_obj_ref_rt) + dst_obj_ref_rt_json = json.loads(dst_obj_ref_rt) - src_obj_ref_rt_json = json.loads(src_obj_ref_rt) - dst_obj_ref_rt_json = json.loads(dst_obj_ref_rt) + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + dst_url = dst_obj_ref_rt_json["access_urls"]["write_url"] - src_url = src_obj_ref_rt_json["access_urls"]["read_url"] - dst_url = dst_obj_ref_rt_json["access_urls"]["write_url"] + response = session.get(src_url, timeout=30) + bts = response.content - response = session.get(src_url, timeout=30) - bts = response.content + nparr = np.frombuffer(bts, np.uint8) + img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + img_resized = cv.resize(img, dsize=(dsize_x, dsize_y), fx=fx, fy=fy) - nparr = np.frombuffer(bts, np.uint8) - img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) - img_resized = cv.resize(img, dsize=(dsize_x, dsize_y), fx=fx, fy=fy) + bts = cv.imencode(ext, img_resized)[1].tobytes() - bts = cv.imencode(ext, img_resized)[1].tobytes() + ext = ext.replace(".", "") + ext_mappings = {"jpg": "jpeg", "tif": "tiff"} + ext = ext_mappings.get(ext, ext) + content_type = "image/" + ext - ext = ext.replace(".", "") - ext_mappings = {"jpg": "jpeg", "tif": "tiff"} - ext = ext_mappings.get(ext, ext) - content_type = "image/" + ext + session.put( + url=dst_url, + data=bts, + headers={ + "Content-Type": content_type, + }, + timeout=30, + ) - session.put( - url=dst_url, - data=bts, - headers={ - "Content-Type": content_type, - }, - timeout=30, - ) + except Exception as e: + result_dict["status"] = str(e) - return dst_obj_ref_rt + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] image_resize_def = FunctionDef( @@ -295,31 +349,45 @@ def image_resize_to_bytes_func( fx: float, fy: float, ext: str, -) -> bytes: + verbose: bool, +) -> str: + import base64 import json - import cv2 as cv # type: ignore - import numpy as np - import requests - from requests import adapters + status = "" + content = b"" + + try: + import cv2 as cv # type: ignore + import numpy as np + import requests + from requests import adapters + + session = requests.Session() + session.mount("https://", adapters.HTTPAdapter(max_retries=3)) - session = requests.Session() - session.mount("https://", adapters.HTTPAdapter(max_retries=3)) + ext = ext or ".jpeg" - ext = ext or ".jpeg" + src_obj_ref_rt_json = json.loads(src_obj_ref_rt) + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] - src_obj_ref_rt_json = json.loads(src_obj_ref_rt) - src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + response = session.get(src_url, timeout=30) + bts = response.content - response = session.get(src_url, timeout=30) - bts = response.content + nparr = np.frombuffer(bts, np.uint8) + img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + img_resized = cv.resize(img, dsize=(dsize_x, dsize_y), fx=fx, fy=fy) + content = cv.imencode(".jpeg", img_resized)[1].tobytes() - nparr = np.frombuffer(bts, np.uint8) - img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) - img_resized = cv.resize(img, dsize=(dsize_x, dsize_y), fx=fx, fy=fy) - bts = cv.imencode(".jpeg", img_resized)[1].tobytes() + except Exception as e: + status = str(e) - return bts + encoded_content = base64.b64encode(content).decode("utf-8") + result_dict = {"status": status, "content": encoded_content} + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] image_resize_to_bytes_def = FunctionDef( @@ -334,58 +402,68 @@ def image_normalize_func( beta: float, norm_type: str, ext: str, + verbose: bool, ) -> str: import json - import cv2 as cv # type: ignore - import numpy as np - import requests - from requests import adapters + result_dict = {"status": "", "content": dst_obj_ref_rt} + + try: + import cv2 as cv # type: ignore + import numpy as np + import requests + from requests import adapters + + session = requests.Session() + session.mount("https://", adapters.HTTPAdapter(max_retries=3)) - session = requests.Session() - session.mount("https://", adapters.HTTPAdapter(max_retries=3)) + ext = ext or ".jpeg" - ext = ext or ".jpeg" + norm_type_mapping = { + "inf": cv.NORM_INF, + "l1": cv.NORM_L1, + "l2": cv.NORM_L2, + "minmax": cv.NORM_MINMAX, + } - norm_type_mapping = { - "inf": cv.NORM_INF, - "l1": cv.NORM_L1, - "l2": cv.NORM_L2, - "minmax": cv.NORM_MINMAX, - } + src_obj_ref_rt_json = json.loads(src_obj_ref_rt) + dst_obj_ref_rt_json = json.loads(dst_obj_ref_rt) - src_obj_ref_rt_json = json.loads(src_obj_ref_rt) - dst_obj_ref_rt_json = json.loads(dst_obj_ref_rt) + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + dst_url = dst_obj_ref_rt_json["access_urls"]["write_url"] - src_url = src_obj_ref_rt_json["access_urls"]["read_url"] - dst_url = dst_obj_ref_rt_json["access_urls"]["write_url"] + response = session.get(src_url, timeout=30) + bts = response.content - response = session.get(src_url, timeout=30) - bts = response.content + nparr = np.frombuffer(bts, np.uint8) + img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + img_normalized = cv.normalize( + img, None, alpha=alpha, beta=beta, norm_type=norm_type_mapping[norm_type] + ) - nparr = np.frombuffer(bts, np.uint8) - img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) - img_normalized = cv.normalize( - img, None, alpha=alpha, beta=beta, norm_type=norm_type_mapping[norm_type] - ) + bts = cv.imencode(ext, img_normalized)[1].tobytes() - bts = cv.imencode(ext, img_normalized)[1].tobytes() + ext = ext.replace(".", "") + ext_mappings = {"jpg": "jpeg", "tif": "tiff"} + ext = ext_mappings.get(ext, ext) + content_type = "image/" + ext - ext = ext.replace(".", "") - ext_mappings = {"jpg": "jpeg", "tif": "tiff"} - ext = ext_mappings.get(ext, ext) - content_type = "image/" + ext + session.put( + url=dst_url, + data=bts, + headers={ + "Content-Type": content_type, + }, + timeout=30, + ) - session.put( - url=dst_url, - data=bts, - headers={ - "Content-Type": content_type, - }, - timeout=30, - ) + except Exception as e: + result_dict["status"] = str(e) - return dst_obj_ref_rt + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] image_normalize_def = FunctionDef( @@ -394,41 +472,59 @@ def image_normalize_func( def image_normalize_to_bytes_func( - src_obj_ref_rt: str, alpha: float, beta: float, norm_type: str, ext: str -) -> bytes: + src_obj_ref_rt: str, + alpha: float, + beta: float, + norm_type: str, + ext: str, + verbose: bool, +) -> str: + import base64 import json - import cv2 as cv # type: ignore - import numpy as np - import requests - from requests import adapters + result_dict = {"status": "", "content": ""} + + try: + import cv2 as cv # type: ignore + import numpy as np + import requests + from requests import adapters + + session = requests.Session() + session.mount("https://", adapters.HTTPAdapter(max_retries=3)) - session = requests.Session() - session.mount("https://", adapters.HTTPAdapter(max_retries=3)) + ext = ext or ".jpeg" - ext = ext or ".jpeg" + norm_type_mapping = { + "inf": cv.NORM_INF, + "l1": cv.NORM_L1, + "l2": cv.NORM_L2, + "minmax": cv.NORM_MINMAX, + } - norm_type_mapping = { - "inf": cv.NORM_INF, - "l1": cv.NORM_L1, - "l2": cv.NORM_L2, - "minmax": cv.NORM_MINMAX, - } + src_obj_ref_rt_json = json.loads(src_obj_ref_rt) + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + + response = session.get(src_url, timeout=30) + bts = response.content - src_obj_ref_rt_json = json.loads(src_obj_ref_rt) - src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + nparr = np.frombuffer(bts, np.uint8) + img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + img_normalized = cv.normalize( + img, None, alpha=alpha, beta=beta, norm_type=norm_type_mapping[norm_type] + ) + bts = cv.imencode(".jpeg", img_normalized)[1].tobytes() - response = session.get(src_url, timeout=30) - bts = response.content + content_b64 = base64.b64encode(bts).decode("utf-8") + result_dict["content"] = content_b64 - nparr = np.frombuffer(bts, np.uint8) - img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) - img_normalized = cv.normalize( - img, None, alpha=alpha, beta=beta, norm_type=norm_type_mapping[norm_type] - ) - bts = cv.imencode(".jpeg", img_normalized)[1].tobytes() + except Exception as e: + result_dict["status"] = str(e) - return bts + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] image_normalize_to_bytes_def = FunctionDef( @@ -437,7 +533,7 @@ def image_normalize_to_bytes_func( # Extracts all text from a PDF url -def pdf_extract_func(src_obj_ref_rt: str) -> str: +def pdf_extract_func(src_obj_ref_rt: str, verbose: bool) -> str: try: import io import json @@ -470,8 +566,10 @@ def pdf_extract_func(src_obj_ref_rt: str) -> str: except Exception as e: result_dict = {"status": str(e), "content": ""} - result_json = json.dumps(result_dict) - return result_json + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] pdf_extract_def = FunctionDef( @@ -480,7 +578,9 @@ def pdf_extract_func(src_obj_ref_rt: str) -> str: # Extracts text from a PDF url and chunks it simultaneously -def pdf_chunk_func(src_obj_ref_rt: str, chunk_size: int, overlap_size: int) -> str: +def pdf_chunk_func( + src_obj_ref_rt: str, chunk_size: int, overlap_size: int, verbose: bool +) -> str: try: import io import json @@ -526,8 +626,10 @@ def pdf_chunk_func(src_obj_ref_rt: str, chunk_size: int, overlap_size: int) -> s except Exception as e: result_dict = {"status": str(e), "content": []} - result_json = json.dumps(result_dict) - return result_json + if verbose: + return json.dumps(result_dict) + else: + return json.dumps(result_dict["content"]) pdf_chunk_def = FunctionDef( diff --git a/bigframes/operations/blob.py b/bigframes/operations/blob.py index d505f096f4..4da9bfee82 100644 --- a/bigframes/operations/blob.py +++ b/bigframes/operations/blob.py @@ -22,7 +22,7 @@ import pandas as pd import requests -from bigframes import clients +from bigframes import clients, dtypes from bigframes.core import log_adapter import bigframes.dataframe import bigframes.exceptions as bfe @@ -80,9 +80,20 @@ def metadata(self) -> bigframes.series.Series: Returns: bigframes.series.Series: JSON metadata of the Blob. Contains fields: content_type, md5_hash, size and updated(time).""" - details_json = self._apply_unary_op(ops.obj_fetch_metadata_op).struct.field( - "details" - ) + series_to_check = bigframes.series.Series(self._block) + # Check if it's a struct series from a verbose operation + if dtypes.is_struct_like(series_to_check.dtype): + pyarrow_dtype = series_to_check.dtype.pyarrow_dtype + if "content" in [field.name for field in pyarrow_dtype]: + content_field_type = pyarrow_dtype.field("content").type + content_bf_type = dtypes.arrow_dtype_to_bigframes_dtype( + content_field_type + ) + if content_bf_type == dtypes.OBJ_REF_DTYPE: + series_to_check = series_to_check.struct.field("content") + details_json = series_to_check._apply_unary_op( + ops.obj_fetch_metadata_op + ).struct.field("details") import bigframes.bigquery as bbq return bbq.json_extract(details_json, "$.gcs_metadata").rename("metadata") @@ -313,6 +324,7 @@ def exif( max_batching_rows: int = 8192, container_cpu: Union[float, int] = 0.33, container_memory: str = "512Mi", + verbose: bool = False, ) -> bigframes.series.Series: """Extract EXIF data. Now only support image types. @@ -322,18 +334,21 @@ def exif( max_batching_rows (int, default 8,192): Max number of rows per batch send to cloud run to execute the function. container_cpu (int or float, default 0.33): number of container CPUs. Possible values are [0.33, 8]. Floats larger than 1 are cast to intergers. container_memory (str, default "512Mi"): container memory size. String of the format . Possible values are from 512Mi to 32Gi. + verbose (bool, default False): If True, returns a struct with status and content fields. If False, returns only the content. Returns: - bigframes.series.Series: JSON series of key-value pairs. + bigframes.series.Series: JSON series of key-value pairs if verbose=False, or struct with status and content if verbose=True. """ if engine is None or engine.casefold() != "pillow": raise ValueError("Must specify the engine, supported value is 'pillow'.") import bigframes.bigquery as bbq import bigframes.blob._functions as blob_func + import bigframes.pandas as bpd connection = self._resolve_connection(connection) df = self.get_runtime_json_str(mode="R").to_frame() + df["verbose"] = verbose exif_udf = blob_func.TransformFunction( blob_func.exif_func_def, @@ -345,9 +360,21 @@ def exif( ).udf() res = self._df_apply_udf(df, exif_udf) - res = bbq.parse_json(res) - return res + if verbose: + exif_content_series = bbq.parse_json( + res._apply_unary_op(ops.JSONValue(json_path="$.content")) + ).rename("exif_content") + exif_status_series = res._apply_unary_op( + ops.JSONValue(json_path="$.status") + ) + results_df = bpd.DataFrame( + {"status": exif_status_series, "content": exif_content_series} + ) + results_struct = bbq.struct(results_df).rename("exif_results") + return results_struct + else: + return bbq.parse_json(res) def image_blur( self, @@ -359,6 +386,7 @@ def image_blur( max_batching_rows: int = 8192, container_cpu: Union[float, int] = 0.33, container_memory: str = "512Mi", + verbose: bool = False, ) -> bigframes.series.Series: """Blurs images. @@ -374,14 +402,17 @@ def image_blur( max_batching_rows (int, default 8,192): Max number of rows per batch send to cloud run to execute the function. container_cpu (int or float, default 0.33): number of container CPUs. Possible values are [0.33, 8]. Floats larger than 1 are cast to intergers. container_memory (str, default "512Mi"): container memory size. String of the format . Possible values are from 512Mi to 32Gi. + verbose (bool, default False): If True, returns a struct with status and content fields. If False, returns only the content. Returns: - bigframes.series.Series: blob Series if destination is GCS. Or bytes Series if destination is BQ. + bigframes.series.Series: blob Series if destination is GCS. Or bytes Series if destination is BQ. If verbose=True, returns struct with status and content. """ if engine is None or engine.casefold() != "opencv": raise ValueError("Must specify the engine, supported value is 'opencv'.") + import bigframes.bigquery as bbq import bigframes.blob._functions as blob_func + import bigframes.pandas as bpd connection = self._resolve_connection(connection) df = self.get_runtime_json_str(mode="R").to_frame() @@ -400,9 +431,29 @@ def image_blur( df["ksize_x"], df["ksize_y"] = ksize df["ext"] = ext # type: ignore + df["verbose"] = verbose res = self._df_apply_udf(df, image_blur_udf) - return res + if verbose: + blurred_content_b64_series = res._apply_unary_op( + ops.JSONValue(json_path="$.content") + ) + blurred_content_series = bbq.sql_scalar( + "FROM_BASE64({0})", columns=[blurred_content_b64_series] + ) + blurred_status_series = res._apply_unary_op( + ops.JSONValue(json_path="$.status") + ) + results_df = bpd.DataFrame( + {"status": blurred_status_series, "content": blurred_content_series} + ) + results_struct = bbq.struct(results_df).rename("blurred_results") + return results_struct + else: + blurred_bytes = bbq.sql_scalar( + "FROM_BASE64({0})", columns=[res] + ).rename("blurred_bytes") + return blurred_bytes if isinstance(dst, str): dst = os.path.join(dst, "") @@ -428,11 +479,27 @@ def image_blur( df = df.join(dst_rt, how="outer") df["ksize_x"], df["ksize_y"] = ksize df["ext"] = ext # type: ignore + df["verbose"] = verbose res = self._df_apply_udf(df, image_blur_udf) res.cache() # to execute the udf - return dst + if verbose: + blurred_status_series = res._apply_unary_op( + ops.JSONValue(json_path="$.status") + ) + results_df = bpd.DataFrame( + { + "status": blurred_status_series, + "content": dst.blob.uri().str.to_blob( + connection=self._resolve_connection(connection) + ), + } + ) + results_struct = bbq.struct(results_df).rename("blurred_results") + return results_struct + else: + return dst def image_resize( self, @@ -446,6 +513,7 @@ def image_resize( max_batching_rows: int = 8192, container_cpu: Union[float, int] = 0.33, container_memory: str = "512Mi", + verbose: bool = False, ): """Resize images. @@ -463,9 +531,10 @@ def image_resize( max_batching_rows (int, default 8,192): Max number of rows per batch send to cloud run to execute the function. container_cpu (int or float, default 0.33): number of container CPUs. Possible values are [0.33, 8]. Floats larger than 1 are cast to intergers. container_memory (str, default "512Mi"): container memory size. String of the format . Possible values are from 512Mi to 32Gi. + verbose (bool, default False): If True, returns a struct with status and content fields. If False, returns only the content. Returns: - bigframes.series.Series: blob Series if destination is GCS. Or bytes Series if destination is BQ. + bigframes.series.Series: blob Series if destination is GCS. Or bytes Series if destination is BQ. If verbose=True, returns struct with status and content. """ if engine is None or engine.casefold() != "opencv": raise ValueError("Must specify the engine, supported value is 'opencv'.") @@ -477,7 +546,9 @@ def image_resize( "Only one of dsize or (fx, fy) parameters must be set. And the set values must be positive. " ) + import bigframes.bigquery as bbq import bigframes.blob._functions as blob_func + import bigframes.pandas as bpd connection = self._resolve_connection(connection) df = self.get_runtime_json_str(mode="R").to_frame() @@ -497,9 +568,30 @@ def image_resize( df["dsize_x"], df["dsizye_y"] = dsize df["fx"], df["fy"] = fx, fy df["ext"] = ext # type: ignore + df["verbose"] = verbose res = self._df_apply_udf(df, image_resize_udf) - return res + if verbose: + resized_content_b64_series = res._apply_unary_op( + ops.JSONValue(json_path="$.content") + ) + resized_content_series = bbq.sql_scalar( + "FROM_BASE64({0})", columns=[resized_content_b64_series] + ) + + resized_status_series = res._apply_unary_op( + ops.JSONValue(json_path="$.status") + ) + results_df = bpd.DataFrame( + {"status": resized_status_series, "content": resized_content_series} + ) + results_struct = bbq.struct(results_df).rename("resized_results") + return results_struct + else: + resized_bytes = bbq.sql_scalar( + "FROM_BASE64({0})", columns=[res] + ).rename("resized_bytes") + return resized_bytes if isinstance(dst, str): dst = os.path.join(dst, "") @@ -526,11 +618,27 @@ def image_resize( df["dsize_x"], df["dsizye_y"] = dsize df["fx"], df["fy"] = fx, fy df["ext"] = ext # type: ignore + df["verbose"] = verbose res = self._df_apply_udf(df, image_resize_udf) res.cache() # to execute the udf - return dst + if verbose: + resized_status_series = res._apply_unary_op( + ops.JSONValue(json_path="$.status") + ) + results_df = bpd.DataFrame( + { + "status": resized_status_series, + "content": dst.blob.uri().str.to_blob( + connection=self._resolve_connection(connection) + ), + } + ) + results_struct = bbq.struct(results_df).rename("resized_results") + return results_struct + else: + return dst def image_normalize( self, @@ -544,6 +652,7 @@ def image_normalize( max_batching_rows: int = 8192, container_cpu: Union[float, int] = 0.33, container_memory: str = "512Mi", + verbose: bool = False, ) -> bigframes.series.Series: """Normalize images. @@ -561,14 +670,17 @@ def image_normalize( max_batching_rows (int, default 8,192): Max number of rows per batch send to cloud run to execute the function. container_cpu (int or float, default 0.33): number of container CPUs. Possible values are [0.33, 8]. Floats larger than 1 are cast to intergers. container_memory (str, default "512Mi"): container memory size. String of the format . Possible values are from 512Mi to 32Gi. + verbose (bool, default False): If True, returns a struct with status and content fields. If False, returns only the content. Returns: - bigframes.series.Series: blob Series if destination is GCS. Or bytes Series if destination is BQ. + bigframes.series.Series: blob Series if destination is GCS. Or bytes Series if destination is BQ. If verbose=True, returns struct with status and content. """ if engine is None or engine.casefold() != "opencv": raise ValueError("Must specify the engine, supported value is 'opencv'.") + import bigframes.bigquery as bbq import bigframes.blob._functions as blob_func + import bigframes.pandas as bpd connection = self._resolve_connection(connection) df = self.get_runtime_json_str(mode="R").to_frame() @@ -589,9 +701,29 @@ def image_normalize( df["beta"] = beta df["norm_type"] = norm_type df["ext"] = ext # type: ignore + df["verbose"] = verbose res = self._df_apply_udf(df, image_normalize_udf) - return res + if verbose: + normalized_content_b64_series = res._apply_unary_op( + ops.JSONValue(json_path="$.content") + ) + normalized_bytes = bbq.sql_scalar( + "FROM_BASE64({0})", columns=[normalized_content_b64_series] + ) + normalized_status_series = res._apply_unary_op( + ops.JSONValue(json_path="$.status") + ) + results_df = bpd.DataFrame( + {"status": normalized_status_series, "content": normalized_bytes} + ) + results_struct = bbq.struct(results_df).rename("normalized_results") + return results_struct + else: + normalized_bytes = bbq.sql_scalar( + "FROM_BASE64({0})", columns=[res] + ).rename("normalized_bytes") + return normalized_bytes if isinstance(dst, str): dst = os.path.join(dst, "") @@ -619,11 +751,27 @@ def image_normalize( df["beta"] = beta df["norm_type"] = norm_type df["ext"] = ext # type: ignore + df["verbose"] = verbose res = self._df_apply_udf(df, image_normalize_udf) res.cache() # to execute the udf - return dst + if verbose: + normalized_status_series = res._apply_unary_op( + ops.JSONValue(json_path="$.status") + ) + results_df = bpd.DataFrame( + { + "status": normalized_status_series, + "content": dst.blob.uri().str.to_blob( + connection=self._resolve_connection(connection) + ), + } + ) + results_struct = bbq.struct(results_df).rename("normalized_results") + return results_struct + else: + return dst def pdf_extract( self, @@ -675,19 +823,22 @@ def pdf_extract( container_memory=container_memory, ).udf() - src_rt = self.get_runtime_json_str(mode="R") - - res = src_rt.apply(pdf_extract_udf) - - content_series = res._apply_unary_op(ops.JSONValue(json_path="$.content")) + df = self.get_runtime_json_str(mode="R").to_frame() + df["verbose"] = verbose + res = self._df_apply_udf(df, pdf_extract_udf) if verbose: + extracted_content_series = res._apply_unary_op( + ops.JSONValue(json_path="$.content") + ) status_series = res._apply_unary_op(ops.JSONValue(json_path="$.status")) - res_df = bpd.DataFrame({"status": status_series, "content": content_series}) - struct_series = bbq.struct(res_df) - return struct_series + results_df = bpd.DataFrame( + {"status": status_series, "content": extracted_content_series} + ) + results_struct = bbq.struct(results_df).rename("extracted_results") + return results_struct else: - return content_series + return res.rename("extracted_content") def pdf_chunk( self, @@ -754,21 +905,23 @@ def pdf_chunk( container_memory=container_memory, ).udf() - src_rt = self.get_runtime_json_str(mode="R") - df = src_rt.to_frame() + df = self.get_runtime_json_str(mode="R").to_frame() df["chunk_size"] = chunk_size df["overlap_size"] = overlap_size + df["verbose"] = verbose res = self._df_apply_udf(df, pdf_chunk_udf) - content_series = bbq.json_extract_string_array(res, "$.content") if verbose: + chunked_content_series = bbq.json_extract_string_array(res, "$.content") status_series = res._apply_unary_op(ops.JSONValue(json_path="$.status")) - res_df = bpd.DataFrame({"status": status_series, "content": content_series}) - struct_series = bbq.struct(res_df) - return struct_series + results_df = bpd.DataFrame( + {"status": status_series, "content": chunked_content_series} + ) + resultes_struct = bbq.struct(results_df).rename("chunked_results") + return resultes_struct else: - return content_series + return bbq.json_extract_string_array(res, "$").rename("chunked_content") def audio_transcribe( self, @@ -841,4 +994,4 @@ def audio_transcribe( results_struct = bbq.struct(results_df).rename("transcription_results") return results_struct else: - return transcribed_content_series + return transcribed_content_series.rename("transcribed_content") diff --git a/tests/system/large/blob/test_function.py b/tests/system/large/blob/test_function.py index f006395d2f..7963fabd0b 100644 --- a/tests/system/large/blob/test_function.py +++ b/tests/system/large/blob/test_function.py @@ -63,7 +63,7 @@ def test_blob_exif( ) actual = exif_image_df["blob_col"].blob.exif( - engine="pillow", connection=bq_connection + engine="pillow", connection=bq_connection, verbose=False ) expected = bpd.Series( ['{"ExifOffset": 47, "Make": "MyCamera"}'], @@ -78,6 +78,31 @@ def test_blob_exif( ) +def test_blob_exif_verbose( + bq_connection: str, + session: bigframes.Session, +): + exif_image_df = session.from_glob_path( + "gs://bigframes_blob_test/images_exif/*", + name="blob_col", + connection=bq_connection, + ) + + actual = exif_image_df["blob_col"].blob.exif( + engine="pillow", connection=bq_connection, verbose=True + ) + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + assert content_series.dtype == dtypes.JSON_DTYPE + + def test_blob_image_blur_to_series( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -89,8 +114,9 @@ def test_blob_image_blur_to_series( ) actual = images_mm_df["blob_col"].blob.image_blur( - (8, 8), dst=series, connection=bq_connection, engine="opencv" + (8, 8), dst=series, connection=bq_connection, engine="opencv", verbose=False ) + expected_df = pd.DataFrame( { "uri": images_output_uris, @@ -110,6 +136,33 @@ def test_blob_image_blur_to_series( assert not actual.blob.size().isna().any() +def test_blob_image_blur_to_series_verbose( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_uris: list[str], + session: bigframes.Session, +): + series = bpd.Series(images_output_uris, session=session).str.to_blob( + connection=bq_connection + ) + + actual = images_mm_df["blob_col"].blob.image_blur( + (8, 8), dst=series, connection=bq_connection, engine="opencv", verbose=True + ) + + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + # Content should be blob objects for GCS destination + # verify the files exist + assert not actual.blob.size().isna().any() + + def test_blob_image_blur_to_folder( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -117,7 +170,11 @@ def test_blob_image_blur_to_folder( images_output_uris: list[str], ): actual = images_mm_df["blob_col"].blob.image_blur( - (8, 8), dst=images_output_folder, connection=bq_connection, engine="opencv" + (8, 8), + dst=images_output_folder, + connection=bq_connection, + engine="opencv", + verbose=False, ) expected_df = pd.DataFrame( { @@ -138,9 +195,38 @@ def test_blob_image_blur_to_folder( assert not actual.blob.size().isna().any() +def test_blob_image_blur_to_folder_verbose( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_folder: str, + images_output_uris: list[str], +): + actual = images_mm_df["blob_col"].blob.image_blur( + (8, 8), + dst=images_output_folder, + connection=bq_connection, + engine="opencv", + verbose=True, + ) + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + # Content should be blob objects for GCS destination + assert hasattr(content_series, "blob") + + # verify the files exist + assert not actual.blob.size().isna().any() + + def test_blob_image_blur_to_bq(images_mm_df: bpd.DataFrame, bq_connection: str): actual = images_mm_df["blob_col"].blob.image_blur( - (8, 8), connection=bq_connection, engine="opencv" + (8, 8), connection=bq_connection, engine="opencv", verbose=False ) assert isinstance(actual, bpd.Series) @@ -148,6 +234,26 @@ def test_blob_image_blur_to_bq(images_mm_df: bpd.DataFrame, bq_connection: str): assert actual.dtype == dtypes.BYTES_DTYPE +def test_blob_image_blur_to_bq_verbose(images_mm_df: bpd.DataFrame, bq_connection: str): + actual = images_mm_df["blob_col"].blob.image_blur( + (8, 8), connection=bq_connection, engine="opencv", verbose=True + ) + + assert isinstance(actual, bpd.Series) + assert len(actual) == 2 + + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + assert content_series.dtype == dtypes.BYTES_DTYPE + + def test_blob_image_resize_to_series( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -159,8 +265,13 @@ def test_blob_image_resize_to_series( ) actual = images_mm_df["blob_col"].blob.image_resize( - (200, 300), dst=series, connection=bq_connection, engine="opencv" + (200, 300), + dst=series, + connection=bq_connection, + engine="opencv", + verbose=False, ) + expected_df = pd.DataFrame( { "uri": images_output_uris, @@ -180,6 +291,40 @@ def test_blob_image_resize_to_series( assert not actual.blob.size().isna().any() +def test_blob_image_resize_to_series_verbose( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_uris: list[str], + session: bigframes.Session, +): + series = bpd.Series(images_output_uris, session=session).str.to_blob( + connection=bq_connection + ) + + actual = images_mm_df["blob_col"].blob.image_resize( + (200, 300), + dst=series, + connection=bq_connection, + engine="opencv", + verbose=True, + ) + + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + # Content should be blob objects for GCS destination + assert hasattr(content_series, "blob") + + # verify the files exist + assert not actual.blob.size().isna().any() + + def test_blob_image_resize_to_folder( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -187,8 +332,13 @@ def test_blob_image_resize_to_folder( images_output_uris: list[str], ): actual = images_mm_df["blob_col"].blob.image_resize( - (200, 300), dst=images_output_folder, connection=bq_connection, engine="opencv" + (200, 300), + dst=images_output_folder, + connection=bq_connection, + engine="opencv", + verbose=False, ) + expected_df = pd.DataFrame( { "uri": images_output_uris, @@ -208,9 +358,39 @@ def test_blob_image_resize_to_folder( assert not actual.blob.size().isna().any() +def test_blob_image_resize_to_folder_verbose( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_folder: str, + images_output_uris: list[str], +): + actual = images_mm_df["blob_col"].blob.image_resize( + (200, 300), + dst=images_output_folder, + connection=bq_connection, + engine="opencv", + verbose=True, + ) + + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + # Content should be blob objects for GCS destination + assert hasattr(content_series, "blob") + + # verify the files exist + assert not content_series.blob.size().isna().any() + + def test_blob_image_resize_to_bq(images_mm_df: bpd.DataFrame, bq_connection: str): actual = images_mm_df["blob_col"].blob.image_resize( - (200, 300), connection=bq_connection, engine="opencv" + (200, 300), connection=bq_connection, engine="opencv", verbose=False ) assert isinstance(actual, bpd.Series) @@ -218,6 +398,28 @@ def test_blob_image_resize_to_bq(images_mm_df: bpd.DataFrame, bq_connection: str assert actual.dtype == dtypes.BYTES_DTYPE +def test_blob_image_resize_to_bq_verbose( + images_mm_df: bpd.DataFrame, bq_connection: str +): + actual = images_mm_df["blob_col"].blob.image_resize( + (200, 300), connection=bq_connection, engine="opencv", verbose=True + ) + + assert isinstance(actual, bpd.Series) + assert len(actual) == 2 + + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + assert content_series.dtype == dtypes.BYTES_DTYPE + + def test_blob_image_normalize_to_series( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -235,7 +437,9 @@ def test_blob_image_normalize_to_series( dst=series, connection=bq_connection, engine="opencv", + verbose=False, ) + expected_df = pd.DataFrame( { "uri": images_output_uris, @@ -255,6 +459,39 @@ def test_blob_image_normalize_to_series( assert not actual.blob.size().isna().any() +def test_blob_image_normalize_to_series_verbose( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_uris: list[str], + session: bigframes.Session, +): + series = bpd.Series(images_output_uris, session=session).str.to_blob( + connection=bq_connection + ) + + actual = images_mm_df["blob_col"].blob.image_normalize( + alpha=50.0, + beta=150.0, + norm_type="minmax", + dst=series, + connection=bq_connection, + engine="opencv", + verbose=True, + ) + + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + # Content should be blob objects for GCS destination + assert hasattr(content_series, "blob") + + def test_blob_image_normalize_to_folder( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -268,7 +505,9 @@ def test_blob_image_normalize_to_folder( dst=images_output_folder, connection=bq_connection, engine="opencv", + verbose=False, ) + expected_df = pd.DataFrame( { "uri": images_output_uris, @@ -288,6 +527,35 @@ def test_blob_image_normalize_to_folder( assert not actual.blob.size().isna().any() +def test_blob_image_normalize_to_folder_verbose( + images_mm_df: bpd.DataFrame, + bq_connection: str, + images_output_folder: str, + images_output_uris: list[str], +): + actual = images_mm_df["blob_col"].blob.image_normalize( + alpha=50.0, + beta=150.0, + norm_type="minmax", + dst=images_output_folder, + connection=bq_connection, + engine="opencv", + verbose=True, + ) + + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + # Content should be blob objects for GCS destination + assert hasattr(content_series, "blob") + + def test_blob_image_normalize_to_bq(images_mm_df: bpd.DataFrame, bq_connection: str): actual = images_mm_df["blob_col"].blob.image_normalize( alpha=50.0, @@ -295,6 +563,7 @@ def test_blob_image_normalize_to_bq(images_mm_df: bpd.DataFrame, bq_connection: norm_type="minmax", connection=bq_connection, engine="opencv", + verbose=False, ) assert isinstance(actual, bpd.Series) @@ -302,21 +571,40 @@ def test_blob_image_normalize_to_bq(images_mm_df: bpd.DataFrame, bq_connection: assert actual.dtype == dtypes.BYTES_DTYPE -@pytest.mark.parametrize( - "verbose", - [ - (True), - (False), - ], -) +def test_blob_image_normalize_to_bq_verbose( + images_mm_df: bpd.DataFrame, bq_connection: str +): + actual = images_mm_df["blob_col"].blob.image_normalize( + alpha=50.0, + beta=150.0, + norm_type="minmax", + connection=bq_connection, + engine="opencv", + verbose=True, + ) + + assert isinstance(actual, bpd.Series) + assert len(actual) == 2 + + assert hasattr(actual, "struct") + actual_exploded = actual.struct.explode() + assert "status" in actual_exploded.columns + assert "content" in actual_exploded.columns + + status_series = actual_exploded["status"] + assert status_series.dtype == dtypes.STRING_DTYPE + + content_series = actual_exploded["content"] + assert content_series.dtype == dtypes.BYTES_DTYPE + + def test_blob_pdf_extract( pdf_mm_df: bpd.DataFrame, - verbose: bool, bq_connection: str, ): actual = ( pdf_mm_df["pdf"] - .blob.pdf_extract(connection=bq_connection, verbose=verbose, engine="pypdf") + .blob.pdf_extract(connection=bq_connection, verbose=False, engine="pypdf") .explode() .to_pandas() ) @@ -325,20 +613,14 @@ def test_blob_pdf_extract( expected_text = "Sample PDF This is a testing file. Some dummy messages are used for testing purposes." expected_len = len(expected_text) - actual_text = "" - if verbose: - # The first entry is for a file that doesn't exist, so we check the second one - successful_results = actual[actual.apply(lambda x: x["status"] == "")] - actual_text = successful_results.apply(lambda x: x["content"]).iloc[0] - else: - actual_text = actual[actual != ""].iloc[0] + actual_text = actual[actual != ""].iloc[0] actual_len = len(actual_text) relative_length_tolerance = 0.25 min_acceptable_len = expected_len * (1 - relative_length_tolerance) max_acceptable_len = expected_len * (1 + relative_length_tolerance) assert min_acceptable_len <= actual_len <= max_acceptable_len, ( - f"Item (verbose={verbose}): Extracted text length {actual_len} is outside the acceptable range " + f"Item (verbose=False): Extracted text length {actual_len} is outside the acceptable range " f"[{min_acceptable_len:.0f}, {max_acceptable_len:.0f}]. " f"Expected reference length was {expected_len}. " ) @@ -348,24 +630,93 @@ def test_blob_pdf_extract( for keyword in major_keywords: assert ( keyword.lower() in actual_text.lower() - ), f"Item (verbose={verbose}): Expected keyword '{keyword}' not found in extracted text. " + ), f"Item (verbose=False): Expected keyword '{keyword}' not found in extracted text. " -@pytest.mark.parametrize( - "verbose", - [ - (True), - (False), - ], -) -def test_blob_pdf_chunk(pdf_mm_df: bpd.DataFrame, verbose: bool, bq_connection: str): +def test_blob_pdf_extract_verbose( + pdf_mm_df: bpd.DataFrame, + bq_connection: str, +): + actual = ( + pdf_mm_df["pdf"] + .blob.pdf_extract(connection=bq_connection, verbose=True, engine="pypdf") + .explode() + .to_pandas() + ) + + # check relative length + expected_text = "Sample PDF This is a testing file. Some dummy messages are used for testing purposes." + expected_len = len(expected_text) + + # The first entry is for a file that doesn't exist, so we check the second one + successful_results = actual[actual.apply(lambda x: x["status"] == "")] + actual_text = successful_results.apply(lambda x: x["content"]).iloc[0] + actual_len = len(actual_text) + + relative_length_tolerance = 0.25 + min_acceptable_len = expected_len * (1 - relative_length_tolerance) + max_acceptable_len = expected_len * (1 + relative_length_tolerance) + assert min_acceptable_len <= actual_len <= max_acceptable_len, ( + f"Item (verbose=True): Extracted text length {actual_len} is outside the acceptable range " + f"[{min_acceptable_len:.0f}, {max_acceptable_len:.0f}]. " + f"Expected reference length was {expected_len}. " + ) + + # check for major keywords + major_keywords = ["Sample", "PDF", "testing", "dummy", "messages"] + for keyword in major_keywords: + assert ( + keyword.lower() in actual_text.lower() + ), f"Item (verbose=True): Expected keyword '{keyword}' not found in extracted text. " + + +def test_blob_pdf_chunk(pdf_mm_df: bpd.DataFrame, bq_connection: str): + actual = ( + pdf_mm_df["pdf"] + .blob.pdf_chunk( + connection=bq_connection, + chunk_size=50, + overlap_size=10, + verbose=False, + engine="pypdf", + ) + .explode() + .to_pandas() + ) + + # check relative length + expected_text = "Sample PDF This is a testing file. Some dummy messages are used for testing purposes." + expected_len = len(expected_text) + + # First entry is NA + actual_text = "".join(actual.dropna()) + actual_len = len(actual_text) + + relative_length_tolerance = 0.25 + min_acceptable_len = expected_len * (1 - relative_length_tolerance) + max_acceptable_len = expected_len * (1 + relative_length_tolerance) + assert min_acceptable_len <= actual_len <= max_acceptable_len, ( + f"Item (verbose=False): Extracted text length {actual_len} is outside the acceptable range " + f"[{min_acceptable_len:.0f}, {max_acceptable_len:.0f}]. " + f"Expected reference length was {expected_len}. " + ) + + # check for major keywords + major_keywords = ["Sample", "PDF", "testing", "dummy", "messages"] + for keyword in major_keywords: + assert ( + keyword.lower() in actual_text.lower() + ), f"Item (verbose=False): Expected keyword '{keyword}' not found in extracted text. " + + +def test_blob_pdf_chunk_verbose(pdf_mm_df: bpd.DataFrame, bq_connection: str): actual = ( pdf_mm_df["pdf"] .blob.pdf_chunk( connection=bq_connection, chunk_size=50, overlap_size=10, - verbose=verbose, + verbose=True, engine="pypdf", ) .explode() @@ -376,21 +727,16 @@ def test_blob_pdf_chunk(pdf_mm_df: bpd.DataFrame, verbose: bool, bq_connection: expected_text = "Sample PDF This is a testing file. Some dummy messages are used for testing purposes." expected_len = len(expected_text) - actual_text = "" - if verbose: - # The first entry is for a file that doesn't exist, so we check the second one - successful_results = actual[actual.apply(lambda x: x["status"] == "")] - actual_text = "".join(successful_results.apply(lambda x: x["content"]).iloc[0]) - else: - # First entry is NA - actual_text = "".join(actual.dropna()) + # The first entry is for a file that doesn't exist, so we check the second one + successful_results = actual[actual.apply(lambda x: x["status"] == "")] + actual_text = "".join(successful_results.apply(lambda x: x["content"]).iloc[0]) actual_len = len(actual_text) relative_length_tolerance = 0.25 min_acceptable_len = expected_len * (1 - relative_length_tolerance) max_acceptable_len = expected_len * (1 + relative_length_tolerance) assert min_acceptable_len <= actual_len <= max_acceptable_len, ( - f"Item (verbose={verbose}): Extracted text length {actual_len} is outside the acceptable range " + f"Item (verbose=True): Extracted text length {actual_len} is outside the acceptable range " f"[{min_acceptable_len:.0f}, {max_acceptable_len:.0f}]. " f"Expected reference length was {expected_len}. " ) @@ -400,28 +746,25 @@ def test_blob_pdf_chunk(pdf_mm_df: bpd.DataFrame, verbose: bool, bq_connection: for keyword in major_keywords: assert ( keyword.lower() in actual_text.lower() - ), f"Item (verbose={verbose}): Expected keyword '{keyword}' not found in extracted text. " + ), f"Item (verbose=True): Expected keyword '{keyword}' not found in extracted text. " @pytest.mark.parametrize( - "model_name, verbose", + "model_name", [ - ("gemini-2.0-flash-001", True), - ("gemini-2.0-flash-001", False), - ("gemini-2.0-flash-lite-001", True), - ("gemini-2.0-flash-lite-001", False), + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", ], ) def test_blob_transcribe( audio_mm_df: bpd.DataFrame, model_name: str, - verbose: bool, ): actual = ( audio_mm_df["audio"] .blob.audio_transcribe( model_name=model_name, # type: ignore - verbose=verbose, + verbose=False, ) .to_pandas() ) @@ -430,18 +773,63 @@ def test_blob_transcribe( expected_text = "Now, as all books not primarily intended as picture-books consist principally of types composed to form letterpress" expected_len = len(expected_text) - actual_text = "" - if verbose: - actual_text = actual[0]["content"] - else: - actual_text = actual[0] + actual_text = actual[0] if pd.isna(actual_text) or actual_text == "": # Ensure the tests are robust to flakes in the model, which isn't # particularly useful information for the bigframes team. - logging.warning( - f"blob_transcribe() model {model_name} verbose={verbose} failure" + logging.warning(f"blob_transcribe() model {model_name} verbose=False failure") + return + + actual_len = len(actual_text) + + relative_length_tolerance = 0.2 + min_acceptable_len = expected_len * (1 - relative_length_tolerance) + max_acceptable_len = expected_len * (1 + relative_length_tolerance) + assert min_acceptable_len <= actual_len <= max_acceptable_len, ( + f"Item (verbose=False): Transcribed text length {actual_len} is outside the acceptable range " + f"[{min_acceptable_len:.0f}, {max_acceptable_len:.0f}]. " + f"Expected reference length was {expected_len}. " + ) + + # check for major keywords + major_keywords = ["book", "picture"] + for keyword in major_keywords: + assert ( + keyword.lower() in actual_text.lower() + ), f"Item (verbose=False): Expected keyword '{keyword}' not found in transcribed text. " + + +@pytest.mark.parametrize( + "model_name", + [ + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + ], +) +def test_blob_transcribe_verbose( + audio_mm_df: bpd.DataFrame, + model_name: str, +): + actual = ( + audio_mm_df["audio"] + .blob.audio_transcribe( + model_name=model_name, # type: ignore + verbose=True, ) + .to_pandas() + ) + + # check relative length + expected_text = "Now, as all books not primarily intended as picture-books consist principally of types composed to form letterpress" + expected_len = len(expected_text) + + actual_text = actual[0]["content"] + + if pd.isna(actual_text) or actual_text == "": + # Ensure the tests are robust to flakes in the model, which isn't + # particularly useful information for the bigframes team. + logging.warning(f"blob_transcribe() model {model_name} verbose=True failure") return actual_len = len(actual_text) @@ -450,7 +838,7 @@ def test_blob_transcribe( min_acceptable_len = expected_len * (1 - relative_length_tolerance) max_acceptable_len = expected_len * (1 + relative_length_tolerance) assert min_acceptable_len <= actual_len <= max_acceptable_len, ( - f"Item (verbose={verbose}): Transcribed text length {actual_len} is outside the acceptable range " + f"Item (verbose=True): Transcribed text length {actual_len} is outside the acceptable range " f"[{min_acceptable_len:.0f}, {max_acceptable_len:.0f}]. " f"Expected reference length was {expected_len}. " ) @@ -460,4 +848,4 @@ def test_blob_transcribe( for keyword in major_keywords: assert ( keyword.lower() in actual_text.lower() - ), f"Item (verbose={verbose}): Expected keyword '{keyword}' not found in transcribed text. " + ), f"Item (verbose=True): Expected keyword '{keyword}' not found in transcribed text. " From 7ce0ac52271dc8168e19b12f5a15821dc3ac9ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Thu, 16 Oct 2025 12:10:18 -0500 Subject: [PATCH 157/313] chore: remove global repr_mode setting from tests (#2175) --- tests/system/small/bigquery/test_ai.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/system/small/bigquery/test_ai.py b/tests/system/small/bigquery/test_ai.py index 203de616ee..f2aa477168 100644 --- a/tests/system/small/bigquery/test_ai.py +++ b/tests/system/small/bigquery/test_ai.py @@ -285,7 +285,6 @@ def test_ai_if_multi_model(session): def test_ai_classify(session): s = bpd.Series(["cat", "orchid"], session=session) - bpd.options.display.repr_mode = "deferred" result = bbq.ai.classify(s, ["animal", "plant"]) From 8f27e737fc78a182238090025d09479fac90b326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Thu, 16 Oct 2025 15:04:00 -0500 Subject: [PATCH 158/313] docs: clarify that only NULL values are handled by fillna/isna, not NaN (#2176) * docs: clarify that only NULL values are handled by fillna/isna, not NaN * fix series fillna doctest --- .gitignore | 1 + conftest.py | 37 ++++++++ .../bigframes_vendored/pandas/core/frame.py | 54 ++++++------ .../bigframes_vendored/pandas/core/generic.py | 87 +++++++++++-------- .../pandas/core/indexes/base.py | 20 +++-- .../bigframes_vendored/pandas/core/series.py | 26 +++--- 6 files changed, 145 insertions(+), 80 deletions(-) create mode 100644 conftest.py diff --git a/.gitignore b/.gitignore index d083ea1ddc..63a3e53971 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,4 @@ system_tests/local_test_setup # Make sure a generated file isn't accidentally committed. pylintrc pylintrc.test +dummy.pkl diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000000..657a59bc0e --- /dev/null +++ b/conftest.py @@ -0,0 +1,37 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pyarrow as pa +import pytest + +import bigframes._config +import bigframes.pandas as bpd + + +@pytest.fixture(autouse=True) +def default_doctest_imports(doctest_namespace): + """ + Avoid some boilerplate in pandas-inspired tests. + + See: https://docs.pytest.org/en/stable/how-to/doctest.html#doctest-namespace-fixture + """ + doctest_namespace["np"] = np + doctest_namespace["pd"] = pd + doctest_namespace["pa"] = pa + doctest_namespace["bpd"] = bpd + bigframes._config.options.display.progress_bar = None diff --git a/third_party/bigframes_vendored/pandas/core/frame.py b/third_party/bigframes_vendored/pandas/core/frame.py index 557c332797..4c0abff545 100644 --- a/third_party/bigframes_vendored/pandas/core/frame.py +++ b/third_party/bigframes_vendored/pandas/core/frame.py @@ -6978,7 +6978,7 @@ def query(self, expr: str) -> DataFrame | None: def interpolate(self, method: str = "linear"): """ - Fill NaN values using an interpolation method. + Fill NA (NULL in BigQuery) values using an interpolation method. **Examples:** @@ -7028,35 +7028,39 @@ def interpolate(self, method: str = "linear"): def fillna(self, value): """ - Fill NA/NaN values using the specified method. + Fill NA (NULL in BigQuery) values using the specified method. - **Examples:** + Note that empty strings ``''``, :attr:`numpy.inf`, and + :attr:`numpy.nan` are ***not*** considered NA values. This NA/NULL + logic differs from numpy, but it is the same as BigQuery and the + :class:`pandas.ArrowDtype`. - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None + **Examples:** - >>> df = bpd.DataFrame([[np.nan, 2, np.nan, 0], - ... [3, 4, np.nan, 1], - ... [np.nan, np.nan, np.nan, np.nan], - ... [np.nan, 3, np.nan, 4]], - ... columns=list("ABCD")).astype("Float64") + >>> df = bpd.DataFrame( + ... [ + ... pa.array([np.nan, 2, None, 0], type=pa.float64()), + ... pa.array([3, np.nan, None, 1], type=pa.float64()), + ... pa.array([None, None, np.nan, None], type=pa.float64()), + ... pa.array([4, 5, None, np.nan], type=pa.float64()), + ... ], columns=list("ABCD"), dtype=pd.ArrowDtype(pa.float64())) >>> df - A B C D - 0 2.0 0.0 - 1 3.0 4.0 1.0 - 2 - 3 3.0 4.0 + A B C D + 0 NaN 2.0 0.0 + 1 3.0 NaN 1.0 + 2 NaN + 3 4.0 5.0 NaN [4 rows x 4 columns] - Replace all NA elements with 0s. + Replace all NA (NULL) elements with 0s. >>> df.fillna(0) A B C D - 0 0.0 2.0 0.0 0.0 - 1 3.0 4.0 0.0 1.0 - 2 0.0 0.0 0.0 0.0 - 3 0.0 3.0 0.0 4.0 + 0 NaN 2.0 0.0 0.0 + 1 3.0 NaN 0.0 1.0 + 2 0.0 0.0 NaN 0.0 + 3 4.0 5.0 0.0 NaN [4 rows x 4 columns] @@ -7072,11 +7076,11 @@ def fillna(self, value): [3 rows x 4 columns] >>> df.fillna(df_fill) - A B C D - 0 0.0 2.0 2.0 0.0 - 1 3.0 4.0 6.0 1.0 - 2 8.0 9.0 10.0 11.0 - 3 3.0 4.0 + A B C D + 0 NaN 2.0 2.0 0.0 + 1 3.0 NaN 6.0 1.0 + 2 8.0 9.0 NaN 11.0 + 3 4.0 5.0 NaN [4 rows x 4 columns] diff --git a/third_party/bigframes_vendored/pandas/core/generic.py b/third_party/bigframes_vendored/pandas/core/generic.py index 273339efcf..bf67326025 100644 --- a/third_party/bigframes_vendored/pandas/core/generic.py +++ b/third_party/bigframes_vendored/pandas/core/generic.py @@ -816,67 +816,80 @@ def bfill(self, *, limit: Optional[int] = None): raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) def isna(self) -> NDFrame: - """Detect missing values. + """Detect missing (NULL) values. - Return a boolean same-sized object indicating if the values are NA. - NA values get mapped to True values. Everything else gets mapped to - False values. Characters such as empty strings ``''`` or - :attr:`numpy.inf` are not considered NA values. + Return a boolean same-sized object indicating if the values are NA + (NULL in BigQuery). NA/NULL values get mapped to True values. + Everything else gets mapped to False values. - **Examples:** + Note that empty strings ``''``, :attr:`numpy.inf`, and + :attr:`numpy.nan` are ***not*** considered NA values. This NA/NULL + logic differs from numpy, but it is the same as BigQuery and the + :class:`pandas.ArrowDtype`. - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> import numpy as np + **Examples:** >>> df = bpd.DataFrame(dict( - ... age=[5, 6, np.nan], - ... born=[bpd.NA, "1940-04-25", "1940-04-25"], - ... name=['Alfred', 'Batman', ''], - ... toy=[None, 'Batmobile', 'Joker'], + ... age=pd.Series(pa.array( + ... [5, 6, None, 4], + ... type=pa.int64(), + ... ), dtype=pd.ArrowDtype(pa.int64())), + ... born=pd.to_datetime([pd.NA, "1940-04-25", "1940-04-25", "1941-08-25"]), + ... name=['Alfred', 'Batman', '', 'Plastic Man'], + ... toy=[None, 'Batmobile', 'Joker', 'Play dough'], + ... height=pd.Series(pa.array( + ... [6.1, 5.9, None, np.nan], + ... type=pa.float64(), + ... ), dtype=pd.ArrowDtype(pa.float64())), ... )) >>> df - age born name toy - 0 5.0 Alfred - 1 6.0 1940-04-25 Batman Batmobile - 2 1940-04-25 Joker + age born name toy height + 0 5 Alfred 6.1 + 1 6 1940-04-25 00:00:00 Batman Batmobile 5.9 + 2 1940-04-25 00:00:00 Joker + 3 4 1941-08-25 00:00:00 Plastic Man Play dough NaN - [3 rows x 4 columns] + [4 rows x 5 columns] - Show which entries in a DataFrame are NA: + Show which entries in a DataFrame are NA (NULL in BigQuery): >>> df.isna() - age born name toy - 0 False True False True - 1 False False False False - 2 True False False False + age born name toy height + 0 False True False True False + 1 False False False False False + 2 True False False False True + 3 False False False False False - [3 rows x 4 columns] + [4 rows x 5 columns] >>> df.isnull() - age born name toy - 0 False True False True - 1 False False False False - 2 True False False False + age born name toy height + 0 False True False True False + 1 False False False False False + 2 True False False False True + 3 False False False False False - [3 rows x 4 columns] + [4 rows x 5 columns] - Show which entries in a Series are NA: + Show which entries in a Series are NA (NULL in BigQuery): - >>> ser = bpd.Series([5, None, 6, np.nan, bpd.NA]) + >>> ser = bpd.Series(pa.array( + ... [5, None, 6, np.nan, None], + ... type=pa.float64(), + ... ), dtype=pd.ArrowDtype(pa.float64())) >>> ser - 0 5 + 0 5.0 1 - 2 6 - 3 + 2 6.0 + 3 NaN 4 - dtype: Int64 + dtype: Float64 >>> ser.isna() 0 False 1 True 2 False - 3 True + 3 False 4 True dtype: boolean @@ -884,7 +897,7 @@ def isna(self) -> NDFrame: 0 False 1 True 2 False - 3 True + 3 False 4 True dtype: boolean diff --git a/third_party/bigframes_vendored/pandas/core/indexes/base.py b/third_party/bigframes_vendored/pandas/core/indexes/base.py index eba47fc1f9..82c0563c25 100644 --- a/third_party/bigframes_vendored/pandas/core/indexes/base.py +++ b/third_party/bigframes_vendored/pandas/core/indexes/base.py @@ -957,17 +957,23 @@ def value_counts( def fillna(self, value) -> Index: """ - Fill NA/NaN values with the specified value. + Fill NA (NULL in BigQuery) values using the specified method. - **Examples:** + Note that empty strings ``''``, :attr:`numpy.inf`, and + :attr:`numpy.nan` are ***not*** considered NA values. This NA/NULL + logic differs from numpy, but it is the same as BigQuery and the + :class:`pandas.ArrowDtype`. - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None + **Examples:** - >>> idx = bpd.Index([np.nan, np.nan, 3]) + >>> idx = bpd.Index( + ... pa.array([None, np.nan, 3, None], type=pa.float64()), + ... dtype=pd.ArrowDtype(pa.float64()), + ... ) + >>> idx + Index([, nan, 3.0, ], dtype='Float64') >>> idx.fillna(0) - Index([0.0, 0.0, 3.0], dtype='Float64') + Index([0.0, nan, 3.0, 0.0], dtype='Float64') Args: value (scalar): diff --git a/third_party/bigframes_vendored/pandas/core/series.py b/third_party/bigframes_vendored/pandas/core/series.py index 932959a826..540a66b595 100644 --- a/third_party/bigframes_vendored/pandas/core/series.py +++ b/third_party/bigframes_vendored/pandas/core/series.py @@ -2410,26 +2410,30 @@ def fillna( value=None, ) -> Series | None: """ - Fill NA/NaN values using the specified method. + Fill NA (NULL in BigQuery) values using the specified method. - **Examples:** + Note that empty strings ``''``, :attr:`numpy.inf`, and + :attr:`numpy.nan` are ***not*** considered NA values. This NA/NULL + logic differs from numpy, but it is the same as BigQuery and the + :class:`pandas.ArrowDtype`. - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None + **Examples:** - >>> s = bpd.Series([np.nan, 2, np.nan, -1]) + >>> s = bpd.Series( + ... pa.array([np.nan, 2, None, -1], type=pa.float64()), + ... dtype=pd.ArrowDtype(pa.float64()), + ... ) >>> s - 0 + 0 NaN 1 2.0 2 3 -1.0 dtype: Float64 - Replace all NA elements with 0s. + Replace all NA (NULL) elements with 0s. >>> s.fillna(0) - 0 0.0 + 0 NaN 1 2.0 2 0.0 3 -1.0 @@ -2439,7 +2443,7 @@ def fillna( >>> s_fill = bpd.Series([11, 22, 33]) >>> s.fillna(s_fill) - 0 11.0 + 0 NaN 1 2.0 2 33.0 3 -1.0 @@ -4482,7 +4486,7 @@ def update(self, other) -> None: 2 6 dtype: Int64 - If ``other`` contains NaNs the corresponding values are not updated + If ``other`` contains NA (NULL values) the corresponding values are not updated in the original Series. >>> s = bpd.Series([1, 2, 3]) From 1a01ab97f103361f489f37b0af8c4b4d7806707c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Thu, 16 Oct 2025 16:33:24 -0500 Subject: [PATCH 159/313] docs: remove import bigframes.pandas as bpd boilerplate from many samples (#2147) * docs: remove import bigframes.pandas as bpd boilerplate from many samples Also, fixes several constructors that didn't take a session for compatibility with multi-session applications. * fix docs * fix unit tests * skip sklearn test * fix snapshot * plumb through session for from_tuples and from_arrays * add from_frame * make sure polars session isnt skipped on Kokoro * fix apply doctest * make doctest conftest available everywhere * add python version flexibility for to_dict * disambiguate explicit names * disambiguate explicit name none versus no name * fix for column name comparison in pandas bin op * avoid setting column labels in special case of Series(block) * revert doctest changes * revert doctest changes * revert df docstrings * add polars series unit tests * restore a test * Revert "restore a test" This reverts commit 765b678b34a7976aef1017d2a1fdb34d7a4cfbe4. * skip null * skip unsupported tests * revert more docs changes * revert more docs * revert more docs * fix unit tests python 3.13 * add test to reproduce name error * add tests for session scoped methods * revert new session methods * fix TestSession read_pandas for Series * revert more unnecessary changes * even more * add unit_noextras to improve code coverage * run system tests on latest fully supported * system-3.12 not found * cap polars version * hide progress bar * relax polars upper pin * try to restore docs changes * remove progress bar boilerplate * remove standardd community imports boilerplate * restore bpd to datetimelike * remove bpd boilerplate * avoid bpd.NA * fix more docs * dont skip tests if polars isnt installed * fix more doctests * skip remote functions in Series.apply * feat: implement cos, sin, and log operations for polars compiler * fix domain for log * update snapshot * fix domain for log * update snapshot * revert sqrt change * revert sqrt change * fix more samples * sync polars compiler with main * avoid np in output * Update tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sqrt/out.sql * Update bigframes/core/compile/sqlglot/expressions/numeric_ops.py * upgrade requirements in polars sample * add todo for making doctest more robust --- CHANGELOG.md | 2 +- bigframes/bigquery/_operations/ai.py | 7 - bigframes/bigquery/_operations/approx_agg.py | 1 - bigframes/bigquery/_operations/array.py | 5 - bigframes/bigquery/_operations/datetime.py | 6 - bigframes/bigquery/_operations/geo.py | 9 - bigframes/bigquery/_operations/json.py | 12 - bigframes/bigquery/_operations/search.py | 1 - bigframes/bigquery/_operations/sql.py | 3 - bigframes/bigquery/_operations/struct.py | 1 - bigframes/core/compile/polars/compiler.py | 677 +++++++++--------- bigframes/dataframe.py | 7 - bigframes/ml/compose.py | 1 - bigframes/operations/ai.py | 7 - bigframes/operations/semantics.py | 8 - bigframes/operations/strings.py | 4 +- bigframes/series.py | 7 - bigframes/session/__init__.py | 38 +- conftest.py | 24 +- samples/polars/requirements.txt | 6 +- tests/system/small/operations/test_strings.py | 6 +- tests/system/small/test_series.py | 5 +- tests/unit/test_pandas.py | 2 +- tests/unit/test_series_polars.py | 5 +- .../bigframes_vendored/geopandas/geoseries.py | 9 - .../bigframes_vendored/ibis/expr/api.py | 2 - .../ibis/expr/datatypes/core.py | 1 - .../ibis/expr/types/arrays.py | 1 - .../ibis/expr/types/maps.py | 6 - .../pandas/core/arrays/arrow/accessors.py | 12 - .../pandas/core/arrays/datetimelike.py | 6 - .../pandas/core/computation/eval.py | 2 - .../pandas/core/config_init.py | 1 - .../bigframes_vendored/pandas/core/frame.py | 291 +------- .../bigframes_vendored/pandas/core/generic.py | 27 +- .../pandas/core/groupby/__init__.py | 100 +-- .../pandas/core/indexes/accessor.py | 45 -- .../pandas/core/indexes/base.py | 69 -- .../pandas/core/indexes/datetimes.py | 18 - .../pandas/core/indexes/multi.py | 4 - .../pandas/core/reshape/tile.py | 3 - .../bigframes_vendored/pandas/core/series.py | 421 ++--------- .../pandas/core/strings/accessor.py | 55 +- .../pandas/core/tools/datetimes.py | 1 - .../pandas/core/tools/timedeltas.py | 4 +- .../bigframes_vendored/pandas/io/gbq.py | 1 - .../bigframes_vendored/pandas/io/parquet.py | 1 - .../pandas/io/parsers/readers.py | 2 - .../bigframes_vendored/pandas/io/pickle.py | 1 - .../pandas/plotting/_core.py | 10 - .../sklearn/cluster/_kmeans.py | 1 - .../sklearn/decomposition/_mf.py | 1 - .../sklearn/decomposition/_pca.py | 1 - .../sklearn/impute/_base.py | 1 - .../sklearn/linear_model/_base.py | 1 - .../sklearn/linear_model/_logistic.py | 1 - .../sklearn/metrics/_classification.py | 5 - .../sklearn/metrics/_ranking.py | 3 - .../sklearn/metrics/_regression.py | 3 - .../sklearn/model_selection/_split.py | 2 - .../sklearn/model_selection/_validation.py | 1 - .../sklearn/preprocessing/_encoder.py | 1 - 62 files changed, 491 insertions(+), 1467 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d7315896..25205f48d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -463,7 +463,7 @@ * Address `read_csv` with both `index_col` and `use_cols` behavior inconsistency with pandas ([#1785](https://github.com/googleapis/python-bigquery-dataframes/issues/1785)) ([ba7c313](https://github.com/googleapis/python-bigquery-dataframes/commit/ba7c313c8d308e3ff3f736b60978cb7a51715209)) * Allow KMeans model init parameter as k-means++ alias ([#1790](https://github.com/googleapis/python-bigquery-dataframes/issues/1790)) ([0b59cf1](https://github.com/googleapis/python-bigquery-dataframes/commit/0b59cf1008613770fa1433c6da395e755c86fe22)) -* Replace function now can handle bpd.NA value. ([#1786](https://github.com/googleapis/python-bigquery-dataframes/issues/1786)) ([7269512](https://github.com/googleapis/python-bigquery-dataframes/commit/7269512a28eb42029447d5380c764353278a74e1)) +* Replace function now can handle pd.NA value. ([#1786](https://github.com/googleapis/python-bigquery-dataframes/issues/1786)) ([7269512](https://github.com/googleapis/python-bigquery-dataframes/commit/7269512a28eb42029447d5380c764353278a74e1)) ### Documentation diff --git a/bigframes/bigquery/_operations/ai.py b/bigframes/bigquery/_operations/ai.py index f4302f8ece..e0af130016 100644 --- a/bigframes/bigquery/_operations/ai.py +++ b/bigframes/bigquery/_operations/ai.py @@ -53,7 +53,6 @@ def generate( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> country = bpd.Series(["Japan", "Canada"]) >>> bbq.ai.generate(("What's the capital city of ", country, " one word only")) 0 {'result': 'Tokyo\\n', 'full_response': '{"cand... @@ -155,7 +154,6 @@ def generate_bool( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... "col_1": ["apple", "bear", "pear"], ... "col_2": ["fruit", "animal", "animal"] @@ -240,7 +238,6 @@ def generate_int( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> animal = bpd.Series(["Kangaroo", "Rabbit", "Spider"]) >>> bbq.ai.generate_int(("How many legs does a ", animal, " have?")) 0 {'result': 2, 'full_response': '{"candidates":... @@ -322,7 +319,6 @@ def generate_double( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> animal = bpd.Series(["Kangaroo", "Rabbit", "Spider"]) >>> bbq.ai.generate_double(("How many legs does a ", animal, " have?")) 0 {'result': 2.0, 'full_response': '{"candidates... @@ -402,7 +398,6 @@ def if_( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> us_state = bpd.Series(["Massachusetts", "Illinois", "Hawaii"]) >>> bbq.ai.if_((us_state, " has a city called Springfield")) 0 True @@ -459,7 +454,6 @@ def classify( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'creature': ['Cat', 'Salmon']}) >>> df['type'] = bbq.ai.classify(df['creature'], ['Mammal', 'Fish']) >>> df @@ -517,7 +511,6 @@ def score( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> animal = bpd.Series(["Tiger", "Rabbit", "Blue Whale"]) >>> bbq.ai.score(("Rank the relative weights of ", animal, " on the scale from 1 to 3")) # doctest: +SKIP 0 2.0 diff --git a/bigframes/bigquery/_operations/approx_agg.py b/bigframes/bigquery/_operations/approx_agg.py index 696f8f5a66..73b6fdbb73 100644 --- a/bigframes/bigquery/_operations/approx_agg.py +++ b/bigframes/bigquery/_operations/approx_agg.py @@ -40,7 +40,6 @@ def approx_top_count( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["apple", "apple", "pear", "pear", "pear", "banana"]) >>> bbq.approx_top_count(s, number=2) [{'value': 'pear', 'count': 3}, {'value': 'apple', 'count': 2}] diff --git a/bigframes/bigquery/_operations/array.py b/bigframes/bigquery/_operations/array.py index 4af1416127..6f9dd20b54 100644 --- a/bigframes/bigquery/_operations/array.py +++ b/bigframes/bigquery/_operations/array.py @@ -40,7 +40,6 @@ def array_length(series: series.Series) -> series.Series: >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([[1, 2, 8, 3], [], [3, 4]]) >>> bbq.array_length(s) @@ -78,8 +77,6 @@ def array_agg( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> import numpy as np - >>> bpd.options.display.progress_bar = None For a SeriesGroupBy object: @@ -128,8 +125,6 @@ def array_to_string(series: series.Series, delimiter: str) -> series.Series: >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([["H", "i", "!"], ["Hello", "World"], np.nan, [], ["Hi"]]) >>> bbq.array_to_string(s, delimiter=", ") diff --git a/bigframes/bigquery/_operations/datetime.py b/bigframes/bigquery/_operations/datetime.py index f8767336dd..99467beb06 100644 --- a/bigframes/bigquery/_operations/datetime.py +++ b/bigframes/bigquery/_operations/datetime.py @@ -21,10 +21,8 @@ def unix_seconds(input: series.Series) -> series.Series: **Examples:** - >>> import pandas as pd >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([pd.Timestamp("1970-01-02", tz="UTC"), pd.Timestamp("1970-01-03", tz="UTC")]) >>> bbq.unix_seconds(s) @@ -48,10 +46,8 @@ def unix_millis(input: series.Series) -> series.Series: **Examples:** - >>> import pandas as pd >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([pd.Timestamp("1970-01-02", tz="UTC"), pd.Timestamp("1970-01-03", tz="UTC")]) >>> bbq.unix_millis(s) @@ -75,10 +71,8 @@ def unix_micros(input: series.Series) -> series.Series: **Examples:** - >>> import pandas as pd >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([pd.Timestamp("1970-01-02", tz="UTC"), pd.Timestamp("1970-01-03", tz="UTC")]) >>> bbq.unix_micros(s) diff --git a/bigframes/bigquery/_operations/geo.py b/bigframes/bigquery/_operations/geo.py index 9a92a8960d..254d2ae13f 100644 --- a/bigframes/bigquery/_operations/geo.py +++ b/bigframes/bigquery/_operations/geo.py @@ -53,7 +53,6 @@ def st_area( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq >>> from shapely.geometry import Polygon, LineString, Point - >>> bpd.options.display.progress_bar = None >>> series = bigframes.geopandas.GeoSeries( ... [ @@ -125,7 +124,6 @@ def st_buffer( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq >>> from shapely.geometry import Point - >>> bpd.options.display.progress_bar = None >>> series = bigframes.geopandas.GeoSeries( ... [ @@ -195,7 +193,6 @@ def st_centroid( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq >>> from shapely.geometry import Polygon, LineString, Point - >>> bpd.options.display.progress_bar = None >>> series = bigframes.geopandas.GeoSeries( ... [ @@ -250,7 +247,6 @@ def st_convexhull( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq >>> from shapely.geometry import Polygon, LineString, Point - >>> bpd.options.display.progress_bar = None >>> series = bigframes.geopandas.GeoSeries( ... [ @@ -312,7 +308,6 @@ def st_difference( >>> import bigframes.bigquery as bbq >>> import bigframes.geopandas >>> from shapely.geometry import Polygon, LineString, Point - >>> bpd.options.display.progress_bar = None We can check two GeoSeries against each other, row by row: @@ -407,7 +402,6 @@ def st_distance( >>> import bigframes.bigquery as bbq >>> import bigframes.geopandas >>> from shapely.geometry import Polygon, LineString, Point - >>> bpd.options.display.progress_bar = None We can check two GeoSeries against each other, row by row. @@ -489,7 +483,6 @@ def st_intersection( >>> import bigframes.bigquery as bbq >>> import bigframes.geopandas >>> from shapely.geometry import Polygon, LineString, Point - >>> bpd.options.display.progress_bar = None We can check two GeoSeries against each other, row by row. @@ -583,7 +576,6 @@ def st_isclosed( >>> import bigframes.bigquery as bbq >>> from shapely.geometry import Point, LineString, Polygon - >>> bpd.options.display.progress_bar = None >>> series = bigframes.geopandas.GeoSeries( ... [ @@ -650,7 +642,6 @@ def st_length( >>> import bigframes.bigquery as bbq >>> from shapely.geometry import Polygon, LineString, Point, GeometryCollection - >>> bpd.options.display.progress_bar = None >>> series = bigframes.geopandas.GeoSeries( ... [ diff --git a/bigframes/bigquery/_operations/json.py b/bigframes/bigquery/_operations/json.py index 656e59af0d..4e1f43aab0 100644 --- a/bigframes/bigquery/_operations/json.py +++ b/bigframes/bigquery/_operations/json.py @@ -49,8 +49,6 @@ def json_set( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.read_gbq("SELECT JSON '{\\\"a\\\": 1}' AS data")["data"] >>> bbq.json_set(s, json_path_value_pairs=[("$.a", 100), ("$.b", "hi")]) @@ -101,7 +99,6 @@ def json_extract( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['{"class": {"students": [{"id": 5}, {"id": 12}]}}']) >>> bbq.json_extract(s, json_path="$.class") @@ -141,7 +138,6 @@ def json_extract_array( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['[1, 2, 3]', '[4, 5]']) >>> bbq.json_extract_array(s) @@ -204,7 +200,6 @@ def json_extract_string_array( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['[1, 2, 3]', '[4, 5]']) >>> bbq.json_extract_string_array(s) @@ -272,7 +267,6 @@ def json_query( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['{"class": {"students": [{"id": 5}, {"id": 12}]}}']) >>> bbq.json_query(s, json_path="$.class") @@ -303,7 +297,6 @@ def json_query_array( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['[1, 2, 3]', '[4, 5]']) >>> bbq.json_query_array(s) @@ -355,7 +348,6 @@ def json_value( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['{"name": "Jakob", "age": "6"}', '{"name": "Jakob", "age": []}']) >>> bbq.json_value(s, json_path="$.age") @@ -392,7 +384,6 @@ def json_value_array( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['[1, 2, 3]', '[4, 5]']) >>> bbq.json_value_array(s) @@ -439,7 +430,6 @@ def to_json( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> bbq.to_json(s) @@ -473,7 +463,6 @@ def to_json_string( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> bbq.to_json_string(s) @@ -512,7 +501,6 @@ def parse_json( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['{"class": {"students": [{"id": 5}, {"id": 12}]}}']) >>> s diff --git a/bigframes/bigquery/_operations/search.py b/bigframes/bigquery/_operations/search.py index c16c2af1a9..b65eed2475 100644 --- a/bigframes/bigquery/_operations/search.py +++ b/bigframes/bigquery/_operations/search.py @@ -111,7 +111,6 @@ def vector_search( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> bpd.options.display.progress_bar = None DataFrame embeddings for which to find nearest neighbors. The ``ARRAY`` column is used as the search query: diff --git a/bigframes/bigquery/_operations/sql.py b/bigframes/bigquery/_operations/sql.py index a2de61fc21..295412fd75 100644 --- a/bigframes/bigquery/_operations/sql.py +++ b/bigframes/bigquery/_operations/sql.py @@ -36,9 +36,6 @@ def sql_scalar( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - >>> import pandas as pd - >>> import pyarrow as pa - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["1.5", "2.5", "3.5"]) >>> s = s.astype(pd.ArrowDtype(pa.decimal128(38, 9))) diff --git a/bigframes/bigquery/_operations/struct.py b/bigframes/bigquery/_operations/struct.py index 7cb826351c..a6304677ef 100644 --- a/bigframes/bigquery/_operations/struct.py +++ b/bigframes/bigquery/_operations/struct.py @@ -39,7 +39,6 @@ def struct(value: dataframe.DataFrame) -> series.Series: >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq >>> import bigframes.series as series - >>> bpd.options.display.progress_bar = None >>> srs = series.Series([{"version": 1, "project": "pandas"}, {"version": 2, "project": "numpy"},]) >>> df = srs.struct.explode() diff --git a/bigframes/core/compile/polars/compiler.py b/bigframes/core/compile/polars/compiler.py index 059ec72076..acaf1b8f22 100644 --- a/bigframes/core/compile/polars/compiler.py +++ b/bigframes/core/compile/polars/compiler.py @@ -538,362 +538,371 @@ def compile_agg_op( f"Aggregate op {op} not yet supported in polars engine." ) + @dataclasses.dataclass(frozen=True) + class PolarsCompiler: + """ + Compiles ArrayValue to polars LazyFrame and executes. + + This feature is in development and is incomplete. + While most node types are supported, this has the following limitations: + 1. GBQ data sources not supported. + 2. Joins do not order rows correctly + 3. Incomplete scalar op support + 4. Incomplete aggregate op support + 5. Incomplete analytic op support + 6. Some complex windowing types not supported (eg. groupby + rolling) + 7. UDFs are not supported. + 8. Returned types may not be entirely consistent with BigQuery backend + 9. Some operations are not entirely lazy - sampling and somse windowing. + """ -@dataclasses.dataclass(frozen=True) -class PolarsCompiler: - """ - Compiles ArrayValue to polars LazyFrame and executes. - - This feature is in development and is incomplete. - While most node types are supported, this has the following limitations: - 1. GBQ data sources not supported. - 2. Joins do not order rows correctly - 3. Incomplete scalar op support - 4. Incomplete aggregate op support - 5. Incomplete analytic op support - 6. Some complex windowing types not supported (eg. groupby + rolling) - 7. UDFs are not supported. - 8. Returned types may not be entirely consistent with BigQuery backend - 9. Some operations are not entirely lazy - sampling and somse windowing. - """ + expr_compiler = PolarsExpressionCompiler() + agg_compiler = PolarsAggregateCompiler() + + def compile(self, plan: nodes.BigFrameNode) -> pl.LazyFrame: + if not polars_installed: + raise ValueError( + "Polars is not installed, cannot compile to polars engine." + ) + + # TODO: Create standard way to configure BFET -> BFET rewrites + # Polars has incomplete slice support in lazy mode + node = plan + node = bigframes.core.rewrite.column_pruning(node) + node = nodes.bottom_up(node, bigframes.core.rewrite.rewrite_slice) + node = bigframes.core.rewrite.pull_out_window_order(node) + node = bigframes.core.rewrite.schema_binding.bind_schema_to_tree(node) + node = lowering.lower_ops_to_polars(node) + return self.compile_node(node) - expr_compiler = PolarsExpressionCompiler() - agg_compiler = PolarsAggregateCompiler() + @functools.singledispatchmethod + def compile_node(self, node: nodes.BigFrameNode) -> pl.LazyFrame: + """Defines transformation but isn't cached, always use compile_node instead""" + raise ValueError(f"Can't compile unrecognized node: {node}") + + @compile_node.register + def compile_readlocal(self, node: nodes.ReadLocalNode): + cols_to_read = { + scan_item.source_id: scan_item.id.sql + for scan_item in node.scan_list.items + } + lazy_frame = cast( + pl.DataFrame, pl.from_arrow(node.local_data_source.data) + ).lazy() + lazy_frame = lazy_frame.select(cols_to_read.keys()).rename(cols_to_read) + if node.offsets_col: + lazy_frame = lazy_frame.with_columns( + [pl.int_range(pl.len(), dtype=pl.Int64).alias(node.offsets_col.sql)] + ) + return lazy_frame + + @compile_node.register + def compile_filter(self, node: nodes.FilterNode): + return self.compile_node(node.child).filter( + self.expr_compiler.compile_expression(node.predicate) + ) - def compile(self, plan: nodes.BigFrameNode) -> pl.LazyFrame: - if not polars_installed: - raise ValueError( - "Polars is not installed, cannot compile to polars engine." + @compile_node.register + def compile_orderby(self, node: nodes.OrderByNode): + frame = self.compile_node(node.child) + if len(node.by) == 0: + # pragma: no cover + return frame + return self._sort(frame, node.by) + + def _sort( + self, frame: pl.LazyFrame, by: Sequence[ordering.OrderingExpression] + ) -> pl.LazyFrame: + sorted = frame.sort( + [ + self.expr_compiler.compile_expression(by.scalar_expression) + for by in by + ], + descending=[not by.direction.is_ascending for by in by], + nulls_last=[by.na_last for by in by], + maintain_order=True, ) + return sorted + + @compile_node.register + def compile_reversed(self, node: nodes.ReversedNode): + return self.compile_node(node.child).reverse() - # TODO: Create standard way to configure BFET -> BFET rewrites - # Polars has incomplete slice support in lazy mode - node = plan - node = bigframes.core.rewrite.column_pruning(node) - node = nodes.bottom_up(node, bigframes.core.rewrite.rewrite_slice) - node = bigframes.core.rewrite.pull_out_window_order(node) - node = bigframes.core.rewrite.schema_binding.bind_schema_to_tree(node) - node = lowering.lower_ops_to_polars(node) - return self.compile_node(node) - - @functools.singledispatchmethod - def compile_node(self, node: nodes.BigFrameNode) -> pl.LazyFrame: - """Defines transformation but isn't cached, always use compile_node instead""" - raise ValueError(f"Can't compile unrecognized node: {node}") - - @compile_node.register - def compile_readlocal(self, node: nodes.ReadLocalNode): - cols_to_read = { - scan_item.source_id: scan_item.id.sql for scan_item in node.scan_list.items - } - lazy_frame = cast( - pl.DataFrame, pl.from_arrow(node.local_data_source.data) - ).lazy() - lazy_frame = lazy_frame.select(cols_to_read.keys()).rename(cols_to_read) - if node.offsets_col: - lazy_frame = lazy_frame.with_columns( - [pl.int_range(pl.len(), dtype=pl.Int64).alias(node.offsets_col.sql)] + @compile_node.register + def compile_selection(self, node: nodes.SelectionNode): + return self.compile_node(node.child).select( + **{new.sql: orig.id.sql for orig, new in node.input_output_pairs} ) - return lazy_frame - - @compile_node.register - def compile_filter(self, node: nodes.FilterNode): - return self.compile_node(node.child).filter( - self.expr_compiler.compile_expression(node.predicate) - ) - - @compile_node.register - def compile_orderby(self, node: nodes.OrderByNode): - frame = self.compile_node(node.child) - if len(node.by) == 0: - # pragma: no cover - return frame - return self._sort(frame, node.by) - - def _sort( - self, frame: pl.LazyFrame, by: Sequence[ordering.OrderingExpression] - ) -> pl.LazyFrame: - sorted = frame.sort( - [self.expr_compiler.compile_expression(by.scalar_expression) for by in by], - descending=[not by.direction.is_ascending for by in by], - nulls_last=[by.na_last for by in by], - maintain_order=True, - ) - return sorted - - @compile_node.register - def compile_reversed(self, node: nodes.ReversedNode): - return self.compile_node(node.child).reverse() - - @compile_node.register - def compile_selection(self, node: nodes.SelectionNode): - return self.compile_node(node.child).select( - **{new.sql: orig.id.sql for orig, new in node.input_output_pairs} - ) - - @compile_node.register - def compile_projection(self, node: nodes.ProjectionNode): - new_cols = [] - for proj_expr, name in node.assignments: - bound_expr = ex.bind_schema_fields(proj_expr, node.child.field_by_id) - new_col = self.expr_compiler.compile_expression(bound_expr).alias(name.sql) - if bound_expr.output_type is None: - new_col = new_col.cast( - _bigframes_dtype_to_polars_dtype(bigframes.dtypes.DEFAULT_DTYPE) + + @compile_node.register + def compile_projection(self, node: nodes.ProjectionNode): + new_cols = [] + for proj_expr, name in node.assignments: + bound_expr = ex.bind_schema_fields(proj_expr, node.child.field_by_id) + new_col = self.expr_compiler.compile_expression(bound_expr).alias( + name.sql ) - new_cols.append(new_col) - return self.compile_node(node.child).with_columns(new_cols) - - @compile_node.register - def compile_offsets(self, node: nodes.PromoteOffsetsNode): - return self.compile_node(node.child).with_columns( - [pl.int_range(pl.len(), dtype=pl.Int64).alias(node.col_id.sql)] - ) - - @compile_node.register - def compile_join(self, node: nodes.JoinNode): - left = self.compile_node(node.left_child) - right = self.compile_node(node.right_child) - - left_on = [] - right_on = [] - for left_ex, right_ex in node.conditions: - left_ex, right_ex = lowering._coerce_comparables(left_ex, right_ex) - left_on.append(self.expr_compiler.compile_expression(left_ex)) - right_on.append(self.expr_compiler.compile_expression(right_ex)) - - if node.type == "right": + if bound_expr.output_type is None: + new_col = new_col.cast( + _bigframes_dtype_to_polars_dtype(bigframes.dtypes.DEFAULT_DTYPE) + ) + new_cols.append(new_col) + return self.compile_node(node.child).with_columns(new_cols) + + @compile_node.register + def compile_offsets(self, node: nodes.PromoteOffsetsNode): + return self.compile_node(node.child).with_columns( + [pl.int_range(pl.len(), dtype=pl.Int64).alias(node.col_id.sql)] + ) + + @compile_node.register + def compile_join(self, node: nodes.JoinNode): + left = self.compile_node(node.left_child) + right = self.compile_node(node.right_child) + + left_on = [] + right_on = [] + for left_ex, right_ex in node.conditions: + left_ex, right_ex = lowering._coerce_comparables(left_ex, right_ex) + left_on.append(self.expr_compiler.compile_expression(left_ex)) + right_on.append(self.expr_compiler.compile_expression(right_ex)) + + if node.type == "right": + return self._ordered_join( + right, left, "left", right_on, left_on, node.joins_nulls + ).select([id.sql for id in node.ids]) return self._ordered_join( - right, left, "left", right_on, left_on, node.joins_nulls - ).select([id.sql for id in node.ids]) - return self._ordered_join( - left, right, node.type, left_on, right_on, node.joins_nulls - ) - - @compile_node.register - def compile_isin(self, node: nodes.InNode): - left = self.compile_node(node.left_child) - right = self.compile_node(node.right_child).unique(node.right_col.id.sql) - right = right.with_columns(pl.lit(True).alias(node.indicator_col.sql)) - - left_ex, right_ex = lowering._coerce_comparables(node.left_col, node.right_col) - - left_pl_ex = self.expr_compiler.compile_expression(left_ex) - right_pl_ex = self.expr_compiler.compile_expression(right_ex) - - joined = left.join( - right, - how="left", - left_on=left_pl_ex, - right_on=right_pl_ex, - # Note: join_nulls renamed to nulls_equal for polars 1.24 - join_nulls=node.joins_nulls, # type: ignore - coalesce=False, - ) - passthrough = [pl.col(id) for id in left.columns] - indicator = pl.col(node.indicator_col.sql).fill_null(False) - return joined.select((*passthrough, indicator)) - - def _ordered_join( - self, - left_frame: pl.LazyFrame, - right_frame: pl.LazyFrame, - how: Literal["inner", "outer", "left", "cross"], - left_on: Sequence[pl.Expr], - right_on: Sequence[pl.Expr], - join_nulls: bool, - ): - if how == "right": - # seems to cause seg faults as of v1.30 for no apparent reason - raise ValueError("right join not supported") - left = left_frame.with_columns( - [ - pl.int_range(pl.len()).alias("_bf_join_l"), - ] - ) - right = right_frame.with_columns( - [ - pl.int_range(pl.len()).alias("_bf_join_r"), - ] - ) - if how != "cross": + left, right, node.type, left_on, right_on, node.joins_nulls + ) + + @compile_node.register + def compile_isin(self, node: nodes.InNode): + left = self.compile_node(node.left_child) + right = self.compile_node(node.right_child).unique(node.right_col.id.sql) + right = right.with_columns(pl.lit(True).alias(node.indicator_col.sql)) + + left_ex, right_ex = lowering._coerce_comparables( + node.left_col, node.right_col + ) + + left_pl_ex = self.expr_compiler.compile_expression(left_ex) + right_pl_ex = self.expr_compiler.compile_expression(right_ex) + joined = left.join( right, - how=how, - left_on=left_on, - right_on=right_on, + how="left", + left_on=left_pl_ex, + right_on=right_pl_ex, # Note: join_nulls renamed to nulls_equal for polars 1.24 - join_nulls=join_nulls, # type: ignore + join_nulls=node.joins_nulls, # type: ignore coalesce=False, ) - else: - joined = left.join(right, how=how, coalesce=False) - - join_order = ( - ["_bf_join_l", "_bf_join_r"] - if how != "right" - else ["_bf_join_r", "_bf_join_l"] - ) - return joined.sort(join_order, nulls_last=True).drop( - ["_bf_join_l", "_bf_join_r"] - ) - - @compile_node.register - def compile_concat(self, node: nodes.ConcatNode): - child_frames = [self.compile_node(child) for child in node.child_nodes] - child_frames = [ - frame.rename( - {col: id.sql for col, id in zip(frame.columns, node.output_ids)} - ).cast( - { - field.id.sql: _bigframes_dtype_to_polars_dtype(field.dtype) - for field in node.fields - } - ) - for frame in child_frames - ] - df = pl.concat(child_frames) - return df - - @compile_node.register - def compile_agg(self, node: nodes.AggregateNode): - df = self.compile_node(node.child) - if node.dropna and len(node.by_column_ids) > 0: - df = df.filter( - [pl.col(ref.id.sql).is_not_null() for ref in node.by_column_ids] + passthrough = [pl.col(id) for id in left.columns] + indicator = pl.col(node.indicator_col.sql).fill_null(False) + return joined.select((*passthrough, indicator)) + + def _ordered_join( + self, + left_frame: pl.LazyFrame, + right_frame: pl.LazyFrame, + how: Literal["inner", "outer", "left", "cross"], + left_on: Sequence[pl.Expr], + right_on: Sequence[pl.Expr], + join_nulls: bool, + ): + if how == "right": + # seems to cause seg faults as of v1.30 for no apparent reason + raise ValueError("right join not supported") + left = left_frame.with_columns( + [ + pl.int_range(pl.len()).alias("_bf_join_l"), + ] ) - if node.order_by: - df = self._sort(df, node.order_by) - return self._aggregate(df, node.aggregations, node.by_column_ids) - - def _aggregate( - self, - df: pl.LazyFrame, - aggregations: Sequence[ - Tuple[agg_expressions.Aggregation, identifiers.ColumnId] - ], - grouping_keys: Tuple[ex.DerefOp, ...], - ) -> pl.LazyFrame: - # Need to materialize columns to broadcast constants - agg_inputs = [ - list( - map( - lambda x: x.alias(guid.generate_guid()), - self.agg_compiler.get_args(agg), - ) + right = right_frame.with_columns( + [ + pl.int_range(pl.len()).alias("_bf_join_r"), + ] ) - for agg, _ in aggregations - ] - - df_agg_inputs = df.with_columns(itertools.chain(*agg_inputs)) - - agg_exprs = [ - self.agg_compiler.compile_agg_op( - agg.op, list(map(lambda x: x.meta.output_name(), inputs)) - ).alias(id.sql) - for (agg, id), inputs in zip(aggregations, agg_inputs) - ] - - if len(grouping_keys) > 0: - group_exprs = [pl.col(ref.id.sql) for ref in grouping_keys] - grouped_df = df_agg_inputs.group_by(group_exprs) - return grouped_df.agg(agg_exprs).sort(group_exprs, nulls_last=True) - else: - return df_agg_inputs.select(agg_exprs) - - @compile_node.register - def compile_explode(self, node: nodes.ExplodeNode): - assert node.offsets_col is None - df = self.compile_node(node.child) - cols = [col.id.sql for col in node.column_ids] - return df.explode(cols) - - @compile_node.register - def compile_sample(self, node: nodes.RandomSampleNode): - df = self.compile_node(node.child) - # Sample is not available on lazyframe - return df.collect().sample(fraction=node.fraction).lazy() - - @compile_node.register - def compile_window(self, node: nodes.WindowOpNode): - df = self.compile_node(node.child) - - window = node.window_spec - # Should have been handled by reweriter - assert len(window.ordering) == 0 - if window.min_periods > 0: - raise NotImplementedError("min_period not yet supported for polars engine") - - if (window.bounds is None) or (window.is_unbounded): - # polars will automatically broadcast the aggregate to the matching input rows - agg_pl = self.agg_compiler.compile_agg_expr(node.expression) - if window.grouping_keys: - agg_pl = agg_pl.over( - self.expr_compiler.compile_expression(key) - for key in window.grouping_keys + if how != "cross": + joined = left.join( + right, + how=how, + left_on=left_on, + right_on=right_on, + # Note: join_nulls renamed to nulls_equal for polars 1.24 + join_nulls=join_nulls, # type: ignore + coalesce=False, ) - result = df.with_columns(agg_pl.alias(node.output_name.sql)) - else: # row-bounded window - window_result = self._calc_row_analytic_func( - df, node.expression, node.window_spec, node.output_name.sql - ) - result = pl.concat([df, window_result], how="horizontal") + else: + joined = left.join(right, how=how, coalesce=False) - # Probably easier just to pull this out as a rewriter - if ( - node.expression.op.skips_nulls - and not node.never_skip_nulls - and node.expression.column_references - ): - nullity_expr = functools.reduce( - operator.or_, - ( - pl.col(column.sql).is_null() - for column in node.expression.column_references - ), + join_order = ( + ["_bf_join_l", "_bf_join_r"] + if how != "right" + else ["_bf_join_r", "_bf_join_l"] ) - result = result.with_columns( - pl.when(nullity_expr) - .then(None) - .otherwise(pl.col(node.output_name.sql)) - .alias(node.output_name.sql) + return joined.sort(join_order, nulls_last=True).drop( + ["_bf_join_l", "_bf_join_r"] ) - return result - - def _calc_row_analytic_func( - self, - frame: pl.LazyFrame, - agg_expr: agg_expressions.Aggregation, - window: window_spec.WindowSpec, - name: str, - ) -> pl.LazyFrame: - if not isinstance(window.bounds, window_spec.RowsWindowBounds): - raise NotImplementedError("Only row bounds supported by polars engine") - groupby = None - if len(window.grouping_keys) > 0: - groupby = [ - self.expr_compiler.compile_expression(ref) - for ref in window.grouping_keys + + @compile_node.register + def compile_concat(self, node: nodes.ConcatNode): + child_frames = [self.compile_node(child) for child in node.child_nodes] + child_frames = [ + frame.rename( + {col: id.sql for col, id in zip(frame.columns, node.output_ids)} + ).cast( + { + field.id.sql: _bigframes_dtype_to_polars_dtype(field.dtype) + for field in node.fields + } + ) + for frame in child_frames + ] + df = pl.concat(child_frames) + return df + + @compile_node.register + def compile_agg(self, node: nodes.AggregateNode): + df = self.compile_node(node.child) + if node.dropna and len(node.by_column_ids) > 0: + df = df.filter( + [pl.col(ref.id.sql).is_not_null() for ref in node.by_column_ids] + ) + if node.order_by: + df = self._sort(df, node.order_by) + return self._aggregate(df, node.aggregations, node.by_column_ids) + + def _aggregate( + self, + df: pl.LazyFrame, + aggregations: Sequence[ + Tuple[agg_expressions.Aggregation, identifiers.ColumnId] + ], + grouping_keys: Tuple[ex.DerefOp, ...], + ) -> pl.LazyFrame: + # Need to materialize columns to broadcast constants + agg_inputs = [ + list( + map( + lambda x: x.alias(guid.generate_guid()), + self.agg_compiler.get_args(agg), + ) + ) + for agg, _ in aggregations ] - # Polars API semi-bounded, and any grouped rolling window challenging - # https://github.com/pola-rs/polars/issues/4799 - # https://github.com/pola-rs/polars/issues/8976 - pl_agg_expr = self.agg_compiler.compile_agg_expr(agg_expr).alias(name) - index_col_name = "_bf_pl_engine_offsets" - indexed_df = frame.with_row_index(index_col_name) - # https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.rolling.html - period_n, offset_n = _get_period_and_offset(window.bounds) - return ( - indexed_df.rolling( - index_column=index_col_name, - period=f"{period_n}i", - offset=f"{offset_n}i" if (offset_n is not None) else None, - group_by=groupby, + df_agg_inputs = df.with_columns(itertools.chain(*agg_inputs)) + + agg_exprs = [ + self.agg_compiler.compile_agg_op( + agg.op, list(map(lambda x: x.meta.output_name(), inputs)) + ).alias(id.sql) + for (agg, id), inputs in zip(aggregations, agg_inputs) + ] + + if len(grouping_keys) > 0: + group_exprs = [pl.col(ref.id.sql) for ref in grouping_keys] + grouped_df = df_agg_inputs.group_by(group_exprs) + return grouped_df.agg(agg_exprs).sort(group_exprs, nulls_last=True) + else: + return df_agg_inputs.select(agg_exprs) + + @compile_node.register + def compile_explode(self, node: nodes.ExplodeNode): + assert node.offsets_col is None + df = self.compile_node(node.child) + cols = [col.id.sql for col in node.column_ids] + return df.explode(cols) + + @compile_node.register + def compile_sample(self, node: nodes.RandomSampleNode): + df = self.compile_node(node.child) + # Sample is not available on lazyframe + return df.collect().sample(fraction=node.fraction).lazy() + + @compile_node.register + def compile_window(self, node: nodes.WindowOpNode): + df = self.compile_node(node.child) + + window = node.window_spec + # Should have been handled by reweriter + assert len(window.ordering) == 0 + if window.min_periods > 0: + raise NotImplementedError( + "min_period not yet supported for polars engine" + ) + + if (window.bounds is None) or (window.is_unbounded): + # polars will automatically broadcast the aggregate to the matching input rows + agg_pl = self.agg_compiler.compile_agg_expr(node.expression) + if window.grouping_keys: + agg_pl = agg_pl.over( + self.expr_compiler.compile_expression(key) + for key in window.grouping_keys + ) + result = df.with_columns(agg_pl.alias(node.output_name.sql)) + else: # row-bounded window + window_result = self._calc_row_analytic_func( + df, node.expression, node.window_spec, node.output_name.sql + ) + result = pl.concat([df, window_result], how="horizontal") + + # Probably easier just to pull this out as a rewriter + if ( + node.expression.op.skips_nulls + and not node.never_skip_nulls + and node.expression.column_references + ): + nullity_expr = functools.reduce( + operator.or_, + ( + pl.col(column.sql).is_null() + for column in node.expression.column_references + ), + ) + result = result.with_columns( + pl.when(nullity_expr) + .then(None) + .otherwise(pl.col(node.output_name.sql)) + .alias(node.output_name.sql) + ) + return result + + def _calc_row_analytic_func( + self, + frame: pl.LazyFrame, + agg_expr: agg_expressions.Aggregation, + window: window_spec.WindowSpec, + name: str, + ) -> pl.LazyFrame: + if not isinstance(window.bounds, window_spec.RowsWindowBounds): + raise NotImplementedError("Only row bounds supported by polars engine") + groupby = None + if len(window.grouping_keys) > 0: + groupby = [ + self.expr_compiler.compile_expression(ref) + for ref in window.grouping_keys + ] + + # Polars API semi-bounded, and any grouped rolling window challenging + # https://github.com/pola-rs/polars/issues/4799 + # https://github.com/pola-rs/polars/issues/8976 + pl_agg_expr = self.agg_compiler.compile_agg_expr(agg_expr).alias(name) + index_col_name = "_bf_pl_engine_offsets" + indexed_df = frame.with_row_index(index_col_name) + # https://docs.pola.rs/api/python/stable/reference/dataframe/api/polars.DataFrame.rolling.html + period_n, offset_n = _get_period_and_offset(window.bounds) + return ( + indexed_df.rolling( + index_column=index_col_name, + period=f"{period_n}i", + offset=f"{offset_n}i" if (offset_n is not None) else None, + group_by=groupby, + ) + .agg(pl_agg_expr) + .select(name) ) - .agg(pl_agg_expr) - .select(name) - ) def _get_period_and_offset( diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index bc2bbb963b..ec458cc462 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -1770,8 +1770,6 @@ def to_pandas( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col': [4, 2, 2]}) Download the data from BigQuery and convert it into an in-memory pandas DataFrame. @@ -1892,8 +1890,6 @@ def to_pandas_batches( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col': [4, 3, 2, 2, 3]}) Iterate through the results in batches, limiting the total rows yielded @@ -4252,9 +4248,6 @@ def _resample( **Examples:** >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None - >>> data = { ... "timestamp_col": pd.date_range( ... start="2021-01-01 13:00:00", periods=30, freq="1s" diff --git a/bigframes/ml/compose.py b/bigframes/ml/compose.py index 92c98695cd..54ce7066cb 100644 --- a/bigframes/ml/compose.py +++ b/bigframes/ml/compose.py @@ -69,7 +69,6 @@ class SQLScalarColumnTransformer: >>> from bigframes.ml.compose import ColumnTransformer, SQLScalarColumnTransformer >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'name': ["James", None, "Mary"], 'city': ["New York", "Boston", None]}) >>> col_trans = ColumnTransformer([ diff --git a/bigframes/operations/ai.py b/bigframes/operations/ai.py index ac294b0fbd..ad58e8825c 100644 --- a/bigframes/operations/ai.py +++ b/bigframes/operations/ai.py @@ -45,7 +45,6 @@ def filter( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.ai_operators = True >>> bpd.options.compute.ai_ops_confirmation_threshold = 25 @@ -115,7 +114,6 @@ def map( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.ai_operators = True >>> bpd.options.compute.ai_ops_confirmation_threshold = 25 @@ -134,7 +132,6 @@ def map( >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.ai_operators = True >>> bpd.options.compute.ai_ops_confirmation_threshold = 25 @@ -266,7 +263,6 @@ def classify( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.ai_operators = True >>> bpd.options.compute.ai_ops_confirmation_threshold = 25 @@ -356,7 +352,6 @@ def join( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.ai_operators = True >>> bpd.options.compute.ai_ops_confirmation_threshold = 25 @@ -496,7 +491,6 @@ def search( ** Examples: ** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> import bigframes >>> bigframes.options.experiments.ai_operators = True @@ -608,7 +602,6 @@ def sim_join( ** Examples: ** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.ai_operators = True >>> bpd.options.compute.ai_ops_confirmation_threshold = 25 diff --git a/bigframes/operations/semantics.py b/bigframes/operations/semantics.py index 9fa5450748..2266702d47 100644 --- a/bigframes/operations/semantics.py +++ b/bigframes/operations/semantics.py @@ -52,7 +52,6 @@ def agg( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.semantic_operators = True >>> bpd.options.compute.semantic_ops_confirmation_threshold = 25 @@ -247,7 +246,6 @@ def cluster_by( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.semantic_operators = True >>> bpd.options.compute.semantic_ops_confirmation_threshold = 25 @@ -321,7 +319,6 @@ def filter(self, instruction: str, model, ground_with_google_search: bool = Fals **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.semantic_operators = True >>> bpd.options.compute.semantic_ops_confirmation_threshold = 25 @@ -435,7 +432,6 @@ def map( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.semantic_operators = True >>> bpd.options.compute.semantic_ops_confirmation_threshold = 25 @@ -558,7 +554,6 @@ def join( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.semantic_operators = True >>> bpd.options.compute.semantic_ops_confirmation_threshold = 25 @@ -697,7 +692,6 @@ def search( ** Examples: ** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> import bigframes >>> bigframes.options.experiments.semantic_operators = True @@ -800,7 +794,6 @@ def top_k( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.semantic_operators = True >>> bpd.options.compute.semantic_ops_confirmation_threshold = 25 @@ -1001,7 +994,6 @@ def sim_join( ** Examples: ** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.options.experiments.semantic_operators = True >>> bpd.options.compute.semantic_ops_confirmation_threshold = 25 diff --git a/bigframes/operations/strings.py b/bigframes/operations/strings.py index 4743483954..efbdd865b0 100644 --- a/bigframes/operations/strings.py +++ b/bigframes/operations/strings.py @@ -68,9 +68,7 @@ def reverse(self) -> series.Series: **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - - >>> s = bpd.Series(["apple", "banana", "", bpd.NA]) + >>> s = bpd.Series(["apple", "banana", "", pd.NA]) >>> s.str.reverse() 0 elppa 1 ananab diff --git a/bigframes/series.py b/bigframes/series.py index 490298d8dd..642e574627 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -532,8 +532,6 @@ def to_pandas( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([4, 3, 2]) Download the data from BigQuery and convert it into an in-memory pandas Series. @@ -660,8 +658,6 @@ def to_pandas_batches( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([4, 3, 2, 2, 3]) Iterate through the results in batches, limiting the total rows yielded @@ -2421,9 +2417,6 @@ def _resample( **Examples:** >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None - >>> data = { ... "timestamp_col": pd.date_range( ... start="2021-01-01 13:00:00", periods=30, freq="1s" diff --git a/bigframes/session/__init__.py b/bigframes/session/__init__.py index 886072b884..6418f2b78f 100644 --- a/bigframes/session/__init__.py +++ b/bigframes/session/__init__.py @@ -617,11 +617,9 @@ def read_gbq_query( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - Simple query input: + >>> import bigframes.pandas as bpd >>> df = bpd.read_gbq_query(''' ... SELECT ... pitcherFirstName, @@ -773,11 +771,9 @@ def read_gbq_table( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - Read a whole table, with arbitrary ordering or ordering corresponding to the primary key(s). + >>> import bigframes.pandas as bpd >>> df = bpd.read_gbq_table("bigquery-public-data.ml_datasets.penguins") See also: :meth:`Session.read_gbq`. @@ -852,8 +848,6 @@ def read_gbq_table_streaming( **Examples:** >>> import bigframes.streaming as bst - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> sdf = bst.read_gbq_table("bigquery-public-data.ml_datasets.penguins") @@ -881,11 +875,9 @@ def read_gbq_model(self, model_name: str): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - Read an existing BigQuery ML model. + >>> import bigframes.pandas as bpd >>> model_name = "bigframes-dev.bqml_tutorial.penguins_model" >>> model = bpd.read_gbq_model(model_name) @@ -951,9 +943,6 @@ def read_pandas( **Examples:** - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None >>> d = {'col1': [1, 2], 'col2': [3, 4]} >>> pandas_df = pd.DataFrame(data=d) @@ -1829,9 +1818,7 @@ def udf( **Examples:** - >>> import bigframes.pandas as bpd >>> import datetime - >>> bpd.options.display.progress_bar = None Turning an arbitrary python function into a BigQuery managed python udf: @@ -1885,7 +1872,7 @@ def udf( You can clean-up the BigQuery functions created above using the BigQuery client from the BigQuery DataFrames session: - >>> session = bpd.get_global_session() + >>> session = bpd.get_global_session() # doctest: +SKIP >>> session.bqclient.delete_routine(minutes_to_hours.bigframes_bigquery_function) # doctest: +SKIP >>> session.bqclient.delete_routine(get_hash.bigframes_bigquery_function) # doctest: +SKIP @@ -1993,12 +1980,10 @@ def read_gbq_function( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - Use the [cw_lower_case_ascii_only](https://github.com/GoogleCloudPlatform/bigquery-utils/blob/master/udfs/community/README.md#cw_lower_case_ascii_onlystr-string) function from Community UDFs. + >>> import bigframes.pandas as bpd >>> func = bpd.read_gbq_function("bqutil.fn.cw_lower_case_ascii_only") You can run it on scalar input. Usually you would do so to verify that @@ -2058,13 +2043,13 @@ def read_gbq_function( Another use case is to define your own remote function and use it later. For example, define the remote function: - >>> @bpd.remote_function(cloud_function_service_account="default") + >>> @bpd.remote_function(cloud_function_service_account="default") # doctest: +SKIP ... def tenfold(num: int) -> float: ... return num * 10 Then, read back the deployed BQ remote function: - >>> tenfold_ref = bpd.read_gbq_function( + >>> tenfold_ref = bpd.read_gbq_function( # doctest: +SKIP ... tenfold.bigframes_remote_function, ... ) @@ -2076,7 +2061,7 @@ def read_gbq_function( [2 rows x 3 columns] - >>> df['a'].apply(tenfold_ref) + >>> df['a'].apply(tenfold_ref) # doctest: +SKIP 0 10.0 1 20.0 Name: a, dtype: Float64 @@ -2085,12 +2070,11 @@ def read_gbq_function( note, row processor implies that the function has only one input parameter. - >>> import pandas as pd - >>> @bpd.remote_function(cloud_function_service_account="default") + >>> @bpd.remote_function(cloud_function_service_account="default") # doctest: +SKIP ... def row_sum(s: pd.Series) -> float: ... return s['a'] + s['b'] + s['c'] - >>> row_sum_ref = bpd.read_gbq_function( + >>> row_sum_ref = bpd.read_gbq_function( # doctest: +SKIP ... row_sum.bigframes_remote_function, ... is_row_processor=True, ... ) @@ -2103,7 +2087,7 @@ def read_gbq_function( [2 rows x 3 columns] - >>> df.apply(row_sum_ref, axis=1) + >>> df.apply(row_sum_ref, axis=1) # doctest: +SKIP 0 9.0 1 12.0 dtype: Float64 diff --git a/conftest.py b/conftest.py index 657a59bc0e..bd2053b092 100644 --- a/conftest.py +++ b/conftest.py @@ -20,11 +20,24 @@ import pytest import bigframes._config -import bigframes.pandas as bpd + + +@pytest.fixture(scope="session") +def polars_session_or_bpd(): + # Since the doctest imports fixture is autouse=True, don't skip if polars + # isn't available. + try: + from bigframes.testing import polars_session + + return polars_session.TestSession() + except ImportError: + import bigframes.pandas as bpd + + return bpd @pytest.fixture(autouse=True) -def default_doctest_imports(doctest_namespace): +def default_doctest_imports(doctest_namespace, polars_session_or_bpd): """ Avoid some boilerplate in pandas-inspired tests. @@ -33,5 +46,10 @@ def default_doctest_imports(doctest_namespace): doctest_namespace["np"] = np doctest_namespace["pd"] = pd doctest_namespace["pa"] = pa - doctest_namespace["bpd"] = bpd + doctest_namespace["bpd"] = polars_session_or_bpd bigframes._config.options.display.progress_bar = None + + # TODO(tswast): Consider setting the numpy printoptions here for better + # compatibility across numpy versions. + # https://numpy.org/doc/stable/release/2.0.0-notes.html#representation-of-numpy-scalars-changed + # https://numpy.org/doc/stable/reference/generated/numpy.set_printoptions.html#numpy-set-printoptions diff --git a/samples/polars/requirements.txt b/samples/polars/requirements.txt index a1d8fbcdac..1626982536 100644 --- a/samples/polars/requirements.txt +++ b/samples/polars/requirements.txt @@ -1,3 +1,3 @@ -bigframes==1.11.1 -polars==1.3.0 -pyarrow==15.0.0 +bigframes==2.25.0 +polars==1.24.0 +pyarrow==21.0.0 diff --git a/tests/system/small/operations/test_strings.py b/tests/system/small/operations/test_strings.py index d3e868db59..6cd6309cbb 100644 --- a/tests/system/small/operations/test_strings.py +++ b/tests/system/small/operations/test_strings.py @@ -288,7 +288,7 @@ def test_strip(scalars_dfs): ], ) def test_strip_w_to_strip(to_strip): - s = bpd.Series(["1. Ant. ", "2. Bee!\n", "3. Cat?\t", bpd.NA]) + s = bpd.Series(["1. Ant. ", "2. Bee!\n", "3. Cat?\t", pd.NA]) pd_s = s.to_pandas() bf_result = s.str.strip(to_strip=to_strip).to_pandas() @@ -434,7 +434,7 @@ def test_rstrip(scalars_dfs): ], ) def test_rstrip_w_to_strip(to_strip): - s = bpd.Series(["1. Ant. ", "2. Bee!\n", "3. Cat?\t", bpd.NA]) + s = bpd.Series(["1. Ant. ", "2. Bee!\n", "3. Cat?\t", pd.NA]) pd_s = s.to_pandas() bf_result = s.str.rstrip(to_strip=to_strip).to_pandas() @@ -469,7 +469,7 @@ def test_lstrip(scalars_dfs): ], ) def test_lstrip_w_to_strip(to_strip): - s = bpd.Series(["1. Ant. ", "2. Bee!\n", "3. Cat?\t", bpd.NA]) + s = bpd.Series(["1. Ant. ", "2. Bee!\n", "3. Cat?\t", pd.NA]) pd_s = s.to_pandas() bf_result = s.str.lstrip(to_strip=to_strip).to_pandas() diff --git a/tests/system/small/test_series.py b/tests/system/small/test_series.py index 65b170df32..df538329ce 100644 --- a/tests/system/small/test_series.py +++ b/tests/system/small/test_series.py @@ -1979,7 +1979,10 @@ def test_series_small_repr(scalars_dfs): col_name = "int64_col" bf_series = scalars_df[col_name] pd_series = scalars_pandas_df[col_name] - assert repr(bf_series) == pd_series.to_string(length=False, dtype=True, name=True) + with bigframes.pandas.option_context("display.repr_mode", "head"): + assert repr(bf_series) == pd_series.to_string( + length=False, dtype=True, name=True + ) def test_sum(scalars_dfs): diff --git a/tests/unit/test_pandas.py b/tests/unit/test_pandas.py index 5e75e6b20f..e1e713697d 100644 --- a/tests/unit/test_pandas.py +++ b/tests/unit/test_pandas.py @@ -174,7 +174,7 @@ def test_cut_raises_with_invalid_bins(bins: int, error_message: str): def test_pandas_attribute(): - assert bpd.NA is pd.NA + assert pd.NA is pd.NA assert bpd.BooleanDtype is pd.BooleanDtype assert bpd.Float64Dtype is pd.Float64Dtype assert bpd.Int64Dtype is pd.Int64Dtype diff --git a/tests/unit/test_series_polars.py b/tests/unit/test_series_polars.py index ee4ac245d3..e978ed43da 100644 --- a/tests/unit/test_series_polars.py +++ b/tests/unit/test_series_polars.py @@ -2009,7 +2009,10 @@ def test_series_small_repr(scalars_dfs): col_name = "int64_col" bf_series = scalars_df[col_name] pd_series = scalars_pandas_df[col_name] - assert repr(bf_series) == pd_series.to_string(length=False, dtype=True, name=True) + with bigframes.pandas.option_context("display.repr_mode", "head"): + assert repr(bf_series) == pd_series.to_string( + length=False, dtype=True, name=True + ) def test_sum(scalars_dfs): diff --git a/third_party/bigframes_vendored/geopandas/geoseries.py b/third_party/bigframes_vendored/geopandas/geoseries.py index 92a58b3dc6..20587b4d57 100644 --- a/third_party/bigframes_vendored/geopandas/geoseries.py +++ b/third_party/bigframes_vendored/geopandas/geoseries.py @@ -18,7 +18,6 @@ class GeoSeries: >>> import bigframes.geopandas >>> import bigframes.pandas as bpd >>> from shapely.geometry import Point - >>> bpd.options.display.progress_bar = None >>> s = bigframes.geopandas.GeoSeries([Point(1, 1), Point(2, 2), Point(3, 3)]) >>> s @@ -73,7 +72,6 @@ def x(self) -> bigframes.series.Series: >>> import bigframes.pandas as bpd >>> import geopandas.array >>> import shapely.geometry - >>> bpd.options.display.progress_bar = None >>> series = bpd.Series( ... [shapely.geometry.Point(1, 2), shapely.geometry.Point(2, 3), shapely.geometry.Point(3, 4)], @@ -100,7 +98,6 @@ def y(self) -> bigframes.series.Series: >>> import bigframes.pandas as bpd >>> import geopandas.array >>> import shapely.geometry - >>> bpd.options.display.progress_bar = None >>> series = bpd.Series( ... [shapely.geometry.Point(1, 2), shapely.geometry.Point(2, 3), shapely.geometry.Point(3, 4)], @@ -129,7 +126,6 @@ def boundary(self) -> bigframes.geopandas.GeoSeries: >>> import bigframes.pandas as bpd >>> import geopandas.array >>> import shapely.geometry - >>> bpd.options.display.progress_bar = None >>> from shapely.geometry import Polygon, LineString, Point >>> s = geopandas.GeoSeries( @@ -171,7 +167,6 @@ def from_xy(cls, x, y, index=None, **kwargs) -> bigframes.geopandas.GeoSeries: >>> import bigframes.pandas as bpd >>> import bigframes.geopandas - >>> bpd.options.display.progress_bar = None >>> x = [2.5, 5, -3.0] >>> y = [0.5, 1, 1.5] @@ -210,7 +205,6 @@ def from_wkt(cls, data, index=None) -> bigframes.geopandas.GeoSeries: >>> import bigframes as bpd >>> import bigframes.geopandas - >>> bpd.options.display.progress_bar = None >>> wkts = [ ... 'POINT (1 1)', @@ -246,7 +240,6 @@ def to_wkt(self) -> bigframes.series.Series: >>> import bigframes as bpd >>> import bigframes.geopandas >>> from shapely.geometry import Point - >>> bpd.options.display.progress_bar = None >>> s = bigframes.geopandas.GeoSeries([Point(1, 1), Point(2, 2), Point(3, 3)]) >>> s @@ -279,7 +272,6 @@ def difference(self: GeoSeries, other: GeoSeries) -> GeoSeries: # type: ignore >>> import bigframes as bpd >>> import bigframes.geopandas >>> from shapely.geometry import Polygon, LineString, Point - >>> bpd.options.display.progress_bar = None We can check two GeoSeries against each other, row by row: @@ -411,7 +403,6 @@ def intersection(self: GeoSeries, other: GeoSeries) -> GeoSeries: # type: ignor >>> import bigframes as bpd >>> import bigframes.geopandas >>> from shapely.geometry import Polygon, LineString, Point - >>> bpd.options.display.progress_bar = None We can check two GeoSeries against each other, row by row. diff --git a/third_party/bigframes_vendored/ibis/expr/api.py b/third_party/bigframes_vendored/ibis/expr/api.py index 4ef10e449b..fa09e23b75 100644 --- a/third_party/bigframes_vendored/ibis/expr/api.py +++ b/third_party/bigframes_vendored/ibis/expr/api.py @@ -1532,7 +1532,6 @@ def read_parquet( Examples -------- >>> import ibis - >>> import pandas as pd >>> ibis.options.interactive = True >>> df = pd.DataFrame({"a": [1, 2, 3], "b": list("ghi")}) >>> df @@ -1582,7 +1581,6 @@ def read_delta( Examples -------- >>> import ibis - >>> import pandas as pd >>> ibis.options.interactive = True >>> df = pd.DataFrame({"a": [1, 2, 3], "b": list("ghi")}) >>> df diff --git a/third_party/bigframes_vendored/ibis/expr/datatypes/core.py b/third_party/bigframes_vendored/ibis/expr/datatypes/core.py index eb597cfc6a..4bacebd6d7 100644 --- a/third_party/bigframes_vendored/ibis/expr/datatypes/core.py +++ b/third_party/bigframes_vendored/ibis/expr/datatypes/core.py @@ -62,7 +62,6 @@ def dtype(value: Any, nullable: bool = True) -> DataType: Or other type systems, like numpy/pandas/pyarrow types: - >>> import pyarrow as pa >>> ibis.dtype(pa.int32()) Int32(nullable=True) diff --git a/third_party/bigframes_vendored/ibis/expr/types/arrays.py b/third_party/bigframes_vendored/ibis/expr/types/arrays.py index 72f01334c1..47ae997738 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/arrays.py +++ b/third_party/bigframes_vendored/ibis/expr/types/arrays.py @@ -1008,7 +1008,6 @@ def flatten(self) -> ir.ArrayValue: ... "nulls_only": [None, None, None], ... "mixed_nulls": [[], None, [None]], ... } - >>> import pyarrow as pa >>> t = ibis.memtable( ... pa.Table.from_pydict( ... data, diff --git a/third_party/bigframes_vendored/ibis/expr/types/maps.py b/third_party/bigframes_vendored/ibis/expr/types/maps.py index 881f8327d0..65237decc7 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/maps.py +++ b/third_party/bigframes_vendored/ibis/expr/types/maps.py @@ -35,7 +35,6 @@ class MapValue(Value): -------- >>> import ibis >>> ibis.options.interactive = True - >>> import pyarrow as pa >>> tab = pa.table( ... { ... "m": pa.array( @@ -101,7 +100,6 @@ def get(self, key: ir.Value, default: ir.Value | None = None) -> ir.Value: Examples -------- >>> import ibis - >>> import pyarrow as pa >>> ibis.options.interactive = True >>> tab = pa.table( ... { @@ -167,7 +165,6 @@ def length(self) -> ir.IntegerValue: Examples -------- >>> import ibis - >>> import pyarrow as pa >>> ibis.options.interactive = True >>> tab = pa.table( ... { @@ -224,7 +221,6 @@ def __getitem__(self, key: ir.Value) -> ir.Value: Examples -------- >>> import ibis - >>> import pyarrow as pa >>> ibis.options.interactive = True >>> tab = pa.table( ... { @@ -276,7 +272,6 @@ def contains( Examples -------- >>> import ibis - >>> import pyarrow as pa >>> ibis.options.interactive = True >>> tab = pa.table( ... { @@ -321,7 +316,6 @@ def keys(self) -> ir.ArrayValue: Examples -------- >>> import ibis - >>> import pyarrow as pa >>> ibis.options.interactive = True >>> tab = pa.table( ... { diff --git a/third_party/bigframes_vendored/pandas/core/arrays/arrow/accessors.py b/third_party/bigframes_vendored/pandas/core/arrays/arrow/accessors.py index f4244ab499..94319dbc10 100644 --- a/third_party/bigframes_vendored/pandas/core/arrays/arrow/accessors.py +++ b/third_party/bigframes_vendored/pandas/core/arrays/arrow/accessors.py @@ -19,8 +19,6 @@ def len(self): **Examples:** >>> import bigframes.pandas as bpd - >>> import pyarrow as pa - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... [ ... [1, 2, 3], @@ -45,8 +43,6 @@ def __getitem__(self, key: int | slice): **Examples:** >>> import bigframes.pandas as bpd - >>> import pyarrow as pa - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... [ ... [1, 2, 3], @@ -83,8 +79,6 @@ def field(self, name_or_index: str | int): **Examples:** >>> import bigframes.pandas as bpd - >>> import pyarrow as pa - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... [ ... {"version": 1, "project": "pandas"}, @@ -129,8 +123,6 @@ def explode(self): **Examples:** >>> import bigframes.pandas as bpd - >>> import pyarrow as pa - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... [ ... {"version": 1, "project": "pandas"}, @@ -166,8 +158,6 @@ def dtypes(self): **Examples:** >>> import bigframes.pandas as bpd - >>> import pyarrow as pa - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... [ ... {"version": 1, "project": "pandas"}, @@ -201,8 +191,6 @@ def explode(self, column, *, separator: str = "."): **Examples:** >>> import bigframes.pandas as bpd - >>> import pyarrow as pa - >>> bpd.options.display.progress_bar = None >>> countries = bpd.Series(["cn", "es", "us"]) >>> files = bpd.Series( ... [ diff --git a/third_party/bigframes_vendored/pandas/core/arrays/datetimelike.py b/third_party/bigframes_vendored/pandas/core/arrays/datetimelike.py index 22e946edcd..ace91dad1e 100644 --- a/third_party/bigframes_vendored/pandas/core/arrays/datetimelike.py +++ b/third_party/bigframes_vendored/pandas/core/arrays/datetimelike.py @@ -15,7 +15,6 @@ def strftime(self, date_format: str): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.to_datetime( ... ['2014-08-15 08:15:12', '2012-02-29 08:15:12+06:00', '2015-08-15 08:15:12+05:00'], @@ -51,9 +50,7 @@ def normalize(self): **Examples:** - >>> import pandas as pd >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(pd.date_range( ... start='2014-08-01 10:00', ... freq='h', @@ -86,9 +83,6 @@ def floor(self, freq: str): **Examples:** - >>> import pandas as pd - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> rng = pd.date_range('1/1/2018 11:59:00', periods=3, freq='min') >>> bpd.Series(rng).dt.floor("h") 0 2018-01-01 11:00:00 diff --git a/third_party/bigframes_vendored/pandas/core/computation/eval.py b/third_party/bigframes_vendored/pandas/core/computation/eval.py index d3d11a9c2a..a1809f6cb3 100644 --- a/third_party/bigframes_vendored/pandas/core/computation/eval.py +++ b/third_party/bigframes_vendored/pandas/core/computation/eval.py @@ -172,8 +172,6 @@ def eval( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"animal": ["dog", "pig"], "age": [10, 20]}) >>> df diff --git a/third_party/bigframes_vendored/pandas/core/config_init.py b/third_party/bigframes_vendored/pandas/core/config_init.py index 3425674e4f..dc2b11ab94 100644 --- a/third_party/bigframes_vendored/pandas/core/config_init.py +++ b/third_party/bigframes_vendored/pandas/core/config_init.py @@ -49,7 +49,6 @@ or just remove it. - >>> bpd.options.display.progress_bar = None Setting to default value "auto" will detect and show progress bar automatically. diff --git a/third_party/bigframes_vendored/pandas/core/frame.py b/third_party/bigframes_vendored/pandas/core/frame.py index 4c0abff545..8a2570d4c6 100644 --- a/third_party/bigframes_vendored/pandas/core/frame.py +++ b/third_party/bigframes_vendored/pandas/core/frame.py @@ -39,8 +39,6 @@ def shape(self) -> tuple[int, int]: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2, 3], ... 'col2': [4, 5, 6]}) @@ -63,8 +61,6 @@ def axes(self) -> list: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> df.axes[1:] @@ -78,8 +74,6 @@ def values(self) -> np.ndarray: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> df.values @@ -110,8 +104,6 @@ def T(self) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> df col1 col2 @@ -146,8 +138,6 @@ def transpose(self) -> DataFrame: **Square DataFrame with homogeneous dtype** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> d1 = {'col1': [1, 2], 'col2': [3, 4]} >>> df1 = bpd.DataFrame(data=d1) @@ -256,8 +246,6 @@ def select_dtypes(self, include=None, exclude=None) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': ["hello", "world"], 'col3': [True, False]}) >>> df.select_dtypes(include=['Int64']) @@ -380,8 +368,6 @@ def to_numpy( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> df.to_numpy() @@ -420,7 +406,6 @@ def to_gbq( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Write a DataFrame to a BigQuery table. @@ -530,8 +515,6 @@ def to_parquet( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> gcs_bucket = "gs://bigframes-dev-testing/sample_parquet*.parquet" >>> df.to_parquet(path=gcs_bucket) @@ -586,8 +569,6 @@ def to_dict( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> df.to_dict() @@ -666,9 +647,7 @@ def to_excel( **Examples:** - >>> import bigframes.pandas as bpd >>> import tempfile - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> df.to_excel(tempfile.TemporaryFile()) @@ -703,8 +682,6 @@ def to_latex( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> print(df.to_latex()) @@ -754,8 +731,6 @@ def to_records( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> df.to_records() @@ -814,8 +789,6 @@ def to_string( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> print(df.to_string()) @@ -914,8 +887,6 @@ def to_html( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> print(df.to_html()) @@ -1024,8 +995,6 @@ def to_markdown( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> print(df.to_markdown()) @@ -1058,8 +1027,6 @@ def to_pickle(self, path, *, allow_large_results, **kwargs) -> None: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> gcs_bucket = "gs://bigframes-dev-testing/sample_pickle_gcs.pkl" @@ -1080,8 +1047,6 @@ def to_orc(self, path=None, *, allow_large_results=None, **kwargs) -> bytes | No **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> import tempfile @@ -1190,8 +1155,6 @@ def insert(self, loc, column, value, allow_duplicates=False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) @@ -1243,8 +1206,6 @@ def drop( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame(np.arange(12).reshape(3, 4), ... columns=['A', 'B', 'C', 'D']) @@ -1284,7 +1245,6 @@ def drop( Drop columns and/or rows of MultiIndex DataFrame: - >>> import pandas as pd >>> midx = pd.MultiIndex(levels=[['llama', 'cow', 'falcon'], ... ['speed', 'weight', 'length']], ... codes=[[0, 0, 0, 1, 1, 1, 2, 2, 2], @@ -1402,8 +1362,6 @@ def rename( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}) >>> df @@ -1474,8 +1432,6 @@ def set_index( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'month': [1, 4, 7, 10], ... 'year': [2012, 2014, 2013, 2014], @@ -1616,10 +1572,7 @@ def reset_index( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> import numpy as np >>> df = bpd.DataFrame([('bird', 389.0), ... ('bird', 24.0), ... ('mammal', 80.5), @@ -1659,7 +1612,6 @@ class max_speed You can also use ``reset_index`` with ``MultiIndex``. - >>> import pandas as pd >>> index = pd.MultiIndex.from_tuples([('bird', 'falcon'), ... ('bird', 'parrot'), ... ('mammal', 'lion'), @@ -1795,12 +1747,10 @@ def dropna( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"name": ['Alfred', 'Batman', 'Catwoman'], ... "toy": [np.nan, 'Batmobile', 'Bullwhip'], - ... "born": [bpd.NA, "1940-04-25", bpd.NA]}) + ... "born": [pd.NA, "1940-04-25", pd.NA]}) >>> df name toy born 0 Alfred @@ -1908,8 +1858,6 @@ def isin(self, values): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'num_legs': [2, 4], 'num_wings': [2, 0]}, ... index=['falcon', 'dog']) @@ -1964,8 +1912,6 @@ def keys(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -1985,8 +1931,6 @@ def iterrows(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], ... 'B': [4, 5, 6], @@ -2011,8 +1955,6 @@ def itertuples(self, index: bool = True, name: str | None = "Pandas"): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], ... 'B': [4, 5, 6], @@ -2044,8 +1986,6 @@ def items(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'species': ['bear', 'bear', 'marsupial'], ... 'population': [1864, 22000, 80000]}, @@ -2085,8 +2025,6 @@ def where(self, cond, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'a': [20, 10, 0], 'b': [0, 10, 20]}) >>> df @@ -2177,8 +2115,6 @@ def mask(self, cond, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'a': [20, 10, 0], 'b': [0, 10, 20]}) >>> df @@ -2280,11 +2216,9 @@ def sort_values( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ - ... 'col1': ['A', 'A', 'B', bpd.NA, 'D', 'C'], + ... 'col1': ['A', 'A', 'B', pd.NA, 'D', 'C'], ... 'col2': [2, 1, 9, 8, 7, 4], ... 'col3': [0, 1, 9, 4, 2, 3], ... 'col4': ['a', 'B', 'c', 'D', 'e', 'F'] @@ -2424,8 +2358,6 @@ def eq(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can use method name: @@ -2467,8 +2399,6 @@ def __eq__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, 3, 4], @@ -2498,8 +2428,6 @@ def __invert__(self) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'a':[True, False, True], 'b':[-1, 0, 1]}) >>> ~df @@ -2527,8 +2455,6 @@ def ne(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can use method name: @@ -2569,8 +2495,6 @@ def __ne__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, 3, 4], @@ -2609,8 +2533,6 @@ def le(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can use method name: @@ -2652,8 +2574,6 @@ def __le__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, -1, 1], @@ -2692,8 +2612,6 @@ def lt(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can use method name: @@ -2735,8 +2653,6 @@ def __lt__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, -1, 1], @@ -2775,8 +2691,6 @@ def ge(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can use method name: @@ -2818,8 +2732,6 @@ def __ge__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, -1, 1], @@ -2858,8 +2770,6 @@ def gt(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'angles': [0, 3, 4], ... 'degrees': [360, 180, 360]}, @@ -2899,8 +2809,6 @@ def __gt__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, -1, 1], @@ -2936,8 +2844,6 @@ def add(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -2980,8 +2886,6 @@ def __add__(self, other) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'height': [1.5, 2.6], @@ -3055,8 +2959,6 @@ def radd(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3118,8 +3020,6 @@ def sub(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3162,8 +3062,6 @@ def __sub__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can subtract a scalar: @@ -3210,8 +3108,6 @@ def rsub(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3271,8 +3167,6 @@ def mul(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3315,8 +3209,6 @@ def __mul__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can multiply with a scalar: @@ -3363,8 +3255,6 @@ def rmul(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3407,8 +3297,6 @@ def __rmul__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can multiply with a scalar: @@ -3455,8 +3343,6 @@ def truediv(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3499,8 +3385,6 @@ def __truediv__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can multiply with a scalar: @@ -3547,8 +3431,6 @@ def rtruediv(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3608,8 +3490,6 @@ def floordiv(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3652,8 +3532,6 @@ def __floordiv__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can divide by a scalar: @@ -3700,8 +3578,6 @@ def rfloordiv(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3761,8 +3637,6 @@ def mod(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3805,8 +3679,6 @@ def __mod__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can modulo with a scalar: @@ -3853,8 +3725,6 @@ def rmod(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3915,8 +3785,6 @@ def pow(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -3960,8 +3828,6 @@ def __pow__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can exponentiate with a scalar: @@ -4009,8 +3875,6 @@ def rpow(self, other, axis: str | int = "columns") -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -4105,8 +3969,6 @@ def combine( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df1 = bpd.DataFrame({'A': [0, 0], 'B': [4, 4]}) >>> df2 = bpd.DataFrame({'A': [1, 1], 'B': [3, 3]}) @@ -4155,8 +4017,6 @@ def combine_first(self, other) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df1 = bpd.DataFrame({'A': [None, 0], 'B': [None, 4]}) >>> df2 = bpd.DataFrame({'A': [1, 1], 'B': [3, 3]}) @@ -4185,9 +4045,6 @@ def explode( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': [[0, 1, 2], [], [], [3, 4]], ... 'B': 1, @@ -4244,8 +4101,6 @@ def corr(self, method, min_periods, numeric_only) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': [1, 2, 3], ... 'B': [400, 500, 600], @@ -4278,8 +4133,6 @@ def cov(self, *, numeric_only) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': [1, 2, 3], ... 'B': [400, 500, 600], @@ -4317,8 +4170,6 @@ def corrwith( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> index = ["a", "b", "c", "d", "e"] >>> columns = ["one", "two", "three", "four"] @@ -4353,8 +4204,6 @@ def update( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': [1, 2, 3], ... 'B': [400, 500, 600]}) @@ -4418,8 +4267,6 @@ def groupby( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'Animal': ['Falcon', 'Falcon', ... 'Parrot', 'Parrot'], @@ -4515,15 +4362,12 @@ def map(self, func, na_action: Optional[str] = None) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - Let's use ``reuse=False`` flag to make sure a new ``remote_function`` is created every time we run the following code, but you can skip it to potentially reuse a previously deployed ``remote_function`` from the same user defined function. - >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") # doctest: +SKIP ... def minutes_to_hours(x: int) -> float: ... return x/60 @@ -4540,8 +4384,8 @@ def map(self, func, na_action: Optional[str] = None) -> DataFrame: [5 rows x 2 columns] - >>> df_hours = df_minutes.map(minutes_to_hours) - >>> df_hours + >>> df_hours = df_minutes.map(minutes_to_hours) # doctest: +SKIP + >>> df_hours # doctest: +SKIP system_minutes user_minutes 0 0.0 0.0 1 0.5 0.25 @@ -4557,11 +4401,11 @@ def map(self, func, na_action: Optional[str] = None) -> DataFrame: >>> df_minutes = bpd.DataFrame( ... { - ... "system_minutes" : [0, 30, 60, None, 90, 120, bpd.NA], - ... "user_minutes" : [0, 15, 75, 90, 6, None, bpd.NA] + ... "system_minutes" : [0, 30, 60, None, 90, 120, pd.NA], + ... "user_minutes" : [0, 15, 75, 90, 6, None, pd.NA] ... }, dtype="Int64") - >>> df_hours = df_minutes.map(minutes_to_hours, na_action='ignore') - >>> df_hours + >>> df_hours = df_minutes.map(minutes_to_hours, na_action='ignore') # doctest: +SKIP + >>> df_hours # doctest: +SKIP system_minutes user_minutes 0 0.0 0.0 1 0.5 0.25 @@ -4612,8 +4456,6 @@ def join( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Join two DataFrames by specifying how to handle the operation: @@ -4764,8 +4606,6 @@ def merge( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Merge DataFrames df1 and df2 by specifying type of merge: @@ -4897,7 +4737,6 @@ def round(self, decimals): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([(.21, .32), (.01, .67), (.66, .03), (.21, .18)], ... columns=['dogs', 'cats']) >>> df @@ -4980,9 +4819,6 @@ def apply(self, func, *, axis=0, args=(), **kwargs): **Examples:** - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> df @@ -5008,14 +4844,14 @@ def apply(self, func, *, axis=0, args=(), **kwargs): to select only the necessary columns before calling `apply()`. Note: This feature is currently in **preview**. - >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") # doctest: +SKIP ... def foo(row: pd.Series) -> int: ... result = 1 ... result += row["col1"] ... result += row["col2"]*row["col2"] ... return result - >>> df[["col1", "col2"]].apply(foo, axis=1) + >>> df[["col1", "col2"]].apply(foo, axis=1) # doctest: +SKIP 0 11 1 19 dtype: Int64 @@ -5023,7 +4859,7 @@ def apply(self, func, *, axis=0, args=(), **kwargs): You could return an array output for every input row from the remote function. - >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") # doctest: +SKIP ... def marks_analyzer(marks: pd.Series) -> list[float]: ... import statistics ... average = marks.mean() @@ -5040,8 +4876,8 @@ def apply(self, func, *, axis=0, args=(), **kwargs): ... "chemistry": [88, 56, 72], ... "algebra": [78, 91, 79] ... }, index=["Alice", "Bob", "Charlie"]) - >>> stats = df.apply(marks_analyzer, axis=1) - >>> stats + >>> stats = df.apply(marks_analyzer, axis=1) # doctest: +SKIP + >>> stats # doctest: +SKIP Alice [77.67 78. 77.19 76.71] Bob [75.67 80. 74.15 72.56] Charlie [75.33 75. 75.28 75.22] @@ -5064,14 +4900,14 @@ def apply(self, func, *, axis=0, args=(), **kwargs): [2 rows x 3 columns] - >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") # doctest: +SKIP ... def foo(x: int, y: int, z: int) -> float: ... result = 1 ... result += x ... result += y/z ... return result - >>> df.apply(foo, axis=1) + >>> df.apply(foo, axis=1) # doctest: +SKIP 0 2.6 1 3.8 dtype: Float64 @@ -5131,8 +4967,6 @@ def any(self, *, axis=0, bool_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [True, True], "B": [False, False]}) >>> df @@ -5178,8 +5012,6 @@ def all(self, axis=0, *, bool_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [True, True], "B": [False, False]}) >>> df @@ -5222,8 +5054,6 @@ def prod(self, axis=0, *, numeric_only: bool = False): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({"A": [1, 2, 3], "B": [4.5, 5.5, 6.5]}) >>> df A B @@ -5268,8 +5098,6 @@ def min(self, axis=0, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, 3], "B": [2, 4]}) >>> df @@ -5313,8 +5141,6 @@ def max(self, axis=0, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, 3], "B": [2, 4]}) >>> df @@ -5357,8 +5183,6 @@ def sum(self, axis=0, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, 3], "B": [2, 4]}) >>> df @@ -5399,8 +5223,6 @@ def mean(self, axis=0, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, 3], "B": [2, 4]}) >>> df @@ -5442,8 +5264,6 @@ def median(self, *, numeric_only: bool = False, exact: bool = True): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({"A": [1, 3], "B": [2, 4]}) >>> df A B @@ -5480,7 +5300,6 @@ def quantile( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame(np.array([[1, 1], [2, 10], [3, 100], [4, 100]]), ... columns=['a', 'b']) >>> df.quantile(.1) @@ -5517,8 +5336,6 @@ def var(self, axis=0, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, 3], "B": [2, 4]}) >>> df @@ -5562,8 +5379,6 @@ def skew(self, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': [1, 2, 3, 4, 5], ... 'B': [5, 4, 3, 2, 1], @@ -5603,8 +5418,6 @@ def kurt(self, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, 2, 3, 4, 5], ... "B": [3, 4, 3, 2, 1], @@ -5643,8 +5456,6 @@ def std(self, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, 2, 3, 4, 5], ... "B": [3, 4, 3, 2, 1], @@ -5685,8 +5496,6 @@ def count(self, *, numeric_only: bool = False): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, None, 3, 4, 5], ... "B": [1, 2, 3, 4, 5], @@ -5739,8 +5548,6 @@ def nlargest(self, n: int, columns, keep: str = "first"): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({"A": [1, 1, 3, 3, 5, 5], ... "B": [5, 6, 3, 4, 1, 2], ... "C": ['a', 'b', 'a', 'b', 'a', 'b']}) @@ -5831,8 +5638,6 @@ def nsmallest(self, n: int, columns, keep: str = "first"): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({"A": [1, 1, 3, 3, 5, 5], ... "B": [5, 6, 3, 4, 1, 2], ... "C": ['a', 'b', 'a', 'b', 'a', 'b']}) @@ -5912,8 +5717,6 @@ def idxmin(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 3]}) >>> df @@ -5942,8 +5745,6 @@ def idxmax(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 3]}) >>> df @@ -5976,8 +5777,6 @@ def melt(self, id_vars, value_vars, var_name, value_name): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [1, None, 3, 4, 5], ... "B": [1, 2, 3, 4, 5], @@ -6051,8 +5850,6 @@ def nunique(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 2]}) >>> df @@ -6080,8 +5877,6 @@ def cummin(self) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 3]}) >>> df @@ -6112,8 +5907,6 @@ def cummax(self) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 3]}) >>> df @@ -6144,8 +5937,6 @@ def cumsum(self) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 3]}) >>> df @@ -6181,8 +5972,6 @@ def cumprod(self) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 3]}) >>> df @@ -6222,8 +6011,6 @@ def diff( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 3]}) >>> df @@ -6270,8 +6057,6 @@ def agg(self, func): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [1, 2, 3]}) >>> df @@ -6335,8 +6120,6 @@ def describe(self, include: None | Literal["all"] = None): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [0, 2, 8], "C": ["cat", "cat", "dog"]}) >>> df A B C @@ -6406,8 +6189,6 @@ def pivot(self, *, columns, index=None, values=None): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... "foo": ["one", "one", "one", "two", "two"], @@ -6477,8 +6258,6 @@ def pivot_table(self, values=None, index=None, columns=None, aggfunc="mean"): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({ ... 'Product': ['Product A', 'Product B', 'Product A', 'Product B', 'Product A', 'Product B'], ... 'Region': ['East', 'West', 'East', 'West', 'West', 'East'], @@ -6569,8 +6348,6 @@ def stack(self, level=-1): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': [1, 3], 'B': [2, 4]}, index=['foo', 'bar']) >>> df @@ -6608,8 +6385,6 @@ def unstack(self, level=-1): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': [1, 3], 'B': [2, 4]}, index=['foo', 'bar']) >>> df @@ -6649,8 +6424,6 @@ def index(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can access the index of a DataFrame via ``index`` property. @@ -6702,8 +6475,6 @@ def columns(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can access the column labels of a DataFrame via ``columns`` property. @@ -6750,11 +6521,9 @@ def value_counts( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'num_legs': [2, 4, 4, 6, 7], - ... 'num_wings': [2, 0, 0, 0, bpd.NA]}, + ... 'num_wings': [2, 0, 0, 0, pd.NA]}, ... index=['falcon', 'dog', 'cat', 'ant', 'octopus'], ... dtype='Int64') >>> df @@ -6831,8 +6600,6 @@ def eval(self, expr: str) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': range(1, 6), 'B': range(10, 0, -2)}) >>> df @@ -6907,8 +6674,6 @@ def query(self, expr: str) -> DataFrame | None: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': range(1, 6), ... 'B': range(10, 0, -2), @@ -6982,8 +6747,6 @@ def interpolate(self, method: str = "linear"): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3, None, None, 6], @@ -7114,8 +6877,6 @@ def replace( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({ ... 'int_col': [1, 1, 2, 3], ... 'string_col': ["a", "b", "c", "b"], @@ -7210,8 +6971,6 @@ def iat(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([[0, 2, 3], [0, 4, 1], [10, 20, 30]], ... columns=['A', 'B', 'C']) @@ -7244,8 +7003,6 @@ def at(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([[0, 2, 3], [0, 4, 1], [10, 20, 30]], ... index=[4, 5, 6], columns=['A', 'B', 'C']) @@ -7293,8 +7050,6 @@ def dot(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> left = bpd.DataFrame([[0, 1, -2, -1], [1, 1, 1, 1]]) >>> left @@ -7387,8 +7142,6 @@ def __matmul__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> left = bpd.DataFrame([[0, 1, -2, -1], [1, 1, 1, 1]]) >>> left @@ -7447,8 +7200,6 @@ def __len__(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, 1, 2], @@ -7470,9 +7221,6 @@ def __array__(self, dtype=None, copy: Optional[bool] = None): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> import numpy as np >>> df = bpd.DataFrame({"a": [1, 2, 3], "b": [11, 22, 33]}) @@ -7505,8 +7253,6 @@ def __getitem__(self, key): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... "name" : ["alpha", "beta", "gamma"], @@ -7551,7 +7297,6 @@ def __getitem__(self, key): You can specify a pandas Index with desired column labels. - >>> import pandas as pd >>> df[pd.Index(["age", "location"])] age location 0 20 WA @@ -7580,8 +7325,6 @@ def __setitem__(self, key, value): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... "name" : ["alpha", "beta", "gamma"], diff --git a/third_party/bigframes_vendored/pandas/core/generic.py b/third_party/bigframes_vendored/pandas/core/generic.py index bf67326025..63b9f8199b 100644 --- a/third_party/bigframes_vendored/pandas/core/generic.py +++ b/third_party/bigframes_vendored/pandas/core/generic.py @@ -38,8 +38,6 @@ def size(self) -> int: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series({'a': 1, 'b': 2, 'c': 3}) >>> s.size @@ -65,8 +63,6 @@ def __iter__(self) -> Iterator: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -106,9 +102,6 @@ def astype(self, dtype): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - Create a DataFrame: >>> d = {'col1': [1, 2], 'col2': [3, 4]} @@ -152,7 +145,7 @@ def astype(self, dtype): Note that this is equivalent of using ``to_datetime`` with ``unit='us'``: - >>> bpd.to_datetime(ser, unit='us', utc=True) + >>> bpd.to_datetime(ser, unit='us', utc=True) # doctest: +SKIP 0 2034-02-08 11:13:20.246789+00:00 1 2021-06-19 17:20:44.123101+00:00 2 2003-06-05 17:30:34.120101+00:00 @@ -350,8 +343,6 @@ def get(self, key, default=None): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame( ... [ @@ -461,8 +452,6 @@ def head(self, n: int = 5): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'animal': ['alligator', 'bee', 'falcon', 'lion', ... 'monkey', 'parrot', 'shark', 'whale', 'zebra']}) @@ -562,8 +551,6 @@ def sample( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({'num_legs': [2, 4, 8, 0], ... 'num_wings': [2, 0, 0, 0], ... 'num_specimen_seen': [10, 2, 1, 8]}, @@ -643,8 +630,6 @@ def dtypes(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'float': [1.0], 'int': [1], 'string': ['foo']}) >>> df.dtypes @@ -668,8 +653,6 @@ def copy(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Modification in the original Series will not affect the copy Series: @@ -741,9 +724,6 @@ def ffill(self, *, limit: Optional[int] = None): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([[np.nan, 2, np.nan, 0], ... [3, 4, np.nan, 1], @@ -1081,8 +1061,6 @@ def rolling( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([0,1,2,3,4]) >>> s.rolling(window=3).min() 0 @@ -1167,9 +1145,6 @@ def pipe( Constructing a income DataFrame from a dictionary. - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> data = [[8000, 1000], [9500, np.nan], [5000, 2000]] >>> df = bpd.DataFrame(data, columns=['Salary', 'Others']) diff --git a/third_party/bigframes_vendored/pandas/core/groupby/__init__.py b/third_party/bigframes_vendored/pandas/core/groupby/__init__.py index 1e39ec8f94..ba6310507d 100644 --- a/third_party/bigframes_vendored/pandas/core/groupby/__init__.py +++ b/third_party/bigframes_vendored/pandas/core/groupby/__init__.py @@ -45,8 +45,6 @@ def describe(self, include: None | Literal["all"] = None): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame({"A": [1, 1, 1, 2, 2], "B": [0, 2, 8, 2, 7], "C": ["cat", "cat", "dog", "mouse", "cat"]}) >>> df A B C @@ -85,8 +83,6 @@ def any(self): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b'] >>> ser = bpd.Series([1, 2, 0], index=lst) @@ -124,8 +120,6 @@ def all(self): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b'] >>> ser = bpd.Series([1, 2, 0], index=lst) @@ -163,9 +157,6 @@ def count(self): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b'] >>> ser = bpd.Series([1, 2, np.nan], index=lst) @@ -202,9 +193,6 @@ def mean( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': [1, 1, 2, 1, 2], ... 'B': [np.nan, 2, 3, 4, 5], ... 'C': [1, 2, 1, 1, 2]}, columns=['A', 'B', 'C']) @@ -263,9 +251,6 @@ def median( For SeriesGroupBy: >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None - >>> lst = ['a', 'a', 'a', 'b', 'b', 'b'] >>> ser = bpd.Series([7, 2, 8, 4, 3, 3], index=lst) >>> ser.groupby(level=0).median() @@ -304,7 +289,6 @@ def quantile(self, q=0.5, *, numeric_only: bool = False): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([ ... ['a', 1], ['a', 2], ['a', 3], ... ['b', 1], ['b', 3], ['b', 5] @@ -343,9 +327,6 @@ def std( For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'a', 'b', 'b', 'b'] >>> ser = bpd.Series([7, 2, 8, 4, 3, 3], index=lst) @@ -390,9 +371,6 @@ def var( For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'a', 'b', 'b', 'b'] >>> ser = bpd.Series([7, 2, 8, 4, 3, 3], index=lst) @@ -435,9 +413,6 @@ def rank( **Examples:** >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None - >>> df = bpd.DataFrame( ... { ... "group": ["a", "a", "a", "a", "a", "b", "b", "b", "b", "b"], @@ -510,9 +485,6 @@ def skew( For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series([390., 350., 357., np.nan, 22., 20., 30.], ... index=['Falcon', 'Falcon', 'Falcon', 'Falcon', @@ -546,8 +518,6 @@ def kurt( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'a', 'a', 'b', 'b', 'b', 'b', 'b'] >>> ser = bpd.Series([0, 1, 1, 0, 0, 1, 2, 4, 5], index=lst) @@ -579,8 +549,6 @@ def kurtosis( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'a', 'a', 'b', 'b', 'b', 'b', 'b'] >>> ser = bpd.Series([0, 1, 1, 0, 0, 1, 2, 4, 5], index=lst) @@ -606,9 +574,8 @@ def first(self, numeric_only: bool = False, min_count: int = -1): Defaults to skipping NA elements. **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None + >>> import bigframes.pandas as bpd >>> df = bpd.DataFrame(dict(A=[1, 1, 3], B=[None, 5, 6], C=[1, 2, 3])) >>> df.groupby("A").first() B C @@ -647,8 +614,6 @@ def last(self, numeric_only: bool = False, min_count: int = -1): Defaults to skipping NA elements. **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame(dict(A=[1, 1, 3], B=[5, None, 6], C=[1, 2, 3])) >>> df.groupby("A").last() @@ -685,8 +650,6 @@ def sum( For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b', 'b'] >>> ser = bpd.Series([1, 2, 3, 4], index=lst) @@ -730,9 +693,6 @@ def prod(self, numeric_only: bool = False, min_count: int = 0): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b', 'b'] >>> ser = bpd.Series([1, 2, 3, 4], index=lst) @@ -766,9 +726,6 @@ def min( For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b', 'b'] >>> ser = bpd.Series([1, 2, 3, 4], index=lst) @@ -815,8 +772,6 @@ def max( For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b', 'b'] >>> ser = bpd.Series([1, 2, 3, 4], index=lst) @@ -859,8 +814,6 @@ def cumcount(self, ascending: bool = True): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b', 'b', 'c'] >>> ser = bpd.Series([5, 1, 2, 3, 4], index=lst) @@ -897,9 +850,6 @@ def cumprod(self, *args, **kwargs): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b'] >>> ser = bpd.Series([6, 2, 0], index=lst) @@ -936,9 +886,6 @@ def cumsum(self, *args, **kwargs): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b'] >>> ser = bpd.Series([6, 2, 0], index=lst) @@ -975,9 +922,6 @@ def cummin(self, *args, numeric_only: bool = False, **kwargs): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b'] >>> ser = bpd.Series([6, 2, 0], index=lst) @@ -1014,9 +958,6 @@ def cummax(self, *args, numeric_only: bool = False, **kwargs): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b'] >>> ser = bpd.Series([6, 2, 0], index=lst) @@ -1055,9 +996,6 @@ def diff(self): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'a', 'b', 'b', 'b'] >>> ser = bpd.Series([7, 2, 8, 4, 3, 3], index=lst) @@ -1101,9 +1039,6 @@ def shift(self, periods: int = 1): For SeriesGroupBy: - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b', 'b'] >>> ser = bpd.Series([1, 2, 3, 4], index=lst) @@ -1145,9 +1080,6 @@ def rolling(self, *args, **kwargs): **Examples:** >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None - >>> lst = ['a', 'a', 'a', 'a', 'e'] >>> ser = bpd.Series([1, 0, -2, -1, 2], index=lst) >>> ser.groupby(level=0).rolling(2).min() @@ -1204,9 +1136,6 @@ def expanding(self, *args, **kwargs): **Examples:** >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None - >>> lst = ['a', 'a', 'c', 'c', 'e'] >>> ser = bpd.Series([1, 0, -2, -1, 2], index=lst) >>> ser.groupby(level=0).expanding().min() @@ -1230,8 +1159,6 @@ def head(self, n: int = 5): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([[1, 2], [1, 4], [5, 6]], ... columns=['A', 'B']) @@ -1259,8 +1186,6 @@ def size(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None For SeriesGroupBy: @@ -1313,8 +1238,6 @@ def __iter__(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None For SeriesGroupBy: @@ -1377,9 +1300,6 @@ def agg(self, func): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3, 4], index=[1, 1, 2, 2]) >>> s.groupby(level=0).agg(['min', 'max']) @@ -1410,9 +1330,6 @@ def aggregate(self, func): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3, 4], index=[1, 1, 2, 2]) >>> s.groupby(level=0).aggregate(['min', 'max']) @@ -1443,9 +1360,6 @@ def nunique(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> lst = ['a', 'a', 'b', 'b'] >>> ser = bpd.Series([1, 2, 3, 3], index=lst) @@ -1494,9 +1408,6 @@ def agg(self, func, **kwargs): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> data = {"A": [1, 1, 2, 2], ... "B": [1, 2, 3, 4], @@ -1554,9 +1465,6 @@ def aggregate(self, func, **kwargs): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> data = {"A": [1, 1, 2, 2], ... "B": [1, 2, 3, 4], @@ -1614,9 +1522,6 @@ def nunique(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'id': ['spam', 'egg', 'egg', 'spam', ... 'ham', 'ham'], @@ -1650,9 +1555,6 @@ def value_counts( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'gender': ['male', 'male', 'female', 'male', 'female', 'male'], diff --git a/third_party/bigframes_vendored/pandas/core/indexes/accessor.py b/third_party/bigframes_vendored/pandas/core/indexes/accessor.py index 0dd487d056..b9eb363b29 100644 --- a/third_party/bigframes_vendored/pandas/core/indexes/accessor.py +++ b/third_party/bigframes_vendored/pandas/core/indexes/accessor.py @@ -12,9 +12,6 @@ def day(self): **Examples:** - >>> import pandas as pd - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range("2000-01-01", periods=3, freq="D") ... ) @@ -42,9 +39,6 @@ def dayofweek(self): **Examples:** - >>> import pandas as pd - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range('2016-12-31', '2017-01-08', freq='D').to_series() ... ) @@ -76,9 +70,6 @@ def day_of_week(self): **Examples:** - >>> import pandas as pd - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range('2016-12-31', '2017-01-08', freq='D').to_series() ... ) @@ -106,9 +97,7 @@ def dayofyear(self): **Examples:** - >>> import pandas as pd >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range('2016-12-28', '2017-01-03', freq='D').to_series() ... ) @@ -134,9 +123,7 @@ def day_of_year(self): **Examples:** - >>> import pandas as pd >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range('2016-12-28', '2017-01-03', freq='D').to_series() ... ) @@ -168,7 +155,6 @@ def date(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["1/1/2020 10:00:00+00:00", "2/1/2020 11:00:00+00:00"]) >>> s = bpd.to_datetime(s, utc=True, format="%d/%m/%Y %H:%M:%S%Ez") >>> s @@ -189,9 +175,7 @@ def hour(self): **Examples:** - >>> import pandas as pd >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range("2000-01-01", periods=3, freq="h") ... ) @@ -215,9 +199,7 @@ def minute(self): **Examples:** - >>> import pandas as pd >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range("2000-01-01", periods=3, freq="min") ... ) @@ -241,9 +223,6 @@ def month(self): **Examples:** - >>> import pandas as pd - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range("2000-01-01", periods=3, freq="M") ... ) @@ -267,9 +246,6 @@ def isocalendar(self): **Examples:** - >>> import pandas as pd - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range('2009-12-27', '2010-01-04', freq='d').to_series() ... ) @@ -300,9 +276,7 @@ def second(self): **Examples:** - >>> import pandas as pd >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range("2000-01-01", periods=3, freq="s") ... ) @@ -331,7 +305,6 @@ def time(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["1/1/2020 10:00:00+00:00", "2/1/2020 11:00:00+00:00"]) >>> s = bpd.to_datetime(s, utc=True, format="%m/%d/%Y %H:%M:%S%Ez") >>> s @@ -353,7 +326,6 @@ def quarter(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["1/1/2020 10:00:00+00:00", "4/1/2020 11:00:00+00:00"]) >>> s = bpd.to_datetime(s, utc=True, format="%m/%d/%Y %H:%M:%S%Ez") >>> s @@ -374,9 +346,6 @@ def year(self): **Examples:** - >>> import pandas as pd - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range("2000-01-01", periods=3, freq="Y") ... ) @@ -400,9 +369,6 @@ def days(self): **Examples:** - >>> import pandas as pd - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([pd.Timedelta("4d3m2s1us")]) >>> s 0 4 days 00:03:02.000001 @@ -418,9 +384,6 @@ def seconds(self): **Examples:** - >>> import pandas as pd - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([pd.Timedelta("4d3m2s1us")]) >>> s 0 4 days 00:03:02.000001 @@ -436,9 +399,6 @@ def microseconds(self): **Examples:** - >>> import pandas as pd - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([pd.Timedelta("4d3m2s1us")]) >>> s 0 4 days 00:03:02.000001 @@ -453,9 +413,6 @@ def total_seconds(self): **Examples:** - >>> import pandas as pd - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([pd.Timedelta("1d1m1s1us")]) >>> s 0 1 days 00:01:01.000001 @@ -472,7 +429,6 @@ def tz(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["1/1/2020 10:00:00+00:00", "2/1/2020 11:00:00+00:00"]) >>> s = bpd.to_datetime(s, utc=True, format="%m/%d/%Y %H:%M:%S%Ez") >>> s @@ -495,7 +451,6 @@ def unit(self) -> str: **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["1/1/2020 10:00:00+00:00", "2/1/2020 11:00:00+00:00"]) >>> s = bpd.to_datetime(s, utc=True, format="%m/%d/%Y %H:%M:%S%Ez") >>> s diff --git a/third_party/bigframes_vendored/pandas/core/indexes/base.py b/third_party/bigframes_vendored/pandas/core/indexes/base.py index 82c0563c25..e120dabc66 100644 --- a/third_party/bigframes_vendored/pandas/core/indexes/base.py +++ b/third_party/bigframes_vendored/pandas/core/indexes/base.py @@ -32,8 +32,6 @@ def name(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 2, 3], name='x') >>> idx @@ -63,8 +61,6 @@ def values(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 2, 3]) >>> idx @@ -86,8 +82,6 @@ def ndim(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['Ant', 'Bear', 'Cow']) >>> s @@ -121,8 +115,6 @@ def size(self) -> int: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None For Series: @@ -156,8 +148,6 @@ def is_monotonic_increasing(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bool(bpd.Index([1, 2, 3]).is_monotonic_increasing) True @@ -181,8 +171,6 @@ def is_monotonic_decreasing(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bool(bpd.Index([3, 2, 1]).is_monotonic_decreasing) True @@ -206,8 +194,6 @@ def from_frame(cls, frame) -> Index: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([['HI', 'Temp'], ['HI', 'Precip'], ... ['NJ', 'Temp'], ['NJ', 'Precip']], @@ -246,8 +232,6 @@ def shape(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 2, 3]) >>> idx @@ -268,8 +252,6 @@ def nlevels(self) -> int: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> mi = bpd.MultiIndex.from_arrays([['a'], ['b'], ['c']]) >>> mi @@ -290,8 +272,6 @@ def is_unique(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 5, 7, 7]) >>> idx.is_unique @@ -313,8 +293,6 @@ def has_duplicates(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 5, 7, 7]) >>> bool(idx.has_duplicates) @@ -336,8 +314,6 @@ def dtype(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 2, 3]) >>> idx @@ -364,8 +340,6 @@ def T(self) -> Index: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['Ant', 'Bear', 'Cow']) >>> s @@ -403,8 +377,6 @@ def copy( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index(['a', 'b', 'c']) >>> new_idx = idx.copy() @@ -438,8 +410,6 @@ def astype(self, dtype): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 2, 3]) >>> idx @@ -487,8 +457,6 @@ def get_level_values(self, level) -> Index: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index(list('abc')) >>> idx @@ -517,8 +485,6 @@ def to_series(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index(['Ant', 'Bear', 'Cow'], name='animal') @@ -571,8 +537,6 @@ def isin(self, values): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1,2,3]) >>> idx @@ -611,8 +575,6 @@ def all(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None True, because nonzero integers are considered True. @@ -639,8 +601,6 @@ def any(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> index = bpd.Index([0, 1, 2]) >>> bool(index.any()) @@ -665,8 +625,6 @@ def min(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([3, 2, 1]) >>> int(idx.min()) @@ -687,8 +645,6 @@ def max(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([3, 2, 1]) >>> int(idx.max()) @@ -713,8 +669,6 @@ def argmin(self) -> int: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Consider dataset containing cereal calories @@ -750,8 +704,6 @@ def get_loc( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> unique_index = bpd.Index(list('abc')) >>> unique_index.get_loc('b') @@ -794,8 +746,6 @@ def argmax(self) -> int: Consider dataset containing cereal calories - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series({'Corn Flakes': 100.0, 'Almond Delight': 110.0, ... 'Cinnamon Toast Crunch': 120.0, 'Cocoa Puff': 110.0}) @@ -828,8 +778,6 @@ def nunique(self) -> int: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 3, 5, 7, 7]) >>> s @@ -860,8 +808,6 @@ def sort_values( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([10, 100, 1, 1000]) >>> idx @@ -904,9 +850,6 @@ def value_counts( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> index = bpd.Index([3, 1, 2, 3, 4, np.nan]) >>> index.value_counts() @@ -998,8 +941,6 @@ def rename(self, name, *, inplace): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index(['A', 'C', 'A', 'B'], name='score') >>> idx.rename('grade') @@ -1028,8 +969,6 @@ def drop(self, labels) -> Index: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index(['a', 'b', 'c']) >>> idx.drop(['a']) @@ -1048,9 +987,6 @@ def dropna(self, how: typing.Literal["all", "any"] = "any"): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, np.nan, 3]) >>> idx.dropna() @@ -1077,7 +1013,6 @@ def drop_duplicates(self, *, keep: str = "first"): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Generate an pandas.Index with duplicate values. @@ -1119,8 +1054,6 @@ def unique(self, level: Hashable | int | None = None): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 1, 2, 3, 3]) >>> idx.unique() Index([1, 2, 3], dtype='Int64') @@ -1140,8 +1073,6 @@ def item(self, *args, **kwargs): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1], index=['a']) >>> s.index.item() 'a' diff --git a/third_party/bigframes_vendored/pandas/core/indexes/datetimes.py b/third_party/bigframes_vendored/pandas/core/indexes/datetimes.py index 105a376728..f22554e174 100644 --- a/third_party/bigframes_vendored/pandas/core/indexes/datetimes.py +++ b/third_party/bigframes_vendored/pandas/core/indexes/datetimes.py @@ -15,9 +15,6 @@ def year(self) -> base.Index: **Examples:** - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([pd.Timestamp("20250215")]) >>> idx.year @@ -31,9 +28,6 @@ def month(self) -> base.Index: **Examples:** - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([pd.Timestamp("20250215")]) >>> idx.month @@ -47,9 +41,6 @@ def day(self) -> base.Index: **Examples:** - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([pd.Timestamp("20250215")]) >>> idx.day @@ -63,9 +54,6 @@ def day_of_week(self) -> base.Index: **Examples:** - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([pd.Timestamp("20250215")]) >>> idx.day_of_week @@ -79,9 +67,6 @@ def dayofweek(self) -> base.Index: **Examples:** - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([pd.Timestamp("20250215")]) >>> idx.dayofweek @@ -95,9 +80,6 @@ def weekday(self) -> base.Index: **Examples:** - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([pd.Timestamp("20250215")]) >>> idx.weekday diff --git a/third_party/bigframes_vendored/pandas/core/indexes/multi.py b/third_party/bigframes_vendored/pandas/core/indexes/multi.py index a882aa40e3..018e638de3 100644 --- a/third_party/bigframes_vendored/pandas/core/indexes/multi.py +++ b/third_party/bigframes_vendored/pandas/core/indexes/multi.py @@ -25,8 +25,6 @@ def from_tuples( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> tuples = [(1, 'red'), (1, 'blue'), ... (2, 'red'), (2, 'blue')] >>> bpd.MultiIndex.from_tuples(tuples, names=('number', 'color')) @@ -62,8 +60,6 @@ def from_arrays( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> arrays = [[1, 1, 2, 2], ['red', 'blue', 'red', 'blue']] >>> bpd.MultiIndex.from_arrays(arrays, names=('number', 'color')) MultiIndex([(1, 'red'), diff --git a/third_party/bigframes_vendored/pandas/core/reshape/tile.py b/third_party/bigframes_vendored/pandas/core/reshape/tile.py index 697c17f23c..0f42433384 100644 --- a/third_party/bigframes_vendored/pandas/core/reshape/tile.py +++ b/third_party/bigframes_vendored/pandas/core/reshape/tile.py @@ -34,8 +34,6 @@ def cut( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([0, 1, 5, 10]) >>> s 0 0 @@ -73,7 +71,6 @@ def cut( Cut with pd.IntervalIndex, requires importing pandas for IntervalIndex: - >>> import pandas as pd >>> interval_index = pd.IntervalIndex.from_tuples([(0, 1), (1, 5), (5, 20)]) >>> bpd.cut(s, bins=interval_index) 0 diff --git a/third_party/bigframes_vendored/pandas/core/series.py b/third_party/bigframes_vendored/pandas/core/series.py index 540a66b595..8de1c10f93 100644 --- a/third_party/bigframes_vendored/pandas/core/series.py +++ b/third_party/bigframes_vendored/pandas/core/series.py @@ -38,9 +38,6 @@ def dt(self): **Examples:** >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None - >>> seconds_series = bpd.Series(pd.date_range("2000-01-01", periods=3, freq="s")) >>> seconds_series 0 2000-01-01 00:00:00 @@ -110,8 +107,6 @@ def index(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can access the index of a Series via ``index`` property. @@ -161,13 +156,11 @@ def shape(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 4, 9, 16]) >>> s.shape (4,) - >>> s = bpd.Series(['Alice', 'Bob', bpd.NA]) + >>> s = bpd.Series(['Alice', 'Bob', pd.NA]) >>> s.shape (3,) """ @@ -180,8 +173,6 @@ def dtype(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> s.dtype @@ -200,8 +191,6 @@ def name(self) -> Hashable: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None For a Series: @@ -248,8 +237,6 @@ def hasnans(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3, None]) >>> s @@ -272,8 +259,6 @@ def T(self) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['Ant', 'Bear', 'Cow']) >>> s @@ -297,8 +282,6 @@ def transpose(self) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['Ant', 'Bear', 'Cow']) >>> s @@ -337,9 +320,6 @@ def reset_index( **Examples:** - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3, 4], name='foo', ... index=['a', 'b', 'c', 'd']) @@ -440,8 +420,6 @@ def keys(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3], index=[0, 1, 2]) >>> s.keys() @@ -522,8 +500,6 @@ def to_markdown( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["elk", "pig", "dog", "quetzal"], name="animal") >>> print(s.to_markdown()) @@ -577,9 +553,7 @@ def to_dict( **Examples:** - >>> import bigframes.pandas as bpd >>> from collections import OrderedDict, defaultdict - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3, 4]) >>> s.to_dict() @@ -617,8 +591,6 @@ def to_frame(self, name=None) -> DataFrame: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["a", "b", "c"], ... name="vals") @@ -714,8 +686,6 @@ def tolist(self, *, allow_large_results: Optional[bool] = None) -> list: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> s @@ -748,9 +718,6 @@ def to_numpy( **Examples:** - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series(pd.Categorical(['a', 'b', 'a'])) >>> ser.to_numpy() @@ -803,8 +770,6 @@ def to_pickle(self, path, *, allow_large_results=None, **kwargs): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> original_df = bpd.DataFrame({"foo": range(5), "bar": range(5, 10)}) >>> original_df @@ -865,8 +830,6 @@ def agg(self, func): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3, 4]) >>> s @@ -902,10 +865,8 @@ def count(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([0.0, 1.0, bpd.NA]) + >>> s = bpd.Series([0.0, 1.0, pd.NA]) >>> s 0 0.0 1 1.0 @@ -928,8 +889,6 @@ def nunique(self) -> int: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 3, 5, 7, 7]) >>> s @@ -963,8 +922,6 @@ def unique(self, keep_order=True) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([2, 1, 3, 3], name='A') >>> s @@ -1006,8 +963,6 @@ def mode(self) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([2, 4, 8, 2, 4, None]) >>> s.mode() @@ -1031,11 +986,9 @@ def drop_duplicates( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - Generate a Series with duplicated entries. + >>> import bigframes.pandas as bpd >>> s = bpd.Series(['llama', 'cow', 'llama', 'beetle', 'llama', 'hippo'], ... name='animal') >>> s @@ -1101,7 +1054,6 @@ def duplicated(self, keep="first") -> Series: **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None By default, for each set of duplicated values, the first occurrence is set on False and all others on True: @@ -1172,8 +1124,6 @@ def idxmin(self) -> Hashable: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(data=[1, None, 4, 1], ... index=['A', 'B', 'C', 'D']) @@ -1201,8 +1151,6 @@ def idxmax(self) -> Hashable: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(data=[1, None, 4, 3, 4], ... index=['A', 'B', 'C', 'D', 'E']) @@ -1229,8 +1177,6 @@ def round(self, decimals: int = 0) -> Series: **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([0.1, 1.3, 2.7]) >>> s.round() 0 0.0 @@ -1262,8 +1208,6 @@ def explode(self, *, ignore_index: Optional[bool] = False) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([[1, 2, 3], [], [3, 4]]) >>> s @@ -1301,8 +1245,6 @@ def corr(self, other, method="pearson", min_periods=None) -> float: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s1 = bpd.Series([.2, .0, .6, .2]) >>> s2 = bpd.Series([.3, .6, .0, .1]) @@ -1339,21 +1281,19 @@ def autocorr(self, lag: int = 1) -> float: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([0.25, 0.5, 0.2, -0.05]) - >>> s.autocorr() # doctest: +ELLIPSIS - np.float64(0.10355263309024067) + >>> float(s.autocorr()) # doctest: +ELLIPSIS + 0.1035526330902... - >>> s.autocorr(lag=2) - np.float64(-1.0) + >>> float(s.autocorr(lag=2)) + -1.0 If the Pearson correlation is not well defined, then 'NaN' is returned. >>> s = bpd.Series([1, 0, 0, 0]) - >>> s.autocorr() - np.float64(nan) + >>> float(s.autocorr()) + nan Args: lag (int, default 1): @@ -1377,8 +1317,6 @@ def cov( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s1 = bpd.Series([0.90010907, 0.13484424, 0.62036035]) >>> s2 = bpd.Series([0.12528585, 0.26962463, 0.51111198]) @@ -1406,8 +1344,6 @@ def diff(self) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Difference with previous row @@ -1472,8 +1408,6 @@ def dot(self, other) -> Series | np.ndarray: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([0, 1, 2, 3]) >>> other = bpd.Series([-1, 2, -3, 4]) @@ -1529,9 +1463,6 @@ def sort_values( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([np.nan, 1, 3, 10, 5]) >>> s @@ -1628,9 +1559,6 @@ def sort_index( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['a', 'b', 'c', 'd'], index=[3, 2, 1, 4]) >>> s.sort_index() @@ -1690,8 +1618,6 @@ def nlargest( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> countries_population = {"Italy": 59000000, "France": 65000000, ... "Malta": 434000, "Maldives": 434000, ... "Brunei": 434000, "Iceland": 337000, @@ -1776,8 +1702,6 @@ def nsmallest(self, n: int = 5, keep: str = "first") -> Series: **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> countries_population = {"Italy": 59000000, "France": 65000000, ... "Malta": 434000, "Maldives": 434000, ... "Brunei": 434000, "Iceland": 337000, @@ -1863,16 +1787,47 @@ def apply( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None + Simple vectorized functions, lambdas or ufuncs can be applied directly + with `by_row=False`. - For applying arbitrary python function a `remote_function` is recommended. - Let's use ``reuse=False`` flag to make sure a new `remote_function` - is created every time we run the following code, but you can skip it - to potentially reuse a previously deployed `remote_function` from - the same user defined function. + >>> nums = bpd.Series([1, 2, 3, 4]) + >>> nums + 0 1 + 1 2 + 2 3 + 3 4 + dtype: Int64 + >>> nums.apply(lambda x: x*x + 2*x + 1, by_row=False) + 0 4 + 1 9 + 2 16 + 3 25 + dtype: Int64 + + >>> def is_odd(num): + ... return num % 2 == 1 + >>> nums.apply(is_odd, by_row=False) + 0 True + 1 False + 2 True + 3 False + dtype: boolean + + >>> nums.apply(np.log, by_row=False) + 0 0.0 + 1 0.693147 + 2 1.098612 + 3 1.386294 + dtype: Float64 + + Use `remote_function` to apply an arbitrary Python function. + Set ``reuse=False`` flag to make sure a new `remote_function` + is created every time you run the following code. Omit it + to reuse a previously deployed `remote_function` from + the same user defined function if the hash of the function definition + hasn't changed. - >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") # doctest: +SKIP ... def minutes_to_hours(x: int) -> float: ... return x/60 @@ -1885,8 +1840,8 @@ def apply( 4 120 dtype: Int64 - >>> hours = minutes.apply(minutes_to_hours) - >>> hours + >>> hours = minutes.apply(minutes_to_hours) # doctest: +SKIP + >>> hours # doctest: +SKIP 0 0.0 1 0.5 2 1.0 @@ -1898,7 +1853,7 @@ def apply( a `remote_function`, you would provide the names of the packages via `packages` param. - >>> @bpd.remote_function( + >>> @bpd.remote_function( # doctest: +SKIP ... reuse=False, ... packages=["cryptography"], ... cloud_function_service_account="default" @@ -1915,11 +1870,11 @@ def apply( ... return f.encrypt(input.encode()).decode() >>> names = bpd.Series(["Alice", "Bob"]) - >>> hashes = names.apply(get_hash) + >>> hashes = names.apply(get_hash) # doctest: +SKIP You could return an array output from the remote function. - >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") # doctest: +SKIP ... def text_analyzer(text: str) -> list[int]: ... words = text.count(" ") + 1 ... periods = text.count(".") @@ -1932,46 +1887,13 @@ def apply( ... "I love this product! It's amazing.", ... "Hungry? Wanna eat? Lets go!" ... ]) - >>> features = texts.apply(text_analyzer) - >>> features + >>> features = texts.apply(text_analyzer) # doctest: +SKIP + >>> features # doctest: +SKIP 0 [9 1 0 0] 1 [6 1 1 0] 2 [5 0 1 2] dtype: list[pyarrow] - Simple vectorized functions, lambdas or ufuncs can be applied directly - with `by_row=False`. - - >>> nums = bpd.Series([1, 2, 3, 4]) - >>> nums - 0 1 - 1 2 - 2 3 - 3 4 - dtype: Int64 - >>> nums.apply(lambda x: x*x + 2*x + 1, by_row=False) - 0 4 - 1 9 - 2 16 - 3 25 - dtype: Int64 - - >>> def is_odd(num): - ... return num % 2 == 1 - >>> nums.apply(is_odd, by_row=False) - 0 True - 1 False - 2 True - 3 False - dtype: boolean - - >>> nums.apply(np.log, by_row=False) - 0 0.0 - 1 0.693147 - 2 1.098612 - 3 1.386294 - dtype: Float64 - Args: func (function): BigFrames DataFrames ``remote_function`` to apply. The function @@ -2005,13 +1927,10 @@ def combine( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None - Consider 2 Datasets ``s1`` and ``s2`` containing highest clocked speeds of different birds. + >>> import bigframes.pandas as bpd >>> s1 = bpd.Series({'falcon': 330.0, 'eagle': 160.0}) >>> s1 falcon 330.0 @@ -2065,8 +1984,6 @@ def groupby( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can group by a named index level. @@ -2089,7 +2006,6 @@ def groupby( You can also group by more than one index levels. - >>> import pandas as pd >>> s = bpd.Series([380, 370., 24., 26.], ... index=pd.MultiIndex.from_tuples( ... [("Falcon", "Clear"), @@ -2238,8 +2154,6 @@ def drop( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(data=np.arange(3), index=['A', 'B', 'C']) >>> s @@ -2256,7 +2170,6 @@ def drop( Drop 2nd level label in MultiIndex Series: - >>> import pandas as pd >>> midx = pd.MultiIndex(levels=[['llama', 'cow', 'falcon'], ... ['speed', 'weight', 'length']], ... codes=[[0, 0, 0, 1, 1, 1, 2, 2, 2], @@ -2369,9 +2282,6 @@ def interpolate(self, method: str = "linear"): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None Filling in NaN in a Series via linear interpolation. @@ -2474,8 +2384,6 @@ def replace( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([1, 2, 3, 4, 5]) >>> s 0 1 @@ -2600,9 +2508,6 @@ def dropna(self, *, axis=0, inplace: bool = False, how=None) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None Drop NA values from a Series: @@ -2620,7 +2525,7 @@ def dropna(self, *, axis=0, inplace: bool = False, how=None) -> Series: Empty strings are not considered NA values. ``None`` is considered an NA value. - >>> ser = bpd.Series(['2', bpd.NA, '', None, 'I stay'], dtype='object') + >>> ser = bpd.Series(['2', pd.NA, '', None, 'I stay'], dtype='object') >>> ser 0 2 1 @@ -2664,9 +2569,6 @@ def between( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None Boundary values are included by default: @@ -2723,9 +2625,6 @@ def case_when( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> c = bpd.Series([6, 7, 8, 9], name="c") >>> a = bpd.Series([0, 0, 1, 2]) @@ -2793,9 +2692,6 @@ def cumprod(self): **Examples:** >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([2, np.nan, 5, -1, 0]) >>> s 0 2.0 @@ -2830,9 +2726,6 @@ def cumsum(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([2, np.nan, 5, -1, 0]) >>> s @@ -2873,9 +2766,6 @@ def cummax(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([2, np.nan, 5, -1, 0]) >>> s @@ -2912,9 +2802,6 @@ def cummin(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([2, np.nan, 5, -1, 0]) >>> s @@ -2949,9 +2836,6 @@ def eq(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -2994,9 +2878,6 @@ def ne(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3041,9 +2922,6 @@ def le(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3087,9 +2965,6 @@ def lt(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3134,9 +3009,6 @@ def ge(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3181,9 +3053,6 @@ def gt(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3227,10 +3096,8 @@ def add(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> a = bpd.Series([1, 2, 3, bpd.NA]) + >>> a = bpd.Series([1, 2, 3, pd.NA]) >>> a 0 1 1 2 @@ -3291,8 +3158,6 @@ def __add__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1.5, 2.6], index=['elk', 'moose']) >>> s @@ -3343,9 +3208,6 @@ def radd(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3408,9 +3270,6 @@ def sub( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3453,8 +3312,6 @@ def __sub__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1.5, 2.6], index=['elk', 'moose']) >>> s @@ -3505,9 +3362,6 @@ def rsub(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3567,9 +3421,6 @@ def mul(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3613,8 +3464,6 @@ def __mul__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can multiply with a scalar: @@ -3653,9 +3502,6 @@ def rmul(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3714,9 +3560,6 @@ def truediv(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3760,8 +3603,6 @@ def __truediv__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can multiply with a scalar: @@ -3800,9 +3641,6 @@ def rtruediv(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3862,9 +3700,6 @@ def floordiv(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -3908,8 +3743,6 @@ def __floordiv__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can divide by a scalar: @@ -3948,9 +3781,6 @@ def rfloordiv(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -4010,9 +3840,6 @@ def mod(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -4056,8 +3883,6 @@ def __mod__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can modulo with a scalar: @@ -4095,9 +3920,6 @@ def rmod(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -4159,9 +3981,6 @@ def pow(self, other) -> Series: **Examples:** >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None - >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a a 1.0 @@ -4194,6 +4013,7 @@ def pow(self, other) -> Series: The result of the operation. """ + # TODO(b/452366836): adjust sample if needed to match pyarrow semantics. raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) def __pow__(self, other): @@ -4205,8 +4025,6 @@ def __pow__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can exponentiate with a scalar: @@ -4246,9 +4064,6 @@ def rpow(self, other) -> Series: **Examples:** >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None - >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a a 1.0 @@ -4308,9 +4123,6 @@ def divmod(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -4360,9 +4172,6 @@ def rdivmod(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> a = bpd.Series([1, 1, 1, np.nan], index=['a', 'b', 'c', 'd']) >>> a @@ -4415,9 +4224,6 @@ def combine_first(self, other) -> Series: **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s1 = bpd.Series([1, np.nan]) >>> s2 = bpd.Series([3, 4, 5]) @@ -4457,10 +4263,6 @@ def update(self, other) -> None: **Examples:** - >>> import bigframes.pandas as bpd - >>> import pandas as pd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> s.update(bpd.Series([4, 5, 6])) @@ -4551,9 +4353,6 @@ def any( **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None For Series input, the output is a scalar indicating whether any element is True. @@ -4587,8 +4386,6 @@ def max( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Calculating the max of a Series: @@ -4603,7 +4400,7 @@ def max( Calculating the max of a Series containing ``NA`` values: - >>> s = bpd.Series([1, 3, bpd.NA]) + >>> s = bpd.Series([1, 3, pd.NA]) >>> s 0 1 1 3 @@ -4629,8 +4426,6 @@ def min( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Calculating the min of a Series: @@ -4645,7 +4440,7 @@ def min( Calculating the min of a Series containing ``NA`` values: - >>> s = bpd.Series([1, 3, bpd.NA]) + >>> s = bpd.Series([1, 3, pd.NA]) >>> s 0 1 1 3 @@ -4670,8 +4465,6 @@ def std( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'person_id': [0, 1, 2, 3], ... 'age': [21, 25, 62, 43], @@ -4718,8 +4511,6 @@ def sum(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Calculating the sum of a Series: @@ -4734,7 +4525,7 @@ def sum(self): Calculating the sum of a Series containing ``NA`` values: - >>> s = bpd.Series([1, 3, bpd.NA]) + >>> s = bpd.Series([1, 3, pd.NA]) >>> s 0 1 1 3 @@ -4754,8 +4545,6 @@ def mean(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Calculating the mean of a Series: @@ -4770,7 +4559,7 @@ def mean(self): Calculating the mean of a Series containing ``NA`` values: - >>> s = bpd.Series([1, 3, bpd.NA]) + >>> s = bpd.Series([1, 3, pd.NA]) >>> s 0 1 1 3 @@ -4791,8 +4580,6 @@ def median(self, *, exact: bool = True): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([1, 2, 3]) >>> s.median() np.float64(2.0) @@ -4832,8 +4619,6 @@ def quantile( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([1, 2, 3, 4]) >>> s.quantile(.5) np.float64(2.5) @@ -4884,8 +4669,6 @@ def describe(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['A', 'A', 'B']) >>> s @@ -4912,8 +4695,6 @@ def skew(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> s.skew() @@ -4950,8 +4731,6 @@ def kurt(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 2, 3], index=['cat', 'dog', 'dog', 'mouse']) >>> s @@ -4993,9 +4772,6 @@ def item(self: Series, *args, **kwargs): **Examples:** - >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1]) >>> s.item() np.int64(1) @@ -5017,8 +4793,6 @@ def items(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['A', 'B', 'C']) >>> for index, value in s.items(): @@ -5039,8 +4813,6 @@ def where(self, cond, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([10, 11, 12, 13, 14]) >>> s @@ -5107,9 +4879,6 @@ def mask(self, cond, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([10, 11, 12, 13, 14]) >>> s 0 10 @@ -5153,7 +4922,7 @@ def mask(self, cond, other): condition is evaluated based on a complicated business logic which cannot be expressed in form of a Series. - >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") # doctest: +SKIP ... def should_mask(name: str) -> bool: ... hash = 0 ... for char_ in name: @@ -5166,12 +4935,12 @@ def mask(self, cond, other): 1 Bob 2 Caroline dtype: string - >>> s.mask(should_mask) + >>> s.mask(should_mask) # doctest: +SKIP 0 1 Bob 2 Caroline dtype: string - >>> s.mask(should_mask, "REDACTED") + >>> s.mask(should_mask, "REDACTED") # doctest: +SKIP 0 REDACTED 1 Bob 2 Caroline @@ -5265,8 +5034,6 @@ def argmax(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Consider dataset containing cereal calories. @@ -5303,8 +5070,6 @@ def argmin(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Consider dataset containing cereal calories. @@ -5344,8 +5109,6 @@ def rename(self, index, *, inplace, **kwargs): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> s @@ -5396,8 +5159,6 @@ def rename_axis(self, mapper, *, inplace, **kwargs): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Series @@ -5461,10 +5222,8 @@ def value_counts( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([3, 1, 2, 3, 4, bpd.NA], dtype="Int64") + >>> s = bpd.Series([3, 1, 2, 3, 4, pd.NA], dtype="Int64") >>> s 0 3 @@ -5540,8 +5299,6 @@ def str(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series(["A_Str_Series"]) >>> s 0 A_Str_Series @@ -5569,8 +5326,6 @@ def plot(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> ser = bpd.Series([1, 2, 3, 3]) >>> plot = ser.plot(kind='hist', title="My plot") >>> plot @@ -5596,8 +5351,6 @@ def isin(self, values): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['llama', 'cow', 'llama', 'beetle', 'llama', ... 'hippo'], name='animal') @@ -5662,8 +5415,6 @@ def is_monotonic_increasing(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 2]) >>> s.is_monotonic_increasing @@ -5686,8 +5437,6 @@ def is_monotonic_decreasing(self) -> bool: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([3, 2, 2, 1]) >>> s.is_monotonic_decreasing @@ -5728,10 +5477,7 @@ def map( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - - >>> s = bpd.Series(['cat', 'dog', bpd.NA, 'rabbit']) + >>> s = bpd.Series(['cat', 'dog', pd.NA, 'rabbit']) >>> s 0 cat 1 dog @@ -5751,7 +5497,7 @@ def map( It also accepts a remote function: - >>> @bpd.remote_function(cloud_function_service_account="default") + >>> @bpd.remote_function(cloud_function_service_account="default") # doctest: +SKIP ... def my_mapper(val: str) -> str: ... vowels = ["a", "e", "i", "o", "u"] ... if val: @@ -5760,7 +5506,7 @@ def map( ... ]) ... return "N/A" - >>> s.map(my_mapper) + >>> s.map(my_mapper) # doctest: +SKIP 0 cAt 1 dOg 2 N/A @@ -5794,8 +5540,6 @@ def iloc(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> mydict = [{'a': 1, 'b': 2, 'c': 3, 'd': 4}, ... {'a': 100, 'b': 200, 'c': 300, 'd': 400}, @@ -5874,8 +5618,6 @@ def loc(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([[1, 2], [4, 5], [7, 8]], ... index=['cobra', 'viper', 'sidewinder'], @@ -5961,8 +5703,6 @@ def iat(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([[0, 2, 3], [0, 4, 1], [10, 20, 30]], ... columns=['A', 'B', 'C']) @@ -5996,8 +5736,6 @@ def at(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([[0, 2, 3], [0, 4, 1], [10, 20, 30]], ... index=[4, 5, 6], columns=['A', 'B', 'C']) @@ -6032,8 +5770,6 @@ def values(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> bpd.Series([1, 2, 3]).values array([1, 2, 3]) @@ -6054,8 +5790,6 @@ def size(self) -> int: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None For Series: @@ -6091,9 +5825,6 @@ def __array__(self, dtype=None, copy: Optional[bool] = None) -> numpy.ndarray: **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> import numpy as np >>> ser = bpd.Series([1, 2, 3]) @@ -6119,8 +5850,6 @@ def __len__(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> len(s) @@ -6135,8 +5864,6 @@ def __invert__(self): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series([True, False, True]) >>> ~ser @@ -6156,8 +5883,6 @@ def __and__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([0, 1, 2, 3]) @@ -6195,8 +5920,6 @@ def __or__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([0, 1, 2, 3]) @@ -6234,8 +5957,6 @@ def __xor__(self, other): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([0, 1, 2, 3]) @@ -6273,8 +5994,6 @@ def __getitem__(self, indexer): **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([15, 30, 45]) >>> s[1] diff --git a/third_party/bigframes_vendored/pandas/core/strings/accessor.py b/third_party/bigframes_vendored/pandas/core/strings/accessor.py index fe94bf3049..9a72b98aee 100644 --- a/third_party/bigframes_vendored/pandas/core/strings/accessor.py +++ b/third_party/bigframes_vendored/pandas/core/strings/accessor.py @@ -20,7 +20,6 @@ def __getitem__(self, key: typing.Union[int, slice]): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['Alice', 'Bob', 'Charlie']) >>> s.str[0] @@ -54,7 +53,6 @@ def extract(self, pat: str, flags: int = 0): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None A pattern with two groups will return a DataFrame with two columns. Non-matches will be `NaN`. @@ -115,7 +113,6 @@ def find(self, sub, start: int = 0, end=None): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series(["cow_", "duck_", "do_ve"]) >>> ser.str.find("_") @@ -146,11 +143,10 @@ def len(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Returns the length (number of characters) in a string. - >>> s = bpd.Series(['dog', '', bpd.NA]) + >>> s = bpd.Series(['dog', '', pd.NA]) >>> s.str.len() 0 3 1 0 @@ -172,7 +168,6 @@ def lower(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['lower', ... 'CAPITALS', @@ -197,7 +192,6 @@ def slice(self, start=None, stop=None): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["koala", "dog", "chameleon"]) >>> s @@ -250,13 +244,12 @@ def strip(self, to_strip: typing.Optional[str] = None): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([ ... '1. Ant.', ... ' 2. Bee? ', ... '\\t3. Cat!\\n', - ... bpd.NA, + ... pd.NA, ... ]) >>> s.str.strip() 0 1. Ant. @@ -293,7 +286,6 @@ def upper(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['lower', ... 'CAPITALS', @@ -322,7 +314,6 @@ def isnumeric(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s1 = bpd.Series(['one', 'one1', '1', '']) >>> s1.str.isnumeric() @@ -349,7 +340,6 @@ def isalpha(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s1 = bpd.Series(['one', 'one1', '1', '']) >>> s1.str.isalpha() @@ -375,7 +365,6 @@ def isdigit(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['23', '1a', '1/5', '']) >>> s.str.isdigit() @@ -401,7 +390,6 @@ def isalnum(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s1 = bpd.Series(['one', 'one1', '1', '']) >>> s1.str.isalnum() @@ -439,7 +427,6 @@ def isspace(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([' ', '\\t\\r\\n ', '']) >>> s.str.isspace() @@ -465,7 +452,6 @@ def islower(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['leopard', 'Golden Eagle', 'SNAKE', '']) >>> s.str.islower() @@ -492,7 +478,6 @@ def isupper(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['leopard', 'Golden Eagle', 'SNAKE', '']) >>> s.str.isupper() @@ -519,7 +504,6 @@ def isdecimal(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None The `isdecimal` method checks for characters used to form numbers in base 10. @@ -550,9 +534,8 @@ def rstrip(self, to_strip: typing.Optional[str] = None): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series(['Ant', ' Bee ', '\tCat\n', bpd.NA]) + >>> s = bpd.Series(['Ant', ' Bee ', '\tCat\n', pd.NA]) >>> s.str.rstrip() 0 Ant 1 Bee @@ -583,9 +566,8 @@ def lstrip(self, to_strip: typing.Optional[str] = None): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series(['Ant', ' Bee ', '\tCat\n', bpd.NA]) + >>> s = bpd.Series(['Ant', ' Bee ', '\tCat\n', pd.NA]) >>> s.str.lstrip() 0 Ant 1 Bee @@ -611,7 +593,6 @@ def repeat(self, repeats: int): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['a', 'b', 'c']) >>> s @@ -645,7 +626,6 @@ def capitalize(self): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['lower', ... 'CAPITALS', @@ -673,7 +653,6 @@ def cat(self, others, *, join): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None You can concatenate each string in a Series to another string. @@ -730,7 +709,6 @@ def contains(self, pat, case: bool = True, flags: int = 0, *, regex: bool = True **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Returning a Series of booleans using only a literal pattern. @@ -834,13 +812,12 @@ def replace( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None When *pat* is a string and *regex* is True, the given *pat* is compiled as a regex. When *repl* is a string, it replaces matching regex patterns as with `re.sub()`. NaN value(s) in the Series are left as is: - >>> s = bpd.Series(['foo', 'fuz', bpd.NA]) + >>> s = bpd.Series(['foo', 'fuz', pd.NA]) >>> s.str.replace('f.', 'ba', regex=True) 0 bao 1 baz @@ -850,7 +827,7 @@ def replace( When *pat* is a string and *regex* is False, every *pat* is replaced with *repl* as with `str.replace()`: - >>> s = bpd.Series(['f.o', 'fuz', bpd.NA]) + >>> s = bpd.Series(['f.o', 'fuz', pd.NA]) >>> s.str.replace('f.', 'ba', regex=False) 0 bao 1 fuz @@ -896,9 +873,8 @@ def startswith( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series(['bat', 'Bear', 'caT', bpd.NA]) + >>> s = bpd.Series(['bat', 'Bear', 'caT', pd.NA]) >>> s 0 bat 1 Bear @@ -941,9 +917,8 @@ def endswith( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series(['bat', 'bear', 'caT', bpd.NA]) + >>> s = bpd.Series(['bat', 'bear', 'caT', pd.NA]) >>> s 0 bat 1 bear @@ -987,8 +962,6 @@ def split( **Examples:** >>> import bigframes.pandas as bpd - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... [ @@ -1031,7 +1004,6 @@ def match(self, pat: str, case: bool = True, flags: int = 0): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series(["horse", "eagle", "donkey"]) >>> ser.str.match("e") @@ -1060,7 +1032,6 @@ def fullmatch(self, pat: str, case: bool = True, flags: int = 0): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series(["cat", "duck", "dove"]) >>> ser.str.fullmatch(r'd.+') @@ -1092,7 +1063,6 @@ def get(self, i: int): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["apple", "banana", "fig"]) >>> s.str.get(3) @@ -1122,7 +1092,6 @@ def pad( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["caribou", "tiger"]) >>> s @@ -1170,7 +1139,6 @@ def ljust( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series(['dog', 'bird', 'mouse']) >>> ser.str.ljust(8, fillchar='.') @@ -1202,7 +1170,6 @@ def rjust( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series(['dog', 'bird', 'mouse']) >>> ser.str.rjust(8, fillchar='.') @@ -1238,9 +1205,8 @@ def zfill( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series(['-1', '1', '1000', bpd.NA]) + >>> s = bpd.Series(['-1', '1', '1000', pd.NA]) >>> s 0 -1 1 1 @@ -1278,7 +1244,6 @@ def center( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series(['dog', 'bird', 'mouse']) >>> ser.str.center(8, fillchar='.') @@ -1310,8 +1275,6 @@ def join(self, sep: str): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> import pandas as pd Example with a list that contains non-string elements. diff --git a/third_party/bigframes_vendored/pandas/core/tools/datetimes.py b/third_party/bigframes_vendored/pandas/core/tools/datetimes.py index 9c17b9632e..655f801b3d 100644 --- a/third_party/bigframes_vendored/pandas/core/tools/datetimes.py +++ b/third_party/bigframes_vendored/pandas/core/tools/datetimes.py @@ -38,7 +38,6 @@ def to_datetime( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None Converting a Scalar to datetime: diff --git a/third_party/bigframes_vendored/pandas/core/tools/timedeltas.py b/third_party/bigframes_vendored/pandas/core/tools/timedeltas.py index 9442e965fa..4e418af406 100644 --- a/third_party/bigframes_vendored/pandas/core/tools/timedeltas.py +++ b/third_party/bigframes_vendored/pandas/core/tools/timedeltas.py @@ -54,11 +54,9 @@ def to_timedelta( **Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - Converting a Scalar to timedelta + >>> import bigframes.pandas as bpd >>> scalar = 2 >>> bpd.to_timedelta(scalar, unit='s') Timedelta('0 days 00:00:02') diff --git a/third_party/bigframes_vendored/pandas/io/gbq.py b/third_party/bigframes_vendored/pandas/io/gbq.py index 0fdca4dde1..3190c92b92 100644 --- a/third_party/bigframes_vendored/pandas/io/gbq.py +++ b/third_party/bigframes_vendored/pandas/io/gbq.py @@ -61,7 +61,6 @@ def read_gbq( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None If the input is a table ID: diff --git a/third_party/bigframes_vendored/pandas/io/parquet.py b/third_party/bigframes_vendored/pandas/io/parquet.py index aec911d2fe..c02c5e52c5 100644 --- a/third_party/bigframes_vendored/pandas/io/parquet.py +++ b/third_party/bigframes_vendored/pandas/io/parquet.py @@ -27,7 +27,6 @@ def read_parquet( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> gcs_path = "gs://cloud-samples-data/bigquery/us-states/us-states.parquet" >>> df = bpd.read_parquet(path=gcs_path, engine="bigquery") diff --git a/third_party/bigframes_vendored/pandas/io/parsers/readers.py b/third_party/bigframes_vendored/pandas/io/parsers/readers.py index 4757f5ed9d..5a505c2859 100644 --- a/third_party/bigframes_vendored/pandas/io/parsers/readers.py +++ b/third_party/bigframes_vendored/pandas/io/parsers/readers.py @@ -71,7 +71,6 @@ def read_csv( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> gcs_path = "gs://cloud-samples-data/bigquery/us-states/us-states.csv" >>> df = bpd.read_csv(filepath_or_buffer=gcs_path) @@ -192,7 +191,6 @@ def read_json( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> gcs_path = "gs://bigframes-dev-testing/sample1.json" >>> df = bpd.read_json(path_or_buf=gcs_path, lines=True, orient="records") diff --git a/third_party/bigframes_vendored/pandas/io/pickle.py b/third_party/bigframes_vendored/pandas/io/pickle.py index 33088dc019..03f1afe35e 100644 --- a/third_party/bigframes_vendored/pandas/io/pickle.py +++ b/third_party/bigframes_vendored/pandas/io/pickle.py @@ -35,7 +35,6 @@ def read_pickle( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> gcs_path = "gs://bigframes-dev-testing/test_pickle.pkl" >>> df = bpd.read_pickle(filepath_or_buffer=gcs_path) diff --git a/third_party/bigframes_vendored/pandas/plotting/_core.py b/third_party/bigframes_vendored/pandas/plotting/_core.py index b0c28ddfe9..6c2aed970d 100644 --- a/third_party/bigframes_vendored/pandas/plotting/_core.py +++ b/third_party/bigframes_vendored/pandas/plotting/_core.py @@ -11,7 +11,6 @@ class PlotAccessor: For Series: >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series([1, 2, 3, 3]) >>> plot = ser.plot(kind='hist', title="My plot") @@ -57,9 +56,6 @@ def hist( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None - >>> import numpy as np - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame(np.random.randint(1, 7, 6000), columns=['one']) >>> df['two'] = np.random.randint(1, 7, 6000) + np.random.randint(1, 7, 6000) >>> ax = df.plot.hist(bins=12, alpha=0.5) @@ -96,7 +92,6 @@ def line( **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame( ... { ... 'one': [1, 2, 3, 4], @@ -164,7 +159,6 @@ def area( Draw an area plot based on basic business metrics: >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame( ... { ... 'sales': [3, 2, 3, 9, 10, 6], @@ -233,7 +227,6 @@ def bar( Basic plot. >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'lab':['A', 'B', 'C'], 'val':[10, 30, 20]}) >>> ax = df.plot.bar(x='lab', y='val', rot=0) @@ -293,7 +286,6 @@ def barh( Basic plot. >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'lab':['A', 'B', 'C'], 'val':[10, 30, 20]}) >>> ax = df.plot.barh(x='lab', y='val', rot=0) @@ -356,7 +348,6 @@ def pie( pie function to get a pie plot. >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'mass': [0.330, 4.87 , 5.97], ... 'radius': [2439.7, 6051.8, 6378.1]}, @@ -399,7 +390,6 @@ def scatter( in a DataFrame's columns. >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([[5.1, 3.5, 0], [4.9, 3.0, 0], [7.0, 3.2, 1], ... [6.4, 3.2, 1], [5.9, 3.0, 2]], ... columns=['length', 'width', 'species']) diff --git a/third_party/bigframes_vendored/sklearn/cluster/_kmeans.py b/third_party/bigframes_vendored/sklearn/cluster/_kmeans.py index a7344d49d4..44eefeddd7 100644 --- a/third_party/bigframes_vendored/sklearn/cluster/_kmeans.py +++ b/third_party/bigframes_vendored/sklearn/cluster/_kmeans.py @@ -30,7 +30,6 @@ class KMeans(_BaseKMeans): **Examples:** >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> from bigframes.ml.cluster import KMeans >>> X = bpd.DataFrame({"feat0": [1, 1, 1, 10, 10, 10], "feat1": [2, 4, 0, 2, 4, 0]}) diff --git a/third_party/bigframes_vendored/sklearn/decomposition/_mf.py b/third_party/bigframes_vendored/sklearn/decomposition/_mf.py index c3c3a77b71..e487a2e7c1 100644 --- a/third_party/bigframes_vendored/sklearn/decomposition/_mf.py +++ b/third_party/bigframes_vendored/sklearn/decomposition/_mf.py @@ -24,7 +24,6 @@ class MatrixFactorization(BaseEstimator, metaclass=ABCMeta): >>> import bigframes.pandas as bpd >>> from bigframes.ml.decomposition import MatrixFactorization - >>> bpd.options.display.progress_bar = None >>> X = bpd.DataFrame({ ... "row": [0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6], ... "column": [0,1] * 7, diff --git a/third_party/bigframes_vendored/sklearn/decomposition/_pca.py b/third_party/bigframes_vendored/sklearn/decomposition/_pca.py index f13c52bfb6..3535edc8f9 100644 --- a/third_party/bigframes_vendored/sklearn/decomposition/_pca.py +++ b/third_party/bigframes_vendored/sklearn/decomposition/_pca.py @@ -24,7 +24,6 @@ class PCA(BaseEstimator, metaclass=ABCMeta): >>> import bigframes.pandas as bpd >>> from bigframes.ml.decomposition import PCA - >>> bpd.options.display.progress_bar = None >>> X = bpd.DataFrame({"feat0": [-1, -2, -3, 1, 2, 3], "feat1": [-1, -1, -2, 1, 1, 2]}) >>> pca = PCA(n_components=2).fit(X) >>> pca.predict(X) # doctest:+SKIP diff --git a/third_party/bigframes_vendored/sklearn/impute/_base.py b/third_party/bigframes_vendored/sklearn/impute/_base.py index 42eab24c82..175ad86b21 100644 --- a/third_party/bigframes_vendored/sklearn/impute/_base.py +++ b/third_party/bigframes_vendored/sklearn/impute/_base.py @@ -22,7 +22,6 @@ class SimpleImputer(_BaseImputer): >>> import bigframes.pandas as bpd >>> from bigframes.ml.impute import SimpleImputer - >>> bpd.options.display.progress_bar = None >>> X_train = bpd.DataFrame({"feat0": [7.0, 4.0, 10.0], "feat1": [2.0, None, 5.0], "feat2": [3.0, 6.0, 9.0]}) >>> imp_mean = SimpleImputer().fit(X_train) >>> X_test = bpd.DataFrame({"feat0": [None, 4.0, 10.0], "feat1": [2.0, None, None], "feat2": [3.0, 6.0, 9.0]}) diff --git a/third_party/bigframes_vendored/sklearn/linear_model/_base.py b/third_party/bigframes_vendored/sklearn/linear_model/_base.py index 21ba5a3bf8..7543edd10b 100644 --- a/third_party/bigframes_vendored/sklearn/linear_model/_base.py +++ b/third_party/bigframes_vendored/sklearn/linear_model/_base.py @@ -66,7 +66,6 @@ class LinearRegression(RegressorMixin, LinearModel): >>> from bigframes.ml.linear_model import LinearRegression >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> X = bpd.DataFrame({ \ "feature0": [20, 21, 19, 18], \ "feature1": [0, 1, 1, 0], \ diff --git a/third_party/bigframes_vendored/sklearn/linear_model/_logistic.py b/third_party/bigframes_vendored/sklearn/linear_model/_logistic.py index a85c6fae8d..d449a1040c 100644 --- a/third_party/bigframes_vendored/sklearn/linear_model/_logistic.py +++ b/third_party/bigframes_vendored/sklearn/linear_model/_logistic.py @@ -25,7 +25,6 @@ class LogisticRegression(LinearClassifierMixin, BaseEstimator): >>> from bigframes.ml.linear_model import LogisticRegression >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> X = bpd.DataFrame({ \ "feature0": [20, 21, 19, 18], \ "feature1": [0, 1, 1, 0], \ diff --git a/third_party/bigframes_vendored/sklearn/metrics/_classification.py b/third_party/bigframes_vendored/sklearn/metrics/_classification.py index fd6e8678ea..e60cc8cec4 100644 --- a/third_party/bigframes_vendored/sklearn/metrics/_classification.py +++ b/third_party/bigframes_vendored/sklearn/metrics/_classification.py @@ -30,7 +30,6 @@ def accuracy_score(y_true, y_pred, normalize=True) -> float: >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([0, 2, 1, 3]) >>> y_pred = bpd.DataFrame([0, 1, 2, 3]) @@ -80,7 +79,6 @@ def confusion_matrix( >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([2, 0, 2, 2, 0, 1]) >>> y_pred = bpd.DataFrame([0, 0, 2, 2, 0, 2]) @@ -132,7 +130,6 @@ def recall_score( >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([0, 1, 2, 0, 1, 2]) >>> y_pred = bpd.DataFrame([0, 2, 1, 0, 0, 1]) @@ -181,7 +178,6 @@ def precision_score( >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([0, 1, 2, 0, 1, 2]) >>> y_pred = bpd.DataFrame([0, 2, 1, 0, 0, 1]) @@ -232,7 +228,6 @@ def f1_score( >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([0, 1, 2, 0, 1, 2]) >>> y_pred = bpd.DataFrame([0, 2, 1, 0, 0, 1]) diff --git a/third_party/bigframes_vendored/sklearn/metrics/_ranking.py b/third_party/bigframes_vendored/sklearn/metrics/_ranking.py index 9262ffbd3d..cd5bd2cbcd 100644 --- a/third_party/bigframes_vendored/sklearn/metrics/_ranking.py +++ b/third_party/bigframes_vendored/sklearn/metrics/_ranking.py @@ -33,7 +33,6 @@ def auc(x, y) -> float: >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> x = bpd.DataFrame([1, 1, 2, 2]) >>> y = bpd.DataFrame([2, 3, 4, 5]) @@ -89,7 +88,6 @@ def roc_auc_score(y_true, y_score) -> float: >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([0, 0, 1, 1, 0, 1, 0, 1, 1, 1]) >>> y_score = bpd.DataFrame([0.1, 0.4, 0.35, 0.8, 0.65, 0.9, 0.5, 0.3, 0.6, 0.45]) @@ -139,7 +137,6 @@ def roc_curve( >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([1, 1, 2, 2]) >>> y_score = bpd.DataFrame([0.1, 0.4, 0.35, 0.8]) diff --git a/third_party/bigframes_vendored/sklearn/metrics/_regression.py b/third_party/bigframes_vendored/sklearn/metrics/_regression.py index 1c14e8068b..85f0c1ecf9 100644 --- a/third_party/bigframes_vendored/sklearn/metrics/_regression.py +++ b/third_party/bigframes_vendored/sklearn/metrics/_regression.py @@ -46,7 +46,6 @@ def r2_score(y_true, y_pred, force_finite=True) -> float: >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([3, -0.5, 2, 7]) >>> y_pred = bpd.DataFrame([2.5, 0.0, 2, 8]) @@ -73,7 +72,6 @@ def mean_squared_error(y_true, y_pred) -> float: >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([3, -0.5, 2, 7]) >>> y_pred = bpd.DataFrame([2.5, 0.0, 2, 8]) @@ -100,7 +98,6 @@ def mean_absolute_error(y_true, y_pred) -> float: >>> import bigframes.pandas as bpd >>> import bigframes.ml.metrics - >>> bpd.options.display.progress_bar = None >>> y_true = bpd.DataFrame([3, -0.5, 2, 7]) >>> y_pred = bpd.DataFrame([2.5, 0.0, 2, 8]) diff --git a/third_party/bigframes_vendored/sklearn/model_selection/_split.py b/third_party/bigframes_vendored/sklearn/model_selection/_split.py index ec16fa8cf9..326589be7d 100644 --- a/third_party/bigframes_vendored/sklearn/model_selection/_split.py +++ b/third_party/bigframes_vendored/sklearn/model_selection/_split.py @@ -69,7 +69,6 @@ class KFold(_BaseKFold): >>> import bigframes.pandas as bpd >>> from bigframes.ml.model_selection import KFold - >>> bpd.options.display.progress_bar = None >>> X = bpd.DataFrame({"feat0": [1, 3, 5], "feat1": [2, 4, 6]}) >>> y = bpd.DataFrame({"label": [1, 2, 3]}) >>> kf = KFold(n_splits=3, random_state=42) @@ -162,7 +161,6 @@ def train_test_split( >>> import bigframes.pandas as bpd >>> from bigframes.ml.model_selection import train_test_split - >>> bpd.options.display.progress_bar = None >>> X = bpd.DataFrame({"feat0": [0, 2, 4, 6, 8], "feat1": [1, 3, 5, 7, 9]}) >>> y = bpd.DataFrame({"label": [0, 1, 2, 3, 4]}) >>> X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, random_state=42) diff --git a/third_party/bigframes_vendored/sklearn/model_selection/_validation.py b/third_party/bigframes_vendored/sklearn/model_selection/_validation.py index b93c47ea04..6f84018853 100644 --- a/third_party/bigframes_vendored/sklearn/model_selection/_validation.py +++ b/third_party/bigframes_vendored/sklearn/model_selection/_validation.py @@ -19,7 +19,6 @@ def cross_validate(estimator, X, y=None, *, cv=None): >>> import bigframes.pandas as bpd >>> from bigframes.ml.model_selection import cross_validate, KFold >>> from bigframes.ml.linear_model import LinearRegression - >>> bpd.options.display.progress_bar = None >>> X = bpd.DataFrame({"feat0": [1, 3, 5], "feat1": [2, 4, 6]}) >>> y = bpd.DataFrame({"label": [1, 2, 3]}) >>> model = LinearRegression() diff --git a/third_party/bigframes_vendored/sklearn/preprocessing/_encoder.py b/third_party/bigframes_vendored/sklearn/preprocessing/_encoder.py index 5476a9fb3c..64a5786f17 100644 --- a/third_party/bigframes_vendored/sklearn/preprocessing/_encoder.py +++ b/third_party/bigframes_vendored/sklearn/preprocessing/_encoder.py @@ -25,7 +25,6 @@ class OneHotEncoder(BaseEstimator): >>> from bigframes.ml.preprocessing import OneHotEncoder >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = None >>> enc = OneHotEncoder() >>> X = bpd.DataFrame({"a": ["Male", "Female", "Female"], "b": ["1", "3", "2"]}) From cabbf858e3c0ab0ff478a403eedd7de1e127af84 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Thu, 16 Oct 2025 15:38:18 -0700 Subject: [PATCH 160/313] chore: Migrate strconcat_op operator to SQLGlot (#2166) * chore: Migrate strconcat_op operator to SQLGlot Migrated the `strconcat_op` operator from the Ibis compiler to the new SQLGlot compiler. This includes the implementation of the compiler logic and a corresponding unit test with a snapshot. * fix --- .../core/compile/sqlglot/expressions/string_ops.py | 6 ++++++ .../test_string_ops/test_strconcat/out.sql | 13 +++++++++++++ .../compile/sqlglot/expressions/test_string_ops.py | 7 +++++++ 3 files changed, 26 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strconcat/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/string_ops.py b/bigframes/core/compile/sqlglot/expressions/string_ops.py index 403cf403f5..bdc4808302 100644 --- a/bigframes/core/compile/sqlglot/expressions/string_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/string_ops.py @@ -23,6 +23,7 @@ import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op @register_unary_op(ops.capitalize_op) @@ -276,6 +277,11 @@ def _(expr: TypedExpr) -> sge.Expression: return sge.Upper(this=expr.expr) +@register_binary_op(ops.strconcat_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.Concat(expressions=[left.expr, right.expr]) + + @register_unary_op(ops.ZfillOp, pass_op=True) def _(expr: TypedExpr, op: ops.ZfillOp) -> sge.Expression: return sge.Case( diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strconcat/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strconcat/out.sql new file mode 100644 index 0000000000..de5129a6a3 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strconcat/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CONCAT(`bfcol_0`, 'a') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py index 9121334811..99dbce9410 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py @@ -311,3 +311,10 @@ def test_add_string(scalar_types_df: bpd.DataFrame, snapshot): sql = utils._apply_binary_op(bf_df, ops.add_op, "string_col", ex.const("a")) snapshot.assert_match(sql, "out.sql") + + +def test_strconcat(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = utils._apply_binary_op(bf_df, ops.strconcat_op, "string_col", ex.const("a")) + + snapshot.assert_match(sql, "out.sql") From fbd405def7bbf6fdcf7253ae0b1a9e4edc30f7b9 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 17 Oct 2025 09:30:15 -0700 Subject: [PATCH 161/313] refactor: Swap accessors from inheritance to composition (#2177) --- bigframes/core/indexes/base.py | 4 +- bigframes/core/validations.py | 4 +- bigframes/operations/base.py | 306 ------------------------------ bigframes/operations/blob.py | 53 +++--- bigframes/operations/datetimes.py | 53 +++--- bigframes/operations/lists.py | 14 +- bigframes/operations/strings.py | 90 +++++---- bigframes/operations/structs.py | 18 +- bigframes/series.py | 289 ++++++++++++++++++++++++++-- 9 files changed, 402 insertions(+), 429 deletions(-) delete mode 100644 bigframes/operations/base.py diff --git a/bigframes/core/indexes/base.py b/bigframes/core/indexes/base.py index a258c01195..54d8228ff6 100644 --- a/bigframes/core/indexes/base.py +++ b/bigframes/core/indexes/base.py @@ -384,13 +384,13 @@ def to_series( name = self.name if name is None else name if index is None: return bigframes.series.Series( - data=self, index=self, name=name, session=self._session + data=self, index=self, name=str(name), session=self._session ) else: return bigframes.series.Series( data=self, index=Index(index, session=self._session), - name=name, + name=str(name), session=self._session, ) diff --git a/bigframes/core/validations.py b/bigframes/core/validations.py index 701752c9fc..e6fdcb7bd5 100644 --- a/bigframes/core/validations.py +++ b/bigframes/core/validations.py @@ -27,7 +27,7 @@ from bigframes import Session from bigframes.core.blocks import Block from bigframes.dataframe import DataFrame - from bigframes.operations.base import SeriesMethods + from bigframes.series import Series class HasSession(Protocol): @@ -42,7 +42,7 @@ def _block(self) -> Block: def requires_index(meth): @functools.wraps(meth) - def guarded_meth(df: Union[DataFrame, SeriesMethods], *args, **kwargs): + def guarded_meth(df: Union[DataFrame, Series], *args, **kwargs): df._throw_if_null_index(meth.__name__) return meth(df, *args, **kwargs) diff --git a/bigframes/operations/base.py b/bigframes/operations/base.py deleted file mode 100644 index f2bbcb3320..0000000000 --- a/bigframes/operations/base.py +++ /dev/null @@ -1,306 +0,0 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from __future__ import annotations - -import typing -from typing import List, Sequence, Union - -import bigframes_vendored.constants as constants -import bigframes_vendored.pandas.pandas._typing as vendored_pandas_typing -import pandas as pd - -import bigframes.core.blocks as blocks -import bigframes.core.convert -import bigframes.core.expression as ex -import bigframes.core.identifiers as ids -import bigframes.core.indexes as indexes -import bigframes.core.scalar as scalars -import bigframes.core.utils as bf_utils -import bigframes.dtypes -import bigframes.operations as ops -import bigframes.operations.aggregations as agg_ops -import bigframes.series as series -import bigframes.session - - -class SeriesMethods: - def __init__( - self, - data=None, - index: vendored_pandas_typing.Axes | None = None, - dtype: typing.Optional[ - bigframes.dtypes.DtypeString | bigframes.dtypes.Dtype - ] = None, - name: str | None = None, - copy: typing.Optional[bool] = None, - *, - session: typing.Optional[bigframes.session.Session] = None, - ): - import bigframes.pandas - - # Ignore object dtype if provided, as it provides no additional - # information about what BigQuery type to use. - if dtype is not None and bigframes.dtypes.is_object_like(dtype): - dtype = None - - read_pandas_func = ( - session.read_pandas - if (session is not None) - else (lambda x: bigframes.pandas.read_pandas(x)) - ) - - block: typing.Optional[blocks.Block] = None - if (name is not None) and not isinstance(name, typing.Hashable): - raise ValueError( - f"BigQuery DataFrames only supports hashable series names. {constants.FEEDBACK_LINK}" - ) - if copy is not None and not copy: - raise ValueError( - f"Series constructor only supports copy=True. {constants.FEEDBACK_LINK}" - ) - - if isinstance(data, blocks.Block): - block = data - elif isinstance(data, SeriesMethods): - block = data._get_block() - # special case where data is local scalar, but index is bigframes index (maybe very big) - elif ( - not bf_utils.is_list_like(data) and not isinstance(data, indexes.Index) - ) and isinstance(index, indexes.Index): - block = index._block - block, _ = block.create_constant(data) - block = block.with_column_labels([None]) - # prevents no-op reindex later - index = None - elif isinstance(data, indexes.Index) or isinstance(index, indexes.Index): - data = indexes.Index(data, dtype=dtype, name=name, session=session) - # set to none as it has already been applied, avoid re-cast later - if data.nlevels != 1: - raise NotImplementedError("Cannot interpret multi-index as Series.") - # Reset index to promote index columns to value columns, set default index - data_block = data._block.reset_index(drop=False).with_column_labels( - data.names - ) - if index is not None: # Align data and index by offset - bf_index = indexes.Index(index, session=session) - idx_block = bf_index._block.reset_index( - drop=False - ) # reset to align by offsets, and then reset back - idx_cols = idx_block.value_columns - data_block, (l_mapping, _) = idx_block.join(data_block, how="left") - data_block = data_block.set_index([l_mapping[col] for col in idx_cols]) - data_block = data_block.with_index_labels(bf_index.names) - # prevents no-op reindex later - index = None - block = data_block - - if block: - assert len(block.value_columns) == 1 - assert len(block.column_labels) == 1 - if index is not None: # reindexing operation - bf_index = indexes.Index(index) - idx_block = bf_index._block - idx_cols = idx_block.index_columns - block, _ = idx_block.join(block, how="left") - block = block.with_index_labels(bf_index.names) - if name: - block = block.with_column_labels([name]) - if dtype: - bf_dtype = bigframes.dtypes.bigframes_type(dtype) - block = block.multi_apply_unary_op(ops.AsTypeOp(to_type=bf_dtype)) - else: - if isinstance(dtype, str) and dtype.lower() == "json": - dtype = bigframes.dtypes.JSON_DTYPE - pd_series = pd.Series( - data=data, - index=index, # type:ignore - dtype=dtype, # type:ignore - name=name, - ) - block = read_pandas_func(pd_series)._get_block() # type:ignore - - assert block is not None - self._block: blocks.Block = block - - @property - def _value_column(self) -> str: - return self._block.value_columns[0] - - @property - def _name(self) -> blocks.Label: - return self._block.column_labels[0] - - @property - def _dtype(self): - return self._block.dtypes[0] - - def _set_block(self, block: blocks.Block): - self._block = block - - def _get_block(self) -> blocks.Block: - return self._block - - def _apply_unary_op( - self, - op: ops.UnaryOp, - ) -> series.Series: - """Applies a unary operator to the series.""" - block, result_id = self._block.apply_unary_op( - self._value_column, op, result_label=self._name - ) - return series.Series(block.select_column(result_id)) - - def _apply_binary_op( - self, - other: typing.Any, - op: ops.BinaryOp, - alignment: typing.Literal["outer", "left"] = "outer", - reverse: bool = False, - ) -> series.Series: - """Applies a binary operator to the series and other.""" - if bigframes.core.convert.can_convert_to_series(other): - self_index = indexes.Index(self._block) - other_series = bigframes.core.convert.to_bf_series( - other, self_index, self._block.session - ) - (self_col, other_col, block) = self._align(other_series, how=alignment) - - name = self._name - # Drop name if both objects have name attr, but they don't match - if ( - hasattr(other, "name") - and other_series.name != self._name - and alignment == "outer" - ): - name = None - expr = op.as_expr( - other_col if reverse else self_col, self_col if reverse else other_col - ) - block, result_id = block.project_expr(expr, name) - return series.Series(block.select_column(result_id)) - - else: # Scalar binop - name = self._name - expr = op.as_expr( - ex.const(other) if reverse else self._value_column, - self._value_column if reverse else ex.const(other), - ) - block, result_id = self._block.project_expr(expr, name) - return series.Series(block.select_column(result_id)) - - def _apply_nary_op( - self, - op: ops.NaryOp, - others: Sequence[typing.Union[series.Series, scalars.Scalar]], - ignore_self=False, - ): - """Applies an n-ary operator to the series and others.""" - values, block = self._align_n( - others, ignore_self=ignore_self, cast_scalars=False - ) - block, result_id = block.project_expr(op.as_expr(*values)) - return series.Series(block.select_column(result_id)) - - def _apply_binary_aggregation( - self, other: series.Series, stat: agg_ops.BinaryAggregateOp - ) -> float: - (left, right, block) = self._align(other, how="outer") - assert isinstance(left, ex.DerefOp) - assert isinstance(right, ex.DerefOp) - return block.get_binary_stat(left.id.name, right.id.name, stat) - - AlignedExprT = Union[ex.ScalarConstantExpression, ex.DerefOp] - - @typing.overload - def _align( - self, other: series.Series, how="outer" - ) -> tuple[ex.DerefOp, ex.DerefOp, blocks.Block,]: - ... - - @typing.overload - def _align( - self, other: typing.Union[series.Series, scalars.Scalar], how="outer" - ) -> tuple[ex.DerefOp, AlignedExprT, blocks.Block,]: - ... - - def _align( - self, other: typing.Union[series.Series, scalars.Scalar], how="outer" - ) -> tuple[ex.DerefOp, AlignedExprT, blocks.Block,]: - """Aligns the series value with another scalar or series object. Returns new left column id, right column id and joined tabled expression.""" - values, block = self._align_n( - [ - other, - ], - how, - ) - return (typing.cast(ex.DerefOp, values[0]), values[1], block) - - def _align3(self, other1: series.Series | scalars.Scalar, other2: series.Series | scalars.Scalar, how="left", cast_scalars: bool = True) -> tuple[ex.DerefOp, AlignedExprT, AlignedExprT, blocks.Block]: # type: ignore - """Aligns the series value with 2 other scalars or series objects. Returns new values and joined tabled expression.""" - values, index = self._align_n([other1, other2], how, cast_scalars=cast_scalars) - return ( - typing.cast(ex.DerefOp, values[0]), - values[1], - values[2], - index, - ) - - def _align_n( - self, - others: typing.Sequence[typing.Union[series.Series, scalars.Scalar]], - how="outer", - ignore_self=False, - cast_scalars: bool = False, - ) -> tuple[ - typing.Sequence[Union[ex.ScalarConstantExpression, ex.DerefOp]], - blocks.Block, - ]: - if ignore_self: - value_ids: List[Union[ex.ScalarConstantExpression, ex.DerefOp]] = [] - else: - value_ids = [ex.deref(self._value_column)] - - block = self._block - for other in others: - if isinstance(other, series.Series): - block, ( - get_column_left, - get_column_right, - ) = block.join(other._block, how=how) - rebindings = { - ids.ColumnId(old): ids.ColumnId(new) - for old, new in get_column_left.items() - } - remapped_value_ids = ( - value.remap_column_refs(rebindings) for value in value_ids - ) - value_ids = [ - *remapped_value_ids, # type: ignore - ex.deref(get_column_right[other._value_column]), - ] - else: - # Will throw if can't interpret as scalar. - dtype = typing.cast(bigframes.dtypes.Dtype, self._dtype) - value_ids = [ - *value_ids, - ex.const(other, dtype=dtype if cast_scalars else None), - ] - return (value_ids, block) - - def _throw_if_null_index(self, opname: str): - if len(self._block.index_columns) == 0: - raise bigframes.exceptions.NullIndexError( - f"Series cannot perform {opname} as it has no index. Set an index using set_index." - ) diff --git a/bigframes/operations/blob.py b/bigframes/operations/blob.py index 4da9bfee82..1f6b75a8f5 100644 --- a/bigframes/operations/blob.py +++ b/bigframes/operations/blob.py @@ -26,7 +26,6 @@ from bigframes.core import log_adapter import bigframes.dataframe import bigframes.exceptions as bfe -from bigframes.operations import base import bigframes.operations as ops import bigframes.series @@ -35,7 +34,7 @@ @log_adapter.class_logger -class BlobAccessor(base.SeriesMethods): +class BlobAccessor: """ Blob functions for Series and Index. @@ -46,15 +45,15 @@ class BlobAccessor(base.SeriesMethods): (https://cloud.google.com/products#product-launch-stages). """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, data: bigframes.series.Series): + self._data = data def uri(self) -> bigframes.series.Series: """URIs of the Blob. Returns: bigframes.series.Series: URIs as string.""" - s = bigframes.series.Series(self._block) + s = bigframes.series.Series(self._data._block) return s.struct.field("uri") @@ -63,7 +62,7 @@ def authorizer(self) -> bigframes.series.Series: Returns: bigframes.series.Series: Autorithers(connection) as string.""" - s = bigframes.series.Series(self._block) + s = bigframes.series.Series(self._data._block) return s.struct.field("authorizer") @@ -73,14 +72,16 @@ def version(self) -> bigframes.series.Series: Returns: bigframes.series.Series: Version as string.""" # version must be retrieved after fetching metadata - return self._apply_unary_op(ops.obj_fetch_metadata_op).struct.field("version") + return self._data._apply_unary_op(ops.obj_fetch_metadata_op).struct.field( + "version" + ) def metadata(self) -> bigframes.series.Series: """Retrieve the metadata of the Blob. Returns: bigframes.series.Series: JSON metadata of the Blob. Contains fields: content_type, md5_hash, size and updated(time).""" - series_to_check = bigframes.series.Series(self._block) + series_to_check = bigframes.series.Series(self._data._block) # Check if it's a struct series from a verbose operation if dtypes.is_struct_like(series_to_check.dtype): pyarrow_dtype = series_to_check.dtype.pyarrow_dtype @@ -160,7 +161,11 @@ def _get_runtime( Returns: bigframes.series.Series: ObjectRefRuntime JSON. """ - s = self._apply_unary_op(ops.obj_fetch_metadata_op) if with_metadata else self + s = ( + self._data._apply_unary_op(ops.obj_fetch_metadata_op) + if with_metadata + else self._data + ) return s._apply_unary_op(ops.ObjGetAccessUrl(mode=mode)) @@ -226,7 +231,7 @@ def display( height = height or bigframes.options.display.blob_display_height # col name doesn't matter here. Rename to avoid column name conflicts - df = bigframes.series.Series(self._block).rename("blob_col").to_frame() + df = bigframes.series.Series(self._data._block).rename("blob_col").to_frame() df["read_url"] = df["blob_col"].blob.read_url() @@ -274,7 +279,7 @@ def display_single_url( @property def session(self): - return self._block.session + return self._data._block.session def _resolve_connection(self, connection: Optional[str] = None) -> str: """Resovle the BigQuery connection. @@ -291,11 +296,11 @@ def _resolve_connection(self, connection: Optional[str] = None) -> str: Raises: ValueError: If the connection cannot be resolved to a valid string. """ - connection = connection or self._block.session._bq_connection + connection = connection or self._data._block.session._bq_connection return clients.get_canonical_bq_connection_id( connection, - default_project=self._block.session._project, - default_location=self._block.session._location, + default_project=self._data._block.session._project, + default_location=self._data._block.session._location, ) def get_runtime_json_str( @@ -352,7 +357,7 @@ def exif( exif_udf = blob_func.TransformFunction( blob_func.exif_func_def, - session=self._block.session, + session=self._data._block.session, connection=connection, max_batching_rows=max_batching_rows, container_cpu=container_cpu, @@ -422,7 +427,7 @@ def image_blur( image_blur_udf = blob_func.TransformFunction( blob_func.image_blur_to_bytes_def, - session=self._block.session, + session=self._data._block.session, connection=connection, max_batching_rows=max_batching_rows, container_cpu=container_cpu, @@ -467,7 +472,7 @@ def image_blur( image_blur_udf = blob_func.TransformFunction( blob_func.image_blur_def, - session=self._block.session, + session=self._data._block.session, connection=connection, max_batching_rows=max_batching_rows, container_cpu=container_cpu, @@ -558,7 +563,7 @@ def image_resize( image_resize_udf = blob_func.TransformFunction( blob_func.image_resize_to_bytes_def, - session=self._block.session, + session=self._data._block.session, connection=connection, max_batching_rows=max_batching_rows, container_cpu=container_cpu, @@ -605,7 +610,7 @@ def image_resize( image_resize_udf = blob_func.TransformFunction( blob_func.image_resize_def, - session=self._block.session, + session=self._data._block.session, connection=connection, max_batching_rows=max_batching_rows, container_cpu=container_cpu, @@ -690,7 +695,7 @@ def image_normalize( image_normalize_udf = blob_func.TransformFunction( blob_func.image_normalize_to_bytes_def, - session=self._block.session, + session=self._data._block.session, connection=connection, max_batching_rows=max_batching_rows, container_cpu=container_cpu, @@ -737,7 +742,7 @@ def image_normalize( image_normalize_udf = blob_func.TransformFunction( blob_func.image_normalize_def, - session=self._block.session, + session=self._data._block.session, connection=connection, max_batching_rows=max_batching_rows, container_cpu=container_cpu, @@ -816,7 +821,7 @@ def pdf_extract( pdf_extract_udf = blob_func.TransformFunction( blob_func.pdf_extract_def, - session=self._block.session, + session=self._data._block.session, connection=connection, max_batching_rows=max_batching_rows, container_cpu=container_cpu, @@ -898,7 +903,7 @@ def pdf_chunk( pdf_chunk_udf = blob_func.TransformFunction( blob_func.pdf_chunk_def, - session=self._block.session, + session=self._data._block.session, connection=connection, max_batching_rows=max_batching_rows, container_cpu=container_cpu, @@ -965,7 +970,7 @@ def audio_transcribe( import bigframes.pandas as bpd # col name doesn't matter here. Rename to avoid column name conflicts - audio_series = bigframes.series.Series(self._block) + audio_series = bigframes.series.Series(self._data._block) prompt_text = "**Task:** Transcribe the provided audio. **Instructions:** - Your response must contain only the verbatim transcription of the audio. - Do not include any introductory text, summaries, or conversational filler in your response. The output should begin directly with the first word of the audio." diff --git a/bigframes/operations/datetimes.py b/bigframes/operations/datetimes.py index c80379cc2b..608089ab41 100644 --- a/bigframes/operations/datetimes.py +++ b/bigframes/operations/datetimes.py @@ -24,7 +24,6 @@ from bigframes import dataframe, dtypes, series from bigframes.core import log_adapter import bigframes.operations as ops -import bigframes.operations.base _ONE_DAY = pandas.Timedelta("1d") _ONE_SECOND = pandas.Timedelta("1s") @@ -34,20 +33,22 @@ @log_adapter.class_logger class DatetimeMethods( - bigframes.operations.base.SeriesMethods, vendordt.DatetimeProperties, vendored_pandas_datetimelike.DatelikeOps, ): __doc__ = vendordt.DatetimeProperties.__doc__ + def __init__(self, data: series.Series): + self._data = data + # Date accessors @property def day(self) -> series.Series: - return self._apply_unary_op(ops.day_op) + return self._data._apply_unary_op(ops.day_op) @property def dayofweek(self) -> series.Series: - return self._apply_unary_op(ops.dayofweek_op) + return self._data._apply_unary_op(ops.dayofweek_op) @property def day_of_week(self) -> series.Series: @@ -55,7 +56,7 @@ def day_of_week(self) -> series.Series: @property def dayofyear(self) -> series.Series: - return self._apply_unary_op(ops.dayofyear_op) + return self._data._apply_unary_op(ops.dayofyear_op) @property def day_of_year(self) -> series.Series: @@ -63,78 +64,78 @@ def day_of_year(self) -> series.Series: @property def date(self) -> series.Series: - return self._apply_unary_op(ops.date_op) + return self._data._apply_unary_op(ops.date_op) @property def quarter(self) -> series.Series: - return self._apply_unary_op(ops.quarter_op) + return self._data._apply_unary_op(ops.quarter_op) @property def year(self) -> series.Series: - return self._apply_unary_op(ops.year_op) + return self._data._apply_unary_op(ops.year_op) @property def month(self) -> series.Series: - return self._apply_unary_op(ops.month_op) + return self._data._apply_unary_op(ops.month_op) def isocalendar(self) -> dataframe.DataFrame: iso_ops = [ops.iso_year_op, ops.iso_week_op, ops.iso_day_op] labels = pandas.Index(["year", "week", "day"]) - block = self._block.project_exprs( - [op.as_expr(self._value_column) for op in iso_ops], labels, drop=True + block = self._data._block.project_exprs( + [op.as_expr(self._data._value_column) for op in iso_ops], labels, drop=True ) return dataframe.DataFrame(block) # Time accessors @property def hour(self) -> series.Series: - return self._apply_unary_op(ops.hour_op) + return self._data._apply_unary_op(ops.hour_op) @property def minute(self) -> series.Series: - return self._apply_unary_op(ops.minute_op) + return self._data._apply_unary_op(ops.minute_op) @property def second(self) -> series.Series: - return self._apply_unary_op(ops.second_op) + return self._data._apply_unary_op(ops.second_op) @property def time(self) -> series.Series: - return self._apply_unary_op(ops.time_op) + return self._data._apply_unary_op(ops.time_op) # Timedelta accessors @property def days(self) -> series.Series: self._check_dtype(dtypes.TIMEDELTA_DTYPE) - return self._apply_binary_op(_ONE_DAY, ops.floordiv_op) + return self._data._apply_binary_op(_ONE_DAY, ops.floordiv_op) @property def seconds(self) -> series.Series: self._check_dtype(dtypes.TIMEDELTA_DTYPE) - return self._apply_binary_op(_ONE_DAY, ops.mod_op) // _ONE_SECOND # type: ignore + return self._data._apply_binary_op(_ONE_DAY, ops.mod_op) // _ONE_SECOND # type: ignore @property def microseconds(self) -> series.Series: self._check_dtype(dtypes.TIMEDELTA_DTYPE) - return self._apply_binary_op(_ONE_SECOND, ops.mod_op) // _ONE_MICRO # type: ignore + return self._data._apply_binary_op(_ONE_SECOND, ops.mod_op) // _ONE_MICRO # type: ignore def total_seconds(self) -> series.Series: self._check_dtype(dtypes.TIMEDELTA_DTYPE) - return self._apply_binary_op(_ONE_SECOND, ops.div_op) + return self._data._apply_binary_op(_ONE_SECOND, ops.div_op) def _check_dtype(self, target_dtype: dtypes.Dtype): - if self._dtype == target_dtype: + if self._data._dtype == target_dtype: return - raise TypeError(f"Expect dtype: {target_dtype}, but got {self._dtype}") + raise TypeError(f"Expect dtype: {target_dtype}, but got {self._data._dtype}") @property def tz(self) -> Optional[dt.timezone]: # Assumption: pyarrow dtype - tz_string = self._dtype.pyarrow_dtype.tz + tz_string = self._data._dtype.pyarrow_dtype.tz if tz_string == "UTC": return dt.timezone.utc elif tz_string is None: @@ -145,15 +146,15 @@ def tz(self) -> Optional[dt.timezone]: @property def unit(self) -> str: # Assumption: pyarrow dtype - return self._dtype.pyarrow_dtype.unit + return self._data._dtype.pyarrow_dtype.unit def strftime(self, date_format: str) -> series.Series: - return self._apply_unary_op(ops.StrftimeOp(date_format=date_format)) + return self._data._apply_unary_op(ops.StrftimeOp(date_format=date_format)) def normalize(self) -> series.Series: - return self._apply_unary_op(ops.normalize_op) + return self._data._apply_unary_op(ops.normalize_op) def floor(self, freq: str) -> series.Series: if freq not in _SUPPORTED_FREQS: raise ValueError(f"freq must be one of {_SUPPORTED_FREQS}") - return self._apply_unary_op(ops.FloorDtOp(freq=freq)) # type: ignore + return self._data._apply_unary_op(ops.FloorDtOp(freq=freq)) # type: ignore diff --git a/bigframes/operations/lists.py b/bigframes/operations/lists.py index 16c22dfb2a..34ecdd8118 100644 --- a/bigframes/operations/lists.py +++ b/bigframes/operations/lists.py @@ -22,24 +22,24 @@ from bigframes.core import log_adapter import bigframes.operations as ops from bigframes.operations._op_converters import convert_index, convert_slice -import bigframes.operations.base import bigframes.series as series @log_adapter.class_logger -class ListAccessor( - bigframes.operations.base.SeriesMethods, vendoracessors.ListAccessor -): +class ListAccessor(vendoracessors.ListAccessor): __doc__ = vendoracessors.ListAccessor.__doc__ + def __init__(self, data: series.Series): + self._data = data + def len(self): - return self._apply_unary_op(ops.len_op) + return self._data._apply_unary_op(ops.len_op) def __getitem__(self, key: Union[int, slice]) -> series.Series: if isinstance(key, int): - return self._apply_unary_op(convert_index(key)) + return self._data._apply_unary_op(convert_index(key)) elif isinstance(key, slice): - return self._apply_unary_op(convert_slice(key)) + return self._data._apply_unary_op(convert_slice(key)) else: raise ValueError(f"key must be an int or slice, got {type(key).__name__}") diff --git a/bigframes/operations/strings.py b/bigframes/operations/strings.py index efbdd865b0..3288be591c 100644 --- a/bigframes/operations/strings.py +++ b/bigframes/operations/strings.py @@ -25,7 +25,6 @@ import bigframes.operations as ops from bigframes.operations._op_converters import convert_index, convert_slice import bigframes.operations.aggregations as agg_ops -import bigframes.operations.base import bigframes.series as series # Maps from python to re2 @@ -37,14 +36,17 @@ @log_adapter.class_logger -class StringMethods(bigframes.operations.base.SeriesMethods, vendorstr.StringMethods): +class StringMethods(vendorstr.StringMethods): __doc__ = vendorstr.StringMethods.__doc__ + def __init__(self, data: series.Series): + self._data = data + def __getitem__(self, key: Union[int, slice]) -> series.Series: if isinstance(key, int): - return self._apply_unary_op(convert_index(key)) + return self._data._apply_unary_op(convert_index(key)) elif isinstance(key, slice): - return self._apply_unary_op(convert_slice(key)) + return self._data._apply_unary_op(convert_slice(key)) else: raise ValueError(f"key must be an int or slice, got {type(key).__name__}") @@ -54,13 +56,15 @@ def find( start: Optional[int] = None, end: Optional[int] = None, ) -> series.Series: - return self._apply_unary_op(ops.StrFindOp(substr=sub, start=start, end=end)) + return self._data._apply_unary_op( + ops.StrFindOp(substr=sub, start=start, end=end) + ) def len(self) -> series.Series: - return self._apply_unary_op(ops.len_op) + return self._data._apply_unary_op(ops.len_op) def lower(self) -> series.Series: - return self._apply_unary_op(ops.lower_op) + return self._data._apply_unary_op(ops.lower_op) def reverse(self) -> series.Series: """Reverse strings in the Series. @@ -81,76 +85,76 @@ def reverse(self) -> series.Series: pattern matches the start of each string element. """ # reverse method is in ibis, not pandas. - return self._apply_unary_op(ops.reverse_op) + return self._data._apply_unary_op(ops.reverse_op) def slice( self, start: Optional[int] = None, stop: Optional[int] = None, ) -> series.Series: - return self._apply_unary_op(ops.StrSliceOp(start=start, end=stop)) + return self._data._apply_unary_op(ops.StrSliceOp(start=start, end=stop)) def strip(self, to_strip: Optional[str] = None) -> series.Series: - return self._apply_unary_op( + return self._data._apply_unary_op( ops.StrStripOp(to_strip=" \n\t" if to_strip is None else to_strip) ) def upper(self) -> series.Series: - return self._apply_unary_op(ops.upper_op) + return self._data._apply_unary_op(ops.upper_op) def isnumeric(self) -> series.Series: - return self._apply_unary_op(ops.isnumeric_op) + return self._data._apply_unary_op(ops.isnumeric_op) def isalpha( self, ) -> series.Series: - return self._apply_unary_op(ops.isalpha_op) + return self._data._apply_unary_op(ops.isalpha_op) def isdigit( self, ) -> series.Series: - return self._apply_unary_op(ops.isdigit_op) + return self._data._apply_unary_op(ops.isdigit_op) def isdecimal( self, ) -> series.Series: - return self._apply_unary_op(ops.isdecimal_op) + return self._data._apply_unary_op(ops.isdecimal_op) def isalnum( self, ) -> series.Series: - return self._apply_unary_op(ops.isalnum_op) + return self._data._apply_unary_op(ops.isalnum_op) def isspace( self, ) -> series.Series: - return self._apply_unary_op(ops.isspace_op) + return self._data._apply_unary_op(ops.isspace_op) def islower( self, ) -> series.Series: - return self._apply_unary_op(ops.islower_op) + return self._data._apply_unary_op(ops.islower_op) def isupper( self, ) -> series.Series: - return self._apply_unary_op(ops.isupper_op) + return self._data._apply_unary_op(ops.isupper_op) def rstrip(self, to_strip: Optional[str] = None) -> series.Series: - return self._apply_unary_op( + return self._data._apply_unary_op( ops.StrRstripOp(to_strip=" \n\t" if to_strip is None else to_strip) ) def lstrip(self, to_strip: Optional[str] = None) -> series.Series: - return self._apply_unary_op( + return self._data._apply_unary_op( ops.StrLstripOp(to_strip=" \n\t" if to_strip is None else to_strip) ) def repeat(self, repeats: int) -> series.Series: - return self._apply_unary_op(ops.StrRepeatOp(repeats=repeats)) + return self._data._apply_unary_op(ops.StrRepeatOp(repeats=repeats)) def capitalize(self) -> series.Series: - return self._apply_unary_op(ops.capitalize_op) + return self._data._apply_unary_op(ops.capitalize_op) def match(self, pat, case=True, flags=0) -> series.Series: # \A anchors start of entire string rather than start of any line in multiline mode @@ -164,20 +168,20 @@ def fullmatch(self, pat, case=True, flags=0) -> series.Series: return self.contains(pat=adj_pat, case=case, flags=flags) def get(self, i: int) -> series.Series: - return self._apply_unary_op(ops.StrGetOp(i=i)) + return self._data._apply_unary_op(ops.StrGetOp(i=i)) def pad(self, width, side="left", fillchar=" ") -> series.Series: - return self._apply_unary_op( + return self._data._apply_unary_op( ops.StrPadOp(length=width, fillchar=fillchar, side=side) ) def ljust(self, width, fillchar=" ") -> series.Series: - return self._apply_unary_op( + return self._data._apply_unary_op( ops.StrPadOp(length=width, fillchar=fillchar, side="right") ) def rjust(self, width, fillchar=" ") -> series.Series: - return self._apply_unary_op( + return self._data._apply_unary_op( ops.StrPadOp(length=width, fillchar=fillchar, side="left") ) @@ -190,9 +194,9 @@ def contains( re2flags = _parse_flags(flags) if re2flags: pat = re2flags + pat - return self._apply_unary_op(ops.StrContainsRegexOp(pat=pat)) + return self._data._apply_unary_op(ops.StrContainsRegexOp(pat=pat)) else: - return self._apply_unary_op(ops.StrContainsOp(pat=pat)) + return self._data._apply_unary_op(ops.StrContainsOp(pat=pat)) def extract(self, pat: str, flags: int = 0) -> df.DataFrame: re2flags = _parse_flags(flags) @@ -203,7 +207,7 @@ def extract(self, pat: str, flags: int = 0) -> df.DataFrame: raise ValueError("No capture groups in 'pat'") results: list[str] = [] - block = self._block + block = self._data._block for i in range(compiled.groups): labels = [ label @@ -212,7 +216,7 @@ def extract(self, pat: str, flags: int = 0) -> df.DataFrame: ] label = labels[0] if labels else str(i) block, id = block.apply_unary_op( - self._value_column, + self._data._value_column, ops.StrExtractOp(pat=pat, n=i + 1), result_label=label, ) @@ -242,13 +246,15 @@ def replace( re2flags = _parse_flags(flags) if re2flags: pat_str = re2flags + pat_str - return self._apply_unary_op(ops.RegexReplaceStrOp(pat=pat_str, repl=repl)) + return self._data._apply_unary_op( + ops.RegexReplaceStrOp(pat=pat_str, repl=repl) + ) else: if isinstance(pat, re.Pattern): raise ValueError( "Must set 'regex'=True if using compiled regex pattern." ) - return self._apply_unary_op(ops.ReplaceStrOp(pat=pat_str, repl=repl)) + return self._data._apply_unary_op(ops.ReplaceStrOp(pat=pat_str, repl=repl)) def startswith( self, @@ -256,7 +262,7 @@ def startswith( ) -> series.Series: if not isinstance(pat, tuple): pat = (pat,) - return self._apply_unary_op(ops.StartsWithOp(pat=pat)) + return self._data._apply_unary_op(ops.StartsWithOp(pat=pat)) def endswith( self, @@ -264,7 +270,7 @@ def endswith( ) -> series.Series: if not isinstance(pat, tuple): pat = (pat,) - return self._apply_unary_op(ops.EndsWithOp(pat=pat)) + return self._data._apply_unary_op(ops.EndsWithOp(pat=pat)) def split( self, @@ -276,13 +282,13 @@ def split( "Regular expressions aren't currently supported. Please set " + f"`regex=False` and try again. {constants.FEEDBACK_LINK}" ) - return self._apply_unary_op(ops.StringSplitOp(pat=pat)) + return self._data._apply_unary_op(ops.StringSplitOp(pat=pat)) def zfill(self, width: int) -> series.Series: - return self._apply_unary_op(ops.ZfillOp(width=width)) + return self._data._apply_unary_op(ops.ZfillOp(width=width)) def center(self, width: int, fillchar: str = " ") -> series.Series: - return self._apply_unary_op( + return self._data._apply_unary_op( ops.StrPadOp(length=width, fillchar=fillchar, side="both") ) @@ -292,10 +298,10 @@ def cat( *, join: Literal["outer", "left"] = "left", ) -> series.Series: - return self._apply_binary_op(others, ops.strconcat_op, alignment=join) + return self._data._apply_binary_op(others, ops.strconcat_op, alignment=join) def join(self, sep: str) -> series.Series: - return self._apply_unary_op( + return self._data._apply_unary_op( ops.ArrayReduceOp(aggregation=agg_ops.StringAggOp(sep=sep)) ) @@ -319,9 +325,9 @@ def to_blob(self, connection: Optional[str] = None) -> series.Series: bigframes.series.Series: Blob Series. """ - session = self._block.session + session = self._data._block.session connection = session._create_bq_connection(connection=connection) - return self._apply_binary_op(connection, ops.obj_make_ref_op) + return self._data._apply_binary_op(connection, ops.obj_make_ref_op) def _parse_flags(flags: int) -> Optional[str]: diff --git a/bigframes/operations/structs.py b/bigframes/operations/structs.py index fc277008e2..35010e1733 100644 --- a/bigframes/operations/structs.py +++ b/bigframes/operations/structs.py @@ -20,29 +20,31 @@ from bigframes.core import backports, log_adapter import bigframes.dataframe import bigframes.operations -import bigframes.operations.base import bigframes.series @log_adapter.class_logger -class StructAccessor( - bigframes.operations.base.SeriesMethods, vendoracessors.StructAccessor -): +class StructAccessor(vendoracessors.StructAccessor): __doc__ = vendoracessors.StructAccessor.__doc__ + def __init__(self, data: bigframes.series.Series): + self._data = data + def field(self, name_or_index: str | int) -> bigframes.series.Series: - series = self._apply_unary_op(bigframes.operations.StructFieldOp(name_or_index)) + series = self._data._apply_unary_op( + bigframes.operations.StructFieldOp(name_or_index) + ) if isinstance(name_or_index, str): name = name_or_index else: - struct_field = self._dtype.pyarrow_dtype[name_or_index] + struct_field = self._data._dtype.pyarrow_dtype[name_or_index] name = struct_field.name return series.rename(name) def explode(self) -> bigframes.dataframe.DataFrame: import bigframes.pandas - pa_type = self._dtype.pyarrow_dtype + pa_type = self._data._dtype.pyarrow_dtype return bigframes.pandas.concat( [ self.field(field.name) @@ -53,7 +55,7 @@ def explode(self) -> bigframes.dataframe.DataFrame: @property def dtypes(self) -> pd.Series: - pa_type = self._dtype.pyarrow_dtype + pa_type = self._data._dtype.pyarrow_dtype return pd.Series( data=[ pd.ArrowDtype(field.type) diff --git a/bigframes/series.py b/bigframes/series.py index 642e574627..f08fc6cc14 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -45,7 +45,6 @@ import numpy import pandas from pandas.api import extensions as pd_ext -import pandas.core.dtypes.common import pyarrow as pa import typing_extensions @@ -54,6 +53,7 @@ import bigframes.core.block_transforms as block_ops import bigframes.core.blocks as blocks import bigframes.core.expression as ex +import bigframes.core.identifiers as ids import bigframes.core.indexers import bigframes.core.indexes as indexes import bigframes.core.ordering as order @@ -70,13 +70,13 @@ import bigframes.functions import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops -import bigframes.operations.base import bigframes.operations.blob as blob import bigframes.operations.datetimes as dt import bigframes.operations.lists as lists import bigframes.operations.plotting as plotting import bigframes.operations.strings as strings import bigframes.operations.structs as structs +import bigframes.session if typing.TYPE_CHECKING: import bigframes.geopandas.geoseries @@ -94,7 +94,7 @@ @log_adapter.class_logger -class Series(bigframes.operations.base.SeriesMethods, vendored_pandas_series.Series): +class Series(vendored_pandas_series.Series): # Must be above 5000 for pandas to delegate to bigframes for binops __pandas_priority__ = 13000 @@ -102,14 +102,108 @@ class Series(bigframes.operations.base.SeriesMethods, vendored_pandas_series.Ser # gets set in various places. _block: blocks.Block - def __init__(self, *args, **kwargs): + def __init__( + self, + data=None, + index=None, + dtype: Optional[bigframes.dtypes.DtypeString | bigframes.dtypes.Dtype] = None, + name: str | None = None, + copy: Optional[bool] = None, + *, + session: Optional[bigframes.session.Session] = None, + ): self._query_job: Optional[bigquery.QueryJob] = None - super().__init__(*args, **kwargs) + import bigframes.pandas + + # Ignore object dtype if provided, as it provides no additional + # information about what BigQuery type to use. + if dtype is not None and bigframes.dtypes.is_object_like(dtype): + dtype = None + + read_pandas_func = ( + session.read_pandas + if (session is not None) + else (lambda x: bigframes.pandas.read_pandas(x)) + ) + + block: typing.Optional[blocks.Block] = None + if (name is not None) and not isinstance(name, typing.Hashable): + raise ValueError( + f"BigQuery DataFrames only supports hashable series names. {constants.FEEDBACK_LINK}" + ) + if copy is not None and not copy: + raise ValueError( + f"Series constructor only supports copy=True. {constants.FEEDBACK_LINK}" + ) + + if isinstance(data, blocks.Block): + block = data + elif isinstance(data, bigframes.pandas.Series): + block = data._get_block() + # special case where data is local scalar, but index is bigframes index (maybe very big) + elif ( + not utils.is_list_like(data) and not isinstance(data, indexes.Index) + ) and isinstance(index, indexes.Index): + block = index._block + block, _ = block.create_constant(data) + block = block.with_column_labels([None]) + # prevents no-op reindex later + index = None + elif isinstance(data, indexes.Index) or isinstance(index, indexes.Index): + data = indexes.Index(data, dtype=dtype, name=name, session=session) + # set to none as it has already been applied, avoid re-cast later + if data.nlevels != 1: + raise NotImplementedError("Cannot interpret multi-index as Series.") + # Reset index to promote index columns to value columns, set default index + data_block = data._block.reset_index(drop=False).with_column_labels( + data.names + ) + if index is not None: # Align data and index by offset + bf_index = indexes.Index(index, session=session) + idx_block = bf_index._block.reset_index( + drop=False + ) # reset to align by offsets, and then reset back + idx_cols = idx_block.value_columns + data_block, (l_mapping, _) = idx_block.join(data_block, how="left") + data_block = data_block.set_index([l_mapping[col] for col in idx_cols]) + data_block = data_block.with_index_labels(bf_index.names) + # prevents no-op reindex later + index = None + block = data_block + + if block: + assert len(block.value_columns) == 1 + assert len(block.column_labels) == 1 + if index is not None: # reindexing operation + bf_index = indexes.Index(index) + idx_block = bf_index._block + idx_cols = idx_block.index_columns + block, _ = idx_block.join(block, how="left") + block = block.with_index_labels(bf_index.names) + if name: + block = block.with_column_labels([name]) + if dtype: + bf_dtype = bigframes.dtypes.bigframes_type(dtype) + block = block.multi_apply_unary_op(ops.AsTypeOp(to_type=bf_dtype)) + else: + if isinstance(dtype, str) and dtype.lower() == "json": + dtype = bigframes.dtypes.JSON_DTYPE + pd_series = pandas.Series( + data=data, + index=index, # type:ignore + dtype=dtype, # type:ignore + name=name, + ) + block = read_pandas_func(pd_series)._get_block() # type:ignore + + assert block is not None + self._block: blocks.Block = block + self._block.session._register_object(self) @property def dt(self) -> dt.DatetimeMethods: - return dt.DatetimeMethods(self._block) + return dt.DatetimeMethods(self) @property def dtype(self): @@ -212,15 +306,15 @@ def query_job(self) -> Optional[bigquery.QueryJob]: @property def struct(self) -> structs.StructAccessor: - return structs.StructAccessor(self._block) + return structs.StructAccessor(self) @property def list(self) -> lists.ListAccessor: - return lists.ListAccessor(self._block) + return lists.ListAccessor(self) @property def blob(self) -> blob.BlobAccessor: - return blob.BlobAccessor(self._block) + return blob.BlobAccessor(self) @property @validations.requires_ordering() @@ -2528,8 +2622,8 @@ def _slice( start: typing.Optional[int] = None, stop: typing.Optional[int] = None, step: typing.Optional[int] = None, - ) -> bigframes.series.Series: - return bigframes.series.Series( + ) -> Series: + return Series( self._block.slice( start=start, stop=stop, step=step if (step is not None) else 1 ).select_column(self._value_column), @@ -2555,7 +2649,178 @@ def _cached(self, *, force: bool = True, session_aware: bool = True) -> Series: # confusing type checker by overriding str @property def str(self) -> strings.StringMethods: - return strings.StringMethods(self._block) + return strings.StringMethods(self) + + @property + def _value_column(self) -> __builtins__.str: + return self._block.value_columns[0] + + @property + def _name(self) -> blocks.Label: + return self._block.column_labels[0] + + @property + def _dtype(self): + return self._block.dtypes[0] + + def _set_block(self, block: blocks.Block): + self._block = block + + def _get_block(self) -> blocks.Block: + return self._block + + def _apply_unary_op( + self, + op: ops.UnaryOp, + ) -> Series: + """Applies a unary operator to the series.""" + block, result_id = self._block.apply_unary_op( + self._value_column, op, result_label=self._name + ) + return Series(block.select_column(result_id)) + + def _apply_binary_op( + self, + other: typing.Any, + op: ops.BinaryOp, + alignment: typing.Literal["outer", "left"] = "outer", + reverse: bool = False, + ) -> Series: + """Applies a binary operator to the series and other.""" + if bigframes.core.convert.can_convert_to_series(other): + self_index = indexes.Index(self._block) + other_series = bigframes.core.convert.to_bf_series( + other, self_index, self._block.session + ) + (self_col, other_col, block) = self._align(other_series, how=alignment) + + name = self._name + # Drop name if both objects have name attr, but they don't match + if ( + hasattr(other, "name") + and other_series.name != self._name + and alignment == "outer" + ): + name = None + expr = op.as_expr( + other_col if reverse else self_col, self_col if reverse else other_col + ) + block, result_id = block.project_expr(expr, name) + return Series(block.select_column(result_id)) + + else: # Scalar binop + name = self._name + expr = op.as_expr( + ex.const(other) if reverse else self._value_column, + self._value_column if reverse else ex.const(other), + ) + block, result_id = self._block.project_expr(expr, name) + return Series(block.select_column(result_id)) + + def _apply_nary_op( + self, + op: ops.NaryOp, + others: Sequence[typing.Union[Series, scalars.Scalar]], + ignore_self=False, + ): + """Applies an n-ary operator to the series and others.""" + values, block = self._align_n( + others, ignore_self=ignore_self, cast_scalars=False + ) + block, result_id = block.project_expr(op.as_expr(*values)) + return Series(block.select_column(result_id)) + + def _apply_binary_aggregation( + self, other: Series, stat: agg_ops.BinaryAggregateOp + ) -> float: + (left, right, block) = self._align(other, how="outer") + assert isinstance(left, ex.DerefOp) + assert isinstance(right, ex.DerefOp) + return block.get_binary_stat(left.id.name, right.id.name, stat) + + AlignedExprT = Union[ex.ScalarConstantExpression, ex.DerefOp] + + @typing.overload + def _align( + self, other: Series, how="outer" + ) -> tuple[ex.DerefOp, ex.DerefOp, blocks.Block,]: + ... + + @typing.overload + def _align( + self, other: typing.Union[Series, scalars.Scalar], how="outer" + ) -> tuple[ex.DerefOp, AlignedExprT, blocks.Block,]: + ... + + def _align( + self, other: typing.Union[Series, scalars.Scalar], how="outer" + ) -> tuple[ex.DerefOp, AlignedExprT, blocks.Block,]: + """Aligns the series value with another scalar or series object. Returns new left column id, right column id and joined tabled expression.""" + values, block = self._align_n( + [ + other, + ], + how, + ) + return (typing.cast(ex.DerefOp, values[0]), values[1], block) + + def _align3(self, other1: Series | scalars.Scalar, other2: Series | scalars.Scalar, how="left", cast_scalars: bool = True) -> tuple[ex.DerefOp, AlignedExprT, AlignedExprT, blocks.Block]: # type: ignore + """Aligns the series value with 2 other scalars or series objects. Returns new values and joined tabled expression.""" + values, index = self._align_n([other1, other2], how, cast_scalars=cast_scalars) + return ( + typing.cast(ex.DerefOp, values[0]), + values[1], + values[2], + index, + ) + + def _align_n( + self, + others: typing.Sequence[typing.Union[Series, scalars.Scalar]], + how="outer", + ignore_self=False, + cast_scalars: bool = False, + ) -> tuple[ + typing.Sequence[Union[ex.ScalarConstantExpression, ex.DerefOp]], + blocks.Block, + ]: + if ignore_self: + value_ids: List[Union[ex.ScalarConstantExpression, ex.DerefOp]] = [] + else: + value_ids = [ex.deref(self._value_column)] + + block = self._block + for other in others: + if isinstance(other, Series): + block, ( + get_column_left, + get_column_right, + ) = block.join(other._block, how=how) + rebindings = { + ids.ColumnId(old): ids.ColumnId(new) + for old, new in get_column_left.items() + } + remapped_value_ids = ( + value.remap_column_refs(rebindings) for value in value_ids + ) + value_ids = [ + *remapped_value_ids, # type: ignore + ex.deref(get_column_right[other._value_column]), + ] + else: + # Will throw if can't interpret as scalar. + dtype = typing.cast(bigframes.dtypes.Dtype, self._dtype) + value_ids = [ + *value_ids, + ex.const(other, dtype=dtype if cast_scalars else None), + ] + return (value_ids, block) + + def _throw_if_null_index(self, opname: __builtins__.str): + if len(self._block.index_columns) == 0: + raise bigframes.exceptions.NullIndexError( + f"Series cannot perform {opname} as it has no index. Set an index using set_index." + ) def _is_list_like(obj: typing.Any) -> typing_extensions.TypeGuard[typing.Sequence]: From 0a44e842c5e28c363f4de77122f60fbe59be59c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Fri, 17 Oct 2025 12:13:25 -0500 Subject: [PATCH 162/313] chore: create `DF._to_pandas_batches()` for better type checking of PandasBatches` (#2178) --- bigframes/core/blocks.py | 2 +- bigframes/dataframe.py | 13 +++++++++++++ bigframes/display/anywidget.py | 9 ++++++--- tests/benchmark/read_gbq_colab/aggregate_output.py | 10 ++++++---- tests/benchmark/read_gbq_colab/filter_output.py | 12 +++++++----- tests/benchmark/read_gbq_colab/first_page.py | 5 +++-- tests/benchmark/read_gbq_colab/last_page.py | 5 +++-- tests/benchmark/read_gbq_colab/sort_output.py | 10 ++++++---- 8 files changed, 45 insertions(+), 21 deletions(-) diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index f9896784bb..166841dfbd 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -693,7 +693,7 @@ def to_pandas_batches( page_size: Optional[int] = None, max_results: Optional[int] = None, allow_large_results: Optional[bool] = None, - ) -> Iterator[pd.DataFrame]: + ) -> PandasBatches: """Download results one message at a time. page_size and max_results determine the size and number of batches, diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index ec458cc462..be41ec9e99 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -1930,6 +1930,19 @@ def to_pandas_batches( form the original dataframe. Results stream from bigquery, see https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.table.RowIterator#google_cloud_bigquery_table_RowIterator_to_arrow_iterable """ + return self._to_pandas_batches( + page_size=page_size, + max_results=max_results, + allow_large_results=allow_large_results, + ) + + def _to_pandas_batches( + self, + page_size: Optional[int] = None, + max_results: Optional[int] = None, + *, + allow_large_results: Optional[bool] = None, + ) -> blocks.PandasBatches: return self._block.to_pandas_batches( page_size=page_size, max_results=max_results, diff --git a/bigframes/display/anywidget.py b/bigframes/display/anywidget.py index 5a20ddcb7f..3d12a2032c 100644 --- a/bigframes/display/anywidget.py +++ b/bigframes/display/anywidget.py @@ -23,6 +23,7 @@ import pandas as pd import bigframes +import bigframes.dataframe import bigframes.display.html # anywidget and traitlets are optional dependencies. We don't want the import of this @@ -73,7 +74,7 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): initial_page_size = bigframes.options.display.max_rows # Initialize data fetching attributes. - self._batches = dataframe.to_pandas_batches(page_size=initial_page_size) + self._batches = dataframe._to_pandas_batches(page_size=initial_page_size) # set traitlets properties that trigger observers self.page_size = initial_page_size @@ -82,7 +83,9 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): # SELECT COUNT(*) query. It is a must have however. # TODO(b/428238610): Start iterating over the result of `to_pandas_batches()` # before we get here so that the count might already be cached. - self.row_count = len(dataframe) + # TODO(b/452747934): Allow row_count to be None and check to see if + # there are multiple pages and show "page 1 of many" in this case. + self.row_count = self._batches.total_rows or 0 # get the initial page self._set_table_html() @@ -180,7 +183,7 @@ def _cached_data(self) -> pd.DataFrame: def _reset_batches_for_new_page_size(self): """Reset the batch iterator when page size changes.""" - self._batches = self._dataframe.to_pandas_batches(page_size=self.page_size) + self._batches = self._dataframe._to_pandas_batches(page_size=self.page_size) self._cached_batches = [] self._batch_iter = None self._all_data_loaded = False diff --git a/tests/benchmark/read_gbq_colab/aggregate_output.py b/tests/benchmark/read_gbq_colab/aggregate_output.py index cd33ed2640..e5620d8e16 100644 --- a/tests/benchmark/read_gbq_colab/aggregate_output.py +++ b/tests/benchmark/read_gbq_colab/aggregate_output.py @@ -26,8 +26,9 @@ def aggregate_output(*, project_id, dataset_id, table_id): df = bpd._read_gbq_colab(f"SELECT * FROM `{project_id}`.{dataset_id}.{table_id}") # Simulate getting the first page, since we'll always do that first in the UI. - df.shape - next(iter(df.to_pandas_batches(page_size=PAGE_SIZE))) + batches = df._to_pandas_batches(page_size=PAGE_SIZE) + assert (tr := batches.total_rows) is not None and tr >= 0 + next(iter(batches)) # To simulate very small rows that can only fit a boolean, # some tables don't have an integer column. If an integer column is available, @@ -43,8 +44,9 @@ def aggregate_output(*, project_id, dataset_id, table_id): .sum(numeric_only=True) ) - df_aggregated.shape - next(iter(df_aggregated.to_pandas_batches(page_size=PAGE_SIZE))) + batches = df_aggregated._to_pandas_batches(page_size=PAGE_SIZE) + assert (tr := batches.total_rows) is not None and tr >= 0 + next(iter(batches)) if __name__ == "__main__": diff --git a/tests/benchmark/read_gbq_colab/filter_output.py b/tests/benchmark/read_gbq_colab/filter_output.py index b3c9181770..dc88d31366 100644 --- a/tests/benchmark/read_gbq_colab/filter_output.py +++ b/tests/benchmark/read_gbq_colab/filter_output.py @@ -31,17 +31,19 @@ def filter_output( df = bpd._read_gbq_colab(f"SELECT * FROM `{project_id}`.{dataset_id}.{table_id}") # Simulate getting the first page, since we'll always do that first in the UI. - df.shape - next(iter(df.to_pandas_batches(page_size=PAGE_SIZE))) + batches = df._to_pandas_batches(page_size=PAGE_SIZE) + assert (tr := batches.total_rows) is not None and tr >= 0 + next(iter(batches)) # Simulate the user filtering by a column and visualizing those results df_filtered = df[df["col_bool_0"]] - rows, _ = df_filtered.shape + batches = df_filtered._to_pandas_batches(page_size=PAGE_SIZE) + assert (tr := batches.total_rows) is not None and tr >= 0 + first_page = next(iter(batches)) # It's possible we don't have any pages at all, since we filtered out all # matching rows. - first_page = next(iter(df_filtered.to_pandas_batches(page_size=PAGE_SIZE))) - assert len(first_page.index) <= rows + assert len(first_page.index) <= tr if __name__ == "__main__": diff --git a/tests/benchmark/read_gbq_colab/first_page.py b/tests/benchmark/read_gbq_colab/first_page.py index 7f8cdb0d51..33e2a24bd7 100644 --- a/tests/benchmark/read_gbq_colab/first_page.py +++ b/tests/benchmark/read_gbq_colab/first_page.py @@ -28,8 +28,9 @@ def first_page(*, project_id, dataset_id, table_id): ) # Get number of rows (to calculate number of pages) and the first page. - df.shape - next(iter(df.to_pandas_batches(page_size=PAGE_SIZE))) + batches = df._to_pandas_batches(page_size=PAGE_SIZE) + assert (tr := batches.total_rows) is not None and tr >= 0 + next(iter(batches)) if __name__ == "__main__": diff --git a/tests/benchmark/read_gbq_colab/last_page.py b/tests/benchmark/read_gbq_colab/last_page.py index 7786e2f8bd..2e485a070a 100644 --- a/tests/benchmark/read_gbq_colab/last_page.py +++ b/tests/benchmark/read_gbq_colab/last_page.py @@ -28,8 +28,9 @@ def last_page(*, project_id, dataset_id, table_id): ) # Get number of rows (to calculate number of pages) and then all pages. - df.shape - for _ in df.to_pandas_batches(page_size=PAGE_SIZE): + batches = df._to_pandas_batches(page_size=PAGE_SIZE) + assert (tr := batches.total_rows) is not None and tr >= 0 + for _ in batches: pass diff --git a/tests/benchmark/read_gbq_colab/sort_output.py b/tests/benchmark/read_gbq_colab/sort_output.py index 7933c4472e..3044e0c2a3 100644 --- a/tests/benchmark/read_gbq_colab/sort_output.py +++ b/tests/benchmark/read_gbq_colab/sort_output.py @@ -28,8 +28,9 @@ def sort_output(*, project_id, dataset_id, table_id): ) # Simulate getting the first page, since we'll always do that first in the UI. - df.shape - next(iter(df.to_pandas_batches(page_size=PAGE_SIZE))) + batches = df._to_pandas_batches(page_size=PAGE_SIZE) + assert (tr := batches.total_rows) is not None and tr >= 0 + next(iter(batches)) # Simulate the user sorting by a column and visualizing those results sort_column = "col_int64_1" @@ -37,8 +38,9 @@ def sort_output(*, project_id, dataset_id, table_id): sort_column = "col_bool_0" df_sorted = df.sort_values(sort_column) - df_sorted.shape - next(iter(df_sorted.to_pandas_batches(page_size=PAGE_SIZE))) + batches = df_sorted._to_pandas_batches(page_size=PAGE_SIZE) + assert (tr := batches.total_rows) is not None and tr >= 0 + next(iter(batches)) if __name__ == "__main__": From e95dc2c1321094d9ae26d8d9b06c6c2021be6226 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Fri, 17 Oct 2025 10:54:09 -0700 Subject: [PATCH 163/313] refactor: support ops.case_when_op and fix invert_op for the sqlglot compiler (#2174) --- .../sqlglot/expressions/generic_ops.py | 58 +++++++++++++++---- .../sqlglot/expressions/numeric_ops.py | 5 -- .../system/small/engines/test_generic_ops.py | 10 ++-- .../test_case_when_op/out.sql | 29 ++++++++++ .../test_generic_ops/test_invert/out.sql | 19 ++++++ .../test_numeric_ops/test_invert/out.sql | 13 ----- .../sqlglot/expressions/test_generic_ops.py | 53 +++++++++++++++++ .../sqlglot/expressions/test_numeric_ops.py | 8 --- 8 files changed, 152 insertions(+), 43 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_case_when_op/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_invert/out.sql delete mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_invert/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/generic_ops.py b/bigframes/core/compile/sqlglot/expressions/generic_ops.py index 1ed49b89eb..60366b02c9 100644 --- a/bigframes/core/compile/sqlglot/expressions/generic_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/generic_ops.py @@ -23,6 +23,7 @@ import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_nary_op = scalar_compiler.scalar_op_compiler.register_nary_op register_ternary_op = scalar_compiler.scalar_op_compiler.register_ternary_op @@ -67,23 +68,18 @@ def _(expr: TypedExpr, op: ops.AsTypeOp) -> sge.Expression: return _cast(sg_expr, sg_to_type, op.safe) -@register_ternary_op(ops.clip_op) -def _( - original: TypedExpr, - lower: TypedExpr, - upper: TypedExpr, -) -> sge.Expression: - return sge.Greatest( - this=sge.Least(this=original.expr, expressions=[upper.expr]), - expressions=[lower.expr], - ) - - @register_unary_op(ops.hash_op) def _(expr: TypedExpr) -> sge.Expression: return sge.func("FARM_FINGERPRINT", expr.expr) +@register_unary_op(ops.invert_op) +def _(expr: TypedExpr) -> sge.Expression: + if expr.dtype == dtypes.BOOL_DTYPE: + return sge.Not(this=expr.expr) + return sge.BitwiseNot(this=expr.expr) + + @register_unary_op(ops.isnull_op) def _(expr: TypedExpr) -> sge.Expression: return sge.Is(this=expr.expr, expression=sge.Null()) @@ -114,6 +110,44 @@ def _( return sge.If(this=condition.expr, true=original.expr, false=replacement.expr) +@register_ternary_op(ops.clip_op) +def _( + original: TypedExpr, + lower: TypedExpr, + upper: TypedExpr, +) -> sge.Expression: + return sge.Greatest( + this=sge.Least(this=original.expr, expressions=[upper.expr]), + expressions=[lower.expr], + ) + + +@register_nary_op(ops.case_when_op) +def _(*cases_and_outputs: TypedExpr) -> sge.Expression: + # Need to upcast BOOL to INT if any output is numeric + result_values = cases_and_outputs[1::2] + do_upcast_bool = any( + dtypes.is_numeric(t.dtype, include_bool=False) for t in result_values + ) + if do_upcast_bool: + result_values = tuple( + TypedExpr( + sge.Cast(this=val.expr, to="INT64"), + dtypes.INT_DTYPE, + ) + if val.dtype == dtypes.BOOL_DTYPE + else val + for val in result_values + ) + + return sge.Case( + ifs=[ + sge.If(this=predicate.expr, true=output.expr) + for predicate, output in zip(cases_and_outputs[::2], result_values) + ], + ) + + # Helper functions def _cast_to_json(expr: TypedExpr, op: ops.AsTypeOp) -> sge.Expression: from_type = expr.dtype diff --git a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py index ac40e4a667..3bbe2623ea 100644 --- a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py @@ -148,11 +148,6 @@ def _(expr: TypedExpr) -> sge.Expression: return sge.Floor(this=expr.expr) -@register_unary_op(ops.invert_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.BitwiseNot(this=expr.expr) - - @register_unary_op(ops.ln_op) def _(expr: TypedExpr) -> sge.Expression: return sge.Case( diff --git a/tests/system/small/engines/test_generic_ops.py b/tests/system/small/engines/test_generic_ops.py index ae7eafd347..f209b95496 100644 --- a/tests/system/small/engines/test_generic_ops.py +++ b/tests/system/small/engines/test_generic_ops.py @@ -357,7 +357,7 @@ def test_engines_fillna_op(scalars_array_value: array_value.ArrayValue, engine): assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_casewhen_op_single_case( scalars_array_value: array_value.ArrayValue, engine ): @@ -373,7 +373,7 @@ def test_engines_casewhen_op_single_case( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_casewhen_op_double_case( scalars_array_value: array_value.ArrayValue, engine ): @@ -391,7 +391,7 @@ def test_engines_casewhen_op_double_case( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_isnull_op(scalars_array_value: array_value.ArrayValue, engine): arr, _ = scalars_array_value.compute_values( [ops.isnull_op.as_expr(expression.deref("string_col"))] @@ -400,7 +400,7 @@ def test_engines_isnull_op(scalars_array_value: array_value.ArrayValue, engine): assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_notnull_op(scalars_array_value: array_value.ArrayValue, engine): arr, _ = scalars_array_value.compute_values( [ops.notnull_op.as_expr(expression.deref("string_col"))] @@ -409,7 +409,7 @@ def test_engines_notnull_op(scalars_array_value: array_value.ArrayValue, engine) assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_invert_op(scalars_array_value: array_value.ArrayValue, engine): arr, _ = scalars_array_value.compute_values( [ diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_case_when_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_case_when_op/out.sql new file mode 100644 index 0000000000..08db34a632 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_case_when_op/out.sql @@ -0,0 +1,29 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `int64_too` AS `bfcol_2`, + `float64_col` AS `bfcol_3` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN `bfcol_0` THEN `bfcol_1` END AS `bfcol_4`, + CASE WHEN `bfcol_0` THEN `bfcol_1` WHEN `bfcol_0` THEN `bfcol_2` END AS `bfcol_5`, + CASE WHEN `bfcol_0` THEN `bfcol_0` WHEN `bfcol_0` THEN `bfcol_0` END AS `bfcol_6`, + CASE + WHEN `bfcol_0` + THEN `bfcol_1` + WHEN `bfcol_0` + THEN CAST(`bfcol_0` AS INT64) + WHEN `bfcol_0` + THEN `bfcol_3` + END AS `bfcol_7` + FROM `bfcte_0` +) +SELECT + `bfcol_4` AS `single_case`, + `bfcol_5` AS `double_case`, + `bfcol_6` AS `bool_types_case`, + `bfcol_7` AS `mixed_types_cast` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_invert/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_invert/out.sql new file mode 100644 index 0000000000..b5a5b92b52 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_invert/out.sql @@ -0,0 +1,19 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `bytes_col` AS `bfcol_1`, + `int64_col` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ~`bfcol_2` AS `bfcol_6`, + ~`bfcol_1` AS `bfcol_7`, + NOT `bfcol_0` AS `bfcol_8` + FROM `bfcte_0` +) +SELECT + `bfcol_6` AS `int64_col`, + `bfcol_7` AS `bytes_col`, + `bfcol_8` AS `bool_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_invert/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_invert/out.sql deleted file mode 100644 index 28f2aa6e06..0000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_invert/out.sql +++ /dev/null @@ -1,13 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `int64_col` AS `bfcol_0` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_1` AS ( - SELECT - *, - ~`bfcol_0` AS `bfcol_1` - FROM `bfcte_0` -) -SELECT - `bfcol_1` AS `int64_col` -FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py index 261a630d3a..b7abc63213 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py @@ -168,6 +168,47 @@ def test_astype_json_invalid( ) +def test_case_when_op(scalar_types_df: bpd.DataFrame, snapshot): + ops_map = { + "single_case": ops.case_when_op.as_expr( + "bool_col", + "int64_col", + ), + "double_case": ops.case_when_op.as_expr( + "bool_col", + "int64_col", + "bool_col", + "int64_too", + ), + "bool_types_case": ops.case_when_op.as_expr( + "bool_col", + "bool_col", + "bool_col", + "bool_col", + ), + "mixed_types_cast": ops.case_when_op.as_expr( + "bool_col", + "int64_col", + "bool_col", + "bool_col", + "bool_col", + "float64_col", + ), + } + + array_value = scalar_types_df._block.expr + result, col_ids = array_value.compute_values(list(ops_map.values())) + + # Rename columns for deterministic golden SQL results. + assert len(col_ids) == len(ops_map.keys()) + result = result.rename_columns( + {col_id: key for col_id, key in zip(col_ids, ops_map.keys())} + ).select_columns(list(ops_map.keys())) + + sql = result.session._executor.to_sql(result, enable_cache=False) + snapshot.assert_match(sql, "out.sql") + + def test_clip(scalar_types_df: bpd.DataFrame, snapshot): op_expr = ops.clip_op.as_expr("rowindex", "int64_col", "int64_too") @@ -192,6 +233,18 @@ def test_hash(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_invert(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bytes_col", "bool_col"]] + ops_map = { + "int64_col": ops.invert_op.as_expr("int64_col"), + "bytes_col": ops.invert_op.as_expr("bytes_col"), + "bool_col": ops.invert_op.as_expr("bool_col"), + } + sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + + snapshot.assert_match(sql, "out.sql") + + def test_isnull(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] diff --git a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py index 231d9d5bf0..59726da73b 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py @@ -126,14 +126,6 @@ def test_floor(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") -def test_invert(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "int64_col" - bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.invert_op.as_expr(col_name)], [col_name]) - - snapshot.assert_match(sql, "out.sql") - - def test_ln(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] From ebf95e3ef77822650f2e190df7b868011174d412 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 17 Oct 2025 13:14:39 -0700 Subject: [PATCH 164/313] feat: Add df.sort_index(axis=1) (#2173) --- bigframes/dataframe.py | 32 ++++++++++++------- tests/system/small/test_dataframe.py | 12 +++++-- tests/unit/test_dataframe_polars.py | 12 +++++-- .../bigframes_vendored/pandas/core/frame.py | 4 +++ 4 files changed, 42 insertions(+), 18 deletions(-) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index be41ec9e99..8504a5bb95 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -2564,25 +2564,33 @@ def sort_index( ) -> None: ... - @validations.requires_index def sort_index( self, *, + axis: Union[int, str] = 0, ascending: bool = True, inplace: bool = False, na_position: Literal["first", "last"] = "last", ) -> Optional[DataFrame]: - if na_position not in ["first", "last"]: - raise ValueError("Param na_position must be one of 'first' or 'last'") - na_last = na_position == "last" - index_columns = self._block.index_columns - ordering = [ - order.ascending_over(column, na_last) - if ascending - else order.descending_over(column, na_last) - for column in index_columns - ] - block = self._block.order_by(ordering) + if utils.get_axis_number(axis) == 0: + if na_position not in ["first", "last"]: + raise ValueError("Param na_position must be one of 'first' or 'last'") + na_last = na_position == "last" + index_columns = self._block.index_columns + ordering = [ + order.ascending_over(column, na_last) + if ascending + else order.descending_over(column, na_last) + for column in index_columns + ] + block = self._block.order_by(ordering) + else: # axis=1 + _, indexer = self.columns.sort_values( + return_indexer=True, ascending=ascending, na_position=na_position # type: ignore + ) + block = self._block.select_columns( + [self._block.value_columns[i] for i in indexer] + ) if inplace: self._set_block(block) return None diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 1e6151b7f4..34bb5a4fb3 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -2406,13 +2406,19 @@ def test_set_index_key_error(scalars_dfs): ("na_position",), (("first",), ("last",)), ) -def test_sort_index(scalars_dfs, ascending, na_position): +@pytest.mark.parametrize( + ("axis",), + ((0,), ("columns",)), +) +def test_sort_index(scalars_dfs, ascending, na_position, axis): index_column = "int64_col" scalars_df, scalars_pandas_df = scalars_dfs df = scalars_df.set_index(index_column) - bf_result = df.sort_index(ascending=ascending, na_position=na_position).to_pandas() + bf_result = df.sort_index( + ascending=ascending, na_position=na_position, axis=axis + ).to_pandas() pd_result = scalars_pandas_df.set_index(index_column).sort_index( - ascending=ascending, na_position=na_position + ascending=ascending, na_position=na_position, axis=axis ) pandas.testing.assert_frame_equal(bf_result, pd_result) diff --git a/tests/unit/test_dataframe_polars.py b/tests/unit/test_dataframe_polars.py index a6f5c3d1ef..b83380d789 100644 --- a/tests/unit/test_dataframe_polars.py +++ b/tests/unit/test_dataframe_polars.py @@ -1757,13 +1757,19 @@ def test_set_index_key_error(scalars_dfs): ("na_position",), (("first",), ("last",)), ) -def test_sort_index(scalars_dfs, ascending, na_position): +@pytest.mark.parametrize( + ("axis",), + ((0,), ("columns",)), +) +def test_sort_index(scalars_dfs, ascending, na_position, axis): index_column = "int64_col" scalars_df, scalars_pandas_df = scalars_dfs df = scalars_df.set_index(index_column) - bf_result = df.sort_index(ascending=ascending, na_position=na_position).to_pandas() + bf_result = df.sort_index( + ascending=ascending, na_position=na_position, axis=axis + ).to_pandas() pd_result = scalars_pandas_df.set_index(index_column).sort_index( - ascending=ascending, na_position=na_position + ascending=ascending, na_position=na_position, axis=axis ) pandas.testing.assert_frame_equal(bf_result, pd_result) diff --git a/third_party/bigframes_vendored/pandas/core/frame.py b/third_party/bigframes_vendored/pandas/core/frame.py index 8a2570d4c6..b434b51fb3 100644 --- a/third_party/bigframes_vendored/pandas/core/frame.py +++ b/third_party/bigframes_vendored/pandas/core/frame.py @@ -2316,6 +2316,7 @@ def sort_values( def sort_index( self, *, + axis: str | int = 0, ascending: bool = True, inplace: bool = False, na_position: Literal["first", "last"] = "last", @@ -2323,6 +2324,9 @@ def sort_index( """Sort object by labels (along an axis). Args: + axis ({0 or 'index', 1 or 'columns'}, default 0): + The axis along which to sort. The value 0 identifies the rows, + and 1 identifies the columns. ascending (bool, default True) Sort ascending vs. descending. inplace (bool, default False): From 1f7b2bc69212c7816ec1719fc7ad6b3ec074bd3a Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 09:49:23 -0500 Subject: [PATCH 165/313] chore(main): release 2.26.0 (#2167) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 26 +++++++++++++++++++++++ bigframes/version.py | 4 ++-- third_party/bigframes_vendored/version.py | 4 ++-- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25205f48d0..63bf7fd0ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ [1]: https://pypi.org/project/bigframes/#history +## [2.26.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.25.0...v2.26.0) (2025-10-17) + + +### ⚠ BREAKING CHANGES + +* turn Series.struct.dtypes into a property to match pandas (https://github.com/googleapis/python-bigquery-dataframes/pull/2169) + +### Features + +* Add df.sort_index(axis=1) ([#2173](https://github.com/googleapis/python-bigquery-dataframes/issues/2173)) ([ebf95e3](https://github.com/googleapis/python-bigquery-dataframes/commit/ebf95e3ef77822650f2e190df7b868011174d412)) +* Enhanced multimodal error handling with verbose mode for blob image functions ([#2024](https://github.com/googleapis/python-bigquery-dataframes/issues/2024)) ([f9e28fe](https://github.com/googleapis/python-bigquery-dataframes/commit/f9e28fe3f883cc4d486178fe241bc8b76473700f)) +* Implement cos, sin, and log operations for polars compiler ([#2170](https://github.com/googleapis/python-bigquery-dataframes/issues/2170)) ([5613e44](https://github.com/googleapis/python-bigquery-dataframes/commit/5613e4454f198691209ec28e58ce652104ac2de4)) +* Make `all` and `any` compatible with integer columns on Polars session ([#2154](https://github.com/googleapis/python-bigquery-dataframes/issues/2154)) ([6353d6e](https://github.com/googleapis/python-bigquery-dataframes/commit/6353d6ecad5139551ef68376c08f8749dd440014)) + + +### Bug Fixes + +* `blob.display()` shows <NA> for null rows ([#2158](https://github.com/googleapis/python-bigquery-dataframes/issues/2158)) ([ddb4df0](https://github.com/googleapis/python-bigquery-dataframes/commit/ddb4df0dd991bef051e2a365c5cacf502803014d)) +* Turn Series.struct.dtypes into a property to match pandas (https://github.com/googleapis/python-bigquery-dataframes/pull/2169) ([62f7e9f](https://github.com/googleapis/python-bigquery-dataframes/commit/62f7e9f38f26b6eb549219a4cbf2c9b9023c9c35)) + + +### Documentation + +* Clarify that only NULL values are handled by fillna/isna, not NaN ([#2176](https://github.com/googleapis/python-bigquery-dataframes/issues/2176)) ([8f27e73](https://github.com/googleapis/python-bigquery-dataframes/commit/8f27e737fc78a182238090025d09479fac90b326)) +* Remove import bigframes.pandas as bpd boilerplate from many samples ([#2147](https://github.com/googleapis/python-bigquery-dataframes/issues/2147)) ([1a01ab9](https://github.com/googleapis/python-bigquery-dataframes/commit/1a01ab97f103361f489f37b0af8c4b4d7806707c)) + ## [2.25.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.24.0...v2.25.0) (2025-10-13) diff --git a/bigframes/version.py b/bigframes/version.py index 0236e8236e..6fe84df0ab 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.25.0" +__version__ = "2.26.0" # {x-release-please-start-date} -__release_date__ = "2025-10-13" +__release_date__ = "2025-10-17" # {x-release-please-end} diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index 0236e8236e..6fe84df0ab 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.25.0" +__version__ = "2.26.0" # {x-release-please-start-date} -__release_date__ = "2025-10-13" +__release_date__ = "2025-10-17" # {x-release-please-end} From 2c503107e17c59232b14b0d7bc40c350bb087d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Tue, 21 Oct 2025 08:36:55 -0500 Subject: [PATCH 166/313] docs: Update AI operators deprecation notice (#2182) "deprecated" is a bit confusing in this context. Let's say they have moved because the APIs are supported in the new location. --- notebooks/experimental/ai_operators.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notebooks/experimental/ai_operators.ipynb b/notebooks/experimental/ai_operators.ipynb index 8aaa3f4b7c..9878929cd2 100644 --- a/notebooks/experimental/ai_operators.ipynb +++ b/notebooks/experimental/ai_operators.ipynb @@ -29,7 +29,7 @@ "id": "rWJnGj2ViouP" }, "source": [ - "All AI operators except for `ai.forecast` have been deprecated.\n", + "All AI operators except for `ai.forecast` have moved to the `bigframes.bigquery.ai` module.\n", "\n", "The tutorial notebook for AI functions is located at https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/generative_ai/ai_functions.ipynb\n", "\n", From ee2c40c6789535e259fb6a9774831d6913d16212 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Wed, 22 Oct 2025 08:41:54 -0700 Subject: [PATCH 167/313] feat: include local data bytes in the dry run report when available (#2185) * feat: include local data bytes in the dry run report when available * fix test --- bigframes/core/blocks.py | 2 +- bigframes/session/dry_runs.py | 38 ++++++++++++++++++++++++++++-- tests/system/small/test_session.py | 16 +++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index 166841dfbd..1900b7208a 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -967,7 +967,7 @@ def _compute_dry_run( } dry_run_stats = dry_runs.get_query_stats_with_dtypes( - query_job, column_dtypes, self.index.dtypes + query_job, column_dtypes, self.index.dtypes, self.expr.node ) return dry_run_stats, query_job diff --git a/bigframes/session/dry_runs.py b/bigframes/session/dry_runs.py index 51e8e72c9a..bd54bb65d7 100644 --- a/bigframes/session/dry_runs.py +++ b/bigframes/session/dry_runs.py @@ -20,6 +20,7 @@ import pandas from bigframes import dtypes +from bigframes.core import bigframe_node, nodes def get_table_stats(table: bigquery.Table) -> pandas.Series: @@ -86,13 +87,26 @@ def get_query_stats_with_dtypes( query_job: bigquery.QueryJob, column_dtypes: Dict[str, dtypes.Dtype], index_dtypes: Sequence[dtypes.Dtype], + expr_root: bigframe_node.BigFrameNode | None = None, ) -> pandas.Series: + """ + Returns important stats from the query job as a Pandas Series. The dtypes information is added too. + + Args: + expr_root (Optional): + The root of the expression tree that may contain local data, whose size is added to the + total bytes count if available. + + """ index = ["columnCount", "columnDtypes", "indexLevel", "indexDtypes"] values = [len(column_dtypes), column_dtypes, len(index_dtypes), index_dtypes] s = pandas.Series(values, index=index) - return pandas.concat([s, get_query_stats(query_job)]) + result = pandas.concat([s, get_query_stats(query_job)]) + if expr_root is not None: + result["totalBytesProcessed"] += get_local_bytes(expr_root) + return result def get_query_stats( @@ -145,4 +159,24 @@ def get_query_stats( else None ) - return pandas.Series(values, index=index) + result = pandas.Series(values, index=index) + if result["totalBytesProcessed"] is None: + result["totalBytesProcessed"] = 0 + else: + result["totalBytesProcessed"] = int(result["totalBytesProcessed"]) + + return result + + +def get_local_bytes(root: bigframe_node.BigFrameNode) -> int: + def get_total_bytes( + root: bigframe_node.BigFrameNode, child_results: tuple[int, ...] + ) -> int: + child_bytes = sum(child_results) + + if isinstance(root, nodes.ReadLocalNode): + return child_bytes + root.local_data_source.data.get_total_buffer_size() + + return child_bytes + + return root.reduce_up(get_total_bytes) diff --git a/tests/system/small/test_session.py b/tests/system/small/test_session.py index 001e02c2fa..d3e646dc92 100644 --- a/tests/system/small/test_session.py +++ b/tests/system/small/test_session.py @@ -2173,6 +2173,22 @@ def test_read_gbq_query_dry_run(scalars_table_id, session): _assert_query_dry_run_stats_are_valid(result) +def test_block_dry_run_includes_local_data(session): + df1 = bigframes.dataframe.DataFrame({"col_1": [1, 2, 3]}, session=session) + df2 = bigframes.dataframe.DataFrame({"col_2": [1, 2, 3]}, session=session) + + result = df1.merge(df2, how="cross").to_pandas(dry_run=True) + + assert isinstance(result, pd.Series) + _assert_query_dry_run_stats_are_valid(result) + assert result["totalBytesProcessed"] > 0 + assert ( + df1.to_pandas(dry_run=True)["totalBytesProcessed"] + + df2.to_pandas(dry_run=True)["totalBytesProcessed"] + == result["totalBytesProcessed"] + ) + + def _assert_query_dry_run_stats_are_valid(result: pd.Series): expected_index = pd.Index( [ From cd87ce0d504747f44d1b5a55f869a2e0fca6df17 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Wed, 22 Oct 2025 08:45:30 -0700 Subject: [PATCH 168/313] feat: Add str accessor to index (#2179) * feat: Add str accessor to index * make StringMethods generically typed * fixup str extract method * fix type annotation --- bigframes/core/indexes/base.py | 37 +++++-- bigframes/core/indexes/multi.py | 2 +- bigframes/operations/strings.py | 99 +++++++++---------- bigframes/series.py | 5 +- scripts/publish_api_coverage.py | 1 + tests/system/small/operations/test_strings.py | 2 - tests/system/small/test_index.py | 36 +++++++ .../pandas/core/indexes/base.py | 30 ++++++ 8 files changed, 149 insertions(+), 63 deletions(-) diff --git a/bigframes/core/indexes/base.py b/bigframes/core/indexes/base.py index 54d8228ff6..0e82b6dea7 100644 --- a/bigframes/core/indexes/base.py +++ b/bigframes/core/indexes/base.py @@ -43,6 +43,7 @@ if typing.TYPE_CHECKING: import bigframes.dataframe + import bigframes.operations.strings import bigframes.series @@ -254,6 +255,12 @@ def query_job(self) -> bigquery.QueryJob: self._query_job = query_job return self._query_job + @property + def str(self) -> bigframes.operations.strings.StringMethods: + import bigframes.operations.strings + + return bigframes.operations.strings.StringMethods(self) + def get_loc(self, key) -> typing.Union[int, slice, "bigframes.series.Series"]: """Get integer location, slice or boolean mask for requested label. @@ -317,7 +324,9 @@ def get_loc(self, key) -> typing.Union[int, slice, "bigframes.series.Series"]: result_series = bigframes.series.Series(mask_block) return result_series.astype("boolean") - def _get_monotonic_slice(self, filtered_block, offsets_id: str) -> slice: + def _get_monotonic_slice( + self, filtered_block, offsets_id: __builtins__.str + ) -> slice: """Helper method to get a slice for monotonic duplicates with an optimized query.""" # Combine min and max aggregations into a single query for efficiency min_max_aggs = [ @@ -343,7 +352,7 @@ def _get_monotonic_slice(self, filtered_block, offsets_id: str) -> slice: # Create slice (stop is exclusive) return slice(min_pos, max_pos + 1) - def __repr__(self) -> str: + def __repr__(self) -> __builtins__.str: # Protect against errors with uninitialized Series. See: # https://github.com/googleapis/python-bigquery-dataframes/issues/728 if not hasattr(self, "_block"): @@ -417,7 +426,7 @@ def sort_values( *, inplace: bool = False, ascending: bool = True, - na_position: str = "last", + na_position: __builtins__.str = "last", ) -> Index: if na_position not in ["first", "last"]: raise ValueError("Param na_position must be one of 'first' or 'last'") @@ -604,7 +613,7 @@ def dropna(self, how: typing.Literal["all", "any"] = "any") -> Index: result = block_ops.dropna(self._block, self._block.index_columns, how=how) return Index(result) - def drop_duplicates(self, *, keep: str = "first") -> Index: + def drop_duplicates(self, *, keep: __builtins__.str = "first") -> Index: if keep is not False: validations.enforce_ordered(self, "drop_duplicates") block = block_ops.drop_duplicates(self._block, self._block.index_columns, keep) @@ -656,6 +665,9 @@ def __contains__(self, key) -> bool: block, match_col = self._block.project_expr(match_expr_final) return cast(bool, block.get_stat(match_col, agg_ops.AnyOp())) + def _apply_unary_op(self, op: ops.UnaryOp) -> Index: + return self._apply_unary_expr(op.as_expr(ex.free_var("input"))) + def _apply_unary_expr( self, op: ex.Expression, @@ -762,9 +774,15 @@ def item(self): return self.to_series().peek(2).item() def __eq__(self, other) -> Index: # type: ignore - return self._apply_binop(other, ops.eq_op) + return self._apply_binary_op(other, ops.eq_op) - def _apply_binop(self, other, op: ops.BinaryOp) -> Index: + def _apply_binary_op( + self, + other, + op: ops.BinaryOp, + alignment: typing.Literal["outer", "left"] = "outer", + ) -> Index: + # Note: alignment arg is for compatibility with accessors, is ignored as irrelevant for implicit joins. # TODO: Handle local objects, or objects not implicitly alignable? Gets ambiguous with partial ordering though if isinstance(other, (bigframes.series.Series, Index)): other = Index(other) @@ -785,12 +803,13 @@ def _apply_binop(self, other, op: ops.BinaryOp) -> Index: for lid, rid in zip(lexpr.column_ids, rexpr.column_ids) ] ) + labels = self.names if self.names == other.names else [None] * len(res_ids) return Index( blocks.Block( expr.select_columns(res_ids), index_columns=res_ids, column_labels=[], - index_labels=[None] * len(res_ids), + index_labels=labels, ) ) elif ( @@ -799,7 +818,7 @@ def _apply_binop(self, other, op: ops.BinaryOp) -> Index: block, id = self._block.project_expr( op.as_expr(self._block.index_columns[0], ex.const(other)) ) - return Index(block.select_column(id)) + return Index(block.set_index([id], index_labels=self.names)) elif isinstance(other, tuple) and len(other) == self.nlevels: block = self._block.project_exprs( [ @@ -809,7 +828,7 @@ def _apply_binop(self, other, op: ops.BinaryOp) -> Index: labels=[None] * self.nlevels, drop=True, ) - return Index(block.set_index(block.value_columns)) + return Index(block.set_index(block.value_columns, index_labels=self.names)) else: return NotImplemented diff --git a/bigframes/core/indexes/multi.py b/bigframes/core/indexes/multi.py index a611442b88..cfabd9e70d 100644 --- a/bigframes/core/indexes/multi.py +++ b/bigframes/core/indexes/multi.py @@ -60,7 +60,7 @@ def __eq__(self, other) -> Index: # type: ignore import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops - eq_result = self._apply_binop(other, ops.eq_op)._block.expr + eq_result = self._apply_binary_op(other, ops.eq_op)._block.expr as_array = ops.ToArrayOp().as_expr( *( diff --git a/bigframes/operations/strings.py b/bigframes/operations/strings.py index 3288be591c..d84a66789d 100644 --- a/bigframes/operations/strings.py +++ b/bigframes/operations/strings.py @@ -15,12 +15,13 @@ from __future__ import annotations import re -from typing import Literal, Optional, Union +from typing import Generic, Hashable, Literal, Optional, TypeVar, Union import bigframes_vendored.constants as constants import bigframes_vendored.pandas.core.strings.accessor as vendorstr from bigframes.core import log_adapter +import bigframes.core.indexes.base as indices import bigframes.dataframe as df import bigframes.operations as ops from bigframes.operations._op_converters import convert_index, convert_slice @@ -34,15 +35,17 @@ re.DOTALL: "s", } +T = TypeVar("T", series.Series, indices.Index) + @log_adapter.class_logger -class StringMethods(vendorstr.StringMethods): +class StringMethods(vendorstr.StringMethods, Generic[T]): __doc__ = vendorstr.StringMethods.__doc__ - def __init__(self, data: series.Series): - self._data = data + def __init__(self, data: T): + self._data: T = data - def __getitem__(self, key: Union[int, slice]) -> series.Series: + def __getitem__(self, key: Union[int, slice]) -> T: if isinstance(key, int): return self._data._apply_unary_op(convert_index(key)) elif isinstance(key, slice): @@ -55,18 +58,18 @@ def find( sub: str, start: Optional[int] = None, end: Optional[int] = None, - ) -> series.Series: + ) -> T: return self._data._apply_unary_op( ops.StrFindOp(substr=sub, start=start, end=end) ) - def len(self) -> series.Series: + def len(self) -> T: return self._data._apply_unary_op(ops.len_op) - def lower(self) -> series.Series: + def lower(self) -> T: return self._data._apply_unary_op(ops.lower_op) - def reverse(self) -> series.Series: + def reverse(self) -> T: """Reverse strings in the Series. **Examples:** @@ -91,103 +94,103 @@ def slice( self, start: Optional[int] = None, stop: Optional[int] = None, - ) -> series.Series: + ) -> T: return self._data._apply_unary_op(ops.StrSliceOp(start=start, end=stop)) - def strip(self, to_strip: Optional[str] = None) -> series.Series: + def strip(self, to_strip: Optional[str] = None) -> T: return self._data._apply_unary_op( ops.StrStripOp(to_strip=" \n\t" if to_strip is None else to_strip) ) - def upper(self) -> series.Series: + def upper(self) -> T: return self._data._apply_unary_op(ops.upper_op) - def isnumeric(self) -> series.Series: + def isnumeric(self) -> T: return self._data._apply_unary_op(ops.isnumeric_op) def isalpha( self, - ) -> series.Series: + ) -> T: return self._data._apply_unary_op(ops.isalpha_op) def isdigit( self, - ) -> series.Series: + ) -> T: return self._data._apply_unary_op(ops.isdigit_op) def isdecimal( self, - ) -> series.Series: + ) -> T: return self._data._apply_unary_op(ops.isdecimal_op) def isalnum( self, - ) -> series.Series: + ) -> T: return self._data._apply_unary_op(ops.isalnum_op) def isspace( self, - ) -> series.Series: + ) -> T: return self._data._apply_unary_op(ops.isspace_op) def islower( self, - ) -> series.Series: + ) -> T: return self._data._apply_unary_op(ops.islower_op) def isupper( self, - ) -> series.Series: + ) -> T: return self._data._apply_unary_op(ops.isupper_op) - def rstrip(self, to_strip: Optional[str] = None) -> series.Series: + def rstrip(self, to_strip: Optional[str] = None) -> T: return self._data._apply_unary_op( ops.StrRstripOp(to_strip=" \n\t" if to_strip is None else to_strip) ) - def lstrip(self, to_strip: Optional[str] = None) -> series.Series: + def lstrip(self, to_strip: Optional[str] = None) -> T: return self._data._apply_unary_op( ops.StrLstripOp(to_strip=" \n\t" if to_strip is None else to_strip) ) - def repeat(self, repeats: int) -> series.Series: + def repeat(self, repeats: int) -> T: return self._data._apply_unary_op(ops.StrRepeatOp(repeats=repeats)) - def capitalize(self) -> series.Series: + def capitalize(self) -> T: return self._data._apply_unary_op(ops.capitalize_op) - def match(self, pat, case=True, flags=0) -> series.Series: + def match(self, pat, case=True, flags=0) -> T: # \A anchors start of entire string rather than start of any line in multiline mode adj_pat = rf"\A{pat}" return self.contains(pat=adj_pat, case=case, flags=flags) - def fullmatch(self, pat, case=True, flags=0) -> series.Series: + def fullmatch(self, pat, case=True, flags=0) -> T: # \A anchors start of entire string rather than start of any line in multiline mode # \z likewise anchors to the end of the entire multiline string adj_pat = rf"\A{pat}\z" return self.contains(pat=adj_pat, case=case, flags=flags) - def get(self, i: int) -> series.Series: + def get(self, i: int) -> T: return self._data._apply_unary_op(ops.StrGetOp(i=i)) - def pad(self, width, side="left", fillchar=" ") -> series.Series: + def pad(self, width, side="left", fillchar=" ") -> T: return self._data._apply_unary_op( ops.StrPadOp(length=width, fillchar=fillchar, side=side) ) - def ljust(self, width, fillchar=" ") -> series.Series: + def ljust(self, width, fillchar=" ") -> T: return self._data._apply_unary_op( ops.StrPadOp(length=width, fillchar=fillchar, side="right") ) - def rjust(self, width, fillchar=" ") -> series.Series: + def rjust(self, width, fillchar=" ") -> T: return self._data._apply_unary_op( ops.StrPadOp(length=width, fillchar=fillchar, side="left") ) def contains( self, pat, case: bool = True, flags: int = 0, *, regex: bool = True - ) -> series.Series: + ) -> T: if not case: return self.contains(pat=pat, flags=flags | re.IGNORECASE, regex=True) if regex: @@ -206,23 +209,19 @@ def extract(self, pat: str, flags: int = 0) -> df.DataFrame: if compiled.groups == 0: raise ValueError("No capture groups in 'pat'") - results: list[str] = [] - block = self._data._block + results: dict[Hashable, series.Series] = {} for i in range(compiled.groups): labels = [ label for label, groupn in compiled.groupindex.items() if i + 1 == groupn ] - label = labels[0] if labels else str(i) - block, id = block.apply_unary_op( - self._data._value_column, + label = labels[0] if labels else i + result = self._data._apply_unary_op( ops.StrExtractOp(pat=pat, n=i + 1), - result_label=label, ) - results.append(id) - block = block.select_columns(results) - return df.DataFrame(block) + results[label] = series.Series(result) + return df.DataFrame(results) def replace( self, @@ -232,7 +231,7 @@ def replace( case: Optional[bool] = None, flags: int = 0, regex: bool = False, - ) -> series.Series: + ) -> T: if isinstance(pat, re.Pattern): assert isinstance(pat.pattern, str) pat_str = pat.pattern @@ -259,7 +258,7 @@ def replace( def startswith( self, pat: Union[str, tuple[str, ...]], - ) -> series.Series: + ) -> T: if not isinstance(pat, tuple): pat = (pat,) return self._data._apply_unary_op(ops.StartsWithOp(pat=pat)) @@ -267,7 +266,7 @@ def startswith( def endswith( self, pat: Union[str, tuple[str, ...]], - ) -> series.Series: + ) -> T: if not isinstance(pat, tuple): pat = (pat,) return self._data._apply_unary_op(ops.EndsWithOp(pat=pat)) @@ -276,7 +275,7 @@ def split( self, pat: str = " ", regex: Union[bool, None] = None, - ) -> series.Series: + ) -> T: if regex is True or (regex is None and len(pat) > 1): raise NotImplementedError( "Regular expressions aren't currently supported. Please set " @@ -284,28 +283,28 @@ def split( ) return self._data._apply_unary_op(ops.StringSplitOp(pat=pat)) - def zfill(self, width: int) -> series.Series: + def zfill(self, width: int) -> T: return self._data._apply_unary_op(ops.ZfillOp(width=width)) - def center(self, width: int, fillchar: str = " ") -> series.Series: + def center(self, width: int, fillchar: str = " ") -> T: return self._data._apply_unary_op( ops.StrPadOp(length=width, fillchar=fillchar, side="both") ) def cat( self, - others: Union[str, series.Series], + others: Union[str, indices.Index, series.Series], *, join: Literal["outer", "left"] = "left", - ) -> series.Series: + ) -> T: return self._data._apply_binary_op(others, ops.strconcat_op, alignment=join) - def join(self, sep: str) -> series.Series: + def join(self, sep: str) -> T: return self._data._apply_unary_op( ops.ArrayReduceOp(aggregation=agg_ops.StringAggOp(sep=sep)) ) - def to_blob(self, connection: Optional[str] = None) -> series.Series: + def to_blob(self, connection: Optional[str] = None) -> T: """Create a BigFrames Blob series from a series of URIs. .. note:: diff --git a/bigframes/series.py b/bigframes/series.py index f08fc6cc14..a1372e4d24 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -74,12 +74,13 @@ import bigframes.operations.datetimes as dt import bigframes.operations.lists as lists import bigframes.operations.plotting as plotting -import bigframes.operations.strings as strings import bigframes.operations.structs as structs import bigframes.session if typing.TYPE_CHECKING: import bigframes.geopandas.geoseries + import bigframes.operations.strings as strings + LevelType = typing.Union[str, int] LevelsType = typing.Union[LevelType, typing.Sequence[LevelType]] @@ -2649,6 +2650,8 @@ def _cached(self, *, force: bool = True, session_aware: bool = True) -> Series: # confusing type checker by overriding str @property def str(self) -> strings.StringMethods: + import bigframes.operations.strings as strings + return strings.StringMethods(self) @property diff --git a/scripts/publish_api_coverage.py b/scripts/publish_api_coverage.py index 8f305bcc0f..43f7df4dd6 100644 --- a/scripts/publish_api_coverage.py +++ b/scripts/publish_api_coverage.py @@ -30,6 +30,7 @@ import bigframes.core.groupby import bigframes.core.window import bigframes.operations.datetimes +import bigframes.operations.strings import bigframes.pandas as bpd REPO_ROOT = pathlib.Path(__file__).parent.parent diff --git a/tests/system/small/operations/test_strings.py b/tests/system/small/operations/test_strings.py index 6cd6309cbb..657fc231d1 100644 --- a/tests/system/small/operations/test_strings.py +++ b/tests/system/small/operations/test_strings.py @@ -78,8 +78,6 @@ def test_str_extract(scalars_dfs, pat): bf_result = bf_series.str.extract(pat).to_pandas() pd_result = scalars_pandas_df[col_name].str.extract(pat) - # Pandas produces int col labels, while bq df only supports str labels at present - pd_result = pd_result.set_axis(pd_result.columns.astype(str), axis=1) pd.testing.assert_frame_equal( pd_result, bf_result, diff --git a/tests/system/small/test_index.py b/tests/system/small/test_index.py index 3fe479af6e..0ec1fb6143 100644 --- a/tests/system/small/test_index.py +++ b/tests/system/small/test_index.py @@ -685,3 +685,39 @@ def test_index_eq_aligned_index(scalars_df_index, scalars_pandas_df_index): scalars_pandas_df_index.int64_col.abs() ) assert bf_result == pd.Index(pd_result) + + +def test_index_str_accessor_unary(scalars_df_index, scalars_pandas_df_index): + bf_index = scalars_df_index.set_index("string_col").index + pd_index = scalars_pandas_df_index.set_index("string_col").index + + bf_result = bf_index.str.pad(30, side="both", fillchar="~").to_pandas() + pd_result = pd_index.str.pad(30, side="both", fillchar="~") + + pd.testing.assert_index_equal(bf_result, pd_result) + + +def test_index_str_accessor_binary(scalars_df_index, scalars_pandas_df_index): + if pd.__version__.startswith("1."): + pytest.skip("doesn't work in pandas 1.x.") + bf_index = scalars_df_index.set_index("string_col").index + pd_index = scalars_pandas_df_index.set_index("string_col").index + + bf_result = bf_index.str.cat(bf_index.str[:4]).to_pandas() + pd_result = pd_index.str.cat(pd_index.str[:4]) + + pd.testing.assert_index_equal(bf_result, pd_result) + + +@pytest.mark.parametrize( + ("pat"), + [(r"(ell)(lo)"), (r"(?Ph..)"), (r"(?Pe.*o)([g-l]+)")], +) +def test_index_str_extract(scalars_df_index, scalars_pandas_df_index, pat): + bf_index = scalars_df_index.set_index("string_col").index + pd_index = scalars_pandas_df_index.set_index("string_col").index + + bf_result = bf_index.str.extract(pat).to_pandas() + pd_result = pd_index.str.extract(pat) + + pd.testing.assert_frame_equal(pd_result, bf_result, check_index_type=False) diff --git a/third_party/bigframes_vendored/pandas/core/indexes/base.py b/third_party/bigframes_vendored/pandas/core/indexes/base.py index e120dabc66..d21056a8cf 100644 --- a/third_party/bigframes_vendored/pandas/core/indexes/base.py +++ b/third_party/bigframes_vendored/pandas/core/indexes/base.py @@ -366,6 +366,36 @@ def T(self) -> Index: """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + @property + def str(self): + """ + Vectorized string functions for Series and Index. + + NAs stay NA unless handled otherwise by a particular method. Patterned + after Python’s string methods, with some inspiration from R’s stringr package. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> s = bpd.Series(["A_Str_Series"]) + >>> s + 0 A_Str_Series + dtype: string + + >>> s.str.lower() + 0 a_str_series + dtype: string + + >>> s.str.replace("_", "") + 0 AStrSeries + dtype: string + + Returns: + bigframes.operations.strings.StringMethods: + An accessor containing string methods. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def copy( self, name=None, From c331dfed59174962fbdc8ace175dd00fcc3d5d50 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Wed, 22 Oct 2025 09:40:35 -0700 Subject: [PATCH 169/313] feat: Add __abs__ to dataframe (#2186) --- bigframes/dataframe.py | 5 +++++ bigframes/series.py | 2 ++ tests/system/small/test_dataframe.py | 10 ++++++++++ 3 files changed, 17 insertions(+) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 8504a5bb95..c3735ca3c2 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -1306,6 +1306,11 @@ def __pos__(self) -> DataFrame: def __neg__(self) -> DataFrame: return self._apply_unary_op(ops.neg_op) + def __abs__(self) -> DataFrame: + return self._apply_unary_op(ops.abs_op) + + __abs__.__doc__ = abs.__doc__ + def align( self, other: typing.Union[DataFrame, bigframes.series.Series], diff --git a/bigframes/series.py b/bigframes/series.py index a1372e4d24..ad1f091803 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -1364,6 +1364,8 @@ def update(self, other: Union[Series, Sequence, Mapping]) -> None: def __abs__(self) -> Series: return self.abs() + __abs__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.abs) + def abs(self) -> Series: return self._apply_unary_op(ops.abs_op) diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 34bb5a4fb3..79f8efd00f 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -2460,6 +2460,16 @@ def test_df_neg(scalars_dfs): assert_pandas_df_equal(pd_result, bf_result) +def test_df__abs__(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + bf_result = ( + abs(scalars_df[["int64_col", "numeric_col", "float64_col"]]) + ).to_pandas() + pd_result = abs(scalars_pandas_df[["int64_col", "numeric_col", "float64_col"]]) + + assert_pandas_df_equal(pd_result, bf_result) + + def test_df_invert(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs columns = ["int64_col", "bool_col"] From 4191821b0976281a96c8965336ef51f061b0c481 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Wed, 22 Oct 2025 15:28:46 -0700 Subject: [PATCH 170/313] feat: Support len() on Groupby objects (#2183) --- bigframes/core/groupby/dataframe_group_by.py | 3 +++ bigframes/core/groupby/series_group_by.py | 9 ++++++--- tests/system/small/test_groupby.py | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/bigframes/core/groupby/dataframe_group_by.py b/bigframes/core/groupby/dataframe_group_by.py index 40e96f6f42..e86e1fdd75 100644 --- a/bigframes/core/groupby/dataframe_group_by.py +++ b/bigframes/core/groupby/dataframe_group_by.py @@ -177,6 +177,9 @@ def __iter__(self) -> Iterable[Tuple[blocks.Label, df.DataFrame]]: filtered_df = df.DataFrame(filtered_block) yield group_keys, filtered_df + def __len__(self) -> int: + return len(self.agg([])) + def size(self) -> typing.Union[df.DataFrame, series.Series]: agg_block, _ = self._block.aggregate_size( by_column_ids=self._by_col_ids, diff --git a/bigframes/core/groupby/series_group_by.py b/bigframes/core/groupby/series_group_by.py index 1f2632078d..b09b63dcfe 100644 --- a/bigframes/core/groupby/series_group_by.py +++ b/bigframes/core/groupby/series_group_by.py @@ -108,6 +108,9 @@ def __iter__(self) -> Iterable[Tuple[blocks.Label, series.Series]]: filtered_series.name = self._value_name yield group_keys, filtered_series + def __len__(self) -> int: + return len(self.agg([])) + def all(self) -> series.Series: return self._aggregate(agg_ops.all_op) @@ -275,9 +278,9 @@ def agg(self, func=None) -> typing.Union[df.DataFrame, series.Series]: if column_names: agg_block = agg_block.with_column_labels(column_names) - if len(aggregations) > 1: - return df.DataFrame(agg_block) - return series.Series(agg_block) + if len(aggregations) == 1: + return series.Series(agg_block) + return df.DataFrame(agg_block) aggregate = agg diff --git a/tests/system/small/test_groupby.py b/tests/system/small/test_groupby.py index 553a12a14a..44cbddad2f 100644 --- a/tests/system/small/test_groupby.py +++ b/tests/system/small/test_groupby.py @@ -61,6 +61,15 @@ def test_dataframe_groupby_head(scalars_df_index, scalars_pandas_df_index): pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) +def test_dataframe_groupby_len(scalars_df_index, scalars_pandas_df_index): + col_names = ["int64_too", "float64_col", "int64_col", "bool_col", "string_col"] + + bf_result = len(scalars_df_index[col_names].groupby("bool_col")) + pd_result = len(scalars_pandas_df_index[col_names].groupby("bool_col")) + + assert bf_result == pd_result + + def test_dataframe_groupby_median(scalars_df_index, scalars_pandas_df_index): col_names = ["int64_too", "float64_col", "int64_col", "bool_col", "string_col"] bf_result = ( @@ -668,6 +677,13 @@ def test_dataframe_groupby_last( # ============== +def test_series_groupby_len(scalars_df_index, scalars_pandas_df_index): + bf_result = len(scalars_df_index.groupby("bool_col")["int64_col"]) + pd_result = len(scalars_pandas_df_index.groupby("bool_col")["int64_col"]) + + assert bf_result == pd_result + + @pytest.mark.parametrize( ("agg"), [ From ccd7c0774a65d09e6cf31d2b62d0bc64bd7c4248 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 23 Oct 2025 08:41:54 -0700 Subject: [PATCH 171/313] feat: Add df.groupby().corr()/cov() support (#2190) --- bigframes/core/groupby/dataframe_group_by.py | 70 +++++++++++++++++++ tests/system/small/test_groupby.py | 20 ++++++ .../pandas/core/groupby/__init__.py | 62 ++++++++++++++++ 3 files changed, 152 insertions(+) diff --git a/bigframes/core/groupby/dataframe_group_by.py b/bigframes/core/groupby/dataframe_group_by.py index e86e1fdd75..3948d08a23 100644 --- a/bigframes/core/groupby/dataframe_group_by.py +++ b/bigframes/core/groupby/dataframe_group_by.py @@ -278,6 +278,76 @@ def var( self._raise_on_non_numeric("var") return self._aggregate_all(agg_ops.var_op, numeric_only=True) + def corr( + self, + *, + numeric_only: bool = False, + ) -> df.DataFrame: + if not numeric_only: + self._raise_on_non_numeric("corr") + if len(self._selected_cols) > 30: + raise ValueError( + f"Cannot calculate corr on >30 columns, dataframe has {len(self._selected_cols)} selected columns." + ) + + labels = self._block._get_labels_for_columns(self._selected_cols) + block = self._block + aggregations = [ + agg_expressions.BinaryAggregation( + agg_ops.CorrOp(), ex.deref(left_col), ex.deref(right_col) + ) + for left_col in self._selected_cols + for right_col in self._selected_cols + ] + # unique columns stops + uniq_orig_columns = utils.combine_indices(labels, pd.Index(range(len(labels)))) + result_labels = utils.cross_indices(uniq_orig_columns, uniq_orig_columns) + + block, _ = block.aggregate( + by_column_ids=self._by_col_ids, + aggregations=aggregations, + column_labels=result_labels, + ) + + block = block.stack(levels=labels.nlevels + 1) + # Drop the last level of each index, which was created to guarantee uniqueness + return df.DataFrame(block).droplevel(-1, axis=0).droplevel(-1, axis=1) + + def cov( + self, + *, + numeric_only: bool = False, + ) -> df.DataFrame: + if not numeric_only: + self._raise_on_non_numeric("cov") + if len(self._selected_cols) > 30: + raise ValueError( + f"Cannot calculate cov on >30 columns, dataframe has {len(self._selected_cols)} selected columns." + ) + + labels = self._block._get_labels_for_columns(self._selected_cols) + block = self._block + aggregations = [ + agg_expressions.BinaryAggregation( + agg_ops.CovOp(), ex.deref(left_col), ex.deref(right_col) + ) + for left_col in self._selected_cols + for right_col in self._selected_cols + ] + # unique columns stops + uniq_orig_columns = utils.combine_indices(labels, pd.Index(range(len(labels)))) + result_labels = utils.cross_indices(uniq_orig_columns, uniq_orig_columns) + + block, _ = block.aggregate( + by_column_ids=self._by_col_ids, + aggregations=aggregations, + column_labels=result_labels, + ) + + block = block.stack(levels=labels.nlevels + 1) + # Drop the last level of each index, which was created to guarantee uniqueness + return df.DataFrame(block).droplevel(-1, axis=0).droplevel(-1, axis=1) + def skew( self, *, diff --git a/tests/system/small/test_groupby.py b/tests/system/small/test_groupby.py index 44cbddad2f..4f187dcccc 100644 --- a/tests/system/small/test_groupby.py +++ b/tests/system/small/test_groupby.py @@ -170,6 +170,26 @@ def test_dataframe_groupby_aggregate( pd.testing.assert_frame_equal(pd_result, bf_result_computed, check_dtype=False) +def test_dataframe_groupby_corr(scalars_df_index, scalars_pandas_df_index): + col_names = ["int64_too", "float64_col", "int64_col", "bool_col"] + bf_result = scalars_df_index[col_names].groupby("bool_col").corr().to_pandas() + pd_result = scalars_pandas_df_index[col_names].groupby("bool_col").corr() + + pd.testing.assert_frame_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) + + +def test_dataframe_groupby_cov(scalars_df_index, scalars_pandas_df_index): + col_names = ["int64_too", "float64_col", "int64_col", "bool_col"] + bf_result = scalars_df_index[col_names].groupby("bool_col").cov().to_pandas() + pd_result = scalars_pandas_df_index[col_names].groupby("bool_col").cov() + + pd.testing.assert_frame_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) + + @pytest.mark.parametrize( ("ordered"), [ diff --git a/third_party/bigframes_vendored/pandas/core/groupby/__init__.py b/third_party/bigframes_vendored/pandas/core/groupby/__init__.py index ba6310507d..01852beb9c 100644 --- a/third_party/bigframes_vendored/pandas/core/groupby/__init__.py +++ b/third_party/bigframes_vendored/pandas/core/groupby/__init__.py @@ -1516,6 +1516,68 @@ def aggregate(self, func, **kwargs): """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def corr( + self, + *, + numeric_only: bool = False, + ): + """ + Compute pairwise correlation of columns, excluding NA/null values. + + **Examples:** + + + >>> df = bpd.DataFrame({'A': [1, 2, 3], + ... 'B': [400, 500, 600], + ... 'C': [0.8, 0.4, 0.9]}) + >>> df.corr(numeric_only=True) + A B C + A 1.0 1.0 0.188982 + B 1.0 1.0 0.188982 + C 0.188982 0.188982 1.0 + + [3 rows x 3 columns] + + Args: + numeric_only(bool, default False): + Include only float, int, boolean, decimal data. + + Returns: + bigframes.pandas.DataFrame: Correlation matrix. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def cov( + self, + *, + numeric_only: bool = False, + ): + """ + Compute pairwise covariance of columns, excluding NA/null values. + + **Examples:** + + + >>> df = bpd.DataFrame({'A': [1, 2, 3], + ... 'B': [400, 500, 600], + ... 'C': [0.8, 0.4, 0.9]}) + >>> df.cov(numeric_only=True) + A B C + A 1.0 100.0 0.05 + B 100.0 10000.0 5.0 + C 0.05 5.0 0.07 + + [3 rows x 3 columns] + + Args: + numeric_only(bool, default False): + Include only float, int, boolean, decimal data. + + Returns: + bigframes.pandas.DataFrame: The covariance matrix of the series of the DataFrame. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def nunique(self): """ Return DataFrame with counts of unique elements in each position. From e0b22577800fe09f60242cc1d36a31499b6857ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Thu, 23 Oct 2025 13:31:53 -0500 Subject: [PATCH 172/313] chore: Add setuptools to lint_setup_py session installation (#2192) --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index f9c20c999c..8334fcb0e1 100644 --- a/noxfile.py +++ b/noxfile.py @@ -193,7 +193,7 @@ def format(session): @nox.session(python=DEFAULT_PYTHON_VERSION) def lint_setup_py(session): """Verify that setup.py is valid (including RST check).""" - session.install("docutils", "pygments") + session.install("docutils", "pygments", "setuptools") session.run("python", "setup.py", "check", "--restructuredtext", "--strict") session.install("twine", "wheel") From 68723bc1f08013e43a8b11752f908bf8fd6d51f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Fri, 24 Oct 2025 09:32:14 -0500 Subject: [PATCH 173/313] feat: add support for `np.isnan` and `np.isfinite` ufuncs (#2188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Implement isnan and isfinite in bigframes This commit implements the `isnan` and `isfinite` numpy ufuncs in bigframes. The following changes were made: - Added `IsNanOp` and `IsFiniteOp` to `bigframes/operations/numeric_ops.py` - Mapped `np.isnan` and `np.isfinite` to the new ops in `bigframes/operations/numpy_op_maps.py` - Added compilation logic for the new ops to the ibis, polars, and sqlglot compilers - Added tests for the new ops in `tests/system/small/test_numpy.py` * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * feat: Implement isnan and isfinite in bigframes This commit implements the `isnan` and `isfinite` numpy ufuncs in bigframes. The following changes were made: - Added `IsNanOrNullOp` and `IsFiniteOp` to `bigframes/operations/numeric_ops.py` - Mapped `np.isnan` and `np.isfinite` to the new ops in `bigframes/operations/numpy_op_maps.py` - Added compilation logic for the new ops to the ibis, polars, and sqlglot compilers - Added tests for the new ops in `tests/system/small/test_numpy.py` - Renamed `IsNanOp` to `IsNanOrNullOp` to match numpy semantics and updated compilers accordingly. * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * Apply suggestions from code review * Update bigframes/core/compile/sqlglot/scalar_compiler.py * Update bigframes/core/compile/sqlglot/scalar_compiler.py * Update bigframes/core/compile/sqlglot/scalar_compiler.py * preserve NA * fix annotations --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Owl Bot --- .../ibis_compiler/scalar_op_compiler.py | 11 +++++++++++ .../compile/polars/operations/numeric_ops.py | 18 ++++++++++++++++++ .../compile/sqlglot/expressions/numeric_ops.py | 16 ++++++++++++++++ bigframes/operations/numeric_ops.py | 16 ++++++++++++++++ bigframes/operations/numpy_op_maps.py | 2 ++ tests/system/small/test_numpy.py | 2 ++ 6 files changed, 65 insertions(+) diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_compiler.py b/bigframes/core/compile/ibis_compiler/scalar_op_compiler.py index 1197f6b9da..8a027ca296 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_compiler.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_compiler.py @@ -26,6 +26,7 @@ from bigframes.core import agg_expressions, ordering import bigframes.core.compile.ibis_types import bigframes.core.expression as ex +from bigframes.operations import numeric_ops if TYPE_CHECKING: import bigframes.operations as ops @@ -267,3 +268,13 @@ def _convert_range_ordering_to_table_value( # Singleton compiler scalar_op_compiler = ExpressionCompiler() + + +@scalar_op_compiler.register_unary_op(numeric_ops.isnan_op) +def isnanornull(arg): + return arg.isnan() + + +@scalar_op_compiler.register_unary_op(numeric_ops.isfinite_op) +def isfinite(arg): + return arg.isinf().negate() & arg.isnan().negate() diff --git a/bigframes/core/compile/polars/operations/numeric_ops.py b/bigframes/core/compile/polars/operations/numeric_ops.py index 2572d862e3..8e44f15955 100644 --- a/bigframes/core/compile/polars/operations/numeric_ops.py +++ b/bigframes/core/compile/polars/operations/numeric_ops.py @@ -89,3 +89,21 @@ def sqrt_op_impl( import polars as pl return pl.when(input < 0).then(float("nan")).otherwise(input.sqrt()) + + +@polars_compiler.register_op(numeric_ops.IsNanOp) +def is_nan_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.IsNanOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.is_nan() + + +@polars_compiler.register_op(numeric_ops.IsFiniteOp) +def is_finite_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.IsFiniteOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.is_finite() diff --git a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py index 3bbe2623ea..64e27ebe79 100644 --- a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py @@ -22,6 +22,7 @@ import bigframes.core.compile.sqlglot.expressions.constants as constants from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler +from bigframes.operations import numeric_ops register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op @@ -408,6 +409,21 @@ def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: ) +@register_unary_op(numeric_ops.isnan_op) +def isnan(arg: TypedExpr) -> sge.Expression: + return sge.IsNan(this=arg.expr) + + +@register_unary_op(numeric_ops.isfinite_op) +def isfinite(arg: TypedExpr) -> sge.Expression: + return sge.Not( + this=sge.Or( + this=sge.IsInf(this=arg.expr), + right=sge.IsNan(this=arg.expr), + ), + ) + + def _coerce_bool_to_int(typed_expr: TypedExpr) -> sge.Expression: """Coerce boolean expression to integer.""" if typed_expr.dtype == dtypes.BOOL_DTYPE: diff --git a/bigframes/operations/numeric_ops.py b/bigframes/operations/numeric_ops.py index afdc924c0b..83e2078c88 100644 --- a/bigframes/operations/numeric_ops.py +++ b/bigframes/operations/numeric_ops.py @@ -348,3 +348,19 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT name="unsafe_pow_op", type_signature=op_typing.BINARY_REAL_NUMERIC ) unsafe_pow_op = UnsafePowOp() + +IsNanOp = base_ops.create_unary_op( + name="isnan", + type_signature=op_typing.FixedOutputType( + dtypes.is_numeric, dtypes.BOOL_DTYPE, "numeric" + ), +) +isnan_op = IsNanOp() + +IsFiniteOp = base_ops.create_unary_op( + name="isfinite", + type_signature=op_typing.FixedOutputType( + dtypes.is_numeric, dtypes.BOOL_DTYPE, "numeric" + ), +) +isfinite_op = IsFiniteOp() diff --git a/bigframes/operations/numpy_op_maps.py b/bigframes/operations/numpy_op_maps.py index 7f3decdfa0..791e2eb890 100644 --- a/bigframes/operations/numpy_op_maps.py +++ b/bigframes/operations/numpy_op_maps.py @@ -40,6 +40,8 @@ np.ceil: numeric_ops.ceil_op, np.log1p: numeric_ops.log1p_op, np.expm1: numeric_ops.expm1_op, + np.isnan: numeric_ops.isnan_op, + np.isfinite: numeric_ops.isfinite_op, } diff --git a/tests/system/small/test_numpy.py b/tests/system/small/test_numpy.py index 37a707b9d0..490f927114 100644 --- a/tests/system/small/test_numpy.py +++ b/tests/system/small/test_numpy.py @@ -37,6 +37,8 @@ ("log10",), ("sqrt",), ("abs",), + ("isnan",), + ("isfinite",), ], ) def test_series_ufuncs(floats_pd, floats_bf, opname): From 5ec3cc0298c7a6195d5bd12a08d996e7df57fc5f Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Fri, 24 Oct 2025 09:53:01 -0700 Subject: [PATCH 174/313] feat: support pa.json_(pa.string()) in struct/list if available (#2180) --- bigframes/dtypes.py | 10 +++++++++ tests/system/small/test_series.py | 35 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/bigframes/dtypes.py b/bigframes/dtypes.py index 18ecdede11..6c05b6f4a3 100644 --- a/bigframes/dtypes.py +++ b/bigframes/dtypes.py @@ -340,6 +340,12 @@ def is_struct_like(type_: ExpressionType) -> bool: ) +def is_json_arrow_type(type_: pa.DataType) -> bool: + return isinstance(type_, db_dtypes.JSONArrowType) or ( + hasattr(pa, "JsonType") and isinstance(type_, pa.JsonType) + ) + + def is_json_like(type_: ExpressionType) -> bool: return type_ == JSON_DTYPE or type_ == STRING_DTYPE # Including JSON string @@ -510,6 +516,10 @@ def arrow_dtype_to_bigframes_dtype( if arrow_dtype == pa.null(): return DEFAULT_DTYPE + # Allow both db_dtypes.JSONArrowType() and pa.json_(pa.string()) + if is_json_arrow_type(arrow_dtype): + return JSON_DTYPE + # No other types matched. raise TypeError( f"Unexpected Arrow data type {arrow_dtype}. {constants.FEEDBACK_LINK}" diff --git a/tests/system/small/test_series.py b/tests/system/small/test_series.py index df538329ce..5ace3f54d8 100644 --- a/tests/system/small/test_series.py +++ b/tests/system/small/test_series.py @@ -353,6 +353,41 @@ def test_series_construct_w_json_dtype(json_type): assert s[5] == '{"a":{"b":[1,2,3],"c":true}}' +def test_series_construct_w_nested_json_dtype(): + list_data = [ + [{"key": "1"}], + [{"key": None}], + [{"key": '["1","3","5"]'}], + [{"key": '{"a":1,"b":["x","y"],"c":{"x":[],"z":false}}'}], + ] + pa_array = pa.array(list_data, type=pa.list_(pa.struct([("key", pa.string())]))) + + db_json_arrow_dtype = db_dtypes.JSONArrowType() + s = bigframes.pandas.Series( + pd.arrays.ArrowExtensionArray(pa_array), # type: ignore + dtype=pd.ArrowDtype( + pa.list_(pa.struct([("key", db_json_arrow_dtype)])), + ), + ) + + assert s[0][0]["key"] == "1" + assert not s[1][0]["key"] + assert s[2][0]["key"] == '["1","3","5"]' + assert s[3][0]["key"] == '{"a":1,"b":["x","y"],"c":{"x":[],"z":false}}' + + # Test with pyarrow.json_(pa.string()) if available. + if hasattr(pa, "JsonType"): + pyarrow_json_dtype = pa.json_(pa.string()) + s2 = bigframes.pandas.Series( + pd.arrays.ArrowExtensionArray(pa_array), # type: ignore + dtype=pd.ArrowDtype( + pa.list_(pa.struct([("key", pyarrow_json_dtype)])), + ), + ) + + pd.testing.assert_series_equal(s.to_pandas(), s2.to_pandas()) + + def test_series_keys(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs bf_result = scalars_df["int64_col"].keys().to_pandas() From b9a5804e06437fb193afcf5740f0e20a08f2a816 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Fri, 24 Oct 2025 16:17:32 -0700 Subject: [PATCH 175/313] refactor: add some numeric tests in the golden sql (#2195) --- .../test_numeric_ops/test_add_string/out.sql | 13 +++ .../test_add_timedelta/out.sql | 60 ++++++++++++++ .../test_mul_timedelta/out.sql | 43 ++++++++++ .../test_sub_timedelta/out.sql | 82 +++++++++++++++++++ .../sqlglot/expressions/test_numeric_ops.py | 60 ++++++++++++++ 5 files changed, 258 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_string/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_timedelta/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_timedelta/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_timedelta/out.sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_string/out.sql new file mode 100644 index 0000000000..de5129a6a3 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_string/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CONCAT(`bfcol_0`, 'a') AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_timedelta/out.sql new file mode 100644 index 0000000000..a47531999b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_timedelta/out.sql @@ -0,0 +1,60 @@ +WITH `bfcte_0` AS ( + SELECT + `date_col` AS `bfcol_0`, + `rowindex` AS `bfcol_1`, + `timestamp_col` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_1` AS `bfcol_6`, + `bfcol_2` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + TIMESTAMP_ADD(CAST(`bfcol_0` AS DATETIME), INTERVAL 86400000000 MICROSECOND) AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + TIMESTAMP_ADD(`bfcol_7`, INTERVAL 86400000000 MICROSECOND) AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + TIMESTAMP_ADD(CAST(`bfcol_16` AS DATETIME), INTERVAL 86400000000 MICROSECOND) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + TIMESTAMP_ADD(`bfcol_25`, INTERVAL 86400000000 MICROSECOND) AS `bfcol_42` + FROM `bfcte_3` +), `bfcte_5` AS ( + SELECT + *, + 172800000000 AS `bfcol_50` + FROM `bfcte_4` +) +SELECT + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `timestamp_col`, + `bfcol_38` AS `date_col`, + `bfcol_39` AS `date_add_timedelta`, + `bfcol_40` AS `timestamp_add_timedelta`, + `bfcol_41` AS `timedelta_add_date`, + `bfcol_42` AS `timedelta_add_timestamp`, + `bfcol_50` AS `timedelta_add_timedelta` +FROM `bfcte_5` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_timedelta/out.sql new file mode 100644 index 0000000000..f8752d0a60 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_timedelta/out.sql @@ -0,0 +1,43 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `rowindex` AS `bfcol_1`, + `timestamp_col` AS `bfcol_2`, + `duration_col` AS `bfcol_3` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_1` AS `bfcol_8`, + `bfcol_2` AS `bfcol_9`, + `bfcol_0` AS `bfcol_10`, + `bfcol_3` AS `bfcol_11` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_10` AS `bfcol_18`, + `bfcol_11` AS `bfcol_19`, + CAST(FLOOR(`bfcol_11` * `bfcol_10`) AS INT64) AS `bfcol_20` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_19` AS `bfcol_29`, + `bfcol_20` AS `bfcol_30`, + CAST(FLOOR(`bfcol_18` * `bfcol_19`) AS INT64) AS `bfcol_31` + FROM `bfcte_2` +) +SELECT + `bfcol_26` AS `rowindex`, + `bfcol_27` AS `timestamp_col`, + `bfcol_28` AS `int64_col`, + `bfcol_29` AS `duration_col`, + `bfcol_30` AS `timedelta_mul_numeric`, + `bfcol_31` AS `numeric_mul_timedelta` +FROM `bfcte_3` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_timedelta/out.sql new file mode 100644 index 0000000000..2d615fcca6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_timedelta/out.sql @@ -0,0 +1,82 @@ +WITH `bfcte_0` AS ( + SELECT + `date_col` AS `bfcol_0`, + `rowindex` AS `bfcol_1`, + `timestamp_col` AS `bfcol_2`, + `duration_col` AS `bfcol_3` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_1` AS `bfcol_8`, + `bfcol_2` AS `bfcol_9`, + `bfcol_0` AS `bfcol_10`, + `bfcol_3` AS `bfcol_11` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + `bfcol_11` AS `bfcol_18`, + `bfcol_10` AS `bfcol_19`, + TIMESTAMP_SUB(CAST(`bfcol_10` AS DATETIME), INTERVAL `bfcol_11` MICROSECOND) AS `bfcol_20` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_19` AS `bfcol_29`, + `bfcol_20` AS `bfcol_30`, + TIMESTAMP_SUB(`bfcol_17`, INTERVAL `bfcol_18` MICROSECOND) AS `bfcol_31` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + `bfcol_30` AS `bfcol_42`, + `bfcol_31` AS `bfcol_43`, + TIMESTAMP_DIFF(CAST(`bfcol_29` AS DATETIME), CAST(`bfcol_29` AS DATETIME), MICROSECOND) AS `bfcol_44` + FROM `bfcte_3` +), `bfcte_5` AS ( + SELECT + *, + `bfcol_38` AS `bfcol_52`, + `bfcol_39` AS `bfcol_53`, + `bfcol_40` AS `bfcol_54`, + `bfcol_41` AS `bfcol_55`, + `bfcol_42` AS `bfcol_56`, + `bfcol_43` AS `bfcol_57`, + `bfcol_44` AS `bfcol_58`, + TIMESTAMP_DIFF(`bfcol_39`, `bfcol_39`, MICROSECOND) AS `bfcol_59` + FROM `bfcte_4` +), `bfcte_6` AS ( + SELECT + *, + `bfcol_52` AS `bfcol_68`, + `bfcol_53` AS `bfcol_69`, + `bfcol_54` AS `bfcol_70`, + `bfcol_55` AS `bfcol_71`, + `bfcol_56` AS `bfcol_72`, + `bfcol_57` AS `bfcol_73`, + `bfcol_58` AS `bfcol_74`, + `bfcol_59` AS `bfcol_75`, + `bfcol_54` - `bfcol_54` AS `bfcol_76` + FROM `bfcte_5` +) +SELECT + `bfcol_68` AS `rowindex`, + `bfcol_69` AS `timestamp_col`, + `bfcol_70` AS `duration_col`, + `bfcol_71` AS `date_col`, + `bfcol_72` AS `date_sub_timedelta`, + `bfcol_73` AS `timestamp_sub_timedelta`, + `bfcol_74` AS `timestamp_sub_date`, + `bfcol_75` AS `date_sub_timestamp`, + `bfcol_76` AS `timedelta_sub_timedelta` +FROM `bfcte_6` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py index 59726da73b..fe9a53a558 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py @@ -16,6 +16,7 @@ import pytest from bigframes import operations as ops +import bigframes.core.expression as ex import bigframes.pandas as bpd from bigframes.testing import utils @@ -218,6 +219,34 @@ def test_add_numeric(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(bf_df.sql, "out.sql") +def test_add_string(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = utils._apply_binary_op(bf_df, ops.add_op, "string_col", ex.const("a")) + + snapshot.assert_match(sql, "out.sql") + + +def test_add_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col", "date_col"]] + timedelta = pd.Timedelta(1, unit="d") + + bf_df["date_add_timedelta"] = bf_df["date_col"] + timedelta + bf_df["timestamp_add_timedelta"] = bf_df["timestamp_col"] + timedelta + bf_df["timedelta_add_date"] = timedelta + bf_df["date_col"] + bf_df["timedelta_add_timestamp"] = timedelta + bf_df["timestamp_col"] + bf_df["timedelta_add_timedelta"] = timedelta + timedelta + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_add_unsupported_raises(scalar_types_df: bpd.DataFrame): + with pytest.raises(TypeError): + utils._apply_binary_op(scalar_types_df, ops.add_op, "timestamp_col", "date_col") + + with pytest.raises(TypeError): + utils._apply_binary_op(scalar_types_df, ops.add_op, "int64_col", "string_col") + + def test_div_numeric(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["int64_col", "bool_col", "float64_col"]] @@ -279,6 +308,16 @@ def test_mul_numeric(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(bf_df.sql, "out.sql") +def test_mul_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col", "int64_col", "duration_col"]] + bf_df["duration_col"] = bpd.to_timedelta(bf_df["duration_col"], unit="us") + + bf_df["timedelta_mul_numeric"] = bf_df["duration_col"] * bf_df["int64_col"] + bf_df["numeric_mul_timedelta"] = bf_df["int64_col"] * bf_df["duration_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + def test_mod_numeric(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["int64_col", "float64_col"]] @@ -305,3 +344,24 @@ def test_sub_numeric(scalar_types_df: bpd.DataFrame, snapshot): bf_df["bool_add_int"] = bf_df["bool_col"] - bf_df["int64_col"] snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_sub_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col", "duration_col", "date_col"]] + bf_df["duration_col"] = bpd.to_timedelta(bf_df["duration_col"], unit="us") + + bf_df["date_sub_timedelta"] = bf_df["date_col"] - bf_df["duration_col"] + bf_df["timestamp_sub_timedelta"] = bf_df["timestamp_col"] - bf_df["duration_col"] + bf_df["timestamp_sub_date"] = bf_df["date_col"] - bf_df["date_col"] + bf_df["date_sub_timestamp"] = bf_df["timestamp_col"] - bf_df["timestamp_col"] + bf_df["timedelta_sub_timedelta"] = bf_df["duration_col"] - bf_df["duration_col"] + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_sub_unsupported_raises(scalar_types_df: bpd.DataFrame): + with pytest.raises(TypeError): + utils._apply_binary_op(scalar_types_df, ops.sub_op, "string_col", "string_col") + + with pytest.raises(TypeError): + utils._apply_binary_op(scalar_types_df, ops.sub_op, "int64_col", "string_col") From db21a85b0cf7b433d28c5f27275692534b86b147 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Fri, 24 Oct 2025 16:28:09 -0700 Subject: [PATCH 176/313] refactor: add paren to the invert and neg op (#2189) --- .../sqlglot/expressions/generic_ops.py | 4 +- .../sqlglot/expressions/numeric_ops.py | 2 +- .../system/small/engines/test_generic_ops.py | 2 +- .../test_generic_ops/test_invert/out.sql | 12 ++- .../test_numeric_ops/test_mod_numeric/out.sql | 80 ++++++++++++++----- .../test_numeric_ops/test_neg/out.sql | 4 +- 6 files changed, 76 insertions(+), 28 deletions(-) diff --git a/bigframes/core/compile/sqlglot/expressions/generic_ops.py b/bigframes/core/compile/sqlglot/expressions/generic_ops.py index 60366b02c9..9782ef11d4 100644 --- a/bigframes/core/compile/sqlglot/expressions/generic_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/generic_ops.py @@ -76,8 +76,8 @@ def _(expr: TypedExpr) -> sge.Expression: @register_unary_op(ops.invert_op) def _(expr: TypedExpr) -> sge.Expression: if expr.dtype == dtypes.BOOL_DTYPE: - return sge.Not(this=expr.expr) - return sge.BitwiseNot(this=expr.expr) + return sge.Not(this=sge.paren(expr.expr)) + return sge.BitwiseNot(this=sge.paren(expr.expr)) @register_unary_op(ops.isnull_op) diff --git a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py index 64e27ebe79..8ca884b900 100644 --- a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py @@ -190,7 +190,7 @@ def _(expr: TypedExpr) -> sge.Expression: @register_unary_op(ops.neg_op) def _(expr: TypedExpr) -> sge.Expression: - return sge.Neg(this=expr.expr) + return sge.Neg(this=sge.paren(expr.expr)) @register_unary_op(ops.pos_op) diff --git a/tests/system/small/engines/test_generic_ops.py b/tests/system/small/engines/test_generic_ops.py index f209b95496..5641f91a9a 100644 --- a/tests/system/small/engines/test_generic_ops.py +++ b/tests/system/small/engines/test_generic_ops.py @@ -454,7 +454,7 @@ def test_engines_isin_op(scalars_array_value: array_value.ArrayValue, engine): assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_isin_op_nested_filter( scalars_array_value: array_value.ArrayValue, engine ): diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_invert/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_invert/out.sql index b5a5b92b52..bf005efb05 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_invert/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_invert/out.sql @@ -7,9 +7,15 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - ~`bfcol_2` AS `bfcol_6`, - ~`bfcol_1` AS `bfcol_7`, - NOT `bfcol_0` AS `bfcol_8` + ~( + `bfcol_2` + ) AS `bfcol_6`, + ~( + `bfcol_1` + ) AS `bfcol_7`, + NOT ( + `bfcol_0` + ) AS `bfcol_8` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mod_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mod_numeric/out.sql index 7913b43aa6..64f456a72d 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mod_numeric/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mod_numeric/out.sql @@ -38,23 +38,43 @@ WITH `bfcte_0` AS ( `bfcol_8` AS `bfcol_16`, `bfcol_9` AS `bfcol_17`, CASE - WHEN -`bfcol_7` = CAST(0 AS INT64) + WHEN -( + `bfcol_7` + ) = CAST(0 AS INT64) THEN CAST(0 AS INT64) * `bfcol_7` - WHEN -`bfcol_7` < CAST(0 AS INT64) + WHEN -( + `bfcol_7` + ) < CAST(0 AS INT64) AND ( - MOD(`bfcol_7`, -`bfcol_7`) + MOD(`bfcol_7`, -( + `bfcol_7` + )) ) > CAST(0 AS INT64) - THEN -`bfcol_7` + ( - MOD(`bfcol_7`, -`bfcol_7`) + THEN -( + `bfcol_7` + ) + ( + MOD(`bfcol_7`, -( + `bfcol_7` + )) ) - WHEN -`bfcol_7` > CAST(0 AS INT64) + WHEN -( + `bfcol_7` + ) > CAST(0 AS INT64) AND ( - MOD(`bfcol_7`, -`bfcol_7`) + MOD(`bfcol_7`, -( + `bfcol_7` + )) ) < CAST(0 AS INT64) - THEN -`bfcol_7` + ( - MOD(`bfcol_7`, -`bfcol_7`) + THEN -( + `bfcol_7` + ) + ( + MOD(`bfcol_7`, -( + `bfcol_7` + )) ) - ELSE MOD(`bfcol_7`, -`bfcol_7`) + ELSE MOD(`bfcol_7`, -( + `bfcol_7` + )) END AS `bfcol_18` FROM `bfcte_1` ), `bfcte_3` AS ( @@ -152,23 +172,43 @@ WITH `bfcte_0` AS ( `bfcol_56` AS `bfcol_72`, `bfcol_57` AS `bfcol_73`, CASE - WHEN CAST(-`bfcol_52` AS BIGNUMERIC) = CAST(0 AS INT64) + WHEN CAST(-( + `bfcol_52` + ) AS BIGNUMERIC) = CAST(0 AS INT64) THEN CAST('NaN' AS FLOAT64) * CAST(`bfcol_52` AS BIGNUMERIC) - WHEN CAST(-`bfcol_52` AS BIGNUMERIC) < CAST(0 AS INT64) + WHEN CAST(-( + `bfcol_52` + ) AS BIGNUMERIC) < CAST(0 AS INT64) AND ( - MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-`bfcol_52` AS BIGNUMERIC)) + MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-( + `bfcol_52` + ) AS BIGNUMERIC)) ) > CAST(0 AS INT64) - THEN CAST(-`bfcol_52` AS BIGNUMERIC) + ( - MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-`bfcol_52` AS BIGNUMERIC)) + THEN CAST(-( + `bfcol_52` + ) AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-( + `bfcol_52` + ) AS BIGNUMERIC)) ) - WHEN CAST(-`bfcol_52` AS BIGNUMERIC) > CAST(0 AS INT64) + WHEN CAST(-( + `bfcol_52` + ) AS BIGNUMERIC) > CAST(0 AS INT64) AND ( - MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-`bfcol_52` AS BIGNUMERIC)) + MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-( + `bfcol_52` + ) AS BIGNUMERIC)) ) < CAST(0 AS INT64) - THEN CAST(-`bfcol_52` AS BIGNUMERIC) + ( - MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-`bfcol_52` AS BIGNUMERIC)) + THEN CAST(-( + `bfcol_52` + ) AS BIGNUMERIC) + ( + MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-( + `bfcol_52` + ) AS BIGNUMERIC)) ) - ELSE MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-`bfcol_52` AS BIGNUMERIC)) + ELSE MOD(CAST(`bfcol_52` AS BIGNUMERIC), CAST(-( + `bfcol_52` + ) AS BIGNUMERIC)) END AS `bfcol_74` FROM `bfcte_5` ), `bfcte_7` AS ( diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_neg/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_neg/out.sql index 46c58f766d..39bdd6da7f 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_neg/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_neg/out.sql @@ -5,7 +5,9 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - -`bfcol_0` AS `bfcol_1` + -( + `bfcol_0` + ) AS `bfcol_1` FROM `bfcte_0` ) SELECT From dc46b3cfb2a4807e022ba80a15f507e85a5a708a Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 10:59:37 -0500 Subject: [PATCH 177/313] chore(main): release 2.27.0 (#2184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> Co-authored-by: Tim Sweña (Swast) --- CHANGELOG.md | 18 ++++++++++++++++++ bigframes/version.py | 4 ++-- third_party/bigframes_vendored/version.py | 4 ++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63bf7fd0ec..60fe9ae5e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ [1]: https://pypi.org/project/bigframes/#history +## [2.27.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.26.0...v2.27.0) (2025-10-24) + + +### Features + +* Add __abs__ to dataframe ([#2186](https://github.com/googleapis/python-bigquery-dataframes/issues/2186)) ([c331dfe](https://github.com/googleapis/python-bigquery-dataframes/commit/c331dfed59174962fbdc8ace175dd00fcc3d5d50)) +* Add df.groupby().corr()/cov() support ([#2190](https://github.com/googleapis/python-bigquery-dataframes/issues/2190)) ([ccd7c07](https://github.com/googleapis/python-bigquery-dataframes/commit/ccd7c0774a65d09e6cf31d2b62d0bc64bd7c4248)) +* Add str accessor to index ([#2179](https://github.com/googleapis/python-bigquery-dataframes/issues/2179)) ([cd87ce0](https://github.com/googleapis/python-bigquery-dataframes/commit/cd87ce0d504747f44d1b5a55f869a2e0fca6df17)) +* Add support for `np.isnan` and `np.isfinite` ufuncs ([#2188](https://github.com/googleapis/python-bigquery-dataframes/issues/2188)) ([68723bc](https://github.com/googleapis/python-bigquery-dataframes/commit/68723bc1f08013e43a8b11752f908bf8fd6d51f5)) +* Include local data bytes in the dry run report when available ([#2185](https://github.com/googleapis/python-bigquery-dataframes/issues/2185)) ([ee2c40c](https://github.com/googleapis/python-bigquery-dataframes/commit/ee2c40c6789535e259fb6a9774831d6913d16212)) +* Support len() on Groupby objects ([#2183](https://github.com/googleapis/python-bigquery-dataframes/issues/2183)) ([4191821](https://github.com/googleapis/python-bigquery-dataframes/commit/4191821b0976281a96c8965336ef51f061b0c481)) +* Support pa.json_(pa.string()) in struct/list if available ([#2180](https://github.com/googleapis/python-bigquery-dataframes/issues/2180)) ([5ec3cc0](https://github.com/googleapis/python-bigquery-dataframes/commit/5ec3cc0298c7a6195d5bd12a08d996e7df57fc5f)) + + +### Documentation + +* Update AI operators deprecation notice ([#2182](https://github.com/googleapis/python-bigquery-dataframes/issues/2182)) ([2c50310](https://github.com/googleapis/python-bigquery-dataframes/commit/2c503107e17c59232b14b0d7bc40c350bb087d6f)) + ## [2.26.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.25.0...v2.26.0) (2025-10-17) diff --git a/bigframes/version.py b/bigframes/version.py index 6fe84df0ab..4e319dd41d 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.26.0" +__version__ = "2.27.0" # {x-release-please-start-date} -__release_date__ = "2025-10-17" +__release_date__ = "2025-10-24" # {x-release-please-end} diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index 6fe84df0ab..4e319dd41d 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.26.0" +__version__ = "2.27.0" # {x-release-please-start-date} -__release_date__ = "2025-10-17" +__release_date__ = "2025-10-24" # {x-release-please-end} From 4c4c9b14657b7cda1940ef39e7d4db20a9ff5308 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Mon, 27 Oct 2025 12:30:54 -0700 Subject: [PATCH 178/313] fix: Resolve AttributeError in TableWidget and improve initialization (#1937) * remove expensive len() call * add testcase * fix a typo * change how row_count is updated * testcase stil fails, need to merged in 1888 * update the method of using PandasBatches.total_rows * change tests in read_gbq_colab * polish comment * fix a test * change code and update more testcase * remove unneeded except * add assert for total_rows * get actual row_counts * avoid two query calls * remove double query when display widget * get row count directly * restore notebook * restore notebook change * remove duplicated code * minor updates * still have zero total rows issue * now large dataset can get the correct row counts * benchmark change * revert a benchmark * revert executor change * raising a NotImplementedError when the row count is none * change return type * Revert accidental change of dataframe.ipynb * remove unnecessary execution in benchmark * remove row_count check * remove extra execute_result * remove unnecessary tests * Fix: Address review comments on PandasBatches and docstring - Reinstated 'Iterator[pd.DataFrame]' inheritance for 'PandasBatches' in 'bigframes/core/blocks.py'. - Removed internal type hint 'bigframes.core.blocks.PandasBatches:' from 'to_pandas_batches' docstring in 'bigframes/dataframe.py' to avoid exposing internal types in public documentation. * Revert: Revert import change in read_gbq_colab benchmark This reverts the import path for the benchmark utils to 'benchmark.utils' to address concerns about google3 imports. * Revert: Revert unnecessary changes in read_gbq_colab benchmarks * Remove notebooks/Untitled-2.ipynb * Remove notebooks/multimodal/audio_transcribe_partial_ordering.ipynb * remove unnecessary change * revert typo * add todo * change docstring * revert changes to tests/benchmark/read_gbq_colab * merge change * update how we handle invalid row count * eliminate duplated flags --- bigframes/display/anywidget.py | 75 ++++++++++----- bigframes/display/table_widget.js | 6 ++ notebooks/dataframes/anywidget_mode.ipynb | 68 +++++++++---- tests/system/small/test_anywidget.py | 110 ++++++++++++++++++++-- 4 files changed, 210 insertions(+), 49 deletions(-) diff --git a/bigframes/display/anywidget.py b/bigframes/display/anywidget.py index 3d12a2032c..a0b4f809d8 100644 --- a/bigframes/display/anywidget.py +++ b/bigframes/display/anywidget.py @@ -23,13 +23,14 @@ import pandas as pd import bigframes +from bigframes.core import blocks import bigframes.dataframe import bigframes.display.html -# anywidget and traitlets are optional dependencies. We don't want the import of this -# module to fail if they aren't installed, though. Instead, we try to limit the surface that -# these packages could affect. This makes unit testing easier and ensures we don't -# accidentally make these required packages. +# anywidget and traitlets are optional dependencies. We don't want the import of +# this module to fail if they aren't installed, though. Instead, we try to +# limit the surface that these packages could affect. This makes unit testing +# easier and ensures we don't accidentally make these required packages. try: import anywidget import traitlets @@ -46,9 +47,21 @@ class TableWidget(WIDGET_BASE): + """An interactive, paginated table widget for BigFrames DataFrames. + + This widget provides a user-friendly way to display and navigate through + large BigQuery DataFrames within a Jupyter environment. """ - An interactive, paginated table widget for BigFrames DataFrames. - """ + + page = traitlets.Int(0).tag(sync=True) + page_size = traitlets.Int(0).tag(sync=True) + row_count = traitlets.Int(0).tag(sync=True) + table_html = traitlets.Unicode().tag(sync=True) + _initial_load_complete = traitlets.Bool(False).tag(sync=True) + _batches: Optional[blocks.PandasBatches] = None + _error_message = traitlets.Unicode(allow_none=True, default_value=None).tag( + sync=True + ) def __init__(self, dataframe: bigframes.dataframe.DataFrame): """Initialize the TableWidget. @@ -61,10 +74,11 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): "Please `pip install anywidget traitlets` or `pip install 'bigframes[anywidget]'` to use TableWidget." ) - super().__init__() self._dataframe = dataframe - # Initialize attributes that might be needed by observers FIRST + super().__init__() + + # Initialize attributes that might be needed by observers first self._table_id = str(uuid.uuid4()) self._all_data_loaded = False self._batch_iter: Optional[Iterator[pd.DataFrame]] = None @@ -73,9 +87,6 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): # respect display options for initial page size initial_page_size = bigframes.options.display.max_rows - # Initialize data fetching attributes. - self._batches = dataframe._to_pandas_batches(page_size=initial_page_size) - # set traitlets properties that trigger observers self.page_size = initial_page_size @@ -84,12 +95,21 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): # TODO(b/428238610): Start iterating over the result of `to_pandas_batches()` # before we get here so that the count might already be cached. # TODO(b/452747934): Allow row_count to be None and check to see if - # there are multiple pages and show "page 1 of many" in this case. - self.row_count = self._batches.total_rows or 0 + # there are multiple pages and show "page 1 of many" in this case + self._reset_batches_for_new_page_size() + if self._batches is None or self._batches.total_rows is None: + self._error_message = "Could not determine total row count. Data might be unavailable or an error occurred." + self.row_count = 0 + else: + self.row_count = self._batches.total_rows # get the initial page self._set_table_html() + # Signals to the frontend that the initial data load is complete. + # Also used as a guard to prevent observers from firing during initialization. + self._initial_load_complete = True + @functools.cached_property def _esm(self): """Load JavaScript code from external file.""" @@ -100,11 +120,6 @@ def _css(self): """Load CSS code from external file.""" return resources.read_text(bigframes.display, "table_widget.css") - page = traitlets.Int(0).tag(sync=True) - page_size = traitlets.Int(25).tag(sync=True) - row_count = traitlets.Int(0).tag(sync=True) - table_html = traitlets.Unicode().tag(sync=True) - @traitlets.validate("page") def _validate_page(self, proposal: Dict[str, Any]) -> int: """Validate and clamp the page number to a valid range. @@ -171,7 +186,10 @@ def _get_next_batch(self) -> bool: def _batch_iterator(self) -> Iterator[pd.DataFrame]: """Lazily initializes and returns the batch iterator.""" if self._batch_iter is None: - self._batch_iter = iter(self._batches) + if self._batches is None: + self._batch_iter = iter([]) + else: + self._batch_iter = iter(self._batches) return self._batch_iter @property @@ -181,15 +199,22 @@ def _cached_data(self) -> pd.DataFrame: return pd.DataFrame(columns=self._dataframe.columns) return pd.concat(self._cached_batches, ignore_index=True) - def _reset_batches_for_new_page_size(self): + def _reset_batches_for_new_page_size(self) -> None: """Reset the batch iterator when page size changes.""" self._batches = self._dataframe._to_pandas_batches(page_size=self.page_size) + self._cached_batches = [] self._batch_iter = None self._all_data_loaded = False - def _set_table_html(self): + def _set_table_html(self) -> None: """Sets the current html data based on the current page and page size.""" + if self._error_message: + self.table_html = ( + f"
{self._error_message}
" + ) + return + start = self.page * self.page_size end = start + self.page_size @@ -211,13 +236,17 @@ def _set_table_html(self): ) @traitlets.observe("page") - def _page_changed(self, _change: Dict[str, Any]): + def _page_changed(self, _change: Dict[str, Any]) -> None: """Handler for when the page number is changed from the frontend.""" + if not self._initial_load_complete: + return self._set_table_html() @traitlets.observe("page_size") - def _page_size_changed(self, _change: Dict[str, Any]): + def _page_size_changed(self, _change: Dict[str, Any]) -> None: """Handler for when the page size is changed from the frontend.""" + if not self._initial_load_complete: + return # Reset the page to 0 when page size changes to avoid invalid page states self.page = 0 diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js index 6b4d99ff28..801e262cc1 100644 --- a/bigframes/display/table_widget.js +++ b/bigframes/display/table_widget.js @@ -137,6 +137,12 @@ function render({ model, el }) { } }); model.on(Event.CHANGE_TABLE_HTML, handleTableHTMLChange); + model.on(`change:${ModelProperty.ROW_COUNT}`, updateButtonStates); + model.on(`change:_initial_load_complete`, (val) => { + if (val) { + updateButtonStates(); + } + }); // Assemble the DOM paginationContainer.appendChild(prevPage); diff --git a/notebooks/dataframes/anywidget_mode.ipynb b/notebooks/dataframes/anywidget_mode.ipynb index 328d4a05f1..c2af915721 100644 --- a/notebooks/dataframes/anywidget_mode.ipynb +++ b/notebooks/dataframes/anywidget_mode.ipynb @@ -127,12 +127,24 @@ "id": "ce250157", "metadata": {}, "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "9e3e413eb0774a62818c58d217af8488", + "model_id": "aafd4f912b5f42e0896aa5f0c2c62620", "version_major": 2, - "version_minor": 1 + "version_minor": 0 }, "text/plain": [ "TableWidget(page_size=10, row_count=5552452, table_html='" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "name": "stdout", "output_type": "stream", @@ -181,17 +205,16 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "df5e93f0d03f45cda67aa6da7f9ef1ae", + "model_id": "5ec0ad9f11874d4f9d8edbc903ee7b5d", "version_major": 2, - "version_minor": 1 + "version_minor": 0 }, "text/plain": [ "TableWidget(page_size=10, row_count=5552452, table_html='
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "name": "stdout", "output_type": "stream", @@ -267,17 +304,16 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "a4ec5248708442fabc59c446c78a1304", + "model_id": "651b5aac958c408183775152c2573a03", "version_major": 2, - "version_minor": 1 + "version_minor": 0 }, "text/plain": [ "TableWidget(page_size=10, row_count=5, table_html='
Date: Mon, 27 Oct 2025 13:05:03 -0700 Subject: [PATCH 179/313] refactor: migrate ai.forecast from AIAccessor to bbq (#2198) * refactor: remove ai.forecast from AIAccessor to bbq * add warning test for ai accessor * fix notebook test * fix doc * fix lint --- bigframes/bigquery/_operations/ai.py | 92 +++- bigframes/dataframe.py | 6 +- docs/templates/toc.yml | 3 - notebooks/experimental/ai_operators.ipynb | 2 +- .../bq_dataframes_ai_forecast.ipynb | 458 +++++++++--------- tests/system/small/bigquery/test_ai.py | 50 +- tests/unit/test_dataframe.py | 9 + 7 files changed, 384 insertions(+), 236 deletions(-) diff --git a/bigframes/bigquery/_operations/ai.py b/bigframes/bigquery/_operations/ai.py index e0af130016..8579f7f298 100644 --- a/bigframes/bigquery/_operations/ai.py +++ b/bigframes/bigquery/_operations/ai.py @@ -19,12 +19,15 @@ from __future__ import annotations import json -from typing import Any, List, Literal, Mapping, Tuple, Union +from typing import Any, Iterable, List, Literal, Mapping, Tuple, Union import pandas as pd -from bigframes import clients, dtypes, series, session +from bigframes import clients, dataframe, dtypes +from bigframes import pandas as bpd +from bigframes import series, session from bigframes.core import convert, log_adapter +from bigframes.ml import core as ml_core from bigframes.operations import ai_ops, output_schemas PROMPT_TYPE = Union[ @@ -548,6 +551,91 @@ def score( return series_list[0]._apply_nary_op(operator, series_list[1:]) +@log_adapter.method_logger(custom_base_name="bigquery_ai") +def forecast( + df: dataframe.DataFrame | pd.DataFrame, + *, + data_col: str, + timestamp_col: str, + model: str = "TimesFM 2.0", + id_cols: Iterable[str] | None = None, + horizon: int = 10, + confidence_level: float = 0.95, + context_window: int | None = None, +) -> dataframe.DataFrame: + """ + Forecast time series at future horizon. Using Google Research's open source TimesFM(https://github.com/google-research/timesfm) model. + + .. note:: + + This product or feature is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" + and might have limited support. For more information, see the launch stage descriptions + (https://cloud.google.com/products#product-launch-stages). + + Args: + df (DataFrame): + The dataframe that contains the data that you want to forecast. It could be either a BigFrames Dataframe or + a pandas DataFrame. If it's a pandas DataFrame, the global BigQuery session will be used to load the data. + data_col (str): + A str value that specifies the name of the data column. The data column contains the data to forecast. + The data column must use one of the following data types: INT64, NUMERIC and FLOAT64 + timestamp_col (str): + A str value that specified the name of the time points column. + The time points column provides the time points used to generate the forecast. + The time points column must use one of the following data types: TIMESTAMP, DATE and DATETIME + model (str, default "TimesFM 2.0"): + A str value that specifies the name of the model. TimesFM 2.0 is the only supported value, and is the default value. + id_cols (Iterable[str], optional): + An iterable of str value that specifies the names of one or more ID columns. Each ID identifies a unique time series to forecast. + Specify one or more values for this argument in order to forecast multiple time series using a single query. + The columns that you specify must use one of the following data types: STRING, INT64, ARRAY and ARRAY + horizon (int, default 10): + An int value that specifies the number of time points to forecast. The default value is 10. The valid input range is [1, 10,000]. + confidence_level (float, default 0.95): + A FLOAT64 value that specifies the percentage of the future values that fall in the prediction interval. + The default value is 0.95. The valid input range is [0, 1). + context_window (int, optional): + An int value that specifies the context window length used by BigQuery ML's built-in TimesFM model. + The context window length determines how many of the most recent data points from the input time series are use by the model. + If you don't specify a value, the AI.FORECAST function automatically chooses the smallest possible context window length to use + that is still large enough to cover the number of time series data points in your input data. + + Returns: + DataFrame: + The forecast dataframe matches that of the BigQuery AI.FORECAST function. + See: https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-ai-forecast + + Raises: + ValueError: when any column ID does not exist in the dataframe. + """ + + if isinstance(df, pd.DataFrame): + # Load the pandas DataFrame with global session + df = bpd.read_pandas(df) + + columns = [timestamp_col, data_col] + if id_cols: + columns += id_cols + for column in columns: + if column not in df.columns: + raise ValueError(f"Column `{column}` not found") + + options: dict[str, Union[int, float, str, Iterable[str]]] = { + "data_col": data_col, + "timestamp_col": timestamp_col, + "model": model, + "horizon": horizon, + "confidence_level": confidence_level, + } + if id_cols: + options["id_cols"] = id_cols + if context_window: + options["context_window"] = context_window + + return ml_core.BaseBqml(df._session).ai_forecast(input_data=df, options=options) + + def _separate_context_and_series( prompt: PROMPT_TYPE, ) -> Tuple[List[str | None], List[series.Series]]: diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index c3735ca3c2..f016fddd83 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -5328,7 +5328,7 @@ def _throw_if_null_index(self, opname: str): @property def semantics(self): msg = bfe.format_message( - "The 'semantics' property will be removed. Please use 'ai' instead." + "The 'semantics' property will be removed. Please use 'bigframes.bigquery.ai' instead." ) warnings.warn(msg, category=FutureWarning) return bigframes.operations.semantics.Semantics(self) @@ -5336,4 +5336,8 @@ def semantics(self): @property def ai(self): """Returns the accessor for AI operators.""" + msg = bfe.format_message( + "The 'ai' property will be removed. Please use 'bigframes.bigquery.ai' instead." + ) + warnings.warn(msg, category=FutureWarning) return bigframes.operations.ai.AIAccessor(self) diff --git a/docs/templates/toc.yml b/docs/templates/toc.yml index f368cf21ae..5d043fd85f 100644 --- a/docs/templates/toc.yml +++ b/docs/templates/toc.yml @@ -45,9 +45,6 @@ uid: bigframes.operations.plotting.PlotAccessor - name: StructAccessor uid: bigframes.operations.structs.StructFrameAccessor - - name: AI - uid: bigframes.operations.ai.AIAccessor - status: beta name: DataFrame - items: - name: DataFrameGroupBy diff --git a/notebooks/experimental/ai_operators.ipynb b/notebooks/experimental/ai_operators.ipynb index 9878929cd2..e24ec34d86 100644 --- a/notebooks/experimental/ai_operators.ipynb +++ b/notebooks/experimental/ai_operators.ipynb @@ -29,7 +29,7 @@ "id": "rWJnGj2ViouP" }, "source": [ - "All AI operators except for `ai.forecast` have moved to the `bigframes.bigquery.ai` module.\n", + "All AI functions have moved to the `bigframes.bigquery.ai` module.\n", "\n", "The tutorial notebook for AI functions is located at https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/generative_ai/ai_functions.ipynb\n", "\n", diff --git a/notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb b/notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb index fae6371a89..b9599282b3 100644 --- a/notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb +++ b/notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb @@ -65,7 +65,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -138,87 +138,111 @@ " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", + " \n", + " \n", " \n", " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", @@ -233,40 +257,40 @@ " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", @@ -281,99 +305,75 @@ " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", " \n", - " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", - " \n", + " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", - " \n", + " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", @@ -383,76 +383,76 @@ ], "text/plain": [ " trip_id duration_sec start_date \\\n", - "201708211707592427 13010 2017-08-21 17:07:59+00:00 \n", - "201710071741582009 2303 2017-10-07 17:41:58+00:00 \n", - "201803291935061174 552 2018-03-29 19:35:06+00:00 \n", - "201802081152113283 564 2018-02-08 11:52:11+00:00 \n", - "201710101915391238 642 2017-10-10 19:15:39+00:00 \n", - " 20171010191537666 659 2017-10-10 19:15:37+00:00 \n", - "201803241728231437 683 2018-03-24 17:28:23+00:00 \n", - "201801111613101305 858 2018-01-11 16:13:10+00:00 \n", - "201803171534453756 665 2018-03-17 15:34:45+00:00 \n", - "201803021320282858 791 2018-03-02 13:20:28+00:00 \n", + " 20171215164722144 501 2017-12-15 16:47:22+00:00 \n", + "201708052346051585 712 2017-08-05 23:46:05+00:00 \n", + "201711111447202880 272 2017-11-11 14:47:20+00:00 \n", + "201804251726273755 757 2018-04-25 17:26:27+00:00 \n", + " 20180408155601183 1105 2018-04-08 15:56:01+00:00 \n", + "201804191648501560 857 2018-04-19 16:48:50+00:00 \n", + " 20170810204454839 1256 2017-08-10 20:44:54+00:00 \n", + " 20171012204438666 630 2017-10-12 20:44:38+00:00 \n", + "201711181823281960 353 2017-11-18 18:23:28+00:00 \n", + " 20170806183917510 298 2017-08-06 18:39:17+00:00 \n", "\n", - " start_station_name start_station_id end_date \\\n", - " 10th Ave at E 15th St 222 2017-08-21 20:44:50+00:00 \n", - " 10th Ave at E 15th St 222 2017-10-07 18:20:22+00:00 \n", - " 10th St at Fallon St 201 2018-03-29 19:44:19+00:00 \n", - " 13th St at Franklin St 338 2018-02-08 12:01:35+00:00 \n", - " 2nd Ave at E 18th St 200 2017-10-10 19:26:21+00:00 \n", - " 2nd Ave at E 18th St 200 2017-10-10 19:26:37+00:00 \n", - "El Embarcadero at Grand Ave 197 2018-03-24 17:39:46+00:00 \n", - " Frank H Ogawa Plaza 7 2018-01-11 16:27:28+00:00 \n", - " Frank H Ogawa Plaza 7 2018-03-17 15:45:50+00:00 \n", - " Frank H Ogawa Plaza 7 2018-03-02 13:33:39+00:00 \n", + " start_station_name start_station_id end_date \\\n", + " 10th St at Fallon St 201 2017-12-15 16:55:44+00:00 \n", + " 10th St at Fallon St 201 2017-08-05 23:57:57+00:00 \n", + " 12th St at 4th Ave 233 2017-11-11 14:51:53+00:00 \n", + "13th St at Franklin St 338 2018-04-25 17:39:05+00:00 \n", + "13th St at Franklin St 338 2018-04-08 16:14:26+00:00 \n", + "13th St at Franklin St 338 2018-04-19 17:03:08+00:00 \n", + " 2nd Ave at E 18th St 200 2017-08-10 21:05:50+00:00 \n", + " 2nd Ave at E 18th St 200 2017-10-12 20:55:09+00:00 \n", + " 2nd Ave at E 18th St 200 2017-11-18 18:29:22+00:00 \n", + " 2nd Ave at E 18th St 200 2017-08-06 18:44:15+00:00 \n", "\n", " end_station_name end_station_id bike_number zip_code ... \\\n", - "10th Ave at E 15th St 222 2427 ... \n", - "10th Ave at E 15th St 222 2009 ... \n", - "10th Ave at E 15th St 222 1174 ... \n", - "10th Ave at E 15th St 222 3283 ... \n", - "10th Ave at E 15th St 222 1238 ... \n", + "10th Ave at E 15th St 222 144 ... \n", + "10th Ave at E 15th St 222 1585 ... \n", + "10th Ave at E 15th St 222 2880 ... \n", + "10th Ave at E 15th St 222 3755 ... \n", + "10th Ave at E 15th St 222 183 ... \n", + "10th Ave at E 15th St 222 1560 ... \n", + "10th Ave at E 15th St 222 839 ... \n", "10th Ave at E 15th St 222 666 ... \n", - "10th Ave at E 15th St 222 1437 ... \n", - "10th Ave at E 15th St 222 1305 ... \n", - "10th Ave at E 15th St 222 3756 ... \n", - "10th Ave at E 15th St 222 2858 ... \n", + "10th Ave at E 15th St 222 1960 ... \n", + "10th Ave at E 15th St 222 510 ... \n", "\n", "c_subscription_type start_station_latitude start_station_longitude \\\n", - " 37.792714 -122.24878 \n", - " 37.792714 -122.24878 \n", " 37.797673 -122.262997 \n", + " 37.797673 -122.262997 \n", + " 37.795812 -122.255555 \n", + " 37.803189 -122.270579 \n", " 37.803189 -122.270579 \n", + " 37.803189 -122.270579 \n", + " 37.800214 -122.25381 \n", + " 37.800214 -122.25381 \n", " 37.800214 -122.25381 \n", " 37.800214 -122.25381 \n", - " 37.808848 -122.24968 \n", - " 37.804562 -122.271738 \n", - " 37.804562 -122.271738 \n", - " 37.804562 -122.271738 \n", "\n", " end_station_latitude end_station_longitude member_birth_year \\\n", + " 37.792714 -122.24878 1984 \n", " 37.792714 -122.24878 \n", - " 37.792714 -122.24878 1979 \n", + " 37.792714 -122.24878 1965 \n", " 37.792714 -122.24878 1982 \n", " 37.792714 -122.24878 1987 \n", + " 37.792714 -122.24878 1982 \n", " 37.792714 -122.24878 \n", " 37.792714 -122.24878 \n", - " 37.792714 -122.24878 1987 \n", - " 37.792714 -122.24878 1984 \n", - " 37.792714 -122.24878 1987 \n", - " 37.792714 -122.24878 1984 \n", + " 37.792714 -122.24878 1988 \n", + " 37.792714 -122.24878 1969 \n", "\n", " member_gender bike_share_for_all_trip start_station_geom \\\n", - " POINT (-122.24878 37.79271) \n", - " Male POINT (-122.24878 37.79271) \n", - " Other No POINT (-122.263 37.79767) \n", + " Male POINT (-122.263 37.79767) \n", + " POINT (-122.263 37.79767) \n", + " Female POINT (-122.25555 37.79581) \n", + " Other No POINT (-122.27058 37.80319) \n", " Female No POINT (-122.27058 37.80319) \n", + " Other No POINT (-122.27058 37.80319) \n", " POINT (-122.25381 37.80021) \n", " POINT (-122.25381 37.80021) \n", - " Male No POINT (-122.24968 37.80885) \n", - " Male Yes POINT (-122.27174 37.80456) \n", - " Male No POINT (-122.27174 37.80456) \n", - " Male Yes POINT (-122.27174 37.80456) \n", + " Male POINT (-122.25381 37.80021) \n", + " Male POINT (-122.25381 37.80021) \n", "\n", " end_station_geom \n", "POINT (-122.24878 37.79271) \n", @@ -671,92 +671,92 @@ " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", @@ -766,28 +766,28 @@ ], "text/plain": [ " forecast_timestamp forecast_value confidence_level \\\n", - "2018-04-24 09:00:00+00:00 429.891174 0.95 \n", - "2018-04-24 19:00:00+00:00 288.039368 0.95 \n", - "2018-04-26 19:00:00+00:00 222.30899 0.95 \n", - "2018-04-29 11:00:00+00:00 133.549408 0.95 \n", + "2018-04-24 12:00:00+00:00 147.023743 0.95 \n", + "2018-04-25 00:00:00+00:00 6.955032 0.95 \n", + "2018-04-26 05:00:00+00:00 -37.196533 0.95 \n", + "2018-04-26 14:00:00+00:00 115.635132 0.95 \n", + "2018-04-27 02:00:00+00:00 2.516006 0.95 \n", + "2018-04-29 03:00:00+00:00 22.503326 0.95 \n", + "2018-04-24 04:00:00+00:00 -12.259079 0.95 \n", + "2018-04-24 14:00:00+00:00 126.519211 0.95 \n", "2018-04-26 11:00:00+00:00 120.90567 0.95 \n", "2018-04-27 13:00:00+00:00 162.023026 0.95 \n", - "2018-04-27 20:00:00+00:00 135.216156 0.95 \n", - "2018-04-28 05:00:00+00:00 5.645325 0.95 \n", - "2018-04-29 12:00:00+00:00 138.966232 0.95 \n", - "2018-04-25 03:00:00+00:00 -0.770828 0.95 \n", "\n", " prediction_interval_lower_bound prediction_interval_upper_bound \\\n", - " 287.352243 572.430105 \n", - " 186.949977 389.128758 \n", - " 87.964205 356.653776 \n", - " 67.082484 200.016332 \n", - " 35.78172 206.029621 \n", + " 98.736624 195.310862 \n", + " -6.094232 20.004297 \n", + " -88.759566 14.366499 \n", + " 30.120832 201.149432 \n", + " -69.095591 74.127604 \n", + " -38.714378 83.721031 \n", + " -45.377262 20.859104 \n", + " 96.837778 156.200644 \n", + " 35.781735 206.029606 \n", " 103.946307 220.099744 \n", - " 57.210032 213.22228 \n", - " -30.675206 41.965855 \n", - " 69.876807 208.055658 \n", - " -28.292754 26.751098 \n", "\n", "ai_forecast_status \n", " \n", @@ -811,8 +811,10 @@ } ], "source": [ + "import bigframes.bigquery as bbq\n", + "\n", "# Using all the data except the last week (2842-168) for training. And predict the last week (168).\n", - "result = df_grouped.head(2842-168).ai.forecast(timestamp_column=\"trip_hour\", data_column=\"num_trips\", horizon=168) \n", + "result = bbq.ai.forecast(df_grouped.head(2842-168), timestamp_col=\"trip_hour\", data_col=\"num_trips\", horizon=168) \n", "result" ] }, @@ -860,7 +862,7 @@ }, { "data": { - "image/png": "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wSpD4IQTTgAA3HLLLc3P9JCVZTGZTFAUhfPf/93f/R3uv//+5vtPf/rTOOqoo3DKKacAqAqa55xzDl75ylfii1/8IiaTCd773vd67zchhBBCCCGEEEIICcPOkOYRAMCePXvwmMc8Br/7u7+L0047DbfffjsuvvjipT/v93//9+O6667DDTfcgKOOOgrHHXdcr7/f2NjAhRdeiIsvvhg33HADXvGKV+D5z38+sizDddddh6uuugpPfOITceKJJ+K6667DHXfcgYc97GFLOhpCCCGEEEIIIYQQ0hcqEXcY73znOzGbzXD22WfjhS98IV796lcv/Tlf8pKXIM9znHHGGTjhhBNw44039vr7JzzhCTj99NPx4z/+4/i3//bf4qd+6qdwySWXAAD279+PT3ziE3jKU56CH/zBH8TFF1+MN7zhDXjyk5+8hCMhhBBCCCGEEEIIIS4IqTfY20HcfffdOProo3HXXXdh//79xu8OHz6Mb37zmzjttNOwvr6eaA8JAFxwwQW488478b73vS/1rgwCfrYJIYQQQgghhBASis3qazZUIhJCCCGEEEIIIYQQQjaFRcRdzo033oijjjpq4b++1mVCCCGEEEIIIYSQVWNalPh/r/82br3rcOpd2bEwWGWXc/LJJ2+a8HzyySd7bf9d73qX198TQgghhBBCCCGE+PLxr96BX//z6/HUR5yMtzzrrNS7syNhEXGXMxqN8JCHPCT1bhBCCCGEEEIIIYQsje8e2qge7zuSeE92LrQzE0IIIYQQQgghhJBBo3KFp8WOzBdeCVhEJIQQQgghhBBCCCGDpqxrh9OiTLsjOxgWEQkhhBBCCCGELJ27D0/xx5/8Bm656/7Uu0II2YWUjRKRRURXWEQkhBBCCCGEELJ03vuFb+PVf/33ePvHv5F6VwghuxClRJzRzuwMi4iEEEIIIYQQQpbOPYenACpFIiGExEb1RNygEtEZFhFJUC655BI88pGPjPp8J510EoQQeN/73hfteQkhhBBCCCH9UCqgsqQKiBASn6Iee6hEdIdFRLIlj3vc4/DCF75wW//3JS95Ca666qrl7lDN3//93+OVr3wl3v72t+OWW27Bk5/85CjPuwz6vMaEEEIIIYTsRFQ/shmLiISQBDBYxZ9R6h0gw0BKiaIocNRRR+Goo46K8pxf//rXAQA//dM/DSGE83am0ynG43Go3SKEEEIIIYR0oCbwBYuIhJAEyCZYhWOQK7tHiSglsHFfmn9y+x/Qxz3ucXjBC16A3/iN38Bxxx2HAwcO4JJLLgEA3HDDDRBC4Prrr2/+/5133gkhBK6++moAwNVXXw0hBD7ykY/grLPOwp49e/D4xz8et99+Oz70oQ/hYQ97GPbv349/9+/+HQ4dOrTl/lxwwQX4+Mc/jje96U0QQkAIgRtuuKF5ng996EM4++yzsba2hr/927+dszNfcMEF+Jmf+Rm88pWvxAknnID9+/fjV3/1V7GxsdH8n//xP/4HzjzzTOzZswfHH388zj33XNx3332b7tcll1yCpz71qQCALMuaImJZlrj00kvxL//lv8Ta2hoe+chH4sMf/nDzd+o1/G//7b/hsY99LNbX1/Fnf/ZnAIA//uM/xsMe9jCsr6/joQ99KN72trcZz/mtb30Lz3rWs3Dcccdh3759eNSjHoXrrrsOQFXQ/Omf/mmcdNJJOOqoo/CjP/qj+OhHP2r8/dve9jacfvrpWF9fx0knnYRnPvOZm77GhBBCCCGEDAlJJSIhJCFMZ/Zn9ygRp4eA15yc5rn/r5uByb5t//d3v/vdePGLX4zrrrsO1157LS644AKcc845OP3007e9jUsuuQRvfetbsXfvXvzcz/0cfu7nfg5ra2t4z3veg3vvvRdPe9rT8Ja3vAW/+Zu/uel23vSmN+FrX/safviHfxiXXnopAOCEE05oily/9Vu/hde//vV48IMfjGOPPbYpZupcddVVWF9fx9VXX40bbrgBv/RLv4Tjjz8ev/M7v4NbbrkFz3rWs/C6170OT3va03DPPffgk5/8ZHODsYiXvOQl+P7v/3780i/9Em655RZjf9/whjfg7W9/O8466yy8853vxE/91E/hy1/+svH6/dZv/Rbe8IY34KyzzmoKiS9/+cvx1re+FWeddRa++MUv4rnPfS727duH5zznObj33nvx2Mc+Ft/3fd+H97///Thw4AC+8IUvoCyrwefee+/FU57yFPzO7/wO1tbW8Cd/8id46lOfiq9+9at40IMehM997nN4wQtegP/yX/4LfuzHfgzf/e538clPfnLT15gQQgghhJAhoSbwVCISQlLQpjOziOjK7iki7iAe/vCH4xWveAUA4PTTT8db3/pWXHXVVb2KiK9+9atxzjnnAAAuvPBCvOxlL8PXv/51PPjBDwYAPPOZz8THPvaxLYuIRx99NCaTCfbu3YsDBw7M/f7SSy/FT/zET2y6jclkgne+853Yu3cvfuiHfgiXXnopXvrSl+JVr3oVbrnlFsxmMzz96U/HqaeeCgA488wztzy+o446CscccwwAGPv1+te/Hr/5m7+Jn//5nwcA/Kf/9J/wsY99DL//+7+Pyy67rPl/L3zhC/H0pz+9+f4Vr3gF3vCGNzQ/O+200/CVr3wFb3/72/Gc5zwH73nPe3DHHXfgs5/9LI477jgAwEMe8pDm7x/xiEfgEY94RPP9q171Krz3ve/F+9//fjz/+c/HjTfeiH379uEnf/In8YAHPACnnnoqzjrrrG29xoQQQgghhAyBZgLPIiIhJAEl7cze7J4i4nhvpQhM9dw9ePjDH258/8AHPhC333678zZOOukk7N27tykgqp995jOf6bXNLh71qEdt+X8e8YhHYO/e9jU4ePAg7r33Xtx00014xCMegSc84Qk488wzcd555+GJT3winvnMZ+LYY4/tvS933303br755qZ4qjjnnHPwd3/3dwv3+7777sPXv/51XHjhhXjuc5/b/Hw2m+Hoo48GAFx//fU466yzmgKizb333otLLrkEf/3Xf90URu+//37ceOONAICf+ImfwKmnnooHP/jBeNKTnoQnPelJeNrTnma8LoQQQgghhAyZVolIFRAhJD4qGX5alpBSemUr7FZ2TxFRiF6W4pTYIR9CCJRliSyrWljqVt/pdLrlNoQQC7fpy759fq9pnue48sorcc011+CKK67AW97yFvz2b/82rrvuOpx22mne+7cIfb/vvfdeAMAf/dEf4dGPfvTc/gHAnj17Nt3eS17yElx55ZV4/etfj4c85CHYs2cPnvnMZza9Hx/wgAfgC1/4Aq6++mpcccUVePnLX45LLrkEn/3sZxtFJSGEEEIIIUNGNlZCqoAIIfFRImgpq7YKo5xFxL7snmCVAaD65Ok9APWQlWUxmUxQFIXz3//d3/0d7r///ub7T3/60zjqqKNwyimnAKgKmueccw5e+cpX4otf/CImkwne+9739n6e/fv34+STT8anPvUp4+ef+tSncMYZZyz8u5NOOgknn3wyvvGNb+AhD3mI8U8VMh/+8Ifj+uuvx3e/+93ObXzqU5/CBRdcgKc97Wk488wzceDAgblwlNFo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cceOHejp6cH8+fMBANXV1Whubh7ye1u2bLH82gRBEARBEETmkIhIEARBEARxEjBt2jSsX78ehw4dQkdHR1ZOwHg8jk996lPYsWMH/vrXv+Kee+7BrbfemlE/xPG45JJLsGjRInzsYx/D5s2bsWHDBtx44404//zztVLriy66CG+//TaeeOIJ7N27F/fccw+2bdtm+bUJgiAIgiCIzCERkSAIgiAI4iTgjjvugMvlwvz581FdXY2mpqaMf/fiiy/GrFmzcN555+G6667D+9//ftx777227JckSfjjH/+I8vJynHfeebjkkkswY8YMPPPMM9pzLr/8cnz961/HnXfeidNPPx39/f248cYbbXl9giAIgiAIIjMkZXiDGYIgCIIgCIJQ+fjHP46enh784Q9/cHpXCIIgCIIgCAchJyJBEARBEARBEARBEARBEGNCIiJBEARBEARhiqamJoRCoVH/ZVMyTRAEQRAEQYgNlTMTBEEQBEEQpkgmkzh06NCoP582bRrcbnf+doggCIIgCILIGSQiEgRBEARBEARBEARBEAQxJlTOTBAEQRAEQRAEQRAEQRDEmJCISBAEQRAEQRAEQRAEQRDEmJCISBAEQRAEQRAEQRAEQRDEmJCISBAEQRAEQRAEQRAEQRDEmJCISBAEQRAEQRAEQRAEQRDEmJCISBAEQRAEQRAEQRAEQRDEmJCISBAEQRAEQRAEQRAEQRDEmJCISBAEQRAEQRAEQRAEQRDEmPx/iE/owHFEEpYAAAAASUVORK5CYII=", + "image/png": "iVBORw0KGgoAAAANSUhEUgAABREAAAKnCAYAAAARNgr5AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjMsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvZiW1igAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs/Xu8LEdd741/qntm1toXdm4n2TvREILEA8EAMfjANio8EBNCRLmJIkeJ5uBLfkEEHlB5zIEQEJADKJegHIWAIsdz9BEOIpeESABJCNcggoICIYFcBZKdZGfvNTNdvz+6q7uqpmet1VU1VbN6fd6v137NuuzV0z0zXd31rc/n+xFSSglCCCGEEEIIIYQQQgiZQ5Z6BwghhBBCCCGEEEIIIcsNi4iEEEIIIYQQQgghhJB1YRGREEIIIYQQQgghhBCyLiwiEkIIIYQQQgghhBBC1oVFREIIIYQQQgghhBBCyLqwiEgIIYQQQgghhBBCCFkXFhEJIYQQQgghhBBCCCHrwiIiIYQQQgghhBBCCCFkXQapd8CVoihw00034T73uQ+EEKl3hxBCCCGEEEIIIYSQLYWUEnfddRdOOOEEZNn6WsMtW0S86aabcOKJJ6beDUIIIYQQQgghhBBCtjQ33ngjfvAHf3Dd/7Nli4j3uc99AJQHuWfPnsR7QwghhBBCCCGEEELI1uLAgQM48cQT6zrbemzZIqKyMO/Zs4dFREIIIYQQQgghhBBCHNlMq0AGqxBCCCGEEEIIIYQQQtaFRURCCCGEEEIIIYQQQsi6sIhICCGEEEIIIYQQQghZly3bE3EzSCkxmUwwnU5T7woh3uR5jsFgsKk+BYQQQgghhBBCCCEh6W0RcW1tDTfffDMOHjyYelcICcbOnTtx/PHHYzQapd4VQgghhBBCCCGEbCN6WUQsigLf/OY3kec5TjjhBIxGI6q3yJZGSom1tTXcfvvt+OY3v4lTTjkFWcZuBIQQQgghhBBCCIlDL4uIa2trKIoCJ554Inbu3Jl6dwgJwo4dOzAcDvGtb30La2trWF1dTb1LhBBCCCGEEEII2Sb0WspEpRbpG/xME0IIIYQQQgghJAWsSBBCCCGEEEIIIYQQQtaFRURCCCGEEEIIIYQQQsi6sIhIgnLxxRfjYQ97WOrdIIQQQgghhBBCCCEBYRGRbMijH/1oPO95z9vU/33hC1+IK6+8crE7RAghhBBCCCGEEEKi0st0ZhIfKSWm0yl2796N3bt3p94dQgghhBBCCCGEEBKQbaNElFLi4NokyT8p5ab389GPfjSe+9zn4rd/+7dx9NFHY9++fbj44osBANdffz2EELjuuuvq/3/HHXdACIGrrroKAHDVVVdBCIEPf/jDOP3007Fjxw485jGPwW233YYPfvCDeNCDHoQ9e/bgl37pl3Dw4MEN9+f888/Hxz72MbzhDW+AEAJCCFx//fX183zwgx/EGWecgZWVFfzjP/7jjJ35/PPPxxOf+ES87GUvw7HHHos9e/bgN37jN7C2tlb/n7/5m7/Baaedhh07duCYY47BWWedhXvuuWfTrxkhhBBCCCGEEEIIWSzbRol473iKU1/y4STP/ZVLzsHO0eZf6ne+8514wQtegGuvvRbXXHMNzj//fJx55pk45ZRTNr2Niy++GG9+85uxc+dOPO1pT8PTnvY0rKys4N3vfjfuvvtuPOlJT8Kb3vQm/M7v/M6623nDG96Ar33ta/iRH/kRXHLJJQCAY489Ftdffz0A4Hd/93fx2te+Fve///1x1FFH1cVMnSuvvBKrq6u46qqrcP311+NXf/VXccwxx+D3f//3cfPNN+PpT386XvOa1+BJT3oS7rrrLnziE5/oVHglhBBCCCGEEEIIIYtl2xQRtxIPechD8NKXvhQAcMopp+DNb34zrrzyyk5FxFe84hU488wzAQAXXHABXvziF+PrX/867n//+wMAnvrUp+KjH/3ohkXEI444AqPRCDt37sS+fftmfn/JJZfgp3/6p9fdxmg0wtvf/nbs3LkTD37wg3HJJZfgRS96EV7+8pfj5ptvxmQywZOf/GScdNJJAIDTTjtt08dJCCGEEEIIIYQQQhbPtiki7hjm+Mol5yR77i485CEPMb4//vjjcdtttzlvY+/evdi5c2ddQFQ/+/SnP91pm208/OEP3/D/PPShD8XOnTvr7/fv34+7774bN954Ix760IfisY99LE477TScc845OPvss/HUpz4VRx11lPe+EUIIIYQQQgghhJAwbJsiohCik6U4JcPh0PheCIGiKJBlZQtL3eo7Ho833IYQYu42fdm1a5fX3+d5jiuuuAJXX301Lr/8crzpTW/C7/3e7+Haa6/FySef7L1/hBBCCCGEEEIIIcSfbROs0geOPfZY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FR/96Ec3LCIeccQRGI1G2LlzJ/bt2zfz+0suuQQ//dM/ve42RqMR3v72t2Pnzp148IMfjEsuuQQvetGL8PKXvxw333wzJpMJnvzkJ+Okk04CAJx22mkbHt/u3btx5JFHAoCxX6997WvxO7/zO/jFX/xFAMAf/MEf4KMf/Sj+6I/+CJdeemn9/573vOfhyU9+cv39S1/6Urzuda+rf3byySfjK1/5Ct761rfimc98Jt797nfj9ttvx2c+8xkcffTRAIAHPOAB9d8/9KEPxUMf+tD6+5e//OV4z3veg/e97314znOegxtuuAG7du3Cz/zMz+A+97kPTjrpJJx++umbeo0JIYQQQgjpA/UEnkVEQkgCCtqZvdk+RcThzlIRmOq5O/CQhzzE+P7444/Hbbfd5ryNvXv3YufOnXUBUf3s05/+dKdttvHwhz98w//z0Ic+FDt3Nq/B/v37cffdd+PGG2/EQx/6UDz2sY/FaaedhnPOOQdnn302nvrUp+Koo47qvC8HDhzATTfdVBdPFWeeeSa++MUvzt3ve+65B1//+tdxwQUX4FnPelb988lkgiOOOAIAcN111+H000+vC4g2d999Ny6++GL8/d//fV0Yvffee3HDDTcAAH76p38aJ510Eu5///vjcY97HB73uMfhSU96kvG6EEIIIYQQ0mcaJSJVQISQ+Khk+HFRQErpla2wXdk+RUQhOlmKU2KHfAghUBQFsqxsYalbfcfj8YbbEELM3aYvu3b5vaZ5nuOKK67A1VdfjcsvvxxvetOb8Hu/93u49tprcfLJJ3vv3zz0/b777rsBAH/6p3+KRzziETP7BwA7duxYd3svfOELccUVV+C1r30tHvCAB2DHjh146lOfWvd+vM997oPPf/7zuOqqq3D55ZfjJS95CS6++GJ85jOfqRWVhBBCCCGE9BlZWwmpAiKExEeJoKUs2yoMchYRu7J9glV6gOqTp/cA1ENWFsVoNMJ0OnX++y9+8Yu499576+8/9alPYffu3TjxxBMBlAXNM888Ey972cvwhS98AaPRCO95z3s6P8+ePXtwwgkn4JOf/KTx809+8pM49dRT5/7d3r17ccIJJ+Ab3/gGHvCABxj/VCHzIQ95CK677jp873vfa93GJz/5SZx//vl40pOehNNOOw379u2bCUcZDAY466yz8JrXvAb/9E//hOuvvx7/8A//AMD/NSaEEEIIIWTZUb0Q2ROREJKCQhNksa2CG9tHidgDduzYgUc+8pF49atfjZNPPhm33XYbLrroooU/7/3udz9ce+21uP7667F79+65lt55rK2t4YILLsBFF12E66+/Hi996UvxnOc8B1mW4dprr8WVV16Js88+G8cddxyuvfZa3H777XjQgx7ktK8vetGL8NKXvhQ/9EM/hIc97GG47LLLcN1119UJzPN42ctehuc+97k44ogj8LjHPQ6HDx/GZz/7WXz/+9/HC17wAjz96U/HK1/5SjzxiU/Eq171Khx//PH4whe+gBNOOAH79+/HKaecgr/927/FE57wBAgh8N/+238zlJ7vf//78Y1vfAM/9VM/haOOOgof+MAHUBQF/vN//s8A2l9jpTwlhBBCCCGkD9R25g1CFAkhZBHodcO1aYHVYZ5uZ7YorFJsMd7+9rdjMpngjDPOwPOe9zy84hWvWPhzvvCFL0Se5zj11FNx7LHH1n3+NstjH/tYnHLKKfipn/op/MIv/AJ+9md/FhdffDGAUj348Y9/HI9//OPxwz/8w7jooovwute9Dueee67Tvj73uc/FC17wAvw//8//g9NOOw0f+tCH8L73vW/DUJr/+l//K/7sz/4Ml112GU477TQ86lGPwjve8Y5aiTgajXD55ZfjuOOOw+Mf/3icdtppePWrX13bnV//+tfjqKOOwo//+I/jCU94As455xz86I/+aL39I488En/7t3+LxzzmMXjQgx6EP/mTP8H//J//Ew9+8IMB+L/GhBBCCCGELDuqdkglIiEkBXprOLZVcENIuTWXgQ4cOIAjjjgCd955J/bs2WP87tChQ/jmN7+Jk08+Gaurq4n2kADA+eefjzvuuAPvfe97U+9KL+BnmxBCCCGEbFVe8n/+GX9+zbdw6vF78IHf+snUu0MI2Wb89w//Ky796NcBANf+v4/F3j2cUwPr19dsqEQkhBBCCCGEELJwmnTmLaljWZe1CROnCVl29KFnPOU56wKLiNucG264Abt37577L6Wtdr39+sQnPpFsvwghhBBCyPLwr7ccwL/eciD1bpBNoCbwk6Jfk/e3fuzreMjLPozrbrwj9a4QQtah0KqIY9qZnWCwyjbnhBNOWDfh+YQTTvDa/jve8Q7nv11vv37gB37AebuEEEIIIaQfjKcFfv6PrwEE8Pn/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", "text/plain": [ "
" ] @@ -877,7 +879,7 @@ ], "metadata": { "kernelspec": { - "display_name": "venv", + "display_name": "venv (3.10.17)", "language": "python", "name": "python3" }, @@ -891,7 +893,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.16" + "version": "3.10.17" } }, "nbformat": 4, diff --git a/tests/system/small/bigquery/test_ai.py b/tests/system/small/bigquery/test_ai.py index f2aa477168..0c7c40031b 100644 --- a/tests/system/small/bigquery/test_ai.py +++ b/tests/system/small/bigquery/test_ai.py @@ -20,9 +20,10 @@ import pytest import sqlglot -from bigframes import dtypes, series +from bigframes import dataframe, dtypes, series import bigframes.bigquery as bbq import bigframes.pandas as bpd +from bigframes.testing import utils as test_utils def test_ai_function_pandas_input(session): @@ -325,5 +326,52 @@ def test_ai_score_multi_model(session): assert result.dtype == dtypes.FLOAT_DTYPE +def test_forecast_default_params(time_series_df_default_index: dataframe.DataFrame): + df = time_series_df_default_index[time_series_df_default_index["id"] == "1"] + + result = bbq.ai.forecast(df, timestamp_col="parsed_date", data_col="total_visits") + + expected_columns = [ + "forecast_timestamp", + "forecast_value", + "confidence_level", + "prediction_interval_lower_bound", + "prediction_interval_upper_bound", + "ai_forecast_status", + ] + test_utils.check_pandas_df_schema_and_index( + result, + columns=expected_columns, + index=10, + ) + + +def test_forecast_w_params(time_series_df_default_index: dataframe.DataFrame): + result = bbq.ai.forecast( + time_series_df_default_index, + timestamp_col="parsed_date", + data_col="total_visits", + id_cols=["id"], + horizon=20, + confidence_level=0.98, + context_window=64, + ) + + expected_columns = [ + "id", + "forecast_timestamp", + "forecast_value", + "confidence_level", + "prediction_interval_lower_bound", + "prediction_interval_upper_bound", + "ai_forecast_status", + ] + test_utils.check_pandas_df_schema_and_index( + result, + columns=expected_columns, + index=20 * 2, # 20 for each id + ) + + def _contains_no_nulls(s: series.Series) -> bool: return len(s) == s.count() diff --git a/tests/unit/test_dataframe.py b/tests/unit/test_dataframe.py index 6aaccd644e..2326f2595b 100644 --- a/tests/unit/test_dataframe.py +++ b/tests/unit/test_dataframe.py @@ -172,3 +172,12 @@ def test_dataframe_semantics_property_future_warning( FutureWarning ): dataframe.semantics + + +def test_dataframe_ai_property_future_warning( + monkeypatch: pytest.MonkeyPatch, +): + dataframe = mocks.create_dataframe(monkeypatch) + + with pytest.warns(FutureWarning): + dataframe.ai From 6eab1217e5d59d826547ebe342198aeeea21d5ee Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Mon, 27 Oct 2025 15:32:13 -0700 Subject: [PATCH 180/313] refactor: add parenthesization for binary operations (#2193) --- .../core/compile/sqlglot/scalar_compiler.py | 43 ++++++++++++++++++- .../compile/sqlglot/test_scalar_compiler.py | 37 ++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/bigframes/core/compile/sqlglot/scalar_compiler.py b/bigframes/core/compile/sqlglot/scalar_compiler.py index 8167f40fc3..1da58871c7 100644 --- a/bigframes/core/compile/sqlglot/scalar_compiler.py +++ b/bigframes/core/compile/sqlglot/scalar_compiler.py @@ -31,6 +31,37 @@ class ScalarOpCompiler: typing.Callable[[typing.Sequence[TypedExpr], ops.RowOp], sge.Expression], ] = {} + # A set of SQLGlot classes that may need to be parenthesized + SQLGLOT_NEEDS_PARENS = { + # Numeric operations + sge.Add, + sge.Sub, + sge.Mul, + sge.Div, + sge.Mod, + sge.Pow, + # Comparison operations + sge.GTE, + sge.GT, + sge.LTE, + sge.LT, + sge.EQ, + sge.NEQ, + # Logical operations + sge.And, + sge.Or, + sge.Xor, + # Bitwise operations + sge.BitwiseAnd, + sge.BitwiseOr, + sge.BitwiseXor, + sge.BitwiseLeftShift, + sge.BitwiseRightShift, + sge.BitwiseNot, + # Other operations + sge.Is, + } + @functools.singledispatchmethod def compile_expression( self, @@ -110,10 +141,12 @@ def register_binary_op( def decorator(impl: typing.Callable[..., sge.Expression]): def normalized_impl(args: typing.Sequence[TypedExpr], op: ops.RowOp): + left = self._add_parentheses(args[0]) + right = self._add_parentheses(args[1]) if pass_op: - return impl(args[0], args[1], op) + return impl(left, right, op) else: - return impl(args[0], args[1]) + return impl(left, right) self._register(key, normalized_impl) return impl @@ -177,6 +210,12 @@ def _register( raise ValueError(f"Operation name {op_name} already registered") self._registry[op_name] = impl + @classmethod + def _add_parentheses(cls, expr: TypedExpr) -> TypedExpr: + if type(expr.expr) in cls.SQLGLOT_NEEDS_PARENS: + return TypedExpr(sge.paren(expr.expr, copy=False), expr.dtype) + return expr + # Singleton compiler scalar_op_compiler = ScalarOpCompiler() diff --git a/tests/unit/core/compile/sqlglot/test_scalar_compiler.py b/tests/unit/core/compile/sqlglot/test_scalar_compiler.py index a2ee2c6331..14d7b47389 100644 --- a/tests/unit/core/compile/sqlglot/test_scalar_compiler.py +++ b/tests/unit/core/compile/sqlglot/test_scalar_compiler.py @@ -170,6 +170,43 @@ def _(*args: TypedExpr, op: ops.NaryOp) -> sge.Expression: mock_impl.assert_called_once_with(arg1, arg2, arg3, arg4, op=mock_op) +def test_binary_op_parentheses(): + compiler = scalar_compiler.ScalarOpCompiler() + + class MockAddOp(ops.BinaryOp): + name = "mock_add_op" + + class MockMulOp(ops.BinaryOp): + name = "mock_mul_op" + + add_op = MockAddOp() + mul_op = MockMulOp() + + @compiler.register_binary_op(add_op) + def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.Add(this=left.expr, expression=right.expr) + + @compiler.register_binary_op(mul_op) + def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.Mul(this=left.expr, expression=right.expr) + + a = TypedExpr(sge.Identifier(this="a"), "int") + b = TypedExpr(sge.Identifier(this="b"), "int") + c = TypedExpr(sge.Identifier(this="c"), "int") + + # (a + b) * c + add_expr = compiler.compile_row_op(add_op, [a, b]) + add_typed_expr = TypedExpr(add_expr, "int") + result1 = compiler.compile_row_op(mul_op, [add_typed_expr, c]) + assert result1.sql() == "(a + b) * c" + + # a * (b + c) + add_expr_2 = compiler.compile_row_op(add_op, [b, c]) + add_typed_expr_2 = TypedExpr(add_expr_2, "int") + result2 = compiler.compile_row_op(mul_op, [a, add_typed_expr_2]) + assert result2.sql() == "a * (b + c)" + + def test_register_duplicate_op_raises(): compiler = scalar_compiler.ScalarOpCompiler() From 86a27564b48b854a32b3d11cd2105aa0fa496279 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Mon, 27 Oct 2025 15:34:23 -0700 Subject: [PATCH 181/313] feat: Support some python standard lib callables in apply/combine (#2187) --- bigframes/core/compile/polars/compiler.py | 20 +++ bigframes/core/compile/polars/lowering.py | 21 ++- .../compile/polars/operations/numeric_ops.py | 81 +++++++++-- bigframes/operations/python_op_maps.py | 85 ++++++++++++ bigframes/operations/string_ops.py | 17 +++ bigframes/series.py | 130 ++++++++++-------- tests/system/small/operations/test_lists.py | 30 ++++ tests/system/small/test_polars_execution.py | 13 +- tests/unit/test_series_polars.py | 92 +++++++++++++ 9 files changed, 412 insertions(+), 77 deletions(-) create mode 100644 bigframes/operations/python_op_maps.py diff --git a/bigframes/core/compile/polars/compiler.py b/bigframes/core/compile/polars/compiler.py index acaf1b8f22..d48ddba0cc 100644 --- a/bigframes/core/compile/polars/compiler.py +++ b/bigframes/core/compile/polars/compiler.py @@ -328,6 +328,26 @@ def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: assert isinstance(op, string_ops.StrContainsRegexOp) return input.str.contains(pattern=op.pat, literal=False) + @compile_op.register(string_ops.UpperOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, string_ops.UpperOp) + return input.str.to_uppercase() + + @compile_op.register(string_ops.LowerOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, string_ops.LowerOp) + return input.str.to_lowercase() + + @compile_op.register(string_ops.ArrayLenOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, string_ops.ArrayLenOp) + return input.list.len() + + @compile_op.register(string_ops.StrLenOp) + def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: + assert isinstance(op, string_ops.StrLenOp) + return input.str.len_chars() + @compile_op.register(string_ops.StartsWithOp) def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: assert isinstance(op, string_ops.StartsWithOp) diff --git a/bigframes/core/compile/polars/lowering.py b/bigframes/core/compile/polars/lowering.py index 876ff2794f..bf617d6879 100644 --- a/bigframes/core/compile/polars/lowering.py +++ b/bigframes/core/compile/polars/lowering.py @@ -27,6 +27,7 @@ generic_ops, json_ops, numeric_ops, + string_ops, ) import bigframes.operations as ops @@ -347,11 +348,28 @@ def lower(self, expr: expression.OpExpression) -> expression.Expression: return ops.coalesce_op.as_expr(new_isin, expression.const(False)) +class LowerLenOp(op_lowering.OpLoweringRule): + @property + def op(self) -> type[ops.ScalarOp]: + return string_ops.LenOp + + def lower(self, expr: expression.OpExpression) -> expression.Expression: + assert isinstance(expr.op, string_ops.LenOp) + arg = expr.children[0] + + if dtypes.is_string_like(arg.output_type): + return string_ops.StrLenOp().as_expr(arg) + elif dtypes.is_array_like(arg.output_type): + return string_ops.ArrayLenOp().as_expr(arg) + else: + raise ValueError(f"Unexpected type: {arg.output_type}") + + def _coerce_comparables( expr1: expression.Expression, expr2: expression.Expression, *, - bools_only: bool = False + bools_only: bool = False, ): if bools_only: if ( @@ -446,6 +464,7 @@ def _lower_cast(cast_op: ops.AsTypeOp, arg: expression.Expression): LowerAsTypeRule(), LowerInvertOp(), LowerIsinOp(), + LowerLenOp(), ) diff --git a/bigframes/core/compile/polars/operations/numeric_ops.py b/bigframes/core/compile/polars/operations/numeric_ops.py index 8e44f15955..440415014e 100644 --- a/bigframes/core/compile/polars/operations/numeric_ops.py +++ b/bigframes/core/compile/polars/operations/numeric_ops.py @@ -29,15 +29,6 @@ import polars as pl -@polars_compiler.register_op(numeric_ops.CosOp) -def cos_op_impl( - compiler: polars_compiler.PolarsExpressionCompiler, - op: numeric_ops.CosOp, # type: ignore - input: pl.Expr, -) -> pl.Expr: - return input.cos() - - @polars_compiler.register_op(numeric_ops.LnOp) def ln_op_impl( compiler: polars_compiler.PolarsExpressionCompiler, @@ -80,6 +71,78 @@ def sin_op_impl( return input.sin() +@polars_compiler.register_op(numeric_ops.CosOp) +def cos_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.CosOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.cos() + + +@polars_compiler.register_op(numeric_ops.TanOp) +def tan_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.SinOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.tan() + + +@polars_compiler.register_op(numeric_ops.SinhOp) +def sinh_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.SinOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.sinh() + + +@polars_compiler.register_op(numeric_ops.CoshOp) +def cosh_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.CosOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.cosh() + + +@polars_compiler.register_op(numeric_ops.TanhOp) +def tanh_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.SinOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.tanh() + + +@polars_compiler.register_op(numeric_ops.ArcsinOp) +def asin_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.ArcsinOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.arcsin() + + +@polars_compiler.register_op(numeric_ops.ArccosOp) +def acos_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.ArccosOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.arccos() + + +@polars_compiler.register_op(numeric_ops.ArctanOp) +def atan_op_impl( + compiler: polars_compiler.PolarsExpressionCompiler, + op: numeric_ops.ArctanOp, # type: ignore + input: pl.Expr, +) -> pl.Expr: + return input.arctan() + + @polars_compiler.register_op(numeric_ops.SqrtOp) def sqrt_op_impl( compiler: polars_compiler.PolarsExpressionCompiler, diff --git a/bigframes/operations/python_op_maps.py b/bigframes/operations/python_op_maps.py new file mode 100644 index 0000000000..39f153ec05 --- /dev/null +++ b/bigframes/operations/python_op_maps.py @@ -0,0 +1,85 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math +import operator +from typing import Optional + +import bigframes.operations +from bigframes.operations import ( + aggregations, + array_ops, + bool_ops, + comparison_ops, + numeric_ops, + string_ops, +) + +PYTHON_TO_BIGFRAMES = { + ## operators + operator.add: numeric_ops.add_op, + operator.sub: numeric_ops.sub_op, + operator.mul: numeric_ops.mul_op, + operator.truediv: numeric_ops.div_op, + operator.floordiv: numeric_ops.floordiv_op, + operator.mod: numeric_ops.mod_op, + operator.pow: numeric_ops.pow_op, + operator.pos: numeric_ops.pos_op, + operator.neg: numeric_ops.neg_op, + operator.abs: numeric_ops.abs_op, + operator.eq: comparison_ops.eq_op, + operator.ne: comparison_ops.ne_op, + operator.gt: comparison_ops.gt_op, + operator.lt: comparison_ops.lt_op, + operator.ge: comparison_ops.ge_op, + operator.le: comparison_ops.le_op, + operator.and_: bool_ops.and_op, + operator.or_: bool_ops.or_op, + operator.xor: bool_ops.xor_op, + ## math + math.log: numeric_ops.ln_op, + math.log10: numeric_ops.log10_op, + math.log1p: numeric_ops.log1p_op, + math.expm1: numeric_ops.expm1_op, + math.sin: numeric_ops.sin_op, + math.cos: numeric_ops.cos_op, + math.tan: numeric_ops.tan_op, + math.sinh: numeric_ops.sinh_op, + math.cosh: numeric_ops.cosh_op, + math.tanh: numeric_ops.tanh_op, + math.asin: numeric_ops.arcsin_op, + math.acos: numeric_ops.arccos_op, + math.atan: numeric_ops.arctan_op, + math.floor: numeric_ops.floor_op, + math.ceil: numeric_ops.ceil_op, + ## str + str.upper: string_ops.upper_op, + str.lower: string_ops.lower_op, + ## builtins + len: string_ops.len_op, + abs: numeric_ops.abs_op, + pow: numeric_ops.pow_op, + ### builtins -- iterable + all: array_ops.ArrayReduceOp(aggregations.all_op), + any: array_ops.ArrayReduceOp(aggregations.any_op), + sum: array_ops.ArrayReduceOp(aggregations.sum_op), + min: array_ops.ArrayReduceOp(aggregations.min_op), + max: array_ops.ArrayReduceOp(aggregations.max_op), +} + + +def python_callable_to_op(obj) -> Optional[bigframes.operations.RowOp]: + if obj in PYTHON_TO_BIGFRAMES: + return PYTHON_TO_BIGFRAMES[obj] + return None diff --git a/bigframes/operations/string_ops.py b/bigframes/operations/string_ops.py index f937ed23b6..a50a1b39f6 100644 --- a/bigframes/operations/string_ops.py +++ b/bigframes/operations/string_ops.py @@ -30,6 +30,23 @@ ) len_op = LenOp() +## Specialized len ops for compile-time lowering +StrLenOp = base_ops.create_unary_op( + name="strlen", + type_signature=op_typing.FixedOutputType( + dtypes.is_string_like, dtypes.INT_DTYPE, description="string-like" + ), +) +str_len_op = StrLenOp() + +ArrayLenOp = base_ops.create_unary_op( + name="arraylen", + type_signature=op_typing.FixedOutputType( + dtypes.is_array_like, dtypes.INT_DTYPE, description="array-like" + ), +) +array_len_op = ArrayLenOp() + ReverseOp = base_ops.create_unary_op( name="reverse", type_signature=op_typing.STRING_TRANSFORM ) diff --git a/bigframes/series.py b/bigframes/series.py index ad1f091803..ef0da32dfc 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -74,6 +74,7 @@ import bigframes.operations.datetimes as dt import bigframes.operations.lists as lists import bigframes.operations.plotting as plotting +import bigframes.operations.python_op_maps as python_ops import bigframes.operations.structs as structs import bigframes.session @@ -2033,88 +2034,97 @@ def apply( if by_row not in ["compat", False]: raise ValueError("Param by_row must be one of 'compat' or False") - if not callable(func): + if not callable(func) and not isinstance(func, numpy.ufunc): raise ValueError( "Only a ufunc (a function that applies to the entire Series) or" " a BigFrames BigQuery function that only works on single values" " are supported." ) - if not isinstance(func, bigframes.functions.BigqueryCallableRoutine): - # It is neither a remote function nor a managed function. - # Then it must be a vectorized function that applies to the Series - # as a whole. - if by_row: - raise ValueError( - "You have passed a function as-is. If your intention is to " - "apply this function in a vectorized way (i.e. to the " - "entire Series as a whole, and you are sure that it " - "performs only the operations that are implemented for a " - "Series (e.g. a chain of arithmetic/logical operations, " - "such as `def foo(s): return s % 2 == 1`), please also " - "specify `by_row=False`. If your function contains " - "arbitrary code, it can only be applied to every element " - "in the Series individually, in which case you must " - "convert it to a BigFrames BigQuery function using " - "`bigframes.pandas.udf`, " - "or `bigframes.pandas.remote_function` before passing." + if isinstance(func, bigframes.functions.BigqueryCallableRoutine): + # We are working with bigquery function at this point + if args: + result_series = self._apply_nary_op( + ops.NaryRemoteFunctionOp(function_def=func.udf_def), args + ) + # TODO(jialuo): Investigate why `_apply_nary_op` drops the series + # `name`. Manually reassigning it here as a temporary fix. + result_series.name = self.name + else: + result_series = self._apply_unary_op( + ops.RemoteFunctionOp(function_def=func.udf_def, apply_on_null=True) ) + result_series = func._post_process_series(result_series) - try: - return func(self) - except Exception as ex: - # This could happen if any of the operators in func is not - # supported on a Series. Let's guide the customer to use a - # bigquery function instead - if hasattr(ex, "message"): - ex.message += f"\n{_bigquery_function_recommendation_message}" - raise - - # We are working with bigquery function at this point - if args: - result_series = self._apply_nary_op( - ops.NaryRemoteFunctionOp(function_def=func.udf_def), args - ) - # TODO(jialuo): Investigate why `_apply_nary_op` drops the series - # `name`. Manually reassigning it here as a temporary fix. - result_series.name = self.name - else: - result_series = self._apply_unary_op( - ops.RemoteFunctionOp(function_def=func.udf_def, apply_on_null=True) + return result_series + + bf_op = python_ops.python_callable_to_op(func) + if bf_op and isinstance(bf_op, ops.UnaryOp): + return self._apply_unary_op(bf_op) + + # It is neither a remote function nor a managed function. + # Then it must be a vectorized function that applies to the Series + # as a whole. + if by_row: + raise ValueError( + "You have passed a function as-is. If your intention is to " + "apply this function in a vectorized way (i.e. to the " + "entire Series as a whole, and you are sure that it " + "performs only the operations that are implemented for a " + "Series (e.g. a chain of arithmetic/logical operations, " + "such as `def foo(s): return s % 2 == 1`), please also " + "specify `by_row=False`. If your function contains " + "arbitrary code, it can only be applied to every element " + "in the Series individually, in which case you must " + "convert it to a BigFrames BigQuery function using " + "`bigframes.pandas.udf`, " + "or `bigframes.pandas.remote_function` before passing." ) - result_series = func._post_process_series(result_series) - return result_series + try: + return func(self) # type: ignore + except Exception as ex: + # This could happen if any of the operators in func is not + # supported on a Series. Let's guide the customer to use a + # bigquery function instead + if hasattr(ex, "message"): + ex.message += f"\n{_bigquery_function_recommendation_message}" + raise def combine( self, other, func, ) -> Series: - if not callable(func): + if not callable(func) and not isinstance(func, numpy.ufunc): raise ValueError( "Only a ufunc (a function that applies to the entire Series) or" " a BigFrames BigQuery function that only works on single values" " are supported." ) - if not isinstance(func, bigframes.functions.BigqueryCallableRoutine): - # Keep this in sync with .apply - try: - return func(self, other) - except Exception as ex: - # This could happen if any of the operators in func is not - # supported on a Series. Let's guide the customer to use a - # bigquery function instead - if hasattr(ex, "message"): - ex.message += f"\n{_bigquery_function_recommendation_message}" - raise - - result_series = self._apply_binary_op( - other, ops.BinaryRemoteFunctionOp(function_def=func.udf_def) - ) - result_series = func._post_process_series(result_series) - return result_series + if isinstance(func, bigframes.functions.BigqueryCallableRoutine): + result_series = self._apply_binary_op( + other, ops.BinaryRemoteFunctionOp(function_def=func.udf_def) + ) + result_series = func._post_process_series(result_series) + return result_series + + bf_op = python_ops.python_callable_to_op(func) + if bf_op and isinstance(bf_op, ops.BinaryOp): + result_series = self._apply_binary_op(other, bf_op) + return result_series + + # Keep this in sync with .apply + try: + return func(self, other) + except Exception as ex: + # This could happen if any of the operators in func is not + # supported on a Series. Let's guide the customer to use a + # bigquery function instead + if hasattr(ex, "message"): + ex.message += f"\n{_bigquery_function_recommendation_message}" + raise @validations.requires_index def add_prefix(self, prefix: str, axis: int | str | None = None) -> Series: diff --git a/tests/system/small/operations/test_lists.py b/tests/system/small/operations/test_lists.py index fda01a5dae..16a6802572 100644 --- a/tests/system/small/operations/test_lists.py +++ b/tests/system/small/operations/test_lists.py @@ -106,3 +106,33 @@ def test_len(column_name, dtype, repeated_df, repeated_pandas_df): check_index_type=False, check_names=False, ) + + +@pytest.mark.parametrize( + ("column_name", "dtype"), + [ + pytest.param("int_list_col", pd.ArrowDtype(pa.list_(pa.int64()))), + pytest.param("float_list_col", pd.ArrowDtype(pa.list_(pa.float64()))), + ], +) +@pytest.mark.parametrize( + ("func",), + [ + pytest.param(len), + pytest.param(all), + pytest.param(any), + pytest.param(min), + pytest.param(max), + pytest.param(sum), + ], +) +def test_list_apply_callable(column_name, dtype, repeated_df, repeated_pandas_df, func): + bf_result = repeated_df[column_name].apply(func).to_pandas() + pd_result = repeated_pandas_df[column_name].astype(dtype).apply(func) + pd_result.index = pd_result.index.astype("Int64") + + assert_series_equal( + pd_result, + bf_result, + check_dtype=False, + ) diff --git a/tests/system/small/test_polars_execution.py b/tests/system/small/test_polars_execution.py index 916780b1ce..46eb59260b 100644 --- a/tests/system/small/test_polars_execution.py +++ b/tests/system/small/test_polars_execution.py @@ -11,9 +11,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import math + import pytest import bigframes +import bigframes.bigquery from bigframes.testing.utils import assert_pandas_df_equal polars = pytest.importorskip("polars") @@ -63,13 +66,9 @@ def test_polar_execution_unsupported_sql_fallback( execution_count_before = session_w_polars._metrics.execution_count bf_df = session_w_polars.read_pandas(scalars_pandas_df_index) - pd_df = scalars_pandas_df_index.copy() - pd_df["str_len_col"] = pd_df.string_col.str.len() - pd_result = pd_df - - bf_df["str_len_col"] = bf_df.string_col.str.len() + bf_df["geo_area"] = bigframes.bigquery.st_length(bf_df.geography_col) bf_result = bf_df.to_pandas() - # str len not supported by polar engine yet, so falls back to bq execution + # geo fns not supported by polar engine yet, so falls back to bq execution assert session_w_polars._metrics.execution_count == (execution_count_before + 1) - assert_pandas_df_equal(bf_result, pd_result) + assert math.isclose(bf_result.geo_area.sum(), 70.52332050, rel_tol=0.00001) diff --git a/tests/unit/test_series_polars.py b/tests/unit/test_series_polars.py index e978ed43da..55bc048bcd 100644 --- a/tests/unit/test_series_polars.py +++ b/tests/unit/test_series_polars.py @@ -15,6 +15,7 @@ import datetime as dt import json import math +import operator import pathlib import re import tempfile @@ -4653,6 +4654,74 @@ def test_apply_numpy_ufunc(scalars_dfs, ufunc): assert_series_equal(bf_result, pd_result) +@pytest.mark.parametrize( + ("ufunc",), + [ + pytest.param(math.log), + pytest.param(math.log10), + pytest.param(math.sin), + pytest.param(math.cos), + pytest.param(math.tan), + pytest.param(math.sinh), + pytest.param(math.cosh), + pytest.param(math.tanh), + pytest.param(math.asin), + pytest.param(math.acos), + pytest.param(math.atan), + pytest.param(abs), + ], +) +@pytest.mark.parametrize( + ("col",), + [pytest.param("float64_col"), pytest.param("int64_col")], +) +def test_series_apply_python_numeric_fns(scalars_dfs, ufunc, col): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df[col] + bf_result = bf_col.apply(ufunc).to_pandas() + + pd_col = scalars_pandas_df[col] + + def wrapped(x): + try: + return ufunc(x) + except ValueError: + return pd.NA + except OverflowError: + if ufunc == math.sinh and x < 0: + return float("-inf") + return float("inf") + + pd_result = pd_col.apply(wrapped) + + assert_series_equal(bf_result, pd_result, check_dtype=False) + + +@pytest.mark.parametrize( + ("ufunc",), + [ + pytest.param(str.upper), + pytest.param(str.lower), + pytest.param(len), + ], +) +def test_series_apply_python_string_fns(scalars_dfs, ufunc): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df["string_col"] + bf_result = bf_col.apply(ufunc).to_pandas() + + pd_col = scalars_pandas_df["string_col"] + + def wrapped(x): + return ufunc(x) if isinstance(x, str) else None + + pd_result = pd_col.apply(wrapped) + + assert_series_equal(bf_result, pd_result, check_dtype=False) + + @pytest.mark.parametrize( ("ufunc",), [ @@ -4676,6 +4745,29 @@ def test_combine_series_ufunc(scalars_dfs, ufunc): assert_series_equal(bf_result, pd_result, check_dtype=False) +@pytest.mark.parametrize( + ("func",), + [ + pytest.param(operator.add), + pytest.param(operator.truediv), + ], + ids=[ + "add", + "divide", + ], +) +def test_combine_series_pyfunc(scalars_dfs, func): + scalars_df, scalars_pandas_df = scalars_dfs + + bf_col = scalars_df["int64_col"].dropna() + bf_result = bf_col.combine(bf_col, func).to_pandas() + + pd_col = scalars_pandas_df["int64_col"].dropna() + pd_result = pd_col.combine(pd_col, func) + + assert_series_equal(bf_result, pd_result, check_dtype=False) + + def test_combine_scalar_ufunc(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs From 3e6299fee9204a63e97eb1910e97718ad5d23df5 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Mon, 27 Oct 2025 15:42:46 -0700 Subject: [PATCH 182/313] refactor: enable engine tests for datetime ops (#2196) --- .../compile/sqlglot/expressions/date_ops.py | 21 ++++++--- .../sqlglot/expressions/datetime_ops.py | 24 +++++++++- .../system/small/engines/test_temporal_ops.py | 4 +- .../test_datetime_ops/test_dayofweek/out.sql | 12 +++-- .../test_datetime_ops/test_floor_dt/out.sql | 29 ++++++++++-- .../test_datetime_ops/test_iso_day/out.sql | 2 +- .../sqlglot/expressions/test_datetime_ops.py | 44 ++++++++++++++----- 7 files changed, 110 insertions(+), 26 deletions(-) diff --git a/bigframes/core/compile/sqlglot/expressions/date_ops.py b/bigframes/core/compile/sqlglot/expressions/date_ops.py index f5922ecc8d..be772d978d 100644 --- a/bigframes/core/compile/sqlglot/expressions/date_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/date_ops.py @@ -35,10 +35,7 @@ def _(expr: TypedExpr) -> sge.Expression: @register_unary_op(ops.dayofweek_op) def _(expr: TypedExpr) -> sge.Expression: - # Adjust the 1-based day-of-week index (from SQL) to a 0-based index. - return sge.Extract( - this=sge.Identifier(this="DAYOFWEEK"), expression=expr.expr - ) - sge.convert(1) + return dayofweek_op_impl(expr) @register_unary_op(ops.dayofyear_op) @@ -48,7 +45,8 @@ def _(expr: TypedExpr) -> sge.Expression: @register_unary_op(ops.iso_day_op) def _(expr: TypedExpr) -> sge.Expression: - return sge.Extract(this=sge.Identifier(this="DAYOFWEEK"), expression=expr.expr) + # Plus 1 because iso day of week uses 1-based indexing + return dayofweek_op_impl(expr) + sge.convert(1) @register_unary_op(ops.iso_week_op) @@ -59,3 +57,16 @@ def _(expr: TypedExpr) -> sge.Expression: @register_unary_op(ops.iso_year_op) def _(expr: TypedExpr) -> sge.Expression: return sge.Extract(this=sge.Identifier(this="ISOYEAR"), expression=expr.expr) + + +# Helpers +def dayofweek_op_impl(expr: TypedExpr) -> sge.Expression: + # BigQuery SQL Extract(DAYOFWEEK) returns 1 for Sunday through 7 for Saturday. + # We want 0 for Monday through 6 for Sunday to be compatible with Pandas. + extract_expr = sge.Extract( + this=sge.Identifier(this="DAYOFWEEK"), expression=expr.expr + ) + return sge.Cast( + this=sge.Mod(this=extract_expr + sge.convert(5), expression=sge.convert(7)), + to="INT64", + ) diff --git a/bigframes/core/compile/sqlglot/expressions/datetime_ops.py b/bigframes/core/compile/sqlglot/expressions/datetime_ops.py index 77f4233e1c..949b122a1d 100644 --- a/bigframes/core/compile/sqlglot/expressions/datetime_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/datetime_ops.py @@ -25,8 +25,28 @@ @register_unary_op(ops.FloorDtOp, pass_op=True) def _(expr: TypedExpr, op: ops.FloorDtOp) -> sge.Expression: - # TODO: Remove this method when it is covered by ops.FloorOp - return sge.TimestampTrunc(this=expr.expr, unit=sge.Identifier(this=op.freq)) + pandas_to_bq_freq_map = { + "Y": "YEAR", + "Q": "QUARTER", + "M": "MONTH", + "W": "WEEK(MONDAY)", + "D": "DAY", + "h": "HOUR", + "min": "MINUTE", + "s": "SECOND", + "ms": "MILLISECOND", + "us": "MICROSECOND", + "ns": "NANOSECOND", + } + if op.freq not in pandas_to_bq_freq_map.keys(): + raise NotImplementedError( + f"Unsupported freq paramater: {op.freq}" + + " Supported freq parameters are: " + + ",".join(pandas_to_bq_freq_map.keys()) + ) + + bq_freq = pandas_to_bq_freq_map[op.freq] + return sge.TimestampTrunc(this=expr.expr, unit=sge.Identifier(this=bq_freq)) @register_unary_op(ops.hour_op) diff --git a/tests/system/small/engines/test_temporal_ops.py b/tests/system/small/engines/test_temporal_ops.py index 5a39587886..66edfeddcc 100644 --- a/tests/system/small/engines/test_temporal_ops.py +++ b/tests/system/small/engines/test_temporal_ops.py @@ -25,7 +25,7 @@ REFERENCE_ENGINE = polars_executor.PolarsExecutor() -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_dt_floor(scalars_array_value: array_value.ArrayValue, engine): arr, _ = scalars_array_value.compute_values( [ @@ -46,7 +46,7 @@ def test_engines_dt_floor(scalars_array_value: array_value.ArrayValue, engine): assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_date_accessors(scalars_array_value: array_value.ArrayValue, engine): datelike_cols = ["datetime_col", "timestamp_col", "date_col"] accessors = [ diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofweek/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofweek/out.sql index e6c17587d0..55d3832c1f 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofweek/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofweek/out.sql @@ -1,13 +1,19 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `date_col` AS `bfcol_0`, + `datetime_col` AS `bfcol_1`, + `timestamp_col` AS `bfcol_2` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - EXTRACT(DAYOFWEEK FROM `bfcol_0`) - 1 AS `bfcol_1` + CAST(MOD(EXTRACT(DAYOFWEEK FROM `bfcol_1`) + 5, 7) AS INT64) AS `bfcol_6`, + CAST(MOD(EXTRACT(DAYOFWEEK FROM `bfcol_2`) + 5, 7) AS INT64) AS `bfcol_7`, + CAST(MOD(EXTRACT(DAYOFWEEK FROM `bfcol_0`) + 5, 7) AS INT64) AS `bfcol_8` FROM `bfcte_0` ) SELECT - `bfcol_1` AS `timestamp_col` + `bfcol_6` AS `datetime_col`, + `bfcol_7` AS `timestamp_col`, + `bfcol_8` AS `date_col` FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_floor_dt/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_floor_dt/out.sql index ad4fdb23a1..a8877f8cfa 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_floor_dt/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_floor_dt/out.sql @@ -1,13 +1,36 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `datetime_col` AS `bfcol_0`, + `timestamp_col` AS `bfcol_1` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - TIMESTAMP_TRUNC(`bfcol_0`, D) AS `bfcol_1` + TIMESTAMP_TRUNC(`bfcol_1`, MICROSECOND) AS `bfcol_2`, + TIMESTAMP_TRUNC(`bfcol_1`, MILLISECOND) AS `bfcol_3`, + TIMESTAMP_TRUNC(`bfcol_1`, SECOND) AS `bfcol_4`, + TIMESTAMP_TRUNC(`bfcol_1`, MINUTE) AS `bfcol_5`, + TIMESTAMP_TRUNC(`bfcol_1`, HOUR) AS `bfcol_6`, + TIMESTAMP_TRUNC(`bfcol_1`, DAY) AS `bfcol_7`, + TIMESTAMP_TRUNC(`bfcol_1`, WEEK(MONDAY)) AS `bfcol_8`, + TIMESTAMP_TRUNC(`bfcol_1`, MONTH) AS `bfcol_9`, + TIMESTAMP_TRUNC(`bfcol_1`, QUARTER) AS `bfcol_10`, + TIMESTAMP_TRUNC(`bfcol_1`, YEAR) AS `bfcol_11`, + TIMESTAMP_TRUNC(`bfcol_0`, MICROSECOND) AS `bfcol_12`, + TIMESTAMP_TRUNC(`bfcol_0`, MICROSECOND) AS `bfcol_13` FROM `bfcte_0` ) SELECT - `bfcol_1` AS `timestamp_col` + `bfcol_2` AS `timestamp_col_us`, + `bfcol_3` AS `timestamp_col_ms`, + `bfcol_4` AS `timestamp_col_s`, + `bfcol_5` AS `timestamp_col_min`, + `bfcol_6` AS `timestamp_col_h`, + `bfcol_7` AS `timestamp_col_D`, + `bfcol_8` AS `timestamp_col_W`, + `bfcol_9` AS `timestamp_col_M`, + `bfcol_10` AS `timestamp_col_Q`, + `bfcol_11` AS `timestamp_col_Y`, + `bfcol_12` AS `datetime_col_q`, + `bfcol_13` AS `datetime_col_us` FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_day/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_day/out.sql index d389172fda..f7203fc930 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_day/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_day/out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - EXTRACT(DAYOFWEEK FROM `bfcol_0`) AS `bfcol_1` + CAST(MOD(EXTRACT(DAYOFWEEK FROM `bfcol_0`) + 5, 7) AS INT64) + 1 AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py index 91926e7bdd..3261113806 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py @@ -39,12 +39,11 @@ def test_day(scalar_types_df: bpd.DataFrame, snapshot): def test_dayofweek(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( - bf_df, [ops.dayofweek_op.as_expr(col_name)], [col_name] - ) + col_names = ["datetime_col", "timestamp_col", "date_col"] + bf_df = scalar_types_df[col_names] + ops_map = {col_name: ops.dayofweek_op.as_expr(col_name) for col_name in col_names} + sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") @@ -59,13 +58,38 @@ def test_dayofyear(scalar_types_df: bpd.DataFrame, snapshot): def test_floor_dt(scalar_types_df: bpd.DataFrame, snapshot): + col_names = ["datetime_col", "timestamp_col", "date_col"] + bf_df = scalar_types_df[col_names] + ops_map = { + "timestamp_col_us": ops.FloorDtOp("us").as_expr("timestamp_col"), + "timestamp_col_ms": ops.FloorDtOp("ms").as_expr("timestamp_col"), + "timestamp_col_s": ops.FloorDtOp("s").as_expr("timestamp_col"), + "timestamp_col_min": ops.FloorDtOp("min").as_expr("timestamp_col"), + "timestamp_col_h": ops.FloorDtOp("h").as_expr("timestamp_col"), + "timestamp_col_D": ops.FloorDtOp("D").as_expr("timestamp_col"), + "timestamp_col_W": ops.FloorDtOp("W").as_expr("timestamp_col"), + "timestamp_col_M": ops.FloorDtOp("M").as_expr("timestamp_col"), + "timestamp_col_Q": ops.FloorDtOp("Q").as_expr("timestamp_col"), + "timestamp_col_Y": ops.FloorDtOp("Y").as_expr("timestamp_col"), + "datetime_col_q": ops.FloorDtOp("us").as_expr("datetime_col"), + "datetime_col_us": ops.FloorDtOp("us").as_expr("datetime_col"), + } + + sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + +def test_floor_dt_op_invalid_freq(scalar_types_df: bpd.DataFrame): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( - bf_df, [ops.FloorDtOp("D").as_expr(col_name)], [col_name] - ) - - snapshot.assert_match(sql, "out.sql") + with pytest.raises( + NotImplementedError, match="Unsupported freq paramater: invalid" + ): + utils._apply_unary_ops( + bf_df, + [ops.FloorDtOp(freq="invalid").as_expr(col_name)], # type:ignore + [col_name], + ) def test_hour(scalar_types_df: bpd.DataFrame, snapshot): From 4c98c95596ee4099aad68e9314296f669020139d Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Mon, 27 Oct 2025 16:31:18 -0700 Subject: [PATCH 183/313] refactor: add struct_op and sql_scalar_op for the sqlglot compiler (#2197) --- .../sqlglot/expressions/generic_ops.py | 11 +++++++ .../compile/sqlglot/expressions/struct_ops.py | 11 +++++++ bigframes/testing/utils.py | 14 ++++++-- .../test_sql_scalar_op/out.sql | 14 ++++++++ .../test_struct_ops/test_struct_op/out.sql | 21 ++++++++++++ .../sqlglot/expressions/test_generic_ops.py | 11 +++++++ .../sqlglot/expressions/test_struct_ops.py | 32 +++++++++++++++++++ 7 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_sql_scalar_op/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_op/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/generic_ops.py b/bigframes/core/compile/sqlglot/expressions/generic_ops.py index 9782ef11d4..7572a1e801 100644 --- a/bigframes/core/compile/sqlglot/expressions/generic_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/generic_ops.py @@ -14,6 +14,7 @@ from __future__ import annotations +import sqlglot as sg import sqlglot.expressions as sge from bigframes import dtypes @@ -80,6 +81,16 @@ def _(expr: TypedExpr) -> sge.Expression: return sge.BitwiseNot(this=sge.paren(expr.expr)) +@register_nary_op(ops.SqlScalarOp, pass_op=True) +def _(*operands: TypedExpr, op: ops.SqlScalarOp) -> sge.Expression: + return sg.parse_one( + op.sql_template.format( + *[operand.expr.sql(dialect="bigquery") for operand in operands] + ), + dialect="bigquery", + ) + + @register_unary_op(ops.isnull_op) def _(expr: TypedExpr) -> sge.Expression: return sge.Is(this=expr.expr, expression=sge.Null()) diff --git a/bigframes/core/compile/sqlglot/expressions/struct_ops.py b/bigframes/core/compile/sqlglot/expressions/struct_ops.py index ebd3a38397..b6ec101eb1 100644 --- a/bigframes/core/compile/sqlglot/expressions/struct_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/struct_ops.py @@ -24,6 +24,7 @@ from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler +register_nary_op = scalar_compiler.scalar_op_compiler.register_nary_op register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op @@ -40,3 +41,13 @@ def _(expr: TypedExpr, op: ops.StructFieldOp) -> sge.Expression: this=sge.to_identifier(name, quoted=True), catalog=expr.expr, ) + + +@register_nary_op(ops.StructOp, pass_op=True) +def _(*exprs: TypedExpr, op: ops.StructOp) -> sge.Struct: + return sge.Struct( + expressions=[ + sge.PropertyEQ(this=sge.to_identifier(col), expression=expr.expr) + for col, expr in zip(op.column_names, exprs) + ] + ) diff --git a/bigframes/testing/utils.py b/bigframes/testing/utils.py index b4daab7aad..a0bfc9e648 100644 --- a/bigframes/testing/utils.py +++ b/bigframes/testing/utils.py @@ -475,13 +475,23 @@ def _apply_binary_op( ) -> str: """Applies a binary op to the given DataFrame and return the SQL representing the resulting DataFrame.""" + return _apply_nary_op(obj, op, l_arg, r_arg) + + +def _apply_nary_op( + obj: bpd.DataFrame, + op: Union[ops.BinaryOp, ops.NaryOp], + *args: Union[str, ex.Expression], +) -> str: + """Applies a nary op to the given DataFrame and return the SQL representing + the resulting DataFrame.""" array_value = obj._block.expr - op_expr = op.as_expr(l_arg, r_arg) + op_expr = op.as_expr(*args) result, col_ids = array_value.compute_values([op_expr]) # Rename columns for deterministic golden SQL results. assert len(col_ids) == 1 - result = result.rename_columns({col_ids[0]: l_arg}).select_columns([l_arg]) + result = result.rename_columns({col_ids[0]: args[0]}).select_columns([args[0]]) sql = result.session._executor.to_sql(result, enable_cache=False) return sql diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_sql_scalar_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_sql_scalar_op/out.sql new file mode 100644 index 0000000000..a79e006885 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_sql_scalar_op/out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `bytes_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CAST(`bfcol_0` AS INT64) + BYTE_LENGTH(`bfcol_1`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `bool_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_op/out.sql new file mode 100644 index 0000000000..f7f741a523 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_op/out.sql @@ -0,0 +1,21 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `float64_col` AS `bfcol_2`, + `string_col` AS `bfcol_3` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + STRUCT( + `bfcol_0` AS bool_col, + `bfcol_1` AS int64_col, + `bfcol_2` AS float64_col, + `bfcol_3` AS string_col + ) AS `bfcol_4` + FROM `bfcte_0` +) +SELECT + `bfcol_4` AS `result_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py index b7abc63213..075416d664 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py @@ -261,6 +261,17 @@ def test_notnull(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_sql_scalar_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["bool_col", "bytes_col"]] + sql = utils._apply_nary_op( + bf_df, + ops.SqlScalarOp(dtypes.INT_DTYPE, "CAST({0} AS INT64) + BYTE_LENGTH({1})"), + "bool_col", + "bytes_col", + ) + snapshot.assert_match(sql, "out.sql") + + def test_map(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] diff --git a/tests/unit/core/compile/sqlglot/expressions/test_struct_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_struct_ops.py index 19156ead99..7e67e44cd3 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_struct_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_struct_ops.py @@ -12,15 +12,39 @@ # See the License for the specific language governing permissions and # limitations under the License. +import typing + import pytest from bigframes import operations as ops +from bigframes.core import expression as ex import bigframes.pandas as bpd from bigframes.testing import utils pytest.importorskip("pytest_snapshot") +def _apply_nary_op( + obj: bpd.DataFrame, + op: ops.NaryOp, + *args: typing.Union[str, ex.Expression], +) -> str: + """Applies a nary op to the given DataFrame and return the SQL representing + the resulting DataFrame.""" + array_value = obj._block.expr + op_expr = op.as_expr(*args) + result, col_ids = array_value.compute_values([op_expr]) + + # Rename columns for deterministic golden SQL results. + assert len(col_ids) == 1 + result = result.rename_columns({col_ids[0]: "result_col"}).select_columns( + ["result_col"] + ) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return sql + + def test_struct_field(nested_structs_types_df: bpd.DataFrame, snapshot): col_name = "people" bf_df = nested_structs_types_df[[col_name]] @@ -34,3 +58,11 @@ def test_struct_field(nested_structs_types_df: bpd.DataFrame, snapshot): sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") + + +def test_struct_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["bool_col", "int64_col", "float64_col", "string_col"]] + op = ops.StructOp(column_names=tuple(bf_df.columns.tolist())) + sql = _apply_nary_op(bf_df, op, *bf_df.columns.tolist()) + + snapshot.assert_match(sql, "out.sql") From 3de8995ab658f81eabb6af22d64d2c88d8c9e5a8 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Tue, 28 Oct 2025 16:50:09 -0700 Subject: [PATCH 184/313] refactor: add rowkey to the sqlglot compiler (#2202) --- .../sqlglot/expressions/generic_ops.py | 53 ++++++++++++++ .../test_generic_ops/test_row_key/out.sql | 70 +++++++++++++++++++ .../sqlglot/expressions/test_generic_ops.py | 8 +++ 3 files changed, 131 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_row_key/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/generic_ops.py b/bigframes/core/compile/sqlglot/expressions/generic_ops.py index 7572a1e801..07505855e1 100644 --- a/bigframes/core/compile/sqlglot/expressions/generic_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/generic_ops.py @@ -159,6 +159,30 @@ def _(*cases_and_outputs: TypedExpr) -> sge.Expression: ) +@register_nary_op(ops.RowKey) +def _(*values: TypedExpr) -> sge.Expression: + # All inputs into hash must be non-null or resulting hash will be null + str_values = [_convert_to_nonnull_string_sqlglot(value) for value in values] + + full_row_hash_p1 = sge.func("FARM_FINGERPRINT", sge.Concat(expressions=str_values)) + + # By modifying value slightly, we get another hash uncorrelated with the first + full_row_hash_p2 = sge.func( + "FARM_FINGERPRINT", sge.Concat(expressions=[*str_values, sge.convert("_")]) + ) + + # Used to disambiguate between identical rows (which will have identical hash) + random_hash_p3 = sge.func("RAND") + + return sge.Concat( + expressions=[ + sge.Cast(this=full_row_hash_p1, to="STRING"), + sge.Cast(this=full_row_hash_p2, to="STRING"), + sge.Cast(this=random_hash_p3, to="STRING"), + ] + ) + + # Helper functions def _cast_to_json(expr: TypedExpr, op: ops.AsTypeOp) -> sge.Expression: from_type = expr.dtype @@ -218,3 +242,32 @@ def _cast(expr: sge.Expression, to: str, safe: bool): return sge.TryCast(this=expr, to=to) else: return sge.Cast(this=expr, to=to) + + +def _convert_to_nonnull_string_sqlglot(expr: TypedExpr) -> sge.Expression: + col_type = expr.dtype + sg_expr = expr.expr + + if col_type == dtypes.STRING_DTYPE: + result = sg_expr + elif ( + dtypes.is_numeric(col_type) + or dtypes.is_time_or_date_like(col_type) + or col_type == dtypes.BYTES_DTYPE + ): + result = sge.Cast(this=sg_expr, to="STRING") + elif col_type == dtypes.GEO_DTYPE: + result = sge.func("ST_ASTEXT", sg_expr) + else: + # TO_JSON_STRING works with all data types, but isn't the most efficient + # Needed for JSON, STRUCT and ARRAY datatypes + result = sge.func("TO_JSON_STRING", sg_expr) + + # Escape backslashes and use backslash as delineator + escaped = sge.func( + "REPLACE", + sge.func("COALESCE", result, sge.convert("")), + sge.convert("\\"), + sge.convert("\\\\"), + ) + return sge.Concat(expressions=[sge.convert("\\"), escaped]) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_row_key/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_row_key/out.sql new file mode 100644 index 0000000000..080e35f68e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_row_key/out.sql @@ -0,0 +1,70 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `bytes_col` AS `bfcol_1`, + `date_col` AS `bfcol_2`, + `datetime_col` AS `bfcol_3`, + `geography_col` AS `bfcol_4`, + `int64_col` AS `bfcol_5`, + `int64_too` AS `bfcol_6`, + `numeric_col` AS `bfcol_7`, + `float64_col` AS `bfcol_8`, + `rowindex` AS `bfcol_9`, + `rowindex_2` AS `bfcol_10`, + `string_col` AS `bfcol_11`, + `time_col` AS `bfcol_12`, + `timestamp_col` AS `bfcol_13`, + `duration_col` AS `bfcol_14` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CONCAT( + CAST(FARM_FINGERPRINT( + CONCAT( + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_9` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_0` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_1` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_2` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_3` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(ST_ASTEXT(`bfcol_4`), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_5` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_6` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_7` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_8` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_9` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_10` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(`bfcol_11`, ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_12` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_13` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_14` AS STRING), ''), '\\', '\\\\')) + ) + ) AS STRING), + CAST(FARM_FINGERPRINT( + CONCAT( + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_9` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_0` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_1` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_2` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_3` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(ST_ASTEXT(`bfcol_4`), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_5` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_6` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_7` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_8` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_9` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_10` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(`bfcol_11`, ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_12` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_13` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_14` AS STRING), ''), '\\', '\\\\')), + '_' + ) + ) AS STRING), + CAST(RAND() AS STRING) + ) AS `bfcol_31` + FROM `bfcte_0` +) +SELECT + `bfcol_31` AS `row_key` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py index 075416d664..fd9732bf89 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py @@ -261,6 +261,14 @@ def test_notnull(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_row_key(scalar_types_df: bpd.DataFrame, snapshot): + column_ids = (col for col in scalar_types_df._block.expr.column_ids) + sql = utils._apply_unary_ops( + scalar_types_df, [ops.RowKey().as_expr(*column_ids)], ["row_key"] + ) + snapshot.assert_match(sql, "out.sql") + + def test_sql_scalar_op(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["bool_col", "bytes_col"]] sql = utils._apply_nary_op( From 917f778e59c9a8c847b41dc72c8e6ca90eb51193 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Wed, 29 Oct 2025 10:47:43 -0700 Subject: [PATCH 185/313] chore: Migrate minimum_op operator to SQLGlot (#2205) --- .../compile/sqlglot/expressions/comparison_ops.py | 5 +++++ .../test_comparison_ops/test_minimum_op/out.sql | 14 ++++++++++++++ .../sqlglot/expressions/test_comparison_ops.py | 7 +++++++ 3 files changed, 26 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_minimum_op/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/comparison_ops.py b/bigframes/core/compile/sqlglot/expressions/comparison_ops.py index eb08144b8a..e77b8b50a5 100644 --- a/bigframes/core/compile/sqlglot/expressions/comparison_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/comparison_ops.py @@ -109,6 +109,11 @@ def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: return sge.LTE(this=left_expr, expression=right_expr) +@register_binary_op(ops.minimum_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.Least(this=left.expr, expressions=right.expr) + + @register_binary_op(ops.ne_op) def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: left_expr = _coerce_bool_to_int(left) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_minimum_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_minimum_op/out.sql new file mode 100644 index 0000000000..429c3d2861 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_minimum_op/out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `float64_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LEAST(`bfcol_0`, `bfcol_1`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py index 6c3eb64414..f278a15f3c 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py @@ -110,6 +110,13 @@ def test_le_numeric(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(bf_df.sql, "out.sql") +def test_minimum_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col"]] + sql = utils._apply_binary_op(bf_df, ops.minimum_op, "int64_col", "float64_col") + + snapshot.assert_match(sql, "out.sql") + + def test_ne_numeric(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["int64_col", "bool_col"]] From 5ea640fa99e9ea874ebd6a49bbf882c65d2005cd Mon Sep 17 00:00:00 2001 From: jialuoo Date: Wed, 29 Oct 2025 10:50:49 -0700 Subject: [PATCH 186/313] chore: Migrate round_op operator to SQLGlot (#2204) This commit migrates the `round_op` operator from the Ibis compiler to the SQLGlot compiler. --- .../sqlglot/expressions/numeric_ops.py | 8 ++ .../test_numeric_ops/test_round/out.sql | 81 +++++++++++++++++++ .../sqlglot/expressions/test_numeric_ops.py | 14 ++++ 3 files changed, 103 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_round/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py index 8ca884b900..afc0d9d01c 100644 --- a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py @@ -377,6 +377,14 @@ def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: return result +@register_binary_op(ops.round_op) +def _(expr: TypedExpr, n_digits: TypedExpr) -> sge.Expression: + rounded = sge.Round(this=expr.expr, decimals=n_digits.expr) + if expr.dtype == dtypes.INT_DTYPE: + return sge.Cast(this=rounded, to="INT64") + return rounded + + @register_binary_op(ops.sub_op) def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: if dtypes.is_numeric(left.dtype) and dtypes.is_numeric(right.dtype): diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_round/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_round/out.sql new file mode 100644 index 0000000000..8513c8d63f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_round/out.sql @@ -0,0 +1,81 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `float64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_0` AS `bfcol_7`, + `bfcol_1` AS `bfcol_8`, + CAST(ROUND(`bfcol_0`, 0) AS INT64) AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + CAST(ROUND(`bfcol_7`, 1) AS INT64) AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + CAST(ROUND(`bfcol_15`, -1) AS INT64) AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + ROUND(`bfcol_26`, 0) AS `bfcol_42` + FROM `bfcte_3` +), `bfcte_5` AS ( + SELECT + *, + `bfcol_36` AS `bfcol_50`, + `bfcol_37` AS `bfcol_51`, + `bfcol_38` AS `bfcol_52`, + `bfcol_39` AS `bfcol_53`, + `bfcol_40` AS `bfcol_54`, + `bfcol_41` AS `bfcol_55`, + `bfcol_42` AS `bfcol_56`, + ROUND(`bfcol_38`, 1) AS `bfcol_57` + FROM `bfcte_4` +), `bfcte_6` AS ( + SELECT + *, + `bfcol_50` AS `bfcol_66`, + `bfcol_51` AS `bfcol_67`, + `bfcol_52` AS `bfcol_68`, + `bfcol_53` AS `bfcol_69`, + `bfcol_54` AS `bfcol_70`, + `bfcol_55` AS `bfcol_71`, + `bfcol_56` AS `bfcol_72`, + `bfcol_57` AS `bfcol_73`, + ROUND(`bfcol_52`, -1) AS `bfcol_74` + FROM `bfcte_5` +) +SELECT + `bfcol_66` AS `rowindex`, + `bfcol_67` AS `int64_col`, + `bfcol_68` AS `float64_col`, + `bfcol_69` AS `int_round_0`, + `bfcol_70` AS `int_round_1`, + `bfcol_71` AS `int_round_m1`, + `bfcol_72` AS `float_round_0`, + `bfcol_73` AS `float_round_1`, + `bfcol_74` AS `float_round_m1` +FROM `bfcte_6` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py index fe9a53a558..ab9fe53092 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py @@ -167,6 +167,20 @@ def test_pos(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_round(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col"]] + + bf_df["int_round_0"] = bf_df["int64_col"].round(0) + bf_df["int_round_1"] = bf_df["int64_col"].round(1) + bf_df["int_round_m1"] = bf_df["int64_col"].round(-1) + + bf_df["float_round_0"] = bf_df["float64_col"].round(0) + bf_df["float_round_1"] = bf_df["float64_col"].round(1) + bf_df["float_round_m1"] = bf_df["float64_col"].round(-1) + + snapshot.assert_match(bf_df.sql, "out.sql") + + def test_sqrt(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] From d4100466612df0523d01ed01ca1e115dabd6ef45 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Wed, 29 Oct 2025 12:03:45 -0700 Subject: [PATCH 187/313] fix: Improve error handling in blob operations (#2194) * add error handling for audio_transcribe * add error handling for pdf functions * add eror handling for image functions * final touch * restore rename * update notebook to better reflect our new code change * return None on error with verbose=False for image functions * define typing module in udf * only use local variable * Refactor code --- bigframes/blob/_functions.py | 285 ++++++++---- bigframes/operations/blob.py | 137 ++++-- .../multimodal/multimodal_dataframe.ipynb | 426 ++++++++++++++---- 3 files changed, 625 insertions(+), 223 deletions(-) diff --git a/bigframes/blob/_functions.py b/bigframes/blob/_functions.py index 2a11974b8d..3dfe38811b 100644 --- a/bigframes/blob/_functions.py +++ b/bigframes/blob/_functions.py @@ -14,6 +14,7 @@ from dataclasses import dataclass import inspect +import typing from typing import Callable, Iterable, Union import google.cloud.bigquery as bigquery @@ -70,6 +71,12 @@ def _input_bq_signature(self): def _output_bq_type(self): sig = inspect.signature(self._func) + return_annotation = sig.return_annotation + origin = typing.get_origin(return_annotation) + if origin is Union: + args = typing.get_args(return_annotation) + if len(args) == 2 and args[1] is type(None): + return _PYTHON_TO_BQ_TYPES[args[0]] return _PYTHON_TO_BQ_TYPES[sig.return_annotation] def _create_udf(self): @@ -78,7 +85,7 @@ def _create_udf(self): self._session._anon_dataset_manager.generate_unique_resource_id() ) - func_body = inspect.getsource(self._func) + func_body = "import typing\n" + inspect.getsource(self._func) func_name = self._func.__name__ packages = str(list(self._requirements)) @@ -120,43 +127,50 @@ def udf(self): def exif_func(src_obj_ref_rt: str, verbose: bool) -> str: - import io - import json + try: + import io + import json - from PIL import ExifTags, Image - import requests - from requests import adapters + from PIL import ExifTags, Image + import requests + from requests import adapters - result_dict = {"status": "", "content": "{}"} - try: session = requests.Session() session.mount("https://", adapters.HTTPAdapter(max_retries=3)) src_obj_ref_rt_json = json.loads(src_obj_ref_rt) - src_url = src_obj_ref_rt_json["access_urls"]["read_url"] response = session.get(src_url, timeout=30) + response.raise_for_status() bts = response.content image = Image.open(io.BytesIO(bts)) exif_data = image.getexif() exif_dict = {} + if exif_data: for tag, value in exif_data.items(): tag_name = ExifTags.TAGS.get(tag, tag) - # Pillow might return bytes, which are not serializable. - if isinstance(value, bytes): - value = value.decode("utf-8", "replace") - exif_dict[tag_name] = value - result_dict["content"] = json.dumps(exif_dict) - except Exception as e: - result_dict["status"] = str(e) + # Convert non-serializable types to strings + try: + json.dumps(value) + exif_dict[tag_name] = value + except (TypeError, ValueError): + exif_dict[tag_name] = str(value) + + if verbose: + return json.dumps({"status": "", "content": json.dumps(exif_dict)}) + else: + return json.dumps(exif_dict) - if verbose: - return json.dumps(result_dict) - else: - return result_dict["content"] + except Exception as e: + # Return error as JSON with error field + error_result = {"status": f"{type(e).__name__}: {str(e)}", "content": "{}"} + if verbose: + return json.dumps(error_result) + else: + return "{}" exif_func_def = FunctionDef(exif_func, ["pillow", "requests"]) @@ -170,12 +184,10 @@ def image_blur_func( ksize_y: int, ext: str, verbose: bool, -) -> str: - import json - - result_dict = {"status": "", "content": dst_obj_ref_rt} - +) -> typing.Optional[str]: try: + import json + import cv2 as cv # type: ignore import numpy as np import requests @@ -193,35 +205,52 @@ def image_blur_func( dst_url = dst_obj_ref_rt_json["access_urls"]["write_url"] response = session.get(src_url, timeout=30) + response.raise_for_status() # Raise exception for HTTP errors bts = response.content nparr = np.frombuffer(bts, np.uint8) img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + + if img is None: + raise ValueError( + "Failed to decode image - possibly corrupted or unsupported format" + ) + img_blurred = cv.blur(img, ksize=(ksize_x, ksize_y)) - bts = cv.imencode(ext, img_blurred)[1].tobytes() + success, encoded = cv.imencode(ext, img_blurred) + if not success: + raise ValueError(f"Failed to encode image with extension {ext}") + + bts = encoded.tobytes() ext = ext.replace(".", "") ext_mappings = {"jpg": "jpeg", "tif": "tiff"} ext = ext_mappings.get(ext, ext) content_type = "image/" + ext - session.put( + put_response = session.put( url=dst_url, data=bts, - headers={ - "Content-Type": content_type, - }, + headers={"Content-Type": content_type}, timeout=30, ) + put_response.raise_for_status() - except Exception as e: - result_dict["status"] = str(e) + if verbose: + return json.dumps({"status": "", "content": dst_obj_ref_rt}) + else: + return dst_obj_ref_rt - if verbose: - return json.dumps(result_dict) - else: - return result_dict["content"] + except Exception as e: + if verbose: + error_result = { + "status": f"Error: {type(e).__name__}: {str(e)}", + "content": "", + } + return json.dumps(error_result) + else: + return None image_blur_def = FunctionDef(image_blur_func, ["opencv-python", "numpy", "requests"]) @@ -233,9 +262,6 @@ def image_blur_to_bytes_func( import base64 import json - status = "" - content = b"" - try: import cv2 as cv # type: ignore import numpy as np @@ -251,22 +277,36 @@ def image_blur_to_bytes_func( src_url = src_obj_ref_rt_json["access_urls"]["read_url"] response = session.get(src_url, timeout=30) + response.raise_for_status() bts = response.content nparr = np.frombuffer(bts, np.uint8) img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + if img is None: + raise ValueError( + "Failed to decode image - possibly corrupted or unsupported format" + ) img_blurred = cv.blur(img, ksize=(ksize_x, ksize_y)) - content = cv.imencode(ext, img_blurred)[1].tobytes() + success, encoded = cv.imencode(ext, img_blurred) + if not success: + raise ValueError(f"Failed to encode image with extension {ext}") + content = encoded.tobytes() + + encoded_content = base64.b64encode(content).decode("utf-8") + result_dict = {"status": "", "content": encoded_content} + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] except Exception as e: - status = str(e) - - encoded_content = base64.b64encode(content).decode("utf-8") - result_dict = {"status": status, "content": encoded_content} - if verbose: - return json.dumps(result_dict) - else: - return result_dict["content"] + status = f"Error: {type(e).__name__}: {str(e)}" + encoded_content = base64.b64encode(b"").decode("utf-8") + result_dict = {"status": status, "content": encoded_content} + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] image_blur_to_bytes_def = FunctionDef( @@ -283,12 +323,10 @@ def image_resize_func( fy: float, ext: str, verbose: bool, -) -> str: - import json - - result_dict = {"status": "", "content": dst_obj_ref_rt} - +) -> typing.Optional[str]: try: + import json + import cv2 as cv # type: ignore import numpy as np import requests @@ -306,20 +344,28 @@ def image_resize_func( dst_url = dst_obj_ref_rt_json["access_urls"]["write_url"] response = session.get(src_url, timeout=30) + response.raise_for_status() bts = response.content nparr = np.frombuffer(bts, np.uint8) img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + if img is None: + raise ValueError( + "Failed to decode image - possibly corrupted or unsupported format" + ) img_resized = cv.resize(img, dsize=(dsize_x, dsize_y), fx=fx, fy=fy) - bts = cv.imencode(ext, img_resized)[1].tobytes() + success, encoded = cv.imencode(ext, img_resized) + if not success: + raise ValueError(f"Failed to encode image with extension {ext}") + bts = encoded.tobytes() ext = ext.replace(".", "") ext_mappings = {"jpg": "jpeg", "tif": "tiff"} ext = ext_mappings.get(ext, ext) content_type = "image/" + ext - session.put( + put_response = session.put( url=dst_url, data=bts, headers={ @@ -327,14 +373,22 @@ def image_resize_func( }, timeout=30, ) + put_response.raise_for_status() - except Exception as e: - result_dict["status"] = str(e) + if verbose: + return json.dumps({"status": "", "content": dst_obj_ref_rt}) + else: + return dst_obj_ref_rt - if verbose: - return json.dumps(result_dict) - else: - return result_dict["content"] + except Exception as e: + if verbose: + error_result = { + "status": f"Error: {type(e).__name__}: {str(e)}", + "content": "", + } + return json.dumps(error_result) + else: + return None image_resize_def = FunctionDef( @@ -354,9 +408,6 @@ def image_resize_to_bytes_func( import base64 import json - status = "" - content = b"" - try: import cv2 as cv # type: ignore import numpy as np @@ -372,22 +423,36 @@ def image_resize_to_bytes_func( src_url = src_obj_ref_rt_json["access_urls"]["read_url"] response = session.get(src_url, timeout=30) + response.raise_for_status() bts = response.content nparr = np.frombuffer(bts, np.uint8) img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + if img is None: + raise ValueError( + "Failed to decode image - possibly corrupted or unsupported format" + ) img_resized = cv.resize(img, dsize=(dsize_x, dsize_y), fx=fx, fy=fy) - content = cv.imencode(".jpeg", img_resized)[1].tobytes() + success, encoded = cv.imencode(ext, img_resized) + if not success: + raise ValueError(f"Failed to encode image with extension {ext}") + content = encoded.tobytes() + + encoded_content = base64.b64encode(content).decode("utf-8") + result_dict = {"status": "", "content": encoded_content} + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] except Exception as e: - status = str(e) - - encoded_content = base64.b64encode(content).decode("utf-8") - result_dict = {"status": status, "content": encoded_content} - if verbose: - return json.dumps(result_dict) - else: - return result_dict["content"] + status = f"Error: {type(e).__name__}: {str(e)}" + encoded_content = base64.b64encode(b"").decode("utf-8") + result_dict = {"status": status, "content": encoded_content} + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] image_resize_to_bytes_def = FunctionDef( @@ -403,12 +468,10 @@ def image_normalize_func( norm_type: str, ext: str, verbose: bool, -) -> str: - import json - - result_dict = {"status": "", "content": dst_obj_ref_rt} - +) -> typing.Optional[str]: try: + import json + import cv2 as cv # type: ignore import numpy as np import requests @@ -433,22 +496,30 @@ def image_normalize_func( dst_url = dst_obj_ref_rt_json["access_urls"]["write_url"] response = session.get(src_url, timeout=30) + response.raise_for_status() bts = response.content nparr = np.frombuffer(bts, np.uint8) img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + if img is None: + raise ValueError( + "Failed to decode image - possibly corrupted or unsupported format" + ) img_normalized = cv.normalize( img, None, alpha=alpha, beta=beta, norm_type=norm_type_mapping[norm_type] ) - bts = cv.imencode(ext, img_normalized)[1].tobytes() + success, encoded = cv.imencode(ext, img_normalized) + if not success: + raise ValueError(f"Failed to encode image with extension {ext}") + bts = encoded.tobytes() ext = ext.replace(".", "") ext_mappings = {"jpg": "jpeg", "tif": "tiff"} ext = ext_mappings.get(ext, ext) content_type = "image/" + ext - session.put( + put_response = session.put( url=dst_url, data=bts, headers={ @@ -456,14 +527,22 @@ def image_normalize_func( }, timeout=30, ) + put_response.raise_for_status() - except Exception as e: - result_dict["status"] = str(e) + if verbose: + return json.dumps({"status": "", "content": dst_obj_ref_rt}) + else: + return dst_obj_ref_rt - if verbose: - return json.dumps(result_dict) - else: - return result_dict["content"] + except Exception as e: + if verbose: + error_result = { + "status": f"Error: {type(e).__name__}: {str(e)}", + "content": "", + } + return json.dumps(error_result) + else: + return None image_normalize_def = FunctionDef( @@ -482,8 +561,6 @@ def image_normalize_to_bytes_func( import base64 import json - result_dict = {"status": "", "content": ""} - try: import cv2 as cv # type: ignore import numpy as np @@ -506,25 +583,39 @@ def image_normalize_to_bytes_func( src_url = src_obj_ref_rt_json["access_urls"]["read_url"] response = session.get(src_url, timeout=30) + response.raise_for_status() bts = response.content nparr = np.frombuffer(bts, np.uint8) img = cv.imdecode(nparr, cv.IMREAD_UNCHANGED) + if img is None: + raise ValueError( + "Failed to decode image - possibly corrupted or unsupported format" + ) img_normalized = cv.normalize( img, None, alpha=alpha, beta=beta, norm_type=norm_type_mapping[norm_type] ) - bts = cv.imencode(".jpeg", img_normalized)[1].tobytes() + success, encoded = cv.imencode(ext, img_normalized) + if not success: + raise ValueError(f"Failed to encode image with extension {ext}") + content = encoded.tobytes() - content_b64 = base64.b64encode(bts).decode("utf-8") - result_dict["content"] = content_b64 + encoded_content = base64.b64encode(content).decode("utf-8") + result_dict = {"status": "", "content": encoded_content} - except Exception as e: - result_dict["status"] = str(e) + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] - if verbose: - return json.dumps(result_dict) - else: - return result_dict["content"] + except Exception as e: + status = f"Error: {type(e).__name__}: {str(e)}" + encoded_content = base64.b64encode(b"").decode("utf-8") + result_dict = {"status": status, "content": encoded_content} + if verbose: + return json.dumps(result_dict) + else: + return result_dict["content"] image_normalize_to_bytes_def = FunctionDef( diff --git a/bigframes/operations/blob.py b/bigframes/operations/blob.py index 1f6b75a8f5..577de458f4 100644 --- a/bigframes/operations/blob.py +++ b/bigframes/operations/blob.py @@ -193,6 +193,20 @@ def _df_apply_udf( return s + def _apply_udf_or_raise_error( + self, df: bigframes.dataframe.DataFrame, udf, operation_name: str + ) -> bigframes.series.Series: + """Helper to apply UDF with consistent error handling.""" + try: + res = self._df_apply_udf(df, udf) + except Exception as e: + raise RuntimeError(f"{operation_name} UDF execution failed: {e}") from e + + if res is None: + raise RuntimeError(f"{operation_name} returned None result") + + return res + def read_url(self) -> bigframes.series.Series: """Retrieve the read URL of the Blob. @@ -343,6 +357,10 @@ def exif( Returns: bigframes.series.Series: JSON series of key-value pairs if verbose=False, or struct with status and content if verbose=True. + + Raises: + ValueError: If engine is not 'pillow'. + RuntimeError: If EXIF extraction fails or returns invalid structure. """ if engine is None or engine.casefold() != "pillow": raise ValueError("Must specify the engine, supported value is 'pillow'.") @@ -364,22 +382,28 @@ def exif( container_memory=container_memory, ).udf() - res = self._df_apply_udf(df, exif_udf) + res = self._apply_udf_or_raise_error(df, exif_udf, "EXIF extraction") if verbose: - exif_content_series = bbq.parse_json( - res._apply_unary_op(ops.JSONValue(json_path="$.content")) - ).rename("exif_content") - exif_status_series = res._apply_unary_op( - ops.JSONValue(json_path="$.status") - ) + try: + exif_content_series = bbq.parse_json( + res._apply_unary_op(ops.JSONValue(json_path="$.content")) + ).rename("exif_content") + exif_status_series = res._apply_unary_op( + ops.JSONValue(json_path="$.status") + ) + except Exception as e: + raise RuntimeError(f"Failed to parse EXIF JSON result: {e}") from e results_df = bpd.DataFrame( {"status": exif_status_series, "content": exif_content_series} ) results_struct = bbq.struct(results_df).rename("exif_results") return results_struct else: - return bbq.parse_json(res) + try: + return bbq.parse_json(res) + except Exception as e: + raise RuntimeError(f"Failed to parse EXIF JSON result: {e}") from e def image_blur( self, @@ -411,6 +435,10 @@ def image_blur( Returns: bigframes.series.Series: blob Series if destination is GCS. Or bytes Series if destination is BQ. If verbose=True, returns struct with status and content. + + Raises: + ValueError: If engine is not 'opencv' or parameters are invalid. + RuntimeError: If image blur operation fails. """ if engine is None or engine.casefold() != "opencv": raise ValueError("Must specify the engine, supported value is 'opencv'.") @@ -437,7 +465,7 @@ def image_blur( df["ksize_x"], df["ksize_y"] = ksize df["ext"] = ext # type: ignore df["verbose"] = verbose - res = self._df_apply_udf(df, image_blur_udf) + res = self._apply_udf_or_raise_error(df, image_blur_udf, "Image blur") if verbose: blurred_content_b64_series = res._apply_unary_op( @@ -486,7 +514,7 @@ def image_blur( df["ext"] = ext # type: ignore df["verbose"] = verbose - res = self._df_apply_udf(df, image_blur_udf) + res = self._apply_udf_or_raise_error(df, image_blur_udf, "Image blur") res.cache() # to execute the udf if verbose: @@ -540,6 +568,10 @@ def image_resize( Returns: bigframes.series.Series: blob Series if destination is GCS. Or bytes Series if destination is BQ. If verbose=True, returns struct with status and content. + + Raises: + ValueError: If engine is not 'opencv' or parameters are invalid. + RuntimeError: If image resize operation fails. """ if engine is None or engine.casefold() != "opencv": raise ValueError("Must specify the engine, supported value is 'opencv'.") @@ -570,11 +602,11 @@ def image_resize( container_memory=container_memory, ).udf() - df["dsize_x"], df["dsizye_y"] = dsize + df["dsize_x"], df["dsize_y"] = dsize df["fx"], df["fy"] = fx, fy df["ext"] = ext # type: ignore df["verbose"] = verbose - res = self._df_apply_udf(df, image_resize_udf) + res = self._apply_udf_or_raise_error(df, image_resize_udf, "Image resize") if verbose: resized_content_b64_series = res._apply_unary_op( @@ -620,12 +652,12 @@ def image_resize( dst_rt = dst.blob.get_runtime_json_str(mode="RW") df = df.join(dst_rt, how="outer") - df["dsize_x"], df["dsizye_y"] = dsize + df["dsize_x"], df["dsize_y"] = dsize df["fx"], df["fy"] = fx, fy df["ext"] = ext # type: ignore df["verbose"] = verbose - res = self._df_apply_udf(df, image_resize_udf) + res = self._apply_udf_or_raise_error(df, image_resize_udf, "Image resize") res.cache() # to execute the udf if verbose: @@ -679,6 +711,10 @@ def image_normalize( Returns: bigframes.series.Series: blob Series if destination is GCS. Or bytes Series if destination is BQ. If verbose=True, returns struct with status and content. + + Raises: + ValueError: If engine is not 'opencv' or parameters are invalid. + RuntimeError: If image normalize operation fails. """ if engine is None or engine.casefold() != "opencv": raise ValueError("Must specify the engine, supported value is 'opencv'.") @@ -707,7 +743,9 @@ def image_normalize( df["norm_type"] = norm_type df["ext"] = ext # type: ignore df["verbose"] = verbose - res = self._df_apply_udf(df, image_normalize_udf) + res = self._apply_udf_or_raise_error( + df, image_normalize_udf, "Image normalize" + ) if verbose: normalized_content_b64_series = res._apply_unary_op( @@ -758,7 +796,7 @@ def image_normalize( df["ext"] = ext # type: ignore df["verbose"] = verbose - res = self._df_apply_udf(df, image_normalize_udf) + res = self._apply_udf_or_raise_error(df, image_normalize_udf, "Image normalize") res.cache() # to execute the udf if verbose: @@ -809,6 +847,10 @@ def pdf_extract( depend on the "verbose" parameter. Contains the extracted text from the PDF file. Includes error messages if verbosity is enabled. + + Raises: + ValueError: If engine is not 'pypdf'. + RuntimeError: If PDF extraction fails or returns invalid structure. """ if engine is None or engine.casefold() != "pypdf": raise ValueError("Must specify the engine, supported value is 'pypdf'.") @@ -830,18 +872,29 @@ def pdf_extract( df = self.get_runtime_json_str(mode="R").to_frame() df["verbose"] = verbose - res = self._df_apply_udf(df, pdf_extract_udf) + + res = self._apply_udf_or_raise_error(df, pdf_extract_udf, "PDF extraction") if verbose: - extracted_content_series = res._apply_unary_op( - ops.JSONValue(json_path="$.content") - ) - status_series = res._apply_unary_op(ops.JSONValue(json_path="$.status")) - results_df = bpd.DataFrame( - {"status": status_series, "content": extracted_content_series} - ) - results_struct = bbq.struct(results_df).rename("extracted_results") - return results_struct + # Extract content with error handling + try: + content_series = res._apply_unary_op( + ops.JSONValue(json_path="$.content") + ) + except Exception as e: + raise RuntimeError( + f"Failed to extract content field from PDF result: {e}" + ) from e + try: + status_series = res._apply_unary_op(ops.JSONValue(json_path="$.status")) + except Exception as e: + raise RuntimeError( + f"Failed to extract status field from PDF result: {e}" + ) from e + + res_df = bpd.DataFrame({"status": status_series, "content": content_series}) + struct_series = bbq.struct(res_df).rename("extracted_results") + return struct_series else: return res.rename("extracted_content") @@ -884,6 +937,10 @@ def pdf_chunk( depend on the "verbose" parameter. where each string is a chunk of text extracted from PDF. Includes error messages if verbosity is enabled. + + Raises: + ValueError: If engine is not 'pypdf'. + RuntimeError: If PDF chunking fails or returns invalid structure. """ if engine is None or engine.casefold() != "pypdf": raise ValueError("Must specify the engine, supported value is 'pypdf'.") @@ -915,13 +972,25 @@ def pdf_chunk( df["overlap_size"] = overlap_size df["verbose"] = verbose - res = self._df_apply_udf(df, pdf_chunk_udf) + res = self._apply_udf_or_raise_error(df, pdf_chunk_udf, "PDF chunking") + + try: + content_series = bbq.json_extract_string_array(res, "$.content") + except Exception as e: + raise RuntimeError( + f"Failed to extract content array from PDF chunk result: {e}" + ) from e if verbose: - chunked_content_series = bbq.json_extract_string_array(res, "$.content") - status_series = res._apply_unary_op(ops.JSONValue(json_path="$.status")) + try: + status_series = res._apply_unary_op(ops.JSONValue(json_path="$.status")) + except Exception as e: + raise RuntimeError( + f"Failed to extract status field from PDF chunk result: {e}" + ) from e + results_df = bpd.DataFrame( - {"status": status_series, "content": chunked_content_series} + {"status": status_series, "content": content_series} ) resultes_struct = bbq.struct(results_df).rename("chunked_results") return resultes_struct @@ -962,6 +1031,10 @@ def audio_transcribe( depend on the "verbose" parameter. Contains the transcribed text from the audio file. Includes error messages if verbosity is enabled. + + Raises: + ValueError: If engine is not 'bigquery'. + RuntimeError: If the transcription result structure is invalid. """ if engine.casefold() != "bigquery": raise ValueError("Must specify the engine, supported value is 'bigquery'.") @@ -984,6 +1057,10 @@ def audio_transcribe( model_params={"generationConfig": {"temperature": 0.0}}, ) + # Validate that the result is not None + if transcribed_results is None: + raise RuntimeError("Transcription returned None result") + transcribed_content_series = transcribed_results.struct.field("result").rename( "transcribed_content" ) diff --git a/notebooks/multimodal/multimodal_dataframe.ipynb b/notebooks/multimodal/multimodal_dataframe.ipynb index c04463fc4c..0822ee4c2d 100644 --- a/notebooks/multimodal/multimodal_dataframe.ipynb +++ b/notebooks/multimodal/multimodal_dataframe.ipynb @@ -60,7 +60,8 @@ "2. Combine unstructured data with structured data\n", "3. Conduct image transformations\n", "4. Use LLM models to ask questions and generate embeddings on images\n", - "5. PDF chunking function" + "5. PDF chunking function\n", + "6. Transcribe audio" ] }, { @@ -215,23 +216,23 @@ "
\n", " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", "
0201708211707592427130102017-08-21 17:07:59+00:0010th Ave at E 15th St2222017-08-21 20:44:50+00:00201712151647221445012017-12-15 16:47:22+00:0010th St at Fallon St2012017-12-15 16:55:44+00:0010th Ave at E 15th St2222427144<NA>...<NA>37.797673-122.26299737.792714-122.248781984Male<NA>POINT (-122.263 37.79767)POINT (-122.24878 37.79271)
12017080523460515857122017-08-05 23:46:05+00:0010th St at Fallon St2012017-08-05 23:57:57+00:0010th Ave at E 15th St2221585<NA>...<NA>37.797673-122.26299737.792714-122.24878<NA><NA><NA>POINT (-122.24878 37.79271)POINT (-122.263 37.79767)POINT (-122.24878 37.79271)
120171007174158200923032017-10-07 17:41:58+00:0010th Ave at E 15th St2222017-10-07 18:20:22+00:0022017111114472028802722017-11-11 14:47:20+00:0012th St at 4th Ave2332017-11-11 14:51:53+00:0010th Ave at E 15th St22220092880<NA>...<NA>37.795812-122.25555537.792714-122.2487837.792714-122.248781979Male1965Female<NA>POINT (-122.24878 37.79271)POINT (-122.25555 37.79581)POINT (-122.24878 37.79271)
22018032919350611745522018-03-29 19:35:06+00:0010th St at Fallon St2012018-03-29 19:44:19+00:0032018042517262737557572018-04-25 17:26:27+00:0013th St at Franklin St3382018-04-25 17:39:05+00:0010th Ave at E 15th St22211743755<NA>...<NA>37.797673-122.26299737.803189-122.27057937.792714-122.248781982OtherNoPOINT (-122.263 37.79767)POINT (-122.27058 37.80319)POINT (-122.24878 37.79271)
32018020811521132835642018-02-08 11:52:11+00:0042018040815560118311052018-04-08 15:56:01+00:0013th St at Franklin St3382018-02-08 12:01:35+00:002018-04-08 16:14:26+00:0010th Ave at E 15th St2223283183<NA>...<NA>POINT (-122.24878 37.79271)
42017101019153912386422017-10-10 19:15:39+00:002nd Ave at E 18th St2002017-10-10 19:26:21+00:0052018041916485015608572018-04-19 16:48:50+00:0013th St at Franklin St3382018-04-19 17:03:08+00:0010th Ave at E 15th St22212381560<NA>...<NA>37.800214-122.2538137.803189-122.27057937.792714-122.24878<NA><NA><NA>POINT (-122.25381 37.80021)1982OtherNoPOINT (-122.27058 37.80319)POINT (-122.24878 37.79271)
5201710101915376666592017-10-10 19:15:37+00:0062017081020445483912562017-08-10 20:44:54+00:002nd Ave at E 18th St2002017-10-10 19:26:37+00:002017-08-10 21:05:50+00:0010th Ave at E 15th St222666839<NA>...<NA>POINT (-122.24878 37.79271)
62018032417282314376832018-03-24 17:28:23+00:00El Embarcadero at Grand Ave1972018-03-24 17:39:46+00:007201710122044386666302017-10-12 20:44:38+00:002nd Ave at E 18th St2002017-10-12 20:55:09+00:0010th Ave at E 15th St2221437666<NA>...<NA>37.808848-122.2496837.800214-122.2538137.792714-122.248781987MaleNoPOINT (-122.24968 37.80885)POINT (-122.24878 37.79271)
72018011116131013058582018-01-11 16:13:10+00:00Frank H Ogawa Plaza72018-01-11 16:27:28+00:0010th Ave at E 15th St2221305<NA>...<NA>37.804562-122.27173837.792714-122.248781984MaleYesPOINT (-122.27174 37.80456)<NA>POINT (-122.25381 37.80021)POINT (-122.24878 37.79271)
82018031715344537566652018-03-17 15:34:45+00:00Frank H Ogawa Plaza72018-03-17 15:45:50+00:002017111818232819603532017-11-18 18:23:28+00:002nd Ave at E 18th St2002017-11-18 18:29:22+00:0010th Ave at E 15th St22237561960<NA>...<NA>37.804562-122.27173837.800214-122.2538137.792714-122.2487819871988MaleNoPOINT (-122.27174 37.80456)<NA>POINT (-122.25381 37.80021)POINT (-122.24878 37.79271)
92018030213202828587912018-03-02 13:20:28+00:00Frank H Ogawa Plaza72018-03-02 13:33:39+00:00201708061839175102982017-08-06 18:39:17+00:002nd Ave at E 18th St2002017-08-06 18:44:15+00:0010th Ave at E 15th St2222858510<NA>...<NA>37.804562-122.27173837.800214-122.2538137.792714-122.2487819841969MaleYesPOINT (-122.27174 37.80456)<NA>POINT (-122.25381 37.80021)POINT (-122.24878 37.79271)
02018-04-24 09:00:00+00:00429.8911742018-04-24 12:00:00+00:00147.0237430.95287.352243572.43010598.736624195.310862
12018-04-24 19:00:00+00:00288.0393682018-04-25 00:00:00+00:006.9550320.95186.949977389.128758-6.09423220.004297
22018-04-26 19:00:00+00:00222.308992018-04-26 05:00:00+00:00-37.1965330.9587.964205356.653776-88.75956614.366499
32018-04-29 11:00:00+00:00133.5494082018-04-26 14:00:00+00:00115.6351320.9567.082484200.01633230.120832201.149432
42018-04-26 11:00:00+00:00120.905672018-04-27 02:00:00+00:002.5160060.9535.78172206.029621-69.09559174.127604
52018-04-27 13:00:00+00:00162.0230262018-04-29 03:00:00+00:0022.5033260.95103.946307220.099744-38.71437883.721031
62018-04-27 20:00:00+00:00135.2161562018-04-24 04:00:00+00:00-12.2590790.9557.210032213.22228-45.37726220.859104
72018-04-28 05:00:00+00:005.6453252018-04-24 14:00:00+00:00126.5192110.95-30.67520641.96585596.837778156.200644
82018-04-29 12:00:00+00:00138.9662322018-04-26 11:00:00+00:00120.905670.9569.876807208.05565835.781735206.029606
92018-04-25 03:00:00+00:00-0.7708282018-04-27 13:00:00+00:00162.0230260.95-28.29275426.751098103.946307220.099744
0
1
2
3
4
\n", @@ -297,21 +298,21 @@ "instead of using `db_dtypes` in the future when available in pandas\n", "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", - "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:124: UserWarning: The `json_extract` is deprecated and will be removed in a future\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:121: UserWarning: The `json_extract` is deprecated and will be removed in a future\n", "version. Use `json_query` instead.\n", " warnings.warn(bfe.format_message(msg), category=UserWarning)\n", "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", "instead of using `db_dtypes` in the future when available in pandas\n", "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", - "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:124: UserWarning: The `json_extract` is deprecated and will be removed in a future\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:121: UserWarning: The `json_extract` is deprecated and will be removed in a future\n", "version. Use `json_query` instead.\n", " warnings.warn(bfe.format_message(msg), category=UserWarning)\n", "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", "instead of using `db_dtypes` in the future when available in pandas\n", "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", - "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:124: UserWarning: The `json_extract` is deprecated and will be removed in a future\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:121: UserWarning: The `json_extract` is deprecated and will be removed in a future\n", "version. Use `json_query` instead.\n", " warnings.warn(bfe.format_message(msg), category=UserWarning)\n", "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", @@ -351,7 +352,7 @@ " \n", " \n", " 0\n", - " \n", + " \n", " alice\n", " image/png\n", " 1591240\n", @@ -359,7 +360,7 @@ " \n", " \n", " 1\n", - " \n", + " \n", " bob\n", " image/png\n", " 1182951\n", @@ -367,7 +368,7 @@ " \n", " \n", " 2\n", - " \n", + " \n", " bob\n", " image/png\n", " 1520884\n", @@ -375,7 +376,7 @@ " \n", " \n", " 3\n", - " \n", + " \n", " alice\n", " image/png\n", " 1235401\n", @@ -383,7 +384,7 @@ " \n", " \n", " 4\n", - " \n", + " \n", " bob\n", " image/png\n", " 1591923\n", @@ -463,7 +464,7 @@ "instead of using `db_dtypes` in the future when available in pandas\n", "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", - "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:124: UserWarning: The `json_extract` is deprecated and will be removed in a future\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:121: UserWarning: The `json_extract` is deprecated and will be removed in a future\n", "version. Use `json_query` instead.\n", " warnings.warn(bfe.format_message(msg), category=UserWarning)\n" ] @@ -471,7 +472,7 @@ { "data": { "text/html": [ - "" + "" ], "text/plain": [ "" @@ -483,7 +484,7 @@ { "data": { "text/html": [ - "" + "" ], "text/plain": [ "" @@ -527,19 +528,19 @@ "instead of using `db_dtypes` in the future when available in pandas\n", "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", - "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:180: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", " return method(*args, **kwargs)\n", "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", "instead of using `db_dtypes` in the future when available in pandas\n", "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", - "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:180: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", " return method(*args, **kwargs)\n", "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", "instead of using `db_dtypes` in the future when available in pandas\n", "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", - "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:180: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", " return method(*args, **kwargs)\n" ] } @@ -579,7 +580,7 @@ "instead of using `db_dtypes` in the future when available in pandas\n", "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", - "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:180: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", " return method(*args, **kwargs)\n" ] } @@ -589,9 +590,119 @@ "df_image[\"blur_resized\"] = df_image[\"blurred\"].blob.image_resize((300, 200), dst=f\"gs://{OUTPUT_BUCKET}/image_blur_resize_transformed/\", engine=\"opencv\")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Using `verbose` mode for detailed output\\n\n", + "\\n\n", + "All multimodal functions support a `verbose` parameter, which defaults to `False`.\\n\n", + "\\n\n", + "* When `verbose=False` (the default), the function will only return the main content of the result (e.g., the transformed image, the extracted text).\\n\n", + "* When `verbose=True`, the function returns a `STRUCT` containing two fields:\\n\n", + " * `content`: The main result of the operation.\\n\n", + " * `status`: An informational field. If the operation is successful, this will be empty. If an error occurs during the processing of a specific row, this field will contain the error message, allowing the overall job to complete without failing.\\n\n", + "\\n\n", + "Using `verbose=True` is highly recommended for debugging and for workflows where you need to handle potential failures on a row-by-row basis. Let's see it in action with the `image_blur` function." + ] + }, { "cell_type": "code", "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + " return method(*args, **kwargs)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
blurred_verbose
0{'status': '', 'content': {'uri': 'gs://bigfra...
1{'status': '', 'content': {'uri': 'gs://bigfra...
2{'status': '', 'content': {'uri': 'gs://bigfra...
3{'status': '', 'content': {'uri': 'gs://bigfra...
4{'status': '', 'content': {'uri': 'gs://bigfra...
\n", + "

5 rows × 1 columns

\n", + "
[5 rows x 1 columns in total]" + ], + "text/plain": [ + " blurred_verbose\n", + "0 {'status': '', 'content': {'uri': 'gs://bigfra...\n", + "1 {'status': '', 'content': {'uri': 'gs://bigfra...\n", + "2 {'status': '', 'content': {'uri': 'gs://bigfra...\n", + "3 {'status': '', 'content': {'uri': 'gs://bigfra...\n", + "4 {'status': '', 'content': {'uri': 'gs://bigfra...\n", + "\n", + "[5 rows x 1 columns]" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_image[\"blurred_verbose\"] = df_image[\"image\"].blob.image_blur(\n", + " (20, 20), dst=f\"gs://{OUTPUT_BUCKET}/image_blur_transformed_verbose/\", engine=\"opencv\", verbose=True\n", + ")\n", + "df_image[[\"blurred_verbose\"]]" + ] + }, + { + "cell_type": "code", + "execution_count": 11, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -657,73 +768,79 @@ " resized\n", " normalized\n", " blur_resized\n", + " blurred_verbose\n", " \n", " \n", " \n", " \n", " 0\n", - " \n", + " \n", " alice\n", " image/png\n", " 1591240\n", " 2025-03-20 17:45:04+00:00\n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " {'status': '', 'content': {'uri': 'gs://bigframes_blob_test/image_blur_transformed_verbose/k9-guard-dog-paw-balm.png', 'version': None, 'authorizer': 'bigframes-dev.us.bigframes-default-connection', 'details': None}}\n", " \n", " \n", " 1\n", - " \n", + " \n", " bob\n", " image/png\n", " 1182951\n", " 2025-03-20 17:45:02+00:00\n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " {'status': '', 'content': {'uri': 'gs://bigframes_blob_test/image_blur_transformed_verbose/k9-guard-dog-hot-spot-spray.png', 'version': None, 'authorizer': 'bigframes-dev.us.bigframes-default-connection', 'details': None}}\n", " \n", " \n", " 2\n", - " \n", + " \n", " bob\n", " image/png\n", " 1520884\n", " 2025-03-20 17:44:55+00:00\n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " {'status': '', 'content': {'uri': 'gs://bigframes_blob_test/image_blur_transformed_verbose/fluffy-buns-chinchilla-food-variety-pack.png', 'version': None, 'authorizer': 'bigframes-dev.us.bigframes-default-connection', 'details': None}}\n", " \n", " \n", " 3\n", - " \n", + " \n", " alice\n", " image/png\n", " 1235401\n", " 2025-03-20 17:45:19+00:00\n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " {'status': '', 'content': {'uri': 'gs://bigframes_blob_test/image_blur_transformed_verbose/purrfect-perch-cat-scratcher.png', 'version': None, 'authorizer': 'bigframes-dev.us.bigframes-default-connection', 'details': None}}\n", " \n", " \n", " 4\n", - " \n", + " \n", " bob\n", " image/png\n", " 1591923\n", " 2025-03-20 17:44:47+00:00\n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " {'status': '', 'content': {'uri': 'gs://bigframes_blob_test/image_blur_transformed_verbose/chirpy-seed-deluxe-bird-food.png', 'version': None, 'authorizer': 'bigframes-dev.us.bigframes-default-connection', 'details': None}}\n", " \n", " \n", "\n", - "

5 rows × 9 columns

\n", - "[5 rows x 9 columns in total]" + "

5 rows × 10 columns

\n", + "[5 rows x 10 columns in total]" ], "text/plain": [ " image author content_type \\\n", @@ -761,17 +878,24 @@ "3 {'uri': 'gs://bigframes_blob_test/image_normal... \n", "4 {'uri': 'gs://bigframes_blob_test/image_normal... \n", "\n", - " blur_resized \n", - "0 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", - "1 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", - "2 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", - "3 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", - "4 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", + " blur_resized \\\n", + "0 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", + "1 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", + "2 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", + "3 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", + "4 {'uri': 'gs://bigframes_blob_test/image_blur_r... \n", "\n", - "[5 rows x 9 columns]" + " blurred_verbose \n", + "0 {'status': '', 'content': {'uri': 'gs://bigfra... \n", + "1 {'status': '', 'content': {'uri': 'gs://bigfra... \n", + "2 {'status': '', 'content': {'uri': 'gs://bigfra... \n", + "3 {'status': '', 'content': {'uri': 'gs://bigfra... \n", + "4 {'status': '', 'content': {'uri': 'gs://bigfra... \n", + "\n", + "[5 rows x 10 columns]" ] }, - "execution_count": 10, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } @@ -791,7 +915,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 12, "metadata": { "id": "mRUGfcaFVW-3" }, @@ -800,7 +924,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:180: FutureWarning: Since upgrading the default model can cause unintended breakages, the\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FutureWarning: Since upgrading the default model can cause unintended breakages, the\n", "default model will be removed in BigFrames 3.0. Please supply an\n", "explicit model to avoid this message.\n", " return method(*args, **kwargs)\n" @@ -814,7 +938,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 13, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -874,13 +998,13 @@ " \n", " \n", " 0\n", - " The item is a tin of K9Guard Dog Paw Balm.\n", - " \n", + " The item is a tin of K9 Guard dog paw balm.\n", + " \n", " \n", " \n", " 1\n", - " The item is a bottle of K9 Guard Dog Hot Spot Spray.\n", - " \n", + " The item is K9 Guard Dog Hot Spot Spray.\n", + " \n", " \n", " \n", "\n", @@ -888,9 +1012,9 @@ "[2 rows x 2 columns in total]" ], "text/plain": [ - " ml_generate_text_llm_result \\\n", - "0 The item is a tin of K9Guard Dog Paw Balm. \n", - "1 The item is a bottle of K9 Guard Dog Hot Spot ... \n", + " ml_generate_text_llm_result \\\n", + "0 The item is a tin of K9 Guard dog paw balm. \n", + "1 The item is K9 Guard Dog Hot Spot Spray. \n", "\n", " image \n", "0 {'uri': 'gs://cloud-samples-data/bigquery/tuto... \n", @@ -899,7 +1023,7 @@ "[2 rows x 2 columns]" ] }, - "execution_count": 12, + "execution_count": 13, "metadata": {}, "output_type": "execute_result" } @@ -913,7 +1037,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 14, "metadata": { "id": "IG3J3HsKhyBY" }, @@ -936,7 +1060,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 15, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -996,13 +1120,13 @@ " \n", " \n", " 0\n", - " The item is dog paw balm.\n", - " \n", + " The item is a tin of K9Guard Dog Paw Balm.\n", + " \n", " \n", " \n", " 1\n", - " The picture features a white bottle with a light blue spray nozzle and accents. The background is a neutral gray.\\n\n", - " \n", + " The bottle is mostly white, with a light blue accents. The background is a light gray. There are also black and green elements on the bottle's label.\n", + " \n", " \n", " \n", "\n", @@ -1011,8 +1135,8 @@ ], "text/plain": [ " ml_generate_text_llm_result \\\n", - "0 The item is dog paw balm. \n", - "1 The picture features a white bottle with a lig... \n", + "0 The item is a tin of K9Guard Dog Paw Balm. \n", + "1 The bottle is mostly white, with a light blue ... \n", "\n", " image \n", "0 {'uri': 'gs://cloud-samples-data/bigquery/tuto... \n", @@ -1021,7 +1145,7 @@ "[2 rows x 2 columns]" ] }, - "execution_count": 14, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -1033,7 +1157,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 16, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -1047,7 +1171,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:180: FutureWarning: Since upgrading the default model can cause unintended breakages, the\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FutureWarning: Since upgrading the default model can cause unintended breakages, the\n", "default model will be removed in BigFrames 3.0. Please supply an\n", "explicit model to avoid this message.\n", " return method(*args, **kwargs)\n", @@ -1096,19 +1220,19 @@ " \n", " \n", " 0\n", - " [ 0.00638846 0.01666372 0.00451786 ... -0.02...\n", + " [ 0.00638842 0.01666344 0.00451782 ... -0.02...\n", " \n", " <NA>\n", " <NA>\n", - " {\"access_urls\":{\"expiry_time\":\"2025-10-09T12:2...\n", + " {\"access_urls\":{\"expiry_time\":\"2025-10-25T00:2...\n", " \n", " \n", " 1\n", - " [ 0.0097399 0.0214815 0.00244266 ... 0.00...\n", + " [ 0.00973689 0.02148374 0.00244311 ... 0.00...\n", " \n", " <NA>\n", " <NA>\n", - " {\"access_urls\":{\"expiry_time\":\"2025-10-09T12:2...\n", + " {\"access_urls\":{\"expiry_time\":\"2025-10-25T00:2...\n", " \n", " \n", "\n", @@ -1117,8 +1241,8 @@ ], "text/plain": [ " ml_generate_embedding_result \\\n", - "0 [ 0.00638846 0.01666372 0.00451786 ... -0.02... \n", - "1 [ 0.0097399 0.0214815 0.00244266 ... 0.00... \n", + "0 [ 0.00638842 0.01666344 0.00451782 ... -0.02... \n", + "1 [ 0.00973689 0.02148374 0.00244311 ... 0.00... \n", "\n", " ml_generate_embedding_status ml_generate_embedding_start_sec \\\n", "0 \n", @@ -1129,13 +1253,13 @@ "1 \n", "\n", " content \n", - "0 {\"access_urls\":{\"expiry_time\":\"2025-10-09T12:2... \n", - "1 {\"access_urls\":{\"expiry_time\":\"2025-10-09T12:2... \n", + "0 {\"access_urls\":{\"expiry_time\":\"2025-10-25T00:2... \n", + "1 {\"access_urls\":{\"expiry_time\":\"2025-10-25T00:2... \n", "\n", "[2 rows x 5 columns]" ] }, - "execution_count": 15, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } @@ -1158,7 +1282,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 17, "metadata": { "id": "oDDuYtUm5Yiy" }, @@ -1180,7 +1304,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 18, "metadata": { "colab": { "base_uri": "https://localhost:8080/" @@ -1197,9 +1321,12 @@ "instead of using `db_dtypes` in the future when available in pandas\n", "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", - "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:180: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", " return method(*args, **kwargs)\n", - "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:244: UserWarning: The `json_extract_string_array` is deprecated and will be removed in a\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:239: UserWarning: The `json_extract_string_array` is deprecated and will be removed in a\n", + "future version. Use `json_value_array` instead.\n", + " warnings.warn(bfe.format_message(msg), category=UserWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:239: UserWarning: The `json_extract_string_array` is deprecated and will be removed in a\n", "future version. Use `json_value_array` instead.\n", " warnings.warn(bfe.format_message(msg), category=UserWarning)\n" ] @@ -1211,7 +1338,78 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: FunctionAxisOnePreviewWarning: Blob Functions use bigframes DataFrame Managed function with axis=1 senario, which is a preview feature.\n", + " return method(*args, **kwargs)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/bigquery/_operations/json.py:239: UserWarning: The `json_extract_string_array` is deprecated and will be removed in a\n", + "future version. Use `json_value_array` instead.\n", + " warnings.warn(bfe.format_message(msg), category=UserWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
chunked_verbose
0{'status': '', 'content': array([\"CritterCuisi...
\n", + "

1 rows × 1 columns

\n", + "
[1 rows x 1 columns in total]" + ], + "text/plain": [ + " chunked_verbose\n", + "0 {'status': '', 'content': array([\"CritterCuisi...\n", + "\n", + "[1 rows x 1 columns]" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_pdf[\"chunked_verbose\"] = df_pdf[\"pdf\"].blob.pdf_chunk(engine=\"pypdf\", verbose=True)\n", + "df_pdf[[\"chunked_verbose\"]]" + ] + }, + { + "cell_type": "code", + "execution_count": 20, "metadata": { "id": "kaPvJATN7zlw" }, @@ -1239,7 +1437,7 @@ "Name: chunked, dtype: string" ] }, - "execution_count": 18, + "execution_count": 20, "metadata": {}, "output_type": "execute_result" } @@ -1258,7 +1456,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 21, "metadata": {}, "outputs": [ { @@ -1279,7 +1477,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 22, "metadata": {}, "outputs": [ { @@ -1303,7 +1501,7 @@ "Name: transcribed_content, dtype: string" ] }, - "execution_count": 20, + "execution_count": 22, "metadata": {}, "output_type": "execute_result" } @@ -1312,6 +1510,42 @@ "transcribed_series = df['audio'].blob.audio_transcribe(model_name=\"gemini-2.0-flash-001\", verbose=False)\n", "transcribed_series" ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/github.com/googleapis/python-bigquery-dataframes/bigframes/dtypes.py:959: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/plain": [ + "0 {'status': '', 'content': 'Now, as all books, ...\n", + "Name: transcription_results, dtype: struct[pyarrow]" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "transcribed_series_verbose = df['audio'].blob.audio_transcribe(model_name=\"gemini-2.0-flash-001\", verbose=True)\n", + "transcribed_series_verbose" + ] } ], "metadata": { From d69ba8871a9d36ce26429846375b0f21515db6ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Wed, 29 Oct 2025 16:52:45 -0500 Subject: [PATCH 188/313] refactor: update geo "spec" and split geo ops in ibis compiler (#2208) --- .../core/compile/ibis_compiler/__init__.py | 1 + .../ibis_compiler/operations/geo_ops.py | 159 ++++++++++++++++++ .../ibis_compiler/scalar_op_registry.py | 134 --------------- specs/2025-08-04-geoseries-scalars.md | 13 +- 4 files changed, 168 insertions(+), 139 deletions(-) create mode 100644 bigframes/core/compile/ibis_compiler/operations/geo_ops.py diff --git a/bigframes/core/compile/ibis_compiler/__init__.py b/bigframes/core/compile/ibis_compiler/__init__.py index aef0ed9267..6b9d284c53 100644 --- a/bigframes/core/compile/ibis_compiler/__init__.py +++ b/bigframes/core/compile/ibis_compiler/__init__.py @@ -21,4 +21,5 @@ from __future__ import annotations import bigframes.core.compile.ibis_compiler.operations.generic_ops # noqa: F401 +import bigframes.core.compile.ibis_compiler.operations.geo_ops # noqa: F401 import bigframes.core.compile.ibis_compiler.scalar_op_registry # noqa: F401 diff --git a/bigframes/core/compile/ibis_compiler/operations/geo_ops.py b/bigframes/core/compile/ibis_compiler/operations/geo_ops.py new file mode 100644 index 0000000000..f9155fed5a --- /dev/null +++ b/bigframes/core/compile/ibis_compiler/operations/geo_ops.py @@ -0,0 +1,159 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import cast + +from bigframes_vendored.ibis.expr import types as ibis_types +import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes +import bigframes_vendored.ibis.expr.operations.udf as ibis_udf + +from bigframes.core.compile.ibis_compiler import scalar_op_compiler +from bigframes.operations import geo_ops as ops + +register_unary_op = scalar_op_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_op_compiler.scalar_op_compiler.register_binary_op + + +# Geo Ops +@register_unary_op(ops.geo_area_op) +def geo_area_op_impl(x: ibis_types.Value): + return cast(ibis_types.GeoSpatialValue, x).area() + + +@register_unary_op(ops.geo_st_astext_op) +def geo_st_astext_op_impl(x: ibis_types.Value): + return cast(ibis_types.GeoSpatialValue, x).as_text() + + +@register_unary_op(ops.geo_st_boundary_op, pass_op=False) +def geo_st_boundary_op_impl(x: ibis_types.Value): + return st_boundary(x) + + +@register_unary_op(ops.GeoStBufferOp, pass_op=True) +def geo_st_buffer_op_impl(x: ibis_types.Value, op: ops.GeoStBufferOp): + return st_buffer( + x, + op.buffer_radius, + op.num_seg_quarter_circle, + op.use_spheroid, + ) + + +@register_unary_op(ops.geo_st_centroid_op, pass_op=False) +def geo_st_centroid_op_impl(x: ibis_types.Value): + return cast(ibis_types.GeoSpatialValue, x).centroid() + + +@register_unary_op(ops.geo_st_convexhull_op, pass_op=False) +def geo_st_convexhull_op_impl(x: ibis_types.Value): + return st_convexhull(x) + + +@register_binary_op(ops.geo_st_difference_op, pass_op=False) +def geo_st_difference_op_impl(x: ibis_types.Value, y: ibis_types.Value): + return cast(ibis_types.GeoSpatialValue, x).difference( + cast(ibis_types.GeoSpatialValue, y) + ) + + +@register_binary_op(ops.GeoStDistanceOp, pass_op=True) +def geo_st_distance_op_impl( + x: ibis_types.Value, y: ibis_types.Value, op: ops.GeoStDistanceOp +): + return st_distance(x, y, op.use_spheroid) + + +@register_unary_op(ops.geo_st_geogfromtext_op) +def geo_st_geogfromtext_op_impl(x: ibis_types.Value): + # Ibis doesn't seem to provide a dedicated method to cast from string to geography, + # so we use a BigQuery scalar function, st_geogfromtext(), directly. + return st_geogfromtext(x) + + +@register_binary_op(ops.geo_st_geogpoint_op, pass_op=False) +def geo_st_geogpoint_op_impl(x: ibis_types.Value, y: ibis_types.Value): + return cast(ibis_types.NumericValue, x).point(cast(ibis_types.NumericValue, y)) + + +@register_binary_op(ops.geo_st_intersection_op, pass_op=False) +def geo_st_intersection_op_impl(x: ibis_types.Value, y: ibis_types.Value): + return cast(ibis_types.GeoSpatialValue, x).intersection( + cast(ibis_types.GeoSpatialValue, y) + ) + + +@register_unary_op(ops.geo_st_isclosed_op, pass_op=False) +def geo_st_isclosed_op_impl(x: ibis_types.Value): + return st_isclosed(x) + + +@register_unary_op(ops.geo_x_op) +def geo_x_op_impl(x: ibis_types.Value): + return cast(ibis_types.GeoSpatialValue, x).x() + + +@register_unary_op(ops.GeoStLengthOp, pass_op=True) +def geo_length_op_impl(x: ibis_types.Value, op: ops.GeoStLengthOp): + # Call the st_length UDF defined in this file (or imported) + return st_length(x, op.use_spheroid) + + +@register_unary_op(ops.geo_y_op) +def geo_y_op_impl(x: ibis_types.Value): + return cast(ibis_types.GeoSpatialValue, x).y() + + +@ibis_udf.scalar.builtin +def st_convexhull(x: ibis_dtypes.geography) -> ibis_dtypes.geography: # type: ignore + """ST_CONVEXHULL""" + ... + + +@ibis_udf.scalar.builtin +def st_geogfromtext(a: str) -> ibis_dtypes.geography: # type: ignore + """Convert string to geography.""" + + +@ibis_udf.scalar.builtin +def st_boundary(a: ibis_dtypes.geography) -> ibis_dtypes.geography: # type: ignore + """Find the boundary of a geography.""" + + +@ibis_udf.scalar.builtin +def st_buffer( + geography: ibis_dtypes.geography, # type: ignore + buffer_radius: ibis_dtypes.Float64, + num_seg_quarter_circle: ibis_dtypes.Float64, + use_spheroid: ibis_dtypes.Boolean, +) -> ibis_dtypes.geography: # type: ignore + ... + + +@ibis_udf.scalar.builtin +def st_distance(a: ibis_dtypes.geography, b: ibis_dtypes.geography, use_spheroid: bool) -> ibis_dtypes.float: # type: ignore + """Convert string to geography.""" + + +@ibis_udf.scalar.builtin +def st_length(geog: ibis_dtypes.geography, use_spheroid: bool) -> ibis_dtypes.float: # type: ignore + """ST_LENGTH BQ builtin. This body is never executed.""" + pass + + +@ibis_udf.scalar.builtin +def st_isclosed(a: ibis_dtypes.geography) -> ibis_dtypes.boolean: # type: ignore + """Checks if a geography is closed.""" diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index e983fc7e21..0876722990 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -837,98 +837,6 @@ def normalize_op_impl(x: ibis_types.Value): return result.cast(result_type) -# Geo Ops -@scalar_op_compiler.register_unary_op(ops.geo_area_op) -def geo_area_op_impl(x: ibis_types.Value): - return typing.cast(ibis_types.GeoSpatialValue, x).area() - - -@scalar_op_compiler.register_unary_op(ops.geo_st_astext_op) -def geo_st_astext_op_impl(x: ibis_types.Value): - return typing.cast(ibis_types.GeoSpatialValue, x).as_text() - - -@scalar_op_compiler.register_unary_op(ops.geo_st_boundary_op, pass_op=False) -def geo_st_boundary_op_impl(x: ibis_types.Value): - return st_boundary(x) - - -@scalar_op_compiler.register_unary_op(ops.GeoStBufferOp, pass_op=True) -def geo_st_buffer_op_impl(x: ibis_types.Value, op: ops.GeoStBufferOp): - return st_buffer( - x, - op.buffer_radius, - op.num_seg_quarter_circle, - op.use_spheroid, - ) - - -@scalar_op_compiler.register_unary_op(ops.geo_st_centroid_op, pass_op=False) -def geo_st_centroid_op_impl(x: ibis_types.Value): - return typing.cast(ibis_types.GeoSpatialValue, x).centroid() - - -@scalar_op_compiler.register_unary_op(ops.geo_st_convexhull_op, pass_op=False) -def geo_st_convexhull_op_impl(x: ibis_types.Value): - return st_convexhull(x) - - -@scalar_op_compiler.register_binary_op(ops.geo_st_difference_op, pass_op=False) -def geo_st_difference_op_impl(x: ibis_types.Value, y: ibis_types.Value): - return typing.cast(ibis_types.GeoSpatialValue, x).difference( - typing.cast(ibis_types.GeoSpatialValue, y) - ) - - -@scalar_op_compiler.register_binary_op(ops.GeoStDistanceOp, pass_op=True) -def geo_st_distance_op_impl( - x: ibis_types.Value, y: ibis_types.Value, op: ops.GeoStDistanceOp -): - return st_distance(x, y, op.use_spheroid) - - -@scalar_op_compiler.register_unary_op(ops.geo_st_geogfromtext_op) -def geo_st_geogfromtext_op_impl(x: ibis_types.Value): - # Ibis doesn't seem to provide a dedicated method to cast from string to geography, - # so we use a BigQuery scalar function, st_geogfromtext(), directly. - return st_geogfromtext(x) - - -@scalar_op_compiler.register_binary_op(ops.geo_st_geogpoint_op, pass_op=False) -def geo_st_geogpoint_op_impl(x: ibis_types.Value, y: ibis_types.Value): - return typing.cast(ibis_types.NumericValue, x).point( - typing.cast(ibis_types.NumericValue, y) - ) - - -@scalar_op_compiler.register_binary_op(ops.geo_st_intersection_op, pass_op=False) -def geo_st_intersection_op_impl(x: ibis_types.Value, y: ibis_types.Value): - return typing.cast(ibis_types.GeoSpatialValue, x).intersection( - typing.cast(ibis_types.GeoSpatialValue, y) - ) - - -@scalar_op_compiler.register_unary_op(ops.geo_st_isclosed_op, pass_op=False) -def geo_st_isclosed_op_impl(x: ibis_types.Value): - return st_isclosed(x) - - -@scalar_op_compiler.register_unary_op(ops.geo_x_op) -def geo_x_op_impl(x: ibis_types.Value): - return typing.cast(ibis_types.GeoSpatialValue, x).x() - - -@scalar_op_compiler.register_unary_op(ops.GeoStLengthOp, pass_op=True) -def geo_length_op_impl(x: ibis_types.Value, op: ops.GeoStLengthOp): - # Call the st_length UDF defined in this file (or imported) - return st_length(x, op.use_spheroid) - - -@scalar_op_compiler.register_unary_op(ops.geo_y_op) -def geo_y_op_impl(x: ibis_types.Value): - return typing.cast(ibis_types.GeoSpatialValue, x).y() - - # Parameterized ops @scalar_op_compiler.register_unary_op(ops.StructFieldOp, pass_op=True) def struct_field_op_impl(x: ibis_types.Value, op: ops.StructFieldOp): @@ -2092,17 +2000,6 @@ def _ibis_num(number: float): return typing.cast(ibis_types.NumericValue, ibis_types.literal(number)) -@ibis_udf.scalar.builtin -def st_convexhull(x: ibis_dtypes.geography) -> ibis_dtypes.geography: # type: ignore - """ST_CONVEXHULL""" - ... - - -@ibis_udf.scalar.builtin -def st_geogfromtext(a: str) -> ibis_dtypes.geography: # type: ignore - """Convert string to geography.""" - - @ibis_udf.scalar.builtin def timestamp(a: str) -> ibis_dtypes.timestamp: # type: ignore """Convert string to timestamp.""" @@ -2113,32 +2010,6 @@ def unix_millis(a: ibis_dtypes.timestamp) -> int: # type: ignore """Convert a timestamp to milliseconds""" -@ibis_udf.scalar.builtin -def st_boundary(a: ibis_dtypes.geography) -> ibis_dtypes.geography: # type: ignore - """Find the boundary of a geography.""" - - -@ibis_udf.scalar.builtin -def st_buffer( - geography: ibis_dtypes.geography, # type: ignore - buffer_radius: ibis_dtypes.Float64, - num_seg_quarter_circle: ibis_dtypes.Float64, - use_spheroid: ibis_dtypes.Boolean, -) -> ibis_dtypes.geography: # type: ignore - ... - - -@ibis_udf.scalar.builtin -def st_distance(a: ibis_dtypes.geography, b: ibis_dtypes.geography, use_spheroid: bool) -> ibis_dtypes.float: # type: ignore - """Convert string to geography.""" - - -@ibis_udf.scalar.builtin -def st_length(geog: ibis_dtypes.geography, use_spheroid: bool) -> ibis_dtypes.float: # type: ignore - """ST_LENGTH BQ builtin. This body is never executed.""" - pass - - @ibis_udf.scalar.builtin def unix_micros(a: ibis_dtypes.timestamp) -> int: # type: ignore """Convert a timestamp to microseconds""" @@ -2272,11 +2143,6 @@ def str_lstrip_op( # type: ignore[empty-body] """Remove leading and trailing characters.""" -@ibis_udf.scalar.builtin -def st_isclosed(a: ibis_dtypes.geography) -> ibis_dtypes.boolean: # type: ignore - """Checks if a geography is closed.""" - - @ibis_udf.scalar.builtin(name="rtrim") def str_rstrip_op( # type: ignore[empty-body] x: ibis_dtypes.String, to_strip: ibis_dtypes.String diff --git a/specs/2025-08-04-geoseries-scalars.md b/specs/2025-08-04-geoseries-scalars.md index 38dc77c4cf..66ed77d0dd 100644 --- a/specs/2025-08-04-geoseries-scalars.md +++ b/specs/2025-08-04-geoseries-scalars.md @@ -267,11 +267,14 @@ Raster functions: Functions for analyzing geospatial rasters using geographies. - [ ] **Export the new operation:** - [ ] In `bigframes/operations/__init__.py`, import your new operation dataclass and add it to the `__all__` list. - [ ] **Implement the compilation logic:** - - [ ] In `bigframes/core/compile/scalar_op_compiler.py`: - - [ ] If the BigQuery function has a direct equivalent in Ibis, you can often reuse an existing Ibis method. - - [ ] If not, define a new Ibis UDF using `@ibis_udf.scalar.builtin` to map to the specific BigQuery function signature. - - [ ] Create a new compiler implementation function (e.g., `geo_length_op_impl`). - - [ ] Register this function to your operation dataclass using `@scalar_op_compiler.register_unary_op` or `@scalar_op_compiler.register_binary_op`. + - [ ] In `bigframes/core/compile/ibis_compiler/operations/geo_ops.py`: + - [ ] If the BigQuery function has a direct equivalent in Ibis, you can often reuse an existing Ibis method. + - [ ] If not, define a new Ibis UDF using `@ibis_udf.scalar.builtin` to map to the specific BigQuery function signature. + - [ ] Create a new compiler implementation function (e.g., `geo_length_op_impl`). + - [ ] Register this function to your operation dataclass using `@register_unary_op` or `@register_binary_op`. + - [ ] In `bigframes/core/compile/sqlglot/expressions/geo_ops.py`: + - [ ] Create a new compiler implementation function that generates the appropriate `sqlglot.exp` expression. + - [ ] Register this function to your operation dataclass using `@register_unary_op` or `@register_binary_op`. - [ ] **Implement the user-facing function or property:** - [ ] For a `bigframes.bigquery` function: - [ ] In `bigframes/bigquery/_operations/geo.py`, create the user-facing function (e.g., `st_length`). From d97cafcb5921fca2351b18011b0e54e2631cc53d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Thu, 30 Oct 2025 09:36:05 -0500 Subject: [PATCH 189/313] feat: support INFORMATION_SCHEMA views in `read_gbq` (#1895) * feat: support INFORMATION_SCHEMA tables in read_gbq * avoid storage semi executor * use faster tables for peek tests * more tests * fix mypy * Update bigframes/session/_io/bigquery/read_gbq_table.py * immediately query for information_schema tables * Fix mypy errors and temporarily update python version * snapshot * snapshot again --- .../session/_io/bigquery/read_gbq_table.py | 96 +++++++++++++++++-- bigframes/session/loader.py | 30 +++--- bigframes/session/read_api_execution.py | 3 + .../test_read_gbq_information_schema.py | 50 ++++++++++ tests/unit/session/test_session.py | 4 +- 5 files changed, 161 insertions(+), 22 deletions(-) create mode 100644 tests/system/small/pandas/test_read_gbq_information_schema.py diff --git a/bigframes/session/_io/bigquery/read_gbq_table.py b/bigframes/session/_io/bigquery/read_gbq_table.py index f8a379aee9..465fa08187 100644 --- a/bigframes/session/_io/bigquery/read_gbq_table.py +++ b/bigframes/session/_io/bigquery/read_gbq_table.py @@ -28,6 +28,7 @@ import google.cloud.bigquery as bigquery import google.cloud.bigquery.table +import bigframes.core import bigframes.core.events import bigframes.exceptions as bfe import bigframes.session._io.bigquery @@ -37,18 +38,79 @@ import bigframes.session +def _convert_information_schema_table_id_to_table_reference( + table_id: str, + default_project: Optional[str], +) -> bigquery.TableReference: + """Squeeze an INFORMATION_SCHEMA reference into a TableReference. + This is kind-of a hack. INFORMATION_SCHEMA is a view that isn't available + via the tables.get REST API. + """ + parts = table_id.split(".") + parts_casefold = [part.casefold() for part in parts] + dataset_index = parts_casefold.index("INFORMATION_SCHEMA".casefold()) + + if dataset_index == 0: + project = default_project + else: + project = ".".join(parts[:dataset_index]) + + if project is None: + message = ( + "Could not determine project ID. " + "Please provide a project or region in your INFORMATION_SCHEMA table ID, " + "For example, 'region-REGION_NAME.INFORMATION_SCHEMA.JOBS'." + ) + raise ValueError(message) + + dataset = "INFORMATION_SCHEMA" + table_id_short = ".".join(parts[dataset_index + 1 :]) + return bigquery.TableReference( + bigquery.DatasetReference(project, dataset), + table_id_short, + ) + + +def get_information_schema_metadata( + bqclient: bigquery.Client, + table_id: str, + default_project: Optional[str], +) -> bigquery.Table: + job_config = bigquery.QueryJobConfig(dry_run=True) + job = bqclient.query( + f"SELECT * FROM `{table_id}`", + job_config=job_config, + ) + table_ref = _convert_information_schema_table_id_to_table_reference( + table_id=table_id, + default_project=default_project, + ) + table = bigquery.Table.from_api_repr( + { + "tableReference": table_ref.to_api_repr(), + "location": job.location, + # Prevent ourselves from trying to read the table with the BQ + # Storage API. + "type": "VIEW", + } + ) + table.schema = job.schema + return table + + def get_table_metadata( bqclient: bigquery.Client, - table_ref: google.cloud.bigquery.table.TableReference, - bq_time: datetime.datetime, *, - cache: Dict[bigquery.TableReference, Tuple[datetime.datetime, bigquery.Table]], + table_id: str, + default_project: Optional[str], + bq_time: datetime.datetime, + cache: Dict[str, Tuple[datetime.datetime, bigquery.Table]], use_cache: bool = True, publisher: bigframes.core.events.Publisher, ) -> Tuple[datetime.datetime, google.cloud.bigquery.table.Table]: """Get the table metadata, either from cache or via REST API.""" - cached_table = cache.get(table_ref) + cached_table = cache.get(table_id) if use_cache and cached_table is not None: snapshot_timestamp, table = cached_table @@ -90,7 +152,16 @@ def get_table_metadata( return cached_table - table = bqclient.get_table(table_ref) + if is_information_schema(table_id): + table = get_information_schema_metadata( + bqclient=bqclient, table_id=table_id, default_project=default_project + ) + else: + table_ref = google.cloud.bigquery.table.TableReference.from_string( + table_id, default_project=default_project + ) + table = bqclient.get_table(table_ref) + # local time will lag a little bit do to network latency # make sure it is at least table creation time. # This is relevant if the table was created immediately before loading it here. @@ -98,10 +169,21 @@ def get_table_metadata( bq_time = table.created cached_table = (bq_time, table) - cache[table_ref] = cached_table + cache[table_id] = cached_table return cached_table +def is_information_schema(table_id: str): + table_id_casefold = table_id.casefold() + # Include the "."s to ensure we don't have false positives for some user + # defined dataset like MY_INFORMATION_SCHEMA or tables called + # INFORMATION_SCHEMA. + return ( + ".INFORMATION_SCHEMA.".casefold() in table_id_casefold + or table_id_casefold.startswith("INFORMATION_SCHEMA.".casefold()) + ) + + def is_time_travel_eligible( bqclient: bigquery.Client, table: google.cloud.bigquery.table.Table, @@ -168,6 +250,8 @@ def is_time_travel_eligible( msg, category=bfe.TimeTravelDisabledWarning, stacklevel=stacklevel ) return False + elif table.table_type == "VIEW": + return False # table might support time travel, lets do a dry-run query with time travel if should_dry_run: diff --git a/bigframes/session/loader.py b/bigframes/session/loader.py index 940fdc1352..2d5dec13e6 100644 --- a/bigframes/session/loader.py +++ b/bigframes/session/loader.py @@ -47,6 +47,8 @@ import pandas import pyarrow as pa +import bigframes._tools +import bigframes._tools.strings from bigframes.core import guid, identifiers, local_data, nodes, ordering, utils import bigframes.core as core import bigframes.core.blocks as blocks @@ -272,9 +274,7 @@ def __init__( self._default_index_type = default_index_type self._scan_index_uniqueness = scan_index_uniqueness self._force_total_order = force_total_order - self._df_snapshot: Dict[ - bigquery.TableReference, Tuple[datetime.datetime, bigquery.Table] - ] = {} + self._df_snapshot: Dict[str, Tuple[datetime.datetime, bigquery.Table]] = {} self._metrics = metrics self._publisher = publisher # Unfortunate circular reference, but need to pass reference when constructing objects @@ -629,10 +629,6 @@ def read_gbq_table( _check_duplicates("columns", columns) - table_ref = google.cloud.bigquery.table.TableReference.from_string( - table_id, default_project=self._bqclient.project - ) - columns = list(columns) include_all_columns = columns is None or len(columns) == 0 filters = typing.cast(list, list(filters)) @@ -643,7 +639,8 @@ def read_gbq_table( time_travel_timestamp, table = bf_read_gbq_table.get_table_metadata( self._bqclient, - table_ref=table_ref, + table_id=table_id, + default_project=self._bqclient.project, bq_time=self._clock.get_time(), cache=self._df_snapshot, use_cache=use_cache, @@ -706,18 +703,23 @@ def read_gbq_table( # Optionally, execute the query # ----------------------------- - # max_results introduces non-determinism and limits the cost on - # clustered tables, so fallback to a query. We do this here so that - # the index is consistent with tables that have primary keys, even - # when max_results is set. - if max_results is not None: + if ( + # max_results introduces non-determinism and limits the cost on + # clustered tables, so fallback to a query. We do this here so that + # the index is consistent with tables that have primary keys, even + # when max_results is set. + max_results is not None + # Views such as INFORMATION_SCHEMA can introduce non-determinism. + # They can update frequently and don't support time travel. + or bf_read_gbq_table.is_information_schema(table_id) + ): # TODO(b/338111344): If we are running a query anyway, we might as # well generate ROW_NUMBER() at the same time. all_columns: Iterable[str] = ( itertools.chain(index_cols, columns) if columns else () ) query = bf_io_bigquery.to_query( - table_id, + f"{table.project}.{table.dataset_id}.{table.table_id}", columns=all_columns, sql_predicate=bf_io_bigquery.compile_filters(filters) if filters diff --git a/bigframes/session/read_api_execution.py b/bigframes/session/read_api_execution.py index 2530a1dc8d..136c279c08 100644 --- a/bigframes/session/read_api_execution.py +++ b/bigframes/session/read_api_execution.py @@ -46,6 +46,9 @@ def execute( if node.explicitly_ordered and ordered: return None + if not node.source.table.is_physically_stored: + return None + if limit is not None: if peek is None or limit < peek: peek = limit diff --git a/tests/system/small/pandas/test_read_gbq_information_schema.py b/tests/system/small/pandas/test_read_gbq_information_schema.py new file mode 100644 index 0000000000..32e2dc4712 --- /dev/null +++ b/tests/system/small/pandas/test_read_gbq_information_schema.py @@ -0,0 +1,50 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + + +@pytest.mark.parametrize("include_project", [True, False]) +@pytest.mark.parametrize( + "view_id", + [ + # https://cloud.google.com/bigquery/docs/information-schema-intro + "region-US.INFORMATION_SCHEMA.SESSIONS_BY_USER", + "region-US.INFORMATION_SCHEMA.SCHEMATA", + ], +) +def test_read_gbq_jobs_by_user_returns_schema( + unordered_session, view_id: str, include_project: bool +): + if include_project: + table_id = unordered_session.bqclient.project + "." + view_id + else: + table_id = view_id + + df = unordered_session.read_gbq(table_id, max_results=10) + assert df.dtypes is not None + + +def test_read_gbq_schemata_can_be_peeked(unordered_session): + df = unordered_session.read_gbq("region-US.INFORMATION_SCHEMA.SCHEMATA") + result = df.peek() + assert result is not None + + +def test_read_gbq_schemata_four_parts_can_be_peeked(unordered_session): + df = unordered_session.read_gbq( + f"{unordered_session.bqclient.project}.region-US.INFORMATION_SCHEMA.SCHEMATA" + ) + result = df.peek() + assert result is not None diff --git a/tests/unit/session/test_session.py b/tests/unit/session/test_session.py index d05957b941..f003398706 100644 --- a/tests/unit/session/test_session.py +++ b/tests/unit/session/test_session.py @@ -242,7 +242,7 @@ def test_read_gbq_cached_table(): table._properties["numRows"] = "1000000000" table._properties["location"] = session._location table._properties["type"] = "TABLE" - session._loader._df_snapshot[table_ref] = ( + session._loader._df_snapshot[str(table_ref)] = ( datetime.datetime(1999, 1, 2, 3, 4, 5, 678901, tzinfo=datetime.timezone.utc), table, ) @@ -273,7 +273,7 @@ def test_read_gbq_cached_table_doesnt_warn_for_anonymous_tables_and_doesnt_inclu table._properties["numRows"] = "1000000000" table._properties["location"] = session._location table._properties["type"] = "TABLE" - session._loader._df_snapshot[table_ref] = ( + session._loader._df_snapshot[str(table_ref)] = ( datetime.datetime(1999, 1, 2, 3, 4, 5, 678901, tzinfo=datetime.timezone.utc), table, ) From 316ba9f557d792117d5a7845d7567498f78dd513 Mon Sep 17 00:00:00 2001 From: ccarpentiere Date: Fri, 31 Oct 2025 07:04:34 -0700 Subject: [PATCH 190/313] docs: update bq_dataframes_llm_output_schema.ipynb (#2004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated output schema tutorial notebook * Cleaned up language and structure. * Added set up and clean up sections. * Additional small spelling fixes for bq_dataframes_llm_output_schema.ipynb. * Removed Vertex AI import link * Remove placeholder project name that is breaking tests --------- Co-authored-by: Tim Sweña (Swast) --- .../bq_dataframes_llm_output_schema.ipynb | 175 ++++++++++++++++-- 1 file changed, 155 insertions(+), 20 deletions(-) diff --git a/notebooks/generative_ai/bq_dataframes_llm_output_schema.ipynb b/notebooks/generative_ai/bq_dataframes_llm_output_schema.ipynb index 70714c823c..5399363e34 100644 --- a/notebooks/generative_ai/bq_dataframes_llm_output_schema.ipynb +++ b/notebooks/generative_ai/bq_dataframes_llm_output_schema.ipynb @@ -25,7 +25,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# BigFrames LLM Output Schema\n", + "# Format LLM output using an output schema\n", "\n", "\n", "\n", @@ -43,7 +43,7 @@ " \n", "
\n", " \n", " \"BQ\n", - " Open in BQ Studio\n", + " Open in BigQuery Studio\n", " \n", "
\n" @@ -53,26 +53,124 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "This Notebook introduces BigFrames LLM with output schema to generate structured output dataframes." + "This notebook shows you how to create structured LLM output by specifying an output schema when generating predictions with a Gemini model." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### Setup" + "## Costs\n", + "\n", + "This tutorial uses billable components of Google Cloud:\n", + "\n", + "* BigQuery (compute)\n", + "* BigQuery ML\n", + "* Generative AI support on Vertex AI\n", + "\n", + "Learn about [BigQuery compute pricing](https://cloud.google.com/bigquery/pricing#analysis_pricing_models), [Generative AI support on Vertex AI pricing](https://cloud.google.com/vertex-ai/generative-ai/pricing),\n", + "and [BigQuery ML pricing](https://cloud.google.com/bigquery/pricing#section-11),\n", + "and use the [Pricing Calculator](https://cloud.google.com/products/calculator/)\n", + "to generate a cost estimate based on your projected usage." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Before you begin\n", + "\n", + "Complete the tasks in this section to set up your environment." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Set up your Google Cloud project\n", + "\n", + "**The following steps are required, regardless of your notebook environment.**\n", + "\n", + "1. [Select or create a Google Cloud project](https://console.cloud.google.com/cloud-resource-manager). When you first create an account, you get a $300 credit towards your compute/storage costs.\n", + "\n", + "2. [Make sure that billing is enabled for your project](https://cloud.google.com/billing/docs/how-to/modify-project).\n", + "\n", + "3. [Click here](https://console.cloud.google.com/flows/enableapi?apiid=bigquery.googleapis.com,bigqueryconnection.googleapis.com,aiplatform.googleapis.com) to enable the following APIs:\n", + "\n", + " * BigQuery API\n", + " * BigQuery Connection API\n", + " * Vertex AI API\n", + "\n", + "4. If you are running this notebook locally, install the [Cloud SDK](https://cloud.google.com/sdk)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Authenticate your Google Cloud account\n", + "\n", + "Depending on your Jupyter environment, you might have to manually authenticate. Follow the relevant instructions below.\n", + "\n", + "**BigQuery Studio** or **Vertex AI Workbench**\n", + "\n", + "Do nothing, you are already authenticated.\n", + "\n", + "**Local JupyterLab instance**\n", + "\n", + "Uncomment and run the following cell:" ] }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ - "PROJECT = \"bigframes-dev\" # replace with your project\n", + "# ! gcloud auth login" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Colab**\n", "\n", + "Uncomment and run the following cell:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# from google.colab import auth\n", + "# auth.authenticate_user()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Set up your project" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Set your project and import necessary modules. If you don't know your project ID, see [Locate the project ID](https://support.google.com/googleapi/answer/7014113)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "PROJECT = \"\" # replace with your project\n", "import bigframes\n", - "# Setup project\n", "bigframes.options.bigquery.project = PROJECT\n", "bigframes.options.display.progress_bar = None\n", "\n", @@ -84,8 +182,8 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### 1. Create a BigFrames DataFrame and a Gemini model\n", - "Starting from creating a simple dataframe of several cities and a Gemini model in BigFrames" + "## Create a DataFrame and a Gemini model\n", + "Create a simple [DataFrame](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.dataframe.DataFrame) of several cities:" ] }, { @@ -162,6 +260,13 @@ "df" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Connect to a Gemini model using the [`GeminiTextGenerator` class](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator):" + ] + }, { "cell_type": "code", "execution_count": 4, @@ -186,8 +291,8 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### 2. Generate structured output data\n", - "Before, llm models can only generate text output. Saying if you want to know whether the city is a US city, for example:" + "## Generate structured output data\n", + "Previously, LLMs could only generate text output. For example, you could generate output that identifies whether a given city is a US city:" ] }, { @@ -273,9 +378,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The outputs are text results that human can read. But if want the output data to be more useful for analysis, it is better to transfer to structured data like boolean, int or float values. Usually the process wasn't easy.\n", + "The output is text that a human can read. However, if you want the output to be more useful for analysis, it is better to format the output as structured data. This is especially true when you want to have Boolean, integer, or float values to work with instead of string values. Previously, formatting the output in this way wasn't easy.\n", "\n", - "Now you can get structured output out-of-the-box by specifying the output_schema parameter in Gemini model predict method. In below example, the outputs are only boolean values." + "Now, you can get structured output out-of-the-box by specifying the `output_schema` parameter when calling the Gemini model's [`predict` method](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator#bigframes_ml_llm_GeminiTextGenerator_predict). In the following example, the model output is formatted as Boolean values:" ] }, { @@ -361,7 +466,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "You can also get float or int values, for example, to get populations in millions:" + "You can also format model output as float or integer values. In the following example, the model output is formatted as float values to show the city's population in millions:" ] }, { @@ -447,7 +552,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "And yearly rainy days:" + "In the following example, the model output is formatted as integer values to show the count of the city's rainy days:" ] }, { @@ -533,10 +638,10 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### 3. Generate all types of data in one prediction\n", - "You can get the different output columns and types in one prediction. \n", + "### Format output as multiple data types in one prediction\n", + "Within a single prediction, you can generate multiple columns of output that use different data types. \n", "\n", - "Note it doesn't require dedicated prompts, as long as the output column names are informative to the model." + "The input doesn't have to be dedicated prompts as long as the output column names are informative to the model." ] }, { @@ -630,14 +735,14 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### 4. Generate composite data types" + "### Format output as a composite data type" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Composite datatypes like array and struct can also be generated. Here the example generates a places_to_visit column as array of strings and a gps_coordinates as struct of floats. Along with previous fields, all in one prediction." + "You can generate composite data types like arrays and structs. The following example generates a `places_to_visit` column as an array of strings and a `gps_coordinates` column as a struct of floats:" ] }, { @@ -744,6 +849,36 @@ "result = gemini.predict(df, prompt=[df[\"city\"]], output_schema={\"is_US_city\": \"bool\", \"population_in_millions\": \"float64\", \"rainy_days_per_year\": \"int64\", \"places_to_visit\": \"array\", \"gps_coordinates\": \"struct\"})\n", "result[[\"city\", \"is_US_city\", \"population_in_millions\", \"rainy_days_per_year\", \"places_to_visit\", \"gps_coordinates\"]]" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Clean up\n", + "\n", + "To clean up all Google Cloud resources used in this project, you can [delete the Google Cloud\n", + "project](https://cloud.google.com/resource-manager/docs/creating-managing-projects#shutting_down_projects) you used for the tutorial.\n", + "\n", + "Otherwise, run the following cell to delete the temporary cloud artifacts created during the BigFrames session:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "bpd.close_session()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Next steps\n", + "\n", + "Learn more about BigQuery DataFrames in the [documentation](https://cloud.google.com/python/docs/reference/bigframes/latest) and find more sample notebooks in the [GitHub repo](https://github.com/googleapis/python-bigquery-dataframes/tree/main/notebooks)." + ] } ], "metadata": { From ecee2bc6ada0bc968fc56ed7194dc8c043547e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Fri, 31 Oct 2025 12:13:32 -0500 Subject: [PATCH 191/313] feat: add bigframes.bigquery.st_simplify (#2210) * feat: add bigframes.bigquery.st_simplify * fix mypy errors * add docstring * fix mypy again * fix typos --- bigframes/bigquery/__init__.py | 2 ++ bigframes/bigquery/_operations/geo.py | 20 +++++++++++++ .../ibis_compiler/operations/geo_ops.py | 14 ++++++++++ bigframes/geopandas/geoseries.py | 5 ++++ bigframes/operations/__init__.py | 2 ++ bigframes/operations/geo_ops.py | 9 ++++++ specs/2025-08-04-geoseries-scalars.md | 15 +++++++--- tests/system/small/bigquery/test_geo.py | 9 ++++++ .../bigframes_vendored/geopandas/geoseries.py | 28 +++++++++++++++++++ 9 files changed, 100 insertions(+), 4 deletions(-) diff --git a/bigframes/bigquery/__init__.py b/bigframes/bigquery/__init__.py index e8c7a524d9..c599a4b543 100644 --- a/bigframes/bigquery/__init__.py +++ b/bigframes/bigquery/__init__.py @@ -40,6 +40,7 @@ st_intersection, st_isclosed, st_length, + st_simplify, ) from bigframes.bigquery._operations.json import ( json_extract, @@ -80,6 +81,7 @@ st_intersection, st_isclosed, st_length, + st_simplify, # json ops json_extract, json_extract_array, diff --git a/bigframes/bigquery/_operations/geo.py b/bigframes/bigquery/_operations/geo.py index 254d2ae13f..6b7e5d88a2 100644 --- a/bigframes/bigquery/_operations/geo.py +++ b/bigframes/bigquery/_operations/geo.py @@ -675,3 +675,23 @@ def st_length( series = series._apply_unary_op(ops.GeoStLengthOp(use_spheroid=use_spheroid)) series.name = None return series + + +def st_simplify( + geography: "bigframes.series.Series", + tolerance_meters: float, +) -> "bigframes.series.Series": + """Returns a simplified version of the input geography. + + Args: + geography (bigframes.series.Series): + A Series containing GEOGRAPHY data. + tolerance_meters (float): + A float64 value indicating the tolerance in meters. + + Returns: + a Series containing the simplified GEOGRAPHY data. + """ + return geography._apply_unary_op( + ops.GeoStSimplifyOp(tolerance_meters=tolerance_meters) + ) diff --git a/bigframes/core/compile/ibis_compiler/operations/geo_ops.py b/bigframes/core/compile/ibis_compiler/operations/geo_ops.py index f9155fed5a..2f06c76768 100644 --- a/bigframes/core/compile/ibis_compiler/operations/geo_ops.py +++ b/bigframes/core/compile/ibis_compiler/operations/geo_ops.py @@ -101,6 +101,12 @@ def geo_st_isclosed_op_impl(x: ibis_types.Value): return st_isclosed(x) +@register_unary_op(ops.GeoStSimplifyOp, pass_op=True) +def st_simplify_op_impl(x: ibis_types.Value, op: ops.GeoStSimplifyOp): + x = cast(ibis_types.GeoSpatialValue, x) + return st_simplify(x, op.tolerance_meters) + + @register_unary_op(ops.geo_x_op) def geo_x_op_impl(x: ibis_types.Value): return cast(ibis_types.GeoSpatialValue, x).x() @@ -157,3 +163,11 @@ def st_length(geog: ibis_dtypes.geography, use_spheroid: bool) -> ibis_dtypes.fl @ibis_udf.scalar.builtin def st_isclosed(a: ibis_dtypes.geography) -> ibis_dtypes.boolean: # type: ignore """Checks if a geography is closed.""" + + +@ibis_udf.scalar.builtin +def st_simplify( + geography: ibis_dtypes.geography, # type: ignore + tolerance_meters: ibis_dtypes.float, # type: ignore +) -> ibis_dtypes.geography: # type: ignore + ... diff --git a/bigframes/geopandas/geoseries.py b/bigframes/geopandas/geoseries.py index f3558e4b34..660f1939a9 100644 --- a/bigframes/geopandas/geoseries.py +++ b/bigframes/geopandas/geoseries.py @@ -123,3 +123,8 @@ def distance(self: GeoSeries, other: GeoSeries) -> bigframes.series.Series: # t def intersection(self: GeoSeries, other: GeoSeries) -> bigframes.series.Series: # type: ignore return self._apply_binary_op(other, ops.geo_st_intersection_op) + + def simplify(self, tolerance, preserve_topology=True): + raise NotImplementedError( + f"GeoSeries.simplify is not supported. Use bigframes.bigquery.st_simplify(series, tolerance_meters), instead. {constants.FEEDBACK_LINK}" + ) diff --git a/bigframes/operations/__init__.py b/bigframes/operations/__init__.py index 24a7d6542f..cb03943ada 100644 --- a/bigframes/operations/__init__.py +++ b/bigframes/operations/__init__.py @@ -121,6 +121,7 @@ GeoStBufferOp, GeoStDistanceOp, GeoStLengthOp, + GeoStSimplifyOp, ) from bigframes.operations.json_ops import ( JSONExtract, @@ -416,6 +417,7 @@ "geo_st_isclosed_op", "GeoStBufferOp", "GeoStLengthOp", + "GeoStSimplifyOp", "geo_x_op", "geo_y_op", "GeoStDistanceOp", diff --git a/bigframes/operations/geo_ops.py b/bigframes/operations/geo_ops.py index 3b7754a47a..86e913d543 100644 --- a/bigframes/operations/geo_ops.py +++ b/bigframes/operations/geo_ops.py @@ -133,3 +133,12 @@ class GeoStLengthOp(base_ops.UnaryOp): def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: return dtypes.FLOAT_DTYPE + + +@dataclasses.dataclass(frozen=True) +class GeoStSimplifyOp(base_ops.UnaryOp): + name = "st_simplify" + tolerance_meters: float + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return dtypes.GEO_DTYPE diff --git a/specs/2025-08-04-geoseries-scalars.md b/specs/2025-08-04-geoseries-scalars.md index 66ed77d0dd..e7bc6c61e1 100644 --- a/specs/2025-08-04-geoseries-scalars.md +++ b/specs/2025-08-04-geoseries-scalars.md @@ -261,7 +261,10 @@ Raster functions: Functions for analyzing geospatial rasters using geographies. ### Implementing a new scalar geography operation - [ ] **Define the operation dataclass:** - - [ ] In `bigframes/operations/geo_ops.py`, create a new dataclass inheriting from `base_ops.UnaryOp` or `base_ops.BinaryOp`. + - [ ] In `bigframes/operations/geo_ops.py`, create a new dataclass + inheriting from `base_ops.UnaryOp` or `base_ops.BinaryOp`. Note that + BinaryOp is for methods that take two **columns**. Any literal values can + be passed as parameters to a UnaryOp. - [ ] Define the `name` of the operation and any parameters it requires. - [ ] Implement the `output_type` method to specify the data type of the result. - [ ] **Export the new operation:** @@ -283,13 +286,17 @@ Raster functions: Functions for analyzing geospatial rasters using geographies. - [ ] Add a comprehensive docstring with examples. - [ ] In `bigframes/bigquery/__init__.py`, import your new user-facing function and add it to the `__all__` list. - [ ] For a `GeoSeries` property or method: - - [ ] In `bigframes/geopandas/geoseries.py`, create the property or method. + - [ ] In `bigframes/geopandas/geoseries.py`, create the property or + method. Omit the docstring. - [ ] If the operation is not possible to be supported, such as if the geopandas method returns values in units corresponding to the coordinate system rather than meters that BigQuery uses, raise a - `NotImplementedError` with a helpful message. + `NotImplementedError` with a helpful message. Likewise, if a + required parameter takes a value in terms of the coordinate + system, but BigQuery uses meters, raise a `NotImplementedError`. - [ ] Otherwise, call `series._apply_unary_op` or `series._apply_binary_op`, passing the operation dataclass. - - [ ] Add a comprehensive docstring with examples. + - [ ] Add a comprehensive docstring with examples to the superclass in + `third_party/bigframes_vendored/geopandas/geoseries.py`. - [ ] **Add Tests:** - [ ] Add system tests in `tests/system/small/bigquery/test_geo.py` or `tests/system/small/geopandas/test_geoseries.py` to verify the end-to-end functionality. Test various inputs, including edge cases and `NULL` values. - [ ] If you are overriding a pandas or GeoPandas property and raising `NotImplementedError`, add a unit test to ensure the correct error is raised. diff --git a/tests/system/small/bigquery/test_geo.py b/tests/system/small/bigquery/test_geo.py index c89ca59aca..28db58c711 100644 --- a/tests/system/small/bigquery/test_geo.py +++ b/tests/system/small/bigquery/test_geo.py @@ -480,3 +480,12 @@ def test_st_buffer(session): result = bbq.st_buffer(geoseries, 1000).to_pandas() assert result.iloc[0].geom_type == "Polygon" assert result.iloc[1].geom_type == "Polygon" + + +def test_st_simplify(session): + geoseries = bigframes.geopandas.GeoSeries( + [LineString([(0, 0), (1, 1), (2, 0)])], session=session + ) + result = bbq.st_simplify(geoseries, 100000).to_pandas() + assert len(result.index) == 1 + assert result.isna().sum() == 0 diff --git a/third_party/bigframes_vendored/geopandas/geoseries.py b/third_party/bigframes_vendored/geopandas/geoseries.py index 20587b4d57..642cf2fc90 100644 --- a/third_party/bigframes_vendored/geopandas/geoseries.py +++ b/third_party/bigframes_vendored/geopandas/geoseries.py @@ -496,3 +496,31 @@ def is_closed(self: GeoSeries) -> bigframes.series.Series: ``bigframes.bigquery.st_isclosed(series)``, instead. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def simplify(self, tolerance: float, preserve_topology: bool = True) -> bigframes.series.Series: # type: ignore + """[Not Implemented] Use ``bigframes.bigquery.st_simplify(series, tolerance_meters)``, + instead to set the tolerance in meters. + + In GeoPandas, this returns a GeoSeries containing a simplified + representation of each geometry. + + Args: + tolerance (float): + All parts of a simplified geometry will be no more than + tolerance distance from the original. It has the same units as + the coordinate reference system of the GeoSeries. For example, + using tolerance=100 in a projected CRS with meters as units + means a distance of 100 meters in reality. + preserve_topology (bool): + Default True. False uses a quicker algorithm, but may produce + self-intersecting or otherwise invalid geometries. + + Returns: + bigframes.geopandas.GeoSeries: + Series of simplified geometries. + + Raises: + NotImplementedError: + GeoSeries.simplify is not supported. Use bigframes.bigquery.st_simplify(series, tolerance_meters), instead. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) From b23cf83d37e518ef45d8c919a7b6038beb889d50 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 31 Oct 2025 10:44:48 -0700 Subject: [PATCH 192/313] refactor: ExecuteResult is reusable, sampleable (#2159) --- bigframes/core/array_value.py | 18 +- bigframes/core/blocks.py | 75 +++--- bigframes/core/bq_data.py | 221 ++++++++++++++++++ .../compile/ibis_compiler/ibis_compiler.py | 6 +- bigframes/core/indexes/base.py | 32 ++- bigframes/core/local_data.py | 47 ++-- bigframes/core/nodes.py | 86 ++----- bigframes/core/pyarrow_utils.py | 7 + bigframes/core/rewrite/fold_row_count.py | 6 +- bigframes/core/schema.py | 25 +- bigframes/dtypes.py | 18 ++ bigframes/session/bq_caching_executor.py | 114 ++++----- bigframes/session/direct_gbq_execution.py | 13 +- bigframes/session/executor.py | 201 ++++++++++++++-- bigframes/session/loader.py | 41 ++-- bigframes/session/local_scan_executor.py | 9 +- bigframes/session/polars_executor.py | 22 +- bigframes/session/read_api_execution.py | 79 ++----- bigframes/testing/engine_utils.py | 4 +- bigframes/testing/polars_session.py | 8 +- tests/system/small/engines/test_read_local.py | 12 +- tests/system/small/test_anywidget.py | 31 ++- tests/system/small/test_session.py | 4 +- tests/unit/core/rewrite/conftest.py | 2 - tests/unit/core/rewrite/test_identifiers.py | 2 - .../unit/session/test_local_scan_executor.py | 6 +- tests/unit/test_planner.py | 1 - 27 files changed, 721 insertions(+), 369 deletions(-) create mode 100644 bigframes/core/bq_data.py diff --git a/bigframes/core/array_value.py b/bigframes/core/array_value.py index a89d224ad1..e2948cdd05 100644 --- a/bigframes/core/array_value.py +++ b/bigframes/core/array_value.py @@ -23,7 +23,7 @@ import pandas import pyarrow as pa -from bigframes.core import agg_expressions +from bigframes.core import agg_expressions, bq_data import bigframes.core.expression as ex import bigframes.core.guid import bigframes.core.identifiers as ids @@ -63,7 +63,7 @@ def from_pyarrow(cls, arrow_table: pa.Table, session: Session): def from_managed(cls, source: local_data.ManagedArrowTable, session: Session): scan_list = nodes.ScanList( tuple( - nodes.ScanItem(ids.ColumnId(item.column), item.dtype, item.column) + nodes.ScanItem(ids.ColumnId(item.column), item.column) for item in source.schema.items ) ) @@ -88,9 +88,9 @@ def from_range(cls, start, end, step): def from_table( cls, table: google.cloud.bigquery.Table, - schema: schemata.ArraySchema, session: Session, *, + columns: Optional[Sequence[str]] = None, predicate: Optional[str] = None, at_time: Optional[datetime.datetime] = None, primary_key: Sequence[str] = (), @@ -100,7 +100,7 @@ def from_table( if offsets_col and primary_key: raise ValueError("must set at most one of 'offests', 'primary_key'") # define data source only for needed columns, this makes row-hashing cheaper - table_def = nodes.GbqTable.from_table(table, columns=schema.names) + table_def = bq_data.GbqTable.from_table(table, columns=columns or ()) # create ordering from info ordering = None @@ -111,15 +111,17 @@ def from_table( [ids.ColumnId(key_part) for key_part in primary_key] ) + bf_schema = schemata.ArraySchema.from_bq_table(table, columns=columns) # Scan all columns by default, we define this list as it can be pruned while preserving source_def scan_list = nodes.ScanList( tuple( - nodes.ScanItem(ids.ColumnId(item.column), item.dtype, item.column) - for item in schema.items + nodes.ScanItem(ids.ColumnId(item.column), item.column) + for item in bf_schema.items ) ) - source_def = nodes.BigqueryDataSource( + source_def = bq_data.BigqueryDataSource( table=table_def, + schema=bf_schema, at_time=at_time, sql_predicate=predicate, ordering=ordering, @@ -130,7 +132,7 @@ def from_table( @classmethod def from_bq_data_source( cls, - source: nodes.BigqueryDataSource, + source: bq_data.BigqueryDataSource, scan_list: nodes.ScanList, session: Session, ): diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index 1900b7208a..41986ce5df 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -37,7 +37,6 @@ Optional, Sequence, Tuple, - TYPE_CHECKING, Union, ) import warnings @@ -70,9 +69,6 @@ from bigframes.session import dry_runs, execution_spec from bigframes.session import executor as executors -if TYPE_CHECKING: - from bigframes.session.executor import ExecuteResult - # Type constraint for wherever column labels are used Label = typing.Hashable @@ -98,7 +94,6 @@ LevelsType = typing.Union[LevelType, typing.Sequence[LevelType]] -@dataclasses.dataclass class PandasBatches(Iterator[pd.DataFrame]): """Interface for mutable objects with state represented by a block value object.""" @@ -271,10 +266,14 @@ def shape(self) -> typing.Tuple[int, int]: except Exception: pass - row_count = self.session._executor.execute( - self.expr.row_count(), - execution_spec.ExecutionSpec(promise_under_10gb=True, ordered=False), - ).to_py_scalar() + row_count = ( + self.session._executor.execute( + self.expr.row_count(), + execution_spec.ExecutionSpec(promise_under_10gb=True, ordered=False), + ) + .batches() + .to_py_scalar() + ) return (row_count, len(self.value_columns)) @property @@ -584,7 +583,7 @@ def to_arrow( ordered=ordered, ), ) - pa_table = execute_result.to_arrow_table() + pa_table = execute_result.batches().to_arrow_table() pa_index_labels = [] for index_level, index_label in enumerate(self._index_labels): @@ -636,17 +635,13 @@ def to_pandas( max_download_size, sampling_method, random_state ) - ex_result = self._materialize_local( + return self._materialize_local( materialize_options=MaterializationOptions( downsampling=sampling, allow_large_results=allow_large_results, ordered=ordered, ) ) - df = ex_result.to_pandas() - df = self._copy_index_to_pandas(df) - df.set_axis(self.column_labels, axis=1, copy=False) - return df, ex_result.query_job def _get_sampling_option( self, @@ -683,7 +678,7 @@ def try_peek( self.expr, execution_spec.ExecutionSpec(promise_under_10gb=under_10gb, peek=n), ) - df = result.to_pandas() + df = result.batches().to_pandas() return self._copy_index_to_pandas(df) else: return None @@ -704,13 +699,14 @@ def to_pandas_batches( if (allow_large_results is not None) else not bigframes.options._allow_large_results ) - execute_result = self.session._executor.execute( + execution_result = self.session._executor.execute( self.expr, execution_spec.ExecutionSpec( promise_under_10gb=under_10gb, ordered=True, ), ) + result_batches = execution_result.batches() # To reduce the number of edge cases to consider when working with the # results of this, always return at least one DataFrame. See: @@ -724,19 +720,21 @@ def to_pandas_batches( dfs = map( lambda a: a[0], itertools.zip_longest( - execute_result.to_pandas_batches(page_size, max_results), + result_batches.to_pandas_batches(page_size, max_results), [0], fillvalue=empty_val, ), ) dfs = iter(map(self._copy_index_to_pandas, dfs)) - total_rows = execute_result.total_rows + total_rows = result_batches.approx_total_rows if (total_rows is not None) and (max_results is not None): total_rows = min(total_rows, max_results) return PandasBatches( - dfs, total_rows, total_bytes_processed=execute_result.total_bytes_processed + dfs, + total_rows, + total_bytes_processed=execution_result.total_bytes_processed, ) def _copy_index_to_pandas(self, df: pd.DataFrame) -> pd.DataFrame: @@ -754,7 +752,7 @@ def _copy_index_to_pandas(self, df: pd.DataFrame) -> pd.DataFrame: def _materialize_local( self, materialize_options: MaterializationOptions = MaterializationOptions() - ) -> ExecuteResult: + ) -> tuple[pd.DataFrame, Optional[bigquery.QueryJob]]: """Run query and download results as a pandas DataFrame. Return the total number of results as well.""" # TODO(swast): Allow for dry run and timeout. under_10gb = ( @@ -769,9 +767,11 @@ def _materialize_local( ordered=materialize_options.ordered, ), ) + result_batches = execute_result.batches() + sample_config = materialize_options.downsampling - if execute_result.total_bytes is not None: - table_mb = execute_result.total_bytes / _BYTES_TO_MEGABYTES + if result_batches.approx_total_bytes is not None: + table_mb = result_batches.approx_total_bytes / _BYTES_TO_MEGABYTES max_download_size = sample_config.max_download_size fraction = ( max_download_size / table_mb @@ -792,7 +792,7 @@ def _materialize_local( # TODO: Maybe materialize before downsampling # Some downsampling methods - if fraction < 1 and (execute_result.total_rows is not None): + if fraction < 1 and (result_batches.approx_total_rows is not None): if not sample_config.enable_downsampling: raise RuntimeError( f"The data size ({table_mb:.2f} MB) exceeds the maximum download limit of " @@ -811,7 +811,7 @@ def _materialize_local( "the downloading limit." ) warnings.warn(msg, category=UserWarning) - total_rows = execute_result.total_rows + total_rows = result_batches.approx_total_rows # Remove downsampling config from subsequent invocations, as otherwise could result in many # iterations if downsampling undershoots return self._downsample( @@ -823,7 +823,10 @@ def _materialize_local( MaterializationOptions(ordered=materialize_options.ordered) ) else: - return execute_result + df = result_batches.to_pandas() + df = self._copy_index_to_pandas(df) + df.set_axis(self.column_labels, axis=1, copy=False) + return df, execute_result.query_job def _downsample( self, total_rows: int, sampling_method: str, fraction: float, random_state @@ -1662,15 +1665,19 @@ def retrieve_repr_request_results( ordered=True, ), ) - row_count = self.session._executor.execute( - self.expr.row_count(), - execution_spec.ExecutionSpec( - promise_under_10gb=True, - ordered=False, - ), - ).to_py_scalar() + row_count = ( + self.session._executor.execute( + self.expr.row_count(), + execution_spec.ExecutionSpec( + promise_under_10gb=True, + ordered=False, + ), + ) + .batches() + .to_py_scalar() + ) - head_df = head_result.to_pandas() + head_df = head_result.batches().to_pandas() return self._copy_index_to_pandas(head_df), row_count, head_result.query_job def promote_offsets(self, label: Label = None) -> typing.Tuple[Block, str]: diff --git a/bigframes/core/bq_data.py b/bigframes/core/bq_data.py new file mode 100644 index 0000000000..c72de6ead6 --- /dev/null +++ b/bigframes/core/bq_data.py @@ -0,0 +1,221 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import concurrent.futures +import dataclasses +import datetime +import functools +import os +import queue +import threading +import typing +from typing import Any, Iterator, Optional, Sequence, Tuple + +from google.cloud import bigquery_storage_v1 +import google.cloud.bigquery as bq +import google.cloud.bigquery_storage_v1.types as bq_storage_types +from google.protobuf import timestamp_pb2 +import pyarrow as pa + +from bigframes.core import pyarrow_utils +import bigframes.core.schema + +if typing.TYPE_CHECKING: + import bigframes.core.ordering as orderings + + +@dataclasses.dataclass(frozen=True) +class GbqTable: + project_id: str = dataclasses.field() + dataset_id: str = dataclasses.field() + table_id: str = dataclasses.field() + physical_schema: Tuple[bq.SchemaField, ...] = dataclasses.field() + is_physically_stored: bool = dataclasses.field() + cluster_cols: typing.Optional[Tuple[str, ...]] + + @staticmethod + def from_table(table: bq.Table, columns: Sequence[str] = ()) -> GbqTable: + # Subsetting fields with columns can reduce cost of row-hash default ordering + if columns: + schema = tuple(item for item in table.schema if item.name in columns) + else: + schema = tuple(table.schema) + return GbqTable( + project_id=table.project, + dataset_id=table.dataset_id, + table_id=table.table_id, + physical_schema=schema, + is_physically_stored=(table.table_type in ["TABLE", "MATERIALIZED_VIEW"]), + cluster_cols=None + if table.clustering_fields is None + else tuple(table.clustering_fields), + ) + + def get_table_ref(self) -> bq.TableReference: + return bq.TableReference( + bq.DatasetReference(self.project_id, self.dataset_id), self.table_id + ) + + @property + @functools.cache + def schema_by_id(self): + return {col.name: col for col in self.physical_schema} + + +@dataclasses.dataclass(frozen=True) +class BigqueryDataSource: + """ + Google BigQuery Data source. + + This should not be modified once defined, as all attributes contribute to the default ordering. + """ + + def __post_init__(self): + # not all columns need be in schema, eg so can exclude unsupported column types (eg RANGE) + assert set(field.name for field in self.table.physical_schema).issuperset( + self.schema.names + ) + + table: GbqTable + schema: bigframes.core.schema.ArraySchema + at_time: typing.Optional[datetime.datetime] = None + # Added for backwards compatibility, not validated + sql_predicate: typing.Optional[str] = None + ordering: typing.Optional[orderings.RowOrdering] = None + # Optimization field + n_rows: Optional[int] = None + + +_WORKER_TIME_INCREMENT = 0.05 + + +def _iter_stream( + stream_name: str, + storage_read_client: bigquery_storage_v1.BigQueryReadClient, + result_queue: queue.Queue, + stop_event: threading.Event, +): + reader = storage_read_client.read_rows(stream_name) + for page in reader.rows().pages: + while True: # Alternate between put attempt and checking stop event + try: + result_queue.put(page.to_arrow(), timeout=_WORKER_TIME_INCREMENT) + break + except queue.Full: + if stop_event.is_set(): + return + continue + + +def _iter_streams( + streams: Sequence[bq_storage_types.ReadStream], + storage_read_client: bigquery_storage_v1.BigQueryReadClient, +) -> Iterator[pa.RecordBatch]: + stop_event = threading.Event() + result_queue: queue.Queue = queue.Queue( + len(streams) + ) # each response is large, so small queue is appropriate + + in_progress: list[concurrent.futures.Future] = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=len(streams)) as pool: + try: + for stream in streams: + in_progress.append( + pool.submit( + _iter_stream, + stream.name, + storage_read_client, + result_queue, + stop_event, + ) + ) + + while in_progress: + try: + yield result_queue.get(timeout=0.1) + except queue.Empty: + new_in_progress = [] + for future in in_progress: + if future.done(): + # Call to raise any exceptions + future.result() + else: + new_in_progress.append(future) + in_progress = new_in_progress + finally: + stop_event.set() + + +@dataclasses.dataclass +class ReadResult: + iter: Iterator[pa.RecordBatch] + approx_rows: int + approx_bytes: int + + +def get_arrow_batches( + data: BigqueryDataSource, + columns: Sequence[str], + storage_read_client: bigquery_storage_v1.BigQueryReadClient, + project_id: str, +) -> ReadResult: + table_mod_options = {} + read_options_dict: dict[str, Any] = {"selected_fields": list(columns)} + if data.sql_predicate: + read_options_dict["row_restriction"] = data.sql_predicate + read_options = bq_storage_types.ReadSession.TableReadOptions(**read_options_dict) + + if data.at_time: + snapshot_time = timestamp_pb2.Timestamp() + snapshot_time.FromDatetime(data.at_time) + table_mod_options["snapshot_time"] = snapshot_time + table_mods = bq_storage_types.ReadSession.TableModifiers(**table_mod_options) + + requested_session = bq_storage_types.stream.ReadSession( + table=data.table.get_table_ref().to_bqstorage(), + data_format=bq_storage_types.DataFormat.ARROW, + read_options=read_options, + table_modifiers=table_mods, + ) + if data.ordering is not None: + max_streams = 1 + else: + max_streams = os.cpu_count() or 8 + + # Single stream to maintain ordering + request = bq_storage_types.CreateReadSessionRequest( + parent=f"projects/{project_id}", + read_session=requested_session, + max_stream_count=max_streams, + ) + + session = storage_read_client.create_read_session(request=request) + + if not session.streams: + batches: Iterator[pa.RecordBatch] = iter([]) + else: + batches = _iter_streams(session.streams, storage_read_client) + + def process_batch(pa_batch): + return pyarrow_utils.cast_batch( + pa_batch.select(columns), data.schema.select(columns).to_pyarrow() + ) + + batches = map(process_batch, batches) + + return ReadResult( + batches, session.estimated_row_count, session.estimated_total_bytes_scanned + ) diff --git a/bigframes/core/compile/ibis_compiler/ibis_compiler.py b/bigframes/core/compile/ibis_compiler/ibis_compiler.py index ff0441ea22..0436e05559 100644 --- a/bigframes/core/compile/ibis_compiler/ibis_compiler.py +++ b/bigframes/core/compile/ibis_compiler/ibis_compiler.py @@ -24,7 +24,7 @@ import bigframes_vendored.ibis.expr.types as ibis_types from bigframes import dtypes, operations -from bigframes.core import expression, pyarrow_utils +from bigframes.core import bq_data, expression, pyarrow_utils import bigframes.core.compile.compiled as compiled import bigframes.core.compile.concat as concat_impl import bigframes.core.compile.configs as configs @@ -186,7 +186,7 @@ def compile_readtable(node: nodes.ReadTableNode, *args): # TODO(b/395912450): Remove workaround solution once b/374784249 got resolved. for scan_item in node.scan_list.items: if ( - scan_item.dtype == dtypes.JSON_DTYPE + node.source.schema.get_type(scan_item.source_id) == dtypes.JSON_DTYPE and ibis_table[scan_item.source_id].type() == ibis_dtypes.string ): json_column = scalar_op_registry.parse_json( @@ -204,7 +204,7 @@ def compile_readtable(node: nodes.ReadTableNode, *args): def _table_to_ibis( - source: nodes.BigqueryDataSource, + source: bq_data.BigqueryDataSource, scan_cols: typing.Sequence[str], ) -> ibis_types.Table: full_table_name = ( diff --git a/bigframes/core/indexes/base.py b/bigframes/core/indexes/base.py index 0e82b6dea7..41b32d99e4 100644 --- a/bigframes/core/indexes/base.py +++ b/bigframes/core/indexes/base.py @@ -297,9 +297,13 @@ def get_loc(self, key) -> typing.Union[int, slice, "bigframes.series.Series"]: count_agg = ex_types.UnaryAggregation(agg_ops.count_op, ex.deref(offsets_id)) count_result = filtered_block._expr.aggregate([(count_agg, "count")]) - count_scalar = self._block.session._executor.execute( - count_result, ex_spec.ExecutionSpec(promise_under_10gb=True) - ).to_py_scalar() + count_scalar = ( + self._block.session._executor.execute( + count_result, ex_spec.ExecutionSpec(promise_under_10gb=True) + ) + .batches() + .to_py_scalar() + ) if count_scalar == 0: raise KeyError(f"'{key}' is not in index") @@ -308,9 +312,13 @@ def get_loc(self, key) -> typing.Union[int, slice, "bigframes.series.Series"]: if count_scalar == 1: min_agg = ex_types.UnaryAggregation(agg_ops.min_op, ex.deref(offsets_id)) position_result = filtered_block._expr.aggregate([(min_agg, "position")]) - position_scalar = self._block.session._executor.execute( - position_result, ex_spec.ExecutionSpec(promise_under_10gb=True) - ).to_py_scalar() + position_scalar = ( + self._block.session._executor.execute( + position_result, ex_spec.ExecutionSpec(promise_under_10gb=True) + ) + .batches() + .to_py_scalar() + ) return int(position_scalar) # Handle multiple matches based on index monotonicity @@ -342,10 +350,14 @@ def _get_monotonic_slice( combined_result = filtered_block._expr.aggregate(min_max_aggs) # Execute query and extract positions - result_df = self._block.session._executor.execute( - combined_result, - execution_spec=ex_spec.ExecutionSpec(promise_under_10gb=True), - ).to_pandas() + result_df = ( + self._block.session._executor.execute( + combined_result, + execution_spec=ex_spec.ExecutionSpec(promise_under_10gb=True), + ) + .batches() + .to_pandas() + ) min_pos = int(result_df["min_pos"].iloc[0]) max_pos = int(result_df["max_pos"].iloc[0]) diff --git a/bigframes/core/local_data.py b/bigframes/core/local_data.py index c214d0bb7e..fa18f00483 100644 --- a/bigframes/core/local_data.py +++ b/bigframes/core/local_data.py @@ -83,20 +83,39 @@ def from_pandas(cls, dataframe: pd.DataFrame) -> ManagedArrowTable: return mat @classmethod - def from_pyarrow(self, table: pa.Table) -> ManagedArrowTable: - columns: list[pa.ChunkedArray] = [] - fields: list[schemata.SchemaItem] = [] - for name, arr in zip(table.column_names, table.columns): - new_arr, bf_type = _adapt_chunked_array(arr) - columns.append(new_arr) - fields.append(schemata.SchemaItem(name, bf_type)) - - mat = ManagedArrowTable( - pa.table(columns, names=table.column_names), - schemata.ArraySchema(tuple(fields)), - ) - mat.validate() - return mat + def from_pyarrow( + cls, table: pa.Table, schema: Optional[schemata.ArraySchema] = None + ) -> ManagedArrowTable: + if schema is not None: + pa_fields = [] + for item in schema.items: + pa_type = _get_managed_storage_type(item.dtype) + pa_fields.append( + pyarrow.field( + item.column, + pa_type, + nullable=not pyarrow.types.is_list(pa_type), + ) + ) + pa_schema = pyarrow.schema(pa_fields) + # assumption: needed transformations can be handled by simple cast. + mat = ManagedArrowTable(table.cast(pa_schema), schema) + mat.validate() + return mat + else: # infer bigframes schema + columns: list[pa.ChunkedArray] = [] + fields: list[schemata.SchemaItem] = [] + for name, arr in zip(table.column_names, table.columns): + new_arr, bf_type = _adapt_chunked_array(arr) + columns.append(new_arr) + fields.append(schemata.SchemaItem(name, bf_type)) + + mat = ManagedArrowTable( + pa.table(columns, names=table.column_names), + schemata.ArraySchema(tuple(fields)), + ) + mat.validate() + return mat def to_arrow( self, diff --git a/bigframes/core/nodes.py b/bigframes/core/nodes.py index 0d20509877..9e0fcb3ace 100644 --- a/bigframes/core/nodes.py +++ b/bigframes/core/nodes.py @@ -16,7 +16,6 @@ import abc import dataclasses -import datetime import functools import itertools import typing @@ -31,9 +30,7 @@ Tuple, ) -import google.cloud.bigquery as bq - -from bigframes.core import agg_expressions, identifiers, local_data, sequences +from bigframes.core import agg_expressions, bq_data, identifiers, local_data, sequences from bigframes.core.bigframe_node import BigFrameNode, COLUMN_SET import bigframes.core.expression as ex from bigframes.core.field import Field @@ -599,14 +596,13 @@ def transform_children(self, t: Callable[[BigFrameNode], BigFrameNode]) -> LeafN class ScanItem(typing.NamedTuple): id: identifiers.ColumnId - dtype: bigframes.dtypes.Dtype # Might be multiple logical types for a given physical source type source_id: str # Flexible enough for both local data and bq data def with_id(self, id: identifiers.ColumnId) -> ScanItem: - return ScanItem(id, self.dtype, self.source_id) + return ScanItem(id, self.source_id) def with_source_id(self, source_id: str) -> ScanItem: - return ScanItem(self.id, self.dtype, source_id) + return ScanItem(self.id, source_id) @dataclasses.dataclass(frozen=True) @@ -661,7 +657,7 @@ def remap_source_ids( def append( self, source_id: str, dtype: bigframes.dtypes.Dtype, id: identifiers.ColumnId ) -> ScanList: - return ScanList((*self.items, ScanItem(id, dtype, source_id))) + return ScanList((*self.items, ScanItem(id, source_id))) @dataclasses.dataclass(frozen=True, eq=False) @@ -677,8 +673,10 @@ class ReadLocalNode(LeafNode): @property def fields(self) -> Sequence[Field]: fields = tuple( - Field(col_id, dtype) for col_id, dtype, _ in self.scan_list.items + Field(col_id, self.local_data_source.schema.get_type(source_id)) + for col_id, source_id in self.scan_list.items ) + if self.offsets_col is not None: return tuple( itertools.chain( @@ -726,7 +724,7 @@ def remap_vars( ) -> ReadLocalNode: new_scan_list = ScanList( tuple( - ScanItem(mappings.get(item.id, item.id), item.dtype, item.source_id) + ScanItem(mappings.get(item.id, item.id), item.source_id) for item in self.scan_list.items ) ) @@ -745,64 +743,9 @@ def remap_refs( return self -@dataclasses.dataclass(frozen=True) -class GbqTable: - project_id: str = dataclasses.field() - dataset_id: str = dataclasses.field() - table_id: str = dataclasses.field() - physical_schema: Tuple[bq.SchemaField, ...] = dataclasses.field() - is_physically_stored: bool = dataclasses.field() - cluster_cols: typing.Optional[Tuple[str, ...]] - - @staticmethod - def from_table(table: bq.Table, columns: Sequence[str] = ()) -> GbqTable: - # Subsetting fields with columns can reduce cost of row-hash default ordering - if columns: - schema = tuple(item for item in table.schema if item.name in columns) - else: - schema = tuple(table.schema) - return GbqTable( - project_id=table.project, - dataset_id=table.dataset_id, - table_id=table.table_id, - physical_schema=schema, - is_physically_stored=(table.table_type in ["TABLE", "MATERIALIZED_VIEW"]), - cluster_cols=None - if table.clustering_fields is None - else tuple(table.clustering_fields), - ) - - def get_table_ref(self) -> bq.TableReference: - return bq.TableReference( - bq.DatasetReference(self.project_id, self.dataset_id), self.table_id - ) - - @property - @functools.cache - def schema_by_id(self): - return {col.name: col for col in self.physical_schema} - - -@dataclasses.dataclass(frozen=True) -class BigqueryDataSource: - """ - Google BigQuery Data source. - - This should not be modified once defined, as all attributes contribute to the default ordering. - """ - - table: GbqTable - at_time: typing.Optional[datetime.datetime] = None - # Added for backwards compatibility, not validated - sql_predicate: typing.Optional[str] = None - ordering: typing.Optional[orderings.RowOrdering] = None - n_rows: Optional[int] = None - - -## Put ordering in here or just add order_by node above? @dataclasses.dataclass(frozen=True, eq=False) class ReadTableNode(LeafNode): - source: BigqueryDataSource + source: bq_data.BigqueryDataSource # Subset of physical schema column # Mapping of table schema ids to bfet id. scan_list: ScanList @@ -826,8 +769,12 @@ def session(self): @property def fields(self) -> Sequence[Field]: return tuple( - Field(col_id, dtype, self.source.table.schema_by_id[source_id].is_nullable) - for col_id, dtype, source_id in self.scan_list.items + Field( + col_id, + self.source.schema.get_type(source_id), + self.source.table.schema_by_id[source_id].is_nullable, + ) + for col_id, source_id in self.scan_list.items ) @property @@ -886,7 +833,7 @@ def remap_vars( ) -> ReadTableNode: new_scan_list = ScanList( tuple( - ScanItem(mappings.get(item.id, item.id), item.dtype, item.source_id) + ScanItem(mappings.get(item.id, item.id), item.source_id) for item in self.scan_list.items ) ) @@ -907,7 +854,6 @@ def with_order_cols(self): new_scan_cols = [ ScanItem( identifiers.ColumnId.unique(), - dtype=bigframes.dtypes.convert_schema_field(field)[1], source_id=field.name, ) for field in self.source.table.physical_schema diff --git a/bigframes/core/pyarrow_utils.py b/bigframes/core/pyarrow_utils.py index b9dc2ea2b3..bdbb220b95 100644 --- a/bigframes/core/pyarrow_utils.py +++ b/bigframes/core/pyarrow_utils.py @@ -84,6 +84,13 @@ def cast_batch(batch: pa.RecordBatch, schema: pa.Schema) -> pa.RecordBatch: ) +def rename_batch(batch: pa.RecordBatch, names: list[str]) -> pa.RecordBatch: + if batch.schema.names == names: + return batch + # TODO: Use RecordBatch.rename_columns once min pyarrow>=16.0 + return pa.RecordBatch.from_arrays(batch.columns, names) + + def truncate_pyarrow_iterable( batches: Iterable[pa.RecordBatch], max_results: int ) -> Iterator[pa.RecordBatch]: diff --git a/bigframes/core/rewrite/fold_row_count.py b/bigframes/core/rewrite/fold_row_count.py index 583343d68a..cc0b818fb9 100644 --- a/bigframes/core/rewrite/fold_row_count.py +++ b/bigframes/core/rewrite/fold_row_count.py @@ -15,7 +15,6 @@ import pyarrow as pa -from bigframes import dtypes from bigframes.core import local_data, nodes from bigframes.operations import aggregations @@ -34,10 +33,7 @@ def fold_row_counts(node: nodes.BigFrameNode) -> nodes.BigFrameNode: pa.table({"count": pa.array([node.child.row_count], type=pa.int64())}) ) scan_list = nodes.ScanList( - tuple( - nodes.ScanItem(out_id, dtypes.INT_DTYPE, "count") - for _, out_id in node.aggregations - ) + tuple(nodes.ScanItem(out_id, "count") for _, out_id in node.aggregations) ) return nodes.ReadLocalNode( local_data_source=local_data_source, scan_list=scan_list, session=node.session diff --git a/bigframes/core/schema.py b/bigframes/core/schema.py index b1a77d1259..395ad55f49 100644 --- a/bigframes/core/schema.py +++ b/bigframes/core/schema.py @@ -17,7 +17,7 @@ from dataclasses import dataclass import functools import typing -from typing import Dict, List, Sequence +from typing import Dict, List, Optional, Sequence import google.cloud.bigquery import pyarrow @@ -35,7 +35,7 @@ class SchemaItem: @dataclass(frozen=True) class ArraySchema: - items: Sequence[SchemaItem] + items: tuple[SchemaItem, ...] def __iter__(self): yield from self.items @@ -44,21 +44,26 @@ def __iter__(self): def from_bq_table( cls, table: google.cloud.bigquery.Table, - column_type_overrides: typing.Optional[ + column_type_overrides: Optional[ typing.Dict[str, bigframes.dtypes.Dtype] ] = None, + columns: Optional[Sequence[str]] = None, ): + if not columns: + fields = table.schema + else: + lookup = {field.name: field for field in table.schema} + fields = [lookup[col] for col in columns] + return ArraySchema.from_bq_schema( - table.schema, column_type_overrides=column_type_overrides + fields, column_type_overrides=column_type_overrides ) @classmethod def from_bq_schema( cls, schema: List[google.cloud.bigquery.SchemaField], - column_type_overrides: typing.Optional[ - Dict[str, bigframes.dtypes.Dtype] - ] = None, + column_type_overrides: Optional[Dict[str, bigframes.dtypes.Dtype]] = None, ): if column_type_overrides is None: column_type_overrides = {} @@ -90,14 +95,16 @@ def to_bigquery( for item in self.items ) - def to_pyarrow(self) -> pyarrow.Schema: + def to_pyarrow(self, use_storage_types: bool = False) -> pyarrow.Schema: fields = [] for item in self.items: pa_type = bigframes.dtypes.bigframes_dtype_to_arrow_dtype(item.dtype) + if use_storage_types: + pa_type = bigframes.dtypes.to_storage_type(pa_type) fields.append( pyarrow.field( item.column, - pa_type, + type=pa_type, nullable=not pyarrow.types.is_list(pa_type), ) ) diff --git a/bigframes/dtypes.py b/bigframes/dtypes.py index 6c05b6f4a3..29e1be1ace 100644 --- a/bigframes/dtypes.py +++ b/bigframes/dtypes.py @@ -553,6 +553,24 @@ def bigframes_dtype_to_arrow_dtype( ) +def to_storage_type( + arrow_type: pa.DataType, +): + """Some pyarrow versions don't support extension types fully, such as for empty table generation.""" + if isinstance(arrow_type, pa.ExtensionType): + return arrow_type.storage_type + if pa.types.is_list(arrow_type): + assert isinstance(arrow_type, pa.ListType) + return pa.list_(to_storage_type(arrow_type.value_type)) + if pa.types.is_struct(arrow_type): + assert isinstance(arrow_type, pa.StructType) + return pa.struct( + field.with_type(to_storage_type(field.type)) + for field in bigframes.core.backports.pyarrow_struct_type_fields(arrow_type) + ) + return arrow_type + + def arrow_type_to_literal( arrow_type: pa.DataType, ) -> Any: diff --git a/bigframes/session/bq_caching_executor.py b/bigframes/session/bq_caching_executor.py index c830ca1e29..736dbf7be1 100644 --- a/bigframes/session/bq_caching_executor.py +++ b/bigframes/session/bq_caching_executor.py @@ -15,10 +15,8 @@ from __future__ import annotations import math -import os import threading from typing import Literal, Mapping, Optional, Sequence, Tuple -import warnings import weakref import google.api_core.exceptions @@ -31,13 +29,12 @@ from bigframes import exceptions as bfe import bigframes.constants import bigframes.core -from bigframes.core import compile, local_data, rewrite +from bigframes.core import bq_data, compile, local_data, rewrite import bigframes.core.compile.sqlglot.sqlglot_ir as sqlglot_ir import bigframes.core.events import bigframes.core.guid import bigframes.core.identifiers import bigframes.core.nodes as nodes -import bigframes.core.ordering as order import bigframes.core.schema as schemata import bigframes.core.tree_properties as tree_properties import bigframes.dtypes @@ -60,7 +57,6 @@ MAX_SUBTREE_FACTORINGS = 5 _MAX_CLUSTER_COLUMNS = 4 MAX_SMALL_RESULT_BYTES = 10 * 1024 * 1024 * 1024 # 10G -_MAX_READ_STREAMS = os.cpu_count() SourceIdMapping = Mapping[str, str] @@ -74,7 +70,7 @@ def __init__(self): ] = weakref.WeakKeyDictionary() self._uploaded_local_data: weakref.WeakKeyDictionary[ local_data.ManagedArrowTable, - tuple[nodes.BigqueryDataSource, SourceIdMapping], + tuple[bq_data.BigqueryDataSource, SourceIdMapping], ] = weakref.WeakKeyDictionary() @property @@ -84,23 +80,16 @@ def mapping(self) -> Mapping[nodes.BigFrameNode, nodes.BigFrameNode]: def cache_results_table( self, original_root: nodes.BigFrameNode, - table: bigquery.Table, - ordering: order.RowOrdering, - num_rows: Optional[int] = None, + data: bq_data.BigqueryDataSource, ): # Assumption: GBQ cached table uses field name as bq column name scan_list = nodes.ScanList( tuple( - nodes.ScanItem(field.id, field.dtype, field.id.sql) - for field in original_root.fields + nodes.ScanItem(field.id, field.id.sql) for field in original_root.fields ) ) cached_replacement = nodes.CachedTableNode( - source=nodes.BigqueryDataSource( - nodes.GbqTable.from_table(table), - ordering=ordering, - n_rows=num_rows, - ), + source=data, scan_list=scan_list, table_session=original_root.session, original_node=original_root, @@ -111,7 +100,7 @@ def cache_results_table( def cache_remote_replacement( self, local_data: local_data.ManagedArrowTable, - bq_data: nodes.BigqueryDataSource, + bq_data: bq_data.BigqueryDataSource, ): # bq table has one extra column for offsets, those are implicit for local data assert len(local_data.schema.items) + 1 == len(bq_data.table.physical_schema) @@ -331,7 +320,7 @@ def _export_gbq( # TODO(swast): plumb through the api_name of the user-facing api that # caused this query. - row_iter, query_job = self._run_execute_query( + iterator, job = self._run_execute_query( sql=sql, job_config=job_config, ) @@ -347,14 +336,11 @@ def _export_gbq( table.schema = array_value.schema.to_bigquery() self.bqclient.update_table(table, ["schema"]) - return executor.ExecuteResult( - row_iter.to_arrow_iterable( - bqstorage_client=self.bqstoragereadclient, - max_stream_count=_MAX_READ_STREAMS, + return executor.EmptyExecuteResult( + bf_schema=array_value.schema, + execution_metadata=executor.ExecutionMetadata.from_iterator_and_job( + iterator, job ), - array_value.schema, - query_job, - total_bytes_processed=row_iter.total_bytes_processed, ) def dry_run( @@ -637,6 +623,7 @@ def _execute_plan_gbq( create_table = True if not cache_spec.cluster_cols: + offsets_id = bigframes.core.identifiers.ColumnId( bigframes.core.guid.generate_guid() ) @@ -676,41 +663,62 @@ def _execute_plan_gbq( query_with_job=(destination_table is not None), ) - table_info: Optional[bigquery.Table] = None + # we could actually cache even when caching is not explicitly requested, but being conservative for now + result_bq_data = None if query_job and query_job.destination: - table_info = self.bqclient.get_table(query_job.destination) - size_bytes = table_info.num_bytes - else: - size_bytes = None + # we might add extra sql columns in compilation, esp if caching w ordering, infer a bigframes type for them + result_bf_schema = _result_schema(og_schema, list(compiled.sql_schema)) + dst = query_job.destination + result_bq_data = bq_data.BigqueryDataSource( + table=bq_data.GbqTable( + dst.project, + dst.dataset_id, + dst.table_id, + tuple(compiled_schema), + is_physically_stored=True, + cluster_cols=tuple(cluster_cols), + ), + schema=result_bf_schema, + ordering=compiled.row_order, + n_rows=iterator.total_rows, + ) - # we could actually cache even when caching is not explicitly requested, but being conservative for now if cache_spec is not None: - assert table_info is not None + assert result_bq_data is not None assert compiled.row_order is not None - self.cache.cache_results_table( - og_plan, table_info, compiled.row_order, num_rows=table_info.num_rows - ) + self.cache.cache_results_table(og_plan, result_bq_data) - if size_bytes is not None and size_bytes >= MAX_SMALL_RESULT_BYTES: - msg = bfe.format_message( - "The query result size has exceeded 10 GB. In BigFrames 2.0 and " - "later, you might need to manually set `allow_large_results=True` in " - "the IO method or adjust the BigFrames option: " - "`bigframes.options.compute.allow_large_results=True`." + execution_metadata = executor.ExecutionMetadata.from_iterator_and_job( + iterator, query_job + ) + result_mostly_cached = ( + hasattr(iterator, "_is_almost_completely_cached") + and iterator._is_almost_completely_cached() + ) + if result_bq_data is not None and not result_mostly_cached: + return executor.BQTableExecuteResult( + data=result_bq_data, + project_id=self.bqclient.project, + storage_client=self.bqstoragereadclient, + execution_metadata=execution_metadata, + selected_fields=tuple((col, col) for col in og_schema.names), + ) + else: + return executor.LocalExecuteResult( + data=iterator.to_arrow().select(og_schema.names), + bf_schema=plan.schema, + execution_metadata=execution_metadata, ) - warnings.warn(msg, FutureWarning) - return executor.ExecuteResult( - _arrow_batches=iterator.to_arrow_iterable( - bqstorage_client=self.bqstoragereadclient, - max_stream_count=_MAX_READ_STREAMS, - ), - schema=og_schema, - query_job=query_job, - total_bytes=size_bytes, - total_rows=iterator.total_rows, - total_bytes_processed=iterator.total_bytes_processed, - ) + +def _result_schema( + logical_schema: schemata.ArraySchema, sql_schema: list[bigquery.SchemaField] +) -> schemata.ArraySchema: + inferred_schema = bigframes.dtypes.bf_type_from_type_kind(sql_schema) + inferred_schema.update(logical_schema._mapping) + return schemata.ArraySchema( + tuple(schemata.SchemaItem(col, dtype) for col, dtype in inferred_schema.items()) + ) def _if_schema_match( diff --git a/bigframes/session/direct_gbq_execution.py b/bigframes/session/direct_gbq_execution.py index 9e7db87301..d76a1a7630 100644 --- a/bigframes/session/direct_gbq_execution.py +++ b/bigframes/session/direct_gbq_execution.py @@ -64,12 +64,13 @@ def execute( sql=compiled.sql, ) - return executor.ExecuteResult( - _arrow_batches=iterator.to_arrow_iterable(), - schema=plan.schema, - query_job=query_job, - total_rows=iterator.total_rows, - total_bytes_processed=iterator.total_bytes_processed, + # just immediately downlaod everything for simplicity + return executor.LocalExecuteResult( + data=iterator.to_arrow(), + bf_schema=plan.schema, + execution_metadata=executor.ExecutionMetadata.from_iterator_and_job( + iterator, query_job + ), ) def _run_execute_query( diff --git a/bigframes/session/executor.py b/bigframes/session/executor.py index d0cfe5f4f7..bca98bfb2f 100644 --- a/bigframes/session/executor.py +++ b/bigframes/session/executor.py @@ -18,16 +18,19 @@ import dataclasses import functools import itertools -from typing import Iterator, Literal, Optional, Union +from typing import Iterator, Literal, Optional, Sequence, Union -from google.cloud import bigquery +from google.cloud import bigquery, bigquery_storage_v1 +import google.cloud.bigquery.table as bq_table import pandas as pd import pyarrow +import pyarrow as pa import bigframes import bigframes.core -from bigframes.core import pyarrow_utils +from bigframes.core import bq_data, local_data, pyarrow_utils import bigframes.core.schema +import bigframes.dtypes import bigframes.session._io.pandas as io_pandas import bigframes.session.execution_spec as ex_spec @@ -38,21 +41,39 @@ ) -@dataclasses.dataclass(frozen=True) -class ExecuteResult: - _arrow_batches: Iterator[pyarrow.RecordBatch] - schema: bigframes.core.schema.ArraySchema - query_job: Optional[bigquery.QueryJob] = None - total_bytes: Optional[int] = None - total_rows: Optional[int] = None - total_bytes_processed: Optional[int] = None +class ResultsIterator(Iterator[pa.RecordBatch]): + """ + Iterator for query results, with some extra metadata attached. + """ + + def __init__( + self, + batches: Iterator[pa.RecordBatch], + schema: bigframes.core.schema.ArraySchema, + total_rows: Optional[int] = 0, + total_bytes: Optional[int] = 0, + ): + self._batches = batches + self._schema = schema + self._total_rows = total_rows + self._total_bytes = total_bytes + + @property + def approx_total_rows(self) -> Optional[int]: + return self._total_rows + + @property + def approx_total_bytes(self) -> Optional[int]: + return self._total_bytes + + def __next__(self) -> pa.RecordBatch: + return next(self._batches) @property def arrow_batches(self) -> Iterator[pyarrow.RecordBatch]: result_rows = 0 - for batch in self._arrow_batches: - batch = pyarrow_utils.cast_batch(batch, self.schema.to_pyarrow()) + for batch in self._batches: result_rows += batch.num_rows maximum_result_rows = bigframes.options.compute.maximum_result_rows @@ -80,10 +101,14 @@ def to_arrow_table(self) -> pyarrow.Table: itertools.chain(peek_value, batches), # reconstruct ) else: - return self.schema.to_pyarrow().empty_table() + try: + return self._schema.to_pyarrow().empty_table() + except pa.ArrowNotImplementedError: + # Bug with some pyarrow versions, empty_table only supports base storage types, not extension types. + return self._schema.to_pyarrow(use_storage_types=True).empty_table() def to_pandas(self) -> pd.DataFrame: - return io_pandas.arrow_to_pandas(self.to_arrow_table(), self.schema) + return io_pandas.arrow_to_pandas(self.to_arrow_table(), self._schema) def to_pandas_batches( self, page_size: Optional[int] = None, max_results: Optional[int] = None @@ -105,7 +130,7 @@ def to_pandas_batches( ) yield from map( - functools.partial(io_pandas.arrow_to_pandas, schema=self.schema), + functools.partial(io_pandas.arrow_to_pandas, schema=self._schema), batch_iter, ) @@ -121,6 +146,150 @@ def to_py_scalar(self): return column[0] +class ExecuteResult(abc.ABC): + @property + @abc.abstractmethod + def execution_metadata(self) -> ExecutionMetadata: + ... + + @property + @abc.abstractmethod + def schema(self) -> bigframes.core.schema.ArraySchema: + ... + + @abc.abstractmethod + def batches(self) -> ResultsIterator: + ... + + @property + def query_job(self) -> Optional[bigquery.QueryJob]: + return self.execution_metadata.query_job + + @property + def total_bytes_processed(self) -> Optional[int]: + return self.execution_metadata.bytes_processed + + +@dataclasses.dataclass(frozen=True) +class ExecutionMetadata: + query_job: Optional[bigquery.QueryJob] = None + bytes_processed: Optional[int] = None + + @classmethod + def from_iterator_and_job( + cls, iterator: bq_table.RowIterator, job: Optional[bigquery.QueryJob] + ) -> ExecutionMetadata: + return cls(query_job=job, bytes_processed=iterator.total_bytes_processed) + + +class LocalExecuteResult(ExecuteResult): + def __init__( + self, + data: pa.Table, + bf_schema: bigframes.core.schema.ArraySchema, + execution_metadata: ExecutionMetadata = ExecutionMetadata(), + ): + self._data = local_data.ManagedArrowTable.from_pyarrow(data, bf_schema) + self._execution_metadata = execution_metadata + + @property + def execution_metadata(self) -> ExecutionMetadata: + return self._execution_metadata + + @property + def schema(self) -> bigframes.core.schema.ArraySchema: + return self._data.schema + + def batches(self) -> ResultsIterator: + return ResultsIterator( + iter(self._data.to_arrow()[1]), + self.schema, + self._data.metadata.row_count, + self._data.metadata.total_bytes, + ) + + +class EmptyExecuteResult(ExecuteResult): + def __init__( + self, + bf_schema: bigframes.core.schema.ArraySchema, + execution_metadata: ExecutionMetadata = ExecutionMetadata(), + ): + self._schema = bf_schema + self._execution_metadata = execution_metadata + + @property + def execution_metadata(self) -> ExecutionMetadata: + return self._execution_metadata + + @property + def schema(self) -> bigframes.core.schema.ArraySchema: + return self._schema + + def batches(self) -> ResultsIterator: + return ResultsIterator(iter([]), self.schema, 0, 0) + + +class BQTableExecuteResult(ExecuteResult): + def __init__( + self, + data: bq_data.BigqueryDataSource, + storage_client: bigquery_storage_v1.BigQueryReadClient, + project_id: str, + *, + execution_metadata: ExecutionMetadata = ExecutionMetadata(), + limit: Optional[int] = None, + selected_fields: Optional[Sequence[tuple[str, str]]] = None, + ): + self._data = data + self._project_id = project_id + self._execution_metadata = execution_metadata + self._storage_client = storage_client + self._limit = limit + self._selected_fields = selected_fields or [ + (name, name) for name in data.schema.names + ] + + @property + def execution_metadata(self) -> ExecutionMetadata: + return self._execution_metadata + + @property + @functools.cache + def schema(self) -> bigframes.core.schema.ArraySchema: + source_ids = [selection[0] for selection in self._selected_fields] + return self._data.schema.select(source_ids).rename(dict(self._selected_fields)) + + def batches(self) -> ResultsIterator: + read_batches = bq_data.get_arrow_batches( + self._data, + [x[0] for x in self._selected_fields], + self._storage_client, + self._project_id, + ) + arrow_batches: Iterator[pa.RecordBatch] = map( + functools.partial( + pyarrow_utils.rename_batch, names=list(self.schema.names) + ), + read_batches.iter, + ) + approx_bytes: Optional[int] = read_batches.approx_bytes + approx_rows: Optional[int] = self._data.n_rows or read_batches.approx_rows + + if self._limit is not None: + if approx_rows is not None: + approx_rows = min(approx_rows, self._limit) + arrow_batches = pyarrow_utils.truncate_pyarrow_iterable( + arrow_batches, self._limit + ) + + if self._data.sql_predicate: + approx_bytes = None + approx_rows = None + + return ResultsIterator(arrow_batches, self.schema, approx_rows, approx_bytes) + + @dataclasses.dataclass(frozen=True) class HierarchicalKey: columns: tuple[str, ...] diff --git a/bigframes/session/loader.py b/bigframes/session/loader.py index 2d5dec13e6..6b16fe6bfd 100644 --- a/bigframes/session/loader.py +++ b/bigframes/session/loader.py @@ -49,7 +49,15 @@ import bigframes._tools import bigframes._tools.strings -from bigframes.core import guid, identifiers, local_data, nodes, ordering, utils +from bigframes.core import ( + bq_data, + guid, + identifiers, + local_data, + nodes, + ordering, + utils, +) import bigframes.core as core import bigframes.core.blocks as blocks import bigframes.core.events @@ -324,9 +332,7 @@ def read_managed_data( source=gbq_source, scan_list=nodes.ScanList( tuple( - nodes.ScanItem( - identifiers.ColumnId(item.column), item.dtype, item.column - ) + nodes.ScanItem(identifiers.ColumnId(item.column), item.column) for item in data.schema.items ) ), @@ -337,7 +343,7 @@ def load_data( self, data: local_data.ManagedArrowTable, offsets_col: str, - ) -> nodes.BigqueryDataSource: + ) -> bq_data.BigqueryDataSource: """Load managed data into bigquery""" # JSON support incomplete @@ -379,8 +385,9 @@ def load_data( self._start_generic_job(load_job) # must get table metadata after load job for accurate metadata destination_table = self._bqclient.get_table(load_table_destination) - return nodes.BigqueryDataSource( - nodes.GbqTable.from_table(destination_table), + return bq_data.BigqueryDataSource( + bq_data.GbqTable.from_table(destination_table), + schema=schema_w_offsets, ordering=ordering.TotalOrdering.from_offset_col(offsets_col), n_rows=data.metadata.row_count, ) @@ -389,7 +396,7 @@ def stream_data( self, data: local_data.ManagedArrowTable, offsets_col: str, - ) -> nodes.BigqueryDataSource: + ) -> bq_data.BigqueryDataSource: """Load managed data into bigquery""" schema_w_offsets = data.schema.append( schemata.SchemaItem(offsets_col, bigframes.dtypes.INT_DTYPE) @@ -415,8 +422,9 @@ def stream_data( f"Problem loading at least one row from DataFrame: {errors}. {constants.FEEDBACK_LINK}" ) destination_table = self._bqclient.get_table(load_table_destination) - return nodes.BigqueryDataSource( - nodes.GbqTable.from_table(destination_table), + return bq_data.BigqueryDataSource( + bq_data.GbqTable.from_table(destination_table), + schema=schema_w_offsets, ordering=ordering.TotalOrdering.from_offset_col(offsets_col), n_rows=data.metadata.row_count, ) @@ -425,7 +433,7 @@ def write_data( self, data: local_data.ManagedArrowTable, offsets_col: str, - ) -> nodes.BigqueryDataSource: + ) -> bq_data.BigqueryDataSource: """Load managed data into bigquery""" schema_w_offsets = data.schema.append( schemata.SchemaItem(offsets_col, bigframes.dtypes.INT_DTYPE) @@ -469,8 +477,9 @@ def request_gen() -> Generator[bq_storage_types.AppendRowsRequest, None, None]: assert response.row_count == data.data.num_rows destination_table = self._bqclient.get_table(bq_table_ref) - return nodes.BigqueryDataSource( - nodes.GbqTable.from_table(destination_table), + return bq_data.BigqueryDataSource( + bq_data.GbqTable.from_table(destination_table), + schema=schema_w_offsets, ordering=ordering.TotalOrdering.from_offset_col(offsets_col), n_rows=data.metadata.row_count, ) @@ -804,12 +813,10 @@ def read_gbq_table( bigframes.core.events.ExecutionFinished(), ) - schema = schemata.ArraySchema.from_bq_table(table) - if not include_all_columns: - schema = schema.select(index_cols + columns) + selected_cols = None if include_all_columns else index_cols + columns array_value = core.ArrayValue.from_table( table, - schema=schema, + columns=selected_cols, predicate=filter_str, at_time=time_travel_timestamp if enable_snapshot else None, primary_key=primary_key, diff --git a/bigframes/session/local_scan_executor.py b/bigframes/session/local_scan_executor.py index 65f088e8a1..fee0f557ea 100644 --- a/bigframes/session/local_scan_executor.py +++ b/bigframes/session/local_scan_executor.py @@ -57,10 +57,7 @@ def execute( if (peek is not None) and (total_rows is not None): total_rows = min(peek, total_rows) - return executor.ExecuteResult( - _arrow_batches=arrow_table.to_batches(), - schema=plan.schema, - query_job=None, - total_bytes=None, - total_rows=total_rows, + return executor.LocalExecuteResult( + data=arrow_table, + bf_schema=plan.schema, ) diff --git a/bigframes/session/polars_executor.py b/bigframes/session/polars_executor.py index a1e1d436e1..00f8f37934 100644 --- a/bigframes/session/polars_executor.py +++ b/bigframes/session/polars_executor.py @@ -16,14 +16,11 @@ import itertools from typing import Optional, TYPE_CHECKING -import pyarrow as pa - from bigframes.core import ( agg_expressions, array_value, bigframe_node, expression, - local_data, nodes, ) import bigframes.operations @@ -153,23 +150,10 @@ def execute( if peek is not None: lazy_frame = lazy_frame.limit(peek) pa_table = lazy_frame.collect().to_arrow() - return executor.ExecuteResult( - _arrow_batches=iter(map(self._adapt_batch, pa_table.to_batches())), - schema=plan.schema, - total_bytes=pa_table.nbytes, - total_rows=pa_table.num_rows, + return executor.LocalExecuteResult( + data=pa_table, + bf_schema=plan.schema, ) def _can_execute(self, plan: bigframe_node.BigFrameNode): return all(_is_node_polars_executable(node) for node in plan.unique_nodes()) - - def _adapt_array(self, array: pa.Array) -> pa.Array: - target_type = local_data.logical_type_replacements(array.type) - if target_type != array.type: - # Safe is false to handle weird polars decimal scaling - return array.cast(target_type, safe=False) - return array - - def _adapt_batch(self, batch: pa.RecordBatch) -> pa.RecordBatch: - new_arrays = [self._adapt_array(arr) for arr in batch.columns] - return pa.RecordBatch.from_arrays(new_arrays, names=batch.column_names) diff --git a/bigframes/session/read_api_execution.py b/bigframes/session/read_api_execution.py index 136c279c08..c7138f7b30 100644 --- a/bigframes/session/read_api_execution.py +++ b/bigframes/session/read_api_execution.py @@ -13,12 +13,11 @@ # limitations under the License. from __future__ import annotations -from typing import Any, Iterator, Optional +from typing import Optional from google.cloud import bigquery_storage_v1 -import pyarrow as pa -from bigframes.core import bigframe_node, nodes, pyarrow_utils, rewrite +from bigframes.core import bigframe_node, nodes, rewrite from bigframes.session import executor, semi_executor @@ -28,7 +27,9 @@ class ReadApiSemiExecutor(semi_executor.SemiExecutor): """ def __init__( - self, bqstoragereadclient: bigquery_storage_v1.BigQueryReadClient, project: str + self, + bqstoragereadclient: bigquery_storage_v1.BigQueryReadClient, + project: str, ): self.bqstoragereadclient = bqstoragereadclient self.project = project @@ -53,68 +54,14 @@ def execute( if peek is None or limit < peek: peek = limit - import google.cloud.bigquery_storage_v1.types as bq_storage_types - from google.protobuf import timestamp_pb2 - - bq_table = node.source.table.get_table_ref() - read_options: dict[str, Any] = { - "selected_fields": [item.source_id for item in node.scan_list.items] - } - if node.source.sql_predicate: - read_options["row_restriction"] = node.source.sql_predicate - read_options = bq_storage_types.ReadSession.TableReadOptions(**read_options) - - table_mod_options = {} - if node.source.at_time: - snapshot_time = timestamp_pb2.Timestamp() - snapshot_time.FromDatetime(node.source.at_time) - table_mod_options["snapshot_time"] = snapshot_time = snapshot_time - table_mods = bq_storage_types.ReadSession.TableModifiers(**table_mod_options) - - requested_session = bq_storage_types.stream.ReadSession( - table=bq_table.to_bqstorage(), - data_format=bq_storage_types.DataFormat.ARROW, - read_options=read_options, - table_modifiers=table_mods, - ) - # Single stream to maintain ordering - request = bq_storage_types.CreateReadSessionRequest( - parent=f"projects/{self.project}", - read_session=requested_session, - max_stream_count=1, - ) - session = self.bqstoragereadclient.create_read_session(request=request) - - if not session.streams: - batches: Iterator[pa.RecordBatch] = iter([]) - else: - reader = self.bqstoragereadclient.read_rows(session.streams[0].name) - rowstream = reader.rows() - - def process_page(page): - pa_batch = page.to_arrow() - pa_batch = pa_batch.select( - [item.source_id for item in node.scan_list.items] - ) - return pa.RecordBatch.from_arrays( - pa_batch.columns, names=[id.sql for id in node.ids] - ) - - batches = map(process_page, rowstream.pages) - - if peek: - batches = pyarrow_utils.truncate_pyarrow_iterable(batches, max_results=peek) - - rows = node.source.n_rows or session.estimated_row_count - if peek and rows: - rows = min(peek, rows) - - return executor.ExecuteResult( - _arrow_batches=batches, - schema=plan.schema, - query_job=None, - total_bytes=None, - total_rows=rows, + return executor.BQTableExecuteResult( + data=node.source, + project_id=self.project, + storage_client=self.bqstoragereadclient, + limit=peek, + selected_fields=[ + (item.source_id, item.id.sql) for item in node.scan_list.items + ], ) def _try_adapt_plan( diff --git a/bigframes/testing/engine_utils.py b/bigframes/testing/engine_utils.py index 625d1727ee..edb68c3a9b 100644 --- a/bigframes/testing/engine_utils.py +++ b/bigframes/testing/engine_utils.py @@ -29,6 +29,6 @@ def assert_equivalence_execution( assert e2_result is not None # Convert to pandas, as pandas has better comparison utils than arrow assert e1_result.schema == e2_result.schema - e1_table = e1_result.to_pandas() - e2_table = e2_result.to_pandas() + e1_table = e1_result.batches().to_pandas() + e2_table = e2_result.batches().to_pandas() pandas.testing.assert_frame_equal(e1_table, e2_table, rtol=1e-5) diff --git a/bigframes/testing/polars_session.py b/bigframes/testing/polars_session.py index ba6d502fcc..ca1fa329a2 100644 --- a/bigframes/testing/polars_session.py +++ b/bigframes/testing/polars_session.py @@ -51,11 +51,9 @@ def execute( pa_table = lazy_frame.collect().to_arrow() # Currently, pyarrow types might not quite be exactly the ones in the bigframes schema. # Nullability may be different, and might use large versions of list, string datatypes. - return bigframes.session.executor.ExecuteResult( - _arrow_batches=pa_table.to_batches(), - schema=array_value.schema, - total_bytes=pa_table.nbytes, - total_rows=pa_table.num_rows, + return bigframes.session.executor.LocalExecuteResult( + data=pa_table, + bf_schema=array_value.schema, ) def cached( diff --git a/tests/system/small/engines/test_read_local.py b/tests/system/small/engines/test_read_local.py index bf1a10beec..abdd29c4ac 100644 --- a/tests/system/small/engines/test_read_local.py +++ b/tests/system/small/engines/test_read_local.py @@ -31,7 +31,7 @@ def test_engines_read_local( engine, ): scan_list = nodes.ScanList.from_items( - nodes.ScanItem(identifiers.ColumnId(item.column), item.dtype, item.column) + nodes.ScanItem(identifiers.ColumnId(item.column), item.column) for item in managed_data_source.schema.items ) local_node = nodes.ReadLocalNode( @@ -46,7 +46,7 @@ def test_engines_read_local_w_offsets( engine, ): scan_list = nodes.ScanList.from_items( - nodes.ScanItem(identifiers.ColumnId(item.column), item.dtype, item.column) + nodes.ScanItem(identifiers.ColumnId(item.column), item.column) for item in managed_data_source.schema.items ) local_node = nodes.ReadLocalNode( @@ -64,7 +64,7 @@ def test_engines_read_local_w_col_subset( engine, ): scan_list = nodes.ScanList.from_items( - nodes.ScanItem(identifiers.ColumnId(item.column), item.dtype, item.column) + nodes.ScanItem(identifiers.ColumnId(item.column), item.column) for item in managed_data_source.schema.items[::-2] ) local_node = nodes.ReadLocalNode( @@ -79,7 +79,7 @@ def test_engines_read_local_w_zero_row_source( engine, ): scan_list = nodes.ScanList.from_items( - nodes.ScanItem(identifiers.ColumnId(item.column), item.dtype, item.column) + nodes.ScanItem(identifiers.ColumnId(item.column), item.column) for item in zero_row_source.schema.items ) local_node = nodes.ReadLocalNode( @@ -96,7 +96,7 @@ def test_engines_read_local_w_nested_source( engine, ): scan_list = nodes.ScanList.from_items( - nodes.ScanItem(identifiers.ColumnId(item.column), item.dtype, item.column) + nodes.ScanItem(identifiers.ColumnId(item.column), item.column) for item in nested_data_source.schema.items ) local_node = nodes.ReadLocalNode( @@ -111,7 +111,7 @@ def test_engines_read_local_w_repeated_source( engine, ): scan_list = nodes.ScanList.from_items( - nodes.ScanItem(identifiers.ColumnId(item.column), item.dtype, item.column) + nodes.ScanItem(identifiers.ColumnId(item.column), item.column) for item in repeated_data_source.schema.items ) local_node = nodes.ReadLocalNode( diff --git a/tests/system/small/test_anywidget.py b/tests/system/small/test_anywidget.py index 8944ee5365..51c39c2aec 100644 --- a/tests/system/small/test_anywidget.py +++ b/tests/system/small/test_anywidget.py @@ -117,16 +117,31 @@ def mock_execute_result_with_params( """ Mocks an execution result with configurable total_rows and arrow_batches. """ - from bigframes.session.executor import ExecuteResult - - return ExecuteResult( - iter(arrow_batches_val), - schema=schema, - query_job=None, - total_bytes=None, - total_rows=total_rows_val, + from bigframes.session.executor import ( + ExecuteResult, + ExecutionMetadata, + ResultsIterator, ) + class MockExecuteResult(ExecuteResult): + @property + def execution_metadata(self) -> ExecutionMetadata: + return ExecutionMetadata() + + @property + def schema(self): + return schema + + def batches(self) -> ResultsIterator: + return ResultsIterator( + arrow_batches_val, + self.schema, + total_rows_val, + None, + ) + + return MockExecuteResult() + def _assert_html_matches_pandas_slice( table_html: str, diff --git a/tests/system/small/test_session.py b/tests/system/small/test_session.py index d3e646dc92..698f531d57 100644 --- a/tests/system/small/test_session.py +++ b/tests/system/small/test_session.py @@ -122,7 +122,7 @@ def test_read_gbq_tokyo( assert exec_result.query_job is not None assert exec_result.query_job.location == tokyo_location - assert len(expected) == exec_result.total_rows + assert len(expected) == exec_result.batches().approx_total_rows @pytest.mark.parametrize( @@ -951,7 +951,7 @@ def test_read_pandas_tokyo( assert result.query_job is not None assert result.query_job.location == tokyo_location - assert len(expected) == result.total_rows + assert len(expected) == result.batches().approx_total_rows @all_write_engines diff --git a/tests/unit/core/rewrite/conftest.py b/tests/unit/core/rewrite/conftest.py index bbfbde46f3..8c7ee290ae 100644 --- a/tests/unit/core/rewrite/conftest.py +++ b/tests/unit/core/rewrite/conftest.py @@ -72,7 +72,6 @@ def leaf(fake_session, table): return core.ArrayValue.from_table( session=fake_session, table=table, - schema=bigframes.core.schema.ArraySchema.from_bq_table(table), ).node @@ -81,5 +80,4 @@ def leaf_too(fake_session, table_too): return core.ArrayValue.from_table( session=fake_session, table=table_too, - schema=bigframes.core.schema.ArraySchema.from_bq_table(table_too), ).node diff --git a/tests/unit/core/rewrite/test_identifiers.py b/tests/unit/core/rewrite/test_identifiers.py index f95cd696d0..c23d69c9b9 100644 --- a/tests/unit/core/rewrite/test_identifiers.py +++ b/tests/unit/core/rewrite/test_identifiers.py @@ -56,7 +56,6 @@ def test_remap_variables_nested_join_stability(leaf, fake_session, table): leaf2_uncached = core.ArrayValue.from_table( session=fake_session, table=table, - schema=leaf.schema, ).node leaf2 = leaf2_uncached.remap_vars( { @@ -67,7 +66,6 @@ def test_remap_variables_nested_join_stability(leaf, fake_session, table): leaf3_uncached = core.ArrayValue.from_table( session=fake_session, table=table, - schema=leaf.schema, ).node leaf3 = leaf3_uncached.remap_vars( { diff --git a/tests/unit/session/test_local_scan_executor.py b/tests/unit/session/test_local_scan_executor.py index 30b1b5f78d..fc59253153 100644 --- a/tests/unit/session/test_local_scan_executor.py +++ b/tests/unit/session/test_local_scan_executor.py @@ -16,7 +16,6 @@ import pyarrow import pytest -from bigframes import dtypes from bigframes.core import identifiers, local_data, nodes from bigframes.session import local_scan_executor from bigframes.testing import mocks @@ -37,9 +36,6 @@ def create_read_local_node(arrow_table: pyarrow.Table): items=tuple( nodes.ScanItem( id=identifiers.ColumnId(column_name), - dtype=dtypes.arrow_dtype_to_bigframes_dtype( - arrow_table.field(column_name).type - ), source_id=column_name, ) for column_name in arrow_table.column_names @@ -77,7 +73,7 @@ def test_local_scan_executor_with_slice(start, stop, expected_rows, object_under ) result = object_under_test.execute(plan, ordered=True) - result_table = pyarrow.Table.from_batches(result.arrow_batches) + result_table = pyarrow.Table.from_batches(result.batches().arrow_batches) assert result_table.num_rows == expected_rows diff --git a/tests/unit/test_planner.py b/tests/unit/test_planner.py index c64b50395b..66d83f362d 100644 --- a/tests/unit/test_planner.py +++ b/tests/unit/test_planner.py @@ -39,7 +39,6 @@ LEAF: core.ArrayValue = core.ArrayValue.from_table( session=FAKE_SESSION, table=TABLE, - schema=bigframes.core.schema.ArraySchema.from_bq_table(TABLE), ) From 0ff1395f2fcde0dceaf00aaf3cccd30478b9b37c Mon Sep 17 00:00:00 2001 From: jialuoo Date: Fri, 31 Oct 2025 10:59:24 -0700 Subject: [PATCH 193/313] refactor: rename _apply_unary_ops to _apply_ops_to_sql (#2214) * refactor: rename _apply_unary_ops to _apply_ops_to_sql * fix lint * fix mypy --- bigframes/testing/utils.py | 13 +--- .../sqlglot/expressions/test_ai_ops.py | 24 +++---- .../sqlglot/expressions/test_array_ops.py | 8 +-- .../expressions/test_comparison_ops.py | 2 +- .../sqlglot/expressions/test_datetime_ops.py | 50 ++++++++------- .../sqlglot/expressions/test_generic_ops.py | 30 ++++----- .../sqlglot/expressions/test_geo_ops.py | 24 +++---- .../sqlglot/expressions/test_json_ops.py | 18 +++--- .../sqlglot/expressions/test_numeric_ops.py | 46 +++++++------- .../sqlglot/expressions/test_string_ops.py | 62 ++++++++++--------- .../sqlglot/expressions/test_struct_ops.py | 2 +- .../sqlglot/expressions/test_timedelta_ops.py | 2 +- 12 files changed, 142 insertions(+), 139 deletions(-) diff --git a/bigframes/testing/utils.py b/bigframes/testing/utils.py index a0bfc9e648..cf9c9fc031 100644 --- a/bigframes/testing/utils.py +++ b/bigframes/testing/utils.py @@ -448,12 +448,12 @@ def get_function_name(func, package_requirements=None, is_row_processor=False): return f"bigframes_{function_hash}" -def _apply_unary_ops( +def _apply_ops_to_sql( obj: bpd.DataFrame, ops_list: Sequence[ex.Expression], new_names: Sequence[str], ) -> str: - """Applies a list of unary ops to the given DataFrame and returns the SQL + """Applies a list of ops to the given DataFrame and returns the SQL representing the resulting DataFrame.""" array_value = obj._block.expr result, old_names = array_value.compute_values(ops_list) @@ -485,13 +485,6 @@ def _apply_nary_op( ) -> str: """Applies a nary op to the given DataFrame and return the SQL representing the resulting DataFrame.""" - array_value = obj._block.expr op_expr = op.as_expr(*args) - result, col_ids = array_value.compute_values([op_expr]) - - # Rename columns for deterministic golden SQL results. - assert len(col_ids) == 1 - result = result.rename_columns({col_ids[0]: args[0]}).select_columns([args[0]]) - - sql = result.session._executor.to_sql(result, enable_cache=False) + sql = _apply_ops_to_sql(obj, [op_expr], [args[0]]) # type: ignore return sql diff --git a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py index 13481d88c6..45024fc691 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py @@ -39,7 +39,7 @@ def test_ai_generate(scalar_types_df: dataframe.DataFrame, snapshot): output_schema=None, ) - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] ) @@ -58,7 +58,7 @@ def test_ai_generate_with_output_schema(scalar_types_df: dataframe.DataFrame, sn output_schema="x INT64, y FLOAT64", ) - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] ) @@ -82,7 +82,7 @@ def test_ai_generate_with_model_param(scalar_types_df: dataframe.DataFrame, snap output_schema=None, ) - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] ) @@ -100,7 +100,7 @@ def test_ai_generate_bool(scalar_types_df: dataframe.DataFrame, snapshot): model_params=None, ) - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] ) @@ -125,7 +125,7 @@ def test_ai_generate_bool_with_model_param( model_params=json.dumps(dict()), ) - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] ) @@ -144,7 +144,7 @@ def test_ai_generate_int(scalar_types_df: dataframe.DataFrame, snapshot): model_params=None, ) - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] ) @@ -170,7 +170,7 @@ def test_ai_generate_int_with_model_param( model_params=json.dumps(dict()), ) - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] ) @@ -189,7 +189,7 @@ def test_ai_generate_double(scalar_types_df: dataframe.DataFrame, snapshot): model_params=None, ) - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] ) @@ -215,7 +215,7 @@ def test_ai_generate_double_with_model_param( model_params=json.dumps(dict()), ) - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] ) @@ -230,7 +230,7 @@ def test_ai_if(scalar_types_df: dataframe.DataFrame, snapshot): connection_id=CONNECTION_ID, ) - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] ) @@ -246,7 +246,7 @@ def test_ai_classify(scalar_types_df: dataframe.DataFrame, snapshot): connection_id=CONNECTION_ID, ) - sql = utils._apply_unary_ops(scalar_types_df, [op.as_expr(col_name)], ["result"]) + sql = utils._apply_ops_to_sql(scalar_types_df, [op.as_expr(col_name)], ["result"]) snapshot.assert_match(sql, "out.sql") @@ -259,7 +259,7 @@ def test_ai_score(scalar_types_df: dataframe.DataFrame, snapshot): connection_id=CONNECTION_ID, ) - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] ) diff --git a/tests/unit/core/compile/sqlglot/expressions/test_array_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_array_ops.py index 407c7bbb3c..61b8b99479 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_array_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_array_ops.py @@ -25,7 +25,7 @@ def test_array_to_string(repeated_types_df: bpd.DataFrame, snapshot): col_name = "string_list_col" bf_df = repeated_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.ArrayToStringOp(delimiter=".").as_expr(col_name)], [col_name] ) @@ -35,7 +35,7 @@ def test_array_to_string(repeated_types_df: bpd.DataFrame, snapshot): def test_array_index(repeated_types_df: bpd.DataFrame, snapshot): col_name = "string_list_col" bf_df = repeated_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [convert_index(1).as_expr(col_name)], [col_name] ) @@ -45,7 +45,7 @@ def test_array_index(repeated_types_df: bpd.DataFrame, snapshot): def test_array_slice_with_only_start(repeated_types_df: bpd.DataFrame, snapshot): col_name = "string_list_col" bf_df = repeated_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [convert_slice(slice(1, None)).as_expr(col_name)], [col_name] ) @@ -55,7 +55,7 @@ def test_array_slice_with_only_start(repeated_types_df: bpd.DataFrame, snapshot) def test_array_slice_with_start_and_stop(repeated_types_df: bpd.DataFrame, snapshot): col_name = "string_list_col" bf_df = repeated_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [convert_slice(slice(1, 5)).as_expr(col_name)], [col_name] ) diff --git a/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py index f278a15f3c..52b57623b3 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py @@ -40,7 +40,7 @@ def test_is_in(scalar_types_df: bpd.DataFrame, snapshot): "float_in_ints": ops.IsInOp(values=(1, 2, 3, None)).as_expr(float_col), } - sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py index 3261113806..6384dc79a9 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py @@ -25,7 +25,7 @@ def test_date(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.date_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.date_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -33,7 +33,7 @@ def test_date(scalar_types_df: bpd.DataFrame, snapshot): def test_day(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.day_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.day_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -43,14 +43,14 @@ def test_dayofweek(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[col_names] ops_map = {col_name: ops.dayofweek_op.as_expr(col_name) for col_name in col_names} - sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") def test_dayofyear(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.dayofyear_op.as_expr(col_name)], [col_name] ) @@ -75,7 +75,7 @@ def test_floor_dt(scalar_types_df: bpd.DataFrame, snapshot): "datetime_col_us": ops.FloorDtOp("us").as_expr("datetime_col"), } - sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") @@ -85,7 +85,7 @@ def test_floor_dt_op_invalid_freq(scalar_types_df: bpd.DataFrame): with pytest.raises( NotImplementedError, match="Unsupported freq paramater: invalid" ): - utils._apply_unary_ops( + utils._apply_ops_to_sql( bf_df, [ops.FloorDtOp(freq="invalid").as_expr(col_name)], # type:ignore [col_name], @@ -95,7 +95,7 @@ def test_floor_dt_op_invalid_freq(scalar_types_df: bpd.DataFrame): def test_hour(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.hour_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.hour_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -103,7 +103,7 @@ def test_hour(scalar_types_df: bpd.DataFrame, snapshot): def test_minute(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.minute_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.minute_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -111,7 +111,7 @@ def test_minute(scalar_types_df: bpd.DataFrame, snapshot): def test_month(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.month_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.month_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -119,7 +119,7 @@ def test_month(scalar_types_df: bpd.DataFrame, snapshot): def test_normalize(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.normalize_op.as_expr(col_name)], [col_name] ) @@ -129,7 +129,7 @@ def test_normalize(scalar_types_df: bpd.DataFrame, snapshot): def test_quarter(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.quarter_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.quarter_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -137,7 +137,7 @@ def test_quarter(scalar_types_df: bpd.DataFrame, snapshot): def test_second(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.second_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.second_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -145,7 +145,7 @@ def test_second(scalar_types_df: bpd.DataFrame, snapshot): def test_strftime(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.StrftimeOp("%Y-%m-%d").as_expr(col_name)], [col_name] ) @@ -155,7 +155,7 @@ def test_strftime(scalar_types_df: bpd.DataFrame, snapshot): def test_time(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.time_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.time_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -163,7 +163,7 @@ def test_time(scalar_types_df: bpd.DataFrame, snapshot): def test_to_datetime(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.ToDatetimeOp().as_expr(col_name)], [col_name] ) @@ -173,7 +173,7 @@ def test_to_datetime(scalar_types_df: bpd.DataFrame, snapshot): def test_to_timestamp(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.ToTimestampOp().as_expr(col_name)], [col_name] ) @@ -183,7 +183,7 @@ def test_to_timestamp(scalar_types_df: bpd.DataFrame, snapshot): def test_unix_micros(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.UnixMicros().as_expr(col_name)], [col_name] ) @@ -193,7 +193,7 @@ def test_unix_micros(scalar_types_df: bpd.DataFrame, snapshot): def test_unix_millis(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.UnixMillis().as_expr(col_name)], [col_name] ) @@ -203,7 +203,7 @@ def test_unix_millis(scalar_types_df: bpd.DataFrame, snapshot): def test_unix_seconds(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.UnixSeconds().as_expr(col_name)], [col_name] ) @@ -213,7 +213,7 @@ def test_unix_seconds(scalar_types_df: bpd.DataFrame, snapshot): def test_year(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.year_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.year_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -221,7 +221,7 @@ def test_year(scalar_types_df: bpd.DataFrame, snapshot): def test_iso_day(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.iso_day_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.iso_day_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -229,7 +229,9 @@ def test_iso_day(scalar_types_df: bpd.DataFrame, snapshot): def test_iso_week(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.iso_week_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql( + bf_df, [ops.iso_week_op.as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") @@ -237,7 +239,9 @@ def test_iso_week(scalar_types_df: bpd.DataFrame, snapshot): def test_iso_year(scalar_types_df: bpd.DataFrame, snapshot): col_name = "timestamp_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.iso_year_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql( + bf_df, [ops.iso_year_op.as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py index fd9732bf89..aa40c21fd9 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py @@ -43,7 +43,7 @@ def test_astype_int(scalar_types_df: bpd.DataFrame, snapshot): "str_const": ops.AsTypeOp(to_type=to_type).as_expr(ex.const("100")), } - sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") @@ -56,7 +56,7 @@ def test_astype_float(scalar_types_df: bpd.DataFrame, snapshot): "str_const": ops.AsTypeOp(to_type=to_type).as_expr(ex.const("1.34235e4")), "bool_w_safe": ops.AsTypeOp(to_type=to_type, safe=True).as_expr("bool_col"), } - sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") @@ -71,7 +71,7 @@ def test_astype_bool(scalar_types_df: bpd.DataFrame, snapshot): "float64_col" ), } - sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") @@ -90,7 +90,7 @@ def test_astype_time_like(scalar_types_df: bpd.DataFrame, snapshot): to_type=dtypes.TIME_DTYPE, safe=True ).as_expr("int64_col"), } - sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") @@ -103,7 +103,7 @@ def test_astype_string(scalar_types_df: bpd.DataFrame, snapshot): "bool_col": ops.AsTypeOp(to_type=to_type).as_expr("bool_col"), "bool_w_safe": ops.AsTypeOp(to_type=to_type, safe=True).as_expr("bool_col"), } - sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") @@ -122,7 +122,7 @@ def test_astype_json(scalar_types_df: bpd.DataFrame, snapshot): "string_col" ), } - sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") @@ -138,7 +138,7 @@ def test_astype_from_json(json_types_df: bpd.DataFrame, snapshot): "json_col" ), } - sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") @@ -152,7 +152,7 @@ def test_astype_json_invalid( "datetime_col" ), } - utils._apply_unary_ops( + utils._apply_ops_to_sql( scalar_types_df, list(ops_map_to.values()), list(ops_map_to.keys()) ) @@ -163,7 +163,7 @@ def test_astype_json_invalid( "json_col" ), } - utils._apply_unary_ops( + utils._apply_ops_to_sql( json_types_df, list(ops_map_from.values()), list(ops_map_from.keys()) ) @@ -228,7 +228,7 @@ def test_clip(scalar_types_df: bpd.DataFrame, snapshot): def test_hash(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.hash_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.hash_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -240,7 +240,7 @@ def test_invert(scalar_types_df: bpd.DataFrame, snapshot): "bytes_col": ops.invert_op.as_expr("bytes_col"), "bool_col": ops.invert_op.as_expr("bool_col"), } - sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") @@ -248,7 +248,7 @@ def test_invert(scalar_types_df: bpd.DataFrame, snapshot): def test_isnull(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.isnull_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.isnull_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -256,14 +256,14 @@ def test_isnull(scalar_types_df: bpd.DataFrame, snapshot): def test_notnull(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.notnull_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.notnull_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") def test_row_key(scalar_types_df: bpd.DataFrame, snapshot): column_ids = (col for col in scalar_types_df._block.expr.column_ids) - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( scalar_types_df, [ops.RowKey().as_expr(*column_ids)], ["row_key"] ) snapshot.assert_match(sql, "out.sql") @@ -283,7 +283,7 @@ def test_sql_scalar_op(scalar_types_df: bpd.DataFrame, snapshot): def test_map(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.MapOp(mappings=(("value1", "mapped1"),)).as_expr(col_name)], [col_name], diff --git a/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py index e136d172f6..9b99b37fb6 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py @@ -24,7 +24,9 @@ def test_geo_area(scalar_types_df: bpd.DataFrame, snapshot): col_name = "geography_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.geo_area_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql( + bf_df, [ops.geo_area_op.as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") @@ -32,7 +34,7 @@ def test_geo_area(scalar_types_df: bpd.DataFrame, snapshot): def test_geo_st_astext(scalar_types_df: bpd.DataFrame, snapshot): col_name = "geography_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.geo_st_astext_op.as_expr(col_name)], [col_name] ) @@ -42,7 +44,7 @@ def test_geo_st_astext(scalar_types_df: bpd.DataFrame, snapshot): def test_geo_st_boundary(scalar_types_df: bpd.DataFrame, snapshot): col_name = "geography_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.geo_st_boundary_op.as_expr(col_name)], [col_name] ) @@ -52,7 +54,7 @@ def test_geo_st_boundary(scalar_types_df: bpd.DataFrame, snapshot): def test_geo_st_buffer(scalar_types_df: bpd.DataFrame, snapshot): col_name = "geography_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.GeoStBufferOp(1.0, 8.0, False).as_expr(col_name)], [col_name] ) @@ -62,7 +64,7 @@ def test_geo_st_buffer(scalar_types_df: bpd.DataFrame, snapshot): def test_geo_st_centroid(scalar_types_df: bpd.DataFrame, snapshot): col_name = "geography_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.geo_st_centroid_op.as_expr(col_name)], [col_name] ) @@ -72,7 +74,7 @@ def test_geo_st_centroid(scalar_types_df: bpd.DataFrame, snapshot): def test_geo_st_convexhull(scalar_types_df: bpd.DataFrame, snapshot): col_name = "geography_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.geo_st_convexhull_op.as_expr(col_name)], [col_name] ) @@ -82,7 +84,7 @@ def test_geo_st_convexhull(scalar_types_df: bpd.DataFrame, snapshot): def test_geo_st_geogfromtext(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.geo_st_geogfromtext_op.as_expr(col_name)], [col_name] ) @@ -92,7 +94,7 @@ def test_geo_st_geogfromtext(scalar_types_df: bpd.DataFrame, snapshot): def test_geo_st_isclosed(scalar_types_df: bpd.DataFrame, snapshot): col_name = "geography_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.geo_st_isclosed_op.as_expr(col_name)], [col_name] ) @@ -102,7 +104,7 @@ def test_geo_st_isclosed(scalar_types_df: bpd.DataFrame, snapshot): def test_geo_st_length(scalar_types_df: bpd.DataFrame, snapshot): col_name = "geography_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.GeoStLengthOp(True).as_expr(col_name)], [col_name] ) @@ -112,7 +114,7 @@ def test_geo_st_length(scalar_types_df: bpd.DataFrame, snapshot): def test_geo_x(scalar_types_df: bpd.DataFrame, snapshot): col_name = "geography_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.geo_x_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.geo_x_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -120,6 +122,6 @@ def test_geo_x(scalar_types_df: bpd.DataFrame, snapshot): def test_geo_y(scalar_types_df: bpd.DataFrame, snapshot): col_name = "geography_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.geo_y_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.geo_y_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py index 75206091e0..ca0896bd03 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py @@ -25,7 +25,7 @@ def test_json_extract(json_types_df: bpd.DataFrame, snapshot): col_name = "json_col" bf_df = json_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.JSONExtract(json_path="$").as_expr(col_name)], [col_name] ) @@ -35,7 +35,7 @@ def test_json_extract(json_types_df: bpd.DataFrame, snapshot): def test_json_extract_array(json_types_df: bpd.DataFrame, snapshot): col_name = "json_col" bf_df = json_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.JSONExtractArray(json_path="$").as_expr(col_name)], [col_name] ) @@ -45,7 +45,7 @@ def test_json_extract_array(json_types_df: bpd.DataFrame, snapshot): def test_json_extract_string_array(json_types_df: bpd.DataFrame, snapshot): col_name = "json_col" bf_df = json_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.JSONExtractStringArray(json_path="$").as_expr(col_name)], [col_name] ) @@ -55,7 +55,7 @@ def test_json_extract_string_array(json_types_df: bpd.DataFrame, snapshot): def test_json_query(json_types_df: bpd.DataFrame, snapshot): col_name = "json_col" bf_df = json_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.JSONQuery(json_path="$").as_expr(col_name)], [col_name] ) @@ -65,7 +65,7 @@ def test_json_query(json_types_df: bpd.DataFrame, snapshot): def test_json_query_array(json_types_df: bpd.DataFrame, snapshot): col_name = "json_col" bf_df = json_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.JSONQueryArray(json_path="$").as_expr(col_name)], [col_name] ) @@ -75,7 +75,7 @@ def test_json_query_array(json_types_df: bpd.DataFrame, snapshot): def test_json_value(json_types_df: bpd.DataFrame, snapshot): col_name = "json_col" bf_df = json_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.JSONValue(json_path="$").as_expr(col_name)], [col_name] ) @@ -85,7 +85,9 @@ def test_json_value(json_types_df: bpd.DataFrame, snapshot): def test_parse_json(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.ParseJSON().as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql( + bf_df, [ops.ParseJSON().as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") @@ -93,7 +95,7 @@ def test_parse_json(scalar_types_df: bpd.DataFrame, snapshot): def test_to_json_string(json_types_df: bpd.DataFrame, snapshot): col_name = "json_col" bf_df = json_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.ToJSONString().as_expr(col_name)], [col_name] ) diff --git a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py index ab9fe53092..c66fe15c16 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py @@ -26,7 +26,7 @@ def test_arccosh(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.arccosh_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.arccosh_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -34,7 +34,7 @@ def test_arccosh(scalar_types_df: bpd.DataFrame, snapshot): def test_arccos(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.arccos_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.arccos_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -42,7 +42,7 @@ def test_arccos(scalar_types_df: bpd.DataFrame, snapshot): def test_arcsin(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.arcsin_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.arcsin_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -50,7 +50,7 @@ def test_arcsin(scalar_types_df: bpd.DataFrame, snapshot): def test_arcsinh(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.arcsinh_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.arcsinh_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -58,7 +58,7 @@ def test_arcsinh(scalar_types_df: bpd.DataFrame, snapshot): def test_arctan(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.arctan_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.arctan_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -66,7 +66,7 @@ def test_arctan(scalar_types_df: bpd.DataFrame, snapshot): def test_arctanh(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.arctanh_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.arctanh_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -74,7 +74,7 @@ def test_arctanh(scalar_types_df: bpd.DataFrame, snapshot): def test_abs(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.abs_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.abs_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -82,7 +82,7 @@ def test_abs(scalar_types_df: bpd.DataFrame, snapshot): def test_ceil(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.ceil_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.ceil_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -90,7 +90,7 @@ def test_ceil(scalar_types_df: bpd.DataFrame, snapshot): def test_cos(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.cos_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.cos_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -98,7 +98,7 @@ def test_cos(scalar_types_df: bpd.DataFrame, snapshot): def test_cosh(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.cosh_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.cosh_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -106,7 +106,7 @@ def test_cosh(scalar_types_df: bpd.DataFrame, snapshot): def test_exp(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.exp_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.exp_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -114,7 +114,7 @@ def test_exp(scalar_types_df: bpd.DataFrame, snapshot): def test_expm1(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.expm1_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.expm1_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -122,7 +122,7 @@ def test_expm1(scalar_types_df: bpd.DataFrame, snapshot): def test_floor(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.floor_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.floor_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -130,7 +130,7 @@ def test_floor(scalar_types_df: bpd.DataFrame, snapshot): def test_ln(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.ln_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.ln_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -138,7 +138,7 @@ def test_ln(scalar_types_df: bpd.DataFrame, snapshot): def test_log10(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.log10_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.log10_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -146,7 +146,7 @@ def test_log10(scalar_types_df: bpd.DataFrame, snapshot): def test_log1p(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.log1p_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.log1p_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -154,7 +154,7 @@ def test_log1p(scalar_types_df: bpd.DataFrame, snapshot): def test_neg(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.neg_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.neg_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -162,7 +162,7 @@ def test_neg(scalar_types_df: bpd.DataFrame, snapshot): def test_pos(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.pos_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.pos_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -184,7 +184,7 @@ def test_round(scalar_types_df: bpd.DataFrame, snapshot): def test_sqrt(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.sqrt_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.sqrt_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -192,7 +192,7 @@ def test_sqrt(scalar_types_df: bpd.DataFrame, snapshot): def test_sin(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.sin_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.sin_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -200,7 +200,7 @@ def test_sin(scalar_types_df: bpd.DataFrame, snapshot): def test_sinh(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.sinh_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.sinh_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -208,7 +208,7 @@ def test_sinh(scalar_types_df: bpd.DataFrame, snapshot): def test_tan(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.tan_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.tan_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -216,7 +216,7 @@ def test_tan(scalar_types_df: bpd.DataFrame, snapshot): def test_tanh(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.tanh_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.tanh_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py index 99dbce9410..b20c038ed0 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py @@ -25,7 +25,7 @@ def test_capitalize(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.capitalize_op.as_expr(col_name)], [col_name] ) @@ -40,14 +40,14 @@ def test_endswith(scalar_types_df: bpd.DataFrame, snapshot): "double": ops.EndsWithOp(pat=("ab", "cd")).as_expr(col_name), "empty": ops.EndsWithOp(pat=()).as_expr(col_name), } - sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") def test_isalnum(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.isalnum_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.isalnum_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -55,7 +55,7 @@ def test_isalnum(scalar_types_df: bpd.DataFrame, snapshot): def test_isalpha(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.isalpha_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.isalpha_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -63,7 +63,7 @@ def test_isalpha(scalar_types_df: bpd.DataFrame, snapshot): def test_isdecimal(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.isdecimal_op.as_expr(col_name)], [col_name] ) @@ -73,7 +73,7 @@ def test_isdecimal(scalar_types_df: bpd.DataFrame, snapshot): def test_isdigit(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.isdigit_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.isdigit_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -81,7 +81,7 @@ def test_isdigit(scalar_types_df: bpd.DataFrame, snapshot): def test_islower(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.islower_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.islower_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -89,7 +89,7 @@ def test_islower(scalar_types_df: bpd.DataFrame, snapshot): def test_isnumeric(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.isnumeric_op.as_expr(col_name)], [col_name] ) @@ -99,7 +99,7 @@ def test_isnumeric(scalar_types_df: bpd.DataFrame, snapshot): def test_isspace(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.isspace_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.isspace_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -107,7 +107,7 @@ def test_isspace(scalar_types_df: bpd.DataFrame, snapshot): def test_isupper(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.isupper_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.isupper_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -115,7 +115,7 @@ def test_isupper(scalar_types_df: bpd.DataFrame, snapshot): def test_len(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.len_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.len_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -123,7 +123,7 @@ def test_len(scalar_types_df: bpd.DataFrame, snapshot): def test_lower(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.lower_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.lower_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -131,7 +131,7 @@ def test_lower(scalar_types_df: bpd.DataFrame, snapshot): def test_lstrip(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.StrLstripOp(" ").as_expr(col_name)], [col_name] ) @@ -141,7 +141,7 @@ def test_lstrip(scalar_types_df: bpd.DataFrame, snapshot): def test_replace_str(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.ReplaceStrOp("e", "a").as_expr(col_name)], [col_name] ) snapshot.assert_match(sql, "out.sql") @@ -150,7 +150,7 @@ def test_replace_str(scalar_types_df: bpd.DataFrame, snapshot): def test_regex_replace_str(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.RegexReplaceStrOp(r"e", "a").as_expr(col_name)], [col_name] ) snapshot.assert_match(sql, "out.sql") @@ -159,7 +159,7 @@ def test_regex_replace_str(scalar_types_df: bpd.DataFrame, snapshot): def test_reverse(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.reverse_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.reverse_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -167,7 +167,7 @@ def test_reverse(scalar_types_df: bpd.DataFrame, snapshot): def test_rstrip(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.StrRstripOp(" ").as_expr(col_name)], [col_name] ) @@ -183,14 +183,16 @@ def test_startswith(scalar_types_df: bpd.DataFrame, snapshot): "double": ops.StartsWithOp(pat=("ab", "cd")).as_expr(col_name), "empty": ops.StartsWithOp(pat=()).as_expr(col_name), } - sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") def test_str_get(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.StrGetOp(1).as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql( + bf_df, [ops.StrGetOp(1).as_expr(col_name)], [col_name] + ) snapshot.assert_match(sql, "out.sql") @@ -203,14 +205,14 @@ def test_str_pad(scalar_types_df: bpd.DataFrame, snapshot): "right": ops.StrPadOp(length=10, fillchar="-", side="right").as_expr(col_name), "both": ops.StrPadOp(length=10, fillchar="-", side="both").as_expr(col_name), } - sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") def test_str_slice(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.StrSliceOp(1, 3).as_expr(col_name)], [col_name] ) @@ -220,7 +222,7 @@ def test_str_slice(scalar_types_df: bpd.DataFrame, snapshot): def test_strip(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.StrStripOp(" ").as_expr(col_name)], [col_name] ) @@ -230,7 +232,7 @@ def test_strip(scalar_types_df: bpd.DataFrame, snapshot): def test_str_contains(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.StrContainsOp("e").as_expr(col_name)], [col_name] ) @@ -240,7 +242,7 @@ def test_str_contains(scalar_types_df: bpd.DataFrame, snapshot): def test_str_contains_regex(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.StrContainsRegexOp("e").as_expr(col_name)], [col_name] ) @@ -250,7 +252,7 @@ def test_str_contains_regex(scalar_types_df: bpd.DataFrame, snapshot): def test_str_extract(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.StrExtractOp(r"([a-z]*)", 1).as_expr(col_name)], [col_name] ) @@ -260,7 +262,7 @@ def test_str_extract(scalar_types_df: bpd.DataFrame, snapshot): def test_str_repeat(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.StrRepeatOp(2).as_expr(col_name)], [col_name] ) snapshot.assert_match(sql, "out.sql") @@ -275,7 +277,7 @@ def test_str_find(scalar_types_df: bpd.DataFrame, snapshot): "none_end": ops.StrFindOp("e", start=None, end=5).as_expr(col_name), "start_end": ops.StrFindOp("e", start=2, end=5).as_expr(col_name), } - sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") @@ -283,7 +285,7 @@ def test_str_find(scalar_types_df: bpd.DataFrame, snapshot): def test_string_split(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.StringSplitOp(pat=",").as_expr(col_name)], [col_name] ) snapshot.assert_match(sql, "out.sql") @@ -292,7 +294,7 @@ def test_string_split(scalar_types_df: bpd.DataFrame, snapshot): def test_upper(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops(bf_df, [ops.upper_op.as_expr(col_name)], [col_name]) + sql = utils._apply_ops_to_sql(bf_df, [ops.upper_op.as_expr(col_name)], [col_name]) snapshot.assert_match(sql, "out.sql") @@ -300,7 +302,7 @@ def test_upper(scalar_types_df: bpd.DataFrame, snapshot): def test_zfill(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.ZfillOp(width=10).as_expr(col_name)], [col_name] ) snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_struct_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_struct_ops.py index 7e67e44cd3..0e24426fe8 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_struct_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_struct_ops.py @@ -55,7 +55,7 @@ def test_struct_field(nested_structs_types_df: bpd.DataFrame, snapshot): # When an index integer is provided. "int": ops.StructFieldOp(0).as_expr(col_name), } - sql = utils._apply_unary_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_timedelta_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_timedelta_ops.py index 1f01047ba9..8675b42bec 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_timedelta_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_timedelta_ops.py @@ -33,7 +33,7 @@ def test_to_timedelta(scalar_types_df: bpd.DataFrame, snapshot): def test_timedelta_floor(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] - sql = utils._apply_unary_ops( + sql = utils._apply_ops_to_sql( bf_df, [ops.timedelta_floor_op.as_expr(col_name)], [col_name] ) From 38abfd8750dc8980d5e0183b7312498d506636bd Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Fri, 31 Oct 2025 11:29:03 -0700 Subject: [PATCH 194/313] refactor: move main merge logic from df to reshape package (#2217) * refactor: move main merge logic from df to reshape package * fix mypy and tests --- bigframes/core/reshape/merge.py | 115 ++++++++++++++++++++++++++------ bigframes/dataframe.py | 88 ++---------------------- 2 files changed, 102 insertions(+), 101 deletions(-) diff --git a/bigframes/core/reshape/merge.py b/bigframes/core/reshape/merge.py index e1750d5c7a..5c6cba4915 100644 --- a/bigframes/core/reshape/merge.py +++ b/bigframes/core/reshape/merge.py @@ -18,20 +18,17 @@ from __future__ import annotations -import typing -from typing import Literal, Optional +from typing import Literal, Sequence import bigframes_vendored.pandas.core.reshape.merge as vendored_pandas_merge -# Avoid cirular imports. -if typing.TYPE_CHECKING: - import bigframes.dataframe - import bigframes.series +from bigframes import dataframe, series +from bigframes.core import blocks, utils def merge( - left: bigframes.dataframe.DataFrame, - right: bigframes.dataframe.DataFrame, + left: dataframe.DataFrame, + right: dataframe.DataFrame, how: Literal[ "inner", "left", @@ -39,33 +36,75 @@ def merge( "right", "cross", ] = "inner", - on: Optional[str] = None, + on: blocks.Label | Sequence[blocks.Label] | None = None, *, - left_on: Optional[str] = None, - right_on: Optional[str] = None, + left_on: blocks.Label | Sequence[blocks.Label] | None = None, + right_on: blocks.Label | Sequence[blocks.Label] | None = None, sort: bool = False, suffixes: tuple[str, str] = ("_x", "_y"), -) -> bigframes.dataframe.DataFrame: +) -> dataframe.DataFrame: left = _validate_operand(left) right = _validate_operand(right) - return left.merge( - right, - how=how, - on=on, - left_on=left_on, - right_on=right_on, + if how == "cross": + if on is not None: + raise ValueError("'on' is not supported for cross join.") + result_block = left._block.merge( + right._block, + left_join_ids=[], + right_join_ids=[], + suffixes=suffixes, + how=how, + sort=True, + ) + return dataframe.DataFrame(result_block) + + left_on, right_on = _validate_left_right_on( + left, right, on, left_on=left_on, right_on=right_on + ) + + if utils.is_list_like(left_on): + left_on = list(left_on) # type: ignore + else: + left_on = [left_on] + + if utils.is_list_like(right_on): + right_on = list(right_on) # type: ignore + else: + right_on = [right_on] + + left_join_ids = [] + for label in left_on: # type: ignore + left_col_id = left._resolve_label_exact(label) + # 0 elements already throws an exception + if not left_col_id: + raise ValueError(f"No column {label} found in self.") + left_join_ids.append(left_col_id) + + right_join_ids = [] + for label in right_on: # type: ignore + right_col_id = right._resolve_label_exact(label) + if not right_col_id: + raise ValueError(f"No column {label} found in other.") + right_join_ids.append(right_col_id) + + block = left._block.merge( + right._block, + how, + left_join_ids, + right_join_ids, sort=sort, suffixes=suffixes, ) + return dataframe.DataFrame(block) merge.__doc__ = vendored_pandas_merge.merge.__doc__ def _validate_operand( - obj: bigframes.dataframe.DataFrame | bigframes.series.Series, -) -> bigframes.dataframe.DataFrame: + obj: dataframe.DataFrame | series.Series, +) -> dataframe.DataFrame: import bigframes.dataframe import bigframes.series @@ -79,3 +118,39 @@ def _validate_operand( raise TypeError( f"Can only merge bigframes.series.Series or bigframes.dataframe.DataFrame objects, a {type(obj)} was passed" ) + + +def _validate_left_right_on( + left: dataframe.DataFrame, + right: dataframe.DataFrame, + on: blocks.Label | Sequence[blocks.Label] | None = None, + *, + left_on: blocks.Label | Sequence[blocks.Label] | None = None, + right_on: blocks.Label | Sequence[blocks.Label] | None = None, +): + if on is not None: + if left_on is not None or right_on is not None: + raise ValueError( + "Can not pass both `on` and `left_on` + `right_on` params." + ) + return on, on + + if left_on is not None and right_on is not None: + return left_on, right_on + + left_cols = left.columns + right_cols = right.columns + common_cols = left_cols.intersection(right_cols) + if len(common_cols) == 0: + raise ValueError( + "No common columns to perform merge on." + f"Merge options: left_on={left_on}, " + f"right_on={right_on}, " + ) + if ( + not left_cols.join(common_cols, how="inner").is_unique + or not right_cols.join(common_cols, how="inner").is_unique + ): + raise ValueError(f"Data columns not unique: {repr(common_cols)}") + + return common_cols, common_cols diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index f016fddd83..df8c87416f 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -3653,92 +3653,18 @@ def merge( sort: bool = False, suffixes: tuple[str, str] = ("_x", "_y"), ) -> DataFrame: - if how == "cross": - if on is not None: - raise ValueError("'on' is not supported for cross join.") - result_block = self._block.merge( - right._block, - left_join_ids=[], - right_join_ids=[], - suffixes=suffixes, - how=how, - sort=True, - ) - return DataFrame(result_block) - - left_on, right_on = self._validate_left_right_on( - right, on, left_on=left_on, right_on=right_on - ) - - if utils.is_list_like(left_on): - left_on = list(left_on) # type: ignore - else: - left_on = [left_on] + from bigframes.core.reshape import merge - if utils.is_list_like(right_on): - right_on = list(right_on) # type: ignore - else: - right_on = [right_on] - - left_join_ids = [] - for label in left_on: # type: ignore - left_col_id = self._resolve_label_exact(label) - # 0 elements already throws an exception - if not left_col_id: - raise ValueError(f"No column {label} found in self.") - left_join_ids.append(left_col_id) - - right_join_ids = [] - for label in right_on: # type: ignore - right_col_id = right._resolve_label_exact(label) - if not right_col_id: - raise ValueError(f"No column {label} found in other.") - right_join_ids.append(right_col_id) - - block = self._block.merge( - right._block, + return merge.merge( + self, + right, how, - left_join_ids, - right_join_ids, + on, + left_on=left_on, + right_on=right_on, sort=sort, suffixes=suffixes, ) - return DataFrame(block) - - def _validate_left_right_on( - self, - right: DataFrame, - on: Union[blocks.Label, Sequence[blocks.Label], None] = None, - *, - left_on: Union[blocks.Label, Sequence[blocks.Label], None] = None, - right_on: Union[blocks.Label, Sequence[blocks.Label], None] = None, - ): - if on is not None: - if left_on is not None or right_on is not None: - raise ValueError( - "Can not pass both `on` and `left_on` + `right_on` params." - ) - return on, on - - if left_on is not None and right_on is not None: - return left_on, right_on - - left_cols = self.columns - right_cols = right.columns - common_cols = left_cols.intersection(right_cols) - if len(common_cols) == 0: - raise ValueError( - "No common columns to perform merge on." - f"Merge options: left_on={left_on}, " - f"right_on={right_on}, " - ) - if ( - not left_cols.join(common_cols, how="inner").is_unique - or not right_cols.join(common_cols, how="inner").is_unique - ): - raise ValueError(f"Data columns not unique: {repr(common_cols)}") - - return common_cols, common_cols def join( self, From 5e006e404b65c32e5b1d342ebfcfce59ee592c8c Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 31 Oct 2025 13:01:24 -0700 Subject: [PATCH 195/313] feat: Add Series.dt.day_name (#2218) --- bigframes/operations/datetimes.py | 3 +++ .../system/small/operations/test_datetimes.py | 15 ++++++++++++ .../pandas/core/indexes/accessor.py | 24 +++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/bigframes/operations/datetimes.py b/bigframes/operations/datetimes.py index 608089ab41..3f2d16a896 100644 --- a/bigframes/operations/datetimes.py +++ b/bigframes/operations/datetimes.py @@ -148,6 +148,9 @@ def unit(self) -> str: # Assumption: pyarrow dtype return self._data._dtype.pyarrow_dtype.unit + def day_name(self) -> series.Series: + return self.strftime("%A") + def strftime(self, date_format: str) -> series.Series: return self._data._apply_unary_op(ops.StrftimeOp(date_format=date_format)) diff --git a/tests/system/small/operations/test_datetimes.py b/tests/system/small/operations/test_datetimes.py index 1462a68b49..d6a90597b4 100644 --- a/tests/system/small/operations/test_datetimes.py +++ b/tests/system/small/operations/test_datetimes.py @@ -123,6 +123,21 @@ def test_dt_dayofyear(scalars_dfs, col_name): assert_series_equal(pd_result, bf_result, check_dtype=False) +@pytest.mark.parametrize( + ("col_name",), + DATE_COLUMNS, +) +def test_dt_day_name(scalars_dfs, col_name): + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + bf_series: bigframes.series.Series = scalars_df[col_name] + + bf_result = bf_series.dt.day_name().to_pandas() + pd_result = scalars_pandas_df[col_name].dt.day_name() + + assert_series_equal(pd_result, bf_result, check_dtype=False) + + @pytest.mark.parametrize( ("col_name",), DATE_COLUMNS, diff --git a/third_party/bigframes_vendored/pandas/core/indexes/accessor.py b/third_party/bigframes_vendored/pandas/core/indexes/accessor.py index b9eb363b29..ee4de44b80 100644 --- a/third_party/bigframes_vendored/pandas/core/indexes/accessor.py +++ b/third_party/bigframes_vendored/pandas/core/indexes/accessor.py @@ -91,6 +91,30 @@ def day_of_week(self): raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + @property + def day_name(self): + """ + Return the day names in english. + + **Examples:** + >>> s = bpd.Series(pd.date_range(start="2018-01-01", freq="D", periods=3)) + >>> s + 0 2018-01-01 00:00:00 + 1 2018-01-02 00:00:00 + 2 2018-01-03 00:00:00 + dtype: timestamp[us][pyarrow] + >>> s.dt.day_name() + 0 Monday + 1 Tuesday + 2 Wednesday + dtype: string + + Returns: + Series: Series of day names. + + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + @property def dayofyear(self): """The ordinal day of the year. From ef5e83acedf005cbe1e6ad174bec523ac50517d7 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 31 Oct 2025 14:16:34 -0700 Subject: [PATCH 196/313] feat: Polars engine supports std, var (#2215) --- bigframes/core/compile/polars/compiler.py | 6 ++++-- bigframes/session/polars_executor.py | 3 +++ tests/system/small/engines/test_aggregation.py | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/bigframes/core/compile/polars/compiler.py b/bigframes/core/compile/polars/compiler.py index d48ddba0cc..3211d6ebf7 100644 --- a/bigframes/core/compile/polars/compiler.py +++ b/bigframes/core/compile/polars/compiler.py @@ -535,9 +535,11 @@ def compile_agg_op( if isinstance(op, agg_ops.StdOp): return pl.std(inputs[0]) if isinstance(op, agg_ops.VarOp): - return pl.var(inputs[0]) + # polars var doesnt' support decimal, so use std instead + return pl.std(inputs[0]).pow(2) if isinstance(op, agg_ops.PopVarOp): - return pl.var(inputs[0], ddof=0) + # polars var doesnt' support decimal, so use std instead + return pl.std(inputs[0], ddof=0).pow(2) if isinstance(op, agg_ops.FirstNonNullOp): return pl.col(*inputs).drop_nulls().first() if isinstance(op, agg_ops.LastNonNullOp): diff --git a/bigframes/session/polars_executor.py b/bigframes/session/polars_executor.py index 00f8f37934..575beff8fc 100644 --- a/bigframes/session/polars_executor.py +++ b/bigframes/session/polars_executor.py @@ -103,6 +103,9 @@ agg_ops.SumOp, agg_ops.MeanOp, agg_ops.CountOp, + agg_ops.VarOp, + agg_ops.PopVarOp, + agg_ops.StdOp, ) diff --git a/tests/system/small/engines/test_aggregation.py b/tests/system/small/engines/test_aggregation.py index d71013c648..3e6d4843de 100644 --- a/tests/system/small/engines/test_aggregation.py +++ b/tests/system/small/engines/test_aggregation.py @@ -111,6 +111,20 @@ def test_engines_unary_aggregates( assert_equivalence_execution(node, REFERENCE_ENGINE, engine) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize( + "op", + [agg_ops.std_op, agg_ops.var_op, agg_ops.PopVarOp()], +) +def test_engines_unary_variance_aggregates( + scalars_array_value: array_value.ArrayValue, + engine, + op, +): + node = apply_agg_to_all_valid(scalars_array_value, op).node + assert_equivalence_execution(node, REFERENCE_ENGINE, engine) + + def test_sql_engines_median_op_aggregates( scalars_array_value: array_value.ArrayValue, bigquery_client: bigquery.Client, From a0e1e50e47c758bdceb54d04180ed36b35cf2e35 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Mon, 3 Nov 2025 15:56:15 -0800 Subject: [PATCH 197/313] fix: Correct connection normalization in blob system tests (#2222) * fix: Correct connection normalization in blob system tests * skip more tests * Skip failed e2e tests --- tests/system/conftest.py | 17 +++++++++ tests/system/large/blob/test_function.py | 15 ++++++++ tests/system/small/bigquery/test_ai.py | 2 ++ tests/system/small/blob/test_io.py | 38 ++++++++++++++++---- tests/system/small/blob/test_properties.py | 24 +++++++++++-- tests/system/small/ml/test_multimodal_llm.py | 1 + 6 files changed, 89 insertions(+), 8 deletions(-) diff --git a/tests/system/conftest.py b/tests/system/conftest.py index 70a379fe0e..2f08a695e9 100644 --- a/tests/system/conftest.py +++ b/tests/system/conftest.py @@ -70,6 +70,23 @@ def _hash_digest_file(hasher, filepath): hasher.update(chunk) +@pytest.fixture(scope="session") +def normalize_connection_id(): + """Normalizes the connection ID by casefolding only the LOCATION component. + + Connection format: PROJECT.LOCATION.CONNECTION_NAME + Only LOCATION is case-insensitive; PROJECT and CONNECTION_NAME must be lowercase. + """ + + def normalize(connection_id: str) -> str: + parts = connection_id.split(".") + if len(parts) == 3: + return f"{parts[0]}.{parts[1].casefold()}.{parts[2]}" + return connection_id # Return unchanged if invalid format + + return normalize + + @pytest.fixture(scope="session") def tokyo_location() -> str: return TOKYO_LOCATION diff --git a/tests/system/large/blob/test_function.py b/tests/system/large/blob/test_function.py index 7963fabd0b..9ba8126dc6 100644 --- a/tests/system/large/blob/test_function.py +++ b/tests/system/large/blob/test_function.py @@ -52,6 +52,7 @@ def images_output_uris(images_output_folder: str) -> list[str]: ] +@pytest.mark.skip(reason="b/457416070") def test_blob_exif( bq_connection: str, session: bigframes.Session, @@ -103,6 +104,7 @@ def test_blob_exif_verbose( assert content_series.dtype == dtypes.JSON_DTYPE +@pytest.mark.skip(reason="b/457416070") def test_blob_image_blur_to_series( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -136,6 +138,7 @@ def test_blob_image_blur_to_series( assert not actual.blob.size().isna().any() +@pytest.mark.skip(reason="b/457416070") def test_blob_image_blur_to_series_verbose( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -163,6 +166,7 @@ def test_blob_image_blur_to_series_verbose( assert not actual.blob.size().isna().any() +@pytest.mark.skip(reason="b/457416070") def test_blob_image_blur_to_folder( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -195,6 +199,7 @@ def test_blob_image_blur_to_folder( assert not actual.blob.size().isna().any() +@pytest.mark.skip(reason="b/457416070") def test_blob_image_blur_to_folder_verbose( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -254,6 +259,7 @@ def test_blob_image_blur_to_bq_verbose(images_mm_df: bpd.DataFrame, bq_connectio assert content_series.dtype == dtypes.BYTES_DTYPE +@pytest.mark.skip(reason="b/457416070") def test_blob_image_resize_to_series( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -291,6 +297,7 @@ def test_blob_image_resize_to_series( assert not actual.blob.size().isna().any() +@pytest.mark.skip(reason="b/457416070") def test_blob_image_resize_to_series_verbose( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -325,6 +332,7 @@ def test_blob_image_resize_to_series_verbose( assert not actual.blob.size().isna().any() +@pytest.mark.skip(reason="b/457416070") def test_blob_image_resize_to_folder( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -358,6 +366,7 @@ def test_blob_image_resize_to_folder( assert not actual.blob.size().isna().any() +@pytest.mark.skip(reason="b/457416070") def test_blob_image_resize_to_folder_verbose( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -420,6 +429,7 @@ def test_blob_image_resize_to_bq_verbose( assert content_series.dtype == dtypes.BYTES_DTYPE +@pytest.mark.skip(reason="b/457416070") def test_blob_image_normalize_to_series( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -492,6 +502,7 @@ def test_blob_image_normalize_to_series_verbose( assert hasattr(content_series, "blob") +@pytest.mark.skip(reason="b/457416070") def test_blob_image_normalize_to_folder( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -598,6 +609,7 @@ def test_blob_image_normalize_to_bq_verbose( assert content_series.dtype == dtypes.BYTES_DTYPE +@pytest.mark.skip(reason="b/457416070") def test_blob_pdf_extract( pdf_mm_df: bpd.DataFrame, bq_connection: str, @@ -633,6 +645,7 @@ def test_blob_pdf_extract( ), f"Item (verbose=False): Expected keyword '{keyword}' not found in extracted text. " +@pytest.mark.skip(reason="b/457416070") def test_blob_pdf_extract_verbose( pdf_mm_df: bpd.DataFrame, bq_connection: str, @@ -670,6 +683,7 @@ def test_blob_pdf_extract_verbose( ), f"Item (verbose=True): Expected keyword '{keyword}' not found in extracted text. " +@pytest.mark.skip(reason="b/457416070") def test_blob_pdf_chunk(pdf_mm_df: bpd.DataFrame, bq_connection: str): actual = ( pdf_mm_df["pdf"] @@ -709,6 +723,7 @@ def test_blob_pdf_chunk(pdf_mm_df: bpd.DataFrame, bq_connection: str): ), f"Item (verbose=False): Expected keyword '{keyword}' not found in extracted text. " +@pytest.mark.skip(reason="b/457416070") def test_blob_pdf_chunk_verbose(pdf_mm_df: bpd.DataFrame, bq_connection: str): actual = ( pdf_mm_df["pdf"] diff --git a/tests/system/small/bigquery/test_ai.py b/tests/system/small/bigquery/test_ai.py index 0c7c40031b..6df4a7a528 100644 --- a/tests/system/small/bigquery/test_ai.py +++ b/tests/system/small/bigquery/test_ai.py @@ -273,6 +273,7 @@ def test_ai_if(session): assert result.dtype == dtypes.BOOL_DTYPE +@pytest.mark.skip(reason="b/457416070") def test_ai_if_multi_model(session): df = session.from_glob_path( "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" @@ -293,6 +294,7 @@ def test_ai_classify(session): assert result.dtype == dtypes.STRING_DTYPE +@pytest.mark.skip(reason="b/457416070") def test_ai_classify_multi_model(session): df = session.from_glob_path( "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" diff --git a/tests/system/small/blob/test_io.py b/tests/system/small/blob/test_io.py index 5ada4fabb0..5da113a5e1 100644 --- a/tests/system/small/blob/test_io.py +++ b/tests/system/small/blob/test_io.py @@ -12,27 +12,36 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Callable from unittest import mock import IPython.display import pandas as pd +import pytest import bigframes import bigframes.pandas as bpd def test_blob_create_from_uri_str( - bq_connection: str, session: bigframes.Session, images_uris + bq_connection: str, + session: bigframes.Session, + images_uris, + normalize_connection_id: Callable[[str], str], ): uri_series = bpd.Series(images_uris, session=session) blob_series = uri_series.str.to_blob(connection=bq_connection) pd_blob_df = blob_series.struct.explode().to_pandas() + pd_blob_df["authorizer"] = pd_blob_df["authorizer"].apply(normalize_connection_id) expected_pd_df = pd.DataFrame( { "uri": images_uris, "version": [None, None], - "authorizer": [bq_connection.casefold(), bq_connection.casefold()], + "authorizer": [ + normalize_connection_id(bq_connection), + normalize_connection_id(bq_connection), + ], "details": [None, None], } ) @@ -43,7 +52,11 @@ def test_blob_create_from_uri_str( def test_blob_create_from_glob_path( - bq_connection: str, session: bigframes.Session, images_gcs_path, images_uris + bq_connection: str, + session: bigframes.Session, + images_gcs_path, + images_uris, + normalize_connection_id: Callable[[str], str], ): blob_df = session.from_glob_path( images_gcs_path, connection=bq_connection, name="blob_col" @@ -55,12 +68,16 @@ def test_blob_create_from_glob_path( .sort_values("uri") .reset_index(drop=True) ) + pd_blob_df["authorizer"] = pd_blob_df["authorizer"].apply(normalize_connection_id) expected_df = pd.DataFrame( { "uri": images_uris, "version": [None, None], - "authorizer": [bq_connection.casefold(), bq_connection.casefold()], + "authorizer": [ + normalize_connection_id(bq_connection), + normalize_connection_id(bq_connection), + ], "details": [None, None], } ) @@ -71,7 +88,11 @@ def test_blob_create_from_glob_path( def test_blob_create_read_gbq_object_table( - bq_connection: str, session: bigframes.Session, images_gcs_path, images_uris + bq_connection: str, + session: bigframes.Session, + images_gcs_path, + images_uris, + normalize_connection_id: Callable[[str], str], ): obj_table = session._create_object_table(images_gcs_path, bq_connection) @@ -83,11 +104,15 @@ def test_blob_create_read_gbq_object_table( .sort_values("uri") .reset_index(drop=True) ) + pd_blob_df["authorizer"] = pd_blob_df["authorizer"].apply(normalize_connection_id) expected_df = pd.DataFrame( { "uri": images_uris, "version": [None, None], - "authorizer": [bq_connection.casefold(), bq_connection.casefold()], + "authorizer": [ + normalize_connection_id(bq_connection), + normalize_connection_id(bq_connection), + ], "details": [None, None], } ) @@ -97,6 +122,7 @@ def test_blob_create_read_gbq_object_table( ) +@pytest.mark.skip(reason="b/457416070") def test_display_images(monkeypatch, images_mm_df: bpd.DataFrame): mock_display = mock.Mock() monkeypatch.setattr(IPython.display, "display", mock_display) diff --git a/tests/system/small/blob/test_properties.py b/tests/system/small/blob/test_properties.py index 47d4d2aa04..c411c01f13 100644 --- a/tests/system/small/blob/test_properties.py +++ b/tests/system/small/blob/test_properties.py @@ -12,7 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + +from typing import Callable + import pandas as pd +import pytest import bigframes.dtypes as dtypes import bigframes.pandas as bpd @@ -27,10 +32,19 @@ def test_blob_uri(images_uris: list[str], images_mm_df: bpd.DataFrame): ) -def test_blob_authorizer(images_mm_df: bpd.DataFrame, bq_connection: str): +def test_blob_authorizer( + images_mm_df: bpd.DataFrame, + bq_connection: str, + normalize_connection_id: Callable[[str], str], +): actual = images_mm_df["blob_col"].blob.authorizer().to_pandas() + actual = actual.apply(normalize_connection_id) expected = pd.Series( - [bq_connection.casefold(), bq_connection.casefold()], name="authorizer" + [ + normalize_connection_id(bq_connection), + normalize_connection_id(bq_connection), + ], + name="authorizer", ) pd.testing.assert_series_equal( @@ -38,6 +52,7 @@ def test_blob_authorizer(images_mm_df: bpd.DataFrame, bq_connection: str): ) +@pytest.mark.skip(reason="b/457416070") def test_blob_version(images_mm_df: bpd.DataFrame): actual = images_mm_df["blob_col"].blob.version().to_pandas() expected = pd.Series(["1753907851152593", "1753907851111538"], name="version") @@ -47,6 +62,7 @@ def test_blob_version(images_mm_df: bpd.DataFrame): ) +@pytest.mark.skip(reason="b/457416070") def test_blob_metadata(images_mm_df: bpd.DataFrame): actual = images_mm_df["blob_col"].blob.metadata().to_pandas() expected = pd.Series( @@ -71,6 +87,7 @@ def test_blob_metadata(images_mm_df: bpd.DataFrame): pd.testing.assert_series_equal(actual, expected) +@pytest.mark.skip(reason="b/457416070") def test_blob_content_type(images_mm_df: bpd.DataFrame): actual = images_mm_df["blob_col"].blob.content_type().to_pandas() expected = pd.Series(["image/jpeg", "image/jpeg"], name="content_type") @@ -80,6 +97,7 @@ def test_blob_content_type(images_mm_df: bpd.DataFrame): ) +@pytest.mark.skip(reason="b/457416070") def test_blob_md5_hash(images_mm_df: bpd.DataFrame): actual = images_mm_df["blob_col"].blob.md5_hash().to_pandas() expected = pd.Series( @@ -92,6 +110,7 @@ def test_blob_md5_hash(images_mm_df: bpd.DataFrame): ) +@pytest.mark.skip(reason="b/457416070") def test_blob_size(images_mm_df: bpd.DataFrame): actual = images_mm_df["blob_col"].blob.size().to_pandas() expected = pd.Series([338390, 43333], name="size") @@ -101,6 +120,7 @@ def test_blob_size(images_mm_df: bpd.DataFrame): ) +@pytest.mark.skip(reason="b/457416070") def test_blob_updated(images_mm_df: bpd.DataFrame): actual = images_mm_df["blob_col"].blob.updated().to_pandas() expected = pd.Series( diff --git a/tests/system/small/ml/test_multimodal_llm.py b/tests/system/small/ml/test_multimodal_llm.py index 48a69f522c..fe34f9c02b 100644 --- a/tests/system/small/ml/test_multimodal_llm.py +++ b/tests/system/small/ml/test_multimodal_llm.py @@ -21,6 +21,7 @@ from bigframes.testing import utils +@pytest.mark.skip(reason="b/457416070") @pytest.mark.flaky(retries=2) def test_multimodal_embedding_generator_predict_default_params_success( images_mm_df, session, bq_connection From 94c8b3c19517ed42271b56de94bdf9cb04cde7df Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 17:25:31 -0800 Subject: [PATCH 198/313] chore(main): release 2.28.0 (#2199) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 23 +++++++++++++++++++++++ bigframes/version.py | 4 ++-- third_party/bigframes_vendored/version.py | 4 ++-- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60fe9ae5e3..1df3ad0f70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ [1]: https://pypi.org/project/bigframes/#history +## [2.28.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.27.0...v2.28.0) (2025-11-03) + + +### Features + +* Add bigframes.bigquery.st_simplify ([#2210](https://github.com/googleapis/python-bigquery-dataframes/issues/2210)) ([ecee2bc](https://github.com/googleapis/python-bigquery-dataframes/commit/ecee2bc6ada0bc968fc56ed7194dc8c043547e93)) +* Add Series.dt.day_name ([#2218](https://github.com/googleapis/python-bigquery-dataframes/issues/2218)) ([5e006e4](https://github.com/googleapis/python-bigquery-dataframes/commit/5e006e404b65c32e5b1d342ebfcfce59ee592c8c)) +* Polars engine supports std, var ([#2215](https://github.com/googleapis/python-bigquery-dataframes/issues/2215)) ([ef5e83a](https://github.com/googleapis/python-bigquery-dataframes/commit/ef5e83acedf005cbe1e6ad174bec523ac50517d7)) +* Support INFORMATION_SCHEMA views in `read_gbq` ([#1895](https://github.com/googleapis/python-bigquery-dataframes/issues/1895)) ([d97cafc](https://github.com/googleapis/python-bigquery-dataframes/commit/d97cafcb5921fca2351b18011b0e54e2631cc53d)) +* Support some python standard lib callables in apply/combine ([#2187](https://github.com/googleapis/python-bigquery-dataframes/issues/2187)) ([86a2756](https://github.com/googleapis/python-bigquery-dataframes/commit/86a27564b48b854a32b3d11cd2105aa0fa496279)) + + +### Bug Fixes + +* Correct connection normalization in blob system tests ([#2222](https://github.com/googleapis/python-bigquery-dataframes/issues/2222)) ([a0e1e50](https://github.com/googleapis/python-bigquery-dataframes/commit/a0e1e50e47c758bdceb54d04180ed36b35cf2e35)) +* Improve error handling in blob operations ([#2194](https://github.com/googleapis/python-bigquery-dataframes/issues/2194)) ([d410046](https://github.com/googleapis/python-bigquery-dataframes/commit/d4100466612df0523d01ed01ca1e115dabd6ef45)) +* Resolve AttributeError in TableWidget and improve initialization ([#1937](https://github.com/googleapis/python-bigquery-dataframes/issues/1937)) ([4c4c9b1](https://github.com/googleapis/python-bigquery-dataframes/commit/4c4c9b14657b7cda1940ef39e7d4db20a9ff5308)) + + +### Documentation + +* Update bq_dataframes_llm_output_schema.ipynb ([#2004](https://github.com/googleapis/python-bigquery-dataframes/issues/2004)) ([316ba9f](https://github.com/googleapis/python-bigquery-dataframes/commit/316ba9f557d792117d5a7845d7567498f78dd513)) + ## [2.27.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.26.0...v2.27.0) (2025-10-24) diff --git a/bigframes/version.py b/bigframes/version.py index 4e319dd41d..cf7562a306 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.27.0" +__version__ = "2.28.0" # {x-release-please-start-date} -__release_date__ = "2025-10-24" +__release_date__ = "2025-11-03" # {x-release-please-end} diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index 4e319dd41d..cf7562a306 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.27.0" +__version__ = "2.28.0" # {x-release-please-start-date} -__release_date__ = "2025-10-24" +__release_date__ = "2025-11-03" # {x-release-please-end} From 3d8b17fa5eb9bbfc9e151031141a419f2dc3acb4 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Mon, 3 Nov 2025 17:27:14 -0800 Subject: [PATCH 199/313] fix: support results with STRUCT and ARRAY columns containing JSON subfields in `to_pandas_batches()` (#2216) * Correctly display DataFrames with JSON columns in anywidget * Improve JSON type handling for to_gbq and to_pandas_batches * Revert "Correctly display DataFrames with JSON columns in anywidget" This reverts commit 8c3451266c28ec0da6dd57c4f9929ae68a593574. * Remove unnecessary comment * code refactor * testcase update * Fix testcase * function call updated in bigframes/core/blocks.py, unused function removed from bigframes/dtypes.py * revert the code refactor in loader.py, I will use a seperate pr for this refactor * replace the manual construction of the empty DataFrame with the more robust try...except block that leverages to_pyarrow and empty_table * fix testcase * existing arrow_to_pandas() helper that properly handles dtype conversion * testcase update * refactor testcase * Add pyarrow id to comments --- bigframes/core/blocks.py | 16 +++-- tests/system/small/test_dataframe_io.py | 86 +++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index 41986ce5df..61aaab1120 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -68,6 +68,7 @@ import bigframes.operations.aggregations as agg_ops from bigframes.session import dry_runs, execution_spec from bigframes.session import executor as executors +from bigframes.session._io import pandas as io_pandas # Type constraint for wherever column labels are used Label = typing.Hashable @@ -711,12 +712,15 @@ def to_pandas_batches( # To reduce the number of edge cases to consider when working with the # results of this, always return at least one DataFrame. See: # b/428918844. - empty_val = pd.DataFrame( - { - col: pd.Series([], dtype=self.expr.get_column_type(col)) - for col in itertools.chain(self.value_columns, self.index_columns) - } - ) + try: + empty_arrow_table = self.expr.schema.to_pyarrow().empty_table() + except pa.ArrowNotImplementedError: + # Bug with some pyarrow versions(https://github.com/apache/arrow/issues/45262), + # empty_table only supports base storage types, not extension types. + empty_arrow_table = self.expr.schema.to_pyarrow( + use_storage_types=True + ).empty_table() + empty_val = io_pandas.arrow_to_pandas(empty_arrow_table, self.expr.schema) dfs = map( lambda a: a[0], itertools.zip_longest( diff --git a/tests/system/small/test_dataframe_io.py b/tests/system/small/test_dataframe_io.py index 96d7881d67..4d4a144d0a 100644 --- a/tests/system/small/test_dataframe_io.py +++ b/tests/system/small/test_dataframe_io.py @@ -376,6 +376,92 @@ def test_to_pandas_batches_w_empty_dataframe(session): pandas.testing.assert_series_equal(results[0].dtypes, empty.dtypes) +@pytest.mark.skipif( + bigframes.features.PANDAS_VERSIONS.is_arrow_list_dtype_usable, + reason="Test for pandas 1.x behavior only", +) +def test_to_pandas_batches_preserves_dtypes_for_populated_nested_json_pandas1(session): + """Verifies to_pandas_batches() preserves dtypes for nested JSON in pandas 1.x.""" + sql = """ + SELECT + 0 AS id, + [JSON '{"a":1}', JSON '{"b":2}'] AS json_array, + STRUCT(JSON '{"x":1}' AS json_field, 'test' AS str_field) AS json_struct + """ + df = session.read_gbq(sql, index_col="id") + batches = list(df.to_pandas_batches()) + + assert batches[0].dtypes["json_array"] == "object" + assert isinstance(batches[0].dtypes["json_struct"], pd.ArrowDtype) + + +@pytest.mark.skipif( + not bigframes.features.PANDAS_VERSIONS.is_arrow_list_dtype_usable, + reason="Test for pandas 2.x behavior only", +) +def test_to_pandas_batches_preserves_dtypes_for_populated_nested_json_pandas2(session): + """Verifies to_pandas_batches() preserves dtypes for nested JSON in pandas 2.x.""" + sql = """ + SELECT + 0 AS id, + [JSON '{"a":1}', JSON '{"b":2}'] AS json_array, + STRUCT(JSON '{"x":1}' AS json_field, 'test' AS str_field) AS json_struct + """ + df = session.read_gbq(sql, index_col="id") + batches = list(df.to_pandas_batches()) + + assert isinstance(batches[0].dtypes["json_array"], pd.ArrowDtype) + assert isinstance(batches[0].dtypes["json_array"].pyarrow_dtype, pa.ListType) + assert isinstance(batches[0].dtypes["json_struct"], pd.ArrowDtype) + + +@pytest.mark.skipif( + bigframes.features.PANDAS_VERSIONS.is_arrow_list_dtype_usable, + reason="Test for pandas 1.x behavior only", +) +def test_to_pandas_batches_should_not_error_on_empty_nested_json_pandas1(session): + """Verify to_pandas_batches() works with empty nested JSON types in pandas 1.x.""" + + sql = """ + SELECT + 1 AS id, + [] AS json_array, + STRUCT(NULL AS json_field, 'test2' AS str_field) AS json_struct + """ + df = session.read_gbq(sql, index_col="id") + + # The main point: this should not raise an error + batches = list(df.to_pandas_batches()) + assert sum(len(b) for b in batches) == 1 + + assert batches[0].dtypes["json_array"] == "object" + assert isinstance(batches[0].dtypes["json_struct"], pd.ArrowDtype) + + +@pytest.mark.skipif( + not bigframes.features.PANDAS_VERSIONS.is_arrow_list_dtype_usable, + reason="Test for pandas 2.x behavior only", +) +def test_to_pandas_batches_should_not_error_on_empty_nested_json_pandas2(session): + """Verify to_pandas_batches() works with empty nested JSON types in pandas 2.x.""" + + sql = """ + SELECT + 1 AS id, + [] AS json_array, + STRUCT(NULL AS json_field, 'test2' AS str_field) AS json_struct + """ + df = session.read_gbq(sql, index_col="id") + + # The main point: this should not raise an error + batches = list(df.to_pandas_batches()) + assert sum(len(b) for b in batches) == 1 + + assert isinstance(batches[0].dtypes["json_array"], pd.ArrowDtype) + assert isinstance(batches[0].dtypes["json_struct"], pd.ArrowDtype) + assert isinstance(batches[0].dtypes["json_struct"].pyarrow_dtype, pa.StructType) + + @pytest.mark.parametrize("allow_large_results", (True, False)) def test_to_pandas_batches_w_page_size_and_max_results(session, allow_large_results): """Verify to_pandas_batches() APIs returns the expected page size. From 2e2f119a60ac0bea2885f1003f381bfabd8b979a Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Mon, 3 Nov 2025 17:27:36 -0800 Subject: [PATCH 200/313] Refactor: Remove duplicate _has_json_arrow_type from loader.py (#2221) --- bigframes/session/loader.py | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/bigframes/session/loader.py b/bigframes/session/loader.py index 6b16fe6bfd..4e67eac9ae 100644 --- a/bigframes/session/loader.py +++ b/bigframes/session/loader.py @@ -45,7 +45,6 @@ import google.cloud.bigquery.table from google.cloud.bigquery_storage_v1 import types as bq_storage_types import pandas -import pyarrow as pa import bigframes._tools import bigframes._tools.strings @@ -1307,22 +1306,6 @@ def _transform_read_gbq_configuration(configuration: Optional[dict]) -> dict: return configuration -def _has_json_arrow_type(arrow_type: pa.DataType) -> bool: - """ - Searches recursively for JSON array type within a PyArrow DataType. - """ - if arrow_type == bigframes.dtypes.JSON_ARROW_TYPE: - return True - if pa.types.is_list(arrow_type): - return _has_json_arrow_type(arrow_type.value_type) - if pa.types.is_struct(arrow_type): - for i in range(arrow_type.num_fields): - if _has_json_arrow_type(arrow_type.field(i).type): - return True - return False - return False - - def _validate_dtype_can_load(name: str, column_type: bigframes.dtypes.Dtype): """ Determines whether a datatype is supported by bq load jobs. @@ -1339,7 +1322,9 @@ def _validate_dtype_can_load(name: str, column_type: bigframes.dtypes.Dtype): if column_type == bigframes.dtypes.JSON_DTYPE: return - if isinstance(column_type, pandas.ArrowDtype) and _has_json_arrow_type( + if isinstance( + column_type, pandas.ArrowDtype + ) and bigframes.dtypes.contains_db_dtypes_json_arrow_type( column_type.pyarrow_dtype ): raise NotImplementedError( From 6c9a18d7e67841c6fe6c1c6f34f80b950815141f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Tue, 4 Nov 2025 11:54:41 -0600 Subject: [PATCH 201/313] fix: simplify UnsupportedTypeError message (#2212) * fix: simplify UnsupportedTypeError message * relax assertion --- bigframes/functions/function_typing.py | 16 +++++- .../small/functions/test_remote_function.py | 2 +- tests/unit/functions/test_function_typing.py | 50 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/unit/functions/test_function_typing.py diff --git a/bigframes/functions/function_typing.py b/bigframes/functions/function_typing.py index 44ee071001..30804f317c 100644 --- a/bigframes/functions/function_typing.py +++ b/bigframes/functions/function_typing.py @@ -60,8 +60,22 @@ class UnsupportedTypeError(ValueError): def __init__(self, type_, supported_types): self.type = type_ self.supported_types = supported_types + + types_to_format = supported_types + if isinstance(supported_types, dict): + types_to_format = supported_types.keys() + + supported_types_str = ", ".join( + sorted( + [ + getattr(supported, "__name__", supported) + for supported in types_to_format + ] + ) + ) + super().__init__( - f"'{type_}' must be one of the supported types ({supported_types}) " + f"'{getattr(type_, '__name__', type_)}' must be one of the supported types ({supported_types_str}) " "or a list of one of those types." ) diff --git a/tests/system/small/functions/test_remote_function.py b/tests/system/small/functions/test_remote_function.py index 26c4b89b24..805505ecd5 100644 --- a/tests/system/small/functions/test_remote_function.py +++ b/tests/system/small/functions/test_remote_function.py @@ -1646,7 +1646,7 @@ def func_tuple(x): with pytest.raises( ValueError, - match=r"'typing\.Sequence\[int\]' must be one of the supported types", + match=r"must be one of the supported types", ): bff.remote_function( input_types=int, diff --git a/tests/unit/functions/test_function_typing.py b/tests/unit/functions/test_function_typing.py new file mode 100644 index 0000000000..46ae19555a --- /dev/null +++ b/tests/unit/functions/test_function_typing.py @@ -0,0 +1,50 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import decimal + +import pytest + +from bigframes.functions import function_typing + + +def test_unsupported_type_error_init_with_dict(): + err = function_typing.UnsupportedTypeError( + decimal.Decimal, {int: "INT64", float: "FLOAT64"} + ) + + message = str(err) + + assert "Decimal" in message + assert "float, int" in message + + +def test_unsupported_type_error_init_with_set(): + err = function_typing.UnsupportedTypeError(decimal.Decimal, {int, float}) + + message = str(err) + + assert "Decimal" in message + assert "float, int" in message + + +def test_sdk_type_from_python_type_raises_unsupported_type_error(): + with pytest.raises(function_typing.UnsupportedTypeError) as excinfo: + function_typing.sdk_type_from_python_type(datetime.datetime) + + message = str(excinfo.value) + + assert "datetime" in message + assert "bool, bytes, float, int, str" in message From 764e3186f091c42cfc546a36137cee5f727838a5 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Tue, 4 Nov 2025 10:08:52 -0800 Subject: [PATCH 202/313] refactor: fix remap variable errors for InNode (#2201) --- bigframes/core/nodes.py | 2 +- bigframes/core/rewrite/identifiers.py | 4 +++- tests/system/small/engines/test_join.py | 4 ++-- tests/system/small/engines/test_slicing.py | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/bigframes/core/nodes.py b/bigframes/core/nodes.py index 9e0fcb3ace..553b41a631 100644 --- a/bigframes/core/nodes.py +++ b/bigframes/core/nodes.py @@ -1627,7 +1627,7 @@ class ResultNode(UnaryNode): # TODO: CTE definitions def _validate(self): - for ref, name in self.output_cols: + for ref, _ in self.output_cols: assert ref.id in self.child.ids @property diff --git a/bigframes/core/rewrite/identifiers.py b/bigframes/core/rewrite/identifiers.py index e911d81895..2e31f07a79 100644 --- a/bigframes/core/rewrite/identifiers.py +++ b/bigframes/core/rewrite/identifiers.py @@ -57,8 +57,10 @@ def remap_variables( new_root = root.transform_children(lambda node: remapped_children[node]) # Step 3: Transform the current node using the mappings from its children. + # "reversed" is required for InNode so that in case of a duplicate column ID, + # the left child's mapping is the one that's kept. downstream_mappings: dict[identifiers.ColumnId, identifiers.ColumnId] = { - k: v for mapping in new_child_mappings for k, v in mapping.items() + k: v for mapping in reversed(new_child_mappings) for k, v in mapping.items() } if isinstance(new_root, nodes.InNode): new_root = typing.cast(nodes.InNode, new_root) diff --git a/tests/system/small/engines/test_join.py b/tests/system/small/engines/test_join.py index 91c199a437..7ea24a554d 100644 --- a/tests/system/small/engines/test_join.py +++ b/tests/system/small/engines/test_join.py @@ -55,7 +55,7 @@ def test_engines_join_on_coerced_key( assert_equivalence_execution(result.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) @pytest.mark.parametrize("join_type", ["left", "inner", "right", "outer"]) def test_engines_join_multi_key( scalars_array_value: array_value.ArrayValue, @@ -90,7 +90,7 @@ def test_engines_cross_join( assert_equivalence_execution(result.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) @pytest.mark.parametrize( ("left_key", "right_key"), [ diff --git a/tests/system/small/engines/test_slicing.py b/tests/system/small/engines/test_slicing.py index 7340ff145b..022758893d 100644 --- a/tests/system/small/engines/test_slicing.py +++ b/tests/system/small/engines/test_slicing.py @@ -24,7 +24,7 @@ REFERENCE_ENGINE = polars_executor.PolarsExecutor() -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) @pytest.mark.parametrize( ("start", "stop", "step"), [ From bfbb2f034c4c16dfb6ad64d5ed8db100e4ad55c2 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Tue, 4 Nov 2025 10:16:19 -0800 Subject: [PATCH 203/313] chore: Migrate maximum_op operator to SQLGlot (#2223) Migrated the maximum_op and minimum_op operators to SQLGlot. --- .../compile/sqlglot/expressions/comparison_ops.py | 5 +++++ .../test_comparison_ops/test_maximum_op/out.sql | 14 ++++++++++++++ .../sqlglot/expressions/test_comparison_ops.py | 7 +++++++ 3 files changed, 26 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_maximum_op/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/comparison_ops.py b/bigframes/core/compile/sqlglot/expressions/comparison_ops.py index e77b8b50a5..89d3b4a682 100644 --- a/bigframes/core/compile/sqlglot/expressions/comparison_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/comparison_ops.py @@ -109,6 +109,11 @@ def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: return sge.LTE(this=left_expr, expression=right_expr) +@register_binary_op(ops.maximum_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.Greatest(expressions=[left.expr, right.expr]) + + @register_binary_op(ops.minimum_op) def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: return sge.Least(this=left.expr, expressions=right.expr) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_maximum_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_maximum_op/out.sql new file mode 100644 index 0000000000..c0c0f5c97f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_maximum_op/out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `float64_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + GREATEST(`bfcol_0`, `bfcol_1`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py index 52b57623b3..20dd6c5ca6 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py @@ -110,6 +110,13 @@ def test_le_numeric(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(bf_df.sql, "out.sql") +def test_maximum_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col"]] + sql = utils._apply_binary_op(bf_df, ops.maximum_op, "int64_col", "float64_col") + + snapshot.assert_match(sql, "out.sql") + + def test_minimum_op(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["int64_col", "float64_col"]] sql = utils._apply_binary_op(bf_df, ops.minimum_op, "int64_col", "float64_col") From 0a3e1721e3d21324cb9026991d1d7bce0ae3c2af Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Tue, 4 Nov 2025 10:16:41 -0800 Subject: [PATCH 204/313] refactor: add agg_ops.StdOp, VarOp and PopVarOp for the sqlglot compiler (#2224) --- .../sqlglot/aggregations/op_registration.py | 2 +- .../sqlglot/aggregations/unary_compiler.py | 44 +++++++++++ .../system/small/engines/test_aggregation.py | 2 +- .../test_unary_compiler/test_pop_var/out.sql | 15 ++++ .../test_pop_var/window_out.sql | 13 ++++ .../test_unary_compiler/test_std/out.sql | 27 +++++++ .../test_std/window_out.sql | 13 ++++ .../test_unary_compiler/test_var/out.sql | 15 ++++ .../test_var/window_out.sql | 13 ++++ .../aggregations/test_unary_compiler.py | 78 +++++++++++++++++++ 10 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/window_out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/window_out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/window_out.sql diff --git a/bigframes/core/compile/sqlglot/aggregations/op_registration.py b/bigframes/core/compile/sqlglot/aggregations/op_registration.py index eb02b8bd50..a26429f27e 100644 --- a/bigframes/core/compile/sqlglot/aggregations/op_registration.py +++ b/bigframes/core/compile/sqlglot/aggregations/op_registration.py @@ -52,5 +52,5 @@ def arg_checker(*args, **kwargs): def __getitem__(self, op: str | agg_ops.WindowOp) -> CompilationFunc: key = op if isinstance(op, type) else type(op) if str(key) not in self._registered_ops: - raise ValueError(f"{key} is already not registered") + raise ValueError(f"{key} is not registered") return self._registered_ops[str(key)] diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py index d157f07df2..d0d887588c 100644 --- a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -239,6 +239,20 @@ def _( return apply_window_if_present(sge.func("MIN", column.expr), window) +@UNARY_OP_REGISTRATION.register(agg_ops.PopVarOp) +def _( + op: agg_ops.PopVarOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + expr = column.expr + if column.dtype == dtypes.BOOL_DTYPE: + expr = sge.Cast(this=expr, to="INT64") + + expr = sge.func("VAR_POP", expr) + return apply_window_if_present(expr, window) + + @UNARY_OP_REGISTRATION.register(agg_ops.QuantileOp) def _( op: agg_ops.QuantileOp, @@ -278,6 +292,22 @@ def _( return apply_window_if_present(sge.func("COUNT", sge.convert(1)), window) +@UNARY_OP_REGISTRATION.register(agg_ops.StdOp) +def _( + op: agg_ops.StdOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + expr = column.expr + if column.dtype == dtypes.BOOL_DTYPE: + expr = sge.Cast(this=expr, to="INT64") + + expr = sge.func("STDDEV", expr) + if op.should_floor_result or column.dtype == dtypes.TIMEDELTA_DTYPE: + expr = sge.Cast(this=sge.func("FLOOR", expr), to="INT64") + return apply_window_if_present(expr, window) + + @UNARY_OP_REGISTRATION.register(agg_ops.ShiftOp) def _( op: agg_ops.ShiftOp, @@ -331,3 +361,17 @@ def _( expression=shifted, unit=sge.Identifier(this="MICROSECOND"), ) + + +@UNARY_OP_REGISTRATION.register(agg_ops.VarOp) +def _( + op: agg_ops.VarOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + expr = column.expr + if column.dtype == dtypes.BOOL_DTYPE: + expr = sge.Cast(this=expr, to="INT64") + + expr = sge.func("VAR_SAMP", expr) + return apply_window_if_present(expr, window) diff --git a/tests/system/small/engines/test_aggregation.py b/tests/system/small/engines/test_aggregation.py index 3e6d4843de..4ed826d2ae 100644 --- a/tests/system/small/engines/test_aggregation.py +++ b/tests/system/small/engines/test_aggregation.py @@ -111,7 +111,7 @@ def test_engines_unary_aggregates( assert_equivalence_execution(node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) @pytest.mark.parametrize( "op", [agg_ops.std_op, agg_ops.var_op, agg_ops.PopVarOp()], diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/out.sql new file mode 100644 index 0000000000..de422382d1 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + VAR_POP(`bfcol_1`) AS `bfcol_4`, + VAR_POP(CAST(`bfcol_0` AS INT64)) AS `bfcol_5` + FROM `bfcte_0` +) +SELECT + `bfcol_4` AS `int64_col`, + `bfcol_5` AS `bool_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/window_out.sql new file mode 100644 index 0000000000..fa04dad64e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN `bfcol_0` IS NULL THEN NULL ELSE VAR_POP(`bfcol_0`) OVER () END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/out.sql new file mode 100644 index 0000000000..9bfa6288c3 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/out.sql @@ -0,0 +1,27 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `duration_col` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_1` AS `bfcol_6`, + `bfcol_0` AS `bfcol_7`, + `bfcol_2` AS `bfcol_8` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + STDDEV(`bfcol_6`) AS `bfcol_12`, + STDDEV(CAST(`bfcol_7` AS INT64)) AS `bfcol_13`, + CAST(FLOOR(STDDEV(`bfcol_8`)) AS INT64) AS `bfcol_14`, + CAST(FLOOR(STDDEV(`bfcol_6`)) AS INT64) AS `bfcol_15` + FROM `bfcte_1` +) +SELECT + `bfcol_12` AS `int64_col`, + `bfcol_13` AS `bool_col`, + `bfcol_14` AS `duration_col`, + `bfcol_15` AS `int64_col_w_floor` +FROM `bfcte_2` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/window_out.sql new file mode 100644 index 0000000000..e4e4ff0684 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN `bfcol_0` IS NULL THEN NULL ELSE STDDEV(`bfcol_0`) OVER () END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/out.sql new file mode 100644 index 0000000000..59ccd59e8f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + VARIANCE(`bfcol_1`) AS `bfcol_4`, + VARIANCE(CAST(`bfcol_0` AS INT64)) AS `bfcol_5` + FROM `bfcte_0` +) +SELECT + `bfcol_4` AS `int64_col`, + `bfcol_5` AS `bool_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/window_out.sql new file mode 100644 index 0000000000..a65104215b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/window_out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN `bfcol_0` IS NULL THEN NULL ELSE VARIANCE(`bfcol_0`) OVER () END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index 3d7e4287ac..478368393a 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -370,6 +370,28 @@ def test_min(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql_window_partition, "window_partition_out.sql") +def test_pop_var(scalar_types_df: bpd.DataFrame, snapshot): + col_names = ["int64_col", "bool_col"] + bf_df = scalar_types_df[col_names] + + agg_ops_map = { + "int64_col": agg_ops.PopVarOp().as_expr("int64_col"), + "bool_col": agg_ops.PopVarOp().as_expr("bool_col"), + } + sql = _apply_unary_agg_ops( + bf_df, list(agg_ops_map.values()), list(agg_ops_map.keys()) + ) + snapshot.assert_match(sql, "out.sql") + + # Window tests + col_name = "int64_col" + bf_df_int = scalar_types_df[[col_name]] + agg_expr = agg_ops.PopVarOp().as_expr(col_name) + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df_int, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") + + def test_quantile(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] @@ -428,6 +450,40 @@ def test_shift(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(noop_sql, "noop.sql") +def test_std(scalar_types_df: bpd.DataFrame, snapshot): + col_names = ["int64_col", "bool_col", "duration_col"] + bf_df = scalar_types_df[col_names] + bf_df["duration_col"] = bpd.to_timedelta(bf_df["duration_col"], unit="us") + + # The `to_timedelta` creates a new mapping for the column id. + col_names.insert(0, "rowindex") + name2id = { + col_name: col_id + for col_name, col_id in zip(col_names, bf_df._block.expr.column_ids) + } + + agg_ops_map = { + "int64_col": agg_ops.StdOp().as_expr(name2id["int64_col"]), + "bool_col": agg_ops.StdOp().as_expr(name2id["bool_col"]), + "duration_col": agg_ops.StdOp().as_expr(name2id["duration_col"]), + "int64_col_w_floor": agg_ops.StdOp(should_floor_result=True).as_expr( + name2id["int64_col"] + ), + } + sql = _apply_unary_agg_ops( + bf_df, list(agg_ops_map.values()), list(agg_ops_map.keys()) + ) + snapshot.assert_match(sql, "out.sql") + + # Window tests + col_name = "int64_col" + bf_df_int = scalar_types_df[[col_name]] + agg_expr = agg_ops.StdOp().as_expr(col_name) + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df_int, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") + + def test_sum(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["int64_col", "bool_col"]] agg_ops_map = { @@ -468,3 +524,25 @@ def test_time_series_diff(scalar_types_df: bpd.DataFrame, snapshot): ) sql = _apply_unary_window_op(bf_df, op, window, "diff_time") snapshot.assert_match(sql, "out.sql") + + +def test_var(scalar_types_df: bpd.DataFrame, snapshot): + col_names = ["int64_col", "bool_col"] + bf_df = scalar_types_df[col_names] + + agg_ops_map = { + "int64_col": agg_ops.VarOp().as_expr("int64_col"), + "bool_col": agg_ops.VarOp().as_expr("bool_col"), + } + sql = _apply_unary_agg_ops( + bf_df, list(agg_ops_map.values()), list(agg_ops_map.keys()) + ) + snapshot.assert_match(sql, "out.sql") + + # Window tests + col_name = "int64_col" + bf_df_int = scalar_types_df[[col_name]] + agg_expr = agg_ops.VarOp().as_expr(col_name) + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df_int, agg_expr, window, "agg_int64") + snapshot.assert_match(sql_window, "window_out.sql") From c9ca02c5194c8b8e9b940eddd2224efd2ff0d5d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Tue, 4 Nov 2025 13:03:58 -0600 Subject: [PATCH 205/313] feat: add DataFrame.resample and Series.resample (#2213) * feat: add DataFrame.resample and Series.resample * raise for unsupported values * add docstrings * fix dataframe tests --- bigframes/core/blocks.py | 25 ++++++ bigframes/dataframe.py | 62 ++------------ bigframes/series.py | 39 +-------- tests/system/small/test_dataframe.py | 58 ++++++++----- tests/system/small/test_series.py | 4 +- tests/system/small/test_unordered.py | 12 ++- tests/unit/test_dataframe.py | 62 ++++++++++++++ tests/unit/test_series_polars.py | 4 +- .../bigframes_vendored/pandas/core/frame.py | 81 +++++++++++++++++++ .../bigframes_vendored/pandas/core/series.py | 64 +++++++++++++++ 10 files changed, 288 insertions(+), 123 deletions(-) diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index 61aaab1120..e968172c76 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -1996,6 +1996,31 @@ def _generate_resample_label( Literal["epoch", "start", "start_day", "end", "end_day"], ] = "start_day", ) -> Block: + if not isinstance(rule, str): + raise NotImplementedError( + f"Only offset strings are currently supported for rule, but got {repr(rule)}. {constants.FEEDBACK_LINK}" + ) + + if rule in ("ME", "YE", "QE", "BME", "BA", "BQE", "W"): + raise NotImplementedError( + f"Offset strings 'ME', 'YE', 'QE', 'BME', 'BA', 'BQE', 'W' are not currently supported for rule, but got {repr(rule)}. {constants.FEEDBACK_LINK}" + ) + + if closed == "right": + raise NotImplementedError( + f"Only closed='left' is currently supported. {constants.FEEDBACK_LINK}", + ) + + if label == "right": + raise NotImplementedError( + f"Only label='left' is currently supported. {constants.FEEDBACK_LINK}", + ) + + if origin not in ("epoch", "start", "start_day"): + raise NotImplementedError( + f"Only origin='epoch', 'start', 'start_day' are currently supported, but got {repr(origin)}. {constants.FEEDBACK_LINK}" + ) + # Validate and resolve the index or column to use for grouping if on is None: if len(self.index_columns) == 0: diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index df8c87416f..7471cf587b 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -4182,10 +4182,12 @@ def _split( return [DataFrame(block) for block in blocks] @validations.requires_ordering() - def _resample( + def resample( self, rule: str, *, + closed: Optional[Literal["right", "left"]] = None, + label: Optional[Literal["right", "left"]] = None, on: blocks.Label = None, level: Optional[LevelsType] = None, origin: Union[ @@ -4195,64 +4197,10 @@ def _resample( Literal["epoch", "start", "start_day", "end", "end_day"], ] = "start_day", ) -> bigframes.core.groupby.DataFrameGroupBy: - """Internal function to support resample. Resample time-series data. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> data = { - ... "timestamp_col": pd.date_range( - ... start="2021-01-01 13:00:00", periods=30, freq="1s" - ... ), - ... "int64_col": range(30), - ... "int64_too": range(10, 40), - ... } - - Resample on a DataFrame with index: - - >>> df = bpd.DataFrame(data).set_index("timestamp_col") - >>> df._resample(rule="7s").min() - int64_col int64_too - 2021-01-01 12:59:55 0 10 - 2021-01-01 13:00:02 2 12 - 2021-01-01 13:00:09 9 19 - 2021-01-01 13:00:16 16 26 - 2021-01-01 13:00:23 23 33 - - [5 rows x 2 columns] - - Resample with column and origin set to 'start': - - >>> df = bpd.DataFrame(data) - >>> df._resample(rule="7s", on = "timestamp_col", origin="start").min() - int64_col int64_too - 2021-01-01 13:00:00 0 10 - 2021-01-01 13:00:07 7 17 - 2021-01-01 13:00:14 14 24 - 2021-01-01 13:00:21 21 31 - 2021-01-01 13:00:28 28 38 - - [5 rows x 2 columns] - - Args: - rule (str): - The offset string representing target conversion. - on (str, default None): - For a DataFrame, column to use instead of index for resampling. Column - must be datetime-like. - level (str or int, default None): - For a MultiIndex, level (name or number) to use for resampling. - level must be datetime-like. - origin(str, default 'start_day'): - The timestamp on which to adjust the grouping. Must be one of the following: - 'epoch': origin is 1970-01-01 - 'start': origin is the first value of the timeseries - 'start_day': origin is the first day at midnight of the timeseries - Returns: - DataFrameGroupBy: DataFrameGroupBy object. - """ block = self._block._generate_resample_label( rule=rule, + closed=closed, + label=label, on=on, level=level, origin=origin, diff --git a/bigframes/series.py b/bigframes/series.py index ef0da32dfc..c11cc48394 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -2505,7 +2505,7 @@ def explode(self, *, ignore_index: Optional[bool] = False) -> Series: ) @validations.requires_ordering() - def _resample( + def resample( self, rule: str, *, @@ -2519,43 +2519,6 @@ def _resample( Literal["epoch", "start", "start_day", "end", "end_day"], ] = "start_day", ) -> bigframes.core.groupby.SeriesGroupBy: - """Internal function to support resample. Resample time-series data. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> data = { - ... "timestamp_col": pd.date_range( - ... start="2021-01-01 13:00:00", periods=30, freq="1s" - ... ), - ... "int64_col": range(30), - ... } - >>> s = bpd.DataFrame(data).set_index("timestamp_col") - >>> s._resample(rule="7s", origin="epoch").min() - int64_col - 2021-01-01 12:59:56 0 - 2021-01-01 13:00:03 3 - 2021-01-01 13:00:10 10 - 2021-01-01 13:00:17 17 - 2021-01-01 13:00:24 24 - - [5 rows x 1 columns] - - - Args: - rule (str): - The offset string representing target conversion. - level (str or int, default None): - For a MultiIndex, level (name or number) to use for resampling. - level must be datetime-like. - origin(str, default 'start_day'): - The timestamp on which to adjust the grouping. Must be one of the following: - 'epoch': origin is 1970-01-01 - 'start': origin is the first value of the timeseries - 'start_day': origin is the first day at midnight of the timeseries - Returns: - SeriesGroupBy: SeriesGroupBy object. - """ block = self._block._generate_resample_label( rule=rule, closed=closed, diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 79f8efd00f..475f98407b 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -5915,21 +5915,15 @@ def test_dataframe_explode_xfail(col_names): pytest.param("datetime_col", "5M", "epoch"), pytest.param("datetime_col", "3Q", "start_day"), pytest.param("datetime_col", "3YE", "start"), - pytest.param( - "int64_col", "100D", "start", marks=pytest.mark.xfail(raises=TypeError) - ), - pytest.param( - "datetime_col", "100D", "end", marks=pytest.mark.xfail(raises=ValueError) - ), ], ) -def test__resample_with_column( +def test_resample_with_column( scalars_df_index, scalars_pandas_df_index, on, rule, origin ): # TODO: supply a reason why this isn't compatible with pandas 1.x pytest.importorskip("pandas", minversion="2.0.0") bf_result = ( - scalars_df_index._resample(rule=rule, on=on, origin=origin)[ + scalars_df_index.resample(rule=rule, on=on, origin=origin)[ ["int64_col", "int64_too"] ] .max() @@ -5943,30 +5937,54 @@ def test__resample_with_column( ) +@pytest.mark.parametrize("index_col", ["timestamp_col", "datetime_col"]) +@pytest.mark.parametrize( + ("index_append", "level"), + [(True, 1), (False, None), (False, 0)], +) @pytest.mark.parametrize( - ("append", "level", "col", "rule"), + "rule", [ - pytest.param(False, None, "timestamp_col", "100d"), - pytest.param(True, 1, "timestamp_col", "1200h"), - pytest.param(False, None, "datetime_col", "100d"), + # TODO(tswast): support timedeltas and dataoffsets. + # TODO(tswast): support bins that default to "right". + "100d", + "1200h", ], ) -def test__resample_with_index( - scalars_df_index, scalars_pandas_df_index, append, level, col, rule +# TODO(tswast): support "right" +@pytest.mark.parametrize("closed", ["left", None]) +# TODO(tswast): support "right" +@pytest.mark.parametrize("label", ["left", None]) +@pytest.mark.parametrize( + "origin", + ["epoch", "start", "start_day"], # TODO(tswast): support end, end_day. +) +def test_resample_with_index( + scalars_df_index, + scalars_pandas_df_index, + index_append, + level, + index_col, + rule, + closed, + origin, + label, ): # TODO: supply a reason why this isn't compatible with pandas 1.x pytest.importorskip("pandas", minversion="2.0.0") - scalars_df_index = scalars_df_index.set_index(col, append=append) - scalars_pandas_df_index = scalars_pandas_df_index.set_index(col, append=append) + scalars_df_index = scalars_df_index.set_index(index_col, append=index_append) + scalars_pandas_df_index = scalars_pandas_df_index.set_index( + index_col, append=index_append + ) bf_result = ( scalars_df_index[["int64_col", "int64_too"]] - ._resample(rule=rule, level=level) + .resample(rule=rule, level=level, closed=closed, origin=origin, label=label) .min() .to_pandas() ) pd_result = ( scalars_pandas_df_index[["int64_col", "int64_too"]] - .resample(rule=rule, level=level) + .resample(rule=rule, level=level, closed=closed, origin=origin, label=label) .min() ) assert_pandas_df_equal(bf_result, pd_result) @@ -6010,7 +6028,7 @@ def test__resample_with_index( ), ], ) -def test__resample_start_time(rule, origin, data): +def test_resample_start_time(rule, origin, data): # TODO: supply a reason why this isn't compatible with pandas 1.x pytest.importorskip("pandas", minversion="2.0.0") col = "timestamp_col" @@ -6018,7 +6036,7 @@ def test__resample_start_time(rule, origin, data): scalars_pandas_df_index = pd.DataFrame(data).set_index(col) scalars_pandas_df_index.index.name = None - bf_result = scalars_df_index._resample(rule=rule, origin=origin).min().to_pandas() + bf_result = scalars_df_index.resample(rule=rule, origin=origin).min().to_pandas() pd_result = scalars_pandas_df_index.resample(rule=rule, origin=origin).min() diff --git a/tests/system/small/test_series.py b/tests/system/small/test_series.py index 5ace3f54d8..4df257423f 100644 --- a/tests/system/small/test_series.py +++ b/tests/system/small/test_series.py @@ -4856,14 +4856,14 @@ def test_series_explode_null(data): pytest.param(True, "timestamp_col", "timestamp_col", "1YE"), ], ) -def test__resample(scalars_df_index, scalars_pandas_df_index, append, level, col, rule): +def test_resample(scalars_df_index, scalars_pandas_df_index, append, level, col, rule): # TODO: supply a reason why this isn't compatible with pandas 1.x pytest.importorskip("pandas", minversion="2.0.0") scalars_df_index = scalars_df_index.set_index(col, append=append)["int64_col"] scalars_pandas_df_index = scalars_pandas_df_index.set_index(col, append=append)[ "int64_col" ] - bf_result = scalars_df_index._resample(rule=rule, level=level).min().to_pandas() + bf_result = scalars_df_index.resample(rule=rule, level=level).min().to_pandas() pd_result = scalars_pandas_df_index.resample(rule=rule, level=level).min() pd.testing.assert_series_equal(bf_result, pd_result) diff --git a/tests/system/small/test_unordered.py b/tests/system/small/test_unordered.py index 9cfa54146a..07fdb215df 100644 --- a/tests/system/small/test_unordered.py +++ b/tests/system/small/test_unordered.py @@ -248,7 +248,7 @@ def test_unordered_mode_no_ambiguity_warning(unordered_session): ), ], ) -def test__resample_with_index(unordered_session, rule, origin, data): +def test_resample_with_index(unordered_session, rule, origin, data): # TODO: supply a reason why this isn't compatible with pandas 1.x pytest.importorskip("pandas", minversion="2.0.0") col = "timestamp_col" @@ -256,12 +256,16 @@ def test__resample_with_index(unordered_session, rule, origin, data): scalars_pandas_df_index = pd.DataFrame(data).set_index(col) scalars_pandas_df_index.index.name = None - bf_result = scalars_df_index._resample(rule=rule, origin=origin).min().to_pandas() - + bf_result = scalars_df_index.resample(rule=rule, origin=origin).min() pd_result = scalars_pandas_df_index.resample(rule=rule, origin=origin).min() + assert isinstance(bf_result.index, bpd.DatetimeIndex) + assert isinstance(pd_result.index, pd.DatetimeIndex) pd.testing.assert_frame_equal( - bf_result, pd_result, check_dtype=False, check_index_type=False + bf_result.to_pandas(), + pd_result, + check_index_type=False, + check_dtype=False, ) diff --git a/tests/unit/test_dataframe.py b/tests/unit/test_dataframe.py index 2326f2595b..015dbd030e 100644 --- a/tests/unit/test_dataframe.py +++ b/tests/unit/test_dataframe.py @@ -42,6 +42,68 @@ def test_dataframe_repr_with_uninitialized_object(): assert "DataFrame" in got +@pytest.mark.parametrize( + "rule", + [ + pd.DateOffset(weeks=1), + pd.Timedelta(hours=8), + # According to + # https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.resample.html + # these all default to "right" for closed and label, which isn't yet supported. + "ME", + "YE", + "QE", + "BME", + "BA", + "BQE", + "W", + ], +) +def test_dataframe_rule_not_implememented( + monkeypatch: pytest.MonkeyPatch, + rule, +): + dataframe = mocks.create_dataframe(monkeypatch) + + with pytest.raises(NotImplementedError, match="rule"): + dataframe.resample(rule=rule) + + +def test_dataframe_closed_not_implememented( + monkeypatch: pytest.MonkeyPatch, +): + dataframe = mocks.create_dataframe(monkeypatch) + + with pytest.raises(NotImplementedError, match="Only closed='left'"): + dataframe.resample(rule="1d", closed="right") + + +def test_dataframe_label_not_implememented( + monkeypatch: pytest.MonkeyPatch, +): + dataframe = mocks.create_dataframe(monkeypatch) + + with pytest.raises(NotImplementedError, match="Only label='left'"): + dataframe.resample(rule="1d", label="right") + + +@pytest.mark.parametrize( + "origin", + [ + "end", + "end_day", + ], +) +def test_dataframe_origin_not_implememented( + monkeypatch: pytest.MonkeyPatch, + origin, +): + dataframe = mocks.create_dataframe(monkeypatch) + + with pytest.raises(NotImplementedError, match="origin"): + dataframe.resample(rule="1d", origin=origin) + + def test_dataframe_setattr_with_uninitialized_object(): """Ensures DataFrame can be subclassed without trying to set attributes as columns.""" # Avoid calling __init__ since it might be called later in a subclass. diff --git a/tests/unit/test_series_polars.py b/tests/unit/test_series_polars.py index 55bc048bcd..6f729b0df0 100644 --- a/tests/unit/test_series_polars.py +++ b/tests/unit/test_series_polars.py @@ -5006,14 +5006,14 @@ def test_series_explode_null(data): pytest.param(True, "timestamp_col", "timestamp_col", "1YE"), ], ) -def test__resample(scalars_df_index, scalars_pandas_df_index, append, level, col, rule): +def test_resample(scalars_df_index, scalars_pandas_df_index, append, level, col, rule): # TODO: supply a reason why this isn't compatible with pandas 1.x pytest.importorskip("pandas", minversion="2.0.0") scalars_df_index = scalars_df_index.set_index(col, append=append)["int64_col"] scalars_pandas_df_index = scalars_pandas_df_index.set_index(col, append=append)[ "int64_col" ] - bf_result = scalars_df_index._resample(rule=rule, level=level).min().to_pandas() + bf_result = scalars_df_index.resample(rule=rule, level=level).min().to_pandas() pd_result = scalars_pandas_df_index.resample(rule=rule, level=level).min() pd.testing.assert_series_equal(bf_result, pd_result) diff --git a/third_party/bigframes_vendored/pandas/core/frame.py b/third_party/bigframes_vendored/pandas/core/frame.py index b434b51fb3..1e90e2e210 100644 --- a/third_party/bigframes_vendored/pandas/core/frame.py +++ b/third_party/bigframes_vendored/pandas/core/frame.py @@ -11,6 +11,7 @@ """ from __future__ import annotations +import datetime from typing import Hashable, Iterable, Literal, Optional, Sequence, Union from bigframes_vendored import constants @@ -4734,6 +4735,86 @@ def merge( """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def resample( + self, + rule: str, + *, + closed: Optional[Literal["right", "left"]] = None, + label: Optional[Literal["right", "left"]] = None, + on=None, + level=None, + origin: Union[ + Union[pd.Timestamp, datetime.datetime, np.datetime64, int, float, str], + Literal["epoch", "start", "start_day", "end", "end_day"], + ] = "start_day", + ): + """Resample time-series data. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> data = { + ... "timestamp_col": pd.date_range( + ... start="2021-01-01 13:00:00", periods=30, freq="1s" + ... ), + ... "int64_col": range(30), + ... "int64_too": range(10, 40), + ... } + + Resample on a DataFrame with index: + + >>> df = bpd.DataFrame(data).set_index("timestamp_col") + >>> df.resample(rule="7s").min() + int64_col int64_too + 2021-01-01 12:59:55 0 10 + 2021-01-01 13:00:02 2 12 + 2021-01-01 13:00:09 9 19 + 2021-01-01 13:00:16 16 26 + 2021-01-01 13:00:23 23 33 + + [5 rows x 2 columns] + + Resample with column and origin set to 'start': + + >>> df = bpd.DataFrame(data) + >>> df.resample(rule="7s", on = "timestamp_col", origin="start").min() + int64_col int64_too + 2021-01-01 13:00:00 0 10 + 2021-01-01 13:00:07 7 17 + 2021-01-01 13:00:14 14 24 + 2021-01-01 13:00:21 21 31 + 2021-01-01 13:00:28 28 38 + + [5 rows x 2 columns] + + Args: + rule (str): + The offset string representing target conversion. + Offsets 'ME', 'YE', 'QE', 'BME', 'BA', 'BQE', and 'W' are *not* + supported. + closed (Literal['left'] | None): + Which side of bin interval is closed. The default is 'left' for + all supported frequency offsets. + label (Literal['right'] | Literal['left'] | None): + Which bin edge label to label bucket with. The default is 'left' + for all supported frequency offsets. + on (str, default None): + For a DataFrame, column to use instead of index for resampling. Column + must be datetime-like. + level (str or int, default None): + For a MultiIndex, level (name or number) to use for resampling. + level must be datetime-like. + origin(str, default 'start_day'): + The timestamp on which to adjust the grouping. Must be one of the following: + 'epoch': origin is 1970-01-01 + 'start': origin is the first value of the timeseries + 'start_day': origin is the first day at midnight of the timeseries + Origin values 'end' and 'end_day' are *not* supported. + Returns: + DataFrameGroupBy: DataFrameGroupBy object. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def round(self, decimals): """ Round a DataFrame to a variable number of decimal places. diff --git a/third_party/bigframes_vendored/pandas/core/series.py b/third_party/bigframes_vendored/pandas/core/series.py index 8de1c10f93..2c0f493d81 100644 --- a/third_party/bigframes_vendored/pandas/core/series.py +++ b/third_party/bigframes_vendored/pandas/core/series.py @@ -3,6 +3,7 @@ """ from __future__ import annotations +import datetime from typing import ( Hashable, IO, @@ -19,6 +20,7 @@ from bigframes_vendored.pandas.core.generic import NDFrame import numpy import numpy as np +import pandas as pd from pandas._typing import Axis, FilePath, NaPosition, WriteBuffer from pandas.api import extensions as pd_ext @@ -2502,6 +2504,68 @@ def replace( """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def resample( + self, + rule: str, + *, + closed: Optional[Literal["right", "left"]] = None, + label: Optional[Literal["right", "left"]] = None, + level=None, + origin: Union[ + Union[pd.Timestamp, datetime.datetime, numpy.datetime64, int, float, str], + Literal["epoch", "start", "start_day", "end", "end_day"], + ] = "start_day", + ): + """Resample time-series data. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> data = { + ... "timestamp_col": pd.date_range( + ... start="2021-01-01 13:00:00", periods=30, freq="1s" + ... ), + ... "int64_col": range(30), + ... } + >>> s = bpd.DataFrame(data).set_index("timestamp_col") + >>> s.resample(rule="7s", origin="epoch").min() + int64_col + 2021-01-01 12:59:56 0 + 2021-01-01 13:00:03 3 + 2021-01-01 13:00:10 10 + 2021-01-01 13:00:17 17 + 2021-01-01 13:00:24 24 + + [5 rows x 1 columns] + + Args: + rule (str): + The offset string representing target conversion. + Offsets 'ME', 'YE', 'QE', 'BME', 'BA', 'BQE', and 'W' are *not* + supported. + closed (Literal['left'] | None): + Which side of bin interval is closed. The default is 'left' for + all supported frequency offsets. + label (Literal['right'] | Literal['left'] | None): + Which bin edge label to label bucket with. The default is 'left' + for all supported frequency offsets. + on (str, default None): + For a DataFrame, column to use instead of index for resampling. Column + must be datetime-like. + level (str or int, default None): + For a MultiIndex, level (name or number) to use for resampling. + level must be datetime-like. + origin(str, default 'start_day'): + The timestamp on which to adjust the grouping. Must be one of the following: + 'epoch': origin is 1970-01-01 + 'start': origin is the first value of the timeseries + 'start_day': origin is the first day at midnight of the timeseries + Origin values 'end' and 'end_day' are *not* supported. + Returns: + SeriesGroupBy: SeriesGroupBy object. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def dropna(self, *, axis=0, inplace: bool = False, how=None) -> Series: """ Return a new Series with missing values removed. From 196f6df05e052a134f8b32a20d9fc2962f90361a Mon Sep 17 00:00:00 2001 From: jialuoo Date: Tue, 4 Nov 2025 11:47:10 -0800 Subject: [PATCH 206/313] chore: Migrate coalesce_op operator to SQLGlot (#2211) * chore: Migrate coalesce_op operator to SQLGlot Migrated the coalesce_op operator from Ibis to SQLGlot. * fix test * fix lint * enable engine test --- .../compile/sqlglot/expressions/generic_ops.py | 8 ++++++++ tests/system/small/engines/test_generic_ops.py | 2 +- .../test_generic_ops/test_coalesce/out.sql | 16 ++++++++++++++++ .../sqlglot/expressions/test_generic_ops.py | 14 ++++++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_coalesce/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/generic_ops.py b/bigframes/core/compile/sqlglot/expressions/generic_ops.py index 07505855e1..2bd19e1967 100644 --- a/bigframes/core/compile/sqlglot/expressions/generic_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/generic_ops.py @@ -24,6 +24,7 @@ import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op register_nary_op = scalar_compiler.scalar_op_compiler.register_nary_op register_ternary_op = scalar_compiler.scalar_op_compiler.register_ternary_op @@ -159,6 +160,13 @@ def _(*cases_and_outputs: TypedExpr) -> sge.Expression: ) +@register_binary_op(ops.coalesce_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + if left.expr == right.expr: + return left.expr + return sge.Coalesce(this=left.expr, expressions=[right.expr]) + + @register_nary_op(ops.RowKey) def _(*values: TypedExpr) -> sge.Expression: # All inputs into hash must be non-null or resulting hash will be null diff --git a/tests/system/small/engines/test_generic_ops.py b/tests/system/small/engines/test_generic_ops.py index 5641f91a9a..01d4dad849 100644 --- a/tests/system/small/engines/test_generic_ops.py +++ b/tests/system/small/engines/test_generic_ops.py @@ -329,7 +329,7 @@ def test_engines_where_op(scalars_array_value: array_value.ArrayValue, engine): assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_coalesce_op(scalars_array_value: array_value.ArrayValue, engine): arr, _ = scalars_array_value.compute_values( [ diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_coalesce/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_coalesce/out.sql new file mode 100644 index 0000000000..5b11a1ddeb --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_coalesce/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `int64_too` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_0` AS `bfcol_2`, + COALESCE(`bfcol_1`, `bfcol_0`) AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `int64_col`, + `bfcol_3` AS `int64_too` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py index aa40c21fd9..693f8dc34c 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py @@ -209,6 +209,20 @@ def test_case_when_op(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_coalesce(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "int64_too"]] + + sql = utils._apply_ops_to_sql( + bf_df, + [ + ops.coalesce_op.as_expr("int64_col", "int64_col"), + ops.coalesce_op.as_expr("int64_too", "int64_col"), + ], + ["int64_col", "int64_too"], + ) + snapshot.assert_match(sql, "out.sql") + + def test_clip(scalar_types_df: bpd.DataFrame, snapshot): op_expr = ops.clip_op.as_expr("rowindex", "int64_col", "int64_too") From 5663d2a18064589596558af109e915f87d426eb0 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Tue, 4 Nov 2025 14:34:55 -0800 Subject: [PATCH 207/313] docs: update notebook for JSON subfields support in to_pandas_batches() (#2138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * change to ai.generate * perf: Default to interactive display for SQL in anywidget mode Previously, SQL queries in anywidget mode would fall back to deferred execution, showing a dry run instead of an interactive table. This change modifies the display logic to directly use the anywidget interactive display for SQL queries, providing a more consistent and responsive user experience. A test case has been added to verify this behavior. * fix: resolve double printing issue in anywidget mode * feat: Add test case for STRUCT column in anywidget Adds a test case to verify that a DataFrame with a STRUCT column is correctly displayed in anywidget mode. This test confirms that displaying a STRUCT column does not raise an exception that would trigger the fallback to the deferred representation. It mocks `IPython.display.display` to capture the `TableWidget` instance and asserts that the rendered HTML contains the expected string representation of the STRUCT data. * fix presubmit * Revert accidental changes to test_function.py * revert accidental change to blob.py * change return type * add todo and revert change * Revert "add todo and revert change" This reverts commit 153e1d203c273d6755623b3db30bd2256a240cc1. * Add todo * Fix: Handle JSON dtype in anywidget display This commit fixes an AttributeError that occurred when displaying a DataFrame with a JSON column in anywidget mode. The dtype check was incorrect and has been updated. Additionally, the SQL compilation for casting JSON to string has been corrected to use TO_JSON_STRING. * revert a change * revert a change * Revert: Restore bigframes/dataframe.py to state from 42da847 * remove anywidget from early return, allow execution proceeds to _repr_html_() * remove unnecessary changes * remove redundant code change * code style change * tescase update * revert a change * final touch of notebook * fix presumbit error * remove invlaid test with anywidget bug fix * fix presubmit * fix polar complier * Revert an unnecessary change * apply the workaround to i/O layer * Revert scalar_op_registry.py chnage * remove unnecessary import * Remove duplicate conversation * revert changes to test_dataframe.py * notebook update * call API on local data for complier.py * add more testcase * modfiy polars import * fix failed tests * chore: Migrate minimum_op operator to SQLGlot (#2205) * chore: Migrate round_op operator to SQLGlot (#2204) This commit migrates the `round_op` operator from the Ibis compiler to the SQLGlot compiler. * fix: Improve error handling in blob operations (#2194) * add error handling for audio_transcribe * add error handling for pdf functions * add eror handling for image functions * final touch * restore rename * update notebook to better reflect our new code change * return None on error with verbose=False for image functions * define typing module in udf * only use local variable * Refactor code * refactor: update geo "spec" and split geo ops in ibis compiler (#2208) * feat: support INFORMATION_SCHEMA views in `read_gbq` (#1895) * feat: support INFORMATION_SCHEMA tables in read_gbq * avoid storage semi executor * use faster tables for peek tests * more tests * fix mypy * Update bigframes/session/_io/bigquery/read_gbq_table.py * immediately query for information_schema tables * Fix mypy errors and temporarily update python version * snapshot * snapshot again * Revert: Unwanted code changes * Revert "Revert: Unwanted code changes" This reverts commit db5d8ea04ee3e8a6382ac546764aff0f6880f66b. * revert 1 files to match main branch * Correctly display DataFrames with JSON columns in anywidget * add mis-deleted comment back * revert unnecessary change * move helper function to dtypes.py * revert unnecessary testcase change * Improve JSON type handling for to_gbq and to_pandas_batches * Remove unnecessary comment * Revert bigframes/dtypes.py and mypy.ini to main branch version --------- Co-authored-by: jialuoo Co-authored-by: Tim Sweña (Swast) --- notebooks/dataframes/anywidget_mode.ipynb | 146 ++++++++++++++++++++-- 1 file changed, 133 insertions(+), 13 deletions(-) diff --git a/notebooks/dataframes/anywidget_mode.ipynb b/notebooks/dataframes/anywidget_mode.ipynb index c2af915721..3b99bbeae7 100644 --- a/notebooks/dataframes/anywidget_mode.ipynb +++ b/notebooks/dataframes/anywidget_mode.ipynb @@ -35,7 +35,16 @@ "execution_count": 2, "id": "ca22f059", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/venv/lib/python3.10/site-packages/google/api_core/_python_version_support.py:266: FutureWarning: You are using a Python version (3.10.15) which Google will stop supporting in new releases of google.api_core once it reaches its end of life (2026-10-04). Please upgrade to the latest Python version, or at least Python 3.11, to continue receiving updates for google.api_core past that date.\n", + " warnings.warn(message, FutureWarning)\n" + ] + } + ], "source": [ "import bigframes.pandas as bpd" ] @@ -142,9 +151,9 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "aafd4f912b5f42e0896aa5f0c2c62620", + "model_id": "47795eaa10f149aeb99574232c0936eb", "version_major": 2, - "version_minor": 0 + "version_minor": 1 }, "text/plain": [ "TableWidget(page_size=10, row_count=5552452, table_html='" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/bigframes/dtypes.py:969: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "d1794b42579542a8980bd158e521bd3e", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "TableWidget(page_size=10, row_count=5, table_html='
(\\\"Extract the values.\\\", OBJ.GET_ACCESS_URL(OBJ.FETCH_METADATA(OBJ.MAKE_REF(gcs_path, \\\"us.conn\\\")), \\\"r\\\")),\n", + " connection_id=>\\\"bigframes-dev.us.bigframes-default-connection\\\",\n", + " output_schema=>\\\"publication_date string, class_international string, application_number string, filing_date string\\\") AS result,\n", + " *\n", + " FROM `bigquery-public-data.labeled_patents.extracted_data`\n", + " LIMIT 5;\n", + "\"\"\")" + ] } ], "metadata": { "kernelspec": { - "display_name": "3.10.18", + "display_name": "venv", "language": "python", "name": "python3" }, @@ -341,7 +461,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.18" + "version": "3.10.15" } }, "nbformat": 4, From 10ec52f30a0a9c61b9eda9cf4f9bd6aa0cd95db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Wed, 5 Nov 2025 15:57:18 -0600 Subject: [PATCH 208/313] feat: add bigframes.bigquery.st_regionstats to join raster data from Earth Engine (#2228) * feat: add bigframes.bigquery.st_regionstats to join raster data from Earth Engine * upgrade sqlglot * address samples lint error * avoid sqlglot rtrim/ltrim bug --- bigframes/bigquery/__init__.py | 2 + bigframes/bigquery/_operations/geo.py | 63 ++++++++++++++- .../ibis_compiler/operations/geo_ops.py | 31 +++++++ .../compile/sqlglot/expressions/geo_ops.py | 26 ++++++ bigframes/operations/__init__.py | 8 +- bigframes/operations/geo_ops.py | 24 ++++++ samples/snippets/st_regionstats_test.py | 80 +++++++++++++++++++ setup.py | 3 +- testing/constraints-3.9.txt | 2 +- .../test_st_regionstats/out.sql | 36 +++++++++ .../out.sql | 30 +++++++ .../test_compile_geo/test_st_simplify/out.sql | 15 ++++ .../core/compile/sqlglot/test_compile_geo.py | 52 ++++++++++++ .../ibis/backends/sql/compilers/base.py | 2 +- .../sql/compilers/bigquery/__init__.py | 10 +++ .../ibis/expr/operations/geospatial.py | 22 +++++ 16 files changed, 399 insertions(+), 7 deletions(-) create mode 100644 samples/snippets/st_regionstats_test.py create mode 100644 tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_regionstats/out.sql create mode 100644 tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_regionstats_without_optional_args/out.sql create mode 100644 tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_simplify/out.sql create mode 100644 tests/unit/core/compile/sqlglot/test_compile_geo.py diff --git a/bigframes/bigquery/__init__.py b/bigframes/bigquery/__init__.py index c599a4b543..0650953fc7 100644 --- a/bigframes/bigquery/__init__.py +++ b/bigframes/bigquery/__init__.py @@ -40,6 +40,7 @@ st_intersection, st_isclosed, st_length, + st_regionstats, st_simplify, ) from bigframes.bigquery._operations.json import ( @@ -81,6 +82,7 @@ st_intersection, st_isclosed, st_length, + st_regionstats, st_simplify, # json ops json_extract, diff --git a/bigframes/bigquery/_operations/geo.py b/bigframes/bigquery/_operations/geo.py index 6b7e5d88a2..f0fda99a16 100644 --- a/bigframes/bigquery/_operations/geo.py +++ b/bigframes/bigquery/_operations/geo.py @@ -14,11 +14,13 @@ from __future__ import annotations -from typing import Union +import json +from typing import Mapping, Optional, Union import shapely # type: ignore from bigframes import operations as ops +import bigframes.dataframe import bigframes.geopandas import bigframes.series @@ -677,6 +679,65 @@ def st_length( return series +def st_regionstats( + geography: Union[bigframes.series.Series, bigframes.geopandas.GeoSeries], + raster_id: str, + band: Optional[str] = None, + include: Optional[str] = None, + options: Optional[Mapping[str, Union[str, int, float]]] = None, +) -> bigframes.series.Series: + """Returns statistics summarizing the pixel values of the raster image + referenced by raster_id that intersect with geography. + + The statistics include the count, minimum, maximum, sum, standard + deviation, mean, and area of the valid pixels of the raster band named + band_name. Google Earth Engine computes the results of the function call. + + See: https://cloud.google.com/bigquery/docs/reference/standard-sql/geography_functions#st_regionstats + + Args: + geography (bigframes.series.Series | bigframes.geopandas.GeoSeries): + A series of geography objects to intersect with the raster image. + raster_id (str): + A string that identifies a raster image. The following formats are + supported. A URI from an image table provided by Google Earth Engine + in BigQuery sharing (formerly Analytics Hub). A URI for a readable + GeoTIFF raster file. A Google Earth Engine asset path that + references public catalog data or project-owned assets with read + access. + band (Optional[str]): + A string in one of the following formats: + A single band within the raster image specified by raster_id. A + formula to compute a value from the available bands in the raster + image. The formula uses the Google Earth Engine image expression + syntax. Bands can be referenced by their name, band_name, in + expressions. If you don't specify a band, the first band of the + image is used. + include (Optional[str]): + An optional string formula that uses the Google Earth Engine image + expression syntax to compute a pixel weight. The formula should + return values from 0 to 1. Values outside this range are set to the + nearest limit, either 0 or 1. A value of 0 means that the pixel is + invalid and it's excluded from analysis. A positive value means that + a pixel is valid. Values between 0 and 1 represent proportional + weights for calculations, such as weighted means. + options (Mapping[str, Union[str, int, float]], optional): + A dictionary of options to pass to the function. See the BigQuery + documentation for a list of available options. + + Returns: + bigframes.pandas.Series: + A STRUCT Series containing the computed statistics. + """ + op = ops.GeoStRegionStatsOp( + raster_id=raster_id, + band=band, + include=include, + options=json.dumps(options) if options else None, + ) + return geography._apply_unary_op(op) + + def st_simplify( geography: "bigframes.series.Series", tolerance_meters: float, diff --git a/bigframes/core/compile/ibis_compiler/operations/geo_ops.py b/bigframes/core/compile/ibis_compiler/operations/geo_ops.py index 2f06c76768..0ca69726ff 100644 --- a/bigframes/core/compile/ibis_compiler/operations/geo_ops.py +++ b/bigframes/core/compile/ibis_compiler/operations/geo_ops.py @@ -16,8 +16,10 @@ from typing import cast +from bigframes_vendored import ibis from bigframes_vendored.ibis.expr import types as ibis_types import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes +import bigframes_vendored.ibis.expr.operations.geospatial as ibis_geo import bigframes_vendored.ibis.expr.operations.udf as ibis_udf from bigframes.core.compile.ibis_compiler import scalar_op_compiler @@ -101,6 +103,35 @@ def geo_st_isclosed_op_impl(x: ibis_types.Value): return st_isclosed(x) +@register_unary_op(ops.GeoStRegionStatsOp, pass_op=True) +def geo_st_regionstats_op_impl( + geography: ibis_types.Value, + op: ops.GeoStRegionStatsOp, +): + if op.band: + band = ibis.literal(op.band, type=ibis_dtypes.string()) + else: + band = None + + if op.include: + include = ibis.literal(op.include, type=ibis_dtypes.string()) + else: + include = None + + if op.options: + options = ibis.literal(op.options, type=ibis_dtypes.json()) + else: + options = None + + return ibis_geo.GeoRegionStats( + arg=geography, # type: ignore + raster_id=ibis.literal(op.raster_id, type=ibis_dtypes.string()), # type: ignore + band=band, # type: ignore + include=include, # type: ignore + options=options, # type: ignore + ).to_expr() + + @register_unary_op(ops.GeoStSimplifyOp, pass_op=True) def st_simplify_op_impl(x: ibis_types.Value, op: ops.GeoStSimplifyOp): x = cast(ibis_types.GeoSpatialValue, x) diff --git a/bigframes/core/compile/sqlglot/expressions/geo_ops.py b/bigframes/core/compile/sqlglot/expressions/geo_ops.py index 53a50fab47..4585a7f073 100644 --- a/bigframes/core/compile/sqlglot/expressions/geo_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/geo_ops.py @@ -74,6 +74,32 @@ def _(expr: TypedExpr, op: ops.GeoStLengthOp) -> sge.Expression: return sge.func("ST_LENGTH", expr.expr) +@register_unary_op(ops.GeoStRegionStatsOp, pass_op=True) +def _( + geography: TypedExpr, + op: ops.GeoStRegionStatsOp, +): + args = [geography.expr, sge.convert(op.raster_id)] + if op.band: + args.append(sge.Kwarg(this="band", expression=sge.convert(op.band))) + if op.include: + args.append(sge.Kwarg(this="include", expression=sge.convert(op.include))) + if op.options: + args.append( + sge.Kwarg(this="options", expression=sge.JSON(this=sge.convert(op.options))) + ) + return sge.func("ST_REGIONSTATS", *args) + + +@register_unary_op(ops.GeoStSimplifyOp, pass_op=True) +def _(expr: TypedExpr, op: ops.GeoStSimplifyOp) -> sge.Expression: + return sge.func( + "ST_SIMPLIFY", + expr.expr, + sge.convert(op.tolerance_meters), + ) + + @register_unary_op(ops.geo_x_op) def _(expr: TypedExpr) -> sge.Expression: return sge.func("SAFE.ST_X", expr.expr) diff --git a/bigframes/operations/__init__.py b/bigframes/operations/__init__.py index cb03943ada..2a0beb3fb3 100644 --- a/bigframes/operations/__init__.py +++ b/bigframes/operations/__init__.py @@ -121,6 +121,7 @@ GeoStBufferOp, GeoStDistanceOp, GeoStLengthOp, + GeoStRegionStatsOp, GeoStSimplifyOp, ) from bigframes.operations.json_ops import ( @@ -415,12 +416,13 @@ "geo_st_geogpoint_op", "geo_st_intersection_op", "geo_st_isclosed_op", - "GeoStBufferOp", - "GeoStLengthOp", - "GeoStSimplifyOp", "geo_x_op", "geo_y_op", + "GeoStBufferOp", "GeoStDistanceOp", + "GeoStLengthOp", + "GeoStRegionStatsOp", + "GeoStSimplifyOp", # AI ops "AIClassify", "AIGenerate", diff --git a/bigframes/operations/geo_ops.py b/bigframes/operations/geo_ops.py index 86e913d543..75fef1b832 100644 --- a/bigframes/operations/geo_ops.py +++ b/bigframes/operations/geo_ops.py @@ -13,6 +13,7 @@ # limitations under the License. import dataclasses +from typing import Optional from bigframes import dtypes from bigframes.operations import base_ops @@ -135,6 +136,29 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT return dtypes.FLOAT_DTYPE +@dataclasses.dataclass(frozen=True) +class GeoStRegionStatsOp(base_ops.UnaryOp): + """See: https://cloud.google.com/bigquery/docs/reference/standard-sql/geography_functions#st_regionstats""" + + name = "geo_st_regionstats" + raster_id: str + band: Optional[str] + include: Optional[str] + options: Optional[str] + + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + return dtypes.struct_type( + [ + ("min", dtypes.FLOAT_DTYPE), + ("max", dtypes.FLOAT_DTYPE), + ("sum", dtypes.FLOAT_DTYPE), + ("count", dtypes.INT_DTYPE), + ("mean", dtypes.FLOAT_DTYPE), + ("area", dtypes.FLOAT_DTYPE), + ] + ) + + @dataclasses.dataclass(frozen=True) class GeoStSimplifyOp(base_ops.UnaryOp): name = "st_simplify" diff --git a/samples/snippets/st_regionstats_test.py b/samples/snippets/st_regionstats_test.py new file mode 100644 index 0000000000..f0f4963a82 --- /dev/null +++ b/samples/snippets/st_regionstats_test.py @@ -0,0 +1,80 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Code sample for https://docs.cloud.google.com/bigquery/docs/raster-data#analytics-hub-source""" + + +def test_st_regionstats() -> None: + project_id = "bigframes-dev" + + # [START bigquery_dataframes_st_regionstats] + import datetime + from typing import cast + + import bigframes.bigquery as bbq + import bigframes.pandas as bpd + + # TODO: Set the project_id to your Google Cloud project ID. + # project_id = "your-project-id" + bpd.options.bigquery.project = project_id + + # TODO: Set the dataset_id to the ID of the dataset that contains the + # `climate` table. This is likely a linked dataset to Earth Engine. + # See: https://cloud.google.com/bigquery/docs/link-earth-engine + linked_dataset = "era5_land_daily_aggregated" + + # For the best efficiency, use partial ordering mode. + bpd.options.bigquery.ordering_mode = "partial" + + # Load the table of country boundaries. + countries = bpd.read_gbq("bigquery-public-data.overture_maps.division_area") + + # Filter to just the countries. + countries = countries[countries["subtype"] == "country"].copy() + countries["name"] = countries["names"].struct.field("primary") + countries["simplified_geometry"] = bbq.st_simplify( + countries["geometry"], + tolerance_meters=10_000, + ) + + # Get the reference to the temperature data from a linked dataset. + # Note: This sample assumes you have a linked dataset to Earth Engine. + image_href = ( + bpd.read_gbq(f"{project_id}.{linked_dataset}.climate") + .set_index("start_datetime") + .loc[[datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc)], :] + ) + raster_id = image_href["assets"].struct.field("image").struct.field("href") + raster_id = raster_id.item() + stats = bbq.st_regionstats( + countries["simplified_geometry"], + raster_id=cast(str, raster_id), + band="temperature_2m", + ) + + # Extract the mean and convert from Kelvin to Celsius. + countries["mean_temperature"] = stats.struct.field("mean") - 273.15 + + # Sort by the mean temperature to find the warmest countries. + result = countries[["name", "mean_temperature"]].sort_values( + "mean_temperature", ascending=False + ) + print(result.head(10)) + # [END bigquery_dataframes_st_regionstats] + + assert len(result) > 0 + + +if __name__ == "__main__": + test_st_regionstats() diff --git a/setup.py b/setup.py index abc760b691..fa663f66d5 100644 --- a/setup.py +++ b/setup.py @@ -54,7 +54,8 @@ "pydata-google-auth >=1.8.2", "requests >=2.27.1", "shapely >=1.8.5", - "sqlglot >=23.6.3", + # 25.20.0 introduces this fix https://github.com/TobikoData/sqlmesh/issues/3095 for rtrim/ltrim. + "sqlglot >=25.20.0", "tabulate >=0.9", "ipywidgets >=7.7.1", "humanize >=4.6.0", diff --git a/testing/constraints-3.9.txt b/testing/constraints-3.9.txt index eceec07dc4..b8dc8697d6 100644 --- a/testing/constraints-3.9.txt +++ b/testing/constraints-3.9.txt @@ -21,7 +21,7 @@ pydata-google-auth==1.8.2 requests==2.27.1 scikit-learn==1.2.2 shapely==1.8.5 -sqlglot==23.6.3 +sqlglot==25.20.0 tabulate==0.9 ipywidgets==7.7.1 humanize==4.6.0 diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_regionstats/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_regionstats/out.sql new file mode 100644 index 0000000000..63076077cf --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_regionstats/out.sql @@ -0,0 +1,36 @@ +WITH `bfcte_0` AS ( + SELECT + * + FROM UNNEST(ARRAY>[STRUCT('POINT(1 1)', 0)]) +), `bfcte_1` AS ( + SELECT + *, + ST_REGIONSTATS( + `bfcol_0`, + 'ee://some/raster/uri', + band => 'band1', + include => 'some equation', + options => JSON '{"scale": 100}' + ) AS `bfcol_2` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_2`.`min` AS `bfcol_5`, + `bfcol_2`.`max` AS `bfcol_6`, + `bfcol_2`.`sum` AS `bfcol_7`, + `bfcol_2`.`count` AS `bfcol_8`, + `bfcol_2`.`mean` AS `bfcol_9`, + `bfcol_2`.`area` AS `bfcol_10` + FROM `bfcte_1` +) +SELECT + `bfcol_5` AS `min`, + `bfcol_6` AS `max`, + `bfcol_7` AS `sum`, + `bfcol_8` AS `count`, + `bfcol_9` AS `mean`, + `bfcol_10` AS `area` +FROM `bfcte_2` +ORDER BY + `bfcol_1` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_regionstats_without_optional_args/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_regionstats_without_optional_args/out.sql new file mode 100644 index 0000000000..f794711961 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_regionstats_without_optional_args/out.sql @@ -0,0 +1,30 @@ +WITH `bfcte_0` AS ( + SELECT + * + FROM UNNEST(ARRAY>[STRUCT('POINT(1 1)', 0)]) +), `bfcte_1` AS ( + SELECT + *, + ST_REGIONSTATS(`bfcol_0`, 'ee://some/raster/uri') AS `bfcol_2` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_2`.`min` AS `bfcol_5`, + `bfcol_2`.`max` AS `bfcol_6`, + `bfcol_2`.`sum` AS `bfcol_7`, + `bfcol_2`.`count` AS `bfcol_8`, + `bfcol_2`.`mean` AS `bfcol_9`, + `bfcol_2`.`area` AS `bfcol_10` + FROM `bfcte_1` +) +SELECT + `bfcol_5` AS `min`, + `bfcol_6` AS `max`, + `bfcol_7` AS `sum`, + `bfcol_8` AS `count`, + `bfcol_9` AS `mean`, + `bfcol_10` AS `area` +FROM `bfcte_2` +ORDER BY + `bfcol_1` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_simplify/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_simplify/out.sql new file mode 100644 index 0000000000..b8dd1587a8 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_simplify/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + * + FROM UNNEST(ARRAY>[STRUCT('POINT(1 1)', 0)]) +), `bfcte_1` AS ( + SELECT + *, + ST_SIMPLIFY(`bfcol_0`, 123.125) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `0` +FROM `bfcte_1` +ORDER BY + `bfcol_1` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/test_compile_geo.py b/tests/unit/core/compile/sqlglot/test_compile_geo.py new file mode 100644 index 0000000000..50de1488e6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/test_compile_geo.py @@ -0,0 +1,52 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +import bigframes.bigquery as bbq +import bigframes.geopandas as gpd + +pytest.importorskip("pytest_snapshot") + + +def test_st_regionstats(compiler_session, snapshot): + geos = gpd.GeoSeries(["POINT(1 1)"], session=compiler_session) + result = bbq.st_regionstats( + geos, + "ee://some/raster/uri", + band="band1", + include="some equation", + options={"scale": 100}, + ) + assert "area" in result.struct.dtypes.index + snapshot.assert_match(result.struct.explode().sql, "out.sql") + + +def test_st_regionstats_without_optional_args(compiler_session, snapshot): + geos = gpd.GeoSeries(["POINT(1 1)"], session=compiler_session) + result = bbq.st_regionstats( + geos, + "ee://some/raster/uri", + ) + assert "area" in result.struct.dtypes.index + snapshot.assert_match(result.struct.explode().sql, "out.sql") + + +def test_st_simplify(compiler_session, snapshot): + geos = gpd.GeoSeries(["POINT(1 1)"], session=compiler_session) + result = bbq.st_simplify( + geos, + tolerance_meters=123.125, + ) + snapshot.assert_match(result.to_frame().sql, "out.sql") diff --git a/third_party/bigframes_vendored/ibis/backends/sql/compilers/base.py b/third_party/bigframes_vendored/ibis/backends/sql/compilers/base.py index cbc51e59d6..c01d87fb28 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/compilers/base.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/compilers/base.py @@ -811,7 +811,7 @@ def visit_DefaultLiteral(self, op, *, value, dtype): elif dtype.is_uuid(): return self.cast(str(value), dtype) elif dtype.is_json(): - return sge.ParseJSON(this=sge.convert(str(value))) + return sge.JSON(this=sge.convert(str(value))) elif dtype.is_geospatial(): wkt = value if isinstance(value, str) else value.wkt return self.f.st_geogfromtext(wkt) diff --git a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py index cf205b69d6..95d28991a9 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/compilers/bigquery/__init__.py @@ -261,6 +261,16 @@ def visit_BoundingBox(self, op, *, arg): visit_GeoXMax = visit_GeoXMin = visit_GeoYMax = visit_GeoYMin = visit_BoundingBox + def visit_GeoRegionStats(self, op, *, arg, raster_id, band, include, options): + args = [arg, raster_id] + if op.band: + args.append(sge.Kwarg(this="band", expression=band)) + if op.include: + args.append(sge.Kwarg(this="include", expression=include)) + if op.options: + args.append(sge.Kwarg(this="options", expression=options)) + return sge.func("ST_REGIONSTATS", *args) + def visit_GeoSimplify(self, op, *, arg, tolerance, preserve_collapsed): if ( not isinstance(op.preserve_collapsed, ops.Literal) diff --git a/third_party/bigframes_vendored/ibis/expr/operations/geospatial.py b/third_party/bigframes_vendored/ibis/expr/operations/geospatial.py index 0be832af78..efe038599a 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/geospatial.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/geospatial.py @@ -343,6 +343,28 @@ class GeoNRings(GeoSpatialUnOp): dtype = dt.int64 +@public +class GeoRegionStats(GeoSpatialUnOp): + """Returns results of ST_REGIONSTATS.""" + + raster_id: Value[dt.String] + band: Value[dt.String] + include: Value[dt.String] + options: Value[dt.JSON] + + dtype = dt.Struct( + fields={ + "count": dt.int64, + "min": dt.float64, + "max": dt.float64, + "stdDev": dt.float64, + "sum": dt.float64, + "mean": dt.float64, + "area": dt.float64, + } + ) + + @public class GeoSRID(GeoSpatialUnOp): """Returns the spatial reference identifier for the ST_Geometry.""" From bfcc08f1c3735c9e3ce3fbc904684618bd9277c2 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Wed, 5 Nov 2025 16:18:55 -0800 Subject: [PATCH 209/313] chore: Add cleanup step for old UDFs in anonymous dataset (#2171) * chore: Add cleanup step for old UDFs in anonymous dataset * fix * fix * fix with some test code * fix * no retry * fix retry * disable doctest * testing - increase timeout * revert timout * add warning --- bigframes/exceptions.py | 2 +- bigframes/session/anonymous_dataset.py | 44 +++++++++++++++++++ tests/system/large/test_session.py | 33 ++++++++++++++ .../bigframes_vendored/pandas/core/frame.py | 2 +- 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/bigframes/exceptions.py b/bigframes/exceptions.py index ef51f96575..1fb86d7bd6 100644 --- a/bigframes/exceptions.py +++ b/bigframes/exceptions.py @@ -30,7 +30,7 @@ class UnknownLocationWarning(Warning): class CleanupFailedWarning(Warning): - """Bigframes failed to clean up a table resource.""" + """Bigframes failed to clean up a table or function resource.""" class DefaultIndexWarning(Warning): diff --git a/bigframes/session/anonymous_dataset.py b/bigframes/session/anonymous_dataset.py index 3c1757806b..bdc6e7f59c 100644 --- a/bigframes/session/anonymous_dataset.py +++ b/bigframes/session/anonymous_dataset.py @@ -16,15 +16,21 @@ import threading from typing import List, Optional, Sequence import uuid +import warnings +from google.api_core import retry as api_core_retry import google.cloud.bigquery as bigquery from bigframes import constants import bigframes.core.events +import bigframes.exceptions as bfe from bigframes.session import temporary_storage import bigframes.session._io.bigquery as bf_io_bigquery _TEMP_TABLE_ID_FORMAT = "bqdf{date}_{session_id}_{random_id}" +# UDFs older than this many days are considered stale and will be deleted +# from the anonymous dataset before creating a new UDF. +_UDF_CLEANUP_THRESHOLD_DAYS = 3 class AnonymousDatasetManager(temporary_storage.TemporaryStorageManager): @@ -137,8 +143,46 @@ def generate_unique_resource_id(self) -> bigquery.TableReference: ) return self.dataset.table(table_id) + def _cleanup_old_udfs(self): + """Clean up old UDFs in the anonymous dataset.""" + dataset = self.dataset + routines = list(self.bqclient.list_routines(dataset)) + cleanup_cutoff_time = datetime.datetime.now( + datetime.timezone.utc + ) - datetime.timedelta(days=_UDF_CLEANUP_THRESHOLD_DAYS) + + for routine in routines: + if ( + routine.created < cleanup_cutoff_time + and routine._properties["routineType"] == "SCALAR_FUNCTION" + ): + try: + self.bqclient.delete_routine( + routine.reference, + not_found_ok=True, + retry=api_core_retry.Retry(timeout=0), + ) + except Exception as e: + msg = bfe.format_message( + f"Unable to clean this old UDF '{routine.reference}': {e}" + ) + warnings.warn(msg, category=bfe.CleanupFailedWarning) + def close(self): """Delete tables that were created with this session's session_id.""" for table_ref in self._table_ids: self.bqclient.delete_table(table_ref, not_found_ok=True) self._table_ids.clear() + + try: + # Before closing the session, attempt to clean up any uncollected, + # old Python UDFs residing in the anonymous dataset. These UDFs + # accumulate over time and can eventually exceed resource limits. + # See more from b/450913424. + self._cleanup_old_udfs() + except Exception as e: + # Log a warning on the failure, do not interrupt the workflow. + msg = bfe.format_message( + f"Failed to clean up the old Python UDFs before closing the session: {e}" + ) + warnings.warn(msg, category=bfe.CleanupFailedWarning) diff --git a/tests/system/large/test_session.py b/tests/system/large/test_session.py index d28146498d..a525defe59 100644 --- a/tests/system/large/test_session.py +++ b/tests/system/large/test_session.py @@ -13,6 +13,7 @@ # limitations under the License. import datetime +from unittest import mock import google.cloud.bigquery as bigquery import google.cloud.exceptions @@ -138,3 +139,35 @@ def test_clean_up_via_context_manager(session_creator): bqclient.delete_table(full_id_1) with pytest.raises(google.cloud.exceptions.NotFound): bqclient.delete_table(full_id_2) + + +def test_cleanup_old_udfs(session: bigframes.Session): + routine_ref = session._anon_dataset_manager.dataset.routine("test_routine_cleanup") + + # Create a dummy function to be deleted. + create_function_sql = f""" +CREATE OR REPLACE FUNCTION `{routine_ref.project}.{routine_ref.dataset_id}.{routine_ref.routine_id}`(x INT64) +RETURNS INT64 LANGUAGE python +OPTIONS (entry_point='dummy_func', runtime_version='python-3.11') +AS r''' +def dummy_func(x): + return x + 1 +''' + """ + session.bqclient.query(create_function_sql).result() + + assert session.bqclient.get_routine(routine_ref) is not None + + mock_routine = mock.MagicMock(spec=bigquery.Routine) + mock_routine.created = datetime.datetime.now( + datetime.timezone.utc + ) - datetime.timedelta(days=100) + mock_routine.reference = routine_ref + mock_routine._properties = {"routineType": "SCALAR_FUNCTION"} + routines = [mock_routine] + + with mock.patch.object(session.bqclient, "list_routines", return_value=routines): + session._anon_dataset_manager._cleanup_old_udfs() + + with pytest.raises(google.cloud.exceptions.NotFound): + session.bqclient.get_routine(routine_ref) diff --git a/third_party/bigframes_vendored/pandas/core/frame.py b/third_party/bigframes_vendored/pandas/core/frame.py index 1e90e2e210..1ce9dd9c49 100644 --- a/third_party/bigframes_vendored/pandas/core/frame.py +++ b/third_party/bigframes_vendored/pandas/core/frame.py @@ -420,7 +420,7 @@ def to_gbq( >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> destination = df.to_gbq(ordering_id="ordering_id") >>> # The table created can be read outside of the current session. - >>> bpd.close_session() # Optional, to demonstrate a new session. + >>> bpd.close_session() # Optional, to demonstrate a new session. # doctest: +SKIP >>> bpd.read_gbq(destination, index_col="ordering_id") col1 col2 ordering_id From da9ba267812c01ffa6fa0b09943d7a4c63b8f187 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Wed, 5 Nov 2025 16:44:47 -0800 Subject: [PATCH 210/313] feat: support left_index and right_index for merge (#2220) * feat: support left_index and right_index for merge * checkpoint: managed to let code run without error. need to handle column coalescing next * checkpoint: single-index dev complete. still facing errors when dealing with multi-index * wrap up support for single index * fix format * fix tests * fix test * remove unnecessary deps --- bigframes/core/blocks.py | 115 +++++++++++-- bigframes/core/reshape/merge.py | 162 ++++++++++++------ bigframes/dataframe.py | 4 + tests/system/small/core/test_reshape.py | 120 +++++++++++++ tests/system/small/test_pandas.py | 6 +- .../bigframes_vendored/pandas/core/frame.py | 6 + .../pandas/core/reshape/merge.py | 6 + 7 files changed, 347 insertions(+), 72 deletions(-) create mode 100644 tests/system/small/core/test_reshape.py diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index e968172c76..f657f28a6f 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -2332,6 +2332,8 @@ def merge( right_join_ids: typing.Sequence[str], sort: bool, suffixes: tuple[str, str] = ("_x", "_y"), + left_index: bool = False, + right_index: bool = False, ) -> Block: conditions = tuple( (lid, rid) for lid, rid in zip(left_join_ids, right_join_ids) @@ -2339,34 +2341,52 @@ def merge( joined_expr, (get_column_left, get_column_right) = self.expr.relational_join( other.expr, type=how, conditions=conditions ) - result_columns = [] - matching_join_labels = [] left_post_join_ids = tuple(get_column_left[id] for id in left_join_ids) right_post_join_ids = tuple(get_column_right[id] for id in right_join_ids) - joined_expr, coalesced_ids = coalesce_columns( - joined_expr, left_post_join_ids, right_post_join_ids, how=how, drop=False - ) + if left_index or right_index: + # For some reason pandas coalesces two joining columns if one side is an index. + joined_expr, resolved_join_ids = coalesce_columns( + joined_expr, left_post_join_ids, right_post_join_ids + ) + else: + joined_expr, resolved_join_ids = resolve_col_join_ids( # type: ignore + joined_expr, + left_post_join_ids, + right_post_join_ids, + how=how, + drop=False, + ) + + result_columns = [] + matching_join_labels = [] + # Select left value columns for col_id in self.value_columns: if col_id in left_join_ids: key_part = left_join_ids.index(col_id) matching_right_id = right_join_ids[key_part] if ( - self.col_id_to_label[col_id] + right_index + or self.col_id_to_label[col_id] == other.col_id_to_label[matching_right_id] ): matching_join_labels.append(self.col_id_to_label[col_id]) - result_columns.append(coalesced_ids[key_part]) + result_columns.append(resolved_join_ids[key_part]) else: result_columns.append(get_column_left[col_id]) else: result_columns.append(get_column_left[col_id]) + + # Select right value columns for col_id in other.value_columns: if col_id in right_join_ids: if other.col_id_to_label[col_id] in matching_join_labels: pass + elif left_index: + key_part = right_join_ids.index(col_id) + result_columns.append(resolved_join_ids[key_part]) else: result_columns.append(get_column_right[col_id]) else: @@ -2377,11 +2397,22 @@ def merge( joined_expr = joined_expr.order_by( [ ordering.OrderingExpression(ex.deref(col_id)) - for col_id in coalesced_ids + for col_id in resolved_join_ids ], ) - joined_expr = joined_expr.select_columns(result_columns) + left_idx_id_post_join = [get_column_left[id] for id in self.index_columns] + right_idx_id_post_join = [get_column_right[id] for id in other.index_columns] + index_cols = _resolve_index_col( + left_idx_id_post_join, + right_idx_id_post_join, + resolved_join_ids, + left_index, + right_index, + how, + ) + + joined_expr = joined_expr.select_columns(result_columns + index_cols) labels = utils.merge_column_labels( self.column_labels, other.column_labels, @@ -2400,13 +2431,13 @@ def merge( or other.index.is_null or self.session._default_index_type == bigframes.enums.DefaultIndexKind.NULL ): - expr = joined_expr - index_columns = [] + return Block(joined_expr, index_columns=[], column_labels=labels) + elif index_cols: + return Block(joined_expr, index_columns=index_cols, column_labels=labels) else: expr, offset_index_id = joined_expr.promote_offsets() index_columns = [offset_index_id] - - return Block(expr, index_columns=index_columns, column_labels=labels) + return Block(expr, index_columns=index_columns, column_labels=labels) def _align_both_axes( self, other: Block, how: str @@ -3115,7 +3146,7 @@ def join_mono_indexed( left_index = get_column_left[left.index_columns[0]] right_index = get_column_right[right.index_columns[0]] # Drop original indices from each side. and used the coalesced combination generated by the join. - combined_expr, coalesced_join_cols = coalesce_columns( + combined_expr, coalesced_join_cols = resolve_col_join_ids( combined_expr, [left_index], [right_index], how=how ) if sort: @@ -3180,7 +3211,7 @@ def join_multi_indexed( left_ids_post_join = [get_column_left[id] for id in left_join_ids] right_ids_post_join = [get_column_right[id] for id in right_join_ids] # Drop original indices from each side. and used the coalesced combination generated by the join. - combined_expr, coalesced_join_cols = coalesce_columns( + combined_expr, coalesced_join_cols = resolve_col_join_ids( combined_expr, left_ids_post_join, right_ids_post_join, how=how ) if sort: @@ -3223,13 +3254,17 @@ def resolve_label_id(label: Label) -> str: # TODO: Rewrite just to return expressions -def coalesce_columns( +def resolve_col_join_ids( expr: core.ArrayValue, left_ids: typing.Sequence[str], right_ids: typing.Sequence[str], how: str, drop: bool = True, ) -> Tuple[core.ArrayValue, Sequence[str]]: + """ + Collapses and selects the joining column IDs, with the assumption that + the ids are all belong to value columns. + """ result_ids = [] for left_id, right_id in zip(left_ids, right_ids): if how == "left" or how == "inner" or how == "cross": @@ -3241,7 +3276,6 @@ def coalesce_columns( if drop: expr = expr.drop_columns([left_id]) elif how == "outer": - coalesced_id = guid.generate_guid() expr, coalesced_id = expr.project_to_id( ops.coalesce_op.as_expr(left_id, right_id) ) @@ -3253,6 +3287,21 @@ def coalesce_columns( return expr, result_ids +def coalesce_columns( + expr: core.ArrayValue, + left_ids: typing.Sequence[str], + right_ids: typing.Sequence[str], +) -> tuple[core.ArrayValue, list[str]]: + result_ids = [] + for left_id, right_id in zip(left_ids, right_ids): + expr, coalesced_id = expr.project_to_id( + ops.coalesce_op.as_expr(left_id, right_id) + ) + result_ids.append(coalesced_id) + + return expr, result_ids + + def _cast_index(block: Block, dtypes: typing.Sequence[bigframes.dtypes.Dtype]): original_block = block result_ids = [] @@ -3468,3 +3517,35 @@ def _pd_index_to_array_value( rows.append(row) return core.ArrayValue.from_pyarrow(pa.Table.from_pylist(rows), session=session) + + +def _resolve_index_col( + left_index_cols: list[str], + right_index_cols: list[str], + resolved_join_ids: list[str], + left_index: bool, + right_index: bool, + how: typing.Literal[ + "inner", + "left", + "outer", + "right", + "cross", + ], +) -> list[str]: + if left_index and right_index: + if how == "inner" or how == "left": + return left_index_cols + if how == "right": + return right_index_cols + if how == "outer": + return resolved_join_ids + else: + return [] + elif left_index and not right_index: + return right_index_cols + elif right_index and not left_index: + return left_index_cols + else: + # Joining with value columns only. Existing indices will be discarded. + return [] diff --git a/bigframes/core/reshape/merge.py b/bigframes/core/reshape/merge.py index 5c6cba4915..2afeb2a106 100644 --- a/bigframes/core/reshape/merge.py +++ b/bigframes/core/reshape/merge.py @@ -20,6 +20,7 @@ from typing import Literal, Sequence +from bigframes_vendored import constants import bigframes_vendored.pandas.core.reshape.merge as vendored_pandas_merge from bigframes import dataframe, series @@ -40,6 +41,8 @@ def merge( *, left_on: blocks.Label | Sequence[blocks.Label] | None = None, right_on: blocks.Label | Sequence[blocks.Label] | None = None, + left_index: bool = False, + right_index: bool = False, sort: bool = False, suffixes: tuple[str, str] = ("_x", "_y"), ) -> dataframe.DataFrame: @@ -59,35 +62,16 @@ def merge( ) return dataframe.DataFrame(result_block) - left_on, right_on = _validate_left_right_on( - left, right, on, left_on=left_on, right_on=right_on + left_join_ids, right_join_ids = _validate_left_right_on( + left, + right, + on, + left_on=left_on, + right_on=right_on, + left_index=left_index, + right_index=right_index, ) - if utils.is_list_like(left_on): - left_on = list(left_on) # type: ignore - else: - left_on = [left_on] - - if utils.is_list_like(right_on): - right_on = list(right_on) # type: ignore - else: - right_on = [right_on] - - left_join_ids = [] - for label in left_on: # type: ignore - left_col_id = left._resolve_label_exact(label) - # 0 elements already throws an exception - if not left_col_id: - raise ValueError(f"No column {label} found in self.") - left_join_ids.append(left_col_id) - - right_join_ids = [] - for label in right_on: # type: ignore - right_col_id = right._resolve_label_exact(label) - if not right_col_id: - raise ValueError(f"No column {label} found in other.") - right_join_ids.append(right_col_id) - block = left._block.merge( right._block, how, @@ -95,6 +79,8 @@ def merge( right_join_ids, sort=sort, suffixes=suffixes, + left_index=left_index, + right_index=right_index, ) return dataframe.DataFrame(block) @@ -127,30 +113,106 @@ def _validate_left_right_on( *, left_on: blocks.Label | Sequence[blocks.Label] | None = None, right_on: blocks.Label | Sequence[blocks.Label] | None = None, -): - if on is not None: - if left_on is not None or right_on is not None: - raise ValueError( - "Can not pass both `on` and `left_on` + `right_on` params." - ) - return on, on - - if left_on is not None and right_on is not None: - return left_on, right_on + left_index: bool = False, + right_index: bool = False, +) -> tuple[list[str], list[str]]: + # Turn left_on and right_on to lists + if left_on is not None and not isinstance(left_on, (tuple, list)): + left_on = [left_on] + if right_on is not None and not isinstance(right_on, (tuple, list)): + right_on = [right_on] - left_cols = left.columns - right_cols = right.columns - common_cols = left_cols.intersection(right_cols) - if len(common_cols) == 0: + if left_index and left.index.nlevels > 1: raise ValueError( - "No common columns to perform merge on." - f"Merge options: left_on={left_on}, " - f"right_on={right_on}, " + f"Joining with multi-level index is not supported. {constants.FEEDBACK_LINK}" ) - if ( - not left_cols.join(common_cols, how="inner").is_unique - or not right_cols.join(common_cols, how="inner").is_unique - ): - raise ValueError(f"Data columns not unique: {repr(common_cols)}") + if right_index and right.index.nlevels > 1: + raise ValueError( + f"Joining with multi-level index is not supported. {constants.FEEDBACK_LINK}" + ) + + # The following checks are copied from Pandas. + if on is None and left_on is None and right_on is None: + if left_index and right_index: + return list(left._block.index_columns), list(right._block.index_columns) + elif left_index: + raise ValueError("Must pass right_on or right_index=True") + elif right_index: + raise ValueError("Must pass left_on or left_index=True") + else: + # use the common columns + common_cols = left.columns.intersection(right.columns) + if len(common_cols) == 0: + raise ValueError( + "No common columns to perform merge on. " + f"Merge options: left_on={left_on}, " + f"right_on={right_on}, " + f"left_index={left_index}, " + f"right_index={right_index}" + ) + if ( + not left.columns.join(common_cols, how="inner").is_unique + or not right.columns.join(common_cols, how="inner").is_unique + ): + raise ValueError(f"Data columns not unique: {repr(common_cols)}") + return _to_col_ids(left, common_cols.to_list()), _to_col_ids( + right, common_cols.to_list() + ) - return common_cols, common_cols + elif on is not None: + if left_on is not None or right_on is not None: + raise ValueError( + 'Can only pass argument "on" OR "left_on" ' + 'and "right_on", not a combination of both.' + ) + if left_index or right_index: + raise ValueError( + 'Can only pass argument "on" OR "left_index" ' + 'and "right_index", not a combination of both.' + ) + return _to_col_ids(left, on), _to_col_ids(right, on) + + elif left_on is not None: + if left_index: + raise ValueError( + 'Can only pass argument "left_on" OR "left_index" not both.' + ) + if not right_index and right_on is None: + raise ValueError('Must pass "right_on" OR "right_index".') + if right_index: + if len(left_on) != right.index.nlevels: + raise ValueError( + "len(left_on) must equal the number " + 'of levels in the index of "right"' + ) + return _to_col_ids(left, left_on), list(right._block.index_columns) + + elif right_on is not None: + if right_index: + raise ValueError( + 'Can only pass argument "right_on" OR "right_index" not both.' + ) + if not left_index and left_on is None: + raise ValueError('Must pass "left_on" OR "left_index".') + if left_index: + if len(right_on) != left.index.nlevels: + raise ValueError( + "len(right_on) must equal the number " + 'of levels in the index of "left"' + ) + return list(left._block.index_columns), _to_col_ids(right, right_on) + + # The user correctly specified left_on and right_on + if len(right_on) != len(left_on): # type: ignore + raise ValueError("len(right_on) must equal len(left_on)") + + return _to_col_ids(left, left_on), _to_col_ids(right, right_on) + + +def _to_col_ids( + df: dataframe.DataFrame, join_cols: blocks.Label | Sequence[blocks.Label] +) -> list[str]: + if utils.is_list_like(join_cols): + return [df._block.resolve_label_exact_or_error(col) for col in join_cols] + + return [df._block.resolve_label_exact_or_error(join_cols)] diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 7471cf587b..0ce602d1ea 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -3650,6 +3650,8 @@ def merge( *, left_on: Union[blocks.Label, Sequence[blocks.Label], None] = None, right_on: Union[blocks.Label, Sequence[blocks.Label], None] = None, + left_index: bool = False, + right_index: bool = False, sort: bool = False, suffixes: tuple[str, str] = ("_x", "_y"), ) -> DataFrame: @@ -3662,6 +3664,8 @@ def merge( on, left_on=left_on, right_on=right_on, + left_index=left_index, + right_index=right_index, sort=sort, suffixes=suffixes, ) diff --git a/tests/system/small/core/test_reshape.py b/tests/system/small/core/test_reshape.py new file mode 100644 index 0000000000..0850bf50bb --- /dev/null +++ b/tests/system/small/core/test_reshape.py @@ -0,0 +1,120 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pandas as pd +import pandas.testing +import pytest + +from bigframes import session +from bigframes.core.reshape import merge + + +@pytest.mark.parametrize( + ("left_on", "right_on", "left_index", "right_index"), + [ + ("col_a", None, False, True), + (None, "col_d", True, False), + (None, None, True, True), + ], +) +@pytest.mark.parametrize("how", ["inner", "left", "right", "outer"]) +def test_join_with_index( + session: session.Session, left_on, right_on, left_index, right_index, how +): + df1 = pd.DataFrame({"col_a": [1, 2, 3], "col_b": [2, 3, 4]}, index=[1, 2, 3]) + bf1 = session.read_pandas(df1) + df2 = pd.DataFrame({"col_c": [1, 2, 3], "col_d": [2, 3, 4]}, index=[2, 3, 4]) + bf2 = session.read_pandas(df2) + + bf_result = merge.merge( + bf1, + bf2, + left_on=left_on, + right_on=right_on, + left_index=left_index, + right_index=right_index, + how=how, + ).to_pandas() + pd_result = pd.merge( + df1, + df2, + left_on=left_on, + right_on=right_on, + left_index=left_index, + right_index=right_index, + how=how, + ) + + pandas.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + +@pytest.mark.parametrize( + ("on", "left_on", "right_on", "left_index", "right_index"), + [ + (None, "col_a", None, True, False), + (None, None, "col_c", None, True), + ("col_a", None, None, True, True), + ], +) +def test_join_with_index_invalid_index_arg_raise_error( + session: session.Session, on, left_on, right_on, left_index, right_index +): + df1 = pd.DataFrame({"col_a": [1, 2, 3], "col_b": [2, 3, 4]}, index=[1, 2, 3]) + bf1 = session.read_pandas(df1) + df2 = pd.DataFrame({"col_c": [1, 2, 3], "col_d": [2, 3, 4]}, index=[2, 3, 4]) + bf2 = session.read_pandas(df2) + + with pytest.raises(ValueError): + merge.merge( + bf1, + bf2, + on=on, + left_on=left_on, + right_on=right_on, + left_index=left_index, + right_index=right_index, + ).to_pandas() + + +@pytest.mark.parametrize( + ("left_on", "right_on", "left_index", "right_index"), + [ + (["col_a", "col_b"], None, False, True), + (None, ["col_c", "col_d"], True, False), + (None, None, True, True), + ], +) +@pytest.mark.parametrize("how", ["inner", "left", "right", "outer"]) +def test_join_with_multiindex_raises_error( + session: session.Session, left_on, right_on, left_index, right_index, how +): + multi_idx1 = pd.MultiIndex.from_tuples([(1, 2), (2, 3), (3, 5)]) + df1 = pd.DataFrame({"col_a": [1, 2, 3], "col_b": [2, 3, 4]}, index=multi_idx1) + bf1 = session.read_pandas(df1) + multi_idx2 = pd.MultiIndex.from_tuples([(1, 2), (2, 3), (3, 2)]) + df2 = pd.DataFrame({"col_c": [1, 2, 3], "col_d": [2, 3, 4]}, index=multi_idx2) + bf2 = session.read_pandas(df2) + + with pytest.raises(ValueError): + merge.merge( + bf1, + bf2, + left_on=left_on, + right_on=right_on, + left_index=left_index, + right_index=right_index, + how=how, + ) diff --git a/tests/system/small/test_pandas.py b/tests/system/small/test_pandas.py index d2cde59729..2f4ddaecff 100644 --- a/tests/system/small/test_pandas.py +++ b/tests/system/small/test_pandas.py @@ -13,7 +13,6 @@ # limitations under the License. from datetime import datetime -import re import typing import pandas as pd @@ -440,10 +439,7 @@ def test_merge_raises_error_when_left_right_on_set(scalars_dfs): left = scalars_df[left_columns] right = scalars_df[right_columns] - with pytest.raises( - ValueError, - match=re.escape("Can not pass both `on` and `left_on` + `right_on` params."), - ): + with pytest.raises(ValueError): bpd.merge( left, right, diff --git a/third_party/bigframes_vendored/pandas/core/frame.py b/third_party/bigframes_vendored/pandas/core/frame.py index 1ce9dd9c49..3381f53351 100644 --- a/third_party/bigframes_vendored/pandas/core/frame.py +++ b/third_party/bigframes_vendored/pandas/core/frame.py @@ -4593,6 +4593,8 @@ def merge( *, left_on: Optional[str] = None, right_on: Optional[str] = None, + left_index: bool = False, + right_index: bool = False, sort: bool = False, suffixes: tuple[str, str] = ("_x", "_y"), ) -> DataFrame: @@ -4705,6 +4707,10 @@ def merge( right_on (label or list of labels): Columns to join on in the right DataFrame. Either on or left_on + right_on must be passed in. + left_index (bool, default False): + Use the index from the left DataFrame as the join key. + right_index (bool, default False): + Use the index from the right DataFrame as the join key. sort: Default False. Sort the join keys lexicographically in the result DataFrame. If False, the order of the join keys depends diff --git a/third_party/bigframes_vendored/pandas/core/reshape/merge.py b/third_party/bigframes_vendored/pandas/core/reshape/merge.py index 66fb2c2160..49ff409c9a 100644 --- a/third_party/bigframes_vendored/pandas/core/reshape/merge.py +++ b/third_party/bigframes_vendored/pandas/core/reshape/merge.py @@ -13,6 +13,8 @@ def merge( *, left_on=None, right_on=None, + left_index: bool = False, + right_index: bool = False, sort=False, suffixes=("_x", "_y"), ): @@ -61,6 +63,10 @@ def merge( right_on (label or list of labels): Columns to join on in the right DataFrame. Either on or left_on + right_on must be passed in. + left_index (bool, default False): + Use the index from the left DataFrame as the join key. + right_index (bool, default False): + Use the index from the right DataFrame as the join key. sort: Default False. Sort the join keys lexicographically in the result DataFrame. If False, the order of the join keys depends From 4318d66bbfbf3f9553195473e449f7b6e29218fc Mon Sep 17 00:00:00 2001 From: Garrett Wu <6505921+GarrettWu@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:54:01 -0800 Subject: [PATCH 211/313] test: re-enable Multimodal tests (#2235) * Revert "fix: Correct connection normalization in blob system tests (#2222)" This reverts commit a0e1e50e47c758bdceb54d04180ed36b35cf2e35. * fix --- tests/system/conftest.py | 20 ++--------- tests/system/large/blob/test_function.py | 15 -------- tests/system/small/bigquery/test_ai.py | 14 ++++---- tests/system/small/blob/test_io.py | 38 ++++---------------- tests/system/small/blob/test_properties.py | 24 ++----------- tests/system/small/ml/test_multimodal_llm.py | 1 - 6 files changed, 18 insertions(+), 94 deletions(-) diff --git a/tests/system/conftest.py b/tests/system/conftest.py index 2f08a695e9..9c4fcf58b1 100644 --- a/tests/system/conftest.py +++ b/tests/system/conftest.py @@ -70,23 +70,6 @@ def _hash_digest_file(hasher, filepath): hasher.update(chunk) -@pytest.fixture(scope="session") -def normalize_connection_id(): - """Normalizes the connection ID by casefolding only the LOCATION component. - - Connection format: PROJECT.LOCATION.CONNECTION_NAME - Only LOCATION is case-insensitive; PROJECT and CONNECTION_NAME must be lowercase. - """ - - def normalize(connection_id: str) -> str: - parts = connection_id.split(".") - if len(parts) == 3: - return f"{parts[0]}.{parts[1].casefold()}.{parts[2]}" - return connection_id # Return unchanged if invalid format - - return normalize - - @pytest.fixture(scope="session") def tokyo_location() -> str: return TOKYO_LOCATION @@ -212,7 +195,8 @@ def bq_connection_name() -> str: @pytest.fixture(scope="session") def bq_connection(bigquery_client: bigquery.Client, bq_connection_name: str) -> str: - return f"{bigquery_client.project}.{bigquery_client.location}.{bq_connection_name}" + # TODO(b/458169181): LOCATION casefold is needed for the mutimodal backend bug. Remove after the bug is fixed. + return f"{bigquery_client.project}.{bigquery_client.location.casefold()}.{bq_connection_name}" @pytest.fixture(scope="session", autouse=True) diff --git a/tests/system/large/blob/test_function.py b/tests/system/large/blob/test_function.py index 9ba8126dc6..7963fabd0b 100644 --- a/tests/system/large/blob/test_function.py +++ b/tests/system/large/blob/test_function.py @@ -52,7 +52,6 @@ def images_output_uris(images_output_folder: str) -> list[str]: ] -@pytest.mark.skip(reason="b/457416070") def test_blob_exif( bq_connection: str, session: bigframes.Session, @@ -104,7 +103,6 @@ def test_blob_exif_verbose( assert content_series.dtype == dtypes.JSON_DTYPE -@pytest.mark.skip(reason="b/457416070") def test_blob_image_blur_to_series( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -138,7 +136,6 @@ def test_blob_image_blur_to_series( assert not actual.blob.size().isna().any() -@pytest.mark.skip(reason="b/457416070") def test_blob_image_blur_to_series_verbose( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -166,7 +163,6 @@ def test_blob_image_blur_to_series_verbose( assert not actual.blob.size().isna().any() -@pytest.mark.skip(reason="b/457416070") def test_blob_image_blur_to_folder( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -199,7 +195,6 @@ def test_blob_image_blur_to_folder( assert not actual.blob.size().isna().any() -@pytest.mark.skip(reason="b/457416070") def test_blob_image_blur_to_folder_verbose( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -259,7 +254,6 @@ def test_blob_image_blur_to_bq_verbose(images_mm_df: bpd.DataFrame, bq_connectio assert content_series.dtype == dtypes.BYTES_DTYPE -@pytest.mark.skip(reason="b/457416070") def test_blob_image_resize_to_series( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -297,7 +291,6 @@ def test_blob_image_resize_to_series( assert not actual.blob.size().isna().any() -@pytest.mark.skip(reason="b/457416070") def test_blob_image_resize_to_series_verbose( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -332,7 +325,6 @@ def test_blob_image_resize_to_series_verbose( assert not actual.blob.size().isna().any() -@pytest.mark.skip(reason="b/457416070") def test_blob_image_resize_to_folder( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -366,7 +358,6 @@ def test_blob_image_resize_to_folder( assert not actual.blob.size().isna().any() -@pytest.mark.skip(reason="b/457416070") def test_blob_image_resize_to_folder_verbose( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -429,7 +420,6 @@ def test_blob_image_resize_to_bq_verbose( assert content_series.dtype == dtypes.BYTES_DTYPE -@pytest.mark.skip(reason="b/457416070") def test_blob_image_normalize_to_series( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -502,7 +492,6 @@ def test_blob_image_normalize_to_series_verbose( assert hasattr(content_series, "blob") -@pytest.mark.skip(reason="b/457416070") def test_blob_image_normalize_to_folder( images_mm_df: bpd.DataFrame, bq_connection: str, @@ -609,7 +598,6 @@ def test_blob_image_normalize_to_bq_verbose( assert content_series.dtype == dtypes.BYTES_DTYPE -@pytest.mark.skip(reason="b/457416070") def test_blob_pdf_extract( pdf_mm_df: bpd.DataFrame, bq_connection: str, @@ -645,7 +633,6 @@ def test_blob_pdf_extract( ), f"Item (verbose=False): Expected keyword '{keyword}' not found in extracted text. " -@pytest.mark.skip(reason="b/457416070") def test_blob_pdf_extract_verbose( pdf_mm_df: bpd.DataFrame, bq_connection: str, @@ -683,7 +670,6 @@ def test_blob_pdf_extract_verbose( ), f"Item (verbose=True): Expected keyword '{keyword}' not found in extracted text. " -@pytest.mark.skip(reason="b/457416070") def test_blob_pdf_chunk(pdf_mm_df: bpd.DataFrame, bq_connection: str): actual = ( pdf_mm_df["pdf"] @@ -723,7 +709,6 @@ def test_blob_pdf_chunk(pdf_mm_df: bpd.DataFrame, bq_connection: str): ), f"Item (verbose=False): Expected keyword '{keyword}' not found in extracted text. " -@pytest.mark.skip(reason="b/457416070") def test_blob_pdf_chunk_verbose(pdf_mm_df: bpd.DataFrame, bq_connection: str): actual = ( pdf_mm_df["pdf"] diff --git a/tests/system/small/bigquery/test_ai.py b/tests/system/small/bigquery/test_ai.py index 6df4a7a528..e5af45ec2b 100644 --- a/tests/system/small/bigquery/test_ai.py +++ b/tests/system/small/bigquery/test_ai.py @@ -273,10 +273,11 @@ def test_ai_if(session): assert result.dtype == dtypes.BOOL_DTYPE -@pytest.mark.skip(reason="b/457416070") -def test_ai_if_multi_model(session): +def test_ai_if_multi_model(session, bq_connection): df = session.from_glob_path( - "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" + "gs://bigframes-dev-testing/a_multimodel/images/*", + name="image", + connection=bq_connection, ) result = bbq.ai.if_((df["image"], " contains an animal")) @@ -294,10 +295,11 @@ def test_ai_classify(session): assert result.dtype == dtypes.STRING_DTYPE -@pytest.mark.skip(reason="b/457416070") -def test_ai_classify_multi_model(session): +def test_ai_classify_multi_model(session, bq_connection): df = session.from_glob_path( - "gs://bigframes-dev-testing/a_multimodel/images/*", name="image" + "gs://bigframes-dev-testing/a_multimodel/images/*", + name="image", + connection=bq_connection, ) result = bbq.ai.classify(df["image"], ["photo", "cartoon"]) diff --git a/tests/system/small/blob/test_io.py b/tests/system/small/blob/test_io.py index 5da113a5e1..5ada4fabb0 100644 --- a/tests/system/small/blob/test_io.py +++ b/tests/system/small/blob/test_io.py @@ -12,36 +12,27 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Callable from unittest import mock import IPython.display import pandas as pd -import pytest import bigframes import bigframes.pandas as bpd def test_blob_create_from_uri_str( - bq_connection: str, - session: bigframes.Session, - images_uris, - normalize_connection_id: Callable[[str], str], + bq_connection: str, session: bigframes.Session, images_uris ): uri_series = bpd.Series(images_uris, session=session) blob_series = uri_series.str.to_blob(connection=bq_connection) pd_blob_df = blob_series.struct.explode().to_pandas() - pd_blob_df["authorizer"] = pd_blob_df["authorizer"].apply(normalize_connection_id) expected_pd_df = pd.DataFrame( { "uri": images_uris, "version": [None, None], - "authorizer": [ - normalize_connection_id(bq_connection), - normalize_connection_id(bq_connection), - ], + "authorizer": [bq_connection.casefold(), bq_connection.casefold()], "details": [None, None], } ) @@ -52,11 +43,7 @@ def test_blob_create_from_uri_str( def test_blob_create_from_glob_path( - bq_connection: str, - session: bigframes.Session, - images_gcs_path, - images_uris, - normalize_connection_id: Callable[[str], str], + bq_connection: str, session: bigframes.Session, images_gcs_path, images_uris ): blob_df = session.from_glob_path( images_gcs_path, connection=bq_connection, name="blob_col" @@ -68,16 +55,12 @@ def test_blob_create_from_glob_path( .sort_values("uri") .reset_index(drop=True) ) - pd_blob_df["authorizer"] = pd_blob_df["authorizer"].apply(normalize_connection_id) expected_df = pd.DataFrame( { "uri": images_uris, "version": [None, None], - "authorizer": [ - normalize_connection_id(bq_connection), - normalize_connection_id(bq_connection), - ], + "authorizer": [bq_connection.casefold(), bq_connection.casefold()], "details": [None, None], } ) @@ -88,11 +71,7 @@ def test_blob_create_from_glob_path( def test_blob_create_read_gbq_object_table( - bq_connection: str, - session: bigframes.Session, - images_gcs_path, - images_uris, - normalize_connection_id: Callable[[str], str], + bq_connection: str, session: bigframes.Session, images_gcs_path, images_uris ): obj_table = session._create_object_table(images_gcs_path, bq_connection) @@ -104,15 +83,11 @@ def test_blob_create_read_gbq_object_table( .sort_values("uri") .reset_index(drop=True) ) - pd_blob_df["authorizer"] = pd_blob_df["authorizer"].apply(normalize_connection_id) expected_df = pd.DataFrame( { "uri": images_uris, "version": [None, None], - "authorizer": [ - normalize_connection_id(bq_connection), - normalize_connection_id(bq_connection), - ], + "authorizer": [bq_connection.casefold(), bq_connection.casefold()], "details": [None, None], } ) @@ -122,7 +97,6 @@ def test_blob_create_read_gbq_object_table( ) -@pytest.mark.skip(reason="b/457416070") def test_display_images(monkeypatch, images_mm_df: bpd.DataFrame): mock_display = mock.Mock() monkeypatch.setattr(IPython.display, "display", mock_display) diff --git a/tests/system/small/blob/test_properties.py b/tests/system/small/blob/test_properties.py index c411c01f13..47d4d2aa04 100644 --- a/tests/system/small/blob/test_properties.py +++ b/tests/system/small/blob/test_properties.py @@ -12,12 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - -from typing import Callable - import pandas as pd -import pytest import bigframes.dtypes as dtypes import bigframes.pandas as bpd @@ -32,19 +27,10 @@ def test_blob_uri(images_uris: list[str], images_mm_df: bpd.DataFrame): ) -def test_blob_authorizer( - images_mm_df: bpd.DataFrame, - bq_connection: str, - normalize_connection_id: Callable[[str], str], -): +def test_blob_authorizer(images_mm_df: bpd.DataFrame, bq_connection: str): actual = images_mm_df["blob_col"].blob.authorizer().to_pandas() - actual = actual.apply(normalize_connection_id) expected = pd.Series( - [ - normalize_connection_id(bq_connection), - normalize_connection_id(bq_connection), - ], - name="authorizer", + [bq_connection.casefold(), bq_connection.casefold()], name="authorizer" ) pd.testing.assert_series_equal( @@ -52,7 +38,6 @@ def test_blob_authorizer( ) -@pytest.mark.skip(reason="b/457416070") def test_blob_version(images_mm_df: bpd.DataFrame): actual = images_mm_df["blob_col"].blob.version().to_pandas() expected = pd.Series(["1753907851152593", "1753907851111538"], name="version") @@ -62,7 +47,6 @@ def test_blob_version(images_mm_df: bpd.DataFrame): ) -@pytest.mark.skip(reason="b/457416070") def test_blob_metadata(images_mm_df: bpd.DataFrame): actual = images_mm_df["blob_col"].blob.metadata().to_pandas() expected = pd.Series( @@ -87,7 +71,6 @@ def test_blob_metadata(images_mm_df: bpd.DataFrame): pd.testing.assert_series_equal(actual, expected) -@pytest.mark.skip(reason="b/457416070") def test_blob_content_type(images_mm_df: bpd.DataFrame): actual = images_mm_df["blob_col"].blob.content_type().to_pandas() expected = pd.Series(["image/jpeg", "image/jpeg"], name="content_type") @@ -97,7 +80,6 @@ def test_blob_content_type(images_mm_df: bpd.DataFrame): ) -@pytest.mark.skip(reason="b/457416070") def test_blob_md5_hash(images_mm_df: bpd.DataFrame): actual = images_mm_df["blob_col"].blob.md5_hash().to_pandas() expected = pd.Series( @@ -110,7 +92,6 @@ def test_blob_md5_hash(images_mm_df: bpd.DataFrame): ) -@pytest.mark.skip(reason="b/457416070") def test_blob_size(images_mm_df: bpd.DataFrame): actual = images_mm_df["blob_col"].blob.size().to_pandas() expected = pd.Series([338390, 43333], name="size") @@ -120,7 +101,6 @@ def test_blob_size(images_mm_df: bpd.DataFrame): ) -@pytest.mark.skip(reason="b/457416070") def test_blob_updated(images_mm_df: bpd.DataFrame): actual = images_mm_df["blob_col"].blob.updated().to_pandas() expected = pd.Series( diff --git a/tests/system/small/ml/test_multimodal_llm.py b/tests/system/small/ml/test_multimodal_llm.py index fe34f9c02b..48a69f522c 100644 --- a/tests/system/small/ml/test_multimodal_llm.py +++ b/tests/system/small/ml/test_multimodal_llm.py @@ -21,7 +21,6 @@ from bigframes.testing import utils -@pytest.mark.skip(reason="b/457416070") @pytest.mark.flaky(retries=2) def test_multimodal_embedding_generator_predict_default_params_success( images_mm_df, session, bq_connection From 9b86dcf87929648bf5ab565dfd46a23b639f01ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Thu, 6 Nov 2025 17:09:17 -0600 Subject: [PATCH 212/313] docs: switch API reference docs to pydata theme (#2237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: switch API reference docs to pydata theme * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * use python 3.13 to render the docs * deploy to github pages * Revert "🦉 Updates from OwlBot post-processor" This reverts commit 5b5276b81fb3998e7241401b6cbfc56e172e4831. * exclude conf.py from owlbot * remove unneeded replacement --------- Co-authored-by: Owl Bot --- .github/workflows/docs-deploy.yml | 57 +++++++++++++++++++++++++++++++ .github/workflows/docs.yml | 2 +- README.rst | 2 ++ docs/conf.py | 40 ++++++++++++++-------- noxfile.py | 35 +++++-------------- owlbot.py | 8 +---- scripts/publish_api_coverage.py | 3 ++ 7 files changed, 97 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/docs-deploy.yml diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 0000000000..13d4d87263 --- /dev/null +++ b/.github/workflows/docs-deploy.yml @@ -0,0 +1,57 @@ +name: Deploy docs to GitHub Pages + +on: + # Runs on pushes targeting the default branch + # TODO(tswast): Update this to only be releases once we confirm it's working. + push: + branches: ["main"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Build job + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install nox + run: | + python -m pip install --upgrade setuptools pip wheel + python -m pip install nox + - name: Run docs + run: | + nox -s docs + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/_build/html/ + + # Deployment job + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2833fe98ff..fab2463145 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,7 +12,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.13" - name: Install nox run: | python -m pip install --upgrade setuptools pip wheel diff --git a/README.rst b/README.rst index 36d3c2ca20..84de370652 100644 --- a/README.rst +++ b/README.rst @@ -1,3 +1,5 @@ +:orphan: + BigQuery DataFrames (BigFrames) =============================== diff --git a/docs/conf.py b/docs/conf.py index 23ec7a6b36..9d9e9ebd79 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,9 +24,11 @@ # All configuration values have a default; values that are commented out # serve to show the default. +from __future__ import annotations + import os -import shlex import sys +from typing import Any # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -56,7 +58,7 @@ "sphinx.ext.napoleon", "sphinx.ext.todo", "sphinx.ext.viewcode", - "recommonmark", + "myst_parser", ] # autodoc/autosummary flags @@ -98,7 +100,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en-US" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: @@ -148,19 +150,27 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = "alabaster" +html_theme = "pydata_sphinx_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. +# https://pydata-sphinx-theme.readthedocs.io/en/stable/user_guide/layout.html#references html_theme_options = { - "description": "BigQuery DataFrames provides DataFrame APIs on the BigQuery engine", - "github_user": "googleapis", - "github_repo": "python-bigquery-dataframes", - "github_banner": True, - "font_family": "'Roboto', Georgia, sans", - "head_font_family": "'Roboto', Georgia, serif", - "code_font_family": "'Roboto Mono', 'Consolas', monospace", + "github_url": "https://github.com/googleapis/python-bigquery-dataframes", + "logo": { + "text": "BigQuery DataFrames (BigFrames)", + }, + "external_links": [ + { + "name": "Getting started", + "url": "https://docs.cloud.google.com/bigquery/docs/dataframes-quickstart", + }, + { + "name": "User guide", + "url": "https://docs.cloud.google.com/bigquery/docs/bigquery-dataframes-introduction", + }, + ], } # Add any paths that contain custom themes here, relative to this directory. @@ -264,7 +274,7 @@ # -- Options for LaTeX output --------------------------------------------- -latex_elements = { +latex_elements: dict[str, Any] = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). @@ -282,7 +292,7 @@ ( root_doc, "bigframes.tex", - "bigframes Documentation", + "BigQuery DataFrames (BigFrames)", author, "manual", ) @@ -317,7 +327,7 @@ ( root_doc, "bigframes", - "bigframes Documentation", + "BigQuery DataFrames (BigFrames)", [author], 1, ) @@ -336,7 +346,7 @@ ( root_doc, "bigframes", - "bigframes Documentation", + "BigQuery DataFrames (BigFrames)", author, "bigframes", "bigframes Library", diff --git a/noxfile.py b/noxfile.py index 8334fcb0e1..b02952f9c2 100644 --- a/noxfile.py +++ b/noxfile.py @@ -515,24 +515,14 @@ def cover(session): session.run("coverage", "erase") -@nox.session(python=DEFAULT_PYTHON_VERSION) +@nox.session(python="3.13") def docs(session): """Build the docs for this library.""" session.install("-e", ".[scikit-learn]") session.install( - # We need to pin to specific versions of the `sphinxcontrib-*` packages - # which still support sphinx 4.x. - # See https://github.com/googleapis/sphinx-docfx-yaml/issues/344 - # and https://github.com/googleapis/sphinx-docfx-yaml/issues/345. - "sphinxcontrib-applehelp==1.0.4", - "sphinxcontrib-devhelp==1.0.2", - "sphinxcontrib-htmlhelp==2.0.1", - "sphinxcontrib-qthelp==1.0.3", - "sphinxcontrib-serializinghtml==1.1.5", - SPHINX_VERSION, - "alabaster", - "recommonmark", - "anywidget", + "sphinx==8.2.3", + "myst-parser==4.0.1", + "pydata-sphinx-theme==0.16.1", ) shutil.rmtree(os.path.join("docs", "_build"), ignore_errors=True) @@ -562,19 +552,10 @@ def docfx(session): session.install("-e", ".[scikit-learn]") session.install( - # We need to pin to specific versions of the `sphinxcontrib-*` packages - # which still support sphinx 4.x. - # See https://github.com/googleapis/sphinx-docfx-yaml/issues/344 - # and https://github.com/googleapis/sphinx-docfx-yaml/issues/345. - "sphinxcontrib-applehelp==1.0.4", - "sphinxcontrib-devhelp==1.0.2", - "sphinxcontrib-htmlhelp==2.0.1", - "sphinxcontrib-qthelp==1.0.3", - "sphinxcontrib-serializinghtml==1.1.5", SPHINX_VERSION, - "alabaster", - "recommonmark", - "gcp-sphinx-docfx-yaml==3.0.1", + "pydata-sphinx-theme==0.13.3", + "myst-parser==0.18.1", + "gcp-sphinx-docfx-yaml==3.2.4", "anywidget", ) @@ -599,7 +580,7 @@ def docfx(session): "sphinx.ext.napoleon," "sphinx.ext.todo," "sphinx.ext.viewcode," - "recommonmark" + "myst_parser" ), "-b", "html", diff --git a/owlbot.py b/owlbot.py index b9145d4367..33dd33a84f 100644 --- a/owlbot.py +++ b/owlbot.py @@ -44,6 +44,7 @@ excludes=[ # Need a combined LICENSE for all vendored packages. "LICENSE", + "docs/conf.py", # Multi-processing note isn't relevant, as bigframes is responsible for # creating clients, not the end user. "docs/multiprocessing.rst", @@ -114,13 +115,6 @@ "recursive-include bigframes *.json *.proto *.js *.css py.typed", ) -# Fixup the documentation. -assert 1 == s.replace( # docs/conf.py - ["docs/conf.py"], - re.escape("Google Cloud Client Libraries for bigframes"), - "BigQuery DataFrames provides DataFrame APIs on the BigQuery engine", -) - # Don't omit `*/core/*.py` when counting test coverages assert 1 == s.replace( # .coveragerc [".coveragerc"], diff --git a/scripts/publish_api_coverage.py b/scripts/publish_api_coverage.py index 43f7df4dd6..1c052504d3 100644 --- a/scripts/publish_api_coverage.py +++ b/scripts/publish_api_coverage.py @@ -205,6 +205,9 @@ def generate_pandas_api_coverage(): def generate_sklearn_api_coverage(): """Explore all SKLearn modules, and for each item contained generate a regex to detect it being imported, and record whether we implement it""" + + import sklearn # noqa + sklearn_modules = [ "sklearn", "sklearn.model_selection", From 12e04d55f0d6aef1297b7ca773935aecf3313ee7 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Fri, 7 Nov 2025 14:35:53 -0800 Subject: [PATCH 213/313] fix: Correctly iterate over null struct values in ManagedArrowTable (#2209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes internal issue 446726636 🦕 --- bigframes/core/local_data.py | 37 +++++++++--------- tests/system/small/engines/test_read_local.py | 5 ++- .../out.sql | 19 --------- tests/unit/test_local_data.py | 39 +++++++++++++++---- 4 files changed, 53 insertions(+), 47 deletions(-) delete mode 100644 tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_nested_structs_df/out.sql diff --git a/bigframes/core/local_data.py b/bigframes/core/local_data.py index fa18f00483..0735c4fc5a 100644 --- a/bigframes/core/local_data.py +++ b/bigframes/core/local_data.py @@ -253,9 +253,16 @@ def _( value_generator = iter_array( array.flatten(), bigframes.dtypes.get_array_inner_type(dtype) ) - for (start, end) in _pairwise(array.offsets): - arr_size = end.as_py() - start.as_py() - yield list(itertools.islice(value_generator, arr_size)) + offset_generator = iter_array(array.offsets, bigframes.dtypes.INT_DTYPE) + + start_offset = None + end_offset = None + for offset in offset_generator: + start_offset = end_offset + end_offset = offset + if start_offset is not None: + arr_size = end_offset - start_offset + yield list(itertools.islice(value_generator, arr_size)) @iter_array.register def _( @@ -267,8 +274,15 @@ def _( sub_generators[field_name] = iter_array(array.field(field_name), dtype) keys = list(sub_generators.keys()) - for row_values in zip(*sub_generators.values()): - yield {key: value for key, value in zip(keys, row_values)} + is_null_generator = iter_array(array.is_null(), bigframes.dtypes.BOOL_DTYPE) + + for values in zip(is_null_generator, *sub_generators.values()): + is_row_null = values[0] + row_values = values[1:] + if not is_row_null: + yield {key: value for key, value in zip(keys, row_values)} + else: + yield None for batch in table.to_batches(): sub_generators: dict[str, Generator[Any, None, None]] = {} @@ -491,16 +505,3 @@ def _schema_durations_to_ints(schema: pa.Schema) -> pa.Schema: return pa.schema( pa.field(field.name, _durations_to_ints(field.type)) for field in schema ) - - -def _pairwise(iterable): - do_yield = False - a = None - b = None - for item in iterable: - a = b - b = item - if do_yield: - yield (a, b) - else: - do_yield = True diff --git a/tests/system/small/engines/test_read_local.py b/tests/system/small/engines/test_read_local.py index abdd29c4ac..257bddd917 100644 --- a/tests/system/small/engines/test_read_local.py +++ b/tests/system/small/engines/test_read_local.py @@ -88,8 +88,9 @@ def test_engines_read_local_w_zero_row_source( assert_equivalence_execution(local_node, REFERENCE_ENGINE, engine) -# TODO: Fix sqlglot impl -@pytest.mark.parametrize("engine", ["polars", "bq", "pyarrow"], indirect=True) +@pytest.mark.parametrize( + "engine", ["polars", "bq", "pyarrow", "bq-sqlglot"], indirect=True +) def test_engines_read_local_w_nested_source( fake_session: bigframes.Session, nested_data_source: local_data.ManagedArrowTable, diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_nested_structs_df/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_nested_structs_df/out.sql deleted file mode 100644 index 42b7bc7361..0000000000 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_nested_structs_df/out.sql +++ /dev/null @@ -1,19 +0,0 @@ -SELECT - * -FROM UNNEST(ARRAY>, `bfcol_2` INT64>>[( - 1, - STRUCT( - 'Alice' AS `name`, - 30 AS `age`, - STRUCT('New York' AS `city`, 'USA' AS `country`) AS `address` - ), - 0 -), ( - 2, - STRUCT( - 'Bob' AS `name`, - 25 AS `age`, - STRUCT('London' AS `city`, 'UK' AS `country`) AS `address` - ), - 1 -)]) \ No newline at end of file diff --git a/tests/unit/test_local_data.py b/tests/unit/test_local_data.py index dfd1cd622f..6f23036efb 100644 --- a/tests/unit/test_local_data.py +++ b/tests/unit/test_local_data.py @@ -20,20 +20,21 @@ pd_data = pd.DataFrame( { - "ints": [10, 20, 30, 40], - "nested_ints": [[1, 2], [3, 4, 5], [], [20, 30]], - "structs": [{"a": 100}, {}, {"b": 200}, {"b": 300}], + "ints": [10, 20, 30, 40, 50], + "nested_ints": [[1, 2], [], [3, 4, 5], [], [20, 30]], + "structs": [{"a": 100}, None, {}, {"b": 200}, {"b": 300}], } ) pd_data_normalized = pd.DataFrame( { - "ints": pd.Series([10, 20, 30, 40], dtype=dtypes.INT_DTYPE), + "ints": pd.Series([10, 20, 30, 40, 50], dtype=dtypes.INT_DTYPE), "nested_ints": pd.Series( - [[1, 2], [3, 4, 5], [], [20, 30]], dtype=pd.ArrowDtype(pa.list_(pa.int64())) + [[1, 2], [], [3, 4, 5], [], [20, 30]], + dtype=pd.ArrowDtype(pa.list_(pa.int64())), ), "structs": pd.Series( - [{"a": 100}, {}, {"b": 200}, {"b": 300}], + [{"a": 100}, None, {}, {"b": 200}, {"b": 300}], dtype=pd.ArrowDtype(pa.struct({"a": pa.int64(), "b": pa.int64()})), ), } @@ -122,11 +123,11 @@ def test_local_data_well_formed_round_trip_chunked(): def test_local_data_well_formed_round_trip_sliced(): pa_table = pa.Table.from_pandas(pd_data, preserve_index=False) - as_rechunked_pyarrow = pa.Table.from_batches(pa_table.slice(2, 4).to_batches()) + as_rechunked_pyarrow = pa.Table.from_batches(pa_table.slice(0, 4).to_batches()) local_entry = local_data.ManagedArrowTable.from_pyarrow(as_rechunked_pyarrow) result = pd.DataFrame(local_entry.itertuples(), columns=pd_data.columns) pandas.testing.assert_frame_equal( - pd_data_normalized[2:4].reset_index(drop=True), + pd_data_normalized[0:4].reset_index(drop=True), result.reset_index(drop=True), check_dtype=False, ) @@ -143,3 +144,25 @@ def test_local_data_not_equal_other(): local_entry2 = local_data.ManagedArrowTable.from_pandas(pd_data[::2]) assert local_entry != local_entry2 assert hash(local_entry) != hash(local_entry2) + + +def test_local_data_itertuples_struct_none(): + pd_data = pd.DataFrame( + { + "structs": [{"a": 100}, None, {"b": 200}, {"b": 300}], + } + ) + local_entry = local_data.ManagedArrowTable.from_pandas(pd_data) + result = list(local_entry.itertuples()) + assert result[1][0] is None + + +def test_local_data_itertuples_list_none(): + pd_data = pd.DataFrame( + { + "lists": [[1, 2], None, [3, 4]], + } + ) + local_entry = local_data.ManagedArrowTable.from_pandas(pd_data) + result = list(local_entry.itertuples()) + assert result[1][0] == [] From a2b97edc8fa79052d5720217208498a040c39c05 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Mon, 10 Nov 2025 09:46:59 -0800 Subject: [PATCH 214/313] chore: Migrate arctan2_op operator to SQLGlot (#2226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes b/447388852 🦕 --- .../compile/sqlglot/expressions/numeric_ops.py | 7 +++++++ .../test_numeric_ops/test_arctan2/out.sql | 17 +++++++++++++++++ .../sqlglot/expressions/test_numeric_ops.py | 14 ++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan2/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py index afc0d9d01c..b94ef6759d 100644 --- a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py @@ -77,6 +77,13 @@ def _(expr: TypedExpr) -> sge.Expression: return sge.func("ASINH", expr.expr) +@register_binary_op(ops.arctan2_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.func("ATAN2", left_expr, right_expr) + + @register_unary_op(ops.arctan_op) def _(expr: TypedExpr) -> sge.Expression: return sge.func("ATAN", expr.expr) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan2/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan2/out.sql new file mode 100644 index 0000000000..d131828a98 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan2/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `float64_col` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ATAN2(`bfcol_1`, `bfcol_2`) AS `bfcol_6`, + ATAN2(CAST(`bfcol_0` AS INT64), `bfcol_2`) AS `bfcol_7` + FROM `bfcte_0` +) +SELECT + `bfcol_6` AS `int64_col`, + `bfcol_7` AS `bool_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py index c66fe15c16..266ad1c938 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py @@ -55,6 +55,20 @@ def test_arcsinh(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_arctan2(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col", "bool_col"]] + + sql = utils._apply_ops_to_sql( + bf_df, + [ + ops.arctan2_op.as_expr("int64_col", "float64_col"), + ops.arctan2_op.as_expr("bool_col", "float64_col"), + ], + ["int64_col", "bool_col"], + ) + snapshot.assert_match(sql, "out.sql") + + def test_arctan(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] From 5bee573ba390da4a56fc8ab23788f3fa29c33f6d Mon Sep 17 00:00:00 2001 From: jialuoo Date: Mon, 10 Nov 2025 09:48:36 -0800 Subject: [PATCH 215/313] chore: Migrate geo_st_difference_op operator to SQLGlot (#2240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes b/447388852 🦕 --- .../core/compile/sqlglot/expressions/geo_ops.py | 6 ++++++ .../test_geo_ops/test_geo_st_difference/out.sql | 13 +++++++++++++ .../compile/sqlglot/expressions/test_geo_ops.py | 8 ++++++++ 3 files changed, 27 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_difference/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/geo_ops.py b/bigframes/core/compile/sqlglot/expressions/geo_ops.py index 4585a7f073..24e488699f 100644 --- a/bigframes/core/compile/sqlglot/expressions/geo_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/geo_ops.py @@ -21,6 +21,7 @@ import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op @register_unary_op(ops.geo_area_op) @@ -108,3 +109,8 @@ def _(expr: TypedExpr) -> sge.Expression: @register_unary_op(ops.geo_y_op) def _(expr: TypedExpr) -> sge.Expression: return sge.func("SAFE.ST_Y", expr.expr) + + +@register_binary_op(ops.geo_st_difference_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.func("ST_DIFFERENCE", left.expr, right.expr) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_difference/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_difference/out.sql new file mode 100644 index 0000000000..e57a15443d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_difference/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_DIFFERENCE(`bfcol_0`, `bfcol_0`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `geography_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py index 9b99b37fb6..847671b4b7 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py @@ -81,6 +81,14 @@ def test_geo_st_convexhull(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_geo_st_difference(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_binary_op(bf_df, ops.geo_st_difference_op, col_name, col_name) + + snapshot.assert_match(sql, "out.sql") + + def test_geo_st_geogfromtext(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] From 24ba4a11a1d0d3e883672e8cc6a9bebcb2f8e233 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Mon, 10 Nov 2025 09:50:08 -0800 Subject: [PATCH 216/313] chore: Migrate cosine_distance_op operator to SQLGlot (#2236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes b/447388852 🦕 --- .../compile/sqlglot/expressions/numeric_ops.py | 12 ++++++++++++ .../test_cosine_distance/out.sql | 16 ++++++++++++++++ .../sqlglot/expressions/test_numeric_ops.py | 15 +++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosine_distance/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py index b94ef6759d..36e2973565 100644 --- a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py @@ -125,6 +125,18 @@ def _(expr: TypedExpr) -> sge.Expression: ) +@register_binary_op(ops.cosine_distance_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.Anonymous( + this="ML.DISTANCE", + expressions=[ + left.expr, + right.expr, + sge.Literal.string("COSINE"), + ], + ) + + @register_unary_op(ops.exp_op) def _(expr: TypedExpr) -> sge.Expression: return sge.Case( diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosine_distance/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosine_distance/out.sql new file mode 100644 index 0000000000..eb46a16a83 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosine_distance/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `int_list_col` AS `bfcol_0`, + `float_list_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + ML.DISTANCE(`bfcol_0`, `bfcol_0`, 'COSINE') AS `bfcol_2`, + ML.DISTANCE(`bfcol_1`, `bfcol_1`, 'COSINE') AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `int_list_col`, + `bfcol_3` AS `float_list_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py index 266ad1c938..06731bcbfa 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py @@ -117,6 +117,21 @@ def test_cosh(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_cosine_distance(repeated_types_df: bpd.DataFrame, snapshot): + col_names = ["int_list_col", "float_list_col"] + bf_df = repeated_types_df[col_names] + + sql = utils._apply_ops_to_sql( + bf_df, + [ + ops.cosine_distance_op.as_expr("int_list_col", "int_list_col"), + ops.cosine_distance_op.as_expr("float_list_col", "float_list_col"), + ], + ["int_list_col", "float_list_col"], + ) + snapshot.assert_match(sql, "out.sql") + + def test_exp(scalar_types_df: bpd.DataFrame, snapshot): col_name = "float64_col" bf_df = scalar_types_df[[col_name]] From 0d7d7e49345cdcf6b5d69d3f9fa07533d84319b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Mon, 10 Nov 2025 11:55:32 -0600 Subject: [PATCH 217/313] chore: make labels tests more robust to test order (#2246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes internal issue b/459432106 🦕 --- .github/workflows/docs.yml | 3 ++ .github/workflows/lint.yml | 3 ++ .github/workflows/mypy.yml | 3 ++ .github/workflows/unittest.yml | 3 ++ owlbot.py | 3 +- tests/unit/session/test_io_bigquery.py | 52 ++++++++++++-------------- 6 files changed, 37 insertions(+), 30 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index fab2463145..6773aef7c2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,6 +2,9 @@ on: pull_request: branches: - main + push: + branches: + - main name: docs jobs: docs: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1051da0bdd..7914b72651 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,6 +2,9 @@ on: pull_request: branches: - main + push: + branches: + - main name: lint jobs: lint: diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index e6a79291d0..fc9e970946 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -2,6 +2,9 @@ on: pull_request: branches: - main + push: + branches: + - main name: mypy jobs: mypy: diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index a7805de447..518cec6312 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -2,6 +2,9 @@ on: pull_request: branches: - main + push: + branches: + - main name: unittest jobs: unit: diff --git a/owlbot.py b/owlbot.py index 33dd33a84f..4a189ff0e2 100644 --- a/owlbot.py +++ b/owlbot.py @@ -58,8 +58,9 @@ ".kokoro/build.sh", ".kokoro/continuous/common.cfg", ".kokoro/presubmit/common.cfg", - # Temporary workaround to update docs job to use python 3.10 ".github/workflows/docs.yml", + ".github/workflows/lint.yml", + ".github/workflows/unittest.yml", ], ) diff --git a/tests/unit/session/test_io_bigquery.py b/tests/unit/session/test_io_bigquery.py index 57ac3d88f7..41f3755f13 100644 --- a/tests/unit/session/test_io_bigquery.py +++ b/tests/unit/session/test_io_bigquery.py @@ -18,12 +18,15 @@ from unittest import mock import google.cloud.bigquery as bigquery +import google.cloud.bigquery.job +import google.cloud.bigquery.table import pytest import bigframes from bigframes.core import log_adapter import bigframes.core.events import bigframes.pandas as bpd +import bigframes.session._io.bigquery import bigframes.session._io.bigquery as io_bq from bigframes.testing import mocks @@ -32,7 +35,7 @@ def mock_bq_client(): mock_client = mock.create_autospec(bigquery.Client) mock_query_job = mock.create_autospec(bigquery.QueryJob) - mock_row_iterator = mock.create_autospec(bigquery.table.RowIterator) + mock_row_iterator = mock.create_autospec(google.cloud.bigquery.table.RowIterator) mock_query_job.result.return_value = mock_row_iterator @@ -98,14 +101,12 @@ def test_create_job_configs_labels_log_adaptor_call_method_under_length_limit(): cur_labels = { "source": "bigquery-dataframes-temp", } - df = bpd.DataFrame( - {"col1": [1, 2], "col2": [3, 4]}, session=mocks.create_bigquery_session() - ) - # Test running two methods - df.head() - df.max() - df.columns - api_methods = log_adapter._api_methods + api_methods = [ + "dataframe-columns", + "dataframe-max", + "dataframe-head", + "dataframe-__init__", + ] labels = io_bq.create_job_configs_labels( job_configs_labels=cur_labels, api_methods=api_methods @@ -123,17 +124,13 @@ def test_create_job_configs_labels_log_adaptor_call_method_under_length_limit(): def test_create_job_configs_labels_length_limit_met_and_labels_is_none(): log_adapter.get_and_reset_api_methods() - df = bpd.DataFrame( - {"col1": [1, 2], "col2": [3, 4]}, session=mocks.create_bigquery_session() - ) # Test running methods more than the labels' length limit - for i in range(100): - df.head() - api_methods = log_adapter._api_methods + api_methods = list(["dataframe-head"] * 100) - labels = io_bq.create_job_configs_labels( - job_configs_labels=None, api_methods=api_methods - ) + with bpd.option_context("compute.extra_query_labels", {}): + labels = io_bq.create_job_configs_labels( + job_configs_labels=None, api_methods=api_methods + ) assert labels is not None assert len(labels) == log_adapter.MAX_LABELS_COUNT assert "dataframe-head" in labels.values() @@ -150,17 +147,14 @@ def test_create_job_configs_labels_length_limit_met(): value = f"test{i}" cur_labels[key] = value # If cur_labels length is 62, we can only add one label from api_methods - df = bpd.DataFrame( - {"col1": [1, 2], "col2": [3, 4]}, session=mocks.create_bigquery_session() - ) # Test running two methods - df.head() - df.max() - api_methods = log_adapter._api_methods + api_methods = ["dataframe-max", "dataframe-head"] + + with bpd.option_context("compute.extra_query_labels", {}): + labels = io_bq.create_job_configs_labels( + job_configs_labels=cur_labels, api_methods=api_methods + ) - labels = io_bq.create_job_configs_labels( - job_configs_labels=cur_labels, api_methods=api_methods - ) assert labels is not None assert len(labels) == 56 assert "dataframe-max" in labels.values() @@ -184,7 +178,7 @@ def test_add_and_trim_labels_length_limit_met(): {"col1": [1, 2], "col2": [3, 4]}, session=mocks.create_bigquery_session() ) - job_config = bigquery.job.QueryJobConfig() + job_config = google.cloud.bigquery.job.QueryJobConfig() job_config.labels = cur_labels df.max() @@ -221,7 +215,7 @@ def test_start_query_with_client_labels_length_limit_met( {"col1": [1, 2], "col2": [3, 4]}, session=mocks.create_bigquery_session() ) - job_config = bigquery.job.QueryJobConfig() + job_config = google.cloud.bigquery.job.QueryJobConfig() job_config.labels = cur_labels df.max() From d2d38f94ed8333eae6f9cff3833177756eefe85a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:22:33 -0600 Subject: [PATCH 218/313] feat: SQL Cell no longer escapes formatted string values (#2245) This change updates bigframes/core/pyformat.py to directly embed str values rather than escaping them first. Unit tests have been updated to reflect this change. --- *PR created automatically by Jules for task [5189763854933796072](https://jules.google.com/task/5189763854933796072)* --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Shenyang Cai --- bigframes/core/pyformat.py | 3 +++ tests/system/small/session/test_read_gbq_colab.py | 6 +++--- tests/unit/core/test_pyformat.py | 12 +++++------- tests/unit/pandas/io/test_api.py | 2 +- tests/unit/session/test_read_gbq_colab.py | 4 ++-- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/bigframes/core/pyformat.py b/bigframes/core/pyformat.py index eab86dc629..8f49556ff4 100644 --- a/bigframes/core/pyformat.py +++ b/bigframes/core/pyformat.py @@ -104,6 +104,9 @@ def _field_to_template_value( if isinstance(value, bigframes.dataframe.DataFrame): return _table_to_sql(value._to_placeholder_table(dry_run=dry_run)) + if isinstance(value, str): + return value + return bigframes.core.sql.simple_literal(value) diff --git a/tests/system/small/session/test_read_gbq_colab.py b/tests/system/small/session/test_read_gbq_colab.py index 6d3cf6fe88..65f47fe4e3 100644 --- a/tests/system/small/session/test_read_gbq_colab.py +++ b/tests/system/small/session/test_read_gbq_colab.py @@ -143,7 +143,7 @@ def test_read_gbq_colab_repr_avoids_requery(maybe_ordered_session): def test_read_gbq_colab_includes_formatted_scalars(session): pyformat_args = { "some_integer": 123, - "some_string": "This could be dangerous, but we escape it", + "some_string": "This could be dangerous.", # This is not a supported type, but ignored if not referenced. "some_object": object(), } @@ -153,7 +153,7 @@ def test_read_gbq_colab_includes_formatted_scalars(session): df = session._read_gbq_colab( """ SELECT {some_integer} as some_integer, - {some_string} as some_string, + '{some_string}' as some_string, '{{escaped}}' as escaped """, pyformat_args=pyformat_args, @@ -165,7 +165,7 @@ def test_read_gbq_colab_includes_formatted_scalars(session): { "some_integer": pandas.Series([123], dtype=pandas.Int64Dtype()), "some_string": pandas.Series( - ["This could be dangerous, but we escape it"], + ["This could be dangerous."], dtype="string[pyarrow]", ), "escaped": pandas.Series(["{escaped}"], dtype="string[pyarrow]"), diff --git a/tests/unit/core/test_pyformat.py b/tests/unit/core/test_pyformat.py index 447ce37766..db7cedba8f 100644 --- a/tests/unit/core/test_pyformat.py +++ b/tests/unit/core/test_pyformat.py @@ -444,7 +444,7 @@ def test_pyformat_with_pandas_dataframe_not_dry_run_no_session_raises_valueerror def test_pyformat_with_query_string_replaces_variables(session): pyformat_args = { - "my_string": "some string value", + "my_string": "`my_table`", "max_value": 2.25, "year": 2025, "null_value": None, @@ -456,9 +456,8 @@ def test_pyformat_with_query_string_replaces_variables(session): SELECT {year} - year AS age, @myparam AS myparam, '{{my_string}}' AS escaped_string, - {my_string} AS my_string, - {null_value} AS null_value, - FROM my_dataset.my_table + * + FROM {my_string} WHERE height < {max_value} """.strip() @@ -466,9 +465,8 @@ def test_pyformat_with_query_string_replaces_variables(session): SELECT 2025 - year AS age, @myparam AS myparam, '{my_string}' AS escaped_string, - 'some string value' AS my_string, - NULL AS null_value, - FROM my_dataset.my_table + * + FROM `my_table` WHERE height < 2.25 """.strip() diff --git a/tests/unit/pandas/io/test_api.py b/tests/unit/pandas/io/test_api.py index 14419236c9..dbdf427d91 100644 --- a/tests/unit/pandas/io/test_api.py +++ b/tests/unit/pandas/io/test_api.py @@ -108,7 +108,7 @@ def test_read_gbq_colab_calls_set_location( mock_with_default_session.return_value = mock_df query_or_table = "SELECT {param1} AS param1" - sample_pyformat_args = {"param1": "value1"} + sample_pyformat_args = {"param1": "'value1'"} result = bf_io_api._read_gbq_colab( query_or_table, pyformat_args=sample_pyformat_args, dry_run=False ) diff --git a/tests/unit/session/test_read_gbq_colab.py b/tests/unit/session/test_read_gbq_colab.py index 52b091c045..b1dc1ec702 100644 --- a/tests/unit/session/test_read_gbq_colab.py +++ b/tests/unit/session/test_read_gbq_colab.py @@ -60,7 +60,7 @@ def test_read_gbq_colab_includes_formatted_values_in_dry_run(monkeypatch, dry_ru pyformat_args = { "some_integer": 123, - "some_string": "This could be dangerous, but we escape it", + "some_string": "some_column", "bf_df": bf_df, "pd_df": pd_df, # This is not a supported type, but ignored if not referenced. @@ -84,7 +84,7 @@ def test_read_gbq_colab_includes_formatted_values_in_dry_run(monkeypatch, dry_ru expected = textwrap.dedent( f""" SELECT 123 as some_integer, - 'This could be dangerous, but we escape it' as some_string, + some_column as some_string, '{{escaped}}' as escaped FROM `proj`.`dset`.`temp_{"table" if dry_run else "view"}` AS bf_df FULL OUTER JOIN `proj`.`dset`.`temp_{"table" if dry_run else "view"}` AS pd_df From 44e9869d3689468278f2f2803176ce68360e1255 Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 13:12:27 -0800 Subject: [PATCH 219/313] chore(main): release 2.29.0 (#2242) :robot: I have created a release *beep* *boop* --- ## [2.29.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.28.0...v2.29.0) (2025-11-10) ### Features * Add bigframes.bigquery.st_regionstats to join raster data from Earth Engine ([#2228](https://github.com/googleapis/python-bigquery-dataframes/issues/2228)) ([10ec52f](https://github.com/googleapis/python-bigquery-dataframes/commit/10ec52f30a0a9c61b9eda9cf4f9bd6aa0cd95db5)) * Add DataFrame.resample and Series.resample ([#2213](https://github.com/googleapis/python-bigquery-dataframes/issues/2213)) ([c9ca02c](https://github.com/googleapis/python-bigquery-dataframes/commit/c9ca02c5194c8b8e9b940eddd2224efd2ff0d5d9)) * SQL Cell no longer escapes formatted string values ([#2245](https://github.com/googleapis/python-bigquery-dataframes/issues/2245)) ([d2d38f9](https://github.com/googleapis/python-bigquery-dataframes/commit/d2d38f94ed8333eae6f9cff3833177756eefe85a)) * Support left_index and right_index for merge ([#2220](https://github.com/googleapis/python-bigquery-dataframes/issues/2220)) ([da9ba26](https://github.com/googleapis/python-bigquery-dataframes/commit/da9ba267812c01ffa6fa0b09943d7a4c63b8f187)) ### Bug Fixes * Correctly iterate over null struct values in ManagedArrowTable ([#2209](https://github.com/googleapis/python-bigquery-dataframes/issues/2209)) ([12e04d5](https://github.com/googleapis/python-bigquery-dataframes/commit/12e04d55f0d6aef1297b7ca773935aecf3313ee7)) * Simplify UnsupportedTypeError message ([#2212](https://github.com/googleapis/python-bigquery-dataframes/issues/2212)) ([6c9a18d](https://github.com/googleapis/python-bigquery-dataframes/commit/6c9a18d7e67841c6fe6c1c6f34f80b950815141f)) * Support results with STRUCT and ARRAY columns containing JSON subfields in `to_pandas_batches()` ([#2216](https://github.com/googleapis/python-bigquery-dataframes/issues/2216)) ([3d8b17f](https://github.com/googleapis/python-bigquery-dataframes/commit/3d8b17fa5eb9bbfc9e151031141a419f2dc3acb4)) ### Documentation * Switch API reference docs to pydata theme ([#2237](https://github.com/googleapis/python-bigquery-dataframes/issues/2237)) ([9b86dcf](https://github.com/googleapis/python-bigquery-dataframes/commit/9b86dcf87929648bf5ab565dfd46a23b639f01ac)) * Update notebook for JSON subfields support in to_pandas_batches() ([#2138](https://github.com/googleapis/python-bigquery-dataframes/issues/2138)) ([5663d2a](https://github.com/googleapis/python-bigquery-dataframes/commit/5663d2a18064589596558af109e915f87d426eb0)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 23 +++++++++++++++++++++++ bigframes/version.py | 4 ++-- third_party/bigframes_vendored/version.py | 4 ++-- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1df3ad0f70..7a87fc0160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ [1]: https://pypi.org/project/bigframes/#history +## [2.29.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.28.0...v2.29.0) (2025-11-10) + + +### Features + +* Add bigframes.bigquery.st_regionstats to join raster data from Earth Engine ([#2228](https://github.com/googleapis/python-bigquery-dataframes/issues/2228)) ([10ec52f](https://github.com/googleapis/python-bigquery-dataframes/commit/10ec52f30a0a9c61b9eda9cf4f9bd6aa0cd95db5)) +* Add DataFrame.resample and Series.resample ([#2213](https://github.com/googleapis/python-bigquery-dataframes/issues/2213)) ([c9ca02c](https://github.com/googleapis/python-bigquery-dataframes/commit/c9ca02c5194c8b8e9b940eddd2224efd2ff0d5d9)) +* SQL Cell no longer escapes formatted string values ([#2245](https://github.com/googleapis/python-bigquery-dataframes/issues/2245)) ([d2d38f9](https://github.com/googleapis/python-bigquery-dataframes/commit/d2d38f94ed8333eae6f9cff3833177756eefe85a)) +* Support left_index and right_index for merge ([#2220](https://github.com/googleapis/python-bigquery-dataframes/issues/2220)) ([da9ba26](https://github.com/googleapis/python-bigquery-dataframes/commit/da9ba267812c01ffa6fa0b09943d7a4c63b8f187)) + + +### Bug Fixes + +* Correctly iterate over null struct values in ManagedArrowTable ([#2209](https://github.com/googleapis/python-bigquery-dataframes/issues/2209)) ([12e04d5](https://github.com/googleapis/python-bigquery-dataframes/commit/12e04d55f0d6aef1297b7ca773935aecf3313ee7)) +* Simplify UnsupportedTypeError message ([#2212](https://github.com/googleapis/python-bigquery-dataframes/issues/2212)) ([6c9a18d](https://github.com/googleapis/python-bigquery-dataframes/commit/6c9a18d7e67841c6fe6c1c6f34f80b950815141f)) +* Support results with STRUCT and ARRAY columns containing JSON subfields in `to_pandas_batches()` ([#2216](https://github.com/googleapis/python-bigquery-dataframes/issues/2216)) ([3d8b17f](https://github.com/googleapis/python-bigquery-dataframes/commit/3d8b17fa5eb9bbfc9e151031141a419f2dc3acb4)) + + +### Documentation + +* Switch API reference docs to pydata theme ([#2237](https://github.com/googleapis/python-bigquery-dataframes/issues/2237)) ([9b86dcf](https://github.com/googleapis/python-bigquery-dataframes/commit/9b86dcf87929648bf5ab565dfd46a23b639f01ac)) +* Update notebook for JSON subfields support in to_pandas_batches() ([#2138](https://github.com/googleapis/python-bigquery-dataframes/issues/2138)) ([5663d2a](https://github.com/googleapis/python-bigquery-dataframes/commit/5663d2a18064589596558af109e915f87d426eb0)) + ## [2.28.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.27.0...v2.28.0) (2025-11-03) diff --git a/bigframes/version.py b/bigframes/version.py index cf7562a306..a129daf092 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.28.0" +__version__ = "2.29.0" # {x-release-please-start-date} -__release_date__ = "2025-11-03" +__release_date__ = "2025-11-10" # {x-release-please-end} diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index cf7562a306..a129daf092 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.28.0" +__version__ = "2.29.0" # {x-release-please-start-date} -__release_date__ = "2025-11-03" +__release_date__ = "2025-11-10" # {x-release-please-end} From c62e5535ed4c19b6d65f9a46cb1531e8099621b2 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Mon, 10 Nov 2025 13:37:50 -0800 Subject: [PATCH 220/313] feat: Add bigframes.pandas.crosstab (#2231) --- bigframes/core/reshape/api.py | 3 +- bigframes/core/reshape/pivot.py | 89 +++++++++++++++++++ bigframes/dataframe.py | 31 ++++++- bigframes/pandas/__init__.py | 3 +- bigframes/session/__init__.py | 15 ++++ tests/system/small/test_pandas.py | 66 ++++++++++++++ .../pandas/core/reshape/pivot.py | 57 ++++++++++++ 7 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 bigframes/core/reshape/pivot.py create mode 100644 third_party/bigframes_vendored/pandas/core/reshape/pivot.py diff --git a/bigframes/core/reshape/api.py b/bigframes/core/reshape/api.py index 56dbdae77e..adb33427f9 100644 --- a/bigframes/core/reshape/api.py +++ b/bigframes/core/reshape/api.py @@ -15,6 +15,7 @@ from bigframes.core.reshape.concat import concat from bigframes.core.reshape.encoding import get_dummies from bigframes.core.reshape.merge import merge +from bigframes.core.reshape.pivot import crosstab from bigframes.core.reshape.tile import cut, qcut -__all__ = ["concat", "get_dummies", "merge", "cut", "qcut"] +__all__ = ["concat", "get_dummies", "merge", "cut", "qcut", "crosstab"] diff --git a/bigframes/core/reshape/pivot.py b/bigframes/core/reshape/pivot.py new file mode 100644 index 0000000000..8b83cb0fc7 --- /dev/null +++ b/bigframes/core/reshape/pivot.py @@ -0,0 +1,89 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +import bigframes_vendored.pandas.core.reshape.pivot as vendored_pandas_pivot +import pandas as pd + +import bigframes +from bigframes.core import convert, utils +from bigframes.core.reshape import concat +from bigframes.dataframe import DataFrame + +if TYPE_CHECKING: + import bigframes.session + + +def crosstab( + index, + columns, + values=None, + rownames=None, + colnames=None, + aggfunc=None, + *, + session: Optional[bigframes.session.Session] = None, +) -> DataFrame: + if _is_list_of_lists(index): + index = [ + convert.to_bf_series(subindex, default_index=None, session=session) + for subindex in index + ] + else: + index = [convert.to_bf_series(index, default_index=None, session=session)] + if _is_list_of_lists(columns): + columns = [ + convert.to_bf_series(subcol, default_index=None, session=session) + for subcol in columns + ] + else: + columns = [convert.to_bf_series(columns, default_index=None, session=session)] + + df = concat.concat([*index, *columns], join="inner", axis=1) + # for uniqueness + tmp_index_names = [f"_crosstab_index_{i}" for i in range(len(index))] + tmp_col_names = [f"_crosstab_columns_{i}" for i in range(len(columns))] + df.columns = pd.Index([*tmp_index_names, *tmp_col_names]) + + values = ( + convert.to_bf_series(values, default_index=df.index, session=session) + if values is not None + else 0 + ) + + df["_crosstab_values"] = values + pivot_table = df.pivot_table( + values="_crosstab_values", + index=tmp_index_names, + columns=tmp_col_names, + aggfunc=aggfunc or "count", + sort=False, + ) + pivot_table.index.names = rownames or [i.name for i in index] + pivot_table.columns.names = colnames or [c.name for c in columns] + if aggfunc is None: + # TODO: Push this into pivot_table itself + pivot_table = pivot_table.fillna(0) + return pivot_table + + +def _is_list_of_lists(item) -> bool: + if not utils.is_list_like(item): + return False + return all(convert.can_convert_to_series(subitem) for subitem in item) + + +crosstab.__doc__ = vendored_pandas_pivot.crosstab.__doc__ diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 0ce602d1ea..da6da7a925 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -3479,7 +3479,34 @@ def pivot_table( ] = None, columns: typing.Union[blocks.Label, Sequence[blocks.Label]] = None, aggfunc: str = "mean", + fill_value=None, + margins: bool = False, + dropna: bool = True, + margins_name: Hashable = "All", + observed: bool = False, + sort: bool = True, ) -> DataFrame: + if fill_value is not None: + raise NotImplementedError( + "DataFrame.pivot_table fill_value arg not supported. {constants.FEEDBACK_LINK}" + ) + if margins: + raise NotImplementedError( + "DataFrame.pivot_table margins arg not supported. {constants.FEEDBACK_LINK}" + ) + if not dropna: + raise NotImplementedError( + "DataFrame.pivot_table dropna arg not supported. {constants.FEEDBACK_LINK}" + ) + if margins_name != "All": + raise NotImplementedError( + "DataFrame.pivot_table margins_name arg not supported. {constants.FEEDBACK_LINK}" + ) + if observed: + raise NotImplementedError( + "DataFrame.pivot_table observed arg not supported. {constants.FEEDBACK_LINK}" + ) + if isinstance(index, Iterable) and not ( isinstance(index, blocks.Label) and index in self.columns ): @@ -3521,7 +3548,9 @@ def pivot_table( columns=columns, index=index, values=values if len(values) > 1 else None, - ).sort_index() + ) + if sort: + pivoted = pivoted.sort_index() # TODO: Remove the reordering step once the issue is resolved. # The pivot_table method results in multi-index columns that are always ordered. diff --git a/bigframes/pandas/__init__.py b/bigframes/pandas/__init__.py index 6fcb71f0d8..7b633f6dc8 100644 --- a/bigframes/pandas/__init__.py +++ b/bigframes/pandas/__init__.py @@ -31,7 +31,7 @@ import bigframes.core.blocks import bigframes.core.global_session as global_session import bigframes.core.indexes -from bigframes.core.reshape.api import concat, cut, get_dummies, merge, qcut +from bigframes.core.reshape.api import concat, crosstab, cut, get_dummies, merge, qcut import bigframes.core.tools import bigframes.dataframe import bigframes.enums @@ -372,6 +372,7 @@ def reset_session(): _functions = [ clean_up_by_session_id, concat, + crosstab, cut, deploy_remote_function, deploy_udf, diff --git a/bigframes/session/__init__.py b/bigframes/session/__init__.py index 6418f2b78f..3cb9d2bb68 100644 --- a/bigframes/session/__init__.py +++ b/bigframes/session/__init__.py @@ -2312,6 +2312,21 @@ def cut(self, *args, **kwargs) -> bigframes.series.Series: **kwargs, ) + def crosstab(self, *args, **kwargs) -> dataframe.DataFrame: + """Compute a simple cross tabulation of two (or more) factors. + + Included for compatibility between bpd and Session. + + See :func:`bigframes.pandas.crosstab` for full documentation. + """ + import bigframes.core.reshape.pivot + + return bigframes.core.reshape.pivot.crosstab( + *args, + session=self, + **kwargs, + ) + def DataFrame(self, *args, **kwargs): """Constructs a DataFrame. diff --git a/tests/system/small/test_pandas.py b/tests/system/small/test_pandas.py index 2f4ddaecff..e3c5ace8a9 100644 --- a/tests/system/small/test_pandas.py +++ b/tests/system/small/test_pandas.py @@ -450,6 +450,72 @@ def test_merge_raises_error_when_left_right_on_set(scalars_dfs): ) +def test_crosstab_aligned_series(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + pd_result = pd.crosstab( + scalars_pandas_df["int64_col"], scalars_pandas_df["int64_too"] + ) + bf_result = bpd.crosstab( + scalars_df["int64_col"], scalars_df["int64_too"] + ).to_pandas() + + assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + + +def test_crosstab_nondefault_func(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + pd_result = pd.crosstab( + scalars_pandas_df["int64_col"], + scalars_pandas_df["int64_too"], + values=scalars_pandas_df["float64_col"], + aggfunc="mean", + ) + bf_result = bpd.crosstab( + scalars_df["int64_col"], + scalars_df["int64_too"], + values=scalars_df["float64_col"], + aggfunc="mean", + ).to_pandas() + + assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + + +def test_crosstab_multi_cols(scalars_dfs): + scalars_df, scalars_pandas_df = scalars_dfs + + pd_result = pd.crosstab( + [scalars_pandas_df["int64_col"], scalars_pandas_df["bool_col"]], + [scalars_pandas_df["int64_too"], scalars_pandas_df["string_col"]], + rownames=["a", "b"], + colnames=["c", "d"], + ) + bf_result = bpd.crosstab( + [scalars_df["int64_col"], scalars_df["bool_col"]], + [scalars_df["int64_too"], scalars_df["string_col"]], + rownames=["a", "b"], + colnames=["c", "d"], + ).to_pandas() + + assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + + +def test_crosstab_unaligned_series(scalars_dfs, session): + scalars_df, scalars_pandas_df = scalars_dfs + other_pd_series = pd.Series( + [10, 20, 10, 30, 10], index=[5, 4, 1, 2, 3], dtype="Int64", name="nums" + ) + other_bf_series = session.Series( + [10, 20, 10, 30, 10], index=[5, 4, 1, 2, 3], name="nums" + ) + + pd_result = pd.crosstab(scalars_pandas_df["int64_col"], other_pd_series) + bf_result = bpd.crosstab(scalars_df["int64_col"], other_bf_series).to_pandas() + + assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + + def _convert_pandas_category(pd_s: pd.Series): """ Transforms a pandas Series with Categorical dtype into a bigframes-compatible diff --git a/third_party/bigframes_vendored/pandas/core/reshape/pivot.py b/third_party/bigframes_vendored/pandas/core/reshape/pivot.py new file mode 100644 index 0000000000..8cc33525a4 --- /dev/null +++ b/third_party/bigframes_vendored/pandas/core/reshape/pivot.py @@ -0,0 +1,57 @@ +# Contains code from https://github.com/pandas-dev/pandas/blob/main/pandas/core/reshape/pivot.py +from __future__ import annotations + +from bigframes import constants + + +def crosstab( + index, + columns, + values=None, + rownames=None, + colnames=None, + aggfunc=None, +): + """ + Compute a simple cross tabulation of two (or more) factors. + + By default, computes a frequency table of the factors unless an + array of values and an aggregation function are passed. + + **Examples:** + >>> a = np.array(["foo", "foo", "foo", "foo", "bar", "bar", + ... "bar", "bar", "foo", "foo", "foo"], dtype=object) + >>> b = np.array(["one", "one", "one", "two", "one", "one", + ... "one", "two", "two", "two", "one"], dtype=object) + >>> c = np.array(["dull", "dull", "shiny", "dull", "dull", "shiny", + ... "shiny", "dull", "shiny", "shiny", "shiny"], + ... dtype=object) + >>> bpd.crosstab(a, [b, c], rownames=['a'], colnames=['b', 'c']) + b one two + c dull shiny dull shiny + a + bar 1 2 1 0 + foo 2 2 1 2 + + [2 rows x 4 columns] + + Args: + index (array-like, Series, or list of arrays/Series): + Values to group by in the rows. + columns (array-like, Series, or list of arrays/Series): + Values to group by in the columns. + values (array-like, optional): + Array of values to aggregate according to the factors. + Requires `aggfunc` be specified. + rownames (sequence, default None): + If passed, must match number of row arrays passed. + colnames (sequence, default None): + If passed, must match number of column arrays passed. + aggfunc (function, optional): + If specified, requires `values` be specified as well. + + Returns: + DataFrame: + Cross tabulation of the data. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) From cc2dbae684103a21fe8838468f7eb8267188780d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Mon, 10 Nov 2025 16:47:26 -0600 Subject: [PATCH 221/313] fix: do not warn with DefaultIndexWarning in partial ordering mode (#2230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes internal issue b/356872356 🦕 --- .../session/_io/bigquery/read_gbq_table.py | 7 ++- bigframes/session/loader.py | 1 + tests/unit/session/test_read_gbq_table.py | 43 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/bigframes/session/_io/bigquery/read_gbq_table.py b/bigframes/session/_io/bigquery/read_gbq_table.py index 465fa08187..e12fe502c0 100644 --- a/bigframes/session/_io/bigquery/read_gbq_table.py +++ b/bigframes/session/_io/bigquery/read_gbq_table.py @@ -402,6 +402,7 @@ def get_index_cols( | bigframes.enums.DefaultIndexKind, *, rename_to_schema: Optional[Dict[str, str]] = None, + default_index_type: bigframes.enums.DefaultIndexKind = bigframes.enums.DefaultIndexKind.SEQUENTIAL_INT64, ) -> List[str]: """ If we can get a total ordering from the table, such as via primary key @@ -471,7 +472,11 @@ def get_index_cols( # find index_cols to use. This is to avoid unexpected performance and # resource utilization because of the default sequential index. See # internal issue 335727141. - if _is_table_clustered_or_partitioned(table) and not primary_keys: + if ( + _is_table_clustered_or_partitioned(table) + and not primary_keys + and default_index_type == bigframes.enums.DefaultIndexKind.SEQUENTIAL_INT64 + ): msg = bfe.format_message( f"Table '{str(table.reference)}' is clustered and/or " "partitioned, but BigQuery DataFrames was not able to find a " diff --git a/bigframes/session/loader.py b/bigframes/session/loader.py index 4e67eac9ae..1bebd460a9 100644 --- a/bigframes/session/loader.py +++ b/bigframes/session/loader.py @@ -696,6 +696,7 @@ def read_gbq_table( table=table, index_col=index_col, rename_to_schema=rename_to_schema, + default_index_type=self._default_index_type, ) _check_index_col_param( index_cols, diff --git a/tests/unit/session/test_read_gbq_table.py b/tests/unit/session/test_read_gbq_table.py index d21f0000a9..ce9b587d6b 100644 --- a/tests/unit/session/test_read_gbq_table.py +++ b/tests/unit/session/test_read_gbq_table.py @@ -15,10 +15,13 @@ """Unit tests for read_gbq_table helper functions.""" import unittest.mock as mock +import warnings import google.cloud.bigquery import pytest +import bigframes.enums +import bigframes.exceptions import bigframes.session._io.bigquery.read_gbq_table as bf_read_gbq_table from bigframes.testing import mocks @@ -143,3 +146,43 @@ def test_check_if_index_columns_are_unique(index_cols, values_distinct, expected ) assert result == expected + + +def test_get_index_cols_warns_if_clustered_but_sequential_index(): + table = google.cloud.bigquery.Table.from_api_repr( + { + "tableReference": { + "projectId": "my-project", + "datasetId": "my_dataset", + "tableId": "my_table", + }, + "clustering": { + "fields": ["col1", "col2"], + }, + }, + ) + table.schema = ( + google.cloud.bigquery.SchemaField("col1", "INT64"), + google.cloud.bigquery.SchemaField("col2", "INT64"), + google.cloud.bigquery.SchemaField("col3", "INT64"), + google.cloud.bigquery.SchemaField("col4", "INT64"), + ) + + with pytest.warns(bigframes.exceptions.DefaultIndexWarning, match="is clustered"): + bf_read_gbq_table.get_index_cols( + table, + index_col=(), + default_index_type=bigframes.enums.DefaultIndexKind.SEQUENTIAL_INT64, + ) + + # Ensure that we don't raise if using a NULL index by default, such as in + # partial ordering mode. See: internal issue b/356872356. + with warnings.catch_warnings(): + warnings.simplefilter( + "error", category=bigframes.exceptions.DefaultIndexWarning + ) + bf_read_gbq_table.get_index_cols( + table, + index_col=(), + default_index_type=bigframes.enums.DefaultIndexKind.NULL, + ) From 2e77861f332ef5902a796bc88fe8d64b17be9f57 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Mon, 10 Nov 2025 15:57:42 -0800 Subject: [PATCH 222/313] refactor: make sqlglot compile_sql a top-level function (#2244) This change prepares to enable SQLGlot as the default compiler. Making compile_sql a top-level function makes it consistent with the existing Ibis API, which simplifies onboarding. --- bigframes/core/compile/sqlglot/__init__.py | 4 +- bigframes/core/compile/sqlglot/compiler.py | 645 +++++++++--------- bigframes/session/direct_gbq_execution.py | 4 +- bigframes/testing/compiler_session.py | 10 +- .../sqlglot/test_compile_random_sample.py | 4 +- tests/unit/session/test_io_bigquery.py | 1 - 6 files changed, 320 insertions(+), 348 deletions(-) diff --git a/bigframes/core/compile/sqlglot/__init__.py b/bigframes/core/compile/sqlglot/__init__.py index 4ceb4118cd..9e3f123807 100644 --- a/bigframes/core/compile/sqlglot/__init__.py +++ b/bigframes/core/compile/sqlglot/__init__.py @@ -13,7 +13,7 @@ # limitations under the License. from __future__ import annotations -from bigframes.core.compile.sqlglot.compiler import SQLGlotCompiler +from bigframes.core.compile.sqlglot.compiler import compile_sql import bigframes.core.compile.sqlglot.expressions.ai_ops # noqa: F401 import bigframes.core.compile.sqlglot.expressions.array_ops # noqa: F401 import bigframes.core.compile.sqlglot.expressions.blob_ops # noqa: F401 @@ -29,4 +29,4 @@ import bigframes.core.compile.sqlglot.expressions.struct_ops # noqa: F401 import bigframes.core.compile.sqlglot.expressions.timedelta_ops # noqa: F401 -__all__ = ["SQLGlotCompiler"] +__all__ = ["compile_sql"] diff --git a/bigframes/core/compile/sqlglot/compiler.py b/bigframes/core/compile/sqlglot/compiler.py index 47ad8db21b..7dc8d4bec0 100644 --- a/bigframes/core/compile/sqlglot/compiler.py +++ b/bigframes/core/compile/sqlglot/compiler.py @@ -17,7 +17,6 @@ import functools import typing -from google.cloud import bigquery import sqlglot.expressions as sge from bigframes.core import expression, guid, identifiers, nodes, pyarrow_utils, rewrite @@ -31,371 +30,347 @@ from bigframes.core.rewrite import schema_binding -class SQLGlotCompiler: - """Compiles BigFrame nodes into SQL using SQLGlot.""" - - uid_gen: guid.SequentialUIDGenerator - """Generator for unique identifiers.""" - - def __init__(self): - self.uid_gen = guid.SequentialUIDGenerator() - - def compile( - self, - node: nodes.BigFrameNode, - *, - ordered: bool = True, - limit: typing.Optional[int] = None, - ) -> str: - """Compiles node into sql where rows are sorted with ORDER BY.""" - request = configs.CompileRequest(node, sort_rows=ordered, peek_count=limit) - return self._compile_sql(request).sql - - def compile_raw( - self, - node: nodes.BigFrameNode, - ) -> typing.Tuple[ - str, typing.Sequence[bigquery.SchemaField], bf_ordering.RowOrdering - ]: - """Compiles node into sql that exposes all columns, including hidden - ordering-only columns.""" - request = configs.CompileRequest( - node, sort_rows=False, materialize_all_order_keys=True - ) - result = self._compile_sql(request) - assert result.row_order is not None - return result.sql, result.sql_schema, result.row_order - - def _compile_sql(self, request: configs.CompileRequest) -> configs.CompileResult: - output_names = tuple( - (expression.DerefOp(id), id.sql) for id in request.node.ids - ) - result_node = nodes.ResultNode( - request.node, - output_cols=output_names, - limit=request.peek_count, - ) - if request.sort_rows: - # Can only pullup slice if we are doing ORDER BY in outermost SELECT - # Need to do this before replacing unsupported ops, as that will rewrite slice ops - result_node = rewrite.pull_up_limits(result_node) - result_node = _replace_unsupported_ops(result_node) - # prune before pulling up order to avoid unnnecessary row_number() ops - result_node = typing.cast(nodes.ResultNode, rewrite.column_pruning(result_node)) - result_node = rewrite.defer_order( - result_node, output_hidden_row_keys=request.materialize_all_order_keys - ) - if request.sort_rows: - result_node = typing.cast( - nodes.ResultNode, rewrite.column_pruning(result_node) - ) - result_node = self._remap_variables(result_node) - result_node = typing.cast( - nodes.ResultNode, rewrite.defer_selection(result_node) - ) - sql = self._compile_result_node(result_node) - return configs.CompileResult( - sql, result_node.schema.to_bigquery(), result_node.order_by - ) - - ordering: typing.Optional[bf_ordering.RowOrdering] = result_node.order_by - result_node = dataclasses.replace(result_node, order_by=None) +def compile_sql(request: configs.CompileRequest) -> configs.CompileResult: + """Compiles a BigFrameNode according to the request into SQL using SQLGlot.""" + + # Generator for unique identifiers. + uid_gen = guid.SequentialUIDGenerator() + output_names = tuple((expression.DerefOp(id), id.sql) for id in request.node.ids) + result_node = nodes.ResultNode( + request.node, + output_cols=output_names, + limit=request.peek_count, + ) + if request.sort_rows: + # Can only pullup slice if we are doing ORDER BY in outermost SELECT + # Need to do this before replacing unsupported ops, as that will rewrite slice ops + result_node = rewrite.pull_up_limits(result_node) + result_node = _replace_unsupported_ops(result_node) + # prune before pulling up order to avoid unnnecessary row_number() ops + result_node = typing.cast(nodes.ResultNode, rewrite.column_pruning(result_node)) + result_node = rewrite.defer_order( + result_node, output_hidden_row_keys=request.materialize_all_order_keys + ) + if request.sort_rows: result_node = typing.cast(nodes.ResultNode, rewrite.column_pruning(result_node)) - - result_node = self._remap_variables(result_node) + result_node = _remap_variables(result_node, uid_gen) result_node = typing.cast( nodes.ResultNode, rewrite.defer_selection(result_node) ) - sql = self._compile_result_node(result_node) - # Return the ordering iff no extra columns are needed to define the row order - if ordering is not None: - output_order = ( - ordering - if ordering.referenced_columns.issubset(result_node.ids) - else None - ) - assert (not request.materialize_all_order_keys) or (output_order is not None) + sql = _compile_result_node(result_node, uid_gen) return configs.CompileResult( - sql, result_node.schema.to_bigquery(), output_order + sql, result_node.schema.to_bigquery(), result_node.order_by ) - def _remap_variables(self, node: nodes.ResultNode) -> nodes.ResultNode: - """Remaps `ColumnId`s in the BFET of a `ResultNode` to produce deterministic UIDs.""" - - result_node, _ = rewrite.remap_variables( - node, map(identifiers.ColumnId, self.uid_gen.get_uid_stream("bfcol_")) + ordering: typing.Optional[bf_ordering.RowOrdering] = result_node.order_by + result_node = dataclasses.replace(result_node, order_by=None) + result_node = typing.cast(nodes.ResultNode, rewrite.column_pruning(result_node)) + + result_node = _remap_variables(result_node, uid_gen) + result_node = typing.cast(nodes.ResultNode, rewrite.defer_selection(result_node)) + sql = _compile_result_node(result_node, uid_gen) + # Return the ordering iff no extra columns are needed to define the row order + if ordering is not None: + output_order = ( + ordering if ordering.referenced_columns.issubset(result_node.ids) else None ) - return typing.cast(nodes.ResultNode, result_node) - - def _compile_result_node(self, root: nodes.ResultNode) -> str: - # Have to bind schema as the final step before compilation. - root = typing.cast(nodes.ResultNode, schema_binding.bind_schema_to_tree(root)) - selected_cols: tuple[tuple[str, sge.Expression], ...] = tuple( - (name, scalar_compiler.scalar_op_compiler.compile_expression(ref)) - for ref, name in root.output_cols + assert (not request.materialize_all_order_keys) or (output_order is not None) + return configs.CompileResult(sql, result_node.schema.to_bigquery(), output_order) + + +def _remap_variables( + node: nodes.ResultNode, uid_gen: guid.SequentialUIDGenerator +) -> nodes.ResultNode: + """Remaps `ColumnId`s in the BFET of a `ResultNode` to produce deterministic UIDs.""" + + result_node, _ = rewrite.remap_variables( + node, map(identifiers.ColumnId, uid_gen.get_uid_stream("bfcol_")) + ) + return typing.cast(nodes.ResultNode, result_node) + + +def _compile_result_node( + root: nodes.ResultNode, uid_gen: guid.SequentialUIDGenerator +) -> str: + # Have to bind schema as the final step before compilation. + root = typing.cast(nodes.ResultNode, schema_binding.bind_schema_to_tree(root)) + selected_cols: tuple[tuple[str, sge.Expression], ...] = tuple( + (name, scalar_compiler.scalar_op_compiler.compile_expression(ref)) + for ref, name in root.output_cols + ) + sqlglot_ir = compile_node(root.child, uid_gen).select(selected_cols) + + if root.order_by is not None: + ordering_cols = tuple( + sge.Ordered( + this=scalar_compiler.scalar_op_compiler.compile_expression( + ordering.scalar_expression + ), + desc=ordering.direction.is_ascending is False, + nulls_first=ordering.na_last is False, + ) + for ordering in root.order_by.all_ordering_columns ) - sqlglot_ir = self.compile_node(root.child).select(selected_cols) - - if root.order_by is not None: - ordering_cols = tuple( - sge.Ordered( - this=scalar_compiler.scalar_op_compiler.compile_expression( - ordering.scalar_expression - ), - desc=ordering.direction.is_ascending is False, - nulls_first=ordering.na_last is False, - ) - for ordering in root.order_by.all_ordering_columns + sqlglot_ir = sqlglot_ir.order_by(ordering_cols) + + if root.limit is not None: + sqlglot_ir = sqlglot_ir.limit(root.limit) + + return sqlglot_ir.sql + + +@functools.lru_cache(maxsize=5000) +def compile_node( + node: nodes.BigFrameNode, uid_gen: guid.SequentialUIDGenerator +) -> ir.SQLGlotIR: + """Compiles the given BigFrameNode from bottem-up into SQLGlotIR.""" + bf_to_sqlglot: dict[nodes.BigFrameNode, ir.SQLGlotIR] = {} + child_results: tuple[ir.SQLGlotIR, ...] = () + for current_node in list(node.iter_nodes_topo()): + if current_node.child_nodes == (): + # For leaf node, generates a dumpy child to pass the UID generator. + child_results = tuple([ir.SQLGlotIR(uid_gen=uid_gen)]) + else: + # Child nodes should have been compiled in the reverse topological order. + child_results = tuple( + bf_to_sqlglot[child] for child in current_node.child_nodes ) - sqlglot_ir = sqlglot_ir.order_by(ordering_cols) + result = _compile_node(current_node, *child_results) + bf_to_sqlglot[current_node] = result - if root.limit is not None: - sqlglot_ir = sqlglot_ir.limit(root.limit) + return bf_to_sqlglot[node] - return sqlglot_ir.sql - @functools.lru_cache(maxsize=5000) - def compile_node(self, node: nodes.BigFrameNode) -> ir.SQLGlotIR: - """Compiles node into CompileArrayValue. Caches result.""" - return node.reduce_up( - lambda node, children: self._compile_node(node, *children) - ) +@functools.singledispatch +def _compile_node( + node: nodes.BigFrameNode, *compiled_children: ir.SQLGlotIR +) -> ir.SQLGlotIR: + """Defines transformation but isn't cached, always use compile_node instead""" + raise ValueError(f"Can't compile unrecognized node: {node}") - @functools.singledispatchmethod - def _compile_node( - self, node: nodes.BigFrameNode, *compiled_children: ir.SQLGlotIR - ) -> ir.SQLGlotIR: - """Defines transformation but isn't cached, always use compile_node instead""" - raise ValueError(f"Can't compile unrecognized node: {node}") - - @_compile_node.register - def compile_readlocal(self, node: nodes.ReadLocalNode, *args) -> ir.SQLGlotIR: - pa_table = node.local_data_source.data - pa_table = pa_table.select([item.source_id for item in node.scan_list.items]) - pa_table = pa_table.rename_columns( - [item.id.sql for item in node.scan_list.items] - ) - offsets = node.offsets_col.sql if node.offsets_col else None - if offsets: - pa_table = pyarrow_utils.append_offsets(pa_table, offsets) - - return ir.SQLGlotIR.from_pyarrow(pa_table, node.schema, uid_gen=self.uid_gen) - - @_compile_node.register - def compile_readtable(self, node: nodes.ReadTableNode, *args): - table = node.source.table - return ir.SQLGlotIR.from_table( - table.project_id, - table.dataset_id, - table.table_id, - col_names=[col.source_id for col in node.scan_list.items], - alias_names=[col.id.sql for col in node.scan_list.items], - uid_gen=self.uid_gen, - ) +@_compile_node.register +def compile_readlocal(node: nodes.ReadLocalNode, child: ir.SQLGlotIR) -> ir.SQLGlotIR: + pa_table = node.local_data_source.data + pa_table = pa_table.select([item.source_id for item in node.scan_list.items]) + pa_table = pa_table.rename_columns([item.id.sql for item in node.scan_list.items]) - @_compile_node.register - def compile_selection( - self, node: nodes.SelectionNode, child: ir.SQLGlotIR - ) -> ir.SQLGlotIR: - selected_cols: tuple[tuple[str, sge.Expression], ...] = tuple( - (id.sql, scalar_compiler.scalar_op_compiler.compile_expression(expr)) - for expr, id in node.input_output_pairs - ) - return child.select(selected_cols) - - @_compile_node.register - def compile_projection( - self, node: nodes.ProjectionNode, child: ir.SQLGlotIR - ) -> ir.SQLGlotIR: - projected_cols: tuple[tuple[str, sge.Expression], ...] = tuple( - (id.sql, scalar_compiler.scalar_op_compiler.compile_expression(expr)) - for expr, id in node.assignments - ) - return child.project(projected_cols) - - @_compile_node.register - def compile_filter( - self, node: nodes.FilterNode, child: ir.SQLGlotIR - ) -> ir.SQLGlotIR: - condition = scalar_compiler.scalar_op_compiler.compile_expression( - node.predicate - ) - return child.filter(tuple([condition])) + offsets = node.offsets_col.sql if node.offsets_col else None + if offsets: + pa_table = pyarrow_utils.append_offsets(pa_table, offsets) - @_compile_node.register - def compile_join( - self, node: nodes.JoinNode, left: ir.SQLGlotIR, right: ir.SQLGlotIR - ) -> ir.SQLGlotIR: - conditions = tuple( - ( - typed_expr.TypedExpr( - scalar_compiler.scalar_op_compiler.compile_expression(left), - left.output_type, - ), - typed_expr.TypedExpr( - scalar_compiler.scalar_op_compiler.compile_expression(right), - right.output_type, - ), - ) - for left, right in node.conditions - ) + return ir.SQLGlotIR.from_pyarrow(pa_table, node.schema, uid_gen=child.uid_gen) - return left.join( - right, - join_type=node.type, - conditions=conditions, - joins_nulls=node.joins_nulls, - ) - @_compile_node.register - def compile_isin_join( - self, node: nodes.InNode, left: ir.SQLGlotIR, right: ir.SQLGlotIR - ) -> ir.SQLGlotIR: - conditions = ( - typed_expr.TypedExpr( - scalar_compiler.scalar_op_compiler.compile_expression(node.left_col), - node.left_col.output_type, - ), - typed_expr.TypedExpr( - scalar_compiler.scalar_op_compiler.compile_expression(node.right_col), - node.right_col.output_type, - ), - ) +@_compile_node.register +def compile_readtable(node: nodes.ReadTableNode, child: ir.SQLGlotIR): + table = node.source.table + return ir.SQLGlotIR.from_table( + table.project_id, + table.dataset_id, + table.table_id, + col_names=[col.source_id for col in node.scan_list.items], + alias_names=[col.id.sql for col in node.scan_list.items], + uid_gen=child.uid_gen, + ) - return left.isin_join( - right, - indicator_col=node.indicator_col.sql, - conditions=conditions, - joins_nulls=node.joins_nulls, - ) - @_compile_node.register - def compile_concat( - self, node: nodes.ConcatNode, *children: ir.SQLGlotIR - ) -> ir.SQLGlotIR: - output_ids = [id.sql for id in node.output_ids] - return ir.SQLGlotIR.from_union( - [child.expr for child in children], - output_ids=output_ids, - uid_gen=self.uid_gen, - ) +@_compile_node.register +def compile_selection(node: nodes.SelectionNode, child: ir.SQLGlotIR) -> ir.SQLGlotIR: + selected_cols: tuple[tuple[str, sge.Expression], ...] = tuple( + (id.sql, scalar_compiler.scalar_op_compiler.compile_expression(expr)) + for expr, id in node.input_output_pairs + ) + return child.select(selected_cols) - @_compile_node.register - def compile_explode( - self, node: nodes.ExplodeNode, child: ir.SQLGlotIR - ) -> ir.SQLGlotIR: - offsets_col = node.offsets_col.sql if (node.offsets_col is not None) else None - columns = tuple(ref.id.sql for ref in node.column_ids) - return child.explode(columns, offsets_col) - - @_compile_node.register - def compile_random_sample( - self, node: nodes.RandomSampleNode, child: ir.SQLGlotIR - ) -> ir.SQLGlotIR: - return child.sample(node.fraction) - - @_compile_node.register - def compile_aggregate( - self, node: nodes.AggregateNode, child: ir.SQLGlotIR - ) -> ir.SQLGlotIR: - # The BigQuery ordered aggregation cannot support for NULL FIRST/LAST, - # so we need to add extra expressions to enforce the null ordering. - ordering_cols = windows.get_window_order_by( - node.order_by, override_null_order=True - ) - aggregations: tuple[tuple[str, sge.Expression], ...] = tuple( - ( - id.sql, - aggregate_compiler.compile_aggregate( - agg, order_by=ordering_cols if ordering_cols else () - ), - ) - for agg, id in node.aggregations - ) - by_cols: tuple[sge.Expression, ...] = tuple( - scalar_compiler.scalar_op_compiler.compile_expression(by_col) - for by_col in node.by_column_ids - ) - dropna_cols = [] - if node.dropna: - for key, by_col in zip(node.by_column_ids, by_cols): - if node.child.field_by_id[key.id].nullable: - dropna_cols.append(by_col) +@_compile_node.register +def compile_projection(node: nodes.ProjectionNode, child: ir.SQLGlotIR) -> ir.SQLGlotIR: + projected_cols: tuple[tuple[str, sge.Expression], ...] = tuple( + (id.sql, scalar_compiler.scalar_op_compiler.compile_expression(expr)) + for expr, id in node.assignments + ) + return child.project(projected_cols) - return child.aggregate(aggregations, by_cols, tuple(dropna_cols)) - @_compile_node.register - def compile_window( - self, node: nodes.WindowOpNode, child: ir.SQLGlotIR - ) -> ir.SQLGlotIR: - window_spec = node.window_spec - if node.expression.op.order_independent and window_spec.is_unbounded: - # notably percentile_cont does not support ordering clause - window_spec = window_spec.without_order() +@_compile_node.register +def compile_filter(node: nodes.FilterNode, child: ir.SQLGlotIR) -> ir.SQLGlotIR: + condition = scalar_compiler.scalar_op_compiler.compile_expression(node.predicate) + return child.filter(tuple([condition])) - window_op = aggregate_compiler.compile_analytic(node.expression, window_spec) - inputs: tuple[sge.Expression, ...] = tuple( - scalar_compiler.scalar_op_compiler.compile_expression( - expression.DerefOp(column) - ) - for column in node.expression.column_references +@_compile_node.register +def compile_join( + node: nodes.JoinNode, left: ir.SQLGlotIR, right: ir.SQLGlotIR +) -> ir.SQLGlotIR: + conditions = tuple( + ( + typed_expr.TypedExpr( + scalar_compiler.scalar_op_compiler.compile_expression(left), + left.output_type, + ), + typed_expr.TypedExpr( + scalar_compiler.scalar_op_compiler.compile_expression(right), + right.output_type, + ), ) - - clauses: list[tuple[sge.Expression, sge.Expression]] = [] - if node.expression.op.skips_nulls and not node.never_skip_nulls: - for column in inputs: - clauses.append((sge.Is(this=column, expression=sge.Null()), sge.Null())) - - if window_spec.min_periods and len(inputs) > 0: - if node.expression.op.skips_nulls: - # Most operations do not count NULL values towards min_periods - not_null_columns = [ - sge.Not(this=sge.Is(this=column, expression=sge.Null())) - for column in inputs - ] - # All inputs must be non-null for observation to count - if not not_null_columns: - is_observation_expr: sge.Expression = sge.convert(True) - else: - is_observation_expr = not_null_columns[0] - for expr in not_null_columns[1:]: - is_observation_expr = sge.And( - this=is_observation_expr, expression=expr - ) - is_observation = ir._cast(is_observation_expr, "INT64") - observation_count = windows.apply_window_if_present( - sge.func("SUM", is_observation), window_spec - ) + for left, right in node.conditions + ) + + return left.join( + right, + join_type=node.type, + conditions=conditions, + joins_nulls=node.joins_nulls, + ) + + +@_compile_node.register +def compile_isin_join( + node: nodes.InNode, left: ir.SQLGlotIR, right: ir.SQLGlotIR +) -> ir.SQLGlotIR: + conditions = ( + typed_expr.TypedExpr( + scalar_compiler.scalar_op_compiler.compile_expression(node.left_col), + node.left_col.output_type, + ), + typed_expr.TypedExpr( + scalar_compiler.scalar_op_compiler.compile_expression(node.right_col), + node.right_col.output_type, + ), + ) + + return left.isin_join( + right, + indicator_col=node.indicator_col.sql, + conditions=conditions, + joins_nulls=node.joins_nulls, + ) + + +@_compile_node.register +def compile_concat(node: nodes.ConcatNode, *children: ir.SQLGlotIR) -> ir.SQLGlotIR: + assert len(children) >= 1 + uid_gen = children[0].uid_gen + + output_ids = [id.sql for id in node.output_ids] + return ir.SQLGlotIR.from_union( + [child.expr for child in children], + output_ids=output_ids, + uid_gen=uid_gen, + ) + + +@_compile_node.register +def compile_explode(node: nodes.ExplodeNode, child: ir.SQLGlotIR) -> ir.SQLGlotIR: + offsets_col = node.offsets_col.sql if (node.offsets_col is not None) else None + columns = tuple(ref.id.sql for ref in node.column_ids) + return child.explode(columns, offsets_col) + + +@_compile_node.register +def compile_random_sample( + node: nodes.RandomSampleNode, child: ir.SQLGlotIR +) -> ir.SQLGlotIR: + return child.sample(node.fraction) + + +@_compile_node.register +def compile_aggregate(node: nodes.AggregateNode, child: ir.SQLGlotIR) -> ir.SQLGlotIR: + # The BigQuery ordered aggregation cannot support for NULL FIRST/LAST, + # so we need to add extra expressions to enforce the null ordering. + ordering_cols = windows.get_window_order_by(node.order_by, override_null_order=True) + aggregations: tuple[tuple[str, sge.Expression], ...] = tuple( + ( + id.sql, + aggregate_compiler.compile_aggregate( + agg, order_by=ordering_cols if ordering_cols else () + ), + ) + for agg, id in node.aggregations + ) + by_cols: tuple[sge.Expression, ...] = tuple( + scalar_compiler.scalar_op_compiler.compile_expression(by_col) + for by_col in node.by_column_ids + ) + + dropna_cols = [] + if node.dropna: + for key, by_col in zip(node.by_column_ids, by_cols): + if node.child.field_by_id[key.id].nullable: + dropna_cols.append(by_col) + + return child.aggregate(aggregations, by_cols, tuple(dropna_cols)) + + +@_compile_node.register +def compile_window(node: nodes.WindowOpNode, child: ir.SQLGlotIR) -> ir.SQLGlotIR: + window_spec = node.window_spec + if node.expression.op.order_independent and window_spec.is_unbounded: + # notably percentile_cont does not support ordering clause + window_spec = window_spec.without_order() + + window_op = aggregate_compiler.compile_analytic(node.expression, window_spec) + + inputs: tuple[sge.Expression, ...] = tuple( + scalar_compiler.scalar_op_compiler.compile_expression( + expression.DerefOp(column) + ) + for column in node.expression.column_references + ) + + clauses: list[tuple[sge.Expression, sge.Expression]] = [] + if node.expression.op.skips_nulls and not node.never_skip_nulls: + for column in inputs: + clauses.append((sge.Is(this=column, expression=sge.Null()), sge.Null())) + + if window_spec.min_periods and len(inputs) > 0: + if node.expression.op.skips_nulls: + # Most operations do not count NULL values towards min_periods + not_null_columns = [ + sge.Not(this=sge.Is(this=column, expression=sge.Null())) + for column in inputs + ] + # All inputs must be non-null for observation to count + if not not_null_columns: + is_observation_expr: sge.Expression = sge.convert(True) else: - # Operations like count treat even NULLs as valid observations - # for the sake of min_periods notnull is just used to convert - # null values to non-null (FALSE) values to be counted. - is_observation = ir._cast( - sge.Not(this=sge.Is(this=inputs[0], expression=sge.Null())), - "INT64", - ) - observation_count = windows.apply_window_if_present( - sge.func("COUNT", is_observation), window_spec - ) - - clauses.append( - ( - observation_count < sge.convert(window_spec.min_periods), - sge.Null(), - ) + is_observation_expr = not_null_columns[0] + for expr in not_null_columns[1:]: + is_observation_expr = sge.And( + this=is_observation_expr, expression=expr + ) + is_observation = ir._cast(is_observation_expr, "INT64") + observation_count = windows.apply_window_if_present( + sge.func("SUM", is_observation), window_spec + ) + else: + # Operations like count treat even NULLs as valid observations + # for the sake of min_periods notnull is just used to convert + # null values to non-null (FALSE) values to be counted. + is_observation = ir._cast( + sge.Not(this=sge.Is(this=inputs[0], expression=sge.Null())), + "INT64", + ) + observation_count = windows.apply_window_if_present( + sge.func("COUNT", is_observation), window_spec + ) + + clauses.append( + ( + observation_count < sge.convert(window_spec.min_periods), + sge.Null(), ) - if clauses: - when_expressions = [sge.When(this=cond, true=res) for cond, res in clauses] - window_op = sge.Case(ifs=when_expressions, default=window_op) - - # TODO: check if we can directly window the expression. - return child.window( - window_op=window_op, - output_column_id=node.output_name.sql, ) + if clauses: + when_expressions = [sge.When(this=cond, true=res) for cond, res in clauses] + window_op = sge.Case(ifs=when_expressions, default=window_op) + + # TODO: check if we can directly window the expression. + return child.window( + window_op=window_op, + output_column_id=node.output_name.sql, + ) def _replace_unsupported_ops(node: nodes.BigFrameNode): diff --git a/bigframes/session/direct_gbq_execution.py b/bigframes/session/direct_gbq_execution.py index d76a1a7630..748c43e66c 100644 --- a/bigframes/session/direct_gbq_execution.py +++ b/bigframes/session/direct_gbq_execution.py @@ -40,9 +40,7 @@ def __init__( ): self.bqclient = bqclient self._compile_fn = ( - compile.compile_sql - if compiler == "ibis" - else sqlglot.SQLGlotCompiler()._compile_sql + compile.compile_sql if compiler == "ibis" else sqlglot.compile_sql ) self._publisher = publisher diff --git a/bigframes/testing/compiler_session.py b/bigframes/testing/compiler_session.py index 289b2600fd..b248f37cfc 100644 --- a/bigframes/testing/compiler_session.py +++ b/bigframes/testing/compiler_session.py @@ -16,7 +16,7 @@ import typing import bigframes.core -import bigframes.core.compile.sqlglot as sqlglot +import bigframes.core.compile as compile import bigframes.session.executor @@ -24,7 +24,7 @@ class SQLCompilerExecutor(bigframes.session.executor.Executor): """Executor for SQL compilation using sqlglot.""" - compiler = sqlglot + compiler = compile.sqlglot def to_sql( self, @@ -38,9 +38,9 @@ def to_sql( # Compared with BigQueryCachingExecutor, SQLCompilerExecutor skips # caching the subtree. - return self.compiler.SQLGlotCompiler().compile( - array_value.node, ordered=ordered - ) + return self.compiler.compile_sql( + compile.CompileRequest(array_value.node, sort_rows=ordered) + ).sql def execute( self, diff --git a/tests/unit/core/compile/sqlglot/test_compile_random_sample.py b/tests/unit/core/compile/sqlglot/test_compile_random_sample.py index 6e333f0421..486d994f87 100644 --- a/tests/unit/core/compile/sqlglot/test_compile_random_sample.py +++ b/tests/unit/core/compile/sqlglot/test_compile_random_sample.py @@ -16,7 +16,7 @@ from bigframes.core import nodes import bigframes.core as core -import bigframes.core.compile.sqlglot as sqlglot +import bigframes.core.compile as compile pytest.importorskip("pytest_snapshot") @@ -31,5 +31,5 @@ def test_compile_random_sample( operation, this test constructs the node directly and then compiles it to SQL. """ node = nodes.RandomSampleNode(scalar_types_array_value.node, fraction=0.1) - sql = sqlglot.compiler.SQLGlotCompiler().compile(node) + sql = compile.sqlglot.compile_sql(compile.CompileRequest(node, sort_rows=True)).sql snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/session/test_io_bigquery.py b/tests/unit/session/test_io_bigquery.py index 41f3755f13..4349c1b6ee 100644 --- a/tests/unit/session/test_io_bigquery.py +++ b/tests/unit/session/test_io_bigquery.py @@ -156,7 +156,6 @@ def test_create_job_configs_labels_length_limit_met(): ) assert labels is not None - assert len(labels) == 56 assert "dataframe-max" in labels.values() assert "dataframe-head" not in labels.values() assert "bigframes-api" in labels.keys() From 64995d659837a8576b2ee9335921904e577c7014 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Mon, 10 Nov 2025 16:33:31 -0800 Subject: [PATCH 223/313] feat: Preserve source names better for more readable sql (#2243) --- bigframes/core/compile/sqlglot/sqlglot_ir.py | 4 + bigframes/core/rewrite/select_pullup.py | 42 ++++++- .../test_binary_compiler/test_corr/out.sql | 6 +- .../test_binary_compiler/test_cov/out.sql | 6 +- .../test_row_number/out.sql | 30 ++--- .../test_row_number_with_window/out.sql | 4 +- .../test_nullary_compiler/test_size/out.sql | 30 ++--- .../test_array_agg/out.sql | 4 +- .../test_string_agg/out.sql | 13 ++- .../test_unary_compiler/test_all/out.sql | 4 +- .../test_all/window_out.sql | 6 +- .../test_all/window_partition_out.sql | 8 +- .../test_any_value/out.sql | 4 +- .../test_any_value/window_out.sql | 4 +- .../test_any_value/window_partition_out.sql | 8 +- .../test_approx_quartiles/out.sql | 8 +- .../test_approx_top_count/out.sql | 4 +- .../test_unary_compiler/test_count/out.sql | 4 +- .../test_count/window_out.sql | 4 +- .../test_count/window_partition_out.sql | 6 +- .../test_date_series_diff/out.sql | 4 +- .../test_dense_rank/out.sql | 4 +- .../test_diff/diff_bool.sql | 4 +- .../test_diff/diff_int.sql | 4 +- .../test_unary_compiler/test_first/out.sql | 9 +- .../test_first_non_null/out.sql | 6 +- .../test_unary_compiler/test_last/out.sql | 9 +- .../test_last_non_null/out.sql | 6 +- .../test_unary_compiler/test_max/out.sql | 4 +- .../test_max/window_out.sql | 4 +- .../test_max/window_partition_out.sql | 8 +- .../test_unary_compiler/test_mean/out.sql | 12 +- .../test_mean/window_out.sql | 4 +- .../test_mean/window_partition_out.sql | 8 +- .../test_unary_compiler/test_median/out.sql | 12 +- .../test_unary_compiler/test_min/out.sql | 4 +- .../test_min/window_out.sql | 4 +- .../test_min/window_partition_out.sql | 8 +- .../test_unary_compiler/test_pop_var/out.sql | 8 +- .../test_pop_var/window_out.sql | 4 +- .../test_unary_compiler/test_quantile/out.sql | 6 +- .../test_unary_compiler/test_rank/out.sql | 4 +- .../test_unary_compiler/test_shift/lag.sql | 4 +- .../test_unary_compiler/test_shift/lead.sql | 4 +- .../test_unary_compiler/test_shift/noop.sql | 4 +- .../test_unary_compiler/test_std/out.sql | 12 +- .../test_std/window_out.sql | 4 +- .../test_unary_compiler/test_sum/out.sql | 8 +- .../test_sum/window_out.sql | 8 +- .../test_sum/window_partition_out.sql | 8 +- .../test_time_series_diff/out.sql | 8 +- .../test_unary_compiler/test_var/out.sql | 8 +- .../test_var/window_out.sql | 4 +- .../test_ai_ops/test_ai_classify/out.sql | 4 +- .../test_ai_ops/test_ai_generate/out.sql | 4 +- .../test_ai_ops/test_ai_generate_bool/out.sql | 4 +- .../out.sql | 4 +- .../test_ai_generate_double/out.sql | 4 +- .../out.sql | 4 +- .../test_ai_ops/test_ai_generate_int/out.sql | 4 +- .../out.sql | 4 +- .../test_ai_generate_with_model_param/out.sql | 4 +- .../out.sql | 4 +- .../snapshots/test_ai_ops/test_ai_if/out.sql | 4 +- .../test_ai_ops/test_ai_score/out.sql | 4 +- .../test_array_ops/test_array_index/out.sql | 4 +- .../test_array_slice_with_only_start/out.sql | 4 +- .../out.sql | 4 +- .../test_array_to_string/out.sql | 4 +- .../test_obj_fetch_metadata/out.sql | 8 +- .../test_obj_get_access_url/out.sql | 8 +- .../test_blob_ops/test_obj_make_ref/out.sql | 8 +- .../test_bool_ops/test_and_op/out.sql | 14 +-- .../test_bool_ops/test_or_op/out.sql | 14 +-- .../test_bool_ops/test_xor_op/out.sql | 14 +-- .../test_eq_null_match/out.sql | 6 +- .../test_eq_numeric/out.sql | 14 +-- .../test_ge_numeric/out.sql | 14 +-- .../test_gt_numeric/out.sql | 14 +-- .../test_comparison_ops/test_is_in/out.sql | 20 ++-- .../test_le_numeric/out.sql | 14 +-- .../test_lt_numeric/out.sql | 14 +-- .../test_maximum_op/out.sql | 6 +- .../test_minimum_op/out.sql | 6 +- .../test_ne_numeric/out.sql | 14 +-- .../test_add_timedelta/out.sql | 14 +-- .../test_datetime_ops/test_date/out.sql | 4 +- .../test_datetime_ops/test_day/out.sql | 4 +- .../test_datetime_ops/test_dayofweek/out.sql | 12 +- .../test_datetime_ops/test_dayofyear/out.sql | 4 +- .../test_datetime_ops/test_floor_dt/out.sql | 28 ++--- .../test_datetime_ops/test_hour/out.sql | 4 +- .../test_datetime_ops/test_iso_day/out.sql | 4 +- .../test_datetime_ops/test_iso_week/out.sql | 4 +- .../test_datetime_ops/test_iso_year/out.sql | 4 +- .../test_datetime_ops/test_minute/out.sql | 4 +- .../test_datetime_ops/test_month/out.sql | 4 +- .../test_datetime_ops/test_normalize/out.sql | 4 +- .../test_datetime_ops/test_quarter/out.sql | 4 +- .../test_datetime_ops/test_second/out.sql | 4 +- .../test_datetime_ops/test_strftime/out.sql | 4 +- .../test_sub_timedelta/out.sql | 16 +-- .../test_datetime_ops/test_time/out.sql | 4 +- .../test_to_datetime/out.sql | 4 +- .../test_to_timestamp/out.sql | 4 +- .../test_unix_micros/out.sql | 4 +- .../test_unix_millis/out.sql | 4 +- .../test_unix_seconds/out.sql | 4 +- .../test_datetime_ops/test_year/out.sql | 4 +- .../test_generic_ops/test_astype_bool/out.sql | 10 +- .../test_astype_float/out.sql | 6 +- .../test_astype_from_json/out.sql | 12 +- .../test_generic_ops/test_astype_int/out.sql | 26 ++--- .../test_generic_ops/test_astype_json/out.sql | 20 ++-- .../test_astype_string/out.sql | 10 +- .../test_astype_time_like/out.sql | 10 +- .../test_case_when_op/out.sql | 26 ++--- .../test_generic_ops/test_clip/out.sql | 8 +- .../test_generic_ops/test_coalesce/out.sql | 8 +- .../test_generic_ops/test_hash/out.sql | 4 +- .../test_generic_ops/test_invert/out.sql | 12 +- .../test_generic_ops/test_isnull/out.sql | 4 +- .../test_generic_ops/test_map/out.sql | 4 +- .../test_generic_ops/test_notnull/out.sql | 4 +- .../test_generic_ops/test_row_key/out.sql | 94 ++++++++-------- .../test_sql_scalar_op/out.sql | 6 +- .../test_generic_ops/test_where/out.sql | 8 +- .../test_geo_ops/test_geo_area/out.sql | 4 +- .../test_geo_ops/test_geo_st_astext/out.sql | 4 +- .../test_geo_ops/test_geo_st_boundary/out.sql | 4 +- .../test_geo_ops/test_geo_st_buffer/out.sql | 4 +- .../test_geo_ops/test_geo_st_centroid/out.sql | 4 +- .../test_geo_st_convexhull/out.sql | 4 +- .../test_geo_st_difference/out.sql | 4 +- .../test_geo_st_geogfromtext/out.sql | 4 +- .../test_geo_ops/test_geo_st_isclosed/out.sql | 4 +- .../test_geo_ops/test_geo_st_length/out.sql | 4 +- .../snapshots/test_geo_ops/test_geo_x/out.sql | 4 +- .../snapshots/test_geo_ops/test_geo_y/out.sql | 4 +- .../test_json_ops/test_json_extract/out.sql | 4 +- .../test_json_extract_array/out.sql | 4 +- .../test_json_extract_string_array/out.sql | 4 +- .../test_json_ops/test_json_query/out.sql | 4 +- .../test_json_query_array/out.sql | 4 +- .../test_json_ops/test_json_set/out.sql | 4 +- .../test_json_ops/test_json_value/out.sql | 4 +- .../test_json_ops/test_parse_json/out.sql | 4 +- .../test_json_ops/test_to_json_string/out.sql | 4 +- .../test_numeric_ops/test_abs/out.sql | 4 +- .../test_numeric_ops/test_add_numeric/out.sql | 14 +-- .../test_numeric_ops/test_add_string/out.sql | 4 +- .../test_add_timedelta/out.sql | 14 +-- .../test_numeric_ops/test_arccos/out.sql | 8 +- .../test_numeric_ops/test_arccosh/out.sql | 8 +- .../test_numeric_ops/test_arcsin/out.sql | 8 +- .../test_numeric_ops/test_arcsinh/out.sql | 4 +- .../test_numeric_ops/test_arctan/out.sql | 4 +- .../test_numeric_ops/test_arctan2/out.sql | 10 +- .../test_numeric_ops/test_arctanh/out.sql | 8 +- .../test_numeric_ops/test_ceil/out.sql | 4 +- .../test_numeric_ops/test_cos/out.sql | 4 +- .../test_numeric_ops/test_cosh/out.sql | 6 +- .../test_cosine_distance/out.sql | 8 +- .../test_numeric_ops/test_div_numeric/out.sql | 18 +-- .../test_div_timedelta/out.sql | 14 +-- .../test_numeric_ops/test_exp/out.sql | 6 +- .../test_numeric_ops/test_expm1/out.sql | 6 +- .../test_numeric_ops/test_floor/out.sql | 4 +- .../test_floordiv_timedelta/out.sql | 12 +- .../test_numeric_ops/test_ln/out.sql | 4 +- .../test_numeric_ops/test_log10/out.sql | 8 +- .../test_numeric_ops/test_log1p/out.sql | 8 +- .../test_numeric_ops/test_mod_numeric/out.sql | 34 +++--- .../test_numeric_ops/test_mul_numeric/out.sql | 14 +-- .../test_mul_timedelta/out.sql | 16 +-- .../test_numeric_ops/test_neg/out.sql | 4 +- .../test_numeric_ops/test_pos/out.sql | 4 +- .../test_numeric_ops/test_round/out.sql | 14 +-- .../test_numeric_ops/test_sin/out.sql | 4 +- .../test_numeric_ops/test_sinh/out.sql | 8 +- .../test_numeric_ops/test_sqrt/out.sql | 4 +- .../test_numeric_ops/test_sub_numeric/out.sql | 14 +-- .../test_sub_timedelta/out.sql | 16 +-- .../test_numeric_ops/test_tan/out.sql | 4 +- .../test_numeric_ops/test_tanh/out.sql | 4 +- .../test_string_ops/test_add_string/out.sql | 4 +- .../test_string_ops/test_capitalize/out.sql | 4 +- .../test_string_ops/test_endswith/out.sql | 6 +- .../test_string_ops/test_isalnum/out.sql | 4 +- .../test_string_ops/test_isalpha/out.sql | 4 +- .../test_string_ops/test_isdecimal/out.sql | 4 +- .../test_string_ops/test_isdigit/out.sql | 4 +- .../test_string_ops/test_islower/out.sql | 4 +- .../test_string_ops/test_isnumeric/out.sql | 4 +- .../test_string_ops/test_isspace/out.sql | 4 +- .../test_string_ops/test_isupper/out.sql | 4 +- .../test_string_ops/test_len/out.sql | 4 +- .../test_string_ops/test_lower/out.sql | 4 +- .../test_string_ops/test_lstrip/out.sql | 4 +- .../test_regex_replace_str/out.sql | 4 +- .../test_string_ops/test_replace_str/out.sql | 4 +- .../test_string_ops/test_reverse/out.sql | 4 +- .../test_string_ops/test_rstrip/out.sql | 4 +- .../test_string_ops/test_startswith/out.sql | 6 +- .../test_string_ops/test_str_contains/out.sql | 4 +- .../test_str_contains_regex/out.sql | 4 +- .../test_string_ops/test_str_extract/out.sql | 4 +- .../test_string_ops/test_str_find/out.sql | 10 +- .../test_string_ops/test_str_get/out.sql | 4 +- .../test_string_ops/test_str_pad/out.sql | 12 +- .../test_string_ops/test_str_repeat/out.sql | 4 +- .../test_string_ops/test_str_slice/out.sql | 4 +- .../test_string_ops/test_strconcat/out.sql | 4 +- .../test_string_ops/test_string_split/out.sql | 4 +- .../test_string_ops/test_strip/out.sql | 4 +- .../test_string_ops/test_upper/out.sql | 4 +- .../test_string_ops/test_zfill/out.sql | 8 +- .../test_struct_ops/test_struct_field/out.sql | 6 +- .../test_struct_ops/test_struct_op/out.sql | 16 +-- .../test_timedelta_floor/out.sql | 4 +- .../test_to_timedelta/out.sql | 10 +- .../test_compile_aggregate/out.sql | 8 +- .../test_compile_aggregate_wo_dropna/out.sql | 8 +- .../test_compile_concat/out.sql | 28 ++--- .../test_compile_concat_filter_sorted/out.sql | 104 +++++++++--------- .../test_compile_explode_dataframe/out.sql | 18 +-- .../test_compile_explode_series/out.sql | 12 +- .../test_compile_filter/out.sql | 12 +- .../test_compile_isin/out.sql | 36 +++--- .../test_compile_isin_not_nullable/out.sql | 34 +++--- .../test_compile_join/out.sql | 16 +-- .../test_compile_join_w_on/bool_col/out.sql | 16 +-- .../float64_col/out.sql | 16 +-- .../test_compile_join_w_on/int64_col/out.sql | 16 +-- .../numeric_col/out.sql | 16 +-- .../test_compile_join_w_on/string_col/out.sql | 25 +++-- .../test_compile_join_w_on/time_col/out.sql | 25 +++-- .../test_compile_readtable/out.sql | 62 +++++------ .../out.sql | 8 +- .../test_compile_readtable_w_limit/out.sql | 10 +- .../out.sql | 10 +- .../test_compile_readtable_w_ordering/out.sql | 10 +- .../out.sql | 34 +++--- .../out.sql | 24 ++-- .../out.sql | 12 +- .../out.sql | 12 +- 246 files changed, 1174 insertions(+), 1077 deletions(-) diff --git a/bigframes/core/compile/sqlglot/sqlglot_ir.py b/bigframes/core/compile/sqlglot/sqlglot_ir.py index c7ee13f4e8..91fea44490 100644 --- a/bigframes/core/compile/sqlglot/sqlglot_ir.py +++ b/bigframes/core/compile/sqlglot/sqlglot_ir.py @@ -134,6 +134,8 @@ def from_table( this=sge.to_identifier(col_name, quoted=cls.quoted), alias=sge.to_identifier(alias_name, quoted=cls.quoted), ) + if col_name != alias_name + else sge.to_identifier(col_name, quoted=cls.quoted) for col_name, alias_name in zip(col_names, alias_names) ] table_expr = sge.Table( @@ -227,6 +229,8 @@ def select( this=expr, alias=sge.to_identifier(id, quoted=self.quoted), ) + if expr.alias_or_name != id + else expr for id, expr in selected_cols ] diff --git a/bigframes/core/rewrite/select_pullup.py b/bigframes/core/rewrite/select_pullup.py index 3a2de1238b..415182f884 100644 --- a/bigframes/core/rewrite/select_pullup.py +++ b/bigframes/core/rewrite/select_pullup.py @@ -13,9 +13,10 @@ # limitations under the License. import dataclasses +import functools from typing import cast -from bigframes.core import expression, nodes +from bigframes.core import expression, identifiers, nodes def defer_selection( @@ -26,12 +27,19 @@ def defer_selection( In many cases, these nodes will be merged or eliminated entirely, simplifying the overall tree. """ - return nodes.bottom_up(root, pull_up_select) + return nodes.bottom_up( + root, functools.partial(pull_up_select, prefer_source_names=True) + ) -def pull_up_select(node: nodes.BigFrameNode) -> nodes.BigFrameNode: +def pull_up_select( + node: nodes.BigFrameNode, prefer_source_names: bool +) -> nodes.BigFrameNode: if isinstance(node, nodes.LeafNode): - return node + if prefer_source_names and isinstance(node, nodes.ReadTableNode): + return pull_up_source_ids(node) + else: + return node if isinstance(node, nodes.JoinNode): return pull_up_selects_under_join(node) if isinstance(node, nodes.ConcatNode): @@ -42,6 +50,32 @@ def pull_up_select(node: nodes.BigFrameNode) -> nodes.BigFrameNode: return node +def pull_up_source_ids(node: nodes.ReadTableNode) -> nodes.BigFrameNode: + if all(id.sql == source_id for id, source_id in node.scan_list.items): + return node + else: + source_ids = sorted( + set(scan_item.source_id for scan_item in node.scan_list.items) + ) + new_scan_list = nodes.ScanList.from_items( + [ + nodes.ScanItem(identifiers.ColumnId(source_id), source_id) + for source_id in source_ids + ] + ) + new_source = dataclasses.replace(node, scan_list=new_scan_list) + new_selection = nodes.SelectionNode( + new_source, + tuple( + nodes.AliasedRef( + expression.DerefOp(identifiers.ColumnId(source_id)), id + ) + for id, source_id in node.scan_list.items + ), + ) + return new_selection + + def pull_up_select_unary(node: nodes.UnaryNode) -> nodes.BigFrameNode: child = node.child if not isinstance(child, nodes.SelectionNode): diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_corr/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_corr/out.sql index 8922a71de4..5c838f4882 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_corr/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_corr/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `float64_col` AS `bfcol_1` + `float64_col`, + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - CORR(`bfcol_0`, `bfcol_1`) AS `bfcol_2` + CORR(`int64_col`, `float64_col`) AS `bfcol_2` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_cov/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_cov/out.sql index 6cf189da31..eda082250a 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_cov/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_cov/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `float64_col` AS `bfcol_1` + `float64_col`, + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - COVAR_SAMP(`bfcol_0`, `bfcol_1`) AS `bfcol_2` + COVAR_SAMP(`int64_col`, `float64_col`) AS `bfcol_2` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql index b48dcfa01b..78cc44fa54 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql @@ -1,20 +1,20 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `bytes_col` AS `bfcol_1`, - `date_col` AS `bfcol_2`, - `datetime_col` AS `bfcol_3`, - `geography_col` AS `bfcol_4`, - `int64_col` AS `bfcol_5`, - `int64_too` AS `bfcol_6`, - `numeric_col` AS `bfcol_7`, - `float64_col` AS `bfcol_8`, - `rowindex` AS `bfcol_9`, - `rowindex_2` AS `bfcol_10`, - `string_col` AS `bfcol_11`, - `time_col` AS `bfcol_12`, - `timestamp_col` AS `bfcol_13`, - `duration_col` AS `bfcol_14` + `bool_col`, + `bytes_col`, + `date_col`, + `datetime_col`, + `duration_col`, + `float64_col`, + `geography_col`, + `int64_col`, + `int64_too`, + `numeric_col`, + `rowindex`, + `rowindex_2`, + `string_col`, + `time_col`, + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql index 8dc701e1f9..b63cb1ff61 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - ROW_NUMBER() OVER (ORDER BY `bfcol_0` ASC NULLS LAST) AS `bfcol_1` + ROW_NUMBER() OVER (ORDER BY `int64_col` ASC NULLS LAST) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_size/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_size/out.sql index 8cda9a3d80..ed8e0c7619 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_size/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_size/out.sql @@ -1,20 +1,20 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `bytes_col` AS `bfcol_1`, - `date_col` AS `bfcol_2`, - `datetime_col` AS `bfcol_3`, - `geography_col` AS `bfcol_4`, - `int64_col` AS `bfcol_5`, - `int64_too` AS `bfcol_6`, - `numeric_col` AS `bfcol_7`, - `float64_col` AS `bfcol_8`, - `rowindex` AS `bfcol_9`, - `rowindex_2` AS `bfcol_10`, - `string_col` AS `bfcol_11`, - `time_col` AS `bfcol_12`, - `timestamp_col` AS `bfcol_13`, - `duration_col` AS `bfcol_14` + `bool_col`, + `bytes_col`, + `date_col`, + `datetime_col`, + `duration_col`, + `float64_col`, + `geography_col`, + `int64_col`, + `int64_too`, + `numeric_col`, + `rowindex`, + `rowindex_2`, + `string_col`, + `time_col`, + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_array_agg/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_array_agg/out.sql index 43e0a03db4..eafbc39daf 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_array_agg/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_array_agg/out.sql @@ -1,10 +1,10 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - ARRAY_AGG(`bfcol_0` IGNORE NULLS ORDER BY `bfcol_0` IS NULL ASC, `bfcol_0` ASC) AS `bfcol_1` + ARRAY_AGG(`int64_col` IGNORE NULLS ORDER BY `int64_col` IS NULL ASC, `int64_col` ASC) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_string_agg/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_string_agg/out.sql index 115d7e37ee..321341d4a0 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_string_agg/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_string_agg/out.sql @@ -1,13 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - COALESCE(STRING_AGG(`bfcol_0`, ',' - ORDER BY - `bfcol_0` IS NULL ASC, - `bfcol_0` ASC), '') AS `bfcol_1` + COALESCE( + STRING_AGG(`string_col`, ',' + ORDER BY + `string_col` IS NULL ASC, + `string_col` ASC), + '' + ) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/out.sql index 7303d758cc..d31b21f56b 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/out.sql @@ -1,10 +1,10 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0` + `bool_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - COALESCE(LOGICAL_AND(`bfcol_0`), TRUE) AS `bfcol_1` + COALESCE(LOGICAL_AND(`bool_col`), TRUE) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_out.sql index a91381d3be..83bd288e73 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_out.sql @@ -1,14 +1,14 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0` + `bool_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, CASE - WHEN `bfcol_0` IS NULL + WHEN `bool_col` IS NULL THEN NULL - ELSE COALESCE(LOGICAL_AND(`bfcol_0`) OVER (), TRUE) + ELSE COALESCE(LOGICAL_AND(`bool_col`) OVER (), TRUE) END AS `bfcol_1` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_partition_out.sql index 1a9a020b3e..dc2471148b 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_partition_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_partition_out.sql @@ -1,15 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `string_col` AS `bfcol_1` + `bool_col`, + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, CASE - WHEN `bfcol_0` IS NULL + WHEN `bool_col` IS NULL THEN NULL - ELSE COALESCE(LOGICAL_AND(`bfcol_0`) OVER (PARTITION BY `bfcol_1`), TRUE) + ELSE COALESCE(LOGICAL_AND(`bool_col`) OVER (PARTITION BY `string_col`), TRUE) END AS `bfcol_2` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/out.sql index f95b094a13..4a13901f1c 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/out.sql @@ -1,10 +1,10 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - ANY_VALUE(`bfcol_0`) AS `bfcol_1` + ANY_VALUE(`int64_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_out.sql index b262284b88..f179808b57 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE WHEN `bfcol_0` IS NULL THEN NULL ELSE ANY_VALUE(`bfcol_0`) OVER () END AS `bfcol_1` + CASE WHEN `int64_col` IS NULL THEN NULL ELSE ANY_VALUE(`int64_col`) OVER () END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_partition_out.sql index 66933626b5..e1b3da8a9a 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_partition_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_partition_out.sql @@ -1,15 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `string_col` AS `bfcol_1` + `int64_col`, + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, CASE - WHEN `bfcol_0` IS NULL + WHEN `int64_col` IS NULL THEN NULL - ELSE ANY_VALUE(`bfcol_0`) OVER (PARTITION BY `bfcol_1`) + ELSE ANY_VALUE(`int64_col`) OVER (PARTITION BY `string_col`) END AS `bfcol_2` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_quartiles/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_quartiles/out.sql index e7bb16e57c..9eabb2d88a 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_quartiles/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_quartiles/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - APPROX_QUANTILES(`bfcol_0`, 4)[OFFSET(1)] AS `bfcol_1`, - APPROX_QUANTILES(`bfcol_0`, 4)[OFFSET(2)] AS `bfcol_2`, - APPROX_QUANTILES(`bfcol_0`, 4)[OFFSET(3)] AS `bfcol_3` + APPROX_QUANTILES(`int64_col`, 4)[OFFSET(1)] AS `bfcol_1`, + APPROX_QUANTILES(`int64_col`, 4)[OFFSET(2)] AS `bfcol_2`, + APPROX_QUANTILES(`int64_col`, 4)[OFFSET(3)] AS `bfcol_3` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_top_count/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_top_count/out.sql index b61a72d1b2..b5e6275381 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_top_count/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_top_count/out.sql @@ -1,10 +1,10 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - APPROX_TOP_COUNT(`bfcol_0`, 10) AS `bfcol_1` + APPROX_TOP_COUNT(`int64_col`, 10) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/out.sql index 01684b4af6..9d18367cf6 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/out.sql @@ -1,10 +1,10 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - COUNT(`bfcol_0`) AS `bfcol_1` + COUNT(`int64_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_out.sql index beda182992..0baac95311 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - COUNT(`bfcol_0`) OVER () AS `bfcol_1` + COUNT(`int64_col`) OVER () AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_partition_out.sql index d4a12d73f8..6d3f856459 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_partition_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_partition_out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `string_col` AS `bfcol_1` + `int64_col`, + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - COUNT(`bfcol_0`) OVER (PARTITION BY `bfcol_1`) AS `bfcol_2` + COUNT(`int64_col`) OVER (PARTITION BY `string_col`) AS `bfcol_2` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_date_series_diff/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_date_series_diff/out.sql index cd1cf3ff3b..84c95fd010 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_date_series_diff/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_date_series_diff/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `date_col` AS `bfcol_0` + `date_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CAST(DATE_DIFF(`bfcol_0`, LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` ASC NULLS LAST), DAY) * 86400000000 AS INT64) AS `bfcol_1` + CAST(DATE_DIFF(`date_col`, LAG(`date_col`, 1) OVER (ORDER BY `date_col` ASC NULLS LAST), DAY) * 86400000000 AS INT64) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_dense_rank/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_dense_rank/out.sql index 0f704dd0cc..76b455a65c 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_dense_rank/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_dense_rank/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - DENSE_RANK() OVER (ORDER BY `bfcol_0` DESC) AS `bfcol_1` + DENSE_RANK() OVER (ORDER BY `int64_col` DESC) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_bool.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_bool.sql index 500056928d..96d23c4747 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_bool.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_bool.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0` + `bool_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_0` <> LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` DESC) AS `bfcol_1` + `bool_col` <> LAG(`bool_col`, 1) OVER (ORDER BY `bool_col` DESC) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_int.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_int.sql index f4fd46ee2d..95d786b951 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_int.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_int.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_0` - LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` ASC NULLS LAST) AS `bfcol_1` + `int64_col` - LAG(`int64_col`, 1) OVER (ORDER BY `int64_col` ASC NULLS LAST) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql index df76aa1d33..40c9e6ddd8 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql @@ -1,14 +1,17 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, CASE - WHEN `bfcol_0` IS NULL + WHEN `int64_col` IS NULL THEN NULL - ELSE FIRST_VALUE(`bfcol_0`) OVER (ORDER BY `bfcol_0` DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) + ELSE FIRST_VALUE(`int64_col`) OVER ( + ORDER BY `int64_col` DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) END AS `bfcol_1` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first_non_null/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first_non_null/out.sql index 70eeefda7b..2ef7b7151e 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first_non_null/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first_non_null/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - FIRST_VALUE(`bfcol_0` IGNORE NULLS) OVER ( - ORDER BY `bfcol_0` ASC NULLS LAST + FIRST_VALUE(`int64_col` IGNORE NULLS) OVER ( + ORDER BY `int64_col` ASC NULLS LAST ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS `bfcol_1` FROM `bfcte_0` diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql index 4a5af9af32..ebeaa0e338 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql @@ -1,14 +1,17 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, CASE - WHEN `bfcol_0` IS NULL + WHEN `int64_col` IS NULL THEN NULL - ELSE LAST_VALUE(`bfcol_0`) OVER (ORDER BY `bfcol_0` DESC ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) + ELSE LAST_VALUE(`int64_col`) OVER ( + ORDER BY `int64_col` DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) END AS `bfcol_1` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last_non_null/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last_non_null/out.sql index a246618ff0..c626c263ac 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last_non_null/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last_non_null/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - LAST_VALUE(`bfcol_0` IGNORE NULLS) OVER ( - ORDER BY `bfcol_0` ASC NULLS LAST + LAST_VALUE(`int64_col` IGNORE NULLS) OVER ( + ORDER BY `int64_col` ASC NULLS LAST ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS `bfcol_1` FROM `bfcte_0` diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/out.sql index c88fa58d0f..1537d735ea 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/out.sql @@ -1,10 +1,10 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - MAX(`bfcol_0`) AS `bfcol_1` + MAX(`int64_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_out.sql index 4c86cb38e0..a234601b6a 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE WHEN `bfcol_0` IS NULL THEN NULL ELSE MAX(`bfcol_0`) OVER () END AS `bfcol_1` + CASE WHEN `int64_col` IS NULL THEN NULL ELSE MAX(`int64_col`) OVER () END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_partition_out.sql index 64dc97642b..f918500788 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_partition_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_partition_out.sql @@ -1,15 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `string_col` AS `bfcol_1` + `int64_col`, + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, CASE - WHEN `bfcol_0` IS NULL + WHEN `int64_col` IS NULL THEN NULL - ELSE MAX(`bfcol_0`) OVER (PARTITION BY `bfcol_1`) + ELSE MAX(`int64_col`) OVER (PARTITION BY `string_col`) END AS `bfcol_2` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/out.sql index 6d4bb6f89a..0b33d0b1d0 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/out.sql @@ -1,15 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `duration_col` AS `bfcol_2` + `bool_col`, + `duration_col`, + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_1` AS `bfcol_6`, - `bfcol_0` AS `bfcol_7`, - `bfcol_2` AS `bfcol_8` + `int64_col` AS `bfcol_6`, + `bool_col` AS `bfcol_7`, + `duration_col` AS `bfcol_8` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_out.sql index bc89091ca9..73bec9ccce 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE WHEN `bfcol_0` IS NULL THEN NULL ELSE AVG(`bfcol_0`) OVER () END AS `bfcol_1` + CASE WHEN `int64_col` IS NULL THEN NULL ELSE AVG(`int64_col`) OVER () END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_partition_out.sql index e6c1cf3bb4..d0a8345e12 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_partition_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_partition_out.sql @@ -1,15 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `string_col` AS `bfcol_1` + `int64_col`, + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, CASE - WHEN `bfcol_0` IS NULL + WHEN `int64_col` IS NULL THEN NULL - ELSE AVG(`bfcol_0`) OVER (PARTITION BY `bfcol_1`) + ELSE AVG(`int64_col`) OVER (PARTITION BY `string_col`) END AS `bfcol_2` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_median/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_median/out.sql index bf7006ef87..bfe94622b3 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_median/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_median/out.sql @@ -1,14 +1,14 @@ WITH `bfcte_0` AS ( SELECT - `date_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `string_col` AS `bfcol_2` + `date_col`, + `int64_col`, + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - APPROX_QUANTILES(`bfcol_1`, 2)[OFFSET(1)] AS `bfcol_3`, - APPROX_QUANTILES(`bfcol_0`, 2)[OFFSET(1)] AS `bfcol_4`, - APPROX_QUANTILES(`bfcol_2`, 2)[OFFSET(1)] AS `bfcol_5` + APPROX_QUANTILES(`int64_col`, 2)[OFFSET(1)] AS `bfcol_3`, + APPROX_QUANTILES(`date_col`, 2)[OFFSET(1)] AS `bfcol_4`, + APPROX_QUANTILES(`string_col`, 2)[OFFSET(1)] AS `bfcol_5` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/out.sql index b067817218..0848313456 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/out.sql @@ -1,10 +1,10 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - MIN(`bfcol_0`) AS `bfcol_1` + MIN(`int64_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_out.sql index 0e96b56c50..1d9db63491 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE WHEN `bfcol_0` IS NULL THEN NULL ELSE MIN(`bfcol_0`) OVER () END AS `bfcol_1` + CASE WHEN `int64_col` IS NULL THEN NULL ELSE MIN(`int64_col`) OVER () END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_partition_out.sql index 4121e80f43..8040f43ca5 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_partition_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_partition_out.sql @@ -1,15 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `string_col` AS `bfcol_1` + `int64_col`, + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, CASE - WHEN `bfcol_0` IS NULL + WHEN `int64_col` IS NULL THEN NULL - ELSE MIN(`bfcol_0`) OVER (PARTITION BY `bfcol_1`) + ELSE MIN(`int64_col`) OVER (PARTITION BY `string_col`) END AS `bfcol_2` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/out.sql index de422382d1..2d38311f45 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1` + `bool_col`, + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - VAR_POP(`bfcol_1`) AS `bfcol_4`, - VAR_POP(CAST(`bfcol_0` AS INT64)) AS `bfcol_5` + VAR_POP(`int64_col`) AS `bfcol_4`, + VAR_POP(CAST(`bool_col` AS INT64)) AS `bfcol_5` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/window_out.sql index fa04dad64e..eae3db0048 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/window_out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE WHEN `bfcol_0` IS NULL THEN NULL ELSE VAR_POP(`bfcol_0`) OVER () END AS `bfcol_1` + CASE WHEN `int64_col` IS NULL THEN NULL ELSE VAR_POP(`int64_col`) OVER () END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_quantile/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_quantile/out.sql index c1b3d1fffa..b79d8d381f 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_quantile/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_quantile/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - PERCENTILE_CONT(`bfcol_0`, 0.5) OVER () AS `bfcol_1`, - CAST(FLOOR(PERCENTILE_CONT(`bfcol_0`, 0.5) OVER ()) AS INT64) AS `bfcol_2` + PERCENTILE_CONT(`int64_col`, 0.5) OVER () AS `bfcol_1`, + CAST(FLOOR(PERCENTILE_CONT(`int64_col`, 0.5) OVER ()) AS INT64) AS `bfcol_2` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_rank/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_rank/out.sql index ca3b9e8e54..96b121bde4 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_rank/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_rank/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - RANK() OVER (ORDER BY `bfcol_0` DESC NULLS FIRST) AS `bfcol_1` + RANK() OVER (ORDER BY `int64_col` DESC NULLS FIRST) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lag.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lag.sql index 32e6eabb9b..7d1d62f1ae 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lag.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lag.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` ASC) AS `bfcol_1` + LAG(`int64_col`, 1) OVER (ORDER BY `int64_col` ASC) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lead.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lead.sql index f0797f1e17..67b40c99db 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lead.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lead.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - LEAD(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` ASC) AS `bfcol_1` + LEAD(`int64_col`, 1) OVER (ORDER BY `int64_col` ASC) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/noop.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/noop.sql index fef4a2bde8..0202cf5c21 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/noop.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/noop.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_0` AS `bfcol_1` + `int64_col` AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/out.sql index 9bfa6288c3..36a50302a6 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/out.sql @@ -1,15 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `duration_col` AS `bfcol_2` + `bool_col`, + `duration_col`, + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_1` AS `bfcol_6`, - `bfcol_0` AS `bfcol_7`, - `bfcol_2` AS `bfcol_8` + `int64_col` AS `bfcol_6`, + `bool_col` AS `bfcol_7`, + `duration_col` AS `bfcol_8` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/window_out.sql index e4e4ff0684..cb39f6dbc8 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/window_out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE WHEN `bfcol_0` IS NULL THEN NULL ELSE STDDEV(`bfcol_0`) OVER () END AS `bfcol_1` + CASE WHEN `int64_col` IS NULL THEN NULL ELSE STDDEV(`int64_col`) OVER () END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/out.sql index be684f6768..2bf6c26cd4 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1` + `bool_col`, + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - COALESCE(SUM(`bfcol_1`), 0) AS `bfcol_4`, - COALESCE(SUM(CAST(`bfcol_0` AS INT64)), 0) AS `bfcol_5` + COALESCE(SUM(`int64_col`), 0) AS `bfcol_4`, + COALESCE(SUM(CAST(`bool_col` AS INT64)), 0) AS `bfcol_5` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_out.sql index 939b491dd0..401436019e 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_out.sql @@ -1,11 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE WHEN `bfcol_0` IS NULL THEN NULL ELSE COALESCE(SUM(`bfcol_0`) OVER (), 0) END AS `bfcol_1` + CASE + WHEN `int64_col` IS NULL + THEN NULL + ELSE COALESCE(SUM(`int64_col`) OVER (), 0) + END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_partition_out.sql index a23842867e..f8ada8a5d4 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_partition_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_partition_out.sql @@ -1,15 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `string_col` AS `bfcol_1` + `int64_col`, + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, CASE - WHEN `bfcol_0` IS NULL + WHEN `int64_col` IS NULL THEN NULL - ELSE COALESCE(SUM(`bfcol_0`) OVER (PARTITION BY `bfcol_1`), 0) + ELSE COALESCE(SUM(`int64_col`) OVER (PARTITION BY `string_col`), 0) END AS `bfcol_2` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_time_series_diff/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_time_series_diff/out.sql index 692685ee4d..645f583dc1 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_time_series_diff/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_time_series_diff/out.sql @@ -1,11 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - TIMESTAMP_DIFF(`bfcol_0`, LAG(`bfcol_0`, 1) OVER (ORDER BY `bfcol_0` ASC NULLS LAST), MICROSECOND) AS `bfcol_1` + TIMESTAMP_DIFF( + `timestamp_col`, + LAG(`timestamp_col`, 1) OVER (ORDER BY `timestamp_col` ASC NULLS LAST), + MICROSECOND + ) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/out.sql index 59ccd59e8f..733a22438c 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1` + `bool_col`, + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT - VARIANCE(`bfcol_1`) AS `bfcol_4`, - VARIANCE(CAST(`bfcol_0` AS INT64)) AS `bfcol_5` + VARIANCE(`int64_col`) AS `bfcol_4`, + VARIANCE(CAST(`bool_col` AS INT64)) AS `bfcol_5` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/window_out.sql index a65104215b..d300251447 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/window_out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE WHEN `bfcol_0` IS NULL THEN NULL ELSE VARIANCE(`bfcol_0`) OVER () END AS `bfcol_1` + CASE WHEN `int64_col` IS NULL THEN NULL ELSE VARIANCE(`int64_col`) OVER () END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify/out.sql index bb06760e4d..a40784a3ca 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, AI.CLASSIFY( - input => (`bfcol_0`), + input => (`string_col`), categories => ['greeting', 'rejection'], connection_id => 'bigframes-dev.us.bigframes-default-connection' ) AS `bfcol_1` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate/out.sql index 5c4ccefd7b..19f85b181b 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, AI.GENERATE( - prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + prompt => (`string_col`, ' is the same as ', `string_col`), connection_id => 'bigframes-dev.us.bigframes-default-connection', endpoint => 'gemini-2.5-flash', request_type => 'SHARED' diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql index 28905d0349..f844ed1691 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, AI.GENERATE_BOOL( - prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + prompt => (`string_col`, ' is the same as ', `string_col`), connection_id => 'bigframes-dev.us.bigframes-default-connection', endpoint => 'gemini-2.5-flash', request_type => 'SHARED' diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql index bf52361a52..35538c2ec2 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, AI.GENERATE_BOOL( - prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + prompt => (`string_col`, ' is the same as ', `string_col`), connection_id => 'bigframes-dev.us.bigframes-default-connection', request_type => 'SHARED', model_params => JSON '{}' diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql index cbb05264e9..fae92515cb 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, AI.GENERATE_DOUBLE( - prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + prompt => (`string_col`, ' is the same as ', `string_col`), connection_id => 'bigframes-dev.us.bigframes-default-connection', endpoint => 'gemini-2.5-flash', request_type => 'SHARED' diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql index a1c1a18664..f3ddf71014 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, AI.GENERATE_DOUBLE( - prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + prompt => (`string_col`, ' is the same as ', `string_col`), connection_id => 'bigframes-dev.us.bigframes-default-connection', request_type => 'SHARED', model_params => JSON '{}' diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql index ba5febe0cd..a0c92c959c 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, AI.GENERATE_INT( - prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + prompt => (`string_col`, ' is the same as ', `string_col`), connection_id => 'bigframes-dev.us.bigframes-default-connection', endpoint => 'gemini-2.5-flash', request_type => 'SHARED' diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql index 996906fe9c..1951e13325 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, AI.GENERATE_INT( - prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + prompt => (`string_col`, ' is the same as ', `string_col`), connection_id => 'bigframes-dev.us.bigframes-default-connection', request_type => 'SHARED', model_params => JSON '{}' diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_model_param/out.sql index 8726910619..3419a77c61 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_model_param/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_model_param/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, AI.GENERATE( - prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + prompt => (`string_col`, ' is the same as ', `string_col`), connection_id => 'bigframes-dev.us.bigframes-default-connection', request_type => 'SHARED', model_params => JSON '{}' diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_output_schema/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_output_schema/out.sql index 62fc2f9db0..e1e1670f12 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_output_schema/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_output_schema/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, AI.GENERATE( - prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + prompt => (`string_col`, ' is the same as ', `string_col`), connection_id => 'bigframes-dev.us.bigframes-default-connection', endpoint => 'gemini-2.5-flash', request_type => 'SHARED', diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if/out.sql index d5b6a9330d..275ba8d423 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, AI.IF( - prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + prompt => (`string_col`, ' is the same as ', `string_col`), connection_id => 'bigframes-dev.us.bigframes-default-connection' ) AS `bfcol_1` FROM `bfcte_0` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score/out.sql index e2be615921..01c71065b9 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, AI.SCORE( - prompt => (`bfcol_0`, ' is the same as ', `bfcol_0`), + prompt => (`string_col`, ' is the same as ', `string_col`), connection_id => 'bigframes-dev.us.bigframes-default-connection' ) AS `bfcol_1` FROM `bfcte_0` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_index/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_index/out.sql index 4398084227..d8e223d5f8 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_index/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_index/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_list_col` AS `bfcol_0` + `string_list_col` FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_0`[SAFE_OFFSET(1)] AS `bfcol_1` + `string_list_col`[SAFE_OFFSET(1)] AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_only_start/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_only_start/out.sql index 1ffc3ee8f9..0034ffd69c 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_only_start/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_only_start/out.sql @@ -1,6 +1,6 @@ WITH `bfcte_0` AS ( SELECT - `string_list_col` AS `bfcol_0` + `string_list_col` FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` ), `bfcte_1` AS ( SELECT @@ -8,7 +8,7 @@ WITH `bfcte_0` AS ( ARRAY( SELECT el - FROM UNNEST(`bfcol_0`) AS el WITH OFFSET AS slice_idx + FROM UNNEST(`string_list_col`) AS el WITH OFFSET AS slice_idx WHERE slice_idx >= 1 ) AS `bfcol_1` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_start_and_stop/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_start_and_stop/out.sql index 878b60e5e2..f0638fa3af 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_start_and_stop/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice_with_start_and_stop/out.sql @@ -1,6 +1,6 @@ WITH `bfcte_0` AS ( SELECT - `string_list_col` AS `bfcol_0` + `string_list_col` FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` ), `bfcte_1` AS ( SELECT @@ -8,7 +8,7 @@ WITH `bfcte_0` AS ( ARRAY( SELECT el - FROM UNNEST(`bfcol_0`) AS el WITH OFFSET AS slice_idx + FROM UNNEST(`string_list_col`) AS el WITH OFFSET AS slice_idx WHERE slice_idx >= 1 AND slice_idx < 5 ) AS `bfcol_1` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_to_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_to_string/out.sql index 4dbd602bea..09446bb8f5 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_to_string/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_to_string/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_list_col` AS `bfcol_0` + `string_list_col` FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` ), `bfcte_1` AS ( SELECT *, - ARRAY_TO_STRING(`bfcol_0`, '.') AS `bfcol_1` + ARRAY_TO_STRING(`string_list_col`, '.') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_fetch_metadata/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_fetch_metadata/out.sql index 134fdc363b..bd99b86064 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_fetch_metadata/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_fetch_metadata/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `rowindex` AS `bfcol_0`, - `string_col` AS `bfcol_1` + `rowindex`, + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - OBJ.MAKE_REF(`bfcol_1`, 'bigframes-dev.test-region.bigframes-default-connection') AS `bfcol_4` + OBJ.MAKE_REF(`string_col`, 'bigframes-dev.test-region.bigframes-default-connection') AS `bfcol_4` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT @@ -20,6 +20,6 @@ WITH `bfcte_0` AS ( FROM `bfcte_2` ) SELECT - `bfcol_0` AS `rowindex`, + `rowindex`, `bfcol_10` AS `version` FROM `bfcte_3` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_get_access_url/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_get_access_url/out.sql index 4a963b4972..c65436e530 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_get_access_url/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_get_access_url/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `rowindex` AS `bfcol_0`, - `string_col` AS `bfcol_1` + `rowindex`, + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - OBJ.MAKE_REF(`bfcol_1`, 'bigframes-dev.test-region.bigframes-default-connection') AS `bfcol_4` + OBJ.MAKE_REF(`string_col`, 'bigframes-dev.test-region.bigframes-default-connection') AS `bfcol_4` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT @@ -20,6 +20,6 @@ WITH `bfcte_0` AS ( FROM `bfcte_2` ) SELECT - `bfcol_0` AS `rowindex`, + `rowindex`, `bfcol_10` AS `string_col` FROM `bfcte_3` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_make_ref/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_make_ref/out.sql index e3228feaaa..d74449c986 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_make_ref/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_blob_ops/test_obj_make_ref/out.sql @@ -1,15 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `rowindex` AS `bfcol_0`, - `string_col` AS `bfcol_1` + `rowindex`, + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - OBJ.MAKE_REF(`bfcol_1`, 'bigframes-dev.test-region.bigframes-default-connection') AS `bfcol_4` + OBJ.MAKE_REF(`string_col`, 'bigframes-dev.test-region.bigframes-default-connection') AS `bfcol_4` FROM `bfcte_0` ) SELECT - `bfcol_0` AS `rowindex`, + `rowindex`, `bfcol_4` AS `string_col` FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_and_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_and_op/out.sql index 42c5847401..634a936a0e 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_and_op/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_and_op/out.sql @@ -1,16 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `rowindex` AS `bfcol_2` + `bool_col`, + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_2` AS `bfcol_6`, - `bfcol_0` AS `bfcol_7`, - `bfcol_1` AS `bfcol_8`, - `bfcol_1` & `bfcol_1` AS `bfcol_9` + `rowindex` AS `bfcol_6`, + `bool_col` AS `bfcol_7`, + `int64_col` AS `bfcol_8`, + `int64_col` & `int64_col` AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_or_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_or_op/out.sql index d1e7bd1822..0069b07d8f 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_or_op/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_or_op/out.sql @@ -1,16 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `rowindex` AS `bfcol_2` + `bool_col`, + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_2` AS `bfcol_6`, - `bfcol_0` AS `bfcol_7`, - `bfcol_1` AS `bfcol_8`, - `bfcol_1` | `bfcol_1` AS `bfcol_9` + `rowindex` AS `bfcol_6`, + `bool_col` AS `bfcol_7`, + `int64_col` AS `bfcol_8`, + `int64_col` | `int64_col` AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_xor_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_xor_op/out.sql index 7d5f74ede7..e4c87ed720 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_xor_op/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_xor_op/out.sql @@ -1,16 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `rowindex` AS `bfcol_2` + `bool_col`, + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_2` AS `bfcol_6`, - `bfcol_0` AS `bfcol_7`, - `bfcol_1` AS `bfcol_8`, - `bfcol_1` ^ `bfcol_1` AS `bfcol_9` + `rowindex` AS `bfcol_6`, + `bool_col` AS `bfcol_7`, + `int64_col` AS `bfcol_8`, + `int64_col` ^ `int64_col` AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_null_match/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_null_match/out.sql index 90cbcfe5c7..57af99a52b 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_null_match/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_null_match/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1` + `bool_col`, + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - COALESCE(CAST(`bfcol_1` AS STRING), '$NULL_SENTINEL$') = COALESCE(CAST(CAST(`bfcol_0` AS INT64) AS STRING), '$NULL_SENTINEL$') AS `bfcol_4` + COALESCE(CAST(`int64_col` AS STRING), '$NULL_SENTINEL$') = COALESCE(CAST(CAST(`bool_col` AS INT64) AS STRING), '$NULL_SENTINEL$') AS `bfcol_4` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_numeric/out.sql index 8e3c52310d..9c7c19e61c 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_numeric/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_numeric/out.sql @@ -1,16 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `rowindex` AS `bfcol_2` + `bool_col`, + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_2` AS `bfcol_6`, - `bfcol_1` AS `bfcol_7`, - `bfcol_0` AS `bfcol_8`, - `bfcol_1` = `bfcol_1` AS `bfcol_9` + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` = `int64_col` AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ge_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ge_numeric/out.sql index 494cb861a7..e99fe49c8e 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ge_numeric/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ge_numeric/out.sql @@ -1,16 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `rowindex` AS `bfcol_2` + `bool_col`, + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_2` AS `bfcol_6`, - `bfcol_1` AS `bfcol_7`, - `bfcol_0` AS `bfcol_8`, - `bfcol_1` >= `bfcol_1` AS `bfcol_9` + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` >= `int64_col` AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_gt_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_gt_numeric/out.sql index b0c8768850..4e5aba3d31 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_gt_numeric/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_gt_numeric/out.sql @@ -1,16 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `rowindex` AS `bfcol_2` + `bool_col`, + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_2` AS `bfcol_6`, - `bfcol_1` AS `bfcol_7`, - `bfcol_0` AS `bfcol_8`, - `bfcol_1` > `bfcol_1` AS `bfcol_9` + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` > `int64_col` AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_is_in/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_is_in/out.sql index 7a1a2a743d..197ed279fa 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_is_in/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_is_in/out.sql @@ -1,23 +1,23 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `float64_col` AS `bfcol_1` + `float64_col`, + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - COALESCE(`bfcol_0` IN (1, 2, 3), FALSE) AS `bfcol_2`, + COALESCE(`int64_col` IN (1, 2, 3), FALSE) AS `bfcol_2`, ( - `bfcol_0` IS NULL - ) OR `bfcol_0` IN (123456) AS `bfcol_3`, - COALESCE(`bfcol_0` IN (1.0, 2.0, 3.0), FALSE) AS `bfcol_4`, + `int64_col` IS NULL + ) OR `int64_col` IN (123456) AS `bfcol_3`, + COALESCE(`int64_col` IN (1.0, 2.0, 3.0), FALSE) AS `bfcol_4`, FALSE AS `bfcol_5`, - COALESCE(`bfcol_0` IN (2.5, 3), FALSE) AS `bfcol_6`, + COALESCE(`int64_col` IN (2.5, 3), FALSE) AS `bfcol_6`, FALSE AS `bfcol_7`, - COALESCE(`bfcol_0` IN (123456), FALSE) AS `bfcol_8`, + COALESCE(`int64_col` IN (123456), FALSE) AS `bfcol_8`, ( - `bfcol_1` IS NULL - ) OR `bfcol_1` IN (1, 2, 3) AS `bfcol_9` + `float64_col` IS NULL + ) OR `float64_col` IN (1, 2, 3) AS `bfcol_9` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_le_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_le_numeric/out.sql index 2f642d8cbb..97a00d1c88 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_le_numeric/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_le_numeric/out.sql @@ -1,16 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `rowindex` AS `bfcol_2` + `bool_col`, + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_2` AS `bfcol_6`, - `bfcol_1` AS `bfcol_7`, - `bfcol_0` AS `bfcol_8`, - `bfcol_1` <= `bfcol_1` AS `bfcol_9` + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` <= `int64_col` AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_lt_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_lt_numeric/out.sql index b244e3cbcc..addebd3187 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_lt_numeric/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_lt_numeric/out.sql @@ -1,16 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `rowindex` AS `bfcol_2` + `bool_col`, + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_2` AS `bfcol_6`, - `bfcol_1` AS `bfcol_7`, - `bfcol_0` AS `bfcol_8`, - `bfcol_1` < `bfcol_1` AS `bfcol_9` + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` < `int64_col` AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_maximum_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_maximum_op/out.sql index c0c0f5c97f..bbef212707 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_maximum_op/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_maximum_op/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `float64_col` AS `bfcol_1` + `float64_col`, + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - GREATEST(`bfcol_0`, `bfcol_1`) AS `bfcol_2` + GREATEST(`int64_col`, `float64_col`) AS `bfcol_2` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_minimum_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_minimum_op/out.sql index 429c3d2861..1f00f5892e 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_minimum_op/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_minimum_op/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `float64_col` AS `bfcol_1` + `float64_col`, + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - LEAST(`bfcol_0`, `bfcol_1`) AS `bfcol_2` + LEAST(`int64_col`, `float64_col`) AS `bfcol_2` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ne_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ne_numeric/out.sql index 6fba4b960f..417d24aa72 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ne_numeric/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ne_numeric/out.sql @@ -1,16 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `rowindex` AS `bfcol_2` + `bool_col`, + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_2` AS `bfcol_6`, - `bfcol_1` AS `bfcol_7`, - `bfcol_0` AS `bfcol_8`, - `bfcol_1` <> `bfcol_1` AS `bfcol_9` + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` <> `int64_col` AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_add_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_add_timedelta/out.sql index a47531999b..2fef18eeb8 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_add_timedelta/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_add_timedelta/out.sql @@ -1,16 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `date_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1`, - `timestamp_col` AS `bfcol_2` + `date_col`, + `rowindex`, + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_1` AS `bfcol_6`, - `bfcol_2` AS `bfcol_7`, - `bfcol_0` AS `bfcol_8`, - TIMESTAMP_ADD(CAST(`bfcol_0` AS DATETIME), INTERVAL 86400000000 MICROSECOND) AS `bfcol_9` + `rowindex` AS `bfcol_6`, + `timestamp_col` AS `bfcol_7`, + `date_col` AS `bfcol_8`, + TIMESTAMP_ADD(CAST(`date_col` AS DATETIME), INTERVAL 86400000000 MICROSECOND) AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_date/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_date/out.sql index 615a4a92bb..b8f46ceafe 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_date/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_date/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - DATE(`bfcol_0`) AS `bfcol_1` + DATE(`timestamp_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_day/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_day/out.sql index 460823fa20..52d80fd2a6 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_day/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_day/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - EXTRACT(DAY FROM `bfcol_0`) AS `bfcol_1` + EXTRACT(DAY FROM `timestamp_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofweek/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofweek/out.sql index 55d3832c1f..0119bbb4e9 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofweek/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofweek/out.sql @@ -1,15 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `date_col` AS `bfcol_0`, - `datetime_col` AS `bfcol_1`, - `timestamp_col` AS `bfcol_2` + `date_col`, + `datetime_col`, + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CAST(MOD(EXTRACT(DAYOFWEEK FROM `bfcol_1`) + 5, 7) AS INT64) AS `bfcol_6`, - CAST(MOD(EXTRACT(DAYOFWEEK FROM `bfcol_2`) + 5, 7) AS INT64) AS `bfcol_7`, - CAST(MOD(EXTRACT(DAYOFWEEK FROM `bfcol_0`) + 5, 7) AS INT64) AS `bfcol_8` + CAST(MOD(EXTRACT(DAYOFWEEK FROM `datetime_col`) + 5, 7) AS INT64) AS `bfcol_6`, + CAST(MOD(EXTRACT(DAYOFWEEK FROM `timestamp_col`) + 5, 7) AS INT64) AS `bfcol_7`, + CAST(MOD(EXTRACT(DAYOFWEEK FROM `date_col`) + 5, 7) AS INT64) AS `bfcol_8` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofyear/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofyear/out.sql index 4b60bcc4ca..521419757a 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofyear/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofyear/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - EXTRACT(DAYOFYEAR FROM `bfcol_0`) AS `bfcol_1` + EXTRACT(DAYOFYEAR FROM `timestamp_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_floor_dt/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_floor_dt/out.sql index a8877f8cfa..fe76efb609 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_floor_dt/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_floor_dt/out.sql @@ -1,23 +1,23 @@ WITH `bfcte_0` AS ( SELECT - `datetime_col` AS `bfcol_0`, - `timestamp_col` AS `bfcol_1` + `datetime_col`, + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - TIMESTAMP_TRUNC(`bfcol_1`, MICROSECOND) AS `bfcol_2`, - TIMESTAMP_TRUNC(`bfcol_1`, MILLISECOND) AS `bfcol_3`, - TIMESTAMP_TRUNC(`bfcol_1`, SECOND) AS `bfcol_4`, - TIMESTAMP_TRUNC(`bfcol_1`, MINUTE) AS `bfcol_5`, - TIMESTAMP_TRUNC(`bfcol_1`, HOUR) AS `bfcol_6`, - TIMESTAMP_TRUNC(`bfcol_1`, DAY) AS `bfcol_7`, - TIMESTAMP_TRUNC(`bfcol_1`, WEEK(MONDAY)) AS `bfcol_8`, - TIMESTAMP_TRUNC(`bfcol_1`, MONTH) AS `bfcol_9`, - TIMESTAMP_TRUNC(`bfcol_1`, QUARTER) AS `bfcol_10`, - TIMESTAMP_TRUNC(`bfcol_1`, YEAR) AS `bfcol_11`, - TIMESTAMP_TRUNC(`bfcol_0`, MICROSECOND) AS `bfcol_12`, - TIMESTAMP_TRUNC(`bfcol_0`, MICROSECOND) AS `bfcol_13` + TIMESTAMP_TRUNC(`timestamp_col`, MICROSECOND) AS `bfcol_2`, + TIMESTAMP_TRUNC(`timestamp_col`, MILLISECOND) AS `bfcol_3`, + TIMESTAMP_TRUNC(`timestamp_col`, SECOND) AS `bfcol_4`, + TIMESTAMP_TRUNC(`timestamp_col`, MINUTE) AS `bfcol_5`, + TIMESTAMP_TRUNC(`timestamp_col`, HOUR) AS `bfcol_6`, + TIMESTAMP_TRUNC(`timestamp_col`, DAY) AS `bfcol_7`, + TIMESTAMP_TRUNC(`timestamp_col`, WEEK(MONDAY)) AS `bfcol_8`, + TIMESTAMP_TRUNC(`timestamp_col`, MONTH) AS `bfcol_9`, + TIMESTAMP_TRUNC(`timestamp_col`, QUARTER) AS `bfcol_10`, + TIMESTAMP_TRUNC(`timestamp_col`, YEAR) AS `bfcol_11`, + TIMESTAMP_TRUNC(`datetime_col`, MICROSECOND) AS `bfcol_12`, + TIMESTAMP_TRUNC(`datetime_col`, MICROSECOND) AS `bfcol_13` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_hour/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_hour/out.sql index 8cc9b9081f..5fc6621a7c 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_hour/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_hour/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - EXTRACT(HOUR FROM `bfcol_0`) AS `bfcol_1` + EXTRACT(HOUR FROM `timestamp_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_day/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_day/out.sql index f7203fc930..9422844b34 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_day/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_day/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CAST(MOD(EXTRACT(DAYOFWEEK FROM `bfcol_0`) + 5, 7) AS INT64) + 1 AS `bfcol_1` + CAST(MOD(EXTRACT(DAYOFWEEK FROM `timestamp_col`) + 5, 7) AS INT64) + 1 AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_week/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_week/out.sql index f22e963bc3..4db49fb10f 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_week/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_week/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - EXTRACT(ISOWEEK FROM `bfcol_0`) AS `bfcol_1` + EXTRACT(ISOWEEK FROM `timestamp_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_year/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_year/out.sql index 13b56f709c..8d49933202 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_year/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_year/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - EXTRACT(ISOYEAR FROM `bfcol_0`) AS `bfcol_1` + EXTRACT(ISOYEAR FROM `timestamp_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_minute/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_minute/out.sql index 4ef9b8142f..e089a77af5 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_minute/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_minute/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - EXTRACT(MINUTE FROM `bfcol_0`) AS `bfcol_1` + EXTRACT(MINUTE FROM `timestamp_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_month/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_month/out.sql index 4912622898..53d135903b 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_month/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_month/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - EXTRACT(MONTH FROM `bfcol_0`) AS `bfcol_1` + EXTRACT(MONTH FROM `timestamp_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_normalize/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_normalize/out.sql index 3c7efd3098..b542dfea72 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_normalize/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_normalize/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - TIMESTAMP_TRUNC(`bfcol_0`, DAY) AS `bfcol_1` + TIMESTAMP_TRUNC(`timestamp_col`, DAY) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_quarter/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_quarter/out.sql index 2be2866661..4a232cb5a3 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_quarter/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_quarter/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - EXTRACT(QUARTER FROM `bfcol_0`) AS `bfcol_1` + EXTRACT(QUARTER FROM `timestamp_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_second/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_second/out.sql index 144b704788..e86d830b73 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_second/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_second/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - EXTRACT(SECOND FROM `bfcol_0`) AS `bfcol_1` + EXTRACT(SECOND FROM `timestamp_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_strftime/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_strftime/out.sql index 077c30e7cb..190cd7895b 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_strftime/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_strftime/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - FORMAT_TIMESTAMP('%Y-%m-%d', `bfcol_0`) AS `bfcol_1` + FORMAT_TIMESTAMP('%Y-%m-%d', `timestamp_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_sub_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_sub_timedelta/out.sql index 2d615fcca6..ebcffd67f6 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_sub_timedelta/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_sub_timedelta/out.sql @@ -1,17 +1,17 @@ WITH `bfcte_0` AS ( SELECT - `date_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1`, - `timestamp_col` AS `bfcol_2`, - `duration_col` AS `bfcol_3` + `date_col`, + `duration_col`, + `rowindex`, + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_1` AS `bfcol_8`, - `bfcol_2` AS `bfcol_9`, - `bfcol_0` AS `bfcol_10`, - `bfcol_3` AS `bfcol_11` + `rowindex` AS `bfcol_8`, + `timestamp_col` AS `bfcol_9`, + `date_col` AS `bfcol_10`, + `duration_col` AS `bfcol_11` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_time/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_time/out.sql index 6b74efafd5..5a8ab600ba 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_time/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_time/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - TIME(`bfcol_0`) AS `bfcol_1` + TIME(`timestamp_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_datetime/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_datetime/out.sql index 096f14cc85..bbba3b1533 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_datetime/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_datetime/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CAST(TIMESTAMP_SECONDS(`bfcol_0`) AS DATETIME) AS `bfcol_1` + CAST(TIMESTAMP_SECONDS(`int64_col`) AS DATETIME) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_timestamp/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_timestamp/out.sql index b1e66ce3e7..df01fb3269 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_timestamp/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_timestamp/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - TIMESTAMP_SECONDS(`bfcol_0`) AS `bfcol_1` + TIMESTAMP_SECONDS(`int64_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_micros/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_micros/out.sql index dcbf0be5c2..e6515017f2 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_micros/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_micros/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - UNIX_MICROS(`bfcol_0`) AS `bfcol_1` + UNIX_MICROS(`timestamp_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_millis/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_millis/out.sql index ca58fbc97c..caec5effe0 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_millis/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_millis/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - UNIX_MILLIS(`bfcol_0`) AS `bfcol_1` + UNIX_MILLIS(`timestamp_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_seconds/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_seconds/out.sql index 21f0b7b8c8..6dc0ea2a02 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_seconds/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_seconds/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - UNIX_SECONDS(`bfcol_0`) AS `bfcol_1` + UNIX_SECONDS(`timestamp_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_year/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_year/out.sql index 8352a65e9e..1ceb674137 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_year/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_year/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `timestamp_col` AS `bfcol_0` + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - EXTRACT(YEAR FROM `bfcol_0`) AS `bfcol_1` + EXTRACT(YEAR FROM `timestamp_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_bool/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_bool/out.sql index 440aea9161..1f90accd0b 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_bool/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_bool/out.sql @@ -1,14 +1,14 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `float64_col` AS `bfcol_1` + `bool_col`, + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_0` AS `bfcol_2`, - `bfcol_1` <> 0 AS `bfcol_3`, - `bfcol_1` <> 0 AS `bfcol_4` + `bool_col` AS `bfcol_2`, + `float64_col` <> 0 AS `bfcol_3`, + `float64_col` <> 0 AS `bfcol_4` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_float/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_float/out.sql index 81a8805f47..32c8da56fa 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_float/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_float/out.sql @@ -1,13 +1,13 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0` + `bool_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CAST(CAST(`bfcol_0` AS INT64) AS FLOAT64) AS `bfcol_1`, + CAST(CAST(`bool_col` AS INT64) AS FLOAT64) AS `bfcol_1`, CAST('1.34235e4' AS FLOAT64) AS `bfcol_2`, - SAFE_CAST(SAFE_CAST(`bfcol_0` AS INT64) AS FLOAT64) AS `bfcol_3` + SAFE_CAST(SAFE_CAST(`bool_col` AS INT64) AS FLOAT64) AS `bfcol_3` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_from_json/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_from_json/out.sql index 25d51b26b3..d1577c0664 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_from_json/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_from_json/out.sql @@ -1,15 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `json_col` AS `bfcol_0` + `json_col` FROM `bigframes-dev`.`sqlglot_test`.`json_types` ), `bfcte_1` AS ( SELECT *, - INT64(`bfcol_0`) AS `bfcol_1`, - FLOAT64(`bfcol_0`) AS `bfcol_2`, - BOOL(`bfcol_0`) AS `bfcol_3`, - STRING(`bfcol_0`) AS `bfcol_4`, - SAFE.INT64(`bfcol_0`) AS `bfcol_5` + INT64(`json_col`) AS `bfcol_1`, + FLOAT64(`json_col`) AS `bfcol_2`, + BOOL(`json_col`) AS `bfcol_3`, + STRING(`json_col`) AS `bfcol_4`, + SAFE.INT64(`json_col`) AS `bfcol_5` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_int/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_int/out.sql index 22aa2cf91a..e0fe2af9a9 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_int/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_int/out.sql @@ -1,22 +1,22 @@ WITH `bfcte_0` AS ( SELECT - `datetime_col` AS `bfcol_0`, - `numeric_col` AS `bfcol_1`, - `float64_col` AS `bfcol_2`, - `time_col` AS `bfcol_3`, - `timestamp_col` AS `bfcol_4` + `datetime_col`, + `float64_col`, + `numeric_col`, + `time_col`, + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - UNIX_MICROS(CAST(`bfcol_0` AS TIMESTAMP)) AS `bfcol_5`, - UNIX_MICROS(SAFE_CAST(`bfcol_0` AS TIMESTAMP)) AS `bfcol_6`, - TIME_DIFF(CAST(`bfcol_3` AS TIME), '00:00:00', MICROSECOND) AS `bfcol_7`, - TIME_DIFF(SAFE_CAST(`bfcol_3` AS TIME), '00:00:00', MICROSECOND) AS `bfcol_8`, - UNIX_MICROS(`bfcol_4`) AS `bfcol_9`, - CAST(TRUNC(`bfcol_1`) AS INT64) AS `bfcol_10`, - CAST(TRUNC(`bfcol_2`) AS INT64) AS `bfcol_11`, - SAFE_CAST(TRUNC(`bfcol_2`) AS INT64) AS `bfcol_12`, + UNIX_MICROS(CAST(`datetime_col` AS TIMESTAMP)) AS `bfcol_5`, + UNIX_MICROS(SAFE_CAST(`datetime_col` AS TIMESTAMP)) AS `bfcol_6`, + TIME_DIFF(CAST(`time_col` AS TIME), '00:00:00', MICROSECOND) AS `bfcol_7`, + TIME_DIFF(SAFE_CAST(`time_col` AS TIME), '00:00:00', MICROSECOND) AS `bfcol_8`, + UNIX_MICROS(`timestamp_col`) AS `bfcol_9`, + CAST(TRUNC(`numeric_col`) AS INT64) AS `bfcol_10`, + CAST(TRUNC(`float64_col`) AS INT64) AS `bfcol_11`, + SAFE_CAST(TRUNC(`float64_col`) AS INT64) AS `bfcol_12`, CAST('100' AS INT64) AS `bfcol_13` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_json/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_json/out.sql index 8230b4a60b..2defc2e72b 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_json/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_json/out.sql @@ -1,19 +1,19 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `float64_col` AS `bfcol_2`, - `string_col` AS `bfcol_3` + `bool_col`, + `float64_col`, + `int64_col`, + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - PARSE_JSON(CAST(`bfcol_1` AS STRING)) AS `bfcol_4`, - PARSE_JSON(CAST(`bfcol_2` AS STRING)) AS `bfcol_5`, - PARSE_JSON(CAST(`bfcol_0` AS STRING)) AS `bfcol_6`, - PARSE_JSON(`bfcol_3`) AS `bfcol_7`, - PARSE_JSON(CAST(`bfcol_0` AS STRING)) AS `bfcol_8`, - PARSE_JSON_IN_SAFE(`bfcol_3`) AS `bfcol_9` + PARSE_JSON(CAST(`int64_col` AS STRING)) AS `bfcol_4`, + PARSE_JSON(CAST(`float64_col` AS STRING)) AS `bfcol_5`, + PARSE_JSON(CAST(`bool_col` AS STRING)) AS `bfcol_6`, + PARSE_JSON(`string_col`) AS `bfcol_7`, + PARSE_JSON(CAST(`bool_col` AS STRING)) AS `bfcol_8`, + PARSE_JSON_IN_SAFE(`string_col`) AS `bfcol_9` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_string/out.sql index f230a3799e..da6eb6ce18 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_string/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_string/out.sql @@ -1,14 +1,14 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1` + `bool_col`, + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CAST(`bfcol_1` AS STRING) AS `bfcol_2`, - INITCAP(CAST(`bfcol_0` AS STRING)) AS `bfcol_3`, - INITCAP(SAFE_CAST(`bfcol_0` AS STRING)) AS `bfcol_4` + CAST(`int64_col` AS STRING) AS `bfcol_2`, + INITCAP(CAST(`bool_col` AS STRING)) AS `bfcol_3`, + INITCAP(SAFE_CAST(`bool_col` AS STRING)) AS `bfcol_4` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_time_like/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_time_like/out.sql index 141b7ffa9a..6523d8376c 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_time_like/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_time_like/out.sql @@ -1,14 +1,14 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CAST(TIMESTAMP_MICROS(`bfcol_0`) AS DATETIME) AS `bfcol_1`, - CAST(TIMESTAMP_MICROS(`bfcol_0`) AS TIME) AS `bfcol_2`, - CAST(TIMESTAMP_MICROS(`bfcol_0`) AS TIMESTAMP) AS `bfcol_3`, - SAFE_CAST(TIMESTAMP_MICROS(`bfcol_0`) AS TIME) AS `bfcol_4` + CAST(TIMESTAMP_MICROS(`int64_col`) AS DATETIME) AS `bfcol_1`, + CAST(TIMESTAMP_MICROS(`int64_col`) AS TIME) AS `bfcol_2`, + CAST(TIMESTAMP_MICROS(`int64_col`) AS TIMESTAMP) AS `bfcol_3`, + SAFE_CAST(TIMESTAMP_MICROS(`int64_col`) AS TIME) AS `bfcol_4` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_case_when_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_case_when_op/out.sql index 08db34a632..08a489e240 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_case_when_op/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_case_when_op/out.sql @@ -1,23 +1,23 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `int64_too` AS `bfcol_2`, - `float64_col` AS `bfcol_3` + `bool_col`, + `float64_col`, + `int64_col`, + `int64_too` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE WHEN `bfcol_0` THEN `bfcol_1` END AS `bfcol_4`, - CASE WHEN `bfcol_0` THEN `bfcol_1` WHEN `bfcol_0` THEN `bfcol_2` END AS `bfcol_5`, - CASE WHEN `bfcol_0` THEN `bfcol_0` WHEN `bfcol_0` THEN `bfcol_0` END AS `bfcol_6`, + CASE WHEN `bool_col` THEN `int64_col` END AS `bfcol_4`, + CASE WHEN `bool_col` THEN `int64_col` WHEN `bool_col` THEN `int64_too` END AS `bfcol_5`, + CASE WHEN `bool_col` THEN `bool_col` WHEN `bool_col` THEN `bool_col` END AS `bfcol_6`, CASE - WHEN `bfcol_0` - THEN `bfcol_1` - WHEN `bfcol_0` - THEN CAST(`bfcol_0` AS INT64) - WHEN `bfcol_0` - THEN `bfcol_3` + WHEN `bool_col` + THEN `int64_col` + WHEN `bool_col` + THEN CAST(`bool_col` AS INT64) + WHEN `bool_col` + THEN `float64_col` END AS `bfcol_7` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_clip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_clip/out.sql index 172e1f53e7..b162593147 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_clip/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_clip/out.sql @@ -1,13 +1,13 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `int64_too` AS `bfcol_1`, - `rowindex` AS `bfcol_2` + `int64_col`, + `int64_too`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - GREATEST(LEAST(`bfcol_2`, `bfcol_1`), `bfcol_0`) AS `bfcol_3` + GREATEST(LEAST(`rowindex`, `int64_too`), `int64_col`) AS `bfcol_3` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_coalesce/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_coalesce/out.sql index 5b11a1ddeb..451de48b64 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_coalesce/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_coalesce/out.sql @@ -1,13 +1,13 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `int64_too` AS `bfcol_1` + `int64_col`, + `int64_too` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_0` AS `bfcol_2`, - COALESCE(`bfcol_1`, `bfcol_0`) AS `bfcol_3` + `int64_col` AS `bfcol_2`, + COALESCE(`int64_too`, `int64_col`) AS `bfcol_3` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_hash/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_hash/out.sql index 14d6df6d22..19fce60091 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_hash/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_hash/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - FARM_FINGERPRINT(`bfcol_0`) AS `bfcol_1` + FARM_FINGERPRINT(`string_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_invert/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_invert/out.sql index bf005efb05..1bd2eb7426 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_invert/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_invert/out.sql @@ -1,20 +1,20 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `bytes_col` AS `bfcol_1`, - `int64_col` AS `bfcol_2` + `bool_col`, + `bytes_col`, + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, ~( - `bfcol_2` + `int64_col` ) AS `bfcol_6`, ~( - `bfcol_1` + `bytes_col` ) AS `bfcol_7`, NOT ( - `bfcol_0` + `bool_col` ) AS `bfcol_8` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_isnull/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_isnull/out.sql index 55a2ebb970..0a549bdd44 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_isnull/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_isnull/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_0` IS NULL AS `bfcol_1` + `float64_col` IS NULL AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_map/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_map/out.sql index a17d6584ce..52a3174cf9 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_map/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_map/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE `bfcol_0` WHEN 'value1' THEN 'mapped1' END AS `bfcol_1` + CASE `string_col` WHEN 'value1' THEN 'mapped1' END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_notnull/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_notnull/out.sql index c1961f9d62..bf3425fe6d 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_notnull/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_notnull/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - NOT `bfcol_0` IS NULL AS `bfcol_1` + NOT `float64_col` IS NULL AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_row_key/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_row_key/out.sql index 080e35f68e..13b27c2e14 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_row_key/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_row_key/out.sql @@ -1,20 +1,20 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `bytes_col` AS `bfcol_1`, - `date_col` AS `bfcol_2`, - `datetime_col` AS `bfcol_3`, - `geography_col` AS `bfcol_4`, - `int64_col` AS `bfcol_5`, - `int64_too` AS `bfcol_6`, - `numeric_col` AS `bfcol_7`, - `float64_col` AS `bfcol_8`, - `rowindex` AS `bfcol_9`, - `rowindex_2` AS `bfcol_10`, - `string_col` AS `bfcol_11`, - `time_col` AS `bfcol_12`, - `timestamp_col` AS `bfcol_13`, - `duration_col` AS `bfcol_14` + `bool_col`, + `bytes_col`, + `date_col`, + `datetime_col`, + `duration_col`, + `float64_col`, + `geography_col`, + `int64_col`, + `int64_too`, + `numeric_col`, + `rowindex`, + `rowindex_2`, + `string_col`, + `time_col`, + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT @@ -22,42 +22,42 @@ WITH `bfcte_0` AS ( CONCAT( CAST(FARM_FINGERPRINT( CONCAT( - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_9` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_0` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_1` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_2` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_3` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(ST_ASTEXT(`bfcol_4`), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_5` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_6` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_7` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_8` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_9` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_10` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(`bfcol_11`, ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_12` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_13` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_14` AS STRING), ''), '\\', '\\\\')) + CONCAT('\\', REPLACE(COALESCE(CAST(`rowindex` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bool_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bytes_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`date_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`datetime_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(ST_ASTEXT(`geography_col`), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`int64_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`int64_too` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`numeric_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`float64_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`rowindex` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`rowindex_2` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(`string_col`, ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`time_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`timestamp_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`duration_col` AS STRING), ''), '\\', '\\\\')) ) ) AS STRING), CAST(FARM_FINGERPRINT( CONCAT( - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_9` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_0` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_1` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_2` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_3` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(ST_ASTEXT(`bfcol_4`), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_5` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_6` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_7` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_8` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_9` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_10` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(`bfcol_11`, ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_12` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_13` AS STRING), ''), '\\', '\\\\')), - CONCAT('\\', REPLACE(COALESCE(CAST(`bfcol_14` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`rowindex` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bool_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`bytes_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`date_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`datetime_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(ST_ASTEXT(`geography_col`), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`int64_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`int64_too` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`numeric_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`float64_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`rowindex` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`rowindex_2` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(`string_col`, ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`time_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`timestamp_col` AS STRING), ''), '\\', '\\\\')), + CONCAT('\\', REPLACE(COALESCE(CAST(`duration_col` AS STRING), ''), '\\', '\\\\')), '_' ) ) AS STRING), diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_sql_scalar_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_sql_scalar_op/out.sql index a79e006885..611cbf4e7e 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_sql_scalar_op/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_sql_scalar_op/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `bytes_col` AS `bfcol_1` + `bool_col`, + `bytes_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CAST(`bfcol_0` AS INT64) + BYTE_LENGTH(`bfcol_1`) AS `bfcol_2` + CAST(`bool_col` AS INT64) + BYTE_LENGTH(`bytes_col`) AS `bfcol_2` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_where/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_where/out.sql index 678208e9ba..872c794333 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_where/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_where/out.sql @@ -1,13 +1,13 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `float64_col` AS `bfcol_2` + `bool_col`, + `float64_col`, + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - IF(`bfcol_0`, `bfcol_1`, `bfcol_2`) AS `bfcol_3` + IF(`bool_col`, `int64_col`, `float64_col`) AS `bfcol_3` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_area/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_area/out.sql index 9b4b6894e0..105b5f1665 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_area/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_area/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `geography_col` AS `bfcol_0` + `geography_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - ST_AREA(`bfcol_0`) AS `bfcol_1` + ST_AREA(`geography_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_astext/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_astext/out.sql index 9557e2f1d6..c338baeb5f 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_astext/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_astext/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `geography_col` AS `bfcol_0` + `geography_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - ST_ASTEXT(`bfcol_0`) AS `bfcol_1` + ST_ASTEXT(`geography_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_boundary/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_boundary/out.sql index 31c0b45034..2d4ac2e960 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_boundary/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_boundary/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `geography_col` AS `bfcol_0` + `geography_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - ST_BOUNDARY(`bfcol_0`) AS `bfcol_1` + ST_BOUNDARY(`geography_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_buffer/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_buffer/out.sql index 9669c39a9f..84b3ab1600 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_buffer/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_buffer/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `geography_col` AS `bfcol_0` + `geography_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - ST_BUFFER(`bfcol_0`, 1.0, 8.0, FALSE) AS `bfcol_1` + ST_BUFFER(`geography_col`, 1.0, 8.0, FALSE) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_centroid/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_centroid/out.sql index 97867318ad..733f1e9495 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_centroid/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_centroid/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `geography_col` AS `bfcol_0` + `geography_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - ST_CENTROID(`bfcol_0`) AS `bfcol_1` + ST_CENTROID(`geography_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_convexhull/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_convexhull/out.sql index 8bb5801173..11b3b7f691 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_convexhull/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_convexhull/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `geography_col` AS `bfcol_0` + `geography_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - ST_CONVEXHULL(`bfcol_0`) AS `bfcol_1` + ST_CONVEXHULL(`geography_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_difference/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_difference/out.sql index e57a15443d..4e18216dda 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_difference/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_difference/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `geography_col` AS `bfcol_0` + `geography_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - ST_DIFFERENCE(`bfcol_0`, `bfcol_0`) AS `bfcol_1` + ST_DIFFERENCE(`geography_col`, `geography_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogfromtext/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogfromtext/out.sql index ba4d9dd182..1bbb114349 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogfromtext/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogfromtext/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - SAFE.ST_GEOGFROMTEXT(`bfcol_0`) AS `bfcol_1` + SAFE.ST_GEOGFROMTEXT(`string_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_isclosed/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_isclosed/out.sql index d905e8470b..516f175c13 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_isclosed/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_isclosed/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `geography_col` AS `bfcol_0` + `geography_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - ST_ISCLOSED(`bfcol_0`) AS `bfcol_1` + ST_ISCLOSED(`geography_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_length/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_length/out.sql index a023691d63..80eef1c906 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_length/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_length/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `geography_col` AS `bfcol_0` + `geography_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - ST_LENGTH(`bfcol_0`) AS `bfcol_1` + ST_LENGTH(`geography_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_x/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_x/out.sql index d4c0370ca8..09211270d1 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_x/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_x/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `geography_col` AS `bfcol_0` + `geography_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - SAFE.ST_X(`bfcol_0`) AS `bfcol_1` + SAFE.ST_X(`geography_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_y/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_y/out.sql index 196c2fcad6..625613ae2a 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_y/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_y/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `geography_col` AS `bfcol_0` + `geography_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - SAFE.ST_Y(`bfcol_0`) AS `bfcol_1` + SAFE.ST_Y(`geography_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract/out.sql index 3d23bd1e3e..435ee96df1 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `json_col` AS `bfcol_0` + `json_col` FROM `bigframes-dev`.`sqlglot_test`.`json_types` ), `bfcte_1` AS ( SELECT *, - JSON_EXTRACT(`bfcol_0`, '$') AS `bfcol_1` + JSON_EXTRACT(`json_col`, '$') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_array/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_array/out.sql index 1ddb3999b3..6c9c02594d 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_array/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_array/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `json_col` AS `bfcol_0` + `json_col` FROM `bigframes-dev`.`sqlglot_test`.`json_types` ), `bfcte_1` AS ( SELECT *, - JSON_EXTRACT_ARRAY(`bfcol_0`, '$') AS `bfcol_1` + JSON_EXTRACT_ARRAY(`json_col`, '$') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_string_array/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_string_array/out.sql index cbc3df74c0..a3a51be378 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_string_array/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_string_array/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `json_col` AS `bfcol_0` + `json_col` FROM `bigframes-dev`.`sqlglot_test`.`json_types` ), `bfcte_1` AS ( SELECT *, - JSON_EXTRACT_STRING_ARRAY(`bfcol_0`, '$') AS `bfcol_1` + JSON_EXTRACT_STRING_ARRAY(`json_col`, '$') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query/out.sql index b5d98b80d2..164fe2e426 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `json_col` AS `bfcol_0` + `json_col` FROM `bigframes-dev`.`sqlglot_test`.`json_types` ), `bfcte_1` AS ( SELECT *, - JSON_QUERY(`bfcol_0`, '$') AS `bfcol_1` + JSON_QUERY(`json_col`, '$') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query_array/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query_array/out.sql index 1b7a5908eb..4c3fa8e7e9 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query_array/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query_array/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `json_col` AS `bfcol_0` + `json_col` FROM `bigframes-dev`.`sqlglot_test`.`json_types` ), `bfcte_1` AS ( SELECT *, - JSON_QUERY_ARRAY(`bfcol_0`, '$') AS `bfcol_1` + JSON_QUERY_ARRAY(`json_col`, '$') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_set/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_set/out.sql index b226066b16..f41979ea2e 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_set/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_set/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `json_col` AS `bfcol_0` + `json_col` FROM `bigframes-dev`.`sqlglot_test`.`json_types` ), `bfcte_1` AS ( SELECT *, - JSON_SET(`bfcol_0`, '$.a', 100) AS `bfcol_1` + JSON_SET(`json_col`, '$.a', 100) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_value/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_value/out.sql index 3a84a1a92a..72f7237240 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_value/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_value/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `json_col` AS `bfcol_0` + `json_col` FROM `bigframes-dev`.`sqlglot_test`.`json_types` ), `bfcte_1` AS ( SELECT *, - JSON_VALUE(`bfcol_0`, '$') AS `bfcol_1` + JSON_VALUE(`json_col`, '$') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_parse_json/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_parse_json/out.sql index cdb091ae39..5f80187ba0 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_parse_json/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_parse_json/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - PARSE_JSON(`bfcol_0`) AS `bfcol_1` + PARSE_JSON(`string_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json_string/out.sql index 2786973933..e282c89c80 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json_string/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json_string/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `json_col` AS `bfcol_0` + `json_col` FROM `bigframes-dev`.`sqlglot_test`.`json_types` ), `bfcte_1` AS ( SELECT *, - TO_JSON_STRING(`bfcol_0`) AS `bfcol_1` + TO_JSON_STRING(`json_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_abs/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_abs/out.sql index 6f315f8113..0fb9589387 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_abs/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_abs/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - ABS(`bfcol_0`) AS `bfcol_1` + ABS(`float64_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_numeric/out.sql index 44335805e4..1707aad8c1 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_numeric/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_numeric/out.sql @@ -1,16 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `rowindex` AS `bfcol_2` + `bool_col`, + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_2` AS `bfcol_6`, - `bfcol_1` AS `bfcol_7`, - `bfcol_0` AS `bfcol_8`, - `bfcol_1` + `bfcol_1` AS `bfcol_9` + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` + `int64_col` AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_string/out.sql index de5129a6a3..cb674787ff 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_string/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_string/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CONCAT(`bfcol_0`, 'a') AS `bfcol_1` + CONCAT(`string_col`, 'a') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_timedelta/out.sql index a47531999b..2fef18eeb8 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_timedelta/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_timedelta/out.sql @@ -1,16 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `date_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1`, - `timestamp_col` AS `bfcol_2` + `date_col`, + `rowindex`, + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_1` AS `bfcol_6`, - `bfcol_2` AS `bfcol_7`, - `bfcol_0` AS `bfcol_8`, - TIMESTAMP_ADD(CAST(`bfcol_0` AS DATETIME), INTERVAL 86400000000 MICROSECOND) AS `bfcol_9` + `rowindex` AS `bfcol_6`, + `timestamp_col` AS `bfcol_7`, + `date_col` AS `bfcol_8`, + TIMESTAMP_ADD(CAST(`date_col` AS DATETIME), INTERVAL 86400000000 MICROSECOND) AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccos/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccos/out.sql index df695b7fbc..bb1766adf3 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccos/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccos/out.sql @@ -1,11 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE WHEN ABS(`bfcol_0`) > 1 THEN CAST('NaN' AS FLOAT64) ELSE ACOS(`bfcol_0`) END AS `bfcol_1` + CASE + WHEN ABS(`float64_col`) > 1 + THEN CAST('NaN' AS FLOAT64) + ELSE ACOS(`float64_col`) + END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccosh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccosh/out.sql index 5272e4a6a8..af556b9c3a 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccosh/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccosh/out.sql @@ -1,11 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE WHEN `bfcol_0` < 1 THEN CAST('NaN' AS FLOAT64) ELSE ACOSH(`bfcol_0`) END AS `bfcol_1` + CASE + WHEN `float64_col` < 1 + THEN CAST('NaN' AS FLOAT64) + ELSE ACOSH(`float64_col`) + END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsin/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsin/out.sql index 3afc7c64b8..8243232e0b 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsin/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsin/out.sql @@ -1,11 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE WHEN ABS(`bfcol_0`) > 1 THEN CAST('NaN' AS FLOAT64) ELSE ASIN(`bfcol_0`) END AS `bfcol_1` + CASE + WHEN ABS(`float64_col`) > 1 + THEN CAST('NaN' AS FLOAT64) + ELSE ASIN(`float64_col`) + END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsinh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsinh/out.sql index 6313e80e5f..e6bf3b339c 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsinh/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsinh/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - ASINH(`bfcol_0`) AS `bfcol_1` + ASINH(`float64_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan/out.sql index ec6a22e653..a85ff6403c 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - ATAN(`bfcol_0`) AS `bfcol_1` + ATAN(`float64_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan2/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan2/out.sql index d131828a98..28fc8c869d 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan2/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan2/out.sql @@ -1,14 +1,14 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `float64_col` AS `bfcol_2` + `bool_col`, + `float64_col`, + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - ATAN2(`bfcol_1`, `bfcol_2`) AS `bfcol_6`, - ATAN2(CAST(`bfcol_0` AS INT64), `bfcol_2`) AS `bfcol_7` + ATAN2(`int64_col`, `float64_col`) AS `bfcol_6`, + ATAN2(CAST(`bool_col` AS INT64), `float64_col`) AS `bfcol_7` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctanh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctanh/out.sql index 39b5f565fe..197bf59306 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctanh/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctanh/out.sql @@ -1,11 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE WHEN ABS(`bfcol_0`) > 1 THEN CAST('NaN' AS FLOAT64) ELSE ATANH(`bfcol_0`) END AS `bfcol_1` + CASE + WHEN ABS(`float64_col`) > 1 + THEN CAST('NaN' AS FLOAT64) + ELSE ATANH(`float64_col`) + END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ceil/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ceil/out.sql index 0959f3a0ad..922fe5c550 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ceil/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ceil/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CEIL(`bfcol_0`) AS `bfcol_1` + CEIL(`float64_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cos/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cos/out.sql index 126d2a63f2..0acb2bfa94 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cos/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cos/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - COS(`bfcol_0`) AS `bfcol_1` + COS(`float64_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosh/out.sql index f44dfaac41..8c84a25047 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosh/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosh/out.sql @@ -1,14 +1,14 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, CASE - WHEN ABS(`bfcol_0`) > 709.78 + WHEN ABS(`float64_col`) > 709.78 THEN CAST('Infinity' AS FLOAT64) - ELSE COSH(`bfcol_0`) + ELSE COSH(`float64_col`) END AS `bfcol_1` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosine_distance/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosine_distance/out.sql index eb46a16a83..ba6b6bfa9f 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosine_distance/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosine_distance/out.sql @@ -1,13 +1,13 @@ WITH `bfcte_0` AS ( SELECT - `int_list_col` AS `bfcol_0`, - `float_list_col` AS `bfcol_1` + `float_list_col`, + `int_list_col` FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` ), `bfcte_1` AS ( SELECT *, - ML.DISTANCE(`bfcol_0`, `bfcol_0`, 'COSINE') AS `bfcol_2`, - ML.DISTANCE(`bfcol_1`, `bfcol_1`, 'COSINE') AS `bfcol_3` + ML.DISTANCE(`int_list_col`, `int_list_col`, 'COSINE') AS `bfcol_2`, + ML.DISTANCE(`float_list_col`, `float_list_col`, 'COSINE') AS `bfcol_3` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_numeric/out.sql index 03d48276a0..db11f1529f 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_numeric/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_numeric/out.sql @@ -1,18 +1,18 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `float64_col` AS `bfcol_2`, - `rowindex` AS `bfcol_3` + `bool_col`, + `float64_col`, + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_3` AS `bfcol_8`, - `bfcol_1` AS `bfcol_9`, - `bfcol_0` AS `bfcol_10`, - `bfcol_2` AS `bfcol_11`, - IEEE_DIVIDE(`bfcol_1`, `bfcol_1`) AS `bfcol_12` + `rowindex` AS `bfcol_8`, + `int64_col` AS `bfcol_9`, + `bool_col` AS `bfcol_10`, + `float64_col` AS `bfcol_11`, + IEEE_DIVIDE(`int64_col`, `int64_col`) AS `bfcol_12` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_timedelta/out.sql index 6e05302fc9..1a82a67368 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_timedelta/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_timedelta/out.sql @@ -1,16 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1`, - `timestamp_col` AS `bfcol_2` + `int64_col`, + `rowindex`, + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_1` AS `bfcol_6`, - `bfcol_2` AS `bfcol_7`, - `bfcol_0` AS `bfcol_8`, - CAST(FLOOR(IEEE_DIVIDE(86400000000, `bfcol_0`)) AS INT64) AS `bfcol_9` + `rowindex` AS `bfcol_6`, + `timestamp_col` AS `bfcol_7`, + `int64_col` AS `bfcol_8`, + CAST(FLOOR(IEEE_DIVIDE(86400000000, `int64_col`)) AS INT64) AS `bfcol_9` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_exp/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_exp/out.sql index 6afa3f85a5..610b96cda7 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_exp/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_exp/out.sql @@ -1,14 +1,14 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, CASE - WHEN `bfcol_0` > 709.78 + WHEN `float64_col` > 709.78 THEN CAST('Infinity' AS FLOAT64) - ELSE EXP(`bfcol_0`) + ELSE EXP(`float64_col`) END AS `bfcol_1` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_expm1/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_expm1/out.sql index f3768deb4a..076ad584c2 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_expm1/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_expm1/out.sql @@ -1,14 +1,14 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, CASE - WHEN `bfcol_0` > 709.78 + WHEN `float64_col` > 709.78 THEN CAST('Infinity' AS FLOAT64) - ELSE EXP(`bfcol_0`) + ELSE EXP(`float64_col`) END - 1 AS `bfcol_1` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floor/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floor/out.sql index 56be1019e5..e0c2e1072e 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floor/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floor/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - FLOOR(`bfcol_0`) AS `bfcol_1` + FLOOR(`float64_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floordiv_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floordiv_timedelta/out.sql index bc4f94d306..2fe20fb618 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floordiv_timedelta/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floordiv_timedelta/out.sql @@ -1,8 +1,8 @@ WITH `bfcte_0` AS ( SELECT - `date_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1`, - `timestamp_col` AS `bfcol_2` + `date_col`, + `rowindex`, + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT @@ -11,8 +11,8 @@ WITH `bfcte_0` AS ( FROM `bfcte_0` ) SELECT - `bfcol_1` AS `rowindex`, - `bfcol_2` AS `timestamp_col`, - `bfcol_0` AS `date_col`, + `rowindex`, + `timestamp_col`, + `date_col`, `bfcol_6` AS `timedelta_div_numeric` FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ln/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ln/out.sql index 5d3d1ae09b..776cc33e0f 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ln/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ln/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE WHEN `bfcol_0` <= 0 THEN CAST('NaN' AS FLOAT64) ELSE LN(`bfcol_0`) END AS `bfcol_1` + CASE WHEN `float64_col` <= 0 THEN CAST('NaN' AS FLOAT64) ELSE LN(`float64_col`) END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log10/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log10/out.sql index 532776278d..11a318c22d 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log10/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log10/out.sql @@ -1,11 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE WHEN `bfcol_0` <= 0 THEN CAST('NaN' AS FLOAT64) ELSE LOG(10, `bfcol_0`) END AS `bfcol_1` + CASE + WHEN `float64_col` <= 0 + THEN CAST('NaN' AS FLOAT64) + ELSE LOG(10, `float64_col`) + END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log1p/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log1p/out.sql index 3904025cf8..4297fff227 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log1p/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log1p/out.sql @@ -1,11 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE WHEN `bfcol_0` <= -1 THEN CAST('NaN' AS FLOAT64) ELSE LN(1 + `bfcol_0`) END AS `bfcol_1` + CASE + WHEN `float64_col` <= -1 + THEN CAST('NaN' AS FLOAT64) + ELSE LN(1 + `float64_col`) + END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mod_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mod_numeric/out.sql index 64f456a72d..241ffa0b5e 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mod_numeric/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mod_numeric/out.sql @@ -1,33 +1,33 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `float64_col` AS `bfcol_1`, - `rowindex` AS `bfcol_2` + `float64_col`, + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_2` AS `bfcol_6`, - `bfcol_0` AS `bfcol_7`, - `bfcol_1` AS `bfcol_8`, + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `float64_col` AS `bfcol_8`, CASE - WHEN `bfcol_0` = CAST(0 AS INT64) - THEN CAST(0 AS INT64) * `bfcol_0` - WHEN `bfcol_0` < CAST(0 AS INT64) + WHEN `int64_col` = CAST(0 AS INT64) + THEN CAST(0 AS INT64) * `int64_col` + WHEN `int64_col` < CAST(0 AS INT64) AND ( - MOD(`bfcol_0`, `bfcol_0`) + MOD(`int64_col`, `int64_col`) ) > CAST(0 AS INT64) - THEN `bfcol_0` + ( - MOD(`bfcol_0`, `bfcol_0`) + THEN `int64_col` + ( + MOD(`int64_col`, `int64_col`) ) - WHEN `bfcol_0` > CAST(0 AS INT64) + WHEN `int64_col` > CAST(0 AS INT64) AND ( - MOD(`bfcol_0`, `bfcol_0`) + MOD(`int64_col`, `int64_col`) ) < CAST(0 AS INT64) - THEN `bfcol_0` + ( - MOD(`bfcol_0`, `bfcol_0`) + THEN `int64_col` + ( + MOD(`int64_col`, `int64_col`) ) - ELSE MOD(`bfcol_0`, `bfcol_0`) + ELSE MOD(`int64_col`, `int64_col`) END AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_numeric/out.sql index a9c81f4744..d0c537e482 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_numeric/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_numeric/out.sql @@ -1,16 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `rowindex` AS `bfcol_2` + `bool_col`, + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_2` AS `bfcol_6`, - `bfcol_1` AS `bfcol_7`, - `bfcol_0` AS `bfcol_8`, - `bfcol_1` * `bfcol_1` AS `bfcol_9` + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` * `int64_col` AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_timedelta/out.sql index f8752d0a60..ebdf296b2b 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_timedelta/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_timedelta/out.sql @@ -1,17 +1,17 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1`, - `timestamp_col` AS `bfcol_2`, - `duration_col` AS `bfcol_3` + `duration_col`, + `int64_col`, + `rowindex`, + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_1` AS `bfcol_8`, - `bfcol_2` AS `bfcol_9`, - `bfcol_0` AS `bfcol_10`, - `bfcol_3` AS `bfcol_11` + `rowindex` AS `bfcol_8`, + `timestamp_col` AS `bfcol_9`, + `int64_col` AS `bfcol_10`, + `duration_col` AS `bfcol_11` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_neg/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_neg/out.sql index 39bdd6da7f..4374af349b 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_neg/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_neg/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, -( - `bfcol_0` + `float64_col` ) AS `bfcol_1` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pos/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pos/out.sql index 2d6322a182..1ed016029a 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pos/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pos/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_0` AS `bfcol_1` + `float64_col` AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_round/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_round/out.sql index 8513c8d63f..9ce76f7c63 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_round/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_round/out.sql @@ -1,16 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `float64_col` AS `bfcol_1`, - `rowindex` AS `bfcol_2` + `float64_col`, + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_2` AS `bfcol_6`, - `bfcol_0` AS `bfcol_7`, - `bfcol_1` AS `bfcol_8`, - CAST(ROUND(`bfcol_0`, 0) AS INT64) AS `bfcol_9` + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `float64_col` AS `bfcol_8`, + CAST(ROUND(`int64_col`, 0) AS INT64) AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sin/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sin/out.sql index 62a5cff0b5..1699b6d8df 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sin/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sin/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - SIN(`bfcol_0`) AS `bfcol_1` + SIN(`float64_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sinh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sinh/out.sql index 711dba94a9..c1ea003e2d 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sinh/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sinh/out.sql @@ -1,14 +1,14 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, CASE - WHEN ABS(`bfcol_0`) > 709.78 - THEN SIGN(`bfcol_0`) * CAST('Infinity' AS FLOAT64) - ELSE SINH(`bfcol_0`) + WHEN ABS(`float64_col`) > 709.78 + THEN SIGN(`float64_col`) * CAST('Infinity' AS FLOAT64) + ELSE SINH(`float64_col`) END AS `bfcol_1` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sqrt/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sqrt/out.sql index e6a93e5e6c..152545d550 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sqrt/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sqrt/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CASE WHEN `bfcol_0` < 0 THEN CAST('NaN' AS FLOAT64) ELSE SQRT(`bfcol_0`) END AS `bfcol_1` + CASE WHEN `float64_col` < 0 THEN CAST('NaN' AS FLOAT64) ELSE SQRT(`float64_col`) END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_numeric/out.sql index a43fa2df67..7e0f07af7b 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_numeric/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_numeric/out.sql @@ -1,16 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `rowindex` AS `bfcol_2` + `bool_col`, + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_2` AS `bfcol_6`, - `bfcol_1` AS `bfcol_7`, - `bfcol_0` AS `bfcol_8`, - `bfcol_1` - `bfcol_1` AS `bfcol_9` + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `bool_col` AS `bfcol_8`, + `int64_col` - `int64_col` AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_timedelta/out.sql index 2d615fcca6..ebcffd67f6 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_timedelta/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_timedelta/out.sql @@ -1,17 +1,17 @@ WITH `bfcte_0` AS ( SELECT - `date_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1`, - `timestamp_col` AS `bfcol_2`, - `duration_col` AS `bfcol_3` + `date_col`, + `duration_col`, + `rowindex`, + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_1` AS `bfcol_8`, - `bfcol_2` AS `bfcol_9`, - `bfcol_0` AS `bfcol_10`, - `bfcol_3` AS `bfcol_11` + `rowindex` AS `bfcol_8`, + `timestamp_col` AS `bfcol_9`, + `date_col` AS `bfcol_10`, + `duration_col` AS `bfcol_11` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tan/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tan/out.sql index 5fac274b6b..f09d26a188 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tan/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tan/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - TAN(`bfcol_0`) AS `bfcol_1` + TAN(`float64_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tanh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tanh/out.sql index 5d1a5a5320..a5e5a87fbc 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tanh/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tanh/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_0` + `float64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - TANH(`bfcol_0`) AS `bfcol_1` + TANH(`float64_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_add_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_add_string/out.sql index de5129a6a3..cb674787ff 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_add_string/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_add_string/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CONCAT(`bfcol_0`, 'a') AS `bfcol_1` + CONCAT(`string_col`, 'a') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_capitalize/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_capitalize/out.sql index 7af1708347..b429007ffc 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_capitalize/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_capitalize/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - INITCAP(`bfcol_0`) AS `bfcol_1` + INITCAP(`string_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_endswith/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_endswith/out.sql index e3ac5ec033..eeb2574094 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_endswith/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_endswith/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - ENDS_WITH(`bfcol_0`, 'ab') AS `bfcol_1`, - ENDS_WITH(`bfcol_0`, 'ab') OR ENDS_WITH(`bfcol_0`, 'cd') AS `bfcol_2`, + ENDS_WITH(`string_col`, 'ab') AS `bfcol_1`, + ENDS_WITH(`string_col`, 'ab') OR ENDS_WITH(`string_col`, 'cd') AS `bfcol_2`, FALSE AS `bfcol_3` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalnum/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalnum/out.sql index 02e0094742..61c2643f16 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalnum/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalnum/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - REGEXP_CONTAINS(`bfcol_0`, '^(\\p{N}|\\p{L})+$') AS `bfcol_1` + REGEXP_CONTAINS(`string_col`, '^(\\p{N}|\\p{L})+$') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalpha/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalpha/out.sql index 2615d0452f..2b086f3e3d 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalpha/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalpha/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - REGEXP_CONTAINS(`bfcol_0`, '^\\p{L}+$') AS `bfcol_1` + REGEXP_CONTAINS(`string_col`, '^\\p{L}+$') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdecimal/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdecimal/out.sql index bc1fce3dbc..7355ab7aa7 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdecimal/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdecimal/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - REGEXP_CONTAINS(`bfcol_0`, '^\\d+$') AS `bfcol_1` + REGEXP_CONTAINS(`string_col`, '^\\d+$') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdigit/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdigit/out.sql index 1cb3a883ab..d7dd8c0729 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdigit/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdigit/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - REGEXP_CONTAINS(`bfcol_0`, '^\\p{Nd}+$') AS `bfcol_1` + REGEXP_CONTAINS(`string_col`, '^\\p{Nd}+$') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_islower/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_islower/out.sql index a621b71a3b..b6ff57797c 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_islower/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_islower/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - LOWER(`bfcol_0`) = `bfcol_0` AND UPPER(`bfcol_0`) <> `bfcol_0` AS `bfcol_1` + LOWER(`string_col`) = `string_col` AND UPPER(`string_col`) <> `string_col` AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isnumeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isnumeric/out.sql index 6566c1dd4c..6143b3685a 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isnumeric/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isnumeric/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - REGEXP_CONTAINS(`bfcol_0`, '^\\pN+$') AS `bfcol_1` + REGEXP_CONTAINS(`string_col`, '^\\pN+$') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isspace/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isspace/out.sql index aff12102be..47ccd642d4 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isspace/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isspace/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - REGEXP_CONTAINS(`bfcol_0`, '^\\s+$') AS `bfcol_1` + REGEXP_CONTAINS(`string_col`, '^\\s+$') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isupper/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isupper/out.sql index 03fe005910..54f7b55ce3 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isupper/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isupper/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - UPPER(`bfcol_0`) = `bfcol_0` AND LOWER(`bfcol_0`) <> `bfcol_0` AS `bfcol_1` + UPPER(`string_col`) = `string_col` AND LOWER(`string_col`) <> `string_col` AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len/out.sql index 35fd087bc7..63e8e160bf 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - LENGTH(`bfcol_0`) AS `bfcol_1` + LENGTH(`string_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lower/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lower/out.sql index e730cdee15..0a9623162a 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lower/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lower/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - LOWER(`bfcol_0`) AS `bfcol_1` + LOWER(`string_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lstrip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lstrip/out.sql index 49ed89b40b..ebe4c39bbf 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lstrip/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lstrip/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - TRIM(`bfcol_0`, ' ') AS `bfcol_1` + TRIM(`string_col`, ' ') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_regex_replace_str/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_regex_replace_str/out.sql index 149df6706c..2fd3365a80 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_regex_replace_str/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_regex_replace_str/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - REGEXP_REPLACE(`bfcol_0`, 'e', 'a') AS `bfcol_1` + REGEXP_REPLACE(`string_col`, 'e', 'a') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_replace_str/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_replace_str/out.sql index 3bd7e0e47e..61b2e2f432 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_replace_str/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_replace_str/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - REPLACE(`bfcol_0`, 'e', 'a') AS `bfcol_1` + REPLACE(`string_col`, 'e', 'a') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_reverse/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_reverse/out.sql index 1ef1074149..f9d287a591 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_reverse/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_reverse/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - REVERSE(`bfcol_0`) AS `bfcol_1` + REVERSE(`string_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_rstrip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_rstrip/out.sql index 49ed89b40b..ebe4c39bbf 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_rstrip/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_rstrip/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - TRIM(`bfcol_0`, ' ') AS `bfcol_1` + TRIM(`string_col`, ' ') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_startswith/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_startswith/out.sql index 9679c95f75..54c8adb7b8 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_startswith/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_startswith/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - STARTS_WITH(`bfcol_0`, 'ab') AS `bfcol_1`, - STARTS_WITH(`bfcol_0`, 'ab') OR STARTS_WITH(`bfcol_0`, 'cd') AS `bfcol_2`, + STARTS_WITH(`string_col`, 'ab') AS `bfcol_1`, + STARTS_WITH(`string_col`, 'ab') OR STARTS_WITH(`string_col`, 'cd') AS `bfcol_2`, FALSE AS `bfcol_3` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains/out.sql index a1aa0539ee..e973a97136 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_0` LIKE '%e%' AS `bfcol_1` + `string_col` LIKE '%e%' AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains_regex/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains_regex/out.sql index d0383172cb..510e52e254 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains_regex/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains_regex/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - REGEXP_CONTAINS(`bfcol_0`, 'e') AS `bfcol_1` + REGEXP_CONTAINS(`string_col`, 'e') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_extract/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_extract/out.sql index a7fac093e2..3e59f617ac 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_extract/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_extract/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - REGEXP_EXTRACT(`bfcol_0`, '([a-z]*)') AS `bfcol_1` + REGEXP_EXTRACT(`string_col`, '([a-z]*)') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_find/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_find/out.sql index b850262d80..82847d5e22 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_find/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_find/out.sql @@ -1,14 +1,14 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - INSTR(`bfcol_0`, 'e', 1) - 1 AS `bfcol_1`, - INSTR(`bfcol_0`, 'e', 3) - 1 AS `bfcol_2`, - INSTR(SUBSTRING(`bfcol_0`, 1, 5), 'e') - 1 AS `bfcol_3`, - INSTR(SUBSTRING(`bfcol_0`, 3, 3), 'e') - 1 AS `bfcol_4` + INSTR(`string_col`, 'e', 1) - 1 AS `bfcol_1`, + INSTR(`string_col`, 'e', 3) - 1 AS `bfcol_2`, + INSTR(SUBSTRING(`string_col`, 1, 5), 'e') - 1 AS `bfcol_3`, + INSTR(SUBSTRING(`string_col`, 3, 3), 'e') - 1 AS `bfcol_4` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_get/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_get/out.sql index 1278c3435d..b2a08e0e9d 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_get/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_get/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - SUBSTRING(`bfcol_0`, 2, 1) AS `bfcol_1` + SUBSTRING(`string_col`, 2, 1) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_pad/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_pad/out.sql index 4226843122..5f157bc5cb 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_pad/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_pad/out.sql @@ -1,19 +1,19 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - LPAD(`bfcol_0`, GREATEST(LENGTH(`bfcol_0`), 10), '-') AS `bfcol_1`, - RPAD(`bfcol_0`, GREATEST(LENGTH(`bfcol_0`), 10), '-') AS `bfcol_2`, + LPAD(`string_col`, GREATEST(LENGTH(`string_col`), 10), '-') AS `bfcol_1`, + RPAD(`string_col`, GREATEST(LENGTH(`string_col`), 10), '-') AS `bfcol_2`, RPAD( LPAD( - `bfcol_0`, - CAST(SAFE_DIVIDE(GREATEST(LENGTH(`bfcol_0`), 10) - LENGTH(`bfcol_0`), 2) AS INT64) + LENGTH(`bfcol_0`), + `string_col`, + CAST(SAFE_DIVIDE(GREATEST(LENGTH(`string_col`), 10) - LENGTH(`string_col`), 2) AS INT64) + LENGTH(`string_col`), '-' ), - GREATEST(LENGTH(`bfcol_0`), 10), + GREATEST(LENGTH(`string_col`), 10), '-' ) AS `bfcol_3` FROM `bfcte_0` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_repeat/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_repeat/out.sql index 1c94cfafe2..90a52a40b1 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_repeat/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_repeat/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - REPEAT(`bfcol_0`, 2) AS `bfcol_1` + REPEAT(`string_col`, 2) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_slice/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_slice/out.sql index 4f97ab3ac6..8bd2a5f7fe 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_slice/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_slice/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - SUBSTRING(`bfcol_0`, 2, 2) AS `bfcol_1` + SUBSTRING(`string_col`, 2, 2) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strconcat/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strconcat/out.sql index de5129a6a3..cb674787ff 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strconcat/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strconcat/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CONCAT(`bfcol_0`, 'a') AS `bfcol_1` + CONCAT(`string_col`, 'a') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_string_split/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_string_split/out.sql index fea0d6eaf1..37b15a0cf9 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_string_split/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_string_split/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - SPLIT(`bfcol_0`, ',') AS `bfcol_1` + SPLIT(`string_col`, ',') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strip/out.sql index 311f2c1727..771bb9c49f 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strip/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strip/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - TRIM(' ', `bfcol_0`) AS `bfcol_1` + TRIM(' ', `string_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_upper/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_upper/out.sql index d22c8cff5a..aa14c5f05d 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_upper/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_upper/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - UPPER(`bfcol_0`) AS `bfcol_1` + UPPER(`string_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_zfill/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_zfill/out.sql index e5d70ab44b..97651ece49 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_zfill/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_zfill/out.sql @@ -1,14 +1,14 @@ WITH `bfcte_0` AS ( SELECT - `string_col` AS `bfcol_0` + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, CASE - WHEN SUBSTRING(`bfcol_0`, 1, 1) = '-' - THEN CONCAT('-', LPAD(SUBSTRING(`bfcol_0`, 1), 9, '0')) - ELSE LPAD(`bfcol_0`, 10, '0') + WHEN SUBSTRING(`string_col`, 1, 1) = '-' + THEN CONCAT('-', LPAD(SUBSTRING(`string_col`, 1), 9, '0')) + ELSE LPAD(`string_col`, 10, '0') END AS `bfcol_1` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_field/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_field/out.sql index 60ae78b755..b85e88a90a 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_field/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_field/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `people` AS `bfcol_0` + `people` FROM `bigframes-dev`.`sqlglot_test`.`nested_structs_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_0`.`name` AS `bfcol_1`, - `bfcol_0`.`name` AS `bfcol_2` + `people`.`name` AS `bfcol_1`, + `people`.`name` AS `bfcol_2` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_op/out.sql index f7f741a523..575a162080 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_op/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_op/out.sql @@ -1,18 +1,18 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `float64_col` AS `bfcol_2`, - `string_col` AS `bfcol_3` + `bool_col`, + `float64_col`, + `int64_col`, + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, STRUCT( - `bfcol_0` AS bool_col, - `bfcol_1` AS int64_col, - `bfcol_2` AS float64_col, - `bfcol_3` AS string_col + `bool_col` AS bool_col, + `int64_col` AS int64_col, + `float64_col` AS float64_col, + `string_col` AS string_col ) AS `bfcol_4` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_timedelta_floor/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_timedelta_floor/out.sql index 1a8b9f4e39..432aefd7f6 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_timedelta_floor/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_timedelta_floor/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0` + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - FLOOR(`bfcol_0`) AS `bfcol_1` + FLOOR(`int64_col`) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_to_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_to_timedelta/out.sql index 057e6c778e..3c75cc3e89 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_to_timedelta/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_to_timedelta/out.sql @@ -1,14 +1,14 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1` + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_1` AS `bfcol_4`, - `bfcol_0` AS `bfcol_5`, - `bfcol_0` AS `bfcol_6` + `rowindex` AS `bfcol_4`, + `int64_col` AS `bfcol_5`, + `int64_col` AS `bfcol_6` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_aggregate/test_compile_aggregate/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_aggregate/test_compile_aggregate/out.sql index 02bba41a22..949ed82574 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_aggregate/test_compile_aggregate/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_aggregate/test_compile_aggregate/out.sql @@ -1,13 +1,13 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_too` AS `bfcol_1` + `bool_col`, + `int64_too` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_1` AS `bfcol_2`, - `bfcol_0` AS `bfcol_3` + `int64_too` AS `bfcol_2`, + `bool_col` AS `bfcol_3` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_aggregate/test_compile_aggregate_wo_dropna/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_aggregate/test_compile_aggregate_wo_dropna/out.sql index b8e127eb77..3c09250858 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_aggregate/test_compile_aggregate_wo_dropna/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_aggregate/test_compile_aggregate_wo_dropna/out.sql @@ -1,13 +1,13 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_too` AS `bfcol_1` + `bool_col`, + `int64_too` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_1` AS `bfcol_2`, - `bfcol_0` AS `bfcol_3` + `int64_too` AS `bfcol_2`, + `bool_col` AS `bfcol_3` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat/out.sql index faff452761..f606de4ed3 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat/out.sql @@ -1,8 +1,8 @@ WITH `bfcte_1` AS ( SELECT - `int64_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1`, - `string_col` AS `bfcol_2` + `int64_col`, + `rowindex`, + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_3` AS ( SELECT @@ -16,18 +16,18 @@ WITH `bfcte_1` AS ( FROM `bfcte_3` ), `bfcte_6` AS ( SELECT - `bfcol_1` AS `bfcol_9`, - `bfcol_1` AS `bfcol_10`, - `bfcol_0` AS `bfcol_11`, - `bfcol_2` AS `bfcol_12`, + `rowindex` AS `bfcol_9`, + `rowindex` AS `bfcol_10`, + `int64_col` AS `bfcol_11`, + `string_col` AS `bfcol_12`, `bfcol_8` AS `bfcol_13`, `bfcol_7` AS `bfcol_14` FROM `bfcte_5` ), `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_15`, - `rowindex` AS `bfcol_16`, - `string_col` AS `bfcol_17` + `int64_col`, + `rowindex`, + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_2` AS ( SELECT @@ -41,10 +41,10 @@ WITH `bfcte_1` AS ( FROM `bfcte_2` ), `bfcte_7` AS ( SELECT - `bfcol_16` AS `bfcol_24`, - `bfcol_16` AS `bfcol_25`, - `bfcol_15` AS `bfcol_26`, - `bfcol_17` AS `bfcol_27`, + `rowindex` AS `bfcol_24`, + `rowindex` AS `bfcol_25`, + `int64_col` AS `bfcol_26`, + `string_col` AS `bfcol_27`, `bfcol_23` AS `bfcol_28`, `bfcol_22` AS `bfcol_29` FROM `bfcte_4` diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql index 90825afd20..a67d1943a2 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql @@ -1,106 +1,106 @@ -WITH `bfcte_3` AS ( +WITH `bfcte_2` AS ( SELECT - `int64_col` AS `bfcol_0`, - `float64_col` AS `bfcol_1` + `float64_col`, + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_7` AS ( +), `bfcte_6` AS ( SELECT *, - ROW_NUMBER() OVER (ORDER BY `bfcol_0` ASC NULLS LAST) AS `bfcol_4` - FROM `bfcte_3` -), `bfcte_11` AS ( + ROW_NUMBER() OVER (ORDER BY `int64_col` ASC NULLS LAST) AS `bfcol_4` + FROM `bfcte_2` +), `bfcte_10` AS ( SELECT *, 0 AS `bfcol_5` - FROM `bfcte_7` -), `bfcte_14` AS ( + FROM `bfcte_6` +), `bfcte_13` AS ( SELECT - `bfcol_1` AS `bfcol_6`, - `bfcol_0` AS `bfcol_7`, + `float64_col` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, `bfcol_5` AS `bfcol_8`, `bfcol_4` AS `bfcol_9` - FROM `bfcte_11` -), `bfcte_2` AS ( + FROM `bfcte_10` +), `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_10`, - `int64_too` AS `bfcol_11`, - `float64_col` AS `bfcol_12` + `bool_col`, + `float64_col`, + `int64_too` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_6` AS ( +), `bfcte_4` AS ( SELECT * - FROM `bfcte_2` + FROM `bfcte_0` WHERE - `bfcol_10` -), `bfcte_10` AS ( + `bool_col` +), `bfcte_8` AS ( SELECT *, ROW_NUMBER() OVER () AS `bfcol_15` - FROM `bfcte_6` -), `bfcte_13` AS ( + FROM `bfcte_4` +), `bfcte_12` AS ( SELECT *, 1 AS `bfcol_16` - FROM `bfcte_10` -), `bfcte_15` AS ( + FROM `bfcte_8` +), `bfcte_14` AS ( SELECT - `bfcol_12` AS `bfcol_17`, - `bfcol_11` AS `bfcol_18`, + `float64_col` AS `bfcol_17`, + `int64_too` AS `bfcol_18`, `bfcol_16` AS `bfcol_19`, `bfcol_15` AS `bfcol_20` - FROM `bfcte_13` + FROM `bfcte_12` ), `bfcte_1` AS ( SELECT - `int64_col` AS `bfcol_21`, - `float64_col` AS `bfcol_22` + `float64_col`, + `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_5` AS ( SELECT *, - ROW_NUMBER() OVER (ORDER BY `bfcol_21` ASC NULLS LAST) AS `bfcol_25` + ROW_NUMBER() OVER (ORDER BY `int64_col` ASC NULLS LAST) AS `bfcol_25` FROM `bfcte_1` ), `bfcte_9` AS ( SELECT *, 2 AS `bfcol_26` FROM `bfcte_5` -), `bfcte_16` AS ( +), `bfcte_15` AS ( SELECT - `bfcol_22` AS `bfcol_27`, - `bfcol_21` AS `bfcol_28`, + `float64_col` AS `bfcol_27`, + `int64_col` AS `bfcol_28`, `bfcol_26` AS `bfcol_29`, `bfcol_25` AS `bfcol_30` FROM `bfcte_9` ), `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_31`, - `int64_too` AS `bfcol_32`, - `float64_col` AS `bfcol_33` + `bool_col`, + `float64_col`, + `int64_too` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_4` AS ( +), `bfcte_3` AS ( SELECT * FROM `bfcte_0` WHERE - `bfcol_31` -), `bfcte_8` AS ( + `bool_col` +), `bfcte_7` AS ( SELECT *, ROW_NUMBER() OVER () AS `bfcol_36` - FROM `bfcte_4` -), `bfcte_12` AS ( + FROM `bfcte_3` +), `bfcte_11` AS ( SELECT *, 3 AS `bfcol_37` - FROM `bfcte_8` -), `bfcte_17` AS ( + FROM `bfcte_7` +), `bfcte_16` AS ( SELECT - `bfcol_33` AS `bfcol_38`, - `bfcol_32` AS `bfcol_39`, + `float64_col` AS `bfcol_38`, + `int64_too` AS `bfcol_39`, `bfcol_37` AS `bfcol_40`, `bfcol_36` AS `bfcol_41` - FROM `bfcte_12` -), `bfcte_18` AS ( + FROM `bfcte_11` +), `bfcte_17` AS ( SELECT * FROM ( @@ -109,34 +109,34 @@ WITH `bfcte_3` AS ( `bfcol_7` AS `bfcol_43`, `bfcol_8` AS `bfcol_44`, `bfcol_9` AS `bfcol_45` - FROM `bfcte_14` + FROM `bfcte_13` UNION ALL SELECT `bfcol_17` AS `bfcol_42`, `bfcol_18` AS `bfcol_43`, `bfcol_19` AS `bfcol_44`, `bfcol_20` AS `bfcol_45` - FROM `bfcte_15` + FROM `bfcte_14` UNION ALL SELECT `bfcol_27` AS `bfcol_42`, `bfcol_28` AS `bfcol_43`, `bfcol_29` AS `bfcol_44`, `bfcol_30` AS `bfcol_45` - FROM `bfcte_16` + FROM `bfcte_15` UNION ALL SELECT `bfcol_38` AS `bfcol_42`, `bfcol_39` AS `bfcol_43`, `bfcol_40` AS `bfcol_44`, `bfcol_41` AS `bfcol_45` - FROM `bfcte_17` + FROM `bfcte_16` ) ) SELECT `bfcol_42` AS `float64_col`, `bfcol_43` AS `int64_col` -FROM `bfcte_18` +FROM `bfcte_17` ORDER BY `bfcol_44` ASC NULLS LAST, `bfcol_45` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_explode/test_compile_explode_dataframe/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_explode/test_compile_explode_dataframe/out.sql index 679da58f44..e594b67669 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_explode/test_compile_explode_dataframe/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_explode/test_compile_explode_dataframe/out.sql @@ -1,21 +1,21 @@ WITH `bfcte_0` AS ( SELECT - `rowindex` AS `bfcol_0`, - `int_list_col` AS `bfcol_1`, - `string_list_col` AS `bfcol_2` + `int_list_col`, + `rowindex`, + `string_list_col` FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` ), `bfcte_1` AS ( SELECT * - REPLACE (`bfcol_1`[SAFE_OFFSET(`bfcol_13`)] AS `bfcol_1`, `bfcol_2`[SAFE_OFFSET(`bfcol_13`)] AS `bfcol_2`) + REPLACE (`int_list_col`[SAFE_OFFSET(`bfcol_13`)] AS `int_list_col`, `string_list_col`[SAFE_OFFSET(`bfcol_13`)] AS `string_list_col`) FROM `bfcte_0` - CROSS JOIN UNNEST(GENERATE_ARRAY(0, LEAST(ARRAY_LENGTH(`bfcol_1`) - 1, ARRAY_LENGTH(`bfcol_2`) - 1))) AS `bfcol_13` WITH OFFSET AS `bfcol_7` + CROSS JOIN UNNEST(GENERATE_ARRAY(0, LEAST(ARRAY_LENGTH(`int_list_col`) - 1, ARRAY_LENGTH(`string_list_col`) - 1))) AS `bfcol_13` WITH OFFSET AS `bfcol_7` ) SELECT - `bfcol_0` AS `rowindex`, - `bfcol_0` AS `rowindex_1`, - `bfcol_1` AS `int_list_col`, - `bfcol_2` AS `string_list_col` + `rowindex`, + `rowindex` AS `rowindex_1`, + `int_list_col`, + `string_list_col` FROM `bfcte_1` ORDER BY `bfcol_7` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_explode/test_compile_explode_series/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_explode/test_compile_explode_series/out.sql index 8bfd1eb005..5af0aa0092 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_explode/test_compile_explode_series/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_explode/test_compile_explode_series/out.sql @@ -1,18 +1,18 @@ WITH `bfcte_0` AS ( SELECT - `rowindex` AS `bfcol_0`, - `int_list_col` AS `bfcol_1` + `int_list_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` ), `bfcte_1` AS ( SELECT * - REPLACE (`bfcol_8` AS `bfcol_1`) + REPLACE (`bfcol_8` AS `int_list_col`) FROM `bfcte_0` - CROSS JOIN UNNEST(`bfcol_1`) AS `bfcol_8` WITH OFFSET AS `bfcol_4` + CROSS JOIN UNNEST(`int_list_col`) AS `bfcol_8` WITH OFFSET AS `bfcol_4` ) SELECT - `bfcol_0` AS `rowindex`, - `bfcol_1` AS `int_list_col` + `rowindex`, + `int_list_col` FROM `bfcte_1` ORDER BY `bfcol_4` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_filter/test_compile_filter/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_filter/test_compile_filter/out.sql index 9ca7fb6a74..f5fff16f60 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_filter/test_compile_filter/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_filter/test_compile_filter/out.sql @@ -1,15 +1,15 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1` + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_1` AS `bfcol_5`, - `bfcol_1` AS `bfcol_6`, - `bfcol_0` AS `bfcol_7`, - `bfcol_1` >= 1 AS `bfcol_8` + `rowindex` AS `bfcol_5`, + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `rowindex` >= 1 AS `bfcol_8` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin/out.sql index e3bb0f9eba..77aef6ad8b 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin/out.sql @@ -1,37 +1,41 @@ WITH `bfcte_1` AS ( SELECT - `int64_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1` + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_2` AS ( +), `bfcte_3` AS ( SELECT - `bfcol_1` AS `bfcol_2`, - `bfcol_0` AS `bfcol_3` + `rowindex` AS `bfcol_2`, + `int64_col` AS `bfcol_3` FROM `bfcte_1` ), `bfcte_0` AS ( SELECT - `int64_too` AS `bfcol_4` + `int64_too` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_3` AS ( +), `bfcte_2` AS ( + SELECT + `int64_too` + FROM `bfcte_0` + GROUP BY + `int64_too` +), `bfcte_4` AS ( SELECT - `bfcte_2`.*, + `bfcte_3`.*, EXISTS( SELECT 1 FROM ( SELECT - `bfcol_4` - FROM `bfcte_0` - GROUP BY - `bfcol_4` + `int64_too` AS `bfcol_4` + FROM `bfcte_2` ) AS `bft_0` WHERE - COALESCE(`bfcte_2`.`bfcol_3`, 0) = COALESCE(`bft_0`.`bfcol_4`, 0) - AND COALESCE(`bfcte_2`.`bfcol_3`, 1) = COALESCE(`bft_0`.`bfcol_4`, 1) + COALESCE(`bfcte_3`.`bfcol_3`, 0) = COALESCE(`bft_0`.`bfcol_4`, 0) + AND COALESCE(`bfcte_3`.`bfcol_3`, 1) = COALESCE(`bft_0`.`bfcol_4`, 1) ) AS `bfcol_5` - FROM `bfcte_2` + FROM `bfcte_3` ) SELECT `bfcol_2` AS `rowindex`, `bfcol_5` AS `int64_col` -FROM `bfcte_3` \ No newline at end of file +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin_not_nullable/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin_not_nullable/out.sql index f96a9816dc..8089c5b462 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin_not_nullable/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin_not_nullable/out.sql @@ -1,30 +1,34 @@ WITH `bfcte_1` AS ( SELECT - `rowindex` AS `bfcol_0`, - `rowindex_2` AS `bfcol_1` + `rowindex`, + `rowindex_2` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_2` AS ( +), `bfcte_3` AS ( SELECT - `bfcol_0` AS `bfcol_2`, - `bfcol_1` AS `bfcol_3` + `rowindex` AS `bfcol_2`, + `rowindex_2` AS `bfcol_3` FROM `bfcte_1` ), `bfcte_0` AS ( SELECT - `rowindex_2` AS `bfcol_4` + `rowindex_2` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_3` AS ( +), `bfcte_2` AS ( + SELECT + `rowindex_2` + FROM `bfcte_0` + GROUP BY + `rowindex_2` +), `bfcte_4` AS ( SELECT - `bfcte_2`.*, - `bfcte_2`.`bfcol_3` IN (( + `bfcte_3`.*, + `bfcte_3`.`bfcol_3` IN (( SELECT - `bfcol_4` - FROM `bfcte_0` - GROUP BY - `bfcol_4` + `rowindex_2` AS `bfcol_4` + FROM `bfcte_2` )) AS `bfcol_5` - FROM `bfcte_2` + FROM `bfcte_3` ) SELECT `bfcol_2` AS `rowindex`, `bfcol_5` AS `rowindex_2` -FROM `bfcte_3` \ No newline at end of file +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join/out.sql index 04ee767f8a..3a7ff60d3e 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join/out.sql @@ -1,22 +1,22 @@ WITH `bfcte_1` AS ( SELECT - `int64_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1` + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_2` AS ( SELECT - `bfcol_1` AS `bfcol_2`, - `bfcol_0` AS `bfcol_3` + `rowindex` AS `bfcol_2`, + `int64_col` AS `bfcol_3` FROM `bfcte_1` ), `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_4`, - `int64_too` AS `bfcol_5` + `int64_col`, + `int64_too` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_3` AS ( SELECT - `bfcol_4` AS `bfcol_6`, - `bfcol_5` AS `bfcol_7` + `int64_col` AS `bfcol_6`, + `int64_too` AS `bfcol_7` FROM `bfcte_0` ), `bfcte_4` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/bool_col/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/bool_col/out.sql index 05d5fd0695..30f363e900 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/bool_col/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/bool_col/out.sql @@ -1,22 +1,22 @@ WITH `bfcte_1` AS ( SELECT - `bool_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1` + `bool_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_2` AS ( SELECT - `bfcol_1` AS `bfcol_2`, - `bfcol_0` AS `bfcol_3` + `rowindex` AS `bfcol_2`, + `bool_col` AS `bfcol_3` FROM `bfcte_1` ), `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_4`, - `rowindex` AS `bfcol_5` + `bool_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_3` AS ( SELECT - `bfcol_5` AS `bfcol_6`, - `bfcol_4` AS `bfcol_7` + `rowindex` AS `bfcol_6`, + `bool_col` AS `bfcol_7` FROM `bfcte_0` ), `bfcte_4` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/float64_col/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/float64_col/out.sql index 9e6a4094b2..9fa7673fb3 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/float64_col/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/float64_col/out.sql @@ -1,22 +1,22 @@ WITH `bfcte_1` AS ( SELECT - `float64_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1` + `float64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_2` AS ( SELECT - `bfcol_1` AS `bfcol_2`, - `bfcol_0` AS `bfcol_3` + `rowindex` AS `bfcol_2`, + `float64_col` AS `bfcol_3` FROM `bfcte_1` ), `bfcte_0` AS ( SELECT - `float64_col` AS `bfcol_4`, - `rowindex` AS `bfcol_5` + `float64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_3` AS ( SELECT - `bfcol_5` AS `bfcol_6`, - `bfcol_4` AS `bfcol_7` + `rowindex` AS `bfcol_6`, + `float64_col` AS `bfcol_7` FROM `bfcte_0` ), `bfcte_4` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/int64_col/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/int64_col/out.sql index bd03e05cba..c9fca069d6 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/int64_col/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/int64_col/out.sql @@ -1,22 +1,22 @@ WITH `bfcte_1` AS ( SELECT - `int64_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1` + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_2` AS ( SELECT - `bfcol_1` AS `bfcol_2`, - `bfcol_0` AS `bfcol_3` + `rowindex` AS `bfcol_2`, + `int64_col` AS `bfcol_3` FROM `bfcte_1` ), `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_4`, - `rowindex` AS `bfcol_5` + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_3` AS ( SELECT - `bfcol_5` AS `bfcol_6`, - `bfcol_4` AS `bfcol_7` + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7` FROM `bfcte_0` ), `bfcte_4` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/numeric_col/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/numeric_col/out.sql index 6b77ead97c..88649c6518 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/numeric_col/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/numeric_col/out.sql @@ -1,22 +1,22 @@ WITH `bfcte_1` AS ( SELECT - `numeric_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1` + `numeric_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_2` AS ( SELECT - `bfcol_1` AS `bfcol_2`, - `bfcol_0` AS `bfcol_3` + `rowindex` AS `bfcol_2`, + `numeric_col` AS `bfcol_3` FROM `bfcte_1` ), `bfcte_0` AS ( SELECT - `numeric_col` AS `bfcol_4`, - `rowindex` AS `bfcol_5` + `numeric_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_3` AS ( SELECT - `bfcol_5` AS `bfcol_6`, - `bfcol_4` AS `bfcol_7` + `rowindex` AS `bfcol_6`, + `numeric_col` AS `bfcol_7` FROM `bfcte_0` ), `bfcte_4` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/string_col/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/string_col/out.sql index 1903d5fc22..8758ec8340 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/string_col/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/string_col/out.sql @@ -1,23 +1,28 @@ WITH `bfcte_1` AS ( + SELECT + `rowindex`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( SELECT `rowindex` AS `bfcol_0`, `string_col` AS `bfcol_1` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` + FROM `bfcte_1` ), `bfcte_0` AS ( SELECT - `rowindex` AS `bfcol_2`, - `string_col` AS `bfcol_3` + `rowindex`, + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_2` AS ( +), `bfcte_3` AS ( SELECT - `bfcol_2` AS `bfcol_4`, - `bfcol_3` AS `bfcol_5` + `rowindex` AS `bfcol_4`, + `string_col` AS `bfcol_5` FROM `bfcte_0` -), `bfcte_3` AS ( +), `bfcte_4` AS ( SELECT * - FROM `bfcte_1` - INNER JOIN `bfcte_2` + FROM `bfcte_2` + INNER JOIN `bfcte_3` ON COALESCE(CAST(`bfcol_1` AS STRING), '0') = COALESCE(CAST(`bfcol_5` AS STRING), '0') AND COALESCE(CAST(`bfcol_1` AS STRING), '1') = COALESCE(CAST(`bfcol_5` AS STRING), '1') ) @@ -25,4 +30,4 @@ SELECT `bfcol_0` AS `rowindex_x`, `bfcol_1` AS `string_col`, `bfcol_4` AS `rowindex_y` -FROM `bfcte_3` \ No newline at end of file +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/time_col/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/time_col/out.sql index 9e3477d4a9..42fc15cd1d 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/time_col/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_join/test_compile_join_w_on/time_col/out.sql @@ -1,23 +1,28 @@ WITH `bfcte_1` AS ( + SELECT + `rowindex`, + `time_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( SELECT `rowindex` AS `bfcol_0`, `time_col` AS `bfcol_1` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` + FROM `bfcte_1` ), `bfcte_0` AS ( SELECT - `rowindex` AS `bfcol_2`, - `time_col` AS `bfcol_3` + `rowindex`, + `time_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_2` AS ( +), `bfcte_3` AS ( SELECT - `bfcol_2` AS `bfcol_4`, - `bfcol_3` AS `bfcol_5` + `rowindex` AS `bfcol_4`, + `time_col` AS `bfcol_5` FROM `bfcte_0` -), `bfcte_3` AS ( +), `bfcte_4` AS ( SELECT * - FROM `bfcte_1` - INNER JOIN `bfcte_2` + FROM `bfcte_2` + INNER JOIN `bfcte_3` ON COALESCE(CAST(`bfcol_1` AS STRING), '0') = COALESCE(CAST(`bfcol_5` AS STRING), '0') AND COALESCE(CAST(`bfcol_1` AS STRING), '1') = COALESCE(CAST(`bfcol_5` AS STRING), '1') ) @@ -25,4 +30,4 @@ SELECT `bfcol_0` AS `rowindex_x`, `bfcol_1` AS `time_col`, `bfcol_4` AS `rowindex_y` -FROM `bfcte_3` \ No newline at end of file +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable/out.sql index 10c2a2088a..959a31a2a3 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable/out.sql @@ -1,37 +1,37 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `bytes_col` AS `bfcol_1`, - `date_col` AS `bfcol_2`, - `datetime_col` AS `bfcol_3`, - `geography_col` AS `bfcol_4`, - `int64_col` AS `bfcol_5`, - `int64_too` AS `bfcol_6`, - `numeric_col` AS `bfcol_7`, - `float64_col` AS `bfcol_8`, - `rowindex` AS `bfcol_9`, - `rowindex_2` AS `bfcol_10`, - `string_col` AS `bfcol_11`, - `time_col` AS `bfcol_12`, - `timestamp_col` AS `bfcol_13`, - `duration_col` AS `bfcol_14` + `bool_col`, + `bytes_col`, + `date_col`, + `datetime_col`, + `duration_col`, + `float64_col`, + `geography_col`, + `int64_col`, + `int64_too`, + `numeric_col`, + `rowindex`, + `rowindex_2`, + `string_col`, + `time_col`, + `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ) SELECT - `bfcol_9` AS `rowindex`, - `bfcol_0` AS `bool_col`, - `bfcol_1` AS `bytes_col`, - `bfcol_2` AS `date_col`, - `bfcol_3` AS `datetime_col`, - `bfcol_4` AS `geography_col`, - `bfcol_5` AS `int64_col`, - `bfcol_6` AS `int64_too`, - `bfcol_7` AS `numeric_col`, - `bfcol_8` AS `float64_col`, - `bfcol_9` AS `rowindex_1`, - `bfcol_10` AS `rowindex_2`, - `bfcol_11` AS `string_col`, - `bfcol_12` AS `time_col`, - `bfcol_13` AS `timestamp_col`, - `bfcol_14` AS `duration_col` + `rowindex`, + `bool_col`, + `bytes_col`, + `date_col`, + `datetime_col`, + `geography_col`, + `int64_col`, + `int64_too`, + `numeric_col`, + `float64_col`, + `rowindex` AS `rowindex_1`, + `rowindex_2`, + `string_col`, + `time_col`, + `timestamp_col`, + `duration_col` FROM `bfcte_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_json_types/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_json_types/out.sql index 4e8f61d75d..4b5750d7aa 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_json_types/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_json_types/out.sql @@ -1,10 +1,10 @@ WITH `bfcte_0` AS ( SELECT - `rowindex` AS `bfcol_0`, - `json_col` AS `bfcol_1` + `json_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`json_types` ) SELECT - `bfcol_0` AS `rowindex`, - `bfcol_1` AS `json_col` + `rowindex`, + `json_col` FROM `bfcte_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_limit/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_limit/out.sql index f97eb7bf06..856c7061da 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_limit/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_limit/out.sql @@ -1,13 +1,13 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1` + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ) SELECT - `bfcol_1` AS `rowindex`, - `bfcol_0` AS `int64_col` + `rowindex`, + `int64_col` FROM `bfcte_0` ORDER BY - `bfcol_1` ASC NULLS LAST + `rowindex` ASC NULLS LAST LIMIT 10 \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_nested_structs_types/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_nested_structs_types/out.sql index 75c4a86e18..79ae1ac907 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_nested_structs_types/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_nested_structs_types/out.sql @@ -1,11 +1,11 @@ WITH `bfcte_0` AS ( SELECT - `id` AS `bfcol_0`, - `people` AS `bfcol_1` + `id`, + `people` FROM `bigframes-dev`.`sqlglot_test`.`nested_structs_types` ) SELECT - `bfcol_0` AS `id`, - `bfcol_0` AS `id_1`, - `bfcol_1` AS `people` + `id`, + `id` AS `id_1`, + `people` FROM `bfcte_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_ordering/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_ordering/out.sql index 6a16b98baa..edb8d7fbf4 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_ordering/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_ordering/out.sql @@ -1,12 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1` + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ) SELECT - `bfcol_1` AS `rowindex`, - `bfcol_0` AS `int64_col` + `rowindex`, + `int64_col` FROM `bfcte_0` ORDER BY - `bfcol_0` ASC NULLS LAST \ No newline at end of file + `int64_col` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_repeated_types/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_repeated_types/out.sql index 2436c01a44..a22c845ef1 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_repeated_types/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_repeated_types/out.sql @@ -1,23 +1,23 @@ WITH `bfcte_0` AS ( SELECT - `rowindex` AS `bfcol_0`, - `int_list_col` AS `bfcol_1`, - `bool_list_col` AS `bfcol_2`, - `float_list_col` AS `bfcol_3`, - `date_list_col` AS `bfcol_4`, - `date_time_list_col` AS `bfcol_5`, - `numeric_list_col` AS `bfcol_6`, - `string_list_col` AS `bfcol_7` + `bool_list_col`, + `date_list_col`, + `date_time_list_col`, + `float_list_col`, + `int_list_col`, + `numeric_list_col`, + `rowindex`, + `string_list_col` FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` ) SELECT - `bfcol_0` AS `rowindex`, - `bfcol_0` AS `rowindex_1`, - `bfcol_1` AS `int_list_col`, - `bfcol_2` AS `bool_list_col`, - `bfcol_3` AS `float_list_col`, - `bfcol_4` AS `date_list_col`, - `bfcol_5` AS `date_time_list_col`, - `bfcol_6` AS `numeric_list_col`, - `bfcol_7` AS `string_list_col` + `rowindex`, + `rowindex` AS `rowindex_1`, + `int_list_col`, + `bool_list_col`, + `float_list_col`, + `date_list_col`, + `date_time_list_col`, + `numeric_list_col`, + `string_list_col` FROM `bfcte_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql index f280933a74..11e3f4773e 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql @@ -1,16 +1,16 @@ WITH `bfcte_0` AS ( SELECT - `bool_col` AS `bfcol_0`, - `int64_col` AS `bfcol_1`, - `rowindex` AS `bfcol_2` + `bool_col`, + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `bfcol_2` AS `bfcol_6`, - `bfcol_0` AS `bfcol_7`, - `bfcol_1` AS `bfcol_8`, - `bfcol_0` AS `bfcol_9` + `rowindex` AS `bfcol_6`, + `bool_col` AS `bfcol_7`, + `int64_col` AS `bfcol_8`, + `bool_col` AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT @@ -24,14 +24,14 @@ WITH `bfcte_0` AS ( CASE WHEN SUM(CAST(NOT `bfcol_7` IS NULL AS INT64)) OVER ( PARTITION BY `bfcol_9` - ORDER BY `bfcol_9` ASC NULLS LAST, `bfcol_2` ASC NULLS LAST + ORDER BY `bfcol_9` ASC NULLS LAST, `rowindex` ASC NULLS LAST ROWS BETWEEN 3 PRECEDING AND CURRENT ROW ) < 3 THEN NULL ELSE COALESCE( SUM(CAST(`bfcol_7` AS INT64)) OVER ( PARTITION BY `bfcol_9` - ORDER BY `bfcol_9` ASC NULLS LAST, `bfcol_2` ASC NULLS LAST + ORDER BY `bfcol_9` ASC NULLS LAST, `rowindex` ASC NULLS LAST ROWS BETWEEN 3 PRECEDING AND CURRENT ROW ), 0 @@ -50,14 +50,14 @@ WITH `bfcte_0` AS ( CASE WHEN SUM(CAST(NOT `bfcol_8` IS NULL AS INT64)) OVER ( PARTITION BY `bfcol_9` - ORDER BY `bfcol_9` ASC NULLS LAST, `bfcol_2` ASC NULLS LAST + ORDER BY `bfcol_9` ASC NULLS LAST, `rowindex` ASC NULLS LAST ROWS BETWEEN 3 PRECEDING AND CURRENT ROW ) < 3 THEN NULL ELSE COALESCE( SUM(`bfcol_8`) OVER ( PARTITION BY `bfcol_9` - ORDER BY `bfcol_9` ASC NULLS LAST, `bfcol_2` ASC NULLS LAST + ORDER BY `bfcol_9` ASC NULLS LAST, `rowindex` ASC NULLS LAST ROWS BETWEEN 3 PRECEDING AND CURRENT ROW ), 0 @@ -73,4 +73,4 @@ SELECT FROM `bfcte_5` ORDER BY `bfcol_9` ASC NULLS LAST, - `bfcol_2` ASC NULLS LAST \ No newline at end of file + `rowindex` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_skips_nulls_op/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_skips_nulls_op/out.sql index f22ef37b7e..788eb49ddf 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_skips_nulls_op/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_skips_nulls_op/out.sql @@ -1,24 +1,24 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1` + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, CASE - WHEN SUM(CAST(NOT `bfcol_0` IS NULL AS INT64)) OVER (ORDER BY `bfcol_1` ASC NULLS LAST ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) < 3 + WHEN SUM(CAST(NOT `int64_col` IS NULL AS INT64)) OVER (ORDER BY `rowindex` ASC NULLS LAST ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) < 3 THEN NULL ELSE COALESCE( - SUM(`bfcol_0`) OVER (ORDER BY `bfcol_1` ASC NULLS LAST ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), + SUM(`int64_col`) OVER (ORDER BY `rowindex` ASC NULLS LAST ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 0 ) END AS `bfcol_4` FROM `bfcte_0` ) SELECT - `bfcol_1` AS `rowindex`, + `rowindex`, `bfcol_4` AS `int64_col` FROM `bfcte_1` ORDER BY - `bfcol_1` ASC NULLS LAST \ No newline at end of file + `rowindex` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_wo_skips_nulls_op/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_wo_skips_nulls_op/out.sql index dcf52f2e82..5ad435ddbb 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_wo_skips_nulls_op/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_wo_skips_nulls_op/out.sql @@ -1,21 +1,21 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` AS `bfcol_0`, - `rowindex` AS `bfcol_1` + `int64_col`, + `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, CASE - WHEN COUNT(CAST(NOT `bfcol_0` IS NULL AS INT64)) OVER (ORDER BY `bfcol_1` ASC NULLS LAST ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) < 5 + WHEN COUNT(CAST(NOT `int64_col` IS NULL AS INT64)) OVER (ORDER BY `rowindex` ASC NULLS LAST ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) < 5 THEN NULL - ELSE COUNT(`bfcol_0`) OVER (ORDER BY `bfcol_1` ASC NULLS LAST ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) + ELSE COUNT(`int64_col`) OVER (ORDER BY `rowindex` ASC NULLS LAST ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) END AS `bfcol_4` FROM `bfcte_0` ) SELECT - `bfcol_1` AS `rowindex`, + `rowindex`, `bfcol_4` AS `int64_col` FROM `bfcte_1` ORDER BY - `bfcol_1` ASC NULLS LAST \ No newline at end of file + `rowindex` ASC NULLS LAST \ No newline at end of file From e3a271bc1aa98266586101ebf1a97c94aede1a7f Mon Sep 17 00:00:00 2001 From: jialuoo Date: Wed, 12 Nov 2025 10:48:11 -0800 Subject: [PATCH 224/313] chore: Migrate fillna_op operator to SQLGlot (#2247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes b/447388852 🦕 --- .../compile/sqlglot/expressions/generic_ops.py | 5 +++++ tests/system/small/engines/test_generic_ops.py | 2 +- .../snapshots/test_generic_ops/test_fillna/out.sql | 14 ++++++++++++++ .../sqlglot/expressions/test_generic_ops.py | 6 ++++++ 4 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_fillna/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/generic_ops.py b/bigframes/core/compile/sqlglot/expressions/generic_ops.py index 2bd19e1967..7ff09ab3f6 100644 --- a/bigframes/core/compile/sqlglot/expressions/generic_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/generic_ops.py @@ -134,6 +134,11 @@ def _( ) +@register_binary_op(ops.fillna_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.Coalesce(this=left.expr, expressions=[right.expr]) + + @register_nary_op(ops.case_when_op) def _(*cases_and_outputs: TypedExpr) -> sge.Expression: # Need to upcast BOOL to INT if any output is numeric diff --git a/tests/system/small/engines/test_generic_ops.py b/tests/system/small/engines/test_generic_ops.py index 01d4dad849..c0469ed97a 100644 --- a/tests/system/small/engines/test_generic_ops.py +++ b/tests/system/small/engines/test_generic_ops.py @@ -343,7 +343,7 @@ def test_engines_coalesce_op(scalars_array_value: array_value.ArrayValue, engine assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_fillna_op(scalars_array_value: array_value.ArrayValue, engine): arr, _ = scalars_array_value.compute_values( [ diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_fillna/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_fillna/out.sql new file mode 100644 index 0000000000..07f2877e74 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_fillna/out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + COALESCE(`int64_col`, `float64_col`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py index 693f8dc34c..11daf6813a 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py @@ -239,6 +239,12 @@ def test_clip(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_fillna(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col"]] + sql = utils._apply_binary_op(bf_df, ops.fillna_op, "int64_col", "float64_col") + snapshot.assert_match(sql, "out.sql") + + def test_hash(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] From 98129ba2889e0e1d6a2cc40a5e1095eedbc5b59e Mon Sep 17 00:00:00 2001 From: jialuoo Date: Wed, 12 Nov 2025 10:49:14 -0800 Subject: [PATCH 225/313] chore: Migrate euclidean_distance_op operator to SQLGlot (#2238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes b/447388852 🦕 --- .../compile/sqlglot/expressions/numeric_ops.py | 12 ++++++++++++ .../test_euclidean_distance/out.sql | 16 ++++++++++++++++ .../sqlglot/expressions/test_numeric_ops.py | 15 +++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_euclidean_distance/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py index 36e2973565..e33702b08c 100644 --- a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py @@ -305,6 +305,18 @@ def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: return result +@register_binary_op(ops.euclidean_distance_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.Anonymous( + this="ML.DISTANCE", + expressions=[ + left.expr, + right.expr, + sge.Literal.string("EUCLIDEAN"), + ], + ) + + @register_binary_op(ops.floordiv_op) def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: left_expr = _coerce_bool_to_int(left) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_euclidean_distance/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_euclidean_distance/out.sql new file mode 100644 index 0000000000..3327a99f4b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_euclidean_distance/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `int_list_col`, + `numeric_list_col` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + ML.DISTANCE(`int_list_col`, `int_list_col`, 'EUCLIDEAN') AS `bfcol_2`, + ML.DISTANCE(`numeric_list_col`, `numeric_list_col`, 'EUCLIDEAN') AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `int_list_col`, + `bfcol_3` AS `numeric_list_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py index 06731bcbfa..c58ce9e2f1 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py @@ -315,6 +315,21 @@ def test_div_timedelta(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(bf_df.sql, "out.sql") +def test_euclidean_distance(repeated_types_df: bpd.DataFrame, snapshot): + col_names = ["int_list_col", "numeric_list_col"] + bf_df = repeated_types_df[col_names] + + sql = utils._apply_ops_to_sql( + bf_df, + [ + ops.euclidean_distance_op.as_expr("int_list_col", "int_list_col"), + ops.euclidean_distance_op.as_expr("numeric_list_col", "numeric_list_col"), + ], + ["int_list_col", "numeric_list_col"], + ) + snapshot.assert_match(sql, "out.sql") + + def test_floordiv_numeric(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["int64_col", "bool_col", "float64_col"]] From 9f497a6ddd52c8aa41141c8641b6d189bfb15210 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Wed, 12 Nov 2025 10:52:05 -0800 Subject: [PATCH 226/313] refactor: read table with system time for sqlglot compiler (#2252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes internal issue 459579873🦕 --- bigframes/core/compile/sqlglot/compiler.py | 1 + bigframes/core/compile/sqlglot/sqlglot_ir.py | 13 +++++++ tests/unit/core/compile/sqlglot/conftest.py | 20 +++++++---- .../out.sql | 36 +++++++++++++++++++ .../compile/sqlglot/test_compile_readtable.py | 20 +++++++++++ tests/unit/session/test_session.py | 1 - 6 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_system_time/out.sql diff --git a/bigframes/core/compile/sqlglot/compiler.py b/bigframes/core/compile/sqlglot/compiler.py index 7dc8d4bec0..0bf74e472f 100644 --- a/bigframes/core/compile/sqlglot/compiler.py +++ b/bigframes/core/compile/sqlglot/compiler.py @@ -172,6 +172,7 @@ def compile_readtable(node: nodes.ReadTableNode, child: ir.SQLGlotIR): col_names=[col.source_id for col in node.scan_list.items], alias_names=[col.id.sql for col in node.scan_list.items], uid_gen=child.uid_gen, + system_time=node.source.at_time, ) diff --git a/bigframes/core/compile/sqlglot/sqlglot_ir.py b/bigframes/core/compile/sqlglot/sqlglot_ir.py index 91fea44490..b28c5ede91 100644 --- a/bigframes/core/compile/sqlglot/sqlglot_ir.py +++ b/bigframes/core/compile/sqlglot/sqlglot_ir.py @@ -15,6 +15,7 @@ from __future__ import annotations import dataclasses +import datetime import functools import typing @@ -118,6 +119,7 @@ def from_table( col_names: typing.Sequence[str], alias_names: typing.Sequence[str], uid_gen: guid.SequentialUIDGenerator, + system_time: typing.Optional[datetime.datetime] = None, ) -> SQLGlotIR: """Builds a SQLGlotIR expression from a BigQuery table. @@ -128,6 +130,7 @@ def from_table( col_names (typing.Sequence[str]): The names of the columns to select. alias_names (typing.Sequence[str]): The aliases for the selected columns. uid_gen (guid.SequentialUIDGenerator): A generator for unique identifiers. + system_time (typing.Optional[str]): An optional system time for time-travel queries. """ selections = [ sge.Alias( @@ -138,10 +141,20 @@ def from_table( else sge.to_identifier(col_name, quoted=cls.quoted) for col_name, alias_name in zip(col_names, alias_names) ] + version = ( + sge.Version( + this="TIMESTAMP", + expression=sge.Literal(this=system_time.isoformat(), is_string=True), + kind="AS OF", + ) + if system_time + else None + ) table_expr = sge.Table( this=sg.to_identifier(table_id, quoted=cls.quoted), db=sg.to_identifier(dataset_id, quoted=cls.quoted), catalog=sg.to_identifier(project_id, quoted=cls.quoted), + version=version, ) select_expr = sge.Select().select(*selections).from_(table_expr) return cls(expr=select_expr, uid_gen=uid_gen) diff --git a/tests/unit/core/compile/sqlglot/conftest.py b/tests/unit/core/compile/sqlglot/conftest.py index 3279b3a259..cb5a14b690 100644 --- a/tests/unit/core/compile/sqlglot/conftest.py +++ b/tests/unit/core/compile/sqlglot/conftest.py @@ -97,7 +97,10 @@ def scalar_types_table_schema() -> typing.Sequence[bigquery.SchemaField]: def scalar_types_df(compiler_session) -> bpd.DataFrame: """Returns a BigFrames DataFrame containing all scalar types and using the `rowindex` column as the index.""" - bf_df = compiler_session.read_gbq_table("bigframes-dev.sqlglot_test.scalar_types") + bf_df = compiler_session._loader.read_gbq_table( + "bigframes-dev.sqlglot_test.scalar_types", + enable_snapshot=False, + ) bf_df = bf_df.set_index("rowindex", drop=False) return bf_df @@ -154,8 +157,9 @@ def nested_structs_types_table_schema() -> typing.Sequence[bigquery.SchemaField] def nested_structs_types_df(compiler_session_w_nested_structs_types) -> bpd.DataFrame: """Returns a BigFrames DataFrame containing all scalar types and using the `rowindex` column as the index.""" - bf_df = compiler_session_w_nested_structs_types.read_gbq_table( - "bigframes-dev.sqlglot_test.nested_structs_types" + bf_df = compiler_session_w_nested_structs_types._loader.read_gbq_table( + "bigframes-dev.sqlglot_test.nested_structs_types", + enable_snapshot=False, ) bf_df = bf_df.set_index("id", drop=False) return bf_df @@ -204,8 +208,9 @@ def repeated_types_table_schema() -> typing.Sequence[bigquery.SchemaField]: def repeated_types_df(compiler_session_w_repeated_types) -> bpd.DataFrame: """Returns a BigFrames DataFrame containing all scalar types and using the `rowindex` column as the index.""" - bf_df = compiler_session_w_repeated_types.read_gbq_table( - "bigframes-dev.sqlglot_test.repeated_types" + bf_df = compiler_session_w_repeated_types._loader.read_gbq_table( + "bigframes-dev.sqlglot_test.repeated_types", + enable_snapshot=False, ) bf_df = bf_df.set_index("rowindex", drop=False) return bf_df @@ -237,8 +242,9 @@ def json_types_table_schema() -> typing.Sequence[bigquery.SchemaField]: def json_types_df(compiler_session_w_json_types) -> bpd.DataFrame: """Returns a BigFrames DataFrame containing JSON types and using the `rowindex` column as the index.""" - bf_df = compiler_session_w_json_types.read_gbq_table( - "bigframes-dev.sqlglot_test.json_types" + bf_df = compiler_session_w_json_types._loader.read_gbq_table( + "bigframes-dev.sqlglot_test.json_types", + enable_snapshot=False, ) # TODO(b/427305807): Why `drop=False` will produce two "rowindex" columns? bf_df = bf_df.set_index("rowindex", drop=True) diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_system_time/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_system_time/out.sql new file mode 100644 index 0000000000..59c3687080 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_system_time/out.sql @@ -0,0 +1,36 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `bytes_col`, + `date_col`, + `datetime_col`, + `duration_col`, + `float64_col`, + `geography_col`, + `int64_col`, + `int64_too`, + `numeric_col`, + `rowindex`, + `rowindex_2`, + `string_col`, + `time_col`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` FOR SYSTEM_TIME AS OF '2025-11-09T03:04:05.678901+00:00' +) +SELECT + `bool_col`, + `bytes_col`, + `date_col`, + `datetime_col`, + `geography_col`, + `int64_col`, + `int64_too`, + `numeric_col`, + `float64_col`, + `rowindex`, + `rowindex_2`, + `string_col`, + `time_col`, + `timestamp_col`, + `duration_col` +FROM `bfcte_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/test_compile_readtable.py b/tests/unit/core/compile/sqlglot/test_compile_readtable.py index a5692e5fbf..37d87510ee 100644 --- a/tests/unit/core/compile/sqlglot/test_compile_readtable.py +++ b/tests/unit/core/compile/sqlglot/test_compile_readtable.py @@ -12,6 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime + +import google.cloud.bigquery as bigquery import pytest import bigframes.pandas as bpd @@ -47,3 +50,20 @@ def test_compile_readtable_w_limit(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["int64_col"]] bf_df = bf_df.sort_index().head(10) snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_compile_readtable_w_system_time( + compiler_session, scalar_types_table_schema, snapshot +): + table_ref = bigquery.TableReference( + bigquery.DatasetReference("bigframes-dev", "sqlglot_test"), + "scalar_types", + ) + table = bigquery.Table(table_ref, tuple(scalar_types_table_schema)) + table._properties["location"] = compiler_session._location + compiler_session._loader._df_snapshot[str(table_ref)] = ( + datetime.datetime(2025, 11, 9, 3, 4, 5, 678901, tzinfo=datetime.timezone.utc), + table, + ) + bf_df = compiler_session.read_gbq_table(str(table_ref)) + snapshot.assert_match(bf_df.sql, "out.sql") diff --git a/tests/unit/session/test_session.py b/tests/unit/session/test_session.py index f003398706..fe73643b0c 100644 --- a/tests/unit/session/test_session.py +++ b/tests/unit/session/test_session.py @@ -240,7 +240,6 @@ def test_read_gbq_cached_table(): ) table._properties["location"] = session._location table._properties["numRows"] = "1000000000" - table._properties["location"] = session._location table._properties["type"] = "TABLE" session._loader._df_snapshot[str(table_ref)] = ( datetime.datetime(1999, 1, 2, 3, 4, 5, 678901, tzinfo=datetime.timezone.utc), From 956a5b00dff55b73e3cbebb4e6e81672680f1f63 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Wed, 12 Nov 2025 11:01:58 -0800 Subject: [PATCH 227/313] feat: Support builtins funcs for df.agg (#2256) --- bigframes/core/groupby/dataframe_group_by.py | 10 ++++----- bigframes/operations/aggregations.py | 10 +++++++-- tests/system/small/test_dataframe.py | 22 ++++++++++++++++++++ tests/system/small/test_groupby.py | 2 -- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/bigframes/core/groupby/dataframe_group_by.py b/bigframes/core/groupby/dataframe_group_by.py index 3948d08a23..149971249f 100644 --- a/bigframes/core/groupby/dataframe_group_by.py +++ b/bigframes/core/groupby/dataframe_group_by.py @@ -593,6 +593,7 @@ def _agg_func(self, func) -> df.DataFrame: def _agg_dict(self, func: typing.Mapping) -> df.DataFrame: aggregations: typing.List[agg_expressions.Aggregation] = [] column_labels = [] + function_labels = [] want_aggfunc_level = any(utils.is_list_like(aggs) for aggs in func.values()) @@ -602,8 +603,10 @@ def _agg_dict(self, func: typing.Mapping) -> df.DataFrame: funcs_for_id if utils.is_list_like(funcs_for_id) else [funcs_for_id] ) for f in func_list: - aggregations.append(aggs.agg(col_id, agg_ops.lookup_agg_func(f)[0])) + f_op, f_label = agg_ops.lookup_agg_func(f) + aggregations.append(aggs.agg(col_id, f_op)) column_labels.append(label) + function_labels.append(f_label) agg_block, _ = self._block.aggregate( by_column_ids=self._by_col_ids, aggregations=aggregations, @@ -613,10 +616,7 @@ def _agg_dict(self, func: typing.Mapping) -> df.DataFrame: agg_block = agg_block.with_column_labels( utils.combine_indices( pd.Index(column_labels), - pd.Index( - typing.cast(agg_ops.AggregateOp, agg.op).name - for agg in aggregations - ), + pd.Index(function_labels), ) ) else: diff --git a/bigframes/operations/aggregations.py b/bigframes/operations/aggregations.py index f6e8600d42..1160ab2c8e 100644 --- a/bigframes/operations/aggregations.py +++ b/bigframes/operations/aggregations.py @@ -717,9 +717,15 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT np.all: all_op, np.any: any_op, np.unique: nunique_op, - # TODO(b/443252872): Solve - # list: ArrayAggOp(), np.size: size_op, + # TODO(b/443252872): Solve + list: ArrayAggOp(), + len: size_op, + sum: sum_op, + min: min_op, + max: max_op, + any: any_op, + all: all_op, } diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 475f98407b..5750f03f9c 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -6151,6 +6151,28 @@ def test_agg_with_dict_strs(scalars_dfs): ) +def test_df_agg_with_builtins(scalars_dfs): + bf_df, pd_df = scalars_dfs + + bf_result = ( + bf_df[["int64_col", "bool_col"]] + .dropna() + .groupby(bf_df.int64_too % 2) + .agg({"int64_col": [len, sum, min, max, list], "bool_col": [all, any, max]}) + .to_pandas() + ) + pd_result = ( + pd_df[["int64_col", "bool_col"]] + .dropna() + .groupby(pd_df.int64_too % 2) + .agg({"int64_col": [len, sum, min, max, list], "bool_col": [all, any, max]}) + ) + + pd.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) + + def test_agg_with_dict_containing_non_existing_col_raise_key_error(scalars_dfs): bf_df, _ = scalars_dfs agg_funcs = { diff --git a/tests/system/small/test_groupby.py b/tests/system/small/test_groupby.py index 4f187dcccc..2e09ffd1a6 100644 --- a/tests/system/small/test_groupby.py +++ b/tests/system/small/test_groupby.py @@ -282,8 +282,6 @@ def test_dataframe_groupby_agg_dict_with_list( ) bf_result_computed = bf_result.to_pandas() - # some inconsistency between versions, so normalize to bigframes behavior - pd_result = pd_result.rename({"amax": "max"}, axis="columns") pd.testing.assert_frame_equal( pd_result, bf_result_computed, check_dtype=False, check_index_type=False ) From 20ab469d29767a2f04fe02aa66797893ecd1c539 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 13 Nov 2025 10:14:19 -0800 Subject: [PATCH 228/313] feat: Support mixed scalar-analytic expressions (#2239) --- bigframes/core/agg_expressions.py | 21 ++- bigframes/core/array_value.py | 28 +++- bigframes/core/block_transforms.py | 153 ++++++++---------- bigframes/core/blocks.py | 21 +++ bigframes/core/expression.py | 60 ++++++- bigframes/core/expression_factoring.py | 210 +++++++++++++++++++++++++ bigframes/core/nodes.py | 6 + 7 files changed, 405 insertions(+), 94 deletions(-) create mode 100644 bigframes/core/expression_factoring.py diff --git a/bigframes/core/agg_expressions.py b/bigframes/core/agg_expressions.py index e65718bdc4..125e3fef63 100644 --- a/bigframes/core/agg_expressions.py +++ b/bigframes/core/agg_expressions.py @@ -19,7 +19,7 @@ import functools import itertools import typing -from typing import Callable, Mapping, TypeVar +from typing import Callable, Mapping, Tuple, TypeVar from bigframes import dtypes from bigframes.core import expression, window_spec @@ -63,6 +63,10 @@ def inputs( ) -> typing.Tuple[expression.Expression, ...]: ... + @property + def children(self) -> Tuple[expression.Expression, ...]: + return self.inputs + @property def free_variables(self) -> typing.Tuple[str, ...]: return tuple( @@ -73,6 +77,10 @@ def free_variables(self) -> typing.Tuple[str, ...]: def is_const(self) -> bool: return all(child.is_const for child in self.inputs) + @functools.cached_property + def is_scalar_expr(self) -> bool: + return False + @abc.abstractmethod def replace_args(self: TExpression, *arg) -> TExpression: ... @@ -176,8 +184,13 @@ def output_type(self) -> dtypes.ExpressionType: def inputs( self, ) -> typing.Tuple[expression.Expression, ...]: + # TODO: Maybe make the window spec itself an expression? return (self.analytic_expr, *self.window.expressions) + @property + def children(self) -> Tuple[expression.Expression, ...]: + return self.inputs + @property def free_variables(self) -> typing.Tuple[str, ...]: return tuple( @@ -188,12 +201,16 @@ def free_variables(self) -> typing.Tuple[str, ...]: def is_const(self) -> bool: return all(child.is_const for child in self.inputs) + @functools.cached_property + def is_scalar_expr(self) -> bool: + return False + def transform_children( self: WindowExpression, t: Callable[[expression.Expression], expression.Expression], ) -> WindowExpression: return WindowExpression( - self.analytic_expr.transform_children(t), + t(self.analytic_expr), # type: ignore self.window.transform_exprs(t), ) diff --git a/bigframes/core/array_value.py b/bigframes/core/array_value.py index e2948cdd05..c3d71d19af 100644 --- a/bigframes/core/array_value.py +++ b/bigframes/core/array_value.py @@ -16,6 +16,7 @@ from dataclasses import dataclass import datetime import functools +import itertools import typing from typing import Iterable, List, Mapping, Optional, Sequence, Tuple @@ -23,12 +24,16 @@ import pandas import pyarrow as pa -from bigframes.core import agg_expressions, bq_data +from bigframes.core import ( + agg_expressions, + bq_data, + expression_factoring, + join_def, + local_data, +) import bigframes.core.expression as ex import bigframes.core.guid import bigframes.core.identifiers as ids -import bigframes.core.join_def as join_def -import bigframes.core.local_data as local_data import bigframes.core.nodes as nodes from bigframes.core.ordering import OrderingExpression import bigframes.core.ordering as orderings @@ -261,6 +266,23 @@ def compute_values(self, assignments: Sequence[ex.Expression]): col_ids, ) + def compute_general_expression(self, assignments: Sequence[ex.Expression]): + named_exprs = [ + expression_factoring.NamedExpression(expr, ids.ColumnId.unique()) + for expr in assignments + ] + # TODO: Push this to rewrite later to go from block expression to planning form + # TODO: Jointly fragmentize expressions to more efficiently reuse common sub-expressions + fragments = tuple( + itertools.chain.from_iterable( + expression_factoring.fragmentize_expression(expr) + for expr in named_exprs + ) + ) + target_ids = tuple(named_expr.name for named_expr in named_exprs) + new_root = expression_factoring.push_into_tree(self.node, fragments, target_ids) + return (ArrayValue(new_root), target_ids) + def project_to_id(self, expression: ex.Expression): array_val, ids = self.compute_values( [expression], diff --git a/bigframes/core/block_transforms.py b/bigframes/core/block_transforms.py index 2ee3dc38b3..4e7abb1104 100644 --- a/bigframes/core/block_transforms.py +++ b/bigframes/core/block_transforms.py @@ -399,15 +399,18 @@ def pct_change(block: blocks.Block, periods: int = 1) -> blocks.Block: window_spec = windows.unbound() original_columns = block.value_columns - block, shift_columns = block.multi_apply_window_op( - original_columns, agg_ops.ShiftOp(periods), window_spec=window_spec - ) exprs = [] - for original_col, shifted_col in zip(original_columns, shift_columns): - change_expr = ops.sub_op.as_expr(original_col, shifted_col) - pct_change_expr = ops.div_op.as_expr(change_expr, shifted_col) + for original_col in original_columns: + shift_expr = agg_expressions.WindowExpression( + agg_expressions.UnaryAggregation( + agg_ops.ShiftOp(periods), ex.deref(original_col) + ), + window_spec, + ) + change_expr = ops.sub_op.as_expr(original_col, shift_expr) + pct_change_expr = ops.div_op.as_expr(change_expr, shift_expr) exprs.append(pct_change_expr) - return block.project_exprs(exprs, labels=column_labels, drop=True) + return block.project_block_exprs(exprs, labels=column_labels, drop=True) def rank( @@ -428,16 +431,11 @@ def rank( columns = columns or tuple(col for col in block.value_columns) labels = [block.col_id_to_label[id] for id in columns] - # Step 1: Calculate row numbers for each row - # Identify null values to be treated according to na_option param - rownum_col_ids = [] - nullity_col_ids = [] + + result_exprs = [] for col in columns: - block, nullity_col_id = block.apply_unary_op( - col, - ops.isnull_op, - ) - nullity_col_ids.append(nullity_col_id) + # Step 1: Calculate row numbers for each row + # Identify null values to be treated according to na_option param window_ordering = ( ordering.OrderingExpression( ex.deref(col), @@ -448,87 +446,66 @@ def rank( ), ) # Count_op ignores nulls, so if na_option is "top" or "bottom", we instead count the nullity columns, where nulls have been mapped to bools - block, rownum_id = block.apply_window_op( - col if na_option == "keep" else nullity_col_id, - agg_ops.dense_rank_op if method == "dense" else agg_ops.count_op, - window_spec=windows.unbound( - grouping_keys=grouping_cols, ordering=window_ordering - ) + target_expr = ( + ex.deref(col) if na_option == "keep" else ops.isnull_op.as_expr(col) + ) + window_op = agg_ops.dense_rank_op if method == "dense" else agg_ops.count_op + window_spec = ( + windows.unbound(grouping_keys=grouping_cols, ordering=window_ordering) if method == "dense" else windows.rows( end=0, ordering=window_ordering, grouping_keys=grouping_cols - ), - skip_reproject_unsafe=(col != columns[-1]), + ) + ) + result_expr: ex.Expression = agg_expressions.WindowExpression( + agg_expressions.UnaryAggregation(window_op, target_expr), window_spec ) if pct: - block, max_id = block.apply_window_op( - rownum_id, agg_ops.max_op, windows.unbound(grouping_keys=grouping_cols) + result_expr = ops.div_op.as_expr( + result_expr, + agg_expressions.WindowExpression( + agg_expressions.UnaryAggregation(agg_ops.max_op, result_expr), + windows.unbound(grouping_keys=grouping_cols), + ), ) - block, rownum_id = block.project_expr(ops.div_op.as_expr(rownum_id, max_id)) - - rownum_col_ids.append(rownum_id) - - # Step 2: Apply aggregate to groups of like input values. - # This step is skipped for method=='first' or 'dense' - if method in ["average", "min", "max"]: - agg_op = { - "average": agg_ops.mean_op, - "min": agg_ops.min_op, - "max": agg_ops.max_op, - }[method] - post_agg_rownum_col_ids = [] - for i in range(len(columns)): - block, result_id = block.apply_window_op( - rownum_col_ids[i], - agg_op, - window_spec=windows.unbound(grouping_keys=(columns[i], *grouping_cols)), - skip_reproject_unsafe=(i < (len(columns) - 1)), + # Step 2: Apply aggregate to groups of like input values. + # This step is skipped for method=='first' or 'dense' + if method in ["average", "min", "max"]: + agg_op = { + "average": agg_ops.mean_op, + "min": agg_ops.min_op, + "max": agg_ops.max_op, + }[method] + result_expr = agg_expressions.WindowExpression( + agg_expressions.UnaryAggregation(agg_op, result_expr), + windows.unbound(grouping_keys=(col, *grouping_cols)), ) - post_agg_rownum_col_ids.append(result_id) - rownum_col_ids = post_agg_rownum_col_ids - - # Pandas masks all values where any grouping column is null - # Note: we use pd.NA instead of float('nan') - if grouping_cols: - predicate = functools.reduce( - ops.and_op.as_expr, - [ops.notnull_op.as_expr(column_id) for column_id in grouping_cols], - ) - block = block.project_exprs( - [ - ops.where_op.as_expr( - ex.deref(col), - predicate, - ex.const(None), - ) - for col in rownum_col_ids - ], - labels=labels, - ) - rownum_col_ids = list(block.value_columns[-len(rownum_col_ids) :]) - - # Step 3: post processing: mask null values and cast to float - if method in ["min", "max", "first", "dense"]: - # Pandas rank always produces Float64, so must cast for aggregation types that produce ints - return ( - block.select_columns(rownum_col_ids) - .multi_apply_unary_op(ops.AsTypeOp(pd.Float64Dtype())) - .with_column_labels(labels) - ) - if na_option == "keep": - # For na_option "keep", null inputs must produce null outputs - exprs = [] - for i in range(len(columns)): - exprs.append( - ops.where_op.as_expr( - ex.const(pd.NA, dtype=pd.Float64Dtype()), - nullity_col_ids[i], - rownum_col_ids[i], - ) + # Pandas masks all values where any grouping column is null + # Note: we use pd.NA instead of float('nan') + if grouping_cols: + predicate = functools.reduce( + ops.and_op.as_expr, + [ops.notnull_op.as_expr(column_id) for column_id in grouping_cols], + ) + result_expr = ops.where_op.as_expr( + result_expr, + predicate, + ex.const(None), ) - return block.project_exprs(exprs, labels=labels, drop=True) - return block.select_columns(rownum_col_ids).with_column_labels(labels) + # Step 3: post processing: mask null values and cast to float + if method in ["min", "max", "first", "dense"]: + # Pandas rank always produces Float64, so must cast for aggregation types that produce ints + result_expr = ops.AsTypeOp(pd.Float64Dtype()).as_expr(result_expr) + elif na_option == "keep": + # For na_option "keep", null inputs must produce null outputs + result_expr = ops.where_op.as_expr( + ex.const(pd.NA, dtype=pd.Float64Dtype()), + ops.isnull_op.as_expr(col), + result_expr, + ) + result_exprs.append(result_expr) + return block.project_block_exprs(result_exprs, labels=labels, drop=True) def dropna( diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index f657f28a6f..cf5f0513e6 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -1154,6 +1154,27 @@ def project_exprs( index_labels=self._index_labels, ) + # This is a new experimental version of the project_exprs that supports mixing analytic and scalar expressions + def project_block_exprs( + self, + exprs: Sequence[ex.Expression], + labels: Union[Sequence[Label], pd.Index], + drop=False, + ) -> Block: + new_array, _ = self.expr.compute_general_expression(exprs) + if drop: + new_array = new_array.drop_columns(self.value_columns) + + new_array.node.validate_tree() + return Block( + new_array, + index_columns=self.index_columns, + column_labels=labels + if drop + else self.column_labels.append(pd.Index(labels)), + index_labels=self._index_labels, + ) + def apply_window_op( self, column: str, diff --git a/bigframes/core/expression.py b/bigframes/core/expression.py index 59679f1bc4..22b566f3ac 100644 --- a/bigframes/core/expression.py +++ b/bigframes/core/expression.py @@ -15,11 +15,12 @@ from __future__ import annotations import abc +import collections import dataclasses import functools import itertools import typing -from typing import Callable, Generator, Mapping, TypeVar, Union +from typing import Callable, Dict, Generator, Mapping, Tuple, TypeVar, Union import pandas as pd @@ -43,6 +44,7 @@ def free_var(id: str) -> UnboundVariableExpression: return UnboundVariableExpression(id) +T = TypeVar("T") TExpression = TypeVar("TExpression", bound="Expression") @@ -136,6 +138,11 @@ def is_identity(self) -> bool: """True for identity operation that does not transform input.""" return False + @functools.cached_property + def is_scalar_expr(self) -> bool: + """True if expression represents scalar value or expression over scalar values (no windows or aggregations)""" + return all(expr.is_scalar_expr for expr in self.children) + @abc.abstractmethod def transform_children(self, t: Callable[[Expression], Expression]) -> Expression: ... @@ -150,6 +157,57 @@ def walk(self) -> Generator[Expression, None, None]: for child in self.children: yield from child.children + def unique_nodes( + self: Expression, + ) -> Generator[Expression, None, None]: + """Walks the tree for unique nodes""" + seen = set() + stack: list[Expression] = [self] + while stack: + item = stack.pop() + if item not in seen: + yield item + seen.add(item) + stack.extend(item.children) + + def iter_nodes_topo( + self: Expression, + ) -> Generator[Expression, None, None]: + """Returns nodes in reverse topological order, using Kahn's algorithm.""" + child_to_parents: Dict[Expression, list[Expression]] = collections.defaultdict( + list + ) + out_degree: Dict[Expression, int] = collections.defaultdict(int) + + queue: collections.deque["Expression"] = collections.deque() + for node in list(self.unique_nodes()): + num_children = len(node.children) + out_degree[node] = num_children + if num_children == 0: + queue.append(node) + for child in node.children: + child_to_parents[child].append(node) + + while queue: + item = queue.popleft() + yield item + parents = child_to_parents.get(item, []) + for parent in parents: + out_degree[parent] -= 1 + if out_degree[parent] == 0: + queue.append(parent) + + def reduce_up(self, reduction: Callable[[Expression, Tuple[T, ...]], T]) -> T: + """Apply a bottom-up reduction to the tree.""" + results: dict[Expression, T] = {} + for node in list(self.iter_nodes_topo()): + # child nodes have already been transformed + child_results = tuple(results[child] for child in node.children) + result = reduction(node, child_results) + results[node] = result + + return results[self] + @dataclasses.dataclass(frozen=True) class ScalarConstantExpression(Expression): diff --git a/bigframes/core/expression_factoring.py b/bigframes/core/expression_factoring.py new file mode 100644 index 0000000000..07d5591bc5 --- /dev/null +++ b/bigframes/core/expression_factoring.py @@ -0,0 +1,210 @@ +import collections +import dataclasses +import functools +import itertools +from typing import Generic, Hashable, Iterable, Optional, Sequence, Tuple, TypeVar + +from bigframes.core import agg_expressions, expression, identifiers, nodes + +_MAX_INLINE_COMPLEXITY = 10 + + +@dataclasses.dataclass(frozen=True, eq=False) +class NamedExpression: + expr: expression.Expression + name: identifiers.ColumnId + + +@dataclasses.dataclass(frozen=True, eq=False) +class FactoredExpression: + root_expr: expression.Expression + sub_exprs: Tuple[NamedExpression, ...] + + +def fragmentize_expression(root: NamedExpression) -> Sequence[NamedExpression]: + """ + The goal of this functions is to factor out an expression into multiple sub-expressions. + """ + + factored_expr = root.expr.reduce_up(gather_fragments) + root_expr = NamedExpression(factored_expr.root_expr, root.name) + return (root_expr, *factored_expr.sub_exprs) + + +def gather_fragments( + root: expression.Expression, fragmentized_children: Sequence[FactoredExpression] +) -> FactoredExpression: + replacements: list[expression.Expression] = [] + named_exprs = [] # root -> leaf dependency order + for child_result in fragmentized_children: + child_expr = child_result.root_expr + is_leaf = isinstance( + child_expr, (expression.DerefOp, expression.ScalarConstantExpression) + ) + is_window_agg = isinstance( + root, agg_expressions.WindowExpression + ) and isinstance(child_expr, agg_expressions.Aggregation) + do_inline = is_leaf | is_window_agg + if not do_inline: + id = identifiers.ColumnId.unique() + replacements.append(expression.DerefOp(id)) + named_exprs.append(NamedExpression(child_result.root_expr, id)) + named_exprs.extend(child_result.sub_exprs) + else: + replacements.append(child_result.root_expr) + named_exprs.extend(child_result.sub_exprs) + new_root = replace_children(root, replacements) + return FactoredExpression(new_root, tuple(named_exprs)) + + +def replace_children( + root: expression.Expression, new_children: Sequence[expression.Expression] +): + mapping = {root.children[i]: new_children[i] for i in range(len(root.children))} + return root.transform_children(lambda x: mapping.get(x, x)) + + +T = TypeVar("T", bound=Hashable) + + +class DiGraph(Generic[T]): + def __init__(self, edges: Iterable[Tuple[T, T]]): + self._parents = collections.defaultdict(set) + self._children = collections.defaultdict(set) # specifically, unpushed ones + # use dict for stable ordering, which grants determinism + self._sinks: dict[T, None] = dict() + for src, dst in edges: + self._children[src].add(dst) + self._parents[dst].add(src) + # sinks have no children + if not self._children[dst]: + self._sinks[dst] = None + if src in self._sinks: + del self._sinks[src] + + @property + def nodes(self): + # should be the same set of ids as self._parents + return self._children.keys() + + @property + def sinks(self) -> Iterable[T]: + return self._sinks.keys() + + @property + def empty(self): + return len(self.nodes) == 0 + + def parents(self, node: T) -> set[T]: + return self._parents[node] + + def children(self, node: T) -> set[T]: + return self._children[node] + + def remove_node(self, node: T) -> None: + for child in self._children[node]: + self._parents[child].remove(node) + for parent in self._parents[node]: + self._children[parent].remove(node) + if len(self._children[parent]) == 0: + self._sinks[parent] = None + del self._children[node] + del self._parents[node] + if node in self._sinks: + del self._sinks[node] + + +def push_into_tree( + root: nodes.BigFrameNode, + exprs: Sequence[NamedExpression], + target_ids: Sequence[identifiers.ColumnId], +) -> nodes.BigFrameNode: + curr_root = root + by_id = {expr.name: expr for expr in exprs} + # id -> id + graph = DiGraph( + (expr.name, child_id) + for expr in exprs + for child_id in expr.expr.column_references + if child_id in by_id.keys() + ) + # TODO: Also prevent inlining expensive or non-deterministic + # We avoid inlining multi-parent ids, as they would be inlined multiple places, potentially increasing work and/or compiled text size + multi_parent_ids = set(id for id in graph.nodes if len(graph.parents(id)) > 2) + scalar_ids = set(expr.name for expr in exprs if expr.expr.is_scalar_expr) + + def graph_extract_scalar_exprs() -> Sequence[NamedExpression]: + results: dict[identifiers.ColumnId, expression.Expression] = dict() + while ( + True + ): # Will converge as each loop either reduces graph size, or fails to find any candidate and breaks + candidate_ids = list( + id + for id in graph.sinks + if (id in scalar_ids) + and not any( + ( + child in multi_parent_ids + and id in results.keys() + and not is_simple(results[id]) + ) + for child in graph.children(id) + ) + ) + if len(candidate_ids) == 0: + break + for id in candidate_ids: + graph.remove_node(id) + new_exprs = { + id: by_id[id].expr.bind_refs(results, allow_partial_bindings=True) + } + results.update(new_exprs) + # TODO: We can prune expressions that won't be reused here, + return tuple(NamedExpression(expr, id) for id, expr in results.items()) + + def graph_extract_window_expr() -> Optional[ + Tuple[identifiers.ColumnId, agg_expressions.WindowExpression] + ]: + candidate = list( + itertools.islice((id for id in graph.sinks if id not in scalar_ids), 1) + ) + if not candidate: + return None + else: + id = next(iter(candidate)) + graph.remove_node(id) + result_expr = by_id[id].expr + assert isinstance(result_expr, agg_expressions.WindowExpression) + return (id, result_expr) + + while not graph.empty: + pre_size = len(graph.nodes) + scalar_exprs = graph_extract_scalar_exprs() + if scalar_exprs: + curr_root = nodes.ProjectionNode( + curr_root, tuple((x.expr, x.name) for x in scalar_exprs) + ) + while result := graph_extract_window_expr(): + id, window_expr = result + curr_root = nodes.WindowOpNode( + curr_root, window_expr.analytic_expr, window_expr.window, output_name=id + ) + if len(graph.nodes) >= pre_size: + raise ValueError("graph didn't shrink") + # TODO: Try to get the ordering right earlier, so can avoid this extra node. + post_ids = (*root.ids, *target_ids) + if tuple(curr_root.ids) != post_ids: + curr_root = nodes.SelectionNode( + curr_root, tuple(nodes.AliasedRef.identity(id) for id in post_ids) + ) + return curr_root + + +@functools.cache +def is_simple(expr: expression.Expression) -> bool: + count = 0 + for part in expr.walk(): + count += 1 + if count > _MAX_INLINE_COMPLEXITY: + return False + return True diff --git a/bigframes/core/nodes.py b/bigframes/core/nodes.py index 553b41a631..a8457d383b 100644 --- a/bigframes/core/nodes.py +++ b/bigframes/core/nodes.py @@ -1199,6 +1199,7 @@ def _validate(self): for expression, _ in self.assignments: # throws TypeError if invalid _ = ex.bind_schema_fields(expression, self.child.field_by_id).output_type + assert expression.is_scalar_expr # Cannot assign to existing variables - append only! assert all(name not in self.child.schema.names for _, name in self.assignments) @@ -1404,6 +1405,11 @@ def _validate(self): not self.window_spec.is_row_bounded ) or self.expression.op.implicitly_inherits_order assert all(ref in self.child.ids for ref in self.expression.column_references) + assert self.added_field.dtype is not None + for agg_child in self.expression.children: + assert agg_child.is_scalar_expr + for window_expr in self.window_spec.expressions: + assert window_expr.is_scalar_expr @property def non_local(self) -> bool: From 8f490e68a9a2584236486060ad3b55923781d975 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 13 Nov 2025 10:38:22 -0800 Subject: [PATCH 229/313] feat: pivot_table supports fill_value arg (#2257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes # 🦕 --- bigframes/core/reshape/pivot.py | 5 ++-- bigframes/dataframe.py | 8 ++--- tests/system/small/test_dataframe.py | 29 ++++++++++++++----- .../bigframes_vendored/pandas/core/frame.py | 4 +++ 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/bigframes/core/reshape/pivot.py b/bigframes/core/reshape/pivot.py index 8b83cb0fc7..c69c7f11ab 100644 --- a/bigframes/core/reshape/pivot.py +++ b/bigframes/core/reshape/pivot.py @@ -71,12 +71,11 @@ def crosstab( columns=tmp_col_names, aggfunc=aggfunc or "count", sort=False, + fill_value=0 if (aggfunc is None) else None, ) + # Undo temporary unique level labels pivot_table.index.names = rownames or [i.name for i in index] pivot_table.columns.names = colnames or [c.name for c in columns] - if aggfunc is None: - # TODO: Push this into pivot_table itself - pivot_table = pivot_table.fillna(0) return pivot_table diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index da6da7a925..1e60fe6a8d 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -3486,10 +3486,6 @@ def pivot_table( observed: bool = False, sort: bool = True, ) -> DataFrame: - if fill_value is not None: - raise NotImplementedError( - "DataFrame.pivot_table fill_value arg not supported. {constants.FEEDBACK_LINK}" - ) if margins: raise NotImplementedError( "DataFrame.pivot_table margins arg not supported. {constants.FEEDBACK_LINK}" @@ -3549,6 +3545,8 @@ def pivot_table( index=index, values=values if len(values) > 1 else None, ) + if fill_value is not None: + pivoted = pivoted.fillna(fill_value) if sort: pivoted = pivoted.sort_index() @@ -3556,7 +3554,7 @@ def pivot_table( # The pivot_table method results in multi-index columns that are always ordered. # However, the order of the pivoted result columns is not guaranteed to be sorted. # Sort and reorder. - return pivoted[pivoted.columns.sort_values()] + return pivoted.sort_index(axis=1) # type: ignore def stack(self, level: LevelsType = -1): if not isinstance(self.columns, pandas.MultiIndex): diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 5750f03f9c..d03c778326 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -3784,12 +3784,18 @@ def test_df_pivot_hockey(hockey_df, hockey_pandas_df, values, index, columns): @pytest.mark.parametrize( - ("values", "index", "columns", "aggfunc"), + ("values", "index", "columns", "aggfunc", "fill_value"), [ - (("culmen_length_mm", "body_mass_g"), "species", "sex", "std"), - (["body_mass_g", "culmen_length_mm"], ("species", "island"), "sex", "sum"), - ("body_mass_g", "sex", ["island", "species"], "mean"), - ("culmen_depth_mm", "island", "species", "max"), + (("culmen_length_mm", "body_mass_g"), "species", "sex", "std", 1.0), + ( + ["body_mass_g", "culmen_length_mm"], + ("species", "island"), + "sex", + "sum", + None, + ), + ("body_mass_g", "sex", ["island", "species"], "mean", None), + ("culmen_depth_mm", "island", "species", "max", -1), ], ) def test_df_pivot_table( @@ -3799,12 +3805,21 @@ def test_df_pivot_table( index, columns, aggfunc, + fill_value, ): bf_result = penguins_df_default_index.pivot_table( - values=values, index=index, columns=columns, aggfunc=aggfunc + values=values, + index=index, + columns=columns, + aggfunc=aggfunc, + fill_value=fill_value, ).to_pandas() pd_result = penguins_pandas_df_default_index.pivot_table( - values=values, index=index, columns=columns, aggfunc=aggfunc + values=values, + index=index, + columns=columns, + aggfunc=aggfunc, + fill_value=fill_value, ) pd.testing.assert_frame_equal( bf_result, pd_result, check_dtype=False, check_column_type=False diff --git a/third_party/bigframes_vendored/pandas/core/frame.py b/third_party/bigframes_vendored/pandas/core/frame.py index 3381f53351..dc1bcca213 100644 --- a/third_party/bigframes_vendored/pandas/core/frame.py +++ b/third_party/bigframes_vendored/pandas/core/frame.py @@ -6414,6 +6414,10 @@ def pivot_table(self, values=None, index=None, columns=None, aggfunc="mean"): aggfunc (str, default "mean"): Aggregation function name to compute summary statistics (e.g., 'sum', 'mean'). + fill_value (scalar, default None): + Value to replace missing values with (in the resulting pivot table, after + aggregation). + Returns: bigframes.pandas.DataFrame: An Excel style pivot table. """ From 86ed01b4468c83d5f3bec7a19c99c89888345a89 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Thu, 13 Nov 2025 12:02:05 -0800 Subject: [PATCH 230/313] Refactor: Simplify fillna_op implementation (#2261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes # 🦕 --- bigframes/core/compile/ibis_compiler/scalar_op_registry.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index 0876722990..259e99866b 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -1722,10 +1722,7 @@ def fillna_op( x: ibis_types.Value, y: ibis_types.Value, ): - if hasattr(x, "fill_null"): - return x.fill_null(typing.cast(ibis_types.Scalar, y)) - else: - return x.fill_null(typing.cast(ibis_types.Scalar, y)) + return x.fill_null(typing.cast(ibis_types.Scalar, y)) @scalar_op_compiler.register_binary_op(ops.round_op) From 3702f56cb93d011aeac5afd27358a1d82f4969af Mon Sep 17 00:00:00 2001 From: jialuoo Date: Thu, 13 Nov 2025 12:36:55 -0800 Subject: [PATCH 231/313] chore: Migrate manhattan_distance_op operator to SQLGlot (#2254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes b/447388852 🦕 --- .../compile/sqlglot/expressions/numeric_ops.py | 7 +++++++ .../test_manhattan_distance/out.sql | 16 ++++++++++++++++ .../sqlglot/expressions/test_numeric_ops.py | 15 +++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_manhattan_distance/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py index e33702b08c..e0ea24a470 100644 --- a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py @@ -350,6 +350,13 @@ def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: return result +@register_binary_op(ops.manhattan_distance_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.func( + "ML.DISTANCE", left.expr, right.expr, sge.Literal.string("MANHATTAN") + ) + + @register_binary_op(ops.mod_op) def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: # In BigQuery returned value has the same sign as X. In pandas, the sign of y is used, so we need to flip the result if sign(x) != sign(y) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_manhattan_distance/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_manhattan_distance/out.sql new file mode 100644 index 0000000000..185bb7b277 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_manhattan_distance/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `float_list_col`, + `numeric_list_col` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + ML.DISTANCE(`float_list_col`, `float_list_col`, 'MANHATTAN') AS `bfcol_2`, + ML.DISTANCE(`numeric_list_col`, `numeric_list_col`, 'MANHATTAN') AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `float_list_col`, + `bfcol_3` AS `numeric_list_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py index c58ce9e2f1..5d3b23ebb7 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py @@ -354,6 +354,21 @@ def test_floordiv_timedelta(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(bf_df.sql, "out.sql") +def test_manhattan_distance(repeated_types_df: bpd.DataFrame, snapshot): + col_names = ["float_list_col", "numeric_list_col"] + bf_df = repeated_types_df[col_names] + + sql = utils._apply_ops_to_sql( + bf_df, + [ + ops.manhattan_distance_op.as_expr("float_list_col", "float_list_col"), + ops.manhattan_distance_op.as_expr("numeric_list_col", "numeric_list_col"), + ], + ["float_list_col", "numeric_list_col"], + ) + snapshot.assert_match(sql, "out.sql") + + def test_mul_numeric(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["int64_col", "bool_col"]] From 0e3f2a46835a04684dec8d4059fd30649bc51f4b Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Thu, 13 Nov 2025 16:22:00 -0500 Subject: [PATCH 232/313] chore(librarian): onboard to librarian (#2233) Towards https://github.com/googleapis/librarian/issues/2456 Files removed which are no longer used - Owlbot config files, including owlbot.py - Sync repo settings config file - Release please config files --------- Co-authored-by: ohmayr --- .github/.OwlBot.lock.yaml | 17 ---- .github/.OwlBot.yaml | 18 ----- .github/auto-approve.yml | 3 - .github/release-please.yml | 10 --- .github/release-trigger.yml | 2 - .github/sync-repo-settings.yaml | 52 ------------ .librarian/state.yaml | 10 +++ owlbot.py | 138 -------------------------------- 8 files changed, 10 insertions(+), 240 deletions(-) delete mode 100644 .github/.OwlBot.lock.yaml delete mode 100644 .github/.OwlBot.yaml delete mode 100644 .github/auto-approve.yml delete mode 100644 .github/release-please.yml delete mode 100644 .github/release-trigger.yml delete mode 100644 .github/sync-repo-settings.yaml create mode 100644 .librarian/state.yaml delete mode 100644 owlbot.py diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml deleted file mode 100644 index 51b21a62b7..0000000000 --- a/.github/.OwlBot.lock.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -docker: - image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest - digest: sha256:a7aef70df5f13313ddc027409fc8f3151422ec2a57ac8730fce8fa75c060d5bb -# created: 2025-04-10T17:00:10.042601326Z diff --git a/.github/.OwlBot.yaml b/.github/.OwlBot.yaml deleted file mode 100644 index c379bd3092..0000000000 --- a/.github/.OwlBot.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright 2021 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -docker: - image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest - -begin-after-commit-hash: 92006bb3cdc84677aa93c7f5235424ec2b157146 diff --git a/.github/auto-approve.yml b/.github/auto-approve.yml deleted file mode 100644 index 311ebbb853..0000000000 --- a/.github/auto-approve.yml +++ /dev/null @@ -1,3 +0,0 @@ -# https://github.com/googleapis/repo-automation-bots/tree/main/packages/auto-approve -processes: - - "OwlBotTemplateChanges" diff --git a/.github/release-please.yml b/.github/release-please.yml deleted file mode 100644 index 7c2b8d9e8a..0000000000 --- a/.github/release-please.yml +++ /dev/null @@ -1,10 +0,0 @@ -releaseType: python -handleGHRelease: true -extraFiles: - - bigframes/version.py - - third_party/bigframes_vendored/version.py - -branches: - - branch: v1 - handleGHRelease: true - releaseType: python diff --git a/.github/release-trigger.yml b/.github/release-trigger.yml deleted file mode 100644 index 4fbd4aa427..0000000000 --- a/.github/release-trigger.yml +++ /dev/null @@ -1,2 +0,0 @@ -enabled: true -multiScmName: python-bigquery-dataframes diff --git a/.github/sync-repo-settings.yaml b/.github/sync-repo-settings.yaml deleted file mode 100644 index 80bfd5f951..0000000000 --- a/.github/sync-repo-settings.yaml +++ /dev/null @@ -1,52 +0,0 @@ -# https://github.com/googleapis/repo-automation-bots/tree/main/packages/sync-repo-settings -# Rules for main branch protection -branchProtectionRules: -# Identifies the protection rule pattern. Name of the branch to be protected. -# Defaults to `main` -- pattern: main - requiresCodeOwnerReviews: true - requiresStrictStatusChecks: false - requiredStatusCheckContexts: - - 'OwlBot Post Processor' - - 'conventionalcommits.org' - - 'cla/google' - - 'docs' - - 'lint' - - 'mypy' - - 'unit (3.9)' - - 'unit (3.10)' - - 'unit (3.11)' - - 'unit (3.12)' - - 'cover' - - 'Kokoro presubmit' - - 'Kokoro windows' -- pattern: v1 - requiresCodeOwnerReviews: true - requiresStrictStatusChecks: false - requiredStatusCheckContexts: - - 'OwlBot Post Processor' - - 'conventionalcommits.org' - - 'cla/google' - - 'docs' - - 'lint' - - 'mypy' - - 'unit (3.9)' - - 'unit (3.10)' - - 'unit (3.11)' - - 'unit (3.12)' - - 'cover' - - 'Kokoro presubmit' - - 'Kokoro windows' -permissionRules: - - team: actools-python - permission: admin - - team: actools - permission: admin - - team: api-bigquery-dataframe - permission: push - - team: yoshi-python - permission: push - - team: python-samples-owners - permission: push - - team: python-samples-reviewers - permission: push diff --git a/.librarian/state.yaml b/.librarian/state.yaml new file mode 100644 index 0000000000..36216ec0db --- /dev/null +++ b/.librarian/state.yaml @@ -0,0 +1,10 @@ +image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator@sha256:c8612d3fffb3f6a32353b2d1abd16b61e87811866f7ec9d65b59b02eb452a620 +libraries: + - id: bigframes + version: 2.28.0 + apis: [] + source_roots: + - . + preserve_regex: [] + remove_regex: [] + tag_format: v{version} diff --git a/owlbot.py b/owlbot.py deleted file mode 100644 index 4a189ff0e2..0000000000 --- a/owlbot.py +++ /dev/null @@ -1,138 +0,0 @@ -# Copyright 2021 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""This script is used to synthesize generated parts of this library.""" - -import pathlib -import re -import textwrap - -from synthtool import gcp -import synthtool as s -from synthtool.languages import python - -REPO_ROOT = pathlib.Path(__file__).parent.absolute() - -common = gcp.CommonTemplates() - -# ---------------------------------------------------------------------------- -# Add templated files -# ---------------------------------------------------------------------------- -templated_files = common.py_library( - default_python_version="3.10", - unit_test_python_versions=["3.9", "3.10", "3.11", "3.12", "3.13"], - system_test_python_versions=["3.9", "3.11", "3.12", "3.13"], - cov_level=35, - intersphinx_dependencies={ - "pandas": "https://pandas.pydata.org/pandas-docs/stable/", - "pydata-google-auth": "https://pydata-google-auth.readthedocs.io/en/latest/", - }, -) -s.move( - templated_files, - excludes=[ - # Need a combined LICENSE for all vendored packages. - "LICENSE", - "docs/conf.py", - # Multi-processing note isn't relevant, as bigframes is responsible for - # creating clients, not the end user. - "docs/multiprocessing.rst", - "noxfile.py", - ".pre-commit-config.yaml", - "README.rst", - "CONTRIBUTING.rst", - ".github/release-trigger.yml", - ".github/release-please.yml", - # BigQuery DataFrames manages its own Kokoro cluster for presubmit & continuous tests. - ".kokoro/build.sh", - ".kokoro/continuous/common.cfg", - ".kokoro/presubmit/common.cfg", - ".github/workflows/docs.yml", - ".github/workflows/lint.yml", - ".github/workflows/unittest.yml", - ], -) - -# ---------------------------------------------------------------------------- -# Fixup files -# ---------------------------------------------------------------------------- - -# Encourage sharring all relevant versions in bug reports. -assert 1 == s.replace( # bug_report.md - [".github/ISSUE_TEMPLATE/bug_report.md"], - re.escape("#### Steps to reproduce\n"), - textwrap.dedent( - """ - ```python - import sys - import bigframes - import google.cloud.bigquery - import pandas - import pyarrow - import sqlglot - - print(f"Python: {sys.version}") - print(f"bigframes=={bigframes.__version__}") - print(f"google-cloud-bigquery=={google.cloud.bigquery.__version__}") - print(f"pandas=={pandas.__version__}") - print(f"pyarrow=={pyarrow.__version__}") - print(f"sqlglot=={sqlglot.__version__}") - ``` - - #### Steps to reproduce - """, - ), -) - -# Make sure build includes all necessary files. -assert 1 == s.replace( # MANIFEST.in - ["MANIFEST.in"], - re.escape("recursive-include google"), - "recursive-include third_party/bigframes_vendored *\nrecursive-include bigframes", -) - -# Include JavaScript files for display widgets -assert 1 == s.replace( # MANIFEST.in - ["MANIFEST.in"], - re.escape("recursive-include bigframes *.json *.proto py.typed"), - "recursive-include bigframes *.json *.proto *.js py.typed", -) - -# Include JavaScript and CSS files for display widgets -assert 1 == s.replace( # MANIFEST.in - ["MANIFEST.in"], - re.escape("recursive-include bigframes *.json *.proto *.js py.typed"), - "recursive-include bigframes *.json *.proto *.js *.css py.typed", -) - -# Don't omit `*/core/*.py` when counting test coverages -assert 1 == s.replace( # .coveragerc - [".coveragerc"], - re.escape(" */core/*.py\n"), - "", -) - -# ---------------------------------------------------------------------------- -# Samples templates -# ---------------------------------------------------------------------------- - -python.py_samples(skip_readmes=True) - -# ---------------------------------------------------------------------------- -# Final cleanup -# ---------------------------------------------------------------------------- - -s.shell.run(["nox", "-s", "format"], hide_output=False) -for noxfile in REPO_ROOT.glob("samples/**/noxfile.py"): - s.shell.run(["nox", "-s", "format"], cwd=noxfile.parent, hide_output=False) From f73fb989522a39572c993905b88880e243528196 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Thu, 13 Nov 2025 13:36:00 -0800 Subject: [PATCH 233/313] chore: Migrate geo_st_intersection_op operator to SQLGlot (#2262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes b/447388852 🦕 --- .../core/compile/sqlglot/expressions/geo_ops.py | 5 +++++ .../test_geo_ops/test_geo_st_intersection/out.sql | 13 +++++++++++++ .../compile/sqlglot/expressions/test_geo_ops.py | 8 ++++++++ 3 files changed, 26 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_intersection/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/geo_ops.py b/bigframes/core/compile/sqlglot/expressions/geo_ops.py index 24e488699f..fa3053db63 100644 --- a/bigframes/core/compile/sqlglot/expressions/geo_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/geo_ops.py @@ -114,3 +114,8 @@ def _(expr: TypedExpr) -> sge.Expression: @register_binary_op(ops.geo_st_difference_op) def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: return sge.func("ST_DIFFERENCE", left.expr, right.expr) + + +@register_binary_op(ops.geo_st_intersection_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.func("ST_INTERSECTION", left.expr, right.expr) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_intersection/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_intersection/out.sql new file mode 100644 index 0000000000..f9290fe01a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_intersection/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_INTERSECTION(`geography_col`, `geography_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `geography_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py index 847671b4b7..cce3e726ac 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py @@ -99,6 +99,14 @@ def test_geo_st_geogfromtext(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_geo_st_intersection(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_binary_op(bf_df, ops.geo_st_intersection_op, col_name, col_name) + + snapshot.assert_match(sql, "out.sql") + + def test_geo_st_isclosed(scalar_types_df: bpd.DataFrame, snapshot): col_name = "geography_col" bf_df = scalar_types_df[[col_name]] From 597eb55c1617bc36b378683051aaa10f6bb56bde Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 13 Nov 2025 16:25:20 -0800 Subject: [PATCH 234/313] refactor: Simplify InNode definition (#2264) --- bigframes/core/array_value.py | 6 ++-- bigframes/core/blocks.py | 2 +- .../compile/ibis_compiler/ibis_compiler.py | 2 +- bigframes/core/compile/polars/compiler.py | 7 ++--- bigframes/core/compile/sqlglot/compiler.py | 7 +++-- bigframes/core/nodes.py | 19 ++++++------ bigframes/core/rewrite/identifiers.py | 3 -- bigframes/core/rewrite/implicit_align.py | 31 +------------------ bigframes/core/rewrite/pruning.py | 5 --- bigframes/core/rewrite/schema_binding.py | 3 -- tests/system/small/engines/test_join.py | 4 ++- tests/unit/core/rewrite/test_identifiers.py | 10 +++--- 12 files changed, 32 insertions(+), 67 deletions(-) diff --git a/bigframes/core/array_value.py b/bigframes/core/array_value.py index c3d71d19af..35d0c5d4d4 100644 --- a/bigframes/core/array_value.py +++ b/bigframes/core/array_value.py @@ -450,13 +450,15 @@ def project_window_expr( ) def isin( - self, other: ArrayValue, lcol: str, rcol: str + self, + other: ArrayValue, + lcol: str, ) -> typing.Tuple[ArrayValue, str]: + assert len(other.column_ids) == 1 node = nodes.InNode( self.node, other.node, ex.deref(lcol), - ex.deref(rcol), indicator_col=ids.ColumnId.unique(), ) return ArrayValue(node), node.indicator_col.name diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index cf5f0513e6..bd478800eb 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -2326,7 +2326,7 @@ def isin(self, other: Block): return block def _isin_inner(self: Block, col: str, unique_values: core.ArrayValue) -> Block: - expr, matches = self._expr.isin(unique_values, col, unique_values.column_ids[0]) + expr, matches = self._expr.isin(unique_values, col) new_value_cols = tuple( val_col if val_col != col else matches for val_col in self.value_columns diff --git a/bigframes/core/compile/ibis_compiler/ibis_compiler.py b/bigframes/core/compile/ibis_compiler/ibis_compiler.py index 0436e05559..17bfc6ef55 100644 --- a/bigframes/core/compile/ibis_compiler/ibis_compiler.py +++ b/bigframes/core/compile/ibis_compiler/ibis_compiler.py @@ -128,7 +128,7 @@ def compile_isin( return left.isin_join( right=right, indicator_col=node.indicator_col.sql, - conditions=(node.left_col.id.sql, node.right_col.id.sql), + conditions=(node.left_col.id.sql, list(node.right_child.ids)[0].sql), join_nulls=node.joins_nulls, ) diff --git a/bigframes/core/compile/polars/compiler.py b/bigframes/core/compile/polars/compiler.py index 3211d6ebf7..f822a6a83f 100644 --- a/bigframes/core/compile/polars/compiler.py +++ b/bigframes/core/compile/polars/compiler.py @@ -700,12 +700,11 @@ def compile_join(self, node: nodes.JoinNode): @compile_node.register def compile_isin(self, node: nodes.InNode): left = self.compile_node(node.left_child) - right = self.compile_node(node.right_child).unique(node.right_col.id.sql) + right = self.compile_node(node.right_child).unique() right = right.with_columns(pl.lit(True).alias(node.indicator_col.sql)) - left_ex, right_ex = lowering._coerce_comparables( - node.left_col, node.right_col - ) + right_col = ex.ResolvedDerefOp.from_field(node.right_child.fields[0]) + left_ex, right_ex = lowering._coerce_comparables(node.left_col, right_col) left_pl_ex = self.expr_compiler.compile_expression(left_ex) right_pl_ex = self.expr_compiler.compile_expression(right_ex) diff --git a/bigframes/core/compile/sqlglot/compiler.py b/bigframes/core/compile/sqlglot/compiler.py index 0bf74e472f..2f99b74176 100644 --- a/bigframes/core/compile/sqlglot/compiler.py +++ b/bigframes/core/compile/sqlglot/compiler.py @@ -230,14 +230,17 @@ def compile_join( def compile_isin_join( node: nodes.InNode, left: ir.SQLGlotIR, right: ir.SQLGlotIR ) -> ir.SQLGlotIR: + right_field = node.right_child.fields[0] conditions = ( typed_expr.TypedExpr( scalar_compiler.scalar_op_compiler.compile_expression(node.left_col), node.left_col.output_type, ), typed_expr.TypedExpr( - scalar_compiler.scalar_op_compiler.compile_expression(node.right_col), - node.right_col.output_type, + scalar_compiler.scalar_op_compiler.compile_expression( + expression.DerefOp(right_field.id) + ), + right_field.dtype, ), ) diff --git a/bigframes/core/nodes.py b/bigframes/core/nodes.py index a8457d383b..8d1759afd7 100644 --- a/bigframes/core/nodes.py +++ b/bigframes/core/nodes.py @@ -200,13 +200,10 @@ class InNode(BigFrameNode, AdditiveNode): left_child: BigFrameNode right_child: BigFrameNode left_col: ex.DerefOp - right_col: ex.DerefOp indicator_col: identifiers.ColumnId def _validate(self): - assert not ( - set(self.left_child.ids) & set(self.right_child.ids) - ), "Join ids collide" + assert len(self.right_child.fields) == 1 @property def row_preserving(self) -> bool: @@ -259,7 +256,11 @@ def node_defined_ids(self) -> Tuple[identifiers.ColumnId, ...]: @property def referenced_ids(self) -> COLUMN_SET: - return frozenset({self.left_col.id, self.right_col.id}) + return frozenset( + { + self.left_col.id, + } + ) @property def additive_base(self) -> BigFrameNode: @@ -268,12 +269,13 @@ def additive_base(self) -> BigFrameNode: @property def joins_nulls(self) -> bool: left_nullable = self.left_child.field_by_id[self.left_col.id].nullable - right_nullable = self.right_child.field_by_id[self.right_col.id].nullable + # assumption: right side has one column + right_nullable = self.right_child.fields[0].nullable return left_nullable or right_nullable @property def _node_expressions(self): - return (self.left_col, self.right_col) + return (self.left_col,) def replace_additive_base(self, node: BigFrameNode): return dataclasses.replace(self, left_child=node) @@ -302,9 +304,6 @@ def remap_refs( left_col=self.left_col.remap_column_refs( mappings, allow_partial_bindings=True ), - right_col=self.right_col.remap_column_refs( - mappings, allow_partial_bindings=True - ), ) # type: ignore diff --git a/bigframes/core/rewrite/identifiers.py b/bigframes/core/rewrite/identifiers.py index 2e31f07a79..da43fdf8b9 100644 --- a/bigframes/core/rewrite/identifiers.py +++ b/bigframes/core/rewrite/identifiers.py @@ -69,9 +69,6 @@ def remap_variables( left_col=new_root.left_col.remap_column_refs( new_child_mappings[0], allow_partial_bindings=True ), - right_col=new_root.right_col.remap_column_refs( - new_child_mappings[1], allow_partial_bindings=True - ), ) else: new_root = new_root.remap_refs(downstream_mappings) diff --git a/bigframes/core/rewrite/implicit_align.py b/bigframes/core/rewrite/implicit_align.py index a20b698ff4..ebd48d8236 100644 --- a/bigframes/core/rewrite/implicit_align.py +++ b/bigframes/core/rewrite/implicit_align.py @@ -15,7 +15,7 @@ import dataclasses import itertools -from typing import cast, Optional, Sequence, Set, Tuple +from typing import Optional, Sequence, Set, Tuple import bigframes.core.expression import bigframes.core.identifiers @@ -152,35 +152,6 @@ def pull_up_selection( return node, tuple( bigframes.core.nodes.AliasedRef.identity(field.id) for field in node.fields ) - # InNode needs special handling, as its a binary node, but row identity is from left side only. - # TODO: Merge code with unary op paths - if isinstance(node, bigframes.core.nodes.InNode): - child_node, child_selections = pull_up_selection( - node.left_child, stop=stop, rename_vars=rename_vars - ) - mapping = {out: ref.id for ref, out in child_selections} - - new_in_node: bigframes.core.nodes.InNode = dataclasses.replace( - node, left_child=child_node - ) - new_in_node = new_in_node.remap_refs(mapping) - if rename_vars: - new_in_node = cast( - bigframes.core.nodes.InNode, - new_in_node.remap_vars( - {node.indicator_col: bigframes.core.identifiers.ColumnId.unique()} - ), - ) - added_selection = tuple( - ( - bigframes.core.nodes.AliasedRef( - bigframes.core.expression.DerefOp(new_in_node.indicator_col), - node.indicator_col, - ), - ) - ) - new_selection = child_selections + added_selection - return new_in_node, new_selection if isinstance(node, bigframes.core.nodes.AdditiveNode): child_node, child_selections = pull_up_selection( diff --git a/bigframes/core/rewrite/pruning.py b/bigframes/core/rewrite/pruning.py index 41664e1c47..7695ace3b3 100644 --- a/bigframes/core/rewrite/pruning.py +++ b/bigframes/core/rewrite/pruning.py @@ -55,11 +55,6 @@ def prune_columns(node: nodes.BigFrameNode): result = node.replace_child(prune_node(node.child, node.consumed_ids)) elif isinstance(node, nodes.AggregateNode): result = node.replace_child(prune_node(node.child, node.consumed_ids)) - elif isinstance(node, nodes.InNode): - result = dataclasses.replace( - node, - right_child=prune_node(node.right_child, frozenset([node.right_col.id])), - ) else: result = node return result diff --git a/bigframes/core/rewrite/schema_binding.py b/bigframes/core/rewrite/schema_binding.py index 8a0bcc4921..fe9143baf2 100644 --- a/bigframes/core/rewrite/schema_binding.py +++ b/bigframes/core/rewrite/schema_binding.py @@ -71,9 +71,6 @@ def bind_schema_to_node( left_col=ex.ResolvedDerefOp.from_field( node.left_child.field_by_id[node.left_col.id] ), - right_col=ex.ResolvedDerefOp.from_field( - node.right_child.field_by_id[node.right_col.id] - ), ) if isinstance(node, nodes.AggregateNode): diff --git a/tests/system/small/engines/test_join.py b/tests/system/small/engines/test_join.py index 7ea24a554d..15dbfabdac 100644 --- a/tests/system/small/engines/test_join.py +++ b/tests/system/small/engines/test_join.py @@ -102,8 +102,10 @@ def test_engines_cross_join( def test_engines_isin( scalars_array_value: array_value.ArrayValue, engine, left_key, right_key ): + other = scalars_array_value.select_columns([right_key]) result, _ = scalars_array_value.isin( - scalars_array_value, lcol=left_key, rcol=right_key + other, + lcol=left_key, ) assert_equivalence_execution(result.node, REFERENCE_ENGINE, engine) diff --git a/tests/unit/core/rewrite/test_identifiers.py b/tests/unit/core/rewrite/test_identifiers.py index c23d69c9b9..09904ac4ba 100644 --- a/tests/unit/core/rewrite/test_identifiers.py +++ b/tests/unit/core/rewrite/test_identifiers.py @@ -134,11 +134,13 @@ def test_remap_variables_concat_self_stability(leaf): def test_remap_variables_in_node_converts_dag_to_tree(leaf, leaf_too): # Create an InNode with the same child twice, should create a tree from a DAG + right = nodes.SelectionNode( + leaf_too, (nodes.AliasedRef.identity(identifiers.ColumnId("col_a")),) + ) node = nodes.InNode( left_child=leaf, - right_child=leaf_too, + right_child=right, left_col=ex.DerefOp(identifiers.ColumnId("col_a")), - right_col=ex.DerefOp(identifiers.ColumnId("col_a")), indicator_col=identifiers.ColumnId("indicator"), ) @@ -147,7 +149,5 @@ def test_remap_variables_in_node_converts_dag_to_tree(leaf, leaf_too): new_node = typing.cast(nodes.InNode, new_node) left_col_id = new_node.left_col.id.name - right_col_id = new_node.right_col.id.name + new_node.validate_tree() assert left_col_id.startswith("id_") - assert right_col_id.startswith("id_") - assert left_col_id != right_col_id From d85eee3a69d3c49b5cbbf101df8c207eb35f5976 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Thu, 13 Nov 2025 16:39:33 -0800 Subject: [PATCH 235/313] Refactor: Use sge.func for ML.DISTANCE (#2270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced `sge.Anonymous` with `sge.func` for `ML.DISTANCE` calls to improve code clarity and consistency. Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes # 🦕 --- .../compile/sqlglot/expressions/numeric_ops.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py index e0ea24a470..c022356fd3 100644 --- a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py @@ -127,14 +127,7 @@ def _(expr: TypedExpr) -> sge.Expression: @register_binary_op(ops.cosine_distance_op) def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - return sge.Anonymous( - this="ML.DISTANCE", - expressions=[ - left.expr, - right.expr, - sge.Literal.string("COSINE"), - ], - ) + return sge.func("ML.DISTANCE", left.expr, right.expr, sge.Literal.string("COSINE")) @register_unary_op(ops.exp_op) @@ -307,13 +300,8 @@ def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: @register_binary_op(ops.euclidean_distance_op) def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - return sge.Anonymous( - this="ML.DISTANCE", - expressions=[ - left.expr, - right.expr, - sge.Literal.string("EUCLIDEAN"), - ], + return sge.func( + "ML.DISTANCE", left.expr, right.expr, sge.Literal.string("EUCLIDEAN") ) From e574024175a27eac21377e475bd655e30707c19f Mon Sep 17 00:00:00 2001 From: jialuoo Date: Thu, 13 Nov 2025 16:39:42 -0800 Subject: [PATCH 236/313] Chore: Migrate geo_st_geogpoint_op operator to SQLGlot (#2266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes b/447388852 🦕 --- .../core/compile/sqlglot/expressions/geo_ops.py | 5 +++++ .../test_geo_ops/test_geo_st_geogpoint/out.sql | 14 ++++++++++++++ .../compile/sqlglot/expressions/test_geo_ops.py | 10 ++++++++++ 3 files changed, 29 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogpoint/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/geo_ops.py b/bigframes/core/compile/sqlglot/expressions/geo_ops.py index fa3053db63..10ccdfbeb3 100644 --- a/bigframes/core/compile/sqlglot/expressions/geo_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/geo_ops.py @@ -60,6 +60,11 @@ def _(expr: TypedExpr) -> sge.Expression: return sge.func("ST_CONVEXHULL", expr.expr) +@register_binary_op(ops.geo_st_geogpoint_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.func("ST_GEOGPOINT", left.expr, right.expr) + + @register_unary_op(ops.geo_st_geogfromtext_op) def _(expr: TypedExpr) -> sge.Expression: return sge.func("SAFE.ST_GEOGFROMTEXT", expr.expr) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogpoint/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogpoint/out.sql new file mode 100644 index 0000000000..f6c953d161 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogpoint/out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `rowindex`, + `rowindex_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_GEOGPOINT(`rowindex`, `rowindex_2`) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `rowindex` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py index cce3e726ac..5684ff6f15 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py @@ -99,6 +99,16 @@ def test_geo_st_geogfromtext(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_geo_st_geogpoint(scalar_types_df: bpd.DataFrame, snapshot): + col_names = ["rowindex", "rowindex_2"] + bf_df = scalar_types_df[col_names] + sql = utils._apply_binary_op( + bf_df, ops.geo_st_geogpoint_op, col_names[0], col_names[1] + ) + + snapshot.assert_match(sql, "out.sql") + + def test_geo_st_intersection(scalar_types_df: bpd.DataFrame, snapshot): col_name = "geography_col" bf_df = scalar_types_df[[col_name]] From 95a83f7774766cd19cb583dfaa3417882b5c9b1e Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Thu, 13 Nov 2025 22:01:03 -0800 Subject: [PATCH 237/313] fix: calling info() on empty dataframes no longer leads to errors (#2267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #460154155 🦕 --- bigframes/dataframe.py | 18 ++++++---- tests/system/small/test_dataframe.py | 50 ++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 1e60fe6a8d..4b41006547 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -521,15 +521,21 @@ def info( if self._block.has_index: index_type = "MultiIndex" if self.index.nlevels > 1 else "Index" - # These accessses are kind of expensive, maybe should try to skip? - first_indice = self.index[0] - last_indice = self.index[-1] - obuf.write( - f"{index_type}: {n_rows} entries, {first_indice} to {last_indice}\n" - ) + index_stats = f"{n_rows} entries" + if n_rows > 0: + # These accessses are kind of expensive, maybe should try to skip? + first_indice = self.index[0] + last_indice = self.index[-1] + index_stats += f", {first_indice} to {last_indice}" + obuf.write(f"{index_type}: {index_stats}\n") else: obuf.write("NullIndex\n") + if n_columns == 0: + # We don't display any more information if the dataframe has no columns + obuf.write("Empty DataFrame\n") + return + dtype_strings = self.dtypes.astype("string") if show_all_columns: obuf.write(f"Data columns (total {n_columns} columns):\n") diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index d03c778326..19d3c67e19 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -671,15 +671,61 @@ def test_df_info(scalars_dfs): "dtypes: Float64(1), Int64(3), binary[pyarrow](1), boolean(1), date32[day][pyarrow](1), decimal128(38, 9)[pyarrow](1), duration[us][pyarrow](1), geometry(1), string(1), time64[us][pyarrow](1), timestamp[us, tz=UTC][pyarrow](1), timestamp[us][pyarrow](1)\n" "memory usage: 1341 bytes\n" ) - scalars_df, _ = scalars_dfs - bf_result = io.StringIO() + bf_result = io.StringIO() scalars_df.info(buf=bf_result) assert expected == bf_result.getvalue() +def test_df_info_no_rows(session): + expected = ( + "\n" + "Index: 0 entries\n" + "Data columns (total 1 columns):\n" + " # Column Non-Null Count Dtype\n" + "--- -------- ---------------- -------\n" + " 0 col 0 non-null Float64\n" + "dtypes: Float64(1)\n" + "memory usage: 0 bytes\n" + ) + df = session.DataFrame({"col": []}) + + bf_result = io.StringIO() + df.info(buf=bf_result) + + assert expected == bf_result.getvalue() + + +def test_df_info_no_cols(session): + expected = ( + "\n" + "Index: 3 entries, 1 to 3\n" + "Empty DataFrame\n" + ) + df = session.DataFrame({}, index=[1, 2, 3]) + + bf_result = io.StringIO() + df.info(buf=bf_result) + + assert expected == bf_result.getvalue() + + +def test_df_info_no_cols_no_rows(session): + expected = ( + "\n" + "Index: 0 entries\n" + "Empty DataFrame\n" + ) + df = session.DataFrame({}) + + bf_result = io.StringIO() + df.info(buf=bf_result) + + assert expected == bf_result.getvalue() + + @pytest.mark.parametrize( ("include", "exclude"), [ From b0f16e648a6d8912c2c37ff3882ab31d050a0935 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Fri, 14 Nov 2025 15:03:51 -0800 Subject: [PATCH 238/313] refactor: add ToArrayOp and ArrayReduceOp to the sqlglot compiler (#2263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes internal issue 446726636 🦕 --- .../aggregations/ordered_unary_compiler.py | 2 +- .../sqlglot/aggregations/unary_compiler.py | 13 ++++ .../compile/sqlglot/expressions/array_ops.py | 65 ++++++++++++++++--- tests/system/small/engines/test_array_ops.py | 4 +- .../test_unary_compiler/test_any/out.sql | 12 ++++ .../test_any/window_out.sql | 17 +++++ .../aggregations/test_unary_compiler.py | 14 ++++ .../test_array_reduce_op/out.sql | 37 +++++++++++ .../test_array_ops/test_to_array_op/out.sql | 26 ++++++++ .../sqlglot/expressions/test_array_ops.py | 37 +++++++++++ 10 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/window_out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_reduce_op/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_to_array_op/out.sql diff --git a/bigframes/core/compile/sqlglot/aggregations/ordered_unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/ordered_unary_compiler.py index 9024a9ec89..594d75fd3c 100644 --- a/bigframes/core/compile/sqlglot/aggregations/ordered_unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/ordered_unary_compiler.py @@ -27,7 +27,7 @@ def compile( op: agg_ops.WindowOp, column: typed_expr.TypedExpr, *, - order_by: tuple[sge.Expression, ...], + order_by: tuple[sge.Expression, ...] = (), ) -> sge.Expression: return ORDERED_UNARY_OP_REGISTRATION[op](op, column, order_by=order_by) diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py index d0d887588c..603e8a096c 100644 --- a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -49,6 +49,19 @@ def _( return sge.func("IFNULL", result, sge.true()) +@UNARY_OP_REGISTRATION.register(agg_ops.AnyOp) +def _( + op: agg_ops.AnyOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + expr = column.expr + expr = apply_window_if_present(sge.func("LOGICAL_OR", expr), window) + + # BQ will return null for empty column, result would be false in pandas. + return sge.func("COALESCE", expr, sge.convert(False)) + + @UNARY_OP_REGISTRATION.register(agg_ops.ApproxQuartilesOp) def _( op: agg_ops.ApproxQuartilesOp, diff --git a/bigframes/core/compile/sqlglot/expressions/array_ops.py b/bigframes/core/compile/sqlglot/expressions/array_ops.py index 57ff2ee459..2758178beb 100644 --- a/bigframes/core/compile/sqlglot/expressions/array_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/array_ops.py @@ -16,19 +16,16 @@ import typing -import sqlglot +import sqlglot as sg import sqlglot.expressions as sge from bigframes import operations as ops from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler +import bigframes.dtypes as dtypes register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op - - -@register_unary_op(ops.ArrayToStringOp, pass_op=True) -def _(expr: TypedExpr, op: ops.ArrayToStringOp) -> sge.Expression: - return sge.ArrayToString(this=expr.expr, expression=f"'{op.delimiter}'") +register_nary_op = scalar_compiler.scalar_op_compiler.register_nary_op @register_unary_op(ops.ArrayIndexOp, pass_op=True) @@ -41,9 +38,37 @@ def _(expr: TypedExpr, op: ops.ArrayIndexOp) -> sge.Expression: ) +@register_unary_op(ops.ArrayReduceOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ArrayReduceOp) -> sge.Expression: + sub_expr = sg.to_identifier("bf_arr_reduce_uid") + sub_type = dtypes.get_array_inner_type(expr.dtype) + + if op.aggregation.order_independent: + from bigframes.core.compile.sqlglot.aggregations import unary_compiler + + agg_expr = unary_compiler.compile(op.aggregation, TypedExpr(sub_expr, sub_type)) + else: + from bigframes.core.compile.sqlglot.aggregations import ordered_unary_compiler + + agg_expr = ordered_unary_compiler.compile( + op.aggregation, TypedExpr(sub_expr, sub_type) + ) + + return ( + sge.select(agg_expr) + .from_( + sge.Unnest( + expressions=[expr.expr], + alias=sge.TableAlias(columns=[sub_expr]), + ) + ) + .subquery() + ) + + @register_unary_op(ops.ArraySliceOp, pass_op=True) def _(expr: TypedExpr, op: ops.ArraySliceOp) -> sge.Expression: - slice_idx = sqlglot.to_identifier("slice_idx") + slice_idx = sg.to_identifier("slice_idx") conditions: typing.List[sge.Predicate] = [slice_idx >= op.start] @@ -51,7 +76,7 @@ def _(expr: TypedExpr, op: ops.ArraySliceOp) -> sge.Expression: conditions.append(slice_idx < op.stop) # local name for each element in the array - el = sqlglot.to_identifier("el") + el = sg.to_identifier("el") selected_elements = ( sge.select(el) @@ -66,3 +91,27 @@ def _(expr: TypedExpr, op: ops.ArraySliceOp) -> sge.Expression: ) return sge.array(selected_elements) + + +@register_unary_op(ops.ArrayToStringOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ArrayToStringOp) -> sge.Expression: + return sge.ArrayToString(this=expr.expr, expression=f"'{op.delimiter}'") + + +@register_nary_op(ops.ToArrayOp) +def _(*exprs: TypedExpr) -> sge.Expression: + do_upcast_bool = any( + dtypes.is_numeric(expr.dtype, include_bool=False) for expr in exprs + ) + if do_upcast_bool: + sg_exprs = [_coerce_bool_to_int(expr) for expr in exprs] + else: + sg_exprs = [expr.expr for expr in exprs] + return sge.Array(expressions=sg_exprs) + + +def _coerce_bool_to_int(typed_expr: TypedExpr) -> sge.Expression: + """Coerce boolean expression to integer.""" + if typed_expr.dtype == dtypes.BOOL_DTYPE: + return sge.Cast(this=typed_expr.expr, to="INT64") + return typed_expr.expr diff --git a/tests/system/small/engines/test_array_ops.py b/tests/system/small/engines/test_array_ops.py index c53b9e9dc1..3b80cb8854 100644 --- a/tests/system/small/engines/test_array_ops.py +++ b/tests/system/small/engines/test_array_ops.py @@ -26,7 +26,7 @@ REFERENCE_ENGINE = polars_executor.PolarsExecutor() -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_to_array_op(scalars_array_value: array_value.ArrayValue, engine): # Bigquery won't allow you to materialize arrays with null, so use non-nullable int64_non_null = ops.coalesce_op.as_expr("int64_col", expression.const(0)) @@ -46,7 +46,7 @@ def test_engines_to_array_op(scalars_array_value: array_value.ArrayValue, engine assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_array_reduce_op(arrays_array_value: array_value.ArrayValue, engine): arr, _ = arrays_array_value.compute_values( [ diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/out.sql new file mode 100644 index 0000000000..03b0d5c151 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + COALESCE(LOGICAL_OR(`bool_col`), FALSE) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `bool_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/window_out.sql new file mode 100644 index 0000000000..970349a4f5 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/window_out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `bool_col` IS NULL + THEN NULL + ELSE COALESCE(LOGICAL_OR(`bool_col`) OVER (), FALSE) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `agg_bool` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index 478368393a..a21c753896 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -88,6 +88,20 @@ def test_all(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql_window_partition, "window_partition_out.sql") +def test_any(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "bool_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.AnyOp().as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + # Window tests + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + sql_window = _apply_unary_window_op(bf_df, agg_expr, window, "agg_bool") + snapshot.assert_match(sql_window, "window_out.sql") + + def test_approx_quartiles(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_reduce_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_reduce_op/out.sql new file mode 100644 index 0000000000..b9f87bfd1e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_reduce_op/out.sql @@ -0,0 +1,37 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_list_col`, + `float_list_col`, + `string_list_col` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + ( + SELECT + COALESCE(SUM(bf_arr_reduce_uid), 0) + FROM UNNEST(`float_list_col`) AS bf_arr_reduce_uid + ) AS `bfcol_3`, + ( + SELECT + STDDEV(bf_arr_reduce_uid) + FROM UNNEST(`float_list_col`) AS bf_arr_reduce_uid + ) AS `bfcol_4`, + ( + SELECT + COUNT(bf_arr_reduce_uid) + FROM UNNEST(`string_list_col`) AS bf_arr_reduce_uid + ) AS `bfcol_5`, + ( + SELECT + COALESCE(LOGICAL_OR(bf_arr_reduce_uid), FALSE) + FROM UNNEST(`bool_list_col`) AS bf_arr_reduce_uid + ) AS `bfcol_6` + FROM `bfcte_0` +) +SELECT + `bfcol_3` AS `sum_float`, + `bfcol_4` AS `std_float`, + `bfcol_5` AS `count_str`, + `bfcol_6` AS `any_bool` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_to_array_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_to_array_op/out.sql new file mode 100644 index 0000000000..3e29701658 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_to_array_op/out.sql @@ -0,0 +1,26 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `float64_col`, + `int64_col`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + [COALESCE(`bool_col`, FALSE)] AS `bfcol_8`, + [COALESCE(`int64_col`, 0)] AS `bfcol_9`, + [COALESCE(`string_col`, ''), COALESCE(`string_col`, '')] AS `bfcol_10`, + [ + COALESCE(`int64_col`, 0), + CAST(COALESCE(`bool_col`, FALSE) AS INT64), + COALESCE(`float64_col`, 0.0) + ] AS `bfcol_11` + FROM `bfcte_0` +) +SELECT + `bfcol_8` AS `bool_col`, + `bfcol_9` AS `int64_col`, + `bfcol_10` AS `strs_col`, + `bfcol_11` AS `numeric_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_array_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_array_ops.py index 61b8b99479..67c8bb0e5c 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_array_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_array_ops.py @@ -15,7 +15,9 @@ import pytest from bigframes import operations as ops +from bigframes.core import expression from bigframes.operations._op_converters import convert_index, convert_slice +import bigframes.operations.aggregations as agg_ops import bigframes.pandas as bpd from bigframes.testing import utils @@ -42,6 +44,20 @@ def test_array_index(repeated_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_array_reduce_op(repeated_types_df: bpd.DataFrame, snapshot): + ops_map = { + "sum_float": ops.ArrayReduceOp(agg_ops.SumOp()).as_expr("float_list_col"), + "std_float": ops.ArrayReduceOp(agg_ops.StdOp()).as_expr("float_list_col"), + "count_str": ops.ArrayReduceOp(agg_ops.CountOp()).as_expr("string_list_col"), + "any_bool": ops.ArrayReduceOp(agg_ops.AnyOp()).as_expr("bool_list_col"), + } + + sql = utils._apply_ops_to_sql( + repeated_types_df, list(ops_map.values()), list(ops_map.keys()) + ) + snapshot.assert_match(sql, "out.sql") + + def test_array_slice_with_only_start(repeated_types_df: bpd.DataFrame, snapshot): col_name = "string_list_col" bf_df = repeated_types_df[[col_name]] @@ -60,3 +76,24 @@ def test_array_slice_with_start_and_stop(repeated_types_df: bpd.DataFrame, snaps ) snapshot.assert_match(sql, "out.sql") + + +def test_to_array_op(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col", "float64_col", "string_col"]] + # Bigquery won't allow you to materialize arrays with null, so use non-nullable + int64_non_null = ops.coalesce_op.as_expr("int64_col", expression.const(0)) + bool_col_non_null = ops.coalesce_op.as_expr("bool_col", expression.const(False)) + float_col_non_null = ops.coalesce_op.as_expr("float64_col", expression.const(0.0)) + string_col_non_null = ops.coalesce_op.as_expr("string_col", expression.const("")) + + ops_map = { + "bool_col": ops.ToArrayOp().as_expr(bool_col_non_null), + "int64_col": ops.ToArrayOp().as_expr(int64_non_null), + "strs_col": ops.ToArrayOp().as_expr(string_col_non_null, string_col_non_null), + "numeric_col": ops.ToArrayOp().as_expr( + int64_non_null, bool_col_non_null, float_col_non_null + ), + } + + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") From ca86686df99d99be559e472b474d2553367df20a Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Mon, 17 Nov 2025 16:29:10 -0800 Subject: [PATCH 239/313] chore: Bound sqlglot version below 28 (#2275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes # 🦕 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index fa663f66d5..28ae99a50d 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ "requests >=2.27.1", "shapely >=1.8.5", # 25.20.0 introduces this fix https://github.com/TobikoData/sqlmesh/issues/3095 for rtrim/ltrim. - "sqlglot >=25.20.0", + "sqlglot >=25.20.0, <28.0.0", "tabulate >=0.9", "ipywidgets >=7.7.1", "humanize >=4.6.0", From 7c062a68c6a3c9737865985b4f1fd80117490c73 Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Tue, 18 Nov 2025 11:01:00 -0800 Subject: [PATCH 240/313] feat: use end user credentials for `bigframes.bigquery.ai` functions when `connection_id` is not present (#2272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #460856043 🦕 --- bigframes/bigquery/_operations/ai.py | 16 ++-- .../compile/sqlglot/expressions/ai_ops.py | 11 ++- bigframes/operations/ai_ops.py | 8 +- .../test_ai_ops/test_ai_generate/out.sql | 1 - .../test_ai_ops/test_ai_generate_bool/out.sql | 1 - .../out.sql | 18 ++++ .../out.sql | 1 - .../test_ai_generate_double/out.sql | 1 - .../out.sql | 18 ++++ .../out.sql | 1 - .../test_ai_ops/test_ai_generate_int/out.sql | 1 - .../out.sql | 18 ++++ .../out.sql | 1 - .../out.sql | 18 ++++ .../test_ai_generate_with_model_param/out.sql | 1 - .../out.sql | 1 - .../sqlglot/expressions/test_ai_ops.py | 91 ++++++++++++++++++- .../ibis/expr/operations/ai_ops.py | 8 +- 18 files changed, 181 insertions(+), 34 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_connection_id/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_connection_id/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_connection_id/out.sql create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_connection_id/out.sql diff --git a/bigframes/bigquery/_operations/ai.py b/bigframes/bigquery/_operations/ai.py index 8579f7f298..e8c28e61f5 100644 --- a/bigframes/bigquery/_operations/ai.py +++ b/bigframes/bigquery/_operations/ai.py @@ -88,7 +88,7 @@ def generate( or pandas Series. connection_id (str, optional): Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. - If not provided, the connection from the current session will be used. + If not provided, the query uses your end-user credential. endpoint (str, optional): Specifies the Vertex AI endpoint to use for the model. For example `"gemini-2.5-flash"`. You can specify any generally available or preview Gemini model. If you specify the model name, BigQuery ML automatically identifies and @@ -131,7 +131,7 @@ def generate( operator = ai_ops.AIGenerate( prompt_context=tuple(prompt_context), - connection_id=_resolve_connection_id(series_list[0], connection_id), + connection_id=connection_id, endpoint=endpoint, request_type=request_type, model_params=json.dumps(model_params) if model_params else None, @@ -186,7 +186,7 @@ def generate_bool( or pandas Series. connection_id (str, optional): Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. - If not provided, the connection from the current session will be used. + If not provided, the query uses your end-user credential. endpoint (str, optional): Specifies the Vertex AI endpoint to use for the model. For example `"gemini-2.5-flash"`. You can specify any generally available or preview Gemini model. If you specify the model name, BigQuery ML automatically identifies and @@ -216,7 +216,7 @@ def generate_bool( operator = ai_ops.AIGenerateBool( prompt_context=tuple(prompt_context), - connection_id=_resolve_connection_id(series_list[0], connection_id), + connection_id=connection_id, endpoint=endpoint, request_type=request_type, model_params=json.dumps(model_params) if model_params else None, @@ -267,7 +267,7 @@ def generate_int( or pandas Series. connection_id (str, optional): Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. - If not provided, the connection from the current session will be used. + If not provided, the query uses your end-user credential. endpoint (str, optional): Specifies the Vertex AI endpoint to use for the model. For example `"gemini-2.5-flash"`. You can specify any generally available or preview Gemini model. If you specify the model name, BigQuery ML automatically identifies and @@ -297,7 +297,7 @@ def generate_int( operator = ai_ops.AIGenerateInt( prompt_context=tuple(prompt_context), - connection_id=_resolve_connection_id(series_list[0], connection_id), + connection_id=connection_id, endpoint=endpoint, request_type=request_type, model_params=json.dumps(model_params) if model_params else None, @@ -348,7 +348,7 @@ def generate_double( or pandas Series. connection_id (str, optional): Specifies the connection to use to communicate with the model. For example, `myproject.us.myconnection`. - If not provided, the connection from the current session will be used. + If not provided, the query uses your end-user credential. endpoint (str, optional): Specifies the Vertex AI endpoint to use for the model. For example `"gemini-2.5-flash"`. You can specify any generally available or preview Gemini model. If you specify the model name, BigQuery ML automatically identifies and @@ -378,7 +378,7 @@ def generate_double( operator = ai_ops.AIGenerateDouble( prompt_context=tuple(prompt_context), - connection_id=_resolve_connection_id(series_list[0], connection_id), + connection_id=connection_id, endpoint=endpoint, request_type=request_type, model_params=json.dumps(model_params) if model_params else None, diff --git a/bigframes/core/compile/sqlglot/expressions/ai_ops.py b/bigframes/core/compile/sqlglot/expressions/ai_ops.py index e40173d2fd..680e35c511 100644 --- a/bigframes/core/compile/sqlglot/expressions/ai_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/ai_ops.py @@ -104,10 +104,13 @@ def _construct_named_args(op: ops.NaryOp) -> list[sge.Kwarg]: op_args = asdict(op) - connection_id = op_args["connection_id"] - args.append( - sge.Kwarg(this="connection_id", expression=sge.Literal.string(connection_id)) - ) + connection_id = op_args.get("connection_id", None) + if connection_id is not None: + args.append( + sge.Kwarg( + this="connection_id", expression=sge.Literal.string(connection_id) + ) + ) endpoit = op_args.get("endpoint", None) if endpoit is not None: diff --git a/bigframes/operations/ai_ops.py b/bigframes/operations/ai_ops.py index ea65b705e5..8dc8c2ffab 100644 --- a/bigframes/operations/ai_ops.py +++ b/bigframes/operations/ai_ops.py @@ -29,7 +29,7 @@ class AIGenerate(base_ops.NaryOp): name: ClassVar[str] = "ai_generate" prompt_context: Tuple[str | None, ...] - connection_id: str + connection_id: str | None endpoint: str | None request_type: Literal["dedicated", "shared", "unspecified"] model_params: str | None @@ -57,7 +57,7 @@ class AIGenerateBool(base_ops.NaryOp): name: ClassVar[str] = "ai_generate_bool" prompt_context: Tuple[str | None, ...] - connection_id: str + connection_id: str | None endpoint: str | None request_type: Literal["dedicated", "shared", "unspecified"] model_params: str | None @@ -79,7 +79,7 @@ class AIGenerateInt(base_ops.NaryOp): name: ClassVar[str] = "ai_generate_int" prompt_context: Tuple[str | None, ...] - connection_id: str + connection_id: str | None endpoint: str | None request_type: Literal["dedicated", "shared", "unspecified"] model_params: str | None @@ -101,7 +101,7 @@ class AIGenerateDouble(base_ops.NaryOp): name: ClassVar[str] = "ai_generate_double" prompt_context: Tuple[str | None, ...] - connection_id: str + connection_id: str | None endpoint: str | None request_type: Literal["dedicated", "shared", "unspecified"] model_params: str | None diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate/out.sql index 19f85b181b..ec3515e7ed 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate/out.sql @@ -7,7 +7,6 @@ WITH `bfcte_0` AS ( *, AI.GENERATE( prompt => (`string_col`, ' is the same as ', `string_col`), - connection_id => 'bigframes-dev.us.bigframes-default-connection', endpoint => 'gemini-2.5-flash', request_type => 'SHARED' ) AS `bfcol_1` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql index f844ed1691..3a09da7c3a 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql @@ -7,7 +7,6 @@ WITH `bfcte_0` AS ( *, AI.GENERATE_BOOL( prompt => (`string_col`, ' is the same as ', `string_col`), - connection_id => 'bigframes-dev.us.bigframes-default-connection', endpoint => 'gemini-2.5-flash', request_type => 'SHARED' ) AS `bfcol_1` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_connection_id/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_connection_id/out.sql new file mode 100644 index 0000000000..f844ed1691 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_connection_id/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_BOOL( + prompt => (`string_col`, ' is the same as ', `string_col`), + connection_id => 'bigframes-dev.us.bigframes-default-connection', + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql index 35538c2ec2..2a81ced782 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql @@ -7,7 +7,6 @@ WITH `bfcte_0` AS ( *, AI.GENERATE_BOOL( prompt => (`string_col`, ' is the same as ', `string_col`), - connection_id => 'bigframes-dev.us.bigframes-default-connection', request_type => 'SHARED', model_params => JSON '{}' ) AS `bfcol_1` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql index fae92515cb..3b89429621 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql @@ -7,7 +7,6 @@ WITH `bfcte_0` AS ( *, AI.GENERATE_DOUBLE( prompt => (`string_col`, ' is the same as ', `string_col`), - connection_id => 'bigframes-dev.us.bigframes-default-connection', endpoint => 'gemini-2.5-flash', request_type => 'SHARED' ) AS `bfcol_1` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_connection_id/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_connection_id/out.sql new file mode 100644 index 0000000000..fae92515cb --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_connection_id/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_DOUBLE( + prompt => (`string_col`, ' is the same as ', `string_col`), + connection_id => 'bigframes-dev.us.bigframes-default-connection', + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql index f3ddf71014..480ee09ef6 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql @@ -7,7 +7,6 @@ WITH `bfcte_0` AS ( *, AI.GENERATE_DOUBLE( prompt => (`string_col`, ' is the same as ', `string_col`), - connection_id => 'bigframes-dev.us.bigframes-default-connection', request_type => 'SHARED', model_params => JSON '{}' ) AS `bfcol_1` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql index a0c92c959c..f33af547c7 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql @@ -7,7 +7,6 @@ WITH `bfcte_0` AS ( *, AI.GENERATE_INT( prompt => (`string_col`, ' is the same as ', `string_col`), - connection_id => 'bigframes-dev.us.bigframes-default-connection', endpoint => 'gemini-2.5-flash', request_type => 'SHARED' ) AS `bfcol_1` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_connection_id/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_connection_id/out.sql new file mode 100644 index 0000000000..a0c92c959c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_connection_id/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE_INT( + prompt => (`string_col`, ' is the same as ', `string_col`), + connection_id => 'bigframes-dev.us.bigframes-default-connection', + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql index 1951e13325..2929e57ba0 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql @@ -7,7 +7,6 @@ WITH `bfcte_0` AS ( *, AI.GENERATE_INT( prompt => (`string_col`, ' is the same as ', `string_col`), - connection_id => 'bigframes-dev.us.bigframes-default-connection', request_type => 'SHARED', model_params => JSON '{}' ) AS `bfcol_1` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_connection_id/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_connection_id/out.sql new file mode 100644 index 0000000000..19f85b181b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_connection_id/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + AI.GENERATE( + prompt => (`string_col`, ' is the same as ', `string_col`), + connection_id => 'bigframes-dev.us.bigframes-default-connection', + endpoint => 'gemini-2.5-flash', + request_type => 'SHARED' + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `result` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_model_param/out.sql index 3419a77c61..745243db3a 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_model_param/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_model_param/out.sql @@ -7,7 +7,6 @@ WITH `bfcte_0` AS ( *, AI.GENERATE( prompt => (`string_col`, ' is the same as ', `string_col`), - connection_id => 'bigframes-dev.us.bigframes-default-connection', request_type => 'SHARED', model_params => JSON '{}' ) AS `bfcol_1` diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_output_schema/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_output_schema/out.sql index e1e1670f12..4f7867a0f2 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_output_schema/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_output_schema/out.sql @@ -7,7 +7,6 @@ WITH `bfcte_0` AS ( *, AI.GENERATE( prompt => (`string_col`, ' is the same as ', `string_col`), - connection_id => 'bigframes-dev.us.bigframes-default-connection', endpoint => 'gemini-2.5-flash', request_type => 'SHARED', output_schema => 'x INT64, y FLOAT64' diff --git a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py index 45024fc691..1397c7d6c0 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py @@ -30,6 +30,25 @@ def test_ai_generate(scalar_types_df: dataframe.DataFrame, snapshot): col_name = "string_col" + op = ops.AIGenerate( + prompt_context=(None, " is the same as ", None), + connection_id=None, + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + output_schema=None, + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_with_connection_id(scalar_types_df: dataframe.DataFrame, snapshot): + col_name = "string_col" + op = ops.AIGenerate( prompt_context=(None, " is the same as ", None), connection_id=CONNECTION_ID, @@ -51,7 +70,7 @@ def test_ai_generate_with_output_schema(scalar_types_df: dataframe.DataFrame, sn op = ops.AIGenerate( prompt_context=(None, " is the same as ", None), - connection_id=CONNECTION_ID, + connection_id=None, endpoint="gemini-2.5-flash", request_type="shared", model_params=None, @@ -75,7 +94,7 @@ def test_ai_generate_with_model_param(scalar_types_df: dataframe.DataFrame, snap op = ops.AIGenerate( prompt_context=(None, " is the same as ", None), - connection_id=CONNECTION_ID, + connection_id=None, endpoint=None, request_type="shared", model_params=json.dumps(dict()), @@ -92,6 +111,26 @@ def test_ai_generate_with_model_param(scalar_types_df: dataframe.DataFrame, snap def test_ai_generate_bool(scalar_types_df: dataframe.DataFrame, snapshot): col_name = "string_col" + op = ops.AIGenerateBool( + prompt_context=(None, " is the same as ", None), + connection_id=None, + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_bool_with_connection_id( + scalar_types_df: dataframe.DataFrame, snapshot +): + col_name = "string_col" + op = ops.AIGenerateBool( prompt_context=(None, " is the same as ", None), connection_id=CONNECTION_ID, @@ -119,7 +158,7 @@ def test_ai_generate_bool_with_model_param( op = ops.AIGenerateBool( prompt_context=(None, " is the same as ", None), - connection_id=CONNECTION_ID, + connection_id=None, endpoint=None, request_type="shared", model_params=json.dumps(dict()), @@ -135,6 +174,27 @@ def test_ai_generate_bool_with_model_param( def test_ai_generate_int(scalar_types_df: dataframe.DataFrame, snapshot): col_name = "string_col" + op = ops.AIGenerateInt( + # The prompt does not make semantic sense but we only care about syntax correctness. + prompt_context=(None, " is the same as ", None), + connection_id=None, + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_int_with_connection_id( + scalar_types_df: dataframe.DataFrame, snapshot +): + col_name = "string_col" + op = ops.AIGenerateInt( # The prompt does not make semantic sense but we only care about syntax correctness. prompt_context=(None, " is the same as ", None), @@ -164,7 +224,7 @@ def test_ai_generate_int_with_model_param( op = ops.AIGenerateInt( # The prompt does not make semantic sense but we only care about syntax correctness. prompt_context=(None, " is the same as ", None), - connection_id=CONNECTION_ID, + connection_id=None, endpoint=None, request_type="shared", model_params=json.dumps(dict()), @@ -180,6 +240,27 @@ def test_ai_generate_int_with_model_param( def test_ai_generate_double(scalar_types_df: dataframe.DataFrame, snapshot): col_name = "string_col" + op = ops.AIGenerateDouble( + # The prompt does not make semantic sense but we only care about syntax correctness. + prompt_context=(None, " is the same as ", None), + connection_id=None, + endpoint="gemini-2.5-flash", + request_type="shared", + model_params=None, + ) + + sql = utils._apply_ops_to_sql( + scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_ai_generate_double_with_connection_id( + scalar_types_df: dataframe.DataFrame, snapshot +): + col_name = "string_col" + op = ops.AIGenerateDouble( # The prompt does not make semantic sense but we only care about syntax correctness. prompt_context=(None, " is the same as ", None), @@ -209,7 +290,7 @@ def test_ai_generate_double_with_model_param( op = ops.AIGenerateDouble( # The prompt does not make semantic sense but we only care about syntax correctness. prompt_context=(None, " is the same as ", None), - connection_id=CONNECTION_ID, + connection_id=None, endpoint=None, request_type="shared", model_params=json.dumps(dict()), diff --git a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py index da7f132de3..ef387d3379 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py @@ -21,7 +21,7 @@ class AIGenerate(Value): """Generate content based on the prompt""" prompt: Value - connection_id: Value[dt.String] + connection_id: Optional[Value[dt.String]] endpoint: Optional[Value[dt.String]] request_type: Value[dt.String] model_params: Optional[Value[dt.String]] @@ -52,7 +52,7 @@ class AIGenerateBool(Value): """Generate Bool based on the prompt""" prompt: Value - connection_id: Value[dt.String] + connection_id: Optional[Value[dt.String]] endpoint: Optional[Value[dt.String]] request_type: Value[dt.String] model_params: Optional[Value[dt.String]] @@ -71,7 +71,7 @@ class AIGenerateInt(Value): """Generate integers based on the prompt""" prompt: Value - connection_id: Value[dt.String] + connection_id: Optional[Value[dt.String]] endpoint: Optional[Value[dt.String]] request_type: Value[dt.String] model_params: Optional[Value[dt.String]] @@ -90,7 +90,7 @@ class AIGenerateDouble(Value): """Generate doubles based on the prompt""" prompt: Value - connection_id: Value[dt.String] + connection_id: Optional[Value[dt.String]] endpoint: Optional[Value[dt.String]] request_type: Value[dt.String] model_params: Optional[Value[dt.String]] From 508deae5869e06cdad7bb94537c9c58d8f083d86 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Tue, 18 Nov 2025 11:23:06 -0800 Subject: [PATCH 241/313] fix: Improve Anywidget pagination and display for unknown row counts (#2258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, when the total number of rows (row_count) was unknown (e.g., due to deferred computation or errors), it would incorrectly default to 0. This resulted in confusing UI, such as displaying "Page 1 of 0", and allowed users to navigate to empty pages without automatically returning to valid data. current display strategy for the interactive table widget: * When `row_count` is a positive number (e.g., 50): * Total Rows Display: Shows the exact count, like 50 total rows. * Pagination Display: Shows the page relative to the total rows, like Page 1 of 50. * Navigation: The "Next" button is disabled only on the final page. * When `row_count` is `None` (unknown): * Total Rows Display: Shows Total rows unknown. * Pagination Display: Shows the page relative to an unknown total, like Page 1 of many. * Navigation: The "Next" button is always enabled, allowing you to page forward until the backend determines there is no more data. Fixes #<428238610> 🦕 --- bigframes/display/anywidget.py | 51 ++++++- bigframes/display/table_widget.js | 23 ++- notebooks/dataframes/anywidget_mode.ipynb | 135 ++++++++++++----- tests/system/small/test_anywidget.py | 173 +++++++++++++++++++--- 4 files changed, 310 insertions(+), 72 deletions(-) diff --git a/bigframes/display/anywidget.py b/bigframes/display/anywidget.py index a0b4f809d8..d61c4691c8 100644 --- a/bigframes/display/anywidget.py +++ b/bigframes/display/anywidget.py @@ -55,7 +55,11 @@ class TableWidget(WIDGET_BASE): page = traitlets.Int(0).tag(sync=True) page_size = traitlets.Int(0).tag(sync=True) - row_count = traitlets.Int(0).tag(sync=True) + row_count = traitlets.Union( + [traitlets.Int(), traitlets.Instance(type(None))], + default_value=None, + allow_none=True, + ).tag(sync=True) table_html = traitlets.Unicode().tag(sync=True) _initial_load_complete = traitlets.Bool(False).tag(sync=True) _batches: Optional[blocks.PandasBatches] = None @@ -94,12 +98,17 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): # SELECT COUNT(*) query. It is a must have however. # TODO(b/428238610): Start iterating over the result of `to_pandas_batches()` # before we get here so that the count might already be cached. - # TODO(b/452747934): Allow row_count to be None and check to see if - # there are multiple pages and show "page 1 of many" in this case self._reset_batches_for_new_page_size() - if self._batches is None or self._batches.total_rows is None: - self._error_message = "Could not determine total row count. Data might be unavailable or an error occurred." - self.row_count = 0 + + if self._batches is None: + self._error_message = "Could not retrieve data batches. Data might be unavailable or an error occurred." + self.row_count = None + elif self._batches.total_rows is None: + # Total rows is unknown, this is an expected state. + # TODO(b/461536343): Cheaply discover if we have exactly 1 page. + # There are cases where total rows is not set, but there are no additional + # pages. We could disable the "next" button in these cases. + self.row_count = None else: self.row_count = self._batches.total_rows @@ -131,11 +140,22 @@ def _validate_page(self, proposal: Dict[str, Any]) -> int: Returns: The validated and clamped page number as an integer. """ - value = proposal["value"] + + if value < 0: + raise ValueError("Page number cannot be negative.") + + # If truly empty or invalid page size, stay on page 0. + # This handles cases where row_count is 0 or page_size is 0, preventing + # division by zero or nonsensical pagination, regardless of row_count being None. if self.row_count == 0 or self.page_size == 0: return 0 + # If row count is unknown, allow any non-negative page. The previous check + # ensures that invalid page_size (0) is already handled. + if self.row_count is None: + return value + # Calculate the zero-indexed maximum page number. max_page = max(0, math.ceil(self.row_count / self.page_size) - 1) @@ -229,6 +249,23 @@ def _set_table_html(self) -> None: # Get the data for the current page page_data = cached_data.iloc[start:end] + # Handle case where user navigated beyond available data with unknown row count + is_unknown_count = self.row_count is None + is_beyond_data = self._all_data_loaded and len(page_data) == 0 and self.page > 0 + if is_unknown_count and is_beyond_data: + # Calculate the last valid page (zero-indexed) + total_rows = len(cached_data) + if total_rows > 0: + last_valid_page = max(0, math.ceil(total_rows / self.page_size) - 1) + # Navigate back to the last valid page + self.page = last_valid_page + # Recursively call to display the correct page + return self._set_table_html() + else: + # If no data at all, stay on page 0 with empty display + self.page = 0 + return self._set_table_html() + # Generate HTML table self.table_html = bigframes.display.html.render_html( dataframe=page_data, diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js index 801e262cc1..fab8b54efb 100644 --- a/bigframes/display/table_widget.js +++ b/bigframes/display/table_widget.js @@ -85,14 +85,21 @@ function render({ model, el }) { const rowCount = model.get(ModelProperty.ROW_COUNT); const pageSize = model.get(ModelProperty.PAGE_SIZE); const currentPage = model.get(ModelProperty.PAGE); - const totalPages = Math.ceil(rowCount / pageSize); - - rowCountLabel.textContent = `${rowCount.toLocaleString()} total rows`; - paginationLabel.textContent = `Page ${( - currentPage + 1 - ).toLocaleString()} of ${(totalPages || 1).toLocaleString()}`; - prevPage.disabled = currentPage === 0; - nextPage.disabled = currentPage >= totalPages - 1; + + if (rowCount === null) { + // Unknown total rows + rowCountLabel.textContent = "Total rows unknown"; + paginationLabel.textContent = `Page ${(currentPage + 1).toLocaleString()} of many`; + prevPage.disabled = currentPage === 0; + nextPage.disabled = false; // Allow navigation until we hit the end + } else { + // Known total rows + const totalPages = Math.ceil(rowCount / pageSize); + rowCountLabel.textContent = `${rowCount.toLocaleString()} total rows`; + paginationLabel.textContent = `Page ${(currentPage + 1).toLocaleString()} of ${rowCount.toLocaleString()}`; + prevPage.disabled = currentPage === 0; + nextPage.disabled = currentPage >= totalPages - 1; + } pageSizeSelect.value = pageSize; } diff --git a/notebooks/dataframes/anywidget_mode.ipynb b/notebooks/dataframes/anywidget_mode.ipynb index 3b99bbeae7..f7a4b0e2d6 100644 --- a/notebooks/dataframes/anywidget_mode.ipynb +++ b/notebooks/dataframes/anywidget_mode.ipynb @@ -35,16 +35,7 @@ "execution_count": 2, "id": "ca22f059", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/venv/lib/python3.10/site-packages/google/api_core/_python_version_support.py:266: FutureWarning: You are using a Python version (3.10.15) which Google will stop supporting in new releases of google.api_core once it reaches its end of life (2026-10-04). Please upgrade to the latest Python version, or at least Python 3.11, to continue receiving updates for google.api_core past that date.\n", - " warnings.warn(message, FutureWarning)\n" - ] - } - ], + "outputs": [], "source": [ "import bigframes.pandas as bpd" ] @@ -151,7 +142,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "47795eaa10f149aeb99574232c0936eb", + "model_id": "8fcad7b7e408422cae71d519cd2d4980", "version_major": 2, "version_minor": 1 }, @@ -175,7 +166,7 @@ } ], "source": [ - "df" + "df.set_index(\"name\")" ] }, { @@ -214,7 +205,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "8354ce0f82d3495a9b630dfc362f73ee", + "model_id": "06cb98c577514d5c9654a7792d93f8e6", "version_major": 2, "version_minor": 1 }, @@ -293,27 +284,8 @@ { "data": { "text/html": [ - "\n", - " Query started with request ID bigframes-dev:US.c45952fb-01b4-409c-9da4-f7c5bfc0d47d.
SQL
SELECT\n",
-       "`state` AS `state`,\n",
-       "`gender` AS `gender`,\n",
-       "`year` AS `year`,\n",
-       "`name` AS `name`,\n",
-       "`number` AS `number`\n",
-       "FROM\n",
-       "(SELECT\n",
-       "  *\n",
-       "FROM (\n",
-       "  SELECT\n",
-       "    `state`,\n",
-       "    `gender`,\n",
-       "    `year`,\n",
-       "    `name`,\n",
-       "    `number`\n",
-       "  FROM `bigquery-public-data.usa_names.usa_1910_2013` FOR SYSTEM_TIME AS OF TIMESTAMP('2025-10-30T21:48:48.979701+00:00')\n",
-       ") AS `t0`)\n",
-       "ORDER BY `name` ASC NULLS LAST ,`year` ASC NULLS LAST ,`state` ASC NULLS LAST\n",
-       "LIMIT 5
\n", + "✅ Completed. \n", + " Query processed 171.4 MB in a moment of slot time.\n", " " ], "text/plain": [ @@ -333,7 +305,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "59461286a17d4a42b6be6d9d9c7bf7e3", + "model_id": "1672f826f7a347e38539dbb5fb72cd43", "version_major": 2, "version_minor": 1 }, @@ -373,7 +345,7 @@ "data": { "text/html": [ "✅ Completed. \n", - " Query processed 85.9 kB in 14 seconds of slot time.\n", + " Query processed 85.9 kB in 12 seconds of slot time.\n", " " ], "text/plain": [ @@ -387,7 +359,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/bigframes/dtypes.py:969: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/bigframes/dtypes.py:987: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", "instead of using `db_dtypes` in the future when available in pandas\n", "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" @@ -408,7 +380,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "d1794b42579542a8980bd158e521bd3e", + "model_id": "127a2e356b834c18b6f07c58ee2c4228", "version_major": 2, "version_minor": 1 }, @@ -443,6 +415,93 @@ " LIMIT 5;\n", "\"\"\")" ] + }, + { + "cell_type": "markdown", + "id": "multi-index-display-markdown", + "metadata": {}, + "source": [ + "## Display Multi-Index DataFrame in anywidget mode\n", + "This section demonstrates how BigFrames can display a DataFrame with multiple levels of indexing (a \"multi-index\") when using the `anywidget` display mode." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "ad7482aa", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 483.3 GB in 51 minutes of slot time. [Job bigframes-dev:US.3eace7c0-7776-48d6-925c-965be33d8738 details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 124.4 MB in 7 seconds of slot time. [Job bigframes-dev:US.job_UJ5cx4R1jW5cNxq_1H1x-9-ATfqS details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "3f9652b5fdc0441eac2b05ab36d571d0", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "TableWidget(page_size=10, row_count=3967869, table_html='
seven_days_ago]\n", + " \n", + "# Create a multi-index by grouping by date and project\n", + "pypi_df_recent['date'] = pypi_df_recent['timestamp'].dt.date\n", + "multi_index_df = pypi_df_recent.groupby([\"date\", \"project\"]).size().to_frame(\"downloads\")\n", + " \n", + "# Display the DataFrame with the multi-index\n", + "multi_index_df" + ] } ], "metadata": { diff --git a/tests/system/small/test_anywidget.py b/tests/system/small/test_anywidget.py index 51c39c2aec..99734dc30c 100644 --- a/tests/system/small/test_anywidget.py +++ b/tests/system/small/test_anywidget.py @@ -229,20 +229,15 @@ def test_widget_navigation_should_display_correct_page( _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) -def test_widget_navigation_should_clamp_to_zero_for_negative_input( +def test_widget_navigation_should_raise_error_for_negative_input( table_widget, paginated_pandas_df: pd.DataFrame ): """ Given a widget, when a negative page number is set, - then the page number should be clamped to 0 and display the first page. + then a ValueError should be raised. """ - expected_slice = paginated_pandas_df.iloc[0:2] - - table_widget.page = -1 - html = table_widget.table_html - - assert table_widget.page == 0 - _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) + with pytest.raises(ValueError, match="Page number cannot be negative."): + table_widget.page = -1 def test_widget_navigation_should_clamp_to_last_page_for_out_of_bounds_input( @@ -500,20 +495,18 @@ def __next__(self): raise ValueError("Simulated read error") -def test_widget_should_fallback_to_zero_rows_with_invalid_total_rows( +def test_widget_should_show_error_on_batch_failure( paginated_bf_df: bf.dataframe.DataFrame, monkeypatch: pytest.MonkeyPatch, ): """ - Given an internal component fails to return valid execution data, - when the TableWidget is created, its error_message should be set and displayed. + Given that the internal call to `_to_pandas_batches` fails and returns None, + when the TableWidget is created, its `error_message` should be set and displayed. """ - # Patch the executor's 'execute' method to simulate an error. + # Patch the DataFrame's batch creation method to simulate a failure. monkeypatch.setattr( - "bigframes.session.bq_caching_executor.BigQueryCachingExecutor.execute", - lambda self, *args, **kwargs: mock_execute_result_with_params( - self, paginated_bf_df._block.expr.schema, None, [], *args, **kwargs - ), + "bigframes.dataframe.DataFrame._to_pandas_batches", + lambda self, *args, **kwargs: None, ) # Create the TableWidget under the error condition. @@ -524,9 +517,9 @@ def test_widget_should_fallback_to_zero_rows_with_invalid_total_rows( widget = TableWidget(paginated_bf_df) # The widget should have an error message and display it in the HTML. - assert widget.row_count == 0 + assert widget.row_count is None assert widget._error_message is not None - assert "Could not determine total row count" in widget._error_message + assert "Could not retrieve data batches" in widget._error_message assert widget._error_message in widget.table_html @@ -549,6 +542,148 @@ def test_widget_row_count_reflects_actual_data_available( assert widget.page_size == 2 # Respects the display option +def test_widget_with_unknown_row_count_should_auto_navigate_to_last_page( + session: bf.Session, +): + """ + Given a widget with unknown row count (row_count=None), when a user + navigates beyond the available data and all data is loaded, then the + widget should automatically navigate back to the last valid page. + """ + from bigframes.display import TableWidget + + # Create a small DataFrame with known content + test_data = pd.DataFrame( + { + "id": [0, 1, 2, 3, 4], + "value": ["row_0", "row_1", "row_2", "row_3", "row_4"], + } + ) + bf_df = session.read_pandas(test_data) + + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): + widget = TableWidget(bf_df) + + # Manually set row_count to None to simulate unknown total + widget.row_count = None + + # Navigate to a page beyond available data (page 10) + # With page_size=2 and 5 rows, valid pages are 0, 1, 2 + widget.page = 10 + + # Force data loading by accessing table_html + _ = widget.table_html + + # After all data is loaded, widget should auto-navigate to last valid page + # Last valid page = ceil(5 / 2) - 1 = 2 + assert widget.page == 2 + + # Verify the displayed content is the last page + html = widget.table_html + assert "row_4" in html # Last row should be visible + assert "row_0" not in html # First row should not be visible + + +def test_widget_with_unknown_row_count_should_set_none_state_for_frontend( + session: bf.Session, +): + """ + Given a widget with unknown row count, its `row_count` traitlet should be + `None`, which signals the frontend to display 'Page X of many'. + """ + from bigframes.display import TableWidget + + test_data = pd.DataFrame( + { + "id": [0, 1, 2], + "value": ["a", "b", "c"], + } + ) + bf_df = session.read_pandas(test_data) + + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): + widget = TableWidget(bf_df) + + # Set row_count to None + widget.row_count = None + + # Verify row_count is None (not 0) + assert widget.row_count is None + + # The widget should still function normally + assert widget.page == 0 + assert widget.page_size == 2 + + # Force data loading by accessing table_html. This also ensures that + # rendering does not raise an exception. + _ = widget.table_html + + +def test_widget_with_unknown_row_count_should_allow_forward_navigation( + session: bf.Session, +): + """ + Given a widget with unknown row count, users should be able to navigate + forward until they reach the end of available data. + """ + from bigframes.display import TableWidget + + test_data = pd.DataFrame( + { + "id": [0, 1, 2, 3, 4, 5], + "value": ["p0_r0", "p0_r1", "p1_r0", "p1_r1", "p2_r0", "p2_r1"], + } + ) + bf_df = session.read_pandas(test_data) + + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): + widget = TableWidget(bf_df) + widget.row_count = None + + # Navigate to page 1 + widget.page = 1 + html = widget.table_html + assert "p1_r0" in html + assert "p1_r1" in html + + # Navigate to page 2 + widget.page = 2 + html = widget.table_html + assert "p2_r0" in html + assert "p2_r1" in html + + # Navigate beyond available data (page 5) + widget.page = 5 + _ = widget.table_html + + # Should auto-navigate back to last valid page (page 2) + assert widget.page == 2 + + +def test_widget_with_unknown_row_count_empty_dataframe( + session: bf.Session, +): + """ + Given an empty DataFrame with unknown row count, the widget should + stay on page 0 and display empty content. + """ + from bigframes.display import TableWidget + + empty_data = pd.DataFrame(columns=["id", "value"]) + bf_df = session.read_pandas(empty_data) + + with bf.option_context("display.repr_mode", "anywidget"): + widget = TableWidget(bf_df) + widget.row_count = None + + # Attempt to navigate to page 5 + widget.page = 5 + _ = widget.table_html + + # Should stay on page 0 for empty DataFrame + assert widget.page == 0 + + # TODO(shuowei): Add tests for custom index and multiindex # This may not be necessary for the SQL Cell use case but should be # considered for completeness. From bc33c9834b1f0c7e29d02084b94b81af86a835a5 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Tue, 18 Nov 2025 13:26:02 -0800 Subject: [PATCH 242/313] test: Update test_remote_function_max_instances to expect 0 default (#2278) --- tests/system/large/functions/test_remote_function.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/system/large/functions/test_remote_function.py b/tests/system/large/functions/test_remote_function.py index 00b1b5f1f0..f50e8c0a04 100644 --- a/tests/system/large/functions/test_remote_function.py +++ b/tests/system/large/functions/test_remote_function.py @@ -1651,11 +1651,13 @@ def square(x): return x * x +# Note: Zero represents default, which is 100 instances actually, which is why the remote function still works +# in the df.apply() call here @pytest.mark.parametrize( ("max_instances_args", "expected_max_instances"), [ - pytest.param({}, 100, id="no-set"), - pytest.param({"cloud_function_max_instances": None}, 100, id="set-None"), + pytest.param({}, 0, id="no-set"), + pytest.param({"cloud_function_max_instances": None}, 0, id="set-None"), pytest.param({"cloud_function_max_instances": 1000}, 1000, id="set-explicit"), ], ) From 08c0c0c8fe8f806f6224dc403a3f1d4db708573a Mon Sep 17 00:00:00 2001 From: Garrett Wu <6505921+GarrettWu@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:58:38 -0800 Subject: [PATCH 243/313] docs: update docs and tests for Gemini 2.5 models (#2279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes # 🦕 --- bigframes/ml/llm.py | 22 ++++++++++++++++++---- bigframes/ml/loader.py | 3 +++ tests/system/small/ml/test_llm.py | 17 ++++++++++++++++- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/bigframes/ml/llm.py b/bigframes/ml/llm.py index 531a043c45..b670cabaea 100644 --- a/bigframes/ml/llm.py +++ b/bigframes/ml/llm.py @@ -54,6 +54,10 @@ _GEMINI_2_FLASH_001_ENDPOINT = "gemini-2.0-flash-001" _GEMINI_2_FLASH_LITE_001_ENDPOINT = "gemini-2.0-flash-lite-001" _GEMINI_2P5_PRO_PREVIEW_ENDPOINT = "gemini-2.5-pro-preview-05-06" +_GEMINI_2P5_PRO_ENDPOINT = "gemini-2.5-pro" +_GEMINI_2P5_FLASH_ENDPOINT = "gemini-2.5-flash" +_GEMINI_2P5_FLASH_LITE_ENDPOINT = "gemini-2.5-flash-lite" + _GEMINI_ENDPOINTS = ( _GEMINI_1P5_PRO_PREVIEW_ENDPOINT, _GEMINI_1P5_PRO_FLASH_PREVIEW_ENDPOINT, @@ -64,6 +68,9 @@ _GEMINI_2_FLASH_EXP_ENDPOINT, _GEMINI_2_FLASH_001_ENDPOINT, _GEMINI_2_FLASH_LITE_001_ENDPOINT, + _GEMINI_2P5_PRO_ENDPOINT, + _GEMINI_2P5_FLASH_ENDPOINT, + _GEMINI_2P5_FLASH_LITE_ENDPOINT, ) _GEMINI_PREVIEW_ENDPOINTS = ( _GEMINI_1P5_PRO_PREVIEW_ENDPOINT, @@ -84,6 +91,9 @@ _GEMINI_2_FLASH_EXP_ENDPOINT, _GEMINI_2_FLASH_001_ENDPOINT, _GEMINI_2_FLASH_LITE_001_ENDPOINT, + _GEMINI_2P5_PRO_ENDPOINT, + _GEMINI_2P5_FLASH_ENDPOINT, + _GEMINI_2P5_FLASH_LITE_ENDPOINT, ) _CLAUDE_3_SONNET_ENDPOINT = "claude-3-sonnet" @@ -419,7 +429,7 @@ class GeminiTextGenerator(base.RetriableRemotePredictor): """Gemini text generator LLM model. .. note:: - gemini-1.5-X are going to be deprecated. Use gemini-2.0-X (https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator) instead. + gemini-1.5-X are going to be deprecated. Use gemini-2.5-X (https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator) instead. Args: model_name (str, Default to "gemini-2.0-flash-001"): @@ -427,12 +437,13 @@ class GeminiTextGenerator(base.RetriableRemotePredictor): "gemini-1.5-pro-preview-0514", "gemini-1.5-flash-preview-0514", "gemini-1.5-pro-001", "gemini-1.5-pro-002", "gemini-1.5-flash-001", "gemini-1.5-flash-002", "gemini-2.0-flash-exp", - "gemini-2.0-flash-lite-001", and "gemini-2.0-flash-001". + "gemini-2.0-flash-lite-001", "gemini-2.0-flash-001", + "gemini-2.5-pro", "gemini-2.5-flash" and "gemini-2.5-flash-lite". If no setting is provided, "gemini-2.0-flash-001" will be used by default and a warning will be issued. .. note:: - "gemini-1.5-X" is going to be deprecated. Please use gemini-2.0-X instead. For example, "gemini-2.0-flash-001". + "gemini-1.5-X" is going to be deprecated. Please use gemini-2.5-X instead. For example, "gemini-2.5-flash". "gemini-2.0-flash-exp", "gemini-1.5-pro-preview-0514" and "gemini-1.5-flash-preview-0514" is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available "as is" and might have limited support. For more information, see the launch stage descriptions @@ -462,6 +473,9 @@ def __init__( "gemini-2.0-flash-exp", "gemini-2.0-flash-001", "gemini-2.0-flash-lite-001", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", ] ] = None, session: Optional[bigframes.Session] = None, @@ -510,7 +524,7 @@ def _create_bqml_model(self): msg = exceptions.format_message( _MODEL_DEPRECATE_WARNING.format( model_name=self.model_name, - new_model_name="gemini-2.0-X", + new_model_name="gemini-2.5-X", link="https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator", ) ) diff --git a/bigframes/ml/loader.py b/bigframes/ml/loader.py index a6366273fe..f6b5e4e2dc 100644 --- a/bigframes/ml/loader.py +++ b/bigframes/ml/loader.py @@ -67,6 +67,9 @@ llm._GEMINI_2_FLASH_001_ENDPOINT: llm.GeminiTextGenerator, llm._GEMINI_2_FLASH_LITE_001_ENDPOINT: llm.GeminiTextGenerator, llm._GEMINI_2P5_PRO_PREVIEW_ENDPOINT: llm.GeminiTextGenerator, + llm._GEMINI_2P5_FLASH_ENDPOINT: llm.GeminiTextGenerator, + llm._GEMINI_2P5_FLASH_LITE_ENDPOINT: llm.GeminiTextGenerator, + llm._GEMINI_2P5_PRO_ENDPOINT: llm.GeminiTextGenerator, llm._CLAUDE_3_HAIKU_ENDPOINT: llm.Claude3TextGenerator, llm._CLAUDE_3_SONNET_ENDPOINT: llm.Claude3TextGenerator, llm._CLAUDE_3_5_SONNET_ENDPOINT: llm.Claude3TextGenerator, diff --git a/tests/system/small/ml/test_llm.py b/tests/system/small/ml/test_llm.py index 245fead028..112acb7cac 100644 --- a/tests/system/small/ml/test_llm.py +++ b/tests/system/small/ml/test_llm.py @@ -111,6 +111,9 @@ def test_create_load_multimodal_embedding_generator_model( "gemini-2.0-flash-exp", "gemini-2.0-flash-001", "gemini-2.0-flash-lite-001", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", ), ) @pytest.mark.flaky( @@ -140,9 +143,12 @@ def test_create_load_gemini_text_generator_model( "gemini-2.0-flash-exp", "gemini-2.0-flash-001", "gemini-2.0-flash-lite-001", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", ), ) -@pytest.mark.flaky(retries=2) +# @pytest.mark.flaky(retries=2) def test_gemini_text_generator_predict_default_params_success( llm_text_df, model_name, session, bq_connection ): @@ -161,6 +167,9 @@ def test_gemini_text_generator_predict_default_params_success( "gemini-2.0-flash-exp", "gemini-2.0-flash-001", "gemini-2.0-flash-lite-001", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", ), ) @pytest.mark.flaky(retries=2) @@ -184,6 +193,9 @@ def test_gemini_text_generator_predict_with_params_success( "gemini-2.0-flash-exp", "gemini-2.0-flash-001", "gemini-2.0-flash-lite-001", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", ), ) @pytest.mark.flaky(retries=2) @@ -209,6 +221,9 @@ def test_gemini_text_generator_multi_cols_predict_success( "gemini-2.0-flash-exp", "gemini-2.0-flash-001", "gemini-2.0-flash-lite-001", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", ), ) @pytest.mark.flaky(retries=2) From 3e3fe259567d249d91f90786a577b05577e2b9fd Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Wed, 19 Nov 2025 13:19:43 -0800 Subject: [PATCH 244/313] fix: Pass credentials properly for read api instantiation (#2280) --- bigframes/session/clients.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bigframes/session/clients.py b/bigframes/session/clients.py index 31a021cdd6..2a5c9d6499 100644 --- a/bigframes/session/clients.py +++ b/bigframes/session/clients.py @@ -191,10 +191,11 @@ def _create_bigquery_client(self): client_options=bq_options, project=self._project, location=self._location, - # Instead of credentials, use _http so that users can override + # Use _http so that users can override # requests options with transport adapters. See internal issue # b/419106112. _http=requests_session, + credentials=self._credentials, ) # If a new enough client library is available, we opt-in to the faster From 6e73d77b782a7f80281e7d35e5655e1aad26caa2 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Wed, 19 Nov 2025 14:03:39 -0800 Subject: [PATCH 245/313] refactor: Remove never_skip_nulls param from window node def (#2273) --- bigframes/core/array_value.py | 28 ------------------- bigframes/core/blocks.py | 6 ---- bigframes/core/compile/compiled.py | 7 ----- .../compile/ibis_compiler/ibis_compiler.py | 1 - bigframes/core/compile/polars/compiler.py | 21 -------------- bigframes/core/compile/sqlglot/compiler.py | 6 +--- bigframes/core/groupby/dataframe_group_by.py | 19 +++++++++++-- bigframes/core/groupby/series_group_by.py | 14 ++++++++-- bigframes/core/nodes.py | 1 - bigframes/core/rewrite/timedeltas.py | 3 +- bigframes/core/window/rolling.py | 1 - bigframes/dataframe.py | 17 ++++++++++- bigframes/operations/aggregations.py | 4 +++ bigframes/series.py | 6 +++- tests/system/small/engines/test_windowing.py | 3 -- .../small/session/test_read_gbq_query.py | 10 +++---- tests/system/small/test_series.py | 3 +- .../test_all/window_out.sql | 6 +--- .../test_all/window_partition_out.sql | 6 +--- .../test_any/window_out.sql | 6 +--- .../test_any_value/window_out.sql | 2 +- .../test_any_value/window_partition_out.sql | 6 +--- .../test_unary_compiler/test_first/out.sql | 12 +++----- .../test_unary_compiler/test_last/out.sql | 12 +++----- .../test_max/window_out.sql | 2 +- .../test_max/window_partition_out.sql | 6 +--- .../test_mean/window_out.sql | 2 +- .../test_mean/window_partition_out.sql | 6 +--- .../test_min/window_out.sql | 2 +- .../test_min/window_partition_out.sql | 6 +--- .../test_pop_var/window_out.sql | 2 +- .../test_std/window_out.sql | 2 +- .../test_sum/window_out.sql | 6 +--- .../test_sum/window_partition_out.sql | 6 +--- .../test_var/window_out.sql | 2 +- tests/unit/test_series_polars.py | 3 +- 36 files changed, 88 insertions(+), 157 deletions(-) diff --git a/bigframes/core/array_value.py b/bigframes/core/array_value.py index 35d0c5d4d4..5af6fbd56e 100644 --- a/bigframes/core/array_value.py +++ b/bigframes/core/array_value.py @@ -401,37 +401,10 @@ def aggregate( ) ) - def project_window_op( - self, - column_name: str, - op: agg_ops.UnaryWindowOp, - window_spec: WindowSpec, - *, - never_skip_nulls=False, - skip_reproject_unsafe: bool = False, - ) -> Tuple[ArrayValue, str]: - """ - Creates a new expression based on this expression with unary operation applied to one column. - column_name: the id of the input column present in the expression - op: the windowable operator to apply to the input column - window_spec: a specification of the window over which to apply the operator - output_name: the id to assign to the output of the operator, by default will replace input col if distinct output id not provided - never_skip_nulls: will disable null skipping for operators that would otherwise do so - skip_reproject_unsafe: skips the reprojection step, can be used when performing many non-dependent window operations, user responsible for not nesting window expressions, or using outputs as join, filter or aggregation keys before a reprojection - """ - - return self.project_window_expr( - agg_expressions.UnaryAggregation(op, ex.deref(column_name)), - window_spec, - never_skip_nulls, - skip_reproject_unsafe, - ) - def project_window_expr( self, expression: agg_expressions.Aggregation, window: WindowSpec, - never_skip_nulls=False, skip_reproject_unsafe: bool = False, ): output_name = self._gen_namespaced_uid() @@ -442,7 +415,6 @@ def project_window_expr( expression=expression, window_spec=window, output_name=ids.ColumnId(output_name), - never_skip_nulls=never_skip_nulls, skip_reproject_unsafe=skip_reproject_unsafe, ) ), diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index bd478800eb..466dbfce72 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -1090,7 +1090,6 @@ def multi_apply_window_op( window_spec: windows.WindowSpec, *, skip_null_groups: bool = False, - never_skip_nulls: bool = False, ) -> typing.Tuple[Block, typing.Sequence[str]]: block = self result_ids = [] @@ -1103,7 +1102,6 @@ def multi_apply_window_op( skip_reproject_unsafe=(i + 1) < len(columns), result_label=label, skip_null_groups=skip_null_groups, - never_skip_nulls=never_skip_nulls, ) result_ids.append(result_id) return block, result_ids @@ -1184,7 +1182,6 @@ def apply_window_op( result_label: Label = None, skip_null_groups: bool = False, skip_reproject_unsafe: bool = False, - never_skip_nulls: bool = False, ) -> typing.Tuple[Block, str]: agg_expr = agg_expressions.UnaryAggregation(op, ex.deref(column)) return self.apply_analytic( @@ -1192,7 +1189,6 @@ def apply_window_op( window_spec, result_label, skip_reproject_unsafe=skip_reproject_unsafe, - never_skip_nulls=never_skip_nulls, skip_null_groups=skip_null_groups, ) @@ -1203,7 +1199,6 @@ def apply_analytic( result_label: Label, *, skip_reproject_unsafe: bool = False, - never_skip_nulls: bool = False, skip_null_groups: bool = False, ) -> typing.Tuple[Block, str]: block = self @@ -1214,7 +1209,6 @@ def apply_analytic( agg_expr, window, skip_reproject_unsafe=skip_reproject_unsafe, - never_skip_nulls=never_skip_nulls, ) block = Block( expr, diff --git a/bigframes/core/compile/compiled.py b/bigframes/core/compile/compiled.py index 91d72d96b2..f8be331d59 100644 --- a/bigframes/core/compile/compiled.py +++ b/bigframes/core/compile/compiled.py @@ -394,8 +394,6 @@ def project_window_op( expression: ex_types.Aggregation, window_spec: WindowSpec, output_name: str, - *, - never_skip_nulls=False, ) -> UnorderedIR: """ Creates a new expression based on this expression with unary operation applied to one column. @@ -403,7 +401,6 @@ def project_window_op( op: the windowable operator to apply to the input column window_spec: a specification of the window over which to apply the operator output_name: the id to assign to the output of the operator - never_skip_nulls: will disable null skipping for operators that would otherwise do so """ # Cannot nest analytic expressions, so reproject to cte first if needed. # Also ibis cannot window literals, so need to reproject those (even though this is legal in googlesql) @@ -425,7 +422,6 @@ def project_window_op( expression, window_spec, output_name, - never_skip_nulls=never_skip_nulls, ) if expression.op.order_independent and window_spec.is_unbounded: @@ -437,9 +433,6 @@ def project_window_op( expression, window_spec ) clauses: list[tuple[ex.Expression, ex.Expression]] = [] - if expression.op.skips_nulls and not never_skip_nulls: - for input in expression.inputs: - clauses.append((ops.isnull_op.as_expr(input), ex.const(None))) if window_spec.min_periods and len(expression.inputs) > 0: if not expression.op.nulls_count_for_min_values: is_observation = ops.notnull_op.as_expr() diff --git a/bigframes/core/compile/ibis_compiler/ibis_compiler.py b/bigframes/core/compile/ibis_compiler/ibis_compiler.py index 17bfc6ef55..b46c66f879 100644 --- a/bigframes/core/compile/ibis_compiler/ibis_compiler.py +++ b/bigframes/core/compile/ibis_compiler/ibis_compiler.py @@ -269,7 +269,6 @@ def compile_window(node: nodes.WindowOpNode, child: compiled.UnorderedIR): node.expression, node.window_spec, node.output_name.sql, - never_skip_nulls=node.never_skip_nulls, ) return result diff --git a/bigframes/core/compile/polars/compiler.py b/bigframes/core/compile/polars/compiler.py index f822a6a83f..1c9b0d802d 100644 --- a/bigframes/core/compile/polars/compiler.py +++ b/bigframes/core/compile/polars/compiler.py @@ -16,7 +16,6 @@ import dataclasses import functools import itertools -import operator from typing import cast, Literal, Optional, Sequence, Tuple, Type, TYPE_CHECKING import pandas as pd @@ -868,26 +867,6 @@ def compile_window(self, node: nodes.WindowOpNode): df, node.expression, node.window_spec, node.output_name.sql ) result = pl.concat([df, window_result], how="horizontal") - - # Probably easier just to pull this out as a rewriter - if ( - node.expression.op.skips_nulls - and not node.never_skip_nulls - and node.expression.column_references - ): - nullity_expr = functools.reduce( - operator.or_, - ( - pl.col(column.sql).is_null() - for column in node.expression.column_references - ), - ) - result = result.with_columns( - pl.when(nullity_expr) - .then(None) - .otherwise(pl.col(node.output_name.sql)) - .alias(node.output_name.sql) - ) return result def _calc_row_analytic_func( diff --git a/bigframes/core/compile/sqlglot/compiler.py b/bigframes/core/compile/sqlglot/compiler.py index 2f99b74176..276751d6e3 100644 --- a/bigframes/core/compile/sqlglot/compiler.py +++ b/bigframes/core/compile/sqlglot/compiler.py @@ -324,12 +324,8 @@ def compile_window(node: nodes.WindowOpNode, child: ir.SQLGlotIR) -> ir.SQLGlotI ) clauses: list[tuple[sge.Expression, sge.Expression]] = [] - if node.expression.op.skips_nulls and not node.never_skip_nulls: - for column in inputs: - clauses.append((sge.Is(this=column, expression=sge.Null()), sge.Null())) - if window_spec.min_periods and len(inputs) > 0: - if node.expression.op.skips_nulls: + if not node.expression.op.nulls_count_for_min_values: # Most operations do not count NULL values towards min_periods not_null_columns = [ sge.Not(this=sge.Is(this=column, expression=sge.Null())) diff --git a/bigframes/core/groupby/dataframe_group_by.py b/bigframes/core/groupby/dataframe_group_by.py index 149971249f..21f0d7f426 100644 --- a/bigframes/core/groupby/dataframe_group_by.py +++ b/bigframes/core/groupby/dataframe_group_by.py @@ -38,6 +38,7 @@ import bigframes.core.window_spec as window_specs import bigframes.dataframe as df import bigframes.dtypes as dtypes +import bigframes.operations import bigframes.operations.aggregations as agg_ops import bigframes.series as series @@ -747,14 +748,26 @@ def _apply_window_op( window_spec = window or window_specs.cumulative_rows( grouping_keys=tuple(self._by_col_ids) ) - columns, _ = self._aggregated_columns(numeric_only=numeric_only) + columns, labels = self._aggregated_columns(numeric_only=numeric_only) block, result_ids = self._block.multi_apply_window_op( columns, op, window_spec=window_spec, ) - result = df.DataFrame(block.select_columns(result_ids)) - return result + block = block.project_exprs( + tuple( + bigframes.operations.where_op.as_expr( + r_col, + bigframes.operations.notnull_op.as_expr(og_col), + ex.const(None), + ) + for og_col, r_col in zip(columns, result_ids) + ), + labels=labels, + drop=True, + ) + + return df.DataFrame(block) def _resolve_label(self, label: blocks.Label) -> str: """Resolve label to column id.""" diff --git a/bigframes/core/groupby/series_group_by.py b/bigframes/core/groupby/series_group_by.py index b09b63dcfe..27c55f7a6a 100644 --- a/bigframes/core/groupby/series_group_by.py +++ b/bigframes/core/groupby/series_group_by.py @@ -37,6 +37,7 @@ import bigframes.core.window_spec as window_specs import bigframes.dataframe as df import bigframes.dtypes +import bigframes.operations import bigframes.operations.aggregations as agg_ops import bigframes.series as series @@ -339,7 +340,6 @@ def cumcount(self, *args, **kwargs) -> series.Series: self._apply_window_op( agg_ops.SizeUnaryOp(), discard_name=True, - never_skip_nulls=True, ) - 1 ) @@ -426,7 +426,6 @@ def _apply_window_op( op: agg_ops.UnaryWindowOp, discard_name=False, window: typing.Optional[window_specs.WindowSpec] = None, - never_skip_nulls: bool = False, ) -> series.Series: """Apply window op to groupby. Defaults to grouped cumulative window.""" window_spec = window or window_specs.cumulative_rows( @@ -439,6 +438,15 @@ def _apply_window_op( op, result_label=label, window_spec=window_spec, - never_skip_nulls=never_skip_nulls, ) + if op.skips_nulls: + block, result_id = block.project_expr( + bigframes.operations.where_op.as_expr( + result_id, + bigframes.operations.notnull_op.as_expr(self._value_column), + ex.const(None), + ), + label, + ) + return series.Series(block.select_column(result_id)) diff --git a/bigframes/core/nodes.py b/bigframes/core/nodes.py index 8d1759afd7..e1631c435d 100644 --- a/bigframes/core/nodes.py +++ b/bigframes/core/nodes.py @@ -1394,7 +1394,6 @@ class WindowOpNode(UnaryNode, AdditiveNode): expression: agg_expressions.Aggregation window_spec: window.WindowSpec output_name: identifiers.ColumnId - never_skip_nulls: bool = False skip_reproject_unsafe: bool = False def _validate(self): diff --git a/bigframes/core/rewrite/timedeltas.py b/bigframes/core/rewrite/timedeltas.py index 91c6ab83c6..5c7a85ee1b 100644 --- a/bigframes/core/rewrite/timedeltas.py +++ b/bigframes/core/rewrite/timedeltas.py @@ -67,7 +67,6 @@ def rewrite_timedelta_expressions(root: nodes.BigFrameNode) -> nodes.BigFrameNod _rewrite_aggregation(root.expression, root.schema), root.window_spec, root.output_name, - root.never_skip_nulls, root.skip_reproject_unsafe, ) @@ -112,6 +111,8 @@ def _rewrite_expressions(expr: ex.Expression, schema: schema.ArraySchema) -> _Ty def _rewrite_scalar_constant_expr(expr: ex.ScalarConstantExpression) -> _TypedExpr: + if expr.value is None: + return _TypedExpr(ex.const(None, expr.dtype), expr.dtype) if expr.dtype == dtypes.TIMEDELTA_DTYPE: int_repr = utils.timedelta_to_micros(expr.value) # type: ignore return _TypedExpr(ex.const(int_repr, expr.dtype), expr.dtype) diff --git a/bigframes/core/window/rolling.py b/bigframes/core/window/rolling.py index 1f3466874f..8b21f4166c 100644 --- a/bigframes/core/window/rolling.py +++ b/bigframes/core/window/rolling.py @@ -102,7 +102,6 @@ def _aggregate_block(self, op: agg_ops.UnaryAggregateOp) -> blocks.Block: op, self._window_spec, skip_null_groups=self._drop_null_groups, - never_skip_nulls=True, ) if self._window_spec.grouping_keys: diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 4b41006547..173aa48db8 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -4159,7 +4159,22 @@ def _apply_window_op( op, window_spec=window_spec, ) - return DataFrame(block.select_columns(result_ids)) + if op.skips_nulls: + block = block.project_exprs( + tuple( + bigframes.operations.where_op.as_expr( + r_col, + bigframes.operations.notnull_op.as_expr(og_col), + ex.const(None), + ) + for og_col, r_col in zip(self._block.value_columns, result_ids) + ), + labels=self._block.column_labels, + drop=True, + ) + else: + block = block.select_columns(result_ids) + return DataFrame(block) @validations.requires_ordering() def sample( diff --git a/bigframes/operations/aggregations.py b/bigframes/operations/aggregations.py index 1160ab2c8e..289d3bf00a 100644 --- a/bigframes/operations/aggregations.py +++ b/bigframes/operations/aggregations.py @@ -183,6 +183,10 @@ def output_type(self, *input_types: dtypes.ExpressionType): class SizeUnaryOp(UnaryAggregateOp): name: ClassVar[str] = "size" + @property + def skips_nulls(self): + return False + def output_type(self, *input_types: dtypes.ExpressionType): return dtypes.INT_DTYPE diff --git a/bigframes/series.py b/bigframes/series.py index c11cc48394..f2d4d98c14 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -1780,7 +1780,11 @@ def _apply_window_op( block, result_id = block.apply_window_op( self._value_column, op, window_spec=window_spec, result_label=self.name ) - return Series(block.select_column(result_id)) + result = Series(block.select_column(result_id)) + if op.skips_nulls: + return result.where(self.notna(), None) + else: + return result def value_counts( self, diff --git a/tests/system/small/engines/test_windowing.py b/tests/system/small/engines/test_windowing.py index a34d7b8f38..510a2de3ba 100644 --- a/tests/system/small/engines/test_windowing.py +++ b/tests/system/small/engines/test_windowing.py @@ -43,12 +43,10 @@ def test_engines_with_offsets( assert_equivalence_execution(result.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("never_skip_nulls", [True, False]) @pytest.mark.parametrize("agg_op", [agg_ops.sum_op, agg_ops.count_op]) def test_engines_with_rows_window( scalars_array_value: array_value.ArrayValue, bigquery_client: bigquery.Client, - never_skip_nulls, agg_op, ): window = window_spec.WindowSpec( @@ -61,7 +59,6 @@ def test_engines_with_rows_window( ), window_spec=window, output_name=identifiers.ColumnId("agg_int64"), - never_skip_nulls=never_skip_nulls, skip_reproject_unsafe=False, ) diff --git a/tests/system/small/session/test_read_gbq_query.py b/tests/system/small/session/test_read_gbq_query.py index c1408febca..bb9026dc70 100644 --- a/tests/system/small/session/test_read_gbq_query.py +++ b/tests/system/small/session/test_read_gbq_query.py @@ -36,9 +36,9 @@ def test_read_gbq_query_w_allow_large_results(session: bigframes.Session): allow_large_results=False, ) assert df_false.shape == (1, 1) - roots_false = df_false._get_block().expr.node.roots - assert any(isinstance(node, nodes.ReadLocalNode) for node in roots_false) - assert not any(isinstance(node, nodes.ReadTableNode) for node in roots_false) + nodes_false = df_false._get_block().expr.node.unique_nodes() + assert any(isinstance(node, nodes.ReadLocalNode) for node in nodes_false) + assert not any(isinstance(node, nodes.ReadTableNode) for node in nodes_false) # Large results allowed should wrap a table. df_true = session.read_gbq( @@ -47,8 +47,8 @@ def test_read_gbq_query_w_allow_large_results(session: bigframes.Session): allow_large_results=True, ) assert df_true.shape == (1, 1) - roots_true = df_true._get_block().expr.node.roots - assert any(isinstance(node, nodes.ReadTableNode) for node in roots_true) + nodes_true = df_true._get_block().expr.node.unique_nodes() + assert any(isinstance(node, nodes.ReadTableNode) for node in nodes_true) def test_read_gbq_query_w_columns(session: bigframes.Session): diff --git a/tests/system/small/test_series.py b/tests/system/small/test_series.py index 4df257423f..6c681596f5 100644 --- a/tests/system/small/test_series.py +++ b/tests/system/small/test_series.py @@ -1031,7 +1031,8 @@ def test_series_int_int_operators_scalar( bf_result = maybe_reversed_op(scalars_df["int64_col"], other_scalar).to_pandas() pd_result = maybe_reversed_op(scalars_pandas_df["int64_col"], other_scalar) - assert_series_equal(pd_result, bf_result) + # don't check dtype, as pandas is a bit unstable here across versions, esp floordiv + assert_series_equal(pd_result, bf_result, check_dtype=False) def test_series_pow_scalar(scalars_dfs): diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_out.sql index 83bd288e73..829e5a8836 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_out.sql @@ -5,11 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE - WHEN `bool_col` IS NULL - THEN NULL - ELSE COALESCE(LOGICAL_AND(`bool_col`) OVER (), TRUE) - END AS `bfcol_1` + COALESCE(LOGICAL_AND(`bool_col`) OVER (), TRUE) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_partition_out.sql index dc2471148b..23357817c1 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_partition_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/window_partition_out.sql @@ -6,11 +6,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE - WHEN `bool_col` IS NULL - THEN NULL - ELSE COALESCE(LOGICAL_AND(`bool_col`) OVER (PARTITION BY `string_col`), TRUE) - END AS `bfcol_2` + COALESCE(LOGICAL_AND(`bool_col`) OVER (PARTITION BY `string_col`), TRUE) AS `bfcol_2` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/window_out.sql index 970349a4f5..337f0ff963 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/window_out.sql @@ -5,11 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE - WHEN `bool_col` IS NULL - THEN NULL - ELSE COALESCE(LOGICAL_OR(`bool_col`) OVER (), FALSE) - END AS `bfcol_1` + COALESCE(LOGICAL_OR(`bool_col`) OVER (), FALSE) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_out.sql index f179808b57..ea15243d90 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE WHEN `int64_col` IS NULL THEN NULL ELSE ANY_VALUE(`int64_col`) OVER () END AS `bfcol_1` + ANY_VALUE(`int64_col`) OVER () AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_partition_out.sql index e1b3da8a9a..e722318fbc 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_partition_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_partition_out.sql @@ -6,11 +6,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE - WHEN `int64_col` IS NULL - THEN NULL - ELSE ANY_VALUE(`int64_col`) OVER (PARTITION BY `string_col`) - END AS `bfcol_2` + ANY_VALUE(`int64_col`) OVER (PARTITION BY `string_col`) AS `bfcol_2` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql index 40c9e6ddd8..b053178f58 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql @@ -5,14 +5,10 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE - WHEN `int64_col` IS NULL - THEN NULL - ELSE FIRST_VALUE(`int64_col`) OVER ( - ORDER BY `int64_col` DESC - ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING - ) - END AS `bfcol_1` + FIRST_VALUE(`int64_col`) OVER ( + ORDER BY `int64_col` DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql index ebeaa0e338..61e90ee612 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql @@ -5,14 +5,10 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE - WHEN `int64_col` IS NULL - THEN NULL - ELSE LAST_VALUE(`int64_col`) OVER ( - ORDER BY `int64_col` DESC - ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING - ) - END AS `bfcol_1` + LAST_VALUE(`int64_col`) OVER ( + ORDER BY `int64_col` DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_out.sql index a234601b6a..f55201418a 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE WHEN `int64_col` IS NULL THEN NULL ELSE MAX(`int64_col`) OVER () END AS `bfcol_1` + MAX(`int64_col`) OVER () AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_partition_out.sql index f918500788..ac9b2df84e 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_partition_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_partition_out.sql @@ -6,11 +6,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE - WHEN `int64_col` IS NULL - THEN NULL - ELSE MAX(`int64_col`) OVER (PARTITION BY `string_col`) - END AS `bfcol_2` + MAX(`int64_col`) OVER (PARTITION BY `string_col`) AS `bfcol_2` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_out.sql index 73bec9ccce..fdb59809c3 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE WHEN `int64_col` IS NULL THEN NULL ELSE AVG(`int64_col`) OVER () END AS `bfcol_1` + AVG(`int64_col`) OVER () AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_partition_out.sql index d0a8345e12..d96121e54d 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_partition_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_partition_out.sql @@ -6,11 +6,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE - WHEN `int64_col` IS NULL - THEN NULL - ELSE AVG(`int64_col`) OVER (PARTITION BY `string_col`) - END AS `bfcol_2` + AVG(`int64_col`) OVER (PARTITION BY `string_col`) AS `bfcol_2` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_out.sql index 1d9db63491..cbda2b7d58 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE WHEN `int64_col` IS NULL THEN NULL ELSE MIN(`int64_col`) OVER () END AS `bfcol_1` + MIN(`int64_col`) OVER () AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_partition_out.sql index 8040f43ca5..d601832950 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_partition_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_partition_out.sql @@ -6,11 +6,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE - WHEN `int64_col` IS NULL - THEN NULL - ELSE MIN(`int64_col`) OVER (PARTITION BY `string_col`) - END AS `bfcol_2` + MIN(`int64_col`) OVER (PARTITION BY `string_col`) AS `bfcol_2` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/window_out.sql index eae3db0048..430da33e3c 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/window_out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE WHEN `int64_col` IS NULL THEN NULL ELSE VAR_POP(`int64_col`) OVER () END AS `bfcol_1` + VAR_POP(`int64_col`) OVER () AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/window_out.sql index cb39f6dbc8..80e0cf5bc6 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/window_out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE WHEN `int64_col` IS NULL THEN NULL ELSE STDDEV(`int64_col`) OVER () END AS `bfcol_1` + STDDEV(`int64_col`) OVER () AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_out.sql index 401436019e..47426abcbd 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_out.sql @@ -5,11 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE - WHEN `int64_col` IS NULL - THEN NULL - ELSE COALESCE(SUM(`int64_col`) OVER (), 0) - END AS `bfcol_1` + COALESCE(SUM(`int64_col`) OVER (), 0) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_partition_out.sql index f8ada8a5d4..fd1bd4f630 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_partition_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_partition_out.sql @@ -6,11 +6,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE - WHEN `int64_col` IS NULL - THEN NULL - ELSE COALESCE(SUM(`int64_col`) OVER (PARTITION BY `string_col`), 0) - END AS `bfcol_2` + COALESCE(SUM(`int64_col`) OVER (PARTITION BY `string_col`), 0) AS `bfcol_2` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/window_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/window_out.sql index d300251447..e9d6c1cb93 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/window_out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/window_out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE WHEN `int64_col` IS NULL THEN NULL ELSE VARIANCE(`int64_col`) OVER () END AS `bfcol_1` + VARIANCE(`int64_col`) OVER () AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/test_series_polars.py b/tests/unit/test_series_polars.py index 6f729b0df0..e98db92b93 100644 --- a/tests/unit/test_series_polars.py +++ b/tests/unit/test_series_polars.py @@ -1036,7 +1036,8 @@ def test_series_int_int_operators_scalar( bf_result = maybe_reversed_op(scalars_df["int64_col"], other_scalar).to_pandas() pd_result = maybe_reversed_op(scalars_pandas_df["int64_col"], other_scalar) - assert_series_equal(pd_result, bf_result) + # don't check dtype, as pandas is a bit unstable here across versions, esp floordiv + assert_series_equal(pd_result, bf_result, check_dtype=False) def test_series_pow_scalar(scalars_dfs): From e39dfe26815e3ba6996ec25b9af8fff83cf8ec55 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Wed, 19 Nov 2025 16:24:54 -0800 Subject: [PATCH 246/313] deps: fix missing CTEs in sqlglot v28 and relax version dependency (#2277) --- bigframes/core/compile/sqlglot/sqlglot_ir.py | 55 +++++++++++++++----- setup.py | 2 +- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/bigframes/core/compile/sqlglot/sqlglot_ir.py b/bigframes/core/compile/sqlglot/sqlglot_ir.py index b28c5ede91..3473968450 100644 --- a/bigframes/core/compile/sqlglot/sqlglot_ir.py +++ b/bigframes/core/compile/sqlglot/sqlglot_ir.py @@ -174,7 +174,7 @@ def from_query_string( alias=cte_name, ) select_expr = sge.Select().select(sge.Star()).from_(sge.Table(this=cte_name)) - select_expr.set("with", sge.With(expressions=[cte])) + select_expr = _set_query_ctes(select_expr, [cte]) return cls(expr=select_expr, uid_gen=uid_gen) @classmethod @@ -197,7 +197,8 @@ def from_union( ), f"All provided expressions must be of type sge.Select, but got {type(select)}" select_expr = select.copy() - existing_ctes = [*existing_ctes, *select_expr.args.pop("with", [])] + select_expr, select_ctes = _pop_query_ctes(select_expr) + existing_ctes = [*existing_ctes, *select_ctes] new_cte_name = sge.to_identifier( next(uid_gen.get_uid_stream("bfcte_")), quoted=cls.quoted @@ -229,7 +230,7 @@ def from_union( ), ) final_select_expr = sge.Select().select(sge.Star()).from_(union_expr.subquery()) - final_select_expr.set("with", sge.With(expressions=existing_ctes)) + final_select_expr = _set_query_ctes(final_select_expr, existing_ctes) return cls(expr=final_select_expr, uid_gen=uid_gen) def select( @@ -336,8 +337,8 @@ def join( left_select = _select_to_cte(self.expr, left_cte_name) right_select = _select_to_cte(right.expr, right_cte_name) - left_ctes = left_select.args.pop("with", []) - right_ctes = right_select.args.pop("with", []) + left_select, left_ctes = _pop_query_ctes(left_select) + right_select, right_ctes = _pop_query_ctes(right_select) merged_ctes = [*left_ctes, *right_ctes] join_on = _and( @@ -353,7 +354,7 @@ def join( .from_(sge.Table(this=left_cte_name)) .join(sge.Table(this=right_cte_name), on=join_on, join_type=join_type_str) ) - new_expr.set("with", sge.With(expressions=merged_ctes)) + new_expr = _set_query_ctes(new_expr, merged_ctes) return SQLGlotIR(expr=new_expr, uid_gen=self.uid_gen) @@ -373,8 +374,8 @@ def isin_join( # Prefer subquery over CTE for the IN clause's right side to improve SQL readability. right_select = right.expr - left_ctes = left_select.args.pop("with", []) - right_ctes = right_select.args.pop("with", []) + left_select, left_ctes = _pop_query_ctes(left_select) + right_select, right_ctes = _pop_query_ctes(right_select) merged_ctes = [*left_ctes, *right_ctes] left_condition = typed_expr.TypedExpr( @@ -415,7 +416,7 @@ def isin_join( .select(sge.Column(this=sge.Star(), table=left_cte_name), new_column) .from_(sge.Table(this=left_cte_name)) ) - new_expr.set("with", sge.With(expressions=merged_ctes)) + new_expr = _set_query_ctes(new_expr, merged_ctes) return SQLGlotIR(expr=new_expr, uid_gen=self.uid_gen) @@ -625,14 +626,13 @@ def _select_to_cte(expr: sge.Select, cte_name: sge.Identifier) -> sge.Select: into a new CTE and then generates a 'SELECT * FROM new_cte_name' for the new query.""" select_expr = expr.copy() - existing_ctes = select_expr.args.pop("with", []) + select_expr, existing_ctes = _pop_query_ctes(select_expr) new_cte = sge.CTE( this=select_expr, alias=cte_name, ) - new_with_clause = sge.With(expressions=[*existing_ctes, new_cte]) new_select_expr = sge.Select().select(sge.Star()).from_(sge.Table(this=cte_name)) - new_select_expr.set("with", new_with_clause) + new_select_expr = _set_query_ctes(new_select_expr, [*existing_ctes, new_cte]) return new_select_expr @@ -788,3 +788,34 @@ def _join_condition_for_numeric( this=sge.EQ(this=left_2, expression=right_2), expression=sge.EQ(this=left_3, expression=right_3), ) + + +def _set_query_ctes( + expr: sge.Select, + ctes: list[sge.CTE], +) -> sge.Select: + """Sets the CTEs of a given sge.Select expression.""" + new_expr = expr.copy() + with_expr = sge.With(expressions=ctes) if len(ctes) > 0 else None + + if "with" in new_expr.arg_types.keys(): + new_expr.set("with", with_expr) + elif "with_" in new_expr.arg_types.keys(): + new_expr.set("with_", with_expr) + else: + raise ValueError("The expression does not support CTEs.") + return new_expr + + +def _pop_query_ctes( + expr: sge.Select, +) -> tuple[sge.Select, list[sge.CTE]]: + """Pops the CTEs of a given sge.Select expression.""" + if "with" in expr.arg_types.keys(): + expr_ctes = expr.args.pop("with", []) + return expr, expr_ctes + elif "with_" in expr.arg_types.keys(): + expr_ctes = expr.args.pop("with_", []) + return expr, expr_ctes + else: + raise ValueError("The expression does not support CTEs.") diff --git a/setup.py b/setup.py index 28ae99a50d..fa663f66d5 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ "requests >=2.27.1", "shapely >=1.8.5", # 25.20.0 introduces this fix https://github.com/TobikoData/sqlmesh/issues/3095 for rtrim/ltrim. - "sqlglot >=25.20.0, <28.0.0", + "sqlglot >=25.20.0", "tabulate >=0.9", "ipywidgets >=7.7.1", "humanize >=4.6.0", From c35e21fb121cdebcd81a777a4ca0fddb7a744b9e Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Thu, 20 Nov 2025 11:17:23 -0800 Subject: [PATCH 247/313] refactor: add agg_ops.CutOp to the sqlglot compiler (#2268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes internal issue 445774480🦕 --------- Co-authored-by: Shenyang Cai --- .../sqlglot/aggregations/unary_compiler.py | 134 ++++++++++++++++++ .../test_unary_compiler/test_cut/int_bins.sql | 55 +++++++ .../test_cut/int_bins_labels.sql | 24 ++++ .../test_cut/interval_bins.sql | 18 +++ .../test_cut/interval_bins_labels.sql | 18 +++ .../aggregations/test_unary_compiler.py | 29 ++++ 6 files changed, 278 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/int_bins.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/int_bins_labels.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/interval_bins.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/interval_bins_labels.sql diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py index 603e8a096c..e2bd6b8382 100644 --- a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -111,6 +111,140 @@ def _( return apply_window_if_present(sge.func("COUNT", column.expr), window) +@UNARY_OP_REGISTRATION.register(agg_ops.CutOp) +def _( + op: agg_ops.CutOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + if isinstance(op.bins, int): + case_expr = _cut_ops_w_int_bins(op, column, op.bins, window) + else: # Interpret as intervals + case_expr = _cut_ops_w_intervals(op, column, op.bins, window) + return case_expr + + +def _cut_ops_w_int_bins( + op: agg_ops.CutOp, + column: typed_expr.TypedExpr, + bins: int, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Case: + case_expr = sge.Case() + col_min = apply_window_if_present( + sge.func("MIN", column.expr), window or window_spec.WindowSpec() + ) + col_max = apply_window_if_present( + sge.func("MAX", column.expr), window or window_spec.WindowSpec() + ) + adj: sge.Expression = sge.Sub(this=col_max, expression=col_min) * sge.convert(0.001) + bin_width: sge.Expression = sge.func( + "IEEE_DIVIDE", + sge.Sub(this=col_max, expression=col_min), + sge.convert(bins), + ) + + for this_bin in range(bins): + value: sge.Expression + if op.labels is False: + value = ir._literal(this_bin, dtypes.INT_DTYPE) + elif isinstance(op.labels, typing.Iterable): + value = ir._literal(list(op.labels)[this_bin], dtypes.STRING_DTYPE) + else: + left_adj: sge.Expression = ( + adj if this_bin == 0 and op.right else sge.convert(0) + ) + right_adj: sge.Expression = ( + adj if this_bin == bins - 1 and not op.right else sge.convert(0) + ) + + left: sge.Expression = ( + col_min + sge.convert(this_bin) * bin_width - left_adj + ) + right: sge.Expression = ( + col_min + sge.convert(this_bin + 1) * bin_width + right_adj + ) + if op.right: + left_identifier = sge.Identifier(this="left_exclusive", quoted=True) + right_identifier = sge.Identifier(this="right_inclusive", quoted=True) + else: + left_identifier = sge.Identifier(this="left_inclusive", quoted=True) + right_identifier = sge.Identifier(this="right_exclusive", quoted=True) + + value = sge.Struct( + expressions=[ + sge.PropertyEQ(this=left_identifier, expression=left), + sge.PropertyEQ(this=right_identifier, expression=right), + ] + ) + + condition: sge.Expression + if this_bin == bins - 1: + condition = sge.Is(this=column.expr, expression=sge.Not(this=sge.Null())) + else: + if op.right: + condition = sge.LTE( + this=column.expr, + expression=(col_min + sge.convert(this_bin + 1) * bin_width), + ) + else: + condition = sge.LT( + this=column.expr, + expression=(col_min + sge.convert(this_bin + 1) * bin_width), + ) + case_expr = case_expr.when(condition, value) + return case_expr + + +def _cut_ops_w_intervals( + op: agg_ops.CutOp, + column: typed_expr.TypedExpr, + bins: typing.Iterable[typing.Tuple[typing.Any, typing.Any]], + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Case: + case_expr = sge.Case() + for this_bin, interval in enumerate(bins): + left: sge.Expression = ir._literal( + interval[0], dtypes.infer_literal_type(interval[0]) + ) + right: sge.Expression = ir._literal( + interval[1], dtypes.infer_literal_type(interval[1]) + ) + condition: sge.Expression + if op.right: + condition = sge.And( + this=sge.GT(this=column.expr, expression=left), + expression=sge.LTE(this=column.expr, expression=right), + ) + else: + condition = sge.And( + this=sge.GTE(this=column.expr, expression=left), + expression=sge.LT(this=column.expr, expression=right), + ) + + value: sge.Expression + if op.labels is False: + value = ir._literal(this_bin, dtypes.INT_DTYPE) + elif isinstance(op.labels, typing.Iterable): + value = ir._literal(list(op.labels)[this_bin], dtypes.STRING_DTYPE) + else: + if op.right: + left_identifier = sge.Identifier(this="left_exclusive", quoted=True) + right_identifier = sge.Identifier(this="right_inclusive", quoted=True) + else: + left_identifier = sge.Identifier(this="left_inclusive", quoted=True) + right_identifier = sge.Identifier(this="right_exclusive", quoted=True) + + value = sge.Struct( + expressions=[ + sge.PropertyEQ(this=left_identifier, expression=left), + sge.PropertyEQ(this=right_identifier, expression=right), + ] + ) + case_expr = case_expr.when(condition, value) + return case_expr + + @UNARY_OP_REGISTRATION.register(agg_ops.DateSeriesDiffOp) def _( op: agg_ops.DateSeriesDiffOp, diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/int_bins.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/int_bins.sql new file mode 100644 index 0000000000..015ac32799 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/int_bins.sql @@ -0,0 +1,55 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `int64_col` <= MIN(`int64_col`) OVER () + ( + 1 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + THEN STRUCT( + ( + MIN(`int64_col`) OVER () + ( + 0 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + ) - ( + ( + MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER () + ) * 0.001 + ) AS `left_exclusive`, + MIN(`int64_col`) OVER () + ( + 1 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + 0 AS `right_inclusive` + ) + WHEN `int64_col` <= MIN(`int64_col`) OVER () + ( + 2 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + THEN STRUCT( + ( + MIN(`int64_col`) OVER () + ( + 1 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + ) - 0 AS `left_exclusive`, + MIN(`int64_col`) OVER () + ( + 2 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + 0 AS `right_inclusive` + ) + WHEN `int64_col` IS NOT NULL + THEN STRUCT( + ( + MIN(`int64_col`) OVER () + ( + 2 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + ) - 0 AS `left_exclusive`, + MIN(`int64_col`) OVER () + ( + 3 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + 0 AS `right_inclusive` + ) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int_bins` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/int_bins_labels.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/int_bins_labels.sql new file mode 100644 index 0000000000..c98682f2b8 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/int_bins_labels.sql @@ -0,0 +1,24 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `int64_col` < MIN(`int64_col`) OVER () + ( + 1 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + THEN 'a' + WHEN `int64_col` < MIN(`int64_col`) OVER () + ( + 2 * IEEE_DIVIDE(MAX(`int64_col`) OVER () - MIN(`int64_col`) OVER (), 3) + ) + THEN 'b' + WHEN `int64_col` IS NOT NULL + THEN 'c' + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int_bins_labels` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/interval_bins.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/interval_bins.sql new file mode 100644 index 0000000000..a3e689b11e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/interval_bins.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `int64_col` > 0 AND `int64_col` <= 1 + THEN STRUCT(0 AS `left_exclusive`, 1 AS `right_inclusive`) + WHEN `int64_col` > 1 AND `int64_col` <= 2 + THEN STRUCT(1 AS `left_exclusive`, 2 AS `right_inclusive`) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `interval_bins` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/interval_bins_labels.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/interval_bins_labels.sql new file mode 100644 index 0000000000..1a8a92e38e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/interval_bins_labels.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `int64_col` > 0 AND `int64_col` <= 1 + THEN 0 + WHEN `int64_col` > 1 AND `int64_col` <= 2 + THEN 1 + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `interval_bins_labels` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index a21c753896..ab9f7febbf 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -174,6 +174,35 @@ def test_count(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql_window_partition, "window_partition_out.sql") +def test_cut(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_ops_map = { + "int_bins": agg_exprs.UnaryAggregation( + agg_ops.CutOp(bins=3, right=True, labels=None), expression.deref(col_name) + ), + "interval_bins": agg_exprs.UnaryAggregation( + agg_ops.CutOp(bins=((0, 1), (1, 2)), right=True, labels=None), + expression.deref(col_name), + ), + "int_bins_labels": agg_exprs.UnaryAggregation( + agg_ops.CutOp(bins=3, labels=("a", "b", "c"), right=False), + expression.deref(col_name), + ), + "interval_bins_labels": agg_exprs.UnaryAggregation( + agg_ops.CutOp(bins=((0, 1), (1, 2)), labels=False, right=True), + expression.deref(col_name), + ), + } + window = window_spec.WindowSpec() + + # Loop through the aggregation map items + for test_name, agg_expr in agg_ops_map.items(): + sql = _apply_unary_window_op(bf_df, agg_expr, window, test_name) + + snapshot.assert_match(sql, f"{test_name}.sql") + + def test_dense_rank(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] From bb669153dc9757566a2c4ca4fa2b9fd449ce5938 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Thu, 20 Nov 2025 11:23:11 -0800 Subject: [PATCH 248/313] chore: Migrate GeoStDistanceOp operator to SQLGlot (#2282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes b/447388852 🦕 --- .../core/compile/sqlglot/expressions/geo_ops.py | 5 +++++ .../test_geo_ops/test_geo_st_distance/out.sql | 15 +++++++++++++++ .../compile/sqlglot/expressions/test_geo_ops.py | 15 +++++++++++++++ 3 files changed, 35 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_distance/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/geo_ops.py b/bigframes/core/compile/sqlglot/expressions/geo_ops.py index 10ccdfbeb3..5716dba0e4 100644 --- a/bigframes/core/compile/sqlglot/expressions/geo_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/geo_ops.py @@ -116,6 +116,11 @@ def _(expr: TypedExpr) -> sge.Expression: return sge.func("SAFE.ST_Y", expr.expr) +@register_binary_op(ops.GeoStDistanceOp, pass_op=True) +def _(left: TypedExpr, right: TypedExpr, op: ops.GeoStDistanceOp) -> sge.Expression: + return sge.func("ST_DISTANCE", left.expr, right.expr, sge.convert(op.use_spheroid)) + + @register_binary_op(ops.geo_st_difference_op) def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: return sge.func("ST_DIFFERENCE", left.expr, right.expr) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_distance/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_distance/out.sql new file mode 100644 index 0000000000..e98a581de7 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_distance/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_DISTANCE(`geography_col`, `geography_col`, TRUE) AS `bfcol_1`, + ST_DISTANCE(`geography_col`, `geography_col`, FALSE) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `spheroid`, + `bfcol_2` AS `no_spheroid` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py index 5684ff6f15..9047ce4d04 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py @@ -81,6 +81,21 @@ def test_geo_st_convexhull(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_geo_st_distance(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "geography_col" + bf_df = scalar_types_df[[col_name]] + + sql = utils._apply_ops_to_sql( + bf_df, + [ + ops.GeoStDistanceOp(use_spheroid=True).as_expr(col_name, col_name), + ops.GeoStDistanceOp(use_spheroid=False).as_expr(col_name, col_name), + ], + ["spheroid", "no_spheroid"], + ) + snapshot.assert_match(sql, "out.sql") + + def test_geo_st_difference(scalar_types_df: bpd.DataFrame, snapshot): col_name = "geography_col" bf_df = scalar_types_df[[col_name]] From f7fd2d20896fe3e0e210c3833b6a4c3913270ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Thu, 20 Nov 2025 20:02:19 +0000 Subject: [PATCH 249/313] docs: use autosummary to split documentation pages (#2251) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes # 🦕 --------- Co-authored-by: Shenyang Cai --- bigframes/_config/__init__.py | 179 ++------------- bigframes/_config/compute_options.py | 144 +++++++----- bigframes/_config/display_options.py | 27 +-- bigframes/_config/global_options.py | 186 ++++++++++++++++ bigframes/_config/sampling_options.py | 36 ++- bigframes/bigquery/__init__.py | 51 ++++- bigframes/bigquery/ai.py | 39 ++++ bigframes/enums.py | 3 +- bigframes/exceptions.py | 4 +- bigframes/ml/base.py | 12 +- bigframes/pandas/__init__.py | 11 + bigframes/streaming/__init__.py | 13 ++ docs/_templates/autosummary/class.rst | 1 + docs/_templates/autosummary/module.rst | 1 + docs/conf.py | 5 +- docs/reference/.gitignore | 1 + docs/reference/bigframes.bigquery/ai.rst | 7 - docs/reference/bigframes.bigquery/index.rst | 13 -- .../bigframes.geopandas/geoseries.rst | 17 -- docs/reference/bigframes.geopandas/index.rst | 9 - docs/reference/bigframes.ml/README.rst | 125 ----------- docs/reference/bigframes.ml/cluster.rst | 7 - docs/reference/bigframes.ml/compose.rst | 7 - docs/reference/bigframes.ml/decomposition.rst | 7 - docs/reference/bigframes.ml/ensemble.rst | 7 - docs/reference/bigframes.ml/forecasting.rst | 7 - docs/reference/bigframes.ml/imported.rst | 7 - docs/reference/bigframes.ml/impute.rst | 7 - docs/reference/bigframes.ml/index.rst | 38 ---- docs/reference/bigframes.ml/linear_model.rst | 7 - docs/reference/bigframes.ml/llm.rst | 7 - .../bigframes.ml/metrics.pairwise.rst | 7 - docs/reference/bigframes.ml/metrics.rst | 7 - .../bigframes.ml/model_selection.rst | 7 - docs/reference/bigframes.ml/pipeline.rst | 7 - docs/reference/bigframes.ml/preprocessing.rst | 7 - docs/reference/bigframes.ml/remote.rst | 7 - docs/reference/bigframes.pandas/frame.rst | 44 ---- .../bigframes.pandas/general_functions.rst | 9 - docs/reference/bigframes.pandas/groupby.rst | 20 -- docs/reference/bigframes.pandas/index.rst | 16 -- docs/reference/bigframes.pandas/indexers.rst | 60 ----- docs/reference/bigframes.pandas/indexing.rst | 21 -- docs/reference/bigframes.pandas/options.rst | 6 - docs/reference/bigframes.pandas/series.rst | 69 ------ docs/reference/bigframes.pandas/window.rst | 9 - .../bigframes.streaming/dataframe.rst | 6 - docs/reference/bigframes.streaming/index.rst | 13 -- docs/reference/bigframes/enums.rst | 8 - docs/reference/bigframes/exceptions.rst | 8 - docs/reference/bigframes/index.rst | 22 -- docs/reference/bigframes/options.rst | 16 -- docs/reference/index.rst | 44 +++- .../pandas/core/config_init.py | 206 +++++++++++------- third_party/sphinx/LICENSE.rst | 31 +++ .../templates/autosummary/class.rst | 31 +++ .../templates/autosummary/module.rst | 57 +++++ 57 files changed, 735 insertions(+), 988 deletions(-) create mode 100644 bigframes/_config/global_options.py create mode 100644 bigframes/bigquery/ai.py create mode 120000 docs/_templates/autosummary/class.rst create mode 120000 docs/_templates/autosummary/module.rst create mode 100644 docs/reference/.gitignore delete mode 100644 docs/reference/bigframes.bigquery/ai.rst delete mode 100644 docs/reference/bigframes.bigquery/index.rst delete mode 100644 docs/reference/bigframes.geopandas/geoseries.rst delete mode 100644 docs/reference/bigframes.geopandas/index.rst delete mode 100644 docs/reference/bigframes.ml/README.rst delete mode 100644 docs/reference/bigframes.ml/cluster.rst delete mode 100644 docs/reference/bigframes.ml/compose.rst delete mode 100644 docs/reference/bigframes.ml/decomposition.rst delete mode 100644 docs/reference/bigframes.ml/ensemble.rst delete mode 100644 docs/reference/bigframes.ml/forecasting.rst delete mode 100644 docs/reference/bigframes.ml/imported.rst delete mode 100644 docs/reference/bigframes.ml/impute.rst delete mode 100644 docs/reference/bigframes.ml/index.rst delete mode 100644 docs/reference/bigframes.ml/linear_model.rst delete mode 100644 docs/reference/bigframes.ml/llm.rst delete mode 100644 docs/reference/bigframes.ml/metrics.pairwise.rst delete mode 100644 docs/reference/bigframes.ml/metrics.rst delete mode 100644 docs/reference/bigframes.ml/model_selection.rst delete mode 100644 docs/reference/bigframes.ml/pipeline.rst delete mode 100644 docs/reference/bigframes.ml/preprocessing.rst delete mode 100644 docs/reference/bigframes.ml/remote.rst delete mode 100644 docs/reference/bigframes.pandas/frame.rst delete mode 100644 docs/reference/bigframes.pandas/general_functions.rst delete mode 100644 docs/reference/bigframes.pandas/groupby.rst delete mode 100644 docs/reference/bigframes.pandas/index.rst delete mode 100644 docs/reference/bigframes.pandas/indexers.rst delete mode 100644 docs/reference/bigframes.pandas/indexing.rst delete mode 100644 docs/reference/bigframes.pandas/options.rst delete mode 100644 docs/reference/bigframes.pandas/series.rst delete mode 100644 docs/reference/bigframes.pandas/window.rst delete mode 100644 docs/reference/bigframes.streaming/dataframe.rst delete mode 100644 docs/reference/bigframes.streaming/index.rst delete mode 100644 docs/reference/bigframes/enums.rst delete mode 100644 docs/reference/bigframes/exceptions.rst delete mode 100644 docs/reference/bigframes/index.rst delete mode 100644 docs/reference/bigframes/options.rst create mode 100644 third_party/sphinx/LICENSE.rst create mode 100644 third_party/sphinx/ext/autosummary/templates/autosummary/class.rst create mode 100644 third_party/sphinx/ext/autosummary/templates/autosummary/module.rst diff --git a/bigframes/_config/__init__.py b/bigframes/_config/__init__.py index 52b47e3e9a..1302f6cc03 100644 --- a/bigframes/_config/__init__.py +++ b/bigframes/_config/__init__.py @@ -17,175 +17,24 @@ DataFrames from this package. """ -from __future__ import annotations - -import copy -from dataclasses import dataclass, field -import threading -from typing import Optional - -import bigframes_vendored.pandas._config.config as pandas_config - -import bigframes._config.bigquery_options as bigquery_options -import bigframes._config.compute_options as compute_options -import bigframes._config.display_options as display_options -import bigframes._config.experiment_options as experiment_options -import bigframes._config.sampling_options as sampling_options - - -@dataclass -class ThreadLocalConfig(threading.local): - # If unset, global settings will be used - bigquery_options: Optional[bigquery_options.BigQueryOptions] = None - # Note: use default factory instead of default instance so each thread initializes to default values - display_options: display_options.DisplayOptions = field( - default_factory=display_options.DisplayOptions - ) - sampling_options: sampling_options.SamplingOptions = field( - default_factory=sampling_options.SamplingOptions - ) - compute_options: compute_options.ComputeOptions = field( - default_factory=compute_options.ComputeOptions - ) - experiment_options: experiment_options.ExperimentOptions = field( - default_factory=experiment_options.ExperimentOptions - ) - - -class Options: - """Global options affecting BigQuery DataFrames behavior.""" - - def __init__(self): - self.reset() - - def reset(self) -> Options: - """Reset the option settings to defaults. - - Returns: - bigframes._config.Options: Options object with default values. - """ - self._local = ThreadLocalConfig() - - # BigQuery options are special because they can only be set once per - # session, so we need an indicator as to whether we are using the - # thread-local session or the global session. - self._bigquery_options = bigquery_options.BigQueryOptions() - return self - - def _init_bigquery_thread_local(self): - """Initialize thread-local options, based on current global options.""" - - # Already thread-local, so don't reset any options that have been set - # already. No locks needed since this only modifies thread-local - # variables. - if self._local.bigquery_options is not None: - return - - self._local.bigquery_options = copy.deepcopy(self._bigquery_options) - self._local.bigquery_options._session_started = False - - @property - def bigquery(self) -> bigquery_options.BigQueryOptions: - """Options to use with the BigQuery engine. - - Returns: - bigframes._config.bigquery_options.BigQueryOptions: - Options for BigQuery engine. - """ - if self._local.bigquery_options is not None: - # The only way we can get here is if someone called - # _init_bigquery_thread_local. - return self._local.bigquery_options - - return self._bigquery_options - - @property - def display(self) -> display_options.DisplayOptions: - """Options controlling object representation. - - Returns: - bigframes._config.display_options.DisplayOptions: - Options for controlling object representation. - """ - return self._local.display_options - - @property - def sampling(self) -> sampling_options.SamplingOptions: - """Options controlling downsampling when downloading data - to memory. - - The data can be downloaded into memory explicitly - (e.g., to_pandas, to_numpy, values) or implicitly (e.g., - matplotlib plotting). This option can be overridden by - parameters in specific functions. - - Returns: - bigframes._config.sampling_options.SamplingOptions: - Options for controlling downsampling. - """ - return self._local.sampling_options - - @property - def compute(self) -> compute_options.ComputeOptions: - """Thread-local options controlling object computation. - - Returns: - bigframes._config.compute_options.ComputeOptions: - Thread-local options for controlling object computation - """ - return self._local.compute_options - - @property - def experiments(self) -> experiment_options.ExperimentOptions: - """Options controlling experiments - - Returns: - bigframes._config.experiment_options.ExperimentOptions: - Thread-local options for controlling experiments - """ - return self._local.experiment_options - - @property - def is_bigquery_thread_local(self) -> bool: - """Indicator that we're using a thread-local session. - - A thread-local session can be started by using - `with bigframes.option_context("bigquery.some_option", "some-value"):`. - - Returns: - bool: - A boolean value, where a value is True if a thread-local session - is in use; otherwise False. - """ - return self._local.bigquery_options is not None - - @property - def _allow_large_results(self) -> bool: - """The effective 'allow_large_results' setting. - - This value is `self.compute.allow_large_results` if set (not `None`), - otherwise it defaults to `self.bigquery.allow_large_results`. - - Returns: - bool: - Whether large query results are permitted. - - `True`: The BigQuery result size limit (e.g., 10 GB) is removed. - - `False`: Results are restricted to this limit (potentially faster). - BigQuery will raise an error if this limit is exceeded. - """ - if self.compute.allow_large_results is None: - return self.bigquery.allow_large_results - return self.compute.allow_large_results - - -options = Options() -"""Global options for default session.""" - -option_context = pandas_config.option_context +from bigframes._config.bigquery_options import BigQueryOptions +from bigframes._config.compute_options import ComputeOptions +from bigframes._config.display_options import DisplayOptions +from bigframes._config.experiment_options import ExperimentOptions +from bigframes._config.global_options import option_context, Options +import bigframes._config.global_options as global_options +from bigframes._config.sampling_options import SamplingOptions +options = global_options.options +"""Global options for the default session.""" __all__ = ( "Options", "options", "option_context", + "BigQueryOptions", + "ComputeOptions", + "DisplayOptions", + "ExperimentOptions", + "SamplingOptions", ) diff --git a/bigframes/_config/compute_options.py b/bigframes/_config/compute_options.py index 97cd6e99af..7810ee897f 100644 --- a/bigframes/_config/compute_options.py +++ b/bigframes/_config/compute_options.py @@ -29,7 +29,7 @@ class ComputeOptions: >>> df = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") >>> bpd.options.compute.maximum_bytes_billed = 500 - >>> # df.to_pandas() # this should fail + >>> df.to_pandas() # this should fail # doctest: +SKIP google.api_core.exceptions.InternalServerError: 500 Query exceeded limit for bytes billed: 500. 10485760 or higher required. >>> bpd.options.compute.maximum_bytes_billed = None # reset option @@ -53,68 +53,112 @@ class ComputeOptions: >>> del bpd.options.compute.extra_query_labels["test1"] >>> bpd.options.compute.extra_query_labels {'test2': 'abc', 'test3': False} - - Attributes: - ai_ops_confirmation_threshold (int | None): - Guards against unexpected processing of large amount of rows by semantic operators. - If the number of rows exceeds the threshold, the user will be asked to confirm - their operations to resume. The default value is 0. Set the value to None - to turn off the guard. - - ai_ops_threshold_autofail (bool): - Guards against unexpected processing of large amount of rows by semantic operators. - When set to True, the operation automatically fails without asking for user inputs. - - allow_large_results (bool | None): - Specifies whether query results can exceed 10 GB. Defaults to False. Setting this - to False (the default) restricts results to 10 GB for potentially faster execution; - BigQuery will raise an error if this limit is exceeded. Setting to True removes - this result size limit. - - enable_multi_query_execution (bool | None): - If enabled, large queries may be factored into multiple smaller queries - in order to avoid generating queries that are too complex for the query - engine to handle. However this comes at the cost of increase cost and latency. - - extra_query_labels (Dict[str, Any] | None): - Stores additional custom labels for query configuration. - - maximum_bytes_billed (int | None): - Limits the bytes billed for query jobs. Queries that will have - bytes billed beyond this limit will fail (without incurring a - charge). If unspecified, this will be set to your project default. - See `maximum_bytes_billed`: https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.job.QueryJobConfig#google_cloud_bigquery_job_QueryJobConfig_maximum_bytes_billed. - - maximum_result_rows (int | None): - Limits the number of rows in an execution result. When converting - a BigQuery DataFrames object to a pandas DataFrame or Series (e.g., - using ``.to_pandas()``, ``.peek()``, ``.__repr__()``, direct - iteration), the data is downloaded from BigQuery to the client - machine. This option restricts the number of rows that can be - downloaded. If the number of rows to be downloaded exceeds this - limit, a ``bigframes.exceptions.MaximumResultRowsExceeded`` - exception is raised. - - semantic_ops_confirmation_threshold (int | None): - .. deprecated:: 1.42.0 - Semantic operators are deprecated. Please use AI operators instead - - semantic_ops_threshold_autofail (bool): - .. deprecated:: 1.42.0 - Semantic operators are deprecated. Please use AI operators instead """ ai_ops_confirmation_threshold: Optional[int] = 0 + """ + Guards against unexpected processing of large amount of rows by semantic operators. + + If the number of rows exceeds the threshold, the user will be asked to confirm + their operations to resume. The default value is 0. Set the value to None + to turn off the guard. + + Returns: + Optional[int]: Number of rows. + """ + ai_ops_threshold_autofail: bool = False + """ + Guards against unexpected processing of large amount of rows by semantic operators. + + When set to True, the operation automatically fails without asking for user inputs. + + Returns: + bool: True if the guard is enabled. + """ + allow_large_results: Optional[bool] = None + """ + Specifies whether query results can exceed 10 GB. + + Defaults to False. Setting this to False (the default) restricts results to + 10 GB for potentially faster execution; BigQuery will raise an error if this + limit is exceeded. Setting to True removes this result size limit. + + + Returns: + bool | None: True if results > 10 GB are enabled. + """ enable_multi_query_execution: bool = False + """ + If enabled, large queries may be factored into multiple smaller queries. + + This is in order to avoid generating queries that are too complex for the + query engine to handle. However this comes at the cost of increase cost and + latency. + + + Returns: + bool | None: True if enabled. + """ + extra_query_labels: Dict[str, Any] = dataclasses.field( default_factory=dict, init=False ) + """ + Stores additional custom labels for query configuration. + + Returns: + Dict[str, Any] | None: Additional labels. + """ + maximum_bytes_billed: Optional[int] = None + """ + Limits the bytes billed for query jobs. + + Queries that will have bytes billed beyond this limit will fail (without + incurring a charge). If unspecified, this will be set to your project + default. See `maximum_bytes_billed`: + https://cloud.google.com/python/docs/reference/bigquery/latest/google.cloud.bigquery.job.QueryJobConfig#google_cloud_bigquery_job_QueryJobConfig_maximum_bytes_billed. + + Returns: + int | None: Number of bytes, if set. + """ + maximum_result_rows: Optional[int] = None + """ + Limits the number of rows in an execution result. + + When converting a BigQuery DataFrames object to a pandas DataFrame or Series + (e.g., using ``.to_pandas()``, ``.peek()``, ``.__repr__()``, direct + iteration), the data is downloaded from BigQuery to the client machine. This + option restricts the number of rows that can be downloaded. If the number + of rows to be downloaded exceeds this limit, a + ``bigframes.exceptions.MaximumResultRowsExceeded`` exception is raised. + + Returns: + int | None: Number of rows, if set. + """ + semantic_ops_confirmation_threshold: Optional[int] = 0 + """ + Deprecated. + + .. deprecated:: 1.42.0 + Semantic operators are deprecated. Please use the functions in + :mod:`bigframes.bigquery.ai` instead. + + """ + semantic_ops_threshold_autofail = False + """ + Deprecated. + + .. deprecated:: 1.42.0 + Semantic operators are deprecated. Please use the functions in + :mod:`bigframes.bigquery.ai` instead. + + """ def assign_extra_query_labels(self, **kwargs: Any) -> None: """ diff --git a/bigframes/_config/display_options.py b/bigframes/_config/display_options.py index b7ce29e47e..34c5c77d57 100644 --- a/bigframes/_config/display_options.py +++ b/bigframes/_config/display_options.py @@ -15,38 +15,15 @@ """Options for displaying objects.""" import contextlib -import dataclasses -from typing import Literal, Optional import bigframes_vendored.pandas.core.config_init as vendored_pandas_config import pandas as pd - -@dataclasses.dataclass -class DisplayOptions: - __doc__ = vendored_pandas_config.display_options_doc - - # Options borrowed from pandas. - max_columns: int = 20 - max_rows: int = 10 - precision: int = 6 - - # Options unique to BigQuery DataFrames. - progress_bar: Optional[str] = "auto" - repr_mode: Literal["head", "deferred", "anywidget"] = "head" - - max_colwidth: Optional[int] = 50 - max_info_columns: int = 100 - max_info_rows: Optional[int] = 200000 - memory_usage: bool = True - - blob_display: bool = True - blob_display_width: Optional[int] = None - blob_display_height: Optional[int] = None +DisplayOptions = vendored_pandas_config.DisplayOptions @contextlib.contextmanager -def pandas_repr(display_options: DisplayOptions): +def pandas_repr(display_options: vendored_pandas_config.DisplayOptions): """Use this when visualizing with pandas. This context manager makes sure we reset the pandas options when we're done diff --git a/bigframes/_config/global_options.py b/bigframes/_config/global_options.py new file mode 100644 index 0000000000..4a3da6d380 --- /dev/null +++ b/bigframes/_config/global_options.py @@ -0,0 +1,186 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Configuration for BigQuery DataFrames. Do not depend on other parts of BigQuery +DataFrames from this package. +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass, field +import threading +from typing import Optional + +import bigframes_vendored.pandas._config.config as pandas_config + +import bigframes._config.bigquery_options as bigquery_options +import bigframes._config.compute_options as compute_options +import bigframes._config.display_options as display_options +import bigframes._config.experiment_options as experiment_options +import bigframes._config.sampling_options as sampling_options + + +@dataclass +class ThreadLocalConfig(threading.local): + # If unset, global settings will be used + bigquery_options: Optional[bigquery_options.BigQueryOptions] = None + # Note: use default factory instead of default instance so each thread initializes to default values + display_options: display_options.DisplayOptions = field( + default_factory=display_options.DisplayOptions + ) + sampling_options: sampling_options.SamplingOptions = field( + default_factory=sampling_options.SamplingOptions + ) + compute_options: compute_options.ComputeOptions = field( + default_factory=compute_options.ComputeOptions + ) + experiment_options: experiment_options.ExperimentOptions = field( + default_factory=experiment_options.ExperimentOptions + ) + + +class Options: + """Global options affecting BigQuery DataFrames behavior. + + Do not construct directly. Instead, refer to + :attr:`bigframes.pandas.options`. + """ + + def __init__(self): + self.reset() + + def reset(self) -> Options: + """Reset the option settings to defaults. + + Returns: + bigframes._config.Options: Options object with default values. + """ + self._local = ThreadLocalConfig() + + # BigQuery options are special because they can only be set once per + # session, so we need an indicator as to whether we are using the + # thread-local session or the global session. + self._bigquery_options = bigquery_options.BigQueryOptions() + return self + + def _init_bigquery_thread_local(self): + """Initialize thread-local options, based on current global options.""" + + # Already thread-local, so don't reset any options that have been set + # already. No locks needed since this only modifies thread-local + # variables. + if self._local.bigquery_options is not None: + return + + self._local.bigquery_options = copy.deepcopy(self._bigquery_options) + self._local.bigquery_options._session_started = False + + @property + def bigquery(self) -> bigquery_options.BigQueryOptions: + """Options to use with the BigQuery engine. + + Returns: + bigframes._config.bigquery_options.BigQueryOptions: + Options for BigQuery engine. + """ + if self._local.bigquery_options is not None: + # The only way we can get here is if someone called + # _init_bigquery_thread_local. + return self._local.bigquery_options + + return self._bigquery_options + + @property + def display(self) -> display_options.DisplayOptions: + """Options controlling object representation. + + Returns: + bigframes._config.display_options.DisplayOptions: + Options for controlling object representation. + """ + return self._local.display_options + + @property + def sampling(self) -> sampling_options.SamplingOptions: + """Options controlling downsampling when downloading data + to memory. + + The data can be downloaded into memory explicitly + (e.g., to_pandas, to_numpy, values) or implicitly (e.g., + matplotlib plotting). This option can be overridden by + parameters in specific functions. + + Returns: + bigframes._config.sampling_options.SamplingOptions: + Options for controlling downsampling. + """ + return self._local.sampling_options + + @property + def compute(self) -> compute_options.ComputeOptions: + """Thread-local options controlling object computation. + + Returns: + bigframes._config.compute_options.ComputeOptions: + Thread-local options for controlling object computation + """ + return self._local.compute_options + + @property + def experiments(self) -> experiment_options.ExperimentOptions: + """Options controlling experiments + + Returns: + bigframes._config.experiment_options.ExperimentOptions: + Thread-local options for controlling experiments + """ + return self._local.experiment_options + + @property + def is_bigquery_thread_local(self) -> bool: + """Indicator that we're using a thread-local session. + + A thread-local session can be started by using + `with bigframes.option_context("bigquery.some_option", "some-value"):`. + + Returns: + bool: + A boolean value, where a value is True if a thread-local session + is in use; otherwise False. + """ + return self._local.bigquery_options is not None + + @property + def _allow_large_results(self) -> bool: + """The effective 'allow_large_results' setting. + + This value is `self.compute.allow_large_results` if set (not `None`), + otherwise it defaults to `self.bigquery.allow_large_results`. + + Returns: + bool: + Whether large query results are permitted. + - `True`: The BigQuery result size limit (e.g., 10 GB) is removed. + - `False`: Results are restricted to this limit (potentially faster). + BigQuery will raise an error if this limit is exceeded. + """ + if self.compute.allow_large_results is None: + return self.bigquery.allow_large_results + return self.compute.allow_large_results + + +options = Options() +option_context = pandas_config.option_context diff --git a/bigframes/_config/sampling_options.py b/bigframes/_config/sampling_options.py index ddb2a49713..107142c3ba 100644 --- a/bigframes/_config/sampling_options.py +++ b/bigframes/_config/sampling_options.py @@ -19,18 +19,46 @@ import dataclasses from typing import Literal, Optional -import bigframes_vendored.pandas.core.config_init as vendored_pandas_config - @dataclasses.dataclass class SamplingOptions: - __doc__ = vendored_pandas_config.sampling_options_doc + """ + Encapsulates the configuration for data sampling. + """ max_download_size: Optional[int] = 500 - # Enable downsampling + """ + Download size threshold in MB. Default 500. + + If value set to None, the download size won't be checked. + """ + enable_downsampling: bool = False + """ + Whether to enable downsampling. Default False. + + If max_download_size is exceeded when downloading data (e.g., to_pandas()), + the data will be downsampled if enable_downsampling is True, otherwise, an + error will be raised. + """ + sampling_method: Literal["head", "uniform"] = "uniform" + """ + Downsampling algorithms to be chosen from. Default "uniform". + + The choices are: "head": This algorithm returns a portion of the data from + the beginning. It is fast and requires minimal computations to perform the + downsampling.; "uniform": This algorithm returns uniform random samples of + the data. + """ + random_state: Optional[int] = None + """ + The seed for the uniform downsampling algorithm. Default None. + + If provided, the uniform method may take longer to execute and require more + computation. + """ def with_max_download_size(self, max_rows: Optional[int]) -> SamplingOptions: """Configures the maximum download size for data sampling in MB diff --git a/bigframes/bigquery/__init__.py b/bigframes/bigquery/__init__.py index 0650953fc7..2edd3d71e9 100644 --- a/bigframes/bigquery/__init__.py +++ b/bigframes/bigquery/__init__.py @@ -18,7 +18,7 @@ import sys -from bigframes.bigquery._operations import ai +from bigframes.bigquery import ai from bigframes.bigquery._operations.approx_agg import approx_top_count from bigframes.bigquery._operations.array import ( array_agg, @@ -105,9 +105,54 @@ struct, ] -__all__ = [f.__name__ for f in _functions] + ["ai"] - _module = sys.modules[__name__] for f in _functions: _decorated_object = log_adapter.method_logger(f, custom_base_name="bigquery") setattr(_module, f.__name__, _decorated_object) + del f + +__all__ = [ + # approximate aggregate ops + "approx_top_count", + # array ops + "array_agg", + "array_length", + "array_to_string", + # datetime ops + "unix_micros", + "unix_millis", + "unix_seconds", + # geo ops + "st_area", + "st_buffer", + "st_centroid", + "st_convexhull", + "st_difference", + "st_distance", + "st_intersection", + "st_isclosed", + "st_length", + "st_regionstats", + "st_simplify", + # json ops + "json_extract", + "json_extract_array", + "json_extract_string_array", + "json_query", + "json_query_array", + "json_set", + "json_value", + "json_value_array", + "parse_json", + "to_json", + "to_json_string", + # search ops + "create_vector_index", + "vector_search", + # sql ops + "sql_scalar", + # struct ops + "struct", + # Modules / SQL namespaces + "ai", +] diff --git a/bigframes/bigquery/ai.py b/bigframes/bigquery/ai.py new file mode 100644 index 0000000000..3af52205a6 --- /dev/null +++ b/bigframes/bigquery/ai.py @@ -0,0 +1,39 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This module integrates BigQuery built-in AI functions for use with Series/DataFrame objects, +such as AI.GENERATE_BOOL: +https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-ai-generate-bool""" + +from bigframes.bigquery._operations.ai import ( + classify, + forecast, + generate, + generate_bool, + generate_double, + generate_int, + if_, + score, +) + +__all__ = [ + "classify", + "forecast", + "generate", + "generate_bool", + "generate_double", + "generate_int", + "if_", + "score", +] diff --git a/bigframes/enums.py b/bigframes/enums.py index fd7b5545bb..aa5e1c830f 100644 --- a/bigframes/enums.py +++ b/bigframes/enums.py @@ -21,7 +21,7 @@ class OrderingMode(enum.Enum): - """[Preview] Values used to determine the ordering mode. + """Values used to determine the ordering mode. Default is 'strict'. """ @@ -37,5 +37,6 @@ class DefaultIndexKind(enum.Enum): #: ``n - 3``, ``n - 2``, ``n - 1``, where ``n`` is the number of items in #: the index. SEQUENTIAL_INT64 = enum.auto() + # A completely null index incapable of indexing or alignment. NULL = enum.auto() diff --git a/bigframes/exceptions.py b/bigframes/exceptions.py index 1fb86d7bd6..9facb40e8e 100644 --- a/bigframes/exceptions.py +++ b/bigframes/exceptions.py @@ -127,7 +127,9 @@ class FunctionPackageVersionWarning(PreviewWarning): def format_message(message: str, fill: bool = True): - """Formats a warning message with ANSI color codes for the warning color. + """[Private] Formats a warning message. + + :meta private: Args: message: The warning message string. diff --git a/bigframes/ml/base.py b/bigframes/ml/base.py index c36457d0b5..fe468cb28f 100644 --- a/bigframes/ml/base.py +++ b/bigframes/ml/base.py @@ -15,10 +15,12 @@ """ Wraps primitives for machine learning with BQML -This library is an evolving attempt to -- implement BigQuery DataFrames API for BQML -- follow as close as possible the API design of SKLearn +This library is an evolving attempt to: + +* implement BigQuery DataFrames API for BQML +* follow as close as possible the API design of SKLearn https://arxiv.org/pdf/1309.0238.pdf + """ import abc @@ -46,12 +48,16 @@ class BaseEstimator(bigframes_vendored.sklearn.base.BaseEstimator, abc.ABC): assumed to be the list of hyperparameters. All descendents of this class should implement: + + .. code-block:: python + def __init__(self, hyperparameter_1=default_1, hyperparameter_2=default_2, hyperparameter3, ...): '''Set hyperparameters''' self.hyperparameter_1 = hyperparameter_1 self.hyperparameter_2 = hyperparameter_2 self.hyperparameter3 = hyperparameter3 ... + Note: the object variable names must be exactly the same with parameter names. In order to utilize __repr__. fit(X, y) method is optional. diff --git a/bigframes/pandas/__init__.py b/bigframes/pandas/__init__.py index 7b633f6dc8..e4d82b8884 100644 --- a/bigframes/pandas/__init__.py +++ b/bigframes/pandas/__init__.py @@ -306,11 +306,22 @@ def clean_up_by_session_id( # pandas dtype attributes NA = pandas.NA +"""Alias for :class:`pandas.NA`.""" + BooleanDtype = pandas.BooleanDtype +"""Alias for :class:`pandas.BooleanDtype`.""" + Float64Dtype = pandas.Float64Dtype +"""Alias for :class:`pandas.Float64Dtype`.""" + Int64Dtype = pandas.Int64Dtype +"""Alias for :class:`pandas.Int64Dtype`.""" + StringDtype = pandas.StringDtype +"""Alias for :class:`pandas.StringDtype`.""" + ArrowDtype = pandas.ArrowDtype +"""Alias for :class:`pandas.ArrowDtype`.""" # Class aliases # TODO(swast): Make these real classes so we can refer to these in type diff --git a/bigframes/streaming/__init__.py b/bigframes/streaming/__init__.py index d439d622a2..477c7a99e0 100644 --- a/bigframes/streaming/__init__.py +++ b/bigframes/streaming/__init__.py @@ -12,8 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + import inspect +import sys +from bigframes.core import log_adapter import bigframes.core.global_session as global_session from bigframes.pandas.io.api import _set_default_session_location_if_possible import bigframes.session @@ -32,3 +36,12 @@ def read_gbq_table(table: str) -> streaming_dataframe.StreamingDataFrame: ) StreamingDataFrame = streaming_dataframe.StreamingDataFrame + +_module = sys.modules[__name__] +_functions = [read_gbq_table] + +for _function in _functions: + _decorated_object = log_adapter.method_logger(_function, custom_base_name="pandas") + setattr(_module, _function.__name__, _decorated_object) + +__all__ = ["read_gbq_table", "StreamingDataFrame"] diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst new file mode 120000 index 0000000000..bd84850996 --- /dev/null +++ b/docs/_templates/autosummary/class.rst @@ -0,0 +1 @@ +../../../third_party/sphinx/ext/autosummary/templates/autosummary/class.rst \ No newline at end of file diff --git a/docs/_templates/autosummary/module.rst b/docs/_templates/autosummary/module.rst new file mode 120000 index 0000000000..f330261ac5 --- /dev/null +++ b/docs/_templates/autosummary/module.rst @@ -0,0 +1 @@ +../../../third_party/sphinx/ext/autosummary/templates/autosummary/module.rst \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 9d9e9ebd79..2fc97bc1d0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -65,7 +65,8 @@ autoclass_content = "both" autodoc_default_options = {"members": True} autosummary_generate = True - +autosummary_imported_members = True +autosummary_ignore_module_all = True # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -369,7 +370,7 @@ # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { - "python": ("https://python.readthedocs.org/en/latest/", None), + "python": ("https://docs.python.org/3/", None), "google-auth": ("https://googleapis.dev/python/google-auth/latest/", None), "google.api_core": ( "https://googleapis.dev/python/google-api-core/latest/", diff --git a/docs/reference/.gitignore b/docs/reference/.gitignore new file mode 100644 index 0000000000..3f12795483 --- /dev/null +++ b/docs/reference/.gitignore @@ -0,0 +1 @@ +api/* diff --git a/docs/reference/bigframes.bigquery/ai.rst b/docs/reference/bigframes.bigquery/ai.rst deleted file mode 100644 index 2134125d6f..0000000000 --- a/docs/reference/bigframes.bigquery/ai.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.bigquery.ai -============================= - -.. automodule:: bigframes.bigquery._operations.ai - :members: - :inherited-members: - :undoc-members: \ No newline at end of file diff --git a/docs/reference/bigframes.bigquery/index.rst b/docs/reference/bigframes.bigquery/index.rst deleted file mode 100644 index f9d34f379d..0000000000 --- a/docs/reference/bigframes.bigquery/index.rst +++ /dev/null @@ -1,13 +0,0 @@ - -=========================== -BigQuery Built-in Functions -=========================== - -.. automodule:: bigframes.bigquery - :members: - :undoc-members: - -.. toctree:: - :maxdepth: 2 - - ai diff --git a/docs/reference/bigframes.geopandas/geoseries.rst b/docs/reference/bigframes.geopandas/geoseries.rst deleted file mode 100644 index 481eb73b9d..0000000000 --- a/docs/reference/bigframes.geopandas/geoseries.rst +++ /dev/null @@ -1,17 +0,0 @@ - -========= -GeoSeries -========= - -.. contents:: Table of Contents - :depth: 2 - :local: - :backlinks: none - -GeoSeries ---------- - -.. autoclass:: bigframes.geopandas.GeoSeries - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.geopandas/index.rst b/docs/reference/bigframes.geopandas/index.rst deleted file mode 100644 index e33946461c..0000000000 --- a/docs/reference/bigframes.geopandas/index.rst +++ /dev/null @@ -1,9 +0,0 @@ - -=============================== -BigQuery DataFrames (geopandas) -=============================== - -.. toctree:: - :maxdepth: 2 - - geoseries diff --git a/docs/reference/bigframes.ml/README.rst b/docs/reference/bigframes.ml/README.rst deleted file mode 100644 index 80a1fe97b7..0000000000 --- a/docs/reference/bigframes.ml/README.rst +++ /dev/null @@ -1,125 +0,0 @@ -BigQuery DataFrames ML -====================== - -As BigQuery DataFrames implements the Pandas API over top of BigQuery, BigQuery -DataFrame ML implements the SKLearn API over top of BigQuery Machine Learning. - -Tutorial --------- - -Start a session and initialize a dataframe for a BigQuery table - -.. code-block:: python - - import bigframes.pandas - - df = bigframes.pandas.read_gbq("bigquery-public-data.ml_datasets.penguins") - df - -Clean and prepare the data - -.. code-block:: python - - # filter down to the data we want to analyze - adelie_data = df[df.species == "Adelie Penguin (Pygoscelis adeliae)"] - - # drop the columns we don't care about - adelie_data = adelie_data.drop(columns=["species"]) - - # drop rows with nulls to get our training data - training_data = adelie_data.dropna() - - # take a peek at the training data - training_data - -.. code-block:: python - - # pick feature columns and label column - X = training_data[['island', 'culmen_length_mm', 'culmen_depth_mm', 'flipper_length_mm', 'sex']] - y = training_data[['body_mass_g']] - -Use train_test_split to create train and test datasets - -.. code-block:: python - - from bigframes.ml.model_selection import train_test_split - - X_train, X_test, y_train, y_test = train_test_split( - X, y, test_size=0.2) - -Define the model training pipeline - -.. code-block:: python - - from bigframes.ml.linear_model import LinearRegression - from bigframes.ml.pipeline import Pipeline - from bigframes.ml.compose import ColumnTransformer - from bigframes.ml.preprocessing import StandardScaler, OneHotEncoder - - preprocessing = ColumnTransformer([ - ("onehot", OneHotEncoder(), ["island", "species", "sex"]), - ("scaler", StandardScaler(), ["culmen_depth_mm", "culmen_length_mm", "flipper_length_mm"]), - ]) - - model = LinearRegression(fit_intercept=False) - - pipeline = Pipeline([ - ('preproc', preprocessing), - ('linreg', model) - ]) - - # view the pipeline - pipeline - -Train the pipeline - -.. code-block:: python - - pipeline.fit(X_train, y_train) - -Evaluate the model's performance on the test data - -.. code-block:: python - - from bigframes.ml.metrics import r2_score - - y_pred = pipeline.predict(X_test) - - r2_score(y_test, y_pred) - -Make predictions on new data - -.. code-block:: python - - import pandas - - new_penguins = bigframes.pandas.read_pandas( - pandas.DataFrame( - { - "tag_number": [1633, 1672, 1690], - "species": [ - "Adelie Penguin (Pygoscelis adeliae)", - "Adelie Penguin (Pygoscelis adeliae)", - "Adelie Penguin (Pygoscelis adeliae)", - ], - "island": ["Torgersen", "Torgersen", "Dream"], - "culmen_length_mm": [39.5, 38.5, 37.9], - "culmen_depth_mm": [18.8, 17.2, 18.1], - "flipper_length_mm": [196.0, 181.0, 188.0], - "sex": ["MALE", "FEMALE", "FEMALE"], - } - ).set_index("tag_number") - ) - - # view the new data - new_penguins - -.. code-block:: python - - pipeline.predict(new_penguins) - -Save the trained model to BigQuery, so we can load it later - -.. code-block:: python - - pipeline.to_gbq("bqml_tutorial.penguins_model", replace=True) diff --git a/docs/reference/bigframes.ml/cluster.rst b/docs/reference/bigframes.ml/cluster.rst deleted file mode 100644 index e91a28c051..0000000000 --- a/docs/reference/bigframes.ml/cluster.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.cluster -==================== - -.. automodule:: bigframes.ml.cluster - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/compose.rst b/docs/reference/bigframes.ml/compose.rst deleted file mode 100644 index 9992728362..0000000000 --- a/docs/reference/bigframes.ml/compose.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.compose -==================== - -.. automodule:: bigframes.ml.compose - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/decomposition.rst b/docs/reference/bigframes.ml/decomposition.rst deleted file mode 100644 index ec804ac8cd..0000000000 --- a/docs/reference/bigframes.ml/decomposition.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.decomposition -========================== - -.. automodule:: bigframes.ml.decomposition - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/ensemble.rst b/docs/reference/bigframes.ml/ensemble.rst deleted file mode 100644 index 2652ab5aa4..0000000000 --- a/docs/reference/bigframes.ml/ensemble.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.ensemble -===================== - -.. automodule:: bigframes.ml.ensemble - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/forecasting.rst b/docs/reference/bigframes.ml/forecasting.rst deleted file mode 100644 index 04015c9911..0000000000 --- a/docs/reference/bigframes.ml/forecasting.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.forecasting -======================== - -.. automodule:: bigframes.ml.forecasting - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/imported.rst b/docs/reference/bigframes.ml/imported.rst deleted file mode 100644 index c151cbda6f..0000000000 --- a/docs/reference/bigframes.ml/imported.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.imported -===================== - -.. automodule:: bigframes.ml.imported - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/impute.rst b/docs/reference/bigframes.ml/impute.rst deleted file mode 100644 index 3796e287ef..0000000000 --- a/docs/reference/bigframes.ml/impute.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.impute -========================== - -.. automodule:: bigframes.ml.impute - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/index.rst b/docs/reference/bigframes.ml/index.rst deleted file mode 100644 index c14efaede6..0000000000 --- a/docs/reference/bigframes.ml/index.rst +++ /dev/null @@ -1,38 +0,0 @@ -.. _bigframes_ml: -.. include:: README.rst - -API Reference -------------- - -.. toctree:: - :maxdepth: 3 - - cluster - - compose - - decomposition - - ensemble - - forecasting - - imported - - impute - - linear_model - - llm - - metrics - - metrics.pairwise - - model_selection - - pipeline - - preprocessing - - remote diff --git a/docs/reference/bigframes.ml/linear_model.rst b/docs/reference/bigframes.ml/linear_model.rst deleted file mode 100644 index 8c6c2765b1..0000000000 --- a/docs/reference/bigframes.ml/linear_model.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.linear_model -========================= - -.. automodule:: bigframes.ml.linear_model - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/llm.rst b/docs/reference/bigframes.ml/llm.rst deleted file mode 100644 index 20ae7793e7..0000000000 --- a/docs/reference/bigframes.ml/llm.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.llm -================ - -.. automodule:: bigframes.ml.llm - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/metrics.pairwise.rst b/docs/reference/bigframes.ml/metrics.pairwise.rst deleted file mode 100644 index c20772ef07..0000000000 --- a/docs/reference/bigframes.ml/metrics.pairwise.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.metrics.pairwise -============================= - -.. automodule:: bigframes.ml.metrics.pairwise - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/metrics.rst b/docs/reference/bigframes.ml/metrics.rst deleted file mode 100644 index aca11f7e9f..0000000000 --- a/docs/reference/bigframes.ml/metrics.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.metrics -==================== - -.. automodule:: bigframes.ml.metrics - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/model_selection.rst b/docs/reference/bigframes.ml/model_selection.rst deleted file mode 100644 index d662285f99..0000000000 --- a/docs/reference/bigframes.ml/model_selection.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.model_selection -============================ - -.. automodule:: bigframes.ml.model_selection - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/pipeline.rst b/docs/reference/bigframes.ml/pipeline.rst deleted file mode 100644 index 22e877dc5b..0000000000 --- a/docs/reference/bigframes.ml/pipeline.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.pipeline -===================== - -.. automodule:: bigframes.ml.pipeline - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/preprocessing.rst b/docs/reference/bigframes.ml/preprocessing.rst deleted file mode 100644 index eac72da173..0000000000 --- a/docs/reference/bigframes.ml/preprocessing.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.preprocessing -========================== - -.. automodule:: bigframes.ml.preprocessing - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.ml/remote.rst b/docs/reference/bigframes.ml/remote.rst deleted file mode 100644 index 7827acfe92..0000000000 --- a/docs/reference/bigframes.ml/remote.rst +++ /dev/null @@ -1,7 +0,0 @@ -bigframes.ml.remote -=================== - -.. automodule:: bigframes.ml.remote - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.pandas/frame.rst b/docs/reference/bigframes.pandas/frame.rst deleted file mode 100644 index ea4c6dec1c..0000000000 --- a/docs/reference/bigframes.pandas/frame.rst +++ /dev/null @@ -1,44 +0,0 @@ - -========= -DataFrame -========= - -.. contents:: Table of Contents - :depth: 2 - :local: - :backlinks: none - -DataFrame ---------- - -.. autoclass:: bigframes.dataframe.DataFrame - :members: - :inherited-members: - :undoc-members: - -Accessors ---------- - -Plotting handling -^^^^^^^^^^^^^^^^^ - -.. autoclass:: bigframes.operations.plotting.PlotAccessor - :members: - :inherited-members: - :undoc-members: - -Struct handling -^^^^^^^^^^^^^^^ - -.. autoclass:: bigframes.operations.structs.StructFrameAccessor - :members: - :inherited-members: - :undoc-members: - -AI operators -^^^^^^^^^^^^ - -.. autoclass:: bigframes.operations.ai.AIAccessor - :members: - :inherited-members: - :undoc-members: \ No newline at end of file diff --git a/docs/reference/bigframes.pandas/general_functions.rst b/docs/reference/bigframes.pandas/general_functions.rst deleted file mode 100644 index fff1a9ef59..0000000000 --- a/docs/reference/bigframes.pandas/general_functions.rst +++ /dev/null @@ -1,9 +0,0 @@ - -================= -General functions -================= - -.. automodule:: bigframes.pandas - :members: - :undoc-members: - :noindex: diff --git a/docs/reference/bigframes.pandas/groupby.rst b/docs/reference/bigframes.pandas/groupby.rst deleted file mode 100644 index 483340f348..0000000000 --- a/docs/reference/bigframes.pandas/groupby.rst +++ /dev/null @@ -1,20 +0,0 @@ - -======= -GroupBy -======= - -DataFrameGroupBy ----------------- - -.. autoclass:: bigframes.core.groupby.DataFrameGroupBy - :members: - :inherited-members: - :undoc-members: - -SeriesGroupBy -------------- - -.. autoclass:: bigframes.core.groupby.SeriesGroupBy - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.pandas/index.rst b/docs/reference/bigframes.pandas/index.rst deleted file mode 100644 index 3492f236ee..0000000000 --- a/docs/reference/bigframes.pandas/index.rst +++ /dev/null @@ -1,16 +0,0 @@ - -============================ -BigQuery DataFrames (pandas) -============================ - -.. toctree:: - :maxdepth: 2 - - general_functions - series - frame - indexers - indexing - window - groupby - options diff --git a/docs/reference/bigframes.pandas/indexers.rst b/docs/reference/bigframes.pandas/indexers.rst deleted file mode 100644 index 602b6de837..0000000000 --- a/docs/reference/bigframes.pandas/indexers.rst +++ /dev/null @@ -1,60 +0,0 @@ - -========= -Indexers -========= - -AtDataFrameIndexer --------------------- -.. autoclass:: bigframes.core.indexers.AtDataFrameIndexer - :members: - :inherited-members: - :undoc-members: - -AtSeriesIndexer --------------------- -.. autoclass:: bigframes.core.indexers.AtSeriesIndexer - :members: - :inherited-members: - :undoc-members: - -IatDataFrameIndexer --------------------- -.. autoclass:: bigframes.core.indexers.IatDataFrameIndexer - :members: - :inherited-members: - :undoc-members: - -IatSeriesIndexer --------------------- -.. autoclass:: bigframes.core.indexers.IatSeriesIndexer - :members: - :inherited-members: - :undoc-members: - -ILocDataFrameIndexer --------------------- -.. autoclass:: bigframes.core.indexers.ILocDataFrameIndexer - :members: - :inherited-members: - :undoc-members: - -IlocSeriesIndexer ------------------ -.. autoclass:: bigframes.core.indexers.IlocSeriesIndexer - :members: - :inherited-members: - :undoc-members: - -LocDataFrameIndexer -------------------- -.. autoclass:: bigframes.core.indexers.LocDataFrameIndexer - :members: - :inherited-members: - :undoc-members: - -LocSeriesIndexer ----------------- -.. autoclass:: bigframes.core.indexers.LocSeriesIndexer - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.pandas/indexing.rst b/docs/reference/bigframes.pandas/indexing.rst deleted file mode 100644 index e25e8652ec..0000000000 --- a/docs/reference/bigframes.pandas/indexing.rst +++ /dev/null @@ -1,21 +0,0 @@ - -============= -Index objects -============= - -.. autoclass:: bigframes.core.indexes.base.Index - :members: - :inherited-members: - :undoc-members: - - -.. autoclass:: bigframes.core.indexes.multi.MultiIndex - :members: - :inherited-members: - :undoc-members: - - -.. autoclass:: bigframes.core.indexes.datetimes.DatetimeIndex - :members: - :inherited-members: - :undoc-members: \ No newline at end of file diff --git a/docs/reference/bigframes.pandas/options.rst b/docs/reference/bigframes.pandas/options.rst deleted file mode 100644 index 60af8c826a..0000000000 --- a/docs/reference/bigframes.pandas/options.rst +++ /dev/null @@ -1,6 +0,0 @@ - -==================== -Options and settings -==================== - -``bigframes.pandas.options`` is an alias for :data:`bigframes.options`. diff --git a/docs/reference/bigframes.pandas/series.rst b/docs/reference/bigframes.pandas/series.rst deleted file mode 100644 index 41b1529b0c..0000000000 --- a/docs/reference/bigframes.pandas/series.rst +++ /dev/null @@ -1,69 +0,0 @@ - -====== -Series -====== - -.. contents:: Table of Contents - :depth: 2 - :local: - :backlinks: none - -Series ------- - -.. autoclass:: bigframes.series.Series - :members: - :inherited-members: - :undoc-members: - -Accessors ---------- - -Datetime properties -^^^^^^^^^^^^^^^^^^^ - -.. autoclass:: bigframes.operations.datetimes.DatetimeMethods - :members: - :inherited-members: - :undoc-members: - -String handling -^^^^^^^^^^^^^^^ - -.. autoclass:: bigframes.operations.strings.StringMethods - :members: - :inherited-members: - :undoc-members: - -List handling -^^^^^^^^^^^^^ - -.. autoclass:: bigframes.operations.lists.ListAccessor - :members: - :inherited-members: - :undoc-members: - -Struct handling -^^^^^^^^^^^^^^^ - -.. autoclass:: bigframes.operations.structs.StructAccessor - :members: - :inherited-members: - :undoc-members: - -Blob handling -^^^^^^^^^^^^^ - -.. autoclass:: bigframes.operations.blob.BlobAccessor - :members: - :inherited-members: - :undoc-members: - -Plotting handling -^^^^^^^^^^^^^^^^^ - -.. autoclass:: bigframes.operations.plotting.PlotAccessor - :members: - :inherited-members: - :undoc-members: - :noindex: diff --git a/docs/reference/bigframes.pandas/window.rst b/docs/reference/bigframes.pandas/window.rst deleted file mode 100644 index 55d911ecf4..0000000000 --- a/docs/reference/bigframes.pandas/window.rst +++ /dev/null @@ -1,9 +0,0 @@ - -====== -Window -====== - -.. autoclass:: bigframes.core.window.Window - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes.streaming/dataframe.rst b/docs/reference/bigframes.streaming/dataframe.rst deleted file mode 100644 index 79ec64961c..0000000000 --- a/docs/reference/bigframes.streaming/dataframe.rst +++ /dev/null @@ -1,6 +0,0 @@ -bigframes.streaming.dataframe -============================= - -.. autoclass:: bigframes.streaming.dataframe.StreamingDataFrame - :members: - :inherited-members: diff --git a/docs/reference/bigframes.streaming/index.rst b/docs/reference/bigframes.streaming/index.rst deleted file mode 100644 index 20a22072e5..0000000000 --- a/docs/reference/bigframes.streaming/index.rst +++ /dev/null @@ -1,13 +0,0 @@ - -============================ -BigQuery DataFrame Streaming -============================ - -.. automodule:: bigframes.streaming - :members: - :undoc-members: - -.. toctree:: - :maxdepth: 2 - - dataframe diff --git a/docs/reference/bigframes/enums.rst b/docs/reference/bigframes/enums.rst deleted file mode 100644 index b0a198e184..0000000000 --- a/docs/reference/bigframes/enums.rst +++ /dev/null @@ -1,8 +0,0 @@ - -===== -Enums -===== - -.. automodule:: bigframes.enums - :members: - :undoc-members: diff --git a/docs/reference/bigframes/exceptions.rst b/docs/reference/bigframes/exceptions.rst deleted file mode 100644 index c471aecdf7..0000000000 --- a/docs/reference/bigframes/exceptions.rst +++ /dev/null @@ -1,8 +0,0 @@ - -======================= -Exceptions and Warnings -======================= - -.. automodule:: bigframes.exceptions - :members: - :undoc-members: diff --git a/docs/reference/bigframes/index.rst b/docs/reference/bigframes/index.rst deleted file mode 100644 index f56883dc8e..0000000000 --- a/docs/reference/bigframes/index.rst +++ /dev/null @@ -1,22 +0,0 @@ - -============ -Core objects -============ - -.. toctree:: - :maxdepth: 2 - - enums - exceptions - options - - -Session -------- - -.. autofunction:: bigframes.connect - -.. autoclass:: bigframes.session.Session - :members: - :inherited-members: - :undoc-members: diff --git a/docs/reference/bigframes/options.rst b/docs/reference/bigframes/options.rst deleted file mode 100644 index 991399eb88..0000000000 --- a/docs/reference/bigframes/options.rst +++ /dev/null @@ -1,16 +0,0 @@ -Options and settings -==================== - -.. currentmodule:: bigframes - -.. autodata:: options - -.. autoclass:: bigframes._config.Options - -.. autoclass:: bigframes._config.bigquery_options.BigQueryOptions - -.. autoclass:: bigframes._config.display_options.DisplayOptions - -.. autoclass:: bigframes._config.sampling_options.SamplingOptions - -.. autoclass:: bigframes._config.compute_options.ComputeOptions diff --git a/docs/reference/index.rst b/docs/reference/index.rst index a0f96f751a..7e94784a67 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -4,12 +4,38 @@ API Reference Refer to these pages for details about the public objects in the ``bigframes`` packages. -.. toctree:: - :maxdepth: 2 - - bigframes/index - bigframes.bigquery/index - bigframes.geopandas/index - bigframes.ml/index - bigframes.pandas/index - bigframes.streaming/index +.. autosummary:: + :toctree: api + + bigframes._config + bigframes.bigquery + bigframes.bigquery.ai + bigframes.enums + bigframes.exceptions + bigframes.geopandas + bigframes.pandas + bigframes.streaming + +ML APIs +~~~~~~~ + +BigQuery DataFrames provides many machine learning modules, inspired by +scikit-learn. + + +.. autosummary:: + :toctree: api + + bigframes.ml.cluster + bigframes.ml.compose + bigframes.ml.decomposition + bigframes.ml.ensemble + bigframes.ml.forecasting + bigframes.ml.imported + bigframes.ml.impute + bigframes.ml.linear_model + bigframes.ml.llm + bigframes.ml.model_selection + bigframes.ml.pipeline + bigframes.ml.preprocessing + bigframes.ml.remote diff --git a/third_party/bigframes_vendored/pandas/core/config_init.py b/third_party/bigframes_vendored/pandas/core/config_init.py index dc2b11ab94..194ec4a8a7 100644 --- a/third_party/bigframes_vendored/pandas/core/config_init.py +++ b/third_party/bigframes_vendored/pandas/core/config_init.py @@ -10,109 +10,147 @@ module is imported, register them here rather than in the module. """ + from __future__ import annotations -display_options_doc = """ -Encapsulates the configuration for displaying objects. +import dataclasses +from typing import Literal, Optional -**Examples:** -Define Repr mode to "deferred" will prevent job execution in repr. +@dataclasses.dataclass +class DisplayOptions: + """ + Encapsulates the configuration for displaying objects. - >>> import bigframes.pandas as bpd - >>> df = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") + **Examples:** - >>> bpd.options.display.repr_mode = "deferred" - >>> df.head(20) # will no longer run the job - Computation deferred. Computation will process 28.9 kB + Define Repr mode to "deferred" will prevent job execution in repr. -Users can also get a dry run of the job by accessing the query_job property before they've run the job. This will return a dry run instance of the job they can inspect. + >>> import bigframes.pandas as bpd + >>> df = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") - >>> df.query_job.total_bytes_processed - 28947 + >>> bpd.options.display.repr_mode = "deferred" + >>> df.head(20) # will no longer run the job + Computation deferred. Computation will process 28.9 kB -User can execute the job by calling .to_pandas() + Users can also get a dry run of the job by accessing the query_job property before they've run the job. This will return a dry run instance of the job they can inspect. - >>> # df.to_pandas() + >>> df.query_job.total_bytes_processed + 28947 -Reset repr_mode option + User can execute the job by calling .to_pandas() - >>> bpd.options.display.repr_mode = "head" + >>> # df.to_pandas() -Can also set the progress_bar option to see the progress bar in terminal, + Reset repr_mode option - >>> bpd.options.display.progress_bar = "terminal" + >>> bpd.options.display.repr_mode = "head" -notebook, + Can also set the progress_bar option to see the progress bar in terminal, - >>> bpd.options.display.progress_bar = "notebook" + >>> bpd.options.display.progress_bar = "terminal" -or just remove it. + notebook, + >>> bpd.options.display.progress_bar = "notebook" -Setting to default value "auto" will detect and show progress bar automatically. + or just remove it. - >>> bpd.options.display.progress_bar = "auto" + Setting to default value "auto" will detect and show progress bar automatically. -Attributes: - max_columns (int, default 20): - If `max_columns` is exceeded, switch to truncate view. - max_rows (int, default 25): - If `max_rows` is exceeded, switch to truncate view. - progress_bar (Optional(str), default "auto"): - Determines if progress bars are shown during job runs. - Valid values are `auto`, `notebook`, and `terminal`. Set - to `None` to remove progress bars. - repr_mode (Literal[`head`, `deferred`]): - `head`: - Execute, download, and display results (limited to head) from - Dataframe and Series objects during repr. - `deferred`: - Prevent executions from repr statements in DataFrame and Series objects. - Instead, estimated bytes processed will be shown. DataFrame and Series - objects can still be computed with methods that explicitly execute and - download results. - max_info_columns (int): - max_info_columns is used in DataFrame.info method to decide if - information in each column will be printed. - max_info_rows (int or None): - df.info() will usually show null-counts for each column. - For large frames, this can be quite slow. max_info_rows and max_info_cols - limit this null check only to frames with smaller dimensions than - specified. - memory_usage (bool): - This specifies if the memory usage of a DataFrame should be displayed when - df.info() is called. Valid values True,False, - precision (int): - Controls the floating point output precision, similar to - `pandas.options.display.precision`. - blob_display (bool): - Whether to display the blob content in notebook DataFrame preview. Default True. - blob_display_width (int or None): - Width in pixels that the blob constrained to. - blob_display_height (int or None): - Height in pixels that the blob constrained to. -""" + >>> bpd.options.display.progress_bar = "auto" + """ -sampling_options_doc = """ -Encapsulates the configuration for data sampling. - -Attributes: - max_download_size (int, default 500): - Download size threshold in MB. If value set to None, the download size - won't be checked. - enable_downsampling (bool, default False): - Whether to enable downsampling, If max_download_size is exceeded when - downloading data (e.g., to_pandas()), the data will be downsampled - if enable_downsampling is True, otherwise, an error will be raised. - sampling_method (str, default "uniform"): - Downsampling algorithms to be chosen from, the choices are: - "head": This algorithm returns a portion of the data from - the beginning. It is fast and requires minimal computations - to perform the downsampling.; "uniform": This algorithm returns - uniform random samples of the data. - random_state (int, default None): - The seed for the uniform downsampling algorithm. If provided, - the uniform method may take longer to execute and require more - computation. -""" + # Options borrowed from pandas. + max_columns: int = 20 + """ + Maximum number of columns to display. Default 20. + + If `max_columns` is exceeded, switch to truncate view. + """ + + max_rows: int = 10 + """ + Maximum number of rows to display. Default 10. + + If `max_rows` is exceeded, switch to truncate view. + """ + + precision: int = 6 + """ + Controls the floating point output precision. Defaults to 6. + + See :attr:`pandas.options.display.precision`. + """ + + # Options unique to BigQuery DataFrames. + progress_bar: Optional[str] = "auto" + """ + Determines if progress bars are shown during job runs. Default "auto". + + Valid values are `auto`, `notebook`, and `terminal`. Set + to `None` to remove progress bars. + """ + + repr_mode: Literal["head", "deferred", "anywidget"] = "head" + """ + Determines how to display a DataFrame or Series. Default "head". + + `head` + Execute, download, and display results (limited to head) from + Dataframe and Series objects during repr. + + `deferred` + Prevent executions from repr statements in DataFrame and Series objects. + Instead, estimated bytes processed will be shown. DataFrame and Series + objects can still be computed with methods that explicitly execute and + download results. + """ + + max_colwidth: Optional[int] = 50 + """ + The maximum width in characters of a column in the repr. Default 50. + + When the column overflows, a "..." placeholder is embedded in the output. A + 'None' value means unlimited. + """ + + max_info_columns: int = 100 + """ + Used in DataFrame.info method to decide if information in each column will + be printed. Default 100. + """ + + max_info_rows: Optional[int] = 200_000 + """ + Limit null check in ``df.info()`` only to frames with smaller dimensions than + max_info_rows. Default 200,000. + + df.info() will usually show null-counts for each column. + For large frames, this can be quite slow. max_info_rows and max_info_cols + limit this null check only to frames with smaller dimensions than + specified. + """ + + memory_usage: bool = True + """ + If True, memory usage of a DataFrame should be displayed when + df.info() is called. Default True. + + Valid values True, False. + """ + + blob_display: bool = True + """ + If True, display the blob content in notebook DataFrame preview. Default + True. + """ + + blob_display_width: Optional[int] = None + """ + Width in pixels that the blob constrained to. Default None.. + """ + blob_display_height: Optional[int] = None + """ + Height in pixels that the blob constrained to. Default None.. + """ diff --git a/third_party/sphinx/LICENSE.rst b/third_party/sphinx/LICENSE.rst new file mode 100644 index 0000000000..de3688cd2c --- /dev/null +++ b/third_party/sphinx/LICENSE.rst @@ -0,0 +1,31 @@ +License for Sphinx +================== + +Unless otherwise indicated, all code in the Sphinx project is licenced under the +two clause BSD licence below. + +Copyright (c) 2007-2025 by the Sphinx team (see AUTHORS file). +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third_party/sphinx/ext/autosummary/templates/autosummary/class.rst b/third_party/sphinx/ext/autosummary/templates/autosummary/class.rst new file mode 100644 index 0000000000..89550cb386 --- /dev/null +++ b/third_party/sphinx/ext/autosummary/templates/autosummary/class.rst @@ -0,0 +1,31 @@ +{{ fullname | escape | underline}} + +.. currentmodule:: {{ module }} + +.. autoclass:: {{ objname }} + :no-members: + + {% block methods %} + + {% block attributes %} + {% if attributes %} + .. rubric:: {{ _('Attributes') }} + + .. autosummary:: + :toctree: + {% for item in attributes %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% if methods %} + .. rubric:: {{ _('Methods') }} + + .. autosummary:: + :toctree: + {% for item in methods %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} diff --git a/third_party/sphinx/ext/autosummary/templates/autosummary/module.rst b/third_party/sphinx/ext/autosummary/templates/autosummary/module.rst new file mode 100644 index 0000000000..98d86d1523 --- /dev/null +++ b/third_party/sphinx/ext/autosummary/templates/autosummary/module.rst @@ -0,0 +1,57 @@ +{{ fullname | escape | underline}} + +.. + Originally at + https://github.com/sphinx-doc/sphinx/blob/master/sphinx/ext/autosummary/templates/autosummary/module.rst + with modifications to support recursive generation from + https://github.com/sphinx-doc/sphinx/issues/7912 + +.. automodule:: {{ fullname }} + :no-members: + + {% block functions %} + {%- if functions %} + .. rubric:: {{ _('Functions') }} + + .. autosummary:: + :toctree: + {% for item in functions %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock %} + + {%- block classes %} + {%- if classes %} + .. rubric:: {{ _('Classes') }} + + .. autosummary:: + :toctree: + {% for item in classes %}{% if item not in attributes %} + {{ item }} + {% endif %}{%- endfor %} + {% endif %} + {%- endblock %} + + {%- block exceptions %} + {%- if exceptions %} + .. rubric:: {{ _('Exceptions') }} + + .. autosummary:: + :toctree: + {% for item in exceptions %} + {{ item }} + {%- endfor %} + {% endif %} + {%- endblock %} + +{%- block attributes %} +{%- if attributes %} +.. rubric:: {{ _('Module Attributes') }} + +{% for item in attributes %} +.. autoattribute:: {{ fullname }}.{{ item }} + :no-index: +{% endfor %} +{% endif %} +{%- endblock %} From ac43ee5900d4f8b42bfc5aec30e86264ae6bc52e Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 21 Nov 2025 14:54:49 -0800 Subject: [PATCH 250/313] refactor: WindowOpNode can create multiple cols (#2284) --- bigframes/core/array_value.py | 21 +-- bigframes/core/block_transforms.py | 2 - bigframes/core/blocks.py | 49 +++---- .../compile/ibis_compiler/ibis_compiler.py | 12 +- bigframes/core/compile/polars/compiler.py | 29 +++-- bigframes/core/compile/sqlglot/compiler.py | 122 ++++++++++-------- bigframes/core/expression_factoring.py | 105 +++++++++------ bigframes/core/groupby/dataframe_group_by.py | 8 +- bigframes/core/nodes.py | 94 +++++++++----- bigframes/core/rewrite/order.py | 10 +- bigframes/core/rewrite/schema_binding.py | 8 +- bigframes/core/rewrite/timedeltas.py | 9 +- tests/system/small/engines/test_windowing.py | 9 +- .../aggregations/test_nullary_compiler.py | 3 +- .../aggregations/test_unary_compiler.py | 3 +- .../out.sql | 36 +----- 16 files changed, 284 insertions(+), 236 deletions(-) diff --git a/bigframes/core/array_value.py b/bigframes/core/array_value.py index 5af6fbd56e..2cc8fdf3f0 100644 --- a/bigframes/core/array_value.py +++ b/bigframes/core/array_value.py @@ -268,8 +268,7 @@ def compute_values(self, assignments: Sequence[ex.Expression]): def compute_general_expression(self, assignments: Sequence[ex.Expression]): named_exprs = [ - expression_factoring.NamedExpression(expr, ids.ColumnId.unique()) - for expr in assignments + nodes.ColumnDef(expr, ids.ColumnId.unique()) for expr in assignments ] # TODO: Push this to rewrite later to go from block expression to planning form # TODO: Jointly fragmentize expressions to more efficiently reuse common sub-expressions @@ -279,7 +278,7 @@ def compute_general_expression(self, assignments: Sequence[ex.Expression]): for expr in named_exprs ) ) - target_ids = tuple(named_expr.name for named_expr in named_exprs) + target_ids = tuple(named_expr.id for named_expr in named_exprs) new_root = expression_factoring.push_into_tree(self.node, fragments, target_ids) return (ArrayValue(new_root), target_ids) @@ -403,22 +402,24 @@ def aggregate( def project_window_expr( self, - expression: agg_expressions.Aggregation, + expressions: Sequence[agg_expressions.Aggregation], window: WindowSpec, - skip_reproject_unsafe: bool = False, ): - output_name = self._gen_namespaced_uid() + id_strings = [self._gen_namespaced_uid() for _ in expressions] + agg_exprs = tuple( + nodes.ColumnDef(expression, ids.ColumnId(id_str)) + for expression, id_str in zip(expressions, id_strings) + ) + return ( ArrayValue( nodes.WindowOpNode( child=self.node, - expression=expression, + agg_exprs=agg_exprs, window_spec=window, - output_name=ids.ColumnId(output_name), - skip_reproject_unsafe=skip_reproject_unsafe, ) ), - output_name, + id_strings, ) def isin( diff --git a/bigframes/core/block_transforms.py b/bigframes/core/block_transforms.py index 4e7abb1104..773a615fd9 100644 --- a/bigframes/core/block_transforms.py +++ b/bigframes/core/block_transforms.py @@ -232,13 +232,11 @@ def _interpolate_column( masked_offsets, agg_ops.LastNonNullOp(), backwards_window, - skip_reproject_unsafe=True, ) block, next_value_offset = block.apply_window_op( masked_offsets, agg_ops.FirstNonNullOp(), forwards_window, - skip_reproject_unsafe=True, ) if interpolate_method == "linear": diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index 466dbfce72..e45d945e23 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -1091,20 +1091,14 @@ def multi_apply_window_op( *, skip_null_groups: bool = False, ) -> typing.Tuple[Block, typing.Sequence[str]]: - block = self - result_ids = [] - for i, col_id in enumerate(columns): - label = self.col_id_to_label[col_id] - block, result_id = block.apply_window_op( - col_id, - op, - window_spec=window_spec, - skip_reproject_unsafe=(i + 1) < len(columns), - result_label=label, - skip_null_groups=skip_null_groups, - ) - result_ids.append(result_id) - return block, result_ids + return self.apply_analytic( + agg_exprs=( + agg_expressions.UnaryAggregation(op, ex.deref(col)) for col in columns + ), + window=window_spec, + result_labels=self._get_labels_for_columns(columns), + skip_null_groups=skip_null_groups, + ) def multi_apply_unary_op( self, @@ -1181,44 +1175,39 @@ def apply_window_op( *, result_label: Label = None, skip_null_groups: bool = False, - skip_reproject_unsafe: bool = False, ) -> typing.Tuple[Block, str]: agg_expr = agg_expressions.UnaryAggregation(op, ex.deref(column)) - return self.apply_analytic( - agg_expr, + block, ids = self.apply_analytic( + [agg_expr], window_spec, - result_label, - skip_reproject_unsafe=skip_reproject_unsafe, + [result_label], skip_null_groups=skip_null_groups, ) + return block, ids[0] def apply_analytic( self, - agg_expr: agg_expressions.Aggregation, + agg_exprs: Iterable[agg_expressions.Aggregation], window: windows.WindowSpec, - result_label: Label, + result_labels: Iterable[Label], *, - skip_reproject_unsafe: bool = False, skip_null_groups: bool = False, - ) -> typing.Tuple[Block, str]: + ) -> typing.Tuple[Block, Sequence[str]]: block = self if skip_null_groups: for key in window.grouping_keys: block = block.filter(ops.notnull_op.as_expr(key)) - expr, result_id = block._expr.project_window_expr( - agg_expr, + expr, result_ids = block._expr.project_window_expr( + tuple(agg_exprs), window, - skip_reproject_unsafe=skip_reproject_unsafe, ) block = Block( expr, index_columns=self.index_columns, - column_labels=self.column_labels.insert( - len(self.column_labels), result_label - ), + column_labels=self.column_labels.append(pd.Index(result_labels)), index_labels=self._index_labels, ) - return (block, result_id) + return (block, result_ids) def copy_values(self, source_column_id: str, destination_column_id: str) -> Block: expr = self.expr.assign(source_column_id, destination_column_id) diff --git a/bigframes/core/compile/ibis_compiler/ibis_compiler.py b/bigframes/core/compile/ibis_compiler/ibis_compiler.py index b46c66f879..31cd9a0456 100644 --- a/bigframes/core/compile/ibis_compiler/ibis_compiler.py +++ b/bigframes/core/compile/ibis_compiler/ibis_compiler.py @@ -265,11 +265,13 @@ def compile_aggregate(node: nodes.AggregateNode, child: compiled.UnorderedIR): @_compile_node.register def compile_window(node: nodes.WindowOpNode, child: compiled.UnorderedIR): - result = child.project_window_op( - node.expression, - node.window_spec, - node.output_name.sql, - ) + result = child + for cdef in node.agg_exprs: + result = result.project_window_op( + cdef.expression, # type: ignore + node.window_spec, + cdef.id.sql, + ) return result diff --git a/bigframes/core/compile/polars/compiler.py b/bigframes/core/compile/polars/compiler.py index 1c9b0d802d..5988ecaa90 100644 --- a/bigframes/core/compile/polars/compiler.py +++ b/bigframes/core/compile/polars/compiler.py @@ -853,20 +853,23 @@ def compile_window(self, node: nodes.WindowOpNode): "min_period not yet supported for polars engine" ) - if (window.bounds is None) or (window.is_unbounded): - # polars will automatically broadcast the aggregate to the matching input rows - agg_pl = self.agg_compiler.compile_agg_expr(node.expression) - if window.grouping_keys: - agg_pl = agg_pl.over( - self.expr_compiler.compile_expression(key) - for key in window.grouping_keys + result = df + for cdef in node.agg_exprs: + assert isinstance(cdef.expression, agg_expressions.Aggregation) + if (window.bounds is None) or (window.is_unbounded): + # polars will automatically broadcast the aggregate to the matching input rows + agg_pl = self.agg_compiler.compile_agg_expr(cdef.expression) + if window.grouping_keys: + agg_pl = agg_pl.over( + self.expr_compiler.compile_expression(key) + for key in window.grouping_keys + ) + result = result.with_columns(agg_pl.alias(cdef.id.sql)) + else: # row-bounded window + window_result = self._calc_row_analytic_func( + result, cdef.expression, node.window_spec, cdef.id.sql ) - result = df.with_columns(agg_pl.alias(node.output_name.sql)) - else: # row-bounded window - window_result = self._calc_row_analytic_func( - df, node.expression, node.window_spec, node.output_name.sql - ) - result = pl.concat([df, window_result], how="horizontal") + result = pl.concat([result, window_result], how="horizontal") return result def _calc_row_analytic_func( diff --git a/bigframes/core/compile/sqlglot/compiler.py b/bigframes/core/compile/sqlglot/compiler.py index 276751d6e3..7ecc15f6a2 100644 --- a/bigframes/core/compile/sqlglot/compiler.py +++ b/bigframes/core/compile/sqlglot/compiler.py @@ -19,7 +19,15 @@ import sqlglot.expressions as sge -from bigframes.core import expression, guid, identifiers, nodes, pyarrow_utils, rewrite +from bigframes.core import ( + agg_expressions, + expression, + guid, + identifiers, + nodes, + pyarrow_utils, + rewrite, +) from bigframes.core.compile import configs import bigframes.core.compile.sqlglot.aggregate_compiler as aggregate_compiler from bigframes.core.compile.sqlglot.aggregations import windows @@ -310,67 +318,71 @@ def compile_aggregate(node: nodes.AggregateNode, child: ir.SQLGlotIR) -> ir.SQLG @_compile_node.register def compile_window(node: nodes.WindowOpNode, child: ir.SQLGlotIR) -> ir.SQLGlotIR: window_spec = node.window_spec - if node.expression.op.order_independent and window_spec.is_unbounded: - # notably percentile_cont does not support ordering clause - window_spec = window_spec.without_order() + result = child + for cdef in node.agg_exprs: + assert isinstance(cdef.expression, agg_expressions.Aggregation) + if cdef.expression.op.order_independent and window_spec.is_unbounded: + # notably percentile_cont does not support ordering clause + window_spec = window_spec.without_order() - window_op = aggregate_compiler.compile_analytic(node.expression, window_spec) + window_op = aggregate_compiler.compile_analytic(cdef.expression, window_spec) - inputs: tuple[sge.Expression, ...] = tuple( - scalar_compiler.scalar_op_compiler.compile_expression( - expression.DerefOp(column) + inputs: tuple[sge.Expression, ...] = tuple( + scalar_compiler.scalar_op_compiler.compile_expression( + expression.DerefOp(column) + ) + for column in cdef.expression.column_references ) - for column in node.expression.column_references - ) - clauses: list[tuple[sge.Expression, sge.Expression]] = [] - if window_spec.min_periods and len(inputs) > 0: - if not node.expression.op.nulls_count_for_min_values: - # Most operations do not count NULL values towards min_periods - not_null_columns = [ - sge.Not(this=sge.Is(this=column, expression=sge.Null())) - for column in inputs - ] - # All inputs must be non-null for observation to count - if not not_null_columns: - is_observation_expr: sge.Expression = sge.convert(True) + clauses: list[tuple[sge.Expression, sge.Expression]] = [] + if window_spec.min_periods and len(inputs) > 0: + if not cdef.expression.op.nulls_count_for_min_values: + # Most operations do not count NULL values towards min_periods + not_null_columns = [ + sge.Not(this=sge.Is(this=column, expression=sge.Null())) + for column in inputs + ] + # All inputs must be non-null for observation to count + if not not_null_columns: + is_observation_expr: sge.Expression = sge.convert(True) + else: + is_observation_expr = not_null_columns[0] + for expr in not_null_columns[1:]: + is_observation_expr = sge.And( + this=is_observation_expr, expression=expr + ) + is_observation = ir._cast(is_observation_expr, "INT64") + observation_count = windows.apply_window_if_present( + sge.func("SUM", is_observation), window_spec + ) else: - is_observation_expr = not_null_columns[0] - for expr in not_null_columns[1:]: - is_observation_expr = sge.And( - this=is_observation_expr, expression=expr - ) - is_observation = ir._cast(is_observation_expr, "INT64") - observation_count = windows.apply_window_if_present( - sge.func("SUM", is_observation), window_spec - ) - else: - # Operations like count treat even NULLs as valid observations - # for the sake of min_periods notnull is just used to convert - # null values to non-null (FALSE) values to be counted. - is_observation = ir._cast( - sge.Not(this=sge.Is(this=inputs[0], expression=sge.Null())), - "INT64", - ) - observation_count = windows.apply_window_if_present( - sge.func("COUNT", is_observation), window_spec - ) - - clauses.append( - ( - observation_count < sge.convert(window_spec.min_periods), - sge.Null(), + # Operations like count treat even NULLs as valid observations + # for the sake of min_periods notnull is just used to convert + # null values to non-null (FALSE) values to be counted. + is_observation = ir._cast( + sge.Not(this=sge.Is(this=inputs[0], expression=sge.Null())), + "INT64", + ) + observation_count = windows.apply_window_if_present( + sge.func("COUNT", is_observation), window_spec + ) + + clauses.append( + ( + observation_count < sge.convert(window_spec.min_periods), + sge.Null(), + ) ) + if clauses: + when_expressions = [sge.When(this=cond, true=res) for cond, res in clauses] + window_op = sge.Case(ifs=when_expressions, default=window_op) + + # TODO: check if we can directly window the expression. + result = child.window( + window_op=window_op, + output_column_id=cdef.id.sql, ) - if clauses: - when_expressions = [sge.When(this=cond, true=res) for cond, res in clauses] - window_op = sge.Case(ifs=when_expressions, default=window_op) - - # TODO: check if we can directly window the expression. - return child.window( - window_op=window_op, - output_column_id=node.output_name.sql, - ) + return result def _replace_unsupported_ops(node: nodes.BigFrameNode): diff --git a/bigframes/core/expression_factoring.py b/bigframes/core/expression_factoring.py index 07d5591bc5..d7ac49b585 100644 --- a/bigframes/core/expression_factoring.py +++ b/bigframes/core/expression_factoring.py @@ -1,33 +1,26 @@ import collections import dataclasses import functools -import itertools -from typing import Generic, Hashable, Iterable, Optional, Sequence, Tuple, TypeVar +from typing import cast, Generic, Hashable, Iterable, Optional, Sequence, Tuple, TypeVar -from bigframes.core import agg_expressions, expression, identifiers, nodes +from bigframes.core import agg_expressions, expression, identifiers, nodes, window_spec _MAX_INLINE_COMPLEXITY = 10 -@dataclasses.dataclass(frozen=True, eq=False) -class NamedExpression: - expr: expression.Expression - name: identifiers.ColumnId - - @dataclasses.dataclass(frozen=True, eq=False) class FactoredExpression: root_expr: expression.Expression - sub_exprs: Tuple[NamedExpression, ...] + sub_exprs: Tuple[nodes.ColumnDef, ...] -def fragmentize_expression(root: NamedExpression) -> Sequence[NamedExpression]: +def fragmentize_expression(root: nodes.ColumnDef) -> Sequence[nodes.ColumnDef]: """ The goal of this functions is to factor out an expression into multiple sub-expressions. """ - factored_expr = root.expr.reduce_up(gather_fragments) - root_expr = NamedExpression(factored_expr.root_expr, root.name) + factored_expr = root.expression.reduce_up(gather_fragments) + root_expr = nodes.ColumnDef(factored_expr.root_expr, root.id) return (root_expr, *factored_expr.sub_exprs) @@ -48,7 +41,7 @@ def gather_fragments( if not do_inline: id = identifiers.ColumnId.unique() replacements.append(expression.DerefOp(id)) - named_exprs.append(NamedExpression(child_result.root_expr, id)) + named_exprs.append(nodes.ColumnDef(child_result.root_expr, id)) named_exprs.extend(child_result.sub_exprs) else: replacements.append(child_result.root_expr) @@ -116,24 +109,34 @@ def remove_node(self, node: T) -> None: def push_into_tree( root: nodes.BigFrameNode, - exprs: Sequence[NamedExpression], + exprs: Sequence[nodes.ColumnDef], target_ids: Sequence[identifiers.ColumnId], ) -> nodes.BigFrameNode: curr_root = root - by_id = {expr.name: expr for expr in exprs} + by_id = {expr.id: expr for expr in exprs} # id -> id graph = DiGraph( - (expr.name, child_id) + (expr.id, child_id) for expr in exprs - for child_id in expr.expr.column_references + for child_id in expr.expression.column_references if child_id in by_id.keys() ) # TODO: Also prevent inlining expensive or non-deterministic # We avoid inlining multi-parent ids, as they would be inlined multiple places, potentially increasing work and/or compiled text size multi_parent_ids = set(id for id in graph.nodes if len(graph.parents(id)) > 2) - scalar_ids = set(expr.name for expr in exprs if expr.expr.is_scalar_expr) + scalar_ids = set(expr.id for expr in exprs if expr.expression.is_scalar_expr) - def graph_extract_scalar_exprs() -> Sequence[NamedExpression]: + analytic_defs = filter( + lambda x: isinstance(x.expression, agg_expressions.WindowExpression), exprs + ) + analytic_by_window = grouped( + map( + lambda x: (cast(agg_expressions.WindowExpression, x.expression).window, x), + analytic_defs, + ) + ) + + def graph_extract_scalar_exprs() -> Sequence[nodes.ColumnDef]: results: dict[identifiers.ColumnId, expression.Expression] = dict() while ( True @@ -156,38 +159,55 @@ def graph_extract_scalar_exprs() -> Sequence[NamedExpression]: for id in candidate_ids: graph.remove_node(id) new_exprs = { - id: by_id[id].expr.bind_refs(results, allow_partial_bindings=True) + id: by_id[id].expression.bind_refs( + results, allow_partial_bindings=True + ) } results.update(new_exprs) # TODO: We can prune expressions that won't be reused here, - return tuple(NamedExpression(expr, id) for id, expr in results.items()) + return tuple(nodes.ColumnDef(expr, id) for id, expr in results.items()) def graph_extract_window_expr() -> Optional[ - Tuple[identifiers.ColumnId, agg_expressions.WindowExpression] + Tuple[Sequence[nodes.ColumnDef], window_spec.WindowSpec] ]: - candidate = list( - itertools.islice((id for id in graph.sinks if id not in scalar_ids), 1) - ) - if not candidate: - return None - else: - id = next(iter(candidate)) - graph.remove_node(id) - result_expr = by_id[id].expr - assert isinstance(result_expr, agg_expressions.WindowExpression) - return (id, result_expr) + for id in graph.sinks: + next_def = by_id[id] + if isinstance(next_def.expression, agg_expressions.WindowExpression): + window = next_def.expression.window + window_exprs = [ + cdef + for cdef in analytic_by_window[window] + if cdef.id in graph.sinks + ] + agg_exprs = tuple( + nodes.ColumnDef( + cast( + agg_expressions.WindowExpression, cdef.expression + ).analytic_expr, + cdef.id, + ) + for cdef in window_exprs + ) + for cdef in window_exprs: + graph.remove_node(cdef.id) + return (agg_exprs, window) + + return None while not graph.empty: pre_size = len(graph.nodes) scalar_exprs = graph_extract_scalar_exprs() if scalar_exprs: curr_root = nodes.ProjectionNode( - curr_root, tuple((x.expr, x.name) for x in scalar_exprs) + curr_root, tuple((x.expression, x.id) for x in scalar_exprs) ) while result := graph_extract_window_expr(): - id, window_expr = result + defs, window = result + assert len(defs) > 0 curr_root = nodes.WindowOpNode( - curr_root, window_expr.analytic_expr, window_expr.window, output_name=id + curr_root, + tuple(defs), + window, ) if len(graph.nodes) >= pre_size: raise ValueError("graph didn't shrink") @@ -208,3 +228,14 @@ def is_simple(expr: expression.Expression) -> bool: if count > _MAX_INLINE_COMPLEXITY: return False return True + + +K = TypeVar("K", bound=Hashable) +V = TypeVar("V") + + +def grouped(values: Iterable[tuple[K, V]]) -> dict[K, list[V]]: + result = collections.defaultdict(list) + for k, v in values: + result[k].append(v) + return result diff --git a/bigframes/core/groupby/dataframe_group_by.py b/bigframes/core/groupby/dataframe_group_by.py index 21f0d7f426..2ec3ce2c96 100644 --- a/bigframes/core/groupby/dataframe_group_by.py +++ b/bigframes/core/groupby/dataframe_group_by.py @@ -434,12 +434,12 @@ def cumcount(self, ascending: bool = True) -> series.Series: grouping_keys=tuple(self._by_col_ids) ) ) - block, result_id = self._block.apply_analytic( - agg_expressions.NullaryAggregation(agg_ops.size_op), + block, result_ids = self._block.apply_analytic( + [agg_expressions.NullaryAggregation(agg_ops.size_op)], window=window_spec, - result_label=None, + result_labels=[None], ) - result = series.Series(block.select_column(result_id)) - 1 + result = series.Series(block.select_columns(result_ids)) - 1 if self._dropna and (len(self._by_col_ids) == 1): result = result.mask( series.Series(block.select_column(self._by_col_ids[0])).isna() diff --git a/bigframes/core/nodes.py b/bigframes/core/nodes.py index e1631c435d..ddccb39ef9 100644 --- a/bigframes/core/nodes.py +++ b/bigframes/core/nodes.py @@ -48,6 +48,12 @@ OVERHEAD_VARIABLES = 5 +@dataclasses.dataclass(frozen=True, eq=True) +class ColumnDef: + expression: ex.Expression + id: identifiers.ColumnId + + class AdditiveNode: """Definition of additive - if you drop added_fields, you end up with the descendent. @@ -1391,21 +1397,23 @@ def remap_refs( @dataclasses.dataclass(frozen=True, eq=False) class WindowOpNode(UnaryNode, AdditiveNode): - expression: agg_expressions.Aggregation + agg_exprs: tuple[ColumnDef, ...] # must be analytic/aggregation op window_spec: window.WindowSpec - output_name: identifiers.ColumnId - skip_reproject_unsafe: bool = False def _validate(self): """Validate the local data in the node.""" # Since inner order and row bounds are coupled, rank ops can't be row bounded - assert ( - not self.window_spec.is_row_bounded - ) or self.expression.op.implicitly_inherits_order - assert all(ref in self.child.ids for ref in self.expression.column_references) - assert self.added_field.dtype is not None - for agg_child in self.expression.children: - assert agg_child.is_scalar_expr + for cdef in self.agg_exprs: + assert isinstance(cdef.expression, agg_expressions.Aggregation) + if self.window_spec.is_row_bounded: + assert cdef.expression.op.implicitly_inherits_order + for agg_child in cdef.expression.children: + assert agg_child.is_scalar_expr + for ref in cdef.expression.column_references: + assert ref in self.child.ids + + assert not any(field.dtype is None for field in self.added_fields) + for window_expr in self.window_spec.expressions: assert window_expr.is_scalar_expr @@ -1415,7 +1423,7 @@ def non_local(self) -> bool: @property def fields(self) -> Sequence[Field]: - return sequences.ChainedSequence(self.child.fields, (self.added_field,)) + return sequences.ChainedSequence(self.child.fields, self.added_fields) @property def variables_introduced(self) -> int: @@ -1423,49 +1431,54 @@ def variables_introduced(self) -> int: @property def added_fields(self) -> Tuple[Field, ...]: - return (self.added_field,) + return tuple( + Field( + cdef.id, + ex.bind_schema_fields( + cdef.expression, self.child.field_by_id + ).output_type, + ) + for cdef in self.agg_exprs + ) @property def relation_ops_created(self) -> int: - # Assume that if not reprojecting, that there is a sequence of window operations sharing the same window - return 0 if self.skip_reproject_unsafe else 4 + return 2 @property def row_count(self) -> Optional[int]: return self.child.row_count - @functools.cached_property - def added_field(self) -> Field: - # TODO: Determine if output could be non-null - return Field( - self.output_name, - ex.bind_schema_fields(self.expression, self.child.field_by_id).output_type, - ) - @property def node_defined_ids(self) -> Tuple[identifiers.ColumnId, ...]: - return (self.output_name,) + return tuple(field.id for field in self.added_fields) @property def consumed_ids(self) -> COLUMN_SET: - return frozenset( - set(self.ids).difference([self.output_name]).union(self.referenced_ids) - ) + return frozenset(self.ids) @property def referenced_ids(self) -> COLUMN_SET: + ids_for_aggs = itertools.chain.from_iterable( + cdef.expression.column_references for cdef in self.agg_exprs + ) return ( frozenset() - .union(self.expression.column_references) + .union(ids_for_aggs) .union(self.window_spec.all_referenced_columns) ) @property def inherits_order(self) -> bool: # does the op both use ordering at all? and if so, can it inherit order? - op_inherits_order = ( - not self.expression.op.order_independent - ) and self.expression.op.implicitly_inherits_order + aggs = ( + typing.cast(agg_expressions.Aggregation, cdef.expression) + for cdef in self.agg_exprs + ) + op_inherits_order = any( + not agg.op.order_independent and agg.op.implicitly_inherits_order + for agg in aggs + ) # range-bounded windows do not inherit orders because their ordering are # already defined before rewrite time. return op_inherits_order or self.window_spec.is_row_bounded @@ -1476,7 +1489,10 @@ def additive_base(self) -> BigFrameNode: @property def _node_expressions(self): - return (self.expression, *self.window_spec.expressions) + return ( + *(cdef.expression for cdef in self.agg_exprs), + *self.window_spec.expressions, + ) def replace_additive_base(self, node: BigFrameNode) -> WindowOpNode: return dataclasses.replace(self, child=node) @@ -1485,7 +1501,11 @@ def remap_vars( self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] ) -> WindowOpNode: return dataclasses.replace( - self, output_name=mappings.get(self.output_name, self.output_name) + self, + agg_exprs=tuple( + ColumnDef(cdef.expression, mappings.get(cdef.id, cdef.id)) + for cdef in self.agg_exprs + ), ) def remap_refs( @@ -1493,8 +1513,14 @@ def remap_refs( ) -> WindowOpNode: return dataclasses.replace( self, - expression=self.expression.remap_column_refs( - mappings, allow_partial_bindings=True + agg_exprs=tuple( + ColumnDef( + cdef.expression.remap_column_refs( + mappings, allow_partial_bindings=True + ), + cdef.id, + ) + for cdef in self.agg_exprs ), window_spec=self.window_spec.remap_column_refs( mappings, allow_partial_bindings=True diff --git a/bigframes/core/rewrite/order.py b/bigframes/core/rewrite/order.py index 881badd603..6741dfddad 100644 --- a/bigframes/core/rewrite/order.py +++ b/bigframes/core/rewrite/order.py @@ -168,11 +168,12 @@ def pull_up_order_inner( else: # Otherwise we need to generate offsets agg = agg_expressions.NullaryAggregation(agg_ops.RowNumberOp()) + col_def = bigframes.core.nodes.ColumnDef(agg, node.col_id) window_spec = bigframes.core.window_spec.unbound( ordering=tuple(child_order.all_ordering_columns) ) new_offsets_node = bigframes.core.nodes.WindowOpNode( - child_result, agg, window_spec, node.col_id + child_result, (col_def,), window_spec ) return ( new_offsets_node, @@ -289,8 +290,9 @@ def pull_order_concat( window_spec = bigframes.core.window_spec.unbound( ordering=tuple(order.all_ordering_columns) ) + col_def = bigframes.core.nodes.ColumnDef(agg, offsets_id) new_source = bigframes.core.nodes.WindowOpNode( - new_source, agg, window_spec, offsets_id + new_source, (col_def,), window_spec ) new_source = bigframes.core.nodes.ProjectionNode( new_source, ((bigframes.core.expression.const(i), table_id),) @@ -421,7 +423,9 @@ def rewrite_promote_offsets( ) -> bigframes.core.nodes.WindowOpNode: agg = agg_expressions.NullaryAggregation(agg_ops.RowNumberOp()) window_spec = bigframes.core.window_spec.unbound() - return bigframes.core.nodes.WindowOpNode(node.child, agg, window_spec, node.col_id) + return bigframes.core.nodes.WindowOpNode( + node.child, (bigframes.core.nodes.ColumnDef(agg, node.col_id),), window_spec + ) def rename_cols( diff --git a/bigframes/core/rewrite/schema_binding.py b/bigframes/core/rewrite/schema_binding.py index fe9143baf2..d874c7c598 100644 --- a/bigframes/core/rewrite/schema_binding.py +++ b/bigframes/core/rewrite/schema_binding.py @@ -107,7 +107,13 @@ def bind_schema_to_node( ) return dataclasses.replace( node, - expression=_bind_schema_to_aggregation_expr(node.expression, node.child), + agg_exprs=tuple( + nodes.ColumnDef( + _bind_schema_to_aggregation_expr(cdef.expression, node.child), # type: ignore + cdef.id, + ) + for cdef in node.agg_exprs + ), window_spec=window_spec, ) diff --git a/bigframes/core/rewrite/timedeltas.py b/bigframes/core/rewrite/timedeltas.py index 5c7a85ee1b..7190810f71 100644 --- a/bigframes/core/rewrite/timedeltas.py +++ b/bigframes/core/rewrite/timedeltas.py @@ -64,10 +64,13 @@ def rewrite_timedelta_expressions(root: nodes.BigFrameNode) -> nodes.BigFrameNod if isinstance(root, nodes.WindowOpNode): return nodes.WindowOpNode( root.child, - _rewrite_aggregation(root.expression, root.schema), + tuple( + nodes.ColumnDef( + _rewrite_aggregation(cdef.expression, root.schema), cdef.id + ) + for cdef in root.agg_exprs + ), root.window_spec, - root.output_name, - root.skip_reproject_unsafe, ) if isinstance(root, nodes.AggregateNode): diff --git a/tests/system/small/engines/test_windowing.py b/tests/system/small/engines/test_windowing.py index 510a2de3ba..5e4a94d900 100644 --- a/tests/system/small/engines/test_windowing.py +++ b/tests/system/small/engines/test_windowing.py @@ -54,12 +54,13 @@ def test_engines_with_rows_window( ) window_node = nodes.WindowOpNode( child=scalars_array_value.node, - expression=agg_expressions.UnaryAggregation( - agg_op, expression.deref("int64_too") + agg_exprs=( + nodes.ColumnDef( + agg_expressions.UnaryAggregation(agg_op, expression.deref("int64_too")), + identifiers.ColumnId("agg_int64"), + ), ), window_spec=window, - output_name=identifiers.ColumnId("agg_int64"), - skip_reproject_unsafe=False, ) publisher = events.Publisher() diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_nullary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_nullary_compiler.py index 2348b95496..f9ddf3e0c0 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_nullary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_nullary_compiler.py @@ -46,9 +46,8 @@ def _apply_nullary_window_op( ) -> str: win_node = nodes.WindowOpNode( obj._block.expr.node, - expression=op, + agg_exprs=(nodes.ColumnDef(op, identifiers.ColumnId(new_name)),), window_spec=window_spec, - output_name=identifiers.ColumnId(new_name), ) result = array_value.ArrayValue(win_node).select_columns([new_name]) diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index ab9f7febbf..ed5f1835cc 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -54,9 +54,8 @@ def _apply_unary_window_op( ) -> str: win_node = nodes.WindowOpNode( obj._block.expr.node, - expression=op, + agg_exprs=(nodes.ColumnDef(op, identifiers.ColumnId(new_name)),), window_spec=window_spec, - output_name=identifiers.ColumnId(new_name), ) result = array_value.ArrayValue(win_node).select_columns([new_name]) diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql index 11e3f4773e..b1d498bc76 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql @@ -12,39 +12,13 @@ WITH `bfcte_0` AS ( `int64_col` AS `bfcol_8`, `bool_col` AS `bfcol_9` FROM `bfcte_0` -), `bfcte_2` AS ( +), `bfcte_3` AS ( SELECT * FROM `bfcte_1` WHERE NOT `bfcol_9` IS NULL -), `bfcte_3` AS ( - SELECT - *, - CASE - WHEN SUM(CAST(NOT `bfcol_7` IS NULL AS INT64)) OVER ( - PARTITION BY `bfcol_9` - ORDER BY `bfcol_9` ASC NULLS LAST, `rowindex` ASC NULLS LAST - ROWS BETWEEN 3 PRECEDING AND CURRENT ROW - ) < 3 - THEN NULL - ELSE COALESCE( - SUM(CAST(`bfcol_7` AS INT64)) OVER ( - PARTITION BY `bfcol_9` - ORDER BY `bfcol_9` ASC NULLS LAST, `rowindex` ASC NULLS LAST - ROWS BETWEEN 3 PRECEDING AND CURRENT ROW - ), - 0 - ) - END AS `bfcol_15` - FROM `bfcte_2` ), `bfcte_4` AS ( - SELECT - * - FROM `bfcte_3` - WHERE - NOT `bfcol_9` IS NULL -), `bfcte_5` AS ( SELECT *, CASE @@ -62,15 +36,15 @@ WITH `bfcte_0` AS ( ), 0 ) - END AS `bfcol_21` - FROM `bfcte_4` + END AS `bfcol_16` + FROM `bfcte_3` ) SELECT `bfcol_9` AS `bool_col`, `bfcol_6` AS `rowindex`, `bfcol_15` AS `bool_col_1`, - `bfcol_21` AS `int64_col` -FROM `bfcte_5` + `bfcol_16` AS `int64_col` +FROM `bfcte_4` ORDER BY `bfcol_9` ASC NULLS LAST, `rowindex` ASC NULLS LAST \ No newline at end of file From f4a7206ed03c4f1ef7bbac79e6a38ebff48f7b63 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Fri, 21 Nov 2025 15:06:40 -0800 Subject: [PATCH 251/313] =?UTF-8?q?Fix:=20Anywidget=20Table=20Pagination?= =?UTF-8?q?=20for=20Small=20DataFrames=20&=20Improve=20Displa=E2=80=A6=20(?= =?UTF-8?q?#2287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This pull request addresses a pagination display bug in the `anywidget` table where a small DataFrame (e.g., 5 rows) would incorrectly show "Page 1 of 5" instead of "Page 1 of 1". * **Fixed `table_widget.js` pagination logic:** Corrected the JavaScript to accurately calculate total pages, ensuring "Page 1 of 1" is displayed for datasets smaller than the page size. * **Added comprehensive system test:** Enhanced `test_anywidget.py` by improving the `test_widget_with_few_rows_should_have_only_one_page` test. This test now explicitly asserts the correct `row_count` and verifies that page navigation is correctly clamped to the first page, thus confirming the backend conditions for the "Page 1 of 1" frontend display. Fixes # 🦕 --- bigframes/display/__init__.py | 2 + bigframes/display/anywidget.py | 2 + bigframes/display/table_widget.js | 2 +- notebooks/dataframes/anywidget_mode.ipynb | 99 ++--------------------- tests/system/small/test_anywidget.py | 57 ++++++++++--- 5 files changed, 59 insertions(+), 103 deletions(-) diff --git a/bigframes/display/__init__.py b/bigframes/display/__init__.py index 48e52bc766..97248a0efb 100644 --- a/bigframes/display/__init__.py +++ b/bigframes/display/__init__.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Interactive display objects for BigQuery DataFrames.""" + from __future__ import annotations try: diff --git a/bigframes/display/anywidget.py b/bigframes/display/anywidget.py index d61c4691c8..4c0b9d64ee 100644 --- a/bigframes/display/anywidget.py +++ b/bigframes/display/anywidget.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Interactive, paginated table widget for BigFrames DataFrames.""" + from __future__ import annotations from importlib import resources diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js index fab8b54efb..c1df0a2927 100644 --- a/bigframes/display/table_widget.js +++ b/bigframes/display/table_widget.js @@ -96,7 +96,7 @@ function render({ model, el }) { // Known total rows const totalPages = Math.ceil(rowCount / pageSize); rowCountLabel.textContent = `${rowCount.toLocaleString()} total rows`; - paginationLabel.textContent = `Page ${(currentPage + 1).toLocaleString()} of ${rowCount.toLocaleString()}`; + paginationLabel.textContent = `Page ${(currentPage + 1).toLocaleString()} of ${totalPages.toLocaleString()}`; prevPage.disabled = currentPage === 0; nextPage.disabled = currentPage >= totalPages - 1; } diff --git a/notebooks/dataframes/anywidget_mode.ipynb b/notebooks/dataframes/anywidget_mode.ipynb index f7a4b0e2d6..e810377dd7 100644 --- a/notebooks/dataframes/anywidget_mode.ipynb +++ b/notebooks/dataframes/anywidget_mode.ipynb @@ -142,7 +142,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "8fcad7b7e408422cae71d519cd2d4980", + "model_id": "388323f1f2394f44a7199e7ecda7b5d2", "version_major": 2, "version_minor": 1 }, @@ -166,7 +166,7 @@ } ], "source": [ - "df.set_index(\"name\")" + "df" ] }, { @@ -205,7 +205,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "06cb98c577514d5c9654a7792d93f8e6", + "model_id": "24847f9deca94a2abb4fa1a64ec8b616", "version_major": 2, "version_minor": 1 }, @@ -305,7 +305,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "1672f826f7a347e38539dbb5fb72cd43", + "model_id": "8b8a0b2a3572442f8718baa08657bef6", "version_major": 2, "version_minor": 1 }, @@ -345,7 +345,7 @@ "data": { "text/html": [ "✅ Completed. \n", - " Query processed 85.9 kB in 12 seconds of slot time.\n", + " Query processed 85.9 kB in 15 seconds of slot time.\n", " " ], "text/plain": [ @@ -380,7 +380,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "127a2e356b834c18b6f07c58ee2c4228", + "model_id": "1327a3901c194bf3a03f7f3a3358dc19", "version_major": 2, "version_minor": 1 }, @@ -415,93 +415,6 @@ " LIMIT 5;\n", "\"\"\")" ] - }, - { - "cell_type": "markdown", - "id": "multi-index-display-markdown", - "metadata": {}, - "source": [ - "## Display Multi-Index DataFrame in anywidget mode\n", - "This section demonstrates how BigFrames can display a DataFrame with multiple levels of indexing (a \"multi-index\") when using the `anywidget` display mode." - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "ad7482aa", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "✅ Completed. \n", - " Query processed 483.3 GB in 51 minutes of slot time. [Job bigframes-dev:US.3eace7c0-7776-48d6-925c-965be33d8738 details]\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "✅ Completed. \n", - " Query processed 124.4 MB in 7 seconds of slot time. [Job bigframes-dev:US.job_UJ5cx4R1jW5cNxq_1H1x-9-ATfqS details]\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "3f9652b5fdc0441eac2b05ab36d571d0", - "version_major": 2, - "version_minor": 1 - }, - "text/plain": [ - "TableWidget(page_size=10, row_count=3967869, table_html='
seven_days_ago]\n", - " \n", - "# Create a multi-index by grouping by date and project\n", - "pypi_df_recent['date'] = pypi_df_recent['timestamp'].dt.date\n", - "multi_index_df = pypi_df_recent.groupby([\"date\", \"project\"]).size().to_frame(\"downloads\")\n", - " \n", - "# Display the DataFrame with the multi-index\n", - "multi_index_df" - ] } ], "metadata": { diff --git a/tests/system/small/test_anywidget.py b/tests/system/small/test_anywidget.py index 99734dc30c..c7b957891b 100644 --- a/tests/system/small/test_anywidget.py +++ b/tests/system/small/test_anywidget.py @@ -13,6 +13,9 @@ # limitations under the License. +from typing import Any +from unittest import mock + import pandas as pd import pytest @@ -98,6 +101,33 @@ def small_widget(small_bf_df): yield TableWidget(small_bf_df) +@pytest.fixture +def unknown_row_count_widget(session): + """Fixture to create a TableWidget with an unknown row count.""" + from bigframes.core import blocks + from bigframes.display import TableWidget + + # Create a small DataFrame with known content + test_data = pd.DataFrame( + { + "id": [0, 1, 2, 3, 4], + "value": ["row_0", "row_1", "row_2", "row_3", "row_4"], + } + ) + bf_df = session.read_pandas(test_data) + + # Simulate a scenario where total_rows is not available from the iterator + with mock.patch.object(bf_df, "_to_pandas_batches") as mock_batches: + # We need to provide an iterator of DataFrames, not Series + batches_iterator = iter([test_data]) + mock_batches.return_value = blocks.PandasBatches( + batches_iterator, total_rows=None + ) + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): + widget = TableWidget(bf_df) + yield widget + + @pytest.fixture(scope="module") def empty_pandas_df() -> pd.DataFrame: """Create an empty DataFrame for edge case testing.""" @@ -129,7 +159,7 @@ def execution_metadata(self) -> ExecutionMetadata: return ExecutionMetadata() @property - def schema(self): + def schema(self) -> Any: return schema def batches(self) -> ResultsIterator: @@ -180,7 +210,9 @@ def test_widget_initialization_should_calculate_total_row_count( def test_widget_initialization_should_set_default_pagination( table_widget, ): - """A TableWidget should initialize with page 0 and the correct page size.""" + """ + A TableWidget should initialize with page 0 and the correct page size. + """ # The `table_widget` fixture already creates the widget. # Assert its state. assert table_widget.page == 0 @@ -304,15 +336,20 @@ def test_widget_with_few_rows_should_display_all_rows(small_widget, small_pandas def test_widget_with_few_rows_should_have_only_one_page(small_widget): """ - Given a DataFrame smaller than the page size, the widget should - clamp page navigation, effectively having only one page. + Given a DataFrame with a small number of rows, the widget should + report the correct total row count and prevent navigation beyond + the first page, ensuring the frontend correctly displays "Page 1 of 1". """ + # For a DataFrame with 2 rows and page_size 5 (from small_widget fixture), + # the frontend should calculate 1 total page. + assert small_widget.row_count == 2 + + # The widget should always be on page 0 for a single-page dataset. assert small_widget.page == 0 - # Attempt to navigate past the end + # Attempting to navigate to page 1 should be clamped back to page 0, + # confirming that only one page is recognized by the backend. small_widget.page = 1 - - # Should be clamped back to the only valid page assert small_widget.page == 0 @@ -420,8 +457,10 @@ def test_navigation_after_page_size_change_should_use_new_size( @pytest.mark.parametrize("invalid_size", [0, -5], ids=["zero", "negative"]) def test_setting_invalid_page_size_should_be_ignored(table_widget, invalid_size: int): - """When the page size is set to an invalid number (<=0), the change should - be ignored.""" + """ + When the page size is set to an invalid number (<=0), the change should + be ignored. + """ # Set the initial page to 2. initial_size = table_widget.page_size assert initial_size == 2 From 0cb52171c10f70a1c4be77db12a3d954c721fdb0 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Mon, 24 Nov 2025 10:01:48 -0800 Subject: [PATCH 252/313] refactor: add agg_ops.QcutOp to the sqlglot compiler (#2269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes internal issue 445774480 🦕 --- .../sqlglot/aggregations/unary_compiler.py | 37 +++++++++++ .../compile/sqlglot/aggregations/windows.py | 7 ++- bigframes/core/compile/sqlglot/sqlglot_ir.py | 7 ++- .../test_unary_compiler/test_qcut/out.sql | 61 +++++++++++++++++++ .../aggregations/test_unary_compiler.py | 21 +++++++ 5 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_qcut/out.sql diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py index e2bd6b8382..14da8dd555 100644 --- a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -400,6 +400,43 @@ def _( return apply_window_if_present(expr, window) +@UNARY_OP_REGISTRATION.register(agg_ops.QcutOp) +def _( + op: agg_ops.QcutOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + percent_ranks_order_by = sge.Ordered(this=column.expr, desc=False) + percent_ranks = apply_window_if_present( + sge.func("PERCENT_RANK"), + window, + include_framing_clauses=False, + order_by_override=[percent_ranks_order_by], + ) + if isinstance(op.quantiles, int): + scaled_rank = percent_ranks * sge.convert(op.quantiles) + # Calculate the 0-based bucket index. + bucket_index = sge.func("CEIL", scaled_rank) - sge.convert(1) + safe_bucket_index = sge.func("GREATEST", bucket_index, 0) + + return sge.If( + this=sge.Is(this=column.expr, expression=sge.Null()), + true=sge.Null(), + false=sge.Cast(this=safe_bucket_index, to="INT64"), + ) + else: + case = sge.Case() + first_quantile = sge.convert(op.quantiles[0]) + case = case.when( + sge.LT(this=percent_ranks, expression=first_quantile), sge.Null() + ) + for bucket_n in range(len(op.quantiles) - 1): + quantile = sge.convert(op.quantiles[bucket_n + 1]) + bucket = sge.convert(bucket_n) + case = case.when(sge.LTE(this=percent_ranks, expression=quantile), bucket) + return case.else_(sge.Null()) + + @UNARY_OP_REGISTRATION.register(agg_ops.QuantileOp) def _( op: agg_ops.QuantileOp, diff --git a/bigframes/core/compile/sqlglot/aggregations/windows.py b/bigframes/core/compile/sqlglot/aggregations/windows.py index 099f5832da..b775d6666a 100644 --- a/bigframes/core/compile/sqlglot/aggregations/windows.py +++ b/bigframes/core/compile/sqlglot/aggregations/windows.py @@ -26,6 +26,7 @@ def apply_window_if_present( value: sge.Expression, window: typing.Optional[window_spec.WindowSpec] = None, include_framing_clauses: bool = True, + order_by_override: typing.Optional[typing.List[sge.Ordered]] = None, ) -> sge.Expression: if window is None: return value @@ -44,7 +45,11 @@ def apply_window_if_present( else: order_by = get_window_order_by(window.ordering) - order = sge.Order(expressions=order_by) if order_by else None + order = None + if order_by_override is not None and len(order_by_override) > 0: + order = sge.Order(expressions=order_by_override) + elif order_by: + order = sge.Order(expressions=order_by) group_by = ( [ diff --git a/bigframes/core/compile/sqlglot/sqlglot_ir.py b/bigframes/core/compile/sqlglot/sqlglot_ir.py index 3473968450..fd3bdd532f 100644 --- a/bigframes/core/compile/sqlglot/sqlglot_ir.py +++ b/bigframes/core/compile/sqlglot/sqlglot_ir.py @@ -637,7 +637,12 @@ def _select_to_cte(expr: sge.Select, cte_name: sge.Identifier) -> sge.Select: def _literal(value: typing.Any, dtype: dtypes.Dtype) -> sge.Expression: - sqlglot_type = sgt.from_bigframes_dtype(dtype) + sqlglot_type = sgt.from_bigframes_dtype(dtype) if dtype else None + if sqlglot_type is None: + if value is not None: + raise ValueError("Cannot infer SQLGlot type from None dtype.") + return sge.Null() + if value is None: return _cast(sge.Null(), sqlglot_type) elif dtype == dtypes.BYTES_DTYPE: diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_qcut/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_qcut/out.sql new file mode 100644 index 0000000000..1aa2e436ca --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_qcut/out.sql @@ -0,0 +1,61 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + NOT `int64_col` IS NULL AS `bfcol_4` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + IF( + `int64_col` IS NULL, + NULL, + CAST(GREATEST( + CEIL(PERCENT_RANK() OVER (PARTITION BY `bfcol_4` ORDER BY `int64_col` ASC) * 4) - 1, + 0 + ) AS INT64) + ) AS `bfcol_5` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + IF(`bfcol_4`, `bfcol_5`, NULL) AS `bfcol_6` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + NOT `int64_col` IS NULL AS `bfcol_10` + FROM `bfcte_3` +), `bfcte_5` AS ( + SELECT + *, + CASE + WHEN PERCENT_RANK() OVER (PARTITION BY `bfcol_10` ORDER BY `int64_col` ASC) < 0 + THEN NULL + WHEN PERCENT_RANK() OVER (PARTITION BY `bfcol_10` ORDER BY `int64_col` ASC) <= 0.25 + THEN 0 + WHEN PERCENT_RANK() OVER (PARTITION BY `bfcol_10` ORDER BY `int64_col` ASC) <= 0.5 + THEN 1 + WHEN PERCENT_RANK() OVER (PARTITION BY `bfcol_10` ORDER BY `int64_col` ASC) <= 0.75 + THEN 2 + WHEN PERCENT_RANK() OVER (PARTITION BY `bfcol_10` ORDER BY `int64_col` ASC) <= 1 + THEN 3 + ELSE NULL + END AS `bfcol_11` + FROM `bfcte_4` +), `bfcte_6` AS ( + SELECT + *, + IF(`bfcol_10`, `bfcol_11`, NULL) AS `bfcol_12` + FROM `bfcte_5` +) +SELECT + `rowindex`, + `int64_col`, + `bfcol_6` AS `qcut_w_int`, + `bfcol_12` AS `qcut_w_list` +FROM `bfcte_6` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index ed5f1835cc..428c76cbb4 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -434,6 +434,27 @@ def test_pop_var(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql_window, "window_out.sql") +def test_qcut(scalar_types_df: bpd.DataFrame, snapshot): + if sys.version_info < (3, 12): + pytest.skip( + "Skipping test due to inconsistent SQL formatting on Python < 3.12.", + ) + + col_name = "int64_col" + bf = scalar_types_df[[col_name]] + bf["qcut_w_int"] = bpd.qcut(bf[col_name], q=4, labels=False, duplicates="drop") + + q_list = tuple([0, 0.25, 0.5, 0.75, 1]) + bf["qcut_w_list"] = bpd.qcut( + scalar_types_df[col_name], + q=q_list, + labels=False, + duplicates="drop", + ) + + snapshot.assert_match(bf.sql, "out.sql") + + def test_quantile(scalar_types_df: bpd.DataFrame, snapshot): col_name = "int64_col" bf_df = scalar_types_df[[col_name]] From b487cf1f6ecacb1ee3b35ffdd934221516bbd558 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Mon, 24 Nov 2025 14:40:24 -0800 Subject: [PATCH 253/313] feat: add bigquery.json_keys (#2286) --- bigframes/bigquery/__init__.py | 2 + bigframes/bigquery/_operations/json.py | 29 +++++++++++ .../ibis_compiler/scalar_op_registry.py | 13 +++++ .../compile/sqlglot/expressions/json_ops.py | 5 ++ bigframes/operations/__init__.py | 2 + bigframes/operations/json_ops.py | 17 +++++++ tests/system/small/bigquery/test_json.py | 50 +++++++++++++++++++ .../test_json_ops/test_json_keys/out.sql | 15 ++++++ .../sqlglot/expressions/test_json_ops.py | 13 +++++ 9 files changed, 146 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_keys/out.sql diff --git a/bigframes/bigquery/__init__.py b/bigframes/bigquery/__init__.py index 2edd3d71e9..feabf703bc 100644 --- a/bigframes/bigquery/__init__.py +++ b/bigframes/bigquery/__init__.py @@ -47,6 +47,7 @@ json_extract, json_extract_array, json_extract_string_array, + json_keys, json_query, json_query_array, json_set, @@ -138,6 +139,7 @@ "json_extract", "json_extract_array", "json_extract_string_array", + "json_keys", "json_query", "json_query_array", "json_set", diff --git a/bigframes/bigquery/_operations/json.py b/bigframes/bigquery/_operations/json.py index 4e1f43aab0..0fc184b2fc 100644 --- a/bigframes/bigquery/_operations/json.py +++ b/bigframes/bigquery/_operations/json.py @@ -421,6 +421,35 @@ def json_value_array( return input._apply_unary_op(ops.JSONValueArray(json_path=json_path)) +def json_keys( + input: series.Series, + max_depth: Optional[int] = None, +) -> series.Series: + """Returns all keys in the root of a JSON object as an ARRAY of STRINGs. + + **Examples:** + + >>> import bigframes.pandas as bpd + >>> import bigframes.bigquery as bbq + + >>> s = bpd.Series(['{"b": {"c": 2}, "a": 1}'], dtype="json") + >>> bbq.json_keys(s) + 0 ['a' 'b' 'b.c'] + dtype: list[pyarrow] + + Args: + input (bigframes.series.Series): + The Series containing JSON data. + max_depth (int, optional): + Specifies the maximum depth of nested fields to search for keys. If not + provided, searched keys at all levels. + + Returns: + bigframes.series.Series: A new Series containing arrays of keys from the input JSON. + """ + return input._apply_unary_op(ops.JSONKeys(max_depth=max_depth)) + + def to_json( input: series.Series, ) -> series.Series: diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index 259e99866b..91bbfbfbcf 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -1234,6 +1234,11 @@ def json_value_array_op_impl(x: ibis_types.Value, op: ops.JSONValueArray): return json_value_array(json_obj=x, json_path=op.json_path) +@scalar_op_compiler.register_unary_op(ops.JSONKeys, pass_op=True) +def json_keys_op_impl(x: ibis_types.Value, op: ops.JSONKeys): + return json_keys(x, op.max_depth) + + # Blob Ops @scalar_op_compiler.register_unary_op(ops.obj_fetch_metadata_op) def obj_fetch_metadata_op_impl(obj_ref: ibis_types.Value): @@ -2059,6 +2064,14 @@ def to_json_string(value) -> ibis_dtypes.String: # type: ignore[empty-body] """Convert value to JSON-formatted string.""" +@ibis_udf.scalar.builtin(name="json_keys") +def json_keys( # type: ignore[empty-body] + json_obj: ibis_dtypes.JSON, + max_depth: ibis_dtypes.Int64, +) -> ibis_dtypes.Array[ibis_dtypes.String]: + """Extracts unique JSON keys from a JSON expression.""" + + @ibis_udf.scalar.builtin(name="json_value") def json_value( # type: ignore[empty-body] json_obj: ibis_dtypes.JSON, json_path: ibis_dtypes.String diff --git a/bigframes/core/compile/sqlglot/expressions/json_ops.py b/bigframes/core/compile/sqlglot/expressions/json_ops.py index 442eb9fdf5..ef55f6edac 100644 --- a/bigframes/core/compile/sqlglot/expressions/json_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/json_ops.py @@ -39,6 +39,11 @@ def _(expr: TypedExpr, op: ops.JSONExtractStringArray) -> sge.Expression: return sge.func("JSON_EXTRACT_STRING_ARRAY", expr.expr, sge.convert(op.json_path)) +@register_unary_op(ops.JSONKeys, pass_op=True) +def _(expr: TypedExpr, op: ops.JSONKeys) -> sge.Expression: + return sge.func("JSON_KEYS", expr.expr, sge.convert(op.max_depth)) + + @register_unary_op(ops.JSONQuery, pass_op=True) def _(expr: TypedExpr, op: ops.JSONQuery) -> sge.Expression: return sge.func("JSON_QUERY", expr.expr, sge.convert(op.json_path)) diff --git a/bigframes/operations/__init__.py b/bigframes/operations/__init__.py index 2a0beb3fb3..5da8efaa3b 100644 --- a/bigframes/operations/__init__.py +++ b/bigframes/operations/__init__.py @@ -128,6 +128,7 @@ JSONExtract, JSONExtractArray, JSONExtractStringArray, + JSONKeys, JSONQuery, JSONQueryArray, JSONSet, @@ -381,6 +382,7 @@ "JSONExtract", "JSONExtractArray", "JSONExtractStringArray", + "JSONKeys", "JSONQuery", "JSONQueryArray", "JSONSet", diff --git a/bigframes/operations/json_ops.py b/bigframes/operations/json_ops.py index 487c193cc5..7260a79223 100644 --- a/bigframes/operations/json_ops.py +++ b/bigframes/operations/json_ops.py @@ -199,6 +199,23 @@ def output_type(self, *input_types): return input_type +@dataclasses.dataclass(frozen=True) +class JSONKeys(base_ops.UnaryOp): + name: typing.ClassVar[str] = "json_keys" + max_depth: typing.Optional[int] = None + + def output_type(self, *input_types): + input_type = input_types[0] + if input_type != dtypes.JSON_DTYPE: + raise TypeError( + "Input type must be a valid JSON object or JSON-formatted string type." + + f" Received type: {input_type}" + ) + return pd.ArrowDtype( + pa.list_(dtypes.bigframes_dtype_to_arrow_dtype(dtypes.STRING_DTYPE)) + ) + + @dataclasses.dataclass(frozen=True) class JSONDecode(base_ops.UnaryOp): name: typing.ClassVar[str] = "json_decode" diff --git a/tests/system/small/bigquery/test_json.py b/tests/system/small/bigquery/test_json.py index 5a44c75f17..d2ebb73972 100644 --- a/tests/system/small/bigquery/test_json.py +++ b/tests/system/small/bigquery/test_json.py @@ -434,3 +434,53 @@ def test_to_json_string_from_struct(): ) pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_json_keys(): + json_data = [ + '{"name": "Alice", "age": 30}', + '{"city": "New York", "country": "USA", "active": true}', + "{}", + '{"items": [1, 2, 3]}', + ] + s = bpd.Series(json_data, dtype=dtypes.JSON_DTYPE) + actual = bbq.json_keys(s) + + expected_data_pandas = [ + ["age", "name"], + [ + "active", + "city", + "country", + ], + [], + ["items"], + ] + expected = bpd.Series( + expected_data_pandas, dtype=pd.ArrowDtype(pa.list_(pa.string())) + ) + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_json_keys_with_max_depth(): + json_data = [ + '{"user": {"name": "Bob", "details": {"id": 123, "status": "approved"}}}', + '{"user": {"name": "Charlie"}}', + ] + s = bpd.Series(json_data, dtype=dtypes.JSON_DTYPE) + actual = bbq.json_keys(s, max_depth=2) + + expected_data_pandas = [ + ["user", "user.details", "user.name"], + ["user", "user.name"], + ] + expected = bpd.Series( + expected_data_pandas, dtype=pd.ArrowDtype(pa.list_(pa.string())) + ) + pd.testing.assert_series_equal(actual.to_pandas(), expected.to_pandas()) + + +def test_json_keys_from_string_error(): + s = bpd.Series(['{"a": 1, "b": 2}', '{"c": 3}']) + with pytest.raises(TypeError): + bbq.json_keys(s) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_keys/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_keys/out.sql new file mode 100644 index 0000000000..640f933bb2 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_keys/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + JSON_KEYS(`json_col`, NULL) AS `bfcol_1`, + JSON_KEYS(`json_col`, 2) AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `json_keys`, + `bfcol_2` AS `json_keys_w_max_depth` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py index ca0896bd03..4ae3eb3fcc 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py @@ -52,6 +52,19 @@ def test_json_extract_string_array(json_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_json_keys(json_types_df: bpd.DataFrame, snapshot): + col_name = "json_col" + bf_df = json_types_df[[col_name]] + + ops_map = { + "json_keys": ops.JSONKeys().as_expr(col_name), + "json_keys_w_max_depth": ops.JSONKeys(max_depth=2).as_expr(col_name), + } + + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + def test_json_query(json_types_df: bpd.DataFrame, snapshot): col_name = "json_col" bf_df = json_types_df[[col_name]] From 32757619a40ab2ecfd4336c38b64efe64ec764f6 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Tue, 25 Nov 2025 10:22:49 -0800 Subject: [PATCH 254/313] refactor: switch RowNumber to 0-based indexing in sqlglot compiler (#2292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change aims to fix the tests failing in #2248 because of a 1-based indexing error. Fixes internal issue 417774347 🦕 --- .../core/compile/sqlglot/aggregations/nullary_compiler.py | 4 ++-- .../test_nullary_compiler/test_row_number/out.sql | 2 +- .../test_row_number_with_window/out.sql | 2 +- .../test_compile_concat/test_compile_concat/out.sql | 4 ++-- .../test_compile_concat_filter_sorted/out.sql | 8 ++++---- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bigframes/core/compile/sqlglot/aggregations/nullary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/nullary_compiler.py index 95dad4ff3b..a582a9d4c5 100644 --- a/bigframes/core/compile/sqlglot/aggregations/nullary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/nullary_compiler.py @@ -49,5 +49,5 @@ def _( result: sge.Expression = sge.func("ROW_NUMBER") if window is None: # ROW_NUMBER always needs an OVER clause. - return sge.Window(this=result) - return apply_window_if_present(result, window, include_framing_clauses=False) + return sge.Window(this=result) - 1 + return apply_window_if_present(result, window, include_framing_clauses=False) - 1 diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql index 78cc44fa54..f1197465f0 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql @@ -19,7 +19,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - ROW_NUMBER() OVER () AS `bfcol_32` + ROW_NUMBER() OVER () - 1 AS `bfcol_32` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql index b63cb1ff61..bfa67b8a74 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - ROW_NUMBER() OVER (ORDER BY `int64_col` ASC NULLS LAST) AS `bfcol_1` + ROW_NUMBER() OVER (ORDER BY `int64_col` ASC NULLS LAST) - 1 AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat/out.sql index f606de4ed3..a0d7db2b1a 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat/out.sql @@ -7,7 +7,7 @@ WITH `bfcte_1` AS ( ), `bfcte_3` AS ( SELECT *, - ROW_NUMBER() OVER () AS `bfcol_7` + ROW_NUMBER() OVER () - 1 AS `bfcol_7` FROM `bfcte_1` ), `bfcte_5` AS ( SELECT @@ -32,7 +32,7 @@ WITH `bfcte_1` AS ( ), `bfcte_2` AS ( SELECT *, - ROW_NUMBER() OVER () AS `bfcol_22` + ROW_NUMBER() OVER () - 1 AS `bfcol_22` FROM `bfcte_0` ), `bfcte_4` AS ( SELECT diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql index a67d1943a2..8e65381fef 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql @@ -6,7 +6,7 @@ WITH `bfcte_2` AS ( ), `bfcte_6` AS ( SELECT *, - ROW_NUMBER() OVER (ORDER BY `int64_col` ASC NULLS LAST) AS `bfcol_4` + ROW_NUMBER() OVER (ORDER BY `int64_col` ASC NULLS LAST) - 1 AS `bfcol_4` FROM `bfcte_2` ), `bfcte_10` AS ( SELECT @@ -35,7 +35,7 @@ WITH `bfcte_2` AS ( ), `bfcte_8` AS ( SELECT *, - ROW_NUMBER() OVER () AS `bfcol_15` + ROW_NUMBER() OVER () - 1 AS `bfcol_15` FROM `bfcte_4` ), `bfcte_12` AS ( SELECT @@ -57,7 +57,7 @@ WITH `bfcte_2` AS ( ), `bfcte_5` AS ( SELECT *, - ROW_NUMBER() OVER (ORDER BY `int64_col` ASC NULLS LAST) AS `bfcol_25` + ROW_NUMBER() OVER (ORDER BY `int64_col` ASC NULLS LAST) - 1 AS `bfcol_25` FROM `bfcte_1` ), `bfcte_9` AS ( SELECT @@ -86,7 +86,7 @@ WITH `bfcte_2` AS ( ), `bfcte_7` AS ( SELECT *, - ROW_NUMBER() OVER () AS `bfcol_36` + ROW_NUMBER() OVER () - 1 AS `bfcol_36` FROM `bfcte_3` ), `bfcte_11` AS ( SELECT From ed7914611a7de5116a2c77fe1a419ee5afe413d2 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Tue, 25 Nov 2025 11:28:24 -0800 Subject: [PATCH 255/313] Chore: Migrate unsafe_pow_op operator to SQLGlot (#2281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes b/447388852 🦕 --- .../sqlglot/expressions/numeric_ops.py | 8 ++++ .../test_unsafe_pow_op/out.sql | 43 +++++++++++++++++++ .../sqlglot/expressions/test_numeric_ops.py | 33 ++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_unsafe_pow_op/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py index c022356fd3..83b29f67df 100644 --- a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py @@ -443,6 +443,14 @@ def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: ) +@register_binary_op(ops.unsafe_pow_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + """For internal use only - where domain and overflow checks are not needed.""" + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + return sge.Pow(this=left_expr, expression=right_expr) + + @register_unary_op(numeric_ops.isnan_op) def isnan(arg: TypedExpr) -> sge.Expression: return sge.IsNan(this=arg.expr) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_unsafe_pow_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_unsafe_pow_op/out.sql new file mode 100644 index 0000000000..9957a34665 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_unsafe_pow_op/out.sql @@ -0,0 +1,43 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col`, + `float64_col`, + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bool_col` AS `bfcol_3`, + `int64_col` AS `bfcol_4`, + `float64_col` AS `bfcol_5`, + ( + `int64_col` >= 0 + ) AND ( + `int64_col` <= 10 + ) AS `bfcol_6` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + * + FROM `bfcte_1` + WHERE + `bfcol_6` +), `bfcte_3` AS ( + SELECT + *, + POWER(`bfcol_4`, `bfcol_4`) AS `bfcol_14`, + POWER(`bfcol_4`, `bfcol_5`) AS `bfcol_15`, + POWER(`bfcol_5`, `bfcol_4`) AS `bfcol_16`, + POWER(`bfcol_5`, `bfcol_5`) AS `bfcol_17`, + POWER(`bfcol_4`, CAST(`bfcol_3` AS INT64)) AS `bfcol_18`, + POWER(CAST(`bfcol_3` AS INT64), `bfcol_4`) AS `bfcol_19` + FROM `bfcte_2` +) +SELECT + `bfcol_14` AS `int_pow_int`, + `bfcol_15` AS `int_pow_float`, + `bfcol_16` AS `float_pow_int`, + `bfcol_17` AS `float_pow_float`, + `bfcol_18` AS `int_pow_bool`, + `bfcol_19` AS `bool_pow_int` +FROM `bfcte_3` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py index 5d3b23ebb7..0b4f8fbe70 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py @@ -438,3 +438,36 @@ def test_sub_unsupported_raises(scalar_types_df: bpd.DataFrame): with pytest.raises(TypeError): utils._apply_binary_op(scalar_types_df, ops.sub_op, "int64_col", "string_col") + + +def test_unsafe_pow_op(scalar_types_df: bpd.DataFrame, snapshot): + # Choose certain row so the sql execution won't fail even with unsafe_pow_op. + bf_df = scalar_types_df[ + (scalar_types_df["int64_col"] >= 0) & (scalar_types_df["int64_col"] <= 10) + ] + bf_df = bf_df[["int64_col", "float64_col", "bool_col"]] + + int64_col_id = bf_df["int64_col"]._value_column + float64_col_id = bf_df["float64_col"]._value_column + bool_col_id = bf_df["bool_col"]._value_column + + sql = utils._apply_ops_to_sql( + bf_df, + [ + ops.unsafe_pow_op.as_expr(int64_col_id, int64_col_id), + ops.unsafe_pow_op.as_expr(int64_col_id, float64_col_id), + ops.unsafe_pow_op.as_expr(float64_col_id, int64_col_id), + ops.unsafe_pow_op.as_expr(float64_col_id, float64_col_id), + ops.unsafe_pow_op.as_expr(int64_col_id, bool_col_id), + ops.unsafe_pow_op.as_expr(bool_col_id, int64_col_id), + ], + [ + "int_pow_int", + "int_pow_float", + "float_pow_int", + "float_pow_float", + "int_pow_bool", + "bool_pow_int", + ], + ) + snapshot.assert_match(sql, "out.sql") From 482bd0e1a72c2ba1775ebb5648ea2f9183c10dc5 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Tue, 25 Nov 2025 13:05:44 -0800 Subject: [PATCH 256/313] refactor: add agg_ops.ProductOp and NuniqueOp to the sqlglot compiler (#2289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes internal issue 445774480 🦕 --- .../sqlglot/aggregations/unary_compiler.py | 63 +++++++++++++++++++ .../test_unary_compiler/test_nunique/out.sql | 12 ++++ .../test_unary_compiler/test_product/out.sql | 16 +++++ .../test_product/window_partition_out.sql | 27 ++++++++ .../aggregations/test_unary_compiler.py | 28 +++++++++ 5 files changed, 146 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_nunique/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_product/out.sql create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_product/window_partition_out.sql diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py index 14da8dd555..171c3cc239 100644 --- a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -386,6 +386,17 @@ def _( return apply_window_if_present(sge.func("MIN", column.expr), window) +@UNARY_OP_REGISTRATION.register(agg_ops.NuniqueOp) +def _( + op: agg_ops.NuniqueOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + return apply_window_if_present( + sge.func("COUNT", sge.Distinct(expressions=[column.expr])), window + ) + + @UNARY_OP_REGISTRATION.register(agg_ops.PopVarOp) def _( op: agg_ops.PopVarOp, @@ -400,6 +411,58 @@ def _( return apply_window_if_present(expr, window) +@UNARY_OP_REGISTRATION.register(agg_ops.ProductOp) +def _( + op: agg_ops.ProductOp, + column: typed_expr.TypedExpr, + window: typing.Optional[window_spec.WindowSpec] = None, +) -> sge.Expression: + # Need to short-circuit as log with zeroes is illegal sql + is_zero = sge.EQ(this=column.expr, expression=sge.convert(0)) + + # There is no product sql aggregate function, so must implement as a sum of logs, and then + # apply power after. Note, log and power base must be equal! This impl uses natural log. + logs = ( + sge.Case() + .when(is_zero, sge.convert(0)) + .else_(sge.func("LN", sge.func("ABS", column.expr))) + ) + logs_sum = apply_window_if_present(sge.func("SUM", logs), window) + magnitude = sge.func("EXP", logs_sum) + + # Can't determine sign from logs, so have to determine parity of count of negative inputs + is_negative = ( + sge.Case() + .when( + sge.LT(this=sge.func("SIGN", column.expr), expression=sge.convert(0)), + sge.convert(1), + ) + .else_(sge.convert(0)) + ) + negative_count = apply_window_if_present(sge.func("SUM", is_negative), window) + negative_count_parity = sge.Mod( + this=negative_count, expression=sge.convert(2) + ) # 1 if result should be negative, otherwise 0 + + any_zeroes = apply_window_if_present(sge.func("LOGICAL_OR", is_zero), window) + + float_result = ( + sge.Case() + .when(any_zeroes, sge.convert(0)) + .else_( + sge.Mul( + this=magnitude, + expression=sge.If( + this=sge.EQ(this=negative_count_parity, expression=sge.convert(1)), + true=sge.convert(-1), + false=sge.convert(1), + ), + ) + ) + ) + return float_result + + @UNARY_OP_REGISTRATION.register(agg_ops.QcutOp) def _( op: agg_ops.QcutOp, diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_nunique/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_nunique/out.sql new file mode 100644 index 0000000000..f0b54934b4 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_nunique/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + COUNT(DISTINCT `int64_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_product/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_product/out.sql new file mode 100644 index 0000000000..bec1527137 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_product/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + CASE + WHEN LOGICAL_OR(`int64_col` = 0) + THEN 0 + ELSE EXP(SUM(CASE WHEN `int64_col` = 0 THEN 0 ELSE LN(ABS(`int64_col`)) END)) * IF(MOD(SUM(CASE WHEN SIGN(`int64_col`) < 0 THEN 1 ELSE 0 END), 2) = 1, -1, 1) + END AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_product/window_partition_out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_product/window_partition_out.sql new file mode 100644 index 0000000000..9c1650222a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_product/window_partition_out.sql @@ -0,0 +1,27 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN LOGICAL_OR(`int64_col` = 0) OVER (PARTITION BY `string_col`) + THEN 0 + ELSE EXP( + SUM(CASE WHEN `int64_col` = 0 THEN 0 ELSE LN(ABS(`int64_col`)) END) OVER (PARTITION BY `string_col`) + ) * IF( + MOD( + SUM(CASE WHEN SIGN(`int64_col`) < 0 THEN 1 ELSE 0 END) OVER (PARTITION BY `string_col`), + 2 + ) = 1, + -1, + 1 + ) + END AS `bfcol_2` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `agg_int64` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index 428c76cbb4..5f7d0d7653 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -412,6 +412,15 @@ def test_min(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql_window_partition, "window_partition_out.sql") +def test_nunique(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.NuniqueOp().as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + def test_pop_var(scalar_types_df: bpd.DataFrame, snapshot): col_names = ["int64_col", "bool_col"] bf_df = scalar_types_df[col_names] @@ -434,6 +443,25 @@ def test_pop_var(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql_window, "window_out.sql") +def test_product(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "int64_col" + bf_df = scalar_types_df[[col_name]] + agg_expr = agg_ops.ProductOp().as_expr(col_name) + sql = _apply_unary_agg_ops(bf_df, [agg_expr], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + bf_df_str = scalar_types_df[[col_name, "string_col"]] + window_partition = window_spec.WindowSpec( + grouping_keys=(expression.deref("string_col"),), + ) + sql_window_partition = _apply_unary_window_op( + bf_df_str, agg_expr, window_partition, "agg_int64" + ) + + snapshot.assert_match(sql_window_partition, "window_partition_out.sql") + + def test_qcut(scalar_types_df: bpd.DataFrame, snapshot): if sys.version_info < (3, 12): pytest.skip( From da064397acd2358c16fdd9659edf23afde5c882a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Tue, 25 Nov 2025 16:44:18 -0600 Subject: [PATCH 257/313] docs: update API reference to new `dataframes.bigquery.dev` location (#2293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also: - include link to `bigframes.bigquery.ai` in README - add partial ordering mode recommendation to starter sample - remove 2.0 warning Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Towards b/454350869 🦕 --- README.rst | 58 +++++++++++++++++------------------------------------- 1 file changed, 18 insertions(+), 40 deletions(-) diff --git a/README.rst b/README.rst index 84de370652..281f764094 100644 --- a/README.rst +++ b/README.rst @@ -6,15 +6,25 @@ BigQuery DataFrames (BigFrames) |GA| |pypi| |versions| BigQuery DataFrames (also known as BigFrames) provides a Pythonic DataFrame -and machine learning (ML) API powered by the BigQuery engine. +and machine learning (ML) API powered by the BigQuery engine. It provides modules +for many use cases, including: -* `bigframes.pandas` provides a pandas API for analytics. Many workloads can be +* `bigframes.pandas `_ + is a pandas API for analytics. Many workloads can be migrated from pandas to bigframes by just changing a few imports. -* ``bigframes.ml`` provides a scikit-learn-like API for ML. +* `bigframes.ml `_ + is a scikit-learn-like API for ML. +* `bigframes.bigquery.ai `_ + are a collection of powerful AI methods, powered by Gemini. -BigQuery DataFrames is an open-source package. +BigQuery DataFrames is an `open-source package `_. -**Version 2.0 introduces breaking changes for improved security and performance. See below for details.** +.. |GA| image:: https://img.shields.io/badge/support-GA-gold.svg + :target: https://github.com/googleapis/google-cloud-python/blob/main/README.rst#general-availability +.. |pypi| image:: https://img.shields.io/pypi/v/bigframes.svg + :target: https://pypi.org/project/bigframes/ +.. |versions| image:: https://img.shields.io/pypi/pyversions/bigframes.svg + :target: https://pypi.org/project/bigframes/ Getting started with BigQuery DataFrames ---------------------------------------- @@ -38,7 +48,8 @@ To use BigFrames in your local development environment, import bigframes.pandas as bpd - bpd.options.bigquery.project = your_gcp_project_id + bpd.options.bigquery.project = your_gcp_project_id # Optional in BQ Studio. + bpd.options.bigquery.ordering_mode = "partial" # Recommended for performance. df = bpd.read_gbq("bigquery-public-data.usa_names.usa_1910_2013") print( df.groupby("name") @@ -48,7 +59,6 @@ To use BigFrames in your local development environment, .to_pandas() ) - Documentation ------------- @@ -56,41 +66,9 @@ To learn more about BigQuery DataFrames, visit these pages * `Introduction to BigQuery DataFrames (BigFrames) `_ * `Sample notebooks `_ -* `API reference `_ +* `API reference `_ * `Source code (GitHub) `_ -⚠️ Warning: Breaking Changes in BigQuery DataFrames v2.0 --------------------------------------------------------- - -Version 2.0 introduces breaking changes for improved security and performance. Key default behaviors have changed, including - -* **Large Results (>10GB):** The default value for ``allow_large_results`` has changed to ``False``. - Methods like ``to_pandas()`` will now fail if the query result's compressed data size exceeds 10GB, - unless large results are explicitly permitted. -* **Remote Function Security:** The library no longer automatically lets the Compute Engine default service - account become the identity of the Cloud Run functions. If that is desired, it has to be indicated by passing - ``cloud_function_service_account="default"``. And network ingress now defaults to ``"internal-only"``. -* **@remote_function Argument Passing:** Arguments other than ``input_types``, ``output_type``, and ``dataset`` - to ``remote_function`` must now be passed using keyword syntax, as positional arguments are no longer supported. -* **@udf Argument Passing:** Arguments ``dataset`` and ``name`` to ``udf`` are now mandatory. -* **Endpoint Connections:** Automatic fallback to locational endpoints in certain regions is removed. -* **LLM Updates (Gemini Integration):** Integrations now default to the ``gemini-2.0-flash-001`` model. - PaLM2 support has been removed; please migrate any existing PaLM2 usage to Gemini. **Note:** The current default - model will be removed in Version 3.0. - -**Important:** If you are not ready to adapt to these changes, please pin your dependency to a version less than 2.0 -(e.g., ``bigframes==1.42.0``) to avoid disruption. - -To learn about these changes and how to migrate to version 2.0, see the -`updated introduction guide `_. - -.. |GA| image:: https://img.shields.io/badge/support-GA-gold.svg - :target: https://github.com/googleapis/google-cloud-python/blob/main/README.rst#general-availability -.. |pypi| image:: https://img.shields.io/pypi/v/bigframes.svg - :target: https://pypi.org/project/bigframes/ -.. |versions| image:: https://img.shields.io/pypi/pyversions/bigframes.svg - :target: https://pypi.org/project/bigframes/ - License ------- From 32e531343c764156b45c6fb9de49793d26c19f02 Mon Sep 17 00:00:00 2001 From: Garrett Wu <6505921+GarrettWu@users.noreply.github.com> Date: Tue, 25 Nov 2025 15:39:54 -0800 Subject: [PATCH 258/313] docs: fix LogisticRegression docs rendering (#2295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes # 🦕 --- .../sklearn/linear_model/_logistic.py | 60 ++++++++++--------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/third_party/bigframes_vendored/sklearn/linear_model/_logistic.py b/third_party/bigframes_vendored/sklearn/linear_model/_logistic.py index d449a1040c..1efece251f 100644 --- a/third_party/bigframes_vendored/sklearn/linear_model/_logistic.py +++ b/third_party/bigframes_vendored/sklearn/linear_model/_logistic.py @@ -23,35 +23,37 @@ class LogisticRegression(LinearClassifierMixin, BaseEstimator): """Logistic Regression (aka logit, MaxEnt) classifier. - >>> from bigframes.ml.linear_model import LogisticRegression - >>> import bigframes.pandas as bpd - >>> X = bpd.DataFrame({ \ - "feature0": [20, 21, 19, 18], \ - "feature1": [0, 1, 1, 0], \ - "feature2": [0.2, 0.3, 0.4, 0.5]}) - >>> y = bpd.DataFrame({"outcome": [0, 0, 1, 1]}) - >>> # Create the LogisticRegression - >>> model = LogisticRegression() - >>> model.fit(X, y) - LogisticRegression() - >>> model.predict(X) # doctest:+SKIP - predicted_outcome predicted_outcome_probs feature0 feature1 feature2 - 0 0 [{'label': 1, 'prob': 3.1895929877221615e-07} ... 20 0 0.2 - 1 0 [{'label': 1, 'prob': 5.662891265051953e-06} ... 21 1 0.3 - 2 1 [{'label': 1, 'prob': 0.9999917826885262} {'l... 19 1 0.4 - 3 1 [{'label': 1, 'prob': 0.9999999993659574} {'l... 18 0 0.5 - 4 rows × 5 columns - - [4 rows x 5 columns in total] - - >>> # Score the model - >>> score = model.score(X, y) - >>> score # doctest:+SKIP - precision recall accuracy f1_score log_loss roc_auc - 0 1.0 1.0 1.0 1.0 0.000004 1.0 - 1 rows × 6 columns - - [1 rows x 6 columns in total] + **Examples:** + + >>> from bigframes.ml.linear_model import LogisticRegression + >>> import bigframes.pandas as bpd + >>> X = bpd.DataFrame({ \ + "feature0": [20, 21, 19, 18], \ + "feature1": [0, 1, 1, 0], \ + "feature2": [0.2, 0.3, 0.4, 0.5]}) + >>> y = bpd.DataFrame({"outcome": [0, 0, 1, 1]}) + >>> # Create the LogisticRegression + >>> model = LogisticRegression() + >>> model.fit(X, y) + LogisticRegression() + >>> model.predict(X) # doctest:+SKIP + predicted_outcome predicted_outcome_probs feature0 feature1 feature2 + 0 0 [{'label': 1, 'prob': 3.1895929877221615e-07} ... 20 0 0.2 + 1 0 [{'label': 1, 'prob': 5.662891265051953e-06} ... 21 1 0.3 + 2 1 [{'label': 1, 'prob': 0.9999917826885262} {'l... 19 1 0.4 + 3 1 [{'label': 1, 'prob': 0.9999999993659574} {'l... 18 0 0.5 + 4 rows × 5 columns + + [4 rows x 5 columns in total] + + >>> # Score the model + >>> score = model.score(X, y) + >>> score # doctest:+SKIP + precision recall accuracy f1_score log_loss roc_auc + 0 1.0 1.0 1.0 1.0 0.000004 1.0 + 1 rows × 6 columns + + [1 rows x 6 columns in total] Args: optimize_strategy (str, default "auto_strategy"): From d1ecc61bf448651a0cca0fc760673da54f5c2183 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Tue, 25 Nov 2025 19:33:32 -0800 Subject: [PATCH 259/313] feat: Implement single-column sorting for interactive table widget (#2255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces single-column sorting functionality to the interactive table widget. 1) **Three-State Sorting UI** 1.1) The sort indicator dot (●) is now hidden by default and only appears when the user hovers the mouse over a column header 1.2) Implemented a sorting cycle: unsorted (●) → ascending (▲) → descending (▼) → unsorted (●). 1.3) Visual indicators (●, ▲, ▼) are displayed in column headers to reflect the current sort state. 1.4) Sorting controls are now only enabled for columns with orderable data types. 2) **Tests** 2.1) Updated `paginated_pandas_df` fixture for better sorting test coverage 2.2) Added new system tests to verify ascending, descending, and multi-column sorting. **3. Frontend Unit Tests** JavaScript-level unit tests have been added to validate the widget's frontend logic, specifically the new sorting functionality and UI interactions. **How to Run Frontend Unit Tests**: To execute these tests from the project root directory: ```bash cd tests/js npm install # Only needed if dependencies haven't been installed or have changed npm test ``` Docs has been updated to document the new features. The main description now mentions column sorting and adjustable widths, and a new section has been added to explain how to use the column resizing feature. The sorting section was also updated to mention that the indicators are only visible on hover. Fixes #<459835971> 🦕 --------- Co-authored-by: Tim Sweña (Swast) --- .github/workflows/js-tests.yml | 20 + .gitignore | 1 + bigframes/display/anywidget.py | 64 +- bigframes/display/html.py | 18 +- bigframes/display/table_widget.css | 38 +- bigframes/display/table_widget.js | 188 +- notebooks/dataframes/anywidget_mode.ipynb | 100 +- tests/js/babel.config.cjs | 19 + tests/js/jest.config.cjs | 27 + tests/js/jest.setup.js | 20 + tests/js/package-lock.json | 6530 +++++++++++++++++++++ tests/js/package.json | 19 + tests/js/table_widget.test.js | 209 + tests/system/small/test_anywidget.py | 160 +- 14 files changed, 7329 insertions(+), 84 deletions(-) create mode 100644 .github/workflows/js-tests.yml create mode 100644 tests/js/babel.config.cjs create mode 100644 tests/js/jest.config.cjs create mode 100644 tests/js/jest.setup.js create mode 100644 tests/js/package-lock.json create mode 100644 tests/js/package.json create mode 100644 tests/js/table_widget.test.js diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml new file mode 100644 index 0000000000..588aa854f3 --- /dev/null +++ b/.github/workflows/js-tests.yml @@ -0,0 +1,20 @@ +name: js-tests +on: + pull_request: + branches: + - main + push: + branches: + - main +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install modules + working-directory: ./tests/js + run: npm install + - name: Run tests + working-directory: ./tests/js + run: npm test diff --git a/.gitignore b/.gitignore index 63a3e53971..0ff74ef528 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ coverage.xml # System test environment variables. system_tests/local_test_setup +tests/js/node_modules/ # Make sure a generated file isn't accidentally committed. pylintrc diff --git a/bigframes/display/anywidget.py b/bigframes/display/anywidget.py index 4c0b9d64ee..2c93e437fa 100644 --- a/bigframes/display/anywidget.py +++ b/bigframes/display/anywidget.py @@ -16,6 +16,7 @@ from __future__ import annotations +import dataclasses from importlib import resources import functools import math @@ -28,6 +29,7 @@ from bigframes.core import blocks import bigframes.dataframe import bigframes.display.html +import bigframes.dtypes as dtypes # anywidget and traitlets are optional dependencies. We don't want the import of # this module to fail if they aren't installed, though. Instead, we try to @@ -48,6 +50,12 @@ WIDGET_BASE = object +@dataclasses.dataclass(frozen=True) +class _SortState: + column: str + ascending: bool + + class TableWidget(WIDGET_BASE): """An interactive, paginated table widget for BigFrames DataFrames. @@ -63,6 +71,9 @@ class TableWidget(WIDGET_BASE): allow_none=True, ).tag(sync=True) table_html = traitlets.Unicode().tag(sync=True) + sort_column = traitlets.Unicode("").tag(sync=True) + sort_ascending = traitlets.Bool(True).tag(sync=True) + orderable_columns = traitlets.List(traitlets.Unicode(), []).tag(sync=True) _initial_load_complete = traitlets.Bool(False).tag(sync=True) _batches: Optional[blocks.PandasBatches] = None _error_message = traitlets.Unicode(allow_none=True, default_value=None).tag( @@ -89,15 +100,25 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): self._all_data_loaded = False self._batch_iter: Optional[Iterator[pd.DataFrame]] = None self._cached_batches: List[pd.DataFrame] = [] + self._last_sort_state: Optional[_SortState] = None # respect display options for initial page size initial_page_size = bigframes.options.display.max_rows # set traitlets properties that trigger observers + # TODO(b/462525985): Investigate and improve TableWidget UX for DataFrames with a large number of columns. self.page_size = initial_page_size + # TODO(b/463754889): Support non-string column labels for sorting. + if all(isinstance(col, str) for col in dataframe.columns): + self.orderable_columns = [ + str(col_name) + for col_name, dtype in dataframe.dtypes.items() + if dtypes.is_orderable(dtype) + ] + else: + self.orderable_columns = [] - # len(dataframe) is expensive, since it will trigger a - # SELECT COUNT(*) query. It is a must have however. + # obtain the row counts # TODO(b/428238610): Start iterating over the result of `to_pandas_batches()` # before we get here so that the count might already be cached. self._reset_batches_for_new_page_size() @@ -121,6 +142,11 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): # Also used as a guard to prevent observers from firing during initialization. self._initial_load_complete = True + @traitlets.observe("_initial_load_complete") + def _on_initial_load_complete(self, change: Dict[str, Any]): + if change["new"]: + self._set_table_html() + @functools.cached_property def _esm(self): """Load JavaScript code from external file.""" @@ -221,13 +247,17 @@ def _cached_data(self) -> pd.DataFrame: return pd.DataFrame(columns=self._dataframe.columns) return pd.concat(self._cached_batches, ignore_index=True) + def _reset_batch_cache(self) -> None: + """Resets batch caching attributes.""" + self._cached_batches = [] + self._batch_iter = None + self._all_data_loaded = False + def _reset_batches_for_new_page_size(self) -> None: """Reset the batch iterator when page size changes.""" self._batches = self._dataframe._to_pandas_batches(page_size=self.page_size) - self._cached_batches = [] - self._batch_iter = None - self._all_data_loaded = False + self._reset_batch_cache() def _set_table_html(self) -> None: """Sets the current html data based on the current page and page size.""" @@ -237,6 +267,21 @@ def _set_table_html(self) -> None: ) return + # Apply sorting if a column is selected + df_to_display = self._dataframe + if self.sort_column: + # TODO(b/463715504): Support sorting by index columns. + df_to_display = df_to_display.sort_values( + by=self.sort_column, ascending=self.sort_ascending + ) + + # Reset batches when sorting changes + if self._last_sort_state != _SortState(self.sort_column, self.sort_ascending): + self._batches = df_to_display._to_pandas_batches(page_size=self.page_size) + self._reset_batch_cache() + self._last_sort_state = _SortState(self.sort_column, self.sort_ascending) + self.page = 0 # Reset to first page + start = self.page * self.page_size end = start + self.page_size @@ -272,8 +317,14 @@ def _set_table_html(self) -> None: self.table_html = bigframes.display.html.render_html( dataframe=page_data, table_id=f"table-{self._table_id}", + orderable_columns=self.orderable_columns, ) + @traitlets.observe("sort_column", "sort_ascending") + def _sort_changed(self, _change: Dict[str, Any]): + """Handler for when sorting parameters change from the frontend.""" + self._set_table_html() + @traitlets.observe("page") def _page_changed(self, _change: Dict[str, Any]) -> None: """Handler for when the page number is changed from the frontend.""" @@ -288,6 +339,9 @@ def _page_size_changed(self, _change: Dict[str, Any]) -> None: return # Reset the page to 0 when page size changes to avoid invalid page states self.page = 0 + # Reset the sort state to default (no sort) + self.sort_column = "" + self.sort_ascending = True # Reset batches to use new page size for future data fetching self._reset_batches_for_new_page_size() diff --git a/bigframes/display/html.py b/bigframes/display/html.py index f1133789b4..101bd296f1 100644 --- a/bigframes/display/html.py +++ b/bigframes/display/html.py @@ -17,6 +17,7 @@ from __future__ import annotations import html +from typing import Any import pandas as pd import pandas.api.types @@ -24,7 +25,7 @@ from bigframes._config import options -def _is_dtype_numeric(dtype) -> bool: +def _is_dtype_numeric(dtype: Any) -> bool: """Check if a dtype is numeric for alignment purposes.""" return pandas.api.types.is_numeric_dtype(dtype) @@ -33,18 +34,31 @@ def render_html( *, dataframe: pd.DataFrame, table_id: str, + orderable_columns: list[str] | None = None, ) -> str: """Render a pandas DataFrame to HTML with specific styling.""" classes = "dataframe table table-striped table-hover" table_html = [f'
'] precision = options.display.precision + orderable_columns = orderable_columns or [] # Render table head table_html.append(" ") table_html.append(' ') for col in dataframe.columns: + th_classes = [] + if col in orderable_columns: + th_classes.append("sortable") + class_str = f'class="{" ".join(th_classes)}"' if th_classes else "" + header_div = ( + '
' + f"{html.escape(str(col))}" + "
" + ) table_html.append( - f' ' + f' ' ) table_html.append(" ") table_html.append(" ") diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css index 9ae1e6fcf6..dcef55cae1 100644 --- a/bigframes/display/table_widget.css +++ b/bigframes/display/table_widget.css @@ -28,7 +28,10 @@ align-items: center; display: flex; font-size: 0.8rem; - padding-top: 8px; + justify-content: space-between; + padding: 8px; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } .bigframes-widget .footer > * { @@ -44,6 +47,14 @@ padding: 4px; } +.bigframes-widget .page-indicator { + margin: 0 8px; +} + +.bigframes-widget .row-count { + margin: 0 8px; +} + .bigframes-widget .page-size { align-items: center; display: flex; @@ -52,6 +63,10 @@ justify-content: end; } +.bigframes-widget .page-size label { + margin-right: 8px; +} + .bigframes-widget table { border-collapse: collapse; text-align: left; @@ -59,12 +74,20 @@ .bigframes-widget th { background-color: var(--colab-primary-surface-color, var(--jp-layout-color0)); - /* Uncomment once we support sorting: cursor: pointer; */ position: sticky; top: 0; z-index: 1; } +.bigframes-widget th .sort-indicator { + padding-left: 4px; + visibility: hidden; +} + +.bigframes-widget th:hover .sort-indicator { + visibility: visible; +} + .bigframes-widget button { cursor: pointer; display: inline-block; @@ -78,3 +101,14 @@ opacity: 0.65; pointer-events: none; } + +.bigframes-widget .error-message { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 14px; + padding: 8px; + margin-bottom: 8px; + border: 1px solid red; + border-radius: 4px; + background-color: #ffebee; +} diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js index c1df0a2927..4db109cec6 100644 --- a/bigframes/display/table_widget.js +++ b/bigframes/display/table_widget.js @@ -1,4 +1,4 @@ -/** +/* * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -19,111 +19,109 @@ const ModelProperty = { PAGE_SIZE: "page_size", ROW_COUNT: "row_count", TABLE_HTML: "table_html", + SORT_COLUMN: "sort_column", + SORT_ASCENDING: "sort_ascending", + ERROR_MESSAGE: "error_message", + ORDERABLE_COLUMNS: "orderable_columns", }; const Event = { - CHANGE: "change", - CHANGE_TABLE_HTML: `change:${ModelProperty.TABLE_HTML}`, CLICK: "click", + CHANGE: "change", + CHANGE_TABLE_HTML: "change:table_html", }; /** * Renders the interactive table widget. - * @param {{ - * model: any, - * el: HTMLElement - * }} options + * @param {{ model: any, el: HTMLElement }} props - The widget properties. + * @param {Document} doc - The document object to use for creating elements. */ function render({ model, el }) { // Main container with a unique class for CSS scoping el.classList.add("bigframes-widget"); - // Structure - const tableContainer = document.createElement("div"); - const footer = document.createElement("div"); + // Add error message container at the top + const errorContainer = document.createElement("div"); + errorContainer.classList.add("error-message"); - // Footer: Total rows label - const rowCountLabel = document.createElement("div"); + const tableContainer = document.createElement("div"); + tableContainer.classList.add("table-container"); + const footer = document.createElement("footer"); + footer.classList.add("footer"); - // Footer: Pagination controls + // Pagination controls const paginationContainer = document.createElement("div"); + paginationContainer.classList.add("pagination"); const prevPage = document.createElement("button"); - const paginationLabel = document.createElement("span"); + const pageIndicator = document.createElement("span"); + pageIndicator.classList.add("page-indicator"); const nextPage = document.createElement("button"); + const rowCountLabel = document.createElement("span"); + rowCountLabel.classList.add("row-count"); - // Footer: Page size controls + // Page size controls const pageSizeContainer = document.createElement("div"); - const pageSizeLabel = document.createElement("label"); - const pageSizeSelect = document.createElement("select"); - - // Add CSS classes - tableContainer.classList.add("table-container"); - footer.classList.add("footer"); - paginationContainer.classList.add("pagination"); pageSizeContainer.classList.add("page-size"); + const pageSizeLabel = document.createElement("label"); + const pageSizeInput = document.createElement("select"); - // Configure pagination buttons - prevPage.type = "button"; - nextPage.type = "button"; - prevPage.textContent = "Prev"; - nextPage.textContent = "Next"; + prevPage.textContent = "<"; + nextPage.textContent = ">"; + pageSizeLabel.textContent = "Page size:"; - // Configure page size selector - pageSizeLabel.textContent = "Page Size"; - for (const size of [10, 25, 50, 100]) { + // Page size options + const pageSizes = [10, 25, 50, 100]; + for (const size of pageSizes) { const option = document.createElement("option"); option.value = size; option.textContent = size; if (size === model.get(ModelProperty.PAGE_SIZE)) { option.selected = true; } - pageSizeSelect.appendChild(option); + pageSizeInput.appendChild(option); } /** Updates the footer states and page label based on the model. */ function updateButtonStates() { - const rowCount = model.get(ModelProperty.ROW_COUNT); - const pageSize = model.get(ModelProperty.PAGE_SIZE); const currentPage = model.get(ModelProperty.PAGE); + const pageSize = model.get(ModelProperty.PAGE_SIZE); + const rowCount = model.get(ModelProperty.ROW_COUNT); if (rowCount === null) { // Unknown total rows rowCountLabel.textContent = "Total rows unknown"; - paginationLabel.textContent = `Page ${(currentPage + 1).toLocaleString()} of many`; + pageIndicator.textContent = `Page ${(currentPage + 1).toLocaleString()} of many`; prevPage.disabled = currentPage === 0; nextPage.disabled = false; // Allow navigation until we hit the end } else { // Known total rows const totalPages = Math.ceil(rowCount / pageSize); rowCountLabel.textContent = `${rowCount.toLocaleString()} total rows`; - paginationLabel.textContent = `Page ${(currentPage + 1).toLocaleString()} of ${totalPages.toLocaleString()}`; + pageIndicator.textContent = `Page ${(currentPage + 1).toLocaleString()} of ${totalPages.toLocaleString()}`; prevPage.disabled = currentPage === 0; nextPage.disabled = currentPage >= totalPages - 1; } - pageSizeSelect.value = pageSize; + pageSizeInput.value = pageSize; } /** - * Increments or decrements the page in the model. - * @param {number} direction - `1` for next, `-1` for previous. + * Handles page navigation. + * @param {number} direction - The direction to navigate (-1 for previous, 1 for next). */ function handlePageChange(direction) { - const current = model.get(ModelProperty.PAGE); - const next = current + direction; - model.set(ModelProperty.PAGE, next); + const currentPage = model.get(ModelProperty.PAGE); + model.set(ModelProperty.PAGE, currentPage + direction); model.save_changes(); } /** - * Handles changes to the page size from the dropdown. - * @param {number} size - The new page size. + * Handles page size changes. + * @param {number} newSize - The new page size. */ - function handlePageSizeChange(size) { - const currentSize = model.get(ModelProperty.PAGE_SIZE); - if (size !== currentSize) { - model.set(ModelProperty.PAGE_SIZE, size); - model.save_changes(); - } + function handlePageSizeChange(newSize) { + model.set(ModelProperty.PAGE_SIZE, newSize); + model.set(ModelProperty.PAGE, 0); // Reset to first page + model.save_changes(); } /** Updates the HTML in the table container and refreshes button states. */ @@ -131,13 +129,95 @@ function render({ model, el }) { // Note: Using innerHTML is safe here because the content is generated // by a trusted backend (DataFrame.to_html). tableContainer.innerHTML = model.get(ModelProperty.TABLE_HTML); + + // Get sortable columns from backend + const sortableColumns = model.get(ModelProperty.ORDERABLE_COLUMNS); + const currentSortColumn = model.get(ModelProperty.SORT_COLUMN); + const currentSortAscending = model.get(ModelProperty.SORT_ASCENDING); + + // Add click handlers to column headers for sorting + const headers = tableContainer.querySelectorAll("th"); + headers.forEach((header) => { + const headerDiv = header.querySelector("div"); + const columnName = headerDiv.textContent.trim(); + + // Only add sorting UI for sortable columns + if (columnName && sortableColumns.includes(columnName)) { + header.style.cursor = "pointer"; + + // Create a span for the indicator + const indicatorSpan = document.createElement("span"); + indicatorSpan.classList.add("sort-indicator"); + indicatorSpan.style.paddingLeft = "5px"; + + // Determine sort indicator and initial visibility + let indicator = "●"; // Default: unsorted (dot) + if (currentSortColumn === columnName) { + indicator = currentSortAscending ? "▲" : "▼"; + indicatorSpan.style.visibility = "visible"; // Sorted arrows always visible + } else { + indicatorSpan.style.visibility = "hidden"; // Unsorted dot hidden by default + } + indicatorSpan.textContent = indicator; + + // Add indicator to the header, replacing the old one if it exists + const existingIndicator = headerDiv.querySelector(".sort-indicator"); + if (existingIndicator) { + headerDiv.removeChild(existingIndicator); + } + headerDiv.appendChild(indicatorSpan); + + // Add hover effects for unsorted columns only + header.addEventListener("mouseover", () => { + if (currentSortColumn !== columnName) { + indicatorSpan.style.visibility = "visible"; + } + }); + header.addEventListener("mouseout", () => { + if (currentSortColumn !== columnName) { + indicatorSpan.style.visibility = "hidden"; + } + }); + + // Add click handler for three-state toggle + header.addEventListener(Event.CLICK, () => { + if (currentSortColumn === columnName) { + if (currentSortAscending) { + // Currently ascending → switch to descending + model.set(ModelProperty.SORT_ASCENDING, false); + } else { + // Currently descending → clear sort (back to unsorted) + model.set(ModelProperty.SORT_COLUMN, ""); + model.set(ModelProperty.SORT_ASCENDING, true); + } + } else { + // Not currently sorted → sort ascending + model.set(ModelProperty.SORT_COLUMN, columnName); + model.set(ModelProperty.SORT_ASCENDING, true); + } + model.save_changes(); + }); + } + }); + updateButtonStates(); } + // Add error message handler + function handleErrorMessageChange() { + const errorMsg = model.get(ModelProperty.ERROR_MESSAGE); + if (errorMsg) { + errorContainer.textContent = errorMsg; + errorContainer.style.display = "block"; + } else { + errorContainer.style.display = "none"; + } + } + // Add event listeners prevPage.addEventListener(Event.CLICK, () => handlePageChange(-1)); nextPage.addEventListener(Event.CLICK, () => handlePageChange(1)); - pageSizeSelect.addEventListener(Event.CHANGE, (e) => { + pageSizeInput.addEventListener(Event.CHANGE, (e) => { const newSize = Number(e.target.value); if (newSize) { handlePageSizeChange(newSize); @@ -145,29 +225,33 @@ function render({ model, el }) { }); model.on(Event.CHANGE_TABLE_HTML, handleTableHTMLChange); model.on(`change:${ModelProperty.ROW_COUNT}`, updateButtonStates); + model.on(`change:${ModelProperty.ERROR_MESSAGE}`, handleErrorMessageChange); model.on(`change:_initial_load_complete`, (val) => { if (val) { updateButtonStates(); } }); + model.on(`change:${ModelProperty.PAGE}`, updateButtonStates); // Assemble the DOM paginationContainer.appendChild(prevPage); - paginationContainer.appendChild(paginationLabel); + paginationContainer.appendChild(pageIndicator); paginationContainer.appendChild(nextPage); pageSizeContainer.appendChild(pageSizeLabel); - pageSizeContainer.appendChild(pageSizeSelect); + pageSizeContainer.appendChild(pageSizeInput); footer.appendChild(rowCountLabel); footer.appendChild(paginationContainer); footer.appendChild(pageSizeContainer); + el.appendChild(errorContainer); el.appendChild(tableContainer); el.appendChild(footer); // Initial render handleTableHTMLChange(); + handleErrorMessageChange(); } export default { render }; diff --git a/notebooks/dataframes/anywidget_mode.ipynb b/notebooks/dataframes/anywidget_mode.ipynb index e810377dd7..fa324c246a 100644 --- a/notebooks/dataframes/anywidget_mode.ipynb +++ b/notebooks/dataframes/anywidget_mode.ipynb @@ -45,7 +45,10 @@ "id": "04406a4d", "metadata": {}, "source": [ - "Set the display option to use anywidget" + "This notebook demonstrates the anywidget display mode, which provides an interactive table experience.\n", + "Key features include:\n", + "- **Column Sorting:** Click on column headers to sort data in ascending, descending, or unsorted states.\n", + "- **Adjustable Column Widths:** Drag the dividers between column headers to resize columns." ] }, { @@ -139,15 +142,27 @@ "metadata": {}, "output_type": "display_data" }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "388323f1f2394f44a7199e7ecda7b5d2", + "model_id": "2935e3f8f4f34c558d588f09a9c42131", "version_major": 2, "version_minor": 1 }, "text/plain": [ - "TableWidget(page_size=10, row_count=5552452, table_html='
{html.escape(str(col))}
{header_div}
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "data": { "text/html": [ @@ -205,12 +255,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "24847f9deca94a2abb4fa1a64ec8b616", + "model_id": "fa03d998dfee47638a32b6c21ace0b5c", "version_major": 2, "version_minor": 1 }, "text/plain": [ - "TableWidget(page_size=10, row_count=5552452, table_html='
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "name": "stdout", "output_type": "stream", @@ -305,12 +369,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "8b8a0b2a3572442f8718baa08657bef6", + "model_id": "6d6886bd2bb74be996d54ce240cbe6c9", "version_major": 2, "version_minor": 1 }, "text/plain": [ - "TableWidget(page_size=10, row_count=5, table_html='
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "1327a3901c194bf3a03f7f3a3358dc19", + "model_id": "df774329fd2f47918b986362863d7155", "version_major": 2, "version_minor": 1 }, "text/plain": [ - "TableWidget(page_size=10, row_count=5, table_html='
=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz", + "integrity": "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", + "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "debug": "^4.4.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.10" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz", + "integrity": "sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz", + "integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", + "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", + "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", + "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.5.tgz", + "integrity": "sha512-45DmULpySVvmq9Pj3X9B+62Xe+DJGov27QravQJU1LLcapR6/10i+gYVAucGGJpHBp5mYxIMK4nDAT/QDLr47g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", + "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz", + "integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.3", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", + "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", + "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/template": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", + "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", + "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.5.tgz", + "integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", + "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.5.tgz", + "integrity": "sha512-axUuqnUTBuXyHGcJEVVh9pORaN6wC5bYfE7FGzPiaWa3syib9m7g+/IT/4VgCOe2Upef43PHzeAvcrVek6QuuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", + "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", + "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", + "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.4.tgz", + "integrity": "sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", + "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.5.tgz", + "integrity": "sha512-N6fut9IZlPnjPwgiQkXNhb+cT8wQKFlJNqcZkWlcTqkcqx6/kU4ynGmLFoa4LViBSirn05YAwk+sQBbPfxtYzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", + "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", + "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.4.tgz", + "integrity": "sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", + "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", + "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", + "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", + "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.5.tgz", + "integrity": "sha512-S36mOoi1Sb6Fz98fBfE+UZSpYw5mJm0NUHtIKrOuNcqeFauy1J6dIvXm2KRVKobOSaGq4t/hBXdN4HGU3wL9Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.3", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.27.1", + "@babel/plugin-syntax-import-attributes": "^7.27.1", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.28.0", + "@babel/plugin-transform-async-to-generator": "^7.27.1", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.5", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-class-static-block": "^7.28.3", + "@babel/plugin-transform-classes": "^7.28.4", + "@babel/plugin-transform-computed-properties": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.27.1", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.0", + "@babel/plugin-transform-exponentiation-operator": "^7.28.5", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.27.1", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.5", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-modules-systemjs": "^7.28.5", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", + "@babel/plugin-transform-numeric-separator": "^7.27.1", + "@babel/plugin-transform-object-rest-spread": "^7.28.4", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.27.1", + "@babel/plugin-transform-private-property-in-object": "^7.27.1", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.28.4", + "@babel/plugin-transform-regexp-modifiers": "^7.27.1", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.27.1", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "core-js-compat": "^3.43.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/core/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/core/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@jest/reporters/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jsdom": { + "version": "20.0.1", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", + "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", + "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.7", + "@babel/helper-define-polyfill-provider": "^0.6.5", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", + "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz", + "integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001756", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz", + "integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz", + "integrity": "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dedent": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "license": "MIT", + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.259", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.259.tgz", + "integrity": "sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-jsdom": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", + "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/jsdom": "^20.0.0", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0", + "jsdom": "^20.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-jsdom/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-jsdom/node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-environment-jsdom/node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/jest-environment-jsdom/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-jsdom/node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jest-environment-jsdom/node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nwsapi": { + "version": "2.2.22", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", + "integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/tests/js/package.json b/tests/js/package.json new file mode 100644 index 0000000000..8de4b4747c --- /dev/null +++ b/tests/js/package.json @@ -0,0 +1,19 @@ +{ + "name": "js-tests", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "test": "jest" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@babel/preset-env": "^7.24.7", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "jsdom": "^24.1.0" + } +} diff --git a/tests/js/table_widget.test.js b/tests/js/table_widget.test.js new file mode 100644 index 0000000000..77ec7bcdd5 --- /dev/null +++ b/tests/js/table_widget.test.js @@ -0,0 +1,209 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { jest } from "@jest/globals"; +import { JSDOM } from "jsdom"; + +describe("TableWidget", () => { + let model; + let el; + let render; + + beforeEach(async () => { + jest.resetModules(); + document.body.innerHTML = "
"; + el = document.body.querySelector("div"); + + const tableWidget = ( + await import("../../bigframes/display/table_widget.js") + ).default; + render = tableWidget.render; + + model = { + get: jest.fn(), + set: jest.fn(), + save_changes: jest.fn(), + on: jest.fn(), + }; + }); + + it("should have a render function", () => { + expect(render).toBeDefined(); + }); + + describe("render", () => { + it("should create the basic structure", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return ""; + } + if (property === "row_count") { + return 100; + } + if (property === "error_message") { + return null; + } + if (property === "page_size") { + return 10; + } + if (property === "page") { + return 0; + } + return null; + }); + + render({ model, el }); + + expect(el.classList.contains("bigframes-widget")).toBe(true); + expect(el.querySelector(".error-message")).not.toBeNull(); + expect(el.querySelector("div")).not.toBeNull(); + expect(el.querySelector("div:nth-child(3)")).not.toBeNull(); + }); + + it("should sort when a sortable column is clicked", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return "
col1
"; + } + if (property === "orderable_columns") { + return ["col1"]; + } + if (property === "sort_column") { + return ""; + } + return null; + }); + + render({ model, el }); + + // Manually trigger the table_html change handler + const tableHtmlChangeHandler = model.on.mock.calls.find( + (call) => call[0] === "change:table_html", + )[1]; + tableHtmlChangeHandler(); + + const header = el.querySelector("th"); + header.click(); + + expect(model.set).toHaveBeenCalledWith("sort_column", "col1"); + expect(model.set).toHaveBeenCalledWith("sort_ascending", true); + expect(model.save_changes).toHaveBeenCalled(); + }); + + it("should reverse sort direction when a sorted column is clicked", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return "
col1
"; + } + if (property === "orderable_columns") { + return ["col1"]; + } + if (property === "sort_column") { + return "col1"; + } + if (property === "sort_ascending") { + return true; + } + return null; + }); + + render({ model, el }); + + // Manually trigger the table_html change handler + const tableHtmlChangeHandler = model.on.mock.calls.find( + (call) => call[0] === "change:table_html", + )[1]; + tableHtmlChangeHandler(); + + const header = el.querySelector("th"); + header.click(); + + expect(model.set).toHaveBeenCalledWith("sort_ascending", false); + expect(model.save_changes).toHaveBeenCalled(); + }); + + it("should clear sort when a descending sorted column is clicked", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return "
col1
"; + } + if (property === "orderable_columns") { + return ["col1"]; + } + if (property === "sort_column") { + return "col1"; + } + if (property === "sort_ascending") { + return false; + } + return null; + }); + + render({ model, el }); + + // Manually trigger the table_html change handler + const tableHtmlChangeHandler = model.on.mock.calls.find( + (call) => call[0] === "change:table_html", + )[1]; + tableHtmlChangeHandler(); + + const header = el.querySelector("th"); + header.click(); + + expect(model.set).toHaveBeenCalledWith("sort_column", ""); + expect(model.set).toHaveBeenCalledWith("sort_ascending", true); + expect(model.save_changes).toHaveBeenCalled(); + }); + + it("should display the correct sort indicator", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return "
col1
col2
"; + } + if (property === "orderable_columns") { + return ["col1", "col2"]; + } + if (property === "sort_column") { + return "col1"; + } + if (property === "sort_ascending") { + return true; + } + return null; + }); + + render({ model, el }); + + // Manually trigger the table_html change handler + const tableHtmlChangeHandler = model.on.mock.calls.find( + (call) => call[0] === "change:table_html", + )[1]; + tableHtmlChangeHandler(); + + const headers = el.querySelectorAll("th"); + const indicator1 = headers[0].querySelector(".sort-indicator"); + const indicator2 = headers[1].querySelector(".sort-indicator"); + + expect(indicator1.textContent).toBe("▲"); + expect(indicator2.textContent).toBe("●"); + }); + }); +}); diff --git a/tests/system/small/test_anywidget.py b/tests/system/small/test_anywidget.py index c7b957891b..49d5ff6c92 100644 --- a/tests/system/small/test_anywidget.py +++ b/tests/system/small/test_anywidget.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""System tests for the anywidget-based table widget.""" from typing import Any from unittest import mock @@ -34,19 +35,16 @@ def paginated_pandas_df() -> pd.DataFrame: """Create a minimal test DataFrame with exactly 3 pages of 2 rows each.""" test_data = pd.DataFrame( { - "id": [0, 1, 2, 3, 4, 5], + "id": [5, 4, 3, 2, 1, 0], "page_indicator": [ - # Page 1 (rows 1-2) - "page_1_row_1", - "page_1_row_2", - # Page 2 (rows 3-4) - "page_2_row_1", - "page_2_row_2", - # Page 3 (rows 5-6) - "page_3_row_1", - "page_3_row_2", + "row_5", + "row_4", + "row_3", + "row_2", + "row_1", + "row_0", ], - "value": [0, 1, 2, 3, 4, 5], + "value": [5, 4, 3, 2, 1, 0], } ) return test_data @@ -723,6 +721,146 @@ def test_widget_with_unknown_row_count_empty_dataframe( assert widget.page == 0 +def test_widget_sort_should_sort_ascending_on_first_click( + table_widget, paginated_pandas_df: pd.DataFrame +): + """ + Given a widget, when a column header is clicked for the first time, + then the data should be sorted by that column in ascending order. + """ + table_widget.sort_column = "id" + table_widget.sort_ascending = True + + expected_slice = paginated_pandas_df.sort_values("id", ascending=True).iloc[0:2] + html = table_widget.table_html + + _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) + + +def test_widget_sort_should_sort_descending_on_second_click( + table_widget, paginated_pandas_df: pd.DataFrame +): + """ + Given a widget sorted by a column, when the same column header is clicked again, + then the data should be sorted by that column in descending order. + """ + table_widget.sort_column = "id" + table_widget.sort_ascending = True + + # Second click + table_widget.sort_ascending = False + + expected_slice = paginated_pandas_df.sort_values("id", ascending=False).iloc[0:2] + html = table_widget.table_html + + _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) + + +def test_widget_sort_should_switch_column_and_sort_ascending( + table_widget, paginated_pandas_df: pd.DataFrame +): + """ + Given a widget sorted by a column, when a different column header is clicked, + then the data should be sorted by the new column in ascending order. + """ + table_widget.sort_column = "id" + table_widget.sort_ascending = True + + # Click on a different column + table_widget.sort_column = "value" + table_widget.sort_ascending = True + + expected_slice = paginated_pandas_df.sort_values("value", ascending=True).iloc[0:2] + html = table_widget.table_html + + _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) + + +def test_widget_sort_should_be_maintained_after_pagination( + table_widget, paginated_pandas_df: pd.DataFrame +): + """ + Given a sorted widget, when the user navigates to the next page, + then the sorting should be maintained. + """ + table_widget.sort_column = "id" + table_widget.sort_ascending = True + + # Go to the second page + table_widget.page = 1 + + expected_slice = paginated_pandas_df.sort_values("id", ascending=True).iloc[2:4] + html = table_widget.table_html + + _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) + + +def test_widget_sort_should_reset_on_page_size_change( + table_widget, paginated_pandas_df: pd.DataFrame +): + """ + Given a sorted widget, when the page size is changed, + then the sorting should be reset. + """ + table_widget.sort_column = "id" + table_widget.sort_ascending = True + + table_widget.page_size = 3 + + # Sorting is not reset in the backend, but the view should be of the unsorted df + expected_slice = paginated_pandas_df.iloc[0:3] + html = table_widget.table_html + + _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) + + +@pytest.fixture(scope="module") +def integer_column_df(session): + """Create a DataFrame with integer column labels.""" + pandas_df = pd.DataFrame([[0, 1], [2, 3]], columns=pd.Index([1, 2])) + return session.read_pandas(pandas_df) + + +@pytest.fixture(scope="module") +def multiindex_column_df(session): + """Create a DataFrame with MultiIndex column labels.""" + pandas_df = pd.DataFrame( + { + "foo": ["one", "one", "one", "two", "two", "two"], + "bar": ["A", "B", "C", "A", "B", "C"], + "baz": [1, 2, 3, 4, 5, 6], + "zoo": ["x", "y", "z", "q", "w", "t"], + } + ) + df = session.read_pandas(pandas_df) + # The session is attached to `df` through the constructor. + # We can pass it to the pivoted DataFrame. + pdf = df.pivot(index="foo", columns="bar", values=["baz", "zoo"]) + return pdf + + +def test_table_widget_integer_columns_disables_sorting(integer_column_df): + """ + Given a DataFrame with integer column labels, the widget should + disable sorting. + """ + from bigframes.display import TableWidget + + widget = TableWidget(integer_column_df) + assert widget.orderable_columns == [] + + +def test_table_widget_multiindex_columns_disables_sorting(multiindex_column_df): + """ + Given a DataFrame with a MultiIndex for columns, the widget should + disable sorting. + """ + from bigframes.display import TableWidget + + widget = TableWidget(multiindex_column_df) + assert widget.orderable_columns == [] + + # TODO(shuowei): Add tests for custom index and multiindex # This may not be necessary for the SQL Cell use case but should be # considered for completeness. From 2dcf6ae25191183d8d8f0d29cbb818be8e1ca44c Mon Sep 17 00:00:00 2001 From: jialuoo Date: Wed, 26 Nov 2025 10:29:07 -0800 Subject: [PATCH 260/313] chore: Migrate pow_op operator to SQLGlot (#2291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes b/447388852 🦕 --- .../compile/sqlglot/expressions/constants.py | 12 + .../sqlglot/expressions/numeric_ops.py | 135 +++++++ .../test_numeric_ops/test_pow/out.sql | 329 ++++++++++++++++++ .../sqlglot/expressions/test_numeric_ops.py | 16 + 4 files changed, 492 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pow/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/constants.py b/bigframes/core/compile/sqlglot/expressions/constants.py index 20857f6291..e005a1ed78 100644 --- a/bigframes/core/compile/sqlglot/expressions/constants.py +++ b/bigframes/core/compile/sqlglot/expressions/constants.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import math + import sqlglot.expressions as sge _ZERO = sge.Cast(this=sge.convert(0), to="INT64") @@ -23,3 +25,13 @@ # FLOAT64 has 11 exponent bits, so max values is about 2**(2**10) # ln(2**(2**10)) == (2**10)*ln(2) ~= 709.78, so EXP(x) for x>709.78 will overflow. _FLOAT64_EXP_BOUND = sge.convert(709.78) + +# The natural logarithm of the maximum value for a signed 64-bit integer. +# This is used to check for potential overflows in power operations involving integers +# by checking if `exponent * log(base)` exceeds this value. +_INT64_LOG_BOUND = math.log(2**63 - 1) + +# Represents the largest integer N where all integers from -N to N can be +# represented exactly as a float64. Float64 types have a 53-bit significand precision, +# so integers beyond this value may lose precision. +_FLOAT64_MAX_INT_PRECISION = 2**53 diff --git a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py index 83b29f67df..f7da28c5d2 100644 --- a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py @@ -210,6 +210,141 @@ def _(expr: TypedExpr) -> sge.Expression: return expr.expr +@register_binary_op(ops.pow_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + left_expr = _coerce_bool_to_int(left) + right_expr = _coerce_bool_to_int(right) + if left.dtype == dtypes.INT_DTYPE and right.dtype == dtypes.INT_DTYPE: + return _int_pow_op(left_expr, right_expr) + else: + return _float_pow_op(left_expr, right_expr) + + +def _int_pow_op( + left_expr: sge.Expression, right_expr: sge.Expression +) -> sge.Expression: + overflow_cond = sge.and_( + sge.NEQ(this=left_expr, expression=sge.convert(0)), + sge.GT( + this=sge.Mul( + this=right_expr, expression=sge.Ln(this=sge.Abs(this=left_expr)) + ), + expression=sge.convert(constants._INT64_LOG_BOUND), + ), + ) + + return sge.Case( + ifs=[ + sge.If( + this=overflow_cond, + true=sge.Null(), + ) + ], + default=sge.Cast( + this=sge.Pow( + this=sge.Cast( + this=left_expr, to=sge.DataType(this=sge.DataType.Type.DECIMAL) + ), + expression=right_expr, + ), + to="INT64", + ), + ) + + +def _float_pow_op( + left_expr: sge.Expression, right_expr: sge.Expression +) -> sge.Expression: + # Most conditions here seek to prevent calling BQ POW with inputs that would generate errors. + # See: https://cloud.google.com/bigquery/docs/reference/standard-sql/mathematical_functions#pow + overflow_cond = sge.and_( + sge.NEQ(this=left_expr, expression=constants._ZERO), + sge.GT( + this=sge.Mul( + this=right_expr, expression=sge.Ln(this=sge.Abs(this=left_expr)) + ), + expression=constants._FLOAT64_EXP_BOUND, + ), + ) + + # Float64 lose integer precision beyond 2**53, beyond this insufficient precision to get parity + exp_too_big = sge.GT( + this=sge.Abs(this=right_expr), + expression=sge.convert(constants._FLOAT64_MAX_INT_PRECISION), + ) + # Treat very large exponents as +=INF + norm_exp = sge.Case( + ifs=[ + sge.If( + this=exp_too_big, + true=sge.Mul(this=constants._INF, expression=sge.Sign(this=right_expr)), + ) + ], + default=right_expr, + ) + + pow_result = sge.Pow(this=left_expr, expression=norm_exp) + + # This cast is dangerous, need to only excuted where y_val has been bounds-checked + # Ibis needs try_cast binding to bq safe_cast + exponent_is_whole = sge.EQ( + this=sge.Cast(this=right_expr, to="INT64"), expression=right_expr + ) + odd_exponent = sge.and_( + sge.LT(this=left_expr, expression=constants._ZERO), + sge.EQ( + this=sge.Mod( + this=sge.Cast(this=right_expr, to="INT64"), expression=sge.convert(2) + ), + expression=sge.convert(1), + ), + ) + infinite_base = sge.EQ(this=sge.Abs(this=left_expr), expression=constants._INF) + + return sge.Case( + ifs=[ + # Might be able to do something more clever with x_val==0 case + sge.If( + this=sge.EQ(this=right_expr, expression=constants._ZERO), + true=sge.convert(1), + ), + sge.If( + this=sge.EQ(this=left_expr, expression=sge.convert(1)), + true=sge.convert(1), + ), # Need to ignore exponent, even if it is NA + sge.If( + this=sge.and_( + sge.EQ(this=left_expr, expression=constants._ZERO), + sge.LT(this=right_expr, expression=constants._ZERO), + ), + true=constants._INF, + ), # This case would error POW function in BQ + sge.If(this=infinite_base, true=pow_result), + sge.If( + this=exp_too_big, true=pow_result + ), # Bigquery can actually handle the +-inf cases gracefully + sge.If( + this=sge.and_( + sge.LT(this=left_expr, expression=constants._ZERO), + sge.Not(this=exponent_is_whole), + ), + true=constants._NAN, + ), + sge.If( + this=overflow_cond, + true=sge.Mul( + this=constants._INF, + expression=sge.Case( + ifs=[sge.If(this=odd_exponent, true=sge.convert(-1))], + default=sge.convert(1), + ), + ), + ), # finite overflows would cause bq to error + ], + default=pow_result, + ) + + @register_unary_op(ops.sqrt_op) def _(expr: TypedExpr) -> sge.Expression: return sge.Case( diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pow/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pow/out.sql new file mode 100644 index 0000000000..05fbaa12c9 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pow/out.sql @@ -0,0 +1,329 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col`, + `int64_col`, + `rowindex` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `float64_col` AS `bfcol_8`, + CASE + WHEN `int64_col` <> 0 AND `int64_col` * LN(ABS(`int64_col`)) > 43.66827237527655 + THEN NULL + ELSE CAST(POWER(CAST(`int64_col` AS NUMERIC), `int64_col`) AS INT64) + END AS `bfcol_9` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + CASE + WHEN `bfcol_8` = CAST(0 AS INT64) + THEN 1 + WHEN `bfcol_7` = 1 + THEN 1 + WHEN `bfcol_7` = CAST(0 AS INT64) AND `bfcol_8` < CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) + WHEN ABS(`bfcol_7`) = CAST('Infinity' AS FLOAT64) + THEN POWER( + `bfcol_7`, + CASE + WHEN ABS(`bfcol_8`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_8`) + ELSE `bfcol_8` + END + ) + WHEN ABS(`bfcol_8`) > 9007199254740992 + THEN POWER( + `bfcol_7`, + CASE + WHEN ABS(`bfcol_8`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_8`) + ELSE `bfcol_8` + END + ) + WHEN `bfcol_7` < CAST(0 AS INT64) AND NOT CAST(`bfcol_8` AS INT64) = `bfcol_8` + THEN CAST('NaN' AS FLOAT64) + WHEN `bfcol_7` <> CAST(0 AS INT64) AND `bfcol_8` * LN(ABS(`bfcol_7`)) > 709.78 + THEN CAST('Infinity' AS FLOAT64) * CASE + WHEN `bfcol_7` < CAST(0 AS INT64) AND MOD(CAST(`bfcol_8` AS INT64), 2) = 1 + THEN -1 + ELSE 1 + END + ELSE POWER( + `bfcol_7`, + CASE + WHEN ABS(`bfcol_8`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_8`) + ELSE `bfcol_8` + END + ) + END AS `bfcol_18` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + CASE + WHEN `bfcol_15` = CAST(0 AS INT64) + THEN 1 + WHEN `bfcol_16` = 1 + THEN 1 + WHEN `bfcol_16` = CAST(0 AS INT64) AND `bfcol_15` < CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) + WHEN ABS(`bfcol_16`) = CAST('Infinity' AS FLOAT64) + THEN POWER( + `bfcol_16`, + CASE + WHEN ABS(`bfcol_15`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_15`) + ELSE `bfcol_15` + END + ) + WHEN ABS(`bfcol_15`) > 9007199254740992 + THEN POWER( + `bfcol_16`, + CASE + WHEN ABS(`bfcol_15`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_15`) + ELSE `bfcol_15` + END + ) + WHEN `bfcol_16` < CAST(0 AS INT64) AND NOT CAST(`bfcol_15` AS INT64) = `bfcol_15` + THEN CAST('NaN' AS FLOAT64) + WHEN `bfcol_16` <> CAST(0 AS INT64) AND `bfcol_15` * LN(ABS(`bfcol_16`)) > 709.78 + THEN CAST('Infinity' AS FLOAT64) * CASE + WHEN `bfcol_16` < CAST(0 AS INT64) AND MOD(CAST(`bfcol_15` AS INT64), 2) = 1 + THEN -1 + ELSE 1 + END + ELSE POWER( + `bfcol_16`, + CASE + WHEN ABS(`bfcol_15`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_15`) + ELSE `bfcol_15` + END + ) + END AS `bfcol_29` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + CASE + WHEN `bfcol_26` = CAST(0 AS INT64) + THEN 1 + WHEN `bfcol_26` = 1 + THEN 1 + WHEN `bfcol_26` = CAST(0 AS INT64) AND `bfcol_26` < CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) + WHEN ABS(`bfcol_26`) = CAST('Infinity' AS FLOAT64) + THEN POWER( + `bfcol_26`, + CASE + WHEN ABS(`bfcol_26`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_26`) + ELSE `bfcol_26` + END + ) + WHEN ABS(`bfcol_26`) > 9007199254740992 + THEN POWER( + `bfcol_26`, + CASE + WHEN ABS(`bfcol_26`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_26`) + ELSE `bfcol_26` + END + ) + WHEN `bfcol_26` < CAST(0 AS INT64) AND NOT CAST(`bfcol_26` AS INT64) = `bfcol_26` + THEN CAST('NaN' AS FLOAT64) + WHEN `bfcol_26` <> CAST(0 AS INT64) AND `bfcol_26` * LN(ABS(`bfcol_26`)) > 709.78 + THEN CAST('Infinity' AS FLOAT64) * CASE + WHEN `bfcol_26` < CAST(0 AS INT64) AND MOD(CAST(`bfcol_26` AS INT64), 2) = 1 + THEN -1 + ELSE 1 + END + ELSE POWER( + `bfcol_26`, + CASE + WHEN ABS(`bfcol_26`) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(`bfcol_26`) + ELSE `bfcol_26` + END + ) + END AS `bfcol_42` + FROM `bfcte_3` +), `bfcte_5` AS ( + SELECT + *, + `bfcol_36` AS `bfcol_50`, + `bfcol_37` AS `bfcol_51`, + `bfcol_38` AS `bfcol_52`, + `bfcol_39` AS `bfcol_53`, + `bfcol_40` AS `bfcol_54`, + `bfcol_41` AS `bfcol_55`, + `bfcol_42` AS `bfcol_56`, + CASE + WHEN `bfcol_37` <> 0 AND 0 * LN(ABS(`bfcol_37`)) > 43.66827237527655 + THEN NULL + ELSE CAST(POWER(CAST(`bfcol_37` AS NUMERIC), 0) AS INT64) + END AS `bfcol_57` + FROM `bfcte_4` +), `bfcte_6` AS ( + SELECT + *, + `bfcol_50` AS `bfcol_66`, + `bfcol_51` AS `bfcol_67`, + `bfcol_52` AS `bfcol_68`, + `bfcol_53` AS `bfcol_69`, + `bfcol_54` AS `bfcol_70`, + `bfcol_55` AS `bfcol_71`, + `bfcol_56` AS `bfcol_72`, + `bfcol_57` AS `bfcol_73`, + CASE + WHEN 0 = CAST(0 AS INT64) + THEN 1 + WHEN `bfcol_52` = 1 + THEN 1 + WHEN `bfcol_52` = CAST(0 AS INT64) AND 0 < CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) + WHEN ABS(`bfcol_52`) = CAST('Infinity' AS FLOAT64) + THEN POWER( + `bfcol_52`, + CASE + WHEN ABS(0) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(0) + ELSE 0 + END + ) + WHEN ABS(0) > 9007199254740992 + THEN POWER( + `bfcol_52`, + CASE + WHEN ABS(0) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(0) + ELSE 0 + END + ) + WHEN `bfcol_52` < CAST(0 AS INT64) AND NOT CAST(0 AS INT64) = 0 + THEN CAST('NaN' AS FLOAT64) + WHEN `bfcol_52` <> CAST(0 AS INT64) AND 0 * LN(ABS(`bfcol_52`)) > 709.78 + THEN CAST('Infinity' AS FLOAT64) * CASE + WHEN `bfcol_52` < CAST(0 AS INT64) AND MOD(CAST(0 AS INT64), 2) = 1 + THEN -1 + ELSE 1 + END + ELSE POWER( + `bfcol_52`, + CASE + WHEN ABS(0) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(0) + ELSE 0 + END + ) + END AS `bfcol_74` + FROM `bfcte_5` +), `bfcte_7` AS ( + SELECT + *, + `bfcol_66` AS `bfcol_84`, + `bfcol_67` AS `bfcol_85`, + `bfcol_68` AS `bfcol_86`, + `bfcol_69` AS `bfcol_87`, + `bfcol_70` AS `bfcol_88`, + `bfcol_71` AS `bfcol_89`, + `bfcol_72` AS `bfcol_90`, + `bfcol_73` AS `bfcol_91`, + `bfcol_74` AS `bfcol_92`, + CASE + WHEN `bfcol_67` <> 0 AND 1 * LN(ABS(`bfcol_67`)) > 43.66827237527655 + THEN NULL + ELSE CAST(POWER(CAST(`bfcol_67` AS NUMERIC), 1) AS INT64) + END AS `bfcol_93` + FROM `bfcte_6` +), `bfcte_8` AS ( + SELECT + *, + `bfcol_84` AS `bfcol_104`, + `bfcol_85` AS `bfcol_105`, + `bfcol_86` AS `bfcol_106`, + `bfcol_87` AS `bfcol_107`, + `bfcol_88` AS `bfcol_108`, + `bfcol_89` AS `bfcol_109`, + `bfcol_90` AS `bfcol_110`, + `bfcol_91` AS `bfcol_111`, + `bfcol_92` AS `bfcol_112`, + `bfcol_93` AS `bfcol_113`, + CASE + WHEN 1 = CAST(0 AS INT64) + THEN 1 + WHEN `bfcol_86` = 1 + THEN 1 + WHEN `bfcol_86` = CAST(0 AS INT64) AND 1 < CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) + WHEN ABS(`bfcol_86`) = CAST('Infinity' AS FLOAT64) + THEN POWER( + `bfcol_86`, + CASE + WHEN ABS(1) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(1) + ELSE 1 + END + ) + WHEN ABS(1) > 9007199254740992 + THEN POWER( + `bfcol_86`, + CASE + WHEN ABS(1) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(1) + ELSE 1 + END + ) + WHEN `bfcol_86` < CAST(0 AS INT64) AND NOT CAST(1 AS INT64) = 1 + THEN CAST('NaN' AS FLOAT64) + WHEN `bfcol_86` <> CAST(0 AS INT64) AND 1 * LN(ABS(`bfcol_86`)) > 709.78 + THEN CAST('Infinity' AS FLOAT64) * CASE + WHEN `bfcol_86` < CAST(0 AS INT64) AND MOD(CAST(1 AS INT64), 2) = 1 + THEN -1 + ELSE 1 + END + ELSE POWER( + `bfcol_86`, + CASE + WHEN ABS(1) > 9007199254740992 + THEN CAST('Infinity' AS FLOAT64) * SIGN(1) + ELSE 1 + END + ) + END AS `bfcol_114` + FROM `bfcte_7` +) +SELECT + `bfcol_104` AS `rowindex`, + `bfcol_105` AS `int64_col`, + `bfcol_106` AS `float64_col`, + `bfcol_107` AS `int_pow_int`, + `bfcol_108` AS `int_pow_float`, + `bfcol_109` AS `float_pow_int`, + `bfcol_110` AS `float_pow_float`, + `bfcol_111` AS `int_pow_0`, + `bfcol_112` AS `float_pow_0`, + `bfcol_113` AS `int_pow_1`, + `bfcol_114` AS `float_pow_1` +FROM `bfcte_8` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py index 0b4f8fbe70..1a08a80eb1 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py @@ -196,6 +196,22 @@ def test_pos(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_pow(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "float64_col"]] + + bf_df["int_pow_int"] = bf_df["int64_col"] ** bf_df["int64_col"] + bf_df["int_pow_float"] = bf_df["int64_col"] ** bf_df["float64_col"] + bf_df["float_pow_int"] = bf_df["float64_col"] ** bf_df["int64_col"] + bf_df["float_pow_float"] = bf_df["float64_col"] ** bf_df["float64_col"] + + bf_df["int_pow_0"] = bf_df["int64_col"] ** 0 + bf_df["float_pow_0"] = bf_df["float64_col"] ** 0 + bf_df["int_pow_1"] = bf_df["int64_col"] ** 1 + bf_df["float_pow_1"] = bf_df["float64_col"] ** 1 + + snapshot.assert_match(bf_df.sql, "out.sql") + + def test_round(scalar_types_df: bpd.DataFrame, snapshot): bf_df = scalar_types_df[["int64_col", "float64_col"]] From c4cb39dcbd388356f5f1c48ff28b19b79b996485 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Wed, 26 Nov 2025 10:54:36 -0800 Subject: [PATCH 261/313] feat: Add agg/aggregate methods to windows (#2288) --- bigframes/core/window/rolling.py | 170 +++++++++++++----- tests/system/small/test_window.py | 69 +++++++ .../pandas/core/window/rolling.py | 49 +++++ 3 files changed, 248 insertions(+), 40 deletions(-) diff --git a/bigframes/core/window/rolling.py b/bigframes/core/window/rolling.py index 8b21f4166c..d6c77bf0a7 100644 --- a/bigframes/core/window/rolling.py +++ b/bigframes/core/window/rolling.py @@ -15,19 +15,24 @@ from __future__ import annotations import datetime -import typing +from typing import Literal, Mapping, Sequence, TYPE_CHECKING, Union import bigframes_vendored.pandas.core.window.rolling as vendored_pandas_rolling import numpy import pandas from bigframes import dtypes +from bigframes.core import agg_expressions from bigframes.core import expression as ex -from bigframes.core import log_adapter, ordering, window_spec +from bigframes.core import log_adapter, ordering, utils, window_spec import bigframes.core.blocks as blocks from bigframes.core.window import ordering as window_ordering import bigframes.operations.aggregations as agg_ops +if TYPE_CHECKING: + import bigframes.dataframe as df + import bigframes.series as series + @log_adapter.class_logger class Window(vendored_pandas_rolling.Window): @@ -37,7 +42,7 @@ def __init__( self, block: blocks.Block, window_spec: window_spec.WindowSpec, - value_column_ids: typing.Sequence[str], + value_column_ids: Sequence[str], drop_null_groups: bool = True, is_series: bool = False, skip_agg_column_id: str | None = None, @@ -52,55 +57,106 @@ def __init__( self._skip_agg_column_id = skip_agg_column_id def count(self): - return self._apply_aggregate(agg_ops.count_op) + return self._apply_aggregate_op(agg_ops.count_op) def sum(self): - return self._apply_aggregate(agg_ops.sum_op) + return self._apply_aggregate_op(agg_ops.sum_op) def mean(self): - return self._apply_aggregate(agg_ops.mean_op) + return self._apply_aggregate_op(agg_ops.mean_op) def var(self): - return self._apply_aggregate(agg_ops.var_op) + return self._apply_aggregate_op(agg_ops.var_op) def std(self): - return self._apply_aggregate(agg_ops.std_op) + return self._apply_aggregate_op(agg_ops.std_op) def max(self): - return self._apply_aggregate(agg_ops.max_op) + return self._apply_aggregate_op(agg_ops.max_op) def min(self): - return self._apply_aggregate(agg_ops.min_op) + return self._apply_aggregate_op(agg_ops.min_op) - def _apply_aggregate( - self, - op: agg_ops.UnaryAggregateOp, - ): - agg_block = self._aggregate_block(op) + def agg(self, func) -> Union[df.DataFrame, series.Series]: + if utils.is_dict_like(func): + return self._agg_dict(func) + elif utils.is_list_like(func): + return self._agg_list(func) + else: + return self._agg_func(func) - if self._is_series: - from bigframes.series import Series + aggregate = agg + + def _agg_func(self, func) -> df.DataFrame: + ids, labels = self._aggregated_columns() + aggregations = [agg(col_id, agg_ops.lookup_agg_func(func)[0]) for col_id in ids] + return self._apply_aggs(aggregations, labels) + + def _agg_dict(self, func: Mapping) -> df.DataFrame: + aggregations: list[agg_expressions.Aggregation] = [] + column_labels = [] + function_labels = [] - return Series(agg_block) + want_aggfunc_level = any(utils.is_list_like(aggs) for aggs in func.values()) + + for label, funcs_for_id in func.items(): + col_id = self._block.label_to_col_id[label][-1] # get last matching column + func_list = ( + funcs_for_id if utils.is_list_like(funcs_for_id) else [funcs_for_id] + ) + for f in func_list: + f_op, f_label = agg_ops.lookup_agg_func(f) + aggregations.append(agg(col_id, f_op)) + column_labels.append(label) + function_labels.append(f_label) + if want_aggfunc_level: + result_labels: pandas.Index = utils.combine_indices( + pandas.Index(column_labels), + pandas.Index(function_labels), + ) else: - from bigframes.dataframe import DataFrame + result_labels = pandas.Index(column_labels) - # Preserve column order. - column_labels = [ - self._block.col_id_to_label[col_id] for col_id in self._value_column_ids - ] - return DataFrame(agg_block)._reindex_columns(column_labels) + return self._apply_aggs(aggregations, result_labels) - def _aggregate_block(self, op: agg_ops.UnaryAggregateOp) -> blocks.Block: - agg_col_ids = [ - col_id - for col_id in self._value_column_ids - if col_id != self._skip_agg_column_id + def _agg_list(self, func: Sequence) -> df.DataFrame: + ids, labels = self._aggregated_columns() + aggregations = [ + agg(col_id, agg_ops.lookup_agg_func(f)[0]) for col_id in ids for f in func ] - block, result_ids = self._block.multi_apply_window_op( - agg_col_ids, - op, - self._window_spec, + + if self._is_series: + # if series, no need to rebuild + result_cols_idx = pandas.Index( + [agg_ops.lookup_agg_func(f)[1] for f in func] + ) + else: + if self._block.column_labels.nlevels > 1: + # Restructure MultiIndex for proper format: (idx1, idx2, func) + # rather than ((idx1, idx2), func). + column_labels = [ + tuple(label) + (agg_ops.lookup_agg_func(f)[1],) + for label in labels.to_frame(index=False).to_numpy() + for f in func + ] + else: # Single-level index + column_labels = [ + (label, agg_ops.lookup_agg_func(f)[1]) + for label in labels + for f in func + ] + result_cols_idx = pandas.MultiIndex.from_tuples( + column_labels, names=[*self._block.column_labels.names, None] + ) + return self._apply_aggs(aggregations, result_cols_idx) + + def _apply_aggs( + self, exprs: Sequence[agg_expressions.Aggregation], labels: pandas.Index + ): + block, ids = self._block.apply_analytic( + agg_exprs=exprs, + window=self._window_spec, + result_labels=labels, skip_null_groups=self._drop_null_groups, ) @@ -115,24 +171,50 @@ def _aggregate_block(self, op: agg_ops.UnaryAggregateOp) -> blocks.Block: ) block = block.set_index(col_ids=index_ids) - labels = [self._block.col_id_to_label[col] for col in agg_col_ids] if self._skip_agg_column_id is not None: - result_ids = [self._skip_agg_column_id, *result_ids] - labels.insert(0, self._block.col_id_to_label[self._skip_agg_column_id]) + block = block.select_columns([self._skip_agg_column_id, *ids]) + else: + block = block.select_columns(ids).with_column_labels(labels) + + if self._is_series and (len(block.value_columns) == 1): + import bigframes.series as series + + return series.Series(block) + else: + import bigframes.dataframe as df + + return df.DataFrame(block) + + def _apply_aggregate_op( + self, + op: agg_ops.UnaryAggregateOp, + ): + ids, labels = self._aggregated_columns() + aggregations = [agg(col_id, op) for col_id in ids] + return self._apply_aggs(aggregations, labels) - return block.select_columns(result_ids).with_column_labels(labels) + def _aggregated_columns(self) -> tuple[Sequence[str], pandas.Index]: + agg_col_ids = [ + col_id + for col_id in self._value_column_ids + if col_id != self._skip_agg_column_id + ] + labels: pandas.Index = pandas.Index( + [self._block.col_id_to_label[col] for col in agg_col_ids] + ) + return agg_col_ids, labels def create_range_window( block: blocks.Block, window: pandas.Timedelta | numpy.timedelta64 | datetime.timedelta | str, *, - value_column_ids: typing.Sequence[str] = tuple(), + value_column_ids: Sequence[str] = tuple(), min_periods: int | None, on: str | None = None, - closed: typing.Literal["right", "left", "both", "neither"], + closed: Literal["right", "left", "both", "neither"], is_series: bool, - grouping_keys: typing.Sequence[str] = tuple(), + grouping_keys: Sequence[str] = tuple(), drop_null_groups: bool = True, ) -> Window: @@ -184,3 +266,11 @@ def create_range_window( skip_agg_column_id=None if on is None else rolling_key_col_id, drop_null_groups=drop_null_groups, ) + + +def agg(input: str, op: agg_ops.AggregateOp) -> agg_expressions.Aggregation: + if isinstance(op, agg_ops.UnaryAggregateOp): + return agg_expressions.UnaryAggregation(op, ex.deref(input)) + else: + assert isinstance(op, agg_ops.NullaryAggregateOp) + return agg_expressions.NullaryAggregation(op) diff --git a/tests/system/small/test_window.py b/tests/system/small/test_window.py index b48bb8bc86..29ab581f76 100644 --- a/tests/system/small/test_window.py +++ b/tests/system/small/test_window.py @@ -228,6 +228,75 @@ def test_dataframe_window_agg_ops(scalars_dfs, windowing, agg_op): pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) +@pytest.mark.parametrize( + ("windowing"), + [ + pytest.param(lambda x: x.expanding(), id="expanding"), + pytest.param(lambda x: x.rolling(3, min_periods=3), id="rolling"), + pytest.param( + lambda x: x.groupby(level=0).rolling(3, min_periods=3), id="rollinggroupby" + ), + pytest.param( + lambda x: x.groupby("int64_too").expanding(min_periods=2), + id="expandinggroupby", + ), + ], +) +@pytest.mark.parametrize( + ("func"), + [ + pytest.param("sum", id="sum_by_name"), + pytest.param(np.sum, id="sum_by_by_np"), + pytest.param([np.sum, np.mean], id="list_of_funcs"), + pytest.param( + {"int64_col": np.sum, "float64_col": "mean"}, id="dict_of_single_funcs" + ), + pytest.param( + {"int64_col": np.sum, "float64_col": ["mean", np.max]}, + id="dict_of_lists_and_single_funcs", + ), + ], +) +def test_dataframe_window_agg_func(scalars_dfs, windowing, func): + bf_df, pd_df = scalars_dfs + target_columns = ["int64_too", "float64_col", "bool_col", "int64_col"] + index_column = "bool_col" + bf_df = bf_df[target_columns].set_index(index_column) + pd_df = pd_df[target_columns].set_index(index_column) + + bf_result = windowing(bf_df).agg(func).to_pandas() + + pd_result = windowing(pd_df).agg(func) + + pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) + + +def test_series_window_agg_single_func(scalars_dfs): + bf_df, pd_df = scalars_dfs + index_column = "bool_col" + bf_series = bf_df.set_index(index_column).int64_too + pd_series = pd_df.set_index(index_column).int64_too + + bf_result = bf_series.expanding().agg("sum").to_pandas() + + pd_result = pd_series.expanding().agg("sum") + + pd.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + + +def test_series_window_agg_multi_func(scalars_dfs): + bf_df, pd_df = scalars_dfs + index_column = "bool_col" + bf_series = bf_df.set_index(index_column).int64_too + pd_series = pd_df.set_index(index_column).int64_too + + bf_result = bf_series.expanding().agg(["sum", np.mean]).to_pandas() + + pd_result = pd_series.expanding().agg(["sum", np.mean]) + + pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) + + @pytest.mark.parametrize("closed", ["left", "right", "both", "neither"]) @pytest.mark.parametrize( "window", # skipped numpy timedelta because Pandas does not support it. diff --git a/third_party/bigframes_vendored/pandas/core/window/rolling.py b/third_party/bigframes_vendored/pandas/core/window/rolling.py index a869c86e72..7ca676fbe6 100644 --- a/third_party/bigframes_vendored/pandas/core/window/rolling.py +++ b/third_party/bigframes_vendored/pandas/core/window/rolling.py @@ -37,3 +37,52 @@ def max(self): def min(self): """Calculate the weighted window minimum.""" raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def agg(self, func): + """ + Aggregate using one or more operations over the specified axis. + + **Examples:** + + >>> import bigframes.pandas as bpd + + >>> df = bpd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6], "C": [7, 8, 9]}) + >>> df + A B C + 0 1 4 7 + 1 2 5 8 + 2 3 6 9 + + [3 rows x 3 columns] + + >>> df.rolling(2).sum() + A B C + 0 + 1 3 9 15 + 2 5 11 17 + + [3 rows x 3 columns] + + >>> df.rolling(2).agg({"A": "sum", "B": "min"}) + A B + 0 + 1 3 4 + 2 5 5 + + [3 rows x 2 columns] + + Args: + func (function, str, list or dict): + Function to use for aggregating the data. + + Accepted combinations are: + + - string function name + - list of function names, e.g. ``['sum', 'mean']`` + - dict of axis labels -> function names or list of such. + + Returns: + Series or DataFrame + + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) From 6cdf64b0674d0e673f86362032d549316850837b Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Wed, 26 Nov 2025 10:55:21 -0800 Subject: [PATCH 262/313] fix: Fix issue with stream upload batch size upload limit (#2290) --- bigframes/core/local_data.py | 3 +- bigframes/session/loader.py | 58 ++++++++++++++++++++++++------ tests/system/large/test_session.py | 37 +++++++++++++++++++ 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/bigframes/core/local_data.py b/bigframes/core/local_data.py index 0735c4fc5a..21d773fdad 100644 --- a/bigframes/core/local_data.py +++ b/bigframes/core/local_data.py @@ -124,12 +124,13 @@ def to_arrow( geo_format: Literal["wkb", "wkt"] = "wkt", duration_type: Literal["int", "duration"] = "duration", json_type: Literal["string"] = "string", + max_chunksize: Optional[int] = None, ) -> tuple[pa.Schema, Iterable[pa.RecordBatch]]: if geo_format != "wkt": raise NotImplementedError(f"geo format {geo_format} not yet implemented") assert json_type == "string" - batches = self.data.to_batches() + batches = self.data.to_batches(max_chunksize=max_chunksize) schema = self.data.schema if duration_type == "int": schema = _schema_durations_to_ints(schema) diff --git a/bigframes/session/loader.py b/bigframes/session/loader.py index 1bebd460a9..5e415999ff 100644 --- a/bigframes/session/loader.py +++ b/bigframes/session/loader.py @@ -19,6 +19,7 @@ import datetime import io import itertools +import math import os import typing from typing import ( @@ -397,6 +398,15 @@ def stream_data( offsets_col: str, ) -> bq_data.BigqueryDataSource: """Load managed data into bigquery""" + MAX_BYTES = 10000000 # streaming api has 10MB limit + SAFETY_MARGIN = ( + 40 # Perf seems bad for large chunks, so do 40x smaller than max + ) + batch_count = math.ceil( + data.metadata.total_bytes / (MAX_BYTES // SAFETY_MARGIN) + ) + rows_per_batch = math.ceil(data.metadata.row_count / batch_count) + schema_w_offsets = data.schema.append( schemata.SchemaItem(offsets_col, bigframes.dtypes.INT_DTYPE) ) @@ -410,16 +420,24 @@ def stream_data( ) rows_w_offsets = ((*row, offset) for offset, row in enumerate(rows)) - for errors in self._bqclient.insert_rows( - load_table_destination, - rows_w_offsets, - selected_fields=bq_schema, - row_ids=map(str, itertools.count()), # used to ensure only-once insertion - ): - if errors: - raise ValueError( - f"Problem loading at least one row from DataFrame: {errors}. {constants.FEEDBACK_LINK}" - ) + # TODO: don't use batched + batches = _batched(rows_w_offsets, rows_per_batch) + ids_iter = map(str, itertools.count()) + + for batch in batches: + batch_rows = list(batch) + row_ids = itertools.islice(ids_iter, len(batch_rows)) + + for errors in self._bqclient.insert_rows( + load_table_destination, + batch_rows, + selected_fields=bq_schema, + row_ids=row_ids, # used to ensure only-once insertion + ): + if errors: + raise ValueError( + f"Problem loading at least one row from DataFrame: {errors}. {constants.FEEDBACK_LINK}" + ) destination_table = self._bqclient.get_table(load_table_destination) return bq_data.BigqueryDataSource( bq_data.GbqTable.from_table(destination_table), @@ -434,6 +452,15 @@ def write_data( offsets_col: str, ) -> bq_data.BigqueryDataSource: """Load managed data into bigquery""" + MAX_BYTES = 10000000 # streaming api has 10MB limit + SAFETY_MARGIN = ( + 4 # aim for 2.5mb to account for row variance, format differences, etc. + ) + batch_count = math.ceil( + data.metadata.total_bytes / (MAX_BYTES // SAFETY_MARGIN) + ) + rows_per_batch = math.ceil(data.metadata.row_count / batch_count) + schema_w_offsets = data.schema.append( schemata.SchemaItem(offsets_col, bigframes.dtypes.INT_DTYPE) ) @@ -450,7 +477,9 @@ def write_data( def request_gen() -> Generator[bq_storage_types.AppendRowsRequest, None, None]: schema, batches = data.to_arrow( - offsets_col=offsets_col, duration_type="int" + offsets_col=offsets_col, + duration_type="int", + max_chunksize=rows_per_batch, ) offset = 0 for batch in batches: @@ -1332,3 +1361,10 @@ def _validate_dtype_can_load(name: str, column_type: bigframes.dtypes.Dtype): f"Nested JSON types, found in column `{name}`: `{column_type}`', " f"are currently unsupported for upload. {constants.FEEDBACK_LINK}" ) + + +# itertools.batched not available in python <3.12, so we use this instead +def _batched(iterator: Iterable, n: int) -> Iterable: + assert n > 0 + while batch := tuple(itertools.islice(iterator, n)): + yield batch diff --git a/tests/system/large/test_session.py b/tests/system/large/test_session.py index a525defe59..48c2b9e1b3 100644 --- a/tests/system/large/test_session.py +++ b/tests/system/large/test_session.py @@ -17,6 +17,8 @@ import google.cloud.bigquery as bigquery import google.cloud.exceptions +import numpy as np +import pandas as pd import pytest import bigframes @@ -24,6 +26,41 @@ import bigframes.session._io.bigquery +@pytest.fixture +def large_pd_df(): + nrows = 1000000 + + np_int1 = np.random.randint(0, 1000, size=nrows, dtype=np.int32) + np_int2 = np.random.randint(10000, 20000, size=nrows, dtype=np.int64) + np_bool = np.random.choice([True, False], size=nrows) + np_float1 = np.random.rand(nrows).astype(np.float32) + np_float2 = np.random.normal(loc=50.0, scale=10.0, size=nrows).astype(np.float64) + + return pd.DataFrame( + { + "int_col_1": np_int1, + "int_col_2": np_int2, + "bool_col": np_bool, + "float_col_1": np_float1, + "float_col_2": np_float2, + } + ) + + +@pytest.mark.parametrize( + ("write_engine"), + [ + ("bigquery_load"), + ("bigquery_streaming"), + ("bigquery_write"), + ], +) +def test_read_pandas_large_df(session, large_pd_df, write_engine: str): + df = session.read_pandas(large_pd_df, write_engine=write_engine) + assert len(df.peek(5)) == 5 + assert len(large_pd_df) == 1000000 + + def test_close(session: bigframes.Session): # we will create two tables and confirm that they are deleted # when the session is closed From 9e0f70b0d30658c0817e831314431f584662ccdb Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Wed, 26 Nov 2025 11:26:10 -0800 Subject: [PATCH 263/313] refactor: fix ops.StrftimeOp, ops.ToDatetimeOp, ops.ToTimestampOp in sqlglot compiler (#2297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change aims to fix the `to_datetime` related tests failing in #2248. Fixes internal issue 417774347 🦕 --- .../sqlglot/expressions/datetime_ops.py | 69 ++++++++++++++++--- .../sqlglot/expressions/timedelta_ops.py | 6 ++ bigframes/core/compile/sqlglot/sqlglot_ir.py | 2 + .../test_datetime_ops/test_strftime/out.sql | 13 +++- .../test_to_datetime/out.sql | 12 +++- .../test_to_timestamp/out.sql | 15 +++- .../test_to_timedelta/out.sql | 53 +++++++++----- .../sqlglot/expressions/test_datetime_ops.py | 37 ++++++---- .../sqlglot/expressions/test_timedelta_ops.py | 7 +- 9 files changed, 163 insertions(+), 51 deletions(-) diff --git a/bigframes/core/compile/sqlglot/expressions/datetime_ops.py b/bigframes/core/compile/sqlglot/expressions/datetime_ops.py index 949b122a1d..0f1e9dadf3 100644 --- a/bigframes/core/compile/sqlglot/expressions/datetime_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/datetime_ops.py @@ -16,7 +16,9 @@ import sqlglot.expressions as sge +from bigframes import dtypes from bigframes import operations as ops +from bigframes.core.compile.constants import UNIT_TO_US_CONVERSION_FACTORS from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler @@ -81,7 +83,17 @@ def _(expr: TypedExpr) -> sge.Expression: @register_unary_op(ops.StrftimeOp, pass_op=True) def _(expr: TypedExpr, op: ops.StrftimeOp) -> sge.Expression: - return sge.func("FORMAT_TIMESTAMP", sge.convert(op.date_format), expr.expr) + func_name = "" + if expr.dtype == dtypes.DATE_DTYPE: + func_name = "FORMAT_DATE" + elif expr.dtype == dtypes.DATETIME_DTYPE: + func_name = "FORMAT_DATETIME" + elif expr.dtype == dtypes.TIME_DTYPE: + func_name = "FORMAT_TIME" + elif expr.dtype == dtypes.TIMESTAMP_DTYPE: + func_name = "FORMAT_TIMESTAMP" + + return sge.func(func_name, sge.convert(op.date_format), expr.expr) @register_unary_op(ops.time_op) @@ -89,14 +101,55 @@ def _(expr: TypedExpr) -> sge.Expression: return sge.func("TIME", expr.expr) -@register_unary_op(ops.ToDatetimeOp) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Cast(this=sge.func("TIMESTAMP_SECONDS", expr.expr), to="DATETIME") - +@register_unary_op(ops.ToDatetimeOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ToDatetimeOp) -> sge.Expression: + if op.format: + result = expr.expr + if expr.dtype != dtypes.STRING_DTYPE: + result = sge.Cast(this=result, to="STRING") + result = sge.func( + "PARSE_TIMESTAMP", sge.convert(op.format), result, sge.convert("UTC") + ) + return sge.Cast(this=result, to="DATETIME") + + if expr.dtype == dtypes.STRING_DTYPE: + return sge.TryCast(this=expr.expr, to="DATETIME") + + value = expr.expr + unit = op.unit or "ns" + factor = UNIT_TO_US_CONVERSION_FACTORS[unit] + if factor != 1: + value = sge.Mul(this=value, expression=sge.convert(factor)) + value = sge.func("TRUNC", value) + return sge.Cast( + this=sge.func("TIMESTAMP_MICROS", sge.Cast(this=value, to="INT64")), + to="DATETIME", + ) + + +@register_unary_op(ops.ToTimestampOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ToTimestampOp) -> sge.Expression: + if op.format: + result = expr.expr + if expr.dtype != dtypes.STRING_DTYPE: + result = sge.Cast(this=result, to="STRING") + return sge.func( + "PARSE_TIMESTAMP", sge.convert(op.format), expr.expr, sge.convert("UTC") + ) -@register_unary_op(ops.ToTimestampOp) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("TIMESTAMP_SECONDS", expr.expr) + if expr.dtype == dtypes.STRING_DTYPE: + return sge.func("TIMESTAMP", expr.expr) + + value = expr.expr + unit = op.unit or "ns" + factor = UNIT_TO_US_CONVERSION_FACTORS[unit] + if factor != 1: + value = sge.Mul(this=value, expression=sge.convert(factor)) + value = sge.func("TRUNC", value) + return sge.Cast( + this=sge.func("TIMESTAMP_MICROS", sge.Cast(this=value, to="INT64")), + to="TIMESTAMP", + ) @register_unary_op(ops.UnixMicros) diff --git a/bigframes/core/compile/sqlglot/expressions/timedelta_ops.py b/bigframes/core/compile/sqlglot/expressions/timedelta_ops.py index 667c828b13..f5b9f891c1 100644 --- a/bigframes/core/compile/sqlglot/expressions/timedelta_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/timedelta_ops.py @@ -16,6 +16,7 @@ import sqlglot.expressions as sge +from bigframes import dtypes from bigframes import operations as ops from bigframes.core.compile.constants import UNIT_TO_US_CONVERSION_FACTORS from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr @@ -32,7 +33,12 @@ def _(expr: TypedExpr) -> sge.Expression: @register_unary_op(ops.ToTimedeltaOp, pass_op=True) def _(expr: TypedExpr, op: ops.ToTimedeltaOp) -> sge.Expression: value = expr.expr + if expr.dtype == dtypes.TIMEDELTA_DTYPE: + return value + factor = UNIT_TO_US_CONVERSION_FACTORS[op.unit] if factor != 1: value = sge.Mul(this=value, expression=sge.convert(factor)) + if expr.dtype == dtypes.FLOAT_DTYPE: + value = sge.Cast(this=sge.Floor(this=value), to=sge.DataType(this="INT64")) return value diff --git a/bigframes/core/compile/sqlglot/sqlglot_ir.py b/bigframes/core/compile/sqlglot/sqlglot_ir.py index fd3bdd532f..0d568b098b 100644 --- a/bigframes/core/compile/sqlglot/sqlglot_ir.py +++ b/bigframes/core/compile/sqlglot/sqlglot_ir.py @@ -648,6 +648,8 @@ def _literal(value: typing.Any, dtype: dtypes.Dtype) -> sge.Expression: elif dtype == dtypes.BYTES_DTYPE: return _cast(str(value), sqlglot_type) elif dtypes.is_time_like(dtype): + if isinstance(value, str): + return _cast(sge.convert(value), sqlglot_type) if isinstance(value, np.generic): value = value.item() return _cast(sge.convert(value.isoformat()), sqlglot_type) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_strftime/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_strftime/out.sql index 190cd7895b..1d8f62f948 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_strftime/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_strftime/out.sql @@ -1,13 +1,22 @@ WITH `bfcte_0` AS ( SELECT + `date_col`, + `datetime_col`, + `time_col`, `timestamp_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - FORMAT_TIMESTAMP('%Y-%m-%d', `timestamp_col`) AS `bfcol_1` + FORMAT_DATE('%Y-%m-%d', `date_col`) AS `bfcol_8`, + FORMAT_DATETIME('%Y-%m-%d', `datetime_col`) AS `bfcol_9`, + FORMAT_TIME('%Y-%m-%d', `time_col`) AS `bfcol_10`, + FORMAT_TIMESTAMP('%Y-%m-%d', `timestamp_col`) AS `bfcol_11` FROM `bfcte_0` ) SELECT - `bfcol_1` AS `timestamp_col` + `bfcol_8` AS `date_col`, + `bfcol_9` AS `datetime_col`, + `bfcol_10` AS `time_col`, + `bfcol_11` AS `timestamp_col` FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_datetime/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_datetime/out.sql index bbba3b1533..a8d40a8486 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_datetime/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_datetime/out.sql @@ -1,13 +1,19 @@ WITH `bfcte_0` AS ( SELECT - `int64_col` + `float64_col`, + `int64_col`, + `string_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - CAST(TIMESTAMP_SECONDS(`int64_col`) AS DATETIME) AS `bfcol_1` + CAST(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col` * 0.001) AS INT64)) AS DATETIME) AS `bfcol_6`, + SAFE_CAST(`string_col` AS DATETIME) AS `bfcol_7`, + CAST(TIMESTAMP_MICROS(CAST(TRUNC(`float64_col` * 0.001) AS INT64)) AS DATETIME) AS `bfcol_8` FROM `bfcte_0` ) SELECT - `bfcol_1` AS `int64_col` + `bfcol_6` AS `int64_col`, + `bfcol_7` AS `string_col`, + `bfcol_8` AS `float64_col` FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_timestamp/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_timestamp/out.sql index df01fb3269..a5f9ee1112 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_timestamp/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_timestamp/out.sql @@ -1,13 +1,24 @@ WITH `bfcte_0` AS ( SELECT + `float64_col`, `int64_col` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - TIMESTAMP_SECONDS(`int64_col`) AS `bfcol_1` + CAST(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col` * 0.001) AS INT64)) AS TIMESTAMP) AS `bfcol_2`, + CAST(TIMESTAMP_MICROS(CAST(TRUNC(`float64_col` * 0.001) AS INT64)) AS TIMESTAMP) AS `bfcol_3`, + CAST(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col` * 1000000) AS INT64)) AS TIMESTAMP) AS `bfcol_4`, + CAST(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col` * 1000) AS INT64)) AS TIMESTAMP) AS `bfcol_5`, + CAST(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col`) AS INT64)) AS TIMESTAMP) AS `bfcol_6`, + CAST(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col` * 0.001) AS INT64)) AS TIMESTAMP) AS `bfcol_7` FROM `bfcte_0` ) SELECT - `bfcol_1` AS `int64_col` + `bfcol_2` AS `int64_col`, + `bfcol_3` AS `float64_col`, + `bfcol_4` AS `int64_col_s`, + `bfcol_5` AS `int64_col_ms`, + `bfcol_6` AS `int64_col_us`, + `bfcol_7` AS `int64_col_ns` FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_to_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_to_timedelta/out.sql index 3c75cc3e89..ed7dbc7c8a 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_to_timedelta/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_to_timedelta/out.sql @@ -1,37 +1,54 @@ WITH `bfcte_0` AS ( SELECT + `float64_col`, `int64_col`, `rowindex` FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` ), `bfcte_1` AS ( SELECT *, - `rowindex` AS `bfcol_4`, - `int64_col` AS `bfcol_5`, - `int64_col` AS `bfcol_6` + `rowindex` AS `bfcol_6`, + `int64_col` AS `bfcol_7`, + `float64_col` AS `bfcol_8`, + `int64_col` AS `bfcol_9` FROM `bfcte_0` ), `bfcte_2` AS ( SELECT *, - `bfcol_4` AS `bfcol_10`, - `bfcol_5` AS `bfcol_11`, - `bfcol_6` AS `bfcol_12`, - `bfcol_5` * 1000000 AS `bfcol_13` + `bfcol_6` AS `bfcol_14`, + `bfcol_7` AS `bfcol_15`, + `bfcol_8` AS `bfcol_16`, + `bfcol_9` AS `bfcol_17`, + CAST(FLOOR(`bfcol_8` * 1000000) AS INT64) AS `bfcol_18` FROM `bfcte_1` ), `bfcte_3` AS ( SELECT *, - `bfcol_10` AS `bfcol_18`, - `bfcol_11` AS `bfcol_19`, - `bfcol_12` AS `bfcol_20`, - `bfcol_13` AS `bfcol_21`, - `bfcol_11` * 604800000000 AS `bfcol_22` + `bfcol_14` AS `bfcol_24`, + `bfcol_15` AS `bfcol_25`, + `bfcol_16` AS `bfcol_26`, + `bfcol_17` AS `bfcol_27`, + `bfcol_18` AS `bfcol_28`, + `bfcol_15` * 3600000000 AS `bfcol_29` FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + *, + `bfcol_24` AS `bfcol_36`, + `bfcol_25` AS `bfcol_37`, + `bfcol_26` AS `bfcol_38`, + `bfcol_27` AS `bfcol_39`, + `bfcol_28` AS `bfcol_40`, + `bfcol_29` AS `bfcol_41`, + `bfcol_27` AS `bfcol_42` + FROM `bfcte_3` ) SELECT - `bfcol_18` AS `rowindex`, - `bfcol_19` AS `int64_col`, - `bfcol_20` AS `duration_us`, - `bfcol_21` AS `duration_s`, - `bfcol_22` AS `duration_w` -FROM `bfcte_3` \ No newline at end of file + `bfcol_36` AS `rowindex`, + `bfcol_37` AS `int64_col`, + `bfcol_38` AS `float64_col`, + `bfcol_39` AS `duration_us`, + `bfcol_40` AS `duration_s`, + `bfcol_41` AS `duration_w`, + `bfcol_42` AS `duration_on_duration` +FROM `bfcte_4` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py index 6384dc79a9..9d93b9019f 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py @@ -143,12 +143,15 @@ def test_second(scalar_types_df: bpd.DataFrame, snapshot): def test_strftime(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - sql = utils._apply_ops_to_sql( - bf_df, [ops.StrftimeOp("%Y-%m-%d").as_expr(col_name)], [col_name] - ) + bf_df = scalar_types_df[["timestamp_col", "datetime_col", "date_col", "time_col"]] + ops_map = { + "date_col": ops.StrftimeOp("%Y-%m-%d").as_expr("date_col"), + "datetime_col": ops.StrftimeOp("%Y-%m-%d").as_expr("datetime_col"), + "time_col": ops.StrftimeOp("%Y-%m-%d").as_expr("time_col"), + "timestamp_col": ops.StrftimeOp("%Y-%m-%d").as_expr("timestamp_col"), + } + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") @@ -161,22 +164,26 @@ def test_time(scalar_types_df: bpd.DataFrame, snapshot): def test_to_datetime(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "int64_col" - bf_df = scalar_types_df[[col_name]] - sql = utils._apply_ops_to_sql( - bf_df, [ops.ToDatetimeOp().as_expr(col_name)], [col_name] - ) + col_names = ["int64_col", "string_col", "float64_col"] + bf_df = scalar_types_df[col_names] + ops_map = {col_name: ops.ToDatetimeOp().as_expr(col_name) for col_name in col_names} + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") def test_to_timestamp(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "int64_col" - bf_df = scalar_types_df[[col_name]] - sql = utils._apply_ops_to_sql( - bf_df, [ops.ToTimestampOp().as_expr(col_name)], [col_name] - ) + bf_df = scalar_types_df[["int64_col", "string_col", "float64_col"]] + ops_map = { + "int64_col": ops.ToTimestampOp().as_expr("int64_col"), + "float64_col": ops.ToTimestampOp().as_expr("float64_col"), + "int64_col_s": ops.ToTimestampOp(unit="s").as_expr("int64_col"), + "int64_col_ms": ops.ToTimestampOp(unit="ms").as_expr("int64_col"), + "int64_col_us": ops.ToTimestampOp(unit="us").as_expr("int64_col"), + "int64_col_ns": ops.ToTimestampOp(unit="ns").as_expr("int64_col"), + } + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_timedelta_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_timedelta_ops.py index 8675b42bec..164c11aab5 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_timedelta_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_timedelta_ops.py @@ -22,10 +22,11 @@ def test_to_timedelta(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col"]] + bf_df = scalar_types_df[["int64_col", "float64_col"]] bf_df["duration_us"] = bpd.to_timedelta(bf_df["int64_col"], "us") - bf_df["duration_s"] = bpd.to_timedelta(bf_df["int64_col"], "s") - bf_df["duration_w"] = bpd.to_timedelta(bf_df["int64_col"], "W") + bf_df["duration_s"] = bpd.to_timedelta(bf_df["float64_col"], "s") + bf_df["duration_w"] = bpd.to_timedelta(bf_df["int64_col"], "h") + bf_df["duration_on_duration"] = bpd.to_timedelta(bf_df["duration_us"], "ms") snapshot.assert_match(bf_df.sql, "out.sql") From 33a211eccc1c11523dca1c2356b3d7da386891a0 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Wed, 26 Nov 2025 11:55:19 -0800 Subject: [PATCH 264/313] refactor: fix agg_ops.DiffOp for Datetime and Timestamp (#2296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change aims to fix the `test_timestamp_series_diff_agg` test failing in #2248. Fixes internal issue 417774347 🦕 --- .../sqlglot/aggregations/unary_compiler.py | 66 ++++++------------- .../compile/sqlglot/aggregations/windows.py | 4 +- .../test_date_series_diff/out.sql | 13 ---- .../out.sql} | 0 .../test_diff_w_datetime/out.sql | 17 +++++ .../diff_int.sql => test_diff_w_int/out.sql} | 0 .../out.sql | 4 +- .../aggregations/test_unary_compiler.py | 53 +++++++-------- 8 files changed, 69 insertions(+), 88 deletions(-) delete mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_date_series_diff/out.sql rename tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/{test_diff/diff_bool.sql => test_diff_w_bool/out.sql} (100%) create mode 100644 tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_datetime/out.sql rename tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/{test_diff/diff_int.sql => test_diff_w_int/out.sql} (100%) rename tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/{test_time_series_diff => test_diff_w_timestamp}/out.sql (70%) diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py index 171c3cc239..ec711c7fa1 100644 --- a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -245,27 +245,6 @@ def _cut_ops_w_intervals( return case_expr -@UNARY_OP_REGISTRATION.register(agg_ops.DateSeriesDiffOp) -def _( - op: agg_ops.DateSeriesDiffOp, - column: typed_expr.TypedExpr, - window: typing.Optional[window_spec.WindowSpec] = None, -) -> sge.Expression: - if column.dtype != dtypes.DATE_DTYPE: - raise TypeError(f"Cannot perform date series diff on type {column.dtype}") - shift_op_impl = UNARY_OP_REGISTRATION[agg_ops.ShiftOp(0)] - shifted = shift_op_impl(agg_ops.ShiftOp(op.periods), column, window) - # Conversion factor from days to microseconds - conversion_factor = 24 * 60 * 60 * 1_000_000 - return sge.Cast( - this=sge.DateDiff( - this=column.expr, expression=shifted, unit=sge.Identifier(this="DAY") - ) - * sge.convert(conversion_factor), - to="INT64", - ) - - @UNARY_OP_REGISTRATION.register(agg_ops.DenseRankOp) def _( op: agg_ops.DenseRankOp, @@ -327,13 +306,27 @@ def _( ) -> sge.Expression: shift_op_impl = UNARY_OP_REGISTRATION[agg_ops.ShiftOp(0)] shifted = shift_op_impl(agg_ops.ShiftOp(op.periods), column, window) - if column.dtype in (dtypes.BOOL_DTYPE, dtypes.INT_DTYPE, dtypes.FLOAT_DTYPE): - if column.dtype == dtypes.BOOL_DTYPE: - return sge.NEQ(this=column.expr, expression=shifted) - else: - return sge.Sub(this=column.expr, expression=shifted) - else: - raise TypeError(f"Cannot perform diff on type {column.dtype}") + if column.dtype == dtypes.BOOL_DTYPE: + return sge.NEQ(this=column.expr, expression=shifted) + + if column.dtype in (dtypes.INT_DTYPE, dtypes.FLOAT_DTYPE): + return sge.Sub(this=column.expr, expression=shifted) + + if column.dtype == dtypes.TIMESTAMP_DTYPE: + return sge.TimestampDiff( + this=column.expr, + expression=shifted, + unit=sge.Identifier(this="MICROSECOND"), + ) + + if column.dtype == dtypes.DATETIME_DTYPE: + return sge.DatetimeDiff( + this=column.expr, + expression=shifted, + unit=sge.Identifier(this="MICROSECOND"), + ) + + raise TypeError(f"Cannot perform diff on type {column.dtype}") @UNARY_OP_REGISTRATION.register(agg_ops.MaxOp) @@ -593,23 +586,6 @@ def _( return sge.func("IFNULL", expr, ir._literal(zero, column.dtype)) -@UNARY_OP_REGISTRATION.register(agg_ops.TimeSeriesDiffOp) -def _( - op: agg_ops.TimeSeriesDiffOp, - column: typed_expr.TypedExpr, - window: typing.Optional[window_spec.WindowSpec] = None, -) -> sge.Expression: - if column.dtype != dtypes.TIMESTAMP_DTYPE: - raise TypeError(f"Cannot perform time series diff on type {column.dtype}") - shift_op_impl = UNARY_OP_REGISTRATION[agg_ops.ShiftOp(0)] - shifted = shift_op_impl(agg_ops.ShiftOp(op.periods), column, window) - return sge.TimestampDiff( - this=column.expr, - expression=shifted, - unit=sge.Identifier(this="MICROSECOND"), - ) - - @UNARY_OP_REGISTRATION.register(agg_ops.VarOp) def _( op: agg_ops.VarOp, diff --git a/bigframes/core/compile/sqlglot/aggregations/windows.py b/bigframes/core/compile/sqlglot/aggregations/windows.py index b775d6666a..5ca66ee505 100644 --- a/bigframes/core/compile/sqlglot/aggregations/windows.py +++ b/bigframes/core/compile/sqlglot/aggregations/windows.py @@ -62,10 +62,10 @@ def apply_window_if_present( # This is the key change. Don't create a spec for the default window frame # if there's no ordering. This avoids generating an `ORDER BY NULL` clause. - if not window.bounds and not order: + if window.is_unbounded and not order: return sge.Window(this=value, partition_by=group_by) - if not window.bounds and not include_framing_clauses: + if window.is_unbounded and not include_framing_clauses: return sge.Window(this=value, partition_by=group_by, order=order) kind = ( diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_date_series_diff/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_date_series_diff/out.sql deleted file mode 100644 index 84c95fd010..0000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_date_series_diff/out.sql +++ /dev/null @@ -1,13 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `date_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_1` AS ( - SELECT - *, - CAST(DATE_DIFF(`date_col`, LAG(`date_col`, 1) OVER (ORDER BY `date_col` ASC NULLS LAST), DAY) * 86400000000 AS INT64) AS `bfcol_1` - FROM `bfcte_0` -) -SELECT - `bfcol_1` AS `diff_date` -FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_bool.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_bool/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_bool.sql rename to tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_bool/out.sql diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_datetime/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_datetime/out.sql new file mode 100644 index 0000000000..9c279a479d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_datetime/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `datetime_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + DATETIME_DIFF( + `datetime_col`, + LAG(`datetime_col`, 1) OVER (ORDER BY `datetime_col` ASC NULLS LAST), + MICROSECOND + ) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `diff_datetime` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_int.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_int/out.sql similarity index 100% rename from tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff/diff_int.sql rename to tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_int/out.sql diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_time_series_diff/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_timestamp/out.sql similarity index 70% rename from tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_time_series_diff/out.sql rename to tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_timestamp/out.sql index 645f583dc1..1f8b8227b4 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_time_series_diff/out.sql +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_timestamp/out.sql @@ -7,11 +7,11 @@ WITH `bfcte_0` AS ( *, TIMESTAMP_DIFF( `timestamp_col`, - LAG(`timestamp_col`, 1) OVER (ORDER BY `timestamp_col` ASC NULLS LAST), + LAG(`timestamp_col`, 1) OVER (ORDER BY `timestamp_col` DESC), MICROSECOND ) AS `bfcol_1` FROM `bfcte_0` ) SELECT - `bfcol_1` AS `diff_time` + `bfcol_1` AS `diff_timestamp` FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py index 5f7d0d7653..fbf631d1a0 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -214,18 +214,7 @@ def test_dense_rank(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") -def test_date_series_diff(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "date_col" - bf_df = scalar_types_df[[col_name]] - window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) - op = agg_exprs.UnaryAggregation( - agg_ops.DateSeriesDiffOp(periods=1), expression.deref(col_name) - ) - sql = _apply_unary_window_op(bf_df, op, window, "diff_date") - snapshot.assert_match(sql, "out.sql") - - -def test_diff(scalar_types_df: bpd.DataFrame, snapshot): +def test_diff_w_int(scalar_types_df: bpd.DataFrame, snapshot): # Test integer int_col = "int64_col" bf_df_int = scalar_types_df[[int_col]] @@ -234,9 +223,10 @@ def test_diff(scalar_types_df: bpd.DataFrame, snapshot): agg_ops.DiffOp(periods=1), expression.deref(int_col) ) int_sql = _apply_unary_window_op(bf_df_int, int_op, window, "diff_int") - snapshot.assert_match(int_sql, "diff_int.sql") + snapshot.assert_match(int_sql, "out.sql") + - # Test boolean +def test_diff_w_bool(scalar_types_df: bpd.DataFrame, snapshot): bool_col = "bool_col" bf_df_bool = scalar_types_df[[bool_col]] window = window_spec.WindowSpec(ordering=(ordering.descending_over(bool_col),)) @@ -244,7 +234,29 @@ def test_diff(scalar_types_df: bpd.DataFrame, snapshot): agg_ops.DiffOp(periods=1), expression.deref(bool_col) ) bool_sql = _apply_unary_window_op(bf_df_bool, bool_op, window, "diff_bool") - snapshot.assert_match(bool_sql, "diff_bool.sql") + snapshot.assert_match(bool_sql, "out.sql") + + +def test_diff_w_datetime(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "datetime_col" + bf_df_date = scalar_types_df[[col_name]] + window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) + op = agg_exprs.UnaryAggregation( + agg_ops.DiffOp(periods=1), expression.deref(col_name) + ) + sql = _apply_unary_window_op(bf_df_date, op, window, "diff_datetime") + snapshot.assert_match(sql, "out.sql") + + +def test_diff_w_timestamp(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "timestamp_col" + bf_df_timestamp = scalar_types_df[[col_name]] + window = window_spec.WindowSpec(ordering=(ordering.descending_over(col_name),)) + op = agg_exprs.UnaryAggregation( + agg_ops.DiffOp(periods=1), expression.deref(col_name) + ) + sql = _apply_unary_window_op(bf_df_timestamp, op, window, "diff_timestamp") + snapshot.assert_match(sql, "out.sql") def test_first(scalar_types_df: bpd.DataFrame, snapshot): @@ -606,17 +618,6 @@ def test_sum(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql_window_partition, "window_partition_out.sql") -def test_time_series_diff(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "timestamp_col" - bf_df = scalar_types_df[[col_name]] - window = window_spec.WindowSpec(ordering=(ordering.ascending_over(col_name),)) - op = agg_exprs.UnaryAggregation( - agg_ops.TimeSeriesDiffOp(periods=1), expression.deref(col_name) - ) - sql = _apply_unary_window_op(bf_df, op, window, "diff_time") - snapshot.assert_match(sql, "out.sql") - - def test_var(scalar_types_df: bpd.DataFrame, snapshot): col_names = ["int64_col", "bool_col"] bf_df = scalar_types_df[col_names] From aae1b045b5c46d7ec4a45cdd1034ac408d670ad6 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Mon, 1 Dec 2025 10:20:11 -0800 Subject: [PATCH 265/313] refactor: fix some string ops in the sqlglot compiler. (#2299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change aims to fix some string-related tests failing in #2248. Fixes internal issue 417774347🦕 --- .../sqlglot/expressions/generic_ops.py | 1 + .../compile/sqlglot/expressions/string_ops.py | 24 ++++++++++++------- .../test_generic_ops/test_map/out.sql | 2 +- .../test_string_ops/test_len_w_array/out.sql | 13 ++++++++++ .../test_string_ops/test_strip/out.sql | 2 +- .../test_string_ops/test_zfill/out.sql | 6 ++--- .../sqlglot/expressions/test_string_ops.py | 8 +++++++ 7 files changed, 42 insertions(+), 14 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len_w_array/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/generic_ops.py b/bigframes/core/compile/sqlglot/expressions/generic_ops.py index 7ff09ab3f6..e44a1b5c1d 100644 --- a/bigframes/core/compile/sqlglot/expressions/generic_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/generic_ops.py @@ -107,6 +107,7 @@ def _(expr: TypedExpr, op: ops.MapOp) -> sge.Expression: sge.If(this=sge.convert(key), true=sge.convert(value)) for key, value in op.mappings ], + default=expr.expr, ) diff --git a/bigframes/core/compile/sqlglot/expressions/string_ops.py b/bigframes/core/compile/sqlglot/expressions/string_ops.py index bdc4808302..3e19a2fe33 100644 --- a/bigframes/core/compile/sqlglot/expressions/string_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/string_ops.py @@ -18,6 +18,7 @@ import sqlglot.expressions as sge +from bigframes import dtypes from bigframes import operations as ops from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler @@ -195,6 +196,9 @@ def _(expr: TypedExpr) -> sge.Expression: @register_unary_op(ops.len_op) def _(expr: TypedExpr) -> sge.Expression: + if dtypes.is_array_like(expr.dtype): + return sge.func("ARRAY_LENGTH", expr.expr) + return sge.Length(this=expr.expr) @@ -239,7 +243,7 @@ def to_startswith(pat: str) -> sge.Expression: @register_unary_op(ops.StrStripOp, pass_op=True) def _(expr: TypedExpr, op: ops.StrStripOp) -> sge.Expression: - return sge.Trim(this=sge.convert(op.to_strip), expression=expr.expr) + return sge.Trim(this=expr.expr, expression=sge.convert(op.to_strip)) @register_unary_op(ops.StringSplitOp, pass_op=True) @@ -284,27 +288,29 @@ def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: @register_unary_op(ops.ZfillOp, pass_op=True) def _(expr: TypedExpr, op: ops.ZfillOp) -> sge.Expression: + length_expr = sge.Greatest( + expressions=[sge.Length(this=expr.expr), sge.convert(op.width)] + ) return sge.Case( ifs=[ sge.If( - this=sge.EQ( - this=sge.Substring( - this=expr.expr, start=sge.convert(1), length=sge.convert(1) - ), - expression=sge.convert("-"), + this=sge.func( + "STARTS_WITH", + expr.expr, + sge.convert("-"), ), true=sge.Concat( expressions=[ sge.convert("-"), sge.func( "LPAD", - sge.Substring(this=expr.expr, start=sge.convert(1)), - sge.convert(op.width - 1), + sge.Substring(this=expr.expr, start=sge.convert(2)), + length_expr - 1, sge.convert("0"), ), ] ), ) ], - default=sge.func("LPAD", expr.expr, sge.convert(op.width), sge.convert("0")), + default=sge.func("LPAD", expr.expr, length_expr, sge.convert("0")), ) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_map/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_map/out.sql index 52a3174cf9..22628c6a4b 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_map/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_map/out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - CASE `string_col` WHEN 'value1' THEN 'mapped1' END AS `bfcol_1` + CASE `string_col` WHEN 'value1' THEN 'mapped1' ELSE `string_col` END AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len_w_array/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len_w_array/out.sql new file mode 100644 index 0000000000..609c4131e6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len_w_array/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int_list_col` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + ARRAY_LENGTH(`int_list_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `int_list_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strip/out.sql index 771bb9c49f..ebe4c39bbf 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strip/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strip/out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - TRIM(' ', `string_col`) AS `bfcol_1` + TRIM(`string_col`, ' ') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_zfill/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_zfill/out.sql index 97651ece49..79c4f695aa 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_zfill/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_zfill/out.sql @@ -6,9 +6,9 @@ WITH `bfcte_0` AS ( SELECT *, CASE - WHEN SUBSTRING(`string_col`, 1, 1) = '-' - THEN CONCAT('-', LPAD(SUBSTRING(`string_col`, 1), 9, '0')) - ELSE LPAD(`string_col`, 10, '0') + WHEN STARTS_WITH(`string_col`, '-') + THEN CONCAT('-', LPAD(SUBSTRING(`string_col`, 2), GREATEST(LENGTH(`string_col`), 10) - 1, '0')) + ELSE LPAD(`string_col`, GREATEST(LENGTH(`string_col`), 10), '0') END AS `bfcol_1` FROM `bfcte_0` ) diff --git a/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py index b20c038ed0..d1856b259d 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py @@ -120,6 +120,14 @@ def test_len(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_len_w_array(repeated_types_df: bpd.DataFrame, snapshot): + col_name = "int_list_col" + bf_df = repeated_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.len_op.as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + def test_lower(scalar_types_df: bpd.DataFrame, snapshot): col_name = "string_col" bf_df = scalar_types_df[[col_name]] From 4489687eafc9a1ea1b985600010296a4245cef94 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Mon, 1 Dec 2025 17:24:23 -0800 Subject: [PATCH 266/313] fix: Update max_instances default to reflect actual value (#2302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default maximum instances for cloud functions is 100, not 0. Updated the `expected_max_instances` in the `parametrize` decorator to 100 for the 'no-set' and 'set-None' test cases to accurately reflect the runtime behavior. Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes b/465212379 🦕 --- tests/system/large/functions/test_remote_function.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/system/large/functions/test_remote_function.py b/tests/system/large/functions/test_remote_function.py index f50e8c0a04..dae51c5b49 100644 --- a/tests/system/large/functions/test_remote_function.py +++ b/tests/system/large/functions/test_remote_function.py @@ -1651,13 +1651,12 @@ def square(x): return x * x -# Note: Zero represents default, which is 100 instances actually, which is why the remote function still works -# in the df.apply() call here +# The default value of 100 is used if the maximum instances value is not set. @pytest.mark.parametrize( ("max_instances_args", "expected_max_instances"), [ - pytest.param({}, 0, id="no-set"), - pytest.param({"cloud_function_max_instances": None}, 0, id="set-None"), + pytest.param({}, 100, id="no-set"), + pytest.param({"cloud_function_max_instances": None}, 100, id="set-None"), pytest.param({"cloud_function_max_instances": 1000}, 1000, id="set-explicit"), ], ) From a211753baf21b4b85d3560614fae18d47c35d150 Mon Sep 17 00:00:00 2001 From: Garrett Wu <6505921+GarrettWu@users.noreply.github.com> Date: Tue, 2 Dec 2025 14:35:02 -0800 Subject: [PATCH 267/313] chore: fix librarian release current version (#2305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes # 🦕 --- .librarian/state.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.librarian/state.yaml b/.librarian/state.yaml index 36216ec0db..d9874aa36d 100644 --- a/.librarian/state.yaml +++ b/.librarian/state.yaml @@ -1,7 +1,7 @@ image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator@sha256:c8612d3fffb3f6a32353b2d1abd16b61e87811866f7ec9d65b59b02eb452a620 libraries: - id: bigframes - version: 2.28.0 + version: 2.29.1 apis: [] source_roots: - . From 0b266da10f4d3d0ef9b4dd71ddadebfc7d5064ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Tue, 2 Dec 2025 16:35:48 -0600 Subject: [PATCH 268/313] docs: Add Google Analytics configuration to conf.py (#2301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See instructions at https://pydata-sphinx-theme.readthedocs.io/en/latest/user_guide/analytics.html#google-analytics Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes # 🦕 Co-authored-by: Shuowei Li --- docs/conf.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 2fc97bc1d0..a9ca501a8f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -172,6 +172,9 @@ "url": "https://docs.cloud.google.com/bigquery/docs/bigquery-dataframes-introduction", }, ], + "analytics": { + "google_analytics_id": "G-XVSRMCJ37X", + }, } # Add any paths that contain custom themes here, relative to this directory. From 41630b5f6b3cd91d700690aee40f342677903805 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Tue, 2 Dec 2025 14:58:47 -0800 Subject: [PATCH 269/313] refactor: Migrate DataFrame display to use IPython's _repr_mimebundle_() protocol for anywidget mode (#2271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR refactors the DataFrame display system to properly implement IPython's _repr_mimebundle_() protocol for anywidget mode, moving widget handling logic out of _repr_html_() and into the appropriate display method. See design doc here: screen/8XWbJDa2d9mb6r5 Fixes #<458796812> 🦕 --- bigframes/core/indexes/base.py | 4 +- bigframes/dataframe.py | 161 ++++-- bigframes/streaming/dataframe.py | 8 +- notebooks/dataframes/anywidget_mode.ipynb | 589 ++++++++++++++++++++-- tests/system/small/test_anywidget.py | 141 ++++-- tests/system/small/test_dataframe.py | 5 +- tests/system/small/test_ipython.py | 2 +- tests/system/small/test_progress_bar.py | 9 +- tests/unit/test_dataframe_polars.py | 5 +- 9 files changed, 796 insertions(+), 128 deletions(-) diff --git a/bigframes/core/indexes/base.py b/bigframes/core/indexes/base.py index 41b32d99e4..9576ca8e18 100644 --- a/bigframes/core/indexes/base.py +++ b/bigframes/core/indexes/base.py @@ -376,9 +376,7 @@ def __repr__(self) -> __builtins__.str: # metadata, like we do with DataFrame. opts = bigframes.options.display max_results = opts.max_rows - # anywdiget mode uses the same display logic as the "deferred" mode - # for faster execution - if opts.repr_mode in ("deferred", "anywidget"): + if opts.repr_mode == "deferred": _, dry_run_query_job = self._block._compute_dry_run() return formatter.repr_query_job(dry_run_query_job) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 173aa48db8..739548f791 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -789,9 +789,7 @@ def __repr__(self) -> str: opts = bigframes.options.display max_results = opts.max_rows - # anywdiget mode uses the same display logic as the "deferred" mode - # for faster execution - if opts.repr_mode in ("deferred", "anywidget"): + if opts.repr_mode == "deferred": return formatter.repr_query_job(self._compute_dry_run()) # TODO(swast): pass max_columns and get the true column count back. Maybe @@ -829,68 +827,138 @@ def __repr__(self) -> str: lines.append(f"[{row_count} rows x {column_count} columns]") return "\n".join(lines) - def _repr_html_(self) -> str: - """ - Returns an html string primarily for use by notebooks for displaying - a representation of the DataFrame. Displays 20 rows by default since - many notebooks are not configured for large tables. - """ - opts = bigframes.options.display - max_results = opts.max_rows - if opts.repr_mode == "deferred": - return formatter.repr_query_job(self._compute_dry_run()) - - # Process blob columns first, regardless of display mode - self._cached() - df = self.copy() + def _get_display_df_and_blob_cols(self) -> tuple[DataFrame, list[str]]: + """Process blob columns for display.""" + df = self + blob_cols = [] if bigframes.options.display.blob_display: blob_cols = [ series_name - for series_name, series in df.items() + for series_name, series in self.items() if series.dtype == bigframes.dtypes.OBJ_REF_DTYPE ] - for col in blob_cols: - # TODO(garrettwu): Not necessary to get access urls for all the rows. Update when having a to get URLs from local data. - df[col] = df[col].blob._get_runtime(mode="R", with_metadata=True) + if blob_cols: + df = self.copy() + for col in blob_cols: + # TODO(garrettwu): Not necessary to get access urls for all the rows. Update when having a to get URLs from local data. + df[col] = df[col].blob._get_runtime(mode="R", with_metadata=True) + return df, blob_cols + + def _get_anywidget_bundle(self, include=None, exclude=None): + """ + Helper method to create and return the anywidget mimebundle. + This function encapsulates the logic for anywidget display. + """ + from bigframes import display + + # TODO(shuowei): Keep blob_cols and pass them to TableWidget so that they can render properly. + df, _ = self._get_display_df_and_blob_cols() + + # Create and display the widget + widget = display.TableWidget(df) + widget_repr_result = widget._repr_mimebundle_(include=include, exclude=exclude) + + # Handle both tuple (data, metadata) and dict returns + if isinstance(widget_repr_result, tuple): + widget_repr = dict(widget_repr_result[0]) # Extract data dict from tuple else: - blob_cols = [] + widget_repr = dict(widget_repr_result) - if opts.repr_mode == "anywidget": - try: - from IPython.display import display as ipython_display + # At this point, we have already executed the query as part of the + # widget construction. Let's use the information available to render + # the HTML and plain text versions. + widget_repr["text/html"] = widget.table_html + + widget_repr["text/plain"] = self._create_text_representation( + widget._cached_data, widget.row_count + ) + + return widget_repr + + def _create_text_representation( + self, pandas_df: pandas.DataFrame, total_rows: typing.Optional[int] + ) -> str: + """Create a text representation of the DataFrame.""" + opts = bigframes.options.display + with display_options.pandas_repr(opts): + import pandas.io.formats + + # safe to mutate this, this dict is owned by this code, and does not affect global config + to_string_kwargs = ( + pandas.io.formats.format.get_dataframe_repr_params() # type: ignore + ) + if not self._has_index: + to_string_kwargs.update({"index": False}) + + # We add our own dimensions string, so don't want pandas to. + to_string_kwargs.update({"show_dimensions": False}) + repr_string = pandas_df.to_string(**to_string_kwargs) - from bigframes import display + lines = repr_string.split("\n") - # Always create a new widget instance for each display call - # This ensures that each cell gets its own widget and prevents - # unintended sharing between cells - widget = display.TableWidget(df.copy()) + if total_rows is not None and total_rows > len(pandas_df): + lines.append("...") - ipython_display(widget) - return "" # Return empty string since we used display() + lines.append("") + column_count = len(self.columns) + lines.append(f"[{total_rows or '?'} rows x {column_count} columns]") + return "\n".join(lines) - except (AttributeError, ValueError, ImportError): - # Fallback if anywidget is not available + def _repr_mimebundle_(self, include=None, exclude=None): + """ + Custom display method for IPython/Jupyter environments. + This is called by IPython's display system when the object is displayed. + """ + opts = bigframes.options.display + # Only handle widget display in anywidget mode + if opts.repr_mode == "anywidget": + try: + return self._get_anywidget_bundle(include=include, exclude=exclude) + + except ImportError: + # Anywidget is an optional dependency, so warn rather than fail. + # TODO(shuowei): When Anywidget becomes the default for all repr modes, + # remove this warning. warnings.warn( "Anywidget mode is not available. " "Please `pip install anywidget traitlets` or `pip install 'bigframes[anywidget]'` to use interactive tables. " - f"Falling back to deferred mode. Error: {traceback.format_exc()}" + f"Falling back to static HTML. Error: {traceback.format_exc()}" ) - return formatter.repr_query_job(self._compute_dry_run()) - # Continue with regular HTML rendering for non-anywidget modes - # TODO(swast): pass max_columns and get the true column count back. Maybe - # get 1 more column than we have requested so that pandas can add the - # ... for us? + # In non-anywidget mode, fetch data once and use it for both HTML + # and plain text representations to avoid multiple queries. + opts = bigframes.options.display + max_results = opts.max_rows + + df, blob_cols = self._get_display_df_and_blob_cols() + pandas_df, row_count, query_job = df._block.retrieve_repr_request_results( max_results ) - self._set_internal_query_job(query_job) column_count = len(pandas_df.columns) + html_string = self._create_html_representation( + pandas_df, row_count, column_count, blob_cols + ) + + text_representation = self._create_text_representation(pandas_df, row_count) + + return {"text/html": html_string, "text/plain": text_representation} + + def _create_html_representation( + self, + pandas_df: pandas.DataFrame, + row_count: int, + column_count: int, + blob_cols: list[str], + ) -> str: + """Create an HTML representation of the DataFrame.""" + opts = bigframes.options.display with display_options.pandas_repr(opts): - # Allows to preview images in the DataFrame. The implementation changes the string repr as well, that it doesn't truncate strings or escape html charaters such as "<" and ">". We may need to implement a full-fledged repr module to better support types not in pandas. + # TODO(shuowei, b/464053870): Escaping HTML would be useful, but + # `escape=False` is needed to show images. We may need to implement + # a full-fledged repr module to better support types not in pandas. if bigframes.options.display.blob_display and blob_cols: def obj_ref_rt_to_html(obj_ref_rt) -> str: @@ -919,15 +987,12 @@ def obj_ref_rt_to_html(obj_ref_rt) -> str: # set max_colwidth so not to truncate the image url with pandas.option_context("display.max_colwidth", None): - max_rows = pandas.get_option("display.max_rows") - max_cols = pandas.get_option("display.max_columns") - show_dimensions = pandas.get_option("display.show_dimensions") html_string = pandas_df.to_html( escape=False, notebook=True, - max_rows=max_rows, - max_cols=max_cols, - show_dimensions=show_dimensions, + max_rows=pandas.get_option("display.max_rows"), + max_cols=pandas.get_option("display.max_columns"), + show_dimensions=pandas.get_option("display.show_dimensions"), formatters=formatters, # type: ignore ) else: diff --git a/bigframes/streaming/dataframe.py b/bigframes/streaming/dataframe.py index 7dc9e964bc..3e030a4aa2 100644 --- a/bigframes/streaming/dataframe.py +++ b/bigframes/streaming/dataframe.py @@ -291,13 +291,13 @@ def __repr__(self, *args, **kwargs): __repr__.__doc__ = _curate_df_doc(inspect.getdoc(dataframe.DataFrame.__repr__)) - def _repr_html_(self, *args, **kwargs): - return _return_type_wrapper(self._df._repr_html_, StreamingDataFrame)( + def _repr_mimebundle_(self, *args, **kwargs): + return _return_type_wrapper(self._df._repr_mimebundle_, StreamingDataFrame)( *args, **kwargs ) - _repr_html_.__doc__ = _curate_df_doc( - inspect.getdoc(dataframe.DataFrame._repr_html_) + _repr_mimebundle_.__doc__ = _curate_df_doc( + inspect.getdoc(dataframe.DataFrame._repr_mimebundle_) ) @property diff --git a/notebooks/dataframes/anywidget_mode.ipynb b/notebooks/dataframes/anywidget_mode.ipynb index fa324c246a..427a1e5371 100644 --- a/notebooks/dataframes/anywidget_mode.ipynb +++ b/notebooks/dataframes/anywidget_mode.ipynb @@ -76,11 +76,50 @@ "id": "f289d250", "metadata": {}, "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 0 Bytes in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "name": "stdout", "output_type": "stream", "text": [ - "Computation deferred. Computation will process 171.4 MB\n" + "state gender year name number\n", + " AL F 1910 Vera 71\n", + " AR F 1910 Viola 37\n", + " AR F 1910 Alice 57\n", + " AR F 1910 Edna 95\n", + " AR F 1910 Ollie 40\n", + " CA F 1910 Beatrice 37\n", + " CT F 1910 Marion 36\n", + " CT F 1910 Marie 36\n", + " FL F 1910 Alice 53\n", + " GA F 1910 Thelma 133\n", + "...\n", + "\n", + "[5552452 rows x 5 columns]\n" ] } ], @@ -157,22 +196,210 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "2935e3f8f4f34c558d588f09a9c42131", + "model_id": "2aad385a8a2f411c822dafe7b07fbad8", "version_major": 2, "version_minor": 1 }, + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
state
gender
year
name
number
\n", + " AL\n", + " \n", + " F\n", + " \n", + " 1910\n", + " \n", + " Cora\n", + " \n", + " 61\n", + "
\n", + " AL\n", + " \n", + " F\n", + " \n", + " 1910\n", + " \n", + " Anna\n", + " \n", + " 74\n", + "
\n", + " AR\n", + " \n", + " F\n", + " \n", + " 1910\n", + " \n", + " Willie\n", + " \n", + " 132\n", + "
\n", + " CO\n", + " \n", + " F\n", + " \n", + " 1910\n", + " \n", + " Anna\n", + " \n", + " 42\n", + "
\n", + " FL\n", + " \n", + " F\n", + " \n", + " 1910\n", + " \n", + " Louise\n", + " \n", + " 70\n", + "
\n", + " GA\n", + " \n", + " F\n", + " \n", + " 1910\n", + " \n", + " Catherine\n", + " \n", + " 57\n", + "
\n", + " IL\n", + " \n", + " F\n", + " \n", + " 1910\n", + " \n", + " Jessie\n", + " \n", + " 43\n", + "
\n", + " IN\n", + " \n", + " F\n", + " \n", + " 1910\n", + " \n", + " Anna\n", + " \n", + " 100\n", + "
\n", + " IN\n", + " \n", + " F\n", + " \n", + " 1910\n", + " \n", + " Pauline\n", + " \n", + " 77\n", + "
\n", + " IN\n", + " \n", + " F\n", + " \n", + " 1910\n", + " \n", + " Beulah\n", + " \n", + " 39\n", + "
" + ], "text/plain": [ - "TableWidget(orderable_columns=['state', 'gender', 'year', 'name', 'number'], page_size=10, row_count=5552452, …" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [], - "text/plain": [ - "Computation deferred. Computation will process 171.4 MB" + "state gender year name number\n", + " AL F 1910 Cora 61\n", + " AL F 1910 Anna 74\n", + " AR F 1910 Willie 132\n", + " CO F 1910 Anna 42\n", + " FL F 1910 Louise 70\n", + " GA F 1910 Catherine 57\n", + " IL F 1910 Jessie 43\n", + " IN F 1910 Anna 100\n", + " IN F 1910 Pauline 77\n", + " IN F 1910 Beulah 39\n", + "...\n", + "\n", + "[5552452 rows x 5 columns]" ] }, "execution_count": 6, @@ -255,7 +482,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "fa03d998dfee47638a32b6c21ace0b5c", + "model_id": "0c0b83e7e3c048ff8abb525e1bfd6c5f", "version_major": 2, "version_minor": 1 }, @@ -369,7 +596,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "6d6886bd2bb74be996d54ce240cbe6c9", + "model_id": "6a60e5dd37c64e76a8e3804dd3531f70", "version_major": 2, "version_minor": 1 }, @@ -401,7 +628,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 10, "id": "added-cell-1", "metadata": {}, "outputs": [ @@ -409,7 +636,7 @@ "data": { "text/html": [ "✅ Completed. \n", - " Query processed 85.9 kB in 24 seconds of slot time.\n", + " Query processed 85.9 kB in 14 seconds of slot time.\n", " " ], "text/plain": [ @@ -453,28 +680,330 @@ "metadata": {}, "output_type": "display_data" }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/bigframes/dtypes.py:987: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "df774329fd2f47918b986362863d7155", + "model_id": "893065f8a0164648b241f2cc3d1a9271", "version_major": 2, "version_minor": 1 }, + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
result
gcs_path
issuer
language
publication_date
class_international
class_us
application_number
filing_date
priority_date_eu
representative_line_1_eu
applicant_line_1
inventor_line_1
title_line_1
number
\n", + " {'application_number': None, 'class_international': None, 'filing_date': None, 'publication_date': None, 'full_response': '{}', 'status': 'INVALID_ARGUMENT: Invalid field in objectref details, only a JSON object named gcs_metadata is allowed [type.googleapis.com/util.MessageSetPayload=\\'[dremel.DremelErrorWithDetails] { argument_error { query_error { } } debug_info { error_message_template: "Invalid field in objectref details, only a JSON object named $0 is allowed" error_id: 3270173750 } }\\']'}\n", + " \n", + " gs://gcs-public-data--labeled-patents/espacenet_de73.pdf\n", + " \n", + " EU\n", + " \n", + " DE\n", + " \n", + " 03.10.2018\n", + " \n", + " H05B 6/12\n", + " \n", + " <NA>\n", + " \n", + " 18165514.3\n", + " \n", + " 03.04.2018\n", + " \n", + " 30.03.2017\n", + " \n", + " <NA>\n", + " \n", + " BSH Hausger√§te GmbH\n", + " \n", + " Acero Acero, Jesus\n", + " \n", + " VORRICHTUNG ZUR INDUKTIVEN ENERGIE√úBERTRAGUNG\n", + " \n", + " EP 3 383 141 A2\n", + "
\n", + " {'application_number': None, 'class_international': None, 'filing_date': None, 'publication_date': None, 'full_response': '{}', 'status': 'INVALID_ARGUMENT: Invalid field in objectref details, only a JSON object named gcs_metadata is allowed [type.googleapis.com/util.MessageSetPayload=\\'[dremel.DremelErrorWithDetails] { argument_error { query_error { } } debug_info { error_message_template: "Invalid field in objectref details, only a JSON object named $0 is allowed" error_id: 3270173750 } }\\']'}\n", + " \n", + " gs://gcs-public-data--labeled-patents/espacenet_de2.pdf\n", + " \n", + " EU\n", + " \n", + " DE\n", + " \n", + " 29.08.018\n", + " \n", + " E04H 6/12\n", + " \n", + " <NA>\n", + " \n", + " 18157874.1\n", + " \n", + " 21.02.2018\n", + " \n", + " 22.02.2017\n", + " \n", + " Liedtke & Partner Patentanwälte\n", + " \n", + " SHB Hebezeugbau GmbH\n", + " \n", + " VOLGER, Alexander\n", + " \n", + " STEUERUNGSSYSTEM FÜR AUTOMATISCHE PARKHÄUSER\n", + " \n", + " EP 3 366 869 A1\n", + "
\n", + " {'application_number': None, 'class_international': None, 'filing_date': None, 'publication_date': None, 'full_response': '{}', 'status': 'INVALID_ARGUMENT: Invalid field in objectref details, only a JSON object named gcs_metadata is allowed [type.googleapis.com/util.MessageSetPayload=\\'[dremel.DremelErrorWithDetails] { argument_error { query_error { } } debug_info { error_message_template: "Invalid field in objectref details, only a JSON object named $0 is allowed" error_id: 3270173750 } }\\']'}\n", + " \n", + " gs://gcs-public-data--labeled-patents/espacenet_de70.pdf\n", + " \n", + " EU\n", + " \n", + " DE\n", + " \n", + " 03.10.2018\n", + " \n", + " H01L 21/20\n", + " \n", + " <NA>\n", + " \n", + " 18166536.5\n", + " \n", + " 16.02.2016\n", + " \n", + " <NA>\n", + " \n", + " Scheider, Sascha et al\n", + " \n", + " EV Group E. Thallner GmbH\n", + " \n", + " Kurz, Florian\n", + " \n", + " VORRICHTUNG ZUM BONDEN VON SUBSTRATEN\n", + " \n", + " EP 3 382 744 A1\n", + "
\n", + " {'application_number': None, 'class_international': None, 'filing_date': None, 'publication_date': None, 'full_response': '{}', 'status': 'INVALID_ARGUMENT: Invalid field in objectref details, only a JSON object named gcs_metadata is allowed [type.googleapis.com/util.MessageSetPayload=\\'[dremel.DremelErrorWithDetails] { argument_error { query_error { } } debug_info { error_message_template: "Invalid field in objectref details, only a JSON object named $0 is allowed" error_id: 3270173750 } }\\']'}\n", + " \n", + " gs://gcs-public-data--labeled-patents/espacenet_de5.pdf\n", + " \n", + " EU\n", + " \n", + " DE\n", + " \n", + " 03.10.2018\n", + " \n", + " G06F 11/30\n", + " \n", + " <NA>\n", + " \n", + " 18157347.8\n", + " \n", + " 19.02.2018\n", + " \n", + " 31.03.2017\n", + " \n", + " Hoffmann Eitle\n", + " \n", + " FUJITSU LIMITED\n", + " \n", + " Kukihara, Kensuke\n", + " \n", + " METHOD EXECUTED BY A COMPUTER, INFORMATION PROCESSING APPARATUS AND\n", + " \n", + " EP 3 382 553 A1\n", + "
\n", + " {'application_number': None, 'class_international': None, 'filing_date': None, 'publication_date': None, 'full_response': '{}', 'status': 'INVALID_ARGUMENT: Invalid field in objectref details, only a JSON object named gcs_metadata is allowed [type.googleapis.com/util.MessageSetPayload=\\'[dremel.DremelErrorWithDetails] { argument_error { query_error { } } debug_info { error_message_template: "Invalid field in objectref details, only a JSON object named $0 is allowed" error_id: 3270173750 } }\\']'}\n", + " \n", + " gs://gcs-public-data--labeled-patents/espacenet_de56.pdf\n", + " \n", + " EU\n", + " \n", + " DE\n", + " \n", + " 03.10.2018\n", + " \n", + " A01K 31/00\n", + " \n", + " <NA>\n", + " \n", + " 18171005.4\n", + " \n", + " 05.02.2015\n", + " \n", + " 05.02.2014\n", + " \n", + " Stork Bamberger Patentanwälte\n", + " \n", + " Linco Food Systems A/S\n", + " \n", + " Thrane, Uffe\n", + " \n", + " MASTHÄHNCHENCONTAINER ALS BESTANDTEIL EINER EINHEIT UND EINER ANORDNUNG\n", + " \n", + " EP 3 381 276 A1\n", + "
" + ], "text/plain": [ - "TableWidget(orderable_columns=['gcs_path', 'issuer', 'language', 'publication_date', 'class_international', 'c…" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [], - "text/plain": [ - "Computation deferred. Computation will process 0 Bytes" + " result \\\n", + "0 {'application_number': None, 'class_internatio... \n", + "1 {'application_number': None, 'class_internatio... \n", + "2 {'application_number': None, 'class_internatio... \n", + "3 {'application_number': None, 'class_internatio... \n", + "4 {'application_number': None, 'class_internatio... \n", + "\n", + " gcs_path issuer language \\\n", + "0 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", + "1 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", + "2 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", + "3 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", + "4 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", + "\n", + " publication_date class_international class_us application_number \\\n", + "0 03.10.2018 H05B 6/12 18165514.3 \n", + "1 29.08.018 E04H 6/12 18157874.1 \n", + "2 03.10.2018 H01L 21/20 18166536.5 \n", + "3 03.10.2018 G06F 11/30 18157347.8 \n", + "4 03.10.2018 A01K 31/00 18171005.4 \n", + "\n", + " filing_date priority_date_eu representative_line_1_eu \\\n", + "0 03.04.2018 30.03.2017 \n", + "1 21.02.2018 22.02.2017 Liedtke & Partner Patentanw√§lte \n", + "2 16.02.2016 Scheider, Sascha et al \n", + "3 19.02.2018 31.03.2017 Hoffmann Eitle \n", + "4 05.02.2015 05.02.2014 Stork Bamberger Patentanw√§lte \n", + "\n", + " applicant_line_1 inventor_line_1 \\\n", + "0 BSH Hausger√§te GmbH Acero Acero, Jesus \n", + "1 SHB Hebezeugbau GmbH VOLGER, Alexander \n", + "2 EV Group E. Thallner GmbH Kurz, Florian \n", + "3 FUJITSU LIMITED Kukihara, Kensuke \n", + "4 Linco Food Systems A/S Thrane, Uffe \n", + "\n", + " title_line_1 number \n", + "0 VORRICHTUNG ZUR INDUKTIVEN ENERGIE√úBERTRAGUNG EP 3 383 141 A2 \n", + "1 STEUERUNGSSYSTEM F√úR AUTOMATISCHE PARKH√ÑUSER EP 3 366 869 A1 \n", + "2 VORRICHTUNG ZUM BONDEN VON SUBSTRATEN EP 3 382 744 A1 \n", + "3 METHOD EXECUTED BY A COMPUTER, INFORMATION PRO... EP 3 382 553 A1 \n", + "4 MASTH√ÑHNCHENCONTAINER ALS BESTANDTEIL EINER E... EP 3 381 276 A1 \n", + "\n", + "[5 rows x 15 columns]" ] }, - "execution_count": 11, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } diff --git a/tests/system/small/test_anywidget.py b/tests/system/small/test_anywidget.py index 49d5ff6c92..c8740ed220 100644 --- a/tests/system/small/test_anywidget.py +++ b/tests/system/small/test_anywidget.py @@ -37,12 +37,12 @@ def paginated_pandas_df() -> pd.DataFrame: { "id": [5, 4, 3, 2, 1, 0], "page_indicator": [ - "row_5", - "row_4", - "row_3", - "row_2", - "row_1", - "row_0", + "page_3_row_2", + "page_3_row_1", + "page_2_row_2", + "page_2_row_1", + "page_1_row_2", + "page_1_row_1", ], "value": [5, 4, 3, 2, 1, 0], } @@ -205,11 +205,12 @@ def test_widget_initialization_should_calculate_total_row_count( assert widget.row_count == EXPECTED_ROW_COUNT -def test_widget_initialization_should_set_default_pagination( +def test_widget_initialization_should_default_to_page_zero( table_widget, ): """ - A TableWidget should initialize with page 0 and the correct page size. + Given a new TableWidget, when it is initialized, + then its page number should default to 0. """ # The `table_widget` fixture already creates the widget. # Assert its state. @@ -259,8 +260,8 @@ def test_widget_navigation_should_display_correct_page( _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) -def test_widget_navigation_should_raise_error_for_negative_input( - table_widget, paginated_pandas_df: pd.DataFrame +def test_setting_negative_page_should_raise_error( + table_widget, ): """ Given a widget, when a negative page number is set, @@ -270,19 +271,20 @@ def test_widget_navigation_should_raise_error_for_negative_input( table_widget.page = -1 -def test_widget_navigation_should_clamp_to_last_page_for_out_of_bounds_input( +def test_setting_page_beyond_max_should_clamp_to_last_page( table_widget, paginated_pandas_df: pd.DataFrame ): """ - Given a widget, when a page number greater than the max is set, + Given a widget, + when a page number greater than the max is set, then the page number should be clamped to the last valid page. """ - expected_slice = paginated_pandas_df.iloc[4:6] + expected_slice = paginated_pandas_df.iloc[4:6] # Last page data - table_widget.page = 100 + table_widget.page = 100 # Set page far beyond the total of 3 pages html = table_widget.table_html - assert table_widget.page == 2 + assert table_widget.page == 2 # Page is clamped to the last valid page (0-indexed) _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) @@ -332,11 +334,11 @@ def test_widget_with_few_rows_should_display_all_rows(small_widget, small_pandas _assert_html_matches_pandas_slice(html, small_pandas_df, small_pandas_df) -def test_widget_with_few_rows_should_have_only_one_page(small_widget): +def test_navigation_beyond_last_page_should_be_clamped(small_widget): """ - Given a DataFrame with a small number of rows, the widget should - report the correct total row count and prevent navigation beyond - the first page, ensuring the frontend correctly displays "Page 1 of 1". + Given a DataFrame smaller than the page size, + when navigating beyond the last page, + then the page should be clamped to the last valid page (page 0). """ # For a DataFrame with 2 rows and page_size 5 (from small_widget fixture), # the frontend should calculate 1 total page. @@ -351,43 +353,65 @@ def test_widget_with_few_rows_should_have_only_one_page(small_widget): assert small_widget.page == 0 -def test_widget_page_size_should_be_immutable_after_creation( +def test_global_options_change_should_not_affect_existing_widget_page_size( paginated_bf_df: bf.dataframe.DataFrame, ): """ - A widget's page size should be fixed on creation and not be affected - by subsequent changes to global options. + Given an existing widget, + when global display options are changed, + then the widget's page size should remain unchanged. """ with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): from bigframes.display import TableWidget widget = TableWidget(paginated_bf_df) assert widget.page_size == 2 - - # Navigate to second page to ensure widget is in a non-default state - widget.page = 1 + widget.page = 1 # a non-default state assert widget.page == 1 - # Change global max_rows - widget should not be affected - bf.options.display.max_rows = 10 + bf.options.display.max_rows = 10 # Change global setting - assert widget.page_size == 2 # Should remain unchanged - assert widget.page == 1 # Should remain on same page + assert widget.page_size == 2 # Should remain unchanged + assert widget.page == 1 # Page should not be reset -def test_empty_widget_should_have_zero_row_count(empty_bf_df: bf.dataframe.DataFrame): - """Given an empty DataFrame, the widget's row count should be 0.""" +def test_widget_with_empty_dataframe_should_have_zero_row_count( + empty_bf_df: bf.dataframe.DataFrame, +): + """ + Given an empty DataFrame, + when a widget is created from it, + then its row_count should be 0. + """ + with bf.option_context("display.repr_mode", "anywidget"): from bigframes.display import TableWidget widget = TableWidget(empty_bf_df) - assert widget.row_count == 0 + assert widget.row_count == 0 + + +def test_widget_with_empty_dataframe_should_render_table_headers( + empty_bf_df: bf.dataframe.DataFrame, +): + """ + + + Given an empty DataFrame, + + + when a widget is created from it, + + + then its HTML representation should still render the table headers. + + + """ -def test_empty_widget_should_render_table_headers(empty_bf_df: bf.dataframe.DataFrame): - """Given an empty DataFrame, the widget should still render table headers.""" with bf.option_context("display.repr_mode", "anywidget"): + from bigframes.display import TableWidget widget = TableWidget(empty_bf_df) @@ -395,7 +419,8 @@ def test_empty_widget_should_render_table_headers(empty_bf_df: bf.dataframe.Data html = widget.table_html assert "= 1 - assert test_df._block.retrieve_repr_request_results.cache_info().hits >= 1 + assert test_df._block.retrieve_repr_request_results.cache_info().hits == 0 diff --git a/tests/system/small/test_progress_bar.py b/tests/system/small/test_progress_bar.py index 0c9c4070f4..d726bfde2c 100644 --- a/tests/system/small/test_progress_bar.py +++ b/tests/system/small/test_progress_bar.py @@ -153,7 +153,9 @@ def test_repr_anywidget_dataframe(penguins_df_default_index: bf.dataframe.DataFr pytest.importorskip("anywidget") with bf.option_context("display.repr_mode", "anywidget"): actual_repr = repr(penguins_df_default_index) - assert EXPECTED_DRY_RUN_MESSAGE in actual_repr + assert "species" in actual_repr + assert "island" in actual_repr + assert "[344 rows x 7 columns]" in actual_repr def test_repr_anywidget_index(penguins_df_default_index: bf.dataframe.DataFrame): @@ -161,4 +163,7 @@ def test_repr_anywidget_index(penguins_df_default_index: bf.dataframe.DataFrame) with bf.option_context("display.repr_mode", "anywidget"): index = penguins_df_default_index.index actual_repr = repr(index) - assert EXPECTED_DRY_RUN_MESSAGE in actual_repr + # In non-interactive environments, should still get a useful summary. + assert "Index" in actual_repr + assert "0, 1, 2, 3, 4" in actual_repr + assert "dtype='Int64'" in actual_repr diff --git a/tests/unit/test_dataframe_polars.py b/tests/unit/test_dataframe_polars.py index b83380d789..39dbacd087 100644 --- a/tests/unit/test_dataframe_polars.py +++ b/tests/unit/test_dataframe_polars.py @@ -737,7 +737,7 @@ def test_join_repr(scalars_dfs): assert actual == expected -def test_repr_html_w_all_rows(scalars_dfs, session): +def test_mimebundle_html_repr_w_all_rows(scalars_dfs, session): scalars_df, _ = scalars_dfs # get a pandas df of the expected format df, _ = scalars_df._block.to_pandas() @@ -745,7 +745,8 @@ def test_repr_html_w_all_rows(scalars_dfs, session): pandas_df.index.name = scalars_df.index.name # When there are 10 or fewer rows, the outputs should be identical except for the extra note. - actual = scalars_df.head(10)._repr_html_() + bundle = scalars_df.head(10)._repr_mimebundle_() + actual = bundle["text/html"] with display_options.pandas_repr(bigframes.options.display): pandas_repr = pandas_df.head(10)._repr_html_() From 52665fa57ef13c58254bfc8736afcc521f7f0f11 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Tue, 2 Dec 2025 17:05:19 -0800 Subject: [PATCH 270/313] feat: Allow drop_duplicates over unordered dataframe (#2303) --- bigframes/core/block_transforms.py | 45 +++++++++++------------ bigframes/core/compile/polars/compiler.py | 3 ++ bigframes/core/indexes/base.py | 2 - bigframes/dataframe.py | 4 -- bigframes/series.py | 4 -- tests/system/large/test_dataframe.py | 24 ++++++++++++ 6 files changed, 49 insertions(+), 33 deletions(-) diff --git a/bigframes/core/block_transforms.py b/bigframes/core/block_transforms.py index 773a615fd9..16be560b05 100644 --- a/bigframes/core/block_transforms.py +++ b/bigframes/core/block_transforms.py @@ -67,40 +67,39 @@ def indicate_duplicates( if keep not in ["first", "last", False]: raise ValueError("keep must be one of 'first', 'last', or False'") + rownums = agg_expressions.WindowExpression( + agg_expressions.NullaryAggregation( + agg_ops.RowNumberOp(), + ), + window=windows.unbound(grouping_keys=tuple(columns)), + ) + count = agg_expressions.WindowExpression( + agg_expressions.NullaryAggregation( + agg_ops.SizeOp(), + ), + window=windows.unbound(grouping_keys=tuple(columns)), + ) + if keep == "first": # Count how many copies occur up to current copy of value # Discard this value if there are copies BEFORE - window_spec = windows.cumulative_rows( - grouping_keys=tuple(columns), - ) + predicate = ops.gt_op.as_expr(rownums, ex.const(0)) elif keep == "last": # Count how many copies occur up to current copy of values # Discard this value if there are copies AFTER - window_spec = windows.inverse_cumulative_rows( - grouping_keys=tuple(columns), - ) + predicate = ops.lt_op.as_expr(rownums, ops.sub_op.as_expr(count, ex.const(1))) else: # keep == False # Count how many copies of the value occur in entire series. # Discard this value if there are copies ANYWHERE - window_spec = windows.unbound(grouping_keys=tuple(columns)) - block, dummy = block.create_constant(1) - # use row number as will work even with partial ordering - block, val_count_col_id = block.apply_window_op( - dummy, - agg_ops.sum_op, - window_spec=window_spec, - ) - block, duplicate_indicator = block.project_expr( - ops.gt_op.as_expr(val_count_col_id, ex.const(1)) + predicate = ops.gt_op.as_expr(count, ex.const(1)) + + block = block.project_block_exprs( + [predicate], + labels=[None], ) return ( - block.drop_columns( - ( - dummy, - val_count_col_id, - ) - ), - duplicate_indicator, + block, + block.value_columns[-1], ) diff --git a/bigframes/core/compile/polars/compiler.py b/bigframes/core/compile/polars/compiler.py index 5988ecaa90..20fdeb5be3 100644 --- a/bigframes/core/compile/polars/compiler.py +++ b/bigframes/core/compile/polars/compiler.py @@ -547,6 +547,9 @@ def compile_agg_op( return pl.col(*inputs).first() if isinstance(op, agg_ops.LastOp): return pl.col(*inputs).last() + if isinstance(op, agg_ops.RowNumberOp): + # pl.row_index is not yet stable enough to use here, and only supports polars>=1.32 + return pl.int_range(pl.len(), dtype=pl.Int64) if isinstance(op, agg_ops.ShiftOp): return pl.col(*inputs).shift(op.periods) if isinstance(op, agg_ops.DiffOp): diff --git a/bigframes/core/indexes/base.py b/bigframes/core/indexes/base.py index 9576ca8e18..383534fa4d 100644 --- a/bigframes/core/indexes/base.py +++ b/bigframes/core/indexes/base.py @@ -624,8 +624,6 @@ def dropna(self, how: typing.Literal["all", "any"] = "any") -> Index: return Index(result) def drop_duplicates(self, *, keep: __builtins__.str = "first") -> Index: - if keep is not False: - validations.enforce_ordered(self, "drop_duplicates") block = block_ops.drop_duplicates(self._block, self._block.index_columns, keep) return Index(block) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 739548f791..2206db9bf6 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -5054,8 +5054,6 @@ def drop_duplicates( *, keep: str = "first", ) -> DataFrame: - if keep is not False: - validations.enforce_ordered(self, "drop_duplicates(keep != False)") if subset is None: column_ids = self._block.value_columns elif utils.is_list_like(subset): @@ -5069,8 +5067,6 @@ def drop_duplicates( return DataFrame(block) def duplicated(self, subset=None, keep: str = "first") -> bigframes.series.Series: - if keep is not False: - validations.enforce_ordered(self, "duplicated(keep != False)") if subset is None: column_ids = self._block.value_columns else: diff --git a/bigframes/series.py b/bigframes/series.py index f2d4d98c14..51d7cc76ee 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -2227,8 +2227,6 @@ def reindex_like(self, other: Series, *, validate: typing.Optional[bool] = None) return self.reindex(other.index, validate=validate) def drop_duplicates(self, *, keep: str = "first") -> Series: - if keep is not False: - validations.enforce_ordered(self, "drop_duplicates(keep != False)") block = block_ops.drop_duplicates(self._block, (self._value_column,), keep) return Series(block) @@ -2249,8 +2247,6 @@ def unique(self, keep_order=True) -> Series: return Series(block.select_columns(result).reset_index()) def duplicated(self, keep: str = "first") -> Series: - if keep is not False: - validations.enforce_ordered(self, "duplicated(keep != False)") block, indicator = block_ops.indicate_duplicates( self._block, (self._value_column,), keep ) diff --git a/tests/system/large/test_dataframe.py b/tests/system/large/test_dataframe.py index 396f2eb436..dc7671d18a 100644 --- a/tests/system/large/test_dataframe.py +++ b/tests/system/large/test_dataframe.py @@ -40,3 +40,27 @@ def test_cov_150_columns(scalars_df_numeric_150_columns_maybe_ordered): check_index_type=False, check_column_type=False, ) + + +@pytest.mark.parametrize( + ("keep",), + [ + ("first",), + ("last",), + (False,), + ], +) +def test_drop_duplicates_unordered( + scalars_df_unordered, scalars_pandas_df_default_index, keep +): + uniq_scalar_rows = scalars_df_unordered.drop_duplicates( + subset="bool_col", keep=keep + ) + uniq_pd_rows = scalars_pandas_df_default_index.drop_duplicates( + subset="bool_col", keep=keep + ) + + assert len(uniq_scalar_rows) == len(uniq_pd_rows) + assert len(uniq_scalar_rows.groupby("bool_col")) == len( + uniq_pd_rows.groupby("bool_col") + ) From cca20eaa30ee98c31874360e7388333cacbfae38 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Wed, 3 Dec 2025 10:04:11 -0800 Subject: [PATCH 271/313] fix: Fix reset_index level=0 bugs (#2307) --- bigframes/core/blocks.py | 2 +- bigframes/dataframe.py | 2 +- tests/system/small/test_multiindex.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index e45d945e23..6f87e43821 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -426,7 +426,7 @@ def reset_index( A new Block because dropping index columns can break references from Index classes that point to this block. """ - if level: + if level is not None: # preserve original order, not user provided order level_ids: Sequence[str] = [ id for id in self.index_columns if id in self.index.resolve_level(level) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 2206db9bf6..b9143f2f19 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -2577,7 +2577,7 @@ def reset_index( names: Union[None, Hashable, Sequence[Hashable]] = None, ) -> Optional[DataFrame]: block = self._block - if names: + if names is not None: if isinstance(names, blocks.Label) and not isinstance(names, tuple): names = [names] else: diff --git a/tests/system/small/test_multiindex.py b/tests/system/small/test_multiindex.py index 3a86d5f6c5..4233ed7aae 100644 --- a/tests/system/small/test_multiindex.py +++ b/tests/system/small/test_multiindex.py @@ -110,6 +110,7 @@ def test_set_multi_index(scalars_df_index, scalars_pandas_df_index): ("bool_col", True), (["float64_col", "int64_too"], True), ([2, 0], False), + (0, True), ], ) def test_df_reset_multi_index(scalars_df_index, scalars_pandas_df_index, level, drop): @@ -124,7 +125,7 @@ def test_df_reset_multi_index(scalars_df_index, scalars_pandas_df_index, level, # Pandas uses int64 instead of Int64 (nullable) dtype. if pd_result.index.dtype != bf_result.index.dtype: - pd_result.index = pd_result.index.astype(pandas.Int64Dtype()) + pd_result.index = pd_result.index.astype(bf_result.index.dtype) pandas.testing.assert_frame_equal(bf_result, pd_result) From 37c685d77f8e9c15e932d9bf051ea93a8d2278be Mon Sep 17 00:00:00 2001 From: Garrett Wu <6505921+GarrettWu@users.noreply.github.com> Date: Wed, 3 Dec 2025 10:54:13 -0800 Subject: [PATCH 272/313] chore: release 2.30.0 (#2308) PR created by the Librarian CLI to initialize a release. Merging this PR will auto trigger a release. Librarian Version: v0.7.0 Language Image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator@sha256:c8612d3fffb3f6a32353b2d1abd16b61e87811866f7ec9d65b59b02eb452a620
bigframes: 2.30.0 ## [2.30.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.29.0...v2.30.0) (2025-12-03) ### Features * Support mixed scalar-analytic expressions (#2239) ([20ab469d](https://github.com/googleapis/python-bigquery-dataframes/commit/20ab469d)) * Allow drop_duplicates over unordered dataframe (#2303) ([52665fa5](https://github.com/googleapis/python-bigquery-dataframes/commit/52665fa5)) * Preserve source names better for more readable sql (#2243) ([64995d65](https://github.com/googleapis/python-bigquery-dataframes/commit/64995d65)) * use end user credentials for `bigframes.bigquery.ai` functions when `connection_id` is not present (#2272) ([7c062a68](https://github.com/googleapis/python-bigquery-dataframes/commit/7c062a68)) * pivot_table supports fill_value arg (#2257) ([8f490e68](https://github.com/googleapis/python-bigquery-dataframes/commit/8f490e68)) * Support builtins funcs for df.agg (#2256) ([956a5b00](https://github.com/googleapis/python-bigquery-dataframes/commit/956a5b00)) * add bigquery.json_keys (#2286) ([b487cf1f](https://github.com/googleapis/python-bigquery-dataframes/commit/b487cf1f)) * Add agg/aggregate methods to windows (#2288) ([c4cb39dc](https://github.com/googleapis/python-bigquery-dataframes/commit/c4cb39dc)) * Add bigframes.pandas.crosstab (#2231) ([c62e5535](https://github.com/googleapis/python-bigquery-dataframes/commit/c62e5535)) * Implement single-column sorting for interactive table widget (#2255) ([d1ecc61b](https://github.com/googleapis/python-bigquery-dataframes/commit/d1ecc61b)) ### Bug Fixes * Pass credentials properly for read api instantiation (#2280) ([3e3fe259](https://github.com/googleapis/python-bigquery-dataframes/commit/3e3fe259)) * Update max_instances default to reflect actual value (#2302) ([4489687e](https://github.com/googleapis/python-bigquery-dataframes/commit/4489687e)) * Improve Anywidget pagination and display for unknown row counts (#2258) ([508deae5](https://github.com/googleapis/python-bigquery-dataframes/commit/508deae5)) * Fix issue with stream upload batch size upload limit (#2290) ([6cdf64b0](https://github.com/googleapis/python-bigquery-dataframes/commit/6cdf64b0)) * calling info() on empty dataframes no longer leads to errors (#2267) ([95a83f77](https://github.com/googleapis/python-bigquery-dataframes/commit/95a83f77)) * do not warn with DefaultIndexWarning in partial ordering mode (#2230) ([cc2dbae6](https://github.com/googleapis/python-bigquery-dataframes/commit/cc2dbae6)) ### Documentation * update docs and tests for Gemini 2.5 models (#2279) ([08c0c0c8](https://github.com/googleapis/python-bigquery-dataframes/commit/08c0c0c8)) * Add Google Analytics configuration to conf.py (#2301) ([0b266da1](https://github.com/googleapis/python-bigquery-dataframes/commit/0b266da1)) * fix LogisticRegression docs rendering (#2295) ([32e53134](https://github.com/googleapis/python-bigquery-dataframes/commit/32e53134)) * update API reference to new `dataframes.bigquery.dev` location (#2293) ([da064397](https://github.com/googleapis/python-bigquery-dataframes/commit/da064397)) * use autosummary to split documentation pages (#2251) ([f7fd2d20](https://github.com/googleapis/python-bigquery-dataframes/commit/f7fd2d20))
--- .librarian/state.yaml | 3 +- CHANGELOG.md | 35 +++++++++++++++++++++++ bigframes/version.py | 4 +-- third_party/bigframes_vendored/version.py | 4 +-- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/.librarian/state.yaml b/.librarian/state.yaml index d9874aa36d..009fed869a 100644 --- a/.librarian/state.yaml +++ b/.librarian/state.yaml @@ -1,7 +1,8 @@ image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator@sha256:c8612d3fffb3f6a32353b2d1abd16b61e87811866f7ec9d65b59b02eb452a620 libraries: - id: bigframes - version: 2.29.1 + version: 2.30.0 + last_generated_commit: "" apis: [] source_roots: - . diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a87fc0160..13f8209826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ [1]: https://pypi.org/project/bigframes/#history +## [2.30.0](https://github.com/googleapis/google-cloud-python/compare/bigframes-v2.29.0...bigframes-v2.30.0) (2025-12-03) + + +### Documentation + +* Add Google Analytics configuration to conf.py (#2301) ([0b266da10f4d3d0ef9b4dd71ddadebfc7d5064ca](https://github.com/googleapis/google-cloud-python/commit/0b266da10f4d3d0ef9b4dd71ddadebfc7d5064ca)) +* fix LogisticRegression docs rendering (#2295) ([32e531343c764156b45c6fb9de49793d26c19f02](https://github.com/googleapis/google-cloud-python/commit/32e531343c764156b45c6fb9de49793d26c19f02)) +* update API reference to new `dataframes.bigquery.dev` location (#2293) ([da064397acd2358c16fdd9659edf23afde5c882a](https://github.com/googleapis/google-cloud-python/commit/da064397acd2358c16fdd9659edf23afde5c882a)) +* use autosummary to split documentation pages (#2251) ([f7fd2d20896fe3e0e210c3833b6a4c3913270ebc](https://github.com/googleapis/google-cloud-python/commit/f7fd2d20896fe3e0e210c3833b6a4c3913270ebc)) +* update docs and tests for Gemini 2.5 models (#2279) ([08c0c0c8fe8f806f6224dc403a3f1d4db708573a](https://github.com/googleapis/google-cloud-python/commit/08c0c0c8fe8f806f6224dc403a3f1d4db708573a)) + + +### Features + +* Allow drop_duplicates over unordered dataframe (#2303) ([52665fa57ef13c58254bfc8736afcc521f7f0f11](https://github.com/googleapis/google-cloud-python/commit/52665fa57ef13c58254bfc8736afcc521f7f0f11)) +* Add agg/aggregate methods to windows (#2288) ([c4cb39dcbd388356f5f1c48ff28b19b79b996485](https://github.com/googleapis/google-cloud-python/commit/c4cb39dcbd388356f5f1c48ff28b19b79b996485)) +* Implement single-column sorting for interactive table widget (#2255) ([d1ecc61bf448651a0cca0fc760673da54f5c2183](https://github.com/googleapis/google-cloud-python/commit/d1ecc61bf448651a0cca0fc760673da54f5c2183)) +* add bigquery.json_keys (#2286) ([b487cf1f6ecacb1ee3b35ffdd934221516bbd558](https://github.com/googleapis/google-cloud-python/commit/b487cf1f6ecacb1ee3b35ffdd934221516bbd558)) +* use end user credentials for `bigframes.bigquery.ai` functions when `connection_id` is not present (#2272) ([7c062a68c6a3c9737865985b4f1fd80117490c73](https://github.com/googleapis/google-cloud-python/commit/7c062a68c6a3c9737865985b4f1fd80117490c73)) +* pivot_table supports fill_value arg (#2257) ([8f490e68a9a2584236486060ad3b55923781d975](https://github.com/googleapis/google-cloud-python/commit/8f490e68a9a2584236486060ad3b55923781d975)) +* Support mixed scalar-analytic expressions (#2239) ([20ab469d29767a2f04fe02aa66797893ecd1c539](https://github.com/googleapis/google-cloud-python/commit/20ab469d29767a2f04fe02aa66797893ecd1c539)) +* Support builtins funcs for df.agg (#2256) ([956a5b00dff55b73e3cbebb4e6e81672680f1f63](https://github.com/googleapis/google-cloud-python/commit/956a5b00dff55b73e3cbebb4e6e81672680f1f63)) +* Preserve source names better for more readable sql (#2243) ([64995d659837a8576b2ee9335921904e577c7014](https://github.com/googleapis/google-cloud-python/commit/64995d659837a8576b2ee9335921904e577c7014)) +* Add bigframes.pandas.crosstab (#2231) ([c62e5535ed4c19b6d65f9a46cb1531e8099621b2](https://github.com/googleapis/google-cloud-python/commit/c62e5535ed4c19b6d65f9a46cb1531e8099621b2)) + + +### Bug Fixes + +* Update max_instances default to reflect actual value (#2302) ([4489687eafc9a1ea1b985600010296a4245cef94](https://github.com/googleapis/google-cloud-python/commit/4489687eafc9a1ea1b985600010296a4245cef94)) +* Fix issue with stream upload batch size upload limit (#2290) ([6cdf64b0674d0e673f86362032d549316850837b](https://github.com/googleapis/google-cloud-python/commit/6cdf64b0674d0e673f86362032d549316850837b)) +* Pass credentials properly for read api instantiation (#2280) ([3e3fe259567d249d91f90786a577b05577e2b9fd](https://github.com/googleapis/google-cloud-python/commit/3e3fe259567d249d91f90786a577b05577e2b9fd)) +* Improve Anywidget pagination and display for unknown row counts (#2258) ([508deae5869e06cdad7bb94537c9c58d8f083d86](https://github.com/googleapis/google-cloud-python/commit/508deae5869e06cdad7bb94537c9c58d8f083d86)) +* calling info() on empty dataframes no longer leads to errors (#2267) ([95a83f7774766cd19cb583dfaa3417882b5c9b1e](https://github.com/googleapis/google-cloud-python/commit/95a83f7774766cd19cb583dfaa3417882b5c9b1e)) +* do not warn with DefaultIndexWarning in partial ordering mode (#2230) ([cc2dbae684103a21fe8838468f7eb8267188780d](https://github.com/googleapis/google-cloud-python/commit/cc2dbae684103a21fe8838468f7eb8267188780d)) + ## [2.29.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.28.0...v2.29.0) (2025-11-10) diff --git a/bigframes/version.py b/bigframes/version.py index a129daf092..f7a5eb09dc 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.29.0" +__version__ = "2.30.0" # {x-release-please-start-date} -__release_date__ = "2025-11-10" +__release_date__ = "2025-12-03" # {x-release-please-end} diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index a129daf092..f7a5eb09dc 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.29.0" +__version__ = "2.30.0" # {x-release-please-start-date} -__release_date__ = "2025-11-10" +__release_date__ = "2025-12-03" # {x-release-please-end} From fafd7c732d434eca3f8b5d849a87149f106e3d5d Mon Sep 17 00:00:00 2001 From: Shenyang Cai Date: Wed, 3 Dec 2025 11:36:20 -0800 Subject: [PATCH 273/313] feat: add 'weekday' property to DatatimeMethod (#2304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes b/464971054 🦕 --- bigframes/operations/datetimes.py | 4 +++ .../system/small/operations/test_datetimes.py | 15 +++++++++ .../pandas/core/indexes/accessor.py | 31 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/bigframes/operations/datetimes.py b/bigframes/operations/datetimes.py index 3f2d16a896..1d79afd97f 100644 --- a/bigframes/operations/datetimes.py +++ b/bigframes/operations/datetimes.py @@ -54,6 +54,10 @@ def dayofweek(self) -> series.Series: def day_of_week(self) -> series.Series: return self.dayofweek + @property + def weekday(self) -> series.Series: + return self.dayofweek + @property def dayofyear(self) -> series.Series: return self._data._apply_unary_op(ops.dayofyear_op) diff --git a/tests/system/small/operations/test_datetimes.py b/tests/system/small/operations/test_datetimes.py index d6a90597b4..0e023189d5 100644 --- a/tests/system/small/operations/test_datetimes.py +++ b/tests/system/small/operations/test_datetimes.py @@ -108,6 +108,21 @@ def test_dt_day_of_week(scalars_dfs, col_name): assert_series_equal(pd_result, bf_result, check_dtype=False) +@pytest.mark.parametrize( + ("col_name",), + DATE_COLUMNS, +) +def test_dt_weekday(scalars_dfs, col_name): + pytest.importorskip("pandas", minversion="2.0.0") + scalars_df, scalars_pandas_df = scalars_dfs + bf_series: bigframes.series.Series = scalars_df[col_name] + + bf_result = bf_series.dt.weekday.to_pandas() + pd_result = scalars_pandas_df[col_name].dt.weekday + + assert_series_equal(pd_result, bf_result, check_dtype=False) + + @pytest.mark.parametrize( ("col_name",), DATE_COLUMNS, diff --git a/third_party/bigframes_vendored/pandas/core/indexes/accessor.py b/third_party/bigframes_vendored/pandas/core/indexes/accessor.py index ee4de44b80..a0388317be 100644 --- a/third_party/bigframes_vendored/pandas/core/indexes/accessor.py +++ b/third_party/bigframes_vendored/pandas/core/indexes/accessor.py @@ -91,6 +91,37 @@ def day_of_week(self): raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + @property + def weekday(self): + """The day of the week with Monday=0, Sunday=6. + + Return the day of the week. It is assumed the week starts on + Monday, which is denoted by 0 and ends on Sunday, which is denoted + by 6. + + **Examples:** + + >>> s = bpd.Series( + ... pd.date_range('2016-12-31', '2017-01-08', freq='D').to_series() + ... ) + >>> s.dt.weekday + 2016-12-31 00:00:00 5 + 2017-01-01 00:00:00 6 + 2017-01-02 00:00:00 0 + 2017-01-03 00:00:00 1 + 2017-01-04 00:00:00 2 + 2017-01-05 00:00:00 3 + 2017-01-06 00:00:00 4 + 2017-01-07 00:00:00 5 + 2017-01-08 00:00:00 6 + dtype: Int64 + + Returns: + Series: Containing integers indicating the day number. + """ + + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + @property def day_name(self): """ From 719b278c844ca80c1bec741873b30a9ee4fd6c56 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 20:08:11 +0000 Subject: [PATCH 274/313] feat: add `bigframes.bigquery.ml` methods (#2300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds support for `CREATE MODEL` statement in BigQuery ML via `bigframes.bigquery.ml.create_model`. It includes DDL generation logic handling various clauses like TRANSFORM, OPTIONS, remote models, and different data input formats. It also refactors `bigframes.core.sql` into a package to support the new submodule. --- *PR created automatically by Jules for task [3846335972146851433](https://jules.google.com/task/3846335972146851433) started by @tswast* --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Tim Sweña --- bigframes/bigquery/__init__.py | 3 +- bigframes/bigquery/_operations/ml.py | 391 +++ bigframes/bigquery/ml.py | 36 + bigframes/core/{sql.py => sql/__init__.py} | 0 bigframes/core/sql/ml.py | 215 ++ bigframes/ml/__init__.py | 71 +- docs/reference/index.rst | 3 + notebooks/.gitignore | 4 + ..._dataframes_ml_linear_regression_bbq.ipynb | 2637 +++++++++++++++++ noxfile.py | 1 - tests/unit/bigquery/test_ml.py | 147 + .../create_model_basic.sql | 3 + .../create_model_if_not_exists.sql | 3 + .../create_model_list_option.sql | 3 + .../create_model_remote.sql | 5 + .../create_model_remote_default.sql | 3 + .../create_model_replace.sql | 3 + ...create_model_training_data_and_holiday.sql | 5 + .../create_model_transform.sql | 4 + .../evaluate_model_basic.sql | 1 + .../evaluate_model_with_options.sql | 1 + .../evaluate_model_with_table.sql | 1 + .../explain_predict_model_basic.sql | 1 + .../explain_predict_model_with_options.sql | 1 + .../global_explain_model_basic.sql | 1 + .../global_explain_model_with_options.sql | 1 + .../predict_model_basic.sql | 1 + .../predict_model_with_options.sql | 1 + tests/unit/core/sql/test_ml.py | 171 ++ 29 files changed, 3711 insertions(+), 6 deletions(-) create mode 100644 bigframes/bigquery/_operations/ml.py create mode 100644 bigframes/bigquery/ml.py rename bigframes/core/{sql.py => sql/__init__.py} (100%) create mode 100644 bigframes/core/sql/ml.py create mode 100644 notebooks/ml/bq_dataframes_ml_linear_regression_bbq.ipynb create mode 100644 tests/unit/bigquery/test_ml.py create mode 100644 tests/unit/core/sql/snapshots/test_ml/test_create_model_basic/create_model_basic.sql create mode 100644 tests/unit/core/sql/snapshots/test_ml/test_create_model_if_not_exists/create_model_if_not_exists.sql create mode 100644 tests/unit/core/sql/snapshots/test_ml/test_create_model_list_option/create_model_list_option.sql create mode 100644 tests/unit/core/sql/snapshots/test_ml/test_create_model_remote/create_model_remote.sql create mode 100644 tests/unit/core/sql/snapshots/test_ml/test_create_model_remote_default/create_model_remote_default.sql create mode 100644 tests/unit/core/sql/snapshots/test_ml/test_create_model_replace/create_model_replace.sql create mode 100644 tests/unit/core/sql/snapshots/test_ml/test_create_model_training_data_and_holiday/create_model_training_data_and_holiday.sql create mode 100644 tests/unit/core/sql/snapshots/test_ml/test_create_model_transform/create_model_transform.sql create mode 100644 tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_basic/evaluate_model_basic.sql create mode 100644 tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_with_options/evaluate_model_with_options.sql create mode 100644 tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_with_table/evaluate_model_with_table.sql create mode 100644 tests/unit/core/sql/snapshots/test_ml/test_explain_predict_model_basic/explain_predict_model_basic.sql create mode 100644 tests/unit/core/sql/snapshots/test_ml/test_explain_predict_model_with_options/explain_predict_model_with_options.sql create mode 100644 tests/unit/core/sql/snapshots/test_ml/test_global_explain_model_basic/global_explain_model_basic.sql create mode 100644 tests/unit/core/sql/snapshots/test_ml/test_global_explain_model_with_options/global_explain_model_with_options.sql create mode 100644 tests/unit/core/sql/snapshots/test_ml/test_predict_model_basic/predict_model_basic.sql create mode 100644 tests/unit/core/sql/snapshots/test_ml/test_predict_model_with_options/predict_model_with_options.sql create mode 100644 tests/unit/core/sql/test_ml.py diff --git a/bigframes/bigquery/__init__.py b/bigframes/bigquery/__init__.py index feabf703bc..f835285a21 100644 --- a/bigframes/bigquery/__init__.py +++ b/bigframes/bigquery/__init__.py @@ -18,7 +18,7 @@ import sys -from bigframes.bigquery import ai +from bigframes.bigquery import ai, ml from bigframes.bigquery._operations.approx_agg import approx_top_count from bigframes.bigquery._operations.array import ( array_agg, @@ -157,4 +157,5 @@ "struct", # Modules / SQL namespaces "ai", + "ml", ] diff --git a/bigframes/bigquery/_operations/ml.py b/bigframes/bigquery/_operations/ml.py new file mode 100644 index 0000000000..46c0663d81 --- /dev/null +++ b/bigframes/bigquery/_operations/ml.py @@ -0,0 +1,391 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import cast, Mapping, Optional, Union + +import bigframes_vendored.constants +import google.cloud.bigquery +import pandas as pd + +import bigframes.core.log_adapter as log_adapter +import bigframes.core.sql.ml +import bigframes.dataframe as dataframe +import bigframes.ml.base +import bigframes.session + + +# Helper to convert DataFrame to SQL string +def _to_sql(df_or_sql: Union[pd.DataFrame, dataframe.DataFrame, str]) -> str: + import bigframes.pandas as bpd + + if isinstance(df_or_sql, str): + return df_or_sql + + if isinstance(df_or_sql, pd.DataFrame): + bf_df = bpd.read_pandas(df_or_sql) + else: + bf_df = cast(dataframe.DataFrame, df_or_sql) + + sql, _, _ = bf_df._to_sql_query(include_index=False) + return sql + + +def _get_model_name_and_session( + model: Union[bigframes.ml.base.BaseEstimator, str, pd.Series], + # Other dataframe arguments to extract session from + *dataframes: Optional[Union[pd.DataFrame, dataframe.DataFrame, str]], +) -> tuple[str, Optional[bigframes.session.Session]]: + if isinstance(model, pd.Series): + try: + model_ref = model["modelReference"] + model_name = f"{model_ref['projectId']}.{model_ref['datasetId']}.{model_ref['modelId']}" # type: ignore + except KeyError: + raise ValueError("modelReference must be present in the pandas Series.") + elif isinstance(model, str): + model_name = model + else: + if model._bqml_model is None: + raise ValueError("Model must be fitted to be used in ML operations.") + return model._bqml_model.model_name, model._bqml_model.session + + session = None + for df in dataframes: + if isinstance(df, dataframe.DataFrame): + session = df._session + break + + return model_name, session + + +def _get_model_metadata( + *, + bqclient: google.cloud.bigquery.Client, + model_name: str, +) -> pd.Series: + model_metadata = bqclient.get_model(model_name) + model_dict = model_metadata.to_api_repr() + return pd.Series(model_dict) + + +@log_adapter.method_logger(custom_base_name="bigquery_ml") +def create_model( + model_name: str, + *, + replace: bool = False, + if_not_exists: bool = False, + # TODO(tswast): Also support bigframes.ml transformer classes and/or + # bigframes.pandas functions? + transform: Optional[list[str]] = None, + input_schema: Optional[Mapping[str, str]] = None, + output_schema: Optional[Mapping[str, str]] = None, + connection_name: Optional[str] = None, + options: Optional[Mapping[str, Union[str, int, float, bool, list]]] = None, + training_data: Optional[Union[pd.DataFrame, dataframe.DataFrame, str]] = None, + custom_holiday: Optional[Union[pd.DataFrame, dataframe.DataFrame, str]] = None, + session: Optional[bigframes.session.Session] = None, +) -> pd.Series: + """ + Creates a BigQuery ML model. + + See the `BigQuery ML CREATE MODEL DDL syntax + `_ + for additional reference. + + Args: + model_name (str): + The name of the model in BigQuery. + replace (bool, default False): + Whether to replace the model if it already exists. + if_not_exists (bool, default False): + Whether to ignore the error if the model already exists. + transform (list[str], optional): + A list of SQL transformations for the TRANSFORM clause, which + specifies the preprocessing steps to apply to the input data. + input_schema (Mapping[str, str], optional): + The INPUT clause, which specifies the schema of the input data. + output_schema (Mapping[str, str], optional): + The OUTPUT clause, which specifies the schema of the output data. + connection_name (str, optional): + The connection to use for the model. + options (Mapping[str, Union[str, int, float, bool, list]], optional): + The OPTIONS clause, which specifies the model options. + training_data (Union[bigframes.pandas.DataFrame, str], optional): + The query or DataFrame to use for training the model. + custom_holiday (Union[bigframes.pandas.DataFrame, str], optional): + The query or DataFrame to use for custom holiday data. + session (bigframes.session.Session, optional): + The session to use. If not provided, the default session is used. + + Returns: + pandas.Series: + A Series with object dtype containing the model metadata. Reference + the `BigQuery Model REST API reference + `_ + for available fields. + + """ + import bigframes.pandas as bpd + + training_data_sql = _to_sql(training_data) if training_data is not None else None + custom_holiday_sql = _to_sql(custom_holiday) if custom_holiday is not None else None + + # Determine session from DataFrames if not provided + if session is None: + # Try to get session from inputs + dfs = [ + obj + for obj in [training_data, custom_holiday] + if isinstance(obj, dataframe.DataFrame) + ] + if dfs: + session = dfs[0]._session + + sql = bigframes.core.sql.ml.create_model_ddl( + model_name=model_name, + replace=replace, + if_not_exists=if_not_exists, + transform=transform, + input_schema=input_schema, + output_schema=output_schema, + connection_name=connection_name, + options=options, + training_data=training_data_sql, + custom_holiday=custom_holiday_sql, + ) + + if session is None: + bpd.read_gbq_query(sql) + session = bpd.get_global_session() + assert ( + session is not None + ), f"Missing connection to BigQuery. Please report how you encountered this error at {bigframes_vendored.constants.FEEDBACK_LINK}." + else: + session.read_gbq_query(sql) + + return _get_model_metadata(bqclient=session.bqclient, model_name=model_name) + + +@log_adapter.method_logger(custom_base_name="bigquery_ml") +def evaluate( + model: Union[bigframes.ml.base.BaseEstimator, str, pd.Series], + input_: Optional[Union[pd.DataFrame, dataframe.DataFrame, str]] = None, + *, + perform_aggregation: Optional[bool] = None, + horizon: Optional[int] = None, + confidence_level: Optional[float] = None, +) -> dataframe.DataFrame: + """ + Evaluates a BigQuery ML model. + + See the `BigQuery ML EVALUATE function syntax + `_ + for additional reference. + + Args: + model (bigframes.ml.base.BaseEstimator or str): + The model to evaluate. + input_ (Union[bigframes.pandas.DataFrame, str], optional): + The DataFrame or query to use for evaluation. If not provided, the + evaluation data from training is used. + perform_aggregation (bool, optional): + A BOOL value that indicates the level of evaluation for forecasting + accuracy. If you specify TRUE, then the forecasting accuracy is on + the time series level. If you specify FALSE, the forecasting + accuracy is on the timestamp level. The default value is TRUE. + horizon (int, optional): + An INT64 value that specifies the number of forecasted time points + against which the evaluation metrics are computed. The default value + is the horizon value specified in the CREATE MODEL statement for the + time series model, or 1000 if unspecified. When evaluating multiple + time series at the same time, this parameter applies to each time + series. + confidence_level (float, optional): + A FLOAT64 value that specifies the percentage of the future values + that fall in the prediction interval. The default value is 0.95. The + valid input range is ``[0, 1)``. + + Returns: + bigframes.pandas.DataFrame: + The evaluation results. + """ + import bigframes.pandas as bpd + + model_name, session = _get_model_name_and_session(model, input_) + table_sql = _to_sql(input_) if input_ is not None else None + + sql = bigframes.core.sql.ml.evaluate( + model_name=model_name, + table=table_sql, + perform_aggregation=perform_aggregation, + horizon=horizon, + confidence_level=confidence_level, + ) + + if session is None: + return bpd.read_gbq_query(sql) + else: + return session.read_gbq_query(sql) + + +@log_adapter.method_logger(custom_base_name="bigquery_ml") +def predict( + model: Union[bigframes.ml.base.BaseEstimator, str, pd.Series], + input_: Union[pd.DataFrame, dataframe.DataFrame, str], + *, + threshold: Optional[float] = None, + keep_original_columns: Optional[bool] = None, + trial_id: Optional[int] = None, +) -> dataframe.DataFrame: + """ + Runs prediction on a BigQuery ML model. + + See the `BigQuery ML PREDICT function syntax + `_ + for additional reference. + + Args: + model (bigframes.ml.base.BaseEstimator or str): + The model to use for prediction. + input_ (Union[bigframes.pandas.DataFrame, str]): + The DataFrame or query to use for prediction. + threshold (float, optional): + The threshold to use for classification models. + keep_original_columns (bool, optional): + Whether to keep the original columns in the output. + trial_id (int, optional): + An INT64 value that identifies the hyperparameter tuning trial that + you want the function to evaluate. The function uses the optimal + trial by default. Only specify this argument if you ran + hyperparameter tuning when creating the model. + + Returns: + bigframes.pandas.DataFrame: + The prediction results. + """ + import bigframes.pandas as bpd + + model_name, session = _get_model_name_and_session(model, input_) + table_sql = _to_sql(input_) + + sql = bigframes.core.sql.ml.predict( + model_name=model_name, + table=table_sql, + threshold=threshold, + keep_original_columns=keep_original_columns, + trial_id=trial_id, + ) + + if session is None: + return bpd.read_gbq_query(sql) + else: + return session.read_gbq_query(sql) + + +@log_adapter.method_logger(custom_base_name="bigquery_ml") +def explain_predict( + model: Union[bigframes.ml.base.BaseEstimator, str, pd.Series], + input_: Union[pd.DataFrame, dataframe.DataFrame, str], + *, + top_k_features: Optional[int] = None, + threshold: Optional[float] = None, + integrated_gradients_num_steps: Optional[int] = None, + approx_feature_contrib: Optional[bool] = None, +) -> dataframe.DataFrame: + """ + Runs explainable prediction on a BigQuery ML model. + + See the `BigQuery ML EXPLAIN_PREDICT function syntax + `_ + for additional reference. + + Args: + model (bigframes.ml.base.BaseEstimator or str): + The model to use for prediction. + input_ (Union[bigframes.pandas.DataFrame, str]): + The DataFrame or query to use for prediction. + top_k_features (int, optional): + The number of top features to return. + threshold (float, optional): + The threshold for binary classification models. + integrated_gradients_num_steps (int, optional): + an INT64 value that specifies the number of steps to sample between + the example being explained and its baseline. This value is used to + approximate the integral in integrated gradients attribution + methods. Increasing the value improves the precision of feature + attributions, but can be slower and more computationally expensive. + approx_feature_contrib (bool, optional): + A BOOL value that indicates whether to use an approximate feature + contribution method in the XGBoost model explanation. + + Returns: + bigframes.pandas.DataFrame: + The prediction results with explanations. + """ + import bigframes.pandas as bpd + + model_name, session = _get_model_name_and_session(model, input_) + table_sql = _to_sql(input_) + + sql = bigframes.core.sql.ml.explain_predict( + model_name=model_name, + table=table_sql, + top_k_features=top_k_features, + threshold=threshold, + integrated_gradients_num_steps=integrated_gradients_num_steps, + approx_feature_contrib=approx_feature_contrib, + ) + + if session is None: + return bpd.read_gbq_query(sql) + else: + return session.read_gbq_query(sql) + + +@log_adapter.method_logger(custom_base_name="bigquery_ml") +def global_explain( + model: Union[bigframes.ml.base.BaseEstimator, str, pd.Series], + *, + class_level_explain: Optional[bool] = None, +) -> dataframe.DataFrame: + """ + Gets global explanations for a BigQuery ML model. + + See the `BigQuery ML GLOBAL_EXPLAIN function syntax + `_ + for additional reference. + + Args: + model (bigframes.ml.base.BaseEstimator or str): + The model to get explanations from. + class_level_explain (bool, optional): + Whether to return class-level explanations. + + Returns: + bigframes.pandas.DataFrame: + The global explanation results. + """ + import bigframes.pandas as bpd + + model_name, session = _get_model_name_and_session(model) + sql = bigframes.core.sql.ml.global_explain( + model_name=model_name, + class_level_explain=class_level_explain, + ) + + if session is None: + return bpd.read_gbq_query(sql) + else: + return session.read_gbq_query(sql) diff --git a/bigframes/bigquery/ml.py b/bigframes/bigquery/ml.py new file mode 100644 index 0000000000..93b0670ba5 --- /dev/null +++ b/bigframes/bigquery/ml.py @@ -0,0 +1,36 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This module exposes `BigQuery ML +`_ functions +by directly mapping to the equivalent function names in SQL syntax. + +For an interface more familiar to Scikit-Learn users, see :mod:`bigframes.ml`. +""" + +from bigframes.bigquery._operations.ml import ( + create_model, + evaluate, + explain_predict, + global_explain, + predict, +) + +__all__ = [ + "create_model", + "evaluate", + "predict", + "explain_predict", + "global_explain", +] diff --git a/bigframes/core/sql.py b/bigframes/core/sql/__init__.py similarity index 100% rename from bigframes/core/sql.py rename to bigframes/core/sql/__init__.py diff --git a/bigframes/core/sql/ml.py b/bigframes/core/sql/ml.py new file mode 100644 index 0000000000..ec55fe0426 --- /dev/null +++ b/bigframes/core/sql/ml.py @@ -0,0 +1,215 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Dict, Mapping, Optional, Union + +import bigframes.core.compile.googlesql as googlesql +import bigframes.core.sql + + +def create_model_ddl( + model_name: str, + *, + replace: bool = False, + if_not_exists: bool = False, + transform: Optional[list[str]] = None, + input_schema: Optional[Mapping[str, str]] = None, + output_schema: Optional[Mapping[str, str]] = None, + connection_name: Optional[str] = None, + options: Optional[Mapping[str, Union[str, int, float, bool, list]]] = None, + training_data: Optional[str] = None, + custom_holiday: Optional[str] = None, +) -> str: + """Encode the CREATE MODEL statement. + + See https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-create for reference. + """ + + if replace: + create = "CREATE OR REPLACE MODEL " + elif if_not_exists: + create = "CREATE MODEL IF NOT EXISTS " + else: + create = "CREATE MODEL " + + ddl = f"{create}{googlesql.identifier(model_name)}\n" + + # [TRANSFORM (select_list)] + if transform: + ddl += f"TRANSFORM ({', '.join(transform)})\n" + + # [INPUT (field_name field_type) OUTPUT (field_name field_type)] + if input_schema: + inputs = [f"{k} {v}" for k, v in input_schema.items()] + ddl += f"INPUT ({', '.join(inputs)})\n" + + if output_schema: + outputs = [f"{k} {v}" for k, v in output_schema.items()] + ddl += f"OUTPUT ({', '.join(outputs)})\n" + + # [REMOTE WITH CONNECTION {connection_name | DEFAULT}] + if connection_name: + if connection_name.upper() == "DEFAULT": + ddl += "REMOTE WITH CONNECTION DEFAULT\n" + else: + ddl += f"REMOTE WITH CONNECTION {googlesql.identifier(connection_name)}\n" + + # [OPTIONS(model_option_list)] + if options: + rendered_options = [] + for option_name, option_value in options.items(): + if isinstance(option_value, (list, tuple)): + # Handle list options like model_registry="vertex_ai" + # wait, usually options are key=value. + # if value is list, it is [val1, val2] + rendered_val = bigframes.core.sql.simple_literal(list(option_value)) + else: + rendered_val = bigframes.core.sql.simple_literal(option_value) + + rendered_options.append(f"{option_name} = {rendered_val}") + + ddl += f"OPTIONS({', '.join(rendered_options)})\n" + + # [AS {query_statement | ( training_data AS (query_statement), custom_holiday AS (holiday_statement) )}] + + if training_data: + if custom_holiday: + # When custom_holiday is present, we need named clauses + parts = [] + parts.append(f"training_data AS ({training_data})") + parts.append(f"custom_holiday AS ({custom_holiday})") + ddl += f"AS (\n {', '.join(parts)}\n)" + else: + # Just training_data is treated as the query_statement + ddl += f"AS {training_data}\n" + + return ddl + + +def _build_struct_sql( + struct_options: Mapping[str, Union[str, int, float, bool]] +) -> str: + if not struct_options: + return "" + + rendered_options = [] + for option_name, option_value in struct_options.items(): + rendered_val = bigframes.core.sql.simple_literal(option_value) + rendered_options.append(f"{rendered_val} AS {option_name}") + return f", STRUCT({', '.join(rendered_options)})" + + +def evaluate( + model_name: str, + *, + table: Optional[str] = None, + perform_aggregation: Optional[bool] = None, + horizon: Optional[int] = None, + confidence_level: Optional[float] = None, +) -> str: + """Encode the ML.EVAluate statement. + See https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-evaluate for reference. + """ + struct_options: Dict[str, Union[str, int, float, bool]] = {} + if perform_aggregation is not None: + struct_options["perform_aggregation"] = perform_aggregation + if horizon is not None: + struct_options["horizon"] = horizon + if confidence_level is not None: + struct_options["confidence_level"] = confidence_level + + sql = f"SELECT * FROM ML.EVALUATE(MODEL {googlesql.identifier(model_name)}" + if table: + sql += f", ({table})" + + sql += _build_struct_sql(struct_options) + sql += ")\n" + return sql + + +def predict( + model_name: str, + table: str, + *, + threshold: Optional[float] = None, + keep_original_columns: Optional[bool] = None, + trial_id: Optional[int] = None, +) -> str: + """Encode the ML.PREDICT statement. + See https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-predict for reference. + """ + struct_options = {} + if threshold is not None: + struct_options["threshold"] = threshold + if keep_original_columns is not None: + struct_options["keep_original_columns"] = keep_original_columns + if trial_id is not None: + struct_options["trial_id"] = trial_id + + sql = ( + f"SELECT * FROM ML.PREDICT(MODEL {googlesql.identifier(model_name)}, ({table})" + ) + sql += _build_struct_sql(struct_options) + sql += ")\n" + return sql + + +def explain_predict( + model_name: str, + table: str, + *, + top_k_features: Optional[int] = None, + threshold: Optional[float] = None, + integrated_gradients_num_steps: Optional[int] = None, + approx_feature_contrib: Optional[bool] = None, +) -> str: + """Encode the ML.EXPLAIN_PREDICT statement. + See https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-explain-predict for reference. + """ + struct_options: Dict[str, Union[str, int, float, bool]] = {} + if top_k_features is not None: + struct_options["top_k_features"] = top_k_features + if threshold is not None: + struct_options["threshold"] = threshold + if integrated_gradients_num_steps is not None: + struct_options[ + "integrated_gradients_num_steps" + ] = integrated_gradients_num_steps + if approx_feature_contrib is not None: + struct_options["approx_feature_contrib"] = approx_feature_contrib + + sql = f"SELECT * FROM ML.EXPLAIN_PREDICT(MODEL {googlesql.identifier(model_name)}, ({table})" + sql += _build_struct_sql(struct_options) + sql += ")\n" + return sql + + +def global_explain( + model_name: str, + *, + class_level_explain: Optional[bool] = None, +) -> str: + """Encode the ML.GLOBAL_EXPLAIN statement. + See https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-global-explain for reference. + """ + struct_options = {} + if class_level_explain is not None: + struct_options["class_level_explain"] = class_level_explain + + sql = f"SELECT * FROM ML.GLOBAL_EXPLAIN(MODEL {googlesql.identifier(model_name)}" + sql += _build_struct_sql(struct_options) + sql += ")\n" + return sql diff --git a/bigframes/ml/__init__.py b/bigframes/ml/__init__.py index b2c62ff961..368d272e7b 100644 --- a/bigframes/ml/__init__.py +++ b/bigframes/ml/__init__.py @@ -12,19 +12,82 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""BigQuery DataFrames ML provides a SKLearn-like API on the BigQuery engine.""" +"""BigQuery DataFrames ML provides a SKLearn-like API on the BigQuery engine. + +.. code:: python + + from bigframes.ml.linear_model import LinearRegression + model = LinearRegression() + model.fit(feature_columns, label_columns) + model.predict(feature_columns_from_test_data) + +You can also save your fit parameters to BigQuery for later use. + +.. code:: python + + import bigframes.pandas as bpd + model.to_gbq( + your_model_id, # For example: "bqml_tutorial.penguins_model" + replace=True, + ) + saved_model = bpd.read_gbq_model(your_model_id) + saved_model.predict(feature_columns_from_test_data) + +See the `BigQuery ML linear regression tutorial +`_ for a +detailed example. + +See also the references for ``bigframes.ml`` sub-modules: + +* :mod:`bigframes.ml.cluster` +* :mod:`bigframes.ml.compose` +* :mod:`bigframes.ml.decomposition` +* :mod:`bigframes.ml.ensemble` +* :mod:`bigframes.ml.forecasting` +* :mod:`bigframes.ml.imported` +* :mod:`bigframes.ml.impute` +* :mod:`bigframes.ml.linear_model` +* :mod:`bigframes.ml.llm` +* :mod:`bigframes.ml.metrics` +* :mod:`bigframes.ml.model_selection` +* :mod:`bigframes.ml.pipeline` +* :mod:`bigframes.ml.preprocessing` +* :mod:`bigframes.ml.remote` + +Alternatively, check out mod:`bigframes.bigquery.ml` for an interface that is +more similar to the BigQuery ML SQL syntax. +""" + +from bigframes.ml import ( + cluster, + compose, + decomposition, + ensemble, + forecasting, + imported, + impute, + linear_model, + llm, + metrics, + model_selection, + pipeline, + preprocessing, + remote, +) __all__ = [ "cluster", "compose", "decomposition", + "ensemble", + "forecasting", + "imported", + "impute", "linear_model", + "llm", "metrics", "model_selection", "pipeline", "preprocessing", - "llm", - "forecasting", - "imported", "remote", ] diff --git a/docs/reference/index.rst b/docs/reference/index.rst index 7e94784a67..0a150659c5 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -10,6 +10,7 @@ packages. bigframes._config bigframes.bigquery bigframes.bigquery.ai + bigframes.bigquery.ml bigframes.enums bigframes.exceptions bigframes.geopandas @@ -26,6 +27,7 @@ scikit-learn. .. autosummary:: :toctree: api + bigframes.ml bigframes.ml.cluster bigframes.ml.compose bigframes.ml.decomposition @@ -35,6 +37,7 @@ scikit-learn. bigframes.ml.impute bigframes.ml.linear_model bigframes.ml.llm + bigframes.ml.metrics bigframes.ml.model_selection bigframes.ml.pipeline bigframes.ml.preprocessing diff --git a/notebooks/.gitignore b/notebooks/.gitignore index 87620ac7e7..d9acee9f51 100644 --- a/notebooks/.gitignore +++ b/notebooks/.gitignore @@ -1 +1,5 @@ .ipynb_checkpoints/ +*.bq_exec_time_seconds +*.bytesprocessed +*.query_char_count +*.slotmillis diff --git a/notebooks/ml/bq_dataframes_ml_linear_regression_bbq.ipynb b/notebooks/ml/bq_dataframes_ml_linear_regression_bbq.ipynb new file mode 100644 index 0000000000..6be836c6f8 --- /dev/null +++ b/notebooks/ml/bq_dataframes_ml_linear_regression_bbq.ipynb @@ -0,0 +1,2637 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "id": "ur8xi4C7S06n" + }, + "outputs": [], + "source": [ + "# Copyright 2023 Google LLC\n", + "#\n", + "# Licensed under the Apache License, Version 2.0 (the \"License\");\n", + "# you may not use this file except in compliance with the License.\n", + "# You may obtain a copy of the License at\n", + "#\n", + "# https://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing, software\n", + "# distributed under the License is distributed on an \"AS IS\" BASIS,\n", + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n", + "# See the License for the specific language governing permissions and\n", + "# limitations under the License." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "JAPoU8Sm5E6e" + }, + "source": [ + "## Train a linear regression model with BigQuery DataFrames ML\n", + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + "
\n", + " \n", + " \"Colab Run in Colab\n", + " \n", + " \n", + " \n", + " \"GitHub\n", + " View on GitHub\n", + " \n", + " \n", + " \n", + " \"Vertex\n", + " Open in Vertex AI Workbench\n", + " \n", + " \n", + " \n", + " \"BQ\n", + " Open in BQ Studio\n", + " \n", + "
" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "24743cf4a1e1" + }, + "source": [ + "**_NOTE_**: This notebook has been tested in the following environment:\n", + "\n", + "* Python version = 3.10" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "tvgnzT1CKxrO" + }, + "source": [ + "## Overview\n", + "\n", + "Use this notebook to learn how to train a linear regression model using BigQuery ML and the `bigframes.bigquery` module.\n", + "\n", + "This example is adapted from the [BQML linear regression tutorial](https://cloud.google.com/bigquery-ml/docs/linear-regression-tutorial).\n", + "\n", + "Learn more about [BigQuery DataFrames](https://dataframes.bigquery.dev/)." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "d975e698c9a4" + }, + "source": [ + "### Objective\n", + "\n", + "In this tutorial, you use BigQuery DataFrames to create a linear regression model that predicts the weight of an Adelie penguin based on the penguin's island of residence, culmen length and depth, flipper length, and sex.\n", + "\n", + "The steps include:\n", + "\n", + "- Creating a DataFrame from a BigQuery table.\n", + "- Cleaning and preparing data using pandas.\n", + "- Creating a linear regression model using `bigframes.ml`.\n", + "- Saving the ML model to BigQuery for future use." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "08d289fa873f" + }, + "source": [ + "### Dataset\n", + "\n", + "This tutorial uses the [```penguins``` table](https://console.cloud.google.com/bigquery?p=bigquery-public-data&d=ml_datasets&t=penguins) (a BigQuery Public Dataset) which includes data on a set of penguins including species, island of residence, weight, culmen length and depth, flipper length, and sex." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "aed92deeb4a0" + }, + "source": [ + "### Costs\n", + "\n", + "This tutorial uses billable components of Google Cloud:\n", + "\n", + "* BigQuery (compute)\n", + "* BigQuery ML\n", + "\n", + "Learn about [BigQuery compute pricing](https://cloud.google.com/bigquery/pricing#analysis_pricing_models)\n", + "and [BigQuery ML pricing](https://cloud.google.com/bigquery/pricing#bqml),\n", + "and use the [Pricing Calculator](https://cloud.google.com/products/calculator/)\n", + "to generate a cost estimate based on your projected usage." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "i7EUnXsZhAGF" + }, + "source": [ + "## Installation\n", + "\n", + "If you don't have [bigframes](https://pypi.org/project/bigframes/) package already installed, uncomment and execute the following cells to\n", + "\n", + "1. Install the package\n", + "1. Restart the notebook kernel (Jupyter or Colab) to work with the package" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "id": "9O0Ka4W2MNF3" + }, + "outputs": [], + "source": [ + "# !pip install bigframes" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "id": "f200f10a1da3" + }, + "outputs": [], + "source": [ + "# Automatically restart kernel after installs so that your environment can access the new packages\n", + "# import IPython\n", + "\n", + "# app = IPython.Application.instance()\n", + "# app.kernel.do_shutdown(True)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "BF1j6f9HApxa" + }, + "source": [ + "## Before you begin\n", + "\n", + "Complete the tasks in this section to set up your environment." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "oDfTjfACBvJk" + }, + "source": [ + "### Set up your Google Cloud project\n", + "\n", + "**The following steps are required, regardless of your notebook environment.**\n", + "\n", + "1. [Select or create a Google Cloud project](https://console.cloud.google.com/cloud-resource-manager). When you first create an account, you get a $300 credit towards your compute/storage costs.\n", + "\n", + "2. [Make sure that billing is enabled for your project](https://cloud.google.com/billing/docs/how-to/modify-project).\n", + "\n", + "3. [Enable the BigQuery API](https://console.cloud.google.com/flows/enableapi?apiid=bigquery.googleapis.com).\n", + "\n", + "4. If you are running this notebook locally, install the [Cloud SDK](https://cloud.google.com/sdk)." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "WReHDGG5g0XY" + }, + "source": [ + "#### Set your project ID\n", + "\n", + "If you don't know your project ID, try the following:\n", + "* Run `gcloud config list`.\n", + "* Run `gcloud projects list`.\n", + "* See the support page: [Locate the project ID](https://support.google.com/googleapi/answer/7014113)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "oM1iC_MfAts1" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Updated property [core/project].\n" + ] + } + ], + "source": [ + "PROJECT_ID = \"\" # @param {type:\"string\"}\n", + "\n", + "# Set the project id\n", + "! gcloud config set project {PROJECT_ID}" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "region" + }, + "source": [ + "#### Set the region\n", + "\n", + "You can also change the `REGION` variable used by BigQuery. Learn more about [BigQuery regions](https://cloud.google.com/bigquery/docs/locations#supported_locations)." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "id": "eF-Twtc4XGem" + }, + "outputs": [], + "source": [ + "REGION = \"US\" # @param {type: \"string\"}" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "sBCra4QMA2wR" + }, + "source": [ + "### Authenticate your Google Cloud account\n", + "\n", + "Depending on your Jupyter environment, you might have to manually authenticate. Follow the relevant instructions below." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "74ccc9e52986" + }, + "source": [ + "**Vertex AI Workbench**\n", + "\n", + "Do nothing, you are already authenticated." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "de775a3773ba" + }, + "source": [ + "**Local JupyterLab instance**\n", + "\n", + "Uncomment and run the following cell:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "id": "254614fa0c46" + }, + "outputs": [], + "source": [ + "# ! gcloud auth login" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "ef21552ccea8" + }, + "source": [ + "**Colab**\n", + "\n", + "Uncomment and run the following cell:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "id": "603adbbf0532" + }, + "outputs": [], + "source": [ + "# from google.colab import auth\n", + "# auth.authenticate_user()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "960505627ddf" + }, + "source": [ + "### Import libraries" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "id": "PyQmSRbKA8r-" + }, + "outputs": [], + "source": [ + "import bigframes.pandas as bpd" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "init_aip:mbsdk,all" + }, + "source": [ + "### Set BigQuery DataFrames options" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "id": "NPPMuw2PXGeo" + }, + "outputs": [], + "source": [ + "# Note: The project option is not required in all environments.\n", + "# On BigQuery Studio, the project ID is automatically detected.\n", + "bpd.options.bigquery.project = PROJECT_ID\n", + "\n", + "# Note: The location option is not required.\n", + "# It defaults to the location of the first table or query\n", + "# passed to read_gbq(). For APIs where a location can't be\n", + "# auto-detected, the location defaults to the \"US\" location.\n", + "bpd.options.bigquery.location = REGION\n", + "\n", + "# Recommended for performance. Disables pandas default ordering of all rows.\n", + "bpd.options.bigquery.ordering_mode = \"partial\"" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "D21CoOlfFTYI" + }, + "source": [ + "If you want to reset the location of the created DataFrame or Series objects, reset the session by executing `bpd.close_session()`. After that, you can reuse `bpd.options.bigquery.location` to specify another location." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "9EMAqR37AfLS" + }, + "source": [ + "## Read a BigQuery table into a BigQuery DataFrames DataFrame\n", + "\n", + "Read the [```penguins``` table](https://console.cloud.google.com/bigquery?p=bigquery-public-data&d=ml_datasets&t=penguins) into a BigQuery DataFrames DataFrame:" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "id": "EDAaIwHpQCDZ" + }, + "outputs": [], + "source": [ + "df = bpd.read_gbq(\"bigquery-public-data.ml_datasets.penguins\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "DJu837YEXD7B" + }, + "source": [ + "Take a look at the DataFrame:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "id": "_gPD0Zn1Stdb" + }, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.microsoft.datawrangler.viewer.v0+json": { + "columns": [ + { + "name": "index", + "rawType": "int64", + "type": "integer" + }, + { + "name": "species", + "rawType": "string", + "type": "string" + }, + { + "name": "island", + "rawType": "string", + "type": "string" + }, + { + "name": "culmen_length_mm", + "rawType": "Float64", + "type": "float" + }, + { + "name": "culmen_depth_mm", + "rawType": "Float64", + "type": "float" + }, + { + "name": "flipper_length_mm", + "rawType": "Float64", + "type": "float" + }, + { + "name": "body_mass_g", + "rawType": "Float64", + "type": "float" + }, + { + "name": "sex", + "rawType": "string", + "type": "string" + } + ], + "ref": "a652ba52-0445-4228-a2d5-baf837933515", + "rows": [ + [ + "0", + "Adelie Penguin (Pygoscelis adeliae)", + "Dream", + "36.6", + "18.4", + "184.0", + "3475.0", + "FEMALE" + ], + [ + "1", + "Adelie Penguin (Pygoscelis adeliae)", + "Dream", + "39.8", + "19.1", + "184.0", + "4650.0", + "MALE" + ], + [ + "2", + "Adelie Penguin (Pygoscelis adeliae)", + "Dream", + "40.9", + "18.9", + "184.0", + "3900.0", + "MALE" + ], + [ + "3", + "Chinstrap penguin (Pygoscelis antarctica)", + "Dream", + "46.5", + "17.9", + "192.0", + "3500.0", + "FEMALE" + ], + [ + "4", + "Adelie Penguin (Pygoscelis adeliae)", + "Dream", + "37.3", + "16.8", + "192.0", + "3000.0", + "FEMALE" + ] + ], + "shape": { + "columns": 7, + "rows": 5 + } + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
speciesislandculmen_length_mmculmen_depth_mmflipper_length_mmbody_mass_gsex
0Adelie Penguin (Pygoscelis adeliae)Dream36.618.4184.03475.0FEMALE
1Adelie Penguin (Pygoscelis adeliae)Dream39.819.1184.04650.0MALE
2Adelie Penguin (Pygoscelis adeliae)Dream40.918.9184.03900.0MALE
3Chinstrap penguin (Pygoscelis antarctica)Dream46.517.9192.03500.0FEMALE
4Adelie Penguin (Pygoscelis adeliae)Dream37.316.8192.03000.0FEMALE
\n", + "
" + ], + "text/plain": [ + " species island culmen_length_mm \\\n", + "0 Adelie Penguin (Pygoscelis adeliae) Dream 36.6 \n", + "1 Adelie Penguin (Pygoscelis adeliae) Dream 39.8 \n", + "2 Adelie Penguin (Pygoscelis adeliae) Dream 40.9 \n", + "3 Chinstrap penguin (Pygoscelis antarctica) Dream 46.5 \n", + "4 Adelie Penguin (Pygoscelis adeliae) Dream 37.3 \n", + "\n", + " culmen_depth_mm flipper_length_mm body_mass_g sex \n", + "0 18.4 184.0 3475.0 FEMALE \n", + "1 19.1 184.0 4650.0 MALE \n", + "2 18.9 184.0 3900.0 MALE \n", + "3 17.9 192.0 3500.0 FEMALE \n", + "4 16.8 192.0 3000.0 FEMALE " + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df.peek()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "rwPLjqW2Ajzh" + }, + "source": [ + "## Clean and prepare data\n", + "\n", + "You can use pandas as you normally would on the BigQuery DataFrames DataFrame, but calculations happen in the BigQuery query engine instead of your local environment.\n", + "\n", + "Because this model will focus on the Adelie Penguin species, you need to filter the data for only those rows representing Adelie penguins. Then you drop the `species` column because it is no longer needed.\n", + "\n", + "As these functions are applied, only the new DataFrame object `adelie_data` is modified. The source table and the original DataFrame object `df` don't change." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": { + "id": "6i6HkFJZa8na" + }, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 28.9 kB in 12 seconds of slot time. [Job bigframes-dev:US.bb256e8c-f2c7-4eff-b5f3-fcc6836110cf details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 8.4 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
islandculmen_length_mmculmen_depth_mmflipper_length_mmbody_mass_gsex
0Dream36.618.4184.03475.0FEMALE
1Dream39.819.1184.04650.0MALE
2Dream40.918.9184.03900.0MALE
3Dream37.316.8192.03000.0FEMALE
4Dream43.218.5192.04100.0MALE
5Dream40.220.1200.03975.0MALE
6Dream40.818.9208.04300.0MALE
7Dream39.018.7185.03650.0MALE
8Dream37.016.9185.03000.0FEMALE
9Dream34.017.1185.03400.0FEMALE
\n", + "

10 rows × 6 columns

\n", + "
[152 rows x 6 columns in total]" + ], + "text/plain": [ + "island culmen_length_mm culmen_depth_mm flipper_length_mm body_mass_g \\\n", + " Dream 36.6 18.4 184.0 3475.0 \n", + " Dream 39.8 19.1 184.0 4650.0 \n", + " Dream 40.9 18.9 184.0 3900.0 \n", + " Dream 37.3 16.8 192.0 3000.0 \n", + " Dream 43.2 18.5 192.0 4100.0 \n", + " Dream 40.2 20.1 200.0 3975.0 \n", + " Dream 40.8 18.9 208.0 4300.0 \n", + " Dream 39.0 18.7 185.0 3650.0 \n", + " Dream 37.0 16.9 185.0 3000.0 \n", + " Dream 34.0 17.1 185.0 3400.0 \n", + "\n", + " sex \n", + "FEMALE \n", + " MALE \n", + " MALE \n", + "FEMALE \n", + " MALE \n", + " MALE \n", + " MALE \n", + " MALE \n", + "FEMALE \n", + "FEMALE \n", + "...\n", + "\n", + "[152 rows x 6 columns]" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Filter down to the data to the Adelie Penguin species\n", + "adelie_data = df[df.species == \"Adelie Penguin (Pygoscelis adeliae)\"]\n", + "\n", + "# Drop the species column\n", + "adelie_data = adelie_data.drop(columns=[\"species\"])\n", + "\n", + "# Take a look at the filtered DataFrame\n", + "adelie_data" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "jhK2OlyMbY4L" + }, + "source": [ + "Drop rows with `NULL` values in order to create a BigQuery DataFrames DataFrame for the training data:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": { + "id": "0am3hdlXZfxZ" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Starting." + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 8.1 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
islandculmen_length_mmculmen_depth_mmflipper_length_mmbody_mass_gsex
0Dream36.618.4184.03475.0FEMALE
1Dream39.819.1184.04650.0MALE
2Dream40.918.9184.03900.0MALE
3Dream37.316.8192.03000.0FEMALE
4Dream43.218.5192.04100.0MALE
5Dream40.220.1200.03975.0MALE
6Dream40.818.9208.04300.0MALE
7Dream39.018.7185.03650.0MALE
8Dream37.016.9185.03000.0FEMALE
9Dream34.017.1185.03400.0FEMALE
\n", + "

10 rows × 6 columns

\n", + "
[146 rows x 6 columns in total]" + ], + "text/plain": [ + "island culmen_length_mm culmen_depth_mm flipper_length_mm body_mass_g \\\n", + " Dream 36.6 18.4 184.0 3475.0 \n", + " Dream 39.8 19.1 184.0 4650.0 \n", + " Dream 40.9 18.9 184.0 3900.0 \n", + " Dream 37.3 16.8 192.0 3000.0 \n", + " Dream 43.2 18.5 192.0 4100.0 \n", + " Dream 40.2 20.1 200.0 3975.0 \n", + " Dream 40.8 18.9 208.0 4300.0 \n", + " Dream 39.0 18.7 185.0 3650.0 \n", + " Dream 37.0 16.9 185.0 3000.0 \n", + " Dream 34.0 17.1 185.0 3400.0 \n", + "\n", + " sex \n", + "FEMALE \n", + " MALE \n", + " MALE \n", + "FEMALE \n", + " MALE \n", + " MALE \n", + " MALE \n", + " MALE \n", + "FEMALE \n", + "FEMALE \n", + "...\n", + "\n", + "[146 rows x 6 columns]" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Drop rows with nulls to get training data\n", + "training_data = adelie_data.dropna()\n", + "\n", + "# Take a peek at the training data\n", + "training_data" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Fx4lsNqMorJ-" + }, + "source": [ + "## Create the linear regression model\n", + "\n", + "In this notebook, you create a linear regression model, a type of regression model that generates a continuous value from a linear combination of input features." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create a BigQuery dataset to house the model, adding a name for your dataset as the `DATASET_ID` variable:" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Dataset bqml_tutorial created.\n" + ] + } + ], + "source": [ + "DATASET_ID = \"bqml_tutorial\" # @param {type:\"string\"}\n", + "\n", + "from google.cloud import bigquery\n", + "client = bigquery.Client(project=PROJECT_ID)\n", + "dataset = bigquery.Dataset(PROJECT_ID + \".\" + DATASET_ID)\n", + "dataset.location = REGION\n", + "dataset = client.create_dataset(dataset, exists_ok=True)\n", + "print(f\"Dataset {dataset.dataset_id} created.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "EloGtMnverFF" + }, + "source": [ + "### Create the model using `bigframes.bigquery.ml.create_model`\n", + "\n", + "When you pass the feature columns without transforms, BigQuery ML uses\n", + "[automatic preprocessing](https://cloud.google.com/bigquery/docs/auto-preprocessing) to encode string values and scale numeric values.\n", + "\n", + "BigQuery ML also [automatically splits the data for training and evaluation](https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-create-glm#data_split_method), although for datasets with less than 500 rows (such as this one), all rows are used for training." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": { + "id": "GskyyUQPowBT" + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " Query started with request ID bigframes-dev:US.a33b3628-730b-46e8-ad17-c78bb48619ce.
SQL
CREATE OR REPLACE MODEL `bigframes-dev.bqml_tutorial.penguin_weight`\n",
+              "OPTIONS(model_type = 'LINEAR_REG')\n",
+              "AS SELECT\n",
+              "`bfuid_col_3` AS `island`,\n",
+              "`bfuid_col_4` AS `culmen_length_mm`,\n",
+              "`bfuid_col_5` AS `culmen_depth_mm`,\n",
+              "`bfuid_col_6` AS `flipper_length_mm`,\n",
+              "`bfuid_col_7` AS `label`,\n",
+              "`bfuid_col_8` AS `sex`\n",
+              "FROM\n",
+              "(SELECT\n",
+              "  `t0`.`bfuid_col_3`,\n",
+              "  `t0`.`bfuid_col_4`,\n",
+              "  `t0`.`bfuid_col_5`,\n",
+              "  `t0`.`bfuid_col_6`,\n",
+              "  `t0`.`bfuid_col_7`,\n",
+              "  `t0`.`bfuid_col_8`\n",
+              "FROM `bigframes-dev._63cfa399614a54153cc386c27d6c0c6fdb249f9e._e154f0aa_5b29_492a_b464_a77c5f5a3dbd_bqdf_60fa3196-5a3e-45ae-898e-c2b473bfa1e9` AS `t0`)\n",
+              "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.microsoft.datawrangler.viewer.v0+json": { + "columns": [ + { + "name": "index", + "rawType": "object", + "type": "string" + }, + { + "name": "0", + "rawType": "object", + "type": "unknown" + } + ], + "ref": "851c170c-08a5-4c06-8c0b-4547dbde3f18", + "rows": [ + [ + "etag", + "P3XS+g0ZZM19ywL+hdwUmQ==" + ], + [ + "modelReference", + "{'projectId': 'bigframes-dev', 'datasetId': 'bqml_tutorial', 'modelId': 'penguin_weight'}" + ], + [ + "creationTime", + "1764779445166" + ], + [ + "lastModifiedTime", + "1764779445237" + ], + [ + "modelType", + "LINEAR_REGRESSION" + ], + [ + "trainingRuns", + "[{'trainingOptions': {'lossType': 'MEAN_SQUARED_LOSS', 'l2Regularization': 0, 'inputLabelColumns': ['label'], 'dataSplitMethod': 'AUTO_SPLIT', 'optimizationStrategy': 'NORMAL_EQUATION', 'calculatePValues': False, 'enableGlobalExplain': False, 'categoryEncodingMethod': 'ONE_HOT_ENCODING', 'fitIntercept': True, 'standardizeFeatures': True}, 'trainingStartTime': '1764779429690', 'results': [{'index': 0, 'durationMs': '3104', 'trainingLoss': 78553.60163372214}], 'evaluationMetrics': {'regressionMetrics': {'meanAbsoluteError': 223.87876300779865, 'meanSquaredError': 78553.60163372215, 'meanSquaredLogError': 0.005614202871872688, 'medianAbsoluteError': 181.33091105963013, 'rSquared': 0.6239507555914934}}, 'startTime': '2025-12-03T16:30:29.690Z'}]" + ], + [ + "featureColumns", + "[{'name': 'island', 'type': {'typeKind': 'STRING'}}, {'name': 'culmen_length_mm', 'type': {'typeKind': 'FLOAT64'}}, {'name': 'culmen_depth_mm', 'type': {'typeKind': 'FLOAT64'}}, {'name': 'flipper_length_mm', 'type': {'typeKind': 'FLOAT64'}}, {'name': 'sex', 'type': {'typeKind': 'STRING'}}]" + ], + [ + "labelColumns", + "[{'name': 'predicted_label', 'type': {'typeKind': 'FLOAT64'}}]" + ], + [ + "location", + "US" + ] + ], + "shape": { + "columns": 1, + "rows": 9 + } + }, + "text/plain": [ + "etag P3XS+g0ZZM19ywL+hdwUmQ==\n", + "modelReference {'projectId': 'bigframes-dev', 'datasetId': 'b...\n", + "creationTime 1764779445166\n", + "lastModifiedTime 1764779445237\n", + "modelType LINEAR_REGRESSION\n", + "trainingRuns [{'trainingOptions': {'lossType': 'MEAN_SQUARE...\n", + "featureColumns [{'name': 'island', 'type': {'typeKind': 'STRI...\n", + "labelColumns [{'name': 'predicted_label', 'type': {'typeKin...\n", + "location US\n", + "dtype: object" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import bigframes.bigquery as bbq\n", + "\n", + "model_name = f\"{PROJECT_ID}.{DATASET_ID}.penguin_weight\"\n", + "model_metadata = bbq.ml.create_model(\n", + " model_name,\n", + " replace=True,\n", + " options={\n", + " \"model_type\": \"LINEAR_REG\",\n", + " },\n", + " training_data=training_data.rename(columns={\"body_mass_g\": \"label\"})\n", + ")\n", + "model_metadata" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "GskyyUQPowBT" + }, + "source": [ + "### Evaluate the model\n", + "\n", + "Check how the model performed by using the `evalutate` function. More information on model evaluation can be found [here](https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-evaluate#mlevaluate_output)." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": { + "id": "kGBJKafpo0dl" + }, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 0 Bytes in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
mean_absolute_errormean_squared_errormean_squared_log_errormedian_absolute_errorr2_scoreexplained_variance
0223.87876378553.6016340.005614181.3309110.6239510.623951
\n", + "

1 rows × 6 columns

\n", + "
[1 rows x 6 columns in total]" + ], + "text/plain": [ + " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", + "0 223.878763 78553.601634 0.005614 \n", + "\n", + " median_absolute_error r2_score explained_variance \n", + "0 181.330911 0.623951 0.623951 \n", + "\n", + "[1 rows x 6 columns]" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bbq.ml.evaluate(model_name)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "P2lUiZZ_cjri" + }, + "source": [ + "### Use the model to predict outcomes\n", + "\n", + "Now that you have evaluated your model, the next step is to use it to predict an\n", + "outcome. You can run `bigframes.bigquery.ml.predict` function on the model to\n", + "predict the body mass in grams of all penguins that reside on the Biscoe\n", + "Islands." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": { + "id": "bsQ9cmoWo0Ps" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/swast/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: TimeTravelCacheWarning: Reading cached table from 2025-12-03 16:30:18.272882+00:00 to avoid\n", + "incompatibilies with previous reads of this table. To read the latest\n", + "version, set `use_cache=False` or close the current session with\n", + "Session.close() or bigframes.pandas.close_session().\n", + " return method(*args, **kwargs)\n" + ] + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 29.3 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
predicted_labelspeciesislandculmen_length_mmculmen_depth_mmflipper_length_mmbody_mass_gsex
03945.010052Gentoo penguin (Pygoscelis papua)Biscoe<NA><NA><NA><NA><NA>
13914.916297Adelie Penguin (Pygoscelis adeliae)Biscoe39.718.9184.03550.0MALE
23278.611224Adelie Penguin (Pygoscelis adeliae)Biscoe36.417.1184.02850.0FEMALE
34006.367355Adelie Penguin (Pygoscelis adeliae)Biscoe41.618.0192.03950.0MALE
43417.610478Adelie Penguin (Pygoscelis adeliae)Biscoe35.017.9192.03725.0FEMALE
54009.612421Adelie Penguin (Pygoscelis adeliae)Biscoe41.118.2192.04050.0MALE
64231.330911Adelie Penguin (Pygoscelis adeliae)Biscoe42.019.5200.04050.0MALE
73554.308906Gentoo penguin (Pygoscelis papua)Biscoe43.813.9208.04300.0FEMALE
83550.677455Gentoo penguin (Pygoscelis papua)Biscoe43.314.0208.04575.0FEMALE
93537.882543Gentoo penguin (Pygoscelis papua)Biscoe44.013.6208.04350.0FEMALE
\n", + "

10 rows × 8 columns

\n", + "
[168 rows x 8 columns in total]" + ], + "text/plain": [ + " predicted_label species island \\\n", + "0 3945.010052 Gentoo penguin (Pygoscelis papua) Biscoe \n", + "1 3914.916297 Adelie Penguin (Pygoscelis adeliae) Biscoe \n", + "2 3278.611224 Adelie Penguin (Pygoscelis adeliae) Biscoe \n", + "3 4006.367355 Adelie Penguin (Pygoscelis adeliae) Biscoe \n", + "4 3417.610478 Adelie Penguin (Pygoscelis adeliae) Biscoe \n", + "5 4009.612421 Adelie Penguin (Pygoscelis adeliae) Biscoe \n", + "6 4231.330911 Adelie Penguin (Pygoscelis adeliae) Biscoe \n", + "7 3554.308906 Gentoo penguin (Pygoscelis papua) Biscoe \n", + "8 3550.677455 Gentoo penguin (Pygoscelis papua) Biscoe \n", + "9 3537.882543 Gentoo penguin (Pygoscelis papua) Biscoe \n", + "\n", + " culmen_length_mm culmen_depth_mm flipper_length_mm body_mass_g sex \n", + "0 \n", + "1 39.7 18.9 184.0 3550.0 MALE \n", + "2 36.4 17.1 184.0 2850.0 FEMALE \n", + "3 41.6 18.0 192.0 3950.0 MALE \n", + "4 35.0 17.9 192.0 3725.0 FEMALE \n", + "5 41.1 18.2 192.0 4050.0 MALE \n", + "6 42.0 19.5 200.0 4050.0 MALE \n", + "7 43.8 13.9 208.0 4300.0 FEMALE \n", + "8 43.3 14.0 208.0 4575.0 FEMALE \n", + "9 44.0 13.6 208.0 4350.0 FEMALE \n", + "...\n", + "\n", + "[168 rows x 8 columns]" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = bpd.read_gbq(\"bigquery-public-data.ml_datasets.penguins\")\n", + "biscoe = df[df[\"island\"].str.contains(\"Biscoe\")]\n", + "bbq.ml.predict(model_name, biscoe)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "GTRdUw-Ro5R1" + }, + "source": [ + "### Explain the prediction results\n", + "\n", + "To understand why the model is generating these prediction results, you can use the `explain_predict` function." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " Query started with request ID bigframes-dev:US.161bba69-c852-4916-a2df-bb5b309be6e4.
SQL
SELECT * FROM ML.EXPLAIN_PREDICT(MODEL `bigframes-dev.bqml_tutorial.penguin_weight`, (SELECT\n",
+              "`bfuid_col_22` AS `species`,\n",
+              "`bfuid_col_23` AS `island`,\n",
+              "`bfuid_col_24` AS `culmen_length_mm`,\n",
+              "`bfuid_col_25` AS `culmen_depth_mm`,\n",
+              "`bfuid_col_26` AS `flipper_length_mm`,\n",
+              "`bfuid_col_27` AS `body_mass_g`,\n",
+              "`bfuid_col_28` AS `sex`\n",
+              "FROM\n",
+              "(SELECT\n",
+              "  `t0`.`species`,\n",
+              "  `t0`.`island`,\n",
+              "  `t0`.`culmen_length_mm`,\n",
+              "  `t0`.`culmen_depth_mm`,\n",
+              "  `t0`.`flipper_length_mm`,\n",
+              "  `t0`.`body_mass_g`,\n",
+              "  `t0`.`sex`,\n",
+              "  `t0`.`species` AS `bfuid_col_22`,\n",
+              "  `t0`.`island` AS `bfuid_col_23`,\n",
+              "  `t0`.`culmen_length_mm` AS `bfuid_col_24`,\n",
+              "  `t0`.`culmen_depth_mm` AS `bfuid_col_25`,\n",
+              "  `t0`.`flipper_length_mm` AS `bfuid_col_26`,\n",
+              "  `t0`.`body_mass_g` AS `bfuid_col_27`,\n",
+              "  `t0`.`sex` AS `bfuid_col_28`,\n",
+              "  regexp_contains(`t0`.`island`, 'Biscoe') AS `bfuid_col_29`\n",
+              "FROM (\n",
+              "  SELECT\n",
+              "    `species`,\n",
+              "    `island`,\n",
+              "    `culmen_length_mm`,\n",
+              "    `culmen_depth_mm`,\n",
+              "    `flipper_length_mm`,\n",
+              "    `body_mass_g`,\n",
+              "    `sex`\n",
+              "  FROM `bigquery-public-data.ml_datasets.penguins` FOR SYSTEM_TIME AS OF TIMESTAMP('2025-12-03T16:30:18.272882+00:00')\n",
+              ") AS `t0`\n",
+              "WHERE\n",
+              "  regexp_contains(`t0`.`island`, 'Biscoe'))), STRUCT(3 AS top_k_features))\n",
+              "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
predicted_labeltop_feature_attributionsbaseline_prediction_valueprediction_valueapproximation_errorspeciesislandculmen_length_mmculmen_depth_mmflipper_length_mmbody_mass_gsex
03945.010052[{'feature': 'island', 'attribution': 0.0}\n", + " {'...3945.0100523945.0100520.0Gentoo penguin (Pygoscelis papua)Biscoe<NA><NA><NA><NA><NA>
13914.916297[{'feature': 'flipper_length_mm', 'attribution...3945.0100523914.9162970.0Adelie Penguin (Pygoscelis adeliae)Biscoe39.718.9184.03550.0MALE
23278.611224[{'feature': 'sex', 'attribution': -443.175184...3945.0100523278.6112240.0Adelie Penguin (Pygoscelis adeliae)Biscoe36.417.1184.02850.0FEMALE
34006.367355[{'feature': 'culmen_length_mm', 'attribution'...3945.0100524006.3673550.0Adelie Penguin (Pygoscelis adeliae)Biscoe41.618.0192.03950.0MALE
43417.610478[{'feature': 'sex', 'attribution': -443.175184...3945.0100523417.6104780.0Adelie Penguin (Pygoscelis adeliae)Biscoe35.017.9192.03725.0FEMALE
54009.612421[{'feature': 'culmen_length_mm', 'attribution'...3945.0100524009.6124210.0Adelie Penguin (Pygoscelis adeliae)Biscoe41.118.2192.04050.0MALE
64231.330911[{'feature': 'flipper_length_mm', 'attribution...3945.0100524231.3309110.0Adelie Penguin (Pygoscelis adeliae)Biscoe42.019.5200.04050.0MALE
73554.308906[{'feature': 'sex', 'attribution': -443.175184...3945.0100523554.3089060.0Gentoo penguin (Pygoscelis papua)Biscoe43.813.9208.04300.0FEMALE
83550.677455[{'feature': 'sex', 'attribution': -443.175184...3945.0100523550.6774550.0Gentoo penguin (Pygoscelis papua)Biscoe43.314.0208.04575.0FEMALE
93537.882543[{'feature': 'sex', 'attribution': -443.175184...3945.0100523537.8825430.0Gentoo penguin (Pygoscelis papua)Biscoe44.013.6208.04350.0FEMALE
\n", + "

10 rows × 12 columns

\n", + "
[168 rows x 12 columns in total]" + ], + "text/plain": [ + " predicted_label top_feature_attributions \\\n", + "0 3945.010052 [{'feature': 'island', 'attribution': 0.0}\n", + " {'... \n", + "1 3914.916297 [{'feature': 'flipper_length_mm', 'attribution... \n", + "2 3278.611224 [{'feature': 'sex', 'attribution': -443.175184... \n", + "3 4006.367355 [{'feature': 'culmen_length_mm', 'attribution'... \n", + "4 3417.610478 [{'feature': 'sex', 'attribution': -443.175184... \n", + "5 4009.612421 [{'feature': 'culmen_length_mm', 'attribution'... \n", + "6 4231.330911 [{'feature': 'flipper_length_mm', 'attribution... \n", + "7 3554.308906 [{'feature': 'sex', 'attribution': -443.175184... \n", + "8 3550.677455 [{'feature': 'sex', 'attribution': -443.175184... \n", + "9 3537.882543 [{'feature': 'sex', 'attribution': -443.175184... \n", + "\n", + " baseline_prediction_value prediction_value approximation_error \\\n", + "0 3945.010052 3945.010052 0.0 \n", + "1 3945.010052 3914.916297 0.0 \n", + "2 3945.010052 3278.611224 0.0 \n", + "3 3945.010052 4006.367355 0.0 \n", + "4 3945.010052 3417.610478 0.0 \n", + "5 3945.010052 4009.612421 0.0 \n", + "6 3945.010052 4231.330911 0.0 \n", + "7 3945.010052 3554.308906 0.0 \n", + "8 3945.010052 3550.677455 0.0 \n", + "9 3945.010052 3537.882543 0.0 \n", + "\n", + " species island culmen_length_mm \\\n", + "0 Gentoo penguin (Pygoscelis papua) Biscoe \n", + "1 Adelie Penguin (Pygoscelis adeliae) Biscoe 39.7 \n", + "2 Adelie Penguin (Pygoscelis adeliae) Biscoe 36.4 \n", + "3 Adelie Penguin (Pygoscelis adeliae) Biscoe 41.6 \n", + "4 Adelie Penguin (Pygoscelis adeliae) Biscoe 35.0 \n", + "5 Adelie Penguin (Pygoscelis adeliae) Biscoe 41.1 \n", + "6 Adelie Penguin (Pygoscelis adeliae) Biscoe 42.0 \n", + "7 Gentoo penguin (Pygoscelis papua) Biscoe 43.8 \n", + "8 Gentoo penguin (Pygoscelis papua) Biscoe 43.3 \n", + "9 Gentoo penguin (Pygoscelis papua) Biscoe 44.0 \n", + "\n", + " culmen_depth_mm flipper_length_mm body_mass_g sex \n", + "0 \n", + "1 18.9 184.0 3550.0 MALE \n", + "2 17.1 184.0 2850.0 FEMALE \n", + "3 18.0 192.0 3950.0 MALE \n", + "4 17.9 192.0 3725.0 FEMALE \n", + "5 18.2 192.0 4050.0 MALE \n", + "6 19.5 200.0 4050.0 MALE \n", + "7 13.9 208.0 4300.0 FEMALE \n", + "8 14.0 208.0 4575.0 FEMALE \n", + "9 13.6 208.0 4350.0 FEMALE \n", + "...\n", + "\n", + "[168 rows x 12 columns]" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bbq.ml.explain_predict(model_name, biscoe, top_k_features=3)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "K0mPaoGpcwwy" + }, + "source": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Globally explain the model\n", + "\n", + "To know which features are generally the most important to determine penguin\n", + "weight, you can use the `global_explain` function. In order to use\n", + "`global_explain`, you must retrain the model with the `enable_global_explain`\n", + "option set to `True`." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": { + "id": "ZSP7gt13QrQt" + }, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 6.9 kB in 53 seconds of slot time. [Job bigframes-dev:US.job_welN8ErlZ_sTG7oOEULsWUgmIg7l details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "model_name = f\"{PROJECT_ID}.{DATASET_ID}.penguin_weight_with_global_explain\"\n", + "model_metadata = bbq.ml.create_model(\n", + " model_name,\n", + " replace=True,\n", + " options={\n", + " \"model_type\": \"LINEAR_REG\",\n", + " \"input_label_cols\": [\"body_mass_g\"],\n", + " \"enable_global_explain\": True,\n", + " },\n", + " training_data=training_data,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 0 Bytes in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
featureattribution
0sex221.587592
1flipper_length_mm71.311846
2culmen_depth_mm66.17986
3culmen_length_mm45.443363
4island17.258076
\n", + "

5 rows × 2 columns

\n", + "
[5 rows x 2 columns in total]" + ], + "text/plain": [ + " feature attribution\n", + "0 sex 221.587592\n", + "1 flipper_length_mm 71.311846\n", + "2 culmen_depth_mm 66.17986\n", + "3 culmen_length_mm 45.443363\n", + "4 island 17.258076\n", + "\n", + "[5 rows x 2 columns]" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "bbq.ml.global_explain(model_name)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Compatibility with pandas\n", + "\n", + "The functions in `bigframes.bigquery.ml` can accept pandas DataFrames as well. Use the `to_pandas()` method on the results of methods like `predict()` to get a pandas DataFrame back." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " Query started with request ID bigframes-dev:US.18d9027b-7d55-42c9-ad1b-dabccdda80dc.
SQL
SELECT * FROM ML.PREDICT(MODEL `bigframes-dev.bqml_tutorial.penguin_weight_with_global_explain`, (SELECT\n",
+              "`column_0` AS `sex`,\n",
+              "`column_1` AS `flipper_length_mm`,\n",
+              "`column_2` AS `culmen_depth_mm`,\n",
+              "`column_3` AS `culmen_length_mm`,\n",
+              "`column_4` AS `island`\n",
+              "FROM\n",
+              "(SELECT\n",
+              "  *\n",
+              "FROM (\n",
+              "  SELECT\n",
+              "    *\n",
+              "  FROM UNNEST(ARRAY<STRUCT<`column_0` STRING, `column_1` INT64, `column_2` INT64, `column_3` INT64, `column_4` STRING>>[STRUCT('MALE', 180, 15, 40, 'Biscoe'), STRUCT('FEMALE', 190, 16, 41, 'Biscoe'), STRUCT('MALE', 200, 17, 42, 'Dream'), STRUCT('FEMALE', 210, 18, 43, 'Dream')]) AS `column_0`\n",
+              ") AS `t0`)))\n",
+              "
\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.microsoft.datawrangler.viewer.v0+json": { + "columns": [ + { + "name": "index", + "rawType": "Int64", + "type": "integer" + }, + { + "name": "predicted_body_mass_g", + "rawType": "Float64", + "type": "float" + }, + { + "name": "sex", + "rawType": "string", + "type": "string" + }, + { + "name": "flipper_length_mm", + "rawType": "Int64", + "type": "integer" + }, + { + "name": "culmen_depth_mm", + "rawType": "Int64", + "type": "integer" + }, + { + "name": "culmen_length_mm", + "rawType": "Int64", + "type": "integer" + }, + { + "name": "island", + "rawType": "string", + "type": "string" + } + ], + "ref": "01d67015-64b6-463e-8c16-e8ac1363ff67", + "rows": [ + [ + "0", + "3596.332210728767", + "MALE", + "180", + "15", + "40", + "Biscoe" + ], + [ + "1", + "3384.6999176328636", + "FEMALE", + "190", + "16", + "41", + "Biscoe" + ], + [ + "2", + "4049.581795919061", + "MALE", + "200", + "17", + "42", + "Dream" + ], + [ + "3", + "3837.9495028231568", + "FEMALE", + "210", + "18", + "43", + "Dream" + ] + ], + "shape": { + "columns": 6, + "rows": 4 + } + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
predicted_body_mass_gsexflipper_length_mmculmen_depth_mmculmen_length_mmisland
03596.332211MALE1801540Biscoe
13384.699918FEMALE1901641Biscoe
24049.581796MALE2001742Dream
33837.949503FEMALE2101843Dream
\n", + "
" + ], + "text/plain": [ + " predicted_body_mass_g sex flipper_length_mm culmen_depth_mm \\\n", + "0 3596.332211 MALE 180 15 \n", + "1 3384.699918 FEMALE 190 16 \n", + "2 4049.581796 MALE 200 17 \n", + "3 3837.949503 FEMALE 210 18 \n", + "\n", + " culmen_length_mm island \n", + "0 40 Biscoe \n", + "1 41 Biscoe \n", + "2 42 Dream \n", + "3 43 Dream " + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pandas as pd\n", + "\n", + "predict_df = pd.DataFrame({\n", + " \"sex\": [\"MALE\", \"FEMALE\", \"MALE\", \"FEMALE\"],\n", + " \"flipper_length_mm\": [180, 190, 200, 210],\n", + " \"culmen_depth_mm\": [15, 16, 17, 18],\n", + " \"culmen_length_mm\": [40, 41, 42, 43],\n", + " \"island\": [\"Biscoe\", \"Biscoe\", \"Dream\", \"Dream\"],\n", + "})\n", + "bbq.ml.predict(model_metadata, predict_df).to_pandas()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Compatibility with `bigframes.ml`\n", + "\n", + "The models created with `bigframes.bigquery.ml` can be used with the scikit-learn-like `bigframes.ml` modules by using the `read_gbq_model` method.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "LinearRegression(enable_global_explain=True,\n", + " optimize_strategy='NORMAL_EQUATION')" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model = bpd.read_gbq_model(model_name)\n", + "model" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 7.3 kB in a moment of slot time. [Job bigframes-dev:US.f2f86927-bbd1-431d-b89e-3d6a064268d7 details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
mean_absolute_errormean_squared_errormean_squared_log_errormedian_absolute_errorr2_scoreexplained_variance
0223.87876378553.6016340.005614181.3309110.6239510.623951
\n", + "

1 rows × 6 columns

\n", + "
[1 rows x 6 columns in total]" + ], + "text/plain": [ + " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", + " 223.878763 78553.601634 0.005614 \n", + "\n", + " median_absolute_error r2_score explained_variance \n", + " 181.330911 0.623951 0.623951 \n", + "\n", + "[1 rows x 6 columns]" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "X = training_data[[\"sex\", \"flipper_length_mm\", \"culmen_depth_mm\", \"culmen_length_mm\", \"island\"]]\n", + "y = training_data[[\"body_mass_g\"]]\n", + "model.score(X, y)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "G_wjSfXpWTuy" + }, + "source": [ + "# Summary and next steps\n", + "\n", + "You've created a linear regression model using `bigframes.bigquery.ml`.\n", + "\n", + "Learn more about BigQuery DataFrames in the [documentation](https://dataframes.bigquery.dev/) and find more sample notebooks in the [GitHub repo](https://github.com/googleapis/python-bigquery-dataframes/tree/main/notebooks)." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "TpV-iwP9qw9c" + }, + "source": [ + "## Cleaning up\n", + "\n", + "To clean up all Google Cloud resources used in this project, you can [delete the Google Cloud\n", + "project](https://cloud.google.com/resource-manager/docs/creating-managing-projects#shutting_down_projects) you used for the tutorial.\n", + "\n", + "Otherwise, you can uncomment the remaining cells and run them to delete the individual resources you created in this tutorial:" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": { + "id": "sx_vKniMq9ZX" + }, + "outputs": [], + "source": [ + "# # Delete the BigQuery dataset and associated ML model\n", + "# from google.cloud import bigquery\n", + "# client = bigquery.Client(project=PROJECT_ID)\n", + "# client.delete_dataset(\n", + "# DATASET_ID, delete_contents=True, not_found_ok=True\n", + "# )\n", + "# print(\"Deleted dataset '{}'.\".format(DATASET_ID))" + ] + } + ], + "metadata": { + "colab": { + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/noxfile.py b/noxfile.py index b02952f9c2..44fc5adede 100644 --- a/noxfile.py +++ b/noxfile.py @@ -764,7 +764,6 @@ def notebook(session: nox.Session): ) notebooks_list = list(pathlib.Path("notebooks/").glob("*/*.ipynb")) - denylist = [ # Regionalized testing is manually added later. "notebooks/location/regionalized.ipynb", diff --git a/tests/unit/bigquery/test_ml.py b/tests/unit/bigquery/test_ml.py new file mode 100644 index 0000000000..063ddafcca --- /dev/null +++ b/tests/unit/bigquery/test_ml.py @@ -0,0 +1,147 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from unittest import mock + +import pandas as pd +import pytest + +import bigframes.bigquery._operations.ml as ml_ops +import bigframes.session + + +@pytest.fixture +def mock_session(): + return mock.create_autospec(spec=bigframes.session.Session) + + +MODEL_SERIES = pd.Series( + { + "modelReference": { + "projectId": "test-project", + "datasetId": "test-dataset", + "modelId": "test-model", + } + } +) + +MODEL_NAME = "test-project.test-dataset.test-model" + + +def test_get_model_name_and_session_with_pandas_series_model_input(): + model_name, _ = ml_ops._get_model_name_and_session(MODEL_SERIES) + assert model_name == MODEL_NAME + + +def test_get_model_name_and_session_with_pandas_series_model_input_missing_model_reference(): + model_series = pd.Series({"some_other_key": "value"}) + with pytest.raises( + ValueError, match="modelReference must be present in the pandas Series" + ): + ml_ops._get_model_name_and_session(model_series) + + +@mock.patch("bigframes.pandas.read_pandas") +def test_to_sql_with_pandas_dataframe(read_pandas_mock): + df = pd.DataFrame({"col1": [1, 2, 3]}) + read_pandas_mock.return_value._to_sql_query.return_value = ( + "SELECT * FROM `pandas_df`", + [], + [], + ) + ml_ops._to_sql(df) + read_pandas_mock.assert_called_once() + + +@mock.patch("bigframes.bigquery._operations.ml._get_model_metadata") +@mock.patch("bigframes.pandas.read_pandas") +def test_create_model_with_pandas_dataframe( + read_pandas_mock, _get_model_metadata_mock, mock_session +): + df = pd.DataFrame({"col1": [1, 2, 3]}) + read_pandas_mock.return_value._to_sql_query.return_value = ( + "SELECT * FROM `pandas_df`", + [], + [], + ) + ml_ops.create_model("model_name", training_data=df, session=mock_session) + read_pandas_mock.assert_called_once() + mock_session.read_gbq_query.assert_called_once() + generated_sql = mock_session.read_gbq_query.call_args[0][0] + assert "CREATE MODEL `model_name`" in generated_sql + assert "AS SELECT * FROM `pandas_df`" in generated_sql + + +@mock.patch("bigframes.pandas.read_gbq_query") +@mock.patch("bigframes.pandas.read_pandas") +def test_evaluate_with_pandas_dataframe(read_pandas_mock, read_gbq_query_mock): + df = pd.DataFrame({"col1": [1, 2, 3]}) + read_pandas_mock.return_value._to_sql_query.return_value = ( + "SELECT * FROM `pandas_df`", + [], + [], + ) + ml_ops.evaluate(MODEL_SERIES, input_=df) + read_pandas_mock.assert_called_once() + read_gbq_query_mock.assert_called_once() + generated_sql = read_gbq_query_mock.call_args[0][0] + assert "ML.EVALUATE" in generated_sql + assert f"MODEL `{MODEL_NAME}`" in generated_sql + assert "(SELECT * FROM `pandas_df`)" in generated_sql + + +@mock.patch("bigframes.pandas.read_gbq_query") +@mock.patch("bigframes.pandas.read_pandas") +def test_predict_with_pandas_dataframe(read_pandas_mock, read_gbq_query_mock): + df = pd.DataFrame({"col1": [1, 2, 3]}) + read_pandas_mock.return_value._to_sql_query.return_value = ( + "SELECT * FROM `pandas_df`", + [], + [], + ) + ml_ops.predict(MODEL_SERIES, input_=df) + read_pandas_mock.assert_called_once() + read_gbq_query_mock.assert_called_once() + generated_sql = read_gbq_query_mock.call_args[0][0] + assert "ML.PREDICT" in generated_sql + assert f"MODEL `{MODEL_NAME}`" in generated_sql + assert "(SELECT * FROM `pandas_df`)" in generated_sql + + +@mock.patch("bigframes.pandas.read_gbq_query") +@mock.patch("bigframes.pandas.read_pandas") +def test_explain_predict_with_pandas_dataframe(read_pandas_mock, read_gbq_query_mock): + df = pd.DataFrame({"col1": [1, 2, 3]}) + read_pandas_mock.return_value._to_sql_query.return_value = ( + "SELECT * FROM `pandas_df`", + [], + [], + ) + ml_ops.explain_predict(MODEL_SERIES, input_=df) + read_pandas_mock.assert_called_once() + read_gbq_query_mock.assert_called_once() + generated_sql = read_gbq_query_mock.call_args[0][0] + assert "ML.EXPLAIN_PREDICT" in generated_sql + assert f"MODEL `{MODEL_NAME}`" in generated_sql + assert "(SELECT * FROM `pandas_df`)" in generated_sql + + +@mock.patch("bigframes.pandas.read_gbq_query") +def test_global_explain_with_pandas_series_model(read_gbq_query_mock): + ml_ops.global_explain(MODEL_SERIES) + read_gbq_query_mock.assert_called_once() + generated_sql = read_gbq_query_mock.call_args[0][0] + assert "ML.GLOBAL_EXPLAIN" in generated_sql + assert f"MODEL `{MODEL_NAME}`" in generated_sql diff --git a/tests/unit/core/sql/snapshots/test_ml/test_create_model_basic/create_model_basic.sql b/tests/unit/core/sql/snapshots/test_ml/test_create_model_basic/create_model_basic.sql new file mode 100644 index 0000000000..9affd870e3 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_create_model_basic/create_model_basic.sql @@ -0,0 +1,3 @@ +CREATE MODEL `my_project.my_dataset.my_model` +OPTIONS(model_type = 'LINEAR_REG', input_label_cols = ['label']) +AS SELECT * FROM my_table diff --git a/tests/unit/core/sql/snapshots/test_ml/test_create_model_if_not_exists/create_model_if_not_exists.sql b/tests/unit/core/sql/snapshots/test_ml/test_create_model_if_not_exists/create_model_if_not_exists.sql new file mode 100644 index 0000000000..b67ea13967 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_create_model_if_not_exists/create_model_if_not_exists.sql @@ -0,0 +1,3 @@ +CREATE MODEL IF NOT EXISTS `my_model` +OPTIONS(model_type = 'KMEANS') +AS SELECT * FROM t diff --git a/tests/unit/core/sql/snapshots/test_ml/test_create_model_list_option/create_model_list_option.sql b/tests/unit/core/sql/snapshots/test_ml/test_create_model_list_option/create_model_list_option.sql new file mode 100644 index 0000000000..723a4b037d --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_create_model_list_option/create_model_list_option.sql @@ -0,0 +1,3 @@ +CREATE MODEL `my_model` +OPTIONS(hidden_units = [32, 16], dropout = 0.2) +AS SELECT * FROM t diff --git a/tests/unit/core/sql/snapshots/test_ml/test_create_model_remote/create_model_remote.sql b/tests/unit/core/sql/snapshots/test_ml/test_create_model_remote/create_model_remote.sql new file mode 100644 index 0000000000..878afe0823 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_create_model_remote/create_model_remote.sql @@ -0,0 +1,5 @@ +CREATE MODEL `my_remote_model` +INPUT (prompt STRING) +OUTPUT (content STRING) +REMOTE WITH CONNECTION `my_project.us.my_connection` +OPTIONS(endpoint = 'gemini-pro') diff --git a/tests/unit/core/sql/snapshots/test_ml/test_create_model_remote_default/create_model_remote_default.sql b/tests/unit/core/sql/snapshots/test_ml/test_create_model_remote_default/create_model_remote_default.sql new file mode 100644 index 0000000000..9bbea44259 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_create_model_remote_default/create_model_remote_default.sql @@ -0,0 +1,3 @@ +CREATE MODEL `my_remote_model` +REMOTE WITH CONNECTION DEFAULT +OPTIONS(endpoint = 'gemini-pro') diff --git a/tests/unit/core/sql/snapshots/test_ml/test_create_model_replace/create_model_replace.sql b/tests/unit/core/sql/snapshots/test_ml/test_create_model_replace/create_model_replace.sql new file mode 100644 index 0000000000..7fe9d492da --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_create_model_replace/create_model_replace.sql @@ -0,0 +1,3 @@ +CREATE OR REPLACE MODEL `my_model` +OPTIONS(model_type = 'LOGISTIC_REG') +AS SELECT * FROM t diff --git a/tests/unit/core/sql/snapshots/test_ml/test_create_model_training_data_and_holiday/create_model_training_data_and_holiday.sql b/tests/unit/core/sql/snapshots/test_ml/test_create_model_training_data_and_holiday/create_model_training_data_and_holiday.sql new file mode 100644 index 0000000000..da7b6ba672 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_create_model_training_data_and_holiday/create_model_training_data_and_holiday.sql @@ -0,0 +1,5 @@ +CREATE MODEL `my_arima_model` +OPTIONS(model_type = 'ARIMA_PLUS') +AS ( + training_data AS (SELECT * FROM sales), custom_holiday AS (SELECT * FROM holidays) +) \ No newline at end of file diff --git a/tests/unit/core/sql/snapshots/test_ml/test_create_model_transform/create_model_transform.sql b/tests/unit/core/sql/snapshots/test_ml/test_create_model_transform/create_model_transform.sql new file mode 100644 index 0000000000..e460400be2 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_create_model_transform/create_model_transform.sql @@ -0,0 +1,4 @@ +CREATE MODEL `my_model` +TRANSFORM (ML.STANDARD_SCALER(c1) OVER() AS c1_scaled, c2) +OPTIONS(model_type = 'LINEAR_REG') +AS SELECT c1, c2, label FROM t diff --git a/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_basic/evaluate_model_basic.sql b/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_basic/evaluate_model_basic.sql new file mode 100644 index 0000000000..5889e342e4 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_basic/evaluate_model_basic.sql @@ -0,0 +1 @@ +SELECT * FROM ML.EVALUATE(MODEL `my_project.my_dataset.my_model`) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_with_options/evaluate_model_with_options.sql b/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_with_options/evaluate_model_with_options.sql new file mode 100644 index 0000000000..01eb4d3781 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_with_options/evaluate_model_with_options.sql @@ -0,0 +1 @@ +SELECT * FROM ML.EVALUATE(MODEL `my_model`, STRUCT(False AS perform_aggregation, 10 AS horizon, 0.95 AS confidence_level)) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_with_table/evaluate_model_with_table.sql b/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_with_table/evaluate_model_with_table.sql new file mode 100644 index 0000000000..e1d4fdecd6 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_with_table/evaluate_model_with_table.sql @@ -0,0 +1 @@ +SELECT * FROM ML.EVALUATE(MODEL `my_project.my_dataset.my_model`, (SELECT * FROM evaluation_data)) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_explain_predict_model_basic/explain_predict_model_basic.sql b/tests/unit/core/sql/snapshots/test_ml/test_explain_predict_model_basic/explain_predict_model_basic.sql new file mode 100644 index 0000000000..1d755b34dd --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_explain_predict_model_basic/explain_predict_model_basic.sql @@ -0,0 +1 @@ +SELECT * FROM ML.EXPLAIN_PREDICT(MODEL `my_project.my_dataset.my_model`, (SELECT * FROM new_data)) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_explain_predict_model_with_options/explain_predict_model_with_options.sql b/tests/unit/core/sql/snapshots/test_ml/test_explain_predict_model_with_options/explain_predict_model_with_options.sql new file mode 100644 index 0000000000..1214bba870 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_explain_predict_model_with_options/explain_predict_model_with_options.sql @@ -0,0 +1 @@ +SELECT * FROM ML.EXPLAIN_PREDICT(MODEL `my_model`, (SELECT * FROM new_data), STRUCT(5 AS top_k_features)) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_global_explain_model_basic/global_explain_model_basic.sql b/tests/unit/core/sql/snapshots/test_ml/test_global_explain_model_basic/global_explain_model_basic.sql new file mode 100644 index 0000000000..4fc8250dab --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_global_explain_model_basic/global_explain_model_basic.sql @@ -0,0 +1 @@ +SELECT * FROM ML.GLOBAL_EXPLAIN(MODEL `my_project.my_dataset.my_model`) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_global_explain_model_with_options/global_explain_model_with_options.sql b/tests/unit/core/sql/snapshots/test_ml/test_global_explain_model_with_options/global_explain_model_with_options.sql new file mode 100644 index 0000000000..1a3baa0c13 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_global_explain_model_with_options/global_explain_model_with_options.sql @@ -0,0 +1 @@ +SELECT * FROM ML.GLOBAL_EXPLAIN(MODEL `my_model`, STRUCT(True AS class_level_explain)) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_predict_model_basic/predict_model_basic.sql b/tests/unit/core/sql/snapshots/test_ml/test_predict_model_basic/predict_model_basic.sql new file mode 100644 index 0000000000..a1ac0b2b45 --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_predict_model_basic/predict_model_basic.sql @@ -0,0 +1 @@ +SELECT * FROM ML.PREDICT(MODEL `my_project.my_dataset.my_model`, (SELECT * FROM new_data)) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_predict_model_with_options/predict_model_with_options.sql b/tests/unit/core/sql/snapshots/test_ml/test_predict_model_with_options/predict_model_with_options.sql new file mode 100644 index 0000000000..96c8074e4c --- /dev/null +++ b/tests/unit/core/sql/snapshots/test_ml/test_predict_model_with_options/predict_model_with_options.sql @@ -0,0 +1 @@ +SELECT * FROM ML.PREDICT(MODEL `my_model`, (SELECT * FROM new_data), STRUCT(True AS keep_original_columns)) diff --git a/tests/unit/core/sql/test_ml.py b/tests/unit/core/sql/test_ml.py new file mode 100644 index 0000000000..fe8c1a04d4 --- /dev/null +++ b/tests/unit/core/sql/test_ml.py @@ -0,0 +1,171 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +import bigframes.core.sql.ml + +pytest.importorskip("pytest_snapshot") + + +def test_create_model_basic(snapshot): + sql = bigframes.core.sql.ml.create_model_ddl( + model_name="my_project.my_dataset.my_model", + options={"model_type": "LINEAR_REG", "input_label_cols": ["label"]}, + training_data="SELECT * FROM my_table", + ) + snapshot.assert_match(sql, "create_model_basic.sql") + + +def test_create_model_replace(snapshot): + sql = bigframes.core.sql.ml.create_model_ddl( + model_name="my_model", + replace=True, + options={"model_type": "LOGISTIC_REG"}, + training_data="SELECT * FROM t", + ) + snapshot.assert_match(sql, "create_model_replace.sql") + + +def test_create_model_if_not_exists(snapshot): + sql = bigframes.core.sql.ml.create_model_ddl( + model_name="my_model", + if_not_exists=True, + options={"model_type": "KMEANS"}, + training_data="SELECT * FROM t", + ) + snapshot.assert_match(sql, "create_model_if_not_exists.sql") + + +def test_create_model_transform(snapshot): + sql = bigframes.core.sql.ml.create_model_ddl( + model_name="my_model", + transform=["ML.STANDARD_SCALER(c1) OVER() AS c1_scaled", "c2"], + options={"model_type": "LINEAR_REG"}, + training_data="SELECT c1, c2, label FROM t", + ) + snapshot.assert_match(sql, "create_model_transform.sql") + + +def test_create_model_remote(snapshot): + sql = bigframes.core.sql.ml.create_model_ddl( + model_name="my_remote_model", + connection_name="my_project.us.my_connection", + options={"endpoint": "gemini-pro"}, + input_schema={"prompt": "STRING"}, + output_schema={"content": "STRING"}, + ) + snapshot.assert_match(sql, "create_model_remote.sql") + + +def test_create_model_remote_default(snapshot): + sql = bigframes.core.sql.ml.create_model_ddl( + model_name="my_remote_model", + connection_name="DEFAULT", + options={"endpoint": "gemini-pro"}, + ) + snapshot.assert_match(sql, "create_model_remote_default.sql") + + +def test_create_model_training_data_and_holiday(snapshot): + sql = bigframes.core.sql.ml.create_model_ddl( + model_name="my_arima_model", + options={"model_type": "ARIMA_PLUS"}, + training_data="SELECT * FROM sales", + custom_holiday="SELECT * FROM holidays", + ) + snapshot.assert_match(sql, "create_model_training_data_and_holiday.sql") + + +def test_create_model_list_option(snapshot): + sql = bigframes.core.sql.ml.create_model_ddl( + model_name="my_model", + options={"hidden_units": [32, 16], "dropout": 0.2}, + training_data="SELECT * FROM t", + ) + snapshot.assert_match(sql, "create_model_list_option.sql") + + +def test_evaluate_model_basic(snapshot): + sql = bigframes.core.sql.ml.evaluate( + model_name="my_project.my_dataset.my_model", + ) + snapshot.assert_match(sql, "evaluate_model_basic.sql") + + +def test_evaluate_model_with_table(snapshot): + sql = bigframes.core.sql.ml.evaluate( + model_name="my_project.my_dataset.my_model", + table="SELECT * FROM evaluation_data", + ) + snapshot.assert_match(sql, "evaluate_model_with_table.sql") + + +def test_evaluate_model_with_options(snapshot): + sql = bigframes.core.sql.ml.evaluate( + model_name="my_model", + perform_aggregation=False, + horizon=10, + confidence_level=0.95, + ) + snapshot.assert_match(sql, "evaluate_model_with_options.sql") + + +def test_predict_model_basic(snapshot): + sql = bigframes.core.sql.ml.predict( + model_name="my_project.my_dataset.my_model", + table="SELECT * FROM new_data", + ) + snapshot.assert_match(sql, "predict_model_basic.sql") + + +def test_predict_model_with_options(snapshot): + sql = bigframes.core.sql.ml.predict( + model_name="my_model", + table="SELECT * FROM new_data", + keep_original_columns=True, + ) + snapshot.assert_match(sql, "predict_model_with_options.sql") + + +def test_explain_predict_model_basic(snapshot): + sql = bigframes.core.sql.ml.explain_predict( + model_name="my_project.my_dataset.my_model", + table="SELECT * FROM new_data", + ) + snapshot.assert_match(sql, "explain_predict_model_basic.sql") + + +def test_explain_predict_model_with_options(snapshot): + sql = bigframes.core.sql.ml.explain_predict( + model_name="my_model", + table="SELECT * FROM new_data", + top_k_features=5, + ) + snapshot.assert_match(sql, "explain_predict_model_with_options.sql") + + +def test_global_explain_model_basic(snapshot): + sql = bigframes.core.sql.ml.global_explain( + model_name="my_project.my_dataset.my_model", + ) + snapshot.assert_match(sql, "global_explain_model_basic.sql") + + +def test_global_explain_model_with_options(snapshot): + sql = bigframes.core.sql.ml.global_explain( + model_name="my_model", + class_level_explain=True, + ) + snapshot.assert_match(sql, "global_explain_model_with_options.sql") From 7e959b95b9d3d54417fa56018125d6a3852d2a60 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Thu, 4 Dec 2025 12:43:08 -0800 Subject: [PATCH 275/313] Fix: Defer TableWidget import to prevent ZMQ port conflicts (#2309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR addresses a flaky ZMQError: Address already in use that occasionally occurred during parallel notebook test execution. **Problem:** The bigframes.display module eagerly imported anywidget and traitlets at module load time (`bigframes/display/__init__.py`). This meant that when multiple Jupyter kernels were spun up simultaneously by nox for parallel testing, they would all try to initialize traitlets.HasTraits objects with sync=True properties. This led to race conditions and ZMQ port conflicts, causing notebook tests (including those that did not directly use anywidget like `streaming_dataframe.ipynb`) to fail. Log is [here](https://fusion2.corp.google.com/invocations/72088900-0196-4441-944b-ad68e491a8f8/targets/bigframes%2Fpresubmit%2Fnotebook/log). **Solution:** The TableWidget class import in `bigframes/display/__init__.py` has been refactored to use Python's `__getattr__` for lazy loading. This ensures that anywidget and traitlets are only imported and their associated kernel communication channels are initialized when display.TableWidget is actually accessed by the code. This prevents premature initialization and eliminates the port collision race condition during parallel test startup. Fixes #<465768150> 🦕 --- bigframes/display/__init__.py | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/bigframes/display/__init__.py b/bigframes/display/__init__.py index 97248a0efb..aa1371db56 100644 --- a/bigframes/display/__init__.py +++ b/bigframes/display/__init__.py @@ -16,11 +16,33 @@ from __future__ import annotations -try: - import anywidget # noqa +from typing import Any - from bigframes.display.anywidget import TableWidget - __all__ = ["TableWidget"] -except Exception: - pass +def __getattr__(name: str) -> Any: + """Lazily import TableWidget to avoid ZMQ port conflicts. + + anywidget and traitlets eagerly initialize kernel communication channels on + import. This can lead to race conditions and ZMQ port conflicts when + multiple Jupyter kernels are started in parallel, such as during notebook + tests. By using __getattr__, we defer the import of TableWidget until it is + explicitly accessed, preventing premature initialization and avoiding port + collisions. + """ + if name == "TableWidget": + try: + import anywidget # noqa + + from bigframes.display.anywidget import TableWidget + + return TableWidget + except Exception: + raise AttributeError( + f"module '{__name__}' has no attribute '{name}'. " + "TableWidget requires anywidget and traitlets to be installed. " + "Please `pip install anywidget traitlets` or `pip install 'bigframes[anywidget]'`." + ) + raise AttributeError(f"module '{__name__}' has no attribute '{name}'") + + +__all__ = ["TableWidget"] From dbb03392ec3da04c7a2d2bd41e5bbd580c6ab2d6 Mon Sep 17 00:00:00 2001 From: jialuoo Date: Fri, 5 Dec 2025 11:52:39 -0800 Subject: [PATCH 276/313] chore: Migrate DatetimeToIntegerLabelOp operator to SQLGlot (#2306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes b/447388852 🦕 --- .../sqlglot/expressions/datetime_ops.py | 266 ++++++++++++++++++ .../test_datetime_to_integer_label/out.sql | 38 +++ .../sqlglot/expressions/test_datetime_ops.py | 16 ++ 3 files changed, 320 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_datetime_to_integer_label/out.sql diff --git a/bigframes/core/compile/sqlglot/expressions/datetime_ops.py b/bigframes/core/compile/sqlglot/expressions/datetime_ops.py index 0f1e9dadf3..78e17ae33b 100644 --- a/bigframes/core/compile/sqlglot/expressions/datetime_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/datetime_ops.py @@ -23,6 +23,272 @@ import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler register_unary_op = scalar_compiler.scalar_op_compiler.register_unary_op +register_binary_op = scalar_compiler.scalar_op_compiler.register_binary_op + + +def _calculate_resample_first(y: TypedExpr, origin: str) -> sge.Expression: + if origin == "epoch": + return sge.convert(0) + elif origin == "start_day": + return sge.func( + "UNIX_MICROS", + sge.Cast( + this=sge.Cast( + this=y.expr, to=sge.DataType(this=sge.DataType.Type.DATE) + ), + to=sge.DataType(this=sge.DataType.Type.TIMESTAMPTZ), + ), + ) + elif origin == "start": + return sge.func( + "UNIX_MICROS", + sge.Cast(this=y.expr, to=sge.DataType(this=sge.DataType.Type.TIMESTAMPTZ)), + ) + else: + raise ValueError(f"Origin {origin} not supported") + + +@register_binary_op(ops.DatetimeToIntegerLabelOp, pass_op=True) +def datetime_to_integer_label_op( + x: TypedExpr, y: TypedExpr, op: ops.DatetimeToIntegerLabelOp +) -> sge.Expression: + # Determine if the frequency is fixed by checking if 'op.freq.nanos' is defined. + try: + return _datetime_to_integer_label_fixed_frequency(x, y, op) + except ValueError: + return _datetime_to_integer_label_non_fixed_frequency(x, y, op) + + +def _datetime_to_integer_label_fixed_frequency( + x: TypedExpr, y: TypedExpr, op: ops.DatetimeToIntegerLabelOp +) -> sge.Expression: + """ + This function handles fixed frequency conversions where the unit can range + from microseconds (us) to days. + """ + us = op.freq.nanos / 1000 + x_int = sge.func( + "UNIX_MICROS", + sge.Cast(this=x.expr, to=sge.DataType(this=sge.DataType.Type.TIMESTAMPTZ)), + ) + first = _calculate_resample_first(y, op.origin) # type: ignore + x_int_label = sge.Cast( + this=sge.Floor( + this=sge.func( + "IEEE_DIVIDE", + sge.Sub(this=x_int, expression=first), + sge.convert(int(us)), + ) + ), + to=sge.DataType.build("INT64"), + ) + return x_int_label + + +def _datetime_to_integer_label_non_fixed_frequency( + x: TypedExpr, y: TypedExpr, op: ops.DatetimeToIntegerLabelOp +) -> sge.Expression: + """ + This function handles non-fixed frequency conversions for units ranging + from weeks to years. + """ + rule_code = op.freq.rule_code + n = op.freq.n + if rule_code == "W-SUN": # Weekly + us = n * 7 * 24 * 60 * 60 * 1000000 + x_trunc = sge.TimestampTrunc(this=x.expr, unit=sge.Var(this="WEEK(MONDAY)")) + y_trunc = sge.TimestampTrunc(this=y.expr, unit=sge.Var(this="WEEK(MONDAY)")) + x_plus_6 = sge.Add( + this=x_trunc, + expression=sge.Interval( + this=sge.convert(6), unit=sge.Identifier(this="DAY") + ), + ) + y_plus_6 = sge.Add( + this=y_trunc, + expression=sge.Interval( + this=sge.convert(6), unit=sge.Identifier(this="DAY") + ), + ) + x_int = sge.func( + "UNIX_MICROS", + sge.Cast( + this=x_plus_6, to=sge.DataType(this=sge.DataType.Type.TIMESTAMPTZ) + ), + ) + first = sge.func( + "UNIX_MICROS", + sge.Cast( + this=y_plus_6, to=sge.DataType(this=sge.DataType.Type.TIMESTAMPTZ) + ), + ) + return sge.Case( + ifs=[ + sge.If( + this=sge.EQ(this=x_int, expression=first), + true=sge.convert(0), + ) + ], + default=sge.Add( + this=sge.Cast( + this=sge.Floor( + this=sge.func( + "IEEE_DIVIDE", + sge.Sub( + this=sge.Sub(this=x_int, expression=first), + expression=sge.convert(1), + ), + sge.convert(us), + ) + ), + to=sge.DataType.build("INT64"), + ), + expression=sge.convert(1), + ), + ) + elif rule_code == "ME": # Monthly + x_int = sge.Paren( # type: ignore + this=sge.Add( + this=sge.Mul( + this=sge.Extract( + this=sge.Identifier(this="YEAR"), expression=x.expr + ), + expression=sge.convert(12), + ), + expression=sge.Sub( + this=sge.Extract( + this=sge.Identifier(this="MONTH"), expression=x.expr + ), + expression=sge.convert(1), + ), + ) + ) + first = sge.Paren( # type: ignore + this=sge.Add( + this=sge.Mul( + this=sge.Extract( + this=sge.Identifier(this="YEAR"), expression=y.expr + ), + expression=sge.convert(12), + ), + expression=sge.Sub( + this=sge.Extract( + this=sge.Identifier(this="MONTH"), expression=y.expr + ), + expression=sge.convert(1), + ), + ) + ) + return sge.Case( + ifs=[ + sge.If( + this=sge.EQ(this=x_int, expression=first), + true=sge.convert(0), + ) + ], + default=sge.Add( + this=sge.Cast( + this=sge.Floor( + this=sge.func( + "IEEE_DIVIDE", + sge.Sub( + this=sge.Sub(this=x_int, expression=first), + expression=sge.convert(1), + ), + sge.convert(n), + ) + ), + to=sge.DataType.build("INT64"), + ), + expression=sge.convert(1), + ), + ) + elif rule_code == "QE-DEC": # Quarterly + x_int = sge.Paren( # type: ignore + this=sge.Add( + this=sge.Mul( + this=sge.Extract( + this=sge.Identifier(this="YEAR"), expression=x.expr + ), + expression=sge.convert(4), + ), + expression=sge.Sub( + this=sge.Extract( + this=sge.Identifier(this="QUARTER"), expression=x.expr + ), + expression=sge.convert(1), + ), + ) + ) + first = sge.Paren( # type: ignore + this=sge.Add( + this=sge.Mul( + this=sge.Extract( + this=sge.Identifier(this="YEAR"), expression=y.expr + ), + expression=sge.convert(4), + ), + expression=sge.Sub( + this=sge.Extract( + this=sge.Identifier(this="QUARTER"), expression=y.expr + ), + expression=sge.convert(1), + ), + ) + ) + return sge.Case( + ifs=[ + sge.If( + this=sge.EQ(this=x_int, expression=first), + true=sge.convert(0), + ) + ], + default=sge.Add( + this=sge.Cast( + this=sge.Floor( + this=sge.func( + "IEEE_DIVIDE", + sge.Sub( + this=sge.Sub(this=x_int, expression=first), + expression=sge.convert(1), + ), + sge.convert(n), + ) + ), + to=sge.DataType.build("INT64"), + ), + expression=sge.convert(1), + ), + ) + elif rule_code == "YE-DEC": # Yearly + x_int = sge.Extract(this=sge.Identifier(this="YEAR"), expression=x.expr) + first = sge.Extract(this=sge.Identifier(this="YEAR"), expression=y.expr) + return sge.Case( + ifs=[ + sge.If( + this=sge.EQ(this=x_int, expression=first), + true=sge.convert(0), + ) + ], + default=sge.Add( + this=sge.Cast( + this=sge.Floor( + this=sge.func( + "IEEE_DIVIDE", + sge.Sub( + this=sge.Sub(this=x_int, expression=first), + expression=sge.convert(1), + ), + sge.convert(n), + ) + ), + to=sge.DataType.build("INT64"), + ), + expression=sge.convert(1), + ), + ) + else: + raise ValueError(rule_code) @register_unary_op(ops.FloorDtOp, pass_op=True) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_datetime_to_integer_label/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_datetime_to_integer_label/out.sql new file mode 100644 index 0000000000..5260dd680a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_datetime_to_integer_label/out.sql @@ -0,0 +1,38 @@ +WITH `bfcte_0` AS ( + SELECT + `datetime_col`, + `timestamp_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CAST(FLOOR( + IEEE_DIVIDE( + UNIX_MICROS(CAST(`datetime_col` AS TIMESTAMP)) - UNIX_MICROS(CAST(`timestamp_col` AS TIMESTAMP)), + 86400000000 + ) + ) AS INT64) AS `bfcol_2`, + CASE + WHEN UNIX_MICROS( + CAST(TIMESTAMP_TRUNC(`datetime_col`, WEEK(MONDAY)) + INTERVAL 6 DAY AS TIMESTAMP) + ) = UNIX_MICROS( + CAST(TIMESTAMP_TRUNC(`timestamp_col`, WEEK(MONDAY)) + INTERVAL 6 DAY AS TIMESTAMP) + ) + THEN 0 + ELSE CAST(FLOOR( + IEEE_DIVIDE( + UNIX_MICROS( + CAST(TIMESTAMP_TRUNC(`datetime_col`, WEEK(MONDAY)) + INTERVAL 6 DAY AS TIMESTAMP) + ) - UNIX_MICROS( + CAST(TIMESTAMP_TRUNC(`timestamp_col`, WEEK(MONDAY)) + INTERVAL 6 DAY AS TIMESTAMP) + ) - 1, + 604800000000 + ) + ) AS INT64) + 1 + END AS `bfcol_3` + FROM `bfcte_0` +) +SELECT + `bfcol_2` AS `fixed_freq`, + `bfcol_3` AS `non_fixed_freq_weekly` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py index 9d93b9019f..c4acb37e51 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py @@ -57,6 +57,22 @@ def test_dayofyear(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_datetime_to_integer_label(scalar_types_df: bpd.DataFrame, snapshot): + col_names = ["datetime_col", "timestamp_col"] + bf_df = scalar_types_df[col_names] + ops_map = { + "fixed_freq": ops.DatetimeToIntegerLabelOp( + freq=pd.tseries.offsets.Day(), origin="start", closed="left" # type: ignore + ).as_expr("datetime_col", "timestamp_col"), + "non_fixed_freq_weekly": ops.DatetimeToIntegerLabelOp( + freq=pd.tseries.offsets.Week(weekday=6), origin="start", closed="left" # type: ignore + ).as_expr("datetime_col", "timestamp_col"), + } + + sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) + snapshot.assert_match(sql, "out.sql") + + def test_floor_dt(scalar_types_df: bpd.DataFrame, snapshot): col_names = ["datetime_col", "timestamp_col", "date_col"] bf_df = scalar_types_df[col_names] From d2e8cb45441cf92afd47010c90a73567f07a9ad2 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:28:42 -0600 Subject: [PATCH 277/313] chore: fix pytest crash on pandas 3.0 due to missing SettingWithCopyWarning (#2314) Moved `ignore::pandas.errors.SettingWithCopyWarning` from `pytest.ini` to `conftest.py` with a check for its existence. This fixes `AttributeError: module 'pandas.errors' has no attribute 'SettingWithCopyWarning'` when running tests against pandas 3.0. --- *PR created automatically by Jules for task [1336882389279070030](https://jules.google.com/task/1336882389279070030) started by @tswast* Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- conftest.py | 7 +++++++ pytest.ini | 2 -- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/conftest.py b/conftest.py index bd2053b092..e0f059fa43 100644 --- a/conftest.py +++ b/conftest.py @@ -14,6 +14,8 @@ from __future__ import annotations +import warnings + import numpy as np import pandas as pd import pyarrow as pa @@ -21,6 +23,11 @@ import bigframes._config +# Make sure SettingWithCopyWarning is ignored if it exists. +# It was removed in pandas 3.0. +if hasattr(pd.errors, "SettingWithCopyWarning"): + warnings.simplefilter("ignore", pd.errors.SettingWithCopyWarning) + @pytest.fixture(scope="session") def polars_session_or_bpd(): diff --git a/pytest.ini b/pytest.ini index 75b69ce435..512fd81a7e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,3 @@ [pytest] doctest_optionflags = NORMALIZE_WHITESPACE -filterwarnings = - ignore::pandas.errors.SettingWithCopyWarning addopts = "--import-mode=importlib" From 147aad973523ee43c4ae76c5cb48804053a8221f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Tue, 9 Dec 2025 09:07:36 -0600 Subject: [PATCH 278/313] chore: fix unit tests on pandas 3.0 (#2317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🦕 --- bigframes/operations/datetimes.py | 2 +- tests/unit/test_local_data.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/bigframes/operations/datetimes.py b/bigframes/operations/datetimes.py index 1d79afd97f..c259dd018e 100644 --- a/bigframes/operations/datetimes.py +++ b/bigframes/operations/datetimes.py @@ -25,7 +25,7 @@ from bigframes.core import log_adapter import bigframes.operations as ops -_ONE_DAY = pandas.Timedelta("1d") +_ONE_DAY = pandas.Timedelta("1D") _ONE_SECOND = pandas.Timedelta("1s") _ONE_MICRO = pandas.Timedelta("1us") _SUPPORTED_FREQS = ("Y", "Q", "M", "W", "D", "h", "min", "s", "ms", "us") diff --git a/tests/unit/test_local_data.py b/tests/unit/test_local_data.py index 6f23036efb..1537c896fb 100644 --- a/tests/unit/test_local_data.py +++ b/tests/unit/test_local_data.py @@ -44,6 +44,12 @@ def test_local_data_well_formed_round_trip(): local_entry = local_data.ManagedArrowTable.from_pandas(pd_data) result = pd.DataFrame(local_entry.itertuples(), columns=pd_data.columns) + result = result.assign( + **{ + col: result[col].astype(pd_data_normalized[col].dtype) + for col in pd_data_normalized.columns + } + ) pandas.testing.assert_frame_equal(pd_data_normalized, result, check_dtype=False) @@ -118,6 +124,12 @@ def test_local_data_well_formed_round_trip_chunked(): as_rechunked_pyarrow = pa.Table.from_batches(pa_table.to_batches(max_chunksize=2)) local_entry = local_data.ManagedArrowTable.from_pyarrow(as_rechunked_pyarrow) result = pd.DataFrame(local_entry.itertuples(), columns=pd_data.columns) + result = result.assign( + **{ + col: result[col].astype(pd_data_normalized[col].dtype) + for col in pd_data_normalized.columns + } + ) pandas.testing.assert_frame_equal(pd_data_normalized, result, check_dtype=False) @@ -126,6 +138,12 @@ def test_local_data_well_formed_round_trip_sliced(): as_rechunked_pyarrow = pa.Table.from_batches(pa_table.slice(0, 4).to_batches()) local_entry = local_data.ManagedArrowTable.from_pyarrow(as_rechunked_pyarrow) result = pd.DataFrame(local_entry.itertuples(), columns=pd_data.columns) + result = result.assign( + **{ + col: result[col].astype(pd_data_normalized[col].dtype) + for col in pd_data_normalized.columns + } + ) pandas.testing.assert_frame_equal( pd_data_normalized[0:4].reset_index(drop=True), result.reset_index(drop=True), From d99383195ac3f1683842cfe472cca5a914b04d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Tue, 9 Dec 2025 12:50:41 -0600 Subject: [PATCH 279/313] fix: cache DataFrames to temp tables in bigframes.bigquery.ml methods to avoid time travel (#2318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See internal issue b/310266666 🦕 --- bigframes/bigquery/_operations/ml.py | 4 ++++ bigframes/ml/core.py | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/bigframes/bigquery/_operations/ml.py b/bigframes/bigquery/_operations/ml.py index 46c0663d81..073be0ef2b 100644 --- a/bigframes/bigquery/_operations/ml.py +++ b/bigframes/bigquery/_operations/ml.py @@ -39,6 +39,10 @@ def _to_sql(df_or_sql: Union[pd.DataFrame, dataframe.DataFrame, str]) -> str: else: bf_df = cast(dataframe.DataFrame, df_or_sql) + # Cache dataframes to make sure base table is not a snapshot. + # Cached dataframe creates a full copy, never uses snapshot. + # This is a workaround for internal issue b/310266666. + bf_df.cache() sql, _, _ = bf_df._to_sql_query(include_index=False) return sql diff --git a/bigframes/ml/core.py b/bigframes/ml/core.py index 28f795a0b6..4dbc1a5fa3 100644 --- a/bigframes/ml/core.py +++ b/bigframes/ml/core.py @@ -436,8 +436,9 @@ def create_model( Returns: a BqmlModel, wrapping a trained model in BigQuery """ options = dict(options) - # Cache dataframes to make sure base table is not a snapshot - # cached dataframe creates a full copy, never uses snapshot + # Cache dataframes to make sure base table is not a snapshot. + # Cached dataframe creates a full copy, never uses snapshot. + # This is a workaround for internal issue b/310266666. if y_train is None: input_data = X_train.reset_index(drop=True).cache() else: From e4e3ec85d0bd0524da1c7672b2efa004a9026da5 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Tue, 9 Dec 2025 12:00:11 -0800 Subject: [PATCH 280/313] revert: DataFrame display uses IPython's `_repr_mimebundle_` (#2316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit revert "refactor: Migrate DataFrame display to use IPython's _repr_mimebundle_() protocol for anywidget mode (#2271)" This reverts commit 41630b5f6b3cd91d700690aee40f342677903805 for bug 466155761. Verified at Colab: screen/AjTEQC8SrSfMqhN Fixes #< 466155761 > 🦕 --- bigframes/core/indexes/base.py | 4 +- bigframes/dataframe.py | 161 ++---- bigframes/streaming/dataframe.py | 8 +- notebooks/dataframes/anywidget_mode.ipynb | 589 ++-------------------- tests/system/small/test_anywidget.py | 141 ++---- tests/system/small/test_dataframe.py | 5 +- tests/system/small/test_ipython.py | 2 +- tests/system/small/test_progress_bar.py | 9 +- tests/unit/test_dataframe_polars.py | 5 +- 9 files changed, 128 insertions(+), 796 deletions(-) diff --git a/bigframes/core/indexes/base.py b/bigframes/core/indexes/base.py index 383534fa4d..2e52b5fa25 100644 --- a/bigframes/core/indexes/base.py +++ b/bigframes/core/indexes/base.py @@ -376,7 +376,9 @@ def __repr__(self) -> __builtins__.str: # metadata, like we do with DataFrame. opts = bigframes.options.display max_results = opts.max_rows - if opts.repr_mode == "deferred": + # anywdiget mode uses the same display logic as the "deferred" mode + # for faster execution + if opts.repr_mode in ("deferred", "anywidget"): _, dry_run_query_job = self._block._compute_dry_run() return formatter.repr_query_job(dry_run_query_job) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index b9143f2f19..b34c1cafd0 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -789,7 +789,9 @@ def __repr__(self) -> str: opts = bigframes.options.display max_results = opts.max_rows - if opts.repr_mode == "deferred": + # anywdiget mode uses the same display logic as the "deferred" mode + # for faster execution + if opts.repr_mode in ("deferred", "anywidget"): return formatter.repr_query_job(self._compute_dry_run()) # TODO(swast): pass max_columns and get the true column count back. Maybe @@ -827,138 +829,68 @@ def __repr__(self) -> str: lines.append(f"[{row_count} rows x {column_count} columns]") return "\n".join(lines) - def _get_display_df_and_blob_cols(self) -> tuple[DataFrame, list[str]]: - """Process blob columns for display.""" - df = self - blob_cols = [] + def _repr_html_(self) -> str: + """ + Returns an html string primarily for use by notebooks for displaying + a representation of the DataFrame. Displays 20 rows by default since + many notebooks are not configured for large tables. + """ + opts = bigframes.options.display + max_results = opts.max_rows + if opts.repr_mode == "deferred": + return formatter.repr_query_job(self._compute_dry_run()) + + # Process blob columns first, regardless of display mode + self._cached() + df = self.copy() if bigframes.options.display.blob_display: blob_cols = [ series_name - for series_name, series in self.items() + for series_name, series in df.items() if series.dtype == bigframes.dtypes.OBJ_REF_DTYPE ] - if blob_cols: - df = self.copy() - for col in blob_cols: - # TODO(garrettwu): Not necessary to get access urls for all the rows. Update when having a to get URLs from local data. - df[col] = df[col].blob._get_runtime(mode="R", with_metadata=True) - return df, blob_cols - - def _get_anywidget_bundle(self, include=None, exclude=None): - """ - Helper method to create and return the anywidget mimebundle. - This function encapsulates the logic for anywidget display. - """ - from bigframes import display - - # TODO(shuowei): Keep blob_cols and pass them to TableWidget so that they can render properly. - df, _ = self._get_display_df_and_blob_cols() - - # Create and display the widget - widget = display.TableWidget(df) - widget_repr_result = widget._repr_mimebundle_(include=include, exclude=exclude) - - # Handle both tuple (data, metadata) and dict returns - if isinstance(widget_repr_result, tuple): - widget_repr = dict(widget_repr_result[0]) # Extract data dict from tuple + for col in blob_cols: + # TODO(garrettwu): Not necessary to get access urls for all the rows. Update when having a to get URLs from local data. + df[col] = df[col].blob._get_runtime(mode="R", with_metadata=True) else: - widget_repr = dict(widget_repr_result) - - # At this point, we have already executed the query as part of the - # widget construction. Let's use the information available to render - # the HTML and plain text versions. - widget_repr["text/html"] = widget.table_html - - widget_repr["text/plain"] = self._create_text_representation( - widget._cached_data, widget.row_count - ) - - return widget_repr - - def _create_text_representation( - self, pandas_df: pandas.DataFrame, total_rows: typing.Optional[int] - ) -> str: - """Create a text representation of the DataFrame.""" - opts = bigframes.options.display - with display_options.pandas_repr(opts): - import pandas.io.formats - - # safe to mutate this, this dict is owned by this code, and does not affect global config - to_string_kwargs = ( - pandas.io.formats.format.get_dataframe_repr_params() # type: ignore - ) - if not self._has_index: - to_string_kwargs.update({"index": False}) + blob_cols = [] - # We add our own dimensions string, so don't want pandas to. - to_string_kwargs.update({"show_dimensions": False}) - repr_string = pandas_df.to_string(**to_string_kwargs) + if opts.repr_mode == "anywidget": + try: + from IPython.display import display as ipython_display - lines = repr_string.split("\n") + from bigframes import display - if total_rows is not None and total_rows > len(pandas_df): - lines.append("...") + # Always create a new widget instance for each display call + # This ensures that each cell gets its own widget and prevents + # unintended sharing between cells + widget = display.TableWidget(df.copy()) - lines.append("") - column_count = len(self.columns) - lines.append(f"[{total_rows or '?'} rows x {column_count} columns]") - return "\n".join(lines) + ipython_display(widget) + return "" # Return empty string since we used display() - def _repr_mimebundle_(self, include=None, exclude=None): - """ - Custom display method for IPython/Jupyter environments. - This is called by IPython's display system when the object is displayed. - """ - opts = bigframes.options.display - # Only handle widget display in anywidget mode - if opts.repr_mode == "anywidget": - try: - return self._get_anywidget_bundle(include=include, exclude=exclude) - - except ImportError: - # Anywidget is an optional dependency, so warn rather than fail. - # TODO(shuowei): When Anywidget becomes the default for all repr modes, - # remove this warning. + except (AttributeError, ValueError, ImportError): + # Fallback if anywidget is not available warnings.warn( "Anywidget mode is not available. " "Please `pip install anywidget traitlets` or `pip install 'bigframes[anywidget]'` to use interactive tables. " - f"Falling back to static HTML. Error: {traceback.format_exc()}" + f"Falling back to deferred mode. Error: {traceback.format_exc()}" ) + return formatter.repr_query_job(self._compute_dry_run()) - # In non-anywidget mode, fetch data once and use it for both HTML - # and plain text representations to avoid multiple queries. - opts = bigframes.options.display - max_results = opts.max_rows - - df, blob_cols = self._get_display_df_and_blob_cols() - + # Continue with regular HTML rendering for non-anywidget modes + # TODO(swast): pass max_columns and get the true column count back. Maybe + # get 1 more column than we have requested so that pandas can add the + # ... for us? pandas_df, row_count, query_job = df._block.retrieve_repr_request_results( max_results ) + self._set_internal_query_job(query_job) column_count = len(pandas_df.columns) - html_string = self._create_html_representation( - pandas_df, row_count, column_count, blob_cols - ) - - text_representation = self._create_text_representation(pandas_df, row_count) - - return {"text/html": html_string, "text/plain": text_representation} - - def _create_html_representation( - self, - pandas_df: pandas.DataFrame, - row_count: int, - column_count: int, - blob_cols: list[str], - ) -> str: - """Create an HTML representation of the DataFrame.""" - opts = bigframes.options.display with display_options.pandas_repr(opts): - # TODO(shuowei, b/464053870): Escaping HTML would be useful, but - # `escape=False` is needed to show images. We may need to implement - # a full-fledged repr module to better support types not in pandas. + # Allows to preview images in the DataFrame. The implementation changes the string repr as well, that it doesn't truncate strings or escape html charaters such as "<" and ">". We may need to implement a full-fledged repr module to better support types not in pandas. if bigframes.options.display.blob_display and blob_cols: def obj_ref_rt_to_html(obj_ref_rt) -> str: @@ -987,12 +919,15 @@ def obj_ref_rt_to_html(obj_ref_rt) -> str: # set max_colwidth so not to truncate the image url with pandas.option_context("display.max_colwidth", None): + max_rows = pandas.get_option("display.max_rows") + max_cols = pandas.get_option("display.max_columns") + show_dimensions = pandas.get_option("display.show_dimensions") html_string = pandas_df.to_html( escape=False, notebook=True, - max_rows=pandas.get_option("display.max_rows"), - max_cols=pandas.get_option("display.max_columns"), - show_dimensions=pandas.get_option("display.show_dimensions"), + max_rows=max_rows, + max_cols=max_cols, + show_dimensions=show_dimensions, formatters=formatters, # type: ignore ) else: diff --git a/bigframes/streaming/dataframe.py b/bigframes/streaming/dataframe.py index 3e030a4aa2..7dc9e964bc 100644 --- a/bigframes/streaming/dataframe.py +++ b/bigframes/streaming/dataframe.py @@ -291,13 +291,13 @@ def __repr__(self, *args, **kwargs): __repr__.__doc__ = _curate_df_doc(inspect.getdoc(dataframe.DataFrame.__repr__)) - def _repr_mimebundle_(self, *args, **kwargs): - return _return_type_wrapper(self._df._repr_mimebundle_, StreamingDataFrame)( + def _repr_html_(self, *args, **kwargs): + return _return_type_wrapper(self._df._repr_html_, StreamingDataFrame)( *args, **kwargs ) - _repr_mimebundle_.__doc__ = _curate_df_doc( - inspect.getdoc(dataframe.DataFrame._repr_mimebundle_) + _repr_html_.__doc__ = _curate_df_doc( + inspect.getdoc(dataframe.DataFrame._repr_html_) ) @property diff --git a/notebooks/dataframes/anywidget_mode.ipynb b/notebooks/dataframes/anywidget_mode.ipynb index 427a1e5371..fa324c246a 100644 --- a/notebooks/dataframes/anywidget_mode.ipynb +++ b/notebooks/dataframes/anywidget_mode.ipynb @@ -76,50 +76,11 @@ "id": "f289d250", "metadata": {}, "outputs": [ - { - "data": { - "text/html": [ - "✅ Completed. " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "✅ Completed. \n", - " Query processed 0 Bytes in a moment of slot time.\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "name": "stdout", "output_type": "stream", "text": [ - "state gender year name number\n", - " AL F 1910 Vera 71\n", - " AR F 1910 Viola 37\n", - " AR F 1910 Alice 57\n", - " AR F 1910 Edna 95\n", - " AR F 1910 Ollie 40\n", - " CA F 1910 Beatrice 37\n", - " CT F 1910 Marion 36\n", - " CT F 1910 Marie 36\n", - " FL F 1910 Alice 53\n", - " GA F 1910 Thelma 133\n", - "...\n", - "\n", - "[5552452 rows x 5 columns]\n" + "Computation deferred. Computation will process 171.4 MB\n" ] } ], @@ -196,210 +157,22 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "2aad385a8a2f411c822dafe7b07fbad8", + "model_id": "2935e3f8f4f34c558d588f09a9c42131", "version_major": 2, "version_minor": 1 }, - "text/html": [ - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
state
gender
year
name
number
\n", - " AL\n", - " \n", - " F\n", - " \n", - " 1910\n", - " \n", - " Cora\n", - " \n", - " 61\n", - "
\n", - " AL\n", - " \n", - " F\n", - " \n", - " 1910\n", - " \n", - " Anna\n", - " \n", - " 74\n", - "
\n", - " AR\n", - " \n", - " F\n", - " \n", - " 1910\n", - " \n", - " Willie\n", - " \n", - " 132\n", - "
\n", - " CO\n", - " \n", - " F\n", - " \n", - " 1910\n", - " \n", - " Anna\n", - " \n", - " 42\n", - "
\n", - " FL\n", - " \n", - " F\n", - " \n", - " 1910\n", - " \n", - " Louise\n", - " \n", - " 70\n", - "
\n", - " GA\n", - " \n", - " F\n", - " \n", - " 1910\n", - " \n", - " Catherine\n", - " \n", - " 57\n", - "
\n", - " IL\n", - " \n", - " F\n", - " \n", - " 1910\n", - " \n", - " Jessie\n", - " \n", - " 43\n", - "
\n", - " IN\n", - " \n", - " F\n", - " \n", - " 1910\n", - " \n", - " Anna\n", - " \n", - " 100\n", - "
\n", - " IN\n", - " \n", - " F\n", - " \n", - " 1910\n", - " \n", - " Pauline\n", - " \n", - " 77\n", - "
\n", - " IN\n", - " \n", - " F\n", - " \n", - " 1910\n", - " \n", - " Beulah\n", - " \n", - " 39\n", - "
" - ], "text/plain": [ - "state gender year name number\n", - " AL F 1910 Cora 61\n", - " AL F 1910 Anna 74\n", - " AR F 1910 Willie 132\n", - " CO F 1910 Anna 42\n", - " FL F 1910 Louise 70\n", - " GA F 1910 Catherine 57\n", - " IL F 1910 Jessie 43\n", - " IN F 1910 Anna 100\n", - " IN F 1910 Pauline 77\n", - " IN F 1910 Beulah 39\n", - "...\n", - "\n", - "[5552452 rows x 5 columns]" + "TableWidget(orderable_columns=['state', 'gender', 'year', 'name', 'number'], page_size=10, row_count=5552452, …" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [], + "text/plain": [ + "Computation deferred. Computation will process 171.4 MB" ] }, "execution_count": 6, @@ -482,7 +255,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "0c0b83e7e3c048ff8abb525e1bfd6c5f", + "model_id": "fa03d998dfee47638a32b6c21ace0b5c", "version_major": 2, "version_minor": 1 }, @@ -596,7 +369,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "6a60e5dd37c64e76a8e3804dd3531f70", + "model_id": "6d6886bd2bb74be996d54ce240cbe6c9", "version_major": 2, "version_minor": 1 }, @@ -628,7 +401,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 11, "id": "added-cell-1", "metadata": {}, "outputs": [ @@ -636,7 +409,7 @@ "data": { "text/html": [ "✅ Completed. \n", - " Query processed 85.9 kB in 14 seconds of slot time.\n", + " Query processed 85.9 kB in 24 seconds of slot time.\n", " " ], "text/plain": [ @@ -680,330 +453,28 @@ "metadata": {}, "output_type": "display_data" }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/bigframes/dtypes.py:987: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", - "instead of using `db_dtypes` in the future when available in pandas\n", - "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", - " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" - ] - }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "893065f8a0164648b241f2cc3d1a9271", + "model_id": "df774329fd2f47918b986362863d7155", "version_major": 2, "version_minor": 1 }, - "text/html": [ - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
result
gcs_path
issuer
language
publication_date
class_international
class_us
application_number
filing_date
priority_date_eu
representative_line_1_eu
applicant_line_1
inventor_line_1
title_line_1
number
\n", - " {'application_number': None, 'class_international': None, 'filing_date': None, 'publication_date': None, 'full_response': '{}', 'status': 'INVALID_ARGUMENT: Invalid field in objectref details, only a JSON object named gcs_metadata is allowed [type.googleapis.com/util.MessageSetPayload=\\'[dremel.DremelErrorWithDetails] { argument_error { query_error { } } debug_info { error_message_template: "Invalid field in objectref details, only a JSON object named $0 is allowed" error_id: 3270173750 } }\\']'}\n", - " \n", - " gs://gcs-public-data--labeled-patents/espacenet_de73.pdf\n", - " \n", - " EU\n", - " \n", - " DE\n", - " \n", - " 03.10.2018\n", - " \n", - " H05B 6/12\n", - " \n", - " <NA>\n", - " \n", - " 18165514.3\n", - " \n", - " 03.04.2018\n", - " \n", - " 30.03.2017\n", - " \n", - " <NA>\n", - " \n", - " BSH Hausger√§te GmbH\n", - " \n", - " Acero Acero, Jesus\n", - " \n", - " VORRICHTUNG ZUR INDUKTIVEN ENERGIE√úBERTRAGUNG\n", - " \n", - " EP 3 383 141 A2\n", - "
\n", - " {'application_number': None, 'class_international': None, 'filing_date': None, 'publication_date': None, 'full_response': '{}', 'status': 'INVALID_ARGUMENT: Invalid field in objectref details, only a JSON object named gcs_metadata is allowed [type.googleapis.com/util.MessageSetPayload=\\'[dremel.DremelErrorWithDetails] { argument_error { query_error { } } debug_info { error_message_template: "Invalid field in objectref details, only a JSON object named $0 is allowed" error_id: 3270173750 } }\\']'}\n", - " \n", - " gs://gcs-public-data--labeled-patents/espacenet_de2.pdf\n", - " \n", - " EU\n", - " \n", - " DE\n", - " \n", - " 29.08.018\n", - " \n", - " E04H 6/12\n", - " \n", - " <NA>\n", - " \n", - " 18157874.1\n", - " \n", - " 21.02.2018\n", - " \n", - " 22.02.2017\n", - " \n", - " Liedtke & Partner Patentanwälte\n", - " \n", - " SHB Hebezeugbau GmbH\n", - " \n", - " VOLGER, Alexander\n", - " \n", - " STEUERUNGSSYSTEM FÜR AUTOMATISCHE PARKHÄUSER\n", - " \n", - " EP 3 366 869 A1\n", - "
\n", - " {'application_number': None, 'class_international': None, 'filing_date': None, 'publication_date': None, 'full_response': '{}', 'status': 'INVALID_ARGUMENT: Invalid field in objectref details, only a JSON object named gcs_metadata is allowed [type.googleapis.com/util.MessageSetPayload=\\'[dremel.DremelErrorWithDetails] { argument_error { query_error { } } debug_info { error_message_template: "Invalid field in objectref details, only a JSON object named $0 is allowed" error_id: 3270173750 } }\\']'}\n", - " \n", - " gs://gcs-public-data--labeled-patents/espacenet_de70.pdf\n", - " \n", - " EU\n", - " \n", - " DE\n", - " \n", - " 03.10.2018\n", - " \n", - " H01L 21/20\n", - " \n", - " <NA>\n", - " \n", - " 18166536.5\n", - " \n", - " 16.02.2016\n", - " \n", - " <NA>\n", - " \n", - " Scheider, Sascha et al\n", - " \n", - " EV Group E. Thallner GmbH\n", - " \n", - " Kurz, Florian\n", - " \n", - " VORRICHTUNG ZUM BONDEN VON SUBSTRATEN\n", - " \n", - " EP 3 382 744 A1\n", - "
\n", - " {'application_number': None, 'class_international': None, 'filing_date': None, 'publication_date': None, 'full_response': '{}', 'status': 'INVALID_ARGUMENT: Invalid field in objectref details, only a JSON object named gcs_metadata is allowed [type.googleapis.com/util.MessageSetPayload=\\'[dremel.DremelErrorWithDetails] { argument_error { query_error { } } debug_info { error_message_template: "Invalid field in objectref details, only a JSON object named $0 is allowed" error_id: 3270173750 } }\\']'}\n", - " \n", - " gs://gcs-public-data--labeled-patents/espacenet_de5.pdf\n", - " \n", - " EU\n", - " \n", - " DE\n", - " \n", - " 03.10.2018\n", - " \n", - " G06F 11/30\n", - " \n", - " <NA>\n", - " \n", - " 18157347.8\n", - " \n", - " 19.02.2018\n", - " \n", - " 31.03.2017\n", - " \n", - " Hoffmann Eitle\n", - " \n", - " FUJITSU LIMITED\n", - " \n", - " Kukihara, Kensuke\n", - " \n", - " METHOD EXECUTED BY A COMPUTER, INFORMATION PROCESSING APPARATUS AND\n", - " \n", - " EP 3 382 553 A1\n", - "
\n", - " {'application_number': None, 'class_international': None, 'filing_date': None, 'publication_date': None, 'full_response': '{}', 'status': 'INVALID_ARGUMENT: Invalid field in objectref details, only a JSON object named gcs_metadata is allowed [type.googleapis.com/util.MessageSetPayload=\\'[dremel.DremelErrorWithDetails] { argument_error { query_error { } } debug_info { error_message_template: "Invalid field in objectref details, only a JSON object named $0 is allowed" error_id: 3270173750 } }\\']'}\n", - " \n", - " gs://gcs-public-data--labeled-patents/espacenet_de56.pdf\n", - " \n", - " EU\n", - " \n", - " DE\n", - " \n", - " 03.10.2018\n", - " \n", - " A01K 31/00\n", - " \n", - " <NA>\n", - " \n", - " 18171005.4\n", - " \n", - " 05.02.2015\n", - " \n", - " 05.02.2014\n", - " \n", - " Stork Bamberger Patentanwälte\n", - " \n", - " Linco Food Systems A/S\n", - " \n", - " Thrane, Uffe\n", - " \n", - " MASTHÄHNCHENCONTAINER ALS BESTANDTEIL EINER EINHEIT UND EINER ANORDNUNG\n", - " \n", - " EP 3 381 276 A1\n", - "
" - ], "text/plain": [ - " result \\\n", - "0 {'application_number': None, 'class_internatio... \n", - "1 {'application_number': None, 'class_internatio... \n", - "2 {'application_number': None, 'class_internatio... \n", - "3 {'application_number': None, 'class_internatio... \n", - "4 {'application_number': None, 'class_internatio... \n", - "\n", - " gcs_path issuer language \\\n", - "0 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", - "1 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", - "2 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", - "3 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", - "4 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", - "\n", - " publication_date class_international class_us application_number \\\n", - "0 03.10.2018 H05B 6/12 18165514.3 \n", - "1 29.08.018 E04H 6/12 18157874.1 \n", - "2 03.10.2018 H01L 21/20 18166536.5 \n", - "3 03.10.2018 G06F 11/30 18157347.8 \n", - "4 03.10.2018 A01K 31/00 18171005.4 \n", - "\n", - " filing_date priority_date_eu representative_line_1_eu \\\n", - "0 03.04.2018 30.03.2017 \n", - "1 21.02.2018 22.02.2017 Liedtke & Partner Patentanw√§lte \n", - "2 16.02.2016 Scheider, Sascha et al \n", - "3 19.02.2018 31.03.2017 Hoffmann Eitle \n", - "4 05.02.2015 05.02.2014 Stork Bamberger Patentanw√§lte \n", - "\n", - " applicant_line_1 inventor_line_1 \\\n", - "0 BSH Hausger√§te GmbH Acero Acero, Jesus \n", - "1 SHB Hebezeugbau GmbH VOLGER, Alexander \n", - "2 EV Group E. Thallner GmbH Kurz, Florian \n", - "3 FUJITSU LIMITED Kukihara, Kensuke \n", - "4 Linco Food Systems A/S Thrane, Uffe \n", - "\n", - " title_line_1 number \n", - "0 VORRICHTUNG ZUR INDUKTIVEN ENERGIE√úBERTRAGUNG EP 3 383 141 A2 \n", - "1 STEUERUNGSSYSTEM F√úR AUTOMATISCHE PARKH√ÑUSER EP 3 366 869 A1 \n", - "2 VORRICHTUNG ZUM BONDEN VON SUBSTRATEN EP 3 382 744 A1 \n", - "3 METHOD EXECUTED BY A COMPUTER, INFORMATION PRO... EP 3 382 553 A1 \n", - "4 MASTH√ÑHNCHENCONTAINER ALS BESTANDTEIL EINER E... EP 3 381 276 A1 \n", - "\n", - "[5 rows x 15 columns]" + "TableWidget(orderable_columns=['gcs_path', 'issuer', 'language', 'publication_date', 'class_international', 'c…" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [], + "text/plain": [ + "Computation deferred. Computation will process 0 Bytes" ] }, - "execution_count": 10, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } diff --git a/tests/system/small/test_anywidget.py b/tests/system/small/test_anywidget.py index c8740ed220..49d5ff6c92 100644 --- a/tests/system/small/test_anywidget.py +++ b/tests/system/small/test_anywidget.py @@ -37,12 +37,12 @@ def paginated_pandas_df() -> pd.DataFrame: { "id": [5, 4, 3, 2, 1, 0], "page_indicator": [ - "page_3_row_2", - "page_3_row_1", - "page_2_row_2", - "page_2_row_1", - "page_1_row_2", - "page_1_row_1", + "row_5", + "row_4", + "row_3", + "row_2", + "row_1", + "row_0", ], "value": [5, 4, 3, 2, 1, 0], } @@ -205,12 +205,11 @@ def test_widget_initialization_should_calculate_total_row_count( assert widget.row_count == EXPECTED_ROW_COUNT -def test_widget_initialization_should_default_to_page_zero( +def test_widget_initialization_should_set_default_pagination( table_widget, ): """ - Given a new TableWidget, when it is initialized, - then its page number should default to 0. + A TableWidget should initialize with page 0 and the correct page size. """ # The `table_widget` fixture already creates the widget. # Assert its state. @@ -260,8 +259,8 @@ def test_widget_navigation_should_display_correct_page( _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) -def test_setting_negative_page_should_raise_error( - table_widget, +def test_widget_navigation_should_raise_error_for_negative_input( + table_widget, paginated_pandas_df: pd.DataFrame ): """ Given a widget, when a negative page number is set, @@ -271,20 +270,19 @@ def test_setting_negative_page_should_raise_error( table_widget.page = -1 -def test_setting_page_beyond_max_should_clamp_to_last_page( +def test_widget_navigation_should_clamp_to_last_page_for_out_of_bounds_input( table_widget, paginated_pandas_df: pd.DataFrame ): """ - Given a widget, - when a page number greater than the max is set, + Given a widget, when a page number greater than the max is set, then the page number should be clamped to the last valid page. """ - expected_slice = paginated_pandas_df.iloc[4:6] # Last page data + expected_slice = paginated_pandas_df.iloc[4:6] - table_widget.page = 100 # Set page far beyond the total of 3 pages + table_widget.page = 100 html = table_widget.table_html - assert table_widget.page == 2 # Page is clamped to the last valid page (0-indexed) + assert table_widget.page == 2 _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) @@ -334,11 +332,11 @@ def test_widget_with_few_rows_should_display_all_rows(small_widget, small_pandas _assert_html_matches_pandas_slice(html, small_pandas_df, small_pandas_df) -def test_navigation_beyond_last_page_should_be_clamped(small_widget): +def test_widget_with_few_rows_should_have_only_one_page(small_widget): """ - Given a DataFrame smaller than the page size, - when navigating beyond the last page, - then the page should be clamped to the last valid page (page 0). + Given a DataFrame with a small number of rows, the widget should + report the correct total row count and prevent navigation beyond + the first page, ensuring the frontend correctly displays "Page 1 of 1". """ # For a DataFrame with 2 rows and page_size 5 (from small_widget fixture), # the frontend should calculate 1 total page. @@ -353,65 +351,43 @@ def test_navigation_beyond_last_page_should_be_clamped(small_widget): assert small_widget.page == 0 -def test_global_options_change_should_not_affect_existing_widget_page_size( +def test_widget_page_size_should_be_immutable_after_creation( paginated_bf_df: bf.dataframe.DataFrame, ): """ - Given an existing widget, - when global display options are changed, - then the widget's page size should remain unchanged. + A widget's page size should be fixed on creation and not be affected + by subsequent changes to global options. """ with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): from bigframes.display import TableWidget widget = TableWidget(paginated_bf_df) assert widget.page_size == 2 - widget.page = 1 # a non-default state + + # Navigate to second page to ensure widget is in a non-default state + widget.page = 1 assert widget.page == 1 - bf.options.display.max_rows = 10 # Change global setting + # Change global max_rows - widget should not be affected + bf.options.display.max_rows = 10 - assert widget.page_size == 2 # Should remain unchanged - assert widget.page == 1 # Page should not be reset + assert widget.page_size == 2 # Should remain unchanged + assert widget.page == 1 # Should remain on same page -def test_widget_with_empty_dataframe_should_have_zero_row_count( - empty_bf_df: bf.dataframe.DataFrame, -): - """ - Given an empty DataFrame, - when a widget is created from it, - then its row_count should be 0. - """ - +def test_empty_widget_should_have_zero_row_count(empty_bf_df: bf.dataframe.DataFrame): + """Given an empty DataFrame, the widget's row count should be 0.""" with bf.option_context("display.repr_mode", "anywidget"): from bigframes.display import TableWidget widget = TableWidget(empty_bf_df) - assert widget.row_count == 0 - - -def test_widget_with_empty_dataframe_should_render_table_headers( - empty_bf_df: bf.dataframe.DataFrame, -): + assert widget.row_count == 0 - """ - - - Given an empty DataFrame, - - - when a widget is created from it, - - - then its HTML representation should still render the table headers. - - - """ +def test_empty_widget_should_render_table_headers(empty_bf_df: bf.dataframe.DataFrame): + """Given an empty DataFrame, the widget should still render table headers.""" with bf.option_context("display.repr_mode", "anywidget"): - from bigframes.display import TableWidget widget = TableWidget(empty_bf_df) @@ -419,8 +395,7 @@ def test_widget_with_empty_dataframe_should_render_table_headers( html = widget.table_html assert "= 1 - assert test_df._block.retrieve_repr_request_results.cache_info().hits == 0 + assert test_df._block.retrieve_repr_request_results.cache_info().hits >= 1 diff --git a/tests/system/small/test_progress_bar.py b/tests/system/small/test_progress_bar.py index d726bfde2c..0c9c4070f4 100644 --- a/tests/system/small/test_progress_bar.py +++ b/tests/system/small/test_progress_bar.py @@ -153,9 +153,7 @@ def test_repr_anywidget_dataframe(penguins_df_default_index: bf.dataframe.DataFr pytest.importorskip("anywidget") with bf.option_context("display.repr_mode", "anywidget"): actual_repr = repr(penguins_df_default_index) - assert "species" in actual_repr - assert "island" in actual_repr - assert "[344 rows x 7 columns]" in actual_repr + assert EXPECTED_DRY_RUN_MESSAGE in actual_repr def test_repr_anywidget_index(penguins_df_default_index: bf.dataframe.DataFrame): @@ -163,7 +161,4 @@ def test_repr_anywidget_index(penguins_df_default_index: bf.dataframe.DataFrame) with bf.option_context("display.repr_mode", "anywidget"): index = penguins_df_default_index.index actual_repr = repr(index) - # In non-interactive environments, should still get a useful summary. - assert "Index" in actual_repr - assert "0, 1, 2, 3, 4" in actual_repr - assert "dtype='Int64'" in actual_repr + assert EXPECTED_DRY_RUN_MESSAGE in actual_repr diff --git a/tests/unit/test_dataframe_polars.py b/tests/unit/test_dataframe_polars.py index 39dbacd087..b83380d789 100644 --- a/tests/unit/test_dataframe_polars.py +++ b/tests/unit/test_dataframe_polars.py @@ -737,7 +737,7 @@ def test_join_repr(scalars_dfs): assert actual == expected -def test_mimebundle_html_repr_w_all_rows(scalars_dfs, session): +def test_repr_html_w_all_rows(scalars_dfs, session): scalars_df, _ = scalars_dfs # get a pandas df of the expected format df, _ = scalars_df._block.to_pandas() @@ -745,8 +745,7 @@ def test_mimebundle_html_repr_w_all_rows(scalars_dfs, session): pandas_df.index.name = scalars_df.index.name # When there are 10 or fewer rows, the outputs should be identical except for the extra note. - bundle = scalars_df.head(10)._repr_mimebundle_() - actual = bundle["text/html"] + actual = scalars_df.head(10)._repr_html_() with display_options.pandas_repr(bigframes.options.display): pandas_repr = pandas_df.head(10)._repr_html_() From 29bf680527e9dc59b33d83d18f67e6a178db6a5a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:30:39 -0600 Subject: [PATCH 281/313] chore: Move long-running LLM tests to the large directory (#2323) Moved long-running ML tests to the large directory to improve test suite organization and execution time management. This involves creating new test files in `tests/system/large/ml/` and appending to existing ones, while removing the corresponding tests from `tests/system/small/ml/`. --- *PR created automatically by Jules for task [11110816135219701367](https://jules.google.com/task/11110816135219701367) started by @tswast* --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- tests/system/large/ml/test_linear_model.py | 36 +++ tests/system/large/ml/test_llm.py | 233 +++++++++++++++++++ tests/system/large/ml/test_multimodal_llm.py | 45 ++++ tests/system/small/ml/test_linear_model.py | 36 --- tests/system/small/ml/test_llm.py | 213 ----------------- tests/system/small/ml/test_multimodal_llm.py | 26 --- 6 files changed, 314 insertions(+), 275 deletions(-) create mode 100644 tests/system/large/ml/test_llm.py create mode 100644 tests/system/large/ml/test_multimodal_llm.py diff --git a/tests/system/large/ml/test_linear_model.py b/tests/system/large/ml/test_linear_model.py index f0e2892ba8..a70d214b7f 100644 --- a/tests/system/large/ml/test_linear_model.py +++ b/tests/system/large/ml/test_linear_model.py @@ -452,3 +452,39 @@ def test_model_centroids_with_custom_index(penguins_df_default_index): # If this line executes without errors, the model has correctly ignored the custom index columns model.predict(X_train.reset_index(drop=True)) + + +def test_linear_reg_model_global_explain( + penguins_linear_model_w_global_explain, new_penguins_df +): + training_data = new_penguins_df.dropna(subset=["body_mass_g"]) + X = training_data.drop(columns=["body_mass_g"]) + y = training_data[["body_mass_g"]] + penguins_linear_model_w_global_explain.fit(X, y) + global_ex = penguins_linear_model_w_global_explain.global_explain() + assert global_ex.shape == (6, 1) + expected_columns = pd.Index(["attribution"]) + pd.testing.assert_index_equal(global_ex.columns, expected_columns) + result = global_ex.to_pandas().drop(["attribution"], axis=1).sort_index() + expected_feature = ( + pd.DataFrame( + { + "feature": [ + "island", + "species", + "sex", + "flipper_length_mm", + "culmen_depth_mm", + "culmen_length_mm", + ] + }, + ) + .set_index("feature") + .sort_index() + ) + pd.testing.assert_frame_equal( + result, + expected_feature, + check_exact=False, + check_index_type=False, + ) diff --git a/tests/system/large/ml/test_llm.py b/tests/system/large/ml/test_llm.py new file mode 100644 index 0000000000..1daaebb8cb --- /dev/null +++ b/tests/system/large/ml/test_llm.py @@ -0,0 +1,233 @@ +# Copyright 2024 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pandas as pd +import pyarrow as pa +import pytest + +from bigframes.ml import llm +import bigframes.pandas as bpd +from bigframes.testing import utils + + +@pytest.mark.parametrize( + "model_name", + ( + "gemini-2.0-flash-exp", + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ), +) +@pytest.mark.flaky( + retries=2 +) # usually create model shouldn't be flaky, but this one due to the limited quota of gemini-2.0-flash-exp. +def test_create_load_gemini_text_generator_model( + dataset_id, model_name, session, bq_connection +): + gemini_text_generator_model = llm.GeminiTextGenerator( + model_name=model_name, connection_name=bq_connection, session=session + ) + assert gemini_text_generator_model is not None + assert gemini_text_generator_model._bqml_model is not None + + # save, load to ensure configuration was kept + reloaded_model = gemini_text_generator_model.to_gbq( + f"{dataset_id}.temp_text_model", replace=True + ) + assert f"{dataset_id}.temp_text_model" == reloaded_model._bqml_model.model_name + assert reloaded_model.connection_name == bq_connection + assert reloaded_model.model_name == model_name + + +@pytest.mark.parametrize( + "model_name", + ( + "gemini-2.0-flash-exp", + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ), +) +# @pytest.mark.flaky(retries=2) +def test_gemini_text_generator_predict_default_params_success( + llm_text_df, model_name, session, bq_connection +): + gemini_text_generator_model = llm.GeminiTextGenerator( + model_name=model_name, connection_name=bq_connection, session=session + ) + df = gemini_text_generator_model.predict(llm_text_df).to_pandas() + utils.check_pandas_df_schema_and_index( + df, columns=utils.ML_GENERATE_TEXT_OUTPUT, index=3, col_exact=False + ) + + +@pytest.mark.parametrize( + "model_name", + ( + "gemini-2.0-flash-exp", + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ), +) +@pytest.mark.flaky(retries=2) +def test_gemini_text_generator_predict_with_params_success( + llm_text_df, model_name, session, bq_connection +): + gemini_text_generator_model = llm.GeminiTextGenerator( + model_name=model_name, connection_name=bq_connection, session=session + ) + df = gemini_text_generator_model.predict( + llm_text_df, temperature=0.5, max_output_tokens=100, top_k=20, top_p=0.5 + ).to_pandas() + utils.check_pandas_df_schema_and_index( + df, columns=utils.ML_GENERATE_TEXT_OUTPUT, index=3, col_exact=False + ) + + +@pytest.mark.parametrize( + "model_name", + ( + "gemini-2.0-flash-exp", + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ), +) +@pytest.mark.flaky(retries=2) +def test_gemini_text_generator_multi_cols_predict_success( + llm_text_df: bpd.DataFrame, model_name, session, bq_connection +): + df = llm_text_df.assign(additional_col=1) + gemini_text_generator_model = llm.GeminiTextGenerator( + model_name=model_name, connection_name=bq_connection, session=session + ) + pd_df = gemini_text_generator_model.predict(df).to_pandas() + utils.check_pandas_df_schema_and_index( + pd_df, + columns=utils.ML_GENERATE_TEXT_OUTPUT + ["additional_col"], + index=3, + col_exact=False, + ) + + +@pytest.mark.parametrize( + "model_name", + ( + "gemini-2.0-flash-exp", + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ), +) +@pytest.mark.flaky(retries=2) +def test_gemini_text_generator_predict_output_schema_success( + llm_text_df: bpd.DataFrame, model_name, session, bq_connection +): + gemini_text_generator_model = llm.GeminiTextGenerator( + model_name=model_name, connection_name=bq_connection, session=session + ) + output_schema = { + "bool_output": "bool", + "int_output": "int64", + "float_output": "float64", + "str_output": "string", + "array_output": "array", + "struct_output": "struct", + } + df = gemini_text_generator_model.predict(llm_text_df, output_schema=output_schema) + assert df["bool_output"].dtype == pd.BooleanDtype() + assert df["int_output"].dtype == pd.Int64Dtype() + assert df["float_output"].dtype == pd.Float64Dtype() + assert df["str_output"].dtype == pd.StringDtype(storage="pyarrow") + assert df["array_output"].dtype == pd.ArrowDtype(pa.list_(pa.int64())) + assert df["struct_output"].dtype == pd.ArrowDtype( + pa.struct([("number", pa.int64())]) + ) + + pd_df = df.to_pandas() + utils.check_pandas_df_schema_and_index( + pd_df, + columns=list(output_schema.keys()) + ["prompt", "full_response", "status"], + index=3, + col_exact=False, + ) + + +@pytest.mark.flaky(retries=2) +@pytest.mark.parametrize( + "model_name", + ( + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + ), +) +def test_llm_gemini_score(llm_fine_tune_df_default_index, model_name): + model = llm.GeminiTextGenerator(model_name=model_name) + + # Check score to ensure the model was fitted + score_result = model.score( + X=llm_fine_tune_df_default_index[["prompt"]], + y=llm_fine_tune_df_default_index[["label"]], + ).to_pandas() + utils.check_pandas_df_schema_and_index( + score_result, + columns=[ + "bleu4_score", + "rouge-l_precision", + "rouge-l_recall", + "rouge-l_f1_score", + "evaluation_status", + ], + index=1, + ) + + +@pytest.mark.parametrize( + "model_name", + ( + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + ), +) +def test_llm_gemini_pro_score_params(llm_fine_tune_df_default_index, model_name): + model = llm.GeminiTextGenerator(model_name=model_name) + + # Check score to ensure the model was fitted + score_result = model.score( + X=llm_fine_tune_df_default_index["prompt"], + y=llm_fine_tune_df_default_index["label"], + task_type="classification", + ).to_pandas() + utils.check_pandas_df_schema_and_index( + score_result, + columns=[ + "precision", + "recall", + "f1_score", + "label", + "evaluation_status", + ], + ) diff --git a/tests/system/large/ml/test_multimodal_llm.py b/tests/system/large/ml/test_multimodal_llm.py new file mode 100644 index 0000000000..03fdddf665 --- /dev/null +++ b/tests/system/large/ml/test_multimodal_llm.py @@ -0,0 +1,45 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from bigframes.ml import llm +import bigframes.pandas as bpd +from bigframes.testing import utils + + +@pytest.mark.parametrize( + "model_name", + ( + "gemini-2.0-flash-exp", + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", + ), +) +@pytest.mark.flaky(retries=2) +def test_gemini_text_generator_multimodal_input( + images_mm_df: bpd.DataFrame, model_name, session, bq_connection +): + gemini_text_generator_model = llm.GeminiTextGenerator( + model_name=model_name, connection_name=bq_connection, session=session + ) + pd_df = gemini_text_generator_model.predict( + images_mm_df, prompt=["Describe", images_mm_df["blob_col"]] + ).to_pandas() + utils.check_pandas_df_schema_and_index( + pd_df, + columns=utils.ML_GENERATE_TEXT_OUTPUT + ["blob_col"], + index=2, + col_exact=False, + ) diff --git a/tests/system/small/ml/test_linear_model.py b/tests/system/small/ml/test_linear_model.py index 8b04d55e61..da9fc8e14f 100644 --- a/tests/system/small/ml/test_linear_model.py +++ b/tests/system/small/ml/test_linear_model.py @@ -228,42 +228,6 @@ def test_to_gbq_saved_linear_reg_model_scores( ) -def test_linear_reg_model_global_explain( - penguins_linear_model_w_global_explain, new_penguins_df -): - training_data = new_penguins_df.dropna(subset=["body_mass_g"]) - X = training_data.drop(columns=["body_mass_g"]) - y = training_data[["body_mass_g"]] - penguins_linear_model_w_global_explain.fit(X, y) - global_ex = penguins_linear_model_w_global_explain.global_explain() - assert global_ex.shape == (6, 1) - expected_columns = pandas.Index(["attribution"]) - pandas.testing.assert_index_equal(global_ex.columns, expected_columns) - result = global_ex.to_pandas().drop(["attribution"], axis=1).sort_index() - expected_feature = ( - pandas.DataFrame( - { - "feature": [ - "island", - "species", - "sex", - "flipper_length_mm", - "culmen_depth_mm", - "culmen_length_mm", - ] - }, - ) - .set_index("feature") - .sort_index() - ) - pandas.testing.assert_frame_equal( - result, - expected_feature, - check_exact=False, - check_index_type=False, - ) - - def test_to_gbq_replace(penguins_linear_model, table_id_unique): penguins_linear_model.to_gbq(table_id_unique, replace=True) with pytest.raises(google.api_core.exceptions.Conflict): diff --git a/tests/system/small/ml/test_llm.py b/tests/system/small/ml/test_llm.py index 112acb7cac..d15c5d3160 100644 --- a/tests/system/small/ml/test_llm.py +++ b/tests/system/small/ml/test_llm.py @@ -16,7 +16,6 @@ from unittest import mock import pandas as pd -import pyarrow as pa import pytest from bigframes import exceptions @@ -105,161 +104,6 @@ def test_create_load_multimodal_embedding_generator_model( assert reloaded_model.connection_name == bq_connection -@pytest.mark.parametrize( - "model_name", - ( - "gemini-2.0-flash-exp", - "gemini-2.0-flash-001", - "gemini-2.0-flash-lite-001", - "gemini-2.5-pro", - "gemini-2.5-flash", - "gemini-2.5-flash-lite", - ), -) -@pytest.mark.flaky( - retries=2 -) # usually create model shouldn't be flaky, but this one due to the limited quota of gemini-2.0-flash-exp. -def test_create_load_gemini_text_generator_model( - dataset_id, model_name, session, bq_connection -): - gemini_text_generator_model = llm.GeminiTextGenerator( - model_name=model_name, connection_name=bq_connection, session=session - ) - assert gemini_text_generator_model is not None - assert gemini_text_generator_model._bqml_model is not None - - # save, load to ensure configuration was kept - reloaded_model = gemini_text_generator_model.to_gbq( - f"{dataset_id}.temp_text_model", replace=True - ) - assert f"{dataset_id}.temp_text_model" == reloaded_model._bqml_model.model_name - assert reloaded_model.connection_name == bq_connection - assert reloaded_model.model_name == model_name - - -@pytest.mark.parametrize( - "model_name", - ( - "gemini-2.0-flash-exp", - "gemini-2.0-flash-001", - "gemini-2.0-flash-lite-001", - "gemini-2.5-pro", - "gemini-2.5-flash", - "gemini-2.5-flash-lite", - ), -) -# @pytest.mark.flaky(retries=2) -def test_gemini_text_generator_predict_default_params_success( - llm_text_df, model_name, session, bq_connection -): - gemini_text_generator_model = llm.GeminiTextGenerator( - model_name=model_name, connection_name=bq_connection, session=session - ) - df = gemini_text_generator_model.predict(llm_text_df).to_pandas() - utils.check_pandas_df_schema_and_index( - df, columns=utils.ML_GENERATE_TEXT_OUTPUT, index=3, col_exact=False - ) - - -@pytest.mark.parametrize( - "model_name", - ( - "gemini-2.0-flash-exp", - "gemini-2.0-flash-001", - "gemini-2.0-flash-lite-001", - "gemini-2.5-pro", - "gemini-2.5-flash", - "gemini-2.5-flash-lite", - ), -) -@pytest.mark.flaky(retries=2) -def test_gemini_text_generator_predict_with_params_success( - llm_text_df, model_name, session, bq_connection -): - gemini_text_generator_model = llm.GeminiTextGenerator( - model_name=model_name, connection_name=bq_connection, session=session - ) - df = gemini_text_generator_model.predict( - llm_text_df, temperature=0.5, max_output_tokens=100, top_k=20, top_p=0.5 - ).to_pandas() - utils.check_pandas_df_schema_and_index( - df, columns=utils.ML_GENERATE_TEXT_OUTPUT, index=3, col_exact=False - ) - - -@pytest.mark.parametrize( - "model_name", - ( - "gemini-2.0-flash-exp", - "gemini-2.0-flash-001", - "gemini-2.0-flash-lite-001", - "gemini-2.5-pro", - "gemini-2.5-flash", - "gemini-2.5-flash-lite", - ), -) -@pytest.mark.flaky(retries=2) -def test_gemini_text_generator_multi_cols_predict_success( - llm_text_df: bpd.DataFrame, model_name, session, bq_connection -): - df = llm_text_df.assign(additional_col=1) - gemini_text_generator_model = llm.GeminiTextGenerator( - model_name=model_name, connection_name=bq_connection, session=session - ) - pd_df = gemini_text_generator_model.predict(df).to_pandas() - utils.check_pandas_df_schema_and_index( - pd_df, - columns=utils.ML_GENERATE_TEXT_OUTPUT + ["additional_col"], - index=3, - col_exact=False, - ) - - -@pytest.mark.parametrize( - "model_name", - ( - "gemini-2.0-flash-exp", - "gemini-2.0-flash-001", - "gemini-2.0-flash-lite-001", - "gemini-2.5-pro", - "gemini-2.5-flash", - "gemini-2.5-flash-lite", - ), -) -@pytest.mark.flaky(retries=2) -def test_gemini_text_generator_predict_output_schema_success( - llm_text_df: bpd.DataFrame, model_name, session, bq_connection -): - gemini_text_generator_model = llm.GeminiTextGenerator( - model_name=model_name, connection_name=bq_connection, session=session - ) - output_schema = { - "bool_output": "bool", - "int_output": "int64", - "float_output": "float64", - "str_output": "string", - "array_output": "array", - "struct_output": "struct", - } - df = gemini_text_generator_model.predict(llm_text_df, output_schema=output_schema) - assert df["bool_output"].dtype == pd.BooleanDtype() - assert df["int_output"].dtype == pd.Int64Dtype() - assert df["float_output"].dtype == pd.Float64Dtype() - assert df["str_output"].dtype == pd.StringDtype(storage="pyarrow") - assert df["array_output"].dtype == pd.ArrowDtype(pa.list_(pa.int64())) - assert df["struct_output"].dtype == pd.ArrowDtype( - pa.struct([("number", pa.int64())]) - ) - - pd_df = df.to_pandas() - utils.check_pandas_df_schema_and_index( - pd_df, - columns=list(output_schema.keys()) + ["prompt", "full_response", "status"], - index=3, - col_exact=False, - ) - - # Overrides __eq__ function for comparing as mock.call parameter class EqCmpAllDataFrame(bpd.DataFrame): def __eq__(self, other): @@ -742,63 +586,6 @@ def test_text_embedding_generator_retry_no_progress(session, bq_connection): ) -@pytest.mark.flaky(retries=2) -@pytest.mark.parametrize( - "model_name", - ( - "gemini-2.0-flash-001", - "gemini-2.0-flash-lite-001", - ), -) -def test_llm_gemini_score(llm_fine_tune_df_default_index, model_name): - model = llm.GeminiTextGenerator(model_name=model_name) - - # Check score to ensure the model was fitted - score_result = model.score( - X=llm_fine_tune_df_default_index[["prompt"]], - y=llm_fine_tune_df_default_index[["label"]], - ).to_pandas() - utils.check_pandas_df_schema_and_index( - score_result, - columns=[ - "bleu4_score", - "rouge-l_precision", - "rouge-l_recall", - "rouge-l_f1_score", - "evaluation_status", - ], - index=1, - ) - - -@pytest.mark.parametrize( - "model_name", - ( - "gemini-2.0-flash-001", - "gemini-2.0-flash-lite-001", - ), -) -def test_llm_gemini_pro_score_params(llm_fine_tune_df_default_index, model_name): - model = llm.GeminiTextGenerator(model_name=model_name) - - # Check score to ensure the model was fitted - score_result = model.score( - X=llm_fine_tune_df_default_index["prompt"], - y=llm_fine_tune_df_default_index["label"], - task_type="classification", - ).to_pandas() - utils.check_pandas_df_schema_and_index( - score_result, - columns=[ - "precision", - "recall", - "f1_score", - "label", - "evaluation_status", - ], - ) - - @pytest.mark.parametrize( "model_name", ("gemini-2.0-flash-exp",), diff --git a/tests/system/small/ml/test_multimodal_llm.py b/tests/system/small/ml/test_multimodal_llm.py index 48a69f522c..e29669afd3 100644 --- a/tests/system/small/ml/test_multimodal_llm.py +++ b/tests/system/small/ml/test_multimodal_llm.py @@ -38,32 +38,6 @@ def test_multimodal_embedding_generator_predict_default_params_success( assert len(df["ml_generate_embedding_result"][0]) == 1408 -@pytest.mark.parametrize( - "model_name", - ( - "gemini-2.0-flash-exp", - "gemini-2.0-flash-001", - "gemini-2.0-flash-lite-001", - ), -) -@pytest.mark.flaky(retries=2) -def test_gemini_text_generator_multimodal_input( - images_mm_df: bpd.DataFrame, model_name, session, bq_connection -): - gemini_text_generator_model = llm.GeminiTextGenerator( - model_name=model_name, connection_name=bq_connection, session=session - ) - pd_df = gemini_text_generator_model.predict( - images_mm_df, prompt=["Describe", images_mm_df["blob_col"]] - ).to_pandas() - utils.check_pandas_df_schema_and_index( - pd_df, - columns=utils.ML_GENERATE_TEXT_OUTPUT + ["blob_col"], - index=2, - col_exact=False, - ) - - @pytest.mark.parametrize( "model_name", ( From fd5eae0957ef40d12721139f465edd8bd94dd489 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 11 Dec 2025 07:17:42 -0800 Subject: [PATCH 282/313] chore: librarian release pull request: 20251210T234907Z (#2324) PR created by the Librarian CLI to initialize a release. Merging this PR will auto trigger a release. Librarian Version: v0.7.0 Language Image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator@sha256:c8612d3fffb3f6a32353b2d1abd16b61e87811866f7ec9d65b59b02eb452a620
bigframes: 2.31.0 ## [2.31.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.30.0...v2.31.0) (2025-12-10) ### Features * add `bigframes.bigquery.ml` methods (#2300) ([719b278c](https://github.com/googleapis/python-bigquery-dataframes/commit/719b278c)) * add 'weekday' property to DatatimeMethod (#2304) ([fafd7c73](https://github.com/googleapis/python-bigquery-dataframes/commit/fafd7c73)) ### Bug Fixes * cache DataFrames to temp tables in bigframes.bigquery.ml methods to avoid time travel (#2318) ([d9938319](https://github.com/googleapis/python-bigquery-dataframes/commit/d9938319)) ### Reverts * DataFrame display uses IPython's `_repr_mimebundle_` (#2316) ([e4e3ec85](https://github.com/googleapis/python-bigquery-dataframes/commit/e4e3ec85))
--- .librarian/state.yaml | 2 +- CHANGELOG.md | 13 +++++++++++++ bigframes/version.py | 4 ++-- third_party/bigframes_vendored/version.py | 4 ++-- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.librarian/state.yaml b/.librarian/state.yaml index 009fed869a..99fac71a63 100644 --- a/.librarian/state.yaml +++ b/.librarian/state.yaml @@ -1,7 +1,7 @@ image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator@sha256:c8612d3fffb3f6a32353b2d1abd16b61e87811866f7ec9d65b59b02eb452a620 libraries: - id: bigframes - version: 2.30.0 + version: 2.31.0 last_generated_commit: "" apis: [] source_roots: diff --git a/CHANGELOG.md b/CHANGELOG.md index 13f8209826..6867151bab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ [1]: https://pypi.org/project/bigframes/#history +## [2.31.0](https://github.com/googleapis/google-cloud-python/compare/bigframes-v2.30.0...bigframes-v2.31.0) (2025-12-10) + + +### Features + +* add `bigframes.bigquery.ml` methods (#2300) ([719b278c844ca80c1bec741873b30a9ee4fd6c56](https://github.com/googleapis/google-cloud-python/commit/719b278c844ca80c1bec741873b30a9ee4fd6c56)) +* add 'weekday' property to DatatimeMethod (#2304) ([fafd7c732d434eca3f8b5d849a87149f106e3d5d](https://github.com/googleapis/google-cloud-python/commit/fafd7c732d434eca3f8b5d849a87149f106e3d5d)) + + +### Bug Fixes + +* cache DataFrames to temp tables in bigframes.bigquery.ml methods to avoid time travel (#2318) ([d99383195ac3f1683842cfe472cca5a914b04d8e](https://github.com/googleapis/google-cloud-python/commit/d99383195ac3f1683842cfe472cca5a914b04d8e)) + ## [2.30.0](https://github.com/googleapis/google-cloud-python/compare/bigframes-v2.29.0...bigframes-v2.30.0) (2025-12-03) diff --git a/bigframes/version.py b/bigframes/version.py index f7a5eb09dc..230dc343ac 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.30.0" +__version__ = "2.31.0" # {x-release-please-start-date} -__release_date__ = "2025-12-03" +__release_date__ = "2025-12-10" # {x-release-please-end} diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index f7a5eb09dc..230dc343ac 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.30.0" +__version__ = "2.31.0" # {x-release-please-start-date} -__release_date__ = "2025-12-03" +__release_date__ = "2025-12-10" # {x-release-please-end} From 83da622c6c990d2273ca724af204aaac08f185d3 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 11 Dec 2025 07:44:18 -0800 Subject: [PATCH 283/313] chore: librarian release pull request: 20251210T192622Z (#2321) PR created by the Librarian CLI to initialize a release. Merging this PR will auto trigger a release. Librarian Version: v0.7.0 Language Image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator@sha256:c8612d3fffb3f6a32353b2d1abd16b61e87811866f7ec9d65b59b02eb452a620
bigframes: 2.31.0 ## [2.31.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.30.0...v2.31.0) (2025-12-10) ### Features * add `bigframes.bigquery.ml` methods (#2300) ([719b278c](https://github.com/googleapis/python-bigquery-dataframes/commit/719b278c)) * add 'weekday' property to DatatimeMethod (#2304) ([fafd7c73](https://github.com/googleapis/python-bigquery-dataframes/commit/fafd7c73)) ### Bug Fixes * cache DataFrames to temp tables in bigframes.bigquery.ml methods to avoid time travel (#2318) ([d9938319](https://github.com/googleapis/python-bigquery-dataframes/commit/d9938319)) ### Reverts * DataFrame display uses IPython's `_repr_mimebundle_` (#2316) ([e4e3ec85](https://github.com/googleapis/python-bigquery-dataframes/commit/e4e3ec85))
From 59df7f70a12ef702224ad61e597bd775208dac45 Mon Sep 17 00:00:00 2001 From: Garrett Wu <6505921+GarrettWu@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:04:42 -0800 Subject: [PATCH 284/313] feat: add fit_predict method to ml unsupervised models (#2320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes # 🦕 --- bigframes/ml/base.py | 7 +++++++ .../bq_dataframes_llm_kmeans.ipynb | 4 ++-- .../sklearn/cluster/_kmeans.py | 20 +++++++++++++++++++ .../sklearn/decomposition/_mf.py | 20 +++++++++++++++++++ .../sklearn/decomposition/_pca.py | 20 +++++++++++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/bigframes/ml/base.py b/bigframes/ml/base.py index fe468cb28f..9b38702cce 100644 --- a/bigframes/ml/base.py +++ b/bigframes/ml/base.py @@ -248,6 +248,13 @@ def fit( ) -> _T: return self._fit(X, y) + def fit_predict( + self: _T, + X: utils.ArrayType, + y: Optional[utils.ArrayType] = None, + ) -> _T: + return self.fit(X).predict(X) + class RetriableRemotePredictor(BaseEstimator): def _predict_and_retry( diff --git a/notebooks/generative_ai/bq_dataframes_llm_kmeans.ipynb b/notebooks/generative_ai/bq_dataframes_llm_kmeans.ipynb index bc55096942..08891d2b44 100644 --- a/notebooks/generative_ai/bq_dataframes_llm_kmeans.ipynb +++ b/notebooks/generative_ai/bq_dataframes_llm_kmeans.ipynb @@ -1736,7 +1736,7 @@ "provenance": [] }, "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "venv (3.10.14)", "language": "python", "name": "python3" }, @@ -1750,7 +1750,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.9" + "version": "3.10.14" } }, "nbformat": 4, diff --git a/third_party/bigframes_vendored/sklearn/cluster/_kmeans.py b/third_party/bigframes_vendored/sklearn/cluster/_kmeans.py index 44eefeddd7..2b1778eec8 100644 --- a/third_party/bigframes_vendored/sklearn/cluster/_kmeans.py +++ b/third_party/bigframes_vendored/sklearn/cluster/_kmeans.py @@ -115,6 +115,26 @@ def predict( """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def fit_predict( + self, + X, + y=None, + ): + """Compute cluster centers and predict cluster index for each sample. + + Convenience method; equivalent to calling fit(X) followed by predict(X). + + Args: + X (bigframes.dataframe.DataFrame or bigframes.series.Series or pandas.core.frame.DataFrame or pandas.core.series.Series): + DataFrame of shape (n_samples, n_features). Training data. + y (default None): + Not used, present here for API consistency by convention. + + Returns: + bigframes.dataframe.DataFrame: DataFrame of shape (n_samples, n_input_columns + n_prediction_columns). Returns predicted labels. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def score( self, X, diff --git a/third_party/bigframes_vendored/sklearn/decomposition/_mf.py b/third_party/bigframes_vendored/sklearn/decomposition/_mf.py index e487a2e7c1..7dad196237 100644 --- a/third_party/bigframes_vendored/sklearn/decomposition/_mf.py +++ b/third_party/bigframes_vendored/sklearn/decomposition/_mf.py @@ -94,3 +94,23 @@ def predict(self, X): Returns: bigframes.dataframe.DataFrame: Predicted DataFrames.""" raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + + def fit_predict( + self, + X, + y=None, + ): + """Fit the model with X and generate a predicted rating for every user-item row combination for a matrix factorization model. on X. + + Convenience method; equivalent to calling fit(X) followed by predict(X). + + Args: + X (bigframes.dataframe.DataFrame or bigframes.series.Series or pandas.core.frame.DataFrame or pandas.core.series.Series): + DataFrame of shape (n_samples, n_features). Training data. + y (default None): + Not used, present here for API consistency by convention. + + Returns: + bigframes.dataframe.DataFrame: DataFrame of shape (n_samples, n_input_columns + n_prediction_columns). Returns predicted labels. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) diff --git a/third_party/bigframes_vendored/sklearn/decomposition/_pca.py b/third_party/bigframes_vendored/sklearn/decomposition/_pca.py index 3535edc8f9..f90e193064 100644 --- a/third_party/bigframes_vendored/sklearn/decomposition/_pca.py +++ b/third_party/bigframes_vendored/sklearn/decomposition/_pca.py @@ -101,6 +101,26 @@ def predict(self, X): bigframes.dataframe.DataFrame: Predicted DataFrames.""" raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + def fit_predict( + self, + X, + y=None, + ): + """Fit the model with X and apply the dimensionality reduction on X. + + Convenience method; equivalent to calling fit(X) followed by predict(X). + + Args: + X (bigframes.dataframe.DataFrame or bigframes.series.Series or pandas.core.frame.DataFrame or pandas.core.series.Series): + DataFrame of shape (n_samples, n_features). Training data. + y (default None): + Not used, present here for API consistency by convention. + + Returns: + bigframes.dataframe.DataFrame: DataFrame of shape (n_samples, n_input_columns + n_prediction_columns). Returns predicted labels. + """ + raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + @property def components_(self): """Principal axes in feature space, representing the directions of maximum variance in the data. From 252644826289d9db7a8548884de880b3a4fccafd Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Thu, 11 Dec 2025 12:59:45 -0800 Subject: [PATCH 285/313] fix: Fix pd.timedelta handling in polars comipler with polars 1.36 (#2325) --- bigframes/core/compile/polars/compiler.py | 5 +++++ tests/unit/test_series_polars.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/bigframes/core/compile/polars/compiler.py b/bigframes/core/compile/polars/compiler.py index 20fdeb5be3..1f0ca592e5 100644 --- a/bigframes/core/compile/polars/compiler.py +++ b/bigframes/core/compile/polars/compiler.py @@ -152,6 +152,11 @@ def _( value = None if expression.dtype is None: return pl.lit(None) + + # Polars lit does not handle pandas timedelta well at v1.36 + if isinstance(value, pd.Timedelta): + value = value.to_pytimedelta() + return pl.lit(value, _bigframes_dtype_to_polars_dtype(expression.dtype)) @compile_expression.register diff --git a/tests/unit/test_series_polars.py b/tests/unit/test_series_polars.py index e98db92b93..cffeedea35 100644 --- a/tests/unit/test_series_polars.py +++ b/tests/unit/test_series_polars.py @@ -5109,3 +5109,18 @@ def test_series_item_with_empty(session): with pytest.raises(ValueError, match=re.escape(expected_message)): bf_s_empty.item() + + +def test_series_dt_total_seconds(scalars_df_index, scalars_pandas_df_index): + bf_result = scalars_df_index["duration_col"].dt.total_seconds().to_pandas() + + pd_result = scalars_pandas_df_index["duration_col"].dt.total_seconds() + + # Index will be object type in pandas, string type in bigframes, but same values + pd.testing.assert_series_equal( + bf_result, + pd_result, + check_index_type=False, + # bigframes uses Float64, newer pandas may use double[pyarrow] + check_dtype=False, + ) From 7f1d3df3839ec58f52e48df088057fc0df967da9 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Fri, 12 Dec 2025 07:58:05 -0800 Subject: [PATCH 286/313] fix: Correct DataFrame widget rendering in Colab (#2319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fix the _get_anywidget_bundle method. Previously, when the underlying widget's _repr_mimebundle_ method returned a (data, metadata) tuple, the code was only extracting the data portion and discarding the metadata. This resulted in the widget not rendering correctly in environments like Colab, which rely on this metadata. The change corrects this by properly unpacking the tuple into widget_repr and widget_metadata. The method now preserves the metadata and returns it along with the data, ensuring that the necessary information for widget rendering is passed on. We also revert commit 4df3428dde83a57ed183b5441b7d9af7ddab1a17 to reapply "refactor: Migrate DataFrame display to use IPython's repr_mimebundle() protocol for anywidget mode (https://github.com/googleapis/python-bigquery-dataframes/pull/2271)" A testcase is added to verify this new change. We also verified at colab: screen/AzGa5RMTJnMH5NH Fixes #<466155761> 🦕 --- bigframes/core/indexes/base.py | 4 +- bigframes/dataframe.py | 172 +++++++--- bigframes/streaming/dataframe.py | 8 +- notebooks/dataframes/anywidget_mode.ipynb | 398 ++++++++++++++++++++-- tests/system/small/test_anywidget.py | 249 +++++++++++--- tests/system/small/test_dataframe.py | 5 +- tests/system/small/test_ipython.py | 2 +- tests/system/small/test_progress_bar.py | 9 +- tests/unit/test_dataframe_polars.py | 5 +- 9 files changed, 701 insertions(+), 151 deletions(-) diff --git a/bigframes/core/indexes/base.py b/bigframes/core/indexes/base.py index 2e52b5fa25..383534fa4d 100644 --- a/bigframes/core/indexes/base.py +++ b/bigframes/core/indexes/base.py @@ -376,9 +376,7 @@ def __repr__(self) -> __builtins__.str: # metadata, like we do with DataFrame. opts = bigframes.options.display max_results = opts.max_rows - # anywdiget mode uses the same display logic as the "deferred" mode - # for faster execution - if opts.repr_mode in ("deferred", "anywidget"): + if opts.repr_mode == "deferred": _, dry_run_query_job = self._block._compute_dry_run() return formatter.repr_query_job(dry_run_query_job) diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index b34c1cafd0..9994ae9721 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -789,9 +789,7 @@ def __repr__(self) -> str: opts = bigframes.options.display max_results = opts.max_rows - # anywdiget mode uses the same display logic as the "deferred" mode - # for faster execution - if opts.repr_mode in ("deferred", "anywidget"): + if opts.repr_mode == "deferred": return formatter.repr_query_job(self._compute_dry_run()) # TODO(swast): pass max_columns and get the true column count back. Maybe @@ -829,68 +827,149 @@ def __repr__(self) -> str: lines.append(f"[{row_count} rows x {column_count} columns]") return "\n".join(lines) - def _repr_html_(self) -> str: - """ - Returns an html string primarily for use by notebooks for displaying - a representation of the DataFrame. Displays 20 rows by default since - many notebooks are not configured for large tables. - """ - opts = bigframes.options.display - max_results = opts.max_rows - if opts.repr_mode == "deferred": - return formatter.repr_query_job(self._compute_dry_run()) - - # Process blob columns first, regardless of display mode - self._cached() - df = self.copy() + def _get_display_df_and_blob_cols(self) -> tuple[DataFrame, list[str]]: + """Process blob columns for display.""" + df = self + blob_cols = [] if bigframes.options.display.blob_display: blob_cols = [ series_name - for series_name, series in df.items() + for series_name, series in self.items() if series.dtype == bigframes.dtypes.OBJ_REF_DTYPE ] - for col in blob_cols: - # TODO(garrettwu): Not necessary to get access urls for all the rows. Update when having a to get URLs from local data. - df[col] = df[col].blob._get_runtime(mode="R", with_metadata=True) + if blob_cols: + df = self.copy() + for col in blob_cols: + # TODO(garrettwu): Not necessary to get access urls for all the rows. Update when having a to get URLs from local data. + df[col] = df[col].blob._get_runtime(mode="R", with_metadata=True) + return df, blob_cols + + def _get_anywidget_bundle( + self, include=None, exclude=None + ) -> tuple[dict[str, Any], dict[str, Any]]: + """ + Helper method to create and return the anywidget mimebundle. + This function encapsulates the logic for anywidget display. + """ + from bigframes import display + + df, blob_cols = self._get_display_df_and_blob_cols() + + # Create and display the widget + widget = display.TableWidget(df) + widget_repr_result = widget._repr_mimebundle_(include=include, exclude=exclude) + + # Handle both tuple (data, metadata) and dict returns + if isinstance(widget_repr_result, tuple): + widget_repr, widget_metadata = widget_repr_result else: - blob_cols = [] + widget_repr = widget_repr_result + widget_metadata = {} + + widget_repr = dict(widget_repr) + + # At this point, we have already executed the query as part of the + # widget construction. Let's use the information available to render + # the HTML and plain text versions. + widget_repr["text/html"] = self._create_html_representation( + widget._cached_data, + widget.row_count, + len(self.columns), + blob_cols, + ) - if opts.repr_mode == "anywidget": - try: - from IPython.display import display as ipython_display + widget_repr["text/plain"] = self._create_text_representation( + widget._cached_data, widget.row_count + ) + + return widget_repr, widget_metadata + + def _create_text_representation( + self, pandas_df: pandas.DataFrame, total_rows: typing.Optional[int] + ) -> str: + """Create a text representation of the DataFrame.""" + opts = bigframes.options.display + with display_options.pandas_repr(opts): + import pandas.io.formats + + # safe to mutate this, this dict is owned by this code, and does not affect global config + to_string_kwargs = ( + pandas.io.formats.format.get_dataframe_repr_params() # type: ignore + ) + if not self._has_index: + to_string_kwargs.update({"index": False}) + + # We add our own dimensions string, so don't want pandas to. + to_string_kwargs.update({"show_dimensions": False}) + repr_string = pandas_df.to_string(**to_string_kwargs) - from bigframes import display + lines = repr_string.split("\n") - # Always create a new widget instance for each display call - # This ensures that each cell gets its own widget and prevents - # unintended sharing between cells - widget = display.TableWidget(df.copy()) + if total_rows is not None and total_rows > len(pandas_df): + lines.append("...") - ipython_display(widget) - return "" # Return empty string since we used display() + lines.append("") + column_count = len(self.columns) + lines.append(f"[{total_rows or '?'} rows x {column_count} columns]") + return "\n".join(lines) - except (AttributeError, ValueError, ImportError): - # Fallback if anywidget is not available + def _repr_mimebundle_(self, include=None, exclude=None): + """ + Custom display method for IPython/Jupyter environments. + This is called by IPython's display system when the object is displayed. + """ + # TODO(b/467647693): Anywidget integration has been tested in Jupyter, VS Code, and + # BQ Studio, but there is a known compatibility issue with Marimo that needs to be addressed. + opts = bigframes.options.display + # Only handle widget display in anywidget mode + if opts.repr_mode == "anywidget": + try: + return self._get_anywidget_bundle(include=include, exclude=exclude) + + except ImportError: + # Anywidget is an optional dependency, so warn rather than fail. + # TODO(shuowei): When Anywidget becomes the default for all repr modes, + # remove this warning. warnings.warn( "Anywidget mode is not available. " "Please `pip install anywidget traitlets` or `pip install 'bigframes[anywidget]'` to use interactive tables. " - f"Falling back to deferred mode. Error: {traceback.format_exc()}" + f"Falling back to static HTML. Error: {traceback.format_exc()}" ) - return formatter.repr_query_job(self._compute_dry_run()) - # Continue with regular HTML rendering for non-anywidget modes - # TODO(swast): pass max_columns and get the true column count back. Maybe - # get 1 more column than we have requested so that pandas can add the - # ... for us? + # In non-anywidget mode, fetch data once and use it for both HTML + # and plain text representations to avoid multiple queries. + opts = bigframes.options.display + max_results = opts.max_rows + + df, blob_cols = self._get_display_df_and_blob_cols() + pandas_df, row_count, query_job = df._block.retrieve_repr_request_results( max_results ) - self._set_internal_query_job(query_job) column_count = len(pandas_df.columns) + html_string = self._create_html_representation( + pandas_df, row_count, column_count, blob_cols + ) + + text_representation = self._create_text_representation(pandas_df, row_count) + + return {"text/html": html_string, "text/plain": text_representation} + + def _create_html_representation( + self, + pandas_df: pandas.DataFrame, + row_count: int, + column_count: int, + blob_cols: list[str], + ) -> str: + """Create an HTML representation of the DataFrame.""" + opts = bigframes.options.display with display_options.pandas_repr(opts): - # Allows to preview images in the DataFrame. The implementation changes the string repr as well, that it doesn't truncate strings or escape html charaters such as "<" and ">". We may need to implement a full-fledged repr module to better support types not in pandas. + # TODO(shuowei, b/464053870): Escaping HTML would be useful, but + # `escape=False` is needed to show images. We may need to implement + # a full-fledged repr module to better support types not in pandas. if bigframes.options.display.blob_display and blob_cols: def obj_ref_rt_to_html(obj_ref_rt) -> str: @@ -919,15 +998,12 @@ def obj_ref_rt_to_html(obj_ref_rt) -> str: # set max_colwidth so not to truncate the image url with pandas.option_context("display.max_colwidth", None): - max_rows = pandas.get_option("display.max_rows") - max_cols = pandas.get_option("display.max_columns") - show_dimensions = pandas.get_option("display.show_dimensions") html_string = pandas_df.to_html( escape=False, notebook=True, - max_rows=max_rows, - max_cols=max_cols, - show_dimensions=show_dimensions, + max_rows=pandas.get_option("display.max_rows"), + max_cols=pandas.get_option("display.max_columns"), + show_dimensions=pandas.get_option("display.show_dimensions"), formatters=formatters, # type: ignore ) else: diff --git a/bigframes/streaming/dataframe.py b/bigframes/streaming/dataframe.py index 7dc9e964bc..3e030a4aa2 100644 --- a/bigframes/streaming/dataframe.py +++ b/bigframes/streaming/dataframe.py @@ -291,13 +291,13 @@ def __repr__(self, *args, **kwargs): __repr__.__doc__ = _curate_df_doc(inspect.getdoc(dataframe.DataFrame.__repr__)) - def _repr_html_(self, *args, **kwargs): - return _return_type_wrapper(self._df._repr_html_, StreamingDataFrame)( + def _repr_mimebundle_(self, *args, **kwargs): + return _return_type_wrapper(self._df._repr_mimebundle_, StreamingDataFrame)( *args, **kwargs ) - _repr_html_.__doc__ = _curate_df_doc( - inspect.getdoc(dataframe.DataFrame._repr_html_) + _repr_mimebundle_.__doc__ = _curate_df_doc( + inspect.getdoc(dataframe.DataFrame._repr_mimebundle_) ) @property diff --git a/notebooks/dataframes/anywidget_mode.ipynb b/notebooks/dataframes/anywidget_mode.ipynb index fa324c246a..b0d908fc17 100644 --- a/notebooks/dataframes/anywidget_mode.ipynb +++ b/notebooks/dataframes/anywidget_mode.ipynb @@ -76,11 +76,50 @@ "id": "f289d250", "metadata": {}, "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 0 Bytes in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "name": "stdout", "output_type": "stream", "text": [ - "Computation deferred. Computation will process 171.4 MB\n" + "state gender year name number\n", + " AL F 1910 Annie 482\n", + " AL F 1910 Myrtle 104\n", + " AR F 1910 Lillian 56\n", + " CT F 1910 Anne 38\n", + " CT F 1910 Frances 45\n", + " FL F 1910 Margaret 53\n", + " GA F 1910 Mae 73\n", + " GA F 1910 Beatrice 96\n", + " GA F 1910 Lola 47\n", + " IA F 1910 Viola 49\n", + "...\n", + "\n", + "[5552452 rows x 5 columns]\n" ] } ], @@ -157,22 +196,137 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "2935e3f8f4f34c558d588f09a9c42131", + "model_id": "e2231d99614a4489b2930c24b30f1d34", "version_major": 2, "version_minor": 1 }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
stategenderyearnamenumber
0ALF1910Hazel51
1ALF1910Lucy76
2ARF1910Nellie39
3ARF1910Lena40
4COF1910Thelma36
5COF1910Ruth68
6CTF1910Elizabeth86
7DCF1910Mary80
8FLF1910Annie101
9FLF1910Alma39
\n", + "

10 rows × 5 columns

\n", + "
[5552452 rows x 5 columns in total]" + ], "text/plain": [ - "TableWidget(orderable_columns=['state', 'gender', 'year', 'name', 'number'], page_size=10, row_count=5552452, …" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [], - "text/plain": [ - "Computation deferred. Computation will process 171.4 MB" + "state gender year name number\n", + " AL F 1910 Hazel 51\n", + " AL F 1910 Lucy 76\n", + " AR F 1910 Nellie 39\n", + " AR F 1910 Lena 40\n", + " CO F 1910 Thelma 36\n", + " CO F 1910 Ruth 68\n", + " CT F 1910 Elizabeth 86\n", + " DC F 1910 Mary 80\n", + " FL F 1910 Annie 101\n", + " FL F 1910 Alma 39\n", + "...\n", + "\n", + "[5552452 rows x 5 columns]" ] }, "execution_count": 6, @@ -255,12 +409,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "fa03d998dfee47638a32b6c21ace0b5c", + "model_id": "f26e26da0c84469fb7a9c211ab4423b7", "version_major": 2, "version_minor": 1 }, "text/plain": [ - "TableWidget(orderable_columns=['state', 'gender', 'year', 'name', 'number'], page_size=10, row_count=5552452, …" + "" ] }, "execution_count": 7, @@ -369,12 +523,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "6d6886bd2bb74be996d54ce240cbe6c9", + "model_id": "f1a893516ee04a5f9eb2655d5aaca778", "version_major": 2, "version_minor": 1 }, "text/plain": [ - "TableWidget(orderable_columns=['state', 'gender', 'year', 'name', 'number'], page_size=10, row_count=5, table_…" + "" ] }, "execution_count": 9, @@ -401,7 +555,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 10, "id": "added-cell-1", "metadata": {}, "outputs": [ @@ -409,7 +563,7 @@ "data": { "text/html": [ "✅ Completed. \n", - " Query processed 85.9 kB in 24 seconds of slot time.\n", + " Query processed 85.9 kB in 11 seconds of slot time.\n", " " ], "text/plain": [ @@ -453,28 +607,206 @@ "metadata": {}, "output_type": "display_data" }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/bigframes/dtypes.py:987: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n", + "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/bigframes/dtypes.py:987: JSONDtypeWarning: JSON columns will be represented as pandas.ArrowDtype(pyarrow.json_())\n", + "instead of using `db_dtypes` in the future when available in pandas\n", + "(https://github.com/pandas-dev/pandas/issues/60958) and pyarrow.\n", + " warnings.warn(msg, bigframes.exceptions.JSONDtypeWarning)\n" + ] + }, { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "df774329fd2f47918b986362863d7155", + "model_id": "d48598e7d34a4fd0a817e4995868395e", "version_major": 2, "version_minor": 1 }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
resultgcs_pathissuerlanguagepublication_dateclass_internationalclass_usapplication_numberfiling_datepriority_date_eurepresentative_line_1_euapplicant_line_1inventor_line_1title_line_1number
0{'application_number': None, 'class_internatio...gs://gcs-public-data--labeled-patents/espacene...EUDE03.10.2018H01L 21/20<NA>18166536.516.02.2016<NA>Scheider, Sascha et alEV Group E. Thallner GmbHKurz, FlorianVORRICHTUNG ZUM BONDEN VON SUBSTRATENEP 3 382 744 A1
1{'application_number': None, 'class_internatio...gs://gcs-public-data--labeled-patents/espacene...EUDE03.10.2018G06F 11/30<NA>18157347.819.02.201831.03.2017Hoffmann EitleFUJITSU LIMITEDKukihara, KensukeMETHOD EXECUTED BY A COMPUTER, INFORMATION PRO...EP 3 382 553 A1
2{'application_number': None, 'class_internatio...gs://gcs-public-data--labeled-patents/espacene...EUDE03.10.2018A01K 31/00<NA>18171005.405.02.201505.02.2014Stork Bamberger PatentanwälteLinco Food Systems A/SThrane, UffeMASTHÄHNCHENCONTAINER ALS BESTANDTEIL EINER E...EP 3 381 276 A1
3{'application_number': None, 'class_internatio...gs://gcs-public-data--labeled-patents/espacene...EUDE03.10.2018H05B 6/12<NA>18165514.303.04.201830.03.2017<NA>BSH Hausger√§te GmbHAcero Acero, JesusVORRICHTUNG ZUR INDUKTIVEN ENERGIE√úBERTRAGUNGEP 3 383 141 A2
4{'application_number': None, 'class_internatio...gs://gcs-public-data--labeled-patents/espacene...EUDE29.08.018E04H 6/12<NA>18157874.121.02.201822.02.2017Liedtke & Partner PatentanwälteSHB Hebezeugbau GmbHVOLGER, AlexanderSTEUERUNGSSYSTEM FÜR AUTOMATISCHE PARKHÄUSEREP 3 366 869 A1
\n", + "

5 rows × 15 columns

\n", + "
[5 rows x 15 columns in total]" + ], "text/plain": [ - "TableWidget(orderable_columns=['gcs_path', 'issuer', 'language', 'publication_date', 'class_international', 'c…" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [], - "text/plain": [ - "Computation deferred. Computation will process 0 Bytes" + " result \\\n", + "0 {'application_number': None, 'class_internatio... \n", + "1 {'application_number': None, 'class_internatio... \n", + "2 {'application_number': None, 'class_internatio... \n", + "3 {'application_number': None, 'class_internatio... \n", + "4 {'application_number': None, 'class_internatio... \n", + "\n", + " gcs_path issuer language \\\n", + "0 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", + "1 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", + "2 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", + "3 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", + "4 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", + "\n", + " publication_date class_international class_us application_number \\\n", + "0 03.10.2018 H01L 21/20 18166536.5 \n", + "1 03.10.2018 G06F 11/30 18157347.8 \n", + "2 03.10.2018 A01K 31/00 18171005.4 \n", + "3 03.10.2018 H05B 6/12 18165514.3 \n", + "4 29.08.018 E04H 6/12 18157874.1 \n", + "\n", + " filing_date priority_date_eu representative_line_1_eu \\\n", + "0 16.02.2016 Scheider, Sascha et al \n", + "1 19.02.2018 31.03.2017 Hoffmann Eitle \n", + "2 05.02.2015 05.02.2014 Stork Bamberger Patentanw√§lte \n", + "3 03.04.2018 30.03.2017 \n", + "4 21.02.2018 22.02.2017 Liedtke & Partner Patentanw√§lte \n", + "\n", + " applicant_line_1 inventor_line_1 \\\n", + "0 EV Group E. Thallner GmbH Kurz, Florian \n", + "1 FUJITSU LIMITED Kukihara, Kensuke \n", + "2 Linco Food Systems A/S Thrane, Uffe \n", + "3 BSH Hausger√§te GmbH Acero Acero, Jesus \n", + "4 SHB Hebezeugbau GmbH VOLGER, Alexander \n", + "\n", + " title_line_1 number \n", + "0 VORRICHTUNG ZUM BONDEN VON SUBSTRATEN EP 3 382 744 A1 \n", + "1 METHOD EXECUTED BY A COMPUTER, INFORMATION PRO... EP 3 382 553 A1 \n", + "2 MASTH√ÑHNCHENCONTAINER ALS BESTANDTEIL EINER E... EP 3 381 276 A1 \n", + "3 VORRICHTUNG ZUR INDUKTIVEN ENERGIE√úBERTRAGUNG EP 3 383 141 A2 \n", + "4 STEUERUNGSSYSTEM F√úR AUTOMATISCHE PARKH√ÑUSER EP 3 366 869 A1 \n", + "\n", + "[5 rows x 15 columns]" ] }, - "execution_count": 11, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } @@ -509,7 +841,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.15" + "version": "3.13.0" } }, "nbformat": 4, diff --git a/tests/system/small/test_anywidget.py b/tests/system/small/test_anywidget.py index 49d5ff6c92..260a338bf7 100644 --- a/tests/system/small/test_anywidget.py +++ b/tests/system/small/test_anywidget.py @@ -21,6 +21,9 @@ import pytest import bigframes as bf +import bigframes.core.blocks +import bigframes.dataframe +import bigframes.display pytest.importorskip("anywidget") @@ -37,12 +40,12 @@ def paginated_pandas_df() -> pd.DataFrame: { "id": [5, 4, 3, 2, 1, 0], "page_indicator": [ - "row_5", - "row_4", - "row_3", - "row_2", - "row_1", - "row_0", + "page_3_row_2", + "page_3_row_1", + "page_2_row_2", + "page_2_row_1", + "page_1_row_2", + "page_1_row_1", ], "value": [5, 4, 3, 2, 1, 0], } @@ -53,12 +56,12 @@ def paginated_pandas_df() -> pd.DataFrame: @pytest.fixture(scope="module") def paginated_bf_df( session: bf.Session, paginated_pandas_df: pd.DataFrame -) -> bf.dataframe.DataFrame: +) -> bigframes.dataframe.DataFrame: return session.read_pandas(paginated_pandas_df) @pytest.fixture -def table_widget(paginated_bf_df: bf.dataframe.DataFrame): +def table_widget(paginated_bf_df: bigframes.dataframe.DataFrame): """ Helper fixture to create a TableWidget instance with a fixed page size. This reduces duplication across tests that use the same widget configuration. @@ -66,7 +69,9 @@ def table_widget(paginated_bf_df: bf.dataframe.DataFrame): from bigframes.display import TableWidget - with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): + with bigframes.option_context( + "display.repr_mode", "anywidget", "display.max_rows", 2 + ): # Delay context manager cleanup of `max_rows` until after tests finish. yield TableWidget(paginated_bf_df) @@ -199,17 +204,20 @@ def test_widget_initialization_should_calculate_total_row_count( """A TableWidget should correctly calculate the total row count on creation.""" from bigframes.display import TableWidget - with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): + with bigframes.option_context( + "display.repr_mode", "anywidget", "display.max_rows", 2 + ): widget = TableWidget(paginated_bf_df) assert widget.row_count == EXPECTED_ROW_COUNT -def test_widget_initialization_should_set_default_pagination( +def test_widget_initialization_should_default_to_page_zero( table_widget, ): """ - A TableWidget should initialize with page 0 and the correct page size. + Given a new TableWidget, when it is initialized, + then its page number should default to 0. """ # The `table_widget` fixture already creates the widget. # Assert its state. @@ -259,8 +267,8 @@ def test_widget_navigation_should_display_correct_page( _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) -def test_widget_navigation_should_raise_error_for_negative_input( - table_widget, paginated_pandas_df: pd.DataFrame +def test_setting_negative_page_should_raise_error( + table_widget, ): """ Given a widget, when a negative page number is set, @@ -270,19 +278,20 @@ def test_widget_navigation_should_raise_error_for_negative_input( table_widget.page = -1 -def test_widget_navigation_should_clamp_to_last_page_for_out_of_bounds_input( +def test_setting_page_beyond_max_should_clamp_to_last_page( table_widget, paginated_pandas_df: pd.DataFrame ): """ - Given a widget, when a page number greater than the max is set, + Given a widget, + when a page number greater than the max is set, then the page number should be clamped to the last valid page. """ - expected_slice = paginated_pandas_df.iloc[4:6] + expected_slice = paginated_pandas_df.iloc[4:6] # Last page data - table_widget.page = 100 + table_widget.page = 100 # Set page far beyond the total of 3 pages html = table_widget.table_html - assert table_widget.page == 2 + assert table_widget.page == 2 # Page is clamped to the last valid page (0-indexed) _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) @@ -307,7 +316,9 @@ def test_widget_pagination_should_work_with_custom_page_size( """ A widget should paginate correctly with a custom page size of 3. """ - with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 3): + with bigframes.option_context( + "display.repr_mode", "anywidget", "display.max_rows", 3 + ): from bigframes.display import TableWidget widget = TableWidget(paginated_bf_df) @@ -332,11 +343,11 @@ def test_widget_with_few_rows_should_display_all_rows(small_widget, small_pandas _assert_html_matches_pandas_slice(html, small_pandas_df, small_pandas_df) -def test_widget_with_few_rows_should_have_only_one_page(small_widget): +def test_navigation_beyond_last_page_should_be_clamped(small_widget): """ - Given a DataFrame with a small number of rows, the widget should - report the correct total row count and prevent navigation beyond - the first page, ensuring the frontend correctly displays "Page 1 of 1". + Given a DataFrame smaller than the page size, + when navigating beyond the last page, + then the page should be clamped to the last valid page (page 0). """ # For a DataFrame with 2 rows and page_size 5 (from small_widget fixture), # the frontend should calculate 1 total page. @@ -351,43 +362,68 @@ def test_widget_with_few_rows_should_have_only_one_page(small_widget): assert small_widget.page == 0 -def test_widget_page_size_should_be_immutable_after_creation( +def test_global_options_change_should_not_affect_existing_widget_page_size( paginated_bf_df: bf.dataframe.DataFrame, ): """ - A widget's page size should be fixed on creation and not be affected - by subsequent changes to global options. + Given an existing widget, + when global display options are changed, + then the widget's page size should remain unchanged. """ - with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): + with bigframes.option_context( + "display.repr_mode", "anywidget", "display.max_rows", 2 + ): from bigframes.display import TableWidget widget = TableWidget(paginated_bf_df) - assert widget.page_size == 2 - - # Navigate to second page to ensure widget is in a non-default state - widget.page = 1 + initial_page_size = widget.page_size + assert initial_page_size == 2 + widget.page = 1 # a non-default state assert widget.page == 1 - # Change global max_rows - widget should not be affected - bf.options.display.max_rows = 10 + bf.options.display.max_rows = 10 # Change global setting + + assert widget.page_size == initial_page_size # Should remain unchanged + assert widget.page == 1 # Page should not be reset - assert widget.page_size == 2 # Should remain unchanged - assert widget.page == 1 # Should remain on same page +def test_widget_with_empty_dataframe_should_have_zero_row_count( + empty_bf_df: bf.dataframe.DataFrame, +): + """ + Given an empty DataFrame, + when a widget is created from it, + then its row_count should be 0. + """ -def test_empty_widget_should_have_zero_row_count(empty_bf_df: bf.dataframe.DataFrame): - """Given an empty DataFrame, the widget's row count should be 0.""" - with bf.option_context("display.repr_mode", "anywidget"): + with bigframes.option_context("display.repr_mode", "anywidget"): from bigframes.display import TableWidget widget = TableWidget(empty_bf_df) - assert widget.row_count == 0 + assert widget.row_count == 0 + +def test_widget_with_empty_dataframe_should_render_table_headers( + empty_bf_df: bf.dataframe.DataFrame, +): + + """ + + + Given an empty DataFrame, + + + when a widget is created from it, + + + then its HTML representation should still render the table headers. + + + """ + + with bigframes.option_context("display.repr_mode", "anywidget"): -def test_empty_widget_should_render_table_headers(empty_bf_df: bf.dataframe.DataFrame): - """Given an empty DataFrame, the widget should still render table headers.""" - with bf.option_context("display.repr_mode", "anywidget"): from bigframes.display import TableWidget widget = TableWidget(empty_bf_df) @@ -395,7 +431,8 @@ def test_empty_widget_should_render_table_headers(empty_bf_df: bf.dataframe.Data html = widget.table_html assert "My Table HTML", + "text/plain": "My Table Plain Text", + }, + { + "application/vnd.jupyter.widget-view+json": { + "colab": {"custom_widget_manager": {}} + } + }, + ) + + # Patch the class method directly + with mock.patch( + "bigframes.dataframe.DataFrame._get_anywidget_bundle", + return_value=mock_get_anywidget_bundle_return_value, + ): + result = test_df._repr_mimebundle_() + + assert isinstance(result, tuple) + data, metadata = result + assert "application/vnd.jupyter.widget-view+json" in data + assert "text/html" in data + assert "text/plain" in data + assert "application/vnd.jupyter.widget-view+json" in metadata + assert "colab" in metadata["application/vnd.jupyter.widget-view+json"] + + +# TODO(b/332316283): Add tests for custom index and multiindex # This may not be necessary for the SQL Cell use case but should be # considered for completeness. diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 19d3c67e19..6b3f9a2c13 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -958,7 +958,7 @@ def test_repr_w_display_options(scalars_dfs, session): assert (executions_post - executions_pre) <= 3 -def test_repr_html_w_all_rows(scalars_dfs, session): +def test_mimebundle_html_repr_w_all_rows(scalars_dfs, session): metrics = session._metrics scalars_df, _ = scalars_dfs # get a pandas df of the expected format @@ -968,7 +968,8 @@ def test_repr_html_w_all_rows(scalars_dfs, session): executions_pre = metrics.execution_count # When there are 10 or fewer rows, the outputs should be identical except for the extra note. - actual = scalars_df.head(10)._repr_html_() + bundle = scalars_df.head(10)._repr_mimebundle_() + actual = bundle["text/html"] executions_post = metrics.execution_count with display_options.pandas_repr(bigframes.options.display): diff --git a/tests/system/small/test_ipython.py b/tests/system/small/test_ipython.py index be98ce0067..2d23390718 100644 --- a/tests/system/small/test_ipython.py +++ b/tests/system/small/test_ipython.py @@ -26,4 +26,4 @@ def test_repr_cache(scalars_df_index): results = display_formatter.format(test_df) assert results[0].keys() == {"text/plain", "text/html"} assert test_df._block.retrieve_repr_request_results.cache_info().misses >= 1 - assert test_df._block.retrieve_repr_request_results.cache_info().hits >= 1 + assert test_df._block.retrieve_repr_request_results.cache_info().hits == 0 diff --git a/tests/system/small/test_progress_bar.py b/tests/system/small/test_progress_bar.py index 0c9c4070f4..d726bfde2c 100644 --- a/tests/system/small/test_progress_bar.py +++ b/tests/system/small/test_progress_bar.py @@ -153,7 +153,9 @@ def test_repr_anywidget_dataframe(penguins_df_default_index: bf.dataframe.DataFr pytest.importorskip("anywidget") with bf.option_context("display.repr_mode", "anywidget"): actual_repr = repr(penguins_df_default_index) - assert EXPECTED_DRY_RUN_MESSAGE in actual_repr + assert "species" in actual_repr + assert "island" in actual_repr + assert "[344 rows x 7 columns]" in actual_repr def test_repr_anywidget_index(penguins_df_default_index: bf.dataframe.DataFrame): @@ -161,4 +163,7 @@ def test_repr_anywidget_index(penguins_df_default_index: bf.dataframe.DataFrame) with bf.option_context("display.repr_mode", "anywidget"): index = penguins_df_default_index.index actual_repr = repr(index) - assert EXPECTED_DRY_RUN_MESSAGE in actual_repr + # In non-interactive environments, should still get a useful summary. + assert "Index" in actual_repr + assert "0, 1, 2, 3, 4" in actual_repr + assert "dtype='Int64'" in actual_repr diff --git a/tests/unit/test_dataframe_polars.py b/tests/unit/test_dataframe_polars.py index b83380d789..39dbacd087 100644 --- a/tests/unit/test_dataframe_polars.py +++ b/tests/unit/test_dataframe_polars.py @@ -737,7 +737,7 @@ def test_join_repr(scalars_dfs): assert actual == expected -def test_repr_html_w_all_rows(scalars_dfs, session): +def test_mimebundle_html_repr_w_all_rows(scalars_dfs, session): scalars_df, _ = scalars_dfs # get a pandas df of the expected format df, _ = scalars_df._block.to_pandas() @@ -745,7 +745,8 @@ def test_repr_html_w_all_rows(scalars_dfs, session): pandas_df.index.name = scalars_df.index.name # When there are 10 or fewer rows, the outputs should be identical except for the extra note. - actual = scalars_df.head(10)._repr_html_() + bundle = scalars_df.head(10)._repr_mimebundle_() + actual = bundle["text/html"] with display_options.pandas_repr(bigframes.options.display): pandas_repr = pandas_df.head(10)._repr_html_() From 481d938fb0b840e17047bc4b57e61af15b976e54 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 12 Dec 2025 10:27:16 -0800 Subject: [PATCH 287/313] fix: Improve strictness of nan vs None usage (#2326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes # 🦕 --- bigframes/core/blocks.py | 2 +- bigframes/dataframe.py | 2 +- bigframes/series.py | 15 +- bigframes/testing/utils.py | 52 +++++-- .../large/functions/test_remote_function.py | 42 +++--- .../small/bigquery/test_vector_search.py | 4 +- .../small/functions/test_remote_function.py | 24 ++-- tests/system/small/ml/test_cluster.py | 4 +- tests/system/small/ml/test_core.py | 2 +- tests/system/small/ml/test_decomposition.py | 4 +- .../test_issue355_merge_after_filter.py | 4 +- tests/system/small/test_dataframe.py | 135 ++++++++---------- tests/system/small/test_dataframe_io.py | 8 +- tests/system/small/test_groupby.py | 6 +- tests/system/small/test_large_local_data.py | 10 +- tests/system/small/test_multiindex.py | 6 +- tests/system/small/test_pandas.py | 20 +-- tests/system/small/test_polars_execution.py | 6 +- tests/system/small/test_series.py | 21 +-- tests/system/small/test_unordered.py | 16 +-- tests/unit/test_dataframe_polars.py | 112 +++++++-------- tests/unit/test_series_polars.py | 28 ++-- 22 files changed, 262 insertions(+), 261 deletions(-) diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index 6f87e43821..df7c6dee43 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -1295,7 +1295,7 @@ def aggregate_all_and_stack( as_array = ops.ToArrayOp().as_expr(*(col for col in self.value_columns)) reduced = ops.ArrayReduceOp(operation).as_expr(as_array) block, id = self.project_expr(reduced, None) - return block.select_column(id) + return block.select_column(id).with_column_labels(pd.Index([None])) def aggregate_size( self, diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 9994ae9721..9b10d8b0e1 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -5088,7 +5088,7 @@ def duplicated(self, subset=None, keep: str = "first") -> bigframes.series.Serie return bigframes.series.Series( block.select_column( indicator, - ) + ).with_column_labels(pandas.Index([None])), ) def rank( diff --git a/bigframes/series.py b/bigframes/series.py index 51d7cc76ee..e11c60a999 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -2653,9 +2653,10 @@ def _apply_unary_op( ) -> Series: """Applies a unary operator to the series.""" block, result_id = self._block.apply_unary_op( - self._value_column, op, result_label=self._name + self._value_column, + op, ) - return Series(block.select_column(result_id)) + return Series(block.select_column(result_id), name=self.name) # type: ignore def _apply_binary_op( self, @@ -2683,8 +2684,9 @@ def _apply_binary_op( expr = op.as_expr( other_col if reverse else self_col, self_col if reverse else other_col ) - block, result_id = block.project_expr(expr, name) - return Series(block.select_column(result_id)) + block, result_id = block.project_expr(expr) + block = block.select_column(result_id).with_column_labels([name]) + return Series(block) # type: ignore else: # Scalar binop name = self._name @@ -2692,8 +2694,9 @@ def _apply_binary_op( ex.const(other) if reverse else self._value_column, self._value_column if reverse else ex.const(other), ) - block, result_id = self._block.project_expr(expr, name) - return Series(block.select_column(result_id)) + block, result_id = self._block.project_expr(expr) + block = block.select_column(result_id).with_column_labels([name]) + return Series(block) # type: ignore def _apply_nary_op( self, diff --git a/bigframes/testing/utils.py b/bigframes/testing/utils.py index cf9c9fc031..ae93c00464 100644 --- a/bigframes/testing/utils.py +++ b/bigframes/testing/utils.py @@ -22,11 +22,13 @@ from google.cloud.functions_v2.types import functions import numpy as np import pandas as pd +import pandas.api.types as pd_types import pyarrow as pa # type: ignore import pytest from bigframes import operations as ops from bigframes.core import expression as ex +import bigframes.dtypes import bigframes.functions._utils as bff_utils import bigframes.pandas as bpd @@ -71,7 +73,7 @@ def assert_dfs_equivalent(pd_df: pd.DataFrame, bf_df: bpd.DataFrame, **kwargs): bf_df_local = bf_df.to_pandas() ignore_order = not bf_df._session._strictly_ordered - assert_pandas_df_equal(bf_df_local, pd_df, ignore_order=ignore_order, **kwargs) + assert_frame_equal(bf_df_local, pd_df, ignore_order=ignore_order, **kwargs) def assert_series_equivalent(pd_series: pd.Series, bf_series: bpd.Series, **kwargs): @@ -80,25 +82,49 @@ def assert_series_equivalent(pd_series: pd.Series, bf_series: bpd.Series, **kwar assert_series_equal(bf_df_local, pd_series, ignore_order=ignore_order, **kwargs) -def assert_pandas_df_equal(df0, df1, ignore_order: bool = False, **kwargs): +def _normalize_all_nulls(col: pd.Series) -> pd.Series: + if col.dtype == bigframes.dtypes.FLOAT_DTYPE: + col = col.astype("float64") + if pd_types.is_object_dtype(col): + col = col.fillna(float("nan")) + return col + + +def assert_frame_equal( + left: pd.DataFrame, + right: pd.DataFrame, + *, + ignore_order: bool = False, + nulls_are_nan: bool = True, + **kwargs, +): if ignore_order: # Sort by a column to get consistent results. - if df0.index.name != "rowindex": - df0 = df0.sort_values( - list(df0.columns.drop("geography_col", errors="ignore")) + if left.index.name != "rowindex": + left = left.sort_values( + list(left.columns.drop("geography_col", errors="ignore")) ).reset_index(drop=True) - df1 = df1.sort_values( - list(df1.columns.drop("geography_col", errors="ignore")) + right = right.sort_values( + list(right.columns.drop("geography_col", errors="ignore")) ).reset_index(drop=True) else: - df0 = df0.sort_index() - df1 = df1.sort_index() + left = left.sort_index() + right = right.sort_index() + + if nulls_are_nan: + left = left.apply(_normalize_all_nulls) + right = right.apply(_normalize_all_nulls) - pd.testing.assert_frame_equal(df0, df1, **kwargs) + pd.testing.assert_frame_equal(left, right, **kwargs) def assert_series_equal( - left: pd.Series, right: pd.Series, ignore_order: bool = False, **kwargs + left: pd.Series, + right: pd.Series, + *, + ignore_order: bool = False, + nulls_are_nan: bool = True, + **kwargs, ): if ignore_order: if left.index.name is None: @@ -108,6 +134,10 @@ def assert_series_equal( left = left.sort_index() right = right.sort_index() + if nulls_are_nan: + left = _normalize_all_nulls(left) + right = _normalize_all_nulls(right) + pd.testing.assert_series_equal(left, right, **kwargs) diff --git a/tests/system/large/functions/test_remote_function.py b/tests/system/large/functions/test_remote_function.py index dae51c5b49..253bc7b617 100644 --- a/tests/system/large/functions/test_remote_function.py +++ b/tests/system/large/functions/test_remote_function.py @@ -36,7 +36,7 @@ import bigframes.pandas as bpd import bigframes.series from bigframes.testing.utils import ( - assert_pandas_df_equal, + assert_frame_equal, cleanup_function_assets, delete_cloud_function, get_cloud_functions, @@ -214,7 +214,7 @@ def square(x): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets(square, session.bqclient, session.cloudfunctionsclient) @@ -261,7 +261,7 @@ def add_one(x): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -349,7 +349,7 @@ def square(x): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets(square, session.bqclient, session.cloudfunctionsclient) @@ -403,7 +403,7 @@ def sign(num): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -453,7 +453,7 @@ def circumference(radius): pd_result_col = pd_result_col.astype(pandas.Float64Dtype()) pd_result = pd_float64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -503,7 +503,7 @@ def find_team(num): pd_result_col = pd_result_col.astype(pandas.StringDtype(storage="pyarrow")) pd_result = pd_float64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -591,7 +591,7 @@ def inner_test(): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) # Test that the remote function works as expected inner_test() @@ -683,7 +683,7 @@ def is_odd(num): pd_result_col = pd_int64_col.mask(is_odd) pd_result = pd_int64_col.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -727,7 +727,7 @@ def is_odd(num): pd_result_col = pd_int64_col[pd_int64_col.notnull()].mask(is_odd, -1) pd_result = pd_int64_col.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -770,7 +770,7 @@ def test_remote_udf_lambda(session, scalars_dfs, dataset_id, bq_cf_connection): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -829,7 +829,7 @@ def square(x): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -884,7 +884,7 @@ def pd_np_foo(x) -> None: # comparing for the purpose of this test pd_result.result = pd_result.result.astype(pandas.Float64Dtype()) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -928,7 +928,7 @@ def test_internal(rf, udf): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) # Create an explicit name for the remote function prefixer = test_utils.prefixer.Prefixer("foo", "") @@ -1109,7 +1109,7 @@ def square(x): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets(square, session.bqclient, session.cloudfunctionsclient) @@ -1150,7 +1150,7 @@ def square(x): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets(square, session.bqclient, session.cloudfunctionsclient) @@ -1225,7 +1225,7 @@ def square(x): pd_result_col = pd_result_col.astype(pandas.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets(square, session.bqclient, session.cloudfunctionsclient) @@ -1283,7 +1283,7 @@ def square_num(x): pd_result_col = pd_int64_col.apply(lambda x: x if x is None else x * x) pd_result = pd_int64_col.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -1357,7 +1357,7 @@ def square_num(x): pd_result_col = pd_int64_col.apply(lambda x: x if x is None else x * x) pd_result = pd_int64_col.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -1416,7 +1416,7 @@ def square_num(x): pd_result_col = df["num"].apply(lambda x: x if x is None else x * x) pd_result = df.assign(result=pd_result_col) - assert_pandas_df_equal( + assert_frame_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -1504,7 +1504,7 @@ def square_num(x): pd_result_col = pd_int64_col.apply(square_num) pd_result = pd_int64_col.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( diff --git a/tests/system/small/bigquery/test_vector_search.py b/tests/system/small/bigquery/test_vector_search.py index 3107795730..ff320731e2 100644 --- a/tests/system/small/bigquery/test_vector_search.py +++ b/tests/system/small/bigquery/test_vector_search.py @@ -23,7 +23,7 @@ import bigframes.bigquery as bbq import bigframes.pandas as bpd -from bigframes.testing.utils import assert_pandas_df_equal +from bigframes.testing.utils import assert_frame_equal # Need at least 5,000 rows to create a vector index. VECTOR_DF = pd.DataFrame( @@ -154,7 +154,7 @@ def test_vector_search_basic_params_with_df(): }, index=pd.Index([1, 0, 0, 1], dtype="Int64"), ) - assert_pandas_df_equal( + assert_frame_equal( expected.sort_values("id"), vector_search_result.sort_values("id"), check_dtype=False, diff --git a/tests/system/small/functions/test_remote_function.py b/tests/system/small/functions/test_remote_function.py index 805505ecd5..1ee60dafd6 100644 --- a/tests/system/small/functions/test_remote_function.py +++ b/tests/system/small/functions/test_remote_function.py @@ -34,7 +34,7 @@ from bigframes.functions import _utils as bff_utils from bigframes.functions import function as bff import bigframes.session._io.bigquery -from bigframes.testing.utils import assert_pandas_df_equal, get_function_name +from bigframes.testing.utils import assert_frame_equal, get_function_name _prefixer = test_utils.prefixer.Prefixer("bigframes", "") @@ -159,7 +159,7 @@ def square(x): pd_result_col = pd_result_col.astype(pd.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -208,7 +208,7 @@ def square(x): pd_result_col = pd_result_col.astype(pd.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -300,7 +300,7 @@ def square(x): pd_result_col = pd_result_col.astype(pd.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -388,7 +388,7 @@ def square(x): pd_result_col = pd_result_col.astype(pd.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -437,7 +437,7 @@ def square(x): pd_result_col = pd_result_col.astype(pd.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -482,7 +482,7 @@ def square(x): pd_result_col = pd_result_col.astype(pd.Int64Dtype()) pd_result = pd_int64_col_filtered.to_frame().assign(result=pd_result_col) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -517,7 +517,7 @@ def add_one(x): for col in pd_result: pd_result[col] = pd_result[col].astype(pd_int64_df_filtered[col].dtype) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -552,7 +552,7 @@ def add_one(x): for col in pd_result: pd_result[col] = pd_result[col].astype(pd_int64_df_filtered[col].dtype) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -585,7 +585,7 @@ def add_one(x): for col in pd_result: pd_result[col] = pd_result[col].astype(pd_int64_df[col].dtype) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -738,7 +738,7 @@ def square1(x): s2_result_col = int64_col_filtered.apply(square2) s2_result = int64_col_filtered.to_frame().assign(result=s2_result_col) - assert_pandas_df_equal(s1_result.to_pandas(), s2_result.to_pandas()) + assert_frame_equal(s1_result.to_pandas(), s2_result.to_pandas()) def test_read_gbq_function_runs_existing_udf(session): @@ -937,7 +937,7 @@ def test_read_gbq_function_reads_udfs(session, bigquery_client, dataset_id): indirect_df = indirect_df.assign(y=indirect_df.x.apply(square)) converted_indirect_df = indirect_df.to_pandas() - assert_pandas_df_equal( + assert_frame_equal( direct_df, converted_indirect_df, ignore_order=True, check_index_type=False ) diff --git a/tests/system/small/ml/test_cluster.py b/tests/system/small/ml/test_cluster.py index 4840329cda..2a5e979b30 100644 --- a/tests/system/small/ml/test_cluster.py +++ b/tests/system/small/ml/test_cluster.py @@ -16,7 +16,7 @@ from bigframes.ml import cluster import bigframes.pandas as bpd -from bigframes.testing.utils import assert_pandas_df_equal +from bigframes.testing.utils import assert_frame_equal _PD_NEW_PENGUINS = pd.DataFrame.from_dict( { @@ -71,7 +71,7 @@ def test_kmeans_predict(session, penguins_kmeans_model: cluster.KMeans): dtype="Int64", index=pd.Index(["test1", "test2", "test3", "test4"], dtype="string[pyarrow]"), ) - assert_pandas_df_equal(result, expected, ignore_order=True) + assert_frame_equal(result, expected, ignore_order=True) def test_kmeans_detect_anomalies( diff --git a/tests/system/small/ml/test_core.py b/tests/system/small/ml/test_core.py index ef62e5ddd3..9add4a4a53 100644 --- a/tests/system/small/ml/test_core.py +++ b/tests/system/small/ml/test_core.py @@ -233,7 +233,7 @@ def test_pca_model_principal_component_info(penguins_bqml_pca_model: core.BqmlMo "cumulative_explained_variance_ratio": [0.469357, 0.651283, 0.812383], }, ) - utils.assert_pandas_df_equal( + utils.assert_frame_equal( result, expected, check_exact=False, diff --git a/tests/system/small/ml/test_decomposition.py b/tests/system/small/ml/test_decomposition.py index 10255003a1..297ee49739 100644 --- a/tests/system/small/ml/test_decomposition.py +++ b/tests/system/small/ml/test_decomposition.py @@ -180,7 +180,7 @@ def test_pca_explained_variance_(penguins_pca_model: decomposition.PCA): "explained_variance": [3.278657, 1.270829, 1.125354], }, ) - bigframes.testing.utils.assert_pandas_df_equal( + bigframes.testing.utils.assert_frame_equal( result, expected, check_exact=False, @@ -200,7 +200,7 @@ def test_pca_explained_variance_ratio_(penguins_pca_model: decomposition.PCA): "explained_variance_ratio": [0.469357, 0.181926, 0.1611], }, ) - bigframes.testing.utils.assert_pandas_df_equal( + bigframes.testing.utils.assert_frame_equal( result, expected, check_exact=False, diff --git a/tests/system/small/regression/test_issue355_merge_after_filter.py b/tests/system/small/regression/test_issue355_merge_after_filter.py index 1c3b6e4fe3..d3486810f7 100644 --- a/tests/system/small/regression/test_issue355_merge_after_filter.py +++ b/tests/system/small/regression/test_issue355_merge_after_filter.py @@ -15,7 +15,7 @@ import pandas as pd import pytest -from bigframes.testing.utils import assert_pandas_df_equal +from bigframes.testing.utils import assert_frame_equal @pytest.mark.parametrize( @@ -67,4 +67,4 @@ def test_merge_after_filter(baseball_schedules_df, merge_how): sort=True, ) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index 6b3f9a2c13..d2a157b131 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -35,7 +35,7 @@ import bigframes.series as series from bigframes.testing.utils import ( assert_dfs_equivalent, - assert_pandas_df_equal, + assert_frame_equal, assert_series_equal, assert_series_equivalent, ) @@ -263,7 +263,7 @@ def test_get_rows_with_slice(scalars_dfs, row_slice): scalars_df, scalars_pandas_df = scalars_dfs bf_result = scalars_df[row_slice].to_pandas() pd_result = scalars_pandas_df[row_slice] - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_hasattr(scalars_dfs): @@ -290,7 +290,7 @@ def test_head_with_custom_column_labels( bf_df = scalars_df_index.rename(columns=rename_mapping).head(3) bf_result = bf_df.to_pandas(ordered=ordered) pd_result = scalars_pandas_df_index.rename(columns=rename_mapping).head(3) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) def test_tail_with_custom_column_labels(scalars_df_index, scalars_pandas_df_index): @@ -635,7 +635,7 @@ def test_drop_with_custom_column_labels(scalars_dfs): pd_result = scalars_pandas_df.rename(columns=rename_mapping).drop( columns=dropped_columns ) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_df_memory_usage(scalars_dfs): @@ -1025,7 +1025,7 @@ def test_take_df(scalars_dfs, indices, axis): bf_result = scalars_df.take(indices, axis=axis).to_pandas() pd_result = scalars_pandas_df.take(indices, axis=axis) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_filter_df(scalars_dfs): @@ -1037,7 +1037,7 @@ def test_filter_df(scalars_dfs): pd_bool_series = scalars_pandas_df["bool_col"] pd_result = scalars_pandas_df[pd_bool_series] - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_read_gbq_direct_to_batches_row_count(unordered_session): @@ -1058,7 +1058,7 @@ def test_df_to_pandas_batches(scalars_dfs): assert 6 == capped_unfiltered_batches.total_rows assert len(pd_result) == filtered_batches.total_rows - assert_pandas_df_equal(pd.concat(filtered_batches), pd_result) + assert_frame_equal(pd.concat(filtered_batches), pd_result) @pytest.mark.parametrize( @@ -1109,7 +1109,7 @@ def test_assign_new_column_w_literal(scalars_dfs, literal, expected_dtype): pd_result = scalars_pandas_df.assign(new_col=new_col_pd) pd_result["new_col"] = pd_result["new_col"].astype(expected_dtype) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_assign_new_column_w_loc(scalars_dfs): @@ -1313,7 +1313,7 @@ def test_assign_existing_column(scalars_dfs): # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. pd_result["int64_col"] = pd_result["int64_col"].astype("Int64") - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_assign_listlike_to_empty_df(session): @@ -1325,7 +1325,7 @@ def test_assign_listlike_to_empty_df(session): pd_result["new_col"] = pd_result["new_col"].astype("Int64") pd_result.index = pd_result.index.astype("Int64") - assert_pandas_df_equal(bf_result.to_pandas(), pd_result) + assert_frame_equal(bf_result.to_pandas(), pd_result) def test_assign_to_empty_df_multiindex_error(session): @@ -1359,7 +1359,7 @@ def test_assign_series(scalars_dfs, ordered): bf_result = df.to_pandas(ordered=ordered) pd_result = scalars_pandas_df.assign(new_col=scalars_pandas_df[column_name]) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) def test_assign_series_overwrite(scalars_dfs): @@ -1371,7 +1371,7 @@ def test_assign_series_overwrite(scalars_dfs): **{column_name: scalars_pandas_df[column_name] + 3} ) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_assign_sequential(scalars_dfs): @@ -1386,7 +1386,7 @@ def test_assign_sequential(scalars_dfs): pd_result["new_col"] = pd_result["new_col"].astype("Int64") pd_result["new_col2"] = pd_result["new_col2"].astype("Int64") - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) # Require an index so that the self-join is consistent each time. @@ -1420,7 +1420,7 @@ def test_assign_different_df( new_col=scalars_pandas_df_index[column_name] ) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_assign_different_df_w_loc( @@ -1471,7 +1471,7 @@ def test_assign_callable_lambda(scalars_dfs): # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. pd_result["new_col"] = pd_result["new_col"].astype("Int64") - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -1868,9 +1868,7 @@ def test_df_merge(scalars_dfs, merge_how): sort=True, ) - assert_pandas_df_equal( - bf_result, pd_result, ignore_order=True, check_index_type=False - ) + assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) @pytest.mark.parametrize( @@ -1903,9 +1901,7 @@ def test_df_merge_multi_key(scalars_dfs, left_on, right_on): sort=True, ) - assert_pandas_df_equal( - bf_result, pd_result, ignore_order=True, check_index_type=False - ) + assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) @pytest.mark.parametrize( @@ -1935,9 +1931,7 @@ def test_merge_custom_col_name(scalars_dfs, merge_how): pandas_right_df = scalars_pandas_df[right_columns] pd_result = pandas_left_df.merge(pandas_right_df, merge_how, on, sort=True) - assert_pandas_df_equal( - bf_result, pd_result, ignore_order=True, check_index_type=False - ) + assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) @pytest.mark.parametrize( @@ -1970,9 +1964,7 @@ def test_merge_left_on_right_on(scalars_dfs, merge_how): sort=True, ) - assert_pandas_df_equal( - bf_result, pd_result, ignore_order=True, check_index_type=False - ) + assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) def test_self_merge_self_w_on_args(): @@ -2014,7 +2006,7 @@ def test_dataframe_round(scalars_dfs, decimals): bf_result = scalars_df.round(decimals).to_pandas() pd_result = scalars_pandas_df.round(decimals) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_get_dtypes(scalars_df_default_index): @@ -2496,7 +2488,7 @@ def test_df_pos(scalars_dfs): bf_result = (+scalars_df[["int64_col", "numeric_col"]]).to_pandas() pd_result = +scalars_pandas_df[["int64_col", "numeric_col"]] - assert_pandas_df_equal(pd_result, bf_result) + assert_frame_equal(pd_result, bf_result) def test_df_neg(scalars_dfs): @@ -2504,7 +2496,7 @@ def test_df_neg(scalars_dfs): bf_result = (-scalars_df[["int64_col", "numeric_col"]]).to_pandas() pd_result = -scalars_pandas_df[["int64_col", "numeric_col"]] - assert_pandas_df_equal(pd_result, bf_result) + assert_frame_equal(pd_result, bf_result) def test_df__abs__(scalars_dfs): @@ -2514,7 +2506,7 @@ def test_df__abs__(scalars_dfs): ).to_pandas() pd_result = abs(scalars_pandas_df[["int64_col", "numeric_col", "float64_col"]]) - assert_pandas_df_equal(pd_result, bf_result) + assert_frame_equal(pd_result, bf_result) def test_df_invert(scalars_dfs): @@ -2524,7 +2516,7 @@ def test_df_invert(scalars_dfs): bf_result = (~scalars_df[columns]).to_pandas() pd_result = ~scalars_pandas_df[columns] - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_df_isnull(scalars_dfs): @@ -2541,7 +2533,7 @@ def test_df_isnull(scalars_dfs): pd_result["string_col"] = pd_result["string_col"].astype(pd.BooleanDtype()) pd_result["bool_col"] = pd_result["bool_col"].astype(pd.BooleanDtype()) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_df_notnull(scalars_dfs): @@ -2558,7 +2550,7 @@ def test_df_notnull(scalars_dfs): pd_result["string_col"] = pd_result["string_col"].astype(pd.BooleanDtype()) pd_result["bool_col"] = pd_result["bool_col"].astype(pd.BooleanDtype()) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -2931,7 +2923,7 @@ def test_scalar_binop(scalars_dfs, op, other_scalar, reverse_operands): bf_result = maybe_reversed_op(scalars_df[columns], other_scalar).to_pandas() pd_result = maybe_reversed_op(scalars_pandas_df[columns], other_scalar) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_dataframe_string_radd_const(scalars_dfs): @@ -2947,7 +2939,7 @@ def test_dataframe_string_radd_const(scalars_dfs): bf_result = ("prefix" + scalars_df[columns]).to_pandas() pd_result = "prefix" + scalars_pandas_df[columns] - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize(("other_scalar"), [1, -2]) @@ -2959,7 +2951,7 @@ def test_mod(scalars_dfs, other_scalar): bf_result = (scalars_df[["int64_col", "int64_too"]] % other_scalar).to_pandas() pd_result = scalars_pandas_df[["int64_col", "int64_too"]] % other_scalar - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_scalar_binop_str_exception(scalars_dfs): @@ -3015,7 +3007,7 @@ def test_series_binop_axis_index( bf_result = op(scalars_df[df_columns], scalars_df[series_column]).to_pandas() pd_result = op(scalars_pandas_df[df_columns], scalars_pandas_df[series_column]) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -3043,7 +3035,7 @@ def test_listlike_binop_axis_1_in_memory_data(scalars_dfs, input): input = input.to_pandas() pd_result = scalars_pandas_df[df_columns].add(input, axis=1) - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) def test_df_reverse_binop_pandas(scalars_dfs): @@ -3058,7 +3050,7 @@ def test_df_reverse_binop_pandas(scalars_dfs): bf_result = pd_series + scalars_df[df_columns].to_pandas() pd_result = pd_series + scalars_pandas_df[df_columns] - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) def test_listlike_binop_axis_1_bf_index(scalars_dfs): @@ -3073,7 +3065,7 @@ def test_listlike_binop_axis_1_bf_index(scalars_dfs): ) pd_result = scalars_pandas_df[df_columns].add(pd.Index([1000, 2000, 3000]), axis=1) - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) def test_binop_with_self_aggregate(scalars_dfs_maybe_ordered): @@ -3093,7 +3085,7 @@ def test_binop_with_self_aggregate(scalars_dfs_maybe_ordered): executions = execution_count_after - execution_count_before assert executions == 1 - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) def test_binop_with_self_aggregate_w_index_reset(scalars_dfs_maybe_ordered): @@ -3114,9 +3106,7 @@ def test_binop_with_self_aggregate_w_index_reset(scalars_dfs_maybe_ordered): assert executions == 1 pd_result.index = pd_result.index.astype("Int64") - assert_pandas_df_equal( - bf_result, pd_result, check_dtype=False, check_index_type=False - ) + assert_frame_equal(bf_result, pd_result, check_dtype=False, check_index_type=False) @pytest.mark.parametrize( @@ -3184,7 +3174,7 @@ def test_series_binop_add_different_table( scalars_pandas_df_index[series_column], axis="index" ) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) # TODO(garrettwu): Test series binop with different index @@ -3217,7 +3207,7 @@ def test_join_same_table(scalars_dfs_maybe_ordered, how): pd_result = pd_df_a.join(pd_df_b, how=how) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) def test_join_incompatible_key_type_error(scalars_dfs): @@ -3245,7 +3235,7 @@ def test_join_different_table( pd_df_a = scalars_pandas_df_index[["string_col", "int64_col"]] pd_df_b = scalars_pandas_df_index.dropna()[["float64_col"]] pd_result = pd_df_a.join(pd_df_b, how=how) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) @all_joins @@ -3364,7 +3354,7 @@ def test_join_param_on(scalars_dfs, how): pd_df_a = pd_df_a.assign(rowindex_2=pd_df_a["rowindex_2"] + 2) pd_df_b = pd_df[["float64_col"]] pd_result = pd_df_a.join(pd_df_b, on="rowindex_2", how=how) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) @all_joins @@ -3385,7 +3375,7 @@ def test_df_join_series(scalars_dfs, how): pd_df_a = pd_df_a.assign(rowindex_2=pd_df_a["rowindex_2"] + 2) pd_series_b = pd_df["float64_col"] pd_result = pd_df_a.join(pd_series_b, on="rowindex_2", how=how) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) @pytest.mark.parametrize( @@ -3548,7 +3538,8 @@ def test_dataframe_diff(scalars_df_index, scalars_pandas_df_index, periods): def test_dataframe_pct_change(scalars_df_index, scalars_pandas_df_index, periods): col_names = ["int64_too", "float64_col", "int64_col"] bf_result = scalars_df_index[col_names].pct_change(periods=periods).to_pandas() - pd_result = scalars_pandas_df_index[col_names].pct_change(periods=periods) + # pandas 3.0 does not automatically ffill anymore + pd_result = scalars_pandas_df_index[col_names].ffill().pct_change(periods=periods) pd.testing.assert_frame_equal( pd_result, bf_result, @@ -3664,7 +3655,7 @@ def test_df_transpose(): pd_result = pd_df.T bf_result = bf_df.T.to_pandas() - pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) + assert_frame_equal(pd_result, bf_result, check_dtype=False, nulls_are_nan=True) # type: ignore def test_df_transpose_error(): @@ -4012,7 +4003,7 @@ def test_iloc_slice_nested(scalars_df_index, scalars_pandas_df_index, ordered): bf_result = scalars_df_index.iloc[1:].iloc[1:].to_pandas(ordered=ordered) pd_result = scalars_pandas_df_index.iloc[1:].iloc[1:] - assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) @pytest.mark.parametrize( @@ -4377,10 +4368,8 @@ def test_dataframe_aggregates_axis_1(scalars_df_index, scalars_pandas_df_index, bf_result = op(scalars_df_index[col_names]).to_pandas() pd_result = op(scalars_pandas_df_index[col_names]) - # Pandas may produce narrower numeric types, but bigframes always produces Float64 - pd_result = pd_result.astype("Float64") # Pandas has object index type - pd.testing.assert_series_equal(pd_result, bf_result, check_index_type=False) + assert_series_equal(pd_result, bf_result, check_index_type=False, check_dtype=False) def test_dataframe_aggregates_median(scalars_df_index, scalars_pandas_df_index): @@ -4678,7 +4667,7 @@ def test_df_rows_filter_items(scalars_df_index, scalars_pandas_df_index): # Pandas uses int64 instead of Int64 (nullable) dtype. pd_result.index = pd_result.index.astype(pd.Int64Dtype()) # Ignore ordering as pandas order differently depending on version - assert_pandas_df_equal( + assert_frame_equal( bf_result, pd_result, ignore_order=True, @@ -4950,7 +4939,7 @@ def test_df_setattr_index(): pd_df.index = pandas.Index([4, 5]) bf_df.index = [4, 5] - assert_pandas_df_equal( + assert_frame_equal( pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False ) @@ -4965,7 +4954,7 @@ def test_df_setattr_columns(): bf_df.columns = pandas.Index([4, 5, 6]) - assert_pandas_df_equal( + assert_frame_equal( pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False ) @@ -4978,7 +4967,7 @@ def test_df_setattr_modify_column(): pd_df.my_column = [4, 5] bf_df.my_column = [4, 5] - assert_pandas_df_equal( + assert_frame_equal( pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False ) @@ -5260,9 +5249,7 @@ def test_df_from_dict_columns_orient(): data = {"a": [1, 2], "b": [3.3, 2.4]} bf_result = dataframe.DataFrame.from_dict(data, orient="columns").to_pandas() pd_result = pd.DataFrame.from_dict(data, orient="columns") - assert_pandas_df_equal( - pd_result, bf_result, check_dtype=False, check_index_type=False - ) + assert_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) def test_df_from_dict_index_orient(): @@ -5271,9 +5258,7 @@ def test_df_from_dict_index_orient(): data, orient="index", columns=["col1", "col2"] ).to_pandas() pd_result = pd.DataFrame.from_dict(data, orient="index", columns=["col1", "col2"]) - assert_pandas_df_equal( - pd_result, bf_result, check_dtype=False, check_index_type=False - ) + assert_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) def test_df_from_dict_tight_orient(): @@ -5287,9 +5272,7 @@ def test_df_from_dict_tight_orient(): bf_result = dataframe.DataFrame.from_dict(data, orient="tight").to_pandas() pd_result = pd.DataFrame.from_dict(data, orient="tight") - assert_pandas_df_equal( - pd_result, bf_result, check_dtype=False, check_index_type=False - ) + assert_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) def test_df_from_records(): @@ -5299,9 +5282,7 @@ def test_df_from_records(): records, columns=["c1", "c2"] ).to_pandas() pd_result = pd.DataFrame.from_records(records, columns=["c1", "c2"]) - assert_pandas_df_equal( - pd_result, bf_result, check_dtype=False, check_index_type=False - ) + assert_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) def test_df_to_dict(scalars_df_index, scalars_pandas_df_index): @@ -5648,7 +5629,7 @@ def test_assign_after_binop_row_joins(): bf_df["metric_diff"] = bf_df.metric1 - bf_df.metric2 pd_df["metric_diff"] = pd_df.metric1 - pd_df.metric2 - assert_pandas_df_equal(bf_df.to_pandas(), pd_df) + assert_frame_equal(bf_df.to_pandas(), pd_df) def test_df_cache_with_implicit_join(scalars_df_index): @@ -5817,7 +5798,7 @@ def test_query_complexity_repeated_joins( bf_result = bf_df.to_pandas() pd_result = pd_df - assert_pandas_df_equal(bf_result, pd_result, check_index_type=False) + assert_frame_equal(bf_result, pd_result, check_index_type=False) def test_query_complexity_repeated_subtrees( @@ -5831,7 +5812,7 @@ def test_query_complexity_repeated_subtrees( bf_df = bpd.concat(10 * [bf_df]).head(5) bf_result = bf_df.to_pandas() pd_result = pd_df - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.skipif( @@ -5848,7 +5829,7 @@ def test_query_complexity_repeated_analytic(scalars_df_index, scalars_pandas_df_ pd_df = pd_df.diff() bf_result = bf_df.to_pandas() pd_result = pd_df - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_to_gbq_and_create_dataset(session, scalars_df_index, dataset_id_not_created): @@ -6049,7 +6030,7 @@ def test_resample_with_index( .resample(rule=rule, level=level, closed=closed, origin=origin, label=label) .min() ) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( diff --git a/tests/system/small/test_dataframe_io.py b/tests/system/small/test_dataframe_io.py index 4d4a144d0a..02acb8d8f2 100644 --- a/tests/system/small/test_dataframe_io.py +++ b/tests/system/small/test_dataframe_io.py @@ -1126,7 +1126,7 @@ def test_to_sql_query_unnamed_index_included( ) roundtrip = session.read_gbq(sql, index_col=idx_ids) roundtrip.index.names = [None] - utils.assert_pandas_df_equal(roundtrip.to_pandas(), pd_df, check_index_type=False) + utils.assert_frame_equal(roundtrip.to_pandas(), pd_df, check_index_type=False) def test_to_sql_query_named_index_included( @@ -1147,7 +1147,7 @@ def test_to_sql_query_named_index_included( columns="duration_col" ) roundtrip = session.read_gbq(sql, index_col=idx_ids) - utils.assert_pandas_df_equal(roundtrip.to_pandas(), pd_df) + utils.assert_frame_equal(roundtrip.to_pandas(), pd_df) def test_to_sql_query_unnamed_index_excluded( @@ -1164,7 +1164,7 @@ def test_to_sql_query_unnamed_index_excluded( columns="duration_col" ) roundtrip = session.read_gbq(sql) - utils.assert_pandas_df_equal( + utils.assert_frame_equal( roundtrip.to_pandas(), pd_df, check_index_type=False, ignore_order=True ) @@ -1187,7 +1187,7 @@ def test_to_sql_query_named_index_excluded( .drop(columns="duration_col") ) roundtrip = session.read_gbq(sql) - utils.assert_pandas_df_equal( + utils.assert_frame_equal( roundtrip.to_pandas(), pd_df, check_index_type=False, ignore_order=True ) diff --git a/tests/system/small/test_groupby.py b/tests/system/small/test_groupby.py index 2e09ffd1a6..579e7cd414 100644 --- a/tests/system/small/test_groupby.py +++ b/tests/system/small/test_groupby.py @@ -17,7 +17,7 @@ import pytest import bigframes.pandas as bpd -from bigframes.testing.utils import assert_pandas_df_equal +from bigframes.testing.utils import assert_frame_equal # ================= # DataFrame.groupby @@ -205,7 +205,7 @@ def test_dataframe_groupby_agg_string( pd_result = scalars_pandas_df_index[col_names].groupby("string_col").agg("count") bf_result_computed = bf_result.to_pandas(ordered=ordered) - assert_pandas_df_equal( + assert_frame_equal( pd_result, bf_result_computed, check_dtype=False, ignore_order=not ordered ) @@ -509,7 +509,7 @@ def test_dataframe_groupby_diff(scalars_df_index, scalars_pandas_df_index, order pd_result = scalars_pandas_df_index[col_names].groupby("string_col").diff(-1) bf_result_computed = bf_result.to_pandas(ordered=ordered) - assert_pandas_df_equal( + assert_frame_equal( pd_result, bf_result_computed, check_dtype=False, ignore_order=not ordered ) diff --git a/tests/system/small/test_large_local_data.py b/tests/system/small/test_large_local_data.py index 0c03a8b6a3..39885ea853 100644 --- a/tests/system/small/test_large_local_data.py +++ b/tests/system/small/test_large_local_data.py @@ -17,7 +17,7 @@ import pytest import bigframes -from bigframes.testing.utils import assert_pandas_df_equal +from bigframes.testing.utils import assert_frame_equal large_dataframe = pd.DataFrame(np.random.rand(10000, 10), dtype="Float64") large_dataframe.index = large_dataframe.index.astype("Int64") @@ -27,7 +27,7 @@ def test_read_pandas_defer_noop(session: bigframes.Session): pytest.importorskip("pandas", minversion="2.0.0") bf_df = session.read_pandas(large_dataframe, write_engine="_deferred") - assert_pandas_df_equal(large_dataframe, bf_df.to_pandas()) + assert_frame_equal(large_dataframe, bf_df.to_pandas()) def test_read_pandas_defer_cumsum(session: bigframes.Session): @@ -35,7 +35,7 @@ def test_read_pandas_defer_cumsum(session: bigframes.Session): bf_df = session.read_pandas(large_dataframe, write_engine="_deferred") bf_df = bf_df.cumsum() - assert_pandas_df_equal(large_dataframe.cumsum(), bf_df.to_pandas()) + assert_frame_equal(large_dataframe.cumsum(), bf_df.to_pandas()) def test_read_pandas_defer_cache_cumsum_cumsum(session: bigframes.Session): @@ -43,7 +43,7 @@ def test_read_pandas_defer_cache_cumsum_cumsum(session: bigframes.Session): bf_df = session.read_pandas(large_dataframe, write_engine="_deferred") bf_df = bf_df.cumsum().cache().cumsum() - assert_pandas_df_equal(large_dataframe.cumsum().cumsum(), bf_df.to_pandas()) + assert_frame_equal(large_dataframe.cumsum().cumsum(), bf_df.to_pandas()) def test_read_pandas_defer_peek(session: bigframes.Session): @@ -52,4 +52,4 @@ def test_read_pandas_defer_peek(session: bigframes.Session): bf_result = bf_df.peek(15) assert len(bf_result) == 15 - assert_pandas_df_equal(large_dataframe.loc[bf_result.index], bf_result) + assert_frame_equal(large_dataframe.loc[bf_result.index], bf_result) diff --git a/tests/system/small/test_multiindex.py b/tests/system/small/test_multiindex.py index 4233ed7aae..a28e02a54f 100644 --- a/tests/system/small/test_multiindex.py +++ b/tests/system/small/test_multiindex.py @@ -17,7 +17,7 @@ import pytest import bigframes.pandas as bpd -from bigframes.testing.utils import assert_pandas_df_equal +from bigframes.testing.utils import assert_frame_equal # Sample MultiIndex for testing DataFrames where() method. _MULTI_INDEX = pandas.MultiIndex.from_tuples( @@ -583,7 +583,7 @@ def test_multi_index_dataframe_join(scalars_dfs, how): (["bool_col", "rowindex_2"]) )[["float64_col"]] pd_result = pd_df_a.join(pd_df_b, how=how) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) @all_joins @@ -604,7 +604,7 @@ def test_multi_index_dataframe_join_on(scalars_dfs, how): pd_df_a = pd_df_a.assign(rowindex_2=pd_df_a["rowindex_2"] + 2) pd_df_b = pd_df[["float64_col"]] pd_result = pd_df_a.join(pd_df_b, on="rowindex_2", how=how) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) def test_multi_index_dataframe_where_series_cond_none_other( diff --git a/tests/system/small/test_pandas.py b/tests/system/small/test_pandas.py index e3c5ace8a9..a1c0dc9851 100644 --- a/tests/system/small/test_pandas.py +++ b/tests/system/small/test_pandas.py @@ -21,7 +21,7 @@ import pytz import bigframes.pandas as bpd -from bigframes.testing.utils import assert_pandas_df_equal +from bigframes.testing.utils import assert_frame_equal @pytest.mark.parametrize( @@ -37,7 +37,7 @@ def test_concat_dataframe(scalars_dfs, ordered): bf_result = bf_result.to_pandas(ordered=ordered) pd_result = pd.concat(11 * [scalars_pandas_df]) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) def test_concat_dataframe_w_struct_cols(nested_structs_df, nested_structs_pandas_df): @@ -306,7 +306,7 @@ def test_merge(scalars_dfs, merge_how): sort=True, ) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) @pytest.mark.parametrize( @@ -340,7 +340,7 @@ def test_merge_left_on_right_on(scalars_dfs, merge_how): sort=True, ) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) def test_merge_cross(scalars_dfs): @@ -395,7 +395,7 @@ def test_merge_series(scalars_dfs, merge_how): sort=True, ) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) def test_merge_w_common_columns(scalars_dfs): @@ -413,7 +413,7 @@ def test_merge_w_common_columns(scalars_dfs): "inner", sort=True, ) - assert_pandas_df_equal(df.to_pandas(), pd_result, ignore_order=True) + assert_frame_equal(df.to_pandas(), pd_result, ignore_order=True) def test_merge_raises_error_when_no_common_columns(scalars_dfs): @@ -460,7 +460,7 @@ def test_crosstab_aligned_series(scalars_dfs): scalars_df["int64_col"], scalars_df["int64_too"] ).to_pandas() - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) def test_crosstab_nondefault_func(scalars_dfs): @@ -479,7 +479,7 @@ def test_crosstab_nondefault_func(scalars_dfs): aggfunc="mean", ).to_pandas() - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) def test_crosstab_multi_cols(scalars_dfs): @@ -498,7 +498,7 @@ def test_crosstab_multi_cols(scalars_dfs): colnames=["c", "d"], ).to_pandas() - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) def test_crosstab_unaligned_series(scalars_dfs, session): @@ -513,7 +513,7 @@ def test_crosstab_unaligned_series(scalars_dfs, session): pd_result = pd.crosstab(scalars_pandas_df["int64_col"], other_pd_series) bf_result = bpd.crosstab(scalars_df["int64_col"], other_bf_series).to_pandas() - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) def _convert_pandas_category(pd_s: pd.Series): diff --git a/tests/system/small/test_polars_execution.py b/tests/system/small/test_polars_execution.py index 46eb59260b..1b58dc9d12 100644 --- a/tests/system/small/test_polars_execution.py +++ b/tests/system/small/test_polars_execution.py @@ -17,7 +17,7 @@ import bigframes import bigframes.bigquery -from bigframes.testing.utils import assert_pandas_df_equal +from bigframes.testing.utils import assert_frame_equal polars = pytest.importorskip("polars") @@ -40,7 +40,7 @@ def test_polar_execution_sorted(session_w_polars, scalars_pandas_df_index): bf_result = bf_df.sort_index(ascending=False)[["int64_too", "bool_col"]].to_pandas() assert session_w_polars._metrics.execution_count == execution_count_before - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_polar_execution_sorted_filtered(session_w_polars, scalars_pandas_df_index): @@ -57,7 +57,7 @@ def test_polar_execution_sorted_filtered(session_w_polars, scalars_pandas_df_ind ) assert session_w_polars._metrics.execution_count == execution_count_before - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_polar_execution_unsupported_sql_fallback( diff --git a/tests/system/small/test_series.py b/tests/system/small/test_series.py index 6c681596f5..a95c9623e5 100644 --- a/tests/system/small/test_series.py +++ b/tests/system/small/test_series.py @@ -33,7 +33,7 @@ import bigframes.pandas import bigframes.series as series from bigframes.testing.utils import ( - assert_pandas_df_equal, + assert_frame_equal, assert_series_equal, get_first_file_from_wildcard, ) @@ -801,6 +801,8 @@ def test_series_replace_dict(scalars_dfs, replacement_dict): ) def test_series_interpolate(method): pytest.importorskip("scipy") + if method == "pad" and pd.__version__.startswith("3."): + pytest.skip("pandas 3.0 dropped method='pad'") values = [None, 1, 2, None, None, 16, None] index = [-3.2, 11.4, 3.56, 4, 4.32, 5.55, 76.8] @@ -813,11 +815,12 @@ def test_series_interpolate(method): bf_result = bf_series.interpolate(method=method).to_pandas() # pd uses non-null types, while bf uses nullable types - pd.testing.assert_series_equal( + assert_series_equal( pd_result, bf_result, check_index_type=False, check_dtype=False, + nulls_are_nan=True, ) @@ -1771,7 +1774,7 @@ def test_take(scalars_dfs, indices): bf_result = scalars_df.take(indices).to_pandas() pd_result = scalars_pandas_df.take(indices) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_nested_filter(scalars_dfs): @@ -2730,7 +2733,7 @@ def test_diff(scalars_df_index, scalars_pandas_df_index, periods): def test_series_pct_change(scalars_df_index, scalars_pandas_df_index, periods): bf_result = scalars_df_index["int64_col"].pct_change(periods=periods).to_pandas() # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA - pd_result = scalars_pandas_df_index["int64_col"].pct_change(periods=periods) + pd_result = scalars_pandas_df_index["int64_col"].ffill().pct_change(periods=periods) pd.testing.assert_series_equal( bf_result, @@ -3420,7 +3423,7 @@ def test_to_frame(scalars_dfs): bf_result = scalars_df["int64_col"].to_frame().to_pandas() pd_result = scalars_pandas_df["int64_col"].to_frame() - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_to_frame_no_name(scalars_dfs): @@ -3429,7 +3432,7 @@ def test_to_frame_no_name(scalars_dfs): bf_result = scalars_df["int64_col"].rename(None).to_frame().to_pandas() pd_result = scalars_pandas_df["int64_col"].rename(None).to_frame() - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_to_json(gcs_folder, scalars_df_index, scalars_pandas_df_index): @@ -3673,7 +3676,7 @@ def test_mask_default_value(scalars_dfs): pd_col_masked = pd_col.mask(pd_col % 2 == 1) pd_result = pd_col.to_frame().assign(int64_col_masked=pd_col_masked) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_mask_custom_value(scalars_dfs): @@ -3691,7 +3694,7 @@ def test_mask_custom_value(scalars_dfs): # odd so should be left as is, but it is being masked in pandas. # Accidentally the bigframes bahavior matches, but it should be updated # after the resolution of https://github.com/pandas-dev/pandas/issues/52955 - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_mask_with_callable(scalars_df_index, scalars_pandas_df_index): @@ -4142,7 +4145,7 @@ def test_loc_bool_series_default_index( scalars_pandas_df_default_index.bool_col ] - assert_pandas_df_equal( + assert_frame_equal( bf_result.to_frame(), pd_result.to_frame(), ) diff --git a/tests/system/small/test_unordered.py b/tests/system/small/test_unordered.py index 07fdb215df..c7ff0ca1dd 100644 --- a/tests/system/small/test_unordered.py +++ b/tests/system/small/test_unordered.py @@ -19,7 +19,7 @@ import bigframes.exceptions import bigframes.pandas as bpd -from bigframes.testing.utils import assert_pandas_df_equal, assert_series_equal +from bigframes.testing.utils import assert_frame_equal, assert_series_equal def test_unordered_mode_sql_no_hash(unordered_session): @@ -48,7 +48,7 @@ def test_unordered_mode_cache_aggregate(unordered_session): bf_result = mean_diff.to_pandas(ordered=False) pd_result = pd_df - pd_df.mean() - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) # type: ignore def test_unordered_mode_series_peek(unordered_session): @@ -103,7 +103,7 @@ def test_unordered_mode_read_gbq(unordered_session): } ) # Don't need ignore_order as there is only 1 row - assert_pandas_df_equal(df.to_pandas(), expected, check_index_type=False) + assert_frame_equal(df.to_pandas(), expected, check_index_type=False) @pytest.mark.parametrize( @@ -124,7 +124,7 @@ def test_unordered_drop_duplicates(unordered_session, keep): bf_result = bf_df.drop_duplicates(keep=keep) pd_result = pd_df.drop_duplicates(keep=keep) - assert_pandas_df_equal(bf_result.to_pandas(), pd_result, ignore_order=True) + assert_frame_equal(bf_result.to_pandas(), pd_result, ignore_order=True) def test_unordered_reset_index(unordered_session): @@ -134,7 +134,7 @@ def test_unordered_reset_index(unordered_session): bf_result = bf_df.set_index("b").reset_index(drop=False) pd_result = pd_df.set_index("b").reset_index(drop=False) - assert_pandas_df_equal(bf_result.to_pandas(), pd_result) + assert_frame_equal(bf_result.to_pandas(), pd_result) def test_unordered_merge(unordered_session): @@ -146,7 +146,7 @@ def test_unordered_merge(unordered_session): bf_result = bf_df.merge(bf_df, left_on="a", right_on="c") pd_result = pd_df.merge(pd_df, left_on="a", right_on="c") - assert_pandas_df_equal(bf_result.to_pandas(), pd_result, ignore_order=True) + assert_frame_equal(bf_result.to_pandas(), pd_result, ignore_order=True) def test_unordered_drop_duplicates_ambiguous(unordered_session): @@ -167,7 +167,7 @@ def test_unordered_drop_duplicates_ambiguous(unordered_session): .drop_duplicates() ) - assert_pandas_df_equal(bf_result.to_pandas(), pd_result, ignore_order=True) + assert_frame_equal(bf_result.to_pandas(), pd_result, ignore_order=True) def test_unordered_mode_cache_preserves_order(unordered_session): @@ -181,7 +181,7 @@ def test_unordered_mode_cache_preserves_order(unordered_session): pd_result = pd_df.sort_values("b") # B is unique so unstrict order mode result here should be equivalent to strictly ordered - assert_pandas_df_equal(bf_result, pd_result, ignore_order=False) + assert_frame_equal(bf_result, pd_result, ignore_order=False) def test_unordered_mode_no_ordering_error(unordered_session): diff --git a/tests/unit/test_dataframe_polars.py b/tests/unit/test_dataframe_polars.py index 39dbacd087..b8d251c88e 100644 --- a/tests/unit/test_dataframe_polars.py +++ b/tests/unit/test_dataframe_polars.py @@ -32,7 +32,7 @@ import bigframes.series as series from bigframes.testing.utils import ( assert_dfs_equivalent, - assert_pandas_df_equal, + assert_frame_equal, assert_series_equal, assert_series_equivalent, convert_pandas_dtypes, @@ -226,7 +226,7 @@ def test_get_rows_with_slice(scalars_dfs, row_slice): scalars_df, scalars_pandas_df = scalars_dfs bf_result = scalars_df[row_slice].to_pandas() pd_result = scalars_pandas_df[row_slice] - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_hasattr(scalars_dfs): @@ -253,7 +253,7 @@ def test_head_with_custom_column_labels( bf_df = scalars_df_index.rename(columns=rename_mapping).head(3) bf_result = bf_df.to_pandas(ordered=ordered) pd_result = scalars_pandas_df_index.rename(columns=rename_mapping).head(3) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) def test_tail_with_custom_column_labels(scalars_df_index, scalars_pandas_df_index): @@ -492,7 +492,7 @@ def test_drop_with_custom_column_labels(scalars_dfs): pd_result = scalars_pandas_df.rename(columns=rename_mapping).drop( columns=dropped_columns ) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_df_memory_usage(scalars_dfs): @@ -800,7 +800,7 @@ def test_take_df(scalars_dfs, indices, axis): bf_result = scalars_df.take(indices, axis=axis).to_pandas() pd_result = scalars_pandas_df.take(indices, axis=axis) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_filter_df(scalars_dfs): @@ -812,7 +812,7 @@ def test_filter_df(scalars_dfs): pd_bool_series = scalars_pandas_df["bool_col"] pd_result = scalars_pandas_df[pd_bool_series] - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_assign_new_column(scalars_dfs): @@ -825,7 +825,7 @@ def test_assign_new_column(scalars_dfs): # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. pd_result["new_col"] = pd_result["new_col"].astype("Int64") - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_assign_new_column_w_loc(scalars_dfs): @@ -963,7 +963,7 @@ def test_assign_existing_column(scalars_dfs): # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. pd_result["int64_col"] = pd_result["int64_col"].astype("Int64") - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_assign_listlike_to_empty_df(session): @@ -975,7 +975,7 @@ def test_assign_listlike_to_empty_df(session): pd_result["new_col"] = pd_result["new_col"].astype("Int64") pd_result.index = pd_result.index.astype("Int64") - assert_pandas_df_equal(bf_result.to_pandas(), pd_result) + assert_frame_equal(bf_result.to_pandas(), pd_result) def test_assign_to_empty_df_multiindex_error(session): @@ -1009,7 +1009,7 @@ def test_assign_series(scalars_dfs, ordered): bf_result = df.to_pandas(ordered=ordered) pd_result = scalars_pandas_df.assign(new_col=scalars_pandas_df[column_name]) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) def test_assign_series_overwrite(scalars_dfs): @@ -1021,7 +1021,7 @@ def test_assign_series_overwrite(scalars_dfs): **{column_name: scalars_pandas_df[column_name] + 3} ) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_assign_sequential(scalars_dfs): @@ -1036,7 +1036,7 @@ def test_assign_sequential(scalars_dfs): pd_result["new_col"] = pd_result["new_col"].astype("Int64") pd_result["new_col2"] = pd_result["new_col2"].astype("Int64") - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) # Require an index so that the self-join is consistent each time. @@ -1070,7 +1070,7 @@ def test_assign_different_df( new_col=scalars_pandas_df_index[column_name] ) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_assign_different_df_w_loc( @@ -1121,7 +1121,7 @@ def test_assign_callable_lambda(scalars_dfs): # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. pd_result["new_col"] = pd_result["new_col"].astype("Int64") - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -1396,9 +1396,7 @@ def test_df_merge(scalars_dfs, merge_how): sort=True, ) - assert_pandas_df_equal( - bf_result, pd_result, ignore_order=True, check_index_type=False - ) + assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) @pytest.mark.parametrize( @@ -1432,9 +1430,7 @@ def test_df_merge_multi_key(scalars_dfs, left_on, right_on): sort=True, ) - assert_pandas_df_equal( - bf_result, pd_result, ignore_order=True, check_index_type=False - ) + assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) @pytest.mark.parametrize( @@ -1464,9 +1460,7 @@ def test_merge_custom_col_name(scalars_dfs, merge_how): pandas_right_df = scalars_pandas_df[right_columns] pd_result = pandas_left_df.merge(pandas_right_df, merge_how, on, sort=True) - assert_pandas_df_equal( - bf_result, pd_result, ignore_order=True, check_index_type=False - ) + assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) @pytest.mark.parametrize( @@ -1499,9 +1493,7 @@ def test_merge_left_on_right_on(scalars_dfs, merge_how): sort=True, ) - assert_pandas_df_equal( - bf_result, pd_result, ignore_order=True, check_index_type=False - ) + assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) def test_shape(scalars_dfs): @@ -1801,7 +1793,7 @@ def test_df_pos(scalars_dfs): bf_result = (+scalars_df[["int64_col", "numeric_col"]]).to_pandas() pd_result = +scalars_pandas_df[["int64_col", "numeric_col"]] - assert_pandas_df_equal(pd_result, bf_result) + assert_frame_equal(pd_result, bf_result) def test_df_neg(scalars_dfs): @@ -1809,7 +1801,7 @@ def test_df_neg(scalars_dfs): bf_result = (-scalars_df[["int64_col", "numeric_col"]]).to_pandas() pd_result = -scalars_pandas_df[["int64_col", "numeric_col"]] - assert_pandas_df_equal(pd_result, bf_result) + assert_frame_equal(pd_result, bf_result) def test_df_invert(scalars_dfs): @@ -1819,7 +1811,7 @@ def test_df_invert(scalars_dfs): bf_result = (~scalars_df[columns]).to_pandas() pd_result = ~scalars_pandas_df[columns] - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_df_isnull(scalars_dfs): @@ -1836,7 +1828,7 @@ def test_df_isnull(scalars_dfs): pd_result["string_col"] = pd_result["string_col"].astype(pd.BooleanDtype()) pd_result["bool_col"] = pd_result["bool_col"].astype(pd.BooleanDtype()) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_df_notnull(scalars_dfs): @@ -1853,7 +1845,7 @@ def test_df_notnull(scalars_dfs): pd_result["string_col"] = pd_result["string_col"].astype(pd.BooleanDtype()) pd_result["bool_col"] = pd_result["bool_col"].astype(pd.BooleanDtype()) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -2191,7 +2183,7 @@ def test_scalar_binop(scalars_dfs, op, other_scalar, reverse_operands): bf_result = maybe_reversed_op(scalars_df[columns], other_scalar).to_pandas() pd_result = maybe_reversed_op(scalars_pandas_df[columns], other_scalar) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize(("other_scalar"), [1, -2]) @@ -2203,7 +2195,7 @@ def test_mod(scalars_dfs, other_scalar): bf_result = (scalars_df[["int64_col", "int64_too"]] % other_scalar).to_pandas() pd_result = scalars_pandas_df[["int64_col", "int64_too"]] % other_scalar - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_scalar_binop_str_exception(scalars_dfs): @@ -2259,7 +2251,7 @@ def test_series_binop_axis_index( bf_result = op(scalars_df[df_columns], scalars_df[series_column]).to_pandas() pd_result = op(scalars_pandas_df[df_columns], scalars_pandas_df[series_column]) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -2287,7 +2279,7 @@ def test_listlike_binop_axis_1_in_memory_data(scalars_dfs, input): input = input.to_pandas() pd_result = scalars_pandas_df[df_columns].add(input, axis=1) - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) def test_df_reverse_binop_pandas(scalars_dfs): @@ -2302,7 +2294,7 @@ def test_df_reverse_binop_pandas(scalars_dfs): bf_result = pd_series + scalars_df[df_columns].to_pandas() pd_result = pd_series + scalars_pandas_df[df_columns] - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) def test_listlike_binop_axis_1_bf_index(scalars_dfs): @@ -2317,7 +2309,7 @@ def test_listlike_binop_axis_1_bf_index(scalars_dfs): ) pd_result = scalars_pandas_df[df_columns].add(pd.Index([1000, 2000, 3000]), axis=1) - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) def test_binop_with_self_aggregate(session, scalars_dfs): @@ -2331,7 +2323,7 @@ def test_binop_with_self_aggregate(session, scalars_dfs): pd_df = scalars_pandas_df[df_columns] pd_result = pd_df - pd_df.mean() - assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) + assert_frame_equal(bf_result, pd_result, check_dtype=False) @pytest.mark.parametrize( @@ -2399,7 +2391,7 @@ def test_series_binop_add_different_table( scalars_pandas_df_index[series_column], axis="index" ) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) # TODO(garrettwu): Test series binop with different index @@ -2434,7 +2426,7 @@ def test_join_same_table(scalars_dfs, how): pd_result = pd_df_a.join(pd_df_b, how=how) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) @all_joins @@ -2447,7 +2439,7 @@ def test_join_different_table( pd_df_a = scalars_pandas_df_index[["string_col", "int64_col"]] pd_df_b = scalars_pandas_df_index.dropna()[["float64_col"]] pd_result = pd_df_a.join(pd_df_b, how=how) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) @all_joins @@ -2504,7 +2496,7 @@ def test_join_param_on(scalars_dfs, how): pd_df_a = pd_df_a.assign(rowindex_2=pd_df_a["rowindex_2"] + 2) pd_df_b = pd_df[["float64_col"]] pd_result = pd_df_a.join(pd_df_b, on="rowindex_2", how=how) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) @all_joins @@ -2525,7 +2517,7 @@ def test_df_join_series(scalars_dfs, how): pd_df_a = pd_df_a.assign(rowindex_2=pd_df_a["rowindex_2"] + 2) pd_series_b = pd_df["float64_col"] pd_result = pd_df_a.join(pd_series_b, on="rowindex_2", how=how) - assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) + assert_frame_equal(bf_result, pd_result, ignore_order=True) @pytest.mark.parametrize( @@ -2688,7 +2680,8 @@ def test_dataframe_diff(scalars_df_index, scalars_pandas_df_index, periods): def test_dataframe_pct_change(scalars_df_index, scalars_pandas_df_index, periods): col_names = ["int64_too", "float64_col", "int64_col"] bf_result = scalars_df_index[col_names].pct_change(periods=periods).to_pandas() - pd_result = scalars_pandas_df_index[col_names].pct_change(periods=periods) + # pandas 3.0 does not automatically ffill anymore + pd_result = scalars_pandas_df_index[col_names].ffill().pct_change(periods=periods) pd.testing.assert_frame_equal( pd_result, bf_result, @@ -2804,7 +2797,7 @@ def test_df_transpose(): pd_result = pd_df.T bf_result = bf_df.T.to_pandas() - pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) + assert_frame_equal(pd_result, bf_result, check_dtype=False, nulls_are_nan=True) def test_df_transpose_error(): @@ -3030,7 +3023,7 @@ def test_iloc_slice_nested(scalars_df_index, scalars_pandas_df_index, ordered): bf_result = scalars_df_index.iloc[1:].iloc[1:].to_pandas(ordered=ordered) pd_result = scalars_pandas_df_index.iloc[1:].iloc[1:] - assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) + assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) @pytest.mark.parametrize( @@ -3387,9 +3380,8 @@ def test_dataframe_aggregates_axis_1(scalars_df_index, scalars_pandas_df_index, pd_result = op(scalars_pandas_df_index[col_names]) # Pandas may produce narrower numeric types, but bigframes always produces Float64 - pd_result = pd_result.astype("Float64") # Pandas has object index type - pd.testing.assert_series_equal(pd_result, bf_result, check_index_type=False) + assert_series_equal(pd_result, bf_result, check_index_type=False, check_dtype=False) @pytest.mark.parametrize( @@ -3799,7 +3791,7 @@ def test_df_setattr_index(): pd_df.index = pandas.Index([4, 5]) bf_df.index = [4, 5] - assert_pandas_df_equal( + assert_frame_equal( pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False ) @@ -3814,7 +3806,7 @@ def test_df_setattr_columns(): bf_df.columns = pandas.Index([4, 5, 6]) - assert_pandas_df_equal( + assert_frame_equal( pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False ) @@ -3827,7 +3819,7 @@ def test_df_setattr_modify_column(): pd_df.my_column = [4, 5] bf_df.my_column = [4, 5] - assert_pandas_df_equal( + assert_frame_equal( pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False ) @@ -4058,9 +4050,7 @@ def test_df_from_dict_columns_orient(): data = {"a": [1, 2], "b": [3.3, 2.4]} bf_result = dataframe.DataFrame.from_dict(data, orient="columns").to_pandas() pd_result = pd.DataFrame.from_dict(data, orient="columns") - assert_pandas_df_equal( - pd_result, bf_result, check_dtype=False, check_index_type=False - ) + assert_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) def test_df_from_dict_index_orient(): @@ -4069,9 +4059,7 @@ def test_df_from_dict_index_orient(): data, orient="index", columns=["col1", "col2"] ).to_pandas() pd_result = pd.DataFrame.from_dict(data, orient="index", columns=["col1", "col2"]) - assert_pandas_df_equal( - pd_result, bf_result, check_dtype=False, check_index_type=False - ) + assert_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) def test_df_from_dict_tight_orient(): @@ -4085,9 +4073,7 @@ def test_df_from_dict_tight_orient(): bf_result = dataframe.DataFrame.from_dict(data, orient="tight").to_pandas() pd_result = pd.DataFrame.from_dict(data, orient="tight") - assert_pandas_df_equal( - pd_result, bf_result, check_dtype=False, check_index_type=False - ) + assert_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) def test_df_from_records(): @@ -4097,9 +4083,7 @@ def test_df_from_records(): records, columns=["c1", "c2"] ).to_pandas() pd_result = pd.DataFrame.from_records(records, columns=["c1", "c2"]) - assert_pandas_df_equal( - pd_result, bf_result, check_dtype=False, check_index_type=False - ) + assert_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) def test_df_to_dict(scalars_df_index, scalars_pandas_df_index): @@ -4339,7 +4323,7 @@ def test_assign_after_binop_row_joins(): bf_df["metric_diff"] = bf_df.metric1 - bf_df.metric2 pd_df["metric_diff"] = pd_df.metric1 - pd_df.metric2 - assert_pandas_df_equal(bf_df.to_pandas(), pd_df) + assert_frame_equal(bf_df.to_pandas(), pd_df) def test_df_dot_inline(session): diff --git a/tests/unit/test_series_polars.py b/tests/unit/test_series_polars.py index cffeedea35..9f1a247250 100644 --- a/tests/unit/test_series_polars.py +++ b/tests/unit/test_series_polars.py @@ -38,7 +38,7 @@ import bigframes.pandas as bpd import bigframes.series as series from bigframes.testing.utils import ( - assert_pandas_df_equal, + assert_frame_equal, assert_series_equal, convert_pandas_dtypes, get_first_file_from_wildcard, @@ -798,6 +798,8 @@ def test_series_replace_dict(scalars_dfs, replacement_dict): ) def test_series_interpolate(method): pytest.importorskip("scipy") + if method == "pad" and pd.__version__.startswith("3."): + pytest.skip("pandas 3.0 dropped method='pad'") values = [None, 1, 2, None, None, 16, None] index = [-3.2, 11.4, 3.56, 4, 4.32, 5.55, 76.8] @@ -810,11 +812,12 @@ def test_series_interpolate(method): bf_result = bf_series.interpolate(method=method).to_pandas() # pd uses non-null types, while bf uses nullable types - pd.testing.assert_series_equal( + assert_series_equal( pd_result, bf_result, check_index_type=False, check_dtype=False, + nulls_are_nan=True, ) @@ -1783,7 +1786,7 @@ def test_take(scalars_dfs, indices): bf_result = scalars_df.take(indices).to_pandas() pd_result = scalars_pandas_df.take(indices) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_nested_filter(scalars_dfs): @@ -2739,12 +2742,9 @@ def test_diff(scalars_df_index, scalars_pandas_df_index, periods): def test_series_pct_change(scalars_df_index, scalars_pandas_df_index, periods): bf_result = scalars_df_index["int64_col"].pct_change(periods=periods).to_pandas() # cumsum does not behave well on nullable ints in pandas, produces object type and never ignores NA - pd_result = scalars_pandas_df_index["int64_col"].pct_change(periods=periods) + pd_result = scalars_pandas_df_index["int64_col"].ffill().pct_change(periods=periods) - pd.testing.assert_series_equal( - bf_result, - pd_result, - ) + assert_series_equal(bf_result, pd_result, nulls_are_nan=True) @pytest.mark.skip( @@ -3455,7 +3455,7 @@ def test_to_frame(scalars_dfs): bf_result = scalars_df["int64_col"].to_frame().to_pandas() pd_result = scalars_pandas_df["int64_col"].to_frame() - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_to_frame_no_name(scalars_dfs): @@ -3464,7 +3464,7 @@ def test_to_frame_no_name(scalars_dfs): bf_result = scalars_df["int64_col"].rename(None).to_frame().to_pandas() pd_result = scalars_pandas_df["int64_col"].rename(None).to_frame() - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) @pytest.mark.skip(reason="fixture 'gcs_folder' not found") @@ -3713,7 +3713,7 @@ def test_mask_default_value(scalars_dfs): pd_col_masked = pd_col.mask(pd_col % 2 == 1) pd_result = pd_col.to_frame().assign(int64_col_masked=pd_col_masked) - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_mask_custom_value(scalars_dfs): @@ -3731,7 +3731,7 @@ def test_mask_custom_value(scalars_dfs): # odd so should be left as is, but it is being masked in pandas. # Accidentally the bigframes bahavior matches, but it should be updated # after the resolution of https://github.com/pandas-dev/pandas/issues/52955 - assert_pandas_df_equal(bf_result, pd_result) + assert_frame_equal(bf_result, pd_result) def test_mask_with_callable(scalars_df_index, scalars_pandas_df_index): @@ -4194,7 +4194,7 @@ def test_loc_bool_series_default_index( scalars_pandas_df_default_index.bool_col ] - assert_pandas_df_equal( + assert_frame_equal( bf_result.to_frame(), pd_result.to_frame(), ) @@ -4696,7 +4696,7 @@ def wrapped(x): pd_result = pd_col.apply(wrapped) - assert_series_equal(bf_result, pd_result, check_dtype=False) + assert_series_equal(bf_result, pd_result, check_dtype=False, nulls_are_nan=True) @pytest.mark.parametrize( From 80dbc2dbb895e768a41f5b6750a69ce6195c3f29 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 12 Dec 2025 13:25:06 -0800 Subject: [PATCH 288/313] test: Fix penguins_linear_model_w_global_explain fixture (#2327) --- tests/system/large/ml/conftest.py | 87 +++++++++++++++++++++++++++++++ tests/system/small/ml/conftest.py | 9 ---- 2 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 tests/system/large/ml/conftest.py diff --git a/tests/system/large/ml/conftest.py b/tests/system/large/ml/conftest.py new file mode 100644 index 0000000000..7735f3eff5 --- /dev/null +++ b/tests/system/large/ml/conftest.py @@ -0,0 +1,87 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import hashlib +import logging + +from google.cloud import bigquery +import google.cloud.exceptions +import pytest + +import bigframes +from bigframes.ml import core, linear_model + +PERMANENT_DATASET = "bigframes_testing" + + +@pytest.fixture(scope="session") +def dataset_id_permanent(bigquery_client: bigquery.Client, project_id: str) -> str: + """Create a dataset if it doesn't exist.""" + dataset_id = f"{project_id}.{PERMANENT_DATASET}" + dataset = bigquery.Dataset(dataset_id) + bigquery_client.create_dataset(dataset, exists_ok=True) + return dataset_id + + +@pytest.fixture(scope="session") +def penguins_bqml_linear_model(session, penguins_linear_model_name) -> core.BqmlModel: + model = session.bqclient.get_model(penguins_linear_model_name) + return core.BqmlModel(session, model) + + +@pytest.fixture(scope="function") +def penguins_linear_model_w_global_explain( + penguins_bqml_linear_model: core.BqmlModel, +) -> linear_model.LinearRegression: + bf_model = linear_model.LinearRegression(enable_global_explain=True) + bf_model._bqml_model = penguins_bqml_linear_model + return bf_model + + +@pytest.fixture(scope="session") +def penguins_table_id(test_data_tables) -> str: + return test_data_tables["penguins"] + + +@pytest.fixture(scope="session") +def penguins_linear_model_name( + session: bigframes.Session, dataset_id_permanent, penguins_table_id +) -> str: + """Provides a pretrained model as a test fixture that is cached across test runs. + This lets us run system tests without having to wait for a model.fit(...)""" + sql = f""" +CREATE OR REPLACE MODEL `$model_name` +OPTIONS ( + model_type='linear_reg', + input_label_cols=['body_mass_g'], + data_split_method='NO_SPLIT' +) AS +SELECT + * +FROM + `{penguins_table_id}` +WHERE + body_mass_g IS NOT NULL""" + # We use the SQL hash as the name to ensure the model is regenerated if this fixture is edited + model_name = f"{dataset_id_permanent}.penguins_linear_reg_{hashlib.md5(sql.encode()).hexdigest()}" + sql = sql.replace("$model_name", model_name) + + try: + session.bqclient.get_model(model_name) + except google.cloud.exceptions.NotFound: + logging.info( + "penguins_linear_model fixture was not found in the permanent dataset, regenerating it..." + ) + session.bqclient.query(sql).result() + finally: + return model_name diff --git a/tests/system/small/ml/conftest.py b/tests/system/small/ml/conftest.py index 8f05e7fe03..c735dbc76b 100644 --- a/tests/system/small/ml/conftest.py +++ b/tests/system/small/ml/conftest.py @@ -83,15 +83,6 @@ def ephemera_penguins_linear_model( return bf_model -@pytest.fixture(scope="function") -def penguins_linear_model_w_global_explain( - penguins_bqml_linear_model: core.BqmlModel, -) -> linear_model.LinearRegression: - bf_model = linear_model.LinearRegression(enable_global_explain=True) - bf_model._bqml_model = penguins_bqml_linear_model - return bf_model - - @pytest.fixture(scope="session") def penguins_logistic_model( session, penguins_logistic_model_name From f27196260743883ed8131d5fd33a335e311177e4 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Fri, 12 Dec 2025 14:54:28 -0800 Subject: [PATCH 289/313] feat: Display custom single index column in anywidget mode (#2311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces the single column display for anywidget mode. A screenshot of single index column display is here: screen/6VLA4Nk68TsYczV Fixes #<459515995> 🦕 --- bigframes/display/anywidget.py | 16 +- notebooks/dataframes/anywidget_mode.ipynb | 224 +++++++++++----------- tests/system/small/test_anywidget.py | 136 ++++++++++++- 3 files changed, 260 insertions(+), 116 deletions(-) diff --git a/bigframes/display/anywidget.py b/bigframes/display/anywidget.py index 2c93e437fa..5c1db93dce 100644 --- a/bigframes/display/anywidget.py +++ b/bigframes/display/anywidget.py @@ -245,7 +245,7 @@ def _cached_data(self) -> pd.DataFrame: """Combine all cached batches into a single DataFrame.""" if not self._cached_batches: return pd.DataFrame(columns=self._dataframe.columns) - return pd.concat(self._cached_batches, ignore_index=True) + return pd.concat(self._cached_batches) def _reset_batch_cache(self) -> None: """Resets batch caching attributes.""" @@ -294,8 +294,18 @@ def _set_table_html(self) -> None: break # Get the data for the current page - page_data = cached_data.iloc[start:end] - + page_data = cached_data.iloc[start:end].copy() + + # Handle index display + # TODO(b/438181139): Add tests for custom multiindex + if self._dataframe._block.has_index: + index_name = page_data.index.name + page_data.insert( + 0, index_name if index_name is not None else "", page_data.index + ) + else: + # Default index - include as "Row" column + page_data.insert(0, "Row", range(start + 1, start + len(page_data) + 1)) # Handle case where user navigated beyond available data with unknown row count is_unknown_count = self.row_count is None is_beyond_data = self._all_data_loaded and len(page_data) == 0 and self.page > 0 diff --git a/notebooks/dataframes/anywidget_mode.ipynb b/notebooks/dataframes/anywidget_mode.ipynb index b0d908fc17..0ce286ce64 100644 --- a/notebooks/dataframes/anywidget_mode.ipynb +++ b/notebooks/dataframes/anywidget_mode.ipynb @@ -106,17 +106,17 @@ "name": "stdout", "output_type": "stream", "text": [ - "state gender year name number\n", - " AL F 1910 Annie 482\n", - " AL F 1910 Myrtle 104\n", - " AR F 1910 Lillian 56\n", - " CT F 1910 Anne 38\n", - " CT F 1910 Frances 45\n", - " FL F 1910 Margaret 53\n", - " GA F 1910 Mae 73\n", - " GA F 1910 Beatrice 96\n", - " GA F 1910 Lola 47\n", - " IA F 1910 Viola 49\n", + "state gender year name number\n", + " AL F 1910 Lillian 99\n", + " AL F 1910 Ruby 204\n", + " AL F 1910 Helen 76\n", + " AL F 1910 Eunice 41\n", + " AR F 1910 Dora 42\n", + " CA F 1910 Edna 62\n", + " CA F 1910 Helen 239\n", + " CO F 1910 Alice 46\n", + " FL F 1910 Willie 71\n", + " FL F 1910 Thelma 65\n", "...\n", "\n", "[5552452 rows x 5 columns]\n" @@ -196,7 +196,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "e2231d99614a4489b2930c24b30f1d34", + "model_id": "775e84ca212c4867bb889266b830ae68", "version_major": 2, "version_minor": 1 }, @@ -232,79 +232,79 @@ " AL\n", " F\n", " 1910\n", - " Hazel\n", - " 51\n", + " Cora\n", + " 61\n", " \n", " \n", " 1\n", " AL\n", " F\n", " 1910\n", - " Lucy\n", - " 76\n", + " Anna\n", + " 74\n", " \n", " \n", " 2\n", " AR\n", " F\n", " 1910\n", - " Nellie\n", - " 39\n", + " Willie\n", + " 132\n", " \n", " \n", " 3\n", - " AR\n", + " CO\n", " F\n", " 1910\n", - " Lena\n", - " 40\n", + " Anna\n", + " 42\n", " \n", " \n", " 4\n", - " CO\n", + " FL\n", " F\n", " 1910\n", - " Thelma\n", - " 36\n", + " Louise\n", + " 70\n", " \n", " \n", " 5\n", - " CO\n", + " GA\n", " F\n", " 1910\n", - " Ruth\n", - " 68\n", + " Catherine\n", + " 57\n", " \n", " \n", " 6\n", - " CT\n", + " IL\n", " F\n", " 1910\n", - " Elizabeth\n", - " 86\n", + " Jessie\n", + " 43\n", " \n", " \n", " 7\n", - " DC\n", + " IN\n", " F\n", " 1910\n", - " Mary\n", - " 80\n", + " Anna\n", + " 100\n", " \n", " \n", " 8\n", - " FL\n", + " IN\n", " F\n", " 1910\n", - " Annie\n", - " 101\n", + " Pauline\n", + " 77\n", " \n", " \n", " 9\n", - " FL\n", + " IN\n", " F\n", " 1910\n", - " Alma\n", + " Beulah\n", " 39\n", " \n", " \n", @@ -314,16 +314,16 @@ ], "text/plain": [ "state gender year name number\n", - " AL F 1910 Hazel 51\n", - " AL F 1910 Lucy 76\n", - " AR F 1910 Nellie 39\n", - " AR F 1910 Lena 40\n", - " CO F 1910 Thelma 36\n", - " CO F 1910 Ruth 68\n", - " CT F 1910 Elizabeth 86\n", - " DC F 1910 Mary 80\n", - " FL F 1910 Annie 101\n", - " FL F 1910 Alma 39\n", + " AL F 1910 Cora 61\n", + " AL F 1910 Anna 74\n", + " AR F 1910 Willie 132\n", + " CO F 1910 Anna 42\n", + " FL F 1910 Louise 70\n", + " GA F 1910 Catherine 57\n", + " IL F 1910 Jessie 43\n", + " IN F 1910 Anna 100\n", + " IN F 1910 Pauline 77\n", + " IN F 1910 Beulah 39\n", "...\n", "\n", "[5552452 rows x 5 columns]" @@ -409,12 +409,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "f26e26da0c84469fb7a9c211ab4423b7", + "model_id": "bf4224f8022042aea6d72507ddb5570b", "version_major": 2, "version_minor": 1 }, "text/plain": [ - "" + "" ] }, "execution_count": 7, @@ -523,12 +523,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "f1a893516ee04a5f9eb2655d5aaca778", + "model_id": "8d9bfeeba3ca4d11a56dccb28aacde23", "version_major": 2, "version_minor": 1 }, "text/plain": [ - "" + "" ] }, "execution_count": 9, @@ -563,7 +563,7 @@ "data": { "text/html": [ "✅ Completed. \n", - " Query processed 85.9 kB in 11 seconds of slot time.\n", + " Query processed 85.9 kB in 13 seconds of slot time.\n", " " ], "text/plain": [ @@ -624,7 +624,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "d48598e7d34a4fd0a817e4995868395e", + "model_id": "9fce25a077604e4882144d46d0d4ba45", "version_major": 2, "version_minor": 1 }, @@ -671,6 +671,42 @@ " gs://gcs-public-data--labeled-patents/espacene...\n", " EU\n", " DE\n", + " 29.08.018\n", + " E04H 6/12\n", + " <NA>\n", + " 18157874.1\n", + " 21.02.2018\n", + " 22.02.2017\n", + " Liedtke & Partner Patentanw√§lte\n", + " SHB Hebezeugbau GmbH\n", + " VOLGER, Alexander\n", + " STEUERUNGSSYSTEM F√úR AUTOMATISCHE PARKH√ÑUSER\n", + " EP 3 366 869 A1\n", + " \n", + " \n", + " 1\n", + " {'application_number': None, 'class_internatio...\n", + " gs://gcs-public-data--labeled-patents/espacene...\n", + " EU\n", + " DE\n", + " 03.10.2018\n", + " H05B 6/12\n", + " <NA>\n", + " 18165514.3\n", + " 03.04.2018\n", + " 30.03.2017\n", + " <NA>\n", + " BSH Hausger√§te GmbH\n", + " Acero Acero, Jesus\n", + " VORRICHTUNG ZUR INDUKTIVEN ENERGIE√úBERTRAGUNG\n", + " EP 3 383 141 A2\n", + " \n", + " \n", + " 2\n", + " {'application_number': None, 'class_internatio...\n", + " gs://gcs-public-data--labeled-patents/espacene...\n", + " EU\n", + " DE\n", " 03.10.2018\n", " H01L 21/20\n", " <NA>\n", @@ -684,7 +720,7 @@ " EP 3 382 744 A1\n", " \n", " \n", - " 1\n", + " 3\n", " {'application_number': None, 'class_internatio...\n", " gs://gcs-public-data--labeled-patents/espacene...\n", " EU\n", @@ -702,7 +738,7 @@ " EP 3 382 553 A1\n", " \n", " \n", - " 2\n", + " 4\n", " {'application_number': None, 'class_internatio...\n", " gs://gcs-public-data--labeled-patents/espacene...\n", " EU\n", @@ -719,42 +755,6 @@ " MASTH√ÑHNCHENCONTAINER ALS BESTANDTEIL EINER E...\n", " EP 3 381 276 A1\n", " \n", - " \n", - " 3\n", - " {'application_number': None, 'class_internatio...\n", - " gs://gcs-public-data--labeled-patents/espacene...\n", - " EU\n", - " DE\n", - " 03.10.2018\n", - " H05B 6/12\n", - " <NA>\n", - " 18165514.3\n", - " 03.04.2018\n", - " 30.03.2017\n", - " <NA>\n", - " BSH Hausger√§te GmbH\n", - " Acero Acero, Jesus\n", - " VORRICHTUNG ZUR INDUKTIVEN ENERGIE√úBERTRAGUNG\n", - " EP 3 383 141 A2\n", - " \n", - " \n", - " 4\n", - " {'application_number': None, 'class_internatio...\n", - " gs://gcs-public-data--labeled-patents/espacene...\n", - " EU\n", - " DE\n", - " 29.08.018\n", - " E04H 6/12\n", - " <NA>\n", - " 18157874.1\n", - " 21.02.2018\n", - " 22.02.2017\n", - " Liedtke & Partner Patentanw√§lte\n", - " SHB Hebezeugbau GmbH\n", - " VOLGER, Alexander\n", - " STEUERUNGSSYSTEM F√úR AUTOMATISCHE PARKH√ÑUSER\n", - " EP 3 366 869 A1\n", - " \n", " \n", "\n", "

5 rows × 15 columns

\n", @@ -776,32 +776,32 @@ "4 gs://gcs-public-data--labeled-patents/espacene... EU DE \n", "\n", " publication_date class_international class_us application_number \\\n", - "0 03.10.2018 H01L 21/20 18166536.5 \n", - "1 03.10.2018 G06F 11/30 18157347.8 \n", - "2 03.10.2018 A01K 31/00 18171005.4 \n", - "3 03.10.2018 H05B 6/12 18165514.3 \n", - "4 29.08.018 E04H 6/12 18157874.1 \n", + "0 29.08.018 E04H 6/12 18157874.1 \n", + "1 03.10.2018 H05B 6/12 18165514.3 \n", + "2 03.10.2018 H01L 21/20 18166536.5 \n", + "3 03.10.2018 G06F 11/30 18157347.8 \n", + "4 03.10.2018 A01K 31/00 18171005.4 \n", "\n", " filing_date priority_date_eu representative_line_1_eu \\\n", - "0 16.02.2016 Scheider, Sascha et al \n", - "1 19.02.2018 31.03.2017 Hoffmann Eitle \n", - "2 05.02.2015 05.02.2014 Stork Bamberger Patentanw√§lte \n", - "3 03.04.2018 30.03.2017 \n", - "4 21.02.2018 22.02.2017 Liedtke & Partner Patentanw√§lte \n", + "0 21.02.2018 22.02.2017 Liedtke & Partner Patentanw√§lte \n", + "1 03.04.2018 30.03.2017 \n", + "2 16.02.2016 Scheider, Sascha et al \n", + "3 19.02.2018 31.03.2017 Hoffmann Eitle \n", + "4 05.02.2015 05.02.2014 Stork Bamberger Patentanw√§lte \n", "\n", " applicant_line_1 inventor_line_1 \\\n", - "0 EV Group E. Thallner GmbH Kurz, Florian \n", - "1 FUJITSU LIMITED Kukihara, Kensuke \n", - "2 Linco Food Systems A/S Thrane, Uffe \n", - "3 BSH Hausger√§te GmbH Acero Acero, Jesus \n", - "4 SHB Hebezeugbau GmbH VOLGER, Alexander \n", + "0 SHB Hebezeugbau GmbH VOLGER, Alexander \n", + "1 BSH Hausger√§te GmbH Acero Acero, Jesus \n", + "2 EV Group E. Thallner GmbH Kurz, Florian \n", + "3 FUJITSU LIMITED Kukihara, Kensuke \n", + "4 Linco Food Systems A/S Thrane, Uffe \n", "\n", " title_line_1 number \n", - "0 VORRICHTUNG ZUM BONDEN VON SUBSTRATEN EP 3 382 744 A1 \n", - "1 METHOD EXECUTED BY A COMPUTER, INFORMATION PRO... EP 3 382 553 A1 \n", - "2 MASTH√ÑHNCHENCONTAINER ALS BESTANDTEIL EINER E... EP 3 381 276 A1 \n", - "3 VORRICHTUNG ZUR INDUKTIVEN ENERGIE√úBERTRAGUNG EP 3 383 141 A2 \n", - "4 STEUERUNGSSYSTEM F√úR AUTOMATISCHE PARKH√ÑUSER EP 3 366 869 A1 \n", + "0 STEUERUNGSSYSTEM F√úR AUTOMATISCHE PARKH√ÑUSER EP 3 366 869 A1 \n", + "1 VORRICHTUNG ZUR INDUKTIVEN ENERGIE√úBERTRAGUNG EP 3 383 141 A2 \n", + "2 VORRICHTUNG ZUM BONDEN VON SUBSTRATEN EP 3 382 744 A1 \n", + "3 METHOD EXECUTED BY A COMPUTER, INFORMATION PRO... EP 3 382 553 A1 \n", + "4 MASTH√ÑHNCHENCONTAINER ALS BESTANDTEIL EINER E... EP 3 381 276 A1 \n", "\n", "[5 rows x 15 columns]" ] diff --git a/tests/system/small/test_anywidget.py b/tests/system/small/test_anywidget.py index 260a338bf7..b0eeb4a3c2 100644 --- a/tests/system/small/test_anywidget.py +++ b/tests/system/small/test_anywidget.py @@ -998,6 +998,140 @@ def test_dataframe_repr_mimebundle_anywidget_with_metadata( assert "colab" in metadata["application/vnd.jupyter.widget-view+json"] -# TODO(b/332316283): Add tests for custom index and multiindex +@pytest.fixture(scope="module") +def custom_index_pandas_df() -> pd.DataFrame: + """Create a DataFrame with a custom named index for testing.""" + test_data = pd.DataFrame( + { + "value_a": [10, 20, 30, 40, 50, 60], + "value_b": ["a", "b", "c", "d", "e", "f"], + } + ) + test_data.index = pd.Index( + ["row_1", "row_2", "row_3", "row_4", "row_5", "row_6"], name="custom_idx" + ) + return test_data + + +@pytest.fixture(scope="module") +def custom_index_bf_df( + session: bf.Session, custom_index_pandas_df: pd.DataFrame +) -> bf.dataframe.DataFrame: + return session.read_pandas(custom_index_pandas_df) + + +@pytest.fixture(scope="module") +def multiindex_pandas_df() -> pd.DataFrame: + """Create a DataFrame with MultiIndex for testing.""" + test_data = pd.DataFrame( + { + "value": [100, 200, 300, 400, 500, 600], + "category": ["X", "Y", "Z", "X", "Y", "Z"], + } + ) + test_data.index = pd.MultiIndex.from_arrays( + [ + ["group_A", "group_A", "group_A", "group_B", "group_B", "group_B"], + [1, 2, 3, 1, 2, 3], + ], + names=["group", "item"], + ) + return test_data + + +@pytest.fixture(scope="module") +def multiindex_bf_df( + session: bf.Session, multiindex_pandas_df: pd.DataFrame +) -> bf.dataframe.DataFrame: + return session.read_pandas(multiindex_pandas_df) + + +def test_widget_with_default_index_should_display_index_column_with_empty_header( + paginated_bf_df: bf.dataframe.DataFrame, +): + """ + Given a DataFrame with a default index, when the TableWidget is rendered, + then an index column should be visible with an empty header. + """ + import re + + from bigframes.display.anywidget import TableWidget + + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): + widget = TableWidget(paginated_bf_df) + html = widget.table_html + + # The header for the index should be present but empty, matching the + # internal rendering logic. + thead = html.split("")[1].split("")[0] + # Find the first header cell and check that its content div is empty. + match = re.search(r"]*>]*>([^<]*)", thead) + assert match is not None, "Could not find table header cell in output." + assert ( + match.group(1) == "" + ), f"Expected empty index header, but found: {match.group(1)}" + + +def test_widget_with_custom_index_should_display_index_column( + custom_index_bf_df: bf.dataframe.DataFrame, +): + """ + Given a DataFrame with a custom named index, when rendered, + then the index column and first page of rows should be visible. + """ + from bigframes.display.anywidget import TableWidget + + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): + widget = TableWidget(custom_index_bf_df) + html = widget.table_html + + assert "custom_idx" in html + assert "row_1" in html + assert "row_2" in html + assert "row_3" not in html # Verify pagination is working + assert "row_4" not in html + + +def test_widget_with_custom_index_pagination_preserves_index( + custom_index_bf_df: bf.dataframe.DataFrame, +): + """ + Given a DataFrame with a custom index, when navigating to the second page, + then the second page's index values should be visible. + """ + from bigframes.display.anywidget import TableWidget + + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): + widget = TableWidget(custom_index_bf_df) + + widget.page = 1 # Navigate to page 2 + html = widget.table_html + + assert "row_3" in html + assert "row_4" in html + assert "row_1" not in html # Verify page 1 content is gone + assert "row_2" not in html + + +def test_widget_with_custom_index_matches_pandas_output( + custom_index_bf_df: bf.dataframe.DataFrame, +): + """ + Given a DataFrame with a custom index and max_rows=3, the widget's HTML + output should contain the first three index values. + """ + from bigframes.display.anywidget import TableWidget + + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 3): + widget = TableWidget(custom_index_bf_df) + html = widget.table_html + + assert "row_1" in html + assert "row_2" in html + assert "row_3" in html + assert "row_4" not in html # Verify it respects max_rows + + +# TODO(b/438181139): Add tests for custom multiindex # This may not be necessary for the SQL Cell use case but should be # considered for completeness. From f0ed9bcf9eb5b453c5d966622e267e5c35b22f42 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 12 Dec 2025 15:08:44 -0800 Subject: [PATCH 290/313] test: Make tests work with pandas 3.0 (#2329) --- bigframes/series.py | 2 +- bigframes/testing/utils.py | 18 ++++++++++++- tests/unit/core/test_groupby.py | 7 ++--- tests/unit/test_dataframe_polars.py | 16 +++++++---- tests/unit/test_local_engine.py | 16 ++++++----- tests/unit/test_series_polars.py | 41 ++++++++++++++++++++--------- 6 files changed, 70 insertions(+), 30 deletions(-) diff --git a/bigframes/series.py b/bigframes/series.py index e11c60a999..64a986d1fa 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -2303,7 +2303,7 @@ def to_dict( *, allow_large_results: Optional[bool] = None, ) -> typing.Mapping: - return typing.cast(dict, self.to_pandas(allow_large_results=allow_large_results).to_dict(into)) # type: ignore + return typing.cast(dict, self.to_pandas(allow_large_results=allow_large_results).to_dict(into=into)) # type: ignore def to_excel( self, excel_writer, sheet_name="Sheet1", *, allow_large_results=None, **kwargs diff --git a/bigframes/testing/utils.py b/bigframes/testing/utils.py index ae93c00464..6679f53b2c 100644 --- a/bigframes/testing/utils.py +++ b/bigframes/testing/utils.py @@ -14,6 +14,7 @@ import base64 import decimal +import re from typing import Iterable, Optional, Sequence, Set, Union import geopandas as gpd # type: ignore @@ -69,6 +70,12 @@ ] +def pandas_major_version() -> int: + match = re.search(r"^v?(\d+)", pd.__version__.strip()) + assert match is not None + return int(match.group(1)) + + # Prefer this function for tests that run in both ordered and unordered mode def assert_dfs_equivalent(pd_df: pd.DataFrame, bf_df: bpd.DataFrame, **kwargs): bf_df_local = bf_df.to_pandas() @@ -83,7 +90,7 @@ def assert_series_equivalent(pd_series: pd.Series, bf_series: bpd.Series, **kwar def _normalize_all_nulls(col: pd.Series) -> pd.Series: - if col.dtype == bigframes.dtypes.FLOAT_DTYPE: + if col.dtype in (bigframes.dtypes.FLOAT_DTYPE, bigframes.dtypes.INT_DTYPE): col = col.astype("float64") if pd_types.is_object_dtype(col): col = col.fillna(float("nan")) @@ -134,6 +141,15 @@ def assert_series_equal( left = left.sort_index() right = right.sort_index() + if isinstance(left.index, pd.RangeIndex) or pd_types.is_integer_dtype( + left.index.dtype, + ): + left.index = left.index.astype("Int64") + if isinstance(right.index, pd.RangeIndex) or pd_types.is_integer_dtype( + right.index.dtype, + ): + right.index = right.index.astype("Int64") + if nulls_are_nan: left = _normalize_all_nulls(left) right = _normalize_all_nulls(right) diff --git a/tests/unit/core/test_groupby.py b/tests/unit/core/test_groupby.py index f3d9218123..4bef581b2f 100644 --- a/tests/unit/core/test_groupby.py +++ b/tests/unit/core/test_groupby.py @@ -18,6 +18,7 @@ import bigframes.core.utils as utils import bigframes.pandas as bpd +from bigframes.testing.utils import assert_series_equal pytest.importorskip("polars") pytest.importorskip("pandas", minversion="2.0.0") @@ -217,7 +218,7 @@ def test_groupby_series_iter_by_series(polars_session): bf_result = bf_group_series.to_pandas() pd_key, pd_result = pd_group assert bf_key == pd_key - pandas.testing.assert_series_equal( + assert_series_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -236,7 +237,7 @@ def test_groupby_series_iter_by_series_list_one_item(polars_session): bf_result = bf_group_series.to_pandas() pd_key, pd_result = pd_group assert bf_key == pd_key - pandas.testing.assert_series_equal( + assert_series_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -258,6 +259,6 @@ def test_groupby_series_iter_by_series_list_multiple(polars_session): bf_result = bf_group_series.to_pandas() pd_key, pd_result = pd_group assert bf_key == pd_key - pandas.testing.assert_series_equal( + assert_series_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) diff --git a/tests/unit/test_dataframe_polars.py b/tests/unit/test_dataframe_polars.py index b8d251c88e..1c73d9dc6b 100644 --- a/tests/unit/test_dataframe_polars.py +++ b/tests/unit/test_dataframe_polars.py @@ -593,8 +593,8 @@ def test_drop_bigframes_index_with_na(scalars_dfs): scalars_pandas_df = scalars_pandas_df.copy() scalars_df = scalars_df.set_index("bytes_col") scalars_pandas_df = scalars_pandas_df.set_index("bytes_col") - drop_index = scalars_df.iloc[[3, 5]].index - drop_pandas_index = scalars_pandas_df.iloc[[3, 5]].index + drop_index = scalars_df.iloc[[2, 5]].index + drop_pandas_index = scalars_pandas_df.iloc[[2, 5]].index pd_result = scalars_pandas_df.drop(index=drop_pandas_index) # drop_pandas_index) bf_result = scalars_df.drop(index=drop_index).to_pandas() @@ -2682,9 +2682,10 @@ def test_dataframe_pct_change(scalars_df_index, scalars_pandas_df_index, periods bf_result = scalars_df_index[col_names].pct_change(periods=periods).to_pandas() # pandas 3.0 does not automatically ffill anymore pd_result = scalars_pandas_df_index[col_names].ffill().pct_change(periods=periods) - pd.testing.assert_frame_equal( + assert_frame_equal( pd_result, bf_result, + nulls_are_nan=True, ) @@ -4297,8 +4298,13 @@ def test_df_value_counts(scalars_dfs, subset, normalize, ascending, dropna): subset, normalize=normalize, ascending=ascending, dropna=dropna ) - pd.testing.assert_series_equal( - bf_result, pd_result, check_dtype=False, check_index_type=False + assert_series_equal( + bf_result, + pd_result, + check_dtype=False, + check_index_type=False, + # different pandas versions inconsistent for tie-handling + ignore_order=True, ) diff --git a/tests/unit/test_local_engine.py b/tests/unit/test_local_engine.py index 8c8c2dcf0d..5f80e4928c 100644 --- a/tests/unit/test_local_engine.py +++ b/tests/unit/test_local_engine.py @@ -19,6 +19,7 @@ import bigframes import bigframes.pandas as bpd +from bigframes.testing.utils import assert_frame_equal, assert_series_equal pytest.importorskip("polars") pytest.importorskip("pandas", minversion="2.0.0") @@ -47,7 +48,7 @@ def test_polars_local_engine_series(polars_session: bigframes.Session): pd_series = pd.Series([1, 2, 3], dtype=bf_series.dtype) bf_result = bf_series.to_pandas() pd_result = pd_series - pandas.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) + assert_series_equal(bf_result, pd_result, check_index_type=False) def test_polars_local_engine_add( @@ -74,9 +75,9 @@ def test_polars_local_engine_filter(small_inline_frame: pd.DataFrame, polars_ses pd_df = small_inline_frame bf_df = bpd.DataFrame(pd_df, session=polars_session) - bf_result = bf_df.filter(bf_df["int2"] >= 1).to_pandas() - pd_result = pd_df.filter(pd_df["int2"] >= 1) # type: ignore - pandas.testing.assert_frame_equal(bf_result, pd_result) + bf_result = bf_df[bf_df["int2"] >= 1].to_pandas() + pd_result = pd_df[pd_df["int2"] >= 1] # type: ignore + assert_frame_equal(bf_result, pd_result) def test_polars_local_engine_series_rename_with_mapping(polars_session): @@ -88,7 +89,7 @@ def test_polars_local_engine_series_rename_with_mapping(polars_session): bf_result = bf_series.rename({1: 100, 2: 200, 3: 300}).to_pandas() pd_result = pd_series.rename({1: 100, 2: 200, 3: 300}) # pd default index is int64, bf is Int64 - pandas.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) + assert_series_equal(bf_result, pd_result, check_index_type=False) def test_polars_local_engine_series_rename_with_mapping_inplace(polars_session): @@ -103,7 +104,7 @@ def test_polars_local_engine_series_rename_with_mapping_inplace(polars_session): bf_result = bf_series.to_pandas() pd_result = pd_series # pd default index is int64, bf is Int64 - pandas.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) + assert_series_equal(bf_result, pd_result, check_index_type=False) def test_polars_local_engine_reset_index( @@ -129,11 +130,12 @@ def test_polars_local_engine_join_binop(polars_session): bf_result = (bf_df_1 + bf_df_2).to_pandas() pd_result = pd_df_1 + pd_df_2 # Sort since different join ordering - pandas.testing.assert_frame_equal( + assert_frame_equal( bf_result.sort_index(), pd_result.sort_index(), check_dtype=False, check_index_type=False, + nulls_are_nan=True, ) diff --git a/tests/unit/test_series_polars.py b/tests/unit/test_series_polars.py index 9f1a247250..516a46d4dd 100644 --- a/tests/unit/test_series_polars.py +++ b/tests/unit/test_series_polars.py @@ -42,6 +42,7 @@ assert_series_equal, convert_pandas_dtypes, get_first_file_from_wildcard, + pandas_major_version, ) pytest.importorskip("polars") @@ -147,7 +148,7 @@ def test_series_construct_timestamps(): bf_result = series.Series(datetimes).to_pandas() pd_result = pd.Series(datetimes, dtype=pd.ArrowDtype(pa.timestamp("us"))) - pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) + assert_series_equal(bf_result, pd_result, check_index_type=False) def test_series_construct_copy_with_index(scalars_dfs): @@ -313,9 +314,7 @@ def test_series_construct_geodata(): series = bigframes.pandas.Series(pd_series) - pd.testing.assert_series_equal( - pd_series, series.to_pandas(), check_index_type=False - ) + assert_series_equal(pd_series, series.to_pandas(), check_index_type=False) @pytest.mark.parametrize( @@ -581,6 +580,8 @@ def test_series___getitem__(scalars_dfs, index_col, key): ), ) def test_series___getitem___with_int_key(scalars_dfs, key): + if pd.__version__.startswith("3."): + pytest.skip("pandas 3.0 dropped getitem with int key") col_name = "int64_too" index_col = "string_col" scalars_df, scalars_pandas_df = scalars_dfs @@ -835,7 +836,7 @@ def test_series_dropna(scalars_dfs, ignore_index): col_name = "string_col" bf_result = scalars_df[col_name].dropna(ignore_index=ignore_index).to_pandas() pd_result = scalars_pandas_df[col_name].dropna(ignore_index=ignore_index) - pd.testing.assert_series_equal(pd_result, bf_result, check_index_type=False) + assert_series_equal(pd_result, bf_result, check_index_type=False) @pytest.mark.parametrize( @@ -1179,7 +1180,7 @@ def test_mods(scalars_dfs, col_x, col_y, method): else: bf_result = bf_series.astype("Float64").to_pandas() pd_result = getattr(scalars_pandas_df[col_x], method)(scalars_pandas_df[col_y]) - pd.testing.assert_series_equal(pd_result, bf_result) + assert_series_equal(pd_result, bf_result, nulls_are_nan=True) # We work around a pandas bug that doesn't handle correlating nullable dtypes by doing this @@ -1879,6 +1880,10 @@ def test_series_binop_w_other_types(scalars_dfs, other): bf_result = (scalars_df["int64_col"].head(3) + other).to_pandas() pd_result = scalars_pandas_df["int64_col"].head(3) + other + if isinstance(other, pd.Series): + # pandas 3.0 preserves series name, bigframe, earlier pandas do not + pd_result.index.name = bf_result.index.name + assert_series_equal( bf_result, pd_result, @@ -3962,7 +3967,7 @@ def test_string_astype_date(): pd_result = pd_series.astype("date32[day][pyarrow]") # type: ignore bf_result = bf_series.astype("date32[day][pyarrow]").to_pandas() - pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) + assert_series_equal(bf_result, pd_result, check_index_type=False) def test_string_astype_datetime(): @@ -3975,7 +3980,7 @@ def test_string_astype_datetime(): pd_result = pd_series.astype(pd.ArrowDtype(pa.timestamp("us"))) bf_result = bf_series.astype(pd.ArrowDtype(pa.timestamp("us"))).to_pandas() - pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) + assert_series_equal(bf_result, pd_result, check_index_type=False) def test_string_astype_timestamp(): @@ -3994,7 +3999,7 @@ def test_string_astype_timestamp(): pd.ArrowDtype(pa.timestamp("us", tz="UTC")) ).to_pandas() - pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) + assert_series_equal(bf_result, pd_result, check_index_type=False) @pytest.mark.skip(reason="AssertionError: Series are different") @@ -4615,7 +4620,7 @@ def test_apply_lambda(scalars_dfs, col, lambda_): bf_result = bf_col.apply(lambda_, by_row=False).to_pandas() pd_col = scalars_pandas_df[col] - if pd.__version__[:3] in ("2.2", "2.3"): + if pd.__version__[:3] in ("2.2", "2.3") or pandas_major_version() >= 3: pd_result = pd_col.apply(lambda_, by_row=False) else: pd_result = pd_col.apply(lambda_) @@ -4623,7 +4628,12 @@ def test_apply_lambda(scalars_dfs, col, lambda_): # ignore dtype check, which are Int64 and object respectively # Some columns implicitly convert to floating point. Use check_exact=False to ensure we're "close enough" assert_series_equal( - bf_result, pd_result, check_dtype=False, check_exact=False, rtol=0.001 + bf_result, + pd_result, + check_dtype=False, + check_exact=False, + rtol=0.001, + nulls_are_nan=True, ) @@ -4805,7 +4815,12 @@ def foo(x): # ignore dtype check, which are Int64 and object respectively # Some columns implicitly convert to floating point. Use check_exact=False to ensure we're "close enough" assert_series_equal( - bf_result, pd_result, check_dtype=False, check_exact=False, rtol=0.001 + bf_result, + pd_result, + check_dtype=False, + check_exact=False, + rtol=0.001, + nulls_are_nan=True, ) @@ -4924,7 +4939,7 @@ def test_series_explode_w_index(index, ignore_index): s = bigframes.pandas.Series(data, index=index) pd_s = pd.Series(data, index=index) # TODO(b/340885567): fix type error - pd.testing.assert_series_equal( + assert_series_equal( s.explode(ignore_index=ignore_index).to_pandas(), # type: ignore pd_s.explode(ignore_index=ignore_index).astype(pd.Float64Dtype()), # type: ignore check_index_type=False, From f1ff345b28143792a3db128922c87dea9b3e2d96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Fri, 12 Dec 2025 17:49:00 -0600 Subject: [PATCH 291/313] chore: Add conventional-pre-commit hook (#2322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added conventional-pre-commit hook for commit-msg stage. Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes # 🦕 --------- Co-authored-by: Shenyang Cai --- .pre-commit-config.yaml | 10 ++++++++++ GEMINI.md | 5 +++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b697d2324b..096bdeb2a7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,6 +14,10 @@ # # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks +default_install_hook_types: +- pre-commit +- commit-msg + repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.0.1 @@ -47,3 +51,9 @@ repos: hooks: - id: biome-check files: '\.(js|css)$' +- repo: https://github.com/compilerla/conventional-pre-commit + rev: fdde5f0251edbfc554795afdd6df71826d6602f3 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [] diff --git a/GEMINI.md b/GEMINI.md index d26a51ebfc..0d447f17a4 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -16,7 +16,8 @@ We use `nox` to instrument our tests. nox -r -s unit-3.13 -- -k ``` -- To run system tests, you can execute:: +- Ignore this step if you lack access to Google Cloud resources. To run system + tests, you can execute:: # Run all system tests $ nox -r -s system @@ -26,7 +27,7 @@ We use `nox` to instrument our tests. - The codebase must have better coverage than it had previously after each change. You can test coverage via `nox -s unit system cover` (takes a long - time). + time). Omit `system` if you lack access to cloud resources. ## Code Style From 369f1c0aff29d197b577ec79e401b107985fe969 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Mon, 15 Dec 2025 10:40:27 -0800 Subject: [PATCH 292/313] docs: Add time series analysis notebook (#2328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new notebook, timeseries_analysis.ipynb, to provide a comprehensive walkthrough of time series forecasting with TimesFM and ARIMAPlus. The notebook covers data loading, preprocessing, model training, and visualization of results for both single and multiple time series. Fixes #<466169940> 🦕 --- notebooks/ml/timeseries_analysis.ipynb | 1135 ++++++++++++++++++++++++ 1 file changed, 1135 insertions(+) create mode 100644 notebooks/ml/timeseries_analysis.ipynb diff --git a/notebooks/ml/timeseries_analysis.ipynb b/notebooks/ml/timeseries_analysis.ipynb new file mode 100644 index 0000000000..01c5a20efa --- /dev/null +++ b/notebooks/ml/timeseries_analysis.ipynb @@ -0,0 +1,1135 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cf1403ce", + "metadata": {}, + "source": [ + "# Time Series Forecasting with BigFrames\n", + "\n", + "This notebook provides a comprehensive walkthrough of time series forecasting using the BigFrames library. We will explore two powerful models, TimesFM and ARIMAPlus, to predict bikeshare trip demand based on historical data from San Francisco. The process covers data loading, preprocessing, model training, and visualization of the results." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "c0b2db75", + "metadata": {}, + "outputs": [], + "source": [ + "import bigframes.pandas as bpd\n", + "from bigframes.ml import forecasting\n", + "bpd.options.display.repr_mode = \"anywidget\"" + ] + }, + { + "cell_type": "markdown", + "id": "0eba46b9", + "metadata": {}, + "source": [ + "### 1. Data Loading and Preprocessing\n", + "\n", + "The first step is to load the San Francisco bikeshare dataset from BigQuery. We then preprocess the data by filtering for trips made by 'Subscriber' type users from 2018 onwards. This ensures we are working with a relevant and consistent subset of the data. Finally, we aggregate the trip data by the hour to create a time series of trip counts." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "83928f4d", + "metadata": {}, + "outputs": [], + "source": [ + "df = bpd.read_gbq(\"bigquery-public-data.san_francisco_bikeshare.bikeshare_trips\")\n", + "df = df[df[\"start_date\"] >= \"2018-01-01\"]\n", + "df = df[df[\"subscriber_type\"] == \"Subscriber\"]\n", + "df[\"trip_hour\"] = df[\"start_date\"].dt.floor(\"h\")\n", + "df_grouped = df[[\"trip_hour\", \"trip_id\"]].groupby(\"trip_hour\").count().reset_index()\n", + "df_grouped = df_grouped.rename(columns={\"trip_id\": \"num_trips\"})" + ] + }, + { + "cell_type": "markdown", + "id": "c43b7e65", + "metadata": {}, + "source": [ + "### 2. Forecasting with TimesFM\n", + "\n", + "In this section, we use the TimesFM (Time Series Foundation Model) to forecast future bikeshare demand. TimesFM is a powerful model designed for a wide range of time series forecasting tasks. We will use it to predict the number of trips for the last week of our dataset." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "1096e154", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/bigframes/dataframe.py:5340: FutureWarning: The 'ai' property will be removed. Please use 'bigframes.bigquery.ai'\n", + "instead.\n", + " warnings.warn(msg, category=FutureWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 58.7 MB in 19 seconds of slot time. [Job bigframes-dev:US.eb026c28-038a-4ca7-acfa-474ed0be4119 details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 7.1 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 7.1 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "929eda852e564b799cf76e62d9f7b46a", + "version_major": 2, + "version_minor": 1 + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
forecast_timestampforecast_valueconfidence_levelprediction_interval_lower_boundprediction_interval_upper_boundai_forecast_status
02018-04-24 14:00:00+00:00126.5192110.9596.837778156.200644
12018-04-30 21:00:00+00:0082.2661970.95-7.690994172.223388
22018-04-25 14:00:00+00:00130.0572660.9578.019585182.094948
32018-04-26 06:00:00+00:0047.2352140.95-16.565634111.036063
42018-04-28 01:00:00+00:000.7611390.95-61.08053162.602809
52018-04-27 11:00:00+00:00160.4370420.9580.767928240.106157
62018-04-25 07:00:00+00:00321.4184880.95207.344246435.492729
72018-04-24 16:00:00+00:00284.6405640.95198.550187370.730941
82018-04-25 16:00:00+00:00329.6537480.95201.918472457.389023
92018-04-26 10:00:00+00:00160.9959720.9567.706721254.285223
\n", + "

10 rows × 6 columns

\n", + "
[168 rows x 6 columns in total]" + ], + "text/plain": [ + " forecast_timestamp forecast_value confidence_level \\\n", + "0 2018-04-24 14:00:00+00:00 126.519211 0.95 \n", + "1 2018-04-30 21:00:00+00:00 82.266197 0.95 \n", + "2 2018-04-25 14:00:00+00:00 130.057266 0.95 \n", + "3 2018-04-26 06:00:00+00:00 47.235214 0.95 \n", + "4 2018-04-28 01:00:00+00:00 0.761139 0.95 \n", + "5 2018-04-27 11:00:00+00:00 160.437042 0.95 \n", + "6 2018-04-25 07:00:00+00:00 321.418488 0.95 \n", + "7 2018-04-24 16:00:00+00:00 284.640564 0.95 \n", + "8 2018-04-25 16:00:00+00:00 329.653748 0.95 \n", + "9 2018-04-26 10:00:00+00:00 160.995972 0.95 \n", + "\n", + " prediction_interval_lower_bound prediction_interval_upper_bound \\\n", + "0 96.837778 156.200644 \n", + "1 -7.690994 172.223388 \n", + "2 78.019585 182.094948 \n", + "3 -16.565634 111.036063 \n", + "4 -61.080531 62.602809 \n", + "5 80.767928 240.106157 \n", + "6 207.344246 435.492729 \n", + "7 198.550187 370.730941 \n", + "8 201.918472 457.389023 \n", + "9 67.706721 254.285223 \n", + "\n", + " ai_forecast_status \n", + "0 \n", + "1 \n", + "2 \n", + "3 \n", + "4 \n", + "5 \n", + "6 \n", + "7 \n", + "8 \n", + "9 \n", + "...\n", + "\n", + "[168 rows x 6 columns]" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result = df_grouped.head(2842-168).ai.forecast(\n", + " timestamp_column=\"trip_hour\",\n", + " data_column=\"num_trips\",\n", + " horizon=168\n", + ")\n", + "result" + ] + }, + { + "cell_type": "markdown", + "id": "90e80a82", + "metadata": {}, + "source": [ + "### 3. Forecasting with ARIMAPlus\n", + "\n", + "Next, we will use the ARIMAPlus model, which is a BigQuery ML model available through BigFrames. ARIMAPlus is an advanced forecasting model that can capture complex time series patterns. We will train it on the same historical data and use it to forecast the same period as the TimesFM model." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "f41e1cf0", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " Query processed 1.8 MB in 46 seconds of slot time. [Job bigframes-dev:US.ac354d97-dc91-4d01-9dca-7069db6a26a7 details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 92.2 kB in a moment of slot time. [Job bigframes-dev:US.e61f41af-8761-4853-ae41-d38760c966ed details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 1.3 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 10.8 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 0 Bytes in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "0624fdda2be74b13bc6e6c30e38842b6", + "version_major": 2, + "version_minor": 1 + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
forecast_timestampforecast_valuestandard_errorconfidence_levelprediction_interval_lower_boundprediction_interval_upper_boundconfidence_interval_lower_boundconfidence_interval_upper_bound
02018-04-24 00:00:00+00:0052.76833534.874520.95-15.462203120.998872-15.462203120.998872
12018-04-24 01:00:00+00:0067.328148.0752550.95-26.729122161.385322-26.729122161.385322
22018-04-24 02:00:00+00:0075.20557353.9109210.95-30.268884180.68003-30.268884180.68003
32018-04-24 03:00:00+00:0080.07092255.9940760.95-29.479141189.620985-29.479141189.620985
42018-04-24 04:00:00+00:0075.16177956.5839740.95-35.542394185.865952-35.542394185.865952
52018-04-24 05:00:00+00:0081.42843256.850870.95-29.797913192.654778-29.797913192.654778
62018-04-24 06:00:00+00:00116.98144557.1807670.955.109671228.8532185.109671228.853218
72018-04-24 07:00:00+00:00237.22236157.7703070.95124.197176350.247546124.197176350.247546
82018-04-24 08:00:00+00:00323.72257258.6816620.95208.91436438.530784208.91436438.530784
92018-04-24 09:00:00+00:00357.28895259.8069060.95240.279247474.298656240.279247474.298656
\n", + "

10 rows × 8 columns

\n", + "
[168 rows x 8 columns in total]" + ], + "text/plain": [ + " forecast_timestamp forecast_value standard_error \\\n", + "0 2018-04-24 00:00:00+00:00 52.768335 34.87452 \n", + "1 2018-04-24 01:00:00+00:00 67.3281 48.075255 \n", + "2 2018-04-24 02:00:00+00:00 75.205573 53.910921 \n", + "3 2018-04-24 03:00:00+00:00 80.070922 55.994076 \n", + "4 2018-04-24 04:00:00+00:00 75.161779 56.583974 \n", + "5 2018-04-24 05:00:00+00:00 81.428432 56.85087 \n", + "6 2018-04-24 06:00:00+00:00 116.981445 57.180767 \n", + "7 2018-04-24 07:00:00+00:00 237.222361 57.770307 \n", + "8 2018-04-24 08:00:00+00:00 323.722572 58.681662 \n", + "9 2018-04-24 09:00:00+00:00 357.288952 59.806906 \n", + "\n", + " confidence_level prediction_interval_lower_bound \\\n", + "0 0.95 -15.462203 \n", + "1 0.95 -26.729122 \n", + "2 0.95 -30.268884 \n", + "3 0.95 -29.479141 \n", + "4 0.95 -35.542394 \n", + "5 0.95 -29.797913 \n", + "6 0.95 5.109671 \n", + "7 0.95 124.197176 \n", + "8 0.95 208.91436 \n", + "9 0.95 240.279247 \n", + "\n", + " prediction_interval_upper_bound confidence_interval_lower_bound \\\n", + "0 120.998872 -15.462203 \n", + "1 161.385322 -26.729122 \n", + "2 180.68003 -30.268884 \n", + "3 189.620985 -29.479141 \n", + "4 185.865952 -35.542394 \n", + "5 192.654778 -29.797913 \n", + "6 228.853218 5.109671 \n", + "7 350.247546 124.197176 \n", + "8 438.530784 208.91436 \n", + "9 474.298656 240.279247 \n", + "\n", + " confidence_interval_upper_bound \n", + "0 120.998872 \n", + "1 161.385322 \n", + "2 180.68003 \n", + "3 189.620985 \n", + "4 185.865952 \n", + "5 192.654778 \n", + "6 228.853218 \n", + "7 350.247546 \n", + "8 438.530784 \n", + "9 474.298656 \n", + "...\n", + "\n", + "[168 rows x 8 columns]" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model = forecasting.ARIMAPlus(\n", + " auto_arima_max_order=5, # Reduce runtime for large datasets\n", + " data_frequency=\"hourly\",\n", + " horizon=168\n", + ")\n", + "X = df_grouped.head(2842-168)[[\"trip_hour\"]]\n", + "y = df_grouped.head(2842-168)[[\"num_trips\"]]\n", + "model.fit(\n", + " X, y\n", + ")\n", + "predictions = model.predict(horizon=168, confidence_level=0.95)\n", + "predictions" + ] + }, + { + "cell_type": "markdown", + "id": "ec5a4513", + "metadata": {}, + "source": [ + "### 4. Compare and Visualize Forecasts\n", + "\n", + "Now we will visualize the forecasts from both TimesFM and ARIMAPlus against the actual historical data. This allows for a direct comparison of the two models' performance." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "7f5b5b1e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 31.7 MB in 11 seconds of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 58.8 MB in 12 seconds of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjMAAAH7CAYAAAA5AR6GAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjcsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvTLEjVAAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzsfXmcFMXd/tNz7M0uh8CicomoIKAGo25AMQZFBN+oGDVeaEx8o3jBKxrfGIN4kGCMV1CjPyNGY/T1TIInqGBEIIjRKCgqCotyKQILLLsz012/P2a6u6q7vtU1Mws7M9vP57Ofnemp6rO66qnne5TBGGMIESJEiBAhQoQoUkTa+wRChAgRIkSIECHyQUhmQoQIESJEiBBFjZDMhAgRIkSIECGKGiGZCREiRIgQIUIUNUIyEyJEiBAhQoQoaoRkJkSIECFChAhR1AjJTIgQIUKECBGiqBGSmRAhQoQIESJEUSMkMyFChAgRIkSIokZIZkKECMCxxx6LY489tr1PI0QIJcJ2GqIjIyQzIUoehmFo/c2fP3+PnM/s2bPJc/jFL36xR86hPfH444/jzjvvbPP9NjU14cYbb8QhhxyCmpoaVFZWYsiQIbj22muxbt26Nj9eiBAhCgdGuDZTiFLHY489Jnz/85//jLlz5+LRRx8Vth9//PHo2bOnr34ikQAAlJWVtcn5zJ49GxdeeCGmT5+O/v37C78NGTIEhx56aJscp1Axfvx4fPjhh1i9enWb7fPzzz/H6NGj0djYiB/96EcYOXIkysrK8J///Ad//etf0bVrV3zyySdtdrxCRFu30xAhigmx9j6BECF2N84991zh++LFizF37lzfdi+am5tRVVW12waHsWPH4vDDD2/z/e7cuRPV1dVtvt9CRSqVwmmnnYaNGzdi/vz5GDlypPD7Lbfcgt/+9rftdHa7H7u7nYYIUQwIzUwhQiDtbzBkyBAsW7YMxxxzDKqqqvC///u/zm+8L8L8+fNhGAaefPJJ/O///i/q6+tRXV2N//qv/8LatWvb7Jxef/11HH300aiurkbnzp3xwx/+EB999JFQZtq0aTAMAytWrMDZZ5+NLl26CIP5Y489huHDh6OyshJdu3bFWWedJT3HJUuW4KSTTkKXLl1QXV2NYcOG4a677nJ+/89//oMLLrgA++23HyoqKlBfX4+f/OQn2Lx5s7Cf7du346qrrkK/fv1QXl6OHj164Pjjj8e7774LIH0vX3jhBaxZs8YxrfXr18+pf8899+Dggw9GVVUVunTpgsMPPxyPP/648j4988wzeP/99/HLX/7SR2QAoLa2Frfccouw7amnnnLuy1577YVzzz0XX331lVDmggsuQE1NDRobGzF+/HjU1NRgn332waxZswAAH3zwAY477jhUV1ejb9++vvO0zYlvvvkm/vu//xvdunVDbW0tzj//fGzZskUo+7e//Q3jxo3D3nvvjfLycgwYMAA33XQTTNMUymXTTnXv57///W+MHTsWtbW1qKmpwQ9+8AMsXrxYei0LFy7ElClT0L17d1RXV+PUU0/F119/LXssIULsUYTKTIgQGWzevBljx47FWWedhXPPPVdqcuJxyy23wDAMXHvttdi0aRPuvPNOjB49Gu+99x4qKysDj7dt2zZ88803wra99toLADBv3jyMHTsW++23H6ZNm4Zdu3bhnnvuwYgRI/Duu+8KBAAAfvSjH2HgwIG49dZbYVuOb7nlFvzqV7/CGWecgZ/+9Kf4+uuvcc899+CYY47Bv//9b3Tu3BkAMHfuXIwfPx69evXClVdeifr6enz00UeYM2cOrrzySqfM559/jgsvvBD19fVYvnw5HnjgASxfvhyLFy+GYRgAgJ///Od4+umncdlll2Hw4MHYvHkz3nrrLXz00Uf4zne+g1/+8pfYtm0bvvzyS9xxxx0AgJqaGgDAgw8+iCuuuAKnn346rrzySrS0tOA///kPlixZgrPPPpu8j3//+98BAOedd17gPQdcM993v/tdzJgxAxs3bsRdd92FhQsXCvcFAEzTxNixY3HMMcdg5syZ+Mtf/oLLLrsM1dXV+OUvf4lzzjkHp512Gu6//36cf/75aGho8JkOL7vsMnTu3BnTpk3DypUrcd9992HNmjUOKbbPqaamBlOmTEFNTQ1ef/113HDDDWhqasJtt90m7E+3nercz+XLl+Poo49GbW0trrnmGsTjcfzxj3/EscceiwULFuDII48U9nn55ZejS5cu+PWvf43Vq1fjzjvvxGWXXYYnn3xS696HCLHbwEKE6GCYNGkS8zb9UaNGMQDs/vvv95UfNWoUGzVqlPP9jTfeYADYPvvsw5qampzt//d//8cAsLvuukt5/IcffpgBkP7ZOPTQQ1mPHj3Y5s2bnW3vv/8+i0Qi7Pzzz3e2/frXv2YA2I9//GPhGKtXr2bRaJTdcsstwvYPPviAxWIxZ3sqlWL9+/dnffv2ZVu2bBHKWpblfG5ubvZdx1//+lcGgL355pvOtrq6OjZp0iTl9Y8bN4717dvXt/2HP/whO/jgg5V1ZTjssMNYXV2dVtlEIsF69OjBhgwZwnbt2uVsnzNnDgPAbrjhBmfbxIkTGQB26623Otu2bNnCKisrmWEY7IknnnC2f/zxxwwA+/Wvf+1ss5/z8OHDWSKRcLbPnDmTAWB/+9vfnG2y+/vf//3frKqqirW0tDjbsmmnOvfzlFNOYWVlZWzVqlXOtnXr1rFOnTqxY445xncto0ePFtrF5MmTWTQaZVu3blUeJ0SI3Y3QzBQiRAbl5eW48MILtcuff/756NSpk/P99NNPR69evfDiiy9q1Z81axbmzp0r/AHA+vXr8d577+GCCy5A165dnfLDhg3D8ccfL93/z3/+c+H7s88+C8uycMYZZ+Cbb75x/urr6zFw4EC88cYbANImhi+++AJXXXWVoEgAcFQDAILS1NLSgm+++QZHHXUUADgmJADo3LkzlixZklP0UOfOnfHll19i6dKlWdVramoSnoMK77zzDjZt2oRLL70UFRUVzvZx48bhoIMOwgsvvOCr89Of/lQ4xwMPPBDV1dU444wznO0HHnggOnfujM8//9xX/+KLL0Y8Hne+X3LJJYjFYsJz5O/v9u3b8c033+Doo49Gc3MzPv74Y2F/uu006H6apolXX30Vp5xyCvbbbz9ne69evXD22WfjrbfeQlNTk+9a+HZx9NFHwzRNrFmzJvB8QoTYnQjJTIgQGeyzzz5ZOVEOHDhQ+G4YBvbff3/tKJ0jjjgCo0ePFv4AOAPDgQce6KszaNAgfPPNN9i5c6ew3Wva+PTTT8EYw8CBA9G9e3fh76OPPsKmTZsAAKtWrQKQjqJS4dtvv8WVV16Jnj17orKyEt27d3eOuW3bNqfczJkz8eGHH6J379444ogjMG3aNOkAL8O1116LmpoaHHHEERg4cCAmTZqEhQsXBtarra3F9u3btY6hurcHHXSQb1CuqKhA9+7dhW11dXXYd999hUHd3u71hQH87aSmpga9evUS2sny5ctx6qmnoq6uDrW1tejevbvjoM7fX0C/nQbdz6+//hrNzc1kO7Msy+df1adPH+F7ly5dAEB63SFC7EmEPjMhQmSg4+dSqPCeu2VZMAwDL730EqLRqK+87aeiizPOOANvv/02pk6dikMPPRQ1NTWwLAsnnngiLMsSyh199NF47rnn8Oqrr+K2227Db3/7Wzz77LMYO3as8hiDBg3CypUrMWfOHLz88st45plncO+99+KGG27AjTfeSNY76KCD8O9//xtr165F7969s7quIMjunWo7yyHTxdatWzFq1CjU1tZi+vTpGDBgACoqKvDuu+/i2muvFe4voN9Oc72fKrTldYcI0ZYIlZkQIXLEp59+KnxnjOGzzz7zOedmi759+wIAVq5c6fvt448/xl577RUYej1gwAAwxtC/f3+f+jN69GjHRDRgwAAAwIcffkjua8uWLXjttdfwi1/8AjfeeCNOPfVUHH/88YJpgkevXr1w6aWX4vnnn8cXX3yBbt26CdFEXkWDR3V1Nc4880w8/PDDaGxsxLhx43DLLbegpaWFrHPyyScD8OcTkkF1b1euXOn83pbwtpMdO3Zg/fr1TjuZP38+Nm/ejNmzZ+PKK6/E+PHjMXr0aEf1yAeq+9m9e3dUVVWR7SwSibQ5OQwRYnchJDMhQuSIP//5z4J54+mnn8b69esDFYgg9OrVC4ceeigeeeQRbN261dn+4Ycf4tVXX8VJJ50UuI/TTjsN0WgUN954o2/WzBhzQqq/853voH///rjzzjuFY9nlAHc27t2PN4uvaZo+k0iPHj2w9957o7W11dlWXV3tKwfAF+ZdVlaGwYMHgzGGZDJJXuvpp5+OoUOH4pZbbsGiRYt8v2/fvh2//OUvAQCHH344evTogfvvv184p5deegkfffQRxo0bRx4nVzzwwAPC+d93331IpVJOO5Hd30QigXvvvTev4wbdz2g0ihNOOAF/+9vfBJPXxo0b8fjjj2PkyJGora3N6xxChNhTCM1MIULkiK5du2LkyJG48MILsXHjRtx5553Yf//98bOf/Szvfd92220YO3YsGhoacNFFFzmh2XV1dZg2bVpg/QEDBuDmm2/Gddddh9WrV+OUU05Bp06d8MUXX+C5557DxRdfjKuvvhqRSAT33XcfTj75ZBx66KG48MIL0atXL3z88cdYvnw5XnnlFdTW1jqhyclkEvvssw9effVVfPHFF8Ixt2/fjn333Renn366s6TAvHnzsHTpUtx+++1OueHD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+wIAhg4dqn2dIQoTiZSFix99Bw37dcN/jxrQ3qcTosAQkpkQIXJAZTyKFdPHtNuxs8WwYcOE77169cKmTZty3kfPnj1RVVXlEBl727/+9a+sz82Lww8/PLDMIYccImRjbmhowI4dO7B27Voccsgh+MEPfoChQ4dizJgxOOGEE3D66aejS5cueZ9biPbD8+99hfkrv8b8lV+HZCaEDyGZCREiBxiGkbWppz3hXXfHMAxYloVIJO02x5tg7KUbVPswDIPcZ76orq7Oq340GsXcuXPx9ttv49VXX8U999yDX/7yl1iyZAn69++f9/mFaB/sSoRLLoSgEToAhwjRgdG9e3cAwPr1651tvDPw7kJZWVle6wG9//772LVrl/N98eLFqKmpQe/evQGkidWIESNw44034t///jfKysrw3HPP5X3eIdoP4coLIVQonqlliBAh2hyVlZU46qij8Jvf/Ab9+/fHpk2bcP311+/24/br1w9LlizB6tWrUVNTg65du2ZVP5FI4KKLLsL111+P1atX49e//jUuu+wyRCIRLFmyBK+99hpOOOEE9OjRA0uWLMHXX3+NQYMG7aarCbEnEHKZECqEykyIEB0cf/rTn5BKpTB8+HBcddVVuPnmm3f7Ma+++mpEo1EMHjwY3bt3R2NjY1b1f/CDH2DgwIE45phjcOaZZ+K//uu/nHDz2tpavPnmmzjppJNwwAEH4Prrr8ftt9+OsWPH7oYrCREiRCHAYEUYr9jU1IS6ujps27YNtbW17X06IToAWlpa8MUXX6B///6oqKho79Pp0LjggguwdetWPP/88+19KkWPYmrXjy5eg189/yEAYPVvxrXz2ZQW+v3iBefz7r63u2v8DpWZECFChAhR8AjNTLsPpeCPFJKZECFCtCkaGxtRU1ND/mVrUgoRAiiNAbdQESmBmxs6AIcIEaJNsffeeysjovbee++89j979uy86ofIHVt2JtCluqxdjm20gTbzzLIvUV0exYlDerXBGZUOIgZQ7IHvIZkJESJEmyIWi2H//fdv79MI0cZ47t9fYvKT7+OSYwfg2hMP2uPHz1c82NjUgv956n0AwBczToJRAmpEWyF9L4rOfVZAaGYKESJEiBCB+PXflgMA7pu/ql2Ony/1aNrlJoMsvrCX3YtSoHUhmQkRIkSIEAWPfIUUvr4VshkBpeAzE5KZECFChAgRiOI3y7jnX8yrQ+8OFP2jRUhmQoQIESJEESBfB+BQmaERKjMhQoQIEaJDoN3Hu3zNTNznkMuIaO9H2xYIyUyIEB0M8+fPh2EY2Lp1a7uex8KFCzF06FDE43Gccsopu+UYjDFcfPHF6Nq1KwzD2COLaIbYPch3wOXNZKEyI6LdiWobICsy069fPxiG4fubNGkSgHRq7EmTJqFbt26oqanBhAkTsHHjRmEfjY2NGDduHKqqqtCjRw9MnToVqVSq7a4oRIgQAo499lhcddVVzvfvfe97WL9+Perq6trvpABMmTIFhx56KL744ovdljvm5ZdfxuzZszFnzhysX78eQ4YM2S3H2RPo168f7rzzznY7fluMd9uak8h1BR2Vz86O1hSSpqWuz30OyYyISKT42UxWZGbp0qVYv3698zd37lwAwI9+9CMAwOTJk/GPf/wDTz31FBYsWIB169bhtNNOc+qbpolx48YhkUjg7bffxiOPPILZs2fjhhtuaMNLChEihAplZWWor69vd4fOVatW4bjjjsO+++6Lzp0777Zj9OrVC9/73vdQX1+PWCz71FqMsXDC1QZ4t3ELDpn+Ki77679zqi+aiVwysmVnAkN+/QqO//0CdX3BZyanUyhZdDifme7du6O+vt75mzNnDgYMGIBRo0Zh27ZteOihh/D73/8exx13HIYPH46HH34Yb7/9NhYvXgwAePXVV7FixQo89thjOPTQQzF27FjcdNNNmDVrFhKJxG65wBAhdgsYAxI72+cvi1nlBRdcgAULFuCuu+5ylNTZs2cLZqbZs2ejc+fOmDNnDg488EBUVVXh9NNPR3NzMx555BH069cPXbp0wRVXXAHTdPOEtra24uqrr8Y+++yD6upqHHnkkZg/f77z+5o1a3DyySejS5cuqK6uxsEHH4wXX3wRq1evhmEY2Lx5M37yk58452Sbv1555RUcdthhqKysxHHHHYdNmzbhpZdewqBBg1BbW4uzzz4bzc3NWtd++eWXo7GxEYZhoF+/fs55X3HFFejRowcqKiowcuRILF261Klnn8dLL72E4cOHo7y8HG+99RYsy8KMGTPQv39/VFZW4pBDDsHTTz8tHHP58uUYP348amtr0alTJxx99NFYtSqdl2Xp0qU4/vjjsddee6Gurg6jRo3Cu+++yzUphmnTpqFPnz4oLy/H3nvvjSuu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2aaeP/3pT+jTpw9qampw6aWXwjRNzJw5E/X19ejRowduueUW4di///3vMXToUFRXV6N379649NJLsWPHDud3+zjPP/88Bg4ciIqKCowZMwZr19L+C8ceeyyuuuoqYdspp5yCCy64wPl+7733Ovvr2bMnzjnrzMD7dMEFF+Dyyy9HY2MjDMNAv379AACtra3OCtcVFRUYOXIkli5d6tSbP38+DMPASy+9hOHDh6O8vBxvvfUWLMvCjBkz0L9/f1RWVuKQQw7B008/LRxz+fLlGD9+PGpra9GpUyccffTRzrNZunQpjj/+eOy1116oq6vDqFGj8O677zp1GWOYNm0a+vTpg/Lycuy999644oornHu0Zs0aTJ48GYZh7NZoFd1xT8cBOKnIKUWFZlMmJ199yOvz+011WDLjfi5WJbpjTB3ygGpWfsVf/42vtu7C//13g7YTW4jskW8/LL6oOexAUmdXaheOfPzInM8pHyw5ewmq4vor/u7cuRNTpkzBsGHDsGPHDtxwww049dRTBX+QX/ziF7j99ttx2GGHoaKiAq+88opvP6tWrcJLL72El19+GatWrcLpp5+Ozz//HAcccAAWLFiAt99+Gz/5yU8wevRoHHlk+t5EIhHcfffd6N+/Pz7//HNceumluOaaa3Dvvfc6+21ubsYtt9yCP//5zygrK8Oll16Ks846CwsXLszp/rzzzju44oor8Oijj+J73/sevv32W7z2xvzAenfddRcGDBiABx54AEuXLkU0GgUAXHPNNXjmmWfwyCOPoG/fvpg5cybGjBmDzz77DF27dhXu4e9+9zvst99+6NKlC2bMmIHHHnsM999/PwYOHIg333wT5557Lrp3745Ro0bhq6++wjHHHINjjz0Wr7/+Ompra7Fw4UKkUmlTx/bt2zFx4kTcc889YIzh9ttvx0knnYRPP/0UnTp1wjPPPIM77rgDTzzxBA4++GBs2LAB77//PgDg2WefxSGHHIKLL74YP/vZz3K6j15Q76HuZIOKSuTzzOSUwVcwM9HHp31u9I5fyvAu1lmMxKAYz3mPQsX6//5+ejn7/3y5FYf16bJHz6sjoS1pYi4O3cU6U7ExYcIE4fuf/vQndO/eHStWrEBNTQ0A4KqrrsJpp52m3I9lWfjTn/6ETp06YfDgwfj+97+PlStX4sUXX0QkEsGBBx6I3/72t3jj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8ggURcEzzzyDhQsXIhaL4ZVXXilyM1144YU488wzcfvtt6OhoQGpVAqXX345stms8Pk1TcP111+Puro6NDU14Zvf/Kbl/S1btuCMM85AMplEKpXC2Wefjebm5qI28Lj66qtx3HHHsf+PO+44rFixAldffTVGjBjBat5ISEgMLUgyIyEh4Yj77rsPixcvxiWXXIKdO3di586dGDduHADgxhtvxHe+8x2sX78ec+fOdTx+5cqVWL9+PV588UU8+eST+PWvf43bb79d+PyPPvooKisr8cYbb+Duu+/GHXfcgeeeew6ATnTOOOMMtLS04G9/+xuee+45bNy4Eeecc07gfj766KOIRqN49dVX8eMf/zjw8RISEoMP6WaSkJBwRHV1NaLRKCoqKtDU1AQA+PDDDwEAd9xxB0488UTP46PRKB5++GFUVFTg4IMPxh133IHrrrsO3/rWt6Cq/vuouXPn4rbbbgMATJ06FQ888ABWrlyJE088EStXrsSaNWuwadMmRrAee+wxHHzwwXjrrbdw2GGHCfdz6tSpuPvuu4U/LyEhUXqQlhkJCYnAOPTQQ30/M2/ePFRUVLD/Fy9ejK6uLmzdulXoHHaLz6hRo7B7924AwPr16zFu3DhGZABg1qxZqKmpwfr164W+n2LhwoWBPi8hIVF6kGRGQkIiMCorKwf8HJFIxPK/oijQNE34eFVVQQixvJbL5Yo+dyD6IiEhMbCQZEZCQsIV0WgUhUKhT8euXr0avb297P/XX38dyWTSYk3pK2bOnImtW7darDzr1q1DW1sbZs2aBQBoaGjAzp07LcetWrVqv88tISFRepBkRkJCwhUTJ07EG2+8gc2bN2Pv3r2BLCPZbBYXX3wx1q1bhz/96U+47bbbsGLFCiG9jB+WLVuGOXPm4Pzzz8e7776LN998ExdccAGOPfZY5gI74YQT8Pbbb+Oxxx7DJ598gttuuw0ffPDBfp9bQkKi9CDJjISEhCu+/vWvIxQKYdasWWhoaMCWLVuEj126dCmmTp2KY445Bueccw5OP/30ovDqvkJRFPzf//0famtrccwxx2DZsmWYNGkSnnrqKfaZk046Cf/+7/+O66+/Hocddhg6OztxwQUX9Mv5JSQkSgsKsTuVJSQkJPYTF154Idra2vDb3/52sJsiISFRBpCWGQkJCQkJCYkhDUlmJCQkDii2bNnCyh84/QRxZUlISEgA0s0kISFxgJHP57F582bX9ydOnIhwWObzlJCQEIckMxISEhISEhJDGtLNJCEhISEhITGkIcmMhISEhISExJCGJDMSEhISEhISQxqSzEhISEhISEgMaUgyIyEhISEhITGkIcmMhISEhISExJCGJDMSEhISEhISQxr/H1aA8JlVgtCdAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "timesfm_result = result.sort_values(\"forecast_timestamp\")[[\"forecast_timestamp\", \"forecast_value\"]]\n", + "timesfm_result = timesfm_result.rename(columns={\n", + " \"forecast_timestamp\": \"trip_hour\",\n", + " \"forecast_value\": \"timesfm_forecast\"\n", + "})\n", + "arimaplus_result = predictions.sort_values(\"forecast_timestamp\")[[\"forecast_timestamp\", \"forecast_value\"]]\n", + "arimaplus_result = arimaplus_result.rename(columns={\n", + " \"forecast_timestamp\": \"trip_hour\",\n", + " \"forecast_value\": \"arimaplus_forecast\"\n", + "})\n", + "df_all = df_grouped.merge(timesfm_result, on=\"trip_hour\", how=\"left\")\n", + "df_all = df_all.merge(arimaplus_result, on=\"trip_hour\", how=\"left\")\n", + "df_all.tail(672).plot.line(\n", + " x=\"trip_hour\",\n", + " y=[\"num_trips\", \"timesfm_forecast\", \"arimaplus_forecast\"],\n", + " rot=45,\n", + " title=\"Trip Forecasts Comparison\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "015804c3", + "metadata": {}, + "source": [ + "### 5. Multiple Time Series Forecasting\n", + "\n", + "This section demonstrates a more advanced capability of ARIMAPlus: forecasting multiple time series simultaneously. This is useful when you have several independent series that you want to model together, such as trip counts from different bikeshare stations. The `id_col` parameter is key here, as it is used to differentiate between the individual time series." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6dbe6c48", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/shuowei/src/python-bigquery-dataframes/bigframes/core/log_adapter.py:182: TimeTravelCacheWarning: Reading cached table from 2025-12-12 23:04:48.874384+00:00 to avoid\n", + "incompatibilies with previous reads of this table. To read the latest\n", + "version, set `use_cache=False` or close the current session with\n", + "Session.close() or bigframes.pandas.close_session().\n", + " return method(*args, **kwargs)\n" + ] + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 69.8 MB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Number of stations: 41\n" + ] + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 69.8 MB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 69.8 MB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Date range: 2013-08-29 to 2018-04-30\n" + ] + }, + { + "data": { + "text/html": [ + "\n", + " Query processed 18.8 MB in 2 minutes of slot time. [Job bigframes-dev:US.74ada07a-98ad-4d03-90bb-2b98f1d8b558 details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 1.4 MB in 4 seconds of slot time. [Job bigframes-dev:US.a292f715-1d9c-406d-a7d5-f99b2ba71660 details]\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 4.6 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 11.5 kB in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. \n", + " Query processed 0 Bytes in a moment of slot time.\n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "00fc1edbf6fd40dfb949a3e3a30b6c3e", + "version_major": 2, + "version_minor": 1 + }, + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
forecast_timestampstart_station_nameforecast_valuestandard_errorconfidence_levelprediction_interval_lower_boundprediction_interval_upper_boundconfidence_interval_lower_boundconfidence_interval_upper_bound
02016-09-01 00:00:00+00:00Beale at Market27.9114173.4224340.9521.21556834.60726521.21556834.607265
12016-09-01 00:00:00+00:00Civic Center BART (7th at Market)17.094554.2662870.958.7477425.4413618.7477425.441361
22016-09-01 00:00:00+00:00Embarcadero at Bryant22.3436483.3937020.9515.70401228.98328415.70401228.983284
32016-09-01 00:00:00+00:00Embarcadero at Folsom28.253293.3821580.9521.6362434.87033921.6362434.870339
42016-09-01 00:00:00+00:00Embarcadero at Sansome52.5380836.2692910.9540.27247764.80368940.27247764.803689
52016-09-01 00:00:00+00:00Embarcadero at Vallejo16.5132332.9536830.9510.73447622.2919910.73447622.29199
62016-09-01 00:00:00+00:00Market at 10th34.0512746.2057980.9521.9098946.19265821.9098946.192658
72016-09-01 00:00:00+00:00Market at 4th25.7460294.0015920.9517.91708233.57497717.91708233.574977
82016-09-01 00:00:00+00:00Market at Sansome46.1343685.0718520.9536.21150356.05723336.21150356.057233
92016-09-01 00:00:00+00:00Mechanics Plaza (Market at Battery)23.2699413.1946750.9517.01969229.52018917.01969229.520189
\n", + "

10 rows × 9 columns

\n", + "
[123 rows x 9 columns in total]" + ], + "text/plain": [ + " forecast_timestamp start_station_name \\\n", + "0 2016-09-01 00:00:00+00:00 Beale at Market \n", + "1 2016-09-01 00:00:00+00:00 Civic Center BART (7th at Market) \n", + "2 2016-09-01 00:00:00+00:00 Embarcadero at Bryant \n", + "3 2016-09-01 00:00:00+00:00 Embarcadero at Folsom \n", + "4 2016-09-01 00:00:00+00:00 Embarcadero at Sansome \n", + "5 2016-09-01 00:00:00+00:00 Embarcadero at Vallejo \n", + "6 2016-09-01 00:00:00+00:00 Market at 10th \n", + "7 2016-09-01 00:00:00+00:00 Market at 4th \n", + "8 2016-09-01 00:00:00+00:00 Market at Sansome \n", + "9 2016-09-01 00:00:00+00:00 Mechanics Plaza (Market at Battery) \n", + "\n", + " forecast_value standard_error confidence_level \\\n", + "0 27.911417 3.422434 0.95 \n", + "1 17.09455 4.266287 0.95 \n", + "2 22.343648 3.393702 0.95 \n", + "3 28.25329 3.382158 0.95 \n", + "4 52.538083 6.269291 0.95 \n", + "5 16.513233 2.953683 0.95 \n", + "6 34.051274 6.205798 0.95 \n", + "7 25.746029 4.001592 0.95 \n", + "8 46.134368 5.071852 0.95 \n", + "9 23.269941 3.194675 0.95 \n", + "\n", + " prediction_interval_lower_bound prediction_interval_upper_bound \\\n", + "0 21.215568 34.607265 \n", + "1 8.74774 25.441361 \n", + "2 15.704012 28.983284 \n", + "3 21.63624 34.870339 \n", + "4 40.272477 64.803689 \n", + "5 10.734476 22.29199 \n", + "6 21.90989 46.192658 \n", + "7 17.917082 33.574977 \n", + "8 36.211503 56.057233 \n", + "9 17.019692 29.520189 \n", + "\n", + " confidence_interval_lower_bound confidence_interval_upper_bound \n", + "0 21.215568 34.607265 \n", + "1 8.74774 25.441361 \n", + "2 15.704012 28.983284 \n", + "3 21.63624 34.870339 \n", + "4 40.272477 64.803689 \n", + "5 10.734476 22.29199 \n", + "6 21.90989 46.192658 \n", + "7 17.917082 33.574977 \n", + "8 36.211503 56.057233 \n", + "9 17.019692 29.520189 \n", + "...\n", + "\n", + "[123 rows x 9 columns]" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_multi = bpd.read_gbq(\"bigquery-public-data.san_francisco_bikeshare.bikeshare_trips\")\n", + "df_multi = df_multi[df_multi[\"start_station_name\"].str.contains(\"Market|Powell|Embarcadero\")]\n", + " \n", + "# Create daily aggregation\n", + "features = bpd.DataFrame({\n", + " \"start_station_name\": df_multi[\"start_station_name\"],\n", + " \"date\": df_multi[\"start_date\"].dt.date,\n", + "})\n", + "\n", + "# Group by station and date\n", + "num_trips = features.groupby(\n", + " [\"start_station_name\", \"date\"], as_index=False\n", + ").size()\n", + "# Rename the size column to \"num_trips\"\n", + "num_trips = num_trips.rename(columns={num_trips.columns[-1]: \"num_trips\"})\n", + "\n", + "# Check data quality\n", + "print(f\"Number of stations: {num_trips['start_station_name'].nunique()}\")\n", + "print(f\"Date range: {num_trips['date'].min()} to {num_trips['date'].max()}\")\n", + "\n", + "# Use daily frequency \n", + "model = forecasting.ARIMAPlus(\n", + " data_frequency=\"daily\",\n", + " horizon=30,\n", + " auto_arima_max_order=3,\n", + " min_time_series_length=10,\n", + " time_series_length_fraction=0.8\n", + ")\n", + "\n", + "model.fit(\n", + " num_trips[[\"date\"]],\n", + " num_trips[[\"num_trips\"]],\n", + " id_col=num_trips[[\"start_station_name\"]]\n", + ")\n", + "\n", + "predictions_multi = model.predict()\n", + "predictions_multi" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 4d5de14ccdd05b1ac8f50c3fe71c35ab9e5150c1 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Mon, 15 Dec 2025 14:13:53 -0800 Subject: [PATCH 293/313] feat: Auto-plan complex reduction expressions (#2298) --- bigframes/core/array_value.py | 92 ++++++- bigframes/core/block_transforms.py | 125 +++------ bigframes/core/blocks.py | 145 ++++------ bigframes/core/expression.py | 5 + bigframes/core/expression_factoring.py | 271 +++++++++++++++---- bigframes/core/graphs.py | 76 ++++++ bigframes/core/groupby/dataframe_group_by.py | 31 ++- bigframes/core/groupby/group_by.py | 2 +- bigframes/core/groupby/series_group_by.py | 17 +- bigframes/core/ordered_sets.py | 139 ++++++++++ bigframes/dataframe.py | 8 +- bigframes/operations/aggregations.py | 13 + bigframes/pandas/core/methods/describe.py | 2 +- bigframes/series.py | 10 +- 14 files changed, 664 insertions(+), 272 deletions(-) create mode 100644 bigframes/core/graphs.py create mode 100644 bigframes/core/ordered_sets.py diff --git a/bigframes/core/array_value.py b/bigframes/core/array_value.py index 2cc8fdf3f0..7901243e4b 100644 --- a/bigframes/core/array_value.py +++ b/bigframes/core/array_value.py @@ -16,7 +16,6 @@ from dataclasses import dataclass import datetime import functools -import itertools import typing from typing import Iterable, List, Mapping, Optional, Sequence, Tuple @@ -267,21 +266,96 @@ def compute_values(self, assignments: Sequence[ex.Expression]): ) def compute_general_expression(self, assignments: Sequence[ex.Expression]): + """ + Applies arbitrary column expressions to the current execution block. + + This method transforms the logical plan by applying a sequence of expressions that + preserve the length of the input columns. It supports both scalar operations + and window functions. Each expression is assigned a unique internal column identifier. + + Args: + assignments (Sequence[ex.Expression]): A sequence of expression objects + representing the transformations to apply to the columns. + + Returns: + Tuple[ArrayValue, Tuple[str, ...]]: A tuple containing: + - An `ArrayValue` wrapping the new root node of the updated logical plan. + - A tuple of strings representing the unique column IDs generated for + each expression in the assignments. + """ named_exprs = [ nodes.ColumnDef(expr, ids.ColumnId.unique()) for expr in assignments ] # TODO: Push this to rewrite later to go from block expression to planning form - # TODO: Jointly fragmentize expressions to more efficiently reuse common sub-expressions - fragments = tuple( - itertools.chain.from_iterable( - expression_factoring.fragmentize_expression(expr) - for expr in named_exprs - ) - ) + new_root = expression_factoring.apply_col_exprs_to_plan(self.node, named_exprs) + target_ids = tuple(named_expr.id for named_expr in named_exprs) - new_root = expression_factoring.push_into_tree(self.node, fragments, target_ids) return (ArrayValue(new_root), target_ids) + def compute_general_reduction( + self, + assignments: Sequence[ex.Expression], + by_column_ids: typing.Sequence[str] = (), + *, + dropna: bool = False, + ): + """ + Applies arbitrary aggregation expressions to the block, optionally grouped by keys. + + This method handles reduction operations (e.g., sum, mean, count) that collapse + multiple input rows into a single scalar value per group. If grouping keys are + provided, the operation is performed per group; otherwise, it is a global reduction. + + Note: Intermediate aggregations (those that are inputs to further aggregations) + must be windowizable. Notably excluded are approx quantile, top count ops. + + Args: + assignments (Sequence[ex.Expression]): A sequence of aggregation expressions + to be calculated. + by_column_ids (typing.Sequence[str], optional): A sequence of column IDs + to use as grouping keys. Defaults to an empty tuple (global reduction). + dropna (bool, optional): If True, rows containing null values in the + `by_column_ids` columns will be filtered out before the reduction + is applied. Defaults to False. + + Returns: + ArrayValue: + The new root node representing the aggregation/group-by result. + """ + plan = self.node + + # shortcircuit to keep things simple if all aggs are simple + # TODO: Fully unify paths once rewriters are strong enough to simplify complexity from full path + def _is_direct_agg(agg_expr): + return isinstance(agg_expr, agg_expressions.Aggregation) and all( + isinstance(child, (ex.DerefOp, ex.ScalarConstantExpression)) + for child in agg_expr.children + ) + + if all(_is_direct_agg(agg) for agg in assignments): + agg_defs = tuple((agg, ids.ColumnId.unique()) for agg in assignments) + return ArrayValue( + nodes.AggregateNode( + child=self.node, + aggregations=agg_defs, # type: ignore + by_column_ids=tuple(map(ex.deref, by_column_ids)), + dropna=dropna, + ) + ) + + if dropna: + for col_id in by_column_ids: + plan = nodes.FilterNode(plan, ops.notnull_op.as_expr(col_id)) + + named_exprs = [ + nodes.ColumnDef(expr, ids.ColumnId.unique()) for expr in assignments + ] + # TODO: Push this to rewrite later to go from block expression to planning form + new_root = expression_factoring.apply_agg_exprs_to_plan( + plan, named_exprs, grouping_keys=[ex.deref(by) for by in by_column_ids] + ) + return ArrayValue(new_root) + def project_to_id(self, expression: ex.Expression): array_val, ids = self.compute_values( [expression], diff --git a/bigframes/core/block_transforms.py b/bigframes/core/block_transforms.py index 16be560b05..31ab0312f2 100644 --- a/bigframes/core/block_transforms.py +++ b/bigframes/core/block_transforms.py @@ -129,12 +129,12 @@ def quantile( window_spec=window, ) quantile_cols.append(quantile_col) - block, _ = block.aggregate( - grouping_column_ids, + block = block.aggregate( tuple( agg_expressions.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(col)) for col in quantile_cols ), + grouping_column_ids, column_labels=pd.Index(labels), dropna=dropna, ) @@ -358,12 +358,12 @@ def value_counts( if grouping_keys and drop_na: # only need this if grouping_keys is involved, otherwise the drop_na in the aggregation will handle it for us block = dropna(block, columns, how="any") - block, agg_ids = block.aggregate( - by_column_ids=(*grouping_keys, *columns), + block = block.aggregate( aggregations=[agg_expressions.NullaryAggregation(agg_ops.size_op)], + by_column_ids=(*grouping_keys, *columns), dropna=drop_na and not grouping_keys, ) - count_id = agg_ids[0] + count_id = block.value_columns[0] if normalize: unbound_window = windows.unbound(grouping_keys=tuple(grouping_keys)) block, total_count_id = block.apply_window_op( @@ -622,40 +622,28 @@ def skew( original_columns = skew_column_ids column_labels = block.select_columns(original_columns).column_labels - block, delta3_ids = _mean_delta_to_power( - block, 3, original_columns, grouping_column_ids - ) # counts, moment3 for each column aggregations = [] - for i, col in enumerate(original_columns): + for col in original_columns: + delta3_expr = _mean_delta_to_power(3, col) count_agg = agg_expressions.UnaryAggregation( agg_ops.count_op, ex.deref(col), ) moment3_agg = agg_expressions.UnaryAggregation( agg_ops.mean_op, - ex.deref(delta3_ids[i]), + delta3_expr, ) variance_agg = agg_expressions.UnaryAggregation( agg_ops.PopVarOp(), ex.deref(col), ) - aggregations.extend([count_agg, moment3_agg, variance_agg]) + skew_expr = _skew_from_moments_and_count(count_agg, moment3_agg, variance_agg) + aggregations.append(skew_expr) - block, agg_ids = block.aggregate( - by_column_ids=grouping_column_ids, aggregations=aggregations + block = block.aggregate( + aggregations, grouping_column_ids, column_labels=column_labels ) - - skew_ids = [] - for i, col in enumerate(original_columns): - # Corresponds to order of aggregations in preceding loop - count_id, moment3_id, var_id = agg_ids[i * 3 : (i * 3) + 3] - block, skew_id = _skew_from_moments_and_count( - block, count_id, moment3_id, var_id - ) - skew_ids.append(skew_id) - - block = block.select_columns(skew_ids).with_column_labels(column_labels) if not grouping_column_ids: # When ungrouped, transpose result row into a series # perform transpose last, so as to not invalidate cache @@ -672,36 +660,23 @@ def kurt( ) -> blocks.Block: original_columns = skew_column_ids column_labels = block.select_columns(original_columns).column_labels - - block, delta4_ids = _mean_delta_to_power( - block, 4, original_columns, grouping_column_ids - ) # counts, moment4 for each column - aggregations = [] - for i, col in enumerate(original_columns): + kurt_exprs = [] + for col in original_columns: + delta_4_expr = _mean_delta_to_power(4, col) count_agg = agg_expressions.UnaryAggregation(agg_ops.count_op, ex.deref(col)) - moment4_agg = agg_expressions.UnaryAggregation( - agg_ops.mean_op, ex.deref(delta4_ids[i]) - ) + moment4_agg = agg_expressions.UnaryAggregation(agg_ops.mean_op, delta_4_expr) variance_agg = agg_expressions.UnaryAggregation( agg_ops.PopVarOp(), ex.deref(col) ) - aggregations.extend([count_agg, moment4_agg, variance_agg]) - - block, agg_ids = block.aggregate( - by_column_ids=grouping_column_ids, aggregations=aggregations - ) - kurt_ids = [] - for i, col in enumerate(original_columns): # Corresponds to order of aggregations in preceding loop - count_id, moment4_id, var_id = agg_ids[i * 3 : (i * 3) + 3] - block, kurt_id = _kurt_from_moments_and_count( - block, count_id, moment4_id, var_id - ) - kurt_ids.append(kurt_id) + kurt_expr = _kurt_from_moments_and_count(count_agg, moment4_agg, variance_agg) + kurt_exprs.append(kurt_expr) - block = block.select_columns(kurt_ids).with_column_labels(column_labels) + block = block.aggregate( + kurt_exprs, grouping_column_ids, column_labels=column_labels + ) if not grouping_column_ids: # When ungrouped, transpose result row into a series # perform transpose last, so as to not invalidate cache @@ -712,38 +687,30 @@ def kurt( def _mean_delta_to_power( - block: blocks.Block, n_power: int, - column_ids: typing.Sequence[str], - grouping_column_ids: typing.Sequence[str], -) -> typing.Tuple[blocks.Block, typing.Sequence[str]]: + val_id: str, +) -> ex.Expression: """Calculate (x-mean(x))^n. Useful for calculating moment statistics such as skew and kurtosis.""" - window = windows.unbound(grouping_keys=tuple(grouping_column_ids)) - block, mean_ids = block.multi_apply_window_op(column_ids, agg_ops.mean_op, window) - delta_ids = [] - for val_id, mean_val_id in zip(column_ids, mean_ids): - delta = ops.sub_op.as_expr(val_id, mean_val_id) - delta_power = ops.pow_op.as_expr(delta, ex.const(n_power)) - block, delta_power_id = block.project_expr(delta_power) - delta_ids.append(delta_power_id) - return block, delta_ids + mean_expr = agg_expressions.UnaryAggregation(agg_ops.mean_op, ex.deref(val_id)) + delta = ops.sub_op.as_expr(val_id, mean_expr) + return ops.pow_op.as_expr(delta, ex.const(n_power)) def _skew_from_moments_and_count( - block: blocks.Block, count_id: str, moment3_id: str, moment2_id: str -) -> typing.Tuple[blocks.Block, str]: + count: ex.Expression, moment3: ex.Expression, moment2: ex.Expression +) -> ex.Expression: # Calculate skew using count, third moment and population variance # See G1 estimator: # https://en.wikipedia.org/wiki/Skewness#Sample_skewness moments_estimator = ops.div_op.as_expr( - moment3_id, ops.pow_op.as_expr(moment2_id, ex.const(3 / 2)) + moment3, ops.pow_op.as_expr(moment2, ex.const(3 / 2)) ) - countminus1 = ops.sub_op.as_expr(count_id, ex.const(1)) - countminus2 = ops.sub_op.as_expr(count_id, ex.const(2)) + countminus1 = ops.sub_op.as_expr(count, ex.const(1)) + countminus2 = ops.sub_op.as_expr(count, ex.const(2)) adjustment = ops.div_op.as_expr( ops.unsafe_pow_op.as_expr( - ops.mul_op.as_expr(count_id, countminus1), ex.const(1 / 2) + ops.mul_op.as_expr(count, countminus1), ex.const(1 / 2) ), countminus2, ) @@ -752,14 +719,14 @@ def _skew_from_moments_and_count( # Need to produce NA if have less than 3 data points cleaned_skew = ops.where_op.as_expr( - skew, ops.ge_op.as_expr(count_id, ex.const(3)), ex.const(None) + skew, ops.ge_op.as_expr(count, ex.const(3)), ex.const(None) ) - return block.project_expr(cleaned_skew) + return cleaned_skew def _kurt_from_moments_and_count( - block: blocks.Block, count_id: str, moment4_id: str, moment2_id: str -) -> typing.Tuple[blocks.Block, str]: + count: ex.Expression, moment4: ex.Expression, moment2: ex.Expression +) -> ex.Expression: # Kurtosis is often defined as the second standardize moment: moment(4)/moment(2)**2 # Pandas however uses Fisher’s estimator, implemented below # numerator = (count + 1) * (count - 1) * moment4 @@ -768,28 +735,26 @@ def _kurt_from_moments_and_count( # kurtosis = (numerator / denominator) - adjustment numerator = ops.mul_op.as_expr( - moment4_id, + moment4, ops.mul_op.as_expr( - ops.sub_op.as_expr(count_id, ex.const(1)), - ops.add_op.as_expr(count_id, ex.const(1)), + ops.sub_op.as_expr(count, ex.const(1)), + ops.add_op.as_expr(count, ex.const(1)), ), ) # Denominator - countminus2 = ops.sub_op.as_expr(count_id, ex.const(2)) - countminus3 = ops.sub_op.as_expr(count_id, ex.const(3)) + countminus2 = ops.sub_op.as_expr(count, ex.const(2)) + countminus3 = ops.sub_op.as_expr(count, ex.const(3)) # Denominator denominator = ops.mul_op.as_expr( - ops.unsafe_pow_op.as_expr(moment2_id, ex.const(2)), + ops.unsafe_pow_op.as_expr(moment2, ex.const(2)), ops.mul_op.as_expr(countminus2, countminus3), ) # Adjustment adj_num = ops.mul_op.as_expr( - ops.unsafe_pow_op.as_expr( - ops.sub_op.as_expr(count_id, ex.const(1)), ex.const(2) - ), + ops.unsafe_pow_op.as_expr(ops.sub_op.as_expr(count, ex.const(1)), ex.const(2)), ex.const(3), ) adj_denom = ops.mul_op.as_expr(countminus2, countminus3) @@ -800,9 +765,9 @@ def _kurt_from_moments_and_count( # Need to produce NA if have less than 4 data points cleaned_kurt = ops.where_op.as_expr( - kurt, ops.ge_op.as_expr(count_id, ex.const(4)), ex.const(None) + kurt, ops.ge_op.as_expr(count, ex.const(4)), ex.const(None) ) - return block.project_expr(cleaned_kurt) + return cleaned_kurt def align( diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index df7c6dee43..0f98f582c2 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -1146,13 +1146,15 @@ def project_exprs( index_labels=self._index_labels, ) - # This is a new experimental version of the project_exprs that supports mixing analytic and scalar expressions def project_block_exprs( self, exprs: Sequence[ex.Expression], labels: Union[Sequence[Label], pd.Index], drop=False, ) -> Block: + """ + Version of the project_exprs that supports mixing analytic and scalar expressions + """ new_array, _ = self.expr.compute_general_expression(exprs) if drop: new_array = new_array.drop_columns(self.value_columns) @@ -1167,6 +1169,55 @@ def project_block_exprs( index_labels=self._index_labels, ) + def aggregate( + self, + aggregations: typing.Sequence[ex.Expression] = (), + by_column_ids: typing.Sequence[str] = (), + column_labels: Optional[pd.Index] = None, + *, + dropna: bool = True, + ) -> Block: + """ + Apply aggregations to the block. + + Grouping columns will form the index of the result block. + + Arguments: + aggregations: Aggregation expressions to apply + by_column_id: column id of the aggregation key, this is preserved through the transform and used as index. + dropna: whether null keys should be dropped + + Returns: + Block + """ + if column_labels is None: + column_labels = pd.Index(range(len(aggregations))) + + result_expr = self.expr.compute_general_reduction( + aggregations, by_column_ids, dropna=dropna + ) + + grouping_col_labels: typing.List[Label] = [] + if len(by_column_ids) == 0: + # in the absence of grouping columns, there will be a single row output, assign 0 as its row label. + result_expr, label_id = result_expr.create_constant(0, pd.Int64Dtype()) + index_columns = (label_id,) + grouping_col_labels = [None] + else: + index_columns = tuple(by_column_ids) # type: ignore + for by_col_id in by_column_ids: + if by_col_id in self.value_columns: + grouping_col_labels.append(self.col_id_to_label[by_col_id]) + else: + grouping_col_labels.append(self.col_id_to_index_name[by_col_id]) + + return Block( + result_expr, + index_columns=index_columns, + column_labels=column_labels, + index_labels=grouping_col_labels, + ) + def apply_window_op( self, column: str, @@ -1297,37 +1348,6 @@ def aggregate_all_and_stack( block, id = self.project_expr(reduced, None) return block.select_column(id).with_column_labels(pd.Index([None])) - def aggregate_size( - self, - by_column_ids: typing.Sequence[str] = (), - *, - dropna: bool = True, - ): - """Returns a block object to compute the size(s) of groups.""" - agg_specs = [ - ( - agg_expressions.NullaryAggregation(agg_ops.SizeOp()), - guid.generate_guid(), - ), - ] - output_col_ids = [agg_spec[1] for agg_spec in agg_specs] - result_expr = self.expr.aggregate(agg_specs, by_column_ids, dropna=dropna) - names: typing.List[Label] = [] - for by_col_id in by_column_ids: - if by_col_id in self.value_columns: - names.append(self.col_id_to_label[by_col_id]) - else: - names.append(self.col_id_to_index_name[by_col_id]) - return ( - Block( - result_expr, - index_columns=by_column_ids, - column_labels=["size"], - index_labels=names, - ), - output_col_ids, - ) - def select_column(self, id: str) -> Block: return self.select_columns([id]) @@ -1376,63 +1396,6 @@ def remap_f(x): col_labels.append(remap_f(col_label)) return self.with_column_labels(col_labels) - def aggregate( - self, - by_column_ids: typing.Sequence[str] = (), - aggregations: typing.Sequence[agg_expressions.Aggregation] = (), - column_labels: Optional[pd.Index] = None, - *, - dropna: bool = True, - ) -> typing.Tuple[Block, typing.Sequence[str]]: - """ - Apply aggregations to the block. - - Arguments: - by_column_id: column id of the aggregation key, this is preserved through the transform and used as index. - aggregations: input_column_id, operation tuples - dropna: whether null keys should be dropped - - Returns: - Tuple[Block, Sequence[str]]: - The first element is the grouped block. The second is the - column IDs corresponding to each applied aggregation. - """ - if column_labels is None: - column_labels = pd.Index(range(len(aggregations))) - - agg_specs = [ - ( - aggregation, - guid.generate_guid(), - ) - for aggregation in aggregations - ] - output_col_ids = [agg_spec[1] for agg_spec in agg_specs] - result_expr = self.expr.aggregate(agg_specs, by_column_ids, dropna=dropna) - - names: typing.List[Label] = [] - if len(by_column_ids) == 0: - result_expr, label_id = result_expr.create_constant(0, pd.Int64Dtype()) - index_columns = (label_id,) - names = [None] - else: - index_columns = tuple(by_column_ids) # type: ignore - for by_col_id in by_column_ids: - if by_col_id in self.value_columns: - names.append(self.col_id_to_label[by_col_id]) - else: - names.append(self.col_id_to_index_name[by_col_id]) - - return ( - Block( - result_expr, - index_columns=index_columns, - column_labels=column_labels, - index_labels=names, - ), - output_col_ids, - ) - def get_stat( self, column_id: str, @@ -1792,7 +1755,7 @@ def pivot( agg_expressions.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(col_id)) for col_id in column_ids ] - result_block, _ = block.aggregate( + result_block = block.aggregate( by_column_ids=self.index_columns, aggregations=aggregations, dropna=True, @@ -2246,7 +2209,7 @@ def _get_unique_values( self.select_columns(columns), columns ) else: - unique_value_block, _ = self.aggregate(by_column_ids=columns, dropna=False) + unique_value_block = self.aggregate(by_column_ids=columns, dropna=False) col_labels = self._get_labels_for_columns(columns) unique_value_block = unique_value_block.reset_index( drop=False diff --git a/bigframes/core/expression.py b/bigframes/core/expression.py index 22b566f3ac..5b911de808 100644 --- a/bigframes/core/expression.py +++ b/bigframes/core/expression.py @@ -152,6 +152,11 @@ def bottom_up(self, t: Callable[[Expression], Expression]) -> Expression: expr = t(expr) return expr + def top_down(self, t: Callable[[Expression], Expression]) -> Expression: + expr = t(self) + expr = expr.transform_children(lambda child: child.top_down(t)) + return expr + def walk(self) -> Generator[Expression, None, None]: yield self for child in self.children: diff --git a/bigframes/core/expression_factoring.py b/bigframes/core/expression_factoring.py index d7ac49b585..ac62aeab38 100644 --- a/bigframes/core/expression_factoring.py +++ b/bigframes/core/expression_factoring.py @@ -1,13 +1,100 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + import collections import dataclasses import functools -from typing import cast, Generic, Hashable, Iterable, Optional, Sequence, Tuple, TypeVar - -from bigframes.core import agg_expressions, expression, identifiers, nodes, window_spec +import itertools +from typing import ( + cast, + Hashable, + Iterable, + Iterator, + Mapping, + Optional, + Sequence, + Tuple, + TypeVar, +) + +from bigframes.core import ( + agg_expressions, + expression, + graphs, + identifiers, + nodes, + window_spec, +) _MAX_INLINE_COMPLEXITY = 10 +def apply_col_exprs_to_plan( + plan: nodes.BigFrameNode, col_exprs: Sequence[nodes.ColumnDef] +) -> nodes.BigFrameNode: + # TODO: Jointly fragmentize expressions to more efficiently reuse common sub-expressions + target_ids = tuple(named_expr.id for named_expr in col_exprs) + + fragments = tuple( + itertools.chain.from_iterable( + fragmentize_expression(expr) for expr in col_exprs + ) + ) + return push_into_tree(plan, fragments, target_ids) + + +def apply_agg_exprs_to_plan( + plan: nodes.BigFrameNode, + agg_defs: Sequence[nodes.ColumnDef], + grouping_keys: Sequence[expression.DerefOp], +) -> nodes.BigFrameNode: + factored_aggs = [factor_aggregation(agg_def) for agg_def in agg_defs] + all_inputs = list( + itertools.chain(*(factored_agg.agg_inputs for factored_agg in factored_aggs)) + ) + window_def = window_spec.WindowSpec(grouping_keys=tuple(grouping_keys)) + windowized_inputs = [ + nodes.ColumnDef(windowize(cdef.expression, window_def), cdef.id) + for cdef in all_inputs + ] + plan = apply_col_exprs_to_plan(plan, windowized_inputs) + all_aggs = list( + itertools.chain(*(factored_agg.agg_exprs for factored_agg in factored_aggs)) + ) + plan = nodes.AggregateNode( + plan, + tuple((cdef.expression, cdef.id) for cdef in all_aggs), # type: ignore + by_column_ids=tuple(grouping_keys), + ) + + post_scalar_exprs = tuple( + (factored_agg.root_scalar_expr for factored_agg in factored_aggs) + ) + plan = nodes.ProjectionNode( + plan, tuple((cdef.expression, cdef.id) for cdef in post_scalar_exprs) + ) + final_ids = itertools.chain( + (ref.id for ref in grouping_keys), (cdef.id for cdef in post_scalar_exprs) + ) + plan = nodes.SelectionNode( + plan, tuple(nodes.AliasedRef.identity(ident) for ident in final_ids) + ) + + return plan + + @dataclasses.dataclass(frozen=True, eq=False) class FactoredExpression: root_expr: expression.Expression @@ -24,6 +111,111 @@ def fragmentize_expression(root: nodes.ColumnDef) -> Sequence[nodes.ColumnDef]: return (root_expr, *factored_expr.sub_exprs) +@dataclasses.dataclass(frozen=True, eq=False) +class FactoredAggregation: + """ + A three part recomposition of a general aggregating expression. + + 1. agg_inputs: This is a set of (*col) -> col transformation that preprocess inputs for the aggregations ops + 2. agg_exprs: This is a set of pure aggregations (eg sum, mean, min, max) ops referencing the outputs of (1) + 3. root_scalar_expr: This is the final set, takes outputs of (2), applies scalar expression to produce final result. + """ + + # pure scalar expression + root_scalar_expr: nodes.ColumnDef + # pure agg expression, only refs cols and consts + agg_exprs: Tuple[nodes.ColumnDef, ...] + # can be analytic, scalar op, const, col refs + agg_inputs: Tuple[nodes.ColumnDef, ...] + + +def windowize( + root: expression.Expression, window: window_spec.WindowSpec +) -> expression.Expression: + def windowize_local(expr: expression.Expression): + if isinstance(expr, agg_expressions.Aggregation): + if not expr.op.can_be_windowized: + raise ValueError(f"Op: {expr.op} cannot be windowized.") + return agg_expressions.WindowExpression(expr, window) + if isinstance(expr, agg_expressions.WindowExpression): + raise ValueError(f"Expression {expr} already windowed!") + return expr + + return root.bottom_up(windowize_local) + + +def factor_aggregation(root: nodes.ColumnDef) -> FactoredAggregation: + """ + Factor an aggregation def into three components. + 1. Input column expressions (includes analytic expressions) + 2. The set of underlying primitive aggregations + 3. A final post-aggregate scalar expression + """ + final_aggs = list(dedupe(find_final_aggregations(root.expression))) + agg_inputs = list( + dedupe(itertools.chain.from_iterable(map(find_agg_inputs, final_aggs))) + ) + + agg_input_defs = tuple( + nodes.ColumnDef(expr, identifiers.ColumnId.unique()) for expr in agg_inputs + ) + agg_inputs_dict = { + cdef.expression: expression.DerefOp(cdef.id) for cdef in agg_input_defs + } + + agg_expr_to_ids = {expr: identifiers.ColumnId.unique() for expr in final_aggs} + + isolated_aggs = tuple( + nodes.ColumnDef(sub_expressions(expr, agg_inputs_dict), agg_expr_to_ids[expr]) + for expr in final_aggs + ) + agg_outputs_dict = { + expr: expression.DerefOp(id) for expr, id in agg_expr_to_ids.items() + } + + root_scalar_expr = nodes.ColumnDef( + sub_expressions(root.expression, agg_outputs_dict), root.id # type: ignore + ) + + return FactoredAggregation( + root_scalar_expr=root_scalar_expr, + agg_exprs=isolated_aggs, + agg_inputs=agg_input_defs, + ) + + +def sub_expressions( + root: expression.Expression, + replacements: Mapping[expression.Expression, expression.Expression], +) -> expression.Expression: + return root.top_down(lambda x: replacements.get(x, x)) + + +def find_final_aggregations( + root: expression.Expression, +) -> Iterator[agg_expressions.Aggregation]: + if isinstance(root, agg_expressions.Aggregation): + yield root + elif isinstance(root, expression.OpExpression): + for child in root.children: + yield from find_final_aggregations(child) + elif isinstance(root, expression.ScalarConstantExpression): + return + else: + # eg, window expression, column references not allowed + raise ValueError(f"Unexpected node: {root}") + + +def find_agg_inputs( + root: agg_expressions.Aggregation, +) -> Iterator[expression.Expression]: + for child in root.children: + if not isinstance( + child, (expression.DerefOp, expression.ScalarConstantExpression) + ): + yield child + + def gather_fragments( root: expression.Expression, fragmentized_children: Sequence[FactoredExpression] ) -> FactoredExpression: @@ -57,56 +249,6 @@ def replace_children( return root.transform_children(lambda x: mapping.get(x, x)) -T = TypeVar("T", bound=Hashable) - - -class DiGraph(Generic[T]): - def __init__(self, edges: Iterable[Tuple[T, T]]): - self._parents = collections.defaultdict(set) - self._children = collections.defaultdict(set) # specifically, unpushed ones - # use dict for stable ordering, which grants determinism - self._sinks: dict[T, None] = dict() - for src, dst in edges: - self._children[src].add(dst) - self._parents[dst].add(src) - # sinks have no children - if not self._children[dst]: - self._sinks[dst] = None - if src in self._sinks: - del self._sinks[src] - - @property - def nodes(self): - # should be the same set of ids as self._parents - return self._children.keys() - - @property - def sinks(self) -> Iterable[T]: - return self._sinks.keys() - - @property - def empty(self): - return len(self.nodes) == 0 - - def parents(self, node: T) -> set[T]: - return self._parents[node] - - def children(self, node: T) -> set[T]: - return self._children[node] - - def remove_node(self, node: T) -> None: - for child in self._children[node]: - self._parents[child].remove(node) - for parent in self._parents[node]: - self._children[parent].remove(node) - if len(self._children[parent]) == 0: - self._sinks[parent] = None - del self._children[node] - del self._parents[node] - if node in self._sinks: - del self._sinks[node] - - def push_into_tree( root: nodes.BigFrameNode, exprs: Sequence[nodes.ColumnDef], @@ -115,15 +257,18 @@ def push_into_tree( curr_root = root by_id = {expr.id: expr for expr in exprs} # id -> id - graph = DiGraph( - (expr.id, child_id) - for expr in exprs - for child_id in expr.expression.column_references - if child_id in by_id.keys() + graph = graphs.DiGraph( + (expr.id for expr in exprs), + ( + (expr.id, child_id) + for expr in exprs + for child_id in expr.expression.column_references + if child_id in by_id.keys() + ), ) # TODO: Also prevent inlining expensive or non-deterministic # We avoid inlining multi-parent ids, as they would be inlined multiple places, potentially increasing work and/or compiled text size - multi_parent_ids = set(id for id in graph.nodes if len(graph.parents(id)) > 2) + multi_parent_ids = set(id for id in graph.nodes if len(list(graph.parents(id))) > 2) scalar_ids = set(expr.id for expr in exprs if expr.expression.is_scalar_expr) analytic_defs = filter( @@ -239,3 +384,11 @@ def grouped(values: Iterable[tuple[K, V]]) -> dict[K, list[V]]: for k, v in values: result[k].append(v) return result + + +def dedupe(values: Iterable[K]) -> Iterator[K]: + seen = set() + for k in values: + if k not in seen: + seen.add(k) + yield k diff --git a/bigframes/core/graphs.py b/bigframes/core/graphs.py new file mode 100644 index 0000000000..b7ce80e3cf --- /dev/null +++ b/bigframes/core/graphs.py @@ -0,0 +1,76 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import collections +from typing import Dict, Generic, Hashable, Iterable, Iterator, Tuple, TypeVar + +import bigframes.core.ordered_sets as sets + +T = TypeVar("T", bound=Hashable) + + +class DiGraph(Generic[T]): + def __init__(self, nodes: Iterable[T], edges: Iterable[Tuple[T, T]]): + self._parents: Dict[T, sets.InsertionOrderedSet[T]] = collections.defaultdict( + sets.InsertionOrderedSet + ) + self._children: Dict[T, sets.InsertionOrderedSet[T]] = collections.defaultdict( + sets.InsertionOrderedSet + ) + self._sinks: sets.InsertionOrderedSet[T] = sets.InsertionOrderedSet() + for node in nodes: + self._children[node] + self._parents[node] + self._sinks.add(node) + for src, dst in edges: + assert src in self.nodes + assert dst in self.nodes + self._children[src].add(dst) + self._parents[dst].add(src) + # sinks have no children + if src in self._sinks: + self._sinks.remove(src) + + @property + def nodes(self): + # should be the same set of ids as self._parents + return self._children.keys() + + @property + def sinks(self) -> Iterable[T]: + return self._sinks + + @property + def empty(self): + return len(self.nodes) == 0 + + def parents(self, node: T) -> Iterator[T]: + assert node in self._parents + yield from self._parents[node] + + def children(self, node: T) -> Iterator[T]: + assert node in self._children + yield from self._children[node] + + def remove_node(self, node: T) -> None: + for child in self._children[node]: + self._parents[child].remove(node) + for parent in self._parents[node]: + self._children[parent].remove(node) + if len(self._children[parent]) == 0: + self._sinks.add(parent) + del self._children[node] + del self._parents[node] + if node in self._sinks: + self._sinks.remove(node) diff --git a/bigframes/core/groupby/dataframe_group_by.py b/bigframes/core/groupby/dataframe_group_by.py index 2ec3ce2c96..e3a132d4d0 100644 --- a/bigframes/core/groupby/dataframe_group_by.py +++ b/bigframes/core/groupby/dataframe_group_by.py @@ -182,7 +182,8 @@ def __len__(self) -> int: return len(self.agg([])) def size(self) -> typing.Union[df.DataFrame, series.Series]: - agg_block, _ = self._block.aggregate_size( + agg_block = self._block.aggregate( + aggregations=[agg_ops.SizeOp().as_expr()], by_column_ids=self._by_col_ids, dropna=self._dropna, ) @@ -304,7 +305,7 @@ def corr( uniq_orig_columns = utils.combine_indices(labels, pd.Index(range(len(labels)))) result_labels = utils.cross_indices(uniq_orig_columns, uniq_orig_columns) - block, _ = block.aggregate( + block = block.aggregate( by_column_ids=self._by_col_ids, aggregations=aggregations, column_labels=result_labels, @@ -339,7 +340,7 @@ def cov( uniq_orig_columns = utils.combine_indices(labels, pd.Index(range(len(labels)))) result_labels = utils.cross_indices(uniq_orig_columns, uniq_orig_columns) - block, _ = block.aggregate( + block = block.aggregate( by_column_ids=self._by_col_ids, aggregations=aggregations, column_labels=result_labels, @@ -383,9 +384,9 @@ def first(self, numeric_only: bool = False, min_count: int = -1) -> df.DataFrame agg_ops.FirstNonNullOp(), window_spec=window_spec, ) - block, _ = block.aggregate( - self._by_col_ids, - tuple( + block = block.aggregate( + by_column_ids=self._by_col_ids, + aggregations=tuple( aggs.agg(firsts_id, agg_ops.AnyValueOp()) for firsts_id in firsts_ids ), dropna=self._dropna, @@ -405,9 +406,11 @@ def last(self, numeric_only: bool = False, min_count: int = -1) -> df.DataFrame: agg_ops.LastNonNullOp(), window_spec=window_spec, ) - block, _ = block.aggregate( - self._by_col_ids, - tuple(aggs.agg(lasts_id, agg_ops.AnyValueOp()) for lasts_id in lasts_ids), + block = block.aggregate( + by_column_ids=self._by_col_ids, + aggregations=tuple( + aggs.agg(lasts_id, agg_ops.AnyValueOp()) for lasts_id in lasts_ids + ), dropna=self._dropna, column_labels=index, ) @@ -582,7 +585,7 @@ def _agg_func(self, func) -> df.DataFrame: aggregations = [ aggs.agg(col_id, agg_ops.lookup_agg_func(func)[0]) for col_id in ids ] - agg_block, _ = self._block.aggregate( + agg_block = self._block.aggregate( by_column_ids=self._by_col_ids, aggregations=aggregations, dropna=self._dropna, @@ -608,7 +611,7 @@ def _agg_dict(self, func: typing.Mapping) -> df.DataFrame: aggregations.append(aggs.agg(col_id, f_op)) column_labels.append(label) function_labels.append(f_label) - agg_block, _ = self._block.aggregate( + agg_block = self._block.aggregate( by_column_ids=self._by_col_ids, aggregations=aggregations, dropna=self._dropna, @@ -646,7 +649,7 @@ def _agg_list(self, func: typing.Sequence) -> df.DataFrame: (label, agg_ops.lookup_agg_func(f)[1]) for label in labels for f in func ] - agg_block, _ = self._block.aggregate( + agg_block = self._block.aggregate( by_column_ids=self._by_col_ids, aggregations=aggregations, dropna=self._dropna, @@ -672,7 +675,7 @@ def _agg_named(self, **kwargs) -> df.DataFrame: col_id = self._resolve_label(v[0]) aggregations.append(aggs.agg(col_id, agg_ops.lookup_agg_func(v[1])[0])) column_labels.append(k) - agg_block, _ = self._block.aggregate( + agg_block = self._block.aggregate( by_column_ids=self._by_col_ids, aggregations=aggregations, dropna=self._dropna, @@ -729,7 +732,7 @@ def _aggregate_all( ) -> df.DataFrame: aggregated_col_ids, labels = self._aggregated_columns(numeric_only=numeric_only) aggregations = [aggs.agg(col_id, aggregate_op) for col_id in aggregated_col_ids] - result_block, _ = self._block.aggregate( + result_block = self._block.aggregate( by_column_ids=self._by_col_ids, aggregations=aggregations, column_labels=labels, diff --git a/bigframes/core/groupby/group_by.py b/bigframes/core/groupby/group_by.py index f00ff7c0b0..1d24e61545 100644 --- a/bigframes/core/groupby/group_by.py +++ b/bigframes/core/groupby/group_by.py @@ -55,7 +55,7 @@ def block_groupby_iter( # are more efficient. session_aware=False, ) - keys_block, _ = block.aggregate(by_col_ids, dropna=dropna) + keys_block = block.aggregate(by_column_ids=by_col_ids, dropna=dropna) for chunk in keys_block.to_pandas_batches(): # Convert to MultiIndex to make sure we get tuples, # even for singular keys. diff --git a/bigframes/core/groupby/series_group_by.py b/bigframes/core/groupby/series_group_by.py index 27c55f7a6a..b1485888a8 100644 --- a/bigframes/core/groupby/series_group_by.py +++ b/bigframes/core/groupby/series_group_by.py @@ -189,7 +189,8 @@ def var(self, *args, **kwargs) -> series.Series: return self._aggregate(agg_ops.var_op) def size(self) -> series.Series: - agg_block, _ = self._block.aggregate_size( + agg_block = self._block.aggregate( + aggregations=[agg_ops.SizeOp().as_expr()], by_column_ids=self._by_col_ids, dropna=self._dropna, ) @@ -222,9 +223,9 @@ def first(self, numeric_only: bool = False, min_count: int = -1) -> series.Serie agg_ops.FirstNonNullOp(), window_spec=window_spec, ) - block, _ = block.aggregate( - self._by_col_ids, + block = block.aggregate( (aggs.agg(firsts_id, agg_ops.AnyValueOp()),), + self._by_col_ids, dropna=self._dropna, ) return series.Series(block.with_column_labels([self._value_name])) @@ -246,9 +247,9 @@ def last(self, numeric_only: bool = False, min_count: int = -1) -> series.Series agg_ops.LastNonNullOp(), window_spec=window_spec, ) - block, _ = block.aggregate( - self._by_col_ids, + block = block.aggregate( (aggs.agg(firsts_id, agg_ops.AnyValueOp()),), + self._by_col_ids, dropna=self._dropna, ) return series.Series(block.with_column_labels([self._value_name])) @@ -270,7 +271,7 @@ def agg(self, func=None) -> typing.Union[df.DataFrame, series.Series]: ] column_names = [agg_ops.lookup_agg_func(f)[1] for f in func] - agg_block, _ = self._block.aggregate( + agg_block = self._block.aggregate( by_column_ids=self._by_col_ids, aggregations=aggregations, dropna=self._dropna, @@ -413,9 +414,9 @@ def expanding(self, min_periods: int = 1) -> windows.Window: ) def _aggregate(self, aggregate_op: agg_ops.UnaryAggregateOp) -> series.Series: - result_block, _ = self._block.aggregate( - self._by_col_ids, + result_block = self._block.aggregate( (aggs.agg(self._value_column, aggregate_op),), + self._by_col_ids, dropna=self._dropna, ) diff --git a/bigframes/core/ordered_sets.py b/bigframes/core/ordered_sets.py new file mode 100644 index 0000000000..b09c0ce8e0 --- /dev/null +++ b/bigframes/core/ordered_sets.py @@ -0,0 +1,139 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +from typing import ( + Any, + Dict, + Generic, + Hashable, + Iterable, + Iterator, + MutableSet, + Optional, + TypeVar, +) + +T = TypeVar("T", bound=Hashable) + + +class _ListNode(Generic[T]): + """A private class representing a node in the doubly linked list.""" + + __slots__ = ("value", "prev", "next") + + def __init__( + self, + value: Optional[T], + prev: Optional[_ListNode[T]] = None, + next_node: Optional[_ListNode[T]] = None, + ): + self.value = value + self.prev = prev + self.next = next_node + + +class InsertionOrderedSet(MutableSet[T]): + """ + An ordered set implementation that maintains the order in which elements were + first inserted. It provides O(1) average time complexity for addition, + membership testing, and deletion, similar to Python's built-in set. + """ + + def __init__(self, iterable: Optional[Iterable] = None): + # Dictionary mapping element value -> _ListNode instance for O(1) lookup + self._dict: Dict[T, _ListNode[T]] = {} + + # Sentinel nodes for the doubly linked list. They don't hold actual data. + # head.next is the first element, tail.prev is the last element. + self._head: _ListNode[T] = _ListNode(None) + self._tail: _ListNode[T] = _ListNode(None) + self._head.next = self._tail + self._tail.prev = self._head + + if iterable: + self.update(iterable) + + def __len__(self) -> int: + """Return the number of elements in the set.""" + return len(self._dict) + + def __contains__(self, item: Any) -> bool: + """Check if an item is a member of the set (O(1) average).""" + return item in self._dict + + def __iter__(self) -> Iterator[T]: + """Iterate over the elements in insertion order (O(N)).""" + current = self._head.next + while current is not self._tail: + yield current.value # type: ignore + current = current.next # type: ignore + + def _unlink_node(self, node: _ListNode[T]) -> None: + """Helper to remove a node from the linked list.""" + node.prev.next = node.next # type: ignore + node.next.prev = node.prev # type: ignore + # Clear references to aid garbage collection + node.prev = None + node.next = None + + def _append_node(self, node: _ListNode[T]) -> None: + """Helper to append a node to the end of the linked list.""" + last_node = self._tail.prev + last_node.next = node # type: ignore + node.prev = last_node + node.next = self._tail + self._tail.prev = node + + def add(self, value: T) -> None: + """Add an element to the set. If it exists, its order is unchanged (O(1) average).""" + if value not in self._dict: + new_node = _ListNode(value) + self._dict[value] = new_node + self._append_node(new_node) + + def discard(self, value: T) -> None: + """Remove an element from the set if it is a member (O(1) average).""" + if value in self._dict: + node = self._dict.pop(value) + self._unlink_node(node) + + def remove(self, value: T) -> None: + """Remove an element from the set; raises KeyError if not present (O(1) average).""" + if value not in self._dict: + raise KeyError(f"{value} not found in set") + self.discard(value) + + def update(self, *others: Iterable[T]) -> None: + """Update the set with the union of itself and all others.""" + for other in others: + for item in other: + self.add(item) + + def clear(self) -> None: + """Remove all elements from the set.""" + self._dict.clear() + self._head.next = self._tail + self._tail.prev = self._head + + def _replace_contents(self, source: InsertionOrderedSet) -> InsertionOrderedSet: + """Helper method for inplace operators to transfer content from a result set.""" + self.clear() + for item in source: + self.add(item) + return self + + def __repr__(self) -> str: + """Representation of the set.""" + return f"InsertionOrderedSet({list(self)})" diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 9b10d8b0e1..4d594ddfbc 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -1510,7 +1510,7 @@ def _fast_stat_matrix(self, op: agg_ops.BinaryAggregateOp) -> DataFrame: ) labels = utils.cross_indices(uniq_orig_columns, uniq_orig_columns) - block, _ = block.aggregate(aggregations=aggregations, column_labels=labels) + block = block.aggregate(aggregations=aggregations, column_labels=labels) block = block.stack(levels=orig_columns.nlevels + 1) # The aggregate operation crated a index level with just 0, need to drop it @@ -1765,7 +1765,7 @@ def corrwith( r_block.column_labels, how="outer" ).difference(labels) - block, _ = block.aggregate( + block = block.aggregate( aggregations=tuple( agg_expressions.BinaryAggregation(agg_ops.CorrOp(), left_ex, right_ex) for left_ex, right_ex in expr_pairs @@ -3393,13 +3393,13 @@ def agg(self, func) -> DataFrame | bigframes.series.Series: if any(utils.is_list_like(v) for v in func.values()): new_index, _ = self.columns.reindex(labels) new_index = utils.combine_indices(new_index, pandas.Index(funcnames)) - agg_block, _ = self._block.aggregate( + agg_block = self._block.aggregate( aggregations=aggs, column_labels=new_index ) return DataFrame(agg_block).stack().droplevel(0, axis="index") else: new_index, _ = self.columns.reindex(labels) - agg_block, _ = self._block.aggregate( + agg_block = self._block.aggregate( aggregations=aggs, column_labels=new_index ) return bigframes.series.Series( diff --git a/bigframes/operations/aggregations.py b/bigframes/operations/aggregations.py index 289d3bf00a..5fe8330263 100644 --- a/bigframes/operations/aggregations.py +++ b/bigframes/operations/aggregations.py @@ -68,6 +68,11 @@ def order_independent(self): def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: ... + @property + def can_be_windowized(self): + # this is more of an engine property, but will treat feasibility in bigquery sql as source of truth + return True + @dataclasses.dataclass(frozen=True) class NullaryWindowOp(WindowOp): @@ -257,6 +262,10 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT raise TypeError(f"Type {input_types[0]} is not orderable") return input_types[0] + @property + def can_be_windowized(self): + return False + @dataclasses.dataclass(frozen=True) class ApproxTopCountOp(UnaryAggregateOp): @@ -274,6 +283,10 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT ] return pd.ArrowDtype(pa.list_(pa.struct(fields))) + @property + def can_be_windowized(self): + return False + @dataclasses.dataclass(frozen=True) class MeanOp(UnaryAggregateOp): diff --git a/bigframes/pandas/core/methods/describe.py b/bigframes/pandas/core/methods/describe.py index f8a8721cf2..6fd7960daf 100644 --- a/bigframes/pandas/core/methods/describe.py +++ b/bigframes/pandas/core/methods/describe.py @@ -90,7 +90,7 @@ def _describe( label_tuple = (label,) if block.column_labels.nlevels == 1 else label column_labels.extend((*label_tuple, op.name) for op in agg_ops) # type: ignore - agg_block, _ = block.aggregate( + agg_block = block.aggregate( by_column_ids=by_col_ids, aggregations=stats, dropna=dropna, diff --git a/bigframes/series.py b/bigframes/series.py index 64a986d1fa..de3ce276d8 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -1486,7 +1486,7 @@ def kurt(self): def mode(self) -> Series: block = self._block # Approach: Count each value, return each value for which count(x) == max(counts)) - block, agg_ids = block.aggregate( + block = block.aggregate( by_column_ids=[self._value_column], aggregations=( agg_expressions.UnaryAggregation( @@ -1494,7 +1494,7 @@ def mode(self) -> Series: ), ), ) - value_count_col_id = agg_ids[0] + value_count_col_id = block.value_columns[0] block, max_value_count_col_id = block.apply_window_op( value_count_col_id, agg_ops.max_op, @@ -2234,17 +2234,17 @@ def unique(self, keep_order=True) -> Series: if keep_order: validations.enforce_ordered(self, "unique(keep_order != False)") return self.drop_duplicates() - block, result = self._block.aggregate( - [self._value_column], + block = self._block.aggregate( [ agg_expressions.UnaryAggregation( agg_ops.AnyValueOp(), ex.deref(self._value_column) ) ], + [self._value_column], column_labels=self._block.column_labels, dropna=False, ) - return Series(block.select_columns(result).reset_index()) + return Series(block.reset_index()) def duplicated(self, keep: str = "first") -> Series: block, indicator = block_ops.indicate_duplicates( From 0b14b170bfb092b876670bf73b815b71525d0bda Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Mon, 15 Dec 2025 16:58:03 -0800 Subject: [PATCH 294/313] refactor: fix some string ops in the sqlglot compiler (part 2) (#2332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change aims to fix some string-related tests failing in #2248. Fixes internal issue 417774347🦕 --- .../compile/sqlglot/expressions/array_ops.py | 18 ++- .../compile/sqlglot/expressions/string_ops.py | 150 ++++++++++++------ .../test_string_ops/test_capitalize/out.sql | 2 +- .../test_string_ops/test_lstrip/out.sql | 2 +- .../test_string_ops/test_rstrip/out.sql | 2 +- .../test_string_ops/test_str_extract/out.sql | 6 +- .../test_string_ops/test_str_get/out.sql | 2 +- .../test_string_ops/test_str_pad/out.sql | 2 +- 8 files changed, 128 insertions(+), 56 deletions(-) diff --git a/bigframes/core/compile/sqlglot/expressions/array_ops.py b/bigframes/core/compile/sqlglot/expressions/array_ops.py index 2758178beb..f7b96d0418 100644 --- a/bigframes/core/compile/sqlglot/expressions/array_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/array_ops.py @@ -30,9 +30,12 @@ @register_unary_op(ops.ArrayIndexOp, pass_op=True) def _(expr: TypedExpr, op: ops.ArrayIndexOp) -> sge.Expression: + if expr.dtype == dtypes.STRING_DTYPE: + return _string_index(expr, op) + return sge.Bracket( this=expr.expr, - expressions=[sge.Literal.number(op.index)], + expressions=[sge.convert(op.index)], safe=True, offset=False, ) @@ -115,3 +118,16 @@ def _coerce_bool_to_int(typed_expr: TypedExpr) -> sge.Expression: if typed_expr.dtype == dtypes.BOOL_DTYPE: return sge.Cast(this=typed_expr.expr, to="INT64") return typed_expr.expr + + +def _string_index(expr: TypedExpr, op: ops.ArrayIndexOp) -> sge.Expression: + sub_str = sge.Substring( + this=expr.expr, + start=sge.convert(op.index + 1), + length=sge.convert(1), + ) + return sge.If( + this=sge.NEQ(this=sub_str, expression=sge.convert("")), + true=sub_str, + false=sge.Null(), + ) diff --git a/bigframes/core/compile/sqlglot/expressions/string_ops.py b/bigframes/core/compile/sqlglot/expressions/string_ops.py index 3e19a2fe33..3f0578f843 100644 --- a/bigframes/core/compile/sqlglot/expressions/string_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/string_ops.py @@ -15,6 +15,7 @@ from __future__ import annotations import functools +import typing import sqlglot.expressions as sge @@ -29,7 +30,7 @@ @register_unary_op(ops.capitalize_op) def _(expr: TypedExpr) -> sge.Expression: - return sge.Initcap(this=expr.expr) + return sge.Initcap(this=expr.expr, expression=sge.convert("")) @register_unary_op(ops.StrContainsOp, pass_op=True) @@ -44,9 +45,17 @@ def _(expr: TypedExpr, op: ops.StrContainsRegexOp) -> sge.Expression: @register_unary_op(ops.StrExtractOp, pass_op=True) def _(expr: TypedExpr, op: ops.StrExtractOp) -> sge.Expression: - return sge.RegexpExtract( - this=expr.expr, expression=sge.convert(op.pat), group=sge.convert(op.n) - ) + # Cannot use BigQuery's REGEXP_EXTRACT function, which only allows one + # capturing group. + pat_expr = sge.convert(op.pat) + if op.n != 0: + pat_expr = sge.func("CONCAT", sge.convert(".*?"), pat_expr, sge.convert(".*")) + else: + pat_expr = sge.func("CONCAT", sge.convert(".*?("), pat_expr, sge.convert(").*")) + + rex_replace = sge.func("REGEXP_REPLACE", expr.expr, pat_expr, sge.convert(r"\1")) + rex_contains = sge.func("REGEXP_CONTAINS", expr.expr, sge.convert(op.pat)) + return sge.If(this=rex_contains, true=rex_replace, false=sge.null()) @register_unary_op(ops.StrFindOp, pass_op=True) @@ -75,47 +84,43 @@ def _(expr: TypedExpr, op: ops.StrFindOp) -> sge.Expression: @register_unary_op(ops.StrLstripOp, pass_op=True) def _(expr: TypedExpr, op: ops.StrLstripOp) -> sge.Expression: - return sge.Trim(this=expr.expr, expression=sge.convert(op.to_strip), side="LEFT") + return sge.func("LTRIM", expr.expr, sge.convert(op.to_strip)) + + +@register_unary_op(ops.StrRstripOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrRstripOp) -> sge.Expression: + return sge.func("RTRIM", expr.expr, sge.convert(op.to_strip)) @register_unary_op(ops.StrPadOp, pass_op=True) def _(expr: TypedExpr, op: ops.StrPadOp) -> sge.Expression: - pad_length = sge.func( - "GREATEST", sge.Length(this=expr.expr), sge.convert(op.length) - ) + expr_length = sge.Length(this=expr.expr) + fillchar = sge.convert(op.fillchar) + pad_length = sge.func("GREATEST", expr_length, sge.convert(op.length)) + if op.side == "left": - return sge.func( - "LPAD", - expr.expr, - pad_length, - sge.convert(op.fillchar), - ) + return sge.func("LPAD", expr.expr, pad_length, fillchar) elif op.side == "right": - return sge.func( - "RPAD", - expr.expr, - pad_length, - sge.convert(op.fillchar), - ) + return sge.func("RPAD", expr.expr, pad_length, fillchar) else: # side == both - lpad_amount = sge.Cast( - this=sge.func( - "SAFE_DIVIDE", - sge.Sub(this=pad_length, expression=sge.Length(this=expr.expr)), - sge.convert(2), - ), - to="INT64", - ) + sge.Length(this=expr.expr) + lpad_amount = ( + sge.Cast( + this=sge.Floor( + this=sge.func( + "SAFE_DIVIDE", + sge.Sub(this=pad_length, expression=expr_length), + sge.convert(2), + ) + ), + to="INT64", + ) + + expr_length + ) return sge.func( "RPAD", - sge.func( - "LPAD", - expr.expr, - lpad_amount, - sge.convert(op.fillchar), - ), + sge.func("LPAD", expr.expr, lpad_amount, fillchar), pad_length, - sge.convert(op.fillchar), + fillchar, ) @@ -224,11 +229,6 @@ def _(expr: TypedExpr) -> sge.Expression: return sge.func("REVERSE", expr.expr) -@register_unary_op(ops.StrRstripOp, pass_op=True) -def _(expr: TypedExpr, op: ops.StrRstripOp) -> sge.Expression: - return sge.Trim(this=expr.expr, expression=sge.convert(op.to_strip), side="RIGHT") - - @register_unary_op(ops.StartsWithOp, pass_op=True) def _(expr: TypedExpr, op: ops.StartsWithOp) -> sge.Expression: if not op.pat: @@ -253,26 +253,78 @@ def _(expr: TypedExpr, op: ops.StringSplitOp) -> sge.Expression: @register_unary_op(ops.StrGetOp, pass_op=True) def _(expr: TypedExpr, op: ops.StrGetOp) -> sge.Expression: - return sge.Substring( + sub_str = sge.Substring( this=expr.expr, start=sge.convert(op.i + 1), length=sge.convert(1), ) + return sge.If( + this=sge.NEQ(this=sub_str, expression=sge.convert("")), + true=sub_str, + false=sge.Null(), + ) + @register_unary_op(ops.StrSliceOp, pass_op=True) def _(expr: TypedExpr, op: ops.StrSliceOp) -> sge.Expression: - start = op.start + 1 if op.start is not None else None - if op.end is None: - length = None - elif op.start is None: - length = op.end + column_length = sge.Length(this=expr.expr) + if op.start is None: + start = 0 else: - length = op.end - op.start + start = op.start + + start_expr = sge.convert(start) if start < 0 else sge.convert(start + 1) + length_expr: typing.Optional[sge.Expression] + if op.end is None: + length_expr = None + elif op.end < 0: + if start < 0: + start_expr = sge.Greatest( + expressions=[ + sge.convert(1), + column_length + sge.convert(start + 1), + ] + ) + length_expr = sge.Greatest( + expressions=[ + sge.convert(0), + column_length + sge.convert(op.end), + ] + ) - sge.Greatest( + expressions=[ + sge.convert(0), + column_length + sge.convert(start), + ] + ) + else: + length_expr = sge.Greatest( + expressions=[ + sge.convert(0), + column_length + sge.convert(op.end - start), + ] + ) + else: # op.end >= 0 + if start < 0: + start_expr = sge.Greatest( + expressions=[ + sge.convert(1), + column_length + sge.convert(start + 1), + ] + ) + length_expr = sge.convert(op.end) - sge.Greatest( + expressions=[ + sge.convert(0), + column_length + sge.convert(start), + ] + ) + else: + length_expr = sge.convert(op.end - start) + return sge.Substring( this=expr.expr, - start=sge.convert(start) if start is not None else None, - length=sge.convert(length) if length is not None else None, + start=start_expr, + length=length_expr, ) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_capitalize/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_capitalize/out.sql index b429007ffc..dd1f1473f4 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_capitalize/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_capitalize/out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - INITCAP(`string_col`) AS `bfcol_1` + INITCAP(`string_col`, '') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lstrip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lstrip/out.sql index ebe4c39bbf..1b73ee3258 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lstrip/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lstrip/out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - TRIM(`string_col`, ' ') AS `bfcol_1` + LTRIM(`string_col`, ' ') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_rstrip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_rstrip/out.sql index ebe4c39bbf..72bdbba29f 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_rstrip/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_rstrip/out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - TRIM(`string_col`, ' ') AS `bfcol_1` + RTRIM(`string_col`, ' ') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_extract/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_extract/out.sql index 3e59f617ac..ad02f6b223 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_extract/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_extract/out.sql @@ -5,7 +5,11 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - REGEXP_EXTRACT(`string_col`, '([a-z]*)') AS `bfcol_1` + IF( + REGEXP_CONTAINS(`string_col`, '([a-z]*)'), + REGEXP_REPLACE(`string_col`, CONCAT('.*?', '([a-z]*)', '.*'), '\\1'), + NULL + ) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_get/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_get/out.sql index b2a08e0e9d..f868b73032 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_get/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_get/out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - SUBSTRING(`string_col`, 2, 1) AS `bfcol_1` + IF(SUBSTRING(`string_col`, 2, 1) <> '', SUBSTRING(`string_col`, 2, 1), NULL) AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_pad/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_pad/out.sql index 5f157bc5cb..2bb6042fe9 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_pad/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_pad/out.sql @@ -10,7 +10,7 @@ WITH `bfcte_0` AS ( RPAD( LPAD( `string_col`, - CAST(SAFE_DIVIDE(GREATEST(LENGTH(`string_col`), 10) - LENGTH(`string_col`), 2) AS INT64) + LENGTH(`string_col`), + CAST(FLOOR(SAFE_DIVIDE(GREATEST(LENGTH(`string_col`), 10) - LENGTH(`string_col`), 2)) AS INT64) + LENGTH(`string_col`), '-' ), GREATEST(LENGTH(`string_col`), 10), From 097fcfa116c2a33e690b245fa8d05ef4f8c239fc Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Wed, 17 Dec 2025 13:31:25 -0800 Subject: [PATCH 295/313] refactor: fix some string ops in the sqlglot compiler (part 3) (#2336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change aims to fix some string-related tests failing in #2248. Fixes internal issue 417774347 🦕 --- .../compile/sqlglot/expressions/array_ops.py | 88 ++++++++----- .../compile/sqlglot/expressions/string_ops.py | 120 ++++++++++-------- .../test_string_ops/test_isdecimal/out.sql | 2 +- .../test_string_ops/test_isdigit/out.sql | 5 +- 4 files changed, 126 insertions(+), 89 deletions(-) diff --git a/bigframes/core/compile/sqlglot/expressions/array_ops.py b/bigframes/core/compile/sqlglot/expressions/array_ops.py index f7b96d0418..28b3693caf 100644 --- a/bigframes/core/compile/sqlglot/expressions/array_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/array_ops.py @@ -20,6 +20,10 @@ import sqlglot.expressions as sge from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.string_ops import ( + string_index, + string_slice, +) from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler import bigframes.dtypes as dtypes @@ -31,7 +35,7 @@ @register_unary_op(ops.ArrayIndexOp, pass_op=True) def _(expr: TypedExpr, op: ops.ArrayIndexOp) -> sge.Expression: if expr.dtype == dtypes.STRING_DTYPE: - return _string_index(expr, op) + return string_index(expr, op.index) return sge.Bracket( this=expr.expr, @@ -71,29 +75,10 @@ def _(expr: TypedExpr, op: ops.ArrayReduceOp) -> sge.Expression: @register_unary_op(ops.ArraySliceOp, pass_op=True) def _(expr: TypedExpr, op: ops.ArraySliceOp) -> sge.Expression: - slice_idx = sg.to_identifier("slice_idx") - - conditions: typing.List[sge.Predicate] = [slice_idx >= op.start] - - if op.stop is not None: - conditions.append(slice_idx < op.stop) - - # local name for each element in the array - el = sg.to_identifier("el") - - selected_elements = ( - sge.select(el) - .from_( - sge.Unnest( - expressions=[expr.expr], - alias=sge.TableAlias(columns=[el]), - offset=slice_idx, - ) - ) - .where(*conditions) - ) - - return sge.array(selected_elements) + if expr.dtype == dtypes.STRING_DTYPE: + return string_slice(expr, op.start, op.stop) + else: + return _array_slice(expr, op) @register_unary_op(ops.ArrayToStringOp, pass_op=True) @@ -120,14 +105,51 @@ def _coerce_bool_to_int(typed_expr: TypedExpr) -> sge.Expression: return typed_expr.expr -def _string_index(expr: TypedExpr, op: ops.ArrayIndexOp) -> sge.Expression: - sub_str = sge.Substring( - this=expr.expr, - start=sge.convert(op.index + 1), - length=sge.convert(1), +def _string_slice(expr: TypedExpr, op: ops.ArraySliceOp) -> sge.Expression: + # local name for each element in the array + el = sg.to_identifier("el") + # local name for the index in the array + slice_idx = sg.to_identifier("slice_idx") + + conditions: typing.List[sge.Predicate] = [slice_idx >= op.start] + if op.stop is not None: + conditions.append(slice_idx < op.stop) + + selected_elements = ( + sge.select(el) + .from_( + sge.Unnest( + expressions=[expr.expr], + alias=sge.TableAlias(columns=[el]), + offset=slice_idx, + ) + ) + .where(*conditions) ) - return sge.If( - this=sge.NEQ(this=sub_str, expression=sge.convert("")), - true=sub_str, - false=sge.Null(), + + return sge.array(selected_elements) + + +def _array_slice(expr: TypedExpr, op: ops.ArraySliceOp) -> sge.Expression: + # local name for each element in the array + el = sg.to_identifier("el") + # local name for the index in the array + slice_idx = sg.to_identifier("slice_idx") + + conditions: typing.List[sge.Predicate] = [slice_idx >= op.start] + if op.stop is not None: + conditions.append(slice_idx < op.stop) + + selected_elements = ( + sge.select(el) + .from_( + sge.Unnest( + expressions=[expr.expr], + alias=sge.TableAlias(columns=[el]), + offset=slice_idx, + ) + ) + .where(*conditions) ) + + return sge.array(selected_elements) diff --git a/bigframes/core/compile/sqlglot/expressions/string_ops.py b/bigframes/core/compile/sqlglot/expressions/string_ops.py index 3f0578f843..6af9b6a526 100644 --- a/bigframes/core/compile/sqlglot/expressions/string_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/string_ops.py @@ -153,12 +153,15 @@ def _(expr: TypedExpr) -> sge.Expression: @register_unary_op(ops.isdecimal_op) def _(expr: TypedExpr) -> sge.Expression: - return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\d+$")) + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^(\p{Nd})+$")) @register_unary_op(ops.isdigit_op) def _(expr: TypedExpr) -> sge.Expression: - return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\p{Nd}+$")) + regexp_pattern = ( + r"^[\p{Nd}\x{00B9}\x{00B2}\x{00B3}\x{2070}\x{2074}-\x{2079}\x{2080}-\x{2089}]+$" + ) + return sge.RegexpLike(this=expr.expr, expression=sge.convert(regexp_pattern)) @register_unary_op(ops.islower_op) @@ -253,12 +256,60 @@ def _(expr: TypedExpr, op: ops.StringSplitOp) -> sge.Expression: @register_unary_op(ops.StrGetOp, pass_op=True) def _(expr: TypedExpr, op: ops.StrGetOp) -> sge.Expression: + return string_index(expr, op.i) + + +@register_unary_op(ops.StrSliceOp, pass_op=True) +def _(expr: TypedExpr, op: ops.StrSliceOp) -> sge.Expression: + return string_slice(expr, op.start, op.end) + + +@register_unary_op(ops.upper_op) +def _(expr: TypedExpr) -> sge.Expression: + return sge.Upper(this=expr.expr) + + +@register_binary_op(ops.strconcat_op) +def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.Concat(expressions=[left.expr, right.expr]) + + +@register_unary_op(ops.ZfillOp, pass_op=True) +def _(expr: TypedExpr, op: ops.ZfillOp) -> sge.Expression: + length_expr = sge.Greatest( + expressions=[sge.Length(this=expr.expr), sge.convert(op.width)] + ) + return sge.Case( + ifs=[ + sge.If( + this=sge.func( + "STARTS_WITH", + expr.expr, + sge.convert("-"), + ), + true=sge.Concat( + expressions=[ + sge.convert("-"), + sge.func( + "LPAD", + sge.Substring(this=expr.expr, start=sge.convert(2)), + length_expr - 1, + sge.convert("0"), + ), + ] + ), + ) + ], + default=sge.func("LPAD", expr.expr, length_expr, sge.convert("0")), + ) + + +def string_index(expr: TypedExpr, index: int) -> sge.Expression: sub_str = sge.Substring( this=expr.expr, - start=sge.convert(op.i + 1), + start=sge.convert(index + 1), length=sge.convert(1), ) - return sge.If( this=sge.NEQ(this=sub_str, expression=sge.convert("")), true=sub_str, @@ -266,19 +317,20 @@ def _(expr: TypedExpr, op: ops.StrGetOp) -> sge.Expression: ) -@register_unary_op(ops.StrSliceOp, pass_op=True) -def _(expr: TypedExpr, op: ops.StrSliceOp) -> sge.Expression: +def string_slice( + expr: TypedExpr, op_start: typing.Optional[int], op_end: typing.Optional[int] +) -> sge.Expression: column_length = sge.Length(this=expr.expr) - if op.start is None: + if op_start is None: start = 0 else: - start = op.start + start = op_start start_expr = sge.convert(start) if start < 0 else sge.convert(start + 1) length_expr: typing.Optional[sge.Expression] - if op.end is None: + if op_end is None: length_expr = None - elif op.end < 0: + elif op_end < 0: if start < 0: start_expr = sge.Greatest( expressions=[ @@ -289,7 +341,7 @@ def _(expr: TypedExpr, op: ops.StrSliceOp) -> sge.Expression: length_expr = sge.Greatest( expressions=[ sge.convert(0), - column_length + sge.convert(op.end), + column_length + sge.convert(op_end), ] ) - sge.Greatest( expressions=[ @@ -301,7 +353,7 @@ def _(expr: TypedExpr, op: ops.StrSliceOp) -> sge.Expression: length_expr = sge.Greatest( expressions=[ sge.convert(0), - column_length + sge.convert(op.end - start), + column_length + sge.convert(op_end - start), ] ) else: # op.end >= 0 @@ -312,57 +364,17 @@ def _(expr: TypedExpr, op: ops.StrSliceOp) -> sge.Expression: column_length + sge.convert(start + 1), ] ) - length_expr = sge.convert(op.end) - sge.Greatest( + length_expr = sge.convert(op_end) - sge.Greatest( expressions=[ sge.convert(0), column_length + sge.convert(start), ] ) else: - length_expr = sge.convert(op.end - start) + length_expr = sge.convert(op_end - start) return sge.Substring( this=expr.expr, start=start_expr, length=length_expr, ) - - -@register_unary_op(ops.upper_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Upper(this=expr.expr) - - -@register_binary_op(ops.strconcat_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - return sge.Concat(expressions=[left.expr, right.expr]) - - -@register_unary_op(ops.ZfillOp, pass_op=True) -def _(expr: TypedExpr, op: ops.ZfillOp) -> sge.Expression: - length_expr = sge.Greatest( - expressions=[sge.Length(this=expr.expr), sge.convert(op.width)] - ) - return sge.Case( - ifs=[ - sge.If( - this=sge.func( - "STARTS_WITH", - expr.expr, - sge.convert("-"), - ), - true=sge.Concat( - expressions=[ - sge.convert("-"), - sge.func( - "LPAD", - sge.Substring(this=expr.expr, start=sge.convert(2)), - length_expr - 1, - sge.convert("0"), - ), - ] - ), - ) - ], - default=sge.func("LPAD", expr.expr, length_expr, sge.convert("0")), - ) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdecimal/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdecimal/out.sql index 7355ab7aa7..d4dddc348f 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdecimal/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdecimal/out.sql @@ -5,7 +5,7 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - REGEXP_CONTAINS(`string_col`, '^\\d+$') AS `bfcol_1` + REGEXP_CONTAINS(`string_col`, '^(\\p{Nd})+$') AS `bfcol_1` FROM `bfcte_0` ) SELECT diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdigit/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdigit/out.sql index d7dd8c0729..eba0e51ed0 100644 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdigit/out.sql +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdigit/out.sql @@ -5,7 +5,10 @@ WITH `bfcte_0` AS ( ), `bfcte_1` AS ( SELECT *, - REGEXP_CONTAINS(`string_col`, '^\\p{Nd}+$') AS `bfcol_1` + REGEXP_CONTAINS( + `string_col`, + '^[\\p{Nd}\\x{00B9}\\x{00B2}\\x{00B3}\\x{2070}\\x{2074}-\\x{2079}\\x{2080}-\\x{2089}]+$' + ) AS `bfcol_1` FROM `bfcte_0` ) SELECT From 173b83d7caa7d596def45294634d11686587ab14 Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Wed, 17 Dec 2025 14:58:14 -0800 Subject: [PATCH 296/313] =?UTF-8?q?Revert=20"fix:=20Update=20max=5Finstanc?= =?UTF-8?q?es=20default=20to=20reflect=20actual=20value=20(#2=E2=80=A6=20(?= =?UTF-8?q?#2339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/system/large/functions/test_remote_function.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/system/large/functions/test_remote_function.py b/tests/system/large/functions/test_remote_function.py index 253bc7b617..2591c0c13a 100644 --- a/tests/system/large/functions/test_remote_function.py +++ b/tests/system/large/functions/test_remote_function.py @@ -1651,12 +1651,13 @@ def square(x): return x * x -# The default value of 100 is used if the maximum instances value is not set. +# Note: Zero represents default, which is 100 instances actually, which is why the remote function still works +# in the df.apply() call here @pytest.mark.parametrize( ("max_instances_args", "expected_max_instances"), [ - pytest.param({}, 100, id="no-set"), - pytest.param({"cloud_function_max_instances": None}, 100, id="set-None"), + pytest.param({}, 0, id="no-set"), + pytest.param({"cloud_function_max_instances": None}, 0, id="set-None"), pytest.param({"cloud_function_max_instances": 1000}, 1000, id="set-explicit"), ], ) From ea71936ce240b2becf21b552d4e41e8ef4418e2d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 10:38:04 -0600 Subject: [PATCH 297/313] docs: update supported pandas APIs documentation links (#2330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated `scripts/publish_api_coverage.py` to point documentation links to the new location at `https://dataframes.bigquery.dev/reference/api/`. The links are now generated as individual HTML pages for each method/attribute (e.g., `bigframes.pandas.DataFrame.T.html`) instead of anchors on a single page. Verified the changes by running `nox -r -s docs` and checking the generated HTML files. --- *PR created automatically by Jules for task [5838600072013437716](https://jules.google.com/task/5838600072013437716) started by @tswast* --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Tim Sweña (Swast) --- bigframes/pandas/__init__.py | 44 ++++++++++++++++++++------ bigframes/pandas/api/__init__.py | 21 +++++++++++++ bigframes/pandas/api/typing.py | 35 +++++++++++++++++++++ docs/reference/index.rst | 1 + scripts/publish_api_coverage.py | 54 +++++++++++++------------------- 5 files changed, 114 insertions(+), 41 deletions(-) create mode 100644 bigframes/pandas/api/__init__.py create mode 100644 bigframes/pandas/api/typing.py diff --git a/bigframes/pandas/__init__.py b/bigframes/pandas/__init__.py index e4d82b8884..0b9648fd56 100644 --- a/bigframes/pandas/__init__.py +++ b/bigframes/pandas/__init__.py @@ -28,14 +28,12 @@ import bigframes._config as config from bigframes.core import log_adapter -import bigframes.core.blocks import bigframes.core.global_session as global_session import bigframes.core.indexes from bigframes.core.reshape.api import concat, crosstab, cut, get_dummies, merge, qcut -import bigframes.core.tools import bigframes.dataframe -import bigframes.enums import bigframes.functions._utils as bff_utils +from bigframes.pandas import api from bigframes.pandas.core.api import to_timedelta from bigframes.pandas.io.api import ( _read_gbq_colab, @@ -56,7 +54,6 @@ import bigframes.series import bigframes.session import bigframes.session._io.bigquery -import bigframes.session.clients import bigframes.version try: @@ -410,8 +407,40 @@ def reset_session(): from_glob_path, ] -_function_names = [_function.__name__ for _function in _functions] -_other_names = [ +# Use __all__ to let type checkers know what is part of the public API. +# Note that static analysis checkers like pylance depend on these being string +# literals, not derived at runtime. +__all__ = [ + # Function names + "clean_up_by_session_id", + "concat", + "crosstab", + "cut", + "deploy_remote_function", + "deploy_udf", + "get_default_session_id", + "get_dummies", + "merge", + "qcut", + "read_csv", + "read_arrow", + "read_gbq", + "_read_gbq_colab", + "read_gbq_function", + "read_gbq_model", + "read_gbq_object_table", + "read_gbq_query", + "read_gbq_table", + "read_json", + "read_pandas", + "read_parquet", + "read_pickle", + "remote_function", + "to_datetime", + "to_timedelta", + "from_glob_path", + # Other names + "api", # pandas dtype attributes "NA", "BooleanDtype", @@ -437,9 +466,6 @@ def reset_session(): "udf", ] -# Use __all__ to let type checkers know what is part of the public API. -__all__ = _function_names + _other_names - _module = sys.modules[__name__] for _function in _functions: diff --git a/bigframes/pandas/api/__init__.py b/bigframes/pandas/api/__init__.py new file mode 100644 index 0000000000..6d181f92c1 --- /dev/null +++ b/bigframes/pandas/api/__init__.py @@ -0,0 +1,21 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""BigQuery DataFrames public pandas APIs.""" + +from bigframes.pandas.api import typing + +__all__ = [ + "typing", +] diff --git a/bigframes/pandas/api/typing.py b/bigframes/pandas/api/typing.py new file mode 100644 index 0000000000..e21216bb68 --- /dev/null +++ b/bigframes/pandas/api/typing.py @@ -0,0 +1,35 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""BigQuery DataFrames public pandas types that aren't exposed in bigframes.pandas. + +Note: These objects aren't intended to be constructed directly. +""" + +from bigframes.core.groupby.dataframe_group_by import DataFrameGroupBy +from bigframes.core.groupby.series_group_by import SeriesGroupBy +from bigframes.core.window import Window +from bigframes.operations.datetimes import DatetimeMethods +from bigframes.operations.strings import StringMethods +from bigframes.operations.structs import StructAccessor, StructFrameAccessor + +__all__ = [ + "DataFrameGroupBy", + "DatetimeMethods", + "SeriesGroupBy", + "StringMethods", + "StructAccessor", + "StructFrameAccessor", + "Window", +] diff --git a/docs/reference/index.rst b/docs/reference/index.rst index 0a150659c5..e348bd608b 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -15,6 +15,7 @@ packages. bigframes.exceptions bigframes.geopandas bigframes.pandas + bigframes.pandas.api.typing bigframes.streaming ML APIs diff --git a/scripts/publish_api_coverage.py b/scripts/publish_api_coverage.py index 1c052504d3..f94cd7e6d7 100644 --- a/scripts/publish_api_coverage.py +++ b/scripts/publish_api_coverage.py @@ -35,34 +35,16 @@ REPO_ROOT = pathlib.Path(__file__).parent.parent -URL_PREFIX = { - "pandas": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.pandas#bigframes_pandas_" - ), - "dataframe": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.dataframe.DataFrame#bigframes_dataframe_DataFrame_" - ), - "dataframegroupby": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.core.groupby.DataFrameGroupBy#bigframes_core_groupby_DataFrameGroupBy_" - ), - "index": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.core.indexes.base.Index#bigframes_core_indexes_base_Index_" - ), - "series": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.series.Series#bigframes_series_Series_" - ), - "seriesgroupby": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.core.groupby.SeriesGroupBy#bigframes_core_groupby_SeriesGroupBy_" - ), - "datetimemethods": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.operations.datetimes.DatetimeMethods#bigframes_operations_datetimes_DatetimeMethods_" - ), - "stringmethods": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.operations.strings.StringMethods#bigframes_operations_strings_StringMethods_" - ), - "window": ( - "https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.core.window.Window#bigframes_core_window_Window_" - ), +BIGFRAMES_OBJECT = { + "pandas": "bigframes.pandas", + "dataframe": "bigframes.pandas.DataFrame", + "dataframegroupby": "bigframes.pandas.api.typing.DataFrameGroupBy", + "index": "bigframes.pandas.Index", + "series": "bigframes.pandas.Series", + "seriesgroupby": "bigframes.pandas.api.typing.SeriesGroupBy", + "datetimemethods": "bigframes.pandas.api.typing.DatetimeMethods", + "stringmethods": "bigframes.pandas.api.typing.StringMethods", + "window": "bigframes.pandas.api.typing.Window", } @@ -140,7 +122,7 @@ def generate_pandas_api_coverage(): missing_parameters = "" # skip private functions and properties - if member[0] == "_" and member[1] != "_": + if member[0] == "_": continue # skip members that are also common python methods @@ -308,11 +290,19 @@ def build_api_coverage_table(bigframes_version: str, release_version: str): def format_api(api_names, is_in_bigframes, api_prefix): api_names = api_names.str.slice(start=len(f"{api_prefix}.")) formatted = "" + api_names + "" - url_prefix = URL_PREFIX.get(api_prefix) - if url_prefix is None: + bigframes_object = BIGFRAMES_OBJECT.get(api_prefix) + if bigframes_object is None: return formatted - linked = '' + formatted + "" + linked = ( + '' + + formatted + + "" + ) return formatted.mask(is_in_bigframes, linked) From 58b2ac56cd5f812083e17e202d2317a5f0ca738f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Thu, 18 Dec 2025 15:11:33 -0600 Subject: [PATCH 298/313] chore: make `test_read_gbq_colab_includes_label` more robust to python changes (#2342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) Fixes b/469510777 🦕 --- tests/unit/session/test_read_gbq_colab.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/unit/session/test_read_gbq_colab.py b/tests/unit/session/test_read_gbq_colab.py index b1dc1ec702..cc0508b75a 100644 --- a/tests/unit/session/test_read_gbq_colab.py +++ b/tests/unit/session/test_read_gbq_colab.py @@ -14,6 +14,7 @@ """Unit tests for read_gbq_colab helper functions.""" +import itertools import textwrap from unittest import mock @@ -27,15 +28,21 @@ def test_read_gbq_colab_includes_label(): """Make sure we can tell direct colab usage apart from regular read_gbq usage.""" - session = mocks.create_bigquery_session() + bqclient = mock.create_autospec(bigquery.Client, instance=True) + bqclient.project = "proj" + session = mocks.create_bigquery_session(bqclient=bqclient) _ = session._read_gbq_colab("SELECT 'read-gbq-colab-test'") - configs = session._job_configs # type: ignore label_values = [] - for config in configs: - if config is None: + for kall in itertools.chain( + bqclient.query_and_wait.call_args_list, + bqclient._query_and_wait_bigframes.call_args_list, + bqclient.query.call_args_list, + ): + job_config = kall.kwargs.get("job_config") + if job_config is None: continue - label_values.extend(config.labels.values()) + label_values.extend(job_config.labels.values()) assert "session-read_gbq_colab" in label_values From 7d152d3efef565e6c051d7cbe90a00df4ff9b56e Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Thu, 18 Dec 2025 23:58:49 -0800 Subject: [PATCH 299/313] refactor: Handle special float values and None consistently in sqlglot _literal (#2337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change can resolve two doctests failures in #2248: `groupby.GroupBy.rank` and `bigframes.ml.metrics.roc_curve` Fixes internal issue 417774347🦕 --------- Co-authored-by: Shenyang Cai --- bigframes/core/compile/sqlglot/sqlglot_ir.py | 45 +++++++++++-------- .../out.sql | 25 +++++++++++ .../compile/sqlglot/test_compile_readlocal.py | 23 ++++++++++ 3 files changed, 74 insertions(+), 19 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_special_values/out.sql diff --git a/bigframes/core/compile/sqlglot/sqlglot_ir.py b/bigframes/core/compile/sqlglot/sqlglot_ir.py index 0d568b098b..cbc601ea63 100644 --- a/bigframes/core/compile/sqlglot/sqlglot_ir.py +++ b/bigframes/core/compile/sqlglot/sqlglot_ir.py @@ -21,6 +21,7 @@ from google.cloud import bigquery import numpy as np +import pandas as pd import pyarrow as pa import sqlglot as sg import sqlglot.dialects.bigquery @@ -28,7 +29,7 @@ from bigframes import dtypes from bigframes.core import guid, local_data, schema, utils -from bigframes.core.compile.sqlglot.expressions import typed_expr +from bigframes.core.compile.sqlglot.expressions import constants, typed_expr import bigframes.core.compile.sqlglot.sqlglot_types as sgt # shapely.wkt.dumps was moved to shapely.io.to_wkt in 2.0. @@ -639,12 +640,30 @@ def _select_to_cte(expr: sge.Select, cte_name: sge.Identifier) -> sge.Select: def _literal(value: typing.Any, dtype: dtypes.Dtype) -> sge.Expression: sqlglot_type = sgt.from_bigframes_dtype(dtype) if dtype else None if sqlglot_type is None: - if value is not None: - raise ValueError("Cannot infer SQLGlot type from None dtype.") + if not pd.isna(value): + raise ValueError(f"Cannot infer SQLGlot type from None dtype: {value}") return sge.Null() if value is None: return _cast(sge.Null(), sqlglot_type) + if dtypes.is_struct_like(dtype): + items = [ + _literal(value=value[field_name], dtype=field_dtype).as_( + field_name, quoted=True + ) + for field_name, field_dtype in dtypes.get_struct_fields(dtype).items() + ] + return sge.Struct.from_arg_list(items) + elif dtypes.is_array_like(dtype): + value_type = dtypes.get_array_inner_type(dtype) + values = sge.Array( + expressions=[_literal(value=v, dtype=value_type) for v in value] + ) + return values if len(value) > 0 else _cast(values, sqlglot_type) + elif pd.isna(value): + return _cast(sge.Null(), sqlglot_type) + elif dtype == dtypes.JSON_DTYPE: + return sge.ParseJSON(this=sge.convert(str(value))) elif dtype == dtypes.BYTES_DTYPE: return _cast(str(value), sqlglot_type) elif dtypes.is_time_like(dtype): @@ -658,24 +677,12 @@ def _literal(value: typing.Any, dtype: dtypes.Dtype) -> sge.Expression: elif dtypes.is_geo_like(dtype): wkt = value if isinstance(value, str) else to_wkt(value) return sge.func("ST_GEOGFROMTEXT", sge.convert(wkt)) - elif dtype == dtypes.JSON_DTYPE: - return sge.ParseJSON(this=sge.convert(str(value))) elif dtype == dtypes.TIMEDELTA_DTYPE: return sge.convert(utils.timedelta_to_micros(value)) - elif dtypes.is_struct_like(dtype): - items = [ - _literal(value=value[field_name], dtype=field_dtype).as_( - field_name, quoted=True - ) - for field_name, field_dtype in dtypes.get_struct_fields(dtype).items() - ] - return sge.Struct.from_arg_list(items) - elif dtypes.is_array_like(dtype): - value_type = dtypes.get_array_inner_type(dtype) - values = sge.Array( - expressions=[_literal(value=v, dtype=value_type) for v in value] - ) - return values if len(value) > 0 else _cast(values, sqlglot_type) + elif dtype == dtypes.FLOAT_DTYPE: + if np.isinf(value): + return constants._INF if value > 0 else constants._NEG_INF + return sge.convert(value) else: if isinstance(value, np.generic): value = value.item() diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_special_values/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_special_values/out.sql new file mode 100644 index 0000000000..ba5e0c8f1c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_special_values/out.sql @@ -0,0 +1,25 @@ +WITH `bfcte_0` AS ( + SELECT + * + FROM UNNEST(ARRAY, `bfcol_5` STRUCT, `bfcol_6` ARRAY, `bfcol_7` INT64>>[STRUCT( + CAST(NULL AS FLOAT64), + CAST('Infinity' AS FLOAT64), + CAST('-Infinity' AS FLOAT64), + CAST(NULL AS FLOAT64), + CAST(NULL AS STRUCT), + STRUCT(CAST(NULL AS INT64) AS `foo`), + ARRAY[], + 0 + ), STRUCT(1.0, 1.0, 1.0, 1.0, STRUCT(1 AS `foo`), STRUCT(1 AS `foo`), [1, 2], 1), STRUCT(2.0, 2.0, 2.0, 2.0, STRUCT(2 AS `foo`), STRUCT(2 AS `foo`), [3, 4], 2)]) +) +SELECT + `bfcol_0` AS `col_none`, + `bfcol_1` AS `col_inf`, + `bfcol_2` AS `col_neginf`, + `bfcol_3` AS `col_nan`, + `bfcol_4` AS `col_struct_none`, + `bfcol_5` AS `col_struct_w_none`, + `bfcol_6` AS `col_list_none` +FROM `bfcte_0` +ORDER BY + `bfcol_7` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/test_compile_readlocal.py b/tests/unit/core/compile/sqlglot/test_compile_readlocal.py index 7307fd9b4e..c5fabd99e6 100644 --- a/tests/unit/core/compile/sqlglot/test_compile_readlocal.py +++ b/tests/unit/core/compile/sqlglot/test_compile_readlocal.py @@ -12,6 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys + +import numpy as np import pandas as pd import pytest @@ -58,3 +61,23 @@ def test_compile_readlocal_w_json_df( ): bf_df = bpd.DataFrame(json_pandas_df, session=compiler_session_w_json_types) snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_compile_readlocal_w_special_values( + compiler_session: bigframes.Session, snapshot +): + if sys.version_info < (3, 12): + pytest.skip("Skipping test due to inconsistent SQL formatting") + df = pd.DataFrame( + { + "col_none": [None, 1, 2], + "col_inf": [np.inf, 1.0, 2.0], + "col_neginf": [-np.inf, 1.0, 2.0], + "col_nan": [np.nan, 1.0, 2.0], + "col_struct_none": [None, {"foo": 1}, {"foo": 2}], + "col_struct_w_none": [{"foo": None}, {"foo": 1}, {"foo": 2}], + "col_list_none": [None, [1, 2], [3, 4]], + } + ) + bf_df = bpd.DataFrame(df, session=compiler_session) + snapshot.assert_match(bf_df.sql, "out.sql") From 7d669163a510c962089632dace9998cd635cb40e Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 19 Dec 2025 12:31:44 -0800 Subject: [PATCH 300/313] perf: Improve write api throughput using multiple streams (#2345) --- bigframes/core/bq_data.py | 15 ++++ bigframes/core/local_data.py | 4 +- bigframes/session/loader.py | 144 ++++++++++++++++++++++++----------- 3 files changed, 119 insertions(+), 44 deletions(-) diff --git a/bigframes/core/bq_data.py b/bigframes/core/bq_data.py index c72de6ead6..9b2103b01d 100644 --- a/bigframes/core/bq_data.py +++ b/bigframes/core/bq_data.py @@ -64,6 +64,21 @@ def from_table(table: bq.Table, columns: Sequence[str] = ()) -> GbqTable: else tuple(table.clustering_fields), ) + @staticmethod + def from_ref_and_schema( + table_ref: bq.TableReference, + schema: Sequence[bq.SchemaField], + cluster_cols: Optional[Sequence[str]] = None, + ) -> GbqTable: + return GbqTable( + project_id=table_ref.project, + dataset_id=table_ref.dataset_id, + table_id=table_ref.table_id, + physical_schema=tuple(schema), + is_physically_stored=True, + cluster_cols=tuple(cluster_cols) if cluster_cols else None, + ) + def get_table_ref(self) -> bq.TableReference: return bq.TableReference( bq.DatasetReference(self.project_id, self.dataset_id), self.table_id diff --git a/bigframes/core/local_data.py b/bigframes/core/local_data.py index 21d773fdad..ef7374a5a4 100644 --- a/bigframes/core/local_data.py +++ b/bigframes/core/local_data.py @@ -486,7 +486,9 @@ def _append_offsets( ) -> Iterable[pa.RecordBatch]: offset = 0 for batch in batches: - offsets = pa.array(range(offset, offset + batch.num_rows), type=pa.int64()) + offsets = pa.array( + range(offset, offset + batch.num_rows), size=batch.num_rows, type=pa.int64() + ) batch_w_offsets = pa.record_batch( [*batch.columns, offsets], schema=batch.schema.append(pa.field(offsets_col_name, pa.int64())), diff --git a/bigframes/session/loader.py b/bigframes/session/loader.py index 5e415999ff..d248cf4ff5 100644 --- a/bigframes/session/loader.py +++ b/bigframes/session/loader.py @@ -14,6 +14,8 @@ from __future__ import annotations +import concurrent +import concurrent.futures import copy import dataclasses import datetime @@ -21,20 +23,22 @@ import itertools import math import os +import threading import typing from typing import ( cast, Dict, - Generator, Hashable, IO, Iterable, + Iterator, List, Literal, Optional, overload, Sequence, Tuple, + TypeVar, ) import bigframes_vendored.constants as constants @@ -46,6 +50,7 @@ import google.cloud.bigquery.table from google.cloud.bigquery_storage_v1 import types as bq_storage_types import pandas +import pyarrow as pa import bigframes._tools import bigframes._tools.strings @@ -451,62 +456,97 @@ def write_data( data: local_data.ManagedArrowTable, offsets_col: str, ) -> bq_data.BigqueryDataSource: - """Load managed data into bigquery""" - MAX_BYTES = 10000000 # streaming api has 10MB limit - SAFETY_MARGIN = ( - 4 # aim for 2.5mb to account for row variance, format differences, etc. - ) - batch_count = math.ceil( - data.metadata.total_bytes / (MAX_BYTES // SAFETY_MARGIN) - ) - rows_per_batch = math.ceil(data.metadata.row_count / batch_count) - + """Load managed data into BigQuery using multiple concurrent streams.""" schema_w_offsets = data.schema.append( schemata.SchemaItem(offsets_col, bigframes.dtypes.INT_DTYPE) ) bq_schema = schema_w_offsets.to_bigquery(_STREAM_JOB_TYPE_OVERRIDES) bq_table_ref = self._storage_manager.create_temp_table(bq_schema, [offsets_col]) + parent = bq_table_ref.to_bqstorage() - requested_stream = bq_storage_types.stream.WriteStream() - requested_stream.type_ = bq_storage_types.stream.WriteStream.Type.COMMITTED # type: ignore + # Some light benchmarking went into the constants here, not definitive + TARGET_BATCH_BYTES = ( + 5_000_000 # Must stay under the hard 10MB limit per request + ) + rows_per_batch = math.ceil( + data.metadata.row_count * TARGET_BATCH_BYTES / data.metadata.total_bytes + ) + min_batches = math.ceil(data.metadata.row_count / rows_per_batch) + num_streams = min((os.cpu_count() or 4) * 4, min_batches) - stream_request = bq_storage_types.CreateWriteStreamRequest( - parent=bq_table_ref.to_bqstorage(), write_stream=requested_stream + schema, all_batches = data.to_arrow( + offsets_col=offsets_col, + duration_type="int", + max_chunksize=rows_per_batch, ) - stream = self._write_client.create_write_stream(request=stream_request) + serialized_schema = schema.serialize().to_pybytes() - def request_gen() -> Generator[bq_storage_types.AppendRowsRequest, None, None]: - schema, batches = data.to_arrow( - offsets_col=offsets_col, - duration_type="int", - max_chunksize=rows_per_batch, + def stream_worker(work: Iterator[pa.RecordBatch]) -> str: + requested_stream = bq_storage_types.WriteStream( + type_=bq_storage_types.WriteStream.Type.PENDING ) - offset = 0 - for batch in batches: - request = bq_storage_types.AppendRowsRequest( - write_stream=stream.name, offset=offset - ) - request.arrow_rows.writer_schema.serialized_schema = ( - schema.serialize().to_pybytes() - ) - request.arrow_rows.rows.serialized_record_batch = ( - batch.serialize().to_pybytes() - ) - offset += batch.num_rows - yield request + stream = self._write_client.create_write_stream( + parent=parent, write_stream=requested_stream + ) + stream_name = stream.name - for response in self._write_client.append_rows(requests=request_gen()): - if response.row_errors: - raise ValueError( - f"Problem loading at least one row from DataFrame: {response.row_errors}. {constants.FEEDBACK_LINK}" + def request_generator(): + current_offset = 0 + for batch in work: + request = bq_storage_types.AppendRowsRequest( + write_stream=stream.name, offset=current_offset + ) + + request.arrow_rows.writer_schema.serialized_schema = ( + serialized_schema + ) + request.arrow_rows.rows.serialized_record_batch = ( + batch.serialize().to_pybytes() + ) + + yield request + current_offset += batch.num_rows + + responses = self._write_client.append_rows(requests=request_generator()) + for resp in responses: + if resp.row_errors: + raise ValueError( + f"Errors in stream {stream_name}: {resp.row_errors}" + ) + self._write_client.finalize_write_stream(name=stream_name) + return stream_name + + shared_batches = ThreadSafeIterator(all_batches) + + stream_names = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=num_streams) as executor: + futures = [] + for _ in range(num_streams): + try: + work = next(shared_batches) + except StopIteration: + break # existing workers have consume all work, don't create more workers + # Guarantee at least a single piece of work for each worker + future = executor.submit( + stream_worker, itertools.chain((work,), shared_batches) ) - # This step isn't strictly necessary in COMMITTED mode, but avoids max active stream limits - response = self._write_client.finalize_write_stream(name=stream.name) - assert response.row_count == data.data.num_rows + futures.append(future) + + for future in concurrent.futures.as_completed(futures): + stream_name = future.result() + stream_names.append(stream_name) + + # This makes all data from all streams visible in the table at once + commit_request = bq_storage_types.BatchCommitWriteStreamsRequest( + parent=parent, write_streams=stream_names + ) + self._write_client.batch_commit_write_streams(commit_request) - destination_table = self._bqclient.get_table(bq_table_ref) + result_table = bq_data.GbqTable.from_ref_and_schema( + bq_table_ref, schema=bq_schema, cluster_cols=[offsets_col] + ) return bq_data.BigqueryDataSource( - bq_data.GbqTable.from_table(destination_table), + result_table, schema=schema_w_offsets, ordering=ordering.TotalOrdering.from_offset_col(offsets_col), n_rows=data.metadata.row_count, @@ -1368,3 +1408,21 @@ def _batched(iterator: Iterable, n: int) -> Iterable: assert n > 0 while batch := tuple(itertools.islice(iterator, n)): yield batch + + +T = TypeVar("T") + + +class ThreadSafeIterator(Iterator[T]): + """A wrapper to make an iterator thread-safe.""" + + def __init__(self, it: Iterable[T]): + self.it = iter(it) + self.lock = threading.Lock() + + def __next__(self): + with self.lock: + return next(self.it) + + def __iter__(self): + return self From 3c54c68fd86bd85784f09e7ff819d39ca6e8409b Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Fri, 19 Dec 2025 13:40:02 -0800 Subject: [PATCH 301/313] refactor: fix SQLGlot doctests on the window, ai, to_json ops (#2341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change can resolve three doctests failures in #2248: - Added `to_json` support to the compiler backend. - Fixed minor bugs on AI ops - Fixed minor bugs on windows compiler Fixes internal issue 417774347 🦕 --- bigframes/core/compile/sqlglot/compiler.py | 2 +- .../compile/sqlglot/expressions/ai_ops.py | 1 + .../compile/sqlglot/expressions/json_ops.py | 5 +++++ .../test_json_ops/test_to_json/out.sql | 13 +++++++++++ .../sqlglot/expressions/test_json_ops.py | 8 +++++++ .../out.sql | 22 ++++++++++++++++++- 6 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json/out.sql diff --git a/bigframes/core/compile/sqlglot/compiler.py b/bigframes/core/compile/sqlglot/compiler.py index 7ecc15f6a2..501243fe8e 100644 --- a/bigframes/core/compile/sqlglot/compiler.py +++ b/bigframes/core/compile/sqlglot/compiler.py @@ -378,7 +378,7 @@ def compile_window(node: nodes.WindowOpNode, child: ir.SQLGlotIR) -> ir.SQLGlotI window_op = sge.Case(ifs=when_expressions, default=window_op) # TODO: check if we can directly window the expression. - result = child.window( + result = result.window( window_op=window_op, output_column_id=cdef.id.sql, ) diff --git a/bigframes/core/compile/sqlglot/expressions/ai_ops.py b/bigframes/core/compile/sqlglot/expressions/ai_ops.py index 680e35c511..a8a36cb6c0 100644 --- a/bigframes/core/compile/sqlglot/expressions/ai_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/ai_ops.py @@ -93,6 +93,7 @@ def _construct_prompt( for elem in prompt_context: if elem is None: prompt.append(exprs[column_ref_idx].expr) + column_ref_idx += 1 else: prompt.append(sge.Literal.string(elem)) diff --git a/bigframes/core/compile/sqlglot/expressions/json_ops.py b/bigframes/core/compile/sqlglot/expressions/json_ops.py index ef55f6edac..0a38e8e138 100644 --- a/bigframes/core/compile/sqlglot/expressions/json_ops.py +++ b/bigframes/core/compile/sqlglot/expressions/json_ops.py @@ -69,6 +69,11 @@ def _(expr: TypedExpr) -> sge.Expression: return sge.func("PARSE_JSON", expr.expr) +@register_unary_op(ops.ToJSON) +def _(expr: TypedExpr) -> sge.Expression: + return sge.func("TO_JSON", expr.expr) + + @register_unary_op(ops.ToJSONString) def _(expr: TypedExpr) -> sge.Expression: return sge.func("TO_JSON_STRING", expr.expr) diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json/out.sql new file mode 100644 index 0000000000..ebca0c51c5 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TO_JSON(`string_col`) AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py index 4ae3eb3fcc..1c5894fc96 100644 --- a/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py +++ b/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py @@ -105,6 +105,14 @@ def test_parse_json(scalar_types_df: bpd.DataFrame, snapshot): snapshot.assert_match(sql, "out.sql") +def test_to_json(scalar_types_df: bpd.DataFrame, snapshot): + col_name = "string_col" + bf_df = scalar_types_df[[col_name]] + sql = utils._apply_ops_to_sql(bf_df, [ops.ToJSON().as_expr(col_name)], [col_name]) + + snapshot.assert_match(sql, "out.sql") + + def test_to_json_string(json_types_df: bpd.DataFrame, snapshot): col_name = "json_col" bf_df = json_types_df[[col_name]] diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql index b1d498bc76..e8fabd1129 100644 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_groupby_rolling/out.sql @@ -12,12 +12,32 @@ WITH `bfcte_0` AS ( `int64_col` AS `bfcol_8`, `bool_col` AS `bfcol_9` FROM `bfcte_0` -), `bfcte_3` AS ( +), `bfcte_2` AS ( SELECT * FROM `bfcte_1` WHERE NOT `bfcol_9` IS NULL +), `bfcte_3` AS ( + SELECT + *, + CASE + WHEN SUM(CAST(NOT `bfcol_7` IS NULL AS INT64)) OVER ( + PARTITION BY `bfcol_9` + ORDER BY `bfcol_9` ASC NULLS LAST, `rowindex` ASC NULLS LAST + ROWS BETWEEN 3 PRECEDING AND CURRENT ROW + ) < 3 + THEN NULL + ELSE COALESCE( + SUM(CAST(`bfcol_7` AS INT64)) OVER ( + PARTITION BY `bfcol_9` + ORDER BY `bfcol_9` ASC NULLS LAST, `rowindex` ASC NULLS LAST + ROWS BETWEEN 3 PRECEDING AND CURRENT ROW + ), + 0 + ) + END AS `bfcol_15` + FROM `bfcte_2` ), `bfcte_4` AS ( SELECT *, From 2d07bb32659721d9f922949228c86d273e45ac8e Mon Sep 17 00:00:00 2001 From: TrevorBergeron Date: Fri, 19 Dec 2025 19:47:27 -0800 Subject: [PATCH 302/313] perf: Fragmentize multiple col expressions as one (#2333) --- bigframes/core/block_transforms.py | 58 ++++++++-------- bigframes/core/expression.py | 54 +-------------- bigframes/core/expression_factoring.py | 91 ++++++++++++++++++++++---- 3 files changed, 111 insertions(+), 92 deletions(-) diff --git a/bigframes/core/block_transforms.py b/bigframes/core/block_transforms.py index 31ab0312f2..5c6395d171 100644 --- a/bigframes/core/block_transforms.py +++ b/bigframes/core/block_transforms.py @@ -625,21 +625,7 @@ def skew( # counts, moment3 for each column aggregations = [] for col in original_columns: - delta3_expr = _mean_delta_to_power(3, col) - count_agg = agg_expressions.UnaryAggregation( - agg_ops.count_op, - ex.deref(col), - ) - moment3_agg = agg_expressions.UnaryAggregation( - agg_ops.mean_op, - delta3_expr, - ) - variance_agg = agg_expressions.UnaryAggregation( - agg_ops.PopVarOp(), - ex.deref(col), - ) - skew_expr = _skew_from_moments_and_count(count_agg, moment3_agg, variance_agg) - aggregations.append(skew_expr) + aggregations.append(skew_expr(ex.deref(col))) block = block.aggregate( aggregations, grouping_column_ids, column_labels=column_labels @@ -663,16 +649,7 @@ def kurt( # counts, moment4 for each column kurt_exprs = [] for col in original_columns: - delta_4_expr = _mean_delta_to_power(4, col) - count_agg = agg_expressions.UnaryAggregation(agg_ops.count_op, ex.deref(col)) - moment4_agg = agg_expressions.UnaryAggregation(agg_ops.mean_op, delta_4_expr) - variance_agg = agg_expressions.UnaryAggregation( - agg_ops.PopVarOp(), ex.deref(col) - ) - - # Corresponds to order of aggregations in preceding loop - kurt_expr = _kurt_from_moments_and_count(count_agg, moment4_agg, variance_agg) - kurt_exprs.append(kurt_expr) + kurt_exprs.append(kurt_expr(ex.deref(col))) block = block.aggregate( kurt_exprs, grouping_column_ids, column_labels=column_labels @@ -686,13 +663,38 @@ def kurt( return block +def skew_expr(expr: ex.Expression) -> ex.Expression: + delta3_expr = _mean_delta_to_power(3, expr) + count_agg = agg_expressions.UnaryAggregation( + agg_ops.count_op, + expr, + ) + moment3_agg = agg_expressions.UnaryAggregation( + agg_ops.mean_op, + delta3_expr, + ) + variance_agg = agg_expressions.UnaryAggregation( + agg_ops.PopVarOp(), + expr, + ) + return _skew_from_moments_and_count(count_agg, moment3_agg, variance_agg) + + +def kurt_expr(expr: ex.Expression) -> ex.Expression: + delta_4_expr = _mean_delta_to_power(4, expr) + count_agg = agg_expressions.UnaryAggregation(agg_ops.count_op, expr) + moment4_agg = agg_expressions.UnaryAggregation(agg_ops.mean_op, delta_4_expr) + variance_agg = agg_expressions.UnaryAggregation(agg_ops.PopVarOp(), expr) + return _kurt_from_moments_and_count(count_agg, moment4_agg, variance_agg) + + def _mean_delta_to_power( n_power: int, - val_id: str, + col_expr: ex.Expression, ) -> ex.Expression: """Calculate (x-mean(x))^n. Useful for calculating moment statistics such as skew and kurtosis.""" - mean_expr = agg_expressions.UnaryAggregation(agg_ops.mean_op, ex.deref(val_id)) - delta = ops.sub_op.as_expr(val_id, mean_expr) + mean_expr = agg_expressions.UnaryAggregation(agg_ops.mean_op, col_expr) + delta = ops.sub_op.as_expr(col_expr, mean_expr) return ops.pow_op.as_expr(delta, ex.const(n_power)) diff --git a/bigframes/core/expression.py b/bigframes/core/expression.py index 5b911de808..89bcb9b920 100644 --- a/bigframes/core/expression.py +++ b/bigframes/core/expression.py @@ -15,12 +15,11 @@ from __future__ import annotations import abc -import collections import dataclasses import functools import itertools import typing -from typing import Callable, Dict, Generator, Mapping, Tuple, TypeVar, Union +from typing import Callable, Generator, Mapping, TypeVar, Union import pandas as pd @@ -162,57 +161,6 @@ def walk(self) -> Generator[Expression, None, None]: for child in self.children: yield from child.children - def unique_nodes( - self: Expression, - ) -> Generator[Expression, None, None]: - """Walks the tree for unique nodes""" - seen = set() - stack: list[Expression] = [self] - while stack: - item = stack.pop() - if item not in seen: - yield item - seen.add(item) - stack.extend(item.children) - - def iter_nodes_topo( - self: Expression, - ) -> Generator[Expression, None, None]: - """Returns nodes in reverse topological order, using Kahn's algorithm.""" - child_to_parents: Dict[Expression, list[Expression]] = collections.defaultdict( - list - ) - out_degree: Dict[Expression, int] = collections.defaultdict(int) - - queue: collections.deque["Expression"] = collections.deque() - for node in list(self.unique_nodes()): - num_children = len(node.children) - out_degree[node] = num_children - if num_children == 0: - queue.append(node) - for child in node.children: - child_to_parents[child].append(node) - - while queue: - item = queue.popleft() - yield item - parents = child_to_parents.get(item, []) - for parent in parents: - out_degree[parent] -= 1 - if out_degree[parent] == 0: - queue.append(parent) - - def reduce_up(self, reduction: Callable[[Expression, Tuple[T, ...]], T]) -> T: - """Apply a bottom-up reduction to the tree.""" - results: dict[Expression, T] = {} - for node in list(self.iter_nodes_topo()): - # child nodes have already been transformed - child_results = tuple(results[child] for child in node.children) - result = reduction(node, child_results) - results[node] = result - - return results[self] - @dataclasses.dataclass(frozen=True) class ScalarConstantExpression(Expression): diff --git a/bigframes/core/expression_factoring.py b/bigframes/core/expression_factoring.py index ac62aeab38..b58330f5a4 100644 --- a/bigframes/core/expression_factoring.py +++ b/bigframes/core/expression_factoring.py @@ -18,7 +18,10 @@ import functools import itertools from typing import ( + Callable, cast, + Dict, + Generator, Hashable, Iterable, Iterator, @@ -40,18 +43,72 @@ _MAX_INLINE_COMPLEXITY = 10 +T = TypeVar("T") + + +def unique_nodes( + roots: Sequence[expression.Expression], +) -> Generator[expression.Expression, None, None]: + """Walks the tree for unique nodes""" + seen = set() + stack: list[expression.Expression] = list(roots) + while stack: + item = stack.pop() + if item not in seen: + yield item + seen.add(item) + stack.extend(item.children) + + +def iter_nodes_topo( + roots: Sequence[expression.Expression], +) -> Generator[expression.Expression, None, None]: + """Returns nodes in reverse topological order, using Kahn's algorithm.""" + child_to_parents: Dict[ + expression.Expression, list[expression.Expression] + ] = collections.defaultdict(list) + out_degree: Dict[expression.Expression, int] = collections.defaultdict(int) + + queue: collections.deque[expression.Expression] = collections.deque() + for node in unique_nodes(roots): + num_children = len(node.children) + out_degree[node] = num_children + if num_children == 0: + queue.append(node) + for child in node.children: + child_to_parents[child].append(node) + + while queue: + item = queue.popleft() + yield item + parents = child_to_parents.get(item, []) + for parent in parents: + out_degree[parent] -= 1 + if out_degree[parent] == 0: + queue.append(parent) + + +def reduce_up( + roots: Sequence[expression.Expression], + reduction: Callable[[expression.Expression, Tuple[T, ...]], T], +) -> Tuple[T, ...]: + """Apply a bottom-up reduction to the forest.""" + results: dict[expression.Expression, T] = {} + for node in list(iter_nodes_topo(roots)): + # child nodes have already been transformed + child_results = tuple(results[child] for child in node.children) + result = reduction(node, child_results) + results[node] = result + + return tuple(results[root] for root in roots) + def apply_col_exprs_to_plan( plan: nodes.BigFrameNode, col_exprs: Sequence[nodes.ColumnDef] ) -> nodes.BigFrameNode: - # TODO: Jointly fragmentize expressions to more efficiently reuse common sub-expressions target_ids = tuple(named_expr.id for named_expr in col_exprs) - fragments = tuple( - itertools.chain.from_iterable( - fragmentize_expression(expr) for expr in col_exprs - ) - ) + fragments = fragmentize_expression(col_exprs) return push_into_tree(plan, fragments, target_ids) @@ -101,14 +158,26 @@ class FactoredExpression: sub_exprs: Tuple[nodes.ColumnDef, ...] -def fragmentize_expression(root: nodes.ColumnDef) -> Sequence[nodes.ColumnDef]: +def fragmentize_expression( + roots: Sequence[nodes.ColumnDef], +) -> Sequence[nodes.ColumnDef]: """ The goal of this functions is to factor out an expression into multiple sub-expressions. """ - - factored_expr = root.expression.reduce_up(gather_fragments) - root_expr = nodes.ColumnDef(factored_expr.root_expr, root.id) - return (root_expr, *factored_expr.sub_exprs) + # TODO: Fragmentize a bit less aggressively + factored_exprs = reduce_up([root.expression for root in roots], gather_fragments) + root_exprs = ( + nodes.ColumnDef(factored.root_expr, root.id) + for factored, root in zip(factored_exprs, roots) + ) + return ( + *root_exprs, + *dedupe( + itertools.chain.from_iterable( + factored_expr.sub_exprs for factored_expr in factored_exprs + ) + ), + ) @dataclasses.dataclass(frozen=True, eq=False) From f9b145ead94d36eb05bee9455daaf815b00dd7a6 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Mon, 22 Dec 2025 07:35:47 -0800 Subject: [PATCH 303/313] tests: fix `test_get_formatted_time` from the `humanize` package upgrade (#2349) This change can fix the `test_get_formatted_time` to avoid the inconsistent results from the `humanize` package upgrade. Please refer to the internal screenshot for details: screenshot/BcJFk78xtYtLnyo --- tests/unit/test_formatting_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_formatting_helpers.py b/tests/unit/test_formatting_helpers.py index 9dc1379496..7a1cf1ab13 100644 --- a/tests/unit/test_formatting_helpers.py +++ b/tests/unit/test_formatting_helpers.py @@ -67,7 +67,7 @@ def test_get_formatted_bytes(test_input, expected): @pytest.mark.parametrize( - "test_input, expected", [(None, None), ("string", "string"), (100000, "a minute")] + "test_input, expected", [(None, None), ("string", "string"), (66000, "a minute")] ) def test_get_formatted_time(test_input, expected): assert formatting_helpers.get_formatted_time(test_input) == expected From 7efdda86cf40b2d9675a316a03e51d0168a527a3 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Mon, 22 Dec 2025 16:29:01 -0800 Subject: [PATCH 304/313] refactor: Anywidget Table Styling and Hover Enhancements (#2353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR focuses on improving the user interface and experience of the BigFrames Anywidget table display by applying targeted styling and adding interactive features. Key changes include: - **CSS Refinements:** - Updated footer styling for better alignment and typography. - Introduced styling for header content to allow horizontal resizing of columns. - Enhanced the visual presentation of error messages. - Adjusted default cell padding and added hover styles for rows to improve readability and visual feedback. - **JavaScript Enhancements:** - Implemented a row hover effect that highlights all cells belonging to a logical row, even if it spans multiple physical table rows (e.g., due to multi-indexing or exploded array data). This significantly improves clarity when navigating complex tables. - Added explicit handling for empty datasets to ensure correct pagination button states and labels. This PR aims to deliver a more polished and intuitive table display, aligning with our coding style guides and enhancing user interaction. Temporary patch for b/460861328 🦕 --- bigframes/display/table_widget.css | 49 +++++++++++++++++++---- bigframes/display/table_widget.js | 62 +++++++++++++++++++++++++----- 2 files changed, 94 insertions(+), 17 deletions(-) diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css index dcef55cae1..4ad21c4d34 100644 --- a/bigframes/display/table_widget.css +++ b/bigframes/display/table_widget.css @@ -26,12 +26,15 @@ .bigframes-widget .footer { align-items: center; + /* TODO(b/460861328): We will support dark mode in a media selector once we + * determine how to override the background colors as well. */ + color: black; display: flex; + font-family: + "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; font-size: 0.8rem; justify-content: space-between; padding: 8px; - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } .bigframes-widget .footer > * { @@ -69,16 +72,29 @@ .bigframes-widget table { border-collapse: collapse; + /* TODO(b/460861328): We will support dark mode in a media selector once we + * determine how to override the background colors as well. */ + color: black; text-align: left; } .bigframes-widget th { background-color: var(--colab-primary-surface-color, var(--jp-layout-color0)); + padding: 0; position: sticky; top: 0; z-index: 1; } +.bigframes-widget .bf-header-content { + box-sizing: border-box; + height: 100%; + overflow: auto; + padding: 0.5em; + resize: horizontal; + width: 100%; +} + .bigframes-widget th .sort-indicator { padding-left: 4px; visibility: hidden; @@ -102,13 +118,30 @@ pointer-events: none; } -.bigframes-widget .error-message { +.bigframes-widget .bigframes-error-message { + background-color: #fbe; + border: 1px solid red; + border-radius: 4px; font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; font-size: 14px; - padding: 8px; margin-bottom: 8px; - border: 1px solid red; - border-radius: 4px; - background-color: #ffebee; + padding: 8px; +} + +.bigframes-widget .cell-align-right { + text-align: right; +} + +.bigframes-widget .cell-align-left { + text-align: left; +} + +.bigframes-widget td { + padding: 0.5em; +} + +.bigframes-widget tr:hover td, +.bigframes-widget td.row-hover { + background-color: var(--colab-hover-surface-color, var(--jp-layout-color2)); } diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js index 4db109cec6..ae49eaf9cf 100644 --- a/bigframes/display/table_widget.js +++ b/bigframes/display/table_widget.js @@ -15,26 +15,25 @@ */ const ModelProperty = { + ERROR_MESSAGE: "error_message", + ORDERABLE_COLUMNS: "orderable_columns", PAGE: "page", PAGE_SIZE: "page_size", ROW_COUNT: "row_count", - TABLE_HTML: "table_html", - SORT_COLUMN: "sort_column", SORT_ASCENDING: "sort_ascending", - ERROR_MESSAGE: "error_message", - ORDERABLE_COLUMNS: "orderable_columns", + SORT_COLUMN: "sort_column", + TABLE_HTML: "table_html", }; const Event = { - CLICK: "click", CHANGE: "change", CHANGE_TABLE_HTML: "change:table_html", + CLICK: "click", }; /** * Renders the interactive table widget. - * @param {{ model: any, el: HTMLElement }} props - The widget properties. - * @param {Document} doc - The document object to use for creating elements. + * @param {{ model: any, el: !HTMLElement }} props - The widget properties. */ function render({ model, el }) { // Main container with a unique class for CSS scoping @@ -90,14 +89,24 @@ function render({ model, el }) { if (rowCount === null) { // Unknown total rows rowCountLabel.textContent = "Total rows unknown"; - pageIndicator.textContent = `Page ${(currentPage + 1).toLocaleString()} of many`; + pageIndicator.textContent = `Page ${( + currentPage + 1 + ).toLocaleString()} of many`; prevPage.disabled = currentPage === 0; nextPage.disabled = false; // Allow navigation until we hit the end + } else if (rowCount === 0) { + // Empty dataset + rowCountLabel.textContent = "0 total rows"; + pageIndicator.textContent = "Page 1 of 1"; + prevPage.disabled = true; + nextPage.disabled = true; } else { // Known total rows const totalPages = Math.ceil(rowCount / pageSize); rowCountLabel.textContent = `${rowCount.toLocaleString()} total rows`; - pageIndicator.textContent = `Page ${(currentPage + 1).toLocaleString()} of ${totalPages.toLocaleString()}`; + pageIndicator.textContent = `Page ${( + currentPage + 1 + ).toLocaleString()} of ${totalPages.toLocaleString()}`; prevPage.disabled = currentPage === 0; nextPage.disabled = currentPage >= totalPages - 1; } @@ -200,6 +209,41 @@ function render({ model, el }) { } }); + const table = tableContainer.querySelector("table"); + if (table) { + const tableBody = table.querySelector("tbody"); + + /** + * Handles row hover events. + * @param {!Event} event - The mouse event. + * @param {boolean} isHovering - True to add hover class, false to remove. + */ + function handleRowHover(event, isHovering) { + const cell = event.target.closest("td"); + if (cell) { + const row = cell.closest("tr"); + const origRowId = row.dataset.origRow; + if (origRowId) { + const allCellsInGroup = tableBody.querySelectorAll( + `tr[data-orig-row="${origRowId}"] td`, + ); + allCellsInGroup.forEach((c) => { + c.classList.toggle("row-hover", isHovering); + }); + } + } + } + + if (tableBody) { + tableBody.addEventListener("mouseover", (event) => + handleRowHover(event, true), + ); + tableBody.addEventListener("mouseout", (event) => + handleRowHover(event, false), + ); + } + } + updateButtonStates(); } From 9674c56fb70929ecad9cf41403fdbc53e9862b5d Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Mon, 22 Dec 2025 19:17:17 -0800 Subject: [PATCH 305/313] refactor: fix group_by compiler with dtype convertions (#2350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change can resolve the `bigframes.ml.metrics.roc_auc_score` doctests failures in #2248. Fixes internal issue 417774347 🦕 --- .../compile/sqlglot/aggregations/windows.py | 22 +++++++-- .../sqlglot/aggregations/test_windows.py | 47 +++++++++++++++++-- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/bigframes/core/compile/sqlglot/aggregations/windows.py b/bigframes/core/compile/sqlglot/aggregations/windows.py index 5ca66ee505..d1a68b2ef7 100644 --- a/bigframes/core/compile/sqlglot/aggregations/windows.py +++ b/bigframes/core/compile/sqlglot/aggregations/windows.py @@ -19,7 +19,9 @@ from bigframes.core import utils, window_spec import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler +import bigframes.core.expression as ex import bigframes.core.ordering as ordering_spec +import bigframes.dtypes as dtypes def apply_window_if_present( @@ -52,10 +54,7 @@ def apply_window_if_present( order = sge.Order(expressions=order_by) group_by = ( - [ - scalar_compiler.scalar_op_compiler.compile_expression(key) - for key in window.grouping_keys - ] + [_compile_group_by_key(key) for key in window.grouping_keys] if window.grouping_keys else None ) @@ -164,3 +163,18 @@ def _get_window_bounds( side = "PRECEDING" if value < 0 else "FOLLOWING" return sge.convert(abs(value)), side + + +def _compile_group_by_key(key: ex.Expression) -> sge.Expression: + expr = scalar_compiler.scalar_op_compiler.compile_expression(key) + # The group_by keys has been rewritten by bind_schema_to_node + assert isinstance(key, ex.ResolvedDerefOp) + + # Some types need to be converted to another type to enable groupby + if key.dtype == dtypes.FLOAT_DTYPE: + expr = sge.Cast(this=expr, to="STRING") + elif key.dtype == dtypes.GEO_DTYPE: + expr = sge.Cast(this=expr, to="BYTES") + elif key.dtype == dtypes.JSON_DTYPE: + expr = sge.func("TO_JSON_STRING", expr) + return expr diff --git a/tests/unit/core/compile/sqlglot/aggregations/test_windows.py b/tests/unit/core/compile/sqlglot/aggregations/test_windows.py index f1a3eced9a..af347f4aa3 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_windows.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_windows.py @@ -18,12 +18,14 @@ import pytest import sqlglot.expressions as sge +from bigframes import dtypes from bigframes.core import window_spec from bigframes.core.compile.sqlglot.aggregations.windows import ( apply_window_if_present, get_window_order_by, ) import bigframes.core.expression as ex +import bigframes.core.identifiers as ids import bigframes.core.ordering as ordering @@ -82,16 +84,37 @@ def test_apply_window_if_present_row_bounded_no_ordering_raises(self): ), ) - def test_apply_window_if_present_unbounded_grouping_no_ordering(self): + def test_apply_window_if_present_grouping_no_ordering(self): result = apply_window_if_present( sge.Var(this="value"), window_spec.WindowSpec( - grouping_keys=(ex.deref("col1"),), + grouping_keys=( + ex.ResolvedDerefOp( + ids.ColumnId("col1"), + dtype=dtypes.STRING_DTYPE, + is_nullable=True, + ), + ex.ResolvedDerefOp( + ids.ColumnId("col2"), + dtype=dtypes.FLOAT_DTYPE, + is_nullable=True, + ), + ex.ResolvedDerefOp( + ids.ColumnId("col3"), + dtype=dtypes.JSON_DTYPE, + is_nullable=True, + ), + ex.ResolvedDerefOp( + ids.ColumnId("col4"), + dtype=dtypes.GEO_DTYPE, + is_nullable=True, + ), + ), ), ) self.assertEqual( result.sql(dialect="bigquery"), - "value OVER (PARTITION BY `col1`)", + "value OVER (PARTITION BY `col1`, CAST(`col2` AS STRING), TO_JSON_STRING(`col3`), CAST(`col4` AS BYTES))", ) def test_apply_window_if_present_range_bounded(self): @@ -126,8 +149,22 @@ def test_apply_window_if_present_all_params(self): result = apply_window_if_present( sge.Var(this="value"), window_spec.WindowSpec( - grouping_keys=(ex.deref("col1"),), - ordering=(ordering.OrderingExpression(ex.deref("col2")),), + grouping_keys=( + ex.ResolvedDerefOp( + ids.ColumnId("col1"), + dtype=dtypes.STRING_DTYPE, + is_nullable=True, + ), + ), + ordering=( + ordering.OrderingExpression( + ex.ResolvedDerefOp( + ids.ColumnId("col2"), + dtype=dtypes.STRING_DTYPE, + is_nullable=True, + ) + ), + ), bounds=window_spec.RowsWindowBounds(start=-1, end=0), ), ) From b4cea76ff6ead3330d954f6bda68e6748a646bd8 Mon Sep 17 00:00:00 2001 From: Chelsea Lin Date: Mon, 22 Dec 2025 19:17:26 -0800 Subject: [PATCH 306/313] refactor: support sql_predicate when compile readtable in sqlglot (#2348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change can resolve `read_gbq` doctests failures in #2248. Fixes internal issue 417774347🦕 --- bigframes/core/compile/sqlglot/compiler.py | 1 + bigframes/core/compile/sqlglot/sqlglot_ir.py | 6 ++++++ .../out.sql | 14 ++++++++++++++ .../core/compile/sqlglot/test_compile_readtable.py | 12 ++++++++++++ 4 files changed, 33 insertions(+) create mode 100644 tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_columns_filters/out.sql diff --git a/bigframes/core/compile/sqlglot/compiler.py b/bigframes/core/compile/sqlglot/compiler.py index 501243fe8e..870e7064b8 100644 --- a/bigframes/core/compile/sqlglot/compiler.py +++ b/bigframes/core/compile/sqlglot/compiler.py @@ -180,6 +180,7 @@ def compile_readtable(node: nodes.ReadTableNode, child: ir.SQLGlotIR): col_names=[col.source_id for col in node.scan_list.items], alias_names=[col.id.sql for col in node.scan_list.items], uid_gen=child.uid_gen, + sql_predicate=node.source.sql_predicate, system_time=node.source.at_time, ) diff --git a/bigframes/core/compile/sqlglot/sqlglot_ir.py b/bigframes/core/compile/sqlglot/sqlglot_ir.py index cbc601ea63..176564fe23 100644 --- a/bigframes/core/compile/sqlglot/sqlglot_ir.py +++ b/bigframes/core/compile/sqlglot/sqlglot_ir.py @@ -120,6 +120,7 @@ def from_table( col_names: typing.Sequence[str], alias_names: typing.Sequence[str], uid_gen: guid.SequentialUIDGenerator, + sql_predicate: typing.Optional[str] = None, system_time: typing.Optional[datetime.datetime] = None, ) -> SQLGlotIR: """Builds a SQLGlotIR expression from a BigQuery table. @@ -131,6 +132,7 @@ def from_table( col_names (typing.Sequence[str]): The names of the columns to select. alias_names (typing.Sequence[str]): The aliases for the selected columns. uid_gen (guid.SequentialUIDGenerator): A generator for unique identifiers. + sql_predicate (typing.Optional[str]): An optional SQL predicate for filtering. system_time (typing.Optional[str]): An optional system time for time-travel queries. """ selections = [ @@ -158,6 +160,10 @@ def from_table( version=version, ) select_expr = sge.Select().select(*selections).from_(table_expr) + if sql_predicate: + select_expr = select_expr.where( + sg.parse_one(sql_predicate, dialect="bigquery"), append=False + ) return cls(expr=select_expr, uid_gen=uid_gen) @classmethod diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_columns_filters/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_columns_filters/out.sql new file mode 100644 index 0000000000..0d8a10c956 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_columns_filters/out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col`, + `rowindex`, + `string_col` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` + WHERE + `rowindex` > 0 AND `string_col` IN ('Hello, World!') +) +SELECT + `rowindex`, + `int64_col`, + `string_col` +FROM `bfcte_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/test_compile_readtable.py b/tests/unit/core/compile/sqlglot/test_compile_readtable.py index 37d87510ee..dd776d9a8f 100644 --- a/tests/unit/core/compile/sqlglot/test_compile_readtable.py +++ b/tests/unit/core/compile/sqlglot/test_compile_readtable.py @@ -67,3 +67,15 @@ def test_compile_readtable_w_system_time( ) bf_df = compiler_session.read_gbq_table(str(table_ref)) snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_compile_readtable_w_columns_filters(compiler_session, snapshot): + columns = ["rowindex", "int64_col", "string_col"] + filters = [("rowindex", ">", 0), ("string_col", "in", ["Hello, World!"])] + bf_df = compiler_session._loader.read_gbq_table( + "bigframes-dev.sqlglot_test.scalar_types", + enable_snapshot=False, + columns=columns, + filters=filters, + ) + snapshot.assert_match(bf_df.sql, "out.sql") From 7d2990f1c48c6d74e2af6bee3af87f90189a3d9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Swe=C3=B1a=20=28Swast=29?= Date: Tue, 23 Dec 2025 13:59:49 -0600 Subject: [PATCH 307/313] docs: generate sitemap.xml for better search indexing (#2351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toward b/439708431 🦕 --- docs/conf.py | 4 ++++ noxfile.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index a9ca501a8f..22868aab67 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -58,6 +58,7 @@ "sphinx.ext.napoleon", "sphinx.ext.todo", "sphinx.ext.viewcode", + "sphinx_sitemap", "myst_parser", ] @@ -264,6 +265,9 @@ # Output file base name for HTML help builder. htmlhelp_basename = "bigframes-doc" +# https://sphinx-sitemap.readthedocs.io/en/latest/getting-started.html#usage +html_baseurl = "https://dataframes.bigquery.dev/" + # -- Options for warnings ------------------------------------------------------ diff --git a/noxfile.py b/noxfile.py index 44fc5adede..5322c1af52 100644 --- a/noxfile.py +++ b/noxfile.py @@ -521,6 +521,7 @@ def docs(session): session.install("-e", ".[scikit-learn]") session.install( "sphinx==8.2.3", + "sphinx-sitemap==2.9.0", "myst-parser==4.0.1", "pydata-sphinx-theme==0.16.1", ) @@ -553,6 +554,7 @@ def docfx(session): session.install("-e", ".[scikit-learn]") session.install( SPHINX_VERSION, + "sphinx-sitemap==2.9.0", "pydata-sphinx-theme==0.13.3", "myst-parser==0.18.1", "gcp-sphinx-docfx-yaml==3.2.4", From b8f09015a7c8e6987dc124e6df925d4f6951b1da Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Tue, 23 Dec 2025 13:36:11 -0800 Subject: [PATCH 308/313] feat: Refactor TableWidget and to_pandas_batches (#2250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR refactors DataFrame.to_pandas_batches to return a blocks.PandasBatches object, improving the handling of streamed data and total row counts. Additionally, this PR updates TableWidget to: * Utilize the new PandasBatches return type for more robust data loading. * Improve thread safety during HTML updates using a re-entrant lock (RLock), ensuring concurrent interactions (like rapid sorting or pagination) are handled correctly without race conditions. * Fix compatibility with older Python versions by updating type annotations. * Refactor index handling logic within the widget for better clarity and stability. User Impact: * More Stable Interactive Tables: Users experiencing the interactive table widget in notebooks should see more reliable updates when paging or sorting rapidly. * Internal API Consistency: to_pandas_batches now provides a more structured return type for downstream consumers. Fixes #<459515995> 🦕 --- bigframes/dataframe.py | 2 +- bigframes/display/anywidget.py | 217 +++++++++++++++------------ tests/system/small/test_anywidget.py | 2 +- tests/unit/display/test_anywidget.py | 80 ++++++++++ 4 files changed, 203 insertions(+), 98 deletions(-) create mode 100644 tests/unit/display/test_anywidget.py diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 4d594ddfbc..7a34b152fd 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -1969,7 +1969,7 @@ def to_pandas_batches( max_results: Optional[int] = None, *, allow_large_results: Optional[bool] = None, - ) -> Iterable[pandas.DataFrame]: + ) -> blocks.PandasBatches: """Stream DataFrame results to an iterable of pandas DataFrame. page_size and max_results determine the size and number of batches, diff --git a/bigframes/display/anywidget.py b/bigframes/display/anywidget.py index 5c1db93dce..a81aff9080 100644 --- a/bigframes/display/anywidget.py +++ b/bigframes/display/anywidget.py @@ -20,7 +20,8 @@ from importlib import resources import functools import math -from typing import Any, Dict, Iterator, List, Optional, Type +import threading +from typing import Any, Iterator, Optional import uuid import pandas as pd @@ -39,15 +40,15 @@ import anywidget import traitlets - ANYWIDGET_INSTALLED = True + _ANYWIDGET_INSTALLED = True except Exception: - ANYWIDGET_INSTALLED = False + _ANYWIDGET_INSTALLED = False -WIDGET_BASE: Type[Any] -if ANYWIDGET_INSTALLED: - WIDGET_BASE = anywidget.AnyWidget +_WIDGET_BASE: type[Any] +if _ANYWIDGET_INSTALLED: + _WIDGET_BASE = anywidget.AnyWidget else: - WIDGET_BASE = object + _WIDGET_BASE = object @dataclasses.dataclass(frozen=True) @@ -56,7 +57,7 @@ class _SortState: ascending: bool -class TableWidget(WIDGET_BASE): +class TableWidget(_WIDGET_BASE): """An interactive, paginated table widget for BigFrames DataFrames. This widget provides a user-friendly way to display and navigate through @@ -65,12 +66,8 @@ class TableWidget(WIDGET_BASE): page = traitlets.Int(0).tag(sync=True) page_size = traitlets.Int(0).tag(sync=True) - row_count = traitlets.Union( - [traitlets.Int(), traitlets.Instance(type(None))], - default_value=None, - allow_none=True, - ).tag(sync=True) - table_html = traitlets.Unicode().tag(sync=True) + row_count = traitlets.Int(allow_none=True, default_value=None).tag(sync=True) + table_html = traitlets.Unicode("").tag(sync=True) sort_column = traitlets.Unicode("").tag(sync=True) sort_ascending = traitlets.Bool(True).tag(sync=True) orderable_columns = traitlets.List(traitlets.Unicode(), []).tag(sync=True) @@ -86,9 +83,10 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): Args: dataframe: The Bigframes Dataframe to display in the widget. """ - if not ANYWIDGET_INSTALLED: + if not _ANYWIDGET_INSTALLED: raise ImportError( - "Please `pip install anywidget traitlets` or `pip install 'bigframes[anywidget]'` to use TableWidget." + "Please `pip install anywidget traitlets` or " + "`pip install 'bigframes[anywidget]'` to use TableWidget." ) self._dataframe = dataframe @@ -99,8 +97,10 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): self._table_id = str(uuid.uuid4()) self._all_data_loaded = False self._batch_iter: Optional[Iterator[pd.DataFrame]] = None - self._cached_batches: List[pd.DataFrame] = [] + self._cached_batches: list[pd.DataFrame] = [] self._last_sort_state: Optional[_SortState] = None + # Lock to ensure only one thread at a time is updating the table HTML. + self._setting_html_lock = threading.Lock() # respect display options for initial page size initial_page_size = bigframes.options.display.max_rows @@ -108,6 +108,7 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): # set traitlets properties that trigger observers # TODO(b/462525985): Investigate and improve TableWidget UX for DataFrames with a large number of columns. self.page_size = initial_page_size + # TODO(b/469861913): Nested columns from structs (e.g., 'struct_col.name') are not currently sortable. # TODO(b/463754889): Support non-string column labels for sorting. if all(isinstance(col, str) for col in dataframe.columns): self.orderable_columns = [ @@ -118,13 +119,24 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): else: self.orderable_columns = [] + self._initial_load() + + # Signals to the frontend that the initial data load is complete. + # Also used as a guard to prevent observers from firing during initialization. + self._initial_load_complete = True + + def _initial_load(self) -> None: + """Get initial data and row count.""" # obtain the row counts # TODO(b/428238610): Start iterating over the result of `to_pandas_batches()` # before we get here so that the count might already be cached. self._reset_batches_for_new_page_size() if self._batches is None: - self._error_message = "Could not retrieve data batches. Data might be unavailable or an error occurred." + self._error_message = ( + "Could not retrieve data batches. Data might be unavailable or " + "an error occurred." + ) self.row_count = None elif self._batches.total_rows is None: # Total rows is unknown, this is an expected state. @@ -138,12 +150,8 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): # get the initial page self._set_table_html() - # Signals to the frontend that the initial data load is complete. - # Also used as a guard to prevent observers from firing during initialization. - self._initial_load_complete = True - @traitlets.observe("_initial_load_complete") - def _on_initial_load_complete(self, change: Dict[str, Any]): + def _on_initial_load_complete(self, change: dict[str, Any]): if change["new"]: self._set_table_html() @@ -158,7 +166,7 @@ def _css(self): return resources.read_text(bigframes.display, "table_widget.css") @traitlets.validate("page") - def _validate_page(self, proposal: Dict[str, Any]) -> int: + def _validate_page(self, proposal: dict[str, Any]) -> int: """Validate and clamp the page number to a valid range. Args: @@ -191,7 +199,7 @@ def _validate_page(self, proposal: Dict[str, Any]) -> int: return max(0, min(value, max_page)) @traitlets.validate("page_size") - def _validate_page_size(self, proposal: Dict[str, Any]) -> int: + def _validate_page_size(self, proposal: dict[str, Any]) -> int: """Validate page size to ensure it's positive and reasonable. Args: @@ -255,95 +263,112 @@ def _reset_batch_cache(self) -> None: def _reset_batches_for_new_page_size(self) -> None: """Reset the batch iterator when page size changes.""" - self._batches = self._dataframe._to_pandas_batches(page_size=self.page_size) + self._batches = self._dataframe.to_pandas_batches(page_size=self.page_size) self._reset_batch_cache() def _set_table_html(self) -> None: """Sets the current html data based on the current page and page size.""" - if self._error_message: - self.table_html = ( - f"
{self._error_message}
" - ) - return - - # Apply sorting if a column is selected - df_to_display = self._dataframe - if self.sort_column: - # TODO(b/463715504): Support sorting by index columns. - df_to_display = df_to_display.sort_values( - by=self.sort_column, ascending=self.sort_ascending - ) - - # Reset batches when sorting changes - if self._last_sort_state != _SortState(self.sort_column, self.sort_ascending): - self._batches = df_to_display._to_pandas_batches(page_size=self.page_size) - self._reset_batch_cache() - self._last_sort_state = _SortState(self.sort_column, self.sort_ascending) - self.page = 0 # Reset to first page - - start = self.page * self.page_size - end = start + self.page_size - - # fetch more data if the requested page is outside our cache - cached_data = self._cached_data - while len(cached_data) < end and not self._all_data_loaded: - if self._get_next_batch(): + new_page = None + with self._setting_html_lock: + if self._error_message: + self.table_html = ( + f"
" + f"{self._error_message}
" + ) + return + + # Apply sorting if a column is selected + df_to_display = self._dataframe + if self.sort_column: + # TODO(b/463715504): Support sorting by index columns. + df_to_display = df_to_display.sort_values( + by=self.sort_column, ascending=self.sort_ascending + ) + + # Reset batches when sorting changes + if self._last_sort_state != _SortState( + self.sort_column, self.sort_ascending + ): + self._batches = df_to_display.to_pandas_batches( + page_size=self.page_size + ) + self._reset_batch_cache() + self._last_sort_state = _SortState( + self.sort_column, self.sort_ascending + ) + if self.page != 0: + new_page = 0 # Reset to first page + + if new_page is None: + start = self.page * self.page_size + end = start + self.page_size + + # fetch more data if the requested page is outside our cache cached_data = self._cached_data - else: - break - - # Get the data for the current page - page_data = cached_data.iloc[start:end].copy() - - # Handle index display - # TODO(b/438181139): Add tests for custom multiindex - if self._dataframe._block.has_index: - index_name = page_data.index.name - page_data.insert( - 0, index_name if index_name is not None else "", page_data.index - ) - else: - # Default index - include as "Row" column - page_data.insert(0, "Row", range(start + 1, start + len(page_data) + 1)) - # Handle case where user navigated beyond available data with unknown row count - is_unknown_count = self.row_count is None - is_beyond_data = self._all_data_loaded and len(page_data) == 0 and self.page > 0 - if is_unknown_count and is_beyond_data: - # Calculate the last valid page (zero-indexed) - total_rows = len(cached_data) - if total_rows > 0: - last_valid_page = max(0, math.ceil(total_rows / self.page_size) - 1) - # Navigate back to the last valid page - self.page = last_valid_page - # Recursively call to display the correct page - return self._set_table_html() - else: - # If no data at all, stay on page 0 with empty display - self.page = 0 - return self._set_table_html() - - # Generate HTML table - self.table_html = bigframes.display.html.render_html( - dataframe=page_data, - table_id=f"table-{self._table_id}", - orderable_columns=self.orderable_columns, - ) + while len(cached_data) < end and not self._all_data_loaded: + if self._get_next_batch(): + cached_data = self._cached_data + else: + break + + # Get the data for the current page + page_data = cached_data.iloc[start:end].copy() + + # Handle case where user navigated beyond available data with unknown row count + is_unknown_count = self.row_count is None + is_beyond_data = ( + self._all_data_loaded and len(page_data) == 0 and self.page > 0 + ) + if is_unknown_count and is_beyond_data: + # Calculate the last valid page (zero-indexed) + total_rows = len(cached_data) + last_valid_page = max(0, math.ceil(total_rows / self.page_size) - 1) + if self.page != last_valid_page: + new_page = last_valid_page + + if new_page is None: + # Handle index display + if self._dataframe._block.has_index: + is_unnamed_single_index = ( + page_data.index.name is None + and not isinstance(page_data.index, pd.MultiIndex) + ) + page_data = page_data.reset_index() + if is_unnamed_single_index and "index" in page_data.columns: + page_data.rename(columns={"index": ""}, inplace=True) + + # Default index - include as "Row" column if no index was present originally + if not self._dataframe._block.has_index: + page_data.insert( + 0, "Row", range(start + 1, start + len(page_data) + 1) + ) + + # Generate HTML table + self.table_html = bigframes.display.html.render_html( + dataframe=page_data, + table_id=f"table-{self._table_id}", + ) + + if new_page is not None: + # Navigate to the new page. This triggers the observer, which will + # re-enter _set_table_html. Since we've released the lock, this is safe. + self.page = new_page @traitlets.observe("sort_column", "sort_ascending") - def _sort_changed(self, _change: Dict[str, Any]): + def _sort_changed(self, _change: dict[str, Any]): """Handler for when sorting parameters change from the frontend.""" self._set_table_html() @traitlets.observe("page") - def _page_changed(self, _change: Dict[str, Any]) -> None: + def _page_changed(self, _change: dict[str, Any]) -> None: """Handler for when the page number is changed from the frontend.""" if not self._initial_load_complete: return self._set_table_html() @traitlets.observe("page_size") - def _page_size_changed(self, _change: Dict[str, Any]) -> None: + def _page_size_changed(self, _change: dict[str, Any]) -> None: """Handler for when the page size is changed from the frontend.""" if not self._initial_load_complete: return diff --git a/tests/system/small/test_anywidget.py b/tests/system/small/test_anywidget.py index b0eeb4a3c2..dca8b9e1bb 100644 --- a/tests/system/small/test_anywidget.py +++ b/tests/system/small/test_anywidget.py @@ -918,7 +918,7 @@ def test_repr_mimebundle_should_fallback_to_html_if_anywidget_is_unavailable( "display.repr_mode", "anywidget", "display.max_rows", 2 ): # Mock the ANYWIDGET_INSTALLED flag to simulate absence of anywidget - with mock.patch("bigframes.display.anywidget.ANYWIDGET_INSTALLED", False): + with mock.patch("bigframes.display.anywidget._ANYWIDGET_INSTALLED", False): bundle = paginated_bf_df._repr_mimebundle_() assert "application/vnd.jupyter.widget-view+json" not in bundle assert "text/html" in bundle diff --git a/tests/unit/display/test_anywidget.py b/tests/unit/display/test_anywidget.py new file mode 100644 index 0000000000..2ca8c0da2f --- /dev/null +++ b/tests/unit/display/test_anywidget.py @@ -0,0 +1,80 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import signal +import unittest.mock as mock + +import pandas as pd +import pytest + +import bigframes + +# Skip if anywidget/traitlets not installed, though they should be in the dev env +pytest.importorskip("anywidget") +pytest.importorskip("traitlets") + + +def test_navigation_to_invalid_page_resets_to_valid_page_without_deadlock(): + """ + Given a widget on a page beyond available data, when navigating, + then it should reset to the last valid page without deadlock. + """ + from bigframes.display.anywidget import TableWidget + + mock_df = mock.create_autospec(bigframes.dataframe.DataFrame, instance=True) + mock_df.columns = ["col1"] + mock_df.dtypes = {"col1": "object"} + + mock_block = mock.Mock() + mock_block.has_index = False + mock_df._block = mock_block + + # We mock _initial_load to avoid complex setup + with mock.patch.object(TableWidget, "_initial_load"): + with bigframes.option_context( + "display.repr_mode", "anywidget", "display.max_rows", 10 + ): + widget = TableWidget(mock_df) + + # Simulate "loaded data but unknown total rows" state + widget.page_size = 10 + widget.row_count = None + widget._all_data_loaded = True + + # Populate cache with 1 page of data (10 rows). Page 0 is valid, page 1+ are invalid. + widget._cached_batches = [pd.DataFrame({"col1": range(10)})] + + # Mark initial load as complete so observers fire + widget._initial_load_complete = True + + # Setup timeout to fail fast if deadlock occurs + # signal.SIGALRM is not available on Windows + has_sigalrm = hasattr(signal, "SIGALRM") + if has_sigalrm: + + def handler(signum, frame): + raise TimeoutError("Deadlock detected!") + + signal.signal(signal.SIGALRM, handler) + signal.alarm(2) # 2 seconds timeout + + try: + # Trigger navigation to page 5 (invalid), which should reset to page 0 + widget.page = 5 + + assert widget.page == 0 + + finally: + if has_sigalrm: + signal.alarm(0) From 7171d21b8c8d5a2d61081f41fa1109b5c9c4bc5f Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 21:42:09 +0000 Subject: [PATCH 309/313] fix: bigframes.ml fit with eval data in partial mode avoids join on null index (#2355) Fix ML fit ordering issue with partial mode and eval data. --- *PR created automatically by Jules for task [4750522966926378079](https://jules.google.com/task/4750522966926378079) started by @tswast* --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> --- bigframes/ml/utils.py | 22 ++++++++++++++++++++-- tests/system/large/ml/test_linear_model.py | 15 ++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/bigframes/ml/utils.py b/bigframes/ml/utils.py index 80630c4f81..f97dd561be 100644 --- a/bigframes/ml/utils.py +++ b/bigframes/ml/utils.py @@ -201,10 +201,28 @@ def combine_training_and_evaluation_data( split_col = guid.generate_guid() assert split_col not in X_train.columns + # To prevent side effects on the input dataframes, we operate on copies + X_train = X_train.copy() + X_eval = X_eval.copy() + X_train[split_col] = False X_eval[split_col] = True - X = bpd.concat([X_train, X_eval]) - y = bpd.concat([y_train, y_eval]) + + # Rename y columns to avoid collision with X columns during join + y_mapping = {col: guid.generate_guid() + str(col) for col in y_train.columns} + y_train_renamed = y_train.rename(columns=y_mapping) + y_eval_renamed = y_eval.rename(columns=y_mapping) + + # Join X and y first to preserve row alignment + train_combined = X_train.join(y_train_renamed, how="outer") + eval_combined = X_eval.join(y_eval_renamed, how="outer") + + combined = bpd.concat([train_combined, eval_combined]) + + X = combined[X_train.columns] + y = combined[list(y_mapping.values())].rename( + columns={v: k for k, v in y_mapping.items()} + ) # create options copy to not mutate the incoming one bqml_options = bqml_options.copy() diff --git a/tests/system/large/ml/test_linear_model.py b/tests/system/large/ml/test_linear_model.py index a70d214b7f..d7bb122772 100644 --- a/tests/system/large/ml/test_linear_model.py +++ b/tests/system/large/ml/test_linear_model.py @@ -13,6 +13,7 @@ # limitations under the License. import pandas as pd +import pytest from bigframes.ml import model_selection import bigframes.ml.linear_model @@ -61,12 +62,20 @@ def test_linear_regression_configure_fit_score(penguins_df_default_index, datase assert reloaded_model.tol == 0.01 +@pytest.mark.parametrize( + "df_fixture", + [ + "penguins_df_default_index", + "penguins_df_null_index", + ], +) def test_linear_regression_configure_fit_with_eval_score( - penguins_df_default_index, dataset_id + df_fixture, dataset_id, request ): + df = request.getfixturevalue(df_fixture) model = bigframes.ml.linear_model.LinearRegression() - df = penguins_df_default_index.dropna() + df = df.dropna() X = df[ [ "species", @@ -109,7 +118,7 @@ def test_linear_regression_configure_fit_with_eval_score( assert reloaded_model.tol == 0.01 # make sure the bqml model was internally created with custom split - bq_model = penguins_df_default_index._session.bqclient.get_model(bq_model_name) + bq_model = df._session.bqclient.get_model(bq_model_name) last_fitting = bq_model.training_runs[-1]["trainingOptions"] assert last_fitting["dataSplitMethod"] == "CUSTOM" assert "dataSplitColumn" in last_fitting From 7395d418550058c516ad878e13567256f4300a37 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Tue, 23 Dec 2025 14:25:30 -0800 Subject: [PATCH 310/313] feat: display series in anywidget mode (#2346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces interactive table displays for bigframes.Series objects, aligning their notebook behavior with bigframes.DataFrame. Now, when using the "anywidget" display mode, Series are rendered as interactive tables, significantly improving data exploration. Additionally, the standard text representation for a Series has been enhanced to include the total row count, providing more context at a glance. User-Impactful Changes * Interactive Series Display: When bigframes.options.display.repr_mode is set to "anywidget", displaying a bigframes.Series in a notebook will now render an interactive widget. The widget presents the Series as a two-column table, showing the index and the values. Example Usage: ``` import bigframes.pandas as bpd # Enable interactive mode bpd.options.display.repr_mode = "anywidget" # Create or load a DataFrame df = bpd.read_gbq(...) series = df["my_column"] # Displaying the series will now show an interactive table series ``` * Enhanced Text Representation: The default text representation (e.g., when using print()) for a bigframes.Series now includes the total number of rows at the end, similar to DataFrames. For example: ``` 0 a 1 b 2 c ... Name: my_column, Length: 10, dtype: string [1000 rows] ``` These changes create a more consistent and intuitive user experience between Series and DataFrames within notebook environments. **Verified at:** - vs code notebook: screen/6QWpRs5nuNESp6e - colab notebook: screen/4SPpQXtHxk5bRpk Fixes #<460860439> 🦕 --------- Co-authored-by: Chelsea Lin Co-authored-by: Shenyang Cai --- bigframes/dataframe.py | 198 +--------- bigframes/display/html.py | 226 ++++++++++- bigframes/display/plaintext.py | 102 +++++ bigframes/formatting_helpers.py | 46 ++- bigframes/series.py | 38 +- notebooks/dataframes/anywidget_mode.ipynb | 449 +++++++++++++++------- package-lock.json | 6 + tests/js/package-lock.json | 99 +++++ tests/js/package.json | 1 + tests/js/table_widget.test.js | 53 +++ tests/system/small/test_anywidget.py | 48 ++- 11 files changed, 910 insertions(+), 356 deletions(-) create mode 100644 bigframes/display/plaintext.py create mode 100644 package-lock.json diff --git a/bigframes/dataframe.py b/bigframes/dataframe.py index 7a34b152fd..9efc6ba061 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -19,11 +19,9 @@ import datetime import inspect import itertools -import json import re import sys import textwrap -import traceback import typing from typing import ( Any, @@ -55,7 +53,6 @@ import pyarrow import tabulate -import bigframes._config.display_options as display_options import bigframes.constants import bigframes.core from bigframes.core import agg_expressions, log_adapter @@ -800,32 +797,15 @@ def __repr__(self) -> str: ) self._set_internal_query_job(query_job) + from bigframes.display import plaintext - column_count = len(pandas_df.columns) - - with display_options.pandas_repr(opts): - import pandas.io.formats - - # safe to mutate this, this dict is owned by this code, and does not affect global config - to_string_kwargs = ( - pandas.io.formats.format.get_dataframe_repr_params() # type: ignore - ) - if not self._has_index: - to_string_kwargs.update({"index": False}) - repr_string = pandas_df.to_string(**to_string_kwargs) - - # Modify the end of the string to reflect count. - lines = repr_string.split("\n") - pattern = re.compile("\\[[0-9]+ rows x [0-9]+ columns\\]") - if pattern.match(lines[-1]): - lines = lines[:-2] - - if row_count > len(lines) - 1: - lines.append("...") - - lines.append("") - lines.append(f"[{row_count} rows x {column_count} columns]") - return "\n".join(lines) + return plaintext.create_text_representation( + pandas_df, + row_count, + is_series=False, + has_index=self._has_index, + column_count=len(self.columns), + ) def _get_display_df_and_blob_cols(self) -> tuple[DataFrame, list[str]]: """Process blob columns for display.""" @@ -844,75 +824,6 @@ def _get_display_df_and_blob_cols(self) -> tuple[DataFrame, list[str]]: df[col] = df[col].blob._get_runtime(mode="R", with_metadata=True) return df, blob_cols - def _get_anywidget_bundle( - self, include=None, exclude=None - ) -> tuple[dict[str, Any], dict[str, Any]]: - """ - Helper method to create and return the anywidget mimebundle. - This function encapsulates the logic for anywidget display. - """ - from bigframes import display - - df, blob_cols = self._get_display_df_and_blob_cols() - - # Create and display the widget - widget = display.TableWidget(df) - widget_repr_result = widget._repr_mimebundle_(include=include, exclude=exclude) - - # Handle both tuple (data, metadata) and dict returns - if isinstance(widget_repr_result, tuple): - widget_repr, widget_metadata = widget_repr_result - else: - widget_repr = widget_repr_result - widget_metadata = {} - - widget_repr = dict(widget_repr) - - # At this point, we have already executed the query as part of the - # widget construction. Let's use the information available to render - # the HTML and plain text versions. - widget_repr["text/html"] = self._create_html_representation( - widget._cached_data, - widget.row_count, - len(self.columns), - blob_cols, - ) - - widget_repr["text/plain"] = self._create_text_representation( - widget._cached_data, widget.row_count - ) - - return widget_repr, widget_metadata - - def _create_text_representation( - self, pandas_df: pandas.DataFrame, total_rows: typing.Optional[int] - ) -> str: - """Create a text representation of the DataFrame.""" - opts = bigframes.options.display - with display_options.pandas_repr(opts): - import pandas.io.formats - - # safe to mutate this, this dict is owned by this code, and does not affect global config - to_string_kwargs = ( - pandas.io.formats.format.get_dataframe_repr_params() # type: ignore - ) - if not self._has_index: - to_string_kwargs.update({"index": False}) - - # We add our own dimensions string, so don't want pandas to. - to_string_kwargs.update({"show_dimensions": False}) - repr_string = pandas_df.to_string(**to_string_kwargs) - - lines = repr_string.split("\n") - - if total_rows is not None and total_rows > len(pandas_df): - lines.append("...") - - lines.append("") - column_count = len(self.columns) - lines.append(f"[{total_rows or '?'} rows x {column_count} columns]") - return "\n".join(lines) - def _repr_mimebundle_(self, include=None, exclude=None): """ Custom display method for IPython/Jupyter environments. @@ -920,98 +831,9 @@ def _repr_mimebundle_(self, include=None, exclude=None): """ # TODO(b/467647693): Anywidget integration has been tested in Jupyter, VS Code, and # BQ Studio, but there is a known compatibility issue with Marimo that needs to be addressed. - opts = bigframes.options.display - # Only handle widget display in anywidget mode - if opts.repr_mode == "anywidget": - try: - return self._get_anywidget_bundle(include=include, exclude=exclude) - - except ImportError: - # Anywidget is an optional dependency, so warn rather than fail. - # TODO(shuowei): When Anywidget becomes the default for all repr modes, - # remove this warning. - warnings.warn( - "Anywidget mode is not available. " - "Please `pip install anywidget traitlets` or `pip install 'bigframes[anywidget]'` to use interactive tables. " - f"Falling back to static HTML. Error: {traceback.format_exc()}" - ) - - # In non-anywidget mode, fetch data once and use it for both HTML - # and plain text representations to avoid multiple queries. - opts = bigframes.options.display - max_results = opts.max_rows - - df, blob_cols = self._get_display_df_and_blob_cols() - - pandas_df, row_count, query_job = df._block.retrieve_repr_request_results( - max_results - ) - self._set_internal_query_job(query_job) - column_count = len(pandas_df.columns) - - html_string = self._create_html_representation( - pandas_df, row_count, column_count, blob_cols - ) - - text_representation = self._create_text_representation(pandas_df, row_count) - - return {"text/html": html_string, "text/plain": text_representation} - - def _create_html_representation( - self, - pandas_df: pandas.DataFrame, - row_count: int, - column_count: int, - blob_cols: list[str], - ) -> str: - """Create an HTML representation of the DataFrame.""" - opts = bigframes.options.display - with display_options.pandas_repr(opts): - # TODO(shuowei, b/464053870): Escaping HTML would be useful, but - # `escape=False` is needed to show images. We may need to implement - # a full-fledged repr module to better support types not in pandas. - if bigframes.options.display.blob_display and blob_cols: - - def obj_ref_rt_to_html(obj_ref_rt) -> str: - obj_ref_rt_json = json.loads(obj_ref_rt) - obj_ref_details = obj_ref_rt_json["objectref"]["details"] - if "gcs_metadata" in obj_ref_details: - gcs_metadata = obj_ref_details["gcs_metadata"] - content_type = typing.cast( - str, gcs_metadata.get("content_type", "") - ) - if content_type.startswith("image"): - size_str = "" - if bigframes.options.display.blob_display_width: - size_str = f' width="{bigframes.options.display.blob_display_width}"' - if bigframes.options.display.blob_display_height: - size_str = ( - size_str - + f' height="{bigframes.options.display.blob_display_height}"' - ) - url = obj_ref_rt_json["access_urls"]["read_url"] - return f'' - - return f'uri: {obj_ref_rt_json["objectref"]["uri"]}, authorizer: {obj_ref_rt_json["objectref"]["authorizer"]}' - - formatters = {blob_col: obj_ref_rt_to_html for blob_col in blob_cols} - - # set max_colwidth so not to truncate the image url - with pandas.option_context("display.max_colwidth", None): - html_string = pandas_df.to_html( - escape=False, - notebook=True, - max_rows=pandas.get_option("display.max_rows"), - max_cols=pandas.get_option("display.max_columns"), - show_dimensions=pandas.get_option("display.show_dimensions"), - formatters=formatters, # type: ignore - ) - else: - # _repr_html_ stub is missing so mypy thinks it's a Series. Ignore mypy. - html_string = pandas_df._repr_html_() # type:ignore + from bigframes.display import html - html_string += f"[{row_count} rows x {column_count} columns in total]" - return html_string + return html.repr_mimebundle(self, include=include, exclude=exclude) def __delitem__(self, key: str): df = self.drop(columns=[key]) diff --git a/bigframes/display/html.py b/bigframes/display/html.py index 101bd296f1..3f1667eb9c 100644 --- a/bigframes/display/html.py +++ b/bigframes/display/html.py @@ -17,12 +17,23 @@ from __future__ import annotations import html -from typing import Any +import json +import traceback +import typing +from typing import Any, Union +import warnings import pandas as pd import pandas.api.types -from bigframes._config import options +import bigframes +from bigframes._config import display_options, options +from bigframes.display import plaintext +import bigframes.formatting_helpers as formatter + +if typing.TYPE_CHECKING: + import bigframes.dataframe + import bigframes.series def _is_dtype_numeric(dtype: Any) -> bool: @@ -91,3 +102,214 @@ def render_html( table_html.append("") return "\n".join(table_html) + + +def _obj_ref_rt_to_html(obj_ref_rt: str) -> str: + obj_ref_rt_json = json.loads(obj_ref_rt) + obj_ref_details = obj_ref_rt_json["objectref"]["details"] + if "gcs_metadata" in obj_ref_details: + gcs_metadata = obj_ref_details["gcs_metadata"] + content_type = typing.cast(str, gcs_metadata.get("content_type", "")) + if content_type.startswith("image"): + size_str = "" + if options.display.blob_display_width: + size_str = f' width="{options.display.blob_display_width}"' + if options.display.blob_display_height: + size_str = size_str + f' height="{options.display.blob_display_height}"' + url = obj_ref_rt_json["access_urls"]["read_url"] + return f'' + + return f'uri: {obj_ref_rt_json["objectref"]["uri"]}, authorizer: {obj_ref_rt_json["objectref"]["authorizer"]}' + + +def create_html_representation( + obj: Union[bigframes.dataframe.DataFrame, bigframes.series.Series], + pandas_df: pd.DataFrame, + total_rows: int, + total_columns: int, + blob_cols: list[str], +) -> str: + """Create an HTML representation of the DataFrame or Series.""" + from bigframes.series import Series + + opts = options.display + with display_options.pandas_repr(opts): + if isinstance(obj, Series): + # Some pandas objects may not have a _repr_html_ method, or it might + # fail in certain environments. We fall back to a pre-formatted + # string representation to ensure something is always displayed. + pd_series = pandas_df.iloc[:, 0] + try: + # TODO(b/464053870): Support rich display for blob Series. + html_string = pd_series._repr_html_() + except AttributeError: + html_string = f"
{pd_series.to_string()}
" + + is_truncated = total_rows is not None and total_rows > len(pandas_df) + if is_truncated: + html_string += f"

[{total_rows} rows]

" + return html_string + else: + # It's a DataFrame + # TODO(shuowei, b/464053870): Escaping HTML would be useful, but + # `escape=False` is needed to show images. We may need to implement + # a full-fledged repr module to better support types not in pandas. + if options.display.blob_display and blob_cols: + formatters = {blob_col: _obj_ref_rt_to_html for blob_col in blob_cols} + + # set max_colwidth so not to truncate the image url + with pandas.option_context("display.max_colwidth", None): + html_string = pandas_df.to_html( + escape=False, + notebook=True, + max_rows=pandas.get_option("display.max_rows"), + max_cols=pandas.get_option("display.max_columns"), + show_dimensions=pandas.get_option("display.show_dimensions"), + formatters=formatters, # type: ignore + ) + else: + # _repr_html_ stub is missing so mypy thinks it's a Series. Ignore mypy. + html_string = pandas_df._repr_html_() # type:ignore + + html_string += f"[{total_rows} rows x {total_columns} columns in total]" + return html_string + + +def _get_obj_metadata( + obj: Union[bigframes.dataframe.DataFrame, bigframes.series.Series], +) -> tuple[bool, bool]: + from bigframes.series import Series + + is_series = isinstance(obj, Series) + if is_series: + has_index = len(obj._block.index_columns) > 0 + else: + has_index = obj._has_index + return is_series, has_index + + +def get_anywidget_bundle( + obj: Union[bigframes.dataframe.DataFrame, bigframes.series.Series], + include=None, + exclude=None, +) -> tuple[dict[str, Any], dict[str, Any]]: + """ + Helper method to create and return the anywidget mimebundle. + This function encapsulates the logic for anywidget display. + """ + from bigframes import display + from bigframes.series import Series + + if isinstance(obj, Series): + df = obj.to_frame() + else: + df, blob_cols = obj._get_display_df_and_blob_cols() + + widget = display.TableWidget(df) + widget_repr_result = widget._repr_mimebundle_(include=include, exclude=exclude) + + if isinstance(widget_repr_result, tuple): + widget_repr, widget_metadata = widget_repr_result + else: + widget_repr = widget_repr_result + widget_metadata = {} + + widget_repr = dict(widget_repr) + + # Use cached data from widget to render HTML and plain text versions. + cached_pd = widget._cached_data + total_rows = widget.row_count + total_columns = len(df.columns) + + widget_repr["text/html"] = create_html_representation( + obj, + cached_pd, + total_rows, + total_columns, + blob_cols if "blob_cols" in locals() else [], + ) + is_series, has_index = _get_obj_metadata(obj) + widget_repr["text/plain"] = plaintext.create_text_representation( + cached_pd, + total_rows, + is_series=is_series, + has_index=has_index, + column_count=len(df.columns) if not is_series else 0, + ) + + return widget_repr, widget_metadata + + +def repr_mimebundle_deferred( + obj: Union[bigframes.dataframe.DataFrame, bigframes.series.Series], +) -> dict[str, str]: + return { + "text/plain": formatter.repr_query_job(obj._compute_dry_run()), + "text/html": formatter.repr_query_job_html(obj._compute_dry_run()), + } + + +def repr_mimebundle_head( + obj: Union[bigframes.dataframe.DataFrame, bigframes.series.Series], +) -> dict[str, str]: + from bigframes.series import Series + + opts = options.display + blob_cols: list[str] + if isinstance(obj, Series): + pandas_df, row_count, query_job = obj._block.retrieve_repr_request_results( + opts.max_rows + ) + blob_cols = [] + else: + df, blob_cols = obj._get_display_df_and_blob_cols() + pandas_df, row_count, query_job = df._block.retrieve_repr_request_results( + opts.max_rows + ) + + obj._set_internal_query_job(query_job) + column_count = len(pandas_df.columns) + + html_string = create_html_representation( + obj, pandas_df, row_count, column_count, blob_cols + ) + + is_series, has_index = _get_obj_metadata(obj) + text_representation = plaintext.create_text_representation( + pandas_df, + row_count, + is_series=is_series, + has_index=has_index, + column_count=len(pandas_df.columns) if not is_series else 0, + ) + + return {"text/html": html_string, "text/plain": text_representation} + + +def repr_mimebundle( + obj: Union[bigframes.dataframe.DataFrame, bigframes.series.Series], + include=None, + exclude=None, +): + """Custom display method for IPython/Jupyter environments.""" + # TODO(b/467647693): Anywidget integration has been tested in Jupyter, VS Code, and + # BQ Studio, but there is a known compatibility issue with Marimo that needs to be addressed. + + opts = options.display + if opts.repr_mode == "deferred": + return repr_mimebundle_deferred(obj) + + if opts.repr_mode == "anywidget": + try: + return get_anywidget_bundle(obj, include=include, exclude=exclude) + except ImportError: + # Anywidget is an optional dependency, so warn rather than fail. + # TODO(shuowei): When Anywidget becomes the default for all repr modes, + # remove this warning. + warnings.warn( + "Anywidget mode is not available. " + "Please `pip install anywidget traitlets` or `pip install 'bigframes[anywidget]'` to use interactive tables. " + f"Falling back to static HTML. Error: {traceback.format_exc()}" + ) + + return repr_mimebundle_head(obj) diff --git a/bigframes/display/plaintext.py b/bigframes/display/plaintext.py new file mode 100644 index 0000000000..2f7bc1df07 --- /dev/null +++ b/bigframes/display/plaintext.py @@ -0,0 +1,102 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Plaintext display representations.""" + +from __future__ import annotations + +import typing + +import pandas +import pandas.io.formats + +from bigframes._config import display_options, options + +if typing.TYPE_CHECKING: + import pandas as pd + + +def create_text_representation( + pandas_df: pd.DataFrame, + total_rows: typing.Optional[int], + is_series: bool, + has_index: bool = True, + column_count: int = 0, +) -> str: + """Create a text representation of the DataFrame or Series. + + Args: + pandas_df: + The pandas DataFrame containing the data to represent. + total_rows: + The total number of rows in the original BigFrames object. + is_series: + Whether the object being represented is a Series. + has_index: + Whether the object has an index to display. + column_count: + The total number of columns in the original BigFrames object. + Only used for DataFrames. + + Returns: + A plaintext string representation. + """ + opts = options.display + + if is_series: + with display_options.pandas_repr(opts): + pd_series = pandas_df.iloc[:, 0] + if not has_index: + repr_string = pd_series.to_string( + length=False, index=False, name=True, dtype=True + ) + else: + repr_string = pd_series.to_string(length=False, name=True, dtype=True) + + lines = repr_string.split("\n") + is_truncated = total_rows is not None and total_rows > len(pandas_df) + + if is_truncated: + lines.append("...") + lines.append("") # Add empty line for spacing only if truncated + lines.append(f"[{total_rows} rows]") + + return "\n".join(lines) + + else: + # DataFrame + with display_options.pandas_repr(opts): + # safe to mutate this, this dict is owned by this code, and does not affect global config + to_string_kwargs = ( + pandas.io.formats.format.get_dataframe_repr_params() # type: ignore + ) + if not has_index: + to_string_kwargs.update({"index": False}) + + # We add our own dimensions string, so don't want pandas to. + to_string_kwargs.update({"show_dimensions": False}) + repr_string = pandas_df.to_string(**to_string_kwargs) + + lines = repr_string.split("\n") + is_truncated = total_rows is not None and total_rows > len(pandas_df) + + if is_truncated: + lines.append("...") + lines.append("") # Add empty line for spacing only if truncated + lines.append(f"[{total_rows or '?'} rows x {column_count} columns]") + else: + # For non-truncated DataFrames, we still need to add dimensions if show_dimensions was False + lines.append("") + lines.append(f"[{total_rows or '?'} rows x {column_count} columns]") + return "\n".join(lines) diff --git a/bigframes/formatting_helpers.py b/bigframes/formatting_helpers.py index 55731069a3..3c37a3470d 100644 --- a/bigframes/formatting_helpers.py +++ b/bigframes/formatting_helpers.py @@ -68,7 +68,7 @@ def repr_query_job(query_job: Optional[bigquery.QueryJob]): query_job: The job representing the execution of the query on the server. Returns: - Pywidget html table. + Formatted string. """ if query_job is None: return "No job information available" @@ -94,6 +94,46 @@ def repr_query_job(query_job: Optional[bigquery.QueryJob]): return res +def repr_query_job_html(query_job: Optional[bigquery.QueryJob]): + """Return query job as a formatted html string. + Args: + query_job: + The job representing the execution of the query on the server. + Returns: + Html string. + """ + if query_job is None: + return "No job information available" + if query_job.dry_run: + return f"Computation deferred. Computation will process {get_formatted_bytes(query_job.total_bytes_processed)}" + + # We can reuse the plaintext repr for now or make a nicer table. + # For deferred mode consistency, let's just wrap the text in a pre block or similar, + # but the request implies we want a distinct HTML representation if possible. + # However, existing repr_query_job returns a simple string. + # Let's format it as a simple table or list. + + res = "

Query Job Info

    " + for key, value in query_job_prop_pairs.items(): + job_val = getattr(query_job, value) + if job_val is not None: + if key == "Job Id": # add link to job + url = get_job_url( + project_id=query_job.project, + location=query_job.location, + job_id=query_job.job_id, + ) + res += f'
  • Job: {query_job.job_id}
  • ' + elif key == "Slot Time": + res += f"
  • {key}: {get_formatted_time(job_val)}
  • " + elif key == "Bytes Processed": + res += f"
  • {key}: {get_formatted_bytes(job_val)}
  • " + else: + res += f"
  • {key}: {job_val}
  • " + res += "
" + return res + + current_display: Optional[display.HTML] = None current_display_id: Optional[str] = None previous_display_html: str = "" @@ -296,7 +336,7 @@ def get_job_url( """ if project_id is None or location is None or job_id is None: return None - return f"""https://console.cloud.google.com/bigquery?project={project_id}&j=bq:{location}:{job_id}&page=queryresults""" + return f"""https://console.cloud. google.com/bigquery?project={project_id}&j=bq:{location}:{job_id}&page=queryresults""" def render_bqquery_sent_event_html( @@ -508,7 +548,7 @@ def get_base_job_loading_html(job: GenericJob): Returns: Html string. """ - return f"""{job.job_type.capitalize()} job {job.job_id} is {job.state}. str: # Protect against errors with uninitialized Series. See: # https://github.com/googleapis/python-bigquery-dataframes/issues/728 @@ -579,27 +590,22 @@ def __repr__(self) -> str: # TODO(swast): Avoid downloading the whole series by using job # metadata, like we do with DataFrame. opts = bigframes.options.display - max_results = opts.max_rows - # anywdiget mode uses the same display logic as the "deferred" mode - # for faster execution - if opts.repr_mode in ("deferred", "anywidget"): + if opts.repr_mode == "deferred": return formatter.repr_query_job(self._compute_dry_run()) self._cached() - pandas_df, _, query_job = self._block.retrieve_repr_request_results(max_results) + pandas_df, row_count, query_job = self._block.retrieve_repr_request_results( + opts.max_rows + ) self._set_internal_query_job(query_job) + from bigframes.display import plaintext - pd_series = pandas_df.iloc[:, 0] - - import pandas.io.formats - - # safe to mutate this, this dict is owned by this code, and does not affect global config - to_string_kwargs = pandas.io.formats.format.get_series_repr_params() # type: ignore - if len(self._block.index_columns) == 0: - to_string_kwargs.update({"index": False}) - repr_string = pd_series.to_string(**to_string_kwargs) - - return repr_string + return plaintext.create_text_representation( + pandas_df, + row_count, + is_series=True, + has_index=len(self._block.index_columns) > 0, + ) def astype( self, diff --git a/notebooks/dataframes/anywidget_mode.ipynb b/notebooks/dataframes/anywidget_mode.ipynb index 0ce286ce64..facefc6069 100644 --- a/notebooks/dataframes/anywidget_mode.ipynb +++ b/notebooks/dataframes/anywidget_mode.ipynb @@ -45,10 +45,13 @@ "id": "04406a4d", "metadata": {}, "source": [ - "This notebook demonstrates the anywidget display mode, which provides an interactive table experience.\n", - "Key features include:\n", - "- **Column Sorting:** Click on column headers to sort data in ascending, descending, or unsorted states.\n", - "- **Adjustable Column Widths:** Drag the dividers between column headers to resize columns." + "This notebook demonstrates the **anywidget** display mode for BigQuery DataFrames. This mode provides an interactive table experience for exploring your data directly within the notebook.\n", + "\n", + "**Key features:**\n", + "- **Rich DataFrames & Series:** Both DataFrames and Series are displayed as interactive widgets.\n", + "- **Pagination:** Navigate through large datasets page by page without overwhelming the output.\n", + "- **Column Sorting:** Click column headers to toggle between ascending, descending, and unsorted views.\n", + "- **Column Resizing:** Drag the dividers between column headers to adjust their width." ] }, { @@ -70,6 +73,15 @@ "Load Sample Data" ] }, + { + "cell_type": "markdown", + "id": "interactive-df-header", + "metadata": {}, + "source": [ + "## 1. Interactive DataFrame Display\n", + "Loading a dataset from BigQuery automatically renders the interactive widget." + ] + }, { "cell_type": "code", "execution_count": 4, @@ -106,17 +118,17 @@ "name": "stdout", "output_type": "stream", "text": [ - "state gender year name number\n", - " AL F 1910 Lillian 99\n", - " AL F 1910 Ruby 204\n", - " AL F 1910 Helen 76\n", - " AL F 1910 Eunice 41\n", - " AR F 1910 Dora 42\n", - " CA F 1910 Edna 62\n", - " CA F 1910 Helen 239\n", - " CO F 1910 Alice 46\n", - " FL F 1910 Willie 71\n", - " FL F 1910 Thelma 65\n", + "state gender year name number\n", + " AL F 1910 Vera 71\n", + " AR F 1910 Viola 37\n", + " AR F 1910 Alice 57\n", + " AR F 1910 Edna 95\n", + " AR F 1910 Ollie 40\n", + " CA F 1910 Beatrice 37\n", + " CT F 1910 Marion 36\n", + " CT F 1910 Marie 36\n", + " FL F 1910 Alice 53\n", + " GA F 1910 Thelma 133\n", "...\n", "\n", "[5552452 rows x 5 columns]\n" @@ -128,45 +140,10 @@ "print(df)" ] }, - { - "cell_type": "markdown", - "id": "3a73e472", - "metadata": {}, - "source": [ - "Display Series in anywidget mode" - ] - }, { "cell_type": "code", "execution_count": 5, - "id": "42bb02ab", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Computation deferred. Computation will process 44.4 MB\n" - ] - } - ], - "source": [ - "test_series = df[\"year\"]\n", - "print(test_series)" - ] - }, - { - "cell_type": "markdown", - "id": "7bcf1bb7", - "metadata": {}, - "source": [ - "Display with Pagination" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "ce250157", + "id": "220340b0", "metadata": {}, "outputs": [ { @@ -196,7 +173,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "775e84ca212c4867bb889266b830ae68", + "model_id": "424cfa14088641518224b137b5444d58", "version_major": 2, "version_minor": 1 }, @@ -232,80 +209,80 @@ " AL\n", " F\n", " 1910\n", - " Cora\n", - " 61\n", + " Vera\n", + " 71\n", " \n", " \n", " 1\n", - " AL\n", + " AR\n", " F\n", " 1910\n", - " Anna\n", - " 74\n", + " Viola\n", + " 37\n", " \n", " \n", " 2\n", " AR\n", " F\n", " 1910\n", - " Willie\n", - " 132\n", + " Alice\n", + " 57\n", " \n", " \n", " 3\n", - " CO\n", + " AR\n", " F\n", " 1910\n", - " Anna\n", - " 42\n", + " Edna\n", + " 95\n", " \n", " \n", " 4\n", - " FL\n", + " AR\n", " F\n", " 1910\n", - " Louise\n", - " 70\n", + " Ollie\n", + " 40\n", " \n", " \n", " 5\n", - " GA\n", + " CA\n", " F\n", " 1910\n", - " Catherine\n", - " 57\n", + " Beatrice\n", + " 37\n", " \n", " \n", " 6\n", - " IL\n", + " CT\n", " F\n", " 1910\n", - " Jessie\n", - " 43\n", + " Marion\n", + " 36\n", " \n", " \n", " 7\n", - " IN\n", + " CT\n", " F\n", " 1910\n", - " Anna\n", - " 100\n", + " Marie\n", + " 36\n", " \n", " \n", " 8\n", - " IN\n", + " FL\n", " F\n", " 1910\n", - " Pauline\n", - " 77\n", + " Alice\n", + " 53\n", " \n", " \n", " 9\n", - " IN\n", + " GA\n", " F\n", " 1910\n", - " Beulah\n", - " 39\n", + " Thelma\n", + " 133\n", " \n", " \n", "\n", @@ -313,23 +290,23 @@ "[5552452 rows x 5 columns in total]" ], "text/plain": [ - "state gender year name number\n", - " AL F 1910 Cora 61\n", - " AL F 1910 Anna 74\n", - " AR F 1910 Willie 132\n", - " CO F 1910 Anna 42\n", - " FL F 1910 Louise 70\n", - " GA F 1910 Catherine 57\n", - " IL F 1910 Jessie 43\n", - " IN F 1910 Anna 100\n", - " IN F 1910 Pauline 77\n", - " IN F 1910 Beulah 39\n", + "state gender year name number\n", + " AL F 1910 Vera 71\n", + " AR F 1910 Viola 37\n", + " AR F 1910 Alice 57\n", + " AR F 1910 Edna 95\n", + " AR F 1910 Ollie 40\n", + " CA F 1910 Beatrice 37\n", + " CT F 1910 Marion 36\n", + " CT F 1910 Marie 36\n", + " FL F 1910 Alice 53\n", + " GA F 1910 Thelma 133\n", "...\n", "\n", "[5552452 rows x 5 columns]" ] }, - "execution_count": 6, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } @@ -338,6 +315,175 @@ "df" ] }, + { + "cell_type": "markdown", + "id": "3a73e472", + "metadata": {}, + "source": [ + "## 2. Interactive Series Display\n", + "BigQuery DataFrames `Series` objects now also support the full interactive widget experience, including pagination and formatting." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "42bb02ab", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "3904868f71114a0c95c8c133a6c29d0b", + "version_major": 2, + "version_minor": 1 + }, + "text/html": [ + "
0    1910\n",
+       "1    1910\n",
+       "2    1910\n",
+       "3    1910\n",
+       "4    1910\n",
+       "5    1910\n",
+       "6    1910\n",
+       "7    1910\n",
+       "8    1910\n",
+       "9    1910
[5552452 rows]" + ], + "text/plain": [ + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "Name: year, dtype: Int64\n", + "...\n", + "\n", + "[5552452 rows]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "test_series = df[\"year\"]\n", + "# Displaying the series triggers the interactive widget\n", + "test_series" + ] + }, + { + "cell_type": "markdown", + "id": "7bcf1bb7", + "metadata": {}, + "source": [ + "Display with Pagination" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "da23e0f3", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "✅ Completed. " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "0fd0bd56db2348a68d5755a045652001", + "version_major": 2, + "version_minor": 1 + }, + "text/html": [ + "
0    1910\n",
+       "1    1910\n",
+       "2    1910\n",
+       "3    1910\n",
+       "4    1910\n",
+       "5    1910\n",
+       "6    1910\n",
+       "7    1910\n",
+       "8    1910\n",
+       "9    1910
[5552452 rows]" + ], + "text/plain": [ + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "1910\n", + "Name: year, dtype: Int64\n", + "...\n", + "\n", + "[5552452 rows]" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "test_series" + ] + }, { "cell_type": "markdown", "id": "sorting-intro", @@ -369,9 +515,18 @@ "Programmatic Navigation Demo" ] }, + { + "cell_type": "markdown", + "id": "programmatic-header", + "metadata": {}, + "source": [ + "## 3. Programmatic Widget Control\n", + "You can also instantiate the `TableWidget` directly for more control, such as checking page counts or driving navigation programmatically." + ] + }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 8, "id": "6920d49b", "metadata": {}, "outputs": [ @@ -409,15 +564,15 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "bf4224f8022042aea6d72507ddb5570b", + "model_id": "13b063f7ea74473eb18de270c48c6417", "version_major": 2, "version_minor": 1 }, "text/plain": [ - "" + "" ] }, - "execution_count": 7, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -444,7 +599,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 9, "id": "12b68f15", "metadata": {}, "outputs": [ @@ -476,12 +631,13 @@ "id": "9d310138", "metadata": {}, "source": [ - "Edge Case Demonstration" + "## 4. Edge Cases\n", + "The widget handles small datasets gracefully, disabling unnecessary pagination controls." ] }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 10, "id": "a9d5d13a", "metadata": {}, "outputs": [ @@ -523,15 +679,15 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "8d9bfeeba3ca4d11a56dccb28aacde23", + "model_id": "0918149d2d734296afb3243f283eb2d3", "version_major": 2, "version_minor": 1 }, "text/plain": [ - "" + "" ] }, - "execution_count": 9, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } @@ -553,9 +709,18 @@ "The `AI.GENERATE` function in BigQuery returns results in a JSON column. While BigQuery's JSON type is not natively supported by the underlying Arrow `to_pandas_batches()` method used in anywidget mode ([Apache Arrow issue #45262](https://github.com/apache/arrow/issues/45262)), BigQuery Dataframes automatically converts JSON columns to strings for display. This allows you to view the results of generative AI functions seamlessly." ] }, + { + "cell_type": "markdown", + "id": "ai-header", + "metadata": {}, + "source": [ + "## 5. Advanced Data Types (JSON/Structs)\n", + "The `AI.GENERATE` function in BigQuery returns results in a JSON column. BigQuery Dataframes automatically handles complex types like JSON strings for display, allowing you to view generative AI results seamlessly." + ] + }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 11, "id": "added-cell-1", "metadata": {}, "outputs": [ @@ -563,7 +728,7 @@ "data": { "text/html": [ "✅ Completed. \n", - " Query processed 85.9 kB in 13 seconds of slot time.\n", + " Query processed 85.9 kB in 24 seconds of slot time.\n", " " ], "text/plain": [ @@ -624,7 +789,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "9fce25a077604e4882144d46d0d4ba45", + "model_id": "9543a0ef6eb744f480e49d4876c31b84", "version_major": 2, "version_minor": 1 }, @@ -690,24 +855,6 @@ " EU\n", " DE\n", " 03.10.2018\n", - " H05B 6/12\n", - " <NA>\n", - " 18165514.3\n", - " 03.04.2018\n", - " 30.03.2017\n", - " <NA>\n", - " BSH Hausger√§te GmbH\n", - " Acero Acero, Jesus\n", - " VORRICHTUNG ZUR INDUKTIVEN ENERGIE√úBERTRAGUNG\n", - " EP 3 383 141 A2\n", - " \n", - " \n", - " 2\n", - " {'application_number': None, 'class_internatio...\n", - " gs://gcs-public-data--labeled-patents/espacene...\n", - " EU\n", - " DE\n", - " 03.10.2018\n", " H01L 21/20\n", " <NA>\n", " 18166536.5\n", @@ -720,7 +867,7 @@ " EP 3 382 744 A1\n", " \n", " \n", - " 3\n", + " 2\n", " {'application_number': None, 'class_internatio...\n", " gs://gcs-public-data--labeled-patents/espacene...\n", " EU\n", @@ -738,7 +885,7 @@ " EP 3 382 553 A1\n", " \n", " \n", - " 4\n", + " 3\n", " {'application_number': None, 'class_internatio...\n", " gs://gcs-public-data--labeled-patents/espacene...\n", " EU\n", @@ -755,6 +902,24 @@ " MASTH√ÑHNCHENCONTAINER ALS BESTANDTEIL EINER E...\n", " EP 3 381 276 A1\n", " \n", + " \n", + " 4\n", + " {'application_number': None, 'class_internatio...\n", + " gs://gcs-public-data--labeled-patents/espacene...\n", + " EU\n", + " DE\n", + " 03.10.2018\n", + " H05B 6/12\n", + " <NA>\n", + " 18165514.3\n", + " 03.04.2018\n", + " 30.03.2017\n", + " <NA>\n", + " BSH Hausger√§te GmbH\n", + " Acero Acero, Jesus\n", + " VORRICHTUNG ZUR INDUKTIVEN ENERGIE√úBERTRAGUNG\n", + " EP 3 383 141 A2\n", + " \n", " \n", "\n", "

5 rows × 15 columns

\n", @@ -777,36 +942,36 @@ "\n", " publication_date class_international class_us application_number \\\n", "0 29.08.018 E04H 6/12 18157874.1 \n", - "1 03.10.2018 H05B 6/12 18165514.3 \n", - "2 03.10.2018 H01L 21/20 18166536.5 \n", - "3 03.10.2018 G06F 11/30 18157347.8 \n", - "4 03.10.2018 A01K 31/00 18171005.4 \n", + "1 03.10.2018 H01L 21/20 18166536.5 \n", + "2 03.10.2018 G06F 11/30 18157347.8 \n", + "3 03.10.2018 A01K 31/00 18171005.4 \n", + "4 03.10.2018 H05B 6/12 18165514.3 \n", "\n", " filing_date priority_date_eu representative_line_1_eu \\\n", "0 21.02.2018 22.02.2017 Liedtke & Partner Patentanw√§lte \n", - "1 03.04.2018 30.03.2017 \n", - "2 16.02.2016 Scheider, Sascha et al \n", - "3 19.02.2018 31.03.2017 Hoffmann Eitle \n", - "4 05.02.2015 05.02.2014 Stork Bamberger Patentanw√§lte \n", + "1 16.02.2016 Scheider, Sascha et al \n", + "2 19.02.2018 31.03.2017 Hoffmann Eitle \n", + "3 05.02.2015 05.02.2014 Stork Bamberger Patentanw√§lte \n", + "4 03.04.2018 30.03.2017 \n", "\n", " applicant_line_1 inventor_line_1 \\\n", "0 SHB Hebezeugbau GmbH VOLGER, Alexander \n", - "1 BSH Hausger√§te GmbH Acero Acero, Jesus \n", - "2 EV Group E. Thallner GmbH Kurz, Florian \n", - "3 FUJITSU LIMITED Kukihara, Kensuke \n", - "4 Linco Food Systems A/S Thrane, Uffe \n", + "1 EV Group E. Thallner GmbH Kurz, Florian \n", + "2 FUJITSU LIMITED Kukihara, Kensuke \n", + "3 Linco Food Systems A/S Thrane, Uffe \n", + "4 BSH Hausger√§te GmbH Acero Acero, Jesus \n", "\n", " title_line_1 number \n", "0 STEUERUNGSSYSTEM F√úR AUTOMATISCHE PARKH√ÑUSER EP 3 366 869 A1 \n", - "1 VORRICHTUNG ZUR INDUKTIVEN ENERGIE√úBERTRAGUNG EP 3 383 141 A2 \n", - "2 VORRICHTUNG ZUM BONDEN VON SUBSTRATEN EP 3 382 744 A1 \n", - "3 METHOD EXECUTED BY A COMPUTER, INFORMATION PRO... EP 3 382 553 A1 \n", - "4 MASTH√ÑHNCHENCONTAINER ALS BESTANDTEIL EINER E... EP 3 381 276 A1 \n", + "1 VORRICHTUNG ZUM BONDEN VON SUBSTRATEN EP 3 382 744 A1 \n", + "2 METHOD EXECUTED BY A COMPUTER, INFORMATION PRO... EP 3 382 553 A1 \n", + "3 MASTH√ÑHNCHENCONTAINER ALS BESTANDTEIL EINER E... EP 3 381 276 A1 \n", + "4 VORRICHTUNG ZUR INDUKTIVEN ENERGIE√úBERTRAGUNG EP 3 383 141 A2 \n", "\n", "[5 rows x 15 columns]" ] }, - "execution_count": 10, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..064bdaf362 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "python-bigquery-dataframes", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/tests/js/package-lock.json b/tests/js/package-lock.json index 8a562a11ea..5526e0581e 100644 --- a/tests/js/package-lock.json +++ b/tests/js/package-lock.json @@ -10,11 +10,19 @@ "license": "ISC", "devDependencies": { "@babel/preset-env": "^7.24.7", + "@testing-library/jest-dom": "^6.4.6", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jsdom": "^24.1.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -2453,6 +2461,26 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -2706,6 +2734,16 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3306,6 +3344,13 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssom": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", @@ -3428,6 +3473,13 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -4020,6 +4072,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5321,6 +5383,16 @@ "node": ">=6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5655,6 +5727,20 @@ "dev": true, "license": "MIT" }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -5972,6 +6058,19 @@ "node": ">=6" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", diff --git a/tests/js/package.json b/tests/js/package.json index 8de4b4747c..d34c5a065a 100644 --- a/tests/js/package.json +++ b/tests/js/package.json @@ -14,6 +14,7 @@ "@babel/preset-env": "^7.24.7", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "@testing-library/jest-dom": "^6.4.6", "jsdom": "^24.1.0" } } diff --git a/tests/js/table_widget.test.js b/tests/js/table_widget.test.js index 77ec7bcdd5..6b5dda48d1 100644 --- a/tests/js/table_widget.test.js +++ b/tests/js/table_widget.test.js @@ -206,4 +206,57 @@ describe("TableWidget", () => { expect(indicator2.textContent).toBe("●"); }); }); + + it("should render the series as a table with an index and one value column", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return ` +
+
+ + + + + + + + + + + + + + + + + +
value
0a
1b
+
+
`; + } + if (property === "orderable_columns") { + return []; + } + return null; + }); + + render({ model, el }); + + // Manually trigger the table_html change handler + const tableHtmlChangeHandler = model.on.mock.calls.find( + (call) => call[0] === "change:table_html", + )[1]; + tableHtmlChangeHandler(); + + // Check that the table has two columns + const headers = el.querySelectorAll( + ".paginated-table-container .col-header-name", + ); + expect(headers).toHaveLength(2); + + // Check that the headers are an empty string (for the index) and "value" + expect(headers[0].textContent).toBe(""); + expect(headers[1].textContent).toBe("value"); + }); }); diff --git a/tests/system/small/test_anywidget.py b/tests/system/small/test_anywidget.py index dca8b9e1bb..8d4fcc8e89 100644 --- a/tests/system/small/test_anywidget.py +++ b/tests/system/small/test_anywidget.py @@ -201,6 +201,7 @@ def _assert_html_matches_pandas_slice( def test_widget_initialization_should_calculate_total_row_count( paginated_bf_df: bf.dataframe.DataFrame, ): + """Test that a TableWidget calculates the total row count on creation.""" """A TableWidget should correctly calculate the total row count on creation.""" from bigframes.display import TableWidget @@ -313,9 +314,7 @@ def test_widget_pagination_should_work_with_custom_page_size( start_row: int, end_row: int, ): - """ - A widget should paginate correctly with a custom page size of 3. - """ + """Test that a widget paginates correctly with a custom page size.""" with bigframes.option_context( "display.repr_mode", "anywidget", "display.max_rows", 3 ): @@ -956,10 +955,11 @@ def test_repr_in_anywidget_mode_should_not_be_deferred( assert "page_1_row_1" in representation -def test_dataframe_repr_mimebundle_anywidget_with_metadata( +def test_dataframe_repr_mimebundle_should_return_widget_with_metadata_in_anywidget_mode( monkeypatch: pytest.MonkeyPatch, session: bigframes.Session, # Add session as a fixture ): + """Test that _repr_mimebundle_ returns a widget view with metadata when anywidget is available.""" with bigframes.option_context("display.repr_mode", "anywidget"): # Create a real DataFrame object (or a mock that behaves like one minimally) # for _repr_mimebundle_ to operate on. @@ -984,7 +984,7 @@ def test_dataframe_repr_mimebundle_anywidget_with_metadata( # Patch the class method directly with mock.patch( - "bigframes.dataframe.DataFrame._get_anywidget_bundle", + "bigframes.display.html.get_anywidget_bundle", return_value=mock_get_anywidget_bundle_return_value, ): result = test_df._repr_mimebundle_() @@ -1135,3 +1135,41 @@ def test_widget_with_custom_index_matches_pandas_output( # TODO(b/438181139): Add tests for custom multiindex # This may not be necessary for the SQL Cell use case but should be # considered for completeness. + + +def test_series_anywidget_integration_with_notebook_display( + paginated_bf_df: bf.dataframe.DataFrame, +): + """Test Series display integration in Jupyter-like environment.""" + pytest.importorskip("anywidget") + + with bf.option_context("display.repr_mode", "anywidget"): + series = paginated_bf_df["value"] + + # Test the full display pipeline + from IPython.display import display as ipython_display + + # This should work without errors + ipython_display(series) + + +def test_series_different_data_types_anywidget(session: bf.Session): + """Test Series with different data types in anywidget mode.""" + pytest.importorskip("anywidget") + + # Create Series with different types + test_data = pd.DataFrame( + { + "string_col": ["a", "b", "c"], + "int_col": [1, 2, 3], + "float_col": [1.1, 2.2, 3.3], + "bool_col": [True, False, True], + } + ) + bf_df = session.read_pandas(test_data) + + with bf.option_context("display.repr_mode", "anywidget"): + for col_name in test_data.columns: + series = bf_df[col_name] + widget = bigframes.display.TableWidget(series.to_frame()) + assert widget.row_count == 3 From 92661f330e2f97692b1c73425bafce9e988cacfd Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 16:36:06 -0600 Subject: [PATCH 311/313] chore: make api method logging session-scoped (#2347) Updates `log_adapter` to optionally store API method names in a `Session` instance instead of the global list. This improves label accuracy when running tests in parallel. - Updates `Session` to initialize `_api_methods` list and lock. - Updates `log_adapter.add_api_method` and `get_and_reset_api_methods` to handle session-scoped logging. - Updates `log_adapter.method_logger` and `property_logger` to identify the session from arguments. - Propagates `session` through `start_query_with_client` and its callers to ensure labels are correctly associated with the session. --- *PR created automatically by Jules for task [6421369828766099756](https://jules.google.com/task/6421369828766099756) started by @tswast* --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Tim Swena --- bigframes/core/log_adapter.py | 73 ++++++++++++++++++---- bigframes/session/__init__.py | 6 ++ bigframes/session/_io/bigquery/__init__.py | 15 ++++- bigframes/session/bq_caching_executor.py | 5 ++ bigframes/session/direct_gbq_execution.py | 3 + bigframes/session/loader.py | 2 + 6 files changed, 90 insertions(+), 14 deletions(-) diff --git a/bigframes/core/log_adapter.py b/bigframes/core/log_adapter.py index 8179ffbeed..77c09437c0 100644 --- a/bigframes/core/log_adapter.py +++ b/bigframes/core/log_adapter.py @@ -174,7 +174,8 @@ def wrapper(*args, **kwargs): full_method_name = f"{base_name.lower()}-{api_method_name}" # Track directly called methods if len(_call_stack) == 0: - add_api_method(full_method_name) + session = _find_session(*args, **kwargs) + add_api_method(full_method_name, session=session) _call_stack.append(full_method_name) @@ -220,7 +221,8 @@ def wrapped(*args, **kwargs): full_property_name = f"{class_name.lower()}-{property_name.lower()}" if len(_call_stack) == 0: - add_api_method(full_property_name) + session = _find_session(*args, **kwargs) + add_api_method(full_property_name, session=session) _call_stack.append(full_property_name) try: @@ -250,25 +252,41 @@ def wrapper(func): return wrapper -def add_api_method(api_method_name): +def add_api_method(api_method_name, session=None): global _lock global _api_methods - with _lock: - # Push the method to the front of the _api_methods list - _api_methods.insert(0, api_method_name.replace("<", "").replace(">", "")) - # Keep the list length within the maximum limit (adjust MAX_LABELS_COUNT as needed) - _api_methods = _api_methods[:MAX_LABELS_COUNT] + clean_method_name = api_method_name.replace("<", "").replace(">", "") + + if session is not None and _is_session_initialized(session): + with session._api_methods_lock: + session._api_methods.insert(0, clean_method_name) + session._api_methods = session._api_methods[:MAX_LABELS_COUNT] + else: + with _lock: + # Push the method to the front of the _api_methods list + _api_methods.insert(0, clean_method_name) + # Keep the list length within the maximum limit (adjust MAX_LABELS_COUNT as needed) + _api_methods = _api_methods[:MAX_LABELS_COUNT] -def get_and_reset_api_methods(dry_run: bool = False): + +def get_and_reset_api_methods(dry_run: bool = False, session=None): global _lock + methods = [] + + if session is not None and _is_session_initialized(session): + with session._api_methods_lock: + methods.extend(session._api_methods) + if not dry_run: + session._api_methods.clear() + with _lock: - previous_api_methods = list(_api_methods) + methods.extend(_api_methods) # dry_run might not make a job resource, so only reset the log on real queries. if not dry_run: _api_methods.clear() - return previous_api_methods + return methods def _get_bq_client(*args, **kwargs): @@ -283,3 +301,36 @@ def _get_bq_client(*args, **kwargs): return kwargv._block.session.bqclient return None + + +def _is_session_initialized(session): + """Return True if fully initialized. + + Because the method logger could get called before Session.__init__ has a + chance to run, we use the globals in that case. + """ + return hasattr(session, "_api_methods_lock") and hasattr(session, "_api_methods") + + +def _find_session(*args, **kwargs): + # This function cannot import Session at the top level because Session + # imports log_adapter. + from bigframes.session import Session + + session = args[0] if args else None + if ( + session is not None + and isinstance(session, Session) + and _is_session_initialized(session) + ): + return session + + session = kwargs.get("session") + if ( + session is not None + and isinstance(session, Session) + and _is_session_initialized(session) + ): + return session + + return None diff --git a/bigframes/session/__init__.py b/bigframes/session/__init__.py index 3cb9d2bb68..4f32514652 100644 --- a/bigframes/session/__init__.py +++ b/bigframes/session/__init__.py @@ -23,6 +23,7 @@ import logging import os import secrets +import threading import typing from typing import ( Any, @@ -208,6 +209,9 @@ def __init__( self._session_id: str = "session" + secrets.token_hex(3) # store table ids and delete them when the session is closed + self._api_methods: list[str] = [] + self._api_methods_lock = threading.Lock() + self._objects: list[ weakref.ReferenceType[ Union[ @@ -2160,6 +2164,7 @@ def _start_query_ml_ddl( query_with_job=True, job_retry=third_party_gcb_retry.DEFAULT_ML_JOB_RETRY, publisher=self._publisher, + session=self, ) return iterator, query_job @@ -2188,6 +2193,7 @@ def _create_object_table(self, path: str, connection: str) -> str: timeout=None, query_with_job=True, publisher=self._publisher, + session=self, ) return table diff --git a/bigframes/session/_io/bigquery/__init__.py b/bigframes/session/_io/bigquery/__init__.py index aa56dc0040..9114770224 100644 --- a/bigframes/session/_io/bigquery/__init__.py +++ b/bigframes/session/_io/bigquery/__init__.py @@ -126,6 +126,7 @@ def create_temp_table( schema: Optional[Iterable[bigquery.SchemaField]] = None, cluster_columns: Optional[list[str]] = None, kms_key: Optional[str] = None, + session=None, ) -> str: """Create an empty table with an expiration in the desired session. @@ -153,6 +154,7 @@ def create_temp_view( *, expiration: datetime.datetime, sql: str, + session=None, ) -> str: """Create an empty table with an expiration in the desired session. @@ -228,12 +230,14 @@ def format_option(key: str, value: Union[bool, str]) -> str: return f"{key}={repr(value)}" -def add_and_trim_labels(job_config): +def add_and_trim_labels(job_config, session=None): """ Add additional labels to the job configuration and trim the total number of labels to ensure they do not exceed MAX_LABELS_COUNT labels per job. """ - api_methods = log_adapter.get_and_reset_api_methods(dry_run=job_config.dry_run) + api_methods = log_adapter.get_and_reset_api_methods( + dry_run=job_config.dry_run, session=session + ) job_config.labels = create_job_configs_labels( job_configs_labels=job_config.labels, api_methods=api_methods, @@ -270,6 +274,7 @@ def start_query_with_client( metrics: Optional[bigframes.session.metrics.ExecutionMetrics], query_with_job: Literal[True], publisher: bigframes.core.events.Publisher, + session=None, ) -> Tuple[google.cloud.bigquery.table.RowIterator, bigquery.QueryJob]: ... @@ -286,6 +291,7 @@ def start_query_with_client( metrics: Optional[bigframes.session.metrics.ExecutionMetrics], query_with_job: Literal[False], publisher: bigframes.core.events.Publisher, + session=None, ) -> Tuple[google.cloud.bigquery.table.RowIterator, Optional[bigquery.QueryJob]]: ... @@ -303,6 +309,7 @@ def start_query_with_client( query_with_job: Literal[True], job_retry: google.api_core.retry.Retry, publisher: bigframes.core.events.Publisher, + session=None, ) -> Tuple[google.cloud.bigquery.table.RowIterator, bigquery.QueryJob]: ... @@ -320,6 +327,7 @@ def start_query_with_client( query_with_job: Literal[False], job_retry: google.api_core.retry.Retry, publisher: bigframes.core.events.Publisher, + session=None, ) -> Tuple[google.cloud.bigquery.table.RowIterator, Optional[bigquery.QueryJob]]: ... @@ -340,6 +348,7 @@ def start_query_with_client( # version 3.36.0 or later. job_retry: google.api_core.retry.Retry = third_party_gcb_retry.DEFAULT_JOB_RETRY, publisher: bigframes.core.events.Publisher, + session=None, ) -> Tuple[google.cloud.bigquery.table.RowIterator, Optional[bigquery.QueryJob]]: """ Starts query job and waits for results. @@ -347,7 +356,7 @@ def start_query_with_client( # Note: Ensure no additional labels are added to job_config after this # point, as `add_and_trim_labels` ensures the label count does not # exceed MAX_LABELS_COUNT. - add_and_trim_labels(job_config) + add_and_trim_labels(job_config, session=session) try: if not query_with_job: diff --git a/bigframes/session/bq_caching_executor.py b/bigframes/session/bq_caching_executor.py index 736dbf7be1..ca19d1be86 100644 --- a/bigframes/session/bq_caching_executor.py +++ b/bigframes/session/bq_caching_executor.py @@ -323,6 +323,7 @@ def _export_gbq( iterator, job = self._run_execute_query( sql=sql, job_config=job_config, + session=array_value.session, ) has_timedelta_col = any( @@ -389,6 +390,7 @@ def _run_execute_query( sql: str, job_config: Optional[bq_job.QueryJobConfig] = None, query_with_job: bool = True, + session=None, ) -> Tuple[bq_table.RowIterator, Optional[bigquery.QueryJob]]: """ Starts BigQuery query job and waits for results. @@ -415,6 +417,7 @@ def _run_execute_query( timeout=None, query_with_job=True, publisher=self._publisher, + session=session, ) else: return bq_io.start_query_with_client( @@ -427,6 +430,7 @@ def _run_execute_query( timeout=None, query_with_job=False, publisher=self._publisher, + session=session, ) except google.api_core.exceptions.BadRequest as e: @@ -661,6 +665,7 @@ def _execute_plan_gbq( sql=compiled.sql, job_config=job_config, query_with_job=(destination_table is not None), + session=plan.session, ) # we could actually cache even when caching is not explicitly requested, but being conservative for now diff --git a/bigframes/session/direct_gbq_execution.py b/bigframes/session/direct_gbq_execution.py index 748c43e66c..3ec10bf20f 100644 --- a/bigframes/session/direct_gbq_execution.py +++ b/bigframes/session/direct_gbq_execution.py @@ -60,6 +60,7 @@ def execute( iterator, query_job = self._run_execute_query( sql=compiled.sql, + session=plan.session, ) # just immediately downlaod everything for simplicity @@ -75,6 +76,7 @@ def _run_execute_query( self, sql: str, job_config: Optional[bq_job.QueryJobConfig] = None, + session=None, ) -> Tuple[bq_table.RowIterator, Optional[bigquery.QueryJob]]: """ Starts BigQuery query job and waits for results. @@ -89,4 +91,5 @@ def _run_execute_query( metrics=None, query_with_job=False, publisher=self._publisher, + session=session, ) diff --git a/bigframes/session/loader.py b/bigframes/session/loader.py index d248cf4ff5..bf91637be4 100644 --- a/bigframes/session/loader.py +++ b/bigframes/session/loader.py @@ -1324,6 +1324,7 @@ def _start_query_with_job_optional( metrics=None, query_with_job=False, publisher=self._publisher, + session=self._session, ) return rows @@ -1350,6 +1351,7 @@ def _start_query_with_job( metrics=None, query_with_job=True, publisher=self._publisher, + session=self._session, ) return query_job From 9597f554d68b997da92f2682a7d0de0e4cb25a55 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Sun, 28 Dec 2025 19:46:48 -0800 Subject: [PATCH 312/313] =?UTF-8?q?refactor:=20move=20inline=20styles=20to?= =?UTF-8?q?=20table=5Fwidget.css=20and=20use=20cla=E2=80=A6=20(#2357)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This update refactors how the interactive DataFrame widget (anywidget mode) handles styling. It moves styling logic from Python code into dedicated CSS classes, making the display system cleaner and more robust. Fixes # 🦕 --- bigframes/display/table_widget.css | 141 +++++++++++++++-------------- 1 file changed, 73 insertions(+), 68 deletions(-) diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css index 4ad21c4d34..34134b043d 100644 --- a/bigframes/display/table_widget.css +++ b/bigframes/display/table_widget.css @@ -15,133 +15,138 @@ */ .bigframes-widget { - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; } .bigframes-widget .table-container { - max-height: 620px; - overflow: auto; + max-height: 620px; + overflow: auto; } .bigframes-widget .footer { - align-items: center; - /* TODO(b/460861328): We will support dark mode in a media selector once we - * determine how to override the background colors as well. */ - color: black; - display: flex; - font-family: - "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; - font-size: 0.8rem; - justify-content: space-between; - padding: 8px; + align-items: center; + /* TODO(b/460861328): We will support dark mode in a media selector once we + * determine how to override the background colors as well. */ + color: black; + display: flex; + font-family: + "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; + font-size: 0.8rem; + justify-content: space-between; + padding: 8px; } .bigframes-widget .footer > * { - flex: 1; + flex: 1; } .bigframes-widget .pagination { - align-items: center; - display: flex; - flex-direction: row; - gap: 4px; - justify-content: center; - padding: 4px; + align-items: center; + display: flex; + flex-direction: row; + gap: 4px; + justify-content: center; + padding: 4px; } .bigframes-widget .page-indicator { - margin: 0 8px; + margin: 0 8px; } .bigframes-widget .row-count { - margin: 0 8px; + margin: 0 8px; } .bigframes-widget .page-size { - align-items: center; - display: flex; - flex-direction: row; - gap: 4px; - justify-content: end; + align-items: center; + display: flex; + flex-direction: row; + gap: 4px; + justify-content: end; } .bigframes-widget .page-size label { - margin-right: 8px; + margin-right: 8px; } .bigframes-widget table { - border-collapse: collapse; - /* TODO(b/460861328): We will support dark mode in a media selector once we - * determine how to override the background colors as well. */ - color: black; - text-align: left; + border-collapse: collapse; + /* TODO(b/460861328): We will support dark mode in a media selector once we + * determine how to override the background colors as well. */ + color: black; + text-align: left; } .bigframes-widget th { - background-color: var(--colab-primary-surface-color, var(--jp-layout-color0)); - padding: 0; - position: sticky; - top: 0; - z-index: 1; + background-color: var(--colab-primary-surface-color, var(--jp-layout-color0)); + padding: 0; + position: sticky; + text-align: left; + top: 0; + z-index: 1; } .bigframes-widget .bf-header-content { - box-sizing: border-box; - height: 100%; - overflow: auto; - padding: 0.5em; - resize: horizontal; - width: 100%; + box-sizing: border-box; + height: 100%; + overflow: auto; + padding: 0.5em; + resize: horizontal; + width: 100%; } .bigframes-widget th .sort-indicator { - padding-left: 4px; - visibility: hidden; + padding-left: 4px; + visibility: hidden; } .bigframes-widget th:hover .sort-indicator { - visibility: visible; + visibility: visible; } .bigframes-widget button { - cursor: pointer; - display: inline-block; - text-align: center; - text-decoration: none; - user-select: none; - vertical-align: middle; + cursor: pointer; + display: inline-block; + text-align: center; + text-decoration: none; + user-select: none; + vertical-align: middle; } .bigframes-widget button:disabled { - opacity: 0.65; - pointer-events: none; + opacity: 0.65; + pointer-events: none; } .bigframes-widget .bigframes-error-message { - background-color: #fbe; - border: 1px solid red; - border-radius: 4px; - font-family: - "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; - font-size: 14px; - margin-bottom: 8px; - padding: 8px; + background-color: #fbe; + border: 1px solid red; + border-radius: 4px; + font-family: + "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; + font-size: 14px; + margin-bottom: 8px; + padding: 8px; } .bigframes-widget .cell-align-right { - text-align: right; + text-align: right; } .bigframes-widget .cell-align-left { - text-align: left; + text-align: left; +} + +.bigframes-widget .null-value { + color: gray; } .bigframes-widget td { - padding: 0.5em; + padding: 0.5em; } .bigframes-widget tr:hover td, .bigframes-widget td.row-hover { - background-color: var(--colab-hover-surface-color, var(--jp-layout-color2)); + background-color: var(--colab-hover-surface-color, var(--jp-layout-color2)); } From 69fa7f404cde5a202df68ed21a6faeac98fb1e4d Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Mon, 29 Dec 2025 09:34:36 -0800 Subject: [PATCH 313/313] refactor: use CSS classes in HTML tables (#2358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors the HTML table generation for DataFrames to use CSS classes instead of inline styles. * Switches from style="text-align: ..." to class="cell-align-...". * Uses class="bf-header-content" for header containers. * Standardizes null value styling with class="null-value". Fixes #<438181139> 🦕 --- bigframes/display/html.py | 68 +++++++++++++++++---------------- tests/unit/display/test_html.py | 5 +-- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/bigframes/display/html.py b/bigframes/display/html.py index 3f1667eb9c..912f1d7e3a 100644 --- a/bigframes/display/html.py +++ b/bigframes/display/html.py @@ -48,60 +48,62 @@ def render_html( orderable_columns: list[str] | None = None, ) -> str: """Render a pandas DataFrame to HTML with specific styling.""" - classes = "dataframe table table-striped table-hover" - table_html = [f''] - precision = options.display.precision orderable_columns = orderable_columns or [] + classes = "dataframe table table-striped table-hover" + table_html_parts = [f'
'] + table_html_parts.append(_render_table_header(dataframe, orderable_columns)) + table_html_parts.append(_render_table_body(dataframe)) + table_html_parts.append("
") + return "".join(table_html_parts) - # Render table head - table_html.append(" ") - table_html.append(' ') + +def _render_table_header(dataframe: pd.DataFrame, orderable_columns: list[str]) -> str: + """Render the header of the HTML table.""" + header_parts = [" ", " "] for col in dataframe.columns: th_classes = [] if col in orderable_columns: th_classes.append("sortable") class_str = f'class="{" ".join(th_classes)}"' if th_classes else "" - header_div = ( - '
' - f"{html.escape(str(col))}" - "
" - ) - table_html.append( - f' {header_div}' + header_parts.append( + f'
' + f"{html.escape(str(col))}
" ) - table_html.append(" ") - table_html.append(" ") + header_parts.extend([" ", " "]) + return "\n".join(header_parts) + + +def _render_table_body(dataframe: pd.DataFrame) -> str: + """Render the body of the HTML table.""" + body_parts = [" "] + precision = options.display.precision - # Render table body - table_html.append(" ") for i in range(len(dataframe)): - table_html.append(" ") + body_parts.append(" ") row = dataframe.iloc[i] for col_name, value in row.items(): dtype = dataframe.dtypes.loc[col_name] # type: ignore align = "right" if _is_dtype_numeric(dtype) else "left" - table_html.append( - ' '.format(align) - ) # TODO(b/438181139): Consider semi-exploding ARRAY/STRUCT columns # into multiple rows/columns like the BQ UI does. if pandas.api.types.is_scalar(value) and pd.isna(value): - table_html.append(' <NA>') + body_parts.append( + f' ' + '<NA>' + ) else: if isinstance(value, float): - formatted_value = f"{value:.{precision}f}" - table_html.append(f" {html.escape(formatted_value)}") + cell_content = f"{value:.{precision}f}" else: - table_html.append(f" {html.escape(str(value))}") - table_html.append(" ") - table_html.append(" ") - table_html.append(" ") - table_html.append("") - - return "\n".join(table_html) + cell_content = str(value) + body_parts.append( + f' ' + f"{html.escape(cell_content)}" + ) + body_parts.append(" ") + body_parts.append(" ") + return "\n".join(body_parts) def _obj_ref_rt_to_html(obj_ref_rt: str) -> str: diff --git a/tests/unit/display/test_html.py b/tests/unit/display/test_html.py index fcf1455362..0762a2fd8d 100644 --- a/tests/unit/display/test_html.py +++ b/tests/unit/display/test_html.py @@ -130,9 +130,8 @@ def test_render_html_alignment_and_precision( df = pd.DataFrame(data) html = bf_html.render_html(dataframe=df, table_id="test-table") - for _, align in expected_alignments.items(): - assert 'th style="text-align: left;"' in html - assert f'