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.
Files changed (217) hide show
  1. bb_integrations_lib/__init__.py +0 -0
  2. bb_integrations_lib/converters/__init__.py +0 -0
  3. bb_integrations_lib/gravitate/__init__.py +0 -0
  4. bb_integrations_lib/gravitate/base_api.py +20 -0
  5. bb_integrations_lib/gravitate/model.py +29 -0
  6. bb_integrations_lib/gravitate/pe_api.py +122 -0
  7. bb_integrations_lib/gravitate/rita_api.py +552 -0
  8. bb_integrations_lib/gravitate/sd_api.py +572 -0
  9. bb_integrations_lib/gravitate/testing/TTE/sd/models.py +1398 -0
  10. bb_integrations_lib/gravitate/testing/TTE/sd/tests/test_models.py +2987 -0
  11. bb_integrations_lib/gravitate/testing/__init__.py +0 -0
  12. bb_integrations_lib/gravitate/testing/builder.py +55 -0
  13. bb_integrations_lib/gravitate/testing/openapi.py +70 -0
  14. bb_integrations_lib/gravitate/testing/util.py +274 -0
  15. bb_integrations_lib/mappers/__init__.py +0 -0
  16. bb_integrations_lib/mappers/prices/__init__.py +0 -0
  17. bb_integrations_lib/mappers/prices/model.py +106 -0
  18. bb_integrations_lib/mappers/prices/price_mapper.py +127 -0
  19. bb_integrations_lib/mappers/prices/protocol.py +20 -0
  20. bb_integrations_lib/mappers/prices/util.py +61 -0
  21. bb_integrations_lib/mappers/rita_mapper.py +523 -0
  22. bb_integrations_lib/models/__init__.py +0 -0
  23. bb_integrations_lib/models/dtn_supplier_invoice.py +487 -0
  24. bb_integrations_lib/models/enums.py +28 -0
  25. bb_integrations_lib/models/pipeline_structs.py +76 -0
  26. bb_integrations_lib/models/probe/probe_event.py +20 -0
  27. bb_integrations_lib/models/probe/request_data.py +431 -0
  28. bb_integrations_lib/models/probe/resume_token.py +7 -0
  29. bb_integrations_lib/models/rita/audit.py +113 -0
  30. bb_integrations_lib/models/rita/auth.py +30 -0
  31. bb_integrations_lib/models/rita/bucket.py +17 -0
  32. bb_integrations_lib/models/rita/config.py +188 -0
  33. bb_integrations_lib/models/rita/constants.py +19 -0
  34. bb_integrations_lib/models/rita/crossroads_entities.py +293 -0
  35. bb_integrations_lib/models/rita/crossroads_mapping.py +428 -0
  36. bb_integrations_lib/models/rita/crossroads_monitoring.py +78 -0
  37. bb_integrations_lib/models/rita/crossroads_network.py +41 -0
  38. bb_integrations_lib/models/rita/crossroads_rules.py +80 -0
  39. bb_integrations_lib/models/rita/email.py +39 -0
  40. bb_integrations_lib/models/rita/issue.py +63 -0
  41. bb_integrations_lib/models/rita/mapping.py +227 -0
  42. bb_integrations_lib/models/rita/probe.py +58 -0
  43. bb_integrations_lib/models/rita/reference_data.py +110 -0
  44. bb_integrations_lib/models/rita/source_system.py +9 -0
  45. bb_integrations_lib/models/rita/workers.py +76 -0
  46. bb_integrations_lib/models/sd/bols_and_drops.py +241 -0
  47. bb_integrations_lib/models/sd/get_order.py +301 -0
  48. bb_integrations_lib/models/sd/orders.py +18 -0
  49. bb_integrations_lib/models/sd_api.py +115 -0
  50. bb_integrations_lib/pipelines/__init__.py +0 -0
  51. bb_integrations_lib/pipelines/parsers/__init__.py +0 -0
  52. bb_integrations_lib/pipelines/parsers/distribution_report/__init__.py +0 -0
  53. bb_integrations_lib/pipelines/parsers/distribution_report/order_by_site_product_parser.py +50 -0
  54. bb_integrations_lib/pipelines/parsers/distribution_report/tank_configs_parser.py +47 -0
  55. bb_integrations_lib/pipelines/parsers/dtn/__init__.py +0 -0
  56. bb_integrations_lib/pipelines/parsers/dtn/dtn_price_parser.py +102 -0
  57. bb_integrations_lib/pipelines/parsers/dtn/model.py +79 -0
  58. bb_integrations_lib/pipelines/parsers/price_engine/__init__.py +0 -0
  59. bb_integrations_lib/pipelines/parsers/price_engine/parse_accessorials_prices_parser.py +67 -0
  60. bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/__init__.py +0 -0
  61. bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/price_merge_parser.py +111 -0
  62. bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/price_sync_parser.py +107 -0
  63. bb_integrations_lib/pipelines/parsers/price_engine/price_file_upload/shared.py +81 -0
  64. bb_integrations_lib/pipelines/parsers/tank_reading_parser.py +155 -0
  65. bb_integrations_lib/pipelines/parsers/tank_sales_parser.py +144 -0
  66. bb_integrations_lib/pipelines/shared/__init__.py +0 -0
  67. bb_integrations_lib/pipelines/shared/allocation_matching.py +227 -0
  68. bb_integrations_lib/pipelines/shared/bol_allocation.py +2793 -0
  69. bb_integrations_lib/pipelines/steps/__init__.py +0 -0
  70. bb_integrations_lib/pipelines/steps/create_accessorials_step.py +80 -0
  71. bb_integrations_lib/pipelines/steps/distribution_report/__init__.py +0 -0
  72. bb_integrations_lib/pipelines/steps/distribution_report/distribution_report_datafram_to_raw_data.py +33 -0
  73. bb_integrations_lib/pipelines/steps/distribution_report/get_model_history_step.py +50 -0
  74. bb_integrations_lib/pipelines/steps/distribution_report/get_order_by_site_product_step.py +62 -0
  75. bb_integrations_lib/pipelines/steps/distribution_report/get_tank_configs_step.py +40 -0
  76. bb_integrations_lib/pipelines/steps/distribution_report/join_distribution_order_dos_step.py +85 -0
  77. bb_integrations_lib/pipelines/steps/distribution_report/upload_distribution_report_datafram_to_big_query.py +47 -0
  78. bb_integrations_lib/pipelines/steps/echo_step.py +14 -0
  79. bb_integrations_lib/pipelines/steps/export_dataframe_to_rawdata_step.py +28 -0
  80. bb_integrations_lib/pipelines/steps/exporting/__init__.py +0 -0
  81. bb_integrations_lib/pipelines/steps/exporting/bbd_export_payroll_file_step.py +107 -0
  82. bb_integrations_lib/pipelines/steps/exporting/bbd_export_readings_step.py +236 -0
  83. bb_integrations_lib/pipelines/steps/exporting/cargas_wholesale_bundle_upload_step.py +33 -0
  84. bb_integrations_lib/pipelines/steps/exporting/dataframe_flat_file_export.py +29 -0
  85. bb_integrations_lib/pipelines/steps/exporting/gcs_bucket_export_file_step.py +34 -0
  86. bb_integrations_lib/pipelines/steps/exporting/keyvu_export_step.py +356 -0
  87. bb_integrations_lib/pipelines/steps/exporting/pe_price_export_step.py +238 -0
  88. bb_integrations_lib/pipelines/steps/exporting/platform_science_order_sync_step.py +500 -0
  89. bb_integrations_lib/pipelines/steps/exporting/save_rawdata_to_disk.py +15 -0
  90. bb_integrations_lib/pipelines/steps/exporting/sftp_export_file_step.py +60 -0
  91. bb_integrations_lib/pipelines/steps/exporting/sftp_export_many_files_step.py +23 -0
  92. bb_integrations_lib/pipelines/steps/exporting/update_exported_orders_table_step.py +64 -0
  93. bb_integrations_lib/pipelines/steps/filter_step.py +22 -0
  94. bb_integrations_lib/pipelines/steps/get_latest_sync_date.py +34 -0
  95. bb_integrations_lib/pipelines/steps/importing/bbd_import_payroll_step.py +30 -0
  96. bb_integrations_lib/pipelines/steps/importing/get_order_numbers_to_export_step.py +138 -0
  97. bb_integrations_lib/pipelines/steps/importing/load_file_to_dataframe_step.py +46 -0
  98. bb_integrations_lib/pipelines/steps/importing/load_imap_attachment_step.py +172 -0
  99. bb_integrations_lib/pipelines/steps/importing/pe_bulk_sync_price_structure_step.py +68 -0
  100. bb_integrations_lib/pipelines/steps/importing/pe_price_merge_step.py +86 -0
  101. bb_integrations_lib/pipelines/steps/importing/sftp_file_config_step.py +124 -0
  102. bb_integrations_lib/pipelines/steps/importing/test_exact_file_match.py +57 -0
  103. bb_integrations_lib/pipelines/steps/null_step.py +15 -0
  104. bb_integrations_lib/pipelines/steps/pe_integration_job_step.py +32 -0
  105. bb_integrations_lib/pipelines/steps/processing/__init__.py +0 -0
  106. bb_integrations_lib/pipelines/steps/processing/archive_gcs_step.py +76 -0
  107. bb_integrations_lib/pipelines/steps/processing/archive_sftp_step.py +48 -0
  108. bb_integrations_lib/pipelines/steps/processing/bbd_format_tank_readings_step.py +492 -0
  109. bb_integrations_lib/pipelines/steps/processing/bbd_upload_prices_step.py +54 -0
  110. bb_integrations_lib/pipelines/steps/processing/bbd_upload_tank_sales_step.py +124 -0
  111. bb_integrations_lib/pipelines/steps/processing/bbd_upload_tankreading_step.py +80 -0
  112. bb_integrations_lib/pipelines/steps/processing/convert_bbd_order_to_cargas_step.py +226 -0
  113. bb_integrations_lib/pipelines/steps/processing/delete_sftp_step.py +33 -0
  114. bb_integrations_lib/pipelines/steps/processing/dtn/__init__.py +2 -0
  115. bb_integrations_lib/pipelines/steps/processing/dtn/convert_dtn_invoice_to_sd_model.py +145 -0
  116. bb_integrations_lib/pipelines/steps/processing/dtn/parse_dtn_invoice_step.py +38 -0
  117. bb_integrations_lib/pipelines/steps/processing/file_config_parser_step.py +720 -0
  118. bb_integrations_lib/pipelines/steps/processing/file_config_parser_step_v2.py +418 -0
  119. bb_integrations_lib/pipelines/steps/processing/get_sd_price_price_request.py +105 -0
  120. bb_integrations_lib/pipelines/steps/processing/keyvu_upload_deliveryplan_step.py +39 -0
  121. bb_integrations_lib/pipelines/steps/processing/mark_orders_exported_in_bbd_step.py +185 -0
  122. bb_integrations_lib/pipelines/steps/processing/pe_price_rows_processing_step.py +174 -0
  123. bb_integrations_lib/pipelines/steps/processing/send_process_report_step.py +47 -0
  124. bb_integrations_lib/pipelines/steps/processing/sftp_renamer_step.py +61 -0
  125. bb_integrations_lib/pipelines/steps/processing/tank_reading_touchup_steps.py +75 -0
  126. bb_integrations_lib/pipelines/steps/processing/upload_supplier_invoice_step.py +16 -0
  127. bb_integrations_lib/pipelines/steps/send_attached_in_rita_email_step.py +44 -0
  128. bb_integrations_lib/pipelines/steps/send_rita_email_step.py +34 -0
  129. bb_integrations_lib/pipelines/steps/sleep_step.py +24 -0
  130. bb_integrations_lib/pipelines/wrappers/__init__.py +0 -0
  131. bb_integrations_lib/pipelines/wrappers/accessorials_transformation.py +104 -0
  132. bb_integrations_lib/pipelines/wrappers/distribution_report.py +191 -0
  133. bb_integrations_lib/pipelines/wrappers/export_tank_readings.py +237 -0
  134. bb_integrations_lib/pipelines/wrappers/import_tank_readings.py +192 -0
  135. bb_integrations_lib/pipelines/wrappers/wrapper.py +81 -0
  136. bb_integrations_lib/protocols/__init__.py +0 -0
  137. bb_integrations_lib/protocols/flat_file.py +210 -0
  138. bb_integrations_lib/protocols/gravitate_client.py +104 -0
  139. bb_integrations_lib/protocols/pipelines.py +697 -0
  140. bb_integrations_lib/provider/__init__.py +0 -0
  141. bb_integrations_lib/provider/api/__init__.py +0 -0
  142. bb_integrations_lib/provider/api/cargas/__init__.py +0 -0
  143. bb_integrations_lib/provider/api/cargas/client.py +43 -0
  144. bb_integrations_lib/provider/api/cargas/model.py +49 -0
  145. bb_integrations_lib/provider/api/cargas/protocol.py +23 -0
  146. bb_integrations_lib/provider/api/dtn/__init__.py +0 -0
  147. bb_integrations_lib/provider/api/dtn/client.py +128 -0
  148. bb_integrations_lib/provider/api/dtn/protocol.py +9 -0
  149. bb_integrations_lib/provider/api/keyvu/__init__.py +0 -0
  150. bb_integrations_lib/provider/api/keyvu/client.py +30 -0
  151. bb_integrations_lib/provider/api/keyvu/model.py +149 -0
  152. bb_integrations_lib/provider/api/macropoint/__init__.py +0 -0
  153. bb_integrations_lib/provider/api/macropoint/client.py +28 -0
  154. bb_integrations_lib/provider/api/macropoint/model.py +40 -0
  155. bb_integrations_lib/provider/api/pc_miler/__init__.py +0 -0
  156. bb_integrations_lib/provider/api/pc_miler/client.py +130 -0
  157. bb_integrations_lib/provider/api/pc_miler/model.py +6 -0
  158. bb_integrations_lib/provider/api/pc_miler/web_services_apis.py +131 -0
  159. bb_integrations_lib/provider/api/platform_science/__init__.py +0 -0
  160. bb_integrations_lib/provider/api/platform_science/client.py +147 -0
  161. bb_integrations_lib/provider/api/platform_science/model.py +82 -0
  162. bb_integrations_lib/provider/api/quicktrip/__init__.py +0 -0
  163. bb_integrations_lib/provider/api/quicktrip/client.py +52 -0
  164. bb_integrations_lib/provider/api/telapoint/__init__.py +0 -0
  165. bb_integrations_lib/provider/api/telapoint/client.py +68 -0
  166. bb_integrations_lib/provider/api/telapoint/model.py +178 -0
  167. bb_integrations_lib/provider/api/warren_rogers/__init__.py +0 -0
  168. bb_integrations_lib/provider/api/warren_rogers/client.py +207 -0
  169. bb_integrations_lib/provider/aws/__init__.py +0 -0
  170. bb_integrations_lib/provider/aws/s3/__init__.py +0 -0
  171. bb_integrations_lib/provider/aws/s3/client.py +126 -0
  172. bb_integrations_lib/provider/ftp/__init__.py +0 -0
  173. bb_integrations_lib/provider/ftp/client.py +140 -0
  174. bb_integrations_lib/provider/ftp/interface.py +273 -0
  175. bb_integrations_lib/provider/ftp/model.py +76 -0
  176. bb_integrations_lib/provider/imap/__init__.py +0 -0
  177. bb_integrations_lib/provider/imap/client.py +228 -0
  178. bb_integrations_lib/provider/imap/model.py +3 -0
  179. bb_integrations_lib/provider/sqlserver/__init__.py +0 -0
  180. bb_integrations_lib/provider/sqlserver/client.py +106 -0
  181. bb_integrations_lib/secrets/__init__.py +4 -0
  182. bb_integrations_lib/secrets/adapters.py +98 -0
  183. bb_integrations_lib/secrets/credential_models.py +222 -0
  184. bb_integrations_lib/secrets/factory.py +85 -0
  185. bb_integrations_lib/secrets/providers.py +160 -0
  186. bb_integrations_lib/shared/__init__.py +0 -0
  187. bb_integrations_lib/shared/exceptions.py +25 -0
  188. bb_integrations_lib/shared/model.py +1039 -0
  189. bb_integrations_lib/shared/shared_enums.py +510 -0
  190. bb_integrations_lib/storage/README.md +236 -0
  191. bb_integrations_lib/storage/__init__.py +0 -0
  192. bb_integrations_lib/storage/aws/__init__.py +0 -0
  193. bb_integrations_lib/storage/aws/s3.py +8 -0
  194. bb_integrations_lib/storage/defaults.py +72 -0
  195. bb_integrations_lib/storage/gcs/__init__.py +0 -0
  196. bb_integrations_lib/storage/gcs/client.py +8 -0
  197. bb_integrations_lib/storage/gcsmanager/__init__.py +0 -0
  198. bb_integrations_lib/storage/gcsmanager/client.py +8 -0
  199. bb_integrations_lib/storage/setup.py +29 -0
  200. bb_integrations_lib/util/__init__.py +0 -0
  201. bb_integrations_lib/util/cache/__init__.py +0 -0
  202. bb_integrations_lib/util/cache/custom_ttl_cache.py +75 -0
  203. bb_integrations_lib/util/cache/protocol.py +9 -0
  204. bb_integrations_lib/util/config/__init__.py +0 -0
  205. bb_integrations_lib/util/config/manager.py +391 -0
  206. bb_integrations_lib/util/config/model.py +41 -0
  207. bb_integrations_lib/util/exception_logger/__init__.py +0 -0
  208. bb_integrations_lib/util/exception_logger/exception_logger.py +146 -0
  209. bb_integrations_lib/util/exception_logger/test.py +114 -0
  210. bb_integrations_lib/util/utils.py +364 -0
  211. bb_integrations_lib/workers/__init__.py +0 -0
  212. bb_integrations_lib/workers/groups.py +13 -0
  213. bb_integrations_lib/workers/rpc_worker.py +50 -0
  214. bb_integrations_lib/workers/topics.py +20 -0
  215. bb_integrations_library-3.0.11.dist-info/METADATA +59 -0
  216. bb_integrations_library-3.0.11.dist-info/RECORD +217 -0
  217. 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
+
@@ -0,0 +1,3 @@
1
+
2
+
3
+
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 {})