UncountablePythonSDK 0.0.82__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 +22 -17
  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.82.dist-info → uncountablepythonsdk-0.0.132.dist-info}/WHEEL +1 -1
  295. UncountablePythonSDK-0.0.82.dist-info/METADATA +0 -60
  296. UncountablePythonSDK-0.0.82.dist-info/RECORD +0 -292
  297. docs/quickstart.md +0 -19
  298. {UncountablePythonSDK-0.0.82.dist-info → uncountablepythonsdk-0.0.132.dist-info}/top_level.txt +0 -0
@@ -1,9 +1,11 @@
1
+ import datetime
1
2
  import multiprocessing
2
3
  import subprocess
3
4
  import sys
4
5
  import time
5
6
  from dataclasses import dataclass
6
- from datetime import datetime, timezone
7
+ from datetime import UTC
8
+ from enum import StrEnum
7
9
 
8
10
  from opentelemetry.trace import get_current_span
9
11
 
@@ -18,11 +20,19 @@ from uncountable.integration.telemetry import Logger
18
20
 
19
21
  SHUTDOWN_TIMEOUT_SECS = 30
20
22
 
23
+ AnyProcess = multiprocessing.Process | subprocess.Popen[bytes]
24
+
25
+
26
+ class ProcessName(StrEnum):
27
+ QUEUE_RUNNER = "queue_runner"
28
+ CRON_SERVER = "cron_server"
29
+ UWSGI = "uwsgi"
30
+
21
31
 
22
32
  @dataclass(kw_only=True)
23
33
  class ProcessInfo:
24
- name: str
25
- process: multiprocessing.Process | subprocess.Popen[bytes]
34
+ name: ProcessName
35
+ process: AnyProcess
26
36
 
27
37
  @property
28
38
  def is_alive(self) -> bool:
@@ -45,14 +55,14 @@ class ProcessInfo:
45
55
  return self.process.poll()
46
56
 
47
57
 
48
- def handle_shutdown(logger: Logger, processes: list[ProcessInfo]) -> None:
58
+ def handle_shutdown(logger: Logger, processes: dict[ProcessName, ProcessInfo]) -> None:
49
59
  logger.log_info("received shutdown command, shutting down sub-processes")
50
- for proc_info in processes:
60
+ for proc_info in processes.values():
51
61
  if proc_info.is_alive:
52
62
  proc_info.process.terminate()
53
63
 
54
64
  shutdown_start = time.time()
