UncountablePythonSDK 0.0.52__py3-none-any.whl → 0.0.131__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.

Potentially problematic release.


This version of UncountablePythonSDK might be problematic. Click here for more details.

Files changed (316) hide show
  1. docs/conf.py +54 -7
  2. docs/index.md +107 -4
  3. docs/integration_examples/create_ingredient.md +43 -0
  4. docs/integration_examples/create_output.md +56 -0
  5. docs/integration_examples/index.md +6 -0
  6. docs/justfile +2 -2
  7. docs/requirements.txt +6 -4
  8. examples/async_batch.py +3 -3
  9. examples/basic_auth.py +7 -0
  10. examples/create_entity.py +3 -1
  11. examples/create_ingredient_sdk.py +34 -0
  12. examples/download_files.py +26 -0
  13. examples/edit_recipe_inputs.py +4 -2
  14. examples/integration-server/jobs/materials_auto/concurrent_cron.py +11 -0
  15. examples/integration-server/jobs/materials_auto/example_cron.py +21 -0
  16. examples/integration-server/jobs/materials_auto/example_http.py +47 -0
  17. examples/integration-server/jobs/materials_auto/example_instrument.py +100 -0
  18. examples/integration-server/jobs/materials_auto/example_parse.py +140 -0
  19. examples/integration-server/jobs/materials_auto/example_predictions.py +61 -0
  20. examples/integration-server/jobs/materials_auto/example_runsheet_wh.py +39 -0
  21. examples/integration-server/jobs/materials_auto/example_wh.py +23 -0
  22. examples/integration-server/jobs/materials_auto/profile.yaml +104 -0
  23. examples/integration-server/pyproject.toml +224 -0
  24. examples/invoke_uploader.py +4 -1
  25. examples/oauth.py +7 -0
  26. examples/set_recipe_metadata_file.py +40 -0
  27. examples/set_recipe_output_file_sdk.py +26 -0
  28. examples/upload_files.py +1 -2
  29. pkgs/argument_parser/__init__.py +9 -0
  30. pkgs/argument_parser/_is_namedtuple.py +3 -0
  31. pkgs/argument_parser/argument_parser.py +217 -70
  32. pkgs/filesystem_utils/__init__.py +1 -0
  33. pkgs/filesystem_utils/_blob_session.py +144 -0
  34. pkgs/filesystem_utils/_gdrive_session.py +10 -7
  35. pkgs/filesystem_utils/_s3_session.py +15 -13
  36. pkgs/filesystem_utils/_sftp_session.py +11 -7
  37. pkgs/filesystem_utils/file_type_utils.py +30 -10
  38. pkgs/py.typed +0 -0
  39. pkgs/serialization/__init__.py +7 -2
  40. pkgs/serialization/annotation.py +64 -0
  41. pkgs/serialization/missing_sentry.py +1 -1
  42. pkgs/serialization/opaque_key.py +1 -1
  43. pkgs/serialization/serial_alias.py +47 -0
  44. pkgs/serialization/serial_class.py +47 -26
  45. pkgs/serialization/serial_generic.py +16 -0
  46. pkgs/serialization/serial_union.py +17 -14
  47. pkgs/serialization/yaml.py +4 -1
  48. pkgs/serialization_util/__init__.py +6 -0
  49. pkgs/serialization_util/dataclasses.py +14 -0
  50. pkgs/serialization_util/serialization_helpers.py +15 -5
  51. pkgs/type_spec/actions_registry/__main__.py +0 -4
  52. pkgs/type_spec/actions_registry/emit_typescript.py +5 -5
  53. pkgs/type_spec/builder.py +354 -119
  54. pkgs/type_spec/builder_types.py +9 -0
  55. pkgs/type_spec/config.py +51 -11
  56. pkgs/type_spec/cross_output_links.py +99 -0
  57. pkgs/type_spec/emit_io_ts.py +1 -1
  58. pkgs/type_spec/emit_open_api.py +127 -36
  59. pkgs/type_spec/emit_open_api_util.py +5 -6
  60. pkgs/type_spec/emit_python.py +329 -121
  61. pkgs/type_spec/emit_typescript.py +117 -256
  62. pkgs/type_spec/emit_typescript_util.py +291 -2
  63. pkgs/type_spec/load_types.py +18 -4
  64. pkgs/type_spec/non_discriminated_union_exceptions.py +14 -0
  65. pkgs/type_spec/open_api_util.py +29 -4
  66. pkgs/type_spec/parts/base.py.prepart +13 -10
  67. pkgs/type_spec/parts/base.ts.prepart +4 -0
  68. pkgs/type_spec/type_info/__main__.py +3 -1
  69. pkgs/type_spec/type_info/emit_type_info.py +124 -29
  70. pkgs/type_spec/ui_entry_actions/__init__.py +4 -0
  71. pkgs/type_spec/ui_entry_actions/generate_ui_entry_actions.py +308 -0
  72. pkgs/type_spec/util.py +4 -4
  73. pkgs/type_spec/value_spec/__main__.py +26 -9
  74. pkgs/type_spec/value_spec/convert_type.py +21 -1
  75. pkgs/type_spec/value_spec/emit_python.py +25 -7
  76. pkgs/type_spec/value_spec/types.py +1 -1
  77. uncountable/core/async_batch.py +1 -1
  78. uncountable/core/client.py +142 -39
  79. uncountable/core/environment.py +41 -0
  80. uncountable/core/file_upload.py +52 -18
  81. uncountable/integration/cli.py +142 -0
  82. uncountable/integration/construct_client.py +8 -8
  83. uncountable/integration/cron.py +11 -37
  84. uncountable/integration/db/connect.py +12 -2
  85. uncountable/integration/db/session.py +25 -0
  86. uncountable/integration/entrypoint.py +8 -37
  87. uncountable/integration/executors/executors.py +125 -2
  88. uncountable/integration/executors/generic_upload_executor.py +87 -29
  89. uncountable/integration/executors/script_executor.py +3 -3
  90. uncountable/integration/http_server/__init__.py +5 -0
  91. uncountable/integration/http_server/types.py +69 -0
  92. uncountable/integration/job.py +242 -12
  93. uncountable/integration/queue_runner/__init__.py +0 -0
  94. uncountable/integration/queue_runner/command_server/__init__.py +28 -0
  95. uncountable/integration/queue_runner/command_server/command_client.py +133 -0
  96. uncountable/integration/queue_runner/command_server/command_server.py +142 -0
  97. uncountable/integration/queue_runner/command_server/constants.py +4 -0
  98. uncountable/integration/queue_runner/command_server/protocol/__init__.py +0 -0
  99. uncountable/integration/queue_runner/command_server/protocol/command_server.proto +58 -0
  100. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.py +57 -0
  101. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.pyi +114 -0
  102. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2_grpc.py +264 -0
  103. uncountable/integration/queue_runner/command_server/types.py +75 -0
  104. uncountable/integration/queue_runner/datastore/__init__.py +3 -0
  105. uncountable/integration/queue_runner/datastore/datastore_sqlite.py +250 -0
  106. uncountable/integration/queue_runner/datastore/interface.py +29 -0
  107. uncountable/integration/queue_runner/datastore/model.py +24 -0
  108. uncountable/integration/queue_runner/job_scheduler.py +200 -0
  109. uncountable/integration/queue_runner/queue_runner.py +34 -0
  110. uncountable/integration/queue_runner/types.py +7 -0
  111. uncountable/integration/queue_runner/worker.py +116 -0
  112. uncountable/integration/scan_profiles.py +67 -0
  113. uncountable/integration/scheduler.py +199 -0
  114. uncountable/integration/secret_retrieval/retrieve_secret.py +26 -4
  115. uncountable/integration/server.py +94 -69
  116. uncountable/integration/telemetry.py +150 -34
  117. uncountable/integration/webhook_server/entrypoint.py +97 -0
  118. uncountable/types/__init__.py +78 -1
  119. uncountable/types/api/batch/execute_batch.py +13 -6
  120. uncountable/types/api/batch/execute_batch_load_async.py +9 -3
  121. uncountable/types/api/chemical/convert_chemical_formats.py +17 -5
  122. uncountable/types/api/condition_parameters/__init__.py +1 -0
  123. uncountable/types/api/condition_parameters/upsert_condition_match.py +72 -0
  124. uncountable/types/api/entity/create_entities.py +19 -7
  125. uncountable/types/api/entity/create_entity.py +17 -8
  126. uncountable/types/api/entity/create_or_update_entity.py +48 -0
  127. uncountable/types/api/entity/export_entities.py +59 -0
  128. uncountable/types/api/entity/get_entities_data.py +13 -4
  129. uncountable/types/api/entity/grant_entity_permissions.py +48 -0
  130. uncountable/types/api/entity/list_aggregate.py +79 -0
  131. uncountable/types/api/entity/list_entities.py +42 -10
  132. uncountable/types/api/entity/lock_entity.py +11 -4
  133. uncountable/types/api/entity/lookup_entity.py +116 -0
  134. uncountable/types/api/entity/resolve_entity_ids.py +15 -6
  135. uncountable/types/api/entity/set_entity_field_values.py +44 -0
  136. uncountable/types/api/entity/set_values.py +10 -3
  137. uncountable/types/api/entity/transition_entity_phase.py +22 -7
  138. uncountable/types/api/entity/unlock_entity.py +10 -3
  139. uncountable/types/api/equipment/associate_equipment_input.py +9 -3
  140. uncountable/types/api/field_options/upsert_field_options.py +17 -7
  141. uncountable/types/api/files/__init__.py +1 -0
  142. uncountable/types/api/files/download_file.py +77 -0
  143. uncountable/types/api/id_source/list_id_source.py +16 -7
  144. uncountable/types/api/id_source/match_id_source.py +14 -5
  145. uncountable/types/api/input_groups/get_input_group_names.py +13 -4
  146. uncountable/types/api/inputs/create_inputs.py +23 -9
  147. uncountable/types/api/inputs/get_input_data.py +30 -12
  148. uncountable/types/api/inputs/get_input_names.py +16 -7
  149. uncountable/types/api/inputs/get_inputs_data.py +25 -7
  150. uncountable/types/api/inputs/set_input_attribute_values.py +12 -6
  151. uncountable/types/api/inputs/set_input_category.py +12 -5
  152. uncountable/types/api/inputs/set_input_subcategories.py +10 -3
  153. uncountable/types/api/inputs/set_intermediate_type.py +11 -4
  154. uncountable/types/api/integrations/__init__.py +1 -0
  155. uncountable/types/api/integrations/publish_realtime_data.py +41 -0
  156. uncountable/types/api/integrations/push_notification.py +49 -0
  157. uncountable/types/api/integrations/register_sockets_token.py +41 -0
  158. uncountable/types/api/listing/__init__.py +1 -0
  159. uncountable/types/api/listing/fetch_listing.py +58 -0
  160. uncountable/types/api/material_families/update_entity_material_families.py +10 -4
  161. uncountable/types/api/notebooks/__init__.py +1 -0
  162. uncountable/types/api/notebooks/add_notebook_content.py +119 -0
  163. uncountable/types/api/outputs/get_output_data.py +28 -13
  164. uncountable/types/api/outputs/get_output_names.py +15 -6
  165. uncountable/types/api/outputs/get_output_organization.py +173 -0
  166. uncountable/types/api/outputs/resolve_output_conditions.py +20 -8
  167. uncountable/types/api/permissions/set_core_permissions.py +26 -10
  168. uncountable/types/api/project/get_projects.py +16 -7
  169. uncountable/types/api/project/get_projects_data.py +17 -8
  170. uncountable/types/api/recipe_links/create_recipe_link.py +12 -5
  171. uncountable/types/api/recipe_links/remove_recipe_link.py +11 -4
  172. uncountable/types/api/recipe_metadata/get_recipe_metadata_data.py +16 -7
  173. uncountable/types/api/recipes/add_recipe_to_project.py +10 -3
  174. uncountable/types/api/recipes/add_time_series_data.py +64 -0
  175. uncountable/types/api/recipes/archive_recipes.py +11 -4
  176. uncountable/types/api/recipes/associate_recipe_as_input.py +12 -5
  177. uncountable/types/api/recipes/associate_recipe_as_lot.py +10 -3
  178. uncountable/types/api/recipes/clear_recipe_outputs.py +42 -0
  179. uncountable/types/api/recipes/create_mix_order.py +44 -0
  180. uncountable/types/api/recipes/create_recipe.py +15 -9
  181. uncountable/types/api/recipes/create_recipes.py +21 -9
  182. uncountable/types/api/recipes/disassociate_recipe_as_input.py +10 -3
  183. uncountable/types/api/recipes/edit_recipe_inputs.py +134 -22
  184. uncountable/types/api/recipes/get_column_calculation_values.py +57 -0
  185. uncountable/types/api/recipes/get_curve.py +11 -5
  186. uncountable/types/api/recipes/get_recipe_calculations.py +13 -7
  187. uncountable/types/api/recipes/get_recipe_links.py +10 -4
  188. uncountable/types/api/recipes/get_recipe_names.py +13 -4
  189. uncountable/types/api/recipes/get_recipe_output_metadata.py +12 -6
  190. uncountable/types/api/recipes/get_recipes_data.py +87 -33
  191. uncountable/types/api/recipes/lock_recipes.py +19 -8
  192. uncountable/types/api/recipes/remove_recipe_from_project.py +10 -3
  193. uncountable/types/api/recipes/set_recipe_inputs.py +16 -10
  194. uncountable/types/api/recipes/set_recipe_metadata.py +10 -3
  195. uncountable/types/api/recipes/set_recipe_output_annotations.py +24 -12
  196. uncountable/types/api/recipes/set_recipe_output_file.py +55 -0
  197. uncountable/types/api/recipes/set_recipe_outputs.py +35 -12
  198. uncountable/types/api/recipes/set_recipe_tags.py +26 -9
  199. uncountable/types/api/recipes/set_recipe_total.py +59 -0
  200. uncountable/types/api/recipes/unarchive_recipes.py +10 -3
  201. uncountable/types/api/recipes/unlock_recipes.py +14 -6
  202. uncountable/types/api/runsheet/__init__.py +1 -0
  203. uncountable/types/api/runsheet/complete_async_upload.py +41 -0
  204. uncountable/types/api/triggers/run_trigger.py +11 -4
  205. uncountable/types/api/uploader/complete_async_parse.py +46 -0
  206. uncountable/types/api/uploader/invoke_uploader.py +13 -6
  207. uncountable/types/api/user/__init__.py +1 -0
  208. uncountable/types/api/user/get_current_user_info.py +40 -0
  209. uncountable/types/async_batch.py +2 -1
  210. uncountable/types/async_batch_processor.py +618 -18
  211. uncountable/types/async_batch_t.py +54 -7
  212. uncountable/types/async_jobs.py +8 -0
  213. uncountable/types/async_jobs_t.py +52 -0
  214. uncountable/types/auth_retrieval.py +11 -0
  215. uncountable/types/auth_retrieval_t.py +75 -0
  216. uncountable/types/base.py +0 -1
  217. uncountable/types/base_t.py +13 -11
  218. uncountable/types/calculations.py +0 -1
  219. uncountable/types/calculations_t.py +5 -2
  220. uncountable/types/chemical_structure.py +0 -1
  221. uncountable/types/chemical_structure_t.py +6 -5
  222. uncountable/types/client_base.py +751 -70
  223. uncountable/types/client_config.py +1 -1
  224. uncountable/types/client_config_t.py +17 -3
  225. uncountable/types/curves.py +0 -1
  226. uncountable/types/curves_t.py +10 -7
  227. uncountable/types/data.py +12 -0
  228. uncountable/types/data_t.py +103 -0
  229. uncountable/types/entity.py +4 -1
  230. uncountable/types/entity_t.py +125 -7
  231. uncountable/types/experiment_groups.py +0 -1
  232. uncountable/types/experiment_groups_t.py +5 -2
  233. uncountable/types/exports.py +8 -0
  234. uncountable/types/exports_t.py +34 -0
  235. uncountable/types/field_values.py +19 -1
  236. uncountable/types/field_values_t.py +246 -9
  237. uncountable/types/fields.py +0 -1
  238. uncountable/types/fields_t.py +5 -2
  239. uncountable/types/generic_upload.py +6 -1
  240. uncountable/types/generic_upload_t.py +88 -9
  241. uncountable/types/id_source.py +0 -1
  242. uncountable/types/id_source_t.py +26 -7
  243. uncountable/types/identifier.py +0 -1
  244. uncountable/types/identifier_t.py +13 -5
  245. uncountable/types/input_attributes.py +0 -1
  246. uncountable/types/input_attributes_t.py +4 -4
  247. uncountable/types/inputs.py +1 -1
  248. uncountable/types/inputs_t.py +24 -4
  249. uncountable/types/integration_server.py +8 -0
  250. uncountable/types/integration_server_t.py +46 -0
  251. uncountable/types/integration_session.py +10 -0
  252. uncountable/types/integration_session_t.py +60 -0
  253. uncountable/types/integrations.py +10 -0
  254. uncountable/types/integrations_t.py +62 -0
  255. uncountable/types/job_definition.py +4 -6
  256. uncountable/types/job_definition_t.py +96 -65
  257. uncountable/types/listing.py +9 -0
  258. uncountable/types/listing_t.py +51 -0
  259. uncountable/types/notices.py +8 -0
  260. uncountable/types/notices_t.py +37 -0
  261. uncountable/types/notifications.py +11 -0
  262. uncountable/types/notifications_t.py +74 -0
  263. uncountable/types/outputs.py +0 -1
  264. uncountable/types/outputs_t.py +6 -3
  265. uncountable/types/overrides.py +9 -0
  266. uncountable/types/overrides_t.py +49 -0
  267. uncountable/types/permissions.py +0 -1
  268. uncountable/types/permissions_t.py +1 -2
  269. uncountable/types/phases.py +0 -1
  270. uncountable/types/phases_t.py +5 -2
  271. uncountable/types/post_base.py +0 -1
  272. uncountable/types/post_base_t.py +1 -2
  273. uncountable/types/queued_job.py +17 -0
  274. uncountable/types/queued_job_t.py +140 -0
  275. uncountable/types/recipe_identifiers.py +0 -1
  276. uncountable/types/recipe_identifiers_t.py +21 -8
  277. uncountable/types/recipe_inputs.py +0 -1
  278. uncountable/types/recipe_inputs_t.py +1 -2
  279. uncountable/types/recipe_links.py +0 -1
  280. uncountable/types/recipe_links_t.py +7 -4
  281. uncountable/types/recipe_metadata.py +0 -1
  282. uncountable/types/recipe_metadata_t.py +14 -9
  283. uncountable/types/recipe_output_metadata.py +0 -1
  284. uncountable/types/recipe_output_metadata_t.py +5 -2
  285. uncountable/types/recipe_tags.py +0 -1
  286. uncountable/types/recipe_tags_t.py +5 -2
  287. uncountable/types/recipe_workflow_steps.py +0 -1
  288. uncountable/types/recipe_workflow_steps_t.py +14 -7
  289. uncountable/types/recipes.py +0 -1
  290. uncountable/types/recipes_t.py +6 -2
  291. uncountable/types/response.py +0 -1
  292. uncountable/types/response_t.py +3 -2
  293. uncountable/types/secret_retrieval.py +0 -1
  294. uncountable/types/secret_retrieval_t.py +13 -7
  295. uncountable/types/sockets.py +20 -0
  296. uncountable/types/sockets_t.py +169 -0
  297. uncountable/types/structured_filters.py +25 -0
  298. uncountable/types/structured_filters_t.py +248 -0
  299. uncountable/types/units.py +0 -1
  300. uncountable/types/units_t.py +5 -2
  301. uncountable/types/uploader.py +24 -0
  302. uncountable/types/uploader_t.py +222 -0
  303. uncountable/types/users.py +0 -1
  304. uncountable/types/users_t.py +5 -2
  305. uncountable/types/webhook_job.py +9 -0
  306. uncountable/types/webhook_job_t.py +48 -0
  307. uncountable/types/workflows.py +0 -1
  308. uncountable/types/workflows_t.py +10 -4
  309. uncountablepythonsdk-0.0.131.dist-info/METADATA +64 -0
  310. uncountablepythonsdk-0.0.131.dist-info/RECORD +363 -0
  311. {UncountablePythonSDK-0.0.52.dist-info → uncountablepythonsdk-0.0.131.dist-info}/WHEEL +1 -1
  312. UncountablePythonSDK-0.0.52.dist-info/METADATA +0 -56
  313. UncountablePythonSDK-0.0.52.dist-info/RECORD +0 -246
  314. docs/quickstart.md +0 -19
  315. uncountable/core/version.py +0 -11
  316. {UncountablePythonSDK-0.0.52.dist-info → uncountablepythonsdk-0.0.131.dist-info}/top_level.txt +0 -0
@@ -1,7 +1,8 @@
1
+ import datetime
1
2
  import io
2
3
  import os
3
4
  import re
4
- from datetime import datetime, timezone
5
+ from datetime import UTC
5
6
 
6
7
  import paramiko
7
8
 
@@ -10,12 +11,12 @@ from pkgs.filesystem_utils import (
10
11
  FileSystemFileReference,
11
12
  FileSystemObject,
12
13
  FileSystemS3Config,
14
+ FileSystemSession,
13
15
  FileSystemSFTPConfig,
14
16
  FileTransfer,
15
17
  S3Session,
16
18
  SFTPSession,
17
19
  )
18
- from pkgs.filesystem_utils.filesystem_session import FileSystemSession
19
20
  from uncountable.core.file_upload import DataFileUpload, FileUpload
20
21
  from uncountable.integration.job import Job, JobArguments
21
22
  from uncountable.integration.secret_retrieval import retrieve_secret
@@ -33,6 +34,27 @@ from uncountable.types.job_definition_t import (
33
34
  )
34
35
 
35
36
 
37
+ def _get_extension(filename: str) -> str | None:
38
+ _, ext = os.path.splitext(filename)
39
+ return ext.strip().lower()
40
+
41
+
42
+ def _run_keyword_detection(data: io.BytesIO, keyword: str) -> bool:
43
+ try:
44
+ text = io.TextIOWrapper(data, encoding="utf-8")
45
+ for line in text:
46
+ if (
47
+ keyword in line
48
+ or re.search(keyword, line, flags=re.IGNORECASE) is not None
49
+ ):
50
+ return True
51
+ return False
52
+ except re.error:
53
+ return False
54
+ except UnicodeError:
55
+ return False
56
+
57
+
36
58
  def _filter_files_by_keyword(
37
59
  remote_directory: GenericRemoteDirectoryScope,
38
60
  files: list[FileObjectData],
@@ -41,7 +63,20 @@ def _filter_files_by_keyword(
41
63
  if remote_directory.detection_keyword is None:
42
64
  return files
43
65
 
44
- raise NotImplementedError("keyword detection not implemented yet")
66
+ filtered_files = []
67
+
68
+ for file in files:
69
+ extension = _get_extension(file.filename)
70
+
71
+ if extension not in (".txt", ".csv"):
72
+ raise NotImplementedError(
73
+ "keyword detection is only supported for csv, txt files"
74
+ )
75
+
76
+ if _run_keyword_detection(file.file_IO, remote_directory.detection_keyword):
77
+ filtered_files.append(file)
78
+
79
+ return filtered_files
45
80
 
46
81
 
47
82
  def _filter_by_filename(
@@ -68,7 +103,8 @@ def _filter_by_file_extension(
68
103
  file
69
104
  for file in files
70
105
  if file.filename is not None
71
- and os.path.splitext(file.filename)[-1] in remote_directory.valid_file_extensions
106
+ and os.path.splitext(file.filename)[-1]
107
+ in remote_directory.valid_file_extensions
72
108
  ]
73
109
 
74
110
 
@@ -129,7 +165,7 @@ def _move_files_post_upload(
129
165
  appended_text = ""
130
166
 
131
167
  if remote_directory_scope.prepend_date_on_archive:
132
- appended_text = f"-{datetime.now(timezone.utc).timestamp()}"
168
+ appended_text = f"-{datetime.datetime.now(UTC).timestamp()}"
133
169
 
134
170
  for file_path in success_file_paths:
135
171
  filename = os.path.split(file_path)[-1]
@@ -164,7 +200,7 @@ def _move_files_post_upload(
164
200
  filesystem_session.move_files([*success_file_transfers, *failed_file_transfers])
165
201
 
166
202
 
167
- class GenericUploadJob(Job):
203
+ class GenericUploadJob(Job[None]):
168
204
  def __init__(
169
205
  self,
170
206
  data_source: GenericUploadDataSource,
@@ -176,19 +212,40 @@ class GenericUploadJob(Job):
176
212
  self.upload_strategy = upload_strategy
177
213
  self.data_source = data_source
178
214
 
215
+ @property
216
+ def payload_type(self) -> type[None]:
217
+ return type(None)
218
+
179
219
  def _construct_filesystem_session(self, args: JobArguments) -> FileSystemSession:
180
220
  match self.data_source:
181
221
  case GenericUploadDataSourceSFTP():
182
- pem_secret = retrieve_secret(
183
- self.data_source.pem_secret, profile_metadata=args.profile_metadata
184
- )
185
- pem_key = paramiko.RSAKey.from_private_key(io.StringIO(pem_secret))
186
- sftp_config = FileSystemSFTPConfig(
187
- ip=self.data_source.host,
188
- username=self.data_source.username,
189
- pem_path=None,
190
- pem_key=pem_key,
191
- )
222
+ if self.data_source.pem_secret is not None:
223
+ pem_secret = retrieve_secret(
224
+ self.data_source.pem_secret,
225
+ profile_metadata=args.profile_metadata,
226
+ )
227
+ pem_key = paramiko.RSAKey.from_private_key(io.StringIO(pem_secret))
228
+ sftp_config = FileSystemSFTPConfig(
229
+ ip=self.data_source.host,
230
+ username=self.data_source.username,
231
+ pem_path=None,
232
+ pem_key=pem_key,
233
+ )
234
+ elif self.data_source.password_secret is not None:
235
+ password_secret = retrieve_secret(
236
+ self.data_source.password_secret,
237
+ profile_metadata=args.profile_metadata,
238
+ )
239
+ sftp_config = FileSystemSFTPConfig(
240
+ ip=self.data_source.host,
241
+ username=self.data_source.username,
242
+ pem_path=None,
243
+ password=password_secret,
244
+ )
245
+ else:
246
+ raise ValueError(
247
+ "Either pem_secret or password_secret must be specified for sftp data source"
248
+ )
192
249
  return SFTPSession(sftp_config=sftp_config)
193
250
  case GenericUploadDataSourceS3():
194
251
  if self.data_source.access_key_secret is not None:
@@ -200,19 +257,17 @@ class GenericUploadJob(Job):
200
257
  secret_access_key = None
201
258
 
202
259
  if self.data_source.endpoint_url is None:
203
- assert (
204
- self.data_source.cloud_provider is not None
205
- ), "either cloud_provider or endpoint_url must be specified"
260
+ assert self.data_source.cloud_provider is not None, (
261
+ "either cloud_provider or endpoint_url must be specified"
262
+ )
206
263
  match self.data_source.cloud_provider:
207
264
  case S3CloudProvider.AWS:
208
265
  endpoint_url = "https://s3.amazonaws.com"
209
266
  case S3CloudProvider.OVH:
210
- assert (
211
- self.data_source.region_name is not None
212
- ), "region_name must be specified for cloud_provider OVH"
213
- endpoint_url = (
214
- f"https://s3.{self.data_source.region_name}.cloud.ovh.net"
267
+ assert self.data_source.region_name is not None, (
268
+ "region_name must be specified for cloud_provider OVH"
215
269
  )
270
+ endpoint_url = f"https://s3.{self.data_source.region_name}.cloud.ovh.net"
216
271
  else:
217
272
  endpoint_url = self.data_source.endpoint_url
218
273
 
@@ -227,7 +282,7 @@ class GenericUploadJob(Job):
227
282
 
228
283
  return S3Session(s3_config=s3_config)
229
284
 
230
- def run(self, args: JobArguments) -> JobResult:
285
+ def run_outer(self, args: JobArguments) -> JobResult:
231
286
  client = args.client
232
287
  batch_processor = args.batch_processor
233
288
  logger = args.logger
@@ -248,7 +303,8 @@ class GenericUploadJob(Job):
248
303
  for file_data in filtered_file_data:
249
304
  files_to_upload.append(
250
305
  DataFileUpload(
251
- data=io.BytesIO(file_data.file_data), name=file_data.filename
306
+ data=io.BytesIO(file_data.file_data),
307
+ name=file_data.filename,
252
308
  )
253
309
  )
254
310
  if not self.upload_strategy.skip_moving_files:
@@ -256,7 +312,9 @@ class GenericUploadJob(Job):
256
312
  filesystem_session=filesystem_session,
257
313
  remote_directory_scope=remote_directory,
258
314
  success_file_paths=[
259
- file.filepath if file.filepath is not None else file.filename
315
+ file.filepath
316
+ if file.filepath is not None
317
+ else file.filename
260
318
  for file in filtered_file_data
261
319
  ],
262
320
  # IMPROVE: use triggers/webhooks to mark failed files as failed
@@ -267,12 +325,12 @@ class GenericUploadJob(Job):
267
325
 
268
326
  file_ids = [file.file_id for file in uploaded_files]
269
327
 
270
- for material_family_key in self.upload_strategy.material_family_keys:
328
+ for destination in self.upload_strategy.destinations:
271
329
  for file_id in file_ids:
272
330
  batch_processor.invoke_uploader(
273
331
  file_id=file_id,
274
332
  uploader_key=self.upload_strategy.uploader_key,
275
- material_family_key=material_family_key,
333
+ destination=destination,
276
334
  )
277
335
 
278
336
  return JobResult(success=True)
@@ -19,7 +19,7 @@ def resolve_script_executor(
19
19
  for _, job_class in inspect.getmembers(job_module, inspect.isclass):
20
20
  if getattr(job_class, "_unc_job_registered", False):
21
21
  found_jobs.append(job_class())
22
- assert (
23
- len(found_jobs) == 1
24
- ), f"expected exactly one job class in {executor.import_path}, found {len(found_jobs)}"
22
+ assert len(found_jobs) == 1, (
23
+ f"expected exactly one job class in {executor.import_path}, found {len(found_jobs)}"
24
+ )
25
25
  return found_jobs[0]
@@ -0,0 +1,5 @@
1
+ # CLOSED MODULE
2
+
3
+ from .types import GenericHttpRequest as GenericHttpRequest
4
+ from .types import GenericHttpResponse as GenericHttpResponse
5
+ from .types import HttpException as HttpException
@@ -0,0 +1,69 @@
1
+ import base64
2
+ import functools
3
+ import json
4
+ from dataclasses import dataclass
5
+
6
+ from flask.wrappers import Response
7
+
8
+
9
+ class HttpException(Exception):
10
+ error_code: int
11
+ message: str
12
+
13
+ def __init__(self, *, error_code: int, message: str) -> None:
14
+ self.error_code = error_code
15
+ self.message = message
16
+
17
+ @staticmethod
18
+ def payload_failed_signature() -> "HttpException":
19
+ return HttpException(
20
+ error_code=401, message="webhook payload did not match signature"
21
+ )
22
+
23
+ @staticmethod
24
+ def no_signature_passed() -> "HttpException":
25
+ return HttpException(error_code=400, message="missing signature")
26
+
27
+ @staticmethod
28
+ def body_parse_error() -> "HttpException":
29
+ return HttpException(error_code=400, message="body parse error")
30
+
31
+ @staticmethod
32
+ def unknown_error() -> "HttpException":
33
+ return HttpException(error_code=500, message="internal server error")
34
+
35
+ @staticmethod
36
+ def configuration_error(
37
+ message: str = "internal configuration error",
38
+ ) -> "HttpException":
39
+ return HttpException(error_code=500, message=message)
40
+
41
+ def __str__(self) -> str:
42
+ return f"[{self.error_code}]: {self.message}"
43
+
44
+ def make_error_response(self) -> Response:
45
+ return Response(
46
+ status=self.error_code,
47
+ response=json.dumps({"error": {"message": str(self)}}),
48
+ )
49
+
50
+
51
+ @dataclass(kw_only=True, frozen=True)
52
+ class GenericHttpRequest:
53
+ body_base64: str
54
+ headers: dict[str, str]
55
+
56
+ @functools.cached_property
57
+ def body_bytes(self) -> bytes:
58
+ return base64.b64decode(self.body_base64)
59
+
60
+ @functools.cached_property
61
+ def body_text(self) -> str:
62
+ return self.body_bytes.decode()
63
+
64
+
65
+ @dataclass(kw_only=True)
66
+ class GenericHttpResponse:
67
+ response: str
68
+ status_code: int
69
+ headers: dict[str, str] | None = None
@@ -1,42 +1,272 @@
1
+ import functools
2
+ import hmac
3
+ import typing
1
4
  from abc import ABC, abstractmethod
2
5
  from dataclasses import dataclass
3
6
 
7
+ import simplejson
8
+
9
+ from pkgs.argument_parser import CachedParser
10
+ from pkgs.serialization_util import serialize_for_api
4
11
  from uncountable.core.async_batch import AsyncBatchProcessor
5
12
  from uncountable.core.client import Client
13
+ from uncountable.core.environment import get_local_admin_server_port
14
+ from uncountable.core.file_upload import FileUpload
15
+ from uncountable.core.types import AuthDetailsOAuth
16
+ from uncountable.integration.http_server import (
17
+ GenericHttpRequest,
18
+ GenericHttpResponse,
19
+ HttpException,
20
+ )
21
+ from uncountable.integration.queue_runner.command_server.command_client import (
22
+ send_job_queue_message,
23
+ )
24
+ from uncountable.integration.queue_runner.command_server.types import (
25
+ CommandServerException,
26
+ )
27
+ from uncountable.integration.secret_retrieval.retrieve_secret import retrieve_secret
6
28
  from uncountable.integration.telemetry import JobLogger
7
- from uncountable.types.job_definition_t import JobDefinition, JobResult, ProfileMetadata
29
+ from uncountable.types import (
30
+ base_t,
31
+ job_definition_t,
32
+ queued_job_t,
33
+ webhook_job_t,
34
+ )
35
+ from uncountable.types.job_definition_t import (
36
+ HttpJobDefinitionBase,
37
+ JobDefinition,
38
+ JobResult,
39
+ ProfileMetadata,
40
+ )
8
41
 
9
42
 
10
- @dataclass
11
- class JobArgumentsBase:
43
+ @dataclass(kw_only=True)
44
+ class JobArguments:
12
45
  job_definition: JobDefinition
13
46
  profile_metadata: ProfileMetadata
14
47
  client: Client
15
48
  batch_processor: AsyncBatchProcessor
16
49
  logger: JobLogger
50
+ payload: base_t.JsonValue
51
+ job_uuid: str
17
52
 
18
53
 
19
- @dataclass
20
- class CronJobArguments(JobArgumentsBase):
21
- # can imagine passing additional data such as in the sftp or webhook cases
22
- pass
54
+ # only for compatibility:
55
+ CronJobArguments = JobArguments
23
56
 
24
57
 
25
- JobArguments = CronJobArguments
58
+ class Job[PT](ABC):
59
+ _unc_job_registered: bool = False
26
60
 
61
+ @property
62
+ @abstractmethod
63
+ def payload_type(self) -> type[PT]: ...
27
64
 
28
- class Job(ABC):
29
- _unc_job_registered: bool = False
65
+ @abstractmethod
66
+ def run_outer(self, args: JobArguments) -> JobResult: ...
67
+
68
+ @functools.cached_property
69
+ def _cached_payload_parser(self) -> CachedParser[PT]:
70
+ return CachedParser(self.payload_type)
71
+
72
+ def get_payload(self, payload: base_t.JsonValue) -> PT:
73
+ return self._cached_payload_parser.parse_storage(payload)
74
+
75
+
76
+ class CronJob(Job):
77
+ @property
78
+ def payload_type(self) -> type[None]:
79
+ return type(None)
80
+
81
+ def run_outer(self, args: JobArguments) -> JobResult:
82
+ assert isinstance(args, CronJobArguments)
83
+ return self.run(args)
30
84
 
31
85
  @abstractmethod
32
86
  def run(self, args: JobArguments) -> JobResult: ...
33
87
 
34
88
 
35
- class CronJob(Job):
89
+ WPT = typing.TypeVar("WPT")
90
+
91
+
92
+ @dataclass(kw_only=True)
93
+ class WebhookResponse:
94
+ pass
95
+
96
+
97
+ class _RequestValidatorClient(Client):
98
+ def __init__(self, *, base_url: str, oauth_bearer_token: str):
99
+ super().__init__(
100
+ base_url=base_url,
101
+ auth_details=AuthDetailsOAuth(refresh_token=""),
102
+ config=None,
103
+ )
104
+ self._oauth_bearer_token = oauth_bearer_token
105
+
106
+ def _get_oauth_bearer_token(self, *, oauth_details: AuthDetailsOAuth) -> str:
107
+ return self._oauth_bearer_token
108
+
109
+
110
+ class CustomHttpJob(Job[GenericHttpRequest]):
111
+ @property
112
+ def payload_type(self) -> type[GenericHttpRequest]:
113
+ return GenericHttpRequest
114
+
115
+ @staticmethod
116
+ @abstractmethod
117
+ def validate_request(
118
+ *,
119
+ request: GenericHttpRequest,
120
+ job_definition: HttpJobDefinitionBase,
121
+ profile_meta: ProfileMetadata,
122
+ ) -> None:
123
+ """
124
+ Validate that the request is valid. If the request is invalid, raise an
125
+ exception.
126
+ """
127
+ ...
128
+
129
+ @staticmethod
130
+ def get_validated_oauth_request_user_id(
131
+ *, profile_metadata: ProfileMetadata, request: GenericHttpRequest
132
+ ) -> base_t.ObjectId:
133
+ token = request.headers.get("Authorization", "").replace("Bearer ", "")
134
+ if token == "":
135
+ raise HttpException(
136
+ message="unauthorized; no bearer token in request", error_code=401
137
+ )
138
+ return (
139
+ _RequestValidatorClient(
140
+ base_url=profile_metadata.base_url,
141
+ oauth_bearer_token=token,
142
+ )
143
+ .get_current_user_info()
144
+ .user_id
145
+ )
146
+
147
+ @staticmethod
148
+ @abstractmethod
149
+ def handle_request(
150
+ *,
151
+ request: GenericHttpRequest,
152
+ job_definition: HttpJobDefinitionBase,
153
+ profile_meta: ProfileMetadata,
154
+ ) -> GenericHttpResponse:
155
+ """
156
+ Handle the request synchronously. Normally this should just enqueue a job
157
+ and return immediately (see WebhookJob as an example).
158
+ """
159
+ ...
160
+
161
+ def run_outer(self, args: JobArguments) -> JobResult:
162
+ args.logger.log_warning(
163
+ message=f"Unexpected call to run_outer for CustomHttpJob: {args.job_definition.id}"
164
+ )
165
+ return JobResult(success=False)
166
+
167
+
168
+ class WebhookJob[WPT](Job[webhook_job_t.WebhookEventPayload]):
169
+ @property
170
+ def payload_type(self) -> type[webhook_job_t.WebhookEventPayload]:
171
+ return webhook_job_t.WebhookEventPayload
172
+
173
+ @property
36
174
  @abstractmethod
37
- def run(self, args: CronJobArguments) -> JobResult: ...
175
+ def webhook_payload_type(self) -> type[WPT]: ...
176
+
177
+ @staticmethod
178
+ def validate_request(
179
+ *,
180
+ request: GenericHttpRequest,
181
+ job_definition: job_definition_t.HttpJobDefinitionBase,
182
+ profile_meta: ProfileMetadata,
183
+ ) -> None:
184
+ assert isinstance(job_definition, job_definition_t.WebhookJobDefinition)
185
+ signature_key = retrieve_secret(
186
+ profile_metadata=profile_meta,
187
+ secret_retrieval=job_definition.signature_key_secret,
188
+ )
189
+ passed_signature = request.headers.get("Uncountable-Webhook-Signature")
190
+ if passed_signature is None:
191
+ raise HttpException.no_signature_passed()
192
+
193
+ request_body_signature = hmac.new(
194
+ signature_key.encode("utf-8"), msg=request.body_bytes, digestmod="sha256"
195
+ ).hexdigest()
196
+
197
+ if request_body_signature != passed_signature:
198
+ raise HttpException.payload_failed_signature()
199
+
200
+ @staticmethod
201
+ def handle_request(
202
+ *,
203
+ request: GenericHttpRequest,
204
+ job_definition: job_definition_t.HttpJobDefinitionBase,
205
+ profile_meta: ProfileMetadata, # noqa: ARG004
206
+ ) -> GenericHttpResponse:
207
+ try:
208
+ request_body = simplejson.loads(request.body_text)
209
+ webhook_payload = typing.cast(base_t.JsonValue, request_body)
210
+ except (simplejson.JSONDecodeError, ValueError) as e:
211
+ raise HttpException.body_parse_error() from e
212
+
213
+ try:
214
+ send_job_queue_message(
215
+ job_ref_name=job_definition.id,
216
+ payload=queued_job_t.QueuedJobPayload(
217
+ invocation_context=queued_job_t.InvocationContextWebhook(
218
+ webhook_payload=webhook_payload
219
+ )
220
+ ),
221
+ port=get_local_admin_server_port(),
222
+ )
223
+ except CommandServerException as e:
224
+ raise HttpException.unknown_error() from e
225
+
226
+ return GenericHttpResponse(
227
+ response=simplejson.dumps(serialize_for_api(WebhookResponse())),
228
+ status_code=200,
229
+ )
230
+
231
+ def run_outer(self, args: JobArguments) -> JobResult:
232
+ webhook_body = self.get_payload(args.payload)
233
+ inner_payload = CachedParser(self.webhook_payload_type).parse_api(
234
+ webhook_body.data
235
+ )
236
+ return self.run(args, inner_payload)
237
+
238
+ @abstractmethod
239
+ def run(self, args: JobArguments, payload: WPT) -> JobResult: ...
38
240
 
39
241
 
40
242
  def register_job(cls: type[Job]) -> type[Job]:
41
243
  cls._unc_job_registered = True
42
244
  return cls
245
+
246
+
247
+ class RunsheetWebhookJob(WebhookJob[webhook_job_t.RunsheetWebhookPayload]):
248
+ @property
249
+ def webhook_payload_type(self) -> type:
250
+ return webhook_job_t.RunsheetWebhookPayload
251
+
252
+ @abstractmethod
253
+ def build_runsheet(
254
+ self,
255
+ *,
256
+ args: JobArguments,
257
+ payload: webhook_job_t.RunsheetWebhookPayload,
258
+ ) -> FileUpload: ...
259
+
260
+ def run(
261
+ self, args: JobArguments, payload: webhook_job_t.RunsheetWebhookPayload
262
+ ) -> JobResult:
263
+ runsheet = self.build_runsheet(args=args, payload=payload)
264
+
265
+ files = args.client.upload_files(file_uploads=[runsheet])
266
+ args.client.complete_async_upload(
267
+ async_job_id=payload.async_job_id, file_id=files[0].file_id
268
+ )
269
+
270
+ return JobResult(
271
+ success=True,
272
+ )
File without changes
@@ -0,0 +1,28 @@
1
+ from .command_client import check_health, send_job_queue_message
2
+ from .command_server import serve
3
+ from .types import (
4
+ CommandEnqueueJob,
5
+ CommandEnqueueJobResponse,
6
+ CommandQueue,
7
+ CommandRetryJob,
8
+ CommandRetryJobResponse,
9
+ CommandServerBadResponse,
10
+ CommandServerException,
11
+ CommandServerTimeout,
12
+ CommandTask,
13
+ )
14
+
15
+ __all__: list[str] = [
16
+ "serve",
17
+ "check_health",
18
+ "send_job_queue_message",
19
+ "CommandEnqueueJob",
20
+ "CommandEnqueueJobResponse",
21
+ "CommandRetryJob",
22
+ "CommandRetryJobResponse",
23
+ "CommandTask",
24
+ "CommandQueue",
25
+ "CommandServerTimeout",
26
+ "CommandServerException",
27
+ "CommandServerBadResponse",
28
+ ]