batch-analytics 0.3.34__tar.gz → 0.3.36__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 (40) hide show
  1. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/PKG-INFO +1 -1
  2. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/pyproject.toml +1 -1
  3. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/config.py +42 -6
  4. batch_analytics-0.3.36/src/batch_analytics/extract.py +375 -0
  5. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/job_runner.py +95 -13
  6. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/transform.py +188 -23
  7. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/PKG-INFO +1 -1
  8. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/SOURCES.txt +3 -1
  9. batch_analytics-0.3.36/tests/test_iceberg_staging.py +85 -0
  10. batch_analytics-0.3.36/tests/test_yield_quality_pivot.py +218 -0
  11. batch_analytics-0.3.34/src/batch_analytics/extract.py +0 -214
  12. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/README.md +0 -0
  13. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/setup.cfg +0 -0
  14. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/__init__.py +0 -0
  15. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/__main__.py +0 -0
  16. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/__init__.py +0 -0
  17. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/correlation.py +0 -0
  18. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/equipment_oee.py +0 -0
  19. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/gluon_autogluon_infer.py +0 -0
  20. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/gluon_autogluon_train.py +0 -0
  21. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/linear_regression.py +0 -0
  22. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/pca.py +0 -0
  23. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/pca_clustering.py +0 -0
  24. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/pca_core.py +0 -0
  25. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/pca_mvda.py +0 -0
  26. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/analytics/t_test.py +0 -0
  27. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/log.py +0 -0
  28. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/modules.py +0 -0
  29. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/output/__init__.py +0 -0
  30. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/output/base.py +0 -0
  31. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/output/clickhouse.py +0 -0
  32. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/output/local.py +0 -0
  33. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/output/s3.py +0 -0
  34. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/utils/__init__.py +0 -0
  35. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics/utils/gluon_autogluon_common.py +0 -0
  36. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/dependency_links.txt +0 -0
  37. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/entry_points.txt +0 -0
  38. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/requires.txt +0 -0
  39. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/top_level.txt +0 -0
  40. {batch_analytics-0.3.34 → batch_analytics-0.3.36}/tests/test_pca_mvda.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batch-analytics
3
- Version: 0.3.34
3
+ Version: 0.3.36
4
4
  Summary: PySpark batch analytics: Extract, Transform, Stage, and analytical modules (linear regression, correlation, PCA, t-test, LLM classification).
5
5
  Author: Litewave Analytics Team
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "batch-analytics"
7
- version = "0.3.34"
7
+ version = "0.3.36"
8
8
  description = "PySpark batch analytics: Extract, Transform, Stage, and analytical modules (linear regression, correlation, PCA, t-test, LLM classification)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -66,6 +66,27 @@ class ExtractConfig:
66
66
  # filter_values: comma-separated list, or JSON array e.g. ["a","b"] for values containing commas.
67
67
  filter_column: str = os.environ.get("BATCH_EXTRACT_FILTER_COLUMN", "").strip()
68
68
  filter_values: str = os.environ.get("BATCH_EXTRACT_FILTER_VALUES", "").strip()
69
+ # clickhouse (default) or iceberg. Iceberg reads BATCH_SOURCE_TABLES via spark.table.
70
+ source_format: str = field(
71
+ default_factory=lambda: (
72
+ os.environ.get("BATCH_SOURCE_FORMAT") or "clickhouse"
73
+ ).strip().lower()
74
+ or "clickhouse"
75
+ )
76
+ # Comma-separated left-join keys when more than one source table is set.
77
+ join_keys: str = field(
78
+ default_factory=lambda: os.environ.get("BATCH_EXTRACT_JOIN_KEYS", "").strip()
79
+ )
80
+ # JSON object: table name or short name -> comma-separated columns (or a list).
81
+ columns_json: str = field(
82
+ default_factory=lambda: os.environ.get("BATCH_EXTRACT_COLUMNS_JSON", "").strip()
83
+ )
84
+ # JSON object: table -> {"group_by": "batch_no", "aggs": {"actual_yield_qty": "max"}}.
85
+ aggregate_before_join_json: str = field(
86
+ default_factory=lambda: os.environ.get(
87
+ "BATCH_EXTRACT_AGG_BEFORE_JOIN_JSON", ""
88
+ ).strip()
89
+ )
69
90
 
70
91
 
71
92
  @dataclass
@@ -85,15 +106,23 @@ class TransformConfig:
85
106
  "/tmp/analytics_stage",