55
- still_living_processes = processes
65
+ still_living_processes = list(processes.values())
56
66
  while (
57
67
  time.time() - shutdown_start < SHUTDOWN_TIMEOUT_SECS
58
68
  and len(still_living_processes) > 0
@@ -81,46 +91,84 @@ def handle_shutdown(logger: Logger, processes: list[ProcessInfo]) -> None:
81
91
  proc_info.process.kill()
82
92
 
83
93
 
84
- def check_process_alive(logger: Logger, processes: list[ProcessInfo]) -> None:
85
- for proc_info in processes:
94
+ def restart_process(
95
+ logger: Logger, proc_info: ProcessInfo, processes: dict[ProcessName, ProcessInfo]
96
+ ) -> None:
97
+ logger.log_error(
98
+ f"process {proc_info.name} shut down unexpectedly - exit code {proc_info.exitcode}. Restarting..."
99
+ )
100
+
101
+ match proc_info.name:
102
+ case ProcessName.QUEUE_RUNNER:
103
+ queue_proc = multiprocessing.Process(target=start_queue_runner)
104
+ queue_proc.start()
105
+ new_info = ProcessInfo(name=ProcessName.QUEUE_RUNNER, process=queue_proc)
106
+ processes[ProcessName.QUEUE_RUNNER] = new_info
107
+ try:
108
+ _wait_queue_runner_online()
109
+ logger.log_info("queue runner restarted successfully")
110
+ except Exception as e:
111
+ logger.log_exception(e)
112
+ logger.log_error(
113
+ "queue runner failed to restart, shutting down scheduler"
114
+ )
115
+ handle_shutdown(logger, processes)
116
+ sys.exit(1)
117
+
118
+ case ProcessName.CRON_SERVER:
119
+ cron_proc = multiprocessing.Process(target=cron_target)
120
+ cron_proc.start()
121
+ new_info = ProcessInfo(name=ProcessName.CRON_SERVER, process=cron_proc)
122
+ processes[ProcessName.CRON_SERVER] = new_info
123
+ logger.log_info("cron server restarted successfully")
124
+
125
+ case ProcessName.UWSGI:
126
+ uwsgi_proc: AnyProcess = subprocess.Popen(["uwsgi", "--die-on-term"])
127
+ new_info = ProcessInfo(name=ProcessName.UWSGI, process=uwsgi_proc)
128
+ processes[ProcessName.UWSGI] = new_info
129
+ logger.log_info("uwsgi restarted successfully")
130
+
131
+
132
+ def check_process_alive(
133
+ logger: Logger, processes: dict[ProcessName, ProcessInfo]
134
+ ) -> None:
135
+ for proc_info in processes.values():
86
136
  if not proc_info.is_alive:
87
- logger.log_error(
88
- f"process {proc_info.name} shut down unexpectedly! shutting down scheduler; exit code is {proc_info.exitcode}"
89
- )
90
- handle_shutdown(logger, processes)
91
- sys.exit(1)
137
+ restart_process(logger, proc_info, processes)
92
138
 
93
139
 
94
140
  def _wait_queue_runner_online() -> None:
95
- _MAX_QUEUE_RUNNER_HEALTH_CHECKS = 10
96
- _QUEUE_RUNNER_HEALTH_CHECK_DELAY_SECS = 1
141
+ MAX_QUEUE_RUNNER_HEALTH_CHECKS = 10
142
+ QUEUE_RUNNER_HEALTH_CHECK_DELAY_SECS = 1
97
143
 
98
144
  num_attempts = 0
99
- before = datetime.now(timezone.utc)
100
- while num_attempts < _MAX_QUEUE_RUNNER_HEALTH_CHECKS:
145
+ before = datetime.datetime.now(UTC)
146
+ while num_attempts < MAX_QUEUE_RUNNER_HEALTH_CHECKS:
101
147
  try:
102
148
  if check_health(port=get_local_admin_server_port()):
103
149
  return
104
150
  except CommandServerTimeout:
105
151
  pass
106
152
  num_attempts += 1
107
- time.sleep(_QUEUE_RUNNER_HEALTH_CHECK_DELAY_SECS)
108
- after = datetime.now(timezone.utc)
153
+ time.sleep(QUEUE_RUNNER_HEALTH_CHECK_DELAY_SECS)
154
+ after = datetime.datetime.now(UTC)
109
155
  duration_secs = (after - before).seconds
110
156
  raise Exception(f"queue runner failed to come online after {duration_secs} seconds")
111
157
 
112
158
 
113
159
  def main() -> None:
114
160
  logger = Logger(get_current_span())
115
- processes: list[ProcessInfo] = []
161
+ processes: dict[ProcessName, ProcessInfo] = {}
162
+
163
+ multiprocessing.set_start_method("forkserver")
116
164
 
117
165
  def add_process(process: ProcessInfo) -> None:
118
- processes.append(process)
166
+ processes[process.name] = process
119
167
  logger.log_info(f"started process {process.name}")
120
168
 
121
169
  runner_process = multiprocessing.Process(target=start_queue_runner)
122
170
  runner_process.start()
123
- add_process(ProcessInfo(name="queue runner", process=runner_process))
171
+ add_process(ProcessInfo(name=ProcessName.QUEUE_RUNNER, process=runner_process))
124
172
 
125
173
  try:
126
174
  _wait_queue_runner_online()
@@ -131,13 +179,13 @@ def main() -> None:
131
179
 
132
180
  cron_process = multiprocessing.Process(target=cron_target)
133
181
  cron_process.start()
134
- add_process(ProcessInfo(name="cron server", process=cron_process))
182
+ add_process(ProcessInfo(name=ProcessName.CRON_SERVER, process=cron_process))
135
183
 
136
184
  uwsgi_process = subprocess.Popen([
137
185
  "uwsgi",
138
186
  "--die-on-term",
139
187
  ])
140
- add_process(ProcessInfo(name="uwsgi", process=uwsgi_process))
188
+ add_process(ProcessInfo(name=ProcessName.UWSGI, process=uwsgi_process))
141
189
 
142
190
  try:
143
191
  while True:
@@ -147,4 +195,5 @@ def main() -> None:
147
195
  handle_shutdown(logger, processes=processes)
148
196
 
149
197
 
150
- main()
198
+ if __name__ == "__main__":
199
+ main()
@@ -54,7 +54,7 @@ def _load_secret_overrides(profile_name: str) -> dict[SecretRetrieval, str]:
54
54
  profiles_module = os.environ["UNC_PROFILES_MODULE"]
55
55
  try:
56
56
  overrides = overrides_parser.parse_yaml_resource(
57
- package=".".join([profiles_module, profile_name]),
57
+ package=f"{profiles_module}.{profile_name}",
58
58
  resource="local_overrides.yaml",
59
59
  )
60
60
  return {
@@ -1,7 +1,7 @@
1
1
  import signal
2
2
  from dataclasses import asdict
3
3
  from types import TracebackType
4
- from typing import Optional, assert_never
4
+ from typing import assert_never
5
5
 
6
6
  from apscheduler.executors.pool import ThreadPoolExecutor
7
7
  from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
@@ -11,16 +11,28 @@ from apscheduler.triggers.cron import CronTrigger
11
11
  from opentelemetry.trace import get_current_span
12
12
  from sqlalchemy.engine.base import Engine
13
13
 
14
+ from uncountable.core.environment import get_local_admin_server_port
14
15
  from uncountable.integration.cron import CronJobArgs, cron_job_executor
16
+ from uncountable.integration.queue_runner.command_server.command_client import (
17
+ send_vaccuum_queued_jobs_message,
18
+ )
15
19
  from uncountable.integration.telemetry import Logger
16
20
  from uncountable.types import base_t, job_definition_t
17
21
  from uncountable.types.job_definition_t import (
18
22
  CronJobDefinition,
19
- WebhookJobDefinition,
23
+ HttpJobDefinitionBase,
20
24
  )
21
25
 
22
26
  _MAX_APSCHEDULER_CONCURRENT_JOBS = 1
23
27
 
28
+ VACCUUM_QUEUED_JOBS_JOB_ID = "vacuum_queued_jobs"
29
+
30
+ STATIC_JOB_IDS = {VACCUUM_QUEUED_JOBS_JOB_ID}
31
+
32
+
33
+ def vaccuum_queued_jobs() -> None:
34
+ send_vaccuum_queued_jobs_message(port=get_local_admin_server_port())
35
+
24
36
 
25
37
  class IntegrationServer:
26
38
  _scheduler: BaseScheduler
@@ -36,11 +48,27 @@ class IntegrationServer:
36
48
  )
37
49
  self._server_logger = Logger(get_current_span())
38
50
 
51
+ def _register_static_jobs(self) -> None:
52
+ all_job_ids = {job.id for job in self._scheduler.get_jobs()}
53
+ if VACCUUM_QUEUED_JOBS_JOB_ID in all_job_ids:
54
+ self._scheduler.remove_job(VACCUUM_QUEUED_JOBS_JOB_ID)
55
+
56
+ self._scheduler.add_job(
57
+ vaccuum_queued_jobs,
58
+ max_instances=1,
59
+ coalesce=True,
60
+ trigger=CronTrigger.from_crontab("5 4 * * 4"),
61
+ name="Vaccuum queued jobs",
62
+ id=VACCUUM_QUEUED_JOBS_JOB_ID,
63
+ kwargs={},
64
+ misfire_grace_time=None,
65
+ )
66
+
39
67
  def register_jobs(self, profiles: list[job_definition_t.ProfileMetadata]) -> None:
40
- valid_job_ids = []
68
+ valid_job_ids: set[str] = set()
41
69
  for profile_metadata in profiles:
42
70
  for job_defn in profile_metadata.jobs:
43
- valid_job_ids.append(job_defn.id)
71
+ valid_job_ids.add(job_defn.id)
44
72
  match job_defn:
45
73
  case CronJobDefinition():
46
74
  # Add to ap scheduler
@@ -86,14 +114,15 @@ class IntegrationServer:
86
114
  misfire_grace_time=None,
87
115
  **job_opts,
88
116
  )
