sift-stack-py 0.3.2__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 (291) hide show
  1. google/__init__.py +1 -0
  2. google/api/__init__.py +0 -0
  3. google/api/annotations_pb2.py +27 -0
  4. google/api/annotations_pb2.pyi +29 -0
  5. google/api/annotations_pb2_grpc.py +4 -0
  6. google/api/annotations_pb2_grpc.pyi +30 -0
  7. google/api/field_behavior_pb2.py +30 -0
  8. google/api/field_behavior_pb2.pyi +175 -0
  9. google/api/field_behavior_pb2_grpc.py +4 -0
  10. google/api/field_behavior_pb2_grpc.pyi +30 -0
  11. google/api/http_pb2.py +31 -0
  12. google/api/http_pb2.pyi +433 -0
  13. google/api/http_pb2_grpc.py +4 -0
  14. google/api/http_pb2_grpc.pyi +30 -0
  15. protoc_gen_openapiv2/__init__.py +0 -0
  16. protoc_gen_openapiv2/options/__init__.py +0 -0
  17. protoc_gen_openapiv2/options/annotations_pb2.py +27 -0
  18. protoc_gen_openapiv2/options/annotations_pb2.pyi +48 -0
  19. protoc_gen_openapiv2/options/annotations_pb2_grpc.py +4 -0
  20. protoc_gen_openapiv2/options/annotations_pb2_grpc.pyi +17 -0
  21. protoc_gen_openapiv2/options/openapiv2_pb2.py +132 -0
  22. protoc_gen_openapiv2/options/openapiv2_pb2.pyi +1533 -0
  23. protoc_gen_openapiv2/options/openapiv2_pb2_grpc.py +4 -0
  24. protoc_gen_openapiv2/options/openapiv2_pb2_grpc.pyi +17 -0
  25. sift/__init__.py +0 -0
  26. sift/annotation_logs/__init__.py +0 -0
  27. sift/annotation_logs/v1/__init__.py +0 -0
  28. sift/annotation_logs/v1/annotation_logs_pb2.py +115 -0
  29. sift/annotation_logs/v1/annotation_logs_pb2.pyi +370 -0
  30. sift/annotation_logs/v1/annotation_logs_pb2_grpc.py +135 -0
  31. sift/annotation_logs/v1/annotation_logs_pb2_grpc.pyi +84 -0
  32. sift/annotations/__init__.py +0 -0
  33. sift/annotations/v1/__init__.py +0 -0
  34. sift/annotations/v1/annotations_pb2.py +180 -0
  35. sift/annotations/v1/annotations_pb2.pyi +539 -0
  36. sift/annotations/v1/annotations_pb2_grpc.py +237 -0
  37. sift/annotations/v1/annotations_pb2_grpc.pyi +144 -0
  38. sift/assets/__init__.py +0 -0
  39. sift/assets/v1/__init__.py +0 -0
  40. sift/assets/v1/assets_pb2.py +90 -0
  41. sift/assets/v1/assets_pb2.pyi +235 -0
  42. sift/assets/v1/assets_pb2_grpc.py +168 -0
  43. sift/assets/v1/assets_pb2_grpc.pyi +101 -0
  44. sift/calculated_channels/__init__.py +0 -0
  45. sift/calculated_channels/v1/__init__.py +0 -0
  46. sift/calculated_channels/v1/calculated_channels_pb2.py +99 -0
  47. sift/calculated_channels/v1/calculated_channels_pb2.pyi +280 -0
  48. sift/calculated_channels/v1/calculated_channels_pb2_grpc.py +101 -0
  49. sift/calculated_channels/v1/calculated_channels_pb2_grpc.pyi +64 -0
  50. sift/campaigns/__init__.py +0 -0
  51. sift/campaigns/v1/__init__.py +0 -0
  52. sift/campaigns/v1/campaigns_pb2.py +144 -0
  53. sift/campaigns/v1/campaigns_pb2.pyi +383 -0
  54. sift/campaigns/v1/campaigns_pb2_grpc.py +169 -0
  55. sift/campaigns/v1/campaigns_pb2_grpc.pyi +104 -0
  56. sift/channel_schemas/__init__.py +0 -0
  57. sift/channel_schemas/v1/__init__.py +0 -0
  58. sift/channel_schemas/v1/channel_schemas_pb2.py +69 -0
  59. sift/channel_schemas/v1/channel_schemas_pb2.pyi +117 -0
  60. sift/channel_schemas/v1/channel_schemas_pb2_grpc.py +101 -0
  61. sift/channel_schemas/v1/channel_schemas_pb2_grpc.pyi +64 -0
  62. sift/channels/__init__.py +0 -0
  63. sift/channels/v2/__init__.py +0 -0
  64. sift/channels/v2/channels_pb2.py +88 -0
  65. sift/channels/v2/channels_pb2.pyi +183 -0
  66. sift/channels/v2/channels_pb2_grpc.py +101 -0
  67. sift/channels/v2/channels_pb2_grpc.pyi +64 -0
  68. sift/common/__init__.py +0 -0
  69. sift/common/type/__init__.py +0 -0
  70. sift/common/type/v1/__init__.py +0 -0
  71. sift/common/type/v1/channel_bit_field_element_pb2.py +34 -0
  72. sift/common/type/v1/channel_bit_field_element_pb2.pyi +33 -0
  73. sift/common/type/v1/channel_bit_field_element_pb2_grpc.py +4 -0
  74. sift/common/type/v1/channel_bit_field_element_pb2_grpc.pyi +17 -0
  75. sift/common/type/v1/channel_data_type_pb2.py +29 -0
  76. sift/common/type/v1/channel_data_type_pb2.pyi +50 -0
  77. sift/common/type/v1/channel_data_type_pb2_grpc.py +4 -0
  78. sift/common/type/v1/channel_data_type_pb2_grpc.pyi +17 -0
  79. sift/common/type/v1/channel_enum_type_pb2.py +32 -0
  80. sift/common/type/v1/channel_enum_type_pb2.pyi +29 -0
  81. sift/common/type/v1/channel_enum_type_pb2_grpc.py +4 -0
  82. sift/common/type/v1/channel_enum_type_pb2_grpc.pyi +17 -0
  83. sift/common/type/v1/organization_pb2.py +27 -0
  84. sift/common/type/v1/organization_pb2.pyi +29 -0
  85. sift/common/type/v1/organization_pb2_grpc.py +4 -0
  86. sift/common/type/v1/organization_pb2_grpc.pyi +17 -0
  87. sift/common/type/v1/resource_identifier_pb2.py +46 -0
  88. sift/common/type/v1/resource_identifier_pb2.pyi +145 -0
  89. sift/common/type/v1/resource_identifier_pb2_grpc.py +4 -0
  90. sift/common/type/v1/resource_identifier_pb2_grpc.pyi +17 -0
  91. sift/common/type/v1/user_pb2.py +33 -0
  92. sift/common/type/v1/user_pb2.pyi +36 -0
  93. sift/common/type/v1/user_pb2_grpc.py +4 -0
  94. sift/common/type/v1/user_pb2_grpc.pyi +17 -0
  95. sift/data/__init__.py +0 -0
  96. sift/data/v1/__init__.py +0 -0
  97. sift/data/v1/data_pb2.py +212 -0
  98. sift/data/v1/data_pb2.pyi +745 -0
  99. sift/data/v1/data_pb2_grpc.py +67 -0
  100. sift/data/v1/data_pb2_grpc.pyi +44 -0
  101. sift/ingest/__init__.py +0 -0
  102. sift/ingest/v1/__init__.py +0 -0
  103. sift/ingest/v1/ingest_pb2.py +35 -0
  104. sift/ingest/v1/ingest_pb2.pyi +118 -0
  105. sift/ingest/v1/ingest_pb2_grpc.py +66 -0
  106. sift/ingest/v1/ingest_pb2_grpc.pyi +41 -0
  107. sift/ingestion_configs/__init__.py +0 -0
  108. sift/ingestion_configs/v1/__init__.py +0 -0
  109. sift/ingestion_configs/v1/ingestion_configs_pb2.py +115 -0
  110. sift/ingestion_configs/v1/ingestion_configs_pb2.pyi +332 -0
  111. sift/ingestion_configs/v1/ingestion_configs_pb2_grpc.py +203 -0
  112. sift/ingestion_configs/v1/ingestion_configs_pb2_grpc.pyi +124 -0
  113. sift/notifications/__init__.py +0 -0
  114. sift/notifications/v1/__init__.py +0 -0
  115. sift/notifications/v1/notifications_pb2.py +64 -0
  116. sift/notifications/v1/notifications_pb2.pyi +225 -0
  117. sift/notifications/v1/notifications_pb2_grpc.py +101 -0
  118. sift/notifications/v1/notifications_pb2_grpc.pyi +64 -0
  119. sift/ping/__init__.py +0 -0
  120. sift/ping/v1/__init__.py +0 -0
  121. sift/ping/v1/ping_pb2.py +38 -0
  122. sift/ping/v1/ping_pb2.pyi +36 -0
  123. sift/ping/v1/ping_pb2_grpc.py +66 -0
  124. sift/ping/v1/ping_pb2_grpc.pyi +41 -0
  125. sift/remote_files/__init__.py +0 -0
  126. sift/remote_files/v1/__init__.py +0 -0
  127. sift/remote_files/v1/remote_files_pb2.py +174 -0
  128. sift/remote_files/v1/remote_files_pb2.pyi +472 -0
  129. sift/remote_files/v1/remote_files_pb2_grpc.py +271 -0
  130. sift/remote_files/v1/remote_files_pb2_grpc.pyi +164 -0
  131. sift/report_templates/__init__.py +0 -0
  132. sift/report_templates/v1/__init__.py +0 -0
  133. sift/report_templates/v1/report_templates_pb2.py +146 -0
  134. sift/report_templates/v1/report_templates_pb2.pyi +381 -0
  135. sift/report_templates/v1/report_templates_pb2_grpc.py +169 -0
  136. sift/report_templates/v1/report_templates_pb2_grpc.pyi +104 -0
  137. sift/reports/__init__.py +0 -0
  138. sift/reports/v1/__init__.py +0 -0
  139. sift/reports/v1/reports_pb2.py +193 -0
  140. sift/reports/v1/reports_pb2.pyi +562 -0
  141. sift/reports/v1/reports_pb2_grpc.py +205 -0
  142. sift/reports/v1/reports_pb2_grpc.pyi +136 -0
  143. sift/rule_evaluation/__init__.py +0 -0
  144. sift/rule_evaluation/v1/__init__.py +0 -0
  145. sift/rule_evaluation/v1/rule_evaluation_pb2.py +89 -0
  146. sift/rule_evaluation/v1/rule_evaluation_pb2.pyi +263 -0
  147. sift/rule_evaluation/v1/rule_evaluation_pb2_grpc.py +101 -0
  148. sift/rule_evaluation/v1/rule_evaluation_pb2_grpc.pyi +64 -0
  149. sift/rules/__init__.py +0 -0
  150. sift/rules/v1/__init__.py +0 -0
  151. sift/rules/v1/rules_pb2.py +420 -0
  152. sift/rules/v1/rules_pb2.pyi +1355 -0
  153. sift/rules/v1/rules_pb2_grpc.py +577 -0
  154. sift/rules/v1/rules_pb2_grpc.pyi +351 -0
  155. sift/runs/__init__.py +0 -0
  156. sift/runs/v2/__init__.py +0 -0
  157. sift/runs/v2/runs_pb2.py +150 -0
  158. sift/runs/v2/runs_pb2.pyi +413 -0
  159. sift/runs/v2/runs_pb2_grpc.py +271 -0
  160. sift/runs/v2/runs_pb2_grpc.pyi +164 -0
  161. sift/saved_searches/__init__.py +0 -0
  162. sift/saved_searches/v1/__init__.py +0 -0
  163. sift/saved_searches/v1/saved_searches_pb2.py +144 -0
  164. sift/saved_searches/v1/saved_searches_pb2.pyi +385 -0
  165. sift/saved_searches/v1/saved_searches_pb2_grpc.py +237 -0
  166. sift/saved_searches/v1/saved_searches_pb2_grpc.pyi +144 -0
  167. sift/tags/__init__.py +0 -0
  168. sift/tags/v1/__init__.py +0 -0
  169. sift/tags/v1/tags_pb2.py +49 -0
  170. sift/tags/v1/tags_pb2.pyi +71 -0
  171. sift/tags/v1/tags_pb2_grpc.py +4 -0
  172. sift/tags/v1/tags_pb2_grpc.pyi +17 -0
  173. sift/users/__init__.py +0 -0
  174. sift/users/v2/__init__.py +0 -0
  175. sift/users/v2/users_pb2.py +61 -0
  176. sift/users/v2/users_pb2.pyi +142 -0
  177. sift/users/v2/users_pb2_grpc.py +135 -0
  178. sift/users/v2/users_pb2_grpc.pyi +84 -0
  179. sift/views/__init__.py +0 -0
  180. sift/views/v1/__init__.py +0 -0
  181. sift/views/v1/views_pb2.py +130 -0
  182. sift/views/v1/views_pb2.pyi +466 -0
  183. sift/views/v1/views_pb2_grpc.py +305 -0
  184. sift/views/v1/views_pb2_grpc.pyi +184 -0
  185. sift_grafana/py.typed +0 -0
  186. sift_grafana/sift_query_model.py +64 -0
  187. sift_py/__init__.py +923 -0
  188. sift_py/_internal/__init__.py +5 -0
  189. sift_py/_internal/cel.py +18 -0
  190. sift_py/_internal/channel.py +42 -0
  191. sift_py/_internal/convert/__init__.py +3 -0
  192. sift_py/_internal/convert/json.py +24 -0
  193. sift_py/_internal/convert/protobuf.py +34 -0
  194. sift_py/_internal/convert/timestamp.py +9 -0
  195. sift_py/_internal/test_util/__init__.py +0 -0
  196. sift_py/_internal/test_util/channel.py +136 -0
  197. sift_py/_internal/test_util/fn.py +14 -0
  198. sift_py/_internal/test_util/server_interceptor.py +62 -0
  199. sift_py/_internal/time.py +48 -0
  200. sift_py/_internal/user.py +39 -0
  201. sift_py/data/__init__.py +171 -0
  202. sift_py/data/_channel.py +38 -0
  203. sift_py/data/_deserialize.py +208 -0
  204. sift_py/data/_deserialize_test.py +134 -0
  205. sift_py/data/_service_test.py +276 -0
  206. sift_py/data/_validate.py +10 -0
  207. sift_py/data/error.py +5 -0
  208. sift_py/data/query.py +299 -0
  209. sift_py/data/service.py +497 -0
  210. sift_py/data_import/__init__.py +130 -0
  211. sift_py/data_import/_config.py +167 -0
  212. sift_py/data_import/_config_test.py +166 -0
  213. sift_py/data_import/_csv_test.py +395 -0
  214. sift_py/data_import/_status_test.py +176 -0
  215. sift_py/data_import/_tdms_test.py +238 -0
  216. sift_py/data_import/ch10.py +157 -0
  217. sift_py/data_import/config.py +19 -0
  218. sift_py/data_import/csv.py +259 -0
  219. sift_py/data_import/status.py +113 -0
  220. sift_py/data_import/tdms.py +206 -0
  221. sift_py/data_import/tempfile.py +30 -0
  222. sift_py/data_import/time_format.py +39 -0
  223. sift_py/error.py +11 -0
  224. sift_py/file_attachment/__init__.py +88 -0
  225. sift_py/file_attachment/_internal/__init__.py +0 -0
  226. sift_py/file_attachment/_internal/download.py +13 -0
  227. sift_py/file_attachment/_internal/upload.py +100 -0
  228. sift_py/file_attachment/_service_test.py +161 -0
  229. sift_py/file_attachment/entity.py +30 -0
  230. sift_py/file_attachment/metadata.py +107 -0
  231. sift_py/file_attachment/service.py +142 -0
  232. sift_py/grpc/__init__.py +15 -0
  233. sift_py/grpc/_async_interceptors/__init__.py +0 -0
  234. sift_py/grpc/_async_interceptors/base.py +72 -0
  235. sift_py/grpc/_async_interceptors/metadata.py +36 -0
  236. sift_py/grpc/_interceptors/__init__.py +0 -0
  237. sift_py/grpc/_interceptors/base.py +61 -0
  238. sift_py/grpc/_interceptors/context.py +25 -0
  239. sift_py/grpc/_interceptors/metadata.py +33 -0
  240. sift_py/grpc/_retry.py +70 -0
  241. sift_py/grpc/keepalive.py +34 -0
  242. sift_py/grpc/transport.py +250 -0
  243. sift_py/grpc/transport_test.py +170 -0
  244. sift_py/ingestion/__init__.py +6 -0
  245. sift_py/ingestion/_internal/__init__.py +6 -0
  246. sift_py/ingestion/_internal/channel.py +12 -0
  247. sift_py/ingestion/_internal/error.py +10 -0
  248. sift_py/ingestion/_internal/ingest.py +350 -0
  249. sift_py/ingestion/_internal/ingest_test.py +357 -0
  250. sift_py/ingestion/_internal/ingestion_config.py +130 -0
  251. sift_py/ingestion/_internal/run.py +46 -0
  252. sift_py/ingestion/_service_test.py +478 -0
  253. sift_py/ingestion/buffer.py +189 -0
  254. sift_py/ingestion/channel.py +422 -0
  255. sift_py/ingestion/config/__init__.py +3 -0
  256. sift_py/ingestion/config/telemetry.py +281 -0
  257. sift_py/ingestion/config/telemetry_test.py +405 -0
  258. sift_py/ingestion/config/yaml/__init__.py +0 -0
  259. sift_py/ingestion/config/yaml/error.py +44 -0
  260. sift_py/ingestion/config/yaml/load.py +126 -0
  261. sift_py/ingestion/config/yaml/spec.py +58 -0
  262. sift_py/ingestion/config/yaml/test_load.py +25 -0
  263. sift_py/ingestion/flow.py +73 -0
  264. sift_py/ingestion/manager.py +99 -0
  265. sift_py/ingestion/rule/__init__.py +4 -0
  266. sift_py/ingestion/rule/config.py +11 -0
  267. sift_py/ingestion/service.py +237 -0
  268. sift_py/py.typed +0 -0
  269. sift_py/report_templates/__init__.py +0 -0
  270. sift_py/report_templates/_config_test.py +34 -0
  271. sift_py/report_templates/_service_test.py +94 -0
  272. sift_py/report_templates/config.py +36 -0
  273. sift_py/report_templates/service.py +171 -0
  274. sift_py/rest.py +29 -0
  275. sift_py/rule/__init__.py +0 -0
  276. sift_py/rule/_config_test.py +109 -0
  277. sift_py/rule/_service_test.py +168 -0
  278. sift_py/rule/config.py +229 -0
  279. sift_py/rule/service.py +484 -0
  280. sift_py/yaml/__init__.py +0 -0
  281. sift_py/yaml/_channel_test.py +169 -0
  282. sift_py/yaml/_rule_test.py +207 -0
  283. sift_py/yaml/channel.py +224 -0
  284. sift_py/yaml/report_templates.py +73 -0
  285. sift_py/yaml/rule.py +321 -0
  286. sift_py/yaml/utils.py +15 -0
  287. sift_stack_py-0.3.2.dist-info/LICENSE +7 -0
  288. sift_stack_py-0.3.2.dist-info/METADATA +109 -0
  289. sift_stack_py-0.3.2.dist-info/RECORD +291 -0
  290. sift_stack_py-0.3.2.dist-info/WHEEL +5 -0
  291. sift_stack_py-0.3.2.dist-info/top_level.txt +5 -0
