@1aboveio/skills 0.10.0 → 0.11.0

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.
@@ -0,0 +1,696 @@
1
+ from abc import ABC, abstractmethod
2
+ from argparse import ArgumentParser
3
+ from contextlib import contextmanager
4
+ import enum
5
+ import logging
6
+ import os
7
+ import sys
8
+ import threading
9
+ from typing import List, Dict, Union, Literal, Optional
10
+
11
+ from dateutil.relativedelta import relativedelta
12
+ import pandas as pd
13
+ from pyspark.sql import SparkSession, DataFrame
14
+ from pyspark.sql.functions import *
15
+ from pyspark.sql.types import *
16
+ from pyspark.sql.utils import AnalysisException
17
+ import typer
18
+ from typing_extensions import Annotated
19
+
20
+ from utils.hudi_metadata import hudi_metadata_write_options
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ _SPARK_LEASE_LOCK = threading.RLock()
26
+ _SPARK_LEASES = {}
27
+
28
+
29
+ def _classic_spark_runtime_exists() -> bool:
30
+ from pyspark import SparkContext
31
+
32
+ session = (
33
+ SparkSession.getActiveSession()
34
+ or getattr(SparkSession, "_instantiatedSession", None)
35
+ )
36
+ if (
37
+ session is not None
38
+ and getattr(getattr(session, "_sc", None), "_jsc", None) is not None
39
+ ):
40
+ return True
41
+ context = getattr(SparkContext, "_active_spark_context", None)
42
+ return context is not None and getattr(context, "_jsc", None) is not None
43
+
44
+
45
+ def _connect_spark_runtime_exists() -> bool:
46
+ module = sys.modules.get("pyspark.sql.connect.session")
47
+ connect_session = getattr(module, "SparkSession", None) if module else None
48
+ if connect_session is None:
49
+ return False
50
+ return (
51
+ connect_session.getActiveSession() is not None
52
+ or getattr(connect_session, "_default_session", None) is not None
53
+ )
54
+
55
+
56
+ def _spark_runtime_exists(spark_remote: Optional[str]) -> bool:
57
+ connect_mode = bool(
58
+ spark_remote
59
+ or os.environ.get("SPARK_REMOTE")
60
+ or os.environ.get("SPARK_CONNECT_MODE_ENABLED")
61
+ )
62
+ return (
63
+ _connect_spark_runtime_exists()
64
+ if connect_mode
65
+ else _classic_spark_runtime_exists()
66
+ )
67
+
68
+
69
+ def create_spark_session(spark_remote: Optional[str] = None) -> SparkSession:
70
+ """Create a Spark session in Spark Connect or classic spark-submit mode."""
71
+ builder = SparkSession.builder
72
+ if spark_remote:
73
+ builder = builder.remote(spark_remote)
74
+ return builder.getOrCreate()
75
+
76
+
77
+ @contextmanager
78
+ def managed_spark_session(
79
+ spark_remote: Optional[str] = None,
80
+ *,
81
+ builder=None,
82
+ ):
83
+ """Lease a Spark session and stop only a runtime created by this helper."""
84
+ with _SPARK_LEASE_LOCK:
85
+ runtime_existed = _spark_runtime_exists(spark_remote)
86
+ spark = (
87
+ builder.getOrCreate()
88
+ if builder is not None
89
+ else create_spark_session(spark_remote)
90
+ )
91
+ key = id(spark)
92
+ state = _SPARK_LEASES.get(key)
93
+ if state is None:
94
+ state = {
95
+ "leases": 0,
96
+ "helper_owned": not runtime_existed,
97
+ }
98
+ _SPARK_LEASES[key] = state
99
+ state["leases"] += 1
100
+
101
+ try:
102
+ yield spark
103
+ finally:
104
+ with _SPARK_LEASE_LOCK:
105
+ state = _SPARK_LEASES.get(key)
106
+ if state is None:
107
+ return
108
+ state["leases"] -= 1
109
+ if state["leases"] == 0:
110
+ del _SPARK_LEASES[key]
111
+ if state["helper_owned"]:
112
+ spark.stop()
113
+
114
+
115
+ class Etl(ABC):
116
+ """
117
+ The base class for ETL jobs.
118
+
119
+ Attributes:
120
+ ----------
121
+ id: str
122
+ field name of the unique key, required in Hudi table
123
+ ts: str
124
+ field name of the record unique timestamp, used as Hudi `preCombine` field
125
+ filter_by: str
126
+ field name filtering in `extract` function
127
+
128
+ Methods:
129
+ -------
130
+ extract(start_date: str, end_date: str):
131
+ extract table from <`src_db`.`src_tbl`>
132
+
133
+ transform(df: DataFrame):
134
+ performs transformation to df
135
+
136
+ load(df):
137
+ load into <`dst_db`.`dst_tbl`> as `spark_table` or `hudi_table` based on the `table_type` setting
138
+ """
139
+
140
+ table_type: Literal["hudi_table", "spark_table"] = "hudi_table"
141
+ hudi_mode: Literal["upsert", "insert_overwrite", "insert_overwrite_table"] = (
142
+ "upsert"
143
+ )
144
+ src_db = None
145
+ src_tbl = None
146
+ dst_db = None
147
+ dst_tbl = None
148
+ id = None
149
+ ts = None
150
+ filter_by = None
151
+ offset = None
152
+ par_cols = []
153
+ path = None
154
+ repartition = []
155
+ url: str = None
156
+ concurrency_mode: Literal["SINGLE_WRITER", "OPTIMISTIC_CONCURRENCY_CONTROL"] = (
157
+ "SINGLE_WRITER"
158
+ )
159
+ extra_hudi_options: Dict[str, Union[str, bool, int]] = {}
160
+ zookeeper: str = None
161
+
162
+ def __init__(
163
+ self,
164
+ start_date: str = None,
165
+ end_date: str = None,
166
+ zookeeper: str = None,
167
+ url: str = None,
168
+ bulk: bool = True,
169
+ hudi_mode_override: str = None,
170
+ **kwargs,
171
+ ):
172
+ fields = ["dst_db", "dst_tbl", "path"]
173
+ if self.table_type == "hudi_table":
174
+ fields += ["id", "ts"]
175
+ for field in fields:
176
+ if getattr(self, field) is None:
177
+ raise ValueError(
178
+ f"Field {field} cannot be None. Please set {field} before proceed"
179
+ )
180
+ if len(self.par_cols) == 0:
181
+ logger.warning("`par_cols` is empty; output will not be partitioned")
182
+ if type(self.repartition) != list:
183
+ self.repartition = [self.repartition]
184
+ if self.filter_by is None:
185
+ logger.warning(
186
+ "`filter_by` is not set, will extract full table, please be aware!"
187
+ )
188
+ self.start_date: str = start_date if start_date else None
189
+ self.end_date: str = end_date if end_date else None
190
+ self.zookeeper = zookeeper if zookeeper else self.zookeeper
191
+ self.url = url if url else self.url
192
+ self.bulk = bulk
193
+ # Allow CLI override of hudi_mode (e.g. --hudi-mode bulk_insert)
194
+ if hudi_mode_override:
195
+ self.hudi_mode = hudi_mode_override
196
+ for k, v in kwargs.items():
197
+ setattr(self, k, v)
198
+
199
+ if (
200
+ self.concurrency_mode == "OPTIMISTIC_CONCURRENCY_CONTROL"
201
+ and self.zookeeper is None
202
+ ):
203
+ raise ValueError("concurrency control enabled but zookeeper not set")
204
+ self.spark = SparkSession.getActiveSession()
205
+
206
+ def __call__(self):
207
+ self.process()
208
+
209
+ @classmethod
210
+ def run_from_cli(
211
+ cls,
212
+ start_date: Annotated[str, typer.Option("--start-date")] = None,
213
+ end_date: Annotated[str, typer.Option("--end-date")] = None,
214
+ zookeeper: Annotated[str, typer.Option("--zookeeper")] = None,
215
+ url: Annotated[str, typer.Option("--url")] = None,
216
+ bulk: Annotated[bool, typer.Option("--bulk/--per-day")] = True,
217
+ ):
218
+ obj = cls.__new__(cls)
219
+ obj.__init__(start_date, end_date, zookeeper, url, bulk)
220
+ obj()
221
+ return obj
222
+
223
+ def extract(self, start_date: str = None, end_date: str = None):
224
+ start_date = start_date or self.start_date
225
+ end_date = end_date or self.end_date
226
+ df = self.spark.table(f"{self.src_db}.{self.src_tbl}")
227
+
228
+ if self.offset and start_date:
229
+ from dateutil.relativedelta import relativedelta
230
+ from dateutil.parser import parse as date_parse
231
+
232
+ start_date = date_parse(start_date) - relativedelta(days=self.offset)
233
+ start_date = start_date.strftime("%Y-%m-%d")
234
+
235
+ if self.filter_by and start_date:
236
+ df = df.filter(col(self.filter_by) >= start_date)
237
+ if self.filter_by and end_date:
238
+ df = df.filter(col(self.filter_by) < end_date)
239
+ return df
240
+
241
+ def select_or_none(
242
+ self, df: DataFrame, cols: Union[List, Dict[str, Union[str, DataType]]]
243
+ ) -> DataFrame:
244
+ if type(cols) == list:
245
+ available_cols = [i for i in cols if i in df.columns]
246
+ missing_cols = [i for i in cols if i not in df.columns]
247
+ df = df.select(*available_cols)
248
+
249
+ logger.warning(
250
+ f"Columns {missing_cols} are missing, filling with None and cast as default StringType"
251
+ )
252
+ for col in missing_cols:
253
+ df = df.withColumn(col, lit(None).cast(StringType()))
254
+ elif type(cols) == dict:
255
+ available_cols = [i for i in cols.keys() if i in df.columns]
256
+ missing_cols = [i for i in cols.keys() if i not in df.columns]
257
+ df = df.select(*available_cols)
258
+ for col in missing_cols:
259
+ col_type = cols[col]
260
+ df = df.withColumn(col, lit(None).cast(col_type))
261
+ else:
262
+ raise TypeError(f"cols {cols} should be List or Dict")
263
+ df = df.select(*cols)
264
+ return df
265
+
266
+ @abstractmethod
267
+ def transform(self, df: DataFrame) -> DataFrame:
268
+ return df
269
+
270
+ def _prepare_partition(self, df: DataFrame) -> DataFrame:
271
+ self.spark.conf.set("spark.sql.sources.partitionOverwriteMode", "DYNAMIC")
272
+ new_cols = [i for i in df.columns if i not in self.par_cols] + self.par_cols
273
+ df = df.select(*new_cols)
274
+ if self.start_date and self.filter_by:
275
+ df = df.filter(col(self.filter_by) >= self.start_date)
276
+ return df
277
+
278
+ def _is_existing_hudi_table(self) -> bool:
279
+ """Check if a Hudi table already exists at `self.path`.
280
+
281
+ Used to determine whether we can perform an `upsert` (requires existing
282
+ table metadata at `.hoodie/hoodie.properties`) or need to fall back to
283
+ `bulk_insert` for the initial load.
284
+ """
285
+ try:
286
+ self.spark.read.text(f"{self.path}/.hoodie/hoodie.properties").limit(1).collect()
287
+ return True
288
+ except Exception:
289
+ return False
290
+
291
+ def load_hudi(self, df: DataFrame) -> DataFrame:
292
+ # -----------------------------------------------------------------
293
+ # Hudi write operation auto-detection (added 2026-03-02)
294
+ # -----------------------------------------------------------------
295
+ # In Hudi 0.14+, the `upsert` operation expects an existing Hudi table
296
+ # with `.hoodie/hoodie.properties` on HDFS. Writing to a *new* path
297
+ # with `upsert` fails with `HoodieUpsertException`.
298
+ #
299
+ # Previously (Hudi 0.12), `upsert` could auto-create new tables.
300
+ #
301
+ # Decision: auto-detect whether the table exists. If not, override
302
+ # the operation to `bulk_insert` for the initial load. Subsequent
303
+ # writes use the configured `hudi_mode` (typically `upsert`).
304
+ # -----------------------------------------------------------------
305
+ operation = self.hudi_mode
306
+ needs_existing_table = operation in ("upsert", "insert_overwrite", "insert_overwrite_table")
307
+ if needs_existing_table and not self._is_existing_hudi_table():
308
+ logger.info(
309
+ f"Hudi table not found at {self.path}, "
310
+ f"falling back to 'bulk_insert' for initial load "
311
+ f"(configured mode: {operation})"
312
+ )
313
+ operation = "bulk_insert"
314
+
315
+ hudi_options = {
316
+ "hoodie.table.name": f"{self.dst_db}.{self.dst_tbl}",
317
+ "hoodie.datasource.write.table.type": "COPY_ON_WRITE",
318
+ "hoodie.datasource.write.recordkey.field": self.id,
319
+ "hoodie.datasource.write.partitionpath.field": ",".join(self.par_cols),
320
+ "hoodie.datasource.write.keygenerator.class": "org.apache.hudi.keygen.ComplexKeyGenerator",
321
+ "hoodie.datasource.write.precombine.field": self.ts,
322
+ "hoodie.datasource.write.operation": f"{operation}",
323
+ "hoodie.datasource.write.reconcile.schema": True,
324
+ "hoodie.schema.on.read.enable": True,
325
+ # ---------------------------------------------------------------
326
+ # Hudi 0.15 ComplexKeyGenerator regression fix
327
+ # ---------------------------------------------------------------
328
+ # Hudi 0.14.1, 0.15.0, 1.0.x introduced a regression in
329
+ # ComplexKeyGenerator with a single record key field - the record
330
+ # key encoding changed, which can cause duplicate records during
331
+ # upserts. Setting this to `true` ensures consistent encoding
332
+ # for tables created on these versions.
333
+ #
334
+ # Ref: https://hudi.apache.org/releases/release-1.1.1
335
+ # ---------------------------------------------------------------
336
+ "hoodie.write.complex.keygen.new.encoding": True,
337
+ "hoodie.datasource.hive_sync.enable": True,
338
+ "hoodie.datasource.hive_sync.create_managed_table": True,
339
+ "hoodie.datasource.hive_sync.database": self.dst_db,
340
+ "hoodie.datasource.hive_sync.table": self.dst_tbl,
341
+ "hoodie.datasource.hive_sync.skip_ro_suffix": False,
342
+ "hoodie.datasource.hive_sync.support_timestamp": True,
343
+ "hoodie.datasource.write.hive_style_partitioning": True,
344
+ "hoodie.metadata.enable": False,
345
+ "hoodie.write.concurrency.mode": f"{self.concurrency_mode}",
346
+ "hoodie.write.lock.zookeeper.base_path": "/hudi",
347
+ "hoodie.write.lock.zookeeper.lock_key": f"{self.dst_db}.{self.dst_tbl}",
348
+ "hoodie.write.lock.zookeeper.port": "2181",
349
+ "hoodie.write.lock.zookeeper.url": f"{self.zookeeper}",
350
+ "path": self.path,
351
+ }
352
+ hudi_options.update(self.extra_hudi_options)
353
+ # Applied last, deliberately. A table inside the daily v3 metadata
354
+ # listing scope must have its Hudi metadata index maintained by every
355
+ # writer; an `extra_hudi_options` opt-out would leave the index stale
356
+ # and make readers see the wrong file list. Tables outside the scope
357
+ # keep `hoodie.metadata.enable=false` exactly as before.
358
+ hudi_options.update(
359
+ hudi_metadata_write_options(f"{self.dst_db}.{self.dst_tbl}")
360
+ )
361
+
362
+ df.write.format("hudi").options(**hudi_options).mode("append").save()
363
+
364
+ def load_spark(self, df: DataFrame):
365
+ output = df.repartition(*self.repartition) if self.repartition else df
366
+ try:
367
+ output.write.mode("overwrite").insertInto(f"{self.dst_db}.{self.dst_tbl}")
368
+ except AnalysisException:
369
+ writer = output.write.option("path", self.path).mode("overwrite")
370
+ if len(self.par_cols) == 0:
371
+ writer.saveAsTable(f"{self.dst_db}.{self.dst_tbl}")
372
+ else:
373
+ writer.partitionBy(*self.par_cols).saveAsTable(
374
+ f"{self.dst_db}.{self.dst_tbl}"
375
+ )
376
+
377
+ def load(self, df: DataFrame):
378
+ if len(self.par_cols) > 0:
379
+ df = self._prepare_partition(df)
380
+ if self.table_type == "hudi_table":
381
+ self.load_hudi(df)
382
+ elif self.table_type == "spark_table":
383
+ self.load_spark(df)
384
+ else:
385
+ raise ValueError(f'table_type "{self.table_type}" is incorrect')
386
+
387
+ def etl(self, start_date: str = None, end_date: str = None):
388
+ df = self.extract(start_date=start_date, end_date=end_date)
389
+ res = self.transform(df)
390
+ self.load(res)
391
+
392
+ def process(self):
393
+ if self.bulk:
394
+ self.etl(self.start_date, self.end_date)
395
+ else:
396
+ # 1 Batch per day
397
+ logger.info("Processing ETL task on a per day basis")
398
+ date_rng = pd.date_range(self.start_date, self.end_date)
399
+ for dt in date_rng:
400
+ start_dt = dt.strftime("%Y-%m-%d")
401
+ end_dt = dt + relativedelta(days=1)
402
+ end_dt = end_dt.strftime("%Y-%m-%d")
403
+ logger.info(f"Processing dt {start_dt}")
404
+ self.etl(start_dt, end_dt)
405
+
406
+
407
+ class MongoDbEtl(Etl):
408
+ schema = None
409
+
410
+ def extract(
411
+ self, start_date: str = None, end_date: str = None, schema: StructField = None
412
+ ):
413
+ start_date = start_date or self.start_date
414
+ end_date = end_date or self.end_date
415
+ schema = schema or self.schema
416
+ df = (
417
+ self.spark.read.format("mongodb")
418
+ .option("connection.uri", self.url)
419
+ .option("database", self.src_db)
420
+ .option("collection", self.src_tbl)
421
+ .option("sql.inferSchema.mapTypes.enabled", "true")
422
+ .load(schema=schema)
423
+ )
424
+ if start_date:
425
+ df = df.filter(col(self.filter_by) >= start_date)
426
+ if end_date:
427
+ df = df.filter(col(self.filter_by) < end_date)
428
+ return df
429
+
430
+
431
+ class JdbcEtl(Etl):
432
+ driver = None
433
+
434
+ def __init__(
435
+ self,
436
+ url: str,
437
+ start_date: str = None,
438
+ end_date: str = None,
439
+ bulk=True,
440
+ hudi_mode_override: str = None,
441
+ **kwargs,
442
+ ):
443
+ super().__init__(start_date, end_date, url=url, bulk=bulk, hudi_mode_override=hudi_mode_override, **kwargs)
444
+
445
+ @classmethod
446
+ def run_from_cli(
447
+ cls,
448
+ url: Annotated[str, typer.Option("--url")],
449
+ start_date: Annotated[str, typer.Option("--start-date")] = None,
450
+ end_date: Annotated[str, typer.Option("--end-date")] = None,
451
+ bulk: Annotated[bool, typer.Option("--bulk/--per-day")] = True,
452
+ hudi_mode: Annotated[str, typer.Option("--hudi-mode")] = None,
453
+ ):
454
+ obj = cls.__new__(cls)
455
+ obj.__init__(url, start_date, end_date, bulk, hudi_mode_override=hudi_mode)
456
+ obj()
457
+ return obj
458
+
459
+ def _prepare_query(self, start_date: str = None, end_date: str = None):
460
+ query = f"select * from {self.src_db}.{self.src_tbl} where 1 = 1 "
461
+ if self.filter_by and start_date:
462
+ query += f'and {self.filter_by} >= "{start_date}" '
463
+ if self.filter_by and end_date:
464
+ query += f'and {self.filter_by} < "{end_date}" '
465
+ self.query = f"({query}) as tmp"
466
+ return self.query
467
+
468
+ def extract(self, start_date: str = None, end_date: str = None):
469
+ start_date = start_date or self.start_date
470
+ end_date = end_date or self.end_date
471
+ self._prepare_query(start_date, end_date)
472
+ logger.info(self.query)
473
+ if self.filter_by and start_date and end_date:
474
+ df = (
475
+ self.spark.read.format("jdbc")
476
+ .option("driver", self.driver)
477
+ .option("url", self.url)
478
+ .option("dbtable", self.query)
479
+ .option("partitionColumn", self.ts)
480
+ .option("lowerBound", start_date)
481
+ .option("upperBound", end_date)
482
+ .option("numPartitions", "8")
483
+ .option("fetchsize", "1000")
484
+ .option("pushDownPredicate", "true")
485
+ .option("pushDownAggregate", "true")
486
+ .option("pushDownLimit", "true")
487
+ .option("pushDownOffset", "true")
488
+ .load()
489
+ )
490
+ else:
491
+ df = (
492
+ self.spark.read.format("jdbc")
493
+ .option("driver", self.driver)
494
+ .option("url", self.url)
495
+ .option("dbtable", self.query)
496
+ .option("fetchsize", "1000")
497
+ .option("pushDownPredicate", "true")
498
+ .option("pushDownAggregate", "true")
499
+ .option("pushDownLimit", "true")
500
+ .option("pushDownOffset", "true")
501
+ .load()
502
+ )
503
+ return df
504
+
505
+
506
+ class MySqlEtl(JdbcEtl):
507
+ driver = "com.mysql.cj.jdbc.Driver"
508
+
509
+
510
+ class MsSqlEtl(JdbcEtl):
511
+ """
512
+ ETL base class for Microsoft SQL Server sources via JDBC.
513
+
514
+ JDBC URL format:
515
+ jdbc:sqlserver://host:port;databaseName=db;encrypt=false
516
+
517
+ Differences from MySqlEtl:
518
+ - Driver: com.microsoft.sqlserver.jdbc.SQLServerDriver
519
+ - Date literals use single quotes (ANSI SQL)
520
+ - Identifiers use bracket quoting: [schema].[table]
521
+ """
522
+
523
+ driver = "com.microsoft.sqlserver.jdbc.SQLServerDriver"
524
+
525
+ def _prepare_query(self, start_date: str = None, end_date: str = None):
526
+ # Use bracket-quoted identifiers for MSSQL
527
+ if self.src_db:
528
+ table_ref = f"[{self.src_db}].[{self.src_tbl}]"
529
+ else:
530
+ table_ref = f"[{self.src_tbl}]"
531
+
532
+ query = f"select * from {table_ref} where 1 = 1 "
533
+ if self.filter_by and start_date:
534
+ query += f"and [{self.filter_by}] >= '{start_date}' "
535
+ if self.filter_by and end_date:
536
+ query += f"and [{self.filter_by}] < '{end_date}' "
537
+ self.query = f"({query}) as tmp"
538
+ return self.query
539
+
540
+ def extract(self, start_date: str = None, end_date: str = None):
541
+ """
542
+ MSSQL-specific extract -- always uses single-partition JDBC read.
543
+
544
+ MSSQL servers often redirect partitioned connections to internal
545
+ hostnames (e.g. 'DB197') that Spark workers cannot resolve.
546
+ Avoid `partitionColumn` / `numPartitions` to prevent this.
547
+
548
+ All semicolon-delimited URL properties (user, password, encrypt, etc.)
549
+ are extracted and passed as explicit Spark JDBC `.option()` calls.
550
+ This is required because:
551
+ - MSSQL JDBC driver v10.2+ defaults `encrypt=true`
552
+ - Spark's JDBC Properties object does NOT merge URL-embedded properties
553
+ - All properties must be in the Properties object for the driver
554
+ """
555
+ start_date = start_date or self.start_date
556
+ end_date = end_date or self.end_date
557
+ self._prepare_query(start_date, end_date)
558
+ logger.info(self.query)
559
+
560
+ # Parse MSSQL JDBC URL: jdbc:sqlserver://host:port;key=val;key=val;...
561
+ parts = self.url.split(";")
562
+ base_url = parts[0] # jdbc:sqlserver://host:port
563
+ jdbc_props = {}
564
+ remaining_url_parts = []
565
+ for part in parts[1:]:
566
+ if "=" in part:
567
+ k, v = part.split("=", 1)
568
+ # databaseName must stay in the URL (required by the driver)
569
+ # redirect must stay in the URL (connection-level property;
570
+ # the driver ignores it when passed as a JDBC Properties entry)
571
+ if k.lower() in ("databasename", "redirect"):
572
+ remaining_url_parts.append(part)
573
+ else:
574
+ jdbc_props[k] = v
575
+ else:
576
+ remaining_url_parts.append(part)
577
+
578
+ clean_url = ";".join([base_url] + remaining_url_parts)
579
+
580
+ # Ensure SSL properties are set (driver v10.2+ defaults encrypt=true)
581
+ jdbc_props.setdefault("encrypt", "false")
582
+ jdbc_props.setdefault("trustServerCertificate", "true")
583
+
584
+ logger.info(f"MSSQL clean URL: {clean_url}")
585
+ logger.info(f"MSSQL props keys: {list(jdbc_props.keys())}")
586
+
587
+ df = (
588
+ self.spark.read.format("jdbc")
589
+ .option("driver", self.driver)
590
+ .option("url", clean_url)
591
+ .option("dbtable", self.query)
592
+ .option("fetchsize", "1000")
593
+ .options(**jdbc_props)
594
+ .load()
595
+ )
596
+ return df
597
+
598
+
599
+ class JsonEtl(Etl):
600
+ src_path = None
601
+ schema = None
602
+
603
+ def __init__(
604
+ self,
605
+ src_path: str,
606
+ start_date: str = None,
607
+ end_date: str = None,
608
+ bulk=True,
609
+ recursive: bool = True,
610
+ **kwargs,
611
+ ):
612
+ super().__init__(start_date, end_date, bulk, **kwargs)
613
+ self.src_path = src_path
614
+ self.recursive = recursive
615
+
616
+ @classmethod
617
+ def run_from_cli(
618
+ cls,
619
+ src_path: Annotated[str, typer.Option("--src-path")] = None,
620
+ start_date: Annotated[str, typer.Option("--start-date")] = None,
621
+ end_date: Annotated[str, typer.Option("--end-date")] = None,
622
+ bulk: Annotated[bool, typer.Option("--bulk/--per-day")] = True,
623
+ recursive: Annotated[
624
+ bool, typer.Option("--recursive/--no-recursive")
625
+ ] = True,
626
+ ):
627
+ obj = cls.__new__(cls)
628
+ obj.__init__(src_path, start_date, end_date, bulk, recursive)
629
+ obj()
630
+ return obj
631
+
632
+ def extract(self, start_date: str = None, end_date: str = None):
633
+ start_date = start_date or self.start_date
634
+ end_date = end_date or self.end_date
635
+ recursive = str(self.recursive).lower()
636
+ if self.schema:
637
+ return (
638
+ self.spark.read.option("recursiveFileLookup", recursive)
639
+ .schema(self.schema)
640
+ .json(self.src_path)
641
+ )
642
+ else:
643
+ return self.spark.read.option("recursiveFileLookup", recursive).json(
644
+ self.src_path
645
+ )
646
+
647
+
648
+ class CsvEtl(Etl):
649
+ src_path = None
650
+ schema = None
651
+
652
+ def __init__(
653
+ self,
654
+ src_path: str,
655
+ start_date: str = None,
656
+ end_date: str = None,
657
+ bulk=True,
658
+ recursive: bool = True,
659
+ **kwargs,
660
+ ):
661
+ super().__init__(start_date, end_date, bulk=bulk, **kwargs)
662
+ self.src_path = src_path
663
+ self.recursive = recursive
664
+
665
+ @classmethod
666
+ def run_from_cli(
667
+ cls,
668
+ src_path: Annotated[str, typer.Option("--src-path")] = None,
669
+ start_date: Annotated[str, typer.Option("--start-date")] = None,
670
+ end_date: Annotated[str, typer.Option("--end-date")] = None,
671
+ bulk: Annotated[bool, typer.Option("--bulk/--per-day")] = True,
672
+ recursive: Annotated[
673
+ bool, typer.Option("--recursive/--no-recursive")
674
+ ] = True,
675
+ ):
676
+ obj = cls.__new__(cls)
677
+ obj.__init__(src_path, start_date, end_date, bulk, recursive)
678
+ obj()
679
+ return obj
680
+
681
+ def extract(self, start_date: str = None, end_date: str = None):
682
+ if self.schema:
683
+ return (
684
+ self.spark.read.option("recursiveFileLookup", self.recursive)
685
+ .option("header", True)
686
+ .option("mode", "DROPMALFORMED")
687
+ .schema(self.schema)
688
+ .csv(self.src_path)
689
+ )
690
+ else:
691
+ return (
692
+ self.spark.read.option("recursiveFileLookup", self.recursive)
693
+ .option("header", True)
694
+ .option("mode", "DROPMALFORMED")
695
+ .csv(self.src_path)
696
+ )
@@ -0,0 +1 @@
1
+ """Companion utilities for the bundled ETL template."""