89
- case WebhookJobDefinition():
117
+ case HttpJobDefinitionBase():
90
118
  pass
91
119
  case _:
92
120
  assert_never(job_defn)
93
- all_jobs = self._scheduler.get_jobs()
94
- for job in all_jobs:
95
- if job.id not in valid_job_ids:
96
- self._scheduler.remove_job(job.id)
121
+ all_job_ids = {job.id for job in self._scheduler.get_jobs()}
122
+ invalid_job_ids = all_job_ids.difference(valid_job_ids.union(STATIC_JOB_IDS))
123
+
124
+ for job_id in invalid_job_ids:
125
+ self._scheduler.remove_job(job_id)
97
126
 
98
127
  def serve_forever(self) -> None:
99
128
  signal.pause()
@@ -106,12 +135,13 @@ class IntegrationServer:
106
135
 
107
136
  def __enter__(self) -> "IntegrationServer":
108
137
  self._start_apscheduler()
138
+ self._register_static_jobs()
109
139
  return self
110
140
 
111
141
  def __exit__(
112
142
  self,
113
- exc_type: Optional[type[BaseException]],
114
- exc_val: Optional[BaseException],
115
- exc_tb: Optional[TracebackType],
143
+ exc_type: type[BaseException] | None,
144
+ exc_val: BaseException | None,
145
+ exc_tb: TracebackType | None,
116
146
  ) -> None:
117
147
  self._stop_apscheduler()
@@ -1,4 +1,5 @@
1
1
  import functools
2
+ import json
2
3
  import os
3
4
  import time
4
5
  import traceback
@@ -11,7 +12,10 @@ from opentelemetry import _logs, trace
11
12
  from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
12
13
  from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
13
14
  from opentelemetry.sdk._logs import Logger as OTELLogger
14
- from opentelemetry.sdk._logs import LoggerProvider, LogRecord
15
+ from opentelemetry.sdk._logs import (
16
+ LoggerProvider,
17
+ LogRecord,
18
+ )
15
19
  from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogExporter
16
20
  from opentelemetry.sdk.resources import Attributes, Resource
17
21
  from opentelemetry.sdk.trace import TracerProvider
@@ -32,6 +36,11 @@ def _cast_attributes(attributes: dict[str, base_t.JsonValue]) -> Attributes:
32
36
  return cast(Attributes, attributes)
33
37
 
34
38
 
39
+ def one_line_formatter(record: LogRecord) -> str:
40
+ json_data = record.to_json()
41
+ return json.dumps(json.loads(json_data), separators=(",", ":")) + "\n"
42
+
43
+
35
44
  @functools.cache
36
45
  def get_otel_resource() -> Resource:
37
46
  attributes: dict[str, base_t.JsonValue] = {
@@ -60,7 +69,9 @@ def get_otel_tracer() -> Tracer:
60
69
  @functools.cache
61
70
  def get_otel_logger() -> OTELLogger:
62
71
  provider = LoggerProvider(resource=get_otel_resource())
63
- provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogExporter()))
72
+ provider.add_log_record_processor(
73
+ BatchLogRecordProcessor(ConsoleLogExporter(formatter=one_line_formatter))
74
+ )
64
75
  if get_otel_enabled():
65
76
  provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter()))
66
77
  _logs.set_logger_provider(provider)
@@ -87,8 +98,27 @@ class Logger:
87
98
  def current_trace_id(self) -> int | None:
88
99
  return self.current_span.get_span_context().trace_id
89
100
 
