databricks4py 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. {databricks4py-0.2.0/src/databricks4py.egg-info → databricks4py-0.3.0}/PKG-INFO +1 -1
  2. {databricks4py-0.2.0 → databricks4py-0.3.0}/pyproject.toml +1 -1
  3. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/__init__.py +1 -1
  4. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/io/__init__.py +11 -1
  5. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/io/delta.py +57 -2
  6. databricks4py-0.3.0/src/databricks4py/io/maintenance.py +151 -0
  7. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/io/merge.py +52 -0
  8. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/io/streaming.py +35 -1
  9. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/quality/__init__.py +7 -0
  10. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/quality/expectations.py +90 -1
  11. databricks4py-0.3.0/src/databricks4py/quality/profiler.py +124 -0
  12. databricks4py-0.3.0/src/databricks4py/transforms.py +109 -0
  13. {databricks4py-0.2.0 → databricks4py-0.3.0/src/databricks4py.egg-info}/PKG-INFO +1 -1
  14. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py.egg-info/SOURCES.txt +10 -0
  15. databricks4py-0.3.0/tests/test_circuit_breaker.py +240 -0
  16. databricks4py-0.3.0/tests/test_delta_restore.py +134 -0
  17. databricks4py-0.3.0/tests/test_freshness_expectation.py +111 -0
  18. databricks4py-0.3.0/tests/test_maintenance.py +187 -0
  19. databricks4py-0.3.0/tests/test_profiler.py +204 -0
  20. databricks4py-0.3.0/tests/test_soft_delete.py +155 -0
  21. databricks4py-0.3.0/tests/test_transforms.py +269 -0
  22. {databricks4py-0.2.0 → databricks4py-0.3.0}/LICENSE +0 -0
  23. {databricks4py-0.2.0 → databricks4py-0.3.0}/README.md +0 -0
  24. {databricks4py-0.2.0 → databricks4py-0.3.0}/setup.cfg +0 -0
  25. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/catalog.py +0 -0
  26. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/config/__init__.py +0 -0
  27. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/config/base.py +0 -0
  28. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/config/unity.py +0 -0
  29. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/filters/__init__.py +0 -0
  30. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/filters/base.py +0 -0
  31. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/io/checkpoint.py +0 -0
  32. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/io/dbfs.py +0 -0
  33. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/logging.py +0 -0
  34. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/metrics/__init__.py +0 -0
  35. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/metrics/base.py +0 -0
  36. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/metrics/delta_sink.py +0 -0
  37. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/metrics/logging_sink.py +0 -0
  38. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/migrations/__init__.py +0 -0
  39. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/migrations/alter.py +0 -0
  40. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/migrations/runner.py +0 -0
  41. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/migrations/schema_diff.py +0 -0
  42. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/migrations/validators.py +0 -0
  43. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/observability/__init__.py +0 -0
  44. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/observability/_utils.py +0 -0
  45. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/observability/batch_context.py +0 -0
  46. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/observability/health.py +0 -0
  47. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/observability/query_listener.py +0 -0
  48. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/py.typed +0 -0
  49. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/quality/base.py +0 -0
  50. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/quality/gate.py +0 -0
  51. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/retry.py +0 -0
  52. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/secrets.py +0 -0
  53. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/spark_session.py +0 -0
  54. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/testing/__init__.py +0 -0
  55. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/testing/assertions.py +0 -0
  56. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/testing/builders.py +0 -0
  57. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/testing/fixtures.py +0 -0
  58. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/testing/mocks.py +0 -0
  59. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/testing/temp_table.py +0 -0
  60. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py/workflow.py +0 -0
  61. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py.egg-info/dependency_links.txt +0 -0
  62. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py.egg-info/requires.txt +0 -0
  63. {databricks4py-0.2.0 → databricks4py-0.3.0}/src/databricks4py.egg-info/top_level.txt +0 -0
  64. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_assertions.py +0 -0
  65. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_batch_context.py +0 -0
  66. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_builders.py +0 -0
  67. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_catalog.py +0 -0
  68. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_checkpoint.py +0 -0
  69. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_config.py +0 -0
  70. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_dbfs.py +0 -0
  71. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_delta.py +0 -0
  72. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_delta_advanced_integration.py +0 -0
  73. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_delta_metrics_sink_integration.py +0 -0
  74. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_filters.py +0 -0
  75. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_health.py +0 -0
  76. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_logging.py +0 -0
  77. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_merge.py +0 -0
  78. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_metrics.py +0 -0
  79. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_migration_runner.py +0 -0
  80. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_migration_runner_integration.py +0 -0
  81. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_migrations.py +0 -0
  82. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_mocks.py +0 -0
  83. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_observability_integration.py +0 -0
  84. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_quality.py +0 -0
  85. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_quality_integration.py +0 -0
  86. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_query_listener.py +0 -0
  87. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_retry.py +0 -0
  88. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_schema_diff.py +0 -0
  89. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_secrets.py +0 -0
  90. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_spark_session.py +0 -0
  91. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_streaming.py +0 -0
  92. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_streaming_dlq.py +0 -0
  93. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_streaming_integration.py +0 -0
  94. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_table_alter.py +0 -0
  95. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_table_alter_integration.py +0 -0
  96. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_temp_table.py +0 -0
  97. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_workflow.py +0 -0
  98. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_workflow_e2e_integration.py +0 -0
  99. {databricks4py-0.2.0 → databricks4py-0.3.0}/tests/test_workflow_v2.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: databricks4py
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Spark, Delta Lake, and Databricks utility library for Python
5
5
  Author: kirankbs
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "databricks4py"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Spark, Delta Lake, and Databricks utility library for Python"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -4,7 +4,7 @@ A collection of reusable abstractions for building PySpark applications
4
4
  on Databricks and locally.
