Skip to content

Commit ee77a63

Browse files
committed
cache spark config read
1 parent 6fc9a43 commit ee77a63

8 files changed

Lines changed: 89 additions & 28 deletions

File tree

spark_expectations/core/context.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ def __post_init__(self) -> None:
136136
self._debugger_mode: bool = False
137137
self._supported_df_query_dq: DataFrame = self.set_supported_df_query_dq()
138138

139+
self._ansi_enabled: Optional[bool] = None
140+
139141
self._source_agg_dq_start_time: Optional[datetime] = None
140142
self._final_agg_dq_start_time: Optional[datetime] = None
141143
self._source_query_dq_start_time: Optional[datetime] = None
@@ -3056,3 +3058,20 @@ def get_df_dq_obs_report_dataframe(self) -> DataFrame:
30563058
DataFrame: DataFrame containing DQ observability report data
30573059
"""
30583060
return self._df_dq_obs_report_dataframe
3061+
3062+
@property
3063+
def get_ansi_enabled(self) -> bool:
3064+
"""
3065+
Returns whether Spark's ANSI mode is enabled. Value is cached after the first read.
3066+
3067+
Returns:
3068+
bool: Returns the ANSI mode enabled boolean
3069+
"""
3070+
if self._ansi_enabled is None:
3071+
try:
3072+
self._ansi_enabled = self.spark.conf.get("spark.sql.ansi.enabled", "false").lower() == "true"
3073+
except Exception as e:
3074+
# Catch-all for any unexpected errors; return safe default
3075+
_log.warning(f"Failed to retrieve Spark ANSI mode, setting to 'false'. Error: {e}")
3076+
self._ansi_enabled = False
3077+
return self._ansi_enabled

spark_expectations/sinks/utils/report.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def __post_init__(self) -> None:
3636
def dq_obs_report_data_insert(self) -> tuple[bool, DataFrame]:
3737
try:
3838
context = self._context
39+
ansi_enabled = context.get_ansi_enabled
3940

4041
print("dq_obs_report_data_insert method called stats_detailed table")
4142
df_stats_detailed = context.get_stats_detailed_dataframe
@@ -174,7 +175,7 @@ def dq_obs_report_data_insert(self) -> tuple[bool, DataFrame]:
174175
.withColumn(
175176
"total_records_only_nbr",
176177
when(col("_total_records_str") == "", lit(None).cast("bigint"))
177-
.otherwise(safe_cast(self.spark, "_total_records_str", "bigint")),
178+
.otherwise(safe_cast(ansi_enabled, "_total_records_str", "bigint")),
178179
)
179180
.withColumn(
180181
"_valid_records_str",
@@ -183,7 +184,7 @@ def dq_obs_report_data_insert(self) -> tuple[bool, DataFrame]:
183184
.withColumn(
184185
"valid_records_only_nbr",
185186
when(col("_valid_records_str") == "", lit(None).cast("bigint"))
186-
.otherwise(safe_cast(self.spark, "_valid_records_str", "bigint")),
187+
.otherwise(safe_cast(ansi_enabled, "_valid_records_str", "bigint")),
187188
)
188189
.drop("_total_records_str", "_valid_records_str")
189190
.withColumn(
@@ -199,7 +200,7 @@ def dq_obs_report_data_insert(self) -> tuple[bool, DataFrame]:
199200
.otherwise(
200201
coalesce(
201202
safe_cast(
202-
self.spark,
203+
ansi_enabled,
203204
"""
204205
100
205206
* least(
@@ -330,7 +331,7 @@ def dq_obs_report_data_insert(self) -> tuple[bool, DataFrame]:
330331
.withColumnRenamed("source_dq_error_row_count", "failed_records")
331332
.withColumn(
332333
"success_percentage",
333-
(safe_cast(self.spark, "valid_records", "double") / safe_cast(self.spark, "total_records", "double")) * 100,
334+
(safe_cast(ansi_enabled, "valid_records", "double") / safe_cast(ansi_enabled, "total_records", "double")) * 100,
334335
)
335336
)
336337

spark_expectations/utils/actions.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ def execute_sql_and_get_result(
445445
row_count,
446446
)
447447
except Exception as e:
448-
raise SparkExpectationsMiscException(f"error occurred while running agg_query_dq_detailed_result {e}")
448+
raise SparkExpectationsMiscException(f"error occurred while running agg_query_dq_detailed_result: {e}")
449449

