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.
Files changed (34) hide show
  1. {ml_analytics_tools-0.7.0/ml_analytics_tools.egg-info → ml_analytics_tools-0.7.2}/PKG-INFO +32 -1
  2. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/README.md +31 -0
  3. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/__init__.py +3 -1
  4. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/data_connector.py +2 -4
  5. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/sf_connector.py +42 -220
  6. ml_analytics_tools-0.7.2/ml_analytics/spark_connector.py +431 -0
  7. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2/ml_analytics_tools.egg-info}/PKG-INFO +32 -1
  8. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics_tools.egg-info/SOURCES.txt +1 -0
  9. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/pyproject.toml +1 -1
  10. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_db_s3.py +17 -0
  11. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_sf_connector.py +3 -2
  12. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/LICENSE +0 -0
  13. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/aws_auth.py +0 -0
  14. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/cli.py +0 -0
  15. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/gsheet_connector.py +0 -0
  16. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/model_manager.py +0 -0
  17. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/model_tools.py +0 -0
  18. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/s3_connector.py +0 -0
  19. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/slack_connector.py +0 -0
  20. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/tunnel_manager.py +0 -0
  21. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics/utils.py +0 -0
  22. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics_tools.egg-info/dependency_links.txt +0 -0
  23. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics_tools.egg-info/entry_points.txt +0 -0
  24. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics_tools.egg-info/requires.txt +0 -0
  25. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/ml_analytics_tools.egg-info/top_level.txt +0 -0
  26. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/setup.cfg +0 -0
  27. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_aws_auth.py +0 -0
  28. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_gsheet_connector.py +0 -0
  29. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_identity_column.py +0 -0
  30. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_model_manager.py +0 -0
  31. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_model_tools.py +0 -0
  32. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_s3_redshift_validation.py +0 -0
  33. {ml_analytics_tools-0.7.0 → ml_analytics_tools-0.7.2}/tests/test_tunnel_manager.py +0 -0
  34. {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.0
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, get_spark
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
- # Initialize the default S3 connector only when a default bucket is configured.
682
- if self._s3_bucket:
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
- # Cached Spark session shared across SFConnector instances. Populated lazily by
30
- # get_spark(); never created at import time so the package stays importable
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
- @staticmethod
594
- def _qualified_uc_name(table: str, schema: str = None, catalog: str = None) -> str:
595
- """Build a Unity Catalog table identifier from its parts.
596
-
597
- A ``table`` that already contains dots is treated as fully qualified and
598
- returned as-is; otherwise ``catalog`` / ``schema`` are prepended when given.
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
- Set a Unity Catalog table comment using Databricks table properties.
627
-
628
- Parameters
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
- Run Databricks Delta ``OPTIMIZE`` on a Unity Catalog table.
651
-
652
- Parameters
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
- Uses Spark's native ``df.write.saveAsTable(...)`` (a managed UC table),
690
- not the Snowflake connector. By default, runs Delta ``OPTIMIZE`` after
691
- the write.
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
- if not table:
737
- log_and_raise_error(self._logger, "A destination table name is required.")
738
-
739
- full_name = self._qualified_uc_name(table, schema=schema, catalog=catalog)
740
- spark = getattr(df, "sparkSession", None) or self._spark
741
-
742
- if drop_existing:
743
- try:
744
- spark.sql(f"DROP TABLE IF EXISTS {full_name}")
745
- except Exception as e:
746
- log_and_raise_error(self._logger, f"Error dropping Unity Catalog table '{full_name}': {e}")
747
- self._logger.info(f"Dropped existing Unity Catalog table '{full_name}' before write.")
748
-
749
- try:
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.0
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 |
@@ -11,6 +11,7 @@ ml_analytics/model_tools.py
11
11
  ml_analytics/s3_connector.py
12
12
  ml_analytics/sf_connector.py
13
13
  ml_analytics/slack_connector.py
14
+ ml_analytics/spark_connector.py
14
15
  ml_analytics/tunnel_manager.py
15
16
  ml_analytics/utils.py
16
17
  ml_analytics_tools.egg-info/PKG-INFO
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ml-analytics-tools"
3
- version = "0.7.0"
3
+ version = "0.7.2"
4
4
  description = "Tools for ML projects and data management"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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
- sf_module._spark_ctx = None
16
+ spark_module._spark_ctx = None
16
17
  yield
17
- sf_module._spark_ctx = None
18
+ spark_module._spark_ctx = None
18
19
 
19
20
  SNOWFLAKE_ENV = [
20
21
  "DATABRICKS_SECRET_SCOPE",