86
107
  )
87
108
  )
88
- # Output format for load_staged when reading (parquet/delta/clickhouse).
89
- # Stage job always writes to ClickHouse; use clickhouse for analytics to read from staged table.
109
+ # Staging format for write (ETL) and load_staged (analytics): parquet, delta, clickhouse, iceberg.
110
+ # iceberg writes BATCH_STAGING_TABLE with DataFrame.writeTo and reads it back via spark.sql.
90
111
  staging_format: str = field(
91
112
  default_factory=lambda: os.environ.get("BATCH_STAGING_FORMAT", "clickhouse")
92
113
  )
93
- # Staging table name in ClickHouse (when format=clickhouse)
114
+ # Staging table: ClickHouse table name, or Iceberg identifier e.g. nessie.gold.batch_yield_features
94
115
  staging_table: str = field(
95
116
  default_factory=lambda: os.environ.get("BATCH_STAGING_TABLE", "analytics_staging")
96
117
  )
118
+ # Optional SQL WHERE fragment when reading staging (no leading WHERE).
119
+ staging_filter: str = field(
120
+ default_factory=lambda: os.environ.get("BATCH_STAGING_FILTER", "").strip()
121
+ )
122
+ # Optional comma-separated columns when reading staging (empty = SELECT *).
123
+ staging_columns: str = field(
124
+ default_factory=lambda: os.environ.get("BATCH_STAGING_COLUMNS", "").strip()
125
+ )
97
126
  # Spark save mode for ClickHouse staging (and path staging): overwrite | append