90
- def _patch_attributes(self, attributes: Attributes | None) -> Attributes:
91
- return attributes or {}
101
+ def _patch_attributes(
102
+ self,
103
+ attributes: Attributes | None,
104
+ *,
105
+ message: str | None = None,
106
+ severity: LogSeverity | None = None,
107
+ ) -> Attributes:
108
+ patched_attributes = {**(attributes if attributes is not None else {})}
109
+ if message is not None:
110
+ patched_attributes["message"] = message
111
+ elif "body" in patched_attributes:
112
+ patched_attributes["message"] = patched_attributes["body"]
113
+
114
+ if severity is not None:
115
+ patched_attributes["status"] = severity.lower()
116
+ elif "severity_text" in patched_attributes and isinstance(
117
+ patched_attributes["severity_text"], str
118
+ ):
119
+ patched_attributes["status"] = patched_attributes["severity_text"].lower()
120
+
121
+ return patched_attributes
92
122
 
93
123
  def _emit_log(
94
124
  self, message: str, *, severity: LogSeverity, attributes: Attributes | None
@@ -98,7 +128,9 @@ class Logger:
98
128
  body=message,
99
129
  severity_text=severity,
100
130
  timestamp=time.time_ns(),
101
- attributes=self._patch_attributes(attributes),
131
+ attributes=self._patch_attributes(
132
+ message=message, severity=severity, attributes=attributes
133
+ ),
102
134
  span_id=self.current_span_id,
103
135
  trace_id=self.current_trace_id,
104
136
  trace_flags=DEFAULT_TRACE_OPTIONS,
@@ -131,8 +163,10 @@ class Logger:
131
163
  message: str | None = None,
132
164
  attributes: Attributes | None = None,
133
165
  ) -> None:
134
- traceback_str = "".join(traceback.format_tb(exception.__traceback__))
135
- patched_attributes = self._patch_attributes(attributes)
166
+ traceback_str = "".join(traceback.format_exception(exception))
167
+ patched_attributes = self._patch_attributes(
168
+ message=message, severity=LogSeverity.ERROR, attributes=attributes
169
+ )
136
170
  self.current_span.record_exception(
137
171
  exception=exception, attributes=patched_attributes
138
172
  )
@@ -163,9 +197,17 @@ class JobLogger(Logger):
163
197
  self.job_definition = job_definition
164
198
  super().__init__(base_span)
165
199
 
166
- def _patch_attributes(self, attributes: Attributes | None) -> Attributes:
200
+ def _patch_attributes(
201
+ self,
202
+ attributes: Attributes | None,
203
+ *,
204
+ message: str | None = None,
205
+ severity: LogSeverity | None = None,
206
+ ) -> Attributes:
167
207
  patched_attributes: dict[str, base_t.JsonValue] = {
168
- **(attributes if attributes is not None else {})
208
+ **super()._patch_attributes(
209
+ attributes=attributes, message=message, severity=severity
210
+ )
169
211
  }
170
212
  patched_attributes["profile.name"] = self.profile_metadata.name
171
213
  patched_attributes["profile.base_url"] = self.profile_metadata.base_url
@@ -177,7 +219,7 @@ class JobLogger(Logger):
177
219
  patched_attributes["job.definition.cron_spec"] = (
178
220
  self.job_definition.cron_spec
179
221
  )
180
- case job_definition_t.WebhookJobDefinition():
222
+ case job_definition_t.HttpJobDefinitionBase():
181
223
  pass
182
224
  case _:
183
225
  assert_never(self.job_definition)
@@ -196,3 +238,14 @@ class JobLogger(Logger):
196
238
  case _:
197
239
  assert_never(self.job_definition.executor)
198
240
  return _cast_attributes(patched_attributes)
241
+
242
+
243
+ @contextmanager
244
+ def push_scope_optional(
245
+ logger: Logger | None, scope_name: str, *, attributes: Attributes | None = None
246
+ ) -> Generator[None, None, None]:
247
+ if logger is None:
248
+ yield
249
+ else:
250
+ with logger.push_scope(scope_name, attributes=attributes):
251
+ yield
@@ -1,146 +1,71 @@
1
- import hmac
2
- import typing
3
- from dataclasses import dataclass
1
+ import base64
4
2
 
