gss-bi-udfs 0.1.0__py3-none-any.whl → 0.1.1__py3-none-any.whl

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,11 @@
1
+ from . import io
2
+ from . import merges
3
+ from . import transforms
4
+ from . import utils
5
+
6
+ __all__ = [
7
+ "io",
8
+ "merges",
9
+ "transforms",
10
+ "utils",
11
+ ]
gss_bi_udfs/io.py ADDED
@@ -0,0 +1,376 @@
1
+ from .utils import get_env, get_table_info
2
+
3
+ # def load_latest_file_bronze(spark, data_base, schema, table, env=None):
4
+ def load_latest_parquet(spark, data_base, schema, table, env=None):
5
+ """
6
+ Carga el último archivo Parquet para la tabla especificada y retorna un DataFrame.
7
+
8
+ Parámetros:
9
+ spark (SparkSession): Sesión activa de Spark.
10
+ data_base (str): Nombre de la base de datos.
11
+ schema (str): Nombre del esquema.
12
+ table (str): Nombre de la tabla.
13
+ env (str, opcional): Entorno de ejecución (por ejemplo: 'dev', 'qa', 'prod').
14
+ Si no se proporciona, se obtiene usando get_env().
15
+
16
+ Retorna:
17
+ DataFrame de Spark cargado desde el archivo Parquet más reciente.
18
+ """
19
+ env = env or get_env()
20
+ base_path = f"/Volumes/bronze/{data_base}_{schema}/{env}/{table}/"
21
+
22
+ try:
23
+ files = dbutils.fs.ls(base_path) # type: ignore
24
+
25
+ parquet_files = [f for f in files if table in f.name]
26
+
27
+ if not parquet_files:
28
+ return None
29
+
30
+ latest_file = sorted(parquet_files, key=lambda f: f.name, reverse=True)[0]
31
+ df = spark.read.parquet(latest_file.path)
32
+ return df
33
+
34
+ except Exception as e:
35
+ print("Error al procesar los archivos:", e)
36
+ return None
37
+
38
+
39
+ def return_parquets_and_register_temp_views(spark, tables_load, verbose=False, env=None):
40
+ """
41
+ Carga dataframes a partir de un diccionario de definición de tablas y materializa vistas temporales.
42
+ Retorna un diccionario con los dataframes.
43
+
44
+ Parámetros:
45
+ spark (SparkSession): Sesión activa de Spark.
46
+ tables_load (dict): Diccionario que define las tablas a cargar y sus vistas temporales.
47
+ verbose (bool): Si es True, imprime mensajes de estado durante la carga.
48
+ env (str, opcional): Entorno de ejecución (por ejemplo: 'dev', 'qa', 'prod').
49
+ Si no se proporciona, se obtiene usando get_env().
50
+
51
+ Retorna:
52
+ dict: Diccionario donde las claves son nombres completos de tablas y los valores son DataFrames.
53
+ """
54
+ dataframes = {}
55
+
56
+ for data_base, schemas in tables_load.items():
57
+ for schema, tables in schemas.items():
58
+ for t in tables:
59
+ table = t['table']
60
+ view = t['view']
61
+
62
+ # Cargar el dataframe usando la función que proveés
63
+ df = load_latest_parquet(spark, data_base, schema, table, env)
64
+
65
+ # Guardar en el diccionario
66
+ key = f"{data_base}.{schema}.{table}"
67
+ dataframes[key] = df
68
+
69
+ # Materializar la vista (esto no necesita asignación en Python)
70
+ try:
71
+ df.createOrReplaceTempView(view)
72
+ if verbose:
73
+ print(f'Tabla "{key}" cargada y vista "{view}" materializada')
74
+ except Exception as e:
75
+ print(f"Error al materializar la vista '{view} tabla {key}': {e}")
76
+
77
+ return dataframes
78
+
79
+
80
+ def parquets_register_temp_views(spark, tables_load, verbose=False, env=None):
81
+ """
82
+ Lee los últimos parquets y materializa vistas temporales en Spark. Sin retorna nada.
83
+ Parámetros:
84
+ spark (SparkSession): Sesión activa de Spark.
85
+ tables_load (dict): Diccionario que define las tablas a cargar y sus vistas temporales.
86
+ La estructura esperada del parámetro `tables_load` es un diccionario
87
+ anidado con el siguiente formato:
88
+
89
+ {
90
+ "<base_de_datos>": {
91
+ "<schema>": [
92
+ {
93
+ "table": "<nombre_tabla>",
94
+ "view": "<nombre_vista_temporal>"
95
+ },
96
+ ...
97
+ ]
98
+ },
99
+ ...
100
+ }
101
+ Ejemplo:
102
+ tables_load = {
103
+ "bup": {
104
+ "bup": [
105
+ {"table": "naturalpersons", "view": "vw_naturalpersons"},
106
+ {"table": "maritalstatus", "view": "vw_maritalstatus"},
107
+ {"table": "genders", "view": "vw_genders"},
108
+ {"table": "legalpersons", "view": "vw_legalpersons"},
109
+ {"table": "phones", "view": "vw_phones"},
110
+ {"table": "emails", "view": "vw_emails"},
111
+ {"table": "addresses", "view": "vw_addresses"},
112
+ {"table": "persons", "view": "vw_persons"},
113
+ {"table": "administrativefreezeperiods", "view": "vw_administrativefreezeperiods"},
114
+ {"table": "fraudrisklevels", "view": "vw_fraudrisklevels"},
115
+ ],
116
+ },
117
+ "oraculo": {
118
+ "dbo": [
119
+ {"table": "ml_segmentacion", "view": "vw_segmentacion"},
120
+ ],
121
+ },
122
+ "timepro": {
123
+ "insudb": [
124
+ {"table": "logauth0user", "view": "vw_logauth0user"},
125
+ {"table": "benefitprogramadhesion", "view": "vw_benefitprogramadhesion"},
126
+ ],
127
+ },
128
+ "dwgssprotmp": {
129
+ "dwo": [
130
+ {"table": "int_dim_clientactive_bds", "view": "vw_int_dim_cliente_active"},
131
+ ],
132
+ },
133
+ "odscommon": {
134
+ "employee": [
135
+ {"table": "vw_active_employee", "view": "vw_active_employee"},
136
+ ],
137
+ },
138
+ }
139
+ verbose (bool): Si es True, imprime mensajes de estado durante la carga.
140
+ env (str, opcional): Entorno de ejecución (por ejemplo: 'dev', 'qa', 'prod').
141
+ Si no se proporciona, se obtiene usando get_env().
142
+ Retorna:
143
+ None
144
+ """
145
+ for data_base, schemas in tables_load.items():
146
+ for schema, tables in schemas.items():
147
+ for t in tables:
148
+ table = t['table']
149
+ view = t['view']
150
+ try:
151
+ df = load_latest_parquet(spark, data_base, schema, table, env)
152
+ df.createOrReplaceTempView(view)
153
+ if verbose:
154
+ print(f'Vista "{view}" materializada desde {data_base}.{schema}.{table}')
155
+ except Exception as e:
156
+ print(f"Error al materializar la vista '{view}' desde {data_base}.{schema}.{table}: {e}")
157
+
158
+
159
+ def load_latest_excel(spark, source_file, env=None):
160
+
161
+ """
162
+ Carga el último archivo de Excel (aunque no tenga extensión visible) para la carpeta especificada
163
+ y retorna un DataFrame.
164
+
165
+ Parámetros:
166
+ spark (SparkSession): Sesión activa de Spark.
167
+ source_file (str): Nombre de la carpeta.
168
+ env (str, opcional): Entorno de ejecución (por ejemplo: 'dev', 'qa', 'prod').
169
+ Si no se proporciona, se obtiene usando get_env().
170
+ Retorna:
171
+ DataFrame de Spark cargado desde el archivo Excel más reciente (en formato xls).
172
+ """
173
+
174
+ import pandas as pd
175
+
176
+ env = env or get_env()
177
+ base_path = f"/Volumes/bronze/excel/{env}/{source_file}/"
178
+ print("Ruta base:", base_path)
179
+
180
+ try:
181
+ files = dbutils.fs.ls(base_path) # type: ignore
182
+ print("Archivos encontrados:", [f.name for f in files])
183
+ excel_candidates = [f for f in files if f.isFile()]
184
+
185
+ if not excel_candidates:
186
+ print(f"No se encontraron archivos en la carpeta: {source_file}")
187
+ return None
188
+
189
+ latest_file = sorted(excel_candidates, key=lambda f: f.name, reverse=True)[0]
190
+
191
+ file_path = latest_file.path.replace("dbfs:", "")
192
+
193
+ pdf = pd.read_excel(file_path, header=0, engine='xlrd')
194
+
195
+ return spark.createDataFrame(pdf)
196
+
197
+ except Exception as e:
198
+ return None
199
+
200
+
201
+ def return_excels_and_register_temp_views(spark, files_load, verbose=False, env=None):
202
+ """
203
+ Carga dataframes a partir de un diccionario de definición de excels y materializa vistas temporales.
204
+ Retorna un diccionario con los dataframes.
205
+
206
+ Parámetros:
207
+ spark (SparkSession): Sesión activa de Spark.
208
+ files_load (dict): Diccionario que define las tablas a cargar y sus vistas temporales.
209
+ La estructura esperada del parámetro `files_load` es un diccionario
210
+ anidado con el siguiente formato:
211
+
212
+ {
213
+ "<Dominio>": {
214
+ "<SubDominio>": [
215
+ {
216
+ "file": "<nombre_archivo>",
217
+ "view": "<nombre_vista_temporal>"
218
+ },
219
+ ...
220
+ ]
221
+ },
222
+ ...
223
+ }
224
+ Ejemplo:
225
+ verbose (bool): Si es True, imprime mensajes de estado durante la carga.
226
+ env (str, opcional): Entorno de ejecución (por ejemplo: 'dev', 'qa', 'prod').
227
+ Si no se proporciona, se obtiene usando get_env().
228
+
229
+ Retorna:
230
+ dict: Diccionario donde las claves son nombres completos de tablas y los valores son DataFrames.
231
+ Quedando las vistas materializadas en el entorno Spark.
232
+ """
233
+ dataframes = {}
234
+
235
+ for domain, subdomain in files_load.items():
236
+ for subdomain, tables in subdomain.items():
237
+ for t in tables:
238
+ file = t['file']
239
+ view = t['view']
240
+
241
+ # Cargar el dataframe usando la función que proveés
242
+ source_file = f"{domain}/{subdomain}/{file}"
243
+ df = load_latest_excel(spark, source_file, env)
244
+
245
+ # Guardar en el diccionario
246
+ key = f"{domain}.{subdomain}.{file}"
247
+ dataframes[key] = df
248
+
249
+ # Materializar la vista (esto no necesita asignación en Python)
250
+ try:
251
+ df.createOrReplaceTempView(view)
252
+ if verbose:
253
+ print(f'El archivo "{key}" cargado y vista "{view}" materializada')
254
+ except Exception as e:
255
+ print(f"Error al materializar la vista '{view} tabla {key}': {e}")
256
+
257
+ return dataframes
258
+
259
+
260
+ def excels_register_temp_views(spark, files_load, verbose=False, env=None):
261
+ """
262
+ Lee los últimos archivos excels y materializa vistas temporales en Spark. Sin retorna nada.
263
+
264
+ Parámetros:
265
+ spark (SparkSession): Sesión activa de Spark.
266
+ files_load (dict): Diccionario que define las tablas a cargar y sus vistas temporales.
267
+ La estructura esperada del parámetro `files_load` es un diccionario
268
+ anidado con el siguiente formato:
269
+
270
+ {
271
+ "<Dominio>": {
272
+ "<SubDominio>": [
273
+ {
274
+ "file": "<nombre_archivo>",
275
+ "view": "<nombre_vista_temporal>"
276
+ },
277
+ ...
278
+ ]
279
+ },
280
+ ...
281
+ }
282
+ Ejemplo:
283
+ verbose (bool): Si es True, imprime mensajes de estado durante la carga.
284
+ env (str, opcional): Entorno de ejecución (por ejemplo: 'dev', 'qa', 'prod').
285
+ Si no se proporciona, se obtiene usando get_env().
286
+
287
+ Retorna:
288
+ Nada
289
+ """
290
+
291
+ for domain, subdomain in files_load.items():
292
+ for subdomain, tables in subdomain.items():
293
+ for t in tables:
294
+ file = t['file']
295
+ view = t['view']
296
+
297
+ # Cargar el dataframe usando la función que proveés
298
+ source_file = f"{domain}/{subdomain}/{file}"
299
+ df = load_latest_excel(spark, source_file, env)
300
+
301
+ # Guardar en el diccionario
302
+ key = f"{domain}.{subdomain}.{file}"
303
+
304
+ # Materializar la vista (esto no necesita asignación en Python)
305
+ try:
306
+ df.createOrReplaceTempView(view)
307
+ if verbose:
308
+ print(f'El archivo "{key}" leido y vista "{view}" materializada')
309
+ except Exception as e:
310
+ print(f"Error al materializar la vista '{view} tabla {key}': {e}")
311
+
312
+
313
+ def load_and_materialize_views(action, **kwargs):
314
+ actions_load_bronze = {
315
+ # Todas las acciones aqui declaradas deberan devolver un diccionario de DataFrames
316
+ # 'load_notebook': load_notebook,
317
+ 'return_parquets_and_register_temp_views': return_parquets_and_register_temp_views,
318
+ 'parquets_register_temp_views': parquets_register_temp_views,
319
+ 'return_excels_and_register_temp_views': return_excels_and_register_temp_views,
320
+ 'excels_register_temp_views': excels_register_temp_views,
321
+ # ir agregando más acciones acá
322
+ }
323
+ results = {}
324
+ func = actions_load_bronze.get(action)
325
+ if func:
326
+ results = func(**kwargs)
327
+ # results[action] = result
328
+ else:
329
+ print(f"Acción '{action}' no está implementada.")
330
+ return results
331
+
332
+
333
+ def save_table_to_delta(df, catalog, schema, table_name):
334
+ """
335
+ Guarda un DataFrame en formato Delta en la ubicación y tabla especificadas,
336
+ sobrescribiendo los datos existentes y el esquema si es necesario.
337
+
338
+ Parámetros:
339
+ df (DataFrame): DataFrame de Spark que se desea guardar.
340
+ db_name (str): Nombre del catálogo o base de datos destino.
341
+ schema (str): Nombre del esquema, capa o entorno destino (ejemplo: 'silver', 'gold').
342
+ table_name (str): Nombre de la tabla destino.
343
+
344
+ Retorna:
345
+ None
346
+
347
+ Lógica:
348
+ - Utiliza la función auxiliar 'get_table_info' para obtener el path
349
+ de almacenamiento y el nombre completo de la tabla.
350
+ - Escribe el DataFrame en formato Delta en la ruta especificada,
351
+ sobrescribiendo cualquier dato y adaptando el esquema si es necesario.
352
+ - Registra la tabla como tabla administrada en el metastore con el nombre completo.
353
+
354
+ Notas:
355
+ - El modo 'overwrite' reemplaza todos los datos existentes en la tabla.
356
+ - La opción 'overwriteSchema' asegura que el esquema de la tabla se actualice si cambió.
357
+ - Es necesario que la ruta y la tabla existan o sean accesibles en el entorno Spark.
358
+ - Las opciones
359
+ `.option("delta.columnMapping.mode", "nameMapping")` y `.option("delta.columnMapping.mode", "name")`
360
+ permiten especificar el modo de mapeo de columnas para Delta Lake:
361
+ - **"nameMapping"**: usa un mapeo explícito de columnas por nombre, útil para cambios de nombre o reordenamiento de columnas sin perder datos.
362
+ - **"name"**: usa el nombre de columna directamente para el mapeo, opción recomendada cuando no se necesita trazabilidad de cambios en los nombres de columna, permite utilizar los acentos en el nombre de las columnas.
363
+ - Si ambas opciones se usan al mismo tiempo, solo una tendrá efecto (se aplicará la última indicada).
364
+
365
+ """
366
+ dim_destino = get_table_info(catalog=catalog, schema=schema, table=table_name)
367
+ (
368
+ df.write
369
+ .format("delta")
370
+ .option("path", dim_destino["path"])
371
+ .mode("overwrite")
372
+ .option("overwriteSchema", "true")
373
+ .option("delta.columnMapping.mode", "nameMapping") \
374
+ .option("delta.columnMapping.mode", "name") \
375
+ .saveAsTable(dim_destino["full_table_name"])
376
+ )
gss_bi_udfs/merges.py ADDED
@@ -0,0 +1,191 @@
1
+ from pyspark.sql import functions as F
2
+ from .io import save_table_to_delta
3
+ from .utils import get_table_info
4
+ from .transforms import add_hashid
5
+
6
+ def merge_scd2(
7
+ spark,
8
+ df_dim_src,
9
+ table_name,
10
+ business_keys,
11
+ surrogate_key,
12
+ eow_date="9999-12-31"
13
+ ):
14
+ """
15
+ Aplica Slowly Changing Dimension Tipo 2 (SCD2) sobre una tabla Delta.
16
+
17
+ El DataFrame de entrada (`df_dim_src`) debe representar el estado actual de la dimensión
18
+ a nivel de negocio, con el MISMO esquema lógico que la tabla destino,
19
+ excluyendo únicamente la PK física (surrogate key) del Data Warehouse.
20
+ `df_dim_src` NO debe incluir la clave primaria física (surrogate key).
21
+ Esta se genera internamente como un hash de (business_keys + valid_from).
22
+
23
+ La lógica implementa versionado histórico de registros utilizando fechas de vigencia
24
+ (valid_from, valid_to), manteniendo una única versión activa por entidad de negocio.
25
+
26
+ CONCEPTOS CLAVE Y SUPUESTOS DEL MODELO
27
+ -------------------------------------
28
+ 1. Clave de negocio (Business Keys):
29
+ - El parámetro `business_keys` DEBE representar la clave de negocio que identifica
30
+ unívocamente a la entidad (por ejemplo: nbranch, nproduct).
31
+ - Esta clave NO debe cambiar en el tiempo.
32
+ - El merge se realiza exclusivamente contra esta clave de negocio
33
+ y contra el registro activo (valid_to = eow_date).
34
+
35
+ 2. Clave primaria física del Data Warehouse:
36
+ - La dimensión DEBE tener una clave primaria física (surrogate key) propia del DW
37
+ para identificar cada versión histórica del registro.
38
+ - Esta PK física NO participa de la lógica de comparación ni del merge funcional,
39
+ y su generación queda fuera del alcance de esta función.
40
+
41
+ 3. Columnas comparadas (detección de cambios):
42
+ - La función compara TODAS las columnas del DataFrame de entrada
43
+ EXCEPTO:
44
+ - la clave de negocio (`business_keys`)
45
+ - las columnas de vigencia (`valid_from`, `valid_to`)
46
+ - Si cualquiera de las columnas comparadas cambia, se genera una nueva versión
47
+ del registro (SCD Tipo 2).
48
+ - Esto implica que cualquier nueva columna agregada al esquema será
49
+ automáticamente considerada para versionado.
50
+
51
+ IMPORTANTE:
52
+ - Por este motivo, se recomienda que el DataFrame NO incluya columnas técnicas
53
+ o volátiles (timestamps de carga, ids de proceso, metadatos, etc.),
54
+ ya que provocarían versionado innecesario.
55
+
56
+ - El DataFrame de entrada PUEDE incluir las columnas `valid_from` y `valid_to`;
57
+ en caso de existir, serán ignoradas para la detección de cambios y
58
+ recalculadas internamente por la función.
59
+
60
+ 4. Manejo de fechas de vigencia:
61
+ - valid_from: DATE inclusiva
62
+ - valid_to: DATE inclusiva
63
+ - El registro activo siempre tiene valid_to = eow_date (por defecto 9999-12-31).
64
+ - Ante un cambio:
65
+ * la versión anterior se cierra con valid_to = current_date() - 1
66
+ * la nueva versión se inserta con valid_from = current_date()
67
+ - Esto garantiza que no existan solapamientos de vigencia.
68
+
69
+ - Si el DataFrame de entrada no contiene `valid_from` y `valid_to`,
70
+ la función las generará automáticamente durante la carga inicial
71
+ o incremental.
72
+
73
+ 5. Carga inicial:
74
+ - Si la tabla destino no existe, se realiza un full load inicial,
75
+ asignando valid_from = '1900-01-01' y valid_to = eow_date
76
+ a todos los registros.
77
+
78
+ PARÁMETROS
79
+ ----------
80
+ spark : SparkSession
81
+ SparkSession activo.
82
+ df_dim_src : DataFrame
83
+ DataFrame de Spark que contiene los datos a mergear.
84
+ Debe incluir TODAS las columnas de negocio de la dimensión
85
+ (clave(s) de negocio + atributos versionables),
86
+ con el mismo esquema lógico que la tabla destino.
87
+ NO debe incluir la clave primaria física (surrogate key).
88
+ table_name : str
89
+ Nombre completo (catalogo.esquema.tabla) de la tabla Delta destino.
90
+ business_keys : str or list[str]
91
+ Nombre(s) de la(s) columna(s) que representa(n) la clave de negocio.
92
+ Puede ser una clave simple (str) o compuesta (lista de str).
93
+ surrogate_key : str
94
+ Nombre de la clave primaria física (surrogate key) de la dimensión.
95
+ Se genera internamente como un hash de (business_keys + valid_from).
96
+ eow_date : str, opcional
97
+ Fecha de fin de vigencia para registros activos.
98
+ Por defecto: '9999-12-31'.
99
+
100
+ RETORNO
101
+ -------
102
+ None
103
+ La función ejecuta el merge directamente sobre la tabla Delta.
104
+ """
105
+
106
+ # Normalize business_keys to a list (support single column or composite key)
107
+ if isinstance(business_keys, str):
108
+ business_keys = [business_keys]
109
+
110
+ missing_bk = [c for c in business_keys if c not in df_dim_src.columns]
111
+ if missing_bk:
112
+ raise ValueError(f"Columnas de business key inexistentes en df_dim_src: {missing_bk}")
113
+
114
+ exclude_cols = set(business_keys) | {"valid_from", "valid_to"}
115
+ compare_cols = [c for c in df_dim_src.columns if c not in exclude_cols]
116
+
117
+ info_table = get_table_info(spark,full_table_name=table_name)
118
+
119
+ if not spark.catalog.tableExists(table_name):
120
+ df_nov = (
121
+ df_dim_src
122
+ .withColumn("valid_from", F.to_date(F.lit("1900-01-01")))
123
+ .withColumn("valid_to", F.to_date(F.lit(eow_date)))
124
+ )
125
+ pk_cols = business_keys + ["valid_from"]
126
+ df_nov = add_hashid(df_nov, pk_cols, surrogate_key)
127
+
128
+ save_table_to_delta(spark, df_nov, info_table["catalog"], info_table["schema"], info_table["table"])
129
+ df_nov.write \
130
+ .format("delta") \
131
+ .mode("overwrite") \
132
+ .option("overwriteSchema", "true") \
133
+ .saveAsTable(table_name)
134
+ print(f"[FULL LOAD] {table_name} creado con SCD-2")
135
+ return
136
+
137
+ df_nov = (
138
+ df_dim_src
139
+ .withColumn("valid_from", F.current_date().cast("date"))
140
+ .withColumn("valid_to", F.to_date(F.lit(eow_date)))
141
+ )
142
+ pk_cols = business_keys + ["valid_from"]
143
+ df_nov = add_hashid(df_nov, pk_cols, surrogate_key)
144
+
145
+ delta_tgt = info_table["full_table_name"]
146
+ bk_cond = " AND ".join([f"t.{k} = s.{k}" for k in business_keys])
147
+ merge_cond = f"{bk_cond} AND t.valid_to = date('{eow_date}')"
148
+ t_hash = "xxhash64(concat_ws('', " + ", ".join(f"t.{c}" for c in compare_cols) + "))"
149
+ s_hash = "xxhash64(concat_ws('', " + ", ".join(f"s.{c}" for c in compare_cols) + "))"
150
+ diff_cond = f"{t_hash} <> {s_hash}"
151
+
152
+ (delta_tgt.alias("t")
153
+ .merge(df_nov.alias("s"), merge_cond)
154
+ .whenMatchedUpdate(
155
+ condition=diff_cond,
156
+ set={"valid_to": "date_sub(current_date(), 1)"}
157
+ )
158
+ .whenNotMatchedInsertAll()
159
+ .whenNotMatchedBySourceUpdate(
160
+ condition=f"t.valid_to = date('{eow_date}')",
161
+ set={"valid_to": "date_sub(current_date(), 1)"}
162
+ )
163
+ .execute()
164
+ )
165
+
166
+ closed_count = delta_tgt.toDF() \
167
+ .filter(F.col("valid_to") == F.date_sub(F.current_date(), 1)) \
168
+ .count()
169
+ if closed_count > 0:
170
+ print(f"Se cerraron {closed_count} versiones en {table_name}")
171
+
172
+ t_active = (
173
+ delta_tgt.toDF()
174
+ .filter(F.col("valid_to") == F.to_date(F.lit(eow_date)))
175
+ )
176
+ join_conds = [F.col(f"s.{k}") == F.col(f"t.{k}") for k in business_keys] + [
177
+ F.col(f"s.{c}") == F.col(f"t.{c}") for c in compare_cols
178
+ ]
179
+ df_to_app = df_nov.alias("s").join(
180
+ t_active.alias("t"), on=join_conds, how="left_anti"
181
+ )
182
+
183
+ new_count = df_to_app.limit(1).count()
184
+ if new_count > 0:
185
+ df_to_app.write \
186
+ .format("delta") \
187
+ .mode("append") \
188
+ .saveAsTable(table_name)
189
+ print(f"Se insertaron nuevas versiones en {table_name}")
190
+ else:
191
+ print(f"No hay nuevas versiones para {table_name}")
@@ -0,0 +1,56 @@
1
+ from pyspark.sql import DataFrame
2
+ from pyspark.sql.functions import xxhash64
3
+ from pyspark.sql.functions import concat_ws, col
4
+
5
+
6
+ from .utils import get_default_value_by_type
7
+
8
+ def add_hashid(
9
+ df: DataFrame,
10
+ columns: list[str],
11
+ new_col_name: str = "hashid"
12
+ ) -> DataFrame:
13
+ """
14
+ Agrega una columna hash (PK) a partir de la concatenación de columnas
15
+ y reordena el DataFrame dejando el hash como primera columna.
16
+
17
+ Parametros:
18
+ df: DataFrame de Spark de entrada
19
+ columns: Lista de columnas a concatenar
20
+ new_col_name: Nombre de la columna hash (default: hashid)
21
+
22
+ Retorna:
23
+ DataFrame con hash agregado
24
+ """
25
+
26
+ if not columns:
27
+ raise ValueError("La lista de columnas no puede estar vacía")
28
+
29
+ # Concatenación segura
30
+ concatenated = concat_ws("|", *[col(c).cast("string") for c in columns])
31
+
32
+ # Hash rápido y determinístico (ideal para PK técnica)
33
+ df_with_hash = df.withColumn(new_col_name, xxhash64(concatenated))
34
+
35
+ # Reordenar columnas
36
+ original_cols = [c for c in df.columns if c != new_col_name]
37
+ new_cols = [new_col_name] + original_cols
38
+
39
+ return df_with_hash.select(*new_cols)
40
+
41
+
42
+ def get_default_record(spark, df: DataFrame) -> DataFrame:
43
+ """
44
+ Crea un DataFrame con un único registro de valores por defecto según el esquema de df:
45
+ Parámetros:
46
+ spark: SparkSession activo.
47
+ df (DataFrame): DataFrame de Spark del cual se tomará el esquema.
48
+
49
+ Retorna:
50
+ DataFrame: DataFrame con un único registro con valores por defecto.
51
+ """
52
+ defaults = {}
53
+ for field in df.schema.fields:
54
+ defaults[field.name] = get_default_value_by_type(field.dataType)
55
+
56
+ return spark.createDataFrame([defaults], schema=df.schema)
gss_bi_udfs/utils.py ADDED
@@ -0,0 +1,185 @@
1
+ import os
2
+ from pyspark.sql.types import (IntegerType, LongType, ShortType, ByteType,
3
+ DecimalType, DoubleType, FloatType,
4
+ DateType, TimestampType, BooleanType, StringType)
5
+ from pyspark.sql.functions import lit, concat_ws, col
6
+ from pyspark.sql import DataFrame, Column
7
+
8
+
9
+ def get_env(default="dev"):
10
+ """
11
+ Obtiene el entorno de ejecución a partir de la variable de entorno ENV.
12
+ Si no está definida, retorna el valor por defecto indicado.
13
+
14
+ Parámetros:
15
+ - default (str): entorno por defecto a utilizar si ENV no está definida.
16
+
17
+ Retorna:
18
+ - str: nombre del entorno de ejecución (por ejemplo: 'dev', 'qa', 'prod').
19
+ """
20
+
21
+ return os.getenv("ENV", default)
22
+
23
+ def get_env_catalog(catalog):
24
+ """
25
+ Genera el nombre del catálogo ajustado al environment.
26
+
27
+ Parámetros:
28
+ catalog (str): Nombre base del catálogo (ej. 'fi_comunes').
29
+
30
+ Retorna:
31
+ str: Nombre del catálogo ajustado al environment.
32
+ Ejemplo: 'fi_comunes_dev' si ENV='dev'
33
+ 'fi_comunes' si ENV='pro'
34
+ """
35
+
36
+ if get_env() == "pro":
37
+ return catalog
38
+ else:
39
+ return f"{catalog}_{get_env()}"
40
+
41
+
42
+ def get_env_table_path(catalog, table_path):
43
+ """
44
+ Genera el path completo de una tabla incluyendo el sufijo de ambiente en el catálogo.
45
+
46
+ Parámetros:
47
+ catalog (str): Nombre base del catálogo (ej. 'fi_comunes').
48
+ table_path (str): Path de la tabla incluyendo esquema y nombre (ej. 'silver.dim_afiliado').
49
+
50
+ Retorna:
51
+ str: Path completo de la tabla ajustado al environment.
52
+ Ejemplo: 'fi_comunes_dev.silver.dim_afiliado' si ENV='dev'
53
+ 'fi_comunes.silver.dim_afiliado' si ENV='pro'
54
+ """
55
+
56
+ # Concatena el catálogo modificado con el path de la tabla
57
+ return f"{get_env_catalog(catalog)}.{table_path}"
58
+
59
+ def get_schema_root_location(spark, catalog, schema):
60
+ """
61
+ Obtiene la ruta física (RootLocation) de un esquema específico, considerando el catálogo ajustado al ambiente.
62
+
63
+ Parámetros:
64
+ catalog (str): Nombre base del catálogo (ej. 'fi_comunes').
65
+ schema (str): Nombre del esquema dentro del catálogo (ej. 'silver').
66
+
67
+ Retorna:
68
+ str: Ruta física donde se almacenan los datos del esquema.
69
+ Ejemplo: 's3://bucket/path/fi_comunes_dev/silver' si ENV='dev'
70
+
71
+ Requiere:
72
+ - La función get_env_catalog debe estar definida y retornar el nombre de catálogo ajustado al ambiente.
73
+ - SparkSession activa y permisos para ejecutar `DESCRIBE SCHEMA EXTENDED`.
74
+
75
+ Ejemplo:
76
+ >>> get_schema_root_location("fi_comunes", "silver")
77
+ 's3://mi-bucket/datalake/fi_comunes_dev/silver'
78
+ """
79
+ cat = get_env_catalog(catalog)
80
+ df = spark.sql(f"DESCRIBE SCHEMA EXTENDED {cat}.{schema}")
81
+ return df.filter("database_description_item = 'RootLocation'") \
82
+ .select("database_description_value") \
83
+ .collect()[0][0]
84
+
85
+ def get_table_info(
86
+ spark,
87
+ *,
88
+ full_table_name: str = None,
89
+ catalog: str = None,
90
+ schema: str = None,
91
+ table: str = None
92
+ ) -> dict:
93
+ """
94
+ Devuelve información de una tabla a partir de:
95
+ - full_table_name (catalog.schema.table)
96
+ o
97
+ - catalog + schema + table
98
+ """
99
+
100
+ # -----------------------------
101
+ # 1. Resolver inputs
102
+ # -----------------------------
103
+ if full_table_name:
104
+ parts = full_table_name.split(".")
105
+ if len(parts) != 3:
106
+ raise ValueError(
107
+ "full_table_name debe tener formato catalog.schema.table"
108
+ )
109
+ catalog, schema, table = parts
110
+
111
+ elif catalog and schema and table:
112
+ full_table_name = f"{catalog}.{schema}.{table}"
113
+
114
+ else:
115
+ raise ValueError(
116
+ "Debe informar full_table_name o catalog + schema + table"
117
+ )
118
+
119
+ # -----------------------------
120
+ # 2. Environment catalog
121
+ # -----------------------------
122
+ catalog_env = get_env_catalog(catalog)
123
+
124
+ # -----------------------------
125
+ # 3. Path físico
126
+ # -----------------------------
127
+ root_location = get_schema_root_location(spark, catalog, schema)
128
+ path = f"{root_location.rstrip('/')}/{table}"
129
+
130
+ # -----------------------------
131
+ # 4. Metadata Spark (si existe)
132
+ # -----------------------------
133
+ info = {
134
+ "catalog": catalog_env,
135
+ "schema": schema,
136
+ "table": table,
137
+ "full_table_name": f"{catalog_env}.{schema}.{table}",
138
+ "path": path,
139
+ "exists": False,
140
+ "provider": None,
141
+ "table_type": None,
142
+ }
143
+
144
+ if spark.catalog.tableExists(info["full_table_name"]):
145
+ info["exists"] = True
146
+
147
+ desc = (
148
+ spark.sql(f"DESCRIBE EXTENDED {info['full_table_name']}")
149
+ .filter("col_name in ('Location', 'Provider', 'Type')")
150
+ .collect()
151
+ )
152
+
153
+ for row in desc:
154
+ if row.col_name == "Location":
155
+ info["path"] = row.data_type
156
+ elif row.col_name == "Provider":
157
+ info["provider"] = row.data_type
158
+ elif row.col_name == "Type":
159
+ info["table_type"] = row.data_type
160
+
161
+ return info
162
+
163
+
164
+
165
+ def get_default_value_by_type(dtype):
166
+ """
167
+ Devuelve "default" por tipo de dato para registros 'default/unknown'.
168
+ Parámetros:
169
+ - dtype: Tipo de dato (DataType) de PySpark.
170
+ Retorna:
171
+ - valor por defecto correspondiente al tipo de dato.
172
+ """
173
+ if isinstance(dtype, (IntegerType, LongType, ShortType, ByteType)):
174
+ return lit(-999)
175
+ if isinstance(dtype, (DecimalType, DoubleType, FloatType)):
176
+ return lit(-999)
177
+ if isinstance(dtype, (DateType, TimestampType)):
178
+ return lit("1900-01-01").cast(dtype)
179
+ if isinstance(dtype, BooleanType):
180
+ return lit(False)
181
+ if isinstance(dtype, StringType):
182
+ return lit("N/A")
183
+ return lit(None)
184
+
185
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gss-bi-udfs
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Utilidades reutilizables para Spark y Delta Lake en arquitecturas Lakehouse.
5
5
  Author: Geronimo Forconi