450450
@staticmethod
451451
def create_agg_dq_results(
@@ -479,7 +479,7 @@ def create_agg_dq_results(
479479
return meta_results
480480
return None
481481
except Exception as e:
482-
raise SparkExpectationsMiscException(f"error occurred while running create agg dq results {e}")
482+
raise SparkExpectationsMiscException(f"error occurred while running create_agg_dq_results: {e}")
483483

484484
@staticmethod
485485
def run_dq_rules(
@@ -632,7 +632,7 @@ def run_dq_rules(
632632
return df
633633

634634
except Exception as e:
635-
raise SparkExpectationsMiscException(f"error occurred while running expectations {e}")
635+
raise SparkExpectationsMiscException(f"error occurred while running expectations: {e}")
636636

637637
@staticmethod
638638
def action_on_rules(

spark_expectations/utils/udf.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pyspark.sql import SparkSession, Column
1+
from pyspark.sql import Column
22
from pyspark.sql.functions import filter, size, transform, when, lit, array, expr
33

44

@@ -41,18 +41,21 @@ def get_actions_list(column: Column) -> Column:
4141
action_if_failed = transform(column, lambda x: x["action_if_failed"])
4242
return when(size(action_if_failed) == 0, array(lit("ignore"))).otherwise(action_if_failed) # pragma: no cover
4343

44-
def safe_cast(spark: SparkSession, column: str, target_type: str) -> Column:
44+
45+
def safe_cast(ansi_enabled: bool, column: str, target_type: str) -> Column:
4546
"""
46-
Checks if ANSI mode is enabled. If enabled, uses try_cast to cast the column to the target type. If not, uses cast.
47+
If ANSI mode is enabled, uses try_cast to cast the column to the target type. If not, uses cast.
4748
Args:
48-
spark: SparkSession
49-
column: column to cast (provided as a string)
50-
target_type: target type to cast to
49+
ansi_enabled: bool for if ANSI mode is enabled or not
50+
column_expr: column expression to cast (provided as a string that gets parsed as SQL)
51+
target_type: target type to cast to (also gets parsed as SQL)
52+
53+
"column_expr" and "target_type" are interpolated to SQL and parsed by Spark. Never pass user-controlled input, as this is a SQL injection risk.
54+
Both must be hardcoded literals or values that have been validated against an allow list.
5155
5256
Returns:
5357
Column: the casted column
5458
"""
55-
ansi_enabled = spark.conf.get("spark.sql.ansi.enabled", "false").lower() == "true"
5659
if ansi_enabled:
5760
return expr(f"try_cast({column} as {target_type})")
5861
else:

tests/integration/core/test_context.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3036,3 +3036,41 @@ def test_get_kafka_write_error_message_default():
30363036
context = SparkExpectationsContext(product_id="product1", spark=spark)
30373037
# Test default value when attribute not set
30383038
assert context.get_kafka_write_error_message == ""
3039+
3040+
3041+
def test_get_ansi_enabled_happy_path():
3042+
"""Test getting ANSI mode from spark conf"""
3043+
from unittest.mock import MagicMock
3044+
3045+
mock_spark = MagicMock()
3046+
mock_spark.conf.get.return_value = "True"
3047+
3048+
context = SparkExpectationsContext(product_id="product1", spark=mock_spark)
3049+
result = context.get_ansi_enabled
3050+
assert result == True
3051+
3052+
def test_get_ansi_enabled_already_set():
3053+
"""Test getting ANSI mode when it's already cached--should not call the conf"""
3054+
from unittest.mock import MagicMock
3055+
3056+
mock_spark = MagicMock()
3057+
context = SparkExpectationsContext(product_id="product1", spark=mock_spark)
3058+
context._ansi_enabled = False
3059+
3060+
result = context.get_ansi_enabled
3061+
assert result == False
3062+
mock_spark.conf.get.assert_not_called()
3063+
3064+
def test_get_ansi_enabled_catches_exception():
3065+
"""Test an exception when getting ANSI mode"""
3066+
class BadSpark:
3067+
@property
3068+
def version(self):
3069+
raise Exception("boom")
3070+
3071+
context = SparkExpectationsContext(product_id="test_product", spark=spark)
3072+
context.spark = BadSpark()
3073+
3074+
result = context.get_ansi_enabled
3075+
assert result == False
3076+

tests/integration/utils/test_actions.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -806,7 +806,7 @@ def test_agg_query_dq_detailed_result_exception_v2(_fixture_df, _query_dq_rule_e
806806
_fixture_df.createOrReplaceTempView("query_test_table_target")
807807
with pytest.raises(
808808
SparkExpectationsMiscException,
809-
match=r"(error occurred while running agg_query_dq_detailed_result Sql query is invalid. *)|(error occurred while running agg_query_dq_detailed_result Regex match not found. *)",
809+
match=r"(error occurred while running agg_query_dq_detailed_result: Sql query is invalid. *)|(error occurred while running agg_query_dq_detailed_result: Regex match not found. *)",
810810
):
811811
SparkExpectationsActions().agg_query_dq_detailed_result(
812812
_fixture_mock_context, _query_dq_rule_exception, _fixture_df, []
@@ -1028,7 +1028,7 @@ def test_run_dq_rules_query(
10281028
def test_run_dq_rules_negative_case(_fixture_df, _fixture_mock_context):
10291029
expectations = {"row_dq_rules": []}
10301030

1031-
with pytest.raises(SparkExpectationsMiscException, match=r"error occurred while running expectations .*"):
1031+
with pytest.raises(SparkExpectationsMiscException, match=r"error occurred while running expectations: .*"):
10321032
SparkExpectationsActions.run_dq_rules(_fixture_mock_context, _fixture_df, expectations, "row_dq")
10331033

10341034
@pytest.mark.parametrize(
@@ -1054,7 +1054,7 @@ def test_run_dq_rules_negative_case(_fixture_df, _fixture_mock_context):
10541054
)
10551055
def test_run_dq_rules_exception(_fixture_df, _fixture_mock_context, expectations, expected_exception):
10561056
# test the exception functionality in run_dq_rules with faulty user input
1057-
with pytest.raises(expected_exception, match=r"error occurred while running expectations .*"):
1057+
with pytest.raises(expected_exception, match=r"error occurred while running expectations: .*"):
10581058
SparkExpectationsActions.run_dq_rules(_fixture_mock_context, _fixture_df, expectations, "row_dq")
10591059

10601060

@@ -1080,7 +1080,7 @@ def test_run_dq_rules_condition_expression_exception(
10801080
}
10811081
_fixture_df.createOrReplaceTempView("query_test_table")
10821082

1083-
with pytest.raises(SparkExpectationsMiscException, match=r"error occurred while running expectations .*"):
1083+
with pytest.raises(SparkExpectationsMiscException, match=r"error occurred while running expectations: .*"):
10841084
SparkExpectationsActions.run_dq_rules(
10851085
_fixture_mock_context, _fixture_df, _expectations, "query_dq", False, True
10861086
)
@@ -1113,7 +1113,7 @@ def test_run_dq_rules_condition_expression_dynamic_exception(
11131113
}
11141114
_fixture_df.createOrReplaceTempView("query_test_table")
11151115

1116-
with pytest.raises(SparkExpectationsMiscException, match=r"error occurred while running expectations .*"):
1116+
with pytest.raises(SparkExpectationsMiscException, match=r"error occurred while running expectations: .*"):
11171117
_rule_type = _rule_test.get("rule_type")
11181118
SparkExpectationsActions.run_dq_rules(
11191119
_fixture_mock_context, _fixture_df, _expectations, _rule_type, False, True
@@ -1129,7 +1129,7 @@ def collect(self):
11291129
return [[["unexpected", "list"]]] # Not int, float, str, or date
11301130
return DummyRow()
11311131
dummy_df = DummyDF()
1132-
with pytest.raises(SparkExpectationsMiscException, match="error occurred while running agg_query_dq_detailed_result .*"):
1132+
with pytest.raises(SparkExpectationsMiscException, match="error occurred while running agg_query_dq_detailed_result: .*"):
11331133
SparkExpectationsActions.agg_query_dq_detailed_result(
11341134
_fixture_mock_context, _fixture_agg_dq_rule, dummy_df, []
11351135
)
@@ -1215,7 +1215,7 @@ def test_create_agg_dq_results(input_df, rule_type_name, expected_output, _fixtu
12151215
)
12161216
def test_create_agg_dq_results_exception(input_df, _fixture_mock_context):
12171217
# faulty user input is given to test the exception functionality of the agg_dq_result
1218-
with pytest.raises(SparkExpectationsMiscException, match=r"error occurred while running create agg dq results .*"):
1218+
with pytest.raises(SparkExpectationsMiscException, match=r"error occurred while running create_agg_dq_results: .*"):
12191219
SparkExpectationsActions().create_agg_dq_results(
12201220
_fixture_mock_context,
12211221
input_df,
@@ -1961,7 +1961,7 @@ def test_agg_query_dq_detailed_result_type_error_line_210(_fixture_agg_dq_rule,
19611961
mock_result = Mock()
19621962
mock_result.collect.return_value = [[[]]] # Return list to trigger TypeError
19631963
mock_agg.return_value = mock_result
1964-
with pytest.raises(SparkExpectationsMiscException, match="error occurred while running agg_query_dq_detailed_result .*"):
1964+
with pytest.raises(SparkExpectationsMiscException, match="error occurred while running agg_query_dq_detailed_result: .*"):
19651965
SparkExpectationsActions.agg_query_dq_detailed_result(
19661966
_fixture_mock_context, _fixture_agg_dq_rule, df, []
19671967
)

tests/integration/utils/test_udf.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,13 @@ def test_get_actions_list():
6262
def test_safe_cast_ansi_disabled():
6363
# with default spark.sql.ansi.enabled=false, safe cast should return
6464
# a column equavalent to cast(col as type)
65-
spark.conf.set("spark.sql.ansi.enabled", "false")
6665

66+
ansi_enabled = "false"
6767
columns = ["Name", "Value"]
6868
data = [("thing", "123")]
6969
df = spark.createDataFrame(data, schema=columns)
7070

71-
result_df = df.withColumn("casted_value", safe_cast(spark, "Value", "bigint"))
71+
result_df = df.withColumn("casted_value", safe_cast(ansi_enabled, "Value", "bigint"))
7272
result = result_df.select("casted_value").collect()[0]["casted_value"]
7373

7474
assert result == 123
@@ -78,16 +78,16 @@ def test_safe_cast_ansi_disabled():
7878
def test_safe_cast_ansi_enabled():
7979
# with spark.sql.ansi.enabled=true, safe cast should succeed if it's a castable value and should
8080
# return Null if it's not a valid cast (without safe cast it would throw an exception insead)
81-
spark.conf.set("spark.sql.ansi.enabled", "true")
8281

82+
ansi_enabled = "true"
8383
columns = ["Name", "Value", "Color"]
8484
data = [("mug", "10", "red")]
8585
df = spark.createDataFrame(data, schema=columns)
8686

87-
success_result_df = df.withColumn("success_casted_value", safe_cast(spark, "Value", "double"))
87+
success_result_df = df.withColumn("success_casted_value", safe_cast(ansi_enabled, "Value", "double"))
8888
success_result = success_result_df.select("success_casted_value").collect()[0]["success_casted_value"]
8989

90-
fail_result_df = df.withColumn("fail_casted_value", safe_cast(spark, "Color", "double"))
90+
fail_result_df = df.withColumn("fail_casted_value", safe_cast(ansi_enabled, "Color", "double"))
9191
fail_result = fail_result_df.select("fail_casted_value").collect()[0]["fail_casted_value"]
9292

9393
# re-set ansi mode so it doesn't affect other tests

tests/unit/utils/test_actions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def test_agg_query_dq_detailed_result_exception():
7777
# faulty user input is given to test the exception functionality of the agg_query_dq_detailed_result
7878

7979
with pytest.raises(
80-
SparkExpectationsMiscException, match=r"error occurred while running agg_query_dq_detailed_result .*"
80+
SparkExpectationsMiscException, match=r"error occurred while running agg_query_dq_detailed_result: .*"
8181
):
8282
SparkExpectationsActions().agg_query_dq_detailed_result(
8383
_mock_object_context, "_fixture_query_dq_rule", "<df>", []

0 commit comments

Comments
 (0)