5
3
  import flask
6
- import simplejson
7
4
  from flask.typing import ResponseReturnValue
8
- from flask.wrappers import Response
9
5
  from opentelemetry.trace import get_current_span
10
6
  from uncountable.core.environment import (
11
- get_local_admin_server_port,
7
+ get_http_server_port,
12
8
  get_server_env,
13
- get_webhook_server_port,
14
- )
15
- from uncountable.integration.queue_runner.command_server.command_client import (
16
- send_job_queue_message,
17
- )
18
- from uncountable.integration.queue_runner.command_server.types import (
19
- CommandServerException,
20
9
  )
10
+ from uncountable.integration.executors.script_executor import resolve_script_executor
11
+ from uncountable.integration.http_server import GenericHttpRequest, HttpException
12
+ from uncountable.integration.job import CustomHttpJob, WebhookJob
21
13
  from uncountable.integration.scan_profiles import load_profiles
22
- from uncountable.integration.secret_retrieval.retrieve_secret import retrieve_secret
23
14
  from uncountable.integration.telemetry import Logger
24
- from uncountable.types import base_t, job_definition_t, queued_job_t, webhook_job_t
25
-
26
- from pkgs.argument_parser import CachedParser
15
+ from uncountable.types import job_definition_t
27
16
 
28
17
  app = flask.Flask(__name__)
29
18
 
30
19
 
31
- @dataclass(kw_only=True)
32
- class WebhookResponse:
33
- pass
34
-
35
-
36
- webhook_payload_parser = CachedParser(webhook_job_t.WebhookEventBody)
37
-
38
-
39
- class WebhookException(BaseException):
40
- error_code: int
41
- message: str
42
-
43
- def __init__(self, *, error_code: int, message: str) -> None:
44
- self.error_code = error_code
45
- self.message = message
46
-
47
- @staticmethod
48
- def payload_failed_signature() -> "WebhookException":
49
- return WebhookException(
50
- error_code=401, message="webhook payload did not match signature"
51
- )
52
-
53
- @staticmethod
54
- def no_signature_passed() -> "WebhookException":
55
- return WebhookException(error_code=400, message="missing signature")
56
-
57
- @staticmethod
58
- def body_parse_error() -> "WebhookException":
59
- return WebhookException(error_code=400, message="body parse error")
60
-
61
- @staticmethod
62
- def unknown_error() -> "WebhookException":
63
- return WebhookException(error_code=500, message="internal server error")
64
-
65
- def __str__(self) -> str:
66
- return f"[{self.error_code}]: {self.message}"
67
-
68
- def make_error_response(self) -> Response:
69
- return Response(
70
- status=self.error_code, response={"error": {"message": str(self)}}
71
- )
72
-
73
-
74
- def _parse_webhook_payload(
75
- *, raw_request_body: bytes, signature_key: str, passed_signature: str
76
- ) -> base_t.JsonValue:
77
- request_body_signature = hmac.new(
78
- signature_key.encode("utf-8"), msg=raw_request_body, digestmod="sha256"
79
- ).hexdigest()
80
-
81
- if request_body_signature != passed_signature:
82
- raise WebhookException.payload_failed_signature()
83
-
84
- try:
85
- request_body = simplejson.loads(raw_request_body.decode())
86
- return typing.cast(base_t.JsonValue, request_body)
87
- except (simplejson.JSONDecodeError, ValueError) as e:
88
- raise WebhookException.body_parse_error() from e
89
-
90
-
91
20
  def register_route(
92
21
  *,
93
22
  server_logger: Logger,
94
23
  profile_meta: job_definition_t.ProfileMetadata,
95
- job: job_definition_t.WebhookJobDefinition,
24
+ job: job_definition_t.HttpJobDefinitionBase,
96
25
  ) -> None:
97
26
  route = f"/{profile_meta.name}/{job.id}"
98
27
 
