UncountablePythonSDK 0.0.8__py3-none-any.whl → 0.0.92__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 (312) hide show
  1. UncountablePythonSDK-0.0.92.dist-info/METADATA +61 -0
  2. UncountablePythonSDK-0.0.92.dist-info/RECORD +301 -0
  3. {UncountablePythonSDK-0.0.8.dist-info → UncountablePythonSDK-0.0.92.dist-info}/WHEEL +1 -1
  4. {UncountablePythonSDK-0.0.8.dist-info → UncountablePythonSDK-0.0.92.dist-info}/top_level.txt +1 -1
  5. docs/.gitignore +1 -0
  6. docs/conf.py +57 -0
  7. docs/index.md +13 -0
  8. docs/justfile +12 -0
  9. docs/quickstart.md +19 -0
  10. docs/requirements.txt +7 -0
  11. docs/static/favicons/android-chrome-192x192.png +0 -0
  12. docs/static/favicons/android-chrome-512x512.png +0 -0
  13. docs/static/favicons/apple-touch-icon.png +0 -0
  14. docs/static/favicons/browserconfig.xml +9 -0
  15. docs/static/favicons/favicon-16x16.png +0 -0
  16. docs/static/favicons/favicon-32x32.png +0 -0
  17. docs/static/favicons/manifest.json +18 -0
  18. docs/static/favicons/mstile-150x150.png +0 -0
  19. docs/static/favicons/safari-pinned-tab.svg +32 -0
  20. docs/static/logo_blue.png +0 -0
  21. examples/async_batch.py +35 -0
  22. examples/create_entity.py +22 -17
  23. examples/download_files.py +26 -0
  24. examples/edit_recipe_inputs.py +50 -0
  25. examples/integration-server/jobs/materials_auto/example_cron.py +18 -0
  26. examples/integration-server/jobs/materials_auto/example_wh.py +15 -0
  27. examples/integration-server/jobs/materials_auto/profile.yaml +43 -0
  28. examples/integration-server/pyproject.toml +224 -0
  29. examples/invoke_uploader.py +26 -0
  30. examples/set_recipe_metadata_file.py +40 -0
  31. examples/set_recipe_output_file_sdk.py +26 -0
  32. examples/upload_files.py +18 -0
  33. pkgs/argument_parser/__init__.py +5 -0
  34. pkgs/argument_parser/_is_enum.py +1 -6
  35. pkgs/argument_parser/argument_parser.py +232 -76
  36. pkgs/argument_parser/case_convert.py +4 -3
  37. pkgs/filesystem_utils/__init__.py +20 -0
  38. pkgs/filesystem_utils/_blob_session.py +137 -0
  39. pkgs/filesystem_utils/_gdrive_session.py +309 -0
  40. pkgs/filesystem_utils/_local_session.py +69 -0
  41. pkgs/filesystem_utils/_s3_session.py +117 -0
  42. pkgs/filesystem_utils/_sftp_session.py +147 -0
  43. pkgs/filesystem_utils/file_type_utils.py +91 -0
  44. pkgs/filesystem_utils/filesystem_session.py +39 -0
  45. pkgs/py.typed +0 -0
  46. pkgs/serialization/__init__.py +8 -1
  47. pkgs/serialization/annotation.py +64 -0
  48. pkgs/serialization/missing_sentry.py +1 -1
  49. pkgs/serialization/opaque_key.py +1 -1
  50. pkgs/serialization/serial_alias.py +47 -0
  51. pkgs/serialization/serial_class.py +65 -50
  52. pkgs/serialization/serial_generic.py +16 -0
  53. pkgs/serialization/serial_union.py +84 -0
  54. pkgs/serialization/yaml.py +57 -0
  55. pkgs/serialization_util/__init__.py +7 -7
  56. pkgs/serialization_util/_get_type_for_serialization.py +1 -3
  57. pkgs/serialization_util/convert_to_snakecase.py +27 -0
  58. pkgs/serialization_util/dataclasses.py +14 -0
  59. pkgs/serialization_util/serialization_helpers.py +116 -74
  60. pkgs/strenum_compat/strenum_compat.py +1 -9
  61. pkgs/type_spec/actions_registry/__init__.py +0 -0
  62. pkgs/type_spec/actions_registry/__main__.py +126 -0
  63. pkgs/type_spec/actions_registry/emit_typescript.py +182 -0
  64. pkgs/type_spec/builder.py +475 -89
  65. pkgs/type_spec/config.py +24 -19
  66. pkgs/type_spec/emit_io_ts.py +5 -2
  67. pkgs/type_spec/emit_open_api.py +266 -32
  68. pkgs/type_spec/emit_open_api_util.py +32 -13
  69. pkgs/type_spec/emit_python.py +599 -151
  70. pkgs/type_spec/emit_typescript.py +74 -273
  71. pkgs/type_spec/emit_typescript_util.py +239 -5
  72. pkgs/type_spec/load_types.py +55 -10
  73. pkgs/type_spec/open_api_util.py +30 -41
  74. pkgs/type_spec/parts/base.py.prepart +4 -3
  75. pkgs/type_spec/type_info/emit_type_info.py +178 -16
  76. pkgs/type_spec/util.py +11 -11
  77. pkgs/type_spec/value_spec/__main__.py +3 -3
  78. pkgs/type_spec/value_spec/convert_type.py +8 -1
  79. pkgs/type_spec/value_spec/emit_python.py +13 -4
  80. uncountable/__init__.py +1 -2
  81. uncountable/core/__init__.py +12 -2
  82. uncountable/core/async_batch.py +37 -0
  83. uncountable/core/client.py +293 -43
  84. uncountable/core/environment.py +41 -0
  85. uncountable/core/file_upload.py +135 -0
  86. uncountable/core/types.py +17 -0
  87. uncountable/integration/__init__.py +0 -0
  88. uncountable/integration/cli.py +49 -0
  89. uncountable/integration/construct_client.py +51 -0
  90. uncountable/integration/cron.py +29 -0
  91. uncountable/integration/db/__init__.py +0 -0
  92. uncountable/integration/db/connect.py +18 -0
  93. uncountable/integration/db/session.py +25 -0
  94. uncountable/integration/entrypoint.py +13 -0
  95. uncountable/integration/executors/__init__.py +0 -0
  96. uncountable/integration/executors/executors.py +148 -0
  97. uncountable/integration/executors/generic_upload_executor.py +284 -0
  98. uncountable/integration/executors/script_executor.py +25 -0
  99. uncountable/integration/job.py +87 -0
  100. uncountable/integration/queue_runner/__init__.py +0 -0
  101. uncountable/integration/queue_runner/command_server/__init__.py +24 -0
  102. uncountable/integration/queue_runner/command_server/command_client.py +68 -0
  103. uncountable/integration/queue_runner/command_server/command_server.py +64 -0
  104. uncountable/integration/queue_runner/command_server/protocol/__init__.py +0 -0
  105. uncountable/integration/queue_runner/command_server/protocol/command_server.proto +22 -0
  106. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.py +40 -0
  107. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2.pyi +38 -0
  108. uncountable/integration/queue_runner/command_server/protocol/command_server_pb2_grpc.py +129 -0
  109. uncountable/integration/queue_runner/command_server/types.py +52 -0
  110. uncountable/integration/queue_runner/datastore/__init__.py +3 -0
  111. uncountable/integration/queue_runner/datastore/datastore_sqlite.py +93 -0
  112. uncountable/integration/queue_runner/datastore/interface.py +19 -0
  113. uncountable/integration/queue_runner/datastore/model.py +17 -0
  114. uncountable/integration/queue_runner/job_scheduler.py +163 -0
  115. uncountable/integration/queue_runner/queue_runner.py +26 -0
  116. uncountable/integration/queue_runner/types.py +7 -0
  117. uncountable/integration/queue_runner/worker.py +119 -0
  118. uncountable/integration/scan_profiles.py +67 -0
  119. uncountable/integration/scheduler.py +150 -0
  120. uncountable/integration/secret_retrieval/__init__.py +3 -0
  121. uncountable/integration/secret_retrieval/retrieve_secret.py +93 -0
  122. uncountable/integration/server.py +117 -0
  123. uncountable/integration/telemetry.py +209 -0
  124. uncountable/integration/webhook_server/entrypoint.py +170 -0
  125. uncountable/types/__init__.py +136 -20
  126. uncountable/types/api/batch/execute_batch.py +15 -7
  127. uncountable/types/api/batch/execute_batch_load_async.py +42 -0
  128. uncountable/types/api/chemical/__init__.py +1 -0
  129. uncountable/types/api/chemical/convert_chemical_formats.py +63 -0
  130. uncountable/types/api/entity/create_entities.py +23 -11
  131. uncountable/types/api/entity/create_entity.py +21 -12
  132. uncountable/types/api/entity/get_entities_data.py +18 -8
  133. uncountable/types/api/entity/grant_entity_permissions.py +48 -0
  134. uncountable/types/api/entity/list_entities.py +27 -12
  135. uncountable/types/api/entity/lock_entity.py +45 -0
  136. uncountable/types/api/entity/resolve_entity_ids.py +17 -7
  137. uncountable/types/api/entity/set_entity_field_values.py +44 -0
  138. uncountable/types/api/entity/set_values.py +14 -7
  139. uncountable/types/api/entity/transition_entity_phase.py +80 -0
  140. uncountable/types/api/entity/unlock_entity.py +44 -0
  141. uncountable/types/api/equipment/__init__.py +1 -0
  142. uncountable/types/api/equipment/associate_equipment_input.py +44 -0
  143. uncountable/types/api/field_options/__init__.py +1 -0
  144. uncountable/types/api/field_options/upsert_field_options.py +55 -0
  145. uncountable/types/api/files/__init__.py +1 -0
  146. uncountable/types/api/files/download_file.py +77 -0
  147. uncountable/types/api/id_source/__init__.py +1 -0
  148. uncountable/types/api/id_source/list_id_source.py +56 -0
  149. uncountable/types/api/id_source/match_id_source.py +54 -0
  150. uncountable/types/api/input_groups/get_input_group_names.py +16 -6
  151. uncountable/types/api/inputs/create_inputs.py +24 -11
  152. uncountable/types/api/inputs/get_input_data.py +32 -13
  153. uncountable/types/api/inputs/get_input_names.py +18 -8
  154. uncountable/types/api/inputs/get_inputs_data.py +29 -10
  155. uncountable/types/api/inputs/set_input_attribute_values.py +16 -9
  156. uncountable/types/api/inputs/set_input_category.py +44 -0
  157. uncountable/types/api/inputs/set_input_subcategories.py +45 -0
  158. uncountable/types/api/inputs/set_intermediate_type.py +50 -0
  159. uncountable/types/api/material_families/__init__.py +1 -0
  160. uncountable/types/api/material_families/update_entity_material_families.py +48 -0
  161. uncountable/types/api/outputs/get_output_data.py +32 -16
  162. uncountable/types/api/outputs/get_output_names.py +18 -8
  163. uncountable/types/api/outputs/resolve_output_conditions.py +23 -10
  164. uncountable/types/api/permissions/__init__.py +1 -0
  165. uncountable/types/api/permissions/set_core_permissions.py +105 -0
  166. uncountable/types/api/project/get_projects.py +17 -7
  167. uncountable/types/api/project/get_projects_data.py +21 -11
  168. uncountable/types/api/recipe_links/__init__.py +1 -0
  169. uncountable/types/api/recipe_links/create_recipe_link.py +46 -0
  170. uncountable/types/api/recipe_links/remove_recipe_link.py +45 -0
  171. uncountable/types/api/recipe_metadata/get_recipe_metadata_data.py +18 -8
  172. uncountable/types/api/recipes/add_recipe_to_project.py +42 -0
  173. uncountable/types/api/recipes/archive_recipes.py +42 -0
  174. uncountable/types/api/recipes/associate_recipe_as_input.py +44 -0
  175. uncountable/types/api/recipes/associate_recipe_as_lot.py +43 -0
  176. uncountable/types/api/recipes/clear_recipe_outputs.py +42 -0
  177. uncountable/types/api/recipes/create_recipe.py +51 -0
  178. uncountable/types/api/recipes/create_recipes.py +25 -12
  179. uncountable/types/api/recipes/disassociate_recipe_as_input.py +42 -0
  180. uncountable/types/api/recipes/edit_recipe_inputs.py +283 -0
  181. uncountable/types/api/recipes/get_column_calculation_values.py +58 -0
  182. uncountable/types/api/recipes/get_curve.py +15 -7
  183. uncountable/types/api/recipes/get_recipe_calculations.py +17 -10
  184. uncountable/types/api/recipes/get_recipe_links.py +13 -6
  185. uncountable/types/api/recipes/get_recipe_names.py +16 -6
  186. uncountable/types/api/recipes/get_recipe_output_metadata.py +14 -7
  187. uncountable/types/api/recipes/get_recipes_data.py +63 -38
  188. uncountable/types/api/recipes/lock_recipes.py +63 -0
  189. uncountable/types/api/recipes/remove_recipe_from_project.py +42 -0
  190. uncountable/types/api/recipes/set_recipe_inputs.py +19 -10
  191. uncountable/types/api/recipes/set_recipe_metadata.py +43 -0
  192. uncountable/types/api/recipes/set_recipe_output_annotations.py +115 -0
  193. uncountable/types/api/recipes/set_recipe_output_file.py +56 -0
  194. uncountable/types/api/recipes/set_recipe_outputs.py +26 -12
  195. uncountable/types/api/recipes/set_recipe_tags.py +109 -0
  196. uncountable/types/api/recipes/unarchive_recipes.py +41 -0
  197. uncountable/types/api/recipes/unlock_recipes.py +50 -0
  198. uncountable/types/api/triggers/__init__.py +1 -0
  199. uncountable/types/api/triggers/run_trigger.py +43 -0
  200. uncountable/types/api/uploader/__init__.py +1 -0
  201. uncountable/types/api/uploader/invoke_uploader.py +47 -0
  202. uncountable/types/async_batch.py +13 -0
  203. uncountable/types/async_batch_processor.py +384 -0
  204. uncountable/types/async_batch_t.py +97 -0
  205. uncountable/types/async_jobs.py +9 -0
  206. uncountable/types/async_jobs_t.py +53 -0
  207. uncountable/types/auth_retrieval.py +12 -0
  208. uncountable/types/auth_retrieval_t.py +75 -0
  209. uncountable/types/base.py +5 -78
  210. uncountable/types/base_t.py +85 -0
  211. uncountable/types/calculations.py +3 -18
  212. uncountable/types/calculations_t.py +27 -0
  213. uncountable/types/chemical_structure.py +8 -0
  214. uncountable/types/chemical_structure_t.py +28 -0
  215. uncountable/types/client_base.py +1093 -54
  216. uncountable/types/client_config.py +8 -0
  217. uncountable/types/client_config_t.py +26 -0
  218. uncountable/types/curves.py +5 -42
  219. uncountable/types/curves_t.py +51 -0
  220. uncountable/types/entity.py +8 -269
  221. uncountable/types/entity_t.py +393 -0
  222. uncountable/types/experiment_groups.py +3 -18
  223. uncountable/types/experiment_groups_t.py +27 -0
  224. uncountable/types/field_values.py +17 -60
  225. uncountable/types/field_values_t.py +204 -0
  226. uncountable/types/fields.py +3 -19
  227. uncountable/types/fields_t.py +28 -0
  228. uncountable/types/generic_upload.py +15 -0
  229. uncountable/types/generic_upload_t.py +119 -0
  230. uncountable/types/id_source.py +12 -0
  231. uncountable/types/id_source_t.py +68 -0
  232. uncountable/types/identifier.py +11 -0
  233. uncountable/types/identifier_t.py +63 -0
  234. uncountable/types/input_attributes.py +3 -24
  235. uncountable/types/input_attributes_t.py +30 -0
  236. uncountable/types/inputs.py +6 -56
  237. uncountable/types/inputs_t.py +83 -0
  238. uncountable/types/integration_server.py +9 -0
  239. uncountable/types/integration_server_t.py +42 -0
  240. uncountable/types/job_definition.py +27 -0
  241. uncountable/types/job_definition_t.py +260 -0
  242. uncountable/types/outputs.py +3 -21
  243. uncountable/types/outputs_t.py +30 -0
  244. uncountable/types/overrides.py +10 -0
  245. uncountable/types/overrides_t.py +49 -0
  246. uncountable/types/permissions.py +8 -0
  247. uncountable/types/permissions_t.py +46 -0
  248. uncountable/types/phases.py +3 -18
  249. uncountable/types/phases_t.py +27 -0
  250. uncountable/types/post_base.py +8 -0
  251. uncountable/types/post_base_t.py +30 -0
  252. uncountable/types/queued_job.py +16 -0
  253. uncountable/types/queued_job_t.py +123 -0
  254. uncountable/types/recipe_identifiers.py +12 -0
  255. uncountable/types/recipe_identifiers_t.py +76 -0
  256. uncountable/types/recipe_inputs.py +9 -0
  257. uncountable/types/recipe_inputs_t.py +30 -0
  258. uncountable/types/recipe_links.py +4 -45
  259. uncountable/types/recipe_links_t.py +54 -0
  260. uncountable/types/recipe_metadata.py +5 -45
  261. uncountable/types/recipe_metadata_t.py +58 -0
  262. uncountable/types/recipe_output_metadata.py +3 -19
  263. uncountable/types/recipe_output_metadata_t.py +28 -0
  264. uncountable/types/recipe_tags.py +3 -18
  265. uncountable/types/recipe_tags_t.py +27 -0
  266. uncountable/types/recipe_workflow_steps.py +14 -0
  267. uncountable/types/recipe_workflow_steps_t.py +95 -0
  268. uncountable/types/recipes.py +8 -0
  269. uncountable/types/recipes_t.py +25 -0
  270. uncountable/types/response.py +3 -20
  271. uncountable/types/response_t.py +26 -0
  272. uncountable/types/secret_retrieval.py +12 -0
  273. uncountable/types/secret_retrieval_t.py +75 -0
  274. uncountable/types/units.py +3 -18
  275. uncountable/types/units_t.py +27 -0
  276. uncountable/types/users.py +3 -19
  277. uncountable/types/users_t.py +28 -0
  278. uncountable/types/webhook_job.py +9 -0
  279. uncountable/types/webhook_job_t.py +37 -0
  280. uncountable/types/workflows.py +4 -27
  281. uncountable/types/workflows_t.py +39 -0
  282. UncountablePythonSDK-0.0.8.dist-info/METADATA +0 -27
  283. UncountablePythonSDK-0.0.8.dist-info/RECORD +0 -134
  284. examples/recipe-import/importer.py +0 -39
  285. type_spec/external/api/batch/execute_batch.yaml +0 -56
  286. type_spec/external/api/entity/create_entities.yaml +0 -33
  287. type_spec/external/api/entity/create_entity.yaml +0 -39
  288. type_spec/external/api/entity/get_entities_data.yaml +0 -29
  289. type_spec/external/api/entity/list_entities.yaml +0 -52
  290. type_spec/external/api/entity/resolve_entity_ids.yaml +0 -29
  291. type_spec/external/api/entity/set_values.yaml +0 -18
  292. type_spec/external/api/input_groups/get_input_group_names.yaml +0 -29
  293. type_spec/external/api/inputs/create_inputs.yaml +0 -48
  294. type_spec/external/api/inputs/get_input_data.yaml +0 -95
  295. type_spec/external/api/inputs/get_input_names.yaml +0 -38
  296. type_spec/external/api/inputs/get_inputs_data.yaml +0 -82
  297. type_spec/external/api/inputs/set_input_attribute_values.yaml +0 -33
  298. type_spec/external/api/outputs/get_output_data.yaml +0 -92
  299. type_spec/external/api/outputs/get_output_names.yaml +0 -35
  300. type_spec/external/api/outputs/resolve_output_conditions.yaml +0 -50
  301. type_spec/external/api/project/get_projects.yaml +0 -42
  302. type_spec/external/api/project/get_projects_data.yaml +0 -50
  303. type_spec/external/api/recipe_metadata/get_recipe_metadata_data.yaml +0 -41
  304. type_spec/external/api/recipes/create_recipes.yaml +0 -47
  305. type_spec/external/api/recipes/get_curve.yaml +0 -18
  306. type_spec/external/api/recipes/get_recipe_calculations.yaml +0 -39
  307. type_spec/external/api/recipes/get_recipe_links.yaml +0 -26
  308. type_spec/external/api/recipes/get_recipe_names.yaml +0 -29
  309. type_spec/external/api/recipes/get_recipe_output_metadata.yaml +0 -36
  310. type_spec/external/api/recipes/get_recipes_data.yaml +0 -238
  311. type_spec/external/api/recipes/set_recipe_inputs.yaml +0 -36
  312. type_spec/external/api/recipes/set_recipe_outputs.yaml +0 -52
