CPILake-Utils 11.0.0__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.
@@ -0,0 +1,967 @@
1
+ import re
2
+ import json
3
+ import time
4
+ import pytz
5
+ import html
6
+ import base64
7
+ import requests
8
+ import pandas as pd
9
+ # import sempy.fabric as fabric
10
+ from pyspark.sql import SparkSession
11
+ from pyspark.sql import DataFrame, functions as F
12
+ from pyspark.sql.functions import unix_timestamp, col, max, from_utc_timestamp
13
+ from pyspark.conf import SparkConf
14
+ from datetime import datetime
15
+ # from notebookutils.credentials import getSecret
16
+ # from azure.identity import CertificateCredential
17
+ # from tqdm.auto import tqdm
18
+ from typing import Optional, List, Dict, Tuple, Union
19
+ # from fabric.analytics.environment.credentials import SetFabricAnalyticsDefaultTokenCredentials
20
+
21
+
22
+ ## <<<<<<<<<<<<<<<< hash_function
23
+
24
+ def hash_function(s):
25
+ """Hash function for alphanumeric strings"""
26
+ if s is None:
27
+ return None
28
+ s = str(s).upper()
29
+ s = re.sub(r'[^A-Z0-9]', '', s)
30
+ base36_map = {ch: idx for idx, ch in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")}
31
+ result = 0
32
+ for i, ch in enumerate(reversed(s)):
33
+ result += base36_map.get(ch, 0) * (36 ** i)
34
+ result += len(s) * (36 ** (len(s) + 1))
35
+ return result
36
+
37
+
38
+ ## <<<<<<<<<<<<<<<< send_email_via_http
39
+
40
+ def send_email_via_http(params):
41
+ """
42
+ Sends an email using an HTTP endpoint (uses global endpoint_url & access_token set by init_mail()).
43
+
44
+ Required:
45
+ - to (str | list), subject (str), body (str) # body will be replaced if df_in_body=True
46
+
47
+ Optional:
48
+ - cc, bcc, from_addr, headers (dict), attachments (list), timeout (int, default 15)
49
+ - df : Spark or Pandas DataFrame to render into the email body
50
+ - df_limit : limit rows if Spark DF (default 1000)
51
+ - df_in_body : if True (default), replace body with styled DF HTML (your format)
52
+ - df_attach : if True, also attach the same DF as HTML file (default False)
53
+ - df_name : attachment filename (default "data.html")
54
+ - tz_name : timezone string for timestamp header (default "America/Los_Angeles")
55
+ """
56
+ # Ensure init_mail() ran
57
+ try:
58
+ _ = endpoint_url
59
+ except NameError:
60
+ raise RuntimeError("endpoint_url not set. Call init_mail(...) once in this session before send_email_via_http().")
61
+ try:
62
+ _ = access_token
63
+ except NameError:
64
+ raise RuntimeError("access_token not set. Call init_mail(...) once in this session before send_email_via_http().")
65
+
66
+ # Required checks
67
+ required = ['to', 'subject', 'body']
68
+ missing = [f for f in required if not params.get(f)]
69
+ if missing:
70
+ return None, f"Missing required fields: {', '.join(missing)}"
71
+
72
+ # Base payload
73
+ payload = {
74
+ "to": ";".join(params["to"]) if isinstance(params["to"], list) else params["to"],
75
+ "subject": params["subject"],
76
+ "body": params["body"],
77
+ }
78
+ if params.get("cc"):
79
+ payload["cc"] = params["cc"] if isinstance(params["cc"], list) else [params["cc"]]
80
+ if params.get("bcc"):
81
+ payload["bcc"] = params["bcc"] if isinstance(params["bcc"], list) else [params["bcc"]]
82
+ if params.get("from_addr"):
83
+ payload["from"] = params["from_addr"]
84
+ if params.get("attachments"):
85
+ payload["attachments"] = params["attachments"]
86
+
87
+ # ---- DataFrame → HTML body (your existing style) ----
88
+ df = params.get("df")
89
+ if df is not None:
90
+ df_limit = int(params.get("df_limit", 1000))
91
+ tz_name = params.get("tz_name", "America/Los_Angeles")
92
+ df_in_body = params.get("df_in_body", True)
93
+ df_attach = params.get("df_attach", False)
94
+ df_name = params.get("df_name", "data.html")
95
+
96
+ # Get pandas DataFrame
97
+ pdf = None
98
+ try:
99
+ from pyspark.sql import DataFrame as SparkDF
100
+ if isinstance(df, SparkDF):
101
+ pdf = df.limit(df_limit).toPandas()
102
+ else:
103
+ pdf = df # assume already pandas
104
+ except Exception:
105
+ pdf = df
106
+
107
+ html_body = _df_to_html_table(pdf, tz_name=tz_name)
108
+
109
+ if df_in_body:
110
+ subject = str(params.get("subject", ""))
111
+ if "QA Success" in subject:
112
+ payload["body"] ='<html><body><h4>No data available to display.</h4></body></html>'
113
+ else:
114
+ payload["body"] = html_body
115
+ else:
116
+ # append to body if you prefer not to replace
117
+ payload["body"] = f'{payload["body"]}{html_body}'
118
+
119
+ if df_attach:
120
+ content_b64 = base64.b64encode(html_body.encode("utf-8")).decode("utf-8")
121
+ attach = {"name": df_name, "contentBytes": content_b64, "contentType": "text/html"}
122
+ if "attachments" in payload and isinstance(payload["attachments"], list):
123
+ payload["attachments"].append(attach)
124
+ else:
125
+ payload["attachments"] = [attach]
126
+
127
+ # Auth header
128
+ req_headers = {"Authorization": f"Bearer {access_token}"}
129
+ if params.get("headers"):
130
+ req_headers.update(params["headers"])
131
+
132
+ timeout = params.get("timeout", 15)
133
+
134
+ # Send
135
+ try:
136
+ response = requests.post(endpoint_url, json=payload, headers=req_headers, timeout=timeout)
137
+ status_msg = "✅ Success" if response.status_code == 200 else f"❌ Failed ({response.status_code})"
138
+ print(f"Email send: {status_msg}")
139
+ return response.status_code, response.text, req_headers
140
+ except requests.RequestException as e:
141
+ error_msg = f"Request failed: {str(e)}"
142
+ print(f"❌ {error_msg}")
143
+ return None, error_msg
144
+
145
+
146
+ ## <<<<<<<<<<<<<<<< _df_to_html_table
147
+
148
+ def _df_to_html_table(pdf, tz_name="America/Los_Angeles"):
149
+ """Render a pandas DataFrame to your styled HTML table."""
150
+ # Empty DF → simple message
151
+ if pdf is None or len(pdf.index) == 0:
152
+ return '<html><body><h4>No data available to display.</h4></body></html>'
153
+
154
+ # Header with PST time
155
+ pst = pytz.timezone(tz_name)
156
+ now_pst = datetime.now(pst).strftime("%Y-%m-%d %H:%M:%S")
157
+
158
+ html_Table = []
159
+ html_Table.append('<html><head><style>')
160
+ html_Table.append('table {border-collapse: collapse; width: 100%} '
161
+ 'table, td, th {border: 1px solid black; padding: 3px; font-size: 9pt;} '
162
+ 'td, th {text-align: left;}')
163
+ html_Table.append('</style></head><body>')
164
+ html_Table.append(f'<h4>(Refresh Time: {now_pst})</h4><hr>')
165
+ html_Table.append('<table style="width:100%; border-collapse: collapse;">')
166
+ html_Table.append('<thead style="background-color:#000000; color:#ffffff;"><tr>')
167
+
168
+ # Columns (skip FailureFlag in header, to match your code)
169
+ cols = list(pdf.columns)
170
+ visible_cols = [c for c in cols if c != "FailureFlag"]
171
+ for c in visible_cols:
172
+ html_Table.append(f'<th style="border: 1px solid black; padding: 5px;">{html.escape(str(c))}</th>')
173
+ html_Table.append('</tr></thead><tbody>')
174
+
175
+ # Rows (highlight red if FailureFlag == 'Yes', else light green default)
176
+ ff_present = "FailureFlag" in cols
177
+ for _, row in pdf.iterrows():
178
+ row_bg_color = '#ccff66' # default
179
+ if ff_present:
180
+ try:
181
+ if str(row["FailureFlag"]).strip().lower() == "yes":
182
+ row_bg_color = '#ff8080'
183
+ except Exception:
184
+ pass
185
+ html_Table.append(f'<tr style="background-color:{row_bg_color};">')
186
+ for c in visible_cols:
187
+ val = row[c]
188
+ html_Table.append(f'<td>{html.escape("" if val is None else str(val))}</td>')
189
+ html_Table.append('</tr>')
190
+
191
+ html_Table.append('</tbody></table></body></html>')
192
+ return "".join(html_Table)
193
+
194
+
195
+ # s<<<<<<<<<<< end_email_no_attachment
196
+
197
+ def send_email_no_attachment(
198
+ body: str,
199
+ recipients: List[str],
200
+ tenant_id: str,
201
+ client_id: str,
202
+ certificate_secret_name: str,
203
+ keyvault_url: str,
204
+ endpoint_url: Optional[str] = None,
205
+ scope: Optional[str] = None,
206
+ subject: Optional[str] = None,
207
+ headers: Optional[Dict[str, str]] = None,
208
+ timeout: int = 15
209
+ ) -> Tuple[Optional[int], str]:
210
+
211
+ from notebookutils.credentials import getSecret
212
+ from azure.identity import CertificateCredential
213
+
214
+ # Defaults
215
+ if endpoint_url is None:
216
+ endpoint_url = (
217
+ "https://fdne-inframail-logicapp01.azurewebsites.net:443/"
218
+ "api/fdne-infra-appmail-sender/triggers/"
219
+ "When_a_HTTP_request_is_received/invoke"
220
+ "?api-version=2022-05-01" )
221
+
222
+ if scope is None:
223
+ scope = "api://27d45411-0d7a-4f27-bc5f-412d74ea249b/.default"
224
+
225
+ # Payload
226
+ payload = {
227
+ "to": ";".join(recipients),
228
+ "subject": subject or "",
229
+ "body": body, }
230
+
231
+ # Credential
232
+ secret_value = getSecret(keyvault_url, certificate_secret_name)
233
+ certificate_data = base64.b64decode(secret_value)
234
+
235
+ credential = CertificateCredential(
236
+ tenant_id=tenant_id,
237
+ client_id=client_id,
238
+ certificate_data=certificate_data,
239
+ send_certificate_chain=True
240
+ )
241
+
242
+ access_token = credential.get_token(scope).token
243
+
244
+ # Headers
245
+ request_headers = {
246
+ "Content-Type": "application/json",
247
+ "Authorization": f"Bearer {access_token}",
248
+ **(headers or {}),
249
+ }
250
+
251
+ # Call API
252
+ try:
253
+ resp = requests.post(
254
+ endpoint_url,
255
+ json=payload,
256
+ headers=request_headers,
257
+ timeout=timeout,
258
+ )
259
+
260
+ if resp.status_code in (200, 201, 202):
261
+ return resp.status_code, resp.text
262
+
263
+ return resp.status_code, f"Failed: {resp.text}"
264
+
265
+ except requests.RequestException as e:
266
+ return None, str(e)
267
+ """
268
+ # call the function
269
+ status, response = send_email_no_attachment(
270
+ body=markdown,
271
+ recipients=recipients,
272
+ subject=subject,
273
+ tenant_id=tenant_id,
274
+ client_id=client_id,
275
+ certificate_secret_name = certificate_secret_name,
276
+ keyvault_url=keyvault_url
277
+ )
278
+ """
279
+
280
+ # >>>>>>>>>>>>>>>>> end_email_no_attachment
281
+
282
+
283
+ ## <<<<<<<<<<<<<<<< QA_CheckUtil
284
+ """
285
+ A status column: PASS / FAIL / SKIPPED
286
+ A skip_reason column
287
+ Checks are skipped instead of failing when:
288
+ One or both DataFrames are empty
289
+ Required columns are missing
290
+ Aggregation column exists but contains only nulls
291
+ match becomes None when skipped (clearer than False)
292
+ """
293
+ def QA_CheckUtil(
294
+ source_df: DataFrame,
295
+ qa_df: DataFrame
296
+ ) -> DataFrame:
297
+
298
+ spark = source_df.sparkSession
299
+ qa_rows: List[tuple] = []
300
+
301
+ def calc_diff(src, qa):
302
+ if src is None or qa is None:
303
+ return None
304
+ return float(src) - float(qa)
305
+
306
+ def add_row(check_type, check_name, column, src, qa, skip_reason=None):
307
+ if skip_reason:
308
+ qa_rows.append((
309
+ check_type,
310
+ check_name,
311
+ column,
312
+ src,
313
+ qa,
314
+ None,
315
+ None,
316
+ "SKIPPED",
317
+ skip_reason
318
+ ))
319
+ else:
320
+ match = src == qa
321
+ qa_rows.append((
322
+ check_type,
323
+ check_name,
324
+ column,
325
+ src,
326
+ qa,
327
+ calc_diff(src, qa),
328
+ match,
329
+ "PASS" if match else "FAIL",
330
+ None
331
+ ))
332
+
333
+ # Row count
334
+ src_count = source_df.count()
335
+ qa_count = qa_df.count()
336
+
337
+ add_row(
338
+ "ROW_COUNT",
339
+ "row_count",
340
+ None,
341
+ float(src_count),
342
+ float(qa_count)
343
+ )
344
+
345
+ # Null check
346
+ common_cols = set(source_df.columns).intersection(set(qa_df.columns))
347
+
348
+ if not common_cols:
349
+ add_row(
350
+ "NULL_CHECK",
351
+ "null_count",
352
+ None,
353
+ None,
354
+ None,
355
+ "No common columns between source and QA"
356
+ )
357
+ else:
358
+ for col in common_cols:
359
+ src_nulls = source_df.filter(F.col(col).isNull()).count()
360
+ qa_nulls = qa_df.filter(F.col(col).isNull()).count()
361
+
362
+ add_row(
363
+ "NULL_CHECK",
364
+ "null_count",
365
+ col,
366
+ float(src_nulls),
367
+ float(qa_nulls)
368
+ )
369
+
370
+
371
+ # Aggregation check
372
+ if "amount" not in source_df.columns or "amount" not in qa_df.columns:
373
+ add_row(
374
+ "AGG_CHECK",
375
+ "sum",
376
+ "amount",
377
+ None,
378
+ None,
379
+ "Column 'amount' missing in one or both DataFrames"
380
+ )
381
+ else:
382
+ src_sum = source_df.select(F.sum("amount")).collect()[0][0]
383
+ qa_sum = qa_df.select(F.sum("amount")).collect()[0][0]
384
+
385
+ if src_sum is None and qa_sum is None:
386
+ add_row(
387
+ "AGG_CHECK",
388
+ "sum",
389
+ "amount",
390
+ None,
391
+ None,
392
+ "All values are NULL in both DataFrames"
393
+ )
394
+ else:
395
+ add_row(
396
+ "AGG_CHECK",
397
+ "sum",
398
+ "amount",
399
+ float(src_sum or 0.0),
400
+ float(qa_sum or 0.0)
401
+ )
402
+
403
+
404
+ # Duplicate check on id column
405
+ if "id" not in source_df.columns or "id" not in qa_df.columns:
406
+ add_row(
407
+ "DUPLICATE_CHECK",
408
+ "duplicate_id",
409
+ "id",
410
+ None,
411
+ None,
412
+ "Column 'id' missing in one or both DataFrames"
413
+ )
414
+ else:
415
+ src_dupes = source_df.count() - source_df.select("id").distinct().count()
416
+ qa_dupes = qa_df.count() - qa_df.select("id").distinct().count()
417
+
418
+ add_row(
419
+ "DUPLICATE_CHECK",
420
+ "duplicate_id",
421
+ "id",
422
+ float(src_dupes),
423
+ float(qa_dupes)
424
+ )
425
+
426
+ # Create final QA DataFrame
427
+ return spark.createDataFrame(
428
+ qa_rows,
429
+ [
430
+ "check_type",
431
+ "check_name",
432
+ "column_name",
433
+ "source_value",
434
+ "qa_value",
435
+ "diff",
436
+ "match",
437
+ "status",
438
+ "skip_reason"
439
+ ]
440
+ )
441
+
442
+ ## <<<<<<<<<<<<<<<< create_lakehouse_shortcuts
443
+
444
+ def create_lakehouse_shortcuts(shortcut_configs, workspace_id, lakehouse_id, target_schema):
445
+ access_token = mssparkutils.credentials.getToken("https://api.fabric.microsoft.com/.default")
446
+ headers = {
447
+ "Authorization": f"Bearer {access_token}",
448
+ "Content-Type": "application/json"
449
+ }
450
+ print("Access token starts with:", access_token[:20])
451
+
452
+ for config in shortcut_configs:
453
+ source_path = config["source_subpath"]
454
+ target_shortcut_name = config["target_shortcut_name"]
455
+ source_workspace_id = config["source_workspace_id"]
456
+ source_lakehouse_id = config["source_lakehouse_id"]
457
+
458
+ target_path = f"Tables/{target_schema or 'dbo'}/"
459
+
460
+ payload = {
461
+ "path": target_path,
462
+ "name": target_shortcut_name,
463
+ "target": {
464
+ "type": "OneLake",
465
+ "oneLake": {
466
+ "workspaceId": source_workspace_id,
467
+ "itemId": source_lakehouse_id,
468
+ "path": source_path
469
+ }
470
+ }
471
+ }
472
+
473
+ url = f"https://api.fabric.microsoft.com/v1/workspaces/{workspace_id}/items/{lakehouse_id}/shortcuts"
474
+ print(f"Creating shortcut '{target_shortcut_name}' → {target_path}")
475
+ print(json.dumps(payload, indent=2))
476
+
477
+ # --- Send POST request ---
478
+ response = requests.post(url, headers=headers, json=payload)
479
+
480
+ if response.status_code in [200, 201]:
481
+ print(f"Shortcut '{target_shortcut_name}' created successfully.")
482
+ print(response.json())
483
+ else:
484
+ print(f"Failed to create shortcut '{target_shortcut_name}'.")
485
+ print("Status Code:", response.status_code)
486
+ print("Response:", response.text)
487
+
488
+
489
+ ## <<<<<<<<<<<<<<<< create_lakehouse_shortcuts_01
490
+
491
+ def create_lakehouse_shortcuts_01(shortcut_configs):
492
+ access_token = mssparkutils.credentials.getToken("https://api.fabric.microsoft.com/.default")
493
+ headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json" }
494
+ print("Access token starts with:", access_token[:20])
495
+
496
+ for config in shortcut_configs:
497
+ source_path = config["source_subpath"]
498
+ target_schema = config["target_schema"]
499
+ workspace_name = config["workspace_name"]
500
+ lakehouse_name = config["lakehouse_name"]
501
+ target_shortcut_name = config["target_shortcut_name"]
502
+
503
+ resp_ws = requests.get("https://api.fabric.microsoft.com/v1/workspaces", headers=headers)
504
+ resp_ws.raise_for_status()
505
+ workspace_id = next(ws["id"] for ws in resp_ws.json()["value"] if ws["displayName"] == workspace_name)
506
+
507
+ resp_lh = requests.get(f"https://api.fabric.microsoft.com/v1/workspaces/{workspace_id}/lakehouses", headers=headers)
508
+ resp_lh.raise_for_status()
509
+ lakehouse_id = next(lh["id"] for lh in resp_lh.json()["value"] if lh["displayName"] == lakehouse_name)
510
+
511
+ target_path = f"Tables/{target_schema or 'dbo'}/"
512
+
513
+ payload = {
514
+ "path": target_path,
515
+ "name": target_shortcut_name,
516
+ "target": {
517
+ "type": "OneLake",
518
+ "oneLake": {
519
+ "workspaceId" : workspace_id,
520
+ "itemId" : lakehouse_id,
521
+ "path" : source_path,
522
+ "target_schema" : config["target_schema"]
523
+ }
524
+ }
525
+ }
526
+
527
+ url = f"https://api.fabric.microsoft.com/v1/workspaces/{workspace_id}/items/{lakehouse_id}/shortcuts"
528
+ print(f"Creating shortcut '{target_shortcut_name}' → {target_path}")
529
+ print(json.dumps(payload, indent=2))
530
+
531
+ # --- Send POST request ---
532
+ response = requests.post(url, headers=headers, json=payload)
533
+
534
+ if response.status_code in [200, 201]:
535
+ print(f"Shortcut '{target_shortcut_name}' created successfully.")
536
+ print(response.json())
537
+ else:
538
+ print(f"Failed to create shortcut '{target_shortcut_name}'.")
539
+ print("Status Code:", response.status_code)
540
+ print("Response:", response.text)
541
+
542
+ """
543
+ # How to call function
544
+ shortcut_configs = [
545
+ {
546
+ "target_shortcut_name" : "DIM_Date",
547
+ "workspace_name" : "FDnECostHubReporting_DEV",
548
+ "lakehouse_name" : "Cost_Hub",
549
+ "source_subpath" : "Tables/DIM_Date",
550
+ "target_schema" : "CostHub",
551
+ }
552
+ ]
553
+ create_lakehouse_shortcuts_01(shortcut_configs)
554
+ """
555
+
556
+ ## <<<<<<<<<<<<<<<< create_adls_shortcuts
557
+
558
+ def create_adls_shortcuts(shortcut_configs, workspace_id, lakehouse_id, target_schema):
559
+ access_token = mssparkutils.credentials.getToken("https://api.fabric.microsoft.com/.default")
560
+ headers = {
561
+ "Authorization": f"Bearer {access_token}",
562
+ "Content-Type": "application/json"
563
+ }
564
+ print("Access token starts with:", access_token[:20])
565
+
566
+ target_path = f"Tables/{target_schema}/"
567
+
568
+ for config in shortcut_configs:
569
+ payload = {
570
+ "name": config["name"],
571
+ "path": target_path,
572
+ "target": {
573
+ "type": "AdlsGen2",
574
+ "adlsGen2": {
575
+ "connectionId": config["connection_id"],
576
+ "location": config["location"],
577
+ "subpath": config["subpath"]
578
+ }
579
+ }
580
+ }
581
+
582
+ url = f"https://api.fabric.microsoft.com/v1/workspaces/{workspace_id}/items/{lakehouse_id}/shortcuts"
583
+ response = requests.post(url, headers=headers, json=payload)
584
+
585
+ if response.status_code in [200, 201]:
586
+ print(f"Shortcut '{config['name']}' created successfully.")
587
+ else:
588
+ print(f"Failed to create shortcut '{config['name']}'.")
589
+ print("Status Code:", response.status_code)
590
+ print("Response:", response.text)
591
+
592
+
593
+ ## <<<<<<<<<<<<<<<< create_adls_shortcuts_01
594
+
595
+ def create_adls_shortcuts_01(shortcut_configs):
596
+ access_token = mssparkutils.credentials.getToken("https://api.fabric.microsoft.com/.default")
597
+ headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json" }
598
+ print("Access token starts with:", access_token[:20])
599
+
600
+ for config in shortcut_configs:
601
+ target_schema = config["target_schema"]
602
+ workspace_name = config["workspace_name"]
603
+ lakehouse_name = config["lakehouse_name"]
604
+ connection_name = config["connection_name"]
605
+ target_path = f"Tables/{target_schema}/"
606
+
607
+ resp_ws = requests.get("https://api.fabric.microsoft.com/v1/workspaces", headers=headers)
608
+ resp_ws.raise_for_status()
609
+ workspace_id = next(ws["id"] for ws in resp_ws.json()["value"] if ws["displayName"] == workspace_name)
610
+
611
+ resp_lh = requests.get(f"https://api.fabric.microsoft.com/v1/workspaces/{workspace_id}/lakehouses", headers=headers)
612
+ resp_lh.raise_for_status()
613
+ lakehouse_id = next(lh["id"] for lh in resp_lh.json()["value"] if lh["displayName"] == lakehouse_name)
614
+
615
+ resp_cn = requests.get(f"https://api.fabric.microsoft.com/v1/connections", headers=headers)
616
+ resp_cn.raise_for_status()
617
+ connection_id = next(conn["id"] for conn in resp_cn.json()["value"] if conn["displayName"] == connection_name)
618
+
619
+ conn_loc = next(conn for conn in resp_cn.json()["value"] if conn["displayName"] == connection_name)
620
+ location = conn_loc["connectionDetails"]["path"]
621
+
622
+ payload = {
623
+ "name": config["name"],
624
+ "path": target_path,
625
+ "target": {
626
+ "type": "AdlsGen2",
627
+ "adlsGen2": {
628
+ "connectionId" : connection_id,
629
+ "location" : location,
630
+ "subpath" : config["subpath"]
631
+ }
632
+ }
633
+ }
634
+
635
+ url = f"https://api.fabric.microsoft.com/v1/workspaces/{workspace_id}/items/{lakehouse_id}/shortcuts"
636
+ response = requests.post(url, headers=headers, json=payload)
637
+
638
+ if response.status_code in [200, 201]:
639
+ print(f"Shortcut '{config['name']}' created successfully.")
640
+ else:
641
+ print(f"Failed to create shortcut '{config['name']}'.")
642
+ print("Status Code:", response.status_code)
643
+ print("Response:", response.text)
644
+
645
+ """
646
+ # How to call function
647
+
648
+ # Define shortcut configurations
649
+ shortcut_configs = [
650
+ {
651
+ "name" : "Bridge_ExecOrgSummary",
652
+ "target_schema" : "CostHub",
653
+ "workspace_name" : "FDnECostHubReporting_DEV",
654
+ "lakehouse_name" : "Cost_Hub",
655
+ "connection_name" : "CostHub_ADLS abibrahi",
656
+ "subpath" : "/abidatamercury/MercuryDataProd/CostHub/Bridge_ExecOrgSummary"
657
+ }]
658
+
659
+ # Call the function
660
+ create_adls_shortcuts_01(shortcut_configs)
661
+ """
662
+
663
+ ## <<<<<<<<<<<<<<<< lakehouse_metadata_sync
664
+
665
+ def pad_or_truncate_string(input_string, length, pad_char=' '):
666
+ if len(input_string) > length:
667
+ return input_string[:length]
668
+ return input_string.ljust(length, pad_char)
669
+
670
+ def lakehouse_metadata_sync(workspace_id, lakehouse_id):
671
+ client = fabric.FabricRestClient()
672
+
673
+ # Get the SQL endpoint ID from the lakehouse
674
+ lakehouse_props = client.get(f"/v1/workspaces/{workspace_id}/lakehouses/{lakehouse_id}").json()
675
+ sqlendpoint = lakehouse_props['properties']['sqlEndpointProperties']['id']
676
+
677
+ # Prepare the metadata refresh payload
678
+ uri = f"/v1.0/myorg/lhdatamarts/{sqlendpoint}"
679
+ payload = {
680
+ "commands": [
681
+ {"$type": "MetadataRefreshExternalCommand"}
682
+ ]
683
+ }
684
+
685
+ try:
686
+ response = client.post(uri, json=payload)
687
+ response_data = response.json()
688
+
689
+ batchId = response_data["batchId"]
690
+ progressState = response_data["progressState"]
691
+ statusuri = f"/v1.0/myorg/lhdatamarts/{sqlendpoint}/batches/{batchId}"
692
+
693
+ # Poll the status until it's no longer "inProgress"
694
+ while progressState == 'inProgress':
695
+ time.sleep(2)
696
+ status_response = client.get(statusuri).json()
697
+ progressState = status_response["progressState"]
698
+ display(f"Sync state: {progressState}")
699
+
700
+ # Handle success
701
+ if progressState == 'success':
702
+ table_details = [
703
+ {
704
+ 'tableName': t['tableName'],
705
+ 'warningMessages': t.get('warningMessages', []),
706
+ 'lastSuccessfulUpdate': t.get('lastSuccessfulUpdate', 'N/A'),
707
+ 'tableSyncState': t['tableSyncState'],
708
+ 'sqlSyncState': t['sqlSyncState']
709
+ }
710
+ for t in status_response['operationInformation'][0]['progressDetail']['tablesSyncStatus']
711
+ ]
712
+
713
+ print("✅ Extracted Table Details:")
714
+ for detail in table_details:
715
+ print(
716
+ f"Table: {pad_or_truncate_string(detail['tableName'], 30)}"
717
+ f" | Last Update: {detail['lastSuccessfulUpdate']}"
718
+ f" | tableSyncState: {detail['tableSyncState']}"
719
+ f" | Warnings: {detail['warningMessages']}"
720
+ )
721
+ return {"status": "success", "details": table_details}
722
+
723
+ # Handle failure
724
+ elif progressState == 'failure':
725
+ print("❌ Metadata sync failed.")
726
+ display(status_response)
727
+ return {"status": "failure", "error": status_response}
728
+
729
+ else:
730
+ print(f"⚠️ Unexpected progress state: {progressState}")
731
+ return {"status": "unknown", "raw_response": status_response}
732
+
733
+ except Exception as e:
734
+ print("🚨 Error during metadata sync:", str(e))
735
+ return {"status": "exception", "error": str(e)}
736
+
737
+ """
738
+ # How to call function ( lakehouse_metadata_sync )
739
+ workspace_id = spark.conf.get("trident.workspace.id")
740
+ lakehouse_id = spark.conf.get("trident.lakehouse.id")
741
+
742
+ # Call the function
743
+ result = lakehouse_metadata_sync(workspace_id, lakehouse_id)
744
+ display(result)
745
+ """
746
+
747
+
748
+
749
+
750
+
751
+
752
+ # <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< Archieve
753
+
754
+
755
+
756
+ ## <<<<<<<<<<<<<<<< QA_CheckUtil_01
757
+ def QA_CheckUtil_01(
758
+ source_df: DataFrame,
759
+ qa_df: DataFrame
760
+ ) -> DataFrame:
761
+
762
+ spark = source_df.sparkSession
763
+ qa_rows: List[tuple] = []
764
+
765
+ def calc_diff(src: Optional[Union[int, float]], qa: Optional[Union[int, float]]) -> Optional[float]:
766
+ if src is None or qa is None:
767
+ return None
768
+ return float(src) - float(qa)
769
+
770
+ # Row count
771
+ src_count = float(source_df.count())
772
+ qa_count = float(qa_df.count())
773
+ qa_rows.append((
774
+ "ROW_COUNT",
775
+ "row_count",
776
+ None,
777
+ src_count,
778
+ qa_count,
779
+ calc_diff(src_count, qa_count),
780
+ src_count == qa_count
781
+ ))
782
+
783
+ # Null check
784
+ common_cols = set(source_df.columns).intersection(set(qa_df.columns))
785
+ for col in common_cols:
786
+ src_nulls = float(source_df.filter(F.col(col).isNull()).count())
787
+ qa_nulls = float(qa_df.filter(F.col(col).isNull()).count())
788
+ qa_rows.append((
789
+ "NULL_CHECK",
790
+ "null_count",
791
+ col,
792
+ src_nulls,
793
+ qa_nulls,
794
+ calc_diff(src_nulls, qa_nulls),
795
+ src_nulls == qa_nulls
796
+ ))
797
+
798
+ # Aggregation check (SUM for amount)
799
+ if "amount" in source_df.columns and "amount" in qa_df.columns:
800
+ src_sum = float(source_df.select(F.sum("amount")).collect()[0][0] or 0.0)
801
+ qa_sum = float(qa_df.select(F.sum("amount")).collect()[0][0] or 0.0)
802
+ qa_rows.append((
803
+ "AGG_CHECK",
804
+ "sum",
805
+ "amount",
806
+ src_sum,
807
+ qa_sum,
808
+ calc_diff(src_sum, qa_sum),
809
+ src_sum == qa_sum
810
+ ))
811
+
812
+ # Duplicate check on id column
813
+ if "id" in source_df.columns and "id" in qa_df.columns:
814
+ src_dupes = float(source_df.count() - source_df.select("id").distinct().count())
815
+ qa_dupes = float(qa_df.count() - qa_df.select("id").distinct().count())
816
+ qa_rows.append((
817
+ "DUPLICATE_CHECK",
818
+ "duplicate_id",
819
+ "id",
820
+ src_dupes,
821
+ qa_dupes,
822
+ calc_diff(src_dupes, qa_dupes),
823
+ src_dupes == qa_dupes
824
+ ))
825
+
826
+ # Create final QA DataFrame
827
+ qa_df_result = spark.createDataFrame(
828
+ qa_rows,
829
+ [
830
+ "check_type",
831
+ "check_name",
832
+ "column_name",
833
+ "source_value",
834
+ "qa_value",
835
+ "diff",
836
+ "match"
837
+ ]
838
+ )
839
+ return qa_df_result
840
+
841
+ ## >>>>>>>>>>>>>>>>>> QA_CheckUtil_01
842
+
843
+
844
+ ## <<<<<<<<<<<<<<<< send_email_no_attachment_02
845
+
846
+ def send_email_no_attachment_02(p, endpoint_url=None, access_token=None):
847
+ """
848
+ Send email via POST API without attachment.
849
+
850
+ Parameters:
851
+ p (dict): {
852
+ "to": str | list[str],
853
+ "subject": str,
854
+ "body": str,
855
+ "headers": dict (optional),
856
+ "timeout": int (optional)
857
+ }
858
+ endpoint_url (str): API endpoint for sending mail
859
+ access_token (str): Bearer token
860
+
861
+ Returns:
862
+ (status_code, response_text) or (None, error_message)
863
+ """
864
+ if not endpoint_url:
865
+ raise ValueError("endpoint_url is required")
866
+ if not access_token:
867
+ raise ValueError("access_token is required")
868
+
869
+ missing = [k for k in ("to", "subject", "body") if not p.get(k)]
870
+ if missing:
871
+ return None, f"Missing required fields: {', '.join(missing)}"
872
+
873
+ payload = {
874
+ "to": ";".join(p["to"]) if isinstance(p["to"], list) else p["to"],
875
+ "subject": p["subject"],
876
+ "body": p["body"],
877
+ }
878
+
879
+ headers = {
880
+ "Authorization": f"Bearer {access_token}",
881
+ **p.get("headers", {})
882
+ }
883
+
884
+ try:
885
+ resp = requests.post(
886
+ endpoint_url,
887
+ json=payload,
888
+ headers=headers,
889
+ timeout=p.get("timeout", 15)
890
+ )
891
+ success_codes = (200, 201, 202)
892
+ return resp.status_code, resp.text
893
+ except requests.RequestException as e:
894
+ return None, str(e)
895
+
896
+
897
+ ## <<<<<<<<<<<<<<<< send_email_no_attachment_01
898
+
899
+ def send_email_no_attachment_01(
900
+ body : Optional[str] = None,
901
+ endpoint_url: Optional[str] = None,
902
+ access_token: Optional[str] = None,
903
+ subject : Optional[str] = None,
904
+ recipients : Optional[List[str]] = None,
905
+ headers : Optional[Dict[str, str]] = None,
906
+ timeout : int = 15,
907
+ tz_name : str = "America/Los_Angeles"
908
+ ) -> Tuple[Optional[int], str]:
909
+ """
910
+ Send email via POST API without attachments. All parameters are optional.
911
+ If required information is missing, returns a descriptive message instead of sending.
912
+ """
913
+ # If endpoint or token not provided, skip sending
914
+ if not endpoint_url or not access_token:
915
+ return None, "Skipping send: endpoint_url or access_token not provided."
916
+
917
+ # Determine recipients
918
+ final_recipients = recipients
919
+ if not final_recipients:
920
+ return None, "Skipping send: no recipients provided."
921
+
922
+ # Determine body content
923
+ final_body = body
924
+ if not final_body:
925
+ return None, "Skipping send: no body content provided."
926
+
927
+ payload = {
928
+ "to": ";".join(final_recipients) if isinstance(final_recipients, list) else final_recipients,
929
+ "subject": subject or "",
930
+ "body": final_body
931
+ }
932
+
933
+ request_headers = {
934
+ "Authorization": f"Bearer {access_token}",
935
+ **(headers or {})
936
+ }
937
+
938
+ try:
939
+ resp = requests.post(
940
+ endpoint_url,
941
+ json=payload,
942
+ headers=request_headers,
943
+ timeout=timeout
944
+ )
945
+ if resp.status_code in (200, 201, 202):
946
+ return resp.status_code, resp.text
947
+ else:
948
+ return resp.status_code, f"Failed: {resp.text}"
949
+ except requests.RequestException as e:
950
+ return None, str(e)
951
+
952
+ """
953
+ # How to call function
954
+
955
+ apiid = spark.conf.get("spark.scopeid")
956
+ scope = f"api://{apiid}/.default"
957
+ access_token = credential.get_token(scope).token
958
+ endpoint_base = "https://fdne-inframail-logicapp01.azurewebsites.net:443/api/fdne-infra-appmail-sender"
959
+ endpoint_url = f"{endpoint_base}/triggers/When_a_HTTP_request_is_received/invoke?api-version=2022-05-01"
960
+
961
+ status, response = send_email_no_attachment_01(
962
+ body=markdown,
963
+ recipients=recipients,
964
+ endpoint_url=endpoint_url,
965
+ access_token=access_token,
966
+ subject=subject)
967
+ """
@@ -0,0 +1,16 @@
1
+ from .CPILake_Utils import hash_function, \
2
+ send_email_via_http, \
3
+ _df_to_html_table, \
4
+ send_email_no_attachment, \
5
+ QA_CheckUtil, \
6
+ create_lakehouse_shortcuts, \
7
+ create_lakehouse_shortcuts_01, \
8
+ create_adls_shortcuts, \
9
+ create_adls_shortcuts_01, \
10
+ lakehouse_metadata_sync
11
+
12
+ __all__ = [ "hash_function", "send_email_via_http", "_df_to_html_table", "send_email_no_attachment",
13
+ "QA_CheckUtil", "create_lakehouse_shortcuts",
14
+ "create_lakehouse_shortcuts_01", "create_adls_shortcuts", "create_adls_shortcuts_01",
15
+ "lakehouse_metadata_sync"]
16
+
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: CPILake_Utils
3
+ Version: 11.0.0
4
+ Summary: Reusable common utility functions including email and hash functions
5
+ Author: Abhilash Ibrahimpatnam
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: requests
9
+
10
+
11
+ # CPILake_Utils
12
+
13
+ ## Introduction
14
+
15
+ A lightweight internal Python library providing reusable email-sending utilities and helper functions for the Fabric Notebook.
16
+
17
+ This package is distributed as a Python wheel (`.whl`) and can be used across notebooks, scripts, or internal pipelines.
18
+
19
+ ## Features
20
+
21
+ - Send emails via a simple POST API
22
+ - Supports multiple recipients
23
+ - Handles HTTP 200, 201, 202 as successful response codes
24
+ - Minimal dependencies (`requests` only)
25
+ - Includes a pure-Python hash function for alphanumeric strings
26
+
27
+ ## Package Structure
28
+
29
+ common_functions/ <- root folder
30
+ ├─ CPILake_Utils/ <- package folder
31
+ │ ├─ __init__.py <- import all functions here
32
+ │ └─ CPILake_Utils.py <- all utilities go here (email, hash, etc.)
33
+ ├─ pyproject.toml
34
+ └─ README.md
35
+
36
+
37
+ ## Installation
38
+
39
+ # Install the wheel file directly
40
+ pip install CPILake-Utils-1.0.0-py3-none-any.whl
41
+
42
+ # OR
43
+ pip install build # Install the build tool
44
+ python -m build # Creates the .whl file in dist/
45
+ twine upload dist/CPILake-Utils-1.0.0-py3-none-any.whl
46
+
47
+ # Usage Example
48
+
49
+ pip install CPILake-Utils==1.0.0
50
+
51
+ # OR
52
+ from CPILake-Utils import send_email_no_attachment, hash_function, send_email_no_attachment_01
53
+
54
+ print(hash_function("test"))
55
+
56
+ status, response = send_email_no_attachment(
57
+ {"to": ["user@domain.com"], "subject": "Hi", "body": "Test email"},
58
+ endpoint_url="https://your-endpoint",
59
+ access_token="YOUR_ACCESS_TOKEN"
60
+ )
61
+ print(status, response)
62
+
63
+ print(status) # 200 or None
64
+ print(response) # Response text or skip response
65
+
66
+
67
+ # Upload to PyPI (Token-based Authentication)
68
+ -- upload it to PyPI using an API token instead of username/password
69
+
70
+ # Replace $env:PYPI_API_TOKEN with your PyPI API token (PowerShell)
71
+ twine upload dist/* -u __token__ -p $env:PYPI_API_TOKEN
72
+
73
+ This securely uploads all files in the dist/ folder, After upload, your package will be available at:
74
+ https://pypi.org/project/CPILake-Utils/1.0.0/
75
+
76
+ ## Author
77
+
78
+ Abhilash Ibrahimpatnam
79
+
80
+ ## Summary
81
+
82
+ This README includes:
83
+
84
+ 1. Clean introduction and feature list
85
+ 2. Clear package structure explanation
86
+ 3. Local installation and build instructions
87
+ 4. PyPI install instructions
88
+ 5. Usage examples with both functions
89
+ 6. Token-based upload instructions for PyPI
90
+ 7. Dependencies and author info
91
+
92
+
93
+ ## Clean old build artifacts
94
+ Remove-Item -Recurse -Force build, dist
95
+ Remove-Item -Recurse -Force *.egg-info
96
+
97
+
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ CPILake_Utils/CPILake_Utils.py
4
+ CPILake_Utils/__init__.py
5
+ CPILake_Utils.egg-info/PKG-INFO
6
+ CPILake_Utils.egg-info/SOURCES.txt
7
+ CPILake_Utils.egg-info/dependency_links.txt
8
+ CPILake_Utils.egg-info/requires.txt
9
+ CPILake_Utils.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ CPILake_Utils
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: CPILake_Utils
3
+ Version: 11.0.0
4
+ Summary: Reusable common utility functions including email and hash functions
5
+ Author: Abhilash Ibrahimpatnam
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: requests
9
+
10
+
11
+ # CPILake_Utils
12
+
13
+ ## Introduction
14
+
15
+ A lightweight internal Python library providing reusable email-sending utilities and helper functions for the Fabric Notebook.
16
+
17
+ This package is distributed as a Python wheel (`.whl`) and can be used across notebooks, scripts, or internal pipelines.
18
+
19
+ ## Features
20
+
21
+ - Send emails via a simple POST API
22
+ - Supports multiple recipients
23
+ - Handles HTTP 200, 201, 202 as successful response codes
24
+ - Minimal dependencies (`requests` only)
25
+ - Includes a pure-Python hash function for alphanumeric strings
26
+
27
+ ## Package Structure
28
+
29
+ common_functions/ <- root folder
30
+ ├─ CPILake_Utils/ <- package folder
31
+ │ ├─ __init__.py <- import all functions here
32
+ │ └─ CPILake_Utils.py <- all utilities go here (email, hash, etc.)
33
+ ├─ pyproject.toml
34
+ └─ README.md
35
+
36
+
37
+ ## Installation
38
+
39
+ # Install the wheel file directly
40
+ pip install CPILake-Utils-1.0.0-py3-none-any.whl
41
+
42
+ # OR
43
+ pip install build # Install the build tool
44
+ python -m build # Creates the .whl file in dist/
45
+ twine upload dist/CPILake-Utils-1.0.0-py3-none-any.whl
46
+
47
+ # Usage Example
48
+
49
+ pip install CPILake-Utils==1.0.0
50
+
51
+ # OR
52
+ from CPILake-Utils import send_email_no_attachment, hash_function, send_email_no_attachment_01
53
+
54
+ print(hash_function("test"))
55
+
56
+ status, response = send_email_no_attachment(
57
+ {"to": ["user@domain.com"], "subject": "Hi", "body": "Test email"},
58
+ endpoint_url="https://your-endpoint",
59
+ access_token="YOUR_ACCESS_TOKEN"
60
+ )
61
+ print(status, response)
62
+
63
+ print(status) # 200 or None
64
+ print(response) # Response text or skip response
65
+
66
+
67
+ # Upload to PyPI (Token-based Authentication)
68
+ -- upload it to PyPI using an API token instead of username/password
69
+
70
+ # Replace $env:PYPI_API_TOKEN with your PyPI API token (PowerShell)
71
+ twine upload dist/* -u __token__ -p $env:PYPI_API_TOKEN
72
+
73
+ This securely uploads all files in the dist/ folder, After upload, your package will be available at:
74
+ https://pypi.org/project/CPILake-Utils/1.0.0/
75
+
76
+ ## Author
77
+
78
+ Abhilash Ibrahimpatnam
79
+
80
+ ## Summary
81
+
82
+ This README includes:
83
+
84
+ 1. Clean introduction and feature list
85
+ 2. Clear package structure explanation
86
+ 3. Local installation and build instructions
87
+ 4. PyPI install instructions
88
+ 5. Usage examples with both functions
89
+ 6. Token-based upload instructions for PyPI
90
+ 7. Dependencies and author info
91
+
92
+
93
+ ## Clean old build artifacts
94
+ Remove-Item -Recurse -Force build, dist
95
+ Remove-Item -Recurse -Force *.egg-info
96
+
97
+
@@ -0,0 +1,88 @@
1
+
2
+ # CPILake_Utils
3
+
4
+ ## Introduction
5
+
6
+ A lightweight internal Python library providing reusable email-sending utilities and helper functions for the Fabric Notebook.
7
+
8
+ This package is distributed as a Python wheel (`.whl`) and can be used across notebooks, scripts, or internal pipelines.
9
+
10
+ ## Features
11
+
12
+ - Send emails via a simple POST API
13
+ - Supports multiple recipients
14
+ - Handles HTTP 200, 201, 202 as successful response codes
15
+ - Minimal dependencies (`requests` only)
16
+ - Includes a pure-Python hash function for alphanumeric strings
17
+
18
+ ## Package Structure
19
+
20
+ common_functions/ <- root folder
21
+ ├─ CPILake_Utils/ <- package folder
22
+ │ ├─ __init__.py <- import all functions here
23
+ │ └─ CPILake_Utils.py <- all utilities go here (email, hash, etc.)
24
+ ├─ pyproject.toml
25
+ └─ README.md
26
+
27
+
28
+ ## Installation
29
+
30
+ # Install the wheel file directly
31
+ pip install CPILake-Utils-1.0.0-py3-none-any.whl
32
+
33
+ # OR
34
+ pip install build # Install the build tool
35
+ python -m build # Creates the .whl file in dist/
36
+ twine upload dist/CPILake-Utils-1.0.0-py3-none-any.whl
37
+
38
+ # Usage Example
39
+
40
+ pip install CPILake-Utils==1.0.0
41
+
42
+ # OR
43
+ from CPILake-Utils import send_email_no_attachment, hash_function, send_email_no_attachment_01
44
+
45
+ print(hash_function("test"))
46
+
47
+ status, response = send_email_no_attachment(
48
+ {"to": ["user@domain.com"], "subject": "Hi", "body": "Test email"},
49
+ endpoint_url="https://your-endpoint",
50
+ access_token="YOUR_ACCESS_TOKEN"
51
+ )
52
+ print(status, response)
53
+
54
+ print(status) # 200 or None
55
+ print(response) # Response text or skip response
56
+
57
+
58
+ # Upload to PyPI (Token-based Authentication)
59
+ -- upload it to PyPI using an API token instead of username/password
60
+
61
+ # Replace $env:PYPI_API_TOKEN with your PyPI API token (PowerShell)
62
+ twine upload dist/* -u __token__ -p $env:PYPI_API_TOKEN
63
+
64
+ This securely uploads all files in the dist/ folder, After upload, your package will be available at:
65
+ https://pypi.org/project/CPILake-Utils/1.0.0/
66
+
67
+ ## Author
68
+
69
+ Abhilash Ibrahimpatnam
70
+
71
+ ## Summary
72
+
73
+ This README includes:
74
+
75
+ 1. Clean introduction and feature list
76
+ 2. Clear package structure explanation
77
+ 3. Local installation and build instructions
78
+ 4. PyPI install instructions
79
+ 5. Usage examples with both functions
80
+ 6. Token-based upload instructions for PyPI
81
+ 7. Dependencies and author info
82
+
83
+
84
+ ## Clean old build artifacts
85
+ Remove-Item -Recurse -Force build, dist
86
+ Remove-Item -Recurse -Force *.egg-info
87
+
88
+
@@ -0,0 +1,15 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "CPILake_Utils"
7
+ version = "11.0.0"
8
+ description = "Reusable common utility functions including email and hash functions"
9
+ authors = [{ name="Abhilash Ibrahimpatnam" }]
10
+ dependencies = ["requests"]
11
+ readme = "README.md"
12
+ requires-python = ">=3.8"
13
+
14
+ [tool.setuptools]
15
+ packages = ["CPILake_Utils"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+