99
- def handle_webhook() -> ResponseReturnValue:
28
+ def handle_request() -> ResponseReturnValue:
100
29
  with server_logger.push_scope(route):
101
30
  try:
102
- signature_key = retrieve_secret(
103
- profile_metadata=profile_meta,
104
- secret_retrieval=job.signature_key_secret,
31
+ if not isinstance(job.executor, job_definition_t.JobExecutorScript):
32
+ raise HttpException.configuration_error(
33
+ message="[internal] http job must use a script executor"
34
+ )
35
+ job_instance = resolve_script_executor(
36
+ executor=job.executor, profile_metadata=profile_meta
105
37
  )
106
-
107
- passed_signature = flask.request.headers.get(
108
- "Uncountable-Webhook-Signature"
38
+ if not isinstance(job_instance, (CustomHttpJob, WebhookJob)):
39
+ raise HttpException.configuration_error(
40
+ message="[internal] http job must descend from CustomHttpJob"
41
+ )
42
+ http_request = GenericHttpRequest(
43
+ body_base64=base64.b64encode(flask.request.get_data()).decode(),
44
+ headers=dict(flask.request.headers),
109
45
  )
110
- if passed_signature is None:
111
- raise WebhookException.no_signature_passed()
112
-
113
- webhook_payload = _parse_webhook_payload(
114
- raw_request_body=flask.request.data,
115
- signature_key=signature_key,
116
- passed_signature=passed_signature,
46
+ job_instance.validate_request(
47
+ request=http_request, job_definition=job, profile_meta=profile_meta
48
+ )
49
+ http_response = job_instance.handle_request(
50
+ request=http_request, job_definition=job, profile_meta=profile_meta
117
51
  )
118
52
 
119
- try:
120
- send_job_queue_message(
121
- job_ref_name=job.id,
122
- payload=queued_job_t.QueuedJobPayload(
123
- invocation_context=queued_job_t.InvocationContextWebhook(
124
- webhook_payload=webhook_payload
125
- )
126
- ),
127
- port=get_local_admin_server_port(),
128
- )
129
- except CommandServerException as e:
130
- raise WebhookException.unknown_error() from e
131
-
132
- return flask.jsonify(WebhookResponse())
133
- except WebhookException as e:
53
+ return flask.make_response(
54
+ http_response.response,
55
+ http_response.status_code,
56
+ http_response.headers,
57
+ )
58
+ except HttpException as e:
134
59
  server_logger.log_exception(e)
135
60
  return e.make_error_response()
136
61
  except Exception as e:
137
62
  server_logger.log_exception(e)
138
- return WebhookException.unknown_error().make_error_response()
63
+ return HttpException.unknown_error().make_error_response()
139
64
 
140
65
  app.add_url_rule(
141
66
  route,
142
- endpoint=f"handle_webhook_{job.id}",
143
- view_func=handle_webhook,
67
+ endpoint=f"handle_request_{job.id}",
68
+ view_func=handle_request,
144
69
  methods=["POST"],
145
70
  )
146
71
 
@@ -148,11 +73,13 @@ def register_route(
148
73
 
149
74
 
150
75
  def main() -> None:
76
+ app.add_url_rule("/health", "health", lambda: ("OK", 200))
77
+
151
78
  profiles = load_profiles()
152
79
  for profile_metadata in profiles:
153
80
  server_logger = Logger(get_current_span())
154
81
  for job in profile_metadata.jobs:
155
- if isinstance(job, job_definition_t.WebhookJobDefinition):
82
+ if isinstance(job, job_definition_t.HttpJobDefinitionBase):
156
83
  register_route(
157
84
  server_logger=server_logger, profile_meta=profile_metadata, job=job
158
85
  )
@@ -164,7 +91,7 @@ main()
164
91
  if __name__ == "__main__":
165
92
  app.run(
166
93
  host="0.0.0.0",
167
- port=get_webhook_server_port(),
94
+ port=get_http_server_port(),
168
95
  debug=get_server_env() == "playground",
169
96
  exclude_patterns=[],
170
97
  )