ml-analytics-tools 0.7.0__tar.gz → 0.7.2__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.
- {ml_analytics_tools-0.7.0/ml_analytics_tools.egg-info → ml_analytics_tools-0.7.2}/PKG-INFO +32 -1
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/README.md +31 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/__init__.py +3 -1
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/data_connector.py +2 -4
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/sf_connector.py +42 -220
- ml_analytics_tools-0.7.2/ml_analytics/spark_connector.py +431 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2/ml_analytics_tools.egg-info}/PKG-INFO +32 -1
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics_tools.egg-info/SOURCES.txt +1 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/pyproject.toml +1 -1
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_db_s3.py +17 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_sf_connector.py +3 -2
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/LICENSE +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/aws_auth.py +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/cli.py +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/gsheet_connector.py +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/model_manager.py +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/model_tools.py +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/s3_connector.py +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/slack_connector.py +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/tunnel_manager.py +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/utils.py +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics_tools.egg-info/dependency_links.txt +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics_tools.egg-info/entry_points.txt +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics_tools.egg-info/requires.txt +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics_tools.egg-info/top_level.txt +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/setup.cfg +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_aws_auth.py +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_gsheet_connector.py +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_identity_column.py +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_model_manager.py +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_model_tools.py +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_s3_redshift_validation.py +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_tunnel_manager.py +0 -0
- {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ml-analytics-tools
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Tools for ML projects and data management
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -56,6 +56,7 @@ arguments.
|
|
|
56
56
|
|
|
57
57
|
- `DataConnector`: run Redshift or Snowflake SQL, load SQL files, unload/load data through S3, and create Redshift tables from DataFrames.
|
|
58
58
|
- `SFConnector`: read Snowflake through Spark and save results to Unity Catalog tables (Databricks). PySpark is imported lazily, so the rest of the package works without it.
|
|
59
|
+
- `SparkTableManager`: source-agnostic Spark / Unity Catalog operations — save a Spark/pandas/polars DataFrame to a Delta table, convert pandas/polars to Spark, run Spark SQL, optimize, comment, drop, or read tables.
|
|
59
60
|
- `S3Connector`: read, write, list, delete, and query S3 data with DuckDB.
|
|
60
61
|
- `GSheet`: read, write, share, and export Google Sheets data.
|
|
61
62
|
- `SlackConnector`: send messages, upload files, and manage simple Slack interactions.
|
|
@@ -239,6 +240,35 @@ variable → Databricks secret. See the
|
|
|
239
240
|
[Snowflake Spark Connector](docs/SF_CONNECTOR_USAGE.md) guide for credential
|
|
240
241
|
setup and all options.
|
|
241
242
|
|
|
243
|
+
### Manage Unity Catalog Tables (`SparkTableManager`)
|
|
244
|
+
|
|
245
|
+
When you already have a DataFrame (from any source) and just need Spark / Unity
|
|
246
|
+
Catalog table operations, use `SparkTableManager`. It accepts Spark, pandas, or
|
|
247
|
+
polars DataFrames and imports PySpark lazily, the same way `SFConnector` does.
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
from ml_analytics import SparkTableManager
|
|
251
|
+
|
|
252
|
+
tm = SparkTableManager(catalog="prod", schema="analytics")
|
|
253
|
+
|
|
254
|
+
# run Spark SQL (Spark DataFrame, or pandas with return_pandas=True)
|
|
255
|
+
df = tm.sql("SELECT * FROM prod.analytics.lessons WHERE country = 'US'")
|
|
256
|
+
|
|
257
|
+
# save a Spark / pandas / polars DataFrame to a Unity Catalog Delta table
|
|
258
|
+
tm.save_to_uc(df, table="lessons_us", comment="US lessons")
|
|
259
|
+
|
|
260
|
+
# convert pandas/polars to Spark (optionally with an explicit schema)
|
|
261
|
+
sdf = tm.to_spark(pandas_df, schema="user_id long, country string")
|
|
262
|
+
|
|
263
|
+
# read back, optimize, or drop
|
|
264
|
+
again = tm.read_table("lessons_us")
|
|
265
|
+
tm.optimize_uc_table("lessons_us", zorder_by="country")
|
|
266
|
+
tm.drop_table("lessons_us")
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
See the [Spark Table Manager](docs/SPARK_CONNECTOR_USAGE.md) guide for all
|
|
270
|
+
options.
|
|
271
|
+
|
|
242
272
|
### Create A Redshift Table From A DataFrame
|
|
243
273
|
|
|
244
274
|
```python
|
|
@@ -339,6 +369,7 @@ slack.send_message(channel="#ml-alerts", text="Training finished")
|
|
|
339
369
|
| [AWS Authentication](docs/AWS_AUTHENTICATION.md) | AWS SSO setup and Python helpers |
|
|
340
370
|
| [CLI Commands](docs/CLI_COMMANDS.md) | Available console commands |
|
|
341
371
|
| [Snowflake Spark Connector](docs/SF_CONNECTOR_USAGE.md) | `SFConnector` credential setup, reads, and writes on Spark/Databricks |
|
|
372
|
+
| [Spark Table Manager](docs/SPARK_CONNECTOR_USAGE.md) | `SparkTableManager` — save DataFrames to Unity Catalog, convert pandas/polars, table maintenance |
|
|
342
373
|
| [Google Sheets](docs/GSHEET_CONNECTOR_USAGE.md) | Sheets setup, sharing, exports, and examples |
|
|
343
374
|
| [Slack](docs/SLACK_CONNECTOR_USAGE.md) | Slack token setup and message/file examples |
|
|
344
375
|
| [Tunnel Manager](docs/TUNNEL_MANAGER.md) | SSH tunnel configuration and CLI usage |
|
|
@@ -17,6 +17,7 @@ arguments.
|
|
|
17
17
|
|
|
18
18
|
- `DataConnector`: run Redshift or Snowflake SQL, load SQL files, unload/load data through S3, and create Redshift tables from DataFrames.
|
|
19
19
|
- `SFConnector`: read Snowflake through Spark and save results to Unity Catalog tables (Databricks). PySpark is imported lazily, so the rest of the package works without it.
|
|
20
|
+
- `SparkTableManager`: source-agnostic Spark / Unity Catalog operations — save a Spark/pandas/polars DataFrame to a Delta table, convert pandas/polars to Spark, run Spark SQL, optimize, comment, drop, or read tables.
|
|
20
21
|
- `S3Connector`: read, write, list, delete, and query S3 data with DuckDB.
|
|
21
22
|
- `GSheet`: read, write, share, and export Google Sheets data.
|
|
22
23
|
- `SlackConnector`: send messages, upload files, and manage simple Slack interactions.
|
|
@@ -200,6 +201,35 @@ variable → Databricks secret. See the
|
|
|
200
201
|
[Snowflake Spark Connector](docs/SF_CONNECTOR_USAGE.md) guide for credential
|
|
201
202
|
setup and all options.
|
|
202
203
|
|
|
204
|
+
### Manage Unity Catalog Tables (`SparkTableManager`)
|
|
205
|
+
|
|
206
|
+
When you already have a DataFrame (from any source) and just need Spark / Unity
|
|
207
|
+
Catalog table operations, use `SparkTableManager`. It accepts Spark, pandas, or
|
|
208
|
+
polars DataFrames and imports PySpark lazily, the same way `SFConnector` does.
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from ml_analytics import SparkTableManager
|
|
212
|
+
|
|
213
|
+
tm = SparkTableManager(catalog="prod", schema="analytics")
|
|
214
|
+
|
|
215
|
+
# run Spark SQL (Spark DataFrame, or pandas with return_pandas=True)
|
|
216
|
+
df = tm.sql("SELECT * FROM prod.analytics.lessons WHERE country = 'US'")
|
|
217
|
+
|
|
218
|
+
# save a Spark / pandas / polars DataFrame to a Unity Catalog Delta table
|
|
219
|
+
tm.save_to_uc(df, table="lessons_us", comment="US lessons")
|
|
220
|
+
|
|
221
|
+
# convert pandas/polars to Spark (optionally with an explicit schema)
|
|
222
|
+
sdf = tm.to_spark(pandas_df, schema="user_id long, country string")
|
|
223
|
+
|
|
224
|
+
# read back, optimize, or drop
|
|
225
|
+
again = tm.read_table("lessons_us")
|
|
226
|
+
tm.optimize_uc_table("lessons_us", zorder_by="country")
|
|
227
|
+
tm.drop_table("lessons_us")
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
See the [Spark Table Manager](docs/SPARK_CONNECTOR_USAGE.md) guide for all
|
|
231
|
+
options.
|
|
232
|
+
|
|
203
233
|
### Create A Redshift Table From A DataFrame
|
|
204
234
|
|
|
205
235
|
```python
|
|
@@ -300,6 +330,7 @@ slack.send_message(channel="#ml-alerts", text="Training finished")
|
|
|
300
330
|
| [AWS Authentication](docs/AWS_AUTHENTICATION.md) | AWS SSO setup and Python helpers |
|
|
301
331
|
| [CLI Commands](docs/CLI_COMMANDS.md) | Available console commands |
|
|
302
332
|
| [Snowflake Spark Connector](docs/SF_CONNECTOR_USAGE.md) | `SFConnector` credential setup, reads, and writes on Spark/Databricks |
|
|
333
|
+
| [Spark Table Manager](docs/SPARK_CONNECTOR_USAGE.md) | `SparkTableManager` — save DataFrames to Unity Catalog, convert pandas/polars, table maintenance |
|
|
303
334
|
| [Google Sheets](docs/GSHEET_CONNECTOR_USAGE.md) | Sheets setup, sharing, exports, and examples |
|
|
304
335
|
| [Slack](docs/SLACK_CONNECTOR_USAGE.md) | Slack token setup and message/file examples |
|
|
305
336
|
| [Tunnel Manager](docs/TUNNEL_MANAGER.md) | SSH tunnel configuration and CLI usage |
|
|
@@ -8,8 +8,9 @@ from .aws_auth import ensure_aws_authenticated, ensure_aws_sso_login
|
|
|
8
8
|
from .data_connector import DataConnector
|
|
9
9
|
from .gsheet_connector import GSheet
|
|
10
10
|
from .s3_connector import S3Connector
|
|
11
|
-
from .sf_connector import SFConnector
|
|
11
|
+
from .sf_connector import SFConnector
|
|
12
12
|
from .slack_connector import SlackConnector
|
|
13
|
+
from .spark_connector import SparkTableManager, get_spark
|
|
13
14
|
from .utils import (
|
|
14
15
|
execute_sql_scripts,
|
|
15
16
|
find_project_root,
|
|
@@ -82,4 +83,5 @@ __all__ = [
|
|
|
82
83
|
"S3Connector",
|
|
83
84
|
"SFConnector",
|
|
84
85
|
"SlackConnector",
|
|
86
|
+
"SparkTableManager",
|
|
85
87
|
]
|
|
@@ -678,10 +678,8 @@ class DataConnector:
|
|
|
678
678
|
self.cursor = self.connection.cursor()
|
|
679
679
|
self._start_idle_timer() # Start idle timer on new connection
|
|
680
680
|
|
|
681
|
-
#
|
|
682
|
-
|
|
683
|
-
self.s3 = self._get_s3_for_bucket(self._s3_bucket)
|
|
684
|
-
|
|
681
|
+
# Defer S3 connector initialization until an S3 operation is actually required.
|
|
682
|
+
# This avoids AWS credential refresh/SSO login when only the database connection is used.
|
|
685
683
|
except Exception as e:
|
|
686
684
|
log_and_raise_error(self._logger, f"Failed to connect to {self.engine.title()}: {e}")
|
|
687
685
|
|
|
@@ -18,6 +18,7 @@ from .data_connector import (
|
|
|
18
18
|
_load_private_key_pem_for_spark,
|
|
19
19
|
_snowflake_secret_scope,
|
|
20
20
|
)
|
|
21
|
+
from .spark_connector import SparkTableManager, get_spark
|
|
21
22
|
from .utils import (
|
|
22
23
|
format_sql_ignoring_comments,
|
|
23
24
|
get_logger,
|
|
@@ -26,70 +27,8 @@ from .utils import (
|
|
|
26
27
|
resolve_sql_query_paths,
|
|
27
28
|
)
|
|
28
29
|
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
# without PySpark.
|
|
32
|
-
_spark_ctx = None
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def get_spark():
|
|
36
|
-
"""
|
|
37
|
-
Get or create a cached Spark session that works both locally and on Databricks.
|
|
38
|
-
|
|
39
|
-
Neither PySpark nor Databricks Connect is a dependency of this package; both
|
|
40
|
-
are imported lazily so the rest of the package stays usable without them.
|
|
41
|
-
|
|
42
|
-
Resolution order:
|
|
43
|
-
|
|
44
|
-
1. Reuse an active :class:`SparkSession` if one exists. This is the normal
|
|
45
|
-
case inside a Databricks notebook/cluster, where ``spark`` is already
|
|
46
|
-
provided, so we attach to it rather than spinning up a new one.
|
|
47
|
-
2. Otherwise create a Databricks Connect session
|
|
48
|
-
(``DatabricksSession.builder.getOrCreate()``). This is the local-dev case:
|
|
49
|
-
it connects to a remote cluster/serverless using your Databricks config
|
|
50
|
-
(profile / env vars), so no notebook boilerplate is needed.
|
|
51
|
-
3. Otherwise fall back to a plain local ``SparkSession``.
|
|
52
|
-
|
|
53
|
-
This means a single ``spark = get_spark()`` line behaves correctly whether the
|
|
54
|
-
code runs locally via Databricks Connect or as a notebook on Databricks.
|
|
55
|
-
"""
|
|
56
|
-
global _spark_ctx
|
|
57
|
-
if _spark_ctx is not None:
|
|
58
|
-
return _spark_ctx
|
|
59
|
-
|
|
60
|
-
# 1. Reuse an active session (the normal case inside a Databricks notebook/cluster).
|
|
61
|
-
try:
|
|
62
|
-
from pyspark.sql import SparkSession
|
|
63
|
-
|
|
64
|
-
active = SparkSession.getActiveSession()
|
|
65
|
-
if active is not None:
|
|
66
|
-
_spark_ctx = active
|
|
67
|
-
return _spark_ctx
|
|
68
|
-
except ImportError:
|
|
69
|
-
# PySpark itself isn't installed; Databricks Connect (below) ships its own.
|
|
70
|
-
pass
|
|
71
|
-
|
|
72
|
-
# 2. Try Databricks Connect (local dev against a remote cluster/serverless).
|
|
73
|
-
try:
|
|
74
|
-
from databricks.connect import DatabricksSession
|
|
75
|
-
|
|
76
|
-
_spark_ctx = DatabricksSession.builder.getOrCreate()
|
|
77
|
-
return _spark_ctx
|
|
78
|
-
except ImportError:
|
|
79
|
-
pass
|
|
80
|
-
|
|
81
|
-
# 3. Fall back to a plain local Spark session.
|
|
82
|
-
try:
|
|
83
|
-
from pyspark.sql import SparkSession
|
|
84
|
-
except ImportError as exc:
|
|
85
|
-
raise ImportError(
|
|
86
|
-
"SFConnector needs a Spark session but neither PySpark nor Databricks "
|
|
87
|
-
"Connect is available. Run it on a Spark runtime (e.g. Databricks) or "
|
|
88
|
-
"install one locally with `pip install databricks-connect`."
|
|
89
|
-
) from exc
|
|
90
|
-
|
|
91
|
-
_spark_ctx = SparkSession.builder.appName("ml_analytics").getOrCreate()
|
|
92
|
-
return _spark_ctx
|
|
30
|
+
# Re-exported for backward compatibility; the canonical home is spark_connector.
|
|
31
|
+
__all__ = ["SFConnector", "get_spark"]
|
|
93
32
|
|
|
94
33
|
|
|
95
34
|
def _snowflake_account_url(account: str) -> str:
|
|
@@ -171,6 +110,9 @@ class SFConnector:
|
|
|
171
110
|
self.source_format = source_format
|
|
172
111
|
self.extra_options = dict(extra_options or {})
|
|
173
112
|
self._spark = spark
|
|
113
|
+
# Engine-agnostic Unity Catalog table operations are delegated here so the
|
|
114
|
+
# same logic backs SFConnector and a standalone SparkTableManager.
|
|
115
|
+
self._tables = SparkTableManager(spark=spark, logger=self._logger)
|
|
174
116
|
|
|
175
117
|
self._secret_scope = _snowflake_secret_scope(secret_scope, user=user)
|
|
176
118
|
|
|
@@ -231,6 +173,8 @@ class SFConnector:
|
|
|
231
173
|
if self._spark is not None:
|
|
232
174
|
return self._spark
|
|
233
175
|
self._spark = get_spark()
|
|
176
|
+
# Keep the delegated table manager pointed at the same session.
|
|
177
|
+
self._tables._spark = self._spark
|
|
234
178
|
return self._spark
|
|
235
179
|
|
|
236
180
|
def spark_options(self, include_private_key: bool = True) -> dict[str, str]:
|
|
@@ -590,84 +534,25 @@ class SFConnector:
|
|
|
590
534
|
|
|
591
535
|
return results if return_all else last_df
|
|
592
536
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
if "." in table:
|
|
601
|
-
return table
|
|
602
|
-
parts = [part for part in (catalog, schema, table) if part]
|
|
603
|
-
return ".".join(parts)
|
|
604
|
-
|
|
605
|
-
@staticmethod
|
|
606
|
-
def _zorder_clause(zorder_by=None) -> str:
|
|
607
|
-
"""Build the optional Delta ZORDER BY clause."""
|
|
608
|
-
if not zorder_by:
|
|
609
|
-
return ""
|
|
610
|
-
if isinstance(zorder_by, str):
|
|
611
|
-
columns = [column.strip() for column in zorder_by.split(",")]
|
|
612
|
-
else:
|
|
613
|
-
columns = [str(column).strip() for column in zorder_by]
|
|
614
|
-
columns = [column for column in columns if column]
|
|
615
|
-
if not columns:
|
|
616
|
-
return ""
|
|
617
|
-
return f" ZORDER BY ({', '.join(columns)})"
|
|
618
|
-
|
|
619
|
-
@staticmethod
|
|
620
|
-
def _sql_string_literal(value: str) -> str:
|
|
621
|
-
"""Escape a value for use inside a single-quoted SQL string literal."""
|
|
622
|
-
return str(value).replace("'", "''")
|
|
537
|
+
# Engine-agnostic Unity Catalog helpers live on SparkTableManager. They are
|
|
538
|
+
# re-exposed here (as static references / thin delegators) so SFConnector's
|
|
539
|
+
# public API is unchanged for callers that read from Snowflake and persist to
|
|
540
|
+
# Unity Catalog in one place.
|
|
541
|
+
_qualified_uc_name = staticmethod(SparkTableManager._qualified_uc_name)
|
|
542
|
+
_zorder_clause = staticmethod(SparkTableManager._zorder_clause)
|
|
543
|
+
_sql_string_literal = staticmethod(SparkTableManager._sql_string_literal)
|
|
623
544
|
|
|
624
545
|
def set_uc_table_comment(self, table: str, comment: str, schema: str = None, catalog: str = None, spark=None):
|
|
625
|
-
"""
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
----------
|
|
630
|
-
table
|
|
631
|
-
Table name. May be fully qualified.
|
|
632
|
-
comment
|
|
633
|
-
Comment text to store.
|
|
634
|
-
schema, catalog
|
|
635
|
-
Optional qualifiers when ``table`` is not fully qualified.
|
|
636
|
-
spark
|
|
637
|
-
Optional SparkSession to use. Defaults to this connector's Spark session.
|
|
638
|
-
"""
|
|
639
|
-
full_name = self._qualified_uc_name(table, schema=schema, catalog=catalog)
|
|
640
|
-
spark = spark or self._get_spark()
|
|
641
|
-
escaped_comment = self._sql_string_literal(comment)
|
|
642
|
-
try:
|
|
643
|
-
spark.sql(f"ALTER TABLE {full_name} SET TBLPROPERTIES ('comment' = '{escaped_comment}')")
|
|
644
|
-
except Exception as e:
|
|
645
|
-
log_and_raise_error(self._logger, f"Error setting comment for Unity Catalog table '{full_name}': {e}")
|
|
646
|
-
self._logger.info(f"Comment set for Unity Catalog table '{full_name}'.")
|
|
546
|
+
"""Set a Unity Catalog table comment. See :meth:`SparkTableManager.set_uc_table_comment`."""
|
|
547
|
+
return self._tables.set_uc_table_comment(
|
|
548
|
+
table, comment, schema=schema, catalog=catalog, spark=spark or self._get_spark()
|
|
549
|
+
)
|
|
647
550
|
|
|
648
551
|
def optimize_uc_table(self, table: str, schema: str = None, catalog: str = None, zorder_by=None, spark=None):
|
|
649
|
-
"""
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
----------
|
|
654
|
-
table
|
|
655
|
-
Table name. May be fully qualified.
|
|
656
|
-
schema, catalog
|
|
657
|
-
Optional qualifiers when ``table`` is not fully qualified.
|
|
658
|
-
zorder_by
|
|
659
|
-
Optional column or columns for ``ZORDER BY``.
|
|
660
|
-
spark
|
|
661
|
-
Optional SparkSession to use. Defaults to this connector's Spark session.
|
|
662
|
-
"""
|
|
663
|
-
full_name = self._qualified_uc_name(table, schema=schema, catalog=catalog)
|
|
664
|
-
spark = spark or self._get_spark()
|
|
665
|
-
optimize_sql = f"OPTIMIZE {full_name}{self._zorder_clause(zorder_by)}"
|
|
666
|
-
try:
|
|
667
|
-
spark.sql(optimize_sql)
|
|
668
|
-
except Exception as e:
|
|
669
|
-
log_and_raise_error(self._logger, f"Error optimizing Unity Catalog table '{full_name}': {e}")
|
|
670
|
-
self._logger.info(f"Table '{full_name}' optimized.")
|
|
552
|
+
"""Run Delta ``OPTIMIZE`` on a Unity Catalog table. See :meth:`SparkTableManager.optimize_uc_table`."""
|
|
553
|
+
return self._tables.optimize_uc_table(
|
|
554
|
+
table, schema=schema, catalog=catalog, zorder_by=zorder_by, spark=spark or self._get_spark()
|
|
555
|
+
)
|
|
671
556
|
|
|
672
557
|
def save_to_uc(
|
|
673
558
|
self,
|
|
@@ -682,90 +567,27 @@ class SFConnector:
|
|
|
682
567
|
comment: str = None,
|
|
683
568
|
drop_existing: bool = True,
|
|
684
569
|
overwrite_schema: bool = True,
|
|
570
|
+
spark_schema=None,
|
|
685
571
|
):
|
|
686
572
|
"""
|
|
687
573
|
Write a Spark DataFrame to a Databricks Unity Catalog table.
|
|
688
574
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
the
|
|
692
|
-
|
|
693
|
-
Parameters
|
|
694
|
-
----------
|
|
695
|
-
df : pyspark.sql.DataFrame
|
|
696
|
-
DataFrame to write.
|
|
697
|
-
table : str
|
|
698
|
-
Destination table name. May be fully qualified
|
|
699
|
-
(``catalog.schema.table``), in which case ``schema`` / ``catalog``
|
|
700
|
-
are ignored.
|
|
701
|
-
schema, catalog : str, optional
|
|
702
|
-
Unity Catalog schema and catalog to qualify ``table`` with.
|
|
703
|
-
mode : str, optional
|
|
704
|
-
Spark write mode: 'overwrite', 'append', 'ignore', or 'error'.
|
|
705
|
-
Defaults to 'overwrite'.
|
|
706
|
-
optimize : bool, optional
|
|
707
|
-
If True, run ``OPTIMIZE`` after saving. Defaults to True.
|
|
708
|
-
zorder_by : str or list[str], optional
|
|
709
|
-
Optional columns for Delta ``ZORDER BY`` during optimize.
|
|
710
|
-
merge_schema : bool, optional
|
|
711
|
-
If True, writes as Delta with ``mergeSchema=true`` (used for appends and
|
|
712
|
-
whenever the schema is not being overwritten). Defaults to True. Ignored on
|
|
713
|
-
an overwrite when ``overwrite_schema`` is True (the two are mutually
|
|
714
|
-
exclusive in Delta).
|
|
715
|
-
comment : str, optional
|
|
716
|
-
Optional table comment stored as a Unity Catalog table property.
|
|
717
|
-
drop_existing : bool, optional
|
|
718
|
-
If True, ``DROP TABLE IF EXISTS`` the destination before writing so the
|
|
719
|
-
table is fully recreated from this DataFrame (removed columns disappear,
|
|
720
|
-
and table properties / grants / history are reset). Defaults to True.
|
|
721
|
-
Set to False to preserve the existing table — required for ``mode='append'``,
|
|
722
|
-
which would otherwise drop the table on every call.
|
|
723
|
-
overwrite_schema : bool, optional
|
|
724
|
-
If True and ``mode='overwrite'``, writes with Delta ``overwriteSchema=true``
|
|
725
|
-
so the table schema is replaced (columns absent from this DataFrame are
|
|
726
|
-
dropped) rather than merged. Defaults to True. Has no effect on non-overwrite
|
|
727
|
-
modes.
|
|
728
|
-
|
|
729
|
-
Returns
|
|
730
|
-
-------
|
|
731
|
-
pyspark.sql.DataFrame
|
|
732
|
-
A DataFrame reading back from the saved Unity Catalog Delta table
|
|
733
|
-
(``spark.table(full_name)``), not the source DataFrame. Downstream actions
|
|
734
|
-
then scan the fast Delta table instead of re-running the original read.
|
|
575
|
+
Thin delegator to :meth:`SparkTableManager.save_to_uc`; see that method
|
|
576
|
+
for the full parameter and return-value documentation. Provided here so
|
|
577
|
+
callers can read from Snowflake and persist the result through a single
|
|
578
|
+
``SFConnector`` instance.
|
|
735
579
|
"""
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
writer = df.write.format("delta")
|
|
751
|
-
# overwriteSchema and mergeSchema are mutually exclusive in Delta. On an
|
|
752
|
-
# overwrite, replacing the schema (so removed columns are dropped) takes
|
|
753
|
-
# precedence; mergeSchema is used otherwise (e.g. evolving on append).
|
|
754
|
-
if mode == "overwrite" and overwrite_schema:
|
|
755
|
-
writer = writer.option("overwriteSchema", "true")
|
|
756
|
-
elif merge_schema:
|
|
757
|
-
writer = writer.option("mergeSchema", "true")
|
|
758
|
-
writer.mode(mode).saveAsTable(full_name)
|
|
759
|
-
except Exception as e:
|
|
760
|
-
log_and_raise_error(self._logger, f"Error writing to Unity Catalog table '{full_name}': {e}")
|
|
761
|
-
self._logger.info(f"Table '{full_name}' written to Unity Catalog (mode={mode}).")
|
|
762
|
-
|
|
763
|
-
if comment is not None:
|
|
764
|
-
self.set_uc_table_comment(full_name, comment, spark=spark)
|
|
765
|
-
|
|
766
|
-
if optimize:
|
|
767
|
-
self.optimize_uc_table(full_name, zorder_by=zorder_by, spark=spark)
|
|
768
|
-
|
|
769
|
-
# Return a DataFrame backed by the just-written Delta table so callers read
|
|
770
|
-
# from fast Unity Catalog storage rather than re-executing the Snowflake read.
|
|
771
|
-
return spark.table(full_name)
|
|
580
|
+
return self._tables.save_to_uc(
|
|
581
|
+
df,
|
|
582
|
+
table,
|
|
583
|
+
schema=schema,
|
|
584
|
+
catalog=catalog,
|
|
585
|
+
mode=mode,
|
|
586
|
+
optimize=optimize,
|
|
587
|
+
zorder_by=zorder_by,
|
|
588
|
+
merge_schema=merge_schema,
|
|
589
|
+
comment=comment,
|
|
590
|
+
drop_existing=drop_existing,
|
|
591
|
+
overwrite_schema=overwrite_schema,
|
|
592
|
+
spark_schema=spark_schema,
|
|
593
|
+
)
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic Spark / Databricks Unity Catalog operations.
|
|
3
|
+
|
|
4
|
+
This module holds engine-agnostic Spark helpers that don't care where the data
|
|
5
|
+
came from: a shared :func:`get_spark` session factory and
|
|
6
|
+
:class:`SparkTableManager`, which writes/optimizes/comments/drops Databricks
|
|
7
|
+
Unity Catalog Delta tables given any Spark DataFrame.
|
|
8
|
+
|
|
9
|
+
:class:`ml_analytics.sf_connector.SFConnector` reads from Snowflake and then
|
|
10
|
+
delegates its table-management methods here, so the same logic backs both a
|
|
11
|
+
plain ``SparkTableManager()`` and ``SFConnector(...).save_to_uc(...)``.
|
|
12
|
+
|
|
13
|
+
PySpark is intentionally NOT a dependency of this package and is imported
|
|
14
|
+
lazily, only when a method that actually needs a Spark session is called. This
|
|
15
|
+
keeps the rest of the package usable in environments without Spark installed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .utils import get_logger, log_and_raise_error
|
|
19
|
+
|
|
20
|
+
# Cached Spark session shared across SparkTableManager / SFConnector instances.
|
|
21
|
+
# Populated lazily by get_spark(); never created at import time so the package
|
|
22
|
+
# stays importable without PySpark.
|
|
23
|
+
_spark_ctx = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_spark():
|
|
27
|
+
"""
|
|
28
|
+
Get or create a cached Spark session that works both locally and on Databricks.
|
|
29
|
+
|
|
30
|
+
Neither PySpark nor Databricks Connect is a dependency of this package; both
|
|
31
|
+
are imported lazily so the rest of the package stays usable without them.
|
|
32
|
+
|
|
33
|
+
Resolution order:
|
|
34
|
+
|
|
35
|
+
1. Reuse an active :class:`SparkSession` if one exists. This is the normal
|
|
36
|
+
case inside a Databricks notebook/cluster, where ``spark`` is already
|
|
37
|
+
provided, so we attach to it rather than spinning up a new one.
|
|
38
|
+
2. Otherwise create a Databricks Connect session
|
|
39
|
+
(``DatabricksSession.builder.getOrCreate()``). This is the local-dev case:
|
|
40
|
+
it connects to a remote cluster/serverless using your Databricks config
|
|
41
|
+
(profile / env vars), so no notebook boilerplate is needed.
|
|
42
|
+
3. Otherwise fall back to a plain local ``SparkSession``.
|
|
43
|
+
|
|
44
|
+
This means a single ``spark = get_spark()`` line behaves correctly whether the
|
|
45
|
+
code runs locally via Databricks Connect or as a notebook on Databricks.
|
|
46
|
+
"""
|
|
47
|
+
global _spark_ctx
|
|
48
|
+
if _spark_ctx is not None:
|
|
49
|
+
return _spark_ctx
|
|
50
|
+
|
|
51
|
+
# 1. Reuse an active session (the normal case inside a Databricks notebook/cluster).
|
|
52
|
+
try:
|
|
53
|
+
from pyspark.sql import SparkSession
|
|
54
|
+
|
|
55
|
+
active = SparkSession.getActiveSession()
|
|
56
|
+
if active is not None:
|
|
57
|
+
_spark_ctx = active
|
|
58
|
+
return _spark_ctx
|
|
59
|
+
except ImportError:
|
|
60
|
+
# PySpark itself isn't installed; Databricks Connect (below) ships its own.
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
# 2. Try Databricks Connect (local dev against a remote cluster/serverless).
|
|
64
|
+
try:
|
|
65
|
+
from databricks.connect import DatabricksSession
|
|
66
|
+
|
|
67
|
+
_spark_ctx = DatabricksSession.builder.getOrCreate()
|
|
68
|
+
return _spark_ctx
|
|
69
|
+
except ImportError:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
# 3. Fall back to a plain local Spark session.
|
|
73
|
+
try:
|
|
74
|
+
from pyspark.sql import SparkSession
|
|
75
|
+
except ImportError as exc:
|
|
76
|
+
raise ImportError(
|
|
77
|
+
"A Spark session is needed but neither PySpark nor Databricks Connect "
|
|
78
|
+
"is available. Run on a Spark runtime (e.g. Databricks) or install one "
|
|
79
|
+
"locally with `pip install databricks-connect`."
|
|
80
|
+
) from exc
|
|
81
|
+
|
|
82
|
+
_spark_ctx = SparkSession.builder.appName("ml_analytics").getOrCreate()
|
|
83
|
+
return _spark_ctx
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SparkTableManager:
|
|
87
|
+
"""
|
|
88
|
+
Read and manage Databricks Unity Catalog (Delta) tables through Spark.
|
|
89
|
+
|
|
90
|
+
This class is source-agnostic: give it any Spark DataFrame and it writes,
|
|
91
|
+
optimizes, comments, or drops the corresponding managed Unity Catalog table.
|
|
92
|
+
It performs no Snowflake/Redshift reads of its own — pair it with
|
|
93
|
+
:class:`ml_analytics.data_connector.DataConnector` or
|
|
94
|
+
:class:`ml_analytics.sf_connector.SFConnector` to produce the DataFrame.
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
catalog, schema : str, optional
|
|
99
|
+
Default Unity Catalog catalog/schema used to qualify unqualified table
|
|
100
|
+
names. An explicit ``catalog`` / ``schema`` passed to a method, or a
|
|
101
|
+
fully-qualified (dotted) table name, overrides these.
|
|
102
|
+
spark : SparkSession, optional
|
|
103
|
+
Existing Spark session to reuse. If omitted, the active session is used,
|
|
104
|
+
or one is created on first use via :func:`get_spark`.
|
|
105
|
+
logger : logging.Logger, optional
|
|
106
|
+
Logger to use. Defaults to a ``"Spark Table Manager"`` logger.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, *, catalog=None, schema=None, spark=None, logger=None):
|
|
110
|
+
self.catalog = catalog
|
|
111
|
+
self.schema = schema
|
|
112
|
+
self._spark = spark
|
|
113
|
+
self._logger = logger or get_logger("Spark Table Manager")
|
|
114
|
+
|
|
115
|
+
def _get_spark(self):
|
|
116
|
+
"""Return the Spark session: the one passed in, or the cached shared one."""
|
|
117
|
+
if self._spark is not None:
|
|
118
|
+
return self._spark
|
|
119
|
+
self._spark = get_spark()
|
|
120
|
+
return self._spark
|
|
121
|
+
|
|
122
|
+
def to_spark(self, df, schema=None, spark=None):
|
|
123
|
+
"""
|
|
124
|
+
Return a Spark DataFrame, converting from pandas or polars when needed.
|
|
125
|
+
|
|
126
|
+
A pandas DataFrame is converted via ``spark.createDataFrame``; a polars
|
|
127
|
+
DataFrame is first turned into pandas. Anything else (already a Spark
|
|
128
|
+
DataFrame) is returned unchanged, so no Spark session is created for the
|
|
129
|
+
passthrough case.
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
df
|
|
134
|
+
A pandas, polars, or Spark DataFrame.
|
|
135
|
+
schema
|
|
136
|
+
Optional explicit Spark schema for the conversion, passed straight to
|
|
137
|
+
``spark.createDataFrame(..., schema=schema)``. Accepts a
|
|
138
|
+
``pyspark.sql.types.StructType`` or a DDL-style string (e.g.
|
|
139
|
+
``"id long, name string"``). Use this when Spark's automatic type
|
|
140
|
+
inference is lossy (all-null columns, mixed dtypes, tz-aware datetimes).
|
|
141
|
+
Ignored when ``df`` is already a Spark DataFrame.
|
|
142
|
+
spark
|
|
143
|
+
Optional SparkSession to use for the conversion. Defaults to this
|
|
144
|
+
manager's Spark session.
|
|
145
|
+
"""
|
|
146
|
+
import pandas as pd
|
|
147
|
+
|
|
148
|
+
is_pandas = isinstance(df, pd.DataFrame)
|
|
149
|
+
is_polars = False
|
|
150
|
+
try:
|
|
151
|
+
import polars as pl
|
|
152
|
+
|
|
153
|
+
is_polars = isinstance(df, pl.DataFrame)
|
|
154
|
+
except ImportError:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
if not (is_pandas or is_polars):
|
|
158
|
+
# Assume it's already a Spark DataFrame; don't spin up a session.
|
|
159
|
+
return df
|
|
160
|
+
|
|
161
|
+
spark = spark or self._get_spark()
|
|
162
|
+
pandas_df = df.to_pandas() if is_polars else df
|
|
163
|
+
try:
|
|
164
|
+
if schema is not None:
|
|
165
|
+
return spark.createDataFrame(pandas_df, schema=schema)
|
|
166
|
+
return spark.createDataFrame(pandas_df)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
log_and_raise_error(self._logger, f"Error converting DataFrame to Spark: {e}")
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _qualified_uc_name(table: str, schema: str = None, catalog: str = None) -> str:
|
|
172
|
+
"""Build a Unity Catalog table identifier from its parts.
|
|
173
|
+
|
|
174
|
+
A ``table`` that already contains dots is treated as fully qualified and
|
|
175
|
+
returned as-is; otherwise ``catalog`` / ``schema`` are prepended when given.
|
|
176
|
+
"""
|
|
177
|
+
if "." in table:
|
|
178
|
+
return table
|
|
179
|
+
parts = [part for part in (catalog, schema, table) if part]
|
|
180
|
+
return ".".join(parts)
|
|
181
|
+
|
|
182
|
+
def _resolve_uc_name(self, table: str, schema: str = None, catalog: str = None) -> str:
|
|
183
|
+
"""Qualify ``table``, falling back to the manager's default schema/catalog."""
|
|
184
|
+
return self._qualified_uc_name(
|
|
185
|
+
table,
|
|
186
|
+
schema=schema if schema is not None else self.schema,
|
|
187
|
+
catalog=catalog if catalog is not None else self.catalog,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _zorder_clause(zorder_by=None) -> str:
|
|
192
|
+
"""Build the optional Delta ZORDER BY clause."""
|
|
193
|
+
if not zorder_by:
|
|
194
|
+
return ""
|
|
195
|
+
if isinstance(zorder_by, str):
|
|
196
|
+
columns = [column.strip() for column in zorder_by.split(",")]
|
|
197
|
+
else:
|
|
198
|
+
columns = [str(column).strip() for column in zorder_by]
|
|
199
|
+
columns = [column for column in columns if column]
|
|
200
|
+
if not columns:
|
|
201
|
+
return ""
|
|
202
|
+
return f" ZORDER BY ({', '.join(columns)})"
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _sql_string_literal(value: str) -> str:
|
|
206
|
+
"""Escape a value for use inside a single-quoted SQL string literal."""
|
|
207
|
+
return str(value).replace("'", "''")
|
|
208
|
+
|
|
209
|
+
def set_uc_table_comment(self, table: str, comment: str, schema: str = None, catalog: str = None, spark=None):
|
|
210
|
+
"""
|
|
211
|
+
Set a Unity Catalog table comment using Databricks table properties.
|
|
212
|
+
|
|
213
|
+
Parameters
|
|
214
|
+
----------
|
|
215
|
+
table
|
|
216
|
+
Table name. May be fully qualified.
|
|
217
|
+
comment
|
|
218
|
+
Comment text to store.
|
|
219
|
+
schema, catalog
|
|
220
|
+
Optional qualifiers when ``table`` is not fully qualified.
|
|
221
|
+
spark
|
|
222
|
+
Optional SparkSession to use. Defaults to this manager's Spark session.
|
|
223
|
+
"""
|
|
224
|
+
full_name = self._resolve_uc_name(table, schema=schema, catalog=catalog)
|
|
225
|
+
spark = spark or self._get_spark()
|
|
226
|
+
escaped_comment = self._sql_string_literal(comment)
|
|
227
|
+
try:
|
|
228
|
+
spark.sql(f"ALTER TABLE {full_name} SET TBLPROPERTIES ('comment' = '{escaped_comment}')")
|
|
229
|
+
except Exception as e:
|
|
230
|
+
log_and_raise_error(self._logger, f"Error setting comment for Unity Catalog table '{full_name}': {e}")
|
|
231
|
+
self._logger.info(f"Comment set for Unity Catalog table '{full_name}'.")
|
|
232
|
+
|
|
233
|
+
def optimize_uc_table(self, table: str, schema: str = None, catalog: str = None, zorder_by=None, spark=None):
|
|
234
|
+
"""
|
|
235
|
+
Run Databricks Delta ``OPTIMIZE`` on a Unity Catalog table.
|
|
236
|
+
|
|
237
|
+
Parameters
|
|
238
|
+
----------
|
|
239
|
+
table
|
|
240
|
+
Table name. May be fully qualified.
|
|
241
|
+
schema, catalog
|
|
242
|
+
Optional qualifiers when ``table`` is not fully qualified.
|
|
243
|
+
zorder_by
|
|
244
|
+
Optional column or columns for ``ZORDER BY``.
|
|
245
|
+
spark
|
|
246
|
+
Optional SparkSession to use. Defaults to this manager's Spark session.
|
|
247
|
+
"""
|
|
248
|
+
full_name = self._resolve_uc_name(table, schema=schema, catalog=catalog)
|
|
249
|
+
spark = spark or self._get_spark()
|
|
250
|
+
optimize_sql = f"OPTIMIZE {full_name}{self._zorder_clause(zorder_by)}"
|
|
251
|
+
try:
|
|
252
|
+
spark.sql(optimize_sql)
|
|
253
|
+
except Exception as e:
|
|
254
|
+
log_and_raise_error(self._logger, f"Error optimizing Unity Catalog table '{full_name}': {e}")
|
|
255
|
+
self._logger.info(f"Table '{full_name}' optimized.")
|
|
256
|
+
|
|
257
|
+
def drop_table(self, table: str, schema: str = None, catalog: str = None, spark=None):
|
|
258
|
+
"""
|
|
259
|
+
``DROP TABLE IF EXISTS`` a Unity Catalog table.
|
|
260
|
+
|
|
261
|
+
Parameters
|
|
262
|
+
----------
|
|
263
|
+
table
|
|
264
|
+
Table name. May be fully qualified.
|
|
265
|
+
schema, catalog
|
|
266
|
+
Optional qualifiers when ``table`` is not fully qualified.
|
|
267
|
+
spark
|
|
268
|
+
Optional SparkSession to use. Defaults to this manager's Spark session.
|
|
269
|
+
"""
|
|
270
|
+
full_name = self._resolve_uc_name(table, schema=schema, catalog=catalog)
|
|
271
|
+
spark = spark or self._get_spark()
|
|
272
|
+
try:
|
|
273
|
+
spark.sql(f"DROP TABLE IF EXISTS {full_name}")
|
|
274
|
+
except Exception as e:
|
|
275
|
+
log_and_raise_error(self._logger, f"Error dropping Unity Catalog table '{full_name}': {e}")
|
|
276
|
+
self._logger.info(f"Dropped Unity Catalog table '{full_name}'.")
|
|
277
|
+
|
|
278
|
+
def sql(self, query: str, return_pandas: bool = False, spark=None):
|
|
279
|
+
"""
|
|
280
|
+
Run a Spark SQL statement via ``spark.sql(query)`` and return the result.
|
|
281
|
+
|
|
282
|
+
Use this for arbitrary Spark/Unity Catalog SQL (SELECT, DDL, MERGE, ...).
|
|
283
|
+
Unlike :class:`ml_analytics.sf_connector.SFConnector.sql`, this runs the
|
|
284
|
+
query on the Spark engine itself, not against Snowflake.
|
|
285
|
+
|
|
286
|
+
Parameters
|
|
287
|
+
----------
|
|
288
|
+
query
|
|
289
|
+
The Spark SQL statement to execute.
|
|
290
|
+
return_pandas
|
|
291
|
+
If True, return a pandas DataFrame; otherwise return the Spark
|
|
292
|
+
DataFrame. Defaults to False.
|
|
293
|
+
spark
|
|
294
|
+
Optional SparkSession to use. Defaults to this manager's Spark session.
|
|
295
|
+
"""
|
|
296
|
+
spark = spark or self._get_spark()
|
|
297
|
+
try:
|
|
298
|
+
df = spark.sql(query)
|
|
299
|
+
except Exception as e:
|
|
300
|
+
log_and_raise_error(self._logger, f"Error executing Spark SQL: {e}")
|
|
301
|
+
return df.toPandas() if return_pandas else df
|
|
302
|
+
|
|
303
|
+
def read_table(self, table: str, schema: str = None, catalog: str = None, spark=None):
|
|
304
|
+
"""
|
|
305
|
+
Read a Unity Catalog table into a Spark DataFrame via ``spark.table(...)``.
|
|
306
|
+
|
|
307
|
+
Parameters
|
|
308
|
+
----------
|
|
309
|
+
table
|
|
310
|
+
Table name. May be fully qualified.
|
|
311
|
+
schema, catalog
|
|
312
|
+
Optional qualifiers when ``table`` is not fully qualified.
|
|
313
|
+
spark
|
|
314
|
+
Optional SparkSession to use. Defaults to this manager's Spark session.
|
|
315
|
+
"""
|
|
316
|
+
full_name = self._resolve_uc_name(table, schema=schema, catalog=catalog)
|
|
317
|
+
spark = spark or self._get_spark()
|
|
318
|
+
try:
|
|
319
|
+
return spark.table(full_name)
|
|
320
|
+
except Exception as e:
|
|
321
|
+
log_and_raise_error(self._logger, f"Error reading Unity Catalog table '{full_name}': {e}")
|
|
322
|
+
|
|
323
|
+
def save_to_uc(
|
|
324
|
+
self,
|
|
325
|
+
df,
|
|
326
|
+
table: str,
|
|
327
|
+
schema: str = None,
|
|
328
|
+
catalog: str = None,
|
|
329
|
+
mode: str = "overwrite",
|
|
330
|
+
optimize: bool = True,
|
|
331
|
+
zorder_by=None,
|
|
332
|
+
merge_schema: bool = True,
|
|
333
|
+
comment: str = None,
|
|
334
|
+
drop_existing: bool = True,
|
|
335
|
+
overwrite_schema: bool = True,
|
|
336
|
+
spark_schema=None,
|
|
337
|
+
):
|
|
338
|
+
"""
|
|
339
|
+
Write a Spark DataFrame to a Databricks Unity Catalog table.
|
|
340
|
+
|
|
341
|
+
Uses Spark's native ``df.write.saveAsTable(...)`` (a managed UC table).
|
|
342
|
+
By default, runs Delta ``OPTIMIZE`` after the write.
|
|
343
|
+
|
|
344
|
+
Parameters
|
|
345
|
+
----------
|
|
346
|
+
df : pandas, polars, or pyspark.sql.DataFrame
|
|
347
|
+
DataFrame to write. pandas/polars frames are converted to Spark first
|
|
348
|
+
via :meth:`to_spark`.
|
|
349
|
+
table : str
|
|
350
|
+
Destination table name. May be fully qualified
|
|
351
|
+
(``catalog.schema.table``), in which case ``schema`` / ``catalog``
|
|
352
|
+
are ignored.
|
|
353
|
+
schema, catalog : str, optional
|
|
354
|
+
Unity Catalog schema and catalog to qualify ``table`` with. Fall back
|
|
355
|
+
to the manager's defaults.
|
|
356
|
+
mode : str, optional
|
|
357
|
+
Spark write mode: 'overwrite', 'append', 'ignore', or 'error'.
|
|
358
|
+
Defaults to 'overwrite'.
|
|
359
|
+
optimize : bool, optional
|
|
360
|
+
If True, run ``OPTIMIZE`` after saving. Defaults to True.
|
|
361
|
+
zorder_by : str or list[str], optional
|
|
362
|
+
Optional columns for Delta ``ZORDER BY`` during optimize.
|
|
363
|
+
merge_schema : bool, optional
|
|
364
|
+
If True, writes as Delta with ``mergeSchema=true`` (used for appends and
|
|
365
|
+
whenever the schema is not being overwritten). Defaults to True. Ignored on
|
|
366
|
+
an overwrite when ``overwrite_schema`` is True (the two are mutually
|
|
367
|
+
exclusive in Delta).
|
|
368
|
+
comment : str, optional
|
|
369
|
+
Optional table comment stored as a Unity Catalog table property.
|
|
370
|
+
drop_existing : bool, optional
|
|
371
|
+
If True, ``DROP TABLE IF EXISTS`` the destination before writing so the
|
|
372
|
+
table is fully recreated from this DataFrame (removed columns disappear,
|
|
373
|
+
and table properties / grants / history are reset). Defaults to True.
|
|
374
|
+
Set to False to preserve the existing table — required for ``mode='append'``,
|
|
375
|
+
which would otherwise drop the table on every call.
|
|
376
|
+
overwrite_schema : bool, optional
|
|
377
|
+
If True and ``mode='overwrite'``, writes with Delta ``overwriteSchema=true``
|
|
378
|
+
so the table schema is replaced (columns absent from this DataFrame are
|
|
379
|
+
dropped) rather than merged. Defaults to True. Has no effect on non-overwrite
|
|
380
|
+
modes.
|
|
381
|
+
spark_schema : optional
|
|
382
|
+
Explicit Spark schema used only when ``df`` is a pandas/polars frame that
|
|
383
|
+
must be converted to Spark. Passed to :meth:`to_spark`. Accepts a
|
|
384
|
+
``StructType`` or a DDL string. Ignored when ``df`` is already a Spark
|
|
385
|
+
DataFrame.
|
|
386
|
+
|
|
387
|
+
Returns
|
|
388
|
+
-------
|
|
389
|
+
pyspark.sql.DataFrame
|
|
390
|
+
A DataFrame reading back from the saved Unity Catalog Delta table
|
|
391
|
+
(``spark.table(full_name)``), not the source DataFrame. Downstream actions
|
|
392
|
+
then scan the fast Delta table instead of re-running the original read.
|
|
393
|
+
"""
|
|
394
|
+
if not table:
|
|
395
|
+
log_and_raise_error(self._logger, "A destination table name is required.")
|
|
396
|
+
|
|
397
|
+
full_name = self._resolve_uc_name(table, schema=schema, catalog=catalog)
|
|
398
|
+
spark = getattr(df, "sparkSession", None) or self._spark or self._get_spark()
|
|
399
|
+
# Accept pandas/polars DataFrames too, converting them to Spark first.
|
|
400
|
+
df = self.to_spark(df, schema=spark_schema, spark=spark)
|
|
401
|
+
|
|
402
|
+
if drop_existing:
|
|
403
|
+
try:
|
|
404
|
+
spark.sql(f"DROP TABLE IF EXISTS {full_name}")
|
|
405
|
+
except Exception as e:
|
|
406
|
+
log_and_raise_error(self._logger, f"Error dropping Unity Catalog table '{full_name}': {e}")
|
|
407
|
+
self._logger.info(f"Dropped existing Unity Catalog table '{full_name}' before write.")
|
|
408
|
+
|
|
409
|
+
try:
|
|
410
|
+
writer = df.write.format("delta")
|
|
411
|
+
# overwriteSchema and mergeSchema are mutually exclusive in Delta. On an
|
|
412
|
+
# overwrite, replacing the schema (so removed columns are dropped) takes
|
|
413
|
+
# precedence; mergeSchema is used otherwise (e.g. evolving on append).
|
|
414
|
+
if mode == "overwrite" and overwrite_schema:
|
|
415
|
+
writer = writer.option("overwriteSchema", "true")
|
|
416
|
+
elif merge_schema:
|
|
417
|
+
writer = writer.option("mergeSchema", "true")
|
|
418
|
+
writer.mode(mode).saveAsTable(full_name)
|
|
419
|
+
except Exception as e:
|
|
420
|
+
log_and_raise_error(self._logger, f"Error writing to Unity Catalog table '{full_name}': {e}")
|
|
421
|
+
self._logger.info(f"Table '{full_name}' written to Unity Catalog (mode={mode}).")
|
|
422
|
+
|
|
423
|
+
if comment is not None:
|
|
424
|
+
self.set_uc_table_comment(full_name, comment, spark=spark)
|
|
425
|
+
|
|
426
|
+
if optimize:
|
|
427
|
+
self.optimize_uc_table(full_name, zorder_by=zorder_by, spark=spark)
|
|
428
|
+
|
|
429
|
+
# Return a DataFrame backed by the just-written Delta table so callers read
|
|
430
|
+
# from fast Unity Catalog storage rather than re-executing the source read.
|
|
431
|
+
return spark.table(full_name)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ml-analytics-tools
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Tools for ML projects and data management
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -56,6 +56,7 @@ arguments.
|
|
|
56
56
|
|
|
57
57
|
- `DataConnector`: run Redshift or Snowflake SQL, load SQL files, unload/load data through S3, and create Redshift tables from DataFrames.
|
|
58
58
|
- `SFConnector`: read Snowflake through Spark and save results to Unity Catalog tables (Databricks). PySpark is imported lazily, so the rest of the package works without it.
|
|
59
|
+
- `SparkTableManager`: source-agnostic Spark / Unity Catalog operations — save a Spark/pandas/polars DataFrame to a Delta table, convert pandas/polars to Spark, run Spark SQL, optimize, comment, drop, or read tables.
|
|
59
60
|
- `S3Connector`: read, write, list, delete, and query S3 data with DuckDB.
|
|
60
61
|
- `GSheet`: read, write, share, and export Google Sheets data.
|
|
61
62
|
- `SlackConnector`: send messages, upload files, and manage simple Slack interactions.
|
|
@@ -239,6 +240,35 @@ variable → Databricks secret. See the
|
|
|
239
240
|
[Snowflake Spark Connector](docs/SF_CONNECTOR_USAGE.md) guide for credential
|
|
240
241
|
setup and all options.
|
|
241
242
|
|
|
243
|
+
### Manage Unity Catalog Tables (`SparkTableManager`)
|
|
244
|
+
|
|
245
|
+
When you already have a DataFrame (from any source) and just need Spark / Unity
|
|
246
|
+
Catalog table operations, use `SparkTableManager`. It accepts Spark, pandas, or
|
|
247
|
+
polars DataFrames and imports PySpark lazily, the same way `SFConnector` does.
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
from ml_analytics import SparkTableManager
|
|
251
|
+
|
|
252
|
+
tm = SparkTableManager(catalog="prod", schema="analytics")
|
|
253
|
+
|
|
254
|
+
# run Spark SQL (Spark DataFrame, or pandas with return_pandas=True)
|
|
255
|
+
df = tm.sql("SELECT * FROM prod.analytics.lessons WHERE country = 'US'")
|
|
256
|
+
|
|
257
|
+
# save a Spark / pandas / polars DataFrame to a Unity Catalog Delta table
|
|
258
|
+
tm.save_to_uc(df, table="lessons_us", comment="US lessons")
|
|
259
|
+
|
|
260
|
+
# convert pandas/polars to Spark (optionally with an explicit schema)
|
|
261
|
+
sdf = tm.to_spark(pandas_df, schema="user_id long, country string")
|
|
262
|
+
|
|
263
|
+
# read back, optimize, or drop
|
|
264
|
+
again = tm.read_table("lessons_us")
|
|
265
|
+
tm.optimize_uc_table("lessons_us", zorder_by="country")
|
|
266
|
+
tm.drop_table("lessons_us")
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
See the [Spark Table Manager](docs/SPARK_CONNECTOR_USAGE.md) guide for all
|
|
270
|
+
options.
|
|
271
|
+
|
|
242
272
|
### Create A Redshift Table From A DataFrame
|
|
243
273
|
|
|
244
274
|
```python
|
|
@@ -339,6 +369,7 @@ slack.send_message(channel="#ml-alerts", text="Training finished")
|
|
|
339
369
|
| [AWS Authentication](docs/AWS_AUTHENTICATION.md) | AWS SSO setup and Python helpers |
|
|
340
370
|
| [CLI Commands](docs/CLI_COMMANDS.md) | Available console commands |
|
|
341
371
|
| [Snowflake Spark Connector](docs/SF_CONNECTOR_USAGE.md) | `SFConnector` credential setup, reads, and writes on Spark/Databricks |
|
|
372
|
+
| [Spark Table Manager](docs/SPARK_CONNECTOR_USAGE.md) | `SparkTableManager` — save DataFrames to Unity Catalog, convert pandas/polars, table maintenance |
|
|
342
373
|
| [Google Sheets](docs/GSHEET_CONNECTOR_USAGE.md) | Sheets setup, sharing, exports, and examples |
|
|
343
374
|
| [Slack](docs/SLACK_CONNECTOR_USAGE.md) | Slack token setup and message/file examples |
|
|
344
375
|
| [Tunnel Manager](docs/TUNNEL_MANAGER.md) | SSH tunnel configuration and CLI usage |
|
|
@@ -650,6 +650,23 @@ class TestThreadSafeConnect:
|
|
|
650
650
|
|
|
651
651
|
assert mock_redshift.connect.call_count == 1
|
|
652
652
|
|
|
653
|
+
def test_connect_does_not_initialize_s3_when_default_bucket_configured(self, mock_credentials):
|
|
654
|
+
"""connect() should not initialize S3 unless it's actually used."""
|
|
655
|
+
with patch("ml_analytics.data_connector.redshift_connector") as mock_redshift, patch(
|
|
656
|
+
"ml_analytics.s3_connector.boto3"
|
|
657
|
+
) as mock_boto3:
|
|
658
|
+
mock_connection = MagicMock()
|
|
659
|
+
mock_connection.closed = False
|
|
660
|
+
mock_cursor = MagicMock()
|
|
661
|
+
mock_connection.cursor.return_value = mock_cursor
|
|
662
|
+
mock_redshift.connect.return_value = mock_connection
|
|
663
|
+
|
|
664
|
+
dc = DataConnector()
|
|
665
|
+
dc.connect()
|
|
666
|
+
|
|
667
|
+
assert dc.s3 is None
|
|
668
|
+
assert mock_boto3.Session.call_count == 0
|
|
669
|
+
|
|
653
670
|
|
|
654
671
|
# === Tests for multi-statement SQL in unload_to_s3 ===
|
|
655
672
|
|
|
@@ -6,15 +6,16 @@ from unittest.mock import MagicMock, patch
|
|
|
6
6
|
import pytest
|
|
7
7
|
|
|
8
8
|
import ml_analytics.sf_connector as sf_module
|
|
9
|
+
import ml_analytics.spark_connector as spark_module
|
|
9
10
|
from ml_analytics import SFConnector
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
@pytest.fixture(autouse=True)
|
|
13
14
|
def _reset_spark_ctx():
|
|
14
15
|
"""Keep the module-level cached Spark session from leaking between tests."""
|
|
15
|
-
|
|
16
|
+
spark_module._spark_ctx = None
|
|
16
17
|
yield
|
|
17
|
-
|
|
18
|
+
spark_module._spark_ctx = None
|
|
18
19
|
|
|
19
20
|
SNOWFLAKE_ENV = [
|
|
20
21
|
"DATABRICKS_SECRET_SCOPE",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics_tools.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics_tools.egg-info/requires.txt
RENAMED
|
File without changes
|
{ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics_tools.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|