delta-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. delta_engine/__init__.py +41 -0
  2. delta_engine/adapters/__init__.py +0 -0
  3. delta_engine/adapters/databricks/__init__.py +8 -0
  4. delta_engine/adapters/databricks/errors.py +53 -0
  5. delta_engine/adapters/databricks/executor.py +120 -0
  6. delta_engine/adapters/databricks/factory.py +21 -0
  7. delta_engine/adapters/databricks/log_config.py +87 -0
  8. delta_engine/adapters/databricks/reader.py +364 -0
  9. delta_engine/adapters/databricks/sql/__init__.py +46 -0
  10. delta_engine/adapters/databricks/sql/compile.py +269 -0
  11. delta_engine/adapters/databricks/sql/dialect.py +18 -0
  12. delta_engine/adapters/databricks/sql/queries.py +167 -0
  13. delta_engine/adapters/databricks/sql/types.py +185 -0
  14. delta_engine/api/__init__.py +7 -0
  15. delta_engine/api/delta_table.py +349 -0
  16. delta_engine/application/__init__.py +23 -0
  17. delta_engine/application/dependency_resolution.py +339 -0
  18. delta_engine/application/desired_tables.py +50 -0
  19. delta_engine/application/engine.py +305 -0
  20. delta_engine/application/errors.py +37 -0
  21. delta_engine/application/failures.py +138 -0
  22. delta_engine/application/ports.py +151 -0
  23. delta_engine/application/properties.py +161 -0
  24. delta_engine/application/rendering.py +352 -0
  25. delta_engine/application/report.py +93 -0
  26. delta_engine/application/validation.py +450 -0
  27. delta_engine/databricks.py +35 -0
  28. delta_engine/domain/__init__.py +0 -0
  29. delta_engine/domain/model/__init__.py +62 -0
  30. delta_engine/domain/model/column.py +41 -0
  31. delta_engine/domain/model/constraints.py +133 -0
  32. delta_engine/domain/model/data_type.py +156 -0
  33. delta_engine/domain/model/qualified_name.py +52 -0
  34. delta_engine/domain/model/table.py +195 -0
  35. delta_engine/domain/model/table_aspect.py +26 -0
  36. delta_engine/domain/plan/__init__.py +93 -0
  37. delta_engine/domain/plan/actions.py +337 -0
  38. delta_engine/domain/plan/diff.py +769 -0
  39. delta_engine/py.typed +0 -0
  40. delta_engine/schema.py +56 -0
  41. delta_engine-0.1.0.dist-info/METADATA +75 -0
  42. delta_engine-0.1.0.dist-info/RECORD +44 -0
  43. delta_engine-0.1.0.dist-info/WHEEL +4 -0
  44. delta_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,41 @@
