bb-integrations-library 3.0.11__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.
- bb_integrations_lib/__init__.py +0 -0
- bb_integrations_lib/converters/__init__.py +0 -0
- bb_integrations_lib/gravitate/__init__.py +0 -0
- bb_integrations_lib/gravitate/base_api.py +20 -0
- bb_integrations_lib/gravitate/model.py +29 -0
- bb_integrations_lib/gravitate/pe_api.py +122 -0
- bb_integrations_lib/gravitate/rita_api.py +552 -0
- bb_integrations_lib/gravitate/sd_api.py +572 -0
- bb_integrations_lib/gravitate/testing/TTE/sd/models.py +1398 -0
- bb_integrations_lib/gravitate/testing/TTE/sd/tests/test_models.py +2987 -0
- bb_integrations_lib/gravitate/testing/__init__.py +0 -0
- bb_integrations_lib/gravitate/testing/builder.py +55 -0
- bb_integrations_lib/gravitate/testing/openapi.py +70 -0
- bb_integrations_lib/gravitate/testing/util.py +274 -0
- bb_integrations_lib/mappers/__init__.py +0 -0
- bb_integrations_lib/mappers/prices/__init__.py +0 -0
- bb_integrations_lib/mappers/prices/model.py +106 -0
- bb_integrations_lib/mappers/prices/price_mapper.py +127 -0
- bb_integrations_lib/mappers/prices/protocol.py +20 -0
- bb_integrations_lib/mappers/prices/util.py +61 -0
- bb_integrations_lib/mappers/rita_mapper.py +523 -0
- bb_integrations_lib/models/__init__.py +0 -0
- bb_integrations_lib/models/dtn_supplier_invoice.py +487 -0
- bb_integrations_lib/models/enums.py +28 -0
- bb_integrations_lib/models/pipeline_structs.py +76 -0
- bb_integrations_lib/models/probe/probe_event.py +20 -0
- bb_integrations_lib/models/probe/request_data.py +431 -0
- bb_integrations_lib/models/probe/resume_token.py +7 -0
- bb_integrations_lib/models/rita/audit.py +113 -0
- bb_integrations_lib/models/rita/auth.py +30 -0
- bb_integrations_lib/models/rita/bucket.py +17 -0
- bb_integrations_lib/models/rita/config.py +188 -0
- bb_integrations_lib/models/rita/constants.py +19 -0
- bb_integrations_lib/models/rita/crossroads_entities.py +293 -0
- bb_integrations_lib/models/rita/crossroads_mapping.py +428 -0
- bb_integrations_lib/models/rita/crossroads_monitoring.py +78 -0
- bb_integrations_lib/models/rita/crossroads_network.py +41 -0
- bb_integrations_lib/models/rita/crossroads_rules.py +80 -0
- bb_integrations_lib/models/rita/email.py +39 -0
- bb_integrations_lib/models/rita/issue.py +63 -0
- bb_integrations_lib/models/rita/mapping.py +227 -0
- bb_integrations_lib/models/rita/probe.py +58 -0
- bb_integrations_lib/models/rita/reference_data.py +110 -0
- bb_integrations_lib/models/rita/source_system.py +9 -0
- bb_integrations_lib/models/rita/workers.py +76 -0
- bb_integrations_lib/models/sd/bols_and_drops.py +241 -0
- bb_integrations_lib/models/sd/get_order.py +301 -0
- bb_integrations_lib/models/sd/orders.py +18 -0
- bb_integrations_lib/models/sd_api.py +115 -0
- bb_integrations_lib/pipelines/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/distribution_report/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/distribution_report/order_by_site_product_parser.py +50 -0
- bb_integrations_lib/pipelines/parsers/distribution_report/tank_configs_parser.py +47 -0
- bb_integrations_lib/pipelines/parsers/dtn/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/dtn/dtn_price_parser.py +102 -0
- bb_integrations_lib/pipelines/parsers/dtn/model.py +79 -0
- bb_integrations_lib/pipelines/parsers/price_engine/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/price_engine/parse_accessorials_prices_parser.py +67 -0
- bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/__init__.py +0 -0
- bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/price_merge_parser.py +111 -0
- bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/price_sync_parser.py +107 -0
- bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/shared.py +81 -0
- bb_integrations_lib/pipelines/parsers/tank_reading_parser.py +155 -0
- bb_integrations_lib/pipelines/parsers/tank_sales_parser.py +144 -0
- bb_integrations_lib/pipelines/shared/__init__.py +0 -0
- bb_integrations_lib/pipelines/shared/allocation_matching.py +227 -0
- bb_integrations_lib/pipelines/shared/bol_allocation.py +2793 -0
- bb_integrations_lib/pipelines/steps/__init__.py +0 -0
- bb_integrations_lib/pipelines/steps/create_accessorials_step.py +80 -0
- bb_integrations_lib/pipelines/steps/distribution_report/__init__.py +0 -0
- bb_integrations_lib/pipelines/steps/distribution_report/distribution_report_datafram_to_raw_data.py +33 -0
- bb_integrations_lib/pipelines/steps/distribution_report/get_model_history_step.py +50 -0
- bb_integrations_lib/pipelines/steps/distribution_report/get_order_by_site_product_step.py +62 -0
- bb_integrations_lib/pipelines/steps/distribution_report/get_tank_configs_step.py +40 -0
- bb_integrations_lib/pipelines/steps/distribution_report/join_distribution_order_dos_step.py +85 -0
- bb_integrations_lib/pipelines/steps/distribution_report/upload_distribution_report_datafram_to_big_query.py +47 -0
- bb_integrations_lib/pipelines/steps/echo_step.py +14 -0
- bb_integrations_lib/pipelines/steps/export_dataframe_to_rawdata_step.py +28 -0
- bb_integrations_lib/pipelines/steps/exporting/__init__.py +0 -0
- bb_integrations_lib/pipelines/steps/exporting/bbd_export_payroll_file_step.py +107 -0
- bb_integrations_lib/pipelines/steps/exporting/bbd_export_readings_step.py +236 -0
- bb_integrations_lib/pipelines/steps/exporting/cargas_wholesale_bundle_upload_step.py +33 -0
- bb_integrations_lib/pipelines/steps/exporting/dataframe_flat_file_export.py +29 -0
- bb_integrations_lib/pipelines/steps/exporting/gcs_bucket_export_file_step.py +34 -0
- bb_integrations_lib/pipelines/steps/exporting/keyvu_export_step.py +356 -0
- bb_integrations_lib/pipelines/steps/exporting/pe_price_export_step.py +238 -0
- bb_integrations_lib/pipelines/steps/exporting/platform_science_order_sync_step.py +500 -0
- bb_integrations_lib/pipelines/steps/exporting/save_rawdata_to_disk.py +15 -0
- bb_integrations_lib/pipelines/steps/exporting/sftp_export_file_step.py +60 -0
- bb_integrations_lib/pipelines/steps/exporting/sftp_export_many_files_step.py +23 -0
- bb_integrations_lib/pipelines/steps/exporting/update_exported_orders_table_step.py +64 -0
- bb_integrations_lib/pipelines/steps/filter_step.py +22 -0
- bb_integrations_lib/pipelines/steps/get_latest_sync_date.py +34 -0
- bb_integrations_lib/pipelines/steps/importing/bbd_import_payroll_step.py +30 -0
- bb_integrations_lib/pipelines/steps/importing/get_order_numbers_to_export_step.py +138 -0
- bb_integrations_lib/pipelines/steps/importing/load_file_to_dataframe_step.py +46 -0
- bb_integrations_lib/pipelines/steps/importing/load_imap_attachment_step.py +172 -0
- bb_integrations_lib/pipelines/steps/importing/pe_bulk_sync_price_structure_step.py +68 -0
- bb_integrations_lib/pipelines/steps/importing/pe_price_merge_step.py +86 -0
- bb_integrations_lib/pipelines/steps/importing/sftp_file_config_step.py +124 -0
- bb_integrations_lib/pipelines/steps/importing/test_exact_file_match.py +57 -0
- bb_integrations_lib/pipelines/steps/null_step.py +15 -0
- bb_integrations_lib/pipelines/steps/pe_integration_job_step.py +32 -0
- bb_integrations_lib/pipelines/steps/processing/__init__.py +0 -0
- bb_integrations_lib/pipelines/steps/processing/archive_gcs_step.py +76 -0
- bb_integrations_lib/pipelines/steps/processing/archive_sftp_step.py +48 -0
- bb_integrations_lib/pipelines/steps/processing/bbd_format_tank_readings_step.py +492 -0
- bb_integrations_lib/pipelines/steps/processing/bbd_upload_prices_step.py +54 -0
- bb_integrations_lib/pipelines/steps/processing/bbd_upload_tank_sales_step.py +124 -0
- bb_integrations_lib/pipelines/steps/processing/bbd_upload_tankreading_step.py +80 -0
- bb_integrations_lib/pipelines/steps/processing/convert_bbd_order_to_cargas_step.py +226 -0
- bb_integrations_lib/pipelines/steps/processing/delete_sftp_step.py +33 -0
- bb_integrations_lib/pipelines/steps/processing/dtn/__init__.py +2 -0
- bb_integrations_lib/pipelines/steps/processing/dtn/convert_dtn_invoice_to_sd_model.py +145 -0
- bb_integrations_lib/pipelines/steps/processing/dtn/parse_dtn_invoice_step.py +38 -0
- bb_integrations_lib/pipelines/steps/processing/file_config_parser_step.py +720 -0
- bb_integrations_lib/pipelines/steps/processing/file_config_parser_step_v2.py +418 -0
- bb_integrations_lib/pipelines/steps/processing/get_sd_price_price_request.py +105 -0
- bb_integrations_lib/pipelines/steps/processing/keyvu_upload_deliveryplan_step.py +39 -0
- bb_integrations_lib/pipelines/steps/processing/mark_orders_exported_in_bbd_step.py +185 -0
- bb_integrations_lib/pipelines/steps/processing/pe_price_rows_processing_step.py +174 -0
- bb_integrations_lib/pipelines/steps/processing/send_process_report_step.py +47 -0
- bb_integrations_lib/pipelines/steps/processing/sftp_renamer_step.py +61 -0
- bb_integrations_lib/pipelines/steps/processing/tank_reading_touchup_steps.py +75 -0
- bb_integrations_lib/pipelines/steps/processing/upload_supplier_invoice_step.py +16 -0
- bb_integrations_lib/pipelines/steps/send_attached_in_rita_email_step.py +44 -0
- bb_integrations_lib/pipelines/steps/send_rita_email_step.py +34 -0
- bb_integrations_lib/pipelines/steps/sleep_step.py +24 -0
- bb_integrations_lib/pipelines/wrappers/__init__.py +0 -0
- bb_integrations_lib/pipelines/wrappers/accessorials_transformation.py +104 -0
- bb_integrations_lib/pipelines/wrappers/distribution_report.py +191 -0
- bb_integrations_lib/pipelines/wrappers/export_tank_readings.py +237 -0
- bb_integrations_lib/pipelines/wrappers/import_tank_readings.py +192 -0
- bb_integrations_lib/pipelines/wrappers/wrapper.py +81 -0
- bb_integrations_lib/protocols/__init__.py +0 -0
- bb_integrations_lib/protocols/flat_file.py +210 -0
- bb_integrations_lib/protocols/gravitate_client.py +104 -0
- bb_integrations_lib/protocols/pipelines.py +697 -0
- bb_integrations_lib/provider/__init__.py +0 -0
- bb_integrations_lib/provider/api/__init__.py +0 -0
- bb_integrations_lib/provider/api/cargas/__init__.py +0 -0
- bb_integrations_lib/provider/api/cargas/client.py +43 -0
- bb_integrations_lib/provider/api/cargas/model.py +49 -0
- bb_integrations_lib/provider/api/cargas/protocol.py +23 -0
- bb_integrations_lib/provider/api/dtn/__init__.py +0 -0
- bb_integrations_lib/provider/api/dtn/client.py +128 -0
- bb_integrations_lib/provider/api/dtn/protocol.py +9 -0
- bb_integrations_lib/provider/api/keyvu/__init__.py +0 -0
- bb_integrations_lib/provider/api/keyvu/client.py +30 -0
- bb_integrations_lib/provider/api/keyvu/model.py +149 -0
- bb_integrations_lib/provider/api/macropoint/__init__.py +0 -0
- bb_integrations_lib/provider/api/macropoint/client.py +28 -0
- bb_integrations_lib/provider/api/macropoint/model.py +40 -0
- bb_integrations_lib/provider/api/pc_miler/__init__.py +0 -0
- bb_integrations_lib/provider/api/pc_miler/client.py +130 -0
- bb_integrations_lib/provider/api/pc_miler/model.py +6 -0
- bb_integrations_lib/provider/api/pc_miler/web_services_apis.py +131 -0
- bb_integrations_lib/provider/api/platform_science/__init__.py +0 -0
- bb_integrations_lib/provider/api/platform_science/client.py +147 -0
- bb_integrations_lib/provider/api/platform_science/model.py +82 -0
- bb_integrations_lib/provider/api/quicktrip/__init__.py +0 -0
- bb_integrations_lib/provider/api/quicktrip/client.py +52 -0
- bb_integrations_lib/provider/api/telapoint/__init__.py +0 -0
- bb_integrations_lib/provider/api/telapoint/client.py +68 -0
- bb_integrations_lib/provider/api/telapoint/model.py +178 -0
- bb_integrations_lib/provider/api/warren_rogers/__init__.py +0 -0
- bb_integrations_lib/provider/api/warren_rogers/client.py +207 -0
- bb_integrations_lib/provider/aws/__init__.py +0 -0
- bb_integrations_lib/provider/aws/s3/__init__.py +0 -0
- bb_integrations_lib/provider/aws/s3/client.py +126 -0
- bb_integrations_lib/provider/ftp/__init__.py +0 -0
- bb_integrations_lib/provider/ftp/client.py +140 -0
- bb_integrations_lib/provider/ftp/interface.py +273 -0
- bb_integrations_lib/provider/ftp/model.py +76 -0
- bb_integrations_lib/provider/imap/__init__.py +0 -0
- bb_integrations_lib/provider/imap/client.py +228 -0
- bb_integrations_lib/provider/imap/model.py +3 -0
- bb_integrations_lib/provider/sqlserver/__init__.py +0 -0
- bb_integrations_lib/provider/sqlserver/client.py +106 -0
- bb_integrations_lib/secrets/__init__.py +4 -0
- bb_integrations_lib/secrets/adapters.py +98 -0
- bb_integrations_lib/secrets/credential_models.py +222 -0
- bb_integrations_lib/secrets/factory.py +85 -0
- bb_integrations_lib/secrets/providers.py +160 -0
- bb_integrations_lib/shared/__init__.py +0 -0
- bb_integrations_lib/shared/exceptions.py +25 -0
- bb_integrations_lib/shared/model.py +1039 -0
- bb_integrations_lib/shared/shared_enums.py +510 -0
- bb_integrations_lib/storage/README.md +236 -0
- bb_integrations_lib/storage/__init__.py +0 -0
- bb_integrations_lib/storage/aws/__init__.py +0 -0
- bb_integrations_lib/storage/aws/s3.py +8 -0
- bb_integrations_lib/storage/defaults.py +72 -0
- bb_integrations_lib/storage/gcs/__init__.py +0 -0
- bb_integrations_lib/storage/gcs/client.py +8 -0
- bb_integrations_lib/storage/gcsmanager/__init__.py +0 -0
- bb_integrations_lib/storage/gcsmanager/client.py +8 -0
- bb_integrations_lib/storage/setup.py +29 -0
- bb_integrations_lib/util/__init__.py +0 -0
- bb_integrations_lib/util/cache/__init__.py +0 -0
- bb_integrations_lib/util/cache/custom_ttl_cache.py +75 -0
- bb_integrations_lib/util/cache/protocol.py +9 -0
- bb_integrations_lib/util/config/__init__.py +0 -0
- bb_integrations_lib/util/config/manager.py +391 -0
- bb_integrations_lib/util/config/model.py +41 -0
- bb_integrations_lib/util/exception_logger/__init__.py +0 -0
- bb_integrations_lib/util/exception_logger/exception_logger.py +146 -0
- bb_integrations_lib/util/exception_logger/test.py +114 -0
- bb_integrations_lib/util/utils.py +364 -0
- bb_integrations_lib/workers/__init__.py +0 -0
- bb_integrations_lib/workers/groups.py +13 -0
- bb_integrations_lib/workers/rpc_worker.py +50 -0
- bb_integrations_lib/workers/topics.py +20 -0
- bb_integrations_library-3.0.11.dist-info/METADATA +59 -0
- bb_integrations_library-3.0.11.dist-info/RECORD +217 -0
- bb_integrations_library-3.0.11.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import ftplib
|
|
2
|
+
from abc import ABC
|
|
3
|
+
from io import BytesIO, StringIO
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from stat import S_ISREG
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
import paramiko
|
|
9
|
+
import tenacity
|
|
10
|
+
from loguru import logger
|
|
11
|
+
from paramiko import RSAKey
|
|
12
|
+
from tenacity import retry_if_exception_type, stop_after_attempt, wait_fixed
|
|
13
|
+
|
|
14
|
+
from bb_integrations_lib.provider.ftp.model import FTPFileInfo, FTPAuthType, FTPType
|
|
15
|
+
from bb_integrations_lib.secrets.credential_models import FTPCredential
|
|
16
|
+
from bb_integrations_lib.shared.model import RawData, File
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FTPClientInterface(ABC):
|
|
20
|
+
def __init__(self, credentials: FTPCredential):
|
|
21
|
+
self.credentials = credentials
|
|
22
|
+
self.first_connection = True
|
|
23
|
+
|
|
24
|
+
def connect(self):
|
|
25
|
+
self.first_connection = False
|
|
26
|
+
|
|
27
|
+
def disconnect(self):
|
|
28
|
+
raise NotImplementedError()
|
|
29
|
+
|
|
30
|
+
def _reconnect(self, retry_state: tenacity.RetryCallState):
|
|
31
|
+
raise NotImplementedError()
|
|
32
|
+
|
|
33
|
+
def cwd(self, directory: str) -> None:
|
|
34
|
+
raise NotImplementedError()
|
|
35
|
+
|
|
36
|
+
def list_files(self, directory: str) -> Iterable[str]:
|
|
37
|
+
raise NotImplementedError()
|
|
38
|
+
|
|
39
|
+
def rename_files(self, files: Iterable[tuple[str, str]]) -> None:
|
|
40
|
+
raise NotImplementedError()
|
|
41
|
+
|
|
42
|
+
def delete_files(self, paths: Iterable[str]) -> None:
|
|
43
|
+
raise NotImplementedError()
|
|
44
|
+
|
|
45
|
+
def upload_files(self, files: Iterable[File], path: str) -> None:
|
|
46
|
+
raise NotImplementedError()
|
|
47
|
+
|
|
48
|
+
def download_files(self, paths: Iterable[str]) -> Iterable[RawData]:
|
|
49
|
+
raise NotImplementedError()
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def reconnect_retry(func):
|
|
53
|
+
def wrap(*args, **kwargs):
|
|
54
|
+
if args[0].first_connection:
|
|
55
|
+
args[0].connect()
|
|
56
|
+
r = tenacity.Retrying(
|
|
57
|
+
reraise=True,
|
|
58
|
+
retry=retry_if_exception_type((OSError, AttributeError)),
|
|
59
|
+
stop=stop_after_attempt(3),
|
|
60
|
+
wait=wait_fixed(3),
|
|
61
|
+
after=args[0]._reconnect
|
|
62
|
+
)
|
|
63
|
+
for attempt in r:
|
|
64
|
+
with attempt:
|
|
65
|
+
return func(*args, **kwargs)
|
|
66
|
+
|
|
67
|
+
return wrap
|
|
68
|
+
|
|
69
|
+
class FTPClient(FTPClientInterface):
|
|
70
|
+
def __init__(self, credentials: FTPCredential):
|
|
71
|
+
super().__init__(credentials)
|
|
72
|
+
|
|
73
|
+
if self.credentials.ftp_type not in [FTPType.ftp, FTPType.ftps, FTPType.ftpes]:
|
|
74
|
+
raise NotImplementedError(
|
|
75
|
+
f"Attempted to use FTPClient with unsupported FTP type: {self.credentials.ftp_type} "
|
|
76
|
+
"(only supports ftp, ftps, ftpes)"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
self.is_tls = self.credentials.ftp_type in [FTPType.ftps, FTPType.ftpes]
|
|
80
|
+
self.client: ftplib.FTP | None = None
|
|
81
|
+
|
|
82
|
+
def connect(self):
|
|
83
|
+
if self.is_tls:
|
|
84
|
+
self.client = ftplib.FTP_TLS(
|
|
85
|
+
host=self.credentials.host,
|
|
86
|
+
user=self.credentials.username,
|
|
87
|
+
passwd=self.credentials.password,
|
|
88
|
+
)
|
|
89
|
+
if self.credentials.ftp_type == FTPType.ftpes:
|
|
90
|
+
self.client.prot_p()
|
|
91
|
+
else:
|
|
92
|
+
self.client = ftplib.FTP(
|
|
93
|
+
host=self.credentials.host,
|
|
94
|
+
user=self.credentials.username,
|
|
95
|
+
passwd=self.credentials.password
|
|
96
|
+
)
|
|
97
|
+
super().connect()
|
|
98
|
+
|
|
99
|
+
def disconnect(self):
|
|
100
|
+
self.client.quit()
|
|
101
|
+
# Will force a reconnection next time it is used
|
|
102
|
+
self.first_connection = True
|
|
103
|
+
|
|
104
|
+
def _reconnect(self, retry_state: tenacity.RetryCallState):
|
|
105
|
+
# Targeted by reconnect_retry
|
|
106
|
+
self.connect()
|
|
107
|
+
|
|
108
|
+
@FTPClientInterface.reconnect_retry
|
|
109
|
+
def list_files(self, directory: str) -> Iterable[str]:
|
|
110
|
+
# Paths might be absolute - for consistency with the SFTP client, make them relative.
|
|
111
|
+
return [Path(x).name for x in self.client.nlst(directory)]
|
|
112
|
+
|
|
113
|
+
@FTPClientInterface.reconnect_retry
|
|
114
|
+
def rename_files(self, files: Iterable[tuple[str, str]]) -> None:
|
|
115
|
+
for old_name, new_name in files:
|
|
116
|
+
logger.debug(f"Renaming file {old_name} -> {new_name}")
|
|
117
|
+
self.client.rename(old_name, new_name)
|
|
118
|
+
|
|
119
|
+
@FTPClientInterface.reconnect_retry
|
|
120
|
+
def delete_files(self, paths: Iterable[str]) -> None:
|
|
121
|
+
for path in paths:
|
|
122
|
+
logger.debug(f"Deleting file {path}")
|
|
123
|
+
self.client.delete(path)
|
|
124
|
+
|
|
125
|
+
@FTPClientInterface.reconnect_retry
|
|
126
|
+
def upload_files(self, files: Iterable[File], path: str) -> None:
|
|
127
|
+
for file in files:
|
|
128
|
+
name = file.file_name
|
|
129
|
+
logger.debug(f"Uploading file {name}")
|
|
130
|
+
self.client.storbinary(f"STOR {path}/{name}", File.to_bytes(file.file_data))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@FTPClientInterface.reconnect_retry
|
|
134
|
+
def download_files(self, paths: Iterable[str]) -> Iterable[RawData]:
|
|
135
|
+
results = []
|
|
136
|
+
for path in paths:
|
|
137
|
+
logger.debug(f"Downloading {path}")
|
|
138
|
+
buf = BytesIO()
|
|
139
|
+
self.client.retrbinary(f"RETR {path}", buf.write)
|
|
140
|
+
buf.seek(0)
|
|
141
|
+
p = Path(path)
|
|
142
|
+
results.append(RawData(file_name=p.name, data=buf))
|
|
143
|
+
return results
|
|
144
|
+
|
|
145
|
+
@FTPClientInterface.reconnect_retry
|
|
146
|
+
def get_file_info(self, path: str) -> FTPFileInfo:
|
|
147
|
+
# Size is guaranteed
|
|
148
|
+
size = self.client.size(path)
|
|
149
|
+
|
|
150
|
+
return FTPFileInfo(
|
|
151
|
+
size=size,
|
|
152
|
+
permissions=None,
|
|
153
|
+
owner_id=None,
|
|
154
|
+
group_id=None,
|
|
155
|
+
last_access_time=None,
|
|
156
|
+
last_modification_time=None,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class SFTPClient(FTPClientInterface):
|
|
161
|
+
def __init__(self, credentials: FTPCredential, private_key_path: str | None = None):
|
|
162
|
+
super().__init__(credentials)
|
|
163
|
+
self.ssh = paramiko.SSHClient()
|
|
164
|
+
self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
165
|
+
self.sftp: paramiko.SFTPClient | None = None
|
|
166
|
+
self.private_key_path = private_key_path
|
|
167
|
+
|
|
168
|
+
def connect(self):
|
|
169
|
+
if self.credentials.private_key:
|
|
170
|
+
pkey = RSAKey.from_private_key(
|
|
171
|
+
StringIO(self.credentials.private_key, self.credentials.passphrase)
|
|
172
|
+
)
|
|
173
|
+
elif self.credentials.auth_type == FTPAuthType.rsa:
|
|
174
|
+
pkey = get_ppk()
|
|
175
|
+
else:
|
|
176
|
+
pkey = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
self.ssh.connect(
|
|
180
|
+
hostname=self.credentials.host,
|
|
181
|
+
username=self.credentials.username,
|
|
182
|
+
password=self.credentials.password,
|
|
183
|
+
port=self.credentials.port or 22,
|
|
184
|
+
key_filename=self.private_key_path,
|
|
185
|
+
pkey=pkey,
|
|
186
|
+
passphrase=self.credentials.passphrase,
|
|
187
|
+
look_for_keys=False,
|
|
188
|
+
allow_agent=False,
|
|
189
|
+
)
|
|
190
|
+
self.sftp = self.ssh.open_sftp()
|
|
191
|
+
super().connect()
|
|
192
|
+
|
|
193
|
+
def disconnect(self):
|
|
194
|
+
self.sftp.close()
|
|
195
|
+
self.first_connection = True
|
|
196
|
+
|
|
197
|
+
def _reconnect(self, retry_state: tenacity.RetryCallState):
|
|
198
|
+
self.connect()
|
|
199
|
+
|
|
200
|
+
@FTPClientInterface.reconnect_retry
|
|
201
|
+
def cwd(self, directory: str) -> None:
|
|
202
|
+
self.sftp.chdir(directory)
|
|
203
|
+
|
|
204
|
+
@FTPClientInterface.reconnect_retry
|
|
205
|
+
def list_files(self, directory: str) -> Iterable[str]:
|
|
206
|
+
logger.debug(f"Listing {directory}")
|
|
207
|
+
res = self.sftp.listdir_attr(directory)
|
|
208
|
+
return [x.filename for x in res if S_ISREG(x.st_mode)]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@FTPClientInterface.reconnect_retry
|
|
212
|
+
def rename_files(self, files: Iterable[tuple[str, str]]) -> None:
|
|
213
|
+
for old_path, new_path in files:
|
|
214
|
+
logger.debug(f"Renaming {old_path} -> {new_path}")
|
|
215
|
+
return self.sftp.rename(old_path, new_path)
|
|
216
|
+
|
|
217
|
+
@FTPClientInterface.reconnect_retry
|
|
218
|
+
def delete_files(self, paths: Iterable[str]) -> None:
|
|
219
|
+
for path in paths:
|
|
220
|
+
logger.debug(f"Deleting {path}")
|
|
221
|
+
self.sftp.remove(path)
|
|
222
|
+
|
|
223
|
+
@FTPClientInterface.reconnect_retry
|
|
224
|
+
def upload_files(self, files: Iterable[File], path: str) -> None:
|
|
225
|
+
for file in files:
|
|
226
|
+
name = file.file_name
|
|
227
|
+
logger.debug(f"Uploading file {name}")
|
|
228
|
+
self.sftp.putfo(file.to_bytes(file.file_data), f"{path}/{name}")
|
|
229
|
+
|
|
230
|
+
@FTPClientInterface.reconnect_retry
|
|
231
|
+
def download_files(self, paths: Iterable[str]) -> Iterable[RawData]:
|
|
232
|
+
results = []
|
|
233
|
+
for path in paths:
|
|
234
|
+
logger.debug(f"Downloading {path}")
|
|
235
|
+
buf = BytesIO()
|
|
236
|
+
self.sftp.getfo(path, buf)
|
|
237
|
+
buf.seek(0)
|
|
238
|
+
p = Path(path)
|
|
239
|
+
results.append(RawData(file_name=p.name, data=buf))
|
|
240
|
+
return results
|
|
241
|
+
|
|
242
|
+
@FTPClientInterface.reconnect_retry
|
|
243
|
+
def get_file_info(self, path: str) -> FTPFileInfo:
|
|
244
|
+
file_info = self.sftp.stat(path)
|
|
245
|
+
return FTPFileInfo(
|
|
246
|
+
size=file_info.st_size,
|
|
247
|
+
permissions=oct(file_info.st_mode) if file_info.st_mode else None,
|
|
248
|
+
owner_id=file_info.st_uid,
|
|
249
|
+
group_id=file_info.st_gid,
|
|
250
|
+
last_access_time=file_info.st_atime,
|
|
251
|
+
last_modification_time=file_info.st_mtime,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def get_ppk(path: str = 'secrets/id_rsa') -> RSAKey:
|
|
255
|
+
"""
|
|
256
|
+
Given a path, try to load RSA key file
|
|
257
|
+
:param path: The path to load
|
|
258
|
+
:return: The loaded RSA key
|
|
259
|
+
"""
|
|
260
|
+
try:
|
|
261
|
+
private_key_path = Path(path)
|
|
262
|
+
if not private_key_path.exists():
|
|
263
|
+
raise FileNotFoundError(f"Private key file not found at: {private_key_path}")
|
|
264
|
+
private_key: RSAKey = RSAKey.from_private_key_file(str(private_key_path))
|
|
265
|
+
return private_key
|
|
266
|
+
except FileNotFoundError as fne:
|
|
267
|
+
msg = f'Failed to load private key from file: {path} -> {fne}'
|
|
268
|
+
logger.error(msg)
|
|
269
|
+
raise
|
|
270
|
+
except Exception as e:
|
|
271
|
+
msg = f'Failed to load private key from file: {path} -> {e}'
|
|
272
|
+
logger.error(msg)
|
|
273
|
+
raise
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Optional, Dict, Any
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
class FTPType(str, Enum):
|
|
7
|
+
"""
|
|
8
|
+
Enumeration of supported FTP types.
|
|
9
|
+
|
|
10
|
+
Attributes:
|
|
11
|
+
ftps (str): FTPS (FTP over SSL) protocol.
|
|
12
|
+
sftp (str): SFTP (SSH File Transfer Protocol) protocol.
|
|
13
|
+
ftp (str): Standard FTP protocol.
|
|
14
|
+
ftpes (str): FTPeS (FTP over explicit SSL) protocol.
|
|
15
|
+
"""
|
|
16
|
+
ftps = "ftps"
|
|
17
|
+
sftp = "sftp"
|
|
18
|
+
ftp = "ftp"
|
|
19
|
+
ftpes = "ftpes"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FTPAuthType(str, Enum):
|
|
23
|
+
"""
|
|
24
|
+
Enumeration of FTP authentication methods.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
basic (str): Basic authentication with username and password.
|
|
28
|
+
rsa (str): RSA authentication using private key.
|
|
29
|
+
"""
|
|
30
|
+
basic = "basic"
|
|
31
|
+
rsa = "rsa"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FTPFileInfo(BaseModel):
|
|
35
|
+
"""
|
|
36
|
+
Model representing metadata information about a file on the FTP server.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
size (Optional[int]): Size of the file in bytes.
|
|
40
|
+
permissions (Optional[str]): File permissions.
|
|
41
|
+
owner_id (Optional[int]): User ID of the file owner.
|
|
42
|
+
group_id (Optional[int]): Group ID of the file owner.
|
|
43
|
+
last_access_time (Optional[int]): Timestamp of the last file access.
|
|
44
|
+
last_modification_time (Optional[int]): Timestamp of the last file modification.
|
|
45
|
+
"""
|
|
46
|
+
size: Optional[int] = None
|
|
47
|
+
permissions: Optional[str] = None
|
|
48
|
+
owner_id: Optional[int] = None
|
|
49
|
+
group_id: Optional[int] = None
|
|
50
|
+
last_access_time: Optional[int] = None
|
|
51
|
+
last_modification_time: Optional[int] = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def last_modified_on(self):
|
|
55
|
+
"""
|
|
56
|
+
Get the last modification date as a `datetime` object, if available.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
datetime or None: Last modified date if `last_modification_time` is set, otherwise None.
|
|
60
|
+
"""
|
|
61
|
+
if self.last_modification_time:
|
|
62
|
+
return datetime.fromtimestamp(self.last_modification_time)
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
66
|
+
"""
|
|
67
|
+
Convert file info to a dictionary, including the `last_modified_on` property.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Dict[str, Any]: Dictionary of file metadata with added `last_modified_on` field.
|
|
71
|
+
"""
|
|
72
|
+
return {
|
|
73
|
+
**self.model_dump(),
|
|
74
|
+
"last_modified_on": self.last_modified_on,
|
|
75
|
+
}
|
|
76
|
+
|
|
File without changes
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import email
|
|
2
|
+
import imaplib
|
|
3
|
+
import time
|
|
4
|
+
from email.message import Message
|
|
5
|
+
from io import BytesIO
|
|
6
|
+
from typing import Iterable, Optional, List, Self
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
from charset_normalizer import detect
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
from bb_integrations_lib.secrets.credential_models import IMAPAuthOAuth, IMAPCredential
|
|
13
|
+
from bb_integrations_lib.shared.model import RawData
|
|
14
|
+
from bb_integrations_lib.util.utils import load_credentials
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ImapEmailIntegration:
|
|
18
|
+
"""Integration client to download attachments from an IMAP email server."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
credentials: IMAPCredential,
|
|
23
|
+
criteria: str,
|
|
24
|
+
retries: int = 3,
|
|
25
|
+
type: str = 'sales',
|
|
26
|
+
file_extension: str = '.csv',
|
|
27
|
+
):
|
|
28
|
+
self.credentials = credentials
|
|
29
|
+
self.criteria = criteria
|
|
30
|
+
self.retries = retries
|
|
31
|
+
self.type = type
|
|
32
|
+
self.file_extension = file_extension
|
|
33
|
+
|
|
34
|
+
def get_raw_data(self) -> Iterable[RawData]:
|
|
35
|
+
mail = IMAPClient(self.credentials)
|
|
36
|
+
|
|
37
|
+
mail.connect()
|
|
38
|
+
unseen_message_idxs = mail.search(self.criteria)
|
|
39
|
+
logger.info(f"📬 Found {len(unseen_message_idxs)} new emails in tank data inbox")
|
|
40
|
+
|
|
41
|
+
for idx in unseen_message_idxs:
|
|
42
|
+
retry_count = 0
|
|
43
|
+
while retry_count < self.retries:
|
|
44
|
+
try:
|
|
45
|
+
message = mail.fetch(idx)
|
|
46
|
+
attachment = mail.get_attachment_from_message(message, extension=self.file_extension)
|
|
47
|
+
|
|
48
|
+
if not attachment:
|
|
49
|
+
logger.error(f"⚠️ No valid attachment found in email id: {idx}")
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
if attachment is None:
|
|
53
|
+
logger.error(f"⚠️ Could not process file from email id: {idx}")
|
|
54
|
+
break # No need to retry if processing failed
|
|
55
|
+
|
|
56
|
+
raw_data = RawData(
|
|
57
|
+
file_name=f"data_{idx}{self.file_extension}",
|
|
58
|
+
data=attachment,
|
|
59
|
+
)
|
|
60
|
+
yield raw_data
|
|
61
|
+
logger.info(f"✅ Successfully processed {self.file_extension} file from email id: {idx}")
|
|
62
|
+
break # Exit retry loop on success
|
|
63
|
+
|
|
64
|
+
except Exception as e:
|
|
65
|
+
retry_count += 1
|
|
66
|
+
logger.error(f"❌ Error reading data from email (Attempt {retry_count}/{self.retries}): {e}")
|
|
67
|
+
time.sleep(5)
|
|
68
|
+
if retry_count == self.retries:
|
|
69
|
+
mail.mark_unseen(idx)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class IMAPClient:
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
credentials: IMAPCredential,
|
|
76
|
+
mailbox: str = "INBOX",
|
|
77
|
+
dry_run: bool = False,
|
|
78
|
+
):
|
|
79
|
+
self.credentials = credentials
|
|
80
|
+
self.mailbox = mailbox
|
|
81
|
+
self.mail = None
|
|
82
|
+
self.dry_run = dry_run
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_credential_file(cls, credential_file_name: str | None = None) -> Self:
|
|
86
|
+
credential_file_name = credential_file_name or "ftp.credentials"
|
|
87
|
+
credentials = load_credentials(credential_file_name)
|
|
88
|
+
return cls(credentials)
|
|
89
|
+
|
|
90
|
+
def test_imap(self, auth_string):
|
|
91
|
+
"""Authenticate with Gmail IMAP using XOAUTH2 in dry_run mode."""
|
|
92
|
+
try:
|
|
93
|
+
imap_conn = imaplib.IMAP4_SSL('imap.gmail.com')
|
|
94
|
+
imap_conn.debug = 4
|
|
95
|
+
imap_conn.authenticate('XOAUTH2', lambda x: auth_string)
|
|
96
|
+
imap_conn.select('INBOX')
|
|
97
|
+
logger.success("✅ IMAP Authentication Successful! (Dry Run Mode)")
|
|
98
|
+
return True
|
|
99
|
+
except imaplib.IMAP4.error as e:
|
|
100
|
+
logger.error(f"❌ IMAP Authentication Failed (Dry Run Mode): {e}")
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
def generate_oauth2_string(self, username, access_token):
|
|
104
|
+
"""Generates a properly formatted OAuth2 authentication string."""
|
|
105
|
+
auth_string = f"user={username}\x01auth=Bearer {access_token}\x01\x01"
|
|
106
|
+
return auth_string.encode("utf-8") # MUST return UTF-8 encoded bytes
|
|
107
|
+
|
|
108
|
+
def call_refresh_token(self, client_id, client_secret, refresh_token):
|
|
109
|
+
"""Fetch a new access token using the refresh token."""
|
|
110
|
+
url = "https://oauth2.googleapis.com/token"
|
|
111
|
+
payload = {
|
|
112
|
+
"client_id": client_id,
|
|
113
|
+
"client_secret": client_secret,
|
|
114
|
+
"refresh_token": refresh_token,
|
|
115
|
+
"grant_type": "refresh_token"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
119
|
+
|
|
120
|
+
response = requests.post(url, data=payload, headers=headers)
|
|
121
|
+
|
|
122
|
+
if response.status_code == 200:
|
|
123
|
+
return response.json()
|
|
124
|
+
else:
|
|
125
|
+
print("❌ Failed to refresh token:", response.text)
|
|
126
|
+
raise Exception(f"Failed to refresh token: {response.text}")
|
|
127
|
+
|
|
128
|
+
def refresh_authorization(self, client_id, client_secret, refresh_token):
|
|
129
|
+
"""Refresh OAuth access token."""
|
|
130
|
+
response = self.call_refresh_token(client_id, client_secret, refresh_token)
|
|
131
|
+
if "access_token" in response:
|
|
132
|
+
return response["access_token"], response["expires_in"]
|
|
133
|
+
else:
|
|
134
|
+
raise Exception("❌ Failed to get access token: Invalid response")
|
|
135
|
+
|
|
136
|
+
def connect(self):
|
|
137
|
+
if self.mail:
|
|
138
|
+
return self.mail
|
|
139
|
+
self.mail = imaplib.IMAP4_SSL(self.credentials.host, self.credentials.port)
|
|
140
|
+
self.mail.debug = 4
|
|
141
|
+
auth = self.credentials.auth
|
|
142
|
+
if isinstance(auth, IMAPAuthOAuth):
|
|
143
|
+
try:
|
|
144
|
+
access_token, expires_in = self.refresh_authorization(
|
|
145
|
+
client_id=auth.client_id,
|
|
146
|
+
client_secret=auth.client_secret,
|
|
147
|
+
refresh_token=auth.refresh_token
|
|
148
|
+
)
|
|
149
|
+
auth_string = self.generate_oauth2_string(self.credentials.email_address, access_token)
|
|
150
|
+
if self.dry_run:
|
|
151
|
+
logger.info("Dry run mode: testing IMAP Authentication only.")
|
|
152
|
+
self.test_imap(auth_string)
|
|
153
|
+
return None
|
|
154
|
+
self.mail.authenticate("XOAUTH2", lambda x: auth_string)
|
|
155
|
+
except imaplib.IMAP4.error as e:
|
|
156
|
+
logger.error(f"Gmail authentication failed: {e}")
|
|
157
|
+
raise Exception("Invalid Gmail OAuth authentication. Check your credentials and refresh token.")
|
|
158
|
+
else:
|
|
159
|
+
self.mail.login(self.credentials.email_address, self.credentials.auth.password)
|
|
160
|
+
self.mail.select(self.mailbox)
|
|
161
|
+
return self.mail
|
|
162
|
+
|
|
163
|
+
def change_mailbox(self, mailbox: str):
|
|
164
|
+
"""Change the mailbox (folder) in IMAP."""
|
|
165
|
+
self.connect().select(mailbox)
|
|
166
|
+
|
|
167
|
+
def search(self, criteria: str) -> List[int]:
|
|
168
|
+
"""Search for emails based on criteria (e.g., '(UNSEEN)')."""
|
|
169
|
+
status, message_indexes = self.connect().search(None, criteria)
|
|
170
|
+
message_indexes = [int(idx) for idx in next(iter(message_indexes), "").split()]
|
|
171
|
+
return message_indexes
|
|
172
|
+
|
|
173
|
+
def fetch(self, message_index: int) -> Optional[Message]:
|
|
174
|
+
"""Fetch an email by its index."""
|
|
175
|
+
status, message = self.connect().fetch(str(message_index), "(RFC822)")
|
|
176
|
+
try:
|
|
177
|
+
if message and message[0]:
|
|
178
|
+
raw_email_string = message[0][1].decode("utf-8")
|
|
179
|
+
return email.message_from_string(raw_email_string)
|
|
180
|
+
except (IndexError, AttributeError) as e:
|
|
181
|
+
logger.error(f"Error fetching email {message_index}: {e}")
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
def mark_unseen(self, message_index: int):
|
|
185
|
+
"""Mark an email as unseen (unread)."""
|
|
186
|
+
try:
|
|
187
|
+
self.connect().store(str(message_index), "-FLAGS", "\\Seen")
|
|
188
|
+
except Exception as e:
|
|
189
|
+
logger.error(f"Error marking email {message_index} as unseen: {e}")
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def get_attachment_from_message(message: Message, extension: str, return_rawdata: bool = False) -> Optional[bytes | str | RawData]:
|
|
193
|
+
"""Extracts an email attachment and handles unknown encoding.
|
|
194
|
+
|
|
195
|
+
- Returns **decoded text** for text-based files (CSV, TXT, JSON, etc.).
|
|
196
|
+
- Returns **raw binary data** for non-text files (PDF, XLSX, ZIP, etc.).
|
|
197
|
+
"""
|
|
198
|
+
for part in message.walk():
|
|
199
|
+
if part.get_content_maintype() == 'multipart':
|
|
200
|
+
continue
|
|
201
|
+
if part.get("Content-Disposition") is None and part.get_filename() is None:
|
|
202
|
+
continue
|
|
203
|
+
if part.get_filename() and part.get_filename().lower().endswith(extension.lower()):
|
|
204
|
+
try:
|
|
205
|
+
raw_bytes = part.get_payload(decode=True)
|
|
206
|
+
if extension.lower() in [".pdf", ".xlsx", ".xls", ".zip", ".png", ".jpg", ".jpeg", ".gif", ".docx",
|
|
207
|
+
".pptx"]:
|
|
208
|
+
logger.info(f"🗂 Binary file detected ({part.get_filename()}), returning raw bytes")
|
|
209
|
+
return raw_bytes
|
|
210
|
+
detected = detect(raw_bytes)
|
|
211
|
+
encoding = detected["encoding"]
|
|
212
|
+
if not encoding:
|
|
213
|
+
logger.warning(
|
|
214
|
+
f"⚠️ Encoding could not be detected for {part.get_filename()}, defaulting to latin-1")
|
|
215
|
+
encoding = "latin-1"
|
|
216
|
+
logger.info(f"📄 Detected encoding: {encoding} for {part.get_filename()}")
|
|
217
|
+
if return_rawdata:
|
|
218
|
+
return RawData(file_name=part.get_filename(), data=BytesIO(raw_bytes))
|
|
219
|
+
else:
|
|
220
|
+
return raw_bytes.decode(encoding, errors="replace") # ✅ Decode with replacement for errors
|
|
221
|
+
except UnicodeDecodeError as e:
|
|
222
|
+
logger.error(f"❌ Failed to decode {part.get_filename()} as text: {e}")
|
|
223
|
+
if return_rawdata:
|
|
224
|
+
return RawData(file_name=part.get_filename(), data=BytesIO(raw_bytes))
|
|
225
|
+
else:
|
|
226
|
+
return raw_bytes # ✅ Return raw bytes if decoding fails
|
|
227
|
+
return None
|
|
228
|
+
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from typing import Dict, List, Any, Optional
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from urllib.parse import quote_plus
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
from sqlalchemy import create_engine, text, MetaData
|
|
7
|
+
from sqlalchemy.engine import Engine, Connection, Result
|
|
8
|
+
from sqlalchemy.orm import sessionmaker, Session
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SQLServerClient:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
server: str,
|
|
15
|
+
database: str,
|
|
16
|
+
username: Optional[str] = None,
|
|
17
|
+
password: Optional[str] = None,
|
|
18
|
+
driver: str = "ODBC Driver 17 for SQL Server",
|
|
19
|
+
trusted_connection: bool = False,
|
|
20
|
+
echo: bool = False,
|
|
21
|
+
mars_connection: bool = True
|
|
22
|
+
):
|
|
23
|
+
|
|
24
|
+
self.server = server
|
|
25
|
+
self.database = database
|
|
26
|
+
self.username = username
|
|
27
|
+
self.password = password
|
|
28
|
+
self.driver = driver
|
|
29
|
+
self.trusted_connection = trusted_connection
|
|
30
|
+
self.echo = echo
|
|
31
|
+
self.mars_connection = mars_connection
|
|
32
|
+
self.engine = self._create_engine()
|
|
33
|
+
self.metadata = MetaData()
|
|
34
|
+
self.Session = sessionmaker(bind=self.engine)
|
|
35
|
+
|
|
36
|
+
def _create_engine(self) -> Engine:
|
|
37
|
+
connection_string = self._build_connection_string()
|
|
38
|
+
return create_engine(connection_string, echo=self.echo)
|
|
39
|
+
|
|
40
|
+
def _build_connection_string(self) -> str:
|
|
41
|
+
params = {
|
|
42
|
+
"driver": self.driver,
|
|
43
|
+
"server": self.server,
|
|
44
|
+
"database": self.database,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if self.trusted_connection:
|
|
48
|
+
params["trusted_connection"] = "yes"
|
|
49
|
+
else:
|
|
50
|
+
params["uid"] = self.username
|
|
51
|
+
params["pwd"] = self.password
|
|
52
|
+
if self.mars_connection:
|
|
53
|
+
params["Mars_Connection"] = "yes"
|
|
54
|
+
params["TrustServerCertificate"] = "yes"
|
|
55
|
+
params["MultipleActiveResultSets"] = "True" if self.mars_connection else "False"
|
|
56
|
+
params["Connection Timeout"] = "30"
|
|
57
|
+
params["Command Timeout"] = "30"
|
|
58
|
+
connection_string_parts = []
|
|
59
|
+
for key, value in params.items():
|
|
60
|
+
if value is not None:
|
|
61
|
+
connection_string_parts.append(f"{key}={quote_plus(str(value))}")
|
|
62
|
+
|
|
63
|
+
connection_string = ";".join(connection_string_parts)
|
|
64
|
+
return f"mssql+pyodbc:///?odbc_connect={quote_plus(connection_string)}"
|
|
65
|
+
|
|
66
|
+
@contextmanager
|
|
67
|
+
def get_connection(self) -> Connection:
|
|
68
|
+
connection = self.engine.connect()
|
|
69
|
+
try:
|
|
70
|
+
yield connection
|
|
71
|
+
finally:
|
|
72
|
+
connection.close()
|
|
73
|
+
|
|
74
|
+
@contextmanager
|
|
75
|
+
def get_session(self) -> Session:
|
|
76
|
+
session = self.Session()
|
|
77
|
+
try:
|
|
78
|
+
yield session
|
|
79
|
+
session.commit()
|
|
80
|
+
except:
|
|
81
|
+
session.rollback()
|
|
82
|
+
raise
|
|
83
|
+
finally:
|
|
84
|
+
session.close()
|
|
85
|
+
|
|
86
|
+
def execute_query(self, query: str, params: Optional[Dict[str, Any]] = None) -> Result:
|
|
87
|
+
with self.get_session() as session:
|
|
88
|
+
result = session.execute(text(query), params or {})
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
def get_all(self, query: str, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
|
|
92
|
+
"""
|
|
93
|
+
:rtype: object
|
|
94
|
+
"""
|
|
95
|
+
with self.get_session() as session:
|
|
96
|
+
result = session.execute(text(query), params or {})
|
|
97
|
+
rows = result.fetchall()
|
|
98
|
+
return [dict(row._mapping) for row in rows]
|
|
99
|
+
|
|
100
|
+
def get_mappings(self, query: str, params: Optional[Dict[str, Any]] = None, source_system: Optional[str] = None,
|
|
101
|
+
mapping_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
102
|
+
if source_system is not None:
|
|
103
|
+
logger.warning("Source System not implemented")
|
|
104
|
+
if mapping_type is not None:
|
|
105
|
+
logger.warning("Mapping Type not implemented")
|
|
106
|
+
return self.get_all(query, params or {})
|