6
6
  Requires-Python: >=3.8
@@ -8,6 +8,14 @@ Description-Content-Type: text/markdown
8
8
  Requires-Dist: pyspark>=3.0.0
9
9
 
10
10
  # gss-bi-udfs
11
+
11
12
  Creo modulo para guardar UDFs comunes a todas las areas de BI.
12
13
 
13
14
 
15
+ # para compilar
16
+
17
+ python3 -m build
18
+
19
+ # para publicar
20
+
21
+ python3 -m twine upload dist/*
@@ -0,0 +1,9 @@
1
+ gss_bi_udfs/__init__.py,sha256=VNj2_l7MHiRGF497XVM4KtU7p6JOX1xddkvFJLG1vUQ,152
2
+ gss_bi_udfs/io.py,sha256=yEqQvpyBod9kIv7p-_5yLtINuIwsi-piWy5rKI3BgQk,15939
3
+ gss_bi_udfs/merges.py,sha256=4YHfw6TWU08ZWEMKBtFlMqj_tzXzjqkuM_CJn0uRNUI,7977
4
+ gss_bi_udfs/transforms.py,sha256=yDg7uvPFSTrGXgy5rOUKDdSrRBBZSubfi9K-6rATCWY,1876
5
+ gss_bi_udfs/utils.py,sha256=ryyqrzhybC6mZFTUWsnnrQXReUcLkVqw6e2gIf4Id_g,5982
6
+ gss_bi_udfs-0.1.1.dist-info/METADATA,sha256=q241xBvvuhhJRUL1wIGB_JKCkTxXAF9HY13yYjV3Ae8,423
7
+ gss_bi_udfs-0.1.1.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
8
+ gss_bi_udfs-0.1.1.dist-info/top_level.txt,sha256=jLjGHQoep6-wLbW6wFV611Zx4ak42Q9hKtH_3sUzX9o,12
9
+ gss_bi_udfs-0.1.1.dist-info/RECORD,,
@@ -0,0 +1 @@
1
+ gss_bi_udfs
@@ -1,4 +0,0 @@
1
- gss_bi_udfs-0.1.0.dist-info/METADATA,sha256=ChQSSxNCSDO4zK3mupeVxs9X0rXov2NWzib4n0HAoaM,339
2
- gss_bi_udfs-0.1.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
3
- gss_bi_udfs-0.1.0.dist-info/top_level.txt,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
4
- gss_bi_udfs-0.1.0.dist-info/RECORD,,
@@ -1 +0,0 @@
1
-