UncountablePythonSDK 0.0.83__py3-none-any.whl → 0.0.132__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 (298) 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/basic_auth.py +7 -0
  9. examples/create_ingredient_sdk.py +34 -0
  10. examples/download_files.py +26 -0
  11. examples/integration-server/jobs/materials_auto/concurrent_cron.py +11 -0
  12. examples/integration-server/jobs/materials_auto/example_cron.py +3 -0
  13. examples/integration-server/jobs/materials_auto/example_http.py +47 -0
  14. examples/integration-server/jobs/materials_auto/example_instrument.py +100 -0
  15. examples/integration-server/jobs/materials_auto/example_parse.py +140 -0
  16. examples/integration-server/jobs/materials_auto/example_predictions.py +61 -0
  17. examples/integration-server/jobs/materials_auto/example_runsheet_wh.py +39 -0
  18. examples/integration-server/jobs/materials_auto/example_wh.py +17 -9
  19. examples/integration-server/jobs/materials_auto/profile.yaml +61 -0
  20. examples/integration-server/pyproject.toml +10 -10
  21. examples/oauth.py +7 -0
  22. examples/set_recipe_metadata_file.py +1 -1
  23. examples/upload_files.py +1 -2
  24. pkgs/argument_parser/__init__.py +8 -0
  25. pkgs/argument_parser/_is_namedtuple.py +3 -0
  26. pkgs/argument_parser/argument_parser.py +196 -63
  27. pkgs/filesystem_utils/__init__.py +1 -0
  28. pkgs/filesystem_utils/_blob_session.py +144 -0
  29. pkgs/filesystem_utils/_gdrive_session.py +5 -5
  30. pkgs/filesystem_utils/_s3_session.py +2 -1
  31. pkgs/filesystem_utils/_sftp_session.py +6 -3
  32. pkgs/filesystem_utils/file_type_utils.py +30 -10
  33. pkgs/serialization/__init__.py +7 -2
  34. pkgs/serialization/annotation.py +64 -0
  35. pkgs/serialization/missing_sentry.py +1 -1
  36. pkgs/serialization/opaque_key.py +1 -1
  37. pkgs/serialization/serial_alias.py +47 -0
  38. pkgs/serialization/serial_class.py +40 -48
  39. pkgs/serialization/serial_generic.py +16 -0
  40. pkgs/serialization/serial_union.py +16 -16
  41. pkgs/serialization_util/__init__.py +6 -0
  42. pkgs/serialization_util/dataclasses.py +14 -0
  43. pkgs/serialization_util/serialization_helpers.py +15 -5
  44. pkgs/type_spec/actions_registry/__main__.py +0 -4
  45. pkgs/type_spec/actions_registry/emit_typescript.py +2 -4
  46. pkgs/type_spec/builder.py +248 -70
  47. pkgs/type_spec/builder_types.py +9 -0
  48. pkgs/type_spec/config.py +40 -7
  49. pkgs/type_spec/cross_output_links.py +99 -0
  50. pkgs/type_spec/emit_open_api.py +121 -34
  51. pkgs/type_spec/emit_open_api_util.py +5 -5
  52. pkgs/type_spec/emit_python.py +277 -86
  53. pkgs/type_spec/emit_typescript.py +102 -29
  54. pkgs/type_spec/emit_typescript_util.py +66 -10
  55. pkgs/type_spec/load_types.py +16 -3
  56. pkgs/type_spec/non_discriminated_union_exceptions.py +14 -0
  57. pkgs/type_spec/open_api_util.py +29 -4
  58. pkgs/type_spec/parts/base.py.prepart +11 -8
  59. pkgs/type_spec/parts/base.ts.prepart +4 -0
  60. pkgs/type_spec/type_info/__main__.py +3 -1
  61. pkgs/type_spec/type_info/emit_type_info.py +115 -22
  62. pkgs/type_spec/ui_entry_actions/__init__.py +4 -0
  63. pkgs/type_spec/ui_entry_actions/generate_ui_entry_actions.py +308 -0
  64. pkgs/type_spec/util.py +3 -3
  65. pkgs/type_spec/value_spec/__main__.py +26 -9
  66. pkgs/type_spec/value_spec/convert_type.py +18 -0
  67. pkgs/type_spec/value_spec/emit_python.py +13 -3
  68. pkgs/type_spec/value_spec/types.py +1 -1
  69. uncountable/core/async_batch.py +1 -1
  70. uncountable/core/client.py +133 -34
  71. uncountable/core/environment.py +3 -3
  72. uncountable/core/file_upload.py +39 -15
  73. uncountable/integration/cli.py +116 -23
  74. uncountable/integration/construct_client.py +3 -3
  75. uncountable/integration/executors/executors.py +12 -2
  76. uncountable/integration/executors/generic_upload_executor.py +66 -14
  77. uncountable/integration/http_server/__init__.py +5 -0
  78. uncountable/integration/http_server/types.py +69 -0
  79. uncountable/integration/job.py +192 -7
  80. uncountable/integration/queue_runner/command_server/__init__.py +4 -0
  81. uncountable/integration/queue_runner/command_server/command_client.py +65 -0
  82. uncountable/integration/queue_runner/command_server/command_server.py +83 -5
  83. uncountable/integration/queue_runner/command_server/constants.py +4 -0
  84. uncountable/integration/queue_runner/command_server/protocol/command_server.proto +36 -0
  85. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.py +28 -11
  86. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.pyi +77 -1
  87. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2_grpc.py +135 -0
  88. uncountable/integration/queue_runner/command_server/types.py +25 -2
  89. uncountable/integration/queue_runner/datastore/datastore_sqlite.py +168 -11
  90. uncountable/integration/queue_runner/datastore/interface.py +10 -0
  91. uncountable/integration/queue_runner/datastore/model.py +8 -1
  92. uncountable/integration/queue_runner/job_scheduler.py +63 -23
  93. uncountable/integration/queue_runner/queue_runner.py +10 -2
  94. uncountable/integration/queue_runner/worker.py +3 -5
  95. uncountable/integration/scan_profiles.py +1 -1
  96. uncountable/integration/scheduler.py +74 -25
  97. uncountable/integration/secret_retrieval/retrieve_secret.py +1 -1
  98. uncountable/integration/server.py +42 -12
  99. uncountable/integration/telemetry.py +63 -10
  100. uncountable/integration/webhook_server/entrypoint.py +39 -112
  101. uncountable/types/__init__.py +58 -1
  102. uncountable/types/api/batch/execute_batch.py +5 -6
  103. uncountable/types/api/batch/execute_batch_load_async.py +2 -3
  104. uncountable/types/api/chemical/convert_chemical_formats.py +10 -5
  105. uncountable/types/api/condition_parameters/__init__.py +1 -0
  106. uncountable/types/api/condition_parameters/upsert_condition_match.py +72 -0
  107. uncountable/types/api/entity/create_entities.py +7 -7
  108. uncountable/types/api/entity/create_entity.py +8 -8
  109. uncountable/types/api/entity/create_or_update_entity.py +48 -0
  110. uncountable/types/api/entity/export_entities.py +59 -0
  111. uncountable/types/api/entity/get_entities_data.py +3 -4
  112. uncountable/types/api/entity/grant_entity_permissions.py +6 -6
  113. uncountable/types/api/entity/list_aggregate.py +79 -0
  114. uncountable/types/api/entity/list_entities.py +34 -10
  115. uncountable/types/api/entity/lock_entity.py +4 -4
  116. uncountable/types/api/entity/lookup_entity.py +116 -0
  117. uncountable/types/api/entity/resolve_entity_ids.py +5 -6
  118. uncountable/types/api/entity/set_entity_field_values.py +44 -0
  119. uncountable/types/api/entity/set_values.py +3 -3
  120. uncountable/types/api/entity/transition_entity_phase.py +14 -7
  121. uncountable/types/api/entity/unlock_entity.py +3 -3
  122. uncountable/types/api/equipment/associate_equipment_input.py +2 -3
  123. uncountable/types/api/field_options/upsert_field_options.py +7 -7
  124. uncountable/types/api/files/__init__.py +1 -0
  125. uncountable/types/api/files/download_file.py +77 -0
  126. uncountable/types/api/id_source/list_id_source.py +6 -7
  127. uncountable/types/api/id_source/match_id_source.py +4 -5
  128. uncountable/types/api/input_groups/get_input_group_names.py +3 -4
  129. uncountable/types/api/inputs/create_inputs.py +10 -9
  130. uncountable/types/api/inputs/get_input_data.py +11 -12
  131. uncountable/types/api/inputs/get_input_names.py +6 -7
  132. uncountable/types/api/inputs/get_inputs_data.py +6 -7
  133. uncountable/types/api/inputs/set_input_attribute_values.py +5 -6
  134. uncountable/types/api/inputs/set_input_category.py +5 -5
  135. uncountable/types/api/inputs/set_input_subcategories.py +3 -3
  136. uncountable/types/api/inputs/set_intermediate_type.py +4 -4
  137. uncountable/types/api/integrations/__init__.py +1 -0
  138. uncountable/types/api/integrations/publish_realtime_data.py +41 -0
  139. uncountable/types/api/integrations/push_notification.py +49 -0
  140. uncountable/types/api/integrations/register_sockets_token.py +41 -0
  141. uncountable/types/api/listing/__init__.py +1 -0
  142. uncountable/types/api/listing/fetch_listing.py +58 -0
  143. uncountable/types/api/material_families/update_entity_material_families.py +3 -4
  144. uncountable/types/api/notebooks/__init__.py +1 -0
  145. uncountable/types/api/notebooks/add_notebook_content.py +119 -0
  146. uncountable/types/api/outputs/get_output_data.py +12 -13
  147. uncountable/types/api/outputs/get_output_names.py +5 -6
  148. uncountable/types/api/outputs/get_output_organization.py +173 -0
  149. uncountable/types/api/outputs/resolve_output_conditions.py +7 -8
  150. uncountable/types/api/permissions/set_core_permissions.py +16 -10
  151. uncountable/types/api/project/get_projects.py +6 -7
  152. uncountable/types/api/project/get_projects_data.py +7 -8
  153. uncountable/types/api/recipe_links/create_recipe_link.py +5 -5
  154. uncountable/types/api/recipe_links/remove_recipe_link.py +4 -4
  155. uncountable/types/api/recipe_metadata/get_recipe_metadata_data.py +6 -7
  156. uncountable/types/api/recipes/add_recipe_to_project.py +3 -3
  157. uncountable/types/api/recipes/add_time_series_data.py +64 -0
  158. uncountable/types/api/recipes/archive_recipes.py +4 -4
  159. uncountable/types/api/recipes/associate_recipe_as_input.py +5 -5
  160. uncountable/types/api/recipes/associate_recipe_as_lot.py +3 -3
  161. uncountable/types/api/recipes/clear_recipe_outputs.py +3 -3
  162. uncountable/types/api/recipes/create_mix_order.py +44 -0
  163. uncountable/types/api/recipes/create_recipe.py +8 -9
  164. uncountable/types/api/recipes/create_recipes.py +8 -9
  165. uncountable/types/api/recipes/disassociate_recipe_as_input.py +3 -3
  166. uncountable/types/api/recipes/edit_recipe_inputs.py +101 -24
  167. uncountable/types/api/recipes/get_column_calculation_values.py +4 -5
  168. uncountable/types/api/recipes/get_curve.py +4 -5
  169. uncountable/types/api/recipes/get_recipe_calculations.py +6 -7
  170. uncountable/types/api/recipes/get_recipe_links.py +3 -4
  171. uncountable/types/api/recipes/get_recipe_names.py +3 -4
  172. uncountable/types/api/recipes/get_recipe_output_metadata.py +5 -6
  173. uncountable/types/api/recipes/get_recipes_data.py +62 -34
  174. uncountable/types/api/recipes/lock_recipes.py +9 -8
  175. uncountable/types/api/recipes/remove_recipe_from_project.py +3 -3
  176. uncountable/types/api/recipes/set_recipe_inputs.py +9 -10
  177. uncountable/types/api/recipes/set_recipe_metadata.py +3 -3
  178. uncountable/types/api/recipes/set_recipe_output_annotations.py +11 -12
  179. uncountable/types/api/recipes/set_recipe_output_file.py +5 -6
  180. uncountable/types/api/recipes/set_recipe_outputs.py +24 -13
  181. uncountable/types/api/recipes/set_recipe_tags.py +14 -9
  182. uncountable/types/api/recipes/set_recipe_total.py +59 -0
  183. uncountable/types/api/recipes/unarchive_recipes.py +3 -3
  184. uncountable/types/api/recipes/unlock_recipes.py +7 -6
  185. uncountable/types/api/runsheet/__init__.py +1 -0
  186. uncountable/types/api/runsheet/complete_async_upload.py +41 -0
  187. uncountable/types/api/triggers/run_trigger.py +4 -4
  188. uncountable/types/api/uploader/complete_async_parse.py +46 -0
  189. uncountable/types/api/uploader/invoke_uploader.py +4 -5
  190. uncountable/types/api/user/__init__.py +1 -0
  191. uncountable/types/api/user/get_current_user_info.py +40 -0
  192. uncountable/types/async_batch.py +1 -1
  193. uncountable/types/async_batch_processor.py +506 -23
  194. uncountable/types/async_batch_t.py +35 -8
  195. uncountable/types/async_jobs.py +0 -1
  196. uncountable/types/async_jobs_t.py +1 -2
  197. uncountable/types/auth_retrieval.py +0 -1
  198. uncountable/types/auth_retrieval_t.py +6 -6
  199. uncountable/types/base.py +0 -1
  200. uncountable/types/base_t.py +11 -9
  201. uncountable/types/calculations.py +0 -1
  202. uncountable/types/calculations_t.py +1 -2
  203. uncountable/types/chemical_structure.py +0 -1
  204. uncountable/types/chemical_structure_t.py +5 -5
  205. uncountable/types/client_base.py +614 -69
  206. uncountable/types/client_config.py +1 -1
  207. uncountable/types/client_config_t.py +13 -3
  208. uncountable/types/curves.py +0 -1
  209. uncountable/types/curves_t.py +6 -7
  210. uncountable/types/data.py +12 -0
  211. uncountable/types/data_t.py +103 -0
  212. uncountable/types/entity.py +1 -1
  213. uncountable/types/entity_t.py +90 -10
  214. uncountable/types/experiment_groups.py +0 -1
  215. uncountable/types/experiment_groups_t.py +1 -2
  216. uncountable/types/exports.py +8 -0
  217. uncountable/types/exports_t.py +34 -0
  218. uncountable/types/field_values.py +19 -1
  219. uncountable/types/field_values_t.py +242 -9
  220. uncountable/types/fields.py +0 -1
  221. uncountable/types/fields_t.py +1 -2
  222. uncountable/types/generic_upload.py +0 -1
  223. uncountable/types/generic_upload_t.py +14 -14
  224. uncountable/types/id_source.py +0 -1
  225. uncountable/types/id_source_t.py +13 -7
  226. uncountable/types/identifier.py +0 -1
  227. uncountable/types/identifier_t.py +10 -5
  228. uncountable/types/input_attributes.py +0 -1
  229. uncountable/types/input_attributes_t.py +3 -4
  230. uncountable/types/inputs.py +0 -1
  231. uncountable/types/inputs_t.py +3 -4
  232. uncountable/types/integration_server.py +0 -1
  233. uncountable/types/integration_server_t.py +13 -4
  234. uncountable/types/integration_session.py +10 -0
  235. uncountable/types/integration_session_t.py +60 -0
  236. uncountable/types/integrations.py +10 -0
  237. uncountable/types/integrations_t.py +62 -0
  238. uncountable/types/job_definition.py +2 -1
  239. uncountable/types/job_definition_t.py +57 -32
  240. uncountable/types/listing.py +9 -0
  241. uncountable/types/listing_t.py +51 -0
  242. uncountable/types/notices.py +8 -0
  243. uncountable/types/notices_t.py +37 -0
  244. uncountable/types/notifications.py +11 -0
  245. uncountable/types/notifications_t.py +74 -0
  246. uncountable/types/outputs.py +0 -1
  247. uncountable/types/outputs_t.py +2 -3
  248. uncountable/types/overrides.py +0 -1
  249. uncountable/types/overrides_t.py +10 -4
  250. uncountable/types/permissions.py +0 -1
  251. uncountable/types/permissions_t.py +1 -2
  252. uncountable/types/phases.py +0 -1
  253. uncountable/types/phases_t.py +1 -2
  254. uncountable/types/post_base.py +0 -1
  255. uncountable/types/post_base_t.py +1 -2
  256. uncountable/types/queued_job.py +2 -1
  257. uncountable/types/queued_job_t.py +29 -12
  258. uncountable/types/recipe_identifiers.py +0 -1
  259. uncountable/types/recipe_identifiers_t.py +18 -8
  260. uncountable/types/recipe_inputs.py +0 -1
  261. uncountable/types/recipe_inputs_t.py +1 -2
  262. uncountable/types/recipe_links.py +0 -1
  263. uncountable/types/recipe_links_t.py +3 -4
  264. uncountable/types/recipe_metadata.py +0 -1
  265. uncountable/types/recipe_metadata_t.py +9 -10
  266. uncountable/types/recipe_output_metadata.py +0 -1
  267. uncountable/types/recipe_output_metadata_t.py +1 -2
  268. uncountable/types/recipe_tags.py +0 -1
  269. uncountable/types/recipe_tags_t.py +1 -2
  270. uncountable/types/recipe_workflow_steps.py +0 -1
  271. uncountable/types/recipe_workflow_steps_t.py +7 -7
  272. uncountable/types/recipes.py +0 -1
  273. uncountable/types/recipes_t.py +2 -2
  274. uncountable/types/response.py +0 -1
  275. uncountable/types/response_t.py +2 -2
  276. uncountable/types/secret_retrieval.py +0 -1
  277. uncountable/types/secret_retrieval_t.py +7 -7
  278. uncountable/types/sockets.py +20 -0
  279. uncountable/types/sockets_t.py +169 -0
  280. uncountable/types/structured_filters.py +25 -0
  281. uncountable/types/structured_filters_t.py +248 -0
  282. uncountable/types/units.py +0 -1
  283. uncountable/types/units_t.py +1 -2
  284. uncountable/types/uploader.py +24 -0
  285. uncountable/types/uploader_t.py +222 -0
  286. uncountable/types/users.py +0 -1
  287. uncountable/types/users_t.py +1 -2
  288. uncountable/types/webhook_job.py +1 -1
  289. uncountable/types/webhook_job_t.py +14 -3
  290. uncountable/types/workflows.py +0 -1
  291. uncountable/types/workflows_t.py +3 -4
  292. uncountablepythonsdk-0.0.132.dist-info/METADATA +64 -0
  293. uncountablepythonsdk-0.0.132.dist-info/RECORD +363 -0
  294. {UncountablePythonSDK-0.0.83.dist-info → uncountablepythonsdk-0.0.132.dist-info}/WHEEL +1 -1
  295. UncountablePythonSDK-0.0.83.dist-info/METADATA +0 -60
  296. UncountablePythonSDK-0.0.83.dist-info/RECORD +0 -292
  297. docs/quickstart.md +0 -19
  298. {UncountablePythonSDK-0.0.83.dist-info → uncountablepythonsdk-0.0.132.dist-info}/top_level.txt +0 -0
@@ -9,6 +9,7 @@ from uncountable.types import (
9
9
  entity_t,
10
10
  field_values_t,
11
11
  identifier_t,
12
+ integration_server_t,
12
13
  job_definition_t,
13
14
  transition_entity_phase_t,
14
15
  )
@@ -33,7 +34,10 @@ def resolve_executor(
33
34
 
34
35
 
35
36
  def _create_run_entity(
36
- *, client: Client, logging_settings: job_definition_t.JobLoggingSettings
37
+ *,
38
+ client: Client,
39
+ logging_settings: job_definition_t.JobLoggingSettings,
40
+ job_uuid: str,
37
41
  ) -> entity_t.Entity:
38
42
  run_entity = client.create_entity(
39
43
  entity_type=entity_t.EntityType.ASYNC_JOB,
@@ -49,6 +53,10 @@ def _create_run_entity(
49
53
  field_ref_name=async_jobs_t.ASYNC_JOB_STATUS_FIELD_REF_NAME,
50
54
  value=async_jobs_t.AsyncJobStatus.IN_PROGRESS,
51
55
  ),
56
+ field_values_t.FieldRefNameValue(
57
+ field_ref_name=integration_server_t.INTEGRATION_SERVER_RUN_UUID_FIELD_REF_NAME,
58
+ value=job_uuid,
59
+ ),
52
60
  ],
53
61
  ).entity
54
62
  client.transition_entity_phase(
@@ -93,7 +101,9 @@ def execute_job(
93
101
  and job_definition.logging_settings.enabled
94
102
  ):
95
103
  run_entity = _create_run_entity(
96
- client=args.client, logging_settings=job_definition.logging_settings
104
+ client=args.client,
105
+ logging_settings=job_definition.logging_settings,
106
+ job_uuid=args.job_uuid,
97
107
  )
98
108
  result = job.run_outer(args=args)
99
109
  except Exception as e:
@@ -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(
@@ -130,7 +165,7 @@ def _move_files_post_upload(
130
165
  appended_text = ""
131
166
 
132
167
  if remote_directory_scope.prepend_date_on_archive:
133
- appended_text = f"-{datetime.now(timezone.utc).timestamp()}"
168
+ appended_text = f"-{datetime.datetime.now(UTC).timestamp()}"
134
169
 
135
170
  for file_path in success_file_paths:
136
171
  filename = os.path.split(file_path)[-1]
@@ -184,16 +219,33 @@ class GenericUploadJob(Job[None]):
184
219
  def _construct_filesystem_session(self, args: JobArguments) -> FileSystemSession:
185
220
  match self.data_source:
186
221
  case GenericUploadDataSourceSFTP():
187
- pem_secret = retrieve_secret(
188
- self.data_source.pem_secret, profile_metadata=args.profile_metadata
189
- )
190
- pem_key = paramiko.RSAKey.from_private_key(io.StringIO(pem_secret))
191
- sftp_config = FileSystemSFTPConfig(
192
- ip=self.data_source.host,
193
- username=self.data_source.username,
194
- pem_path=None,
195
- pem_key=pem_key,
196
- )
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
+ )
197
249
  return SFTPSession(sftp_config=sftp_config)
198
250
  case GenericUploadDataSourceS3():
199
251
  if self.data_source.access_key_secret is not None:
@@ -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,14 +1,43 @@
1
1
  import functools
2
+ import hmac
2
3
  import typing
3
4
  from abc import ABC, abstractmethod
4
5
  from dataclasses import dataclass
5
6
 
7
+ import simplejson
8
+
6
9
  from pkgs.argument_parser import CachedParser
10
+ from pkgs.serialization_util import serialize_for_api
7
11
  from uncountable.core.async_batch import AsyncBatchProcessor
8
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
9
28
  from uncountable.integration.telemetry import JobLogger
10
- from uncountable.types import base_t, webhook_job_t
11
- 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
+ )
12
41
 
13
42
 
14
43
  @dataclass(kw_only=True)
@@ -19,16 +48,14 @@ class JobArguments:
19
48
  batch_processor: AsyncBatchProcessor
20
49
  logger: JobLogger
21
50
  payload: base_t.JsonValue
51
+ job_uuid: str
22
52
 
23
53
 
24
54
  # only for compatibility:
25
55
  CronJobArguments = JobArguments
26
56
 
27
57
 
28
- PT = typing.TypeVar("PT")
29
-
30
-
31
- class Job(ABC, typing.Generic[PT]):
58
+ class Job[PT](ABC):
32
59
  _unc_job_registered: bool = False
33
60
 
34
61
  @property
@@ -62,7 +89,83 @@ class CronJob(Job):
62
89
  WPT = typing.TypeVar("WPT")
63
90
 
64
91
 
65
- class WebhookJob(Job[webhook_job_t.WebhookEventPayload], typing.Generic[WPT]):
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]):
66
169
  @property
67
170
  def payload_type(self) -> type[webhook_job_t.WebhookEventPayload]:
68
171
  return webhook_job_t.WebhookEventPayload
@@ -71,6 +174,60 @@ class WebhookJob(Job[webhook_job_t.WebhookEventPayload], typing.Generic[WPT]):
71
174
  @abstractmethod
72
175
  def webhook_payload_type(self) -> type[WPT]: ...
73
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
+
74
231
  def run_outer(self, args: JobArguments) -> JobResult:
75
232
  webhook_body = self.get_payload(args.payload)
76
233
  inner_payload = CachedParser(self.webhook_payload_type).parse_api(
@@ -85,3 +242,31 @@ class WebhookJob(Job[webhook_job_t.WebhookEventPayload], typing.Generic[WPT]):
85
242
  def register_job(cls: type[Job]) -> type[Job]:
86
243
  cls._unc_job_registered = True
87
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
+ )
@@ -4,6 +4,8 @@ from .types import (
4
4
  CommandEnqueueJob,
5
5
  CommandEnqueueJobResponse,
6
6
  CommandQueue,
7
+ CommandRetryJob,
8
+ CommandRetryJobResponse,
7
9
  CommandServerBadResponse,
8
10
  CommandServerException,
9
11
  CommandServerTimeout,
@@ -16,6 +18,8 @@ __all__: list[str] = [
16
18
  "send_job_queue_message",
17
19
  "CommandEnqueueJob",
18
20
  "CommandEnqueueJobResponse",
21
+ "CommandRetryJob",
22
+ "CommandRetryJobResponse",
19
23
  "CommandTask",
20
24
  "CommandQueue",
21
25
  "CommandServerTimeout",
@@ -10,6 +10,12 @@ from uncountable.integration.queue_runner.command_server.protocol.command_server
10
10
  CheckHealthResult,
11
11
  EnqueueJobRequest,
12
12
  EnqueueJobResult,
13
+ ListQueuedJobsRequest,
14
+ ListQueuedJobsResult,
15
+ RetryJobRequest,
16
+ RetryJobResult,
17
+ VaccuumQueuedJobsRequest,
18
+ VaccuumQueuedJobsResult,
13
19
  )
14
20
  from uncountable.integration.queue_runner.command_server.types import (
15
21
  CommandServerBadResponse,
@@ -57,6 +63,26 @@ def send_job_queue_message(
57
63
  return response.queued_job_uuid
58
64
 
59
65
 
66
+ def send_retry_job_message(
67
+ *,
68
+ job_uuid: str,
69
+ host: str = "localhost",
70
+ port: int,
71
+ ) -> str:
72
+ with command_server_connection(host=host, port=port) as stub:
73
+ request = RetryJobRequest(uuid=job_uuid)
74
+
75
+ try:
76
+ response = stub.RetryJob(request, timeout=_DEFAULT_MESSAGE_TIMEOUT_SECS)
77
+ assert isinstance(response, RetryJobResult)
78
+ if not response.successfully_queued:
79
+ raise CommandServerBadResponse("queue operation was not successful")
80
+
81
+ return response.queued_job_uuid
82
+ except grpc.RpcError as e:
83
+ raise ValueError(e.details()) # type: ignore
84
+
85
+
60
86
  def check_health(*, host: str = _LOCAL_RPC_HOST, port: int) -> bool:
61
87
  with command_server_connection(host=host, port=port) as stub:
62
88
  request = CheckHealthRequest()
@@ -66,3 +92,42 @@ def check_health(*, host: str = _LOCAL_RPC_HOST, port: int) -> bool:
66
92
  assert isinstance(response, CheckHealthResult)
67
93
 
68
94
  return response.success
95
+
96
+
97
+ def send_list_queued_jobs_message(
98
+ *,
99
+ offset: int,
100
+ limit: int,
101
+ host: str = "localhost",
102
+ port: int,
103
+ ) -> list[ListQueuedJobsResult.ListQueuedJobsResultItem]:
104
+ with command_server_connection(host=host, port=port) as stub:
105
+ request = ListQueuedJobsRequest(
106
+ offset=offset,
107
+ limit=limit,
108
+ )
109
+
110
+ try:
111
+ response = stub.ListQueuedJobs(
112
+ request, timeout=_DEFAULT_MESSAGE_TIMEOUT_SECS
113
+ )
114
+ except grpc.RpcError as e:
115
+ raise ValueError(e.details()) # type: ignore
116
+
117
+ assert isinstance(response, ListQueuedJobsResult)
118
+ return list(response.queued_jobs)
119
+
120
+
121
+ def send_vaccuum_queued_jobs_message(*, host: str = "localhost", port: int) -> None:
122
+ with command_server_connection(host=host, port=port) as stub:
123
+ request = VaccuumQueuedJobsRequest()
124
+
125
+ try:
126
+ response = stub.VaccuumQueuedJobs(
127
+ request, timeout=_DEFAULT_MESSAGE_TIMEOUT_SECS
128
+ )
129
+ except grpc.RpcError as e:
130
+ raise ValueError(e.details()) # type: ignore
131
+
132
+ assert isinstance(response, VaccuumQueuedJobsResult)
133
+ return None