5
5
  """
6
6
 
7
- __version__ = "0.2.0"
7
+ __version__ = "0.3.0"
8
8
 
9
9
  from databricks4py.catalog import CatalogSchema
10
10
  from databricks4py.config import Environment, JobConfig, UnityConfig
@@ -10,8 +10,13 @@ from databricks4py.io.delta import (
10
10
  optimize_table,
11
11
  vacuum_table,
12
12
  )
13
+ from databricks4py.io.maintenance import MaintenanceResult, MaintenanceRunner, analyze_table
13
14
  from databricks4py.io.merge import MergeBuilder, MergeResult
14
- from databricks4py.io.streaming import StreamingTableReader, StreamingTriggerOptions
15
+ from databricks4py.io.streaming import (
16
+ CircuitBreakerError,
17
+ StreamingTableReader,
18
+ StreamingTriggerOptions,
19
+ )
15
20
 
16
21
  __all__ = [
17
22
  # Checkpoint
@@ -31,10 +36,15 @@ __all__ = [
31
36
  "GeneratedColumn",
32
37
  "optimize_table",
33
38
  "vacuum_table",
39
+ # Maintenance
40
+ "MaintenanceResult",
41
+ "MaintenanceRunner",
42
+ "analyze_table",
34
43
  # Merge
35
44
  "MergeBuilder",
36
45
  "MergeResult",
37
46
  # Streaming
47
+ "CircuitBreakerError",
38
48
  "StreamingTableReader",
39
49
  "StreamingTriggerOptions",
40
50
  ]
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import logging
6
6
  from collections.abc import Sequence
7
7
  from dataclasses import dataclass
8
+ from datetime import datetime
8
9
  from typing import TYPE_CHECKING, Any
9
10
 
10
11
  from pyspark.sql import DataFrame, SparkSession
@@ -95,7 +96,7 @@ class DeltaTable:
95
96
  spark: SparkSession | None = None,
96
97
  ) -> None:
97
98
  self._spark = active_fallback(spark)
98
- self._table_name = table_name
99
+ self._table_name = self._validated_table_name(table_name)
99
100
  self._schema = self._resolve_schema(schema)
100
101
  self._location = location
101
102
  self._generated_columns = list(generated_columns or [])
@@ -107,6 +108,20 @@ class DeltaTable:
107
108
 
108
109
  self._ensure_table_exists()
109
110
 
111
+ @staticmethod
112
+ def _validated_table_name(name: str) -> str:
113
+ """Validate that a table name doesn't contain SQL injection characters.
114
+
115
+ Allows word characters, dots, backticks, hyphens, spaces, and quotes
116
+ (all valid in Spark/Delta table identifiers). Rejects semicolons and
117
+ SQL comment sequences that are the primary injection vectors.
118
+ """
119
+ import re
120
+
121
+ if re.search(r"--|;|/\*|\*/", name):
122
+ raise ValueError(f"Unsafe characters in table name: {name!r}")
123
+ return name
124
+
110
125
  @staticmethod
111
126
  def _resolve_schema(schema: StructType | dict[str, str]) -> StructType:
112
127
  if isinstance(schema, dict):
@@ -251,6 +266,46 @@ class DeltaTable:
251
266
  row = self.detail().select("partitionColumns").first()
252
267
  return list(row["partitionColumns"]) if row else []
253
268
 
269
+ def history(self, limit: int = 20) -> DataFrame:
270
+ """Retrieve the Delta transaction history for this table.
271
+
272
+ Args:
273
+ limit: Maximum number of history entries to return (default 20).
274
+
275
+ Returns:
276
+ DataFrame with columns including ``version``, ``timestamp``,
277
+ ``operation``, and ``operationMetrics``.
278
+ """
279
+ return self._spark.sql(f"DESCRIBE HISTORY {self._table_name} LIMIT {limit}")
280
+
281
+ def restore(self, version: int) -> DataFrame:
282
+ """Restore this table to a prior Delta version.
283
+
284
+ Args:
285
+ version: The Delta version number to restore to (from :meth:`history`).
286
+
287
+ Returns:
288
+ DataFrame summarising the restore operation.
289
+ """
290
+ logger.info("Restoring %s to version %d", self._table_name, version)
291
+ return self._spark.sql(f"RESTORE TABLE {self._table_name} TO VERSION AS OF {version}")
292
+
293
+ def restore_to_timestamp(self, timestamp: datetime) -> DataFrame:
294
+ """Restore this table to the state at a given timestamp.
295
+
296
+ The ``timestamp`` is formatted as ``YYYY-MM-DD HH:MM:SS`` for the SQL
297
+ statement. Pass a timezone-aware datetime for predictable behaviour.
298
+
299
+ Args:
300
+ timestamp: The point in time to restore to.
301
+
302
+ Returns:
303
+ DataFrame summarising the restore operation.
304
+ """
305
+ ts_str = timestamp.strftime("%Y-%m-%d %H:%M:%S")
306
+ logger.info("Restoring %s to timestamp %s", self._table_name, ts_str)
307
+ return self._spark.sql(f"RESTORE TABLE {self._table_name} TO TIMESTAMP AS OF '{ts_str}'")
308
+
254
309
  def merge(
255
310
  self,
256
311
  source: DataFrame,
@@ -359,7 +414,7 @@ class DeltaTable:
359
414
  history = self._spark.sql(f"DESCRIBE HISTORY {self._table_name} LIMIT 1")
360
415
  rows = history.collect()
361
416
  if not rows:
362
- return
417
+ return _MergeResult(rows_inserted=0, rows_updated=0, rows_deleted=0)
363
418
  metrics: dict[str, str] = rows[0]["operationMetrics"] or {}
364
419
 
365
420
  result = _MergeResult(
@@ -0,0 +1,151 @@
1
+ """Table maintenance utilities for Delta Lake."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from collections.abc import Sequence
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING
10
+
11
+ from pyspark.sql import SparkSession
12
+
13
+ from databricks4py.io.delta import optimize_table, vacuum_table
14
+ from databricks4py.spark_session import active_fallback
15
+
16
+ if TYPE_CHECKING:
17
+ from databricks4py.metrics.base import MetricsSink
18
+
19
+ __all__ = [
20
+ "MaintenanceResult",
21
+ "MaintenanceRunner",
22
+ "analyze_table",
23
+ ]
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def analyze_table(
29
+ table_name: str,
30
+ *,
31
+ columns: Sequence[str] | None = None,
32
+ spark: SparkSession | None = None,
33
+ ) -> None:
34
+ """Run ANALYZE TABLE to compute statistics.
35
+
36
+ Args:
37
+ table_name: The table to analyze.
38
+ columns: Optional columns to compute statistics for.
39
+ spark: Optional SparkSession.
40
+ """
41
+ _spark = active_fallback(spark)
42
+
43
+ if columns:
44
+ col_list = ", ".join(columns)
45
+ sql = f"ANALYZE TABLE {table_name} COMPUTE STATISTICS FOR COLUMNS {col_list}"
46
+ else:
47
+ sql = f"ANALYZE TABLE {table_name} COMPUTE STATISTICS"
48
+
49
+ logger.info("Running: %s", sql)
50
+ _spark.sql(sql)
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class MaintenanceResult:
55
+ """Result of a maintenance run."""
56
+
57
+ table_name: str
58
+ optimized: bool
59
+ vacuumed: bool
60
+ analyzed: bool
61
+ duration_ms: float
62
+
63
+
64
+ class MaintenanceRunner:
65
+ """Composes optimize, vacuum, and analyze into a single maintenance run."""
66
+
67
+ def __init__(
68
+ self,
69
+ table_name: str,
70
+ *,
71
+ optimize: bool = True,
72
+ vacuum: bool = True,
73
+ analyze: bool = True,
74
+ zorder_by: str | Sequence[str] | None = None,
75
+ retention_hours: int = 168,
76
+ analyze_columns: Sequence[str] | None = None,
77
+ metrics_sink: MetricsSink | None = None,
78
+ spark: SparkSession | None = None,
79
+ ) -> None:
80
+ self._table_name = table_name
81
+ self._optimize = optimize
82
+ self._vacuum = vacuum
83
+ self._analyze = analyze
84
+ self._zorder_by = zorder_by
85
+ self._retention_hours = retention_hours
86
+ self._analyze_columns = analyze_columns
87
+ self._metrics_sink = metrics_sink
88
+ self._spark = active_fallback(spark)
89
+
90
+ def run(self) -> MaintenanceResult:
91
+ start = time.monotonic()
92
+
93
+ if self._optimize:
94
+ logger.info("Optimizing %s", self._table_name)
95
+ optimize_table(
96
+ self._table_name,
97
+ zorder_by=self._zorder_by,
98
+ spark=self._spark,
99
+ )
100
+
101
+ if self._vacuum:
102
+ logger.info("Vacuuming %s", self._table_name)
103
+ vacuum_table(
104
+ self._table_name,
105
+ retention_hours=self._retention_hours,
106
+ spark=self._spark,
107
+ )
108
+
109
+ if self._analyze:
110
+ logger.info("Analyzing %s", self._table_name)
111
+ analyze_table(
112
+ self._table_name,
113
+ columns=self._analyze_columns,
114
+ spark=self._spark,
115
+ )
116
+
117
+ duration_ms = (time.monotonic() - start) * 1000
118
+
119
+ result = MaintenanceResult(
120
+ table_name=self._table_name,
121
+ optimized=self._optimize,
122
+ vacuumed=self._vacuum,
123
+ analyzed=self._analyze,
124
+ duration_ms=duration_ms,
125
+ )
126
+
127
+ if self._metrics_sink:
128
+ from datetime import datetime, timezone
129
+
130
+ from databricks4py.metrics.base import MetricEvent
131
+
132
+ event = MetricEvent(
133
+ job_name="maintenance",
134
+ event_type="maintenance_complete",
135
+ timestamp=datetime.now(tz=timezone.utc),
136
+ duration_ms=int(duration_ms),
137
+ table_name=self._table_name,
138
+ metadata={
139
+ "optimized": result.optimized,
140
+ "vacuumed": result.vacuumed,
141
+ "analyzed": result.analyzed,
142
+ },
143
+ )
144
+ self._metrics_sink.emit(event)
145
+
146
+ logger.info(
147
+ "Maintenance complete for %s in %.0fms",
148
+ self._table_name,
149
+ duration_ms,
150
+ )
151
+ return result
@@ -92,6 +92,45 @@ class MergeBuilder:
92
92
  self._actions.append({"type": "not_matched_by_source_delete", "condition": condition})