98
127
  staging_write_mode: str = field(
99
128
  default_factory=lambda: os.environ.get("BATCH_STAGING_WRITE_MODE", "overwrite")
@@ -211,11 +240,18 @@ class SparkK8sConfig:
211
240
  executor_cores: int = int(os.environ.get("SPARK_EXECUTOR_CORES", "1"))
212
241
  executor_memory: str = os.environ.get("SPARK_EXECUTOR_MEMORY", "512m")
213
242
  executor_memory_overhead: str = os.environ.get("SPARK_EXECUTOR_MEMORY_OVERHEAD", "128m")
214
- # S3 (optional; set for s3a:// paths)
243
+ # S3 (optional; set for s3a:// paths / Iceberg warehouse)
215
244
  s3_access_key: str = os.environ.get("AWS_ACCESS_KEY_ID", "")
216
245
  s3_secret_key: str = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
217
- s3_endpoint: str = os.environ.get("AWS_ENDPOINT", "s3.amazonaws.com")
218
- s3_region: str = os.environ.get("AWS_REGION", "us-east-2")
246
+ # Prefer S3_ENDPOINT (lakehouse MinIO); fall back to AWS_ENDPOINT for legacy.
247
+ s3_endpoint: str = os.environ.get(
248
+ "S3_ENDPOINT",
249
+ os.environ.get("AWS_ENDPOINT", ""),
250
+ )
251
+ s3_region: str = os.environ.get(
252
+ "AWS_DEFAULT_REGION",
253
+ os.environ.get("AWS_REGION", "us-east-2"),
254
+ )
219
255
 
220
256
 
221
257
  @dataclass
@@ -0,0 +1,375 @@
1
+ """
2
+ Extract stage: Load data from ClickHouse using Spark ClickHouse connector or JDBC.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ from typing import Dict, List, Optional, Tuple
9
+
10
+ from pyspark.sql import DataFrame, SparkSession
11
+ from pyspark.sql import functions as F
12
+ from pyspark.sql.functions import col
13
+
14
+ from .config import BatchAnalyticsConfig
15
+ from .transform import quote_iceberg_identifier
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def parse_extract_filter_values(raw: str) -> List[str]:
21
+ """
22
+ Parse BATCH_EXTRACT_FILTER_VALUES: comma-separated tokens, or JSON array string.
23
+
24
+ Examples:
25
+ a,b,c -> ["a","b","c"]
26
+ ["GP/A","GP/B"] -> JSON list (values may contain commas)
27
+ """
28
+ text = (raw or "").strip()
29
+ if not text:
30
+ return []
31
+ if text.startswith("["):
32
+ try:
33
+ data = json.loads(text)
34
+ if isinstance(data, list):
35
+ out = [str(x).strip() for x in data if str(x).strip()]
36
+ return out
37
+ except json.JSONDecodeError:
38
+ logger.warning("BATCH_EXTRACT_FILTER_VALUES looks like JSON but failed to parse; using comma split")
39
+ return [p.strip() for p in text.split(",") if p.strip()]
40
+
41
+
42
+ def _apply_extract_filter(df: DataFrame, config: BatchAnalyticsConfig) -> DataFrame:
43
+ """Apply col IN (values) when filter_column is set; empty column = no filter."""
44
+ col_name = (config.extract.filter_column or "").strip()
45
+ if not col_name:
46
+ return df
47
+ if col_name not in df.columns:
48
+ logger.warning(
49
+ "BATCH_EXTRACT_FILTER_COLUMN=%r not in extracted columns %s; skipping filter",
50
+ col_name,
51
+ df.columns,
52
+ )
53
+ return df
54
+ values = parse_extract_filter_values(config.extract.filter_values)
55
+ if not values:
56
+ logger.warning(
57
+ "BATCH_EXTRACT_FILTER_COLUMN=%r set but BATCH_EXTRACT_FILTER_VALUES is empty; skipping IN filter",
58
+ col_name,
59
+ )
60
+ return df
61
+ filtered = df.filter(col(col_name).isin(values))
62
+ return filtered
63
+
64
+
65
+ def _parse_ch_database_table(table: str, default_database: str) -> Tuple[str, str]:
66
+ """
67
+ Resolve ``table`` reference to (database, table_name).
68
+
69
+ ``batch_metric_facts`` → (default_database, batch_metric_facts)
70
+ ``analytics.batch_metric_facts`` → (analytics, batch_metric_facts)
71
+ """
72
+ t = (table or "").strip()
73
+ if not t:
74
+ return default_database, t
75
+ if "." in t and not t.startswith("("):
76
+ db, tbl = t.split(".", 1)
77
+ db, tbl = db.strip(), tbl.strip()
78
+ if db and tbl:
79
+ return db, tbl
80
+ return default_database, t
81
+
82
+
83
+ def _read_via_catalog(spark: SparkSession, cfg: BatchAnalyticsConfig, table: str) -> Optional[DataFrame]:
84
+ """
85
+ Read via Spark SQL catalog (ClickHouseCatalog), registered in job_runner.create_spark_session.
86
+
87
+ clickhouse-spark-runtime does **not** register legacy short name ``format(\"clickhouse\")`` /
88
+ ``clickhouse.DefaultSource``; catalog + ``spark.table(catalog.db.table)`` is the supported path.
89
+ """
90
+ cat = os.environ.get("BATCH_CLICKHOUSE_CATALOG", "batch_ch").strip()
91
+ if not cat:
92
+ return None
93
+ db, tbl = _parse_ch_database_table(table, cfg.clickhouse.database)
94
+ ident = f"{cat}.{db}.{tbl}"
95
+ try:
96
+ return spark.table(ident)
97
+ except Exception as e:
98
+ logger.warning(
99
+ "Catalog read failed for %s (%s): %s. Trying other readers.",
100
+ ident,
101
+ table,
102
+ e,
103
+ )
104
+ return None
105
+
106
+
107
+ def _read_via_format(spark: SparkSession, cfg: BatchAnalyticsConfig, table: str) -> Optional[DataFrame]:
108
+ """
109
+ Read from ClickHouse using the native format API (clickhouse-spark-runtime).
110
+ Requires: com.clickhouse.spark:clickhouse-spark-runtime in spark.jars.packages
111
+ """
112
+ try:
113
+ rd = (
114
+ spark.read.format("clickhouse")
115
+ .option("host", cfg.clickhouse.host)
116
+ .option("protocol", cfg.clickhouse.protocol)
117
+ .option("http_port", str(cfg.clickhouse.port))
118
+ .option("database", cfg.clickhouse.database)
119
+ .option("table", table)
120
+ .option("user", cfg.clickhouse.user)
121
+ )
122
+ if cfg.clickhouse.password:
123
+ rd = rd.option("password", cfg.clickhouse.password)
124
+ df = rd.load()
125
+ return df
126
+ except Exception as e:
127
+ logger.warning(
128
+ "Native ClickHouse connector failed for table %s: %s. Falling back to JDBC.",
129
+ table,
130
+ e,
131
+ )
132
+ return None
133
+
134
+
135
+ def _read_via_jdbc(spark: SparkSession, cfg: BatchAnalyticsConfig, table: str) -> DataFrame:
136
+ """Read from ClickHouse via JDBC."""
137
+ return spark.read.jdbc(
138
+ cfg.clickhouse.jdbc_url,
139
+ table,
140
+ properties=cfg.clickhouse.jdbc_properties,
141
+ )
142
+
143
+
144
+ def extract_table(
145
+ spark: SparkSession,
146
+ table: str,
147
+ config: BatchAnalyticsConfig,
148
+ ) -> DataFrame:
149
+ """
150
+ Extract a single source table.
151
+
152
+ ``BATCH_SOURCE_FORMAT=iceberg`` reads a Nessie Iceberg identifier via ``spark.table``.
153
+ The default ``clickhouse`` path uses the native connector when configured, otherwise JDBC.
154
+ """
155
+ fmt = (config.extract.source_format or "clickhouse").strip().lower()
156
+ if fmt == "iceberg":
157
+ ident = quote_iceberg_identifier(table)
158
+ logger.info("Extracting Iceberg table %s", ident)
159
+ df = spark.table(ident)
160
+ elif fmt == "clickhouse":
161
+ if config.extract.use_native_connector:
162
+ # Prefer catalog (matches clickhouse-spark-runtime); avoid legacy DefaultSource path.
163
+ df = _read_via_catalog(spark, config, table)
164
+ if df is None:
165
+ df = _read_via_format(spark, config, table)
166
+ if df is None:
167
+ df = _read_via_jdbc(spark, config, table)
168
+ else:
169
+ df = _read_via_jdbc(spark, config, table)
170
+ else:
171
+ raise ValueError(
172
+ f"BATCH_SOURCE_FORMAT must be clickhouse or iceberg; got {fmt!r}"
173
+ )
174
+
175
+ df = _apply_extract_filter(df, config)
176
+ logger.info("Extracted table %s: %d rows", table, df.count())
177
+ return df
178
+
179
+
180
+ def extract_all(
181
+ spark: SparkSession,
182
+ config: BatchAnalyticsConfig,
183
+ ) -> Dict[str, DataFrame]:
184
+ """
185
+ Extract all configured source tables (ClickHouse or Iceberg).
186
+ Returns a dict mapping table name to DataFrame, in source_tables order.
187
+ """
188
+ tables = [t.strip() for t in config.extract.source_tables.split(",") if t.strip()]
189
+ if not tables:
190
+ raise ValueError("No source tables configured in BATCH_SOURCE_TABLES")
191
+
192
+ result: Dict[str, DataFrame] = {}
193
+ for table in tables:
194
+ df = extract_table(spark, table, config)
195
+ result[table] = df
196
+
197
+ return result
198
+
199
+
200
+ _AGG_FUNCS = {
201
+ "max": F.max,
202
+ "min": F.min,
203
+ "sum": F.sum,
204
+ "avg": F.avg,
205
+ "mean": F.avg,
206
+ "first": F.first,
207
+ }
208
+
209
+
210
+ def _parse_json_object(raw: str, env_name: str) -> Dict:
211
+ text = (raw or "").strip()
212
+ if not text:
213
+ return {}
214
+ try:
215
+ data = json.loads(text)
216
+ except json.JSONDecodeError as e:
217
+ raise ValueError(f"{env_name} must be valid JSON: {e}") from e
218
+ if not isinstance(data, dict):
219
+ raise ValueError(f"{env_name} must be a JSON object")
220
+ return data
221
+
222
+
223
+ def _table_short_name(table_name: str) -> str:
224
+ text = (table_name or "").strip().rstrip("`")
225
+ if not text:
226
+ return ""
227
+ return text.split(".")[-1].strip("`")
228
+
229
+
230
+ def _spec_for_table(table_name: str, spec: Dict) -> Optional[object]:
231
+ if not spec:
232
+ return None
233
+ if table_name in spec:
234
+ return spec[table_name]
235
+ short = _table_short_name(table_name)
236
+ if short and short in spec:
237
+ return spec[short]
238
+ return None
239
+
240
+
241
+ def _column_list(raw: object, table_name: str) -> List[str]:
242
+ if isinstance(raw, str):
243
+ names = [part.strip() for part in raw.split(",") if part.strip()]
244
+ elif isinstance(raw, list):
245
+ names = [str(part).strip() for part in raw if str(part).strip()]
246
+ else:
247
+ raise ValueError(
248
+ f"extract.columns for {table_name} must be a comma-separated string or a list"
249
+ )
250
+ if not names:
251
+ raise ValueError(f"extract.columns for {table_name} is empty")
252
+ return names
253
+
254
+
255
+ def prepare_source_frame(
256
+ df: DataFrame,
257
+ table_name: str,
258
+ config: BatchAnalyticsConfig,
259
+ ) -> DataFrame:
260
+ """Project columns and optionally aggregate one source before a join."""
261
+ columns_spec = _parse_json_object(
262
+ config.extract.columns_json, "BATCH_EXTRACT_COLUMNS_JSON"
263
+ )
264
+ selected = _spec_for_table(table_name, columns_spec)
265
+ if selected is not None:
266
+ names = _column_list(selected, table_name)
267
+ missing = [name for name in names if name not in df.columns]
268
+ if missing:
269
+ raise ValueError(
270
+ f"extract columns {missing} not in {table_name}; have {df.columns}"
271
+ )
272
+ df = df.select(*names)
273
+
274
+ agg_spec_all = _parse_json_object(
275
+ config.extract.aggregate_before_join_json,
276
+ "BATCH_EXTRACT_AGG_BEFORE_JOIN_JSON",
277
+ )
278
+ agg_spec = _spec_for_table(table_name, agg_spec_all)
279
+ if not agg_spec:
280
+ return df
281
+ if not isinstance(agg_spec, dict):
282
+ raise ValueError(f"aggregate_before_join for {table_name} must be an object")
283
+ group_by = agg_spec.get("group_by") or agg_spec.get("groupBy")
284
+ gcols = [part.strip() for part in str(group_by or "").split(",") if part.strip()]
285
+ aggs = agg_spec.get("aggs") or {}
286
+ if not gcols or not isinstance(aggs, dict) or not aggs:
287
+ raise ValueError(
288
+ f"aggregate_before_join for {table_name} requires group_by and aggs"
289
+ )
290
+ for name in gcols:
291
+ if name not in df.columns:
292
+ raise ValueError(
293
+ f"aggregate group_by column {name!r} not in {table_name}; have {df.columns}"
294
+ )
295
+ exprs = []
296
+ for col_name, agg_name in aggs.items():
297
+ key = str(col_name).strip()
298
+ fn_name = str(agg_name).strip().lower()
299
+ if key not in df.columns:
300
+ raise ValueError(
301
+ f"aggregate column {key!r} not in {table_name}; have {df.columns}"
302
+ )
303
+ fn = _AGG_FUNCS.get(fn_name)
304
+ if fn is None:
305
+ raise ValueError(
306
+ f"aggregate {fn_name!r} must be one of {sorted(_AGG_FUNCS)}; table {table_name}"
307
+ )
308
+ exprs.append(fn(F.col(key)).alias(key))
309
+ return df.groupBy(*gcols).agg(*exprs)
310
+
311
+
312
+ def _join_keys(config: BatchAnalyticsConfig, join_keys: Optional[List[str]]) -> Optional[List[str]]:
313
+ if join_keys:
314
+ return [key.strip() for key in join_keys if key and str(key).strip()]
315
+ raw = (config.extract.join_keys or "").strip()
316
+ if not raw:
317
+ return None
318
+ return [part.strip() for part in raw.split(",") if part.strip()]
319
+
320
+
321
+ def unify_dataframes(
322
+ all_dfs: Dict[str, DataFrame],
323
+ config: BatchAnalyticsConfig,
324
+ join_keys: Optional[List[str]] = None,
325
+ primary_table: Optional[str] = None,
326
+ ) -> DataFrame:
327
+ """
328
+ Project, aggregate, and left-join already loaded source frames.
329
+
330
+ First table is the join base. Non-key columns that exist on both sides are
331
+ rejected so a wide pivot is not built on ambiguous names.
332
+ """
333
+ if not all_dfs:
334
+ raise ValueError("No source tables to unify")
335
+ prepared: Dict[str, DataFrame] = {
336
+ name: prepare_source_frame(df, name, config) for name, df in all_dfs.items()
337
+ }
338
+ keys = _join_keys(config, join_keys)
339
+ frames = list(prepared.values())
340
+ if len(frames) == 1:
341
+ return frames[0]
342
+ if keys:
343
+ base = frames[0]
344
+ for other in frames[1:]:
345
+ missing = [key for key in keys if key not in base.columns or key not in other.columns]
346
+ if missing:
347
+ raise ValueError(f"join keys {missing} missing after extract projection")
348
+ overlap = (set(base.columns) & set(other.columns)) - set(keys)
349
+ if overlap:
350
+ raise ValueError(
351
+ "join would duplicate columns "
352
+ f"{sorted(overlap)}; set extract.columns to drop them"
353
+ )
354
+ base = base.join(other, on=keys, how="left")
355
+ return base
356
+ if primary_table and primary_table in prepared:
357
+ return prepared[primary_table]
358
+ return frames[0]
359
+
360
+
361
+ def extract_unified(
362
+ spark: SparkSession,
363
+ config: BatchAnalyticsConfig,
364
+ join_keys: Optional[List[str]] = None,
365
+ primary_table: Optional[str] = None,
366
+ ) -> DataFrame:
367
+ """
368
+ Extract and unify source tables into one DataFrame.
369
+ - Single table: returns it directly (after optional projection and aggregate).
370
+ - Multiple tables + join_keys: joins on those keys (left join, first table base).
371
+ - Multiple tables, no join_keys: returns the primary (or first) table.
372
+ Use primary_table to choose which table to use for analytics.
373
+ """
374
+ all_dfs = extract_all(spark, config)
375
+ return unify_dataframes(all_dfs, config, join_keys=join_keys, primary_table=primary_table)
@@ -19,7 +19,7 @@ from .extract import extract_unified
19
19
  from .log import log_dataframe_summary, log_run
20
20
  from .modules import DEFAULT_MODULES, MODULE_REGISTRY, VALID_MODULES
21
21
  from .output import write_analytics_output
22
- from .transform import load_staged, stage_to_clickhouse, transform
22
+ from .transform import load_staged, stage_to_clickhouse, stage_to_iceberg, transform
23
23
 
24
24
  logging.basicConfig(
25
25
  level=logging.INFO,
@@ -161,6 +161,9 @@ def create_spark_session(
161
161
  read_codec,
162
162
  )
163
163
 
164
+ builder = _apply_iceberg_nessie_catalog(builder, config)
165
+ builder = _apply_s3a_lakehouse(builder, config)
166
+
164
167
  if cfg.master.startswith("k8s://"):
165
168
  driver_host = socket.gethostbyname(socket.gethostname())
166
169
  builder = (
@@ -188,19 +191,91 @@ def create_spark_session(
188
191
  .config("spark.kubernetes.executor.serviceAccountName", cfg.service_account)
189
192
  .config("spark.kubernetes.container.image.pullPolicy", "IfNotPresent")
190
193
  )
191
- if cfg.s3_access_key and cfg.s3_secret_key:
192
- builder = (
193
- builder.config("spark.hadoop.fs.s3a.impl", "org.apache.hadoop.fs.s3a.S3AFileSystem")
194
- .config("spark.hadoop.fs.s3a.access.key", cfg.s3_access_key)
195
- .config("spark.hadoop.fs.s3a.secret.key", cfg.s3_secret_key)
196
- .config("spark.hadoop.fs.s3a.endpoint", cfg.s3_endpoint)
197
- .config("spark.hadoop.fs.s3a.endpoint.region", cfg.s3_region)
198
- )
199
194
  logger.info("Spark on Kubernetes: master=%s", cfg.master)
200
195
 
201
196
  return builder.getOrCreate()
202
197
 
203
198
 
199
+ def _apply_iceberg_nessie_catalog(builder, config: BatchAnalyticsConfig):
200
+ """Register Nessie Iceberg catalog when lakehouse env is present."""
201
+ nessie_uri = (os.environ.get("NESSIE_URI") or "").strip()
202
+ warehouse = (os.environ.get("ICEBERG_WAREHOUSE") or "").strip()
203
+ if not nessie_uri and not warehouse:
204
+ return builder
205
+ catalog = (os.environ.get("ICEBERG_CATALOG") or "nessie").strip() or "nessie"
206
+ ref = (os.environ.get("NESSIE_REF") or "main").strip() or "main"
207
+ if not nessie_uri:
208
+ nessie_uri = "http://nessie:19120/api/v2"
209
+ if not warehouse:
210
+ warehouse = "s3a://lake/"
211
+ logger.info(
212
+ "Configuring Iceberg catalog %s uri=%s ref=%s warehouse=%s",
213
+ catalog,
214
+ nessie_uri,
215
+ ref,
216
+ warehouse,
217
+ )
218
+ return (
219
+ builder.config(
220
+ "spark.sql.extensions",
221
+ "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions",
222
+ )
223
+ .config(f"spark.sql.catalog.{catalog}", "org.apache.iceberg.spark.SparkCatalog")
224
+ .config(
225
+ f"spark.sql.catalog.{catalog}.catalog-impl",
226
+ "org.apache.iceberg.nessie.NessieCatalog",
227
+ )
228
+ .config(f"spark.sql.catalog.{catalog}.uri", nessie_uri)
229
+ .config(f"spark.sql.catalog.{catalog}.ref", ref)
230
+ .config(f"spark.sql.catalog.{catalog}.warehouse", warehouse)
231
+ .config(
232
+ f"spark.sql.catalog.{catalog}.io-impl",
233
+ "org.apache.iceberg.hadoop.HadoopFileIO",
234
+ )
235
+ )
236
+
237
+
238
+ def _apply_s3a_lakehouse(builder, config: BatchAnalyticsConfig):
239
+ """S3A settings for Iceberg warehouse / s3a staging (AWS or MinIO via S3_ENDPOINT)."""
240
+ cfg = config.spark_k8s
241
+ if not (cfg.s3_access_key or cfg.s3_secret_key or os.environ.get("ICEBERG_WAREHOUSE")):
242
+ return builder
243
+
244
+ builder = builder.config(
245
+ "spark.hadoop.fs.s3a.impl",
246
+ "org.apache.hadoop.fs.s3a.S3AFileSystem",
247
+ )
248
+ if cfg.s3_access_key:
249
+ builder = builder.config("spark.hadoop.fs.s3a.access.key", cfg.s3_access_key)
250
+ if cfg.s3_secret_key:
251
+ builder = builder.config("spark.hadoop.fs.s3a.secret.key", cfg.s3_secret_key)
252
+
253
+ endpoint = (cfg.s3_endpoint or "").strip()
254
+ region = (cfg.s3_region or "us-east-2").strip()
255
+ if endpoint:
256
+ # Custom/MinIO endpoint
257
+ ssl = not endpoint.startswith("http://")
258
+ builder = (
259
+ builder.config("spark.hadoop.fs.s3a.endpoint", endpoint)
260
+ .config("spark.hadoop.fs.s3a.path.style.access", "true")
261
+ .config(
262
+ "spark.hadoop.fs.s3a.connection.ssl.enabled",
263
+ "true" if ssl else "false",
264
+ )
265
+ )
266
+ else:
267
+ builder = (
268
+ builder.config(
269
+ "spark.hadoop.fs.s3a.endpoint",
270
+ f"https://s3.{region}.amazonaws.com",
271
+ )
272
+ .config("spark.hadoop.fs.s3a.endpoint.region", region)
273
+ .config("spark.hadoop.fs.s3a.path.style.access", "false")
274
+ .config("spark.hadoop.fs.s3a.connection.ssl.enabled", "true")
275
+ )
276
+ return builder
277
+
278
+
204
279
  def run_pipeline(
205
280
  config: Optional[BatchAnalyticsConfig] = None,
206
281
  spark: Optional[SparkSession] = None,
@@ -281,12 +356,19 @@ def run_pipeline(
281
356
  df_transformed = remove_duplicates(df_transformed, key_columns=dedup_cols)
282
357
  result["stages"]["transform"] = {"skipped": True}
283
358
 
284
- # ----- Stage (to ClickHouse) - separate job before analytics -----
359
+ # ----- Stage (ClickHouse or Iceberg) - separate job before analytics -----
285
360
  if run_stage:
286
- logger.info("Stage: Stage to ClickHouse")
287
- stage_to_clickhouse(spark, df_transformed, config)
361
+ staging_format = (config.transform.staging_format or "").strip().lower()
362
+ if staging_format == "iceberg":
363
+ logger.info("Stage: Stage to Iceberg")
364
+ stage_to_iceberg(spark, df_transformed, config)
365
+ destination = config.transform.staging_table
366
+ else:
367
+ logger.info("Stage: Stage to ClickHouse")
368
+ stage_to_clickhouse(spark, df_transformed, config)
369
+ destination = f"{config.clickhouse.database}.{config.transform.staging_table}"
288
370
  result["stages"]["stage"] = {
289
- "destination": f"{config.clickhouse.database}.{config.transform.staging_table}",
371
+ "destination": destination,
290
372
  "row_count": df_transformed.count(),
291
373
  }
292
374
  else: