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,207 @@
1
+ import asyncio
2
+ from datetime import datetime, timedelta
3
+ from pprint import pprint
4
+ from typing import Optional
5
+
6
+ import httpx
7
+ from loguru import logger
8
+
9
+ from bb_integrations_lib.protocols.flat_file import TankReading, TankMonitorType
10
+
11
+
12
+ class WarrenRogersClient(httpx.AsyncClient):
13
+ def __init__(
14
+ self,
15
+ client_id: str,
16
+ client_secret: str,
17
+ api_key: str,
18
+ company_id: str,
19
+ token_endpoint: str,
20
+ base_url: str,
21
+ timeout: float = 180.0
22
+ ):
23
+ super().__init__(base_url=base_url, timeout=timeout)
24
+ self.client_id = client_id
25
+ self.client_secret = client_secret
26
+ self.base_url = base_url.rstrip("/")
27
+ self.api_key = api_key
28
+ self.company_id = company_id
29
+ self.token_endpoint = token_endpoint
30
+ self.page_size = 100
31
+ self.oauth_token = None
32
+ # Expect to refresh token 30s before actual expiry, in case of time desync between us and server
33
+ self.pre_expiry_refresh = timedelta(seconds=30)
34
+
35
+ def __repr__(self):
36
+ return "Warren Rogers API Client"
37
+
38
+ @property
39
+ async def access_token(self):
40
+ await self.ensure_token()
41
+ return self.oauth_token["access_token"]
42
+
43
+ async def _refresh_token(self):
44
+ now = datetime.now()
45
+ res = await self.post(self.token_endpoint,
46
+ auth=(self.client_id, self.client_secret),
47
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
48
+ params="grant_type=client_credentials")
49
+ res.raise_for_status()
50
+ self.oauth_token = res.json()
51
+ self.oauth_token["expires_at"] = now + timedelta(seconds=self.oauth_token["expires_in"])
52
+
53
+ def is_token_expired(self):
54
+ return datetime.now() > self.oauth_token["expires_at"] + self.pre_expiry_refresh
55
+
56
+ async def ensure_token(self):
57
+ # First run or expired token? Then refresh
58
+ if not self.oauth_token or self.is_token_expired():
59
+ await self._refresh_token()
60
+
61
+ def _build_url(self, endpoint: str, api_ver: str = "v1") -> str:
62
+ return f"api/{api_ver}/companies/{self.company_id}/{endpoint}"
63
+
64
+ async def _get(self, url: str, params=None):
65
+ headers = {
66
+ "Accept": "application/json",
67
+ "x-api-key": self.api_key,
68
+ "wra-auth": await self.access_token
69
+ }
70
+ return await self.get(url, headers=headers, params=params)
71
+
72
+ async def _paginated_get(self, url: str):
73
+ pages = []
74
+ page_no = 1
75
+
76
+ first_page = (await self._get(url)).json()
77
+ total_results = first_page["totalResults"]
78
+ pages.append(first_page)
79
+ entries_retrieved = first_page["size"]
80
+
81
+ while entries_retrieved < total_results:
82
+ page_no += 1
83
+ page = (await self._get(url, params={"page": page_no})).json()
84
+ pages.append(page)
85
+ entries_retrieved += page["size"]
86
+
87
+ return pages
88
+
89
+ async def _continuation_get(self, url: str, starting_token: Optional[str] = None) -> tuple[str, list[dict]]:
90
+ """
91
+ Get data from a paginated stream that uses continuation tokens, like the /deliveries endpoint.
92
+ If a starting continuation token is provided, it will be passed to the API; otherwise, the default API behavior
93
+ will be used. In /deliveries case, this is retrieving the last hour of data, for example.
94
+
95
+ :param url: The URL of the endpoint.
96
+ :param starting_token: An optional continuation token to resume the stream from.
97
+ :return: A tuple; item 1 is the continuation token from the API, and item 2 is the list of pages of the API
98
+ response.
99
+ """
100
+ pages = []
101
+ page_n = 1
102
+ starting = True
103
+ con_token = starting_token
104
+ while starting or con_token:
105
+ logger.debug(f"Getting page {page_n}, continuation token {con_token}")
106
+ resp = await self._get(url=url, params={"continuationToken": con_token} if con_token else {})
107
+ resp.raise_for_status()
108
+ data = resp.json()
109
+ con_token = data.get("continuationToken")
110
+ pages.append(resp.json())
111
+ if not data.get("truncated"):
112
+ break
113
+ starting = False
114
+ page_n += 1
115
+ return con_token, pages
116
+
117
+ async def get_inventory_levels(self) -> list[dict]:
118
+ """
119
+ Get "inventory levels" (tank readings) for all tanks. Does not parse any values, but deserializes the json to a
120
+ dict.
121
+ """
122
+ raw = await self._paginated_get(self._build_url("inventory-levels"))
123
+ # The data this endpoint provides is a list of pages. Each page has some metadata plus an "inventoryLevels"
124
+ # field, which has the actual locations and their ATGs and readings. Flatten to one big list of inventoryLevels.
125
+ return [x for xs in raw for x in xs["inventoryLevels"]]
126
+
127
+ async def get_inventory_levels_at_loc(self, location: str) -> list[dict]:
128
+ """Like get_inventory_levels, but for a specific location."""
129
+ raw = await self._get(self._build_url(f"locations/{location}/inventory-levels"))
130
+ return raw.json()
131
+
132
+ def _parse_tankreadings(self, location: dict) -> list[TankReading]:
133
+ """Parse inventory levels from the WR API and convert them to TankReadings."""
134
+ trs: list[TankReading] = []
135
+ # For each ATG at location
136
+ for atg_level in location["atgLevels"]:
137
+ # For each of the actual inventory readings on this ATG
138
+ ils = atg_level["tankInventoryLevels"]
139
+ for il in ils:
140
+ # Specific inventory level reading - i.e., tank
141
+ # Note that this does not take the units reported by the WR API into account - this appears to be
142
+ # gallons, so far.
143
+ trs.append(
144
+ TankReading(
145
+ date=il["readingDateTime"],
146
+ payload={},
147
+ store=location["locationUniqueId"],
148
+ tank=il["tankUniqueId"],
149
+ timezone=None, # Not needed, ISO date str has timezone
150
+ volume=il["atgGrossVolume"],
151
+ monitor_type=TankMonitorType.bbd
152
+ )
153
+ )
154
+ return trs
155
+
156
+ async def get_unmapped_tank_readings(self) -> list[TankReading]:
157
+ """
158
+ Get all tank readings available to the client, like get_inventory_levels, but parse the tank readings with
159
+ _parse_tankreadings, returning them as a flat list. Note that the store and tank identifiers are returned
160
+ straight from the API, unmapped.
161
+ """
162
+ raw = await self._paginated_get(self._build_url("inventory-levels"))
163
+ # The data this endpoint provides is a list of pages. Each page has some metadata plus an "inventoryLevels"
164
+ # field, which has the actual locations and their ATGs and readings. Flatten to one big list of inventoryLevels.
165
+ inventory_levels = [x for xs in raw for x in xs["inventoryLevels"]]
166
+ trs = []
167
+ # For each store
168
+ for location in inventory_levels:
169
+ trs.extend(self._parse_tankreadings(location))
170
+ return trs
171
+
172
+ async def get_unmapped_tank_readings_loc(self, location: str):
173
+ """Like get_unmapped_tank_readings, but for a specific location."""
174
+ raw = await self._get(self._build_url(f"locations/{location}/inventory-levels"))
175
+ return self._parse_tankreadings(raw.json())
176
+
177
+ async def get_deliveries(self, starting_token: Optional[str] = None) -> tuple[str, list[dict]]:
178
+ """
179
+ Get a list of locations and their deliveries. If starting_token is provided, resume the data stream from that
180
+ continuation token. May return duplicates / repeated data.
181
+
182
+ :param starting_token: An optional continuation token to resume the stream from. If not provided, gets the last
183
+ hour of deliveries.
184
+ :return: A tuple; item 1 is the final continuation token from the API (for resuming the stream in the future),
185
+ and item 2 is the list of locations and their deliveries.
186
+ """
187
+ next_ct, raw = await self._continuation_get(
188
+ self._build_url("deliveries", api_ver="v3"),
189
+ starting_token=starting_token)
190
+ # Flatten the locationDeliveries lists from each page into one list of dicts of locations and their deliveries.
191
+ return next_ct, [x for xs in raw for x in xs["locationDeliveries"]]
192
+
193
+
194
+ if __name__ == "__main__":
195
+ async def main():
196
+ client = WarrenRogersClient(
197
+ client_id="",
198
+ client_secret="",
199
+ api_key="",
200
+ company_id="LOVES_TRAVEL_STOPS_AND_COUNTRY_STORES",
201
+ token_endpoint="https://auth.api.wr-cloud.com/oauth2/token",
202
+ base_url="https://api.wr-cloud.com")
203
+
204
+ inventory_levels = await client.get_inventory_levels()
205
+ pprint(inventory_levels)
206
+
207
+ asyncio.run(main())
File without changes
File without changes
@@ -0,0 +1,126 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+ from typing import Iterable
4
+ from json import JSONDecodeError
5
+ import boto3
6
+ from bb_integrations_lib.shared.model import RawData
7
+ from loguru import logger
8
+
9
+
10
+ @dataclass
11
+ class FileData:
12
+ """
13
+ Data class representing a file and its contents.
14
+
15
+ Attributes:
16
+ file_name (str): The name of the file.
17
+ data (dict): The content of the file parsed as a dictionary.
18
+ """
19
+ file_name: str
20
+ data: dict
21
+
22
+
23
+ class S3Client:
24
+ """
25
+ Client for interacting with an AWS S3 bucket, providing methods for fetching,
26
+ archiving, and processing files.
27
+
28
+ Attributes:
29
+ bucket_name (str): Name of the AWS S3 bucket.
30
+ access_key_id (str): AWS access key ID for authentication.
31
+ secret_access_key (str): AWS secret access key for authentication.
32
+ archive_dir (str): Directory in the bucket where files are archived.
33
+ file_prefix (list[str]): List of file prefixes used for filtering files in the bucket.
34
+ files_per_chunk (int): Maximum number of files to process in a single iteration.
35
+ s3 (boto3.resource): Boto3 resource for S3 operations.
36
+ bucket (boto3.Bucket): Boto3 bucket object for interacting with the specified S3 bucket.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ aws_bucket_name: str,
43
+ aws_access_key_id: str,
44
+ aws_secret_access_key: str,
45
+ aws_archive_dir: str,
46
+ aws_file_prefix: list[str],
47
+ files_per_chunk: int = 500,
48
+ ):
49
+ """
50
+ Initialize the S3Client with AWS credentials and configuration.
51
+
52
+ Args:
53
+ aws_bucket_name (str): Name of the S3 bucket to interact with.
54
+ aws_access_key_id (str): AWS access key ID.
55
+ aws_secret_access_key (str): AWS secret access key.
56
+ aws_archive_dir (str): Directory in the S3 bucket for archiving files.
57
+ aws_file_prefix (list[str]): List of file prefixes for filtering files.
58
+ files_per_chunk (int, optional): Number of files to fetch per query. Defaults to 500.
59
+ """
60
+ self.bucket_name = aws_bucket_name
61
+ self.access_key_id = aws_access_key_id
62
+ self.secret_access_key = aws_secret_access_key
63
+ self.archive_dir = aws_archive_dir
64
+ self.file_prefix = aws_file_prefix
65
+ self.files_per_chunk = files_per_chunk
66
+
67
+ self.s3 = boto3.resource(
68
+ "s3",
69
+ aws_access_key_id=self.access_key_id,
70
+ aws_secret_access_key=self.secret_access_key,
71
+ )
72
+ self.bucket = self.s3.Bucket(name=self.bucket_name)
73
+
74
+ def get_raw_data(self) -> Iterable[RawData]:
75
+ """
76
+ Fetch raw data from the S3 bucket.
77
+
78
+ Iterates through files matching the specified prefixes, loads their contents,
79
+ and returns them as a collection of `RawData` objects. Malformed files are
80
+ archived and deleted from the bucket.
81
+
82
+ Returns:
83
+ Iterable[RawData]: An iterable containing raw data from the files.
84
+ """
85
+ ret = []
86
+ for file in self._files():
87
+ try:
88
+ ret.append(
89
+ RawData(file_name=file.key, data=(json.load(file.get()["Body"])))
90
+ )
91
+ except JSONDecodeError:
92
+ logger.error(f"Archiving bad file {file.key}")
93
+ copy_source = {"Bucket": self.bucket_name, "Key": file.key}
94
+ destination = f"{self.archive_dir}/{file.key}"
95
+ self.bucket.copy(copy_source, destination)
96
+ self.s3.Object(self.bucket_name, file.key).delete()
97
+ return ret
98
+
99
+ def archive_data(self, raw_data: RawData):
100
+ """
101
+ Archive a processed file in the designated archive directory of the S3 bucket.
102
+
103
+ Args:
104
+ raw_data (RawData): The raw data object representing the file to be archived.
105
+ """
106
+ copy_source = {"Bucket": self.bucket_name, "Key": raw_data.file_name}
107
+ destination = f"{self.archive_dir}/{raw_data.file_name}"
108
+ self.bucket.copy(copy_source, destination)
109
+ self.s3.Object(self.bucket_name, raw_data.file_name).delete()
110
+
111
+ def _files(self) -> Iterable:
112
+ """
113
+ Generate an iterable of file objects from the S3 bucket.
114
+
115
+ Queries the S3 bucket for files matching the configured prefixes, yielding
116
+ them in chunks defined by `files_per_chunk`.
117
+
118
+ Returns:
119
+ Iterable: An iterable of S3 file objects.
120
+ """
121
+ file_queries = [
122
+ self.bucket.objects.filter(Prefix=prefix) for prefix in self.file_prefix
123
+ ]
124
+ return (
125
+ obj for query in file_queries for obj in query.limit(self.files_per_chunk)
126
+ )
File without changes
@@ -0,0 +1,140 @@
1
+ from typing import Iterable, Callable
2
+
3
+ from bb_integrations_lib.provider.ftp.interface import FTPClient, SFTPClient, FTPClientInterface
4
+ from bb_integrations_lib.provider.ftp.model import FTPFileInfo, FTPType
5
+ from bb_integrations_lib.secrets.credential_models import FTPCredential
6
+ from bb_integrations_lib.shared.model import RawData, File
7
+ from bb_integrations_lib.util.utils import load_credentials
8
+
9
+
10
+ class FTPIntegrationClient:
11
+ """
12
+ A client for interacting with an FTP or SFTP server, offering methods for listing, uploading, downloading,
13
+ renaming, and deleting files.
14
+ Gracefully manages the FTP connection for you, handling timeouts and retries.
15
+
16
+ Note that most commands are simply sent to the server, not double-checked - for example, a delete may not report an
17
+ error even if the file doesn't exist.
18
+ """
19
+
20
+ def __init__(self, credentials: FTPCredential):
21
+ self.credentials = credentials
22
+ self.interface: FTPClientInterface
23
+ match self.credentials.ftp_type:
24
+ case FTPType.ftp:
25
+ self.interface = FTPClient(self.credentials)
26
+ case FTPType.ftps:
27
+ self.interface = FTPClient(self.credentials)
28
+ case FTPType.sftp:
29
+ self.interface = SFTPClient(self.credentials)
30
+ case FTPType.ftpes:
31
+ self.interface = FTPClient(self.credentials)
32
+ case _:
33
+ raise Exception(f"Unknown FTP credential type {self.credentials.ftp_type}, please implement an adapter")
34
+ self.cur_dir = "/"
35
+
36
+ def __enter__(self):
37
+ self.interface.connect()
38
+ return self
39
+
40
+ def __exit__(self, type, value, traceback):
41
+ self.interface.disconnect()
42
+
43
+ def list_files(self, directory: str) -> Iterable[str]:
44
+ """
45
+ List all files in the given directory.
46
+ :returns: An iterable of file names in the directory.
47
+ """
48
+ return self.interface.list_files(directory)
49
+
50
+ def rename_file(self, old_name: str, new_name: str) -> None:
51
+ """
52
+ Rename a single file.
53
+ :param old_name: The current name of the file.
54
+ :param new_name: The name to change to.
55
+ """
56
+ return self.rename_files([(old_name, new_name)])
57
+
58
+ def rename_files(self, files: Iterable[tuple[str, str]]) -> None:
59
+ """
60
+ Rename a batch of files.
61
+ :param files: An iterable of tuples, each containing the old/new names for each files like (old, new).
62
+ """
63
+ return self.interface.rename_files(files)
64
+
65
+ def delete_file(self, path: str) -> None:
66
+ """
67
+ Delete a single file.
68
+ :param path: The path to the file to delete.
69
+ """
70
+ return self.delete_files([path])
71
+
72
+ def delete_files(self, paths: Iterable[str]) -> None:
73
+ """
74
+ Delete a batch of files.
75
+ :param paths: The paths to files to delete.
76
+ """
77
+ return self.interface.delete_files(paths)
78
+
79
+ def upload_file(self, file: File, path: str) -> None:
80
+ """
81
+ Upload a single File to a given parent directory.
82
+ :param file: The file to upload. Needs at least ``file_name`` and data of a type that ``file.to_bytes()``
83
+ supports.
84
+ :param path: The parent directory to upload the file to.
85
+ """
86
+ self.upload_files([file], path)
87
+
88
+ def upload_files(self, files: Iterable[File], path: str) -> None:
89
+ """
90
+ Like ``upload_file``, but for a batch of files.
91
+ :param files: An iterable of File objects to upload. See docs on ``upload_file`` for how these are handled.
92
+ :param path: The parent directory to upload the files to.
93
+ """
94
+ self.interface.upload_files(files, path)
95
+
96
+ def download_file(self, path: str) -> RawData:
97
+ """
98
+ Download a single file into memory.
99
+ :param path: The remote path of the file to download.
100
+ :return: A RawData object representing the downloaded file. ``file_name`` and ``data`` attributes will be set.
101
+ ``data`` will be a BytesIO object pre-seeked to 0.
102
+ :raises FileNotFoundError: If the file doesn't exist.
103
+ """
104
+ try:
105
+ return next(iter(self.download_files([path])))
106
+ except StopIteration:
107
+ raise FileNotFoundError(f"Remote file {path} not found")
108
+
109
+ def download_files(self, paths: Iterable[str]) -> Iterable[RawData]:
110
+ """
111
+ Like ``download_file``, but for a batch of files. Not all downloads may succeed; if any fail they will not be
112
+ present in the result list. If none succeed, an empty list will be returned.
113
+ :param paths: The remote paths of the files to download.
114
+ :return:
115
+ """
116
+ return self.interface.download_files(paths)
117
+
118
+ def get_file_info(self, path: str) -> FTPFileInfo:
119
+ """
120
+ Get details about a single file.
121
+ :param path: The remote path of the file to get info for.
122
+ :return: A FTPFileInfo object with details about the file. If this is an FTP connection, only the ``size``
123
+ attribute is guaranteed. If this is an SFTP connection, all attributes but the permissions are guaranteed.
124
+ """
125
+ return self.interface.get_file_info(path)
126
+
127
+ def download_dir(self, directory: str, filt: Callable[[str], bool] = None) -> Iterable[RawData]:
128
+ """
129
+ Download files from an entire directory, optionally filtering with the ``filt`` callable.
130
+
131
+ :param directory: The directory to download.
132
+ :param filt: An optional filter callable. Should take one argument (the file name) and return a boolean
133
+ indicating whether to include the file..
134
+
135
+ :returns: An iterable of `RawData` objects representing the downloaded files.
136
+ """
137
+ file_list = self.list_files(directory)
138
+ if filt:
139
+ file_list = filter(filt, file_list)
140
+ return self.download_files(map(lambda file_name: f"{directory}/{file_name}", file_list))