1
+ """
2
+ delta-engine: declarative schema management for Delta Lake tables.
3
+
4
+ This is the curated runtime entry point. The preferred user imports are::
5
+
6
+ from delta_engine.schema import DeltaTable, Column, Integer
7
+ from delta_engine.databricks import build_engine
8
+ from delta_engine import Engine
9
+
10
+ Only backend-neutral runtime types live here. Schema declarations belong in
11
+ ``delta_engine.schema`` and Databricks helpers belong in ``delta_engine.databricks``.
12
+ """
13
+
14
+ from importlib.metadata import PackageNotFoundError, version as _version
15
+
16
+ from delta_engine.application import (
17
+ Engine,
18
+ Failure,
19
+ SyncFailedError,
20
+ SyncReport,
21
+ TableRunStatus,
22
+ render_diff,
23
+ render_report,
24
+ )
25
+
26
+ try:
27
+ __version__ = _version("delta-engine")
28
+ except PackageNotFoundError: # running from a source tree that is not installed
29
+ __version__ = "0.0.0"
30
+
31
+ # `__version__` is package metadata, not part of the runtime API surface, so it
32
+ # is intentionally not advertised in `__all__`; access it as `delta_engine.__version__`.
33
+ __all__ = [
34
+ "Engine",
35
+ "Failure",
36
+ "SyncFailedError",
37
+ "SyncReport",
38
+ "TableRunStatus",
39
+ "render_diff",
40
+ "render_report",
41
+ ]
File without changes
@@ -0,0 +1,8 @@
1
+ """
2
+ Databricks/Spark adapter package.
3
+
4
+ Import concrete modules directly (``factory``, ``log_config``, ``reader``,
5
+ ``executor``); the public user-facing entry points live in
6
+ :mod:`delta_engine.databricks`. This ``__init__`` stays empty so importing
7
+ the package never pulls PySpark.
8
+ """
@@ -0,0 +1,53 @@
1
+ """
2
+ Translate backend exceptions into neutral summaries for typed failures.
3
+
4
+ Spark raises a heterogeneous set of failures (``Py4JJavaError``,
5
+ ``AnalysisException``, plain Python errors) that varies across runtime
6
+ environments. Both adapter boundaries (reader and executor) reduce any of
7
+ them to the same two facts a failure report needs: the most informative type
8
+ name and a bounded message.
9
+ """
10
+
11
+ from dataclasses import dataclass
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ExceptionSummary:
16
+ """The two facts a typed failure records about a backend exception."""
17
+
18
+ type_name: str
19
+ message: str
20
+
21
+
22
+ def summarize_exception(exception: Exception) -> ExceptionSummary:
23
+ """Summarize an exception as its most informative type name and message head."""
24
+ return ExceptionSummary(_exception_type_name(exception), _message_preview(exception))
25
+
26
+
27
+ def _message_preview(exception: Exception) -> str:
28
+ """Return the first lines of an exception message, bounded for reports."""
29
+ message_head = str(exception)
30
+ return "\n".join(message_head.splitlines()[:5])
31
+
32
+
33
+ def _exception_type_name(exception: Exception) -> str:
34
+ """
35
+ Return the most informative exception class name available.
36
+
37
+ For Py4JJavaError (the primary failure shape on Databricks, where JVM
38
+ exceptions surface through py4j), the underlying Java class is preferred
39
+ over the py4j wrapper — e.g. 'org.apache.spark.sql.AnalysisException'
40
+ rather than 'Py4JJavaError'. Falls back to the Python class name for all
41
+ other exceptions.
42
+ """
43
+ try:
44
+ from py4j.protocol import Py4JJavaError # type: ignore[import]
45
+
46
+ if isinstance(exception, Py4JJavaError):
47
+ try:
48
+ return exception.java_exception.getClass().getName()
49
+ except (AttributeError, TypeError):
50
+ return "Py4JJavaError"
51
+ except ImportError:
52
+ pass
53
+ return type(exception).__name__
@@ -0,0 +1,120 @@
1
+ """
2
+ Execute compiled plans on Databricks/Spark and capture results.
3
+
4
+ Compiles an `ActionPlan` to SQL, runs each statement via a `SparkSession`, and
5
+ returns `ExecutionResult` entries including SQL previews and failure details.
6
+ """
7
+
8
+ from collections.abc import Iterable
9
+ import logging
10
+
11
+ from pyspark.sql import SparkSession
12
+
13
+ from delta_engine.adapters.databricks.errors import summarize_exception
14
+ from delta_engine.adapters.databricks.sql import (
15
+ CompiledAction,
16
+ compile_plan,
17
+ )
18
+ from delta_engine.application.failures import ExecutionFailure
19
+ from delta_engine.application.ports import (
20
+ ExecutionFailed,
21
+ ExecutionResult,
22
+ ExecutionSucceeded,
23
+ ExecutionSummary,
24
+ )
25
+ from delta_engine.domain.model import QualifiedName
26
+ from delta_engine.domain.plan import Action, ActionPlan
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class DatabricksExecutor:
32
+ """Plan executor that runs compiled statements via a Spark session."""
33
+
34
+ def __init__(self, spark: SparkSession) -> None:
35
+ self.spark = spark
36
+
37
+ def execute(self, qualified_name: QualifiedName, plan: ActionPlan) -> ExecutionSummary:
38
+ """
39
+ Execute the plan's actions against ``qualified_name`` and summarize the outcome.
40
+
41
+ Execution stops at the first failure: the actions form a dependency
42
+ chain, and the engine is not transactional, so continuing past a failure
43
+ risks compounding a half-migrated table. The summary covers the actions
44
+ attempted, ending at the one that failed; actions after it are left
45
+ unattempted rather than run against an inconsistent table.
46
+ """
47
+ return _execute_compiled(self.spark, compile_plan(qualified_name, plan))
48
+
49
+
50
+ def _execute_compiled(spark: SparkSession, compiled: Iterable[CompiledAction]) -> ExecutionSummary:
51
+ """
52
+ Run each compiled action in plan order, stopping at the first failure.
53
+
54
+ Holds the stop-on-first-failure loop as a free function so it is testable
55
+ without a Spark session: a unit test passes a fake ``spark`` and pre-built
56
+ ``CompiledAction`` pairs, with no need to inject a compiler.
57
+ """
58
+ results: list[ExecutionResult] = []
59
+ for action_index, compiled_action in enumerate(compiled):
60
+ result = _run_statement(
61
+ spark, compiled_action.action, action_index, compiled_action.statement
62
+ )
63
+ results.append(result)
64
+ if isinstance(result, ExecutionFailed):
65
+ break
66
+ return ExecutionSummary(tuple(results))
67
+
68
+
69
+ def _run_statement(
70
+ spark: SparkSession, action: Action, action_index: int, statement: str
71
+ ) -> ExecutionResult:
72
+ """
73
+ Run a single compiled statement and map its outcome to an `ExecutionResult`.
74
+
75
+ The broad ``except`` is intentional and mirrors the reader's ``fetch_state``:
76
+ Spark raises a heterogeneous set of failures (``Py4JJavaError``,
77
+ ``AnalysisException``, and plain Python errors) that varies across runtime
78
+ environments. The executor's contract is to wrap any failure in an
79
+ ``ExecutionFailed`` so the run can record it and stop cleanly, never to let
80
+ a backend-specific exception escape. Narrowing the catch would reintroduce
81
+ silent propagation of whichever type was missed.
82
+ """
83
+ action_name = type(action).__name__
84
+ preview = _sql_preview(statement)
85
+ try:
86
+ spark.sql(statement)
87
+ except Exception as exception:
88
+ summary = summarize_exception(exception)
89
+ logger.warning("%s failed: %s\nSQL: %s", action_name, summary.message, preview)
90
+ return ExecutionFailed(
91
+ action=action_name,
92
+ failure=ExecutionFailure(
93
+ action_index=action_index,
94
+ exception_type=summary.type_name,
95
+ message=summary.message,
96
+ statement_preview=preview,
97
+ ),
98
+ )
99
+
100
+ logger.info("Executed: %s", action_name)
101
+ return ExecutionSucceeded(
102
+ action=action_name,
103
+ action_index=action_index,
104
+ statement_preview=preview,
105
+ )
106
+
107
+
108
+ def _sql_preview(sql: str, *, max_chars: int = 240) -> str:
109
+ """
110
+ Return a compact, bounded preview of a SQL statement for logs/results.
111
+
112
+ - Normalizes all runs of whitespace to single spaces on one line.
113
+ - Truncates with an ellipsis when longer than max_chars.
114
+
115
+ The bound and formatting are this executor's reporting policy — the preview
116
+ lands in ``statement_preview`` on execution results and in log lines, never
117
+ back in SQL sent to Spark.
118
+ """
119
+ normalized = " ".join(sql.split())
120
+ return normalized if len(normalized) <= max_chars else (normalized[:max_chars] + "…")
@@ -0,0 +1,21 @@
1
+ """Factory for constructing an `Engine` wired to Databricks/Spark adapters."""
2
+
3
+ from pyspark.sql import SparkSession
4
+
5
+ from delta_engine.adapters.databricks.executor import DatabricksExecutor
6
+ from delta_engine.adapters.databricks.reader import DatabricksReader
7
+ from delta_engine.application.engine import Engine
8
+
9
+
10
+ def build_engine(spark: SparkSession) -> Engine:
11
+ """
12
+ Create an engine configured for Databricks.
13
+
14
+ This is the public entry point for end users. It has no logging side effect:
15
+ for the common script/notebook case, call :func:`configure_logging` once at
16
+ startup to install the package's coloured handler; embedders who own their
17
+ own logging simply leave it alone.
18
+ """
19
+ reader = DatabricksReader(spark)
20
+ executor = DatabricksExecutor(spark)
21
+ return Engine(reader=reader, executor=executor)
@@ -0,0 +1,87 @@
1
+ """
2
+ Logging helpers with ANSI-colored level output.
3
+
4
+ Provides a `LevelColorFormatter` and `configure_logging` to install a colored
5
+ stderr handler on the root logger for consistent, readable logs across the
6
+ package.
7
+
8
+ """
9
+
10
+ import logging
11
+ import sys
12
+ from typing import TextIO
13
+
14
+ RESET = "\033[0m"
15
+ YELLOW = "\033[33m"
16
+ RED = "\033[31m"
17
+ BRIGHT_RED = "\033[91m"
18
+
19
+
20
+ class LevelColorFormatter(logging.Formatter):
21
+ """Formatter that colorizes log records based on the level."""
22
+
23
+ def format(self, record: logging.LogRecord) -> str:
24
+ """Apply ANSI color codes to a formatted log message."""
25
+ text = super().format(record)
26
+ if record.levelno == logging.WARNING:
27
+ return f"{YELLOW}{text}{RESET}"
28
+ if record.levelno == logging.ERROR:
29
+ return f"{RED}{text}{RESET}"
30
+ if record.levelno == logging.CRITICAL:
31
+ return f"{BRIGHT_RED}{text}{RESET}"
32
+ return text
33
+
34
+
35
+ class SafeStreamHandler(logging.StreamHandler):
36
+ """
37
+ StreamHandler that tolerates interpreter/pytest teardown.
38
+
39
+ Swallows ValueError raised when the stream is already closed.
40
+ """
41
+
42
+ def emit(self, record: logging.LogRecord) -> None:
43
+ """Emit a log record, swallowing ValueError if the stream is closed."""
44
+ try:
45
+ super().emit(record)
46
+ except ValueError:
47
+ pass
48
+
49
+
50
+ def configure_logging(level: int = logging.INFO, stream: TextIO | None = None) -> None:
51
+ """
52
+ Configure root logging with a colored formatter.
53
+
54
+ Takes over the root logger: any handlers already installed on it are
55
+ removed and replaced with this package's single shutdown-safe coloured
56
+ handler, and noisy third-party loggers (py4j) are quieted. Intended for
57
+ scripts and notebooks that want ready-made output; embedders who own
58
+ their application's logging should configure it themselves and not call
59
+ this function.
60
+
61
+ Args:
62
+ level: Root log level to set (default ``logging.INFO``).
63
+ stream: Destination for log records. Defaults to ``sys.__stderr__``,
64
+ which avoids pytest's captured stream. Notebooks should pass
65
+ ``sys.stdout`` so logs and ``print`` output share one ordered
66
+ stream — otherwise stdout and stderr flush independently and log
67
+ lines can surface after later prints.
68
+
69
+ """
70
+ root = logging.getLogger()
71
+
72
+ root.handlers.clear()
73
+ root.setLevel(level)
74
+
75
+ handler = SafeStreamHandler(stream=stream if stream is not None else sys.__stderr__)
76
+ handler.setFormatter(
77
+ LevelColorFormatter(
78
+ "%(asctime)s | %(levelname)s | %(name)s | %(message)s",
79
+ datefmt="%Y-%m-%d %H:%M:%S",
80
+ )
81
+ )
82
+ root.addHandler(handler)
83
+
84
+ for name in ("py4j", "py4j.clientserver"):
85
+ py4j_logger = logging.getLogger(name)
86
+ py4j_logger.setLevel(logging.WARNING)
87
+ py4j_logger.propagate = False