batch-analytics 0.3.35__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.35 → batch_analytics-0.3.36}/PKG-INFO +1 -1
  2. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/pyproject.toml +1 -1
  3. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/config.py +23 -3
  4. batch_analytics-0.3.36/src/batch_analytics/extract.py +375 -0
  5. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/job_runner.py +12 -5
  6. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/transform.py +92 -2
  7. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/PKG-INFO +1 -1
  8. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/SOURCES.txt +2 -1
  9. batch_analytics-0.3.36/tests/test_yield_quality_pivot.py +218 -0
  10. batch_analytics-0.3.35/src/batch_analytics/extract.py +0 -214
  11. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/README.md +0 -0
  12. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/setup.cfg +0 -0
  13. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/__init__.py +0 -0
  14. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/__main__.py +0 -0
  15. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/__init__.py +0 -0
  16. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/correlation.py +0 -0
  17. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/equipment_oee.py +0 -0
  18. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/gluon_autogluon_infer.py +0 -0
  19. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/gluon_autogluon_train.py +0 -0
  20. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/linear_regression.py +0 -0
  21. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/pca.py +0 -0
  22. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/pca_clustering.py +0 -0
  23. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/pca_core.py +0 -0
  24. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/pca_mvda.py +0 -0
  25. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/t_test.py +0 -0
  26. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/log.py +0 -0
  27. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/modules.py +0 -0
  28. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/output/__init__.py +0 -0
  29. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/output/base.py +0 -0
  30. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/output/clickhouse.py +0 -0
  31. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/output/local.py +0 -0
  32. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/output/s3.py +0 -0
  33. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/utils/__init__.py +0 -0
  34. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/utils/gluon_autogluon_common.py +0 -0
  35. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/dependency_links.txt +0 -0
  36. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/entry_points.txt +0 -0
  37. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/requires.txt +0 -0
  38. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/top_level.txt +0 -0
  39. {batch_analytics-0.3.35 → batch_analytics-0.3.36}/tests/test_iceberg_staging.py +0 -0
  40. {batch_analytics-0.3.35 → 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.35
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.35"
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,9 +106,8 @@ class TransformConfig:
85
106
  "/tmp/analytics_stage",
86
107
  )
87
108
  )
88
- # Output format for load_staged when reading (parquet/delta/clickhouse/iceberg).
89
- # Stage job always writes to ClickHouse; use clickhouse for analytics to read from staged table.
90
- # Use iceberg to read Nessie Iceberg gold/silver tables (BATCH_STAGING_TABLE = catalog.ns.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.
91
111
  staging_format: str = field(
92
112
  default_factory=lambda: os.environ.get("BATCH_STAGING_FORMAT", "clickhouse")
93
113
  )
@@ -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,
@@ -356,12 +356,19 @@ def run_pipeline(
356
356
  df_transformed = remove_duplicates(df_transformed, key_columns=dedup_cols)
357
357
  result["stages"]["transform"] = {"skipped": True}
358
358
 
359
- # ----- Stage (to ClickHouse) - separate job before analytics -----
359
+ # ----- Stage (ClickHouse or Iceberg) - separate job before analytics -----
360
360
  if run_stage:
361
- logger.info("Stage: Stage to ClickHouse")
362
- 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}"
363
370
  result["stages"]["stage"] = {
364
- "destination": f"{config.clickhouse.database}.{config.transform.staging_table}",
371
+ "destination": destination,
365
372
  "row_count": df_transformed.count(),
366
373
  }
367
374
  else:
@@ -200,10 +200,23 @@ def apply_spark_expr_columns(
200
200
  return out
201
201
 
202
202
 
203
+ def _pivot_column_prefix(spec: dict) -> str:
204
+ """Prefix for renamed pivot columns.
205
+
206
+ Omitted ``column_name_prefix`` defaults to ``v`` (``v_ph``). An explicit empty
207
+ string stays empty so the column is the sanitized pivot value (``ph``).
208
+ """
209
+ if "column_name_prefix" in spec and spec.get("column_name_prefix") is not None:
210
+ return str(spec.get("column_name_prefix")).strip()
211
+ return "v"
212
+
213
+
203
214
  def _pivot_output_column_name(prefix: str, pivot_value: str) -> str:
204
215
  """Stable identifier for pivoted columns (e.g. imp_rm_001 from RM-001)."""
205
- p = (prefix or "v").rstrip("_")
216
+ p = (prefix or "").rstrip("_")
206
217
  body = str(pivot_value).replace("-", "_").replace(" ", "_").lower()
218
+ if not p:
219
+ return body
207
220
  return f"{p}_{body}"
208
221
 
209
222
 
@@ -246,7 +259,7 @@ def apply_pivot(
246
259
  if not group_by or not pivot_col or not value_col:
247
260
  raise ValueError("pivot spec requires group_by, pivot_column, value_column")
248
261
  agg_name = (spec.get("agg") or "max").strip().lower()
249
- prefix = (spec.get("column_name_prefix") or "v").strip() or "v"
262
+ prefix = _pivot_column_prefix(spec)
250
263
  rename_pivot = spec.get("rename_pivot_columns", True)
251
264
 
252
265
  from pyspark.sql import functions as F
@@ -494,6 +507,31 @@ def stage_to_clickhouse(
494
507
  )
495
508
 
496
509
 
510
+ def stage_to_iceberg(
511
+ spark: SparkSession,
512
+ df: DataFrame,
513
+ config: BatchAnalyticsConfig,
514
+ ) -> None:
515
+ """Write transformed data to a Nessie Iceberg table (``BATCH_STAGING_FORMAT=iceberg``)."""
516
+ del spark # catalog is already registered on the session that owns ``df``
517
+ n = df.count()
518
+ mode = _normalize_staging_write_mode(config.transform.staging_write_mode)
519
+ table = quote_iceberg_identifier(_resolve_iceberg_table_name(config))
520
+ logger.info(
521
+ "Staging to Iceberg %s (mode=%s); dataframe columns (%d): %s",
522
+ table,
523
+ mode,
524
+ len(df.columns),
525
+ sorted(df.columns),
526
+ )
527
+ writer = df.writeTo(table)
528
+ if mode == "overwrite":
529
+ writer.createOrReplace()
530
+ else:
531
+ writer.append()
532
+ logger.info("Staged data to Iceberg %s (%d rows)", table, n)
533
+
534
+
497
535
  def stage_to_path(
498
536
  spark: SparkSession,
499
537
  df: DataFrame,
@@ -570,6 +608,58 @@ def _staging_where_clause(config: BatchAnalyticsConfig) -> str:
570
608
  return f" WHERE {filt}" if filt else ""
571
609
 
572
610
 
611
+ _SIMPLE_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
612
+
613
+
614
+ def quote_iceberg_identifier(raw: str) -> str:
615
+ """Quote Iceberg path segments that are not plain Spark identifiers.
616
+
617
+ ``nessie.gold.batch_yield_features`` stays unquoted.
618
+ A hyphenated tenant namespace is wrapped in backticks.
619
+ """
620
+ text = (raw or "").strip()
621
+ if not text:
622
+ raise ValueError("Iceberg table name is empty")
623
+ parts = _split_iceberg_identifier(text)
624
+ return ".".join(_quote_iceberg_part(part) for part in parts)
625
+
626
+
627
+ def _split_iceberg_identifier(text: str) -> List[str]:
628
+ parts: List[str] = []
629
+ buf: List[str] = []
630
+ in_quote = False
631
+ i = 0
632
+ while i < len(text):
633
+ ch = text[i]
634
+ if ch == "`":
635
+ if in_quote and i + 1 < len(text) and text[i + 1] == "`":
636
+ buf.append("`")
637
+ i += 2
638
+ continue
639
+ in_quote = not in_quote
640
+ i += 1
641
+ continue
642
+ if ch == "." and not in_quote:
643
+ parts.append("".join(buf))
644
+ buf = []
645
+ i += 1
646
+ continue
647
+ buf.append(ch)
648
+ i += 1
649
+ if in_quote:
650
+ raise ValueError(f"Unbalanced backticks in Iceberg identifier: {text}")
651
+ parts.append("".join(buf))
652
+ if any(part == "" for part in parts):
653
+ raise ValueError(f"Empty segment in Iceberg identifier: {text}")
654
+ return parts
655
+
656
+
657
+ def _quote_iceberg_part(part: str) -> str:
658
+ if _SIMPLE_IDENT.match(part):
659
+ return part
660
+ return "`" + part.replace("`", "``") + "`"
661
+
662
+
573
663
  def _resolve_iceberg_table_name(config: BatchAnalyticsConfig) -> str:
574
664
  """Return fully-qualified Iceberg table id (catalog.namespace.table)."""
575
665
  raw = (config.transform.staging_table or "").strip()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batch-analytics
3
- Version: 0.3.35
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
@@ -33,4 +33,5 @@ src/batch_analytics/output/s3.py
33
33
  src/batch_analytics/utils/__init__.py
34
34
  src/batch_analytics/utils/gluon_autogluon_common.py
35
35
  tests/test_iceberg_staging.py
36
- tests/test_pca_mvda.py
36
+ tests/test_pca_mvda.py
37
+ tests/test_yield_quality_pivot.py
@@ -0,0 +1,218 @@
1
+ """Wide yield/quality pivot on local Spark, using gold-shaped fixture rows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from pathlib import Path
11
+ from unittest import mock
12
+ from unittest.mock import MagicMock
13
+
14
+ _TESTS = Path(__file__).resolve().parent
15
+ if str(_TESTS) not in sys.path:
16
+ sys.path.insert(0, str(_TESTS))
17
+
18
+ from fixtures.yield_quality_wide import ( # noqa: E402
19
+ AGGREGATE_BEFORE_JOIN,
20
+ EXPECTED_WIDE_ROWS,
21
+ EXPR_COLUMNS,
22
+ EXTRACT_COLUMNS,
23
+ PIVOT_SPEC,
24
+ QUALITY_ROWS,
25
+ QUALITY_TABLE,
26
+ QUOTED_WIDE_TABLE,
27
+ WIDE_TABLE,
28
+ YIELD_ROWS,
29
+ YIELD_TABLE,
30
+ )
31
+
32
+ from batch_analytics.config import BatchAnalyticsConfig, ExtractConfig, TransformConfig
33
+ from batch_analytics.extract import unify_dataframes
34
+ from batch_analytics.transform import (
35
+ _pivot_output_column_name,
36
+ quote_iceberg_identifier,
37
+ stage_to_iceberg,
38
+ transform,
39
+ )
40
+
41
+
42
+ def _json_frame(spark, rows):
43
+ """Load rows through the JVM JSON reader.
44
+
45
+ ``createDataFrame`` pickles each row. PySpark 3.5 on Python 3.14 recurses
46
+ inside cloudpickle, so the fixture is written as JSON lines instead.
47
+ """
48
+ handle, path = tempfile.mkstemp(suffix=".jsonl")
49
+ os.close(handle)
50
+ with open(path, "w", encoding="utf-8") as fh:
51
+ for row in rows:
52
+ fh.write(json.dumps(row) + "\n")
53
+ return spark.read.json(path)
54
+
55
+
56
+ def _frames(spark):
57
+ return {
58
+ QUALITY_TABLE: _json_frame(spark, QUALITY_ROWS),
59
+ YIELD_TABLE: _json_frame(spark, YIELD_ROWS),
60
+ }
61
+
62
+
63
+ def _config() -> BatchAnalyticsConfig:
64
+ return BatchAnalyticsConfig(
65
+ extract=ExtractConfig(
66
+ source_tables=f"{QUALITY_TABLE},{YIELD_TABLE}",
67
+ source_format="iceberg",
68
+ join_keys="batch_no",
69
+ columns_json=json.dumps(EXTRACT_COLUMNS),
70
+ aggregate_before_join_json=json.dumps(AGGREGATE_BEFORE_JOIN),
71
+ filter_column="",
72
+ filter_values="",
73
+ use_native_connector=False,
74
+ ),
75
+ transform=TransformConfig(
76
+ dedup_columns="batch_no",
77
+ add_dimension_column="__absent__",
78
+ expr_columns_json=json.dumps(EXPR_COLUMNS),
79
+ pivot_json=json.dumps(PIVOT_SPEC),
80
+ staging_format="iceberg",
81
+ staging_table=WIDE_TABLE,
82
+ staging_write_mode="overwrite",
83
+ ),
84
+ )
85
+
86
+
87
+ class PivotNameTests(unittest.TestCase):
88
+ def test_source_format_defaults_to_clickhouse(self) -> None:
89
+ with mock.patch.dict(os.environ, {}, clear=False):
90
+ os.environ.pop("BATCH_SOURCE_FORMAT", None)
91
+ cfg = ExtractConfig()
92
+ self.assertEqual(cfg.source_format, "clickhouse")
93
+
94
+ def test_empty_prefix_sanitizes_test_name(self) -> None:
95
+ self.assertEqual(_pivot_output_column_name("", "NaOCl Assay"), "naocl_assay")
96
+ self.assertEqual(_pivot_output_column_name("", "pH"), "ph")
97
+
98
+ def test_default_prefix_keeps_v(self) -> None:
99
+ self.assertEqual(_pivot_output_column_name("v", "pH"), "v_ph")
100
+
101
+ def test_hyphenated_tenant_namespace_is_quoted(self) -> None:
102
+ self.assertEqual(quote_iceberg_identifier(WIDE_TABLE), QUOTED_WIDE_TABLE)
103
+ self.assertEqual(
104
+ quote_iceberg_identifier("nessie.gold.batch_yield_features"),
105
+ "nessie.gold.batch_yield_features",
106
+ )
107
+
108
+
109
+ class StageIcebergTests(unittest.TestCase):
110
+ def test_overwrite_uses_quoted_write_to(self) -> None:
111
+ df = MagicMock()
112
+ writer = MagicMock()
113
+ df.writeTo.return_value = writer
114
+ df.columns = ["batch_no", "actual_yield_qty", "ph"]
115
+ df.count.return_value = 3
116
+ stage_to_iceberg(MagicMock(), df, _config())
117
+ df.writeTo.assert_called_once_with(QUOTED_WIDE_TABLE)
118
+ writer.createOrReplace.assert_called_once()
119
+ writer.append.assert_not_called()
120
+
121
+ def test_append_mode(self) -> None:
122
+ cfg = _config()
123
+ cfg.transform.staging_write_mode = "append"
124
+ df = MagicMock()
125
+ writer = MagicMock()
126
+ df.writeTo.return_value = writer
127
+ df.columns = ["batch_no"]
128
+ df.count.return_value = 1
129
+ stage_to_iceberg(MagicMock(), df, cfg)
130
+ writer.append.assert_called_once()
131
+ writer.createOrReplace.assert_not_called()
132
+
133
+
134
+ class YieldQualityWideTests(unittest.TestCase):
135
+ @classmethod
136
+ def setUpClass(cls) -> None:
137
+ from pyspark.sql import SparkSession
138
+
139
+ cls.spark = (
140
+ SparkSession.builder.master("local[1]")
141
+ .appName("yield-quality-pivot")
142
+ .config("spark.ui.enabled", "false")
143
+ .config("spark.sql.shuffle.partitions", "2")
144
+ .getOrCreate()
145
+ )
146
+
147
+ @classmethod
148
+ def tearDownClass(cls) -> None:
149
+ cls.spark.stop()
150
+
151
+ def setUp(self) -> None:
152
+ self._env = {
153
+ "BATCH_TRANSFORM_EXPR_COLUMNS": os.environ.pop("BATCH_TRANSFORM_EXPR_COLUMNS", None),
154
+ "BATCH_TRANSFORM_PIVOT_JSON": os.environ.pop("BATCH_TRANSFORM_PIVOT_JSON", None),
155
+ }
156
+
157
+ def tearDown(self) -> None:
158
+ for key, value in self._env.items():
159
+ if value is None:
160
+ os.environ.pop(key, None)
161
+ else:
162
+ os.environ[key] = value
163
+
164
+ def test_pivot_matches_expected_wide_rows(self) -> None:
165
+ wide = transform(unify_dataframes(_frames(self.spark), _config()), _config())
166
+ rows = {row["batch_no"]: row.asDict() for row in wide.collect()}
167
+ self.assertEqual(set(rows), {"SL25264", "SL25265", "SL25266"})
168
+ self.assertEqual(
169
+ set(wide.columns),
170
+ {"batch_no", "actual_yield_qty", "ph", "naocl_assay", "temperature"},
171
+ )
172
+ for expected in EXPECTED_WIDE_ROWS:
173
+ got = rows[expected["batch_no"]]
174
+ self.assertEqual(got["batch_no"], expected["batch_no"])
175
+ for col_name in ("actual_yield_qty", "ph", "naocl_assay", "temperature"):
176
+ self._assert_close(got[col_name], expected[col_name], col_name, expected["batch_no"])
177
+
178
+ def test_duplicate_non_key_columns_are_rejected(self) -> None:
179
+ quality = _json_frame(self.spark, QUALITY_ROWS).select("batch_no", "document_id", "test_name")
180
+ yield_df = _json_frame(self.spark, YIELD_ROWS).select("batch_no", "document_id", "actual_yield_qty")
181
+ cfg = BatchAnalyticsConfig(
182
+ extract=ExtractConfig(
183
+ source_tables="q,y",
184
+ join_keys="batch_no",
185
+ columns_json="",
186
+ aggregate_before_join_json="",
187
+ filter_column="",
188
+ filter_values="",
189
+ use_native_connector=False,
190
+ )
191
+ )
192
+ with self.assertRaises(ValueError) as ctx:
193
+ unify_dataframes({"q": quality, "y": yield_df}, cfg)
194
+ self.assertIn("document_id", str(ctx.exception))
195
+
196
+ def test_omitted_prefix_uses_v(self) -> None:
197
+ spec = dict(PIVOT_SPEC)
198
+ del spec["column_name_prefix"]
199
+ cfg = _config()
200
+ cfg.transform.pivot_json = json.dumps(spec)
201
+ wide = transform(unify_dataframes(_frames(self.spark), cfg), cfg)
202
+ self.assertIn("v_ph", wide.columns)
203
+ self.assertNotIn("ph", wide.columns)
204
+
205
+ @staticmethod
206
+ def _assert_close(got, expected, col_name: str, batch_no: str) -> None:
207
+ if expected is None:
208
+ if got is not None:
209
+ raise AssertionError(f"{batch_no}.{col_name}: expected null, got {got}")
210
+ return
211
+ if got is None:
212
+ raise AssertionError(f"{batch_no}.{col_name}: expected {expected}, got null")
213
+ if abs(float(got) - float(expected)) > 1e-6:
214
+ raise AssertionError(f"{batch_no}.{col_name}: expected {expected}, got {got}")
215
+
216
+
217
+ if __name__ == "__main__":
218
+ unittest.main()
@@ -1,214 +0,0 @@
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.functions import col
12
-
13
- from .config import BatchAnalyticsConfig
14
-
15
- logger = logging.getLogger(__name__)
16
-
17
-
18
- def parse_extract_filter_values(raw: str) -> List[str]:
19
- """
20
- Parse BATCH_EXTRACT_FILTER_VALUES: comma-separated tokens, or JSON array string.
21
-
22
- Examples:
23
- a,b,c -> ["a","b","c"]
24
- ["GP/A","GP/B"] -> JSON list (values may contain commas)
25
- """
26
- text = (raw or "").strip()
27
- if not text:
28
- return []
29
- if text.startswith("["):
30
- try:
31
- data = json.loads(text)
32
- if isinstance(data, list):
33
- out = [str(x).strip() for x in data if str(x).strip()]
34
- return out
35
- except json.JSONDecodeError:
36
- logger.warning("BATCH_EXTRACT_FILTER_VALUES looks like JSON but failed to parse; using comma split")
37
- return [p.strip() for p in text.split(",") if p.strip()]
38
-
39
-
40
- def _apply_extract_filter(df: DataFrame, config: BatchAnalyticsConfig) -> DataFrame:
41
- """Apply col IN (values) when filter_column is set; empty column = no filter."""
42
- col_name = (config.extract.filter_column or "").strip()
43
- if not col_name:
44
- return df
45
- if col_name not in df.columns:
46
- logger.warning(
47
- "BATCH_EXTRACT_FILTER_COLUMN=%r not in extracted columns %s; skipping filter",
48
- col_name,
49
- df.columns,
50
- )
51
- return df
52
- values = parse_extract_filter_values(config.extract.filter_values)
53
- if not values:
54
- logger.warning(
55
- "BATCH_EXTRACT_FILTER_COLUMN=%r set but BATCH_EXTRACT_FILTER_VALUES is empty; skipping IN filter",
56
- col_name,
57
- )
58
- return df
59
- filtered = df.filter(col(col_name).isin(values))
60
- return filtered
61
-
62
-
63
- def _parse_ch_database_table(table: str, default_database: str) -> Tuple[str, str]:
64
- """
65
- Resolve ``table`` reference to (database, table_name).
66
-
67
- ``batch_metric_facts`` → (default_database, batch_metric_facts)
68
- ``analytics.batch_metric_facts`` → (analytics, batch_metric_facts)
69
- """
70
- t = (table or "").strip()
71
- if not t:
72
- return default_database, t
73
- if "." in t and not t.startswith("("):
74
- db, tbl = t.split(".", 1)
75
- db, tbl = db.strip(), tbl.strip()
76
- if db and tbl:
77
- return db, tbl
78
- return default_database, t
79
-
80
-
81
- def _read_via_catalog(spark: SparkSession, cfg: BatchAnalyticsConfig, table: str) -> Optional[DataFrame]:
82
- """
83
- Read via Spark SQL catalog (ClickHouseCatalog), registered in job_runner.create_spark_session.
84
-
85
- clickhouse-spark-runtime does **not** register legacy short name ``format(\"clickhouse\")`` /
86
- ``clickhouse.DefaultSource``; catalog + ``spark.table(catalog.db.table)`` is the supported path.
87
- """
88
- cat = os.environ.get("BATCH_CLICKHOUSE_CATALOG", "batch_ch").strip()
89
- if not cat:
90
- return None
91
- db, tbl = _parse_ch_database_table(table, cfg.clickhouse.database)
92
- ident = f"{cat}.{db}.{tbl}"
93
- try:
94
- return spark.table(ident)
95
- except Exception as e:
96
- logger.warning(
97
- "Catalog read failed for %s (%s): %s. Trying other readers.",
98
- ident,
99
- table,
100
- e,
101
- )
102
- return None
103
-
104
-
105
- def _read_via_format(spark: SparkSession, cfg: BatchAnalyticsConfig, table: str) -> Optional[DataFrame]:
106
- """
107
- Read from ClickHouse using the native format API (clickhouse-spark-runtime).
108
- Requires: com.clickhouse.spark:clickhouse-spark-runtime in spark.jars.packages
109
- """
110
- try:
111
- rd = (
112
- spark.read.format("clickhouse")
113
- .option("host", cfg.clickhouse.host)
114
- .option("protocol", cfg.clickhouse.protocol)
115
- .option("http_port", str(cfg.clickhouse.port))
116
- .option("database", cfg.clickhouse.database)
117
- .option("table", table)
118
- .option("user", cfg.clickhouse.user)
119
- )
120
- if cfg.clickhouse.password:
121
- rd = rd.option("password", cfg.clickhouse.password)
122
- df = rd.load()
123
- return df
124
- except Exception as e:
125
- logger.warning(
126
- "Native ClickHouse connector failed for table %s: %s. Falling back to JDBC.",
127
- table,
128
- e,
129
- )
130
- return None
131
-
132
-
133
- def _read_via_jdbc(spark: SparkSession, cfg: BatchAnalyticsConfig, table: str) -> DataFrame:
134
- """Read from ClickHouse via JDBC."""
135
- return spark.read.jdbc(
136
- cfg.clickhouse.jdbc_url,
137
- table,
138
- properties=cfg.clickhouse.jdbc_properties,
139
- )
140
-
141
-
142
- def extract_table(
143
- spark: SparkSession,
144
- table: str,
145
- config: BatchAnalyticsConfig,
146
- ) -> DataFrame:
147
- """
148
- Extract a single table from ClickHouse.
149
- Uses native connector if configured, otherwise JDBC.
150
- """
151
- if config.extract.use_native_connector:
152
- # Prefer catalog (matches clickhouse-spark-runtime); avoid legacy DefaultSource path.
153
- df = _read_via_catalog(spark, config, table)
154
- if df is None:
155
- df = _read_via_format(spark, config, table)
156
- if df is None:
157
- df = _read_via_jdbc(spark, config, table)
158
- else:
159
- df = _read_via_jdbc(spark, config, table)
160
-
161
- df = _apply_extract_filter(df, config)
162
- logger.info("Extracted table %s: %d rows", table, df.count())
163
- return df
164
-
165
-
166
- def extract_all(
167
- spark: SparkSession,
168
- config: BatchAnalyticsConfig,
169
- ) -> Dict[str, DataFrame]:
170
- """
171
- Extract all configured source tables from ClickHouse.
172
- Returns a dict mapping table name to DataFrame.
173
- """
174
- tables = [t.strip() for t in config.extract.source_tables.split(",") if t.strip()]
175
- if not tables:
176
- raise ValueError("No source tables configured in BATCH_SOURCE_TABLES")
177
-
178
- result: Dict[str, DataFrame] = {}
179
- for table in tables:
180
- df = extract_table(spark, table, config)
181
- result[table] = df
182
-
183
- return result
184
-
185
-
186
- def extract_unified(
187
- spark: SparkSession,
188
- config: BatchAnalyticsConfig,
189
- join_keys: Optional[List[str]] = None,
190
- primary_table: Optional[str] = None,
191
- ) -> DataFrame:
192
- """
193
- Extract and unify source tables into one DataFrame.
194
- - Single table: returns it directly.
195
- - Multiple tables + join_keys: joins on those keys (left join, first table base).
196
- - Multiple tables, no join_keys: returns the primary (or first) table.
197
- Use primary_table to choose which table to use for analytics.
198
- """
199
- all_dfs = extract_all(spark, config)
200
-
201
- if len(all_dfs) == 1:
202
- return list(all_dfs.values())[0]
203
-
204
- if join_keys:
205
- dfs = list(all_dfs.values())
206
- base = dfs[0]
207
- for other in dfs[1:]:
208
- base = base.join(other, on=join_keys, how="left")
209
- return base
210
-
211
- # Multiple tables, no join: use primary or first
212
- if primary_table and primary_table in all_dfs:
213
- return all_dfs[primary_table]
214
- return list(all_dfs.values())[0]