@@ -0,0 +1,113 @@
1
+ import time
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ from typing import Optional, Union
5
+ from urllib.parse import urljoin
6
+
7
+ import requests
8
+ from pydantic import BaseModel, ConfigDict, field_validator
9
+ from pydantic.alias_generators import to_camel
10
+ from pydantic_core import PydanticCustomError
11
+ from typing_extensions import Self
12
+
13
+ from sift_py.rest import SiftRestConfig, compute_uri
14
+
15
+
16
+ class DataImportStatusType(Enum):
17
+ """Status of the data import."""
18
+
19
+ SUCCEEDED = "DATA_IMPORT_STATUS_SUCCEEDED"
20
+ PENDING = "DATA_IMPORT_STATUS_PENDING"
21
+ IN_PROGRESS = "DATA_IMPORT_STATUS_IN_PROGRESS"
22
+ FAILED = "DATA_IMPORT_STATUS_FAILED"
23
+
24
+ @classmethod
25
+ def from_str(cls, val: str) -> Optional[Self]:
26
+ try:
27
+ return cls(val)
28
+ except ValueError:
29
+ return None
30
+
31
+ def as_human_str(self) -> str:
32
+ return self.value
33
+
34
+
35
+ class DataImport(BaseModel):
36
+ """Metadata regarding the data import."""
37
+
38
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
39
+
40
+ data_import_id: str
41
+ created_date: datetime
42
+ modified_date: datetime
43
+ source_url: str = ""
44
+ status: Union[str, DataImportStatusType]
45
+ error_message: str = ""
46
+ csv_config: dict
47
+
48
+ @field_validator("status", mode="before")
49
+ @classmethod
50
+ def convert_status(cls, raw: Union[str, DataImportStatusType]) -> DataImportStatusType:
51
+ if isinstance(raw, DataImportStatusType):
52
+ return raw
53
+ elif isinstance(raw, str):
54
+ value = DataImportStatusType.from_str(raw)
55
+ if value is not None:
56
+ return value
57
+
58
+ raise PydanticCustomError(
59
+ "invalid_data_import_error", f"Invalid data import status: {raw}."
60
+ )
61
+
62
+
63
+ class DataImportService:
64
+ """
65
+ Service used to retrive information about a particular data import.
66
+ """
67
+
68
+ STATUS_PATH = "/api/v1/data-imports"
69
+ _data_import_id: str
70
+
71
+ def __init__(self, restconf: SiftRestConfig, data_import_id: str):
72
+ base_uri = compute_uri(restconf)
73
+ self._data_import_id = data_import_id
74
+ self._status_uri = urljoin(base_uri, self.STATUS_PATH)
75
+ self._apikey = restconf["apikey"]
76
+
77
+ def get_data_import(self) -> DataImport:
78
+ """
79
+ Returns information about the data import.
80
+ """
81
+ response = requests.get(
82
+ url=f"{self._status_uri}/{self._data_import_id}",
83
+ headers={"Authorization": f"Bearer {self._apikey}"},
84
+ )
85
+ response.raise_for_status()
86
+
87
+ data = response.json().get("dataImport")
88
+ data_import = DataImport(**data)
89
+ return data_import
90
+
91
+ def wait_until_complete(self) -> DataImport:
92
+ """
93
+ Blocks until the data import is completed. Check the status to determine
94
+ if the import was successful or not.
95
+ """
96
+ polling_interval = 1
97
+ while True:
98
+ data_import = self.get_data_import()
99
+ status: DataImportStatusType = data_import.status # type: ignore
100
+ if status in [
101
+ DataImportStatusType.SUCCEEDED,
102
+ DataImportStatusType.FAILED,
103
+ ]:
104
+ return data_import
105
+ elif status in [
106
+ DataImportStatusType.PENDING,
107
+ DataImportStatusType.IN_PROGRESS,
108
+ ]:
109
+ pass
110
+ else:
111
+ raise Exception(f"Unknown status: {status}")
112
+ time.sleep(polling_interval)
113
+ polling_interval = min(polling_interval * 2, 60)
@@ -0,0 +1,206 @@
1
+ from pathlib import Path
2
+ from typing import Dict, List, Optional, Union
3
+
4
+ try:
5
+ from nptdms import ( # type: ignore
6
+ ChannelObject,
7
+ RootObject,
8
+ TdmsChannel,
9
+ TdmsFile,
10
+ TdmsWriter,
11
+ types,
12
+ )
13
+ except ImportError as e:
14
+ raise RuntimeError(
15
+ "The npTDMS package is required to use the TDMS upload service. "
16
+ "Please include this dependency in your project by specifying `sift-stack-py[tdms]`."
17
+ ) from e
18
+
19
+ from sift_py.data_import._config import DataColumn, TimeColumn
20
+ from sift_py.data_import.config import CsvConfig
21
+ from sift_py.data_import.csv import CsvUploadService
22
+ from sift_py.data_import.status import DataImportService
23
+ from sift_py.data_import.tempfile import NamedTemporaryFile
24
+ from sift_py.data_import.time_format import TimeFormatType
25
+ from sift_py.ingestion.channel import ChannelDataType
26
+ from sift_py.rest import SiftRestConfig
27
+
28
+ TDMS_TO_SIFT_TYPES = {
29
+ types.Boolean: ChannelDataType.BOOL,
30
+ types.Int8: ChannelDataType.INT_32,
31
+ types.Int16: ChannelDataType.INT_32,
32
+ types.Int32: ChannelDataType.INT_32,
33
+ types.Int64: ChannelDataType.INT_64,
34
+ types.Uint8: ChannelDataType.UINT_32,
35
+ types.Uint16: ChannelDataType.UINT_32,
36
+ types.Uint32: ChannelDataType.UINT_32,
37
+ types.Uint64: ChannelDataType.UINT_64,
38
+ types.SingleFloat: ChannelDataType.FLOAT,
39
+ types.DoubleFloat: ChannelDataType.DOUBLE,
40
+ }
41
+
42
+
43
+ class TdmsUploadService:
44
+ """
45
+ Service to upload TDMS files.
46
+ """
47
+
48
+ _csv_upload_service: CsvUploadService
49
+
50
+ def __init__(self, rest_conf: SiftRestConfig):
51
+ self._csv_upload_service = CsvUploadService(rest_conf)
52
+
53
+ def upload(
54
+ self,
55
+ path: Union[str, Path],
56
+ asset_name: str,
57
+ group_into_components: bool = False,
58
+ ignore_errors: bool = False,
59
+ run_name: Optional[str] = None,
60
+ run_id: Optional[str] = None,
61
+ ) -> DataImportService:
62
+ """
63
+ Uploads the TDMS file pointed to by `path` to the specified asset.
64
+
65
+ Set `group_into_components` to True if you want to upload the TDMS groups as
66
+ a Sift Component.
67
+
68
+ If `ignore_errors` is True will skip channels without timing information.
69
+
70
+ Override `run_name` to specify the name of the run to create for this data. Default is None.
71
+ Override `run_id` to specify the id of the run to add this data to. Default is None.
72
+ """
73
+ posix_path = Path(path) if isinstance(path, str) else path
74
+
75
+ if not posix_path.is_file():
76
+ raise Exception(f"Provided path, '{path}', does not point to a regular file.")
77
+
78
+ with NamedTemporaryFile(mode="w", suffix=".csv") as temp_file:
79
+ valid_channels = self._convert_to_csv(path, temp_file.name, ignore_errors)
80
+ if not valid_channels:
81
+ raise Exception(f"No valid channels remaining in {path}")
82
+
83
+ csv_config = self._create_csv_config(
84
+ valid_channels, asset_name, group_into_components, run_name, run_id
85
+ )
86
+ return self._csv_upload_service.upload(temp_file.name, csv_config)
87
+
88
+ def _convert_to_csv(
89
+ self, src_path: Union[str, Path], dst_path: Union[str, Path], ignore_errors: bool
90
+ ) -> List[TdmsChannel]:
91
+ """Converts the TDMS file to a temporary CSV on disk that we will upload.
92
+
93
+ Returns the valid channels after parsing the TDMS file. Valid channels contain
94
+ timing information.
95
+ """
96
+
97
+ def contains_timing(channel: TdmsChannel) -> bool:
98
+ """Returns true if the TDMS Channel contains timing information."""
99
+ return all(
100
+ [
101
+ "wf_increment" in channel.properties,
102
+ "wf_start_time" in channel.properties,
103
+ "wf_start_offset" in channel.properties,
104
+ ]
105
+ )
106
+
107
+ def normalize_name(channel_name: str) -> str:
108
+ """Normalize channel names by invalid characters."""
109
+ return " ".join(channel_name.replace("/", " ").split())
110
+
111
+ src_file = TdmsFile(src_path)
112
+
113
+ original_groups = src_file.groups()
114
+ valid_channels: List[ChannelObject] = []
115
+ for group in original_groups:
116
+ for channel in group.channels():
117
+ if contains_timing(channel):
118
+ new_channel = ChannelObject(
119
+ group=normalize_name(channel.group_name),
120
+ channel=normalize_name(channel.name),
121
+ data=channel.data,
122
+ properties=channel.properties,
123
+ )
124
+ valid_channels.append(new_channel)
125
+ else:
126
+ if ignore_errors:
127
+ print(
128
+ f"{group.name}:{channel.name} does not contain timing information. Skipping."
129
+ )
130
+ else:
131
+ raise Exception(
132
+ f"{group.name}:{channel.name} does not contain timing information. "
133
+ "Set `ignore_errors` to True to skip channels without timing information."
134
+ )
135
+
136
+ # Write out the new TDMS file with invalid channels removed, then convert to csv.
137
+ with NamedTemporaryFile(mode="w") as f:
138
+ with TdmsWriter(f.name) as tdms_writer:
139
+ root_object = RootObject(src_file.properties)
140
+ tdms_writer.write_segment([root_object] + original_groups + valid_channels)
141
+
142
+ filtered_tdms_file = TdmsFile(f.name)
143
+ df = filtered_tdms_file.as_dataframe(time_index=True, absolute_time=True)
144
+ df.to_csv(dst_path, encoding="utf-8")
145
+
146
+ return [channel for group in filtered_tdms_file.groups() for channel in group.channels()]
147
+
148
+ def _create_csv_config(
149
+ self,
150
+ channels: List[TdmsChannel],
151
+ asset_name: str,
152
+ group_into_components: bool,
153
+ run_name: Optional[str] = None,
154
+ run_id: Optional[str] = None,
155
+ ) -> CsvConfig:
156
+ """Construct a CsvConfig based on metadata within the TDMS file."""
157
+ data_config: Dict[int, DataColumn] = {}
158
+ # Data columns start in column 2 (1-indexed)
159
+ first_data_column = 2
160
+ for i, channel in enumerate(channels):
161
+ try:
162
+ data_type = TDMS_TO_SIFT_TYPES[channel.data_type].as_human_str(api_format=True)
163
+ except KeyError:
164
+ data_type = None
165
+
166
+ if data_type is None:
167
+ raise Exception(f"{channel.name} data type not supported: {channel.data_type}")
168
+
169
+ extra_info = ""
170
+ for k, v in channel.properties.items():
171
+ # Skip these since the csv config has dedicated fields for them.
172
+ if k in ["description", "unit_string"]:
173
+ continue
174
+ # Must convert datetime to a string
175
+ elif k == "wf_start_time":
176
+ v = str(v)
177
+ extra_info += f"{k}: {v}\n"
178
+
179
+ channel_config = DataColumn(
180
+ name=channel.name,
181
+ data_type=data_type,
182
+ description=f"{channel.properties.get('description')}\n{extra_info}",
183
+ units=channel.properties.get("unit_string") or "",
184
+ )
185
+ if group_into_components and channel.group_name:
186
+ channel_config.component = channel.group_name
187
+
188
+ data_config[first_data_column + i] = channel_config
189
+
190
+ config_info = {
191
+ "asset_name": asset_name,
192
+ "first_data_row": first_data_column,
193
+ "time_column": TimeColumn(
194
+ format=TimeFormatType.ABSOLUTE_DATETIME,
195
+ column_number=1,
196
+ ),
197
+ "data_columns": data_config,
198
+ }
199
+
200
+ if run_name is not None:
201
+ config_info["run_name"] = run_name
202
+
203
+ if run_id is not None:
204
+ config_info["run_id"] = run_id
205
+
206
+ return CsvConfig(config_info)
@@ -0,0 +1,30 @@
1
+ import os
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+
6
+ class NamedTemporaryFile:
7
+ """
8
+ Created a named temporary file.
9
+
10
+ Works on both Windows and Unix systems.
11
+
12
+ See https://stackoverflow.com/questions/23212435/permission-denied-to-write-to-my-temporary-file
13
+ for more information on Windows compatibility.
14
+ """
15
+
16
+ def __init__(self, mode, suffix=""):
17
+ self.temp_dir = tempfile.mkdtemp()
18
+ self.name = Path(self.temp_dir) / f"tempfile{suffix}"
19
+ self.file = open(self.name, mode)
20
+
21
+ def __enter__(self):
22
+ return self.file
23
+
24
+ def __exit__(self, exc_type, exc_value, traceback):
25
+ self.file.close()
26
+ try:
27
+ os.remove(self.name)
28
+ os.rmdir(self.temp_dir)
29
+ except FileNotFoundError:
30
+ pass
@@ -0,0 +1,39 @@
1
+ from enum import Enum
2
+ from typing import Optional
3
+
4
+ from typing_extensions import Self
5
+
6
+
7
+ class TimeFormatType(Enum):
8
+ ABSOLUTE_RFC3339 = "TIME_FORMAT_ABSOLUTE_RFC3339"
9
+ ABSOLUTE_DATETIME = "TIME_FORMAT_ABSOLUTE_DATETIME"
10
+ ABSOLUTE_UNIX_SECONDS = "TIME_FORMAT_ABSOLUTE_UNIX_SECONDS"
11
+ ABSOLUTE_UNIX_MILLISECONDS = "TIME_FORMAT_ABSOLUTE_UNIX_MILLISECONDS"
12
+ ABSOLUTE_UNIX_MICROSECONDS = "TIME_FORMAT_ABSOLUTE_UNIX_MICROSECONDS"
13
+ ABSOLUTE_UNIX_NANOSECONDS = "TIME_FORMAT_ABSOLUTE_UNIX_NANOSECONDS"
14
+ RELATIVE_NANOSECONDS = "TIME_FORMAT_RELATIVE_NANOSECONDS"
15
+ RELATIVE_MICROSECONDS = "TIME_FORMAT_RELATIVE_MICROSECONDS"
16
+ RELATIVE_MILLISECONDS = "TIME_FORMAT_RELATIVE_MILLISECONDS"
17
+ RELATIVE_SECONDS = "TIME_FORMAT_RELATIVE_SECONDS"
18
+ RELATIVE_MINUTES = "TIME_FORMAT_RELATIVE_MINUTES"
19
+ RELATIVE_HOURS = "TIME_FORMAT_RELATIVE_HOURS"
20
+
21
+ @classmethod
22
+ def from_str(cls, val: str) -> Optional[Self]:
23
+ try:
24
+ return cls(val)
25
+ except ValueError:
26
+ return None
27
+
28
+ def as_human_str(self) -> str:
29
+ return self.value
30
+
31
+ def is_relative(self) -> bool:
32
+ return self in [
33
+ self.RELATIVE_NANOSECONDS,
34
+ self.RELATIVE_MICROSECONDS,
35
+ self.RELATIVE_MILLISECONDS,
36
+ self.RELATIVE_SECONDS,
37
+ self.RELATIVE_MINUTES,
38
+ self.RELATIVE_HOURS,
39
+ ]
sift_py/error.py ADDED
@@ -0,0 +1,11 @@
1
+ class SiftError(Exception):
2
+ """
3
+ These exceptions are raised when something totally unexpected occurs and is
4
+ meant to indicate that the error is likely not caused by the user, but rather,
5
+ the library itself. These errors should be reported to Sift.
6
+ """
7
+
8
+ msg: str
9
+
10
+ def __init__(self, msg: str):
11
+ super().__init__(f"{msg}\nPlease notify Sift.")
@@ -0,0 +1,88 @@
1
+ """
2
+ This module contains services to facilitate uploading and downloading file attachments.
3
+ It also provides utilities to easily query all file attachments for a given entity
4
+ which could be a run, annotation, or annotation logs. File attachment deletion is also supported.
5
+
6
+ Once files have been attached, they should be viewable on the Sift application, attached to their
7
+ respective entities. Below are various examples on how to leverage the `sift_py.file_attachment.service.FileAttachmentService`.
8
+
9
+ ## Initializing the file attachment service
10
+
11
+ Unlike other services throughout `sift_py`, the `sift_py.file_attachment.service.FileAttachmentService` does rely on both
12
+ REST and gRPC APIs, so with that in mind we can initialize our service like so:
13
+
14
+ ```python
15
+ from sift_py.grpc.transport import SiftChannelConfig, use_sift_channel
16
+ from sift_py.file_attachment.service import FileAttachmentService
17
+ from sift_py.file_attachment.entity import Entity, EntityType
18
+ from sift_py.file_attachment.metadata import ImageMetadata
19
+ from sift_py.rest import SiftRestConfig
20
+
21
+ from sift.remote_files.v1.remote_files_pb2 import GetRemoteFileRequest
22
+ from sift.remote_files.v1.remote_files_pb2_grpc import RemoteFileServiceStub
23
+
24
+ rest_config: SiftRestConfig = {
25
+ # Be sure to exclude the "https://" or "http://" scheme out of the uri
26
+ "uri": rest_base_uri,
27
+ "apikey": apikey,
28
+ }
29
+
30
+ sift_channel_config = SiftChannelConfig(uri=grpc_base_uri, apikey=apikey)
31
+
32
+ with use_sift_channel(sift_channel_config) as channel:
33
+ file_attachment_service = FileAttachmentService(channel, rest_config)
34
+ ...
35
+ ```
36
+
37
+ With the service initialized we can now interact with the file attachments API.
38
+
39
+ ## Various Examples
40
+
41
+ For demonstrative purposes we will upload an `mp4` file and attach to a run of `run_id`.
42
+ Once it is uploaded we will query all file attachments for a particular run and re-download
43
+ what we just uploaded.
44
+
45
+ ```python
46
+
47
+ from sift_py.grpc.transport import SiftChannelConfig, use_sift_channel
48
+ from sift_py.file_attachment.service import FileAttachmentService
49
+ from sift_py.file_attachment.entity import Entity, EntityType
50
+ from sift_py.file_attachment.metadata import VideoMetadata
51
+ from sift_py.rest import SiftRestConfig
52
+
53
+ from sift.remote_files.v1.remote_files_pb2 import GetRemoteFileRequest
54
+ from sift.remote_files.v1.remote_files_pb2_grpc import RemoteFileServiceStub
55
+
56
+ ...
57
+
58
+ with use_sift_channel(sift_channel_config) as channel:
59
+ file_attachment_service = FileAttachmentService(channel, rest_config)
60
+
61
+ run = entity=Entity(
62
+ entity_id=run_id, # some arbitrary run ID that refers to an existing run
63
+ entity_type=EntityType.RUN,
64
+ )
65
+
66
+ # uploading the file attachment and attaching it to a run of `run_id`.
67
+ remote_file = file_attachment_service.upload_attachment(
68
+ path="path/to/foo.mp4",
69
+ entity=run,
70
+ # Metatadata.. optional but recommended for optimal viewing in the application
71
+ metadata=VideoMetadata(height=2160, width=3840, duration_seconds=5.5, timestamp=datetime(2024, 10, 19, 2, 22, 22),
72
+ description="thrusters getting too hot" ,
73
+ )
74
+
75
+ # retrieving all of the file attachments for our run
76
+ all_file_attachments = file_attachment_service.retrieve_attachments(run)
77
+
78
+ # downloading our file_attachment and saving it to our current working dir
79
+ file_attachment_service.download_attachment(remote_file)
80
+
81
+ # downloading our file_attachment and saving it somewhere else with a different name
82
+ file_attachment_service.download_attachment(remote_file, "somewhere/else/foo.mp4")
83
+
84
+ # deleting out file attachment from Sift
85
+ file_attachment_service.delete_file_attachments(remote_file_1, remote_file_2, remote_file_etc)
86
+ ```
87
+
88
+ """
File without changes
@@ -0,0 +1,13 @@
1
+ import io
2
+ from pathlib import Path
3
+
4
+ import requests
5
+
6
+
7
+ def download_remote_file(url: str, out: Path):
8
+ with requests.get(url, stream=True) as req:
9
+ req.raise_for_status()
10
+ with open(out, "wb") as output_file:
11
+ for chunk in req.iter_content(chunk_size=io.DEFAULT_BUFFER_SIZE):
12
+ if chunk:
13
+ output_file.write(chunk)
@@ -0,0 +1,100 @@
1
+ import mimetypes
2
+ from pathlib import Path
3
+ from typing import Any, Dict, Optional, Tuple, Union
4
+ from urllib.parse import urljoin
5
+
6
+ import requests
7
+ from requests_toolbelt import MultipartEncoder
8
+
9
+ from sift_py._internal.convert.json import to_json
10
+ from sift_py.file_attachment.entity import Entity
11
+ from sift_py.file_attachment.metadata import Metadata
12
+ from sift_py.rest import SiftRestConfig, compute_uri
13
+
14
+
15
+ class UploadService:
16
+ UPLOAD_PATH = "/api/v0/remote-files/upload"
17
+ UPLOAD_BULK_PATH = "/api/v0/remote-files/upload:bulk"
18
+
19
+ _upload_uri: str
20
+ _upload_bulk_uri: str
21
+ _apikey: str
22
+
23
+ def __init__(self, restconf: SiftRestConfig):
24
+ base_uri = compute_uri(restconf)
25
+ self._upload_uri = urljoin(base_uri, self.UPLOAD_PATH)
26
+ self._upload_bulk_uri = urljoin(base_uri, self.UPLOAD_BULK_PATH)
27
+ self._apikey = restconf["apikey"]
28
+
29
+ def upload_attachment(
30
+ self,
31
+ path: Union[str, Path],
32
+ entity: Entity,
33
+ metadata: Optional[Metadata] = None,
34
+ description: Optional[str] = None,
35
+ organization_id: Optional[str] = None,
36
+ ) -> str:
37
+ posix_path = Path(path) if isinstance(path, str) else path
38
+
39
+ if not posix_path.is_file():
40
+ raise Exception(f"Provided path, '{path}', does not point to a regular file.")
41
+
42
+ file_name, mimetype, content_encoding = self.__class__._mime_and_content_type_from_path(
43
+ posix_path
44
+ )
45
+
46
+ if not mimetype:
47
+ raise Exception(f"The MIME-type of '{posix_path}' could not be computed.")
48
+
49
+ with open(path, "rb") as file:
50
+ form_fields: Dict[str, Any] = {
51
+ "entityId": entity.entity_id,
52
+ "entityType": entity.entity_type.value,
53
+ }
54
+
55
+ if content_encoding:
56
+ form_fields["file"] = (
57
+ file_name,
58
+ file,
59
+ mimetype,
60
+ {
61
+ "Content-Encoding": content_encoding,
62
+ },
63
+ )
64
+ else:
65
+ form_fields["file"] = (file_name, file, mimetype)
66
+
67
+ if metadata:
68
+ form_fields["metadata"] = to_json(metadata)
69
+
70
+ if organization_id:
71
+ form_fields["organizationId"] = organization_id
72
+
73
+ if description:
74
+ form_fields["description"] = description
75
+
76
+ form_data = MultipartEncoder(fields=form_fields)
77
+
78
+ # https://github.com/requests/toolbelt/issues/312
79
+ # Issue above is reason for the type ignoring
80
+ response = requests.post(
81
+ url=self._upload_uri,
82
+ data=form_data, # type: ignore
83
+ headers={
84
+ "Authorization": f"Bearer {self._apikey}",
85
+ "Content-Type": form_data.content_type,
86
+ },
87
+ )
88
+
89
+ if response.status_code != 200:
90
+ raise Exception(
91
+ f"Request failed with status code {response.status_code} ({response.reason})."
92
+ )
93
+
94
+ return response.json().get("remoteFile").get("remoteFileId")
95
+
96
+ @staticmethod
97
+ def _mime_and_content_type_from_path(path: Path) -> Tuple[str, Optional[str], Optional[str]]:
98
+ file_name = path.name
99
+ mime, encoding = mimetypes.guess_type(path)
100
+ return file_name, mime, encoding