@@ -0,0 +1,163 @@
1
+ import asyncio
2
+ import typing
3
+ from concurrent.futures import ProcessPoolExecutor
4
+ from dataclasses import dataclass
5
+
6
+ from opentelemetry.trace import get_current_span
7
+
8
+ from uncountable.integration.db.connect import IntegrationDBService, create_db_engine
9
+ from uncountable.integration.db.session import get_session_maker
10
+ from uncountable.integration.queue_runner.command_server import (
11
+ CommandEnqueueJob,
12
+ CommandEnqueueJobResponse,
13
+ CommandQueue,
14
+ CommandTask,
15
+ )
16
+ from uncountable.integration.queue_runner.datastore import DatastoreSqlite
17
+ from uncountable.integration.queue_runner.datastore.interface import Datastore
18
+ from uncountable.integration.queue_runner.worker import Worker
19
+ from uncountable.integration.scan_profiles import load_profiles
20
+ from uncountable.integration.telemetry import Logger
21
+ from uncountable.types import job_definition_t, queued_job_t
22
+
23
+ from .types import ResultQueue, ResultTask
24
+
25
+ _MAX_JOB_WORKERS = 5
26
+
27
+
28
+ @dataclass(kw_only=True, frozen=True)
29
+ class JobListenerKey:
30
+ profile_name: str
31
+ subqueue_name: str = "default"
32
+
33
+
34
+ def _get_job_worker_key(
35
+ job_definition: job_definition_t.JobDefinition, profile_name: str
36
+ ) -> JobListenerKey:
37
+ return JobListenerKey(profile_name=profile_name)
38
+
39
+
40
+ def on_worker_crash(
41
+ worker_key: JobListenerKey,
42
+ ) -> typing.Callable[[asyncio.Task], None]:
43
+ def hook(task: asyncio.Task) -> None:
44
+ raise Exception(
45
+ f"worker {worker_key.profile_name}_{worker_key.subqueue_name} crashed unexpectedly"
46
+ )
47
+
48
+ return hook
49
+
50
+
51
+ def _start_workers(
52
+ process_pool: ProcessPoolExecutor, result_queue: ResultQueue, datastore: Datastore
53
+ ) -> dict[str, Worker]:
54
+ profiles = load_profiles()
55
+ job_queue_worker_lookup: dict[JobListenerKey, Worker] = {}
56
+ job_worker_lookup: dict[str, Worker] = {}
57
+ job_definition_lookup: dict[str, job_definition_t.JobDefinition] = {}
58
+ for profile in profiles:
59
+ for job_definition in profile.jobs:
60
+ job_definition_lookup[job_definition.id] = job_definition
61
+ job_worker_key = _get_job_worker_key(job_definition, profile.name)
62
+ if job_worker_key not in job_queue_worker_lookup:
63
+ worker = Worker(
64
+ process_pool=process_pool,
65
+ listen_queue=asyncio.Queue(),
66
+ result_queue=result_queue,
67
+ datastore=datastore,
68
+ )
69
+ task = asyncio.create_task(worker.run_worker_loop())
70
+ task.add_done_callback(on_worker_crash(job_worker_key))
71
+ job_queue_worker_lookup[job_worker_key] = worker
72
+ job_worker_lookup[job_definition.id] = job_queue_worker_lookup[
73
+ job_worker_key
74
+ ]
75
+ return job_worker_lookup
76
+
77
+
78
+ async def start_scheduler(command_queue: CommandQueue) -> None:
79
+ logger = Logger(get_current_span())
80
+ result_queue: ResultQueue = asyncio.Queue()
81
+ engine = create_db_engine(IntegrationDBService.RUNNER)
82
+ session_maker = get_session_maker(engine)
83
+
84
+ datastore = DatastoreSqlite(session_maker)
85
+ datastore.setup(engine)
86
+
87
+ with ProcessPoolExecutor(max_workers=_MAX_JOB_WORKERS) as process_pool:
88
+ job_worker_lookup = _start_workers(
89
+ process_pool, result_queue, datastore=datastore
90
+ )
91
+
92
+ queued_jobs = datastore.load_job_queue()
93
+
94
+ async def enqueue_queued_job(queued_job: queued_job_t.QueuedJob) -> None:
95
+ try:
96
+ worker = job_worker_lookup[queued_job.job_ref_name]
97
+ except KeyError as e:
98
+ logger.log_exception(e)
99
+ datastore.remove_job_from_queue(queued_job.queued_job_uuid)
100
+ return
101
+ await worker.listen_queue.put(queued_job)
102
+
103
+ async def _enqueue_or_deduplicate_job(
104
+ job_ref_name: str,
105
+ payload: queued_job_t.QueuedJobPayload,
106
+ ) -> str:
107
+ if isinstance(
108
+ payload.invocation_context,
109
+ (
110
+ queued_job_t.InvocationContextCron,
111
+ queued_job_t.InvocationContextManual,
112
+ ),
113
+ ):
114
+ existing_queued_jobs = datastore.load_job_queue()
115
+ duplicate_job = next(
116
+ (
117
+ job
118
+ for job in existing_queued_jobs
119
+ if job.job_ref_name == job_ref_name
120
+ ),
121
+ None,
122
+ )
123
+ if duplicate_job is not None:
124
+ return duplicate_job.queued_job_uuid
125
+ queued_job = datastore.add_job_to_queue(
126
+ job_payload=payload,
127
+ job_ref_name=job_ref_name,
128
+ )
129
+ await enqueue_queued_job(queued_job)
130
+ return queued_job.queued_job_uuid
131
+
132
+ async def _handle_enqueue_job_command(command: CommandEnqueueJob) -> None:
133
+ queued_job_uuid = await _enqueue_or_deduplicate_job(
134
+ job_ref_name=command.job_ref_name,
135
+ payload=command.payload,
136
+ )
137
+ await command.response_queue.put(
138
+ CommandEnqueueJobResponse(queued_job_uuid=queued_job_uuid)
139
+ )
140
+
141
+ for queued_job in queued_jobs:
142
+ await enqueue_queued_job(queued_job)
143
+
144
+ result_task: ResultTask = asyncio.create_task(result_queue.get())
145
+ command_task: CommandTask = asyncio.create_task(command_queue.get())
146
+ while True:
147
+ finished, _ = await asyncio.wait(
148
+ [result_task, command_task], return_when=asyncio.FIRST_COMPLETED
149
+ )
150
+
151
+ for task in finished:
152
+ if task == command_task:
153
+ command = command_task.result()
154
+ match command:
155
+ case CommandEnqueueJob():
156
+ await _handle_enqueue_job_command(command=command)
157
+ case _:
158
+ typing.assert_never(command)
159
+ command_task = asyncio.create_task(command_queue.get())
160
+ elif task == result_task:
161
+ queued_job_result = result_task.result()
162
+ datastore.remove_job_from_queue(queued_job_result.queued_job_uuid)
163
+ result_task = asyncio.create_task(result_queue.get())
@@ -0,0 +1,26 @@
1
+ import asyncio
2
+
3
+ from uncountable.integration.queue_runner.command_server import serve
4
+ from uncountable.integration.queue_runner.command_server.types import CommandQueue
5
+ from uncountable.integration.queue_runner.job_scheduler import start_scheduler
6
+
7
+
8
+ async def queue_runner_loop() -> None:
9
+ command_queue: CommandQueue = asyncio.Queue()
10
+
11
+ command_server = asyncio.create_task(serve(command_queue))
12
+
13
+ scheduler = asyncio.create_task(start_scheduler(command_queue))
14
+
15
+ await scheduler
16
+ await command_server
17
+
18
+
19
+ def start_queue_runner() -> None:
20
+ loop = asyncio.new_event_loop()
21
+ loop.run_until_complete(queue_runner_loop())
22
+ loop.close()
23
+
24
+
25
+ if __name__ == "__main__":
26
+ start_queue_runner()
@@ -0,0 +1,7 @@
1
+ from asyncio import Queue, Task
2
+
3
+ from uncountable.types import queued_job_t
4
+
5
+ ListenQueue = Queue[queued_job_t.QueuedJob]
6
+ ResultQueue = Queue[queued_job_t.QueuedJobResult]
7
+ ResultTask = Task[queued_job_t.QueuedJobResult]
@@ -0,0 +1,119 @@
1
+ import asyncio
2
+ from concurrent.futures import ProcessPoolExecutor
3
+ from dataclasses import dataclass
4
+
5
+ from opentelemetry.trace import get_current_span
6
+
7
+ from uncountable.core.async_batch import AsyncBatchProcessor
8
+ from uncountable.integration.construct_client import construct_uncountable_client
9
+ from uncountable.integration.executors.executors import execute_job
10
+ from uncountable.integration.job import JobArguments
11
+ from uncountable.integration.queue_runner.datastore.interface import Datastore
12
+ from uncountable.integration.queue_runner.types import ListenQueue, ResultQueue
13
+ from uncountable.integration.scan_profiles import load_profiles
14
+ from uncountable.integration.telemetry import JobLogger, Logger, get_otel_tracer
15
+ from uncountable.types import base_t, job_definition_t, queued_job_t
16
+
17
+
18
+ class Worker:
19
+ def __init__(
20
+ self,
21
+ *,
22
+ process_pool: ProcessPoolExecutor,
23
+ listen_queue: ListenQueue,
24
+ result_queue: ResultQueue,
25
+ datastore: Datastore,
26
+ ) -> None:
27
+ self.process_pool = process_pool
28
+ self.listen_queue = listen_queue
29
+ self.result_queue = result_queue
30
+ self.datastore = datastore
31
+
32
+ async def run_worker_loop(self) -> None:
33
+ logger = Logger(get_current_span())
34
+ while True:
35
+ try:
36
+ queued_job = await self.listen_queue.get()
37
+ self.datastore.increment_num_attempts(queued_job.queued_job_uuid)
38
+ loop = asyncio.get_event_loop()
39
+ result = await loop.run_in_executor(
40
+ self.process_pool, run_queued_job, queued_job
41
+ )
42
+ assert isinstance(result, job_definition_t.JobResult)
43
+ await self.result_queue.put(
44
+ queued_job_t.QueuedJobResult(
45
+ job_result=result, queued_job_uuid=queued_job.queued_job_uuid
46
+ )
47
+ )
48
+ except BaseException as e:
49
+ logger.log_exception(e)
50
+ raise e
51
+
52
+
53
+ @dataclass(kw_only=True)
54
+ class RegisteredJobDetails:
55
+ profile_metadata: job_definition_t.ProfileMetadata
56
+ job_definition: job_definition_t.JobDefinition
57
+
58
+
59
+ def get_registered_job_details(job_ref_name: str) -> RegisteredJobDetails:
60
+ profiles = load_profiles()
61
+ for profile_metadata in profiles:
62
+ for job_definition in profile_metadata.jobs:
63
+ if job_definition.id == job_ref_name:
64
+ return RegisteredJobDetails(
65
+ profile_metadata=profile_metadata,
66
+ job_definition=job_definition,
67
+ )
68
+ raise Exception(f"profile not found for job {job_ref_name}")
69
+
70
+
71
+ def _resolve_queued_job_payload(queued_job: queued_job_t.QueuedJob) -> base_t.JsonValue:
72
+ match queued_job.payload.invocation_context:
73
+ case queued_job_t.InvocationContextCron():
74
+ return None
75
+ case queued_job_t.InvocationContextManual():
76
+ return None
77
+ case queued_job_t.InvocationContextWebhook():
78
+ return queued_job.payload.invocation_context.webhook_payload
79
+
80
+
81
+ def run_queued_job(
82
+ queued_job: queued_job_t.QueuedJob,
83
+ ) -> job_definition_t.JobResult:
84
+ with get_otel_tracer().start_as_current_span(name="run_queued_job") as span:
85
+ job_details = get_registered_job_details(queued_job.job_ref_name)
86
+ job_logger = JobLogger(
87
+ base_span=span,
88
+ profile_metadata=job_details.profile_metadata,
89
+ job_definition=job_details.job_definition,
90
+ )
91
+ try:
92
+ client = construct_uncountable_client(
93
+ profile_meta=job_details.profile_metadata, logger=job_logger
94
+ )
95
+ batch_processor = AsyncBatchProcessor(client=client)
96
+
97
+ payload = _resolve_queued_job_payload(queued_job)
98
+
99
+ args = JobArguments(
100
+ job_definition=job_details.job_definition,
101
+ client=client,
102
+ batch_processor=batch_processor,
103
+ profile_metadata=job_details.profile_metadata,
104
+ logger=job_logger,
105
+ payload=payload,
106
+ )
107
+
108
+ return execute_job(
109
+ args=args,
110
+ profile_metadata=job_details.profile_metadata,
111
+ job_definition=job_details.job_definition,
112
+ job_uuid=queued_job.queued_job_uuid,
113
+ )
114
+ except Exception as e:
115
+ job_logger.log_exception(e)
116
+ return job_definition_t.JobResult(success=False)
117
+ except BaseException as e:
118
+ job_logger.log_exception(e)
119
+ raise e
@@ -0,0 +1,67 @@
1
+ import functools
2
+ from importlib import resources
3
+
4
+ from pkgs.argument_parser import CachedParser
5
+ from uncountable.core import environment
6
+ from uncountable.types import integration_server_t, job_definition_t
7
+
8
+ profile_parser = CachedParser(job_definition_t.ProfileDefinition)
9
+
10
+ _DEFAULT_PROFILE_ENV = integration_server_t.IntegrationEnvironment.PROD
11
+ _IGNORED_PROFILE_FOLDERS = ["__pycache__"]
12
+
13
+
14
+ @functools.cache
15
+ def load_profiles() -> list[job_definition_t.ProfileMetadata]:
16
+ profiles_module = environment.get_profiles_module()
17
+ integration_envs = environment.get_integration_envs()
18
+ profiles = [
19
+ entry
20
+ for entry in resources.files(profiles_module).iterdir()
21
+ if entry.is_dir() and entry.name not in _IGNORED_PROFILE_FOLDERS
22
+ ]
23
+ profile_details: list[job_definition_t.ProfileMetadata] = []
24
+ seen_job_ids: set[str] = set()
25
+ for profile_file in profiles:
26
+ profile_name = profile_file.name
27
+ try:
28
+ definition = profile_parser.parse_yaml_resource(
29
+ package=".".join([profiles_module, profile_name]),
30
+ resource="profile.yaml",
31
+ )
32
+ for job in definition.jobs:
33
+ if job.id in seen_job_ids:
34
+ raise Exception(f"multiple jobs with id {job.id}")
35
+ seen_job_ids.add(job.id)
36
+
37
+ if definition.environments is not None:
38
+ for integration_env in integration_envs:
39
+ environment_config = definition.environments.get(integration_env)
40
+ if environment_config is not None:
41
+ profile_details.append(
42
+ job_definition_t.ProfileMetadata(
43
+ name=profile_name,
44
+ jobs=definition.jobs,
45
+ base_url=environment_config.base_url,
46
+ auth_retrieval=environment_config.auth_retrieval,
47
+ client_options=environment_config.client_options,
48
+ )
49
+ )
50
+ elif _DEFAULT_PROFILE_ENV in integration_envs:
51
+ assert (
52
+ definition.base_url is not None
53
+ and definition.auth_retrieval is not None
54
+ ), f"define environments in profile.yaml for {profile_name}"
55
+ profile_details.append(
56
+ job_definition_t.ProfileMetadata(
57
+ name=profile_name,
58
+ jobs=definition.jobs,
59
+ base_url=definition.base_url,
60
+ auth_retrieval=definition.auth_retrieval,
61
+ client_options=definition.client_options,
62
+ )
63
+ )
64
+ except FileNotFoundError as e:
65
+ print(f"WARN: profile.yaml not found for {profile_name}", e)
66
+ continue
67
+ return profile_details
@@ -0,0 +1,150 @@
1
+ import multiprocessing
2
+ import subprocess
3
+ import sys
4
+ import time
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+
8
+ from opentelemetry.trace import get_current_span
9
+
10
+ from uncountable.core.environment import get_local_admin_server_port
11
+ from uncountable.integration.entrypoint import main as cron_target
12
+ from uncountable.integration.queue_runner.command_server import (
13
+ CommandServerTimeout,
14
+ check_health,
15
+ )
16
+ from uncountable.integration.queue_runner.queue_runner import start_queue_runner
17
+ from uncountable.integration.telemetry import Logger
18
+
19
+ SHUTDOWN_TIMEOUT_SECS = 30
20
+
21
+
22
+ @dataclass(kw_only=True)
23
+ class ProcessInfo:
24
+ name: str
25
+ process: multiprocessing.Process | subprocess.Popen[bytes]
26
+
27
+ @property
28
+ def is_alive(self) -> bool:
29
+ match self.process:
30
+ case multiprocessing.Process():
31
+ return self.process.is_alive()
32
+ case subprocess.Popen():
33
+ return self.process.poll() is None
34
+
35
+ @property
36
+ def pid(self) -> int | None:
37
+ return self.process.pid
38
+
39
+ @property
40
+ def exitcode(self) -> int | None:
41
+ match self.process:
42
+ case multiprocessing.Process():
43
+ return self.process.exitcode
44
+ case subprocess.Popen():
45
+ return self.process.poll()
46
+
47
+
48
+ def handle_shutdown(logger: Logger, processes: list[ProcessInfo]) -> None:
49
+ logger.log_info("received shutdown command, shutting down sub-processes")
50
+ for proc_info in processes:
51
+ if proc_info.is_alive:
52
+ proc_info.process.terminate()
53
+
54
+ shutdown_start = time.time()
55
+ still_living_processes = processes
56
+ while (
57
+ time.time() - shutdown_start < SHUTDOWN_TIMEOUT_SECS
58
+ and len(still_living_processes) > 0
59
+ ):
60
+ current_loop_processes = [*still_living_processes]
61
+ logger.log_info(
62
+ "waiting for sub-processes to shut down",
63
+ attributes={
64
+ "still_living_processes": [
65
+ proc_info.name for proc_info in still_living_processes
66
+ ]
67
+ },
68
+ )
69
+ still_living_processes = []
70
+ for proc_info in current_loop_processes:
71
+ if not proc_info.is_alive:
72
+ logger.log_info(f"{proc_info.name} shut down successfully")
73
+ else:
74
+ still_living_processes.append(proc_info)
75
+ time.sleep(1)
76
+
77
+ for proc_info in still_living_processes:
78
+ logger.log_warning(
79
+ f"{proc_info.name} failed to shut down after {SHUTDOWN_TIMEOUT_SECS} seconds, forcefully terminating"
80
+ )
81
+ proc_info.process.kill()
82
+
83
+
84
+ def check_process_alive(logger: Logger, processes: list[ProcessInfo]) -> None:
85
+ for proc_info in processes:
86
+ 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)
92
+
93
+
94
+ def _wait_queue_runner_online() -> None:
95
+ MAX_QUEUE_RUNNER_HEALTH_CHECKS = 10
96
+ QUEUE_RUNNER_HEALTH_CHECK_DELAY_SECS = 1
97
+
98
+ num_attempts = 0
99
+ before = datetime.now(timezone.utc)
100
+ while num_attempts < MAX_QUEUE_RUNNER_HEALTH_CHECKS:
101
+ try:
102
+ if check_health(port=get_local_admin_server_port()):
103
+ return
104
+ except CommandServerTimeout:
105
+ pass
106
+ num_attempts += 1
107
+ time.sleep(QUEUE_RUNNER_HEALTH_CHECK_DELAY_SECS)
108
+ after = datetime.now(timezone.utc)
109
+ duration_secs = (after - before).seconds
110
+ raise Exception(f"queue runner failed to come online after {duration_secs} seconds")
111
+
112
+
113
+ def main() -> None:
114
+ logger = Logger(get_current_span())
115
+ processes: list[ProcessInfo] = []
116
+
117
+ def add_process(process: ProcessInfo) -> None:
118
+ processes.append(process)
119
+ logger.log_info(f"started process {process.name}")
120
+
121
+ runner_process = multiprocessing.Process(target=start_queue_runner)
122
+ runner_process.start()
123
+ add_process(ProcessInfo(name="queue runner", process=runner_process))
124
+
125
+ try:
126
+ _wait_queue_runner_online()
127
+ except Exception as e:
128
+ logger.log_exception(e)
129
+ handle_shutdown(logger, processes=processes)
130
+ return
131
+
132
+ cron_process = multiprocessing.Process(target=cron_target)
133
+ cron_process.start()
134
+ add_process(ProcessInfo(name="cron server", process=cron_process))
135
+
136
+ uwsgi_process = subprocess.Popen([
137
+ "uwsgi",
138
+ "--die-on-term",
139
+ ])
140
+ add_process(ProcessInfo(name="uwsgi", process=uwsgi_process))
141
+
142
+ try:
143
+ while True:
144
+ check_process_alive(logger, processes=processes)
145
+ time.sleep(1)
146
+ except KeyboardInterrupt:
147
+ handle_shutdown(logger, processes=processes)
148
+
149
+
150
+ main()
@@ -0,0 +1,3 @@
1
+ from .retrieve_secret import retrieve_secret
2
+
3
+ __all__: list[str] = ["retrieve_secret"]
@@ -0,0 +1,93 @@
1
+ import base64
2
+ import functools
3
+ import json
4
+ import os
5
+
6
+ import boto3
7
+
8
+ from pkgs.argument_parser import CachedParser
9
+ from uncountable.types import overrides_t
10
+ from uncountable.types.job_definition_t import ProfileMetadata
11
+ from uncountable.types.secret_retrieval_t import (
12
+ SecretRetrieval,
13
+ SecretRetrievalAWS,
14
+ SecretRetrievalEnv,
15
+ )
16
+
17
+
18
+ class SecretRetrievalError(Exception):
19
+ def __init__(
20
+ self, secret_retrieval: SecretRetrieval, message: str | None = None
21
+ ) -> None:
22
+ self.secret_retrieval = secret_retrieval
23
+ self.message = message
24
+
25
+ def __str__(self) -> str:
26
+ append_message = ""
27
+ if self.message is not None:
28
+ append_message = f": {self.message}"
29
+ return f"{self.secret_retrieval.type} secret retrieval failed{append_message}"
30
+
31
+
32
+ @functools.cache
33
+ def _get_aws_secret(*, secret_name: str, region_name: str, sub_key: str | None) -> str:
34
+ client = boto3.client("secretsmanager", region_name=region_name)
35
+ response = client.get_secret_value(SecretId=secret_name)
36
+
37
+ if "SecretString" in response:
38
+ secret = response["SecretString"]
39
+ else:
40
+ secret = base64.b64decode(response["SecretBinary"])
41
+
42
+ value = json.loads(secret)
43
+
44
+ if sub_key is not None:
45
+ assert isinstance(value, dict)
46
+ return str(value[sub_key])
47
+ else:
48
+ return str(value)
49
+
50
+
51
+ @functools.cache
52
+ def _load_secret_overrides(profile_name: str) -> dict[SecretRetrieval, str]:
53
+ overrides_parser = CachedParser(overrides_t.Overrides)
54
+ profiles_module = os.environ["UNC_PROFILES_MODULE"]
55
+ try:
56
+ overrides = overrides_parser.parse_yaml_resource(
57
+ package=".".join([profiles_module, profile_name]),
58
+ resource="local_overrides.yaml",
59
+ )
60
+ return {
61
+ override.secret_retrieval: override.value for override in overrides.secrets
62
+ }
63
+ except FileNotFoundError:
64
+ return {}
65
+
66
+
67
+ def retrieve_secret(
68
+ secret_retrieval: SecretRetrieval, profile_metadata: ProfileMetadata
69
+ ) -> str:
70
+ value_from_override = _load_secret_overrides(profile_metadata.name).get(
71
+ secret_retrieval
72
+ )
73
+ if value_from_override is not None:
74
+ return value_from_override
75
+
76
+ match secret_retrieval:
77
+ case SecretRetrievalEnv():
78
+ env_name = f"UNC_{profile_metadata.name.upper()}_{secret_retrieval.env_key.upper()}"
79
+ secret = os.environ.get(env_name)
80
+ if secret is None:
81
+ raise SecretRetrievalError(
82
+ secret_retrieval, f"environment variable {env_name} missing"
83
+ )
84
+ return secret
85
+ case SecretRetrievalAWS():
86
+ try:
87
+ return _get_aws_secret(
88
+ secret_name=secret_retrieval.secret_name,
89
+ region_name=secret_retrieval.region,
90
+ sub_key=secret_retrieval.sub_key,
91
+ )
92
+ except Exception as e:
93
+ raise SecretRetrievalError(secret_retrieval) from e