93
93
  return self
94
94
 
95
+ def when_matched_soft_delete(
96
+ self,
97
+ *,
98
+ deleted_col: str = "is_deleted",
99
+ deleted_at_col: str | None = "deleted_at",
100
+ condition: str | None = None,
101
+ ) -> MergeBuilder:
102
+ """Mark matched rows as logically deleted without physically removing them.
103
+
104
+ Sets ``deleted_col`` to ``True`` and optionally ``deleted_at_col`` to
105
+ ``current_timestamp()``. Useful for audit-friendly delete patterns in
106
+ regulated environments.
107
+
108
+ Args:
109
+ deleted_col: Boolean column to set ``True`` on the matched row.
110
+ deleted_at_col: Timestamp column to set to ``current_timestamp()``.
111
+ Pass ``None`` to skip updating a timestamp column.
112
+ condition: Optional SQL condition restricting which matched rows to affect.
113
+
114
+ Returns:
115
+ This builder for chaining.
116
+
117
+ Raises:
118
+ ValueError: If ``deleted_col`` or ``deleted_at_col`` is an empty string.
119
+ """
120
+ if not deleted_col:
121
+ raise ValueError("deleted_col must be a non-empty column name")
122
+ if deleted_at_col is not None and not deleted_at_col:
123
+ raise ValueError("deleted_at_col must be a non-empty column name or None")
124
+ self._actions.append(
125
+ {
126
+ "type": "matched_soft_delete",
127
+ "deleted_col": deleted_col,
128
+ "deleted_at_col": deleted_at_col,
129
+ "condition": condition,
130
+ }
131
+ )
132
+ return self
133
+
95
134
  def _build_condition(self) -> str:
96
135
  if self._join_condition:
97
136
  return self._join_condition
@@ -144,6 +183,19 @@ class MergeBuilder:
144
183
  return merger.whenNotMatchedBySourceDelete(condition=cond)
145
184
  return merger.whenNotMatchedBySourceDelete()
146
185
 
186
+ if action_type == "matched_soft_delete":
187
+ deleted_col = action["deleted_col"]
188
+ deleted_at_col = action.get("deleted_at_col")
189
+ cond = action.get("condition")
190
+
191
+ update_map: dict[str, str] = {deleted_col: "true"}
192
+ if deleted_at_col:
193
+ update_map[deleted_at_col] = "current_timestamp()"
194
+
195
+ if cond:
196
+ return merger.whenMatchedUpdate(condition=cond, set=update_map)
197
+ return merger.whenMatchedUpdate(set=update_map)
198
+
147
199
  msg = f"Unknown merge action: {action_type}"
148
200
  raise ValueError(msg)
149
201
 
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
19
19
  from databricks4py.metrics.base import MetricsSink
20
20
 
21
21
  __all__ = [
22
+ "CircuitBreakerError",
22
23
  "StreamingTableReader",
23
24
  "StreamingTriggerOptions",
24
25
  ]
@@ -26,6 +27,14 @@ __all__ = [
26
27
  logger = logging.getLogger(__name__)
27
28
 
28
29
 
30
+ class CircuitBreakerError(RuntimeError):
31
+ """Raised when the streaming circuit breaker trips due to consecutive failures.
32
+
33
+ Stops the query so the issue can be investigated rather than silently
34
+ accumulating data in the DLQ.
35
+ """
36
+
37
+
29
38
  class StreamingTriggerOptions(Enum):
30
39
  """Common streaming trigger configurations.
31
40
 
@@ -83,6 +92,10 @@ class StreamingTableReader(ABC):
83
92
  is written here with ``_dlq_error_message``, ``_dlq_error_timestamp``,
84
93
  and ``_dlq_batch_id`` columns appended. Uses ``mergeSchema=true`` so
85
94
  the table is auto-created on first failure.
95
+ max_consecutive_failures: If set, raises CircuitBreakerError on the Nth
96
+ consecutive batch failure (i.e. ``max_consecutive_failures=1`` means the
97
+ very first failure trips the breaker). Applies whether or not a DLQ is
98
+ configured. Resets to zero after the first subsequent successful batch.
86
99
  spark: Optional SparkSession.
87
100
  """
88
101
 
@@ -101,12 +114,15 @@ class StreamingTableReader(ABC):
101
114
  checkpoint_manager: CheckpointManager | None = None,
102
115
  metrics_sink: MetricsSink | None = None,
103
116
  dead_letter_table: str | None = None,
117
+ max_consecutive_failures: int | None = None,
104
118
  spark: SparkSession | None = None,
105
119
  ) -> None:
106
120
  self._spark = active_fallback(spark)
107
121
  self._source_table = source_table
108
122
  self._metrics_sink = metrics_sink
109
123
  self._dead_letter_table = dead_letter_table
124
+ self._max_consecutive_failures = max_consecutive_failures
125
+ self._consecutive_failures: int = 0
110
126
  self._query: StreamingQuery | None = None
111
127
 
112
128
  # Resolve trigger to a plain dict
@@ -169,6 +185,17 @@ class StreamingTableReader(ABC):
169
185
  error_msg[:200],
170
186
  )
171
187
 
188
+ def _check_circuit_breaker(self, cause: BaseException) -> None:
189
+ """Raise CircuitBreakerError if the failure threshold has been exceeded."""
190
+ if (
191
+ self._max_consecutive_failures is not None
192
+ and self._consecutive_failures >= self._max_consecutive_failures
193
+ ):
194
+ raise CircuitBreakerError(
195
+ f"Circuit breaker tripped after {self._consecutive_failures} consecutive "
196
+ f"failures on {self._source_table}"
197
+ ) from cause
198
+
172
199
  def _foreach_batch_wrapper(self, df: DataFrame, batch_id: int) -> None:
173
200
  """Internal wrapper handling empty batch detection, filtering, and metrics."""
174
201
  if self._skip_empty_batches and df.isEmpty():
