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,117 @@
1
+ import signal
2
+ from dataclasses import asdict
3
+ from types import TracebackType
4
+ from typing import Optional, assert_never
5
+
6
+ from apscheduler.executors.pool import ThreadPoolExecutor
7
+ from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
8
+ from apscheduler.schedulers.background import BackgroundScheduler
9
+ from apscheduler.schedulers.base import BaseScheduler
10
+ from apscheduler.triggers.cron import CronTrigger
11
+ from opentelemetry.trace import get_current_span
12
+ from sqlalchemy.engine.base import Engine
13
+
14
+ from uncountable.integration.cron import CronJobArgs, cron_job_executor
15
+ from uncountable.integration.telemetry import Logger
16
+ from uncountable.types import base_t, job_definition_t
17
+ from uncountable.types.job_definition_t import (
18
+ CronJobDefinition,
19
+ WebhookJobDefinition,
20
+ )
21
+
22
+ _MAX_APSCHEDULER_CONCURRENT_JOBS = 1
23
+
24
+
25
+ class IntegrationServer:
26
+ _scheduler: BaseScheduler
27
+ _engine: Engine
28
+ _server_logger: Logger
29
+
30
+ def __init__(self, engine: Engine) -> None:
31
+ self._engine = engine
32
+ self._scheduler = BackgroundScheduler(
33
+ timezone="UTC",
34
+ jobstores={"default": SQLAlchemyJobStore(engine=engine)},
35
+ executors={"default": ThreadPoolExecutor(_MAX_APSCHEDULER_CONCURRENT_JOBS)},
36
+ )
37
+ self._server_logger = Logger(get_current_span())
38
+
39
+ def register_jobs(self, profiles: list[job_definition_t.ProfileMetadata]) -> None:
40
+ valid_job_ids = []
41
+ for profile_metadata in profiles:
42
+ for job_defn in profile_metadata.jobs:
43
+ valid_job_ids.append(job_defn.id)
44
+ match job_defn:
45
+ case CronJobDefinition():
46
+ # Add to ap scheduler
47
+ job_kwargs = asdict(
48
+ CronJobArgs(
49
+ definition=job_defn, profile_metadata=profile_metadata
50
+ )
51
+ )
52
+ try:
53
+ existing_job = self._scheduler.get_job(job_defn.id)
54
+ except ValueError as e:
55
+ self._server_logger.log_warning(
56
+ f"could not reconstitute job {job_defn.id}: {e}"
57
+ )
58
+ self._scheduler.remove_job(job_defn.id)
59
+ existing_job = None
60
+ if existing_job is not None:
61
+ existing_job.modify(
62
+ name=job_defn.name,
63
+ kwargs=job_kwargs,
64
+ misfire_grace_time=None,
65
+ )
66
+ existing_job.reschedule(
67
+ CronTrigger.from_crontab(job_defn.cron_spec)
68
+ )
69
+ if not job_defn.enabled:
70
+ existing_job.pause()
71
+ else:
72
+ existing_job.resume()
73
+ else:
74
+ job_opts: dict[str, base_t.JsonValue] = {}
75
+ if not job_defn.enabled:
76
+ job_opts["next_run_time"] = None
77
+ self._scheduler.add_job(
78
+ cron_job_executor,
79
+ # IMPROVE: reconsider these defaults
80
+ max_instances=1,
81
+ coalesce=True,
82
+ trigger=CronTrigger.from_crontab(job_defn.cron_spec),
83
+ name=job_defn.name,
84
+ id=job_defn.id,
85
+ kwargs=job_kwargs,
86
+ misfire_grace_time=None,
87
+ **job_opts,
88
+ )
89
+ case WebhookJobDefinition():
90
+ pass
91
+ case _:
92
+ 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)
97
+
98
+ def serve_forever(self) -> None:
99
+ signal.pause()
100
+
101
+ def _start_apscheduler(self) -> None:
102
+ self._scheduler.start()
103
+
104
+ def _stop_apscheduler(self) -> None:
105
+ self._scheduler.shutdown()
106
+
107
+ def __enter__(self) -> "IntegrationServer":
108
+ self._start_apscheduler()
109
+ return self
110
+
111
+ def __exit__(
112
+ self,
113
+ exc_type: Optional[type[BaseException]],
114
+ exc_val: Optional[BaseException],
115
+ exc_tb: Optional[TracebackType],
116
+ ) -> None:
117
+ self._stop_apscheduler()
@@ -0,0 +1,209 @@
1
+ import functools
2
+ import os
3
+ import time
4
+ import traceback
5
+ import typing
6
+ from contextlib import contextmanager
7
+ from enum import StrEnum
8
+ from typing import Generator, assert_never, cast
9
+
10
+ from opentelemetry import _logs, trace
11
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
12
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
13
+ from opentelemetry.sdk._logs import Logger as OTELLogger
14
+ from opentelemetry.sdk._logs import LoggerProvider, LogRecord
15
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogExporter
16
+ from opentelemetry.sdk.resources import Attributes, Resource
17
+ from opentelemetry.sdk.trace import TracerProvider
18
+ from opentelemetry.sdk.trace.export import (
19
+ SimpleSpanProcessor,
20
+ )
21
+ from opentelemetry.trace import DEFAULT_TRACE_OPTIONS, Span, Tracer
22
+
23
+ from uncountable.core.environment import (
24
+ get_otel_enabled,
25
+ get_server_env,
26
+ get_version,
27
+ )
28
+ from uncountable.types import base_t, job_definition_t
29
+
30
+
31
+ def _cast_attributes(attributes: dict[str, base_t.JsonValue]) -> Attributes:
32
+ return cast(Attributes, attributes)
33
+
34
+
35
+ @functools.cache
36
+ def get_otel_resource() -> Resource:
37
+ attributes: dict[str, base_t.JsonValue] = {
38
+ "service.name": "integration-server",
39
+ "sdk.version": get_version(),
40
+ }
41
+ unc_version = os.environ.get("UNC_VERSION")
42
+ if unc_version is not None:
43
+ attributes["service.version"] = unc_version
44
+ unc_env = get_server_env()
45
+ if unc_env is not None:
46
+ attributes["deployment.environment"] = unc_env
47
+ resource = Resource.create(attributes=_cast_attributes(attributes))
48
+ return resource
49
+
50
+
51
+ @functools.cache
52
+ def get_otel_tracer() -> Tracer:
53
+ provider = TracerProvider(resource=get_otel_resource())
54
+ if get_otel_enabled():
55
+ provider.add_span_processor(SimpleSpanProcessor(OTLPSpanExporter()))
56
+ trace.set_tracer_provider(provider)
57
+ return provider.get_tracer("integration.telemetry")
58
+
59
+
60
+ @functools.cache
61
+ def get_otel_logger() -> OTELLogger:
62
+ provider = LoggerProvider(resource=get_otel_resource())
63
+ provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogExporter()))
64
+ if get_otel_enabled():
65
+ provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter()))
66
+ _logs.set_logger_provider(provider)
67
+ return provider.get_logger("integration.telemetry")
68
+
69
+
70
+ class LogSeverity(StrEnum):
71
+ INFO = "Info"
72
+ WARN = "Warn"
73
+ ERROR = "Error"
74
+
75
+
76
+ class Logger:
77
+ current_span: Span
78
+
79
+ def __init__(self, base_span: Span) -> None:
80
+ self.current_span = base_span
81
+
82
+ @property
83
+ def current_span_id(self) -> int:
84
+ return self.current_span.get_span_context().span_id
85
+
86
+ @property
87
+ def current_trace_id(self) -> int | None:
88
+ return self.current_span.get_span_context().trace_id
89
+
90
+ def _patch_attributes(self, attributes: Attributes | None) -> Attributes:
91
+ return attributes or {}
92
+
93
+ def _emit_log(
94
+ self, message: str, *, severity: LogSeverity, attributes: Attributes | None
95
+ ) -> None:
96
+ otel_logger = get_otel_logger()
97
+ log_record = LogRecord(
98
+ body=message,
99
+ severity_text=severity,
100
+ timestamp=time.time_ns(),
101
+ attributes=self._patch_attributes(attributes),
102
+ span_id=self.current_span_id,
103
+ trace_id=self.current_trace_id,
104
+ trace_flags=DEFAULT_TRACE_OPTIONS,
105
+ severity_number=_logs.SeverityNumber.UNSPECIFIED,
106
+ resource=get_otel_resource(),
107
+ )
108
+ otel_logger.emit(log_record)
109
+
110
+ def log_info(self, message: str, *, attributes: Attributes | None = None) -> None:
111
+ self._emit_log(
112
+ message=message, severity=LogSeverity.INFO, attributes=attributes
113
+ )
114
+
115
+ def log_warning(
116
+ self, message: str, *, attributes: Attributes | None = None
117
+ ) -> None:
118
+ self._emit_log(
119
+ message=message, severity=LogSeverity.WARN, attributes=attributes
120
+ )
121
+
122
+ def log_error(self, message: str, *, attributes: Attributes | None = None) -> None:
123
+ self._emit_log(
124
+ message=message, severity=LogSeverity.ERROR, attributes=attributes
125
+ )
126
+
127
+ def log_exception(
128
+ self,
129
+ exception: BaseException,
130
+ *,
131
+ message: str | None = None,
132
+ attributes: Attributes | None = None,
133
+ ) -> None:
134
+ traceback_str = "".join(traceback.format_tb(exception.__traceback__))
135
+ patched_attributes = self._patch_attributes(attributes)
136
+ self.current_span.record_exception(
137
+ exception=exception, attributes=patched_attributes
138
+ )
139
+ self.log_error(
140
+ message=f"error: {message}\nexception: {exception}{traceback_str}",
141
+ attributes=patched_attributes,
142
+ )
143
+
144
+ @contextmanager
145
+ def push_scope(
146
+ self, scope_name: str, *, attributes: Attributes | None = None
147
+ ) -> Generator[typing.Self, None, None]:
148
+ with get_otel_tracer().start_as_current_span(
149
+ scope_name, attributes=self._patch_attributes(attributes)
150
+ ):
151
+ yield self
152
+
153
+
154
+ class JobLogger(Logger):
155
+ def __init__(
156
+ self,
157
+ *,
158
+ base_span: Span,
159
+ profile_metadata: job_definition_t.ProfileMetadata,
160
+ job_definition: job_definition_t.JobDefinition,
161
+ ) -> None:
162
+ self.profile_metadata = profile_metadata
163
+ self.job_definition = job_definition
164
+ super().__init__(base_span)
165
+
166
+ def _patch_attributes(self, attributes: Attributes | None) -> Attributes:
167
+ patched_attributes: dict[str, base_t.JsonValue] = {
168
+ **(attributes if attributes is not None else {})
169
+ }
170
+ patched_attributes["profile.name"] = self.profile_metadata.name
171
+ patched_attributes["profile.base_url"] = self.profile_metadata.base_url
172
+ patched_attributes["job.name"] = self.job_definition.name
173
+ patched_attributes["job.id"] = self.job_definition.id
174
+ patched_attributes["job.definition_type"] = self.job_definition.type
175
+ match self.job_definition:
176
+ case job_definition_t.CronJobDefinition():
177
+ patched_attributes["job.definition.cron_spec"] = (
178
+ self.job_definition.cron_spec
179
+ )
180
+ case job_definition_t.WebhookJobDefinition():
181
+ pass
182
+ case _:
183
+ assert_never(self.job_definition)
184
+ patched_attributes["job.definition.executor.type"] = (
185
+ self.job_definition.executor.type
186
+ )
187
+ match self.job_definition.executor:
188
+ case job_definition_t.JobExecutorScript():
189
+ patched_attributes["job.definition.executor.import_path"] = (
190
+ self.job_definition.executor.import_path
191
+ )
192
+ case job_definition_t.JobExecutorGenericUpload():
193
+ patched_attributes["job.definition.executor.data_source.type"] = (
194
+ self.job_definition.executor.data_source.type
195
+ )
196
+ case _:
197
+ assert_never(self.job_definition.executor)
198
+ return _cast_attributes(patched_attributes)
199
+
200
+
201
+ @contextmanager
202
+ def push_scope_optional(
203
+ logger: Logger | None, scope_name: str, *, attributes: Attributes | None = None
204
+ ) -> Generator[None, None, None]:
205
+ if logger is None:
206
+ yield
207
+ else:
208
+ with logger.push_scope(scope_name, attributes=attributes):
209
+ yield
@@ -0,0 +1,170 @@
1
+ import hmac
2
+ import typing
3
+ from dataclasses import dataclass
4
+
5
+ import flask
6
+ import simplejson
7
+ from flask.typing import ResponseReturnValue
8
+ from flask.wrappers import Response
9
+ from opentelemetry.trace import get_current_span
10
+ from uncountable.core.environment import (
11
+ get_local_admin_server_port,
12
+ 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
+ )
21
+ from uncountable.integration.scan_profiles import load_profiles
22
+ from uncountable.integration.secret_retrieval.retrieve_secret import retrieve_secret
23
+ 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
27
+
28
+ app = flask.Flask(__name__)
29
+
30
+
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
+ def register_route(
92
+ *,
93
+ server_logger: Logger,
94
+ profile_meta: job_definition_t.ProfileMetadata,
95
+ job: job_definition_t.WebhookJobDefinition,
96
+ ) -> None:
97
+ route = f"/{profile_meta.name}/{job.id}"
98
+
99
+ def handle_webhook() -> ResponseReturnValue:
100
+ with server_logger.push_scope(route):
101
+ try:
102
+ signature_key = retrieve_secret(
103
+ profile_metadata=profile_meta,
104
+ secret_retrieval=job.signature_key_secret,
105
+ )
106
+
107
+ passed_signature = flask.request.headers.get(
108
+ "Uncountable-Webhook-Signature"
109
+ )
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,
117
+ )
118
+
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:
134
+ server_logger.log_exception(e)
135
+ return e.make_error_response()
136
+ except Exception as e:
137
+ server_logger.log_exception(e)
138
+ return WebhookException.unknown_error().make_error_response()
139
+
140
+ app.add_url_rule(
141
+ route,
142
+ endpoint=f"handle_webhook_{job.id}",
143
+ view_func=handle_webhook,
144
+ methods=["POST"],
145
+ )
146
+
147
+ server_logger.log_info(f"job {job.id} webhook registered at: {route}")
148
+
149
+
150
+ def main() -> None:
151
+ profiles = load_profiles()
152
+ for profile_metadata in profiles:
153
+ server_logger = Logger(get_current_span())
154
+ for job in profile_metadata.jobs:
155
+ if isinstance(job, job_definition_t.WebhookJobDefinition):
156
+ register_route(
157
+ server_logger=server_logger, profile_meta=profile_metadata, job=job
158
+ )
159
+
160
+
161
+ main()
162
+
163
+
164
+ if __name__ == "__main__":
165
+ app.run(
166
+ host="0.0.0.0",
167
+ port=get_webhook_server_port(),
168
+ debug=get_server_env() == "playground",
169
+ exclude_patterns=[],
170
+ )