@@ -187,7 +214,9 @@ class StreamingTableReader(ABC):
187
214
  start = time.monotonic()
188
215
  try:
189
216
  self.process_batch(df, batch_id)
190
- except Exception: # noqa: BLE001 — broad catch intentional: DLQ must capture all failures
217
+ except Exception as exc: # noqa: BLE001 — broad catch intentional: DLQ must capture all failures
218
+ self._consecutive_failures += 1
219
+
191
220
  if self._dead_letter_table is not None:
192
221
  import traceback
193
222
 
@@ -202,8 +231,13 @@ class StreamingTableReader(ABC):
202
231
  exc_info=True,
203
232
  )
204
233
  raise
234
+ self._check_circuit_breaker(exc)
205
235
  return
236
+
237
+ self._check_circuit_breaker(exc)
206
238
  raise
239
+ else:
240
+ self._consecutive_failures = 0
207
241
  duration_ms = (time.monotonic() - start) * 1000
208
242
 
209
243
  if self._metrics_sink is not None:
@@ -3,6 +3,7 @@
3
3
  from databricks4py.quality.base import Expectation, ExpectationResult, QualityReport
4
4
  from databricks4py.quality.expectations import (
5
5
  ColumnExists,
6
+ FreshnessExpectation,
6
7
  InRange,
7
8
  MatchesRegex,
8
9
  NotNull,
@@ -10,11 +11,13 @@ from databricks4py.quality.expectations import (
10
11
  Unique,
11
12
  )
12
13
  from databricks4py.quality.gate import QualityError, QualityGate
14
+ from databricks4py.quality.profiler import ColumnProfile, DataProfile, profile
13
15
 
14
16
  __all__ = [
15
17
  "ColumnExists",
16
18
  "Expectation",
17
19
  "ExpectationResult",
20
+ "FreshnessExpectation",
18
21
  "InRange",
19
22
  "MatchesRegex",
20
23
  "NotNull",
@@ -23,4 +26,8 @@ __all__ = [
23
26
  "QualityReport",
24
27
  "RowCount",
25
28
  "Unique",
29
+ # Profiler
30
+ "ColumnProfile",
31
+ "DataProfile",
32
+ "profile",
26
33
  ]
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from datetime import timedelta
5
6
  from functools import reduce
6
7
  from typing import TYPE_CHECKING
7
8
 
@@ -10,7 +11,15 @@ from databricks4py.quality.base import Expectation, ExpectationResult
10
11
  if TYPE_CHECKING:
11
12
  from pyspark.sql import Column, DataFrame
12
13
 
13
- __all__ = ["ColumnExists", "InRange", "MatchesRegex", "NotNull", "RowCount", "Unique"]
14
+ __all__ = [
15
+ "ColumnExists",
16
+ "FreshnessExpectation",
17
+ "InRange",
18
+ "MatchesRegex",
19
+ "NotNull",
20
+ "RowCount",
21
+ "Unique",
22
+ ]
14
23
 
15
24
 
16
25
  class NotNull(Expectation):
@@ -182,3 +191,83 @@ class ColumnExists(Expectation):
182
191
  if self._dtype:
183
192
  return f"ColumnExists({cols}, dtype={self._dtype!r})"
184
193
  return f"ColumnExists({cols})"
194
+
195
+
196
+ class FreshnessExpectation(Expectation):
197
+ """Validates that a timestamp column contains sufficiently recent data.
198
+
199
+ The table passes when ``max(column) >= now - max_age``. The failing condition
200
+ identifies individual rows older than the cutoff, which is useful for quarantine
201
+ routing via :class:`~databricks4py.quality.gate.QualityGate`.
202
+
203
+ Args:
204
+ column: Timestamp column to check.
205
+ max_age: Maximum allowed age of the most-recent row. A ``timedelta(hours=1)``
206
+ means the newest row must be within the last hour.
207
+
208
+ Example::
209
+
210
+ from datetime import timedelta
211
+ from databricks4py.quality.expectations import FreshnessExpectation
212
+
213
+ expectation = FreshnessExpectation("event_time", max_age=timedelta(hours=6))
214
+ result = expectation.validate(df)
215
+ if not result.passed:
216
+ print(result)
217
+ """
218
+
219
+ def __init__(self, column: str, max_age: timedelta) -> None:
220
+ self._column = column
221
+ self._max_age = max_age
222
+
223
+ def validate(self, df: DataFrame) -> ExpectationResult:
224
+ from datetime import datetime, timezone
225
+
226
+ from pyspark.sql import functions as F
227
+
228
+ total = df.count()
229
+ if total == 0:
230
+ return ExpectationResult(
231
+ expectation=repr(self),
232
+ passed=False,
233
+ total_rows=0,
234
+ failing_rows=0,
235
+ )
236
+
237
+ cutoff = datetime.now(tz=timezone.utc) - self._max_age
238
+
239
+ max_row = df.agg(F.max(F.col(self._column)).alias("max_ts")).first()
240
+ max_ts = max_row["max_ts"] if max_row else None
241
+
242
+ if max_ts is None:
243
+ return ExpectationResult(
244
+ expectation=repr(self),
245
+ passed=False,
246
+ total_rows=total,
247
+ failing_rows=0,
248
+ )
249
+
250
+ max_ts_utc = max_ts.replace(tzinfo=timezone.utc) if max_ts.tzinfo is None else max_ts
251
+ passed = max_ts_utc >= cutoff
252
+
253
+ # Use the same cutoff computed above so row-count and table-level verdict are consistent
254
+ stale_condition = F.col(self._column) < F.lit(cutoff)
255
+ failing = df.where(stale_condition).count()
256
+
257
+ return ExpectationResult(
258
+ expectation=repr(self),
259
+ passed=passed,
260
+ total_rows=total,
261
+ failing_rows=failing,
262
+ )
263
+
264
+ def failing_condition(self) -> Column | None:
265
+ from datetime import datetime, timezone
266
+
267
+ from pyspark.sql import functions as F
268
+
269
+ cutoff = datetime.now(tz=timezone.utc) - self._max_age
270
+ return F.col(self._column) < F.lit(cutoff)
271
+
272
+ def __repr__(self) -> str:
273
+ return f"FreshnessExpectation({self._column!r}, max_age={self._max_age!r})"